Compatibilidade binária Reaktive: como a fornecemos

Oi Meu nome é Yuri Vlad, sou desenvolvedor Android do Badoo e participo da criação da biblioteca Reaktive - Extensões Reativas em Kotlin puro.


Qualquer biblioteca deve observar a compatibilidade binária sempre que possível. Se versões diferentes da biblioteca nas dependências forem incompatíveis, o resultado será falhas no tempo de execução. Podemos encontrar esse problema, por exemplo, ao adicionar suporte Reaktive ao MVICore .



Neste artigo, explicarei brevemente o que é compatibilidade binária e quais são seus recursos para o Kotlin, bem como como ele é suportado no JetBrains e agora no Badoo.


Problema de compatibilidade binária do Kotlin


Suponha que tenhamos uma maravilhosa biblioteca com.sample:lib:1.0 com esta classe:


 data class A(val a: Int) 

Com base nisso, criamos uma segunda biblioteca com.sample:lib-extensions:1.0 . Entre suas dependências está com.sample:lib:1.0 . Por exemplo, ele contém um método de fábrica para a classe A :


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

Agora lançaremos a nova versão da nossa biblioteca com.sample:lib:2.0 com a seguinte alteração:


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

A mudança totalmente compatível de Kotlin, não é? Com o parâmetro padrão, podemos continuar usando a construção val a = A(a) , mas apenas se todas as dependências forem completamente recompiladas. Os parâmetros padrão não fazem parte da JVM e são implementados pelo construtor sintético especial A , que contém todos os campos da classe nos parâmetros. No caso de receber dependências do repositório Maven, nós as reunimos e não podemos recompilá-las.


Uma nova versão do com.sample:lib e nós a conectamos imediatamente ao nosso projeto. Queremos estar atualizados! Novos recursos, novas correções, novos erros !


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

E, neste caso, temos falha no tempo de execução. createA função createA no bytecode tentará chamar o construtor da classe com um parâmetro, e já não existe esse no bytecode. De todas as dependências com o mesmo grupo e nome, Gradle selecionará a que possui a versão mais recente e a incluirá na montagem.


Provavelmente, você já encontrou incompatibilidade binária em seus projetos. Pessoalmente, me deparei com isso quando migramos nossos aplicativos para o AndroidX.


Você pode ler mais sobre compatibilidade binária nos artigos “Compatibilidade binária nos exemplos e não apenas” do usuário gvsmirnov , “Evoluindo APIs baseadas em Java 2” dos criadores do Eclipse e no artigo recentemente publicado “Desafios da API pública no Kotlin” de Jake Wharton.


Maneiras de garantir compatibilidade binária


Parece que você só precisa tentar fazer alterações compatíveis. Por exemplo, adicione construtores com um valor padrão ao adicionar novos campos, adicione novos parâmetros às funções substituindo um método por um novo parâmetro etc. Mas sempre é fácil cometer um erro. Portanto, foram criadas várias ferramentas para verificar a compatibilidade binária de duas versões diferentes da mesma biblioteca, como:


  1. Verificador de conformidade da API Java
  2. Clirr
  3. Revapi
  4. Japicmp
  5. Japitools
  6. Jour
  7. Verificador de Japi
  8. Sigtest

Eles pegam dois arquivos JAR e fornecem o resultado: quão compatíveis eles são.


No entanto, estamos desenvolvendo a biblioteca Kotlin, que até agora faz sentido usar apenas a partir do Kotlin. Isso significa que nem sempre precisamos de 100% de compatibilidade, por exemplo, para classes internal . Embora sejam públicos em código de bytes, seu uso fora do código Kotlin é improvável. Portanto, para manter a compatibilidade binária, o kotlin-stdlib JetBrains usa um verificador de compatibilidade binária . O princípio básico é este: um despejo de toda a API pública é criado a partir do arquivo JAR e gravado no arquivo. Este arquivo é uma linha de base (referência) para todas as verificações adicionais e é assim:


 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 } 

Após fazer alterações no código-fonte, a biblioteca da linha de base é regenerada, comparada com a atual, e a verificação falha se houver alguma alteração na linha de base. Essas alterações podem ser substituídas passando -Doverwrite.output=true . Um erro ocorrerá mesmo que tenham ocorrido alterações binárias compatíveis. Isso é necessário para atualizar oportunamente a linha de base e ver suas alterações diretamente na solicitação de recebimento.


Validador de compatibilidade binária


Vamos ver como essa ferramenta funciona. A compatibilidade binária é fornecida no nível da JVM (bytecode) e é independente do idioma. É possível substituir a implementação da classe Java pelo Kotlin- sem quebrar a compatibilidade binária (e vice-versa).
Primeiro, você precisa entender quais classes existem na biblioteca. Lembramos que, mesmo para funções e constantes globais, uma classe é criada com o nome do arquivo e o sufixo Kt , por exemplo, ContinuationKt . Para obter todas as classes, usamos a classe JarFile do JDK, obtemos ponteiros para cada classe e os passamos para org.objectweb.asm.tree.ClassNode . Esta classe nos permitirá saber a visibilidade da classe, seus métodos, campos e anotações.


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

O Kotlin, ao compilar, adiciona sua anotação de tempo de execução @Metadata a cada classe, para que o kotlin-reflect possa restaurar a aparência da classe Kotlin antes de ser convertida em bytecode. É assim:


 @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 pode obter a anotação @Metadata do @Metadata e analisá-la no KotlinClassHeader . Você deve fazer isso manualmente, porque o kotlin-reflect não sabe como trabalhar com o 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) } 

O kotlin.Metadata é necessário para lidar adequadamente com o internal , porque ele não existe no bytecode. Alterações nas classes e funções internal não podem afetar os usuários da biblioteca, embora sejam uma API pública em termos de bytecode.


No kotlin.Metadata, você pode descobrir sobre o companion object . Mesmo se você o declarar privado, ele ainda será armazenado no campo estático público Companion , o que significa que esse campo se enquadra nos requisitos de compatibilidade binária.


 class CompositeException() { private companion object { } } 

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

Das anotações necessárias, vale a pena observar @PublishedApi para classes e métodos usados ​​em funções públicas em inline . O corpo de tais funções permanece nos locais de suas chamadas, o que significa que as classes e métodos nelas devem ser compatíveis com binários. Ao tentar usar classes e métodos não públicos nessas funções, o compilador Kotlin @PublishedApi erro e oferecerá marcá-los com a anotação @PublishedApi .


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

A árvore da herança de classes e a implementação de interfaces são importantes para oferecer suporte à compatibilidade binária. Não podemos, por exemplo, simplesmente remover alguma interface da classe. E obter a classe pai e interfaces implementáveis ​​é bastante simples.


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

Object removido da lista, pois o rastreamento não faz sentido.


Dentro do validador, existem várias verificações adicionais específicas do Kotlin: verificar os métodos padrão nas interfaces através da Interface$DefaultImpls , ignorando as classes $WhenMappings para o operador when , e outras.


Em seguida, você precisa passar por todo o ClassNode e obter o MethodNode e FieldNode . A partir da assinatura das classes, seus campos e métodos, obtemos ClassBinarySignature , FieldBinarySignature e MethodBinarySignature , que são declarados localmente no projeto. Todos eles implementam a interface MemberBinarySignature , são capazes de determinar sua visibilidade pública usando o método isEffectivelyPublic e exibem sua assinatura em um formato legível 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) } } 

Após receber a lista ClassBinarySignature , você pode gravá-la em um arquivo ou memória usando o método dump(to: Appendable) e compará-lo com a linha de base, o que ocorre no teste 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) } 

Ao confirmar uma nova linha de base, obtemos as alterações em um formato legível, como neste 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 { 

Usando um validador em seu projeto


O uso é extremamente simples. Copie o binary-compatibility-validator no seu projeto e altere seu build.gradle e 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") } 

No nosso caso, uma das funções de teste do arquivo RuntimePublicAPITest é semelhante a esta:


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

Agora, para cada solicitação pull, execute ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false e force os desenvolvedores a atualizar os arquivos de linha de base no prazo.


Voar na pomada


No entanto, essa abordagem tem alguns pontos negativos.


Primeiro, devemos analisar independentemente as alterações nos arquivos da linha de base. Nem sempre suas alterações levam à incompatibilidade binária. Por exemplo, se você implementar uma nova interface, terá uma diferença tão grande na linha de base:


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

Em segundo lugar, são utilizadas ferramentas que não se destinam a isso. Os testes não devem ter efeitos colaterais na forma de gravar algum arquivo no disco, que será subsequentemente usado pelo mesmo teste e, mais ainda, passar parâmetros para ele através de variáveis ​​de ambiente. Seria ótimo usar essa ferramenta em um plug-in Gradle e criar uma linha de base usando uma tarefa. Mas eu realmente não quero mudar algo por conta própria no validador, para que mais tarde seja fácil extrair todas as alterações do repositório Kotlin, porque no futuro novas construções poderão aparecer no idioma que precisará ser suportado.


Bem e em terceiro lugar, apenas a JVM é suportada.


Conclusão


Usando o verificador de compatibilidade binária, você pode obter compatibilidade binária e responder a tempo a uma alteração em seu estado. Para usá-lo no projeto, foi necessário alterar apenas dois arquivos e conectar os testes ao nosso IC. Esta solução tem algumas desvantagens, mas ainda é bastante conveniente de usar. Agora, o Reaktive tentará manter a compatibilidade binária para a JVM da mesma maneira que o JetBrains faz para a Biblioteca Padrão Kotlin.


Obrigado pela atenção!

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


All Articles