Rustのバックエンド開発の最近の動向を追うのトップ画像

Rustのバックエンド開発の最近の動向を追う

投稿日時:
yukiのアイコン

Rust.Tokyo オーガナイザー

yuki

Xアカウントリンク

はじめに

yukiです。RustのカンファレンスであるRust.Tokyoのオーガナイザーを務めているほか、『実践Rustプログラミング入門』『RustによるWebアプリケーション開発』といった書籍を共著で執筆しました。

この記事のテーマは、近年利用が進み人気が高まるRustのバックエンド開発における動向です。前半で、現在人気のあるライブラリの動向を簡単にまとめます。次に、私が現在開発の動向に注目しているいくつかのライブラリについて紹介します。

人気のバックエンド開発ライブラリの動向

Rustによるバックエンド開発[1]では、やはり最近でも次の2つのクレートが選ばれる傾向にあるようです。「デファクトスタンダード」と呼べるくらいには、そろそろなってきたのではないでしょうか。

数年前であればactix-webが一強ではあったものの、近年はtokioチームが開発するaxumというクレートの方が、crates.ioのダウンロード数などをみる限りでは人気のようです。両者ともに、最低限エンドポイントを作れるような機能を提供しつつ、ミドルウェアと呼ばれるプラグイン機能を実装しておき、それを通じてユーザーは必要に応じてコードベースをカスタマイズできるという思想を持ちます。

大きな違いは次のとおりです。

  • actix-webは内部で普通にunsafeを利用している一方で、axumはまったくunsafeを利用していないことを謳っています。[2]
  • axumはtowerというミドルウェアを組み込むための抽象を提供するクレートをベースとしており、tower向けに作られたミドルウェアであれば互換性があります。
  • axumは、デフォルトではtokio非同期ランタイムでのみ動きますが、actix-webはtokio以外にも、actix_rtというtokioベースの独自のランタイム上でも動かすことができます。
  • axumはまだメジャーバージョンに達していませんが、actix-webはバージョン4に到達しています。axumは時折破壊的変更が入り、その修正に追われる作業が発生することがあります。

とくにaxumは実質Rustの代表的なバックエンド開発用のクレートとみなされているように見受けられます。たとえば最近何かと話題のMCPサーバーのRust向けSDKでは、MCPサーバーのうちSSE(Server-Sent Events)サーバーがaxumに統合されて立ち上がるようにライブラリが実装されています。[3]このことからも、axumは他と比べると少し優位な立ち位置を獲得しつつあると言えるかもしれません。

非同期ランタイムの動向

Rustでバックエンド開発を行う際、非同期ランタイムは欠かせない存在です。いわゆるasync.awaitというキーワードがRustにはあり、これをバックエンド開発では日常的に利用するからです。非同期ランタイムとは、OSなどのネイティブスレッドを用いず、ユーザーランドで仮想的なスレッドを用意してその上で並行処理を行う基盤のことを指します。その非同期ランタイムについても最近動きがありました。

まずそもそもですが、Rustは、言語自体は非同期ランタイムを提供していません。これにはいくつか理由がありますが、大きいのはRustはいわゆるベアメタルプログラミングからWebのようなプログラミングまで、かなり幅広く対応する言語だからだ、と私は思っています。こうしたニーズすべてに対応できる非同期ランタイムを提供するのは事実上不可能に近いと言えるでしょう。そのためRustとしては、言語標準では提供せず、サードパーティ製のものを利用することを想定していると私は理解しています。

余談ですが、よくRustの非同期ランタイムの乱立に関して指摘が入ることがあります。私が思うに、非同期処理において何が最適かというのは、それを動かす環境や用途に応じて比較的変わるものです。そうした細かい用途別にそれぞれのクレートが開発されており、きめ細やかな最適化を実現していると捉えることができると私は考えています。したがって、非同期ランタイムにいくつか種類があることは、結果としてユーザーの利益にもなると考えられます。

現状のサードパーティ製の最も有力な非同期ランタイムはtokioです。バックエンド開発の大半のケースでは、tokioを利用しておけば、まず間違いない選択肢になるでしょう。多くのバックエンド開発向けのライブラリやSDKなどは、tokio上で動かすことを前提として開発されているものが多いです。

その他の非同期ランタイムとして有力だったものに、async-stdがありました。tokioの対抗馬として注目を集めていましたが、今年に入って開発の中止が発表され、代わりに「smol」という非同期ランタイムの利用が推奨されることとなりました。async-stdは、実はほとんどsmolのラッパーに近い状態になっており、近年は開発も滞っていました。であれば、開発も引き続き活発なsmolを利用した方がよいという判断理由のようです。

さてそのsmolですが、近年注目を集めてきているように見受けられます。smolは、非同期処理を扱ういくつかのクレートによって構成され、tokioと比較して軽量でシンプルな非同期ランタイムです。提供されるクレートにはたとえば、非同期I/Oを扱うasync-ioや、非同期ネットワーキングを扱うasync-netなどがあります。このような細かい単位でモジュール化されており、ユーザーはモジュールを自身のアプリケーションに組み込んで利用することができます。smolというクレートそれ自体は、各モジュールをre-exportしたものにすぎません。

たとえばtokioのようなクレートは、tokioというクレート全体でさまざまな機能を提供しており、ともすればバイナリサイズが大きくなったり、コンパイル時間が伸びたりします。tokioを実際使ってみるとわかりますが、意外に使う機能は一部に限られていることが多く、他の大半は不要というアプリケーションも多々あります。そうした場合、他の不要な機能に引っ張られてコンパイル時間が伸びたり、バイナリサイズが膨らんだりするわけですから、ここを節約したいというのはひとつの着目点になります。

tokioは基本的に多くのケースで非常によいパフォーマンスを発揮しますが、細かいチューニングが難しい構成になっています。たとえばtokioの並行処理基盤で採用されるアルゴリズムはWork Stealingと呼ばれるもので、現代の並行処理を必要とする多くのアプリケーションでは、このアルゴリズムでパフォーマンスを十分担保できます。が、中にはWork Stealingでは厳しいものもあります。この場合、並行処理基盤のアルゴリズムを差し替えたくなるわけですが、tokioの構成ではそれが非常に難しいです。

smolは、まず軽量な非同期ランタイムを謳っているだけあり、バイナリサイズがtokioよりも小さくなる傾向にあるようです。私が手元で確認した限りでも、「Hello, world!」という文字列を非同期ランタイム上で表示するだけのコードを書いた際、smolの方がtokioよりバイナリサイズが小さくなることを確認できました。HTTPサーバーを簡単に実装した結果を見比べても、smolの方がやはりtokioよりバイナリサイズが小さくなるようです。こちらのGitHubリポジトリで詳しい結果を確認できます。

また、smolは非同期処理の実行基盤を差し替えて利用することも可能なようにモジュール化されています。これによりたとえばWork Stealing以外のアルゴリズムを使用したいとなったとしても、実行基盤だけ自前で実装しておき、それ以外はsmolのモジュールを利用して実装するといったような差し替えを行うことができます。具体的な事例としては、Zedという最近話題のRust製エディタがあります。Zedに必要だったのは最低限の非同期タスクの管理や、ファイルシステムに対する操作、チャネルなどのみで、いわゆる非同期タスクの実行を司るエグゼキュータは自前で用意したものを使用しています。[4]

このほかにも、Bytedance社が開発を進める「Monoio」という非同期ランタイムにも注目しています。ただし、最近はGitHubリポジトリのコミットがやや停滞気味のようで、どのような開発体制やロードマップになっているのかが不明瞭な点には注意してください。

この非同期ランタイムは、tokioとはいくつか異なるデザインや非同期戦略を採用しています。まず、tokioのようなwork-stealingモデルではなくthread-per-coreモデルを採用しており設計が異なります。また、Linuxカーネル5.1で採用されたio_uringと呼ばれるシステムコールを使った非同期I/Oを扱えるように実装されています。アプリケーションの特性によっては、Monoioの方がパフォーマンスを発揮する可能性は大いにありえます。最適化の選択肢のひとつとして念頭に置いておくとよいでしょう。

近年開発の進む注目クレート

さて、非同期ランタイムの話題に一通りキャッチアップしたところで、次は近年開発が始まり私が注目しているいくつかのバックエンド開発向けクレートの紹介に移りたいと思います。poem、loco、cotについて今回は紹介します。

紹介にあたり、いくつか免責事項があります。

  • 本記事では、クレートの詳細な解説は行いません。各クレートにどのような特徴があるかを簡単に紹介するにとどめます。
  • クレートの紹介用のコードを実装するにあたって発見した、筆者の私も原因を最後まで詳しく追求できていないいくつかのバグが存在します。
  • 本番環境での利用を推奨するものではありません。これから紹介するクレートはまだまだ開発段階であり、axumやactix-webなどと比べると品質が安定しない可能性があります。
  • Rustの文法には入門済みであることを前提としています。文法に関する細かい解説は一切含まれません。

調査にあたり実装したサンプルコードは下記のリポジトリにあります。

poem

poemは2021年ごろにバージョン1.0を迎えたバックエンド開発向けのフレームワークです。他のaxumやactix-webなどと同じように使いやすさに重点を置いていますが、両者と比べるとpoemは、poemそれ自体で多くの機能を提供することも目標としているように見受けられます。

poem: https://github.com/poem-web/poem

poemはtokio上で動き、towerとの互換性があります。tokio以外の非同期ランタイム上で動くことは今のところ想定されておらず、たとえば、別の非同期ランタイムであるsmolへの差し替えは対応していなさそうです。また、ミドルウェアの追加にはtower::Servicetower::Layerが利用できます。[5]tower向けに用意した実装はPoemでも使い回し、活かすことができます。

poemも他のバックエンド開発向けのクレートと同様に、リクエストを処理し、レスポンスを返す「ハンドラ」と呼ばれる関数を定義します。この関数にパスやHTTPメソッドなどの情報を付与し、ルーターに設定して外部からアクセスできるようになります。ハンドラの実装は、#[handler]というマクロを設定するだけです。たとえば常にHTTPステータスコード204を返すハンドラは、次のように実装できます。

#[handler]  
async fn health_check() -> StatusCode {  
    StatusCode::NO_CONTENT  
}  

本記事では、この実装を便宜的に「オーソドックス」と呼びます。後述するOpenAPI側との実装の対比のためです。オーソドックスなハンドラは、他のaxumなどのクレートと大差なく、これだけでも十分活用できるかもしれません。

私が今回poemを触ってみて感じた大きな特徴は、OpenAPIドキュメントの提供機能との統合がよく考えられ、デザインされている点です。poemのOpenAPI周りの対応は、poem-openapiというクレートで提供されています。非常にシームレスにOpenAPIドキュメントを提供できると感じました。

たとえば、Rustの類似のライブラリとして有名な「axum」でOpenAPIドキュメントを生成させようとすると、「utoipa」というクレートを追加で使用する必要が出てきます。axumとutoipaの統合はうまくいっているかというとpoemほどではない印象を持ちました。poemでは、#[oai(...)]というマクロを呼び出すと、HTTPメソッドやパスをマクロで指定しつつ、OpenAPIドキュメントを生成します。ルーターを設定する際に追加でこれらを指定する必要はなく、すんなり統合できてしまう点が魅力的だと感じました。

下記のようなコードを書くと、定義したOpenAPI仕様に基づく設定を生成しつつ、同時にアプリケーションのエンドポイントを実装できます。

#[derive(Object, sqlx::FromRow)]  
struct Todo {  
    id: Uuid,  
    title: String,  
    description: String,  
    done: bool,  
}

#[OpenApi]  
impl Api {  
    #[oai(path = "/todo", method = "get")]  
    async fn list(&self, pool: Data<&Pool<Postgres>>) -> Result<Json<Vec<Todo>>> {  
        let todos = sqlx::query_as::<_, Todo>("SELECT * FROM todo")  
            .fetch_all(pool.0)  
            .await  
            .with_context(|| "Failed to fetch todos")?;  
        Ok(Json(todos))  
    }  
}

#[tokio::main]  
async fn main() -> anyhow::Result<()> {  
    // ...略...  
    let api_service =  
        OpenApiService::new(Api, "Hello World", "1.0").server(format!("http://{}", addr));  
    let ui = api_service.redoc();  
    let app = Route::new()  
        .at("/hc", get(health_check))  
        .nest("/", api_service)  
        .nest("/docs", ui)  
        .data(pool);

    Server::new(TcpListener::bind(addr))  
        .run(app)  
        .await  
        .with_context(|| "Failed to run server")  
}  

poemはOpenAPI以外にも、AWS Lambda向けの基盤を提供しているほか、最近話題のMCPサーバー向けの実装基盤も提供しているようです。私もMCPサーバーの実装を試してみましたが、普通にHTTPサーバーを実装するのと同じ感覚で実装することができました。(サンプル実装[6]

一方で、私が個人的に実務での利用がまだ難しいのではないかと感じた点は、ドキュメントや例が比較的不足していることです。オーソドックスなバックエンド用のクレート側ではまずまずドキュメントがあるようですが、OpenAPIやMCPサーバーなどはかなり記述が乏しく、どのようにクレートを使ったらよいかを把握するのに苦労する場面も多々ありました。

また注意点として、OpenAPI向けの実装とオーソドックスな実装との間にはあまり互換性がありません。つまり、オーソドックス側を実装して、あとからOpenAPI向けに実装を付け足そうとすると、実質フルリプレースになるので苦労するということです。OpenAPI側はPoemにおいては追加のオプションではなくて、オーソドックスかOpenAPIかのどちらかを最初から選んでおかなければならないものだと理解しておくとよいでしょう。

ただそれを差し置いても、OpenAPIとの統合がシームレスなのは魅力的だなと感じました。開発するアプリケーションの規模が大きくなればなるほど、チームも増え分業も進むわけですが、そうした状況下ではOpenAPIのようなツールは欠かすことができません。axumとutoipaでもできなくはないのですが、poemのシームレスさには及ばないと個人的には思いました。

開発それ自体は活発そうで、今後の動きに注目しておきたいクレートです。バージョン1.0を迎えてはいるので、大きな破壊的変更がないと期待できるのも注目ポイントかもしれません。

loco

locoはRuby on RailsをRustに持ち込んだ、通称Rust on Railsとして時々話題にのぼります(locoは「Locomotive = 機関車」の略称で、線路の上で走る機関車、というイメージでしょうか)。バージョンはまだ0.15.0であり、メジャーバージョンに到達するまでは互換性のない変更が入る可能性は十分にあります。本番での利用には少し注意が必要です。もっとも、Rustのエコシステムではよくあることですが…。

loco: https://github.com/loco-rs/loco

locoそれ自体は、バックエンド開発用のライブラリであるaxumと、O/RマッパーであるSeaORMを組み合わせ、さらにいくつかの便利ライブラリ(たとえばバリデーションチェックにvalidator、JSONのシリアライズ・デシリアライズにserdeを利用するなど)を組み合わせて構築されています。SeaORMはActive Recordそのままという印象を持っており、これを起点にRuby on Railsとほとんど遜色ない開発体験が提供されています。

Ruby on Railsにある機能はだいたい揃っている印象で、たとえばScaffoldのような便利なCLIの機能から、バックグラウンドジョブやメーラーあたりも取り揃えられています。Ruby on Railsでの開発に慣れた方であれば、将来的にはこちらを利用するとプログラミング言語だけRustにして、その他の開発体験はRuby on Railsに揃えるといったことも可能ではないでしょうか。

私も少しプロジェクトを立ち上げて遊んでみたので、以降でどのような開発体験だったかを簡単に紹介します。

まず、locoを始めるには2つのCLIツールをインストールする必要があります。

cargo install loco  
cargo install sea-orm-cli  
  • loco: locoでの開発中、サーバーを起動したりDBマイグレーションをかけたりなど、さまざまな場面で利用されるツールです。
  • sea-orm-cli: SeaORMが提供するCLIツールです。loco-cliでマイグレーションをかける際、どうやら内部的にこれを呼び出す必要があるようで、インストールを求められます。

次に、下記コマンドで新しいプロジェクトを開始できます。あとは、指示に従って入力を行うと、新しいプロジェクトを開始することができます。

loco new  

ただ、ここで一つ不具合が見つかりました。loco-cliの現在の最新バージョンであるv0.13.0でloco newコマンドを実行しようとすると、筆者の環境では下記のようなエラーが発生します。

🙀 failed to read directory `/var/folders/yk/c94zcxj96qn6jc9zm9zgd4qw0000gq/T/8Ql9fcWKmcj3NxUVp7FN/starters`  

loco newコマンドが内部でGitを使ってlocoのGitHub上のリポジトリ https://github.com/loco-rs/loco をクローンし、startersというディレクトリを参照するという処理をしていることが原因のようです。このときmasterブランチを参照しています。ところが、執筆時点で最新のmasterブランチからは、startersディレクトリが削除されています。どうやらv0.15.0からstartersディレクトリが削除されてしまっているようです。startersはLocoによるプロジェクト作成時に使用できるいくつかのテンプレートを提供するディレクトリでした。

困ったことに執筆時点(2025年4月上旬)では、次のようなワークアラウンドを実行しなければ動かすことができないようです。[7]

  1. GitHubのlocoのリポジトリ本体をクローンする。
  2. クローンしたリポジトリのディレクトリに入り、タグを切り替えてv0.14.1を使用する。
  3. export STARTERS_LOCAL_PATH=<クローンしたリポジトリのディレクトリパス>の環境変数を設定する。
  4. loco newを実行する。

さてこれでようやく、どのテンプレートからlocoプロジェクトを開始するかを選択できるようになりました。今回は「Rest API」を選択しておきます。データベースに接続したいためです。データベースはPostgreSQL、バックグラウンドワーカーの種別はAsyncを選択しました。

yukiimage1.png

ここからは少しだけToDoアプリのバックエンドを実装してみます。

RailsにもあるScaffoldがせっかく使えるようなので、Scaffoldを利用してToDoアプリ用のAPIを簡単に用意してみましょう。すると、次のようにScaffoldが実行され、データベースのテーブルやモデルが追加されたことがわかります。実行時には裏でテストも走り、一通りエンドポイントが正しく動作することが保証されてから、開発を進めることができるようです。

cargo loco generate scaffold todo title:string! description:string! done:bool! --api  
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s  
     Running `target/debug/todo_loco-cli generate scaffold todo 'title:string'!'' 'description:string'!'' 'done:bool'!'' --api`  
skipped (exists): "migration/src/m20250405_133050_todos.rs"  
skipped (exists): "tests/models/todos.rs"  
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s  
     Running `target/debug/tool db migrate`  
2025-04-05T13:30:51.525869Z  INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development  
2025-04-05T13:30:51.529000Z  WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development  
2025-04-05T13:30:51.637048Z  WARN app: loco_rs::boot: migrate: environment=development  
2025-04-05T13:30:51.641930Z  INFO app: sea_orm_migration::migrator: Applying all pending migrations environment=development  
2025-04-05T13:30:51.647416Z  INFO app: sea_orm_migration::migrator: No pending migrations environment=development  
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s  
     Running `target/debug/tool db entities`  
2025-04-05T13:30:52.885427Z  INFO app: loco_rs::config: loading environment from selected_path="config/development.yaml" environment=development  
2025-04-05T13:30:52.888589Z  WARN app: loco_rs::boot: pretty backtraces are enabled (this is great for development but has a runtime cost for production. disable with `logger.pretty_backtrace` in your config yaml) environment=development  
2025-04-05T13:30:52.960655Z  WARN app: loco_rs::boot: entities: environment=development  
Connecting to Postgres ...  
Discovering schema ...  
... discovered.  
Generating todo.rs  
    > Column `todo_id`: Uuid, not_null  
    > Column `title`: String, not_null  
    > Column `description`: Option<String>  
    > Column `due`: Option<DateTime>  
    > Column `status`: String, not_null  
    > Column `created_at`: DateTime, not_null  
    > Column `updated_at`: DateTime, not_null  
Generating todos.rs  
    > Column `created_at`: DateTimeWithTimeZone, not_null  
    > Column `updated_at`: DateTimeWithTimeZone, not_null  
    > Column `id`: i32, auto_increment, not_null  
    > Column `title`: Option<String>  
    > Column `description`: Option<String>  
    > Column `done`: Option<bool>  
Generating users.rs  
    > Column `created_at`: DateTimeWithTimeZone, not_null  
    > Column `updated_at`: DateTimeWithTimeZone, not_null  
    > Column `id`: i32, auto_increment, not_null  
    > Column `pid`: Uuid, not_null  
    > Column `email`: String, not_null, unique  
    > Column `password`: String, not_null  
    > Column `api_key`: String, not_null, unique  
    > Column `name`: String, not_null  
    > Column `reset_token`: Option<String>  
    > Column `reset_sent_at`: Option<DateTimeWithTimeZone>  
    > Column `email_verification_token`: Option<String>  
    > Column `email_verification_sent_at`: Option<DateTimeWithTimeZone>  
    > Column `email_verified_at`: Option<DateTimeWithTimeZone>  
Writing src/models/_entities/todo.rs  
Writing src/models/_entities/todos.rs  
Writing src/models/_entities/users.rs  
Writing src/models/_entities/mod.rs  
Writing src/models/_entities/prelude.rs  
... Done.  
2025-04-05T13:30:53.678990Z  WARN app: loco_rs::boot:  environment=development  
added: "src/controllers/todo.rs"  
injected: "src/controllers/mod.rs"  
injected: "src/app.rs"  
added: "tests/requests/todo.rs"  
injected: "tests/requests/mod.rs"  
* Controller `Todo` was added successfully.  
* Tests for controller `Todo` was added successfully. Run `cargo run test`.  

モデルの情報は、scaffoldの引数で与えています。最初のtodoがモデルの名前、それ以降が、各プロパティとその型を定義する引数です。Railsではテーブル名は複数形が使用されますが、locoも同様に複数形になります。[8]なので、todotodosとして生成されます。

cargo loco generate scaffold todo title:string! description:string! done:bool! --api  

モデルはSeaORMベースのものが下記のように生成されました。

//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.8

use sea_orm::entity::prelude::*;  
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]  
#[sea_orm(table_name = "todos")]  
pub struct Model {  
    pub created_at: DateTimeWithTimeZone,  
    pub updated_at: DateTimeWithTimeZone,  
    #[sea_orm(primary_key)]  
    pub id: i32,  
    pub title: Option<String>,  
    pub description: Option<String>,  
    pub done: Option<bool>,  
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]  
pub enum Relation {}  

また、コントローラは次のようにCRUDするエンドポイントが自動生成されています。

use axum::debug_handler;  
use loco_rs::prelude::*;  
use serde::{Deserialize, Serialize};

use crate::models::_entities::todos::{ActiveModel, Entity, Model};

#[derive(Clone, Debug, Serialize, Deserialize)]  
pub struct Params {  
    pub title: Option<String>,  
    pub description: Option<String>,  
    pub done: Option<bool>,  
}

impl Params {  
    fn update(&self, item: &mut ActiveModel) {  
        item.title = Set(self.title.clone());  
        item.description = Set(self.description.clone());  
        item.done = Set(self.done.clone());  
    }  
}

async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {  
    let item = Entity::find_by_id(id).one(&ctx.db).await?;  
    item.ok_or_else(|| Error::NotFound)  
}

#[debug_handler]  
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {  
    format::json(Entity::find().all(&ctx.db).await?)  
}

#[debug_handler]  
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {  
    let mut item = ActiveModel {  
        ..Default::default()  
    };  
    params.update(&mut item);  
    let item = item.insert(&ctx.db).await?;  
    format::json(item)  
}

#[debug_handler]  
pub async fn update(  
    Path(id): Path<i32>,  
    State(ctx): State<AppContext>,  
    Json(params): Json<Params>,  
) -> Result<Response> {  
    let item = load_item(&ctx, id).await?;  
    let mut item = item.into_active_model();  
    params.update(&mut item);  
    let item = item.update(&ctx.db).await?;  
    format::json(item)  
}

#[debug_handler]  
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {  
    load_item(&ctx, id).await?.delete(&ctx.db).await?;  
    format::empty()  
}

#[debug_handler]  
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {  
    format::json(load_item(&ctx, id).await?)  
}

pub fn routes() -> Routes {  
    Routes::new()  
        .prefix("api/todos/")  
        .add("/", get(list))  
        .add("/", post(add))  
        .add(":id", get(get_one))  
        .add(":id", delete(remove))  
        .add(":id", put(update))  
        .add(":id", patch(update))  
}  

あとは実際にcurlなどでタスクを追加してみると、無事にToDoリストに内容を追加できることを確認できます。

最後に実用性と将来性を、私の主観ですが簡単に整理します。

実用面ですが、少し品質が不安定なようには感じられました。裏を返せばコントリビューションチャンスが多そうです。技術選定されるプロジェクトの期間や開発に余裕があるようでしたら、OSSへのコントリビュートをかねてぜひ、という肌感を持ちました。

将来性ですが、Ruby on Railsやそれに類するフレームワークは非常に高い生産性を得られることから、多くの支持を得ていることは周知の事実です。そのため、このまま開発が進みメジャーバージョンになるのが非常に楽しみではあるなと思っています。Rustには、axumやActix-Webのような軽量なライブラリは実用レベルで存在しているのですが、この手の「全部入り」のフレームワークはまだまだないのが実情です。選択肢が広がるという意味でも重要なプロジェクトであると私は考えています。

開発それ自体はかなり活発そうで、やはり今後の動きに着目しておきたいクレートです。

cot

Ruby on Railsに影響を受けたものがあれば、当然PythonのDjangoインスパイアなものもあるだろう…と思うかもしれません。cotはまさに、Djangoインスパイアなバックエンド開発向けフレームワークです。

cot: https://github.com/cot-rs/cot

cotは現在もまだ開発中のDjangoベースのバックエンド開発向けフレームワークで、現在はバージョン0.2.0が最新です。そもそも最初のバージョン0.1.0が今年の2月に公開されたかなり新しいクレートです。

cotそれ自体は、やはりlocoと同様にaxumをベースにしつつ、データベース操作周りはSeaORMを開発する組織が公開するSeaQueryというクエリビルダーをベースに、それをラップする形で自分たちで実装しているようです。

cotを始めるためには、やはりcot専用のCLIツールが用意されているため、それをインストールします。

cargo install cot-cli  

次に、CLIツールを使って新しいプロジェクトを作成します。

cot new todo_coto  

作成後、プロジェクトのディレクトリに移動し、cargo runすることでアプリケーションを起動できます。

cd todo_coto  
cargo run  

さて、ToDo用のデータモデルを用意してみましょう。まずは次のように実装を追加します。#[model]というマクロと構造体で実装することができます。カラムのメタ情報もマクロを通じて付与する方式のようです。

#[model]  
pub struct Todo {  
    // UUIDはまだ未対応らしい。  
    #[model(primary_key)]  
    id: Auto<i64>,  
    title: String,  
    description: String,  
    done: bool,  
}  

UUIDv4をIDとしたかったのですが、これはまだ未対応のようでした。実装がそもそもなさそうなので、Auto Incrementのi64型でIDを生成するように実装を調整しています。

cotはデータベースのマイグレーション機構を内部に持っているので、それを利用しながらスキーマの管理を行います。下記のように cot migration makeコマンドを実行すると、先ほど定義したTodo構造体の情報を読み取り、マイグレーションファイルを生成してくれます。

cot migration make  
    Creating Model 'todo'  
     Created Model 'todo'  
    Creating Migration 'm_0001_initial'  
    Creating Migration file '/Users/toyoda/github/private/yuk1ty/recent-backend-investigations/todo_cot/src/migrations/m_0001_initial.rs'  
     Created Migration file '/Users/toyoda/github/private/yuk1ty/recent-backend-investigations/todo_cot/src/migrations/m_0001_initial.rs'  
     Created Migration 'm_0001_initial'  

ToDoのリストの受け取りや、新規作成、削除をするハンドラを簡単に実装し、ルーターに情報を登録します。

#[model]  
pub struct Todo {  
    // UUIDはまだ未対応らしい。  
    #[model(primary_key)]  
    id: Auto<i64>,  
    title: String,  
    description: String,  
    done: bool,  
}

#[derive(Deserialize)]  
pub struct CreateTodoReq {  
    title: String,  
    description: String,  
}

#[derive(Serialize)]  
pub struct TodoRes {  
    id: i64,  
    title: String,  
    description: String,  
    done: bool,  
}

async fn list_todos(RequestDb(db): RequestDb) -> cot::Result<Response> {  
    let todos = Todo::objects().all(&db).await?;

    let response = Response::new_json(  
        StatusCode::OK,  
        &todos  
            .iter()  
            .map(|todo| TodoRes {  
                id: todo.id.unwrap(),  
                title: todo.title.clone(),  
                description: todo.description.clone(),  
                done: todo.done,  
            })  
            .collect::<Vec<TodoRes>>(),  
    )?;  
    Ok(response)  
}

async fn create_todo(  
    RequestDb(db): RequestDb,  
    Json(req): Json<CreateTodoReq>,  
) -> cot::Result<Response> {  
    let mut todo = Todo {  
        id: Auto::default(),  
        title: req.title,  
        description: req.description,  
        done: false,  
    };

    todo.insert(&db).await?;

    Ok(Response::builder()  
        .status(StatusCode::CREATED)  
        .body(Body::empty())  
        .unwrap())  
}

async fn delete_todo(RequestDb(db): RequestDb, Path(id): Path<i64>) -> cot::Result<Response> {  
    query!(Todo, $id == id).delete(&db).await?;  
    Ok(Response::builder()  
        .status(StatusCode::NO_CONTENT)  
        .body(Body::empty())  
        .unwrap())  
}

struct TodoCotApp;

impl App for TodoCotApp {  
    // 略

    fn router(&self) -> Router {  
        Router::with_urls([  
            Route::with_handler_and_name("/", index, "index"),  
            Route::with_handler_and_name("/todos", list_todos, "list-todo"),  
            Route::with_handler_and_name("/todos", create_todo, "create-todo"),  
            Route::with_handler_and_name("/todos/:id", delete_todo, "delete-todo"),  
        ])  
    }

	// 略  
}  

特徴的なのは、Djangoを利用した際と同じようなデータベースの呼び出し方法になっていることでしょうか。たとえば、リストを取得する下記のコードは、DjangoでもそのままTodo.objects.all()という呼び出し方をします。

async fn list_todos(RequestDb(db): RequestDb) -> cot::Result<Response> {  
    let todos = Todo::objects().all(&db).await?;  
    // ...続く  

locoもそうでしたが、こうしたフレームワークはユーザーをAPIの開発に集中させてくれます。axumのような薄いバックエンド用クレートを利用すると、データベースへの接続部分などは自分で実装する必要があります。この部分は意外に苦労するポイントで、開発時に少し時間を使います。locoやcotはYAMLの設定ファイルを編集するだけで接続設定が終わるため、APIのハンドラ部分を実装、ルーターに登録するだけで作業が完結します。

最後に実用性と将来性を、私の主観ですが簡単に整理します。

実用面ですが、そもそもGitHubのページにも書いてある通り、まだまだ実験段階であり本番での利用には注意が必要です。機能面で見ても、たとえばUUIDによるID生成は対応していなさそうなど、開発途上であることがわかります。本番利用してフィードバックをしながら開発を自身も進めるといった関わり方は可能ではありそうですが、locoと比較しても機能が少なく、まだまだ足りないものにたくさん直面しそうです。

将来性ですが、今のところは開発も活発ですし、何よりDjangoベースのバックエンド開発向けフレームワークはこれまでRustにはありませんでした。まだまだ開発がはじまったばかりですので、将来のことは何も言及しようがありません。しかしもし仮に今後メジャーバージョンまで到達するようなことがあれば、Djangoに慣れた開発者にとっての第一の選択肢になりうる可能性はあると思います。

最後に

Rustのバックエンド開発での利用は、国内のみならず海外的にも進んでいるようです。とくにRust公式が毎年行っている調査では、Rustの利用事例として最も多いのが実は、バックエンド開発における利用であるということがわかっています。この結果を踏まえ、Rust開発それ自体においても、バックエンドの非同期処理をより良くするための開発が、高い優先度で進められています。[9]

人気の高まりに対してエコシステムがどうなのかは気になるポイントではありますが、ひとまず本番運用していて問題ないレベルには揃っていると思います。ただ、他のプログラミング言語にあるようなライブラリはだいたいあるといえばあるのですが、たとえばGoogle CloudのRust向けSDKがまだ公式からは用意されていないなど、細かいところで微妙に足りない場面に当たることはあるかもしれません。ただし総じて、筆者個人としてはエコシステムの不足で困る場面に出会ったことはなかったなと思います。[10]

RustはたしかにいわゆるOS開発をはじめとするシステムプログラミングのための言語として設計されていますし、またそうした評価を受けています。しかしそうした評判や印象とは裏腹に、バックエンド開発のようなレイヤーで使用しても生産性高く開発を進めることができる印象です。Rustの使いやすい(エルゴノミックな)ツール周りや文法機能の恩恵を受けられることはもちろん、最大の強みである高効率なリソース使用の恩恵を受けられるのは、アプリケーションの性質やビジネスの形態によっては大きなメリットとなりえます。

もちろんコーディングしている最中に、所有権やライフタイムのような他のプログラミング言語を利用していれば考えなくても済んだようなことを考えなければならないのは、確かな事実ではあります。しかし、このあたりの選択はトレードオフです。考えなくてもよかったことに気を配る代わりに、大きなリターンを得られることもあるのです。したがって、何を目的とし、どこを狙いとするかをきちんと明確化して導入するのが肝要です。その追加の労力に意味を見出せるかが重要だからです。それらの労力はまったく無駄な労力ではなく、大きなリターンを得るために必要な過程なのだ、という点については改めてお伝えしておきたいと思います。

昨年私は、Rustでのバックエンド開発が気になっている方向けに、『RustによるWebアプリケーション開発』という本を共著で執筆しました。この本は私ならびに共著者の実務でのRust利用経験から、Rustでバックエンド開発を行う際に必要になるエッセンスを詰め込んで説明した一冊です。この記事を読んで、Rustによるバックエンド開発が気になり始めた方はぜひ、本書を手に取ってみてください。ただしRustの文法については一通り入門済みであることを前提としています。

■査読:kymmt90(@kymmt90

脚注
  1. バックエンド開発とは、要するにたとえばHTTPやProtocol Buffersなどに乗ったリクエストをフロントエンドから受け取り、中身を解析して所定の処理を行い、結果をフロントエンド側に再び返す一連の処理を行うサーバーを開発することを指します。その際、データベースに接続し、データベース上にあるデータを取得し、処理したのちにフロントエンドが求める形式で返す部分まで含むこともあります。

  2. axumは具体的には、クレートに対してunsafe_code = "forbid"を有効化しています(執筆時点でのCargo.tomlにおける該当箇所)。これによりコードベースではunsafeを使用することができなくなっています。

  3. https://github.com/modelcontextprotocol/rust-sdk

  4. Async Rust: https://zed.dev/blog/zed-decoded-async-rust?curius=2011

  5. READMEに「tower::Service and tower::Layer compatibility.」と記載があります。https://github.com/poem-web/poem/blob/d501357676f5e7c5f94074bf32246ccb15a2d557/poem/README.md

  6. ただしドキュメントはまだまだ不足しており、少しでも横道に逸れた実装をしようとすると、ライブラリのコードの解読から始まります。

  7. 私の環境でのみうまく動作しなかったのか、そうでなかったのかはわかりませんでした。関連するIssueもとくに見つかっておらず、もしかすると私の環境の問題なのかもしれません。

  8. 私もあまりRailsに詳しいわけではないのですが、こちらのガイドに説明があります。モデルのクラス名は単数形が使用され、データベースのテーブル名は複数形が使用されるという規約になっています。

  9. 最近のRustの開発では、Project Goalsという開発目標が半年単位で設定され、動き始めています。今年は2025H1というProject Goalsで、非同期Rustの開発を進めることが発表されています。

  10. 筆者は最近Kotlinを業務で使用していますが、Kotlinの方ではむしろ、ピュアKotlinを追求しようとするとエコシステムで足りないなと思うものに出会うことが多いです。Javaの資産に乗ればよいというのはそうなのですが。

プロフィール