Reaktive Binärkompatibilität: wie wir sie anbieten

Hallo! Mein Name ist Yuri Vlad, ich bin Android-Entwickler bei Badoo und arbeite an der Erstellung der Reaktiven Bibliothek - Reactive Extensions auf pure Kotlin mit.


Jede Bibliothek sollte nach Möglichkeit die Binärkompatibilität beachten. Wenn verschiedene Versionen der Bibliothek in den Abhängigkeiten nicht kompatibel sind, stürzt das Ergebnis zur Laufzeit ab. Ein solches Problem kann beispielsweise auftreten, wenn Sie MVICore mit reaktiver Unterstützung versehen .



In diesem Artikel werde ich Ihnen kurz erläutern, was Binärkompatibilität ist und welche Funktionen Kotlin bietet und wie es in JetBrains und jetzt in Badoo unterstützt wird.


Kotlin-Binärkompatibilitätsproblem


Angenommen, wir haben eine wunderbare Bibliothek com.sample:lib:1.0 mit dieser Klasse:


 data class A(val a: Int) 

Darauf basierend haben wir eine zweite Bibliothek com.sample:lib-extensions:1.0 . Zu seinen Abhängigkeiten gehört com.sample:lib:1.0 . Beispielsweise enthält es eine Factory-Methode für Klasse A :


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

Jetzt veröffentlichen wir die neue Version unserer Bibliothek com.sample:lib:2.0 mit der folgenden Änderung:


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

Kotlins voll kompatibles Wechselgeld, nicht wahr? Mit dem Standardparameter können wir weiterhin das Konstrukt val a = A(a) , jedoch nur, wenn alle Abhängigkeiten vollständig neu kompiliert wurden. Die Standardparameter sind nicht Teil der JVM und werden vom speziellen synthetischen Konstruktor A implementiert, der alle Felder der Klasse in den Parametern enthält. Wenn Abhängigkeiten aus dem Maven-Repository empfangen werden, werden sie bereits zusammengestellt und können nicht erneut kompiliert werden.


Eine neue Version von com.sample:lib und wir verbinden sie sofort mit unserem Projekt. Wir wollen auf dem Laufenden sein! Neue Funktionen, neue Korrekturen, neue Bugs !


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

In diesem Fall kommt es zur Laufzeit zum Absturz. createA Funktion createA im Bytecode versucht, den Konstruktor der Klasse mit einem Parameter aufzurufen, und im Bytecode ist bereits kein solcher vorhanden. Von allen Abhängigkeiten mit derselben Gruppe und demselben Namen wählt Gradle die mit der neuesten Version aus und nimmt sie in die Assembly auf.


Höchstwahrscheinlich sind Sie in Ihren Projekten bereits auf binäre Inkompatibilität gestoßen. Persönlich bin ich auf dieses Problem gestoßen, als ich unsere Anwendungen auf AndroidX migriert habe.


Weitere Informationen zur Binärkompatibilität finden Sie in den Artikeln "Binärkompatibilität in Beispielen und nicht nur" von gvsmirnov- Benutzern, "Entwickeln von Java-basierten APIs 2" von Eclipse- Entwicklern und im kürzlich veröffentlichten Artikel "Öffentliche API-Herausforderungen in Kotlin" von Jake Wharton.


Möglichkeiten zur Gewährleistung der Binärkompatibilität


Es scheint, dass Sie nur versuchen müssen, kompatible Änderungen vorzunehmen. Fügen Sie beispielsweise Konstruktoren mit einem Standardwert hinzu, wenn Sie neue Felder hinzufügen, fügen Sie neue Parameter zu Funktionen hinzu, indem Sie eine Methode mit einem neuen Parameter überschreiben usw. Es ist jedoch immer leicht, einen Fehler zu machen. Aus diesem Grund wurden verschiedene Tools zur Überprüfung der Binärkompatibilität von zwei verschiedenen Versionen derselben Bibliothek erstellt, z.


  1. Java API Compliance Checker
  2. Clirr
  3. Revapi
  4. Japicmp
  5. Japitools
  6. Jour
  7. Japi-Checker
  8. Sigtest

Sie nehmen zwei JAR-Dateien und geben das Ergebnis an: wie kompatibel sie sind.


Wir entwickeln jedoch die Kotlin-Bibliothek, deren Verwendung bisher nur von Kotlin aus sinnvoll ist. Dies bedeutet, dass wir zum Beispiel für internal Klassen nicht immer eine 100% ige Kompatibilität benötigen. Obwohl sie im Bytecode öffentlich sind, ist ihre Verwendung außerhalb des Kotlin-Codes unwahrscheinlich. Zur Aufrechterhaltung der Binärkompatibilität verwendet kotlin-stdlib JetBrains daher einen Binärkompatibilitätsprüfer . Das Grundprinzip lautet: Aus der JAR-Datei wird ein Dump der gesamten öffentlichen API erstellt und in die Datei geschrieben. Diese Datei ist eine Referenz für alle weiteren Überprüfungen und sieht folgendermaßen aus:


 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 } 

Nachdem Sie Änderungen am Quellcode vorgenommen haben, wird die Baseline-Bibliothek im Vergleich zur aktuellen neu generiert, und die Überprüfung schlägt fehl, wenn Änderungen an der Baseline angezeigt werden. Diese Änderungen können überschrieben werden, indem -Doverwrite.output=true . Ein Fehler tritt auch dann auf, wenn binärkompatible Änderungen vorgenommen wurden. Dies ist erforderlich, um die Baseline rechtzeitig zu aktualisieren und ihre Änderungen direkt in der Pull-Anforderung anzuzeigen.


Binärkompatibilitätsprüfer


Mal sehen, wie dieses Tool funktioniert. Die Binärkompatibilität wird auf JVM-Ebene (Bytecode) bereitgestellt und ist sprachunabhängig. Es ist möglich, die Java-Klassenimplementierung durch Kotlin zu ersetzen, ohne die Binärkompatibilität zu beeinträchtigen (und umgekehrt).
Zuerst müssen Sie verstehen, welche Klassen sich in der Bibliothek befinden. Wir erinnern uns, dass auch für globale Funktionen und Konstanten eine Klasse mit dem Dateinamen und dem Suffix Kt , zum Beispiel ContinuationKt . Um alle Klassen JarFile , verwenden wir die JarFile Klasse aus dem JDK, JarFile Zeiger auf jede Klasse ab und übergeben sie an org.objectweb.asm.tree.ClassNode . Diese Klasse informiert uns über die Sichtbarkeit der Klasse, ihre Methoden, Felder und Anmerkungen.


 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 fügt beim Kompilieren jeder Klasse die Laufzeitanmerkung @Metadata hinzu, damit kotlin-reflect das Aussehen der Kotlin-Klasse wiederherstellen kann, bevor sie in Bytecode konvertiert wird. Es sieht so aus:


 @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 können die @Metadata Annotation von @Metadata und in KotlinClassHeader . Sie müssen dies manuell tun, da kotlin-reflect nicht weiß, wie man mit ObjectWeb ASM arbeitet.


 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 wird benötigt, um internal korrekt zu behandeln, da es im Bytecode nicht vorhanden ist. Änderungen an internal Klassen und Funktionen können sich nicht auf Bibliotheksbenutzer auswirken, obwohl sie im Hinblick auf den Bytecode eine öffentliche API sind.


In kotlin.Metadata können Sie sich über companion object informieren. Auch wenn Sie es als privat deklarieren, wird es weiterhin im öffentlichen statischen Feld Companion gespeichert, was bedeutet, dass dieses Feld unter die Anforderung der Binärkompatibilität fällt.


 class CompositeException() { private companion object { } } 

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

Von den erforderlichen Anmerkungen sollte @PublishedApi für Klassen und Methoden angegeben werden, die in öffentlichen inline Funktionen verwendet werden. Der Hauptteil solcher Funktionen verbleibt an den Stellen ihres Aufrufs, was bedeutet, dass die Klassen und Methoden in ihnen binärkompatibel sein müssen. Wenn Sie versuchen, nicht öffentliche Klassen und Methoden in solchen Funktionen zu verwenden, gibt der Kotlin-Compiler @PublishedApi Fehler aus und bietet an, sie mit der Annotation @PublishedApi zu kennzeichnen.


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

Der Baum der Klassenvererbung und die Implementierung von Schnittstellen sind wichtig, um die Binärkompatibilität zu unterstützen. Wir können zum Beispiel nicht einfach irgendeine Schnittstelle aus der Klasse entfernen. Und die Elternklasse und die implementierbaren Schnittstellen zu bekommen ist ziemlich einfach.


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

Object aus der Liste entfernt, da die Verfolgung keinen Sinn ergibt.


Innerhalb des Validators gibt es eine Vielzahl zusätzlicher Kotlin-spezifischer Überprüfungen: Überprüfen der Standardmethoden in Interfaces über Interface$DefaultImpls , Ignorieren der $WhenMappings Klassen, damit der when Operator funktioniert, und andere.


Als Nächstes müssen Sie alle ClassNode und deren MethodNode und FieldNode . Aus der Signatur der Klassen, ihrer Felder und Methoden erhalten wir ClassBinarySignature , FieldBinarySignature und MethodBinarySignature , die lokal im Projekt deklariert werden. Alle implementieren die MemberBinarySignature Schnittstelle, können ihre öffentliche Sichtbarkeit mithilfe der isEffectivelyPublic Methode isEffectivelyPublic und ihre Signatur in einem lesbaren Format anzeigen.


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

Nachdem Sie die ClassBinarySignature Liste erhalten haben, können Sie sie mit der dump(to: Appendable) in eine Datei oder einen Speicher schreiben und mit der Baseline vergleichen. RuntimePublicAPITest geschieht im RuntimePublicAPITest Test:


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

Durch das Festschreiben einer neuen Baseline erhalten wir die Änderungen in einem lesbaren Format, wie zum Beispiel in diesem Festschreiben :


  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 { 

Verwendung eines Validators in Ihrem Projekt


Die Bedienung ist denkbar einfach. Kopieren Sie den binary-compatibility-validator in Ihr Projekt und ändern Sie dessen build.gradle und 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") } 

In unserem Fall sieht eine der Testfunktionen der RuntimePublicAPITest Datei folgendermaßen aus:


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

Führen ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false nun für jede Pull-Anforderung ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false und zwingen Sie die Entwickler, die Baseline-Dateien rechtzeitig zu aktualisieren.


Fliege in der Salbe


Dieser Ansatz hat jedoch einige Nachteile.


Zunächst müssen wir die Änderungen an den Basisdateien unabhängig analysieren. Nicht immer führen ihre Änderungen zu binärer Inkompatibilität. Wenn Sie zum Beispiel eine neue Schnittstelle implementieren, erhalten Sie einen solchen Unterschied in der Baseline:


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

Zweitens werden Werkzeuge verwendet, die nicht dafür vorgesehen sind. Tests sollten keine Nebeneffekte in Form des Schreibens einer Datei auf die Festplatte haben, die anschließend vom selben Test verwendet wird, und noch mehr, indem Parameter über Umgebungsvariablen an ihn übergeben werden. Es wäre großartig, dieses Tool in einem Gradle-Plugin zu verwenden und mithilfe einer Aufgabe eine Baseline zu erstellen. Aber ich möchte im Validator wirklich nichts alleine ändern, so dass es später einfach ist, alle Änderungen aus dem Kotlin-Repository abzurufen, da in Zukunft möglicherweise neue Konstrukte in der Sprache erscheinen, die unterstützt werden muss.


Drittens wird nur JVM unterstützt.


Fazit


Mit der Binärkompatibilitätsprüfung können Sie eine Binärkompatibilität erreichen und rechtzeitig auf eine Änderung des Status reagieren. Um es im Projekt zu verwenden, mussten nur zwei Dateien geändert und die Tests mit unserem CI verbunden werden. Diese Lösung hat einige Nachteile, ist aber dennoch recht bequem zu bedienen. Jetzt wird Reaktive versuchen, die Binärkompatibilität für die JVM auf dieselbe Weise aufrechtzuerhalten wie JetBrains für die Kotlin-Standardbibliothek.


Vielen Dank für Ihre Aufmerksamkeit!

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


All Articles