はじめに
こんにちは。京都情報大学院大学、未来環境ラボの中口です。この記事では、Madoi と React を組み合わせて、簡単なチャットアプリケーションを作る方法を紹介します。Madoi(参考: WebSocketメッセージングサーバ"Madoi"をASL2で公開しました)は、ネットワーク共有機能を利用したウェブアプリを開発するためのオープンソースのメッセージングサーバ + クライアントライブラリです。
まとめ
- Madoi と React を組み合わせるヘルパが用意されている
- useSharedState フックで共有状態を扱える
- useSharedModel フックで共有オブジェクトを扱える
- React らしい記述でチャットなどの共有アプリを簡単に作れる!
- 最終的に出来上がったチャット(vite + ts + react)のソース一式はここ
Madoiの仕組みとReact
Madoiにおける共有の仕組み
Madoi は、Madoi Client(TypeScript/JavaScript ライブラリ) と Madoi Server(メッセージング + 状態管理サーバ)で構成されています。メッセージ送受信機能だけを利用することもできますが、メソッド実行の共有によるオブジェクト状態同期機能も提供しています。後者では、共有対象のメソッドが実行されようとした際に、Madoi Clientがその実行を横取りし、Madoi Server に通知を送ります。Madoi Server は参加しているクライアント全てに通知を配信し、通知を受け取ったクライアントはメソッドを実行します。これにより、あるクライアントでメソッドが実行されようとすると、全てのクライアントで同じメソッドが実行されます(後述するuseSharedState がメッセージ送受信のみを使用し、useSharedModelがメソッド実行の共有を利用しています)。更新された状態を表示することにより、あるユーザの操作が他のユーザに共有されます。
React
React は、状態を描画することに特化したUIフレームワークです。UIを描画する(=HTMLを生成する)関数を用意し、内部で useState フックを実行すると、状態と変更関数が返されます。状態はHTMLの描画に使用し、変更関数はボタンクリックなどのイベント発生時に実行します。変更関数を実行すると、UIを描画する関数が再度呼ばれ、その際 useState からは変更後の状態が返され、状態を反映したHTMLが生成されて画面が更新されます。
以下は入力された文字列をリストに追加するプログラムの例です。
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
export default function App() {
const [logs, setLogs] = useState<string[]>([]);
const input = useRef<HTMLInputElement>(null!);
const onSubmit: FormEventHandler = e=>{
e.preventDefault();
setLogs(logs=>[...logs, input.current.value]);
};
return <>
<form onSubmit={onSubmit}>
<input ref={input} />
<button>送信</button>
</form>
<div>
{logs.map(
(log, index)=><div key={index}>{log}</div>
)}
</div>
</>;
}
useStateで文字列の配列を状態として宣言し、状態logsと変更関数setLogsを受け取り、コードの後半ではlogsの内容をdivタグに変換しています。次にuseRefでテキストボックスを操作するための変数inputを宣言し、イベントハンドラonSubmit内で状態logsとinputの内容を連結して、変更関数setLogsに渡しています。setLogs実行後、Reactにより再度App()が実行され、渡された内容がuseStateの戻り値のlogsに格納されて返され、表示に反映されます。
useSharedStateによる状態共有機能の追加
前節で示したプログラムを、Madoiを使って共有アプリに変更してみましょう。
まず、Madoiのプロジェクトページを参考に、Madoiサーバを起動して下さい(Dockerが必要です)。
次に、viteでts-reactプロジェクトを作成し、Madoiクライアントとヘルパーをプロジェクトに加えて下さい。
npm create vite@latest madoi-sample -- --template react-swc-ts
# Use roldown-vite? と Install with npm and start now? には No を選択
cd madoi-sample
npm i
npm i madoi-client madoi-client-react
次に、main.tsxで、Madoiクライアントを使ってサーバに接続します。
const roomId = "madoiChat_2rnieg"; // 追加。任意の文字列
const apikey = "ahfuTep6ooDi7Oa4"; // 追加。apikey
export const MadoiContext = createContext(new Madoi( // 追加
`ws://localhost:8080/madoi/rooms/${roomId}`,
apikey));
createRoot(document.getElementById('root')!).render(
...(以降は変更無し)...
この例では、React の context(MadoiContext) として Madoi クライアントを生成しています。接続先は先ほど起動した、localhost の Madoi Server です。
次に、App.tsxで、MadoiContext から Madoi Client を取得して文字列のリストを共有状態にします。
export default function App() {
const madoi = useContext(MadoiContext); // 追加
const [logs, setLogs] = useSharedState<string[]>(madoi, []); // 変更
const input = useRef<HTMLInputElement>(null!);
...(以降は変更無し)...
App 関数の冒頭で useContext を使って madoi(main.tsxで作成したもの) を取得し、次の行の useState を useSharedState に変更して madoi を渡すようにしました。Madoi Client の利用に必要な変更はこれだけです。
これだけの変更で、アプリケーションがネットワーク共有に対応できました。
問題点とuseSharedModel
前節では、文字列のリストを共有の状態として扱うことで、チャットアプリのような振る舞いを実現しました。しかし、複数のクライアントで同時に状態の更新が行われると、タイミング次第で、追加した内容が失われてしまうことがあります。
Madoi Server は、送られてきた通知を、受け取った順番に全てのクライアントに転送します。そのため、状態そのものを同時に送り合い、受け取った状態をそのまま表示すると、最後に送った状態だけが表示されることになります。これは、ネットワークアプリケーションにおいて最も深刻な、クライアント毎に保持している情報が異なる不整合な状態よりはマシですが、チャットアプリケーションとしては想定した動作ではありません。
求める動作を実現するには、状態とそれに対する操作をクラスにまとめ、操作を共有するよう変更する必要があります。まず、次のようなクラスを用意します。
export class Logs{
private logs: string[] = [];
@Distributed()
@ChangeState()
add(log: string){
this.logs = [...this.logs, log];
}
@GetState()
getLogs(){
return this.logs;
}
@SetState()
setLogs(logs: string[]){
this.logs = logs;
}
}
@Distributed, @ChangeState, @GetState, @SetStateは、Madoi Client が用意しているデコレータです。それぞれ、実行されたら同じルーム内の他のアプリケーションでも実行するメソッド、状態変更を起こすメソッド、状態取得メソッド、状態設定メソッドに付与します。Madoi Client は、このデコレータを手がかりに、メソッド実行の分散やオブジェクトの状態の同期を行います。オブジェクトの内部状態自体は管理の対象にはせず、常にデコレータが指定されたメソッドを通じて状態を操作します。
次に、App.tsxを、上記のクラスを使うように書き換えます。
export default function App() {
const madoi = useContext(MadoiContext);
const logs = useSharedModel(madoi, new Logs()); // 変更
const input = useRef<HTMLInputElement>(null!);
const onSubmit: FormEventHandler = e=>{
e.preventDefault();
logs.add(input.current.value); // 変更
};
return <>
<form onSubmit={onSubmit}>
<input ref={input} />
<button>送信</button>
</form>
<div>
{logs.getLogs().map( // 変更
(log, index)=><div key={index}>{log}</div>
)}
</div>
</>;
}
useSharedState の代わりに useSharedModel を呼び出すようにし、文字列の配列を状態として扱っていたところを、Logs クラスのオブジェクトに変更しました。これにより、状態ではなく操作(addLog メソッドの呼び出し)が共有されるようになり、複数のクライアントで同時に文字列の追加が行われても、文字列が失われることは無くなりました。
おわりに
この記事では、オープンソースメッセージングサーバ Madoi と React を使って、非常に単純なチャットアプリケーションを作成しました。
Madoi は研究の成果展開の一環で公開している基盤システムです。ネットワークプログラミングを極限まで単純化し、少ないコード量で共有アプリケーションが開発できます。
実験的なソフトウェアであり、商用を見据えたテストは行っていませんので、as is でご利用ください。リクエストやバグ報告などあればMadoiのリポジトリまでお寄せください!
