リアルタイム共同編集エディタを作れるyjsを触ってみた

この記事は[エンジニアと人生コミュニティのAdvent Calender2021 #2](https://adventar.org/calendars/6522) の16日目の記事です。


[マーケット・マイニング株式会社](https://www.market-mining.tech/)の案件でリアルタイム共同編集エディタを開発するため、技術調査として [yjs](https://docs.yjs.dev/) を試しました。

ただ、yjsの日本語リソースが少なく、公式ドキュメントも執筆中のため、全体像の把握が難しかったり、私が欲しかった[データ保存関係のチュートリアル](https://docs.yjs.dev/tutorials/persisting-the-document-to-a-central-database)が作成中であったりと、必要な情報はコードやGitter等を追う必要がありました。

そこで本記事では、調査時に私が欲しかった以下の項目についてまとめます。

1. yjsの全体像・利用しているデータ構造
2. デモアプリの利用方法・アーキテクチャ
3. yjsでのデータ保存の仕組み（サーバサイド、クライアントサイド)

今後yjsを利用される方の助けになれば幸いです。
ドキュメントが充実している箇所については概要説明にとどめ、ドキュメントへのリンクを貼ります。

# yjsとは

Yjsの最も簡単な説明は、[Getting Started](https://docs.yjs.dev/getting-started/a-collaborative-editor)にある以下のものです。

> Yjs is a modular framework for syncing things in real-time - like editors!

Yjsは、Google Docsのようなリアルタイム共同編集エディタの実現に利用できる、リアルタイムにデータをクライアント間で同期するフレームワークです。yjsが利用されている有名なサービスとしては、[Jupyterlab](https://jupyterlab.readthedocs.io/en/stable/)のリアルタイムコラボレーション機能があります。
[Real Time Collaboration — JupyterLab 3.2.4 documentation](https://jupyterlab.readthedocs.io/en/stable/user/rtc.html)

yjsは、大まかに言えば２つの機能を提供しています。

1. CRDTの実装
2. ライブラリを用いたコンポーネントの切り替え

## CRDTの実装

### CRDTとは

以下の本の日本語版が要約されていたので、それを抜粋します。(正式に[CRDTを定義付けた論文](https://pages.lip6.fr/Marc.Shapiro/papers/RR-7687.pdf)はこちらです。)

> CRDTs(Conflict-free replicated datatypes)は、集合、マップ、順序付きリスト、カウンタといったデータ構造のファミリであり、複数のユーザーから並行して編集可能であるとともに、実用的な方法で自動的に衝突を解決してくれます。
"[データ指向アプリケーションデザイン](https://www.oreilly.co.jp/books/9784873118703/) p.186 5章 レプリケーション" より抜粋

wikipediaにも項目があります。
https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type

リアルタイム共同編集エディタでは、同じドキュメントに複数のユーザーが同時に編集を行います。その際、ドキュメントの同期を取ったり、編集内容に衝突が起こるのでその解決が必要です。その問題を、編集単位を小さなデータの単位に分けて各クライアントに共有し、結果整合性を使って解決するアルゴリズムを持つ共有データ型という印象です。その実装のためのアルゴリズムは種々提案されており、yjsでは[YATA](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types)というアルゴリズムを実装しています。

### yjsにおける実装

yjsにおいてエディタを構築する場合、小さな単位(1文字ごとの挿入・削除等の操作)を１つの変更履歴として、ノード間で共有し、その変更履歴を積み重ねることでドキュメントを構成します。

以下の例はABCという文字列を書き込む際のデータ例です。操作タイミングを表すclockを1ずつ増やしながら、A, B, Cという文字を挿入したイベントを表しています。yjsは、基本的には双方向リンクリストで、左から右へ書かれた文章を一つの単位として編集履歴データを保存します。もし変更や削除があったら、それは別の単位として保存します。

![スクリーンショット 2021-12-07 18.33.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1584/ad5daa42-056a-de37-dd65-e4e1b8303af9.png)
抜粋: [Are CRDTs suitable for shared editing?](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/)　

yjsで扱うドキュメント(YDoc)は、このような変更履歴を積み重ねたものだ、というイメージです。

#### 変更履歴を積み重ねることの、スケールへの懸念

ただ、変更履歴を積み上げていくのであれば、追加や削除が増えるとデータ量が膨大になり、ドキュメントとして展開するのが遅いのでは、という懸念があると思います。

その点は工夫があり、左から右へ文章を書く場合や、文章をコピペで追加した場合は、1文字ずつではなくコピペ文全体を１つの編集履歴としてメモリ効率よく保持するようです。上の画像の例でいうと、左から右に順に書いただけなので、”ABC”というcontentをもつ要素が１つだけあるように縮約されます。

これらの最適化により104,852文字(182,315文字追加、77,463文字削除)の17ページの論文原稿を20msでロードできた、とyjs作者の記事にあります。ここで説明した内容について詳しくはそちらをご覧ください。

[Are CRDTs suitable for shared editing?](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/)

また、yjs作者による他のCRDTライブラリとのベンチマークも用意されています。こちらも興味があればご確認ください。

https://github.com/dmonad/crdt-benchmarks


### Text, Array, Map, Set等のデータ構造

また、データ型はText以外もArrayやMap, Set等が用意されているため、エディタ以外のリアルタイム共同編集サービスを構築する基盤として利用できるでしょう。ダイアグラムや3Dデータの編集サービス等も例として上げられています。詳細は、公式ドキュメントの[Shared Types](https://docs.yjs.dev/getting-started/working-with-shared-types)に記載されています。


## ライブラリを用いたコンポーネントの切り替え

色々な状況に対応できるエディタを作れるよう、yjsはモジュール化のアプローチを取っており、
エディタ統合や通信方式がライブラリになっているので、開発者の問題に合わせて置き換えたり、開発しやすくなっています。

個々のライブラリは、githubのyjs organization以下に、y-prefixを持ったリポジトリに保持されています。

https://github.com/yjs

例えば以下のような形です。

* Quillエディタと統合できるライブラリ: [y-quill](https://github.com/yjs/y-quill)
* WebSocketで接続するネットワークライブラリ: [y-websocket](https://github.com/yjs/y-websocket)

### ライブラリがあるエディタ

* ProseMirror
* TipTap
* Monaco
* Quill
* CodeMirror
* Remirror

リアルタイム共同編集エディタをメインのユースケースと考えているため、既存のエディタライブラリをyjsと組み合わせるためのライブラリが用意されています。

ドキュメントでは[Editor Binding](https://docs.yjs.dev/ecosystem/editor-bindings)という節でそれぞれについて説明しています。

### ライブラリがある通信方式

* websocket
* webrtc
* dat

y-webrtcライブラリを使うと、P2Pで通信することができます。
ただし、デモアプリではWebSocketを利用しているため、以下の説明ではWebSocketを通信方式として用います。

ドキュメントでは[Connection Provider](https://docs.yjs.dev/ecosystem/connection-provider)という節でそれぞれについて説明しています。


### ライブラリがあるDB

* サーバーサイドでのデータ保存用のDB
  * leveldb
  * redis
* クライアントサイドでのデータ保存用のDB
  * indexeddb

データベース用のライブラリは、サーバサイド用とクライアントサイド用で分かれている点に注意が必要です。
以下では、サーバサイドでのデータ保存として、デモアプリにleveldbを組み合わせる例を説明します。

ドキュメントでは[Database Provider](https://docs.yjs.dev/ecosystem/database-provider)という節でそれぞれについて説明しています。


# デモアプリの動かし方

デモアプリは以下のrepoにあります。
https://github.com/yjs/yjs-demos

デモアプリは以下のようなアーキテクチャで動きます。

![スライド2.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1584/e10c56b6-4a78-e604-af6e-59ad105943ca.jpeg)

実際にデモアプリを動かしてみましょう。

```bash
git clone git@github.com:yjs/yjs-demos.git
cd yjs-demos/quill
npm install
npm start
# => http://localhost:8080/dist/quill.htmlがブラウザで開く
```

ブラウザが開き、以下のようにデモ用のエディタが表示されます。

![スクリーンショット 2021-12-07 16.32.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1584/32567d44-f4df-a888-4b63-a4e25cbc5c1d.png)


エディタ上には他のユーザーが書き込んだデータが表示されています。
これは、websocketでyjsが用意したデモ用のwebsocketサーバに接続し、他のユーザーの書き込んだデータを取得しているためです。

cloneしてきたrepoのquill/quill.jsにある[デモ用のコード](https://github.com/yjs/yjs-demos/blob/main/quill/quill.js#L13) を確認してみましょう。

```js
window.addEventListener('load', () => {
  const ydoc = new Y.Doc()
  const provider = new WebsocketProvider('wss://demos.yjs.dev', 'quill-demo-2', ydoc) // ここで、demos.yjs.devでホストされているwebsocketサーバへ接続している
  ...
})
```

何か書き込んだあとリロードすると、書き込んだ結果が再度表示されることが確認できます。
これはキャッシュではなく、websocket経由で他のノードからデータを受け取っているためです。

また2つブラウザを開いて片方で編集すると、別の片方に編集結果がすぐ反映されるのが確認できます。

## デモアプリにおける周辺ライブラリの動作手順

デモアプリのjsコードにおいて、どのように周辺ライブラリが連携して動作しているのでしょうか。
以下のような流れで動作しています。

![スライド5.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1584/99b77e0f-5d24-2220-6cb2-ec610b2d8c91.jpeg)

y-xxx系のライブラリは、基本的にイベントを組み合わせて動作しています。

1. エディタライブラリQuill.jsで描画されたエディタ内でテキストを入力します。
2. y-quillは、初期化時にQuill.jsの`editor-change`イベントを監視します。([コードへのリンク](https://github.com/yjs/y-quill/blob/master/src/y-quill.js#L104-L145)) イベントを検知したら、yText.applyDelta()関数をコールし、yDocへ更新を反映します。
3.　yText.applyDelta()関数は、更新が完了するとyjs内部で`update`イベントを送出します。 
4. y-websocketは、初期化時にyjsの`update`イベントを監視します。　([コードへのリンク](https://github.com/yjs/y-websocket/blob/master/src/y-websocket.js#L259-L267)) イベントを検知したら、WebSocketサーバへ変更のデータを送ります。

この例では、テキスト入力を起点にした場合ですが、逆に、他クライアントからの変更をWebSocket経由で受け取る場合も、同じような形で実現されています。


# WebSocketサーバをローカルで起動

先程は、yjsが用意したデモ用のWebSocketサーバに接続しました。次にローカルでwebsocketサーバを立てて、そちらに接続することを試してみましょう。以下のようなアーキテクチャになります。図の一番上のWebSocketサーバ部分を自前で立てるということになります。

![スライド3.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1584/9c704319-f0f9-54ca-1752-b5dbec49298b.jpeg)


websocketサーバのコードも同じyjs-demo repoのdemo-server以下に保持されています。

```bash
# 一つ前のquillフォルダ以下で起動しているnpm startを止めているとする
# demo-serverフォルダへ移動
cd ./demo-server
npm install
npm start
# => localhost:8080でwebsocketサーバが起動する
```

次に、デモ用のクライアントコード(quill/quill.js)を編集して、ローカルのWebSocketサーバへ接続するように変更してみます。

```js
window.addEventListener('load', () => {
  const ydoc = new Y.Doc()
  const provider = new WebsocketProvider('ws://localhost:8080', 'quill-demo-2', ydoc) // 第1引数をws://localhost:8080に変更
  ...
})
```

変更後、quillフォルダ以下で再度ビルドしてwebpack-dev-serverを起動させます。

```bash
cd quill
npm start
# => http://localhost:8081/dist/quill.htmlがブラウザで開く
```

WebSocketサーバは他に接続しているクライアントがおらず、既存のデータを受け渡せないので、初期状態である"Start collaborating..."とだけ表示される状態になりました。

この状態でブラウザを２つ開いて書き込むと、その内容が即座に反映されます。


## WebSocketサーバが落ちるとどうなるか

このとき、WebSocketサーバが落ちるとどうなるでしょうか。タブでyjsを開いている状態によって、少し興味深い動きをします。

WebSocketサーバとの接続が切れた後、ブラウザをすべて閉じていたら、js実行環境内に残っていたドキュメントのデータは失われます。WebSocketサーバを再起動後、ブラウザで確認しても、ドキュメントデータのない初期状態("Start collaborating..."が表示される)に戻ります。

ですが、もしブラウザで１つでもクライアントコードを実行しているタブを残した状態でwebsocketサーバを再起動すると以下のような状況が起こります。
WebSocketサーバとの接続が切れると、yjsのクライアントコードは再接続を行おうとします。
ドキュメントのデータはまだブラウザのjs実行環境の中に保持されているので、WebSocketサーバが再起動し、クライアントがWebSocketサーバへの再接続が成功したら、その後開いた他のクライアントはドキュメントをロードできます。


# データのサーバサイド保存

デモ用のWebSocketサーバは今のところドキュメントのデータを保存しないので、WebSocketサーバが落ちユーザーがブラウザのタブを閉じてしまったら、ドキュメントは失われてしまいます。

そうなるとエディタとしては機能しませんので、データを保持するDBが必要になります。DBを用意しましょう。
WebSocketサーバは、WebSocket用ライブラリであるy-websocketを利用して動いていましたが、そのコードにy-leveldbライブラリを利用してサーバサイドの特定のパスにLevelDBを用いてデータを保存する機能が用意されています。

サーバサイドでデータを保存する場合のアーキテクチャは以下のようになります。

![スライド4.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1584/125c363e-6f60-0ec6-c386-29352961c292.jpeg)


起動していたWebSocketサーバを止め、以下の方法で再度、起動してみましょう。

```bash
# demo-serverフォルダにいるとする
YPERSISTENCE=./dbDir PRODUCTION=1 node ./demo-server.js
# YPERSISTENCE環境変数を追加すると、その変数に指定されているパスをlevelDBのストレージとして、yDocの内容を保存するようになる
```

環境引数に従ってLevelDBの用意をしている部分は以下のコードになります。
https://github.com/yjs/y-websocket/blob/master/bin/utils.js#L25-L48

[LevelDB](https://github.com/google/leveldb)は、組み込みを目的としたキーバリューストレージです。モダンブラウザに実装されているindexeddb等にも利用されています。
上記のデモで利用しているnode用パッケージである[level](https://github.com/Level/level)は、LevelDBの抽象インターフェースであるようで、[levelの関連ライブラリのリンク集repo](https://github.com/Level/awesome)によると、対応しているストレージエンジンはLevelDBだけでなく、sqlite3やDynamoDBへも保存ができるようです。

ただし、WebSocketサーバでlevelのストレージとしてDynamoDB等を利用するためには、y-websocketライブラリのコードをforkして手を加える必要があります。現状、y-websocketライブラリはLevelDBのファイルストレージを利用するようハードコードされています。


# データのクライアントサイド保存

もしオフラインな状態であっても、モダンブラウザにあるindexeddbの機能を用いて、ブラウザ内のストレージに編集途中の内容を保存することができます。yjsでは、オフライン時に編集していた内容も、オンラインに戻った際に反映できます。
このオフライン時のクライアントサイドでのデータ保存には、[y-indexeddb](https://github.com/yjs/y-indexeddb)ライブラリを利用します。

こちらの内容については、ドキュメントを読むと理解しやすいため、ドキュメントへのリンクを記載しておきます。
https://docs.yjs.dev/getting-started/allowing-offline-editing


# その他

## 実際のアプリケーション実装におけるネットワーク・DBの選択

デモアプリではデータ保存にサーバ内のファイルを利用していましたが、実際の本番稼働するアプリケーションにおいては可用性に不安があるため、Amazon DynamoDBやCloud Firestore等を利用するのが良いと思います。
DynamoDBを利用する場合は、y-websocketのコードをforkし、サーバサイドの永続化層で、[dynamo-down](https://github.com/jed/dynamo-down)を使ってDynamoDBへアクセスするよう書き直す必要があります。

Cloud Firestoreを利用する場合について、現在検討を進めています。
Firestoreにオフラインサポート等も存在するため、うまく組み合わせることができればyjs + indexeddbを利用するよりもオフラインでの編集を簡単に実装できる可能性がある上、認証等もFirebase Authenticationを使うことで楽ができるのではないかと思っています。
実装上の注意点としては、leveldbのようにWebSocketサーバ側で保存処理を行うというよりは、クライアントサイドから直接Firestoreへ保存を行い、ネットワーク層ではリアルタイム編集時の差分のみを送るようなアーキテクチャになるかと思います。

## 関連する記事

### Google Docsで利用しているアルゴリズムOTとCRDTs

[Google Docsで利用されているアルゴリズムは、CRDTより以前に開発されたOT(Operational Transformation)](https://drive.googleblog.com/2010/05/whats-different-about-new-google-docs.html)というエントリがあります。OTはサーバ・クライアントモデルを前提としており、いくつかの例外を除いてP2Pなどの接続方式はサポートしていないようです。
yjsは周辺ライブラリでwebrtcをベースにP2P接続をサポートしています。

登場当初はスピードやメモリ使用量が問題になっていたようですが、改善したという記事があります。OTを利用していたGoogle Waveの開発者によるCRDTsについての記事が参考になります。 

[I was wrong. CRDTs are the future](https://josephg.com/blog/crdts-are-the-future/)

### OTとCRDTの歴史

エディタライブラリであるtinyMCEを提供しているtiny.cloudが、リアルタイム共同編集機能を実装する際に、OTとCRDTの歴史的経緯をサーベイした記事があります。

ユーザビリティを考慮し、tinyMCEはOTを選択しましたが、一読の価値があります。

[Building real-time collaboration applications: OT vs CRDT](https://www.tiny.cloud/blog/real-time-collaboration-ot-vs-crdt/)

# 謝辞

本技術調査は、[マーケット・マイニング株式会社](https://www.market-mining.tech/)の依頼により実現しました。