かとじゅんの技術日誌

技術の話をするところ

sbtでDAOを自動生成する方法

DDDのリポジトリを実装する際、ほとんどのケースでDAOが必要になります。が、ボイラープレートが多く、自動生成したいところです。というわけで作りました。

どうやって自動化するか

septeni-original/sbt-dao-generator

指定されたスキーマのJDBCメタ情報とテンプレートをマージさせて、ソースコードを出力します。その機能を提供するのがsbt-dao-generator1です。つまり、sbtからコマンド一発でこういうことができるようになるわけですが、 DBのインスタンスが立ち上がっていて、スキーマ情報が組み込まれた状態でないと使えません。

chatwork/sbt-wix-embedded-mysql

sbtから組み込みMySQLを起動するプラグインです。MySQL固定です…。

flyway/flyway-sbt

こちらは言わずもがな、有名なsbtプラグイン。組み込みMySQL上にスキーマを自動作成するために使います。

環境構築手順

実際のサンプルコードは、j5ik2o/scala-ddd-baseをみてください。 flywayを扱うプロジェクトflywayとDAOを自動生成するプロジェクトexampleはわけています。

project/plugins.sbt

プラグインを追加しましょう

addSbtPlugin("com.chatwork" % "sbt-wix-embedded-mysql" % "1.0.9")

addSbtPlugin("jp.co.septeni-original" % "sbt-dao-generator" % "1.0.8")

addSbtPlugin("io.github.davidmweber" % "flyway-sbt" % "5.0.0")

テンプレートを作りましょう

FTLでDAOのテンプレートを書きます。以下はSkinnyORMのための例です。レコードクラスとDAOクラスです。

  case class ${className}Record(
<#list primaryKeys as primaryKey>
    ${primaryKey.propertyName}: ${primaryKey.propertyTypeName}<#if primaryKey_has_next>,</#if></#list><#if primaryKeys?has_content>,</#if>
<#list columns as column>
    <#if column.columnName == "status">
        <#assign softDelete=true>
    </#if>
    <#if column.nullable>    ${column.propertyName}: Option[${column.propertyTypeName}]<#if column_has_next>,</#if>
    <#else>    ${column.propertyName}: ${column.propertyTypeName}<#if column_has_next>,</#if>
    </#if>
</#list>
  ) extends Record

  object ${className}Dao extends Dao[${className}Record] {

      override def useAutoIncrementPrimaryKey: Boolean = false

      override val tableName: String = "${tableName}"

      override protected def toNamedValues(record: ${className}Record): Seq[(Symbol, Any)] = Seq(
<#list columns as column>       '${column.name} -> record.${column.propertyName}<#if column.name?ends_with("id") || column.name?ends_with("Id")>.value</#if><#if column_has_next>,</#if>
</#list>
    )

      override def defaultAlias: Alias[UserAccountRecord] = createAlias("${className[0]?lower_case}")

      override def extract(rs: WrappedResultSet, s: ResultName[${className}Record]): ${className}Record = autoConstruct(rs, s)


  }

特定のDAOに依存しないので、ほとんどのものに対応できるはず。以下は、Slick用とSkinny用の両方に対応したテンプレート例です。どちらでも好きなORMを使ってください。

https://github.com/j5ik2o/scala-ddd-base/blob/reboot/example/templates/template.ftl

テンプレートの書き方はこちら参照。カラム名をあらかじめプロパティ名としてテンプレートコンテキストに含めているので、簡単に書けるはずです。

build.sbt

  • flywayプロジェクト

https://github.com/j5ik2o/scala-ddd-base/blob/master/build.sbt#L114-L136

このプロジェクトではchatwork/sbt-wix-embedded-mysqlとflyway/flyway-sbtを使って自動的にスキーマを作ります。flywayMigrate := (flywayMigrate dependsOn wixMySQLStart).value としているので、sbt flyway/flywayMigrateする前に組み込みMySQLが起動します。

  • exampleプロジェクト

https://github.com/j5ik2o/scala-ddd-base/blob/master/build.sbt#L138-L188

JDBCの接続先設定は、flywayプロジェクトと同じ設定を指定してください。

このプロジェクトでは、septeni-original/sbt-dao-generatorを使ってJDBCメタ情報とテンプレートをマージして、DAOクラスのソースコードを生成します。 今回は生成物をGitで管理したかったので、以下のようにして通常のソースコードと同じパスに出力していますが、(sourceManaged in Compile).valueを使ってtarget/src_managedに出力することも可能です。

outputDirectoryMapper in generator := {
  case s if s.endsWith("Spec") => (sourceDirectory in Test).value
  case s => new java.io.File((scalaSource in Compile).value, "/com/github/j5ik2o/dddbase/example/dao")
},
outputDirectoryMapper in generator := { className: String => (sourceManaged in Compile).value },

コンパイル時にソースコード生成するには以下のようにしてください。コンパイルと無関係に生成タスクを実行したい場合はsbt generator::generateAllとしてください。

// sourceGenerators in Compile時に出力
sourceGenerators in Compile += (generateAll in generator).value

コンパイルより前に出力したい場合は以下でも動作します。

// コンパイルより前に出力
compile in Compile := ((compile in Compile) dependsOn (generateAll in generator)).value

あとでexampleプロジェクトからflywayプロジェクトに依存することをお忘れ無く

val example = ...
dependsOn(..., flyway)

生成

コンパイル時に自動的に以下が行われます。

  1. 組み込みMySQLの起動
  2. flyway マイグレーション実行
  3. JDBCメタ情報とテンプレートのマージとソースファイル出力
  4. コンパイル

実際に生成されたソースコードはこちら。

https://github.com/j5ik2o/scala-ddd-base/blob/master/example/src/main/scala/com/github/j5ik2o/dddbase/example/dao/UserAccount.scala

package com.github.j5ik2o.dddbase.example.dao

package slick {
  import com.github.j5ik2o.dddbase.slick.SlickDaoSupport

  trait UserAccountComponent extends SlickDaoSupport {

    import profile.api._

    case class UserAccountRecord(
        id: Long,
        status: String,
        email: String,
        password: String,
        firstName: String,
        lastName: String,
        createdAt: java.time.ZonedDateTime,
        updatedAt: Option[java.time.ZonedDateTime]
    ) extends SoftDeletableRecord

    case class UserAccounts(tag: Tag)
        extends TableBase[UserAccountRecord](tag, "user_account")
        with SoftDeletableTableSupport[UserAccountRecord] {
      // def id = column[Long]("id", O.PrimaryKey)
      def status    = column[String]("status")
      def email     = column[String]("email")
      def password  = column[String]("password")
      def firstName = column[String]("first_name")
      def lastName  = column[String]("last_name")
      def createdAt = column[java.time.ZonedDateTime]("created_at")
      def updatedAt = column[Option[java.time.ZonedDateTime]]("updated_at")
      override def * =
        (id, status, email, password, firstName, lastName, createdAt, updatedAt) <> (UserAccountRecord.tupled, UserAccountRecord.unapply)
    }

    object UserAccountDao extends TableQuery(UserAccounts)

  }

}

package skinny {

  import com.github.j5ik2o.dddbase.skinny.SkinnyDaoSupport
  import scalikejdbc._
  import _root_.skinny.orm._

  trait UserAccountComponent extends SkinnyDaoSupport {

    case class UserAccountRecord(
        id: Long,
        status: String,
        email: String,
        password: String,
        firstName: String,
        lastName: String,
        createdAt: java.time.ZonedDateTime,
        updatedAt: Option[java.time.ZonedDateTime]
    ) extends Record

    object UserAccountDao extends Dao[UserAccountRecord] {

      override def useAutoIncrementPrimaryKey: Boolean = false

      override val tableName: String = "user_account"

      override protected def toNamedValues(record: UserAccountRecord): Seq[(Symbol, Any)] = Seq(
        'status     -> record.status,
        'email      -> record.email,
        'password   -> record.password,
        'first_name -> record.firstName,
        'last_name  -> record.lastName,
        'created_at -> record.createdAt,
        'updated_at -> record.updatedAt
      )

      override def defaultAlias: Alias[UserAccountRecord] = createAlias("u")

      override def extract(rs: WrappedResultSet, s: ResultName[UserAccountRecord]): UserAccountRecord =
        autoConstruct(rs, s)

    }

  }

}

まとめ

このプロジェクト構成は、仕事でも結構がっつり使っていて気に入っています。スキーマ変更が起こっても、DAOは一瞬で自動生成できるので楽になると思います。興味あれば使ってみてください!


  1. 僕とセプテーニさんとコラボして作りました。