1人用Local-first softwareで使うCRDTとYjs
CRDTとは
CRDTとはConflict-free Replicated Data Typesのことで、ものすごくざっくり説明するならオンライン同時編集機能をアプリケーション実装依存せず、データ構造レベルで競合なく実現させてしまおうという欲張りな技術である。
CRDT自体についてはこの記事ではそこまで深煎りしない予定なので深煎りしたい人向けの役立ちリンクはこのあたり。
Yjsとは
CRDT自体はただの理論や考え方であって、その理論をソフトウェアとして実装としたものの1つがYjsである。
他にも有力・有名なCRDT実装はいくつかあり、例えばAutomergeもよく名前が挙がる。
作った(作っている)アプリ
Draw.ioみたいな作図ツール。この記事で紹介するYjsの使い方はほぼ全てこのアプリケーションを作ることを前提に検証と実践をしてきたものであることに注意。いくらCRDTがアプリケーションに依存しないとはいえ、それをどう活用するかは結局のところアプリケーション次第で、その用途目的によって千差万別となる。
アプリ自体のことについては以前記事にしたのでここではリンクだけ。
当初の導入理由
おそらくほとんどの場合でYjsの導入するモチベーションはCRDTによる同時編集機能の実現にあると思われる。今回のアプリでは同時編集機能の優先度は相当下げていて未だに着手目処も立っていない状態だが、将来的に導入する可能性を踏まえてYjsをコアに導入しておくメリットは大きいだろうと判断していた。
もう1つ大きな理由は、ファイルベースの保存機能を前提としつつもネットワーク越しのコラボレーションも実現してしまおうというLocal-first softwareを目指してみたかったから。
同時編集機能は既に述べたように優先度は高くない。しかしネットワーク越しのコラボレーションは時間差でも発生し得る。特に自分同士の時間差コラボレーションは個人が複数端末を使いこなす現代では頻出である
常に最新版データを端末に同期してから作業し、作業結果を最新版として常にクラウドストレージに同期することを徹底できるなら自分同士の時間差コラボレーションはただのファイルベースでも機能する。ただし残念なことにこの約束を守り続けるのは我々人間には難しすぎて、必ずどこかで枝分かれしたファイルが発生する。終わりである。
この枝分かれしたファイルの存在に対してCRDTの「競合なく一貫性のあるマージが可能」という特性が輝いてくれる。この特性により、散々に枝分かれしたファイルをどんな順番でマージしようとも競合は発生しないし結果も一意に定まる。アプリケーションの実装に依らず、データ構造レベルでこの特性が担保されている。すごい。使ってみたすぎるということで採用。
データ構造
Y.Doc
の分割
今回のアプリは1つのプロジェクトデータを複数のY.Doc
に分割している。シェイプデータを含むシートをそれぞれ別のY.Doc
に、そしてシートの名前や並び順などプロジェクト全体を構成するデータをさらに別のY.Doc
としている。
実はYjsにはSubdocumentsという仕組みがあり、Y.Doc
自体を他のY.Doc
内のデータとして管理して非同期にロードしたりできるという強力な機能が用意されている。しかし今回はこの機能は利用せず、分割したY.Doc
は自前でそれぞれ別のファイルとして管理している。利用しなかった理由は、この機能は強力故にややこしく、さらにデータ永続化を担うadapterも全てがこの機能に対応しているわけではないという注意書きが非常に気になったから。
Not all providers support subdocuments yet.
ただでさえファイルベースのY.Doc
分割管理という情報が全然出てこないことをやろうとしている状況で、Yjsのプロたちでさえ未だ持て余していそうな機能をデータ構造の中核に導入するのは気が引けてしまった。仮にSubdocuments
に頼らなかったとしても、ファイルの境界はシート毎と明確なので自前実装のイメージも十分にあり、ブラックボックスも減るだろうということで使わないことを選択。
今のところこの選択は正解だったように思う。Subdocuments
の理解度は浅いので比較はしにくいものの、分割したY.Doc
の管理で大きく詰まった箇所はなかったはず。React.jsのhooksと折り合いを付けることが最大の難関だった可能性が高い。
各Y.Doc
をファイルに保存するときはその時点での状態を丸ごと保存している。DBに保存するときと同じように更新差分を都度ファイルにappend
モードで書き込めたら無駄がなくクールではないかと期待したがそもそもブラウザのFile System APIにはappend
モードが存在していなかったので断念。さすがに毎操作ごとに保存を実行するのは気が引けたので操作頻度に応じてある程度まとめて保存を実行するようにはしている。
GCの挙動
YjsにはGCが搭載されていて、CRDTの実現に必須ではない情報はGCによって削除される。例えばY.Map
で要素を削除するとそのvalue
は保持されないことになっているが、実際にはGC発動まで保持されている。Undo/Redo機能があるのでこの挙動には納得性がある。ただ厄介なのが、GCは「Y.applyUpdate
が実行されたタイミング」で発動するようになっている。Y.encodeStateAsUpdate
で得られるデータにはGCが発動していない。
なぜこういう挙動になっているのかは正直なところ分かっていない。リアルタイムにY.Doc
の差分を交換し合っているような状況ではY.applyUpdate
するまでGC発動なしのフルデータが必要なのかもしれないが本アプリには搭載されていないため未検証。
多分そうだろうという期待と、実際にGC発動(空のY.Doc
に一度Y.applyUpdate
してから再度Y.encodeStateAsUpdate
)してからファイルに保存->それを開いたりマージしても挙動に変化がなさそうだったので本アプリではGC発動後のY.encodeStateAsUpdate
を丸ごとファイルに保存している。特に大量のデータを削除した後ではGC発動のありなしでデータ量が相当に変化するので、自動保存で頻繁にファイル保存を行う本アプリではGCを発動して可能な限りデータ量を削減しておきたい。
リアルタイムコラボレーション機能を実装する段になったらこの部分はまた見直しが必要だろうと思われる。少なくともYjsがそこをGC発動タイミングとして選んでいるのだからきっと何かしらの理由は存在するはず。
Y.Doc
とY.UndoManager
分割されたY.Doc
を分割してことに関連して考慮不足だったことは、Y.UndoManagerは複数のY.Doc
を跨っての利用ができなかったこと。例えばシート内データであるシェイプの編集をY.UndoManager
に担当させた場合、シート外データであるシート並び順の変更はそのY.UndoManager
に担当させることができない。
実装時は複数Y.Doc
を跨がせるような設定をY.UndoManager
に与えても何もエラーは出ずただサイレントにUndo/Redoに失敗していたのでこの制限に気づくのにやや時間がかかってしまった。これは確かその後改善されて今は警告なりが表示されるようになっていた気がする。
もちろん複数のY.UndoManager
を用意すればそれぞれのY.Doc
にUndo/Redoの搭載が可能ではあるが、1つの画面にUndo/Redoのスタックが2種類存在する状況は認知不可が高すぎるだろうということで採用せず。今回のアプリでは妥協案として、Undo/Redo操作は「シェイプに対してのみ可能」で、「シートを切り替えるとクリアされる」挙動となっている。なので例えば「シェイプ編集 -> シート名変更 -> Undo」と操作しても、「シート名変更」部分はUndoも/Redoもされない。あくまでも妥協案にすぎないが、引き換えに得られるシンプルさは非常に大きい。シートの追加や削除などY.Doc
自体の増減が絡む操作の履歴管理を放棄する絶好の口実になった可能性も高い。
仮にSubdocuments
を使っていたらY.Doc
横断履歴管理ができていたのかは調査していないので不明。できるのかもしれないが、永続化されたデータとの兼ね合いを考えるといずれにせよややこしいだろうとは予想している。もし全ての操作を絶対に履歴として管理したいんだという場合、やはりY.Doc
の分割はせず1つの巨大なY.Doc
に全てを記録していく方法が最善と思われる。
Y.Map
entity storeとしてのシートやシェイプのようなentityはY.Map
で保存している。Y.Map
の一番の利点はやはりその分かりやすさにある。Yjs側の実装はほぼ完全に隠蔽されているため、アプリケーションからはMap
同等の使い心地になる。
一方でY.Map
にはデータ量が膨れやすいという明確なデメリットがある。一見ただのMap
なのだから保存されているデータ量もその分だけに見えてしまうが、内部的にはkey
毎にY.Array
を保持する形で実装されていたりと複雑めな構造となっている。
第一に、Y.Map
はkey
を削除してもそのkey
は削除フラグと共に保持され続ける。value
側は廃棄されるので個別の増加量は少なめだが、一度でもset
したkey
分の容量が単調増加し続けるという状況はあまり気持ちの良いものではない。
第二に、Y.Map
はset
回数に応じてデータ量が増える。当たり前にも聞こえるが、これは「新規のset
」だけでなく「既存key
に対するset
」でも増える。つまり普通のMap
気分でランダムアクセスしながらset
を繰り返すようなユースケースでは思わぬデータ量増大に遭遇する可能性が非常に高い。
この事実は当初把握していなかったものの、幸いなことに今回はY.Map
に保存するentityそれぞれもY.Map
として各プロパティ単位で変更する構造を採用していたためある程度は回避できている。
この構成だと全体のY.Map
に対するset
はシェイプを新規作成したときだけ行われる。シェイプのプロパティを変更した場合は、全体のY.Mpa
ではなく個別entityのY.Map
に対してset
が行われる。もちろん個別entityのY.Map
にてset
の度にデータ量増大が起こることになるが、少なともここのkey
数は予め定めたシェイプのプロパティ数に収まっているため大きな問題には繋がりにくい(多分)。ちなみにこの構造は「シェイプのプロパティ単位で同時編集可能」を実現するために採用したもので、それが問題回避に役立ったのは完全に偶然だったりする。たとえば別々の環境で同じシェイプの位置とサイズをそれぞれ変更しても、マージときには両方の変更が消えることなく統合される。Y.Map
をネストせずシェイプをそのままvalue
として持っていた場合は「シェイプ」が同時編集の最小単位となるためいずれかの編集が消滅する。
Yjs作者もY.Map
に大量のkey
をset
することを推奨しておらず、そのケースにはY.Array
の利用を促している(2021年のforum情報)。
Y.Map
のこうした問題を軽減した改良版としてYKeyValueがutilityとして提供されているので検討余地がある。Y.Array
を独自にwrappしてY.Map
相当のインタフェースを自作するという手もある。
データ量増大はもちろん回避したいのでY.Map
からこうした代替手段への乗り換えも検討したが未だ採用には至っていない。いくら代替とはいえやはり100%互換ではなく、データ変更を通知してくれるobserve
ハンドラの便利さなどがY.Map
に及ばない。削除されたentityが持っていたid
がどうにも分からないなどの問題が確かあった覚えがあるが記憶は若干怪しい。今回のようにY.Map
をネストしたいようなユースケースに対応できるかどうかも怪しそうだった覚えがある。そして何よりY.Map
という(使い手から見た)構造の分かりやすさが捨て難い。将来的にデータ構造変更とそれに伴うマイグレーションの手間がかかってしまったとしても、現状ベストアンサーがないのなら素直に標準として提供されているY.Map
を使っておくでいいだろうと考えそのまま使っている。少なくとも不特定多数のkey
が不特定多数回set
される状況は回避できているので許容範囲だろうと思われる。
データ量が膨大になった場合に備えてアプリケーションの特性を活かした回避手段も用意できている。例えばY.Doc
はシート毎に別れているので、新規シートにシェイプを丸ごとコピペすれば内部に溜まった不要データは全てクリアされる。他にもシートを切り替えることなく、既存シートの不要データをクリアする機能をアプリ内に用意している。Y.Doc
自体にはクリア機能がないので、新規にY.Doc
を作成してデータをまるっと移し替える実装がアプリ側で必要であった。もちろんこれらの操作でCRDTを実現しているメタデータも削除されて派生データとの競合なき一貫したマージは不可能となる。なのであまりにもデータが膨大になったとき用の避難ハッチ扱いである。
Y.Map
の是非についてまとめるとこのようになる。繰り返しになるが今回のアプリケーション特有の事情も多く関わっているので鵜呑みには注意。
- データ量が増え続けるのは気になるが許容できる範囲
- あまりにもデータ量が増えた場合に備えて避難ハッチを用意できる
- 代替手段よりも
Y.Map
の使いやすさと分かりやすさに利がある - 将来的に素晴らしい代替手段が登場したら頑張ってマイグレートすればいい
Fractional indexingによるentity順序制御
Yjsに搭載されている機能ではないが同時編集を前提とするデータ構造として関わりがあるのでFractional indexingの利用について紹介する。Fractional indexing自体についてはFigmaから素晴らしい記事が出ているのでそちらを参照。
前述のようにentityはMap
で保存しているのでそこにはentity同士の順序が保存されない。そこでentityそれぞれにindex
のようなプロパティを持たせてそれの大小を比較することで順序を復元することになる。Fractional indexingはシンプルに言ってしまえばそのindex
を(なるべく)重複なく効率的に生成してくれる仕組みである。
単純にfloat
な数値を1/2にしていく方法もFractional indexingの実装の1つである。この方法はfloat
精度の上限に意外と早く到達してしまうらしい。今回採用したのはテキストベースのFractional indexing、例えばAa
とAc
の中間としてAb
を生成するような手法で、こちらではfloat
版が持つ精度上限デメリットが改善されている。もちろんキー長に応じた容量を食うので文字通り無限のFractionalというわけにはいかないものの、人間の操作を前提とした用途では十分だと思われる。
当初は名前の通りfractional-indexingというライブラリを採用していた。
しかし素朴なFractional indexingは入力に対して出力が一定で、それは当たり前なのだが、アプリケーションの汚い世界では出力が綺麗すぎる故に同じインデックスを持つentityがどうしても生まれてしまう。
同じ入力に対してFractional indexingを満たしつつ多少のランダム性を付与してくれるような都合の良い仕組みはないものかと他人任せに日々を過ごしていたらなんとあった。しかも先に導入していたfractional-indexingリスペクトのインタフェースを用意してくれているので移行もお手軽。
ランダム性が付与されたことでさらに重複は発生しにくくなるものの可能性がゼロになるわけではない。ただ今回のアプリでは重複が発生して多少entityの順序が不定になったとしてもきっと橋は崩壊しないので特に対策はしていない。できないわけではないのでもちろんやるべきなのだがさぼっている、いつかやる。自動での重複解消が厳しいとしても「ここ重複してるよ」的な表示をして手動での解消を促しておくべだろう、いつかやる。
Y.Array
ベースでentityを保存した場合にFractional indexingが必要になるかどうかは不明。Array
なので要素同士にはもちろん順序があって利用可能だが、CRDTのArray
は要素の入れ替えに弱いことが多々ある。Y.Array
も例外ではなく要素の「追加」と「削除」で実装されているため、要素の入れ替えは実質「追加」と「削除」の組み合わせになる。ここに同時編集が発生すると「追加」が複数回認識されて要素が重複する。深く検証はしていないがY.Array
にも特有の難しさやテクニックがあると思われるので同じくFractional indexingを導入した方が綺麗にまとまるのかもしれない。
テキストはY.Textに
一部シェイプにはテキストを書き込むことが可能で、そのテキストはシェイプとは別の場所にY.Text
として保存している。
Y.Text
を利用することでテキスト部分もYjsの力によってCRDTが適用される。Y.Text
はQuill Deltaに対応しているためリッチテキストエディタの実装にもピッタリである。嬉しいことにQuill Deltaの標準的なオペレーションを内蔵してくれているのでアプリケーションからQuillに依存する必要もない。
Y.UndoManager
はもちろんY.Text
の変更も追跡してくれる。captureTimeout
を指定することで特定時間内に行われた操作を1まとまりのUndo/Redo対象とみなしてくれるため、テキスト編集中は1000ms
にしてある程度の操作をまとめ、それ以外では0ms
にして操作と1対1対応させるといった柔軟な使い方ができる。
さらにYjsにはY.RelativePositionという編集中のカーソル位置をいい感じに保存復元するための機能が存在する。例えばリアルタイムコラボレーションな状況で、他の誰かが同じテキストを編集したとしても自分のカーソルを「テキスト内の相対的に同じ位置」にキープすることができる。当初これはリアルタイムコラボレーションを搭載しない場合には不要な機能と思っていたが、実は同じ状況がY.UndoManager
でUndo/Redoしたときにも発生し、Y.RelativePosition
でカーソル位置を管理していなかった場合は容赦なくカーソル位置がずれてしまう。幸いにも機能として用意してくれているので、リアルタイムコラボレーションがなかったとしてもこれは最初から組み込んでおいたほうがよい。
今更だがこのあたりはテキストエディタを自作するときの話題であって、何かしらリッチテキストエディタライブラリにYjsをbindingして使う場合には気にしないでいい可能性は高い。
テキスト保存場所としてシェイプとは別のY.Map
を用意したことにはやや後悔がある。前述のようにY.Map
は追加したkey
が残り続けてしまうため、1つのシェイプに対して実質key
2つ分のデータが永続化されてしまう。シェイプ自体がY.Map
になっていることからも、その中のプロパティとしてY.Text
を用意しておく構造にしても大きな問題はなかったと考えている。FirebaseのRealtimeDB的にネストはとりあえず浅くした方がいいだろうと当時はあまり深く考えておらず、ここは後悔ポイントの1つ。
また1つのシェイプに対して1つのY.Text
という構造も実は不十分だった可能性が浮上している。例えばtitleとdescriptionの2種類のテキストを持つ単体のシェイプを作りたくなった場合にこの構造では対応できない。将来的に大規模なデータ構造変更を行うとしたらまずここがまず候補に挙がる可能性が高い。
CRDT活用例
せっかくなので本アプリケーションの数少ないCRDTならでは機能を紹介。
ワークスペースをエクスポートすることが可能となっているため、例えばGoogle Driveをクラウドストレージとして以下のようなマルチデバイス間コラボレーションが可能。
デバイスAで作成したワークスペースを作成。
ワークスペースをGoogle Driveにエクスポート。デバイスBにてGoogle Driveからワークスペースを開き、それをローカルファイルとしてエクスポート。
デバイスAとBそれぞれでワークスペースを更新。この時点では各ワークスペースは同期されておらず、「マスター」に相当するものは存在しない。
デバイスAでワークスペースを再度Google Driveにエクスポートし、続けてデバイスBでもエクスポート。エクスポート先にワークスペースが存在する場合はマージが実行される。このマージはCRDTの名のもとに行われるためGoogle Driveには両デバイスで行われた更新が競合なくマージされている。
以降は同様で、Google Driveとデバイスのワークスペースはエクスポートを通してその都度一貫性を保ったまま最新化されている。仮に最新化されていないワークスペースを編集してしまったとしても(そもそも最新のワークスペースという概念がほぼないのだが)、どこかのタイミングでエクスポートしてマージすれば全ての変更は消滅することなく合流する。
中央集権的なサーバーが存在しない都合上、クラウドストレージへのエクスポートがほぼ同じタイミングで実行されると一方のエクスポートはキャンセルされる形となってしまう。同一ユーザーが同一タイミングでそのような操作をするケースは概ね考えられないのとはいえ、ここはサーバーレスなLocal-first software故の妥協点でもある。
まとめ
リアルタイムコラボレーションを搭載しないアプリケーションでYjsを使う利点があるのかがおそらく本記事最大のテーマであるが、ここまで様々書いてきたように十分に利点があったと考えている。特にテキスト編集関連の実装はYjsの機能に大きく助けられていて、仮にコラボレーション要素が一切ないアプリケーションだったとしてもテキスト編集機能が存在する(そして自作する)なら導入価値が十分にあるかもしれない。
そしてリアルタイムコラボレーション機能は現状搭載していないというだけで、Websocketやら何やらを用意すればアプリケーション側には大きな変更なく導入することが可能なはずである。そうした実装の心配を全て先送りにしてまずはアプリケーションとしての機能実装に集中できているのはやはりYjsの存在が大きい。
ちなみに記事中の図たちは当のアプリケーションにてさくっと作成している。CRDTやYjsという重厚な仕組みはそれはそれとして、こういったちょっと作図したいんだけどな用途にこそうってつけなツールなので是非とも試してみほしい。
Discussion