前回の日記(Go言語でのORMを色々検討してみた - タオルケット体操)の続きみたいなもんです。
もしかしてRevelはそもそもMVCに関心がない?
もしかしてMVCゆーてるだけちゃうんかみたいな疑問が。だったらもっとminimalなフレームワーク使うんですが、それは…(絶望)
前回の日記のケツの方で、RevelがFat Controllerを推奨しているようにしかみえなくてどうなの大丈夫なの? みたいな疑問への、自分なりの妥協案回答です。
同じようなことを考えてる人はちらほらいるようで、RevelのUserメーリスにもこんな質問がありました。
Google グループ
サンプルの場所はここ(Booking | Samples | Revel - A Web Application Framework for Go!)です。
ソースコード本体はRevelに含まれるgithub.com/revel/samples/booking
で読めます。
コントローラーがデータベースに関心を持っている
問題の構成ですが、まずRevelのサンプルではGorpControllerというものを作り、そこにgorp(つまりデータベース)の初期化やトランザクションを管理するための関数を定義しています。
次にcontroller/init.go
(init.goは自動的に実行される)を使い、revel.InterceptMethod
でリクエストの開始、後、終了後にそれぞれトランザクションの開始終了とロールバックを仕込んでいます。
次に、Revel全体のコントローラーの基底クラスとしてこのGorpControllerを継承(構造体の埋め込みですが)を行い、それを継承することで各コントローラーがデータベースのトランザクションオブジェクトにアクセス出来る、という構成になっています。
日本語で存在するRevelの解説記事もだいたいこの構成のコピーで書かれています。
まずここで「コントローラーがデータベースの初期化やトランザクションの開始に関するロジックを持つのってどうなんだ?」という疑問が出るわけです。
コントローラーがSQLの発行や構造体へのマッピング、その他の処理を受け持っている
件のメーリスで例に挙げられているメソッドは以下の物です。
// controllers/hotels.go func (c Hotels) List(search string, size, page int) revel.Result { if page == 0 { page = 1 } nextPage := page + 1 search = strings.TrimSpace(search) var hotels []*models.Hotel if search == "" { hotels = loadHotels(c.Txn.Select(models.Hotel{}, `select * from Hotel limit ?, ?`, (page-1)*size, size)) } else { search = strings.ToLower(search) hotels = loadHotels(c.Txn.Select(models.Hotel{}, `select * from Hotel where lower(Name) like ? or lower(City) like ? limit ?, ?`, "%"+search+"%", "%"+search+"%", (page-1)*size, size)) } return c.Render(hotels, search, size, page, nextPage) } func loadHotels(results []interface{}, err error) []*models.Hotel { if err != nil { panic(err) } var hotels []*models.Hotel for _, r := range results { hotels = append(hotels, r.(*models.Hotel)) } return hotels }
質問主はthis is not entirely clean
と感じたようですが、僕も大体同じ意見です。
全てがこの調子で実装されると、例えば複数のモデル……というかデータが協調するようなロジックが必要となった際にも、先ほどのHodels.Listのように再利用性のないメソッドにロジックが集約されていて抽象度が足りず、同じような実装が増殖していってしまいます。*1
これに対する回答ですが、一人からしかついていない上、イマイチ要領を得ない(僕の英語力不足で誤解している可能性もある)ので省略します。
ちなみに、一応models
という名前のディレクトリは掘られているのですが、マッピングする構造体とバリデーションの定義くらいしか書かれていません。
典型的なFat Controller……ドメインモデル貧血症の症状ですね。
ちなみに他のサンプルもちょいちょい見てみましたけど、何をするにしてもまずControllerに機能を乗せて拡張していくような感じになってます。それでいいんか。
っていうか最近聞かないんですけど、ドメインモデル貧血症ってもしかしてもう死語ですか?
MVC的にどうあるべきかの教科書回答
なんか最近MVCどうこう言って盛り上がってる人達をあんまり見ないので寂しいですね。
まず原典であるSmalltalkのMVCですが、そもそもデータの入出力や永続化は関心の外にあります。
が、画面に表示するデータを保持するのがモデルであるということで、やはりモデルがそこらへんのロジックを持つのが自然な流れです。
また、画面に表示されているデータの状態とモデルの状態というのは常に対応している必要があります(その方が明瞭な設計になりますからね)。ということは、永続化されたデータを取り出して、必要であれば結合や加工するという工程もモデルに含まれている必要があります。
これが「モデル層はデータを扱うビジネスロジックを内包する」と言われる所以で、Railsが盛り上がっていた時によく言われていた「ActiveRecord(ORM) = モデルじゃねえぞ!」という批判はここに由来します(基本的には)。
ActiveRecordで使われるクラスだけをモデルとすると、結局ビューに出力する前にコントローラーでデータを加工する必要が出てくるわけです。それが積み重なると、ユーザの見ている状態とモデルの持つアプリケーションの本質的な状態の間に不整合が生じて、バグを生み出す原因になるわけです。(もっとも、こういった設計上のミスが致命的になりやすいのはWebよりもネイティブの方ですが)
Ruby on Rails以降、一気に知名度が広まった(らしい)WebアプリケーションのMVCですが、ステートレスなHTTP上に構築されている関係上からか、ネイティブアプリを対象とした原典のMVCとはだいぶアーキテクチャのパターンや実装の定石が異なります(モデルに仕込んだオブザーバーから能動的にビューを更新しなかったりとか)。
しかし、教義の根本にある思想は共通です。やはりアプリケーションの状態はモデルに凝集されているべきですし、その方が結果として明瞭でわかりやすい実装になります。
オレ的RevelでMVCをするモジュール構成
どうもSinatra系のフレームワークばっかりだったり、Revelがフルスタックといいつつもルーティングがめんどいだけで中身は結局Sinatra系 + ユーティリティ関数の集合体だったりするわけで、Go言語界隈はあんまりMVCとかそういうのは好きじゃないのかなぁと思います。まぁそういうのってめんどくさいですし、気持ちはわかります。
でも俺はMVCしたいんだよ!
というわけで暫定的に以下みたいな構成にしてます。
app/models/ ├── hoge.go ├── ... ├── db │ ├── init.go │ └── utils.go └── entity ├── base.go ├── hoge └── ...
モデルというモジュールを一つ切って、その中でモデル、DB、ドメインに役割を分離しています。
一つづつ解説します。
db/以下のファイル
データベースに関わるような設定だのなんだのの関心毎を全部ぶっこみます。
init.goの実装サンプルです。僕はgenmaiを使ってますが、基本的にgorpだろうが何使おうが大差ないはずです。
// ORマッパーの初期化とかに関わる挙動の制御 package db import ( "your-fantastic-application/app/models/entity" _ "github.com/mattn/go-sqlite3" "github.com/naoina/genmai" ) var ( DbMap *genmai.DB ) type Transactional struct { DB *genmai.DB } func NewTransactional() *Transactional { return &Transactional{DB: DbMap} } func (txn *Transactional) TransactWith(callback func(*genmai.DB) error) error { defer func() { if err := recover(); err != nil { txn.Rollback() } else { txn.Commit() } }() txn.Begin() return callback(DbMap) } func (txn *Transactional) Begin() { err := DbMap.Begin() if err != nil { panic(err) } } func (txn *Transactional) Commit() error { return DbMap.Commit() } func (txn *Transactional) Rollback() error { return DbMap.Rollback() } func InitDB() { db, err := genmai.New(&genmai.SQLite3Dialect{}, "./app.db") if err != nil { panic(err) } db.CreateTable(&entity.Hoge{}) DbMap = db }
あくまで例ですが、データベースを扱うためのオブジェクトや、トランザクション制御を行うメソッドと基底クラスを用意してます。
その他、なんかゴチャゴチャとやりたい場合はutils.goの中に宣言します。
後はcontroller/init.go
からrevel.OnAppStart(db.InitDB)
するか、func init() {}
を宣言して適当に初期化するといいんじゃないでしょうか。
revelのフックを使ってトランザクションの管理をするメリットが僕には感じられないので、その管理はTransactional
を委譲したモデルが明示的に行うことにします。普通に手で書けばいいじゃんってやつですね。
entity/以下のファイル
こちらには、データベースのテーブルと連携させる構造体(エンティティ)や、その他ドメインを表現させたい構造体(ValueObjectっていうのかな)を宣言しておきます。
ここではデータ構造と、永続化に関連したバリデーションなどデータそのものにのみ関心を持たせることにします。
僕はやらないですが、データとフォームの関連づけをやりたいのであればここにフォーム生成のロジックを書くことになるのなーといった感じです。
以上の、db/
とこのentity/
をControllerから操作することはないようにします。これらへの操作は、モデル層として抽象化されたAPIから行われることになります。
コントローラーはデータを直接いじらずに、操作が抽象化されていることで、アプリケーションのとりうる状態や操作が明瞭になります。
また、嬉しい副作用としてビジネスロジックの単体テストを行う際にHTTPレスポンスのエミュレーションなどの手間が減ります。
models/直下のファイル
ビジネスロジックとかを抽象化したものをここにねじ込みます。
データベースを扱いたい場合は、先ほどのTransactional
構造体を委譲(埋め込み)してくることで対応します。
ここは、より抽象化してデータを扱う層なので複数のエンティティにまたがった処理も記述出来ます。
が、それだけの取り決めだとメンバーのスキル感によってはドメインモデル貧血症を引き起こしたり、モデル間の依存や実装の重複が収拾不可能なカオスを生み出す可能性があります。
そういう時はサービス層を新しく設けて、手続きを分離するのが簡単で効果を上げやすいかなぁというのが僕の拙い経験上のアレです。
まとめ
色々と突っ込みところはあるとおもいますが、だいたいこんなかんじです。
まだ細かいところの試行錯誤は続くとおもうんですが、ベースの構成としてはなんとなく落ち着く感じになったかなぁといったところでしょうか。
エンティティにどれだけ実装を持たせるかとか、そーいうところが気がかりですかね。
コントローラーになんでもかんでも詰め込む方がとっつきやすいですし、ぱっと見のソースの量とかは減るかもしれないんですけども、モデル層はしっかり分離させていった方が結果的にシンプルで楽になるとおもうんですよね。そこらへんは各人の嗜好やプロジェクトの納期に委ねられるのかもしれませんが。
おしまい。
*1:ここで述べたいのは「同じような処理だから一つの関数にまとめる」という作業ではない。共通化はクソ