この記事は中国語版を AI が翻訳したものです。内容に相違がある場合は中国語版を正とします。
仕事の都合で、私は常時 2 台の PC を使い分けています。1 台はメールや日常業務用の会社支給マシン、もう 1 台は権限制約のある作業をこなすための個人マシンです。
メインブラウザはずっと Firefox です。ただ会社の IT セキュリティポリシーで、すべてのブラウザで個人アカウントへのログインを一律禁止しています。そこで困ったのがブックマークの分断です。個人 PC で調べものをして保存したブックマークは会社の PC に出てこないし、会社で保存した便利なリンクも家に帰ると開けません。
floccus のような定番ツールも試しました。ただ汎用ニーズ向けの作りで、機能が多く、設定や依存関係も重めです。私の要望はごくシンプルでした。数台の PC 間で安定して双方向同期できること。データは自分の VPS にだけ置くこと。余計な機能はいらないこと。
ぴったり合うものが見つからないなら、自分で作ればいい。そうして生まれたのが bmsync です。最小構成の Firefox 拡張と軽量バックエンドの組み合わせで、拡張のアイコンを押すたびに各端末のブックマークツリーを双方向同期します。データは終始自分のサーバー内だけに留まります。
ここでは設計の考え方や技術選定、途中で方針を変えた経緯を残しておきます。
全体設計:サーバー集約マージと全量転送
複数端末のデータ同期でいちばん事故が起きやすいのは、各クライアントに Diff(差分)計算を任せる方式です。両端の状態が食い違うと、マージ処理はすぐ破綻します。
全体をシンプルに保つため、ブラウザ拡張はあえて薄く作り、役割は 2 つに絞りました。
- Collect:ローカルのブックマークツリーを読み取り、全量スナップショットに変換してサーバーへ送る。
- Apply:サーバーが計算した最新の完全な状態を受け取り、ローカルにそのまま上書きする。
マージ(Merge)計算はすべてサーバーに集約します。 こうすると操作は自然に冪等になります。ネットワークのリトライや連続クリック、2 台の端末が短時間に続けて同期しても、サーバー側の最終結果は一致します。複数端末が並行にマージして壊す心配がありません。
転送方式は全量転送を選びました。実測では 5000 件のブックマークで JSON 状態が約 800 KB、gzip 圧縮でさらに小さくなります。今の回線事情なら、この程度の帯域増は大した問題になりません。増分用のバージョン番号を管理し、中断再開や順序ずれに対応する保守コストに比べれば、素直な選択です。
グローバル ID がない中で、同じブックマークをどう見分けるか
Firefox の Bookmarks API が払い出すのは、端末ローカルの連番整数 ID だけです。同じブックマークでも端末ごとに ID が異なり、独自のグローバル項目を書き込む手段もありません。
そこで内容ハッシュから安定した識別子を導く方式にしました。
- ブックマークの Key:
SHA-256("u:" + url)の先頭 16 バイト。 - フォルダーの Key:
SHA-256("f:" + 親フォルダーKey + NUL + タイトル)の先頭 16 バイト。
ここには明確な意図があります。ブックマークの Key には親フォルダーの情報を含めていません。 ブックマークをフォルダー A からフォルダー B へ移しても、Key 自体は変わらず、親への紐付けだけが変わります。サーバーはこれを「削除+新規作成」ではなく「位置の移動」として素直に読み取れます。
代償として、ブックマークの URL を書き換えたり、上位フォルダー名を変えたりすると、システムは「旧項目の削除+新項目の作成」として扱います。
削除と Tombstone の扱い
同期システムでは、削除は新規追加より厄介です。
両端のブックマーク一覧を比べるだけでは、サーバーは次の区別がつきません。「端末 B がそのブックマークを削除した」のか、「端末 B は新規端末でまだデータを取り込んでいない」のか。
そこでローカルでの削除は物理削除しません。d: true 属性を持つ「墓石(Tombstone)」として残します。Tombstone は通常のブックマークと同じく最終書き込み優先(Last-Write-Wins)で古い状態を上書きし、サーバー側に 90 日間保持します。普段使う端末なら、その間に起動して削除状態を取り込む余裕があります。90 日を過ぎた Tombstone だけをサーバーが完全に掃除します。
時計ずれ対策:ハイブリッド論理時計(HLC)の導入
タイムスタンプで上書き順を決める以上、端末間の物理時計のずれは避けて通れません。ある PC の時計が数分進んでいると、古い変更が、時刻の正しい端末の新しい変更を上書きしかねません。
物理時計のずれを排除するため、**HLC(ハイブリッド論理時計)**を入れました。端末 B が端末 A の状態を受け取った後、B が生成する操作の論理タイムスタンプは必ず A より大きくなる決まりです。
サーバー側と拡張側で HLC をそれぞれ実装し、共通のテストケースで振る舞いが一致するか確認しています。論理時刻の進め方について両端の解釈がずれないようにしています。
技術選定:Go から C# への切り替え
バックエンドの初版は Go で書きました。自作の小物には、Go の単体バイナリ出力やメモリの軽さは導入しやすく、相性がいいのは確かです。
ただ AI と一緒に素早く組み立てた Go コードをレビューしてみると、保守の感触が良くありませんでした。厳密な型付けやオブジェクト指向の抽象化がない分、処理が平坦な関数群に散らばり、修正のたびに依存関係をたどり直す手間がかかります。
悩んだ末、自分がいちばん慣れた C# に戻し、軽量デプロイの要望は NativeAOT で満たす形にしました。
- コードの見通しが良い:強い型システムにパターンマッチと明確な層分けが加わり、処理の流れを一目で追えます。数か月ぶりに開いても保守に入りやすい作りです。
- NativeAOT で資源を抑える:単体のネイティブバイナリにまとめ、Docker の
FROM scratchイメージに入れると 20〜30 MB 程度に収まります。実行時のメモリ消費も控えめです。 - 拡張側は素のまま:拡張はピュアな JavaScript(ES Modules)だけで書き、フロントエンドフレームワークもバンドラーも npm 依存も入れていません。修正後は Firefox の
about:debuggingで更新を押すだけですぐ反映します。 - テストで信頼性を担保:テストスクリプト(
./test/all.sh)に 250 件超のケースを用意し、静的チェックからエンドツーエンドの API 振る舞いまで通しています。
操作面でも、手動で同期する形をあえて残しました。拡張はバックグラウンドで定期実行せず、アイコンを押したら 1 回同期する方式です。この確実さが、小物ツールに対する自分の使い方に合っています。
割り切った点
自分用に仕立てた道具なので、端のケースは最初から割り切っています。
- フォルダー内の並び順は保証しない:同期したブックマークは末尾に追加します。内容と階層の一致だけを守ります。
- URL は全体で一意:別フォルダーに同じ URL を保存しても、同期後は 1 件にまとまります。
- フォルダー名の変更は重い:フォルダー名を変えると配下全体の Key を作り直します。内容は消えませんが、古い Tombstone がまとめて残ります。
- 90 日を超えるオフラインは不可:長期(90 日超)オフラインの端末が復帰すると、サーバー側の Tombstone はすでに掃除済みのため、古いブックマークを新規データとして上げ直す場合があります。
- 中核のブックマーク同期に絞る:タグやメモ、ファビコン、リーディングリストなどの付随データには対応しません。
関連リンク
サーバーとブラウザー拡張はいずれも公開済みで、拡張は Firefox 公式ストアにも出しています。
- Firefox アドオン: bm-sync on Firefox Add-ons
- GitHub リポジトリ: tilv37/sync-bookmark
公開 SaaS ではないため、使う前にバックエンドの導入が必要です。リポジトリ同梱の docker-compose.yml で起動し、拡張の設定画面にサーバーアドレスとアクセストークンを入れるだけで動きます。
管理端末をまたぐブックマーク同期に同じ悩みがあり、データを自分のサーバー内に留めたい方は、ぜひ試してみてください。