Compatibilité binaire Reaktive: comment nous la fournissons

Salut Je m'appelle Yuri Vlad, je suis développeur Android chez Badoo et participe à la création de la bibliothèque Reaktive - Reactive Extensions sur Kotlin pur.


Toute bibliothèque doit respecter la compatibilité binaire dans la mesure du possible. Si différentes versions de la bibliothèque dans les dépendances sont incompatibles, le résultat sera un plantage lors de l'exécution. Nous pouvons rencontrer un tel problème, par exemple, lors de l'ajout de la prise en charge de Reaktive à MVICore .



Dans cet article, je vais vous dire brièvement ce qu'est la compatibilité binaire et quelles sont ses fonctionnalités pour Kotlin, ainsi que la façon dont elle est prise en charge dans JetBrains, et maintenant dans Badoo.


Problème de compatibilité binaire Kotlin


Supposons que nous ayons une merveilleuse bibliothèque com.sample:lib:1.0 avec cette classe:


 data class A(val a: Int) 

Sur cette base, nous avons créé une deuxième bibliothèque com.sample:lib-extensions:1.0 . Parmi ses dépendances se trouve com.sample:lib:1.0 . Par exemple, il contient une méthode d'usine pour la classe A :


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

Nous allons maintenant publier la nouvelle version de notre bibliothèque com.sample:lib:2.0 avec la modification suivante:


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

Le changement entièrement compatible de Kotlin, n'est-ce pas? Avec le paramètre par défaut, nous pouvons continuer à utiliser la construction val a = A(a) , mais seulement si toutes les dépendances sont complètement recompilées. Les paramètres par défaut ne font pas partie de la JVM et sont implémentés par le constructeur synthétique spécial A , qui contient tous les champs de la classe dans les paramètres. Dans le cas de la réception de dépendances du référentiel Maven, nous les obtenons déjà assemblés et ne pouvons pas les recompiler.


Une nouvelle version de com.sample:lib et nous la connectons immédiatement à notre projet. Nous voulons être à jour! Nouvelles fonctionnalités, nouveaux correctifs, nouveaux bugs !


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

Et dans ce cas, nous obtenons un crash lors de l'exécution. createA fonction createA dans le bytecode va essayer d'appeler le constructeur de classe avec un paramètre, mais il n'y en a pas un dans le bytecode. De toutes les dépendances avec le même groupe et le même nom, Gradle sélectionnera celle qui a la version la plus récente et l'inclura dans l'assembly.


Très probablement, vous avez déjà rencontré une incompatibilité binaire dans vos projets. Personnellement, je suis tombé sur cela lorsque j'ai migré nos applications vers AndroidX.


Vous pouvez en savoir plus sur la compatibilité binaire dans les articles «Compatibilité binaire dans les exemples et pas seulement» de l'utilisateur de gvsmirnov , «Evolving Java-based APIs 2» des créateurs Eclipse et dans l'article récemment publié «Public API challenges in Kotlin» de Jake Wharton.


Moyens d'assurer la compatibilité binaire


Il semblerait que vous ayez juste besoin d'essayer d'apporter des modifications compatibles. Par exemple, ajoutez des constructeurs avec une valeur par défaut lors de l'ajout de nouveaux champs, ajoutez de nouveaux paramètres aux fonctions en remplaçant une méthode par un nouveau paramètre, etc. Mais il est toujours facile de se tromper. Par conséquent, divers outils pour vérifier la compatibilité binaire de deux versions différentes de la même bibliothèque ont été créés, tels que:


  1. Vérificateur de conformité de l'API Java
  2. Clirr
  3. Revapi
  4. Japicmp
  5. Japitools
  6. Jour
  7. Japi-checker
  8. Sigtest

Ils prennent deux fichiers JAR et donnent le résultat: leur compatibilité.


Cependant, nous développons la bibliothèque Kotlin, qui jusqu'à présent n'a de sens que d'être utilisée uniquement à partir de Kotlin. Cela signifie que nous n'avons pas toujours besoin d'une compatibilité à 100%, par exemple, pour internal classes internal . Bien qu'ils soient publics en bytecode, leur utilisation en dehors du code Kotlin est peu probable. Par conséquent, pour maintenir la compatibilité binaire, kotlin-stdlib JetBrains utilise un vérificateur de compatibilité binaire . Le principe de base est le suivant: un vidage de l'ensemble de l'API publique est créé à partir du fichier JAR et écrit dans le fichier. Ce fichier est une référence (référence) pour toutes les vérifications ultérieures, et il ressemble à ceci:


 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 } 

Après avoir apporté des modifications au code source, la bibliothèque de ligne de base est régénérée, par rapport à la bibliothèque actuelle, et la vérification échoue si des modifications de la ligne de base apparaissent. Ces modifications peuvent être remplacées en passant -Doverwrite.output=true . Une erreur se produira même si des modifications compatibles binaires se sont produites. Cela est nécessaire afin de mettre à jour en temps opportun la ligne de base et de voir ses changements directement dans la demande d'extraction.


Validateur de compatibilité binaire


Voyons comment fonctionne cet outil. La compatibilité binaire est fournie au niveau JVM (bytecode) et est indépendante du langage. Il est possible de remplacer l'implémentation de la classe Java par Kotlin- sans casser la compatibilité binaire (et vice versa).
Vous devez d'abord comprendre quelles classes sont dans la bibliothèque. Nous nous souvenons que même pour les fonctions globales et les constantes, une classe est créée avec le nom de fichier et le suffixe Kt , par exemple, ContinuationKt . Pour obtenir toutes les classes, nous utilisons la classe JarFile du JDK, obtenons des pointeurs vers chaque classe et les transmettons à org.objectweb.asm.tree.ClassNode . Cette classe nous fera connaître la visibilité de la classe, ses méthodes, champs et annotations.


 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, lors de la compilation, ajoute son annotation d'exécution @Metadata à chaque classe afin que kotlin-reflect puisse restaurer l'apparence de la classe Kotlin avant sa conversion en bytecode. Cela ressemble à ceci:


 @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 pouvez obtenir l'annotation @Metadata de @Metadata et l'analyser dans KotlinClassHeader . Vous devez le faire manuellement, car kotlin-reflect ne sait pas comment travailler avec 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 est nécessaire pour gérer correctement internal , car il n'existe pas dans le bytecode. Les modifications apportées aux classes et fonctions internal ne peuvent pas affecter les utilisateurs de la bibliothèque, bien qu'elles soient une API publique en termes de bytecode.


De kotlin.Metadata, vous pouvez en savoir plus sur l' companion object . Même si vous le déclarez privé, il sera toujours stocké dans le champ statique public Companion , ce qui signifie que ce champ est soumis à l'exigence de compatibilité binaire.


 class CompositeException() { private companion object { } } 

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

Parmi les annotations nécessaires, il convient de noter @PublishedApi pour les classes et méthodes utilisées dans les fonctions publiques en inline . Le corps de ces fonctions reste à la place de leur appel, ce qui signifie que les classes et méthodes qu'elles contiennent doivent être compatibles binaires. Lorsque vous essayez d'utiliser des classes et des méthodes non publiques dans de telles fonctions, le compilateur Kotlin @PublishedApi erreur et suggère qu'elles soient annotées avec @PublishedApi .


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

L'arbre de l'héritage de classe et l'implémentation d'interfaces sont importants pour prendre en charge la compatibilité binaire. Nous ne pouvons pas, par exemple, simplement supprimer une interface de la classe. Et obtenir la classe parente et les interfaces implémentables est assez simple.


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

Object supprimé de la liste, car son suivi n'a aucun sens.


À l'intérieur du validateur, il existe de nombreuses vérifications supplémentaires spécifiques à Kotlin: vérification des méthodes par défaut dans les interfaces via Interface$DefaultImpls , ignorant les classes $WhenMappings pour que l'opérateur fonctionne, et d'autres.


Ensuite, vous devez parcourir tous les ClassNode et obtenir leurs MethodNode et FieldNode . À partir de la signature des classes, de leurs champs et méthodes, nous obtenons ClassBinarySignature , FieldBinarySignature et MethodBinarySignature , qui sont déclarés localement dans le projet. Tous implémentent l'interface MemberBinarySignature , sont capables de déterminer leur visibilité publique à l'aide de la méthode isEffectivelyPublic et d'afficher leur signature dans un format lisible 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) } } 

Après avoir reçu la liste ClassBinarySignature , vous pouvez l'écrire dans un fichier ou une mémoire à l'aide de la méthode dump(to: Appendable) et la comparer avec la ligne de base, ce qui se produit dans le test 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) } 

En validant une nouvelle ligne de base, nous obtenons les modifications dans un format lisible, comme, par exemple, dans ce 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 { 

Utiliser un validateur dans votre projet


L'utilisation est extrêmement simple. Copiez binary-compatibility-validator dans votre projet et modifiez son build.gradle et 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") } 

Dans notre cas, l'une des fonctions de test du fichier RuntimePublicAPITest ressemble à ceci:


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

Maintenant, pour chaque demande d'extraction, exécutez ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false et forcez les développeurs à mettre à jour les fichiers de base à temps.


Voler dans la pommade


Cependant, cette approche a quelques mauvais points.


Tout d'abord, nous devons analyser indépendamment les modifications apportées aux fichiers de référence. Leurs modifications ne conduisent pas toujours à une incompatibilité binaire. Par exemple, si vous implémentez une nouvelle interface, vous obtenez une telle différence de référence:


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

Deuxièmement, des outils qui ne sont pas destinés à cela sont utilisés. Les tests ne devraient pas avoir d'effets secondaires sous forme d'écriture d'un fichier sur le disque, qui sera ensuite utilisé par le même test, et plus encore, en lui passant des paramètres via des variables d'environnement. Ce serait formidable d'utiliser cet outil dans un plugin Gradle et de créer une base de référence à l'aide d'une tâche. Mais je ne veux vraiment pas changer quelque chose par nous-mêmes dans le validateur, afin que plus tard, il soit facile de retirer toutes ses modifications du référentiel Kotlin, car à l'avenir de nouvelles constructions pourraient apparaître dans le langage qui devra être pris en charge.


Enfin et troisièmement, seule la JVM est prise en charge.


Conclusion


À l'aide du vérificateur de compatibilité binaire, vous pouvez obtenir la compatibilité binaire et répondre à temps à un changement de son état. Pour l'utiliser dans le projet, il a fallu modifier seulement deux fichiers et connecter les tests à notre CI. Cette solution présente certains inconvénients, mais elle est toujours très pratique à utiliser. Reaktive va maintenant essayer de maintenir la compatibilité binaire pour la JVM de la même manière que JetBrains le fait pour la bibliothèque standard de Kotlin.


Merci de votre attention!

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


All Articles