Rust で何か作ってみようと思い、API サーバーを作ってみました。
アーキテクチャや開発手法、インフラ構成について色々と考えながら構築したので少しずつ記事にします。
今回はアーキテクチャの面についてです。
今回の設計に至るまでの経緯#
クリーンアーキテクチャ…に挫折した#
かの有名な
Clean Architecture 達人に学ぶソフトウェアの構造と設計を読んだことはあったのですが、実践したことがなかったので、これを機に再度勉強して実装しようかと思いました。
実際にコードにしてみる際に参考にした資料・コードは以下の通りです:
- API サーバーを Clean Architecture で構築する
- Clean ArchitectureでAPI Serverを構築してみる
- Go × Clean Architectureのサンプル実装
- Rust API Server Architecture Sample
- actix-web-clean-architecture-sample
特に、一番目の記事はかなりクリーンアーキテクチャの 例の図に忠実に実装していたため、参考になりました(結構、 Output/Input Port, Interactor あたりは明確に分けずに実装している例が多いと個人的に感じています。)。
しかし、1つ問題にぶつかりました。
「インフラ(フレームワーク&ドライバー)層ってどうやって実装すれば良いの・・・?」
後ほど紹介しますが、DB の ORM(OR マッパー)や Actor に外部フレームワークを用いたため、フレームワーク&ドライバー層からコントローラ層に向く依存の方向性(フレームワーク&ドライバー層がコントローラ層を呼び出す構造)を実装する方法がピンと来ませんでした。
オニオンアーキテクチャとの出会い#
同じような悩みを抱いてた方もいらっしゃるようで、下記の記事の内容にとても納得しました。
以下、記事から引用します:
「フレームワーク」「ドライバ」は大抵ライブラリとして提供されている事実
エントリポイント以外ではインフラストラクチャ層からインターフェイス層を呼び出す手立てがありません(エントリポイント自体がインターフェイスに属するという意見も聞くので一概に言えませんが)。これはつまり、どうやってもインフラストラクチャ層はインターフェイス層を呼び出すことはできません。
記事内にもありますが、ORM などの外部ライブラリを使う際には、下記のような構造にせざるを得ないということです。
そこで、記事内で提案されているのが オニオンアーキテクチャです。クリーンアーキテクチャよりも古い概念だそうです。
この構成でも、クリーンアークテクチャのキモとなる SOLID 原則を満たすことができるため、クリーンアーキテクチャのあの図とやりたいことは変わらないということなのです。
DDD? ヘキサゴナルアーキテクチャ??#
もう少し、オニオンアーキテクチャについて詳しく調べてみると、下記の記事に「結局、オニオンアーキテクチャはDDDの本にも載ってる『ヘキサゴナルアーキテクチャ』をもう少し具体的にしたものだよ」ということが書いてありました。
DDDについては、 「実践ドメイン駆動設計」から学ぶDDDの実装入門 や ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 の本を読んでいたため、これもまたなんとなくは知ってましたが、実装したことはありませんでした。
で、結論#
どのアーキテクチャを採用するかを色々試行錯誤しましたが、最終的には、 DDD の考え方を入れてオニオンアーキテクチャで SOLID 原則を満たすアーキテクチャ を採用して実装することにしました。
実際に、Rust のコードに落とし込むにあたっては、以下の記事を参考にしています:
- クリーンアーキわからんかった人のためのクリーンじゃないけどクリーンみたいなオニオンに見せかけたSOLIDの話
- Hexagonal architecture in Rust #1 - Domain
- DDDのパターンをRustで表現する ~ Value Object編 ~
- Rustで楽しいDependency Injection
- Rustでクリーンアーキテクチャによる依存関係逆転の原則について
- Rustでクリーンアーキテクチャを組む時DIするサンプルコード
- Rust で DI する時の小技
実際の成果物#
前置きが長くなりましたが、今回の成果物は こちら です。
この記事では、アーキテクチャの面で解説していきます。
※記事に載せるソースコードは完全なものではありません。
作ったもの#
ポケモンデータをDB(PostgreSQL)に出したり入れたり見たり(要は、CRUD操作)ができるようにする API です。
ドメインモデル層#
値オブジェクトやエンティティとビジネスルール、リポジトリを実装します(/server/src/domain/models
)。今回は、ドメインサービスに相当するものがありませんでしたが、ある場合は/server/src/domain/services
に実装します。
ポケモンオブジェクトは以下のようにします(/server/src/domain/models/pokemon/pokemon.rs
):
1#[derive(Clone, PartialEq, Eq, Debug)]
2pub struct Pokemon {
3 pub number: PokemonNumber,
4 pub name: PokemonName,
5 pub types: PokemonTypes,
6}
7
8impl Pokemon {
9 pub fn new(number: PokemonNumber, name: PokemonName, types: PokemonTypes) -> Self {
10 Self {
11 number,
12 name,
13 types,
14 }
15 }
16}
番号や名前、タイプについては値オブジェクトとして定義します。
例えば、番号は以下のようにします(/server/src/domain/models/pokemon/pokemon_number.rs
):
1use std::convert::TryFrom;
2
3/// ポケモンの図鑑 No を表す。
4#[derive(PartialEq, Eq, Clone, PartialOrd, Ord, Debug)]
5pub struct PokemonNumber(i32);
6
7/// ポケモンの図鑑 No の振る舞い:u16 から PokemonNumber への変換。
8/// 現時点でポケモンの図鑑 No は898 までなので、
9/// それ以上にならないように決めている。
10impl TryFrom<i32> for PokemonNumber {
11 type Error = ();
12
13 fn try_from(n: i32) -> Result<Self, Self::Error> {
14 if n > 0 && n < 899 {
15 Ok(Self(n))
16 } else {
17 Err(())
18 }
19 }
20}
21
22/// 図鑑 No から u16 への変換処理の振る舞いを定義。
23impl From<PokemonNumber> for i32 {
24 fn from(n: PokemonNumber) -> Self {
25 n.0
26 }
27}
番号は構造体として定義するものの、プロパティは1つしかないので、タプル構造体として定義します。
採用する整数型ですが、 ORM の Diesel と PostgreSQL で扱う型の変換の都合上、 i32
を使います。
一般的な整数から PokemonNumber
への変換の際にはチェックをしており、Result
型で返します。
リポジトリについては基本的な CRUD 操作と存在確認を定義します(/server/src/domain/models/pokemon/pokemon_repository.rs
):
1use anyhow::Result;
2
3/// Pokemon のリポジトリインタフェース
4pub trait PokemonRepository {
5 /// 番号からポケモンを探す
6 fn find_by_number(&self, number: &PokemonNumber) -> Result<Pokemon>;
7
8 /// ポケモン一覧を表示する
9 fn list(&self) -> Result<Vec<Pokemon>>;
10
11 /// オブジェクトを永続化(保存)する振る舞い
12 fn insert(&self, pokemon: &Pokemon) -> Result<()>;
13
14 /// オブジェクトを再構築する振る舞い
15 fn update(&self, pokemon: &Pokemon) -> Result<()>;
16
17 /// オブジェクトを永続化(破棄)する振る舞い
18 fn delete(&self, number: &PokemonNumber) -> Result<()>;
19
20 /// 作成したポケモンの重複確認を行う。
21 fn exists(&self, pokemon: &Pokemon) -> bool {
22 match self.find_by_number(&pokemon.number) {
23 Ok(_) => true,
24 Err(_) => false,
25 }
26 }
27}
アプリケーションサービス層#
いわゆる、ユースケースを定義します。ジェネリクスを利用して DI(依存性の注入)をおこなっています。
以下は、指定された番号のポケモンを取り出してくるユースケースの実装の例です(/server/src/application/pokemon_get_service.rs
):
1use anyhow::Result;
2use std::convert::TryFrom;
3
4/// アプリケーションサービスの構造体。
5/// generics でリポジトリへの依存を表し、trait 境界を定義することで、DI を行う。
6pub struct PokemonGetService<T>
7where
8 T: PokemonRepository,
9{
10 pokemon_repository: T,
11}
12
13/// アプリケーションサービスの振る舞いを定義。
14impl<T: PokemonRepository> PokemonGetService<T> {
15 /// コンストラクタ
16 pub fn new(pokemon_repository: T) -> Self {
17 Self { pokemon_repository }
18 }
19
20 /// 取得処理の実行。
21 pub fn handle(&self, no: i32) -> Result<PokemonData> {
22 let number = PokemonNumber::try_from(no).unwrap();
23 match self.pokemon_repository.find_by_number(&number) {
24 Ok(value) => Ok(PokemonData::new(value)),
25 Err(_) => Err(anyhow::anyhow!(
26 "取得しようとしたポケモンが存在しません: no {:?}",
27 number
28 )),
29 }
30 }
31}
外部からドメインオブジェクトを直接扱わないように DTO(Data Transfer Object)も定義しています(/server/src/domain/application/pokemon_data.rs
):
1use crate::domain::models::pokemon::pokemon::Pokemon;
2use getset::Getters;
3use serde::{Deserialize, Serialize};
4use std::convert::TryInto;
5
6#[derive(Serialize, Deserialize, Clone, Getters, PartialEq, Eq, Debug)]
7pub struct PokemonData {
8 #[getset(get = "pub with_prefix")]
9 number: i32,
10 #[getset(get = "pub with_prefix")]
11 name: String,
12 #[getset(get = "pub with_prefix")]
13 types: Vec<String>,
14}
15
16impl PokemonData {
17 pub fn new(source: Pokemon) -> Self {
18 Self {
19 number: source.number.try_into().unwrap(),
20 name: source.name.try_into().unwrap(),
21 types: source.types.try_into().unwrap(),
22 }
23 }
24}
インフラ層#
DB とのやりとりと Actor を定義します。外部ライブラリを利用します。
DB とのやりとり:Diesel を利用#
前回の記事 で利用した Diesel を利用します。
下層であるドメイン層のリポジトリの実装を行います。
これにより、依存性の逆転を実現しています:
ソースコードのうち、ポケモンデータの取得処理の部分を示します(/server/src/infra/diesel/pokemon/pokemon_repository.rs
):
1use anyhow::{Context, Result};
2use diesel::pg::PgConnection;
3use diesel::prelude::*;
4use diesel::r2d2::{ConnectionManager, Pool};
5use std::convert::TryInto;
6
7/// Diesel が直接利用するデータモデル。
8#[derive(Debug, Queryable, Clone)]
9pub struct PokemonEntity {
10 pub no: i32,
11 pub name: String,
12 pub type_: Vec<String>,
13}
14
15#[derive(Debug, Insertable)]
16#[table_name = "pokemon"]
17pub struct NewPokemon {
18 pub no: i32,
19 pub name: String,
20 pub type_: Vec<String>,
21}
22
23/// Pokemon の振る舞い: PokemonEntity から Pokemon への変換処理。
24impl From<PokemonEntity> for Pokemon {
25 fn from(entity: PokemonEntity) -> Pokemon {
26 Pokemon {
27 number: entity.no.try_into().unwrap(),
28 name: entity.name.try_into().unwrap(),
29 types: entity.type_.try_into().unwrap(),
30 }
31 }
32}
33
34pub struct PokemonRepositoryImpl {
35 pub pool: Box<Pool<ConnectionManager<PgConnection>>>,
36}
37
38impl PokemonRepository for PokemonRepositoryImpl {
39 ...(略)
40
41 /// 引数で渡した図鑑 No のポケモンを返却する
42 fn find_by_number(&self, number: &PokemonNumber) -> Result<Pokemon> {
43 let conn = self.pool.get().context("failed to get connection")?;
44 let target_num: i32 = number.clone().try_into().unwrap();
45 match pokemon
46 .filter(pokemon::no.eq(target_num))
47 .load::<PokemonEntity>(&conn)
48 {
49 Ok(result) => match result.get(0) {
50 Some(value) => Ok(Pokemon::from(value.clone())),
51 None => Err(anyhow::anyhow!("Not Found Pokemon number:{}", target_num)),
52 },
53 Err(e) => Err(anyhow::anyhow!(e)),
54 }
55 }
56 ...(略)
57}
各メソッド内でコネクションプールからコネクションを取得して利用するようにします。
外部からのリクエストの受付(Actor):actix-web の利用#
受け付けた HTTP リクエストを actix-web を利用して処理するようにします。
リクエストの JSON データを格納する型を以下のように定義します(/server/src/infra/actix/request.rs
):
1use std::convert::TryInto;
2use crate::domain::models::pokemon::pokemon::Pokemon;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Deserialize, Serialize)]
6pub struct PokemonRequest {
7 pub number: i32,
8 pub name: String,
9 pub types: Vec<String>,
10}
11
12impl PokemonRequest {
13 pub fn of(&self) -> Pokemon {
14 Pokemon::new(
15 self.number.try_into().unwrap(),
16 self.name.clone().try_into().unwrap(),
17 self.types.clone().try_into().unwrap(),
18 )
19 }
20}
(今記事で扱っているポケモンデータの取得処理ではこれは使いません)。
リクエストのパス、メソッドに応じた処理は以下のように実装できます(/server/src/infra/actix/handler.rs
):
1use crate::infra::actix::request::PokemonRequest;
2use actix_web::{delete, get, post, put, web, web::Json, HttpResponse, Responder};
3use serde::Serialize;
4
5#[derive(Serialize)]
6struct ErrorResponse {
7 message: String,
8 r#type: String,
9}
10
11...(略)
12
13#[get("/pokemon/{number}")]
14async fn get_pokemon(
15 data: web::Data<RequestContext>,
16 path_params: web::Path<(i32,)>,
17) -> impl Responder {
18 let pokemon_application = PokemonGetService::new(data.pokemon_repository());
19 let no = path_params.into_inner().0.into();
20 match pokemon_application.handle(no) {
21 Ok(pokemon) => HttpResponse::Ok().json(pokemon),
22 Err(_) => {
23 let response = ErrorResponse {
24 message: format!("FAILURE Get Pokemon: no {:?}", no),
25 r#type: "get_pokemon_list_error".to_string(),
26 };
27 HttpResponse::InternalServerError().json(response)
28 }
29 }
30}
31
32...(略)
エラーについては、メッセージとタイプを定義して、レスポンスで返却できるようにしています。
サーバーの起動処理は以下のように実装できます(/server/src/infra/actix/router.rs
):
1use super::handlers;
2use crate::{config::CONFIG, domain::models::pokemon::pokemon_repository::PokemonRepository};
3use actix_web::{middleware::Logger, App, HttpServer};
4use diesel::{
5 r2d2::{ConnectionManager, Pool},
6 PgConnection,
7};
8
9#[actix_web::main]
10pub async fn run() -> std::io::Result<()> {
11 dotenv::dotenv().ok();
12 let port = std::env::var("PORT")
13 .ok()
14 .and_then(|val| val.parse::<u16>().ok())
15 .unwrap_or(CONFIG.server_port);
16
17 HttpServer::new(|| {
18 App::new()
19 .data(RequestContext::new())
20 .wrap(Logger::default())
21 .service(handlers::health)
22 .service(handlers::post_pokemon)
23 .service(handlers::get_pokemon)
24 .service(handlers::update_pokemon)
25 .service(handlers::delete_pokemon)
26 .service(handlers::get_pokemon_list)
27 })
28 .bind(format!("{}:{}", CONFIG.server_address, port))?
29 .run()
30 .await
31}
32
33#[derive(Clone)]
34pub struct RequestContext {
35 pool: Pool<ConnectionManager<PgConnection>>,
36}
37
38impl RequestContext {
39 pub fn new() -> RequestContext {
40 let manager = ConnectionManager::<PgConnection>::new(&CONFIG.database_url);
41 let pool = Pool::builder()
42 .build(manager)
43 .expect("Failed to create DB connection pool.");
44 RequestContext { pool }
45 }
46
47 pub fn pokemon_repository(&self) -> impl PokemonRepository {
48 use crate::infra::diesel::pokemon_repository::PokemonRepositoryImpl;
49
50 PokemonRepositoryImpl {
51 pool: Box::new(self.pool.to_owned()),
52 }
53 }
54}
これをメイン関数で呼び出すことで、サーバーが起動するようになります。
まとめ#
- Rust で実装した API サーバーのアーキテクチャの考え方を解説しました。
- 細かい実現方法については違うものがあるかと思います(もっと厳密な実装方法があるかと思います)が、やりすぎてもコストが掛かってしまうので、個人開発ではこれくらいが妥協点かなと思います。
- 本を読んで知ってるだけではなく、実際に全部実装してみることでクリーンアーキテクチャやDDDへの理解が深まった気がします。