Rustとソフトウェアアーキテクチャの親和性について
この記事は株式会社LabBase テックカレンダー Advent Calendar 2022の11日目です。前日の記事はこちら。
はじめに
こんにちは、株式会社LabBaseでエンジニアをしている上久保です。業務ではフロントエンド開発を担当することが多いのですが、「バックエンドやりたいです!」と周囲に愚痴るアピールしまくったことで今年の後半からは少しずつバックエンド開発でRustを書かせてもらえることが増えてきました。私のわがままを聞いてくれる職場の上司・同僚には大変感謝しています。
この記事では私がRustでバックエンド開発をしていて、特にソフトウェアアーキテクチャを実装する際にRust(注1)が優れているなと感じた点を紹介したいと思います。ちなみに自分はRust歴半年でRustでのバックエンド開発は今回が初めてです。
Rustの難しさには意味がある
Rustは2010年に誕生した比較的新しい言語です。C言語とほぼ同じ速度性能を誇ることや、既に存在しているプログラミング言語の仕組みや文法などの良いところを盛んに取り入れていることから、近年エンジニアからの人気が高くなっています。
一方でRustは所有権やトレイトなど簡単ではない概念が存在し、プログラミング初学者〜初心者にとっては学習コストが高く向いていないとも言われています。しかし所有権の概念は煩雑で速度低下の一因となるGCからの脱却を実現し、高度なトレイト・ジェネリクスはRustの大きなメリットであるゼロコスト抽象化には欠かせない機能です。
確かにPythonのように人間が直感的に考えた内容をコードできる言語はその点で優れていて人気が出るのも当然だと思いますが、Rustは自由で直感的な記述方法をある程度犠牲にしてパフォーマンスを出せることに良さがあるので結局はみんな違ってみんな良い、適材適所で要件に合う言語選択が大事だなと自分の中では結論づけています。
ソフトウェアアーキテクチャの本質
自分にとって2022年はソフトウェアアーキテクチャの重要性を噛み締めた一年でした。それはあるプロジェクトに途中から参入し、プロダクトを引き継いで機能追加・バグ修正などを対応した時のことで、そのプロダクトはアーキテクチャやコーディング規約などが十分に洗練されておらず、実装が密結合になっていて修正がとても大変なものになっていました。結局開発しながらリファクタリングをすることで少しずつ見通しの良いコードに直していくことが出来ました。
アーキテクチャを意識すると一見冗長な記述が増えて開発速度が落ちるように思いますが、かの有名なClean Architecture(注2)にもあるようにコードの変更コストをなるべく下げ、技術的な負債を将来に積み残さないように常に気をつけて開発することが開発速度を落とさないために不可欠である、そのことをよく理解できた経験だったなと感じています。
自分はアークテクチャの本質は制限を加え、コードの冗長性を持たせることで可読性や保守性などのメリットを享受する点にあると考えています。このことは文法や概念が複雑だけれどもメリットのある先に触れたRustとよく似ており、両者には親和性があるのでは?と感じ始めました。
ではここからは、私がバックエンド開発をする中で感じた、アークテクチャの実現がRustだとやりやすい点をいくつかご紹介したいと思います。
アーキテクチャ内の依存関係の実現
こちらはあるRustのプロダクトのディレクトリ構造です。serverディレクトリをWorkspaceとして捉え、その配下のpresentation, application, domain, infrastructureディレクトリをそれぞれcrateとして実装しています。application層やdomain層をcrateとして実装することで、ビルド時に変更差分がないcrateは再コンパイルしないのでビルド時間の短縮になります。
.
├── docker # ローカル開発用のDBコンテナ設定
└── server
├── application
├── domain
├── external # 各ディレクトリで使用する外部crateの置き場
├── infrastructure
├── migration
├── presentation
├── tests
└── target
また、各層間の依存関係は各crateのCargo.tomlに[dependencies]として定義出来ます。これによってアーキテクチャ内の依存関係の方向性に制限を与えることが出来ます。
# Cargo.toml
[package]
name = "domain"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
infrastructure = { path = "../infrastructure" }
external = { path = "../external" }
さらに、crateの内部のmodule単位でもprivate/publicの制限を定義出来ます。Rustはデフォルトで非公開であり、publicに指定しても公開範囲がディレクトリの1階層上までに制限されているため、意図しない依存関係が生まれてバグの原因に繋がることが少ないのも良い特徴だと思います。
依存性注入の実現
アーキテクチャで依存関係の方向を逆転させるためにはDI(依存性注入)をすれば良く、Rustの実装例は以下のようになります。
こちらはInfrastructure層のDBにアクセスするdriverの実装ですが、driverはDB接続を行うclientを持っています。このclientはRustのスマートポインタであるBox<>を用いて実装されています。コンパイル時にサイズが定まらないオブジェクトはBox<>を用いてheap領域に格納することでポインタでアクセスすることが出来ます。
StringやVecなどスマートポインタは自分もちゃんと理解できていない他にもたくさんあるので詳細は省きますが、適切な選択をすることで安全かつ効率の良くメモリを使い、Rustのパフォーマンス向上を期待できます。自分も次の開発の機会までにちゃんと勉強します…
#[derive(new)]
pub struct UserDriver {
client: Box<dyn DBClient + Sync + Send>,
}
#[async_trait]
impl GetUser for UserDriver {
async fn get_user_by_id(
&self,
request: &GetUserDriverRequest,
) -> Result<GetUserDriverResponse> {
/* ... */
}
}
知識集約の実現
アーキテクチャを意識する中で自分は、関連のあるもの同士はできるだけ同じところに記述されていて、そうでないものは離れた場所に存在するようにコードを書くことが特に大切だなと感じました。
以下のコードはユーザー名のドメインモデルとテストコードの簡単な実装例です。Rustではユニットテストを実装と同じファイルに記述することができ、ユーザ名に関する知識が全てこのファイルに集約させることができて見通しが良くなります。
例えばチームで開発している時に自分以外の人が実装した部分がこのように集約されていれば、あちこち調べる必要が無くなり仕様の理解にかける時間を短くすることができるでしょう。(注3)
use external::{
anyhow::{anyhow, Result},
getset::Getters,
};
#[derive(Debug, Getters)]
#[getset(get = "pub")]
pub struct UserName {
value: String,
}
const MAX_LENGTH: usize = 255;
impl UserName {
pub fn new(value: &str) -> Result<Self> {
if value == "" {
return Err(anyhow!("user_name is required."));
}
if value.chars().count() > MAX_LENGTH {
return Err(anyhow!(
"{}: user_name must be shorter than {} characters.",
value,
MAX_LENGTH
));
}
Ok(Self {
value: value.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_user_name() {
let user_name = UserName::new("田中太郎");
assert!(user_name.is_ok());
assert_eq!(user_name.unwrap().value(), "田中太郎");
}
#[test]
fn user_name_is_required() {
let user_name = UserName::new("");
assert!(user_name.is_err());
assert_eq!(
user_name.unwrap_err().to_string(),
"user_name is required."
);
}
#[test]
fn user_name_is_too_long() {
let value = "a".repeat(256);
let user_name = UserName::new(&value);
assert!(user_name.is_err());
assert_eq!(
user_name.unwrap_err().to_string(),
format!(
"{}: user_name must be shorter than {} characters.",
&value, MAX_LENGTH
)
);
}
}
さいごに
色々書きましたが、Rustの持つ書き方の複雑さや慣れない概念にはちゃんと存在理由があり、それを理解してアーキテクチャを設計し開発することで大きなメリットが得られるという話でした。Rustとアーキテクチャの魅力が少しでも伝われば嬉しいです。みんなもRust書きましょう!
弊社ではRustが書ける or 書いてみたいエンジニアを募集しています。興味がある方は是非こちらからお願いします。
以上です。
明日はスクラムマスター提督さんの記事です!お楽しみに!
注1)使用しているRustのバージョンは1.65.0です。
注2)Clean Architecture 達人に学ぶソフトウェアの構造と設計
注3)さらにPdMなどの非エンジニアがドメインモデルだけでもRustのコードが読めれば仕様書のメンテナンスが不要になって幸せだなと思ったりします。
この記事が気に入ったらサポートをしてみませんか?