blog

  • DBスキーマ駆動のJavaコード生成ツールを自作した(2) 〜DB標準機能に敗れる〜

    前回、個人的な好みと、カラムデフォルト値問題からツールを作成した経緯を書きました。
    今回はもう一つのきっかけである作成日時・更新日時問題について記載します。


    更新日時・作成日時カラム問題

    これらのカラムを全テーブルに入れることは、多くのプロジェクトで行われるのではないでしょうか。
    私が初めて参画したJava プロジェクトもこの方式でした。
    そこではORマッパーを使っていたため、更新日時と作成日時をJava でセットする必要がありました。
    それは言い換えると、開発者全員がUpdate 前に更新日時をセットするコードを必ず実装することが求められていた状態です。

    残念ながらプロジェクトの途中で、実装漏れがいくつも見つかりました。
    そして、更新日時がそれほど重要な位置付けでなかったことと、工数がかかるので問題は放置されました。

    また、作成日時が更新されてしまう問題も起きていました。
    Java のUpdate 処理をInsert 処理と同じように new Entity() から書き始めた場合、作成日時をわざわざデータベースから取得してセットするのが面倒なのでサボりがちです。
    作成日時カラムに現在日付でも入れておけば処理は完了すると、実装者は考えてしまいます。
    気持ちは良く分かります。

    私はこの問題もツールで解消しようとしました。


    ツールでの解決

    前回、DBのカラム定義がnot null で、デフォルト値を持っている場合に、Java から指定された値がnull の場合、Insert やUpdate の対象から外す機能をつけたと書きました。
    しかし、これだけでは足りません。誤ってnull 以外の値をJava で入れた場合にデータが書き変わってしまうからです。
    そこで、Update 時はJava で何を書いてもUpdate されない仕組みが必要と思いました。( 正確にはRepository 層以外のJava コードの記載に影響されないという意味です)

    このように書いた時に、

    Java
    var account = new Account();
    account.setName("グリーン");
    var now = LocalDateTime.now();
    account.setCreatedAt(now);
    repository.update(account);

    下のようなcreated_at を含まないUpdate のSQL を実行したいです。

    SQL
    update account set name = 'グリーン';

    そのため、ツールでは設定でUpdate 対象外とするカラムを指定できる機能を付けました。
    ついでに、Insert 対象外とするカラムを指定する機能と、更新する値を常に now() とするカラムを指定する機能も付けました。

    例を書いてみます。

    DDL(再掲) とツールの設定は以下のとおりです。

    SQL
    create table account (
      name text not null,
      created_at timestamp not null default now(),
      updated_at timestamp not null default now()
    )

    ツールの設定

    YAML
    # param.yml
    excludeUpdateColumnsByTable:
       "*":
          - created_at
    excludeInsertColumnsByTable:
       "*":
          - created_at
          - updated_at
    setNowColumnsByTable:
       "*":
          - updated_at

    (”*” は全てのテーブルを意味します)

    Insert 時にこのように書いても、

    Java
    var account = new Account();
    account.setName("グリーン");
    var createdAt = LocalDateTime.of(2001,1,1,1,1,1);
    account.setCreatedAt(createdAt); // 無視される
    var updatedAt = LocalDateTime.of(2002,2,2,2,2,2);
    account.setUpdatedAt(updatedAt); // 無視される
    repository.insert(account);

    発行されるSQL はこちらになります。

    SQL
    insert into account (name) values ('グリーン');

    createdAt とupdatedAt はInsert 時に除外されるため、DBがカラム定義に従ってデフォルト値のnow() を入れてくれます。

    Update はこのようになります。

    Java
    var account = new Account();
    account.setName("グリーングリーン");
    var createdAt = LocalDateTime.of(2003,3,3,3,3,3);
    account.setCreatedAt(createdAt); // 無視される
    var updatedAt = LocalDateTime.of(2004,4,4,4,4,4);
    account.setUpdatedAt(updatedAt); // 無視される
    repository.update(account);
    SQL
    update account set name = 'グリーングリーン', updated_at = now();

    Insert と同様に、created_at は除外されます。また、updated_at の値はnow() で置き換えられます。
    呼び出す側は更新日時・作成日時を考えなくても、もし誤って変な値をセットしても、Repository 層以下でよろしくやってくれるところが便利な点です。(Java から更新できなくなるとも言える)

    これで解決したように思えました。
    これで解決ならツールを作った甲斐もあったものです。
    しかしですね。。。


    トリガーで全て解決

    残念ながらツールを作った後に、トリガーで全て解決することに気づきました。
    トリガーでUpdate 時に作成日時として時刻で上書きすることが可能です。
    同時に、作成日時を更新前の値に強制的に戻すこともできます。
    また、Insert 時だけ作成日時を時刻で上書きすることもできます。
    アプリケーションの実装漏れを、DBレイヤーで強制的に防げる点が大きいです。

    PostgreSQL の例を挙げます。

    トリガーの定義

    SQL
    CREATE OR REPLACE FUNCTION refresh_created_updated_columns()
    RETURNS TRIGGER AS $$
    BEGIN
        -- 更新時は常に updated_at を現在時刻にする
        NEW.updated_at = NOW();
    
        IF (TG_OP = 'INSERT') THEN
            -- 新規作成時は created_at を現在時刻にする
            NEW.created_at = NOW();
        
        ELSIF (TG_OP = 'UPDATE') THEN
            -- 更新時は OLD の値を維持して上書きを防止する
            NEW.created_at = OLD.created_at;
        END IF;
    
        RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;

    トリガーの適用

    SQL
    CREATE TRIGGER trigger_account_created_updated_columns
    BEFORE INSERT OR UPDATE ON account FOR EACH ROW
    EXECUTE FUNCTION refresh_created_updated_columns();

    これでこの問題はほぼ解決です。
    ツールを作った後に気づいたので、悲しい。本当に悲しい。

    1点だけ気になるのは、トリガーと言えば速度が問題になりがちですが、実際どうなのでしょうか。


    トリガーの速度を試す

    自分のPC にDocker でPostgreSQL を起動して、大量Update を行ってみました。

    シェルで簡易に行ったもので、DB 接続とコミットを毎回行っています。

    結果
    10,000 回Update
    トリガーなし 99秒
    トリガーあり 103秒 (+4秒)

    20,000 回Update
    トリガーなし 208秒
    トリガーあり 214秒 (+6秒)

    3%から4% の速度劣化といったところです。
    Update を2回行うよりはずっと少ない負担のようです。
    トリガー適用時にBEFORE を指定したため、データベースとしては更新対象のレコードが把握できている状態からトリガーで指定された処理を行うため、高速なようです。

    この程度であれば多くのプロジェクトで許容されるのではないでしょうか。
    ただ、ツールを使えばトリガーを入れなくても良いという分があるにはあります。

    参考 負荷投入shell

    ShellScript
    date
    for i in $(seq 1 20000)
    do
      psql -Upostgres <<! >/dev/null 2>&1
    update account set name = 'グリーングリーン', created_at='2000-01-01', updated_at='2000-02-02';
    !
    done
    date

    最後に感想

    多くのプロジェクトはトリガーで解決できるため、ツールが活きるのは以下2つの場合でしょう。

    • トリガーの負荷を許容できない
    • Java からDB アクセスをライブラリの好みが私と近い(Spring JDBC が好き)

    いずれにせよ、あまりないかもしれません。ツールはこちらに残しておきます。

    別の角度の話ですが、私はJava でJava のコードを作るというのが初めてだったのですが、やってみて自分のコードの抽象度を大きく高めることができると感じました。AOP とは違う角度で、場合によってはより強力に、抽象化ができると思いました。

    これを使えば、作成者ID をSpring Security のLoginUserDetail から取得して強制的にセットするという流れを実装することもできそうです。記事では触れませんでしたが、作成者ID・更新者IDも同じ話になります。
    思想に反するという反論があるかもしれませんが、実装漏れを必ず食い止めるという視点で、手段の一つとして持っておくと何かの役に立つかもしれないです。

    【広告】

  • DBスキーマ駆動のJavaコード生成ツールを自作した(1)

    いろいろと思うところがあり、Spring JDBC ベースのコード生成ツールを自作しました。
    なぜ今更このようなものを作ったのか、個人的な好みと、カラムデフォルト値問題、更新日時・作成日時問題(次回)から記載します。


    個人的な好み

    Java からDB アクセスを行う仕組みは歴史がある枯れた領域です。
    無料で使えるライブラリが多数あるのですが、個人的にはどれも一長一短だと感じています。
    私が使ったことのあるライブラリについて独断と偏見を列挙します。
    多分私はマイノリティなので、反論は当然あると思います。

    JOOQ

    始めは好印象だった。Java でSQL の全てを書くことができる。ORマッパーとしては最強だと今も思っている。PostgreSQL、MySQL は無償版でも使えるが、Oracle は有償版でないと使えない点は気になる。今後料金体系が変わったりしたら一大事かも。ORマッパーなのでJava のコードからSQL を推測するスキルが必要。私は自分で書いたクエリーを時間が経ってから読んだら意味が分からなかった。

    JPA

    こちらも始めは使いやすいと思っていた。しかし、一次キャッシュにより更新されない問題を踏んだ時に、二度と使わないと思えるほど嫌になった。私の頭では一次キャッシュを使いこなせない。ライブラリの容量が大きいので起動が遅くなる、Boot jar のサイズが大きくなる、といった問題もある。

    MyBatis

    XML にSQL を書くのが嫌。CDATA がたくさん出てきて読みづらくなる。if foreach OGNL を覚えるのが面倒。Java ならすんなり書けるのに使い方を調べながら書くのがストレス。実質業界標準なので業務で使うことは多い。

    Spring JDBC

    機能が薄くてシンプル。SQL をJava で書くのでMyBatis のように独自の文法を覚える必要がない。欠点は簡単なCRUD を書くだけでも、Entity とRepository を手で作成する必要がありそこそこ手間がかかる。Java の文字列結合でSQL を書くとセキュリティ上の問題が起きると信じている人がいる気がする。

    DBFlute

    SQL を書いたらEntity を作ってくれるという、他にはない思想が良い。開発速度向上につながる。Generator が吐き出すjava コードをEclipse で開くと、警告が沢山出たことを当時(10年以上前)気にしていた。後述の問題は解消できない。

    Exposed (kotlin)

    Kotlin 良さと相まって、ものすごく少ない行数でDB アクセスができる点が素晴らしい。後述の問題にも対応できる。だが、ORマッパーの苦しさはJOOQ と同じ。あと、少し前にメジャーバージョンに変わった辺りでIF が変わり書き直しが必要になったのが辛かった。

    よくある、ORマッパー派か生SQL派で言うと、私は生SQL派になります。生SQL を効率よく書きつつ、後述の問題を回避したいというのが私の願いです。

    次に、多くのライブラリでカラムのデフォルト値が使えないという問題があると思うので触れてみます。


    カラムデフォルト値問題

    こちらは業務で影響が出ることはほとんどない重箱の隅のような話です。多くのライブラリは抽象化を優先している(そうあるべき)ため、この問題を孕んでいると思います。

    例えばこのように、create_at カラムにnot null 制約と初期値としてnow() がついているとします。

    SQL
    create table account (
      name text not null,
      created_at timestamp not null default now()
      updated_at timestamp not null default now()
    )

    ORマッパーでInsert を行います。

    Java
    var account = new Account();
    account.setName("グリーン");
    account.setCreatedAt(null);
    account.setUpdatedAt(null);
    repository.insert(account);

    このコードでは not null 制約に抵触してエラーになると思います。
    発行されるSQLがこのようになるためです。

    SQL
    insert into account (name, created_at, updated_at) values ('グリーン', null, null);
    --> ERROR:  リレーション"account"の列"created_at"のNULL値が非NULL制約に違反しています

    DB でデフォルト値を持っているので、エラーにならなくてもよい気がします。
    この問題が業務に影響することはほとんどありません。
    こうするだけです。

    Java
    var now = LocalDateTime.now();
    account.setCreatedAt(now);

    一応、これに対する反論として、Java のサーバとDB サーバのマシン時刻が合っていない可能性があるというものがあります。
    ですが、NTP を使えばずれはその差はほぼありません。僅かな差が許されない要件であることは稀でしょう。

    さらに、初期値をカラムの定義に委ねるのはよろしくない、アプリで初期値を保つべきだという思想もあると思います。

    思想に反する上に業務上困ることはないので気にしなければ良いのですが、私はDB が持つデフォルト値という機能をJava から使えなくなるという点にどうしても違和感を感じます。データはシステムの中心であり、データを管理するデータベースの機能がミドルウェアにより制限されるのは違うと思います。


    ツールで解消

    私が作成したツールは、DBのカラム定義がnot null で、デフォルト値を持っている場合に、Java から指定された値がnull の場合、Insert やUpdate の対象から外すようにしました。

    そのため、先ほどのサンプルのSQL はこの形で実行されます。

    SQL
    insert into account (name) values ('グリーン');

    ツールはこちら

    このツールは、万人向けではありません。ただ、生SQL派で、DBのデフォルト値を大事にしたい人にはそれなりに刺さるかもしれません。
    次回は、このツールを作るもうひとつのきっかけになった「作成日時・更新日時問題」について書きます。

    【広告】

  • このサイトのSSL設定ミスっていました

    昨年10月頃に設定を見直したときに、中間証明書を入れ忘れていました。
    Safari は何もなく接続可能でしたが、Chrome は証明書の警告が出てしまう状態でした。
    おそらく、Chrome ユーザが離脱されたことと思います。残念です。

    今後はSSL 設定変更後に複数ブラウザで確認した方が良さそうですね。
    もしくは、SSL 解析を行ってくれるサイトを使うのも良いと思います。

    https://www.ssllabs.com/ssltest/

    中間証明書がない時は評価「B」でしたが、今は「A+」になりました。👍

  • Spring Boot + Nginx でセキュリティ対策を大雑把に実施する

    業務でセキュリティ診断を受けました。
    数回の診断を経て、最終的には「指摘なし」との高評価をいただくことができました。
    この記事では、高評価に至るまでに実施した事を残したいと思います。

    なお、お約束ではありますが、セキュリティは要件次第で対策内容が変わるものです。
    以下の内容は、大雑把にセキュリティ対応を行うためのもので、万全を保証するものではありません。


    今回の構成

    • Spring Boot (3.4.1)
    • Spring Security
    • Thymeleaf
    • バニラJavaScript (react やvue などを使わない)
    • MyBatis
    • Nginx

    Spring Boot

    application-prd.yml に以下の設定を入れます。

    YAML
    server:
      servlet:
        session:
          cookie:
            secure: true   # (1)
            name: __Host-SESSION   # (2)

    これらの設定はHTTPS 環境でないと動かないので、application-prd.yml に記載してapplication.yml (-prdなし)に記載しないと良いです。
    こうすることでローカル環境でもSpring Boot に直接アクセスできます。

    (1) Cookie中のセッションID をSecure 属性で保護します

    付けなかった場合は、HTTP で通信を行った時にもcookie が送付されるため、セッションID が漏えいしてしまい、セッションを乗っ取られる可能性があります。
    とても重要な設定です。

    (2) Cookie中のセッションIDをホストオンリーとします

    セッションIDの名前がデフォルトのJSESSIONID から変更されます。
    __Host- が先頭につくことでサブドメインからのCookie 注入を防いでくれます。
    こちらを設定した場合、Cookie にドメインを指定できなくなります。(する必要がない)


    Spring Security

    以下の設定を入れます。

    Java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.headers((headers) -> headers
            .contentSecurityPolicy(config -> config
                .policyDirectives("default-src 'self'")
            ) // (3)
        ).csrf(csrf -> csrf
            .csrfTokenRepository(new HttpSessionCsrfTokenRepository())
        ); // (4)
        return http.build();
    }

    (3) Content security policy (CSP)の適用

    CSP を適用することで、ブラウザに意図しない外部サイトのスクリプト実行を制限させます。
    クロスサイトスクリプティング対策として有用です。
    Nginx で設定することも可能ですが、開発時にSpring Boot へ直接アクセスすることがあるので、Spring Security で設定した方が良いと思います。加えて、nonce という例外を書く機能が使えるようになるメリットがありますが、私の場合は最後まで使うことはありませんでした。

    設定した場合に、開発上の注意点が2つあります。

    ひとつは、JS、CSS、画像が外部から読み込めなくなることです。
    CDN など外部から読み込みが必要な場合は、例外の設定が必要になります。
    私の場合、Google recaptcha が必要だったため、以下のような例外設定を追記しました。

    default-src 'self’;
    script-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
    frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;
    connect-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;

    もうひとつは、onclick= やstyle= などのインライン属性が使えなくなります。
    こちらはフロント側の開発に大きく影響します。

    onclick についてはイベント形式に書き直します。

    ❌ CSP で制限される書き方

    HTML
    <span onclick="alert('クリックされた!')">クリック可能な文字列</span>

    ✅ CSP 対応済みの書き方

    HTML
    <span data-action="alert">クリック可能な文字列</span>
    JavaScript
    document.querySelectorAll('[data-action="alert"]').forEach(btn => {
      btn.addEventListener('click', () => {
        alert('クリックされた!')
      })
    })

    style= についてはCSS に必要なクラスを作成して対応します。

    ❌ CSP で制限される書き方

    HTML
    <span style="display:none">非表示</span>

    ✅ CSP 対応済みの書き方

    HTML
    <span class="app-display-none">クリック可能な文字列</span>
    CSS
    .app-display-none {
    	display: none;
    }

    当初私は style が使えないことを面倒だと感じましたが、外観をCSS で全て管理できるので綺麗な書き方になると考え直しました。

    (4) CSRF 対策

    攻撃者がXSS などで任意のJavaScript を実行できる場合に、被害者のログイン済みサイトから送金操作などのデータ操作を行うことを防ぎます。
    この設定を入れると、form タグにトークンが自動的に追加されます。

    HTML
    <form action="/action" method="post"> 
      <input type="hidden" name="_csrf" value="...長いトークン...">
      ...
    </form>

    2行目をSpring Security が自動的に挿入します。

    同時に、Spring Securityがトークンの検証を行うため、意図しないページからのPOST アクセスを防ぐことができます。
    注意点として、GETリクエストには適用されないことが挙げられます。
    HTTP の原則に従って、GET でデータ操作を行わないというルールを守る必要があります。

    バニラJavaScript(フレームワークを使わないJavaScript のこと)でサーバへリクエストを投げる(fetch)場合は、このトークンを付与することで投げることができます。
    これは、攻撃者も同じことを行うことが可能なのですが、前述のCSP 対応と後述のThymeleaf によるXSS 対策で防ぐことになります。

    今回の構成から外れますが、SPA の場合は、cookie を使った対策をフレームワークで行います。
    私が経験のあるAngular について、リンクを置いておきます。


    Nginx

    以下の設定を入れます。

    # (5)
    server_tokens off;
    # (6)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    # (7)
    add_header Referrer-Policy "same-origin, origin-when-cross-origin" always;

    (5) Nginx のバージョンを表示しない

    デフォルトではエラー画面やレスポンスヘッダーにNginx のバージョンが記載されます。
    万一、使っているNginx のバージョンに脆弱性が見つかった場合、バージョン番号が攻撃者に有用な情報となってしまいます。

    (6) Strict-Transport-Security ヘッダを設定

    HTTPS を使っており、HTTP を使わないことをブラウザに覚えさせる設定です。
    一度このヘッダーを受信したブラウザは、タイムアウトするまで(2年間)常にHTTPS でアクセスします。

    (7) Referrer にパスを入れない

    パスに大切な情報が含まれると情報漏洩につながる恐れがあるので、念の為外部サイトにパスを送付しない設定を入れます。

    origin-when-cross-origin を指定すると、同一オリジンにはReferrer を送るが、クロスオリジンにパスを送付しなくなります。この機能は一部のブラウザで未対応らしです。
    same-origin を指定すると、外部サイトにReferrer を送らなくなります。origin-when-cross-origin 未対応のブラウザはこちらが有効になります。


    Thymeleaf

    クロスサイトスクリプティング(XSS)対策として、文字列を代入するときに、th:text の使用を徹底します。

    ✅ 安全な書き方

    HTML
    <span th:text="${name}">ユーザ名</span>

    th:utext を使うとHTML を代入することができますが、問題を起こす可能性が上がるので極力使いません。
    要件次第ではありますが、私が携わったプロジェクトでは一切使わずに済みました。

    ❌ 問題の可能性が上がる書き方

    HTML
    <span th:utext="${html}">動的HTML</span>

    その他のフレームワークを使っている場合でも、似たような機能があるはずです。


    MyBatis

    SQL インジェクション対策として、基本的には$ を使わず# を使うように徹底します。
    しかし、order by だけは # で直接指定することができません。
    代わりに<choose>を使います。

    ✅ 安全な書き方

    SQL
    SELECT
      id,
      name,
      email,
      created_at
    FROM users
    <choose>
      <when test="orderBy == 'name'">
        ORDER BY name
      </when>
      <when test="orderBy == 'createdAt'">
        ORDER BY created_at
      </when>
      <otherwise>
        ORDER BY id
      </otherwise>
    </choose>

    くれぐれもユーザが指定する値を直接$ で渡すことは避けてください。

    ❌ 問題の可能性が上がる書き方

    SQL
    SELECT ... FROM users ORDER BY ${orderBy}

    その他

    IPA の「安全なウェブサイトの作り方」を見ると、上で触れていない問題があります。

    触れてはいませんが、構成上問題になりにくいものと、私の場合要件にないため問題にならないものがありましたので、分類しておきます。

    上で触れたもの
    1 SQLインジェクション
    4 セッション管理の不備
    5 クロスサイト・スクリプティング
    6 CSRF(クロスサイト・リクエスト・フォージェリ)

    構成上問題になりにくいもの
    9 クリックジャッキング (Spring Security 使用のため)
    10 バッファオーバーフロー (Java 使用のため)

    要件にないので問題にならなかったもの
    2 OSコマンド・インジェクション
    3 パス名パラメータの未チェック/ディレクトリ・トラバーサル
    7 HTTPヘッダ・インジェクション
    8 メールヘッダ・インジェクション

    こちらは今回の記事の対象外とさせてください。
    11 アクセス制御や認可制御の欠落


    感想

    脆弱性診断で高評価を得られたことで、今回の記事を書く動機が湧きました。

    勝因は、自前でセキュリティ対策のコードを書かずに、Spring、Nginx、Mybatis といった巨人に委ねた点だと考えています。

    実装にあたって特に面倒だと感じたのは CSP、Thymeleaf、Mybatis のところでした。ですが、一度正しく設定して、雛形をチーム内で共有するところまでたどり着いた後は、スムーズに開発できたと思っています。チーム内で不満の声は上がらなかったです。

    くどいですが、これらの対策を行えば万全というものではなく、何もしないよりこれらをやっておいた方が無難というものになります。
    抜け道を作ることや設定を緩めることは可能ですが、その際は要件と設計とセキュリティをよく検討の上実施ください。

    【広告】

  • 大量更新SQL の性能改善

    業務で大量更新処理の速度改善を行ったので、大切な点を残しておきます。
    プロジェクトを離れた前任者の一括処理が、20時間以上経っても終わらないという状況で相談を受けました。
    結果としては複雑なチューニングよりも、業務要件の洗い出しと、基本的なSQLの組み方が最も効果的でした。

    状況調査

    まず、処理内容とSQL に登場するテーブル件数を確認しました。
    一括処理は、1億レコードのテーブルを、その他のテーブルから集めた情報で更新するというものでした。
    1億件ものレコードを全件洗い替えするため、20時間要していました。

    SQL のイメージはこちらです。

    SQL
    -- 1億レコードを全件更新する処理(改善前)
    UPDATE heavy_table t
    INNER JOIN (
        SELECT ...
    ) info ON ...
    SET t.column = info.value;

    対象レコードを業務要件から絞り込み、大幅高速化

    業務要件を確認したところ、全件の洗い替えではなく、その日に更新が入ったデータだけを洗い替えれば十分だと分かりました。
    そのため、本当に更新が必要な対象は、1億ではなく数千件程度でした。
    SQL のwhere 句で更新対象を絞り込むことで、約10分の1まで短縮されました。
    この時点で、20時間かかっていた処理が2時間まで短縮されました。

    SQL
    -- 1億レコードのうち、本日更新分だけに絞り込む(約10分の1に短縮)
    UPDATE heavy_table t
    INNER JOIN (
        SELECT ...
    ) i ON ...
    SET t.column = i.value
    WHERE t.id IN (
        SELECT id
        FROM other_table ...
    );

    update 文でサブクエリを使わない

    その日に更新が入ったデータの抽出をサブクエリで行っていたのですが、update 文とサブクエリを同時に使うよりも、サブクエリ結果を一時テーブルに入れてからupdate を行った方が速いことが分かりました。
    私にはデータベース内部の挙動を正確に説明できないのですが、update 文にサブクエリを含めるとデータベースに適切な最適化を難しくさせたり、不必要なロックが発生するようです。

    こちらの対策を行うことで、処理時間が30分になりました。

    SQL
    -- 本日分のIDを事前に一時テーブルへ投入(サブクエリの事前計算)
    INSERT INTO tmp_id
    SELECT id
    FROM other_table ...;
    
    -- 一時テーブルと結合して高速に更新(最終的に30分まで短縮)
    UPDATE heavy_table t
    INNER JOIN (
        SELECT ...
    ) i ON ...
    INNER JOIN tmp_id tmp ON t.id = tmp.id
    SET t.column = i.value;

    まとめ

    以前、SQLチューニングについて、先輩から教わったことを思い出しました。
    「業務影響がある対策が一番効果があり、業務影響がない対策では大きな改善は難しい。」
    今回は、はじめに業務要件を確認したのが良かったと思います。
    チームでは並行して、ヒント句を入れたりクラウドなのでRDB のスケールアップを試みたりしましたが、おそらくそれらだけでは、1億件の更新を30分で終わらせることはできなかったと思います。
    改めて先輩の知見に感謝です。

    【広告】

  • SwiftUI DatePicker でできないこと

    DatePicker は使いやすく、日付と時間の両方をピッカーで選択できるようになり、とても便利です。しかしながら、できそうでできないことがいくつかあり、初心者である私は採用を見送りました。2025年12月時点の話なのと、私が個人で調査した範囲の話である点を前提としていただければと思います。

    基本的な使い方

    Swift
    @State private var date = Date()
    
    DatePicker(
        "日時",
        selection: $date,
        // 日付と時間のピッカーを表示する。ここに問題がある
        displayedComponents: [.date, .hourAndMinute]
    )
    .datePickerStyle(.compact)
    .environment(.locale, Locale(identifier: "ja_JP"))

    このように書くだけで、日付と時間が横並びで表示されそれぞれをピッカーで選択できます。

    テキストの背景に素敵な灰色の楕円を描画してくれます。
    カスタマイズしないのであれば、素晴らしいと思います。

    1. 左寄せにすると謎の隙間ができる

    こちらを左寄せにしようとすると、途端に難しくなりました。

    Swift
    HStack(spacing: 0) {
        Image(systemName: "(toDay(date)).calendar")
            .foregroundColor(.green)
            .font(.system(size: 24))
        DatePicker(
            "",
            selection: $date,
            displayedComponents: [.date, .hourAndMinute]
        )
        .datePickerStyle(.compact)
        .environment(
            .locale,
            Locale(identifier: "ja_JP")
        )
        .labelsHidden()
    }

    .labelsHidden() を入れたので、本来ラベルが表示される領域がなくなるはずですが、なぜか領域が残っています。(赤線部分)
    背景色を付けてもう少し分かりやすくします。

    Swift
    HStack(spacing: 0) {
        Image(systemName: "(toDay(date)).calendar")
            .foregroundColor(.green)
            .font(.system(size: 24))
            .background(.blue)  // 追加
        DatePicker(
            "",
            selection: $date,
            displayedComponents: [.date, .hourAndMinute]
        )
        .datePickerStyle(.compact)
        .environment(
            .locale,
            Locale(identifier: "ja_JP")
        )
        .labelsHidden()
        .background(.orange)  // 追加
    }

    コードにはないスペースが存在していることが分かります。DatePicker の内部でスペースを描画しているようです。
    不思議なことに、何度か再起動するとスペースが入らないこともありました。確実に再現しない点も悩ましいです。

    2. タップ可能領域を調整できない

    DatePicker 自体を隠して、別の表示領域を作り、そちらのタップイベントでDatePicker を呼び出す方式を考えました。Javascript ではよくあるやり方と思います。
    しかし、DatePicker にPicker だけ表示するインターフェースが存在しないため、この方式実現できないようです。

    3. macos は別の実装となっている

    DatePicker に限りませんが、SwiftUI は全体的にmacos の見た目はios と同じレベルまで洗練されていはいない印象があります。
    DatePicker については、.wheel がコンパイルレベルで選択できなくて、.compact は.graphical と同じ挙動となります。.graphical はios とは見た目が大きく異なります。
    こちらも残念です。

    参考 macos の見た目

    まとめ

    長い時間悩みましたが、私には解決できないため、独自に実装することとしました。
    こちらのコードがとても綺麗で参考にさせてもらいました。
    https://github.com/SimformSolutionsPvtLtd/SSDateTimePicker

    また、iPhone のGoogle カレンダーアプリも、カレンダー部分は独自実装と思われます。せっかく見た目が綺麗なDatePicker なので、カスタマイズができれば少し活躍できそうな気がします

    参考 iPhone 版google カレンダーアプリ

    アップルさんの今後に期待しています。

    【広告】

  • SwiftUIでスクロールに応じてメニューを隠す処理を実装してみた(2)

    以前、スクロール操作に応じて上下のメニューを自動的に隠す機能を作成しました。
    その時点では問題なく動いていましたが、後から子View 側でもメニューと連動するアニメーション(新規投稿ボタンの上下移動)を追加したところ、画面がカクつくようになりました。

    以下のGIF が改善後の動様子です。

    メニューが下がると新規投稿ボタンも下がっています。こちらが今回修正したかったポイントになります。

    前提として、

    • メニューは親View に配置
    • 新規投稿ボタンは子View に配置
    • アニメーションは親View で制御していた

    という構造になっています。


    ダメだったやり方

    最初は、アニメーションで変化するメニュー表示位置(offset) をそのまま子View に渡せば、同時に動くだろうと考えました。
    しかし、結果は冒頭の通り画面がカクついてしまいました。

    原因は、連続的に変化する表示位置を子View に渡すと子View のbody がその都度再作成されるため、更新が遅くなってしまうというものです。


    改善案

    そこで、親子間で共有する値を連続的に変化する表示位置ではなく、メニューを表示するかどうかだけを示すBoolean フラグに変更しました。
    それを、ViewModel に持たせて親子間で共有し、親子それぞれがフラグの変化をトリガーとして独自のアニメーションを行うようにしました。
    こうすることで、アニメーション中に子View のbody が再作成されないため、カクツキがなくなりました。

    厳密には、親と子のアニメーションが完全に同じタイミングで動いているわけではありませんが、体感では違和感のないレベルに収まっています。

    ソースはこちらです。

    【広告】

  • Keycloakカスタマイズ アクセストークンにデバイスID(claim)を追加したい(2) Authenticator編

    前回はカスタムMapper の作成と登録について記載しました。
    今回はカスタムAuthenticator についてです。
    認可URL に追加されたパラメータ device_id=xxx の値を読み取り、クライアントセッションに格納する処理になります。

    Authenticator の作成

    前回作成したJava プロジェクトにクラスDeviceIdAuthenticatorFactory とDeviceIdAuthenticator を追加します。
    DeviceIdAuthenticatorFactory は特筆すべきことはありません。こちらのサンプル(再掲)を参考にしてください。
    DeviceIdAuthenticator はインターフェースAuthenticator を実装します。
    重要なのはこの部分です。

    Java
    @Override
    public void authenticate(AuthenticationFlowContext context) {
        // パラメータからdevice_id を取得
        String deviceId = context.getHttpRequest().getUri().getQueryParameters().getFirst("device_id");
        if (deviceId != null && !deviceId.isBlank()) {
            // クライアントセッション(デバイス毎のセッション)にdevice_id を保存
            logger.debugf("Device ID captured in authenticator: %s", deviceId);
            context.getAuthenticationSession().setClientNote("device_id", deviceId);
            context.success();
        } else {
            // 取得できなければエラーとして認証処理を中断させる
            logger.warn("Missing device_id parameter in authentication request");
            context.failure(AuthenticationFlowError.INVALID_CREDENTIALS);
        }
    }

    また、Mapper と同じように、作成したFactory クラス完全名を META-INF/services/org.keycloak.authentication.AuthenticatorFactory に記載します。

    com.example.sample.keycloak.DeviceIdAuthenticatorFactory

    jar の作成やKeycloak へのインストール手順は前回と同じです。

    Keycloak のAuthentication flow へ登録

    Keycloak ではログイン処理がAuthentication flow と呼ばれるステップで構成されています。一から作成するのは大変なので、browser の認証フローをコピーしたものをカスタマイズしてきます。

    サイドメニューのAuthentication からbrowser の右側のメニューよりDuplicate を選びます。

    Name をbrowser with device-id とします。

    これで認証フローのコピーが作成されました。

    上の画像は最上位のトグルを閉じた状態になっています。

    Authentication flow の簡単な説明

    Requirement としてRequired(必須)、Alternative(いずれか1つ必須)、Disabled(無効)の3つがあります。
    現在の状態は、Cookie、Identity Provider Redirector、Organization、forms が同じレベル(最上位)に並んでおり、いずれか1つ成功すれば次に進むという意味になります。
    Kerberos はDisabled なので無効です。
    Cookie は一度ログインされたセッションを持っていた場合、ログイン画面を出さずに認証が通ったものとして扱うものです。
    Identity Provider Redirector はGoogle sign in 等の外部OIDC 連携を行っている場合にリダイレクトを行うものです。今回は使っていないのでDisable にしても良いです。
    Organization は組織ごとにログイン画面を変更する場合に使うそうです。こちらも今回は使っていないのでDisable にしても良いです。
    forms はログイン画面を表示します。同じレベルでAlternative として登録されているCookie が満たされなかった場合に、ログイン画面が表示されます。今回はここのサブフローとして作成したカスタムAuthenticator を追加します。

    Authentication flow へ登録つづき

    画面上部のAdd execution をクリックします。

    device で検索を行い、Device ID Capture Authenticator にチェックを入れてAdd をクリックします。

    追加直後の画面です。閉じていたトグルが全て開いた状態になってしまいます。(少し焦ります)

    再度トグルを閉じると、一番下に追加されていることが分かります。

    このままでは、最上位にRequiredとして配置されいるため、先ほど紹介したCookie が使えない問題が起きます。(1敗)
    もう少し細かくお話しすると、この状態ではセッションが確立されていたとしてもdevice_id をパラメータとして毎回送付しなければエラーになってしまいます。Cookie がAlternative でDevice ID がRequired として同列で並ぶため、Cookie が成功してもDevice ID が必須となるためです。

    左側の点々をドラッグできるので、なんとかしてforms 配下の一番上にDevice ID を配置します。
    ドラッグドロップが多少不安定(バグ?)なので何度か試行錯誤が必要です。ctrl キーとマイナスを何度か押して、画面を大きくすると動かしやすいかもしれません。
    最終的に下の画像の構成にします。

    最後にAuthentication 一覧からbrowser with device-id をBrowser flow としてBind します。

    Authenticator 一覧画面を見ると、used by にチェックが入っています。

    以上でKeycloak の設定は完了です。

    取得されたアクセストークンを解析

    前回と同じように、認証完了後に取得できるトークンを解析します。

    device_id としてswift から送付した文字列deivce-ios が追加されていることが分かります。

    感想

    私はAuthentication flow のRequired とAlternative の意味を分からずに、最上位にRequired として登録していたため、いつまでもCookie が通らずに苦戦しました。
    ちゃんと理解しないとダメですね。。。(笑)

    【広告】

  • Keycloakカスタマイズ アクセストークンにデバイスID(claim)を追加したい(1) Mapper編

    ユーザが利用するデバイスを厳密に管理したいのでアクセストークンにデバイスID を含めるやり方を探していました。調べていくと、Keycloak にMapper とAutenticator を登録することで実現可能と分かりました。
    私は当初、管理画面の設定を少し変更すれば実現できるかと思いましたが実際はもう少し複雑で、Java のコードを書いてKeycloak に登録する必要がありました。
    この記事ではJava の作成手順とKeycloak への登録手順を残します。使用したKeycloak はバージョン26.4.2 です。

    認証処理の流れ

    • クライアントが認可URL に追加パラメータ device_id=xxx を送付
    • Keycloak に登録されたカスタムAuthenticator が、パラメータからdevice_id 取得しクライアントセッションに格納
    • ユーザがログインを行う
    • Keycloak に登録されたカスタムのMapper が、クライアントセッションのdevice_id をトークンに追加

    認証処理の順番とは逆になりますが、今回は実装が容易なMapper 登録について記載します。
    内容はこちらの記事を参考にさせていただきました。

    Javaプロジェクトの作成

    必要な依存関係を build.gradle.kts の形式で記載します。

    val keycloakVersion = "26.4.2" // 2025/11時点最新
    dependencies {
        compileOnly("org.keycloak:keycloak-server-spi:${keycloakVersion}")
        compileOnly("org.keycloak:keycloak-server-spi-private:${keycloakVersion}")
        compileOnly("org.keycloak:keycloak-services:${keycloakVersion}")
        compileOnly("com.google.auto.service:auto-service:$autoServiceVersion")
        annotationProcessor("com.google.auto.service:auto-service:$autoServiceVersion")
    }

    こちらにgradle のサンプルソースをおいたのでよかったら参考にしてください。

    Mapper の作成

    Mapper クラスを作成して、AbstractOIDCProtocolMapper 等を継承します。

    重要なのはこの部分です。

    Java
    /**
     * アクセストークン作成時に呼び出される
     */
    @Override
    protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession, ClientSessionContext clientSessionCtx) {
        // クライアントセッション(デバイス毎のセッション)からdevice_id を取得
        String deviceId = clientSessionCtx.getClientSession().getNote("device_id");
        // TODO 動作確認のため固定値を設定。本番稼働時は削除する
        if (deviceId == null) {
            deviceId = "sample-device-id";
        }
        logger.infof("device_id: %s", deviceId);
        OIDCAttributeMapperHelper.mapClaim(token, mappingModel, deviceId);
    }

    本来であればカスタマイズされたAuthenticator がクライアントセッションにdevice_id を格納するはずですが、今時点では実装していません。
    取得できない場合は、固定値sample-device-id をセットすることで動作が確認できるようにしておきます、
    device_id をmapClaim() に引き渡すことで、アクセストークンに追加されます。

    また、作成したクラス完全名を META-INF/services/org.keycloak.protocol.ProtocolMapper に記載します。
    サンプルではこのように記載しています。

    com.example.sample.keycloak.DeviceIdTokenMapper

    jar の作成

    gradle のコマンドでjar を作成します。

    ShellScript
    gradlew clean jar

    私はintelliJ が好きなので、こちらをダブルクリックして作成しています。(成果物は同じ)

    こちらにjar ができあがります。
    build/libs/sample_keycloak_mapper-0.0.1-SNAPSHOT.jar

    Keycloak へインストール

    作成されたjar をkeycloak の/providers フォルダにコピーします。
    コピー後にkeycloak を再起動します。
    うまくいけばKeycloak 起動時のコンソールログにjar を読み込んだ旨のメッセージが出るはずです。

    keycloak-1 | 2025-11-03 13:10:15,266 WARN org.keycloak.services KC-SERVICES0047: 
    device-id-token-mapper (com.example.sample.keycloak.DeviceIdTokenMapper) 
    is implementing the internal SPI protocol-mapper. This SPI is internal and may change without notice

    ※補足
    PROVIDER_ID を変更せずにパッケージを変更すると認識されない場合がありました。
    その場合は起動時にオプションを追加すると解消されました。

    ShellScript
    kc.sh start --spi-providers-reset=true

    Keycloak のClient Scope へ登録

    Keycloak の対象realm の管理画面にログインします。

    Client scopes > Create client scope

    Name をdevice-id-scope とします。他はデフォルトです。

    Mappers > Configure a new mapper

    Name とToken Claim Name にdevice_id と入力します。他はデフォルトです。

    作成されたClient Scope を対象のクライアントに割り当てます。
    Clients > 対象のクライアント(画像略) > Client Scopes > Add client scope

    device-id-scope にチェックを入れて、Add > Default を選択します。

    以上でKeycloak の設定は完了です。

    クライアントからdevice_id を送付

    認可URL にパラメータ device_id を追加して送付します。
    このようなURL になるはずです。
    7行目の追加されたdevice_id が重要な点です。

    GET https://<KEYCLOAK_HOST>/realms/<REALM_NAME>/protocol/openid-connect/auth?
     client_id=<CLIENT_ID>
     &redirect_uri=<REDIRECT_URI>
     &response_type=code
     &scope=openid%20profile
     &state=<RANDOM_STRING>
     &device_id=<DEVICE_ID>

    私はswift のAppAuth for iOS を使ってclient で作成しましたので、参考までにコードを記載しておきます。
    10行目のparams にdevice_id が追加されている点が重要です。

    Swift
    OIDAuthorizationService.discoverConfiguration(
        forIssuer: Constants.issuer
    ) { configuration, error in
        guard let configuration = configuration else {
            // TODO error handling
            return
        }
        // device-id をセット
        // TODO 実際にはUUID などを動的にセットする
        let device_id = "device-ios"
        let params = ["prompt": "login",
                 "device_id": device_id
                 ]
        let request = OIDAuthorizationRequest(
            configuration: configuration,
            clientId: Constants.keycloakClientId,
            scopes: [OIDScopeOpenID, OIDScopeProfile],
            redirectURL: Constants.redirectUrl,
            responseType: OIDResponseTypeCode,
            additionalParameters: params
        )
        guard
            let rootViewController = UIApplication.shared
                .connectedScenes.compactMap({
                    ($0 as? UIWindowScene)?.windows.first?.rootViewController
                }).first
        else {
            // TODO error handling
            return
        }
        self.currentAuthorizationFlow = OIDAuthState.authState(
            byPresenting: request,
            presenting: rootViewController
        ) { authState, error in
            Task {
                await self.handleAuthResponse(authState, error: error)
            }
        }
    }

    取得されたアクセストークンを解析

    認証完了後に取得できるトークンをhttps://jwt.io 等で解析すると、device_id が追加されていることが分かります。

    次回

    次回はAuthenticator の作成と設定について記載します。

    【広告】

  • 開発中のmacOS アプリでKeychain への書き込みが-24299 エラーとなる

    急に発生し、調べても調べても解決できずに困ってしまった。
    AI の回答は、App Sandbox やentitlements に問題があるというものだったが、いずれも該当せずでした
    結論をいうと、アプリのBundle Identifier が変更されたため、別のアプリと扱われたことが原因だった。

    再発手順

    • 古いアプリでKeychain に書き込み
    • アプリを更新(Bundle Identifier を変更)
    • 新しいアプリで1. と同じキーの値を更新しようとすると –> エラー発生

    確認手順

    • Mac のKeychain を起動する
    • デフォルトキーチェーン: ログイン > パスワードを選択
    • 変更日で並び替えると探しやすい
    • アプリで使用しているキーを一覧から探してクリックする
    • アクセス制御タブを表示する

    こちらのアプリが同一でないと、Keychain の値を更新・削除できません。

    対応案

    Mac の「キーチェーンアクセス」アプリから対象のキーを削除すると、アプリから再度書き込みできます。キー名称を右クリックすると削除できます。

    開発中にBuneld Identifier を変更する際はご注意ください。(あまりないかもですが)

    【広告】