Реактивная двоичная совместимость: как мы этого достигаем

В рамках семьи Bumble - материнской компании, работающей с приложениями Badoo и Bumble - один из моих основных проектов был задействован в команде, создавшей библиотеку Reaktive - реактивные расширения на чистом Kotlin.

По возможности любая библиотека должна поддерживать двоичную совместимость. Всякий раз, когда различные версии библиотеки несовместимы с точки зрения их зависимостей, во время выполнения будут сбои. Мы можем столкнуться с этой проблемой при добавлении поддержки Reaktive в MVICore.

В этой статье я кратко объясню, что такое бинарная совместимость: ее особенности в случае Kotlin; как он поддерживался в JetBrains, и как он теперь поддерживается в Bumble.

Проблема бинарной совместимости в Котлине

Допустим, у нас замечательная библиотека. com.sample:lib:1.0 со следующим классом:

На основе этой библиотеки мы создали вторую библиотеку com.sample:lib-extensions:1.0. Среди его зависимостей есть com.sample:lib:1.0. Например, он содержит фабричный метод для класса A:

Затем мы выпускаем новую версию нашей библиотеки com.sample:lib:2.0 со следующей модификацией:

Эта модификация полностью совместима с точки зрения Kotlin, не так ли? С параметром по умолчанию мы можем продолжать использовать конструкцию val a = A(a), но только если все зависимости полностью перекомпилированы. Параметры по умолчанию не являются частью JVM и реализуются с помощью специального синтетического конструктора класса A, который содержит все поля класса в своих параметрах. Если мы получаем зависимости из репозитория Maven, они уже собраны, и мы не можем их повторно скомпилировать.

Когда выходит новая версия com.sample:lib, мы сразу добавляем ее в наш проект. В конце концов, мы хотим быть в курсе событий! Новые функции, новые исправления, новые ошибки!

В этом случае мы получаем сбой во время выполнения. Функция createA в байт-коде пытается вызвать конструктор класса А с одним параметром, но такой конструктор не существует в байт-коде. Из всех зависимостей с одинаковой группой и именем Gradle выберет ту, которая имеет самую последнюю версию, и добавит ее в сборку.

Скорее всего, вы уже сталкивались с бинарной несовместимостью в своих проектах. Я лично столкнулся с этим при переносе наших приложений на AndroidX.

Вы найдете больше о бинарной совместимости в следующих статьях: Развитие API на основе Java 2 от создателей Eclipse; и в недавней статье Джейка Уортона Проблемы публичного API в Kotlin.

Способы достижения бинарной совместимости

Вы можете подумать, что все, что вам нужно сделать, это внести совместимые изменения, например, добавить конструкторы со значением по умолчанию при добавлении новых полей или добавить новые параметры к функциям, перегрузив метод новым параметром и т. Д. при выполнении любого из этих действий слишком легко ошибиться, поэтому были созданы различные инструменты для проверки, являются ли две разные версии библиотеки двоично-совместимыми, в том числе:

  1. Средство проверки соответствия Java API
  2. Clirr
  3. Ревапи
  4. Japicmp
  5. Японские инструменты
  6. Jour
  7. Япошекер
  8. SigTest

Они принимают два файла JAR и возвращают результат, показывающий степень их совместимости.

Однако мы разрабатываем библиотеку Kotlin, которую пока имеет смысл использовать только из Kotlin. А это значит, что 100% совместимость не всегда будет нужна - например, для internal классов. Несмотря на то, что они общедоступны в байт-коде, маловероятно, что они будут использоваться вне кода Kotlin. По этой причине, чтобы сохранить бинарную совместимость, JetBrains использует валидатор бинарной совместимости для kotlin-stdlib. Главный принцип заключается в том, что дамп всего публичного API создается из файла JAR и записывается в файл. Этот файл формирует основу для всех дальнейших проверок и выглядит так:

После внесения изменений в исходный код библиотеки базовый план создается снова и сравнивается с текущим базовым планом, и, если в исходный код вносятся какие-либо изменения, тест завершается с ошибкой. Эти изменения можно переписать, используя -Doverwrite.output=true. Ошибка возникает, даже если произошедшие изменения бинарно совместимы. Это необходимо для обеспечения своевременного обновления базовой линии и для того, чтобы ее изменения были видны непосредственно в запросе на вытягивание.

Валидатор двоичной совместимости

Давайте посмотрим, как работает этот инструмент. Двоичная совместимость достигается на уровне JVM (байт-кода) и не зависит от языка. Вполне возможно заменить реализацию класса Java на Kotlin без ущерба для бинарной совместимости (и наоборот).

Во-первых, нам нужно знать, какие классы содержит библиотека. Даже в случае глобальных функций и констант создается класс с именем файла и суффиксом Kt, например, ContinuationKt. Чтобы получить все классы, мы используем класс JarFile из JDK, получаем указатели на каждый класс и передаем их org.objectweb.asm.tree.ClassNode. Этот класс позволяет нам определять видимость класса, его методов, полей и аннотаций.

Во время компиляции Kotlin добавляет аннотацию среды выполнения @Metadata к каждому классу, так что kotlin-reflect может восстановить представление Kotlin класса до того, как оно будет преобразовано в байт-код. Вот как это выглядит:

Вы можете получить аннотацию @Metadata из ClassNode и преобразовать ее в KotlinClassHeader. Это нужно делать вручную, поскольку kotlin-reflect не работает с ObjectWeb ASM.

Метаданные необходимы для правильной обработки internal, поскольку их нет в байт-коде. Изменения internal классов и функций не могут повлиять на пользователей библиотек, даже если они являются общедоступным API в байт-коде.

Из метаданных вы можете узнать о companion object. Даже если мы объявим его частным, он все равно будет храниться в общедоступном статическом поле Companion, а это означает, что это поле подчиняется требованиям для двоичной совместимости.

Из необходимых аннотаций также стоит обратить внимание на @PublishedApi классы и методы, которые используются в общедоступных inline функциях. Тело этих функций остается там, где они вызываются, а это означает, что классы и методы в них должны быть двоично-совместимыми. Если будет сделана попытка использовать в этих функциях закрытые классы и методы, компилятор Kotlin вернет ошибку и предложит пометить их аннотацией @PublishedApi.

Дерево наследования классов и реализация интерфейса важны для поддержки двоичной совместимости. Мы не можем, например, просто удалить данный интерфейс из класса, тогда как получить родительский класс и интерфейсы довольно легко.

Object был удален из списка, так как нет абсолютно никакого смысла отслеживать его.

Внутри валидатора есть множество различных дополнительных проверок, специфичных для Kotlin: проверка методов по умолчанию в интерфейсах через Interface$DefaultImpls; игнорирование $WhenMappings классов для работы оператора when; и другие.

Затем необходимо пройти все ClassNodes и получить их MethodNodes и FieldNodes. Из сигнатур классов, их полей и методов мы получаем ClassBinarySignature, FieldBinarySignature и MethodBinarySignature, которые объявлены локально в проекте. Они реализуют интерфейс MemberBinarySignature, могут определять свою общедоступность с помощью метода isEffectivelyPublic и предоставлять свою подпись в удобочитаемом формате val signature: String.

После получения списка ClassBinarySignature его можно записать в файл или в память с помощью метода dump(to: Appendable) и сравнить с базовым показателем, что и происходит в тесте RuntimePublicAPITest:

Приняв новый базовый план, мы получаем изменения в удобочитаемом формате, например, в этом коммите:

Использование валидатора в вашем проекте

Пользоваться им очень просто. Скопируйте binary-compatibility-validator в свой проект и измените его build.gradle и RuntimePublicAPITest:

В нашем примере одна из тестовых функций файла RuntimePublicAPITest выглядит так:

Теперь мы запускаем ./gradlew :tools:binary-compatibility:test -Pbinary-compatibility-override=false для каждого запроса на вытягивание и заставляем разработчиков своевременно обновлять базовые файлы.

Ложка дегтя

Однако у этого подхода есть и недостатки.

Во-первых, мы сами должны проанализировать изменения в базовых файлах. Такие изменения не всегда приводят к бинарной несовместимости. Например, реализация нового интерфейса приводит к следующему отличию базовой линии:

Во-вторых, он использует инструменты, которые никогда для этого не предназначались. Тесты не должны иметь побочных эффектов, таких как запись какого-либо файла на диск, что приводит к его использованию в этом тесте, и они никогда не должны передавать ему параметры через переменные среды. Было бы здорово использовать этот инструмент как задачу Gradle. Но мы действительно не хотим что-то менять в валидаторе сами, поэтому упростим извлечение всех его изменений из репозитория Kotlin, потому что в будущем могут появиться новые конструкции на языке, которые необходимо будет поддерживать.

И в-третьих, поддерживается только JVM.

Заключение

Двоичная совместимость и время реакции на изменение его состояния может быть достигнуто с помощью Валидатора двоичной совместимости. Его использование в этом проекте потребовало изменения всего двух файлов и добавления тестов в наш CI. Несмотря на то, что это решение имеет ряд недостатков, пользоваться им все же достаточно удобно. Reaktive теперь пытается поддерживать двоичную совместимость для JVM так же, как JetBrains делает для стандартной библиотеки Kotlin.

Спасибо за прочтение!

Обновление: на прошлых выходных команда Kotlin наконец-то выпустила валидатор двоичной совместимости как отдельный плагин Gradle. Он использует те же принципы, которые я описал в этой статье, и теперь его можно использовать намного проще!