SpringBoot开发

我们在前面详细介绍了Spring框架,它的主要功能包括IoC容器、AOP支持、事务支持、MVC开发以及强大的第三方集成功能等。那么SpringBoot又是什么?它和Spring是什么关系?SpringBoot是一个基于Spring的套件,它帮我们预组装了Spring的一系列组件,以便以尽可能少的代码和配置来开发基于Spring的Java应用程序,它们不是取代关系,试图跳过Spring直接学习SpringBoot是不可能的。SpringBoot的目标就是提供一个开箱即用的应用程序架构,我们基于SpringBoot的预置结构继续开发是省时省力的。本章我们详细介绍如何使用SpringBoot。

第一个SpringBoot应用

我们新建一个springboot-hello的工程,创建标准的Maven目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
springboot-hello
├── pom.xml
├── src
│ └── main
│ ├── java
│ └── resources
│ ├── application.yml
│ ├── logback-spring.xml
│ ├── static
│ └── templates
└── target

其中,在src/main/resources目录下,注意到几个文件:

application.yml

这是SpringBoot默认的配置文件,它采用YAML格式而不是.properties格式,因为YAML格式更易读,文件名必须是application.yml而不是其他名称。

YAML是一种层级格式,它和.properties很容易互相转换,它的优点是去掉了大量重复的前缀,更加易读。SpringBoot中也可以使用application.properties作为配置文件。

使用环境变量

在配置文件中,我们经常使用如下格式对某个key进行配置:

1
2
3
4
5
app:
db:
host: ${DB_HOST:localhost}
user: ${DB_USER:root}
password: ${DB_PASSWORD:password}

这种${DB_HOST:localhost}意思是,首先从环境变量查找DB_HOST,如果环境变量定义了,那么使用环境变量的值,否则,使用默认值localhost。类似地,这使得我们在开发和部署时更加方便,因为开发时无需设置任何环境变量,直接使用默认值,而实际线上运行时,只需要传入环境变量即可:

1
$ DB_HOST=10.0.1.123 DB_USER=prod DB_PASSWORD=xxxx java -jar xxx.jar

logback-spring.xml

这是SpringBoot的logback配置文件名称(也可以使用logback.xml),一个标准的写法如下:

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
27
28
29
30
31
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml" />

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>utf8</charset>
</encoder>
</appender>

<appender name="APP_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>utf8</charset>
</encoder>
<file>app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<maxIndex>1</maxIndex>
<fileNamePattern>app.log.%i</fileNamePattern>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<MaxFileSize>1MB</MaxFileSize>
</triggeringPolicy>
</appender>

<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="APP_LOG" />
</root>
</configuration>

它主要通过<include resource="...">引入SpringBoot的缺省配置,这样我们就可以引用类似${CONSOLE_LOG_PATTERN}这样的变量。上述配置定义了一个控制台输出和文件输出,可根据需要修改。

static是静态文件目录,templates是模板文件目录。注意它们不再存放在src/main/webapp下,而是直接放到src/main/resources这个classpath目录下,因为SpringBoot已经不需要专门的webapp目录了。

以上就是SpringBoot的标准目录结构,它完全是一个基于Java应用的普通Java项目。

我们再来看源码目录结构:

1
2
3
4
5
6
7
8
9
10
11
src/main/java
└── com
└── itranswarp
└── learnjava
├── Application.java
├── entity
│ └── User.java
├── service
│ └── UserService.java
└── web
└── UserController.java

在存放源码的src/main/java目录中,SpringBoot对Java包的层级结构有一个要求。注意到我们的根package是com.itranswarp.learnjava,下面还有entityserviceweb等子package。SpringBoot要求main()方法所在的启动类必须放到根package下,命名不做要求,这里我们以Application.java命名,它的内容如下:

1
2
3
4
5
6
@SpringBootApplication
public class Application {
public static void main(String[] args) throws Exception {
SpringApplication.run(Application.class, args);
}
}

启动SpringBoot应用程序只需要一行代码加上一个注解@SpringBootApplication,该注解实际上又包含了:

  • SpringBootConfiguration
    • Configuration
  • EnableAutoConfiguration
    • AutoConfigurationPackage
  • ComponentScan

这样一个注解就相当于启动了自动配置和自动扫描。

我们再观察pom.xml,它的内容如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<project ...>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.0.RELEASE</version>
</parent>

<modelVersion>4.0.0</modelVersion>
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>springboot-hello</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<java.version>11</java.version>
<pebble.version>3.1.2</pebble.version>
</properties>

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

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

<!-- 集成Pebble View -->
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble-spring-boot-starter</artifactId>
<version>${pebble.version}</version>
</dependency>

<!-- JDBC驱动 -->
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
</dependency>
</dependencies>
</project>

使用SpringBoot时,强烈推荐从spring-boot-starter-parent继承,因为这样就可以引入SpringBoot的预置配置。紧接着,我们引入了依赖spring-boot-starter-webspring-boot-starter-jdbc,它们分别引入了Spring MVC和Spring JDBC的相关依赖,无需指定版本号,因为引入的<parent>内已经指定了,只有我们自己引入的某些第三方jar包需要指定版本号。

这里我们引入pebble-spring-boot-starter作为View,以及hsqldb作为嵌入式数据库。hsqldb已在spring-boot-starter-jdbc中预置了版本号2.5.0,因此此处无需指定版本号。

根据pebble-spring-boot-starter文档,加入如下配置到application.yml

1
2
3
4
5
pebble:
# 默认为".pebble",改为"":
suffix:
# 开发阶段禁用模板缓存:
cache: false

Application稍作改动,添加WebMvcConfigurer这个Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootApplication
public class Application {
...

@Bean
WebMvcConfigurer createWebMvcConfigurer(@Autowired HandlerInterceptor[] interceptors) {
return new WebMvcConfigurer() {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 映射路径`/static/`到classpath路径:
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
}
};
}
}

现在就可以直接运行Application,启动后观察Spring Boot的日志。SpringBoot自动启动了嵌入式Tomcat,当看到Started Application in xxx seconds时,Spring Boot应用启动成功。现在,我们在浏览器输入localhost:8080就可以直接访问页面。

那么前面我们定义的数据源、声明式事务、JdbcTemplate在哪创建的?怎么就可以直接注入到自己编写的UserService中呢?这些自动创建的Bean就是SpringBoot的特色:AutoConfiguration。当我们引入spring-boot-starter-web后,启动时会自动扫描所有的XxxAutoConfiguration

  • DataSourceAutoConfiguration:自从创建一个DataSource,其中配置项从application.ymlspring.datasource读取
  • DataSourceTransactionManagerAutoConfiguration:自动创建一个基于JDBC的事务管理器
  • JdbcTemplateAutoConfiguration:自动创建了一个JdbcTemplate

因此,我们自动得到了一个DataSource、一个DataSourceTransactionManager和一个JdbcTemplate

类似地,当我们引入spring-boot-starter-web时,自动创建了:

  • ServletWebServerFactoryAutoConfiguration:自动创建一个嵌入式Web服务器,默认是Tomcat;
  • DispatcherServletAutoConfiguration:自动创建一个DispatcherServlet
  • HttpEncodingAutoConfiguration:自动创建一个CharacterEncodingFilter
  • WebMvcAutoConfiguration:自动创建若干与MVC相关的Bean。

引入第三方pepple-spring-boot-starter时,自动创建了:

  • PebbleAutoConfiguration:自动创建了一个PebbleViewResolver

SpringBoot大量的使用XxxAutoConfiguration来使得许多组件被自动化配置并创建,而这些创建过程又大量的使用了Spring的Conditional的功能。

例如,我们观察JdbcTemplateAutoConfiguration,它的代码如下:

1
2
3
4
5
6
7
8
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ DataSource.class, JdbcTemplate.class })
@ConditionalOnSingleCandidate(DataSource.class)
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
@EnableConfigurationProperties(JdbcProperties.class)
@Import({ JdbcTemplateConfiguration.class, NamedParameterJdbcTemplateConfiguration.class })
public class JdbcTemplateAutoConfiguration {
}

当满足条件:

  • @ConditionalOnClass:在classpath中能找到DataSourceJdbcTemplate
  • @ConditionalOnSingleCandidate(DataSource.class):在当前Bean的定义中能找到唯一的DataSource

JdbcTemplateAutoConfiguration就会起作用,实际创建由导入的JdbcTemplateConfiguration完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(JdbcOperations.class)
class JdbcTemplateConfiguration {
@Bean
@Primary
JdbcTemplate jdbcTemplate(DataSource dataSource, JdbcProperties properties) {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
JdbcProperties.Template template = properties.getTemplate();
jdbcTemplate.setFetchSize(template.getFetchSize());
jdbcTemplate.setMaxRows(template.getMaxRows());
if (template.getQueryTimeout() != null) {
jdbcTemplate.setQueryTimeout((int) template.getQueryTimeout().getSeconds());
}
return jdbcTemplate;
}
}

创建JdbcTemplate之前,要满足@ConditionalOnMissingBean(JdbcOperations.class),即不存在JdbcOperations的Bean。

如果我们自己创建了一个JdbcTemplate:

1
2
3
4
5
6
7
8
@SpringBootApplication
public class Application {
...
@Bean
JdbcTemplate createJdbcTemplate(@Autowired DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}

那么根据条件@ConditionalOnMissingBean(JdbcOperations.class),SpringBoot就不会再创建一个重复的JdbcTemplate(因为JdbcOperationsJdbcTemplate的父类)。

可见,Spring Boot自动装配功能是通过自动扫描+条件装配实现的,这一套机制在默认情况下工作得很好,但是,如果我们要手动控制某个Bean的创建,就需要详细地了解Spring Boot自动创建的原理,很多时候还要跟踪XxxAutoConfiguration,以便设定条件使得某个Bean不会被自动创建。

使用开发者工具

在开发阶段,我们经常需要修改代码,然后重启SpringBoot应用。经常手动停止再启动,比较麻烦。SpingBoot提供了一个开发者工具,可以监控classpath路径上的文件。只要源码或配置文件发生修改,SpringBoot应用就可以自动重启。在开发阶段,这个功能比较有用。

只需要添加如下依赖:

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

直接启动应用程序,然后试着修改源码、保存,观察日志输出,SpringBoot会自动重新加载。默认配置下,针对/static/public/templates目录中的文件修改,不会自动重启,因为禁用缓存后,这些文件的修改可以实时更新。

打包SpringBoot应用

我们在Maven的使用插件一节中介绍了如何使用maven-shade-plugin打包一个可执行的jar包。在SpringBoot应用中,打包更简单,因为SpringBoot自带一个更简单的spring-boot-maven-plugin插件来打包,我们只需要在pom.xml中加入以下配置:

1
2
3
4
5
6
7
8
9
10
11
<project ...>
...
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

无需任何配置,这个插件就可以自动定位应用程序的入口Class,执行以下命令就可以打包:

1
$ mvn clean package

springboot-exec-jar项目为例,打包后在target目录可以看到以下两个jar文件:

1
2
3
4
5
6
7
$ ls
classes
generated-sources
maven-archiver
maven-status
springboot-exec-jar-1.0-SNAPSHOT.jar
springboot-exec-jar-1.0-SNAPSHOT.jar.original

其中,springboot-exec-jar-1.0-SNAPSHOT.jar.original是Maven标准打包插件打的jar包,它只包含我们自己的Class,不包含依赖,而springboot-exec-jar-1.0-SNAPSHOT.jar是Spring Boot打包插件创建的包含依赖的jar,可以直接运行:

1
$ java -jar springboot-exec-jar-1.0-SNAPSHOT.jar

这样,部署一个Spring Boot应用就非常简单,无需预装任何服务器,只需要上传jar包即可。

在打包的时候,因为打包后的Spring Boot应用不会被修改,因此,默认情况下,spring-boot-devtools这个依赖不会被打包进去。但是要注意,使用早期的Spring Boot版本时,需要配置一下才能排除spring-boot-devtools这个依赖:

1
2
3
4
5
6
7
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludeDevtools>true</excludeDevtools>
</configuration>
</plugin>

如果不喜欢默认的项目名+版本号作为文件名,可以加一个配置指定文件名:

1
2
3
4
5
6
7
<project ...>
...
<build>
<finalName>awesome-app</finalName>
...
</build>
</project>

这样打包后的文件名就是awesome-app.jar

瘦身SpringBoot应用

上一节中,我们使用SpringBoot提供的spring-boot-maven-plugin打包SpringBoot应用,可以直接获得一个完整的可运行的jar包,把它上传到服务器上再运行就极其方便。但是,这种方式最大的缺点是包太大了,动不动几十MB,在网速不给力的情况下,上传服务器非常耗时。并且,其中我们引入的Tomcat、Spring和其他第三方组件,只要版本号不变,这些jar就相当于每次都重复打进去,再重复上传了一遍。

真正经常改动的是我们自己编写的代码,如果只打包我们自己的代码,通常jar包也就几百KB。但是,运行的时候,classpath里没有依赖的包,肯定会报错。

那如何只打包我们自己编写的代码,同时又自动把依赖包下载到某处,并自动引入到classpath中?答案就是使用spring-boot-thin-launcher

使用spring-boot-thin-launcher

我们先演示如何使用,再详细介绍工作原理。

首先复制一份上一节的Maven项目,并重命名为spring-thin-jar

1
2
3
4
5
6
<project ...>
...
<groupId>com.itranswarp.learnjava</groupId>
<artifactId>springboot-thin-jar</artifactId>
<version>1.0-SNAPSHOT</version>
...

然后,修改<build>-<plugins>-<plugin>,给原来的spring-boot-maven-plugin增加一个dependency如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<project ...>
...
<build>
<finalName>awesome-app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot.experimental</groupId>
<artifactId>spring-boot-thin-layout</artifactId>
<version>1.0.27.RELEASE</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>

到此为止不再需要其他改动。接下来按照正常流程打包,执行mvn clean package,观察target目录最终生成的可执行awesome-app.jar,只占很小的体积。

直接运行java -jar awesome-app.jar,效果和上节完全一样。

实际上,spring-boot-thin-launcher这个插件改变了spring-boot-maven-plugin的默认行为。它输出的jar包只包含我们自己代码编译后的class、一个很小的ThinJarWrapper,以及解析pom.xml后得到的所有依赖jar的列表。

运行的时候,入口实际上是ThinJarWrapper,它会先在指定目录搜索看看依赖的jar包是否都存在,如果不存在,先从Maven中央仓库下载到本地,然后,再执行我们自己编写的main()入口方法。这种方式有点类似于在线安装程序:用户下载后得到的是一个很小的exe安装程序,执行安装程序时,会首先在先下载所需的若干巨大的文件,再进行安装。

这个spring-boot-thin-launcher在启动时搜索的默认目录是用户主目录的.m2,我们也可以指定下载目录,例如,将下载目录指定为当前目录:

1
$ java -Dthin.root=. -jar awesome-app.jar

上述命令通过环境变量thin.root传入当前目录,执行后发现当前目录下自动生成了一个repository目录,这和Maven的默认下载目录~/.m2/repository的结构是完全一样的,只是它仅包含awesome-app.jar所需的运行期依赖项。

注意:只有首次运行时会自动下载依赖项,再次运行时由于无需下载,所以启动速度会大大加快。如果删除了repository目录,再次运行时就会再次触发下载。

预热

把79KB大小的awesome-app.jar直接扔到服务器执行,上传过程就非常快。但是,第一次在服务器上运行awesome-app.jar时,仍需要从Maven中央仓库下载大量的jar包,所以,spring-boot-thin-launcher还提供了一个dryrun选项,专门用来下载依赖项而不执行实际代码:

1
java -Dthin.dryrun=true -Dthin.root=. -jar awesome-app.jar

执行上述代码会在当前目录创建repository目录,并下载所有依赖项,但并不会运行我们编写的main()方法。此过程称之为“预热”(warm up)。

如果服务器由于安全限制不允许从外网下载文件,那么可以在本地预热,然后把awesome-app.jarrepository目录上传到服务器。只要依赖项没有变化,后续改动只需要上传awesome-app.jar即可。

使用Actuator

在生产环境中,需要对应用程序的状态进行监控。前面我们介绍了使用JMX对程序包括JVM进行监控,我们回忆一下:使用JMX需要把一些监控信息以MBean的形式暴露给JMX Server。而SpringBoot已经内置了一个监控功能,它叫Actuator。

使用Actuator非常简单,只需要添加如下依赖:

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

然后启动应用程序,Actuator就会把它能收集到的所有信息暴露给JMX。此外,Actuator还可以通过URL/actuator/挂载一些监控点,例如,输入http://localhost:8080/actuator/health,我们可以查看应用程序当前状态:

1
2
3
{
"status": "UP"
}

许国网关作为反向代理需要一个URL来探测后端集群应用是否存活,这个URL就可以提供给网关使用。

Actuator默认把所有访问点暴露给JMX,但出于安全原因,只有healthinfo会暴露给Web。Actuator提供的所有访问点均在官方文档列出,要暴露更多的访问点给Web,需要在application.yml中加上配置:

1
2
3
4
5
management:
endpoints:
web:
exposure:
include: info, health, beans, env, metrics

要特别注意暴露URL的安全性,例如,/actuator/env可以获取当前机器的所有环境变量,不可暴露给公网。

使用Profiles

Profile本身是Spring提供的功能,我们在使用条件装配中讲到了,Profile表示一个环境,如开发、测试和生产这三个环境:

  • native
  • test
  • production

或者按git分支定义的master、dev这些环境:

  • master
  • dev

在启动一个应用程序时,可以传入一个或多个环境,例如:

1
-Dspring.profiles.active=test,master

大多数情况下,一个环境就足够了。

SpringBoot对Profiles的支持在于,可以在application.yml中为每个环境进行配置,下面是一个示例配置:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
spring:
application:
name: ${APP_NAME:unnamed}
datasource:
url: jdbc:hsqldb:file:testdb
username: sa
password:
dirver-class-name: org.hsqldb.jdbc.JDBCDriver
hikari:
auto-commit: false
connection-timeout: 3000
validation-timeout: 3000
max-lifetime: 60000
maximum-pool-size: 20
minimum-idle: 1

pebble:
suffix:
cache: false

server:
port: ${APP_PORT:8080}

---

spring:
profiles: test

server:
port: 8000

---

spring:
profiles: production

server:
port: 80

pebble:
cache: true

注意到分隔符---,最前面的配置是默认配置,不需要指定Profile,后面的每段配置都必须以spring.profiles: xxx开头,表示一个Profile。上述配置默认使用8080端口,但在test环境下使用8000端口,在production环境下,使用80端口,并启用Pepple的缓存。

如果我们不指定任何Profile,直接启动应用程序,那么Profile实际上就是default,可以从SpringBoot启动日志看到。

要以test环境启动,可输入以下命令:

1
$ java -Dspring.profiles.active=test -jar springboot-profiles-1.0-SNAPSHOT.jar

可以在日志中看到活动的Profile是test,Tomcat的监听端口是8000。

通过Profile可以实现一套代码在不同环境启用不同的配置和功能。

我们来举个例子。

假设我们需要一个存储服务,在本地开发时,直接使用文件存储即可,但是,在测试和生产环境,需要存储到云端如S3上,如何通过Profile实现该功能?

首先,我们要定义存储接口StorageService

1
2
3
4
5
6
7
8
public interface StorageService {

// 根据URI打开InputStream:
InputStream openInputStream(String uri) throws IOException;

// 根据扩展名+InputStream保存并返回URI:
String store(String extName, InputStream input) throws IOException;
}

本地存储可通过LocalStorageService实现:

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
27
28
29
30
31
32
@Component
@Profile("default")
public class LocalStorageService implements StorageService {
@Value("${storage.local:/var/static}")
String localStorageRootDir;

final Logger logger = LoggerFactory.getLogger(getClass());

private File localStorageRoot;

@PostConstruct
public void init() {
logger.info("Intializing local storage with root dir: {}", this.localStorageRootDir);
this.localStorageRoot = new File(this.localStorageRootDir);
}

@Override
public InputStream openInputStream(String uri) throws IOException {
File targetFile = new File(this.localStorageRoot, uri);
return new BufferedInputStream(new FileInputStream(targetFile));
}

@Override
public String store(String extName, InputStream input) throws IOException {
String fileName = UUID.randomUUID().toString() + "." + extName;
File targetFile = new File(this.localStorageRoot, fileName);
try (OutputStream output = new BufferedOutputStream(new FileOutputStream(targetFile))) {
input.transferTo(output);
}
return fileName;
}
}

而云端存储可通过CloudStorageService实现:

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
27
28
29
30
31
32
@Component
@Profile("!default")
public class CloudStorageService implements StorageService {
@Value("${storage.cloud.bucket:}")
String bucket;

@Value("${storage.cloud.access-key:}")
String accessKey;

@Value("${storage.cloud.access-secret:}")
String accessSecret;

final Logger logger = LoggerFactory.getLogger(getClass());

@PostConstruct
public void init() {
// TODO:
logger.info("Initializing cloud storage...");
}

@Override
public InputStream openInputStream(String uri) throws IOException {
// TODO:
throw new IOException("File not found: " + uri);
}

@Override
public String store(String extName, InputStream input) throws IOException {
// TODO:
throw new IOException("Unable to access cloud storage.");
}
}

注意到LocalStorageService使用了条件装配@Profile("default"),即默认启用LocalStorageService,而CloudStorageService使用了条件装配@Profile("!default"),即非default环境时,自动启用CloudStorageService。这样,一套代码,就实现了不同环境启用不同的配置。

使用Conditional

使用Profile能根据不同的Profile进行条件装配,但是Profile控制比较糙,如果想要精细控制,例如,配置本地存储、AWS存储和阿里云存储,将来很可能会增加Azure存储等,用Profile就很难实现。Spring本身提供了条件装配@Conditional,但是自己要写比较复杂的Condition来做判断,比较麻烦。SpringBoot则为我们提供了几个非常实用的条件:

  • @ConditionalOnProperty:如果有指定的配置,条件生效
  • @ConditionalOnBean:如果有指定的Bean,条件生效
  • @ConditionalOnMissingBean:如果没有指定的Bean,条件生效
  • @ConditionalOnMissingClass:如果没有指定的Class,条件生效
  • @ConditionalOnWebApplication:在Web环境中条件生效
  • @ConditionalOnExpression:根据表达式判断条件是否生效

我们以最常用的@ConditionalOnProperty为例,把上一节的StorageService改写如下。首先,定义配置storage.type=xxx用作判断条件,默认为local

1
2
storage:
type: ${STORAGE_TYPE:local}

设定为local时,启动LocalStorageService

1
2
3
4
5
@Component
@ConditionalOnProperty(value = "storage.type", havingValue = "local", matchIfMissing = true)
public class LocalStorageService implements StorageService {
...
}

设定为aws时,启用AwsStorageService

1
2
3
4
5
@Component
@ConditionalOnProperty(value = "storage.type", havingValue = "aws")
public class AwsStorageService implements StorageService {
...
}

设定为aliyun时,启用AliyunStorageService

1
2
3
4
5
@Component
@ConditionalOnProperty(value = "storage.type", havingValue = "aliyun")
public class AliyunStorageService implements StorageService {
...
}

注意到LocalStorageService的注解,当指定配置为local,或者配置不存在,均启用LocalStorageService。由此可见,SpringBoot提供的条件装配使得应用程序更加具有灵活性。

加载配置文件

加载配置文件可以直接使用注解@Value,例如,我们定义一个最大允许上传的文件大小的配置:

1
2
3
storage:
local:
max-size: 102400

在某个FileUploader里获取该配置:

1
2
3
4
5
6
7
@Component
public class FileUploader {
@Value("${storage.local.max-size:102400}")
int maxSize;

...
}

在另一个UploadFilter中,因为要检查文件的MD5,同时也要检查输入流的大小,因此,也需要该配置:

1
2
3
4
5
6
7
@Component
public class UploadFilter implements Filter {
@Value("${storage.local.max-size:100000}")
int maxSize;

...
}

多次引用同一个@Value不但麻烦,而且@Value使用字符串,缺少编译器检查,容易造成多处引用不一致。

为了更好地管理配置,SpringBoot允许创建一个Bean,持有一组配置,并由SpringBoot自动注入。

假设我们在application.yml中添加了如下配置:

1
2
3
4
5
6
7
8
9
10
storage:
local:
# 文件存储根目录:
root-dir: ${STORAGE_LOCAL_ROOT:/var/storage}
# 最大文件大小,默认100K:
max-size: ${STORAGE_LOCAL_MAX_SIZE:102400}
# 是否允许空文件:
allow-empty: false
# 允许的文件类型:
allow-types: jpg, png, gif

可以首先定义一个JavaBean,持有该组配置:

1
2
3
4
5
6
7
8
9
public class StorageConfiguration {

private String rootDir;
private int maxSize;
private boolean allowEmpty;
private List<String> allowTypes;

// TODO: getters and setters
}

保证JavaBean的属性名称和配置一致即可,然后,我们添加两个注解:

1
2
3
4
5
@Configuration
@ConfigurationProperties("storage.local")
public class StorageConfiguration {
...
}

注意@ConfigurationProperties("storage.local")表示将从配置项storage.local读取该项的所有子项配置,并且,@Configuration表示StorageConfiguration也是一个Spring管理的Bean,可直接注入到其他Bean中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class StorageService {
final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
StorageConfiguration storageConfig;

@PostConstruct
public void init() {
logger.info("Load configuration: root-dir = {}", storageConfig.getRootDir());
logger.info("Load configuration: max-size = {}", storageConfig.getMaxSize());
logger.info("Load configuration: allowed-types = {}", storageConfig.getAllowTypes());
}
}

这样一来,引入storage.local的相关配置就很容易了,因为只需要注入StorageConfiguration这个Bean,这样就可以由编译器检查类型,无需编写重复的@Value注解。

禁用自动配置

SpringBoot大量使用自动配置和默认配置,极大地减少了代码,通常只需要加上几个注解,并按照默认规则设置一下必要的配置即可。例如,配置JDBC,默认情况下,只需要配置一个spring.datasource

1
2
3
4
5
6
spring:
datasource:
url: jdbc:hsqldb:file:testdb
username: sa
password:
dirver-class-name: org.hsqldb.jdbc.JDBCDriver

SpringBoot就会自动创建出DataSourceJdbcTemplateDataSourceTransactionManager,非常方便。

但是,有时候,我们必须要禁用某些自动配置。例如,系统有主从两个数据库,而SpringBoot的自动配置只能配一个,怎么办?这时,针对DataSource相关的自动配置,就必须关掉。我们需要用exclude指定需要关掉的自动配置:

1
2
3
4
5
6
@SpringBootApplication
// 启动自动配置,但排除指定的自动配置:
@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class)
public class Application {
...
}

现在,SpringBoot不再给我们自动创建DataSourceJdbcTemplateDataSourceTransactionManager了,要实现主从数据库支持,怎么办?

首先,我们需要把主从数据库配置写到application.yml中,仍然按照SpringBoot默认的格式写,但datasource改为datasource-masterdatasource-slave

1
2
3
4
5
6
7
8
9
10
11
spring:
datasource-master:
url: jdbc:hsqldb:file:testdb
username: sa
password:
dirver-class-name: org.hsqldb.jdbc.JDBCDriver
datasource-slave:
url: jdbc:hsqldb:file:testdb
username: sa
password:
dirver-class-name: org.hsqldb.jdbc.JDBCDriver

注意到两个数据库实际上是一个库。如果使用MySQL,可以创建一个只读用户,作为datasource-slave的用户来模拟一个从库。下一步,我们分别创建两个HikariCP的DataSource

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
public class MasterDataSourceConfiguration {
@Bean("masterDataSourceProperties")
@ConfigurationProperties("spring.datasource-master")
DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}

@Bean("masterDataSource")
DataSource dataSource(@Autowired @Qualifier("masterDataSourceProperties") DataSourceProperties props) {
return props.initializeDataSourceBuilder().build();
}
}

public class SlaveDataSourceConfiguration {
@Bean("slaveDataSourceProperties")
@ConfigurationProperties("spring.datasource-slave")
DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}

@Bean("slaveDataSource")
DataSource dataSource(@Autowired @Qualifier("slaveDataSourceProperties") DataSourceProperties props) {
return props.initializeDataSourceBuilder().build();
}
}

注意到上述class并未添加@Configuration@Component,要使之生效,可是使用@Import导入:

1
2
3
4
5
6
@SpringBootApplication
@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class)
@Import({ MasterDataSourceConfiguration.class, SlaveDataSourceConfiguration.class})
public class Application {
...
}

此外,上述两个DataSource的Bean名称分别为masterDataSourceslaveDataSource,我们还需要一个最终的@Primary标注的DataSource,它采用Spring提供的AbstractRoutingDataSource,代码实现如下:

1
2
3
4
5
6
7
class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 从ThreadLocal中取出key:
return RoutingDataSourceContext.getDataSourceRoutingKey();
}
}

RoutingDataSource本身并不是真正的DataSource,它通过Map关联一组DataSource,下面的代码创建了包含两个DataSourceRoutingDataSource,关联的key分别为masterDataSourceslaveDataSource

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
public class RoutingDataSourceConfiguration {
@Primary
@Bean
DataSource dataSource(
@Autowired @Qualifier("masterDataSource") DataSource masterDataSource,
@Autowired @Qualifier("slaveDataSource") DataSource slaveDataSource) {
var ds = new RoutingDataSource();
// 关联两个DataSource:
ds.setTargetDataSources(Map.of(
"masterDataSource", masterDataSource,
"slaveDataSource", slaveDataSource));
// 默认使用masterDataSource:
ds.setDefaultTargetDataSource(masterDataSource);
return ds;
}

@Bean
JdbcTemplate jdbcTemplate(@Autowired DataSource dataSource) {
return new JdbcTemplate(dataSource);
}

@Bean
DataSourceTransactionManager dataSourceTransactionManager(@Autowired DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}

仍然需要自己创建JdbcTemplatePlatformTransactionManager,注入的是标记为@PrimaryRoutingDataSource

这样,我们通过如下的代码就可以切换RoutingDataSource底层使用的真正的DataSource

1
2
RoutingDataSourceContext.setDataSourceRoutingKey("slaveDataSource");
jdbcTemplate.query(...);

只不过写代码切换DataSource即麻烦又容易出错,更好的方式是通过注解配合AOP实现自动切换,这样,客户端代码实现如下:

1
2
3
4
5
6
7
8
@Controller
public class UserController {
@RoutingWithSlave // <-- 指示在此方法中使用slave数据库
@GetMapping("/profile")
public ModelAndView profile(HttpSession session) {
...
}
}

实现上述功能需要编写一个@RoutingWithSlave注解,一个AOP织入和一个ThreadLocal来保存key。由于代码比较简单,这里我们不再详述。

如果我们想要确认是否真的切换了DataSource,可以覆写determineTargetDataSource()方法并打印出DataSource的名称:

1
2
3
4
5
6
7
8
9
10
class RoutingDataSource extends AbstractRoutingDataSource {
...

@Override
protected DataSource determineTargetDataSource() {
DataSource ds = super.determineTargetDataSource();
logger.info("determin target datasource: {}", ds);
return ds;
}
}

访问不同的URL,可以在日志中看到两个DataSource,分别是HikariPool-1hikariPool-2

1
2
2020-06-14 17:55:21.676  INFO 91561 --- [nio-8080-exec-7] c.i.learnjava.config.RoutingDataSource   : determin target datasource: HikariDataSource (HikariPool-1)
2020-06-14 17:57:08.992 INFO 91561 --- [io-8080-exec-10] c.i.learnjava.config.RoutingDataSource : determin target datasource: HikariDataSource (HikariPool-2)

我们用一个图来表示创建的DataSource以及相关Bean的关系:

1
2
3
4
5
6
7
8
9
10
┌────────────────────┐       ┌──────────────────┐
│@Primary │<──────│ JdbcTemplate │
│RoutingDataSource │ └──────────────────┘
│ ┌────────────────┐ │ ┌──────────────────┐
│ │MasterDataSource│ │<──────│DataSource │
│ └────────────────┘ │ │TransactionManager│
│ ┌────────────────┐ │ └──────────────────┘
│ │SlaveDataSource │ │
│ └────────────────┘ │
└────────────────────┘

注意到DataSourceTransactionManagerJdbcTemplate引用的都是RoutingDataSource,所以,这种设计的一个限制就是:在一个请求中,一旦切换了内部数据源,在同一个事务中,不能再切到另一个,否则,DataSourceTransactionManagerJdbcTemplate操作的就不是同一个数据库连接。

小结

可以通过@EnableAutoConfiguration(exclude = {...})指定禁用的自动配置;

可以通过@Import({...})导入自定义配置。

添加Filter

我们在Spring中已经学过了集成Filter,本质上就是通过代理,把Spring管理的Bean注册到Servlet容器中,不过步骤比较繁琐,需要配置web.xml

在SpringBoot中添加一个Filter更简单。SpringBoot会自动扫描所有的FilterRegistrationBean类型的Bean,然后,将他们返回的Filter自动注册到Servlet容器中,无需任何配置。

我们还是以AuthFilter为例,首先编写一个AuthFilterRegistrationBean,它继承自FilterRegistrationBean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Order(10)
@Component
public class AuthFilterRegistrationBean extends FilterRegistrationBean<Filter> {
@Autowired
UserService userService;

@Override
public Filter getFilter() {
return new AuthFilter();
}

class AuthFilter implements Filter {
...
}
}

FilterRegistrationBean本身不是Filter,它实际上是Filter的工厂。Spring Boot会调用getFilter(),把返回的Filter注册到Servlet容器中。因为我们可以在FilterRegistrationBean中注入需要的资源,然后,在返回的AuthFilter中,这个内部类可以引用外部类的所有字段,自然也包括注入的UserService,所以,整个过程完全基于Spring的IoC容器完成。

再注意到AuthFilterRegistrationBean标记了一个@Order(10),因为Spring Boot支持给多个Filter排序,数字小的在前面,所以,多个Filter的顺序是可以固定的。

我们再编写一个ApiFilter,专门过滤/api/*这样的URL。首先编写一个ApiFilterRegistrationBean

1
2
3
4
5
6
7
8
9
10
11
12
13
@Order(20)
@Component
public class ApiFilterRegistrationBean extends FilterRegistrationBean<Filter> {
@PostConstruct
public void init() {
setFilter(new ApiFilter());
setUrlPatterns(List.of("/api/*"));
}

class ApiFilter implements Filter {
...
}
}

这个ApiFilterRegistrationBeanAuthFilterRegistrationBean又有所不同。因为我们要过滤URL,而不是针对所有URL生效,因此,在@PostConstruct方法中,通过setFilter()设置一个Filter实例后,再调用setUrlPatterns()传入要过滤的URL列表。

集成第三方组件

和Spring相比,使用SpringBoot通过自动配置即成第三方组件通常来说更简单,这一节我们详细介绍如何通过SpringBoot集成常用的第三方组件。

集成Open API

Open API是一个标准,它的主要作用是描述REST API,既可以作为文档给开发者阅读,又可以让机器根据这个文档自动生成客户端代码。在SpringBoot应用中,假设我们编写了一堆REST API,如何添加Open API的支持?

我们只需要在pom.xml中加入以下依赖:

1
org.springdoc:springdoc-openapi-ui:1.4.0

然后直接启动应用,打开浏览器输入http://localhost:8080/swagger-ui.html,立即可以看到自动生成的API文档,这里列出了3个API,来自api-controller(因为定义在ApiController这个类中),点击某个API还可以交互,即输入API参数,点“try it out”,获得运行结果。

因为我们引入springdoc-openapi-ui这个依赖后,它自动引入Swagger UI用来创建API文档。可以给API加入一些描述信息,例如:

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/api")
public class ApiController {
...
@Operation(summary = "Get specific user object by it's id.")
@GetMapping("/users/{id}")
public User user(@Parameter(description = "id of the user.") @PathVariable("id") long id) {
return userService.getUserById(id);
}
...
}

@Operation可以对API进行描述,@Parameter可以对参数进行描述,它们的目的是用于生成API文档的描述信息。

大多数情况下,不需要任何配置,我们就直接得到了一个运行时动态生成的可交互的API文档,该API文档总是和代码保持同步,大大简化了文档的编写工作。要自定义文档的样式、控制某些API显示等,请参考springdoc文档

配置反向代理

如果在服务器上,用户访问的域名是https://example.com,但内部是通过类似Nginx这样的反向代理访问实际的Spring Boot应用,比如http://localhost:8080,这个时候,在页面https://example.com/swagger-ui.html上,显示的URL仍然是http://localhost:8080,这样一来,就无法直接在页面执行API,非常不方便。

这是因为Spring Boot内置的Tomcat默认获取的服务器名称是localhost,端口是实际监听端口,而不是对外暴露的域名和80443端口。要让Tomcat获取到对外暴露的域名等信息,必须在Nginx配置中传入必要的HTTP Header,常用的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
# Nginx配置
server {
...
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
...
}

然后,在Spring Boot的application.yml中,加入如下配置:

1
2
3
4
5
server:
# 实际监听端口:
port: 8080
# 从反向代理读取相关的HTTP Header:
forward-headers-strategy: native

重启Spring Boot应用,即可在Swagger中显示正确的URL。

访问Redis

在SpringBoot中,要访问Redis,可以直接引入spring-boot-starter-data-redis,它实际上是Spring Data的一个子项目–Spring Data Redis,主要用到了这几个组件:

  • Lettuce:一个基于Netty的高性能Redis客户端
  • RedisTemplate:一个类似于JdbcTemplate的接口,用于简化Redis的操作

因为Spring Data Redis引入的依赖项很多,如果只是为了使用Redis,完全可以只引入Lettuce,剩下的操作由自己来完成。

本节我们稍微深入了解一下Redis的客户端,看怎么一步步把一个第三方组件引入到Spring Boot中。

首先我们添加必要的几个依赖项:

  • io.lettuce:lettuce-core
  • org.apache.commons:commons-pool2

注意我们并未指定版本号,因为在spring-boot-starter-parent中已经把常用组件的版本号确定下来了。第一步是在配置文件application.yml中添加Redis的相关配置:

1
2
3
4
5
6
7
spring:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
ssl: ${REDIS_SSL:false}
database: ${REDIS_DATABASE:0}

然后,通过RedisConfiguration来加载它:

1
2
3
4
5
6
7
8
9
@ConfigurationProperties("spring.redis")
public class RedisConfiguration {
private String host;
private int port;
private String password;
private int database;

// getters and setters...
}

再编写一个@Bean方法来创建RedisClient,可以直接放在RedisConfiguration中:

1
2
3
4
5
6
7
8
9
10
11
12
13
@ConfigurationProperties("spring.redis")
public class RedisConfiguration {
...

@Bean
RedisClient redisClient() {
RedisURI uri = RedisURI.Builder.redis(this.host, this.port)
.withPassword(this.password)
.withDatabase(this.database)
.build();
return RedisClient.create(uri);
}
}

在启动入口引入该配置:

1
2
3
4
5
@SpringBootApplication
@Import(RedisConfiguration.class) // 加载Redis配置
public class Application {
...
}

注意:如果在RedisConfiguration上标注@Configuration,则可通过SpringBoot的自动扫描机制自动加载,否则,使用@Import手动加载。紧接着,我们用一个RedisService来封装所有Redis操作,基础代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class RedisService {
@Autowired
RedisClient redisClient;

GenericObjectPool<StatefulRedisConnection<String, String>> redisConnectionPool;

@PostConstruct
public void init() {
GenericObjectPoolConfig<StatefulRedisConnection<String, String>> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(20);
poolConfig.setMaxIdle(5);
poolConfig.setTestOnReturn(true);
poolConfig.setTestWhileIdle(true);
this.redisConnectionPool = ConnectionPoolSupport.createGenericObjectPool(() -> redisClient.connect(), poolConfig);
}

@PreDestroy
public void shutdown() {
this.redisConnectionPool.close();
this.redisClient.shutdown();
}
}

上述代码引入Common Pool的一个对象池,用于缓存Redis连接。因为Lettuce本身是基于Netty的异步驱动,在异步访问时并不需要创建连接池,但基于Servlet模型的同步访问时,连接池是必要的。连接池在@PostConstruct方法中初始化,在@PreDestroy方法中关闭。

然后,在RedisService中添加Redis访问方法。为了简化代码,我们仿照JdbcTemplate.execute(ConnectionCallback)方法,传入回调函数,可大幅减少样板代码。首先,定义回调函数接口SyncCommandCallback

1
2
3
4
5
@FunctionalInterface
public interface SyncCommandCallback<T> {
// 在此操作Redis:
T doInConnection(RedisCommands<String, String> commands);
}

编写executeSync方法,在该方法中,获取Redis连接,利用callback操作Redis,最后释放连接,并返回操作结果:

1
2
3
4
5
6
7
8
9
10
public <T> T executeSync(SyncCommandCallback<T> callback) {
try (StatefulRedisConnection<String, String> connection = redisConnectionPool.borrowObject()) {
connection.setAutoFlushCommands(true);
RedisCommands<String, String> commands = connection.sync();
return callback.doInConnection(commands);
} catch (Exception e) {
logger.warn("executeSync redis failed.", e);
throw new RuntimeException(e);
}
}

有的童鞋觉得这样访问Redis的代码太复杂了,实际上我们可以针对常用操作把它封装一下,例如setget命令:

1
2
3
4
5
6
7
public String set(String key, String value) {
return executeSync(commands -> commands.set(key, value));
}

public String get(String key) {
return executeSync(commands -> commands.get(key));
}

类似的,hgethset操作如下:

1
2
3
4
5
6
7
8
9
10
11
public boolean hset(String key, String field, String value) {
return executeSync(commands -> commands.hset(key, field, value));
}

public String hget(String key, String field) {
return executeSync(commands -> commands.hget(key, field));
}

public Map<String, String> hgetall(String key) {
return executeSync(commands -> commands.hgetall(key));
}

常用命令可以提供方法接口,如果要执行任意复杂的操作,就可以通过executeSync(SyncCommandCallback<T>)来完成。

完成了RedisService后,就可以使用Redis了。例如,,在UserController中,我们在Session中只存放登录用户的ID,用户信息存放到Redis,提供两个方法用于读写:

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
@Controller
public class UserController {
public static final String KEY_USER_ID = "__userid__";
public static final String KEY_USERS = "__users__";

@Autowired ObjectMapper objectMapper;
@Autowired RedisService redisService;

// 把User写入Redis:
private void putUserIntoRedis(User user) throws Exception {
redisService.hset(KEY_USERS, user.getId().toString(), objectMapper.writeValueAsString(user));
}

// 从Redis读取User:
private User getUserFromRedis(HttpSession session) throws Exception {
Long id = (Long) session.getAttribute(KEY_USER_ID);
if (id != null) {
String s = redisService.hget(KEY_USERS, id.toString());
if (s != null) {
return objectMapper.readValue(s, User.class);
}
}
return null;
}
...
}

用户登录成功后,把ID放入Session,把User实例放入Redis:

1
2
3
4
5
6
7
8
9
10
11
@PostMapping("/signin")
public ModelAndView doSignin(@RequestParam("email") String email, @RequestParam("password") String password, HttpSession session) throws Exception {
try {
User user = userService.signin(email, password);
session.setAttribute(KEY_USER_ID, user.getId());
putUserIntoRedis(user);
} catch (RuntimeException e) {
return new ModelAndView("signin.html", Map.of("email", email, "error", "Signin failed"));
}
return new ModelAndView("redirect:/profile");
}

需要获取User时,从Redis取出:

1
2
3
4
5
6
7
8
@GetMapping("/profile")
public ModelAndView profile(HttpSession session) throws Exception {
User user = getUserFromRedis(session);
if (user == null) {
return new ModelAndView("redirect:/signin");
}
return new ModelAndView("profile.html", Map.of("user", user));
}

在Redis读写Java对象时,序列化和反序列化是应用程序的工作,上述代码使用JSON作为序列化方案,简单可靠。也可将相关序列化操作封装到RedisService中,这样可以提供更通用的方法:

1
2
3
4
5
6
7
public <T> T get(String key, Class<T> clazz) {
...
}

public <T> T set(String key, T value) {
...
}

SpringBoot默认使用Lettuce作为Redis客户端,同步使用时,应通过连接池提高效率。

集成Artemis

ArctiveMQ Artemis是一个JMS服务器,在集成JMS一节中我们已经详细讨论了如何在Spring中集成Artemis,本节我们介绍在SpringBoot中集成Artemis。

还是以实际工程为例,创建一个springboot-jms工程,引入的依赖除了spring-boot-starter-webspring-boot-starter-jdbc等以外,新增spring-boot-starter-artemis,同样无需指定版本号:

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

创建Artemis服务后,我们在application.yml中加入相关配置:

1
2
3
4
5
6
7
8
9
10
spring:
artemis:
# 指定连接外部Artemis服务器,而不是启动嵌入式服务:
mode: native
# 服务器地址和端口号:
host: 127.0.0.1
port: 61616
# 连接用户名和口令由创建Artemis服务器时指定:
user: admin
password: password

和Spring版本的JMS代码相比,在SpringBoot中集成JMS时,只要引入了spring-boot-starter-artemis,SpringBoot会自动创建JMS相关的ConnectionFactoryJmsListenerContainerFactoryJmsTemplate等,无需我们再手动配置了。

发送消息时只需要引入JmsTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class MessagingService {
@Autowired
JmsTemplate jmsTemplate;

public void sendMailMessage() throws Exception {
String text = "...";
jmsTemplate.send("jms/queue/mail", new MessageCreator() {
public Message createMessage(Session session) throws JMSException {
return session.createTextMessage(text);
}
});
}
}

接收消息只需要标注@JmsListener

1
2
3
4
5
6
7
8
9
@Component
public class MailMessageListener {
final Logger logger = LoggerFactory.getLogger(getClass());

@JmsListener(destination = "jms/queue/mail", concurrency = "10")
public void onMailMessageReceived(Message message) throws Exception {
logger.info("received message: " + message);
}
}

可见,应用程序收发消息的逻辑和Spring中使用JMS使用完全相同,只是通过SpringBoot,我们把工程简化到只需要设定Artemis相关配置。

集成RabbitMQ

前面我们讲了ArctiveMQ Artemis,它实现了JMS的消息服务协议。JMS是JavaEE的消息服务标准接口,但是,如果Java程序要和另一种语言编写的程序通过消息服务器进行通信,那么JMS就不太适合了。

AMQP是一种使用广泛的独立与语言的消息协议,它的全称是Advanced Message Queuing Protocol,即高级消息队列协议,它定义了一种二进制格式的消息流,任何编程语言都可以实现该协议。实际上,Artemis也支持AMQP,但实际应用最广泛的AMQP服务器是使用Erlang编写的RabbitMQ。

安装RabbitMQ

首先从官网下载并安装RabbitMQ,安装和启动RabbitMQ请参考官方设计文档。要验证启动是否成功,可以访问RabbitMQ的管理后台http://localhost:15672,如看到登录页面就表示RabbitMQ安装成功。管理后台的默认用户名和口令都是guest

AMQP协议

AMQP协议和前面介绍过的JMS协议有所不同。我们回顾一下,在JMS中有两种类型的消息通道:

  1. 点对点的Queue,即Producer发送消息到指定的Queue,接收方从Queue收取消息
  2. 一对多的Topic,即Producer发送消息到指定的Topic,任意多个在线的接收方均可从Topic获得一份完整的消息副本

而AMQP协议比JMS要复杂一点,它只有Queue,没有Topic,并且引入了Exchange。当Producer想要发送消息时,它将消息发送给Exchange,由Exchange将消息根据某些规则投递到一个或多个Queue。

如果某个Exchange总是把消息发送到固定的Queue,那么这个消息通道就相当于JMS的Queue。如果某个Exchange把消息发送到多个Queue,那么这个消息通道就相当于JMS中的Topic。和JMS的Topic相比,Exchange的投递规则更灵活,比如一个“登录成功”的消息被投放到Queue-1和Queue-2,而“登录失败”则被投递到Queue-3。这些路由规则称之为Binding,通常都在RabbitMQ的管理后台设置。

我们以实际应用场景为例。

在RabbitMQ,首先创建3个Queue,分别用于发送邮件、短信和App通知。创建Queue时注意到可配置为持久化(Durable)和非持久化(Transient),当Consumer不在线时,持久化的Queue会暂存消息,非持久化的Queue会丢弃消息。

紧接着,我们还Exchanges中创建一个Direct类型的Exchange,命名为registration,并添加两个Binding,其规则是:凡是发送到registration这个Exchange的消息,均被发送到q_mailq_sms这样个Queue。

我们再创建一个Direct类型的Exchange,命名为login,并添加Binding,其规则是:当消息发送给login这个Exchange时,如果消息没有指定Routing Key,则被投递到q_appq_mail,如果消息指定了Routing Key=”login_failed”,那么消息被投递到q_sms

配合好RabbitMQ后,我们就可以基于SpringBoot开发AMQP应用程序。

使用RabbitMQ

我们首先创建Spring Boot工程springboot-rabbitmq,并添加如下依赖引入RabbitMQ:

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

然后在application.yml中添加RabbitMQ相关配置:

1
2
3
4
5
6
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest

我们还需要在Application中添加一个MessageConverter

1
2
3
4
5
6
7
8
9
10
11
import org.springframework.amqp.support.converter.MessageConverter;

@SpringBootApplication
public class Application {
...

@Bean
MessageConverter createMessageConverter() {
return new Jackson2JsonMessageConverter();
}
}

MessageConverter用于将Java对象转换为RabbitMQ的消息。默认情况下,SpringBoot使用SimpleMessageConverter,只能发送Stringbyte[]的消息,不太方便。使用Jackson2JsonMessageConverter,我们就可以发送JavaBean对象,由SpringBoot自动序列化为JSON并以文本消息传递。

因为引入了starter,所有RabbitMQ相关的Bean均自动装配,我们需要在Producer注入的是RabbitTemplate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class MessagingService {
@Autowired
RabbitTemplate rabbitTemplate;

public void sendRegistrationMessage(RegistrationMessage msg) {
rabbitTemplate.convertAndSend("registration", "", msg);
}

public void sendLoginMessage(LoginMessage msg) {
String routingKey = msg.success ? "" : "login_failed";
rabbitTemplate.convertAndSend("login", routingKey, msg);
}
}

发送消息时,使用convertAndSend(exchange, routingKey, message)可以指定Exchange、Routing Key以及消息本身。这里传入JavaBean后会自动序列化为JSON文本。上述代码将RegistrationMessage发送到registration,将LoginMessage发送到login,并根据登录是否成功来指定Routing Key。

接收消息时,需要在消息处理的方法上标注@RabbitListener

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
27
28
29
30
31
32
33
@Component
public class QueueMessageListener {
final Logger logger = LoggerFactory.getLogger(getClass());

static final String QUEUE_MAIL = "q_mail";
static final String QUEUE_SMS = "q_sms";
static final String QUEUE_APP = "q_app";

@RabbitListener(queues = QUEUE_MAIL)
public void onRegistrationMessageFromMailQueue(RegistrationMessage message) throws Exception {
logger.info("queue {} received registration message: {}", QUEUE_MAIL, message);
}

@RabbitListener(queues = QUEUE_SMS)
public void onRegistrationMessageFromSmsQueue(RegistrationMessage message) throws Exception {
logger.info("queue {} received registration message: {}", QUEUE_SMS, message);
}

@RabbitListener(queues = QUEUE_MAIL)
public void onLoginMessageFromMailQueue(LoginMessage message) throws Exception {
logger.info("queue {} received message: {}", QUEUE_MAIL, message);
}

@RabbitListener(queues = QUEUE_SMS)
public void onLoginMessageFromSmsQueue(LoginMessage message) throws Exception {
logger.info("queue {} received message: {}", QUEUE_SMS, message);
}

@RabbitListener(queues = QUEUE_APP)
public void onLoginMessageFromAppQueue(LoginMessage message) throws Exception {
logger.info("queue {} received message: {}", QUEUE_APP, message);
}
}

上述代码一共定义了5个Consumer,监听3个Queue。

启动应用程序,我们注册一个新用户,然后发送一条RegistrationMessage消息。此时,根据registration这个Exchange的设定,我们会在两个Queue收到消息;当我们登录失败时,发送LoginMessage并设定Routing Key为login_failed,此时,只有q_sms会收到消息:登录成功后,发送LoginMessage,此时,q_mailq_app将收到消息。

RabbitMQ还提供了使用Topic的Exchange(此Topic指消息的标签,并非JMS的Topic的概念),可以使用*进行匹配路由。可见,掌握RabbitMQ的核心是理解其消息的路由规则。

直接指定一个Queue并投递消息也是可以的,此时指定Routing Key为Queue的名称即可,因为RabbitMQ提供了一个default exchange用于根据Routing Key查找Queue并直接投递消息到指定的Queue。但是要实现一对多的投递就必须自己配置Exchange。

Spring Boot提供了AMQP的集成,默认使用RabbitMQ作为AMQP消息服务器。

使用RabbitMQ发送消息时,理解Exchange如何路由至一个或多个Queue至关重要。

集成Kafka

我们在前面已经介绍了JMS和AMQP,JMS的JavaEE的标准消息接口,Artemis是一个JMS的实现产品,同样的AMQP是一个标准消息接口,RabbitMQ是一个AMQP实现产品。Kafka也是一个消息服务器,它的特点一是快,二是有巨大的吞吐量,那么Kafka实现了什么标准消息接口呢?

Kafka没有实现任何标准的消息接口,它自己提供的API就是Kafka的接口。

Kafka本身是Scala编写的,运行在JVM之上。Producer和Consumer都通过Kafka的客户端使用网络来与之通信。从逻辑上讲,Kafka的设计非常简单,它只有一种类似于JMS的Topic的消息通道:

1
2
3
4
5
6
7
8
9
                              ┌──────────┐
┌──>│Consumer-1│
│ └──────────┘
┌────────┐ ┌─────┐ │ ┌──────────┐
│Producer│─────>│Topic│───┼──>│Consumer-2│
└────────┘ └─────┘ │ └──────────┘
│ ┌──────────┐
└──>│Consumer-3│
└──────────┘

那么Kafka是如何支持十万甚至百万的并发呢?答案是分区。Kafka的一个Topic可以有一个至多个Partition,并且可以分布到多台机器上:

1
2
3
4
5
6
7
8
9
10
11
12
13
            ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Topic
│ │
┌───────────┐ ┌──────────┐
│┌─>│Partition-1│──┐│┌──>│Consumer-1│
│ └───────────┘ │ │ └──────────┘
┌────────┐ ││ ┌───────────┐ │││ ┌──────────┐
│Producer│───┼─>│Partition-2│──┼─┼──>│Consumer-2│
└────────┘ ││ └───────────┘ │││ └──────────┘
│ ┌───────────┐ │ │ ┌──────────┐
│└─>│Partition-3│──┘│└──>│Consumer-3│
└───────────┘ └──────────┘
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

Kafka只保证在一个Partition内部,消息是有序的,但是,存在多个Partition的情况下,Producer发送的3个消息会依次发送到Partition-1、Partition-2和Partition-3,Consumer从三个Partition接收的消息并不一定是Producer发送消息的顺序。因此,多个Partition只能保证接收消息大概率是按发送时间有序,并不能完全保证按Producer的发送顺序。这一点在使用Kafka作为消息服务器时要特别注意,对发送顺序有严格要求的Topic只能有一个Partition。

Kafka的另一个特点是消息发送和接收都尽量使用批处理,一次处理几十甚至上百条消息,比一次一条效率高很多。

另外,Kafka总是将消息写入Partition对应的文件,消息保存多久取决于服务器的配置,可以按照时间删除,也可以按照文件大小删除,因此,只要Consumer在离线期内还没有被删除,再次上线仍然可以接收完整的信息流。这一功能是客户端自己实现的,客户端会存储它接收到的最后一个消息的offsetId,再次上线后按上次的offsetId查询。offsetId是Kafka标识某个Partion的每一条消息的递增整数,客户端通常将它存储在ZooKeeper中。

有了Kafka消息设计的基本概念,我们来看看如何在SpringBoot中使用Kafka。

安装Kafka

首先从Kafka官网下载最新版Kafaka,解压后在bin目录找到两个文件:

  • zookeeper-server-start.sh:启动ZooKeeper(已内置在Kafka中);
  • kafka-server-start.sh:启动Kafka。

先启动ZooKeeper:

1
$ ./zookeeper-server-start.sh ../config/zookeeper.properties 

再启动Kafka:

1
./kafka-server-start.sh ../config/server.properties

看到如下输出表示启动成功:

1
... INFO [KafkaServer id=0] started (kafka.server.KafkaServer)

如果要关闭Kafka和ZooKeeper,依次按Ctrl-C退出即可。注意这是在本地开发时使用Kafka的方式,线上Kafka服务推荐使用云服务厂商托管模式(AWS的MSK,阿里云的消息队列Kafka版)。

使用Kafka

在SpringBoot中使用Kafka,首先引入依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>

注意这个依赖是spring-kafka项目提供的。

然后在application.yml中添加Kafka配置:

1
2
3
4
5
6
7
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
auto-offset-reset: latest
max-poll-records: 100
max-partition-fetch-bytes: 1000000

除了bootstrap-servers必须指定外,consumer相关的配置项均为调优选项。例如,max-poll-records表示一次最多抓取100条消息。配置名称去哪里看?IDE里定义一个KafkaProperties.Consumer的变量:

1
KafkaProperties.Consumer c = null;

然后按住Ctrl查看源码即可。

发送消息

Spring Boot自动为我们创建一个KafkaTemplate用于发送消息。注意到这是一个泛型类,而默认配置总是使用String作为Kafka消息的类型,所以注入KafkaTemplate<String, String>即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class MessagingService {
@Autowired ObjectMapper objectMapper;

@Autowired KafkaTemplate<String, String> kafkaTemplate;

public void sendRegistrationMessage(RegistrationMessage msg) throws IOException {
send("topic_registration", msg);
}

public void sendLoginMessage(LoginMessage msg) throws IOException {
send("topic_login", msg);
}

private void send(String topic, Object msg) throws IOException {
ProducerRecord<String, String> pr = new ProducerRecord<>(topic, objectMapper.writeValueAsString(msg));
pr.headers().add("type", msg.getClass().getName().getBytes(StandardCharsets.UTF_8));
kafkaTemplate.send(pr);
}
}

发送消息时,需指定Topic名称,消息正文。为了发送一个JavaBean,这里我们没有使用MessageConverter来转换JavaBean,而是直接把消息类型作为Header添加到消息中,Header名称为type,值为Class全名。消息正文是序列化的JSON。

接收消息

接收消息可以使用@KafkaListener注解:

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
27
28
29
30
31
32
33
34
35
@Component
public class TopicMessageListener {
private final Logger logger = LoggerFactory.getLogger(getClass());

@Autowired
ObjectMapper objectMapper;

@KafkaListener(topics = "topic_registration", groupId = "group1")
public void onRegistrationMessage(@Payload String message, @Header("type") String type) throws Exception {
RegistrationMessage msg = objectMapper.readValue(message, getType(type));
logger.info("received registration message: {}", msg);
}

@KafkaListener(topics = "topic_login", groupId = "group1")
public void onLoginMessage(@Payload String message, @Header("type") String type) throws Exception {
LoginMessage msg = objectMapper.readValue(message, getType(type));
logger.info("received login message: {}", msg);
}

@KafkaListener(topics = "topic_login", groupId = "group2")
public void processLoginMessage(@Payload String message, @Header("type") String type) throws Exception {
LoginMessage msg = objectMapper.readValue(message, getType(type));
logger.info("process login message: {}", msg);
}

@SuppressWarnings("unchecked")
private static <T> Class<T> getType(String type) {
// TODO: use cache:
try {
return (Class<T>) Class.forName(type);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
}

在接收消息的方法中,使用@Payload表示传入的是消息正文,使用@Header可传入消息的指定Header,这里传入@Header("type"),就是我们发送消息时指定的Class全名。接收消息时,我们需要根据Class全名来反序列化获得JavaBean。

上述代码一共定义了3个Listener,其中有两个方法监听的是同一个Topic,但它们的Group ID不同。假设Producer发送的消息流是A、B、C、D,Group ID不同表示这是两个不同的Consumer,它们将分别收取完整的消息流,即各自均收到A、B、C、D。Group ID相同的多个Consumer实际上被视作一个Consumer,即如果有两个Group ID相同的Consumer,那么它们各自收到的很可能是A、C和B、D。

因为Group ID不同,同一个消息被两个Consumer分别独立接收。如果把Group ID改为相同,那么同一个消息只会被两者之一接收。所以配置Consumer时,指定Group ID非常重要。

有细心的童鞋可能会问,在Kafka中是如何创建Topic的?又如何指定某个Topic的分区数量?

实际上开发使用的Kafka默认允许自动创建Topic,创建Topic时默认的分区数量是2,可以通过server.properties修改默认分区数量。

在生产环境中通常会关闭自动创建功能,Topic需要由运维人员先创建好。和RabbitMQ相比,Kafka并不提供网页版管理后台,管理Topic需要使用命令行,比较繁琐,只有云服务商通常会提供更友好的管理后台。

如果只剩三个小时就要出现丧尸,你会干什么 集成第三方组件
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×