2024-01-08
Rustでバックエンドを快適に書くためにはどうしたら良いだろうか?
観点の一つとして、フロントエンドとバックエンドの間の通信における型安全性をどうするか?という話題がある。参考にTypeScriptのエコシステムを調べると、以下のようなアプローチがされている。
- Next.js
- T3-OSS: Create T3 App フルスタックtype safe Next.jsフレームワーク。tRPCを利用。
- Nuxt.js
- Nuxt3のuseFetchの型定義を探索してみたら結構面白かった話 - ROXX開発者ブログ
useFetch
を用いて型安全なルーティングを実現する。nitro
というサーバエンジンがserver/
ディレクトリに配置した関数の返り値を解釈して型定義を生成してくれる。これによってフロントエンドでパスの文字列を引数に入れたuseFetch
の戻り値はサーバ側の実装から自動で決定される。
- tRPC
- サーバサイドはTypeScript
- サーバサイドでproceduresと呼ばれる関数を定義する。これはZodを用いたバリデーションと、スキーマを含む。
- 続いてクライアントサイドで
createTRPCProxyClient<T>
としてサーバから型T
を渡してやることで、通信におけるクエリの型を決定する。
- buf/Connect
- どちらかというとGo
- IDLとしてProtocol Buffersを採用し、スキーマに基づいてメソッドを自動生成する。
他にもOpenAPI Generatorなどがある。
以上からいくつかアイデアが出てきた 前提として、フロントエンドはTypeScriptで書かれていて、TypeScriptとスキーマ記述言語との相互運用性を考えている。
方針1: スキーマ記述言語(IDL)を中間表現として、IDLとTypeScriptの間はよしなにやってくれると信じて Rust backend <=> IDLの間を考える
- Rust backend側でマクロを用いてスキーマ記述言語を生成する
- スキーマ記述言語からRust backendのコードを生成する
- backendのフレームワークの数だけ生成部分を書く必要がありそうでつらそう
- 抽象度の高いハンドラーを自動生成すればいけるという説はある
方針2: IDLを採用せず、Rust backend <=> TypeScriptの間で直接やり取りする
- Rust backend側でマクロを用いてTypeScriptの型定義ファイルを生成する
- TypeScriptの型定義ファイルからRustの関数を生成する
方針1,2いずれにせよおそらく双方向性が大切で、例えば一回IDLからRust backendを生成しても、変更をIDLに入れた時にRust backendコードを再度生成するとコンフリクトする。 理想的にはIDLの変更からRust backendコードを生成してvalidateのようなものが掛けられたり、clippy lintのように自動fixができると良い。さらに、実装側が変更を加えたときにそれがIDLと乖離することもあるだろうから、それを検知するためにRust側からIDLを生成して、本質的なdiffが導出できると良い。
まとめると、generate(双方向に生成)からのvalidate(構造的な本質差分がわかる)、さらにlint(自動fix)の3つの仕組みがあればいい。
試しにIDLを独自に小さく定義して、これができないかpocを作ってみる。
minIDLとRustのコードのInteropのpocの構想
NuxtのuseFetchの仕組みが素晴らしいと思うので、IDLの要件として、以下を考える
- RESTで通信し、JSONで、websocket等は考慮しない
(Endpoint, Request, Response, Options)
の組が書かれているendpoint
はパスoptions
はmethod
やheaders
などの追加情報
こうすればフロントエンド側でendpointの文字列をfetchのwrapperに渡した時にRequestとResponseの型が決定されるだろう。
JSONの型は string
, number
, true
, false
, null
, array
, object
がある。参考: JSON 今回は object
, array
, string
のみを考えてみる。
本当は type: T | Error
みたいなことをしたいけど、そこまでパースするのは本題ではないので諦める。
minIDL(今回のpoc用途の小さなIDL)は以下のような雰囲気とする。
- endpoint: /api/users
response:
type: array
items:
type: object
properties:
name:
type: string
options:
method: GET
- endpoint: /api/users
request:
type: object
properties:
name:
type: string
password:
type: string
response:
type: object
properties:
user_id:
type: string
options:
method: POST
headers:
Content-Type: application/json
Authorization: Bearer {token}
- endpoint: /api/users/{user_id}
response:
type: object
properties:
name:
type: string
options:
method: GET
headers:
Authorization: Bearer {token}
目標:
- 上のminIDLからRustのコードを生成する
- Rustのコードから上のIDLを生成する
pocやってみる
RustでWebバックエンドを書き始めてから1年くらい経った を読んで気になっていた hyper, routerify を用いて薄いルーティングを行う。
routerify, 3年前で更新止まっているからhyperとの噛み合わせが難しい
hyperのChangelog を眺めるとexampleの hyper::Body
が hyper::body::Incoming
になっているようなので直す。
うーんrouterifyのexampleが動かないので今回はhyperをバージョン下げて0.14で動かす。 このあたり結局枯れたソフトウェアのために依存関係が破壊的変更をすると困る例のやつで、hyperは割と低レベルAPIだけど破壊的変更をしてくるところで困っているという感じがする。(まあしょうがない)
バージョンを合わせて動いた。
exampleコードを眺めるとそもそも hyper::Body
になっていてスキーマがない。低レベルAPIからやったら楽かと思ったけどそうでもないのか…?色々自分で一から書く必要を感じてpocにしては厳しい。
axumのextractor axum::extract - Rust が関連しそうだからこっち方面で考えてみる。
Next Action: axumのextractorを触ってみて、実現できそうか考える。
時間が来てしまったので切り上げる。