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)
生成
コンパイル時に自動的に以下が行われます。
- 組み込みMySQLの起動
- flyway マイグレーション実行
- JDBCメタ情報とテンプレートのマージとソースファイル出力
- コンパイル
実際に生成されたソースコードはこちら。
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は一瞬で自動生成できるので楽になると思います。興味あれば使ってみてください!
-
僕とセプテーニさんとコラボして作りました。↩