Привет, дорогой читатель! Думаю, ты точно знаешь, что такое API и как сделать, чтобы твои изменения были API-совместимыми. На самом деле я сам никогда не задумывался о том, что существует ABI-совместимость, пока не столкнулся с разработкой библиотеки.

У нас в Альфе есть библиотеки, которые используются несколькими проектами. Про это у меня был доклад на Mobius. При разработке этих библиотек мы всегда думали об API-совместимости, и это логично, но не задумывались о вопросе ABI-совместимости, а это довольно важный вопрос. Эту тему я раскрывал в другом докладе на Mobius. Сейчас расскажу, почему этот вопрос стоит вашего внимания.

Что такое API и что такое ABI

API — application programming interface, например, исходный код нашего приложения.

ABI — application binary interface, например, класс-файлы, которые были сгенерированы на основе нашего исходного кода.

Почти всегда ABI напрямую связан с API, так как ABI — это скомпилированная форма API. Но эта связь не всегда работает так, как мы ожидаем. Давай рассмотрим несколько практических примеров:

Source code - что мы написали:
public List<String> getStrings() {}
public List<Integer> getInts() {}

Class file - что в итоге стало с нашим кодом:
public List getStrings() {}
public List getInts() {}

В данном простом примере ты можешь обратить внимание, что пропала информация о дженериках. Теперь мы с тобой поняли, что API не всегда равно ABI.

Почему помнить про ABI важно при разработке библиотек

Давай рассмотрим простую схему, как библиотека может подключаться в проект, и будем эту схему расширять:

Пока диспозиция несложная, есть наш проект, он подключает в себя Library A версии 2.0.
Ок, давай пойдем дальше:

Мы с тобой подключили в наш проект библиотеку B, которая в свою очередь подключает в себя библиотеку A с версией 1.0. Мы получаем конфликт версий у библиотеки A, который в дефолтной стратегии Gradle решается таким образом, что в Runtime classpath у нас остаётся библиотека A версии 2.0.

Ок, сейчас мы закрепили с тобой, что Gradle оставит библиотеку A более высокой версии. И именно в этот момент вопрос бинарной совместимости становится для нас максимально острым. А почему так ? Давай разбираться дальше.

Дата-класс, который испортил всё (без негатива к самой фиче дата-классов в Kotlin)

Давай представим, что мы разработчики Library A и в нашей библиотеке в версии 1.0 есть вот такой класс:

data class MarkdownModel(
    val size: Size? = null
)

Теперь нам понадобилось добавить в него новое поле и выпустить библиотеку версии 2.0:

data class MarkdownModel(
    val size: Size? = null,
    val jackFresco: String? = null
)

А в Library B у нас есть вот такой код:

init {
    val k = MarkdownModel(size = null)
    println(k.copy())
}

Давай остановимся и подумаем. Будут ли у нас проблемы, если мы запустим наше приложение и исполнение дойдёт до кода с инициализацией дата-класса?

Сектор «Краш в рантайме» на барабане!

Посмотрим на стектрейс этого краша:

StackTrace из Libary B
StackTrace из Libary B

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

Мы сделали API-совместимое изменение, которое ломает ABI-совместимость (бинарную совместимость). И вот эту проблему наш клиент уже не отловит в компайл-тайме. Она отстрелит у него в рантайме, как на скриншоте выше. А всё потому, что мы не перекомпилировали Library B, она всё ещё скомпилирована с Library A версии 1.0 и теми сигнатурами конструкторов и методов, которые были в этой версии. Сигнатура конструктора нашего дата-класса поменялась, и Library B крашнулась, потому что в рантайме не нашлось подходящего конструктора класса MarkdownModel.

У меня даже есть быстрое решение, давай добавим @JvmOverloads в конструкторе класса:

data class MarkdownModel @JvmOverloads constructor(
    val size: Size? = null,
    val jackFresco: String? = null
)

Запускаем, ну теперь-то точно сработает:

StackTrace из Libary B
StackTrace из Libary B

Опять не сработало, но теперь мы получили новую ошибку. Что же тут не так ? Взглянем на сгенерированный метод copy до нашего изменения:

@NotNull
public final MarkdownModel copy(@Nullable Size size) {
   return new MarkdownModel(size);
}

// $FF: synthetic method
public static MarkdownModel copy$default(MarkdownModel var0, Size var1, int var2, Object var3) {
   if ((var2 & 1) != 0) {
      var1 = var0.size;
   }

   return var0.copy(var1);
}

А теперь посмотрим на метод copy после нашего изменения:

@NotNull
public final MarkdownModel copy(@Nullable Size size, 
                                @Nullable String jackFresco) {
   return new MarkdownModel(text, size, jackFresco);
}

// $FF: synthetic method
public static MarkdownModel copy$default(MarkdownModel var0, 
                                         Size var1, String var2, 
                                         int var3, Object var4) {
   if ((var3 & 1) != 0) {
      var1 = var0.size;
   }

   if ((var3 & 2) != 0) {
      var2 = var0.jackFresco;
   }
   return var0.copy(var1, var2);
}

Если ты обратишь внимание, сигнатура метода copy поменялась, и JvmOverloads аннотация нас не спасла. Library B просто не знает про такую сигнатуру и вполне закономерно выбросит нам exception NoSuchMethodError. Хуже всего, что в дата-классе добавление нового поля в праймари конструктор всегда равно бинарно несовместимым изменениям. Но когда только начинаешь делать библиотеку, это не совсем очевидная история. И хуже всего то, что такие проблемы не отлавливаются компилятором (они и не могут быть им отловлены). Клиенты нашей библиотеки получат краш в рантайме.

Ещё один пример

Допустим, в Library A версии 1.0 есть вот такой интерфейс:

interface BaseAnalyticsEvents {

    val ERROR: String
        get() = "Error"
    val SUCCESS: String
        get() = "Success"
}

В Library A версии 2.0 в этот интерфейс добавилось новое поле LONG_TAP:

interface BaseAnalyticsEvents {

    val ERROR: String
        get() = "Error"
    val SUCCESS: String
        get() = “Success”
    val LONG_TAP: String
        get() = “Long Tap”
}

В Library B есть вот такой метод:

fun sendEvent(action: String, label: String) {
    performEvent(
        action = action,
        label = label
    )
}

А внутри нашего My Awesome Project есть кусочек кода:

fun trackCardItemClick() {
    sendEvent(
        action = LONG_TAP,
        label = "Card Item"
    )
}

Фух, ну вот мы и собрали кейс. Будут ли у нас проблемы в такой схеме ?

Не буду тебя долго томить. Проблема будет. Это кажется странным, но добавление нового публичного поля может стать ломающим бинарную совместимость изменением ¯_(ツ)_/¯. Думаю, нет смысла объяснять почему именно, схема примерно такая же:

Выводы

Давай подведём черту:

  • API и ABI — это не всегда одно и то же.

  • Если мы с тобой пишем библиотеку, нужно всегда думать о бинарной совместимости.

  • Бинарная совместимость может ломаться в самых неожиданных местах. Однако в своём докладе на Mobius я рассказывал, как детектить такие кейсы. Мы для этого пользуемся своей надстройкой над плагином от JetBrains.

Список полезных источников:

Комментарии (10)


  1. Drake757
    23.11.2023 07:45
    +1

    Мы получаем конфликт версий у библиотеки A, который в дефолтной стратегии Gradle решается таким образом, что в Runtime classpath у нас остаётся библиотека A версии 2.0.

    Ок, сейчас мы закрепили с тобой, что Gradle оставит библиотеку A более высокой версии.

    Может быть вопрос не стоял бы так остро, если отказаться от дефолтной стратегии Gradle? Что мешает ему указать, что зависимости нужны 2 и не превращать это все в пытку.

    Это ещё хорошо если библиотеки конфликтующие находятся под вашей эгидой и там ещё можно что-то сделать


    1. Ab0cha Автор
      23.11.2023 07:45
      +1

      К сожалению, при отказе от дефолтной стратегии проблем становится больше(


  1. KrutoyAn
    23.11.2023 07:45
    +1

    На сколько тяжело следить за этим делом в таком большом проекте ?


    1. saege5b
      23.11.2023 07:45

      Dll hell. - Форточки, пингвин.

      Яблоко решило просто - каждая программа несёт с собой, всё, что ей надо.

      Более радикально решают этот вопрос контейнеры.


    1. Ab0cha Автор
      23.11.2023 07:45
      +1

      задача не самая простая, но тут думаю лучшая стратегия автоматизировать те проверки, которые можно автоматизировать)


      1. KrutoyAn
        23.11.2023 07:45

        Можете поделиться примером ?


  1. Sipaha
    23.11.2023 07:45
    +1

    У нас в kotlin/java продукте много библиотек используется и проблемы поднимаемые в статье раньше тоже часто встречались. В итоге пришли к такому решению:


    1. Во всех библиотеках зависимости от остальных библиотек выставлены как provided (т.е. транзитивно не тянутся).

    2. Каждая библиотека заливает в maven repo не только себя и исходники, но и тесты отдельной jar'кой

    3. Все библиотеки с указанием нужных версий подключены в общем проекте, который билдит родительские pom'ники для всех конечных приложений. Этот общий проект при выполнении тестов так же прогоняет все тесты из подключаемых библиотек. Это позволяет довольно легко отлавливать проблемы с совместимостью ABI.


    1. Ab0cha Автор
      23.11.2023 07:45
      +1

      Круто, спасибо вам, что поделились опытом)


  1. aleksandy
    23.11.2023 07:45
    +1

    А может проблема в том, что вместо православных POJO, которыми ты полностью управляешь, используются data-классы, генерируемые неконтролируемым тобою компилятором?


    1. Ab0cha Автор
      23.11.2023 07:45
      +1

      Вполне резонно, data классы приносят много ограничений с точки зрения ABI совместимости, если они торчат в публичном api библиотеки)