Як ми мігрували проєкт на Spring Boot 3
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як міграція застосунків.
Ця тема рідко зустрічається в статтях, оскільки у більшості випадків міграція є тривіальним процесом. Але Spring Boot 3 стоїть окремо в цьому списку, позаяк тут була порушена зворотна сумісність з попередніми версіями, причому порушена неодноразово.
Ситуацію ускладнює те, що мало хто використовує чистий Spring Boot, а скоріше у зв’язці зі Spring MVC, Spring Data, Spring Cloud, які також потребують міграції. Тому в цій статті я хотів би поділитися досвідом у переході кількох своїх проєктів на нову версію Spring Boot, тим більше, що ми торкаємося цієї теми на деяких наших тренінгах.
Зміни у Spring Boot 3
Є кілька Java-проєктів, де головні (major) версії випускають дуже рідко, ретельно тестуються і вітається фідбек розробників про milestone (beta) версії. Цим може похвалитися і Hibernate, і лінійка Spring/Spring Boot проєктів.
Попередня версія Spring Boot 2.0 була випущена в березні 2018 року, а поточна 3.0 — через 4 роки, у листопаді 2022 року. Змін, як завжди, було дуже багато, деякі з них були несумісні з попередньою версією, тому довелося випустити спеціальний посібник з міграції. Які ж зміни були найістотнішими?
- Перехід на Spring Framework 6. Тут відбулася істотна внутрішня зміна конфігурації — відмова від конфігураційного файлу spring.factories на користь нового файлу (і формату) для автоконфігурації — META-INF/spring/org.springframework.boot.autoconfigure. AutoConfiguration.imports. Крім того, деякі налаштування було перейменовано.
- Використання JDK 17 як мінімально припустимої версії. Як ми пам’ятаємо, Spring 5 став однією з перших Java-технологій, яка перейшла на JDK 8. І ось тепер Spring 6/Spring Boot 3 стали першими великими Java-проєктами, які підняли планку до JDK 17. Чому не до JDK 11? Насамперед, LTS-версії стали випускатися набагато частіше. Наприклад, JDK 21 вийде вже цієї осені. По-друге, в JDK
12-17 було багато цікавих фітч (Java Records, pattern matching, текстові блоки, switch expressions), які спрощували розробку самого Spring. Зрозуміло, вам теж доведеться перейти на JDK 17, якщо ви хочете використати Spring 6/Spring Boot 3. - Перехід на Jakarta EE 9. Цей перехід був найболючішим, оскільки Jakarta EE 9 несумісний з попередньою версією (ребрендинг javax.*->Jakarta.*). Так що доведеться оновити всі імпорти та багато налаштувань, пов’язаних з Enterprise Java. Крім того, доведеться перейти на ті enterprise технології, які сумісні з Jakarta EE 9/10, оскільки Spring 6 перестав підтримувати Hibernate 5, а підтримує тільки Hibernate 6 (або ті версії Hibernate 5.x, які сумісні з Jakarta EE 9).
- Стабільна підтримка GraalVM Native images. Якщо раніше доводилося використовувати окрему залежність Spring Native, то тепер цей проєкт оголошено deprecated, а підтримка GraalVM реалізована в Spring і Spring Boot плагінах.
- Підтримка нового Observation API від Micrometer.
З цього списку, якщо не брати до уваги інфраструктурні зміни (JDK 17, Jakarta EE 9), найцікавішою фічею є стабільна підтримка GraalVM (версії 22.x), яку робили майже 3 роки.
Способи міграції
Робити міграцію на нову версію, коли йдеться про порушення зворотної сумісності, простою зміною версії в скрипті збірки небезпечно. Хоча в нас і є посібник з міграції, доведеться скрупульозно по ньому йти, перейменовуючи імпорти та налаштування. А раптом забудемо перейменувати якусь властивість? Адже жодних помилок (компіляції чи run-time) не буде.
Чи є автоматизований спосіб, наприклад Angular CLI з його schematics? Є, більш того, є навіть способи:
- Бібліотека OpenRewrite.
- Проєкт Spring Boot Migrator.
OpenRewrite — це порівняно новий проєкт, який стартував у 2020 році і позиціонує себе як бібліотека для повномасштабного рефакторингу коду та конфігурації. Вона написана на Java, але з коробки підтримує Python, Kotlin, SQL і навіть Terraform.
При цьому не потрібно помилятися щодо рефакторингу. OpenRewrite зовсім не призначений для перейменування класів і методів, він дозволяє здійснювати безпечну міграцію на нові версії ПЗ, проводити статичний аналіз коду, оновлювати ваш API та багато іншого. Сама бібліотека надає базові можливості, а різні готові розширення вже адаптовані для прикладних «рецептів»:
- міграція на JDK 17;
- перехід із Junit 4 на Junit 5;
- міграція з Log4j на Slf4j;
- перехід із Micronaut 2 на Micronaut 3.
Кожен «рецепт» може включати інші рецепти і складається з двох операцій: пошук і трансформацію коду (конфігурації). При цьому реалізовано це досить гнучко. Сам рецепт — це Java-клас, але який можна додатково налаштувати у YAML-файлі. Ось як виглядає конфігурація рецепту, який видаляє deprecated властивості:
type: specs.openrewrite.org/v1beta/recipe
name: org.openrewrite.java.spring.boot3.ActuatorEndpointSanitization
displayName: Remove the deprecated properties `additional-keys-to-sanitize` from the `config-props` and `env` end points
description: Spring Boot 3.0 removed the key-based sanitization mechanism used in Spring Boot 2.x in favor of a unified approach. See
recipeList:
- org.openrewrite.java.spring.DeleteSpringProperty:
propertyKey: management.endpoint.configprops.additional-keys-to-sanitize
- org.openrewrite.java.spring.DeleteSpringProperty:
propertyKey: management.endpoint.env.additional-keys-to-sanitize
Як бачите, всі назви властивостей не прописуються жорстко в коді, а виносяться в YAML і це дозволяє, по-перше, перевикористовувати базові рецепти, по-друге, швидше розробляти нові рецепти.
Для Java-проєктів є дві головні бібліотеки — rewrite-migrate-java та rewrite-spring, остання нам і потрібна.
Може здатися надмірним використовувати окрему бібліотеку, якщо потрібно просто поміняти версію JDK на 17 для проєкту, але список рецептів тут досить великий:
- org.openrewrite.java.migrate.Java8toJava11
- org.openrewrite.java.migrate.JavaVersion17
- org.openrewrite.java.migrate.lang.StringFormatted
- org.openrewrite.java.migrate.lombok.UpdateLombokToJava17
- org.openrewrite.github.SetupJavaUpgradeJavaVersion
- org.openrewrite.java.cleanup.InstanceOfPatternMatch
- org.openrewrite.java.migrate.lang.UseTextBlocks
І він включає, наприклад, явне застосування тих фітч, які з’явилися з JDK 9 JDK 17 — текстові блоки, pattern matching, заміна Collections.singletonList() на List.of() і т.д. Є й складніші рецепти — наприклад, заміна типів з Google Guava на стандартні колекції/Optional JDK.
По суті, такий рецепт заощаджує ваш час і позбавляє рутинних помилок. Якщо повернутись до міграції на Spring Boot 3, то тут список рецептів ще більше:
- org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_7
- org.openrewrite.java.spring.boot3.RemoveEnableBatchProcessing
- org.openrewrite.java.spring.boot3.MavenPomUpgrade
- org.openrewrite.java.migrate.UpgradeToJava17
- org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta
- org.openrewrite.java.spring.boot3.RemoveConstructorBindingAnnotation
- org.openrewrite.java.spring.boot2.MoveAutoConfigurationToImportsFile
- org.openrewrite.java.spring.boot3.ActuatorEndpointSanitization
- org.openrewrite.java.spring.boot3.MigrateMaxHttpHeaderSize
- org.openrewrite.java.spring.boot3.DowngradeServletApiWhenUsingJetty
- org.openrewrite.java.spring.boot3.ConfigurationOverEnableSecurity
- org.openrewrite.java.spring.boot3.SpringBootProperties_3_0_0
- org.openrewrite.java.spring.boot3.MigrateThymeleafDependencies
- org.openrewrite.java.spring.security6.UpgradeSpringSecurity_6_0
14 рецептів — це багато чи мало? Дивлячись, з чим порівнювати. Наприклад, рецепт міграції на Jakar-ta EE 9 (JavaxMigrationToJakarta) містить 33(!) рецепти.
Використовувати OpenRewrite дуже просто. Достатньо лише додати OpenRewrite плагін і вказати потрібні назви рецептів. Вся трансформація буде проведена під час мануального запуску (активації) відповідного плагіна, причому OpenRewrite гарантує збереження оригінального форматування коду.
Spring Boot Migrator (SBM) — це експериментальний проєкт, створений 2022 року, швидше за все якраз під вихід Spring Boot 3. Він використовує OpenRewrite під капотом, але якщо OpenRewrite робить всі зміни відразу і автоматично, то SBM може працювати в двох режимах:
- Інтерактивний CLI.
- Зручний Web UI.
На відміну від SBM тут ви можете подивитися список запропонованих рецептів і застосувати тільки один з них. Крім того, SBM вміє і деякі цікавіші речі, наприклад, міграцію з Java EE застосунків на Spring Boot.
Міграція Spring Boot 3
Спочатку ми спробували використати OpenRewrite:
<build>
<plugins>
<plugin>
<groupId>org.openrewrite.maven</groupId>
<artifactId>rewrite-maven-plugin</artifactId>
<version>4.42.0</version>
<configuration>
<activeRecipes>
<recipe>org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0</recipe>
</activeRecipes>
</configuration>
<dependencies>
<dependency>
<groupId>org.openrewrite.recipe</groupId>
<artifactId>rewrite-spring</artifactId>
<version>4.34.0</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
Тут ми вказали ідентифікатор плагіна, назву головного рецепту та потрібну залежність, де зберігаються рецепти (rewrite-spring). Для старту міграції достатньо цієї команди:
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run
Можна зробити ще простіше, якщо не хочеться змінювати скрипти збірки. Просто запустити цей плагін, явно вказавши активний рецепт:
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:LATEST -Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0
Сканування та заміна йдуть не так швидко, крім того, як виявилося OpenRewrite, не тільки додає нові залежності, а намагається і завантажити їх.
Проаналізувавши зміни, можна побачити багато цікавого. Наприклад, OpenRewrite застосував деякі рецепти так званих «best practices», які ніяк не пов’язані з Spring Boot. Для Junit тестів він, по-перше, видалив модифікатор public із тестових класів. По-друге, застосував стандарт RSPEC-3415 із Sonar, в якому в assertion спочатку має йти очікуване значення, а потім актуальне:
assertEquals(OrderState.COMPLETED,order.getState());
а не:
assertEquals(order.getState(), OrderState.COMPLETED);
Хоча це не впливає на роботу тесту, але робить його зрозумілішим. Як OpenRewrite відрізняє ці два значення? Якщо він бачить поле static/final/enumeration, то вважає, що це очікуване значення. Ще одна корисна зміна:
return String.format(format, getClass().getSimpleName(), id);
на
return format.formatted(getClass().getSimpleName(), id);
Метод formatted класу String з’явився у JDK 15.
Є й складніші зміни. Наприклад, у ті Spring біни, які оголошені через @Bean і залежать від біна типу DataSource, додається анотація @DependsOnDatabaseInitialization. Вона каже Spring ініціалізувати такі біни тільки після ініціалізації бази даних.
Також OpenRewrite поміняв імпорти javax.* -> Jakarta.*, але зробив це не механічною заміною, тому що, наприклад, у JDK є теж пакети javax.*. Тому тут потрібно брати кореневий пакет для кожної специфікації з Jakarta EE: javax.validation, javax.persistence і т.д.
Тут же випливли й деякі неточності. Наприклад, якщо є окремі властивості для версій Spring проєктів, то вони не змінилися:
<spring.boot.version>2.7.1</spring.boot.version>
<spring.cloud.version>3.1.1</spring.cloud.version>
<spring.retry.version>1.3.3</spring.retry.version>
<spring.kafka.version>2.8.7</spring.kafka.version>
Більш того, мені здалося, що OpenRewrite не вміє працювати із залежностями Spring Cloud та деяких інших Spring-проєктів.
У деяких випадках він не зміг завантажити залежність і вставляв коментар у pom.xml (що добре), але таких випадків було занадто багато:
<!--~~(Unable to download POM. Tried repositories:
https://repo.maven.apache.org/maven2: HTTP 404)~~>--><!--~~(Unable to download POM. Tried repositories:
https://repo.maven.apache.org/maven2: Did not attempt to download because of a previous failure to retrieve from this repository.)~~>--><dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>5.0.3</version>
</dependency>
Іноді він вставляв абсолютно непотрібні залежності:
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>4.0.2</version>
<scope>provided</scope>
</dependency>
Невідомо чому, можливо, через наявність Junit 5 у проєкті, OpenRewrite вставив exclusion, який видаляє Junit 4 з транзитивних залежностей:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
Це досить мило, але така турбота за чистоту конфігурації призвела до падіння тестів, оскільки саме в контейнері Kafka Junit 4 все ще використовувався, так що довелося це видалити.
OpenRewrite може не лише додавати, а й змінювати залежності. Наприклад, нещодавно змінився MySQL connector для Java з:
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
на:
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
OpenRewrite змінив і це залежність. Але він не може робити ті заміни, де потрібне схвалення людини. Наприклад, у Spring 6 видалили клас CommonsMultipartResolver, оскільки mul-tipart тепер підтримується на рівні контейнерів сервлетів. Автор Spring рекомендує тепер використовувати клас StandardServletMultipartResolver, але такий вибір має зробити розробник, а не плагін.
І в кінці роботи OpenRewrite плагін видаляється з pom.xml. Для іншого проєкту ми протестували Gradle плагін:
plugins {
id("org.openrewrite.rewrite") version("5.38.0")
}
rewrite {
activeRecipe("org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_0")
}
dependencies {
rewrite("org.openrewrite.recipe:rewrite-spring:4.34.0")
}
Одна з переваг OpenRewrite — це можливість запуску в так званому «dry» режимі, коли вихідні файли не змінюються, але створюється patch-файл з усіма змінами плюс вказуються ті рецепти, які були застосовані для кожного випадку.
Запускаємо «dry» режим: gradle rewriteDryRun і виявляється, що дефолтних 512 Мб для роботи йому не вистачає, довелося вказати 2 Гб. Через три хвилини роботи створився patch-файл розміром 255 кб.
На жаль, зміни були тільки для Java-файлів, скрипти збірки на Groovy виявилися незайманими. Це зрозуміло, оскільки парсинг таких скриптів по суті потребує їх компіляції, і все це набагато складніше зробити, ніж для pom.xml в Maven.
У чомусь OpenRewrite мені здався подібним до Liquibase, але всі результати його роботи вимагають ретельної перевірки. Тим не менш, його колекція «best practices» захоплює, і він справді може внести чимало корисних змін до вашого проєкту.
Тепер спробуємо Spring Boot Migrator (SBM). Так як це утиліта, то її потрібно завантажити і почнемо з Web UI, який називається Spring Boot Upgrade.
Розмір вселяє повагу — 153 Мб. Включає базу даних RockDB, JRuby та ICU4j (робота з UTF). Запускаємо його:
java -jar --add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens ja-va.base/java.io=ALL-UNNAMED spring-boot-upgrade.jar .
Відразу помилка, тому що OpenRewrite знайшов порожній beans.xml і не зміг його розпарcити:
org.openrewrite.xml.XmlParsingException: Syntax error in \src\main\webapp\WEB-INF\beans.xml at line 1:0 mismatched input '<EOF>' expecting {COMMENT, UTF_ENCODING_BOM, '<?xml', '<', SPECIAL_OPEN, DTD_OPEN}.
at org.openrewrite.xml.XmlParser$ForwardingErrorListener.syntaxError(XmlParser.java:118)
at org.antlr.v4.runtime.ProxyErrorListener.syntaxError(ProxyErrorListener.java:41)
Але це не викликало завершення роботи, потім вискочила ще одна помилка, на жаль, у виключенні нічого не сказано про його причину:
java.lang.StringIndexOutOfBoundsException: Range [1023, 1023) out of bounds for length 1020
at java.base/jdk.internal.util.Preconditions$1.apply(Preconditions.java:55)
at java.base/jdk.internal.util.Preconditions$1.apply(Preconditions.java:52)
at java.base/jdk.internal.util.Preconditions$4.apply(Preconditions.java:213)
at java.base/jdk.internal.util.Preconditions$4.apply(Preconditions.java:210)
at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:98)
at ja-va.base/jdk.internal.util.Preconditions.outOfBoundsCheckFromToIndex(Preconditions.java:112)
at ja-va.base/jdk.internal.util.Preconditions.checkFromToIndex(Preconditions.java:349)
at java.base/java.lang.String.checkBoundsBeginEnd(String.java:4608)
at java.base/java.lang.String.substring(String.java:2720)
at org.openrewrite.java.isolated.ReloadableJava17ParserVisitor.convert(ReloadableJava17ParserVisitor.java:1480)
Щобільше, при детальному аналізі виявилося, що SBM використовує досить стару версію rewrite-spring — 4.26, хоча вже є версія 4.34. Причина банальна — SBM виходить раз на
localhost:8080/spring-boot-upgrade
Відкриваємо у браузері посилання і перед нами як опис змін у Spring Boot 3 зі зручною навігацією по секціях:
Так і можливість запустити вручну окремі рецепти (кнопка «Run Recipe)»::
Натискаємо цю чарівну кнопку, яка призводить до виключення в консолі. Спроба розібратися в тому, що відбувається, призвела до цікавих спостережень. Якщо запустити ще раз SBM, то він виведе попередження:
It seems that the project was changed while running the recipe. The project was scanned again but you'll need to run the recipe again.] with root cause
org.springframework.sbm.engine.git.ProjectOutOfSyncException: It seems that the project was changed while running the recipe. The project was scanned again but you'll need to run the recipe again.
at org.springframework.sbm.engine.git.ProjectSyncVerifier.verifyProjectIsInSyncWhenGitAvailable(ProjectSyncVerifier.java:57)
Звідки SBM знає про рецепти, які вже були запущені? Після роботи SBM з’являється папка .rewrte-cache, де є база RocksDB і складаються логи роботи. Крім того, запуск кожного рецепта автоматично створює Git коміт без змінених файлів, але з текстом, наприклад: SBM: applied recipe ’sbu30-upgrade-dependencies’
Це можна вимкнути, змінивши спеціальну властивість sbm.gitSupportedEnabled (за замовчуванням увімкнено).
Спробуємо запустити Spring Boot Migrator CLI::
java -jar spring-boot-migrator.jar
Тут виявилася ще одна цікава особливість. Якщо скопіювати jar-файл у папку з проєктом, то все працює успішно, якщо ж до будь-якої іншої, то потрібно вказати папку з проєктом командою scan, і чомусь видається дивна помилка, яку ми так і не змогли усунути:
[X] SBM requires a Maven build file. Please provide a minimal pom.xml
Можливо, це було викликано особливостями операційної системи, правами чи ще чимось, але нам не вдалося це виправити.
Висновки
Перехід на Spring Boot 3 — серйозний відповідальний процес, які вимагає автоматичних інструментів, таких, наприклад, як OpenRewrite. Він дозволяє автоматично виконати рутинну роботу, а також покращити якість коду за рахунок застосування «best practices» і тих JDK фітч, які з’явилися після
При цьому потрібно розуміти, що OpenRewrite надає базові можливості міграції, працює тільки з файлами вашого проєкту та очікує на виконання певних конвенцій. Так, наприклад, він може перейменовувати та видаляти властивості з .properties та YAML файлів. Але що, якщо ви використовуєте розподілену конфігурацію і налаштування зберігаються віддалено в Consul/Zookeeper/Github? У такому разі вам доведеться або виконати перейменування вручну або написати свій клас-рецепт.
Spring Boot Migrator розширює можливості OpenRewrite і дозволяє застосувати окремі рецепти, а не всі скопом, як OpenReerite. Крім того, він має два режими роботи — CLI і Web UI.
20 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів