Upgrade to Pro — share decks privately, control downloads, hide ads and more …

RustでWeb開発コソコソ噂話

Yuki Toyoda
October 24, 2024
9.5k

 RustでWeb開発コソコソ噂話

下記に登壇した際の資料です。
https://findy.connpass.com/event/331621/

スライドはメモ書き程度のものとなっており、実際には講演の中で口頭で数多くの補足が入っています。講演の内容をまとめた記事も近日公開される予定なので、あわせてご覧ください。

Yuki Toyoda

October 24, 2024
Tweet

Transcript

  1. 『Rust による Web アプリケーション開発』について 2024 年 9 月 26 日刊行。

    「現場に Rust を導入し、バックエンド開発をするならどう作るか?」を主眼に置いてい る。 すでに Rust への入門は済ませた方向けの一冊。
  2. 私の担当章 1 章: はじめに 3 章: 最小構成アプリケーションの実装 4 章: 蔵書管理サーバーアプリケーションの設計

    7 章: アプリケーションの運用 8 章: エコシステムの紹介 ならびに、各章に追加した「コラム」等のうちいくつかが含まれる。
  3. 「リアルワールドな」実装を目指した よくあるこの手の本への課題意識 「サンプルで作り方はなんとなくわかった。で、私たちのアプリケーションはどう 作ればいいの?」 実装の元ネタは、 「Rust の新しい HTTP サーバーのクレート Axum

    をフルに活用してサ ーバーサイドアプリケーション開発をしてみる」という記事。 https://blog-dry.com/entry/2021/12/26/002649 『Zero To Production In Rust』から影響を受けた。 ただありものを実装して終わりの本ではなく、考え方を伝える本にすることを目指 した。 十分とは言えないが、保守運用に関する Tips を入れた。
  4. Rust でバックエンド開発ってどうなんだ? 下記は書籍に書いた意見ですが… バックエンド開発はどのプログラミング言語を使おうとも、欲しいリターンはそれなり に得られる傾向にある。 Ruby on Rails とか PHP

    とか Java、TypeScript、Python などがメインストリームと思 われるけれど、 中には Haskell で作っている会社もある。 そういうわけでいいとは思うんだけど、 「システムプログラミング言語を利用するのはオ ーバーキルでは」という疑義は拭えないと思われる。
  5. 意外に書き方に迷うことがない やはり Kotlin との比較になってしまうが、書き方に迷うことが意外になく、意外に統一 感が出る。 Kotlin の場合、 データの表現に class、data class、value

    class、enum、sealed interface など、 本当にさまざまな表現方法がある。 どう違うん?となりがち。 他だと、例外と Result 型が混ざったり。 手段を複数取れると、人によって解決策にばらつきが出がちになる。 Rust の場合、意外に 1 か 2 パターンくらいに実装のバリアンスが収まる印象。
  6. 運用は楽だったかも? あくまで、 「広告配信サーバー」が前提です。 普通に実装しても広告配信サーバーに必要な QPS に耐えられるくらいのパフォーマンス を楽に出せる。 JVM 系言語で広告配信サーバーを実装していたことはあるが、結構いろんなプロフ ァイルをして、緩和策を敷いてという頑張りを行なっていた印象がある。

    Rust 側はほとんどハックした記憶がない。JVM 系言語だったときは結構がんばっ た記憶がある。 メモリの動きの予測が立ちやすいような気がする。 ここはスタック、ここはヒープ、みたいなのが明示的。 単に GC がなく、オブジェクトが残り続けている…みたいなことが起こらない。 StW もない。 VM に対する変なパラメータチューニングはもちろん不要。
  7. 「難しいんでしょ?」と常に言われること バックエンド開発に限っていえば、単にリクエストを受け取ってデータベースとのやり とりをし、レスポンスを返すだけなので…と言いたいところだが。 async/await や futures、tokio をしっかり使おうとすると難しい場面が出てくる。 ライフタイムと非同期処理が絡むと難しさは確かに増幅される。 が、futures と組み合わせると解消できる場面も多くあるような?(パッと例は出て

    きませんが…) ただ Scala の経験から行くと、Akka Streams や cats-effect みたいなライブラリを 入れる時に同様の「難しさ」を感じたことがある。 非同期処理というか Future というかストリームというかそういう概念自体がそもそ も難しいのかも。 なので、両手をあげて「難しくなんかないですよ」とは言えない。 ただ、手に負えないほど難しいわけではない、とも言いたい。
  8. 「Web に使うのは非合理的だ、意味ない」と言われること 「用途違い」という主旨の主張の場合、たとえば Kotlin を入れるのと同じでは?と思っ ている。 システムプログラミング用の Rust みたいな言語をバックエンド開発にいれるのは、 ほとんど

    Android 用みたいな言語をバックエンド開発に入れるのと同じ構図。 Kotlin 自体、言語自体のデザインの尖り度合いのバランスがよくて使っている(vs Java、Scala) 「オーバーキルだ」という主旨の主張の場合 要するに他の言語なら簡単に達成できる目標に、わざわざ余計な難しさを投入して いないか?という話。だいたいのケースがそうでしょうね。 Rust の言語デザインあるいはシンタックスが好きで入れているケースが多そう。そ してそれは必ずしも悪いことではない。 Swift や Scala ではダメなのか…?→ 難しい理由がいくつかある。
  9. 書籍で紹介した Rust の実装テクニック クリーンアーキテクチャ(風) DI の実装 AppRegistryImpl と動的ディスパッチ エラーハンドリング モデル変換バケツリレーには

    From Newtype Pattern 関連し合う型同士の情報を型に落としておく フィーチャーフラグを使った実装のオン・オフの管理 cargo workspace 「こうすればよかった」と思う箇所
  10. クリーンアーキテクチャ(風) 「レイヤードアーキテクチャ」 「オニオンアーキテクチャ」 api 、 kernel 、 adapter の 3

    層に分けた。 kernel と adapter はいわゆる DIP を施している。 レイヤードアーキテクチャを採用し、DIP させた例としては、単によく見る実装だか ら。説明用であって、推奨しているわけではない。
  11. AppRegistryImpl と動的ディスパッチ #[derive(Clone)] pub struct AppRegistryImpl { health_check_repository: Arc<dyn HealthCheckRepository>,

    book_repository: Arc<dyn BookRepository>, auth_repository: Arc<dyn AuthRepository>, checkout_repository: Arc<dyn CheckoutRepository>, user_repository: Arc<dyn UserRepository>, }
  12. AppRegistryExt DI コンテナをテストでモックできるようにトレイトに切り出している。 #[automock] pub trait AppRegistryExt { fn health_check_repository(&self)

    -> Arc<dyn HealthCheckRepository>; fn book_repository(&self) -> Arc<dyn BookRepository>; fn auth_repository(&self) -> Arc<dyn AuthRepository>; fn checkout_repository(&self) -> Arc<dyn CheckoutRepository>; fn user_repository(&self) -> Arc<dyn UserRepository>; }
  13. State Axum の State は、Axum のサーバー全体で保持する状態を管理するための機構。 書籍では State しか利用していないが、 FromRef

    トレイトを実装させることで、子の State を作り、それをハンドラ側で取り出させる、みたいな実装も可能。
  14. State の利用例 registry から BookRepository を呼び出している。ここには、依存関係が解決された状態 の BookRepository が来ている。 pub

    async fn register_book( user: AuthorizedUser, State(registry): State<AppRegistry>, Json(req): Json<CreateBookRequest>, ) -> AppResult<StatusCode> { req.validate(&())?; registry .book_repository() .create(req.into(), user.id()) .await .map(|_| StatusCode::CREATED) }
  15. アプリケーション内でのエラーハンドリング Rust には ? があるので、基本的にその伝播をハンドラーまで回している。最後、ハンドラー がさらにエラーごとに HTTP ステータスコードを切り替える。エラーハンドリングの方針は プロジェクトによりけりだと思うので、参考程度に。 pub

    async fn login( State(registry): State<AppRegistry>, Json(req): Json<LoginRequest>, ) -> AppResult<Json<AccessTokenResponse>> { // 略 let user_id = registry .auth_repository() .verify_user(&req.email, &req.password) .await?; let access_token = registry .auth_repository() .create_token(CreateToken::new(user_id)) .await?; // 略 }
  16. 続き: エラーごとに返す HTTP ステータスを決めるハンドリング impl IntoResponse for AppError { fn

    into_response(self) -> axum::response::Response { let status_code = match self { AppError::UnprocessableEntity(_) => { StatusCode::UNPROCESSABLE_ENTITY } // 略 e @ (AppError::TransactionError(_) // 略 | AppError::ConversionEntityError(_)) => { tracing::error!( error.cause_chain = ?e, error.message = %e, "Unexpected error happened" ); StatusCode::INTERNAL_SERVER_ERROR } }; // 略 } }
  17. モデル変換バケツリレー impl From<CreateBookRequest> for CreateBook { fn from(value: CreateBookRequest) ->

    Self { let CreateBookRequest { title, author, isbn, description, } = value; CreateBook { title, author, isbn, description, } } }
  18. Newtype Pattern ID 型など、通常は UUID 型などで全統一しておいてもよいものの、使う ID の取り違えを防げ ると有益なケースがある。Newtype Pattern

    を使うと、中身は同じだが実質別物として判定 され、取り違えるとコンパイルエラーを発生させることができる。オーバーヘッドはない。 書籍内ではマクロを使って関連実装を自動生成させている。 pub struct BookId(uuid::Uuid);
  19. RedisKey 、RedisValue まずは二つのトレイトを用意し、Key 側にどの Value の型を持ちうるかの情報を持たせられ るよう、関連型を利用する。 pub trait RedisKey

    { type Value: RedisValue + TryFrom<String, Error = AppError>; fn inner(&self) -> String; } pub trait RedisValue { fn inner(&self) -> String; }
  20. 実際に適用する pub struct AuthorizationKey(String); pub struct AuthorizedUserId(UserId); impl RedisKey for

    AuthorizationKey { type Value = AuthorizedUserId; fn inner(&self) -> String { self.0.clone() } } impl RedisValue for AuthorizedUserId { fn inner(&self) -> String { self.0.to_string() } }
  21. フィーチャーフラグを使った実装のオン・オフの管理 utoipa で OpenAPI 向け実装を生やす際に使用している。 今回は、下記のような要件とした。 ローカル環境では OpenAPI が欲しい。 本番リリース時には

    OpenAPI は不要。 utoipa はマクロでいろいろ実装を生やすが、本番モジュール(リリースビルド)にはそ うした実装を含めたくない。 こういったケースでフィーチャーフラグが使える。
  22. サーバーの起動時 #[cfg(debug_assertions)] とすると、デバッグビルドでのみコンパイル対象とできる。 async fn bootstrap() -> Result<()> { //

    略 let router = Router::new().merge(v1::routes()).merge(auth::routes()); #[cfg(debug_assertions)] let router = router.merge(Redoc::with_url("/docs", ApiDoc::openapi())); // 略
  23. 各ハンドラにおける制御 #[cfg_attr(debug_assertions, ...)] で制御することができる。 #[cfg_attr( debug_assertions, utoipa::path(post, path="/api/v1/books", request_body =

    CreateBookRequest, responses( (status = 201, description = " 蔵書の登録に成功した場合。"), (status = 400, description = " リクエストのパラメータに不備があった場合。"), (status = 401, description = " 認証されていないユーザーがアクセスした場合。"), (status = 422, description = " リクエストした蔵書の登録に失敗した場合。") ) ) )] // 略 pub async fn register_book(/* 略 */) {}
  24. cargo workspace 一つのワークスペース下で、パッケージごとに api 、 kernel 、 adapter を切るのも 悪くないとは思うが、年月を経るとだんだん肥大化する。

    ワークスペースひとつだと、たとえば api に変更を入れただけでも kernel と adapter のコンパイルないしはチェックが走る。 これにより、コンパイル時間が増大する可能性がある。
  25. cargo workspace 上記のような問題の解決策として、cargo workspace という機能が利用できる。レイヤ ードアーキテクチャとは相性がいいように思う。 複数プロジェクトを管理できるようになる機能。本書では、 api 、 kernel

    などの単位 をワークスペースにしている。 他のプログラミング言語用のビルドツールとかで見る。Gradle とか、sbt とかでは割と 普通の機能ではある。 workspace 機能を利用すると、変更しておらず再コンパイル不要なプロジェクトをコン パイル対象外に置いてくれるケースが増える。
  26. 本書で「こうすればよかった」と思う箇所 Always Valid Domain Model に従えばよかった。 ドメインモデルは、常に valid な状態でしか生成されないということ。 つまりバリデーションチェックを

    kernel のデータを生成する瞬間に行わせればよ かった。 単にコードが無意味なバケツリレーをしているように見えるため。