Міграція застосунків на JDK 22. Частина третя
Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер та технічний письменник, хочу поділитися з вами своїм досвідом міграції проєктів з JDK 21 на JDK 22. У першій частині цієї статті я розповів про найцікавіші фічі, які увійшли до JDK 22 та навів приклади їх використання. У другій частині я написав про результати тестування продуктивності нових фіч і складнощі, з якими ми зіткнулися в міру того, як переводили наші сервіси на нову версію Java. У третій, останній частині, я поділюся фінальними проблемами міграції та використання нових фіч у нашому проєкті.
Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про нові фічі в Java 22, розібратися в тому, як провести таку міграцію і яка від цього практична користь.
Запуск сервісів
Після того, як ми виправили всі помилки компіляції та неробочі тести, залишився фінальний етап — перевірка в умовах, наближених до бойових, на запущених сервісах. Спочатку замінимо Docker image для складання сервісів з:
FROM gradle:8-jdk21-alpine as gradle
на:
FROM gradle:8-jdk-21-and-22-alpine as gradle
Відразу впадає у вічі наявність двох версій JDK 21/22 у назві Docker image, чого раніше я ніколи не спостерігав. Річ у тому, що люди, відповідальні за офіційні Docker images для Gradle, вирішили тепер включати і останню версію LTS JDK (21) і просто останню версію (22). Яку версію буде вибрано за замовчуванням? На жаль, збирання Docker images свідчить, що обирається версія 21. Як її замінити на 22? В офіційному README є згадка про те, що в цьому image є спеціальний файл gradle.properties:
org.gradle.java.installations.auto-detect=false
org.gradle.java.installations.auto-download=false
org.gradle.java.installations.fromEnv=JAVA_LTS_HOME,JAVA_CURRENT_HOME
Здається логічним, що потрібно просто в останньому рядку прибрати JAVA_LTS_HOME. На жаль, це не вирішує нашої проблеми. Єдиний можливий варіант — для всіх проєктів вказати JDK 22 у так званому блоці toolchain:
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(22))
}
}
Що таке toolchain у Gradle? Річ у тому, що спочатку Gradle використовував ту саму JVM і для свого запуску, і для збирання Java-проєктів. Але згодом виникли деякі проблеми сумісності, коли поточна версія Kotlin або Groovy не підтримувала поточну JDK. І ви не могли використовувати її для запуску Kotlin або Groovy скриптів збірки. Оскільки така проблема виникала регулярно після виходу нових версій JDK, розробники Gradle вирішили додати підтримку JVM toolchains.
І тепер ви можете запускати Gradle на тій версії JDK, яка ним підтримується, але в скриптах збирання вказати, що для компіляції проєкту потрібна інша JDK. Більше того, можна вказати іншого вендора (наприклад, IBM або Adoptium). Якщо ж такої JDK на поточному сервері немає, вона буде автоматично завантажена. Тепер Gradle складання проходить успішно. Потім потрібно замінити версію JRE для запуску з:
FROM eclipse-temurin:21-jre-alpine
на:
FROM eclipse-temurin:22-jre-alpine
І фінальний крок — замінити версію JVM у тих сервісах, що використовують зовнішній Tomcat. І тут виникає проблема, тому що виявляється, що для Tomcat немає офіційного Docker image на базі JDK 22 і підтримуються тільки LTS версії. Дивно, але такої підтримки немає й у альтернативному Bitnami image для Tomcat. Цікаво, що підтримки JDK 22 немає і для альтернативного вебсервера Jetty, тож тут можна говорити про справжнісінький бойкот нової версії Java. Єдина втіха — це те, що нарешті з’явився Docker image на базі JRE, а не JDK, тож хоча б його ми можемо використовувати і натомість:
FROM tomcat:jdk21-openjdk-slim
вказати:
FROM tomcat:10-jre21
Але доведеться для тих сервісів, які базуються на Enterprise Java, залишити сумісність із JDK 21:
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_21
targetCompatibility = org.gradle.api.JavaVersion.VERSION_21
Продовжуємо збирання Gradle images і виникає помилка під час складання сервісу Micronaut:
/opt/layers/application.jar: not found
Ось як зараз виглядає частина Dockerfile для генерації Docker image:
FROM eclipse-temurin:22-jre-alpine
RUN apk update && apk add curl
WORKDIR /home/app
COPY --from=gradle /opt/layers/libs libs
COPY --from=gradle /opt/layers/resources resources
COPY --from=gradle /opt/layers/application.jar application.jar
ENTRYPOINT ["java", "-jar", "/home/app/application.jar"]
Тобто ми копіюємо ті папки, які мали б бути згенеровані на попередній стадії під час складання проєкту. Однак файлу application.jar немає. Детальне дослідження показує, що в новій версії плагіна Micronaut файл application.jar тепер розташовується в папці app/application.jar, тому потрібно поміняти один рядок на Dockerfile на:
COPY --from=gradle /opt/layers/app/application.jar application.jar
Але це ще не всі зміни у цьому блоці. Вивчення роботи плагіна Micronaut показало, що тепер він копіює jar-файли залежних проєктів в папку project_libs, а не libs, як раніше. Тому і ці файли потрібно окремою інструкцією копіювати в libs, як і всі інші jars:
COPY --from=gradle /opt/layers/libs libs
COPY --from=gradle /opt/layers/project_libs libs
COPY --from=gradle /opt/layers/resources resources
COPY --from=gradle /opt/layers/app/application.jar application.jar
ENTRYPOINT ["java", "-jar", "/home/app/application.jar"]
Тепер збирання відбувається успішно, контейнери запускаються, і система загалом працює без проблем. Але поки що тільки перевели частину наших сервісів (Spring Boot, Micronaut) на запуск за допомогою JDK 22. Самі фічі з JDK 22 наразі використовувати не можемо, тому що у нас глобально стоїть сумісність на рівні JDK 21. Це не зовсім правильно , оскільки нівелює цілі міграції, тому для проєктів на базі Spring Boot та Micronaut піднімемо рівень сумісності до JDK 22:
java {
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_22
targetCompatibility = org.gradle.api.JavaVersion.VERSION_22
toolchain {
languageVersion.set(JavaLanguageVersion.of(22))
}
}
Які з JDK 22 фіч ми можемо використати? Поки що фіча Implicitly Declared Classes and Instance Main Methods перебуває на стадії прев’ю, але цікаво перевірити, чи вона підтримується сучасними Java технологіями. Замінимо у Spring Boot сервісі головний клас з:
@SpringBootApplication
public class MainApplication {
public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}
}
на:
@SpringBootApplication
public class MainApplication {
void main() {
SpringApplication.run(MainApplication.class);
}
}
На жаль, під час спроби зібрати цей сервіс отримуємо помилку:
Error while evaluating property 'mainClass' of task ':main-service:bootJar'.
> Failed to calculate the value of task ':main-service:bootJar' property 'mainClass'.
> Main class name has not been configured and it could not be resolved from classpath
Ця помилка на рівні Spring Boot плагіна, але в будь-якому випадку Spring Boot поки що не готовий до нових фіч (навіть якщо ми будемо використовувати останню версію 3.3.1). А що Micronaut? Тут для головного bootstrap класу немає жодних анотацій:
public class PaymentApplication {
public static void main(String[] args) {
Micronaut.run(PaymentApplication.class, args);
}
}
Тому ми можемо спробувати прибрати те оголошення класу зовсім. Щоправда, тоді нам доведеться перемістити клас у top-level пакет (це одна з вимог до Implicitly Declared Classes). Але тут виникне нова складність. Для класів таких пакетів неможливе звернення на ім’я (тобто PaymentApplication.class). Тому доведеться викрутитись і написати так:
void main() {
Micronaut.run(this.getClass());
}
Вмикаємо режим превʼю, запускаємо цей клас і сервіс Micronaut працює без проблем. Таким чином, можна констатувати, що і тут Micronaut показав себе більш технологічно просунутим порівняно з Spring Boot. У будь-якому разі ми цю фічу використати не можемо, а що можемо? Безіменні змінні та патерни.
Насамперед це різні блоки catch, де не використовується змінна-виключення, і це можна замінити з:
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
на:
try {
Thread.sleep(500);
} catch (InterruptedException _) {
}
Головна мета такої заміни — показати, що ми справді не використовуємо виключення, а не забули написати його обробку. Далі є тест, у якому не використовується аргумент лямбди-виразу:
@Test
void generate_limit9_numbersDontExceedTheLimit() {
NumberGenerator numberGenerator = new RandomNumberGenerator(9);
IntStream.range(0, 1000).map(i -> numberGenerator.generate()).filter(i -> i > 9)
.forEach(i -> fail("Generated number " + i + " exceeds limit 9"));
}
І його можна переписати як:
IntStream.range(0, 1000).map(_ -> numberGenerator.generate()).filter(i -> i > 9)
.forEach(i -> fail("Generated number " + i + " exceeds limit 9"));
Pattern matching for switch з’явилися трохи раніше, але тільки зараз там можна використовувати безіменні змінні, і такий блок коду:
@Singleton
@Slf4j
public class ApplicationExceptionHandler implements ExceptionHandler<Exception, HttpResponse<?>> {
@Override
public HttpResponse<?> handle(HttpRequest request, Exception e) {
if (e instanceof InvalidParameterException) {
log.debug(e.getMessage(), e);
return HttpResponse.notFound();
} else if (e instanceof ValidationException) {
log.debug(e.getMessage(), e);
return HttpResponse.badRequest(e.getMessage());
}
log.error(e.getMessage(), e);
return HttpResponse.serverError(e.getMessage());
}
}
перетворюється на:
@Override
public HttpResponse<?> handle(HttpRequest request, Exception e) {
return switch (e) {
case InvalidParameterException _ -> {
log.debug(e.getMessage(), e);
yield HttpResponse.notFound();
}
case ValidationException _ -> {
log.debug(e.getMessage(), e);
yield HttpResponse.badRequest(e.getMessage());
}
default -> {
log.error(e.getMessage(), e);
yield HttpResponse.serverError(e.getMessage());
}
};
}
Взагалі, зміни в switch дозволяють значно спростити код, але не завжди це можливо зробити. Наприклад, тут у нас у коді трапляються численні if-else:
Class<?> clz = invocation.getArgument(1, Class.class);
String value = (String) properties.get(property);
if (clz == Long.class) {
return Optional.ofNullable(Long.parseLong(value));
} else if (clz == Double.class) {
return Optional.ofNullable(Double.parseDouble(value));
} else if (clz == Boolean.class) {
return Optional.ofNullable(Boolean.parseBoolean(value));
} else if (clz == Integer.class) {
return Optional.ofNullable(Integer.parseInt(value));
}
return Optional.ofNullable(value);
};
Які прямо хочеться переписати на:
return switch(clz) {
case Long.class -> Optional.ofNullable(Long.parseLong(value));
case Double.class -> Optional.ofNullable(Double.parseDouble(value));
case Boolean.class -> Optional.ofNullable(Boolean.parseBoolean(value));
case Integer.class -> Optional.ofNullable(Integer.parseInt(value));
default -> Optional.ofNullable(value);
};
Але, на жаль, це не компілюватиметься, тому що в case можна використовувати тільки константи.
Висновки
Якщо підбити підсумки міграції, то вона вийшла вкрай тривалою. Були виявлені як проблеми в сумісності деяких технологій з JDK 22, так і неможливість запуску деяких сервісів на JDK 22 (якщо ви використовуєте Docker-контейнери для запуску). Найбільші проблеми принесла міграція на останню версію Micronaut, яка зламала і нашу збірку, і тести. Найпростіше було перевести на JDK 22 Spring Boot сервіси, причому ми навіть не перевели ці проєкти на останню версію Spring Boot. Це свідчить про те, що розробники Spring заздалегідь готують свою платформу до нових версій JDK, щоб програмісти не мали проблем сумісності при міграції.
На жаль, у самій JDK 22 не так багато стабільних фіч, якими можна скористатися прямо зараз, але дуже багато фіч, які перебувають в режимі ознайомлення, і сподіватимемося, що вони будуть оголошені стабільними вже в наступній версії Java.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів