書籍「RustによるWebアプリケーション開発 設計からリリース・運用まで」学習ログ
公式GitHubからテンプレートをClone
git clone git@github.com:rust-web-app-book/rusty-book-manager-template.git
フロントエンドの依存関係をインストールする
cd frontend
npm install
Node.js のバージョンが古かったので、アップデート
$ brew upgrade node
フロントエンドの起動テスト
$ npm run dev
car new には、2つのモードがある。
- bin mode
- lib mode
前者は、バイナリを用意したい場合に指定する。後者は、ライブラリを用意したい場合に使用する。lib モードは、--lib オプションで指定可能
axum という、HTTPサーバを実装するためのクレートを追加する
cargo add axum@0.7.5 --features macros
features フラグで、追加する機能を指定することができる。今回は、macros という機能を使うため、これを指定する。また、「@数字」でクレートのバージョンを指定することができる。
tokio というクレートを追加する。これは、axum によるHTTPサーバの実装に不可欠な、非同期ランタイムというクレートである。
$ cargo add tokio@1.37.0 --features full
Cargo.toml とは?
Cargo.toml は、cargo プロジェクト全体の管理情報を記録する設定ファイルである。
[package]
name = "rusty-book-manager"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7.5", features = ["macros"] }
tokio = { version = "1.37.0", features = ["full"] }
[]で囲まれた領域は、セクションと呼ばれる。dependenciesは、リリースビルドに含まれる依存関係を定義するセクションである。テストやベンチマーク時にのみ追加したいクレートがある場合は、--dev フラグをつけることで、テスト用の[dev-dependencies]セクションへ追加することができる。こうすることで、テスト時にのみ使うクレートを毎回ビルドすることがなくなり、ビルドのコストが減る。
テスト時の依存関係にクレートを追加してみる
$ cargo add --dev rstest@0.18.2
すると、ファイルが次のようになった
[package]
name = "rusty-book-manager"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7.5", features = ["macros"] }
tokio = { version = "1.37.0", features = ["full"] }
[dev-dependencies]
rstest = "0.18.2"
[dev-dependencies] セクションにテスト用のセクションが追加されている
axum とは?
axum は、Webフレームワークである。Webフレームワークは、次の機能を持つ場合が多い
- 特定ポートで待ち受けるサーバを立ち上げる
- HTTPリクエストを受け取り、パスやヘッダなどを解析し、それに応じた処理を行った後、HTTPレスポンスを返す
- サーバ内の状態を管理する
axum の機能
axum は、ハンドラとルータの2つの役割を持つ。
ハンドラ(handler):
リクエストが来た時に、どのような処理をし、どのようなレスポンスを返すかまでを担当
ルータ(router)
やってきたリクエストをどのように振り分けるかを、リクエストの情報をもとに決める
axum だけでは、大量のリクエストをさばいたり、認証などを行うことができない。そこで、機能を追加するためのインタフェースを用意する必要がある。
axum は、次の2つのクレートを使って、足りない機能を実装している。
- hyper: HTTPクライアントやHTTPサーバを立ち上げられる機能を提供
- tower: タイムアウトやレートリミット、認証、ロードバランシングなどの要素を「Service」と「Layer」という抽象的な単位にして、プラグイン的に機能を拡張することを可能にする。この単位を、middlewareと呼ぶ
- tokio: 非同期処理を実行するための基盤
レートリミット(rate limit)
システムやサービスが、一定時間以内に許可するリクエストの回数を制限する仕組み。これにより、過剰なアクセスによるシステム負荷の増大や、悪意のある攻撃(ブルートフォース攻撃, DoS攻撃など)から、システムを保護することができる。
tower とは?
towerは、クライアントとサーバのネットワーキングにおける処理や実装を、柔軟で平易に扱えることを目指したクレートである。Serviceという抽象的な処理単位があり、これをLayerという単位で積み重ねることで、1つの処理基盤を作ることができる。
Service:
tower::Service というトレイトで実装する。任意のResponseを受け取り、任意のResponseを返すということだけが定義されている。
Layer:
tower::layer::Layerというトレイトで実装する。Serviceを管理するための単位。
axumは、towerのサービスの連なりで実装されている。こうすることで、towerで定義したロギングや認証などのサービスを使いまわしたり、axum以外のフレームワークでも、towerのサービスを使うことができるなど、コード資産を再利用することができる。
非同期プログラミング(asynchronous programming)
大量のネットワーク接続のような、複数の処理を大量かつ並列に実行したい時に用いる手法。非同期タスクという抽象的な単位を生成し、これをスケジューラが管理するキューに登録する。スレッドよりも高速にタスクの実行やコンテキストスイッチを行うことができる。
I/O多重化
I/Oの待ち時間にCPUを別の処理に回すことで、単位あたりの処理能力を向上させる仕組み。
Future トレイト
Rust の非同期プログラミングの根幹となるトレイト。このトレイトを実装した型は、「まだ利用できないかもしれないが、将来値を生成する」ことを示す。Future トレイトは、次のように定義されている。
trait Future {
type Output;
fn poll(&mut self) -> Poll<Self::Output>;
}
Output という型エイリアス(type alias) には、そのFutureが返す値の型情報が設定される。たとえば、i32という型が設定されると、将来i32を生成する型であることを示すようになる。
poll 関数は、ポーリング(polling) を行う。Futureトレイトが実装された型は、自身の値が生成できる準備が整うまで、何度もポーリングを繰り返す。値の準備が整うと、それが準備できた旨を返す。それを示すのが、Poll型である。Pollはenum になっており、Pending(準備中)とReady(準備完了)の2つの状態を持つ。
enum Poll<T> {
Ready(T),
Pening
}
ただし、Future のみで非同期処理を実装しようとすると、以下のように複雑な記述になりかねない。
// i32の数値をもつ構造体
struct Number {
val: i32
}
// Number型に、Future トレイトを実装する。
impl Future for Number {
type Output = i32; // Future の返す値として、i32を指定
// 簡単のため、呼び出されると常にReadyで内部に保持する値を返す
// Pinは、ラップした内部の値をムーブさせないという取り決めをする型である。これは、Futureを実装する際に必要になる
fn poll (self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
std::task::Poll::Ready(self.val)
}
}
// i32 を返すFuture型の関数
fn a1() -> impl Future<Output = i32> {
Number { val: 1 }
}
fn a2() -> impl Future<Output = i32> {
Number { val: 2 }
}
fn ans(a: i32, b: i32) -> impl Future<Output = i32> {
Number { val: a + b }
}
fn main()
{
// block_on は、Futureを実行するために必要
// a1, a2を実行し、それをans関数に渡して足し算する
let ans = block_on(a1().then(|a| a2().then(move |b| ans(a, b))));
println!("{}", ans);
}
move を使うのはなぜ?
let ans = block_on(a1().then(|a| a2().then(move |b| ans(a, b))));
このコードでthenのクロージャ引数にmoveをつける理由は、外側のスコープにある値aをクロージャの実行時まで保持し、かつ所有権を渡すため。
背景
Rustのthenは、
fn then<Fut, F>(self, f: F) -> Then<Self, Fut, F>
where
F: FnOnce(Self::Output) -> Fut,
Fut: Future
のように、fnOnceクロージャを受け取る。つまり、クロージャは引数の値や外部のキャプチャした値を消費してよい契約になっている。
今回のケースでは、aは、|b| ans(a, b) 内部で使おうとしても、ひとつ外側のスコープに存在するため、ライフタイムが合わず、借用チェックで弾かれてしまう。そこで、moveをつけることで、内側のスコープで使用可能にしている。
非同期処理はすぐに実行されるかわからず、aが消費されたあとに実行される可能性を否定できない。そこで、内部のスコープににaの所有権を渡すことで、値を安全に保持できる。
where 記法
Rust のwhere は、ジェネリック型やトレイト境界の条件を、関数や型定義の後ろにまとめて書く構文のこと。「この型はこのトレイトを実装していなければならない」という条件を、わかりやすく書くことができる。
fn foo<T, U>(x: T, y: U) -> i32
where
T: Display, // TはDisplayトレイトを実装している必要がある
U: Debug + Clone, // UはDebugとCloneを両方実装している必要がある
{
// ...
}
where 使用せずに書くと
fn foo<T: Display, U: Debug + Clone>(x: T, y: U) -> i32 {
// ...
}
となる。複雑なトレイト要件を付与する場合、上の書き方はコードの視認性を下げる要因となる。
非同期処理ランタイムtokioを使うと、非同期化された処理を実行することができる。また、asyncやawaitといった、シンタックスシュガーを使うことができる。
axum を使用した簡易サーバの実装
axumを用いて、簡単なサーバを実装する。要件は次の通り
- localhostsの8080ポートをリッスンするサーバを立ち上げる
- /hello というエンドポイントにGETリクエストを送ると、Hello Worldというレスポンスを返す
APIエンドポイントとは?
APIエンドポイントは、アプリケーション・プログラミング・インターフェース(API)がサーバー上のリソースに対するAPI呼び出し(APIリクエストとも呼ばれます)を受信するデジタル空間の場所です。APIエンドポイントはAPIのコンポーネントであり、多くの場合、URLまたはユニフォーム・リソース・ロケーターの形式になっています。
&'static str とは?
- & : 参照を示す
- 'static: ライフタイム注釈。プログラムの開始から終了まで生き続ける
- str: 文字列型
std::net とは?
TCP/UDP通信のための低レベルネットワーキング機能。
このモジュールは、TCPとUDPおよびIPアドレスやソケットアドレスを扱う型を提供する。
- IpAddrは、IPv4とIPv6のIPアドレスを表す型である。Ipv4AddrとIpv6Addrは、それぞれIpv4とIpv6を表す型である。
- SocketAdderは、IPv4とIPv6へのソケットアドレスを表す型である。SocketAddrV4とSocketAddrV6は、それぞれIPv4とIPv6のソケットアドレスを表す型である。
// ローカルホストの8080番ポートでリクエストを待ち受ける
let addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 8080);
SocketAddr::new:
SocketAddr構造体を定義する。SocketAddrは、「IPアドレス+ポート番号」をまとめた型。new(ip: IpAddr, port: u16) というコンストラクタを持っている。
Ipv4Addr::LOCALHOST.into():
Ipv4Addrは、Intoトレイトを用いることで、IpAddr型という、IPv4とIPv6両方を扱える列挙型に変換できる。ここでは、IpAddr型にintoで変換している。
cargo-make
cargo上で動作するタスクランナー。複数のコマンドをまとめて扱ったり、依存関係を整理することができる
tokio::net::TcpListener とは?
接続を待ち受けるためのTCPソケットサーバ。accept methodなどを使うことで、新しい接続を受け取ることができる。TcpListenerは、TcpListenerStreamを使ってStream型に変換することができる。
関数シグネチャ
pub async fn bind<A: ToSocketAddrs>(addr: A) -> Result<TcpListener>
特定のアドレスにひもづけられたTcpListenerを生成する。返されるリスナーは、接続を受け入れる準備ができている。
ポート番号に0を指定してバインドすると、OSがこのリスナーにポートを割り当てます。割り当てられたポートは、local_addr メソッドで取得できる。
アドレス型は、ToSocketAddrs トレイトを実装する任意の型を指定できす。addr が複数のアドレスを返す場合、リスナーの生成に成功するまで、それぞれのアドレスで順にバインドを試みます。どのアドレスでもリスナーの生成に失敗した場合は、最後に試みたアドレスで発生したエラーが返される。
この関数は、ソケットに SO_REUSEADDR オプションを設定する。
使用例
use tokio::net::TcpListener;
use std::io;
#[tokio::main]
async fn main() -> io::Result<()> {
// 127.0.0.1:2345 で接続を受け付けるTcpListenerを生成する
let listener = TcpListener::bind("127.0.0.1:2345").await?;
// use the listener
Ok(())
}
bind関数は、Future型であるため「await」が付いている。これは、ToSocketAddrsが非同期にDNSの名前解決を行うため、リスナーが使えるようになるまで待機する必要があるから。今回は、IPアドレスを直接指定しているためDNSの名前解決は不要だが、一貫してFuture型が帰るため、awaitする必要がある。
.await?
? を付与することで、Futureの結果であるResult 型を受け取ることができ、実行結果を呼び出し元に伝播することができる。unwrapの場合と異なり、実行結果がErrであっても、パニックしない。
impl とは?
型に対してメソッドや関連関数、トレイト実装を定義するための構文。implは、2つの使い方がある。
固有実装
ある型に専用のメソッドや関連関数を追加する。トレイトに依存せず、その型だけで使える機能を定義する。
struct Example {
number: i32,
}
// Example 構造体に機能を追加している
impl Example {
fn boo() {
println!("boo! Example::boo() was called!");
}
fn answer(&mut self) {
self.number += 42;
}
fn get_number(&self) -> i32 {
self.number
}
}
トレイト実装
型が特定のトレイトを実装するために使う。これにより、その型でトレイトが要求するメソッドが使えるようになる。
struct Example {
number: i32,
}
trait Thingy {
fn do_thingy(&self);
}
// Example 構造体に、Thingy トレイトを実装している
impl Thingy for Example {
fn do_thingy(&self) {
println!("doing a thing! also, number is {}!", self.number);
}
}
サンプルコードを実装して実行したところ、
$ curl http://127.0.0.1:8080/hello -v
* Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
* using HTTP/1.x
> GET /hello HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.12.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< content-type: text/plain; charset=utf-8
< content-length: 11
< date: Sun, 10 Aug 2025 05:36:42 GMT
<
* Connection #0 to host 127.0.0.1 left intact
Hello World%
無事、エンドポイントが動作することが確認できた。
なぜ、ハンドラを非同期型で実装するのか?
axumは、Future型のハンドラを前提とした設計になっているから。
Rust上では、asyncは、Future型を返す関数に脱糖される。サーバは、このFuture型の関数を実行する。これは、I/OやDBへのアクセスといった待ちを含む処理をブロックせずに実行するため。
たとえawaitを含む処理を記述しなくても、axumの要件としてasync型である必要がある。
ヘルスチェック(helth check)とは?
アプリケーションの運用時に、そのアプリケーションが正常に稼働しているかどうかを、特定のエンドポイントへアクセスして確認すること。
p.47 より
ヘルスチェック用のハンドラの実装要件
- リクエストを受け取ると、HTTPステータスコード200(OK)を返す
- "/health" というパスに実装する
anyhow クレートの追加
エラーハンドリング用に、anyhow というクレートを追加する。Rustでは、エラーハンドリング用にResult型を使うが、この型を利用したエラーハンドリングでは、追加でanyhowを使うことが多い。
anyhow の利点
- 他のエラー型を anyhow::Error という型に変換することができる。これによりクレート間でのエラー型を統一的に扱うことができ、実装上の複雑さが下がる
- エラーのバックトレース(プログラムの実行中に発生したエラーや例外の原因を特定するために、関数やメソッドの呼び出し履歴を遡って表示する)をすることができる
p.48 より
anyhow の実装
main関数の返り値を、anyhowのResult型に変更
#[tokio::main]
async fn main() -> Result<()> {
listenerの提供処理を、Result型でラップする
Ok(axum::serve(listener, app).await?)
}
この処理は
- サーバを起動して終了まで待つ
- 途中でErrが発生したら、呼び出し元に戻す
- 正常したらOk(())を返す
を1行にまとめている。
axum::serve(listener, app).await?;
Ok(())
}
と書いても等価である。
p.58より
単体テスト(Unit Test)
関数などの小さなロジック・コード単位に対して行われるテスト。実行時間が短く、隔離された環境で行われるケースが多い。プログラム全体の品質を担保する上で、欠かせない。
p.52
cargo-nextest
テストを並列に実行できたり、不安定なテストを複数回行えたりなど。既存のcargo testよりも高機能なテストプラグイン
$ cargo install cargo-next
でインストール
$ cargo nextest run
で起動
Finished `test` profile [unoptimized + debuginfo] target(s) in 10.46s
────────────
Nextest run ID 92f8ae3c-30f4-4c41-b9ca-d62462f4fc1a with nextest profile: default
Starting 1 test across 1 binary
PASS [ 0.018s] rusty-book-manager::bin/rusty-book-manager health_check_works
────────────
Summary [ 0.019s] 1 test run: 1 passed, 0 skipped
実行完了
sqlx とは?
Rustでデータベースにアクセスし、データベース上のデータを取得する際に用いられるクレート。クエリビルダーと呼ばれるもので、SQLクエリを直接書いてデータベースを操作する。
ORMと比べて、データベースクエリの発行がブラックボックスになりにくい反面、毎回SQLを書かなくてはならない手間がある。今回は、演習のためsqlxを使用する。
$ cargo add sqlx@0.7.3 --features runtime-tokio,uuid,chrono,macros,postgres,migrate
それぞれ
runtime-tokio: tokioとの統合を行う
uuid: uuidクレートをsqlx上で扱う
chono: 日時を扱う型を提供するchronoクレートをsqlx上で扱う
macros: sqlxのマクロを使用する
postgres: postgresに接続する
migrate: マイグレーション機能を使う
という目的がある
p.54
PgPool::connect と PgPool::connect_lazy_with の違い
PgPool::connect:
作成時に、少なくとも一本は接続を開く。失敗すれば、その時点でエラーが発生する。非同期的で、await。
PgPool::connect_lazy_with:
起動時は接続せず、クエリを送る時などに接続する。同期的に動作する。起動が前者と比べて高速。
DBのヘルスチェックを実装
$ curl 127.0.0.1:8080/health/db -v
* Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080
* using HTTP/1.x
> GET /health/db HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/8.12.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< content-length: 0
< date: Sun, 10 Aug 2025 07:17:08 GMT
<
* Connection #0 to host 127.0.0.1 left intact
動作確認
レイヤードアーキテクチャ
- api
- kernel
- adapter
の3層でレイヤードアーキテクチャを実装する
マイグレーション(migration)
システムの機能追加・変更に伴うデータベースのスキーマの変更のこと。テーブル構成の変更をするためのSQLスクリプトを用意することが多い。
updated_at を自動更新する関数の作成
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS '
BEGIN
new.updated_at := ''now'';
return new;
END;
' LANGUAGE 'plpgsql';
SQLの書き方が全くわからないので、調べる
-
CREATE OR REPLACE FUNCTION
関数を新しく作る(すでにあれば置き換える) -
set_updated_at()
関数名 -
RETURNS trigger
この関数は「トリガー」から呼び出される専用関数だと宣言している -
AS ' ... '
関数の中身(PostgreSQL の PL/pgSQL 言語で書かれた処理) -
new
更新後の行データを表す変数 -
new.updated_at := 'now';
更新後の updated_at カラムに「今の時刻」を代入している -
return new;
更新後の行データを返す(これで実際の更新が行われる)
books テーブルの行が更新されるとき、自動的に set_updated_at() 関数を呼び出すトリガー
CREATE TRIGGER books_updated_at_trigger
BEFORE UPDATE ON books FOR EACH ROW
EXECUTE PROCEDURE set_updated_at();
CRAETE TRIGGER:
トリガーを作成する。今回は、books_updated_at_trigger という名前で作成する
BEFORE UPDATE:
BEFORE: 更新処理を実際に行う前に、このトリガーを実行する
UPDATE: UPDATE文が対象
ON books:
books テーブルにこのトリガーを対応させる
FOR EACH ROW:
更新される行ごとに発火する。10行まとめてUPDATEされたら、このトリガーは10回ずつ呼ばれる。もしFOR EACH STATEMENTと呼ばれたら、UPDATE文につき1回呼ばれる。
EXECUTE PROCEDURE:
トリガーから呼び出す関数を指定する。
トリガー(trigger)とは?
データベースにおいて、特定の操作(INSERT, UPDATE, DELETE)などが行われた際に、自動で特定の処理(関数)を実行する仕組みのこと。
todo! と unimplemented! の違い
todo!: まだ実装されていない。実装する意図がある
unimplemented!: 実装されていないことのみを伝える。実装予定かどうかは言及しない
両者とも、実行するとプログラムをパニックさせる
構造体同士の変換処理は、From トレイトを用いると、実装が簡単である
DATABASE_URL
to use query macros online, or run cargo sqlx prepare
to update the query cache
set async fn create(&self, event: CreateBook) -> Result<()> {
sqlx::query!(
r#"
INSERT INTO books (title, author, isbn, description)
VALUES ($1, $2, $3, $4)
"#,
event.title,
event.author,
event.isbn,
event.description
)
.execute(self.db.inner_ref())
.await?;
Ok(())
}
というコードで発生。
= note: this error originates in the macro $crate::sqlx_macros::expand_query
which comes from the expansion of the macro sqlx::query
(in Nightly builds, run with -Z macro-backtrace for more info)
Postgres のURLが環境変数に登録されていないのが原因であった。Docker compose ファイルには、自動で設定する記述があるので、一度コンテナを落としてからもう一度起動。問題なく動作した。
Arc とは?
複数のスレッド間で所有権を共有できるスマートポインタ。Atomic Reference Counted の略。
特徴
- 共有所有権を安全にもてる: ヒープ上に値を格納し、複数のスレッドからその値をクローンして参照する。Arc<T>が破棄されると、内部の値も自動でドロップされる
- スレッド安全性: 参照カウントの増減に「原子操作」をもちいるため、マルチスレッド環境に適している
- 可変性: 基本的に可変的である。Mutex<T>やRwLock<T>を使うと、内部データをロックできる
- 循環参照への対策: Arc<T>同士で相互に参照し合う循環構造ができると、メモリリークの原因となる。そこで、弱参照Weak<T>を使って、循環を断ち切ることができる
serde とは?
Rustで広く使われている、シリアライズ・デシリアライズ用フレーム枠。Rustのデータ構造をJSONやYAMLなどの形式に変換したり、外部形式のデータをRustの構造体や列挙型に変換できる。
基本的な使い方
serde は、単体では、「仕組み」だけ提供する。実際のフォーマットとのやりとりは、serde_json や serde_yaml などのクレートが担当する。
use serde::{Serialize, Deserialize};
use serde_json;
#[derive(Serialize, Deserialize, Debug)]
struct User {
id: u32,
name: String,
}
fn main() {
// Rust -> JSON (Serialize)
let user = User { id: 1, name: "Alice".to_string() };
let json = serde_json::to_string(&user).unwrap();
println!("JSON: {}", json);
// JSON -> Rust (Deserialize)
let parsed: User = serde_json::from_str(&json).unwrap();
println!("User: {:?}", parsed);
}
axum における Extractor
axum では、HTTPリクエストの情報(パスパラメータ, クエリ, ヘッダー, ボディなど)を extractor(抽出器)を通して、ハンドラ関数の引数として直接受け取れる仕組みが存在する。
例
use axum::{
extract::Path,
routing::get,
Router,
};
async fn hello(Path(name): Path<String>) -> String {
format!("Hello, {name}!")
}
fn app() -> Router {
Router::new().route("/hello/:name", get(hello))
}
ここでは、Path<String> がextractor。リクエストのパスパラメータ :name を取り出してStringに変換し、関数引数に渡している。
thiserror クレートを使ったエラーハンドリングの記述方法
#[derive(Error, Debug)]
pub enum AppError {
#[error("{0}")]
InternalError(#[from] anyhow::Error),
}
1. #[derive(Error, Debug)]
- deriveでErrorとDebugを自動生成している
- thiserror::Error トレイトを deriveすることで、この列挙型AppErrorがstd::error::Errorを実装する
- Debugはデバッグ出力用である
2. 列挙型 AppError
pub enum AppError {
#[error("{0}")]
InternalError(#[from] anyhow::Error),
}
#[error("{0}")]
- thiserrorの属性。Display 実装時のエラーメッセージのフォーマットを指定している
- {0} は、このバリアントのフィールドをそのまま表示するという意味。つまり、エラーメッセージをそのまま表示する
#[from]
- 自動的に Fromanyhow::Error for AppError が実装される。anyhow::Error を? 演算子で戻すと、自動でAppError::InternalError に変換される