blog

  • 端末間のデータ同期にwebsocket とポーリングのどちらを使うべきか

    端末間でデータを同期させたいとき、選択肢として「WebSocket」と「ポーリング」があります。リアルタイム性をどこまで求めるのかによりますが、私の場合リアルタイム性を少し捨ててポーリングを選びました。検討中に気になったポイントをまとめておきます。

    WebSocket

    WebSocket はサーバとクライアントの双方向通信が可能な仕組みです。ただし、通知が相手に確実に届いたかどうかはWebSocket の仕組みでは保証されないため、必要であればアプリ側受信確認やリトライの実装が必要になります。
    また、スマホの場合はトンネルなどで通信断となった場合に、スマホへリアルタイム通知が届かない可能性があります。

    ポーリング

    30秒に一度など定期的にサーバへ更新が入っていないか問い合わせを行う方式です。WebSocket に比べてリアルタイム性は落ちてしまいます。
    スマホから接続するため、通信断についてはスマホからリトライを行うだけでよく、運用が楽になります。
    ただし、WebSocket に比べて通信量が多くなります。ポーリングは毎回新たなHTTP リクエストを発行するため、アクセストークンやヘッダーが送信されます。1回のポーリングで数百バイト〜数KB程度の通信が発生し、これらがネットワークやサーバーの負荷となります。

    結論

    websocket を導入したとしても、スマホの通信断から復旧時にポーリングを行う必要があると考えました。
    それであれば、多少のリアルタイム性を捨ててポーリングに一本化することで、開発と運用のコストをさげられるポーリングの方が現実的と思いました。

    【広告】

  • Spring Boot + Spring Security + Keycloak でJWT の公開鍵を更新してみる

    私は、JWT 署名に使われる公開鍵を変更した場合、リソースサーバで新しい公開鍵を取得する作業が必要だと考えていました。
    しかし、結論から言うとこれは間違いでした。Spring Boot + Spring Security を使用している場合は何もせずに自動対応されます。

    本当にそうなのか、少し不安に思い検証してみました。

    Keycloak で公開鍵の追加

    Keycloak に公開鍵を追加して試してみます。

    Realm settings > Keys
    ここで鍵の一覧を確認できます。JWT の署名で必要なのはRS256 になります。既存の鍵が1つ存在します。

    ※画像は Keycloak バージョン 26.1.4 のものです。以下同様です。

    公開鍵を追加する場合、Add providers > Add provider をクリックします。

    鍵の種類を選ぶ画面が出ますので、rsa-generated を選択します。

    既存の鍵と区別するためにName をrsa-generated-test とします。

    一覧画面で追加されていることが確認できます。

    この状態で、JWKS エンドポイントをブラウザで表示してみます。

    https://<KEYCLOAK_HOST>/realms/<REALM_NAME>/protocol/openid-connect/certs

    表示されたJSON をchrome console で解析させると、kid が8wY… から始まる鍵が公開されていることが分かります。

    古い鍵のprovider のEnabled をoff に変更すると、JWKS エンドポイントからも削除されます。

    Spring Boot で検証

    以下の順序で検証してみました。

    • Spring boot を起動
    • Keycloak に新しい公開鍵の追加して、古い鍵を無効化する
    • 新しい鍵で著名されたJWT をSpring boot に送信

    結果、Spring boot を再起動することなく、新しい鍵によるJWT 検証が行われました。

    内部の挙動(推測)

    Spring boot はJWK を一定時間キャッシュします。
    JWT ヘッダーに含まれるkid に対応する公開鍵がキャッシュに存在しない場合、JWKS エンドポイントを呼び出して新たな鍵を取得した上で再度検証を行なっていると考えられます。

    便利ですね。

    【広告】

  • バックグラウンド処理から@Published 付き変数を更新する(swift)

    このエラーに悩んだ。

    Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

    XCode を探すと紫色の警告が出ていた。(探さないと分からなかった)


    どうやら、バックグラウンドの処理で、@Published がついた変数を変更するとこのエラーが出るようだ。

    私が開発中のアプリは、アプリ起動直後にローディング画面を表示し、そのバックグラウンドで初期化処理を行なっている。
    バックグラウンドから@Published 付き変数を無邪気に更新すると、上のエラーが出る。

    バックグラウンドスレッドの作り方

    そもそもだが、メインスレッドを使わず、バックグラウンドスレッドを作るには下のように書く。

    Task を使う例

    Task {
      await initMethodA()
    }


    async let :() を使う例

    async let initB: () = try initMethodB()
    async let initC: () = try initMethodC()
    _ = try await (initB, initC)

    ※initMethodB とinitMethodC が並列実行される

    そして、バックグラウンドスレッドから以下のように変数を更新すると上のエラーになる。

    func initMethodA() async throws {
      someVM.publishedValue = "newValue"
    }

    バックグラウンドスレッドからMainActor で実行させる書き方

    私の調査では2つのやり方が見つかった。

    MainActor.run を使うやり方

    func initMethodA() async throws {
      await MainActor.run {
        someVM.publishedValue = "newValue"
      }
    }

    MainActor.run の内側はメインスレッドで実行される。
    メインスレッドで変数を更新すればエラーは起きない。

    気をつけなければならないのは、await を使うことができない点だ。
    使いたい場合は、先にawait が必要な処理を行っておくとよい。

    func initMethodA() async throws {
      // 先に非同期処理を実行
      let newValue = await someAsyncMethod()
      await MainActor.run {
        // メインスレッドは変数の代入のみ
        someVM.publishedValue = newValue
      }
    }

    Task { @MainActor in を使うやり方

    Task { @MainActor in
      someVM.publishedValue = await someAsyncMethod()
    }

    もう一つがこちら。await が使えるので使いやすい。コードも簡潔で見通しも良い。

    しかし、Task がメインスレッドで実行されて、someAsyncMethod はメインか別スレッドのどちらかで実行されて、publishedValue への代入はメインスレッドで実行される。
    MainActor.run に比べて、メインスレッドの出番が多い。

    私が思う解決策

    @Published にprivate(set) を追加することで、外部から変更不可にできる。代わりに、ViewModel にsetter を作成してsetter の中でMainActor.run を使うことで、呼び出し元がメインスレッドかどうかを考えずに済む。

    class SomeViewModel: ObservableObject {
      @Published private(set) var publishedValue = ""
      func setValue(_ newValue: String) async {
        await MainActor.run {
          publishedValue = newValue
        }
      }
    }

    残念だがこれは完璧ではない。画面から更新する変数の場合、private(set) を付けられないことがある。例えばトグルの場合だ。

    Toggle("有効にする",isOn: $someVM.publishedValue)

    ※publishedValue はView から更新する必要がある


    このようなケースは仕方ないので、個別に対応するしかない。(と思う)

    【広告】

  • クラウドストレージの料金調査(3)

    前回は、自動コスト最適化機能についてご紹介しました。
    今回はそのおまけとして、料金算出をツール化してみました。
    よろしければ、皆さまの前提条件に合わせて料金シミュレーションをお試しください。

    なお、より正確な見積もりが必要な場合は、各社が提供している公式の見積もりツールをご利用いただくことをおすすめします。


    前提

    保存サイズ(GB)
    取出しサイズ(GB)
    為替レート円/1ドル

    見積り

    製品 料金($/GB) 月額
    (円)
    保存 取出 転送
    S3 Standard 0.139
    S3 I. A. 0.1378
    S3 G. Instant R. 0.149
    S3 G. Flexible R. 0.1295
    S3 G. Deep A. 0.121
    S3 Express O. Z. 0.23856
    GCP Standard 0.14
    GCP Nearline 0.146
    GCP Coldline 0.146
    GCP Archive 0.1725
    R2 Standard 0.015
    R2 I. A. 0.01

    ※テキストボックスの数値を変更すると自動的に月額が再計算されます

    ※料金の初期値は2025年9月時点のものです

    ※略語説明

    • I. A.: Infrequent Access
    • G.: Glacier
    • R.: Retrieve
    • A.: Archive
    • O. Z.: One Zone
    • R2: Cloudflare R2

    【広告】

  • クラウドストレージの料金調査(2)

    前回は、料金と制約の調査結果について共有しました。
    今回は、AWS と GCP が提供する 自動コスト最適化機能 についてご紹介します。


    自動コスト計算とは?

    長期間アクセスされないファイルを、自動的に低コストなストレージ階層へ移動してくれる仕組みです。
    各サービスでの名称は以下の通りです。

    • AWS S3 Intelligent Tiering
    • GCP Cloud Storage Autoclass

    AWS の場合

    • 初期状態では Standard クラスに保存されます。
    • 30日 経過すると、自動的に Infrequent Access へ移行。
    • さらに 60日 経過すると、Glacier Instant Retrieve に移行します。
    • アクセスが発生すると、自動的に Standard に戻ります。
    • 128KB以下のファイル は対象外となり、Standardから移行しません。

    オプションを有効にすると、さらに低コストな下記のストレージクラスにも自動移行できます。

    • Glacier Flexible Retrieve
    • Glacier Deep Archive

    ※これらに移行した場合、即時のファイル取得はできなくなります。


    GCP の場合

    • 初期状態は Standard です。
    • 30日 経過で Nearline に移行。
    • さらに 60日 経過で Coldline に移行。
    • さらに 275日 経過で Archive に移行します。
    • アクセスが発生した場合、AWSと同様に Standard に戻ります。

    課金体系の違い

    通常のストレージと異なる課金体系があります。

    • 取出し料金が無料
    • ファイル管理料金が発生
      1,000ファイルあたり $0.0025/月(※2025年9月時点、AWS/GCP共通)

    見積もり例

    前回の前提条件に自動コスト計算を適用してみます。

    前提条件

    • 年に1回すべてのファイルを取出す
    • ファイル数1,000個

    1年間で各階層でのファイル滞在期間は次になります。

    • Standard 1ヶ月(1/12)
    • NearLine 2ヶ月(2/12)
    • ColdLine 9ヶ月(9/12)
    • Archive 0ヶ月

    ※GCP のストレージ階層名で表しましたがAWS でも似たような状態になります。
    ※GCPでは約1年後にArchiveへ移行する仕様ですが、今回の前提では1年未満で全ファイルを取り出すため、Archive への移行は発生しません。


    感想

    二番目のと三番目のストレージ階層の中間程度のコストになりました。
    取出し頻度と料金の関係が予測しづらいと思いました。

    例)

    • 毎月1回取出した場合はStandard とほぼ同じ料金
    • 1日に連続12回取出した場合はこの見積もりのような料金

    それでも、自動でコストを最適化してくれるのは大きな利点です。Standard より高くなることはなく、運用の手間を減らしつつ費用を抑えることができます。

    【広告】

  • クラウドストレージの料金調査(1)

    添付ファイルを登録できるアプリを作成するにあたって、ファイルの保存先としてどのクラウドストレージを選ぶべきか、個人的に調査してみました。今回は、調査結果を共有します。

    注目した要素

    料金は大事な要素ではありますが、制約条件も無視できません。

    コストに影響する主な要素は以下の通りです。

    • 保存(データを保管する料金)
    • 取出(ファイルアクセスにかかる料金)
    • 転送(ネットワーク経由でダウンロードする料金)

    一方、見落としがちな制約として、以下のようなものがあります。

    • 最低課金日数(例:30日間分の料金がかかる)
    • 最低ファイルサイズの課金単位(例:128KB未満でも128KB分課金される)
    • 取出し時間の制限(即時アクセスできない場合あり)

    これらを踏まえ、各クラウドストレージサービスを比較した表がこちらです。


    比較表

    ※2025年9月時点の調査結果です

    各項目の説明

    • 保存:ファイルをクラウド上に保存しておくための料金
    • 取出:ファイルを読み込む操作にかかる費用。※ネットワーク転送費用は含みません。
    • 転送:ファイルをダウンロードするためのネットワーク費用。EC2 など同一リージョンのサービスからアクセスする場合、無料になることもあります。
    • 最低日数:保存期間に関係なく、最低○日分の料金が発生する仕組みです。たとえば「30日」の場合、1日しか保存しなくても30日分課金されます。
    • 最低サイズ:ファイルサイズが小さくても、指定の最小単位(例:128KB)で課金されます。
    • 取出し時間:取出し時間の指定があるストレージは、即時ファイル取り出しができません。取出しリクエストを送り、指定時間の後にダウンロード可能となります。

    利用シナリオに基づく料金見積もり

    例えば、1日に写真3枚をメモと共に保存したとすると、1日およそ7Mb を保存していくと仮定します。

    • 1年間で約2.5GB(7MB × 30日 × 12か月 ÷ 1,000)
    • 20年間で約50GB

    年に1回全てのファイルを取り出すと仮定すると、毎月約4Gb (50/12=4.1)のダウンロードが発生します。
    為替レートは1ドル=150円として、これらの前提をもとに料金を試算しました。


    評価・感想

    • AWS S3 Glacier Deep Archive がもっとも安いですが、取出し時間の制約があるため、アプリから使うには不向きです。主にバックアップ向けです。
    • GCP Archive は即時取り出せる点は魅力ですが、最低日数が約1年と長めです。同じくバックアップ向けと言えます。
    • CloudFlare は取出しと転送が無料な点で非常に魅力的です。一方、リージョンを指定できないため、利用規約やデータ保管ポリシーに影響する可能性があり注意が必要です。
    • AWS S3 Express One Zone は高性能低遅延がウリで、今回の安価なストレージを調査する趣旨と異なるのですが、試しに計算してみました。予想通り高額ですが、性能重視のアプリには良いかもしれません。

    今回はここまでです。
    次回は、自動割り当てについてご紹介します。

    【広告】

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

    スマホアプリは小さな画面を有効活用するため、必要のない時はメニューを隠しておくとUX 向上につながります。
    メニューを常時表示する方が実装は簡単ですが、今回はスクロールに応じて表示・非表示を切り替えるUIを実装してみました。


    スクロール位置の取得

    Swift でスクロール位置を取得するには PreferenceKey を使用します。これは、スクロールする View と位置を受け取りたい親 View 間で値を連携する手段です。
    今回は、LazyVStack のスクロール位置を取得して、ContentsView で位置を受け取りメニュー表示の判断を行います。
    私はjavascript のように、window.scrollY で取得できるだろうと安易に考えていましたが、それよりは少し複雑でした。

    スクロールするView

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 0) {
                // 大量のコンテンツ(省略)
            }
            // padding については後述
            .padding(.top, topMenuHeight)
            .padding(.bottom, bottomMenuHeight)
            .background(
                GeometryReader {
                    Color.clear.preference(
                        key: ScrollOffsetPreferenceKey.self,
                        // 画面上部のスクロール位置を取得してPreferenceKey で連携
                        value: $0.frame(in: .scrollView).minY
                    )
                }
            )
        }
    }

    Color.clear は画面に何も表示しないです。.preference を設定するために使用しています

    位置を取得するContentsView

    var body: some View {
        GeometryReader { geo in
            ZStack(alignment: .top) {
                // 画面コンテンツ(省略)
            }       .onPreferenceChange(ScrollOffsetPreferenceKey.self) { newY in
                // スクロール発生時にnewY が連携される
                refreshMenu(newY: newY, geo: geo)
            }
        }
    }

    PreferenceKey の実装(お作法に従ったもの)

    struct ScrollOffsetPreferenceKey: PreferenceKey {
        static var defaultValue: CGFloat = 0
        static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
            value += nextValue()
        }
    }

    Color.clear.preference から.onPreferenceChange へスクロール位置が連携されます。


    メニュー表示判定

    判定自体は比較的簡単です。
    スクロール位置は1フレーム毎に送付されるので、前回のスクロール位置を記憶しておくことで差分を算出できます。
    私は差分を15(ポイント)で判断するのが良いと思いました。
    そのため、+15 以上であれば下向きスクロールなのでメニューを隠す、-15 以下であれば、上向スクロールなのでメニューを表示する、これが基本になります。

    加えて、スクロールの上部に来た時はメニューが常に表示された方が良いです。
    スクロール位置が10 以下の場合は差分に関係なく表示するようにしました。

    private func calcMenuVisibilityAction(_ newY: CGFloat, _ oldY: CGFloat) -> MenuVisibilityActionEnum {
        let threshold: CGFloat = 15
        let topArea: CGFloat = 10
        if topArea < newY || topArea < oldY {
        // 上部に近い場合は常にメニューを表示
            return .show
        }
        let delta = newY - oldY
        if threshold < delta {
            // 上スクロール:メニューを表示
            return .show
        } else if threshold < -delta {
            // 下スクロール:メニューを隠す
            return .hide
        }
        // その他は何もしない
        return .none
    }

    メニュー位置の算出

    上のメニューについて考えてみます。
    表示するときは、上メニューのY座標を0 にすることで表示されます。こちらは簡単です。
    隠す時は少し厄介で、iPhone では、画面上部のベゼル部分(ノッチなど)を考慮する必要があります。
    メニューの高さにベゼルの高さを加えた分を画面外へ移動させます。

    ベゼルの高さはGeometoryReader のsafeAreaInsets.top で取得できます。
    画面外へのオフセットはこのようなコードで取得できます。(topMenuHeight は固定値)
    topMenuOffset = 0 – geo.safeAreaInsets.top – topMenuHeight

    下のメニューも同様にベゼルを考慮しつつ、画面外に移動させる量を算出します。


    スクロールView に余白を追加

    LazyVStack に.padding で上下メニューの高さを追加します。
    これがないと、メニューの裏側にコンテンツが隠れてしまいます。

    ただし、一番下までスクロールした際に、.padding(.bottom) で追加した余白がそのまま表示されてしまいます。
    見た目的に少し間延びする印象があるのですが、完全に解決するのは難しく、今回は割り切ってそのままにしています。
    ちなみに、GmailのiOSアプリでも同じような余白が見られたため、それほど大きな問題にはならないと判断しました。


    まとめ

    • PreferenceKey を使ってスクロール座標を取得する
    • 前回のスクロール位置と比較することで、メニュー表示の判定が可能
    • ベゼルを考慮してメニューを隠すときの表示位置を算出
    • メニューが重ならないように padding を入れる必要がある

    ソース

    【広告】

  • Java からBigQuery へのデータ投入してみた

    アクセスログをBigQuery に蓄積して解析したい、との要望があり、Java からBigQuery を操作する方法を調査・検証してみました。

    手順としては以下のようになります。


    1. プロジェクトの作成とBigQuery API の有効化
    2. データセット作成
    3. テーブル作成
    4. サービスアカウント作成
    5. Java からデータ挿入

    1. プロジェクトの作成とBigQuery API の有効化

    まずは Google Cloud Console にアクセスしてプロジェクトを作成します。

    ご注意:クレジットカードを登録しておかないと、select 文を発行することができません。今回の検証範囲であれば無料枠内に収まります。

    プロジェクト作成後、左側メニューから「API とサービス」>「ライブラリ」 を開きます。

    BigQuery API を検索します。

    有効にします。


    2. データセット作成

    BigQuery コンソールにて、プロジェクト名の横にあるケバブメニュー(三点アイコン)から「データセットを作成」を選択します。

    データセットに必要な情報を入力します。今回は、appdataset とリージョン東京を指定しました。

    ※後から知ったのですが、データセットIDにはアンダースコア(_)も使えるので、単語の区切りに利用すると読みやすくなります。

    作成が終わると、メニューにデータセットが表示され、クリックすると詳細が確認できます。


    3. テーブル作成

    無題のクエリータブを開いて、以下のようなDDL を実行してテーブルを作成します。

    create table appdataset.apptable (id int, memo string)

    BigQuery の DDL は PostgreSQL などと非常に似ているため、SQL に慣れていれば迷わず操作できると思います。

    「無題のクエリ」というタブを選ぶか、「+」を押すとクエリーを発行できます。実行を押すとテーブルが作成されます。


    4. サービスアカウントの作成

    次に、Java から BigQuery にアクセスするための認証情報を設定します。

    「IAMと管理」 > 「サービスアカウント」を選択します。

    「+サービスアカウントを作成」をクリック

    お好きなサービスアカウント名を入力します。今回は「bqtest」としました。

    権限を設定します。最低限必要な権限は以下の通りです。

    • データ挿入のみ 
        BigQuery データ編集者
    • クエリ実行も行う場合 
        BigQuery データ閲覧者
       BigQuery ジョブユーザー

    アクセス権を持つプリンシパルは省略しました。

    アカウント作成後は鍵の作成を行います。操作にあるケバブメニューから「鍵を管理」を選びます。

    「キーを追加」> 「新しい鍵を作成」を選択します。

    タイプをJSON にして作成すると、JSON ファイルがダウンロードされます。

    この JSON ファイルは後ほど Java コードの環境変数で使用するので、安全な場所に保管し、ファイルパスを控えておいてください。


    5. Java からデータ挿入

    依存ライブラリをgradle (or maven) に追加します。

    implementation("com.google.cloud:google-cloud-bigquery:2.54.2")

    このようなjava コードを書きます。

    package org.example;
    
    import com.google.cloud.bigquery.*;
    import java.util.*;
    
    public class Main {
    
        public static void main(String[] args) throws Exception {
            // BigQuery クライアントの作成
            BigQuery bigquery = BigQueryOptions.getDefaultInstance().getService();
    
            // テーブル参照
            TableId tableId = TableId.of("appdataset", "apptable");
    
            // 挿入する行データの定義(カラム名と値のペア)
            Map<String, Object> rowContent = new HashMap<>();
            rowContent.put("id", new Date().getTime());
            rowContent.put("memo", new Date().toString());
    
            // RowToInsert オブジェクトを作成
            InsertAllRequest.RowToInsert row = InsertAllRequest.RowToInsert.of(rowContent);
    
            // 挿入リクエストの作成
            InsertAllResponse response = bigquery.insertAll(
                    InsertAllRequest.newBuilder(tableId)
                            .addRow(row)
                            .build()
            );
    
            // エラーハンドリング
            if (response.hasErrors()) {
                System.out.println("エラーが発生しました:");
                response.getInsertErrors().forEach((key, err) -> System.out.println(err));
            } else {
                System.out.println("データを正常に挿入しました。");
            }
        }
    }

    このプログラムを実行する際、環境変数に先ほどダウンロードしたJSONファイルのパスを指定する必要があります。

    GOOGLE_APPLICATION_CREDENTIALS=/opt/testpj-3866e9977d0a.json

    intelliJ を使っている場合は、構成の編集から環境変数を指定できます。


    まとめ

    一連のセットアップが完了すれば、Java からBigQuery へデータ挿入するのはとてもスムーズでした。

    JDBC での操作とほとんど変わらない印象です。

    【広告】

  • テキストをマージしたい(5) – 小さなテクニック

    前回までで、3-way-merge が実装できました。
    今回は、記載できなかった小さなテクニックを2つお話しします。


    deltas の一部をapplyTo() として適用する方法

    前回 (2) の時に試した通りdiff() メソッドの戻り値から、差分の一覧を取得できます。

    val patch = DiffUtils.diff(base, local)
    patch.detlas <-- List型の差分

    この差分を全てではなく一部のみを、applyTo するやり方があります。

    // 差分の一部を抜き出し
    val newDeltas = listOf(patch.deltas[3], patch.deltas[5], …)
    // Patch オブジェクトを新規作成
    val patch = Patch()
    // 抜き出した差分を登録
    patch.deltas.addAll(deltas)
    // 元文章にパッチを適用
    val applied = patch.applyTo(base)

    このように新たにPatch を作成してそこにdelta を登録することでapplyTo が使えます。
    ただし、delta は修正対象を行番号で管理しているため、base を変更すると整合性が合わなくなりエラーになになる点は注意する必要があります。


    差分が適用された範囲を抜き出す方法

    元の文章 (base)
    A B C D E F G
    
    修正後の文章 (local)
    A B x D x F G

    ここから、差分を適用して x D x を抽出するには、下の手順が必要です。

    1. 元文章から範囲の後半部分を削除
      A B C D E F G からF Gを削除します
      –> A B C D E
    2. applyTo を使用して差分を適用
      後半部分(F G)に差分はないので問題なく適用できます
      –> A B x D x
    3. 適用後の文章から前半部分を削除
      A B x D x からA B を削除
      –> x D x

    こうすることで、整合性を合わせつつ、修正後の文字列が取得できます。
    はじめに後半を削除しないと、2. でINSERT が行われた場合、後半部分が何文字目から始まるのか算出するのが困難になります。
    また、先に前半を削除すると、source.position が合わなくなり、applyTo でエラーが起きてしまいます。

    【広告】

  • テキストをマージしたい(4) – コンフリクトの原因と修正範囲の特定

    前回は簡易な実装で3-way-merge を行ないました。
    前回の途中で、コンフリクト時に問題があることが分かりましたので、そこからお話ししていきます。


    両方修正の場合に進める行数が正しくない

    local とremote で元文章の変更範囲が異なる可能性があります。
    具体例はこちらです。

    元の文章 (base)
    A B C D E
    
    修正後の文章1 (local)
    A x C D E
    
    修正後の文章2 (remote)
    A y y y E

    ※見易くするために、1行1文字の文章をスペース区切りで横に並べています。

    local では、元文章の修正がB のみの1行ですが、リモートではB C D の3行です。
    前回のマージを適用するとこのような結果になります。

    A x y y y C D E

    これは悪くないかもしれません。
    しかし、local とremote を逆にするとこうなります。

    A y y y x E

    この場合、C D が消えてしまいます。
    置き換えた後にsouce.lines.size 行数分ループを進めることが理由です。

    前回掲載のコードから抜粋

    else -> {
        // 両方修正
        result += localDelta!!.target.lines
        result += remoteDelta!!.target.lines
        index += localDelta.source.lines.size // <-- ここ!
    }

    要件である、「コンフリクト時はその箇所についての両方の文章を残す」に合わなくなります。
    もし、C D がユーザにとって必要なメモである場合、消えてしまうと困ってしまいます。

    理想は修正1と修正2のそれぞれを並べることです。下の形にしたいです。

    A x C D y y y E

    これを実現するためには、両方の差分をひとつにまとめる必要があります。


    差分の範囲

    差分をまとめるにはどうすれば良いのでしょうか。
    もう一度、先ほどのケースについてdiff を取得し差分を細かく見てみます。

    val base   = listOf("A", "B", "C", "D", "E")
    val local  = listOf("A", "x", "C", "D", "E")
    val remote = listOf("A", "y", "y", "y", "E")
    val localPatch = DiffUtils.diff(base, local)
    val remotePatch = DiffUtils.diff(base, remote)
    
    localPatch.deltas
      0:
        source:
          position: 1
          lines: [B]
    
    remotePatch.deltas
      0:
        source:
          position: 1
          lines: [BCD]

    よく見ると、source.position とsource.lines.size を使うことで、元文章の修正範囲が取得できることが分かります。
    これを使って、local と remote で修正範囲が重複している部分を特定すれば、連続する差分が求められそうです。

    ここで、もう少し複雑なケースを考えてみます。


    複雑な差分の範囲が連続するケース

    下の例を考えてみます。

    元の文章 (base)
    A B C D E F G
    
    修正後の文章1 (local)
    A x x D z z G
    
    修正後の文章2 (remote)
    A B y y y F G

    この場合、元文章のB C D E F が修正範囲となります。
    local がB C を修正し、remote が C D E を修正し、local がE F を修正しています。
    一連の修正範囲を繋げると、B C D E F となります。

    実際に実装する時は、差分の修正範囲が相手側、つまりlocal ならremote側に、修正が入っているかどうか判定する必要があります。
    差分が見つかったらさらに、反対側を同じように判定するというロジックを繰り返します。

    こうして求められた修正範囲に対して、local の差分とremote の差分を適用します。

    以上を盛り込むと、目的のマージ処理を行うことができます。


    ソース

    ソースはこちらです。よかったら覗いてみてください。

    マージ本体
    テスト
    build.gradle


    次回予告

    小さなテクニックを紹介します。

    【広告】