はじめに
プロジェクトをKotlin+Springでリライトしようと思い、JPAにおける値オブジェクトの表現について考えてみました。
まだKotlin歴が浅く、コード上おかしなところがあるかもしれませんがご容赦下さいm(_ _)m
JPAと値オブジェクト
JPAで値オブジェクトを永続化する方法は、おそらく大きく分けると以下のいずれかになると思います。
- EmbeddableとAttributeOverrideを使う
- AttributeConverterを使う
- ElementCollectionを使う
- Entityと代理識別子を使う
1. EmbeddableとAttributeOverrideを使う
一つ目の方法は、@Embeddableと@AttributeOverrideを使って値クラスを埋め込む方法です。
JPAでIdクラスを値オブジェクト化して主キーに使う方法はおそらくこの方法のみで、さらにこの方法を使う場合はAutoIncrementな主キーを使うことは出来ません。
(後述の"4. Entityと代理識別子を使う"の方法では、サロゲートキーを隠蔽してAutoIncrementにすることができますが、システム内でユニークなIdを発行する仕組みを自前で用意する必要があります。もし良い方法があればお教えください。。)
// 商品Idクラス
@Embeddable
data class ItemId(
val value: String
): Serializable
// 商品エンティティクラス
@Entity
class Item(
@EmbeddedId
@AttributeOverride(name = "value", column = Column(name = "item_id"))
val itemId: ItemId,
val name: String,
val price: Long
)
2. AttributeConverterを使う
二つ目の方法は、独自の変換クラスを用意してテーブルのカラム型と独自型を変換する手法です。
例えば性別を表現する独自のEnumクラスをデータベースの文字列カラムに保存したい場合は以下のようにします。
// 性別を表す型
enum class Gender {
MALE, FEMALE, OTHER, UNKNOWN
}
// DBとオブジェクト表現の変換用クラス
class GenderConverter: AttributeConverter<Gender, String> {
override fun convertToDatabaseColumn(attribute: Gender?): String {
return attribute?.toString() ?: "UNKNOWN"
}
override fun convertToEntityAttribute(dbData: String?): Gender {
return dbData?.let { Gender.valueOf(it) } ?: Gender.UNKNOWN
}
}
// 顧客を表すクラス
@Entity
class Customer(
val name: String,
@Convert(converter = GenderConverter::class) // 変換クラスを指定
val gender: Gender
) ...
ここまで紹介した2つの方法は、いずれも値オブジェクトが同じテーブルに存在する場合のみ使うことができます。
しかし、実際の開発ではある程度の塊ごとにテーブルを分割したい場合が多々あります。
ここから紹介する2つの方法では、値オブジェクトを別のテーブルに分割して保存することができます。
3. ElementCollectionを使う
エンティティが持つ値オブジェクトがCollectionの場合は、ElementCollectionアノテーションを使うことができます。
例えば、以下は商品カートを表すEntityクラスに購入したい商品のIdのリストが含まれるケースです。
// 商品Id
@Embeddable
data class ItemId(
val value: Long
): Serializable
// カートId
@Embeddable
data class CartId(
val value: Long
): Serializable
// カートエンティティ
@Entity
class Cart(
@EmbeddedId
@AttributeOverride(name = "value", column = Column(name = "cart_id"))
val cartId: CartId
) {
@ElementCollection
private val items: MutableList<ItemId> = mutableListOf() // 別テーブルに保存されるリスト
// 現在のカートにあるItemIdの一覧を取得する
fun currentItems() = this.items.toList()
// カートにItemを追加する
fun addItem(itemId: ItemId): Unit {
if(!this.items.contains(itemId)) {
this.items.add(itemId)
}
}
// カートからItemを削除する
fun removeItem(itemId: ItemId): Unit {
this.items.remove(itemId)
}
}
カートの商品はList型のフィールドで表現されていますが、@ElementCollectionをつけることによってこれを別テーブルに永続化することが出来ます。
命名規則を指定することもできますが、この場合だとデフォルトではitemsはcart_itemsテーブルに保存され、cart_itemsテーブルのcart_cart_idカラムに関連するカートのcart_idが保存されます。
4. Entityと代理識別子を使う
最後はEntityと代理識別子を使う方法です。
これは実践ドメイン駆動設計の中で提案されていた方法で、値オブジェクトを「データベース上のエンティティ(≒テーブル)」にまるっとマッピングしてしまおうというものです。
この方法では値オブジェクトを格納するためのテーブルに予めサロゲートキー(代理識別子)を用意しておき、レイヤースーパータイプ(Martin Fowler, PoEAA)パターンを使ってその代理識別子を隠蔽します。
以下の例では、注文を表すOrderエンティティと、それに紐づく顧客を表すCustomerクラス
を用意し、ここではCustomerは値オブジェクトとして扱いたいけど別テーブルに保存するケースを想定します。
// 代理識別子を隠蔽するための抽象クラス
@MappedSuperclass
abstract class IdentifiedDomainObject {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
protected val id: Long? = null
}
// 注文を表すEntityクラス
@Table(name = "`order`") // orderが予約語なので自動生成クエリだとこけることに後で気づきました。。
@Entity
class Order(
@EmbeddedId
@AttributeOverride(name = "value", column = Column(name = "order_id"))
val orderId: OrderId,
@OneToOne(cascade = arrayOf(CascadeType.ALL))
@JoinColumn(name = "customer_id")
val customer: Customer // この値オブジェクトが別テーブルに保存される
)
// 顧客を表す値クラス
// テーブル分割のために@Entityを使う
@Entity
data class Customer(
@Convert(converter = FullnameConverter::class)
val name: Fullname, // せっかくなのでこれも値オブジェクトに
@Embedded
@AttributeOverrides(
AttributeOverride(name = "mail", column = Column(name = "mail")),
AttributeOverride(name = "tel", column = Column(name = "tel"))
)
val contact: Contact // せっかくなのでこれも値オブジェクトに
): IdentifiedDomainObject
ポイントはIdentifiedDomainObjectと@MappedSuperclassで、この抽象クラスが内部的にidフィールドを持っています。
Customerはこのクラスを継承しているのでJPAのEntityとして振る舞うことが出来ますが、idフィールドはprotectedで外部公開されないため、外から見たときCustomerクラスはただの値オブジェクトのように見えるのです。
また、このテクニックを用いるとEntityクラスにも同様にサロゲートキーを導入することができるため、最初に紹介したEmbeddableと併せて使うことにより、AutoIncrementなidを持ちつつ独自のIdを使うことができます。
まとめ
KotlinというよりほぼJPAの話になってしまいましたが、Javaよりもかなりシンプルに書けることがおわかりいただけたかと思います。
また、値オブジェクトをkotlinのenumやdata classで表現することにより、kotlinのパターンマッチ等を利用して更に可読性の高いコードを書ける可能性が広がりました。
Kotlin純正のフレームワークやライブラリも増えてきましたが、サーバサイドはまだまだSpringやJPAにお世話になることが多いと思うので、今後ももう少しKotlin+Springの可能性を掘り下げていきたいと思います。
[おまけ] SpringとKotlinを使ってみた感想
今回はSpring5とKotlin1.1で色々と試してみましたが、ほぼ大きくハマることはなく、Javaの構文をKotlinに書き直すだけで簡単に導入できました。
少ない学習コストでKotlinの便利な言語仕様の恩恵を受けられるため、Java+Springユーザには非常に良い選択肢だと感じました。