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 важно при разработке библиотек
Давай рассмотрим простую схему, как библиотека может подключаться в проект, и будем эту схему расширять:
Пока диспозиция несложная, есть наш проект, он подключает в себя 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
Теперь попробуем разобраться, почему всё-таки мы поймали краш. Вернёмся на шаг назад. Если мы сделаем в 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
Опять не сработало, но теперь мы получили новую ошибку. Что же тут не так? Взглянем на сгенерированный метод 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.