Don't Repeat Yourself

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

Goでプロジェクトを始めたい際に楽できるツールを作った

数年ぶりに戻ってきたGoですが、環境が大きく様変わりしていて劇的に使いやすくなっていました。

とくにいいなと思ったのが go mod でした。これは Go の 1.11 に実験で入ったあとから利用できる機能のようです。

ところで、go mod init というコマンドがあります。これは Go プロジェクトを新規に始める際に、go.mod という設定ファイルを作成してくれるものです。Go はプロジェクトの内容をここから色々読み取って機能するようになっています。

このコマンドは、一度ディレクトリを作成してからそのディレクトリに入って叩く必要があります。たとえば、github.com/yuk1ty/startgo というパッケージパスでプロジェクトを始めたい際には、一度 startgo というディレクトリを作成し、cd し、その中で go mod init を叩く必要があります。

mkdir startgo
cd startgo
go mod init github.com/yuk1ty/startgo

さらに、git を使えるようにしたい場合、git init も中で打つ必要があります。加えて .gitignore も用意したいことも多いでしょう。Hello, world のために main.go もファイルを作成してエディタで用意して…といった具合にです。

git init
gibo dump go >> .gitignore
touch main.go
vim main.go
# vim で Go を編集する

最初 go mod init を触った際、Rust の cargo new のように一通りプロジェクトに必要な内容物を一気に生成してくれるといろいろ手間が省けて嬉しいなと感じたのを覚えています。git も gitignore も Hello, world 用の簡単なファイルも用意された状態でプロジェクトが始まり、go run main.go するだけで開発をスタートできると嬉しいはずです。

というわけで cargo にインスパイアされてこのニーズを満たす CLI ツールを作ってみました。

github.com

この手のツールはすでに存在していそうですが、とくに調べずにとりあえず好きに作ってみました。知り合いの Gopher に聞いてみたところとくに心当たりがなさそうでしたので[*1、この手のツールは本当に現時点ではないのかもしれません。心当たりのいる Gopher の方がいたら教えていただきたいです。

使い方と仕様

前提

  • v0.2.0 時点では Windows は動作するか保証できていないです。
  • VCS には git を想定しています。

コマンド

readygo コマンドには次のオプションが用意されています。

  • --module-path (-p): go mod init する際に使用するモジュールパス。
  • --dir-name (-n): 作成するディレクトリに使用する名前。省略可能。省略した場合は、--module-path を参考にディレクトリ名は設定される。
  • --layout (-l): Go Standard Layout か、そうではなく空っぽのディレクトリを作成するかを選べる。defaultstandard を設定可能。省略可能。省略した場合の値は default

たとえば、次のように使用することができます。

readygo --module-path github.com/yuk1ty/startgo --dir-name startgo --layout default

この結果生成されるファイル等は次のようになります。

ls -a --tree --level 1 startgo
startgo
├── .git
├── .gitignore
├── go.mod
└── main.go

もちろん短いコマンドも用意されています。

readygo -p github.com/yuk1ty/startgo -n startgo -s default

また、--dir-name オプションは省略可能です。

readygo --module-path github.com/yuk1ty/startgo

この場合、--dir-name には startgo が自動で挿入されます。これは非常にシンプルなロジックで決定されています。具体的には、スラッシュで一度 split した後に、配列の一番うしろを取るという簡単なロジックです。だいたいのケースはこれで対応できるのではないかと思い採用しています。

最後に --layoutstandard を設定すると、いわゆる Standard Go Project Layout のうち、cmd, pkg, internal の3つのディレクトリを一旦生成します。この Standard Go Project Layout はいろいろと議論の余地があるようですが[*2]OSS をいくつか眺めていると意外と利用のユースケースが見受けられたので、最小限用意するようにしています。

readygo --module-path github.com/yuk1ty/startgo --layout standard

この結果生成されるファイル等は次のようになります。

ls -a --tree --level 1 startgo
startgo
├── .git
├── .gitignore
├── cmd
├── go.mod
├── internal
├── main.go
└── pkg

なお、この --layout には所定のフォーマットの YAML ファイルを読み込む機能を導入しようかと考えています。ご自身のよく作られるパッケージの型に合わせてカスタマイズできるとよいのではないかとは考えています。他にも Go のコミュニティでよく利用されるディレクトリレイアウトなどあればご教授ください。一番多いのはフラットディレクトリではないかと思っているので、基本的には default の生成する空のディレクトリで事足りるのではないかとは思っています。

生成されるファイル

readygo コマンドを実行すると、go.mod 以外にもいくつか開発に必要なファイルを用意します。

コマンド実行後用意されるのは、具体的には次のファイルやディレクトリです。パッケージ作成後すぐに git にコミットしたり、あるいは go run して動作確認できることを目指してこのファイルやディレクトリを選んでいます。

  • .git: git init した結果生成されるディレクトリ。
  • .gitignore: Go パッケージで使用できる .gitignore が生成される。
  • main.go: Hello, world できるコードが記述された Go ファイル。

内部実装

内部実装はだいたい300行前後の比較的簡単なロジックになっています。Go で CLI ツールを作ったのは初めてでしたので、知見を少し残しておきたいと思います。

Cobra

Go で CLI ツールを使う際に使えるライブラリのようです。

github.com

とくに cobra-cli が強力で、この CLI ツールにいろいろと指示を出すと雛形を用意してくれます。この上で開発をすれば好みの CLI ツールを作成可能なので、非常に開発しやすく体験がよかったです。

今回作成した CLI ツールもこの Cobra を使い倒しています。コマンド処理の本体実装は root.go に記述されています。このディレクトリのレイアウトなどは cobra-cli の生成するものに従っています。

github.com

git や go コマンド周りの実装

git については最初 git 専用のライブラリがありそうだったので使用しようかと思いましたが、結局普通にシェルを Go から実行することにしました。これが一般的な方法なのかはよくわかりませんが、やることは git init くらいでその結果をアプリケーション側で利用することはなかったため、この形で間に合ったかなと思っています。git コマンドがお使いの環境にない場合はエラーになりそこで処理が終了します。

   git := exec.Command("git", "init")
    err = git.Run()
    if err != nil {
        return err
    }

github.com

ただ、cargo などの実装を見ていると VCS にはさまざまな種類を選択できるようです。一旦自分が使いたいために git での初期化のみに対応しましたが、今後他の VCS 対応も追加していこうかなと思っています。cargo では git 以外の VCS を使用する場合は追加でオプションをつけることで専用の初期化が走るように作られていますが、readygo もこの方式に習って新しいオプションをつけるようにしようかと考えています。

go xxx コマンドを Go から実行する際に特別なパッケージがあるかもよくわからなかったので、やはり同様に go mod init コマンドを Go から直接コマンドを実行しています。同様にとくにコマンドを実行した結果を使用したかったわけではなく、副作用だけ発生させて結果はそのまま破棄で構わなかったので、この形で間に合ったかなと思います。

   cmd := exec.Command("go", "mod", "init", *pkgName)
    err = cmd.Run()
    if err != nil {
        return err
    }

readygo -p [パッケージパス] ではじめられるので、ぜひ試してみてください。

今後のプラン

*1:というか、たった数コマンドなのでそこまで手間じゃないというのはありそうです。

*2:Russ Cox がコメントしている(https://github.com/golang-standards/project-layout/issues/117)。そもそも Go がオフィシャルに推進しているものではないことや、「多くの Go のエコシステムで使われてきた」という言説自体が誤りであること、また pkg ディレクトリがとくに余計な複雑性を持ち込むことになりよくないなどといった話が書かれている。

GitHub Actions 上の cargo install でインストールされるプラグインをキャッシュしつつ使いたい

GitHub Actions 上で cargo install 経由でインストールされるプラグインを使いたいと思いました。しかし実際に使ってみると、そのプラグインのインストールとビルドに5分程度毎回時間を要することがわかりました。これはビルド時間を伸ばすことにつながり開発の生産性を下げるのので、解決策を探しました。

背景

今回解決したい事象の背景としては次のとおりです。

  • GitHub Actions 上で、SQL クエリビルダー sqlx のクライアント sqlx-cli を実行し、ビルド時とテスト時にデータベースにマイグレーションをかけたい。
    • sqlx はコンパイル時にデータベースに接続し、データベース上にテーブルがあるかどうかまでチェックする機能がある。それに必要。
  • ただ、sqlx-cli はインストールとビルドに時間がかかる。手元の GitHub Actions ではだいたい5分くらいを要していたようだった。
  • キャッシュすれば解決するのでキャッシュしたい。

解決策

結果的に便利な action が見つかったので、それを使用することにしました。ただこの action は、基本的には cargo install 時のキャッシュ専用のディレクトリを作ってそこにキャッシュを行うだけという比較的単純なものなので、自分で同様の action を作ってみてもよいかもしれません。

github.com

今回は sqlx に対して使用したいので、使えるように下記のように定義を書き足しました。

# GitHub Actions の定義ファイルの一部
      - uses: baptiste0928/cargo-install@v1
        name: Install sqlx-cli
        with:
          crate: sqlx-cli
# 続く

最初のビルドではフルインストールとビルドが走りますが、2度目以降のビルドではきちんとキャッシュされていることを確認できます。

f:id:yuk1tyd:20220417163419p:plain
キャッシュが効くと、「Restored crate from cache」と出る

「ちょい使い」に便利なIOクレート ezio

Rust を使っているとどうしても思い出しながらでないと書けないものに標準入力、ファイルの読み書きがあります。というのも、Web アプリケーションを作るソフトウェアエンジニア(私)の場合日常業務でそこまで必要になる操作ではなく、ファイルの読み書きは S3 の SDK を叩くことのほうが多いですし、ターミナルからの入力はほぼ使うことはないためです。

しかし、ちょっとした CLI ツールを作るとなると話は変わってきます。これらはもちろんものによりますが、比較的プリミティブな標準入力操作やファイル読み書きの操作を求められるためです。そういった操作の際、Rust は Python ほどは楽に書けないというか、気をつけなければならないポイントがいくつかあり「手軽」とは呼べないことがあります。

こうした「手軽さ」のなさは面倒なポイントのひとつではありましたが、先日 ezio というクレートを作者の方が Twitter で紹介しているのを見て使ってみたところ、これが本当に使いやすくおすすめしたいと思いました。というわけで、ezio を軽く紹介する記事です。

ezio

Easy IO の略でしょうね。easy(/ˈiːzi/) + io→ez + io→ezioでしょうか。

github.com

docs.rs

作者いわく「エラーハンドリングや速度面はこだわっていないので、本番で使うには少し適さないかもしれない」とのことです。詳しくベンチマークを取ったわけではありませんが、もしかすると本番のアプリケーションのユースケースで使用が適さない場合があることに注意が必要かもしれません。しかし、手元で軽く動かす程度の CLI ツールを作る分にはこれで十分です。

特徴的なのは簡単なインタフェースで、プレリュードを一度読み込んでおくだけで、

use ezio::prelude::*;

たとえば標準入力はたったの1行で済みます。

let _ = stdio::read_line();

それでは実際に「Rust の標準ライブラリで書いたコード」と「ezio で書き直したコード」を比較しながら、どう便利になっていくのかを見てみましょう。

英和辞書ツールを作る

今回は先日発売された『手を動かして考えればよくわかる 高効率言語 Rust 書きかた・作りかた』より題材をお借りしております。実際の書籍の内容より少し改良を加えたものを使用しています。

英単語を入力すると日本語での意味を検索できる簡易的なアプリケーションを作ってみましょう。処理としては、

  1. 標準入力操作を待ち、単語をユーザーに入力させる。
  2. 裏で辞書ファイルを読み、メモリに保持させる。
  3. 1行1行一致検索を行う。

です。

Rust の標準ライブラリで

これを Rust の標準ライブラリでやると結構大変です。もちろん本番のアプリケーションではこのくらい注意深く実装する必要はあります。

標準入力は std::io::stdin().read_line() のように書くと行うことができます。普段 Rust の標準入力を使わない私は今回躓いたのですが、read_line すると末尾に改行コードがついてくるようです。そこで、改行コードを削る実装を追加しています。

ファイルの読み込みは、std::fs::Filestd::io::BufReaderstd::io::BufRead の3つの助けが必要になります。ファイルを読み込んで、それを1行1行読み込むという操作を行っています。BufReader に乗せてバッファリングし、その中身を順繰りにイテレートして一致検索を行います。

use std::fs::File;
use std::io::{stdin, BufRead, BufReader};

fn main() {
    // 1. 標準入力操作
    let mut word = String::new();
    stdin().read_line(&mut word).unwrap();
    if !word.is_empty() {
        // 末尾に改行コードがくっついてるので削る
        word.truncate(word.len() - 1);
    }

    // 2. ファイル読み込み
    let file = File::open("ejdict-hand-utf8.txt").unwrap();
    let reader = BufReader::new(file);

    // 3. 一致検索
    for line in reader.lines() {
        let line = line.unwrap();
        if line.find(&word).is_none() {
            continue;
        }
        println!("{}", line);
    }
}

同じディレクトリ内に下記のようにファイルを用意します。ファイル自体はこのサイトからダウンロードして置いています。

❯ ls --tree .
.
├── Cargo.lock
├── Cargo.toml
├── ejdict-hand-utf8.txt
├── src
...

動かすと単語の和訳の検索ができます。

❯ cargo run -q
homebrew
homebrew        自家醸造ビール

これを ezio で書き直すとどうなるでしょうか。見てみましょう。

ezio で

まず、Cargo.toml に下記を追加しましょう。

[dependencies]
ezio = "0.1.2"

ezio で書いたコードは下記のようになります。

use ezio::prelude::*;

fn main() {
    // 1. 標準入力操作
    let word = stdio::read_line();
    // 2. ファイル読み込み & 3. 一致検索
    for line in file::reader("ejdict-hand-utf8.txt") {
        if line.find(&word).is_none() {
            continue;
        }
        println!("{}", line);
    }
}

嘘みたいに短くなったことがわかります。標準入力の read_line 関数は String を返すので、わざわざ先ほどの標準ライブラリの例のように空のミュータブルな String を用意してそれを渡すといった処理は不要になります。

また、File::open とバッファリングを reader という関数で一挙に行っています。実際、reader 関数は下記のような実装になっています。

pub fn reader(path: impl AsRef<Path>) -> Reader {
    Reader(std::io::BufReader::new(
        std::fs::File::open(path).expect("Couldn't open file"),
    ))
}

このクレートは先ほども説明したとおり細かいエラーハンドリング等は行わず、内部で expect を使って Result 型は実質 unwrap されていることが多いので注意が必要ですが、簡易的な CLI ツールを作る分にはむしろこれで十分ではないかと思います。

今回は紹介しませんでしたがクレートの README に書かれているように、ファイルへの書き出しは file::write 等のシンプルな関数で済ませられます。

まとめ

  • ezio を使うと素早く標準入出力を含む操作ができる。

『コンセプトから理解するRust』

『コンセプトから理解するRust』を一足お先に読みました。Rust に関する日本語書籍の発刊が増えてきており、読むより発売するペースが上がっている気がします。私もだんだん精読するより積むほうが増えてきてしまいました。

今回も例のごとく、全体に目を通した上で感想などを書いていきたいと思います。本自体は2/12発売のようです。

本書はアプリケーションを実装しながら Rust を学んでいくというより、Rust に登場する特有の概念を解説しながら Rust を学んでいくというコンセプトになっています。その過程で他のプログラミング言語の実装と比較しながら、他の言語と Rust で違う点を説明したり、あるいは共通しているポイントを見つけたりしながら Rust 特有の概念を理解していきます。特有の概念というのは、代表的なものは本書の表紙でも紹介されている「所有権、型、トレイト」です。たしかにこれらを押さえれば、一旦 Rust は書き始められるかと思います。

『実践Rustプログラミング入門』をはじめとしたアプリケーションを実装しながらプログラミング言語を解説する本ですと、どうしてもサンプルコードを多く掲載する関係で本が分厚くなりがちです。しかし本書は類書と比較してもポイントをしっかり押さえつつもかなり薄めに仕上がっており、「他のプログラミング言語はそれなりに使用してきた*1が、Rust 特有の話だけサクッと学びたい」という方はこの本がよいのかもしれません。

最初にまず変数にまつわる話題を取り扱ったあと、その際登場する所有権について解説されます。その後に、Rust に登場する代表的な型について紹介されます。その中で Option や Result、Box や Rc をはじめとするスマートポインタも解説されます。スマートポインタには概念図が記載されており、理解しやすく書かれていると感じました。そうした話題を一通り押さえたあとに、Rust での抽象化の話題(トレイトやジェネリクス)が扱われます。その他の話題として、ファイルの入出力や関数型プログラミングの側面を利用した Rust プログラミング、並列処理・並行処理、あるいは非同期処理に関する話題、そして C との FFI が扱われます。

本書の目次はこちらのサイトに掲載されていますので、興味がある章があるようでしたら読んでみるとよいかもしれません。

本書の良さ

  • Rust 特有の概念を丁寧に説明している。
  • とくに、メモリ管理にまつわる部分は図でわかりやすく説明している。
  • 他の言語をそれなりにやってきた方は楽しめるかも。

Rust 特有の概念を丁寧に説明している

「所有権」や「ライフタイム」あるいは「トレイト」など、最初他の言語から Rust に入門すると理解に苦労する概念をとくに丁寧に説明しています。あとがきにもありましたが、もともと著者の方もこうした Rust 特有の概念の理解に苦労したうちの一人で、どのように説明すればわかりやすく伝えられるかを考えて本書を書かれたとのことでした。あるいは、変数の代入と束縛については微妙に違いがある話など、他の入門書では見ない話題が度々書かれていました。

トレイトとジェネリクスの説明に1章丸々割いているのは本書の大きな特徴だと思います。Rust ではこの2つの概念を用いて抽象化をゼロコストで行えるという特徴がありますが、これらを懇切丁寧に解説していると思います。dyn Traitimpl Trait の話題、ならびに動的ディスパッチと静的ディスパッチにまつわる話題が解説されます。

とくに、メモリ管理にまつわる部分は図でわかりやすく説明している

Rust 特有の概念として所有権をはじめとするメモリ管理の概念があります。それを理解するのは最初の関門となるわけですが、本書では図を使って直感的に理解できる説明を心がけているようです。要所要所で図によるメモリ管理や処理の遷移がよく解説されており、ひとまず雰囲気を掴むのにはこれで十分だと感じました。

他の言語をそれなりにやってきた方は楽しめるかも

この本では C/C++Python との比較がまま登場するため、他のプログラミング言語をいくつかよく触ったことがあり、プログラミング言語間の思想や目的の違いを理解されている方にはとくに楽しめる内容になっていそうだと思いました。

ただ本書はあくまで Rust への入門書ですので、「所有権のチェックはそもそもコンパイラ内部ではどのように扱われているか」「トレイトと実際の実装の紐付けがコンパイラ内部ではどのように扱われているか」といったような言語処理系が好きな方が興味を持つであろう話題には触れられてはいません。そうした高度な話題をタイトルから期待されている方には少し向かないかもしれません。

Rust 入門時に必要な知識がコンパクトに得られる一冊ではないかと思います。「作りたいものは決まっていて、すばやくキャッチアップしたいが手頃な資料がないだろうか?」と考えていた方にはおすすめできるかもしれません。

*1:それなり、の定義は難しいですが…

『手を動かして考えればよくわかる 高効率言語 Rust 書きかた・作りかた』を読みました

先日発売になった『手を動かして考えればよくわかる 高効率言語 Rust 書きかた・作りかた』を、一通り目を通していました。感想を記しておきます。なお感想は、例のごとく全体に軽く目を通して、いくつかサンプルプログラムを写経してみた程度の上でのものです。あらかじめご了承ください。

Python から Rust に入門するという切り口

最近はRustの本が多く出版され始めており、読むよりも買うほうがだんだん多くなってきてしまっています。とくに単なる言語の入門にとどまらず、さまざまな切り口から解説する本が増え始めているように思います。本書もそのひとつで、Python から Rust に入門しようという非常に特異な切り口の一冊です。

Python から Rust という切り口は、多少なりとも求められている気がしています。SNS を見ていると、データサイエンティストや機械学習関連の研究者の方が Rust を使い始めたよ、という話を見かけるようになりました。私も仕事上そういった方々と関わりがあるのですが、Rust への注目をそれなりに感じます。

何より私が入門したときは、Java からいきなり Rust に入門してしまったので、「Java で書くこれは Rust ではどう書くのだろうか…?」「トレイトって何」「参照の使いどころがわからない」などと日々調べながら苦労して覚えていった記憶があります。他の言語での書き方を Rust に直すとどうなるかという切り口は、そうした数年前の私のようなユーザーに非常にフィットすると思います。この本をきっかけに、他言語から Rust に入門するユーザーも増えていくのではないでしょうか。

Rust は言語仕様がライトではない関係で解説も重厚になりがちではあるのですが、JavaPython 出身者は最初面食らってしまうかもしれません。私も面食らった一人でした。しかし本書は、全体的にライトな解説の仕上がりになっていて、そうしたユーザーであってもつまづきにくく構成されているなと感じました。

説明が明快でわかりやすい

解説は非常にライトでつまづきポイントを抑えておりわかりやすいと思いました。Rust は、PythonJava のようないわゆるレイヤーの高い言語を触っていると登場してこない概念が平気で登場してきます。たとえば参照は、そうした言語のユーザーを最初に悩ませるポイントになると思います。本書では図や解説文でそうした慣れない概念をよく解説しています。

これは少し語弊があるかもしれませんが、『詳解Rustプログラミング』や『プログラミングRust』は、どちらかというとアカデミック寄りの説明方法をしていると思っていました。一方で、本書はそうしたアカデミックなバックグラウンドのない方であってもわかるように、実学的で平易な説明の仕方をしていると感じました。

豊富なサンプルでテンポよく Rust を学べる

本書では非常に豊富なサンプルでテンポよく Rust の文法と使い方を学んでいくことができます。Rust と Python で共通する概念が登場するアプリケーションでは、まず Python で実装し、そのあと Rust で実装し直してみるという流れを取ります。本書の後半に登場する Rust 特有の概念(トレイトやジェネリクスなど、Python にはない概念)については、Rust コードのみでの解説です。まずは Python から Rust への変換で基本的な書き方に慣れ、Rust の応用的な話に踏み込んでいく、といった構成でしょうか。

題材が個人的に好きで、迷路の自動生成、乱数生成器のフルスクラッチや画像加工、音源の制作&wavファイルの生成、チャットサーバーの実装から簡易的なプログラミング言語の実装まで幅広かったです。いくつか実装してみましたが、個人的にも非常に楽しめました。

機能の解説は網羅的

サンプルアプリケーションを実装し、それを通じて言語の機能を解説する形式の本だと、どうしてもサンプルアプリケーションの選定によって紹介する言語機能が網羅的にならないという問題があるかと思います。たとえばスマートポインタの解説は意外とサンプルアプリケーションの規模だと紹介の流れが難しく、苦心するポイントかもしれません。

本書は応用的な話、とくにスマートポインタまで含めて網羅的によく解説されていると思いました。スマートポインタは私も最初よくわからなかったので、解説は必須だと感じていました。肝心のスマートポインタの解説のサンプルアプリケーションは、いわゆる単方向リストの実装が採用されていました。単方向リストでは、参照と参照外しがまずまずハードに登場しますが、順を追って丁寧に解説していてわかりやすかったです。

スマートポインタ以外にも、マクロやC・Pythonとのつなぎ方、WebAssembly にいたるまで結構網羅的です。マクロの説明の仕方は個人的にも苦労するポイントだと感じていたのですが、本書の説明の仕方は真似したいです。

まとめ

サンプルが豊富で日曜工作にうってつけです。今週末の予定が埋まりました。

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 でエラーメッセージを付与したり、サードパーティライブラリのエラー型を吸収したりします。

VO (Value Object) の量を節約する

DDD 等で登場する値オブジェクト (VO) は、堅牢なアプリケーションの開発が必要な場面で大きな威力を発揮するコンセプトです。コンパイルタイムで値の入れ間違えなどを検知できるようになったり、そもそも値オブジェクトを生成するタイミングで契約プログラミングによって弾いてありえない値が入らないようにできたりとよく登場します。

が、エンティティの ID を個別にたくさん実装すると面倒な場面があります。構造体の名前が増えてきて管理の手間が膨らむなどです。共通して行う処理を二重に書いてしまったりと、わけたからこそ発生する問題もあるといえばあります。

Rust では NewType を使って、下記のように VO を定義することは多いと思いますが、

pub struct StockId(pub Ulid);

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

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

すべてのパターンの VO で共通する実装は、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年を一言でまとめると?

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