ArchUnitでKotlinのdata classのcopyメソッドを禁止する
はじめに
ログラスの小林(@mako-makok)です。
ご存知の方も多いと思いますが、Kotlin で data class 宣言をすると、copy というメソッドがそのクラスに対して自動生成されます。
この data class は便利な反面、様々な問題があり、copy メソッドをどうにかして隠したいというニーズがあります。
今回は ArchUnit を使ったアプローチをご紹介します。
Kotlin の data class 宣言で自動生成されるメソッド
改めて、Kotlin には data class という機能があります。
data class で宣言するだけで自動的にequals
, hashCode
, toString
, componentN
をよしなに実装してくれます。
なので、以下のようなことができるようになります。
data class Clazz(
val foo: String,
val bar: Int
)
fun main() {
// これはtrueになる
Clazz("foo", 1) == Clazz("foo", 1)
// componentNが実装されているので、分解宣言ができる
val (foo, bar) = Clazz("foo", 1)
}
data class の不都合な点
data class の実装されるメソッドの中に、copy メソッドがあります。
copy メソッドはある data class のプロパティを一部だけ入れ替えてインスタンスを作ることができるメソッドです。
data class Clazz(
val foo: String,
val bar: Int
)
fun main() {
// Clazz("foo", 2)となる
Clazz("foo", 1).copy(bar = 2)
}
非常に便利な反面、このメソッドがあることによってファクトリ関数を無視してインスタンスを作ることが可能になってしまいます。
以下の実装だとコンストラクタは隠すことができますが、copy メソッドでメールアドレスのルールを突破できます。
data class MailAddress private constructor (
val value: String
) {
companion object {
fun of(localPart: String, domain: String): MailAddress {
throw IllegalStateExcepton()
return MailAddress("$localPart@$domain")
}
}
}
fun main() {
MailAddress.of("a", "b").copy(value = "broke through")
}
ちなみにこの自動生成される copy メソッドの問題は公式の youtrack で長い間議論されています。
一般的な回避策
実装を interface で隠す
sealed interface MailAddress {
val value: String
private data class MailAddressData(
override val value: String
): MailAddress
companion object {
fun of(localPart: String, domain: String): MailAddress {
return MailAddressData("$localPart@$domain")
}
}
}
fun main() {
// MailAddressはinterfaceなので、copyメソッドは呼び出せない
MailAddress.of("a", "b").copy(value = "broke through")
}
この実装だと MailAddress 自体は interface となっています。そのため、data class によって生成されるメソッドは外では一切利用できません。
data class のメリットを享受しつつ、ファクトリメソッドを経由しないとインスタンスを生成できないようになっています。
これは目的を達成するという意味では完璧ですが、2 点ほど問題があります。
- 拡張関数を使用しなければ
internal
が利用できなくなる - 実装が冗長になる
sealed された class や interface ではinternal
を利用できません。
internal
を利用したい場合、拡張関数を利用する必要があります。
sealed interface MailAddress {
val value: String
private data class MailAddressData(
override val value: String
): MailAddress
companion object {
fun of(localPart: String, domain: String): MailAddress {
return MailAddressData("$localPart@$domain")
}
}
}
internal fun MailAddress.localPart(): String {
return value.substringBefore("@")
}
実装が冗長になる点については、MailAddress
程度であれば問題ありませんが、例えば、以下のようにパターンマッチを実現する実装を目指すと段々とカオスになります。
sealed interface MailAddress {
val value: String
}
sealed interface ValidatedMailAddress: MailAddress {
private data class ValidatedMailAddressData(
override val value: String
): ValidatedMailAddress
}
sealed interface UnValidatedMailAddress: MailAddress {
private data class UnValidatedMailAddress(
override val value: String
): ValidatedMailAddress
}
fun main(mailAddress: MailAddress) {
when (mailAddress) {
is ValidatedMailAddress -> {}
is UnValidatedMailAddress -> {}
}
}
やりたいことは特定の interface を実装した 2 つの コンストラクタが見えない class を作りたいだけですが、interface を複数定義しなければ実現することが出来ません。
data class を使わない
元も子もありませんが、思い切って data class を利用しないというのも 1 つの手です。
class MailAddress private constructor (
val value: String
) {
companion object {
fun of(localPart: String, domain: String): MailAddress {
return MailAddress("$localPart@$domain")
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MailAddress) return false
return value == other.value
}
override fun hashCode(): Int {
return value.hashCode()
}
}
この実装でも目的は達成されるものの、当たり前ですが data class のメリットを享受することが出来ません。
特に、頻繁に改修が入るクラスであれば、プロパティの拡張でequals
やhashCode
の実装漏れで同値チェックがすり抜けてしまう危険性もあります。
ArchUnit で copy メソッドを抑制する
この記事の本題です。
ArchUnit とは
ArchUnit
は主に Java 向けのライブラリで、Java コードのアーキテクチャをテストできます。
Java 向けですが、Kotlin でも利用できます。
使い方はシンプルで、archunit を依存に追加してテストを書き始めることができます。
dependencies {
testImplementation("com.tngtech.archunit:archunit:1.1.0")
}
ユースケースは公式で公開されていますので、ぜひ御覧ください。
ArchUnit の実態は内部でバイトコードの解析とリフレクションでソースコードを解析しており、利用者側は提供されているユーティリティでモジュール内のパッケージ/クラス/メソッドの依存関係をテストできます。
パッケージからメソッド名、あとは引数に至るまで取ることができます。
要は機械的にできそうなソースコードのチェックはだいたい出来ます。
テストの実装
下記をコピーしてパッケージ名などを埋めていただくと試すことができます。
class DomainDataClassCannotUseCopyMethod {
@Test
fun `ドメイン層のデータクラスに付属するcopyメソッドを呼び出すことはできない`() {
val targetPackages = arrayOf(
// 解析対象のパッケージを列挙する
"com.example.domain..",
"com.example.infrastructure..",
)
val allApplicationClasses = ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages(*targetPackages)
methods()
.should(BanDomainLayerDataClassCopyCondition)
.check(allApplicationClasses)
}
private object BanDomainLayerDataClassCopyCondition :
ArchCondition<JavaMethod>("ドメイン層に定義されたdata classのcopyは、同じクラス内でしかコールすることができない") {
override fun check(method: JavaMethod, events: ConditionEvents) {
// Kotlinで自動的に実装されるメソッドにはメタデータの関係上サフィックスに$defaultが付与される
if (method.name != "copy\$default") {
return
}
// domainレイヤー以外のcopyは許容する
if (!method.owner.packageName.startsWith("{your domain package}")) {
return
}
val temporaryAllowMethods = listOf(
"com.example.Main.main()",
)
method.callsOfSelf.forEach { caller ->
if (temporaryAllowMethods.contains(caller.origin.fullName)) {
return@forEach
}
// this.copyは許容する
if (method.owner != caller.originOwner) {
events.add(
SimpleConditionEvent.violated(
/* correspondingObject = */ method,
/* message = */buildErrorMessage(caller = caller),
),
)
}
}
}
private fun buildErrorMessage(caller: JavaMethodCall): String {
val callerClassAndMethod =
"${caller.origin.owner.name.replace("${caller.origin.owner.packageName}.", "")}.${caller.origin.name}"
val calledClassAndMethod = "${caller.target.owner.simpleName}.${caller.name}"
return "$callerClassAndMethod calls $calledClassAndMethod"
}
}
}
このテストを定義することで copy メソッドの乱用が制限されるため、下記のような interface 定義が可能になります。
sealed interface MailAddress {
val value: String
}
data class ValidatedMailAddress private constractor(
override val value: String
): MailAddress
data class UnValidatedMailAddress private constractor(
override val value: String
): MailAddress
最後に
ArchUnit を使う力技になってしまうものの、data class の copy メソッドを問題に対するアプローチのご紹介でした。
ログラス社では数ヶ月運用してみましたが、インタフェースがより直感的に記述できるようになったため、非常に効果を感じております。
もしよろしければ参考にしていただけると幸いです!
Discussion
自分も ArchUnit のヘビーユーザーなのでArchUnit の利用者が増えて嬉しい限りです ><
1点だけ気になった点の指摘です
の記述は少なくとも自分の環境で正しくなさそうです。(手元で Java Bytecode に変換後、Java のコードにデコンパイルしてみました)
また公式の data class についての説明でも実装としては以下のようにコンストラクトが呼ばれる旨が記載されています。
参照 : https://kotlinlang.org/docs/data-classes.html#copying
従って例えば以下のような記述は有効であり init ブロックが実行されることになるかと思います
実際
a.copy(str = "")
の部分でjava.lang.AssertionError
の例外が投げられるかと思います。playground のリンク : https://pl.kotl.in/aBZkEufCz
コメント&ご指摘ありがとうございます!
おっしゃるとおりでしたので記事を修正いたしました。