はじめに

本書ではRustを使ってウェブアプリケーションを開発する方法を学びます。

本書の見方

本書の構成は下記の通りとなっています。

  • フレームワークセクションでは、ウェブアプリケーションを簡単に作成できるライブラリの基本的な使い方を学びます
  • データベースセクションでは、フレームワークからデータベースを使う方法を学びます
  • 認証セクションでは、フレームワークで認証の仕組みを実装する方法を学びます

各セクションでは重要なポイントについての解説があり、最後にそれらを踏まえ演習があります。演習を通して実際にウェブアプリケーションを製作していきます。

今回製作するもの

本書の演習の題材としてTwitterクローンを取り上げます。

要求定義

まずは、今回製作するTwitterクローンにどのようなことが期待されているかを明らかにします。

今回は演習なので、すでに分かっているものとして仮定することにします。

  • 自分のツイートを全世界に発信したい
  • 発信したけど、後からでもなかったことにしたい
  • 友だちやお気に入りの人のツイートを優先的に見たい
  • 他の人が自分を騙ってツイートできないようにしたい

要件定義

上記の要求から、今回のシステムに必要なものを検討します。

Twitterクローンなので、当然、ツイートを操作できることは必須でしょう。

その他、他の人が自分を騙ってツイートできないようにしたり、 友だちのツイートを優先的に見られるようにするためには、 アカウントのシステムを構築すれば実現できそうです。

以上を踏まえ、今回のアプリケーションでは、下記のことができるようにします。

  • アカウントを作成できる
  • アカウント名とパスワードでログインすることができる
  • 自分を含め、ユーザーのツイートを見ることができる
  • ログイン後にツイートをすることができる
  • ログイン後にツイートを消すことができる
  • ログイン後に他のユーザーを検索することができる
  • ログイン後に他のユーザーをフォローすることができる
  • ログイン後にフォローしたユーザーのツイート一覧を見ることができる

それでは、次セクションから製作に入っていきましょう!

補講:設計

今回製作するアプリケーションへの理解を深めるために、いくつか設計資料を掲載しておきます。

すでにアプリケーションを開発したことがあり、Rustでの開発を学びたい方は本セクションをスキップして次セクションに進むこともできます。

ユースケース

ユーザーの要求とシステムの振る舞いを視覚的に明確化したものがユースケース図です。

ユースケース

ロバストネス分析

ユースケースを、よりシステムの観点で記載したものがロバストネス分析図です。

ロバストネス分析

ドメインモデル

ビジネスモデルをデータの観点で洗い出したものがドメインモデル図です。

今回はそこまで複雑な機能は作り込まずシンプルなモデリングにしているため、登場するドメインモデルは少なめになっています。

ドメインモデル

フレームワーク編

このセクションでは、フレームワークの使い方を学びます。

Rustは、ライブラリを始めエコシステムが充実しているため、 フレームワークを使わずともウェブサービスを製作することは可能です。

しかし、フレームワークを使うことで汎用的な仕組みまでイチから作ることなく、 製作したいサービスの実装に専念できます。

それでは、まずはセットアップから始めましょう。

セットアップ

今回は非同期ランタイムライブラリで有名な tokio が開発している axum を使います。

Hello, world!

まずはRustプロジェクトを作成します。

$ mkdir rustwi
$ cd rustwi
$ cargo init
     Created binary (application) package

次に Cargo.toml[dependencies] 節にクレートを追加します。

Cargo.toml
[package]
name = "rustwi"
version = "0.1.0"
edition = "2018"

[dependencies]
axum = "0.3"
tokio = { version = "1.0", features = ["full"] }

メインのソースコードも変更します。

src/main.rs
use axum::{response::Html, routing::get, Router};
use std::net::SocketAddr;

#[tokio::main]  // main関数を非同期関数にするために必要
async fn main() {
    let app = Router::new().route("/", get(handler));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("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>")
}

Note: src/main.rs は特別なファイルです。 プログラムを実行すると、必ずこのファイルのmain関数から始まります。

特に重要なのが

    let app = Router::new().route("/", get(handler));

この1行です。これは

/ のパスに GET でアクセスがあったら handler を実行せよ

という意味になります。詳しくはファイルの配信のページで解説します。

ここまで実装したらバイナリを実行してみましょう。

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/rustwi`
listening on 127.0.0.1:3000

ブラウザを開いて http://localhost:3000/にアクセスすると、 Hello, World! と表示されるはずです。数行のコードでウェブサービスを立ち上げることができました!

とはいえ、これはまだウェブサービス製作のほんの入口です。次のページ以降で、より柔軟なHTMLを返却する方法を学びます。

HTMLの表示

より柔軟なHTMLを表示できるようにするために、テンプレートエンジンを使ってみましょう。

askamaの導入

テンプレートエンジンには askama を使います。 Cargo.toml[dependencies] 節にクレートを追加します。

Cargo.toml
[package]
name = "rustwi"
version = "0.1.0"
edition = "2018"

[dependencies]
axum = "0.3"
tokio = { version = "1.0", features = ["full"] }
askama = "0.10"  # 追加

テンプレートHTMLの作成

askamaを使うことで、テンプレートにデータを埋め込んで、データに合わせたHTMLを表示することができます。

まずはシンプルに、ツイートを表示するテンプレートを作成します。

templates/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Rustwi</title>
</head>
<body>

<div style="border: 1px solid gray;">
  <span style="font-weight: bold; border-bottom: 1px solid gray; border-right: 1px solid gray;">{{name}}</span>
  <div>{{message}}</div>
  <div style="font-size: 80%; color: gray">{{posted_at}}</div>
</div>

</body>
</html>

Note: テンプレートファイルは templates ディレクトリ配下に配置します。

構文はJinjaに準拠しているため、具体的な書き方はリファレンスを参照すると良いでしょう。

テンプレート構造体の作成

テンプレートを使うには、 askama::Template トレイトを継承(derive)した構造体を用意します。

use askama::Template;

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    name: String,
    message: String,
    posted_at: String,
}

フィールドにテンプレートに埋め込みたいデータを持たせることで、テンプレートにデータを反映させることができます。

let view = IndexView {
    name: "太郎".into(),
    message: "こんにちは!".into(),
    posted_at: "2020-01-01 12:34".into(),
};

ユーティリティの作成

上記まででテンプレートを作成することができますが、レスポンスとしてテンプレートを使うためには、テンプレートが IntoResponse を実装している必要があります。

直接この構造体に実装する方法もありますが、今回は、テンプレートをフィールドに持つ構造体を用意し、この構造体に IntoResponse を実装してみます。

use askama::Template;
use axum::{
    body::{Bytes, Full},
    http::{Response, StatusCode},
    response::{Html, IntoResponse},
    routing::get,
    Router,
};
use std::{convert::Infallible, net::SocketAddr};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(index));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn index() -> impl IntoResponse {
    HtmlTemplate(IndexView {
        name: "太郎".into(),
        message: "こんにちは!".into(),
        posted_at: "2020-01-01 12:34".into(),
    })
}

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    name: String,
    message: String,
    posted_at: String,
}

struct HtmlTemplate<T>(T);

impl<T> IntoResponse for HtmlTemplate<T>
where
    T: Template,
{
    type Body = Full<Bytes>;
    type BodyError = Infallible;

    fn into_response(self) -> Response<Self::Body> {
        match self.0.render() {
            Ok(html) => Html(html).into_response(),
            Err(err) => Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Full::from(format!(
                    "Failed to render template. Error: {}",
                    err
                )))
                .unwrap(),
        }
    }
}

少し回りくどい方法ですが、こうすることで テンプレートごとにIntoResponseを実装する必要がなくなる というメリットがあります。

このように変更して実行すると、太郎さんのツイートが1つ表示されるはずです。

ファイルの配信

HTMLにベタ書きされているCSSを、ファイルとして取り扱えるように変更しましょう。

CSSファイルの作成

何はともあれ、まずはHTMLファイルにあるCSSを抜き出してファイル化します。

static/css/index.css
.tweet {
  border: 1px solid gray;
}

.name {
  font-weight: bold;
  border-bottom: 1px solid gray;
  border-right: 1px solid gray;
}

.posted_at {
  font-size: 80%;
  color: gray;
}

今後、他のCSSや画像などのリソースも配置することを考慮し、 static ディレクトリを作成し、その配下に配置しました。

tower-httpの導入

先ほど作成したファイルですが、実はaxumではそのままファイルを配信することができません。もちろんファイルを配信する部分を実装をすれば実現可能ですが、ここではすでに有志によって作成されているHTTPクライアント/サーバーライブラリの tower-http を使うことにします。

Cargo.toml[dependencies] 節にクレートを追加します。

Cargo.toml
[package]
name = "rustwi"
version = "0.1.0"
edition = "2018"

[dependencies]
axum = "0.3"
tokio = { version = "1.0", features = ["full"] }
askama = "0.10"
tower-http = { version = "0.1", features = ["fs"] }  # 追加

ルーティング

ここで、一度axumのルーティングについて触れておきます。

axumでは、 Router 構造体を利用してパスと処理を関連付けることができます。関連付けは route メソッドで行い、HTTPメソッドは axum::routing の関数で指定します。

use axum::{
    routing::get,
    response::IntoResponse,
    Router,
};

fn main() {
    let router = Router::new()
        .route("/", get(handler));  // routing::getでHTTP GETに関連付け
}

async fn handler() -> impl IntoResponse {
    ...
}

1つのパスについて、複数のHTTPメソッドと処理を関連付けることもできます。

use axum::{
    routing::get,
    response::IntoResponse,
    Router,
};

fn main() {
    let router = Router::new()
        .route("/", get(handler).post(post_handler));  // HTTP POSTにも関連付け
}

async fn handler() -> impl IntoResponse {
    ...
}

async fn post_handler() -> impl IntoResponse {
    ...
}

さらに、 Router 自体にメソッドチェインで複数のパスと処理を関連付けることもできます。

use axum::{
    routing::get,
    response::IntoResponse,
    Router,
};

fn main() {
    let router = Router::new()
        .route("/", get(handler).post(post_handler))
        .route("/details", get(details_handler));  // `/details` にも関連付け
}

async fn handler() -> impl IntoResponse {
    ...
}

async fn post_handler() -> impl IntoResponse {
    ...
}

async fn details_handler() -> impl IntoResponse {
    ...
}

サービスの組み込み

tower-httpには ServeDir というサービスが実装されており、これを使うことで指定したディレクトリとその配下のすべてのディレクトリ/ファイルを取り扱うことができます。

axumにはtowerサービスを手軽に組み込める service というモジュールも用意されているため、これを使って組み込みます。

src/main.rs
use askama::Template;
use axum::error_handling::HandleErrorExt;
use axum::{
    body::{Bytes, Full},
    http::{Response, StatusCode},
    response::{Html, IntoResponse},
    routing::{get, service_method_routing},
    Router,
};
use std::{convert::Infallible, net::SocketAddr};
use tower_http::services::ServeDir;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .nest("/static", static_contents())
        .route("/", get(index));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn index() -> impl IntoResponse {
    HtmlTemplate(IndexView {
        name: "太郎".into(),
        message: "こんにちは!".into(),
        posted_at: "2020-01-01 12:34".into(),
    })
}

fn static_contents() -> Router {
    Router::new().nest(
        "/",
        service_method_routing::get(ServeDir::new("static")).handle_error(|error: std::io::Error| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Unhandled internal error: {}", error),
            )
        }),
    )
}

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    name: String,
    message: String,
    posted_at: String,
}

struct HtmlTemplate<T>(T);

impl<T> IntoResponse for HtmlTemplate<T>
where
    T: Template,
{
    type Body = Full<Bytes>;
    type BodyError = Infallible;

    fn into_response(self) -> Response<Self::Body> {
        match self.0.render() {
            Ok(html) => Html(html).into_response(),
            Err(err) => Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Full::from(format!(
                    "Failed to render template. Error: {}",
                    err
                )))
                .unwrap(),
        }
    }
}

ここでは、ルーティングを入れ子にするテクニックを使っています。

ルーティングの入れ子

パスごとに処理する関数を関連付ける方法は前述したとおりですが、実はそれだけでなく、 Router 自体をパスに関連付けることもできます。 Router 自体を関連付けるためには nest を使います。

use axum::{
    routing::get,
    response::IntoResponse,
    Router,
};

fn main() {
    let detail_router = Router::new()
        .route("/", get(details_handler));
    let router = Router::new()
        .route("/", get(handler).post(post_handler))
        .nest("/details", detail_router);  // `/details` に別のRouterを関連付け
}

async fn handler() -> impl IntoResponse {
    ...
}

async fn post_handler() -> impl IntoResponse {
    ...
}

async fn details_handler() -> impl IntoResponse {
    ...
}

こうすることにより、全てを1つの Router で管理する必要がなくなるため、見通しの良い設計が可能となります。

HTMLの変更

ここまででCSSファイルの配信ができるようになったので、最後にHTMLファイルがそのCSSファイルを参照するように変更します。

templates/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Rustwi</title>
  <link rel="stylesheet" href="/static/css/index.css">
</head>
<body>

<div class="tweet">
  <span class="name">{{name}}</span>
  <div>{{message}}</div>
  <div class="posted_at">{{posted_at}}</div>
</div>

</body>
</html>

テンプレートの部品化

今後、製作が進むにつれて、再利用可能なHTMLが出てくるかも知れません。また、HTMLファイルが数百行に膨れ上がるかも知れません。そこで、今のうちに、HTMLを分割して管理する方法を学んでおきましょう。

ツイート表示の切り出し

ツイートの表示部分は、ホーム画面やユーザー検索結果画面など、今後さまざまなページで使うことが予想されます。よって、この部分をページの部品として切り出します。

templates/tweet.html
{% macro render(tweet) %}
<div class="tweet">
  <span class="name">{{tweet.name}}</span>
  <div>{{tweet.message}}</div>
  <div class="posted_at">{{tweet.posted_at}}</div>
</div>
{% endmacro %}

このテンプレートに対応する構造体を定義します。といっても、今までの IndexViewTweetView に変更し、 pathtweet.html に変更するだけです。

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

複数ツイートへの対応

ツイートは複数表示する想定のため、このタイミングで複数のツイートを取り扱うように IndexView を変更しておきます。

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    tweets: Vec<TweetView>,
}

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

複数のツイートを取り扱うように変更したため、メインのコードもそれに合わせて変更します。

src/main.rs
use askama::Template;
use axum::error_handling::HandleErrorExt;
use axum::{
    body::{Bytes, Full},
    http::{Response, StatusCode},
    response::{Html, IntoResponse},
    routing::{get, post, service_method_routing},
    Router,
};
use std::{convert::Infallible, net::SocketAddr};
use tower_http::services::ServeDir;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .nest("/static", static_contents())
        .route("/", get(index));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn index() -> impl IntoResponse {
    HtmlTemplate(IndexView {
        tweets: vec![
            TweetView {
                name: "太郎".into(),
                message: "こんにちは!".into(),
                posted_at: "2020-01-01 12:34".into(),
            },
            TweetView {
                name: "次郎".into(),
                message: "こんにちは!こんにちは!!".into(),
                posted_at: "2020-01-02 12:34".into(),
            },
        ],
    })
}

fn static_contents() -> Router {
    Router::new().nest(
        "/",
        service_method_routing::get(ServeDir::new("static")).handle_error(
            |error: std::io::Error| {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("Unhandled internal error: {}", error),
                )
            },
        ),
    )
}

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    tweets: Vec<TweetView>,
}

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

struct HtmlTemplate<T>(T);

impl<T> IntoResponse for HtmlTemplate<T>
where
    T: Template,
{
    type Body = Full<Bytes>;
    type BodyError = Infallible;

    fn into_response(self) -> Response<Self::Body> {
        match self.0.render() {
            Ok(html) => Html(html).into_response(),
            Err(err) => Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Full::from(format!(
                    "Failed to render template. Error: {}",
                    err
                )))
                .unwrap(),
        }
    }
}

TweetView テンプレートを参照し、複数のツイートを表示するようにHTMLとCSSを改修します。

templates/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Rustwi</title>
  <link rel="stylesheet" href="/static/css/index.css">
</head>
<body>

{% import "tweet.html" as tweet %}
{% for t in tweets %}
{% call tweet::render(t) %}
{% endfor %}

</body>
</html>
static/css/index.css
.tweet {
  border: 1px solid gray;
}

.tweet:not(:last-child) {
  margin-bottom: 12px;
}

.name {
  font-weight: bold;
  border-bottom: 1px solid gray;
  border-right: 1px solid gray;
}

.posted_at {
  font-size: 80%;
  color: gray;
}

フォームパラメータの取得

このままでは固定のデータを表示しているだけなので、フォームパラメータを取り扱ってみましょう。

serdeの導入

まず、 serde と呼ばれるライブラリを導入します。このライブラリはシリアライズ/デシリアライズを行うことができるライブラリです。

Note: 構造体などのデータ構造を文字列に変換することをシリアライズ、 その逆で文字列をデータ構造に変換することをデシリアライズといいます。

Cargo.toml
[package]
name = "rustwi"
version = "0.1.0"
edition = "2018"

[dependencies]
axum = "0.3"
tokio = { version = "1.0", features = ["full"] }
askama = "0.10"
tower-http = { version = "0.1", features = ["fs"] }
serde = { version = "1.0", feature = ["derive"] }  # 追加

フォームデータ用の構造体の作成

リクエストからフォームデータを取得するために、フォームデータを格納する構造体を作成します。

use serde::Deserialize;

#[derive(Deserialize)]
struct TweetForm {
    name: String,
    message: String,
}

serde::Deserialize トレイトを継承しているため、文字列データからこの構造体を生成する標準的な方法が実装されます。

フォームデータの取り扱い

これでフォームデータを受け取る準備ができました。フォームデータを受け取る方法は簡単で、ハンドラ関数の引数で Form<T> 型の引数を取るように変更するだけです。

use axum::extract::Form;

async fn post_tweets(form: Form<TweetForm>) -> impl IntoResponse {  // Form<T>のTに作成した構造体型を指定する
    HtmlTemplate(IndexView {
        tweets: vec![TweetView {
            name: form.name.clone(),
            message: form.message.clone(),
            posted_at: "2020-01-01 12:34".into(),
        }],
    })
}

これで、フォームデータの値を利用して IndexView を変化させることができるようになりました。

ビューの修正

フォームを作成し、フォームから投稿した内容が IndexView に表示されることを確認してみましょう。

templates/post-tweet.html
<form action="/tweets/new" method="post" class="form">
  <table>
    <tr>
      <td>名前</td>
      <td><input name="name" /></td>
    </tr>
    <tr>
      <td>メッセージ</td>
      <td><input name="message" /></td>
    </tr>
    <tr>
      <td colspan="2"><button type="submit">送信</button></td>
    </tr>
  </table>
</form>
templates/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Rustwi</title>
  <link rel="stylesheet" href="/static/css/index.css">
</head>
<body>

{% include "post_tweet.html" %}

{% import "tweet.html" as tweet %}
{% for t in tweets %}
{% call tweet::render(t) %}
{% endfor %}

</body>
</html>
src/main.rs
use askama::Template;
use axum::error_handling::HandleErrorExt;
use axum::extract::Form;
use axum::{
    body::{Bytes, Full},
    http::{Response, StatusCode},
    response::{Html, IntoResponse},
    routing::{get, post, service_method_routing},
    Router,
};
use serde::Deserialize;
use std::{convert::Infallible, net::SocketAddr};
use tower_http::services::ServeDir;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .nest("/static", static_contents())
        .route("/", get(index))
        .route("/tweets/new", post(post_tweets));
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

async fn index() -> impl IntoResponse {
    HtmlTemplate(IndexView {
        tweets: vec![
            TweetView {
                name: "太郎".into(),
                message: "こんにちは!".into(),
                posted_at: "2020-01-01 12:34".into(),
            },
            TweetView {
                name: "次郎".into(),
                message: "こんにちは!こんにちは!!".into(),
                posted_at: "2020-01-02 12:34".into(),
            },
        ],
    })
}

async fn post_tweets(form: Form<TweetForm>) -> impl IntoResponse {
    HtmlTemplate(IndexView {
        tweets: vec![TweetView {
            name: form.name.clone(),
            message: form.message.clone(),
            posted_at: "2020-01-01 12:34".into(),
        }],
    })
}

fn static_contents() -> Router {
    Router::new().nest(
        "/",
        service_method_routing::get(ServeDir::new("static")).handle_error(
            |error: std::io::Error| {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("Unhandled internal error: {}", error),
                )
            },
        ),
    )
}

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    tweets: Vec<TweetView>,
}

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

#[derive(Deserialize)]
struct TweetForm {
    name: String,
    message: String,
}

struct HtmlTemplate<T>(T);

impl<T> IntoResponse for HtmlTemplate<T>
where
    T: Template,
{
    type Body = Full<Bytes>;
    type BodyError = Infallible;

    fn into_response(self) -> Response<Self::Body> {
        match self.0.render() {
            Ok(html) => Html(html).into_response(),
            Err(err) => Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Full::from(format!(
                    "Failed to render template. Error: {}",
                    err
                )))
                .unwrap(),
        }
    }
}
static/css/index.css
.form {
  margin-bottom: 24px;
}

.tweet {
  border: 1px solid gray;
}

.tweet:not(:last-child) {
  margin-bottom: 12px;
}

.name {
  font-weight: bold;
  border-bottom: 1px solid gray;
  border-right: 1px solid gray;
}

.posted_at {
  font-size: 80%;
  color: gray;
}

リファクタ

このページでは、ライブラリ化とモジュール化について学びます。

バイナリクレートとライブラリクレート

src/main.rs は特別なファイルであると以前説明しましたが、これは バイナリクレートクレートルートファイル と呼ばれています。

クレートとはパッケージ(他ユーザーや他パッケージに機能を提供する目的を持ったコード群)の中で、最も大きなプログラムです。下記2つの種類に分類できます。

  • バイナリクレート
    • ユーザーが実行できるプログラムで、1つのパッケージでいくつでも持てます。
  • ライブラリクレート
    • 自身のバイナリクレートや他のパッケージにロジックを提供するプログラムで、1つのパッケージで1つまでしか持てません。

クレートは複数のモジュールで構成することができます。そのため、クレートには予め起点となる特別なファイルが規定されており、これをクレートルートファイルと呼びます。

ライブラリクレートのクレートルートファイルは src/lib.rs となります。

なお、パッケージは1つ以上のクレートを持っている必要があります。

ロジックのライブラリクレート化

Rustではプログラムの実行に関する部分だけを src/main.rs に記述し、ロジックは src/lib.rs に記述する習慣があります。そうすることにより、

  • src/main.rs をプログラムの実行に関することだけに集中させることができ、コード量が小さくなる
  • テストコードが書けるようになる
    • src/main.rs はテストできない

などの恩恵があります。

では、早速 src/lib.rs を作成しましょう。といっても、ほとんどのコードを src/lib.rs に移動するだけです。

src/lib.rs
use askama::Template;
use axum::error_handling::HandleErrorExt;
use axum::extract::Form;
use axum::{
    body::{Bytes, Full},
    http::{Response, StatusCode},
    response::{Html, IntoResponse},
    routing::{get, post, service_method_routing},
    Router,
};
use serde::Deserialize;
use std::{convert::Infallible, net::SocketAddr};
use tower_http::services::ServeDir;

pub fn routes() {  // main.rsからアクセスできるようにするためにpubを付ける必要がある
    Router::new()
        .nest("/static", static_contents())
        .route("/", get(index))
        .route("/tweets/new", post(post_tweets))
}

async fn index() -> impl IntoResponse {
    HtmlTemplate(IndexView {
        tweets: vec![
            TweetView {
                name: "太郎".into(),
                message: "こんにちは!".into(),
                posted_at: "2020-01-01 12:34".into(),
            },
            TweetView {
                name: "次郎".into(),
                message: "こんにちは!こんにちは!!".into(),
                posted_at: "2020-01-02 12:34".into(),
            },
        ],
    })
}

async fn post_tweets(form: Form<TweetForm>) -> impl IntoResponse {
    HtmlTemplate(IndexView {
        tweets: vec![TweetView {
            name: form.name.clone(),
            message: form.message.clone(),
            posted_at: "2020-01-01 12:34".into(),
        }],
    })
}

pub fn static_contents() -> Router {
    Router::new().nest(
        "/",
        service_method_routing::get(ServeDir::new("static")).handle_error(
            |error: std::io::Error| {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("Unhandled internal error: {}", error),
                )
            },
        ),
    )
}

#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    tweets: Vec<TweetView>,
}

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

#[derive(Deserialize)]
struct TweetForm {
    name: String,
    message: String,
}

struct HtmlTemplate<T>(T);

impl<T> IntoResponse for HtmlTemplate<T>
where
    T: Template,
{
    type Body = Full<Bytes>;
    type BodyError = Infallible;

    fn into_response(self) -> Response<Self::Body> {
        match self.0.render() {
            Ok(html) => Html(html).into_response(),
            Err(err) => Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Full::from(format!(
                    "Failed to render template. Error: {}",
                    err
                )))
                .unwrap(),
        }
    }
}

コードを移動したので、 src/main.rs は、移動した関数の呼び出しのみになります。

src/main.rs
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let routes = rustwi::routes();
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(routes.into_make_service())
        .await
        .unwrap();
}

モジュール分割

しかし、 src/lib.rs に移動しただけでは今までとあまり変わりません。ここからさらに、いくつかのファイルに分割してみます。

手始めに、 HtmlTemplate をファイル化しましょう。

src/html_template.rs
use askama::Template;
use axum::body::{Bytes, Full};
use axum::http::{Response, StatusCode};
use axum::response::{Html, IntoResponse};
use std::convert::Infallible;

pub struct HtmlTemplate<T>(pub T);  // pubをつける

impl<T> IntoResponse for HtmlTemplate<T>
where
    T: Template,
{
    type Body = Full<Bytes>;
    type BodyError = Infallible;

    fn into_response(self) -> Response<Self::Body> {
        match self.0.render() {
            Ok(html) => Html(html).into_response(),
            Err(err) => Response::builder()
                .status(StatusCode::INTERNAL_SERVER_ERROR)
                .body(Full::from(format!(
                    "Failed to render template. Error: {}",
                    err
                )))
                .unwrap(),
        }
    }
}

Note: ファイル化した場合、他のファイルから利用する構造体や関数には pub キーワードが必要です。

src/lib.rs で先程ファイル化した内容を呼び出すためには src/lib.rsmod キーワードを使用します。これにより、先程作成したファイルがモジュールとして登録されます。

src/lib.rs
mod html_template;  // モジュール宣言する

use axum::extract::Form;
use axum::{
    routing::{get, post},
    Router,
};
use serde::Deserialize;
use std::net::SocketAddr;

use crate::html_template::HtmlTemplate;  // HtmlTemplateの使用を宣言する

pub fn routes() {  // main.rsからアクセスできるようにするためにpubを付ける必要がある
    Router::new()
        .nest("/static", static_contents())
        .route("/", get(index))
        .route("/tweets/new", post(post_tweets))
}

async fn index() -> impl IntoResponse {
    HtmlTemplate(IndexView {  // いままでどおりHtmlTemplateが使える
        tweets: vec![
            TweetView {
                name: "太郎".into(),
                message: "こんにちは!".into(),
                posted_at: "2020-01-01 12:34".into(),
            },
            TweetView {
                name: "次郎".into(),
                message: "こんにちは!こんにちは!!".into(),
                posted_at: "2020-01-02 12:34".into(),
            },
        ],
    })
}

async fn post_tweets(form: Form<TweetForm>) -> impl IntoResponse {
    HtmlTemplate(IndexView {
        tweets: vec![TweetView {
            name: form.name.clone(),
            message: form.message.clone(),
            posted_at: "2020-01-01 12:34".into(),
        }],
    })
}

fn static_contents() -> Router {
    Router::new().nest(
        "/",
        service_method_routing::get(ServeDir::new("static")).handle_error(
            |error: std::io::Error| {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("Unhandled internal error: {}", error),
                )
            },
        ),
    )
}


#[derive(Template)]
#[template(path = "index.html")]
struct IndexView {
    tweets: Vec<TweetView>,
}

struct TweetView {
    name: String,
    message: String,
    posted_at: String,
}

#[derive(Deserialize)]
struct TweetForm {
    name: String,
    message: String,
}

このようにファイルとして切り出すことで、モジュールとして取り扱うことができます。

Note: ちなみに、ファイル化せず直接モジュールとして定義することもできます。

mod moduleA {
    pub fn funcA() {
        ...
    }
}

モジュールの階層管理

より大きな機能の場合、モジュールを階層化したい欲求がでてくるでしょう。Rustではモジュールを階層化することができます。

次に、 TweetForm をファイル化しましょう。フォームは今後いくつも作成する可能性があるため、 forms モジュール配下でそれぞれファイル化します。

src/forms/ ディレクトリを作成し、その中にファイルを作成します。

src/forms/tweet.rs
use serde::Deserialize;

#[derive(Deserialize)]
pub struct Tweet {  // formsモジュールに宣言しているため、名前からFormを削除した
    pub name: String,
    pub message: String,
}

このファイルをモジュールで扱うために src/forms.rs を作成します。

src/forms.rs
mod tweet;

pub use tweet::*;  // re-exports (pub useで「利用すること」自体を公開している)

Note: ディレクトリを作成した場所に、ディレクトリ名と同名でファイルを作成する必要があります。

3行目は re-exports と呼ばれるテクニックです。モジュールの階層は作成者が管理しやすいように構成するのが普通ですが、それが利用者にとっても都合がいいとは限りません。そのため、公開する内容を再構成する機能が提供されています。

これにより、Tweet は以下のいずれの方法でも利用可能となります。

let form1 = crate::forms::tweet::Tweet {name: "Bob", message: "Hello!"};
let form2 = crate::forms::Tweet {name: "Bob", message: "Hello!"};  // tweetの重複がなくなり記述がスッキリしている

あとは、先程と同じように src/lib.rsmod forms; を記載すれば利用可能となります。

役割ごとの階層化

下記のように役割を設定し、この単位でモジュール化してみます。

モジュール役割
コントローラーリクエストを受けてレスポンスを返す部分を実装する
エンティティビジネスモデルを実装する
フォームフォームデータのデータ構造を定義する
ビューレスポンスのデータ構造を定義する

src/ はこのように階層化できます。

src
├── entities
│   └── tweet.rs
├── entities.rs
├── forms
│   └── tweet.rs
├── forms.rs
├── lib.rs
├── main.rs
├── controllers
│   ├── root.rs
│   ├── static_contents.rs
│   └── tweets.rs
├── controllers.rs
├── views
│   ├── html_template.rs
│   └── index.rs
└── views.rs

Note: HtmlTemplateはsrc/viewsのディレクトリに移動しました。

残りのファイルについても掲載しておきます。

src/controllers.rs
mod root;
mod static_contents;
mod tweets;

use axum::Router;

pub fn routes() -> Router {
    Router::new()
        .nest("/", root::root())
        .nest("/static", static_contents::static_contents())
        .nest("/tweets", tweets::tweets())
}
src/controllers/root.rs
use crate::entities::Tweet;
use crate::views::Index;
use axum::response::IntoResponse;
use axum::routing;
use axum::Router;

pub fn root() -> Router {
    Router::new().route("/", routing::get(get))
}

async fn get() -> impl IntoResponse {
    Index::new(vec![
        Tweet::new(
            "太郎".into(),
            "こんにちは!".into(),
            "2020-01-01 12:34".into(),
        ),
        Tweet::new(
            "次郎".into(),
            "こんにちは!こんにちは!!".into(),
            "2020-01-02 12:34".into(),
        ),
    ])
    .into_html()
}
src/controllers/static_contents.rs
use axum::error_handling::HandleErrorExt;
use axum::http::StatusCode;
use axum::routing::service_method_routing;
use axum::Router;
use tower_http::services::ServeDir;

pub fn static_contents() -> Router {
    Router::new().nest(
        "/",
        service_method_routing::get(ServeDir::new("static")).handle_error(
            |error: std::io::Error| {
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("Unhandled internal error: {}", error),
                )
            },
        ),
    )
}
src/controllers/tweets.rs
use crate::entities::Tweet as TweetEntity;
use crate::forms::Tweet as TweetForm;
use crate::views::Index;
use axum::extract::Form;
use axum::response::IntoResponse;
use axum::routing;
use axum::Router;

pub fn tweets() -> Router {
    Router::new().route("/new", routing::post(post))
}

async fn post(form: Form<TweetForm>) -> impl IntoResponse {
    Index::new(vec![TweetEntity::new(
        form.name.clone(),
        form.message.clone(),
        "2020-01-01 12:34".into(),
    )])
    .into_html()
}
src/views.rs
mod index;

pub use index::*;
src/views/index.rs
use super::html_template::HtmlTemplate;
use crate::entities::Tweet;
use askama::Template;

#[derive(Template)]
#[template(path = "index.html")]
pub struct Index {
    pub tweets: Vec<Tweet>,
}

impl Index {
    pub fn new(tweets: Vec<Tweet>) -> Index {
        Index { tweets }
    }

    pub fn into_html(self) -> HtmlTemplate<Index> {
        HtmlTemplate(self)
    }
}
src/entities.rs
mod tweet;

pub use tweet::*;
src/entities/tweet.rs
pub struct Tweet {
    pub name: String,
    pub message: String,
    pub posted_at: String,
}

impl Tweet {
    pub fn new(name: String, message: String, posted_at: String) -> Tweet {
        Tweet {
            name,
            message,
            posted_at,
        }
    }
}
src/lib.rs
mod entities;
mod forms;
mod controllers;
mod views;

pub use services::routes;

データベース編

このセクションでは、axumでデータベースを使う方法を学びます。

今回はデータベースとして PostgreSQL を使う前提で説明します。

それでは、まずはセットアップから始めましょう。

セットアップ

このページではaxumでデータベースを使うための準備をします。

ライブラリの導入

まず、データベースを取り扱うにあたって、いくつか便利なクレートを追加します。

Cargo.toml
[package]
name = "rustwi"
version = "0.1.0"
edition = "2018"

[dependencies]
dotenv = "0.15"  # 追加
axum = "0.3"
tokio = { version = "1.0", features = ["full"] }
askama = "0.10"
tower-http = { version = "0.1", features = ["fs"] }
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }  # 追加
bb8 = "0.7.1"  # 追加
bb8-postgres = "0.7.0"  # 追加
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] }  # 追加

各クレートの役割は下記のとおりです。

  • dotenv
    • 環境変数をファイルから読み込むユーティリティ
  • chrono
    • 日付と時間を取り扱うユーティリティ
  • bb8
    • 非同期処理に対応したデータベースユーティリティ
  • bb8-postgres
    • bb8でPostgreSQLを取り扱うためのドライバー
  • tokio-postgres
    • PostgreSQLを非同期で取り扱うためのユーティリティ

データベース接続の実装

続いて、データベース接続部分を実装します。といっても、開発者が行うことはコネクションプール1を作成するだけです。

use bb8::Pool;
use bb8_postgres::PostgresConnectionManager;
use std::env;
use tokio_postgres::NoTls;

type ConnectionPool = Pool<PostgresConnectionManager<NoTls>>;

async fn establish_connection() -> ConnectionPool {
    dotenv::dotenv().ok();
    let manager =
        PostgresConnectionManager::new_from_stringlike(env::var("DATABASE_URL").unwrap(), NoTls)
            .unwrap();
    Pool::builder().build(manager).await.unwrap()
}

fn main() {
    let pool = establish_connection();
    let conn = pool.get().await.unwrap();
    let rows = conn.query("SELECT 1", &[]).await.unwrap();
}

コネクションプールには、接続情報を管理するトレイト ManageConnection を実装した構造体を渡します。今回はPostgreSQLを使用するので、 PostgresConnectionManager を使います。

PostgresConnectionManager::new_from_stringlike(str: &str) を使って、データベース接続情報からインスタンスを生成します。データベース接続情報は .env ファイルに書いておき、これを読み込むことにします。こうすることで、ローカル環境では環境設定を設定せずに .env を利用し、本番環境では直接設定した環境変数を利用することができます。

.env
DATABASE_URL=postgres://[username]:[password]@[address]:[port]/[database]

下記の部分は実際の内容に書き換えてください。

  • [username]: DBユーザー名
  • [password]: DBユーザーのパスワード
  • [address]: DBの接続先アドレス( localhost など)
  • [port]: DBの接続先ポート(通常は5432)
  • [database]: DBのデータベース名

dotenv::dotenv().env ファイルがある場合にファイルの内容を環境変数として読み込みます。環境変数は std::env::var(key: &str) で読み込むことができます。

axumでのコネクションプールの利用

コネクションプールをaxumで使えるようにします。状態の共有には ExtensionLayer という仕組みを使います。ExtensionLayerを設定すると、各handlerで引数として受け取って利用することができます。

ExtensionLayerを生成する関数を作成してみましょう。

src/database.rs
use bb8::Pool;
use bb8_postgres::PostgresConnectionManager;
use std::env;
use tokio_postgres::NoTls;
use tower_http::add_extension::AddExtensionLayer;

pub type ConnectionPool = Pool<PostgresConnectionManager<NoTls>>;

pub async fn init() -> AddExtensionLayer<ConnectionPool> {
    dotenv::dotenv().ok();
    let manager =
        PostgresConnectionManager::new_from_stringlike(env::var("DATABASE_URL").unwrap(), NoTls)
            .unwrap();
    let pool = Pool::builder().build(manager).await.unwrap();
    AddExtensionLayer::new(pool)
}

ExtensionLayerを公開し、 main.rs でインスタンス化したRouterに登録します。

src/lib.rs
mod controllers;
mod database;
mod entities;
mod forms;
mod views;

pub use controllers::routes;
pub use database::init as init_database;
src/main.rs
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let pool = rustwi::init_database().await;
    let routes = rustwi::routes().layer(pool);  // コネクションプールを持つExtensionLayerをlayerメソッドで追加する
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(routes.into_make_service())
        .await
        .unwrap();
}

コネクションプールの利用

これでハンドラーでコネクションプールを利用する準備ができました。 ハンドラーからコネクションプールを使ってデータベースからデータを取り出してみます。

事前に、PostgreSQLに下記のSQLを流してテーブルを作成しておいてください。

CREATE TABLE tweets(
    id        serial      primary key, 
    name      text        not null,
    message   text        not null,
    posted_at timestamptz not null
);
INSERT INTO tweets VALUES (1, '太郎', 'こんにちは', now());

ツイート一覧を表示する部分で、データベースからツイートを取得します。

src/controllers/root.rs
use crate::database::ConnectionPool;
use crate::entities::Tweet;
use crate::views::Index;
use axum::extract::Extension;
use axum::response::IntoResponse;
use axum::routing;
use axum::Router;
use chrono::{DateTime, Utc};

pub fn root() -> Router {
    Router::new().route("/", routing::get(get))
}

async fn get(Extension(pool): Extension<ConnectionPool>) -> impl IntoResponse {  // 引数でExtensionLaterを受け取る
    let conn = pool.get().await.unwrap();  // コネクションプールからコネクションを取得する
    let rows = conn.query("SELECT * FROM tweets", &[]).await.unwrap();  // コネクションを使ってSQLを発行する
    let tweets = rows
        .into_iter()
        .map(|r| {
            Tweet::new(
                r.get("name"),
                r.get("message"),
                r.get::<&str, DateTime<Utc>>("posted_at")
                    .format("%F")
                    .to_string(),
            )
        })
        .collect();
    Index::new(tweets).into_html()
}

ツイート一覧にアクセスすることで、先ほど発行したSQLで挿入されたデータが表示されるようになります。


1

データベースからデータを取得する手順として、データベースに接続し、クエリを実行し、終われば切断します。しかし、データベースにアクセスする度にこの手順を行っていてはデータベースに負荷がかかってしまうので、一般的にはクエリ実行したあとも接続を維持しておき、再度データベースにアクセスするときに、維持しておいた接続を再利用する方法がとられます。これをコネクションプーリングといい、このとき接続を維持しておく領域をコネクションプールといいます。

エンティティの取得

前回のセットアップでツイートの取得ができましたが、今後のためにもう少しコードを整理しておきましょう。

前回のコントローラー実装の問題点

現状ではコントローラーがデータベースからのデータ取得の役割も担っていますが、データをどのように取得するかは 技術的な関心事 であり、コントローラーの役割としては少し責任が重くなってしまっています。

例えば、PostgreSQLをやめて全く別種のデータベースであるMongoDBを使うことにしたとしたらどうでしょうか。本来的には「保存されているツイートを返す」という役割を持っているだけのコントローラーが、技術的な関心事であるデータベースシステムの変更に対応しなければならなくなってしまいます。

今回は依存性逆転の原則1というテクニックを利用して、この問題にアプローチしてみます。

リポジトリの定義

まずは、データ取り扱いのインターフェースとなる リポジトリ をトレイトとして用意します。

src/repositories/tweets.rs
use crate::entities::Tweet;

#[axum::async_trait]
pub trait Tweets {
    async fn list(&self) -> Vec<Tweet>;
}

このトレイトでは

  • ツイートを全て取得することができ、その返却値は Vec<Tweet> である

だけが定義されており、技術的なことは明示されていません。これにより、コントローラは技術的な関心事に振り回されることなく、コントローラーとしてやるべきことだけに専念することができます。

Note: src/repositories.rs の作成と src/lib.rs へのmodの記載も必要です。今までと同じ要領で変更するだけなので割愛します。

リポジトリの実装

今までコントローラーに記載していたものはコントローラーとは別の場所で実装する必要があります。別途、構造体を定義し、上記で定義したリポジトリを実装してみましょう。

src/repositories_impl/tweets.rs
use crate::database::ConnectionPool;
use crate::entities::Tweet;
use crate::repositories;
use chrono::{DateTime, Utc};

pub struct Tweets<'a> {
    pub pool: &'a ConnectionPool,
}

#[axum::async_trait]
impl<'a> repositories::Tweets for Tweets<'a> {
    async fn list(&self) -> Vec<Tweet> {
        let conn = self.pool.get().await.unwrap();
        let rows = conn.query("SELECT * FROM tweets", &[]).await.unwrap();
        rows.into_iter()
            .map(|r| {
                Tweet::new(
                    r.get("name"),
                    r.get("message"),
                    r.get::<&str, DateTime<Utc>>("posted_at")
                        .format("%F")
                        .to_string(),
                )
            })
            .collect()
    }
}

データベースからデータを取り出すためにはコネクションプールの情報が必要です。なので、構造体の生成時に接続情報の参照を持たせることにします。また、この構造体のインスタンスが存在する間は、このコネクションプールの情報を参照できる必要があるので、構造体と参照それぞれにライフタイム指定子をつけておきます。

これにより、リポジトリを実装した構造体の準備もできました。

Note: src/repositories_impl.rs の作成と src/lib.rs へのmodの記載も必要です。今までと同じ要領で変更するだけなので割愛します。

コントローラへの注入

前回、ExtensionLayerを使ってコネクションプールの情報をコントローラーに渡す方法を実装しましたが、今回は更にそれを拡張子し、このリポジトリをコントローラに渡すことにします。

src/database.rs
use crate::repositories_impl::Tweets as TweetsImpl;
use bb8::Pool;
use bb8_postgres::PostgresConnectionManager;
use std::env;
use tokio_postgres::NoTls;
use tower_http::add_extension::AddExtensionLayer;

pub type ConnectionPool = Pool<PostgresConnectionManager<NoTls>>;

pub async fn repository_layer() -> AddExtensionLayer<RepositoryProvider> {
    dotenv::dotenv().ok();
    let manager =
        PostgresConnectionManager::new_from_stringlike(env::var("DATABASE_URL").unwrap(), NoTls)
            .unwrap();
    let pool = Pool::builder().build(manager).await.unwrap();
    AddExtensionLayer::new(RepositoryProvider(pool))
}

#[derive(Clone)]
pub struct RepositoryProvider(ConnectionPool);

impl RepositoryProvider {
    pub fn tweets(&self) -> TweetsImpl {
        TweetsImpl { pool: &self.0 }
    }
}

RepositoryProvider という構造体を用意しました。ExtensionLayerではこの構造体のインスタンスを持たせるようにしています。 RepositoryProviderは tweets というメソッドを持っているので、コントローラー側ではこのメソッドを呼び出すことで Tweets リポジトリを実装したインスタンスを取得することができます。

Note: ExtensionLayerに持たせるデータは Clone トレイトを実装している必要があります。

メイン処理でも、このレイヤーを使うように修正しましょう。

src/lib.rs
mod controllers;
mod database;
mod entities;
mod forms;
mod repositories;
mod repositories_impl;
mod services;
mod views;

pub use controllers::routes;
pub use database::*;  
src/main.rs
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let routes = rustwi::routes().layer(rustwi::repository_layer().await);  // repository_layerを呼び出す
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(routes.into_make_service())
        .await
        .unwrap();
}

コントローラーの修正

コントローラーでは、ExtensionLayerからRepositoryProviderを受け取り、リポジトリを操作します。

src/controllers/root.rs
use crate::repositories::Tweets;
use crate::views::Index;
use crate::RepositoryProvider;
use axum::extract::Extension;
use axum::response::IntoResponse;
use axum::routing;
use axum::Router;

pub fn root() -> Router {
    Router::new().route("/", routing::get(get))
}

async fn get(Extension(repository_provider): Extension<RepositoryProvider>) -> impl IntoResponse {
    let tweets = repository_provider.tweets().list().await;
    Index::new(tweets).into_html()
}

しかしこれでは repository_provider.tweets() で取得できるものがリポジトリの 実装 になっているので、リポジトリの 定義 をした意味がよく分からなくなってしまいます。

実は、更にコントローラーの実装を分割することができます。新たにサービスというモジュールを作成します。

src/services/tweets.rs
use crate::repositories::Tweets;
use crate::views::Index;

pub async fn list_tweets(repo: &impl Tweets) -> Index {
    let tweets = repo.list().await;
    Index::new(tweets)
}

ここで、ようやく定義した repositories::Tweets が引数の型として現れました。この処理だけを見た場合、repositories::Tweets の参照を存在型2としてもらい、 views::Index 型を返す関数と見ることができます。 技術的な関心事 を排除し、「どのリポジトリを使い、どんなビューを返すか」という役割に専念することができるようになりました。

コントローラーも見てみましょう。

src/controllers/root.rs
use crate::services;
use crate::RepositoryProvider;
use axum::extract::Extension;
use axum::response::IntoResponse;
use axum::routing;
use axum::Router;

pub fn root() -> Router {
    Router::new().route("/", routing::get(get))
}

async fn get(Extension(repository_provider): Extension<RepositoryProvider>) -> impl IntoResponse {
    let tweet_repo = repository_provider.tweets();
    let view = services::list_tweets(&tweet_repo).await;  // サービスを利用する
    view.into_html()
}

コントローラーについては先ほどとあまり変わっていないように感じられます。むしろ、サービスを作ったので、余計にコード量が増えているように感じるかも知れません。しかし、コントローラーはaxumを取り扱っているため、技術的な関心事(Webフレームワーク)を含んでいます。そのため、コントローラは技術的な関心事に集中できるようになったと考えることもできます。

役割の整理

ここまで様々な役割を持ったモジュールが登場したので、もう一度役割を整理してみましょう。

モジュール役割利用されている技術的関心事
コントローラーWebフレームワークを取り扱う-Webフレームワーク
サービスユースケースを実装するコントローラー-
エンティティビジネスモデルを実装するリポジトリ-
リポジトリ(定義)データ入出力の仕様を定義するサービス-
リポジトリ(実装)データベースを取り扱いデータ入出力を実装する-データベース
フォームフォームデータのデータ構造を定義するコントローラー-
ビューレスポンスのデータ構造を定義するサービス-

技術的関心事を持つモジュールが他のモジュールからは利用されておらず、技術な変更に柔軟に対応できるようになっていることが分かります。


1

依存性逆転の原則とはオブジェクト指向におけるソフトウェア設計の5原則(SOLID)の一つです。ここでは詳細な説明は割愛しますが、よく分かってない方は こちら をご覧ください。

2

impl トレイト型 のような型を 存在型 といいます。関数・メソッドの引数や戻り値に存在型を使えます。存在型を使うことで、具体的な型の指定を宣言側ではなく利用側に決定させることができます。存在型は コンパイル時に具体的な型に解決される ため、実行時に条件分岐で渡す/返す型が決まるような場合には使えません。 Box<T> 型など、ヒープ領域への参照として取り扱う型を利用する必要があります。

単体テスト

サービスとしてユースケースの分離ができたので、ユースケースのテストコードを書いてみます。

mockallの導入

サービスはリポジトリを使っているので、本来であれば、テストをするためにはデータベースを起動する必要があります。しかし、実際には起動する必要はありません。

リポジトリの定義に依存するように実装したため、リポジトリを実装しているものであれば何であれ利用できるのです。実際にデータベースに繋がずに固定のデータを返すリポジトリを実装するのでも良いのです。

とはいえ、テストしたい内容(テストケース)に合わせてリポジトリから返却されるデータを柔軟に変えるのは大変です。今回はテストダブルのライブラリを使用して、リポジトリの動作をテストケースごとに柔軟に変えられるようにしましょう。

Cargo.toml
[package]
name = "rustwi"
version = "0.1.0"
edition = "2018"

[dependencies]
dotenv = "0.15"
axum = "0.3"
tokio = { version = "1.0", features = ["full"] }
askama = "0.10"
tower-http = { version = "0.1", features = ["fs"] }
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
bb8 = "0.7.1"
bb8-postgres = "0.7.0"
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] }
mockall = "0.10"  # 追加

テストダブルの実装

まずはテストダブルについて説明します。rustではテストは慣例的に下記のように書きます。

// テストしたい関数
fn add(a: i32, b: i32) -> i32 {
    a + b
}

// テスト用コンパイルのときにだけ有効にする宣言
#[cfg(test)]
mod tests {
    // 実行したいテストの関数には #[test] が必要
    #[test]
    fn test_add() {
        assert_eq!(3, super::add(1, 2))  // 検証にはassertを使う
    }
}

add 関数はシンプルな処理なのでテストを書く必要性は感じられないかもしれません。もう少し複雑な例を見てみましょう。下記は add 関数が Counter トレイトに依存する場合のテストです。

trait Counter {
    fn count(&self) -> i32;
    fn increment(&mut self);
}

fn add(a: i32, counter: &impl Counter) -> i32 {
    a + counter.count()
}

#[cfg(test)]
mod tests {
    use super::Counter;
    
    struct StandardCounter(i32);
    
    impl Counter for StandardCounter {
        fn count(&self) -> i32 {
            self.0
        }
        
        fn increment(&mut self) {
            self.0 += 1;
        }
    }

    #[test]
    fn test_add() {
        let mut counter = StandardCounter(1);
        assert_eq!(2, super::add(1, &counter));
    }
}

本体のプログラムで add 関数に渡す Counter は、より複雑で、外部の仕組みを使うようなものになるかもしれません。しかし、 add 関数自体をテストするだけならば、そこまでのものは必要ないはずです。

そこで、テスト用に SimpleCounter という構造体を用意し、最低限の動作を実装しています。これで、 add 関数をテストできるようになります。このとき実装した SimpleCounter をテストダブルといいます。

このようにテストダブルを実装することで依存性を気にすることなくテストを行うことができますが、逐一自前で実装していては時間が足りません。そこで、テストダブルを自動で作成してくれるライブラリを利用します。

テストダブルの自動生成

今回はサービスのテストを実装するので、前回定義した Tweets リポジトリのテストダブルを用意します。といっても利用は非常に簡単で、トレイトに対し #[mockall::automock] を適用するだけです。

src/repositories/tweets.rs
use crate::entities::Tweet;

#[cfg_attr(test, mockall::automock)]  // 追加
#[axum::async_trait]
pub trait Tweets {
    async fn list(&self) -> Vec<Tweet>;
}

cfg_attr は条件付きコンパイルというもので、 #[cfg_attr(A, B)] は「条件Aが有効なとき #[B] を適用する」というものです。今回のケースでは「テスト用コンパイルが有効なとき #[mockall::automock] を適用する」という意味になります。テスト用コンパイルのときにだけテストダブルが必要(逆に、通常のコンパイルでは自動生成して欲しくない)なので、このような指定をします。

サービスのテストケースの実装

テストダブルが用意できたので、次にサービスのテストケースを実装します。今回は下記の2パターンを実装してみましょう。

  • リポジトリから Tweet を1件以上取得できれば views::Indextweets はリポジトリから取得できた内容になっているはず
  • リポジトリから Tweet を1件も取得できなければ views::Indextweets は空になっているはず
src/services/tweets.rs
use crate::repositories::Tweets;
use crate::views::Index;

pub async fn list_tweets(repo: &impl Tweets) -> Index {
    let tweets = repo.list().await;
    Index::new(tweets)
}

#[cfg(test)]
mod tests {
    use crate::entities::Tweet;
    // mockall::automock により"Mock"という接頭語が付いたクラスが自動生成されている
    use crate::repositories::MockTweets; 

    // 非同期処理のテストは #[test] ではなく #[tokio::test] を使う
    #[tokio::test]
    async fn test_list_tweets() {
        let mut tweets = MockTweets::new();
        tweets.expect_list().returning(|| {
            vec![
                Tweet::new("name1".into(), "message1".into(), "posted_at1".into()),
                Tweet::new("name2".into(), "message2".into(), "posted_at2".into()),
            ]
        });

        let result = super::list_tweets(&tweets).await;
        assert_eq!(result.tweets.len(), 2);
        let result0 = result.tweets.get(0).unwrap();
        assert_eq!(result0.name, "name1");
    }

    #[tokio::test]
    async fn test_list_tweets_empty() {
        let mut tweets = MockTweets::new();
        tweets.expect_list().returning(|| vec![]);

        let result = super::list_tweets(&tweets).await;
        assert_eq!(result.tweets.len(), 0);
    }
}

今回注目すべきは下記の行です。

mod tests {
    use crate::entities::Tweet;
    use crate::repositories::MockTweets;

    #[tokio::test]
    async fn test_list_tweets() {
        let mut tweets = MockTweets::new();
        tweets.expect_list().returning(|| {
            vec![
                Tweet::new("name1".into(), "message1".into(), "posted_at1".into()),
                Tweet::new("name2".into(), "message2".into(), "posted_at2".into()),
            ]
        });

        let result = super::list_tweets(&tweets).await;
        assert_eq!(result.tweets.len(), 2);
        let result0 = result.tweets.get(0).unwrap();
        assert_eq!(result0.name, "name1");
    }
}

MockTweets に実装されている expect_listrepositories::Tweets で定義されている list メソッドの挙動を変更することを宣言するメソッドです。もし find というメソッドが定義されていたとしたら、同様に expect_find というメソッドが MockTweets に用意されます。

returning は何を返却するかを定義するメソッドで、返したい値を返すクロージャーを渡します。クロージャーの引数に、呼び出されたときの引数が渡されます。

以上を踏まえると、上記のコードはテストダブルに対して、

  • list が呼ばれたら vec![(略)] を返しなさい

という挙動を登録していることになります。

このように、テストケースごとにテストダブルの挙動を柔軟に変更できることがテストダブルライブラリの大きな利点です。これにより、必要なテストケースの実装に専念することができます。

テストの実行

テストケースを実装したので、テストを実行します。テストにも cargo コマンドを利用します。

$ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.06s
     Running unittests (target/debug/deps/rustwi-3ed4691616c6fd6d)

running 2 tests
test services::tweets::tests::test_list_tweets_empty ... ok
test services::tweets::tests::test_list_tweets ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests (target/debug/deps/rustwi-488960a342ddc6c3)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rustwi

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

テストが実行され、先ほど実装した2つのテストがOKになっていることが確認できます。