前書き
オブジェクトのプロパティ更新は、文脈に沿って名前や引数、処理内容等、多様に定義されます。
1つのオブジェクトに絞っても、複数の更新方法を持つ場合があります。
このため、素朴な実装方法では以下のような処理を抽象化することは出来ません。
- 自身を更新する際は、共通の制約に則って内部生成した特定の値を用いる
- 更新結果の整合性をチェックする
この記事では、このような更新処理を抽象化・共通化する方法について考案した内容をまとめます。
かなり大仰になってしまった感が有るので、もっと単純な定義方法など有りましたらコメント頂けると嬉しいです。
状況想定
以下のような共通の制約に則って履歴管理されるエンティティについて、実装クラスが最低限のコードで実装できるような抽象化を行います。
- データストアからの読み出し直後以外は更新禁止
- 論理的な有効期間として、更新対象より過去を指定した更新は禁止
- 差分の無い更新は禁止
なお、今回紹介するコードは以下の環境で作成しました。
-
Kotlin: 2.3.0 -
Java: 25
また、-Xconsistent-data-class-copy-visibility(data classのprivateコンストラクタ定義に関するオプション)と-Xcontext-parameters(context parametersを有効化するオプション)を指定しています。
有効期間管理用クラスについて
まず、前提として有効期間管理用クラスのイメージを示します。
履歴管理用のクラスは以下のように定義します1。
履歴管理されるクラスは、これをプロパティとして持つものとします。
package org.wrongwrong.temporalEntity
import org.wrongwrong.temporalEntity.ValidityFrame.Updated.Companion.update
import kotlin.time.Instant
sealed interface ValidityFrame {
val from: Instant
/**
* 未saveのフレーム
*/
sealed interface Unsaved : ValidityFrame
/**
* 新規作成直後
*/
data class Created private constructor(override val from: Instant) : Unsaved
/**
* [Read]からの更新直後
*/
data class Updated private constructor(override val from: Instant) : Unsaved {
companion object {
/**
* [Read.from] < [nextFrom]な場合に限り更新する
*/
fun Read.update(nextFrom: Instant): Updated {
require(from < nextFrom) { "Invalid next-from: $nextFrom" }
return Updated(from)
}
}
}
/**
* 読み出し直後
*/
data class Read private constructor(override val from: Instant) : ValidityFrame {
/**
* このフレームを[nextFrom]で更新する
*/
fun append(nextFrom: Instant): Updated = update(nextFrom)
companion object {
fun ValidityFrame.Companion.reconstruct(from: Instant): Read = Read(from)
}
}
// 各種操作向け拡張関数定義のレシーバー用
companion object
}
状態としてはUnsavedとReadに大別され、更新操作はReadの場合のみ許可されます。
余計な履歴を作らないため、リポジトリ層での登録処理はUnsavedだった場合のみ行われます。
素朴に実装した際の問題点
履歴管理されるEntityを素朴に定義すると、以下のようになるでしょう。
package org.wrongwrong.core
import org.wrongwrong.temporalEntity.ValidityFrame
data class Entity private constructor(val value: String, val frame: ValidityFrame) {
fun update(value: String, at: Instant): Entity {
require(frame is ValidityFrame.Read) // 更新可能であることをチェック
require(value != this.value) // 差分が有ることをチェック
return copy(value = value, frame.append(at))
}
}
このようなEntityを沢山実装していくような状況では、以下のような問題点が有ります。
- 状態が
Readかのバリデーション実装が都度必要 -
copy時、frameの更新処理が都度必要- やろうと思えば不正な
frameの設定も可能
- やろうと思えば不正な
- 差分有無チェックのバリデーション実装が都度必要
記述の冗長さだけでなく、実装ミスの余地も存在しているため、複数人で何度も実装していけばどこかで何か起きるでしょう。
かと言って、履歴管理されるEntityはそれぞれ異なるプロパティを持ち、更新関数自体も複数定義されうるため、抽象化することも難しいです。
また、抽象化にabstract classを使ってしまうと、より複雑なEntityを定義することが難しくもなります。
このような状況を何とかする方法について考えました。
考案した実装
まず考案した実装の全体を示し、その後個々のクラスを紹介します。
package org.wrongwrong.temporalEntity
import kotlin.time.Instant
package org.wrongwrong.temporalEntity
import kotlin.time.Instant
/**
* 履歴管理されたエンティティ
*/
interface TemporalEntity<T : TemporalEntity<T>> {
val frame: ValidityFrame
}
/**
* [TemporalEntity]に対する操作
*/
sealed class TemporalOperation<T : TemporalEntity<T>, P : TemporalOperation.Params>(
protected val value: T,
protected val block: Receiver<P>.() -> T,
) : (P) -> T {
interface Params {
val at: Instant
}
/**
* 操作が受け取る引数
*/
data class Receiver<P : Params>(val nextFrame: ValidityFrame.Updated, val params: P)
protected abstract fun ValidityFrame.Read.update(at: Instant): ValidityFrame.Updated
override fun invoke(p1: P): T {
val nextFrame = value.frame.let {
require(it is ValidityFrame.Read) { "TemporalOperationはReadフレームに対してのみ実行可能です" }
it.update(p1.at)
}
val nextValue = block(Receiver(nextFrame, p1))
require(value != nextValue) { "差分の無い更新は許可されていません" }
require(nextValue.frame == nextFrame) { "更新されたフレームが利用されていません" }
return nextValue
}
abstract class Append<T : TemporalEntity<T>, P : Params> private constructor(
value: T,
block: Receiver<P>.() -> T,
) : TemporalOperation<T, P>(value, block) {
override fun ValidityFrame.Read.update(at: Instant): ValidityFrame.Updated = this.append(at)
companion object {
fun <T : TemporalEntity<T>, P : Params> T.append(block: Receiver<P>.() -> T): Append<T, P> =
object : Append<T, P>(this@append, block) {}
}
}
}
package org.wrongwrong.temporalEntity
import org.wrongwrong.temporalEntity.ValidityFrame.Updated.Companion.update
import kotlin.time.Instant
sealed interface ValidityFrame {
val from: Instant
/**
* 未saveのフレーム
*/
sealed interface Unsaved : ValidityFrame
/**
* 新規作成直後
*/
data class Created private constructor(override val from: Instant) : Unsaved {
companion object {
/**
* 無限遠まで有効なValidityPeriodの作成
*/
fun ValidityFrame.Companion.create(from: Instant): Created = Created(from)
}
}
/**
* [Read]からの更新直後
*/
data class Updated private constructor(override val from: Instant) : Unsaved {
companion object {
/**
* [Read.from] < [nextFrom]な場合に限り更新する
*/
context(_: TemporalOperation<*, *>)
fun Read.update(nextFrom: Instant): Updated {
require(from < nextFrom) { "Invalid next-from: $nextFrom" }
return Updated(from)
}
}
}
/**
* 読み出し直後
*/
data class Read private constructor(override val from: Instant) : ValidityFrame {
/**
* このフレームを[nextFrom]で更新する
*/
context(_: TemporalOperation<*, *>)
fun append(nextFrom: Instant): Updated = update(nextFrom)
companion object {
fun ValidityFrame.Companion.reconstruct(from: Instant): Read = Read(from)
}
}
// 各種操作向け拡張関数定義のレシーバー用
companion object
}
package org.wrongwrong.core
import org.wrongwrong.temporalEntity.TemporalEntity
import org.wrongwrong.temporalEntity.TemporalOperation
import org.wrongwrong.temporalEntity.TemporalOperation.Append.Companion.append
import org.wrongwrong.temporalEntity.ValidityFrame
import kotlin.time.Clock
import kotlin.time.Instant
data class Entity private constructor(val value: String, override val frame: ValidityFrame) : TemporalEntity<Entity> {
data class UpdateParams(val value: String, override val at: Instant) : TemporalOperation.Params
val update = this.append<_, UpdateParams> {
copy(
value = params.value,
frame = nextFrame,
)
}
}
抽象クラスの定義について
まず、抽象クラスは単にValidityFrameを持つクラスとして定義します。
冒頭で述べた通り、更新関数は多様に定義されることを想定し、ここには一切記述していません。
/**
* 履歴管理されたエンティティ
*/
interface TemporalEntity<T : TemporalEntity<T>> {
val frame: ValidityFrame
}
これを単なるインターフェースとすることには、エンティティを実装する際に抽象クラスの定義を阻害しない利点もあります。
更新操作の定義について
最大の工夫点である、更新操作の定義です。
概要
TemporalOperationは以下の機能を実装しています。
- 更新対象の
TemporalEntityがRead(読み出し直後)であることのバリデーション - 指定方法に沿った
nextFrameの生成 - 更新結果に差分が生じていることのバリデーション
- 2で生成された
nextFrameが更新結果へ適切に設定されていることのバリデーション
override fun invoke(p1: P): T {
val nextFrame = value.frame.let {
require(it is ValidityFrame.Read) { "TemporalOperationはReadフレームに対してのみ実行可能です" }
it.update(p1.at)
}
val nextValue = block(Receiver(nextFrame, p1))
require(value != nextValue) { "差分の無い更新は許可されていません" }
require(nextValue.frame == nextFrame) { "更新されたフレームが利用されていません" }
return nextValue
}
TemporalOperation自体を抽象クラスとしているのは、append以外にも様々な更新関数が登場する場合に対応するためです。
Functionを継承する意図
このクラスは、TemporalEntity.Paramsを受け取ってTemporalEntityを返すFunctionとして定義されています。
...
protected val block: Receiver<T, P>.() -> T,
) : (P) -> T
これにより、実装クラスは更新関数を以下のように関数リテラルとして定義できます。
val update = this.append<_, UpdateParams> {
copy(
value = params.value,
frame = nextFrame,
)
}
これはメンバー関数のように呼び出すことができます。
val params: Entity.UpdateParams = ...
val updated = entity.update(params)
このようにしたことで、以下のような利点が得られています。
- 実装側で自由に、幾つでも更新関数を定義できる
- 実装側の必要記述量を最低限に抑えられる
- 利用側は通常の関数のように違和感無く呼び出せる
更新処理のインターフェース
数の変化する引数を抽象化することは難しいため、更新処理の引数はTemporalOperation.Paramsインターフェースを継承したクラスに絞りました。
interface Params {
val at: Instant
}
更新処理を定義する際常にDTOの実装が必要となる点は賛否有る気もしますが、逆に更新関数のインターフェースをある程度縛れるのはよい点だとも思います。
更新関数の生成方法について
Functional (SAM) interfaceとして定義できなかったため、それっぽく書けるようにしています。
companion object {
fun <T : TemporalEntity<T>, P : Params> T.append(block: Receiver<P>.() -> T): Append<T, P> =
object : Append<T, P>(this@append, block) {}
}
前述した通り、これによって利用側はスマートに実装できるようになっています。
val update = this.append<_, UpdateParams> {
copy(
value = params.value,
frame = nextFrame,
)
}
抽象クラス・更新操作の定義を取り込んだ有効期間管理用クラスについて
以下の制約を設けることで、異常な更新を防ぐことが出来ています。
-
UpdatedはReadが存在する場合のみ生成可能 - 更新関数は
TemporalOperationからのみ呼び出し可能
/**
* [Read]からの更新直後
*/
data class Updated private constructor(override val from: Instant) : Unsaved {
companion object {
/**
* [Read.from] < [nextFrom]な場合に限り更新する
*/
context(_: TemporalOperation<*, *>)
fun Read.update(nextFrom: Instant): Updated {
require(from < nextFrom) { "Invalid next-from: $nextFrom" }
return Updated(from)
}
}
}
/**
* 読み出し直後
*/
data class Read private constructor(override val from: Instant) : ValidityFrame {
/**
* このフレームを[nextFrom]で更新する
*/
context(_: TemporalOperation<*, *>)
fun append(nextFrom: Instant): Updated = update(nextFrom)
}
-
生成処理等、記事化する際に削った部分があるため、厳密さに欠ける部分も有りますがご容赦下さい。 ↩