Rustでクリーンアーキテクチャやってみた
Rustを勉強したくて色々とググってたら、以下の記事とサンプルコードを拝読させていただき、参考にさせていただきながら模写させていただいた。
- https://blog.foresta.me/posts/rust-layered-architecture/
- https://blog.j5ik2o.me/entry/2021/12/08/000000
とても勉強になりました。ありがとうございます!!
GitHubは、 https://github.com/yoshiyoshifujii/rust-api-architecture-sample です。
first commit
Cargoはいいぞって聞いてましたが、とてもシンプルですね。
ひとまず、どのdependenciesで何ができるのかとかよく分かっていませんが、参考にさせていただいたサイトの内容を見つつ、最新バージョンを指定するようにしました。
エディタは、IntelliJ IDEAにRustプラグインを入れているのですが、Cargo.tomlで、dependenciesを編集していると、補完が効いてバージョンも引いてきてくれるので、すごい楽に入力できました。
すごいなー
...
[dependencies]
actix-web = "3.3.3"
futures = "0.3.19"
serde = "1.0.133"
serde_derive = "1.0.133"
serde_json = "1.0.75"
dotenv = "0.15.0"
chrono = "0.4.19"
failure = "0.1.8"
ulid = "0.5.0"
[dependencies.diesel]
features = ["mysql", "chrono", "r2d2"]
version = "1.4.8"
domains
クリーンアーキテクチャな感じでいくなら、まずは、方針を書くよねってことで、そこから書き始めてみました。
ドメインのあたりは、ユースケースを書いてドメイン書いてっていうループになってきますが、ユースケースはTDDだとテストにまずは書いて、そこからドメインをどう使いたいかとか考えてってやっていくあたりをまず書いていくなら、domainsのあたりに書くよねってところです。
main.rs
に mod domains;
って書いて、IDEAで補完すると、 domains/mod.rs
ファイルを作ってくれるので、 pub mod documents;
って書いて、補完を効かせて…
って感じで、簡単にモジュール構成を作っていけるのが、すごい楽しいです。
DocumentId
Idは、 Ulid にしてみました。
use ulid::Ulid;
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct DocumentId {
pub value: String,
}
impl DocumentId {
pub fn new() -> Self {
let id = Ulid::new();
Self {
value: id.to_string(),
}
}
}
domainモジュールは、できるだけ、外部ライブラリへの依存を少なくしたいってなったとき、Scalaだとsbtのmulti projectでこのあたりを制御したりするんですが、Rustの場合はどうするのかな…
あー https://doc.rust-jp.rs/book-ja/ch14-03-cargo-workspaces.html このあたりにありそう。
厳密に依存の方向性を強制させるのであれば↑上記のあたりも考慮していくと良さそうだなー
DocumentTitle
特に操作を持たないのですが、titleの事前条件を満たしていないと、インスタンスにできないような処理を書いてみました。
#[derive(Debug, Clone)]
pub struct DocumentTitle {
pub value: String,
}
impl DocumentTitle {
pub fn new(value: String) -> Self {
if value.is_empty() {
panic!("Document title is not empty.")
}
if value.len() > 120 {
panic!("Document title length is over 120.")
}
Self { value }
}
}
Rustは、testもさくっと書けて楽しいですね。
#[cfg(test)]
mod document_title_tests {
use std::panic;
use crate::domains::documents::DocumentTitle;
#[test]
fn success_document_title_new() {
let value = String::from("new title");
let actual = DocumentTitle::new(value.clone());
assert_eq!(actual.value, value);
}
#[test]
fn panic_document_title_input_to_empty_string() {
let value = String::from("");
let actual = panic::catch_unwind(|| DocumentTitle::new(value));
assert!(actual.is_err());
}
#[test]
fn panic_document_title_input_to_over_length() {
let value = String::from("0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890");
let actual = panic::catch_unwind(|| DocumentTitle::new(value));
assert!(actual.is_err());
}
}
domain層のあたりは、こんな感じでやってけばいいんだなーってことが分かりました。
domainにVOを書いていくのとか、testをさくさくっと書いていくあたりの書きっぷりが気持ちいいのは、とても良いですねー
usecases
次は、domainsを使うあたりのusecasesを書いてみます。
main.rs
に mod usecases;
って書いて、IDEAでごにょって…って感じで、src/usecases/documents.rs
を作ります。
ここもさくさくっといけて気持ちいいです。
Clean Architecture の書籍の中で、ボブおじさんが、ソフトウェアアプリケーションのアーキテクチャもユースケースについて叫ぶべきっておっしゃってたので、叫ぶような感じで書いていきたいところ。
叫ぶっていうあたりは、今回のサンプルではできていないけど、ここで叫んでいけば良いよねっていうのは、なんとなく見えた…か…な?
#[derive(Debug, Clone)]
pub struct PostDocumentInput {
title: DocumentTitle,
body: DocumentBody,
}
impl PostDocumentInput {
pub fn new(title: DocumentTitle, body: DocumentBody) -> Self {
Self { title, body }
}
}
#[derive(Debug, Clone)]
pub struct PostDocumentOutput {
pub id: DocumentId,
}
impl PostDocumentOutput {
pub fn new(id: DocumentId) -> Self {
Self { id }
}
}
pub fn post_document(
repository: &mut impl DocumentRepository,
input: &PostDocumentInput,
) -> Result<PostDocumentOutput, Error> {
let document = Document::create(input.title.to_owned(), input.body.to_owned());
let result = repository.insert(&document);
result.map(|_| PostDocumentOutput::new(document.id))
}
I/O用のstructを用意して、 post_document
っていうユースケースがあるんだよ!って感じ。
Inputは何で、Outputはなんだよってあたりか。
今回は、 src/usecases/document.rs
の1ファイルに書いているけど、さらに mod
を切って、 src/usecases/documents/post_document.ts
ってしていけば、ユースケースの一覧はしやすそう。
repositories
src/repositories/documents.ts
を作った。
ここは、traitを書いておくだけにして、implは、 interface_adaptors
でMySQL用とか、 usecases
のテストでは、OnMemory用とかを impl するようにした。
pub trait DocumentRepository {
fn insert(&mut self, document: &Document) -> Result<(), Error>;
}
test用に、OnMemory版のRepository
pub struct DocumentRepositoryImplOnMemory {
pool: HashMap<DocumentId, Vec<Document>>,
}
impl DocumentRepository for DocumentRepositoryImplOnMemory {
fn insert(&mut self, document: &Document) -> Result<(), Error> {
let _ = &self
.pool
.entry(document.id.clone())
.or_insert_with(|| vec![])
.push(document.clone());
Ok(())
}
}
MySQL版のRepository
pub struct DocumentRepositoryImplOnMySQL {
pub pool: Box<Pool<ConnectionManager<MysqlConnection>>>,
}
impl DocumentRepository for DocumentRepositoryImplOnMySQL {
fn insert(&mut self, document: &Document) -> Result<(), Error> {
use super::super::interface_adaptors::databases::schema::documents::dsl;
let entity = DocumentEntity::from(document);
let conn = self.pool.get().unwrap();
diesel::insert_into(dsl::documents)
.values(entity)
.execute(&conn)?;
Ok(())
}
}
その他
MySQLとのやりとりは、ORMの、 https://diesel.rs/ を使ったり、HTTP Serverのあたりは、 https://actix.rs/ を使っている。
が、このあたりは、それぞれのライブラリをどう使うかってことなので、詳細の話になってくるなーって感じ。
Clean Architectureの考えで、個人的に重要だなーと考えているのは、方針の実装に詳細を持ち込まないってあたりだと思っている。
今回、Rustでレイヤー化アーキテクチャを実装してみて、方針を先に詰めて、詳細はあとから作り込んでいけそうな気配を感じれて、とても良かったです。
Discussion