blog

  • テキストをマージしたい(3) – 3-way-merge 簡易実装

    前回、JavaDiffUtils の基本動作を確認しました。
    今回は簡易な実装で3-way-merge を行なっていきます。


    3-way-merge 基本方針

    applyTo() は便利ですが、「両方の変更を残す」という要件は満たせません。そこで、source.lines とtarget.lines を使って、自前でマージを行います。

    方針

    • 修正なし –> base の行をそのまま採用
    • 片方のみ修正 –> その修正版を採用
    • 両方修正 –> 両方の修正を採用

    実装例

    kotlin
    fun merge(base: List<String>, local: List<String>, remote: List<String>): List<String> {
        val localPatch = DiffUtils.diff(base, local)
        val remotePatch = DiffUtils.diff(base, remote)
    
        // 元文章の何行目に関する修正かMap 型に変換しておく
        // 適用済み管理と速度向上が目的なのでmutable
        val localDeltasFirstPositionMap = localPatch.deltas.associateBy { it.source.position }.toMutableMap()
        val remoteDeltasFirstPositionMap = remotePatch.deltas.associateBy { it.source.position }.toMutableMap()
    
        val result = mutableListOf<String>()
        var index = 0
        // 文末に行を追加した場合、source.position がbase 最終行+1 となるので、base.size までループ
        while (index <= base.size) {
            val localDelta = localDeltasFirstPositionMap.remove(index)
            val remoteDelta = remoteDeltasFirstPositionMap.remove(index)
    
            when {
                localDelta == null && remoteDelta == null -> {
                    // 修正なし
                    if (index < base.size) result += base[index]
                    index++
                }
                localDelta != null && remoteDelta == null -> {
                    // 片方修正(local)
                    result += localDelta.target.lines
                    index += localDelta.source.lines.size
                }
                localDelta == null && remoteDelta != null -> {
                    // 片方修正(remote)
                    result += remoteDelta.target.lines
                    index += remoteDelta.source.lines.size
                }
                else -> {
                    // 両方修正
                    result += localDelta!!.target.lines
                    result += remoteDelta!!.target.lines
                    index += localDelta.source.lines.size
                }
            }
        }
        return result
    }

    動かすとこのようになります。

    val base = listOf("AAA", "BBB", "CCC")
    val local = listOf("AAA", "BeB", "CCC")
    val remote = listOf("AAA", "BfB", "CCC")
    val result = merge(base, local, remote)
    result shouldBe listOf("AAA", "BeB", "BfB", "CCC")
    
    AAA
    BeB
    BfB
    CCC

    行単位でのマージはこれでバッチリです。
    ですが、厳密にはコンフリクト時に問題があり、このロジックでは一部の文字が失われる可能性があります(詳細は次回説明します)。
    一方で、コンフリクトが発生した行を文字単位に分解し、同じロジックを適用すれば目的の結果を得られそうです。

    上のロジックのまま、文字単位でのマージを試してみます。

    文字単位でマージ

    2行目を文字単位で分解して、先ほどのロジックを適用してみます。

    val base = listOf("B", "B", "B")
    val local = listOf("B", "e", "B")
    val remote = listOf("B", "f", "B")
    val result = merge(base, local, remote)
    result shouldBe listOf("B", "e", "f", "B")
    
    BefB

    期待通りの結果が得られました。
    行単位でマージを試みて、コンフリクトが発生した行は文字単位でマージを行う方針が良さそうです。

    次回予告

    コンフリクト時の問題についてお話しします。

    【広告】

  • テキストをマージしたい(2) – JavaDiffUtils を試す

    前回はiPhone メモアプリの挙動とマージの難しさを確認しました。
    今回は実際に JavaDiffUtils を使い、テキスト差分の取得と適用の基本を押さえます。そして、次回の 3-way-merge 実装につなげます。

    まずは、今回のマージ機能の要件を整理します。


    要件の整理

    今回のマージ機能はメモアプリでの利用を前提にしています。Git等のソース管理で行われているマージとは要件が異なります。

    • ユーザ操作なしで自動マージする
    • コンフリクト時は両方の修正版を残す

    要件の例

    このように、eとf の両方の変更を残すことで、必要な情報が失われるのを防ぎます。最終的な判断はユーザに委ねますが、情報が消えるよりはマシという方針です。

    JavaDiffUtils の基本

    使い方はとても簡単です。

    kotlin
    val base = listOf("AAA", "BBB", "CCC")
    val local = listOf("AAA", "BeB", "CCC")
    val patch = DiffUtils.diff(base, local)

    DiffUtils.diff() を実行するだけで、差分を算出してくれます。
    patch の中身を見てみます。

    patch:
      deltas:
        0:
          type: CHANGE    <-- 他に、INSERT, DELETEがある
          source:
            position: 1   <-- 修正元の行番号(0始まり)
            lines: [BBB]  <-- 修正前の内容
          target:
            position: 1
            lines: [BeB]  <-- 修正後の内容
    • type: CHANGE(変更)、INSERT(追加)とDELETE(削除)がある
    • source: 修正前の行情報(位置と内容)
    • target: 修正後の行情報

    さらに、patch.applyTo(base) を使えば、差分を元文章に適用できます。便利ですね。

    val applied = patch.applyTo(base)
    println(applied)
    // AAA
    // BeB
    // CCC

    注意点として、deltas は修正行番号で管理しているため、base を変更するとパッチが適用できずにエラーになることがあります。


    次回予告

    ここまでで、JavaDiffUtils を使った差分取得と適用の基本がわかりました。次回はこの仕組みを使い、自動で両方の変更を残す 3-way-merge を実装していきます。

    【広告】

  • テキストをマージしたい(1) – iPhoneメモアプリの挙動から考える

    iPhoneのメモアプリは、私にとって非常に使いやすく優れたツールです。その中でも、特に気に入っているのが 複数デバイス間での自動同期機能 です。

    この機能のおかげで、メモを取るときにネット接続がなくても構いません。ネットに接続されたタイミングで、自動的に他のデバイスに同期されるからです。しかも、ユーザーが意識して操作をすることなく、「気づいたら同期されている」 という自然な使い心地が実現されています。


    同期とマージの難しさ

    このような便利な機能を、自分のアプリでも再現できないかと考えています。
    「ネットに再接続されたタイミングでバックアップ処理が走る」という仕組みは比較的想像しやすいのですが、問題はそのときの テキストのマージ処理 です。

    プログラマーであればお分かりかと思いますが、これは Git のマージコンフリクトの解消に近い処理になります。複数の編集が同じ場所で衝突したとき、どちらの内容を優先するか、あるいはどう統合するかを決めなければなりません。


    実際にiPhoneのメモアプリで試してみた

    挙動を確認するため、iPhoneとMacBookでそれぞれ同じメモを別々に編集し、同期がどのように行われるかを検証してみました。

    なんと、両方の修正が取り込まれている結果になりました。

    これはメモアプリとして非常に良い挙動と思います。どちらか一方の編集だけが残り、もう一方の内容が失われてしまうと、ユーザーにとっては大切な情報の欠落するかもしれません。欠落するより、両方の修正が残った方がユーザにとっては嬉しいはずです。


    Gitで同じことが起きたら?

    先述の通り、通常はコンフリクト解消作業が必要となります。
    たとえば以下のように、ブランチ毎に修正内容が列挙されるので、開発者が差分を確認しながらマージ作業を行わなければなりません。この作業って大変ですよね。

    <<<<<<< HEAD
    beb
    =======
    bfb
    >>>>>>> feature-branch

    技術的解決の方向性

    差分の算出には JavaDiffUtils という優れたライブラリがありました。これを使えば、2つのテキスト間の差分抽出は完璧に行えます。さらに、3-way-merge(3者間マージ) という手法を使うことで、編集前・編集1・編集2の3つのバージョンを比較して、よりスマートなマージが可能になります。

    現在、プロトタイプを作成しています。近いうちに記事として公開予定なので、興味のある方はチェックしていただけると嬉しいです。


    アイコンを使わせていただきました。素敵なアイコンをありがとうございます。
    https://icooon-mono.com
    Offline icons created by NajmunNahar – Flaticon
    Internet-connection icons created by NajmunNahar – Flaticon

    【広告】

  • windows(powershell) でmysqldump の文字化けに苦しむ

    結論

    mysqldump を使う時はオプションの –result-file=dump.sql を付けた方が良い。トラブルを防げます。

    問題の発端

    PowerShell でこのようなコードを書いたことが元凶。

    chcp 65001
    & mysqldump ... > dump.sql

    このdump.sql を後でリストアしようとすると、以下のようなエラーが発生しました。

    mysql ... < dump.sql
    ... invalid character '-◻︎' ...

    理由は、PowerShell のリダイレクト(>)が文字コードに余計な手を加えるため、MySql のパーサが読んでくれません。

    一時回避

    PowerShell 経由でファイルを読み書きしてから、MySql パーサに読ませます。一見無意味に見えますが、実際には文字コードが変わります。

    Get-Content dump.sql | Out-File dump.sql.clean
    mysql ... < dump.sql.clean

    こちらは正しくリストアできます。ただし、dump.sql が巨大な場合はメモリ使用量が問題になり、Get-Content でエラーになります。
    Stream で文字コードを変更する案もあるのですが、試したものの結局MySql パーサは読み込んでくれませんでした。

    根本的な解決方法

    以下のように mysqldump に –result-file オプションを明示的に指定することで解決しました。

    mysqldump --result-file=dump.sql ...

    この方法で出力されたファイルは余計な文字コード変換が起きず、そのままリストア可能です。

    感想

    結局、windows のリダイレクトが余計な気配りをしてくるのが問題でした。個人的には、Unix系OSの方がトラブルが少ないので、運用するにはそちらの方が安定すると思います。

    【広告】

  • Chrome Extension 更新失敗の原因について

    先日、Chrome Extension の更新版をリリースしました。(詳しくはこちら
    その際にいくつかのミスがありましたので、備忘録としてここに記録しておきます。

    (1) 大量データに関する問題

    コメントでご指摘いただき、初めて気づくことができました。
    私は、品質向上のためには性能試験が重要だと考えているにもかかわらず、大量データに対するテストを行わずにリリースしてしまいました。
    自分の Amazon 購入履歴は多くても10ページ程度だったため、それ以上のケースは「テストできない」と思い込んでいました。
    しかし、それは単なる言い訳です。たとえば、1ページを10回ずつ読み込むようにコードを少し変更すれば、簡単に大量データのシミュレーションができます。

    原因と対応策

    エラーの原因は、巨大な配列を String.fromCharCode に展開して渡した際、
    「Maximum call stack size exceeded(スタックオーバーフロー)」が発生していたことです。
    これに対しては、32KBずつに分割して処理することで対応しました。
    該当の修正コミットはこちら

    (2) ZIP 作成ミス

    こちらもコメントでご指摘いただきました。ありがとうございます。
    Chrome Extension は ZIP にまとめて Chrome Web Store にアップロードする必要があります。
    しかし、リリース版にテスト用のデータ水増しコードがそのまま含まれてしまうという、非常に初歩的なミスをしてしまいました。
    私は修正後に git commit をしたことで、未コミットのファイルは ZIP に含まれないと思い込んでいました。
    これは、他のプロジェクトでは CI/CD により自動でビルド・リリース用の ZIP が作成されるため、その感覚が残っていたことが原因です。

    今後の対策

    今後は、ZIP 作成処理をスクリプト化する予定です。
    具体的には、別フォルダに最新コードをクローンし、そこから ZIP を作成する仕組みにします。
    以下は参考までに、macOS 上でのシェルスクリプト例です:

    cd /tmp
    rm -fr ./work
    mkdir work
    cd work
    git clone --depth 1 --branch main git@github-greencode:green-code-developer/chrome_extension_amazon_jp_history .
    zip -r extension.zip ./extension
    ls -l /tmp/work/extension.zip

    ご指摘くださった皆さま、本当にありがとうございました。
    この経験を次回のリリースに活かしていきたいと思います。

    【広告】

  • Spring Security の認証処理をカスタマイズ

    今までSpring Security の認証処理を理解できていなかった。
    単純なパスワード突合だけなら、UserDetailsService を実装してDB からselect するだけで良いので何とかそれで乗り切っていた。
    しかしながら、現在のプロジェクトで複雑なログイン要件が追加されたため、それだけでは立ち行かなくなってしまった。

    参考までに追加された要件

    • パスワードを連続5回失敗失敗したらアカウントをロックする
    • パスワード変更の有効期限を過ぎたらアカウントをロックする

    いろいろ探しところ、DaoAuthenticationProvider のadditionalAuthenticationChecks() を継承すると非常に柔軟に実装できることが分かった。

    コードはこちら。

    @Component
    @Slf4j
    public class AppAuthenticationProvider extends DaoAuthenticationProvider {
    
      public static final String DEFAULT_LOGIN_ERROR = "ユーザー名またはパスワードが違います";
      public static final String ERROR_TOO_MANY_ATTEMPTS = "連続5回ログインに失敗しました";
      public static final String ERROR_PASSWORD_EXPIRED = "パスワードの有効期限が過ぎています";
    
      private final PlatformTransactionManager transactionManager;
      private final AccountRepository accountRepository;
    
      public AppAuthenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService, PlatformTransactionManager transactionManager, AccountRepository accountRepository) {
        super(passwordEncoder);
        // 親クラスで必要
        setUserDetailsService(userDetailsService);
        this.transactionManager = transactionManager;
        this.accountRepository = accountRepository;
      }
    
      @Override
      protected void additionalAuthenticationChecks(
          UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    
        // 5回以上ログイン失敗している場合はログイン不可
        if (LOGIN_FAIL_MAX_COUNT <= userDetails.getLoginFailCount()) {
          throw new AppLoginException(ERROR_TOO_MANY_ATTEMPTS);
        }
    
        // パスワード変更期限を過ぎていたらログイン不可
        if (userDetails.getPasswordExpiredDate() != null && now().isAfter(account.getPasswordExpiredDate())) {
          throw new AppLoginException(ERROR_PASSWORD_EXPIRED);
        }
    
        // パスワードチェック
        try {
          // 親クラス(DaoAuthenticationProvider)のパスワードチェックを呼び出す
          // パスワード相違時は例外がthrow される
          super.additionalAuthenticationChecks(userDetails, authentication);
          // パスワード一致時はカウンターをリセットする
          new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
            // spring security から呼ばれるのでトランザクションを持っていない
            accountRepository.resetFailCounter(userDetails.getAccountId());
          });
        } catch (AuthenticationException ae) {
          // パスワード相違時は、カウンターをインクリメント
          new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
            // spring security から呼ばれるのでトランザクションを持っていない
            accountRepository.incrementFailCounter(userDetails.getAccountId());
          });
          // すでに4回失敗していたら、エラーメッセージを変更する
          if ((LOGIN_FAIL_MAX_COUNT - 1) <= userDetails.getLoginFailCount()) {
            throw new AppLoginException(ERROR_TOO_MANY_ATTEMPTS);
          } else {
            throw new AppLoginException(DEFAULT_LOGIN_ERROR);
          }
        }
      }
    }

    こちらを@Bean 登録しすると有効になる。

    @Bean
    public AuthenticationManager authenticationManager(AppAuthenticationProvider provider) {
      return new ProviderManager(provider);
    }

    ビルトインの機能を使って、UserDetails のisAccountNonExpired() で似たような実装を行うこともできる。その場合、すでに4回失敗した状態でパスワード相違した時にメッセージを変更することができないと思った。

    throw された例外は、formLogin のfailureHandler に入ってくるので、それを一時的にセッションへ格納し、ログイン画面を表示するコントローラでセッションの値を取得して画面に表示すると良い。
    セッションの値は一度限りで即時削除する。(redirectAttribute と同じだがここでは使えない)

    また、failureHandler を使わず、additionalAuthenticationChecks() でセッションに例外を詰めることもできるが、ユーザ名不正の場合は、additionalAuthenticationChecks() が呼ばれないのでハンドリングできない。(1敗)

    【広告】

  • 購入履歴取り込み変更点

    先日更新した、アマゾン購入履歴csv取得の変更点について説明します。

    変更前(v1.1) まではこちらの方式でした。

    ポップアップからコンテンツへ直接指示を出していました。

    この方式の問題は、ポップアップが突然閉じてしまうと、収集処理が途中で止まってしまうことです。

    更新後(v1.2+)の方式がこちらです。

    バックグラウンドは画面を持たない仮想のタブのようなものです。こちらは、ポップアップと違って、閉じることがありません。そのため、安定して全ページを取得することができます。

    バックグラウンドについてはこちらの記事が分かりやすかったです。

    【広告】

  • recaptcha v2 とv3 でタグが異なる

    下のエラー原因が分からず、数時間を無駄にした。悲しいので記録を残す。

    結論は件名の通り。公式マニュアルのコードを例に出して違いを記載する。

    v2 の正しい書き方

    <div class="g-recaptcha" data-sitekey="your_site_key"></div>

    v3 の正しい書き方

    <button class="g-recaptcha" data-sitekey="reCAPTCHA_site_key" 
            data-callback='onSubmit' 
            data-action='submit'>Submit</button>

    そして、私がやっていたダメな書き方(やってはいけない)

    <div class="g-recaptcha" data-sitekey="reCAPTCHA_site_key" 
            data-callback='onSubmit' 
            data-action='submit'>Submit</div>

    v3 を使う場合、<button> タグが正解であり、<div> はエラーになる。

    もう少し別のエラー表記にしてもらえれば、時間を無駄にしなかったのに。。。

    タグの間違い以外は、こちらのサイトが詳しかった。

    【広告】

  • アマゾン購入履歴CSV取得Chrome Extension を更新しました

    Chrome Extension でアマゾンの購入履歴をCSV としてダウンロードするアプリに、以下の点を改良してリリースしました。

    • 文字コード選択(SJIS or UTF8)
    • UI改善
    • 動作が途中で止まるバグ対応

    正直に言いましてChrome Web Store を作成以来放置しておりました。先週になってようやくコメントをいただいていたことに気づきました。(遅くなってすみません)
    多くが文字コードに関するものでした。

    Chrom extenson デベロッパーコンソールが上の画像で、ここから約8割の方がwindows での利用されている状況と分かりました。そのため、SJIS でダウンロードする機能が必要と考えて、文字コード選択機能を追加しました。

    よかったら使ってみてください。
    アマゾン購入履歴取得ツール

    SJIS の変換は encoding-japanese を使わせていただきました。
    https://www.npmjs.com/package/encoding-japanese

    【広告】

  • OpenPDF シミュレーション機能が便利

    先日、下のように表を出力するPDF を作成した。

    単純に思えるかもしれないが、以下の様な要望と技術的問題があった。

    • 行の高さを固定せず文字量やデータ量に応じて自動拡張したい
    • 文字量とデータ件数は動的変化する
    • 行の途中で改行しない
    • 各ページの上部に固定ヘッダーを表示したい
    • その上で表のタイトル行も毎ページ表示したい

    これらを満たすためには、表に1行ずつ追加した場合の高さを取得できる必要がある。
    無償のPDF ライブラリを探したところ、OpenPDF でできることが分かった。

    ColumnText にgo(simulate) という機能があり、こちらを使うと描画する前にコンテンツの高さを取得することができる。
    Javadoc

    コードの例

    // 新規PDF ドキュメント作成(マージンとファイル名は適当)
    Document document = new Document(PageSize.A4, 36, 36, 36, 36);
    PdfWriter writer = PdfWriter.getInstance(document, new FileOutputStream("sample.pdf"));
    document.open();
    PdfContentByte cb = writer.getDirectContent();
    // シミュレーション用のcolumNText を作成
    ColumnText columnText = new ColumnText(cb);
    // 左下(0,0) 右上(1000,1000) のシミュレーション領域を作成
    float baseHeight = 1000;
    columnText.setSimpleColumn(0, 0, 1000, baseHeight);
    // デフォルトフォントでは日本語が表示されないのでNotoSans を指定
    BaseFont baseFont = BaseFont.createFont("NotoSansJP-Regular.ttf", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
    Font font = new Font(baseFont, 18);
    PdfPTable table = new PdfPTable(1);
    PdfPCell cell = new PdfPCell(new Phrase("改行込み\n長い長い\nテキスト", font));
    // 計算を簡略化するためセルの余白にゼロを指定
    cell.setPadding(0);
    table.addCell(cell);
    columnText.addElement(table);
    // シミュレーションモードで実行
    columnText.go(true);
    // table 描画後のyLine を取得
    float yLine = columnText.getYLine();
    // 領域の高さからyLine を引いた数値が高さになる
    float height = baseHeight - yLine;
    System.out.println(height); // 54.0 (フォント18pt x 3行 + セルの余白0)

    build.gradle

    implementation("com.github.librepdf:openpdf:2.0.5")

    columnText.go(true) が重要な点である。これを使うと描画せずに高さを取得できる。表の高さがページの余白の高さに収まるか判定すれば要件を満たせる。

    余談だが、フォント(NotoSans)はこちらを使用した。

    【広告】