📑

ArchUnitでKotlinのdata classのcopyメソッドを禁止する

2024/02/16に公開
2

はじめに

ログラスの小林(@mako-makok)です。
ご存知の方も多いと思いますが、Kotlin で data class 宣言をすると、copy というメソッドがそのクラスに対して自動生成されます。
この data class は便利な反面、様々な問題があり、copy メソッドをどうにかして隠したいというニーズがあります。
今回は ArchUnit を使ったアプローチをご紹介します。

Kotlin の data class 宣言で自動生成されるメソッド

改めて、Kotlin には data class という機能があります。
https://kotlinlang.org/docs/data-classes.html

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 で長い間議論されています。
https://youtrack.jetbrains.com/issue/KT-11914

一般的な回避策

実装を 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を利用できません。
https://kotlinlang.org/docs/sealed-classes.html#inheritance-in-multiplatform-projects

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 のメリットを享受することが出来ません。
特に、頻繁に改修が入るクラスであれば、プロパティの拡張でequalshashCodeの実装漏れで同値チェックがすり抜けてしまう危険性もあります。

ArchUnit で copy メソッドを抑制する

この記事の本題です。

ArchUnit とは

ArchUnitは主に Java 向けのライブラリで、Java コードのアーキテクチャをテストできます。
Java 向けですが、Kotlin でも利用できます。
https://www.archunit.org/

使い方はシンプルで、archunit を依存に追加してテストを書き始めることができます。

dependencies {
    testImplementation("com.tngtech.archunit:archunit:1.1.0")
}

ユースケースは公式で公開されていますので、ぜひ御覧ください。
https://www.archunit.org/use-cases

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 メソッドを問題に対するアプローチのご紹介でした。
ログラス社では数ヶ月運用してみましたが、インタフェースがより直感的に記述できるようになったため、非常に効果を感じております。
もしよろしければ参考にしていただけると幸いです!

GitHubで編集を提案
株式会社ログラス テックブログ

Discussion

omuomuginomuomugin

自分も ArchUnit のヘビーユーザーなのでArchUnit の利用者が増えて嬉しい限りです ><

1点だけ気になった点の指摘です

copy をコールすると内部的には自動生成された setter メソッドがコールされます。
setter はインスタンスの再生成をしないので、init ブロックをすり抜けます。

の記述は少なくとも自分の環境で正しくなさそうです。(手元で Java Bytecode に変換後、Java のコードにデコンパイルしてみました)
また公式の data class についての説明でも実装としては以下のようにコンストラクトが呼ばれる旨が記載されています。

The implementation of this function for the User class above would be as follows:

fun copy(name: String = this.name, age: Int = this.age) = User(name, age)

参照 : https://kotlinlang.org/docs/data-classes.html#copying

従って例えば以下のような記述は有効であり init ブロックが実行されることになるかと思います
実際 a.copy(str = "") の部分で java.lang.AssertionError の例外が投げられるかと思います。

fun main() {
    val a = A.of("hoge")
    val b = a.copy(str = "")
}

data class A private constructor(
    val str: String
) {
    companion object {
        fun of(str: String): A {
            return A(str)
        }
    }
    
    init {
        assert(str.isNotEmpty())
    }
}

playground のリンク : https://pl.kotl.in/aBZkEufCz

Makoto KobayashiMakoto Kobayashi

コメント&ご指摘ありがとうございます!
おっしゃるとおりでしたので記事を修正いたしました。