随着SpringBoot日渐流行,有关SpringBoot的各类“心经”、“秘籍”、“从入门到精通”之类的文章或者书籍琳琅满目、层出不穷,本文无意与这些典籍争锋,而是从实际应用出发,结合官方文档,收集并整理一些不常见却很有用的知识点,同时蹭一下You Don’t Know JS Yet的热度,姑且就叫它你不知道的SpringBoot

创建项目

start.spring.io可以说是最好的SpringBoot项目创建工具,没有之一。start.spring.io可以满足绝大多数创建由SpringBoot驱动的应用程序的场景,它具备以下特征:

  1. 生成的工程开箱即用,几乎不做任何修改就可以运行;
  2. IDE友好,可以快速导入进主流IDE中;
  3. 依赖版本准确,不会引发依赖不匹配而导致的各类诡异问题,例如SpringBootSpring Cloud大版本的匹配都是准确的;
  4. 多构建工具支持,可以自由选择MavenGradle
  5. 多语言支持,可以自由选择JavaGroovyKotlin
  6. 多Java版本支持,可以自由选择LTS或最新版的JDK;
  7. 可以通过Explore功能拷贝需要的依赖代码片段而不需要下载整个项目;
  8. 可以通过Share功能把创建项目的属性以URL的方式分享给其他人。

除以上特征之外,start.spring.io本身提供了restful API,可以通过简单的http调用来创建项目而不需要访问他的web UI,例如下面的例子,通过curl来创建一个新的项目并下载到本地:

1
2
$ curl https://start.spring.io/starter.zip -d dependencies=web,devtools \
-d bootVersion=2.3.5.RELEASE -o my-project.zip

这种API调用的方式也是大多数IDE生成SpringBoot项目的底层手段。除使用curl外,还可以通过Spring Boot CLI的方式创建项目,与curl方式是非类似,

1
2
3
$ spring init --build=gradle \ 
--java-version=1.8 --dependencies=websocket \
--packaging=jar sample-app.zip

Spring Boot CLI的安装可以参考官方文档

以上start.spring.io以及其衍生工具的使用,除此之外,我们甚至可以搭建自己的Spring Initializr Reference,原理类似与自己运行一个start.spring.io,这里我们就不展开讨论了。
更多详情请参考官方文档

内置的构建工具

如果采用上述start.spring.io生成MavenGradle项目时,项目中会自带Maven WrapperGrade Wrapper,以Maven项目为例,我们会在项目下看到下面的目录结构,

1
2
3
4
5
6
7
8
9
10
├── .mvn
│   └── wrapper
│   ├── MavenWrapperDownloader.java
│   ├── maven-wrapper.jar
│   └── maven-wrapper.properties
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src

其中的.mvnmvnw就是项目自带Maven Wrapper。一般我们在使用Maven时,用到的都是提前安装好的全局的Maven,所有需要构建的程序也都会共享这一个全局的Maven。但是对于某些项目来说,构建是可能需要用到特定的Maven版本,这时做全局安装费时费力,还要污染全局,而使用Maven Wrapper就可以避免类似问题,进而高效地使用特定版本的MavenMaven Wrapper的工作流程是这样的:

  1. 在执行mvnw命令时,会检查/.m2/wrapper目录下是否有对应版本的Maven安装,如果有,则继续构建;
  2. 如果没有,则根据当前目录下.mvn/wrapper/maven-wrapper.properties中的的distributionUrl属性值来下载Maven,下载后再完成构建;

有很多同学在生成SpringBoot项目后,直接就把Maven Wrapper相关的文件删除掉了,这里还是建议保留,并且也应该把这些文件一并提交到代码仓库里,这样对于其他开发人员和CI/CD工具也是友好的,大家都可以使用一致的环境来构建项目。

特有的转换器

Converter SPISpring Framework中非常重要的基本概念,Spring Framework也内置转换器可以实现从String到其他常用数据类型的转换。SpringBoot进一步强化了转换器,引入了3个特有的转换器,可以方便的处理DurationPeroid以及DataSize,详情如下表所示:

Java类型 单位 示例
java.time.Duration ns
us
ms
s
m
h
d
50s
3d
java.time.Period y
m
w
d
2m
1y3d
org.springframework.util.unit.DataSize B
KB
MB
GB
TB
512B
2MB

完整的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ConstructorBinding
@ConfigurationProperties("demo")
@Getter
class DemoProperties {

private final Duration duration;

private final Period period;

private final DataSize dataSize;

DemoProperties(@DefaultValue("30s") Duration duration,
@DefaultValue("2y3d") Period period,
@DefaultValue("5MB") DataSize dataSize) {
this.duration = duration;
this.period = period;
this.dataSize = dataSize;
}
}

更多详情请参考官方文档

特有的事件

Spring Framework本身实现了一整套事件体系,并定义了一组内置的事件,但这些事件基本上是Context级别的,而不是Application级别的。SpringBoot拓展了Spring Framework的事件体系,并引入了Application Level的一组事件:

  1. 应用程序启动时会触发ApplicationStartingEvent
  2. 应用程序运行的Environment准备好后,在创建Applicatoin Context之前,会触发ApplicationEnvironmentPreparedEvent
  3. ApplicationContext准备好后,所有ApplicationContextInitializers被执行后,但在所有的Bean定义加载之前,会触发ApplicationContextInitializedEvent事件;
  4. Bean定义加载后,会触发ApplicationPreparedEvent事件;
  5. Application Context刷新后,command-line runner被调用之前,会触发ApplicationStartedEvent事件;
  6. LivenessState.CORRECT被检测到之后,换言之,应用程序已经被认为处于活跃状态,会触发AvailabilityChangeEvent事件;
  7. 在所有的command-line runner被执行后,会触发ApplicationReadyEvent事件。(原文是“any”而不是“all”,表述不是非常准确,特意提了这个issue);
  8. ReadinessState.ACCEPTING_TRAFFIC被简则之后,换言之,应用程序已被认为可以处理外部请求,会再触发AvailabilityChangeEvent事件;
  9. 在应用程序启动过程中产生任何异常,会触发ApplicationFailedEvent事件。

以上Application Level的各类事件中,最常用的监听ApplicationReadyEvent事件来做其他初始化操作,因为这个时间点,所有的Bean及其依赖都已经创建,这些Bean已经可以使用,例如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Data
class Student {
private Long id;
private String name;
}

@Repository
interface StudentRepo {
Stream<Student> findAll();
}

@Service
@RequiredArgsConstructor
class StudentService {

private final StudentRepo studentRepo;

private final AtomicReference<Map<Long, Student>> _caches = new AtomicReference<>();

@EventListener(ApplicationReadyEvent.class)
void init() {
this._caches.set(this.studentRepo.findAll()
.collect(Collectors.toConcurrentMap(Student::getId, Function.identity())));
}

}

更多详情请参考官方文档

运行初始化代码

CommandLineRunnerApplicationRunner都可以用来运行初始化代码,CommandLineRunner通过一个字符串数组来访问命令行参数,而ApplicationRunner是通过ApplicationArguments来访问。除此之外,可以配置多个CommandLineRunner或者ApplicationRunner,其优先级和执行顺序遵循一下规则:

  1. ApplicationRunner会优先于CommandLineRunner执行;
  2. 多个同类型的Runner可以通过@Order注解来指定运行顺序,但@Order只有放在class上才生效。

考虑下面的代码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// incorrect code, don't use it
@Bean
@Order(2)
public CommandLineRunner commandLineRunner1() {
return args -> {
log.info("I'm commandline runner 1");
};
}
@Bean
@Order(1)
public CommandLineRunner commandLineRunner2() {
return args -> {
log.info("I'm commandline runner 2");
};
}
@Bean
@Order(10)
public ApplicationRunner applicationRunner3() {
return args -> {
log.info("I'm application runner 3");
};
}

其输出结果为,

1
2
3
m.d.y.YouDontKnowSpringbootApplication   : I'm application runner 3
m.d.y.YouDontKnowSpringbootApplication : I'm commandline runner 1
m.d.y.YouDontKnowSpringbootApplication : I'm commandline runner 2

可以看到applicationRunner3最先被运行,但commandLineRunner1commandLineRunner2并没有按照@Order指定的顺序运行,其中的原理可以参考这里。只有按照下面的方式才能做到有序,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Order(2)
@Component
@Slf4j
class CommandLineRunner1 implements CommandLineRunner {

@Override
public void run(String... args) throws Exception {
log.info("I'm commandline runner 1");
}
}

@Order(1)
@Component
@Slf4j
class CommandLineRunner2 implements CommandLineRunner {

@Override
public void run(String... args) throws Exception {
log.info("I'm commandline runner 2");
}
}

其输出结果为,

1
2
me.danielpf.ydtk.CommandLineRunner2      : I'm commandline runner 2
me.danielpf.ydtk.CommandLineRunner1 : I'm commandline runner 1

上面已经提到过,ApplicationReadyEvent会在所有的Runner运行之后才会触发,所以尽量避免既在Runner中执行过长、过慢的逻辑又要依赖于监听ApplicationReadyEvent,考虑下面的代码,

1
2
3
4
5
6
7
8
9
10
11
@Bean
pulic CommandLineRunner commandLineRunner() {
return args -> {
log.info("start commandline runner...");
TimeUnit.SECONDS.sleep(10);
};

@EentListener(ApplicationReadyEvent.class)
pulic void ready() {
log.info("application ready...");
}
其输出结果为,
1
2
2021-04-21 15:57:32.379  INFO 56531 --- [           main] m.d.y.YouDontKnowSpringbootApplication   : start commandline runner...
2021-04-21 15:57:42.386 INFO 56531 --- [ main] m.d.y.YouDontKnowSpringbootApplication : application ready...
注意

更多详情请参考官方文档

Web Application类型

SpringBoot会根据依赖尝试创建合适的Web Environment,其默认规则可以参考下面的表格,

Starter Application Conext Web Application Type Web Container
spring-boot-starter-web only AnnotationConfigServletWebServer SERVLET Tomcat
spring-boot-starter-webflux only AnnotationConfigReactiveWebServer REACTIVE Netty
spring-boot-starter-web
spring-boot-starter-webflux
AnnotationConfigServletWebServer SERVLET Tomcat
spring-boot-starter AnnotationConfig NONE N/A

需要注意的是,

  1. 如果spring-boot-starter-webspring-boot-starter-webflux混用,那么Web Environment还是会被设置为SERVLET,这种情况是为了兼容在SERVLET应用中使用Reactive API,例如WebClient
  2. 可以通过编程的方式,手动设置WebApplicationType,甚至关闭Web Environment,例如下面的代码是通过flunt-builder的方式关闭Web Environment
1
2
3
4
5
6
public static void main(String[] args) {
new SpringApplicationBuilder()
.sources(YouDontKnowSpringbootApplication.class)
.web(WebApplicationType.NONE)
.run(args);
}

更多详情请参考官方文档

使用JSON配置应用

大多数场景下,我们一般会使用properties文件或者YAML文件,结合命令行参数来配置SpringBoot应用程序,除此之外,我们还可以通过JSON来配置应用程序,我们可以把基于JSON的配置理解成为一个增强版的命令行。我们写在JSON里面的配置,都会被merge进当前的Environment

JSON配置可以通过以下几种方式传递给应用程序,这里借官方文档的几个示例说明,

  1. 通过UN*X shell的环境变量传递:
    1
    $ SPRING_APPLICATION_JSON='{"acme":{"name":"test"}}' java -jar myapp.jar
  2. 通过system property传递:
    1
    $ java -Dspring.application.json='{"acme":{"name":"test"}}' -jar myapp.jar
  3. 通过命令行参数传递:
    1
    $ java -jar myapp.jar --spring.application.json='{"acme":{"name":"test"}}'
  4. 如果应用程序部署在传统的web中间件中,可以通过JNDI传递,变量名称为:
    1
    java:comp/env/spring.application.json

更多详情请参考官方文档

基于Profile的Logback

SpringBoot中使用Logback也能享受到Spring Profile所带来的便利,基于ProfileLogback配置文件为logback-spring.xml,放置在classpath下会被SpringBoot自动加载。
我们可以使用springProfile来控制不同Profile下有那些appender会生效,例如这样,

1
2
3
4
5
6
7
8
9
10
11
<springProfile name="dev">
<appender name="ROLL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
......
</appender>
</springProfile>

<springProfile name="staging | prod">
<appender name="MAILER" class="ch.qos.logback.classic.net.SMTPAppender">
......
</appender>
</springProfile>

也可以使用springProperty来定义一些属性,并在后面的配置引用这些属性,

1
2
3
4
<springProperty scope="context" name="mailSubjectPrefix" source="mail.subject.prefix" defaultValue=""/>
<springProperty scope="context" name="component" source="mail.component" defaultValue="[my-app]"/>
<springProperty name="LOG_PATH" source="logging.path" defaultValue="/var/log/my-app/" />
<springProperty name="LOG_FILE" source="logging.path" defaultValue="main" />

更多详情请参考官方文档

探活

Spring Boot Actuator提供了非常强大的系统监控功能。在K8s等容器环境部署SpringBoot应用时,我们可以可以借助Spring Boot Actuator内置的Health Indicator来实现探活,一般情况下借助/actuator/health/liveness/actuator/health/readiness这两个endpoint,

1
2
3
4
5
6
7
8
9
10
11
12
13
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: <actuator-port>
failureThreshold: ...
periodSeconds: ...

readinessProbe:
httpGet:
path: /actuator/health/readiness
port: <actuator-port>
failureThreshold: ...
periodSeconds: ...

我们还可以实现自己的HealthIndicators

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class MyHealthIndicator implements HealthIndicator {

@Override
public Health health() {
int errorCode = check(); // perform some specific health check
if (errorCode != 0) {
return Health.down().withDetail("Error Code", errorCode).build();
}
return Health.up().build();
}

}

更多详情请参考官方文档

多数据源

在检测到有数据库访问相关的依赖后,SpringBoot会尝试Auto Config数据源,我们只需要设定数据源的各类属性即可。但在某些业务场景下,我们需要多个数据源,我们可以借助SpringBoot内置的一些工具,比较方便地配置多个数据源。下面的代码展示了从配置上彻底分开的两个不同数据源,

@Bean
@Primary
@ConfigurationProperties("app.datasource.first")
public DataSourceProperties firstDataSourceProperties() {
    return new DataSourceProperties();
}

@Bean
@Primary
@ConfigurationProperties("app.datasource.first.configuration")
public HikariDataSource firstDataSource() {
    return firstDataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build();
}

@Bean
@ConfigurationProperties("app.datasource.second")
public DataSourceProperties secondDataSourceProperties() {
    return new DataSourceProperties();
}

@Bean
@ConfigurationProperties("app.datasource.second.configuration")
public BasicDataSource secondDataSource() {
    return secondDataSourceProperties().initializeDataSourceBuilder().type(BasicDataSource.class).build();
}

上述代码的要点如下:

  1. 可以借助DataSourceProperties来绑定特定前缀的数据源属性(这些属性仍为标准属性,只是前缀不同),并通过initializeDataSourceBuilder()type()创建特定类型的数据源;
  2. 可以借助@ConfigurationProperties再次将自定义的属性绑定到已经在上一步创建的数据源对象上;
  3. 一定要通过@Primary来指定默认的数据源。

关于多数据源的动态切换,不在本文做过多讨论,可以参考这篇Blog
更多详情请参考官方文档