Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

Rust の新しい HTTP サーバーのクレート Axum をフルに活用してサーバーサイドアプリケーション開発をしてみる

この記事は Rust Advent Calendar 25日目の記事です。Merry Christmas!

今年の Web バックエンド開発関連で一番大きかったなと思っているイベントに、Axum のリリースがあります。2021年の夏頃に tokio チームからリリースされた Web アプリケーション用のライブラリです。

基本的なデザインは actix-web 等とそこまで変わらないものの、マクロレスなのが大きな特徴かなと思います。tokio 上に直接載るアプリケーションになり、独自のランタイムをもたないため、tokio のバージョン管理に悩まされずに済むのも大きなメリットかも知れません。私はあまり重要ではないと思っていますが、明示的に #![forbid(unsafe_code)] をしているのでライブラリ内部に unsafe がないのも特徴かもしれません。

github.com

現在のんびり私が作っているアプリケーションを例に、Axum を使ってアプリケーションを開発する方法についていくつか解説をします。下記の順に説明します。

  • まず、扱うドメインが金融ということで少し特殊なため、ざっくりとどういったデータを扱うかについて解説します。
  • 次に、アプリケーションアーキテクチャの全体像を簡単に説明します。
  • さらに、いわゆるクリーンアーキテクチャ [*1] を取り入れて実装してみたので、少しだけコード側のアーキテクチャの話を書きます。
  • Axum の機能ならびに tokio の機能等をフル活用して、アプリケーションを作っていきます。

リポジトリです。

github.com

前提として、次のことを想定しています。

  • 最終的には AWS 上で動くアプリケーション。具体的には下記のサービスの利用を想定しています。
    • Aurora
    • DynamoDB
    • ECS

また、Axum のバージョンは 0.4.3 でした。multipart の機能を使用するのでオンにします。

axum = { version = "0.4.3", features = ["multipart"] }

なお、例のごとく記事が長いです。目次を掲載しておきます。ご興味のあるところをかいつまんでご覧いただいても楽しめるはずです。

ドメインの説明

今回扱う題材は株式市場とします。株式のマスターデータ(Stock)と、どのマーケットで取引される株式かを記すマスターデータ(MarketKind)を用意します。さらに、その株式の時価データ(MarketData)を取り込むことができるものとします。最終的には株式の単位で時価を閲覧できたり、あるいは移動平均ボラティリティ、ボリジャーバンドなどのいくつかのテクニカルな指標を算出します[*2]

株式のマスターデータ(Stock)には、ティッカーシンボルやその株式の名称などが記載されています。これはマスターデータで、他のデータと組み合わせてはじめて意義を発揮する類のものです。主に株式に関するメタ的な情報を保持する管理目的のテーブルです。

どのマーケットかを示すマスターデータ(MarketKind)は、その株式市場のコードと名称をもつことができるようになっています。マーケットの種類というのは、たとえば東京証券取引所大阪証券取引所などといったものが日本ではあげられると思いますが、それです。このデータは管理用のもので、最終的にはそのマーケット単位で自身がどれだけの株式を保持しているかを知るためのものです。

MarketKind を用意した実装上の意義としては、複数テーブル(リポジトリ)にまたがる実装を例示したいというものです。Webアプリケーションのサンプルコードでは1種類のテーブルを扱うものが多いですが、そうではなくnパターンを例示したいので2種類目のテーブルとしてあえて追加しているという事情があります。

時価データ(MarketData)は、ある株式の時価のデータです。日単位での取得を想定しています。たとえば、Yahooファイナンスで取得できる NIKKEI 225 の csv ファイルを想像してもらうとわかりやすいと思います。今回は始値終値、高値、安値あたりを保持して、さまざまなテクニカル指標の算出に利用します。

MarketData を用意した実装上の意義としては、ミドルウェアをまたぐ実装を例示したいというものです。このデータは DynamoDB に時系列データの形で保存することを想定しています。ただ、必ずしも DynamoDB でなければならないわけではなく、RDS でも構わないデータです。あくまで例示のための区分けです。

アプリケーションのアーキテクチャ

すでにいくつか説明済みですが、AWS の各サービスを使用してアプリケーションを組み上げることを最終的には目標としています。データの保存先には Aurora と DynamoDB 、アプリケーションを動かす基盤には ECS を想定しています。

レイヤードアーキテクチャ

今回は4層のアーキテクチャを使用しています[*3]。具体的には、driver、app、kernel、adapter の4つを採用しています。kernel と adapter は DIP (依存関係逆転の原則)が適用されています。DI は、非常に単純ないわゆるコンストラクタインジェクションを採用しています。DI コンテナなどは用いずに、シンプルにモジュールという箇所に手書きをしています。

各レイヤーの担当領域

各レイヤーの担当領域は次のように定義しています。

  • driver はおもにルーターとサーバーの起動を実装しています。このレイヤーでは Axum の機能を利用してエンドポイントとサーバーの起動までを実装します。内部的に行われた処理の結果、どのようなステータスコードを返すかをハンドリングしたり、JSONシリアライズ・デシリアライズも担当します。
  • app はいわゆるユースケースのレイヤーで、アプリケーションを動作させるために必要なロジックを記述しています。複数リポジトリをまたいでアプリケーションに必要なデータ構造を返すなどします。
  • kernel はいわゆるドメインのレイヤーで、アプリケーションのコアとなる実装を記述します。具体的にはドメインモデルの生成や、各種指標の算出ロジックの記述などを行います。
  • adapter はいわゆる外部サービスとの連携のレイヤーです。RDS との接続やクエリの発行、DynamoDB との接続や操作の実装を記述します。

上位のレイヤーからのみ下位のレイヤーを呼び出すように実装しており、逆は呼び出せないように作られています。つまり、driver は app を呼び出し、app は kernel を呼び出すようにしています。kernel と adapter は DIP されているので、kernel はトレイトのみが定義されており、実際の実装は adapter に記述されています。

Rust でレイヤードアーキテクチャをきれいに実装するには

cargo のワークスペースという機能を使用すると、少なくとも下位レイヤーから上位レイヤーを呼び出してしまうという経路の違反を防ぐことができます。もちろん常にワークスペースの使用が必要というわけではありませんが、この機能を使用すると差分のないワークスペースコンパイルを省略できたりといくつかメリットがあります。

詳しくは GitHub の実装をご覧いただくとよいかと思いますが、たとえば app レイヤーは次のように kernel と adapter への経路のみが定義されている関係で、app から driver を呼び出すことはできません。

[package]
name = "stock-metrics-app"
version = "0.1.0"
edition = "2021"

[dependencies]
stock-metrics-kernel = { path = "../stock-metrics-kernel" }
stock-metrics-adapter = { path  = "../stock-metrics-adapter" }
// 続く

Dependency Injection

コンストラクタインジェクションの採用

今回はシンプルなコンストラクタインジェクションを採用しています。構造体に依存を注入させたい構造体のフィールドを記述しておき、内部でそれを使う方式です。

ヘルスチェックの実装を例に取ると、ユースケースが内部にリポジトリをもっていることがわかります。 HealthCheckUseCase はヘルスチェックのためのユースケースであり、HealthCheckRepository はヘルスチェックというドメインで使用できるリポジトリです。

Arc は並行処理時も安全に実行できる参照カウンタです。Web アプリケーションではサーバー側で並行処理を行うことが多いため、参照カウンタ Rc ではなく、スレッドセーフな参照カウンタ Arc を用いる必要があります。

pub struct HealthCheckUseCase {
    repository: Arc<HealthCheckRepository>,
}

他の言語でいうコンストラクタのようなものを Rust で用意するには new という専用の関数を使用します。この new 関数が HealthCheckRepository を外部から受け取り、HealthCheckUseCase に渡して依存オブジェクトの注入を行います。

impl HealthCheckUseCase {
    pub fn new(repository: HealthCheckRepository) -> Self {
        Self {
            repository: Arc::new(repository),
        }
    }
// 続く
}

最後に、Repository を作成する処理を記述し、UseCase を作成する new 関数を呼び出すと UseCase の生成を行うことができます。HealthCheckRepository にも new 関数があり、同様に必要な依存をここで注入しています。

// db は RDS へのコネクションプール、dynamodb は DynamoDB へのクライアントをもつ
let health_check_use_case = HealthCheckUseCase::new(HealthCheckRepository::new(db, dynamodb));

モジュール

今回はシンプルにモジュール(Modules)という構造体を用意し、それを実質 DI コンテナとみなしています。DI コンテナを使用する際には専用のライブラリを使う手があるとは思いますが、今回作る程度の規模の小さなアプリケーションでしたら Modules にコツコツ依存の定義を書いていく方式でも悪くはないと思います。

Modules は先ほど紹介したコンストラクタインジェクションをシンプルに記述しただけのものです。そして後述しますが、これを Axum のレイヤーと呼ばれる概念を経由して呼び出すように作っています。Moduels を記事で紹介してしまうと大変なことになるので、下記にコードの例を示すにとどめます。

pub struct Modules {
    health_check_use_case: HealthCheckUseCase,
    stock_view_use_case: StockViewUseCase<RepositoriesModule>,
    stock_use_case: StockUseCase<RepositoriesModule>,
    market_kind_use_case: MarketKindUseCase<RepositoriesModule>,
    market_data_use_case: MarketDataUseCase<RepositoriesModule>,
}

pub trait ModulesExt {
    type RepositoriesModule: RepositoriesModuleExt;

    fn health_check_use_case(&self) -> &HealthCheckUseCase;
    fn stock_view_use_case(&self) -> &StockViewUseCase<Self::RepositoriesModule>;
    fn stock_use_case(&self) -> &StockUseCase<Self::RepositoriesModule>;
    fn market_kind_use_case(&self) -> &MarketKindUseCase<Self::RepositoriesModule>;
    fn market_data_use_case(&self) -> &MarketDataUseCase<Self::RepositoriesModule>;
}

impl ModulesExt for Modules {
    type RepositoriesModule = RepositoriesModule;

    fn health_check_use_case(&self) -> &HealthCheckUseCase {
        &self.health_check_use_case
    }

    fn stock_view_use_case(&self) -> &StockViewUseCase<Self::RepositoriesModule> {
        &self.stock_view_use_case
    }

    fn stock_use_case(&self) -> &StockUseCase<Self::RepositoriesModule> {
        &self.stock_use_case
    }

    fn market_kind_use_case(&self) -> &MarketKindUseCase<Self::RepositoriesModule> {
        &self.market_kind_use_case
    }

    fn market_data_use_case(&self) -> &MarketDataUseCase<Self::RepositoriesModule> {
        &self.market_data_use_case
    }
}

impl Modules {
    pub async fn new() -> Modules {
        let db = Db::new().await;
        let client = init_client().await;
        let dynamodb = DynamoDB::new(client);

        let repositories_module = Arc::new(RepositoriesModule::new(db.clone()));

        let health_check_use_case =
            HealthCheckUseCase::new(HealthCheckRepository::new(db, dynamodb));
        let stock_view_use_case = StockViewUseCase::new(repositories_module.clone());
        let stock_use_case = StockUseCase::new(repositories_module.clone());
        let market_kind_use_case = MarketKindUseCase::new(repositories_module.clone());
        let market_data_use_case = MarketDataUseCase::new(repositories_module.clone());

        Self {
            health_check_use_case,
            stock_view_use_case,
            stock_use_case,
            market_kind_use_case,
            market_data_use_case,
        }
    }
}

DIP (依存関係逆転の原則)

今回のコードでは kernel と adapter に対して DIP が適用されている関係で、kernel に Repository のトレイトのみが用意され、実際の実装は adapter で行われるという形になっています。app レイヤーで最終的に呼び出されるのは kernel のみになります。

DIP 適用のメリットは、仮にデータソースをまたいだ CRUD 操作をひとつのドメインに対して行いたい際に同じインターフェースで扱えることや、データソースの変更が行われたとしても、ドメインレイヤーやアプリケーションレイヤーの実装には影響が出ないといった保守運用のしやすさにつながる点です。

kernel では、Repository は次のようにインタフェースのみが用意されています。MarketKind の Repository を例に取ります。

#[async_trait]
pub trait MarketKindRepository {
    async fn find(&self, id: &Id<MarketKind>) -> anyhow::Result<Option<MarketKind>>;
    async fn insert(&self, source: NewMarketKind) -> anyhow::Result<Id<MarketKind>>;
}

実装は adapter に存在します。DatabaseRepositoryImpl という型は、各ドメインモデルに対する Repository の実装を意味します。

impl MarketKindRepository for DatabaseRepositoryImpl<MarketKind> {
    async fn find(&self, id: &Id<MarketKind>) -> anyhow::Result<Option<MarketKind>> {
        let pool = self.pool.0.clone();
        let market_kind = query_as::<_, MarketKindTable>("select * from market_kind where id = ?")
            .bind(id.value.to_string())
            .fetch_one(&*pool)
            .await
            .ok();
        match market_kind {
            Some(mk) => {
                let mk = mk.try_into()?;
                Ok(Some(mk))
            }
            None => Ok(None),
        }
    }
// 以下、インタフェースを満たすのに必要なだけ。
}

このアプリケーションでは最終的に app の UseCase で Repository は呼び出されますが、UseCase は kernel のインタフェースのみを知っていれば adapter の実装がどうであれ呼び出し可能です。依存関係の定義の関係で、DI コンテナとして使用している Modules は adapter に設置する必要はありますが、Repository に属する関数の呼び出し時に必要なのは kernel の情報のみであることがわかります。

use std::sync::Arc;

use derive_new::new;
use stock_metrics_adapter::modules::RepositoriesModuleExt;
use stock_metrics_kernel::repository::{market_kind::MarketKindRepository, stock::StockRepository};

use crate::model::stock_view::StockView;

#[derive(new)]
pub struct StockViewUseCase<R: RepositoriesModuleExt> {
    repositories: Arc<R>,
}

impl<R: RepositoriesModuleExt> StockViewUseCase<R> {
    pub async fn show_specific_stock(&self, id: String) -> anyhow::Result<Option<StockView>> {
        let stock = self
            .repositories
            .stock_repository()
            .find(id.try_into()?)
            .await?;
        match stock {
            Some(stock) => {
                let market_kind = self
                    .repositories
                    .market_kind_repository()
                    .find(&stock.market_kind)
                    .await?;
                Ok(market_kind.map(|market_kind| StockView::new(stock, market_kind)))
            }
            None => Ok(None),
        }
    }
}

採用した以外の方法

ひとつは Cake Pattern を用いた実装が考えられます。これは同様にトレイトを採用している Scala でよく知られた DI の手法です。ただ、ボイラープレートが増えて少し好みがわかれる方法ではあると思います。

また、DI 用のライブラリがあるといえばあるので、それを利用する方法も考えられます。が、私は Rust ではこうしたライブラリは使用したことがなく、どれがいいかなどの情報は持ち合わせていません。デファクトスタンダードのようなライブラリもないように見受けられます。

使用したクレート

主なクレートを少しだけ紹介します。

  • axum: これから紹介する予定の Web サーバーを実装するためのクレートです。
  • sqlx: 今回 MySQL との接続を行いますが、そのために使用したクレートです。
  • aws-sdk-rust: DynamoDB 関連の接続のための使用したクレートです。

その他のクレートは、リポジトリ内の Cargo.toml をご覧ください。

Axum をフル活用してアプリケーションを実装する

今回実装したアプリケーションがどのような構造をもつアプリケーションだったかを簡単にご紹介しました。株式の管理に関するもので、4層のレイヤードアーキテクチャをしたアプリケーションでした。

ここから、Axum のもつ機能や tokio が提供する機能を使用して、本番使用しても問題ないようなアプリケーションを実装できるのかを見ていきます。

ルーティングの基本的な定義方法

単純にパスを指定するとステータスコードを返すだけのエンドポイントから、REST でよく行われるパスに ID を含むリクエストを送るものや、JSON のやりとりをするものといった代表的なパターンを簡単にご紹介します。

Router

まず、Axum ではエンドポイントの管理を Router という構造体が担っています。この構造体はいわゆるビルダーパターンのようになっていて、route という関数を経由してエンドポイント情報を登録させます。

Axum にはルーターの他に、ハンドラ(Handler)という概念があります。詳しい実装については後述しますが、このハンドラを各 HTTP メソッドを示す関数に渡すことで、裏でエンドポイントが Router に登録されます。これを繰り返してエンドポイントを登録していき、サーバーが所定のパスへのアクセスに対してレスポンスを返すことができるようになります。

最終的にこの Router は、サービス(Service)に直されサーバーの起動時に使用されます。

use crate::{
    module::Modules,
    routes::{
        health::{hc, hc_db, hc_dynamo},
        market_data::upload_market_data,
        market_kind::create_market_kind,
        stock_view::{create_stock, stock_view},
    },
};
use axum::{
    handler::{get, post},
    AddExtensionLayer, Router,
};
use dotenv::dotenv;
use std::{net::SocketAddr, sync::Arc};

pub async fn startup(modules: Arc<Modules>) {
    let hc_router = Router::new()
        // `get` 関数にハンドラを入れ、GET 用のエンドポイントを作る。
        // `hc` が「ハンドラ」にあたる。`hc` の中身については後述する。
        // POST の場合は `post` など、各リクエストメソッドに対応した関数がある。
        .route("/", get(hc))
        .route("/db", get(hc_db))
        .route("/dynamo", get(hc_dynamo));
    let stocks_router = Router::new()
        .route("/", post(create_stock))
        .route("/:id", get(stock_view));
    let market_kind_router = Router::new().route("/", post(create_market_kind));
    let market_data_router = Router::new().route("/:stock_id", post(upload_market_data));

    let app = Router::new()
        .nest("/hc", hc_router)
        .nest("/stocks", stocks_router)
        .nest("/market_kind", market_kind_router)
        .nest("/market_data", market_data_router)
        .layer(AddExtensionLayer::new(modules));

    let addr = SocketAddr::from(([127, 0, 0, 1], 8080));

    tracing::info!("Server listening on {}", addr);

    axum::Server::bind(&addr)
        // 最後ルーターはサービスに直されてサーバーの起動時に使われる
        .serve(app.into_make_service())
        .await
        .unwrap_or_else(|_| panic!("Server cannot launch!"))
}

単純なルーティング

もっとも単純なルーティングとして、/hc にアクセスすると成功のステータスコードを返すというアプリケーションのヘルスチェックのためのハンドラを用意しています。それは、次のように記述できます。

pub async fn hc() -> impl IntoResponse {
    // tracing::debug! マクロについては後ほど説明するが、tracing の機能。
    tracing::debug!("Access health check endpoint from user!");
    StatusCode::NO_CONTENT
}

impl IntoResponse についてですが、IntoResponse はトレイトで、このトレイトを実装した型であれば返すことができます。つまり、StatusCode には IntoResponse が実装されています。IntoResponse トレイトを実装すると、自分のカスタムレスポンスを返すことができるようになります。

この記事では、StatusCode とレスポンスボディが含まれる場合はそのレスポンスボディのタプルを返しているケースが多いですが、struct Response のような独自型を定義して、タプルを使用せず別の方法をとることもできると思います。その際、Response 型に IntoResponse の実装を記述しておけば問題なく取り扱いできるはずです。

パスに ID のような変数を含むもの

たとえば /stock/:id というエンドポイントを作りたいときです。:id の部分に ID を入れるとそれをルーティング時にパースして ID として別個認識し、取り出せるというものです。他のクレートでもある機能は Axum にも同様にあります。

Path という構造体を用いるとパスの取り出しを実装できます。なぜこの記述だけで取り出せるかについては、のちの「JSON を含むリクエストの受け取りを含むもの」でも説明しますが FromRequest というトレイトを実装しておりそれが裏で絡んでいるからです。

Extension については後述しますが、これは依存を注入したり、アプリケーション全体で保持させたい状態をハンドラ間(ルーター間)で引き回す際に使用できるものです。今回私が作ったアプリケーションでは、ほぼすべてのハンドラに登場し、ここからユースケースの取り出し等を行っています。

// ルーターの定義方法は次の通り。
// `/stocks/1` とアクセスすると、`:id` に `1` が入る。
let stocks_router = Router::new()
        // 間にいろいろ
    .route("/stocks/:id", get(stock_view));
pub async fn stock_view(
    Path(id): Path<String>,
    Extension(modules): Extension<Arc<Modules>>,
) -> Result<impl IntoResponse, StatusCode> {
    let res = modules.stock_view_use_case().show_specific_stock(id).await;
    match res {
        Ok(sv) => sv
            .map(|sv| {
                let json: JsonStockView = sv.into();
                (StatusCode::OK, Json(json))
            })
            .ok_or_else(|| StatusCode::NOT_FOUND),
        Err(err) => {
            error!("Unexpected error: {:?}", err);
            Err(StatusCode::INTERNAL_SERVER_ERROR)
        }
    }
}

JSON を含むリクエストの受け取りを含むもの

JSON を含むリクエストとしてありえるのは、たとえば POST メソッドを使って新規リソースを作成する際、JSON で登録内容を送信する方法です。サーバーで JSON を解析し、その内容を元に各ストレージレイヤーにアクセスしてデータを保存するというのは、Web アプリケーションでは一般的な方法かと思われます。

Axum は Serde クレートの Deserialize が実装された構造体等であれば、裏で自動でパースします。もっともシンプルな記述は下記のようにできます。単に関数の引数にデシリアライズしたい構造体をもたせておくと、Axum が裏で自動で構造体に値を詰めます。

// 別ファイルに記述されている想定。
#[derive(Deserialize, Debug)]
pub struct JsonCreateStock {
    name: String,
    ticker_symbol: String,
    market_kind: String,
}

// 引数に JsonCreateStock を入れておくと、裏で JSON パースしてくれる
pub async fn create_stock(
    source: JsonCreateStock,
    Extension(modules): Extension<Arc<Modules>>,
) -> Result<impl IntoResponse, StatusCode> {
    let res = modules.stock_use_case().register_stock(source.into()).await;
    res.map(|_| StatusCode::CREATED).map_err(|err| {
        error!("Unexpected error: {:?}", err);
        StatusCode::INTERNAL_SERVER_ERROR
    })
}

JSON の各フィールドの値を受け取りのタイミングでバリデーションチェックすることができます。Axum のリクエストは FromRequest というトレイトでいろいろ処理を追加すると、自身でカスタマイズすることができるようになっています。下記は validator というクレートと組み合わせて、フィールドの値のバリデーションチェックを追加してみた例です。

// Validate を derive する。
#[derive(Deserialize, Debug, Validate)]
pub struct JsonCreateStock {
    // validator クレートが提供するマクロ。
    // 詳しくは validator というクレートを参照。
    #[validate(length(min = 1, max = 255))]
    name: String,
    #[validate(length(min = 1, max = 255))]
    ticker_symbol: String,
    market_kind: String,
}
use thiserror::Error;

// 今回使用するためのエラー用の型。
#[derive(Debug, Error)]
pub enum AppError {
    #[error(transparent)]
    Validation(#[from] validator::ValidationErrors),
    #[error(transparent)]
    JsonRejection(#[from] axum::extract::rejection::JsonRejection),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            AppError::Validation(_) => {
                let msg = format!("Input validation error: [{}]", self).replace('\n', ", ");
                (StatusCode::BAD_REQUEST, msg)
            }
            AppError::JsonRejection(_) => (StatusCode::BAD_REQUEST, self.to_string()),
        }
        .into_response()
    }
}

// FromRequest は、リクエストから特定の型への取り出し処理を実装することができる。
// これを使って、カスタマイズしたリクエスト用の型を定義できたりする。

#[async_trait]
impl<T, B> FromRequest<B> for ValidatedRequest<T>
where
    T: DeserializeOwned + Validate,
    B: http_body::Body + Send,
    B::Data: Send,
    B::Error: Into<BoxError>,
{
    type Rejection = AppError;

    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req).await?;
        value.validate()?;
        Ok(ValidatedRequest(value))
    }
}

これらをまとめると、先ほどの create_stock 関数は次のようにアップデートされます。リクエストが送られてきた際にバリデーションチェックが先に走り、不正なリクエストは 400 で返します。JSON のフィールドに対するバリデーションチェックは、ルーターの内部でやりたい(たとえばドメインレイヤーなど)ケースも多いとは思うので常に使ったらよいというわけではありませんが、考え方のひとつとして持っておくと実装の幅が広がりそうです。

pub async fn create_stock(
    ValidatedRequest(source): ValidatedRequest<JsonCreateStock>,
    Extension(modules): Extension<Arc<Modules>>,
) -> Result<impl IntoResponse, StatusCode> {
    let res = modules.stock_use_case().register_stock(source.into()).await;
    res.map(|_| StatusCode::CREATED).map_err(|err| {
        error!("Unexpected error: {:?}", err);
        StatusCode::INTERNAL_SERVER_ERROR
    })
}

JSON を HTTP レスポンスボディに含むレスポンスを返すもの

JSON をレスポンスボディに含むレスポンスを返すには、Json という型に包んで返します。この型に包むことができる型は、Serde の Serialize を derive したもののみです。

下記は少し実装を隠蔽してしまっているのでわかりにくいかもしれませんが、JSON を返すハンドラの例です。JsonStockView という型は Serialize を derive しており Json に包むことが可能です。

#[derive(Serialize)]
pub struct JsonStockView {
    id: String,
    name: String,
    ticker_symbol: String,
    market_kind: String,
}

pub async fn stock_view(
    Path(id): Path<String>,
    Extension(modules): Extension<Arc<Modules>>,
) -> Result<impl IntoResponse, StatusCode> {
    let res = modules.stock_view_use_case().show_specific_stock(id).await;
    match res {
        Ok(sv) => sv
            .map(|sv| {
                let json: JsonStockView = sv.into();
                (StatusCode::OK, Json(json))
            })
            .ok_or_else(|| StatusCode::NOT_FOUND),
        Err(err) => {
            error!("Unexpected error: {:?}", err);
            Err(StatusCode::INTERNAL_SERVER_ERROR)
        }
    }
}

DI の扱い方

Axum にはレイヤーという機能があり、これを使うことで DI を実現できます。先ほどから登場していますが Extension という型がそれにあたります。Extension はハンドラ間で状態の共有をするために使われる機能ですが、これを用いると DI を管理できます。

今回私の作ったアプリケーションでは、Modules という構造体で実質的に DI コンテナの役割を果たすように作っています。なので、この ModulesExtension を経由して各ハンドラに配布しておけば、そこから自由に必要なモジュールの呼び出しをできるようになっています。

各ハンドラでは先ほどからも登場している通り、次のように Extension の引数での定義と呼び出しができます。

pub async fn hc_db(
    Extension(module): Extension<Arc<Modules>>,
) -> Result<impl IntoResponse, StatusCode> {
    module
        .health_check_use_case()
        .diagnose_db_conn()
        .await
        .map(|_| StatusCode::NO_CONTENT)
        .map_err(|err| {
            error!("{:?}", err);
            StatusCode::SERVICE_UNAVAILABLE
        })
}

Extension に渡される Arc<Modules> はどのタイミングで渡されているかと言うと、ルーターです。Routerlayer という関数が用意されており、そこに渡すとあとは裏側で自動で各ハンドラに配ってくれるようです。

// Modules は起動の瞬間に new して、startup 関数に渡す
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    init_app();

    let modules = Modules::new().await;
    let _ = startup(Arc::new(modules)).await;

    Ok(())
}

pub async fn startup(modules: Arc<Modules>) {
    let hc_router = Router::new()
        .route("/", get(hc))
        .route("/db", get(hc_db))
        .route("/dynamo", get(hc_dynamo));
    let stocks_router = Router::new()
        .route("/", post(create_stock))
        .route("/:id", get(stock_view));
    let market_kind_router = Router::new().route("/", post(create_market_kind));
    let market_data_router = Router::new().route("/:stock_id", post(upload_market_data));

    let app = Router::new()
        .nest("/hc", hc_router)
        .nest("/stocks", stocks_router)
        .nest("/market_kind", market_kind_router)
        .nest("/market_data", market_data_router)
        // ↓ここで Modules を入れ込む。
        .layer(AddExtensionLayer::new(modules));

    let addr = SocketAddr::from(([127, 0, 0, 1], 8080));

    tracing::info!("Server listening on {}", addr);

    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap_or_else(|_| panic!("Server cannot launch!"))
}

multipart/formdata の扱い方

multipart も扱えます。扱う際には、Axum の multipart 用の機能をオンにする必要があります。Cargo.toml の Axum の依存を次のように変えます。

axum = { version = "0.4.3", features = ["multipart"] }

csv ファイル形式の時価データをアップロードする機能の実装です。この部分では csv というクレートを使用して csv ファイルの読み込みを行い、中身をアプリケーション内部で扱うことができる構造体の形に詰め直しています。csv ファイルのサイズの上限はとくに考えていなかったため、一旦 100MB としています[*4]

pub async fn upload_market_data(
    Path(stock_id): Path<String>,
    ContentLengthLimit(mut multipart): ContentLengthLimit<Multipart, { 100 * 1024 * 1024 }>,
    Extension(modules): Extension<Arc<Modules>>,
) -> Result<impl IntoResponse, StatusCode> {
    while let Some(field) = multipart.next_field().await.unwrap() {
        let bytes = field.bytes().await.unwrap();
        let bytes = String::from_utf8(bytes.to_vec()).unwrap();

        let mut rdr = ReaderBuilder::new()
            .has_headers(true)
            .from_reader(bytes.as_bytes());

        let mut market_data_list = Vec::new();
        for result in rdr.deserialize() {
            let market_data: CsvMarketData = result.unwrap();
            market_data_list.push(market_data);
        }

        let market_data_list: Vec<MarketData> = market_data_list
            .into_iter()
            .filter_map(|csv| csv.to_market_data(&stock_id).ok())
            .collect();

        let _ = modules
            .market_data_use_case()
            .register_market_data(market_data_list)
            .map_err(|err| {
                error!("Unexpected error: {:?}", err);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;
    }
    Ok(StatusCode::CREATED)
}

tracing を使ってトレーシングをする

アプリケーションは作って終了というわけではなく、運用する必要があります。運用には監視が必要なわけですが、そのためにはよいロギングができるとよいです。どこのハンドラの処理で吐き出されたログかがわかるととてもよいですよね。

tracing を使用すると、プロセス内部の処理を追いかけることができるようになります。Zipkin や Jaeger などが類似のライブラリとしては代表的でしょうか。tokio が提供するトレーシング用のクレートで、tokio を使用したアプリケーションを作っているのであれば、入れておいて損はないと思います。詳しい実装方法などについてはこの記事がよいと思います。

Axum は tokio チームが作っているというのもあるからか、tokio の周辺ツールと非常にシームレスに連携できます。ドキュメントのとおりに tracing に関するマクロを追加するだけで実装できます。tracing そのものは actix-web などのクレートにも対応してはいます。

もっともシンプルな例ですが、ヘルスチェックに次のようなデバッグログを仕込んでみます。

pub async fn hc() -> impl IntoResponse {
    tracing::debug!("Access health check endpoint from user!");
    StatusCode::NO_CONTENT
}

これをサーバーで動かすと、ログの結果として次のように出力できます。

2021-12-25T15:08:39.484032Z DEBUG stock_metrics_driver::routes::health: Access health check endpoint from user!

さらに、トレーシングの文脈でいうスパン(span)のような機能もあります。スパンは #[tracing::instrument] というマクロを関数に追加すると、裏で実行できるようになっています。例として、MarketKind を作成するハンドラに追加して、様子を見てみます。

// skip(module) は、関数の引数のうちロギング対象としたくない引数を定義しておくと、ロギングしなくなる。
#[tracing::instrument(skip(modules))]
pub async fn create_market_kind(
    ValidatedRequest(source): ValidatedRequest<JsonCreateMarketKind>,
    Extension(modules): Extension<Arc<Modules>>,
) -> Result<impl IntoResponse, StatusCode> {
    let res = modules
        .market_kind_use_case()
        .register_market_kind(source.into())
        .await;
    res.map(|id| (StatusCode::CREATED, id)).map_err(|err| {
        error!("Unexpected error: {:?}", err);
        StatusCode::INTERNAL_SERVER_ERROR
    })
}

このエンドポイントに、次のような JSON 定義をもつ HTTP リクエストを送信してみます。

{
    "name": "東京証券取引所",
    "code": "TSE"
}

sqlx に関する INFO ログが出力されていますが、その情報の中に先ほど関数の引数にあった JsonCreateMarketKind の情報が追加されています。今回はかなり雑にログを吐き出させてしまっているのでわかりにくいかもしれませんが、ログの情報を何段階か追加することで、処理がどのように行われていっているかをトレーシングの結果から追いかけやすくなるはずです。

...
2021-12-25T15:13:24.934399Z DEBUG hyper::proto::h1::conn: incoming body completed
2021-12-25T15:13:25.150209Z  INFO create_market_kind{source=JsonCreateMarketKind { code: "TSE", name: "東京証券取引所" }}: sqlx::query: insert into market_kind (id, …; rows: 0, elapsed: 208.464ms

insert into
  market_kind (id, code, name, created_at, updated_at)
values
  (?, ?, ?, ?, ?)
  
2021-12-25T15:13:25.150468Z DEBUG hyper::proto::h1::io: flushed 133 bytes
...

トレーシングそのものは、マイクロサービス化に応じて経路が複雑化し、監視が難しくなりつつある現代のアプリケーションでは必須の機能だと思っています。とくに本番運用するとなると、この機能を楽に使用できるかどうかはアプリケーションエンジニアにとっては重要な話題です。tracing は思った以上にシームレスに Axum と連携でき、運用面でも費用対効果のよいアプリケーションを実装しやすくなっていると感じました。

Tips

DTO (Data Transfer Object) の詰め替えには From/TryFrom を実装

レイヤードアーキテクチャにすると、レイヤー間でオブジェクトの詰替えのような作業を行うことがあります。これはたとえば下位レイヤーが上位レイヤーに依存しないという規則を守らせる際にとくに重要で、上位レイヤーのうちに下位レイヤーのデータ構造に直してから引数に渡すといった作業が必要になります。今回作ったアプリケーションでも、app レイヤーから kernel レイヤーへのデータの変換などが行われています。

その際に便利なのが Rust の標準ライブラリに入っている FromTryFrom といったトレイトです。これを構造体へ追加で実装しておくだけで、from()into() といった関数を呼び出して DTO の変換をかけられるようになります。

下記は、MarketKind を新規作成する際に app レイヤー用のデータから kernel レイヤー用のデータへ変換する作業を行っている例です。

#[derive(new)]
pub struct CreateMarketKind {
    pub code: i32,
    pub name: String,
}

impl TryFrom<CreateMarketKind> for NewMarketKind {
    type Error = anyhow::Error;
    fn try_from(c: CreateMarketKind) -> anyhow::Result<Self> {
        let market_kind_id = Id::gen();
        Ok(NewMarketKind::new(
            market_kind_id,
            MarketCode(c.code),
            MarketName(c.name),
        ))
    }
}

呼び出し自体は次のようにして済ませられます。try_into() 関数を呼び出すだけです。

impl<R: RepositoriesModuleExt> MarketKindUseCase<R> {
    pub async fn register_market_kind(&self, source: CreateMarketKind) -> anyhow::Result<String> {
        self.repositories
            .market_kind_repository()
            .insert(source.try_into()?)
            .await
            .map(|id| id.value.to_string())
    }
}

各関数の Result 型は anyhow::Result にしておき、エラーの定義には thiserror を使う

今回書いたコードの至るところに登場しているイディオムですが、各関数の Result 型は anyhow::Result にしておき、独自にエラー型を定義した際には thiserror で拡張するという方法ととっています。

今回は独自のエラー型をビジネスロジックごとに正しく定義するという手間を省くために anyhow! マクロを使ってエラー型を作成しています。

しかし、本来エラー型は、エラーになる異常処理のケース単位で型を用意しておくことが多いかと思います。その際には thiserror でエラーメッセージを付与したり、サードパーティライブラリのエラー型を吸収したりします。

ID 用の型を節約する

エンティティに使用される ID 型は、エンティティの数が増えるにつれてだんだん構造体の定義型自体も増えていく傾向にあります。たとえば、下記のように Stock に使用したい ID 型を用意すると次のように StockId などという構造体を用意するかもしれません。ここにさらに MarketData に使用する ID 型を増やすとなると、MarketDataId のような構造体を追加する必要が出てきます。

pub struct StockId(pub Ulid);

都度構造体を追加するのは少し手間かもしれません。

そこで、PhantomData を活用したイディオムがあります。T には Id を使用する元のエンティティ名が入ります。つまり、Stock というエンティティに使用するなら Id<Stock> のような型になります。

#[derive(new, Debug, Clone)]
pub struct Id<T> {
    pub value: Ulid,
    _marker: PhantomData<T>,
}

すべてのパターンの ID 型で共通する実装は、T 型に対する実装を用意しておけばよいです。

impl<T> Id<T> {
    pub fn gen() -> Id<T> {
        Id::new(Ulid::new())
    }
}

個別のエンティティに対する実装を生やしたくなったら、エンティティの型をはめた実装を用意すればよいです。

impl Id<Stock> {
    pub fn id(&self) -> String {
        self.value.to_string()
    }
}

あるいは、マクロを利用して構造体を自動生成させてしまう手も考えられます。今回は実装はしていませんが、好みに応じて使い分けられるかもしれません。

トレイトを使用した DI に使用される型引数を削る

Rust でトレイトを使用した DI では、動的ディスパッチを使用する方法と静的ディスパッチを使用する方法の2つがあると思います。

  • 動的ディスパッチ: Box<dyn TraitName>Arc<dyn TraitName> など。
  • 静的ディスパッチ: 型引数にトレイト側の名前を入れておく。あるいは、関連型を使用する。

たとえば動的ディスパッチの場合は下記のように、

struct SomeUseCase {
    repo_a: Arc<dyn RepositoryA>,
    repo_b: Arc<dyn RepositoryB>,
}

静的ディスパッチの場合は下記のようにといった具合です。

struct SomeUseCase<A: RepositoryA, B: RepositoryB> {
    repo_a: A,
    repo_b: B,
}

動的ディスパッチの場合は、型が複雑になりすぎずに済み型の解決が比較的楽になる傾向にあると思います。最近だと Wasmer という OSS の実装で、動的ディスパッチをあえて積極的に使っているようなコードをみた記憶があります。

一方で、動的ディスパッチには静的ディスパッチと比較するとオーバーヘッドが発生したり、Web アプリケーションの場合は、Axum がそうだと思うのですが、動的ディスパッチ用のトレイト実装がいくつか生えておらず、そもそも対応していないのでコンパイルエラーといったケースがあるように見受けられます。

静的ディスパッチの場合は速度面は問題ないですし、他の Web アプリケーション構築用のクレートが対応しているように見えるので、Rust では最もオーソドックスな手法かと思われます。

一方で、先ほど示した UseCase に対する DI の例からもわかるように、依存するフィールドが増えるとその分型引数名が増えるというデメリットがあると思います。関連型を使用する手はひとつ、型引数の数を減らすのに有力な方法ではあると思うのですが、その分余分なトレイトを1枚噛ませる必要が出てくるなど、手間が増えそうです。

静的ディスパッチしつつ型引数を増やさないためには、リポジトリの依存をまとめたモジュール(ModulesDependencies みたいな名前)を作って、それを経由して呼び出すという方法がひとつ考えられます。下記のような実装を用意しておき、それを UseCase の型引数にひとつ与えるだけでよくなります。

pub struct RepositoriesModule {
    stock_repository: DatabaseRepositoryImpl<Stock>,
    market_kind_repository: DatabaseRepositoryImpl<MarketKind>,
}

pub trait RepositoriesModuleExt {
    type StockRepo: StockRepository;
    type MarketKindRepo: MarketKindRepository;
    fn stock_repository(&self) -> &Self::StockRepo;
    fn market_kind_repository(&self) -> &Self::MarketKindRepo;
}

impl RepositoriesModuleExt for RepositoriesModule {
    type StockRepo = DatabaseRepositoryImpl<Stock>;
    type MarketKindRepo = DatabaseRepositoryImpl<MarketKind>;

    fn stock_repository(&self) -> &Self::StockRepo {
        &self.stock_repository
    }

    fn market_kind_repository(&self) -> &Self::MarketKindRepo {
        &self.market_kind_repository
    }
}

この処理は StockRepositoryMarketKindReposiory の2つのリポジトリを呼び出す必要のあるコードですが、型引数は1つで済むようになりました。

pub struct StockViewUseCase<R: RepositoriesModuleExt> {
    repositories: Arc<R>,
}

impl<R: RepositoriesModuleExt> StockViewUseCase<R> {
    pub async fn show_specific_stock(&self, id: String) -> anyhow::Result<Option<StockView>> {
        let stock = self
            .repositories
            .stock_repository()
            .find(id.try_into()?)
            .await?;
        match stock {
            Some(stock) => {
                let market_kind = self
                    .repositories
                    .market_kind_repository()
                    .find(&stock.market_kind)
                    .await?;
                Ok(market_kind.map(|market_kind| StockView::new(stock, market_kind)))
            }
            None => Ok(None),
        }
    }
}

デメリットとしては、モジュールに大きくリポジトリの依存をまとめてしまうと、そのモジュールで管理するリポジトリの量が増えたときに大変です。また、Stock から Bondリポジトリは呼び出せしまってはいけないのだが、なぜか呼び出せてしまうミスなどが発生してしまうことは考えられます。その際は、適切な量に再度モジュールを切り直す必要があるでしょう。がいずれにせよ、リポジトリひとつひとつの型引数を UseCase に渡し続けるよりかは、かなり状況はよくなると思います。

for ループやパターンマッチングより filter や map などのアダプタを使う

私は下記のようにパターンマッチングでもよいケースや for ループでもよいケースであっても、可能な限りアダプタ(mapmap_err を Rust ではそう呼びます)を採用するようにしています。これを用いると、変数のスコープを狭めることができるようになり、 Rust 特有の所有権やライフタイム問題を起こさずコーディングしやすくなるかなと体感的に思っています。

pub async fn delete_market_kind(
    Path(id): Path<String>,
    Extension(modules): Extension<Arc<Modules>>,
) -> Result<impl IntoResponse, StatusCode> {
    modules
        .market_kind_use_case()
        .delete_market_kind(id)
        .await
        .map(|_| StatusCode::NO_CONTENT)
        .map_err(|err| {
            error!("Unexpected error: {:?}", err);
            StatusCode::INTERNAL_SERVER_ERROR
        })
}

また、Rust 特有の事情として、for ループは所有権とライフタイムがループ1回ごとにチェックされるので、他の言語ではコンパイルが通るケースも通らなくなることがある、という難しさがあると思っています。1回ループを回すとその時点で内部で確保した変数の所有権はすべて解放されてしまうので、追加で clone が必要になったり、必要以上に Rc する必要が出てきたりとだんだんコードが難しくなります。が、アダプタを使用した際にはこのような問題は起こりにくくなります。

アダプタの使いこなしは慣れが必要ですが、宣言的にコードを記述できるようになるためコードの見通しがよくなると思います。また、アダプタはコンパイラ側の最適化が走りやすく、ゼロコスト抽象化の恩恵をより受けられやすくなるなどのメリットもあるようです。

まとめ

普段 Web アプリケーションを作っている者から見ても、Axum ならびに Rust の Web のエコシステムがどんどん充実してきていることが改めてわかりました。ただ一方で、「他の言語だったらこれあるのにな」と感じたものはまだまだなかったり、突然クレートのバージョンを上げるとコンパイルエラーが出始めるケースなど、枯れているとは言い切れないのも事実だと思います。

また、今回は記事では紹介しなかったのですが、Axum 使用中にもハマりポイントが何箇所かありました。が、まだクレートそのものが出たてで Axum の使用例が少なく、「このワークアラウンドで正しいのだろうか」と思う場面も多々ありました。この記事は、そうしたワークアラウンドの紹介のためのものでもあります。

実装そのものはまだ未完成の段階で、一旦未完成ながらも紹介可能な箇所を紹介しました。examples ディレクトリの充実やテストの充実なども今後必要だと思います。

記事が長くなりすぎてしまったので、Zenn で本を書いて出そうかなと思い始めました。

みなさまよいお年を!

*1:レイヤードアーキテクチャといったほうが正しいのかもしれません。ドライバ、アプリ、カーネル、アダプタの4層のレイヤーをもつアプリケーションを実装しています。

*2:ただし、執筆時点ではテクニカル指標の算出はまだ実装できていません。

*3:処理内容は複雑ではないものの4層にしてしまったので、単に面倒臭さを増しただけと思っていますが、今回は試してみたかったからということで。

*4:この部分は同期的にやってしまうとレスポンスが返るまでクライアントを止めてしまう可能性があるので、キューに詰めてタスク管理させて別のサーバーで処理させるなどしたいなとは思っています。

はてなブロガーに10の質問に答える

はてなブログ10周年特別お題「はてなブロガーに10の質問

ブログ名もしくはハンドルネームの由来は?

Don't Repeat Yourself はプログラマの間では言わずと知れた DRY の原則というから得ています。タイトルの下にどういう意味かは書いてあります。ブログ名の由来にあまり深い意味はなく、とくにブログタイトルにつけたいものがなかったので、適当に思いついた言葉を載せておきました。

はてなブログを始めたきっかけは?

見聞きした話を、改めて自分なりにまとめ直して理解するため。

自分で書いたお気に入りの1記事はある?あるならどんな記事?

理解するのに一番時間がかかった&調べごとをするのも含めて書くのに1ヶ月近くかかったという点でこれです。今思うと、ブロッキングI/OだったときとノンブロッキングI/Oだったときのパフォーマンスの比較や、どうやって調べるか、Linux だとどういうシステムコールが呼ばれているかを ptrace してみるかもしれません。

blog-dry.com

ブログを書きたくなるのはどんなとき?

新しい技術に触ったとき、触るだけじゃなく体系的に理解したいと思ったときです。私の記事は基本的に他の人が読むことをほぼ想定していなくて、自分の知識を体系的にまとめ直したいがために書かれています。体系だって理解することはとても重要だからです。

体系的に理解するときには、どうしても記事が長くなりがちです。1記事はそれでも短くて1時間程度で書かれていますが、記事によっては調べごとをして1ヶ月程度かかっているものもあります。あれだけ長い記事ではありますが、実は大半の記事はパラグラフライティングしているので、パラグラフの冒頭の一文だけ読んで読み飛ばしができるようには作られています。

下書きに保存された記事は何記事? あるならどんなテーマの記事?

20記事。溜め込んでいるのはたとえば、共著した本に載せようと思っていたのですが、「初心者には難しすぎる。もうちょっとキャッチーなものに」という理由でボツになった原稿。

f:id:yuk1tyd:20211125095027p:plain
実践Rustプログラミング入門に当初書かれていた幻の原稿

自分の記事を読み返すことはある?

ツールの使い方に関する記事は読み返したり仕事でシェアしたりしますが、それ以外を読み返すことはありません。

好きなはてなブロガーは?

チェコ好きの日記さん

aniram-czech.hatenablog.com

基本読書さん

huyukiitoichi.hatenadiary.jp

はてなブログに一言メッセージを伝えるなら?

GitHub との連携があると嬉しいです。

ついでですが、はてなブログに関する話ではありませんが、はてなブックマークのコメント欄にはモデレータが必要だと思います。

10年前は何してた?

大学生してました。政治哲学のゼミの輪読用の哲学書を毎週読まなくちゃいけなくて、わりとひいひい言っていた時期だと思います。

この10年を一言でまとめると?

ブログに限った話であれば、ソフトウェアエンジニアにキャリアチェンジしたくらいから始めているので、私のキャリアの歩みがそのまま書かれているものになっています。

『詳解Rustプログラミング』(Rust in Action)を読みました

先日発売になった『詳解Rustプログラミング』という本をひとまず一通り軽く読んでみました。実は原著の Rust in Action をすでに読んでしまっていたので、内容の流れは把握していたのですが、私は一応日本語ネイティブなので日本語の書籍は非常に嬉しいですね。

Rust in Action

Rust in Action

Amazon

本書をまず読んで最初に思い出したのは、私も大好きな『低レベルプログラミング』という本でした。この本は C とアセンブラで書かれているのですが、これを Rust でやり直す感覚を覚えました。コンピュータサイエンスやコンピュータアーキテクチャの話題が豊富で、大学のコンピュータサイエンスの講義を受けているような印象を持ちました。

あるいは、『Go ならわかるシステムプログラミング』の Rust 版と言ってもよいかもしれません。

従来はこの分野の入門書は C あるいは C++ で書かれていることが多かったと思いますが、ついに Rust による書籍が出たと、原著を読んだ当初に思いました。最近はこうした CS の基礎の本は Python で出ることも増えたように思いますが、Rust での解説はこれから市民権を得ていくでしょうか。もしそうなるとすると、自分が一番馴染みがある言語で学べることになりとてもワクワクします。

本書が一番特徴的なのはやはり、幅広い分野にまたがる適切な題材が豊富に用意されているという点だと思います。4章ではまずまずの規模の状態の書き換えが発生する人工衛星と地上の通信を模したアプリケーションを作ります。5章では関数呼び出しくらいまでを行える CPU エミュレータを実装しますし、6章ではメモリの動きを見るためのグラフィカルアプリケーションを実装します。7章では実用的なキーバリューストア、8章では OSI 参照モデルを徐々に掘っていくようなアプリケーションを実装します。最後は簡単な OS までを実装します*1。著者の知見と知識の幅広さ、そして実装力がすごいです。

Rust の文法や機能の解説書としての側面からですと、他の言語から Rust に入門するユーザー*2がつまづきやすいポイントを丁寧に押さえています。本書はまず2、3、4章で軽く Rust の基本的な文法について説明したあと、5章以降でさらに Rust の踏み込んだ機能について解説されます。この踏み込んだ機能の解説が白眉でした。Copy トレイトや Clone の説明、スマートポインタの説明やトレイトオブジェクトの説明は、私が以前共著で書いた本では載せなかった記憶がありますが、本書は適切な題材とともにそれらをよく解説しています。

本書で取り扱っていない話題があるとすれば、Rust アプリケーションの本番運用にまつわるものでしょうか。たとえばパフォーマンスチューニングを Rust ではどのように行っていくかという話題や、Rust アプリケーションの CI/CD に関する現場に近い実務的な話題は載っていません。また、マクロやテストに関する細かめの話も載ってはいません。本書の主題はあくまで Rust を使ってシステムプログラミングに入門することだからです。この点をもし学びたいようであれば、別の記事や書籍を参照する必要はありそうでした。

ただそうした「取り扱っていない話題」については重々承知のようです。カバーには次のようなコメントが付されており、本書の性格をよく表しています。

本書は包括的な教科書や参考書ではない。Rust とその標準ライブラリに関して、専門的で特別な扱いが必要な部分は略している。本書の目標は、十分な基礎知識を提供し、必要に応じて特殊なトピックを学べる自信を与えるということだ。

翻訳については、正直サラッと読み流した程度なのでコメントするには適切ではない精読量かもしれませんが、私は違和感ない水準でよく訳されていると感じました。読みやすかったです。技術書だと、正直言って翻訳された結果日本語の文章がよくわからなくなっている本が多々あるといえばあるのですが、本書の翻訳は非常によかったと思います。

本書の主題であるシステムプログラミング(やコンピュータアーキテクチャコンピュータサイエンスの基礎)を学ぶことは、ソフトウェアエンジニア自身の足腰を強化する第一歩だと思います。Rust で足腰を鍛えたい方にはおすすめの一冊だと思います。私も引き続き、日本語で楽しみたいです。

*1:ソースコードが公開されています

*2:ちなみに著者は Rust コミュニティの運営にも携わっており、私自身も RustFest Global の運営で交流があったりします。コミュニティ運営でユーザーと接する機会が多いからでしょうか、よくポイントを押さえていると思います。

複数のテストケースをまとめたい際に使える test_case クレート

test_case というクレートを使用すると、複数のパターンのテストを1つのコードでまとめて記述できるようになります。実際に私もこのクレートを使用して、いくつかのテストをまとめて書いてみました。メリットは、重複コードを減らしつつ、テストがどこで失敗したかをひと目でわかるようにできるという点です。デメリットは、テストケースの入力値のコードが多いと、アトリビュートの可読性が下がりそうに見えるという点です。ただ、テストケースの可読性はそこまで重視されない傾向にはあると思うので、あまり気にするほどのことでもないかもしれません。

下記がクレートの GitHub あるいは crates.io のページです。

github.com

https://crates.io/crates/test-case

私が実際に test_case クレートを使ってみた例は下記です。

github.com

できること

複数のテストケースを1つの関数にまとめて assertion できるようになります。専用のマクロが用意されているので、そのマクロに入力の値と期待値を記述し、テストケースに名前をつければ実装できます。正常系のテストはもちろんのこと、専用の構文を利用すると異常系(panic するケース)にも対応できるように作られています。

正常系のテスト

下記のように、いくつかの入力パターンに対して加算のテストを行い、期待値をそれぞれのパターンに対して用意しておくというテストを書くものとします。10+20, 10+0, 0+10 など複数のパターンが考えられます。それぞれに対して assertion を行います。

通常の Rust のテストコードであれば、こうした複数入力パターンのテストについては、関数を別々に用意してテストケースをそれぞれ書くか、あるいは assertion を1つの関数内に複数個用意して対応します。下記の例では複数関数に分けることによって、テストの名前空間を分けるという意図で実装しています。関数を1つにして assertion を複数書く場合は、名前空間は分かれませんが関数がひとつで済みます。

    #[test]
    fn test_10_plus_20_should_work() {
        let e = add(integer(10), integer(20));
        assert_eq!(30, interpreter().interpret(e).unwrap());
    }

    #[test]
    fn test_10_plus_0_should_work() {
        let e = add(integer(10), integer(0));
        assert_eq!(10, interpreter().interpret(e).unwrap());
    }

    #[test]
    fn test_0_plus_10_should_work() {
        let e = add(integer(0), integer(10));
        assert_eq!(10, interpreter().interpret(e).unwrap());
    }

test_case クレートを使用すると、一つの関数にまとめつつ名前空間を分けてテストケースを用意できます。専用のマクロに入力値と期待値、テストケース名を記述することでそれを実現できます。

    use test_case::test_case;

    #[test_case(10, 20 => 30; "10_plus_20")]
    #[test_case(10, 0 => 10; "10_plus_0")]
    #[test_case(0, 10 => 10; "0_plus_10")]
    fn test_plus_should_work(lhs: i32, rhs: i32) -> i32 {
        let e = add(integer(lhs), integer(rhs));
        interpreter().interpret(e).unwrap()
    }

実装の内訳としては、

  • 10, 20 で入力の数値を記述しています。これが、関数の lhsrhs という仮引数に与えられます。
  • => 30 は期待値です。
  • ; の横の "10_plus_20" は、生成する関数名です。このマクロは interpreter::test::test_plus_should_work::_10_plus_20 という名前空間を裏で自動生成します。

となっています。

このテストは単純な数値以外にも、String などの別の組み込み型や、自身で用意した独自のデータ構造あるいは関数であっても適用できます。裏ではマクロによって各テストケースに対するコード生成が走っているだけなようなので、use してインポートしているモジュールについては、通常のコードを書くかのように使用できます。

下記は、別のモジュールにあるデータ構造と関数を使用したテストを記述している例です。add, integer, Expressionast というモジュールの配下にいるものとします。

    use crate::ast::*;

    #[test_case("idnta", add(integer(10), integer(20)) => 30; "assign_addition")]
    fn test_assignment_should_work(name: impl Into<String>, expression: Expression) -> i32 {
        let e = assignment(name, expression);
        let mut interpreter = interpreter();
        interpreter.interpret(e).unwrap())
    }

パニック時のテスト

通常の Rust のパニック時のテストの場合、正常時と同様に複数関数に分けつつ、#[should_panic] というアトリビュートをつけて実行します。このアトリビュートを使用すると裏でパニックが起きることを期待値としてテストが処理され、パニックに関するテストが実行可能です。

   #[test]
    #[should_panic]
    fn test_function_call_check_disable_while() {
        let mut parser = super::function_call();
        let actual = parser.parse("add(while (i > 0) { i; })");
        actual.unwrap();
    }

    #[test]
    #[should_panic]
    fn test_function_call_check_disable_if() {
        let mut parser = super::function_call();
        let actual = parser.parse("add(if (n > 1) { 1; } else { 0; })");
        actual.unwrap();
    }

    #[test]
    #[should_panic]
    fn test_function_call_check_disable_assignment() {
        let mut parser = super::function_call();
        let actual = parser.parse("add(n = 1)");
        actual.unwrap();
    }

このタイプのテストについても、test_case クレートの panics オプションを用いることで、正常系と同じように複数のテストケースをまとめて記述できるようになります。=> panics という記述を入力値の後ろに付け加えることで、実質裏で #[should_panic] による検知を行ってくれるという仕組みになっています。

また、panic の後ろには、パニック時に出力される文字列を記述し、その期待値を照合することもできます。この機能を使うと、いくつかパニックする可能性があるけれど、それらのうちのひとつを検出するテストを書きたい、というユースケースに対応することができます。

    #[test_case("add(while (i > 0) { i; })" => panics)]
    #[test_case("add(if (n > 1) { 1; } else { 0; })" => panics)]
    #[test_case("add(n = 1)" => panics)]
    fn test_function_call_disability<'a>(source: &'a str) {
        let mut parser = crate::parser::function_call();
        let actual = parser.parse(source);
        actual.unwrap();
    }

ユースケース、デメリット

今回私は言語処理系の入力値の検査に使用しました。言語処理系は、ちょっと形が違うだけの複数入力をいくつかテストするというケースがあるのですが、それらを効率よくまとめるのに大きく役立ちました。デメリットが強いてあるとすれば、アトリビュートに記述する入力値のコード量が増えると、その分アトリビュートのコード上に占める領域が広がり、認知負荷が上がりそうだという点でした。

ユースケース

言語処理系では、入力値に対する期待値をいくつかまとめてテストしたいというケースが発生します。具体的には先ほどのような足し算であったり、あるいは文字列の長さが違うだけのケースをそれぞれ通るか確かめたい、といったニーズが考えられます。

その際通常の Rust のテスト機能では、関数を複数分けるか、1つの関数の中に複数 assertion を設置するかという選択を取ることになります。関数を複数分けた場合には、コードの重複が多く発生することになり、若干メンテナンスコストが発生します。また、1つの関数の中に複数 assertion を設置すると、どこでテストが失敗したかが瞬時にはわかりにくいというデメリットがあると思います。

今回紹介した test_case クレートは、関数を複数に分けつつもコードの重複を発生させないテストケースの記述を可能にしてくれます。これを利用することにより、コードの量が増えてメンテナンスコストがかさむことと、テスト失敗時にどこで落ちたかわかりにくいという問題の両方をよい形で解決できるようになります。

感じたデメリットと回避方法

ただ注意点は可読性を高く保つのが困難だということです。test_case のアトリビュートの入力時の記述に通常の Rust コードを使用するのですが、1行に収まらない構造体の生成のようなコードを書いてしまうと、アトリビュートの記述が複数行に渡ってしまうことになるため、少し読みづらくなりそうだなと思いました。

もちろん、可読性の担保のためにテスト用の独自関数を用意して、可能な限りアトリビュートの記述を1行で済ませられるように頑張るという方法も考えられます。たとえば構造体の生成には new 関数をテスト用に特別に用意するなどです。が、テストコードのために通常のコードを増やすのは考える余地はありそうに見えます。一方で、テストコードの可読性は現場ではそこまで重視されない傾向にはあると思うので、そこまでがんばる必要はないという意見も十分ありえます。

使い所を選んで使う必要はありますが、全般的には記述量を減らせてよいクレートだと思いました。

RustConfメモ

さすがに時差の関係で全部は見られませんでしたが、出席していたのとアーカイブが残っていたので、いくつか気になったものを見てみました。ただ、家事などの合間に聞き流ししていた内容を思い出して書いているので、記憶が曖昧なところがあります。ご了承ください。すべてのセッションの動画リストはこちらにあります。

字幕はいくつか見てみましたが、自動生成でしょうか。second が segment 、toml が thermal になっていたりと少し間違っている箇所はありました。ただ、だいたいあってるので大丈夫そうです。もしよかったら、動画も見てみてください。

Whoops! I Rewrote It in Rust by Brian Martin

Twitter 社の方が Rust について語っているセッションでした。Pelikan という Twitter のキャッシングフレームワークに関する話をしています。元が C 実装だったコンポーネントを、Rust で書き直してみたというセッションです。個人的には1番おもしろくて、こういう仕事したいなと思うセッションでした。

github.com

特徴としては下記でしょうか。

  • Memcached コンパチ
  • tokio をネットワーキングに使用
  • C のストレージライブラリを使用

C をもともと使用していたようですが、Rust で書き直した際に 10%-15% のスループットの低下が観測されました。また、レイテンシーも C と比べると少し劣るというベンチマーク結果が出たようです。キャッシュ機構でのパフォーマンスの低下は、そのまま必要なクラスタの追加が必要ということにつながってくるため、そう簡単には許容できません。

C 実装では epoll を使用していたので mio を使用して epoll を使い C 側と揃えるようにし、C 実装と同じように簡潔なイベントループにしました。C のコンポーネントを Rust でほぼ書き直すなどの対応を取りました。要するに C 側の実装に揃うように Rust 側のコードを書き直したのです。すると、C と Rust のスループットレイテンシーはほとんど同じになりました。

その他、Rust で書き直した際に感じたよさに関して、いくつか語られていました。

最終的には Memcached よりもレイテンシの低いキャッシュサーバーが作れてしまったとのこと。これはテンション上がりますね。

Supercharging Your Code With Five Little-Known Attributes by Jackson Lewis

Rust のコードを眺めていると登場するアトリビュートについて、とくに便利なものを紹介しています。紹介されていたものは下記でしょうか。

個人的には readonly というアトリビュートは今すぐ使ってみようと思いました。

Fuzz Driven Development

略して FDD 。Fuzzing を使った開発の進め方について紹介しているセッションです。JSON パーサーを例にどのように Fuzzing 駆動開発を進めていくかについて解説されています。

最初の方に FDD の流れが説明されます。下記のような手順で FDD を行うことができます。

  1. Invariant: 不変条件を決定すること。
  2. Fuzzing Target: Fuzzing するターゲットを決定すること。
  3. Run until failure: 失敗するまで Fuzzing テストを実行すること。
  4. Reflect: なぜ失敗したか考えること。
  5. Develop/Unit tests: ユニットテストを書き、コードを追加して失敗したケースを通るようにすること。
  6. Iterate: 2に戻って、以下繰り返し。

JSON パーサーを例に取ると、

  1. パニックしないケースを考える。
  2. Fuzzer を使ってパーサーに値を入れられるコードを書く。
  3. Fuzzing Target を実行する。
  4. 結果を振り返る。
  5. 落ちたケースに関してユニットテストを書いて、対応する実装を追加する。
  6. 書いたケースが落ちないかの確認と、また別のケースでパニックするまで実行する。

といった流れになるようです。

実際に FDD をしながら開発を進めていく様子が話されていました。加えて、Rust のファジングについては Rust Fuzz Book が詳しいです。

Project Update: Libs Team by Mara Bos

Rust にコントリビューションしているとよく見る方ですね。

Mutex をめぐって、 Rust の標準ライブラリに parking_lot を入れるかどうか検討され、結果300以上のコメントがついたが力尽きてしまったという話が紹介されていました。議論が紛糾したのは非常に大きなインパクトを多方面に与える変更だったからです。

github.com

詳細は追いきれていませんが、Mara さんがその後いくつか修正を加えてこの修正を前に進めました。そのときの経験談について語られています。

こんなことがあったんだなあ、を知ることができたのでおもしろかったです。あと futex というシステムコールを知りました。

手元のアイスティー?がほとんどないのにまだ飲むか!みたいなツッコミを画面の前で入れてしまいました。

Hacking rustc: Contributing to the Compiler by Esteban Kuber

この方も Rust にコントリビューションしているとよく見る方ですね。

rustc へのコントリビューションを開始する人向けに、どうやってコントリビューションをしていくか、流れなどを説明してくれています。x.py などのツールから、Rust Compiler Dev Guide、RFC に関する話など網羅的に話してくれています。

Identifying Pokémon Cards by Hugo Peixoto

自分のポケモンカードのコレクションの整理に Rust を使ったという話。どのようにカードを画像認識させ、どのような Rust のクレートを使ったのかを説明しています。ホログラムの入ったカードの認識は難しいみたいです。

Rust.Tokyo 2021 を開催しました

9/18 に Rust.Tokyo 2021 を開催しました。2020年はコロナの影響が読みきれずキャンセルとしたので、2019年以来2度目の開催です。この2年間で Rust そのものやコミュニティイベントに関してさまざまなことがあったような気がしますが、それは後ほど書くことにします。

今年は2020年に引き続き世の中ができるだけ対面を避けよう、という状況の中行われたカンファレンスとなりました。したがって Rust.Tokyo そのものもオンラインで行いましたし、今やカンファレンスでよく見かけるようになった YouTube Live を使ったよくある形式を採用しました。もうかれこれこうした世の中になって1年半以上経つわけですが、オンラインカンファレンスは市民権を得ているように感じます。

今振り返ってみると、実はオーガナイザー側も一度も対面で会うことなく開催したカンファレンスとなりました。結局今年のミーティングは、キックオフから開催に至るまで、一度も対面で会っていません。なんだか不思議な感じがします。

この記事の免責事項ですが、私の意見を多く含みます。私の意見は必ずしも他の Rust.Tokyo チームメンバーの意見を代表するとは限りません。

今年のカンファレンス

  • 1トラック6セッション
  • 字幕を入れてみた
  • ほぼ事前録画のセッションに

少ない・短いと感じた方も多かったかもしれませんが、1トラック6セッション(スポンサー含め8セッション)としました。単純に運営のリソースと体力の制約です。RustFest Global で初のオンラインカンファレンスを経験したのですが、そのとき5時間1トラックではあったものの、疲労感があったように思いました。私は表にはほぼ出ないので、どらやきさんや同時通訳役をする chiko さんほどではないとは思いますが、それでも疲れました。

今年の試みとして、日本語発表には英語字幕を、英語発表には日本語字幕を入れました。翻訳は我々が行ったわけではなく、プロの翻訳業者を通しています。動画の字幕テロップ編集はどらやきさんが行いました。

字幕をつけたのには理由があります。英語セッションになるとリアクションがとても少なくなるというのを RustFest Global で思っていたのと、オンラインカンファレンスの時代になったので、時差の問題さえなんとかなれば世界中から参加者が来るためです。

英語セッションになるとリアクションが少なくなる問題は前から気になっていて、なので字幕を入れる提案しました。というのも、日本語話者は英語の発表の聞き取りが難しいことが多く*1リアクションを残せず、発表者はがんばって発表したもののなんだかリアクションが少ないぞ、という状況が発生するのは、お互いにとって不幸なのではと思っていたためです。結果はどうだったでしょうか?

Rust.Tokyo をはじめた2019年とは時代が大きく変わり、今やオンラインカンファレンスということで海外からも気軽に日本のカンファレンスに参加できるようになりました。私自身も先日の RustConf に少し参加していたのですが、現地に行かずとも発表を聞ける時代になりました。なので、できる限りそうした参加者のアクセシビリティを確保するためにも、英語の字幕はとくに必要です。

字幕や海外からも参加者を募る関係で、動画はできる限り事前録画を推奨することにしました。発表者の方の負担は正直なところ増えてしまったとは思いますが、結果字幕を埋め込むことができました。みなさんありがとうございました。

今年の担当

今年は下記を担当しました。

今年は新しくデザイナーさんが参加した初めてのカンファレンスとなりました。実は去年の1月か2月くらいに対面で一度お会いし、「今年もがんばるぞ」なんて話をしていたと思うのですが、2020年の Rust.Tokyo はキャンセルになり、RustFest Global には参加されなかったので、今年初めて彼女が参加したカンファレンスとなりました。

2019年はデザインを担当していたのですが、以前 Web デザイナーとして少し仕事をしていたことがあったとはいえ、さすがに私はもうソフトウェアエンジニアです。とくに意識的にデザインについてインプットしているとは言えず(デザインを見るのは好きなんですが)、実際いろいろデザインしてみて、引き出しの量にもちょっと限界があるなと思っていました。新しく入ってきていただいて非常に幅が広がりました。

今年できあがったロゴは下記のようになりました。当初の案ではお正月のようなベージュと赤の組み合わせのものもあったのですが、遠くから見た際の視認性の関係で今年は紫と赤の対比を選びました。デザインはできるだけ中性的になるように以前よりこだわってはいて、masculine *2になりすぎないように日本語を使った柔らかい表現を追加して調整してもらいました。

Rust.Tokyo 2021のロゴ
Rust.Tokyo 2021のロゴ。
中央の東京タワーは認知が取れていそうだったので残しつつ、
これまでとは少しバランスを変えました。

Twitter の広報の文章は(半ば勝手に)英訳を担当しました。まず英語でバーっと書いて、それを日本語で考え直してもう一度書く、というスタイルで書きました*3。ただたまにどらやきさんが英語を書いてくれるときもありました。セッションの広報用ツイートが主な仕事です。

Twitter の広報については、Rust.Tokyo は、最初から登壇者や参加者を日本語話者に限らないために日英両方を用意するようにしています。これには理由があり、今回のように中国や、韓国、そしてたとえばインドネシアベトナム、オーストラリアなどの地域からも参加できるようにするためです。Rust.Tokyo は RustFest のチームからは、どうやら APAC で連絡が取りやすいチームとして認識されているところがあり、そうした役割も少しだけ担っていると思っています。

私の個人的なカンファレンスの感想

私の感想は下記です。

  • 懇親会をしたかった
  • Web サービスのサーバーサイドのセッションなかった

懇親会がしたかったですね。oVice や remo など、懇親会をしやすいツールはいくつか存在しており、他のカンファレンスや学会では懇親会をそうしたツールを使って行うことがあります。頭の中からだいぶ抜けていて、Rust.Tokyo 開催の1週間前くらいからそうしたものをやりたいなと思い始めていました。時はすでに遅しですが。

最近の Rust コミュニティはほぼオンラインで LT 会などが開催される上に、1時間以上ある雑談がメインの懇親会がセットなものは少なく(ないんじゃ?)、Rust コミュニティにいる人の顔をほぼ知らないという寂しい状態になっています。私自身は、2018年〜2019年頃は登壇していたので、その際に多くの方と交流できてとても楽しかったのですが、最近はそうした機会がなくなってしまいました*4

Web アプリケーション開発に関連するセッションは今年は選出できませんでした。スポンサーセッションの Node.js 関連の発表がその一つだったかもしれません。が、Web サービスの Rust によるサーバーサイド開発のセッションがなかったのは、正直な気持ちを言うと少し寂しかったです。記憶では、CFP の時点でもなかったように思います。来年以降に期待です。

日本の Rust の流行について

せっかくですので、Rust のはやりについて少し言及しておこうと思います。私の観測範囲ですので、一般的な話にはできませんが。

  • 個人で触っている方は増えているように感じる
  • SNS での言及は増えているように感じる
  • ただ、企業での採用は増えてそうでしょうか?ちょっとわかりません

いつの間にか Rust のコミュニティに参加するようになって、もう3年〜4年くらい経ちます。2018年の頃と比べると、Rust への関心の高まりは非常に大きくなってきていると思います。私自身も Rust に関する講演を依頼されることが増えましたし、そうした講演が行われているのを見る機会も増えました。

また、私は実はよく新卒面接に出席し面接を行うのですが、学生さんが「Rust を書いています」と言っている確率がとても高くなってきているように感じます。3, 4年前は、それは Go でした。それが今は Rust に変わってきているように見受けられます。

数年前と比べると、明らかに認知度は高まってきています。

一方で SNS だけを見ていると感覚がおかしくなるのですが、とくに私のいる Web 系の会社に限っていうと、 Rust はまだ「これ、(どこで)使えるの?」というフェーズかと思っています。よく聞かれる質問は、「Go と Rust の違いは?」「Go のメリット/ Rust のメリット」といったところでしょうか*5。関心はあるが、導入には及び腰/ユースケースを思いつかないというのが現状かなと思っています。

そういった意味で、今回 PingCAP 社がスポンサーセッションで発表されたような、実際に会社の主軸となるプロダクトに Rust を使用した事例や Tips といった話はとくに貴重です。利用を迷っている方には、実際に入れた話が一番よい特効薬になるからです。

Rust.Tokyo では引き続き、そのような企業での導入事例もたくさん取り上げていきたいと思っています。普段の LT 会ではなかなか難しい話も、カンファレンスであれば可能なはずです。普段の LT では少し尺が足りない話を、ぜひ積極的にカンファレンスの場に出してもらえるととても嬉しいです。

最後に

カンファレンスやコミュニティは、プログラミング言語の「よさ」を支える柱の一つだと思っています。友好的で初心者にも親切なコミュニティには、多くの人が集まってくるはずです。Rust は、言語が主戦場とするフィールドは高度で、コンパイラは鬼教官でありながらも、豊富な機能でユーザーに力を与える (empowerment) 言語だと思っています*6。Rust の友好的なコミュニティは、今のところユーザーの empowerment に一役買っていると思います。

みなさんは Rust のどんなところが好きですか?

私は Rust コミュニティが友好的であり、多様な人の意見をそう簡単には排除しないというのはとても重要で好きだなと思っています。たとえば機能追加に関して言えば、Rust は RFC などを通じて、ユーザーが提案したものを一旦ディスカッションの対象としてくれる傾向にあると思います。「言語の機能がなぜこうなのか?」といった疑問をフォーラム等に書くと、RFC なり経緯を含む GitHub 上の URL なりが必ず返ってきて、理由を説明してくれます。「道を外れるな」「やめろ」といったパターナリスティックなコミュニケーションではなく、そもそも議論がオープンなところが好きです*7

Rust.Tokyo 2019 で、 Florian という元 Rust コアチーム(当時はそうだった?)の人がキーノートで言っていましたが、「『Rust が好きです』ということを発信することも立派なコントリビューションのひとつだ」と言っていたのを思い出しました。Rust を好きと言っていくだけで、あなたはもうコントリビュータなのです。好きをたくさん発信していきましょう。Rust.Tokyo をはじめとするカンファレンスをそのためにぜひ、今後ともご利用ください 🙋🏻‍♀️

*1:日本国内で英語を使う機会はほぼありませんから、こればかりはどうしようもありません。

*2:適切な日本語を思いつかず…

*3:英語を使っている時と日本語を使っている時で別の思考回路になってしまうのです…

*4:登壇していないというのもある。オンラインでの発表はどうも好きではなく、収まるのを待っています。ただ、懇親会のある LT 会がほぼなくなったのは事実だと思います。

*5:ちなみに私はこの手の質問には、「まずは実際に作ろうと思っているアプリケーションを作って動かしてみましょう」と答えます。たとえば、よくある Web アプリケーションのサーバーサイドで、よほどどちらかを積極的に採用できる理由がない限りは、書いてみて好きな方を使ったらよいと思うからです。「どちらを採用しても大して実利に差はない」局面において、両者を区別する大きな差異は書き味と、プログラミングという行為そのものに対する哲学だからです。これはもはや言ってしまえば好き嫌いの問題に換言されると思います。ただ現状だと Rust を使うとエコシステムが未成熟で適切なものがない可能性はありますが。

*6:Rust の根本哲学は empowerment です。公式ガイドにも言及があるくらいには、この言葉を大切にしています。

*7:他には、変数宣言が let で始まるとか、コンパイルさえ通れば書いたとおりに動く(そして書いた以上のことはしない)とか、強く型付けできるし強く型付けしたほうが静的ディスパッチになって速度的にもよいとか、あとはそもそも言語仕様からちょっと小難しく、そこに知的好奇心がくすぐられるところも含めて好きです。

Swift で filter や map 、flatMap などのコンビネータを実装してみる

今年の言語として Swift を選んで最近練習しています。Swift は iOS を作るために使用されることが多いですが、言語としての表現力が非常に豊かで、たとえば Web アプリケーションのサーバーサイド開発に利用できるのではないかと思っています。まだ Swift を学び始めでランタイムなどには詳しくないので、どこまでいけるのかはわかっていません。が、可能性を感じます。

新しい言語を学ぶ際にやることはいくつかあるのですが、型の表現が豊かそうな言語であればまっさきにやるのは「連結リストを作ってモナドっぽいものを足してみる」です。Swift にはジェネリクスがあるほか、言語に組み込みで Optional などの型が存在しており、それなりに型の表現力があるのではないかと感じました。なので、試してみました。

結論としては、

  • 連結リストは結構気持ちよく書ける上に、言語特有のおもしろい機能があるようです。
  • Swift には高カインド型(HKT)がサポートされていないのでモナドは実装できません。
  • Swift には検査例外があるので、それを考慮した実装にする必要がありそうです。

といったところでしょうか。

この記事は Swift を書き始めてまだ24時間経った程度の超初心者が書く記事です。したがって、各所に誤りを含む可能性があります。その点を留意してご覧ください。

実際に書いてみる

連結リストを作る

まずは連結リストを作ってみましょう。連結リストの概観がわからない方はこちらをご覧ください。

Swift では enum を使って次のように書くことができます。これは他の Rust や Scala 3 などとほぼ同じような記法で、後ほどパターンマッチングで中身を取り出せる点も同じです。

enum List<T: Equatable> {
    indirect case cons(head: T, tail: List<T>)
    case `nil`
}

Swift では nil予約語です。なので、バッククオートで囲む必要があります。

注目すべきなのは indirect で、これは再帰的な enum (Recursive Enumerations)を作る際に使用するキーワードです。このキーワードを付与すると、ヒープ領域が確保され値がそこにコピーされるという動作が行われるようです*1。このケースの場合、cons が確保する必要のあるメモリサイズがわからないため、残念ながらスタック領域を使用したメモリの確保が難しくなります。そのため、ヒープ領域に値を確保することにしているといったところでしょうか*2

Swift のメモリ管理

indirect キーワードの裏側を理解するためには、Swift における値型と参照型の違いの理解を必要とします。Swift には enumstruct といったキーワードを代表とする値型と呼ばれる概念と、class といったキーワードを代表とする参照型という概念があります。この2つの理解は、Swift のメモリモデルの理解につながってきます。

値型は、変数の値が参照ではなく直接値をもつ型のことをいいます。変数や定数に値を代入されたときや、引数として渡された際に値のコピーが裏で走り、新たにメモリ領域が確保されます。値型には structenum が該当しますが、これらの所有者は常に1つであることが保証されている型です。値型は再帰的なデータ構造をもつことは、コピー時に確保すべきメモリ領域が不明であることからできません。

参照型は、変数の値への参照をもつ型のことをいいます。値型とは異なり、変数や定数に値を代入された際や、引数として渡された際には、既存のインスタンスへの参照が渡されます。参照型には class が該当します。

値型は、使用されなくなるとすぐに確保されていたメモリ領域が解放されます。これは値型を使用するひとつのメリットになりえます。

参照型の方については、メモリ管理に ARC という仕組みが利用されます。要するに参照カウンタが後ろにいて、ある参照型への参照がどの程度残っているかをチェックしています。カウントがゼロになれば、その参照型が確保していたメモリ領域は解放されるという仕組みです。参照カウンタを利用している以上、循環参照によるメモリリークのリスクと隣り合わせであり、要するに注意して使う必要がありそうということがわかります。

値型は都度コピーコストが発生しますが、Swift はコレクションに対しては Copy on Write という最適化を適用するようです。これにより、評価が必要になるまでコピーを行わないという方式をとっているようです。*3

値型と参照型の使い分けは、調べた限りではケースバイケースの微妙な使い分けをはらんでいるようです。他の言語でもそうですが、局所的に参照をもたせてスコープを狭めながら使うのがよさそうです。Swift の標準ライブラリを読むと struct を基本にデータ構造を設計していることから、基本は struct 中心に構築していくものだと思います。使い分けについてはこちらの資料がわかりやすいです。

最後に、この enum は下記のようにして呼び出すことができます。

let list = List.cons(1, List.cons(2, List.cons(3, List.cons(4, List.`nil`))))

便利プロパティを生やす

プロパティをいくつか生やしていきます。プロパティとは、型に紐付いた値のことです。空判定や先頭の取り出しなどを定義していきます。

extension List {
    var isEmpty: Bool {
        return self == .`nil`
    }
    
    var head: T? {
        switch self {
        case .`nil`:
            return nil
        case .cons(let h, _):
            return h
        }
    }
    
    var tail: List<T>? {
        switch self{
        case .`nil`:
            return nil
        case .cons(_, let t):
            return t
        }
    }
    
    var len: Int {
        switch self {
        case .`nil`:
            return 0
        case .cons(_, let t):
            return 1 + t.len
        }
    }
    
    var first: T? {
        return self.head
    }
    
    var last: T? {
        switch self {
        case .`nil`:
            return nil
        case .cons(let h, .`nil`):
            return h
        case .cons(_, let t):
            return t.last
        }
    }

実装内容そのものは他の言語と変わりありませんが、いくつか注目すべきポイントがあります。 extension キーワード switch、そして Computed Property です。

extension キーワードは、すでに存在する型に対してプロパティやメソッド、イニシャライザなどの型を構成する要素をあとから追加できる機能です。型を拡張することができます。Scala などでは enrich my library という技法で親しまれていたほか、Scala 3 では extension キーワードが同じく使えるようになっています。内実はああいった機能と似ています。今回 enum への実装は extension を使って追加しています。

switch 文は Java や C などにある同等の機能とよく似ています。違いとしては、パターンマッチングができる点だと思います。switch 文を使いながら enum のヴァリアントに応じて処理を記述できます。なお、Swift では switch は文であることに注意が必要です。

Computed Property はアクセスがあるたびに毎回計算が走るプロパティです。計算元との値の整合が常に取れるという特徴があります。Swift に数あるプロパティのひとつです。Swift にはこの他にも Stored Property というものがあります。上の例では、isEmptylen などが Computed Property として定義されています。

高階関数を書く

まだ関数の話もしていませんが、飛び石して高階関数を書いていきます。高階関数を書くためには、まず関数定義とクロージャーが必要です。Swift では、関数定義は func というキーワードで定義できます。クロージャーも言語機能として提供されています。

filter, map, flatMap などのコンビネータは、foldr という関数を使うことですべて実装可能です。foldr 関数を実装した後に各コンビネータを実装するという流れで実装していきます。

// extension の続き
    func foldr<U>(acc: U, f: (T, U) -> U) -> U {
        switch self {
        case .`nil`:
            return acc
        case .cons(let h, let t):
            return f(h, t.foldr(acc: acc, f: f))
        }
    }
    
    func map<U>(f: (T) -> U) -> List<U> {
        return self.foldr(acc: List<U>.`nil`, f: { acc, list in
            return List<U>.cons(head: f(acc), tail: list)
        })
    }
    
    func flatMap<U>(f: (T) -> List<U>) -> List<U> {
        return foldr(acc: List<U>.`nil`, f: { acc, list in
            return f(acc).append(another: list)
        })
    }
    
    func filter(p: (T) -> Bool) -> List<T> {
        return foldr(acc: List<T>.`nil`, f: { acc, list in
            if p(acc) {
                return List.cons(head: acc, tail: list)
            }
            return list
        })
    }

クロージャーの記法ですが、仮引数の際は (遷移元型) -> 遷移先型 で書くことができます。実引数として渡す場合には記法が変わり、{ 一時変数名 in 処理内容 } となるようです。他の言語では仮引数でも実引数でも記法がだいたい一致していると思いますが、Swift は異なるので注意が必要そうです。

ソースコードは下記にあります。

github.com

Swift 本体のコードを少し読んでみる

モナドにするのは厳しそうなことはわかったのですが、今度は Swift 本体がどのように Functor 等を構築しているのかが気になってきました。少し読んでみたのでそのことについて書きます。ただ、今回目を通したのは Optional 型だけなので、もしかすると見落としているかもしれません。

github.com

検査例外の存在

たとえば Optional の Map の実装は次のようになっています。

  @inlinable
  public func map<U>(
    _ transform: (Wrapped) throws -> U
  ) rethrows -> U? {
    switch self {
    case .some(let y):
      return .some(try transform(y))
    case .none:
      return .none
    }
  }

完全に見落としていたのですが、Swift には検査例外があります。throwsrethrows は検査例外があるがゆえの記述です。今回作ったリスト構造と付随するコンビネータを本番で使えるコードにするためには、高階関数であってもエラーの可能性を考慮した実装にする必要があるようですね。

throws はエラーを送出する可能性があることを示すキーワードです。Java につける同等のキーワードと同じ役割を果たしています。

rethrows は、引数のクロージャーが発生させるエラーを呼び出し元へ伝播させるためのキーワードです。これは Java にはなく、Javaラムダ式を実装した際には、例外をハンドリングできる自前のインタフェースを用意して対処した記憶があります。便利なキーワードです。

最後に try キーワードですが、これはエラーを発生させる可能性がある処理を実行するために使用します。つまりこの場合は、transform という関数はエラーを発生させる可能性がある処理だ、ということがわかります。実際、transform の型シグネチャtransform: (Wrapped) throws -> U ですから、これはエラーを発生させる可能性がある関数です。

Swift における2種類のエラーハンドリング

Swift はこれまで検査例外一筋だったようです。ただ、Swift の検査例外は Java のそれと比較するとかなり改善されているようです。実際、私も map 関数のコードを読んで各キーワードを軽く調べてみましたが、Java のそれと比べると使い心地も安全性も向上しているように思われます。

Swift には他にも、Swift 5.0 以降から利用できる Result 型が入ってきているようです。Rust の Result 型と使い方にほぼ差はありません。ただ、既存の検査例外との共存や使い分けが少々難しそうに見えました。Swift の Result 型は、現在のところ非同期的な処理のエラーハンドリングに利用されることが想定されているようです

あまり抽象化はされていない

map 関数やその他の関数も読んでみましたが、どうやら Functor のような型を新たに作ってそちらに処理を集約させるといったことはしていないように思われました。

他にも配列の map や flatMap の実装も読んでみましたが、個別実装されています。

Swift を触った感想

  • 言語仕様はかなり野心的である一方、そうした野心的な機能を快適に利用できるようにする実用性も兼ね備えていると思いました。また、暗黙的に裏で言語のランタイムが気を利かせて何かをやらないようにしている感じがしており、明示的にキーワードを指定する必要があるのもよかったです。加えてたとえば、Int + Double の演算は暗黙的にはできません。どちらかを明示的にキャストする必要があります。
  • 触って24時間程度ですが、今のところびっくりするような挙動はありません。書いたら書いたとおりに動きますし、書いた以上のことはしません。これは極めて重要だと思います。資料を読んでいると参照型のあたりが少しびっくりするかもしれない予感がしていますが…。
  • iOS 開発に関連する Swift の情報はかなり充実している一方で、私のような道を外れた Swift の使い方をするための情報はなかなか Google さんが上に出してくれず、苦戦しました。最初 Xcode でどうやって素の Swift コードを動かすか、やり方がわからずまとめました…→Swift で iOS や MacOS アプリではない開発をはじめる
  • メモリの確保の仕方を厳密に定義しようと思えば、それなりにできそうなところはよいところだと思います。
  • mutability / immutability を明示的に管理できそうなところもよいポイントだと思います。
  • キーワードがとにかく多いです。もちろん特定の状況下でのみ有効になるキーワードもあるから一概には言えませんが、予約語が多くて、普段使っている変数名や関数名が不意にキーワードになってコンパイルエラーになったりします。ただこれは、ユーザーにとっては嬉しいかも。特定の機能を呼び出すために型パズルを作るか、増えたキーワードを覚えるかは言語のスタンスによりそうです。
  • 型推論はそこまで強くないように思いました。Scala 2 と同じくらいの強さかなと言う感じがします。サンプルで載せたコードをご覧いただいてもわかるとおり、比較的型注釈を必要とする傾向にあると思います。

*1:余談ですが、古い Swift のスナップショットでは Rust と同じように Box を使ってヒープ領域を確保していたように見受けられます。Swift をはじめて触った感想は「とにかく特殊な場面向けのキーワードが多いな」といったでした。Box 型も、言語のアップデートによって indirect キーワードに変わっていったようです。特殊な用途ごとに型ではなくキーワードを用意する――これはある種の Swift の設計思想なのでしょうか。参考: https://airspeedvelocity.net/2015/07/22/a-persistent-tree-using-indirect-enums-in-swift/

*2:このあたりを説明している資料を相当数探してみたが、見つけられませんでした。どこかにあるのだろうか。

*3:具体的な動きは下記の記事がわかりやすいです: https://medium.com/@lucianoalmeida1/understanding-swift-copy-on-write-mechanisms-52ac31d68f2f