首页 / 资讯中心 / 文章详情

Spring Boot自动配置核心原理与手写starter实战解析

Spring Boot自动配置核心原理与手写starter实战解析 ★ FEATURED ARTICLE
很多同学用了两三年 Spring Boot配置过数据源、用过各种 starter但如果有人突然问你一句你项目里引入的那个 starterSpring Boot 到底是怎么知道要加载哪些 Bean 的不少人都会卡壳。这不是什么冷门知识恰恰是理解 Spring Boot 的核心入口。今天这篇就把自动配置机制从启动链路到条件装配一层层拆开然后带着你亲手写一个能用的 starter实现一个带注解、AOP 切面和配置绑定的操作日志组件。这篇文章适合两类人一类是把 Spring Boot 停留在“会用”阶段、想真正搞懂原理的开发者另一类是需要在团队里沉淀公共组件想通过 starter 统一封装通用能力的后端工程师。看完之后你不仅能说清 Spring Boot 的自动配置原理还能自己做出一个可发布的 starter之后再遇到任何第三方 starter都会觉得它不再神秘。1. Spring Boot 到底帮我们做了什么1.1 从 Spring Framework 到 Spring Boot一次“配置困境”的解决方案Spring Framework 本身已经把 IoC 容器和 AOP 这两大核心能力做得很扎实但很长一段时间里项目里依然绕不开大量重复的 XML 配置和样板代码。你想连一个数据库要配置数据源、配置事务管理器、配置会话工厂还要处理各种 Bean 之间的依赖关系。每个项目都在做差不多的事情但每个项目都要重新写一遍。Spring Boot 的思路很直接把这些高频出现、高度重复的初始化逻辑沉淀成一套通用的“默认装配方案”。你只需要引入对应的依赖框架就会根据当前类路径下的情况自动把相关的 Bean 创建出来。比如你引入了数据访问相关的依赖它就把数据源和事务管理自动配好你引入了 Web 相关的依赖它就把内嵌容器和 MVC 基础设施自动配好。这就是 Spring Boot 真正解决的核心问题不是重新造了一套框架而是在 Spring Framework 之上做了一层“自动决策层”。你写业务代码时感觉什么都没做实际上框架在后台替你做了大量的初始化工作。1.2 约定优于配置以及它对我们写业务代码的影响“约定优于配置”是 Spring Boot 设计理念里很重要的一点。它把选择权从“配置”转移到了“约定”启动类放在根包下扫描范围就自动限定配置文件名写成 application.yml配置内容就自动被加载类路径里存在某个类时对应的自动配置才会生效。这个理念对你写业务代码的影响非常直接你不需要关心框架为什么知道要怎么做只要遵循约定它就会以最合理的方式跑起来。但反过来如果你不遵循约定框架是不会主动去猜你想干什么的。这也是为什么很多人写自定义 starter 时会出现“引进来但没生效”的问题大概率就是约定没对齐。我平时排查这类问题第一反应不是去看业务逻辑而是先确认三件事目录结构是否符合约定配置文件路径和格式是否正确条件判断是否满足。这三件事对上了绝大多数 starter 都能正常工作。1.3 starter 与自动配置的关系一个负责打包一个负责运行很多人会把 starter 和自动配置混在一起说其实它们是两个层面的东西。自动配置是一套运行时逻辑负责在 Spring Boot 启动时根据条件创建 Beanstarter 是一个可发布、可复用的依赖单元把这个逻辑连同所需的第三方依赖一起打包成 Maven 构件。也就是说starter 相当于一个“分发器”它告诉使用方想用这个能力引入我这一个依赖就够了。而自动配置则是这个依赖内部真正干活的程序。一个 starter 里可以包含多个自动配置类也可以只是把另一个现成组件重新打包让用户少写几个 XML 配置。理解了这层关系你再去看各种第三方框架的 starter就会发现它们本质上是同一个套路封装配置、提供默认 Bean、暴露可扩展的接口。我们今天要手写的操作日志 starter也完全按照这个套路来做。2. 自动配置的原理拆开来看2.1 一次启动里自动配置是怎么被发现的先看一条完整链路。Spring Boot 启动时SpringApplication.run()最终会创建并刷新 Spring 容器。在刷新容器的过程中框架会解析所有配置类其中最关键的一个节点就是启动类上的EnableAutoConfiguration。EnableAutoConfiguration做的事情不是直接注册一堆 Bean而是通过Import导入一个叫AutoConfigurationImportSelector的类。这个类负责去类路径下查找所有自动配置的候选类。查找路径在 Spring Boot 2.7 之后发生了变化Spring Boot 版本自动配置候选类的注册文件2.7 之前META-INF/spring.factorieskey 为org.springframework.boot.autoconfigure.EnableAutoConfiguration2.7 至 3.x新增META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports推荐使用该方式3.x 之后仅支持 imports 方式spring.factories不再用于注册自动配置读到候选类列表之后框架会做几件事去重、按顺序排序然后逐个评估候选类上的条件注解把条件不满足的过滤掉。最后保留下来的配置类会被当成普通的Configuration处理其中的Bean方法开始创建对象。简单来说整个流程就是读取候选列表根据条件筛选再按顺序装配。这也是为什么自动配置报告里的“positive matches”和“negative matches”能看到每个配置类到底是匹配了还是被过滤了。2.2 条件装配让一切“按需生效”的核心机制自动配置之所以能做到“按需生效”靠的是一整套条件注解。最常用的几个包括ConditionalOnClass、ConditionalOnMissingBean、ConditionalOnProperty、ConditionalOnBean。ConditionalOnClass表示某个类存在于类路径时才生效Spring Boot 用它来根据你有没有引入某个依赖决定要不要加载对应的配置。ConditionalOnProperty则是根据配置项的值来判断通常用来提供总开关。ConditionalOnMissingBean是最容易理解也最容易用错的一个它的意思是容器里没有某个类型的 Bean 时这里才创建一个默认实现。自动配置类之所以能判断出“容器里有没有用户自定义的 Bean”是因为AutoConfigurationImportSelector是一个DeferredImportSelector它的执行时机被推迟到用户自定义的配置解析完成之后。也就是说框架先让用户的配置类完成 Bean 注册再回头处理自动配置候选类。这样一来自动配置永远可以作为“兜底方案”存在用户有自定义 Bean 时默认实现就自动退位。这个设计很关键它保证了框架不会和你“抢地盘”。你写业务代码时想覆盖某个默认行为只要在容器里声明一个同类型的 Bean 就行了剩下的事情由条件装配自己判断。2.3 外部化配置配置属性是怎么绑定到 Properties 类上的Spring Boot 的配置体系也很值得看。我们将application.yml里的配置项绑定到一个普通的类上可以用ConfigurationProperties加一个prefix。比如prefix custom.log那么custom.log.enabled就会绑定到这个类里的enabled字段上。Spring Boot 会从多个位置读取配置按优先级从高到低大致是命令行参数、Java 系统属性、操作系统环境变量、profile 指定的配置文件、默认的 application.yml、代码里的默认值。这意味着使用方既可以在 yml 里写配置也可以在启动命令里用--custom.log.enabledfalse这样的方式覆盖非常灵活。对于 starter 开发者来说用ConfigurationProperties而不是Value来做配置绑定有很实际的好处可以把一组配置集中起来字段校验更规范也更容易在 IDE 里获得提示。等下我写操作日志 starter 的时候就会用一个CustomLogProperties类来承载所有可配置项。3. 手写一个可用的 starter操作日志组件3.1 设计一个具备代表性的 starter我选择做“操作日志 starter”是因为它麻雀虽小五脏俱全有自定义注解、有 AOP 切面、有配置属性绑定、有条件装配、还有可扩展的 LogWriter 接口。通过这一个例子你几乎能覆盖到写 starter 会遇到的所有核心点。这个组件的使用场景很常见。业务系统里经常需要在方法级别记录用户操作谁在什么时间对哪个模块做了什么操作、耗时多久。最朴素的做法是每个业务方法里手动写一段日志代码但这样侵入性太强而且散落各处很难维护。我们用注解加 AOP 的方式把日志采集逻辑下沉到组件层业务方只需要在方法上打一个OperationLog注解。在设计时我刻意加入了几个扩展点模块名、动作类型、操作描述都支持 SpEL 表达式日志输出默认用 slf4j也允许使用方自定义 LogWriter。这样做的目的是让你看到“默认实现 可替换机制”在 starter 里是怎么配合的。3.2 工程搭建与依赖规划先新建一个 Maven 工程artifactId 我建议用operation-log-spring-boot-starter。这里有一个规范需要注意官方提供的 starter 一般以spring-boot-starter-开头第三方自定义 starter 不要再用这个前缀而是把自家项目名放在前面最后再接-spring-boot-starter。这样做既避免和官方命名冲突也方便 IDE 识别。工程的目录结构大致如下operation-log-spring-boot-starter ├── pom.xml └── src/main/java/com/example/log ├── annotation/OperationLog.java ├── config/CustomLogAutoConfiguration.java ├── properties/CustomLogProperties.java ├── writer/LogWriter.java ├── writer/Slf4jLogWriter.java └── aspect/OperationLogAspect.javapom 里的依赖需要克制。一个常见的错误是把业务无关的依赖也塞进 starter。我这个组件需要的能力只有两个AOP 和自动配置相关的类所以依赖控制在spring-boot-autoconfigure和spring-boot-starter-aop。不要引入 web starter因为操作日志功能不一定只在 Web 项目里使用依赖范围越小使用方的负担越轻。另外要注意的是给正式组件写 pom 时最好不要直接继承spring-boot-starter-parent而是用dependencyManagement引入 Spring Boot 的 BOM。演示工程为了方便可以继承 parent但正式入库的组件建议保持更干净的依赖管理方式。3.3 核心代码逐段解析先写注解。这个注解非常简单它只是承载用户配置的模块名、动作类型和描述信息Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface OperationLog { String module() default ; String action() default ; String description() default ; }然后是配置属性类。前缀我定为custom.logConfigurationProperties(prefix custom.log) public class CustomLogProperties { private boolean enabled true; private String moduleName default; public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled enabled; } public String getModuleName() { return moduleName; } public void setModuleName(String moduleName) { this.moduleName moduleName; } }为了让日志输出具备可扩展性我定义一个LogWriter接口默认实现是 slf4jpublic interface LogWriter { void write(LogRecord record); }public class Slf4jLogWriter implements LogWriter { private static final Logger log LoggerFactory.getLogger(Slf4jLogWriter.class); Override public void write(LogRecord record) { log.info(operation log | module{} | action{} | description{} | method{} | cost{}ms, record.getModule(), record.getAction(), record.getDescription(), record.getMethodName(), record.getCostTime()); } }LogRecord就是一个普通的 POJO字段包括模块、动作、描述、方法名、耗时这里就不贴完整代码了自己补上 getter/setter 就行。接下来是核心切面。切面负责拦截带有OperationLog注解的方法记录方法执行耗时并把日志交给LogWriter输出Aspect public class OperationLogAspect { private static final Logger log LoggerFactory.getLogger(OperationLogAspect.class); private final CustomLogProperties properties; private final LogWriter logWriter; private final SpelExpressionParser parser new SpelExpressionParser(); private final DefaultParameterNameDiscoverer discoverer new DefaultParameterNameDiscoverer(); public OperationLogAspect(CustomLogProperties properties, LogWriter logWriter) { this.properties properties; this.logWriter logWriter; } Around(annotation(operationLog)) public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long start System.currentTimeMillis(); try { return joinPoint.proceed(); } finally { writeLog(joinPoint, operationLog, System.currentTimeMillis() - start); } } private void writeLog(ProceedingJoinPoint joinPoint, OperationLog operationLog, long costTime) { String module resolve(joinPoint, operationLog.module()); String action resolve(joinPoint, operationLog.action()); String description resolve(joinPoint, operationLog.description()); MethodSignature signature (MethodSignature) joinPoint.getSignature(); LogRecord record new LogRecord(); record.setModule(module); record.setAction(action); record.setDescription(description); record.setMethodName(signature.getDeclaringTypeName() . signature.getMethod().getName()); record.setCostTime(costTime); logWriter.write(record); } private String resolve(ProceedingJoinPoint joinPoint, String expression) { if (!expression.startsWith(#)) { return expression; } try { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); Object[] args joinPoint.getArgs(); EvaluationContext context new MethodBasedEvaluationContext( TypedValue.NULL, method, args, discoverer); return parser.parseExpression(expression).getValue(context, String.class); } catch (Exception e) { log.warn(SpEL 解析失败保留原始字符串: {}, expression, e); return expression; } } }这里解释几个设计选择。为什么支持 SpEL因为日志描述如果是写死的字符串很多信息就浪费了。比如“创建了用户 #userId 的订单”这种带参数的描述需要在拦截时把方法参数动态解析出来。切面通过DefaultParameterNameDiscoverer拿到编译后的参数名再用 SpEL 表达式求值。注意这里有一个前提项目编译时需要开启-parameters参数否则拿到的参数名会是 arg0、arg1 这种没有语义的名字。Spring Boot 的 parent 默认已经开启但非 parent 的 Maven 工程需要自己配置。为什么要把LogWriter抽出来因为日志最终落到哪儿不同团队有不同需求有人要打印到文件有人要推送到消息队列有人要入库。让谁来做这个决定应该是使用方。所以我在自动配置里只提供一个默认实现并且在容器里已经有自定义LogWriter时自动退让。最后看自动配置类AutoConfiguration EnableConfigurationProperties(CustomLogProperties.class) ConditionalOnClass(org.aspectj.lang.annotation.Aspect.class) ConditionalOnProperty(prefix custom.log, name enabled, havingValue true, matchIfMissing true) public class CustomLogAutoConfiguration { Bean ConditionalOnMissingBean(LogWriter.class) public LogWriter logWriter(CustomLogProperties properties) { return new Slf4jLogWriter(properties); } Bean ConditionalOnMissingBean(OperationLogAspect.class) public OperationLogAspect operationLogAspect(CustomLogProperties properties, LogWriter logWriter) { return new OperationLogAspect(properties, logWriter); } }这个自动配置类用AutoConfiguration而不是普通的Configuration是为了让框架在读取 imports 文件时能把它识别为自动配置候选类。EnableConfigurationProperties用来注册CustomLogProperties并开启属性绑定ConditionalOnProperty是总开关默认没配置也生效两个ConditionalOnMissingBean分别给默认实现兜底保证使用方有自定义 Bean 时可以覆盖。3.4 注册自动配置与发布到本地仓库自动配置类写完后还需要把它登记到框架能找到的位置。在src/main/resources/META-INF/spring/目录下新建一个文件文件名必须是org.springframework.boot.autoconfigure.AutoConfiguration.imports文件内容只写自动配置类的全限定名com.example.log.config.CustomLogAutoConfiguration这个文件名很长少写一个字符都会导致自动配置加载失败。我在实际开发中经常看到有人在这类问题上花掉大半天最后发现是文件名拼错或者目录层级不对。检查的方式很简单打包完成后用jar tf命令看一眼 jar 里的目录结构确认文件确实被打进去了。执行mvn clean install如果不是新项目可以在命令后面加-DskipTests跳过测试节省时间。这里要特别提一句自定义 starter 的 pom 里不要显式配置spring-boot-maven-plugin的repackage目标否则打出来的会是一个可执行 jar里面会包含多余的启动信息作为库被引用时容易出问题。4. 把它集成到业务项目里逐一验证4.1 搭建演示应用并引入 starter新建一个普通的 Spring Boot 项目pom 里加入之前的依赖dependency groupIdcom.example/groupId artifactIdoperation-log-spring-boot-starter/artifactId version0.0.1-SNAPSHOT/version /dependency写一个 Service 方法加上OperationLog注解Service public class OrderService { OperationLog(module order, action create, description 创建订单) public void createOrder(String userId) { System.out.println(创建订单逻辑执行中...); } }再通过 Controller 或测试方法调用createOrder然后启动应用观察控制台输出。如果看到了类似下面这样的日志说明 starter 已经生效o.example.log.writer.Slf4jLogWriter - operation log | moduleorder | actioncreate | description创建订单 | methodcom.example.demo.OrderService.createOrder | cost12ms4.2 验证默认行为、配置开关、自定义覆盖接下来我们验证一下自动配置的“可覆盖性”。第一步在application.yml里加一个配置项看看配置属性类是否正确绑定custom: log: enabled: true module-name: mall如果CustomLogProperties里的moduleName绑定成功你会发现日志输出里不再使用注解上的 module 值而是优先使用了配置里的默认模块名。当然这里的优先级是我在切面里设计的先取配置值再取注解值具体业务规则可以自己调整关键是验证配置绑定链路通没通。第二步把custom.log.enabled改成false重新启动切面不会创建日志也不会输出。这个功能很适合做灰度开关上线初期观察行为出问题可以一键关闭不用改动业务代码。第三步验证ConditionalOnMissingBean。在演示项目里加一个配置类Configuration public class CustomLogConfig { Bean public LogWriter logWriter() { return new LogWriter() { Override public void write(LogRecord record) { System.out.println(自定义日志写入: record.getDescription()); } }; } }重启后你会发现日志不再走默认的 slf4j 实现而是走到了自定义实现里。这就是自动配置的“默认 可覆盖”机制框架提供兜底但不和你抢控制权。4.3 用自动配置报告确认生效过程如果你发现自定义 starter 没有生效不要瞎猜直接用自动配置报告定位。在application.yml里设置debug: true或者启动时加--debugSpring Boot 会打印一份 CONDITIONS EVALUATION REPORT。在这份报告里搜索CustomLogAutoConfiguration就能看到 positive matches 和 negative matches以及每个条件注解的评估结果。有了这份报告排查条件就清晰很多如果条件不满足它会告诉你缺少哪个类、哪个配置项没匹配上如果根本没出现在报告里那就是 imports 文件没被读到优先检查 jar 包内部文件结构。这个排查思路对任何第三方 starter 都适用你不需要一上来就断点调试框架内部先看自动配置报告是最快的。5. 手写 starter 的常见坑和我的实操经验5.1 常见问题速查表写 starter 的过程中有一些问题是高频出现的。我自己踩过不少也帮别人排查过不少整理成一张表方便你查阅。坑点现象原因处理方式自动配置未生效引入 starter 后没有看到任何日志imports 文件缺失、路径不对、条件不满足打开 debug 报告看匹配情况检查 jar 内的 imports 文件配置属性绑定不上配置项写在 yml 里但字段仍是空值prefix 写错、字段名不一致、Properties 类没注册检查 prefix 和字段名确认有EnableConfigurationPropertiesSpEL 拿不到参数名日志输出的是#userId这类原始表达式项目编译时没有开启-parameters在 maven-compiler-plugin 中开启 parameters 参数AOP 切面不生效加了注解但方法没有被拦截没有引入 AOP 依赖、方法自调用确认依赖里有 spring-boot-starter-aop避免同类内部调用自定义覆盖不生效自定义 LogWriter 没生效配置类没有被扫描到、条件注解放错位置确认自定义配置类能被扫描ConditionalOnMissingBean放在Bean方法上打包成可执行 jar引用方无法正常使用 starter配置了spring-boot-maven-plugin的 repackage移除该插件或用 skip 参数跳过其中条件注解放错位置这个问题值得多说两句。很多人会把ConditionalOnMissingBean直接放在自动配置类上这会带来一个严重的副作用整个配置类可能因为容器里存在某个 Bean 而完全不加载连里面的其他Bean方法也一起失效了。正确做法是把它放在具体的Bean方法上这样只对默认实现生效。5.2 几个值得坚持的 starter 设计习惯写 starter 和写业务代码的心态不太一样。业务代码面向的是具体的需求starter 面向的是未知的使用场景所以设计上要给足空间但也不能无限扩展。我有几个习惯每次写组件都会遵守。第一按功能边界拆分不要做一个什么都能干的“万能 starter”。日志、限流、分布式锁、幂等这些能力如果全部塞进一个 starter使用方引入时就会被迫带上一堆无关依赖。拆分成独立组件之后每个组件都有自己的文档和版本维护起来清晰得多。第二默认值要保证“零配置可用”。这是 starter 和普通工具类的最大区别使用方引入依赖后不需要做任何配置组件就应该能跑起来。配置项可以暴露但默认行为要合理。我见过不少 starter 把自动装配条件写得特别严苛用户不配十来个参数根本起不来这就违背了 starter 的初衷。第三自动配置类上的条件要尽量精准。能用ConditionalOnClass判断就判断能用ConditionalOnProperty提供开关就提供开关每个Bean方法都要明确它在什么情况下生效。条件写得越清楚使用方越不会踩到“虽然引入了但什么都没发生”的问题。前阵子我在整理团队基础组件时也是用这套模板把操作日志、分布式锁、幂等机制各自打成了独立的 starter。回头想想真正让人理解 Spring Boot 的不是背概念而是亲手把自动配置跑通一次。你把流程走一遍之后再看到任何第三方 starter一眼就能判断出它的自动配置类、条件注解和扩展点大概长什么样。如果这篇文章对你认识 Spring Boot 有帮助不妨自己也挑一个公共能力按这个套路走一遍过程会很值得。
阅读完成 · 觉得有帮助?
咨询建站