2009-10-14
Web アプリの MVC 設計まとめ
MVC 設計について考えていたときに、ちょうどその辺りの話をされている方々が居たので、今の考えをまとめてみました。
目次
- 前提
- 肥大化するコントローラを避ける
- ビジネスロジックをどこに書けば良いのか
- コントローラとモデルの間にもう一つの層があるとうまくいく?
- まとめ
前提
対象は Web アプリケーションで、画面数(ビューの数)は数個〜100個程度の規模です。WordPress、Twitter、37signals のサービスのようなものを作ろうとするとき、どういう MVC 設計をしていくかについて考えます。巨大なシステム、金融系システム、基幹系システムなどを作る場合とは異なる考え方もあると思います(そもそも MVC を使わない、など)。
肥大化するコントローラを避ける
例えば、八百屋さんで「60円で仕入れたリンゴ1つを100円で売った」こと(Sales Transaction)を記録する場合を想定しよう。
会計ソフトが一般的な会計の常識に従って作られていれば、この1つのSales Transactionにより、少なくとも4つの変更がデータベースに加えられる。
1.「手持ちの現金の増減」を記録するテーブルに「現金100円の増加」を記録
2.「売り上げ」を記録するテーブルに「100円の売り上げ」を記録
3.「在庫の増減」を記録するテーブルに「リンゴ1つ減少」を記録
4.「経費の計上」を記録するテーブルに「仕入れ値60円の経費計上」を記録
Ruby on Railsの「えせMVC」の弊害
1テーブルが1モデルに対応しているフレームワークでは
- 「手持ちの現金の増減」、「売り上げ」、「在庫の増減」、「経費の計上」それぞれのテーブルに対応する4つのモデルを作成し、
- コントローラからそれぞれのモデルにアクセスして一連の操作(トランザクション)を行う
としてしまい、ビジネスロジックがコントローラに混ざり込んで、データの整合性を保つのが難しくなる、と指摘されています。
まずい Sales Transaction の例
ビジネスロジックがコントローラに混ざり込んだ Sales Transaction を具体的なコードにしてみました。コントローラからそれぞれのモデルにアクセスして、Sales Transaction を処理するコードの例です。データの整合性を保つ処理がコントローラ内に記述してあります。コントローラでのコードの書き方によっては、テーブル間のデータの整合性が取れなくなってしまう可能性があります。またそれぞれのモデル単体をどれだけテストしたとしても、コントローラのテストをしなければアプリケーションの信頼性は上がりません。コントローラのテストをするにしても、テスト対象の粒度が大きいので、テストコードの見通しが悪くなりそうです。
まずい Sales Transaction のコード:
<?php class StoreController extends AppController { ... // コントローラの中にビジネスロジックがある public function sale() { $item = new Item(...); // 売った物(リンゴ)の初期化 ... try { ... // トランザクション開始 $cash->add($item->price); // 現金:現金100円の増加 $sales->save($item); // 売上:100円の売上を記録 $stock->sub($item); // 在庫:リンゴ1つ減少 $cost->save($item); // 経費:仕入れ値60円の経費を計上 ... // トランザクションのコミット } catch (Exception $e) { ... // ロールバックなど } ... } }
モデルにトランザクションを任せる
次は、Sales Transaction を八百屋モデルのメソッドとして実装した例です。八百屋モデルは、手持ちの現金、売上などのモデルに自由にアクセスできます。各テーブル間のデータの整合性は八百屋モデルが責任をもって面倒を見てくれるので、Sales Transaction を実行するコントローラを安心して作成できます。テストコードも、モデル側とコントローラ側で分担されて、見通しがよくなるはずです。
モデルにトランザクションを任せるコード:
<?php // 八百屋モデル class Store extends AppModel { ... // 売った物を引数に取る販売トランザクション public function salesTransaction(Item $item) { ... try { ... // トランザクション開始 $this->cash->add($item->price); // 現金:現金100円の増加 $this->sales->save($item); // 売上:100円の売上を記録 $this->stock->sub($item); // 在庫:リンゴ1つ減少 $this->cost->save($item); // 経費:仕入れ値60円の経費を計上 ... // トランザクションのコミット } catch (Exception $e) { ... // ロールバックしてから例外を投げる } } } class StoreController extends AppController { ... // すっきりしたコントローラ public function sale() { $store = new Store(...); $item = new Item(...); // 売った物(リンゴ)の初期化 ... try { // 八百屋($store)に対して売った物($item)を渡すだけ $store->salesTransaction($item); } catch (Exception $e) { ... // 失敗したときの処理 } } }
ビジネスロジックをどこに書けばいいのか
上の例は、八百屋モデルのメソッドにトランザクションをまとめることですっきりしました。一方、どこにビジネスロジックを書けば良いのか判断するのが難しいこともあります。例えば、次のような場合です:
トランザクションの内容:八百屋が60円で仕入れたリンゴをお客が100円で買う
さきほどのトランザクションに似た単純な購買トランザクションです。このトランザクションではデータベースに対して次の変更が必要です*1。
- お客テーブル:現金残高を100円減らす
- お客の持ち物テーブル:リンゴ1つを追加
- 八百屋の売上テーブル:100円の売上を記録
- 八百屋の現金の増減テーブル:現金の100円増を記録
- 八百屋の在庫の増減テーブル:リンゴ1つ減少を記録
- 八百屋の経費テーブル:リンゴの経費60円を記録
Sales Transaction と異なるのは、八百屋の他にお客も関わってくるという点です。当然この一連の手順は不可分(atomic)でなければなりません。つまり、お客テーブルの現金残高カラムの値が減って、八百屋の売り上げテーブルに記録が残らないということがあってはいけません。
複数のモデルが関係するトランザクション
この購買トランザクションを実装する方法はいろいろありますが、ここでは八百屋モデルのメソッドとして実装しました。購買トランザクションメソッドは、お客と、お客が買う物を引数に取ります。そしてもちろんコントローラは単にメソッドを呼び出すだけなので、モデルがデータの整合性に責任を持ちます。
複数のモデルが関係するトランザクション:
<?php // 八百屋モデル class Store extends AppModel { public function purchaseTransaction(Customer $customer, Item $item) { ... try { ... // トランザクション開始 $customer->subCash($item->price); // お客:現金残高100円減少 $customer->addItem($item); // お客の持ち物:リンゴ1つ増加 $this->cash->add($item->price); // 八百屋の現金:現金100円の増加 $this->sales->save($item); // 八百屋の売上:100円の売上を記録 $this->stock->sub($item); // 八百屋の在庫:リンゴ1つ減少 $this->cost->save($item); // 八百屋の経費:仕入れ値60円の経費を計上 ... // トランザクションのコミット } catch (Exception $e) { ... // ロールバックしてから例外を投げる } } } class StoreController extends AppController { ... public function buy() { $store = new Store(...); $customer = new Customer(...); // 買う人の初期化 $item = new Item(...); // 買う物(リンゴ)の初期化 ... try { // 八百屋にお客($customer)とお客が買う物($item)を渡す $store->purchaseTransaction($customer, $item); } catch (Exception $e) { ... // 失敗したときの処理 } } }
購買トランザクションを実装する方法はいろいろあると書きましたが、八百屋モデルのメソッドとして実装する以外にも次のようなものが考えられます。
- お客モデルのメソッドとして実装:八百屋と買う物を引数に取る
- 取引モデルのメソッドとして実装:お客、八百屋、買う物を引数に取るメソッドを持つ取引モデルを用意する
八百屋モデルのメソッドを採用したのは、八百屋の店内にお客が入って商品を買うという動作と、八百屋モデルのメソッドでお客モデルを引数に取るという操作が似ていたからです。もし八百屋ではなく自販機だったら、スイッチを押すのはお客のほうなので、お客モデルのメソッドとして購買トランザクションを実装するかもしれません。
取引モデルというのは、お客と八百屋はどちらかが偉いのではなく対等なのではないかという考えをすると出てきます。取引モデルはお客と八百屋の取引を仲介します。例えば物々交換サイトで、会員モデルの実体として $user_a、$user_b があるとき、対等に交換するため、$user_a、$user_b のメソッドではなく、二人の会員を引数に取る取引モデルのメソッドを用意するとしっくりきそうです。
コントローラとモデルの間にもう一つの層があるとうまくいく?
これまで、コントローラをすっきりさせて、モデルを肥やす(Skinny Controller, Fat Model)というアプローチを書いてきました。ところがこれをどんどん進めていくと、今度は、特定のモデルに大量のメソッドが集中したり、どのモデルがどういうトランザクションを担当するのかが分かりにくくなってきて、コードの可読性は下がり、重点的にテストすべき場所も分かりづらくなってきます。
Fat ModelはFat Controllerと同様にテストはしにくいし、理解しづらくなる。ロジックはそれぞれ適切なところで実装すべきで、Fatなのはやはりよくないのです。
えせMVCについてそろそろ一言言っておくか
対策として、モデルに収まりが悪いビジネスロジックは Service というモデルの形態に置くことをおすすめされています(そういうものを勝手に Operation モデルと呼んでいました。Service というのが一般的なのでしょうか)。
「八百屋が60円で仕入れたリンゴをお客が100円で買う」というトランザクションを Service を使って実装してみます。ただしこの例の場合は、トランザクション専用のモデルを用意するのは少し大げさな感じがします。
ビジネスロジックを実現するための専用モデル:
<?php class PurchaseService extends AppModel { public function transaction(Store $store, Customer $customer, Item $item) { ... try { ... // トランザクション開始 $customer->subCash($item->price); // お客:現金残高100円減少 $customer->addItem($item); // お客の持ち物:リンゴ1つ増加 $store->cash->add($item->price); // 八百屋の現金:現金100円の増加 $store->sales->save($item); // 八百屋の売上:100円の売上を記録 $store->stock->sub($item); // 八百屋の在庫:リンゴ1つ減少 $store->cost->save($item); // 八百屋の経費:仕入れ値60円の経費を計上 ... // トランザクションのコミット } catch (Exception $e) { ... // ロールバックしてから例外を投げる } } } class StoreController extends AppController { ... public function buy() { $store = new Store(...); $customer = new Customer(...); // 買う人の初期化 $item = new Item(...); // 買う物(リンゴ)の初期化 ... try { // PurchaseService に八百屋、お客、お客が買う物を渡す $purchase_service = new PurchaseService; $purchase_service->transaction($store, $customer, $item); } catch (Exception $e) { ... // 失敗したときの処理 } } }
Service という形でモデルのメソッドとうまく分けられると、重要なビジネスロジックを把握しやすくなり、アプリケーションの見通しが良くなります。
まとめ
- 基本方針:Skinny Controller, Fat Model
- ただしモデルが肥えすぎないように気をつける
*1:もっと細かくすることもできますが、ここでは重要な点ではないので単純にしてあります
