经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » Java相关 » Spring Boot » 查看文章
Spring Boot 工程开发常见问题解决方案,日常开发全覆盖
来源:cnblogs  作者:顾志兵  时间:2024/3/29 8:48:15  对本文有异议

本文是 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 中设置日期格式。如下所示:

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

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

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

  1. spring:
  2. jackson:
  3. date-format: yyyy-MM-dd HH:mm:ss
  4. locale: zh_CN
  5. time-zone: "GMT+8"
  6. http:
  7. converters:
  8. preferred-json-mapper: jackson
  9.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄

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

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

  1. import org.springframework.context.ApplicationContextAware;
  2. import org.springframework.stereotype.Component;
  3. @Component
  4. public class ApplicationContextHolder implements ApplicationContextAware {
  5. // 声明一个静态变量来持有 ApplicationContext
  6. private static ApplicationContext appContext;
  7. @Override
  8. public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
  9. ApplicationContextHolder.appContext = applicationContext;
  10. }
  11. public static ApplicationContext getContext() {
  12. return ApplicationContextHolder.appContext;
  13. }
  14. }

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

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

  • 在 pom.xml 中添加 spring boot 的构建插件
  • 为上一步的插件配置执行目标
  • 在工程目录下,命令行执行 maven clean package -Dmaven.test.skip=true
  1. <build>
  2. <plugins>
  3. <plugin>
  4. <groupId>org.springframework.boot</groupId>
  5. <artifactId>spring-boot-maven-plugin</artifactId>
  6. <version>2.1.6.RELEASE</version>
  7. <executions>
  8. <execution>
  9. <goals>
  10. <goal>repackage</goal>
  11. </goals>
  12. </execution>
  13. </executions>
  14. </plugin>
  15. </plugins>
  16. </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 配置文件相关参数,指定配置文件的位置,如下所示:

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

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

?? 特别说明:

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

  1. /** 样例A */
  2. public static void main(String[] args) {
  3. SpringApplication.run(OverSpeedDataInsightMain.class);
  4. }
  5. /** 样例B */
  6. public static void main(String[] args) {
  7. SpringApplication.run(OverSpeedDataInsightMain.class, args);
  8.  ̄ ̄ ̄
  9. }

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

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

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

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

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

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

application.yml (点击查看)
  1. spring:
  2. datasource:
  3. primary:
  4. driver: org.sqlite.JDBC
  5. url: jdbc:sqlite::resource:biz1.sqlite3?date_string_format=yyyy-MM-dd HH:mm:ss
  6. minor:
  7. driver: org.sqlite.JDBC
  8. url: jdbc:sqlite::resource:biz2.sqlite3?date_string_format=yyyy-MM-dd HH:mm:ss
主数据源装配 (点击查看)
  1. @MapperScan(
  2. basePackages = {"cnblogs.guzb.biz1"},
  3. sqlSessionFactoryRef = "PrimarySqlSessionFactory"
  4. )
  5. @Configuration
  6. public class PrimarySqlSessionFactoryConfig {
  7. // 表示这个数据源是默认数据源,多数据源情况下,必须指定一个主数据源
  8. @Primary
  9. @Bean(name = "PrimaryDataSource")
  10. @ConfigurationProperties(prefix = "spring.datasource.primary")
  11. public DataSource getPrimaryDateSource() {
  12. // 这个MyBatis内置的无池化的数据源,仅仅用于演示,实际工程请更换成具体的数据源对象
  13. return new UnpooledDataSource();
  14. }
  15. @Primary
  16. @Bean(name = "PrimarySqlSessionFactory")
  17. public SqlSessionFactory primarySqlSessionFactory(
  18. @Qualifier("PrimaryDataSource") DataSource datasource) throws Exception {
  19. SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
  20. bean.setDataSource(datasource);
  21. // 主数据源的XML SQL配置资源
  22. Resource[] xmlMapperResources = new PathMatchingResourcePatternResolver().getResources("classpath:mappers/primary/*.xml");
  23. bean.setMapperLocations(xmlMapperResources);
  24. return bean.getObject();
  25. }
  26. @Primary
  27. @Bean("PrimarySqlSessionTemplate")
  28. public SqlSessionTemplate primarySqlSessionTemplate(
  29. @Qualifier("PrimarySqlSessionFactory") SqlSessionFactory sessionFactory) {
  30. return new SqlSessionTemplate(sessionFactory);
  31. }
  32. }
副数据源装配 (点击查看)
  1. @Configuration
  2. @MapperScan(
  3. basePackages = {"cnblogs.guzb.biz2"},
  4. sqlSessionFactoryRef = "MinorSqlSessionFactory"
  5. )
  6. public class MinorSqlSessionFactoryConfig {
  7. @Bean(name = "MinorDataSource")
  8. @ConfigurationProperties(prefix = "spring.datasource.minor")
  9. public DataSource getPrimaryDateSource() {
  10. // 这个MyBatis内置的无池化的数据源,仅仅用于演示,实际工程请更换成具体的数据源对象
  11. return new UnpooledDataSource();
  12. }
  13. @Bean(name = "MinorSqlSessionFactory")
  14. public SqlSessionFactory primarySqlSessionFactory(
  15. @Qualifier("MinorDataSource") DataSource datasource) throws Exception {
  16. SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
  17. bean.setDataSource(datasource);
  18. // 主数据源的XML SQL配置资源
  19. Resource[] xmlMapperResources = new PathMatchingResourcePatternResolver().getResources("classpath:mappers/minor/*.xml");
  20. bean.setMapperLocations(xmlMapperResources);
  21. return bean.getObject();
  22. }
  23. @Bean("MinorSqlSessionTemplate")
  24. public SqlSessionTemplate primarySqlSessionTemplate(
  25. @Qualifier("MinorSqlSessionFactory") SqlSessionFactory sessionFactory) {
  26. return new SqlSessionTemplate(sessionFactory);
  27. }
  28. }

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

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

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

DynamicDataSource.java
  1. public class DynamicDataSource extends AbstractRoutingDataSource {
  2. /** 通过 ThreadLocal 来记录当前线程中的数据源名称 */
  3. private final ThreadLocal<String> localDataSourceName = new ThreadLocal<>();
  4. public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
  5. super.setDefaultTargetDataSource(defaultTargetDataSource);
  6. super.setTargetDataSources(targetDataSources);
  7. }
  8. @Override
  9. protected Object determineCurrentLookupKey() {
  10. return localDataSourceName.get();
  11. }
  12. public void setDataSourceName(String dataSourceName) {
  13. localDataSourceName.set(dataSourceName);
  14. }
  15. public void clearDataSourceName() {
  16. localDataSourceName.remove();
  17. }
  18. }
DynamicDataSourceConfig
  1. @Configuration
  2. public class DynamicDataSourceConfig {
  3. // 表示这个数据源是默认数据源,多数据源情况下,必须指定一个主数据源
  4. @Primary
  5. @Bean(name = "dynamic-data-source")
  6. @DependsOn(DataSourceName.FIRST)
  7. public DynamicDataSource getPrimaryDateSource(
  8. @Qualifier(DataSourceName.FIRST) DataSource defaultDataSource,
  9. @Qualifier(DataSourceName.SECOND) @Autowired(required = false) DataSource secondDataSource
  10. ) {
  11. System.out.println("first=" + defaultDataSource + ", second = " + secondDataSource);
  12. Map<Object, Object> allTargetDataSources = new HashMap<>();
  13. allTargetDataSources.put(DataSourceName.FIRST, defaultDataSource);
  14. allTargetDataSources.put(DataSourceName.SECOND, secondDataSource);
  15. return new DynamicDataSource(defaultDataSource, allTargetDataSources);
  16. }
  17. @Bean(name= DataSourceName.FIRST)
  18. @ConfigurationProperties(prefix = "spring.datasource.first")
  19. public DataSource createFirstDataSource() {
  20. // 这个MyBatis内置的无池化的数据源,仅仅用于演示,实际工程请更换成具体的数据源对象
  21. return new UnpooledDataSource();
  22. }
  23. @Bean(name= DataSourceName.SECOND)
  24. @ConfigurationProperties(prefix = "spring.datasource.second")
  25. public DataSource createSecondDataSource() {
  26. // 这个MyBatis内置的无池化的数据源,仅仅用于演示,实际工程请更换成具体的数据源对象
  27. return new UnpooledDataSource();
  28. }
  29. }
SwitchDataSourceTo
  1. @Retention(RetentionPolicy.RUNTIME)
  2. @Target(ElementType.METHOD)
  3. public @interface SwitchDataSourceTo {
  4. /** 数据源的名称 */
  5. String value() default DataSourceName.FIRST;
  6. }
SwitchDataSourceAspect
  1. @Aspect
  2. @Component
  3. public class SwitchDataSourceAspect {
  4. @Autowired
  5. DynamicDataSource dynamicDataSource;
  6. @Around("@annotation(switchDataSourceTo)")
  7. public Object around(ProceedingJoinPoint point, SwitchDataSourceTo switchDataSourceTo) throws Throwable {
  8. String dataSourceName = switchDataSourceTo.value();
  9. try {
  10. dynamicDataSource.setDataSourceName(dataSourceName);
  11. System.out.println("切换到数据源: " + dataSourceName);
  12. return point.proceed();
  13. } finally {
  14. System.out.println("执行结束,准备切换回到主数据源");
  15. dynamicDataSource.setDataSourceName(DataSourceName.FIRST);
  16. }
  17. }
  18. }
Biz1Mapper
  1. @Mapper
  2. public interface Biz1Mapper {
  3. // 未指定数据源,即为「默认数据源」
  4. @Select("select * from user")
  5. List<UserEntity> listAll();
  6. @SwitchDataSourceTo(DataSourceName.FIRST)
  7. @Select("select * from user where id=#{id}")
  8. UserEntity getById(@Param("id") Long id);
  9. }
Biz2Mapper
  1. @Mapper
  2. public interface Biz2Mapper {
  3. @Select("select * from authority")
  4. @SwitchDataSourceTo(DataSourceName.SECOND)
  5. List<AuthorityEntity> listAll();
  6. // 本方法没有添加 SwitchDataSourceTo 注解,因此会使用默认的数据源,即 first
  7. // 但 first 数据源中没有这个表。该方法会通过在程序中手动设置数据源名称的方式,来切换
  8. @Select("select count(*) as quantity from authority")
  9. Integer totalCount();
  10. }

完整源码请访问码云上的工程 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
  1. @Configuration
  2. public class RedisConfiguration {
  3. /**
  4. * 0号数据库的连接工厂
  5. * 本示例没有使用早期的 JedisConnectionFactory, 而是选择了并发性更好的 LettuceConnectionFactory, 下同
  6. */
  7. @Primary
  8. @Bean("redis-connection-factory-db0") // 明确地指定 Bean 名称,该实例将作为依赖项,传递给相应的 RedisTemplate, 下同
  9.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  10. public RedisConnectionFactory createLettuceConnectionFactory0() {
  11. // 这里使用的是单实例Redis服务器的连接配置类,
  12. // 哨兵与集群模式的服务器,使用对应的配置类设置属性即可。
  13. // 另外,这里没有演示通过yaml外部配置文件来设置相应的连接参数,因为这不是本小节的重点
  14. RedisStandaloneConfiguration clientProps = new RedisStandaloneConfiguration();
  15. clientProps.setHostName("localhost");
  16. clientProps.setPort(6379);
  17. clientProps.setDatabase(0);
  18. return new LettuceConnectionFactory(clientProps);
  19. }
  20. /** 1号数据库的连接工厂 */
  21. @Bean("redis-connection-factory-db1")
  22.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  23. public RedisConnectionFactory createLettuceConnectionFactory1() {
  24. RedisStandaloneConfiguration clientProps = new RedisStandaloneConfiguration();
  25. clientProps.setHostName("localhost");
  26. clientProps.setPort(6379);
  27. clientProps.setDatabase(1);
  28. return new LettuceConnectionFactory(clientProps);
  29. }
  30. /**
  31. * 操作0号数据库的 RedisTemplate,
  32. * 创建时,直接将0号数据库的 RedisConnectionFactory 实例传递给它
  33. */
  34. @Primary
  35. @Bean("redis-template-db-0")
  36. public RedisTemplate<String, String> createRedisTemplate0(
  37. @Qualifier("redis-connection-factory-db0") RedisConnectionFactory factory0) {
  38.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  39. RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
  40. redisTemplate.setConnectionFactory(factory0);
  41. redisTemplate.setKeySerializer(new StringRedisSerializer());
  42. redisTemplate.setValueSerializer(new StringRedisSerializer());
  43. return redisTemplate;
  44. }
  45. /**
  46. * 操作1号数据库的 RedisTemplate,
  47. * 创建时,直接将1号数据库的 RedisConnectionFactory 实例传递给它
  48. */
  49. @Bean("redis-template-db-1")
  50. public RedisTemplate<String, String> createRedisTemplate1(
  51. @Qualifier("redis-connection-factory-db1") RedisConnectionFactory factory1) {
  52.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  53. RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
  54. redisTemplate.setConnectionFactory(factory1);
  55. redisTemplate.setKeySerializer(new StringRedisSerializer());
  56. redisTemplate.setValueSerializer(new StringRedisSerializer());
  57. return redisTemplate;
  58. }
  59. }
多Redis连接的测试验证代码
  1. @Component
  2. @SpringBootApplication
  3. public class MultiRedisAppMain {
  4. // 注入操作0号数据库的Redis模板
  5. @Resource(name = "redis-template-db-0")
  6. RedisTemplate redisTemplate0;
  7. // 注入操作1号数据库的Redis模板
  8. @Resource(name = "redis-template-db-1")
  9. RedisTemplate redisTemplate1;
  10. public static void main(String[] args) {
  11. SpringApplication.run(MultiRedisAppMain.class, args);
  12. }
  13. @EventListener(ApplicationReadyEvent.class)
  14. public void operateBook() {
  15. redisTemplate0.opsForValue().set("bookName", "三体");
  16. redisTemplate0.opsForValue().set("bookPrice", "102");
  17. redisTemplate1.opsForValue().set("bookName", "老人与海");
  18. redisTemplate1.opsForValue().set("bookPrice", "95");
  19. }
  20. }

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

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

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

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

点击查看代码
  1. /** 多个topic在一个方法中消费的情况 */
  2. @KafkaListener(topics = {"topic-1", "topic-2", "topic-3"}, groupId = "group-1")
  3.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  4. public void consumeTopc1_2_3(String message) {
  5. System.out.println("收到消息 kafka :" + message);
  6. }
  7. /** 不同 topic 在不同方法中消费的情况 */
  8. @KafkaListener(topics = "topic-A", groupId = "group-1")
  9. public void consumeTopicA(String message) {
  10. System.out.println("收到消息 kafka :" + message);
  11. }
  12. /** 不同 topic 在不同方法中消费的情况 */
  13. @KafkaListener(topics = "topic-B", groupId = "group-1")
  14. public void consumeTopicB(String message) {
  15. System.out.println("收到消息 kafka :" + message);
  16. }

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 服务器的组件配置类
  1. @Configuration
  2. public class KafkaConfiguration {
  3. @Primary
  4. @Bean("consumerFactory")
  5.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  6. public ConsumerFactory createConsumerFactory() {
  7. Map<String, Object> consumerProperties = new HashMap<>();
  8. consumerProperties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
  9. consumerProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
  10. consumerProperties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
  11. return new DefaultKafkaConsumerFactory<>(consumerProperties);
  12. }
  13. // 第二个消费工厂,为便于实操, 这里依然连接的是同一个 Kafka 服务器
  14. @Bean("consumerFactory2")
  15.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  16. public ConsumerFactory createConsumerFactory2() {
  17. Map<String, Object> consumerProperties = new HashMap<>();
  18. consumerProperties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
  19. consumerProperties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
  20. consumerProperties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
  21. return new DefaultKafkaConsumerFactory<>(consumerProperties);
  22. }
  23. @Primary
  24. // 自己创建的监听容器工厂实例中,一定要有一个实例的名字叫: kafkaListenerContainerFactory,
  25. // 因为 KafkaAnnotationDrivenConfiguration 中也默认配置了一个 KafkaListenerContainerFactory,
  26. // 这个默认的 KafkaListenerContainerFactory 名称就叫 kafkaListenerContainerFactory,
  27. // 其装配条件就是当容器中没有名称为 kafkaListenerContainerFactory 的Bean时,那个装配就生效,
  28. // 如果不阻止这个默认的KafkaListenerContainerFactory装备,会导致容器中有两个 KafkaListenerContainerFactory,这会引入一些初始化问题
  29. @Bean("kafkaListenerContainerFactory")
  30. public KafkaListenerContainerFactory<KafkaMessageListenerContainer> createContainerFactory1(
  31. ConcurrentKafkaListenerContainerFactoryConfigurer configurer,
  32. @Qualifier("consumerFactory") ConsumerFactory consumerFactory) {
  33.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  34. ConcurrentKafkaListenerContainerFactory listenerContainerFactory = new ConcurrentKafkaListenerContainerFactory();
  35. configurer.configure(listenerContainerFactory, consumerFactory);
  36. return listenerContainerFactory;
  37. }
  38. // 第二个监听器容器工厂
  39. @Bean("kafkaListenerContainerFactory2")
  40. public KafkaListenerContainerFactory<KafkaMessageListenerContainer> createContainerFactory2(
  41. ConcurrentKafkaListenerContainerFactoryConfigurer configurer,
  42. @Qualifier("consumerFactory2") ConsumerFactory consumerFactory2) {
  43.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  44. ConcurrentKafkaListenerContainerFactory listenerContainerFactory = new ConcurrentKafkaListenerContainerFactory();
  45. configurer.configure(listenerContainerFactory, consumerFactory2);
  46. return listenerContainerFactory;
  47. }
  48. }
连接多 Kafka 服务器的测试主程序
  1. @Component
  2. @EnableKafka
  3. @SpringBootApplication
  4. public class MultiKafkaAppMain {
  5. public static void main(String[] args) {
  6. SpringApplication.run(MultiKafkaAppMain.class, args);
  7. }
  8. @KafkaListener(topics = "topic1", groupId = "g1", containerFactory = "kafkaListenerContainerFactory")
  9.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  10. public void consumeKafka1(String message) {
  11. System.out.println("[KAFKA-1]: 收到消息:" + message);
  12. }
  13. @KafkaListener(topics = "topic-2", groupId = "g1", containerFactory = "kafkaListenerContainerFactory2")
  14.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  15. public void consumeKafka2(String message) {
  16. System.out.println("[KAFKA-2]: 收到消息:" + message);
  17. }
  18. @EventListener(ApplicationReadyEvent.class)
  19. public void init() {
  20. System.out.println("[MAIN]: 启动成功,等待Kakfa消息");
  21. }
  22. }

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

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

方式一:遍历Environment对象

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

PrintAllPropetiesDemo.java(点击查看)
  1. @Component
  2. public class PrintAllPropetiesDemo {
  3. @Resource
  4. Environment env;
  5. @EventListener(ApplicationReadyEvent.class)
  6. public void printAllProperties throws Exception {
  7. // 打印当前激活的 profile
  8. System.out.println("Active profiles: " + Arrays.toString(env.getActiveProfiles()));
  9. // 从「环境」对象中,获取「属性源」
  10. final MutablePropertySources sources = ((AbstractEnvironment) env).getPropertySources();
  11. // 打印所有的属性,包括:去重、脱敏
  12. StreamSupport.stream(sources.spliterator(), false)
  13. .filter(ps -> ps instanceof EnumerablePropertySource)
  14. .map(ps -> ((EnumerablePropertySource) ps).getPropertyNames())
  15. .flatMap(Arrays::stream)
  16. // 去除重复的属性名
  17. .distinct()
  18. // 过滤敏感属性内容
  19. .filter(prop -> !(prop.contains("credentials") || prop.contains("password")))
  20. .forEach(prop -> System.out.println(prop + ": " + env.getProperty(prop)));
  21. }
  22. }

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

先引入 acuator 的依赖

  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-actuator</artifactId>
  4. </dependency>

然后在配置 acuator 的 web 访问 uri

  1. @Bean
  2. public SecurityWebFilterChain securityWebFilterChain(
  3. ServerHttpSecurity http) {
  4. return http.authorizeExchange()
  5. .pathMatchers("/actuator/**").permitAll()
  6. .anyExchange().authenticated()
  7. .and().build();
  8. }

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

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

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

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

下面是一段示例代码

  1. // 启用异步特性
  2. @EnableAsync
  3. public class BookService {
  4. @Async // 声明要异步执行的方法
  5. public void disableAllExpiredBooks(){
  6. ....
  7. }
  8. }

?? 特别说明

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

  1. @EnableAsync
  2. public class BookService {
  3. @Async("bookExcutor") // 在注解中指定执行器
  4.  ̄ ̄ ̄ ̄ ̄ ̄ ̄
  5. public void disableAllExpiredBooks(){
  6. ....
  7. }
  8. }
  9. @Configuration
  10. public class ExecutorConfiguration {
  11. // 装配书籍任务的通用执行器
  12. @Bean("bookExcutor")
  13.  ̄ ̄ ̄ ̄ ̄ ̄ ̄
  14. public Executor speedingArbitrationExecutor() {
  15. ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
  16. executor.setCorePoolSize(6);
  17. executor.setMaxPoolSize(24);
  18. executor.setQueueCapacity(20000;
  19. executor.setKeepAliveSeconds(30);
  20. executor.setThreadNamePrefix("书籍后台任务线程-");
  21. executor.setWaitForTasksToCompleteOnShutdown(true);
  22. // 任务队列排满后,直接在主线程(提交任务的线程)执行任务,异步执行变同步
  23. executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
  24. return executor;
  25. }
  26. }

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

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

  • 方式一:项目工程直接继承 Boot Starter Parent POM
  1. <parent>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-parent</artifactId>
  4. <version>3.1.5</version>
  5. </parent>
  • 方式二:在pom.xml的依赖管理节点下,添加 spring-boot-dependencies
  1. <dependencies>
  2. <!-- ② 这里添加starter依赖,但不用指定版本 -->
  3. <dependency>
  4. <groupId>org.springframework.boot</groupId>
  5. <artifactId>spring-boot-starter</artifactId>
  6. </dependency>
  7. </dependencies
  8. ......
  9. <dependencyManagement>
  10. <dependencies>
  11. <!-- ① 在这里添加spring-boot的依赖pom -->
  12. <dependency>
  13. <groupId>org.springframework.boot</groupId>
  14. <artifactId>spring-boot-dependencies</artifactId>
  15. <version>2.7.16</version>
  16. <type>pom</type>
  17. <scope>import</scope>
  18. </dependency>
  19. </dependencies>
  20. </dependencyManagement>

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

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

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

  1. import org.springframework.web.util.WebUtils;
  2. import org.springframework.web.context.request.RequestAttributes;
  3. import org.springframework.web.context.request.RequestContextHolder;
  4. import org.springframework.web.context.request.ServletRequestAttributes;
  5. public class WebTool extends WebUtils {
  6. public static HttpServletRequest getHttpRequest() {
  7. RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
  8.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  9. ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
  10. return servletRequestAttributes.getRequest();
  11. }
  12. public static HttpServletResponse getHttpResponse() {
  13. RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
  14.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
  15. ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
  16. return servletRequestAttributes.getResponse();
  17. }
  18. }

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

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

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

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

    启动类

    1. @SpringBootApplication
    2. @EnableAutoConfiguration
    3. @EnableConfigurationProperties
    4.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    5. public class MyBootApp {
    6. public static void main(String[] args) {
    7. SpringApplication.run(MyBootApp.clss, args);
    8. }
    9. }

    自定义的 Properties 类

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

    启动类

    1. @SpringBootApplication
    2. @ConfigurationPropertiesScan({"vip.guzb"})
    3.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    4. public class MyBootApp {
    5. public static void main(String[] args) {
    6. SpringApplication.run(MyBootApp.clss, args);
    7. }
    8. }

    自定义的 Properties 类

    1. @ConfigurationProperties(prefix="gzub.hdfs")
    2.  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
    3. public class HdfsProperties {
    4. private String nameNode;
    5. private String user;
    6. private String password;
    7. }

15. 如何统一处理异常

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

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

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

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

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

下面是一较完整的示例代码(点击查看)
  1. import org.springframework.web.bind.annotation.ResponseStatus;
  2. import org.springframework.web.bind.annotation.RestControllerAdvice;
  3. import org.springframework.http.ResponseEntity
  4. import org.springframework.http.HttpStatus;
  5. @RestControllerAdvice
  6. public class MyGlobalExceptionHandlerResolver {
  7. /** 处理最外层的异常 */
  8. @ExceptionHandler(Exception.class)
  9. public ResponseEntity<ErrorResponse> handleException(Exception e) {
  10. List details = new ArrayList();
  11. details.add(e.getMesssage());
  12. ErrorResponse error = new ErrorResponse(e.getMessage, details);
  13. return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
  14. }
  15. /** 处理业务异常,这里使用了另外一种方式来设置 http 响应码 */
  16. @ExceptionHandler(BusinessException.class)
  17. @ResponseStatus(HttpStatus.HTTP_BAD_REQUEST)
  18. public ErrorResponse handleException(BusinessException e) {
  19. List details = new ArrayList();
  20. details.add(e.getBackendMesssage());
  21. return new ErrorResponse(e.getFrontendMessage(), details);
  22. }
  23. }
  24. /** 返回给前端的错误内容对象 */
  25. public class ErrorResponse {
  26. private String message;
  27. private List<String> details;
  28. ......
  29. }
  30. /** 业务异常 */
  31. public class BusinessException extends RuntimeException{
  32. private String frontendMessage;
  33. private String backendMessage;
  34. ......
  35. }

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

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

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

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

  1. 业务异常处理

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

比如下面这个经典的场景

  1. java.lang.NullPointerException

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

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

  1. java -XX:-OmitStackTraceInFastThrow -jar xxxx.jar

?? 说明1

JVM只对以下异常做FastThrow优化

  • NullPointerException
  • ArithmeticException
  • ArrayStoreException
  • ClassCastException
  • ArrayIndexOutOfBoundsException

?? 说明2

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

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

事务失效示例代码(点击查看)
  1. @Service
  2. public class BookService {
  3. @Resource
  4. BookDao bookDao;
  5. public void changePrice(Long bookId, Double newPrice) {
  6. doChangePrice(bookId, newPrice);
  7. logOperation();
  8. sendMail();
  9. }
  10. @Transactional(rollbackFor = Exception.class)
  11. public void resetPrice(Long bookId, Double newPrice) {
  12. doChangePrice(bookId, newPrice);
  13. logOperation();
  14. sendMail();
  15. }
  16. @Transactional(rollbackFor = Exception.class)
  17. public void doChangePrice(Long bookId, Double newPrice) {
  18. bookDao.setPrice(bookId, newPrice);
  19. }
  20. @Transactional(rollbackFor = Exception.class)
  21. public void logOperation(Long bookId, Double newPrice) {
  22. .... // 省略记录操作日志的代码
  23. }
  24. public void sendMail(Long bookId, Double newPrice) {
  25. .... // 省略发送邮件的代码
  26. }
  27. }

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

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

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

解决办法:在外部单独调用这些有事务注解的方法。如果需要将这些方法组合在一个方法体内,整体完成一个业务逻辑,也在其它类中创建方法,在该方法中调用这些有事务注解的方法完成逻辑组织。

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

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

    如下代码所示:

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

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

    1. spring:
    2. autoconfigure:
    3. # 指定要排除的自动装配类,多个类使用英文逗号分隔
    4. 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步中写的类

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

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

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

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

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

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

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

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

?? 特别说明

  • 需要将 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注解,如下所示:

  1. public class BookVo {
  2. @NotBlank(message = "图书名称不能为空")
  3. private String name;
  4. @Valid // 在author字段上添加该注解后,其内部的JSR-303约束注解就能生效了
  5. private AuthorVo author;
  6. // 出版商字段没有添加 @Valid 注解,而是在 PublisherVo 类的定义上添加了该注解
  7. privaet PublisherVo publisher;
  8. }
  9. public class AuthorVo {
  10. @NotBlank(message = "作者名称不能为空")
  11. private String name;
  12. @NotNull(message = "年龄不为空")
  13. @Range(min=6, max=120, message = "年龄必须在 6~120 以内");
  14. private Integer age;
  15. }
  16. /** 注解直接作用在类上,这样所有类型为该类的字段,校验都会生效 */
  17. @Valid
  18. public class Publisher {
  19. @NotBlank(message = "出版商名称不能为空")
  20. private name;
  21. @NotBlank(message = "出让商地址不能为空")
  22. private String address;
  23. }

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

  • 方式一:实现 InitializingBean接口

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

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

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

    1. import javax.annotation.PostConstruct;
    2. import org.springframework.stereotype.Component;
    3. @Component
    4. public class AuthenticationFailureLimitor {
    5. // 其它业务代码
    6. ......
    7. @PostConstruct
    8. public void init() {
    9. System.out.println("init()方法将在启动完成后执行");
    10. }
    11. // 方法的作用域即使是 private 也可以
    12. @PostConstruct
    13. private void setup() {
    14. System.out.println("setup()方法将在启动完成后执行");
    15. }
    16. }

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

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

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

    1. 实现 ApplicationListener 接口

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

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

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

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

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

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

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

?? 特别说明

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

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

示例代码(点击查看)
  1. @Comonent
  2. public class OnceTask implements InitializingBean, ApplicationListener, CommandLineRunner {
  3. @PostConstruct
  4. private void setup() {
  5. System.out.println("OnceTask.@PostConstruct");
  6. }
  7. @EventListener
  8. private void onStartedByAnnotation(ApplicationStartedEvent event){
  9. System.out.println("OnceTask.@EventListener(ApplicationStartedEvent)");
  10. }
  11. @EventListener
  12. private void onReadyByAnnotation(ApplicationReadyEvent event){
  13. System.out.println("OnceTask.@EventListener(ApplicationReadyEvent)");
  14. }
  15. @Override
  16. public void afterPropertiesSet() throws Exception {
  17. System.out.println("OnceTask .afterPropertiesSet()");
  18. }
  19. @Override
  20. public void onApplicationEvent(ApplicationStartedEvent event) {
  21. System.out.println("OnceTask.onStartedByInterface(ApplicationStartedEvent)");
  22. }
  23. @Override
  24. public void run(String... args) throws Exception {
  25. System.out.println("OnceTask.commandLineRunner");
  26. }
  27. }

以上代码的执行结果为:

  1. OnceTask.@PostConstruct
  2. OnceTask.afterPropertiesSet()
  3. OnceTask.onStartedByInterface(ApplicationStartedEvent)
  4. OnceTask.@EventListener(ApplicationStartedEvent)
  5. OnceTask.commandLineRunner
  6. OnceTask.@EventListener(ApplicationReadyEvent)

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

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

循环依赖示意图:

  1. ┌───┐ ┌───┐ ┌───┐
  2. A --- Depends On --> B --- Depends On --> C
  3. └───┘ └───┘ └─┬─┘
  4. └─────────────────── Depends On ──────────────────┘

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

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

存在循环依赖问题的原始代码(点击查看)
  1. //这个设计中,SerivceA 和 ServiceB 相互依赖对方,导致容器启动失败
  2. @Service
  3. public class ServiceA {
  4. @Autowired
  5. private ServiceB serviceB;
  6. }
  7. @Service
  8. public class ServiceB {
  9. @Autowired
  10. private ServiceA serviceA;
  11. }
通过手动装配部分依赖,解决循环依赖问题(点击查看)
  1. @Service
  2. public class ServiceA {
  3. // serviceB 字段的值不交由 Spring 容器处理,由我们手动赋值
  4. private ServiceB serviceB;
  5. /**
  6. * 这里通过「应用程序启动完成」事件,通过Bean容器中取出 ServiceB 实例
  7. * 然后再手动赋值给 serviceB 字段,解决循环依赖问题
  8. *
  9. * 说明一:ApplicationStartedEvent 事件表明所有 Bean 已装配完成,但此时尚未发生任何对外服务的调用,
  10. * 而整个系统可以对外提供服务的事件是 ApplicationReadyEvent, 因此这里使用 Started 事件更合适
  11. *
  12. * 说明二:手动装配的关键是「拿到ApplicationContext」和「在合适的时机进行手动装配」,这个合适的时机就是 ApplicationStartedEvent
  13. * 「拿到ApplilcationContext对象」还有其它一些办法,参见「第13小节」
  14. * 「合适的时机」同样也有一些其它的选择,参见「第24小节」
  15. */
  16. @EventListener(ApplicationStartedEvent.class)
  17. void setCycleDependencyFields(ApplicationStartedEvent appStartedEvent) {
  18. ServiceB serviceB = appStartedEvent.getApplicationContext().getBean(ServiceB.class);
  19. this.serviceB = serviceB;
  20. }
  21. }
  22. // ServiceB 正常装配
  23. @Service
  24. public class ServiceB {
  25. @Autowired
  26. private ServiceA serviceA;
  27. }

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() 包裹的属性值进行解密,并用解密后的明文替换原来的值。

下面是实操步骤:

引入依赖

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

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

  1. # 无需加密的配置项,以明文配置
  2. demo-app:
  3. username: westing-loafer
  4. password: continuous-wandering
  5. # 加密后的配置项,PropertySource 加载后,[Jasypt][jasypt] 会查找 被 ENC() 包裹的配置属性项,然后将其解码
  6. database:
  7. username: ENC(QTQhWWDOt4c2u3gHzd50F38nkQriShqE) # ①
  8. password: ENC(NMiiJQbnMQFhZBbmiEa+LEe7Ps+u+DmWNd1JATXXPWs=) # ②
  9. # Jasypt 的加密密钥,解密配置项时,也使用该密钥。这里将其注释,因为正式的项目,不会将密钥写在配置文件中
  10. # 详见 com.ulisesbocchio.jasyptspringboot.encryptor.DefaultLazyEncryptor#createPBEDefault() 方法
  11. # jasypt.encryptor.password: cnblogs

在主方法中验证

点击查看代码
  1. @SpringBootApplication
  2. public class JaspytDemoAppMain implements CommandLineRunner {
  3. @Autowired
  4. Environment env;
  5. public static void main(String[] args) {
  6. args = checkOrSetDefaultJasyptPassword(args); // ③
  7. SpringApplication.run(JaspytDemoAppMain.class, args);
  8. }
  9. /**
  10. * 检查 Jasypt 的密钥设置情况,若未通过命令行传递,则设置默认值
  11. * @param cmdLineArgs 命令行的参数
  12. * @return 经过检查后的参数数组
  13. */
  14. private static String[] checkOrSetDefaultJasyptPassword(String[] cmdLineArgs) {
  15. String defaultJasyptPasswordProperty = "--jasypt.encryptor.password=cnblogs";
  16. if (cmdLineArgs.length == 0) {
  17. return new String[]{defaultJasyptPasswordProperty};
  18. }
  19. if (isCmdLineArgsContainsJasyptPassword(cmdLineArgs)) {
  20. return cmdLineArgs;
  21. }
  22. String[] enhancedArgs = new String[cmdLineArgs.length + 1];
  23. for (int i = 0; i < cmdLineArgs.length; i++) {
  24. enhancedArgs[i] = cmdLineArgs[i];
  25. }
  26. enhancedArgs[enhancedArgs.length - 1] = defaultJasyptPasswordProperty;
  27. return enhancedArgs;
  28. }
  29. private static boolean isCmdLineArgsContainsJasyptPassword(String[] args) {
  30. for (String arg : args) {
  31. if (arg.startsWith("----jasypt.encryptor.password=")) {
  32. return true;
  33. }
  34. }
  35. return false;
  36. }
  37. @Override
  38. public void run(String... args) throws Exception {
  39. // 这两个配置,本身就是明文的
  40. System.out.println("demo-app.username = " + env.getProperty("demo-app.username"));
  41. System.out.println("demo-app.password = " + env.getProperty("demo-app.password"));
  42. // 以下两个属性配置项,文件为密文,但PropertySource加载后经过jasypt的处理,变成了明文
  43. System.out.println("database.username = " + env.getProperty("database.username"));
  44. System.out.println("database.password = " + env.getProperty("database.password"));
  45. }
  46. }

代码说明一:

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

  1. public static String encrypt(String plainText, String secretKey) {
  2. BasicTextEncryptor textEncryptor = new BasicTextEncryptor();
  3. textEncryptor.setPassword(secretKey);
  4. return textEncryptor.encrypt(plainText);
  5. }

代码说明二:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

示例代码如下:

先装配一个任务执行器

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

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

  1. @Component
  2. public class ExecutorShutdownHook implements DisposableBean {
  3. @Resource
  4. ThreadPoolTaskExecutor taskExecutor;
  5. // 进程结束前,会调用本方法
  6. @Override
  7. public void destroy() throws Exception {
  8. System.out.println("[ShutdownHook]: 开始停止任务执行器");
  9. taskExecutor.shutdown();
  10. System.out.println("[ShutdownHook]: 任务执行器已平滑停止");
  11. }
  12. }

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

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

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

  1. server:
  2. # 开启Web服务的优雅停止特性
  3. shutdown: graceful
  4. spring:
  5. lifecycle:
  6. # 设置收到TERM信号后,等待完成的最大时长
  7. timeout-per-shutdown-phase: 2m

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

  1. [任务-1]: 我开始睡觉了哈...
  2. [任务-2]: 我开始睡觉了哈...
  3. 2024-03-27 11:36:21.617 INFO 26540 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
  4. 2024-03-27 11:36:21.617 INFO 26540 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
  5. 2024-03-27 11:36:21.618 INFO 26540 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 0 ms
  6. [SampleController#justRequest]: 将于10秒后返回
  7. 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
  8. [SampleController#justRequest]: 10秒时间到了,返回给客户端
  9. 2024-03-27 11:36:31.768 INFO 26540 --- [tomcat-shutdown] o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete
  10. [ShutdownHook]: 开始停止任务执行器
  11. [任务-1]: 我已经睡醒了喔 ^_^
  12. [任务-2]: 我已经睡醒了喔 ^_^
  13. [任务-3]: 我开始睡觉了哈...
  14. [任务-3]: 我已经睡醒了喔 ^_^
  15. [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,但打包后使用命令启动则抛出类似这面这样的错误:

  1. 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的配置文件中,指定日志配置的刷新周期,程序在运行期会按这个周期重要加载日志输出配置,如下所示:

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

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

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

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

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

本脚本提供以下特性

  • 关闭终端后,Java进程不会退出
  • 提供了部署、启动、停止、重启三种最见的操作
  • 每次部署时,均备份当前的程序包(可通过参数关闭备份)
Shell部署脚本
  1. #!/bin/bash
  2. THIS_DIR=$(cd $(dirname $0);pwd)
  3. # 要部署的服务名称,请根据实际情况修改
  4. SERVICE_NAME=gateway-service
  5. # 要部署的服务名Jar包名称,请根据实际情况修改
  6. JAR=gateway-service.jar
  7. CONFIG_FILE=${THIS_DIR}/application.yml
  8. usage() {
  9. # 提示内容请根据实际情况修改
  10. echo "网关服务"
  11. echo "用法: startup.sh [deploy|start|stop|status]"
  12. echo " deploy: 部署新的jar包, 动作有"
  13. echo " · 备份当前正运行的jar包"
  14. echo " · 替换正运行的jar包"
  15. echo " · 停止当前服务进程"
  16. echo " · 使用jar包启动服务程序"
  17. echo " · 启动成功,则只保留最近10个部署包"
  18. echo ""
  19. echo " start: 直接启动程序包,如果已存在服务进程,会启动失败"
  20. echo ""
  21. echo " stop: 停止服务进程"
  22. echo ""
  23. echo " status: 检查服务进程是否处于运行状态"
  24. echo ""
  25. exit 0
  26. }
  27. check_process_existance(){
  28. pid=`ps -ef | grep $JAR | grep -v grep | awk '{print $2}'`
  29. if [ -z "$pid" ]; then
  30. return 1
  31. else
  32. return 0
  33. fi
  34. }
  35. run(){
  36. OPTS="-server -Dfile.encoding=utf8 -Dsun.jnu.encoding=utf8 -Xms128m -Xmx256m -Xmn256m"
  37. OPTS="${OPTS} -XX:HeapDumpPath=$THIS_DIR/vmstack/heap.dump -XX:+PrintGCDetails"
  38. OPTS="${OPTS} -XX:-HeapDumpOnOutOfMemoryError -XX:ErrorFile=$THIS_DIR/vmstack/hs_err.log "
  39. CMD="java ${OPTS} -jar $THIS_DIR/$JAR "
  40. RUN_PARAMS=" --spring.config.location=${CONFIG_FILE}"
  41. echo -e "Start ${SERVICE_NAME} with command:\n\t${CMD} ${RUN_PARAMS}"
  42. nohup $CMD $RUN_PARAMS > /dev/null 2>&1 &
  43. echo "$CMD $RUN_PARAMS"
  44. # 下面这个命令是将java程序在前台运行,以便在启动失败时,查看详细原因
  45. # 如果使用该命令,在终端前台也找不到有用的错误信息的话,需要个性 ${THIS_DIR}/logback.xml的日志输出器
  46. # 生产环境下,log方式的日志不输出到控制台, 只有 System.out 和 System.error 的打印才输出到终端
  47. #$CMD $RUN_PARAMS
  48. }
  49. deploy() {
  50. echo "deploy命令功能尚未完成"
  51. exit 1
  52. }
  53. start(){
  54. check_process_existance
  55. if [ $? -eq "0" ];then
  56. echo "服务 ${SERVICE_NAME} 正处于运行中,进程号为:${pid} ";
  57. else
  58. run
  59. fi
  60. }
  61. stop(){
  62. check_process_existance
  63. if [ $? -eq "0" ];then
  64. echo "Stopping ${SERVICE_NAME} with pid: ${pid}"
  65. kill $pid
  66. while [ -e /proc/${pid} ]; do sleep 1; done
  67. echo "Shutdown ${SERVICE_NAME} with pid: ${pid}"
  68. else
  69. echo "${SERVICE_NAME} is not running"
  70. fi
  71. }
  72. status(){
  73. check_process_existance
  74. if [ $? -eq "0" ];then
  75. echo "${SERVICE_NAME} is running, pid is ${pid}"
  76. else
  77. echo "${SERVICE_NAME} is not running."
  78. fi
  79. }
  80. case "$1" in
  81. "deploy")
  82. deploy
  83. ;;
  84. "start")
  85. start
  86. ;;
  87. "stop")
  88. stop
  89. ;;
  90. "status")
  91. status
  92. ;;
  93. *)
  94. usage
  95. ;;
  96. esac

原文链接:https://www.cnblogs.com/guzb/p/spring-boot-common-development-issue-solution-list.html

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号