Hai Nama saya Yuri Vlad, saya adalah pengembang Android di Badoo dan ikut serta dalam membuat perpustakaan Reaktive - Ekstensi Reaktif di Kotlin murni.
Setiap perpustakaan harus memperhatikan kompatibilitas biner jika memungkinkan. Jika versi pustaka yang berbeda dalam dependensi tidak kompatibel, maka hasilnya akan macet di runtime. Kami mungkin menghadapi masalah seperti itu, misalnya, ketika menambahkan dukungan Reaktive ke MVICore .

Dalam artikel ini saya akan secara singkat memberi tahu Anda apa kompatibilitas biner itu dan apa fitur-fiturnya untuk Kotlin, serta bagaimana itu didukung di JetBrains, dan sekarang di Badoo.
Masalah kompatibilitas biner Kotlin
Misalkan kita memiliki perpustakaan yang luar biasa com.sample:lib:1.0
dengan kelas ini:
data class A(val a: Int)
Berdasarkan itu, kami membuat com.sample:lib-extensions:1.0
perpustakaan kedua. com.sample:lib-extensions:1.0
. Di antara dependensinya adalah com.sample:lib:1.0
. Misalnya, ini berisi metode pabrik untuk kelas A
:
fun createA(a: Int = 0): A = A(a)
Sekarang kami akan merilis versi baru dari perpustakaan kami com.sample:lib:2.0
dengan perubahan berikut:
data class A(val a: Int, val b: String? = null)
Perubahan sepenuhnya kompatibel Kotlin, bukan? Dengan parameter default, kita dapat terus menggunakan konstruk val a = A(a)
, tetapi hanya jika semua dependensi sepenuhnya dikompilasi ulang. Parameter default bukan bagian dari JVM dan diimplementasikan oleh konstruktor sintetis khusus A
, yang berisi semua bidang kelas dalam parameter. Dalam hal menerima dependensi dari repositori Maven, kami membuatnya sudah dirakit dan tidak dapat mengkompilasi ulang.
Versi baru dari com.sample:lib
, dan kami segera menghubungkannya ke proyek kami. Kami ingin selalu terbarui! Fitur baru, perbaikan baru, bug baru !
dependencies { implementation 'com.sample:lib:2.0' implementation 'com.sample:lib-extensions:1.0' }
Dan dalam hal ini, kita mengalami crash dalam runtime. createA
di bytecode akan mencoba memanggil konstruktor kelas
dengan satu parameter, dan sudah tidak ada yang seperti itu di bytecode. Dari semua dependensi dengan grup dan nama yang sama, Gradle akan memilih salah satu yang memiliki versi terbaru dan memasukkannya ke dalam rakitan.
Kemungkinan besar, Anda telah menemui ketidakcocokan biner dalam proyek Anda. Secara pribadi, saya menemukan ini ketika saya memigrasi aplikasi kami ke AndroidX.
Anda dapat membaca lebih lanjut tentang kompatibilitas biner dalam artikel "Kompatibilitas biner dalam contoh dan tidak hanya" oleh pengguna gvsmirnov , "Evolving Java-based APIs 2" dari pembuat Eclipse dan dalam artikel yang baru-baru ini diterbitkan "Tantangan API Publik di Kotlin" oleh Jake Wharton.
Cara untuk memastikan kompatibilitas biner
Tampaknya Anda hanya perlu mencoba membuat perubahan yang kompatibel. Misalnya, tambahkan konstruktor dengan nilai default saat menambahkan bidang baru, tambahkan parameter baru ke fungsi dengan menimpa metode dengan parameter baru, dll. Tetapi selalu mudah untuk membuat kesalahan. Oleh karena itu, berbagai alat untuk memeriksa kompatibilitas biner dari dua versi berbeda dari pustaka yang sama dibuat, seperti:
- Pemeriksa Kepatuhan API Java
- Clirr
- Revapi
- Japicmp
- Japitools
- Jour
- Pemeriksa japi
- Sigtest
Mereka mengambil dua file JAR dan memberikan hasilnya: seberapa kompatibel mereka.
Namun, kami sedang mengembangkan perpustakaan Kotlin, yang sejauh ini masuk akal untuk digunakan hanya dari Kotlin. Ini berarti bahwa kita tidak selalu membutuhkan kompatibilitas 100%, misalnya, untuk kelas internal
. Meskipun mereka bersifat publik dalam bytecode, penggunaannya di luar kode Kotlin tidak mungkin. Oleh karena itu, untuk menjaga kompatibilitas biner, Jetlinrlin kotlin-stdlib menggunakan pemeriksa kompatibilitas Biner . Prinsip dasarnya adalah ini: dump seluruh API publik dibuat dari file JAR dan ditulis ke file. File ini adalah dasar (referensi) untuk semua pemeriksaan lebih lanjut, dan terlihat seperti ini:
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 }
Setelah membuat perubahan pada kode sumber, pustaka baseline dibuat ulang, dibandingkan dengan yang sekarang, dan pemeriksaan gagal jika ada perubahan pada pangkalan data yang muncul. Perubahan ini dapat ditimpa dengan melewati -Doverwrite.output=true
. Kesalahan akan terjadi bahkan jika perubahan yang kompatibel biner telah terjadi. Ini diperlukan untuk memperbarui baseline secara tepat waktu dan melihat perubahannya secara langsung dalam permintaan tarik.
Validator kompatibilitas biner
Mari kita lihat bagaimana alat ini bekerja. Kompatibilitas biner disediakan di tingkat JVM (bytecode) dan tidak tergantung bahasa. Dimungkinkan untuk mengganti implementasi kelas Java dengan Kotlin- tanpa memutus kompatibilitas biner (dan sebaliknya).
Pertama, Anda perlu memahami kelas apa yang ada di perpustakaan. Kami ingat bahwa bahkan untuk fungsi dan konstanta global, kelas dibuat dengan nama file dan akhiran Kt
, misalnya, ContinuationKt
. Untuk mendapatkan semua kelas, kita menggunakan kelas JarFile
dari JDK, mendapatkan pointer ke setiap kelas dan meneruskannya ke org.objectweb.asm.tree.ClassNode
. Kelas ini akan memberi tahu kami visibilitas kelas, metode, bidang, dan anotasinya.
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, ketika mengkompilasi, menambahkan anotasi runtime @Metadata
ke setiap kelas sehingga kotlin-reflect
dapat mengembalikan tampilan kelas Kotlin sebelum dikonversi ke bytecode. Ini terlihat seperti ini:
@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
bisa mendapatkan anotasi @Metadata
dari @Metadata
dan menguraikannya ke dalam KotlinClassHeader
. Anda harus melakukan ini secara manual, karena kotlin-reflect
tidak tahu cara bekerja dengan 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 diperlukan agar dapat menangani internal
dengan benar, karena tidak ada dalam bytecode. Perubahan ke kelas dan fungsi internal
tidak dapat memengaruhi pengguna perpustakaan, meskipun mereka adalah API publik dalam hal bytecode.
Dari kotlin.Metadata Anda dapat mencari tahu tentang companion object
. Bahkan jika Anda mendeklarasikannya sebagai pribadi, itu akan tetap disimpan di bidang bidang publik statis, yang berarti bahwa bidang ini termasuk dalam persyaratan kompatibilitas biner.
class CompositeException() { private companion object { } }
public final static Lcom/badoo/reaktive/base/exceptions/CompositeException$Companion; Companion @Ljava/lang/Deprecated;()
Dari anotasi yang diperlukan, perlu dicatat @PublishedApi
untuk kelas dan metode yang digunakan dalam fungsi inline
publik. Isi fungsi tersebut tetap berada di tempat panggilannya, yang berarti bahwa kelas dan metode di dalamnya harus kompatibel dengan biner. Saat Anda mencoba menggunakan kelas dan metode non-publik dalam fungsi-fungsi tersebut, kompiler Kotlin akan @PublishedApi
kesalahan dan menawarkan untuk menandainya dengan penjelasan @PublishedApi
.
fun ClassNode.isPublishedApi() = findAnnotation("kotlin/PublishedApi", includeInvisible = true) != null
Pohon warisan kelas dan implementasi antarmuka sangat penting untuk mendukung kompatibilitas biner. Kami tidak dapat, misalnya, cukup menghapus beberapa antarmuka dari kelas. Dan mendapatkan kelas induk dan antarmuka yang dapat diterapkan sangat sederhana.
val supertypes = listOf(classNode.superName) - "java/lang/Object" + classNode.interfaces.sorted()
Object
dihapus dari daftar, karena melacaknya tidak masuk akal.
Di dalam validator ada banyak berbagai pemeriksaan tambahan khusus Kotlin: memeriksa metode default di antarmuka melalui Interface$DefaultImpls
, mengabaikan kelas $WhenMappings
untuk operator when
bekerja, dan lainnya.
Selanjutnya, Anda harus melalui semua ClassNode
dan mendapatkan MethodNode
dan FieldNode
. Dari tanda tangan kelas, bidang dan metode mereka, kita mendapatkan ClassBinarySignature
, FieldBinarySignature
dan MethodBinarySignature
, yang dideklarasikan secara lokal di proyek. Semua dari mereka menerapkan antarmuka MemberBinarySignature
, dapat menentukan visibilitas publik mereka menggunakan metode isEffectivelyPublic
dan menampilkan tanda tangan mereka dalam format yang dapat dibaca, 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) } }
Setelah menerima daftar ClassBinarySignature
, Anda dapat menulisnya ke file atau memori menggunakan metode dump(to: Appendable)
dan membandingkannya dengan baseline, yang terjadi dalam tes 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) }
Dengan melakukan baseline baru, kami mendapatkan perubahan dalam format yang dapat dibaca, seperti, misalnya, dalam komit ini:
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 {
Menggunakan validator di proyek Anda
Penggunaannya sangat sederhana. Salin binary-compatibility-validator
ke proyek Anda dan ubah build.gradle
dan 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) {
Dalam kasus kami, salah satu fungsi uji dari file RuntimePublicAPITest
terlihat seperti ini:
@Test fun reaktive() { snapshotAPIAndCompare("../../reaktive/build/libs", "reaktive-jvm") }
Sekarang untuk setiap permintaan tarik, jalankan ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false
dan memaksa pengembang untuk memperbarui file baseline tepat waktu.
Terbang di salep
Namun, pendekatan ini memiliki beberapa poin buruk.
Pertama, kita harus menganalisis perubahan pada file baseline secara independen. Tidak selalu perubahannya menyebabkan ketidakcocokan biner. Misalnya, jika Anda menerapkan antarmuka baru, Anda mendapatkan perbedaan dalam baseline:
- public final class com/test/A { + public final class com/test/A : Comparable {
Kedua, alat yang tidak dimaksudkan untuk ini digunakan. Pengujian seharusnya tidak memiliki efek samping dalam bentuk penulisan beberapa file ke disk, yang selanjutnya akan digunakan oleh pengujian yang sama, dan terlebih lagi, meneruskan parameter ke sana melalui variabel lingkungan. Alangkah baiknya jika menggunakan alat ini dalam plugin Gradle dan membuat garis dasar menggunakan tugas. Tapi saya benar-benar tidak ingin mengubah sesuatu sendiri di validator, sehingga nantinya akan mudah untuk menarik semua perubahannya dari repositori Kotlin, karena di masa depan konstruksi baru mungkin muncul dalam bahasa yang perlu didukung.
Yah dan ketiga, hanya JVM yang didukung.
Kesimpulan
Menggunakan pemeriksa kompatibilitas Biner, Anda dapat mencapai kompatibilitas biner dan merespons dalam waktu terhadap perubahan dalam statusnya. Untuk menggunakannya dalam proyek, perlu mengubah hanya dua file dan menghubungkan tes ke CI kami. Solusi ini memiliki beberapa kelemahan, tetapi masih cukup nyaman untuk digunakan. Sekarang Reaktive akan mencoba mempertahankan kompatibilitas biner untuk JVM dengan cara yang sama seperti yang dilakukan JetBrains untuk Perpustakaan Standar Kotlin.
Terima kasih atas perhatian anda!