2024-01-08

Rustでバックエンドを快適に書くためにはどうしたら良いだろうか?

観点の一つとして、フロントエンドとバックエンドの間の通信における型安全性をどうするか?という話題がある。参考にTypeScriptのエコシステムを調べると、以下のようなアプローチがされている。

  • Next.js
    • T3-OSS: Create T3 App フルスタックtype safe Next.jsフレームワーク。tRPCを利用。
  • Nuxt.js
  • tRPC
    • サーバサイドはTypeScript
    • サーバサイドでproceduresと呼ばれる関数を定義する。これはZodを用いたバリデーションと、スキーマを含む。
    • 続いてクライアントサイドで createTRPCProxyClient<T> としてサーバから型 T を渡してやることで、通信におけるクエリの型を決定する。
  • buf/Connect
    • どちらかというとGo
    • IDLとしてProtocol Buffersを採用し、スキーマに基づいてメソッドを自動生成する。

他にもOpenAPI Generatorなどがある。

以上からいくつかアイデアが出てきた 前提として、フロントエンドはTypeScriptで書かれていて、TypeScriptとスキーマ記述言語との相互運用性を考えている。

方針1: スキーマ記述言語(IDL)を中間表現として、IDLとTypeScriptの間はよしなにやってくれると信じて Rust backend <=> IDLの間を考える

方針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 はパス
    • optionsmethodheaders などの追加情報

こうすればフロントエンド側で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::Bodyhyper::body::Incoming になっているようなので直す。

うーんrouterifyのexampleが動かないので今回はhyperをバージョン下げて0.14で動かす。 このあたり結局枯れたソフトウェアのために依存関係が破壊的変更をすると困る例のやつで、hyperは割と低レベルAPIだけど破壊的変更をしてくるところで困っているという感じがする。(まあしょうがない)

バージョンを合わせて動いた。

exampleコードを眺めるとそもそも hyper::Body になっていてスキーマがない。低レベルAPIからやったら楽かと思ったけどそうでもないのか…?色々自分で一から書く必要を感じてpocにしては厳しい。

axumのextractor axum::extract - Rust が関連しそうだからこっち方面で考えてみる。

Next Action: axumのextractorを触ってみて、実現できそうか考える。


時間が来てしまったので切り上げる。