この記事は一休.com Advent Calendar 2023 25日目の記事です。
一休レストランでは、よりスムーズな予約体験の提供を目的とするシステムのリニューアルを進めています。その一環として、2023年10月から、レストラン個別ページの表示から予約までのスマートフォンビューにおいて、バックエンドのサーバをRustで書かれたものに置き換えました。
一休レストランの Rust バックエンドが正式リリースされました。https://t.co/7N4VGv5ej9 このページのスマートフォンビューはバックエンドが Rust で書かれた GraphQL になってます
— naoya (@naoya_ito) October 4, 2023
本番運用が始まって3か月近く経ちましたが、これまで安定して継続的な開発と運用ができています。これはRustだからと構えることなく、「ふつう」のバックエンド開発を心がけてきたからだと考えています。
Advent Calendar 2023最終日は、一休レストランの開発チーム一同から、一休レストランのRustバックエンド開発の様子をお届けします。
Rustを選定した理由
一休レストランのリニューアル計画が始まったころ、一休では宿泊予約サービスや社内の基盤サービスを中心としてGoが標準的なバックエンドの技術スタックでした。
一休レストランの開発でも、宿泊予約サービスでの経験があるメンバーのスキルセットに基づいてGoを使うこともできました。その一方で、この方針だと社内の技術ポートフォリオがGoに偏ってしまうという懸念もありました。
一休では、社内で蓄積する技術的知見に多様性を持たせ、結果として状況に応じて最適な技術選定ができるように、複数のプログラミング言語を使うことを意図的に選択しています。
そこで、チームメンバーの中にRustに詳しいエンジニアがいたことも助けになり、Rustをバックエンドの言語として採用するかどうかを検討しました。
Rustの採用による狙いは次のとおりです。
- まず置き換えたい参照系処理のCPU利用効率を上げて、高速なバックエンドサーバとする
- 今後のさらなる開発を見据え、メモリ安全、型安全な開発体験を実現する
- 技術的知見の多様性という点で、関数型のメンタルモデルでプログラミングできるエンジニアを増やす
同時に、Rustの採用に対する次のような懸念も上がりました。
- 初めて使うエンジニアにとっては学習に時間がかかる
- ライブラリの自作が必要となるケースもありそう
Rustは公式ドキュメントやdocs.rsのリファレンスなどでドキュメントが充実しているので、学習曲線は急ではあるものの、学習自体は進めやすいと判断しました。
ライブラリについては、Rustから一休の基幹DBであるSQL Serverにどうやって接続するかという技術的な検証が必要でした。最終的には、Prismaが公開しているTiberiusというSQL Server用のDBドライバをベースとして、ある程度アプリケーションから使いやすいインタフェースのライブラリを整備することで開発が進められると判断できました。
これらの議論や調査に基づいて、一休レストランのバックエンドでRustを採用することになりました。
現在、一休レストランのバックエンドを開発するエンジニアは3人います。そのうち2人は、一休レストランの開発をきっかけに、はじめてRustを本格的に利用し始めました。豊富な学習リソースやRustに詳しいメンバーのヘルプを通じて、プロジェクト開始前の学習ではString
と&str
の違いを理解するところから始めたメンバーも、プロジェクト開始後はスムーズに開発できるようになりました。
現在のバックエンドのユースケース
ここからはRustでバックエンドを「ふつう」に開発するための、設計や実装における面白いポイントを紹介していきます。
現在は主に次のユースケースでバックエンドを利用しています。
レストラン情報の取得
店舗情報や予約可能時間など、レストランの情報をお客様に提供するための情報を取得します。機能はGraphQLのクエリとして提供しています。
今回はレストラン個別のページの表示から予約までのフローの置き換えを開発スコープとしたので、現在はこのユースケースが大半を占めています。後述のとおりコードベース上もデータの読み出しに関するコードが多いです。
予約の確保
お客様から入力いただいた情報をもとに予約を確保するエンドポイントをGraphQLのミューテーションとして提供しています。また、実際の予約処理は、予約処理モジュールを持つ既存の社内別サービスに委譲しています。
現在のアーキテクチャ
現在、アプリケーションのアーキテクチャとしてコマンドクエリ責務分離(CQRS)に基づいた構造を採用しています。つまり、データを読み出すだけのクエリと、データの作成や更新をするコマンドで、利用するモデルを分離する方式をとっています。
また、たとえばクエリの場合、DBとSolrそれぞれについてデータアクセス層を設け、GraphQLのデータローダーのようなシステムの界面に近い層からは、データアクセス層を通じてクエリモデルの形式でデータを取得します。
これらのモジュールはCargo workspaceを用いて管理しています。この点についてはあとで詳しく説明します。
各モジュールの紹介
上述した図における各層を構成するモジュールについて紹介します。
ドメインモデル
CQRSにおけるクエリとコマンドで利用するモデルを実装している層です。ドメインモデルは他のどのモジュールにも依存しません。また、クエリとコマンドは別モジュールとするためにcrateを分けています。
クエリモデルの例としては、レストラン詳細画面で表示する店舗情報があります。これらのデータは実際は複数のテーブルに存在しますが、クエリモデルはそのような実装詳細には依存せず、クエリの結果としてほしい構造を定義しています。実際には、SQL ServerもしくはSolrから得たデータをクエリモデルに変換して利用します。
#[derive(Debug, Clone)] pub struct Restaurant { pub id: RestaurantId, pub name: String, pub description: Option<String>, // ... }
コマンドモデルの例としてはお気に入り店舗登録用のコマンドモデルなどが存在します。こちらはまだ数が少ないので割愛します。
データアクセス層
実際のデータを取得するためのロジックを実装している層です。現在は、一休の基幹DBであるSQL Serverや、検索サーバであるSolrからデータを取得しています。このデータアクセス層の利用者に対して、取得したデータをもとにモデルのインスタンスを返します。つまり、ドメインモデルに依存します。
クエリを実行するときは、Serdeやserde_withを利用して、データストアから取得した生データをDTOにデシリアライズします。
mod dto { // ... #[serde_with::serde_as] #[derive(Debug, serde::Deserialize)] pub struct Restaurant { #[serde(rename = "restaurant_id")] #[serde_as(as = "serde_with::TryFromInto<i32>")] id: RestaurantId, #[serde(rename = "restaurant_name")] name: String, // ... } }
さらに、このDTOからクエリモデルに変換するためにstd::convert
のFrom
トレイトやTryFrom
トレイトを活用しています。詳しくは後述します。
GraphQLとHTTPサーバ
バックエンドはGraphQLを通じてフロントエンドにクエリとミューテーションを提供しています。このGraphQL APIの実装にはasync-graphqlを利用しています。async-graphqlはコードファーストでGraphQLスキーマを定義できるcrateです。
// Restaurant { // name // } // のようなスキーマをコードで定義 pub struct Restaurant(pub query_model::Restaurant); #[async_graphql::Object] impl Restaurant { async fn name(&self) -> &str { &self.0.name } // ... }
また、HTTPサーバとしてはAxumを利用しています。
これまではGraphQLなのでエンドポイント1つで済んでいましたが、最近は社内の他サービスと通信するためにインターナルなREST APIを作る機会も増えてきています。
ライブラリ
アプリケーションを構成するモジュールとは別に、独立したロジックをまとめたライブラリとしてのcrateもいくつか作成してworkspaceに含めています。これらのライブラリは他モジュールから利用されます。
たとえば、先述したTiberiusをベースにしたDBドライバや社内サービスのクライアント、他にはログなどの横断的関心事を扱うライブラリが存在します。
Rustによる開発のふりかえり
よかったこと
Rustはビジネスロジックを書くのにも便利
Rustの言語機能として、所有権やライフタイムのようにメモリ安全性を意識したものがよく注目されます。さらに、Webアプリケーションバックエンドを書くうえでは、Option
やResult
に代表される関数型言語のエッセンスを取り込んだ機能や、データ変換にまつわる機能も非常に便利だとあらためて感じました。
一休レストランは15年以上の歴史があるサービスです。このようなサービスは、しばしば歴史的事情からなるデータ構造やコードを多く持っています。たとえば有効な値とnullの両方が存在しうるカラムを扱うこともあります。このときにOption
を利用することで、ビジネスロジック上でnullにまつわるバグを避け、match式やif let式によって値がないケースをつねに考慮できます。
また、Webアプリケーションは無効な値を入力されたり外部のサービスとの通信に失敗するなど、つねにロジックが失敗する可能性があります。そのようなロジックでは返り値としてResult
1を使うことで、確実にエラーをハンドリングできます。また、?
演算子を利用することで、コードを簡潔に保ちつつエラーハンドリングできるのも便利な点です。
他には、一休レストランだと予約可能な時間や食事コースの検索結果などでコレクションを操作する場面が数多くあります。このようなときに、イテレータとmap
やfilter
のようなイテレータアダプタを利用することで、コレクションにまつわるビジネスロジックを簡潔に書けるのもよい点だと感じています。
アプリケーションの各層で型安全にデータを変換
先述したように、このアプリケーションでは複数のモジュールで責務を分けています。よって、そのままではデータアクセス層でデータストアから取得した生のデータをDTOを経由してクエリモデルに変換するロジックを書く必要が出てきます。
ここで、From
トレイトやTryFrom
トレイトを用いて型安全なデータの変換を実装することで、層の間で安全にデータを受け渡しできます。たとえばDTOをクエリモデルに変換するためにFrom
トレイトやTryFrom
トレイトをDTOに対して実装し、適切にモデルへ変換できるようにしています。
impl From<dto::Restaurant> for query_model::Restaurant { fn from(d: dto::Restaurant) -> Self { query_model::Restaurant { id: d.id, name: d.name, // ... } } }
このようにモデルに対して変換のためのトレイトを実装しておけば、あとはfrom
/try_from
やinto
/try_into
を使うだけで層の間の型安全なデータ変換が可能になります。
Cargo workspaceを活用した開発
Cargo workspaceを活用してモジュール間の依存関係を制御しながら開発できているのもよい点です。
リポジトリのルートディレクトリにあるCargo.tomlでは、workspaceのmembersとしてアプリケーション内の各モジュールを指定しています。そして、それらのモジュールをcrateとして実装し、各crateのCargo.tomlではアーキテクチャを意識して他のcrateへの依存関係を設定することで、意図しない依存はコンパイラによってエラーにできる構造にしています。
# ルートディレクトリのCargo.toml [workspace] resolver = "2" members = [ "backend/*", ] # データアクセス層のCargo.toml [package] name = "backend-data-access" version.workspace = true authors.workspace = true edition.workspace = true publish.workspace = true [dependencies] backend-query-model = { workspace = true }
また、モジュールをcrateに分離したことで、コードを変更したときに、変更のあったcrateとそのcrateに依存するcrateだけを再ビルドすればよくなりました。結果として、毎回アプリケーション全体をビルドせずに済み、開発時のビルド時間の短縮にも貢献しています。
パフォーマンスの向上
もちろんパフォーマンスの向上も当初の狙いどおり達成できた点であり、よかったことの1つです。
バックエンドはGoogle Cloud Runで運用しています。現在は年末年始でレストラン予約が非常に増える時期ですが、ピーク時でも3台程度のインスタンスでリクエストを受けることができています。
また、一休レストランのバックエンドの一部をRustに移行したことで、従来のPythonのバックエンドにおけるKubernetes DeploymentのReplicaSet数を次のように60程度から40程度に減らすことができました。
他には、バックエンドの高速化にともなってサービス全体の構成を最適化することで、一休レストラン全体のパフォーマンスが向上しました。こちらについてはチームメンバーのkozaiyが次の記事に詳しく書いたのでご覧ください。
もっとよくなると嬉しいこと
エコシステムのさらなる成熟
Webアプリケーションバックエンドを開発するうえで、さらにプラットフォームのRust対応が拡充されると開発が楽になりそうです。
たとえば、現在はCloud Runを使っているので、APMとしてCloud Traceを利用することにしました。しかし、公式にはRustのSDKが提供されていないことから、独自のライブラリを開発することで対応しています。
まとめ
この記事では、一休レストランにおいてRustを採用した理由と、Rustによる「ふつう」のWebアプリケーションバックエンド開発の様子について紹介しました。
Rustを採用したことで、期待どおり性能面で大きなメリットを得ることができました。また、RustやCargoの機能を適切に活用することで、生産性を保ちつつ今後の継続性も考慮した設計で開発を進めることができています。
新たにRustを利用し始めたチームメンバーからは、Rustに対する感想として
- 自分自身にプログラミングを教えてくれる言語だなと思いました
- プログラミングする上で、気にすべきポイントを気にさせてくれる言語
という声もあがっています。
今後のバックエンドの展望としては、よりよい予約体験の提供やレガシーシステムの改善を目的として、
- 高速なレスポンスが求められるレストラン検索
- レストラン予約のロジックなどのレガシーかつコアドメインであるモジュール
についてもRustで置き換えていく予定です。このような箇所では、高いパフォーマンスや型に守られた開発体験を提供してくれるRustを活かすことができるだろうと考えています。
このような技術的なチャレンジができる一休レストランのバックエンド開発に興味があるかたは、ぜひカジュアル面談応募ページや求人ページからご連絡ください。
-
一休レストランでは
anyhow::Result
を利用しています↩