- PVSM.RU - https://www.pvsm.ru -
Привет! Меня зовут Юрий Влад, я Android-разработчик в компании Badoo и принимаю участие в создании библиотеки Reaktive [1] — Reactive Extensions на чистом Kotlin.
Любая библиотека должна по возможности соблюдать бинарную совместимость. Если разные версии библиотеки в зависимостях несовместимы, то результатом будут краши в рантайме. С такой проблемой мы можем столкнуться, например, при добавлении поддержки Reaktive в MVICore [2].

В этой статье я вкратце расскажу, что такое бинарная совместимость и каковы её особенности для Kotlin, а также о том, как её поддерживают в JetBrains, а теперь и в Badoo.
Предположим, у нас есть замечательная библиотека 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.
Подробнее про бинарную совместимость вы можете почитать в статьях «Бинарная совместимость в примерах и не только» [3] пользователя gvsmirnov [4], «Evolving Java-based APIs 2» [5] от создалей Eclipse и в недавно вышедшей статье «Public API challenges in Kotlin» [6] Джейка Уортона.
Казалось бы, нужно лишь стараться вносить совместимые изменения. Например, добавлять конструкторы со значением по умолчанию при добавлении новых полей, новые параметры в функции добавлять через переопределение метода с новым параметром и т. д. Но всегда легко совершить ошибку. Поэтому были созданы различные инструменты проверки бинарной совместимости двух разных версий одной библиотеки, такие как:
Они принимают два JAR-файла и выдают результат: насколько они совместимы.
Однако мы разрабатываем Kotlin-библиотеку, которую пока есть смысл использовать только из Kotlin. А значит, нам не всегда нужна 100%-ная совместимость, например для internal классов. Хоть они и являются публичными в байт-коде, но их использование вне Kotlin-кода маловероятно. Поэтому для поддержания бинарной совместимости kotlin-stdlib JetBrains использует Binary compatibility checker [7]. Основной принцип такой: из 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.
Давайте разберём, как работает этот инструмент. Бинарная совместимость обеспечивается на уровне 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 nu0002u0018u0002nu0002u0010u0000nu0000nu0002u0010bnu0002bu0006nu0002u0010u000bnu0002bu0003nu0002u0010u000enu0000bu0086bu0018u00002u00020u0001Bru0012u0006u0010u0002u001au00020u0003¢u0006u0002u0010u0004Jtu0010u0007u001au00020u0003HÆu0003Ju0013u0010bu001au00020u00002bbu0002u0010u0002u001au00020u0003HÆu0001Ju0013u0010tu001au00020n2bu0010u000bu001au0004u0018u00010u0001HÖu0003Jtu0010fu001au00020u0003HÖu0001Jtu0010ru001au00020u000eHÖu0001Ru0011u0010u0002u001au00020u0003¢u0006bnu0000u001au0004bu0005u0010u0006¨u0006u000f"},
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, мы получим изменения в читабельном формате, как, например, в этом коммите [8]:
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 checker [7] можно добиться бинарной совместимости и вовремя реагировать на изменение её состояния. Для его использования в проекте потребовалось изменить всего два файла и подключить тесты к нашему CI. У этого решения есть некоторые недостатки, но оно всё равно довольно удобно в использовании. Теперь Reaktive будет стараться поддерживать бинарную совместимость для JVM так же, как это делает JetBrains для Kotlin Standard Library.
Спасибо за внимание!
Автор: ChPr
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/344873
Ссылки в тексте:
[1] Reaktive: https://github.com/badoo/Reaktive
[2] MVICore: https://github.com/badoo/MVICore
[3] «Бинарная совместимость в примерах и не только»: https://habr.com/ru/post/133907/
[4] gvsmirnov: https://habr.com/ru/users/gvsmirnov/
[5] «Evolving Java-based APIs 2»: https://wiki.eclipse.org/Evolving_Java-based_APIs_2
[6] «Public API challenges in Kotlin»: https://jakewharton.com/public-api-challenges-in-kotlin/
[7] Binary compatibility checker: https://github.com/jetBrains/kotlin/tree/master/libraries/tools/binary-compatibility-validator
[8] коммите: https://github.com/badoo/Reaktive/pull/312/commits/c8985c15d24346ecfe89cb2ac84fbe7cf6cbeb3e
[9] Источник: https://habr.com/ru/post/484712/?utm_source=habrahabr&utm_medium=rss&utm_campaign=484712
Нажмите здесь для печати.