本文你将学习到以下知识点:
- 如何开发一个 Spring Boot Starter
- 写框架代码需要注意些什么?
- 为什么要用自动配置?自动装配的机制
- 如何通过 AutoConfiguration.imports 自动注入?
- Spring的条件装配机制、如何配置默认Bean
- Starter的接口抽象设计
- 如何为我们的Starter做一个用户可控的开关
- 等等
如果你只是想要一份可以直接使用的 Starter 示例代码,可以直接跳转到章节 4.5。
0、引言
之前在重构一个项目的时候,经常涉及到一些表的数据合并、迁移等操作。小公司嘛,基建其实是不太成熟的,像我们常说的灰度发布、双写方案等等,这边都很难去做到,没法做到真正的不停机平滑数据迁移,停机的话又需要跟甲方那边申请,比较麻烦。
所以后来我选择了一种“平民方案” —— 在不改动现有核心逻辑的前提下,通过拦截器动态拦截特定接口防增量 再配合 Nacos 的配置热更新,做到了动态切换和控制,效果还不错,可以实现比较优雅的不停机动态配置拦截和响应的防增量。很多人就会想到了:这不就是 Sentinel 的流控嘛?确实类似。只是我们当时项目组没有接入 Sentinel,为了这个特地加个组件实在没必要。
回到项目,实现起来其实是很简单的,无非就是定位一下哪些接口有增量,再搭配Nacos配置中心的热更新,直接拦截并响应。
关于Nacos配置中心的使用,大家伙有兴趣可以看看这篇文章:
Spring项目将配置文件移至nacos上,实现统一配置和热更新_将此文件夹下所有配置文件内容复制到 nacos 对应的配置中-CSDN博客https://blog.csdn.***/weixin_46739493/article/details/134301141?ops_request_misc=%257B%2522request%255Fid%2522%253A%25226d8978fa82c676b18784cfd0107afb9b%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=6d8978fa82c676b18784cfd0107afb9b&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-3-134301141-null-null.nonecase&utm_term=nacos&spm=1018.2226.3001.4450
后来听其它同事说CV在其它项目中也有用,当然很是欣喜。所以打算实现一个Starter放到内网给大家引入即用。正好一直想着写个类似的博客,但是由于公司的保密原则和复杂性(有的i项目需要保密,有的需求又太复杂,很难在一篇文章中讲明白),这次这个需求很简单,正好借着这次实现,顺便记录下这个动作。
本文适用于:熟悉 Spring Boot ,且想要深入理解并构建自己的 starter 模块的开发者。
1、为什么要构建 Starter?
Spring Boot 的强大生态中,Starter 模块的设计是其核心之一。通过合理封装,我们可以将常用功能打包为 Starter,供别人一行依赖即可使用,极大提升开发效率。
2、确认需求
开发之前,特别是这种组件类的,一定要很明确自己的需求:
-
可以动态注册一个拦截器;
-
可以自动为接口返回结果做统一包装;
-
用户只需引入依赖、加一点配置,功能开箱即用;
3、基础知识点
之前也有看过一些"教你如何快速开发Starter"的类似的文章,写的也挺好,只是发现基本上是不讲"为什么"的。我个人认为,开发Starter起码要理解这部分接下来说的最基本的几个点。不然后续在Strarter后续的拓展上会很受阻,这部分真的很重要。
我预留几个问题,可以后面思考一下,看看自己到底懂了没,比如:
- 我们怎么让用户使用我们的Starter的时候去注册我们的Bean。
- 自动配置是什么、为什么要自动配置?
- 怎么样才能做到跟用户的Bean和接口不冲突
- 如何去实现一个默认Bean等等。
3.1 自动配置
Starter 模块的灵魂就是“自动配置”,我们要让用户无需手动声明任何@***ponentScan 或 @Bean,功能即可生效。
注意一下,是自动配置,不是自动装配。
-
自动装配(Autowiring):是 Spring IoC 容器的功能,通过 @Autowired 等注解实现依赖注入。
-
自动配置(Auto-Configuration):是 Spring Boot 的功能,通过 META-INF/spring.factories 或类似机制加载配置类,动态注册 Bean。Spring Boot 的 @EnableAutoConfiguration 会读取 META-INF中的配置加载这些配置类,然后通过 @Bean 方法注册 Bean。
要做到这点,我们依赖于 Java 的 SPI(Service Provider Interface)机制。
而在boot2和boot3中,有些不同:
Spring Boot 2 vs 3 自动配置机制对比
| 项目版本 | 自动配置入口声明文件 | 格式 |
|---|---|---|
| Spring Boot 2.x | META-INF/spring.factories | org.springframework.boot.autoconfigure.EnableAutoConfiguration=... |
| Spring Boot 3.x+ | META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 直接写配置类的全限定类名,一行一个 |
boot2:
Spring Boot 3.x 之后,推荐使用这个文件:
Spring Boot 启动时会读取该文件,自动尝试注入配置类中的@Bean组件。
另外,boot3也兼容boot2的写法,但是已经标记为“非推荐方式”了
随便打开一个依赖下都能看到它们:
3.2 为什么要自动配置
一些小伙伴可能会有些疑惑:
为啥要通过配置 META-INF/spring.factories 或 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 来自动加载并尝试注入?
@Bean、@***ponent 等注解不是也能把类注册成 Bean 吗?
首先得先知道一下 普通组件注册 和 自动配置机制 的区别。
普通 Bean 注册的机制
大家都知道,@SpringBootApplication 是 Spring Boot 启动类的注解,里面包含了 @***ponentScan,它会扫描当前类所在包及其子包,自动将带有 @***ponent、@Service等注解的类注册为 Spring 容器中的 Bean。
这在我们自己开发应用时非常好用,因为我们可以控制代码结构和包路径。
但是,在开发 Starter 的时候就不行了!
当我们写一个 Spring Boot Starter 的时候,代码并不在用户应用的主包路径下。换句话说:
使用者写的应用并不会去扫描我们 Starter 的包路径。
也就是说,就算我们在 Starter 中写了 @***ponent 或 @Configuration 等,这些类也不会被用户项目的 @***ponentScan 扫描到,更不会被 Spring 容器管理。
这时候就要自动配置了。所以 自动配置的目的是 让别人“什么都不写” 就能自动集成你的功能模块!
3.3 条件装配机制 (Conditional Auto Configuration)
核心知识点:
条件装配 是 Spring Boot 提供的一种机制:
只有在满足某个条件时,Spring 才会加载这个 @Configuration 类或注册这个 @Bean。
这些条件都是通过注解(如 @ConditionalXXX)来声明的。我们就可以通过这些注解,给用户提供一个按钮,让他们可以控制我们的组件的Bean注册、默认Bean创建等等。常用注解如下:
比如:
@ConditionalOnMissingBean:
当 Spring 容器中 不存在 某个 Bean 时,当前的 Bean 或配置才会被加载。那我们就可以用它来做默认Bean,当用户的类重写了我们的接口并注册成Bean之后,就不用注册我们这个默认Bean了。
再比如:
@ConditionalOnProperty
基于配置文件中是否存在某个属性,以及其值是否匹配来控制 Bean 的注册。只有满足某个配置条件,才加载这个配置类或 Bean
那我们就可以用它,来给用户一个开关。他想要的时候就开启,不想要的时候关闭即可。
我也写过相关的文章:
Spring Boot 条件装配机制:用它写出更优雅的自动配置-CSDN博客https://blog.csdn.***/weixin_46739493/article/details/148170274?sharetype=blogdetail&sharerId=148170274&sharerefer=PC&sharesource=weixin_46739493&spm=1011.2480.3001.8118
3.4 总结一下 Spring Boot Starter 自动装配的流程
用户项目
↓ 引入 starter 依赖
[Spring Boot 启动时自动加载配置类]
↓
自动配置类 resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
↓
Spring Boot 会扫描这些配置类,尝试注入为 @Configuration
↓
条件装配(比如 @ConditionalOnProperty)满足才会生效
↓
最终,只有真正“符合条件”的 @Bean 会注册到 Spring 容器中
3.5 配置类绑定 @EnableConfigurationProperties
一句话总结:
@EnableConfigurationProperties 的作用就是把使用了 @ConfigurationProperties 注解的类注册为一个 Spring Bean,并读取配置文件自动赋值。
从规范上面讲,spring.factories 中 如果是一个简单的配置类(比如@ConfigurationProperties)的类,是不应该写在其中的。那么,这种配置类是要怎么加载成Bean呢。就使用这个注解: @EnableConfigurationProperties
以ShieldProperties配置类和ShieldAutoConfiguration自动配置类为例,
@Configuration
@EnableConfigurationProperties(ShieldProperties.class)
public class ShieldAutoConfiguration {
}
当 Spring Boot 加载 ShieldAutoConfiguration 时,自动顺带注册并初始化 ShieldProperties,你就可以像普通 Bean 一样使用它了。
在加载ShieldAutoConfiguration的时候,告诉Spring Boot:我要启用并注册ShieldProperties配置类,然后Spring就会创建ShieldProperties-Bean,并读取配置文件并赋值。这样清晰职责分离,是最佳实践。
4、代码开发
4.1 提炼需求
在知道了上面一些知识点,我们就可以开始实际的业务开发了。我们再确认一下需求,核心逻辑:
命中配置的接口,就直接拦截并终止请求流程,返回一个统一的响应体。
典型的流程如下:
-
用户配置哪些路径要拦截(如 /test/**)
-
进入拦截器,判断路径是否命中
-
如果命中,构造一个 返回体 → 序列化为 JSON → 写入 response,终止后续流程
随便写个代码:
这就满足这个需求对吧。然后大家可以想到几个问题不:
1,每个用户、每个项目的通用响应体Response是不同的,用户怎么定制返回结构,如何「策略扩展」。
2,看起来像“硬编码的 JSON 响应”,少了点“框架味”和弹性
3,怎么注册这个拦截器
4.2 怎么让它更优雅?
简单点说 我们的设计重点其实是 怎么让用户"告诉你怎么构造这个拦截后返回的JSON"
我们现在从两个维度做抽象和升级,让我们的 starter 更具“可配置 + 可扩展 ”的特性:
4.2.1 抽象"响应内容"接口
public interface ShieldResponseBuilder {
Object buildResponse(HttpServletRequest request, String matchedPath);
}
我们要默认提供一个实现(@ConditionalOnMissingBean):返回默认的响应体
@Bean
@ConditionalOnMissingBean(ShieldResponseBuilder.class)
public ShieldResponseBuilder defaultShieldResponseBuilder(ShieldProperties properties) {
return (request, path) -> {
ShieldProperties.ShieldResponseConfig cfg = properties.getResponse();
return Response.fail(cfg.getCode(), cfg.getMessage());
};
}
4.2.2 加上响应字段动态配置
前面我们有了开关和路径。那么此时我们要再对Resp进行动态配置。
shield:
enabled: true
paths:
- /test/a
- /test/b
response:
code: 403
message: 服务升级中,请稍后再试
默认的 ShieldResponseBuilder 中读取这些字段生成响应。
4.2.3 怎么新增这个拦截器
我们在项目中注册拦截器经常是这样写的,去重写这个添加拦截器接口:
感觉没问题对吧。
但是,我们在写框架代码的时候千万不能这么写,因为一旦存在用户也实现了WebMv***onfigurer,就会启动失败,因为Spring不知道要加载哪一个。所以我们要这么写:
然后可能会有小伙伴会有疑惑:
我自己 @Bean 提供了一个 WebMv***onfigurer,但别人项目中也有,Spring 会不会冲突?这里new的会不会覆盖? 等等
答案是 不会冲突,Spring Boot 会自动组合(***posite)所有的 WebMv***onfigurer Bean,它们的拦截器、格式化器、视图解析器等会被累加合并(组合模式)。
所以用户最终体验应该是:
什么都不用写,配置完 YAML,拦截器自动生效,
如果想定制响应结构,自己提供个 @Bean 实现接口就行。
这才是一个真正「优雅、可配置、可扩展」的 Spring Boot Starter。
4.3 接口拓展评估
不知道大家有没有接触过 Spring Security,它有一个非常核心的接口 —— UserDetails。
这个接口定义了用户认证所需的基本方法,比如:
-
getUsername() 获取用户名
-
getPassword():获取密码
-
还有其他如账号是否锁定、是否过期等方法
这样的设计可以让开发者根据自身业务,灵活地实现接口,定义自己需要的字段和逻辑。
同样的还有比如:以 Spring Boot 的 HealthIndicator :
这个接口允许我们为系统的某些模块(如数据库、Redis、消息队列等)定义自定义的健康检查逻辑。例如:
@***ponent
public class MyRedisHealthIndicator implements HealthIndicator {
@Override
public Health health() {
// 检查 Redis 是否连接正常
boolean redisStatus = checkRedis();
if (redisStatus) {
return Health.up().withDetail("Redis", "正常").build();
} else {
return Health.down().withDetail("Redis", "连接失败").build();
}
}
}
这种设计思路和 UserDetails 类似:提供一个接口,由用户在特定场景中自行扩展。
但如果你的项目追求快速集成和“开箱即用”,你可能会倾向使用 Spring Boot Actuator 自带的默认健康检查功能,而不去实现这些扩展。
因为我是希望可以引入一下依赖,加个yml配置(甚至公司内部使用公司底板的,连yml都不用加),直接可用的。而且功能上来讲也比较简单,这种小功能其实也没有什么太多需要拓展的地方,没什么必要性。所以就摒弃了这个想法。
不过我建议大家在学习和实践过程中,还是要了解并尝试这种设计思路。我认为没有任何一种设计方案是放之四海而皆准的“最优解” —— 灵活性和易用性常常是设计中的一对矛盾,如何取舍,需要看你当下最需要解决的问题是什么。设计的好坏,永远取决于具体的业务场景与使用需求。
4.4 条件装配评估
另外还有个很关键的点值得考虑:我们需不需要为 Bean 的注册提供开关,即是否只有在配置项 enable=true 的情况下才注册该 Bean。
问题
其实核心就是在于评估“没有注册开关会带来什么影响?”这背后主要涉及两个方面:
- Bean 会不会很重。如果我们默认自动装配(即不加开关),需要考虑这个 Bean 是否会对用户项目造成明显的性能开销或副作用。
- Bean 是否对功能是必要的?如果我们不进行注册,是否会导致某些核心功能无法生效,比如我们希望实现的“热更新”能力
结论
在本 Starter 的设计中,我们可以明确得出结论:这个 Bean 应当默认自动装配,且不需要额外的 enable 开关控制。
原因如下:
- 这个 Bean 本身非常轻量,几乎不会对用户项目造成任何负担,不存在资源消耗大或强依赖等问题。而且,本身会引入这个依赖的用户,一定是为了这个功能而来的。
- 自动装配是实现“热更新”功能的基础。我们希望用户在修改配置文件后,相关逻辑能立刻生效。如果不注册 Bean,将根本无法实现这一目标。
所以我们的拦截器可以这样写:
而我们的配置文件的默认值是:
也就是说,默认情况下(只引入了该依赖,但没做任何配置的),这个拦截器是注册了。但是并不会有任何的拦截逻辑。
-
无论用户是否配置,只要引入了该依赖,拦截器就会注册;
-
拦截器内部再根据配置属性 shield.enabled 和 shield.paths 来动态控制是否真正执行拦截;
-
配置文件中 enabled=false 且 paths=[] 是默认值,意味着“默认注册但默认不生效”。
这种设计方式的最大好处是:
这主要是为了 支持运行时热更新配置。因为我们的热更新是依赖于Bean的。那如果这时候都没注册上Bean,更何谈所谓的热更新。
所以我们反过来处理:Bean 永远注册,是否启用交给运行时配置判断,这样就天然支持热更新,非常适合业务场景下的动态开关控制。
好的。现在这样基本的设计就已经完成了。现在只需要实现代码即可。
代码部分比较简单:
4.5 完整代码:
https://gitee.***/zhuosihua1116/upgrade-shield-starterhttps://gitee.***/zhuosihua1116/upgrade-shield-starter
// Response
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class Response implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 成功标识true or false
*/
private Boolean su***ess;
/**
* 响应code
*/
private Integer code;
/**
* 信息
*/
private String msg;
public static Response fail(Integer code, String msg) {
Response result = new Response();
result.setSu***ess(Boolean.FALSE);
result.setMsg(msg);
result.setCode(code);
return result;
}
}
// ResultUtil
public class ResultUtil {
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 将resp对象响应到响应体中
*/
public static void responseJson(HttpServletResponse response, Object resp) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
objectMapper.writeValue(response.getWriter(), resp);
}
}
// ShieldAutoConfiguration
package ***.za.upgradeshield;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMv***onfigurer;
@Configuration
@EnableConfigurationProperties(ShieldProperties.class)
public class ShieldAutoConfiguration {
@Bean
@ConditionalOnMissingBean(ShieldResponseBuilder.class)
public ShieldResponseBuilder defaultShieldResponseBuilder(ShieldProperties properties) {
return (request, path) -> {
ShieldProperties.ShieldResponseConfig cfg = properties.getResponse();
return Response.fail(cfg.getCode(), cfg.getMessage());
};
}
@Bean
public ShieldInterceptor shieldInterceptor(ShieldProperties properties, ShieldResponseBuilder responseBuilder) {
return new ShieldInterceptor(properties, responseBuilder);
}
@Bean
public WebMv***onfigurer shieldWebMv***onfigurer(ShieldInterceptor interceptor) {
return new WebMv***onfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor);
}
};
}
}
// ShieldInterceptor
package ***.za.upgradeshield;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
public class ShieldInterceptor implements HandlerInterceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(ShieldInterceptor.class);
private final ShieldProperties properties;
private final ShieldResponseBuilder responseBuilder;
public ShieldInterceptor(ShieldProperties properties, ShieldResponseBuilder builder) {
this.properties = properties;
this.responseBuilder = builder;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
LOGGER.debug("enter shield interceptor ... ");
if (!properties.isEnabled()) return true;
String uri = request.getRequestURI();
List<String> paths = properties.getPaths();
if (paths != null && paths.stream().anyMatch(uri::startsWith)) {
LOGGER.info("shield request:{} ", uri);
ResultUtil.responseJson(response, responseBuilder.build(request, uri));
return false;
}
return true;
}
}
// ShieldProperties
@ConfigurationProperties(prefix = "shield")
public class ShieldProperties {
private boolean enabled;
private List<String> paths;
private ShieldResponseConfig response = new ShieldResponseConfig();
public static class ShieldResponseConfig {
private Integer code = 500;
private String message = "系统升级中,部分功能暂时不可用。预计将于1小时内完成,请稍后再试。";
// getter and setter
}
// getter and setter
}
// ShieldResponseBuilder
@FunctionalInterface
public interface ShieldResponseBuilder {
/**
* 构建拦截响应体的方法。
*
* @param request 当前的 HTTP 请求对象,便于获取 Header、参数、IP 等上下文信息
* @param path 当前被拦截的请求路径,便于用户判断来源路径或个性化提示
* @return 任何可以被序列化为 JSON 的对象(如 Map、POJO、自定义响应体类等)
*/
Object build(HttpServletRequest request, String path);
}
5、测试
5.1 starter打包
maven - clean - install
可以去maven的仓库里面看看:
这样就是有了。
5.2 外部项目
现在我们直接找一个别的项目,直接在maven中引入这个gav。
刷新一下maven,看看lib
然后啥都不用动,包括yml,直接启动!
在拦截器这里打个断点,发个请求。
可以看到顺利进来了。然后由于没配置启用。所以它直接放行了。
现在我们再去配置一下yml:
可以看到 得益于Nacos配置中心,我们的配置类热更新了。
不用重启项目,直接发请求:
可以看到接口被拦截下来了。且也按着我们默认的返回实体返回了。因此,如果是使用公司的项目底板,这就可以完美适配 无需任何改动,只要引入依赖就能直接使用!
接下来可以再试试用户动态配置的bean,我这里图省事,弄了个Map(这里需要重启,因为bean得重新载入)。再次发请求,可以看到响应体也按着用户的格式走了:
6、完善“用户友好体验”
6.1 Readme
一个好的项目,没有 Readme 是肯定不行的。
Readme 是用户接触你项目的第一印象,是项目的说明书、门面。
需要包括啥倒是没有硬性要求。无非包括以下几个关键内容:
-
项目简介 简要说明这个项目是做什么的,它解决了什么问题,适用于什么场景。可以用 2~3 行话让用户快速“理解价值”。
-
特性亮点 用简洁的列表方式列出核心功能点,比如:
- ✅ 支持路径拦截和自定义响应 - ✅ 开箱即用,默认实现快速上手 - ✅ 支持通过配置文件一键启用/禁用 -
快速开始、使用说明、进阶配置、常见问题、参与贡献、开源协议、致谢/鸣谢 等等
比如这样:
6.2 配置提示文件
希望用户yaml写的更方便一点的话,可以加一下提示文件:
由于篇幅的原因,被我拆出去了,有兴趣的看看:
Spring Boot Starter 怎么加配置提示文件-CSDN博客https://blog.csdn.***/weixin_46739493/article/details/148172132?sharetype=blogdetail&sharerId=148172132&sharerefer=PC&sharesource=weixin_46739493&spm=1011.2480.3001.8118
todo 发布到 Maven 中央仓库
7、结束语
不知不觉写了1.3w字。本文主要还是Starter的构建过程,同时也记录了一些我当时实现的过程以及思考过程,也许这不是一篇特别标准的教程,但我希望它更像一次真实的开发记录,能给遇到类似需求的朋友提供一些参考或借鉴。
如果对大家有帮助的话可以点个关注收藏啥的,后续我会再写一篇实战篇。另外,文章中也可能存在一些不足之处,或者有些地方讲解不够清楚,欢迎在评论区留言指正或交流。