使用 spring boot 开发通用程序

简介: * tag: spring 学习笔记 * date: 2018-03 spring 是什么?spring 核心是应用组件容器,管理组件生命周期,依赖关系,并提倡面向接口编程实现模块间松耦合。 spring boot 是什么?spring boot 是按特定(约定)方式使用 spring 及相关程序库以简化应用开发的一套框架和工具。 以下统称 spring。 本文使用 spring b
  • tag: spring 学习笔记
  • date: 2018-03

spring 是什么?spring 核心是应用组件容器,管理组件生命周期,依赖关系,并提倡面向接口编程实现模块间松耦合。
spring boot 是什么?spring boot 是按特定(约定)方式使用 spring 及相关程序库以简化应用开发的一套框架和工具。
以下统称 spring。
本文使用 spring boot 2.0.0.RELEASE 测试。

ApplicationRunner

spring 广泛应用于 web 应用开发,使用 spring 开发命令行工具、后台服务等通用程序也非常方便。
开发 web 应用时,web 服务器(如 tomcat)启动后即开始监听请求。
开发命令行工具时,只需要实现一个 ApplicationRunner,spring 容器启动后即自动执行之。
如开发一个查看文件大小的示例程序 atest.filesize.App,代码如下:

public class App implements ApplicationRunner {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(App.class);
        app.setBannerMode(Banner.Mode.OFF);
        app.run(args);
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<String> fileList = args.getNonOptionArgs();
        Validate.isTrue(!fileList.isEmpty(), "missing file");
        Validate.isTrue(fileList.size() == 1, "require only one file, got: %s", fileList);
        String path = fileList.get(0);
        File file = new File(path);
        if (!file.exists()) {
            throw new FileNotFoundException(path);
        }
        long size = file.length();
        System.out.println(size);
    }
    
}
  • ApplicationArguments 是 spring boot 解析后的命令行参数。
    如果需要原始命令行参数,可以调用 args.getSourceArgs(),或使用 CommandLineRunner
  • spring 容器生命周期即应用生命周期,spring boot 默认注册了 spring 容器 shutdown hook,jvm 退出时会自动关闭 spring 容器。
    当然也可以手动关闭 spring 容器,这时会自动移除注册的 shutdown hook。

程序退出码

程序退出时通常返回非 0 退出码表示错误(或非正常结束),方便 shell 脚本等自动化检查控制。
命令行下运行应用并查看退出码:

mvn compile dependency:build-classpath -Dmdep.outputFile=target/cp.txt
java -cp "target/classes/:$(cat target/cp.txt)" atest.filesize.App ; echo "exit code: ${?}"

可看到主线程抛出异常时,java 进程默认返回非 0 退出码(默认为 1)。
ApplicationRunner 在主线程中执行,异常堆栈如下:

java.lang.IllegalStateException: Failed to execute ApplicationRunner
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:784)
    at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:771)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
    at atest.filesize.App.main(App.java:18)
Caused by: java.lang.IllegalArgumentException: missing file
    at org.apache.commons.lang3.Validate.isTrue(Validate.java:155)
    at atest.filesize.App.run(App.java:24)
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:781)
    ... 3 common frames omitted

spring boot 主线程中处理异常时,SpringBootExceptionHandler 默认将自己设置为线程 UncaughtExceptionHandler,
其检查发现已经打印了异常日志,因此不再打印异常到 stderr。

程序异常映射为退出码

主线程发生异常时还可以自定义设置退出码:

  • 配置 ExitCodeExceptionMapper 可将主线程产生的异常映射为退出码。
  • 此时还会调用 "getExitCodeFromExitCodeGeneratorException()" 检查异常本身是否为 ExitCodeGenerator。
    SpringApplication.exit() 没有这个逻辑,为保持一致性,不建议使用此类异常(?)。
  • 定义好异常和退出码规范,可方便实现自动化检查控制。

将异常映射为退出码,示例代码如下:

public class AppExitCodeExceptionMapper implements ExitCodeExceptionMapper {

    @Override
    public int getExitCode(Throwable exception) {
        return 2;
    }

}
  • 上述简单示例将所有 Throwable 映射为退出码 2。

分析 spring 相关代码。

查看主线程 handleRunFailure 调用栈:

Thread [main](Suspended)
    SpringApplication.getExitCodeFromMappedException(ConfigurableApplicationContext, Throwable) line: 881    
    SpringApplication.getExitCodeFromException(ConfigurableApplicationContext, Throwable) line: 866    
    SpringApplication.handleExitCode(ConfigurableApplicationContext, Throwable) line: 852    
    SpringApplication.handleRunFailure(ConfigurableApplicationContext, SpringApplicationRunListeners, Collection<SpringBootExceptionReporter>, Throwable) line: 803    
    SpringApplication.run(String...) line: 338    
    App.main(String[]) line: 29    

SpringApplication 方法:

    private void handleExitCode(ConfigurableApplicationContext context,
            Throwable exception) {
        int exitCode = getExitCodeFromException(context, exception);
        if (exitCode != 0) {
            if (context != null) {
                context.publishEvent(new ExitCodeEvent(context, exitCode));
            }
            SpringBootExceptionHandler handler = getSpringBootExceptionHandler();
            if (handler != null) {
                handler.registerExitCode(exitCode);
            }
        }
    }
    
    private int getExitCodeFromException(ConfigurableApplicationContext context,
            Throwable exception) {
        int exitCode = getExitCodeFromMappedException(context, exception);
        if (exitCode == 0) {
            exitCode = getExitCodeFromExitCodeGeneratorException(exception);
        }
        return exitCode;
    }
    
    private int getExitCodeFromMappedException(ConfigurableApplicationContext context,
            Throwable exception) {
        if (context == null || !context.isActive()) {
            return 0;
        }
        ExitCodeGenerators generators = new ExitCodeGenerators();
        Collection<ExitCodeExceptionMapper> beans = context
                .getBeansOfType(ExitCodeExceptionMapper.class).values();
        generators.addAll(exception, beans);
        return generators.getExitCode();
    }

ExitCodeGenerators 方法:

    public void add(Throwable exception, ExitCodeExceptionMapper mapper) {
        Assert.notNull(exception, "Exception must not be null");
        Assert.notNull(mapper, "Mapper must not be null");
        add(new MappedExitCodeGenerator(exception, mapper));
    }

spring boot 获取退出码并注册到 SpringBootExceptionHandler,
其将自己设置为线程 UncaughtExceptionHandler,退出码非 0 时调用 System.exit() 退出进程。

代码 SpringBootExceptionHandler.LoggedExceptionHandlerThreadLocal

        @Override
        protected SpringBootExceptionHandler initialValue() {
            SpringBootExceptionHandler handler = new SpringBootExceptionHandler(
                    Thread.currentThread().getUncaughtExceptionHandler());
            Thread.currentThread().setUncaughtExceptionHandler(handler);
            return handler;
        }

代码 SpringBootExceptionHandler

    @Override
    public void uncaughtException(Thread thread, Throwable ex) {
        try {
            if (isPassedToParent(ex) && this.parent != null) {
                this.parent.uncaughtException(thread, ex);
            }
        }
        finally {
            this.loggedExceptions.clear();
            if (this.exitCode != 0) {
                System.exit(this.exitCode);
            }
        }
    }

这里直接调用 System.exit() 过于粗暴,因此 只有主线程 handleRunFailure 执行了这个逻辑。

多线程应用中工作线程发生异常,可否设置进程退出码呢?

多线程应用结构

先来看看多线程应用结构:

  • 多线程应用默认最后一个非 deamon 线程结束后退出进程。
  • 可以显式控制应用生命周期,显式执行退出,这样就不用关心是否 daemon 线程,简化开发。
  • 退出应用时显式关闭 Spring 容器,线程池也由 spring 容器管理,此时即可退出所有线程。非 daemon 线程可以更优雅的结束,因为 jvm 会等待其结束。
  • 应用退出前需要保持至少一个非 daemon 线程,主线程即可作为这个线程,实现应用主控逻辑,主函数结束即退出应用。
  • 桌面应用、后台服务(如 web 服务器)等需要显式等待应用退出。显式等待应放在主函数主控逻辑之后。即所有 ApplicationRunner 之后,避免阻塞其他 ApplicationRunner。
  • 为简单一致性,将显式退出(关闭容器)操作放在主函数等待结束后。容器在主线程中创建,在主线程中销毁,逻辑更加清晰和一致。
  • Ctrl-C 或 kill 等显式退出进程时,shutdown hook 会关闭容器,但不会等待非 deamon 线程(如主线程)。(会唤醒 sleep ?但不会影响 CountDownLatch.await() ?)

示例程序移动部分逻辑到工作线程,代码如下:

public class App implements ApplicationRunner {

    private static final Logger logger = LoggerFactory.getLogger(App.class);
    
    public static void main(String[] args) throws Exception {
        SpringApplication app = new SpringApplication(App.class, AppExitCodeExceptionMapper.class);
        app.setBannerMode(Banner.Mode.OFF);
        try (ConfigurableApplicationContext ctx = app.run(args)) {
            ctx.getBean(App.class).await();
        }
    }
    
    protected ExecutorService executor;
    protected CountDownLatch done = new CountDownLatch(1);
    
    @PostConstruct
    protected void init() {
        executor = Executors.newSingleThreadExecutor();
    }
    
    @PreDestroy
    protected void destroy() {
        executor.shutdownNow();
        try {
            executor.awaitTermination(5000, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            logger.error("executor.awaitTermination() interrupted", e);
        }
    }
    
    public void await() throws InterruptedException {
        done.await();
    }
    
    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<String> fileList = args.getNonOptionArgs();
        Validate.isTrue(!fileList.isEmpty(), "missing file");
        Validate.isTrue(fileList.size() == 1, "require only one file, got: %s", fileList);
        String path = fileList.get(0);
        Runnable fileSizeTask = () -> {
            File file = new File(path);
            if (!file.exists()) {
                throw new IllegalArgumentException("file not found: " + path);
            }
            long size = file.length();
            System.out.println(size);
        };
        Runnable mainTask = () -> {
            try {
                fileSizeTask.run();
            } finally {
                done.countDown();
            }
        };
        executor.execute(mainTask);
    }
    
}
  • 主函数中使用 "try with resource" 语法在退出主函数前自动执行 ctx.close() 关闭容器(退出应用)。
  • 使用 CountDownLatch 可以扩展支持多个模块(异步任务),等待所有模块都结束才退出应用。
  • 正常退出时没有必要 executor.awaitTermination(),但 Ctrl-C 等显式退出进程触发 shutdown hook 时,可以尝试预留一点时间给线程优雅退出。

运行程序可知:

  • 主线程检查参数不对抛出异常时,会返回错误码(可配置 ExitCodeExceptionMapper 映射错误码)。
  • 工作线程检查文件不存在抛出异常时,主线程感知不到,默认返回正常退出码 0 。
  • 工作线程没有设置 UncaughtExceptionHandler,默认在 stderr 打印异常:
Exception in thread "pool-1-thread-1" java.lang.IllegalArgumentException: file not found: xx
    at atest.filesize.App.lambda$0(App.java:65)
    at atest.filesize.App.lambda$1(App.java:72)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)

捕获线程池异常

针对线程池的场景,可以使用 "submit + future.get()" 使工作线程的异常传播到主线程,这也是一种主线程等待多个模块结束的办法。
简单修改代码如下:

        executor.submit(mainTask).get();

主线程抛出异常并映射为退出码,异常调用栈如下:

java.lang.IllegalStateException: Failed to execute ApplicationRunner
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:784)
    at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:771)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
    at atest.filesize.App.main(App.java:29)
Caused by: java.util.concurrent.ExecutionException: java.lang.IllegalArgumentException: file not found: xx
    at java.util.concurrent.FutureTask.report(FutureTask.java:122)
    at java.util.concurrent.FutureTask.get(FutureTask.java:192)
    at atest.filesize.App.run(App.java:78)
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:781)
    ... 3 common frames omitted
Caused by: java.lang.IllegalArgumentException: file not found: xx
    at atest.filesize.App.lambda$0(App.java:65)
    at atest.filesize.App.lambda$1(App.java:72)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)
  • 工作线程异常被线程池包装为 ExecutionException ,需要进一步 "getCause()" 才能拿到原始异常。
  • 注意: "submit()" 使用 FutureTask 包装 Runnable 任务后,任务异常被捕获(参考 FutureTask.run()),即不会默认打印到 stderr。
    如果此时主线程不调用 "future.get()",则异常将被吃掉,即发生了异常但两个地方都看不到。

除了传播异常到主线程,工作线程还有其他办法设置退出码吗?

使用 ExitCodeGenerator 设置退出码

回顾前面映射异常为退出码的代码,其中将异常转换为注册 ExitCodeGenerator,
这个逻辑在主线程发生异常时发生,"SpringApplication.handleRunFailure()" 调用到 "handleExitCode()"。

实际上应用可以直接在 Spring 容器中注册 ExitCodeGenerator。
可以注册多个 ExitCodeGenerator,spring boot 尝试返回最严重的错误(退出码数值最大)。
应用模块不必将异常传播到主线程,只需要注册和正确设置 ExitCodeGenerator,这里可以考虑复用异常映射退出码的逻辑,保持一致。
主线程退出应用时调用 SpringApplication.exit() 即可拿到最终退出码并关闭容器。

添加 ExitCodeGenerator 示例代码如下:

    @Autowired
    protected AppExitCodeExceptionMapper exitCodeMapper;
    protected int fileSizeExitCode = 0;
    
    @Bean
    public ExitCodeGenerator fileSizeExitCodeGenerator() {
        return () -> {
            return fileSizeExitCode;
        };
    }
    
    @Override
    public void run(ApplicationArguments args) throws Exception {
        // ... ...
        Runnable fileSizeTaskWrapper = () -> {
            try {
                // 添加 sleep, 测试 Ctrl-C 触发 shutdown hook
                Thread.sleep(2000);
                fileSizeTask.run();
            } catch (Throwable ex) {
                // 设置退出码
                fileSizeExitCode = exitCodeMapper.getExitCode(ex);
                logger.error("fileSizeTask error, exitCode: {}", fileSizeExitCode, ex);
            } finally {
                // 任务结束
                done.countDown();
            }
        };
        executor.execute(fileSizeTaskWrapper);
    }

修改主函数如下:

    public static void main(String[] args) throws Exception {
        SpringApplication app = new SpringApplication(App.class, AppExitCodeExceptionMapper.class);
        app.setBannerMode(Banner.Mode.OFF);
        ConfigurableApplicationContext ctx = app.run(args);
        try {
            ctx.getBean(App.class).await();
        } finally {
            // 获取退出码,关闭容器,退出进程
            System.exit(SpringApplication.exit(ctx));
        }
    }
  • 退出应用 SpringApplication.exit() 和退出进程 System.exit() 拆分开,
    假如以模块方式引入某个子应用,可以只退出子应用但不退出进程。

进程退出码总结:

  • 主线程发生异常时,映射异常为退出码。
  • 主线程未发生异常时,SpringApplication.exit() 获取退出码。
  • Ctrl-C 等显式退出进程时,jvm 自动设置退出码(不能自定义设置),shutdown hook 关闭容器。

shutdown hook 与退出码

Ctrl-C 等显式退出进程时,shutdown hook 关闭容器,主线程持续阻塞,不会执行 SpringApplication.exit()System.exit(),这是预期的行为。

  • SpringApplication.exit() 需要从容器获取 ExitCodeGenerator,容器关闭后不能正常执行。
  • 触发 shutdown hook 后不能调用 System.exit(),从而不能设置进程退出码。
    参考 Runtime.exit() 文档:
>If this method is invoked after the virtual machine has begun its shutdown sequence 

then if shutdown hooks are being run this method will block indefinitely.

通常约定 kill 进程时(Ctrl-C 等于 kill -SIGINT)返回退出码为 `128 + <信号值>`,jvm 默认遵循这个约定。

虽然不能设置进程退出码,能否将获取应用退出码的逻辑移动到 shutdown hook(此时不要注册关闭容器 shutdown hook),仍然计算退出码呢?
这样也有问题,不建议这样做。

  • 主线程发生异常映射退出码时,这些映射没有添加到容器中,SpringApplication.exit() 拿到的退出码与实际退出码不一致。
  • shutdown hook 触发 SpringApplication.exit() 立即执行,此时工作线程还没来得及设置退出码,拿到的退出码不对。
  • 如果 shutdown hook 中添加等待工作线程结束,则可能违背了希望立即退出的约定(可考虑不关闭容器但通知组件退出后等待?)。

https://oolap.com/spring/spring-boot-generic-app

相关文章
|
30天前
|
人工智能 运维 Java
Spring AI Alibaba Admin 开源!以数据为中心的 Agent 开发平台
Spring AI Alibaba Admin 正式发布!一站式实现 Prompt 管理、动态热更新、评测集构建、自动化评估与全链路可观测,助力企业高效构建可信赖的 AI Agent 应用。开源共建,现已上线!
2591 40
|
1月前
|
安全 前端开发 Java
《深入理解Spring》:现代Java开发的核心框架
Spring自2003年诞生以来,已成为Java企业级开发的基石,凭借IoC、AOP、声明式编程等核心特性,极大简化了开发复杂度。本系列将深入解析Spring框架核心原理及Spring Boot、Cloud、Security等生态组件,助力开发者构建高效、可扩展的应用体系。(238字)
|
3月前
|
前端开发 Java API
利用 Spring WebFlux 技术打造高效非阻塞 API 的完整开发方案与实践技巧
本文介绍了如何使用Spring WebFlux构建高效、可扩展的非阻塞API,涵盖响应式编程核心概念、技术方案设计及具体实现示例,适用于高并发场景下的API开发。
337 0
|
5月前
|
XML 人工智能 Java
优化SpringBoot程序启动速度
本文介绍了三种优化SpringBoot启动速度的方法:1) 延迟初始化Bean,通过设置`spring.main.lazy-initialization`为true,将耗时操作延后执行;2) 创建扫描索引,利用`spring-context-indexer`生成@ComponentScan的索引文件,加速类扫描过程;3) 升级至最新版SpringBoot,享受官方性能优化成果。这些方法能显著提升程序编译与启动效率。
1306 0
|
2月前
|
存储 安全 Java
如何在 Spring Web 应用程序中使用 @SessionScope 和 @RequestScope
Spring框架中的`@SessionScope`和`@RequestScope`注解用于管理Web应用中的状态。`@SessionScope`绑定HTTP会话生命周期,适用于用户特定数据,如购物车;`@RequestScope`限定于单个请求,适合无状态、线程安全的操作,如日志记录。合理选择作用域能提升应用性能与可维护性。
123 1
|
2月前
|
安全 数据可视化 Java
AiPy开发的 Spring 漏洞检测神器,未授权访问无所遁形
针对Spring站点未授权访问问题,现有工具难以检测如Swagger、Actuator等组件漏洞,且缺乏修复建议。全新AI工具基于Aipy开发,具备图形界面,支持一键扫描常见Spring组件,自动识别未授权访问风险,按漏洞类型标注并提供修复方案,扫描结果可视化展示,支持导出报告,大幅提升渗透测试与漏洞定位效率。
|
3月前
|
缓存 Java API
Spring WebFlux 2025 实操指南详解高性能非阻塞 API 开发全流程核心技巧
本指南基于Spring WebFlux 2025最新技术栈,详解如何构建高性能非阻塞API。涵盖环境搭建、响应式数据访问、注解与函数式两种API开发模式、响应式客户端使用、测试方法及性能优化技巧,助你掌握Spring WebFlux全流程开发核心实践。
631 0
|
3月前
|
存储 NoSQL Java
探索Spring Boot的函数式Web应用开发
通过这种方式,开发者能以声明式和函数式的编程习惯,构建高效、易测试、并发友好的Web应用,同时也能以较小的学习曲线迅速上手,因为这些概念与Spring Framework其他部分保持一致性。在设计和编码过程中,保持代码的简洁性和高内聚性,有助于维持项目的可管理性,也便于其他开发者阅读和理解。
128 0
|
5月前
|
Java API 网络架构
基于 Spring Boot 框架开发 REST API 接口实践指南
本文详解基于Spring Boot 3.x构建REST API的完整开发流程,涵盖环境搭建、领域建模、响应式编程、安全控制、容器化部署及性能优化等关键环节,助力开发者打造高效稳定的后端服务。
763 1

热门文章

最新文章