ScalaでDBアクセスの抽象化の件
本記事は関数プログラミングAdventCalendar2015 4日目の記事です。予定を変更してDBアクセス抽象化の話をしたいと思います。
pab_techさんによる二つの記事やtayama0324さんの記事でDBアクセスに対する抽象化が取り上げられています。
- 【ScalaMatsuriセッション当選御礼】ドワンゴ秘伝のトランザクションモナドを解説!
- Scalaにおける最適なDependency Injectionの方法を考察する 〜なぜドワンゴアカウントシステムの生産性は高いのか〜
- Minimal Cake Pattern のお作法
ちょうど某プロジェクトでScala + Repositoryパターン的なものを利用しようとしていたので、参考にさせて頂きました。結論から言うと、うちの開発では「Minimal Cake Pattern」と呼ばれるものほぼそのままと、Readerモナドによるトランザクションの合成で手を打った感じです。ドワンゴさんのところ程本格的じゃないけど似たようなことをやろうとする人には参考になるかもしれませんので紹介します。
まずはRepositoryです。例えとして商品データ(Products)を扱うとします。クラスの関係は下図のようなイメージです。実際にはxxxServiceのtraitが沢山あってそれをmixinする事を想定しています。
/** * 複数のxxxServiceをmixinした時に名前空間を区切るために変数を導入する。 */ trait ProductsSevice[Context] { val productsRepository : ProductsRepository[Context] // リポジトリの実体を束縛する変数 } /** * データベースアクセス用のServiceの実体(scalikejdbc版) */ trait ProductsServiceDBImple extends ProductsService[scalikejdbc.DBSession] { @Override val productsRepository : ProductsRepository[scalikejdbc.DBSession] = ProductsRepositoryDBImple } /** * DBアクセスインターフェイス * ここに色々抽象的なデータ取得/更新関数を定義する。 */ trait ProductsRepository[Context] { def readAll : Transaction.T[Context, Seq[Product]] // 全商品情報の読出し } /** * DBアクセスインターフェイスの実装. */ object ProductsRepositoryDBImple extends ProductsRepository[scalikejdbc.DBSession] { import scalikejdbc._ def readAll : Transaction.T[DBSession, Seq[Product]] = { Transaction ({ implicit session:DBSession => // ここに実装が入る. 例外も捉えてアプリケーションレベルの例外にするなども行う. }) } }
DBアクセスの結果をTransaction.Tという型で包んでいます。これがドワンゴさんではトランザクションモナドと呼んでいた機能の一部(合成する機能だけ)を担うもので、実体はDBアクセスの文脈を運ぶReaderモナドです。実装は下記のような感じです。
/** * DBトランザクションを表現する */ object Transaction { type T[Context, A] = scalaz.Reader[Context, A] // scalazのReaderモナドの名前を付け替えただけ。 def apply[Context, A](run : Context => A): T[Context, A] = { scalaz.Reader(run) } /** * DBアクセス用実行関数. */ def runDB[A](t:T[scalikejdbc.DBSession, A]) : A = { import scalikejdbc._ DB localTx { implicit session => t.run(session) } } /** * ユニットテスト用実行関数(文脈無し) */ def runDummy[A](t:T[Unit, A]) : A = { t.run(Unit) } }
これらの下準備をしたら、利用する側では下記のようなコードを書きます。ポイントはRepositoryServiceトレイトでContextを存在型にしてrunTransactionの実装を要求しているところです。これでRepositoryServiceを使う側でrunTransactionを実行できます。
/** * 全てのサービスをまとめたもの */ trait AllService[Context] extends ProductsService[Context] // 商品サービス with SalesService[Context] // 売上サービス with xxxService[Context] ... // その他種々のサービスをまとめる /** * Transaction実行を含むRepositoryService */ trait RepositoryService { type Context // Context型パラメーターを存在型にする。これでrunTransactionメソッドを実装できるようにする。 val repos : AllService[Context] def runTransaction[A](transaction:Transaction.T[Context, A]) : Try[A] // 隠した型パラメーターに対応するトランザクションの実行メソッド } /** * RepositoryServiceのDBアクセス実装版 */ object RepositoryServiceDBImple extends RepositoryService { override type Context = scalikejdbc.DBSession override val repos = new AllService[scalikejdbc.DBSession] with ProductsServiceDBImple with SalesServiceDBImple with xxxServiceDBImple @Override def runTransaction[A](t:Transaction.T[Context, A]) : Try[A] = { Try(Transaction.runDB(t)) } } // ロジックを書くときにはRepositoryServiceを使う class ApplicationLogic(service:RepositoryService){ /** * お客が品物を買う動作を表現する */ def buy(Customer customer, goods:Seq[(Product, Integer)]) : Try[Sales] = { // 複数のDBアクセスをトランザクションとして扱う。 service.runTransaction(for( number <- service.repos.salesService.add(customer, goods) // 売上を保存して伝票番号を得る _ <- Traverse[List].sequence( goods.map( (p,n) => service.repos.stockService.sub(p, n, number) )) // 在庫の減算 ) yield ( Sales(number, customer, goods ) )) } } // // new ApplicationLogic(RepositoryServiceDBImple).buy(...) //
DBアクセス中に例外発生したらReaderモナド台無しじゃんというご指摘はごもっともですが、トランザクションがロールバックするので勘弁してもらう感じです。runTransactionで発生した例外の種類でエラーを拾います。