新しいプロダクトパートナーを募集しています

オフラインファーストはキャッシュ機能ではなく、データモデルの決定

多くのアプリはオフライン対応を最後に後付けし、それが最初からアーキテクチャの問題だったと気づきます。実際に変わるもの、そして選ばざるをえない衝突解決ルール。

「オフラインでも動くようにして」は、1 スプリントで足せる機能のように聞こえます。違います。データがどこにあるか、そして何が真実かを誰が決めるかを変えるので、すべてに触れます。

良い知らせは、この作業がよく理解されていることです。悪い知らせは、後付けの費用が最初から組み込む費用の数倍になることです。

二つのアーキテクチャ

既定のアーキテクチャはサーバー権威です。画面はサーバーを映します。操作はリクエストを送り、待ち、応答で更新します。オフラインは「壊れている」を意味します。表示するものがなく、書き込む先もないからです。

もう一つはローカルファーストです。端末が自前のデータベースを持ち、UI はそこから読みます。書き込みは即座にローカルストレージへ入り、画面はそれを反映します。バックグラウンドの処理が、接続があるときにサーバーと突き合わせます。

この反転がすべてです。ネットワークがユーザーとそのデータのあいだから外れる。 残りはそこから導かれます。

実際に変わるもの

すべてのレコードにオフラインで生成できる識別子が要る

サーバーが ID を割り当てる設計だと、端末はオフラインで何も作れません。仮 ID を作ってあとで全参照を書き換えるしかなくなります。初日から UUID などでクライアント側 ID を生成してください。この一つの決定は前払いなら安く、後付けなら苦痛です。

書き込みは呼び出しではなくキューになる

操作は永続的なローカルキューに意図を追加して即座に返ります。同期処理がそれを排出します。つまりキューはアプリの強制終了を生き延び、バックオフ付きで再試行し、冪等でなければなりません。いくつかの操作は必ず二度送ることになるからです。

削除が削除でなくなる

端末がオフラインで行を削除し、同期が「存在するもの」だけを送るなら、サーバーはそれを知らず、親切に送り返してきます。トゥームストーンが必要です。削除をタイムスタンプ付きの事実として記録し、すべての端末が見るまで十分に長く保持します。

UI が同期状態を示さなければならない

書き込みがローカルで成功し、あとから失敗しうるようになった以上、ユーザーは知る必要があります。すべてにスピナーを出すのではなく、正直な表示を。ローカル保存済み、同期中、同期済み、失敗。これを隠すと最悪の結果になります。作業が安全だと信じている人が、実はそうでない状態です。

衝突解決ルールを選ぶ

二台がオフラインで同じレコードを編集し、両方が戻ってきます。何かが決めなければなりません。普遍的に正しい答えはなく、あなたのデータにとって正しい答えがあるだけです。

方式仕組み向いているもの
後勝ちタイムスタンプが新しいほうが上書き設定、端末ごとの状態、影響の小さい項目
フィールド単位のマージ項目ごとのタイムスタンプで、衝突しない項目を統合別々の部分を編集するレコード
操作ログ意図を保存し、順に再生カウンタ、リスト、加算的なもの
CRDT決定的にマージできる構造共同編集テキストや共有ドキュメント
ユーザーに聞く両方を提示して選ばせるまれで価値の高い衝突のみ

経験からの警告が二つ。端末時計に基づく「後勝ち」は静かにデータを失います。 端末の時計はずれているからです。サーバー割り当てか論理時計を使ってください。そして「ユーザーに聞く」は既定ではありません。 自動解決の費用が高すぎる場合の非常口です。頻繁に聞かれれば、人は読まずにクリックします。

衝突ルールはアプリ単位ではなくデータ型ごとに選んでください。ユーザーのフラッシュカードの進捗は加算的で、きれいにマージできます。表示名は後勝ちで十分です。両方に同じルールを使えば、どちらかにとって必ず間違いです。

同期そのもの

多くのプロダクトで機能する形:

  1. 各レコードがサーバーの割り当てたバージョンまたは更新時刻を持つ。
  2. クライアントはカーソル、つまり追いついた地点を保存する。
  3. 同期時、クライアントはキューにある書き込みと自分のカーソルを送る。
  4. サーバーは書き込みを適用し、衝突を解決し、そのカーソル以降の変更と新しいカーソルを返す。
  5. クライアントは変更の適用とカーソルの前進をアトミックに行う。

手順 5 のアトミック性は見た目より重要です。カーソルを進めてから適用に失敗すると、変更を静かに取りこぼし、それを知らせるものは何もありません。

醜いケースを意図的にテストする

オフラインのバグは通常のテストでは出ません。通常のテストには Wi-Fi があるからです。次をテスト計画に入れてください。

  • 機内モードで一週間、そして数百件のキューを抱えて再接続。
  • 同期中に強制終了。 バッチが飛んでいる最中にアプリを殺す。失われても二重適用されてもいけません。
  • キャプティブポータル。 最悪のネットワーク状態です。端末は接続ありと報告し、すべてのリクエストがログインページを返す。リクエストはきれいに失敗し、状態を壊してはいけません。
  • 時計のずれ。 端末時計を一日ずらして、順序が保たれるか確認する。
  • 同一アカウントの二台が両方オフラインで同じレコードを編集。衝突ルールが真価を問われる場面です。

やらないほうがよい場合

オフラインファーストは実作業であり、常に正当化されるわけではありません。次の場合は見送りましょう。ネットワークがなければどのみち無意味なアプリ(リアルタイムチャットや決済)。データが本質的に共有かつ同時であり、オフラインよりリアルタイムが欲しい場合。あるいは利用頻度の低い社内ツールで、費用が便益を上回る場合。

やるべきなのは、接続が不安定な場所で使われるときです。通勤、公共交通、飛行機、地方、電波の悪い建物。学習アプリならそれが大半のセッションなので、私たちは機能要望ではなく出発点の前提として扱っています。

いちばん安い版

完全なローカルファーストが現段階では重すぎるなら、取る価値のある中間地帯が広くあります。

  • 読み取りを積極的にキャッシュし、アプリがスピナーではなくコンテンツで開くようにする。
  • 書き込みを永続的にキューへ。 完全な同期がなくても、操作が切断で失われないようにする。
  • ID を今からクライアントで生成する。 今日はコストがゼロで、将来すべてを解放します。

この三つで、体感上の便益の大半をわずかな労力で得られますし、あとで先へ進んでも無駄になるものは一つもありません。

モバイル エンジニアリング アーキテクチャ