Rust 勉強中の身ですので、何かしら作ってみようと思い立ち、 API サーバーを構築してみました。
自力で一から公開できるサーバーを構築したのは初めてでしたので、試行錯誤の過程を記事にします。
作ったもの
何の変哲もない API サーバーです。
成果物は こちら にアップしました。
- API サーバー
- DB サーバー
- PostgreSQL を利用する
- Heroku PostgreSQL で稼働させる
開発方針
上記のインフラ構成を目標として、以下の開発方針を軸として調査や検証を行いました。
- ローカルでの開発とサーバーへのデプロイはスムーズにできるようにする。
- ローカルでテストや動作確認がスムーズにできるようにする:Docker の利用
- デプロイも GitHub Actions を利用してできるようにする
- Rust のコードは DDD を実践できるアーキテクチャを採用する
アーキテクチャの検討
一番四苦八苦したのはアーキテクチャでした。試行錯誤の過程を記載します。
クリーンアーキテクチャでの実装と挫折
かの有名な本 も読んだことあったので、こちらの記事を参考に下記の図を忠実に1実装しようとしました。
内側から実装を順調に進めていたのですが、フレームワーク&ドライバー層の実装方法がわからず詰まってしまいました。詰まった理由としては、「フレームワーク」や「ドライバ」は Diesel や actix-web といった外部ライブラリとして提供されており、エントリポイント以外ではフレームワーク&ドライバー層からインターフェイス層を呼び出す手立てがないためです。
実際に実装しようとすると、下記のようにインターフェース層からインフラ層を呼び出すという形しか出来ませんでした2。
オニオンアーキテクチャとの出会い
何か別のアプローチがないか調べていたところ、こちらの記事 で紹介されていたオニオンアーキテクチャに出会いました。
これは DDD のヘキサゴナルアーキテクチャの「Application」部分をより具体化したものとして紹介されています。
オニオンアーキテクチャの特徴
従来のレイヤードアーキテクチャは上位の層は直ぐ下の層しか呼び出せないのに対して、オニオンアーキテクチャはさらに下の層の呼び出しも許容しているのが特徴です。
これにより、ドメインモデルやドメインサービス、ビジネスルールをインフラに依存せずに実装できます。
結局したかったことは何だっけ・・・?
ここまでいろいろ書籍を見返したりネットの記事を調べている中出、目的を見失いそうになったので、整理しました。結局、クリーンアーキテクチャとは、以下の SOLID 原則を満たしているアーキテクチャであることを再確認しました:
- S:SRP、単一責任の原則
- O:OCP、解放閉鎖の原則
- L:LSP、リスコフの置換原則
- I:ISP、インタフェース分離の原則
- D:DIP、依存性逆転の原則
オニオンアーキテクチャで API サーバーを実装するにあたっては、 SOLID 原則を満たしているかを注意しながら実装を進めました。
実装内容
アイデアとしては Rust でヘキサゴナルアーキテクチャを実装している こちらも記事 を参考に、ポケモンの情報を CRUD 操作できる API です。動作例は以下の通りです:
$ curl -X GET localhost:8080/pokemon
{"message":"FAILURE Get Pokemon List","type":"get_pokemon_list_error"}
$ curl -X POST -H "Content-Type: application/json" -d '{"number":1, "name":"test_name", "types": [ "Fire" ]}' localhost:8080/pokemon
SUCCESS Register Pokemon
$ curl -X POST -H "Content-Type: application/json" -d '{"number":2, "name":"test_name2", "types": [ "Water", "Electric" ]}' localhost:8080/pokemon
SUCCESS Register Pokemon
$ curl -X GET localhost:8080/pokemon
[{"number":1,"name":"test_name","types":["Fire"]},{"number":2,"name":"test_name2","types":["Water","Electric"]}]
$ curl -X GET localhost:8080/pokemon/1
{"number":1,"name":"test_name","types":["Fire"]}
$ curl -X PUT -H "Content-Type: application/json" -d '{"number":1, "name":"test_name2", "types": [ "Water" ]}' localhost:8080/pokemon/1
SUCCESS Update Pokemon: no 1
$ curl -X GET localhost:8080/pokemon/1
{"number":1,"name":"test_name2","types":["Water"]}
$ curl -X DELETE localhost:8080/pokemon/1
SUCCESS Delete Pokemon: no 1
$ curl -X GET localhost:8080/pokemon
[{"number":2,"name":"test_name2","types":["Water","Electric"]}]
アーキテクチャとしては、先ほどのオニオンアーキテクチャをベースに実装していきました。
ドメインモデル層
値オブジェクトやエンティティとビジネスルール、リポジトリを実装します(/server/src/domain/models
)。今回は、ドメインサービスに相当するものがありませんでしたが、ある場合は /server/src/domain/services
に実装します。
ポケモンオブジェクトは以下のようにします(/server/src/domain/models/pokemon/pokemon.rs
):
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Pokemon {
pub number: PokemonNumber,
pub name: PokemonName,
pub types: PokemonTypes,
}
impl Pokemon {
pub fn new(number: PokemonNumber, name: PokemonName, types: PokemonTypes) -> Self {
Self {
number,
name,
types,
}
}
}
番号や名前、タイプについては値オブジェクトとして定義します。
例えば、番号は以下のようにします(/server/src/domain/models/pokemon/pokemon_number.rs
):
use std::convert::TryFrom;
/// ポケモンの図鑑 No を表す。
#[derive(PartialEq, Eq, Clone, PartialOrd, Ord, Debug)]
pub struct PokemonNumber(i32);
/// ポケモンの図鑑 No の振る舞い:u16 から PokemonNumber への変換。
/// 現時点でポケモンの図鑑 No は898 までなので、
/// それ以上にならないように決めている。
impl TryFrom<i32> for PokemonNumber {
type Error = ();
fn try_from(n: i32) -> Result<Self, Self::Error> {
if n > 0 && n < 899 {
Ok(Self(n))
} else {
Err(())
}
}
}
/// 図鑑 No から u16 への変換処理の振る舞いを定義。
impl From<PokemonNumber> for i32 {
fn from(n: PokemonNumber) -> Self {
n.0
}
}
番号は構造体として定義するものの、プロパティは1つしかないので、タプル構造体として定義します。
採用する整数型ですが、 ORM の Diesel と PostgreSQL で扱う型の変換の都合上、 i32
を使います。
一般的な整数から PokemonNumber
への変換の際にはチェックをしており、 Result
型で返します。
リポジトリについては基本的な CRUD 操作と存在確認を定義します(/server/src/domain/models/pokemon/pokemon_repository.rs
):
use anyhow::Result;
/// Pokemon のリポジトリインタフェース
pub trait PokemonRepository {
/// 番号からポケモンを探す
fn find_by_number(&self, number: &PokemonNumber) -> Result<Pokemon>;
/// ポケモン一覧を表示する
fn list(&self) -> Result<Vec<Pokemon>>;
/// オブジェクトを永続化(保存)する振る舞い
fn insert(&self, pokemon: &Pokemon) -> Result<()>;
/// オブジェクトを再構築する振る舞い
fn update(&self, pokemon: &Pokemon) -> Result<()>;
/// オブジェクトを永続化(破棄)する振る舞い
fn delete(&self, number: &PokemonNumber) -> Result<()>;
/// 作成したポケモンの重複確認を行う。
fn exists(&self, pokemon: &Pokemon) -> bool {
match self.find_by_number(&pokemon.number) {
Ok(_) => true,
Err(_) => false,
}
}
}
アプリケーションサービス層
いわゆる、ユースケースを定義します。ジェネリクスを利用して DI(依存性の注入)をおこなっています。
以下は、指定された番号のポケモンを取り出してくるユースケースの実装の例です(/server/src/application/pokemon_get_service.rs
):
use anyhow::Result;
use std::convert::TryFrom;
/// アプリケーションサービスの構造体。
/// generics でリポジトリへの依存を表し、trait 境界を定義することで、DI を行う。
pub struct PokemonGetService<T>
where
T: PokemonRepository,
{
pokemon_repository: T,
}
/// アプリケーションサービスの振る舞いを定義。
impl<T: PokemonRepository> PokemonGetService<T> {
/// コンストラクタ
pub fn new(pokemon_repository: T) -> Self {
Self { pokemon_repository }
}
/// 取得処理の実行。
pub fn handle(&self, no: i32) -> Result<PokemonData> {
let number = PokemonNumber::try_from(no).unwrap();
match self.pokemon_repository.find_by_number(&number) {
Ok(value) => Ok(PokemonData::new(value)),
Err(_) => Err(anyhow::anyhow!(
"取得しようとしたポケモンが存在しません: no {:?}",
number
)),
}
}
}
外部からドメインオブジェクトを直接扱わないように DTO(Data Transfer Object)も定義しています(/server/src/domain/application/pokemon_data.rs
):
use crate::domain::models::pokemon::pokemon::Pokemon;
use getset::Getters;
use serde::{Deserialize, Serialize};
use std::convert::TryInto;
#[derive(Serialize, Deserialize, Clone, Getters, PartialEq, Eq, Debug)]
pub struct PokemonData {
#[getset(get = "pub with_prefix")]
number: i32,
#[getset(get = "pub with_prefix")]
name: String,
#[getset(get = "pub with_prefix")]
types: Vec<String>,
}
impl PokemonData {
pub fn new(source: Pokemon) -> Self {
Self {
number: source.number.try_into().unwrap(),
name: source.name.try_into().unwrap(),
types: source.types.try_into().unwrap(),
}
}
}
インフラ層
DB とのやりとりと Actor を定義します。外部ライブラリを利用して、 Adaptor としての役割を担います。
DB とのやりとり:Diesel を利用
Diesel の使いから、DB のマイグレーションについては(手前味噌ですが)私が過去に書いた記事を参考にしていただければと思います。
下層であるドメイン層のリポジトリの実装を行います。
これにより、依存性の逆転を実現しています:
ソースコードのうち、ポケモンデータの取得処理の部分を示します(/server/src/infra/diesel/pokemon/pokemon_repository.rs
):
use anyhow::{Context, Result};
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
use std::convert::TryInto;
/// Diesel が直接利用するデータモデル。
#[derive(Debug, Queryable, Clone)]
pub struct PokemonEntity {
pub no: i32,
pub name: String,
pub type_: Vec<String>,
}
#[derive(Debug, Insertable)]
#[table_name = "pokemon"]
pub struct NewPokemon {
pub no: i32,
pub name: String,
pub type_: Vec<String>,
}
/// Pokemon の振る舞い: PokemonEntity から Pokemon への変換処理。
impl From<PokemonEntity> for Pokemon {
fn from(entity: PokemonEntity) -> Pokemon {
Pokemon {
number: entity.no.try_into().unwrap(),
name: entity.name.try_into().unwrap(),
types: entity.type_.try_into().unwrap(),
}
}
}
pub struct PokemonRepositoryImpl {
pub pool: Box<Pool<ConnectionManager<PgConnection>>>,
}
impl PokemonRepository for PokemonRepositoryImpl {
...(略)
/// 引数で渡した図鑑 No のポケモンを返却する
fn find_by_number(&self, number: &PokemonNumber) -> Result<Pokemon> {
let conn = self.pool.get().context("failed to get connection")?;
let target_num: i32 = number.clone().try_into().unwrap();
match pokemon
.filter(pokemon::no.eq(target_num))
.load::<PokemonEntity>(&conn)
{
Ok(result) => match result.get(0) {
Some(value) => Ok(Pokemon::from(value.clone())),
None => Err(anyhow::anyhow!("Not Found Pokemon number:{}", target_num)),
},
Err(e) => Err(anyhow::anyhow!(e)),
}
}
...(略)
}
各メソッド内でコネクションプールからコネクションを取得して利用するようにします。
外部からのリクエストの受付(Actor):actix-web の利用
受け付けた HTTP リクエストを actix-web を利用して処理するようにします。
リクエストの JSON データを格納する型を以下のように定義します(/server/src/infra/actix/request.rs
):
use std::convert::TryInto;
use crate::domain::models::pokemon::pokemon::Pokemon;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Deserialize, Serialize)]
pub struct PokemonRequest {
pub number: i32,
pub name: String,
pub types: Vec<String>,
}
impl PokemonRequest {
pub fn of(&self) -> Pokemon {
Pokemon::new(
self.number.try_into().unwrap(),
self.name.clone().try_into().unwrap(),
self.types.clone().try_into().unwrap(),
)
}
}
(今記事で扱っているポケモンデータの取得処理ではこれは使いません)。
リクエストのパス、メソッドに応じた処理は以下のように実装できます(/server/src/infra/actix/handler.rs
):
use crate::infra::actix::request::PokemonRequest;
use actix_web::{delete, get, post, put, web, web::Json, HttpResponse, Responder};
use serde::Serialize;
#[derive(Serialize)]
struct ErrorResponse {
message: String,
r#type: String,
}
...(略)
#[get("/pokemon/{number}")]
async fn get_pokemon(
data: web::Data<RequestContext>,
path_params: web::Path<(i32,)>,
) -> impl Responder {
let pokemon_application = PokemonGetService::new(data.pokemon_repository());
let no = path_params.into_inner().0.into();
match pokemon_application.handle(no) {
Ok(pokemon) => HttpResponse::Ok().json(pokemon),
Err(_) => {
let response = ErrorResponse {
message: format!("FAILURE Get Pokemon: no {:?}", no),
r#type: "get_pokemon_list_error".to_string(),
};
HttpResponse::InternalServerError().json(response)
}
}
}
...(略)
エラーについては、メッセージとタイプを定義して、レスポンスで返却できるようにしています。
サーバーの起動処理は以下のように実装できます(/server/src/infra/actix/router.rs
):
use super::handlers;
use crate::{config::CONFIG, domain::models::pokemon::pokemon_repository::PokemonRepository};
use actix_web::{middleware::Logger, App, HttpServer};
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
#[actix_web::main]
pub async fn run() -> std::io::Result<()> {
dotenv::dotenv().ok();
let port = std::env::var("PORT")
.ok()
.and_then(|val| val.parse::<u16>().ok())
.unwrap_or(CONFIG.server_port);
HttpServer::new(|| {
App::new()
.data(RequestContext::new())
.wrap(Logger::default())
.service(handlers::health)
.service(handlers::post_pokemon)
.service(handlers::get_pokemon)
.service(handlers::update_pokemon)
.service(handlers::delete_pokemon)
.service(handlers::get_pokemon_list)
})
.bind(format!("{}:{}", CONFIG.server_address, port))?
.run()
.await
}
#[derive(Clone)]
pub struct RequestContext {
pool: Pool<ConnectionManager<PgConnection>>,
}
impl RequestContext {
pub fn new() -> RequestContext {
let manager = ConnectionManager::<PgConnection>::new(&CONFIG.database_url);
let pool = Pool::builder()
.build(manager)
.expect("Failed to create DB connection pool.");
RequestContext { pool }
}
pub fn pokemon_repository(&self) -> impl PokemonRepository {
use crate::infra::diesel::pokemon_repository::PokemonRepositoryImpl;
PokemonRepositoryImpl {
pool: Box::new(self.pool.to_owned()),
}
}
}
これで、main
関数から run
関数を呼び出すと、Web サーバーが起動します。
補足:環境変数の値取り出しについて
Rust の dotenv を利用し、 .env
ファイルに書かれた環境変数を読み出すようにしていますが、アプリ起動時に環境変数を static 変数 CONFIG
に格納するようにしています。
詳しい方法は(手前味噌ですが)私が過去に書いたメモをご参照ください。
ローカルでの動作確認
DB とのやりとりを含むコードですので、そのまま cargo run
しただけでは動作確認ができません。 DB とのやりとりも含めた動作確認ができる環境を Docker(docker-compose)を用いて構築します。
DB 設定のための Dockerfile
/db/Dockerfile
に DB のための設定を書きます:
ENV LANG ja_JP.utf8
FROM postgres:14-alpine AS db
/db/docker-entrypoint-initdb.d
のディレクトリの下に DB 立ち上げ直後に実行する SQL を置いておきます3。
CREATE ROLE root WITH LOGIN;
CREATE DATABASE root;
API サーバのための Dockerfile
/server/Dockerfile
に API サーバを立ち上げるための設定を書きます:
- マルチステージビルドを利用します。
- 開発環境(
develop-stage
)ではローカル環境でサーバーを稼働させるためのコマンドcargo-watch
、Diesel で PostgreSQL を扱う際に必要なlibpg-dev
、Diesel でマイグレーションなどを行うのに必要なコマンドdiesel_cli
をインストールします。また、本番環境のために成果物をコピーしておきます。 - ビルド環境(
build-stage
)では、開発環境を用いて本番環境向けのビルドを実行します。 - 本番環境(
production-stage
) では、ビルド環境の成果物をコピーしてきて実行します。
- 開発環境(
# 開発環境
FROM rust:1.57.0 as develop-stage
WORKDIR /app
RUN cargo install cargo-watch
RUN apt install -y libpq-dev
RUN cargo install diesel_cli
COPY . .
# ビルド環境
FROM develop-stage as build-stage
RUN update-ca-certificates
RUN cargo build --release
# 本番環境
FROM rust:1.57.0-slim-buster as production-stage
RUN apt-get update
RUN apt-get install libpq-dev -y
COPY --from=build-stage /app/target/release/actix_web_sample .
CMD ["./actix_web_sample"]
docker-compose.yml
の記述
DB とサーバーの両方をローカル環境で立ち上げるために、 docker-composed.yml
を用意します。
- DB は
pg_isready
コマンドでヘルスチェックを行い、正常に起動していることを確認します。 - DB の環境変数は後に紹介する
.env
ファイルの内容に合わせて設定します。 - サーバーは DB が正常に起動したことを確認してから起動します。
- 起動の際、diesel によるマイグレーション(テーブル作成)と
cargo watch
によるアプリ起動を行います。- cargo watch により、ソースの保存を自動で検知して再ビルドしてくれるため、開発が楽になります。
version: '3.7'
services:
server:
build:
context: ./server
target: 'develop-stage'
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
volumes:
- ./server:/app
- cargo-cache:/usr/local/cargo/registry
- target-cache:/app/target
command: /bin/sh -c "diesel setup && cargo watch -x run"
tty: true
db:
build:
context: ./db
ports:
- '5432:5432'
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
volumes:
- ./db/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready", "-U", "${POSTGRES_USER}", "-d", "postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
cargo-cache:
target-cache:
.env
の用意
ローカルで立ち上げる際に利用する環境変数の設定を記述します。
SERVER_PORT=8080
DATABASE_URL=postgres://admin:password@db:5432/postgres
SERVER_ADDRESS=0.0.0.0
ローカル開発環境でのサーバ&DB立ち上げ
docker-compose
を用いてローカル環境を立ち上げます。
前述の通り、開発中は立ち上げっぱなしにしておけば、Rust のコードを変更した場合でも自動的にビルドして反映してくれます。
docker-compose up -d
Heroku へのデプロイ
実装した API サーバーを Heroku にデプロイします。Heroku のアカウントは作成済みで、 Heroku CLI もインストール済みであるとします。
Heroku の設定
コマンドラインから Heroku の設定をします。アプリの追加と、 PostgreSQL のアドオンを追加します。
PostgreSQL のアドオンを追加すると自動的にアプリの環境変数 DATABASE_URL が追加されます。
# Heroku に CLI でログイン(ブラウザが立ち上がるのでログインを行う)
$ heroku login
heroku: Press any key to open up the browser to login or q to exit:
Opening browser to https://cli-auth.heroku.com/auth/cli/browser/XXXXX
Logging in... done
Logged in as hoge@mail.com
heroku create rust-web-api-server-sample
# アプリの作成
$ heroku create rust-web-api-sample
Creating ⬢ rust-web-api-sample... done
https://rust-web-api-sample.herokuapp.com/ | https://git.heroku.com/rust-web-api-sample.git
# アプリに PostgreSQL のアドオン(Hobby プラン)を追加
$ heroku addons:create heroku-postgresql:hobby-dev --app rust-web-api-sample
Creating heroku-postgresql:hobby-dev on ⬢ rust-web-api-sample... free
Database has been created and is available
! This database is empty. If upgrading, you can transfer
! data from another database with pg:copy
Created postgresql-contoured-25420 as DATABASE_URL
Use heroku addons:docs heroku-postgresql to view documentation
GitHub Actions の設定
ここから、手でコマンドをポチポチ打ってデプロイをしても良いのですが、せっかくなので、 GitHub Action でデプロイを自動化します。GitHub のリポジトリに push したら Heroku にデプロイするように設定します。
以下のように .github/workflows/deploy-to-heroku.yml
を作成します:
- AkhileshNS/heroku-deploy を利用してデプロイします。
- Heroku へのデプロイは Docker を利用する設定にしています。
- あらかじめ、リポジトリのシークレットに以下を設定しておきます:
-
HEROKU_API_KEY
: Heroku の Account Settings 画面 -> API Key で取得できる API キーを指定 -
HEROKU_EMAIL
: Heroku のアカウントで使用しているメールアドレスを指定
-
- 起動したサーバーの IP アドレスは環境変数で設定します。
- 環境変数の設定は
heroku config:set
コマンドで行います。- Github Actions 内で heroku コマンドを利用するためには Heroku へのログイン処理を行う必要があるのですが、heroku-deploy を一度でも使うことで Heroku へのログインを行なってくれます。
- このツールを使ってログイン処理だけ行うということもできます(
justlogin
オプション)
- ヘルスチェックも入れています。
name: Deploy to heroku
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: akhileshns/heroku-deploy@v3.12.12
with:
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
heroku_app_name: "rust-web-api-server-sample"
heroku_email: ${{secrets.HEROKU_EMAIL}}
buildpack: "https://github.com/emk/heroku-buildpack-rust.git"
usedocker: true
appdir: server
healthcheck: "https://rust-web-api-server-sample.herokuapp.com/health"
checkstring: "Ok"
rollbackonhealthcheckfailed: true
- run: |
heroku config:set SERVER_ADDRESS=0.0.0.0
これでデプロイが成功したら、 https://rust-web-api-server-sample.herokuapp.com/
が利用できるようになります。
補足:Heroku へのデプロイにあたってのポート指定の環境変数について
Heroku でのサーバー起動の際のポートの指定は、環境変数 PORT
の値を使用するようにすればOKですので、以下のように書けばOKです。
let port = std::env::var("PORT")
.ok()
.and_then(|val| val.parse::<u16>().ok())
.unwrap_or(CONFIG.server_port)
今回は、ローカルでの起動も考慮に入れているので、環境変数 PORT
の指定がなければ、 .env
ファイルの SERVER_PORT
の値を利用するという設定にしています。
まとめ
- 一から Web API サーバーを初めて実装してみました。
- アーキテクチャについては色々悩みましたが、 SOLID 原則を満たし、かつ、実装の方法が分かりやすかったオニオンアーキテクチャを採用しました。
- ローカル開発からデプロイまで、 Docker を利用することでラクに環境構築をすることができました。
- 今後はこの方法・実装方法をテンプレートとして利用できればと思います。
追記1: 「これって DDD やなくない?」(2022/01/25)
Twitter で下記のコメントいただきました:
このような実装を基盤にドメイン知識(制約や振る舞い)を反映していくか、そもそもCRUDしかありませんというならDDDとか言わずにDOAに振り切ったほうがシンプルな設計になると思います。中途半端な状況が余計混乱を生むというのは経験があります。
— かとじゅん (@j5ik2o) January 25, 2022
ご指摘の通り、タイトルには「DDD を実践」と書いたのですが、ドメインルールの実装がまだまだ甘いのでこれは DDD ではないです。本文で
今回は、ドメインサービスに相当するものがありません
と書いた通りです。
ユースケースやビジネスルールを明確に定義することが実際の場面では必要になってくると思います。
追記2: 似たような実践例(2022/01/25)
似たような実践例がついこないだのアドベントカレンダーに投稿されていたということをお聞きしましたので紹介します:
Rust の新しい HTTP サーバーのクレート Axum をフルに活用してサーバーサイドアプリケーション開発をしてみる - Don't Repeat Yourself
Web アプリフレームワークには新しめの Axum を使ってたり、アーキテクチャとしては Explicit Architecture を採用しているなどの違いはあるものの、かなり参考になる記事でした。
これを参考に、リファクタリングしてみようかと思います。
参考資料
-
実践Rust入門 [言語仕様から開発手法まで]
- Rust に関する基礎知識、Web サーバーの実装方法など、基本的なこと全般的に参考にしています。
- 私が過去に書いた記事:これまでに色々調べたり試したことを活用しました:
-
Clean Architecture 達人に学ぶソフトウェアの構造と設計
- ソフトウェアのアーキテクチャに関する考え方を学びました。
- オニオンアーキテクチャの元ネタ(Jeffrey Palermo 氏のブログ)
-
クリーンアーキわからんかった人のためのクリーンじゃないけどクリーンみたいなオニオンに見せかけたSOLIDの話
- クリーンアーキテクチャの実装に挫折したときに参考にしました。
-
イラストで理解するSOLID原則
- SOLID 原則の理解のために参考にしました。
-
[DDD]ドメイン駆動 + オニオンアーキテクチャ概略
- クリーン、ヘキサゴナル、オニオンの各アーキテクチャの位置付けの整理に参考にしました。
-
実践ドメイン駆動設計
- DDD について初めて学んだ本です。
-
ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本
- 上記の本で理解しにくかった箇所の理解を深めるのに大変役立ちました。
- 今回、実装したもののアイデアを参考にした記事はこちらです。ヘキサゴナルアーキテクチャで Web API サーバーを実装しています;
- Hexagonal architecture in Rust #1 - Domain
- Hexagonal architecture in Rust #2 - In-memory repository
- Hexagonal architecture in Rust #2 - In-memory repository
- Hexagonal architecture in Rust #3 - HTTP API
- Hexagonal architecture in Rust #4 - Refactoring
- Hexagonal architecture in Rust #5 - Remaining use-cases
- Hexagonal architecture in Rust #7 - Long-lived repositories
-
DDDのパターンをRustで表現する ~ Value Object編 ~
- 値オブジェクトの実装方法について参考にしました。
-
Rustでクリーンアーキテクチャを組む時DIするサンプルコード
- DI を実現するための具体的な書き方について参考にしました。
-
RustでたのしいDI オニオンアーキテクチャとDependency Injectionをいい感じに実現したい
- タイトル的にやりたいことがドンピシャでした。
- DI の実装パターンについて参考にしました。結局、今回はシンプルな「1. Static Dispatch」を採用しました。
-
Docker によるデプロイ - Heroku
- Heroku へのデプロイ方法に関するマニュアルです。
- 初めのうちは手元でこれらのコマンドを実行してデプロイをしましたが、のちに下の記事を参考に GitHubActions に移行しました。
-
Deploy to Heroku · Actions · GitHub Marketplace · GitHub
- GitHub Actions で Heroku にデプロイするためのプラグインのマニュアルです。
-
Github ActionsでGolangプロジェクトをHerokuに自動デプロイしてみた
- 上記プラグインの利用に際して参考にしました。
-
How to deploy Rust on Heroku (with Docker)
- プラグインを使わずにデプロイする方法としてはこちらが使えるかなと考えました。
-
「忠実な」の主な対象はユースケース層を指します。あちこちで見られるユースケース層の実装を見ると、ユースケースを「Output/Input Port」「Interactor」に分割して実装している(もしくは、名前を明示的にそうしている)ものがあまり見られなかったので、敢えて明示的にこれらを分割して実装しようとしてました。 ↩
-
もしかすると、上手いやり方が存在するのかもしれないのですが、調べきれていない/思いついていないです。 ↩
-
今回は、DB 立ち上げ時にロール root とデータベース root がないよ、というエラーメッセージが出るのに対応するための SQL を置いてます。なぜこのエラーが出るのかはよく分かってません。 ↩