Hibernate/JPAからExposed DAOに乗り換えて、継承クラスを扱いやすくした話
こんにちは。AnyPay CTOのTomo( HAIL )です。本記事は、Qiita Kotlin Advent Calendar 2018 16日目の記事です。
最近書き始めたKotlinサーバでは、JetBrainsのSQL FrameworkであるExposedをつかいはじめました。もうJava/Kotlinではサーバを10年弱ぐらい書いていて、これまではなんだかんだHibernateを使っていたのですが乗り換えました。Exposedは他社さんでも既に本番での利用事例が見受けられるものの、ドキュメントが結構少ないので、弊社のサーバでどのようにモデル周りの設計をしているかと、継承クラス周りでありがたがっていることを書きます。
まず前段として、Exposedではテーブル定義を object
にて行います。
object Users : IntIdTable("users") {
val firstName = varchar("first_name", 30)
val countryCode = integer("country_code").nullable()
val expiryDate = datetime("expiry_date").nullable()
}
次に、このテーブルデータにアクセスする方法として、DSL形式とDAO形式の二通りが提供されています。これは好みの問題ですが、よりORMっぽく利用したい僕はDAO形式を選択しました。その場合、以下のように対応するDAO用のクラスを定義します。
class UserDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<UserDAO>(Users) var firstName by Users.firstName
var countryCode by Users.countryCode
var expiryDate by Users.expiryDate}
この2つが最低限定義するべきものなんですが、ここで定義した UserDAO
のインスタンスはあくまでDAOなので、 transaction
コンテキストの中でのみ使うべきものです。
transaction {
val mario = SampleUserDAO.new {
firstName = "Mario"
...
}
mario.countryCode = 81
}
上記のように囲っていなければ、
Caused by: java.lang.IllegalStateException: No transaction in context.
at org.jetbrains.exposed.sql.transactions.TransactionManager$Companion.current(TransactionApi.kt:70) ~[na:na]
at org.jetbrains.exposed.dao.EntityClass.new(Entity.kt:669) ~[na:na]
at org.jetbrains.exposed.dao.EntityClass.new(Entity.kt:652) ~[na:na]
のように怒られます。これはこのインスタンスがDB上のレコードを表しているわけなので、当然の振る舞いですが、DBから値を取ってきた結果を使ってDTOを構成し、レスポンスを返すまでの間に使うEntityクラスがあった方がやりやすいので、作ることにしています。ここで、 User
は、 RegisteredUser
(登録済みユーザ)と GuestUser
(ゲストユーザ)の二種類のユーザに分かれる設定にしましょう。
interface User {
var id: Int?
val firstName: String
}class RegisteredUser (
override var id: Int? = null,
override val firstName: String,
val countryCode: Int
) : Userclass GuestUser (
override var id: Int? = null,
override val firstName: String,
val expiryDate: OffsetDateTime
) : User
RegisteredUser
と GuestUser
はそれぞれ、 countryCode
と expiryDate
を独自のフィールドとして持つとします。こうすることで、例えばユーザ登録APIが呼ぶサービス層では、
fun registerUser(user: RegisteredUser): RegisteredUser {
val user: User = transaction {
val userDAO = UserDAO.new {
this.setUser(user)
}
userDAO.getUser()
}
return user as RegisteredUser
}
のように transaction
の中でのみ利用するDAOのインスタンスと、 transaction
の内外で利用するEntityのインスタンスを分けて扱うことができます。
随分と長々と前置きを置くことになってしまったのですが、HibernateとJPA annotation @Entity
を使えば、テーブル定義 Users
も UserDAO
も書く必要がありません。ですが、Hibernateを使っている皆さんはご存知の通り、これを使いこなすには圧倒的Hibernate/JPA力が試されていきます。
あれ、俺はプログラミングをしていたのか、Hibernate/JPAの言われるがままに設定ファイルを書いているのかどっちだ……? という気分に襲われることがままあり、そしてその機会はプロジェクトが複雑になればなるほどに増大していきます。
その一つが、このような継承クラスを利用している場合に、DBのレコードの取得からEntityモデルの初期化の際に、どのクラスで初期化するかの表現方法なのですが、
@Inheritance(strategy= InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name="type",discriminatorType=DiscriminatorType.STRING)
上記は type
というカラムでどのサブクラスのレコードかを表現するためのJPAアノテーションですが、こういった表現を調べている時間がつらいわけです。もっと自由にコードで表現できたらいいのに……。でも生でSQLを書くのはいやだ……とわがままなことをExposedを使って実現しました。
UserDAO
と RegisteredUser
または GuestUser
の間を繋ぐため、UserDAO
のコードを拡張していきます。
class UserDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<UserDAO>(Users) var firstName by Users.firstName
var countryCode by Users.countryCode
var expiryDate by Users.expiryDate fun setUser(user: User) {
this.firstName = user.firstName
if (user is RegisteredUser) {
this.countryCode = user.countryCode
} else if (user is GuestUser) {
this.expiryDate = DateTime(user.expiryDate.toInstant().toEpochMilli())
}
} fun getUser(): User {
if (countryCode != null) {
return RegisteredUser(
id = this.id.value,
firstName = this.firstName,
countryCode = this.countryCode!!
)
} else {
return GuestUser(
id = this.id.value,
firstName = this.firstName,
expiryDate = OffsetDateTime.ofInstant(
Instant.ofEpochMilli(expiryDate!!.millis),
ZoneId.of("Z")
)
)
}
}
}
嬉しいのは、 getUser()
の、
if (countryCode != null) {
の部分。DAOのどのフィールドがどういった値のときにどのサブクラスで初期化するのか、という条件を自由にコードで記述できました!
おわりに
この記事では、言ってみれば JOOQ や Querydsl ほど生のSQLを書きたくもないが、もう Hibernate に振り回されるのは嫌だ! と言った方向けに、Exposed DAO を使ってテーブル定義、DAO、Entityクラスと定義していくことで快適な暮らしを手に入れた話をしました。
まだまだ発展途上なライブラリではありますが、積極的に使い倒していこうと思っています。ありがとうございました!