ScalaによるDCIアーキテクチャ:ローンシンジケート再考

GroovyによるDCIアーキテクチャのサンプル実装で明らかになった2つの問題点をScalaによって解決する。

導入

前回のエントリでは、DCIアーキテクチャの構想に従った実装を示しつつ、言語的な制約から来る問題点を2つ提示しました。以下に再掲します。

  1. ミックスインしたロールクラスにキャストすることができないため、ロールクラスを使用する際に型の安全性が確保されていない。追記:asTypeを使用することで、ミックスインしたクラスへのキャストが可能です。uehaj様より指摘を頂きました。(2010/06/04)
  2. ロールクラスが定められたふるまいを実行するためにはデータ構造についての知識が必要だが、それを静的に保証することができていない。

これらはいずれも型の安全性に関する問題で、アーキテクチャの問題というよりは言語仕様の問題です。そこで今回はScalaを使用し、これらの問題をトレイトと抽象メンバによって解決することにします。


また、実装の置き換えに合わせて、以下の問題についてもリファクタリングを行います。

  1. PercentagePieAmountPieの間でコードの重複が多い。
  2. AmountPieincrease/decreaseで、処理のシンメトリーが崩れている。


なお、トレイトを使うことに伴う変更が1点あります。Groovyではインスタンスに対して別のクラスをMixinすることができましたが、Scalaでトレイトを注入できるのは型の宣言時かインスタンスの生成時のみとなります。今回のサンプルでは「DCIアーキテクチャ」に習い、インスタンス生成時にトレイトを注入することとします。ただし、実際には既に値のあるデータオブジェクトに対してミックスインを行いたいケースも多いと思いますので、常にデータオブジェクトのインスタンス生成が必要になるとすると、データの受け渡しに関する考慮が別途必要になるでしょう。


説明に使用するサンプルの要件は、前回のエントリで使用したものと同じです。前回はDCIアーキテクチャの概要についても解説していますので、まだお読みでない方は先に進む前に読んで頂くことをおすすめします。なお、今回はScalaを利用する目的を型の安全性の確保に置いており、「関数型」のパラダイムは重視していません。随所に手続き的な処理が出てきますがご了承下さい。

サンプル:ローンシンジケート(Scala

モデル

基本的な概念レベルではモデルに変更はありません。ただし、SharePieをトレイトに変更している点、またトレイトのミックスインのためにFacilityクラスのフィールドloanを最初nullで初期化している点に違いがあります。

class Facility(val id:Int) {
    var limit:BigInt = 0
    var loan:Loan = null
    var shares = Map[Int, Share]()
}

class Loan {
    var shares = Map[Int, Share]()
}

class Share(
    val ownerId:Int, 
    var amount:BigInt) {
}
ロール

ロールクラスをトレイトとして実装している点が今回のポイントです。まずはコンテキストクラスにおけるインスタンス生成部分を示します。

class LoanSyndicate {
    
    private var _facility:Facility = null
    private var percentagePie:PercentagePie = null
    private var lender:Lender = null
    private var amountPie:AmountPie = null
    
    def buildFacility(id:Int):LoanSyndicate = {
        
    var facility = new Facility(id) with Lender with PercentagePie
    var loan = new Loan with AmountPie
        
        facility.loan = loan
        
        _facility = facility
        
        percentagePie = facility
        lender = facility
        amountPie = loan
        
        this
    }

FacilityクラスとLoanクラスのインスタンス生成時にwithキーワードを使って各ロールをミックスインしています。Loanクラスのインスタンス生成もコンテキストで行い、ミックスイン後のインスタンスFacilityクラスのフィールドに設定しています。ここでのポイントはロールクラスを宣言しているフィールドです。

    // ロールクラスを宣言しているフィールドを抜粋
    private var percentagePie:PercentagePie = null
    private var lender:Lender = null
    private var amountPie:AmountPie = null

それぞれをロールクラスの型で宣言することができているため、以降の処理はタイプセーフに記述することができます。


さて、ロールクラスであるPercentagePieAmountPieはどちらも「会社ごとの分け前をデータとして保持し、そのデータに対して必要なふるまいを実行する」という性質を共有していたので、両者をトレイトに変更したSharePieのサブクラスとして実装することにしました。このSharePieクラスの一部を示します。

trait SharePie {
    
    var shares:Map[Int, Share]
    
    def totalAmount:BigInt = {
        var sum:BigInt = 0
        shares.values foreach { share => sum += share.amount }
        sum        
    }

メンバとして宣言されているsharesは抽象メンバです。この段階では実際の値を持ちませんが、トレイト内ではsharesはタイプセーフに利用できます。同時にこの抽象メンバはミックスインされる先との契約となり、ミックスイン先がこのメンバを持っていない場合はコンパイルエラーとなります。


以上により、冒頭に提示した型の安全性に関する2つの問題はクリアすることができています。

リファクタリング:処理のシンメトリー

AmountPieクラスのGroovyコードを再掲します。

public class AmountPie {

    void increase(int ownerId, BigInteger amount) {
        shares.get(ownerId).amount += amount
    }

    void decrease(BigInteger amount) {
        BigInteger totalAmount = shares.totalAmount()
        shares.each {
            Share eachShare = it.value
            eachShare.amount -= 
                amount * ( eachShare.amount / totalAmount)
        }
    }

ご覧の通り、「会社を指定して増やす」「減らす時には各会社に内部で振り分ける」と処理が対称ではありません。これは「引出時はローン出資割合に従って各会社からのローンが増え、返済時は貸し出している金額に応じて各会社のローンが減る」という非対称の要件を引きずってしまったものですが、この非対称性は「貸し手」のロジックであり、抽象的な分け前クラスとしてはもう少し中立的にふるまうべきでしょう。そこで、「基準となる分け前に従って分配する」という考え方を取ることにしました。

trait AmountPie extends SharePie {
    
    def increase(amount:BigInt, criterion:SharePie) = {
        val currentTotalAmount:BigInt = criterion.totalAmount
        criterion.shares.values foreach { share =>
           shares(share.ownerId).amount += 
               criterion.allot(share.ownerId, amount, currentTotalAmount)
        }
    }
    
    def decrease(amount:BigInt, criterion:SharePie) = {
        val currentTotalAmount:BigInt = criterion.totalAmount
        criterion.shares.values foreach { share =>
            shares(share.ownerId).amount -= 
                criterion.allot(share.ownerId, amount, currentTotalAmount)
        }
    }

}

その上で、どういう時にどの分け前を使うかは貸し手が判断します。

trait Lender {

    var limit:BigInt

    def draw(amount:BigInt, amountPie:AmountPie, 
        percentagePie:PercentagePie) = {
        
        amountPie.increase(amount, percentagePie)
        decreaseLimit(amount)
    }
    
    def pay(amount:BigInt, amountPie:AmountPie) = {
        
        amountPie.decrease(amount, amountPie)
        increaseLimit(amount)
    }

SharePieの責務がはっきりした所で、ロールモデルの責任分担もかなり明確になったと思います。データモデルの世界とロールモデルの世界を合わせて図示すると概ね以下のようになります。

この2重のモデルは確かにメンタルモデルに合致したものです。このモデルに忠実に従ってデータとロールを実装しつつ、コンテキストにおいて両者を自然に結びつけることができている点が、DCIアーキテクチャの実装言語としてScalaが極めて優れている点でしょう。

まとめ

「型の安全性を保ちながらデータとふるまいを分割すること」は、Scalaを用いることにより「抽象メンバを宣言したトレイト」という形で実現することができます。しかし、メンタルモデルという観点からすれば、ここまででまだ半分です。処理を細かい粒度に分割すること、分割した処理を何らかの単位にまとめること、この2つをメンタルモデルと整合した一定の抽象的な観点から行われなければ、結局はコンポーネントが散逸する混沌としたアーキテクチャになってしまいます。DCIアーキテクチャは思想的に「ロール」という概念を導入することによってふるまいをモデリングする際の指針を与えています。メンタルモデルの実装というDCIアーキテクチャが本来目指している観点からすると、この点が最も重要なものであるということを強調しておきたいと思います。


なお、今回使用したサンプルはすべてこちらで公開しています。

追記(2010/06/06)

せっかくScalaで実装しているので、クロージャや畳み込みを使用してSharePie関連クラスのリファクタリングを行いました。最終的なコードを示します。

trait SharePie {
    
    var shares:Map[Int, Share]
    
    def totalAmount:BigInt =
        (BigInt(0) /: shares.values) ((sum, share) =>  sum + share.amount)
    
    def add(share:Share) = shares += ( share.ownerId -> share )
    
    def allot(ownerId:Int, amount:BigInt, totalAmount:BigInt):BigInt =
        amount * shares(ownerId).amount / totalAmount
    
    def transfer(from:Company, to:Company, amount:Int) = {
        assert(BigInt(amount) <= shares(from.id).amount)
        
        shares(from.id).amount -= amount
        shares(to.id).amount += amount
    }

}

trait PercentagePie extends SharePie {

    def validate():Boolean = { BigInt(100) == totalAmount }

}

trait AmountPie extends SharePie {
    
    def increase(amount:BigInt, criterion:SharePie) = 
        allotToEachShare(amount, criterion, 
            (share:Share, allot:BigInt) => share.amount += allot)
    
    def decrease(amount:BigInt, criterion:SharePie) = 
        allotToEachShare(amount, criterion, 
            (share:Share, allot:BigInt) => share.amount -= allot)
    
    private def allotToEachShare(amount:BigInt, criterion:SharePie, 
            operate:(Share, BigInt) => Unit) = { 
        for {
            thatShare:Share <- criterion.shares.values
            currentTotalAmount:BigInt = criterion.totalAmount
        } operate(shares(thatShare.ownerId), 
            criterion.allot(thatShare.ownerId, amount, currentTotalAmount))
    }

}