はじめに
本書ではRustを使ってウェブアプリケーションを開発する方法を学びます。
本書の見方
本書は
- Django や Express などのフレームワークを使い、チュートリアルに沿ってウェブアプリ開発を試したことがある
- Rust By Example の内容がだいたい分かる
というレベル感の方を対象に、rustでのウェブアプリの開発が理解できるようになることを狙いとしています。
各ページごとに、サンプルアプリの実装を進めながら実装のポイントをおさえていきます。
学習に利用するサンプルアプリ
サンプルアプリとして Twitter クローンを実装してみます。
下記に各種設計資料を示しますが、今回はグレーになっていない部分を実装します。
URL設計
各画面のURLをどうするか検討しておく必要があります。
メソッド&パス | 画面 | 処理内容 | 処理後の表示内容 | ログイン |
---|---|---|---|---|
GET / | ホーム画面 | ツイート一覧を取得する | - | 必須 |
POST /tweets/new | - | 投稿フォームの内容に従ってツイートを作成する | ホーム画面 | 必須 |
POST /tweets/:id/delete | - | 指定されたIDのツイートを削除する | ホーム画面 | 必須 |
GET /login | ログイン画面 | - | - | - |
POST /account/session | - | メールアドレスとパスワードでログイン認証する | ホーム画面 or ログイン画面 | - |
GET /register | アカウント作成画面 | - | - | |
POST /account/new | - | フォームの内容でアカウントを作成する | ホーム画面 | - |
ユースケース
ユーザーの要求とシステムの振る舞いを視覚的に明確化したものがユースケース図です。
ロバストネス分析
ユースケースを、よりシステムの観点で記載したものがロバストネス分析図です。
ドメインモデル
ビジネスモデルをデータの観点で洗い出したものがドメインモデル図です。
今回はそこまで複雑な機能は作り込まずシンプルなモデリングにしているため、登場するドメインモデルは少なめになっています。
フレームワークのセットアップ
今回は非同期ランタイムライブラリで有名な tokio が開発している axum を使います。axumはマクロを使わずに作られており、見通しの良い実装がし易くなっています。
Hello, world!
まずはRustプロジェクトを作成します。
$ mkdir rustwi
$ cd rustwi
$ cargo init
Created binary (application) package
次に Cargo.toml
の [dependencies]
節にクレートを追加します。
[package]
name = "rustwi"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.4"
tokio = { version = "1.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version="0.3", features = ["env-filter"] }
メインのソースコードも変更します。
use axum::{response::Html, routing::get, Router};
use std::net::SocketAddr;
#[tokio::main] // main関数を非同期関数にするために必要
async fn main() {
if std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "rustwi=debug")
}
tracing_subscriber::fmt::init();
let app = Router::new().route("/", get(handler));
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
tracing::debug!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
async fn handler() -> Html<&'static str> {
Html("<h1>Hello, World!</h1>")
}
ルーティング
ルーティングは Router
で行います。Router#route
でハンドラー関数を登録します。
fn main() {
let app = Router::new().route("/", get(handler));
}
async fn handler() -> impl IntoResponse {
// ...
}
上記のルーティングは、あえてマクロで表現すると
#[get("/")]
async fn handler() -> impl IntoResponse {
...
}
のようなイメージです。 (axumでは上記のコードは動作しません)
ハンドラー関数
ハンドラー関数では、 Extractor
という仕組みで、リクエストデータを引数で受け取ることができます。これについては以降のページで説明していきます。
戻り値は IntoResponse
トレイトに準拠したオブジェクトを返す必要があります。例えば、前述の Html<&'static str>
も IntoResponse
を実装しています。
その他にも、axumは様々な型に対して IntoResponse
を実装してくれているので、そちらを確認してみると良いでしょう。
https://docs.rs/axum/latest/axum/response/trait.IntoResponse.html
Note: Routerにはハンドラー関数だけでなく、 towerのService に準拠したサービスを登録することもできます。
axumに 指定したディレクトリをホスティングする例 があるので、それを見てみるのも良いでしょう。
サーバーの起動
サーバーを起動してみましょう。cargoコマンドで起動できます。
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/rustwi`
2021-01-01T09:00:00.000000Z DEBUG rustwi: listening on 127.0.0.1:3000
ブラウザを開いて http://localhost:3000/ にアクセスすると、 Hello, World!
と表示されるはずです。数行のコードでウェブサービスを立ち上げることができました!
次ページ以降で、より柔軟なHTMLを返却する方法を学びます。
HTMLレスポンス
このページではテンプレートエンジン askama を利用して、データを埋め込んだHTMLをレスポンスできるようにします。
テンプレートエンジン
テンプレートエンジンを使うことで、テンプレートとなるHTMLをベースに、条件による表示/非表示の切り替えや配列データをループして表示させることができます。表示制御の書き方については テンプレート記法 を確認してください。
askamaではデフォルトで templates/
ディレクトリ配下のHTMLファイルをテンプレートと認識します。(コンフィギュレーションで変更可能です)
今回は、他のテンプレートから利用されるテンプレートも含め、3つ作成しています。
構造体の実装
テンプレートをプログラムから扱えるようにするためには、テンプレートに対応する構造体を作成する必要があります。
templates/hoge.html
にベースをする場合、下記のような構造体を用意します。
<html>
<body>
{{hoge_hoge}}
</body>
</html>
use askama::Template;
#[derive(Template)] // Templateトレイトを実装
#[template(path = "hoge.html")] // template(path)でHTMLファイルを指定
struct Hoge {
hoge_hoge: String,
}
#[template(...)]
に指定できる属性については、 The template() attribute を確認してください。
IntoResponseへの変換
Template
をderiveした構造体を用意したので、 render()
を呼び出せるようになります。呼び出すことで構造体のフィールドのデータが埋め込まれたHTML文字列を Result<String, Error>
型で取得することができます。
fn handler() -> impl IntoResponse {
let hoge = Hoge { "こんにちは".to_string() };
let html = hoge.render().unwrap();
Html(html).into_response()
}
モジュール化
コードが増えてくると、1ファイルの実装では見通しが悪くなってくるため、このタイミングでモジュール化を行います。
モジュール概要
モジュールについては The Rust Programming Language 日本語版 の7章にて解説されているため、そちらに沿ってファイルに分割していきます。
階層化のポイント
rustでは ファイルを作って mod
宣言しただけではモジュールにならない ので注意が必要です。特にディレクトリを階層構造にする場合に、整理整頓のためだけのサブディレクトリであっても、rustではモジュールとして扱うため、モジュール宣言をする必要があります。サブディレクトリを含むモジュール宣言にはいくつか方式があります。
方式A) ディレクトリ構造は lib.rs
に記載する
階層化の階数やファイル数が多くなると lib.rs
の記載量が多くなりますが、作成するファイルが少なくてすむため、今回はこの方式で実装してみます。
src
└── lib.rs
├── dir_a
│ └── module_b.rs
└── module_c.rs
mod dir_a {
mod module_b;
pub use module_b::fn_b;
}
mod module_c;
pub use dir_a::fn_b;
pub use module_c::fn_c;
方式B) ディレクトリ名と同名のファイルを作成する
ディレクトリと同名のファイルを作成することで、そのディレクトリの内部にあるモジュールを宣言することができます。
src
└── lib.rs
├── dir_a.rs
├── dir_a
│ └── module_b.rs
└── module_c.rs
mod dir_a;
mod module_c;
pub use dir_a::fn_b;
pub use module_c::fn_c;
mod module_b;
pub use module_b::fn_b;
方式C) mod.rs
を作成する
mod.rs
をディレクトリ内に作成することで、そのディレクトリにあるモジュールを宣言することができます。
Note: Rust 2018 Editionからは方式Bがスタンダードになりました。この方式はRust 2015 Editionとの後方互換のために残されているようです。
src
└── lib.rs
├── dir_a
│ ├── mod.rs
│ └── module_b.rs
└── module_c.rs
mod dir_a;
mod module_b;
pub use dir_a::fn_b;
pub use module_c::fn_c;
mod module_b;
pub use module_b::fn_b;
フォームリクエスト
このページではフォームから投稿された内容を取り扱ってみます。
ハンドラー関数でデータを受け取る
リクエストデータを取り扱うには、 axum::extract::Form
を使用します。
https://docs.rs/axum/latest/axum/extract/struct.Form.html
上記ページに記載されているサンプルです。
use axum::{
extract::Form,
routing::post,
Router,
};
use serde::Deserialize;
#[derive(Deserialize)] // Form<T>のTはserde::Deserializeを実装している必要がある
struct SignUp {
username: String,
password: String,
}
async fn accept_form(form: Form<SignUp>) { // 引数にForm<T>型の変数を宣言することで取り扱いができる
let sign_up: SignUp = form.0;
// ...
}
let app = Router::new().route("/sign_up", post(accept_form));
serde
はデータ構造をシリアライズ/デシリアライズするためのユーティリティです。例えば、構造体をjsonにする場合(逆の場合も)などにも使われます。必要に応じて Cargo.toml
に依存関係を追加してください。
このサンプルは、下記のフォーム送信に対応します。
<form action="/sign_up" method="post">
<input type="text" name="username" placeholder="ユーザー名" />
<input type="password" name="password" placeholder="パスワード" />
<button type="submit">送信</button>
</form>
データベースのセットアップ
フォームデータも受け取れるようになったので、このページからデータベースを使ってデータの永続化をしていきます。
Note: 今回はデータベースにPostgreSQLを使用します。
データベースのセットアップ
dockerなどを利用して、ローカル環境にデータベースを起動しておきましょう。
$ docker run --rm -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust postgres:14-alpine
データベースに入り、必要なテーブルを作成しておきます。
CREATE TABLE tweets
(
id serial primary key,
message text not null,
posted_at timestamptz not null
);
データベース取り扱いの準備
rustのデータベース用クレートはいくつかありますが、今回はaxumのサンプルでも使用されている bb8 を使用します。
rustで使えるDBドライバーもいくつか提供されていますが、今回は非同期処理での取り扱いに優れている tokio-postgres を利用します。
bb8 で tokio-postgres を直接利用はできないのですが、 bb8-postgres というアダプターが bb8 に同梱されているので、それを使用します。サンプルを見てみましょう。
use bb8::Pool;
use bb8_postgres::PostgresConnectionManager;
use tokio_postgres::NoTls;
fn main() {
let manager = PostgresConnectionManager::new_from_stringlike("postgres://postgres@localhost/postgres", NoTls).unwrap(); // --(1)
let pool = Pool::builder()
.max_size(15)
.build(manager) // --(2)
.unwrap();
for _ in 0..20 {
let pool = pool.clone();
tokio::spawn(move || {
let conn = pool.get().await.unwrap(); // --(3)
// use the connection
// it will be returned to the pool when it falls out of scope.
})
}
}
データベースを使う流れは下記のとおりです。
- データベース接続情報を渡して
ConnectionManager
を作成する(今回は bb8-postgres を使っているためPostgresConnectionManager
を作成している) ConnectionManager
を渡してConnectionPool
を作成するConnectionPool
からPooledConnection
を取得するPooledConnection
を使ってSQLを発行する
Note: データベースへの接続は時間がかかるので、使うたびに接続と切断をするのではなく、使い終わったあとも接続したままにしておき、また使いたくなったときに再利用するのが一般的です。この再利用のために使い終わった接続を管理しておくのが
ConnectionPool
の役割です。
dotenvの使用
上記ではデータベースの接続情報を直接記載していますが、通常、セキュリティの観点からこのような実装をすることはなく、環境変数などから取得するのが一般的です。
サービスをデプロイする際には環境変数から取得できる前提で、ローカル開発では dotenv を使うことで、環境変数への変数設定を簡略化します。
下記はサンプルです。
use dotenv::dotenv;
use std::env;
fn main() {
dotenv().ok(); // 「.env」ファイルに設定した内容が環境変数として設定される
for (key, value) in env::vars() {
println!("{}: {}", key, value);
}
}
ルートディレクトリに .env
というファイルを作成し、 KEY=VALUE
の形式で記載することで、 dotenv
クレートは実行時にそれを読み取って環境変数に設定してくれます。
取得系SQL文の発行
tokio-postgres では、発行するSQLは文字列として書きます。
let id = 42;
let row = conn
.query_opt("SELECT * FROM hoge WHERE id = $1", &[&id])
.await?;
パラメータはSQL文中では $1, $2, ...
と記載し、第2引数で参照のスライスを渡します。
取得系SQL文(SELECT)を発行するためのメソッドは3種類あります。
- query
- 複数行を取得するSQLを発行する場合に使い、結果を
Vec<Row>
で受け取れます - データが取得できないケースでも正常終了します
- 複数行を取得するSQLを発行する場合に使い、結果を
- query_one
- 1行を取得するSQLを発行する場合に使い、結果を
Row
で受け取れます - データが取得できない、または、2行以上取得できるケースではエラー終了します
- 1行を取得するSQLを発行する場合に使い、結果を
- query_opt
- 1行を取得するSQLを発行する場合に使い、結果を
Option<Row>
で受け取れます - データが取得できないケースでも正常終了します
- 2行以上取得できるケースではエラー終了します
- 1行を取得するSQLを発行する場合に使い、結果を
Extension
の利用
上記の流れで作成した ConnectionPool
は、何回リクエストが来ても、また、別のメソッドにリクエストが来ても同じ ConnectionPool
を参照して PooledConnection
を取得できる必要があります。(そうでないと接続の再利用ができません)
axumには Extension
というものがあり、これを使うことで全てのリクエストで同じオブジェクトを共有することができます。
https://docs.rs/axum/latest/axum/extract/struct.Extension.html
上記のドキュメントにあるサンプルを見てみましょう。
use axum::{
AddExtensionLayer,
extract::Extension,
routing::get,
Router,
};
use std::sync::Arc;
// Some shared state used throughout our application
struct State {
// ...
}
async fn handler(state: Extension<Arc<State>>) { // 引数でExtensionを受け取る
// ...
}
let state = Arc::new(State { /* ... */ });
let app = Router::new().route("/", get(handler))
// Add middleware that inserts the state into all incoming request's
// extensions.
.layer(AddExtensionLayer::new(state)); // ExtensionをRouterに追加する
Extension
も Extractor
の一種なので、ハンドラー関数の引数で受け取ることができます。
リファクタ
このページでは、データの取得・保存と要件実現のための処理の依存性を弱くし、メンテナンスしやすい作りにします。
役割ごとにモジュール化
メンテナンス性を高めるために重要なことは下記2点です
- 技術的な知識と要件実現のための知識を分離する
- 役割の依存方向性を明確にし逆方向への依存をしないようにする
これらを満たす方法として、 クリーンアーキテクチャ を簡易的に導入してみます。
すでにこのアプリケーションはモジュール化の準備ができているため、下記の単位でモジュールを切ります。
モジュール | 役割 | 利用されている | 技術的関心事 |
---|---|---|---|
コントローラー | Webフレームワークを取り扱う | - | axum |
サービス1 | ユースケースを実装する | コントローラー | - |
エンティティ | ビジネスモデルを実装する | リポジトリ | - |
リポジトリ(定義) | エンティティの取得・保存の仕様を定義する | サービス | - |
リポジトリ(実装) | リポジトリ(定義)に沿って具体的な処理を実装したもの | - | PostgreSQL |
フォーム | フォームデータのデータ構造を定義する | コントローラー | - |
ビュー | レスポンスのデータ構造を定義する | サービス | - |
From
トレイトの利用
階層化して管理すると、逆方向へ依存しないようにするために、構造体のデータの詰め替えが必要になってきます。 例えば、エンティティが表示用の機能を持つことは依存性が逆転してしまうためできません。そのためビューを別途用意し、エンティティからビューに変換する処理が必要になってきます。
このような型変換が必要なケースにおいては、 From
トレイトや Into
トレイトを使うのがベターです。 Rust By Example 日本語版 にこのトレイトの使用例があります。
このプロジェクトでも From
トレイトを使用した変換を行っています。
use crate::entities::Tweet as TweetEntity;
pub struct Tweet {
pub name: String,
pub message: String,
pub posted_at: String,
}
impl From<TweetEntity> for Tweet { // エンティティをビューに変換する処理を実装する
fn from(e: TweetEntity) -> Self {
Tweet {
name: "太郎".to_string(),
message: e.message,
posted_at: e.posted_at.format("%Y/%m/%d %H:%M").to_string(),
}
}
}
use crate::repositories::Tweets;
use crate::views::Home;
pub async fn list_tweets(repo: &impl Tweets) -> Home {
let tweets = repo.list().await;
Home {
tweets: tweets.into_iter().map(|x| x.into()).collect(), // ここで .into() を呼び出し、エンティティをビューに変換している
}
}
クリーンアーキテクチャでは、この役割は UseCase と Interactor に分かれていますが、今回はそこまではしていません。
テスト
このページではサービス(ユースケース)のテストをしてみます。
テスト概要
テストについては The Rust Programming Language 日本語版 の11章にて解説されているため、そちらに沿ってテストを実装していきます。
テストダブル
サービスはリポジトリに依存しており、テストをするためにはデータベースシステムを用意しないといけません。 もちろん用意しても良いのですが、純粋にサービスだけをテストしたい場合には手間が大きいですので、ここではテストダブルを使用したテストを書きます。
テストダブル用のクレートも様々なものがありますが、ここでは使い勝手の良い mockall を使用します。 サンプルを見てみましょう。
#[cfg(test)]
use mockall::{automock, mock, predicate::*};
#[cfg_attr(test, automock)]
trait MyTrait {
fn foo(&self, x: u32) -> u32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mytest() {
let mut mock = MockMyTrait::new();
mock.expect_foo() // expect_関数名で、モック対象の関数を指定する
.with(eq(4)) // 引数が4であるはず
.times(1) // 1度だけ呼ばれるはず
.returning(|x| x + 1); // 返却する値は「引数の数値+1」
assert_eq!(5, mock.foo(4));
}
}
モックしたいトレイト MyTrait
に対して #[automock]
を指定すると、 MockHogeTrait
という構造体が自動生成されます。これはテストのときにだけ欲しいので、各所で #[cfg(test)]
による条件分岐を行って、コンパイルを制御しています。
Note:
#[cfg_attr(A, B)]
は#[cfg(A)]
のときだけ#[B]
が指定されていると解釈してコンパイルします。
非同期処理のテスト
非同期処理には #[test]
が使えません。代わりに #[tokio::test]
を使用します。
データの更新
このページではデータの更新として、保存と削除を行ってみます。
パスパラメータの取得
データの削除については、パスに含まれるIDから対象を特定して削除するようにしているため、パスパラメータを取得する必要があります。
パスパラメータを取り扱うには、 axum::extract::Path
を使用します。
https://docs.rs/axum/latest/axum/extract/struct.Path.html
上記ページに記載されているサンプルです。
use axum::{
extract::Path,
routing::get,
Router,
};
use uuid::Uuid;
async fn users_teams_show(
Path((user_id, team_id)): Path<(Uuid, Uuid)>, // 引数にPath<(T, ...)>型の変数を宣言することで取り扱いができる
) {
// ...
}
let app = Router::new().route("/users/:user_id/team/:team_id", get(users_teams_show));
パスに :
で始まる名前を含めると、その部分をパラメータと解釈して、リクエストされたときに Path<(T, ...)>
型の値として受け取ることができます。
Note: 通常、Pathの型パラメータはタプルですが、パラメータが1つだけのときはタプルを省略できます。
Pathのタプルに使う型 T
は serde::Deserialize
を満たす必要があります。パラメータの値が型と合わない場合はエラーになります。
更新系SQLの発行
更新系SQL文(INSERT, UPDATE, DELETE)を発行するためのメソッドは execute
になります。メソッドの使い方は query
と同じです。
let name = "Bob";
let id = 42;
let row = conn
.execute("UPDATE hoge SET name = $1 WHERE id = $2", &[&name, &id])
.await?;
セッション
このページでは、ログインしたユーザーのセッションを保持する仕組みを実装します。
データベースのテーブル追加
アカウントモデルを実装したので、それを保存するためのテーブルが必要です。データベースに入り、作成しておきます。
CREATE TABLE accounts
(
id serial primary key,
email varchar(256) not null unique,
password varchar(64) not null,
display_name varchar(16) not null
);
セッション管理のセットアップ
ユーザーセッションを管理するクレートも様々なものがありますが、ここではaxumのサンプルでも利用されている async-session を使用します。
また、セッションの保存先にもデータベースを利用したいので、 async-sqlx-session を使ってデータベースに保存できるようにします。サンプルを見てみましょう。
use async_sqlx_session::PostgresSessionStore;
use async_session::{Session, SessionStore};
use std::time::Duration;
let store = PostgresSessionStore::new(&std::env::var("PG_TEST_DB_URL").unwrap()).await?; // --(1)
store.migrate().await?; // --(2)
store.spawn_cleanup_task(Duration::from_secs(60 * 60)); // --(3)
let mut session = Session::new(); // --(4)
session.insert("key", vec![1,2,3]);
let cookie_value = store.store_session(session).await?.unwrap(); // --(5)
let session = store.load_session(cookie_value).await?.unwrap(); // --(6)
assert_eq!(session.get::<Vec<i8>>("key").unwrap(), vec![1,2,3]);
セッションストアを使う流れは下記のとおりです。
- データベース接続情報を渡して
SessionStore
を作成する(今回は async-sqlx-session を使っているためPostgresSessionStore
を作成している) - (任意)セッション保存用のテーブルがなければ作成する
- (任意)有効期限切れのセッションを削除する処理を定期実行する設定をする
- セッションを作成し、
key = value
の形で保存するデータを格納する - セッションストアにセッションを渡して保存する(Cookie文字列が返却される)
- Cookie文字列をキーにしてセッションストアからセッションを取得する
セッションの作成
セッションを作成するとキーとなるCookie文字列が取得できるので、ログイン成功時にこのクッキー文字列を返却します。
async fn new_session(
form: Form<SignInForm>,
Extension(repository_provider): Extension<RepositoryProvider>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let account_repo = repository_provider.accounts();
if let Some(session_token) =
services::create_session(&account_repo, &form.email, &form.password).await
{
let headers = Headers(vec![("Set-Cookie", session_token.cookie())]); // レスポンスヘッダーの作成
let response = Redirect::to(Uri::from_static("/"));
Ok((headers, response))
} else {
Err(Redirect::to(Uri::from_static("/login?error=invalid")))
}
}
axumではレスポンス時に Headers
を使ってレスポンスヘッダーの指定ができるため、 Set-Cookie
ヘッダーをセットしてレスポンスし、Cookieをブラウザに保存させています。 <T: IntoResponse>(Headers, T)
型も IntoResponse
を実装しています。
セッションのチェック
セッションがなかったり、有効期限切れの場合はログイン画面を表示する必要があります。このようにリクエストの検証を行いたい場合には FromRequest
トレイトを実装します。
Note: 今まで使ってきた
Extractor
もFromRequest
を実装しています。このように、FromRequest
は非常に応用の幅が広いトレイトになっています。
FromRequest
を使って セッションを検証するサンプル を見てみましょう。
#[derive(Clone)]
struct State {
// ...
}
struct AuthenticatedUser {
// ...
}
#[async_trait]
impl<B> FromRequest<B> for AuthenticatedUser
where
B: Send,
{
// (追記)Rejectionに from_request() が失敗した場合の型を宣言する
type Rejection = Response;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
// (追記)Authorizationヘッダーの値を読み取る
let TypedHeader(Authorization(token)) =
TypedHeader::<Authorization<Bearer>>::from_request(req)
.await
.map_err(|err| err.into_response())?;
// (追記)共有しているstateの情報を取得する
let Extension(state): Extension<State> = Extension::from_request(req)
.await
.map_err(|err| err.into_response())?;
// actually perform the authorization...
unimplemented!()
// (追記)これ以降の処理として、tokenとstateのデータを見て
// Ok(...)を返すかErr(...)を返すか制御する必要がある
}
}
async fn handler(user: AuthenticatedUser) { // ハンドラー関数の引数に追加するとAuthenticatedUserのfrom_requestが実行される
// (追記)AuthenticatedUser#from_requestがOk(...)のときにだけ実行される
// Err(...)の場合は実行されずにErrの中身が返却される
// ...
}
let state = State { /* ... */ };
let app = Router::new().route("/", get(handler)).layer(AddExtensionLayer::new(state));
このサンプルでは
Authorization: Bearer XXXXXX
というヘッダーがクライアントから指定されリクエストされてくることを想定しているState
構造体のデータをアプリケーションサーバーで共有情報として持っている- つまりアプリケーションサーバーが再起動されると共有情報が消える
という前提で書かれているようです。
同じ要領で、今回のセッションの仕組みに対応した FromRequest
を実装しています。
大まかな流れは下記のとおりです。
- リクエストヘッダーにある Cookie を読み取り、予め発行しておいた値(キーが
AXUM_SESSION_COOKIE_NAME
のもの)を読み取る - 読み取った値でセッションストアに問い合わせを行い、保存しているユーザーIDを取得する
- ユーザーIDを含む
UserContext
をハンドラー関数に返却する - 上記までのどこかでエラーが発生した場合はログイン画面にリダイレクトする
use crate::constants::{database_url, AXUM_SESSION_COOKIE_NAME, AXUM_SESSION_USER_ID_KEY};
use async_session::SessionStore;
use async_sqlx_session::PostgresSessionStore;
use axum::extract::{FromRequest, RequestParts, TypedHeader};
use axum::headers::Cookie;
use axum::http::Uri;
use axum::response::Redirect;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct UserContext {
pub user_id: i32,
}
#[axum::async_trait]
impl<B> FromRequest<B> for UserContext
where
B: Send,
{
type Rejection = Redirect;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let redirect = || Redirect::to(Uri::from_static("/login"));
let database_url = database_url();
let store = PostgresSessionStore::new(&database_url)
.await
.map_err(|_| redirect())?;
let cookies = Option::<TypedHeader<Cookie>>::from_request(req)
.await
.unwrap()
.ok_or(redirect())?;
let session_str = cookies.get(AXUM_SESSION_COOKIE_NAME).ok_or(redirect())?;
let session = store
.load_session(session_str.to_string())
.await
.map_err(|_| redirect())?;
let session = session.ok_or(redirect())?;
let context = UserContext {
user_id: session.get::<i32>(AXUM_SESSION_USER_ID_KEY).unwrap(),
};
Ok(context)
}
}
あとは、認証が必要なリクエストのハンドラー関数の引数に UserContext
を追加すれば完了です。
テーブルリレーション
このページでは、2つのテーブルがリレーションしている状態でデータの保存と取得をしてみます。
テーブルのリレーション追加
まず、前回作成したアカウントとツイートを関連させるために、データベースのテーブル定義を変更します。
DELETE FROM tweets;
ALTER TABLE tweets ADD COLUMN posted_by integer not null;
ALTER TABLE tweets ADD CONSTRAINT fk_tweets_posted_by_accounts FOREIGN KEY (posted_by) REFERENCES accounts (id);
テーブルに列を足すにあたり、ツイートデータが残っていると都合が悪いため、事前に削除しています。
複数テーブルのデータからビューの作成
複数テーブルのデータを取得してビューを作成する方法は2通りあります。
INNER JOIN
等を使ったSQLを発行し、データベースで連結した状態のデータを取得する- それぞれのテーブルからエンティティを取得し、プログラムでそれぞれのエンティティを組み合わせてビューのデータを作成する
今回は2の方法で取得しています。1の方法は1度のSQL発行で済むため 処理性能が良い のですが、連結した状態のデータがエンティティではない場合、コードが荒れるのを防ぐために CQS など別の概念を導入してコードを整理する必要があり、少し実装が難しいです。とはいえ、実際は1の方法を採ることが多いので、今後のチャレンジとしても良いでしょう。
pub async fn list_tweets(repo: &impl Tweets, account_repo: &impl Accounts) -> Home {
// ツイート一覧を取得する
let tweets = repo.list().await;
// ツイート一覧にある posted_by を重複無しの一覧にする
let posted_account_ids = tweets.iter().map(|x| x.posted_by).collect::<HashSet<i32>>();
// posted_by のidでアカウント一覧を取得する
let accounts = account_repo.find(posted_account_ids).await;
let tweets = tweets
.into_iter()
.map(|x| {
let account = accounts.get(&x.posted_by).unwrap();
// ツイートとアカウントのタプルからビューを作る
(x, account).into()
})
.collect();
Home { tweets }
}
use crate::entities::{Account, Tweet as TweetEntity};
pub struct Tweet {
pub id: String,
pub name: String,
pub message: String,
pub posted_at: String,
}
impl From<(TweetEntity, &Account)> for Tweet {
fn from(e: (TweetEntity, &Account)) -> Self {
Tweet {
id: e.0.id().unwrap_or(-1).to_string(),
name: e.1.display_name.clone(),
message: e.0.message,
posted_at: e.0.posted_at.format("%Y/%m/%d %H:%M").to_string(),
}
}
}
tokio-postgres での where in の使用について
tokio-postgres では、where
句の in
に パラメータとして &Vec
を渡すことができませんでした。
let ids = vec![1, 2, 3];
let rows = conn
.query(
"SELECT * FROM accounts WHERE id in ($1)",
&[&ids],
)
.await
.unwrap();
展開された状態であれば大丈夫なようです。(が、これでは in
に指定するパラメータの数が限定されてしまい、使い勝手が悪いです。)
let rows = conn
.query(
"SELECT * FROM accounts WHERE id in ($1,$2,$3)",
&[&1, &2, &3],
)
.await
.unwrap();
そこで、in
に指定する文字列を生成し、SQL文に直接指定する方法にしています。
let ids = vec![1, 2, 3];
let ids_str = ids
.into_iter()
.map(|x| x.to_string())
.collect::<Vec<String>>()
.join(",");
let rows = conn
.query(
&format!("SELECT * FROM accounts WHERE id in ({})", ids_str),
&[],
)
.await
.unwrap();
Note: リクエストのパラメータを使用する場合は、別途、SQLインジェクション対策が必要です。
おわりに
おめでとうございます!ここまでで、rustとaxumを使ったウェブアプリの開発が一通りできるようになりました。今後は下記のチャレンジをしてみるのが良いでしょう。
- 今回スコープアウトしたグレーになっているユースケースを機能拡張してみる
- バリデーションを実装してみる
- axumに バリデーションの例 があります
- 別途Reactなどのフロントエンドのフレームワークを用意し、JSONリクエストとJSONレスポンスをするAPIサーバーに作り変えてみる
- axumに JSONを使ったTODO管理アプリの例 があります
- ファイルアップロードできるようにする
- axumに multipart/form-dataを取り扱う例 や ファイル配信する例 があります
- Socket通信に対応してみる
- axumに Websocketの例 があります
その他 多くの例 が実装されているので、一度覗いてみると良いと思います。