Rust/ActixWeb + React/Next.js で GraphQL を使ってビデオチャットアプリを構築してみた
1. はじめに
バックエンドにRust/Actix Web、フロントエンドにReact/Next.jsを使用し、APIランタイムにGraphQLを用いてチャットアプリを構築してみました。元々自学用のデモアプリとして作成していたのですが、そこそこまとまったものになったので公開してみます。
今回、GraphQLサーバーの構築にはAsync-graphql、クライアントにはApollo Clientを使用しています。どちらも非常によくまとまったドキュメントが存在しているのですが、実際にこれらのライブラリを用いてアプリを構築してみようとなると、フレームワークとの統合方法やアプリ構成等、調べることや考えなければいけないことが途端に増え、技術的、心理的に大きな負担があると思います。
そうした状況において、ある程度形のまとまったサンプルがあると、それだけでいくらか気が楽になった経験があるので、RustでGraphQLというなかなか希少な需要だとは思うのですが、この記事が似た構成でアプリ構築を目指す将来のRust、Reactプログラマーの一助となれば幸いです。
2. 成果物
リポジトリ
デモサイト
予告なく終了する場合があります。
サーバーは最低限のスペックなので、アクセス状況次第で正常に動作しない可能性があります。
https://linkschat.site/
※2022/11/03 閉鎖しました。
レイアウト
※ 実装の都合上、同じ端末内だと通話機能が動作しない為、PC2台で撮影しています。
レスポンシブにも一応対応していますが、使い勝手の面はあまり深く考えていません。
スマホだとひょっとしたら使いにくい箇所あるかもしれないです。
また、メッセージの通知音等も用意していません。
先述の通り、あくまでデモアプリとして見ていただきますようお願いします。
3. 技術・実装雑記
このアプリにおける設計の理念や実装の際のあれこれをつらつらと書いていきます。
あくまでこのアプリではこうしたという話としてお読みください。
☆ バックエンド
■ 技術スタック
使用技術 | |
---|---|
言語 | Rust |
FW | Actix Web |
DB ORM | Diesel |
GraphQL | Async-graphql |
今回、GraphQLバックエンドの構築にはAsync-graphqlを使用しています。
RustのGraphQLライブラリとして、もうひとつ有名なJuniperも存在しますが、
Async-graphqlの方が機能が豊富で、実現できる仕様の幅が広いです。
また、不具合が非常に少なく、この手のライブラリを使用する際に稀に発生する、
「ライブラリの不具合を回避するためのハック的な実装」みたいなものは今回一切必要ありませんでした。
このライブラリの使い勝手がよかったがために、
ミニマムに抑えようとしていたアプリ機能が少し大きくなった気がします。
■ セッション管理について
本アプリにおいてセッションの管理は、一般的なWebアプリに用いられるものと同じ、
サーバーでセッションデータを保持し、紐づくセッションIDをクライアントのCookieに保存する方式をとっています。
この手のアプリ構成において、セッション管理の方法には毎回頭を悩まされます。
今回採用したようなCookieによるセッション管理のほかに、例えばJWTのようなトークンを用いてペイロードにセッションデータを保持する方法や、セッション毎にアクセストークンを発行する方法など、様々な手法が考えられます。
しかし、前者でいえば秘密鍵を知り得る者によるトークンの改ざんや偽造、またトークン内容とサーバー状態の乖離(例えば削除済みのユーザーであっても、削除前に発行されたトークンを使用することで不正にセッションを継続できてしまうといったリスク)等、考慮しなければならない課題が多くあります。
さらに、クライアントでのトークンの保存場所にも注意を払う必要があります。
トークンをCookieに保存する場合、そのサイズ上限を超えたデータは保存することができないことを念頭に置く必要があり、その他の場所、例えば単純なJavaScriptオブジェクトやlocalStorageにトークンを保存する場合、JavaScriptによって容易にアクセスできる点、セキュリティ上のリスクはCookieより高いことを理解する必要があります。
後者に関してはセッション管理というよりは認証方式の話なのですが、ユーザーデータに紐づく一意のキーを発行し、そのキーによってセッションユーザーを識別するという手法であり、セッションIDを発行する方法と本質的な違いはありません。
こちらもトークンの保存場所には注意を払う必要があり、クライアント環境にCookieが利用できない環境を想定に入れる場合において、初めてその意味を成すものといえます。
APIをステートフルなものにしてしまうことに抵抗感はあるものの、今回のような従来のCookieによるセッション管理が行える状況においては、その方法が一番セキュアで柔軟性のある方法だと考えています。
■ 認証機能
アプリの認証周りの機能として「サインイン」を始め「メールアドレス認証」や「Remember me」、「パスワード忘れ」といった機能を実装しています。これらの機能はGraphQLの使用有無に関わらず、Actix Webでのアプリ構築であれば同じように使えるようなものになっていると思います。どれも特別複雑なことはしておらず、一般的なフルスタックWeb FWでおこなわれている実装と同じような内容としています。
※ 厳密にテストを行ったわけではないので、流用は自己責任でお願いします。
■ サーバーのホットリロード
開発環境におけるRustパッケージの実行はCargo Watchを用いて行っています。このツールは、プロジェクト内のファイルを監視し、ファイルに変更があったタイミングで任意のコマンドを実行させることのできるツールです。
Cargo Watchを用いて、こちらで紹介されているように、以下のコマンドでプロジェクトを実行することで、ファイルに変更があったタイミングでビルドチェックを行い、ビルドチェックをパスできた場合のみアプリを自動的にビルド、再起動させることができます。
$ cargo watch -x check -s 'touch .trigger' -i .trigger
$ cargo watch --no-gitignore -w .trigger -x run
1つ目のコマンドでプロジェクト内に変更があったタイミングにcargo checkを実行し、チェックをパスした場合touchコマンドで.trigger
ファイルを更新、2つ目のコマンドで.trigger
ファイルの変更を監視し、変更があったタイミングでcargo runを実行するという内容になっています。
既知のテクニックなのでご存知の方も多いとは思うのですが、Cargo Watchを使えばRustがWebアプリ開発言語に早変わりします。もし手動でのプロジェクト再起動にストレスを感じている方がいれば、ぜひ一度試してみることをお勧めします。
☆ フロントエンド
■ 技術スタック
使用技術 | |
---|---|
言語 | TypeScript |
View | React |
FW | Next.js |
UIコンポーネント | Chakra UI |
Form | React Hook Form |
Validation | zod |
GraphQL / 状態管理 | Apollo Client |
フロントはReact、Next.js、Apollo Clientというそこそこ目にする構成で作成しています。
ReactはシームレスなUXが必要で定期的な機能拡張が予定されているプロジェクト以外での使用はよく検討するべき、というスタンスなのですが、今後の機能拡張は予定していないにせよ、アプリの特徴的にはそういった類のものなので、今回はReactを採用することにしました。
Apollo Clientに関しては、とりあえず有名だからという理由で選定しましたが、ドキュメントには明記のない仕様なのか不具合なのかわからない予期せぬ挙動が少し目立ち、それを理由に仕様変更が発生するといったことがありました。似たライブラリに、RelayやUrql等がありますが、もし仮にまたReact + GraphQLという組み合わせでアプリを構築する機会があれば、このうちのどちらかを選択すると思います。
とはいえ、全体の完成度は非常に高く、扱いやすいライブラリであることは間違いないと思います。
■ コンポーネント粒度
コンポーネントの作成粒度は「Atomic design」をベースに、独自に「Interaction」と「Layout」を加えた以下の7つのレベルに分割して作成しています。
-
Atom
あらゆるUIコンポーネントの最小単位。
これ以上UIとしての機能性を破壊しない最小の要素。
複数の文脈においての使いまわしを想定した設計とする。 -
Interaction
ホバー等のUIインタラクションをコンポーザブルなコンポーネントとして作成したもの。
複数の文脈においての使いまわしを想定した設計とする。 -
Molecule
2つ以上のAtomが結合して作成されたコンポーネント。
Atom層の機能を組み合わせてユーザに具体的な動機付けを行い、
ユーザが意識してやりたいと思っていることに対して機能を与える。
複数の文脈においての使いまわしを想定した設計とする。 -
Organism
2つ以上のAtom, Moleculeが結合して作成される。
単一のコンポーネントで完結するコンテンツの提供を行う。
独立したコンポーネントとし、異なる文脈においての使いまわしを想定しない。 -
Layout
レイアウトパターンをコンポーザブルなコンポーネントとして作成したもの。
今回はHTMLのスケルトンのみ作成。 -
Template
Organism, Molecule, Atom等のコンポーネントを実際のサービスのページと同様に配置したもの。
ページのひな形。具体的なコンテンツを持ち合わせない。 -
Page
Templateコンポーネントに実際のコンテンツを流し込んだもの。
ユーザが実際にプロダクト上で触れるもの。
基本的にはこちらの書籍を参考に、自分の使いやすい形にアレンジを加えたものとしています。
MoleculeとOrganismの差別化
本アプリのMoleculeとOrganismの差別化について、プロジェクト内に用意してあるOrganismの説明には「独立して存在できるスタンドアローンなコンポーネントか否か」との旨を記載しているのですが、より正確には「コンポーネントがアプリケーションの文脈に依存するか」、簡潔に言えば「Propsとして受け取る値の型定義に、GraphQLスキーマから自動生成された型を含むか」という区別を行っています。
例えば、プロジェクト内にContactCardとContactInfoHeadという2つのコンポーネントが存在しており、どちらも連絡先のユーザー情報を表示するというコンポーネントなのですが、
ContactCardはMolecule層、ContactInfoHeadはOrganism層のコンポーネントとして作成しています。
各コンポーネントのProps型定義は以下となっています。
・ContactCardのProps型
・ContactInfoHeadのProps型
ContactInfoHeadはGraphQLスキーマから自動生成された型情報、つまりこのアプリ固有のデータ構造をPropsとして受け取るのに対し、ContactCardの受け取るPropsの値は、基本的なデータ型のみで構成されています。
このように、Organismはアプリ固有の文脈を持った、基本的には汎用性のないコンポーネントであるのに対し、Moleculeはアプリの文脈に依存しない、別プロジェクトでも利用可能な汎用的なコンポーネント、という位置付けによって差別化を行っています。
Page層の役割
コンポーネント実装は、上述通り基本的にAtomic designに準拠しています。
その為、アプリのデータは全てPage層コンポーネントにて取得し、各下位コンポーネントはPropsでデータを受け取ることによってアプリの状態を参照しています。
今回はそれに加え、データのRefetch関数やMutate関数も全てPage層で生成したものを下位コンポーネントに渡すようにしています。
この方法については賛否が分かれるところだと思います。
一般にデータ取得はそのデータを使用する場所で行った方が楽でわかりやすいし、何よりバケツリレーで何層もデータを受け渡していくのは確実に面倒な作業です。
しかし、あえてこの方法を取るメリットとして、各コンポーネントと状態管理で使用してるライブラリとの結合度を下げられる点にあると思います。
各コンポーネントは上位コンポーネントから受け取ったデータを表示し、必要に応じて受け取った関数を実行するだけのことしか行わないので、同じインターフェースを持ったデータや関数であれば、その生成元を問わずコンポーネントに受け渡すことが可能になります。
これはいわばDIのようなもので、例えばデータ取得元のAPIが用意できていない状態においてもコンポーネントの実装およびテストを可能にし、
また、ある程度開発が進んだ段階において、何らかの事情で状態管理ライブラリを別のライブラリに変更するといったことが起きたとしても、データの構造を同一のものにさえしてしまえば、それまで実装したコンポーネントを修正なしにそのまま利用することが可能になります。
しかし、この方法は上記通り、やはりかなり面倒な側面もあります。実装中、最下層コンポーネントにてそれまで想定してなかったデータが追加で必要になり、そのための修正として上位何層ものコンポーネントに影響が及ぶ、というのは想像に容易いと思います。
その辺はトレードオフなもので、各プロジェクトの特性を鑑みてどういった方式を採用するのが一番効率がよいのか見極める必要があります。
■ コンポーネント実装
各コンポーネントは「Presenter」と「Container」という2つの層に分けて実装しています。
これは、先述のAtomic designの書籍で紹介されていたテクニックなのですが、
PresenterはDOM構造を構築することだけに注力するステートレスなコンポーネントとして実装し、Containerはアプリの状態を持ってPresenterを包括し、Presenterの外部からアプリの状態を接続する役割を持ったコンポーネントとして実装する、というアプローチをとることで、ロジックとUIの実装を切り離し、後の改修や依存ライブラリの変更時に手を加えやすいものにするという狙いのもと行っています。
各コンポーネントの記述量は増えますが、
アプリ全体を通してコンポーネントのコードスタイルが体系立てて統一され、可読性や保守性に優れたものになると考えます。
■ 無限スクロール
無限スクロールの実装にはReact Virtuosoを利用しています。
Reactで無限スクロールを実現できるライブラリは、react-infinite-scroll-componentや、react-virtualized等、そこそこ選択肢があるのですが、どのライブラリも一長一短といったところで、何を選択するにしてもある程度冒険する必要があると思います。
そんな中でReact Virtuosoは、比較的少なめの記述量で無限スクロールが実現でき、かつカスタマイズ可能な項目も数が用意されているため、現状でいえば一番少ない労力で作りたいスクロールが再現できるのではないかと思います。
4. おわりに
なかなかまとまった時間が確保できず、突貫で作ってそのままにしている箇所や、もっと最適化できる箇所が多く残っているのですが、冒頭に書いた通り、そのまま動くサンプルとして誰かの役に立つかもしれないので一旦今の状態で公開します。
使用技術について、そもそもGraphQLを利用したいだけならRust以外の言語を選択した方がよいし、フロントも単純なHTML/CSS/JavaScriptで構築した方が、Reactで作るよりも遥かに安上がりで素早くできます。
しかし、あえてそういう遠回りのような行為から得られる経験も、プログラミングの醍醐味のひとつだと思っています。
今後も時間を見つけては、いろいろとやってみようと考えています。
Discussion
有益な記事の投稿ありがとうございます。
早速フォークして動かそうとしてみたのですが、
docker compose up -d にてbackend の cargo install cargo-watch にてバージョン絡み(zbus)のエラーで落ちてしまいました。。自分で直せれば良かったのですが、ちょっと手こずってしまい、もし宜しければ見ていただけないでしょうか?
ありがとうございます、私の方でも再現しました。
結論から申し上げますと
backend/Dockerfile
の2行目で指定しているrustのタグ(バージョン)をlatest等に更新することで解決できるとおもいます。コメントいただいたように、cargo installによってインストールされる今時点最新のcargo-watchの依存しているzbusというcrateが、ver3.00以降にrustのverに縛りを設けており、Dockerfile内で指定していたrustのver1.57ではzbus^2.0.0しかindexに登録されておらず、cargo-watchの要求するzbus^3.10という依存関係を解決できないといった状態がおきていました。
(正確にはcargo-watch → notify-rust → zbus)
その為、rustのver upをすることで当該エラーが発生しなくなることが確認できました。
cargo installでインストールするcargo-watchのバージョンを固定するべきでしたね、、
今後に生かしたいと思います、コメントありがとうございました!
返信いただいたのに気づかずすみません。
その後、自分でも気づいてrust のタグをlatestにして一応、サーバ起動までは行けたのですが、
アカウント登録でエラーになってしまいました。筆者さんの環境ではうまく行くのでしょうか?
(READMEの通りにGCP系の設定をしたつもりなのですが、その辺りは自分も自信ありません><)
いちから環境作ってやってみたのですが、私の環境では上手くいくようでした。
アカウント作成時にメアド認証のため、認証メールを送信する処理が入っているのですが、
仮にアカウント作成時、プロフィール画像を指定しない状態でサインアップを実行してもエラーが発生するようであれば、SMTPサーバーの設定値が誤っている可能性があります。
具体的には
.env.dev
ファイルの「MAIL_HOST」「MAIL_USERNAME」「MAIL_PASSWORD」に指定する値になります。指定する値についてGmailを用いた例で紹介すると、「MAIL_HOST」には「smtp.gmail.com」、「MAIL_USERNAME」には自分のGmailアドレス、「MAIL_PASSWORD」にはアプリパスワード(参考)を指定することで、簡単に設定することが可能です。
すみませんが、今一度ご確認のほどお願いします。
再度、検証していただきありがとうございます。しかも、ビンゴでした!
MAIL_PASSWORDにアプリパスワードにしたところ、アカウント登録できました。
ありがとうございます!
早速いろいろ使ってみて、また自分用にアレンジしてみたいと思います。
誤字報告です。タイトルがActicWebになってしまっています。
修正しました。ありがとうございます!