Spring Boot 原理

介绍一下 Spring Boot 整体的启动流程?

Spring Boot 启动流程的核心是通过自动配置和上下文初始化,将零散的组件装配成一个可运行的应用上下文(ApplicationContext),整体分为 前置准备、上下文初始化、核心刷新、启动收尾 四大步

  1. 首先从 main 找到 SpringApplication.run(主类.class, args) 方法,在执行 run() 方法之前先 new 一个 SpringApplication 实例对象。

    实例化时做的具体核心内容:

    • 然后进行应用类型推断,判断当前应用是 Servlet 应用(Spring Web MVC)、Reactive 应用(Spring WebFlux)还是普通非 Web 应用。

    • 紧接着进行初始化器加载,从 META-INF/spring.factories 中加载所有 ApplicationContextInitializer 的实现类,用于在上下文刷新前进行自定义配置,包括 Spring Boot 配置的默认实现和开发者添加的自定义实现。

      这里也要注意版本差异:上面提到的 .imports 文件是”自动配置类”专属的注册方式,而 ApplicationContextInitializer、ApplicationListener、EnvironmentPostProcessor 这些扩展点,从 2.x 到 3.x 一直都是通过 spring.factories 注册的,所以在 3.x 的自动配置原理里听到 SpringFactoriesLoader 依然是对的,关键是要分清它在加载什么。

    • 接着加载所有实现ApplicationListener的类,监听启动过程中的应用生命周期事件

      进入 SpringApplication.run() 后启动事件监听机制,创建 SpringApplicationRunListeners 对象,它是所有监听器的 总调度器,发布 ApplicationStartingEvent 事件,通知所有监听器“应用要开始启动了”。监听器可以做一些前置工作

    • 推断出主启动类

    所以说这一步是启动前的资源预先加载的步骤

  2. 然后进入 run()方法,创建应用监听器 SpringApplicationRunListeners 开始监听

  3. 然后加载 Spring Boot 配置环境 ConfigurableEnviroment,把配置环境 Enviroment 加入监听对象中

    这一步是环境准备,为整个应用提供配置来源,是配置中心初始化,把所有外部配置统一抽象成 Environment,供后续上下文和 Bean 使用。

  4. 然后进行上下文初始化,创建应用上下文 ConfigurableApplicationContext,当作 run 方法的返回对象。

    这里会根据推断的应用类型,创建对应类型的上下文,然后调用所有加载的 Initializer 对上下文进行定制化配置,通知所有监听器应用开始启动。这一步是容器的空壳创建,只完成了基础结构搭建,还没开始加载 Bean。

  5. 最后创建 Spring 容器,调用 refreshContext(context)进行容器上下文刷新,实现 starter 自动化配置和 Bean 加载和实例化,发布 ApplicationReadyEvent通知应用就绪。

Spring Boot 只是在 Spring refresh() 之前加了自动配置、环境准备、内嵌容器等封装,核心容器逻辑还是 Spring 原生的。

注意 3.0 的版本差异(如果被问到”2.x 和 3.x 的区别”可以主动带出来):Spring Boot 3.0 之后,自动配置的注册方式发生了变化,META-INF/spring.factories 中的 EnableAutoConfiguration 键不再被读取,取而代之的是 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件。所以说,2.x 时代写的老教程、老 starter 如果还在用 spring.factories 注册自动配置,直接搬到 3.x 上会失效,而且不会报错,只是配置默默不生效,这类问题排查起来很费时间。除此之外,2.x 的 spring.factories 里的其他键(例如 ApplicationContextInitializer、ApplicationListener、EnvironmentPostProcessor)在 3.x 仍然有效,废弃的只是自动配置这一个用途,这一点要说清楚,不然容易答过头。

Spring Boot 和 Spring 有什么区别?讲解一下 Spring Boot 的核心优势?为什么要用 Spring Boot?

Spring Boot 是基于 Spring 框架的快速开发脚手架,核心是 “约定大于配置”,解决了 Spring 框架配置繁琐、依赖管理复杂的问题。

而 Spring 是一套完整的企业级开发框架,核心是 IOC(控制反转)和 AOP(面向切面编程),提供基础的核心能力,但需要大量手动配置。

简单说:Spring 是 基础能力库,Spring Boot 是 基于 Spring 的高效的开发脚手架。

核心特点如下

  1. 约定大于配置

    Spring Boot 内置了大量默认配置,而且无需手动编写 XML 配置即可启动,而且使用注解就可以进行配置,比如开发一个 Web 项目,只需引入spring-boot-starter-web依赖,就会自动配置一系列框架

  2. 起步依赖 Starter

    将常用的依赖组合打包成 Starter,面对一个开发场景,直接引入 starter 就可以包含一系列的开发依赖

  3. 自动配置并且简化配置

    基于条件注解@Conditional等,根据类路径下的依赖、配置文件等自动创建 Bean

    而且不用各种复杂的 XML 配置,使用配置文件加上注解,可以让配置很轻松,把更多精力放在业务逻辑开发上

  4. 内嵌服务器

    内置 Tomcat、Jetty、Undertow 等 Web 服务器,无需手动部署 WAR 包到外部服务器。项目可直接打成 JAR 包,通过java -jar启动,部署、测试、运维更简单;

  5. 简化监控与运维

总之,使用 Spring Boot,相比传统的Spring MVC,开发效率提升非常明显。

什么是自动装配?Spring Boot自动装配原理是什么?

Spring Boot 的自动装配原理是基于 Spring Framework 的条件化配置和@EnableAutoConfiguration注解实现的。

自动装配就是通过注解或一些简单的配置就可以在 Spring Boot 的帮助下很方便的开启和配置各种功能,比如数据库访问、Web开发。

这种机制允许开发者在项目中引入相关的依赖,Spring Boot 将根据这些依赖自动配置应用程序的上下文和功能。

Spring Boot 定义了一套接口规范,这套规范规定:

  • Spring Boot 在启动时会扫描外部引用 jar 包中的自动配置元数据文件(Spring Boot 2.7 以前是 META-INF/spring.factories,2.7 及以后是 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports),将文件中配置的类型信息加载到 Spring 容器,并执行类中定义的各种操作。对于外部 jar 来说,只需要按照 Spring Boot 定义的标准,就能将自己的功能装进 Spring Boot。
  • 注意这里有个版本差异,是面试比较容易被追问的点:2.7 是过渡版本,两种文件都能用,如果 jar 包里存在 .imports 文件,就优先按它加载;而到了 3.0,spring.factories 里的 EnableAutoConfiguration 键彻底不再被读取,自动配置类必须注册到 .imports 文件里。所以说,如果你看的教程或者自己写的 starter 还在用 spring.factories 注册自动配置,在 Spring Boot 3.x 上会直接失效,而且不会有任何报错提示,只是配置默默不生效。

对于 Spring Boot 自动装配的原理

  • @SpringBootApplication 注解的内部有个@EnableAutoConfiguration, 这个注解是实现自动装配的核心注解,其中这个注解的内容如下

    • @AutoConfigurationPackage,将项目 src 中 main 包下的所有组件注册到容器中,例如标注了 Component 注解的类等

    • @Import({AutoConfigurationImportSelector.class}),是自动装配的核心

      AutoConfigurationImportSelector 是 Spring Boot 中一个重要的类,它实现了 ImportSelector 接口,用于实现自动配置的选择和导入。具体来说,它通过分析项目的类路径,然后进行条件判断,来决定应该导入哪些自动配置类。

从启动和源码来看整个流程:

  • 起点:@SpringBootApplication。它是一个组合注解,包含了@EnableAutoConfiguration、@ComponentScan和@SpringBootConfiguration。
  • 核心入口:@EnableAutoConfiguration。它通过@Import导入AutoConfigurationImportSelector。
  • 执行决策:AutoConfigurationImportSelector.selectImports()。这个方法内部会调用getAutoConfigurationEntry()来完成自动配置类列表的最终确定。
  • 加载候选。getCandidateConfigurations()方法读取候选清单:2.7 以前是通过 SpringFactoriesLoader 加载 META-INF/spring.factories 中 org.springframework.boot.autoconfigure.EnableAutoConfiguration 键对应的配置类名;2.7 及以后(尤其是 3.x)是通过 ImportCandidates.load() 加载各个 jar 包里的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,文件里就是一行一个自动配置类的全限定名,没有 key,比 properties 格式更简单。
  • 过滤与生效。对这些候选类进行去重、排除(exclude / excludeName)、条件过滤(@Conditional 系列),最终返回符合条件的配置类全限定名数组,由 Spring 容器加载。

关于两个加载器的顺序,也要说清楚:读取到候选清单只是”拿到了名单”,真正的去重、排除、条件过滤都发生在后面。而且这里还有一个工程上的坑,@ConditionalOnMissingBean 能否生效,取决于自动配置类和用户自己的配置类谁先被处理,Spring Boot 保证用户定义的 Bean 优先注册,所以自定义 Bean 才能覆盖自动配置的默认 Bean。

说下 Spring 由哪些重要的模块组成?

Spring 框架采用模块化设计,按职责划分成多个独立 jar 包,用哪个引哪个。核心可以拆成 5 大块:

  • 核心容器
    • spring-core:最底层,其他模块都依赖它。
    • spring-beans:Bean 的定义、装配、生命周期管理。
    • spring-context:在 beans 之上,提供应用上下文。
    • spring-expression:SpEL,让配置和注解具备”运行时计算”的能力
  • AOP
    • spring-aop:面向切面编程的核心。
    • spring-aspects:集成 AspectJ
  • 数据访问与事务
    • spring-jdbc:JDBC 的封装
    • spring-tx:事务管理
    • `spring-orm:ORM 的集成
  • Web 层
    • spring-web:Web 基础抽象
    • spring-webmvc:Servlet 栈的 MVC 框架
    • spring-webflux:响应式 Web 框架
  • 测试
    • spring-test

Bean 生命周期完整链路了解吗?BeanPostProcessor 与 BeanFactoryPostProcessor 的区别是什么?

先看整个生命周期,从容器启动到 Bean 销毁,一个单例 Bean 会依次经历这些阶段:

1
2
3
4
5
6
7
8
1. 实例化       newInstance(),此时只是个"空壳",属性都还没注入
2. 属性注入 处理 @Autowired / @Value,循环依赖的"提前暴露"就在这一步前后
3. Aware 回调 BeanNameAware → BeanFactoryAware → ApplicationContextAware
4. 初始化前 BeanPostProcessor#postProcessBeforeInitialization
5. 初始化 @PostConstruct → InitializingBean#afterPropertiesSet → init-method
6. 初始化后 BeanPostProcessor#postProcessAfterInitialization ← AOP 代理在这里生成
7. 使用中 放进一级缓存 singletonObjects,正常对外提供服务
8. 销毁 @PreDestroy → DisposableBean#destroy → destroy-method

有两个点值得特别记住

  • AOP 代理是在初始化后才生成的,所以在构造函数、@PostConstruct 里调用同类中被 @Transactional、@Async 增强的方法,拿到的是原始对象,动态代理增强不会生效,这也是自调用失效在生命周期上的解释。
  • 第 3 步到第 6 步的顺序不要搞反。@PostConstruct 在 afterPropertiesSet 之前执行,这也是为什么依赖注入完成后做初始化要用 @PostConstruct,而构造方法里不行,因为那时候属性还没注入。

再说两个后置处理器的区别,这也是面试最爱追问的地方,一句话概括就是

  • BeanFactoryPostProcessor 操作的是 BeanDefinition(图纸),在所有 Bean 实例化之前执行,能改的是这个 Bean 要怎么造;它的典型实现就是 ConfigurationClassPostProcessor,负责解析 @Configuration、@ComponentScan、@Import、@Bean,也就是把我们写的注解变成一份份 BeanDefinition。
  • BeanPostProcessor 操作的是 Bean 实例(造好的产品),在每个 Bean 初始化前后执行,能改的是这个 Bean 造出来之后长什么样;@Autowired 的注入、@Async/@Transactional 的代理、AOP 织入都靠它。
维度 BeanFactoryPostProcessor BeanPostProcessor
操作对象 BeanDefinition(元数据) Bean 实例
执行时机 refresh() 中 invokeBeanFactoryPostProcessors,所有单例实例化之前 每个 Bean 初始化前后(postProcessBeforeInitialization / After)
执行次数 整体一次 每个 Bean 各一次
典型实现 ConfigurationClassPostProcessor、PropertySourcesPlaceholderConfigurer AutowiredAnnotationBeanPostProcessor、AbstractAutoProxyCreator

这里有一个实践中的问题:如果 BeanPostProcessor 自身依赖了别的 Bean,会导致那个依赖被”提前初始化”,启动日志里会出现 is not eligible for getting processed by all BeanPostProcessors 这样的警告。原因是 BeanPostProcessor 必须在其他 Bean 之前就位,所以它依赖的 Bean 只能被提前创建,从而跳过了后续的 BeanPostProcessor 增强(比如没被 AOP 代理)。所以这类组件的依赖要尽量简单,必要时用 ObjectProvider 延迟获取。

讲解一下一次 Web 请求的完整链路。其中涉及到的其他内容也简单串一下讲讲。

完整链路是这样的,从 TCP 连接建立到响应写回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
客户端
↓
Filter 链(Servlet 容器层面)
↓
DispatcherServlet#doDispatch
↓
① HandlerMapping 找到 HandlerMethod(带 @RequestMapping 的那个方法)
↓
② HandlerAdapter 适配调用(RequestMappingHandlerAdapter)
↓
Interceptor#preHandle
↓
③ HandlerMethodArgumentResolver 解析参数(配合 HttpMessageConverter 反序列化请求体)
↓
④ 反射调用 Controller 方法
↓
⑤ HandlerMethodReturnValueHandler 处理返回值(@ResponseBody 走 HttpMessageConverter 序列化)
↓
Interceptor#postHandle
↓
⑥ HandlerExceptionResolver 处理抛出的异常(@ExceptionHandler 就在这一环)
↓
Interceptor#afterCompletion
↓
Filter 返回(chain.doFilter() 之后)
↓
客户端

请求里”九个组件”的分工

  • DispatcherServlet 是总调度,它自己不干活,只负责把请求分发给各个组件,所以它又被叫做”前端控制器”。
  • HandlerMapping 负责”找”:根据 URL 和 POST GET 这种请求方式匹配到 HandlerMethod,也就是具体哪个类的哪个方法,同时把匹配到的拦截器一起打包成 HandlerExecutionChain。
  • HandlerAdapter 负责”调”:因为 Handler 的形态可能有多种(注解方法、HttpRequestHandler、老的 Controller 接口),适配器屏蔽了这些差异,统一调用方式。
  • HandlerMethodArgumentResolver 负责”参数从哪来”:@PathVariable 从路径取、@RequestParam 从查询串取、@RequestBody 交给 HttpMessageConverter 反序列化。前面”参数绑定”那节讲的”请求体只能读一次”,根因就在这一层。
  • HandlerMethodReturnValueHandler 负责”结果往哪去”:返回 String 时可能是视图名,标了 @ResponseBody 时交给 HttpMessageConverter(Jackson)序列化成 JSON。
  • HandlerExceptionResolver 负责”出错了怎么办”:@ExceptionHandler 就挂在这一环,它解析不了才会继续往上抛,最终走到容器的 /error 兜底(这部分可以对照后面统一异常处理那节)。
  • HttpMessageConverter 负责”Java 对象和 HTTP 报文的互转”:MappingJackson2HttpMessageConverter 处理 JSON,StringHttpMessageConverter 处理文本,Content-Type 决定了选哪个。
  • Filter 与 Interceptor 负责”横切”:前者在容器层、后者在 Spring MVC 层,执行时机和选型原则在前面拦截器那节已经详细写过,这里就不重复了。
  • MultipartResolver / LocaleResolver / ThemeResolver 属于旁路能力,分别处理文件上传、国际化和主题,问到时提一句即可。

Spring / Spring Boot 框架知识

Spring IoC 和 AOP 介绍一下?IoC 和 AOP 是通过什么机制来实现的?

  • IoC:控制反转,它是一种创建和获取对象的技术思想,依赖注入 DI 是实现这种技术的一种方式。

    传统开发过程中,我们需要通过 new 关键字来创建对象。使用 IoC 思想开发方式的话,我们不通过 new 关键字创建对象,而是通过 IoC 容器来帮我们实例化对象,把创建对象和对象之间的依赖管理的权利从自己身上交给了 Spring 容器,这就是控制反转的含义。 通过 IoC 的方式,可以大大降低对象之间的耦合度。

  • AOP:是面向切面编程,能够将那些与业务无关,却为业务模块所共同调用的逻辑封装起来,以减少系统的重复代码,降低模块间的耦合度。

    Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理。

在 Spring 框架中,IOC 和 AOP 结合使用,可以更好地实现代码的模块化和分层管理。例如:

  • 通过 IOC 容器管理对象的依赖关系,然后通过 AOP 将横切关注点统一切入到需要的业务逻辑中。
  • 使用 IOC 容器管理 Service 层和 DAO 层的依赖关系,然后通过 AOP 在 Service 层实现事务管理、日志记录等横切功能,使得业务逻辑更加清晰和可维护

Spring IoC 实现机制

  • 反射:Spring IoC 容器利用 Java 的反射机制动态地加载类、创建对象实例及调用对象方法,反射允许在运行时检查类、方法、属性等信息,从而实现灵活的对象实例化和管理。
  • 依赖注入:IoC 的核心概念是依赖注入,即容器负责管理应用程序组件之间的依赖关系。Spring 通过构造函数注入、属性注入或方法注入,将组件之间的依赖关系描述在配置文件中或使用注解。
  • 设计模式 - 工厂模式:Spring IoC 容器通常采用工厂模式来管理对象的创建和生命周期。容器作为工厂负责实例化Bean 并管理它们的生命周期,将 Bean 的实例化过程交给容器来管理。
  • 容器实现:Spring IoC 容器是实现 IoC 的核心,通常使用 BeanFactory 或ApplicationContext来管理 Bean。BeanFactory是 IoC 容器的基本形式,提供基本的IoC功能;ApplicationContext是BeanFactory的扩展,并提供更多企业级功能。

Spring AOP 实现机制

Spring AOP的实现依赖于动态代理技术。

动态代理是在运行时动态生成代理对象,而不是在编译时,从而实现在不修改源码的情况下增强方法的功能。

Spring AOP支持两种动态代理:

  • 基于JDK的动态代理:使用java.lang.reflect.Proxy类和java.lang.reflect.InvocationHandler接口实现。这种方式需要代理的类实现一个或多个接口。
  • 基于CGLIB的动态代理:当被代理的类没有实现接口时,Spring 会使用 CGLIB 库生成一个被代理类的子类作为代理。CGLIB(Code Generation Library)是一个第三方代码生成库,通过继承方式实现代理。

什么是依赖注入?DI 的实现方式有哪些?

传统编程中,对象直接使用 new 关键字创建依赖,这种硬编码方式让代码紧耦合,而 Spring 通过 DI 实现了 IoC 的设计思想,把对象创建和依赖管理的控制权从业务代码转移给外部容器。这样,业务代码只需要声明需要什么依赖,不用关心如何获取。

依赖注入(DI)其实是一种设计模式,通过外部容器向对象提供其所需的依赖对象,而不是对象内部主动创建依赖。

Spring IoC 容器启动时会扫描指定包路径,通过反射机制识别带有@Component等注解的类,将它们实例化为 Bean 并存储在容器中。当某个 Bean 需要依赖时,容器会根据类型或名称自动匹配并注入相应的 Bean 实例。

DI的实现方式主要有三种

  1. 构造器注入:通过构造函数参数传入依赖
  2. 属性注入:通过 setter 方法或直接字段赋值
  3. 接口注入:通过实现特定接口来接收依赖

Spring 的态度是不支持接口注入,因为侵入性太强,违背 “面向接口编程” 的初衷,Spring 的 DI 实现仅覆盖构造器注入和设值注入。

在实际开发中,构造器注入因为能保证依赖的不可变性和完整性,被认为是最佳实践。

Spring框架作为DI容器的典型实现,通过 IoC 容器管理对象的生命周期和依赖关系。

你可以使用@Autowired注解实现自动装配,@Component、@Service、@Repository等注解标记组件让Spring管理。

当遇到循环依赖问题时,Spring 通过三级缓存机制解决单例Bean的循环依赖问题。简单来说,Spring 在 Bean 实例化和初始化过程中,会提前暴露正在创建的 Bean 引用,让其他依赖它的 Bean 能够获取到这个引用,从而打破循环依赖的死锁。不过要注意,构造器循环依赖 Spring 无法解决,这也是推荐使用构造器注入时需要注意的设计问题。

怎么理解SpringBoot中的约定大于配置

约定大于配置是 Spring Boot 的特点,通过预设合理的默认行为和项目规范,大幅减少开发者需要手动配置的步骤,从而提升开发效率和项目标准化程度。可以从以下几个方面来解释:

  1. 自动化配置:Spring Boot 提供了大量的自动化配置,通过分析项目的依赖和环境,自动配置应用程序的行为。开发者无需显式地配置每个细节,大部分常用的配置都已经预设好了。

    例如,引入spring-boot-starter-web后,Spring Boot 会自动配置内嵌 Tomcat 和 Spring MVC,无需像 Spring 那样还需要手动编写XML中的很多配置才能启动。

  2. 默认配置:Spring Boot 为诸多方面提供大量默认配置,如连接数据库、设置 Web 服务器、处理日志等。开发人员无需手动配置这些常见内容,框架已做好基本的决策。

  3. 约定的项目结构:Spring Boot 提倡特定的项目结构,通常主应用程序类(含 main 方法)置于根包,控制器类、服务类、数据访问类等分别放在相应子包。此约定使团队成员更易理解项目结构与组织,规范项目开发,新成员加入项目时能快速定位各功能代码位置,提升协作效率。

Spring Boot 用到了哪些经典的设计模式?

  • 代理模式:Spring 的 AOP 通过动态代理实现方法级别的切面增强。
  • 策略模式:Spring AOP 支持 JDK 和 Cglib 两种动态代理实现方式,通过策略接口和不同策略类,运行时动态选择,其创建一般通过工厂方法实现。
  • 观察者模式:Spring 观察者模式包含 Event 事件、Listener 监听者、Publisher 发送者,通过定义事件、监听器和发送者实现,观察者注册在 ApplicationContext 中,消息发送由 ApplicationEventMulticaster 完成。
  • 单例模式:Spring Bean 默认是单例模式,通过单例注册表实现
  • 工厂方法模式:Spring 中的 FactoryBean 体现工厂方法模式,为不同产品提供不同工厂。

Spring 框架应用启动后执行初始化任务的三种方式与顺序?

应用启动完成后执行,最标准的三种方式是 @PostConstruct、ApplicationRunner、CommandLineRunner。它们的执行顺序如下:

  • @PostConstruct

    最早,Bean 层面,在 Bean 的依赖注入完成后,立即执行。而且它属于 Bean 生命周期的一部分,此时 Spring 容器尚未完全刷新完毕。

  • ApplicationRunner 与 CommandLineRunner

    这两个接口的调用发生在 SpringApplication.run() 方法的最后一步,此时容器已完全就绪,Web 服务器也启动完成。

    两者都可以通过 @Order 或实现 Ordered 接口来控制顺序,值越小越先执行

    同一个类同时实现这两个接口,ApplicationRunner 的 run 方法会先于 CommandLineRunner 执行

如果是 @PostConstruct 抛异常,会导致该 Bean 创建失败,通常直接导致应用启动失败,而且逻辑耗时过长会直接拖慢应用启动速度。如果是 ApplicationRunner/CommandLineRunner 抛异常,同样会导致 应用启动失败,因为它们在 SpringApplication.run 内部调用,异常会传播出去。

总结来说,如果是数据预热、缓存加载,我首选 ApplicationRunner,因为此时应用已完全就绪且参数处理方便;如果是Bean 自身的属性校验或资源准备,我会用 @PostConstruct;如果只是临时记录启动日志,CommandLineRunner 最直接。

拦截器 Filter 和过滤器 Interceptor 的区别?各自的使用场景和执行时机?结合 Spring 框架聊聊。

一次 HTTP 请求进入 Spring Boot 应用,经过的严格顺序是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
客户端
↓
Filter 链(Servlet 容器层面)
↓ chain.doFilter() 之前 = 前置逻辑
DispatcherServlet#doDispatch
↓
HandlerMapping 找到 HandlerMethod
↓
Interceptor#preHandle ← 返回 false 就到此为止,Controller 不会执行
↓
HandlerAdapter → 参数解析 → Controller 方法执行
↓
Interceptor#postHandle ← 只有 Controller 正常返回才执行
↓
返回 ModelAndView → 视图渲染 / @ResponseBody 序列化
↓
Interceptor#afterCompletion ← 无论成功、失败、抛异常都会执行
↓ chain.doFilter() 之后 = 后置逻辑
Filter 返回
↓
客户端

过滤器属于 Servlet 规范,工作在 Web 容器层面,主要处理字符编码、请求日志、跨域等底层 Web 层面的通用逻辑。

拦截器是 Spring MVC 的组件,运行在 Spring 容器内部,在 Controller 方法执行前后进行拦截。

过滤器的生命周期遵循 Servlet 规范的标准流程,过滤器只能访问ServletRequest和ServletResponse这些原始的 Servlet 对象,它看不到 Spring 的上下文,也无法进行依赖注入。

OncePerRequestFilter 为什么必要

OncePerRequestFilter 是 Spring 提供的一个抽象过滤器基类,核心作用是保证在同一个请求中,过滤器的核心逻辑只被执行一次。

因为一次 HTTP 请求不等于一次 Filter 调用。请求转发,异步分发等会多次调用。OncePerRequestFilter 通过一个标记位来解决这个问题:它在 doFilter 方法中检查请求属性里是否已经有“已过滤”的标记来实现。

拦截器是 Spring MVC 框架的组件,所以它运行在 Spring 容器内部,不仅可以访问 HttpServletRequest 和HttpServletResponse,还能通过@Autowired注入任何 Spring 管理的 Bean,甚至可以获取到即将执行的 Controller 方法信息。

在实际开发中有一个简单的判断原则

  • 如果这个功能需要访问 Spring 容器中的 Bean,或者需要处理业务逻辑,就选择拦截器;
  • 如果只是对HTTP请求响应做一些通用的预处理,就用过滤器。

例如,过滤器的经典应用场景包括字符编码处理,这是最基础但也最重要的场景。

在处理中文参数时,会用过滤器统一设置字符编码,确保所有请求都能正确处理中文字符。CORS跨域处理也是过滤器的典型应用,前后端分离的项目中,经常用过滤器来处理跨域问题,因为跨域本质上是HTTP协议层面的限制,用过滤器处理最合适。

而拦截器的应用场景要突出它与业务逻辑的紧密结合。用户权限校验是最常见的场景,需要调用UserService来验证token,还要根据用户角色判断是否有访问特定接口的权限。

Spring Boot 如何实现自动配置?Spring Boot 三种配置你了解吗?谁覆盖谁?说一下 Spring Boot 的配置文件的优先级,配置覆盖方式。application.yml 和 application.properties 中有相同的配置,他们的覆盖关系能说清楚吗?

  • 属性配置:application.yml/properties,通过外部文件、环境变量、命令行定义的配置项。
  • Java Config:就是 @Configuration + @Bean,开发者自己写的配置类,显式声明 Bean。
  • @EnableXxx / 自动配置:Spring Boot 官方或第三方 Starter 通过 @EnableAutoConfiguration(或封装成 @EnableXxx)自动装配的配置类。

覆盖规则如下

  • 属性配置 vs Java Config:属性配置(外部配置)优先级更高。Spring Boot 的 Environment 在初始化 Bean 时,外部配置会注入到 Bean 的属性中,从而覆盖 Java Config 里硬编码的值。唯一的例外通常是 ApplicationContext 的创建源(sources)。
  • Java Config vs 自动配置:用户手写的 Java Config 覆盖自动配置。自动配置类大量使用了 @ConditionalOnMissingBean。意思是:如果容器里已经存在了同类型的 Bean(由用户的 Java Config 创建),自动配置就会退让(Back off),不再创建默认的 Bean。
  • 属性配置 vs 自动配置:属性配置用于调整自动配置的行为。例如,自动配置会创建默认的 DataSource,但具体连接哪个数据库、用户名密码是什么,由 application.yml 里的属性决定。因此也是属性配置优先级更高

所以说,对于类似 如果我在 application.yml 里定义了 server.port,同时在 @Bean 里返回了一个 WebServerFactoryCustomizer 把端口改成了 8081,最终生效的是哪个?这种的问题,就很清楚了

这取决于注入时机。如果属性是绑定到 ServerProperties 这个 Bean 上的,那么最终 ServerProperties 里的值会是 application.yml 的值,因为外部配置优先级高

提一下配置属性的加载优先级

  • 由高到低,命令行参数 > Java 系统属性 > 操作系统环境变量 > 配置文件(jar 包外 > jar 包内),其中 application-{profile}.properties 优先级高于不带 profile 的版本

所有配置最终都会被放进 Environment 的 PropertySource 列表中,列表越靠前,优先级越高,查找时命中即止。

常见的配置源优先级从高到低大致如下

  • 命令行参数:如 java -jar app.jar --server.port=8081。
  • Java 系统属性:-Dserver.port=8082。、
  • 操作系统环境变量:如 SERVER_PORT=8083。
  • Jar 包外部的配置文件:如 file:./config/application.yml。
  • Jar 包内部的配置文件:如 classpath:/config/application.yml 或 classpath:/application.yml

然后,带有 spring.profiles.active=prod 时,application-prod.yml 的优先级总是高于普通的 application.yml。然后 Jar 包内部带 profile 的配置优先级比 Jar 包外部不带 profile 的配置更高。

在目前主流的 Spring Boot 2.4.0 及以上版本中,application.properties 的优先级高于 application.yml。也就是说,如果两个文件里写了同一个配置项,.properties 里的值会覆盖 .yml 里的值。

然后,spring.config.import允许你在一个配置文件中导入另一个配置文件,且被导入文件的优先级高于触发导入的文件

一些额外的内容

在 YAML 中,如果多个 Profile 都配置了同一个 List 属性,Spring Boot 不会把两个列表合并,而是直接用高优先级 Profile 中的整个列表覆盖低优先级的列表

@PropertySource 是 Spring 提供的加载自定义配置文件的注解,它默认只能加载 .properties 文件,无法直接加载 .yml 文件,而且通过 @PropertySource 加载的配置,其优先级通常低于 application.properties 等系统默认配置文件,它无法覆盖主配置中的同名属性。

Web 参数绑定与 JSON 的问题你了解吗?为什么@RequestParam/@PathVariable/@RequestBody 混用导致报错?Spring Boot 中日期格式怎么处理,日期反序列化失败的问题你如何解决?

@RequestParam、@PathVariable、@RequestBody 混用导致报错,根源在于 HTTP 请求体(Body)只能被读取一次。

具体来说,这三个注解背后对应的是不同的 HandlerMethodArgumentResolver(参数解析器),它们处理的数据来源完全不同:

  • @PathVariable:从 URL 路径模板中取值(如 /users/{id} 中的 id)。
  • @RequestParam:从 URL 查询字符串或表单数据中取值(如 ?name=xxx 或 x-www-form-urlencoded 表单)。
  • @RequestBody:通过 HttpMessageConverter(通常是 Jackson)把整个请求体反序列化为一个对象。它读取的是 InputStream,而 Servlet 的请求体流只能被读取一次,读完后无法再次读取。

所以,当你混用 @RequestBody 和 @RequestParam 时,如果 @RequestBody 先读取了请求体,后续的 @RequestParam 再去尝试解析同一个请求体(比如表单数据),就会因为流已关闭而报错。反过来,如果请求是 application/json 格式,但你的方法签名里用了 @RequestParam 来接收本该在 Body 里的 JSON 字段,同样会因为找不到对应的请求参数而报 400 错误。

所以说,如果你需要同时接收 JSON Body 和额外的参数,额外的参数必须放在 URL 查询参数(?key=value)或 路径变量(/path/{id})中,而不是试图从 Body 里再次读取。

类似的问题,能不能同时用两个 @RequestBody,这种,也就一个道理了。

日期问题在 Spring Boot 中主要分为接收请求参数和接收响应体 JSON 两个场景,使用的注解和配置不同。

接收请求参数时的日期,一般是使用 @DateTimeFormat(pattern = "yyyy-MM-dd")。

接收 JSON Body 时的日期反序列化,一般是使用 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")。告诉 Jackson 如何反序列化 JSON 字符串到日期对象。

Spring Boot 3.x vs 2.x 的区别你了解吗?用过 Spring 4.x 吗?Spring Boot 3.x vs 4.x 的区别你了解吗?3.x和4.x都带给你什么样的感受

Spring Boot 实践相关

如何在 Spring Boot 中定义和读取自定义配置?Spring Boot 配置文件加载优先级你知道吗?

Spring Boot 支持多种配置文件格式,而自定义配置项的核心就是定义配置项 + 读取配置项

先在配置文件中定义自定义配置项,这就是我们把配置写到配置文件里

1
2
3
4
5
6
7
8
9
10
# application.yml
app:
database:
url: jdbc:mysql://localhost:3306/test
username: root
password: 123456
servers:
- 192.168.1.100
- 192.168.1.101
- 192.168.1.102

而读取配置项有几种方式

  1. @Value 注解:${配置项key:默认值}

    直接注入单个配置项,需要注意配置文件需要成为 Bean 交给 Spring Boot 管理,加@Component

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Component;

    // 必须交给Spring容器管理,否则无法注入
    @Component
    public class AppConfig {
    // 注入单个配置项,冒号后是默认值
    @Value("${app.name:default-app}")
    private String appName;

    @Value("${app.version:1.0.0}")
    private String appVersion;

    // 注入嵌套配置项
    @Value("${app.database.url}")
    private String dbUrl;
    }
  2. @ConfigurationProperties

    它是批量绑定配置项,适合配置项多、有层级的场景。一般情况下,我们需要自定义一系列自定义配置,就使用这个注解编写对应的配置类,业务中可以直接注入使用

    一般需要如下几步

    1. 编写配置绑定类,也就是在这里声明里的yaml中需要绑定哪些自定义配置

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      import org.springframework.boot.context.properties.ConfigurationProperties;
      import org.springframework.stereotype.Component;

      import java.util.List;

      // 前缀:绑定yaml配置文件中以app开头的配置项
      @ConfigurationProperties(prefix = "app")
      // Spring Boot 2.2+ 也可通过 @EnableConfigurationProperties 启用
      @Component
      // 必须有getter/setter,否则无法绑定
      @Getter
      @Setter
      public class AppProperties {
      // 字段名与配置项后缀一致(name → app.name)
      private String name;
      private String version;
      private String env;

      // 嵌套配置(对应 app.database)
      private Database database;
      // 数组配置(对应 app.servers)
      private List<String> servers;

      // 内部类:绑定嵌套配置
      public static class Database {
      private String url;
      private String username;
      private String password;
      }
      }

      如果不写 @Component,可在启动类加 @EnableConfigurationProperties

    2. 然后就可以使用配置,注入依赖后直接使用就可以了

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.web.bind.annotation.GetMapping;
      import org.springframework.web.bind.annotation.RestController;

      @RestController
      public class ConfigController {
      @Autowired
      private AppProperties appProperties;

      @GetMapping("/config")
      public String getConfig() {
      return "应用名称:" + appProperties.getName() +
      "<br>数据库地址:" + appProperties.getDatabase().getUrl() +
      "<br>服务器列表:" + appProperties.getServers();
      }
      }

然后,Spring Boot 会从多个位置加载配置文件,优先级高的配置会覆盖优先级低的,从高到低优先级如下

  1. 命令行参数

    启动时通过启动命令传入的参数,会覆盖所有配置,适合临时修改配置。

  2. 操作系统环境变量

    Spring Boot 会自动识别操作系统的环境变量

  3. JVM系统属性

    启动时通过 JVM 属性传入的配置,-Dapp.name=prod 传入,如 java -Dapp.name=prod -jar app.jar。

  4. 外置的配置文件

    (项目根目录 config/ > 项目根目录)

    项目根目录下的 config/ 文件夹:./config/application.yml

    项目根目录:./application.yml

  5. 内置的配置文件

    (classpath/config/ > classpath 根目录)

    类路径(resources)下的 config/ 文件夹:classpath:/config/application.yml

    类路径根目录:classpath:/application.yml

  6. 配置类中的默认值

    如 @Value("${app.name:default}") 中的 default,是最后兜底的默认值

如果配置了多环境,优先级规则如下

  • 通过 spring.profiles.active=dev 指定环境后,对应环境的配置(如 application-dev.yml)会覆盖 application.yml 中的同名配置;
  • 多环境配置的加载优先级遵循上述「外置 > 内置」规则。

如何实现自定义的AOP功能?如何在AOP中获取方法参数和返回值?ProceedingJoinPoint的使用?

在Spring中,你可以通过@Aspect注解标记一个 @Component 的 Bean 作为 AOP 切面对象,配合@Around、@Before、@After等注解快速实现切面逻辑

在实际项目中,AOP的应用场景主要集中在四个方面:日志记录、性能监控、权限校验和缓存处理。自定义注解配合AOP是一种优雅的设计思路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
String value() default "";
}

@Aspect
@Component
public class OrderAspect {

@Around("@annotation(operationLog)")
public Object logOperation(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable {
String operation = operationLog.value();
String userId = getCurrentUserId();

try {
Object result = joinPoint.proceed();
logService.recordSuccess(userId, operation, joinPoint.getArgs());
return result;
} catch (Exception e) {
logService.recordFailure(userId, operation, e.getMessage());
throw e;
}
}
}

切面最佳实践方面,建议遵循单一职责原则,一个切面只处理一类横切关注点,避免在切面中写复杂业务逻辑。切点表达式要精确,防止误拦截不相关方法。异常处理要谨慎,避免切面异常影响主业务流程。在Spring环境下,@Transactional就是经典的AOP应用,声明式事务通过切面自动管理事务边界。

那么,在 AOP 中获取方法参数和返回值主要通过 ProceedingJoinPoint 实现。这个接口是Spring AOP提供的连接点对象,专门用于环绕通知@Around中。

通过 joinPoint.getArgs() 可以获取目标方法的所有参数数组,获取返回值则需要调用 joinPoint.proceed() 执行目标方法,这个方法会返回目标方法的执行结果。

在@Around通知的整个处理中,先通过getArgs()获取入参进行前置处理,然后调用proceed()执行目标方法并捕获返回值,最后对返回值进行后置处理。

需要注意的是,proceed()方法必须被调用,否则目标方法不会执行。如果目标方法抛出异常,proceed()会将异常向上传播,这时你可以在catch块中处理异常情况。通过joinPoint.getSignature()还能获取方法签名信息,包括方法名、参数类型等元数据,这在动态日志记录和权限校验中特别有用。

写过 Spring Boot Starter 吗?

  1. 创建Maven项目

    首先,需要创建一个新的Maven项目。在 pom.xml 中添加 Spring Boot 的 starter parent 和一些依赖

    而且工程上更规范的做法是拆成两个模块,这也是 Spring 官方和大多数第三方 starter 的结构:

    • xxx-spring-boot-starter:只放 pom,不放代码,作用就是依赖聚合。用户只需要引这一个依赖,不用关心里面用了哪些组件;
    • xxx-spring-boot-autoconfigure:放真正的代码,自动配置类、配置属性类、要对外提供的服务类都在这里。
    1
    2
    3
    4
    5
    6
    7
    8
    <!-- xxx-spring-boot-starter/pom.xml:只做依赖聚合,一行代码都没有 -->
    <dependencies>
    <dependency>
    <groupId>com.example</groupId>
    <artifactId>xxx-spring-boot-autoconfigure</artifactId>
    <version>${project.version}</version>
    </dependency>
    </dependencies>

    那为什么要拆开?因为引用 starter 和使用自动配置是两件事。比如有的场景我只想要自动配置类(框架内部已有依赖),那么单独引 autoconfigure 模块即可;反过来如果我需要更精细的依赖控制,也可以只引 starter 再加自己的依赖,避免版本冲突。

  2. 然后添加自动配置

    在 autoconfigure 模块的 resources 文件夹下,添加自动配置的注册文件。

    Spring Boot 2.7 及以后(3.x 必须),注册文件是 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,内容是一行一个配置类全限定名,没有键值对:

    1
    com.example.starter.MyAutoConfiguration

    配套的自动配置类用 @AutoConfiguration 注解(2.7 引入的新注解,3.x 的标准写法):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // @AutoConfiguration 是 2.7+ 的注解,本质是组合注解,
    // 内部等价于 @Configuration(proxyBeanMethods = false) + @AutoConfigureBefore/After 支持
    @AutoConfiguration
    // 条件一:类路径上有 MyService 时才生效,避免用户没引依赖就报 ClassNotFound
    @ConditionalOnClass(MyService.class)
    // 条件三:用户配置了 xxx.enabled=true 才生效(默认开启,matchIfMissing = true)
    @ConditionalOnProperty(prefix = "xxx", name = "enabled", havingValue = "true", matchIfMissing = true)
    @EnableConfigurationProperties(MyProperties.class)
    public class MyAutoConfiguration {

    // 构造器注入,依赖声明为 final,比字段注入更符合规范
    private final MyProperties properties;

    public MyAutoConfiguration(MyProperties properties) {
    this.properties = properties;
    }

    // 条件二(必须有):容器里没有 MyService 时才创建,把覆盖权留给用户
    @Bean
    @ConditionalOnMissingBean
    public MyService myService() {
    return new MyServiceImpl(properties);
    }
    }

    补充一句,这三个条件注解的分工:

    • @ConditionalOnClass 判断的是”类路径上有没有这个类”,用在自动配置类上,作用是用户没引依赖时直接跳过;
    • @ConditionalOnMissingBean 判断的是”容器里有没有这个 Bean”,用在 @Bean 方法上,作用是把覆盖权让给用户,自定义 starter 里几乎是必须的;
    • @ConditionalOnProperty 判断的是”配置项的值”,用在自动配置类或 @Bean 上,作用相当于给这个功能加一个开关,用户关了就不生效。

    @ConditionalOnMissingBean 是自定义 starter 的”礼貌性”底线,缺了它,用户在业务里自己定义同类型的 Bean 就会直接报 BeanDefinitionOverrideException 或者被自动配置覆盖,starter 就变得”不可定制”。所以这个注解几乎是必写的,而且要注意它只在自动配置类里可靠生效(因为用户配置类先被处理)。

    如果自动配置之间有先后依赖,还要用 @AutoConfigureBefore(XxxAutoConfiguration.class) / @AutoConfigureAfter(XxxAutoConfiguration.class) 声明顺序,或者用 @AutoConfigureOrder,否则可能出现”我的 Bean 依赖别人还没注册”的问题。

    如果你维护的是 Spring Boot 2.7 以前的版本,那注册文件还是老的 META-INF/spring.factories 格式,键是 org.springframework.boot.autoconfigure.EnableAutoConfiguration:

    1
    org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.starter.MyAutoConfiguration

    这个写法在 Spring Boot 3.x 上是无效的,配置不会报错、只是静默不生效。

  3. 创建配置属性类

    一般情况下,我们的 starter 中需要有供用户定义的配置,创建一个配置属性类,使用@ConfigurationProperties注解来绑定配置文件中的属性。

    1
    2
    3
    4
    5
    6
    @ConfigurationProperties(prefix = "xxx")
    public class MyProperties {
    private boolean enabled = true;
    private String endpoint;
    // getter / setter...
    }

    再配合自动配置类上的 @EnableConfigurationProperties(MyProperties.class) 把它注册为 Bean,属性类本身就不需要标 @Component 了。

    然后别忘了加配置元数据处理器,它会在编译期扫描 @ConfigurationProperties 字段,生成 META-INF/spring-configuration-metadata.json:

    1
    2
    3
    4
    5
    6
    <!-- autoconfigure 模块的 pom.xml -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
    </dependency>

    有了这个元数据,用户在自己的 application.yml 里输入 xxx. 时 IDEA 才有自动提示,也能显示每个配置项的默认值和注释。如果需要补充一些处理器推不出来的说明(比如某个配置项的取值枚举、废弃提示),可以在 resources/META-INF/additional-spring-configuration-metadata.json 里手工追加。

  4. 创建服务和控制器

    创建一个服务类和服务实现类,以及一个控制器来展示和测试你的 starter 的功能。

  5. 写自动配置的单元测试

    自动配置类最怕条件注解不生效乱生效,所以用 ApplicationContextRunner 做断言,它能在不起完整 Spring Boot 应用、不连数据库的情况下,模拟不同的类路径和配置来验证条件逻辑。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    import org.springframework.boot.autoconfigure.AutoConfigurations;
    import org.springframework.boot.test.context.assertj.AssertableApplicationContext;
    import org.springframework.boot.test.context.runner.ApplicationContextRunner;

    import static org.assertj.core.api.Assertions.assertThat;
    import static org.assertj.core.api.Assertions.assertThatThrownBy;

    class MyAutoConfigurationTest {

    private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
    .withConfiguration(AutoConfigurations.of(MyAutoConfiguration.class));

    /** 场景一:默认条件下,Bean 应该被创建 */
    @Test
    void shouldCreateBeanByDefault() {
    contextRunner.run(context ->
    assertThat(context).hasSingleBean(MyService.class));
    }

    /** 场景二:用户自己定义了同类型 Bean,自动配置应该主动让位 */
    @Test
    void shouldBackOffWhenUserDefinesBean() {
    contextRunner
    .withBean(MyService.class, () -> new MyServiceImpl(null))
    .run(context -> {
    assertThat(context).hasSingleBean(MyService.class);
    assertThat(context.getBean(MyService.class)).isNotInstanceOf(MyServiceImpl.class);
    });
    }

    /** 场景三:关掉开关后不生效 */
    @Test
    void shouldNotCreateBeanWhenDisabled() {
    contextRunner
    .withPropertyValues("xxx.enabled=false")
    .run(context -> assertThat(context).doesNotHaveBean(MyService.class));
    }
    }
  6. 发布 Starter

    测试后,将你的 starter 发布到Maven仓库,可以是私有的或是公共的

  7. 使用 Starter

    在你的主应用的 pom.xml 中添加你的 starter 依赖,然后在 application.yml 或 application.properties 中配置你的属性。

Spring Boot 如何处理跨域请求(CORS)?

跨域只存在于浏览器端,之所以会跨域,是因为受到了同源策略的限制,同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。不同的请求类型,CORS的处理也不一样

  • 简单请求:GET/POST/HEAD,且请求头只有默认字段(如 Content-Type 为 text/plain),直接触发跨域检查;
  • 预检请求(OPTIONS):PUT/DELETE/ 自定义请求头,Content-Type 为 application/json 时,浏览器会先发送 OPTIONS 预检请求,确认后端允许跨域后,再发送真实请求。

Spring Boot 处理跨域的 3 种方案如下

  1. 全局跨域配置

    通过配置类统一设置跨域规则,覆盖所有接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.cors.CorsConfiguration;
    import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
    import org.springframework.web.filter.CorsFilter;
    import org.springframework.web.servlet.config.annotation.CorsRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

    @Configuration // 标记为配置类
    public class CorsConfig {

    // 方式 1:通过 WebMvcConfigurer 配置(更简洁,推荐)
    @Bean
    public WebMvcConfigurer corsConfigurer() {
    return new WebMvcConfigurer() {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**") // 对所有接口生效
    // 允许的跨域源(前端域名),* 表示允许所有(生产环境建议指定具体域名)
    .allowedOriginPatterns("*")
    // 允许的请求方法(GET/POST/PUT/DELETE 等)
    .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
    // 允许的请求头
    .allowedHeaders("*")
    // 是否允许携带 Cookie(跨域认证需要)
    .allowCredentials(true)
    // 预检请求的缓存时间(秒),减少 OPTIONS 请求次数
    .maxAge(3600);
    }
    };
    }

    // 方式 2:通过 CorsFilter 配置(更灵活,面试可提)
    /*
    @Bean
    public CorsFilter corsFilter() {
    CorsConfiguration config = new CorsConfiguration();
    // 允许的源(Spring Boot 2.4+ 推荐用 allowedOriginPatterns,替代 allowedOrigins)
    config.addAllowedOriginPattern("*");
    // 允许的请求方法
    config.addAllowedMethod("*");
    // 允许的请求头
    config.addAllowedHeader("*");
    // 允许携带 Cookie
    config.setAllowCredentials(true);
    // 预检缓存时间
    config.setMaxAge(3600L);

    // 配置生效的路径
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);

    return new CorsFilter(source);
    }
    */
    }
  2. 局部跨域配置 @CrossOrigin 注解

    针对单个接口 / 控制器生效,接口级 @CrossOrigin > 控制器级 @CrossOrigin > 全局配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import org.springframework.web.bind.annotation.CrossOrigin;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;

    @RestController
    // 对整个控制器的所有接口生效
    @CrossOrigin(origins = "http://localhost:8080", maxAge = 3600)
    public class TestController {

    // 对单个接口生效(优先级高于控制器注解)
    @GetMapping("/api/test")
    @CrossOrigin(origins = "*", allowedHeaders = "*", methods = {RequestMethod.GET, RequestMethod.POST})
    public String testCors() {
    return "跨域请求成功";
    }
    }
  3. 通过网关处理

    如果项目中使用 Spring Cloud Gateway/Zuul 网关,可在网关层统一配置跨域,避免每个微服务重复配置

一般情况下,除非开发时测试,否则禁止使用 allowedOriginPatterns("*"),必须指定具体的前端域名(如 http://www.xxx.com),避免安全风险。而且开启 allowCredentials(true) 时,前端必须同步设置 withCredentials: true;

Spring Boot 的项目结构是怎么样的?

一个正常的企业项目里,通常会使用一种通用的项目结构和代码层级划分,来规范化项目开发和便于团队协作

一般情况下,一个 Spring Boot 项目里会有这样的一个结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
com
└── pxd
└── project
├── ProjectApplication.java # 启动类(最顶层)
├── config/ # 配置类
├── controller/ # 控制层(API接口)
├── service/ # 业务层
│ ├── impl/ # 业务实现类
├── mapper/ # 数据访问层(DAO/Mapper)
├── entity/ # 实体/数据库映射
├── dto/ # 数据传输(请求/响应)
├── vo/ # 视图返回对象
├── common/ # 通用工具
│ ├── exception/ # 全局异常
│ ├── result/ # 统一返回结果
│ ├── util/ # 工具类
├── security/ # 安全/认证相关
└── aspect/ # AOP切面
  1. 配置层 config

    存放所有配置:Redis、Security、跨域配置、线程池等。包括你自定义配置中的内容。

  2. 控制器层 controller

    接收前端请求、参数校验、调用 service、返回统一结果。

  3. 业务逻辑层 service

    核心业务处理、调用DAO持久层,调用第三方服务等

  4. 数据访问层 dao

    和数据库交互,CRUD。

  5. 数据模型层 model

    1. 数据库实体 entity:对应数据库表结构
    2. 数据传输对象 dto /vo:
      • dto:前端传给后端的请求
      • vo:后端返回给前端的视图对象
  6. 通用模块 common

    里面可以放工具类,异常处理,自定义的一些注解啊什么

  7. 安全模块 security

    登录、认证、授权、JWT、OAuth2、2FA等安全业务在这里处理

  8. AOP 切面层 aspect

    日志、操作记录、权限校验、接口耗时统计、网站 UV 收集等

所以说,Spring Boot 的标准结构是典型的三层架构,Controller → Service → Mapper,结构清晰、职责明确、便于团队协作和维护。

image-20260310201218523

Spring Boot 当中,你是如何实现统一异常处理的?

在 Spring Boot 里,我是通过 @ControllerAdvice + @ExceptionHandler 实现全局统一异常处理的,同时配合自定义业务异常和统一返回格式,让所有接口的异常都能集中处理、格式一致、便于排查。

@ExceptionHandler异常处理器注解,指定方法处理某一类异常,比如业务异常、参数异常、系统异常。

@ControllerAdvice开启全局控制器增强,能捕获所有 Controller 抛出的异常。

这样代码不冗余、返回格式统一、安全不泄露堆栈、方便日志记录、便于前端处理。

  1. 首先肯定要定义统一返回结果

    所有接口成功 / 失败都用同一个格式

  2. 然后写一个自定义业务异常 BusinessException

    区分业务错误和系统错误

  3. 然后写一个全局异常处理器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    @RestControllerAdvice
    @Slf4j
    public class GlobalExceptionHandler {

    // 1. 处理业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<?> handleBusinessException(BusinessException e) {
    log.error("业务异常:{}", e.getMessage());
    return Result.fail(e.getCode(), e.getMessage());
    }

    // 2. 处理参数校验异常(@Valid 校验失败)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<?> handleValidException(MethodArgumentNotValidException e) {
    String msg = e.getBindingResult().getFieldError().getDefaultMessage();
    return Result.fail(400, "参数错误:" + msg);
    }

    // 3. 兜底:处理所有未知系统异常
    @ExceptionHandler(Exception.class)
    public Result<?> handleException(Exception e) {
    log.error("系统异常", e);
    return Result.fail(500, "服务器繁忙,请稍后重试");
    }
    }

这样,所有 Controller 不写 try/catch,直接抛自定义业务异常,代码非常干净,而且能够全局捕获,分类处理,让异常不再飞升老冯

Spring Boot 中,你如何结合 Java 的多线程去做异步任务进行异步处理的?如何使用@Async,参数怎么算的?@Async什么时候会失效?为什么它能和 @Transactional 打架?

在传统的同步请求中,主线程会阻塞等待每一个任务完成,发送邮件、生成报表、调用第三方接口,这种完全可以异步的耗时任务,我们就可以开启异步处理,让 Spring 交给辅助线程池处理,主线程立即返回响应。

使用 @Async 是 Spring 提供的一种基于 AOP 的简便实现。

首先,就是在启动类或配置类上标注 @EnableAsync。然后,在方法上标注注解@Async。如果需要获取返回值,推荐使用 CompletableFuture

1
2
3
4
@Async
public CompletableFuture<String> doTask() {
return CompletableFuture.completedFuture("任务完成");
}

线程池参数核心是从流量模型倒推

  • CPU密集型任务:线程数 = CPU核心数 + 1
  • IO密集型任务:线程数 = CPU核心数 × (1 + 等待时间/计算时间) 【线程数 = CPU核心数 × 2】

而@Async 失效的根本原因就一个:代理没生效。

Spring 的 @Async 底层是通过 AOP 代理来实现的,只有调用走代理对象的方法,才能被拦截然后丢到线程池里执行。任何绕过代理的情况,都会让 @Async 变成摆设。

最常见的失效场景有这几种:

  1. 同一个类里内部调用。

    在 Service 的 A 方法里直接调 this.B(),这个 B 方法上的 @Async 压根不生效。因为 this 指向的是原始对象,不是代理对象,调用根本没经过 AOP 拦截。

    增强逻辑挂在代理对象上,不在原始对象上

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Service
    public class OrderService {
    public void createOrder() {
    // 失效!this 指向原始对象,不是代理
    this.sendNotification();
    }

    @Async
    public void sendNotification() {
    // 期望异步执行,但实际是同步的
    }
    }
  2. 方法不是 public 的

    Spring 默认用的是基于接口的 JDK 动态代理或者基于类的 CGLIB 代理,这两种代理都只能拦截 public 方法

  3. 不能是 static 方法

    静态方法属于类,不属于 Spring Bean 实例,无法被代理

  4. 压根没开启异步支持

    @Async 不是开箱即用的,必须在配置类或启动类上加 @EnableAsync,Spring 才会去扫描 @Async 注解并创建代理。不加这个注解,@Async 就是个装饰品。

  5. 返回值类型不对。

    @Async 方法的返回值只能是 void、Future、CompletableFuture 这几种。你要是让它返回个普通 String,其实也不会报错,方法也执行了,但是调用方拿到的就是 null,异步执行的结果你拿不到,在需要结果的场景下,看起来好像就失效了。

@Async 的实现靠的是 AsyncAnnotationBeanPostProcessor 这个后置处理器。Spring 容器启动时,这个处理器会扫描所有 Bean,发现有 @Async 注解的方法,就给这个 Bean 套一层代理。

代理对象在方法被调用时,会先判断方法上有没有 @Async。有的话,就把这次调用包装成一个 Callable 任务,丢给 TaskExecutor 线程池去执行,然后立即返回。这就是异步的本质。

而且@Async 默认用的是 SimpleAsyncTaskExecutor,这玩意每来一个任务就 new 一个线程,没有线程复用,生产环境直接用会出事。正确做法是自定义 ThreadPoolTaskExecutor,然后在 @Async 注解里指定用哪个线程池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@EnableAsync
public class AsyncConfig {

@Bean(name = "executor1")
public Executor executor1() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("AsyncExecutor1-");
executor.initialize();
return executor;
}
}
1
2
3
4
5
6
7
8
9
@Service
public class AsyncService {

@Async("executor1")
public void performTask1() {
// 任务1的逻辑
System.out.println("Executing Task1 with Executor1");
}
}

关于上下文透传也需要注意,ThreadLocal、SecurityContext等,都是绑定在当前线程上的。任务一提交到线程池,就换了一个线程执行,这些上下文会丢失。可以使用TransmittableThreadLocal(TTL),作为阿里开源的方案,能解决线程池复用导致的上下文污染问题。

而和 @Transactional 打架的问题,一般是这样、

  • @Async 方法内部没有调用方的事务,会把方法丢到另一个线程执行,而 Spring 的事务是绑定线程,TransactionSynchronizationManager 用 ThreadLocal 存连接。所以调用方的事务不会传播到异步线程里。异步方法里如果直接查库,走的是自己的新连接,和调用方事务无关。

  • 在事务里调 @Async,任务可能读不到数据,因为主线程事务还没提交,异步线程就去操作库,会有问题,所以说,等事务提交后再执行异步任务。

    1
    2
    3
    4
    5
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async("bizExecutor")
    public void onOrderCreated(OrderCreatedEvent event) {
    // 此时主事务已提交,能读到数据
    }

关于异常的出合理,@Async 方法如果返回 void,异常不会抛给调用方,默认只打日志,很容易被忽略。需要实现 AsyncUncaughtExceptionHandler:

1
2
3
4
5
6
7
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("异步任务异常, method={}", method.getName(), ex);
// 可接告警、落库、补偿
};
}

如果返回 CompletableFuture,异常会包在 future 里,调用方 join() 或 get() 时能拿

Spring Boot 中你如何实现定时任务?

Spring Boot 内置了对定时任务的支持,实现起来非常简单

  1. 开启定时任务: 在启动类或配置类上标注 @EnableScheduling。

  2. 创建定时方法: 在 Bean 的方法上标注 @Scheduled。

    1
    2
    3
    4
    5
    6
    7
    8
    @Component
    public class MyTask {

    @Scheduled(fixedRate = 5000) // 每隔5秒执行一次
    public void task() {
    System.out.println("执行定时任务: " + LocalDateTime.now());
    }
    }

@Scheduled 注解的参数决定了定时的情况

  • fixedRate:固定频率,以任务开始执行的时间点算起。例如 fixedRate = 5000,无论任务执行多久,每隔 5 秒都会尝试启动下一个任务。
  • fixedDelay:固定延迟,以任务执行完成的时间点算起。例如 fixedDelay = 5000,任务执行完后,再等 5 秒才执行下一个。
  • cron:使用 Cron 表达式,最常用,因为能通过表达式控制精确时间。格式为[秒] [分] [时] [日] [月] [周]

我们都知道 JUC 中有一个专门用于执行定时任务的线程池,那么 Spring 的定时任务也是这样吗?是这样,但是

  • Spring 默认使用单线程的调度器。

    什么意思,如果你有多个定时任务(Task A 和 Task B),如果 Task A 阻塞了,Task B 即使到了时间也不会执行。

所以说,我们需要自定义 ScheduledExecutorService 线程池,设置合理的核心线程大小

1
2
3
4
5
6
7
8
9
10
@Configuration
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10); // 设置核心线程池大小
scheduler.setThreadNamePrefix("my-scheduled-task-");
return scheduler;
}
}

分布式场景下的定时任务也有一些要注意的内容

  • 例如,在微服务架构下,如果你部署了 3 个服务实例,@Scheduled 会在 3 台机器上同时运行,导致任务重复执行

  • 怎么办?

    1. 分布式锁

      使用 Redisson 配合 Redis 或 MySQL。在执行任务前尝试抢占锁,抢到的机器执行,抢不到的跳过。

    2. 分布式任务调度平台

      • XXL-JOB:支持中心化管理、调度中心与执行器分离、失败重试、执行日志监控、分片任务等。
      • Quartz:老牌调度框架,功能强大但配置较复杂。

Spring Boot 中你如何使用拦截器,说说你在什么业务下如何使用的拦截器?

我用拦截器解决的是需要 Spring 上下文 + 需要知道目标方法的 那类

需要知道目标方法是因为,要知道这次请求最终要进哪个 Controller 的哪个方法,从而读取方法上的注解。

Filter 只能看到 ServletRequest,拿不到这次请求最终要进哪个 Controller 的哪个方法,也没法优雅地做依赖注入。

注册方式是 WebMvcConfigurer#addInterceptors

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

private final AuthInterceptor authInterceptor;
private final PermissionInterceptor permissionInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/auth/login",
"/api/auth/register",
"/api/wechat/callback",
"/api/public/**",
"/error",
"/swagger-ui/**", "/v3/api-docs/**",
"/actuator/health"
)
.order(1);

registry.addInterceptor(permissionInterceptor)
.addPathPatterns("/api/**")
.order(2); // order 决定 preHandle 正序、afterCompletion 逆序
}
}

而我们项目里有三件事恰好都需要这两样东西:

  1. 用户上下文的建立与清理:从 Token 解析出用户身份,放到 ThreadLocal 里,供整个请求链路的业务代码直接取用;

    拦截器负责把结果落到 Spring 的上下文里,并负责在请求结束时清理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    @Component
    @RequiredArgsConstructor
    public class AuthInterceptor implements HandlerInterceptor {

    private final UserService userService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
    Object handler) throws Exception {
    // 1. 只拦截 Controller 方法,放行静态资源等
    if (!(handler instanceof HandlerMethod handlerMethod)) {
    return true;
    }

    // 2. 从请求头拿 Token,解析出用户
    String token = request.getHeader("Authorization");
    LoginUser loginUser = userService.parseToken(token);
    if (loginUser == null) {
    writeJson(response, 401, "登录已过期,请重新登录");
    return false; // 不放行:Controller 不会执行
    }

    // 3. 写入上下文,供后续业务代码直接取用
    UserContextHolder.set(loginUser);
    MDC.put("userId", String.valueOf(loginUser.getUserId()));
    return true;
    }

    // 一定要在 afterCompletion 里 remove()
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
    Object handler, Exception ex) throws Exception {
    // 无论成功、业务异常还是被拦截,这里都会执行安全的清理
    UserContextHolder.clear();
    MDC.clear();
    }
    }
  2. 接口级权限校验:需要读取目标方法上的自定义注解(哪些角色能访问,是系统级还是业务级权限);

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    @Component
    @RequiredArgsConstructor
    public class PermissionInterceptor implements HandlerInterceptor {

    private final PermissionService permissionService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
    Object handler) throws Exception {
    if (!(handler instanceof HandlerMethod handlerMethod)) {
    return true;
    }

    // 方法上的注解优先,没有就找类上的
    RequirePermission anno = handlerMethod.getMethodAnnotation(RequirePermission.class);
    if (anno == null) {
    anno = handlerMethod.getBeanType().getAnnotation(RequirePermission.class);
    }
    if (anno == null) {
    return true; // 没有声明就默认
    }

    LoginUser user = UserContextHolder.get();
    // 1. 系统级权限:用户是否具备注解里声明的任一权限
    if (!permissionService.hasAnyPermission(user, anno.value())) {
    writeJson(response, 403, "无权限访问该接口");
    return false;
    }

    // 2. 业务级权限:需要结合请求里的业务主体 ID 判断"在这个项目里我是什么角色"
    if (anno.businessScope()) {
    Long projectId = extractProjectId(request, handlerMethod);
    if (!permissionService.hasBusinessRole(user, projectId, anno.value())) {
    writeJson(response, 403, "您在当前项目中无此操作权限");
    return false;
    }
    }
    return true;
    }
    }
  3. 操作日志与耗时统计:preHandle 记开始时间,afterCompletion 记结束时间并输出

说说你对 Spring Boot 事件机制的了解?

Spring Boot的事件机制,本质上是一个基于观察者模式的、用于应用内部组件间通信。

它允许一个组件(发布者)发出信号(事件),而无需知道谁会处理它;其他组件(监听器)可以订阅这些信号,并在信号发出时自动执行相应逻辑。

  • 事件(Event):信息的载体,封装了要传递的数据。可以继承ApplicationEvent类
  • 事件发布者(Publisher):负责发出事件。通过实现ApplicationEventPublisher接口(其实现类通常是ApplicationContext)来发布事件。
  • 事件监听者(Listener):负责监听并处理特定类型的事件。实现方式有两种:
    • 实现接口:实现ApplicationListener接口,并重写onApplicationEvent方法。
    • 使用注解(更常用):在方法上添加@EventListener注解,并指定要监听的事件类型。

整个流程是:发布者通过ApplicationEventPublisher发布一个事件 -> 事件广播器ApplicationEventMulticaster将事件分发给所有已注册的监听器 -> 匹配的监听器执行其业务逻辑。

以最常用的注解方式为例

  • 自定义事件类

    1
    2
    3
    4
    public class OrderCreatedEvent {
    private String orderId;
    // 构造方法、getter、setter...
    }
  • 定义监听器

    1
    2
    3
    4
    5
    6
    7
    8
    @Component
    public class OrderEventListener {
    @EventListener
    public void handleOrderCreated(OrderCreatedEvent event) {
    // 处理订单创建后的逻辑,如发送邮件、更新库存等
    System.out.println("收到订单创建事件,订单号:" + event.getOrderId());
    }
    }
  • 发布事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Service
    public class OrderService {
    @Autowired
    private ApplicationEventPublisher publisher;

    public void createOrder() {
    // ... 订单创建逻辑
    publisher.publishEvent(new OrderCreatedEvent("123456"));
    }
    }

默认情况下,事件的发布和监听是同步的,即发布者会等待所有监听器执行完毕后才继续。若要实现异步,需在配置类上添加@EnableAsync,并在@EventListener方法上添加@Async注解。这对于发送邮件、记录日志等非核心业务非常有用。

如果一个事件有多个监听器,可以通过@Order注解来控制它们的执行顺序。

@EventListener注解支持SpEL表达式,可以根据事件对象的属性决定是否触发监听。

你的项目中涉及到了多数据源啊?那么在 Spring Boot 中你是如何配置和实现多数据源的?为什么要 exclude 自动配置?多数据源下 @Transactional 怎么工作?

在 Spring Boot 中实现多数据源,本质上是绕过 Spring Boot 的默认的单数据源自动装配,手动创建并管理多个 DataSource 实例,因此需要 exclude 自动配置

例如,我的平台使用 PostgreSQL 处理结构化业务、MongoDB 存储非结构化文档,那么基本是这样实现的

  • 首先在启动类上排除默认的数据源自动配置类,避免和自定义数据源冲突,这是基础步骤,必须先做:

    1
    2
    3
    4
    5
    6
    @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class})
    public class ZyPlatformApplication {
    public static void main(String[] args) {
    SpringApplication.run(ZyPlatformApplication.class, args);
    }
    }
  • 在 application.yml 中配置所有数据源的连接参数,我项目里配了 PostgreSQL、MySQL、MongoDB 三个,核心是给每个数据源单独指定连接池、驱动、url、账号密码,用自定义前缀区分,同时配置连接池参数,按数据源的业务压力单独调优,示例核心配置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    spring:
    datasource:
    # 结构化业务- PostgreSQL数据源
    pg:
    driver-class-name: org.postgresql.Driver
    jdbc-url: jdbc:postgresql://ip:port/zy_platform
    username: xxx
    password: xxx
    hikari:
    minimum-idle: 5
    maximum-pool-size: 20
    # 备用结构化- MySQL数据源
    mysql:
    driver-class-name: com.mysql.cj.jdbc.Driver
    jdbc-url: jdbc:mysql://ip:port/zy_platform?useUnicode=true&characterEncoding=utf8
    username: xxx
    password: xxx
    # MongoDB非结构化数据源(单独配置,基于Spring Data MongoDB)
    data:
    mongodb:
    uri: mongodb://ip:port/zy_mongo
    database: zy_platform
  • 新建配置类,通过@ConfigurationProperties绑定 yml 中的多数据源配置,然后为每个数据源手动创建对应的「数据源 + 会话工厂 + 事务管理器」,给每个数据源的组件单独命名,然后用@Primary指定主数据源,避免容器中 bean 冲突。

  • 分库路由,指定不同包 / 方法对应不同数据源,项目中用包扫描 + 注解结合的方式,简单高效

    • 包扫描路由:在配置类中,给不同数据源的EntityManagerFactory/SqlSessionFactory指定专属的 Repository 包路径,比如 PostgreSQL 对应com.zy.platform.repository.pg,MySQL 对应com.zy.platform.repository.mysql,MongoDB 对应com.zy.platform.repository.mongo,框架会自动根据包路径匹配数据源,开发时只需按包分层开发就能自动对应得上
    • 注解式路由:针对少数跨库操作的方法,自定义一个注解,结合 AOP 实现方法级的数据源动态切换
  • MongoDB 是非关系型数据库,基于 Spring Data MongoDB 单独配置,和关系型数据源的配置逻辑分离,这是多源异构的关键。

多数据源下 @Transactional 怎么工作?这涉及到事务管理,多数据源事务的处理方式

  • 单库:直接在 Service 方法上用@Transactional,需要指定对应的事务管理器

    @Transactional 本身不感知数据源,它依赖 PlatformTransactionManager。 在单数据源下,Spring 会自动注入唯一的事务管理器;但在多数据源下,容器里有多个 TransactionManager,不指定就会报错

  • 跨库:如果是跨库事务,就涉及到分布式事务了,一般情况下使用 Seata 的 AT 模式都能搞定,整合 Seata 后,只需在跨库方法上加@GlobalTransactional,Seata 会自动管理多数据源的事务一致性,无需手动处理回滚;

注意,多数据源下禁用二级缓存共享,每个数据源单独配置缓存,要不然极容易脏读,嗷嗷

什么情况下Spring容易出现循环依赖?Spring 是如何解决的?一般你是如何解决循环依赖的?为什么 Spring 循环依赖需要三级缓存,二级不够吗?

循环依赖是指两个或多个 Bean 互相持有对方,形成闭环

Spring 处理循环依赖的能力和注入方式相关

注入方式 是否支持循环依赖 原因
setter / 字段注入(@Autowired 在字段上) ✅ 支持 允许先创建、后填充,可以先创建空对象,再注入属性
构造器注入 ❌ 不支持(默认) 要求依赖先就绪,构造器必须拿到完整依赖才能 new,无法先暴露半成品
@Lazy 延迟注入 ✅ 支持 注入的是代理,真正用到才解析

一般情况下,两个 Service 互相注入,构造器注入的循环,这种是常见的场景

具体 Spring 是靠三级缓存机制来解决了循环依赖的

  • 一级缓存(singletonObjects):存储所有已创建完毕的单例 Bean
  • 二级缓存(earlySingletonObjects):半成品,存储所有仅完成实例化,但还未进行属性注入和初始化的 Bean
  • 三级缓存(singletonFactories):存储能建立这个 Bean 的一个工厂,通过工厂能获取这个 Bean,延迟化 Bean 的生成,工厂生成的 Bean 会塞入二级缓存

然后,三级缓存互相配合,解决循环依赖

  1. 首先,获取单例 Bean 的时候会通过 BeanName 先去 singletonObjects一级缓存 查找完整的 Bean,如果找到则直接返回,否则进行步骤 2。
  2. 看对应的 Bean 是否在创建中,如果不在直接返回null,如果是,则会去 earlySingletonObjects二级缓存 查找 Bean,如果找到则返回,否则进行步骤 3
  3. 去 singletonFactories 三级缓存 通过 BeanName 查找到对应的工厂,如果存着工厂则通过工厂创建 Bean ,并且放置到二级缓存中。
  4. 如果三个缓存都没找到,则返回 null。

如果查询发现 Bean 还未创建,到第二步就直接返回 null,不会继续查二级和三级缓存。返回 null 之后,说明这个 Bean 还未创建,这个时候会标记这个 Bean 正在创建中,然后再调用 createBean 来创建 Bean,实际上就是调用 doCreateBean,进行,实例化,属性注入和初始化三步

在实例化 Bean 之后,会往 singletonFactories 塞入一个工厂,而调用这个工厂的 getObject 方法,就能得到这个 Bean,这里就是提前暴露,因为此时 Spring 是不知道会不会有循环依赖发生的

然后就开始执行属性注入,这个时候 A 发现需要注入 B,所以去 getBean(B),此时又会走一遍上面的逻辑,到了 B 的属性注入这一步。然后到了 B 调用 getBean(A),这时候一级缓存里面找不到,但是发现 A 正在创建中的,于是去二级缓存找,发现没找到,于是去三级缓存找,通过提前在三级缓存里暴露的工厂得到 A,然后将这个工厂从三级缓存里删除,并将 A 加入到二级缓存中。这样 B 属性注入成功。紧接着 B 调用 initializeBean 初始化,最终返回,此时 B 已经被加到了一级缓存里 。这时候就回到了 A 的属性注入,就能正常注入 B,接着执行初始化,最后 A 也会被加到一级缓存里,且从二级缓存中删除 A。

在 Spring 中,只有同时满足以下两点才能解决循环依赖的问题:

  1. 依赖的 Bean 必须都是单例
  2. 依赖注入的方式,必须不全是构造器注入,且 beanName 字母序在前的不能是构造器注入

那么,我们手动解决一般从如下角度

  • 从设计上避免,因为循环依赖本身就是设计坏味道。 理想做法是消除它,而不是依赖 Spring 兜底。

  • 工程上的具体手段

    • 改用 setter / 字段注入

    • @Lazy

      @Lazy 注解是怎么打破循环依赖的?

      @Lazy 会让 Spring 在注入的时候不是注入真实对象,是注入一个代理。这个代理会在第一次调用方法的时候才去 getBean 拿真实对象。所以创建 A 的时候,注入的 B 其实是个代理,压根不会触发 B 的创建流程,循环就断开了。等 A 创建完了,B 的代理第一次被调用时再去创建真正的 B。

    • ObjectProvider / ApplicationContext

那么,如果仅仅是为了解决循环依赖,二级缓存确实是够用的。只要我们在 Bean 实例化(new)之后,不管它需不需要 AOP,都直接把它的代理对象生成出来,放到二级缓存里,B 就可以拿到了。这样循环依赖就解了。

但是,在 Spring 的设计中,Bean 的动态代理(AOP)通常是在 Bean 初始化完成之后(postProcessAfterInitialization)才创建的。这是标准流程。按照上述的情况,本该最后创建代理,结果一开始就创建了,就打破了 Bean 的标准生命周期

所以,Spring 引入了三级缓存(singletonFactories),存的不是 Bean,而是一个工厂(Lambda表达式)。这是一种延迟加载

三级缓存就是为了在解决循环依赖和维持标准生命周期之间做一个平衡。它让我们可以只在真正发生循环依赖的时候,才去提前创建代理对象;

Spring Boot 中你都如何进行统一异常处理?@ControllerAdvice 的限定方式你能说说吗?@ExceptionHandler 的匹配规则清楚吗? 异常处理器本身抛异常会怎样?统一异常处理里,如何区分“业务异常”和“系统异常”?

Spring Boot 统一异常处理一般是@ControllerAdvice + @ExceptionHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// @RestControllerAdvice = @ControllerAdvice + @ResponseBody,返回的对象会自动序列化成 JSON。
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public Result<?> handleBusiness(BusinessException e) {
return Result.fail(e.getCode(), e.getMessage());
}

@ExceptionHandler(Exception.class)
public Result<?> handleUnknown(Exception e) {
log.error("未知异常", e);
return Result.fail(500, "系统繁忙,请稍后重试");
}
}
  • 它只处理 Controller 层抛出的异常,Service 层抛出的异常如果冒泡到 Controller,也会被捕获。

@ControllerAdvice标记一个全局异常处理器。默认对所有 Controller 生效。也支持精确限定作用范围

1
2
3
4
5
6
7
@RestControllerAdvice(
basePackages = "com.zy.platform.controller", // 只对某个包下的 Controller 生效
basePackageClasses = UserController.class, // 或指定基准类
assignableTypes = {OrderController.class}, // 只作用于某些 Controller
annotations = RestController.class // 只作用于带某注解的类
)
public class GlobalExceptionHandler { }

@ExceptionHandler 的匹配规则是最近父类优先,也就是说,越具体的异常越优先,找离实际抛出的异常最近的那个处理器,这也就是为什么兜底的 @ExceptionHandler(Exception.class) 不会抢走更精确的处理,会通常写上

所以,如果在 @ExceptionHandler 方法内部又抛了异常(比如 Result.fail() 里 NPE、日志拼串时异常、依赖的 Bean 为 null),Spring 不会再找一个处理器,而是直接放弃处理,最终走容器默认错误页。表现就是,前端拿到 HTML 白页或者一个和约定完全不同的 JSON 结构。

而且,异步请求(@Async)里的异常不能直接捕获。@Async 方法在另一个线程执行,异常不会冒泡到 Controller。一般是让异步方法返回 CompletableFuture,在调用处 .exceptionally() 处理。

所以说,异常处理器内部尽量不依赖外部 Bean,处理器逻辑用 try/catch 包住,兜底返回统一结构;

而统一异常处理里,注意区分业务异常和系统异常的处理

  • 业务异常(BusinessException):可预期,返回具体错误码和提示给用户,不打印堆栈(或只打 warn 日志)。
  • 系统异常(Exception):不可预期,返回通用提示是系统繁忙,必须打印 error 日志和堆栈,方便排查。