🐾

connect(gRPC-web)を用いたReact-Javaのリアルタイムチャットつくってみた

2024/03/21に公開

作ってみようとおもったきっかけ

リアルタイムチャットつくってみたいなーと思った次第です。
React-Goの記事はよく見かけるのですが、React-Javaに関する記事がこれといってなく、色々試行錯誤して作ったので、今後似た構成を検討する方向けに発信しようかなと思いました。

そもそもチャットってどういう仕組みなんだ

当たり前に使うLineだったりGoogleChatだったりSlackだったり、リアルタイムチャットの仕組みってどうなってるんですか・・ケテ・・・タスケテ

とりあえず結論

クライアント-サーバ間でコネクション確立したら、あとはサーバから確立したコネクションを通して任意のタイミングで都度レスポンスされるからクライアント側で表示したいようにすればOK
※ここで瞬間的に仕組みを理解できる方はreact側制御、java側制御まで飛ばしてください。

調べてみる

とりあえずポーリングしときゃいんじゃねとか思いつつ調べるとWebSocket登場
ポーリングはまあ間違ってはないんだけど遅延発生するしサーバ負荷もかかるし現実的ではないと...数秒毎にサーバにリクエストが来るわけだからそりゃそうかなと。これを回避するための仕組みとして双方向通信なるものが登場、それを可能とするプロトコルがWebSocketであると。
https://websockets.spec.whatwg.org/#the-websocket-interface

gRPCに双方向通信ってあったような気がする

gRPCの詳細は以下をご覧ください。※gRPCのハンズオン的なものは別途書こうかなと思います。
https://grpc.io/docs/what-is-grpc/introduction/

双方向通信ってなんだろうなってgRPC使ったときに思ったんですが、あまりピンときてませんでした。なるほど今かと。チャットみたいなサーバからクライアントに投げる必要があるパターンにもってこいな感じの仕組みですね。

実装後の個人的理解しておくべき大枠

  1. AユーザーとBユーザーが同じチャット画面を開くとユーザー毎にコネクションがクライアント-サーバー間で確立される。コネクションの確立自体はサーバ側でメモリ保持(ここ重要)
  2. Aユーザーがメッセージをサーバに送信すると、サーバ側で保存制御をした後、最後に、確立しているコネクションに対してメッセージを送る
  3. Bユーザーはメッセージを受け取れるようなクライアント側制御を実装することで、Aユーザーの送信メッセージが即座に反映されるようになる

構成について

connectとgrpc-web

構成に関して以下参考になりました。
https://symthy.hatenablog.com/entry/2022/09/24/160309

React-Java構成結論

grpc-webとenvoy構成におちつきました。
これには実装した際に大きくはまった部分と関係があります。ざっというとconnectを利用してenvoyを不要とする場合はクライアントとサーバそれぞれがconnectに対応していないといけないというのが自分の理解です。javaはまだconnectに対応するソースが存在しておらず、grpc-java を利用したgRPCサーバを構築しています。だめもとでReact-Javaの疎通をconnect利用の元実装してみましたが、案の定だったためgrpc-webとenvoy構成にしておちつきました。connect-java的なものが作成された段階でgrpc-webとenvoy構成をconnect利用に変更しようかなと思っています。

※以下公式抜粋です。

How do I use the Connect protocol to call existing gRPC servers?
Most Connect implementations support the gRPC protocol, so you have a choice:
Use the Connect runtime, but configure your client to use the gRPC protocol, or
Use the Connect runtime and Connect protocol, and have Envoy automatically handle the Connect-to-gRPC translation. Envoy is a popular and widely used proxy.

https://connectrpc.com/docs/faq/#is-streaming-supported

proto

gRPCの利用にあたってprotoの作成は必須となります。今回のチャット作成にあたってconnect、grpc-web、grpc等プロトコルによる作成方法に違いはありません。ただし通信方式のみ気を付けましょう(client streamingは不可です。※grpcであればOK)

チャット制御は以下2つで可能です。

  • unAry(1request - 1response)はチャット内容の保存&確立されたコネクションへの返答
  • server streaming(1request - Nresponse)はチャット内容の取得&コネクションの確立

あくまでserver streamingによる制御はコネクションを確立することで、確立されたコネクションに返答する制御はunAryであることに注意が必要です。
https://github.com/akki-F/akki-proto/blob/main/spec/akki_service.proto#L14-L24

react側制御

環境

viteで構築しました。nextでも何でもよいのですが、クライアント側におけるサーバサイド実装(正確にはサーバコンポーネント制御)が不要なのでviteにしてます。あとチャットとは関係ないものですが以下導入してます。理由としては実際何らかのPJで利用するにあたって、デザインやフォーム制御等で使う可能性が高いかなと思うライブラリなのでそのあたりを考慮してます。

  • mui
  • React Hook Form + Zod
  • dayjs

gRPC-webやconnectを利用するにあたっては以下を参考ください。

connect for webを利用しましょう。

https://connectrpc.com/docs/web/getting-started
connect for node.jsがあってややこしいのですが、これはあくまでクライアント側でサーバサイド制御する際に利用する認識です。※たとえばnextで構築してapp/apiにfetchしてたrestをgRPCに変更する的な、今回のJavaの立ち位置がnodeになる的な

ここからが本題

  • ルーティング

https://github.com/akki-F/akki-react-chat/blob/main/src/App.tsx#L11
react-router-domの利用によるルーティングをしています。HOMEとかそのまま。。

  • React Hook Form + Zod

https://github.com/akki-F/akki-react-chat/blob/main/src/app/chat/validation.ts
React Hook Form + Zodでバリデーション制御やフォームの型を一括管理可能なため、React Hook Form + Zodの管理ファイルは分離しました。1ファイルで管理したいので、同一画面に複数フォームが存在しても問題ないようにnamespace毎でフォームの型管理を可能な状態にしています。auth0とかの利用も考えてuserモデルとか持たせたんですけども、作成途中でauth0の利用って別記事でよくねって思ってやめました。笑
以下に併せてチャットアプリにも組み込みました。
https://zenn.dev/akkif/articles/2162fdc1f2e94d

https://github.com/akki-F/akki-react-chat/blob/main/src/app/chat/ChatForm.tsx
zod管理ファイルで定義したフォーム情報をルーティング先コンポーネントで利用するようにしています。チャットのメッセージ保存制御はReact Hook Formを利用してonSubmit時にgRPC疎通してるだけです。チャット制御で地味に対応する必要があるなと思ったのがスクロール制御です。チャットメッセージのレンダリングに応じて毎回初回メッセージから表示されちゃうので要注意です。ただ、チャットの構成として、メッセージ全取得(unAry)、メッセージ保存(unAry)、追加メッセージ取得(server streaming)みたいな、3つ用意するパターンだと追加メッセージのみレンダリングさせれば済みそう感なのでスクロール制御不要なんじゃね?とか思ってます。

  • チャット描画制御

https://github.com/akki-F/akki-react-chat/blob/main/src/fooks/useApi.tsx
チャットメッセージの取得はカスタムフックを作成しています。server streamingにおける疎通はコネクションが張られている状態なので不定期にバックエンドから返答がきます。そのため返答に応じて動的にsetMessagesする制御を組むことが必要です。描画自体はmessageStateを展開することを意識しておけば同期されたチャットメッセージ表示が可能となります。

  • gRPC疎通装置の作成

https://github.com/akki-F/akki-react-chat/blob/main/src/util/grpcClientUtil.ts
jsonリクエストを引数にgRPC疎通可能なつくりにしています。unAryやserver streamingといったそれぞれに共通する送信制御を作成しておくとdryにしやすいかなと思います。

connectがconnect,grpc-web,grpcに対応しているという記載が公式に存在しますが、利用する状況に応じて使い分けてね感なので以下の認識で利用するのがいいかなと考えています。
・createConnectTransport(connectプロトコル)
※connectプロトコルがクライアントとバックエンドそれぞれ対応している場合に利用
※これとenvoyを組み合わせるとHTTP 415エラー発生
・createGrpcWebTransport(grpc-webプロトコル)
※connectプロトコルがバックエンドに対応していない場合に利用
・createGrpcTransport(grpcプロトコル)
※サーバサイドで利用する。grpcプロトコル利用の通信はバックエンドtoバックエンド

なおnextのver13系以降で環境構築すると分かりますが、grpcプロトコルを利用するcreateGrpcTransportはその利用するオプションがサーバサイド側コンポーネントでしか動かない等発生します。逆にcreateGrpcWebTransportはクライアント側コンポーネントでのみ動きます。

  • gRPC型のfactory

https://github.com/akki-F/akki-react-chat/blob/main/src/util/grpcFactoryUtil.ts
jsonオブジェクトからgRPCオブジェクトの生成を担ったり、dayjsからgRPCオブジェクトの生成を担ったりなどfactoryの役目です。役目を明確にしておくと利用する際や追加すべき制御が発生しても対応しやすいと思っています。

java側制御

環境

spring boot 3系利用のgradleで構築しました。O/RマッパーはmyBatisを利用しています。gRPCに関係ない依存は少なくspring boot関連やlombok入れたりcommons入れたりして実装楽にしてる程度です。gRPCの依存はgrpc-java にのってるので確認ください。注意する点として今回はprotoからjavaファイルを生成するにあたってmavenLocalを利用し、依存解決している点に注意してください。submoduleでいい感じにしてもいいと思います。※一応どっちでもいけるようにproto側のgradleにタスク追加してます。

ここからが本題

  • gRPC疎通装置の作成

https://github.com/akki-F/akki-java-chat/blob/main/src/main/java/akki/service/GrpcService.java#L39-L102
react同様にunAryとserver streaming用に共通する送信制御を作成しています。正常時とエラー時での制御を各サービスで柔軟に行いたかったので、正常時制御とエラー時制御を強制するinterfaceを引数にとる実装としています。

  • 各サービスの基底クラス

https://github.com/akki-F/akki-java-chat/blob/main/src/main/java/akki/service/base/BaseService.java
トランザクション制御を一括管理したかったので、gRPC疎通装置内で引数にとるinterfaceを実装した抽象クラスにアノテーションを付与した基底クラスを作成しています。各サービスはこの基底クラスを継承することでgRPC疎通装置内でinterfaceを引数にとる共通メソッドを利用可能としています。

  • コネクションの確立

https://github.com/akki-F/akki-java-chat/blob/main/src/main/java/akki/service/GrpcService.java#L33-L37
コネクションはメモリ上で保持します。

https://github.com/akki-F/akki-java-chat/blob/main/src/main/java/akki/service/GrpcService.java#L122-L129
コネクションを確立するタイミングはserver streamingによるアクセスが来たタイミングです。
つまり今回でいうとチャットメッセージの取得のタイミングです。

  • コネクション確立先に返答

https://github.com/akki-F/akki-java-chat/blob/main/src/main/java/akki/service/GrpcService.java#L104-L120
コネクション確立先に返答するタイミングは初回メッセージ取得時を除いてメッセージ保存のタイミングです。冪等性を考慮する必要があるので、トランザクション制御を含む共通制御が完了した後にメッセージをメモリ保持したコネクション確立先にループして返すようにしています。

https://github.com/akki-F/akki-java-chat/blob/main/src/main/java/akki/service/GrpcService.java#L131-L152
メッセージ返答段階でエラー発生時はいずれもコネクションを閉じるように制御しています。

envoy

https://github.com/akki-F/akki-environment/blob/main/docker/envoy/Dockerfile
envoyのDockerfileです。

https://github.com/akki-F/akki-environment/blob/main/docker/envoy/docker-compose.yml
Dockerfileをビルド対象として9000ポートでうけるようにしています。React側からアクセスされるポートです。

https://github.com/akki-F/akki-environment/blob/main/docker/envoy/envoy.yaml
React側から9000で受けたのち、grpcとして6565ポートにとばすようにしています。6565ポートはJava実装のgRPCサーバがうけるポートです。envoyをdockerコンテナで動作させるため、adress指定は必ずhost.docker.internalにしましょう。※ここでlocalhost指定にするとコンテナ内におけるlocalhostになってしまうため、コンテナ外のネットワークにおけるlocalhostを指定する必要がある場合はdocker環境においてhost.docker.internalになります。他気を付けるポイントとしてはheaderに付随する情報くらいなのでうまくいかない場合はallow_headersを疑ってみるといいかもしれません。

最後に

いかがだったでしょうか。実装してみると意外とシンプルに実装できると思います。多分もっといい組み方もあるんじゃないかなと思います。
なおkeepaliveの設定が怪しいです。。ェ。。ここらへんいい感じにできる方やこうしたらどうみたいなご意見いただけますと幸いです。
ありがとうございました。

Discussion