Spring Boot 工程开发常见问题解决方案,日常开发全覆盖
本文是 SpringBoot 开发的干货集中营,涵盖了日常开发中遇到的诸多问题,通篇着重讲解如何快速解决问题,部分重点问题会讲解原理,以及为什么要这样做。便于大家快速处理实践中经常遇到的小问题,既方便自己也方便他人,老鸟和新手皆适合,值得收藏 😄
1. 哪里可以搜索依赖包的 Maven 坐标和版本
-
这个在2023年前使用得最多,但目前(2024)国内访问该网站时,经常卡死在人机校验这一步,导致无法使用
-
刚开始我是临时用这个网站来替换前面那个,现在它越来越好用,就直接使用它了
2. 如何确定 SpringBoot 与 JDK 之间的版本关系
在 Spring官网 可以找到 SpringBoot 对应的 JDK 关系,但这种关系说明位于具体版本的参考手册(Reference Doc)中,按照以下图示顺序操作即可找到。
重大版本与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. 如何统一处理异常
-
编写一个普通的Bean,不继承和实现任何类与接口
-
在该Bean的类级别上添加 @RestControllerAdvice 注解,向框架声明这是一个可跨 Controller 处理异常、初始绑定和视图模型特性的类
-
在类中编写处理异常的方法,并在方法上添加 @ExceptionHandler 注解,向框架声明这是一个异常处理方法
编写异常处理方法的要求如下:
- 方法是 public 的
- 方法必须用 @ExceptionHandler 注解修饰
- 方法的返回值就是最终返给前端的内容,通常是JSON文本
- 方法参数中,需指定要处理的异常类型
-
如果需要对特定异常做特殊的处理,则重复第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. 异常处理组件应该具备的特性
-
业务异常处理
-
异常信息中,要明确区分出前端展示内容与后端错误内容
-
后端错误内容可再进一步分为「错误的一般描述信息」和「详细的错误列表」
-
前后端错误信息中,应过滤敏感内容,如身份证、密码等,且过滤机制提供开关功能,以方便开发调试
-
异常信息中,应该包含业务流水号,便于调试和排查线上问题时,将各个节点的错误内容串联起来
-
多数情况下,业务异常都不应该打印堆栈,只需要在日志中输出第一个触发业务异常的代码位置即可
- 因为业务异常是我们在编码阶段就手动捕获了的,也就是说,这些异常是可预期的,并且是我们自己手动编码抛出的。因此,只需要输出该异常的抛出点代码位置,异常堆栈是没有意义的,它只会增加日志的存储体积
- 另外,多数业务异常都是在检查业务的执行条件时触发的,比如:商品不存在、库存不足、越权访问、输入数据不合规等。且这类错误会频繁发生,若输出其堆栈的话,日志中会大量充斥着这样的异常堆栈。它既增加了日志的存储体积,也干扰了正常日志内容的查看
-
异常信息中,要详细记录错误内容,尽可能把异常现场的信息都输出。
这是开发人员最容易给自己和他人挖坑的地方,比如:一个业务异常的日志输出内容是这样:“积分等级不够”。这个异常信息是严重不足的,它缺少以下这些重要信息,以致极难在线上排查问题:- 谁的积分等级不足
- 这个用户当前的积分是多少
- 他要拥有多少积分,和什么样的等级
- 他在访问什么资源
注意:您可能会有疑问,把用户账号输出到日志就可以了,没必要输出它当前的积分,因为积分可以去数据库查。但这样做是不行的,因为:
- 生产环境的数据库研发人员是不能直接访问的,让运维人员查,效率不高还增加运维工作量
- 数据查询出来的值,也不是发生异常当时的值,时光荏然,你大妈已经不你大妈了 😁
- 即使是个相对静态(变动不频繁)的参数,运行期代码所使用的值,也极有可能与数据库中不一致。比如程序启动时,没有从数据库中加载,而是使用了默认值,又或者是某个处理逻辑将它的值临时改变了
-
-
非业务异常
- 尽可能地捕获所有异常
- 一定要在日志中输出非业务异常的堆栈<重要>
- 尽量不要二次包装非业务异常,如果一定要包装,「务必」在将包装后的异常 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的代理实例方法了。操作如下:
-
先在代码中明确调用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 代理实例,因此上述代码中 ② 和 ③ 处的调用,其事务都会生效。
-
启用 Spring Aop 的代理对象暴露特性
默认情况情况下,上述代码 ① 处获得的实例依然是原始对象。需要开启「AOP代理对象暴露」特性,① 处的代码才会生效。具体操作,就是在启动类上加上 @EnableAspectJAutoProxy(exposeProxy = true) 注解,比如:
@SpringBootApplication @EnableAspectJAutoProxy(exposeProxy = true) public class MainApp { public static void main(String[] args) { .... } }
📌 特别说明
开启「AOP代理对象暴露」特性会影响 Spring 容器的性能,所以框架默认未开启此特性
本方法仅对AOP代理对象有效,非AOP代理对象是无效的,比如:@Sync 注解的方法,其所在的实例,虽然也是代理对象,但不是基于切面的代理对象,因此,该方法对此类代理对象是无效的。
幸运的是 @Transactional 注解会创建一个切面,故而在可用此方法来解决本小节提及的问题
21. 如何阻止某个第三方组件的自动装配
-
方法一:配置 @SpringBootApplication 注解的 exclude 属性
如下代码所示:
// 启动时,将Spring官方的数据源自动装配排除 @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ public class MyAppMain{ public static void main(String[] args) { SpringApplication.run(MyAppMain.class, args); } }
-
方法二:在配置文件中指定 <推荐>
方法一需要修改代码,对于普通的业务系统而言,是能不改代码就坚决不改。因此推荐下面这种配置的方式来指定:
spring: autoconfigure: # 指定要排除的自动装配类,多个类使用英文逗号分隔 exclude: org.springframework.cloud.gateway.config.GatewayAutoConfiguration
-
方法三:临时注释掉该组件的 @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®ionLevel=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); }
📣 特别说明
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请求的前后执行自己的代码
采用以下两种方式中的一种即可
-
Sevlet 过滤器
-
SpringMvc的HandlerInterceptor接口
27. 如何统一给配置项属性值加密
一般说来,研发人员是接触不到生产环境中的配置文件的,正规的项目,也不会将生产环境的信息内置到源代码Jar包中。因此,多数 C 端的项目是不需要对配置文件进行加密的。有此要求的大多是 toB 或 toG 类项目。
统一给配置文件中的指定属性值加密,可以使用 Jasypt 来完成。Jasypt 原本只是一个加密解密的基础工具,但经过进一步封装增强后的 jasypt-spring-boot-starter,便能够统一地对 SpringBoot 项目中的加密配置属性进行解密。全程是自动的,默认情况下,只需要进行以下两步设置:
-
设置 jasypt 的加密密钥
Jasypt 默认的加密类的是 SimpleAsymmetricStringEncryptor, 它的密钥取自属性 jasypt.encryptor.password
-
生成密文并标识其需要由 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
解决文案分两步:
-
修正文件编码
-
设置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