我们在前面详细介绍了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
,下面还有entity
、service
、web
等子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
EnableAutoConfiguration
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 > <dependency > <groupId > io.pebbletemplates</groupId > <artifactId > pebble-spring-boot-starter</artifactId > <version > ${pebble.version}</version > </dependency > <dependency > <groupId > org.hsqldb</groupId > <artifactId > hsqldb</artifactId > </dependency > </dependencies > </project >
使用SpringBoot时,强烈推荐从spring-boot-starter-parent
继承,因为这样就可以引入SpringBoot的预置配置。紧接着,我们引入了依赖spring-boot-starter-web
和spring-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: 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) { 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.yml
的spring.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中能找到DataSource
和JdbcTemplate
@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
(因为JdbcOperations
是JdbcTemplate
的父类)。
可见,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,执行以下命令就可以打包:
以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.jar
和repository
目录上传到服务器。只要依赖项没有变化,后续改动只需要上传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
,我们可以查看应用程序当前状态:
许国网关作为反向代理需要一个URL来探测后端集群应用是否存活,这个URL就可以提供给网关使用。
Actuator默认把所有访问点暴露给JMX,但出于安全原因,只有health
和info
会暴露给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表示一个环境,如开发、测试和生产这三个环境:
或者按git分支定义的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 () { logger.info("Initializing cloud storage..." ); } @Override public InputStream openInputStream (String uri) throws IOException { throw new IOException("File not found: " + uri); } @Override public String store (String extName, InputStream input) throws IOException { 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} 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; }
保证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就会自动创建出DataSource
、JdbcTemplate
、DataSourceTransactionManager
,非常方便。
但是,有时候,我们必须要禁用某些自动配置。例如,系统有主从两个数据库,而SpringBoot的自动配置只能配一个,怎么办?这时,针对DataSource
相关的自动配置,就必须关掉。我们需要用exclude
指定需要关掉的自动配置:
1 2 3 4 5 6 @SpringBootApplication @EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class) public class Application { ... }
现在,SpringBoot不再给我们自动创建DataSource
、JdbcTemplate
和DataSourceTransactionManager
了,要实现主从数据库支持,怎么办?
首先,我们需要把主从数据库配置写到application.yml
中,仍然按照SpringBoot默认的格式写,但datasource改为datasource-master
和datasource-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名称分别为masterDataSource
和slaveDataSource
,我们还需要一个最终的@Primary
标注的DataSource
,它采用Spring提供的AbstractRoutingDataSource
,代码实现如下:
1 2 3 4 5 6 7 class RoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey () { return RoutingDataSourceContext.getDataSourceRoutingKey(); } }
RoutingDataSource
本身并不是真正的DataSource
,它通过Map关联一组DataSource
,下面的代码创建了包含两个DataSource
的RoutingDataSource
,关联的key分别为masterDataSource
和slaveDataSource
:
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(); ds.setTargetDataSources(Map.of( "masterDataSource" , masterDataSource, "slaveDataSource" , slaveDataSource)); 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); } }
仍然需要自己创建JdbcTemplate
和PlatformTransactionManager
,注入的是标记为@Primary
的RoutingDataSource
。
这样,我们通过如下的代码就可以切换RoutingDataSource
底层使用的真正的DataSource
:
1 2 RoutingDataSourceContext.setDataSourceRoutingKey("slaveDataSource" ); jdbcTemplate.query(...);
只不过写代码切换DataSource即麻烦又容易出错,更好的方式是通过注解配合AOP实现自动切换,这样,客户端代码实现如下:
1 2 3 4 5 6 7 8 @Controller public class UserController { @RoutingWithSlave @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-1
和hikariPool-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 │ │ │ └────────────────┘ │ └────────────────────┘
注意到DataSourceTransactionManager
和JdbcTemplate
引用的都是RoutingDataSource
,所以,这种设计的一个限制就是:在一个请求中,一旦切换了内部数据源,在同一个事务中,不能再切到另一个,否则,DataSourceTransactionManager
和JdbcTemplate
操作的就不是同一个数据库连接。
小结 可以通过@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 { ... } }
这个ApiFilterRegistrationBean
和AuthFilterRegistrationBean
又有所不同。因为我们要过滤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
,端口是实际监听端口,而不是对外暴露的域名和80
或443
端口。要让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 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; }
再编写一个@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) 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 > { 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的代码太复杂了,实际上我们可以针对常用操作把它封装一下,例如set
和get
命令:
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)); }
类似的,hget
和hset
操作如下:
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; private void putUserIntoRedis (User user) throws Exception { redisService.hset(KEY_USERS, user.getId().toString(), objectMapper.writeValueAsString(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 //TODO