相对二进制兼容性:我们如何提供它

你好 我叫Yuri Vlad,我是Badoo的一名Android开发人员,参与了创建Reaktive库-纯Kotlin上的Reactive Extensions。


任何库都应尽可能遵守二进制兼容性。 如果依赖项中库的不同版本不兼容,则结果将导致运行时崩溃。 例如,当向MVICore添加Reaktive支持时,我们可能会遇到这样的问题。



在本文中,我将简要介绍一下二进制兼容性是什么以及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的一部分,而是由特殊的合成构造函数A实现的,该构造函数在参数中包含类的所有字段。 在从Maven存储库接收依赖项的情况下,我们已经将它们组装好了,无法重新编译它们。


com.sample:lib新版本com.sample:lib ,我们立即将其连接到我们的项目。 我们希望与时俱进! 新功能,新修复程序, 新错误


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

在这种情况下,我们在运行时崩溃。 字节码中createA函数将尝试使用一个参数调用类构造函数,但字节码中没有此类参数。 在具有相同组和名称的所有依赖项中,Gradle将选择具有最新版本的依赖项并将其包括在程序集中。


您很可能已经在项目中遇到了二进制不兼容问题。 就个人而言,当我将应用程序迁移到AndroidX时遇到了这个问题。


您可以在gvsmirnov用户的 示例中的二进制兼容性,不仅是示例 ,Eclipse创建者的“基于Java的不断发展的API 2”以及Jake Wharton最近发表的文章“ Kotlin中的公共API挑战”中阅读有关二进制兼容性的更多信息。


确保二进制兼容性的方法


似乎您只需要尝试进行兼容的更改。 例如,在添加新字段时添加具有默认值的构造函数,通过用新参数覆盖方法将新参数添加到函数中,等等。但是总是很容易犯错。 因此,创建了多种工具来检查同一库的两个不同版本的二进制兼容性,例如:


  1. Java API合规性检查器
  2. 克利尔
  3. Revapi
  4. Japicmp
  5. Japitools
  6. 周刊
  7. Japi检查器
  8. 西格斯特

它们采用两个JAR文件并给出结果:它们的兼容性。


但是,我们正在开发Kotlin库,到目前为止,仅从Kotlin使用才有意义。 这意味着例如对于internal类,我们并不总是需要100%的兼容性。 尽管它们以字节码形式公开,但不太可能在Kotlin码之外使用它们。 因此,为了维持二进制兼容性,kotlin-stdlib JetBrains使用二进制兼容性检查器 。 基本原理是这样的:从JAR文件创建整个公共API的转储并将其写入该文件。 该文件是所有进一步检查的基准(参考),看起来像这样:


 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 } 

对源代码进行更改后,将与当前库进行比较,重新生成基线库,并且如果对基线出现任何更改,则检查失败。 可以通过传递-Doverwrite.output=true来覆盖这些更改。 即使发生二进制兼容更改,也会发生错误。 为了及时更新基准并直接在拉取请求中查看其更改,这是必需的。


二进制兼容性验证器


让我们看看这个工具是如何工作的。 二进制兼容性在JVM(字节码)级别提供,并且与语言无关。 可以使用Kotlin-替换Java类实现而不会破坏二进制兼容性(反之亦然)。
首先,您需要了解库中的类。 我们记得即使对于全局函数和常量,也会创建一个带有文件名和后缀Kt ,例如ContinuationKt 。 要获取所有类,我们使用JDK中的JarFile类,获取指向每个类的指针,并将它们传递给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获取@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错误并提供使用@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并获取它们的MethodNodeFieldNode 。 从类的签名,它们的字段和方法,我们得到ClassBinarySignatureFieldBinarySignatureMethodBinarySignature ,它们在项目中本地声明。 它们全部实现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)将其写入文件或内存,并将其与基线进行比较,这在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) } 

通过提交新的基准,我们以可读格式获得更改,例如,在此commit中


  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.gradleRuntimePublicAPITest


 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") } 

现在,对于每个请求请求,运行./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false并强制开发人员及时更新基线文件。


美中不足


但是,这种方法有一些缺点。


首先,我们必须独立分析对基准文件的更改。 它们的更改并非总是会导致二进制不兼容。 例如,如果实现一个新接口,则基线会有这样的差异:


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

其次,使用了非预期的工具。 测试不应以将某些文件写入磁盘的形式产生副作用,该文件随后将由同一测试使用,甚至更重要的是,通过环境变量将参数传递给该文件。 最好在Gradle插件中使用此工具并使用任务创建基准。 但我真的不想在验证器中自行更改某些内容,以便以后可以很轻松地从Kotlin存储库中提取所有更改,因为将来将来可能会需要支持这种语言的新构造出现。


第三,仅支持JVM。


结论


使用二进制兼容性检查器,您可以实现二进制兼容性并及时响应其状态更改。 要在项目中使用它,仅需更改两个文件并将测试连接到我们的CI。 该解决方案有一些缺点,但是使用起来还是很方便的。 现在,Reaktive将尝试以与JetBrains对Kotlin标准库相同的方式维护JVM的二进制兼容性。


感谢您的关注!

Source: https://habr.com/ru/post/zh-CN484712/


All Articles