Scala
MySQL
sbt
Flyway

sbt-dao-generatorとsbt-wix-embedded-mysqlとflyway-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/reboot/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/reboot/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/reboot/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. 僕とセプテーニさんとコラボして作りました。