私たちはオフラインについて話してきました-最初にHasuraとRxDB(基本的にはPostgresとPouchDBの下にあります)
この投稿は、トピックをさらに深く掘り下げていきます。これは、Postgres(中央バックエンドデータベース)およびPouchDB(フロントエンドアプリユーザー)とのCouchDBスタイルの競合解決を実装するためのディスカッションおよびガイドです。 データベース)。
これからお話しする内容は次のとおりです。
- 競合解決とは何ですか?
- アプリで競合を解決する必要がありますか?
- PouchDBとの競合の解決について説明しました
- RxDBとHasuraを使用して、pouchdb(フロントエンド)とPostgres(バックエンド)に簡単なレプリケーションと競合管理をもたらします
- Hasuraのセットアップ
- クライアント側のセットアップ
- 競合解決の実装
- ビューの使用
- postgresトリガーの使用
- Hasuraを使用したカスタムの競合解決戦略
- サーバーでのカスタム競合解決
- クライアントでのカスタム競合解決
- 結論
競合解決とは何ですか?
例としてTrelloボードを取り上げましょう。オフライン中にTrelloカードの担当者を変更したとします。その間、同僚が同じカードの説明を編集します。オンラインに戻ったら、両方の変更を確認する必要があります。ここで、両方が同時に説明を変更したとすると、この場合はどうなりますか? 1つのオプションは、単に最後の書き込みを取得することです。つまり、以前の変更を新しい変更でオーバーライドします。もう1つは、ユーザーに通知し、マージされたフィールド(git!など)でカードを更新できるようにすることです。
複数の同時変更(競合する可能性があります)を取得し、それらを1つの変更にマージするこの側面は、競合解決と呼ばれます。
優れたレプリケーション機能と競合解決機能があれば、どのような種類のアプリを構築できますか?
レプリケーションと競合解決のインフラストラクチャは、アプリケーションのフロントエンドとバックエンドに組み込むのが面倒です。しかし、セットアップが完了すると、いくつかの重要なユースケースが実行可能になります。実際、特定の種類のアプリケーションでは、レプリケーション(したがって競合の解決)がアプリの機能にとって重要です!
- リアルタイム:さまざまなデバイスでユーザーが行った変更は相互に同期されます
- 共同作業:異なるユーザーが同時に同じデータで作業します
- オフラインファースト:アプリが中央データベースに接続されていない場合でも、同じユーザーがデータを操作できます
例:Trello、Gmail、Superhuman、Googleドキュメント、Facebook、Twitterなどのメールクライアント
Hasuraを使用すると、既存のPostgresベースのアプリケーションに高性能で安全なリアルタイム機能を非常に簡単に追加できます。これらのユースケースをサポートするために、追加のバックエンドインフラストラクチャを展開する必要はありません。次のいくつかのセクションでは、フロントエンドでPouchDB / RxDBを使用し、それをHasuraと組み合わせて、優れたユーザーエクスペリエンスを備えた強力なアプリを構築する方法を学習します。
PouchDBとの競合解決について説明しました
PouchDBによるバージョン管理
RxDBがその下で使用するPouchDBには、強力なバージョン管理と競合管理メカニズムが付属しています。 PouchDBのすべてのドキュメントには、バージョンフィールドが関連付けられています。バージョンフィールドの形式は、<depth>-<object-hash>
です。 例:2-c1592ce7b31cc26e91d2f2029c57e621
。ここで、depthはリビジョンツリーの深さを示します。オブジェクトハッシュはランダムに生成された文字列です。
PouchDBのリビジョンを覗き見
PouchDBは、ドキュメントの改訂履歴を取得するためのAPIを公開しています。この方法で改訂履歴を照会できます。
todos.pouch.get(todo.id, {
revs: true
})
これにより、_revisions
を含むドキュメントが返されます 分野:
{
"id": "559da26d-ad0f-42bc-a172-1821641bf2bb",
"_rev": "4-95162faab173d1e748952179e0db1a53",
"_revisions": {
"ids": [
"95162faab173d1e748952179e0db1a53",
"94162faab173d1e748952179e0db1a53",
"9055e63d99db056a95b61936f0185c8c",
"de71900ec14567088bed5914b2439896"
],
"start": 4
}
}
ここにids
リビジョンのリビジョン(現在のものを含む)とstart
の階層が含まれています 現在のリビジョンの「プレフィックス番号」が含まれています。新しいリビジョンが追加されるたびにstart
がインクリメントされ、新しいハッシュがids
の先頭に追加されます 配列。
ドキュメントがリモートサーバーに同期されると、_revisions
および_rev
フィールドを含める必要があります。このようにして、すべてのクライアントは最終的に完全なバージョン履歴を持ちます。これは、PouchDBがCouchDBと同期するように設定されている場合に自動的に発生します。上記のプルリクエストは、GraphQLを介して同期する場合にもこれを有効にします。
すべてのクライアントが必ずしもすべてのリビジョンを持っているわけではありませんが、最終的にはすべてのクライアントが最新バージョンとこれらのバージョンのリビジョンIDの履歴を持っていることに注意してください。
競合の解決
2つのリビジョンの親が同じである場合、または2つのリビジョンの深さが同じである場合は、競合が検出されます。競合が検出されると、CouchDBとPouchDBは同じアルゴリズムを使用して勝者を自動選択します:
- 削除済みとしてマークされていない被写界深度が最も高いリビジョンを選択します
- そのようなフィールドが1つしかない場合は、それを勝者として扱います
- 1を超える場合は、リビジョンフィールドを降順で並べ替え、最初のフィールドを選択します。
削除に関する注意: PouchDBとCouchDBは、リビジョンまたはドキュメントを削除することはありません。代わりに、_deletedフラグをtrueに設定して新しいリビジョンが作成されます。したがって、上記のアルゴリズムのステップ1では、削除済みとしてマークされたリビジョンで終わるチェーンはすべて無視されます。
このアルゴリズムの優れた機能の1つは、競合を解決するためにクライアント間またはクライアントとサーバー間で調整が不要なことです。バージョンを勝者としてマークするために必要な追加のマーカーもありません。各クライアントとサーバーは独立して勝者を選びます。ただし、同じ決定論的アルゴリズムを使用しているため、勝者は同じリビジョンになります。クライアントの1つにいくつかのリビジョンが欠落している場合でも、最終的にそれらのリビジョンが同期されると、同じリビジョンが勝者として選択されます。
カスタムの競合解決戦略の実装
しかし、別の紛争解決戦略が必要な場合はどうでしょうか。たとえば、「フィールドによるマージ」-2つの競合するリビジョンがオブジェクトの異なるキーを変更した場合、両方のキーを使用してリビジョンを作成することで自動マージします。 PouchDBでこれを行うための推奨される方法は、次のとおりです。
- 任意のチェーンでこの新しいリビジョンを作成します
- 他の各チェーンに_deletedをtrueに設定してリビジョンを追加します
マージされたリビジョンは、上記のアルゴリズムに従って自動的に勝者のリビジョンになります。サーバーまたはクライアントのいずれかでカスタム解決を行うことができます。リビジョンが同期されると、すべてのクライアントとサーバーは、マージされたリビジョンを勝者のリビジョンとして認識します。
HasuraおよびRxDBとの競合解決
上記の競合解決戦略を実装するには、Hasuraがリビジョン履歴も保存し、RxDBがGraphQLを使用して複製しながらリビジョンを同期する必要があります。
Hasuraのセットアップ
前の投稿のTodoアプリの例を続けます。 Todosテーブルのスキーマを次のように更新する必要があります。
todo (
id: text primary key,
userId: text,
text: text, <br/>
createdAt: timestamp,
isCompleted: boolean,
deleted: boolean,
updatedAt: boolean,
_revisions: jsonb,
_rev: text primary key,
_parent_rev: text,
_depth: integer,
)
追加のフィールドに注意してください:
-
_rev
レコードのリビジョンを表します。 -
_parent_rev
レコードの親リビジョンを表します -
_depth
リビジョンツリーのレコードの深さです -
_revisions
レコードの改訂の完全な履歴が含まれています。
テーブルの主キーは(id
、_rev
。
厳密に言えば、必要なのは_revisions
だけです。 他の情報はそこから導き出すことができるので、フィールド。ただし、他のフィールドをすぐに利用できるようにすると、競合の検出と解決が容易になります。
クライアント側のセットアップ
syncRevisions
を設定する必要があります レプリケーションの設定中にtrueに
async setupGraphQLReplication(auth) {
const replicationState = this.db.todos.syncGraphQL({
url: syncURL,
headers: {
'Authorization': `Bearer ${auth.idToken}`
},
push: {
batchSize,
queryBuilder: pushQueryBuilder
},
pull: {
queryBuilder: pullQueryBuilder(auth.userId)
},
live: true,
liveInterval: 1000 * 60 * 10,
deletedFlag: 'deleted',
syncRevisions: true,
});
...
}
また、テキストフィールドlast_pulled_rev
を追加する必要があります RxDBスキーマに。このフィールドは、サーバーからフェッチされたリビジョンをサーバーにプッシュバックすることを回避するために、プラグインによって内部的に使用されます。
const todoSchema = {
...
'properties': {
...
'last_pulled_rev': {
'type': 'string'
}
},
...
};
最後に、プル&プッシュクエリビルダーを変更して、リビジョン関連の情報を同期する必要があります
プルクエリビルダー
const pullQueryBuilder = (userId) => {
return (doc) => {
if (!doc) {
doc = {
id: '',
updatedAt: new Date(0).toUTCString()
};
}
const query = `{
todos(
where: {
_or: [
{updatedAt: {_gt: "${doc.updatedAt}"}},
{
updatedAt: {_eq: "${doc.updatedAt}"},
id: {_gt: "${doc.id}"}
}
],
userId: {_eq: "${userId}"}
},
limit: ${batchSize},
order_by: [{updatedAt: asc}, {id: asc}]
) {
id
text
isCompleted
deleted
createdAt
updatedAt
userId
_rev
_revisions
}
}`;
return {
query,
variables: {}
};
};
};
ここで、_revフィールドと_revisionsフィールドをフェッチします。アップグレードされたプラグインは、これらのフィールドを使用してローカルのPouchDBリビジョンを作成します。
プッシュクエリビルダー
const pushQueryBuilder = doc => {
const query = `
mutation InsertTodo($todo: [todos_insert_input!]!) {
insert_todos(objects: $todo){
returning {
id
}
}
}
`;
const depth = doc._revisions.start;
const parent_rev = depth == 1 ? null : `${depth - 1}-${doc._revisions.ids[1]}`
const todo = Object.assign({}, doc, {
_depth: depth,
_parent_rev: parent_rev
})
delete todo['updatedAt']
const variables = {
todo: todo
};
return {
query,
variables
};
};
アップグレードされたプラグインでは、入力パラメーターdoc
_rev
が含まれるようになりました および_revisions
田畑。 GraphQLクエリでHasuraに渡します。フィールド_depth
を追加します 、_parent_rev
doc
へ そうする前に。
以前は、アップサートを使用してtodo
を挿入または更新していました ハスラの記録。これで、各バージョンが新しいレコードになるため、代わりに単純な古い挿入ミューテーションを使用します。
競合解決の実装
2つの異なるクライアントが競合する変更を行った場合、両方のリビジョンが同期され、Hasuraに存在します。両方のクライアントは、最終的に他のリビジョンも受け取ります。 PouchDBの競合解決戦略は決定論的であるため、両方のクライアントが「勝利したリビジョン」と同じバージョンを選択します。
サーバー上でこの勝利のリビジョンをどのように見つけることができますか? SQLで同じアルゴリズムを実装する必要があります。
PostgresでのCouchDBの競合解決アルゴリズムの実装
ステップ1:削除済みとしてマークされていないリーフノードを見つける
これを行うには、子リビジョンを持つバージョンと、削除済みとしてマークされているバージョンを無視する必要があります。
SELECT
id,
_rev,
_depth
FROM
todos
WHERE
NOT EXISTS (
SELECT
id
FROM
todos AS t
WHERE
todos.id = t.id
AND t._parent_rev = todos._rev)
AND deleted = FALSE
ステップ2:最大の深さのチェーンを見つける
上記のクエリの結果がleafsというテーブル(またはviewまたはwith句)にあると仮定すると、最大の深さのチェーンは簡単です。
SELECT
id,
MAX(_depth) AS max_depth
FROM
leaves
GROUP BY
id
ステップ3:最大深度が等しいリビジョンの中から勝者のリビジョンを見つける
ここでも、上記のクエリの結果がmax_depthsというテーブル(またはビューまたはwith句)にあると仮定すると、次のように勝利のリビジョンを見つけることができます。
SELECT
leaves.id,
MAX(leaves._rev) AS _rev
FROM
leaves
JOIN max_depths ON leaves.id = max_depths.id
AND leaves._depth = max_depths.max_depth
GROUP BY
leaves.id
勝利したリビジョンでビューを作成する
上記の3つのクエリをまとめると、次のように、受賞したリビジョンを示すビューを作成できます。
CREATE OR REPLACE VIEW todos_current_revisions AS
WITH leaves AS (
SELECT
id,
_rev,
_depth
FROM
todos
WHERE
NOT EXISTS (
SELECT
id
FROM
todos AS t
WHERE
todos.id = t.id
AND t._parent_rev = todos._rev)
AND deleted = FALSE
),
max_depths AS (
SELECT
id,
MAX(_depth) AS max_depth
FROM
leaves
GROUP BY
id
),
winning_revisions AS (
SELECT
leaves.id,
MAX(leaves._rev) AS _rev
FROM
leaves
JOIN max_depths ON leaves.id = max_depths.id
AND leaves._depth = max_depths.max_depth
GROUP BY
(leaves.id))
SELECT
todos.*
FROM
todos
JOIN winning_revisions ON todos._rev = winning_revisions._rev;
Hasuraはビューを追跡し、GraphQLを介してそれらをクエリできるため、受賞したリビジョンを他のクライアントやサービスに公開できるようになりました。
ビューにクエリを実行するたびに、Postgresはビューをビュー定義のクエリに置き換え、結果のクエリを実行します。ビューを頻繁にクエリすると、CPUサイクルが大量に浪費される可能性があります。 Postgresトリガーを使用し、勝ったリビジョンを別のテーブルに保存することで、これを最適化できます。
Postgresトリガーを使用して勝利リビジョンを計算する
ステップ1:新しいテーブルtodos_current_revisionsを作成します
スキーマはtodos
のスキーマと同じになります テーブル。ただし、主キーはid
になります (id, _rev)
の代わりに列
ステップ2:Postgresトリガーを作成する
ビュークエリから開始することで、トリガーのクエリを記述できます。トリガー関数は一度に1行ずつ実行されるため、クエリを簡略化できます。
CREATE OR REPLACE FUNCTION calculate_winning_revision ()
RETURNS TRIGGER
AS $BODY$
BEGIN
INSERT INTO todos_current_revisions WITH leaves AS (
SELECT
id,
_rev,
_depth
FROM
todos
WHERE
NOT EXISTS (
SELECT
id
FROM
todos AS t
WHERE
t.id = NEW.id
AND t._parent_rev = todos._rev)
AND deleted = FALSE
AND id = NEW.id
),
max_depths AS (
SELECT
MAX(_depth) AS max_depth
FROM
leaves
),
winning_revisions AS (
SELECT
MAX(leaves._rev) AS _rev
FROM
leaves
JOIN max_depths ON leaves._depth = max_depths.max_depth
)
SELECT
todos.*
FROM
todos
JOIN winning_revisions ON todos._rev = winning_revisions._rev
ON CONFLICT ON CONSTRAINT todos_winning_revisions_pkey
DO UPDATE SET
_rev = EXCLUDED._rev,
_revisions = EXCLUDED._revisions,
_parent_rev = EXCLUDED._parent_rev,
_depth = EXCLUDED._depth,
text = EXCLUDED.text,
"updatedAt" = EXCLUDED."updatedAt",
deleted = EXCLUDED.deleted,
"userId" = EXCLUDED."userId",
"createdAt" = EXCLUDED."createdAt",
"isCompleted" = EXCLUDED."isCompleted";
RETURN NEW;
END;
$BODY$
LANGUAGE plpgsql;
CREATE TRIGGER trigger_insert_todos
AFTER INSERT ON todos
FOR EACH ROW
EXECUTE PROCEDURE calculate_winning_revision ()
それでおしまい!これで、サーバーとクライアントの両方で勝者のバージョンをクエリできます。
カスタム競合解決
次に、HasuraとRxDBを使用したカスタムの競合解決の実装を見てみましょう。
サーバー側でのカスタム競合解決
ToDoをフィールドごとにマージしたいとしましょう。どうすればこれを行うことができますか?以下の要点はこれを示しています:
<!-kg-card-begin:html-> <!-kg-card-end:html->
そのSQLは多くのように見えますが、実際のマージ戦略を扱う唯一の部分は次のとおりです。
CREATE OR REPLACE FUNCTION merge_revisions (item1 jsonb, item2 jsonb)
RETURNS jsonb
AS $$
BEGIN
IF NOT item1 ? 'id' THEN
RETURN item2;
ELSE
RETURN item1 || (item2 -> 'diff');
END IF;
END;
$$
LANGUAGE plpgsql;
CREATE OR REPLACE AGGREGATE agg_merge_revisions (jsonb) (
INITCOND = '{}',
STYPE = jsonb,
SFUNC = merge_revisions
);
ここでは、カスタムのPostgres集計関数agg_merge_revisions
を宣言します。 要素をマージします。これが機能する方法は、「reduce」関数に似ています。Postgresは集計値を'{}'
に初期化します。 、次にmerge_revisions
を実行します 現在の集計とマージされる次の要素で機能します。したがって、マージする3つの競合するバージョンがある場合、結果は次のようになります。
merge_revisions(merge_revisions(merge_revisions('{}', v1), v2), v3)
別の戦略を実装する場合は、merge_revisions
を変更する必要があります 働き。たとえば、「最後の書き込みが勝つ」戦略を実装する場合は、次のようになります。
CREATE OR REPLACE FUNCTION merge_revisions (item1 jsonb, item2 jsonb)
RETURNS jsonb
AS $$
BEGIN
IF NOT (item1 ? 'id') THEN
RETURN item2;
ELSE
IF (item2 -> 'updatedAt') > (item1 -> 'updatedAt') THEN
RETURN item2
ELSE
RETURN item1
END IF;
END IF;
END;
$$
LANGUAGE plpgsql;
上記の要点の挿入クエリは、挿入後のトリガーで実行して、競合が発生するたびに自動マージすることができます。
注: 上記では、SQLを使用してカスタムの競合解決を実装しました。別のアプローチは、書き込みアクションを使用することです:
- デフォルトの自動生成された挿入ミューテーションの代わりに、挿入を処理するカスタムミューテーションを作成します。
- アクションハンドラーで、レコードの新しいリビジョンを作成します。これには、Hasuraインサートミューテーションを使用できます。
- リストクエリを使用して、オブジェクトのすべてのリビジョンを取得します
- リビジョンツリーをトラバースして競合を検出します。
- マージされたバージョンを書き戻します。
このロジックをSQL以外の言語で記述したい場合は、このアプローチが魅力的です。もう1つのアプローチは、SQLビューを作成して競合するリビジョンを表示し、残りのロジックをアクションハンドラーに実装することです。これにより、競合を検出するためにビューにクエリを実行できるようになったため、上記の手順4が簡略化されます。
クライアント側でのカスタム競合解決
競合を解決するためにユーザーの介入が必要なシナリオがあります。たとえば、Trelloアプリのようなものを構築していて、2人のユーザーが同じタスクの説明を変更した場合、ユーザーに両方のバージョンを表示して、マージされたバージョンを作成させることができます。これらのシナリオでは、クライアント側で競合を解決する必要があります。
PouchDBはすでにAPIをクエリの競合するリビジョンに公開しているため、クライアント側の競合解決は実装が簡単です。 todos
を見ると 前回の投稿のRxDBコレクション。競合するバージョンを取得する方法は次のとおりです。
todos.pouch.get(todo.id, {
conflicts: true
})
上記のクエリは、_conflicts
に競合するリビジョンを入力します 結果のフィールド。その後、これらをユーザーに提示して解決することができます。
結論
PouchDBには、バージョン管理および競合管理ソリューションのための柔軟で強力な構成が付属しています。この投稿では、これらの構成をHasura/Postgresで使用する方法を示しました。この投稿では、plpgsqlを使用してこれを行うことに焦点を当てました。バックエンドで選択した言語を使用できるように、アクションでこれを行う方法を示すフォローアップ投稿を行います!
この記事を楽しんだ? HasuraとGraphQLの詳細については、Discordに参加してください。
ニュースレターに登録して、新しい記事をいつ公開するかを確認してください。