DCIアーキテクチャによるWebアプリケーションの実装:ドメインレイヤ
DCIアーキテクチャを用いて、割り勘の計算を行うWebアプリケーションを実装する。
導入
前回のエントリでは、DCIアーキテクチャの思想に基づき、試験的にドメインレイヤを実装しました。今回からはその発展編として、DCIアーキテクチャによるWebアプリケーションについて、数回に渡り検討していきます。言語はScala、WebフレームワークとしてはWicketを使用します。なお、データは今のところメモリに保持することとし、永続化層の実装については後日検討することにします。初回の今回はドメイン層をターゲットとして設計について考えていきます。
割り勘アプリケーション
要件
これから実装していくのは「割り勘アプリケーション」です。前回でも使用していたSharePieの概念を身近な形で引き継いだものですね。使用される状況は以下のようなものを想定しています。
- 飲み会の清算は等分ではなく、役職に応じて「傾斜」をつけることになっている。
- 例えば:
- 課長1+新人2で飲みに行って、
- 飲み代が10000円ならば、
- 課長が5000円支払い、新人は2500円ずつ支払う。
- 傾斜の相場は事前に決まっている。
- この場合であれば、課長:新人=2:1
現実には丸めや端数の処理も必要ですが、おおよその考え方はどこの会社にもあるのではないでしょうか。
アーキテクチャ
レイヤ構成は以下の通りです。
WebアプリケーションのエントリポイントとなるPageクラスからはContextが呼び出され、ContextによってDataとRoleが結びつけられます。Roleによって操作されたDataはRepositoryによって永続化されます。図では表現されていませんが、RoleはDataの操作のみを行い、Repositoryを使用した永続化はContextが行うようにしています。なお、リポジトリとはDDDに登場するドメインの構成要素の一つで、エンティティの永続化を担当するクラスです。
ロールベースのデザイン
コンテキストとロールを意識すると要件は以下のように表現できます。
- 準備:役職に応じた傾斜の相場はあらかじめ、管理者が定める。
- 企画:企画者が飲み会の参加者を募る。
- 支払:店に対する飲み代の支払いは支払者が一括で行う。
- 清算:後日、会計士が各参加者の支払い金額を決定する。
- 割り勘係は管理者に相場を問い合わせ、割り勘表を作成する。
- 会計士は割り勘表に従って、各参加者の支払い額を決定する。
ここでは「準備」、「企画」などがコンテキスト、「管理者」、「企画者」がロールに相当します。準備から清算までのコンテキストを一通り流すテストコードは以下の通り。
// id val slopeId = 10 val partyId = 20 // Preparation val preparation:Preparation = new Preparation(slopeId) preparation.addMapping("Chief", 4) preparation.addMapping("Manager", 3) preparation.addMapping("Member", 2) preparation.addMapping("Novice", 1) preparation.commit // Planning val planning:Planning = new Planning(partyId) planning.addPerticipant("Ken", "Chief") planning.addPerticipant("Taro", "Manager") planning.addPerticipant("Hanako", "Member") planning.addPerticipant("Yuji", "Novice") planning.commit // Paying val paying:Paying = new Paying(partyId) paying.pay(10000) paying.commit // Accounting val accounting:Accounting = new Accounting(partyId) accounting.adjustBy(slopeId) accounting.commit // verify val accounting2:Accounting = new Accounting(partyId) assertEquals(4000, accounting2.paymentOf("Ken")) assertEquals(3000, accounting2.paymentOf("Taro")) assertEquals(2000, accounting2.paymentOf("Hanako")) assertEquals(1000, accounting2.paymentOf("Yuji"))
ドメインレイヤへのエントリポイントはコンテキストですが、これはメンタルモデルとも合致しており、分かりやすいのではないでしょうか。
データモデル
このアプリケーションで使用されるデータモデルを図示します。
「データモデルには一切のロジックが実装されていません」と言いたい所ですが、他のインスタンスから値をコピーするロジックのみ実装されています(理由は後述)。Party
クラスのコードを示します。
class Party(val id:Int) { var sum:Int = 0 var participants = Map[String, Participant]() var allot = Map[String, Allot]() def populateWith(another:Party) = { this.sum = another.sum this.participants = another.participants this.allot = another.allot } }
コンテキスト
データに対するロールのミックスインはコンテキストにおいて行われます。コンテキスト-ロール-データの対応関係を図示します。
コンテキストで行っているのは主に以下の2点です。
- データへのロールのミックスイン
- ロールのメソッドをキック
Accounting
コンテキストのコードを示します。
class Accounting(val id:Int) extends PartyPopulater with SlopePopulater { val accountant:Accountant = new Party(id) with Accountant with PieMaker val pieMaker:PieMaker = accountant.asInstanceOf[PieMaker] val party:Party = populateParty(accountant) def adjustBy(slopeId:Int) = accountant.adjust(pieMaker, administrator(slopeId)) def paymentOf(name:String):Int = accountant.paymentOf(name) def commit = PartyRepository.add(party) private def administrator(slopeId:Int):Administrator = { val administrator:Administrator = new Slope(slopeId) with Administrator populateSlope(administrator) administrator }
ご覧の通り、極めてシンプルです。一点注意があります。Party
クラスがコンテキストに応じて異なるロールを演じるのに対し、言語仕様上、トレイトはインスタンス生成時にしかミックスインできず、自由に外すこともできません。したがって、永続化してあるインスタンスから新しく生成したインスタンスに値を移し替える処理が必要になります。このやや「臭う」コードは、ひとまずXxxPopulater
クラスのpopulateXxxx
メソッドにまとめておきます。
ロール
ほとんどのロールは「飲み会」にミックスインされています。DCIアーキテクチャを採用しなかった場合、これらの処理を行うメソッドはイベントに紐づいたクラスに吸収されて見えなくなってしまう(せいぜいプライベートメソッドとして切り出される程度)か、「飲み会」クラスが肥大化してしまうか、処理を委譲するための技巧的なクラスが登場していたか、おおよそこのいづれかだったのではないでしょうか。DCIアーキテクチャをScalaで実装した場合であれば、トレイトと抽象メンバを使用することによって、データを隠蔽しつつクラスの責務を明確に分割することができます。
Accountant
クラスの実装を示します。
trait Accountant { var sum:Int var allot:Map[String, Allot] def adjust(pieMaker:PieMaker, administrator:Administrator) = { val pie = pieMaker.createPie(administrator) this.allot = calculateAllot(pie) } def paymentOf(name:String) = allot(name).amount private def calculateAllot(pie:Map[String, Fraction]): Map[String, Allot] = { var tempAllot = Map[String, Allot]() for (it <- pie) { val name = it._1 val payment:Int = pie(name) * sum tempAllot += (name -> new Allot(name, payment)) } tempAllot } }
ここで、pieとは「参加者の名前」をキー、「自分の重み/参加者全員の重みの合計」を値としたマップになっています。要件では「割り勘表」と表現していたこのマップを作るのが「割り勘係 / PieMaker」の役割、割り勘表を元に各人の支払い金額を計算するのが「会計士 / Accountant」の役割です(なお、Fraction
クラスは分数を表現するために独自に実装したもので、*メソッドを持っています)。
まとめ
実は「会計士」と「割り勘係」という役割分担はアップフロントに設計できていたものではなく、数回リファクタリングをした結果に出てきたものです。そういった経緯を経て概ね以下のような指針に従うと良いのではないかという気がしています。
- ロールの分割はシナリオの登場人物として記述できる単位であることが望ましい。
- データモデルの特定のフィールドに対する更新は特定のロールに集めるべき。
- ロールは別のロールとやり取りするために自分が抽象メンバとして持っているフィールドを渡すべきではない。
- もう一方のロールも同じデータクラスにミックスインし、必要なフィールドを抽象メンバとして宣言すれば良い。
一点懸念も付け加えておきます。Accountingコンテキストのコードの一部を再掲します。
def adjustBy(slopeId:Int) =
accountant.adjust(pieMaker, administrator(slopeId))
今のところ、「コンテキストの1メソッドで呼び出すのはあるロールのトリガメソッドを1つだけ」というルールに従っています。論文にも「コンテキストにとってエントリとなるロールのトリガメソッドをキックする」とあることから、DCIアーキテクチャとしては正しいことだと思うのですが、ロールの数が増えると多少ごちゃごちゃしそうな気配もあります。この問題について考える時のポイントは、DCIアーキテクチャの目的が「ユーザのメンタルモデルに従うこと」にあると考えています。ロールの分割に関するルールの最初にシナリオとの整合性をあげたのは、その混乱を極力メンタルモデルと整合させるためでもありました。
なお、今回使用したサンプルは、すべてこちらで公開しています。