概要
RustのWebフレームワーク Axum でWebAPIを実装します。MySQLにデータをRead/Writeするサンプルを実装してみます。MySQLはインストールされている前提とします。
はじめに
コード編集時に毎回cargo buildするのは結構手間なのでcargo watchをインストールしておきます。
$ cargo install cargo-watch
cargo-watchを使うとコードの変更検知をして自動で再ビルドしてくれます。
$ cargo watch -x "run --release"
プロジェクトを作成する
$ cargo new testapi
使用するライブラリ
Webアプリ開発に最低限必要なライブラリ群を使います。
ORMはDieselやSeaORMが選択肢になりますが、今回はORMは使いません。
ライブラリ | 用途 |
---|---|
tokio | 非同期ランタイム |
tower | 低レイヤーHTTPライブラリ Axumの内部実装はtowerに依存している |
axum | Webフレームワーク |
sqlx | AsyncなSQLライブラリ |
serde | オブジェクト(デ)シリアライザ serde_jsonとの組み合わせでjson形式を扱う |
tracing | ロギングライブラリ |
chrono | 時刻操作ライブラリ |
Cargo.tomlを整備する
[package]
name = "testapi"
version = "0.1.0"
edition = "2021"
[dependencies]
dotenvy = { version = "0.15.7" }
axum = { version = "0.7.5", features = ["macros", "http2", "multipart", "ws"] }
axum-extra = { version = "0.9.3", features = ["cookie", "cookie-private", "typed-header", "multipart"] }
tower = "0.4.13"
tower-http = { version = "0.5.2", features = ["trace", "cors"] }
tokio = { version = "1.36.0", features = ["full"] }
sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "chrono", "mysql"] }
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.114"
serde_repr = "0.1.18"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
chrono = { version = "0.4.34", features = ["serde"] }
chrono-tz = { version = "0.8.6", features = ["filter-by-regex", "serde"] }
データベースの初期レイアウトを作成する
sqlx-cliをインストール
DBのマイグレーションにsqlx-cliを使います。 インストールしましょう。
$ cargo install sqlx-cli
データベースを作成
testappというデータベースを作成します。sqlx-cliを使わずにmysqlから直接create databaseしても問題ありません。
$ DATABASE_URL=mysql://root:password@localhost:3306/testapp sqlx database create
sqlx-cliは環境変数DATABASE_URLを参照して接続先を特定するので、direnvなどを利用して環境変数ファイルに変数を切り出しておくと良いと思います。
migrationファイルを作成
migrationファイルを作ります。
$ sqlx migrate add create_initial_tables
% ls migrations
20240507233955_create_initial_tables.sql
sqlxでは生SQLでmigrationルールを管理します。migrate addして作成したsqlファイルに直接SQLを記述します。
-- Add migration script here
CREATE TABLE sample_entity
(
id SERIAL PRIMARY KEY,
text VARCHAR(512),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
マイグレーションを実行します。以下のように結果がAppliedとなれば成功です。 MySQLにテーブルが作成されました。
% DATABASE_URL=mysql://root:password@localhost:3306/testapp sqlx migrate run
Applied 20240507233955/migrate create initial tables (26.296ms)
環境変数ファイルを作成する
DB接続先は環境変数に切り出しておきます。 プロジェクトルートに .env ファイルを作成し、DATABASE_URLを記述します。接続先はよしなに書き換えてください。
DATABASE_URL=mysql://root:password@localhost:3306/testapp
AppStateを定義する
ここからRustでコーディングしていきます。 データベースの接続プールなどのアプリケーション全体で使い回すリソースをAppStateとして定義します。main.rsに以下のコードを記述します。
use sqlx::{MySql, Pool};
#[derive(Debug, Clone)]
pub struct AppState {
pub pool: Pool<MySql>,
}
impl AppState {
pub fn new(pool: Pool<MySql>) -> Self {
Self { pool }
}
}
サーバを実装する
まだcontrollerは実装しませんが、プログラムのエントリポイントとなるmain関数を実装します。
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 環境変数初期化
dotenvy::dotenv().unwrap();
// logger初期化
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_ansi(true)
.init();
// DB接続プール作成
let uri = std::env::var("DATABASE_URL")?;
let pool = MySqlPoolOptions::new()
.max_connections(4)
.connect(&uri)
.await?;
// Router定義
let app = Router::new()
//.route("/", post(some_handler)) TODO controllerはあとで実装する
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(tracing::Level::DEBUG))
.on_request(DefaultOnRequest::new().level(tracing::Level::DEBUG))
.on_response(DefaultOnResponse::new().level(tracing::Level::DEBUG)),
)
.with_state(AppState::new(pool));
// サーバー起動
let listener = tokio::net::TcpListener::bind("localhost:8000").await?;
axum::serve(listener, app).await?;
Ok(())
}
ここまででサーバが待ち受け状態になるのでビルドして起動します。 debugログに以下のようにログが出れば成功です。
$ cargo watch -x run
[Running 'cargo run']
Finished dev [unoptimized + debuginfo] target(s) in 0.24s
Running `target/debug/testapi`
2024-05-09T21:32:27.770774Z DEBUG sqlx::query: summary="SET sql_mode=(SELECT CONCAT(@@sql_mode, ',PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION')),time_zone='+00:00',NAMES …" db.statement="\n\nSET\n sql_mode =(\n SELECT\n CONCAT(\n @ @sql_mode,\n ',PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION'\n )\n ),\n time_zone = '+00:00',\n NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;\n" rows_affected=0 rows_returned=0 elapsed=2.71175ms elapsed_secs=0.00271175
データベースの接続先が間違っていたり、MySQLが起動してないなど環境設定不備があると接続エラーでアプリが落ちます。
Error: PoolTimedOut
[Finished running. Exit status: 1]
エンティティを定義する
APIのエンドポイントとなるコントローラを実装する前に、データベース読み書き用のエンティティを定義します。 DBに問い合わせたクエリの結果をRustのstructにマッピングするため、sqlx::FromRowをderiveします。 structのフィールドメンバーはDBのカラム名と対応するように命名します。 sqlxの型変換仕様はこちら。
#[derive(Debug, Serialize, Deserialize, FromRow)]
struct SampleEntity {
id: u64,
text: String,
created_at: DateTime<Utc>
}
コントローラを実装する
登録
DBにtextを永続化するコントローラを実装します。
async fn create_entity(
State(state): State<AppState>,
Json(params): Json<serde_json::Value>,
) -> Result<impl IntoResponse, String> {
// { "text": "ユーザー入力値" } のJsonを受け取る
let text: Option<String> = params.get("text").map(|value| value.to_string());
if let Some(text) = text {
QueryBuilder::new("INSERT INTO sample_entity (text) VALUES (")
.push_bind(text)
.push(")")
.build()
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
}
Ok(())
}
AppStateとJson形式のリクエストボディを受け取ります。
AppStateはRouterのwith_stateに登録したインスタンスをStateオブジェクトとして引数展開されます。
Json型として受け取っているのはserde_json::Valueで、これは任意のjsonを受け取るserdeの汎用型となります。
Json型に指定可能なのは serde::Deserialize をimplした型なので、Deserializeをderiveした独自の型を指定することも可能です。
今回はテキストを1つ受け取るだけなのでserde_json::Valueを使います。
データベースの永続化処理はこの部分です。sqlxのQueryBuilderを使ってSQL生成と実行をしています。
QueryBuilder::new("INSERT INTO sample_entity (text) VALUES (")
.push_bind(text)
.push(")")
.build()
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
最後に定義したハンドラ関数をRouteに登録します。 先ほど定義したmain関数を以下のように書き換えます。 POST /sample_entity としてエンドポイントが作成されます。
// Router定義
let app = Router::new()
.route("/sample_entity", post(create_entity)) // これ
.layer(
cargo watch -x run してサーバを起動し、リクエストを送ると 200 OK が返却されます。
% curl -X POST -H "Content-Type: application/json" -d '{ "text": "データを登録してね" }' http://localhost:8000/sample_entity -v
Note: Unnecessary use of -X or --request, POST is already inferred.
* Trying 127.0.0.1:8000...
* Connected to localhost (127.0.0.1) port 8000 (#0)
> POST /sample_entity HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/8.1.2
> Accept: */*
> Content-Type: application/json
> Content-Length: 41
>
< HTTP/1.1 200 OK
< content-length: 0
< date: Sat, 11 May 2024 04:42:03 GMT
<
* Connection #0 to host localhost left intact
サーバのログては以下のような感じ。sqlxのログでINSERTクエリが実行されていることが確認できます。
[Running 'cargo run']
Compiling testapi v0.1.0 (/Users/shotaro/work/testapi)
Finished dev [unoptimized + debuginfo] target(s) in 1.91s
Running `target/debug/testapi`
2024-05-11T04:40:25.974723Z DEBUG sqlx::query: summary="SET sql_mode=(SELECT CONCAT(@@sql_mode, ',PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION')),time_zone='+00:00',NAMES …" db.statement="\n\nSET\n sql_mode =(\n SELECT\n CONCAT(\n @ @sql_mode,\n ',PIPES_AS_CONCAT,NO_ENGINE_SUBSTITUTION'\n )\n ),\n time_zone = '+00:00',\n NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;\n" rows_affected=0 rows_returned=0 elapsed=3.094375ms elapsed_secs=0.003094375
2024-05-11T04:42:03.698116Z DEBUG request{method=POST uri=/sample_entity version=HTTP/1.1}: tower_http::trace::on_request: started processing request
2024-05-11T04:42:03.716841Z DEBUG request{method=POST uri=/sample_entity version=HTTP/1.1}: sqlx::query: summary="INSERT INTO sample_entity (text) …" db.statement="\n\nINSERT INTO\n sample_entity (text)\nVALUES\n (?)\n" rows_affected=1 rows_returned=0 elapsed=16.148792ms elapsed_secs=0.016148792
2024-05-11T04:42:03.716950Z DEBUG request{method=POST uri=/sample_entity version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=19 ms status=200
登録APIは完成です!
取得
次は登録したエンティティを取得してJsonでレスポンスするAPIを作ります。 ページャ対応した一覧取得APIとしましょう。 エンドポイント仕様は以下です。
GET /sample_entity?page=0&count=20*
早速ハンドラ関数の定義から。
async fn entity_list(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<impl IntoResponse, String> {
// クエリパラメータを抽出
let page: Option<i32> = params
.get("page")
.and_then(|value| value.to_string().parse::<i32>().ok());
let count: Option<i32> = params
.get("count")
.and_then(|value| value.to_string().parse::<i32>().ok());
let count = count.unwrap_or(10); // 指定されない場合は10件ページャ
let offset = count * page.unwrap_or(0); // ページ指定がない場合は先頭ページ
// DB問い合わせ
let entities: Vec<SampleEntity> =
QueryBuilder::new("SELECT * FROM sample_entity ORDER BY id DESC LIMIT ")
.push_bind(count)
.push(" OFFSET ")
.push_bind(offset)
.build_query_as()
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
// { "list": [{"id":1, "text":"ユーザー入力値", "created_at": <unixtimestamp> }] }
// の形式でレスポンス
Ok(Json(json!({
"list": entities
})))
}
今回はGETのクエリパラメータを受け取るのでaxum::extract::Json型ではなくaxum::extract::Query型でパラメータをキャッチします。
レスポンスボディをJsonとして返却するのでJson型を返却します。 serde_json::json!マクロ はjson表現から serde_json::Value を生成してくれるショートカットマクロです。 Pythonの辞書型っぽい表現でjsonを記述できるので便利。
データベースへのread問い合わせは受け取る変数の型を明示する必要があります。
let entities: Vec<SampleEntity> = ...
の部分です。sqlx::FromRowをderiveした型であれば、sqlで読み出した結果をsqlxが型に自動マッピングしてくれるので、Vec<SampleEntity>
として受け取るようにします(sqlxのfetch_allはエンティティのVecを返却します)
実装したハンドラ関数をRouteに登録します。
let app = Router::new()
.route("/sample_entity", post(create_entity).get(entity_list)) // ここ。getに追加
.layer(
//...
リクエストするとDBに登録されているデータが返却されます。
% curl http://localhost:8000/sample_entity
{"list":[{"created_at":"2024-05-11T04:42:03Z","id":2,"text":"\"データを登録してね\""},{"created_at":"2024-05-11T04:19:54Z","id":1,"text":"\"データを登録してね\""}]}
ページャも機能しているかテストして問題なさそうです。
% curl "http://localhost:8000/sample_entity?page=1&count=1"
{"list":[{"created_at":"2024-05-11T04:19:54Z","id":1,"text":"\"データを登録してね\""}]}
これで取得APIも完成です。
まとめ
AxumでAPIを作ってみました。 Axumを使うとPythonのFlaskとかFastAPIに近い感覚でAPIを実装できます。
https://github.com/lockhart9/rust-rest-sample
作成したAPIは動くものをGitHubにアップしておきました。
参考にしていただけそうならcloneしてcargo runしてみてください。
それでは良きRustライフを!