LoginSignup
1
1

More than 5 years have passed since last update.

SkinnyFramework + GuiceでDI

Posted at

はじめに

DI(Dependency Injection)のメリットはいくつかありますが、

Seasar - DI Container with AOP にあるように、
「お互いがインターフェースのみで会話する」という考え方が個人的に一番しっくりきます。

プログラムを作成する場合、1つの関数の中で処理が完結することは少なく、「他の関数の呼び出し」が必要になることが多いと思います。
ただ、Unitテストの時点では、テスト対象の関数の責務としては、

  • 自分の責務を満たしているか

のみが観点となります。テスト対象の関数が「他の関数」を呼び出している時は

  • 「他の関数」が呼び出させているか
  • 呼び出し結果を元に適切な処理を行っているか

が観点として追加されます。ぶっちゃけて言えば、「他の関数」の内部実装はどうでも良いのです。インターフェース通りであればそれで問題ありません。

実際のところ、「他の関数」がまだ未完成だったり、外部システムにアクセスするような場合、自分の責務を満たしているか確認したいだけなら「他の関数」の実装をMockに置き換えるという手段が取られます。

DIを使用することでUnitテストの際には「他の関数」を「Mock」に差し替えることが簡単にできるようになります。実際にUnitテストするかどうかはさておき、テストしやすい状況を作るのは大事なことだと考えています。

ScalaでDIってどうなんでしょう

では、Scalaではどうか、という所ですが、私が知っている中ではWebアプリケーションのフレームワークでDIを推奨してそうなのは最近のPlay Frameworkくらいの認識です。後はCakeパターンを使う、というのが大部分のように見受けられました。

ただ、私の頭ではCakeパターンを「簡単だ」とも思えず、「まぁ、簡単なシステムだからDIとか考えなくて良いや」と逃げてきました
ServiceやDaoはobjectにしてその場をしのいでいたのです。

ですが最近、第何次かはわかりませんがDIの話が盛り上がっている気がしますし、
「object直接呼び出しで本当にそれで良いの?」と心に問いかけてくるもう一人の自分の声が無視できなくなってきました。
「テスタビリティが悪いまま放置してdeveloperを名乗っていいのか」という気持ちが沸々と湧いてきたので勢いで書いてみた次第です。少し長いですが、お付き合いください。

DIコンテナの導入

SkinnyFrameworkを使用した今のままでもScalaMockを使うことでobjectのMockも作れなくは無さそうですがなんとなく面倒そうです。

Scalaやってる人がDIやる時の「普通」はCakeパターンなのかもしれませんが、私のような凡人にはDIコンテナを使った方がとっつきやすいです。
ということで、Guiceを使用することにしました。

私がシステムを作る時の前提条件

Webシステムのサーバサイドで考えた時、

いつもの.png

  • Controller
    • ブラウザからのリクエストを受ける
    • 呼べるのはService
  • Service
    • Daoを呼んだりデータをごにょごにょしてControllerへ返す
    • 呼べるのはDao / Service
  • Dao
    • RDBMSへSQLを発行する

のように責務を限定して論理的にレイヤを設け、そのレイヤ間の呼び出しの際にDIを使用します1

traitと実装クラスは1:1の関係で状態を持たせず、Singletonでメモリを使わないように心がけます。
レイヤ呼び出し時の引数としては基本インスタンスが作りやすいものとし(POJO的な奴)、HttpRequest等は禁止という感じにします。
Scalaを使用していますが、「Javaでやるならこうなっちゃうかもね」という構成です2

ソースコード

GitHubと合わせてご覧ください

Dao

Staff.scala
case class Staff(
  id: Long,
  staffName: String
)

trait StaffDao {
  def create(entity:Staff)(implicit s:DBSession):Long
  def update(entity:Staff)(implicit s:DBSession):Long
  def findById(id:Long)(implicit s:DBSession):Option[Staff]
  def deleteById(id:Long)(implicit s:DBSession):Int
}

object Staff extends SkinnyCRUDMapper[Staff] with StaffDao {
  override lazy val tableName = "staff"
  override lazy val defaultAlias = createAlias("s")

  override def extract(rs: WrappedResultSet, rn: ResultName[Staff]): Staff = new Staff(
    id = rs.get(rn.id),
    staffName = rs.get(rn.staffName)
  )

  override def create(entity: Staff)(implicit session: DBSession): Long = {
    Staff.createWithAttributes(
      'staffName -> entity.staffName
    )
  }

  override def update(entity: Staff)(implicit session: DBSession): Long = {
    Staff.updateById(entity.id).withAttributes(
      'staffName -> entity.staffName
    )
  }
}

Skinny-ORMを使用しているので、こんな感じになります。
定義したtraitの実装はclassではなくobjectになります。DIに関する記述はありません。

Service

StaffService.scala

trait StaffService {

  @Inject
  private val staffDao:StaffDao = null

  def createStaff(name:String)(implicit session:DBSession) = {
    staffDao.create(Staff(id = -1L, staffName = name))
  }

  def getStaff(id:Long)(implicit session:DBSession) = {
    staffDao.findById(id)
  }
}

class StaffServiceImpl extends StaffService

Daoのtraitがフィールドにいます。Field InjectionでGuiceにインジェクションしてもらいます。
trait側にデフォルト実装持たせたかったので、コンストラクタインジェクションは使っていません。好みの問題ですが、システム内で統一はした方が良いです。

Controller

RootController.scala
class RootController extends ApplicationController with DiInjector {
  def index = {
    DB.localTx { implicit session =>
      val staffService = injector.getInstance(classOf[StaffService])
      val id = staffService.createStaff("DIも良いもんだぜ!" + new Date())
      println(id)
      render("/root/index")
    }
  }
}

SkinnyFrameworkのControllerです。DiInjectorというtraitをmixinしています。

val staffService = injector.getInstance(classOf[StaffService])

の箇所がGuiceから当該Serviceのインスタンスを取得する箇所になります。該当ServiceがDaoに依存する場合も芋づる式にInjectされます。

Module

BindModule.scala
class BindModule extends AbstractModule{
  override def configure() = {
    //Serviceはclassなのでto、Singleton   
    bind(classOf[StaffService]).to(classOf[StaffServiceImpl]).in(classOf[Singleton])

    //Daoは、objectなので、インスタンスを設定
    bind(classOf[StaffDao]).toInstance(Staff)
  }
}

object BindModule {
  val injector = Guice.createInjector(new BindModule())
}

Guiceに、「こんな関連でtraitとインスタンスを管理してね」と設定する箇所になります。
Serviceはclass、Daoはobjectなので設定方法が異なります。

Injector

DiInjector.scala

trait DiInjector {
  val injector = BindModule.injector
}

Moduleのインスタンスを管理するtraitです。Controllerでmixinすることを想定しています。
わざわざtrait化しているのはUnitテストしやすいようにです。

Daoテストクラス

StaffSpec.scala

class StaffSpec extends fixture.FunSpec with AutoRollback with Matchers with DBSettings with BeforeAndAfter {

  @Inject
  private val staffDao:StaffDao = null

  before {
    BindModule.injector.injectMembers(this)
  }

  describe("create") {
    it("社員を作成し、更新して、削除するテスト") { implicit session =>

      val createdId = staffDao.create(Staff(id=1L, staffName = "とーろく社員!!"))
      staffDao.findById(createdId).get.staffName should be ("とーろく社員!!")

      val staffDi = Staff(id=createdId, staffName = "更新後社員名!!")
      staffDao.update(staffDi)

      staffDao.findById(createdId).get.staffName should be ("更新後社員名!!")

      staffDao.deleteById(createdId)
      staffDao.findById(createdId) should be (None)
    }
  }
}

個人的にはDaoのテストは実際にSQLを発行させたほうが良いと思っているので、Mockは使いません。

before {
  BindModule.injector.injectMembers(this)
}

の箇所で、Injectアノテーションが付与されているメンバ変数にInjectionしてもらい、それをテストメソッドで呼び出し、結果どうだったかの検証を行います。

Serviceテストクラス

StaffServiceSpec.scala

class StaffServiceSpec extends fixture.FunSpec with AutoRollback with Matchers with DBSettings with BeforeAndAfter with MockitoSugar {

  @Inject
  private val staffService:StaffService = null

  before {
    Guice.createInjector(
      Modules.`override`(new BindModule()).`with`(createMockModule())
    ).injectMembers(this)
  }

  describe("getStaff") {
    it("DB取得") { implicit session =>
      val actual = staffService.getStaff(2L).get
      actual.id should be (2L)
      actual.staffName should be ("Mock社員ン!")
      staffService.getStaff(1L) should be (None)
    }
  }

  private def createMockModule() = new AbstractModule() {
    override def configure() = {
      val m = mock[StaffDao]
      when(m.findById(org.mockito.Matchers.eq(1L))(org.mockito.Matchers.anyObject())).thenReturn(None)
      when(m.findById(org.mockito.Matchers.eq(2L))(org.mockito.Matchers.anyObject())).thenReturn(Option(
        Staff(id=2L, staffName="Mock社員ン!")
      ))

      bind(classOf[StaffDao]).toInstance(m)
    }
  }
}

このServiceの実装はDaoに依存するのですが、それをMockに置き換えることで、SQLの発行無しにテストを行っています。

createMockModuleでDaoをMock化し、traitとインスタンスの紐付けを行い、

before {
  Guice.createInjector(
    Modules.`override`(new BindModule()).`with`(createMockModule())
  ).injectMembers(this)
}

で、設定を上書きつつ、Injectionします。Service呼び出しの結果より、Dao処理がMockに置き換わっていることが確認できます。

Controllerテストクラス

RootControllerSpec.scala
class RootControllerSpec extends FunSpec with Matchers with MockitoSugar with DBSettings {

  describe("RootController") {
    it("shows top page") {
      val controller = new RootController with MockController with MockDiInjector
      controller.index
      controller.contentType should equal("text/html; charset=utf-8")
      controller.renderCall.map(_.path) should equal(Some("/root/index"))
    }
  }

  trait MockDiInjector extends DiInjector {
    override val injector = Guice.createInjector(
      Modules.`override`(new BindModule()).`with`(createMockModule())
    )
  }

  private def createMockModule() = new AbstractModule() {
    override def configure() = {
      val m = mock[StaffService]
      when(m.createStaff(anyString())(anyObject())).thenReturn(123456L)
      bind(classOf[StaffService]).toInstance(m)
    }
  }
}

createMockModuleでServiceをMock化し、traitとインスタンスの紐付けを行い、

trait MockDiInjector extends DiInjector {
  override val injector = Guice.createInjector(
    Modules.`override`(new BindModule()).`with`(createMockModule())
  )
}

で、DiInjectorのModuleインスタンスを上書き、

val controller = new RootController with MockController with MockDiInjector

で、MockControllerMockDiInjectorをmixinしてテスト対象のControllerのインスタンスを生成し、呼び出します。

まとめ

素のSkinnyFramework + Guiceを使ってDIを実現してみました。

ここまで書いててなんですが、私は外部のシステムに接続する場合くらいしかMock化しないようにしています。
Mockだらけになると管理が大変ですし、複雑なMockを作るとメンテナンスコストもバカになりません。
Daoはテスト用のスキーマを用意して実際のSQLを発行させればいいじゃん、と思っているので、どこまで時間・リソースをかけられるか鑑みたうえで方針を決めた方が良いと思います3
AWSのサービスを使っている場合でも疑似的にローカルで再現できるものもありますし、
テスト用にAWSのサービスを契約して実際に呼ぶでも良いかもしれません。
このあたりの方針は、プロジェクト/プロダクトでどうするかを事前に合意を取っておく必要があるでしょう。
ただ、DIの仕組みを前もって導入しておくことで、テストしたくなった時にすぐMockに差し替えることができます。それだけでも充分な気がします4

最後に

SkinnyFramework、DIコンテナとの連携あるじゃん...5



  1. ServiceとDaoの間にLogicレイヤも設けることもあるのですが、「手間がかかりすぎる」とあまり評判は良くありません... 

  2. Scalaならこうやるかな、という方法教えて下さい。勉強します 

  3. 複雑で大きなシステムや変更の多いシステムを作っている方々は「そんなんじゃできないよ」、というのもあると思いますが... 

  4. ひとまず、まだ自分はdeveloperだと言える気がしました。 

  5. ま、まぁ、やり方はひとつじゃありませんしね...orz 

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1