こんにちはかとじゅんです。
この記事は、ドメイン駆動設計 Advent Calendar 2020の23日目の記事です1。DDDというよりRustの記事になってしまった…。
Rustの勉強を始めたのは2017年あたりと古いのですがなかなか身が入らず、本腰入れたのは今年の11月ぐらいでした(遅ッ。Scalaで実装してたライブラリをRustに書き換えたおかげでようやく開眼しました2。
というわけで、今回は完全趣味の領域であるRustでドメインモデルをどう実装すればいいのかについて、僕の意見やアイデアなど雑にまとめてみたいと思います。まぁこれについてもいろんな観点がありますが、値オブジェクトやエンティティを実装するならという観点です。
※あ、Rustの所有権システムなどの言語仕様については細かく触れないので、各位適宜正しい情報源を参照してください。
構造体とメソッド
見慣れた(見飽きた)銀行口座オブジェクトを例にモデルの実装を考えてみましょう。銀行口座へ入金したり口座から出金したりする例です。
Rustにはclass
キーワードがありません。が、struct
キーワードを使う構造体とメソッド(関数)を使えば、いわゆるオブジェクト指向言語でいうクラスを実装することが可能です。
/// deriveは指定したtraitのための実装を自動生成する /// ここではデバッグ出力のためのDebug, インスタンスの複製のためのCloneが指定されている /// pubはpublic、pubが付かないものはprivate #[derive(Debug, Clone)] pub struct BankAccount { id: BankAccountId, user_account_id: UserAccountId, balance: Money, } impl BankAccount { /// コンストラクタは慣習的に作る。名前はnewなど。 /// 第一引数がself以外のものは関連関数(Javaでいうクラスメソッド) pub fn new(id: BankAccountId, user_account_id: UserAccountId, balance: Money) -> Self { Self { id, user_account_id, balance, } } /// 残高の参照 /// pub(crate)はクレート内だけpublic /// 第一引数がselfの場合はメソッド扱い(Javaでいうインスタンスメソッド) pub(crate) fn balance(&self) -> &Money { &self.balance } /// 口座への入金 /// ResultはEither型相当の型。成功もしくは失敗の値を保持できます pub fn deposit(mut self, amount: Money) -> Result<BankAccount, MoneyError> { self.balance = self.balance.add(amount)?; Ok(self) } /// 口座からの出金 pub fn withdraw(mut self, amount: Money) -> Result<BankAccount, MoneyError> { self.balance = self.balance.subtract(amount)?; Ok(self) } }
書き方が違うだけでそのままクラスの実装相当にみえます。struct
で属性を定義し、impl
でメソッドを定義します。
お金を口座に入金する際は以下のように書けます。レシーバーを先に記述できるので、Javaなどのオブジェクト指向言語と変わりない雰囲気ですね。やったぜ。
let ba1 = BankAccount::new( BankAccountId::new(1), UserAccountId::new(1), Money::zero(CurrencyCode::JPY), ); let new_ba1 = ba1.deposit(Money::yens_i32(1000)).unwrap(); // 雑にunwrapしてますがプロダクトコードではやらないように! println!("{:?}", new_ba1);
struct
とimpl
の2つのコードブロックに分かれますが、同じ箇所に書けばJavaなどのクラス定義と変わらず、ビジネスロジックをモジュールに凝集させることができます。
というか、オブジェクト指向のクラスは、その昔C言語の構造体とthisを受け取る関数によって実現していたので、原点に戻った感覚です。
継承と多相性
OOP言語には必ずある継承はRustにはありません。継承がないので必然的にポリモーフィズムという概念もありません。ポリモーフィズムで実現したいことは「呼び出し側のコードの共通化」でしたが、Rustではそれをアドホック多相とパラメータ多相によって解決しています。具体的にはtrait
3とジェネリクスを使うことになります。
以下はBankAccount
で利用しているお金オブジェクト(Money)。お金はお金を足してお金にできます(何を言ってるのか…うっ…)。要はモノイドのような性質を持つと考えることができます。let money3: Money = money1 + money2;
のように加算できるイメージです。Rustではstd::ops::Add
のadd
メソッドを実装すると、+
演算子として利用できます。
#[derive(Debug, Clone, Copy, PartialEq)] pub struct Money { pub amount: Decimal, pub currency: CurrencyCode, } impl std::ops::Add for Money { type Output = Money; fn add(mut self, rhs: Self) -> Self::Output { if self.currency != rhs.currency { panic!("Invalid currency: self = {:?}, rhs = {:?}", self, rhs) } else { self.amount += rhs.amount; self } } }
型引数であるRhs
のデフォルトはSelf
です。Output
は戻り値の型を指定できます4。
通貨単位が異なる場合にpanic
していますが、Rust版のEither
相当であるResult
のErr
を返したい場合はOutput
を変更するとよいでしょう。
impl Add for Money { type Output = Result<Money, MoneyError>; fn add(mut self, rhs: Self) -> Self::Output { if self.currency != rhs.currency { Err(MoneyError::NotSameCurrencyError) } else { self.amount += rhs.amount; Ok(self) } } }
このメソッドをOOPの多態のように、再利用しやすいコードにするには以下のadd
関数のようにします。ジェネリックな型引数T
にtrait
名を指定しています。これは指定したtrait
を実装したT
型という意味です。
trait
は型ではないので、T
の部分には指定できません。std::ops::Add
の性質を満たす型Tとして引数を受け取ります。
#[test] fn test_add() { let m1 = Money::from((1u32, CurrencyCode::USD)); let m2 = Money::from((2u32, CurrencyCode::USD)); fn add<T: Add<Output=T>>(v1: T, v2: T) -> T { v1 + v2 } let m3 = add(m1, m2); println!("{:?}", m3); }
列挙型によるサブタイピング
Rustには継承はありませんが、列挙型(enum
キーワード)を使って実質的にサブタイピングが可能です。
Javaの列挙型は、列挙される値は同じデータ構造にしなければなりませんでした。Rustでは列挙される値ごとにデータ構造を変えることもできます。
習作目的で雑に単方向リストを作ってみました。Rustのenum
では、List<A>
の値としてそれぞれ構造の異なるNil
, Cons
を定義できます。
#[derive(Debug, Clone, PartialEq, Eq)] pub enum List<A> { Nil, Cons { head: A, tail: Rc<List<A>> }, } impl<A> Stack<A> for List<A> { // ... fn head(&self) -> Result<&A, StackError> { match self { List::Nil => Err(StackError::NoSuchElementError), List::Cons { head: ref value, .. } => Ok(value), } } // ... }
不変と可変の使い分け
値オブジェクトは様々ロジックから共有されるので、不変オブジェクトにするとよいというのが定石です。今どきの言語ではエンティティも不変することが多いですね。
さきほどのadd
メソッドの引数はmut self
でした。メソッドの実装ではself.amount += rhs.amount;
のようにミューテーションを起こします。なぜself
を破壊するのか?と思ってしまうのですが、少し落ち着きましょう。Rustでもデフォルトは不変ですが、このメソッド内だけself
を可変にしています。
impl std::ops::Add for Money { type Output = Money; fn add(mut self, rhs: Self) -> Self::Output { if self.currency != rhs.currency { panic!("Invalid currency: self = {:?}, rhs = {:?}", self, rhs) } else { self.amount += rhs.amount; self } } }
Rustの所有権システムでは、関数の引数に値を渡す際も所有権の移動が起こります。以下のコードであれば、m1
,m2
がメソッドの引数のために複製されてm1
,m2
が使えなくなります。
let m3 = m1 + m2; // let m3 = m1.add(m2); // let m3 = Add::add(m1, m2); // 以降ではm1, m2は使えなくなる
ちなみに、所有権システムの解説が一番わかりやすかった本は以下でした。
add
の外にあるm1
は不変であっても、メソッド内部では複製を伴う所有権の移動後にmut self
として可変に切り替わります。このメソッド内だけでインスタンスが可変という意味になります。mut self
を戻り値として返していますがlet m3 = ...
なので不変のようにみえます。この場合戻り値の所有権が移動するという考え方になるのかなと思います。
まぁ普通にインスタンスを新たに生成してもよいでしょう。ただ、この場合、引数と戻り値用にインスタンスの複製が2回起こりそうですね。
impl std::ops::Add for Money { type Output = Money; fn add(self, rhs: Self) -> Self::Output { if self.currency != rhs.currency { panic!("Invalid currency!!!") } else { Self { amount: self.amount + rhs.amount, currency: self.currency, } /// もしくは /// let mut result = self.clone(); /// result.amount += rhs.amount; /// result } } }
メソッド単位でインスタンスの不変・可変を使い分けることができるようです。既存の言語ではこれは難しいですよね。なんだろう、うまく説明できないのですが、所有権の移動によって可変となる範囲が限定されるので、安全でかつ効率的ですね。すごいです…。この発想はなかった。
ということで、不変性を基本として、狭いスコープでは可変性をうまく使って性能を最適化できそうですね。
コンテキスト依存の振る舞いを解決する
DCIでは文脈(Context)5に依存しないデータ(Data)と文脈に依存するロール(Interaction)に分けてモデリングします。データの部分をドメインオブジェクトとして、文脈というより場面によってロールを切り替えたいケースで使えます。
例えば、口座送金のコンテキストでは、データとしての銀行口座には、送金先口座と送金元口座のロールがあります。これをRustで素直に実装すると以下のようになると思います。
まずは送金先口座と送金元口座のロールをtrait
で定義します。
/// ロールトレイト。 mod roles { use crate::bank_account::BankAccount; use crate::money::{Money, MoneyError}; /// 送金先のロール。 pub trait ReceiveRole { fn on_receive(self, money: Money, from: BankAccount) -> Result<Self, MoneyError> where Self: Sized; } /// 送金元のロール。 pub trait SenderRole<T> { fn send(self, money: Money, to: T) -> Result<(Self, T), MoneyError> where Self: Sized; } }
BankAccount
のための実装は以下。データとしての銀行口座の振る舞いを使ってロールを実装するだけです。
余談ですが、deposit
はResult
型を返しますが、?
をつけることで正常ケースだけを記述できます。Err
が発生した場合はその値で早期リターンしてくれます。Result
を返す関数内でしか使えないのですが、これがコーディングのリズムを崩さない感じで軽快にコードが書けますね。
/// ロールの実装。 mod roles_impl { use crate::{BankAccount, Money, MoneyError}; use crate::bank_account::roles::{ReceiveRole, SenderRole}; /// 送金先のロール。 /// _fromは便宜上未使用。 impl ReceiveRole for BankAccount { fn on_receive(self, money: Money, _from: BankAccount) -> Result<Self, MoneyError> where Self: Sized, { let new_state = self.deposit(money)?; Ok(new_state) } } /// 送金元のロール。 impl<T: ReceiveRole> SenderRole<T> for BankAccount { fn send(self, money: Money, to: T) -> Result<(Self, T), MoneyError> where Self: Sized, { let new_from = self.withdraw(money.clone())?; let new_to = to.on_receive(money, new_from.clone())?; Ok((new_from, new_to)) } } }
先ほども似たような例を示しましたが、口座間送金のコンテキストに渡す送金元と送金先はジェネリックになります。ゆえにBankAccountとは非依存になります。継承脳の人はT
型の範囲を指定したくなるかもですが、Rustでは継承がないのでそういったことはできません。なので、指定したtrait
を実装したT
型という考え方になります。
/// 送金コンテキスト /// BankAccountには非依存。送金できるT型として定義する。 mod context { use crate::{Money, MoneyError}; use crate::bank_account::roles::{ReceiveRole, SenderRole}; pub struct TransferContext<T: ReceiveRole, F: SenderRole<T>> { from: F, to: T, } impl<T: ReceiveRole, F: SenderRole<T>> TransferContext<T, F> { pub fn new(from: F, to: T) -> Self { Self { from, to } } pub fn transfer(self, money: Money) -> Result<(F, T), MoneyError> { self.from.send(money, self.to) } } }
コンテキストに銀行口座オブジェクトを渡して実行すれば、送金が可能です。コンテキストはある意味ドメインサービスです。ドメインサービスはどうしても手続き的な表現になりがちですが、このようなアプローチを利用すればドメインの表現力や相互作用を少しでも犠牲にしないで実装できるかもしれません。
let ba1 = BankAccount::new( BankAccountId::new(1), UserAccountId::new(1), Money::zero(CurrencyCode::JPY), ); let new_ba1 = ba1.deposit(Money::yens_i32(1000)).unwrap(); let ba2 = BankAccount::new( BankAccountId::new(2), UserAccountId::new(1), Money::zero(CurrencyCode::JPY), ); use crate::bank_account::context::TransferContext; let context: TransferContext<BankAccount, BankAccount> = TransferContext::new(new_ba1, ba2); let (from, to) = context.transfer(Money::yens_i32(10)).unwrap(); println!("from = {:?}, to = {:?}", from, to);
まとめ
ということで、まだRust初学者なので間違ったことを書いているかもしれません。間違いに気がついたら教えてください。
所有権についても以下がようやく理解できたので、Scalaと同じ速度でRustのコードを書けそうです。CLI系のツールとかGoじゃなくてRustで書いてみたい。
fn foo(b: &Bar) { let b = b.clone(); … } は fn foo(b: Bar) { … } に書き換えるべき。呼び出し側がCloneするかどうか決める。前者はその柔軟性がない(もちろんbをcloneしないなら借用のままでいいわけですが)というやつ、最近理解できるようになった。
— かとじゅん (@j5ik2o) 2020年12月21日
ここ最近プライベートでRustばかり書いてますが、今のところ所感的には以下。
- Rustのプログラミングになれるには、継承の考え方を邪魔なので捨てたほうがよい。
trait
はHaskellの型クラスそのものやんという感想(厳密には違うんだろうけど) std
ライブラリのAPIはほぼプリミティブなので、高レベルな機能がほしければ配布されているクレートを探そう…。標準APIが充実しているJVM言語やっている人がギャップを感じそうなところ。逆に言えば、std
でサポートしない機能は、車輪を再実装してコミュニティに貢献できるチャンスもある。map
/fliter
/fold
などのStream API的なものあって、Scalaをやっている人でも十分に戦っていける。というか、RustにはやはりScalaとかHaskellっぽいものを感じるのでマルチパラダイムな言語なんだと思った- GATsがNightlyでサポートされたので、Monadなどの実装も楽しくなった
というわけで(どういうわけだ…)、Rustでもモデル駆動設計は十分に可能だと思います(無理矢理まとめた感がでている…)。
今のところ、Webフレームワークとか触ってないですが、今後継続的に学んでいきたいと思っています。Rust部という社内の部活で新規参加メンバーが増えているので、仕事で使う日もそう遠くなさそうな気がしてます!
年末年始にRustで何作ろうかなー。ということで、ごきげんよう。