Spring Boot 工程开发常见问题解决方案,日常开发全覆盖

本文是 SpringBoot 开发的干货集中营,涵盖了日常开发中遇到的诸多问题,通篇着重讲解如何快速解决问题,部分重点问题会讲解原理,以及为什么要这样做。便于大家快速处理实践中经常遇到的小问题,既方便自己也方便他人,老鸟和新手皆适合,值得收藏 😄

1. 哪里可以搜索依赖包的 Maven 坐标和版本

  • https://mvnrepository.com/

    这个在2023年前使用得最多,但目前(2024)国内访问该网站时,经常卡死在人机校验这一步,导致无法使用

  • https://central.sonatype.com/

    刚开始我是临时用这个网站来替换前面那个,现在它越来越好用,就直接使用它了

2. 如何确定 SpringBoot 与 JDK 之间的版本关系

Spring官网 可以找到 SpringBoot 对应的 JDK 关系,但这种关系说明位于具体版本的参考手册(Reference Doc)中,按照以下图示顺序操作即可找到。

进入SpringBoot参考手册页面

点击 Quick Start

查看 System Requirement

重大版本与JDK及Spring基础框架的对应关系表

Spring Boot 版本 JDK 版本 Spring Framework 版本
2.7.18 JDK8 + 5.3.31 +
3.2.3 JDK17 + 6.1.4 +

3. 如何统一处理Web请求的JSON日期格式问题

方式一:编程式声明

在 JacksonAutoConfiguration 装配前, 先装配一个 Jackson2ObjectMapperBuilderCustomizer,并在这个 Customizer 中设置日期格式。如下所示:

@Configuration
@ConditionalOnClass(ObjectMapper.class)
@AutoConfigureBefore(JacksonAutoConfiguration.class)     // 本装配提前于官方的自动装配
 ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer myJacksonCustomizer() {
        return builder -> {
            builder.locale(Locale.CHINA);
            builder.timeZone(TimeZone.getTimeZone(ZoneId.systemDefault()));
            builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");
    }
}    

方式二:配置式声明 <推荐>

参考下面的示例代码即可,关键之处是要指定 spring.http.converters.preferred-json-mapper 的值为 jackson, 否则配置不生效

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    locale: zh_CN
    time-zone: "GMT+8"
  http:
    converters:
      preferred-json-mapper: jackson
       ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄

4. 如何以静态方式访问Bean容器

写一个实现了 ApplicationContextAware 接口的类,通过该接口的 setApplicationContext()方法,获取 ApplicationContext, 然后用一个静态变量来持有它。之后便可以通过静态方法使用 ApplicationContext 了。Spring 框架在启动完成后,会遍历容器中所有实现了该接口的Bean,然后调用它们的setApplicationContext()方法,将ApplicationContext(也就是容器自身)作为参数传递过去。下面是示例代码:

import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

@Component
public class ApplicationContextHolder implements ApplicationContextAware {

    // 声明一个静态变量来持有 ApplicationContext 
    private static ApplicationContext appContext;

    @Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
		ApplicationContextHolder.appContext = applicationContext;
	}

    public static ApplicationContext getContext() {
        return ApplicationContextHolder.appContext;
    }

}

5. 如何将工程打包成一个独立的可执行jar包

按以下三步操作即可(仅针对maven工程):

  • 在 pom.xml 中添加 spring boot 的构建插件
  • 为上一步的插件配置执行目标
  • 在工程目录下,命令行执行 maven clean package -Dmaven.test.skip=true
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
			<version>2.1.6.RELEASE</version>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

🔔 关于 spring-boot-maven-plugin 插件的版本问题

如果不指定版本,默认会去下载最新的,这极有可能与代码工程所用的 jdk 版本不兼容,导致打包失败。那么应该用哪个版本呢?一个简单的办法,是先进入到本机的 Maven 仓库目录,然后再分别打开以下两个目录

  • org/springframework/boot/spring-boot
  • org/springframework/boot/spring-boot-maven-plugin

再结合自己工程的spring-boot版本(可通过IDE查看),选择相同版本或稍低版本的plugin插件

6. 如何从jar包外部读取配置文件

在 Java 启动命令中添加 spring-boot 配置文件相关参数,指定配置文件的位置,如下所示:

java -jar xxxx.jar --spring.config.location={yaml配置文件绝对路径} 
                    ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄

指定外部配置文件还有其它一些方式,详情参见 SpringBoot项目常见配置

📌 特别说明:

--spring.config.location 这个配置项一定要写在 xxxx.jar 之后,因为这是一个 SpringApplication 的参数,不是 java 命令的参数或选项,该参数最终是传递到了 main 方法的 args 变量上,因此在 main 方法中构建 SpringApplication 实例时,务必要把 args 参数传递过去,比如下面这两种写法

/** 样例A */
public static void main(String[] args) {
    SpringApplication.run(OverSpeedDataInsightMain.class);
}

/** 样例B */
public static void main(String[] args) {
    SpringApplication.run(OverSpeedDataInsightMain.class, args);
                                                           ̄ ̄ ̄
}

样例A由于没有传递args参数,因此通过命令行添加的 --spring.config.location 参数不会被SpringBoot实例读取到,在运行期间也就不会去读取它指定的配置文件了。

7. 如何同时启用多个数据源

方式一:手动创建多个My Batis的SqlSessionFactory

因为国内使用 MyBatis 框架最多,因此特别针对此框架单独说明。总体思路是这样的:

  • 多个数据源,各有各的配置
  • 针对每个数据源,单独创建一个 SqlSessionFactory
  • 每个 SqlSession 各自扫描不同数包和目录下的 Mapper.java 和 mapper.xml
  • 指定某个数据源为主数据源<强制>

样例工程部分代码如下,完整源码请访问码云上的工程 mybatis-multi-ds-demo

application.yml (点击查看)
spring:
  datasource:
    primary:
      driver: org.sqlite.JDBC
      url: jdbc:sqlite::resource:biz1.sqlite3?date_string_format=yyyy-MM-dd HH:mm:ss
    minor:
      driver: org.sqlite.JDBC
      url: jdbc:sqlite::resource:biz2.sqlite3?date_string_format=yyyy-MM-dd HH:mm:ss
主数据源装配 (点击查看)
@MapperScan(
        basePackages = {"cnblogs.guzb.biz1"},
        sqlSessionFactoryRef = "PrimarySqlSessionFactory"
)
@Configuration
public class PrimarySqlSessionFactoryConfig {

    // 表示这个数据源是默认数据源,多数据源情况下,必须指定一个主数据源
    @Primary
    @Bean(name = "PrimaryDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.primary")
    public DataSource getPrimaryDateSource() {
        // 这个MyBatis内置的无池化的数据源,仅仅用于演示,实际工程请更换成具体的数据源对象
        return new UnpooledDataSource();
    }

    @Primary
    @Bean(name = "PrimarySqlSessionFactory")
    public SqlSessionFactory primarySqlSessionFactory(
            @Qualifier("PrimaryDataSource") DataSource datasource) throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(datasource);
        // 主数据源的XML SQL配置资源
        Resource[] xmlMapperResources = new PathMatchingResourcePatternResolver().getResources("classpath:mappers/primary/*.xml");
        bean.setMapperLocations(xmlMapperResources);
        return bean.getObject();
    }

    @Primary
    @Bean("PrimarySqlSessionTemplate")
    public SqlSessionTemplate primarySqlSessionTemplate(
            @Qualifier("PrimarySqlSessionFactory") SqlSessionFactory sessionFactory) {
        return new SqlSessionTemplate(sessionFactory);
    }
}
副数据源装配 (点击查看)
@Configuration
@MapperScan(
        basePackages = {"cnblogs.guzb.biz2"},
        sqlSessionFactoryRef = "MinorSqlSessionFactory"
)
public class MinorSqlSessionFactoryConfig {

    @Bean(name = "MinorDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.minor")
    public DataSource getPrimaryDateSource() {
        // 这个MyBatis内置的无池化的数据源,仅仅用于演示,实际工程请更换成具体的数据源对象
        return new UnpooledDataSource();
    }

    @Bean(name = "MinorSqlSessionFactory")
    public SqlSessionFactory primarySqlSessionFactory(
            @Qualifier("MinorDataSource") DataSource datasource) throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(datasource);
        // 主数据源的XML SQL配置资源
        Resource[] xmlMapperResources = new PathMatchingResourcePatternResolver().getResources("classpath:mappers/minor/*.xml");
        bean.setMapperLocations(xmlMapperResources);
        return bean.getObject();
    }

    @Bean("MinorSqlSessionTemplate")
    public SqlSessionTemplate primarySqlSessionTemplate(
            @Qualifier("MinorSqlSessionFactory") SqlSessionFactory sessionFactory) {
        return new SqlSessionTemplate(sessionFactory);
    }
}

方式二:使用路由式委托数据源 AbstractRoutingDataSource <推荐>

上面这种方式,粒度比较粗,在创建SqlSessionFactory时,将一组Mapper与DataSource绑定。如果想粒度更细一些,比如在一个Mapper内,A方法使用数据源A, B方法使用数据源B,则无法做到。

Spring 官方有个 AbstractRoutingDataSource 抽象类, 它提供了以代码方式设置当前要使用的数据源的能力。其实就是把自己作为 DataSource 的一个实现类,并将自己作为数据源的集散地(代理人),在内部维护了一个数据源的池,将 getConnection() 方法委托给这个池中对应的数据源。

DynamicDataSource.java
public class DynamicDataSource extends AbstractRoutingDataSource {

    /** 通过 ThreadLocal 来记录当前线程中的数据源名称 */
    private final ThreadLocal<String> localDataSourceName = new ThreadLocal<>();

    public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
        super.setDefaultTargetDataSource(defaultTargetDataSource);
        super.setTargetDataSources(targetDataSources);
    }

    @Override
    protected Object determineCurrentLookupKey() {
        return localDataSourceName.get();
    }

    public void setDataSourceName(String dataSourceName) {
        localDataSourceName.set(dataSourceName);
    }

    public void clearDataSourceName() {
        localDataSourceName.remove();
    }
}
DynamicDataSourceConfig
@Configuration
public class DynamicDataSourceConfig {

    // 表示这个数据源是默认数据源,多数据源情况下,必须指定一个主数据源
    @Primary
    @Bean(name = "dynamic-data-source")
    @DependsOn(DataSourceName.FIRST)
    public DynamicDataSource getPrimaryDateSource(
            @Qualifier(DataSourceName.FIRST) DataSource defaultDataSource,
            @Qualifier(DataSourceName.SECOND) @Autowired(required = false) DataSource secondDataSource
    ) {

        System.out.println("first=" + defaultDataSource + ", second = " + secondDataSource);
        Map<Object, Object> allTargetDataSources = new HashMap<>();
        allTargetDataSources.put(DataSourceName.FIRST, defaultDataSource);
        allTargetDataSources.put(DataSourceName.SECOND, secondDataSource);

        return new DynamicDataSource(defaultDataSource, allTargetDataSources);
    }

    @Bean(name= DataSourceName.FIRST)
    @ConfigurationProperties(prefix = "spring.datasource.first")
    public DataSource createFirstDataSource() {
        // 这个MyBatis内置的无池化的数据源,仅仅用于演示,实际工程请更换成具体的数据源对象
        return new UnpooledDataSource();
    }

    @Bean(name= DataSourceName.SECOND)
    @ConfigurationProperties(prefix = "spring.datasource.second")
    public DataSource createSecondDataSource() {
        // 这个MyBatis内置的无池化的数据源,仅仅用于演示,实际工程请更换成具体的数据源对象
        return new UnpooledDataSource();
    }

}
SwitchDataSourceTo
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SwitchDataSourceTo {

    /** 数据源的名称 */
    String value() default DataSourceName.FIRST;

}
SwitchDataSourceAspect
@Aspect
@Component
public class SwitchDataSourceAspect {

    @Autowired
    DynamicDataSource dynamicDataSource;

    @Around("@annotation(switchDataSourceTo)")
    public Object around(ProceedingJoinPoint point, SwitchDataSourceTo switchDataSourceTo) throws Throwable {
        String dataSourceName = switchDataSourceTo.value();
        try {
            dynamicDataSource.setDataSourceName(dataSourceName);
            System.out.println("切换到数据源: " + dataSourceName);
            return point.proceed();
        } finally {
            System.out.println("执行结束,准备切换回到主数据源");
            dynamicDataSource.setDataSourceName(DataSourceName.FIRST);
        }
    }
}
Biz1Mapper
@Mapper
public interface Biz1Mapper {

    // 未指定数据源,即为「默认数据源」
    @Select("select * from user")
    List<UserEntity> listAll();

    @SwitchDataSourceTo(DataSourceName.FIRST)
    @Select("select * from user where id=#{id}")
    UserEntity getById(@Param("id") Long id);
}
Biz2Mapper
@Mapper
public interface Biz2Mapper {

    @Select("select * from authority")
    @SwitchDataSourceTo(DataSourceName.SECOND)
    List<AuthorityEntity> listAll();

    // 本方法没有添加 SwitchDataSourceTo 注解,因此会使用默认的数据源,即 first
    // 但 first 数据源中没有这个表。该方法会通过在程序中手动设置数据源名称的方式,来切换
    @Select("select count(*) as quantity from authority")
    Integer totalCount();

}

完整源码请访问码云上的工程 mybatis-multi-ds-demo

方式三:使用 MyBatisPlus 的 多数据源方案 <推荐>

MyBatisPlus 增加了对多数据源的支持,详细做法请参考 MyBatis多数据源官方手册,它的底层原理与方式二一致,但特性更多,功能出更完善。若有兴趣的话,建议将这个多数据源的功能单独做成一个 jar 包或 maven 依赖。以使其可以在非 MyBatis 环境中使用。

多数据源切换引起的事务问题

对于纯查询类非事务性方法,上面的多数据源切换工作良好,一旦一个Service方法开启了事务,且内部调用了多个有不同数据源的Dao层方法,则这些数据源切换均会失败。原因为切换数据源发生在openConnection()方法执行时刻,但一个事务内只有一个Connection。当开启事务后,再次切换数据源时,由于已经有connection了,此时切换会无效。

因此解决办法为:先切换数据源,再开启事务。开启事务后,不能再切换数据源了。

8. 如何同时启用多个Redis连接

最简单的办法是直接使用 Redis官方的客户端库,但这样脱离了本小节的主旨。业务代码中使用spring 的 redis 封装,主要是使用 RedisTemplate 类,RedisTemplate 封装了常用的业务操作,但它并不关注如何获得 redis 的连接。这个工作是交由 RedisConnectionFactory 负责的。因此,RedisTemplate 需要指定一个 RedisConnectionFactory。由此可知,在工程中,创建两个RedisConnectionFactory, 每个连接工厂连接到不同的 redis 服务器即可。以下简易示例代码中,两个连接工厂连接的是同一个服务器的不同数据库。

创建两个 RedisConnectionFactory 和两个 RedisTemplate
@Configuration
public class RedisConfiguration {

    /** 
     * 0号数据库的连接工厂
     * 本示例没有使用早期的 JedisConnectionFactory, 而是选择了并发性更好的 LettuceConnectionFactory, 下同
     */
    @Primary
    @Bean("redis-connection-factory-db0")   // 明确地指定 Bean 名称,该实例将作为依赖项,传递给相应的 RedisTemplate, 下同
           ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public RedisConnectionFactory createLettuceConnectionFactory0() {
        // 这里使用的是单实例Redis服务器的连接配置类,
        // 哨兵与集群模式的服务器,使用对应的配置类设置属性即可。
        // 另外,这里没有演示通过yaml外部配置文件来设置相应的连接参数,因为这不是本小节的重点
        RedisStandaloneConfiguration clientProps = new RedisStandaloneConfiguration();
        clientProps.setHostName("localhost");
        clientProps.setPort(6379);
        clientProps.setDatabase(0);
        return new LettuceConnectionFactory(clientProps);
    }

    /** 1号数据库的连接工厂 */
    @Bean("redis-connection-factory-db1")
            ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public RedisConnectionFactory createLettuceConnectionFactory1() {
        RedisStandaloneConfiguration clientProps = new RedisStandaloneConfiguration();
        clientProps.setHostName("localhost");
        clientProps.setPort(6379);
        clientProps.setDatabase(1);
        return new LettuceConnectionFactory(clientProps);
    }

    /** 
     * 操作0号数据库的 RedisTemplate, 
     * 创建时,直接将0号数据库的 RedisConnectionFactory 实例传递给它
     */
    @Primary
    @Bean("redis-template-db-0")
    public RedisTemplate<String, String> createRedisTemplate0(
            @Qualifier("redis-connection-factory-db0") RedisConnectionFactory factory0) {
                         ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory0);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }

    /** 
     * 操作1号数据库的 RedisTemplate, 
     * 创建时,直接将1号数据库的 RedisConnectionFactory 实例传递给它
     */
    @Bean("redis-template-db-1")
    public RedisTemplate<String, String> createRedisTemplate1(
            @Qualifier("redis-connection-factory-db1") RedisConnectionFactory factory1) {
                         ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory1);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }

}
多Redis连接的测试验证代码
@Component
@SpringBootApplication
public class MultiRedisAppMain {

    // 注入操作0号数据库的Redis模板
    @Resource(name = "redis-template-db-0")
    RedisTemplate redisTemplate0;

    // 注入操作1号数据库的Redis模板
    @Resource(name = "redis-template-db-1")
    RedisTemplate redisTemplate1;

    public static void main(String[] args) {
        SpringApplication.run(MultiRedisAppMain.class, args);
    }

    @EventListener(ApplicationReadyEvent.class)
    public void operateBook() {
        redisTemplate0.opsForValue().set("bookName", "三体");
        redisTemplate0.opsForValue().set("bookPrice", "102");
        
        redisTemplate1.opsForValue().set("bookName", "老人与海");
        redisTemplate1.opsForValue().set("bookPrice", "95");
    }
}

本小节完整的示例代码已上传到 multi-redis-demo

9. 如何同时消费多个 Kafka Topic

9.1 同时消费同一 Kakfa 服务器的多个topic

这个是最常见的情况,同时也是最容易实现的,具体操作是:为 @KafkaListener 指定多个 topic 即可,如下所示

点击查看代码
/** 多个topic在一个方法中消费的情况 */
@KafkaListener(topics = {"topic-1", "topic-2", "topic-3"}, groupId = "group-1")
                ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
public void consumeTopc1_2_3(String message) {
    System.out.println("收到消息 kafka :" + message);
}

/** 不同 topic 在不同方法中消费的情况 */
@KafkaListener(topics = "topic-A", groupId = "group-1")
public void consumeTopicA(String message) {
    System.out.println("收到消息 kafka :" + message);
}

/** 不同 topic 在不同方法中消费的情况 */
@KafkaListener(topics = "topic-B", groupId = "group-1")
public void consumeTopicB(String message) {
    System.out.println("收到消息 kafka :" + message);
}

9.2 同时消费不同Kafka服务器的多个topic

这种情况是本小节的重点,与 spring 对 redis 的封装不同,spring 对 kafka 官方的 client lib 封装比较重,引入了以下概念

  • ConsumerFactroy

    消费者工厂,该接口能创建一个消费者,它将创建与消息系统的网络连接

  • MessageListenerContainer

    消息监听器容器,这是 spring 在 Consumer 之上单独封装出来的概念,顾名思义,该组件的作用是根据监听参数,创建一个消息监听器。看上去它似乎与 Consumer 组件要干的事一样,但在 spring 的封装结构里,consumer 实际上只负责连接到消息系统,然后抓取消息,抓取后如何消费,是其它组件的事,MessageLisntener 便是这样的组件,而 MessageListenerContainer 是创建 MessageListener 的容器类组件。

  • KafkaListenerContainerFactory

    消息监听器容器的工厂类,即这个组件是用来创建 MessageListenerContainer 的,而 MessageListenerContainer 又是用来创建 MessageLisntener 的。

看了上面3个重要的组件的介绍,你一定会产生个疑问:创建一个监听器,需要这么复杂吗?感觉一堆的工厂类,这些工厂类还是三层套娃式的。答案是:如果仅仅针对 Kafka,不需要这么复杂。spring 的这种封装是要建立一套『事件编程模型』来消费消息。并且还是跨消息中间件的,也就是说,无论是消费 kafka 还是 rabbitmq , 它们的上层接口都是这种结构。为了应对不同消息系统间的差异,才引出了这么多的工厂类。

但不得不说,作为一个具体的使用者而言,这就相当于到菜单市买一斤五花肉,非得强行塞给你二两边角料,实得五花肉只有8两不说,那二两完全是多余的,既浪费又增加负担。spring 官方的这种封装,让它们的程序员爽了,但使用者的负担却是增加了。我们愿意花大把时间来学习 Spring Framework 和 Spring Boot 的编程思想和源代码,因为这两个是非常基础的通用框架。但是对具体产品的过渡封装,使用者大多是不喜欢的,因为我们可没那么多时间来学习它的复杂设计。毕竟这些只是工具的封装,不是一个可部署的产品。业务代码要基于它们来实现功能,谁也不想错误堆栈里全是一堆第三访库的类,而不是我们自己写的代码。尽管spring 的工具质量很好。但复杂的包装增加了使用难度,概念没有理解到位、某个理解不透彻的参数配置不对、某个完全没听说过的默认配置项在自己特定的环境下出错,这些因素导致的异常,都会让开发者花费巨大的时间成本来解决。因此,对于有复杂需求的同仁们,建议大家还是直接使用 kafka 官方提供的原生 client lib, 自己进行封装,这样可以做到完全可控。

回到主题,要实现同时连接多个不同的kafka服务器,提供相应服务器的 ConsumerFactory 即可。只是 ConsumerFactory 实例还需要传递给 KafkaListenerContainerFactory,最后在 @KafkaLisntener 注解中指定要使用的 KafkaListenerContainerFactory 名称即可。

连接多个 Kafka 服务器的组件配置类
@Configuration
public class KafkaConfiguration {
    @Primary
    @Bean("consumerFactory")
            ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public ConsumerFactory createConsumerFactory() {
        Map<String, Object> consumerProperties = new HashMap<>();
        consumerProperties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        consumerProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        consumerProperties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        return new DefaultKafkaConsumerFactory<>(consumerProperties);
    }

    // 第二个消费工厂,为便于实操, 这里依然连接的是同一个 Kafka 服务器
    @Bean("consumerFactory2")
            ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public ConsumerFactory createConsumerFactory2() {
        Map<String, Object> consumerProperties = new HashMap<>();
        consumerProperties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        consumerProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        consumerProperties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        return new DefaultKafkaConsumerFactory<>(consumerProperties);
    }

    @Primary
    // 自己创建的监听容器工厂实例中,一定要有一个实例的名字叫: kafkaListenerContainerFactory,
    // 因为 KafkaAnnotationDrivenConfiguration 中也默认配置了一个 KafkaListenerContainerFactory,
    // 这个默认的 KafkaListenerContainerFactory 名称就叫 kafkaListenerContainerFactory,
    // 其装配条件就是当容器中没有名称为 kafkaListenerContainerFactory 的Bean时,那个装配就生效,
    // 如果不阻止这个默认的KafkaListenerContainerFactory装备,会导致容器中有两个 KafkaListenerContainerFactory,这会引入一些初始化问题
    @Bean("kafkaListenerContainerFactory")
    public KafkaListenerContainerFactory<KafkaMessageListenerContainer> createContainerFactory1(
            ConcurrentKafkaListenerContainerFactoryConfigurer configurer,
            @Qualifier("consumerFactory") ConsumerFactory consumerFactory) {
                         ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        ConcurrentKafkaListenerContainerFactory listenerContainerFactory = new ConcurrentKafkaListenerContainerFactory();
        configurer.configure(listenerContainerFactory, consumerFactory);
        return listenerContainerFactory;
    }

    // 第二个监听器容器工厂
    @Bean("kafkaListenerContainerFactory2")
    public KafkaListenerContainerFactory<KafkaMessageListenerContainer> createContainerFactory2(
            ConcurrentKafkaListenerContainerFactoryConfigurer configurer,
            @Qualifier("consumerFactory2") ConsumerFactory consumerFactory2) {
                         ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        ConcurrentKafkaListenerContainerFactory listenerContainerFactory = new ConcurrentKafkaListenerContainerFactory();
        configurer.configure(listenerContainerFactory, consumerFactory2);
        return listenerContainerFactory;
    }
}
连接多 Kafka 服务器的测试主程序
@Component
@EnableKafka
@SpringBootApplication
public class MultiKafkaAppMain {
    public static void main(String[] args) {
        SpringApplication.run(MultiKafkaAppMain.class, args);
    }

    @KafkaListener(topics = "topic1", groupId = "g1", containerFactory = "kafkaListenerContainerFactory")
                                                                           ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public void consumeKafka1(String message) {
        System.out.println("[KAFKA-1]: 收到消息:" + message);
    }

    @KafkaListener(topics = "topic-2", groupId = "g1", containerFactory = "kafkaListenerContainerFactory2")
                                                                           ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public void consumeKafka2(String message) {
        System.out.println("[KAFKA-2]: 收到消息:" + message);
    }

    @EventListener(ApplicationReadyEvent.class)
    public void init() {
        System.out.println("[MAIN]: 启动成功,等待Kakfa消息");
    }
}

本小节完整的示例代码已上传到 multi-kafka-demo

10. 如何查看程序启动后所有的 Properties

方式一:遍历Environment对象

Spring Boot 中有个 Environment 接口,它记录了当前激活的 profile 和所有的「属性源」,下面是一段在 runtime 期间打印所有 properties 的示例代码

PrintAllPropetiesDemo.java(点击查看)
@Component
public class PrintAllPropetiesDemo {
    @Resource
    Environment env;

    @EventListener(ApplicationReadyEvent.class)
    public void printAllProperties throws Exception {
        // 打印当前激活的 profile
        System.out.println("Active profiles: " + Arrays.toString(env.getActiveProfiles()));
        
        // 从「环境」对象中,获取「属性源」
        final MutablePropertySources sources = ((AbstractEnvironment) env).getPropertySources();

        // 打印所有的属性,包括:去重、脱敏
        StreamSupport.stream(sources.spliterator(), false)
                .filter(ps -> ps instanceof EnumerablePropertySource)
                .map(ps -> ((EnumerablePropertySource) ps).getPropertyNames())
                .flatMap(Arrays::stream)
                // 去除重复的属性名
                .distinct()
                // 过滤敏感属性内容
                .filter(prop -> !(prop.contains("credentials") || prop.contains("password")))
                .forEach(prop -> System.out.println(prop + ": " + env.getProperty(prop)));
    }
}

方式二:查看 Spring Acuator 的 /env 监控页面 <推荐>

先引入 acuator 的依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

然后在配置 acuator 的 web 访问 uri

@Bean
public SecurityWebFilterChain securityWebFilterChain(
  ServerHttpSecurity http) {
    return http.authorizeExchange()
      .pathMatchers("/actuator/**").permitAll()
      .anyExchange().authenticated()
      .and().build();
}

假定端口为8080, 则访问 http://localhost:8080/acuator/env 便能看到工程运行起来后所有的 properties 了

11. 如何申明和使用异步方法

在 SpringBoot 中使用异步方法非常简单,只要做以下同步

  • 启用异步特性
  • 在要异步执行的方法中,添加 @Async 注解

下面是一段示例代码

// 启用异步特性
@EnableAsync
public class BookService {
    @Async   // 声明要异步执行的方法
    public void disableAllExpiredBooks(){
        ....
    }
}

📣 特别说明

以上代码确实可以让 disableAllExpiredBook() 方法异步执行,但它的执行方式是: 每次调用此方法时,都新创建一个线程,然后在新线程中执行这个方法。如果方法调用得不是很频繁,这个做法是OK的。但如果方法调用得很频繁,就会导致系统频繁地开线程,而创建线程的开销是比较大的。Spring 已经考虑到了这个场景,只需要为异步执行的方法指定一个执行器就可以了,而这个执行器通常都是一个具备线程池功能的执行器。示例代码如下:

@EnableAsync
public class BookService {
    @Async("bookExcutor")  // 在注解中指定执行器
             ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public void disableAllExpiredBooks(){
        ....
    }
}

@Configuration
public class ExecutorConfiguration {
    // 装配书籍任务的通用执行器
    @Bean("bookExcutor")
            ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public Executor speedingArbitrationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(6);
        executor.setMaxPoolSize(24);
        executor.setQueueCapacity(20000;
        executor.setKeepAliveSeconds(30);
        executor.setThreadNamePrefix("书籍后台任务线程-");
        executor.setWaitForTasksToCompleteOnShutdown(true);
        // 任务队列排满后,直接在主线程(提交任务的线程)执行任务,异步执行变同步
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
}

12. 如何快速添加 boot 的 maven 依赖项

Spring Boot 是一个以Boot为中心的生态圈,当我们指定了boot的版本后,如果要使用中生态圈中的组件,就不用再指定该组件的版本了。有两种方式可达到此目的。

  • 方式一:项目工程直接继承 Boot Starter Parent POM
<parent>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-parent</artifactId>
	<version>3.1.5</version>
</parent>
  • 方式二:在pom.xml的依赖管理节点下,添加 spring-boot-dependencies
<dependencies>
  <!-- ② 这里添加starter依赖,但不用指定版本 -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
  </dependency>
</dependencies

......

<dependencyManagement>
  <dependencies>
    <!-- ① 在这里添加spring-boot的依赖pom -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>2.7.16</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

同理,如果要引入 Spring Cloud 生态圈中的相关组件,也建议通过「方式二」,把 spring-cloud-dependencies 加入到依赖管理节点下

13. 如何以静态方式获取 HttpServletRequest 和 HttpServletResponse

通过 spring-web 组件提供的 RequestContextHolder 中的静态方法来获取 HttpServletRequest 和 HttpServletResponse,如下所示:

import org.springframework.web.util.WebUtils;

import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

public class WebTool extends WebUtils {

    public static HttpServletRequest getHttpRequest() {
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
         ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
        return servletRequestAttributes.getRequest();
    }

    public static HttpServletResponse getHttpResponse() {
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
         ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
        return servletRequestAttributes.getResponse();
    }
}

14. 如何解决 ConfigurationProperties 不生效的问题

如果你在自己的 Properties 类上添加了 @ConfigurationProperties 注解,启动程序后没有效果,可参考下面这两种方法来解决:

  • 方式一
    1. 在启动类添加 @EnableConfigurationProperties 注解

    2. 在 @ConfigurationProperties 标注的类上添加 @Component 注解 (@Service注解也可以)

    启动类

    @SpringBootApplication
    @EnableAutoConfiguration
    @EnableConfigurationProperties 
     ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public class MyBootApp {
         public static void main(String[] args) {
             SpringApplication.run(MyBootApp.clss, args);
         }
    }
    

    自定义的 Properties 类

    @Component
     ̄ ̄ ̄ ̄ ̄ ̄ ̄
    @ConfigurationProperties(prefix="gzub.hdfs")
     ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public class HdfsProperties {
        private String nameNode;
        private String user;
        private String password;
    }
    
  • 方式二
    1. 在启动类添加 @ConfigurationPropertiesScan 注解,并指定要扫描的 package
    2. 在自定义的 Properties 类上添加 @ConfigurationProperties(不需要添加 @Component 注解)

    启动类

    @SpringBootApplication
    @ConfigurationPropertiesScan({"vip.guzb"})
     ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public class MyBootApp {
         public static void main(String[] args) {
             SpringApplication.run(MyBootApp.clss, args);
         }
    }
    

    自定义的 Properties 类

    @ConfigurationProperties(prefix="gzub.hdfs")
     ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public class HdfsProperties {
        private String nameNode;
        private String user;
        private String password;
    }
    

15. 如何统一处理异常

  1. 编写一个普通的Bean,不继承和实现任何类与接口

  2. 在该Bean的类级别上添加 @RestControllerAdvice 注解,向框架声明这是一个可跨 Controller 处理异常、初始绑定和视图模型特性的类

  3. 在类中编写处理异常的方法,并在方法上添加 @ExceptionHandler 注解,向框架声明这是一个异常处理方法

    编写异常处理方法的要求如下:

    • 方法是 public 的
    • 方法必须用 @ExceptionHandler 注解修饰
    • 方法的返回值就是最终返给前端的内容,通常是JSON文本
    • 方法参数中,需指定要处理的异常类型
  4. 如果需要对特定异常做特殊的处理,则重复第3步

下面是一较完整的示例代码(点击查看)
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.http.ResponseEntity
import org.springframework.http.HttpStatus;

@RestControllerAdvice
public class MyGlobalExceptionHandlerResolver {
    
    /** 处理最外层的异常 */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception e) {
        List details = new ArrayList();
        details.add(e.getMesssage());
        ErrorResponse error = new ErrorResponse(e.getMessage, details);
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    /** 处理业务异常,这里使用了另外一种方式来设置 http 响应码 */
    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.HTTP_BAD_REQUEST)
    public ErrorResponse handleException(BusinessException e) {
        List details = new ArrayList();
        details.add(e.getBackendMesssage());
        return new ErrorResponse(e.getFrontendMessage(), details);
    }
}

/** 返回给前端的错误内容对象 */
public class ErrorResponse {
    private String message;
    private List<String> details;
    ......
}

/** 业务异常 */
public class BusinessException extends RuntimeException{
    private String frontendMessage;
    private String backendMessage;
    ......
}

16. 应该对哪些异常做特殊处理

对于Web开发而言,我们应该在全局异常处理类中,对以下异常做特殊处理

  • Exception
  • BusinessExecption
  • HttpRequestMethodNotSupportedException
  • HttpClientErrorException
  • FeignException
  • ConstraintViolationException
  • ValidationException

17. 异常处理组件应该具备的特性

  1. 业务异常处理

    • 异常信息中,要明确区分出前端展示内容与后端错误内容

    • 后端错误内容可再进一步分为「错误的一般描述信息」和「详细的错误列表」

    • 前后端错误信息中,应过滤敏感内容,如身份证、密码等,且过滤机制提供开关功能,以方便开发调试

    • 异常信息中,应该包含业务流水号,便于调试和排查线上问题时,将各个节点的错误内容串联起来

    • 多数情况下,业务异常都不应该打印堆栈,只需要在日志中输出第一个触发业务异常的代码位置即可

      • 因为业务异常是我们在编码阶段就手动捕获了的,也就是说,这些异常是可预期的,并且是我们自己手动编码抛出的。因此,只需要输出该异常的抛出点代码位置,异常堆栈是没有意义的,它只会增加日志的存储体积
      • 另外,多数业务异常都是在检查业务的执行条件时触发的,比如:商品不存在、库存不足、越权访问、输入数据不合规等。且这类错误会频繁发生,若输出其堆栈的话,日志中会大量充斥着这样的异常堆栈。它既增加了日志的存储体积,也干扰了正常日志内容的查看
    • 异常信息中,要详细记录错误内容,尽可能把异常现场的信息都输出。
      这是开发人员最容易给自己和他人挖坑的地方,比如:一个业务异常的日志输出内容是这样:“积分等级不够”。这个异常信息是严重不足的,它缺少以下这些重要信息,以致极难在线上排查问题:

      • 谁的积分等级不足
      • 这个用户当前的积分是多少
      • 他要拥有多少积分,和什么样的等级
      • 他在访问什么资源

      注意:您可能会有疑问,把用户账号输出到日志就可以了,没必要输出它当前的积分,因为积分可以去数据库查。但这样做是不行的,因为:

      • 生产环境的数据库研发人员是不能直接访问的,让运维人员查,效率不高还增加运维工作量
      • 数据查询出来的值,也不是发生异常当时的值,时光荏然,你大妈已经不你大妈了 😁
      • 即使是个相对静态(变动不频繁)的参数,运行期代码所使用的值,也极有可能与数据库中不一致。比如程序启动时,没有从数据库中加载,而是使用了默认值,又或者是某个处理逻辑将它的值临时改变了
  2. 非业务异常

    • 尽可能地捕获所有异常
    • 一定要在日志中输出非业务异常的堆栈<重要>
    • 尽量不要二次包装非业务异常,如果一定要包装,「务必」在将包装后的异常 throw 前,先输出原始异常的堆栈信息

18. 为什么出错了却没有异常日志

在 WebMVC 程序中,通常都有一全局异常处理器(如15小节所述),因此,有异常一定是会被捕获,并输出日志的。不过,这个全局异常处理器,仅对Web请求有效,如果是以下以下情况,则需要在代码中手动捕获和输出异常日志:

  • 在非WEB请求的线程中运行的代码
    比如定时任务中的代码所产生的异常。如果没有捕获和输出异常日志,那么发生了异常也不知道,只能从结果数据上判断,可能发生了错误,但却无法快速定位。

  • 从Web请求线程中脱离出来的异步线程中的代码
    这种情况更常见,同时也要非常小心。比如异步发送短信,异步发邮件等,一定要做好异常处理

19. 如何处理异常日志只有一行简短的文本

比如下面这个经典的场景

java.lang.NullPointerException

异常信息只有这么一行,没有代码位置,没有causedException, 更没有堆栈。这是因为JVM有个快速抛出(FastThrow)的异常优化:如果相同的异常在短时间内集中大量throw,则将这些异常都合并为同一个异常对象,且没有堆栈。

解决办法为:java 启动命令中,添加-OmitStackTraceInFastThrow这个JVM选项,如:

java -XX:-OmitStackTraceInFastThrow -jar xxxx.jar

📌 说明1

JVM只对以下异常做FastThrow优化

  • NullPointerException
  • ArithmeticException
  • ArrayStoreException
  • ClassCastException
  • ArrayIndexOutOfBoundsException

📌 说明2

出现此问题,基本上意味着代码有重大缺陷,跟死循环差不多,不然不会出现大量相同的常集中抛出。另外,开启该选项后,若这种场景出现,是会刷爆日志存储的。当然,相比之下找到问题更重要,该选项是否要在生产环境开启,就自行决定吧。

20. 如何解决同一实例内部方法调用时,部分事务失效的问题

事务失效示例代码(点击查看)
@Service
public class BookService {
    @Resource
    BookDao bookDao;

    public void changePrice(Long bookId, Double newPrice) {
        doChangePrice(bookId, newPrice);
        logOperation();
        sendMail();
    }

    @Transactional(rollbackFor = Exception.class)
    public void resetPrice(Long bookId, Double newPrice) {
        doChangePrice(bookId, newPrice);
        logOperation();
        sendMail();
    }

    @Transactional(rollbackFor = Exception.class)
    public void doChangePrice(Long bookId, Double newPrice) {
        bookDao.setPrice(bookId, newPrice);
    }

    @Transactional(rollbackFor = Exception.class)
    public void logOperation(Long bookId, Double newPrice) {
        .... // 省略记录操作日志的代码
    }

    public void sendMail(Long bookId, Double newPrice) {
        .... // 省略发送邮件的代码
    }
}

上述代码,调用 changePrice() 方法时,如果 sendMail() 方法在执行时发生了异常,则前面的 doChangePrice() 和 logOperation() 所执行的数据库操作均不会回滚。但同样的情形如果发生在 resetPrice() 方法上,doChangePrice() 和 logOperation() 均会回滚。

这个例子还可以进行更细化的演进,不过核心原因都是一个:Spring 对注解事务的实现手段,是通过 CGLib 工具库创建一个继承这个业务类的新类,捕获原业务类方法执行期间的异常,然后执行回滚的。但是对原业务类中,方法内部对其它方法的调用,这个被调用的方法,其上的事务注解则不再生效。如果直接在外部调用这些方法,则事务注解是生效的。

以上面的示例代码为准, changePrice() 方法内部分别调用了 doChangePrice()、logOperation()、sendMail() 三个方法,但由于 changePrice() 方法本身并没有添加事务注解,因此,它内部调用的 doChangePrice()、logOperation() 这两个方法的事务注解是不生效的。因此,实际上执行过程都没有开启事务。当然,如果是从外部直接单独调用 doChangePrice() 和 logOperation(),则二者的事务均生效。

方案一:手动隔离

可以通过在外部(其它Class)单独调用这些有事务注解的方法来使事务生效,如果需要将这些方法组合在一个方法体内,整体完成一个业务逻辑,也在其它类中创建方法,在该方法中调用这些有事务注解的方法完成逻辑组织。这是开发者最能理解的方式,代码也较直观。

方案二:明确调用代理对象的方法 <推荐>

事务失效的原因是同一个类中,无事务的方法,调用了相同实例的有事务注解的方法所致。如果在编码阶段,就能在这个无事务的方法内明确声明:运行期调用的是由Aop代理的实例的方法,则此问题可解。
Spring 提供了 AopContext 类来获取被切面代理的实例,利用它便能够在方法中,明确调用本Class的代理实例方法了。操作如下:

  1. 先在代码中明确调用AOP代理的Class

    以上面的 BookService 为例子,变更后的内容如下:

    点击查看代码
    @Service
    public class BookService {
        @Resource
        BookDao bookDao;
     
        public void changePrice(Long bookId, Double newPrice) {
            // ① 直接拿到 BookService 的AOP代理实例
            BookService proxyOfThis= (BookService) AopContext.currentProxy();
            proxyOfThis.doChangePrice(bookId, newPrice);   // ②
            proxyOfThis.logOperation();                    // ③
            sendMail();
        }
     
        @Transactional(rollbackFor = Exception.class)
        public void doChangePrice(Long bookId, Double newPrice) {
            bookDao.setPrice(bookId, newPrice);
        }
     
        @Transactional(rollbackFor = Exception.class)
        public void logOperation(Long bookId, Double newPrice) {
            .... // 省略记录操作日志的代码
        }
     
        public void sendMail(Long bookId, Double newPrice) {
            .... // 省略发送邮件的代码
        }
    }
    

    由于直接获得了 BookService 的 AOP 代理实例,因此上述代码中 ② 和 ③ 处的调用,其事务都会生效。

  2. 启用 Spring Aop 的代理对象暴露特性

    默认情况情况下,上述代码 ① 处获得的实例依然是原始对象。需要开启「AOP代理对象暴露」特性,① 处的代码才会生效。具体操作,就是在启动类上加上 @EnableAspectJAutoProxy(exposeProxy = true) 注解,比如:

    @SpringBootApplication
    @EnableAspectJAutoProxy(exposeProxy = true)
    public class MainApp {
        public static void main(String[] args) {
            ....
        }
    }
    

📌 特别说明

  1. 开启「AOP代理对象暴露」特性会影响 Spring 容器的性能,所以框架默认未开启此特性

  2. 本方法仅对AOP代理对象有效,非AOP代理对象是无效的,比如:@Sync 注解的方法,其所在的实例,虽然也是代理对象,但不是基于切面的代理对象,因此,该方法对此类代理对象是无效的。
    幸运的是 @Transactional 注解会创建一个切面,故而在可用此方法来解决本小节提及的问题

21. 如何阻止某个第三方组件的自动装配

  1. 方法一:配置 @SpringBootApplication 注解的 exclude 属性

    如下代码所示:

    // 启动时,将Spring官方的数据源自动装配排除
    @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})       
     ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    public class MyAppMain{
      public static void main(String[] args) {
        SpringApplication.run(MyAppMain.class, args);
      }
    }
    
  2. 方法二:在配置文件中指定 <推荐>

    方法一需要修改代码,对于普通的业务系统而言,是能不改代码就坚决不改。因此推荐下面这种配置的方式来指定:

    spring:
      autoconfigure:
        # 指定要排除的自动装配类,多个类使用英文逗号分隔
        exclude: org.springframework.cloud.gateway.config.GatewayAutoConfiguration
    
  3. 方法三:临时注释掉该组件的 @EnableXXX 注解
    比如常见的 @EnableConfigurationProperies 、@EnalbeAsync 、@EnableJms 等,在代码中临时注释掉这些注解即可。但仅适用于提供了这种 Enable 注解方式装配的组件。

22. 如何进行Body、Query、Path Variable类型的参数校验

  • Http Body 实体类型的参数校验

    这里特指 JSON 格式的 Body 体,这是最常见的情况。步骤如下:

    1. 编写一个类用来接收JSON格式的Body参数。这个类的要求如下

    • 字段需有相应的 Getter 和 Setter 方法
    • 在要做校验的字段上添加相应的约束注解,如 @NotBlank

    2. 对应的 Controller 方法参数中,使用 @RequestBody 修饰类型为第1步中写的类

    3. 对应的 Controller 方法参数中,使用 @Valid 或 @Validated 修饰类型为第1步中写的类

    示例代码(点击查看)
    /** 接收 http body JSON 参数的对象 */
    public class AddRoleRequest {
        @NotBlank(message = "角色编码不能为空")
        private String code;
    
        @NotBlank(message = "角色名称不能为空")
        private String name;
    
        @NotEmpty(message = "角色适用的区域等级不能为空")
        private List<RegionLevel> regionLevels;
    
        // Getter & Setter
    }
    
    @RestController
    @RequestMapping(path="/role")
    public class RoleController {
        // 这里的 @Valid 也可以换成 @Validated
        @PostMapping(path="/add")
        public void addRole(@Valid @RequestBody AddRoleRequest addRequest) {
            // 业务代码 ...
        }
    }
    
  • Query 与 Path Variable 类型的参数校验

    如下所示,下划线部分就是 Query 参数, 而{}中的内容就是 Path Vairable

    http://demo.guzb.vip/books/{china}/list-roles?code=system-admin&regionLevel=COUNTY
                                                   ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    

    默认情况下,Query 与 Path Variable 参数验证是没有效果的。可通过以下步骤开启该类型的参数校验:

    • 提供一个 MethodValidationPostProcessor 来处理 @Validated 注解。
    • 在 Controller 类上添加 @Validated 注解
      ⚠️ 注意:将 @Validate 注解添加在 Controller 的方法上或方法的参数上,均不会使 MethodValidationPostProcessor 生效,也就不能执行Query与PathVariable参数的校验
    示例代码(点击查看)
    @Configuration
    public class ValidationConfig {
        /** 
         * 重点是要添加一个MethodValidationPostProcessor实到到Bean容器中,
         * URL路径参数(Path Vairable)和查询参数(Query Parameter)的校验才会生效
         */
        @Bean
        public MethodValidationPostProcessor methodValidationPostProcessor() {
           return new MethodValidationPostProcessor();
        }
    }
    
    @Validated    // 这个注解必须加在 Controller 类级别上,MethodValidationPostProcessor 才会生效
     ̄ ̄ ̄ ̄ ̄ ̄
    @RestController
    @RequestMapping(path="/books")
    public class RoleController {
        @PostMapping(path="/{region}")
        public void addRole(
                @PathVairable("region") region, 
                @RequestParam("author") @Size(min=2, message="作者名称长度不能小于2")author) {
            // 业务代码 ...
        }
    }
    

    Query 与 Path Variable 参数验证是不需要在Controller方法的参数签名上加 @Validated 修饰的

    Spring Validation 框架在参数检验未通过时,会抛出 ConstraintViolationException 异常,因此应该在全局异常处理类中(请参考第15小节),添加对它的处理。

    ConstraintViolationException 异常处理示例代码(点击查看)
    @ExceptionHandler(ConstraintViolationException.class)
    public final ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
      List<String> details = ex.getConstraintViolations()
                    .stream()
                    .map(ConstraintViolation::getMessage)
                    .collect(Collectors.toList());
    
      ErrorResponse error = new ErrorResponse(BAD_REQUEST, details);
      return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }
    

📣 特别说明

  • 需要将 JSR-303 的api及相应实现添加到 classpath。当我们引入SpringMvc时,它们也自动引入了
  • @Valid 是 JSR-303 的标准注解
  • @Validated 是 Spring 的验证框架注解,它完全兼容 @Valid
  • MethodValidationPostProcessor 是 Spring 的验证框架中的一个容器Bean后置处理器

23. 如何级联校验多层级的参数对象

如果一个对象,它的拥有的字段,都是String 和 Java 的原始(Primitive)类型或其对应的包装类型,那么这些字段上具体的JSR-303校验注解是会生效的。但如果字段类型是数组、集合、或自定义的其它类,则我们在自定类的义字段上添加的JSR-303校验注解,默认情况下是不生效的。

有两种方式使上述多层级复杂结构对象上的校验注解生效:在复杂类型字段上添加 @Valid 注解,或在复杂类型的 class 定义上添加 @Valid注解,如下所示:

public class BookVo {
    @NotBlank(message = "图书名称不能为空")
    private String name;

    @Valid                    // 在author字段上添加该注解后,其内部的JSR-303约束注解就能生效了
    private AuthorVo author;

    // 出版商字段没有添加 @Valid 注解,而是在 PublisherVo 类的定义上添加了该注解
    privaet PublisherVo publisher;
}

public class AuthorVo {
    @NotBlank(message = "作者名称不能为空")
    private String name;

    @NotNull(message = "年龄不为空")
    @Range(min=6, max=120, message = "年龄必须在 6~120 以内");
    private Integer age;
}

/** 注解直接作用在类上,这样所有类型为该类的字段,校验都会生效 */
@Valid 
public class Publisher {
  @NotBlank(message = "出版商名称不能为空")
  private name;

  @NotBlank(message = "出让商地址不能为空")
  private String address;
}

24. 如何在程序启动完毕后自动执行某个任务

  • 方式一:实现 InitializingBean接口

    InitializingBean 是 Spring 基础框架提供的一个工 Bean 生命周期接口,它只有一个名为 afterPropertiesSet() 的方法。如果一个受容器管理的类实现了 InitializingBean,那么 Spring 容器在初始化完这个类后,会调用它的 afterPropertiesSet() 方法。比如下面这段示例代码:

    import org.springframework.stereotype.Component;
    import org.springframework.beans.factory.InitializingBean;
    
    @Component
    public class AuthenticationFailureLimitor implements InitializingBean {
        private LimitSettings limitSettings;
        
        // 这是一个模拟的业务方法:限制认证失败的次数
        public void tryBlock(int failureCount) {
            if (limitSettings.isEnable && failureCount > limitSettings.getMaxFailureCount) {
                doBlock();
            }
        }
    
        /** 
         * 本方法将在初化完成后(即所有需要自动注入的字段都被赋值后)调用
         * 这里在方法中模拟对限制设置对象的初始加载
         */
        @Override
        public void afterPropertiesSet() throws Exception {
            this.limitSettings = loadLmitSettings();
        }
        
    }
    
  • 方式二:使用 @PostContruct 注解修饰要在启动完成后立即执行的方法

    @PostContruct 是Java JSR-250 基础规范中的标准注解,

    import javax.annotation.PostConstruct;
    import org.springframework.stereotype.Component;
    
    @Component
    public class AuthenticationFailureLimitor {
    
        // 其它业务代码
        ......
    
        @PostConstruct
        public void init() {
            System.out.println("init()方法将在启动完成后执行");
        }
    
        // 方法的作用域即使是 private 也可以
        @PostConstruct
        private void setup() {
            System.out.println("setup()方法将在启动完成后执行");
        }
    }
    

    上述示例代码中的 init() 和 setup() 方法都会在启动完成后执行

  • 方式三:监听容器事件 <推荐>

    Spring 基础框架提供了事件机制,在容器启动的各个阶段中,均会向容器内的组件广播相应的事件,以便业务代码或第三方组件添加自己的扩展逻辑。有两种方法来监听事件。

    1. 实现 ApplicationListener 接口

    这是在 Spring4.2 版本以前的标准做法,接口定义了 onApplicationEvent () 方法,接口声明支持范型,可以指定要监听事件类型,这里需要监听的事件为 ApplicationReadyEvent。示例代码如下:

    import org.springframework.stereotype.Component;
    import org.springframework.context.ApplicationListener;
    
    @Component
    public class OneceTask implements ApplicationListener<ApplicationReadyEvent> { // 指定要监听的事件
                                                          ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
        @Override
        public void onApplicationEvent(ApplicationReadyEvent event) {
            System.out.println("onApplicationEvent()方法将在容器启动完成后执行");
        }
    }
    

    2. 用 @EventLisnter 注解修饰目标方法 <推荐>

    随着注解方式装配容器成为主流,Spring4.2 版本以后引入了 @EventListener 来快速实现事件监听。示例代码如下:

    import org.springframework.stereotype.Component;
    import org.springframework.context.event.EventListener;
    
    @Component
    public class OnceTask {
        // 监听的事件还可以换成 ApplicationReadyEvent, 作用域也可以是 private 的
        @EventListener(ApplicationStartedEvent.class)
        private void doSomething(ApplicationStartedEvent event){
            System.out.println("我是容器启动后的初始化任务");
        }
    }
    
  • 方式四:实现 CommandLineRunner 接口 <推荐>

    SpringBoot 第一个版本就有这个接口,当应用容器完成所有Bean的装配后(对应的事件为 ApplicationStarted,此时应用还不能接收外部请求),将调用该接口内的方法。示例代码如下:

    import org.springframework.stereotype.Component;
    import org.springframework.boot.CommandLineRunner;  
    @Component
    public class OnceTask implements CommandLineRunner {
        // 监听的事件还可以换成 ApplicationReadyEvent
        @Override
        public void run(String... args) throws Exception {
            System.out.println("我将在应用容器完成所有Bean的装配后执行");
        }
    }
    

📣 特别说明

严格说来,事件监听方式和 CommandLineRunner 方式才是最符合要求的,它才是真正意义上的「容器启动完成后」执行的方法。另外两种方式实际上是在「Bean 初始化完成后」执行的。

假定有一个类,集中使用了上面的所有方式,那么这些方法谁先执行谁后执行呢 ?看看下面这个例子:

示例代码(点击查看)
@Comonent
public class OnceTask implements InitializingBean, ApplicationListener, CommandLineRunner {
    @PostConstruct
    private void setup() {
        System.out.println("OnceTask.@PostConstruct");
    }

    @EventListener
    private void onStartedByAnnotation(ApplicationStartedEvent event){
        System.out.println("OnceTask.@EventListener(ApplicationStartedEvent)");
    }

    @EventListener
    private void onReadyByAnnotation(ApplicationReadyEvent event){
        System.out.println("OnceTask.@EventListener(ApplicationReadyEvent)");
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("OnceTask .afterPropertiesSet()");
    }

    @Override
    public void onApplicationEvent(ApplicationStartedEvent event) {
        System.out.println("OnceTask.onStartedByInterface(ApplicationStartedEvent)");
    }

    @Override
    public void run(String... args) throws Exception {
        System.out.println("OnceTask.commandLineRunner");
    }
}

以上代码的执行结果为:

OnceTask.@PostConstruct
OnceTask.afterPropertiesSet()
OnceTask.onStartedByInterface(ApplicationStartedEvent)
OnceTask.@EventListener(ApplicationStartedEvent)
OnceTask.commandLineRunner
OnceTask.@EventListener(ApplicationReadyEvent)

另外,使用XML装配方式时,在XML的<Bean>标签中,还可以通过 init-method 属性指定某个类的初始化方法,其作用与 @PostContrust 注解和 InitializingBean 的 afterProperties() 方法一致。但由于目前(2023年)国内所有新项目都使用注解来装配容器,这里就不再详细介绍它了。

25. 如何解决Bean装配过程中的循环依赖

循环依赖示意图:

┌───┐                    ┌───┐                    ┌───┐
│ A │ --- Depends On --> │ B │ --- Depends On --> │ C │
└───┘                    └───┘                    └─┬─┘
  ↑                                                 │
  └─────────────────── Depends On ──────────────────┘

Spring 本身是不支持循环依赖的,在程序启动期间,Bean 容器会检查是否存在循环依赖,如果存在,则直接启动失败,同时也会在日志中输出循环依赖的 Bean 信息。

最好的办法是在设计上避免循环依赖,如果实在避免不了,可以通过「手动装配部分」依赖的方式来解决。即让 Spring 完成无循环依赖的部分,在程序启动完毕后,再手动完成涉及循环依赖部分。下面是一个示例(示例的代码注释阐述了实现原理):

存在循环依赖问题的原始代码(点击查看)
//这个设计中,SerivceA 和 ServiceB 相互依赖对方,导致容器启动失败
@Service
public class ServiceA {
    @Autowired
    private ServiceB serviceB;
}

@Service
public class ServiceB {
    @Autowired
    private ServiceA serviceA;
}
通过手动装配部分依赖,解决循环依赖问题(点击查看)
@Service
public class ServiceA {
    // serviceB 字段的值不交由 Spring 容器处理,由我们手动赋值
    private ServiceB serviceB;

    /**
     * 这里通过「应用程序启动完成」事件,通过Bean容器中取出 ServiceB 实例
     * 然后再手动赋值给 serviceB 字段,解决循环依赖问题
     *
     * 说明一:ApplicationStartedEvent 事件表明所有 Bean 已装配完成,但此时尚未发生任何对外服务的调用,
     *        而整个系统可以对外提供服务的事件是 ApplicationReadyEvent, 因此这里使用 Started 事件更合适
     *
     * 说明二:手动装配的关键是「拿到ApplicationContext」和「在合适的时机进行手动装配」,这个合适的时机就是 ApplicationStartedEvent
     *       「拿到ApplilcationContext对象」还有其它一些办法,参见「第13小节」
     *       「合适的时机」同样也有一些其它的选择,参见「第24小节」
     */
    @EventListener(ApplicationStartedEvent.class)
    void setCycleDependencyFields(ApplicationStartedEvent appStartedEvent) {
        ServiceB serviceB = appStartedEvent.getApplicationContext().getBean(ServiceB.class);
        this.serviceB = serviceB;
    }
}

// ServiceB 正常装配
@Service
public class ServiceB {
    @Autowired
    private ServiceA serviceA;
}

26. 如何在所有Web请求的前后执行自己的代码

采用以下两种方式中的一种即可

  1. Sevlet 过滤器

  2. SpringMvc的HandlerInterceptor接口

27. 如何统一给配置项属性值加密

一般说来,研发人员是接触不到生产环境中的配置文件的,正规的项目,也不会将生产环境的信息内置到源代码Jar包中。因此,多数 C 端的项目是不需要对配置文件进行加密的。有此要求的大多是 toB 或 toG 类项目。

统一给配置文件中的指定属性值加密,可以使用 Jasypt 来完成。Jasypt 原本只是一个加密解密的基础工具,但经过进一步封装增强后的 jasypt-spring-boot-starter,便能够统一地对 SpringBoot 项目中的加密配置属性进行解密。全程是自动的,默认情况下,只需要进行以下两步设置:

  1. 设置 jasypt 的加密密钥

    Jasypt 默认的加密类的是 SimpleAsymmetricStringEncryptor, 它的密钥取自属性 jasypt.encryptor.password

  2. 生成密文并标识其需要由 Jasypt 做解密处理

    将要加密的明文,提前用第1步的密钥生成密文,然后将其包裹在ENC()的括号中,这样 Jasypt 就会在属性加载完毕后,对被 ENC() 包裹的属性值进行解密,并用解密后的明文替换原来的值。

下面是实操步骤:

引入依赖

<dependency>
    <groupId>com.github.ulisesbocchio</groupId>
    <artifactId>jasypt-spring-boot-starter</artifactId>
    <!-- SpringBoot3 以上的版本,使用3.x的版本,反之使用2.x的版本 -->
    <version>2.1.2</version>
</dependency>

配置文件中针敏感属性加密

# 无需加密的配置项,以明文配置
demo-app:
  username: westing-loafer
  password: continuous-wandering

# 加密后的配置项,PropertySource 加载后,[Jasypt][jasypt] 会查找 被 ENC() 包裹的配置属性项,然后将其解码
database:
  username: ENC(QTQhWWDOt4c2u3gHzd50F38nkQriShqE)               # ①
  password: ENC(NMiiJQbnMQFhZBbmiEa+LEe7Ps+u+DmWNd1JATXXPWs=)   # ②

# Jasypt 的加密密钥,解密配置项时,也使用该密钥。这里将其注释,因为正式的项目,不会将密钥写在配置文件中
# 详见 com.ulisesbocchio.jasyptspringboot.encryptor.DefaultLazyEncryptor#createPBEDefault() 方法
# jasypt.encryptor.password: cnblogs

在主方法中验证

点击查看代码
@SpringBootApplication
public class JaspytDemoAppMain implements CommandLineRunner {

    @Autowired
    Environment env;

    public static void main(String[] args) {
        args = checkOrSetDefaultJasyptPassword(args);           // ③
        SpringApplication.run(JaspytDemoAppMain.class, args);
    }

    /**
     * 检查 Jasypt 的密钥设置情况,若未通过命令行传递,则设置默认值
     * @param cmdLineArgs 命令行的参数
     * @return 经过检查后的参数数组
     */
    private static String[] checkOrSetDefaultJasyptPassword(String[] cmdLineArgs) {
        String defaultJasyptPasswordProperty = "--jasypt.encryptor.password=cnblogs";
        if (cmdLineArgs.length == 0) {
            return new String[]{defaultJasyptPasswordProperty};
        }

        if (isCmdLineArgsContainsJasyptPassword(cmdLineArgs)) {
            return cmdLineArgs;
        }
        
        String[] enhancedArgs = new String[cmdLineArgs.length + 1];
        for (int i = 0; i < cmdLineArgs.length; i++) {
            enhancedArgs[i] = cmdLineArgs[i];
        }
        enhancedArgs[enhancedArgs.length - 1] = defaultJasyptPasswordProperty;
        return enhancedArgs;
    }

    private static boolean isCmdLineArgsContainsJasyptPassword(String[] args) {
        for (String arg : args) {
            if (arg.startsWith("----jasypt.encryptor.password=")) {
                return true;
            }
        }
        return false;
    }

    @Override
    public void run(String... args) throws Exception {
        // 这两个配置,本身就是明文的
        System.out.println("demo-app.username = " + env.getProperty("demo-app.username"));
        System.out.println("demo-app.password = " + env.getProperty("demo-app.password"));

        // 以下两个属性配置项,文件为密文,但PropertySource加载后经过jasypt的处理,变成了明文
        System.out.println("database.username = " + env.getProperty("database.username"));
        System.out.println("database.password = " + env.getProperty("database.password"));
    }
}

代码说明一:

上述代码的 ① ② 处,您一定会有疑问: 这个密文是如何生成的呢,Jasypt 如何能在项目启动阶段对其正确的解密呢?没错,实际上这里的密文就是提前用 Jasypt 加密生成出来的。必须要保证生成密文所用的 password 与 SpringBoot 项目中给 Jasypt 指定的 password 内容是一致的,才能解密成功。下面是一段简单的加密代码示例:

public static String encrypt(String plainText, String secretKey) {
    BasicTextEncryptor textEncryptor = new BasicTextEncryptor();
    textEncryptor.setPassword(secretKey);
    return textEncryptor.encrypt(plainText);
}

代码说明二:

代码 ③ 处,在正式执行 SpringApplication.run(....) 方法之前, 对命令行参数 args 进行了检查,并使用了检查后的 args 作为命令行参数传递给 SpringApplication.run() 方法。对 args 参数的检查逻辑为:判断 args 中是否包含 --jasypt.encryptor.password= 的配置,如果没有,则默认追加上该条目,并给出一个默认的password。

这一步实际上就是希望将 Jasypt 的密钥通过命令行参数传递过来,而不是配置在配置文件中,避免可以通过配置中心的管理页面,直接查看到该密钥。如何保管好密钥,这是一个纯管理问题了,技术上只要能支撑起设计好的管理方式即可。

本小节的完整代码已上传到 jasypt-spring-boot-demo

28. 如何处理同名Bean对象多次注册导致的启动失败问题

这里单指不能修改程序包的情况,如果能修改源码,该问题自然好处理了,在代码中避免注册同名的Bean即可。以下情况,往往是无法修改源码的

  • 从其它团队交接过来的项目,且只有jar包,没有源码
    至于为什么交接的项目没有源码,国内的公司管理情况你懂的 😁 ,尤其是那种交接了好几手的项目,部分工程源码丢失也不是没有可能的

  • 项目中嵌套引用的基础工具
    这些工具以AutoConfiguration的方式做成Jar包,在其它工程中引入时,再次被包装成上层工具,这种层层包装方式,极有可能导致同名组件注册

  • 引入的第三方包中有同名组件注册问题

解决办法是,直接允许同名组件多次注册,配置如下:

spring.main.allow-bean-definition-overriding = true

关于 spring.main 的更多配置参见 SpringBoot项目组件常见配置

29. 如何优雅地停止 SpringBoot 服务

29.1 优雅停止不涉及 Web 服务的 SpringBoot 项目

通常来说,如果 SpringBoot 项目不涉及 Web 服务,但它还长时间在运行,那程序中一定有任务执行器在执行周期性任务。因此,优雅停机的方式就是要调用所有任务执行器的 shutdown 方法,该方法会让任务执行器进入停止状态,此时它具有以下特性:

  • 执行器不再接收新任务
  • 执行器等待已提交任务的执行完成
  • 超过最大等待时长任务依然没有执行完,则强制结束

示例代码如下:

先装配一个任务执行器

@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
	ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
	taskExecutor.setCorePoolSize(2);
	taskExecutor.setMaxPoolSize(2);
    // 开启「停机时等待已提交任务的执行完成」特性
	taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
    // 设置最大等待时长
	taskExecutor.setAwaitTerminationSeconds(120);
    // 初始化任务执行器,此后执行器就进入工作状态了
	taskExecutor.initialize();
	return taskExecutor;
}

然后利用 Spring 生命周期中的 Bean 销毁回调,触发执行器 shutdown 方法的调用

@Component
public class ExecutorShutdownHook implements DisposableBean {
    @Resource
    ThreadPoolTaskExecutor taskExecutor;

    // 进程结束前,会调用本方法
    @Override
    public void destroy() throws Exception {
        System.out.println("[ShutdownHook]: 开始停止任务执行器");
        taskExecutor.shutdown();
        System.out.println("[ShutdownHook]: 任务执行器已平滑停止");
    }
}

29.2 优雅停止包含 Web 服务的 SpringBoot 项目

对于涉及 Web 服务的 SpringBoot 项目,与 nginx 一样,先让 Servlet 容器停止接收新请求,待已接收的请求处理完毕后,执行业务服务本身的清理工作。最后停止Servlet容器,结果整个服务进程。

在早期,上述工作需要开发人员,结合所用的 Servelt 容器(如 Tomcat)所提供的接口能力,手动编码来完成。从 spring-boot 2.3 开始,官方引入了 server.shutdown 这个配置项,只需要将其设置为 graceful 即可。即当配置了 server.shutdown=graceful 时,程序就能优雅停止 Web 服务了。与任务执行器的优雅停止一样,等待已接收请求的处理完成,也有个最大时长,这个时长也可以在 properties 中配置。如下所示:

server:
  # 开启Web服务的优雅停止特性
  shutdown: graceful

spring:
  lifecycle:
    # 设置收到TERM信号后,等待完成的最大时长
    timeout-per-shutdown-phase: 2m

下面是一个同时包含了「任务执行器」和「Web服务」的SpringBoot样例项目,平滑停机的最后日志输出:

[任务-1]: 我开始睡觉了哈...
[任务-2]: 我开始睡觉了哈...
2024-03-27 11:36:21.617  INFO 26540 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-03-27 11:36:21.617  INFO 26540 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2024-03-27 11:36:21.618  INFO 26540 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 0 ms
[SampleController#justRequest]: 将于10秒后返回
2024-03-27 11:36:25.909  INFO 26540 --- [ionShutdownHook] o.s.b.w.e.tomcat.GracefulShutdown        : Commencing graceful shutdown. Waiting for active requests to complete
[SampleController#justRequest]: 10秒时间到了,返回给客户端
2024-03-27 11:36:31.768  INFO 26540 --- [tomcat-shutdown] o.s.b.w.e.tomcat.GracefulShutdown        : Graceful shutdown complete
[ShutdownHook]: 开始停止任务执行器
[任务-1]: 我已经睡醒了喔 ^_^
[任务-2]: 我已经睡醒了喔 ^_^
[任务-3]: 我开始睡觉了哈...
[任务-3]: 我已经睡醒了喔 ^_^
[ShutdownHook]: 任务执行器已平滑停止

在第 6 行时,我们通过浏览器访问了 http://localhost:8080/test,这个请求会进入到 SampleController 的 test 方法,方法故意 sleep 了10秒,因此浏览器端处于等等响应的过程。页面没有输出。

在第 7 行处,向进程发出了终止信号,该行日志表明: 整个程序在等等活跃请求(active requests 即已接收到的请求)的执行完成。而此时第 6 行的Web请求依然还在处理中,直到 SampleController 的 test 方法 sleep 结束,才返回给了浏览器端,同时 Servelt 容器完全停止(见第 9 行)。

后面的 [任务-]、[任务-2]、[任务-3] 是任务执行器的平滑停机过程,这个是需要我们手动编码来控制的。

整个优雅停机样例的源码已上传到 gracefully-shutdown-spring-boot

30. 如何处理 YAML 或 Properties 的解析异常 MalformedInputException

时常遇到在IDE中启动程序OK,但打包后使用命令启动则抛出类似这面这样的错误:

org.yaml.snakeyaml.error.YAMLException: java.nio.charset.MalformedInputException: Input length = 1

解决文案分两步:

  1. 修正文件编码

  2. 设置java命令的编码参数

    如:java -Dfile.encoding=utf8 -jar xxxx.jar

31. 如何在运行期动态调整日志级别和增减Logger

可以在logback的配置文件中,指定日志配置的刷新周期,程序在运行期会按这个周期重要加载日志输出配置,如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="300 seconds">
                            ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄

32. IntelliJ Idea 中代码飘红的常见解决步骤

按照以下顺序进行,如果前一步执行完后问题已解决,就不用尝试后面的办法了。一般而言最后一步是终极大招,若这一招也不灵,那只能乞求上帝了。

  • 重新导入
  • 重建idea 文件索引缓存
  • 删除工程目录下的 .idea/ 目录

33. 一份 Linux 环境下部署 SpringBoot 程序的参考 Shell 脚本

本脚本提供以下特性

  • 关闭终端后,Java进程不会退出
  • 提供了部署、启动、停止、重启三种最见的操作
  • 每次部署时,均备份当前的程序包(可通过参数关闭备份)
Shell部署脚本
#!/bin/bash

THIS_DIR=$(cd $(dirname $0);pwd)

# 要部署的服务名称,请根据实际情况修改
SERVICE_NAME=gateway-service
# 要部署的服务名Jar包名称,请根据实际情况修改
JAR=gateway-service.jar
CONFIG_FILE=${THIS_DIR}/application.yml
 
usage() {
    # 提示内容请根据实际情况修改
    echo "网关服务"
    echo "用法: startup.sh [deploy|start|stop|status]"
    echo "   deploy: 部署新的jar包, 动作有"
    echo "     · 备份当前正运行的jar包"
    echo "     · 替换正运行的jar包"
    echo "     · 停止当前服务进程"
    echo "     · 使用jar包启动服务程序"
    echo "     · 启动成功,则只保留最近10个部署包"
    echo ""
    echo "   start: 直接启动程序包,如果已存在服务进程,会启动失败"
    echo ""
    echo "   stop: 停止服务进程"
    echo ""
    echo "   status: 检查服务进程是否处于运行状态"
    echo ""
    exit 0
}
 
 
check_process_existance(){
    pid=`ps -ef | grep $JAR | grep -v grep | awk '{print $2}'`
    if [ -z "$pid" ]; then
        return 1
    else
        return 0
    fi
}
 
run(){
    OPTS="-server -Dfile.encoding=utf8 -Dsun.jnu.encoding=utf8 -Xms3g -Xmx3g -Xmn2g -XX:SurvivorRatio=9"
    OPTS="${OPTS} -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDateStamps -XX:+PrintReferenceGC"
    OPTS="${OPTS} -XX:+PrintTenuringDistribution -XX:+PrintHeapAtGC -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime"
    OPTS="${OPTS} -Xloggc:$THIS_DIR/logs/gc/gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=10M"
    OPTS="${OPTS} -XX:-UseAdaptiveSizePolicy -XX:+PrintAdaptiveSizePolicy -XX:ErrorFile=$THIS_DIR/hs_err_license-service_%p.log"
    CMD="java ${OPTS} -jar $THIS_DIR/$JAR "
    RUN_PARAMS=" --spring.config.location=${CONFIG_FILE}"
    echo -e "Start ${SERVICE_NAME} with command:\n\t${CMD} ${RUN_PARAMS}"
    nohup $CMD $RUN_PARAMS > /dev/null 2>&1 &
    echo "$CMD $RUN_PARAMS"

    # 下面这个命令是将java程序在前台运行,以便在启动失败时,查看详细原因
    # 如果使用该命令,在终端前台也找不到有用的错误信息的话,需要个性 ${THIS_DIR}/logback.xml的日志输出器
    # 生产环境下,log方式的日志不输出到控制台, 只有 System.out 和 System.error 的打印才输出到终端
    #$CMD $RUN_PARAMS
}

deploy() {
    echo "deploy命令功能尚未完成"
    exit 1
}
 
start(){
    check_process_existance

    if [ $? -eq "0" ];then    
        echo "服务 ${SERVICE_NAME} 正处于运行中,进程号为:${pid} ";
    else
        run
    fi
}
 
stop(){
    check_process_existance
    if [ $? -eq "0" ];then
        echo "Stopping ${SERVICE_NAME} with pid: ${pid}"
        kill $pid
    while [ -e /proc/${pid} ]; do sleep 1; done
        echo "Shutdown ${SERVICE_NAME} with pid: ${pid}"
    else
        echo "${SERVICE_NAME} is not running"
    fi
}
 
status(){
    check_process_existance

    if [ $? -eq "0" ];then
        echo "${SERVICE_NAME} is running, pid is ${pid}"
    else
        echo "${SERVICE_NAME} is not running."
    fi
}
 
case "$1" in
  "deploy")
    deploy
    ;;
  "start")
    start
    ;;
  "stop")
    stop
    ;;
  "status")
    status
    ;;
  *)
    usage
    ;;
esac
posted @ 2024-03-27 14:11  顾志兵  阅读(5107)  评论(33编辑  收藏  举报