こんにちは。AI事業本部 オンライン接客事業部のGokanです。
オンライン接客システム「リモてなし」の開発チームの中で、フロントエンドを主に担当しています。
今回は、システムにYjsを導入したので、その紹介をしたいと思います。
話としては「WebSocketとYjsを使うことで抱えていた課題を解決できた」という内容になっています。

目次

オンライン接客システム「リモてなし」の特徴

まず、リモてなしの特徴は、システムを挟んで人と人がリアルタイムにコミュニケーションをとることです。これは相手の顔が見える、声が聞こえるというだけではありません。他にも、パワポなどの資料共有、商品の提案、アンケート送付などをすることができます。

また、リモてなしは「対面以上の接客ができる」ことを目指しています。対面接客でできていた目線移動やボディランゲージを補うために、参加者のカーソル位置表示や資料への書き込み機能などがあります。

つまり、上記機能を実現するためには、接客参加者のデータをリアルタイムで交換することが必要でした。そのために、リモてなしではWebSocketを利用していました。

 

今までの課題

今までは、シンプルにWebSocketでデータを交換することで機能を提供していました。しかしながら、これには2つの課題がありました。

1. コネクション切断によりデータを受け取れないケースがある

接客中に一時的にWebSocketコネクションが切断され、データが受け取れないケースが想定されました。例えば、通信状況が悪くなった時や、サーバー側で新しくソースコードをデプロイする時です。結果として、切断中にWebSocket経由で渡されるはずであったデータが取得できず、機能が適切に利用できなくなります。

2.  WebSocketで受け渡したデータを保存しておいて、途中から接客に参加した人に渡す仕組みを別途開発する必要がある

接客は一対一だけではなく複数人の場合もあり、途中から参加する人もいます。その際、途中から参加した人は、接客参加前にWebSocket経由で受け渡していたデータを受け取ることができません。これに対応するために、WebSocketで共有したデータをDBに保存しておき、途中参加した人はこのデータを読み込むという手段をとっていました。しかしながら、接客に関する機能が増えるにつれ途中参加者用の実装やテストケースが増えるなど開発の負担になっていました。

要するに、上記課題の解決には「WebSocketを接続した時に、接続される前に受け渡していたデータを取得できる仕組み」が必要でした。よって、それが実現できるYjsを導入しました。

新たに導入した Yjs とは

Yjsとは、コラボレーションアプリを作るためのJavaScriptライブラリです。GoogleDocsやNotionで行っているような、複数人が同じデータを同時編集するアプリを開発することができます。

このライブラリの素晴らしいところはクライアントが常にオンラインである必要がないことです。もし、オフラインでデータを編集したとしても、オンラインになった時にデータを適切に同期・マージすることができます。

 

引用: https://docs.yjs.dev/getting-started/adding-awareness

 

追加:

Yjsは中央サーバーがない分散システムになっています。クライアントが編集したデータを別のクライアントが受け取って、各々のクライアントがデータをマージする仕組みになっています。これは、同期されていない時は整合性のないデータになりますが、最終的には整合性が取れるようになっています。

加えてYjsが利用しやすい理由は、プロバイダーと呼ばれる補助ライブラリが用意されていることです。
例えば下記プロバイダーがあります。

データがマージできる仕組みの概要

Yjsとは、YATAというアルゴリズム(データ構造)を実装したものです。YATAとはCRDTのひとつです。

CRDT(Conflict-free Replicated Data Types)は、分散システムで利用できるデータ構造のことです。これはデータを同期する際に競合が起きても自動で解決することができ、結果整合性が保証されています。今までに、様々なデータ構造が研究・提案されていています。例えば、カウンターを表現できるデータ構造、Setを表現できるデータ構造などがあります。

YATA(Yet Another Transformation Approach)は、CRDTを満たすデータ構造です。これは、Listを表現できるデータ構造です。そして、YATAを実装したものがYjsです。

YjsではArray型・Map型・Text型などが利用できるようになっています。ただし、これらを表現するためにそれぞれ専用のデータ構造が用意されているわけではありません。基本のデータ構造は同じであり、Listを表現できるデータ構造を利用しています。

これをシンプルに利用しているのはArray型です。Array型ではデータを挿入する毎に下記のようなBlockと呼ばれるデータが作成され、このデータをクライアントノード間でやりとりを行います。各ノードはこのデータを受け取り、データ挿入時における隣接要素の情報を基にマージします。もし競合した場合は、idの大小比較により優先度を決めてマージしていきます。つまり、マージとは「複数のBlockをどのノードでも同じ順番になるように並べること」と言うことができます。

Blockの大まかなデータ構造
■ id: {replicaId}{clock} // replicaId: ノード毎に発行されるId、clock: ノードでデータを操作する毎にインクリメントされるカウンター
■ left: xxx // 値を挿入した際に左に存在していたBlockのId
■ right: yyy // 値を挿入した際に右に存在していたBlockのId
■ value: 'CyberAgent' // 挿入した値

一方、Map型ですが、こちらも同様のデータ構造が使われます。ただし、Map型では一つのKey-Valueにおいて、複数のBlockをマージしていくことになります。例えば、Map型の値を更新した場合、Blockの削除と新しいBlockの挿入が行われます。ここで、もし同じタイミングで、他のノードでも同様の更新が行われた場合、同期後には削除されていないBlockが複数存在することになります。その場合も、idを基に優先度を決めてマージされ、Blockが並べられます。そして、並べられたBlockの最後の要素をMapのValue値として扱うようにします。

アルゴリズムの詳細は下記ブログが大変参考になります。ただし、ブログ内で使われている単語とYjsの実装で使われている単語が異なる箇所があるので注意です。

リモてなしでの活用

リモてなしの課題解決には、「WebSocketを接続した時に、接続される前に受け渡していたデータを取得できる仕組み」が必要でした。そしてYjsであれば、それが実現できると考えYjsを導入することにしました。

データ通信は引き続きWebSocketで行うことにしました。WebRTCという選択肢もありましたが、利用者のネットワーク環境により繋がらないことが多々ありそうだと思い採用を見送りました。また、データ永続化は、速度が出ることを期待してRedisで行うようにしました。

クライアント側では、今まではWebSocketでデータ受信したことをトリガーに画面の書き換えを行っていました。それを、Yjsで定義している値が書き換わったタイミングで画面を書き換えるように変更していきました。

結果として、これら基盤づくりやソースの書き換えに時間は掛かりましたが、今までのWebSocket関連の課題を解決することができました。今後はYjsの強みである共同編集機能を導入するなどして、更に接客体験を向上させていきたいと思います。

最後に

リモてなしチームでは、接客の新時代を一緒に築き上げるメンバーを絶賛募集中です!
ちょっとでも気になった方は、カジュアルにお話するところから始めましょう!
こちらのリンクから気軽にご連絡ください!

2022年サイバーエージェント中途入社。 オンライン接客システム「リモてなし」の開発チームに所属。