Spring Boot 4 — нові можливості та нові виклики

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом міграції проєктів на Spring Boot 4, який вийшов у листопаді минулого року. Я розповім про те, що увійшло в новий реліз і як це можна використовувати у ваших проєктах. Сподіваюся, що ця стаття буде корисна для всіх, хто хоче більше дізнатися про нові фічі в Spring Boot, розібратися в тому, як провести міграцію Java-застосунків і які тут можуть бути складності.

Що нового в Spring Framework 7

2025 рік став роком великих релізів для Java-спільноти. Почавши з виходу Jakarta EE 11 і LTS версії Java — JDK 25, рік закінчився на мажорній ноті випуском Spring Framework 7 і Spring Boot 4. Оскільки Spring Boot 4 базується на Spring 7, то переходячи на нього ви фактично отримуєте фічі від обох фреймворків. Давайте розберемо, які саме і почнемо з «фундаменту» — Spring Framework 7. Отже, серед заявленого:

  1. Мінімально підтримувана версія все ще JDK 17 (хоча підтримуються всі фічі Java до JDK 25).
  2. Підтримка Jakarta EE 11.
  3. Використання бібліотеки JSpecify для боротьби з NullPointerException.
  4. Підтримка Jackson 3.
  5. Підтримка Kotlin 2.2.
  6. Перехід на Junit 6.
  7. API Versioning.
  8. Програмна реєстрація бінів.
  9. Злиття spring-core з іншою бібліотекою Spring — spring-retry.

Цікаво, що на момент релізу Spring 7, LTS версією Java була JDK 25, але все одно мінімально підтримуваної версією залишилася навіть не JDK 21 (що було б очікувано), а більш стара JDK 17. Розробники Spring пояснили це тим, що багато пов’язаних технологій все ще використовують JDK 17 — наприклад, Jakarta EE 11 та Hibernate, і що Java-індустрія в цілому все ще широко використовує саме JDK 17.

Розберемо деякі з елементів цього списку детальніше і почнемо з бібліотеки JSpecify, про яку я вже писав. Основна ідея цього проєкту — введення кількох анотацій (@Nullable, @NonNull) та інших для того, щоб вказати у вашому коді, чи можуть аргументи і значення, що повертаються, бути або не бути null. Такі інструкції зустрічаються у більш ніж 30 Java-проєктах, але цей проєкт претендує на те, щоб стати стандартом для Java-спільноти. І Spring-розробники інтенсивно йому допомагають у цьому, зробивши використання JSpecify однією з головних фіч у Spring Framework 7/Spring Boot 4.

Візьмемо для прикладу добре відомий інтерфейс BeanFactory, який у тому числі використовується для пошуку бінів. Більшість його методів викидають виключення, якщо бін не знайдено, тому нові анотації там не потрібні:

<T> T getBean(Class<T> requiredType) throws BeansException;

Але там, де може повертатися null, вже стоїть інструкція @Nullable:

@Nullable Class<?> getType(String name) throws NoSuchBeanDefinitionException;

Цікаво, що у Spring є свої анотації для маркування коду, які з’явилися ще в 5-й версії, але через перехід на JSpecify вони оголошені як deprecated, рекомендується перейти на аналоги від JSpecify:

@Deprecated(since = «7.0»)
public @interface Nullable {
}

Справа в тому, що їх можна було використовувати тільки для полів та елементів методу, тоді як JSpecify анотації можна використовувати в generic типах та масивах. Нові інструкції з’явилися у всіх Spring-проєктах, включаючи і, наприклад, проєкт Reactor. І якщо ви підключите бібліотеку NullAway до свого проєкту, то вона автоматично перевірятиме ваш код на відповідність присвоюваним значенням і вказаним інструкціям.

Більше того, у Spring з’явився новий перелік Nullness:

public enum Nullness {
    UNSPECIFIED,
    NULLABLE,
    NON_NULL;

Який містить масу утилітних методів для визначення того, чи може параметр або тип методу, що повертається, бути null чи ні:

var method = ReflectionUtils.findMethod(Starter.class, «handle», Object.class);
var nullness = Nullness.forMethodReturnType(method);

Підтримка JSpecify в Spring має велике значення для тих, хто використовує Kotlin, тому що ці анотації впливають на типи даних в Kotlin-проєктах (приймати або не приймати null-значення). Раніше така трансляція типів не проводилася, навіть якщо ви використовували Spring-анотації @Nullable/@NonNull.

У зв’язку з використанням JSpecify виникло два цікавих питання:

  1. Чому б її не включити до JDK, якщо це планує бути стандартом?
  2. Чи потрібний тип Optional у сучасному коді? Адже перевага Optional була в тому, що він дозволяв робити API прогнозованішим при роботі з null-даними.

Ще одне приємне оновлення — підтримка Jackson 3, який вийшов нещодавно, у жовтні. Нова версія відрізняється від попередньої масою несумісних змін на всіх рівнях, тому підтримувати одночасно Jackson 2 та 3 практично неможливо. Але для спрощення міграції в Spring Boot 4 додали нову властивість spring.jackson.use-jackson2-defaults, яка, якщо вона встановлена, налаштовує конфігурацію Jackson так само, як це було в другій версії.

На щастя, в Junit 6 несумісних змін немає, тому перейти на нього з Junit 5 не становить ніяких проблем, а його апдейт заслуговують на окрему статтю.

Програмна реєстрація бінів у Spring існує досить давно. Ще в Spring 3.1 з’явився новий інтерфейс — ApplicationContextInitializer:

@FunctionalInterface
public interface ApplicationContextInitializer<C extends ConfigurableApplicationContext> {
       void initialize(C applicationContext);
}

Який дозволяв програмно реєструвати біни:

public class AppInitializer implements ApplicationContextInitializer<
GenericApplicationContext>{
      @Override
     public void initialize(GenericApplicationContext ctx) {
          ctx.registerBean(MemoryWriter.class);
      }
}

Більше того, він дозволяв налаштовувати конфігурацію та вказувати атрибути бінів (те, що раніше робилося за допомогою анотацій @Bean, @Scope та інших):

@Override
public void initialize(GenericApplicationContext ctx) {
       ctx.registerBean(MemoryWriter.class, (spec) -> {
              spec.setLazyInit(true);
              spec.setInitMethodName("setup");
              spec.setScope(BeanDefinition.SCOPE_PROTOTYPE);});

Але використовувати це можна було переважно у Spring Boot застосунках. Вам потрібно було або написати файл spring.factories:

org.springframework.context.ApplicationContextInitializer=demo.AppInitializer

Або в головному bootstrap Spring Boot класі:

new SpringApplicationBuilder().initializers(new AppInitializer()).build().run(args);

Тепер це можна зробити і в Spring Framework, так як там з’явилися нові абстракції — BeanRegistrar і ​​BeanRegistry:

public class CustomBeanRegistrar implements BeanRegistrar {
        @Override
        public void register(BeanRegistry registry, Environment env) {
              registry.registerBean(DBInitializer.class);
        }
}

Новий клас не потрібно обвішувати ніякими анотаціями, а просто імпортувати його в класі-конфігурації:

@Import(CustomBeanRegistrar.class)
@Configuration
public class CustomConfig {

Реєструвати біни стало простіше, тому що у вас відразу є доступ до Environment, а ініціалізація властивостей робиться в Builder-стилі:

@Override
public void register(BeanRegistry registry, Environment env) {
registry.registerBean(DBInitializer.class, spec -> spec
      .lazyInit()
      .order(Ordered.HIGHEST_PRECEDENCE)
      .primary()
      .prototype());
}

Головна відмінність BeanRegistry від GenericApplicationContext — це те, що перший може лише реєструвати біни, але не може брати існуючі біни з контексту. І це розумний поділ відповідальності.

Ще одне важливе нововведення — це злиття Spring Framework як платформи та бібліотеки spring-retry. Розробники намагаються робити Spring якомога легковажнішим і менш громіздким. Можна згадати лише кілька прикладів подібної інтеграції — наприклад, злиття із проєктом spring-native. Тепер розробники Spring вирішили прибрати з spring-retry код, що не використовується, додати нові фічі і включити цю бібліотеку в spring-core. Завдяки цьому ви вже можете використовувати механізм retry для своїх операцій (за замовчуванням три спроби з паузою в 1 секунду):

@Component
public class DataLoader {
       @Retryable
       public void load() {

А також нову анотацію @ConcurrencyLimit, яка дозволяє обмежити одночасну кількість потоків, які можуть викликати ваш метод (те, що називається throttling):

@ConcurrencyLimit(5)
public void load() {

Все це доступно і буде працювати тільки якщо ви це включите через анотацію @EnableResilientMethods:

@Configuration
@EnableResilientMethods
public class CustomConfig {

API Versioning використовується у будь-якому більш-менш великому проєкті, де є REST API. Тепер ви можете не винаходити власний велосипед, а використовувати конфігурацію від Spring, якщо у вас в проєкті є Spring MVC або Spring WebFlux. Насамперед потрібно визначитися з тим, а який спосіб Versioning ви хочете:

  1. Вказівка ​​версії в URL.
  2. Вказівка ​​версії в заголовку запиту.
  3. Вказівка ​​версії у параметрі.
  4. Вказівка ​​версії в атрибуті MediaType.

А потім написати все це у конфігурації:

@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
     @Override
     public void configureApiVersioning(ApiVersionConfigurer configurer) {
          configurer.addSupportedVersions("1+");
          configurer.setDefaultVersion("1.0″);
          configurer.useRequestHeader("v“);
}

За замовчуванням ви можете вказувати трирівневу версію («2.3.1»), але якщо ваша поточна реалізація відрізняється від дефолтної, ви можете вказувати власну реалізацію, яка буде як парсить версію, так і брати її з запиту. Також підтримка Versioning з’явилася і у всіх клієнтів REST (RestClient, WebClient):

RestClient restClient = RestClient.builder()
          .apiVersionInserter(ApiVersionInserter.useHeader("v"))
          .baseUrl("http://localhost:8080/")
          .build();

Нові фічі в Spring Boot 4

Тепер перейдемо до тих фіч, які з’явилися в Spring Boot 4:

  1. Перехід на Spring Framework 7.
  2. Використання бібліотеки JSpecify та її анотацій.
  3. Повна модуляризація автоконфігурації Spring Boot.
  4. Підтримка автоконфігурації для нових фіч (API Versioning та HTTP Service клієнтів).

Найбільшим із нововведень безумовно є модуляризація. Спочатку Spring Boot і його автоконфігурація знаходилася в jar файлі spring-boot-autoconfigure, який до версії 3.5.9 доріс до 2 мегабайт. І більшість із його коду вами ніяк не використовувалася. Тому в Spring Boot 4 вирішили розбити його за технологіями та окремими jar-ами, що виглядає дуже розумно.

Крім того, таке розбиття торкнулося і тестових стартерів. Якщо раніше ви додавали одну залежність spring-boot-starter-test, то тепер потрібно додавати специфічний стартер для вашої технології. Наприклад, якщо ви використовуєте Spring Data JPA, то основний стартер, як і раніше — spring-boot-starter-data-jpa, а ось для тестів — вже spring-boot-starter-data-jpa-test.

Втім, якщо у вас великий проєкт, досить багато модулів і складні конфігураційні файли, розробники Spring Boot пропонують вам «класичні» стартери, які включають всі модулі авто-конфігурації: spring-boot-starter-classic і spring-boot-starter-test-classic.

Гід переходу на Spring Boot 4 відразу вказує на те, що гладко він може і не пройти, тому що деякі фічі були видалені в новій версії (особливо все, що стосується авто-конфігурації):

  1. Припинено підтримку вебсервера Undertow (оскільки він несумісний з Jakarta EE 11 і Servlet 6.1).
  2. Припинено підтримку Spring Pulsar Reactive.
  3. Припинено підтримку Spring Session MongoDB/Hazelcast (оскільки ці проєкти тепер розробляються самими продуктовими компаніями).
  4. Припинено підтримку тестової бібліотеки Spock (оскільки він несумісний з Groovy 5).

Як ви бачите, якщо раніше розробники Spring намагалися по максимуму включати зворотну сумісність в нових версіях, тепер вони поламали все, що можна. Про те, як це вплине на міграцію, поговоримо у наступному розділі.

Переходимо на Spring Boot 4

Для міграції було використано кілька різнотипних Maven-проєктів, щоб охопити якнайбільше різних Spring Boot технологій та конфігурацій. Почнемо з того, що поміняємо глобально версію Junit з:

<junit.version>5.11.3</junit.version>

На:

<junit.version>6.0.1</junit.version>

Потім замінимо саму версію Spring Boot з:

<spring.boot.version>3.5.6</spring.boot.version>

На:

<spring.boot.version>4.0.1</spring.boot.version>

Якщо у вас вебзастосунок, то швидше за все ви використовуєте цей стартер:

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-web</artifactId>
     <version>${spring.boot.version}</version>
</dependency>

У новій версії він зберігся, але оголошений deprecated, потрібно перейти на spring-boot-starter-webmvc:

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-webmvc</artifactId>
     <version>${spring.boot.version}</version>
</dependency>

Цю залежність можна видалити, оскільки вона інтегрована у Spring Framework (spring-core):

<dependency>
     <groupId>org.springframework.retry</groupId>
     <artifactId>spring-retry</artifactId>
     <version>${spring.retry.version}</version>
</dependency>

Ще один стартер:

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-aop</artifactId>
     <version>${spring.boot.version}</version>
</dependency>

Був перейменований на:

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-aspectj</artifactId>
     <version>${spring.boot.version}</version>
</dependency>

Цей стартер також оголошено як deprecated:

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-test</artifactId>
     <version>${spring.boot.version}</version>
     <scope>test</scope>
</dependency>

І тому що у нас всі Spring-проєкти — це веб-застосунки, то замінимо його на:

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-webmvc-test</artifactId>
     <version>${spring.boot.version}</version>
     <scope>test</scope>
</dependency>

Все це були зміни, притаманні всім Spring Boot проєктам. Тепер перейдемо до конкретних технологій. Якщо у вас використовується Spring DocAPI, потрібно обов’язково оновити версію з:

<dependency>
     <groupId>org.springdoc</groupId>
     <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
     <version>2.2.0</version>
</dependency>

На:

<dependency>
     <groupId>org.springdoc</groupId>
     <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
     <version>3.0.0</version>
</dependency>

Після всіх змін проєкт перестав компілюватись. Проблема в тому, що в класі HttpEntity метод getHeaders тепер повертає HttpHeaders, а не MultiValueMap. Це відома проблема, яка виникла в результаті того, що клас HttpHeaders більше не реалізує інтерфейс MultiValueMap, так що нам потрібно лише поміняти:

response.getHeaders()

На:

response.getHeaders().asMultiValueMap()

Щоб код компілювався. Але метод як MultiValueMap оголошений deprecated, так що це буде тимчасовим рішенням, краще використовувати HttpHeaders.

Далі не знаходиться імпорт:

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;

Який потрібно змінити на:

import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;

Це глобальні зміни по всьому Spring Boot, де у четвертій версії змінили ієрархію пакетів так, щоб top-level пакет завжди був org.springframework.boot.<technology>.

Ще один імпорт, який не знаходиться:

import org.springframework.retry.annotation.EnableRetry;

Анотації @EnableRetry більше немає, потрібно використовувати нову — @EnableResilientMethods:

@EnableResilientMethods
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}

Запускаємо тести і всі вони падають з помилкою:

Caused by: java.lang.ClassNotFoundException: org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
 at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)

Ця помилка пов’язана з тим, що у нас глобально використовується стара версія Spring Cloud 2024:

<spring.cloud.version>4.3.0</spring.cloud.version>

Яку потрібно поміняти на 2025-у:

<spring.cloud.version>5.0.0</spring.cloud.version>

Тепер тести падають із новою помилкою:

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type ’com.fasterxml.jackson.databind.ObjectMapper’ available
 at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:386)
 at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:377)
 at org.springframework.context.support.AbstractApplicationContext.getBean(AbstractApplicationContext.java:1296)
 at config.ServiceFactoryInitializer.createServiceRegistry(ServiceFactoryInitializer.java:48)

Тести падають через те, що ми намагаємося дістати ObjectMapper бін із Spring контексту:

var objectMapper = ctx.getBean(ObjectMapper.class);

А його там нема. Тут ситуація трохи складніша і пов’язана з тим, що в Jackson 3 додали новий тип JsonMapper, який розширює відомий всім клас ObjectMapper і додає код, специфічний тільки для JSON-серіалізації:

public class JsonMapper extends ObjectMapper
{

Тому розробники Spring Boot вирішили підтримувати тільки його і в класі JacksonAutoCon-figuration оголошується бін типу JsonMapper, а не ObjectMapper, як раніше:

@Bean
@Primary
@ConditionalOnMissingBean
JsonMapper jacksonJsonMapper(JsonMapper.Builder builder) {
        return builder.build();
}

Якби ObjectMapper був інтерфейсом, то наш код продовжував би працювати, як і раніше, але так як це клас, то доведеться явно вказати новий тип:

var objectMapper = ctx.getBean(JsonMapper.class);

Але наразі виникла нова проблема. Справа в тому, що раніше ми використовували ObjectMapper, щоб передати для конфігурації бібліотеки OpenFeign. Але ця бібліотека все ще не підтримує Jackson 3, тому нам поки що доведеться створювати його вручну, благо старий добрий ObjectMapper з Jackson 2 все ще є в classpath.

var objectMapper = new ObjectMapper().findAndRegisterModules();

Запускаємо тести, і один із них все ще падає з помилкою:

java.lang.NoSuchFieldError: Class com.fasterxml.jackson.annotation.JsonFormat$Shape does not have member field ’com.fasterxml.jackson.annotation.JsonFormat$Shape POJO’
 at tools.jackson.databind.deser.DeserializerCache._createDeserializer2(DeserializerCache.java:399)
 at tools.jackson.databind.deser.DeserializerCache._createDeserializer(DeserializerCache.java:361)
 at tools.jackson.databind.deser.DeserializerCache._createAndCache2(DeserializerCache.java:265)

У ньому використовується спеціальний тип JacksonTester:

@Autowired
JacksonTester<LoginDTO> loginTester;

Який викидає помилку під час серіалізації:

loginTester.write(loginDTO);

Тут ситуація ще заплутаніша і, мабуть, пов’язана з конфліктами версії бібліотек Jackson. Звідки вона береться у нашому проєкті? Спочатку для цього існував стартер spring-boot-starter-json, але модулярізація призвела до того, що замість нього виникло відразу три незалежні стартери для кожної технології серіалізації:

  1. Spring-boot-starter-jackson
  2. Spring-boot-starter-gson
  3. Spring-boot-starter-jsonb

Більше того, кожен з них має свій тестовий стартер. Перший стартер ми і використовуємо. Цікаво інше. Анотація @AutoConfigureJsonTesters і клас JacksonTester, які ми використовуємо в цьому місці, нейтральні і не прив’язані до жодної з бібліотек, тому їх довелося залишити у загальних jar-ах spring-boot-test-autoconfigure та spring-boot-test.

Але чому ж у нас конфлікт? Швидше за все, з транзитивних залежностей інших бібліотек, наприклад WireMock, який все ще не підтримує Jackson 3, а також OpenFeign.

У Maven це вирішується досить просто, явним додаванням залежності з потрібною версією:

<dependency>
     <groupId>com.fasterxml.jackson.core</groupId>
     <artifactId>jackson-annotations</artifactId>
     <version>2.20</version>
</dependency>

Наразі тести проходять успішно. Перейдемо до наступного проєкту. Оновимо версію Spring Rest Docs з:

<properties>
       <restdoc.version>3.0.3</restdoc.version>
</properties>

На:

<properties>
       <restdoc.version>4.0.0</restdoc.version>
</properties>

Тут у нас використовується Spring Data JPA, тому замінимо:

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <version>${spring.boot.version}</version>
      <scope>test</scope>
</dependency>

На:

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa-test</artifactId>
      <version>${spring.boot.version}</version>
      <scope>test</scope>
</dependency>

Тут не знаходиться імпорт:

import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

Який потрібно замінити на:

import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;

Цей імпорт теж не знаходиться:

import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;

Тому потрібно додати новий стартер для конфігурації авто Spring RestDocs:

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-restdocs</artifactId>
      <version>${spring.boot.version}</version>
      <scope>test</scope>
</dependency>

Також оновимо версію Spring Kafka:

<spring.kafka.version>4.0.1</spring.kafka.version>

Запускаємо тести, що падають з помилкою:

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named ’kafkaListenerContainerFactory’ available
 at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeanDefinition(DefaultListableBeanFactory.java:971)
 at org.springframework.beans.factory.support.AbstractBeanFactory.getMergedLocalBeanDefinition(AbstractBeanFactory.java:1369)
 at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:296)

Виявляється, що і для Spring Kafka потрібно додати свій стартер:

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-kafka</artifactId>
      <version>${spring.boot.version}</version>
</dependency>

Тепер тести падають із новою помилкою:

Caused by: org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsNotFoundException: No ConnectionDetails found for source ’@ServiceConnection source for KafkaEventConsumerTest.kafka’
 at org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories.getConnectionDetails(ConnectionDetailsFactories.java:96)

Виявилось, що тут потрібно додати стартер і для тестів:

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-kafka-test</artifactId>
      <version>${spring.boot.version}</version>
</dependency>

Але це не позбавило нас нової помилки в тестах:

java.lang.AssertionError: Content type expected:<application/hal+json> but was:<application/vnd.hal+json>
 at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:62)
 at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:129)
 at org.springframework.test.web.servlet.result.ContentResultMatchers.lambda$contentType$0(ContentResultMatchers.java:86)

Виявляється, що цього року розробники Spring HATEOAS вирішили додати до існуючого media-type application/hal+json новий стандартний тип application/vnd.hal+json, а потім розробники Spring Data REST зробили останнє значення дефолтним. Це поламало нам всі тести, тому довелося в них міняти все на VND_HAL_JSON:

ResultActions result = mockMvc.perform(get("/orders«));
result.andExpect(status().isOk()).andExpect(content().contentType(MediaTypes.VND_HAL_JSON))

Тепер з’явилася нова помилка:

Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module «com.fasterxml.jackson.datatype:jackson-datatype-jsr310» to enable handling (through reference chain: event.PaymentFailureEvent["createdAt"])
 at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77)
 at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1328)

Швидше за все помилка пов’язана з тим, що в Jackson 3 модуль для підтримки типів з Java Time був доданий до основного модуля, тому Spring Boot Kafka його більше не імпортує. Але так як нам доводиться все ще використовувати Jackson 2 через старі технології, відсутність цього модуля призводить до помилки. Доводиться його явно додавати:

<dependency>
      <groupId>com.fasterxml.jackson.datatype</groupId>
      <artifactId>jackson-datatype-jsr310</artifactId>
      <version>2.20.0</version>
      <scope>runtime</scope>
</dependency>

Наразі тести проходять успішно. Переходимо до наступного проєкту. Тут тести падають із несподіваною помилкою:

Caused by: org.hibernate.tool.schema.spi.SchemaManagementException: Schema validation: missing table [`USER`]
 at org.hibernate.tool.schema.internal.AbstractSchemaValidator.validateTable(AbstractSchemaValidator.java:121)
 at org.hibernate.tool.schema.internal.GroupedSchemaValidatorImpl.validateTables(GroupedSchemaValidatorImpl.java:42)

Ця таблиця створюється за допомогою Liquibase, стартера для якої нам і не вистачає, доведеться його додати:

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-liquibase</artifactId>
      <version>${spring.boot.version}</version>
</dependency>

Тепер компіляція та всі тести проходять успішно. Потім перезбираємо Docker images, запускаємо контейнери — програми зовні працюють успішно. Єдина помилка під час пошуку даних:

Description:
Failed to bind properties under ’spring.jackson.serialization’ to java.util.Map<tools.jackson.databind.SerializationFeature, java.lang.Boolean>:
 Reason: failed to convert java.lang.String to tools.jackson.databind.SerializationFeature (caused by java.lang.IllegalArgumentException: No enum constant tools.jackson.databind.SerializationFeature.write_dates_as_timestamps)
 Action:
 Update your application’s configuration. The following values are valid:
 CLOSE_CLOSEABLE
 EAGER_SERIALIZER_FETCH
 FAIL_ON_EMPTY_BEANS
 FAIL_ON_ORDER_MAP_BY_INCOMPARABLE_KEY
 FAIL_ON_SELF_REFERENCES
 FAIL_ON_UNWRAPPED_TYPE_IDENTIFIERS
 FLUSH_AFTER_WRITE_VALUE
 INDENT_OUTPUT
 ORDER_MAP_ENTRIES_BY_KEYS
 USE_EQUALITY_FOR_OBJECT_ID
 WRAP_EXCEPTIONS
 WRAP_ROOT_VALUE
 WRITE_CHAR_ARRAYS_AS_JSON_ARRAYS
 WRITE_EMPTY_JSON_ARRAYS
 WRITE_SELF_REFERENCES_AS_NULL
 WRITE_SINGLE_ELEM_ARRAYS_UNWRAPPED

Дійсно, в одному з проєктів ми додали налаштування для тоді ще Jackson 2:

spring.jackson.serialization.write_dates_as_timestamps=false

Як вона працює? У класі JacksonProperties є поле serialization:

private final Map<SerializationFeature, Boolean> serialization = new EnumMap<>(SerializationFeature.class);

Де можна вказувати ті чи інші опції Jackson. Все це залишилося без змін у Spring Boot 4, але зміни відбулися в Jackson 3, де значення WRITE_DATES_AS_TIMESTAMPS перекочувало з переліку SerializationFeature в DateTimeFeature, причому за умовчанням вона false (вимкнена), хоча була включена в Jackson 2. А це означає, що ми можемо просто видалити цей рядок, тому що нам підходить дефолтне значення. Якщо ж нам потрібно було б змінити це значення, для цього у JacksonProperties є поле datetime:

private final Map<DateTimeFeature, Boolean> datetime = new EnumMap<>(DateTimeFeature.class);

Спробуємо запустити всі наші сервіси, і Spring Boot сервіси падають з однією і тією ж помилкою, яка говорить про те, що сервіси намагалися звернутися до Consul-серверу для отримання властивостей (конфігурації) через Spring Cloud Config, але той ще був недоступний або завантажувався. У проєкті такий варіант давно передбачений, тому було додано механізм Retry до application.yml:

spring:
      cloud:
            consul:
                  retry:
                        enabled: true
                        initialInterval: 2000
                        multiplier: 2
                        maxInterval: 60000

І хоча ми видалили бібліотеку spring-retry із залежностей, але вона ж була інтегрована в spring-core в Spring 7, що могло піти не так? Починаємо розбирати документацію по Spring Cloud Config і насторожує фраза, що для того, щоб Retry працював, потрібна бібліотека spring-retry. Відкриваємо вихідні коди ConsulRetryBootstrapper і бачимо код, який був написаний давним-давно:

public class ConsulRetryBootstrapper implements BootstrapRegistryInitializer {
      static final boolean RETRY_IS_PRESENT = ClassUtils.isPresent("org.springframework.retry.annotation.Retryable",
null);
      @Override
      public void initialize(BootstrapRegistry registry) {
            if (!RETRY_IS_PRESENT) {
                  return;
            }

Що тут могло зламатися? Але виявляється, що при злитті spring-retry розробники Spring порушили сумісність і поклали інструкцію @Retryable в пакет org.springframework.core.retry. Ще одна загадка в тому, що розробники Spring Cloud Consul не змінили клас ConsulRetryBootstrapper. Я відкрив тикет на це, а поки що доведеться повернути зворотно залежність spring-retry.

Перебираємо Docker images, запускаємо контейнери та сервіси працюють без помилок. Поки що ми просто оновили версії Spring Boot та пов’язаних залежностей, жодних нових фіч ми в проєкті не використовуємо. У чому поточний виграш від міграції? Насамперед це те, що ми позбулися монолітного jar-файлу spring-boot-autoconfigure, який перетворився на кілька невеликих jar-файлів на кожен стартер/технологію.

Використовуємо нові фічі Spring Boot

Спробуємо використати нові фічі, у тому числі програмну реєстрацію бінів. Зараз є клас BaseContextInitializer, який реєструє Spring-біни:

public class BaseContextInitializer implements ApplicationContextInitializer<GenericApplicationContext> {
       @Override
       public void initialize(GenericApplicationContext ctx) {
               ctx.registerBean(DefaultRouteHandler.class);
              ctx.registerBean(RestTemplate.class, () -> new RestTemplate());
              ctx.registerBean(DefaultRequestComposer.class);
              ctx.registerBean(DefaultRequestRouter.class);
       }
}

Замінимо в цьому класі ApplicationContextInitializer на новий тип BeanRegistrar з Spring Framework 7:

public class BaseContextInitializer implements BeanRegistrar {
       @Override
       public void register(BeanRegistry registry, Environment env) {
              registry.registerBean(DefaultRouteHandler.class);
              registry.registerBean(RestTemplate.class, (spec) -> spec.supplier(_-> new RestTemplate()));
              registry.registerBean(DefaultRequestComposer.class);
              registry.registerBean(DefaultRequestRouter.class);
       }
}

Цей базовий клас був створений тільки для використання в тестах для зберігання мінімальної конфігурації бінів і підключався за допомогою атрибуту initializers:

@SpringJUnitWebConfig(classes = GatewayApplication.class, initializers = BaseContextInitializer.class)
class GatewayHandlerMappingTest {

Як його підключити зараз? Якщо спробувати перемістити новий клас до атрибуту classes:

@SpringJUnitWebConfig(classes = { GatewayApplication.class, BaseContextInitializer.class })
class GatewayHandlerMappingTest {

То отримаємо помилку, пов’язану з тим, що цей клас просто не завантажується у тесті. Тому єдино можливий варіант — це явно імпортувати цей клас за допомогою @Import, як це робиться для класів-конфігурації:

@SpringJUnitWebConfig(classes = { GatewayApplication.class })
@Import(BaseContextInitializer.class)
class GatewayHandlerMappingTest {

Але ще залишився клас ServiceFactoryInitializer:

public class ServiceFactoryInitializer extends BaseContextInitializer {
       @Override
       public void initialize(GenericApplicationContext ctx) {
              super.initialize(ctx);
              ctx.registerBean(UserFacade.class, () -> {
                     String authUrl = ctx.getEnvironment().getRequiredProperty("authentication.url");
                     return UserFacade.create(authUrl);
              });
              ctx.registerBean(UrlResolver.class, () -> new GatewayUrlResolver(ctx.getBean(RouteConfig.class)));
              ctx.registerBean(ServiceRegistry.class, () -> createServiceRegistry(ctx));
       }
       private ServiceRegistry createServiceRegistry(ApplicationContext ctx) {
              var registry = new DefaultServiceRegistry(ctx.getBean(UrlResolver.class));
              var gsonDecoder = new GsonDecoder();
              var gsonEncoder = new GsonEncoder();
              var objectMapper = new ObjectMapper().findAndRegisterModules();
              registry.add(TripServiceResolver.builder().decoder(new JacksonDecoder(objectMapper))
                     .encoder(new JacksonEncoder(objectMapper)).build());
              registry.add(UserServiceResolver.builder().decoder(gsonDecoder).encoder(gsonEncoder).build());
              return registry;
       }
}

А ось тут відразу видно відмінність BeanRegistry від ApplicationContext. Якщо раніше ми могли при реєстрації бінів взяти будь-який інший бін із контексту, то тепер такої можливості у BeanRegistry немає. В принципі, це теж здається розумним підходом, тому що заздалегідь не може бути відомо, чи є якийсь бін у ApplicationContext чи ні. Але в нашому випадку ми точно знаємо, що бін є, тому що він реєструється у базовому класі. І тут нам допоможе абстракція SupplierContext, яка через адаптер бере дані з BeanFactory:

registry.registerBean(UrlResolver.class,
       (spec) -> spec.supplier(context -> new GatewayUrlResolver(context.bean(RouteConfig.class))));
registry.registerBean(ServiceRegistry.class,
       (spec) -> spec.supplier(context -> createServiceRegistry(context.bean(UrlResolver.class))));

У результаті отримаємо:

public class ServiceFactoryInitializer extends BaseContextInitializer {
       @Override
       public void register(BeanRegistry registry, Environment env) {
              super.register(registry, env);
              registry.registerBean(UserFacade.class, (spec) -> {
                     String authUrl = env.getRequiredProperty("authentication.url");
                     spec.supplier(_ -> UserFacade.create(authUrl));
              });
              registry.registerBean(UrlResolver.class,
                     (spec) -> spec.supplier(context -> new GatewayUrlResolver(context.bean(RouteConfig.class))));
              registry.registerBean(ServiceRegistry.class,
                     (spec) -> spec.supplier(context -> createServiceRegistry(context.bean(UrlResolver.class))));
}
       private ServiceRegistry createServiceRegistry(UrlResolver urlResolver) {
              var registry = new DefaultServiceRegistry(urlResolver);
              var gsonDecoder = new GsonDecoder();
              var gsonEncoder = new GsonEncoder();
              var objectMapper = new ObjectMapper().findAndRegisterModules();
              registry.add(TripServiceResolver.builder().decoder(new JacksonDecoder(objectMapper))
                     .encoder(new JacksonEncoder(objectMapper)).build());
              registry.add(UserServiceResolver.builder().decoder(gsonDecoder).encoder(gsonEncoder).build());
              return registry;
       }
}

Тепер можна видалити файл spring.factories у src/main/resources/META-INF:

org.springframework.context.ApplicationContextInitializer=demo.config.ServiceFactoryInitializer

А ServiceFactoryInitializer імпортувати через головний bootstrap клас:

@SpringBootApplication
@Import(ServiceFactoryInitializer.class)
public class GatewayApplication {
       public static void main(String[] args) {
              SpringApplication.run(GatewayApplication.class, args);
       }
}

Залишилось у всіх тестах, де використовується цей клас:

@SpringJUnitWebConfig(classes = { GatewayApplication.class, ServiceFactoryInitializer.class })
class AuthenticationControllerTest {

Видалити його, тому що ми і так його імпортуємо в GatewayApplication:

@SpringJUnitWebConfig(classes = GatewayApplication.class)
class AuthenticationControllerTest {

Особливості та помилки в міграції

Які потенційні перешкоди для міграції чи використання нової версії можуть виникнути? Крім того, що в самому Spring Boot 4 багато несумісних змін, ви оновлюєте бібліотеки, які також включають такі зміни, наприклад, Jackson 3. Тому якщо ви використовуєте бібліотеку BiWeekly для роботи з iCalendar, вона все ще використовує Jackson 2. Або є інша популярна бібліотека — jjwt. Зрозуміло, що повністю перейти на Jackson 3 у них не вийде, оскільки базується на JDK 17, а не всі технології готові на неї перейти. Тому у вас у проєкті може вийти каша з коду, який десь використовує Jackson 2, а десь Jackson 3.

Деякі речі перестануть працювати у Spring Framework 7 або у пізніших версіях:

  1. Більше не підтримуються анотації з пакетів javax.inject, javax.annotation, наприклад, @ PostConstruct. Потрібно перейти на аналогічні анотації з Jakarta EE.
  2. RestTemplate оголошено deprecated і може бути видалено в будь-який момент (як і TestRestTemplate).
  3. Підтримка Junit 4/Jackson 2 також оголошена як deprecated і буде видалена у майбутньому.

Крім того, велика кількість типів змінила свій пакет, наприклад, @EntityScan з:

import org.springframework.boot.autoconfigure.domain.EntityScan;

На:

import org.springframework.boot.persistence.autoconfigure.EntityScan;

Якщо ви використовували інструкцію @MockBean, то і вона перестає виявлятися:

import org.springframework.boot.test.mock.mockito.MockBean;

Тому що вона була оголошена deprecated ще у Spring Boot 3.4 і зараз була видалена, так що тепер потрібно перейти на @MockitoBean:

@MockitoBean
BookService bookService;

Можливо, ви використовували цей універсальний клас для налаштувань вашого вебсервера:

import org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration;

Натомість з’явилися класи, специфічні для кожного вебсервера (Tomcat, Jetty, Undertow), наприклад Tomcat:

import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration;

Ну якщо ви безпосередньо працювали з Jackson та його API, то вам доведеться і тут поміняти імпорти з:

import com.fasterxml.jackson.jr.ob.JSON;
import com.fasterxml.jackson.jr.ob.JSONObjectException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

на

import tools.jackson.jr.ob.JSON;
import tools.jackson.jr.ob.JSONObjectException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;

А класи-виключення:

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;

Замінені на DatabindException, причому всі вони unchecked в Jackson 3, так як у них базовий клас JacksonException, а у того в свою чергу — RuntimeException.

Висновки

Перехід на Spring Boot 4 автоматично оновлює і пов’язані залежності — Spring Framework, Jakarta EE, Hibernate, Junit, Jackson та багато інших. Через те, що в деяких технологіях було порушено зворотну сумісність, міграція проходить не так гладко, як у попередніх версіях. Якщо вам не хочеться самостійно займатися цим процесом, OpenRewrite вже має готовий рецепт для автоматичної міграції.

У той же час у Spring Boot 4/Spring 7 є безліч корисних та потрібних фіч, які ви можете використовувати. Це модуляризація для авто-конфігурації, використання бібліотеки JSpecify для боротьби з NullPointerException, нова програмна реєстрація бінів, Versioning API, можливості Spring Retry та багато інших.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Кілька тижнів тому мігрував з 3.5 на 4.0

Зайняло ± 2 години промтів і рев’ю коду.

Два роки тому це був б таск на ± тиждень.

Також доволі швидко мігрував.

Підписатись на коментарі