尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot Starter实战:手把手带你封装积分服务组件

SpringBoot Starter实战:手把手带你封装积分服务组件 写一个 SpringBoot starter 到底难不难如果你已经用 SpringBoot 写过一段时间业务代码肯定见过这种场景在 pom.xml 里加一个spring-boot-starter-web项目就自动拥有了内置 Tomcat、MVC、JSON 序列化能力加一个spring-boot-starter-data-redisRedisTemplate 就能直接注入使用。很多人用起来很爽但真要自己动手封装一个 starter心里就没底了。这篇文章我就拿一个实际项目来聊透这件事做一个“积分服务 SpringBoot starter”。它会对外提供积分增加、扣减、余额查询和排行榜能力并且支持 Redis 和内存两种存储策略。做完之后你会彻底理解 SpringBoot 自动装配原理、Conditional 条件装配、ConfigurationProperties 配置绑定这三大核心机制以后面试被问到 starter 也能直接甩出真实项目经验。这篇文章适合刚学完 SpringBoot 基础、想突破“只会用不会写”的同学也适合工作中需要给团队封装公共组件的开发。我会按照完整的开发流程走一遍从工程创建、核心代码、自动配置注册到调试排错、发布到公司内部仓库全程拆解保证你能照着写出来。1. 整体设计与思路拆解1.1 starter 到底解决了什么问题一个业务项目里经常会有一些和具体业务无关、但每个模块都要用的公共能力比如发短信、操作日志、参数校验、现在要做的积分账户。如果每个服务和模块都复制一份代码后续维护就是个灾难。你改一个 bug得把所有用到的地方全部同步一遍漏一个就出线上事故。starter 的核心思路就是把这种公共能力做成“即插即用”的依赖。调用方只需要在 pom.xml 里引入坐标再在 application.yml 里写几行配置这个能力就自动生效了。这一切的关键是 SpringBoot 的自动配置机制SpringBoot 在启动过程中会扫描所有 jar 包里的META-INF/spring.factories或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件把里面声明的自动配置类加载进来再结合条件装配注解决定哪些 Bean 真正生效。所以封装 starter 这件事表面上是写业务逻辑实际上核心是掌握“如何让 SpringBoot 在启动时识别我的配置类、如何让配置项优雅绑定、如何让默认实现可以被覆盖”这三板斧。把这三件事想透了任何 starter 你都能写。1.2 一个标准 starter 的工程骨架在实际开发中一个完整的 starter 通常会拆成两个 Maven 模块xxx-spring-boot-starter空壳模块只做依赖聚合里面一般不写代码xxx-spring-boot-autoconfigure核心模块放着自动配置类、属性绑定类和服务实现。为什么要拆两个模块因为前面这个空壳模块会被业务项目直接依赖把这两个模块拆开以后如果调用方只想引入配置和 API 接口不想要自动装配的默认实现比如想自己实现存储逻辑它可以直接依赖后者而不触发自动配置这样更灵活。不过对于大多数内部组件合并成单个模块也完全够用。以我这次要做的points-spring-boot-starter为例最终目录结构是这样points-spring-boot-starter/ ├── pom.xml ├── src/main/java/com/example/points/ │ ├── PointsProperties.java # 配置属性绑定类 │ ├── PointsService.java # 对外服务接口 │ ├── InMemoryPointsService.java # 内存策略实现 │ ├── RedisPointsService.java # Redis 策略实现 │ └── PointsAutoConfiguration.java # 自动配置类 └── src/main/resources/ └── META-INF/ └── spring/ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports这里我刻意没有拆两个模块因为积分服务的规模不算大合并成一个更容易理解。如果你在公司里沉淀组件再看团队规范决定是否要拆。1.3 本次实战的功能范围我做的这个积分服务不是那种简单的“积分加一减一”它支持以下能力给指定用户增加积分扣减用户积分余额不足时报错查询用户当前积分余额查询积分排行榜 Top NRedis 策略用 ZSet 实现内存策略用并发 Map 模拟。存储策略采用“有 Redis 用 Redis没有 Redis 用内存兜底”的降级设计。这样写的好处是在本地开发或单元测试时不需要启动 Redis也能把整个流程跑通在生产环境只要 Spring 容器里有StringRedisTemplate就会自动切换到 Redis 实现。同时我会提供一个配置开关points.enabled默认置为 true允许调用方通过配置方式关闭整个自动装配的积分服务。这个设计在真实组件中很常见因为不是每个下游服务都想启用所有能力。2. 核心细节解析自动装配的两个关键机制2.1 ConfigurationProperties 如何优雅绑定配置自动配置的第一步是把application.yml里的配置项和 Java 对象绑定起来。在 SpringBoot 早期版本中需要在配置类上写EnableConfigurationProperties(PointsProperties.class)现在更推荐直接在属性类上使用ConfigurationProperties注解配合Component注册但在自动配置类场景下最佳实践是两者配合。我把属性绑定类设计成这样ConfigurationProperties(prefix points) public class PointsProperties { /** * 是否启用积分服务默认启用 */ private boolean enabled true; /** * 新用户默认初始积分 */ private int defaultBalance 0; /** * 积分排行榜 key 前缀 */ private String rankKeyPrefix points:rank; public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled enabled; } public int getDefaultBalance() { return defaultBalance; } public void setDefaultBalance(int defaultBalance) { this.defaultBalance defaultBalance; } public String getRankKeyPrefix() { return rankKeyPrefix; } public void setRankKeyPrefix(String rankKeyPrefix) { this.rankKeyPrefix rankKeyPrefix; } }注意prefix points这意味着application.yml里的配置项是points: enabled: true default-balance: 100 rank-key-prefix: points:rankSpringBoot 的属性绑定对defaultBalance和default-balance这种中划线写法会自动映射这是它的宽松绑定规则。我在实际开发中踩过一个坑如果ConfigurationProperties类没有 getter/setter属性绑定会直接失败启动时还不报错等到运行时才发现配置项全是默认值。所以在写属性类的时候一定要把 setter 补全如果用的是 Lombok则加Data注解即可。2.2 Conditional 条件装配是 starter 的灵魂如果说属性绑定是“配置的基础设施”那条件装配就是“自动配置的指挥中心”。SpringBoot 提供了大量ConditionalOnXxx注解它们决定了某个 Bean 在什么情况下才创建。我用到的三个核心注解是ConditionalOnProperty根据配置项判断是否生效比如points.enabledfalse时不加载ConditionalOnClass当类路径下存在指定类时才加载比如存在StringRedisTemplate时才装配 Redis 实现ConditionalOnMissingBean当容器中不存在某个 Bean 时才创建这是实现“调用方可以覆盖默认实现”的关键。这套条件装配思想有点像“水电线路的Switch”每个开关都有明确的触发条件只有所有条件满足后电流才接通。在 starter 设计中这些开关组合起来就能做到有 Redis 时走 Redis没有 Redis 时走内存调用方自己定义过PointsService时自动配置让路不再重复注册。2.3 自动配置日志报告 debug 模式还有一个非常实用的调试手段是所有 starter 开发者都必须掌握的在application.yml里加一行debug: trueSpringBoot 启动后会把自动配置报告输出到控制台。这份报告分 Positive matches 和 Negative matches 两部分前者列出了哪些自动配置类生效后者列出了哪些没生效以及没生效的原因。我第一次封装 starter 时功能写完了依赖也加了但启动后服务一直没有被注入排查了半天都没头绪。后来打开 debug 报告发现我的自动配置类出现在 Negative matches 里原因写得很清楚ConditionalOnClass匹配不到类。因为那个类在另一个模块里我的 starter 没有声明对该模块的依赖。顺着报告一加依赖就解决了。所以建议大家写 starter 时先把 debug 打开能省下大量盲猜的时间。3. 实操过程从零封装积分 starter3.1 创建 Maven 工程并配置 pom.xml我使用的是 SpringBoot 2.7.x 版本Maven 工程JDK 8。这里明确讲一下SpringBoot 2.x 和 3.x 在自动配置注册文件上有一个重要区别2.x 支持META-INF/spring.factories和META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports两种方式3.x 开始只认后者前面的spring.factories被移除了。写代码时建议直接使用AutoConfiguration.imports方式这样后续升级到 3.x 不用改结构。pom.xml 的核心依赖只需要两个dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-autoconfigure/artifactId version2.7.13/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId version2.7.13/version optionaltrue/optional /dependency /dependencies这里第二个依赖必须加optionaltrue/optional原因是如果调用方项目本身没引入 Redis它不应该因为用了我的 starter 就被动引入 Redis 全家桶。optional 依赖不会传递给下游项目所以用 Redis 的集成就是“可有可无”的只在调用方自己需要 Redis 时才生效。同样地spring-boot-autoconfigure 这个依赖本身也可以考虑 optional但为了简单起见积分类组件通常直接依赖它没问题因为它非常轻量。3.2 定义对外服务接口对外暴露的服务接口是整个 starter 的门面调用方只依赖这个接口编码不关心底层实现是 Redis 还是内存public interface PointsService { /** * 给用户增加积分 * * param userId 用户ID * param points 增加的积分值必须大于0 */ void add(String userId, int points); /** * 扣减用户积分 * * param userId 用户ID * param points 扣减的积分值必须大于0 * return 扣减后的余额 * throws IllegalStateException 余额不足时抛出 */ int deduct(String userId, int points); /** * 查询用户当前积分 */ int balance(String userId); /** * 获取积分排行榜 Top N * * param topN 排行榜条数 * return 用户ID和积分的有序列表积分从高到低 */ ListMap.EntryString, Integer top(int topN); }接口设计这个环节很容易被人忽略但它恰恰是最重要的。接口一旦发布出去后续所有改动都是破坏性变更调用方都得跟着改代码。所以我给的接口都是最基本的原子操作把组合逻辑留给了调用方。3.3 实现内存版本内存版本用于本地调试和默认兜底使用ConcurrentHashMap存储每个用户的积分余额并维护一个TreeSet来支持排行榜查询public class InMemoryPointsService implements PointsService { private final ConcurrentHashMapString, Integer balances new ConcurrentHashMap(); private final int defaultBalance; public InMemoryPointsService(int defaultBalance) { this.defaultBalance defaultBalance; } Override public void add(String userId, int points) { balances.merge(userId, points, Integer::sum); } Override public int deduct(String userId, int points) { balances.computeIfAbsent(userId, key - defaultBalance); int newBalance balances.computeIfPresent(userId, (key, oldValue) - { if (oldValue points) { throw new IllegalStateException(用户积分不足); } return oldValue - points; }); return newBalance; } Override public int balance(String userId) { return balances.getOrDefault(userId, defaultBalance); } Override public ListMap.EntryString, Integer top(int topN) { return balances.entrySet().stream() .sorted(Map.Entry.String, IntegercomparingByValue().reversed()) .limit(topN) .collect(Collectors.toList()); } }这种实现有几个局限积分数据不持久化、多实例部署时数据不一致、排行榜在高并发下有性能瓶颈。但这不妨碍它作为“没有 Redis 时的兜底方案”至少在本地开发和单元测试中非常方便。我在这里用到了ConcurrentHashMap.computeIfPresent有个细节要注意在 lambda 中抛出异常时异常会正常传给调用方但 ConcurrentHashMap 的内部状态不会因为异常而破坏。如果你对这个行为不放心扣减逻辑也可以改成先查后写的复合操作但那样要多写不少防御代码。这里直接抛异常是安全的。3.4 实现 Redis 版本Redis 版本是生产环境真正会用的实现。我把用户的积分余额存在 String 类型的 key 中points:balance:{userId}同时用一个 ZSet 维护所有用户的积分用于排行榜查询public class RedisPointsService implements PointsService { private final StringRedisTemplate redisTemplate; private final String rankKey; public RedisPointsService(StringRedisTemplate redisTemplate, String rankKey) { this.redisTemplate redisTemplate; this.rankKey rankKey; } Override public void add(String userId, int points) { String balanceKey balanceKey(userId); Long newBalance redisTemplate.opsForValue().increment(balanceKey, points); redisTemplate.opsForZSet().add(rankKey, userId, newBalance); } Override public int deduct(String userId, int points) { String balanceKey balanceKey(userId); String balanceStr redisTemplate.opsForValue().get(balanceKey); int current balanceStr null ? 0 : Integer.parseInt(balanceStr); if (current points) { throw new IllegalStateException(用户积分不足); } Long newBalance redisTemplate.opsForValue().increment(balanceKey, -points); redisTemplate.opsForZSet().add(rankKey, userId, newBalance); return newBalance.intValue(); } Override public int balance(String userId) { String balanceStr redisTemplate.opsForValue().get(balanceKey(userId)); return balanceStr null ? 0 : Integer.parseInt(balanceStr); } Override public ListMap.EntryString, Integer top(int topN) { SetZSetOperations.TypedTupleString range redisTemplate.opsForZSet().reverseRangeWithScores(rankKey, 0, topN - 1); ListMap.EntryString, Integer result new ArrayList(); if (range ! null) { for (ZSetOperations.TypedTupleString tuple : range) { result.add(new AbstractMap.SimpleEntry(tuple.getValue(), tuple.getScore().intValue())); } } return result; } private String balanceKey(String userId) { return points:balance: userId; } }有几个设计细节说一下。排行榜直接用 ZSet 的 score 字段表示积分reverseRangeWithScores是从高到低取排名。扣减积分的逻辑里我写成“先判断后扣减”这里有一个并发场景的坑两个请求同时查余额都是 100都发现可以扣 80结果最终余额变成了 -60。真实产品里要同时兼顾并发应该用 Lua 脚本做原子性扣减。这篇文章重点在 starter 封装机制所以我在示例里用了一个简化版但在生产代码中强烈建议你把扣减逻辑放进 Lua 脚本执行。3.5 核心编写自动配置类自动配置类是 starter 的心脏它要把前面所有组件串起来。我在PointsAutoConfiguration上使用了如下组合AutoConfiguration EnableConfigurationProperties(PointsProperties.class) ConditionalOnProperty(prefix points, name enabled, havingValue true, matchIfMissing true) public class PointsAutoConfiguration { Bean ConditionalOnMissingBean(PointsService.class) public PointsService pointsService(PointsProperties properties, ObjectProviderStringRedisTemplate redisTemplateProvider) { StringRedisTemplate redisTemplate redisTemplateProvider.getIfAvailable(); if (redisTemplate ! null) { return new RedisPointsService(redisTemplate, properties.getRankKeyPrefix()); } return new InMemoryPointsService(properties.getDefaultBalance()); } }这里有三个关键设计我逐个解释。AutoConfiguration是 SpringBoot 2.7 之后推荐使用的注解它等价于Configuration(proxyBeanMethods false)加固定的自动配置语义。用它标记的类会被 SpringBoot 的自动配置处理器正确处理。如果你还在用纯Configuration也可以正常生效但推荐尽早切换到新注解。ConditionalOnMissingBean(PointsService.class)保证了调用方可以完全覆盖默认实现。如果调用方在自己的配置类中定义了一个PointsService类型的 Bean我的自动配置就会自动让路以调用方的实现为准。这就是 SpringBoot 对外提供扩展点的标准做法。ObjectProviderStringRedisTemplate是解决“可能有也可能没有”依赖的最好方式。直接注入StringRedisTemplate的话如果没有这个 Bean启动就会失败用ObjectProvider.getIfAvailable()则可以优雅地处理“没有就返回 null”的场景。这也比在配置类上写ConditionalOnBean更精准因为ConditionalOnBean在自动配置类上的判定有时会因为 Bean 定义顺序而失效这是 SpringBoot 官方文档里都承认的一个坑。3.6 注册自动配置META-INF 文件写完自动配置类以后如果不做这一步SpringBoot 完全感知不到你的类。需要在src/main/resources/META-INF/spring/目录下新建一个文件文件名固定为org.springframework.boot.autoconfigure.AutoConfiguration.imports文件内容只有一行写你的自动配置类的全限定类名com.example.points.PointsAutoConfiguration这个文件名和路径一点都不能错。我见过不少同事第一次写 starter 时在这里踩坑文件名多打了一个字母、路径写成了META-INF/services结果启动后自动配置完全不生效。另外注意这个文件和spring.factories是两套机制SpringBoot 2.7 和 3.x 对两者都支持或只支持前者我已经在前面说过了。为了兼容性直接用AutoConfiguration.imports是最省心的选择。3.7 在测试工程中验证我建议单独新建一个测试工程来验证 starter。先在本地执行mvn clean install把 starter 装进本地仓库然后在测试工程里添加依赖dependency groupIdcom.example/groupId artifactIdpoints-spring-boot-starter/artifactId version1.0.0/version /dependency测试工程的application.ymlserver: port: 8080 points: enabled: true default-balance: 100 rank-key-prefix: points:rank写一个简单的 Controller 验证接口RestController RequestMapping(/points) public class PointsController { private final PointsService pointsService; public PointsController(PointsService pointsService) { this.pointsService pointsService; } PostMapping(/add) public void add(RequestParam String userId, RequestParam int points) { pointsService.add(userId, points); } GetMapping(/balance) public int balance(RequestParam String userId) { return pointsService.balance(userId); } GetMapping(/top) public ListMap.EntryString, Integer top(RequestParam(defaultValue 10) int topN) { return pointsService.top(topN); } }启动后在浏览器或 Postman 里访问/points/balance?userIdu001在不配置 Redis 的情况下返回 100默认积分这就代表自动配置生效、内存兜底实现被成功加载了。4. 实战中的调试技巧与进阶设计4.1 给 IDE 加上配置自动提示在测试工程的application.yml中写points.default-balance时你会发现 IDEA 没有像 SpringBoot 官方配置那样给代码提示。这是因为 starter 里缺少配置元数据。解决办法是引入spring-boot-configuration-processor在 pom.xml 中加入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependency重新编译后在target/classes/META-INF/下会生成一个spring-configuration-metadata.json文件IDE 会根据它提供提示。这属于锦上添花但对提升团队使用体验非常有帮助。我在内部给其他团队发组件时都会加上这个依赖减少”配置半天没反应“的咨询量。4.2 多模块开发时最顺手的调试方式如果你把 starter 和测试工程拆成了两个独立 Maven 项目每次改代码都要先mvn install再启动测试工程非常低效。更快的方案是使用 IDEA 的多模块方式创建一个父 pom把 starter 和测试工程作为两个 module在测试工程的 pom 中声明对 starter module 的依赖直接运行测试工程IDEA 会自动把 starter module 编译进 classpath。这样修改 starter 代码后重启测试工程就能生效不需要反复 install。如果是在公司内部甚至可以用 Maven 的-pl参数指定模块单独构建配合-am参数自动构建依赖模块。4.3 如何给 starter 增加自定义开关和扩展点我这次只做了一种自动配置类和一种服务策略但真实世界的 starter 往往要考虑更多。比如你希望调用方可以启用/关闭具体某个 Bean而不是整个服务。这时候可以用ConditionalOnProperty细粒度控制Bean ConditionalOnProperty(prefix points, name redis-enabled, havingValue true, matchIfMissing false) ConditionalOnMissingBean(PointsService.class) public RedisPointsService redisPointsService(StringRedisTemplate redisTemplate, PointsProperties properties) { return new RedisPointsService(redisTemplate, properties.getRankKeyPrefix()); }又比如你希望调用方可以自定义积分累加规则可以在自动配置类中留一个自定义回调接口public interface PointsCalculator { int calculate(int currentBalance, int delta); }然后在服务实现中判断如果容器中有PointsCalculator的实现就调用它否则走默认策略。这套扩展思路和 SpringBoot 处理 bean 的方式完全一致给你一个口子让你在不改源码的情况下改变行为。5. 常见问题与排查技巧实录5.1 常见问题速查表现象可能原因解决方案自动配置类没有被加载AutoConfiguration.imports文件名或路径错误核对META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports自动配置类加载了但 Bean 不存在条件装配不满足开启debug: true查看 Negative matches 原因配置属性绑定不上全是默认值ConfigurationProperties类缺少 setter补全 setter 或使用 Lombok Data调用方自定义实现没有生效自动配置类顺序不对使用AutoConfigureBefore/AutoConfigureAfter控制顺序下游项目被迫引入不需要的依赖starter 依赖没有标记 optional在 pom 中加optionaltrue/optional两个同名 Bean 导致启动失败自动配置类和调用方配置都注册了同一类型的 Bean在自动配置类上加ConditionalOnMissingBean5.2 最容易踩的坑Bean 的顺序问题ConditionalOnMissingBean有个经典问题当自动配置类和其他配置类同时被加载时判定顺序是不同的。如果调用方自己定义了PointsService但他的配置类比 starter 的自动配置类晚加载那么 starter 里的ConditionalOnMissingBean在判定时发现容器中还没有对应的 Bean就会把默认实现创建出来最终可能出现两个PointsService类型的 Bean启动直接报NoUniqueBeanDefinitionException。解决办法有几种一是用AutoConfigureBefore或AutoConfigureAfter明确声明自动配置类和其他配置类的先后顺序二是建议调用方在配置类上使用Bean返回PointsService时把方法名定义得有区分度。但最根本的还是要让调用方理解这个机制自动配置类的优先级在 SpringBoot 中默认是相对靠后的这一点在使用时要特别注意。另一种做法是把自动配置类上的条件改成ConditionalOnMissingBean(name pointsService)按名字匹配。但这样不够优雅因为你不能预设调用方的 Bean 名。总体原则是能用类型匹配就用类型匹配无法解决时再考虑用名称和顺序方案。5.3 条件装配失效的隐藏原因排查条件装配失效时除了看代码本身还要注意 jar 包是否被正确引入。如果你的 starter 里用到了 Redis 相关的ConditionalOnClass但调用方项目里没有做 Redis 自动配置那么StringRedisTemplate这个类虽然存在容器里也没有这个 Bean。这种情况我的代码里用的是ObjectProvider.getIfAvailable()来规避但如果你在自动配置类的方法参数里直接写StringRedisTemplate启动就会直接失败。还有一个隐藏问题是ConditionalOnClass和ConditionalOnMissingClass的字符串写法。官方推荐用name属性指定类名因为 Spring 在解析ConditionalOnClass时如果直接value SomeClass.class而该类本身又不在 classpath会导致类加载异常。用字符串就没有这个风险。我从一开始就用ConditionalOnClass(name org.springframework.data.redis.core.StringRedisTemplate)这种写法在复杂的依赖环境下更稳。5.4 版本升级带来的兼容性问题最后提醒一下版本问题。在 SpringBoot 2.7 到 3.x 的升级过程中starter 的打包方式出现过兼容性变化主要表现为SpringBoot 3.x 基于 Jakarta EE许多 javax 开头的类全部变成了 jakarta 开头同时自动配置注册文件从spring.factories改为AutoConfiguration.imports。如果公司里有老的 starter 需要升级建议在升级时不仅改文件还要把代码里所有javax.*的引用批量替换成jakarta.*否则启动时会出现ClassNotFoundException。如果你接手的是老项目里面还在用spring.factories也不要慌SpringBoot 2.7 仍然支持它只是控制台会打印一条 deprecated 警告。等做了全面升级之后再迁移到 imports 方式也不迟。末尾的几句话回到开头那个问题自研 starter 到底难不难我的答案是不难但前提是理解了自动配置和条件装配的底层机制。这篇文章看着代码量不多但核心不在这几十行而在于你是否想清楚了“哪些 Bean 该自动创建”“哪些情况下让调用方自己发挥”“配置项暴露到什么粒度”这些设计问题。我第一次写 starter 时确实花了很多时间在调试自动配置不生效和条件判断不精确这些坑上所以我才在这篇文章里把这些细节都摊开来聊。如果你现在所在的团队有一些公共组件频繁在不同项目之间复制粘贴我的建议是直接尝试把其中一个比较稳定的组件抽成 starter。一开始可以先做成我在 3.2 节那种最简结构跑通以后再逐步细化条件装配和属性绑定。这个过程的收获比你单纯背一百道面试题都来得实在。最后再分享一个小技巧写好的 starter 一定要在 clean 环境里验证一次依赖传递是否干净。把本地 Maven 仓库里之前 install 的旧版本删掉再重新安装引用看能否拉取到最新版本。这个操作能避免你在本地因为缓存依赖而测试通过、同事那边却拉到旧版本的问题也是组件发布前最稳妥的自检方式。
返回列表