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.
- Java API Compliance Checker
- Clirr
- Revapi
- Japicmp
- Japitools
- Jour
- Japi-Checker
- 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) {
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!