Бинарная совместимость Reaktive: как мы её обеспечиваем
Привет! Меня зовут Юрий Влад, я 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 checker. Основной принцип такой: из 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?)?.toIntArray(),
bytecodeVersion = (get("bv") as List?)?.toIntArray(),
data1 = (get("d1") as List?)?.toTypedArray(),
data2 = (get("d2") as List?)?.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 = emptyList(),
nonPublicPackages: List = 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 checker можно добиться бинарной совместимости и вовремя реагировать на изменение её состояния. Для его использования в проекте потребовалось изменить всего два файла и подключить тесты к нашему CI. У этого решения есть некоторые недостатки, но оно всё равно довольно удобно в использовании. Теперь Reaktive будет стараться поддерживать бинарную совместимость для JVM так же, как это делает JetBrains для Kotlin Standard Library.
Спасибо за внимание!