前言

最初的目标很简单:将项目从 Spring Boot 3.2.3 直接升级到 4.0.0(当前最新 GA)
实际操作后发现,这种“一步登天”的做法并不现实:编译错误、弃用 API、大量隐式行为变更接踵而至,在对项目配置和底层机制理解不够深入的前提下,几乎无法推进。

在查阅官方资料后,我注意到 Spring Boot 官方 Wiki 已明确给出建议:

升级到 4.0.0 之前,务必先升级到 3.5.x

这并不是形式建议,而是对真实迁移成本的精准判断。
因此,我最终选择 分阶段升级,在保证系统可运行的前提下,逐步体会 Spring Boot 的演进方向与设计变化。

重要前置条件
如果目标版本是 Spring Boot 4.0.0,必须提前将 Java 版本从 17 升级到 21,否则项目将无法通过编译。

一、3.2.3 → 3.3.0:版本兼容的第一道门槛

升级 spring-boot.version 后,项目启动即失败。

image.png

问题定位

报错信息指向 Spring Boot 与 Spring Cloud 版本不兼容
这一点在 Spring Cloud 官方文档 中有明确说明:

  • Spring Boot 3.3.x
  • Spring Cloud 2023.0.x

我当前使用的是 2023.0.0,但根据 Spring Cloud 2023.0 Release Notes,该版本并非最终推荐版本。
尝试升级到 2023.0.6 后,项目恢复正常运行。

<spring-cloud.version>2023.0.6</spring-cloud.version>
说明
后续每一次 Spring Boot 升级,都需要同步升级 Spring Cloud。
最终在 4.0.0 阶段,对应的 Spring Cloud 版本为 2025.1.0

二、3.3.0 → 3.4.0:注解迁移与测试的价值

升级后出现编译错误:

import org.jetbrains.annotations.NotNull;

问题本质

jetbrains 注解不再被 Spring 官方生态推荐使用。
此时有一个实用技巧

先移除错误依赖,让 IDE 自动提示可替代的注解来源

image.png

IDE 给出了多个选项,其中主要是:

  1. jakarta.validation.constraints.NotNull
  2. com.sun.istack.NotNull

选择依据

  • jakarta.validation.constraints.NotNull

    • Java 标准校验注解
    • 用于参数、字段的非空约束
    • Spring 生态主流方案
  • com.sun.istack.NotNull

    • 多用于 JAXB / XML 序列化
    • 不适用于 Web 层参数校验

结论:选择第一个。


本阶段的最大感悟:测试的价值被低估了

升级过程中,并没有修改任何核心业务逻辑;
项目能启动,几个关键接口也能跑通——但心里并不踏实

这正是单元测试与集成测试真正发挥价值的时刻:

  • 正所谓 失之东隅,收之桑榆
  • 它不是为了写得“好看”
  • 而是为了在非功能性改动(如框架升级)中,验证核心逻辑仍然成立

事实证明,已有的数十个测试用例,让这次升级可控、可验证、可回滚


三、3.4.0 → 3.5.0:平稳过渡

这一阶段没有遇到明显问题,项目可直接运行。

这恰恰印证了一个事实:

当你遵循官方升级路径时,Spring 的演进是克制且连续的。
升级和编程学习之路是类似的,一步一个脚印,明确每个阶段的目标,且钻研进去,切莫好高骛远。

四、3.5.0 → 4.0.0:真正的迁移开始

从这一阶段开始,问题全部集中在编译期,但每一个都指向了明确的设计变更


问题一:Jackson 配置方式变更(核心难点)

image.png

旧代码中使用了:

Jackson2ObjectMapperBuilderCustomizer

该接口在新版本中被弃用。

原配置的意图

确保 未显式声明 @JsonView 的字段仍然参与序列化
这是一个非常实用的策略,尤其在需要精细控制返回字段时。

新方案

Spring Boot 4.x 推荐使用:

JsonMapperBuilderCustomizer

等价实现如下:

@Bean
public JsonMapperBuilderCustomizer jsonCustomizer() {
    return builder -> builder.enable(MapperFeature.DEFAULT_VIEW_INCLUSION);
}

问题二:Feign 解码器 API 变更

原有代码引用:

import org.springframework.boot.autoconfigure.http.HttpMessageConverters;

HttpMessageConverters报错,最开始以为是依赖变更的问题,后来发现当前配置feign文件的下面代码报错。这里的解决,完全参考下面idea的提示:

image.png

修复后构造方式如下:

public class SpringDecoder implements Decoder {

    private final ObjectProvider<FeignHttpMessageConverters> converters;

    public SpringDecoder(ObjectProvider<FeignHttpMessageConverters> converters) {
        this.converters = converters;
    }

    ...

}

问题三:Specification 查询的空条件歧义

image.png

Spring Data JPA 在新版本中不再容忍 null 作为 where 条件

这里的问题是:新版本查询,当where条件为null时,spring不知道用的是哪一个类型,看了源码好久,最后发现可以直接使用unrestricted()来代替。

image.png

image.png

解决方案是显式返回:

return Specification.unrestricted();

该方法语义清晰,避免类型歧义。


问题四:PathMatcher 被彻底弃用

image.png

旧配置目标:

  • 使用字符串规则匹配 URL
  • 忽略大小写

新版本中 PathMatcher 被移除,推荐使用 PathPatternParser

@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
    PathPatternParser parser = new PathPatternParser();
    parser.setCaseSensitive(false);
    configurer.setPatternParser(parser);
}

这是一次从运行时字符串匹配 → 预编译路径模式的设计转变。


问题五:DaoAuthenticationProvider 构造器变更

image.png

原无参构造器被移除,新版本必须显式传入:UserDetailsService

image.png

image.png

这是一次安全配置显式化的调整,修改方式直接、明确。修复的方式很简单,这里不给出修复代码,防止篇幅过长。

问题六:无法再重写 matches 方法(OTP 场景)

image.png

旧方案依赖重写 PasswordEncoder#matches,新版本已禁止该用法。

最终决策

放弃技巧性 override,改为自定义 AuthenticationProvider

具体逻辑如下:

@Component
public class CustomizedDaoAuthenticationProvider extends DaoAuthenticationProvider {

    private final OneTimePasswordService oneTimePasswordService;

    public CustomizedDaoAuthenticationProvider(PasswordEncoder passwordEncoder,
                                               OneTimePasswordService oneTimePasswordService,
                                               UserDetailsService userDetailsService) {
        super(userDetailsService);
        this.setPasswordEncoder(passwordEncoder);
        this.oneTimePasswordService = oneTimePasswordService;
    }

    @Override
    protected void additionalAuthenticationChecks(@NonNull UserDetails userDetails,
                                                  UsernamePasswordAuthenticationToken authentication) {
        String password = null;
        if (authentication.getCredentials() != null) {
            password = authentication.getCredentials().toString();
        }

        // 1. 先尝试一次性密码
        if (oneTimePasswordService.matches(password)) {
            return; // 验证通过
        }

        // 2. 正常密码验证
        if (!getPasswordEncoder().matches(password, userDetails.getPassword())) {
            throw new BadCredentialsException("密码错误");
        }
    }
}

这不仅符合新版本约束,也让认证逻辑更加清晰。


问题七(不会影响运行):Redis 序列化弃用提示

image.png

image.png

参考源码推荐替换为:

GenericJacksonJsonRedisSerializer

该调整涉及 多态反序列化,属于架构层面问题,后续单独展开。


问题八:测试依赖的模块化重构与注解迁移

在解决上述运行时问题后,项目终于能够正常启动。但当我执行 mvn test 运行本地测试时,却遇到了多个与测试配置相关的编译和运行错误。这让我意识到,Spring Boot 4.0 在测试支持上的变化是最彻底、最需要细致处理的迁移点之一

问题本质:测试依赖的模块化设计

Spring Boot 4.0 引入了全面模块化(modularization)架构,这直接影响了测试 classpath。旧版本中,我们通常只需添加一个“万能”的测试 starter:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

它会隐式拉取几乎所有测试支持(MockMvc、Security 测试注解、JPA 测试切片等),非常方便,但也导致 classpath 臃肿,且依赖不明确。

新版本中,这个万能 starter 被拆分成细颗粒度的技术特定 test starter,每个对应一种主流技术栈。官方迁移指南明确指出:

“Given that all test 'starter' POMs bring spring-boot-starter-test transitively, you don’t need to define this starter anymore. Rather, you should list the starters of the technologies that are under test for the module.”

这意味着:

  • 不再直接依赖 spring-boot-starter-test(它现在仅提供核心测试库,如 JUnit 6、Mockito 5、AssertJ)。
  • 必须显式添加项目实际使用的 test starter,否则某些 auto-configuration(如 MockMvc、@WithMockUser)将不会自动启用,导致测试失败。

在本项目中,使用了 WebMVC、Security、Flyway 等技术,因此需要添加对应的 test starter:

<dependencies>
    <!-- 测试依赖:按需添加模块化 test starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-flyway-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- 如果还有其他技术,如 data-jpa-test、data-redis-test 等,也需类似添加 -->
</dependencies>

迁移技巧与过渡方案

  • 逐步迁移:如果项目测试众多,一次性切换所有 starter 可能引入大量错误。可以先临时引入 spring-boot-starter-test-classic(test 作用域),它模拟旧版行为,提供所有测试模块,便于快速验证应用运行。随后逐步替换为具体 test starter,并移除 classic。
  • Maven 版本兼容:顺带确认 mvn -v 显示的 Maven 支持 Java 21(Spring Boot 4.0 要求),否则编译测试也会失败。

另一个关键变化:MockBean 的弃用

除了依赖,另一个常见报错来自测试注解:

Spring Boot’s @MockBean and @SpyBean support has been deprecated in this release, in favor of @MockitoBean and @MockitoSpyBean support.

旧版广泛使用的 @MockBean(用于在 Spring 上下文中注入 Mockito mock)已被弃用,取而代之的是 Spring Framework 原生的 @MockitoBean@MockitoSpyBean

迁移方式简单直接:

  • 将测试类或字段上的 @MockBean 替换为 @MockitoBean
  • @SpyBean 同理替换为 @MockitoSpyBean
  • 如果需要共享 mock 配置,可将 @MockitoBean 用作元注解创建自定义组合注解。

这一变化背后的动机是减少 Spring Boot 对 Mockito 的自定义包装,转向更标准、更轻量的 Framework 支持。同时,新注解在某些场景(如 @Configuration 类中)行为略有差异,但对于大多数测试类字段使用是直接替换。

本阶段感悟

测试部分的迁移看似“琐碎”,却最能体现 Spring Boot 4.0 的设计哲学:从隐式到显式,从 monolithic 到 modular。它迫使我们审视项目实际依赖的技术栈,避免不必要的 classpath 膨胀。同时,再次验证了完整测试套件在框架升级中的防护作用——没有它们,许多隐蔽问题会在生产环境才暴露。

如果你的项目测试覆盖率较高,这一阶段会相对顺利;反之,建议借迁移机会补齐测试。总之,按官方指南逐个添加所需 test starter,并全局替换 @MockBean,即可平稳过渡。


总结

这次升级最大的收获并不在于“成功跑起来”,而在于:

  • 明确了 Spring 官方推荐的升级节奏
  • 理解了多个 API 变更背后的设计动机
  • 验证了 测试在非功能性改动中的核心价值

Spring Boot 4.0 并不激进,但它不再纵容模糊与隐式行为。
如果你准备升级,建议和我一样:慢一点,但走稳。

在此,再次万分感谢潘老师给予我的机会。师傅领进门,修行在个人。我会继续努力,稳扎稳打,亦步亦趋。


姜姜
6 声望7 粉丝

行百里者半九十