Это текстовая версия доклада с Java Rock Star Meetup, с которым выступал Владимир Ситников (@vladimirsitnikov) — performance engineer, PgJDBC и JMeter committer, а также член программных комитетов JPoint, Joker, Heisenbug, DevOops и SmartDara. Если вы больше любите смотреть видео, то смотрите запись доклада на YouTube или VK Видео.
От приложения мы хотим стабильности и предсказуемости. Мы хотим, чтобы приложение было одинаковым. Эта предсказуемость и обратная совместимость являются эдакой священной коровой, которая движет Java вперёд, возможно, движет назад и, возможно, по некоторым сведениям, из-за этого Java и умрёт.
Однако 30 лет Java прожила. Давайте посмотрим, как это всё было и что было в начале.
Эволюция Java: от переменных до сломанных интерфейсов
Представим ситуацию: мы написали приложение и запустили его. Всё хорошо, оно работает. Проходит время, мы ничего трогали. В какой-то момент мы решили обновить одну зависимость. Что должно произойти?

Все должно хорошо работать. Но что значит должно работать? Это понятие растяжимое. Какие есть варианты:
Заменили jar с зависимостью, и всё работает. Простой случай — задача решена.
Заменили зависимость, приложение не компилируется. Уже нехорошо, но хотя бы понятно, где точно стоит смотреть.
Компилируется, как и раньше. Приложение раньше контролировалось и сейчас тоже компилируется. При этом, если компилируется, то не означает, что работает.
Выполняется с тем же результатом. Например, раньше выдавало на выходе какой-то файл или печатало строку и сейчас продолжает так делать. Вот это и есть идеальная для нас, как для потребителей, картина.
Работает, но после перекомпиляции. Например, если вы обращались к полю библиотечного класса, и сохраняли его в Object, а в библиотеке поле изменилось (было Object, а стало String), то без перекомпиляции работать не будет. Это менее болезненно, чем когда приходится адаптировать код под новую версию, но всё равно хотелось бы избегать лишних действий.
За 30 лет накопилось много старого кода. Некоторые ранее допустимые конструкции стали недопустимыми. Например:
int assert = 10;
Никто не задумывался о том, что слово assert сделают ключевым словом в языке и запретят называть так переменные.
void process( int id, int _);
Кто-то взял и скрыл параметр. В моменте неважно было имя параметра, поэтому подставили нижнее подчеркивание в качестве имени. Но потом это начало падать с компиляцией.
thread.stop();
Кто-то написал код и использовал метод stop()
, чтобы остановить потоки. Через время Java кидает исключение, так как этот метод больше не работает и объявлен устаревшим.
Бывает и хуже. Существовал интерфейс java.sql.PreparedStatement
и что с ним могло пойти не так? В интерфейс добавили метод. Допустим, у вас было приложение, которое реализовывало этот интерфейс, а потом вдруг появился новый метод. Если вы не угадали, как этот метод должен был называться и какие у него должны быть аргументы, то вам не повезло, ваше приложение теперь падает.
interface java.sql.PreparedStatement() {
//since 1.6
void setBinaryStream(int parameterIndex, InputStream x);
}
Ещё один пример — переименование пакетов в Java 9 при переходе с Java EE (javax.
) на Jakarta EE (jakarta.
).
Выход есть и ровно один. Что нам это дает? Когда приложение упадёт на компиляции — это хорошо. Но гораздо более сложная ситуация — когда мы хотим, чтобы оно выполнялось с тем же самым результатом. Что значит, выполнялось с тем же самым результатом? Как понять, что результат тот же самый?
Есть такая байка в Интернете. Какой-то пользователь говорит: “Мы посмотрели софт и обновили”. При этом в примечаниях к релизу написано:
Обновления в 10.17: сокращено потребление CPU при нажатом пробеле.
То есть, если пробел нажимаешь, то CPU не тратятся, как это было раньше. Но что происходит в реальности и в мире Java? Приходит пользователь и случается следующее:
Пользователь: Ребята, вы что сделали? Я раньше на этот пробел рассчитывал. У меня мониторинг стоял. Когда CPU зашкаливал, то CD-ROM выезжал и нажимал на кнопку. Оно перестало греться. Вы мне всё сломали.
Разработчик: 0_0. Никто же не обещал, что будет греться процессор.
Пользователь: А вы можете опцию добавить для старого поведения, чтобы обратная совместимость была?
И здесь кто-то может задуматься: SemVer (Semantic Version) же? Софт версионируется по семантическим правилам. Есть правило, что номер версии состоит из трёх чисел:
MAJOR.MINOR.PATCH,
где:
MAJOR+1 — нарушили совместимость.
MINOR+1 — добавили функциональность.
PATCH+1 — ничего не сломали.
Есть альтернативный взгляд на эту систему версионирования, который называется СломВер — то, насколько сильно мы сломали код.
СломВер 3.0: МНОГО.МАЛО.ЧУТОЧКУ
В СломВер номер версии интерпретируется так:
МНОГО.МАЛО.ЧУТОЧКУ
Так вот пример с перегрузкой CPU вроде как непоправимо хорошее улучшение, но для кого-то это — обратная совместимость: раньше всё работало, а сейчас перестало греться. Если так посудить, то многие исправления ошибок ломают обратную совместимость.
Такие примеры есть не только в мире Java. Например, в мире Linux есть сборка от Red Hat и была сборка от Oracle (Oracle Enterprise Linux). Было время, когда Oracle говорил, что сборка Oracle Enterprise Linux является bug-to-bug compatible. Это значит, что в Oracle Linux были те же самые баги, что и в Red Hat Linux. Это было критически важно для людей, которые мигрируют с одной сборки на другую.
Главное, что нужно понимать в подходе СломВер — мы рано или поздно что-то сломаем. Нужно понять, насколько сильно мы сломаем для пользователей опыт, и затем сообщить это пользователям. Мы (как разработчики) используем номер версии для того, чтобы сигнализировать, насколько наши изменения существенны для пользователя.
Изменения могут иметь разный эффект.
При обновлении придётся править код.
При обновлении достаточно перекомпилировать код.
При обновлении всё должно работать.
Идеальный случай: должны сохраняться даже старые ошибки.
Давайте посмотрим пример того, как это могло быть и как это было на самом деле в Java.
Пример с TreeSet
Класс TreeSet при определённых условиях мог вести себя по-разному в зависимости от порядка вставки элементов. Рассмотрим такой пример:
var conferences = new TreeSet<String>();
names.add("Java Rock Stars");
names.add(null);
Мы берём и добавляем в наш TreeSet записи. Что должно произойти, после попытки добавления null? Логично предположить, что NullPointerException
(NPE). А если мы попробуем добавить записи в обратном порядке?
var conferences2 = new TreeSet<String>();
names.add(null);
names.add("Java Rock Stars");
Результат должен быть тот же. На практике можно получить разный эффект при изменении порядка добавления записей в пустой TreeSet. Эта бага называется JDK-5045147: Adding null key to empty TreeMap .. should throw NPE.
Ошибку признали, но исправили только в следующем релизе, т.е. нашли в Java 6, а починили только в Java 7. Почему? Потому что исправление повлияло бы на поведение, а значит — сломало бы совместимость.
Парадоксально, но обратная совместимость требует сохранять и баги.
Как делать так, чтобы баги и фичи сохранялись
Как пользователи видят API, который мы им предоставили? В идеале они видят, не просто API, а Public API. То, что мы взяли и назвали Public API.
Допустим, мы выставили интерфейс и захотели добавить в него метод. В Java есть возможность добавить default-метод в интерфейс.
interface java.sql.PreparedStatement{
default long executeLargeUpdate() throws SQLException {
throw new UnsupportedOPerationException("executeLargeUpdate not implemented");
}
}
Получается, если кто-то использует интерфейс, то он не будет напрямую ломаться. Уже есть хороший способ, как расширять язык. А что является этим самым интерфейсом, который мы оставляем пользователю?
В Java есть методы public, private, package-private. Всё ли это является API? Казалось бы, public-элементы являются элементами Public API. На самом деле нет. Правильнее сказать, что это Published API. Этот термин был введён Мартином Фаулером, который и сказал, что важно то, что вы сами посчитали интерфейсом. API является то, что автор явно обозначил. А как это можно обозначить?
В Java — никак, поэтому для такого есть специальная библиотека API Guardian, которая позволяет использовать аннотации. Например, бёрем аннотацию И вешаем на наш класс интерфейс, метод, поля и т.д. Примеры аннотаций:
@API(status = STABLE)
@API(status = MAINTAINED)
@API(status = EXPERIMENTAL)
@API(status = INTERNAL)
Получается, когда вы дизайните классы, интерфейсы, методы, то размечаете их соответствующим образом. Библиотека также предоставляет дополнительные поля, например, для обозначения номера версии, начиная с которой поменялся статус. То есть вы ввели какой-то API и он стал экспериментальным с некоторой версии.
Некоторые даже делают инструменты “поверх” этих аннотаций, проверяя, что внутренние проекты не используют Private API или не используют экспериментальные фичи и т.п.
Аннотации не наследуются в Java. Чтобы понять, что аннотация пришла из какого-нибудь базового пакета, базового класса или ещё откуда-то, можно написать свои хелперы, а можно посмотреть как они написаны в JUnit.
В JUnit используется тот же самый пакет аннотаций. Поэтому когда вы читаете код JUnit (JUnit 5), то можно увидеть, какие у них API: maintained, experimental и пр.
package
org.junit.platform.commons.support
;
@API(status = MAINTAINED, since = "1.0")
public final class AnnotationSupport
Как в чистой Java размечать код
Был такой класс java.lang.Object, в котором был метод finalize()
. Авторы пометили его как устаревший (deprecated), написав следующее:
public class Object {
@Deprecated(since="9", forRemoval=true)
void finalize(){}
}
А хотелось бы, чтобы его случайно нельзя было вызвать. Метод помечен как устаревший, но как мы узнаем, что его не нужно вызывать? Хотелось бы аннотацию типа @Hidden
:
public class Object {
@Hidden
@Deprecated(since="9", forRemoval=true)
void finalize(){}
}
Получится, что метод будет существовать и работать для старого кода, а в новом коде его вызвать нельзя.
К сожалению, в Java пока такого не изобрели. Однако на уровне байт-кода есть такая конструкция, которая называется @JvmSynthetic
. То есть можно метод отметить как @JvmSynthetic
только не на уровне исходного кода, а на уровне байт-кода. Посмотрим как и где это используется. Сейчас мы немного отойдём в сторону Kotlin.
Аннотации в Kotlin
В Kotlin есть метод max()
. Вообще говоря, можно на любом типе отрастить новый метод. Например, последовательность Double позволяет отрастить на ней метод максимального Double
. Казалось бы, что может пойти не так? Ниже представлена одна из первых версий этого метода:
public fun Sequence<Double>.max(): Double?
У нас есть последовательность Double
, на ней рассчитываем максимальное и возвращаем либо Double
, либо null
, если там ничего не было.
Через некоторое время авторы поняли, что так делать не надо и нужно метод улучшить. Почему? Когда у вас нет элемента, возникает вопрос: законно ли возвращать null
на пустой коллекции?
Кто-то может сказать: лучше просто кинуть ошибку (Exception), потому что, null
на пустом множестве — это ерунда какая-то. Невозможно выбрать максимальный элемент, если там элементов вообще нет.
И что авторы придумали? Они написали метод лучше — maxOrNull()
. Когда мы видим такой метод, то сразу понимаем, что он может вернуть. Результат выполнения метода max()
так и остался без изменений: он возвращает либо максимальное значение, либо ошибку. Что потребовалось для миграции?
Во-первых, авторы пометили метод как устаревший, предложив пользователям лучшую альтернативу старому методу max()
.
@Deprecated(
"Use maxOrNull instead.",
)
public fun Sequence<Double>.max(): Double?
Во-вторых, в Kotlin есть возможность сказать это hidden депрекация (DeprecationLevel.HIDDEN
).
@Deprecated(\
"Use maxOrNull instead.",
level = DeprecationLevel.HIDDEN,
)
public fun Sequence<Double>.max(): Double?
Это значит, что новый код уже не сможет использовать старый метод. Он будет в исходниках, но вызвать его нельзя. Компилятор сам это проверяет.
Однако в байт-коде метод останется. Получается, если старый код использовал старый метод max(), то код продолжит работать. Для лучшей поддержки в IDE есть расширение аннотации, которая говорит, на что этот старый метод можно заменить.
@Deprecated(
"Use maxOrNull instead.",
level = DeprecationLevel.HIDDEN,
ReplaceWith("this.maxOrNull()"),
)
public fun Sequence<Double>.max(): Double?
Иначе говоря, здесь написано: если у вас такой метод вызывается, то надо менять на maxOrNull()
. С помощью горячих клавиш в IDE вы это сделаете и у вас получится более хороший код.
Преимущества такой миграции:
Старый код не ломается.
Есть понятный путь, как пользователи должны обновиться.
Давайте попробуем понять, а что же нам предлагает Java в этом месте.
Инструменты для автоматической миграции в Java
Разумеется, Java пока ещё не даёт нам возможность автоматических миграций. Но есть внешние инструменты.
Первый инструмент — Error Prone. Это статический анализатор, показывающий проблемные или возможно проблемные участки кода. В Error Prone есть интересная аннотация @InlineMe, которая говорит о том, что вот этот метод лучше бы заменить на другой.
@Deprecated
@InlineMe(
replacement = "this.setDeadline(Duration.ofMillis(deadlineMs))",
imports = {"java.time.Duration"})
public void setDeadline(long deadlineMs){
setDeadline(Duration.ofMillis(deadlineMs));
}
Если вы такую аннотацию навесите на ваш метод, то тогда в проверке Error Prone возникнет ошибка. Вам будет предложено изменить метод setDeadline()
на другой, который вместо long
принимает Duration
.
Вы уже не ошибетесь в секундах или в миллисекундах ли этот long
. Лучше вызывать метод с Duration
, чтобы было видно, что вы имеете в виду миллисекунды. @InlineMe
позволяет автоматически показывать, что нужно делать.
Когда человек увидит, что вы пометили метод как устаревший, то он не поймёт, что вы имели в виду. Ему нужно идти и читать эти доки, чтобы понять на что менять и т.д., поэтому автоматические миграции — наше всё.
И второй инструмент — bridger. Он позволяет генерировать волшебные synthetic-методы для того, чтобы прятать код от Java-компилятора. Иначе говоря, в коде конструкция есть для обратной совместимости, но вызывать её нельзя. Это @Deprecated
и @Hidden
из Kotlin, но для Java.
Рассмотрим пример, где связка @Deprecated
и @Hidden
могла бы быть полезна в Java.
Метод toLowerCase()
используется часто, а с его использованием сопряжено много ошибок. Когда-то казалось нормальным использовать Locale.getDefault()
:
public String toLowerCase(String input) {
return input.toLowerCase(Locale.getDefault());
}
Но значение Locale.getDefault()
зависит от окружения, что может приводить к неожиданным проблемам. В таких случаях полезно использовать Locale.ROOT
, чтобы у нас метод всегда одинаково работал на всех операционных системах, вне зависимости от настроек, чтобы была повторяемость и т.д.
public String toLowerCase(String input) {
// return input.toLowerCase(Locale.getDefault());
return input.toLowerCase(Locale.ROOT);
}
Возможно, вы сталкивались с проблемой , например, при парсинге enum
из командной строки, уровней логирования (info
, debug
и т.п.). Обычно строковое значение, приводят к верхнему регистру с помощью toUpperCase()
и ищут в Enum.valueOf()
. Если вы в турецкой локали tr_TR
сделаете toUpperCase()
, то буква i в info
при переводе в верхний регистр станет турецкой İ, т.е. просто большой i с точкой. В enum
такого значения нет, поэтому вылетает ошибка. По этой причине для нормализации enum
стоит использовать именно Locale.ROOT
.
Однако в Java нельзя сделать 2 метода с одинаковой сигнатурой. Если у нас есть метод, который принимает строку, то второй вынужден называться иначе. Старый метод мы скрыть не можем. В этом и есть проблема.
public String toLowerCase(String input) {
return input.toLowerCase(Locale.getDefault());
}
public String toLowerCaseRootLocale(String input) {
return input.toLowerCase(Locale.ROOT);
}
Тут пригодится библиотека bridger, которую мы обсуждали ранее. Она оставляет старый метод, но с изменённым названием, а новый так, как нам надо. Получается примерно такая конструкция:
public String toLowerCase$$bridge(String input) {
return input.toLowerCase(Locale.getDefault());
}
public String toLowerCaseRootLocale(String input) {
return input.toLowerCase(Locale.ROOT);
}
Мы рассмотрели как можно менять, дорабатывать и добавлять новые методы.
Бывает ситуация, что мы не хотим объявлять метод устаревшим, а хотим отметить его как такой “деликатный”, эдакий кунг-фу API: не пытайтесь повторить дома.
Кунг-фу API
В библиотеке Guava есть внезапно класс интерфейса для того, чтобы хэшировать строчки, какие-то наборы данных. Рассмотрим класс HashingInputStream
:
package
com.google
.common.hash;
public final class HashingInputStream extends FilterInputStream {
Когда этот класс придумали, возникла задача отметить, что этот самый класс в бета-версии. Устаревшим его пометить нельзя, потому что он не может выйти сразу устаревшим. В Guava сделали аннотацию @Beta
. Разумеется, никто, кроме Guava про неё не знает. Однако есть специальный проверятор (отдельный проект), который проверяет @Beta
аннотации — Guava Beta Checker.
src/main/java/foo/MyClass.java:14: error: [BetaApi] @Beta APIs should not be used in library code as they are subject to change.
Files.copy(a, b);
^
(see
https://github.com/google/guava/wiki/PhilosophyExplained#beta-apis
)
Вы можете настроить его у себя, и он будет вам сигнализировать, где у вас в проекте используется @Beta
аннотация.
Это лучшее, что нам предлагает Java-мир на данный момент.
Бета-аннотации в Kotlin
Рассмотрим пример. В данном случае мы массив байт конвертируем в hex.
public fun ByteArray.toHexString(
format: HexFormat = HexFormat.Default): String = ...
Когда это появилось в Kotlin, авторы добавили аннотацию @ExperimentalStdlibApi
, потому что не были уверены в сигнатуре.
@ExperimentalStdlibApi
public fun ByteArray.toHexString(
format: HexFormat = HexFormat.Default): String = ...
Казалось бы, чем это отличается от любой другой произвольной аннотации? Отличается тем, что аннотация в Kotlin позволяет компилятору проверять. Компилятор требует, что по мере использования конкретно вот этой экспериментальной API нужно подписываться. Так вы говорите, что действительно хотите использовать экспериментальный API из этого пакета.
@ExperimentalStdlibApi
public fun ByteArray.toHexString(
format: HexFormat = HexFormat.Default): String = ...
@OptIn(ExperimentalStdlibApi::class)
fun usage() {
println(byteArrayOf(1,2,3).toHexString());
}
Можно изобретать свои аннотации и размечать код ваших библиотек. Тогда пользователи будут видеть, используют ли они стабильное API или же экспериментальное.
Такой подход можно увидеть, например, в kotlinx.coroutines API, где так размечают сложные для понимания многопоточные API. Там есть метод, например, некоторого хитроумного запуска корутины (coroutine). Чтобы использовать этот метод, вы добавляете эту самую аннотацию @OptIn
, показывая осознанность выбора метода. Так, авторы библиотеки защищают пользователя от внезапного использования тех API, которые можно случайно неправильно использовать.
Изменяем сигнатуры
Рассмотрим пример, когда безобидное изменение приводит к проблемам. В Java был метод position()
в классе java.nio.Buffer
. Так мы могли изменить указатель на текущую позицию в буфере.
package java.nio;
public abstract class Buffer {
public Buffer position(int newPosition) {...}
}
Но есть один минус. У класса есть наследники. Например, ByteBuffer
.
public class ByteBuffer extends Buffer {
}
В какой то момент авторы сказали: Почему position()
возвращает Buffer
? Давайте position()
будет возвращать ByteBuffer
. Это даст возможность клиентам использовать цепочки вызовов, и тип ByteBuffer
не будет теряться в цепочках. Логично же, правда? Почему бы и нет.
public class ByteBuffer extends Buffer {
public ByteBuffer position(int newPosition) {...}
}
Используем наш ByteBuffer
и вызовем position()
на нём:
var buffer = ByteBuffer.allocate(8);
buf.position(0);
Всё скомпилируется и запустится, но в рантайме может возникнуть ошибка:
java.lang.NoSuchMethodError:
java.nio.ByteBuffer.position(I)Ljava/nio/ByteBuffer
Как правило, это возникает в такой конструкции: мы выполняем код в Java 8. Почему? Это происходит, потому что сам метод ковариантный, т.е. добавленный, и появился в 9 и выше. В Java 8 этого метода старая сигнатура position()
. На уровне байт-кода, при вызове метода position()
учитывается не только название метода, но и тип возвращаемого значения. Получается, что, когда мы компилируем, то в код вкомпиливается название метода, аргументы, тип возвращаемого значения. Так, если мы компилировали код на Java 9, то указывать target байт-код Java 8 мало для работоспособности на Java 8.
В статье ByteBuffer and the Dreaded NoSuchMethodError разобрана эта ошибка и почему она возникает.
Важно, что пока не выполнишь это на Java 8, то не узнаешь об ошибке.
Из этого мы делаем следующие выводы:
Результирующий class-файл зависит от версии javac.
Возникает желание добавить -source
и -target
. Это не поможет, потому что проблема не в версии класса. NoSuchMethodError
была в PostgreSQL JDBC Driver, когда одно время там был указан -target
.
Правильный подход — использовать опцию
--release
:javac --release 11
.
Как раз эта опция позволяет сгенерировать классы, способные запускаться на указанной версии Java. Старые опции -source
и -target
использовать не стоит.
Работает это следующим образом: в каждой Java есть набор сигнатур от нескольких предыдущих релизов. Возьмём для примера Java 21: в этой версии есть сигнатуры Public API от Java 8 и до Java 21. Таким образом, Java 21 понимает, что в Java 8 метод position()
возвращал ByteBuffer
, и при использовании --release 8
скомпилируется вызов с сигнатурой Java 8.
Аналогичная проблема может возникнуть и при использовании Kotlin. Для него нужна аналогичная опция, которая указывает компилятору Kotlin целевую версию Java.
Правильный подход — использовать опцию:
kotlinc -Xjdk-release=11
.
Для автоматизации и синхронизации настроек между Java и Kotlin есть Gradle-плагин GradleUp/compat-patrouille.
Дженерики, байткод и стирание типов
Добавление дженериков может нарушить совместимость, если не учитывать, как они стираются в байткод.
Допустим, мы хотим вычислить максимальный элемент в нашей коллекции. Как мы это делаем?
public static Object max(Collection coll) {
}
Кто помнит прекрасные времена Java 1.4? А прекрасные, потому что, если сейчас открыть сигнатуру, то выглядит она вот так:
public static <T extends Comparable<? super T>>
T max(Collection <? extends T> coll) {
Есть правда один нюанс: так менять нельзя. Просто добавить дженерики в проект не всегда получится. Точнее, получится, так как они специально были задизайнены так, чтобы их можно было добавить в проект без потери обратной совместимости. Однако без добавления тестов вы не узнаете, что именнно сломалось. Почему же? Потому что правильный дженерик нужно писать следующим образом:
public static <T extends Object & Comparable<? super T>>
T max(Collection <? extends T> coll) {
Давайте разберём, почему T extends Comparable<...>
не обратно совместимо со старым методом. Для этого посмотрим на байткод, который получается при компиляции такого метода.
public static <T extends Comparable<? super T>>
T max(Collection <? extends T> coll) {
// Comparable max(Collection)
В этом случае он компилируется с типом возвращаемого значения Comparable
. А в старой версии кода (Java 1.4) у нас возвращался Object
. Как раз до этого мы разбирали пример с ByteBuffer
, где поняли, что тип возвращаемого значения очень важен, и уже знаем, что нельзя просто так брать и менять тип возвращаемого значения. Нужно добиться того, чтобы T
в T max(Collection <? extends T> coll)
стёрлась в Object
.
Некоторые думают, что дженерики всегда стираются в Object
, но это не так. Если открыть Java language specification, то написано следующее: Стирается в первый тип, указанный сразу после слова extends
. Получается, что у нас написано, что T extends Comparable…
. Значит, T
стирается в Comparable
. Нам для обратной совместимости необходимо, чтобы стиралось в Object
. Именно для этого и добавили хитроумный синтаксис T extends Object & Comparable
:
public static <T extends Object & Comparable<? super T>>
T max(Collection <? extends T> coll) {
Это не нужно нигде, кроме как для того, чтобы можно было дженерики добавлять в нашу Java и тем самым поддерживать обратную совместимость.
Как за этим уследить?
Для отслеживания сигнатур:
Kotlin: Binary compatibility validator
Эти инструменты пробегаются по API и генерируют текстовые файлы, которые затем можно либо анализировать вручную, либо сравнивать, либо следить за тем, чтобы они не менялись (и сохранялось public API).
Если посмотреть на байт-код, то он тоже предназначен для того, чтобы поддерживать обратную совместимость. Его хоть и давным-давно изобрели, он со временем сильно изменился. Однако старые jar работают до сих пор. Как это работает?
Есть в начале файла магическое число, которое говорит о номере версии нашего класса. Пример:
CA FE BA BE 00 00 00 34
Так Java и рантайм видят с каким классом работают и понимают как с ним нужно работать. Байт-код инструкции даже пропадали в разных версиях. Те, которые уже удалены в новом class-файле, всё ещё работают, если у него старая версия.
Как можно посмотреть на байт код нашего класса? Например, с помощью инструмента ObjectWeb ASM. Там довольно интересный подход для того, чтобы поддерживать разные версии нашего байт-кода. Когда вы читаете байт-код, анализируете байт-код, важно понимать, с чем вы работаете. Например, вы хотите написать обход класса:
public class ClassPrinter extends ClassVisitor {
}
Когда ASM парсит класс, то вызывает методы в ClassVisitor
:
public class ClassPrinter extends ClassVisitor {
public void visit(int version, int access, String name
String superName, String[] interfaces) {
}
}
Допустим, вы выводите информацию, что пришёл класс, с некоторыми методами и определённым суперклассом:
public class ClassPrinter extends ClassVisitor {
public void visit(int version, int access, String name,
String superName, String[] interfaces) {
System.out.println(name + " extends " + superName + " {");
}
}
Затем пришла новая версия Java, в которой появились дженерики. И в описании класса у нас не просто название суперинтерфейса, а ещё откуда-то возникли сигнатуры.
public class ClassPrinter extends ClassVisitor {
public void visit(int version, int access, String name,
String signature,
String superName, String[] interfaces) {
System.out.println(name + " extends " + superName + " {");
}
}
Получается, что вам нужно догадаться, что вам нужно этот метод обработать. Как это сделали разработчики ObjectWeb ASM?
Они требуют в ваш ClassVisitor
добавить явный вызов, который скажет библиотеке, на какую версию байт-кода вы подписывались.
public class ClassPrinter extends ClassVisitor {
public ClassPrinter(){
super(ASM6);
}
public void visit(int version, int access, String name,
String signature,
String superName, String[] interfaces) {
System.out.println(name + " extends " + superName + " {");
}
}
Это значит, что, когда мы подписались на то, что мы поддерживаем все методы из шестой версии ASM, то мы будем с этими сигнатурами работать. Когда возникнет class-файл с какими-то новыми сущностями, то библиотека предупредит, что вы не подписывались и не сможете это вызвать, упадете с NPE. А если придет класс, у которого сигнатуры не было, т.е. новых фич не используется, то библиотека прозрачно вызовет старый API, который пользователь реализовал.
Разработчики написали научную статья, где опубликовали все эти принципы и то, как они их спроектировали. Но в Java появился Class File API. Наверное, как-то это будет существовать вместе. Довольно интересный подход: в ваших библиотеках и фреймворках от пользователя просить, на какой API он сам подписывался, т.е. какие методы он готов переопределить.
Когда мы дизайним такие API, хорошо бы понимать, как для пользователей будет выглядеть конечный продукт.
СломВер для потребителей
Пример №1
Допустим, была у нас версия продукта 2.13.0, потом 2.14.0. Прошло какое-то время, и была обнаружена уязвимость CVE, причём 10 уровня. Её легко эксплуатировать, полный контроль. Мы её починили в версии 2.17.0. Но законно ли это?
На самом деле, это пример реального проекта — log4j2. Отсюда также вопрос: а что должны делать пользователи, у которых версия 2.13.0? Им предлагается обновиться. А что, если там нужно обновить какое-то конфиги, что-то перекомпилировать и т.п.?
Вообще говоря, так нельзя. По-хорошему, должна быть возможность мигрировать без замены чего бы то ни было. Когда вы выпускаете что-то с изменениями безопасности, нужно выпускать патч-версии (log4j2 2.13.0 → log4j2 2.13.1). Я — сторонник того, чтобы выпускать версии для всех старых версий, которые вы можете встретить в эксплуатации.
Конечно, иногда кто-то может обновиться на последнюю версию. Однако очень часто ситуация такая, что у вас есть пять минут, чтобы обновиться и починить CVE, и потом ещё, возможно, какое-то время, чтобы оценить миграцию на более новую версию. По этой причине я предлагаю вам требовать изменения в патч-версиях.
Пример №2
Рассмотрим ещё один пример, когда невозможно обновить мажорную версию.
try (var ps = con.prepareStatement("select...");
var rs = ps.executeQuery();
) {
while (
rs.next
()) {
rs.refreshRow();
}
}
Выполняется SQL-запрос, затем он обрабатывается и потом вызывается метод refreshRow()
, который позволяет в базе данных обновить строку. Этот метод реализовали 20 лет назад и написали его на сложении строк.
Возможно вы уже догадались, что, если в таблице назвать колонку с точками c запятыми, с пробелами и прочим, то можно немного пошалить в базе.
create table users(
id bigint primary key,
"l from users;...; select *" bigint
);
С одной стороны, нас спасает, что у Postgres есть ограничение на длину колонки (64 символа). Но для случая с CVE это ни на что не влияет, потому что можно выгрузить любые данные.
И, казалось бы, а зачем защищаться от такого? Но представьте себя на месте Амазон, Яндекс.Облака или еще кого-нибудь, кто предлагает пользователям в интерфейсе создавать базы данных. У пользователя нет прямого доступа, но есть конфиг. Вот и назовёт он таблицу, колонку. А что, безобидная колонка же.
Оказалось, что уровень опасности выявленной CVE-2022-31197 довольно высокий — 7.1. Была версия PgJDBC 42.3.6, а стала — 42.4.1. Однако изменений там ноль, т.е. вероятность того, что из 42.3.6 в 42.4.1 придёт какая-то проблема с совместимостью, равна нулю. В этот момент пришли уважаемые люди из группы Spring Boot и сказали, что согласно политике Spring Boot обновлять минорные версии запрещено. По этой причине PgJDBC не может обновить версию с 42.3.6 на 42.4.1.
Тогда для устранения уязвимости выпустили пачку патч-версий PgJDBC:
PgJDBC 42.2.26 → 42.2.27
PgJDBC 42.3.6 → 42.3.7
PgJDBC 42.4.0 → 42.4.1
Пример №3
Для Log4j 1.2 есть куча известных проблем уязвимости, которые, к сожалению, никто не устраняет:
Ответ от команды разработки Log4j — версии 1.x больше не поддерживается. Что мы имеем:
Миграция на Log4j 2.x — непростая задача.
CVE в 1.х исправить легко: https://github.com/qos-ch/reload4j
Команда Log4j не хочет устранять CVE.
Что нам остаётся? Страдать)
Пример №4
А если выкатить версии 0.х, обкатать их и потом уже выпустить 1.х?
Многие проекты начинаются с версии 0.1.0. Затем будет 0.2.0, далее 0.3.0 и т.д. Проблема в том, что никогда оно не дойдёт до 1.х.
Через какое-то время, разработчики узнают, что есть такой концепт, как ZeroVer: 0.что.угодно. Этот концепт заключается в том, что нужно всегда версию начинать с нуля, никогда ничего не ломать. Он официально задокументирован. Примеры проектов, в которых версии начинались с 0: Apache Kafka, Terraform и др.
Проблема в том, что люди воспринимают версии 0.х как что-то недоделанное. Если у них нет альтернатив, то они будут пользоваться. Если вы пользуетесь JaCoCo для code coverage, то у вас нет альтернатив. Текущая версия — 0.8.13. А если бы это был Spring Boot 0.13?
Вообще стоит прочитать не только главную страницу. Если вы прочитаете главную страницу и перейдёте в раздел About, то увидите, что это была первоапрельская шутка 2018 года. Создатели пишут: Пожалуйста, никогда не используйте это. Если ваш проект дорос, то это использовать не нужно. Что я вам тоже советую.
Команда Terraform когда-то использовала версии с 0:
2014 г.: v0.1,
2017 г.: v0.10,
2020 г.: v0.13,
2020 г.: v0.14,
2021 г.: v0.15.
В 2020 году это уже была хорошая версия, всё работало прекрасно. Я видел беседу maintainerов Terraform и одного из maintainerов облака Azure, где последний прямо говорил, что в энтерпрайзе номер версии 0.х не смотрится. Кажется, будто это подделка какая-то. В 2021 году Terraform всё-таки выпустили версию v1.0.
Обратная совместимость должна быть. Важно, чтобы вы думали об обратной совместимости, когда вы пишете код, меняете его. Есть примеры, когда вроде думали, но получилось как всегда.
Примеры обидных несовместимостей
Рассмотрим пример с java.util.regex
. У нас есть задача — найти подстроку Java в строке с набором языков.
java.util.regex.Pattern.compile("Java")
.matcher("Java, Python, JavaScript")
.results()
.map(MatchResult::group)
.toList()
Вывод: [Java, Java]
. А что, нашли Java, JavaScript не нашли)
А теперь попробуем починить:
java.util.regex.Pattern.compile("\\bJava\\b")
.matcher("Java, Python, JavaScript")
.results()
.map(MatchResult::group)
.toList()
\\b
— это границы слова. Это решение почти работало. Выводилось действительно [Java]
.
Есть один нюанс. Изменим строку, в которой ищем:
java.util.regex.Pattern.compile("\\bJava\\b")
.matcher("Java, JavaСкрипт")
.results()
.map(MatchResult::group)
.toList()
У нас получится, что решение иногда работает, а иногда — нет. Например, в Java 17 вывод будет таким: [Java]
, а в Java 21 вывод будет уже другим: [Java, Java]
.
Эту багу нашли JDK-8282129. \b
было неконсистентно с \w
, т.е. модификатор для слова не учитывал символы Unicode, а модификатор границ для слова их учитывал. Багу починили, но у моего знакомого потом отвалились тесты. Подобные ситуации наблюдались и в проекте JUnit.
Как не стоит нарушать спецификацию: JUnit5
В JUnit5 была такая штука: тест и у него есть метод с @BeforeEach
на уровне класса.
class ConnectionTest {
private Connection con;
@BeforeEach
private
void setUp() throws Exception {
con = TestUtil.openDB();
В документации JUnit к аннотации @BeforeEach
говорится, что, если у вас метод private, то это работать не должно. Это должно работать только для public-методов. В javadoc использование private @BeforeEach
всегда было запрещено.
Потом кто-то пришёл и говорит: Давайте запретим private с @BeforeEach
, чтобы при использовании оно падало с ошибкой, чтобы было согласно документации. Сделали. Затем приходит товарищ из Амазон и говорит, что у них в коде нашлось 5 787 использований private с @BeforeEach
. Код JUnit всё равно исправили согласно спецификации. У пользователей сломались тесты. Пользователи не захотели это всё чинить и потребовали вернуть всё назад. В итоге, на повторном обсуждении private с @BeforeEach
разрешили.
Несмотря на то, что в спеке всегда было написано, что нельзя использовать private с @BeforeEach
, пользователям всё равно удалось убедить команду JUnit, что просто так ломать не нужно.
Вывод: лучше не запрещать то, что ранее работало, даже если это не соответствовало документации. В Java regexp сломали ради какой-то целостности между собой. А в этом случае ради чего?
Автоматизация миграций
Можно задаться таким вопросом: А законно ли 5 787 использований менять? Как с этим работать? Не вручную же это делать.
Есть проект, который позволяет менять наши сигнатуры и методы — OpenRewrite. Он позволяет автоматически мигрировать много что.
С какой-то Java на Java 17.
Изменять тестовые фреймворки с одного на другой, например, с JUnit 5 на JUnit 4.
Написать кастомную миграцию, например с AsserJ на Hamcrest.
Это инструмент, который позволяет вам писать миграции для вашего кода. OpenRewrite пользуются в Spring для миграций старого кода на новые версии.
В документации OpenRewrite можно найти много примеров прикольных миграций, которые сделают ваш ход чище, лучше и красивее. Например, если вы пользуетесь AssertJ, то есть набор миграций, который позволяет сделать сами assert`ы лучше: AssertJ best practices, Simplify AssertJ chained assertions и др.
Инструменты вроде OpenRewrite или Error Prone позволяют проводить миграции автоматически. Это особенно полезно для крупных проектов и библиотек, например Spring или JUnit. Миграции можно писать под конкретные API и обновления, что снижает риск ошибок.
Заключение
Важно не документированное поведение, а фактическое. Не то, что вы обещали в доке, а как оно по факту работало.
Само изменение мажорной версии — это не повод удалять метод или что-то сломать. Если мы хотим что-то изменить или удалить, то мы планируем это через 5 лет. Получается, мы сначала предупреждаем, что мы что-то удалим.
Подход с тем, что мы делаем доработки только в последней версии нормальный. Однако хорошо бы уязвимости чинить во всех версиях. Доработки делаем только в последней версии, а security-патчи во всех.
Планируйте и просите совместимость API от тех, кто вам этот API предоставляет.
Используйте инструменты автоматического контроля за вашими API.
Используйте инструменты автоматической миграции ваших API на новые.