RustでWebアプリケーションを開発する際に基礎となる要素技術からRustの応用まで、Rustに関するあれこれを解説する本連載。第2回ではAPIサーバを構築し、SNSアプリを簡易実装することでRustを使ったWeb開発での記述性や要素技術を解説する。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
paizaでWebエンジニアをやっている藤田と申します。
前回は、WebアプリケーションにおけるRDB(リレーショナルデータベース)の立ち位置と、RustからRDBを制御する実装および自動テストについて記述しました。今回は、RustでWebフレームワークである「axum」を用いて(REST)APIサーバを構築し、SNSアプリを簡易実装することで、RustでのWeb開発での記述性や要素技術を解説します。
今回のプロジェクトもGitHubのサンプルリポジトリを用意していますので、コードを実行する際はご利用ください。
現代のWebアプリケーションは、ネットワーク、データベース、ブラウザ、暗号などの技術を核として、データモデリング、デザイン、UI/UX(ユーザーインタフェース/ユーザーエクスペリエンス)、セキュリティ、ログ、トレーシング、テレメトリー、データ分析、自動テスト、監視などの非常に多種多様な関心事に取り囲まれています。
その全てを詳述し、一度にその解決策を実装するのも難しいため、この記事ではサーバサイドで重要な基礎となるデータモデリングとRDB+クッキーによるセッション管理のサンプル実装を提示します。HTTPS、認証機能、ロギングなども実装されていないので、本番環境で利用する際はアプリに要求されるセキュリティ基準にのっとり機能を追加してください。
RustでAPI開発を進めた所感として、Ruby on Railsなどの成熟したWebフレームワークに比べると、モノリシックに提供されるソリューションが少なく、硬めの型システムを持つ静的型付け言語でAPIを記述するのはそれなりに難しいということです。一方で、以下のような利点があると考えています。
RustでのWeb開発は、初期段階でさまざまな難しさ(言語の難しさ、実装選択の難しさ)に直面するため短期、小規模での開発には向かないと思われますが、長期、継続的に規模が拡大する開発において有利に作用するファクターが多いように感じます。
今回はRustでAPIサーバのサンプルを実装するに当たり某SNSサービスの機能をまねたアプリ「Ruitter」を作成しようと思います。作成するAPIとその基本的要件は以下のようになります。
あくまで今回の記事の主体はRustによるAPIサーバ実装ですが、サンプルコードにはデバッグのWeb UIプログラムも用意しています。
機能2に至ってはユーザー名さえ知っていれば誰でもなりすましができるなど本番運用するには課題がありますが、今回の内容を理解すれば機能拡張を進めていくことができると思います。
今回はWebフレームワークライブラリとしてaxumを利用します。このライブラリは非同期ランタイムTokioの開発チームが開発を進めており、扱いやすいため採用しています。その他利用ライブラリはプロジェクトファイル(Cargo.toml)に記述しています。
[package] authors = ["Naoki Fujita"] edition = "2021" name = "ruitter" version = "0.1.0" [dependencies] # 便利なエラーハンドリングライブラリ anyhow = "1.0.58" # セッションライブラリ async-session = "3.0.0" # セッションデータをRDBに格納するためのライブラリ async-sqlx-session = {version = "0.4.0", features = ["mysql"]} # Webフレームワーク axum = {version = "0.5.13", features = ["headers", "http2", "ws", "tower-log"]} # Cookie管理に便利なユーティリティーがあるので使用 axum-extra = {version = "0.3.6", features = ["cookie"]} # 非同期処理の基本ライブラリ futures = "0.3.21" # シリアライズ・デシリアライズのライブラリ serde = "1.0.140" # JSONとRust構造体間をシリアライズ、デシリアライズするためのライブラリ serde_json = "1.0.82" # RustからRDBを扱うためのライブラリ sqlx = {version = "0.6.0", features = ["runtime-tokio-native-tls", "mysql", "chrono", "json"]} # クッキーの基本ライブラリ cookie = "0.16.0" # 非同期ランタイムライブラリ tokio = {version = "1.17.0", features = ["full"]} [[bin]] name = "init_db" path = "src/init_db.rs"
5つの機能実現に当たり、データテーブルが幾つか必要になります。
モデルの実装を下記に示します。
// src/models.rs use sqlx::{ mysql::{MySqlPoolOptions, MySqlQueryResult}, Executor as _, MySql, Pool, }; use std::collections::HashSet; // 本番DB(想定)のデータベース接続文字列 pub const DB_STRING_PRODUCTION: &'static str = "mysql://user:pass@localhost:53306/production"; // テストDB(想定)のデータベース接続文字列 pub const DB_STRING_TEST: &'static str = "mysql://user:pass@localhost:53306/test"; // 非同期処理を実行するランタイムを作成 pub fn create_tokio_runtime() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .unwrap() } // MySQL接続のためのクライアント // コネクションプーリングによりクライアント生成コストを削減 pub async fn create_pool(url: &str) -> Result<Pool<MySql>, sqlx::Error> { MySqlPoolOptions::new().connect(url).await } #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize, sqlx::FromRow)] pub struct User { pub id: Option<u64>, pub name: String, // ユーザー名 } impl User { pub const TABLE_NAME: &'static str = "users"; pub async fn create_table(pool: &Pool<MySql>) -> Result<MySqlQueryResult, sqlx::Error> { pool.execute(include_str!("../sql/ddl/users_create.sql")) .await } // 指定ユーザー名からUser構造体を取得 pub async fn find_by_name(name: &str, pool: &Pool<MySql>) -> Result<Option<User>, sqlx::Error> { let sql = format!(r#"SELECT * FROM {} WHERE name = ?;"#, Self::TABLE_NAME); let result = sqlx::query_as::<_, User>(&sql) .bind(name) .fetch_optional(pool) .await; result } // UserデータをRDBに永続化する pub async fn insert(&self, pool: &Pool<MySql>) -> Result<MySqlQueryResult, sqlx::Error> { let sql = format!(r#"INSERT INTO {} (name) VALUES (?);"#, Self::TABLE_NAME); let result = sqlx::query(&sql).bind(&self.name).execute(pool).await; result } } #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize, sqlx::FromRow)] pub struct UserTweet { pub id: Option<u64>, pub user_id: u64, pub content: String, } impl UserTweet { pub const TABLE_NAME: &'static str = "user_tweets"; pub async fn create_table(pool: &Pool<MySql>) -> Result<MySqlQueryResult, sqlx::Error> { pool.execute(include_str!("../sql/ddl/user_tweets_create.sql")) .await } pub async fn insert(&self, pool: &Pool<MySql>) -> Result<MySqlQueryResult, sqlx::Error> { let sql = format!( r#"INSERT INTO {} (user_id, content) VALUES (?, ?);"#, Self::TABLE_NAME ); let result = sqlx::query(&sql) .bind(&self.user_id) .bind(&self.content) .execute(pool) .await; result } } #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize, sqlx::FromRow)] pub struct FollowRelation { pub id: Option<u64>, pub followee_id: u64, // フォローされる側のユーザーID pub follower_id: u64, // フォローする側のユーザーID } impl FollowRelation { pub const TABLE_NAME: &'static str = "follow_relations"; pub async fn create_table(pool: &Pool<MySql>) -> Result<MySqlQueryResult, sqlx::Error> { pool.execute(include_str!("../sql/ddl/follow_relations_create.sql")) .await } pub async fn insert(&self, pool: &Pool<MySql>) -> Result<MySqlQueryResult, sqlx::Error> { let sql = format!( r#"INSERT INTO {} (followee_id, follower_id) VALUES (?, ?);"#, Self::TABLE_NAME ); let result = sqlx::query(&sql) .bind(&self.followee_id) .bind(&self.follower_id) .execute(pool) .await; result } pub async fn find_by_follower_id( follower_id: u64, pool: &Pool<MySql>, ) -> Result<Vec<Self>, sqlx::Error> { let sql = format!( r#"SELECT * FROM {} WHERE follower_id = ?;"#, Self::TABLE_NAME ); let result = sqlx::query_as::<_, Self>(&sql) .bind(follower_id) .fetch_all(pool) .await; result } } #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize, sqlx::FromRow)] pub struct TimelineItem { name: String, content: String, } // タイムラインデータを返す // 本当はページネーションなどが必要 pub async fn timeline( follower_id: u64, pool: &Pool<MySql>, ) -> Result<Vec<TimelineItem>, sqlx::Error> { // フォローしているユーザーIDを列挙 let mut ids = FollowRelation::find_by_follower_id(follower_id, &pool) .await? .into_iter() .map(|r| r.followee_id) .collect::<HashSet<_>>(); // タイムラインには自分自身の投稿も含める ids.insert(follower_id); // 現在のsqlxではIN句に配列を直接bindできないのでハックする // idの個数分パラメータをbindする let placeholders = format!("?{}", ",?".repeat(ids.len() - 1)); let sql = format!( r#" SELECT users.name as name, user_tweets.content as content FROM user_tweets INNER JOIN users ON user_tweets.user_id = users.id WHERE user_id IN ({}) ORDER BY user_tweets.id DESC; "#, placeholders ); let mut query = sqlx::query_as::<_, TimelineItem>(&sql); for id in ids { query = query.bind(id); } let result = query.fetch_all(pool).await; result } // MySQLではINDEXにIF NOT EXISTSを宣言できないのでエラーハンドリングする pub fn panic_except_duplicate_key(result: Result<MySqlQueryResult, sqlx::Error>) { if let Err(e) = result { let is_duplicate_index_error = e .as_database_error() .unwrap() .message() .starts_with("Duplicate key name"); if !is_duplicate_index_error { panic!("{}", e); } }; } // テーブルを生成する // structに対するループはマクロなどを使うことを実現できるが省略 pub async fn setup_tables(pool: &Pool<MySql>) { panic_except_duplicate_key(User::create_table(&pool).await); panic_except_duplicate_key(UserTweet::create_table(&pool).await); panic_except_duplicate_key(FollowRelation::create_table(&pool).await); }
大枠は第1回で説明した話の延長上にすぎないので、詳述は割愛します。timeline関数については、SQLの複雑度が上がっていたり、SQLxが現状配列のbindをサポートしていなかったりするので、プレースホルダをidの個数分用意してbindするなどのテクニカルな実装を行っています。しかしながら、SQLで必要なデータを取得するということが基本になります。
モデルメソッドが自動実装される「ActiveRecord」に比べると実装が増えますが、必要以上のメソッドが自動実装されないので、モデルの状態管理がしやすいというメリットもあります。Rustのトレイトとマクロを活用すれば、例えばinsertメソッドを自動実装することなども可能ですが、難しいので可能性だけを示唆します。
次はHTTPリクエストを受け付けるAPIエンドポイントを実装します。API要件については既に示しており、対応する実装例を下記に示します。
// src/endpoints.rs // モデルレイヤーで定義した構造体や関数を読み込み use crate::models::{timeline, FollowRelation, User, UserTweet}; use async_session::{Session, SessionStore as _}; // セッション情報をMySQLに保存するライブラリ use async_sqlx_session::MySqlSessionStore; use axum::{ extract::{Extension, FromRequest, Json, RequestParts}, http::StatusCode, response::IntoResponse, routing::{get, post}, Router, }; // クライアントクッキーを制御する便利なライブラリ use axum_extra::extract::cookie::{Cookie, CookieJar}; use sqlx::{MySql, Pool}; use std::sync::Arc; // ユーザー新規作成APIのリクエストJSONのスキーマ #[derive(serde::Deserialize)] pub struct CreateUserParams { pub name: String, } // ユーザー新規作成API pub(crate) async fn create_user( Json(payload): Json<CreateUserParams>, arc_pool: Extension<Arc<Pool<MySql>>>, ) -> impl IntoResponse { let user = User { id: None, name: payload.name, }; // ユーザー登録を試みる match user.insert(&arc_pool).await { // 成功したらHTTPステータスコード201を返す Ok(_res) => StatusCode::CREATED, // 失敗したらHTTPステータスコード400を返す // ユーザー名重複やサーバ接続エラーなど // より精緻にステータスコードを分けることもできる Err(_e) => StatusCode::BAD_REQUEST, } } // ログインAPI #[derive(serde::Deserialize)] pub struct CreateSessionParams { pub name: String, } pub(crate) async fn create_session( Json(payload): Json<CreateSessionParams>, arc_pool: Extension<Arc<Pool<MySql>>>, session_store: Extension<MySqlSessionStore>, cookie_jar: CookieJar, ) -> impl IntoResponse { // リクエストされた名前が存在するか調べる match User::find_by_name(&payload.name, &arc_pool).await { Ok(user) => match user { // ユーザー名が存在するならログイン処理 Some(user) => { let mut session = Session::new(); let expire_seconds = 86400; session.expire_in(std::time::Duration::from_secs(expire_seconds)); session.insert("user_id", user.id).unwrap(); // RDBにセッション保存を試みる match session_store.store_session(session).await { Ok(cookie_value) => Ok(( StatusCode::CREATED, // 成功したらSet-Cookieレスポンスヘッダを通じてクッキーを更新 cookie_jar.add( Cookie::build(AXUM_SESSION_COOKIE_KEY, cookie_value.unwrap()) // HTTPS(TLS)非対応なのでfalseとした .secure(false) .http_only(true) .same_site(cookie::SameSite::Lax) .max_age(cookie::time::Duration::new(expire_seconds as i64, 0)) .finish(), ), )), )), Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE), } } // ユーザー名が存在しない場合 None => Err(StatusCode::BAD_REQUEST), }, Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE), } } #[derive(serde::Deserialize)] pub struct CreateUserTweetParams { pub content: String, } // ツイート作成API pub(crate) async fn create_user_tweet( Json(payload): Json<CreateUserTweetParams>, arc_pool: Extension<Arc<Pool<MySql>>>, session: CurrentSession, ) -> impl IntoResponse { // セッションからuser_idを取得する match session.0.get::<u64>("user_id") { Some(user_id) => { let tweet = UserTweet { id: None, user_id, content: payload.content, }; match tweet.insert(&arc_pool).await { Ok(_) => Ok(StatusCode::CREATED), Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE), } } // セッションからuser_idを復元できない場合 None => Err(StatusCode::UNAUTHORIZED), } } #[derive(serde::Deserialize)] pub struct CreateFollowRelationParams { pub name: String, } // フォローAPI pub(crate) async fn create_follow_relation( Json(payload): Json<CreateFollowRelationParams>, arc_pool: Extension<Arc<Pool<MySql>>>, session: CurrentSession, ) -> impl IntoResponse { match session.0.get::<u64>("user_id") { Some(user_id) => { // 指定した名前のユーザーが存在するか確認する let result = User::find_by_name(&payload.name, &arc_pool).await; match result { Ok(followee) => match followee { // 存在するならばフォローする Some(followee) => { let follow_relation = FollowRelation { id: None, followee_id: followee.id.unwrap(), follower_id: user_id, }; match follow_relation.insert(&arc_pool).await { Ok(_) => Ok(StatusCode::CREATED), Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE), } } None => Err(StatusCode::BAD_REQUEST), }, Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE), } } None => Err(StatusCode::UNAUTHORIZED), } } pub(crate) async fn get_timeline( arc_pool: Extension<Arc<Pool<MySql>>>, session: CurrentSession, ) -> impl IntoResponse { match session.0.get::<u64>("user_id") { Some(user_id) => match timeline(user_id, &arc_pool).await { Ok(tweets) => Ok(axum::Json(tweets)), Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE), }, None => Err(StatusCode::UNAUTHORIZED), } } pub async fn run_server( arc_pool: Arc<Pool<MySql>>, session_store: MySqlSessionStore, ) -> anyhow::Result<()> { // 8888番ポートで全てのIPアドレスから待ち受ける let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8888)); // ルーティングを定義する // postはHTTP POSTエンドポイント // getはHTTP GETエンドポイント let app = Router::new() .route("/api/users", post(create_user)) .route("/api/sessions", post(create_session)) .route("/api/user_tweets", post(create_user_tweet)) .route("/api/follow_relations", post(create_follow_relation)) .route("/api/pages/timeline", get(get_timeline)) // RDBクライアントをアクション関数から呼び出せるようにする .layer(Extension(arc_pool)) // セッションストアをアクション関数から呼び出せるようにする .layer(Extension(session_store)); axum::Server::bind(&addr) .serve(app.into_make_service()) .await?; Ok(()) } pub struct CurrentSession(Session); const AXUM_SESSION_COOKIE_KEY: &str = "axum_session"; // https://github.com/tokio-rs/axum/blob/main/examples/sessions/src/main.rsを改変 // axumのカスタムextractorを定義 // クッキーに格納されたセッションキーからセッションデータを復元する #[axum::async_trait] impl<B> FromRequest<B> for CurrentSession where B: Send, { type Rejection = StatusCode; async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> { // MySQLセッションストアを参照する let Extension(store) = Extension::<MySqlSessionStore>::from_request(req) .await .unwrap(); // ブラウザから送信されたクッキーを参照する let cookie = CookieJar::from_request(req).await.unwrap(); // クッキーからセッションキーを取得 let session_id = cookie .get(AXUM_SESSION_COOKIE_KEY) .map(|cookie| cookie.value()) .unwrap_or("") .to_string(); // セッションキーからセッションデータを復元する let session_data = store.load_session(session_id).await; match session_data { Ok(session_data) => match session_data { // セッションデータが存在=セッションデータを返す Some(session_data) => Ok(CurrentSession(session_data)), // セッションデータが存在しない=ログインできていない None => Err(StatusCode::UNAUTHORIZED), }, // RDBとの接続が切れている可能性がある、500を返す Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE), } } }
まず見るべきはrun_server関数に含まれているURLパスと関数(リクエストハンドラ)の対応表(ルーティング)です。例えば/api/pages/timelineにGETリクエストを送信すると、get_timelineリクエストハンドラが呼び出されます。またその下にある.layerメソッドを通じて、リクエストハンドラ内からRDBやセッションストアを参照できるよう拡張(Extension)しています。
次にユーザー新規作成APIのcreate_userリクエストハンドラですが、Json(payload)とarc_poolという2つの引数を持っています。arc_poolは前述した.layerメソッドによる拡張の結果であり、MySQLクライアントプールがハンドラ内で使用できることを示しています。
拡張なしでもaxumはリクエストに付随する情報(リクエストヘッダ、パス・クエリパラメーター、JSONデータなど)を自由に引数として記述でき、この機能は「extractor」と呼ばれています。必要なパラメータを関数の引数として自由に引き出せるのは魔法のように思えますが、Rustのトレイトによって実現されています。
CreateUserParamsはJSONデータのスキーマを規定しており、このスキーマに適合しないとaxumがステータスコード422を返してくれるようです。これにより型定義による入力保護と型駆動での開発を推し進めることができます。create_userはユーザー作成の成否に応じて適切なステータスコードを返しています。より緻密にステータスコードを分けたり、エラーメッセージを返却したりすることもできます。
動的型付け言語での実装に比べるとやや煩雑ですが、Rustのnull安全な型システムにより、動作を精緻に記述しやすいです。axumに処理を委譲したり、OptionやResultのメソッドを活用したりすると冗長な記述も削れます。
axumのextractorはFromRequestトレイトを実装することで作成できます。create_session関数ではCookieJarというextractorを使用していますが、これはaxum_extraというライブラリから持ってきたものです。
また今回はCurrentSessionというカスタムextractorを定義し、ログインしていなければステータスコード401を返すような実装を作ってみました。このようなカスタム実装をしなくても、毎回セッションストアとクッキーを付き合わせるという実装をすれば要件は実現できますが、少し大変です。
このカスタムextractorを利用してログイン状態を要求するツイート作成APIやフォローAPI、タイムラインAPIを実装しました。
プログラムを実行する場合は先にデータベーステーブルの作成が必要です。テーブル初期化するプログラムを示します。
// src/init_db.rs use ruitter::models::{create_pool, create_tokio_runtime, setup_tables, DB_STRING_PRODUCTION}; fn main() -> anyhow::Result<()> { let tokio_rt = create_tokio_runtime(); tokio_rt.block_on(run()) } async fn run() -> anyhow::Result<()> { // 本番DBにセッションテーブルを作成 let session_store = async_sqlx_session::MySqlSessionStore::new(DB_STRING_PRODUCTION).await?; session_store.migrate().await?; // 本番DBに接続するクライアントプールを作成 let pool = create_pool(DB_STRING_PRODUCTION).await?; // 本番DBにその他テーブルを作成 setup_tables(&pool).await; Ok(()) }
セッションストアをasync_sqlx_sessionに作らせている他は第1回での説明と重複するので割愛します。MySQLが53306ポートで待ち受けている状態で下記コマンドを実行します。
cargo run --bin init_db
サーバ起動プログラムを下記に示します。
// src/main.rs use ruitter::endpoints::run_server; use ruitter::models::{create_pool, create_tokio_runtime, DB_STRING_PRODUCTION}; use std::sync::Arc; fn main() -> anyhow::Result<()> { // 非同期ランタイムを生成 let tokio_rt = create_tokio_runtime(); tokio_rt.block_on(run()) } async fn run() -> anyhow::Result<()> { let arc_pool = Arc::new(create_pool(DB_STRING_PRODUCTION).await?); let session_store = async_sqlx_session::MySqlSessionStore::new(DB_STRING_PRODUCTION).await?; // APIサーバの起動 run_server(arc_pool, session_store).await }
下記コマンドでコンパイルして実行できます。
cargo run --bin ruitter --release
APIの動作検証としてはリポジトリの/frontにあるWeb UIを使う他、curlなどのHTTPクライアントを用いることができます。
今回はSNSアプリをサンプル実装するという題材でRustにおけるWeb APIサーバ開発事例を示しました。その過程で
などを部分的にでも示せたのではないかと思います。特にaxumの抽象化は洗練されており、ソースを読むといろいろ参考になりそうです。
藤田直己
1988年生まれ、大阪府枚方市出身
京都大学工学部電気電子工学科卒、同大学エネルギー科学研究科修了
応用情報技術者・ネットワークスペシャリスト・情報処理安全確保支援士試験合格者
YKK APにて超高層建築物の外装設計に従事し、型・モジュール設計・ウオーターフォールプロセスに精通する。その後ITエンジニアに転向。paizaにて、Ruby on RailsやReactを用いたWebサービスのスクラム開発に従事、現在に至る。
最も得意な言語はPython、最も影響を受けた言語はClojureであり、シンプルな関数型(的書き方ができる)言語を好む。関数型的記法を持ちながら、実行性能が高いRustに興味を持ち研さんを続けている。
Copyright © ITmedia, Inc. All Rights Reserved.