API vs ABI: разницу видят не только лишь все

Привет, дорогой читатель! Думаю, ты точно знаешь, что такое 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 getStrings() {}
public List getInts() {}

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

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

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

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

1005be79a3d00f1bce001910ecd0a051.png

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

4d130e70dedd4d91bdbb9969a4affff4.png

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

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

9862bf91b3da52ef5e33b764acf9c202.png

Дата-класс, который испортил всё (без негатива к самой фиче дата-классов в 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())
}

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

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

9dfbbbbb569651c984d079636b686edd.png

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

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. Хуже всего, что в дата-классе добавление нового поля в праймари конструктор всегда равно бинарно несовместимым изменениям. Но когда только начинаешь делать библиотеку, это не совсем очевидная история. И хуже всего то, что такие проблемы не отлавливаются компилятором (они и не могут быть им отловлены). Клиенты нашей библиотеки получат краш в рантайме.

36a55af600f141223eb7ee4615e7f742.png

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

Допустим, в 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"
    )
}

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

984ad866b7a05bfd075e6affe25788d7.png

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

711619872940ec2430fd41169ebe2814.png

Выводы

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

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

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

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

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

© Habrahabr.ru