ローカルファースト・ソフトウェア:CRDTでオフライン・自動同期

1. 概要

現在のアプリケーションの大部分は、サーバーを「真実」のデータを保持する場所と見なし、クライアントは単に表示するだけです。ネットワークが切れるとアプリはほぼ使用不可能になります。ローカルファースト・ソフトウェアはこのやり方を逆転させます。データはまずユーザーのデバイスにあり、サーバーは単に同期を支援するだけです。

この記事はローカルファースト・ソフトウェアが何か、なぜそれが注目されているのか、そしてロックや「唯一の勝者」なしでデータをマージできる**CRDT(Conflict-free Replicated Data Type)**の役割を説明します。読み終わったら、Java 21で基本的なCRDTをいくつか自分で実装でき、Spring Boot 3で同期エンドポイントを構築でき、そしていつこのアプローチを選ぶべきではないかが理解できるようになります。

2. ローカルファースト・ソフトウェアとは、そしてなぜ注目されているのか

ローカルファースト・ソフトウェアのアプリケーションは通常、以下の特徴を持っています:

  • マシン上での即座の読み書き:すべての書き込み操作はローカルストレージ(SQLite、IndexedDB、ファイル…)に行われ、ネットワークのラウンドトリップを待ちません。
  • オフラインで動作:ネットワークが切れてもデータを編集できます。ネットワークに接続したら同期します。
  • 複数デバイス、複数ユーザーでの協業:複数のレプリカが同時に編集され、自動的に同じ状態に収束します。
  • ユーザーがデータを所有:サーバーが失われてもデータはデバイス上に存在します。

このアプローチがより多く議論されるようになったいくつかの理由があります:

  1. ユーザー体験の期待値:ユーザーはアプリが即座に反応することに慣れており、クリックの後にスピナーが回ることがありません。
  2. デバイスがより強力に:スマートフォンとラップトップはローカルデータベースとデータマージロジックを実行できるだけの能力を持っています。
  3. エコシステムがより成熟:Automerge、Yjsなどのライブラリと、SQLiteの多数の同期エンジンが、実装を最初から書くより簡単にしました。
  4. プライバシーとデータ主権:データをユーザーのマシンに保つことで、中央サーバーへの依存を減らします。

最大の課題は同期です。2つのデバイスが一緒にオフラインで編集したら、再び出会ったときに、どちらが正しい?

3. CRDTはどのように競合を解決するか

CRDTは、すべてのレプリカが互いの更新をすべて受け取った後、常に同じ状態に収束するように設計されたデータ構造です。サーバーが仲裁者である必要がなく、ロックも必要ありません。

2つの主なファミリーがあります:

  • State-based(CvRDT):レプリカが互いに完全な状態を送信してからmergeを呼び出します。
  • Operation-based(CmRDT):レプリカが各操作を送信します。トランスポートチャネルは、各操作が正確に1回配信されることを保証する必要があります。

この記事はstate-basedを使用します。理解しやすく、説明しやすいからです。収束するには、merge関数が3つの特性を満たす必要があります:

可換性     : merge(a, b) == merge(b, a)
結合性     : merge(merge(a, b), c) == merge(a, merge(b, c))
べき等性   : merge(a, a) == a

これら3つの特性により、メッセージが遅れて到着、順序が狂、または重複しても、結果は同じままです。

4. 基本的なCRDTをいくつか実装する

以下の例はJDKのみを使用します。セクション5のSpring Boot部分には追加のdependencyが必要です。

4.1. G-Counter:増加のみするカウンター

複数のデバイスでOrderのビュー数を数えたいとします。各レプリカは自身のセルだけを増やします。マージするとき、各セルの最大値を取ります。

import java.util.HashMap;
import java.util.Map;

public final class GCounter {
    private final Map<String, Long> counts = new HashMap<>();

    public void increment(String replicaId) {
        counts.merge(replicaId, 1L, Long::sum);
    }

    public long value() {
        return counts.values().stream().mapToLong(Long::longValue).sum();
    }

    public void merge(GCounter other) {
        other.counts.forEach((id, n) -> counts.merge(id, n, Math::max));
    }
}

incrementはreplicaIdのセルのみに触れるので、2つのレプリカが互いに上書きすることはありません。mergeはMath::maxを使用し、maxは可換、結合、べき等です。試しに実行してみます:

GCounter phone = new GCounter();
GCounter laptop = new GCounter();
phone.increment("phone");
phone.increment("phone");
laptop.increment("laptop");

phone.merge(laptop);
laptop.merge(phone);
phone.merge(laptop); // マージを繰り返しても結果は変わりません
System.out.println(phone.value() + " " + laptop.value());
3 3

減少もサポートしたい場合は、PN-Counterを使用します。2つのG-Counter、1つは増加用、1つは減少用で、値はこの2つのカウンターの差です。

4.2. LWW-Register:「最後の書き込みが勝つ」値

Customer.displayNameのような単一フィールドの場合、最も簡単な方法は最新のタイムスタンプを持つ値を保つことです。タイムスタンプが同じ場合は、結果が常に決定的になるようにreplicaIdで処理します。

public record LwwRegister<T>(T value, long timestamp, String replicaId) {

    public LwwRegister<T> merge(LwwRegister<T> other) {
        if (other.timestamp != timestamp) {
            return other.timestamp > timestamp ? other : this;
        }
        return other.replicaId.compareTo(replicaId) > 0 ? other : this;
    }
}
var a = new LwwRegister<>("Lan", 100, "phone");
var b = new LwwRegister<>("Lan Nguyễn", 105, "laptop");
System.out.println(a.merge(b).value() + " | " + b.merge(a).value());
Lan Nguyễn | Lan Nguyễn

注意:デバイス上の時計はしばしばずれています。マシンが数分進んでいると、毎回「勝つ」ことになります。実際には、純粋な物理的時刻の代わりにHybrid Logical ClockまたはLamport clockを使用することがよくあります。

LWWは簡単ですが、2つの編集のうち1つを静かに失うでしょう。協業テキスト編集では、このアプローチは受け入れられません。その場合は、RGAなどの文字列用のCRDT、またはAutomergeとYjsが使用しているアルゴリズムが必要です。

4.3. OR-Set:追加/削除可能なセット

共有ショッピングリストがあるとします。素朴な実装はバグります。1つのマシンが「ミルク」を削除し、別のマシンが同時に「ミルク」を再度追加した場合。Observed-Remove Setは各addに一意のタグを付けることで解決します。removeコマンドはそのレプリカが見たタグのみを削除します。

import java.util.*;

public final class ORSet {
    private final Map<String, Set<String>> adds = new HashMap<>();
    private final Map<String, Set<String>> removes = new HashMap<>();

    public void add(String item) {
        adds.computeIfAbsent(item, k -> new HashSet<>()).add(UUID.randomUUID().toString());
    }

    public void remove(String item) {
        removes.computeIfAbsent(item, k -> new HashSet<>())
               .addAll(adds.getOrDefault(item, Set.of()));
    }

    public Set<String> elements() {
        Set<String> result = new TreeSet<>();
        adds.forEach((item, tags) -> {
            Set<String> alive = new HashSet<>(tags);
            alive.removeAll(removes.getOrDefault(item, Set.of()));
            if (!alive.isEmpty()) result.add(item);
        });
        return result;
    }

    public void merge(ORSet other) {
        union(adds, other.adds);
        union(removes, other.removes);
    }

    private static void union(Map<String, Set<String>> target, Map<String, Set<String>> source) {
        source.forEach((k, v) -> target.computeIfAbsent(k, x -> new HashSet<>()).addAll(v));
    }

    public ORSetState toState() {
        return new ORSetState(adds, removes);
    }

    public static ORSet fromState(ORSetState state) {
        ORSet set = new ORSet();
        union(set.adds, state.adds());
        union(set.removes, state.removes());
        return set;
    }
}
import java.util.Map;
import java.util.Set;

public record ORSetState(Map<String, Set<String>> adds, Map<String, Set<String>> removes) {}

動作を分析します:

  • addは、以前のアイテムを追加しても、各追加に新しいUUIDを生成します。
  • removeは、アイテムの現在のタグをremovesにコピーします。このレプリカがまだ見ていない他のレプリカが追加したタグはまだ生きています。
  • elementsは、削除されていない少なくとも1つのタグを持つアイテムを返します。結果:追加と削除が同時に発生した場合、追加が勝つ(add-wins)。
  • mergeはセット結合に過ぎないため、3つの特性すべてを満たします。
  • ORSetStateは、同期時にJacksonでJSONにシリアライズするためのシンプルなレコードです。

5. Spring Bootでサーバーを介した同期

ローカルファースト・モデルではサーバーは誰が正しいかを決定しません。常にオンラインの1つのレプリカであり、デバイスが状態を交換するのを支援します。webスターター依存関係を追加します(バージョンはspring-boot-starter-parentで管理):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Controllerはクライアントから状態を受け取り、サーバーのコピーにマージしてから、マージされた状態を返します:

import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@RestController
@RequestMapping("/lists/{listId}")
public class ShoppingListSyncController {

    private final Map<String, ORSet> store = new ConcurrentHashMap<>();

    @PostMapping("/sync")
    public synchronized ORSetState sync(@PathVariable String listId,
                                        @RequestBody ORSetState clientState) {
        ORSet serverCopy = store.computeIfAbsent(listId, id -> new ORSet());
        serverCopy.merge(ORSet.fromState(clientState));
        return serverCopy.toState();
    }
}

クライアント側は逆の操作を行います。状態を送信し、状態を受け取り、ローカルコピーにmergeを呼び出します。クライアントはmergeがべき等であるため、ネットワークが不安定なときに何度も送信できます。再試行は安全です。curlで試してみます:

curl -s -X POST localhost:8080/lists/family/sync \
  -H "Content-Type: application/json" \
  -d '{"adds":{"Sữa":["t1"],"Trứng":["t2"]},"removes":{"Trứng":["t2"]}}'
{"adds":{"Sữa":["t1"],"Trứng":["t2"]},"removes":{"Trứng":["t2"]}}

クライアントは返されたstateからelements()を計算して[Sữa]を得ます。

注意:synchronizedとConcurrentHashMapはデモンストレーション用です。本番環境では、stateをデータベースに保存し、状態全体ではなく変更分(delta)のみを送信する必要があります。また、各listIdに対して認証と認可も必要です。

6. 収束動作のテスト

最も重要なテストすべきことは:2つのレプリカが同時に編集し、任意の順序でマージしても、同じ結果が得られることです。

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class ORSetTest {

    @Test
    void concurrentAddAndRemove_convergeWithAddWins() {
        ORSet phone = new ORSet();
        phone.add("Sữa");
        ORSet laptop = ORSet.fromState(phone.toState()); // 初期同期

        phone.remove("Sữa");   // オフライン:電話が削除
        laptop.add("Sữa");     // オフライン:ラップトップが再度追加
        laptop.add("Bánh mì");

        ORSet a = ORSet.fromState(phone.toState());
        a.merge(laptop);
        ORSet b = ORSet.fromState(laptop.toState());
        b.merge(phone);

        assertThat(a.elements()).isEqualTo(b.elements())
                                .containsExactly("Bánh mì", "Sữa");
    }

    @Test
    void merge_isIdempotent() {
        ORSet list = new ORSet();
        list.add("Trứng");
        list.merge(ORSet.fromState(list.toState()));
        assertThat(list.elements()).containsExactly("Trứng");
    }
}

最初のテストは、可換性とadd-wins規則を検証します。「ミルク」はラップトップがそれを電話が見たことのないタグで追加したため、まだ存在します。2番目のテストはべき等性を検証します。べき等性をテストするために、ランダムな操作シーケンスとマージ順序を生成する、プロパティベースのテストを追加する必要があります。その結果、難しい収束バグをキャッチできます。

7. よくあるエラーと使用しない場合

よくあるエラー:

  • LWWにシステムクロックを使用してクロックスキューを処理しないこと。結果は「新しい」データが「古い」データで上書きされます。
  • メタデータが膨らむ:OR-Setは削除されたアイテムのタグ(トンボストーン)を永遠に保持します。ガベージ収集メカニズムが必要ですが、これは通常、すべてのレプリカが削除操作を受け取ったことを確認する必要があります。
  • 収束=ビジネス正確と思うこと:CRDTはすべてのレプリカが同じであることのみを保証し、結果がユーザーの意図と合致することは保証しません。2人が同時にタイトルを変更した場合、LWWはまだ1つのバージョンを失います。
  • スキーママイグレーション計画がない:データはデバイスに長く保存されるため、古いバージョンと新しいバージョンのアプリは互いのstateを読める必要があります。

ローカルファースト・ソフトウェアとCRDTを使用しない場合:

  • グローバル制約が必須:口座残高はマイナスにならない、在庫は売却できない、各座席は1人のみが予約できます。これらの不変条件は集中型協調(トランザクション、ロック)が必要で、CRDTはこれらを保持できません。
  • データを厳密に制御する必要がある:規制がデバイスへのデータ保存を禁止するか、アクセス権をすぐに取り消す必要があります。
  • シンプルなCRUD app、常にオンライン:複雑さの上乗せはメリットより大きい。REST APIと楽観的ロック(optimistic locking)で十分です。
  • 超大容量データまたは多数のユーザーでの共有:各デバイスに状態全体を同期することは実行不可能です。

実践的な方法は通常ハイブリッドです:メモ、リスト、下書きのような協業部分はローカルファースト・ソフトウェアを使用します。支払いなどの取引部分はPaymentService経由でサーバーに行き、厳格な制約があります。

8. まとめ

ローカルファースト・ソフトウェアはデータをまずユーザーのデバイスに置き、アプリが即座に反応してオフラインで実行できるようにします。CRDTは複数のレプリカが同時に編集してから自動的に収束することを可能にする技術基盤です。merge関数が可換、結合、べき等の特性のおかげです。

この記事では、Java 21でG-Counter、LWW-Register、OR-Setを実装し、Spring Bootで同期エンドポイントを構築し、収束動作をテストするコードを書きました。実製品の場合は、最初から書く代わりに、検証済みのCRDTライブラリを使用することを検討してください。ローカルファースト・ソフトウェアを、それから本当に恩恵を受ける部分にのみ適用し、グローバル制約を必要とするビジネスロジックはサーバーに保つようにしましょう。

ローカルファーストCRDTオフラインファースト分散システムSpring Boot