التوافق الثنائي الذري: كيف نوفره

تحية! اسمي Yuri Vlad ، أنا مطور للأندرويد على Badoo وأشارك في إنشاء مكتبة Reaktive - Reactive Extensions على Kotlin الخالصة.


يجب أن تلاحظ أي مكتبة التوافق الثنائي كلما أمكن ذلك. إذا كانت الإصدارات المختلفة من المكتبة في التبعيات غير متوافقة ، فستكون النتيجة تعطل في وقت التشغيل. قد نواجه مثل هذه المشكلة ، على سبيل المثال ، عند إضافة دعم Reaktive إلى MVICore .



في هذه المقالة ، سوف أخبرك باختصار ما هو التوافق الثنائي وما هي ميزاته بالنسبة لـ Kotlin ، وكذلك كيفية دعمه في JetBrains ، والآن في Badoo.


Kotlin مسألة التوافق الثنائي


افترض أن لدينا مكتبة رائعة com.sample:lib:1.0 مع هذه الفئة:


 data class A(val a: Int) 

بناءً على ذلك ، أنشأنا com.sample:lib-extensions:1.0 مكتبة ثانية com.sample:lib-extensions:1.0 . من بين com.sample:lib:1.0 . على سبيل المثال ، يحتوي على طريقة مصنع للفئة A :


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

com.sample:lib:2.0 الآن الإصدار الجديد من com.sample:lib:2.0 بالتغيير التالي:


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

تغيير Kotlin متوافق تماما ، أليس كذلك؟ باستخدام المعلمة الافتراضية ، يمكننا الاستمرار في استخدام بنية val a = A(a) ، ولكن فقط إذا تمت إعادة ترجمة جميع التبعيات بالكامل. المعلمات الافتراضية ليست جزءًا من JVM ويتم تنفيذها بواسطة مُنشئ اصطناعي خاص A ، والذي يحتوي على جميع حقول الفئة في المعلمات. في حالة تلقي التبعيات من مستودع Maven ، نجمعها بالفعل ولا يمكننا إعادة تجميعها.


تم إصدار نسخة جديدة من com.sample:lib ، وقمنا على الفور بتوصيلها com.sample:lib . نريد أن نكون محدثين! ميزات جديدة ، إصلاحات جديدة ، البق جديدة !


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

وفي هذه الحالة ، نحصل على تعطل في وقت التشغيل. createA دالة createA في createA استدعاء مُنشئ الفئة مع معلمة واحدة ، ولا يوجد بالفعل مثل هذا في رمز البايت. من بين كل التبعيات التي لها نفس المجموعة والاسم ، ستحدد Gradle العنصر الذي يحتوي على أحدث إصدار ويدرجه في التجميع.


على الأرجح ، واجهت بالفعل عدم توافق ثنائي في مشاريعك. شخصيا ، صادفت هذا عندما قمت بترحيل تطبيقاتنا إلى AndroidX.


يمكنك قراءة المزيد حول التوافق الثنائي في مقالات "التوافق الثنائي في الأمثلة وليس فقط" من قِبل مستخدم gvsmirnov ، و "تطوير واجهات برمجة التطبيقات المستندة إلى Java 2" من منشئي Eclipse وفي مقالة نُشرت مؤخرًا "تحديات API العامة في Kotlin" بقلم جيك وارتون.


طرق لضمان التوافق الثنائي


يبدو أنك تحتاج فقط إلى محاولة إجراء تغييرات متوافقة. على سبيل المثال ، قم بإضافة مُنشئات ذات قيمة افتراضية عند إضافة حقول جديدة ، وإضافة معلمات جديدة إلى الوظائف من خلال تجاوز طريقة ذات معلمة جديدة ، وما إلى ذلك ، لكن من السهل دائمًا ارتكاب خطأ. لذلك ، تم إنشاء أدوات مختلفة للتحقق من التوافق الثنائي لإصدارين مختلفين من نفس المكتبة ، مثل:


  1. Java API Compliance Checker
  2. Clirr
  3. Revapi
  4. Japicmp
  5. Japitools
  6. جور
  7. Japi المدقق
  8. SigTest

يأخذون ملفين JAR ويعطيان النتيجة: مدى توافقهما.


ومع ذلك ، نحن نعمل على تطوير مكتبة Kotlin ، والتي من المنطقي حتى الآن استخدامها فقط من Kotlin. هذا يعني أننا لا نحتاج دائمًا إلى توافق بنسبة 100٪ ، على سبيل المثال ، للفئات internal . على الرغم من أنها عامة في bytecode ، إلا أن استخدامها خارج كود Kotlin غير مرجح. لذلك ، للحفاظ على التوافق الثنائي ، يستخدم kotlin-stdlib JetBrains مدقق التوافق الثنائي . المبدأ الأساسي هو هذا: يتم إنشاء ملف تفريغ API العمومي بأكمله من ملف JAR ومكتوب إلى الملف. هذا الملف هو الأساس (المرجع) لجميع عمليات الفحص الإضافية ، ويبدو كما يلي:


 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 } 

بعد إجراء تغييرات على الكود المصدري ، يتم تجديد مكتبة الخط الأساسي ، مقارنةً بالمادة الحالية ، وفشل الفحص في حالة ظهور أي تغييرات على الخط الأساسي. يمكن الكتابة فوق هذه التغييرات عن طريق تمرير -Doverwrite.output=true . سيحدث خطأ حتى في حالة حدوث تغييرات متوافقة ثنائية. يعد ذلك ضروريًا لتحديث الخط الأساسي في الوقت المناسب ومشاهدة تغييراته مباشرةً في طلب السحب.


مدقق التوافق الثنائي


دعونا نرى كيف تعمل هذه الأداة. يتم توفير التوافق الثنائي على مستوى JVM (bytecode) وهو مستقل عن اللغة. من الممكن استبدال تطبيق فئة Java بـ Kotlin- دون كسر التوافق الثنائي (والعكس صحيح).
تحتاج أولاً إلى فهم ماهية الفصول الموجودة في المكتبة. نتذكر أنه حتى بالنسبة للوظائف والثوابت العالمية ، يتم إنشاء فئة باسم الملف ولاحقة Kt ، على سبيل المثال ، ContinuationKt . للحصول على جميع الفئات ، نستخدم فئة JarFile من JDK ، ونحصل على مؤشرات لكل فئة org.objectweb.asm.tree.ClassNode إلى org.objectweb.asm.tree.ClassNode . سيُعلمنا هذا الفصل برؤية الفصل وطرقه وحقوله وشروحه.


 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 ، عند @Metadata ، @Metadata التوضيحي الخاص @Metadata تشغيل @Metadata إلى كل فئة حتى يتمكن kotlin-reflect استعادة مظهر فئة Kotlin قبل تحويلها إلى kotlin-reflect . يبدو مثل هذا:


 @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 الحصول على تعليق توضيحي @Metadata من @Metadata وتحليله في KotlinClassHeader . يجب عليك القيام بذلك يدويًا ، لأن kotlin-reflect لا يعرف كيفية العمل مع 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 من أجل التعامل مع internal بشكل صحيح ، لأنه غير موجود في bytecode. لا يمكن أن تؤثر التغييرات التي تطرأ على الفئات والوظائف internal على مستخدمي المكتبة ، على الرغم من كونهم واجهة برمجة تطبيقات عامة من حيث الرمز الفرعي.


من kotlin.Metadata يمكنك معرفة companion object . حتى إذا أعلنت أنه خاص ، فسيظل مخزّنًا في الحقل الثابت العام Companion ، مما يعني أن هذا الحقل يقع ضمن متطلبات التوافق الثنائي.


 class CompositeException() { private companion object { } } 

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

من التعليقات التوضيحية اللازمة ، تجدر الإشارة إلى @PublishedApi للفئات والأساليب المستخدمة في الدالات inline العامة. يظل نص هذه الوظائف في أماكن دعوتهم ، مما يعني أن الفصول والأساليب الموجودة بها يجب أن تكون متوافقة ثنائيًا. عندما تحاول استخدام فئات وأساليب غير عامة في مثل هذه الوظائف ، فإن برنامج التحويل البرمجي Kotlin سوف @PublishedApi خطأ @PublishedApi علامة عليها مع تعليق توضيحي على @PublishedApi .


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

تعتبر شجرة الوراثة الصفية وتنفيذ الواجهات مهمة لدعم التوافق الثنائي. لا يمكننا ، على سبيل المثال ، إزالة بعض الواجهات من الفصل. والحصول على فئة الوالدين واجهات قابلة للتنفيذ هو بسيط جدا.


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

Object إزالة Object من القائمة ، حيث أن تتبعه لا معنى له.


يوجد داخل المدقق الكثير من عمليات الفحص الإضافية الخاصة بـ Kotlin: التحقق من الأساليب الافتراضية في الواجهات من خلال Interface$DefaultImpls ، وتجاهل فئات $WhenMappings when المشغل ، وغيرها.


بعد ذلك ، تحتاج إلى استعراض كل ClassNode والحصول على MethodNode و FieldNode . من توقيع الفئات وحقولها وأساليبها ، نحصل على ClassBinarySignature و FieldBinarySignature و MethodBinarySignature ، والتي يتم الإعلان عنها محليًا في المشروع. جميعهم MemberBinarySignature واجهة MemberBinarySignature ، وهم قادرون على تحديد وضوح الرؤية العامة باستخدام طريقة isEffectivelyPublic وعرض توقيعهم val signature: String بتنسيق قابل للقراءة 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) } } 

بعد تلقي قائمة ClassBinarySignature ، يمكنك كتابتها إلى ملف أو ذاكرة باستخدام طريقة dump(to: Appendable) الأساسي ، والذي يحدث في اختبار 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) } 

من خلال الالتزام بخط أساس جديد ، نحصل على التغييرات بتنسيق قابل للقراءة ، على سبيل المثال ، في هذا الالتزام :


  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 { 

باستخدام مدقق في مشروعك


استخدام بسيط للغاية. انسخ binary-compatibility-validator build.gradle binary-compatibility-validator إلى مشروعك وتغيير build.gradle و 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") } 

في حالتنا ، تبدو إحدى وظائف الاختبار لملف RuntimePublicAPITest كما يلي:


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

الآن لكل طلب سحب ، قم بتشغيل. / ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false وإجبار المطورين على تحديث ملفات الأساس في الوقت المحدد.


يطير في مرهم


ومع ذلك ، فإن هذا النهج لديه بعض النقاط السيئة.


أولاً ، يجب أن نحلل بشكل مستقل التغييرات في الملفات الأساسية. ليس دائمًا ما تؤدي تغييراتها إلى عدم توافق ثنائي. على سبيل المثال ، إذا قمت بتطبيق واجهة جديدة ، فستحصل على مثل هذا الاختلاف في الأساس:


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

ثانياً ، يتم استخدام الأدوات غير المخصصة لهذا الغرض. لا ينبغي أن يكون للاختبارات آثار جانبية في شكل كتابة بعض الملفات على القرص ، والتي سيتم استخدامها لاحقًا بواسطة نفس الاختبار ، وأكثر من ذلك ، لتمرير المعلمات إليه من خلال متغيرات البيئة. سيكون من الرائع استخدام هذه الأداة في مكون إضافي Gradle وإنشاء خط أساسي باستخدام مهمة. لكنني لا أريد حقًا تغيير شيء بمفردنا في المدقق ، وبالتالي سيكون من السهل في وقت لاحق سحب كل تغييراته من مستودع Kotlin ، لأنه في المستقبل قد تظهر بنيات جديدة باللغة التي ستحتاج إلى دعم.


حسنا وثالثا ، يتم دعم JVM فقط.


استنتاج


باستخدام مدقق التوافق الثنائي ، يمكنك تحقيق التوافق الثنائي والاستجابة في الوقت المناسب لأي تغيير في حالته. لاستخدامه في المشروع ، كان من الضروري تغيير ملفين فقط وتوصيل الاختبارات إلى CI لدينا. هذا الحل له بعض العيوب ، لكنه لا يزال مناسبًا للاستخدام. الآن سيحاول Reaktive الحفاظ على التوافق الثنائي لـ JVM بنفس الطريقة التي يعمل بها JetBrains مع مكتبة Kotlin القياسية.


شكرا لاهتمامكم!

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


All Articles