Привет! Меня зовут Юрий Влад, я 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» Джейка Уорт��на.
Способы обеспечения бинарной совместимости
Казалось бы, нужно лишь стараться вносить совместимые изменения. Например, добавлять конструкторы со значением по умолчанию при добавлении новых полей, новые параметры в функции добавлять через переопределение метода с новым параметром и т. д. Но всегда легко совершить ошибку. Поэтому были созданы различные инструменты проверки бинарной совместимости двух разных версий одной библиотеки, такие как:
- Java API Compliance Checker
- Clirr
- Revapi
- Japicmp
- Japitools
- Jour
- Japi-checker
- 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. Он работает так же, как я описал в статье, но теперь для подключения не нужно копировать его к себе в проект, а настраивать стало еще легче
