ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot CommandLineRunner 实战:启动初始化与执行顺序控制

Spring Boot CommandLineRunner 实战:启动初始化与执行顺序控制 做 Spring Boot 项目久了几乎每次都会遇到“应用启动之后需要做点事”的需求初始化缓存、预加载配置、创建目录、给注册中心上报状态……早些年我习惯在 main 方法里追加几行或者在 ServletContextListener 里做后来项目多了统一收到一个更标准的方案——CommandLineRunner。这篇文章就专门聊聊它的用法以及真正让不少人栽过跟头的执行顺序控制问题。CommandLineRunner 是一个Spring Boot里专门“启动后执行”的接口适合做预加载的数据预热、数据库初始化脚本、临时目录清理这类一次性任务。和应用生命周期紧密相连服务起完、Bean 装配完成紧接着执行。新手和老手都用得上但在我接触的大多数项目里很多人只在控制器里堆业务逻辑对这个简单的接口反而一知半解导致不少隐患。今天会从原理、用法、排序、踩坑四个维度展开保证你读完就能直接用到位。1. CommandLineRunner启动即行动的“哨兵”1.1 一个接口解决启动初始化难题先看这个接口本身它简单得有点“欺负人”接口里只有一个方法 run(String... args)要实现的东西就一个方法体。任何被 Spring 管理并且实现了 CommandLineRunner 的 Bean在 Spring Boot 启动流程走到“ApplicationReadyEvent”之前都会被回调 run 方法。我用它的最原始动机是预热 Redis 缓存。项目启动后第一波用户访问若是直接打到数据库在高并发场景下很容易把数据库拖垮。有了 Runner我可以在应用完全对外提供服务之前把热点数据从数据库加载到 Redis。同理很多开关配置、字典表、黑白名单这种“启动时就必须有”的数据也都在这里做。不止缓存预热Runner 还常被用来做以下事情检查磁盘目录是否存在不存在就自动创建启动时拉取远程配置中心覆盖本地默认配置注册到服务注册中心或者向内部监控系统上报实例状态执行数据库初始化脚本比如 Flyway、Liquibase 之外的自定义数据修正初始化线程池、连接池做一次连通性测试。有一个容易混淆的点Runner 里的代码是在 Spring 容器刷新完成之后执行的但此时尚未真正“对外服务”。也就是说HTTP 接口已经注册完成但是请求是能进来的所以要控制好执行时机不能把耗时太长的初始化放到这里否则会拖慢启动过程。1.2 它和主线程、监听器之间的区别有些朋友上来常用“启动时执行”就把 CommandLineRunner 和常见写法划等号实际上差别还是挺大的。main 方法里的代码执行更早。SpringApplication.run() 是阻塞的run 方法返回之前整个容器还没起来。你要是把初始化逻辑写在 run() 之后那确实是在 Spring 容器启动完成后做但这里拿不到 Spring 管理的 Bean除了通过静态方式拿代码可读性和可测试性都很差。CommandLineRunner 则天然被 Spring 管理可以依赖注入比如注入 RedisTemplate、DataSource、自定义 Service。ServletContextListener 的 contextInitialized 方法也能做初始化但它的执行时机是在 Web 容器初始化时期Spring 的 ApplicationContext 可能都没刷完。Runner 是在容器刷新完成后调用也就是说所有 Bean 都已经实例化和注入完毕了再加上 Order 控制先后整体编排起来比 Listener 灵活太多。2. 基本用法从零实现一个 Runner2.1 最小实现三步完成一个Runner第一次写 CommandLineRunner 时我以为有多复杂结果实现一个类就完了。下面是一个最简示例启动时打印一行日志并初始化缓存。Component public class CacheInitRunner implements CommandLineRunner { private static final Logger log LoggerFactory.getLogger(CacheInitRunner.class); Override public void run(String... args) throws Exception { log.info( 开始预热热点数据缓存 ); // 这里写你的缓存初始化逻辑 // cacheService.initHotData(); log.info( 缓存预热完成 ); } }关键的步骤是三步实现 CommandLineRunner 接口把实现类交给 Spring 管理Component、Bean 等方式都可以在 run 方法里写你的初始化逻辑。这里我有必要加一点提醒run 方法签名里的 throws Exception很多人一看到就以为可以随便抛异常。但异常抛出来后的行为很重要——异常往上抛Spring Boot 的启动流程会中断应用直接启动失败。所以如果是业务非必需的任务最好 try-catch 吞掉如果是刚需任务比如连不上数据库就不能起服务那就让它抛让容器快速失败比带着残缺环境硬起好得多。2.2 参数从哪里来String... args 的真实身份每次写 run(String... args) 时总有朋友问这几个参数是干嘛的。其实它就是我们运行 java -jar app.jar 后面跟的命令行参数。比如java -jar my-app.jar --server.port8082 --envprod上面这两个参数会全部变成 args 字符串数组的元素注意它们的格式是 --keyvalue 这种。我用代码打印一下你就懂了。Component public class ArgsPrintRunner implements CommandLineRunner { Override public void run(String... args) throws Exception { System.out.println(Received args count: args.length); for (int i 0; i args.length; i) { System.out.println(args[ i ] args[i]); } } }启动后你会看到Received args count: 2 args[0] --server.port8082 args[1] --envprod这里有几个实用点。Spring Boot 会把 --server.port8082 自动解析到 Environment 里因此你在代码里通过 Value(${server.port}) 能取到。但如果你只想在 Runner 里根据参数去走不同的初始化分支上面那种遍历也是一种方式。更推荐的是先把 args 转成 ApplicationArguments 再玩命解析这个后续再说。有一点要注意如果参数是被编译进 jar 内部的 SpringApplication 构造参数而没有写在 java -jar 后面那 Runner 是拿不到这些参数的。所以别误以为 Runner 能拿到启动配置的全部参数。2.3 注册为 Bean 的两种写法大多数实例都是 Component但我也见过有人把所有 Runner 集中到一个配置类里可读性更高。例如Configuration public class RunnerConfig { Bean public CommandLineRunner initDataRunner(DataSource dataSource) { return args - { System.out.println(使用 DataSource 初始化数据); }; } }通过 Bean 方式定义的好处是方法参数可以自动注入需要的依赖比如这里直接注入 DataSource并且能把 Runner 的创建逻辑放在一个集中配置里。团队多人开发时为了避免每个人都新建一个类这种集中管理反而更清爽。用 Lambda 表达式是偷懒写法因为 CommandLineRunner 本身就是一个函数式接口所以编译器允许这样写。只是实在有多个动作时建议拆成不同 Runner 而不是在一个 Lambda 里塞几百行代码否则后面查问题简直是灾难。3. 执行顺序控制让 Runner 按你的批次运作说完了基础用法现在进入正题怎么让多个 Runner 按我们想要的顺序开工。说实话Spring Boot 官方对多 Runner 的默认执行顺序是“不确定的”如果不加干预实际顺序由 Bean 的实例化顺序决定而这个顺序在不同版本、不同包结构下都可能变化。所以要用明确的手段控制它。3.1 Order 注解最简单也最容易被忽视Order 是 Spring 生态很常见的排序注解在 CommandLineRunner 上同样有效。数字越小优先级越高启动越靠前。看下面这个例子Component Order(1) public class FirstRunner implements CommandLineRunner { Override public void run(String... args) throws Exception { System.out.println(执行顺序1); } } Component Order(2) public class SecondRunner implements CommandLineRunner { Override public void run(String... args) throws Exception { System.out.println(执行顺序2); } } Component Order(3) public class ThirdRunner implements CommandLineRunner { Override public void run(String... args) throws Exception { System.out.println(执行顺序3); } }启动后输出顺序必然是 1、2、3。这里有个很多人误会的地方Order(1) 并不是“第一个”的序号为1而是它在比较器中的排序值为1数值越小越靠前。那 Order(0)、Order(-1) 也都是合法的甚至 -1 会排在 0 前面。再补充一点Order 的默认值是 Integer.MAX_VALUE也就是最低优先级。这意味着没有加 Order 的 Runner 会排在所有有明确顺序的后面而且它们之间的相对顺序还是不可控。所以如果你有多个 Runner 都没有标注 Order那它们在执行顺序上依旧“随缘”。3.2 Ordered 接口适合需要动态调整的场景除了注解Spring 还提供了一个 Ordered 接口。实现它就得实现一个 getOrder() 方法返回 int。Spring 在排序时会通过 OrderedComparator 同时看该类是否实现 Ordered 接口、是否标注了 Order 注解。两者同时存在时一般以接口的 getOrder() 为准因为它是实际实例方法返回的值。为什么我会推荐在某些场景用 Ordered 接口因为 getOrder() 是个方法它可以通过配置动态计算。举个实际例子你的初始化任务 A 依赖外部开关开关开了才需要最优先执行否则它应该排到后面。用 Order 是写死的编译后不可变用 Ordered 接口方法里可以根据 Spring Environment 动态返回不同的值灵活得多。Component public class DynamicOrderRunner implements CommandLineRunner, Ordered { Value(${init.priority:10}) private int priority; Override public void run(String... args) throws Exception { System.out.println(动态顺序值 priority); } Override public int getOrder() { return priority; } }这段代码里priority 的值由配置项 init.priority 控制你可以通过不同环境变量让这个 Runner 在不同的部署环境中有不同的执行优先级。这种动态能力是注解给不了你的。3.3 排序的底层逻辑Spring 统一比较器坦白说我之前有段时间总觉得“明明加了 Order 为什么顺序没变”后面去翻源码才发现问题往往出在“Bean 不是通过 Spring 管理的”。来看看 Spring 是怎么处理 Runner 的Spring Boot 在启动时会获取所有 CommandLineRunner 和 ApplicationRunner 类型的 Bean然后通过AnnotationAwareOrderComparator.sort(list)来排序这个比较器综合了 Ordered 接口、Order 注解、Priority 注解。最后把所有 Runner 封装为Callable逐个调用。我整理了一个简单对照表帮你快速理解排序规则实现顺序优先级说明数字越小越先执行-1 0 1 2Ordered 接口高于 Order 注解接口返回值实际生效同时实现以 Ordered 接口为准绝大多数场景一致都没有默认 MAX_VALUE排序靠后相对不确定看到这张表你在排查顺序问题时就有一个清晰的思路先看是不是实现了 Ordered 接口覆盖了注解的值再看排序值到底是多少、有没有拼错类。4. 实战案例多 Runner 综合编排4.1 场景设计为了把顺序控制体现得淋漓尽致我设计一个典型的应用启动场景大家可以直接拿到项目里改。假设有一个商城服务启动时需要执行这么几件事创建上传目录先做什么都不影响但为了日志明确放在最早加载本地缓存字典数据依赖数据库、基础的配置所以排在目录创建后预热 Redis 商品缓存依赖本地缓存里的字典生效排在字典加载后发送启动通知邮件依赖商品缓存完成放在最后而且发邮件失败不能影响主流程。顺序要求非常明确A - B - C - D。如果次序乱了比如邮件先发出去了可缓存还没预热那监控同事收到邮件一查数据还是空的就闹笑话了。4.2 代码落地根据场景我拆成四个 Runner用 Order 控制顺序每个 Runner 职责单一Component Order(1) public class DirectoryInitRunner implements CommandLineRunner { private static final Logger log LoggerFactory.getLogger(DirectoryInitRunner.class); Value(${upload.path:/data/upload}) private String uploadPath; Override public void run(String... args) { File dir new File(uploadPath); if (!dir.exists()) { boolean created dir.mkdirs(); log.info(创建上传目录 {}结果{}, uploadPath, created); } else { log.info(上传目录已存在{}, uploadPath); } } }Component Order(2) public class DictDataInitRunner implements CommandLineRunner { private static final Logger log LoggerFactory.getLogger(DictDataInitRunner.class); private final DictCacheService dictCacheService; public DictDataInitRunner(DictCacheService dictCacheService) { this.dictCacheService dictCacheService; } Override public void run(String... args) { log.info(开始加载本地字典缓存); dictCacheService.loadAllDict(); log.info(本地字典缓存加载完成共 {}, dictCacheService.count()); } }Component Order(3) public class RedisCachePreheatRunner implements CommandLineRunner { private static final Logger log LoggerFactory.getLogger(RedisCachePreheatRunner.class); private final RedisTemplateString, Object redisTemplate; private final GoodsService goodsService; public RedisCachePreheatRunner(RedisTemplateString, Object redisTemplate, GoodsService goodsService) { this.redisTemplate redisTemplate; this.goodsService goodsService; } Override public void run(String... args) { log.info(开始预热商品缓存); goodsService.getHotGoodsList().forEach(goods - { redisTemplate.opsForValue().set(hot:goods: goods.getId(), goods); }); log.info(商品缓存预热完成); } }Component Order(4) public class NotifyAdminRunner implements CommandLineRunner { private static final Logger log LoggerFactory.getLogger(NotifyAdminRunner.class); private final NotifyService notifyService; public NotifyAdminRunner(NotifyService notifyService) { this.notifyService notifyService; } Override public void run(String... args) { try { notifyService.sendStartupNotification(); log.info(启动通知发送成功); } catch (Exception e) { log.error(启动通知发送失败忽略该异常不影响主流程, e); } } }注意到没有DirectoryInitRunner 没有注入其他依赖所以它一定最先被创建DictDataInitRunner 注入了 DictCacheServiceRedisCachePreheatRunner 注入了 RedisTemplate 和 GoodsServiceNotifyAdminRunner 注入了 NotifyService。虽然这些 Bean 的实例化顺序未必和 Runner 的执行顺序完全一致但最终执行顺序由 Order 保证。4.3 验证启动日志为了确认顺序没反我启动应用控制台输出如下 创建上传目录 /data/upload结果true 开始加载本地字典缓存 本地字典缓存加载完成共 256 开始预热商品缓存 商品缓存预热完成 启动通知发送成功看到这个顺序心里就踏实了。其实这种“日志分段法”是我自己惯用的验证方式在每个 Runner 里都加上 start 和 end 两行日志比看代码猜顺序高效得多。5. 常见问题与避坑实录这部分内容是我在这些年项目中真正踩过的坑也是很多同事反复问我的问题。整理成一个排查表之后再展开解释问题现象可能原因解决方案Runner 不执行类没有被 Spring 扫描到检查包扫描路径加 Component 或注册为 BeanRunner 不执行且启动报错run 方法里抛出了未捕获异常看异常堆栈按需 try-catch 或让启动失败多个 Runner 顺序错乱未使用 Order或排序值为默认值统一加 Order确认数字大小顺序有但不符合预期某些 Runner 实现了 Ordered 接口动态覆盖了注解值检查 getOrder() 返回值重复执行Runner 同时被注册了多种路径检查是否有重复的 Bean 声明阻塞启动run 方法里做了网络请求或长耗时任务根据业务考虑异步化或提前初始化5.1 Runner 不执行的三类原因第一类忘了标注 Component或者类放在了 Spring Boot 启动类所在包的扫描路径之外。Spring Boot 默认只扫描启动类包及子包你要是把 Runner 类放在一个完全无关的包又不加 Bean 手动注册它当然不会执行。这种问题最隐蔽因为 IDE 编译不报错。第二类你在配置类里用 Bean 注册了两个同名同类型的 Runner其中一个覆盖了另一个。Spring 容器对同类型 Bean 的处理是允许存在的但如果你使用相同方法名、同时又有 Component可能会造成其中一个被跳过或者注入混乱导致你看到的 Runner 不是你想的那个。第三类Runner 确实执行了但日志被其他输出冲掉了于是你以为没执行。这个太常见了尤其在启动日志非常多的场景中。排查时不要靠肉眼可以直接在 run 方法第一行打一条异常日志或者用调试工具断点确认。5.2 顺序不对怎么排查顺序不对先打印所有 Runner 的执行顺序。最快的办法是在每个 Runner 里加上 System.out.println( 当前Runner类: xxx )启动后看打印的先后。别急着改代码先用日志定位是哪几个时候乱。定位后检查三类情况是否有的类没有加 Order是否有 Order 值相同比如两个都是默认值两个都写了 Order(1) 也会出现相对无序是否某几个 Runner 实现了 Ordered 接口其返回值压过了注解。这里有个小技巧不想改每一个 Runner 也没关系写一个全局的 Runner 来打印排序后仍在运行的 Runner 名称。利用 ApplicationRunner 或 CommandLineRunner 是拿不到“排序前的全部列表”的但可以用Component里的ObjectProviderCommandLineRunner来收集不过我认为最直接的办法还是一行日志在 Volcano Runner 里先打出来。有耐心的话可以直接 Debug 到CallableRunner的 call 方法里下断点看 Step 信息里的顺序数组从原理上定位。5.3 生产环境不想执行某个 Runner 怎么办这其实是“条件控制”问题。在我早期的一个组件里我想在本地开发时不执行邮件通知可我又不想排除消息。可以这样做Component public class ConditionalRunner implements CommandLineRunner { Value(${notification.enabled:false}) private boolean notificationEnabled; Override public void run(String... args) { if (!notificationEnabled) { System.out.println(通知功能未开启跳过); return; } System.out.println(执行通知任务); } }简单粗暴适合单一环境的开关。如果要区分不同 profiledev/test/prod可以用 Profile 注解把 Runner 拆开Component Profile(prod) Order(1) public class ProdOnlyRunner implements CommandLineRunner { Override public void run(String... args) { System.out.println(只有生产环境会执行); } }这样在 dev 环境启动时那个 Runner 压根不会被实例化也不会执行。5.4 run 方法里能不能做耗时操作你说耗时多长算长通常启动阶段都是尽量快Runner 里如果处理数据库全量导出、远程调用大接口那启动时间将以秒甚至分钟计算影响可用性。我的建议是如果任务必须等它完成后才响应外部请求比如初始化密钥留在 Runner 里同步执行如果不过是要后台慢慢预热完全可以自己开一个线程Runner 里丢出一个异步任务立即返回。但注意应用安全退出时异步任务可能没跑完需要配合线程池的优雅停机。如果任务有重启后瞬态恢复需求建议放到 ApplicationReadyEvent 之后因为那已经算应用可用状态。6. 更进一步的扩展玩法6.1 ApplicationRunner 和 CommandLineRunner 其实是一对孪生Spring Boot 除了 CommandLineRunner还有 ApplicationRunner。两者执行时机完全相同排序规则也一样会被统一排序唯一区别是 run 方法的参数类型。CommandLineRunner 拿到的是原生 String 数组ApplicationRunner 拿到的是 ApplicationArguments 对象它是 Spring Boot 对命令行参数做了一层封装可以方便处理--keyvalue形式Component Order(1) public class ArgsRunner implements ApplicationRunner { Override public void run(ApplicationArguments args) throws Exception { System.out.println(所有参数 args.getOptionNames()); System.out.println(根据 key 取值 args.getOptionValues(env)); System.out.println(非选项参数 args.getNonOptionArgs()); } }启动命令java -jar app.jar --envprod --envtest debug上面的代码能得到所有参数[env] 根据 key 取值[prod, test] 非选项参数[debug]所以在实际项目中我优先推荐用 ApplicationRunner 处理参数因为它能区分“带 -- 的选项参数”和“裸参数”还能拿到同一个 key 的多个值。如果你只在 F 升级前用 CommandLineRunner那这篇文章讲到这里的很多细节也完全能兼容因为两者通分在同一排序体系里。6.2 测试环境中轻松跳过 Runner另外一个实际技巧如果某个 Runner 在你的自动化测试中不适合执行可以在测试配置里通过MockitoBean或者排除符合条件条件的 Bean 实现。例如使用 Spring Boot 测试时SpringBootTest MockBean class NotifyAdminRunner class MyServiceTest { // 测试类逻辑 }被 MockBean 标记后容器里的 NotifyAdminRunner 会被替换成一个 Mock 实例run 方法什么也不做。这比手动写开关更优雅也符合隔离测试的原则。如果你不想使用 Mockito还可以用TestConfiguration里的静态 Bean 覆盖不过那需要配置类的静态方法返回一个空 Runner比较繁琐。我个人更推荐 MockBean一句话完事。6.3 结合 ApplicationReadyEvent 做更精细化的启动后逻辑CommandLineRunner 本身的执行时机其实是SpringApplication.run的callRunners阶段顺序上早于 ApplicationReadyEvent。如果你有些逻辑需要等整个应用“完全就绪”之后再做那最好监听 ApplicationReadyEvent。下面是一个实例Component public class AppReadyListener { EventListener(ApplicationReadyEvent.class) public void onApplicationReady() { System.out.println(应用完全就绪执行最终检查); } }有些业务在“就绪后做”和“启动时做”是有差别的。比如健康检查接口正在监听但缓存还没预热这时候有请求进来就可能拿到错误数据。如果你把预热逻辑放在 Runner 里它执行时健康检查还没上线如果你放在 ApplicationReadyEvent 里那就完全暴露了。到底哪个好取决于业务对“一致性”的容忍度。我通常把“必要且快速”的放 Runner“可延迟但不影响主流程”的放 Ready 事件。6.4 自定义一个可重复执行的初始化组件如果你想做更“高级”一点的东西可以用一个 Manager 类把初始化任务封装成一个个 List通过注入多个 Runner 后动态执行。但这其实没必要Spring 已经替你管理了 Runner 清单你只需要定义 Bean 的顺序即可。我自己常用的一种扩展是在 Runner 里调用一个“初始化状态标记”存储到 Redis 或者数据库防止集群多实例同时启动时重复执行同一种初始化。本质上是分布式锁的问题。比如有两个应用实例同时启动都执行 Runner A那会重复建表或重复初始化数据。用分布式锁包住 Runner 关键动作能让并发启动安全无虞。Component Order(5) public class DistributedInitRunner implements CommandLineRunner { private final RedissonClient redissonClient; public DistributedInitRunner(RedissonClient redissonClient) { this.redissonClient redissonClient; } Override public void run(String... args) { RLock lock redissonClient.getLock(app-init-lock); try { if (lock.tryLock(10, TimeUnit.SECONDS)) { // 执行初始化 System.out.println(集群环境加锁执行初始化); } else { System.out.println(其他实例正在初始化本实例跳过); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这个思路很实用尤其现在大部分项目都会多实例部署。如果你不用分布式锁只用本地标记那多实例情况下每个实例都会执行一遍如果任务是幂等的倒还好不是幂等的就会产生脏数据。最后再分享一个小技巧公司内部代码规范通常会强制所有 Runner 类名后缀带InitRunner或者StartupRunner这样你在包里一眼就能看到哪几个类参与了启动初始化。排查顺序问题时先目录树看一遍再用 Order 统一控制启动日志就不再“随缘”了。希望这篇文章里提到的经验和踩坑点能帮你把 Spring Boot 启动阶段的行为牢牢握在手里。
返回列表