Compatibilidad binaria reactiva: cómo la proporcionamos

Hola Mi nombre es Yuri Vlad, soy desarrollador de Android en Badoo y participo en la creación de la biblioteca Reaktive - Extensiones reactivas en Kotlin puro.


Cualquier biblioteca debe observar la compatibilidad binaria siempre que sea posible. Si las diferentes versiones de la biblioteca en las dependencias son incompatibles, el resultado se bloqueará en tiempo de ejecución. Podemos encontrar un problema de este tipo, por ejemplo, al agregar soporte Reaktive a MVICore .



En este artículo, le diré brevemente qué es la compatibilidad binaria y cuáles son sus características para Kotlin, así como también cómo se admite en JetBrains y ahora en Badoo.


Problema de compatibilidad binaria de Kotlin


Supongamos que tenemos una biblioteca maravillosa com.sample:lib:1.0 con esta clase:


 data class A(val a: Int) 

En base a esto, creamos una segunda biblioteca com.sample:lib-extensions:1.0 . Entre sus dependencias se encuentra com.sample:lib:1.0 . Por ejemplo, contiene un método de fábrica para la clase A :


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

Ahora com.sample:lib:2.0 la nueva versión de nuestra biblioteca com.sample:lib:2.0 con el siguiente cambio:


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

El cambio totalmente compatible de Kotlin, ¿no? Con el parámetro predeterminado, podemos continuar usando la construcción val a = A(a) , pero solo si todas las dependencias se vuelven a compilar por completo. Los parámetros predeterminados no son parte de la JVM y están implementados por el constructor sintético especial A , que contiene todos los campos de la clase en los parámetros. En el caso de recibir dependencias del repositorio de Maven, las tenemos ya ensambladas y no podemos volver a compilarlas.


Se com.sample:lib una nueva versión de com.sample:lib , e inmediatamente la conectamos a nuestro proyecto. ¡Queremos estar al día! Nuevas características, nuevas correcciones, nuevos errores !


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

Y en este caso, nos estrellamos en tiempo de ejecución. createA función createA en el bytecode intentará llamar al constructor de clase con un parámetro, pero no existe tal en el bytecode. De todas las dependencias con el mismo grupo y nombre, Gradle seleccionará la que tenga la versión más reciente y la incluirá en el ensamblado.


Lo más probable es que ya haya encontrado incompatibilidad binaria en sus proyectos. Personalmente, me encontré con esto cuando migré nuestras aplicaciones a AndroidX.


Puede leer más sobre la compatibilidad binaria en los artículos “Compatibilidad binaria en ejemplos y no solo” del usuario de gvsmirnov , “Evolución de las API 2 basadas en Java” de los creadores de Eclipse y en el artículo recientemente publicado “Desafíos de la API pública en Kotlin” de Jake Wharton.


Formas de garantizar la compatibilidad binaria


Parece que solo necesita intentar hacer cambios compatibles. Por ejemplo, agregue constructores con un valor predeterminado al agregar nuevos campos, agregue nuevos parámetros a las funciones anulando un método con un nuevo parámetro, etc. Pero siempre es fácil cometer un error. Por lo tanto, se crearon varias herramientas para verificar la compatibilidad binaria de dos versiones diferentes de la misma biblioteca, tales como:


  1. Comprobador de cumplimiento de API Java
  2. Clirr
  3. Revapi
  4. Japicmp
  5. Japitools
  6. Jour
  7. Japi-checker
  8. Sigtest

Toman dos archivos JAR y dan el resultado: cuán compatibles son.


Sin embargo, estamos desarrollando la biblioteca Kotlin, que hasta ahora tiene sentido usar solo desde Kotlin. Esto significa que no siempre necesitamos una compatibilidad del 100%, por ejemplo, para internal clases internal . Aunque son públicos en bytecode, su uso fuera del código de Kotlin es poco probable. Por lo tanto, para mantener la compatibilidad binaria, kotlin-stdlib JetBrains utiliza un verificador de compatibilidad binaria . El principio básico es el siguiente: se crea un volcado de toda la API pública a partir del archivo JAR y se escribe en el archivo. Este archivo es una línea de base (referencia) para todas las comprobaciones adicionales, y se ve así:


 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 } 

Después de realizar cambios en el código fuente, la biblioteca de línea base se regenera, en comparación con la actual, y la comprobación falla si aparece algún cambio en la línea base. Estos cambios se pueden sobrescribir pasando -Doverwrite.output=true . Se producirá un error incluso si se han producido cambios binarios compatibles. Esto es necesario para actualizar oportunamente la línea de base y ver sus cambios directamente en la solicitud de extracción.


Validador de compatibilidad binaria


Veamos cómo funciona esta herramienta. La compatibilidad binaria se proporciona en el nivel JVM (bytecode) y es independiente del lenguaje. Es posible reemplazar la implementación de la clase Java con Kotlin sin romper la compatibilidad binaria (y viceversa).
Primero debe comprender qué clases hay en la biblioteca. Recordamos que incluso para funciones y constantes globales, se crea una clase con el nombre del archivo y el sufijo Kt , por ejemplo, ContinuationKt . Para obtener todas las clases, utilizamos la clase JarFile del JDK, obtenemos punteros a cada clase y los pasamos a org.objectweb.asm.tree.ClassNode . Esta clase nos permitirá conocer la visibilidad de la clase, sus métodos, campos y anotaciones.


 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, al compilar, agrega su anotación de tiempo de ejecución @Metadata a cada clase para que kotlin-reflect pueda restaurar el aspecto de la clase Kotlin antes de que se convierta en bytecode. Se ve así:


 @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 obtener la anotación @Metadata de @Metadata y analizarla en KotlinClassHeader . kotlin-reflect hacerlo manualmente, porque kotlin-reflect no sabe cómo trabajar con 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 es necesaria para manejar adecuadamente internal , porque no existe en el bytecode. Los cambios en internal clases y funciones internal no pueden afectar a los usuarios de la biblioteca, aunque son una API pública en términos de bytecode.


Desde kotlin.Metadata puede encontrar información sobre companion object . Incluso si lo declara privado, se almacenará en el campo público estático Companion , lo que significa que este campo se encuentra bajo el requisito de compatibilidad binaria.


 class CompositeException() { private companion object { } } 

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

De las anotaciones necesarias, vale la pena señalar @PublishedApi para las clases y métodos que se utilizan en funciones públicas en inline . El cuerpo de tales funciones permanece en los lugares de su llamada, lo que significa que las clases y los métodos en ellas deben ser compatibles con los binarios. Cuando intenta utilizar clases y métodos no públicos en tales funciones, el compilador de Kotlin @PublishedApi error y ofrecerá marcarlos con la anotación @PublishedApi .


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

El árbol de herencia de clases y la implementación de interfaces son importantes para admitir la compatibilidad binaria. No podemos, por ejemplo, simplemente eliminar alguna interfaz de la clase. Y obtener la clase principal y las interfaces implementables es bastante simple.


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

Object eliminado de la lista, ya que el seguimiento no tiene ningún sentido.


Dentro del validador hay muchas verificaciones adicionales específicas de Kotlin adicionales: verificar los métodos predeterminados en las interfaces a través de Interface$DefaultImpls , ignorar las clases $WhenMappings para el operador when trabajar, y otros.


A continuación, debe pasar por todo el ClassNode y obtener su MethodNode y FieldNode . A partir de la firma de las clases, sus campos y métodos, obtenemos ClassBinarySignature , FieldBinarySignature y MethodBinarySignature , que se declaran localmente en el proyecto. Todos ellos implementan la interfaz MemberBinarySignature , pueden determinar su visibilidad pública utilizando el método isEffectivelyPublic y mostrar su firma en un formato legible. 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) } } 

Después de recibir la lista ClassBinarySignature , puede escribirla en un archivo o memoria utilizando el método dump(to: Appendable) y compararla con la línea de base, que ocurre en la prueba 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) } 

Al confirmar una nueva línea de base, obtenemos los cambios en un formato legible, como, por ejemplo, en esta confirmación :


  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 { 

Usando un validador en su proyecto


El uso es extremadamente simple. Copie el binary-compatibility-validator en su proyecto y cambie su build.gradle y 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") } 

En nuestro caso, una de las funciones de prueba del archivo RuntimePublicAPITest ve así:


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

Ahora, para cada solicitud de extracción, ejecute ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false y obligue a los desarrolladores a actualizar los archivos de referencia a tiempo.


Volar en la pomada


Sin embargo, este enfoque tiene algunos puntos negativos.


Primero, debemos analizar independientemente los cambios en los archivos de línea de base. No siempre sus cambios conducen a incompatibilidad binaria. Por ejemplo, si implementa una nueva interfaz, obtiene una diferencia en la línea de base:


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

En segundo lugar, se utilizan herramientas que no están destinadas a esto. Las pruebas no deberían tener efectos secundarios en la forma de escribir algún archivo en el disco, que luego será utilizado por la misma prueba, y aún más, pasarle parámetros a través de variables de entorno. Sería genial usar esta herramienta en un complemento de Gradle y crear una línea base usando una tarea. Pero realmente no quiero cambiar algo por nuestra cuenta en el validador, por lo que más tarde sería fácil extraer todos sus cambios del repositorio de Kotlin, porque en el futuro pueden aparecer nuevas construcciones en el lenguaje que necesita ser compatible.


Bueno y en tercer lugar, solo se admite JVM.


Conclusión


Con el comprobador de compatibilidad binaria, puede lograr la compatibilidad binaria y responder a tiempo a un cambio en su estado. Para usarlo en el proyecto, era necesario cambiar solo dos archivos y conectar las pruebas a nuestro CI. Esta solución tiene algunas desventajas, pero sigue siendo bastante conveniente de usar. Ahora Reaktive intentará mantener la compatibilidad binaria para JVM de la misma manera que JetBrains lo hace para la Biblioteca estándar de Kotlin.


Gracias por su atencion!

Source: https://habr.com/ru/post/484712/


All Articles