手写SpringBoot Starter(三):实现可插拔Starter,像Zuul一样优雅!

系列文章第3篇 | 共5篇

难度:⭐⭐⭐ | 适合人群:想深入理解的开发者


📝 上期回顾

上一篇我们成功写出了第一个Starter:

  • ✅ 创建了HelloService业务类
  • ✅ 编写了自动配置类
  • ✅ 配置了spring.factories
  • ✅ 测试项目引入依赖后直接就能用

上期思考题解答:

Q: 如果想让HelloService只在hello.enabled=true时才生效,怎么做?

A:@ConditionalOnProperty注解!(本篇详细讲解)


💥 开场:一个真实的尴尬场景

时间: 周三下午
地点: 公司工位

小王: "诶,我引入了咱们组的common-log-starter,但是我不想用这个功能啊..."

我: "那你别引入依赖不就行了?"

小王: "不行啊,这个Starter里还有其他工具类我要用,但我不想要日志功能..."

我: "emmm..." 😅(陷入沉思)

小王: "你看人家Zuul,引入了依赖,但可以通过@EnableZuulProxy注解控制要不要启用,这才叫专业!"

我: "有道理!" 💡(醍醐灌顶)


于是,这周我的任务变成了:

"把现有的Starter改造成可插拔的,让用户自己决定要不要启用!"


🤔 第一问:什么是"可插拔"?

Q1:字面意思理解

日常生活类比:

想象你的电脑:

  • USB接口 = Starter依赖

  • U盘 = Starter功能

  • 插拔 = 启用/禁用

    插上U盘(启用)→ 电脑识别并使用
    拔掉U盘(禁用)→ 电脑不受影响

放到Starter上:

复制代码
引入依赖(插入)→ 可以选择启用或不启用
不启用功能(拔出)→ 不影响项目运行

Q2:技术上的定义

可插拔Starter = 引入依赖 ≠ 自动启用

对比:

类型 引入依赖后 控制方式 灵活性
普通Starter 自动启用,无法关闭 ❌ 低
可插拔Starter 默认不启用,需要手动开启 注解/配置 ✅ 高

Q3:为什么需要可插拔?

场景1:功能冲突

java 复制代码
// 引入了Starter,但它的功能和项目现有功能冲突
// 普通Starter:没办法,只能删依赖
// 可插拔Starter:关闭这个功能就行

场景2:按需启用

java 复制代码
// 开发环境需要Mock功能,生产环境不需要
// 普通Starter:得维护两个依赖版本
// 可插拔Starter:配置文件一行搞定

场景3:渐进式迁移

java 复制代码
// 老项目想试用新Starter,但不能影响现有功能
// 普通Starter:风险太大,不敢试
// 可插拔Starter:先引入不启用,测试通过再启用

结论: 可插拔 = 用户有选择权 = 更专业! ✨


🎮 第二问:实现可插拔的三种方式

方式一:配置文件开关(最简单)

原理: 通过配置文件中的开关来控制是否启用

实现步骤:

1. 修改Properties类

java 复制代码
package com.example.starter;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "hello")
public class HelloProperties {

    /**
     * 是否启用Hello功能
     * 默认值:true(向后兼容)
     */
    private boolean enabled = true;  // ← 新增开关

    private String prefix = "Hello";
    private String suffix = "!";

    // Getter 和 Setter
    public boolean isEnabled() {
        return enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    public String getPrefix() {
        return prefix;
    }

    public void setPrefix(String prefix) {
        this.prefix = prefix;
    }

    public String getSuffix() {
        return suffix;
    }

    public void setSuffix(String suffix) {
        this.suffix = suffix;
    }
}

2. 修改自动配置类

java 复制代码
package com.example.starter;

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;

@Configuration
@EnableConfigurationProperties(HelloProperties.class)
public class HelloAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(
        prefix = "hello",           // 配置前缀
        name = "enabled",            // 配置项名称
        havingValue = "true",        // 期望值
        matchIfMissing = true        // 配置缺失时是否匹配(默认启用)
    )  // ← 关键注解
    public HelloService helloService(HelloProperties properties) {
        return new HelloService(properties.getPrefix(), properties.getSuffix());
    }
}

关键注解深度解析:

java 复制代码
@ConditionalOnProperty(
    prefix = "hello",        // 配置前缀:hello.enabled
    name = "enabled",        // 完整配置路径:hello.enabled
    havingValue = "true",    // 值为true时才生效
    matchIfMissing = true    // 如果用户不配置,默认认为是true
)

各参数含义:

参数 含义 示例
prefix 配置前缀 hello
name 配置名称 enabled
havingValue 期望值 "true"
matchIfMissing 配置缺失时的默认行为 true(默认启用)

3. 使用方式

场景A:启用功能(默认)

yaml 复制代码
# 不配置,默认启用
# 或者明确配置
hello:
  enabled: true
  prefix: Hello
  suffix: !

结果: HelloService注入成功 ✅


场景B:禁用功能

yaml 复制代码
hello:
  enabled: false  # ← 关闭功能

结果: HelloService不会注入,使用时会报错 ❌


测试代码:

java 复制代码
@SpringBootTest
public class HelloServiceTest {

    @Autowired(required = false)  // ← 注意:required = false
    private HelloService helloService;

    @Test
    public void testHelloService() {
        if (helloService != null) {
            System.out.println("功能已启用:" + helloService.sayHello("World"));
        } else {
            System.out.println("功能已禁用");
        }
    }
}

方式二:自定义注解开关(最优雅)

原理: 通过自定义@EnableXxx注解来控制是否启用

实现步骤:

1. 创建自定义注解

java 复制代码
package com.example.starter;

import org.springframework.context.annotation.Import;
import java.lang.annotation.*;

/**
 * 启用Hello功能
 * 使用方式:在主类或配置类上添加此注解
 */
@Target(ElementType.TYPE)  // 只能用在类上
@Retention(RetentionPolicy.RUNTIME)  // 运行时生效
@Documented
@Import(HelloAutoConfiguration.class)  // ← 关键:导入自动配置类
public @interface EnableHello {
}

关键点:

  • @Import(HelloAutoConfiguration.class) 导入配置类
  • 只有加了@EnableHello注解,配置类才会被加载

2. 修改自动配置类

java 复制代码
package com.example.starter;

import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(HelloProperties.class)
public class HelloAutoConfiguration {

    /**
     * 创建一个标记Bean
     * 用于标识是否启用了Hello功能
     */
    @Bean
    public HelloMarker helloMarker() {
        return new HelloMarker();
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnBean(HelloMarker.class)  // ← 只有存在HelloMarker才注册
    public HelloService helloService(HelloProperties properties) {
        return new HelloService(properties.getPrefix(), properties.getSuffix());
    }
}

3. 创建标记类

java 复制代码
package com.example.starter;

/**
 * Hello功能标记类
 * 这个类本身没有任何功能,只是用来标记是否启用了Hello功能
 */
public class HelloMarker {
    // 空类,仅作标记用
}

Q:为什么需要标记类?
A: 因为@ConditionalOnBean需要一个具体的类来判断!

流程:

less 复制代码
用户加了@EnableHello
    ↓
导入HelloAutoConfiguration
    ↓
创建HelloMarker Bean
    ↓
@ConditionalOnBean(HelloMarker.class)生效
    ↓
注册HelloService

4. 删除spring.factories(重要!)

properties 复制代码
# 注释掉或删除这一行
# org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
# com.example.starter.HelloAutoConfiguration

为什么要删?

  • 因为现在通过@EnableHello手动导入配置类
  • 不再需要自动扫描加载

5. 使用方式

启用功能:

java 复制代码
@SpringBootApplication
@EnableHello  // ← 加上这个注解
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

禁用功能:

java 复制代码
@SpringBootApplication
// 不加@EnableHello注解
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

就这么简单!


方式三:混合模式(最灵活)

原理: 结合配置文件和注解,提供多种控制方式

实现步骤:

1. 自定义注解(同方式二)

java 复制代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(HelloAutoConfiguration.class)
public @interface EnableHello {
}

2. 自动配置类(组合条件)

java 复制代码
package com.example.starter;

import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
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;

@Configuration
@EnableConfigurationProperties(HelloProperties.class)
public class HelloAutoConfiguration {

    @Bean
    public HelloMarker helloMarker() {
        return new HelloMarker();
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnBean(HelloMarker.class)  // 条件1:必须有@EnableHello
    @ConditionalOnProperty(
        prefix = "hello",
        name = "enabled",
        havingValue = "true",
        matchIfMissing = true
    )  // 条件2:配置文件开关
    public HelloService helloService(HelloProperties properties) {
        return new HelloService(properties.getPrefix(), properties.getSuffix());
    }
}

多条件逻辑:

ini 复制代码
启用条件 = 加了@EnableHello注解 && hello.enabled=true

3. 使用方式

完全启用:

java 复制代码
@SpringBootApplication
@EnableHello  // 加注解
public class Application {
}
yaml 复制代码
hello:
  enabled: true  # 配置开启(或不配置,默认true)

结果: 功能启用 ✅


注解启用,配置禁用:

java 复制代码
@SpringBootApplication
@EnableHello  // 加注解
public class Application {
}
yaml 复制代码
hello:
  enabled: false  # 配置关闭

结果: 功能禁用 ❌(配置优先)


无注解:

java 复制代码
@SpringBootApplication
// 不加@EnableHello
public class Application {
}

结果: 功能禁用 ❌(缺少注解)


📊 三种方式对比

对比表格

维度 配置文件开关 自定义注解 混合模式
实现难度 ⭐ 简单 ⭐⭐ 中等 ⭐⭐⭐ 较复杂
控制方式 配置文件 注解 配置+注解
灵活性 ⭐⭐ 一般 ⭐⭐⭐ 较高 ⭐⭐⭐⭐ 很高
用户体验 简单直观 专业优雅 可选择
修改成本
适用场景 简单Starter 框架级Starter 企业级Starter

场景选择建议

选择配置文件开关:

复制代码
✅ Starter功能简单
✅ 用户主要是初级开发者
✅ 追求简单易用

选择自定义注解:

复制代码
✅ Starter功能复杂
✅ 想体现专业性
✅ 参考官方框架(如Zuul、Feign)

选择混合模式:

复制代码
✅ 企业级Starter
✅ 需要多层控制
✅ 用户群体多样

🔍 第三问:大厂是怎么做的?

案例1:Zuul的可插拔实现

Zuul的启用方式:

java 复制代码
@SpringBootApplication
@EnableZuulProxy  // ← Zuul的开关注解
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

源码分析:

@EnableZuulProxy注解

java 复制代码
package org.springframework.cloud.netflix.zuul;

import org.springframework.context.annotation.Import;
import java.lang.annotation.*;

@EnableCircuitBreaker  // 启用断路器
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(ZuulProxyMarkerConfiguration.class)  // ← 导入标记配置
public @interface EnableZuulProxy {
}

ZuulProxyMarkerConfiguration

java 复制代码
package org.springframework.cloud.netflix.zuul;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ZuulProxyMarkerConfiguration {
    
    @Bean
    public Marker zuulProxyMarkerBean() {
        return new Marker();  // ← 创建标记Bean
    }

    /**
     * 标记类
     */
    class Marker {
        // 空类,仅作标记
    }
}

ZuulProxyAutoConfiguration

java 复制代码
package org.springframework.cloud.netflix.zuul;

import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnBean(ZuulProxyMarkerConfiguration.Marker.class)  // ← 条件判断
public class ZuulProxyAutoConfiguration {
    
    // 注册Zuul相关Bean
    @Bean
    public ZuulController zuulController() {
        return new ZuulController();
    }
    
    @Bean
    public ZuulHandlerMapping zuulHandlerMapping() {
        return new ZuulHandlerMapping();
    }
    
    // ...更多Bean
}

Zuul的设计思路:

css 复制代码
@EnableZuulProxy
    ↓
导入 ZuulProxyMarkerConfiguration
    ↓
创建 Marker Bean
    ↓
ZuulProxyAutoConfiguration 检测到 Marker
    ↓
注册 Zuul 相关 Bean
    ↓
Zuul功能启用

和我们的实现对比:

维度 我们的实现 Zuul的实现 差异
注解名 @EnableHello @EnableZuulProxy 命名规范一致
标记类 HelloMarker Marker内部类 Zuul用内部类更优雅
条件注解 @ConditionalOnBean @ConditionalOnBean 完全一致
设计思想 标记Bean判断 标记Bean判断 ✅ 相同

结论: 我们的实现和Zuul是一样的思路! 🎉


案例2:MyBatis的可插拔实现

MyBatis的启用方式:

java 复制代码
@SpringBootApplication
@MapperScan("com.example.mapper")  // ← MyBatis的开关注解
public class Application {
}

或者:

java 复制代码
@Mapper  // 在每个Mapper接口上加注解
public interface UserMapper {
}

源码简析:

java 复制代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(MapperScannerRegistrar.class)  // ← 导入注册器
public @interface MapperScan {
    String[] value() default {};
    String[] basePackages() default {};
}

MyBatis的思路:

  • 不用标记Bean
  • 直接通过@Import导入扫描注册器
  • 扫描指定包下的Mapper接口

和Zuul对比:

框架 实现方式 适用场景
Zuul 标记Bean 功能开关型
MyBatis 扫描注册器 批量注册型

案例3:Feign的可插拔实现

Feign的启用方式:

java 复制代码
@SpringBootApplication
@EnableFeignClients  // ← Feign的开关注解
public class Application {
}

源码:

java 复制代码
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {
    String[] value() default {};
    String[] basePackages() default {};
}

和MyBatis几乎一样的思路!


🎓 第四问:我该选哪种方式?

决策树

css 复制代码
你的Starter是什么类型?
│
├─ 简单工具类(如:字符串处理、日期工具)
│  └─ 选择:配置文件开关 ⭐
│
├─ 框架集成类(如:Redis、MQ、OSS)
│  └─ 选择:自定义注解 ⭐⭐
│
├─ 业务组件类(如:日志、监控、权限)
│  └─ 选择:混合模式 ⭐⭐⭐
│
└─ 批量注册类(如:Mapper扫描、Bean扫描)
   └─ 选择:@Import + Registrar ⭐⭐⭐

实际案例推荐

如果你要封装:

Starter类型 推荐方式 理由
Redis Starter 配置文件开关 简单够用,用户习惯配置文件
OSS Starter 自定义注解 专业,可以带参数(如指定region)
监控 Starter 混合模式 需要细粒度控制
权限 Starter 混合模式 既要全局开关,又要局部控制
Mapper Starter @Import + Registrar 需要批量扫描注册

💻 完整实战:改造我们的Starter

选择方案

我们选择自定义注解方式(参考Zuul)

理由:

  1. 专业优雅
  2. 符合SpringBoot生态习惯
  3. 可扩展性强

最终代码

1. EnableHello.java

java 复制代码
package com.example.starter;

import org.springframework.context.annotation.Import;
import java.lang.annotation.*;

/**
 * 启用Hello功能
 * 
 * 使用示例:
 * <pre>
 * &#64;SpringBootApplication
 * &#64;EnableHello
 * public class Application {
 *     public static void main(String[] args) {
 *         SpringApplication.run(Application.class, args);
 *     }
 * }
 * </pre>
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(HelloAutoConfiguration.class)
public @interface EnableHello {
}

2. HelloMarker.java

java 复制代码
package com.example.starter;

/**
 * Hello功能标记类
 * 用于标识是否启用了Hello功能
 */
public class HelloMarker {
    // 标记类,无需任何实现
}

3. HelloAutoConfiguration.java

java 复制代码
package com.example.starter;

import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Hello自动配置类
 * 只有在存在HelloMarker时才会注册HelloService
 */
@Configuration
@EnableConfigurationProperties(HelloProperties.class)
public class HelloAutoConfiguration {

    /**
     * 注册标记Bean
     * 表示Hello功能已启用
     */
    @Bean
    public HelloMarker helloMarker() {
        return new HelloMarker();
    }

    /**
     * 注册HelloService
     * 条件:必须存在HelloMarker(即加了@EnableHello注解)
     */
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnBean(HelloMarker.class)
    public HelloService helloService(HelloProperties properties) {
        System.out.println("HelloService自动配置成功!"); // 日志
        return new HelloService(properties.getPrefix(), properties.getSuffix());
    }
}

4. 删除spring.factories

properties 复制代码
# 方式一:完全删除文件

# 方式二:注释掉自动配置
# org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
# com.example.starter.HelloAutoConfiguration

5. 打包测试

bash 复制代码
mvn clean install

测试验证

测试1:不加注解(功能禁用)

java 复制代码
@SpringBootApplication
// 不加@EnableHello
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
java 复制代码
@RestController
public class TestController {

    @Autowired(required = false)  // ← 注意
    private HelloService helloService;

    @GetMapping("/hello/{name}")
    public String hello(@PathVariable String name) {
        if (helloService == null) {
            return "HelloService未启用!";
        }
        return helloService.sayHello(name);
    }
}

访问: http://localhost:8080/hello/World
输出: HelloService未启用!

结论: 禁用成功 ✅


测试2:加注解(功能启用)

java 复制代码
@SpringBootApplication
@EnableHello  // ← 加上注解
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

启动日志:

复制代码
HelloService自动配置成功!

访问: http://localhost:8080/hello/World
输出: Hello World !

结论: 启用成功 ✅


🎯 知识点总结

本篇你学到了什么?

可插拔的概念

  • 引入依赖 ≠ 自动启用
  • 用户有选择权

三种实现方式

  1. 配置文件开关:@ConditionalOnProperty
  2. 自定义注解:@EnableXxx + 标记Bean
  3. 混合模式:两者结合

大厂实现分析

  • Zuul:标记Bean方式
  • MyBatis:扫描注册方式
  • Feign:扫描注册方式

条件注解家族

  • @ConditionalOnProperty:根据配置
  • @ConditionalOnBean:根据Bean存在
  • @ConditionalOnMissingBean:根据Bean不存在

🤔 思考题

问题: 如果我想实现更复杂的条件,比如:

  • 只在生产环境启用
  • 只在特定版本的Spring下启用
  • 同时满足多个条件才启用

怎么办?

提示: SpringBoot还有很多@ConditionalOnXxx注解哦!

答案: 下下篇源码分析时会讲到!


📢 下期预告

《手写SpringBoot Starter(四):配置元数据让你的Starter更专业!》

下一篇我们将:

  • 理解配置元数据是什么
  • 学习如何生成智能提示
  • 实现配置项的描述、默认值、类型提示
  • 让你的Starter像官方一样专业

让IDE智能提示你的配置项!


💬 互动时间

你觉得哪种可插拔方式最优雅?
你见过哪些框架的可插拔设计?
有什么想法或疑问?

欢迎评论区讨论!💭


觉得有帮助?三连支持: 👍 点赞 | ⭐ 收藏 | 🔄 转发

看完这篇,你的Starter已经很专业了! 🚀


下一篇见! 👋

相关推荐
初见0013 小时前
🌱 SpringBoot自动配置:别装了,我知道你的秘密!🤫
spring boot·后端
用户785127814703 小时前
Python代码获取京东商品详情原数据 API 接口(item_get_app)
后端
JAVA数据结构3 小时前
BPMN-Activiti-简单流程委托
后端
sivdead3 小时前
智能体记忆机制详解
人工智能·后端·agent
拉不动的猪4 小时前
图文引用打包时的常见情景解析
前端·javascript·后端
该用户已不存在4 小时前
程序员的噩梦,祖传代码该怎么下手?
前端·后端
间彧4 小时前
Redis缓存穿透、缓存雪崩、缓存击穿详解与代码实现
后端
摸鱼的春哥4 小时前
【编程】是什么编程思想,让老板对小伙怒飙英文?Are you OK?
前端·javascript·后端
Max8125 小时前
Agno Agent 服务端文件上传处理机制
后端