Привет! Меня зовут Юрий Влад, я Android-разработчик в компании Badoo и принимаю участие в создании библиотеки Reaktive — Reactive Extensions на чистом Kotlin.


Любая библиотека должна по возможности соблюдать бинарную совместимость. Если разные версии библиотеки в зависимостях несовместимы, то результатом будут краши в рантайме. С такой проблемой мы можем столкнуться, например, при добавлении поддержки Reaktive в MVICore.



В этой статье я вкратце расскажу, что такое бинарная совместимость и каковы её особенности для Kotlin, а также о том, как её поддерживают в JetBrains, а теперь и в Badoo.


Проблема бинарной совместимости в Kotlin


Предположим, у нас есть замечательная библиотека com.sample:lib:1.0 с таким классом:


data class A(val a: Int)

На базе неё мы создали вторую библиотеку com.sample:lib-extensions:1.0. Среди её зависимостей есть com.sample:lib:1.0. Например, она содержит фабричный метод для класса A:


fun createA(a: Int = 0): A = A(a)

Теперь выпустим новую версию нашей библиотеки com.sample:lib:2.0 со следующим изменением:


data class A(val a: Int, val b: String? = null)

Полностью совместимое с точки зрения Kotlin изменение, не так ли? С параметром по умолчанию мы можем продолжать использовать конструкцию val a = A(a), но только в случае полной перекомпиляции всех зависимостей. Параметры по умолчанию не являются частью JVM и реализованы специальным synthetic-конструктором A, который содержит в параметрах все поля класса. В случае получения зависимостей из репозитория Maven мы их получаем уже в собранном виде и перекомпилировать их не можем.


Выходит новая версия com.sample:lib, и мы сразу же подключаем её к своему проекту. Мы же хотим быть up to date! Новые функции, новые исправления, новые баги!


dependencies {
    implementation 'com.sample:lib:2.0'
    implementation 'com.sample:lib-extensions:1.0'
}

И в этом случае мы получим краш в рантайме. createA функция в байт-коде попытается вызвать конструктор класса А с одним параметром, а такого в байт-коде уже нет. Из всех зависимостей с одинаковыми группой и именем Gradle выберет ту, которая имеет самую свежую версию, и включит её в сборку.


Скорее всего, вы уже сталкивались с бинарной несовместимостью в своих проектах. Лично я столкнулся с этим, когда мигрировал наши приложения на AndroidX.


Подробнее про бинарную совместимость вы можете почитать в статьях «Бинарная совместимость в примерах и не только» пользователя gvsmirnov, «Evolving Java-based APIs 2» от создалей Eclipse и в недавно вышедшей статье «Public API challenges in Kotlin» Джейка Уорт��на.


Способы обеспечения бинарной совместимости


Казалось бы, нужно лишь стараться вносить совместимые изменения. Например, добавлять конструкторы со значением по умолчанию при добавлении новых полей, новые параметры в функции добавлять через переопределение метода с новым параметром и т. д. Но всегда легко совершить ошибку. Поэтому были созданы различные инструменты проверки бинарной совместимости двух разных версий одной библиотеки, такие как:


  1. Java API Compliance Checker
  2. Clirr
  3. Revapi
  4. Japicmp
  5. Japitools
  6. Jour
  7. Japi-checker
  8. SigTest

Они принимают два JAR-файла и выдают результат: насколько они совместимы.


Однако мы разрабатываем Kotlin-библиотеку, которую пока есть смысл использовать только из Kotlin. А значит, нам не всегда нужна 100%-ная совместимость, например для internal классов. Хоть они и являются публичными в байт-коде, но их использование вне Kotlin-кода маловероятно. Поэтому для поддержания бинарной совместимости kotlin-stdlib JetBrains использует Binary compatibility validator. Основной принцип такой: из JAR-файла создаётся дамп всего публичного API и записывается в файл. Этот файл является baseline (эталоном) для всех дальнейших проверок, а выглядит он так:


public final class kotlin/coroutines/ContinuationKt {
    public static final fun createCoroutine (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation;
    public static final fun createCoroutine (Lkotlin/jvm/functions/Function2;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation;
    public static final fun startCoroutine (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)V
    public static final fun startCoroutine (Lkotlin/jvm/functions/Function2;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)V
}

После внесения изменений в исходный код библиотеки baseline заново генерируется, сравнивается с текущим — и проверка завершается с ошибкой, если появились любые изменения в baseline. Эти изменения можно перезаписать, передав -Doverwrite.output=true. Ошибка возникнет, даже если произошли бинарно совместимые изменения. Это нужно для того, чтобы своевременно обновлять baseline и видеть его изменения прямо в pull request.


Binary compatibility validator


Давайте разберём, как работает этот инструмент. Бинарная совместимость обеспечивается на уровне JVM (байт-кода) и не зависит от языка. Вполне возможно заменить реализацию Java-класса на Kotlin-, не сломав бинарную совместимость (и наоборот).
Сначала нужно вообще понять, какие классы есть в библиотеке. Мы помним, что даже для глобальных функций и констант создаётся класс с именем файла и суффиксом Kt, например ContinuationKt. Для получения всех классов воспользуемся классом JarFile из JDK, получим указатели на каждый класс и передадим их в org.objectweb.asm.tree.ClassNode. Этот класс позволит нам узнать видимость класса, его методы, поля и аннотации.


val jar = JarFile("/path/to/lib.jar")
val classStreams = jar.classEntries().map { entry -> jar.getInputStream(entry) }
val classNodes = classStreams.map { 
    it.use { stream ->
        val classNode = ClassNode()
        ClassReader(stream).accept(classNode, ClassReader.SKIP_CODE)
        classNode
    }
}

Kotlin при компиляции добавляет свою рантайм-аннотацию @Metadata к каждому классу, чтобы kotlin-reflect смог восстановить вид Kotlin-класса до его преобразования в байт-код. Выглядит она так:


@Metadata(
   mv = {1, 1, 16},
   bv = {1, 0, 3},
   k = 1,
   d1 = {"\u0000 \n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0000\n\u0002\u0010\b\n\u0002\b\u0006\n\u0002\u0010\u000b\n\u0002\b\u0003\n\u0002\u0010\u000e\n\u0000\b\u0086\b\u0018\u00002\u00020\u0001B\r\u0012\u0006\u0010\u0002\u001a\u00020\u0003¢\u0006\u0002\u0010\u0004J\t\u0010\u0007\u001a\u00020\u0003HÆ\u0003J\u0013\u0010\b\u001a\u00020\u00002\b\b\u0002\u0010\u0002\u001a\u00020\u0003HÆ\u0001J\u0013\u0010\t\u001a\u00020\n2\b\u0010\u000b\u001a\u0004\u0018\u00010\u0001HÖ\u0003J\t\u0010\f\u001a\u00020\u0003HÖ\u0001J\t\u0010\r\u001a\u00020\u000eHÖ\u0001R\u0011\u0010\u0002\u001a\u00020\u0003¢\u0006\b\n\u0000\u001a\u0004\b\u0005\u0010\u0006¨\u0006\u000f"},
   d2 = {"Lcom/sample/A;", "", "a", "", "(I)V", "getA", "()I", "component1", "copy", "equals", "", "other", "hashCode", "toString", "", "app_release"}
)

Из ClassNode можно получить @Metadata аннотацию и распарсить её в KotlinClassHeader. Приходится делать это вручную, поскольку kotlin-reflect не умеет работать с ObjectWeb ASM.


val ClassNode.kotlinMetadata: KotlinClassMetadata?
    get() {
        val metadata = findAnnotation("kotlin/Metadata", false) ?: return null
        val header = with(metadata) {
            KotlinClassHeader(
                kind = get("k") as Int?,
                metadataVersion = (get("mv") as List<Int>?)?.toIntArray(),
                bytecodeVersion = (get("bv") as List<Int>?)?.toIntArray(),
                data1 = (get("d1") as List<String>?)?.toTypedArray(),
                data2 = (get("d2") as List<String>?)?.toTypedArray(),
                extraString = get("xs") as String?,
                packageName = get("pn") as String?,
                extraInt = get("xi") as Int?
            )
        }
        return KotlinClassMetadata.read(header)
    }

kotlin.Metadata понадобится для того, чтобы правильно обрабатывать internal, ведь его не существует в байт-коде. Изменения internal классов и функций не могут повлиять на пользователей библиотеки, хоть они и являются публичным API с точки зрения байт-кода.


Из kotlin.Metadata можно узнать о companion object. Даже если вы его объявите приватным, он всё равно будет храниться в публичном статическом поле Companion, а значит, это поле попадает под требование наличия бинарной совместимости.


class CompositeException() {
    private companion object { }
}

public final static Lcom/badoo/reaktive/base/exceptions/CompositeException$Companion; Companion
  @Ljava/lang/Deprecated;()

Из необходимых аннотаций стоит отметить ещё @PublishedApi для классов и методов, которые используются в публичных inline функциях. Тело таких функций остаётся в местах их вызова, а значит, классы и методы в них должны быть бинарно совместимы. При попытке использовать не публичные классы и методы в таких функциях компилятор Kotlin выдаст ошибку и предложит их пометить аннотацией @PublishedApi.


fun ClassNode.isPublishedApi() = findAnnotation("kotlin/PublishedApi", includeInvisible = true) != null

Для поддержки бинарной совместимости важны дерево наследования классов и реализация интерфейсов. Мы не можем, например, просто удалить какой-то интерфейс из класса. А получить родительский класс и реализуемые интерфейсы довольно просто.


val supertypes = listOf(classNode.superName) - "java/lang/Object" + classNode.interfaces.sorted()

Из списка удалён Object, так как его отслеживание не несёт в себе никакого смысла.


Внутри валидатора содержится очень много различных дополнительных специфичных для Kotlin проверок: проверка методов по умолчанию в интерфейсах через Interface$DefaultImpls, игнорирование $WhenMappings классов для работы when оператора и другие.


Далее необходимо пройтись по всем ClassNode и получить их MethodNode и FieldNode. Из сигнатуры классов, их полей и методов мы получим ClassBinarySignature, FieldBinarySignature и MethodBinarySignature, которые объявлены локально в проекте. Все они реализуют интерфейс MemberBinarySignature, умеют определять свою публичную видимость методом isEffectivelyPublic и выводить свою сигнатуру в читабельном формате val signature: String.


classNodes.map { with(it) {
    val metadata = kotlinMetadata 
    val mVisibility = visibilityMapNew[name]
    val classAccess = AccessFlags(effectiveAccess and Opcodes.ACC_STATIC.inv())

    val supertypes = listOf(superName) - "java/lang/Object" + interfaces.sorted()

    val memberSignatures = (
        fields.map { with(it) { FieldBinarySignature(JvmFieldSignature(name, desc), isPublishedApi(), AccessFlags(access)) } } +
        methods.map { with(it) { MethodBinarySignature(JvmMethodSignature(name, desc), isPublishedApi(), AccessFlags(access)) } }
    ).filter {
        it.isEffectivelyPublic(classAccess, mVisibility)
    }

    ClassBinarySignature(name, superName, outerClassName, supertypes, memberSignatures, classAccess, isEffectivelyPublic(mVisibility), metadata.isFileOrMultipartFacade() || isDefaultImpls(metadata)
} }

Получив список ClassBinarySignature, его можно записать в файл или в память методом dump(to: Appendable) и сравнить с baseline, что и происходит в тесте RuntimePublicAPITest:


class RuntimePublicAPITest {

    @[Rule JvmField]
    val testName = TestName()

    @Test fun kotlinStdlibRuntimeMerged() {
        snapshotAPIAndCompare("../../stdlib/jvm/build/libs", "kotlin-stdlib")
    }

    private fun snapshotAPIAndCompare(
        basePath: String,
        jarPattern: String,
        publicPackages: List<String> = emptyList(),
        nonPublicPackages: List<String> = emptyList()
    ) {
        val base = File(basePath).absoluteFile.normalize()
        val jarFile = getJarPath(base, jarPattern, System.getProperty("kotlinVersion"))

        println("Reading binary API from $jarFile")
        val api = getBinaryAPI(JarFile(jarFile)).filterOutNonPublic(nonPublicPackages)

        val target = File("reference-public-api")
            .resolve(testName.methodName.replaceCamelCaseWithDashedLowerCase() + ".txt")

        api.dumpAndCompareWith(target)
    }

Закоммитив новый baseline, мы получим изменения в читабельном формате, как, например, в этом коммите:


    public static final fun flattenObservable (Lcom/badoo/reaktive/single/Single;)Lcom/badoo/reaktive/observable/Observable;
}

+ public final class com/badoo/reaktive/single/MapIterableKt {
+   public static final fun mapIterable (Lcom/badoo/reaktive/single/Single;Lkotlin/jvm/functions/Function1;)Lcom/badoo/reaktive/single/Single;
+   public static final fun mapIterableTo (Lcom/badoo/reaktive/single/Single;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/badoo/reaktive/single/Single;
+ }

public final class com/badoo/reaktive/single/MapKt {

Использование валидатора в своём проекте


Использовать крайне просто. Скопируйте в свой проект binary-compatibility-validator и измените его build.gradle и RuntimePublicAPITest:


plugins {
    id("org.jetbrains.kotlin.jvm")
}

dependencies {
    implementation(Deps.asm)
    implementation(Deps.asm.tree)
    implementation(Deps.kotlinx.metadata.jvm)

    testImplementation(Deps.kotlin.test.junit)
}

tasks.named("test", Test::class) {

    // В оригинале для зависимостей используются артефакты, но по какой-то причине в моем случае Gradle не смог правильно разрешить зависимости мультиплатформенных библиотек:
    dependsOn(
        ":coroutines-interop:jvmJar",
        ":reaktive-annotations:jvmJar",
        ":reaktive:jvmJar",
        ":reaktive-annotations:jvmJar",
        ":reaktive-testing:jvmJar",
        ":rxjava2-interop:jar",
        ":rxjava3-interop:jar",
        ":utils:jvmJar"
    )

    // Не кешируем этот тест, так как он с побочным эффектом в виде создания baseline-файла:
    outputs.upToDateWhen { false }

    // Задаём параметры теста
    systemProperty("overwrite.output", findProperty("binary-compatibility-override") ?: "true")
    systemProperty("kotlinVersion", findProperty("reaktive_version").toString())
    systemProperty("testCasesClassesDirs", sourceSets.test.get().output.classesDirs.asPath)
    jvmArgs("-ea")
}

В нашем случае одна из тестовых функций файла RuntimePublicAPITest выглядит так:


@Test fun reaktive() {
    snapshotAPIAndCompare("../../reaktive/build/libs", "reaktive-jvm")
}

Теперь для каждого pull request запускаем ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false и заставляем разработчиков вовремя обновлять baseline-файлы.


Ложка дёгтя


Однако у этого подхода есть и плохие стороны.


Во-первых, мы должны самостоятельно анализировать изменения baseline-файлов. Не всегда их изменения приводят к бинарной несовместимости. Например, в случае реализации нового интерфейса получится такая разница в baseline:


- public final class com/test/A {
+ public final class com/test/A : Comparable {

Во-вторых, используются инструменты, которые для этого не предназначены. Тесты не должны иметь сайд-эффекты в виде записи какого-то файла на диск, который будет впоследствии использован этим же тестом, и тем более передавать в него параметры через переменные окружения. Было бы здорово использовать этот инструмент в Gradle-плагине и создавать baseline с помощью задачи. Но очень не хочется самостоятельно что-то менять в валидаторе, чтобы потом легко было подтягивать все его изменения из Kotlin-репозитория, ведь в будущем в языке могут появиться новые конструкции, которые нужно будет поддерживать.


Ну и в-третьих, поддерживается только JVM.


Заключение


С помощью Binary compatibility validator можно добиться бинарной совместимости и вовремя реагировать на изменение её состояния. Для его использования в проекте потребовалось изменить всего два файла и подключить тесты к нашему CI. У этого решения есть некоторые недостатки, но оно всё равно довольно удобно в использовании. Теперь Reaktive будет стараться поддерживать бинарную совместимость для JVM так же, как это делает JetBrains для Kotlin Standard Library.


Спасибо за внимание!


UPD: Разработчики Kotlin наконец-то выпустили Binary compatibility validator в виде отдельного Gradle Plugin. Он работает так же, как я описал в статье, но теперь для подключения не нужно копировать его к себе в проект, а настраивать стало еще легче