Spring Boot

Spring Boot 不是一门新技术,所以不用紧张。从本质上来说,Spring Boot 就是 Spring,它做了那些没有它你也会去做的 Spring 配置。它使用“约定优于配置”(内置了一个习惯性的配置,让你无需手动进行配置)的理念让你的项目快速运行起来。使用 Spring Boot 很容易创建一个独立运行(运行 jar,内嵌 Servlet 容器)、准生产级别的基于 Spring 框架的项目,使用 Spring Boot 你可以不用或者只需要很少的 Spring 配置。

SpringBoot 浅析

SpringBoot 之所以会诞生,是因为 Spring 框架的配置越来越复杂了,不适合“快速开发”,就拿 spring mvc 项目来说,至少需要两个 xml 配置文件:

  • web.xml:servlet 容器的配置文件(拦截所有请求到 spring mvc servlet,以及配置一个 UTF-8 字符编码过滤器)
  • mvc.xml:springmvc 的配置文件(负责将请求交给 Controller 处理,处理之后再交给 ViewResolver 去渲染页面)

当然,从 spring 3 开始,也提供了完全基于 javaconfig 的配置方式,即:

  • web.xml -> WebConfig.java:从 xml 形式变为了 javaconfig 形式
  • mvc.xml -> MvcConfig.java:从 xml 形式变为了 javaconfig 形式

但是配置的复杂度依旧没有降低,只是 javaconfig 可以避免 xmlconfig 的“非类型安全”缺点而已啦(IDEA 无所畏惧,xmlconfig 也能很好的提示)。

而现在又流行什么“微服务、云计算”,虽然我对这些也不是太懂,但是也大概了解了一下,我们先来说说“微服务”,微服务其实就是将一个复杂的大项目拆分为多个小的项目(每个小项目称为一个“服务”),不同组件之间使用 restful-api 或 rpc 进行交互,这样每个组件都可以使用不同的技术栈来开发,比如组件 A 可以使用 java 来开发,组件 B 可以使用 python 来开发,组件 C 可以使用 golang 来开发,因为这些都没有任何影响,各个模块之间的交互都是通过 restful-api 来进行的,它屏蔽了底层的开发语言以及开发框架的差异。

微服务的概念
微服务是一种软件架构风格,一个大型复杂的软件系统应该由一个或多个微服务组成。系统中的各个微服务可被独立部署,各个微服务之间是松耦合的。每个微服务仅关注于完成一件任务并很好地完成该任务,不同微服务之间通常使用语言无关、平台无关的 API 进行通信,如 HTTP API(RESTful API)。在所有情况下,每个任务代表着一个小的业务能力。微服务的概念源于 2014 年 3 月 Martin Fowler 所写的一篇文章 Microservices。尽管“微服务”这种架构风格没有精确的定义,但其具有一些共同的特性,如围绕业务能力组织服务、自动化部署、智能端点、对语言及数据的“去集中化”控制等等。

微服务的由来
微服务最早由 Martin Fowler 与 James Lewis 于 2014 年共同提出,微服务架构风格是一种使用一套小服务来开发单个应用的方式途径,每个服务运行在自己的进程中,并使用轻量级机制通信,通常是 HTTP API,这些服务基于业务能力构建,并能够通过自动化部署机制来独立部署,这些服务使用不同的编程语言实现,以及不同数据存储技术,并保持最低限度的集中式管理。

为什么需要微服务
在传统的 IT 行业,软件大多都是各种独立系统的堆砌,这些系统的问题总结来说就是扩展性差,可靠性不高,维护成本高。到后面引入了 SOA 服务化,但是,由于 SOA 早期均使用了总线模式,这种总线模式是与某种技术栈强绑定的,比如:J2EE。这导致很多企业的遗留系统很难对接,切换时间太长,成本太高,新系统稳定性的收敛也需要一些时间。最终 SOA 看起来很美,但却成为了企业级奢侈品,中小公司都望而生畏。

而微服务又有点“快速开发”的意思,说到快速开发,传统的 spring mvc 程序的痛点就出来了,一大堆繁琐的 xmlconfig/javaconfig 配置,光是搭建一个基本的开发环境就得半天,因为有很多模板式的配置文件,总是复制粘贴,然后修改,效率太低了,不能让我们开发人员专注于写业务代码(必须先专注于写配置文件这些东西)。

于是,spring boot 诞生了,spring boot 不同于 spring framework 的其它模块,实际上 spring boot 并没有引入什么新的功能,springboot 的使命是为了实现“spring应用程序的快速开发”,所以不用害怕,spring boot 只是一颗大的语法糖,帮助你快速搭建一个 spring 应用程序开发环境,而业务代码什么的还是你自己写,没有什么变化,变化的仅仅是 spring 框架的一系列配置(简化了许多)。

那么 springboot 是如何简化 spring 框架的繁琐复杂的配置的呢?核心就是“自动配置”,自动配置本质上还是 spring 4 引入的 条件化创建 bean 功能,所以 spring boot 其实也没有提供什么“自动配置魔法”,只不过将 spring 4 的条件化创建 bean 运用的炉火纯青罢了,所以其实也没什么神奇的。

当然,spring boot 除了简化 spring 框架的初始化配置之外,还有另一个贡献,那就是 starter,所谓 starter 就是一组依赖,springboot 将日常开发的各种框架进行了分类汇总,比如 web starter 就表示 web 开发需要的依赖项目,我们只需要引入这个 web starter 依赖,就可以一键获取到与 web 开发相关的那些依赖项目,如 spring-webmvc、validator、slf4j、logback 等,这样我们就不需要一个一个的配置需要的依赖了,非常方便。使用 starter 依赖组还有另一个好处,那就是我们不需要在关心不同依赖之间的版本冲突问题了,因为一组依赖里面的依赖项的版本都是经过 spring boot 实际测试出来的,是最佳组合,因此也不建议手动更改依赖组里面的依赖项版本号,否则可能引发传递依赖的版本冲突问题,这纯属是自找麻烦。

另外,springboot 还提供了一个令人欣喜的功能:直接将传统的 webapp 项目打包为一个可执行的 jar 包,这样使用 maven 打包之后,就可以直接使用 java -jar my-web-project.jar 来运行我们的应用,而不需要单独的 servlet 容器,简直不要太爽了。当然 springboot 也允许将我们的项目打包为 war 包,然后将这个 war 包放到传统的 servlet 容器中使用,一般在开发环境中,打包为 jar 包比较合适,因为方便嘛;而在生产环境中,打包为 war 可能比较合适,当然这些都不重要,看个人的喜好。默认情况下,springboot 使用 tomcat 作为嵌入式 servlet 容器,你也可以将其替换为 jetty。其实仔细想想,将 webapp 打包为一个可执行 jar 也没什么稀奇的,因为 tomcat 实际上也就是一个普通的 java 项目,而 tomcat 也有对应的嵌入式版本,所谓嵌入式版本就是直接在 java 程序中启动的 tomcat,而不是作为一个单独的服务运行的 tomcat 进程。

自动配置、条件化创建 bean
springboot 的核心是“自动配置”,而自动配置的本质是“条件化创建bean”。在 spring 4 之前,xmlconfig/javaconfig 中的 <bean>@Bean 都是无条件创建的,只要你配置了 bean 元素、@Bean 方法,那么对应的 bean 就会被创建。这显然有点原始了,很多时候,为了完成稍微复杂一些的配置任务,都需要使用条件化创建 bean 功能,好在 spring 4 之后,提供了相关的注解以及接口:

  • @Conditional:条件化创建注解,可以标注 @Configuration 配置类以及 @Configuration 配置类中的 @Bean 方法,该注解有一个 value 属性,用于指定进行条件判断的实现类(Condition 接口的实现类,可以指定多个),只有所有的 Condition 对象的 matches() 方法都返回 true 才表示条件成立,才会去执行当前 @Configuration 或 @Configuration 中的 @Bean 方法。
  • Condition:条件判断的接口,只有一个方法,即 matches(),它是一个函数式接口,如果 matches() 方法返回 true,则表示当前条件成立,如果 matches() 方法返回 false,则表示当前条件不成立。

Spring 4 提供了很多 Condition 接口实现,方便我们开发“条件化创建bean”的配置类(条件化配置),如:

  • @ConditionalOnBean:如果 context 中存在指定 bean,则条件成立
  • @ConditionalOnMissingBean:如果 context 中不存在指定 bean,则条件成立
  • @ConditionalOnClass:如果 classpath 中存在指定 Class,则条件成立
  • @ConditionalOnMissingClass:如果 classpath 中不存在指定 Class,则条件成立
  • @ConditionalOnExpression:如果给出的 SpEL 表达式结果值为 true,则条件成立

借助这些内置 Condition 条件判断类 + 自定义实现的条件判断类,就可以非常方便的实现:Spring 框架的条件化、自动化配置了。这其实就是 Spring Boot 自动配置的原理所在。

传统的 SSM 项目结构

  • pom.xml(每个依赖都要自己导入)
  • web.xml/WebConfig.java
  • mvc.xml/MvcConfig.java
  • Employee.java
  • EmployeeMapper.java
  • EmployeeMapper.xml
  • EmployeeService.java
  • EmployeeServiceImpl.java
  • EmployeeController.java
  • employee-list.jsp
  • employee-edit.jsp

SpringBoot 版本的 SSM

  • pom.xml(直接导入 starter 依赖组)
  • Application.java(配置类&启动类)
  • Employee.java
  • EmployeeMapper.java
  • EmployeeMapper.xml
  • EmployeeService.java
  • EmployeeServiceImpl.java
  • EmployeeController.java
  • employee-list.ftl
  • employee-edit.ftl

可以发现,变化的就是“配置部分”,换为 springboot 后,pom.xml 简化了,WebConfig.java 和 MvcConfig.java 不需要了,取而代之的是 Application.java 配置类&启动类。另外,SpringBoot 不建议使用 JSP 视图解析器,因为打包为 jar 包时,运行是有问题的,即使打包为 war 包也可能会出现不可预知的问题,而且 JSP 不支持 SpringBoot 的很多特性,所以建议使用 FreeMarker、Thymeleaf 这些专业的 Java 模板引擎。这里我就选用 FreeMarker,FreeMarker 的模板文件后缀名通常为 .ftl,但是这个变化其实是无关紧要的,因为使用什么模板引擎其实没什么关系,很多公司的项目也是使用的 FreeMarker 这些模板引擎,而不是 JSP。

pom.xml 的变化这里就先不说了,后面会详细讲解;我们来看看变化最明显的 WebConfig&MvcConfig => Application。

WebConfig(web.xml 的 javaconfig 版本)

MvcConfig(mvc.xml 的 javaconfig 版本)

Application(MvcConfig 的简化版本)

因为 WebConfig 的任务非常简单,简单的说,就是两个东西:

  • 注册 springmvc dispatcher servlet,拦截所有请求到 springmvc 处理;
  • 注册 charset filter,将 request 和 response 的字符编码规范为 UTF-8。

基本上每个 SpringMVC 程序都是这样配置的,不怎么变化,也不需要怎么自定义配置,所以 SpringBoot 干脆将这些配置“内置化“,即默认就配置了这些东西,我们无需关心。所以,Application 和 MvcConfig 是相同的作用,都是 Spring 的配置文件。

可以发现,Application 中我们什么都没有配置,只是在上面加了一个 @SpringBootApplication 注解,就完成了 MvcConfig 里面的全部配置(甚至是更多配置,因为 SpringBoot 的核心功能 - “自动配置”)。那么这里面究竟是什么黑魔法呢?为什么只需要一个 SpringBootApplication 注解,然后一个普通的 main 启动方法就可以直接运行传统的 spring mvc 应用程序呢?

先冷静一下,我们知道,注解和 XML 都是提供元数据的一种方式,如果你没有相应的代码去处理这些 注解/XML 元数据,那么这些 注解/XML 实际上不会有任何作用,就相当于没有使用这个注解一样。OK,理解到这一点之后,我们再来分析一下 SpringBoot 程序启动的原理和流程。

首先映入眼帘的就是 Application 启动类中的 main() 方法:

当我们运行这个 main() 方法的时候,执行的就是 SpringApplicationrun() 静态方法,run() 方法的第一个参数是当前启动类的 Class 对象,第二个参数是 main() 方法接收到的命令行参数(字符串数组,args)。

那么我们先来看看这个 run() 方法内部做了什么操作(仅列举核心操作):

  1. 如果我们调用的是静态 run() 方法,则该方法内部会首先创建 SpringApplication 实例,再调用该实例的 run() 方法。
  2. 根据当前 classpath 是否存在 servlet 相关的类,决定创建 Web 类型的 ApplicationContext 还是普通的 ApplicationContext。
  3. 最核心的一步,将之前通过 @EnableAutoConfiguration 获取的所有 @Configuration 配置加载到已经准备完毕的 ApplicationContext。

这其中最重要的还是第三步,不过这个 @EnableAutoConfiguration 是什么注解?哪来的?别急,我们来看看另一个重要的东西:@SpringBootApplication,这个注解其实是一个组合注解,声明如下:

前面的 4 个 jdk 内置的元注解就不说明了,以下省略;所以核心的注解是这 3 个:

先来看第一个注解,@SpringBootConfiguration,其实就是 @Configuration 而已:

所以又可以简化为:

OK,现在我们来解析一下这些注解,@Configuration 注解大家都比较熟悉,这是 Spring 3 引入的注解,等价于一个 spring.xml 配置文件,也就是说,被 @Configuration 标注的类都是一个 Spring 配置类(等价于一个 spring xml 配置文件),而 @ComponentScan 注解也是 Spring 3 引入的注解,等价于 spring 配置文件中的 <context:component-scan/> 元素,作用就是扫描指定 package(含子包)中的 @Component@Controller@Service@Repository 注解的 Bean 类,然后将它们注册到 bean 容器中,并处理这些 Bean 类上面的 Spring 注解,如 @Autowired。注意,对于 @ComponentScan 注解,如果不指定 basePackage,那么默认就是当前所在的 package(含子包),所以一个很好的实践是,将 Application 启动类&配置类放到我们的顶级 package 下面,比如 com.zfl9 包下面,这样就可以不需要指定 basePackage 属性,“约定优于配置”。

那么最核心的注解其实就是 @EnableAutoConfiguration,前面两个注解都是 Spring 3 中的注解,没什么可讲的。不知道大家有没有发现,Spring 的 @EnableXxx 注解都是 @Import(PartConfigurationClass.class) 注解来实现的,所以看到 @Enable* 就要将其看作为 @Import(Xxx.class) 注解,那么 @Import 注解的作用又是什么呢?还记得我们的 xml-based 配置文件么?如果 spring 配置文件很大,配置的内容很多,通常我们都会采取“分模块”的方法来拆分一个大的 spring 配置文件,然后在主配置文件上使用 <import resource="classpath:part-config.xml"/> 来引入子配置文件到当前配置文件中(合并为一个配置文件),而 @Import 的作用和 <import/> 标签的作用是一样的,都是用来导入其它的子配置文件上的配置内容,只不过 <import/> 导入的是 xml-based configuration,而 @Import 导入的是 java-based configuration。

那么我们来看看 @EnableAutoConfiguration 注解的声明是什么:

看吧,就是 @Import(AutoConfigurationImportSelector.class) 的封装注解而已,不过它上面还有一个 @AutoConfigurationPackage 注解,我们来看看这个 @AutoConfigurationPackage 注解是什么东西:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(AutoConfigurationPackages.Registrar.class)
public @interface AutoConfigurationPackage {}

所以这还是一个 @Import 封装注解,那么组合起来,@EnableAutoConfiguration 其实就是这两个 @Import 的组合注解:

@Import(AutoConfigurationPackages.Registrar.class)
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {
    ...
}

所以,下面两个用法是完全一样的,没有区别(只是 AutoConfigurationPackages.Registrar 不是 public 访问权限的,所以会报错):

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
@Configuration
@ComponentScan
@Import(AutoConfigurationImportSelector.class)
@Import(AutoConfigurationPackages.Registrar.class)
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

AutoConfigurationPackages.Registrar 的 javadoc 说明是(作用暂时不清楚,尝试去除它启动会报错):

// 告诉 SpringBoot,被 `@AutoConfigurationPackage` 标注的类所在的包应该使用 AutoConfigurationPackages 注册。
Indicates that the package containing the annotated class should be registered with AutoConfigurationPackages.

先不管这个,我们来看看进一步精简版的 @SpringBootApplication 注解:

@Configuration
@ComponentScan
@Import(AutoConfigurationImportSelector.class)
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

OK,代码已经很清楚了,Spring Boot 自动配置的关键所在就是 AutoConfigurationImportSelector 这个 sub-configuration 配置类。

而这个类内部就会自动查找 classpath:/META-INF/spring.factories 资源文件(会查找所有的同名资源文件,然后将它们合并为一个),spring.factories 有点类似于 JDK1.6 的 SPI 机制,spring.factories 文件其实就是普通的 java properties 属性文件,这个文件中,有一个 key 为 org.springframework.boot.autoconfigure.EnableutoConfiguration 的配置项,它指定的其实就是所有的 @Configuration Spring 配置类(也就是所谓的自动配置类,因为它们上面有很多 @Conditional 条件配置注解),Spring Boot 内部会使用 SpringFactoriesLoader 工具类读取所有的 spring.factories 文件中的 org.springframework.boot.autoconfigure.EnableutoConfiguration 指定的所有 @Configuration 配置类,然后通过反射 API,实例化它们,将它们合并到当前 @SpringBootApplication 标注的 @Configuration 配置类中(汇总为一个,就相当于一个根 xmlconfig 配置文件中使用 <import> 标签引入所有的外部的 sub xmlconfig 配置文件一样),收集完毕之后,就像往常一样,初始化 Spring Context 容器,运行即可。所以这样看来,Spring Boot 真的没什么神秘的。

SpringBoot Hello

SpringBoot 建议我们使用 maven 或 gradle 项目构建和管理工具,因为我自己对 maven 比较熟悉,所以这里就以 maven 作为例子讲解。为了方便开发人员迅速搭建 SpringBoot 开发环境,SpringBoot 提供了一个非常实用的工具网站,Spring Initializr,在这上面我们可以选择使用 maven 还是 gradle、java 版本、springboot 版本、以及需要使用的 springboot starter 模块,选好之后,我们就可以直接点击 Generate Project 按钮,下载 maven/gradle 项目压缩包,然后使用喜欢的 IDE 打开,编写我们的业务类即可,简直不要太爽。

流行的 Java IDE:IntelliJ IDEA 也提供了对 SpringBoot 应用程序的创建支持,所以我们更多的是直接在 IDE 中创建 SpringBoot 项目,本质上,IDEA 还是调用的 https://start.spring.io 的 API 来完成的,所以它们本质上没有区别,只是直接在 IDEA 中创建显然更方便一些,不需要打开浏览器一个一个选,然后再下载到本地,最后还要使用 IDE 导入 maven/gradle 项目。

因为本人使用的也是 IDEA,所以直接就在 IDEA 中创建 springboot 项目(创建方法很简单,和创建普通的 maven 项目差不多,自己一看就会,这里就不详细说明了)。

项目结构

$ tree
.
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── zfl9
    │   │           └── HelloWorld.java
    │   └── resources
    │       ├── application.properties
    │       ├── static
    │       └── templates
    └── test
        └── java

src/main/java 目录是我们的源码目录,然后 src/main/resources 目录是我们的资源目录,这个目录下默认有 3 个文件:

  • application.properties:springboot 的配置文件,当然也可以在里面存放我们的自定义属性,除了 properties 格式外,springboot 还支持使用 yaml(*.yml)格式,但是这里依旧使用大家比较熟悉的 properties 配置文件格式,如果要使用 yaml 文件格式,可以自己去了解一下,貌似可读性确实好一些,但是我个人不太喜欢这种“缩进”风格的语言、格式,比如 Python,感觉有点恶心。
  • static:静态文件的存放目录,如 index.html、css 和 js 等静态文件也都是放到这个目录下。
  • templates:模板文件的存放目录,如 employee.ftl(freemarker 模板文件)。

因为我们编写的是一个简单的 RESTful 应用,所以就没有编写静态网页文件或模板文件,直接返回一个 "Hello, World!" 字符串。application.properties 也是空的,保持默认。所以我们只需要关心两个文件:pom.xml 和 HelloWorld.java。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.zfl9.springboot</groupId>
  <artifactId>springboot-helloworld</artifactId>
  <version>1.0-SNAPSHOT</version>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.2.RELEASE</version>
  </parent>

  <properties>
    <java.version>1.8</java.version>
  </properties>

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

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

HelloWorld.java

package com.zfl9;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication
public class HelloWorld {
    public static void main(String[] args) {
        SpringApplication.run(HelloWorld.class, args);
    }

    @GetMapping("/")
    public String helloWorld() {
        return "Hello, World!";
    }
}

先来分析 pom.xml,第一个地方就是 <parent> 标签,这里我们引入了一个父级 pom:spring-boot-starter-parent,每个 springboot 项目通常都会引入这个父 pom,这个父 pom 中定义了与 springboot 相关的各种 starter 依赖组,以及相关的 maven 插件,以及各种 properties(依赖版本),基本上,这些 dependency 和 plugin 都是放在 dependencyManagement、pluginManagement 标签中的,这样就不会带来各种不必要的依赖,我们想要使用哪些依赖、插件就自己在项目的 pom.xml 中引入即可(只需提供 groupId、artifactId,不需要给出 version 等信息),而对于依赖项目的版本,也可以在我们的 pom.xml 中通过定义同名的 properties 属性来覆盖父级 pom 中的版本属性,但是不推荐这么做,可能会带来版本冲突风险。

当然,如果当前项目已经有了一个父级 pom,那就不能直接定义 springboot 的 parent pom 了,因为 maven 和 java 一样,都是单继承模型的,不允许多继承。这时候也是有相应的解决办法的,不去动当前项目的已有的父级 pom,而是在我们的项目中,定义 dependencyManagement 标签,在里面定义 type=pom、scope=import 类型的 dependency,这个 dependency 就是 springboot 提供的 spring-boot-dependencies,这个项目里面定义的全是 springboot 的 starter 依赖组,然后我们仍然像上面那样在 dependencies 标签中定义 dependency 依赖项(不需要提供 version 等信息),具体如下(不需要改动我们自己的父级 pom.xml):

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>2.1.2.RELEASE</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

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

第二个就是:<java.version>,注意这个属性会被 springboot 使用,而不是被 maven 使用,指定 maven 使用的 jdk 版本不是用的这个属性,而是

<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>

第三个就是:spring-boot-starter-web starter 依赖组(web),这里面就包含了 spring-webmvc 等基础模块,不需要自己去手动引入众多依赖项。

spring boot starter 命名规范

  • spring-boot-starter:核心 starter,包含自动配置支持、日志框架支持、yaml 配置文件解析库等。
  • spring-boot-starter-web:Web 相关的 starter(使用 Tomcat 作为默认嵌入式 servlet 容器)。
  • spring-boot-starter-aop:AOP 相关的 starter(面向切面编程,Spring AOP and AspectJ)。
  • spring-boot-starter-test:测试相关的库,比如:JUnit、Hamcrest、Mockito
  • spring-boot-starter-jdbc:spring-jdbc 支持、HikariCP 连接池。
  • spring-boot-starter-mail:java mail 支持、spring framework email 支持。
  • spring-boot-starter-validation:Validation with Hibernate Validator。
  • spring-boot-starter-thymeleaf:Thymeleaf 模板引擎支持。
  • spring-boot-starter-freemarker:FreeMarker 模板引擎支持。

官方 starter 的命名为:spring-boot-starter-{name},第三方 starter 的命名为:{name}-spring-boot-starter

第四个就是:spring-boot-maven-plugin plugin 插件,该插件的作用是让 springboot 支持将 webapp 打包为可执行的 jar 包,所以通常建议加上。

接下来就是我们的 SpringBoot 启动类&配置类,相关的注解就不一一解释了,上面说的很明白,而 @RestController 则是之前学习 SpringMVC 时候使用的 RESTful 相关的注解,也不详细解释,整个 HelloWorld 项目结构很简单,清晰易懂;OK,现在我们直接右键 main() 方法,执行即可,然后你就会看到 SpringBoot 的启动信息(并且还会打印一个 ASCII 徽标,以及相关的彩色日志,非常炫酷):

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.2.RELEASE)

2019-02-15 20:30:16.039  INFO 4872 --- [           main] com.zfl9.HelloWorld                      : Starting HelloWorld on Otokaze-Win10 with PID 4872 (C:\Users\Otokaze\IdeaProjects\springboot\01-springboot-helloworld\target\classes started by Otokaze in C:\Users\Otokaze\IdeaProjects\springboot)
2019-02-15 20:30:16.039  INFO 4872 --- [           main] com.zfl9.HelloWorld                      : No active profile set, falling back to default profiles: default
2019-02-15 20:30:16.930  INFO 4872 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2019-02-15 20:30:16.945  INFO 4872 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2019-02-15 20:30:16.945  INFO 4872 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.14]
2019-02-15 20:30:16.945  INFO 4872 --- [           main] o.a.catalina.core.AprLifecycleListener   : An older version [1.2.18] of the APR based Apache Tomcat Native library is installed, while Tomcat recommends a minimum version of [1.2.19]
2019-02-15 20:30:16.945  INFO 4872 --- [           main] o.a.catalina.core.AprLifecycleListener   : Loaded APR based Apache Tomcat Native library [1.2.18] using APR version [1.6.5].
2019-02-15 20:30:16.961  INFO 4872 --- [           main] o.a.catalina.core.AprLifecycleListener   : APR capabilities: IPv6 [true], sendfile [true], accept filters [false], random [true].
2019-02-15 20:30:16.961  INFO 4872 --- [           main] o.a.catalina.core.AprLifecycleListener   : APR/OpenSSL configuration: useAprConnector [false], useOpenSSL [true]
2019-02-15 20:30:16.961  INFO 4872 --- [           main] o.a.catalina.core.AprLifecycleListener   : OpenSSL successfully initialized [OpenSSL 1.1.1  11 Sep 2018]
2019-02-15 20:30:17.023  INFO 4872 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-02-15 20:30:17.023  INFO 4872 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 922 ms
2019-02-15 20:30:17.164  INFO 4872 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-02-15 20:30:17.305  INFO 4872 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2019-02-15 20:30:17.305  INFO 4872 --- [           main] com.zfl9.HelloWorld                      : Started HelloWorld in 1.516 seconds (JVM running for 1.963)
2019-02-15 20:30:32.069  INFO 4872 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2019-02-15 20:30:32.070  INFO 4872 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2019-02-15 20:30:32.078  INFO 4872 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 8 ms

其中几个比较重要的信息:

  • SpringBoot 的版本
  • 当前的 JVM 进程 ID
  • 当前激活的 profile
  • Tomcat 监听的端口号
  • SpringBoot 启动时间

现在,打开浏览器或其它客户端,访问 http://localhost:8080 就可以看到 “Hello, World!” 信息了。

启动类&配置类
这个类是我们的启动类,也是我们的配置类,不要把它看得有多么神秘,其实就是一个普通的 @Configuration 配置类而已,我们可以在里面配置自己的 @Bean 方法,注册相应的 bean 到 spring 容器中。来看一个简单的例子:

package com.zfl9;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication
public class HelloWorld {
    public static void main(String[] args) {
        SpringApplication.run(HelloWorld.class, args);
    }

    @Bean
    public Object object() {
        System.out.println("HelloWorld#object() -- return new Object()");
        return new Object();
    }

    @GetMapping("/")
    public String helloWorld() {
        return "Hello, World!";
    }
}

运行结果:

2019-02-16 08:28:46.236  INFO 5260 --- [           main] com.zfl9.HelloWorld                      : Starting HelloWorld on Otokaze-Win10 with PID 5260 (C:\Users\Otokaze\IdeaProjects\springboot\01-springboot-helloworld\target\classes started by Otokaze in C:\Users\Otokaze\IdeaProjects\springboot)
2019-02-16 08:28:46.236  INFO 5260 --- [           main] com.zfl9.HelloWorld                      : No active profile set, falling back to default profiles: default
2019-02-16 08:28:47.122  INFO 5260 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2019-02-16 08:28:47.137  INFO 5260 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2019-02-16 08:28:47.137  INFO 5260 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.14]
2019-02-16 08:28:47.137  INFO 5260 --- [           main] o.a.catalina.core.AprLifecycleListener   : An older version [1.2.18] of the APR based Apache Tomcat Native library is installed, while Tomcat recommends a minimum version of [1.2.19]
2019-02-16 08:28:47.137  INFO 5260 --- [           main] o.a.catalina.core.AprLifecycleListener   : Loaded APR based Apache Tomcat Native library [1.2.18] using APR version [1.6.5].
2019-02-16 08:28:47.137  INFO 5260 --- [           main] o.a.catalina.core.AprLifecycleListener   : APR capabilities: IPv6 [true], sendfile [true], accept filters [false], random [true].
2019-02-16 08:28:47.137  INFO 5260 --- [           main] o.a.catalina.core.AprLifecycleListener   : APR/OpenSSL configuration: useAprConnector [false], useOpenSSL [true]
2019-02-16 08:28:47.153  INFO 5260 --- [           main] o.a.catalina.core.AprLifecycleListener   : OpenSSL successfully initialized [OpenSSL 1.1.1  11 Sep 2018]
2019-02-16 08:28:47.200  INFO 5260 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-02-16 08:28:47.200  INFO 5260 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 901 ms
HelloWorld#object() -- return new Object()
2019-02-16 08:28:47.356  INFO 5260 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-02-16 08:28:47.481  INFO 5260 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2019-02-16 08:28:47.481  INFO 5260 --- [           main] com.zfl9.HelloWorld                      : Started HelloWorld in 1.542 seconds (JVM running for 2.258)

SpringApplication.run() 是非阻塞的
注意哦,对于 web 应用,我们运行 main() 方法之后,是不会立即退出的,而是一直占用着前台(一直运行),这时候你可能就会理所当然的认为,肯定是 SpringApplication.run() 方法阻塞了,但其实不是这样的,该方法内部实际上会创建一个新的线程去执行(嵌入式 tomcat 服务器),所以它并不是一个阻塞调用,在 Spring 容器初始化完毕之后,它就会创建一个新的线程在后台运行 Tomcat 嵌入式服务器,然后就返回了,不信的话可以看下面这个例子,在运行 main() 方法之后,你可以看到 SpringApplication.run() 方法后的代码很快就被执行了:

package com.zfl9;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication
public class HelloWorld {
    public static void main(String[] args) {
        ApplicationContext context = SpringApplication.run(HelloWorld.class, args);
        Object bean = context.getBean("object", Object.class);
        System.out.println(bean);
    }

    @Bean
    public Object object() {
        return new Object();
    }

    @GetMapping("/")
    public String helloWorld() {
        return "Hello, World!";
    }
}

如果是普通应用(非 web),则没有后台进程
首先,我们将 pom.xml 中的 spring-boot-starter-web(web 模块)改为 spring-boot-starter(核心模块),然后去除启动类中的 Web 相关注解和方法,运行 main() 方法,就可以发现,执行完之后就会很快退出,不会一直运行,因为没有 Tomcat 嵌入式服务器在运行。

SpringBoot Runner

这里指的 Runner 是 SpringBoot 的 CommandLineRunnerApplicationRunner,这两个都是“函数式接口”,声明如下:

@FunctionalInterface
public interface CommandLineRunner {
    void run(String... args) throws Exception;
}
@FunctionalInterface
public interface ApplicationRunner {
    void run(ApplicationArguments args) throws Exception;
}

Spring 容器启动后,会搜寻 context 中,所有实现了 CommandLineRunner、ApplicationRunner 的 bean 对象,然后将它们排序(@Order 注解,该注解有一个 value 属性,类型为 int,表示“优先级”,值越小优先级越高,所以 Integer.MIN_VALUE 的优先级最高,Integer.MAX_VALUE 的优先级最低),最后依次调用这些对象的 run() 方法,其中传递的参数就是 main() 启动方法接收到的命令行参数,对于 CommandLineRunner,就是直接传递 main() 方法的 String[] args 字符串数组过去,对于 ApplicationRunner,会先将这个字符串数组包装一下,再传递给它们的 run() 方法。

ApplicationArguments 是 String[] args 原始命令行参数的包装对象,提供了对命令行参数的解析方法,如果你的 Runner 需要解析命令行参数,建议实现 ApplicationRunner。这些 Runner 会在容器启动后立即执行,所以我们可以借助这些 Runner 接口来做一些应用初始化操作,具体就不演示了。

SpringBoot 日志配置

SpringBoot 默认使用 slf4j + logback 日志记录框架,而且通过上面的 helloworld 程序大家也有了一个基本的印象,springboot 的日志格式非常整齐和漂亮,也非常易读。默认情况下,springboot 启用的日志级别为 INFO,所以低于 INFO 级别的日志消息不会显示在控制台,即 TRACE、DEBUG 级别的日志消息不会显示出来,而 INFO、WARN、ERROR 级别则会打印到控制台;我们来通过一个简单的例子验证一下:

package com.zfl9;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication
public class HelloWorld {
    private static final Logger logger = LoggerFactory.getLogger(HelloWorld.class);

    public static void main(String[] args) {
        SpringApplication.run(HelloWorld.class, args);
        logger.trace("trace message");
        logger.debug("debug message");
        logger.info("info message");
        logger.warn("warn message");
        logger.error("error message");
    }

    @GetMapping("/")
    public String helloWorld() {
        return "Hello, World!";
    }
}

运行结果:

2019-02-16 13:57:32.042  INFO 5656 --- [           main] com.zfl9.HelloWorld                      : Starting HelloWorld on Otokaze-Win10 with PID 5656 (C:\Users\Otokaze\IdeaProjects\springboot\01-springboot-helloworld\target\classes started by Otokaze in C:\Users\Otokaze\IdeaProjects\springboot)
2019-02-16 13:57:32.042  INFO 5656 --- [           main] com.zfl9.HelloWorld                      : No active profile set, falling back to default profiles: default
2019-02-16 13:57:32.924  INFO 5656 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2019-02-16 13:57:32.939  INFO 5656 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2019-02-16 13:57:32.939  INFO 5656 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.14]
2019-02-16 13:57:32.939  INFO 5656 --- [           main] o.a.catalina.core.AprLifecycleListener   : An older version [1.2.18] of the APR based Apache Tomcat Native library is installed, while Tomcat recommends a minimum version of [1.2.19]
2019-02-16 13:57:32.939  INFO 5656 --- [           main] o.a.catalina.core.AprLifecycleListener   : Loaded APR based Apache Tomcat Native library [1.2.18] using APR version [1.6.5].
2019-02-16 13:57:32.939  INFO 5656 --- [           main] o.a.catalina.core.AprLifecycleListener   : APR capabilities: IPv6 [true], sendfile [true], accept filters [false], random [true].
2019-02-16 13:57:32.939  INFO 5656 --- [           main] o.a.catalina.core.AprLifecycleListener   : APR/OpenSSL configuration: useAprConnector [false], useOpenSSL [true]
2019-02-16 13:57:32.939  INFO 5656 --- [           main] o.a.catalina.core.AprLifecycleListener   : OpenSSL successfully initialized [OpenSSL 1.1.1  11 Sep 2018]
2019-02-16 13:57:33.002  INFO 5656 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-02-16 13:57:33.002  INFO 5656 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 898 ms
2019-02-16 13:57:33.158  INFO 5656 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-02-16 13:57:33.283  INFO 5656 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2019-02-16 13:57:33.283  INFO 5656 --- [           main] com.zfl9.HelloWorld                      : Started HelloWorld in 1.475 seconds (JVM running for 1.908)
2019-02-16 13:57:33.283  INFO 5656 --- [           main] com.zfl9.HelloWorld                      : info message
2019-02-16 13:57:33.283  WARN 5656 --- [           main] com.zfl9.HelloWorld                      : warn message
2019-02-16 13:57:33.283 ERROR 5656 --- [           main] com.zfl9.HelloWorld                      : error message

那么如何更改这个默认的日志级别呢?或者是如何更改指定 package 的 logging level?很简单,在 application.properties 配置文件中配置一行:

logging.level.com.zfl9 = trace

语法 logging.level.[logger-name] = <level>,如果是为根 logger 设置日志级别,则直接写 logging.level.root = debug。运行结果:

2019-02-16 14:04:56.201  INFO 4244 --- [           main] com.zfl9.HelloWorld                      : Starting HelloWorld on Otokaze-Win10 with PID 4244 (C:\Users\Otokaze\IdeaProjects\springboot\01-springboot-helloworld\target\classes started by Otokaze in C:\Users\Otokaze\IdeaProjects\springboot)
2019-02-16 14:04:56.201 DEBUG 4244 --- [           main] com.zfl9.HelloWorld                      : Running with Spring Boot v2.1.2.RELEASE, Spring v5.1.4.RELEASE
2019-02-16 14:04:56.201  INFO 4244 --- [           main] com.zfl9.HelloWorld                      : No active profile set, falling back to default profiles: default
2019-02-16 14:04:57.033  INFO 4244 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2019-02-16 14:04:57.049  INFO 4244 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2019-02-16 14:04:57.049  INFO 4244 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.14]
2019-02-16 14:04:57.049  INFO 4244 --- [           main] o.a.catalina.core.AprLifecycleListener   : An older version [1.2.18] of the APR based Apache Tomcat Native library is installed, while Tomcat recommends a minimum version of [1.2.19]
2019-02-16 14:04:57.049  INFO 4244 --- [           main] o.a.catalina.core.AprLifecycleListener   : Loaded APR based Apache Tomcat Native library [1.2.18] using APR version [1.6.5].
2019-02-16 14:04:57.049  INFO 4244 --- [           main] o.a.catalina.core.AprLifecycleListener   : APR capabilities: IPv6 [true], sendfile [true], accept filters [false], random [true].
2019-02-16 14:04:57.049  INFO 4244 --- [           main] o.a.catalina.core.AprLifecycleListener   : APR/OpenSSL configuration: useAprConnector [false], useOpenSSL [true]
2019-02-16 14:04:57.065  INFO 4244 --- [           main] o.a.catalina.core.AprLifecycleListener   : OpenSSL successfully initialized [OpenSSL 1.1.1  11 Sep 2018]
2019-02-16 14:04:57.111  INFO 4244 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-02-16 14:04:57.111  INFO 4244 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 847 ms
2019-02-16 14:04:57.268  INFO 4244 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-02-16 14:04:57.377  INFO 4244 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2019-02-16 14:04:57.377  INFO 4244 --- [           main] com.zfl9.HelloWorld                      : Started HelloWorld in 1.442 seconds (JVM running for 1.9)
2019-02-16 14:04:57.377 TRACE 4244 --- [           main] com.zfl9.HelloWorld                      : trace message
2019-02-16 14:04:57.377 DEBUG 4244 --- [           main] com.zfl9.HelloWorld                      : debug message
2019-02-16 14:04:57.377  INFO 4244 --- [           main] com.zfl9.HelloWorld                      : info message
2019-02-16 14:04:57.377  WARN 4244 --- [           main] com.zfl9.HelloWorld                      : warn message
2019-02-16 14:04:57.377 ERROR 4244 --- [           main] com.zfl9.HelloWorld                      : error message

自定义外部的 logback.xml 配置文件
如果是一些比较简单的 logging 配置,如更改日志级别,建议直接在 application.properties 文件中设置,简单方便;如果 application.properties 提供的配置项无法满足你的要求,springboot 也允许你提供一个外部的 logback 配置文件。注意,你经常会看到两种名称的 logback 配置文件,logback.xmllogback-spring.xml,了解 logback 的同学应该知道,只有前者才是 logback 框架的标准配置文件名,后者不是;logback-spring.xml 其实是 springboot 规定的一个文件名,springboot 建议使用 logback-spring.xml 而不是 logback.xml,因为日志服务通常在 ApplicationContext 创建之前就已经初始化完毕了,所以 springboot 不能很好的控制 logback.xml,但是 logback-spring.xml 能够比较好的控制,如 springboot 会在我们的 logback-spring.xml 配置的基础上添加一些 springboot 特有的配置项;所以建议使用带 -spring 后缀的日志配置文件。

SpringBoot 单元测试

Spring 单元测试 - 简单例子

package com.zfl9.spring;

import javax.annotation.Resource;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@ContextConfiguration(locations = "classpath:spring.xml")
public class IocByXmlConfigTestBySpring {
    @Resource(name = "钟时兰")
    private Teacher teacher;

    @Resource(name = "钟学华")
    private Student student01;

    @Resource(name = "尹帧瑶")
    private Student student02;

    @Resource(name = "廖小兵")
    private Student student03;

    @Test
    public void test01() {
        System.out.println(teacher);
        System.out.println(student01);
        System.out.println(student02);
        System.out.println(student03);
    }

    @Test
    public void test02() {
        for (Student student : teacher.getStudents()) {
            System.out.println(student);
        }
    }
}

SpringBoot 单元测试 - 简单例子

package com.zfl9;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTest {
    @Autowired
    private Application application;

    @Test
    public void test() {
        Logger logger = LoggerFactory.getLogger(this.getClass());
        logger.info("application: {}", application);
    }
}

其实没多大区别,最关键的地方在于测试类的注解声明:

// Spring 单元测试
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = "classpath:spring.xml")

// SpringBoot 单元测试
@RunWith(SpringRunner.class)
@SpringBootTest

@RunWith(SpringRunner.class) 是 JUnit 4 的注解,用于指定使用哪个 Runner 运行当前测试类,无论是 Spring 还是 SpringBoot 都需要使用这个注解,具体含义和作用大家应该都比较清楚,这里不详细解释。在 Spring 单元测试类中,我们用的是 @ContextConfiguration 注解来指定 spring 的配置文件(xml 或 class);但是在 SpringBoot 单元测试类中,我们只需要使用 @SpringBootTest 注解标注就可以了,@SpringBootTest 注解会自动搜索当前包(含子包)中被 @SpringBootApplication 标注的启动类&配置类(本质就是 java-based 形式的配置),不用指定使用哪个“启动类”。

除了这个区别外,其它的行为都没有什么区别;比如:如果测试类、测试方法上使用了 @Transactional 声明式事务注解,那么 spring-test 会自动回滚当前方法的事务,无论是 Spring 还是 SpringBoot 的单元测试,都是一样的,默认都是会自动回滚事务;来回顾一下 Spring 单元测试中的事务管理:

  • @BeforeTransaction:在事务开启之前执行,从输出结果也看得出来,此时没有开启事务。
  • @Before:在事务开启之后且在测试方法之前执行,即在 @Test 前执行,此时已经有一个事务。
  • @Test:在 @Before 方法之后执行,此时也有一个事务,@Before、@Test、@After 都是同一个事务。
  • @After:在 @Test 方法之后执行,此时也有一个事务,@Before、@Test、@After 也都是同一个事务。
  • @AfterTransaction:在事务完成后执行,此时事务已经提交或回滚,可以在方法中测试事务提交是否成功。

SpringBoot FreeMarker

项目结构

$ tree src
src
├── main
│   ├── java
│   │   └── com
│   │       └── zfl9
│   │           └── springboot
│   │               ├── Application.java
│   │               └── HelloController.java
│   └── resources
│       ├── application.properties
│       ├── static
│       │   └── index.html
│       └── templates
│           └── hello.ftl
└── test
    └── java

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.zfl9.springboot</groupId>
  <artifactId>springboot-simple-webapp</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.2.RELEASE</version>
  </parent>

  <properties>
    <java.version>1.8</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <optional>true</optional>
    </dependency>

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

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

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

集成 FreeMarker 非常简单,只需要引入 spring-boot-starter-freemarker 即可;然后你就能立即使用 FreeMarker 模板了,不需要任何必须的 application.properties 配置(开箱即用)。不过为了更愉快的使用 FreeMarker,我们通常也会在 application.properties 中配置一些 FreeMarker 的属性,如开启 cache,设置日期、时间、数字的显示格式等。

另外解释一下 spring-boot-devtools 模块的作用,看名字大概也猜得出来,这是 springboot 提供的一个开发工具,在开发 web 程序的时候,一个共同的需求就是:热重载,即修改了 java 文件或 resource 文件(总之就是 classpath 下的文件变动了)后,能够通过某种手段,更新这些 class、resource 文件,而不需要重启 servlet 服务器,这就叫做热重载,也有人叫做热更新。而 devtools 模块的作用就是提供 springboot 应用的热更新功能,只要我们引入了 devtools 模块,当我们运行 Application.main() 方法之后,如果更改了源代码或者是其它资源文件(如 index.html),不需要重新运行 Application.main() 方法了,而是只需要重新编译当前 project/module 即可,在 IDEA 中,编译当前项目的快捷键为 Ctrl + F9,编译之后,devtools 就会自动感知到 classpath 下的文件内容变动了,于是就会丢弃原来的旧文件内容,重新加载新的文件内容,以此完成热更新。

devtools 热重载原理浅析
spring-boot-devtools 使用了两个类加载器来实现热重载:base 类加载器、restart 类加载器。

  • base ClassLoader:用于加载不会改变的 jar(eg:第三方依赖的 jar 包)。
  • restart ClassLoader:用于加载我们正在开发的 jar(eg:我们当前的项目文件)。应用重载后,devtools 会将原先的 restart ClassLoader 丢掉、然后重新 new 一个 restart ClassLoader 来加载这些修改过的东西,而 base ClassLoader 却不需要动一下。这就是 devtools 重载速度快的原因。

devtools 会自动禁用 FreeMarker、Thymeleaf 等模板引擎的缓存,即使手动指定设置 freemarker/thymeleaf 的 cache 选项为 true 也是如此。

application.properties

spring.freemarker.cache = true
spring.freemarker.charset = UTF-8
spring.freemarker.content-type = text/html; charset=UTF-8
spring.freemarker.expose-request-attributes = true
spring.freemarker.expose-session-attributes = true
spring.freemarker.expose-spring-macro-helpers = true
spring.freemarker.settings.locale = zh_CN
spring.freemarker.settings.time_zone = Asia/Shanghai
spring.freemarker.settings.output_encoding = UTF-8
spring.freemarker.settings.url_escaping_charset = UTF-8
spring.freemarker.settings.number_format = 0.###
spring.freemarker.settings.boolean_format = true,false
spring.freemarker.settings.date_format = yyyy-MM-dd
spring.freemarker.settings.time_format = HH:mm:ss
spring.freemarker.settings.datetime_format = yyyy-MM-dd HH:mm:ss

Application.java

package com.zfl9.springboot;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

HelloController.java

package com.zfl9.springboot;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HelloController {
    @GetMapping("/hello")
    public String hello(Model model) {
        model.addAttribute("addr", "www.zfl9.com");
        model.addAttribute("port", 443);
        model.addAttribute("method", "aes-128-gcm");
        model.addAttribute("passwd", "123456@zfl9.com");
        return "hello";
    }
}

index.html

<h1>hello, world! [freemarker]</h1>

hello.ftl

<h2>addr = ${addr}</h2>
<h2>port = ${port}</h2>
<h2>method = ${method}</h2>
<h2>passwd = ${passwd}</h2>

SpringBoot HikariCP

SpringBoot 1.x 的默认数据库连接池为 Tomcat Jdbc Pool,SpringBoot 2.x 的默认数据库连接池为 HikariCP。HikariCP 虽然是后起之秀,但是它凭借着“速度快”,“轻量级”,“高性能”这些优势,赢得了许多用户的好口碑;SpringBoot 当然也不例外,在 2.x 的时候,直接就将 HikariCP 作为默认的数据库连接池实现,而不是 Tomcat Jdbc Pool。由于本文始终使用 SpringBoot 2.x,所以这里直接介绍 HikariCP 数据库连接池。

项目结构

$ tree src
src
├── main
│   ├── java
│   │   └── com
│   │       └── zfl9
│   │           └── springboot
│   │               ├── Application.java
│   │               ├── Employee.java
│   │               ├── EmployeeRunner.java
│   │               └── HelloController.java
│   └── resources
│       ├── application.properties
│       ├── data.sql
│       ├── schema.sql
│       ├── static
│       │   └── index.html
│       └── templates
│           └── hello.ftl
└── test
    └── java

HelloController、index.html、hello.ftl 这些不用管,这是上一节 FreeMarker 的测试类以及文件。Application 还是照常,一个简单的 main() 方法。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.zfl9.springboot</groupId>
  <artifactId>springboot-simple-webapp</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.2.RELEASE</version>
  </parent>

  <properties>
    <java.version>1.8</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <optional>true</optional>
    </dependency>

    <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>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>

    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>8.0.15</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

新增的依赖为 spring-boot-starter-jdbc(spring-jdbc 模块、HikariCP 连接池)、mysql-connector-java(MySQL JDBC 驱动)。

application.properties

# FreeMarker
spring.freemarker.cache = true
spring.freemarker.charset = UTF-8
spring.freemarker.content-type = text/html; charset=UTF-8
spring.freemarker.expose-request-attributes = true
spring.freemarker.expose-session-attributes = true
spring.freemarker.expose-spring-macro-helpers = true
spring.freemarker.settings.locale = zh_CN
spring.freemarker.settings.time_zone = Asia/Shanghai
spring.freemarker.settings.output_encoding = UTF-8
spring.freemarker.settings.url_escaping_charset = UTF-8
spring.freemarker.settings.number_format = 0.###
spring.freemarker.settings.boolean_format = true,false
spring.freemarker.settings.date_format = yyyy-MM-dd
spring.freemarker.settings.time_format = HH:mm:ss
spring.freemarker.settings.datetime_format = yyyy-MM-dd HH:mm:ss

# HikariCP
spring.datasource.initialization-mode = always
spring.datasource.type = com.zaxxer.hikari.HikariDataSource
spring.datasource.driver-class-name = com.mysql.cj.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost/test
spring.datasource.username = root
spring.datasource.password = 123456
spring.datasource.hikari.connection-timeout = 10000
spring.datasource.hikari.max-lifetime = 7200000
spring.datasource.hikari.maximum-pool-size = 30

这里简单解释一下 spring.datasource.initialization-mode 选项,它有 3 个取值:

  • always:不管什么数据源都会初始化;
  • never:不管什么数据源都不进行初始化;
  • embedded:只初始化嵌入式数据源(默认)。

这里的 DataSource 初始化的意思是(仅讲解 JDBC 数据源的初始化):在应用启动时,会自动执行 classpath 下的 schema.sqldata.sql 文件:

  • schema.sql:DDL 语句,如 create、alter、drop,通常在该脚本中写 table 的 create、drop 命令;
  • data.sql:DML 语句,如 select、insert、update、delete,通常在该脚本中写 table 的 insert 命令。

schema.sql

drop table if exists `springboot`;
create table `springboot` (
  `id` int(11) not null auto_increment,
  `name` varchar(30) not null,
  `email` varchar(50) not null,
  primary key (`id`)
) charset=utf8mb4

data.sql

insert into springboot(name, email) values("zfl9", "root@zfl9.com");
insert into springboot(name, email) values("baidu", "root@baidu.com");
insert into springboot(name, email) values("google", "root@google.com");
insert into springboot(name, email) values("youtube", "root@youtube.com");
insert into springboot(name, email) values("facebook", "root@facebook.com");

springboot 应用启动后,就会自动执行 schema.sql(创建 springboot 测试表)、data.sql(插入 5 条测试数据)。

Employee.java

package com.zfl9.springboot;

public class Employee implements java.io.Serializable {
    private static final long serialVersionUID = 9189662856423316374L;

    private Integer id;
    private String name;
    private String email;

    public Employee() {
    }

    public Employee(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public Employee(Integer id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    @Override
    public String toString() {
        return String.format("Employee{id=%d, name='%s', email='%s'}", id, name, email);
    }
}

EmployeeRunner.java

package com.zfl9.springboot;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Repository
public class EmployeeRunner implements CommandLineRunner {
    private static final Logger LOGGER = LoggerFactory.getLogger(EmployeeRunner.class);

    private JdbcTemplate jdbcTemplate;

    private RowMapper<Employee> rowMapper = (resultSet, rowNum) -> {
        Employee employee = new Employee();
        employee.setId(resultSet.getInt("id"));
        employee.setName(resultSet.getString("name"));
        employee.setEmail(resultSet.getString("email"));
        return employee;
    };

    @Autowired
    public EmployeeRunner(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    private List<Employee> getEmployees() {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee("小明", "xiaoming@gmail.com"));
        employees.add(new Employee("小刚", "xiaogang@gmail.com"));
        employees.add(new Employee("小红", "xiaohong@gmail.com"));
        return employees;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void run(String... args) {
        String sql = "insert into springboot(name, email) values(?, ?)";
        for (Employee employee : getEmployees()) {
            LOGGER.info("insert employee: {}", employee);
            jdbcTemplate.update(sql, employee.getName(), employee.getEmail());
        }
        throw new RuntimeException("runtime exception");
    }
}

运行结果

2019-02-16 21:43:32.146  INFO 8988 --- [  restartedMain] com.zfl9.springboot.Application          : Starting Application on Otokaze-Win10 with PID 8988 (C:\Users\Otokaze\IdeaProjects\springboot\02-springboot-simple-webapp\target\classes started by Otokaze in C:\Users\Otokaze\IdeaProjects\springboot)
2019-02-16 21:43:32.158  INFO 8988 --- [  restartedMain] com.zfl9.springboot.Application          : No active profile set, falling back to default profiles: default
2019-02-16 21:43:32.224  INFO 8988 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2019-02-16 21:43:32.224  INFO 8988 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
2019-02-16 21:43:33.409  INFO 8988 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2019-02-16 21:43:33.426  INFO 8988 --- [  restartedMain] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2019-02-16 21:43:33.426  INFO 8988 --- [  restartedMain] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.14]
2019-02-16 21:43:33.429  INFO 8988 --- [  restartedMain] o.a.catalina.core.AprLifecycleListener   : An older version [1.2.18] of the APR based Apache Tomcat Native library is installed, while Tomcat recommends a minimum version of [1.2.19]
2019-02-16 21:43:33.429  INFO 8988 --- [  restartedMain] o.a.catalina.core.AprLifecycleListener   : Loaded APR based Apache Tomcat Native library [1.2.18] using APR version [1.6.5].
2019-02-16 21:43:33.431  INFO 8988 --- [  restartedMain] o.a.catalina.core.AprLifecycleListener   : APR capabilities: IPv6 [true], sendfile [true], accept filters [false], random [true].
2019-02-16 21:43:33.431  INFO 8988 --- [  restartedMain] o.a.catalina.core.AprLifecycleListener   : APR/OpenSSL configuration: useAprConnector [false], useOpenSSL [true]
2019-02-16 21:43:33.433  INFO 8988 --- [  restartedMain] o.a.catalina.core.AprLifecycleListener   : OpenSSL successfully initialized [OpenSSL 1.1.1  11 Sep 2018]
2019-02-16 21:43:33.518  INFO 8988 --- [  restartedMain] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-02-16 21:43:33.518  INFO 8988 --- [  restartedMain] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1293 ms
2019-02-16 21:43:33.587  INFO 8988 --- [  restartedMain] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2019-02-16 21:43:33.666  INFO 8988 --- [  restartedMain] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2019-02-16 21:43:33.726  INFO 8988 --- [  restartedMain] o.s.b.d.a.OptionalLiveReloadServer       : LiveReload server is running on port 35729
2019-02-16 21:43:33.982  INFO 8988 --- [  restartedMain] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-02-16 21:43:34.061  INFO 8988 --- [  restartedMain] o.s.b.a.w.s.WelcomePageHandlerMapping    : Adding welcome page: class path resource [static/index.html]
2019-02-16 21:43:34.182  INFO 8988 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2019-02-16 21:43:34.185  INFO 8988 --- [  restartedMain] com.zfl9.springboot.Application          : Started Application in 2.295 seconds (JVM running for 2.812)
2019-02-16 21:43:34.193  INFO 8988 --- [  restartedMain] com.zfl9.springboot.EmployeeRunner       : insert employee: Employee{id=null, name='小明', email='xiaoming@gmail.com'}
2019-02-16 21:43:34.204  INFO 8988 --- [  restartedMain] com.zfl9.springboot.EmployeeRunner       : insert employee: Employee{id=null, name='小刚', email='xiaogang@gmail.com'}
2019-02-16 21:43:34.205  INFO 8988 --- [  restartedMain] com.zfl9.springboot.EmployeeRunner       : insert employee: Employee{id=null, name='小红', email='xiaohong@gmail.com'}
2019-02-16 21:43:34.230  INFO 8988 --- [  restartedMain] ConditionEvaluationReportLoggingListener : 

Error starting ApplicationContext. To display the conditions report re-run your application with 'debug' enabled.
2019-02-16 21:43:34.238 ERROR 8988 --- [  restartedMain] o.s.boot.SpringApplication               : Application run failed

java.lang.IllegalStateException: Failed to execute CommandLineRunner
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:816) [spring-boot-2.1.2.RELEASE.jar:2.1.2.RELEASE]
    at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:797) [spring-boot-2.1.2.RELEASE.jar:2.1.2.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:324) [spring-boot-2.1.2.RELEASE.jar:2.1.2.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1260) [spring-boot-2.1.2.RELEASE.jar:2.1.2.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1248) [spring-boot-2.1.2.RELEASE.jar:2.1.2.RELEASE]
    at com.zfl9.springboot.Application.main(Application.java:9) [classes/:na]
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_172]
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_172]
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_172]
    at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_172]
    at org.springframework.boot.devtools.restart.RestartLauncher.run(RestartLauncher.java:49) [spring-boot-devtools-2.1.2.RELEASE.jar:2.1.2.RELEASE]
Caused by: java.lang.RuntimeException: runtime exception
    at com.zfl9.springboot.EmployeeRunner.run(EmployeeRunner.java:50) ~[classes/:na]
    at com.zfl9.springboot.EmployeeRunner$$FastClassBySpringCGLIB$$359c64c5.invoke(<generated>) ~[classes/:na]
    at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) ~[spring-core-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:749) ~[spring-aop-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:139) ~[spring-tx-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:294) ~[spring-tx-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:98) ~[spring-tx-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:688) ~[spring-aop-5.1.4.RELEASE.jar:5.1.4.RELEASE]
    at com.zfl9.springboot.EmployeeRunner$$EnhancerBySpringCGLIB$$8cae41f3.run(<generated>) ~[classes/:na]
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:813) [spring-boot-2.1.2.RELEASE.jar:2.1.2.RELEASE]
    ... 10 common frames omitted

2019-02-16 21:43:34.241  INFO 8988 --- [  restartedMain] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'
2019-02-16 21:43:34.241  INFO 8988 --- [  restartedMain] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2019-02-16 21:43:34.246  INFO 8988 --- [  restartedMain] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

得益于 springboot 的自动配置功能,我们在配置好 DataSource 之后,springboot 就帮我们开启了声明式事务支持,所以我们可以直接在 run() 方法上使用 @Transactional 注解,需要注意的是,我故意在方法最后跑出一个 RuntimeException 运行时异常,以此测试声明式事务是否正常生效:

  • 如果未生效:那么查看 springboot 测试表可以看到多了 3 条记录(小明、小刚、小红);
  • 如果已生效:那么查看 springboot 测试表就看不到这 3 条新纪录(还是原来的 5 条记录);

我测试的结果是第二个,所以证明声明式事务已经正常生效了。

SpringBoot MyBatis

SpringBoot 集成 MyBatis 也很简单,MyBatis 提供了一个 mybatis-spring-boot-starter,添加到 pom.xml 即可。

项目结构

$ tree src
src
├── main
│   ├── java
│   │   └── com
│   │       └── zfl9
│   │           ├── Application.java
│   │           ├── mapper
│   │           │   └── EmployeeMapper.java
│   │           └── model
│   │               └── Employee.java
│   └── resources
│       ├── application.properties
│       └── mapper
│           └── EmployeeMapper.xml
└── test
    └── java
        └── com
            └── zfl9
                └── ApplicationTest.java

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.zfl9.springboot</groupId>
  <artifactId>springboot-mybatis</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.3.RELEASE</version>
  </parent>

  <properties>
    <java.version>1.8</java.version>
  </properties>

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

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

    <dependency>
      <groupId>org.mybatis.spring.boot</groupId>
      <artifactId>mybatis-spring-boot-starter</artifactId>
      <version>2.0.0</version>
    </dependency>

    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

application.properties

# HikariCP
spring.datasource.initialization-mode = always
spring.datasource.type = com.zaxxer.hikari.HikariDataSource
spring.datasource.driver-class-name = com.mysql.cj.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost/test
spring.datasource.username = root
spring.datasource.password = 123456
spring.datasource.hikari.connection-timeout = 10000
spring.datasource.hikari.max-lifetime = 7200000
spring.datasource.hikari.maximum-pool-size = 30

# MyBatis
mybatis.mapper-locations = classpath:mapper/*.xml
mybatis.type-aliases-package = com.zfl9.model
mybatis.configuration.map-underscore-to-camel-case = true

# Logging
logging.level.com.zfl9 = trace
logging.level.org.mybatis.spring = trace

Application.java

package com.zfl9;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.zfl9.mapper")
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

@MapperScan("com.zfl9.mapper") 作用和之间的 SSM 项目是一样的,扫描指定 package 下面的 mapper 接口,自动注入到 spring 容器中。

Employee.java

package com.zfl9.model;

public class Employee implements java.io.Serializable {
    private static final long serialVersionUID = -8898382136809677158L;

    private Integer id;
    private String name;
    private String email;
    private String address;
    private String telephone;

    public Employee() {
    }

    public Employee(String name, String email, String address, String telephone) {
        this.name = name;
        this.email = email;
        this.address = address;
        this.telephone = telephone;
    }

    public Employee(Integer id, String name, String email, String address, String telephone) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.address = address;
        this.telephone = telephone;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public String getTelephone() {
        return telephone;
    }

    public void setTelephone(String telephone) {
        this.telephone = telephone;
    }

    @Override
    public String toString() {
        return String.format("Employee{id=%d, name='%s', email='%s', address='%s', telephone='%s'}", id, name, email, address, telephone);
    }
}

EmployeeMapper.java

package com.zfl9.mapper;

import com.zfl9.model.Employee;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface EmployeeMapper {
    Employee getEmployeeById(int id);

    List<Employee> getAllEmployees();

    int addEmployee(Employee employee);

    int updateEmployee(Employee employee);

    int deleteEmployeeById(int id);

    int deleteAllEmployees();

    void truncateEmployeeTable();
}

EmployeeMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zfl9.mapper.EmployeeMapper">
  <select id="getEmployeeById" resultType="Employee">
    select id,name,email,address,telephone from employee where id=#{id}
  </select>

  <select id="getAllEmployees" resultType="Employee">
    select id,name,email,address,telephone from employee
  </select>

  <insert id="addEmployee" useGeneratedKeys="true" keyProperty="id">
    insert into employee(name, email, address, telephone) values(#{name}, #{email}, #{address}, #{telephone})
  </insert>

  <update id="updateEmployee">
    update employee set name=#{name}, email=#{email}, address=#{address}, telephone=#{telephone} where id=#{id}
  </update>

  <delete id="deleteEmployeeById">
    delete from employee where id=#{id}
  </delete>

  <delete id="deleteAllEmployees">
    delete from employee
  </delete>

  <delete id="truncateEmployeeTable">
    truncate table employee
  </delete>
</mapper>

ApplicationTest.java

package com.zfl9;

import com.zfl9.mapper.EmployeeMapper;
import com.zfl9.model.Employee;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@SpringBootTest
@RunWith(SpringRunner.class)
public class ApplicationTest {
    @Autowired
    private EmployeeMapper mapper;

    private void queryAllEmployees() {
        List<Employee> employees = mapper.getAllEmployees();
        for (Employee employee : employees) {
            System.out.println(employee);
        }
    }

    @Before
    public void before() {
        System.out.println("===> before insert <===");
        queryAllEmployees();
    }

    @Test
    @Transactional(rollbackFor = Exception.class)
    public void test() {
        mapper.addEmployee(new Employee("小金", "xiaojing@gmail.com", "广州市天河区", "999999"));
        mapper.addEmployee(new Employee("小青", "xiaoqing@gmail.com", "南昌市红谷滩", "111111"));
    }

    @After
    public void after() {
        System.out.println("===> after insert <===");
        queryAllEmployees();
    }

    @AfterTransaction
    public void verify() {
        System.out.println("===> after transaction <===");
        queryAllEmployees();
    }
}

运行结果

2019-02-17 14:52:45.625  INFO 2520 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2019-02-17 14:52:45.770  INFO 2520 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2019-02-17 14:52:45.775  INFO 2520 --- [           main] o.s.t.c.transaction.TransactionContext   : Began transaction (1) for test context [DefaultTestContext@27ce24aa testClass = ApplicationTest, testInstance = com.zfl9.ApplicationTest@5119fb47, testMethod = test@ApplicationTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@481a996b testClass = ApplicationTest, locations = '{}', classes = '{class com.zfl9.Application}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@7791a895, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@1d16f93d, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@49fc609f, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@4f063c0a], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map[[empty]]]; transaction manager [org.springframework.jdbc.datasource.DataSourceTransactionManager@3ae66c85]; rollback [true]
===> before insert <===
2019-02-17 14:52:45.983 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Creating a new SqlSession
2019-02-17 14:52:45.990 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22]
2019-02-17 14:52:46.006 DEBUG 2520 --- [           main] o.m.s.t.SpringManagedTransaction         : JDBC Connection [HikariProxyConnection@1145391264 wrapping com.mysql.cj.jdbc.ConnectionImpl@3766c667] will be managed by Spring
2019-02-17 14:52:46.011 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : ==>  Preparing: select id,name,email,address,telephone from employee 
2019-02-17 14:52:46.043 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : ==> Parameters: 
2019-02-17 14:52:46.068 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==    Columns: id, name, email, address, telephone
2019-02-17 14:52:46.068 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 1, 小明, xiaoming@gmail.com, 北京市海淀区, 123456
2019-02-17 14:52:46.077 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 2, 小红, xiaohong@gmail.com, 上海市浦东区, 654321
2019-02-17 14:52:46.077 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 3, 小刚, xiaogang@gmail.com, 深圳市龙岗区, 987654
2019-02-17 14:52:46.077 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==      Total: 3
2019-02-17 14:52:46.078 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22]
Employee{id=1, name='小明', email='xiaoming@gmail.com', address='北京市海淀区', telephone='123456'}
Employee{id=2, name='小红', email='xiaohong@gmail.com', address='上海市浦东区', telephone='654321'}
Employee{id=3, name='小刚', email='xiaogang@gmail.com', address='深圳市龙岗区', telephone='987654'}
2019-02-17 14:52:46.080 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22] from current transaction
2019-02-17 14:52:46.081 DEBUG 2520 --- [           main] c.z.mapper.EmployeeMapper.addEmployee    : ==>  Preparing: insert into employee(name, email, address, telephone) values(?, ?, ?, ?) 
2019-02-17 14:52:46.082 DEBUG 2520 --- [           main] c.z.mapper.EmployeeMapper.addEmployee    : ==> Parameters: 小金(String), xiaojing@gmail.com(String), 广州市天河区(String), 999999(String)
2019-02-17 14:52:46.083 DEBUG 2520 --- [           main] c.z.mapper.EmployeeMapper.addEmployee    : <==    Updates: 1
2019-02-17 14:52:46.084 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22]
2019-02-17 14:52:46.085 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22] from current transaction
2019-02-17 14:52:46.085 DEBUG 2520 --- [           main] c.z.mapper.EmployeeMapper.addEmployee    : ==>  Preparing: insert into employee(name, email, address, telephone) values(?, ?, ?, ?) 
2019-02-17 14:52:46.085 DEBUG 2520 --- [           main] c.z.mapper.EmployeeMapper.addEmployee    : ==> Parameters: 小青(String), xiaoqing@gmail.com(String), 南昌市红谷滩(String), 111111(String)
2019-02-17 14:52:46.086 DEBUG 2520 --- [           main] c.z.mapper.EmployeeMapper.addEmployee    : <==    Updates: 1
2019-02-17 14:52:46.086 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22]
===> after insert <===
2019-02-17 14:52:46.087 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22] from current transaction
2019-02-17 14:52:46.087 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : ==>  Preparing: select id,name,email,address,telephone from employee 
2019-02-17 14:52:46.088 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : ==> Parameters: 
2019-02-17 14:52:46.088 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==    Columns: id, name, email, address, telephone
2019-02-17 14:52:46.088 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 1, 小明, xiaoming@gmail.com, 北京市海淀区, 123456
2019-02-17 14:52:46.089 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 2, 小红, xiaohong@gmail.com, 上海市浦东区, 654321
2019-02-17 14:52:46.089 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 3, 小刚, xiaogang@gmail.com, 深圳市龙岗区, 987654
2019-02-17 14:52:46.090 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 8, 小金, xiaojing@gmail.com, 广州市天河区, 999999
2019-02-17 14:52:46.090 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 9, 小青, xiaoqing@gmail.com, 南昌市红谷滩, 111111
2019-02-17 14:52:46.090 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==      Total: 5
2019-02-17 14:52:46.090 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22]
Employee{id=1, name='小明', email='xiaoming@gmail.com', address='北京市海淀区', telephone='123456'}
Employee{id=2, name='小红', email='xiaohong@gmail.com', address='上海市浦东区', telephone='654321'}
Employee{id=3, name='小刚', email='xiaogang@gmail.com', address='深圳市龙岗区', telephone='987654'}
Employee{id=8, name='小金', email='xiaojing@gmail.com', address='广州市天河区', telephone='999999'}
Employee{id=9, name='小青', email='xiaoqing@gmail.com', address='南昌市红谷滩', telephone='111111'}
2019-02-17 14:52:46.093 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22]
2019-02-17 14:52:46.094 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@40ee0a22]
2019-02-17 14:52:46.134  INFO 2520 --- [           main] o.s.t.c.transaction.TransactionContext   : Rolled back transaction for test: [DefaultTestContext@27ce24aa testClass = ApplicationTest, testInstance = com.zfl9.ApplicationTest@5119fb47, testMethod = test@ApplicationTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@481a996b testClass = ApplicationTest, locations = '{}', classes = '{class com.zfl9.Application}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@7791a895, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@1d16f93d, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@49fc609f, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@4f063c0a], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map[[empty]]]
===> after transaction <===
2019-02-17 14:52:46.134 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Creating a new SqlSession
2019-02-17 14:52:46.134 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@25a73de1] was not registered for synchronization because synchronization is not active
2019-02-17 14:52:46.135 DEBUG 2520 --- [           main] o.m.s.t.SpringManagedTransaction         : JDBC Connection [HikariProxyConnection@696591495 wrapping com.mysql.cj.jdbc.ConnectionImpl@3766c667] will not be managed by Spring
2019-02-17 14:52:46.135 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : ==>  Preparing: select id,name,email,address,telephone from employee 
2019-02-17 14:52:46.135 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : ==> Parameters: 
2019-02-17 14:52:46.135 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==    Columns: id, name, email, address, telephone
2019-02-17 14:52:46.135 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 1, 小明, xiaoming@gmail.com, 北京市海淀区, 123456
2019-02-17 14:52:46.136 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 2, 小红, xiaohong@gmail.com, 上海市浦东区, 654321
2019-02-17 14:52:46.136 TRACE 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==        Row: 3, 小刚, xiaogang@gmail.com, 深圳市龙岗区, 987654
2019-02-17 14:52:46.136 DEBUG 2520 --- [           main] c.z.m.EmployeeMapper.getAllEmployees     : <==      Total: 3
2019-02-17 14:52:46.136 DEBUG 2520 --- [           main] org.mybatis.spring.SqlSessionUtils       : Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@25a73de1]
Employee{id=1, name='小明', email='xiaoming@gmail.com', address='北京市海淀区', telephone='123456'}
Employee{id=2, name='小红', email='xiaohong@gmail.com', address='上海市浦东区', telephone='654321'}
Employee{id=3, name='小刚', email='xiaogang@gmail.com', address='深圳市龙岗区', telephone='987654'}

SpringBoot SSM 示例

项目结构

$ tree src
src
├── main
│   ├── java
│   │   └── com
│   │       └── zfl9
│   │           ├── Application.java
│   │           ├── controller
│   │           │   └── EmployeeController.java
│   │           ├── mapper
│   │           │   └── EmployeeMapper.java
│   │           ├── model
│   │           │   └── Employee.java
│   │           └── service
│   │               ├── EmployeeService.java
│   │               └── EmployeeServiceImpl.java
│   └── resources
│       ├── application.properties
│       ├── mapper
│       │   └── EmployeeMapper.xml
│       ├── static
│       │   └── index.html
│       └── templates
│           ├── employee-edit.ftl
│           └── employee-list.ftl
└── test
    └── java

14 directories, 11 files

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.zfl9.springboot</groupId>
  <artifactId>springboot-ssm</artifactId>
  <version>0.0.1-SNAPSHOT</version>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.3.RELEASE</version>
  </parent>

  <properties>
    <java.version>1.8</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <optional>true</optional>
    </dependency>

    <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>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>

    <dependency>
      <groupId>org.mybatis.spring.boot</groupId>
      <artifactId>mybatis-spring-boot-starter</artifactId>
      <version>2.0.0</version>
    </dependency>

    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

application.properties

# FreeMarker
spring.freemarker.cache = true
spring.freemarker.charset = UTF-8
spring.freemarker.content-type = text/html; charset=UTF-8
spring.freemarker.expose-request-attributes = true
spring.freemarker.expose-session-attributes = true
spring.freemarker.expose-spring-macro-helpers = true
spring.freemarker.settings.locale = zh_CN
spring.freemarker.settings.time_zone = Asia/Shanghai
spring.freemarker.settings.output_encoding = UTF-8
spring.freemarker.settings.url_escaping_charset = UTF-8
spring.freemarker.settings.number_format = 0.###
spring.freemarker.settings.boolean_format = true,false
spring.freemarker.settings.date_format = yyyy-MM-dd
spring.freemarker.settings.time_format = HH:mm:ss
spring.freemarker.settings.datetime_format = yyyy-MM-dd HH:mm:ss

# HikariCP
spring.datasource.initialization-mode = never
spring.datasource.type = com.zaxxer.hikari.HikariDataSource
spring.datasource.driver-class-name = com.mysql.cj.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost/test
spring.datasource.username = root
spring.datasource.password = 123456
spring.datasource.hikari.connection-timeout = 10000
spring.datasource.hikari.max-lifetime = 7200000
spring.datasource.hikari.maximum-pool-size = 30

# MyBatis
mybatis.mapper-locations = classpath:mapper/*.xml
mybatis.configuration.map-underscore-to-camel-case = true

# Logging
logging.level.com.zfl9 = trace
logging.level.org.mybatis.spring = trace

Application.java

package com.zfl9;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.zfl9.mapper")
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Employee.java

package com.zfl9.model;

import java.io.Serializable;

public class Employee implements Serializable {
    private static final long serialVersionUID = -8369197914775006439L;

    private Integer id;
    private String name;
    private String email;
    private String address;
    private String telephone;

    public Employee() {
    }

    public Employee(String name, String email, String address, String telephone) {
        this.name = name;
        this.email = email;
        this.address = address;
        this.telephone = telephone;
    }

    public Employee(Integer id, String name, String email, String address, String telephone) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.address = address;
        this.telephone = telephone;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public String getTelephone() {
        return telephone;
    }

    public void setTelephone(String telephone) {
        this.telephone = telephone;
    }

    @Override
    public String toString() {
        return String.format("Employee{id=%d, name='%s', email='%s', address='%s', telephone='%s'}", id, name, email, address, telephone);
    }
}

EmployeeMapper.java

package com.zfl9.mapper;

import java.util.List;
import org.springframework.stereotype.Repository;
import com.zfl9.model.Employee;

@Repository
public interface EmployeeMapper {
    Employee getEmployeeById(int id);

    List<Employee> getAllEmployees();

    int addEmployee(Employee employee);

    int updateEmployee(Employee employee);

    int deleteEmployeeById(int id);

    int deleteAllEmployees();

    void truncateEmployeeTable();
}

EmployeeMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zfl9.mapper.EmployeeMapper">
  <select id="getEmployeeById" resultType="com.zfl9.model.Employee">
    select id,name,email,address,telephone from employee where id=#{id}
  </select>

  <select id="getAllEmployees" resultType="com.zfl9.model.Employee">
    select id,name,email,address,telephone from employee
  </select>

  <insert id="addEmployee" useGeneratedKeys="true" keyProperty="id">
    insert into employee(name, email, address, telephone) values(#{name}, #{email}, #{address}, #{telephone})
  </insert>

  <update id="updateEmployee">
    update employee set name=#{name}, email=#{email}, address=#{address}, telephone=#{telephone} where id=#{id}
  </update>

  <delete id="deleteEmployeeById">
    delete from employee where id=#{id}
  </delete>

  <delete id="deleteAllEmployees">
    delete from employee
  </delete>

  <delete id="truncateEmployeeTable">
    truncate table employee
  </delete>
</mapper>

EmployeeService.java

package com.zfl9.service;

import java.util.List;
import com.zfl9.model.Employee;

public interface EmployeeService {
    Employee getEmployeeById(int id);

    List<Employee> getAllEmployees();

    int addEmployee(Employee employee);

    int updateEmployee(Employee employee);

    int deleteEmployeeById(int id);

    int deleteAllEmployees();

    void truncateEmployeeTable();
}

EmployeeServiceImpl.java

package com.zfl9.service;

import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.zfl9.mapper.EmployeeMapper;
import com.zfl9.model.Employee;

@Service
public class EmployeeServiceImpl implements EmployeeService {
    private final EmployeeMapper employeeMapper;

    @Autowired
    public EmployeeServiceImpl(EmployeeMapper employeeMapper) {
        this.employeeMapper = employeeMapper;
    }

    @Override
    public Employee getEmployeeById(int id) {
        return employeeMapper.getEmployeeById(id);
    }

    @Override
    public List<Employee> getAllEmployees() {
        return employeeMapper.getAllEmployees();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int addEmployee(Employee employee) {
        return employeeMapper.addEmployee(employee);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int updateEmployee(Employee employee) {
        return employeeMapper.updateEmployee(employee);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteEmployeeById(int id) {
        return employeeMapper.deleteEmployeeById(id);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteAllEmployees() {
        return employeeMapper.deleteAllEmployees();
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void truncateEmployeeTable() {
        employeeMapper.truncateEmployeeTable();
    }
}

EmployeeController.java

package com.zfl9.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import com.zfl9.model.Employee;
import com.zfl9.service.EmployeeService;

@Controller
@RequestMapping("/employees")
public class EmployeeController {
    private final EmployeeService employeeService;

    @Autowired
    public EmployeeController(EmployeeService employeeService) {
        this.employeeService = employeeService;
    }

    @GetMapping
    public String listEmployee(Model model) {
        model.addAttribute("employees", employeeService.getAllEmployees());
        return "employee-list";
    }

    @GetMapping("/new")
    public String newEmployee(Model model) {
        model.addAttribute("title", "Create Employee");
        model.addAttribute("employee", new Employee());
        return "employee-edit";
    }

    @GetMapping("/edit/{id}")
    public String editEmployee(Model model, @PathVariable int id) {
        model.addAttribute("title", "Update Employee");
        model.addAttribute("employee", employeeService.getEmployeeById(id));
        return "employee-edit";
    }

    @PostMapping("/save")
    public String saveEmployee(@ModelAttribute Employee employee) {
        if (employee.getId() == null) {
            employeeService.addEmployee(employee);
        } else {
            employeeService.updateEmployee(employee);
        }
        return "redirect:/employees";
    }

    @GetMapping("/delete/{id}")
    public String deleteEmployee(@PathVariable String id) {
        if ("all".equals(id)) {
            employeeService.deleteAllEmployees();
        } else {
            employeeService.deleteEmployeeById(Integer.valueOf(id));
        }
        return "redirect:/employees";
    }
}

index.html

<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8">
    <title>index</title>
  </head>
  <body>
    <div align="center">
      <h2><a href="employees">Employee Management System</a></h2>
    </div>
  </body>
</html>

employee-list.ftl

<#assign ctx = springMacroRequestContext.contextPath>
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8">
    <title>List Employee</title>
  </head>
  <body>
    <div align="center">
      <h1>List Employee</h1>
      <table border="1">
        <tr>
          <th>Id</th>
          <th>Name</th>
          <th>Email</th>
          <th>Address</th>
          <th>Telephone</th>
          <th>Action</th>
        </tr>
        <#list employees as employee>
          <tr>
            <td>${employee.id}</td>
            <td>${employee.name!}</td>
            <td>${employee.email!}</td>
            <td>${employee.address!}</td>
            <td>${employee.telephone!}</td>
            <td>
              <a href="${ctx}/employees/edit/${employee.id}">Edit</a>
              <a href="${ctx}/employees/delete/${employee.id}">Delete</a>
            </td>
          </tr>
        </#list>
      </table>
      <h3><a href="${ctx}/employees/new">Create Employee</a></h3>
      <h3><a href="${ctx}/employees/delete/all">Delete All Employee</a></h3>
    </div>
  </body>
</html>

employee-edit.ftl

<#assign ctx = springMacroRequestContext.contextPath>
<#import "spring.ftl" as spring>
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8">
    <title>Edit Employee</title>
  </head>
  <body>
    <div align="center">
      <h1>${title}</h1>
      <form action="${ctx}/employees/save" method="post">
        <@spring.formHiddenInput "employee.id"/>
        <table>
          <tr>
            <td>Name:</td>
            <td>
              <@spring.formInput "employee.name"/>
            </td>
          </tr>
          <tr>
            <td>Email:</td>
            <td>
              <@spring.formInput "employee.email"/>
            </td>
          </tr>
          <tr>
            <td>Address:</td>
            <td>
              <@spring.formInput "employee.address"/>
            </td>
          </tr>
          <tr>
            <td>Telephone:</td>
            <td>
              <@spring.formInput "employee.telephone"/>
            </td>
          </tr>
        </table>
        <input type="submit" value="Save">
      </form>
    </div>
  </body>
</html>

命令行参数与 Profile

命令行参数传递 properties 属性
springboot 的配置文件为 application.properties,properties 就是所谓的“属性”文件,即 java.util.Properties;我们通过 JVM 的启动参数 -Dname=value 可以传递 property 进去,JVM 会将这些 name/value 对保存到 System.getProperties() 这个 properties 属性类中,假设运行方式为 java -Dname=value com.zfl9.MainApp,那么我们就可以在 MainApp 中通过 System.getProperty("name") 来读取该属性对应的值("value"),可以传递多个 -D 参数;java 的 properties 类似于操作系统的环境变量,key 和 value 都是字符串类型。

SpringBoot 允许我们通过命令行参数提供 application.properties 中的属性值(jar 运行方式),如改变 tomcat 的监听端口:
java -jar springboot-helloworld-webapp.jar --server.port=80
语法非常简单,就是在 property 的 key 前面加上 -- 就行了,命令行提供的属性值优先于 application.properties 中的属性值。

当然,也可以直接通过 jvm 的 -D 选项传递 properties 属性,如:
java -Dserver.port=80 -jar springboot-helloworld-webapp.jar
如果同时指定了 -Dserver.port=80 jvm 启动参数、--server.port=80 命令行参数,则后者的优先级高。

springboot 的多环境支持(profile)
我们在开发 Spring Boot 应用时,通常同一套程序会被应用和安装到几个不同的环境,比如:开发环境(dev)、测试环境*(test)、生产环境(prod)。其中每个环境的数据源配置、服务器端口等配置都会不同,如果在为不同环境打包时都要频繁修改配置文件的话,那必将是个非常繁琐且容易发生错误的事。

对于多环境的配置,各种项目构建工具或是框架的基本思路是一致的,即通过配置多份不同环境的配置文件,再通过打包命令指定需要打包的内容之后进行区分打包,Spring Boot 也不例外,或者说更加简单。在 Spring Boot 中,多环境配置文件名需要满足 application-{profile}.properties 的格式,其中 {profile} 对应你的环境标识,比如:

  • application-dev.properties:开发环境
  • application-test.properties:测试环境
  • application-prod.properties:生产环境

至于使用哪个运行环境,需要在 application.properties 文件中通过 spring.profiles.active 属性来设置,其值对应 {profile} 值。如:spring.profiles.active=test 就会加载 application-test.properties 配置文件内容(无论什么运行环境,application.properties 配置文件总是会被加载,然后才是加载对应环境下的配置文件,如果存在同名属性,则对应环境下的配置文件的属性值优先级更高);前面提到了,我们可以通过命令行参数的形式提供 springboot 的属性值,且命令行方式指定的属性值的优先级是最高的。

通常,application.properties 中配置通用的属性以及默认的属性,而在 application-{profile}.properties 中配置与环境相关的属性(可覆盖 application.properties 中的同名属性),然后在 application.properties 中设置 spring.profiles.active=dev 来指定默认的运行环境为开发环境,然后通过命令行参数形式,提供 --spring.profiles.active=prod 参数来切换为生产环境。

例子:在 src/main/resources 目录下,创建 3 个不同环境下的配置文件,且设置一个不同的 server.port 监听端口:

  • application-dev.properties:监听端口为 111
  • application-test.properties:监听端口为 222
  • application-prod.properties:监听端口为 333

在 application.properties 文件中设置默认的运行环境为 dev,也就是说,如果更改 spring.profiles.active 属性,那么默认就是监听 111 端口。

然后分别以下面三种形式运行我们的 springboot 应用:

  • java -jar test.jar:监听 111 端口
  • java -jar test.jar --spring.profiles.active=dev:监听 111 端口
  • java -jar test.jar --spring.profiles.active=test:监听 222 端口
  • java -jar test.jar --spring.profiles.active=prod:监听 333 端口

SpringBoot 打包为 War

第一步:修改 pom.xml,将 packaging 打包方式改为 war(默认为 jar):

<packaging>war</packaging>

第二步:修改 pom.xml,添加 spring-boot-starter-tomcat 依赖,将 scope 改为 provided:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-tomcat</artifactId>
  <scope>provided</scope>
</dependency>

完整 pom.xml 参考如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.zfl9.springboot</groupId>
  <artifactId>springboot-ssm</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <packaging>war</packaging>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.3.RELEASE</version>
  </parent>

  <properties>
    <java.version>1.8</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <optional>true</optional>
    </dependency>

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

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-tomcat</artifactId>
      <scope>provided</scope>
    </dependency>

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

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

    <dependency>
      <groupId>org.mybatis.spring.boot</groupId>
      <artifactId>mybatis-spring-boot-starter</artifactId>
      <version>2.0.0</version>
    </dependency>

    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

第三步:修改 Application 启动类,继承 SpringBootServletInitializer 初始化类,并重写 configure() 方法:

package com.zfl9;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

@SpringBootApplication
@MapperScan("com.zfl9.mapper")
public class Application extends SpringBootServletInitializer {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(Application.class);
    }
}

最后,执行 mvn clean package,将 target 目录下的 war 包拷贝到 Tomcat 的 webapps 目录下,打开浏览器测试吧。