はじめに
こんにちは!anyのプロダクトチームに所属している @310kaです。
この記事は any Product Team Advent Calendar2024 24日目の記事になります。
我々が開発しているQastはナレッジプラットフォームであり、社内でのナレッジを様々な形で蓄積し検索できるツールです。
ナレッジを残すためにはエディタが必要不可欠であり、エディタに対してナレッジを残すために様々な便利な機能開発を試みています。
今回は、今後機能開発を検討している同時編集を可能にするための実装をご紹介します。
Lexicalについて
QastはエディタにLexicalを採用しています。Lexicalは複数のブロックノードをツリー構造で持つエディタで様々なUI表現が可能であり、独自でカスタマイズしたノードを作成し組み込むことが容易です。
また、入力補助などのプラグインも多く、共同編集のためにYjs
を利用した @lexical/yjs
プラグインが作られています。
Yjsについて
Yjsはリアルタイム共同編集を実現するフレームワークで、CRDT (Conflict-free Replicated Data Type ) というデータ構造を用いて複数のユーザーが同時に編集するときに共同操作を可能にしています。
Yjsはコアライブラリである yjs, 同期プロトコルを定義したy-protocols に加えて各種サーバまわりのバックエンドの実装が存在します。
今回はy-websocketを使用したwebsocketによる実装を試してみます。
実装
Websocketサーバの実装ですが、今回はNestJSを使用します。
NestJSでは接続を監視するGatewayを実装しAppModuleに組み込むことでWebsocket接続が可能になります。
まずは、y-websocket
を追加します。
pnpm i y-websocket yjs
以下は y-websocket
の setupWSConnection
を使用して接続する処理の例です。
import { Injectable } from '@nestjs/common';
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Request } from 'express';
import { Server, WebSocket } from 'ws';
// @ts-expect-error
import { setupWSConnection } from 'y-websocket/bin/utils';
@Injectable()
@WebSocketGateway({ transports: ['websocket'] })
export class YjsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
async handleConnection(client: WebSocket, request: Request): Promise<void> {
// ?room_id=ROOM_IDをdocNameとする
const docName = new URLSearchParams(request.url.split('?')[1]).get('room_id') ?? 'roomId';
setupWSConnection(client, request, { docName });
}
handleDisconnect(): void {}
}
AppModuleに組み込むことで動作します。
@Module({
providers: [YjsGateway],
})
export class AppModule {}
次にフロントエンドの実装ですが、LexicalのCollaborationPlugin を使用するため@lexical/yjs
を追加します。
pnpm i y-websocket yjs @lexical/yjs
まず y-websocket
の WebsocketProvider
を返却する ProviderFactory
を実装します。
Yjs
ではY.Doc
で共有データを持ちます。 Y.Doc
を同期することで複数の環境で同じ状態を保ち、CollaborationPlugin
でLexical
のNode
に変換して表示・編集を行なっています。
import type { ComponentProps } from 'react';
import React, { useCallback } from 'react';
import { CollaborationPlugin } from '@lexical/react/LexicalCollaborationPlugin';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
type ProviderFactory = ComponentProps<typeof CollaborationPlugin>['providerFactory'];
// 複数のエディタを扱う想定でidごとにY.Docを管理できるようにします
const getDocFromMap = (id: string, yjsDocMap: Map<string, Y.Doc>): Y.Doc => {
let doc = yjsDocMap.get(id);
if (doc === undefined) {
doc = new Y.Doc();
yjsDocMap.set(id, doc);
} else {
doc.load();
}
return doc;
};
const providerFactory: ProviderFactory = useCallback(
(id: string, yjsDocMap: Map<string, Y.Doc>): Provider => {
const doc = getDocFromMap(id, yjsDocMap);
// Y.DocをWebsocketProviderを通じて同期します
return new WebsocketProvider(
'ws://localhost:3000',
`/editor?room_id=${id}`,
doc,
) as unknown as Provider;
},
[],
);
次に CollaborationPlugin
を LexicalComposer
に他のプラグインと同じように追加します。
<CollaborationPlugin
id="test"
providerFactory={providerFactory}
shouldBootstrap={false}
username={currentUser.name}
initialEditorState={editor?.getEditorState()}
/>
ここまでの実装だけで共同編集が可能になります。非常に簡単ですね。
共同編集自体はこれだけで可能になりますが、アプリケーションに組み込むためにはデータの永続化が必要不可欠です。
y-websocket
には leveldb
への保存処理が存在しますが、ここは各々の環境に応じた実装を追加する必要があるでしょう。
y-redis
を利用するか各々の環境に応じた保存処理を setPersistence に実装することになります。
y-websocket
自体は比較的シンプルな作りになっているので、こちらをベースに拡張していくことになります。
y-websocket
のデータはバイナリでやりとりされるため読み取ることができません。
NestJS
では@SubscribeMessage()
デコレータでメッセージイベントを受け取るハンドラーがありますが、バイナリなので受け取れないです。
サーバ側でのデータ取得に処理を入れたい場合は下記のようなAdapterを作成し bootstrap
でapp.useWebSocketAdapter(new YjsWsAdapter(app))
をすれば @SubscribeMessage('yjs')
で受け取ることができます。
データの中身を詳しく知るためには、 y-protocolsを理解していく必要があります。
import { WsAdapter } from '@nestjs/platform-ws';
import { MessageMappingProperties } from '@nestjs/websockets';
import { Observable } from 'rxjs';
export class YjsWsAdapter extends WsAdapter {
bindMessageHandler(
buffer: any,
handlers: MessageMappingProperties[],
transform: (data: any) => Observable<any>,
): Observable<any> {
try {
JSON.parse(buffer.data);
return super.bindMessageHandler(buffer, handlers, transform);
} catch {
const messageHandler = handlers.find(handler => handler.message === 'yjs');
const { callback } = messageHandler;
return transform(callback(buffer));
}
}
}
今回は同時編集を検証するための実装でしたが、実際にQastへ組み込むためにはQast独自で拡張したLexical機能への対応や各ナレッジとの紐付け処理やLexicalでのシリアライズされたJSON形式での永続化、認証やスケーリングなど多岐に渡っての対応が必要になります。
さいごに
Qastではナレッジを残す使命のために、様々な機能開発を試みています。エンジニア側として色んな実装検証や提案もしており、Qast全体の使い勝手をよくする試みや開発体験の向上など様々なタスクを進めています。
エディタにまつわる開発やインフラなどに興味ある方、是非一緒にやりましょう!参画をお待ちしております!
any株式会社ではナレッジ経営クラウドQastのエンジニアを絶賛募集中です。
是非採用ページをご覧ください!
興味がある方は、こちらよりご応募お待ちしております。
エンジニア組織/文化について詳しく知りたい方はこちら。