20
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

朝日新聞社Advent Calendar 2023

Day 13

YjsとWebSocketで共同編集テキストエディタを作る【オフライン編集と永続化に対応 / Lexical】

Last updated at Posted at 2023-12-13

はじめに

テキストエディタの開発において、共同編集機能はチャレンジングである。
そんな共同編集の開発をスムーズに始めるためのガイドを目指して本記事を執筆した。

本記事では、共同編集技術の概要とエディタの作り方を紹介する。
まず最初に共同編集とは何かを説明する。次に、共同編集機能の実装でよく使われるYjsについて軽く紹介し、テキストエディタに適用してみる。最後に、共同編集の結果をデータベースに保存する方法を紹介する。

実装には共同編集機能としてYjs、テキストエディタにLexical.jsを使う。他のエディタで開発する際にも、同じ考え方で実装できるので、参考にしてほしい。

補足:今回作ったデモを公開したので参考にどうぞ。

共同編集を実装するための前提知識

共同編集とは何か、そしてどんな技術が必要か

テキストエディタにおける共同編集とは、複数人で一つのテキストファイルを同時に編集できる機能である。イメージとしては、Google Documentの共同編集がわかりやすいと思う。

誰がどの部分をいじっているか一目でわかるのも、共同編集の機能である。
ezgif-3-af119c0328.gif

共同編集の実装には、3つのサブ機能を実装する必要がある。

  • 一つのファイルに複数人がアクセスできる機能
  • 共同で同じファイルを編集できる機能
  • 編集したファイルが随時サーバーに保存される機能

そして、より現代的なアプリケーションでは、以下のようなオフラインファーストの機能も提供されることが多い。

  • オフライン編集後にオンライン復帰した際に、編集内容が同期される機能
  • オフラインとオンラインの編集がぶつかっても、つじつまを合わせられる機能

共同編集は、これらの複数の要件が組み合わさって成り立つ高度な機能である。
なので、一見して敷居がたかい...。

しかし、これらの要件は、Yjsという外部のパッケージがよしなにやってくれる。
今回はこれら全てに対応した共同編集エディタを構築する。

では早速Yjsの説明に入る。

共同編集のテッパン、Yjsのすごさとは?

先に説明したように、共同編集は難解で複雑な機能である。共同編集を実装する人にとっては、Yjsは、まさに救いの手となる。

公式サイトによると、Yjsとは、「共同編集フレームワーク」である。
Yjsを使えば、stringやlistといった普段使うデータ型を、ユーザー間で簡単に共有できる。そして、共有されたデータを更新すると、即時に他のユーザーにも変更が反映されるのが特徴だ。

また、QuillやLexicalといった有名なJavascriptのテキストエディタに統合でき、オフライン編集にも対応する。

Yjsは、かゆい所に手が届く至れりつくせりのフレームワークなのだ。

Yjsは普段使うデータ型を共有データ型にする機能を持つことがご理解いただけたと思う。
この共有データ型を遠隔地のユーザー間で同期するには、ネットワークを介して通信する必要がある。その代表的な選択肢に、WebSocketがある。

WebSocketで遠く離れたユーザー同士をつなぐ

WebSocketとは、ざっくり言うと、双方向の通信プロトコルである。
よく使われる通信プロトコルにHTTPがあるが、一方向しか情報を伝達できない。チャットアプリや、通話、共同編集といった、ユーザーが、データを送ったり受け取ったりする用途では、双方向が欲しくなる。
そこで開発されたのがWebSocketだ。
WebSocketでは、電話のように、AさんとBさんが相互に発信・受信できる。そのため、チャットアプリや共同編集といった、お互いの変更を伝え合う通信に向いている。
Yjsで使うだけなら容易にセットアップできるので、言葉の響きほど身構えないで良い。

YjsでWebSocketを使うには?

Yjsで共有データ型を作り、それをWebSocketを用いてユーザー間で共有する機能を作る必要がある。
それには、Websocketサーバーのコードと、フロント側で用いる実際のエディタ画面のコードが必要となる。
後述するが、サーバー側のコードはYjsが提供するサンプルコードをほぼそのまま使うことができる。これは、各ユーザー間の共有データ型の更新を仲介する役目を果たす機能で、この1行を書けばあとはよしなにやってくれる。
また、フロント側(Reactなど)ではYjsが用意する「プロバイダー」を設定するだけで接続できる。

共同編集テキストエディタの実装(フロントエンド側)

WebSocketを介したYjsドキュメントの共有

Yjsでは、「プロバイダー」を用いることで、Yjsドキュメント(共有データ型)をWebSocketなどのさまざまな方法で共有できる。WebSocketで接続する際には、Yjsが用意するWebsocketProviderプロバイダーを指定する。

具体的には、フロント側でYjsドキュメントを作り、プロバイダを介して遠隔地のユーザーと共有する。

手順としては以下である。

  1. Yjsの共有データ型を作る
  2. それをプロバイダーに登録する

この操作の部分をコードにしたのがこちらである

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'


// 1. Yjsの共有データ型を作る
const ydoc = new Y.Doc()

// 2. それをプロバイダーに登録する(今回はWebSocketプロバイダー)
const websocketProvider = new WebsocketProvider(
  'wss://demos.yjs.dev', 'yjs-demo', ydoc
) 

// 続く...

オフライン対応

Yjsの共有データ型には、複数のプロバイダーを同時に共有できる。この特性を利用することで、ネットが途切れた際にも編集を継続できる、オフライン編集機能をつけることができる。
オフライン編集機能を使う際には、WebsocketProviderに加えて、IndexeddbPersistanceを併用する。

IndexeddbPersistanceは、ブラウザに搭載されたローカルのデータベースにテキストの編集記録を保存するためのプロバイダだ。IndexDBとは、localStorageと同じように、ブラウザ上で使えるデータベースである。
このプロバイダーを使うことで、もしユーザーがオフラインで編集したとしても、ローカルのIndexDBにも共有データ型が保存されるため、オフライン中の編集を失わずに済む。
編集後にオンラインに復帰した際には、IndexDBに蓄積したオフラインでの編集履歴がWebsocketProviderを経由して一斉に全体に同期される。
IndexeddbPersistanceは大抵の場合、WebsocketProviderなどのオンライン通信用のプロバイダーと併用して使い、オフラインになった時の受け皿としてindexdbを使うというケースが多い。

この説明をコードにしたのが以下である。

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'


// 1. Yjsの共有データ型を作る
const ydoc = new Y.Doc()

// 2. それをプロバイダーに登録する

// WebSocket
const websocketProvider = new WebsocketProvider(
  'wss://demos.yjs.dev', 'count-demo', ydoc
)

// ↓ここ!!
// Indexeddb(オフライン編集への対応)
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
idbP.whenSynced.then(() => {
  console.log('loaded data from indexed db')
})

詳しい解説はこちら。

Lexical.jsを用いて共同編集のテキストエディタを実装してみる

では、今までを踏まえて、実際に共同編集のテキストエディタを構築してみる。今回、エディタにはLexicalを使用している。
以下のコードは共同可能なエディタの全貌だ。
Lexical.jsのお作法に慣れていないとコード量にたじろぐと思うが、大事なのはCollaborationPluginだけだ。ここでのミソは、Lexical.jsの最小構成に、十数行のCollaborationPluginを追加しているだけで、共同編集が可能になるということだ。
Lexicalが提供するCollaborationPluginには共同編集のカーソル表示が実装されているため、誰がどこを編集しているか見えるようになっている。
Screenshot 2023-12-13 at 9.23.46.png

import { CollaborationPlugin } from "@lexical/react/LexicalCollaborationPlugin";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import {
  $createParagraphNode,
  $createTextNode,
  $getRoot,
  LexicalEditor,
} from "lexical";
import { WebsocketProvider } from "y-websocket";

import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";

import * as Y from "yjs";

function initialEditorState(editor: LexicalEditor): void {
  const root = $getRoot();
  const paragraph = $createParagraphNode();
  const text = $createTextNode("Welcome to collab!");
  paragraph.append(text);
  root.append(paragraph);
}

function CollaborativeEditor() {
  const initialConfig = {
    // NOTE: This is critical for collaboration plugin to set editor state to null. It
    // would indicate that the editor should not try to set any default state
    // (not even empty one), and let collaboration plugin do it instead
    editorState: null,
    namespace: "Demo",
    nodes: [],
    onError: (error: Error) => {
      throw error;
    },
    theme: {},
  };

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <PlainTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      // ↓ここが大事
      <CollaborationPlugin
        id="yjs-plugin"
        providerFactory={(id, yjsDocMap) => {
          const doc = new Y.Doc();
          yjsDocMap.set(id, doc);

          const provider = new WebsocketProvider(
            "ws://localhost:1234",
            id,
            doc
          );

          return provider;
        }}
        // Optional initial editor state in case collaborative Y.Doc won't
        // have any existing data on server. Then it'll user this value to populate editor.
        // It accepts same type of values as LexicalComposer editorState
        // prop (json string, state object, or a function)
        initialEditorState={initialEditorState}
        shouldBootstrap={true}
      />
      // ↑ここまでが大事
    </LexicalComposer>
  );
}

export default CollaborativeEditor;

コードはこちらから借用した。実装の際には、最新のコードを確認してほしい。

このコードは、Lexicalから用意された、Yjsの連携プラグインCollaborationPluginを使ってYjsを使っている。
注目すべきは、引数providerFactoryである。この引数にはyjsのプロバイダーを生成するメソッドを渡せるので、このメソッド内に使いたいプロバイダーを自由に設定できる。(この仕組みが気になった方は、依存性の注入で検索!)

        providerFactory={(id, yjsDocMap) => {
          const doc = new Y.Doc();
          yjsDocMap.set(id, doc);

          const provider = new WebsocketProvider(
            "ws://localhost:1234",
            id,
            doc
          );

          return provider;
        }}

例えばもしオフラインをサポートしたいなら、IndexeddbPersistenceを追加するだけで良い。追加すれば、編集がWebSocketに反映されずともローカルにため込まれるから、再びオンラインにしたときに一気に編集が共有される。

        providerFactory={(id, yjsDocMap) => {
          const doc = new Y.Doc();
          yjsDocMap.set(id, doc);

          const provider = new WebsocketProvider(
            "ws://localhost:1234",
            id,
            doc
          );
          // これを追加!
          new IndexeddbPersistence(
            id,
            doc
          );

          return provider;
        }}

共同編集テキストエディタの実装(バックエンド側)

WebSocketサーバーを立ち上げる

フロントエンド側では、ユーザーが操作するエディタの実装をした。バックエンドでは、ユーザー間で通信できるよう、WebSocketサーバーを立ち上げる。
とは言っても、すでにサーバーのサンプルコードが用意されているので、コマンドを打ち込むだけでセットアップできる。
サンプルコードの実装はy-websocketリポジトリのbinに入っている。

それをこのまま使っても良いが、サーバーへのデータ保存(=永続化)はできないため、そのカスタマイズは次の章で行う。
まずはそのまま使う方法を紹介する。

実際にサーバーを開くには以下のコマンドを叩く。

git clone https://github.com/yjs/y-websocket
cd y-websocket
HOST=localhost PORT=1234 npx y-websocket

この状態で、先ほど作ったLexicalエディタを複数タブで起動すると共同編集できるようになっている。

共同編集の結果をデータベースに保存する必要がある

これだけでも共同編集はできるのだが、実際には共同編集の結果をバックエンドデータベースに保存する必要がある。
今まででもユーザー間でデータのやり取りができていたが、現状、全てのユーザーがタブを閉じるとデータを繋ぎ止める人がいなくなって編集履歴が全て消えてしまう。これだとユーザーの編集記録が保存されず、オンラインサービスとして成り立たなくなる。

そのため、共同編集の履歴が蓄積されたYjsドキュメントを常時バックエンドに保存する必要がある。そのためには、Yjsが提供している専用のプロバイダを利用すれば良い。
Yjsドキュメントの保存にはキーバリューストア型のデータベース(NoSQL型)が適しており、代表的なものは以下である。

  • DynamoDB(AWS)
  • MongoDB
  • Redis
  • levelDB
  • IndexedDB

Yjsのオープンソースコミュニティが、これら代表的なデータベースに保存するためのプロバイダを提供している。

動作原理としては、オフライン対応のためのIndexeddbPersistanceと全く同じである。IndexeddbPersistanceがYjsドキュメントをブラウザに保存するのと同様に、WebSocketサーバーに共有されているYjsドキュメントをリアルタイムにデータベースする。
全てのWebSocketコネクションが途切れたとしても、YjsドキュメントはDBに残る。そのため、ユーザーがファイルを再び開こうとコネクションを張った際には、DBにある編集履歴がユーザーに共有され、ユーザーのテキストエディタには最新の状態が保存される。
このように、編集したデータが保存されることを「永続化する」と呼ぶ。

Yjsオブジェクトの永続化

実際には、Yjsオブジェクトの永続化は、y-websocketリポジトリに用意されているWebSocketサーバーの起動コードを改造して実装できる。
今回は、ローカルでセットアップできるNoSQLデータベースのRedisでの実装を紹介する。他のDBの場合は、RedisPersistenceの部分を他のプロバイダに置き換えるだけで良い。

Redisデータベースのインストールと立ち上げ

Redisサーバーをインストールし、立ち上げる。めちゃくちゃすぐ終わる。
最新のインストール方法はこちらを参照。

macの場合

brew install redis
redis-server

windowsの場合はこちらを確認

WebSocketサーバーの構築

redisサーバーが立ち上がったので、redisにつなげるYjsのWebSocketサーバーを建てる。
今回は、serverフォルダにサーバーのコードを書く。
serverフォルダの中に、y-websocketと、redisに保存するためのプロバイダ(y-redis)を入れる。

mkdir server
cd server
npm install y-websocket
npm install y-redis

次に、serverフォルダ内に2つのファイルを作る。

store.js

Redisでの永続化機能をモジュールとして作成する。
Redisのエンドポイントurl等を設定し、RedisPersistenceに保存する。このモジュールを後で使う。

const { RedisPersistence } = require('y-redis');

const Y = require('yjs');

const config = {
  redisOpts: {
    // RedisのURLを指定する
    url: 'redis://localhost:6379',
  },
};
module.exports.persistence = new RedisPersistence(config);

module.exports.persistence.writeState = async (name, doc) => {};

server.js

WebSocketサーバーを起動するコードを作る。
y-websocketリポジトリのbin/server.jsはWebSocketサーバーの起動コードである。
これを改造し、先ほど作ったプロバイダを適用した起動コードを作る。

変更点は、先ほど書いたstore.jsを追加した点である。
元々のサンプルコードには永続化までは行わないため、自分で付け足す。

#!/usr/bin/env node

/**
 * @type {any}
 */
const WebSocket = require('ws')
const http = require('http')
const wss = new WebSocket.Server({ noServer: true })
const {setupWSConnection, setPersistence} = require('y-websocket/bin/utils')

// ↓↓↓追加

// `RedisPersistence`プロバイダー​を設定し、Yjsドキュメントをredisデータベースに常時共有するようにする。
const persisitance = require('./store').persistence
setPersistence(persisitance)

// ↑↑↑追加


const host = process.env.HOST || 'localhost'
const port = process.env.PORT || 1234

const server = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('okay')
})

wss.on('connection', setupWSConnection)

server.on('upgrade', (request, socket, head) => {
  /**
   * @param {any} ws
   */
  const handleAuth = ws => {
    wss.emit('connection', ws, request)
  }
  wss.handleUpgrade(request, socket, head, handleAuth)
})

server.listen(port, host, () => {
  console.log(`running at '${host}' on port ${port}`)
})

あとは実行すると永続化対応のWebSocketサーバーが立ち上がる。

node server.js 

この状態でフロントを立ち上げると、共同編集ができるようになる。
ezgif-3-af119c0328.gif

おわりに

本記事ではYjsを使って多機能な共同編集テキストエディタを作る方法を紹介した。
今回はテキストエディタにLexical.js、サーバーにRedisを使用したが、他の組み合わせでも同様にして実装できる。
この記事を起点にして、深ぼりしてみてほしい。

各種エディタでの実装

サーバー対応

レポジトリ

今回作成したデモのコードはこちら

(メディア研究開発センター・松山 莞太)

20
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?