SpringBoot学习笔记

学习时间:2022年1月20日

视频来源:黑马

1 基础篇

主要内容:

  • SpringBoot快速入门
  • SpringBoot基础配置
  • 基于SpringBoot整合SSMP

1.1 SpringBoot快速上手

SpringBoot是由Pivotal团队提供的全新框架,其设计目的是用来简化Spring应用的初始搭建以及开发过程。

1.1.1 IDEA联网版

下面使用SpringBoot技术快速构建一个SpringMVC的程序,通过这个过程体会简化二字的含义

① 构建步骤
  1. 新建一个空工程,然后创建新模块,选择Spring Initializr,并配置模块相关基础信息

image-20220120163913903

  1. 选择当前模块需要使用的技术集。因为是构建一个Spring MVC的程序,因此选择Spring Web。

image-20220120164229893

  1. 开发控制类
  • 项目结构

image-20220120164737157

  • 控制器代码
1
2
3
4
5
6
7
8
9
10
// Rest模式
@RestController
@RequestMapping("/books")
public class BookController {
@GetMapping
public String getById() {
System.out.println("SpringBoot is running...");
return "SpringBoot is running...";
}
}

入门案例制作的SpringMVC的控制器基于Rest风格开发,当然此处使用原始格式制作SpringMVC的程序也是没有问题的,上例中的@RestController与@GetMapping注解是基于Restful开发的典型注解。

关注:做到这里SpringBoot程序的最基础的开发已经做完了,现在就可以正常的运行Spring程序了。可能有些小伙伴会有疑惑,Tomcat服务器没有配置,Spring也没有配置,什么都没有配置这就能用吗?这就是SpringBoot技术的强大之处。

  1. 运行自动生成的Application类

image-20220120165231512

使用带main方法的java程序的运行形式来运行程序,运行完毕后,控制台输出上述信息。

不难看出,运行的信息中包含了8080的端口,Tomcat这种熟悉的字样,难道这里启动了Tomcat服务器?是的,这里已经启动了。那服务器没有配置,哪里来的呢?后面再说。现在你就可以通过浏览器访问请求的路径,测试功能是否工作正常了。

访问路径:http://localhost:8080/books

image-20220120165319286

image-20220120165350711

② 最简SpringBoot程序所包含的基础文件
  • pom文件:这是maven的配置文件,描述了当前工程构建时相应的配置信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!--需要关注的信息-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.hongyi</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>demo</description>
<properties>
<java.version>11</java.version>
</properties>
<!--需要关注的信息-->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

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

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

</project>

配置中有两个信息需要关注,一个是parent,也就是当前工程继承了另外一个工程,干什么用的后面再说,还有依赖坐标,干什么用的后面再说。

  • Application类:
1
2
3
4
5
6
7
8
@SpringBootApplication
public class DemoApplication {

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

}

这个类功能很简单,就一句代码,前面运行程序就是运行的这个类。

到这里我们可以大胆推测一下,如果上面这两个文件没有的话,SpringBoot肯定没法玩,看来核心就是这两个文件了。由于是制作第一个SpringBoot程序,先不要关注这两个文件的功能,后面详细讲解内部工作流程。

③ Spring程序与SpringBoot程序对比

通过上面的制作,我们不难发现,SpringBoot程序简直太好写了,几乎什么都没写,功能就有了,这也是SpringBoot技术为什么现在这么火的原因,和Spring程序相比,SpringBoot程序在开发的过程中各个层面均具有优势。

类配置文件 Spring SpringBoot
pom文件中的坐标 手工添加 勾选添加
web3.0配置类 手工制作
Spring/SpringMVC配置类 手工制作
控制器 手工制作 手工制作

一句话总结一下就是能少写就少写,能不写就不写,这就是SpringBoot技术给我们带来的好处,行了,现在你就可以动手做一做SpringBoot程序了,看看效果如何,是否真的帮助你简化开发了。

④ 小结
  1. 开发SpringBoot程序可以根据向导进行联网快速制作
  2. SpringBoot程序需要基于JDK8以上版本进行制作
  3. SpringBoot程序中需要使用何种功能通过勾选选择技术,也可以手工添加对应的要使用的技术(后期讲解)
  4. 运行SpringBoot程序通过运行Application程序入口进行

1.1.2 官网创建版

如果Idea不能正常联网,这个SpringBoot程序就无法制作了吗?开什么玩笑,世上IDE工具千千万,难道SpringBoot技术还必须基于Idea来做了?这是不可能的。开发SpringBoot程序,可以不基于任意的IDE工具进行,其实在SpringBoot的官网里面就可以直接创建SpringBoot程序。

① 步骤
  1. 进入页面https://start.spring.io/,下面是输入信息的过程,和前面的一样,只是界面变了而已,根据自己的要求,在左侧选择对应信息和输入对应的信息即可

image-20220120170753704

  1. 将生成的工程文件解压缩后放入刚才的工作空间中,并导入该模块

image-20220120170957133

  1. 新建控制器类,并运行
1
2
3
4
5
6
7
8
9
10
11
12
// Rest模式
@RestController
@RequestMapping("/books")
public class BookController {

@GetMapping
public String getById() {
System.out.println("SpringBoot is running...2");
return "SpringBoot is running...2";
}

}

image-20220120171446304

image-20220120171457231

② 小结

官网创建版和idea联网版效果是一样的。

1.1.3 阿里云版

前面提到网站如果被限制访问了,该怎么办?开动脑筋想一想,不管是方式一还是方式二其实都是走的同一个地方,也就是SpringBoot的官网创建的SpringBoot工程,那如果我们国内有这么一个网站能提供这样的功能,是不是就解决了呢?必然的嘛,新的问题又来了,这个国内的网站有吗?还真有,阿里提供了一个,下面问题就简单了,网址告诉我们就OK了。

① 步骤
  1. 创建工程时,切换选择starter服务路径,然后手工输入阿里云提供给我们的使用地址即可。地址:http://start.aliyun.com或https://start.aliyun.com

image-20220120171756251

  1. 其余步骤同1,2小节。此外,阿里为了便于自己开发使用,因此在依赖坐标中添加了一些阿里相关的技术,也是为了推广自己的技术吧,所以在依赖选择列表中,你有了更多的选择。不过有一点需要说清楚,阿里云地址默认创建的SpringBoot工程版本是2.4.1,所以如果你想更换其他的版本,创建项目后手工修改即可,别忘了刷新一下,加载新版本信息。

image-20220120172033980

  1. 注意:阿里云提供的工程创建地址初始化完毕后和使用SpringBoot官网创建出来的工程略有区别。主要是在配置文件pom.xml的形式上有区别。这个信息在后面讲解Boot程序的执行流程时给大家揭晓。
  2. 运行结果:

image-20220120172503883

image-20220120172514283

② 小结
  1. 选择start来源为自定义URL
  2. 输入阿里云start地址
  3. 创建项目

1.1.4 手工创建版

1.1.5 隐藏不必要的文件

创建SpringBoot工程时,使用SpringBoot向导也好,阿里云也罢,其实都是为了一个目的,得到一个标准的SpringBoot工程文件结构。这个时候就有新的问题出现了,标准的工程结构中包含了一些未知的文件夹,在开发的时候看起来特别别扭,这一节就来说说这些文件怎么处理。

image-20220120172946790

① 步骤
  1. 打开设置,【Files】→【Settings】

  2. 打开文件类型设置界面,【Editor】→【File Types】→【Ignored Files and Folders】,忽略文件或文件夹显示。

image-20220120173309871

  1. 添加你要隐藏的文件名称或文件夹名称,可以使用*号通配符,表示任意,设置完毕即可到这里就做完了,其实就是Idea的一个小功能。

  2. 执行结果:

image-20220120173408504

② 小结
  1. 【Files】→【Settings】
  2. 【Editor】→【File Types】→【Ignored Files and Folders】
  3. 输入要隐藏的名称,支持*号通配符
  4. 回车确认添加

1.2 SpringBoot简介

1.2.1 概述

SpringBoot是由Pivotal团队提供的全新框架,其设计目的是用来简化Spring应用的初始搭建以及开发过程。都简化了了哪些东西呢?其实就是针对原始的Spring程序制作的两个方面进行了简化。

Spring程序缺点

  • 依赖设置繁琐
    • 以前写Spring程序,使用的技术都要自己一个一个的写,现在不需要了,如果做过原始SpringMVC程序的小伙伴应该知道,写SpringMVC程序,最基础的spring-web和spring-webmvc这两个坐标时必须的。
  • 配置繁琐
    • 以前写配置类或者配置文件,然后用什么东西就要自己写加载bean这些东西,现在呢?什么都没写,照样能用

SpringBoot程序的核心功能及优点

  • 起步依赖(简化依赖配置)
    • 依赖配置的书写简化就是靠这个起步依赖达成的
  • 自动配置(简化常用工程相关配置)
    • 配置过于繁琐,使用自动配置就可以做响应的简化,但是内部还是很复杂的,后面具体展开说。
  • 辅助功能(内置服务器,……)
    • 除了上面的功能,其实SpringBoot程序还有其他的一些优势,比如我们没有配置Tomcat服务器,但是能正常运行,这是SpringBoot程序的一个可以感知到的功能,也是SpringBoot的辅助功能之一。

下面结合入门程序来说说这些简化操作都在哪些方面进行体现的,一共分为4个方面

  • parent
  • starter
  • 引导类
  • 内嵌tomcat

1.2.2 parent

SpringBoot关注到开发者在进行开发时,往往对依赖版本的选择具有固定的搭配格式,并且这些依赖版本的选择还不能乱搭配。比如A技术的2.0版与B技术的3.5版可以合作在一起,但是和B技术的3.7版合并使用时就有冲突。其实很多开发者都一直想做一件事情,就是将各种各样的技术配合使用的常见依赖版本进行收集整理,制作出了最合理的依赖版本配置方案,这样使用起来就方便多了。

① 版本依赖的管理

例如两个工程project-aproject-b的pom配置文件都是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency> 
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.16</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.6</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>

此时可以将版本号抽取出来,形成一个父工程project-dependencies,父工程的pom文件内容如下,即和上面的内容相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency> 
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.16</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.6</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>

对于a和b两个工程pom文件内容,则可以改写如下:

1
2
3
4
5
<dependency> 
<groupId>itheima</groupId>
<artifactId>project-dependencies</artifactId>
<version>1.1.10</version>
</dependency>

此时,父工程对版本的依赖性还是很强,于是又将各个依赖的版本抽取出来,再次形成一个父工程project-parent

  • project-dependencies的pom内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency> 
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
  • 整个工程的父工程project-parent的pom内容如下:
1
2
3
4
5
<properties> 
<druid.version>1.1.16</druid.version>
<mybatis.version>3.5.6</mybatis.version>
<mysql.version>5.1.47</mysql.version>
</properties>
  • 总的图示:

image-20220120205603536

② SpringBoot中的版本依赖管理

SpringBoot将所有的技术版本的常见使用方案都给开发者整理了出来,以后开发者使用时直接用它提供的版本方案,就不用担心冲突问题了,相当于SpringBoot做了无数个技术版本搭配的列表,这个技术搭配列表的名字叫做parent。

parent自身具有很多个版本,每个parent版本中包含有几百个其他技术的版本号,不同的parent间使用的各种技术的版本号有可能会发生变化。当开发者使用某些技术时,直接使用SpringBoot提供的parent就行了,由parent帮助开发者统一的进行各种技术的版本管理。

比如你现在要使用Spring配合MyBatis开发,没有parent之前怎么做呢?选个Spring的版本,再选个MyBatis的版本,再把这些技术使用时关联的其他技术的版本逐一确定下来。当你Spring的版本发生变化需要切换时,你的MyBatis版本有可能也要跟着切换,关联技术呢?可能都要切换,而且切换后还可能出现问题。现在这一切工作都可以交给parent来做了。你无需关注这些技术间的版本冲突问题,你只需要关注你用什么技术就行了,冲突问题由parent负责处理。

有人可能会提出来,万一parent给我导入了一些我不想使用的依赖怎么办?记清楚,这一点很关键,parent仅仅帮我们进行版本管理,它不负责帮你导入坐标,说白了用什么还是你自己定,只不过版本不需要你管理了。整体上来说,使用parent可以帮助开发者进行版本的统一管理

关注:parent定义出来以后,并不是直接使用的,仅仅给了开发者一个说明书,但是并没有实际使用,这个一定要确认清楚。

那SpringBoot又是如何做到这一点的呢?可以查阅SpringBoot的配置源码,看到这些定义

  • 项目中的pom.xml中继承了一个坐标
1
2
3
4
5
6
<parent>
<groupId>org.springframework.boot</groupId>
<!--指这里的artifactId-->
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.4</version>
</parent>

image-20220120210238870

  • 打开后可以查阅到其中又继承了一个坐标
1
2
3
4
5
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.5.4</version>
</parent>

image-20220120210332932

  • 这个坐标(spring-boot-dependencies)中定义了两组信息,第一组是各式各样的依赖版本号属性,下面列出依赖版本属性的局部,可以看的出来,定义了若干个技术的依赖版本号
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<properties>
<activemq.version>5.16.3</activemq.version>
<aspectj.version>1.9.7</aspectj.version>
<assertj.version>3.19.0</assertj.version>
<commons-codec.version>1.15</commons-codec.version>
<commons-dbcp2.version>2.8.0</commons-dbcp2.version>
<commons-lang3.version>3.12.0</commons-lang3.version>
<commons-pool.version>1.6</commons-pool.version>
<commons-pool2.version>2.9.0</commons-pool2.version>
<h2.version>1.4.200</h2.version>
<hibernate.version>5.4.32.Final</hibernate.version>
<hibernate-validator.version>6.2.0.Final</hibernate-validator.version>
<httpclient.version>4.5.13</httpclient.version>
<jackson-bom.version>2.12.4</jackson-bom.version>
<javax-jms.version>2.0.1</javax-jms.version>
<javax-json.version>1.1.4</javax-json.version>
<javax-websocket.version>1.1</javax-websocket.version>
<jetty-el.version>9.0.48</jetty-el.version>
<junit.version>4.13.2</junit.version>
</properties>

第二组是各式各样的的依赖坐标信息,可以看出依赖坐标定义中没有具体的依赖版本号,而是引用了第一组信息中定义的依赖版本属性值。同样只列出了部分内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>${hibernate.version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

关注:上面的依赖坐标定义是出现在<dependencyManagement>标签中的,其实是对引用坐标的依赖管理,并不是实际使用的坐标。因此当你的项目中继承了这组parent信息后,在不使用对应坐标的情况下,前面的这组定义是不会具体导入某个依赖的。

image-20220120210411568

关注:因为在maven中继承机会只有一次,上述继承的格式还可以切换成导入的形式进行,并且在阿里云的starter创建工程时就使用了此种形式。

1
2
3
4
5
6
7
8
9
10
11
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

image-20220120210537621

1.2.3 starter

① 概述

SpringBoot关注到开发者在实际开发时,对于依赖坐标的使用往往都有一些固定的组合方式,比如使用spring-webmvc就一定要使用spring-web。每次都要固定搭配着写,非常繁琐,而且格式固定,没有任何技术含量。

SpringBoot把所有的技术使用的固定搭配格式都给开发出来,以后用某个技术,就不用一次写一堆依赖了,开发者使用的时候,直接用SpringBoot做好的这个东西就好了,对于这样的固定技术搭配,SpringBoot给它起了个名字叫做starter。

starter定义了使用某种技术时对于依赖的固定搭配格式,也是一种最佳解决方案,使用starter可以帮助开发者减少依赖配置

  • 项目中的pom.xml定义了使用SpringMVC技术,但是并没有写SpringMVC的坐标,而是添加了一个名字中包含starter的依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • 在spring-boot-starter-web中又定义了若干个具体依赖的坐标
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>2.5.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
<version>2.5.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<version>2.5.4</version>
<scope>compile</scope>
</dependency>
<!--SpringMVC相关的依赖-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.3.9</version>
<scope>compile</scope>
</dependency>
<!--SpringMVC相关的依赖-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.3.9</version>
<scope>compile</scope>
</dependency>
</dependencies>

我们可以发现,starter中包含了若干个坐标,其实就是使用SpringMVC开发通常都会使用到Json,使用json又离不开这里面定义的这些坐标,看来还真是方便,SpringBoot把我们开发中使用的东西能用到的都给提前做好了。你仔细看完会发现,里面有一些你没用过的。的确会出现这种过量导入的可能性,没关系,可以通过maven中的排除依赖剔除掉一部分。

到这里基本上得到了一个信息,使用starter可以帮开发者快速配置依赖关系。以前写依赖3个坐标的,现在写导入一个starter就搞定了,它就是加速依赖配置的。

② starter与parent的区别
  • starter是一个坐标中定了若干个坐标,以前写多个的,现在写一个,是用来减少依赖配置的书写量的

  • parent是定义了几百个依赖版本号,以前写依赖需要自己手工控制版本,现在由SpringBoot统一管理,这样就不存在版本冲突了,是用来减少依赖冲突的

③ 实际开发应用方式
  • 实际开发中如果需要用什么技术,先去找有没有这个技术对应的starter

    • 如果有对应的starter,直接写starter,而且无需指定版本,版本由parent提供
    • 如果没有对应的starter,手写出坐标即可
  • 实际开发中如果发现坐标出现了冲突现象,确认你要使用的可行的版本号,使用手工书写的方式添加对应依赖,覆盖SpringBoot提供给我们的配置管理

    • 方式一:直接写坐标
    • 方式二:覆盖<properties>中定义的版本号,就是下面这堆东西了,哪个冲突了覆盖哪个就OK了
1
2
3
4
5
6
7
8
9
10
11
<!--在子工程中书写-->
<properties>
<activemq.version>5.16.3</activemq.version>
<aspectj.version>1.9.7</aspectj.version>
<assertj.version>3.19.0</assertj.version>
<commons-codec.version>1.15</commons-codec.version>
<commons-dbcp2.version>2.8.0</commons-dbcp2.version>
<commons-lang3.version>3.12.0</commons-lang3.version>
<commons-pool.version>1.6</commons-pool.version>
<commons-pool2.version>2.9.0</commons-pool2.version>
</properties>

starter格式

SpringBoot官方给出了好多个starter的定义,方便我们使用,而且名称都是如下格式:

1
命名规则:spring-boot-starter-技术名称

1.2.4 引导类

下面说一下程序是如何运行的。目前程序运行的入口就是SpringBoot工程创建时自带的那个类了,带有main方法的那个类,运行这个类就可以启动SpringBoot工程的运行。

1
2
3
4
5
6
7
// SpringBoot的引导类,是整个SpringBoot程序的入口
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

SpringBoot本身是为了加速Spring程序的开发的,而Spring程序运行的基础是需要创建自己的Spring容器对象(IoC容器)并将所有的对象交给Spring的容器管理,也就是一个一个的Bean。SpringBoot加速开发Spring程序,这个容器还在吗?这个疑问不用说,一定在。当前这个类运行后就会产生一个Spring容器对象,并且可以将这个对象保存起来,通过容器对象直接操作Bean。

1
2
3
4
5
6
7
8
9
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
// 获取IOC容器
ConfigurableApplicationContext context = SpringApplication.run(DemoApplication.class, args);
BookController bean = context.getBean(BookController.class);
System.out.println(bean);
}
}

运行结果:

image-20220123160913935

通过上述操作不难看出,其实SpringBoot程序启动还是创建了一个Spring容器对象。这个类在SpringBoot程序中是所有功能的入口,称这个类为引导类

作为一个引导类最典型的特征就是当前类上方声明了一个注解@SpringBootApplication

1.2.5 内嵌Tomcat

当前我们做的SpringBoot入门案例勾选了Spirng-web的功能,并且导入了对应的starter。

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

SpringBoot发现,既然你要做web程序,肯定离不开使用web服务器,这样吧,帮人帮到底,送佛送到西。我帮你搞一个web服务器,你要愿意用的,直接使用就好了,干脆我再多给你几种选择,你随便切换。万一你不想用我给你提供的,也行,你可以自己搞。

由于这个功能不属于程序的主体功能,可用可不用,于是乎SpringBoot将其定位成辅助功能,别小看这么一个辅助功能,它可是帮我们开发者又减少了好多的设置性工作。

① 内嵌Tomcat定义位置

打开查看web的starter导入了哪些东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>2.5.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
<version>2.5.4</version>
<scope>compile</scope>
</dependency>
<!--tomcat的依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<version>2.5.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.3.9</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.3.9</version>
<scope>compile</scope>
</dependency>
</dependencies>

第三个依赖就是这个tomcat对应的东西了,居然也是一个starter,再打开看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<dependencies>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<version>1.3.5</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>9.0.52</version>
<scope>compile</scope>
<exclusions>
<exclusion>
<artifactId>tomcat-annotations-api</artifactId>
<groupId>org.apache.tomcat</groupId>
</exclusion>
</exclusions>
</dependency>
<!--tomcat内嵌核心-->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>9.0.52</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>9.0.52</version>
<scope>compile</scope>
<exclusions>
<exclusion>
<artifactId>tomcat-annotations-api</artifactId>
<groupId>org.apache.tomcat</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

这里面有一个核心的坐标,tomcat-embed-core,叫做tomcat内嵌核心。就是这个东西把tomcat功能引入到了我们的程序中。目前解决了第一个问题,找到根儿了,谁把tomcat引入到程序中的?是spring-boot-starter-web中的spring-boot-starter-tomcat做的。之所以你感觉很奇妙的原因就是,这个东西是默认加入到程序中了,所以感觉很神奇,居然什么都不做,就有了web服务器对应的功能,再来说第二个问题,这个服务器是怎么运行的。

② 内嵌Tomcat运行原理

Tomcat服务器是一款软件,而且是一款使用java语言开发的软件,熟悉的小伙伴可能有印象,tomcat安装目录中保存有jar,好多个jar。

下面的问题来了,既然是使用java语言开发的,运行的时候肯定符合java程序运行的原理,java程序运行靠的是什么?对象呀,一切皆对象,万物皆对象。那tomcat运行起来呢?也是对象。

如果是对象,那Spring容器是用来管理对象的,这个对象能不能交给Spring容器管理呢?把吗去掉,是个对象都可以交给Spring容器管理,行了,这下通了。tomcat服务器运行其实是以对象的形式在Spring容器中运行的,怪不得我们没有安装这个tomcat,而且还能用。闹了白天这东西最后是以一个对象的形式存在,保存在Spring容器中悄悄运行的。具体运行的是什么呢?其实就是上前面提到的那个tomcat内嵌核心

那既然是个对象,如果把这个对象从Spring容器中去掉是不是就没有web服务器的功能呢?是这样的,通过依赖排除可以去掉这个web服务器功能。

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!--排除掉spring-boot-starter-tomcat的依赖-->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>

运行结果:

image-20220123162405060

发现并没有输出服务器运行的信息,并且SpringBoot程序还停止了。

③ 更换内嵌Tomcat

根据SpringBoot的工作机制,用什么技术,加入什么依赖就行了。SpringBoot提供了3款内置的服务器。

  • tomcat(默认):apache出品,粉丝多,应用面广,负载了若干较重的组件

  • jetty:更轻量级,负载性能远不及tomcat

  • undertow:负载性能勉强跑赢tomcat

例如替换为jetty服务器:

1
2
3
4
5
<!--替换为jetty服务器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>

运行结果:

image-20220123162655260

1.2.6 补充:REST开发

① 简介
  • REST:Representational State Transfer,表现形式状态转换
  • 传统风格资源描述形式
    • http://localhost/user/getById?id=1
    • http://localhost/user/saveUser
  • REST风格描述形式
    • http://localhost/user/1
    • http://localhost/user

优点

  • 隐藏资源的访问行为,无法通过地址得知对资源是何种操作
  • 书写简化

行为动作

按照REST风格访问资源时,使用行为动作区分对资源进行了何种操作

请求地址 操作 行为动作
http://localhost/users 查询全部用户信息 GET(查询)
http://localhost/users/1 查询指定用户信息 GET(查询)
http://localhost/users 添加用户信息 POST(新增/保存)
http://localhost/users 修改用户信息 PUT(修改/更新)
http://localhost/users/1 删除用户信息 DELETE(删除)

根据REST风格对资源进行访问称为RESTful

注意:

  • 上述行为是约定方式,约定不是规范,可以打破,所以称为REST风格,而不是REST规范。

  • 描述模块的名称通常使用复数,表示此类资源,而非单个资源。

② 入门案例

暂略

1.3 SpringBoot基础配置

1.3.1 准备——复制工程

步骤

  1. 准备一个工程模板,例如demo2,复制一份,并修改工程名称demo-template
  2. 删除demo-template下与IDEA相关配置文件,只保留src和pom.xml
  3. 另外复制一份demo-template,取名为demo_base_config
  4. 修改demo_base_config的pom.xml中的artifactId与工程名相同(即demo_base_config)(重要!),然后删除其中的name标签
  5. 保留备份工程demo-template,日后使用

image-20220123170220350

  1. 将工程导入demo_base_config模块并测试功能

image-20220123170319008

测试功能:

image-20220123170335602

1.3.2 属性配置

① 服务器端口配置

SpringBoot通过配置文件application.properties就可以修改默认的配置(通过键值对配置对应属性),那咱们就先找个简单的配置下手,当前访问tomcat的默认端口是8080,我们改成80,通过这个操作来熟悉一下SpringBoot的配置格式是什么样的。

image-20220123170455784

  • 修改application.properties
1
2
# 设置服务器的端口
server.port=80
  • 运行结果:

image-20220123170705366

注意:

  • SpringBoot程序可以在application.properties文件中进行属性配置
  • application.properties文件中只要输入要配置的属性关键字就可以根据提示进行设置
  • SpringBoot将配置信息集中在一个文件中写,不管你是服务器的配置,还是数据库的配置,总之都写在一起,逃离一个项目十几种配置文件格式的尴尬局面
② 其他配置
  • 例如关闭运行日志图表(banner)
1
spring.main.banner-mode=off
  • 设置banner图片
1
spring.banner.image.location=takao.png

image-20220123181424220

  • 设置运行日志的显示级别
1
logging.level.root=debug

SpringBoot内置属性查询

文档网址:https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#application-properties

image-20220123181530160

温馨提示

所有的starter中都会依赖下面这个starter,叫做spring-boot-starter。这个starter是所有的SpringBoot的starter的基础依赖,里面定义了SpringBoot相关的基础配置,关于这个starter我们到开发应用篇和原理篇中再深入讲解。

1
2
3
4
5
6
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>2.5.4</version>
<scope>compile</scope>
</dependency>

1.3.3 配置文件分类

① 三种配置格式

现在已经能够进行SpringBoot相关的配置了,但是properties格式的配置写起来总是觉得看着不舒服,所以就期望存在一种书写起来更简便的配置格式提供给开发者使用。SpringBoot除了支持properties格式的配置文件,还支持另外两种格式的配置文件。分别如下:

  1. properties格式(传统和默认格式)
  2. yml格式(主流)
  3. yaml格式

格式示例

  • application.properties(properties格式)
1
server.port = 80
  • application.yml(yml格式)
1
2
server:
port: 81
  • application.yaml(yaml格式)
1
2
server:
port: 82

仔细看会发现yml格式和yaml格式除了文件名后缀不一样,格式完全一样,是这样的,yml和yaml文件格式就是一模一样的,只是文件后缀不同,所以可以合并成一种格式来看。那对于这三种格式来说,以后用哪一种比较多呢?记清楚,以后基本上都是用yml格式的

② 配置文件的优先级
  1. 配置文件间的加载优先级 properties(最高)> yml > yaml(最低)

  2. 不同配置文件中相同配置按照加载优先级相互覆盖,不同配置文件中不同配置全部保留

  3. 以后的开发都使用yml格式

③ 自动提示功能消失的解决方案

下图中,application.ymlapplication.yaml并没有没IDEA识别为SpringBoot的配置文件(没有绿叶的标志),并且编辑文件时也没有代码提示功能。

image-20220123183316663

这个自动提示功能消失的原因还是蛮多的,如果想解决这个问题,就要知道为什么会消失,大体原因有如下2种:

  1. Idea认为你现在写配置的文件不是个配置文件,所以拒绝给你提供提示功能
  2. Idea认定你是合理的配置文件,但是Idea加载不到对应的提示信息

这里我们主要解决第一个现象,第二种现象到原理篇再讲解。第一种现象的解决方式如下:

步骤

  1. 打开设置,【Files】→【Project Structure…】
  2. 在弹出窗口中左侧选择【Facets】,右侧选中Spring路径下对应的模块名称,也就是你自动提示功能消失的那个模块。

image-20220123183542357

  1. 点击Customize Spring Boot按钮,此时可以看到当前模块对应的配置文件是哪些了。如果没有你想要称为配置文件的文件格式,就有可能无法弹出提示。选择添加配置文件,然后选中要作为配置文件的具体文件就OK了。

image-20220123183606760

image-20220123183636096

  1. 执行结果:

image-20220123183658927

发现已有绿叶的标志,并且有代码提示功能

1.3.4 yaml文件

① 概述

SpringBoot的配置以后主要使用yml结尾的这种文件格式,并且在书写时可以通过提示的形式加载正确的格式。但是这种文件还是有严格的书写格式要求的。下面就来说一下具体的语法格式。

YAML(YAML Ain't Markup Language),一种数据序列化格式。具有容易阅读、容易与脚本语言交互、以数据为核心,重数据轻格式的特点。常见的文件扩展名有两种:

  • .yml格式(主流)
  • .yaml格式

对于文件自身在书写时,具有严格的语法格式要求,具体如下:

  1. 大小写敏感
  2. 属性层级关系使用多行描述,每行结尾使用冒号结束
  3. 使用缩进表示层级关系,同层级左侧对齐,只允许使用空格(不允许使用Tab键)
  4. 属性值前面添加空格(属性名与属性值之间使用冒号+空格作为分隔)
  5. #号表示注释

示例

1
2
3
4
5
6
7
8
boolean: TRUE  						#TRUE,true,True,FALSE,false,False均可
float: 3.14 #6.8523015e+5,支持科学计数法
int: 123 #0b1010_0111_0100_1010_1110,支持二进制、八进制、十六进制
null: ~ #使用~表示null
string: HelloWorld #字符串可以直接书写
string2: "Hello World" #可以使用双引号包裹特殊字符
date: 2018-02-17 #日期必须使用yyyy-MM-dd格式
datetime: 2018-02-17T15:02:31+08:00 #时间和日期之间使用T连接,最后使用+代表时区

此外,yaml格式中也可以表示数组,在属性名书写位置的下方使用减号作为数据开始符号,每行书写一个数据,减号与数据间空格分隔:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 1.数组
subject:
- Java
- 前端
- 大数据
# 2.数组的缩略形式
subject: [Java, 前端, 大数据]

# 3.数组在属性值中的形式
enterprise:
name: itcast
age: 16
subject:
- Java
- 前端
- 大数据

# 4.对象数组格式一
users:
- name: Tom
age: 4
- name: Jerry
age: 5
# 5.对象数组格式二
users:
-
name: Tom
age: 4
-
name: Jerry
age: 5
# 6.对象数组缩略格式
users2: [ { name:Tom , age:4 } , { name:Jerry , age:5 } ]
② 读取单一数据

对于yaml文件中的数据,其实你就可以想象成这就是一个小型的数据库,里面保存有若干数据,每个数据都有一个独立的名字,如果你想读取里面的数据,肯定是支持的,下面就介绍3种读取数据的方式。

yaml中保存的单个数据,可以使用Spring中的注解直接读取,使用@Value可以读取单个数据,属性名引用方式:${一级属性名.二级属性名……}

image-20220123191451402

示例

1
2
3
4
5
6
7
8
9
country: china

province:
city: Chengdu

likes:
- game
- music
- sleep
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Rest模式
@RestController
@RequestMapping("/books")
public class BookController {

// 读取yaml中的单一数据
@Value("${country}")
private String country;

@Value("${province.city}")
private String city;

@Value("${likes[1]}")
private String likes1;

@GetMapping
public String getById() {
System.out.println("SpringBoot is running...");
System.out.println("country: " + country);
System.out.println("city: " + city);
System.out.println("likes[1]: " + likes1);
return "SpringBoot is running...";
}

}

执行结果:

image-20220123192523301

③ 数据引用

如果你在书写yaml数据时,经常出现如下现象,比如很多个文件都具有相同的目录前缀:

1
2
3
4
5
center:
dataDir: /usr/local/fire/data
tmpDir: /usr/local/fire/tmp
logDir: /usr/local/fire/log
msgDir: /usr/local/fire/msgDir

在配置文件中可以使用属性名引用方式引用属性

1
2
3
4
5
6
baseDir: /usr/local/fire
center:
dataDir: ${baseDir}/data
tmpDir: ${baseDir}/tmp
logDir: ${baseDir}/log
msgDir: ${baseDir}/msgDir

还有一个注意事项,在书写字符串时,如果需要使用转义字符,需要将数据字符串使用双引号包裹起来:

1
lesson: "Spring\tboot\nlesson"

示例

1
2
3
baseDir: c:\windows
# 使用${属性名}引用数据
tempDir: ${baseDir}\temp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/books")
public class BookController {

@Value("${tempDir}")
private String tempDir;

@GetMapping
public String getById() {
System.out.println("SpringBoot is running...");
System.out.println("tempDir: " + tempDir);
return "SpringBoot is running...";
}
}

执行结果:

image-20220123193200671

④ 读取全部数据

读取单一数据可以解决读取数据的问题,但是如果定义的数据量过大,这么一个一个书写肯定会累死人的,SpringBoot提供了一个对象,能够把所有的数据都封装到这一个对象中,这个对象叫做Environment,使用自动装配注解可以将所有的yaml数据封装到这个对象中。

  • 封装全部数据到Environment对象:

image-20220123193259266

数据封装到了Environment对象中,获取属性时,通过Environment的接口操作进行,具体方法是getProperties(String),参数填写属性名即可。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("/books")
public class BookController {

// 将配置文件的所有数据封装到env中
@Autowired
private Environment env;

@GetMapping
public String getById() {
System.out.println("SpringBoot is running...");
System.out.println("country: " + env.getProperty("country"));
System.out.println("city: " + env.getProperty("province.city"));
return "SpringBoot is running...";
}

}

执行结果:

image-20220123193629662

⑤ 读取对象数据

单一数据读取书写比较繁琐,全数据封装又封装的太厉害了,每次拿数据还要一个一个的getProperties,总之用起来都不是很舒服。由于Java是一个面向对象的语言,很多情况下,我们会将一组数据封装成一个对象。SpringBoot也提供了可以将一组yaml对象数据封装一个Java对象的操作。

首先定义一个对象,并将该对象纳入Spring管控的范围,也就是定义成一个bean,然后使用注解@ConfigurationProperties指定该对象加载哪一组yaml中配置的信息。

image-20220123205036069

示例

1
2
3
4
5
datasource:
driver: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost/springboot_db
username: root
password: 12345678

新建一个类MyDataSource:

1
2
3
4
5
6
7
8
9
10
@Data
@ToString
@Component
@ConfigurationProperties(prefix = "datasource")
public class MyDataSource {
private String driver;
private String url;
private String username;
private String password;
}

控制层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/books")
public class BookController {

@Autowired
private MyDataSource myDataSource;

@GetMapping
public String getById() {
System.out.println("SpringBoot is running...");
System.out.println(myDataSource);
System.out.println("driver: " + myDataSource.getDriver());
return "SpringBoot is running...";
}

}

执行结果:

image-20220123210303686

1.4 第三方技术整合

1.4.1 Junit整合

首先新建一个模块demo_junit,不添加任何外部依赖。新建接口和实现类BookDaoBookDaoImpl

1
2
3
public interface BookDao {
public void save();
}
1
2
3
4
5
6
7
@Repository
public class BookDaoImpl implements BookDao {
@Override
public void save() {
System.out.println("book is running...");
}
}

Spring整合Junit的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//加载spring整合junit专用的类运行器
@RunWith(SpringJUnit4ClassRunner.class)
//指定对应的配置信息
@ContextConfiguration(classes = SpringConfig.class)
public class AccountServiceTestCase {
//注入你要测试的对象
@Autowired
private AccountService accountService;
@Test
public void testGetById(){
//执行要测试的对象对应的方法
System.out.println(accountService.findById(2));
}
}

Springboot整合Junit的方式

在pom文件中,已经默认导入了junit相关的依赖:

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

因此直接在测试类中书写代码即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 表明这是一个测试类,并且加载DemoJunitApplication的配置文件
// 这样测试类能够获取到ioc容器
@SpringBootTest(classes = DemoJunitApplication.class)
class DemoJunitApplicationTests {

// 1.注入要测试的对象
@Autowired
private BookDao bookDao;

// 2.执行要测试对象的对应方法
@Test
void contextLoads() {
bookDao.save();
}

}

测试结果:

image-20220124152743091

  • @SpringBootTest注解:
    • 类型:测试类注解
    • 位置:测试类定义的上方
    • 作用:设置Junit加载的springboot启动类
    • 属性:class设置springboot启动类
    • 注意:如果测试类在SpringBoot启动类的包或子包中,可以省略启动类的设置,也就是省略classes的设定。如果不在包或子包中,却省略了启动类设置,会导致测试过程报错。
1
2
Test ignored.
java.lang.IllegalStateException: Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...) with your test

1.4.2 整合MyBatis

新建一个模块demo_mybatis,注意添加mybatis框架mysql驱动的依赖:

image-20220124153228063

① Spring中整合MyBatis
  • 导入坐标,MyBatis坐标不能少,Spring整合MyBatis还有自己专用的坐标,此外Spring进行数据库操作的jdbc坐标是必须的,剩下还有mysql驱动坐标,本例中使用了Druid数据源,这个倒是可以不要
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.16</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.6</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>
<!--1.导入mybatis与spring整合的jar包-->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>1.3.0</version>
</dependency>
<!--导入spring操作数据库必选的包-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.2.10.RELEASE</version>
</dependency>
</dependencies>
  • Spring核心配置
1
2
3
4
5
6
@Configuration
@ComponentScan("com.itheima")
@PropertySource("jdbc.properties")
public class SpringConfig {

}
  • MyBatis要交给Spring接管的bean
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//定义mybatis专用的配置类
@Configuration
public class MyBatisConfig {
//定义创建SqlSessionFactory对应的bean
@Bean
public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource){
//SqlSessionFactoryBean是由mybatis-spring包提供的,专用于整合用的对象
SqlSessionFactoryBean sfb = new SqlSessionFactoryBean();
//设置数据源替代原始配置中的environments的配置
sfb.setDataSource(dataSource);
//设置类型别名替代原始配置中的typeAliases的配置
sfb.setTypeAliasesPackage("com.itheima.domain");
return sfb;
}
//定义加载所有的映射配置
@Bean
public MapperScannerConfigurer mapperScannerConfigurer(){
MapperScannerConfigurer msc = new MapperScannerConfigurer();
msc.setBasePackage("com.itheima.dao");
return msc;
}

}
  • 数据源对应的bean,此处使用Druid数据源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class JdbcConfig {
@Value("${jdbc.driver}")
private String driver;
@Value("${jdbc.url}")
private String url;
@Value("${jdbc.username}")
private String userName;
@Value("${jdbc.password}")
private String password;

@Bean("dataSource")
public DataSource dataSource(){
DruidDataSource ds = new DruidDataSource();
ds.setDriverClassName(driver);
ds.setUrl(url);
ds.setUsername(userName);
ds.setPassword(password);
return ds;
}
}
  • 数据库连接信息(properties格式)
1
2
3
4
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/spring_db?useSSL=false
jdbc.username=root
jdbc.password=root
② SpringBoot整合MyBatis

步骤①:查看默认导入的相关依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

发现是mybatis-spring-boot-starter,与SpringBoot官方的命名方式刚好相反。此外测试的依赖starter也导入了进来。

注:starter命名规范

starter所属 命名规则 示例
官方提供 spring-boot-starter-技术名称 spring-boot-starter-test
第三方提供 第三方技术名称-spring-boot-starter druid-spring-boot-starter
第三方提供 第三方技术名称-boot-starter(第三方技术名称过长,简化命名) mybatis-plus-boot-starter

步骤②:配置数据源相关信息,没有这个信息你连接哪个数据库都不知道

1
2
3
4
5
6
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ssm_db
username: root
password: 12345678

步骤③:编写实体类、映射接口和测试类代码

1
2
3
4
5
6
7
8
@Data
@ToString
public class Book {
private Integer id;
private String type;
private String name;
private String description;
}
1
2
3
4
5
@Mapper
public interface BookDao {
@Select("select * from book where id = #{id}")
public Book getById(Integer id);
}
1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
class DemoMybatisApplicationTests {

@Autowired
private BookDao bookDao;

@Test
void contextLoads() {
System.out.println(bookDao.getById(1));
}

}

测试结果:

image-20220124155257658

1.4.3 整合MyBatisPlus

做完了两种技术的整合了,各位小伙伴要学会总结,我们做这个整合究竟哪些是核心?总结下来就两句话:

  • 导入对应技术的starter坐标
  • 根据对应技术的要求做配置

整合步骤

步骤①:导入对应的starter:

1
2
3
4
5
6
<!--手动导入mybatis-plus的依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.0</version>
</dependency>

截止目前,SpringBoot官网还未收录此坐标,而我们Idea创建模块时读取的是SpringBoot官网的Spring Initializr,所以也没有。如果换用阿里云的url创建项目可以找到对应的坐标。也可以在maven中央仓库中查找该坐标。

步骤②:配置数据源信息

步骤③:编写映射接口、测试类代码

核心在于Dao接口继承了一个BaseMapper的接口,这个接口中帮助开发者预定了若干个常用的API接口,简化了通用API接口的开发工作。

image-20220124161348871

1
2
3
4
@Mapper
public interface BookDao extends BaseMapper<Book> {

}

测试类:

1
2
3
4
5
6
7
8
@Test
void testGetAll() {
List<Book> books = bookDao.selectList(null);
for (Book book :
books) {
System.out.println(book);
}
}

image-20220124161418248

1.4.4 整合Druid

前面整合MyBatis和MP的时候,使用的数据源对象都是SpringBoot默认的数据源对象,下面我们手工控制一下,自己指定了一个数据源对象,Druid

在没有指定数据源时,我们的配置如下:

1
2
3
4
5
6
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/ssm_db?serverTimezone=Asia/Shanghai
username: root
password: root

此时虽然没有指定数据源,但是根据SpringBoot的德行,肯定帮我们选了一个它认为最好的数据源对象,这就是HiKari。通过启动日志可以查看到对应的身影。

image-20220124162952499

整合步骤

步骤①:导入对应的starter

1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.8</version>
</dependency>

步骤②:修改配置,在数据源配置中有一个type属性,专用于指定数据源类型

1
2
3
4
5
6
7
8
9
# 通用型的导入数据源
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ssm_db
username: root
password: 12345678
# 配置数据源
type: com.alibaba.druid.pool.DruidDataSource

注意,这种形式不推荐,下面这种形式更推荐

1
2
3
4
5
6
7
8
# druid整合后专用配置形式(推荐)
spring:
datasource:
druid:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ssm_db
username: root
password: 12345678

步骤③:测试

1
2
3
4
@Test
void contextLoads() {
System.out.println(bookDao.getById(3));
}

image-20220124163439227

这是我们做的第4个技术的整合方案,还是那两句话:导入对应starter,使用对应配置。没了,SpringBoot整合其他技术就这么简单粗暴。

1.5 SpringBoot整合综合案例

1.5.1 技术选型

  1. 实体类开发————使用Lombok快速制作实体类
  2. Dao开发————整合MyBatisPlus,制作数据层测试
  3. Service开发————基于MyBatisPlus进行增量开发,制作业务层测试类
  4. Controller开发————基于Restful开发,使用PostMan测试接口功能
  5. Controller开发————前后端开发协议制作
  6. 页面开发————基于VUE+ElementUI制作,前后端联调,页面数据处理,页面消息处理
    • 列表
    • 新增
    • 修改
    • 删除
    • 分页
    • 查询
  7. 项目异常处理
  8. 按条件查询————页面功能调整、Controller修正功能、Service修正功能

​ 可以看的出来,东西还是很多的,希望通过这个案例,各位小伙伴能够完成基础开发的技能训练。整体开发过程采用做一层测一层的形式进行,过程完整,战线较长,希望各位能跟进进度,完成这个小案例的制作。

1.5.2 模块创建

新建一个模块ssmp,在初始化模块时,添加mysql驱动,SpringWeb,Lombok技术。

之后手动导入mybatisplus,druid技术的依赖

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--手动导入mybatis-plus的依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.8</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

application.yml

1
2
server:
port: 80

1.5.3 实体类开发

本案例对应的模块表结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- ----------------------------
-- Table structure for tbl_book
-- ----------------------------
DROP TABLE IF EXISTS `tbl_book`;
CREATE TABLE `tbl_book` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`type` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`description` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 51 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of tbl_book
-- ----------------------------
INSERT INTO `tbl_book` VALUES (1, '计算机理论', 'Spring实战 第5版', 'Spring入门经典教程,深入理解Spring原理技术内幕');
INSERT INTO `tbl_book` VALUES (2, '计算机理论', 'Spring 5核心原理与30个类手写实战', '十年沉淀之作,手写Spring精华思想');
INSERT INTO `tbl_book` VALUES (3, '计算机理论', 'Spring 5 设计模式', '深入Spring源码剖析Spring源码中蕴含的10大设计模式');
INSERT INTO `tbl_book` VALUES (4, '计算机理论', 'Spring MVC+MyBatis开发从入门到项目实战', '全方位解析面向Web应用的轻量级框架,带你成为Spring MVC开发高手');
INSERT INTO `tbl_book` VALUES (5, '计算机理论', '轻量级Java Web企业应用实战', '源码级剖析Spring框架,适合已掌握Java基础的读者');
INSERT INTO `tbl_book` VALUES (6, '计算机理论', 'Java核心技术 卷I 基础知识(原书第11版)', 'Core Java 第11版,Jolt大奖获奖作品,针对Java SE9、10、11全面更新');
INSERT INTO `tbl_book` VALUES (7, '计算机理论', '深入理解Java虚拟机', '5个维度全面剖析JVM,大厂面试知识点全覆盖');
INSERT INTO `tbl_book` VALUES (8, '计算机理论', 'Java编程思想(第4版)', 'Java学习必读经典,殿堂级著作!赢得了全球程序员的广泛赞誉');
INSERT INTO `tbl_book` VALUES (9, '计算机理论', '零基础学Java(全彩版)', '零基础自学编程的入门图书,由浅入深,详解Java语言的编程思想和核心技术');
INSERT INTO `tbl_book` VALUES (10, '市场营销', '直播就该这么做:主播高效沟通实战指南', '李子柒、李佳琦、薇娅成长为网红的秘密都在书中');
INSERT INTO `tbl_book` VALUES (11, '市场营销', '直播销讲实战一本通', '和秋叶一起学系列网络营销书籍');
INSERT INTO `tbl_book` VALUES (12, '市场营销', '直播带货:淘宝、天猫直播从新手到高手', '一本教你如何玩转直播的书,10堂课轻松实现带货月入3W+');

image-20220129124041381

实体类

1
2
3
4
5
6
7
@Data
public class Book {
private Integer id;
private String type;
private String name;
private String description;
}

Lombok,一个Java类库,提供了一组注解,简化POJO实体类开发,SpringBoot目前默认集成了lombok技术,并提供了对应的版本控制,所以只需要提供对应的坐标即可,在pom.xml中添加lombok的坐标。

使用lombok可以通过一个注解@Data完成一个实体类对应的getter,setter,toString,equals,hashCode等操作的快速添加。对于构造器可以使用注解:

1
2
@NoArgsConstructor
@AllArgsConstructor

1.5.4 数据层开发

① 基础CRUD

数据层开发本次使用MyBatisPlus技术,数据源使用前面学习的Druid,学都学了都用上。

步骤①:导入MyBatisPlus与Druid对应的starter,当然mysql的驱动不能少

步骤②:配置数据库连接相关的数据源配置。注意MP技术默认的主键生成策略为雪花算法,生成的主键ID长度较大,和目前的数据库设定规则不相符,需要配置一下使MP使用数据库的主键生成策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# druid
spring:
datasource:
druid:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ssm_db
username: root
password: 12345678
# mybatis-plus
mybatis-plus:
global-config:
db-config:
# 设置表的前缀
table-prefix: tbl_
# 设置id的生成策略,采用数据库的自增策略
id-type: auto

步骤③:使用MP的标准通用接口BaseMapper加速开发,别忘了@Mapper和泛型的指定

1
2
3
4
@Mapper
public interface BookDao extends BaseMapper<Book> {

}

步骤④:制作测试类测试结果,这个测试类制作是个好习惯,不过在企业开发中往往都为加速开发跳过此步,且行且珍惜吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@SpringBootTest
public class BookDaoTest {

@Autowired
private BookDao bookDao;

@Test
void testGetById() {
System.out.println(bookDao.selectById(2));
}

@Test
void testSave() {
Book book = new Book();
book.setType("test");
book.setName("test123");
book.setDescription("123test");
bookDao.insert(book);
}

@Test
void testUpdate() {
Book book = new Book();
book.setId(51);
book.setType("test123");
book.setName("test123");
book.setDescription("123test");
bookDao.updateById(book);
}
}

注意这个测试类的位置:

image-20220129130144415

② 查看MP运行日志

SpringBoot整合MP的时候充分考虑到了这点,通过配置的形式就可以查阅执行期SQL语句,配置如下:

1
2
3
4
5
6
7
8
9
10
11
# mybatis-plus
mybatis-plus:
global-config:
db-config:
# 设置表的前缀
table-prefix: tbl_
# 设置id的生成策略,采用数据库的自增策略
id-type: auto
configuration:
# 开启日志,设置日志输出方式为标准输出
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

再来看运行结果,此时就显示了运行期执行SQL的情况。

image-20220129131202205

③ 分页功能

步骤①:定义MP拦截器,设置具体的分页拦截器,并将其设置为Spring管控的bean

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class MPConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
// 定义一个拦截器
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 设置一个具体的拦截器——分页拦截器
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}

上述代码第一行是创建MP的拦截器栈,这个时候拦截器栈中没有具体的拦截器,第二行是初始化了分页拦截器,并添加到拦截器栈中。如果后期开发其他功能,需要添加全新的拦截器,按照第二行的格式继续add进去新的拦截器就可以了。

步骤②:编写测试类

1
2
3
4
5
6
7
8
9
10
@Test
void testPage() {
IPage<Book> page = new Page<Book>(1, 5);
page = bookDao.selectPage(page, null);
System.out.println(page.getCurrent()); //当前页码值
System.out.println(page.getSize()); //每页显示数
System.out.println(page.getTotal()); //数据总量
System.out.println(page.getPages()); //总页数
System.out.println(page.getRecords()); //详细数据
}

其中selectPage方法需要传入一个封装分页数据的对象,可以通过new的形式创建这个对象,当然这个对象也是MP提供的。创建此对象时就需要指定分页的两个基本数据:

  • 当前显示第几页
  • 每页显示几条数据

可以通过创建Page对象时利用构造方法初始化这两个数据:

1
IPage page = new Page(2,5); // 表示每页包含5条数据,并获取第2页的数据

将该对象传入到查询方法selectPage后,可以得到查询结果,但是我们会发现当前操作查询结果返回值仍然是一个IPage对象,这又是怎么回事?

1
IPage page = bookDao.selectPage(page, null);

原来这个IPage对象中封装了若干个数据,而查询的结果作为IPage对象封装的一个数据存在的,可以理解为查询结果得到后,又塞到了这个IPage对象中,其实还是为了高度的封装,一个IPage描述了分页所有的信息。

执行结果

image-20220129133352109

④ 条件查询功能

除了分页功能,MP还提供有强大的条件查询功能。以往我们写条件查询要自己动态拼写复杂的SQL语句,现在简单了,MP将这些操作都制作成API接口,调用一个又一个的方法就可以实现各种套件的拼装。这里给大家普及一下基本格式,详细的操作还是到MP的课程中查阅吧。

下面的操作就是执行一个模糊匹配对应的操作,由like条件书写变为了like方法的调用:

1
2
3
4
5
6
7
8
@Test
void testGetBy() {
// 定义一个查询条件
QueryWrapper<Book> queryWrapper = new QueryWrapper<Book>();
// 查询条件为name字段like
queryWrapper.like("name", "Spring");
bookDao.selectList(queryWrapper);
}

其中QueryWrapper对象是一个用于封装查询条件的对象,该对象可以动态使用API调用的方法添加条件,最终转化成对应的SQL语句。第二句就是一个条件了,需要什么条件,使用QueryWapper对象直接调用对应操作即可。比如做大于小于关系,就可以使用ltgt方法,等于使用eq方法,等等。

执行结果

image-20220129134700450

这组API使用还是比较简单的,但是关于属性字段名的书写存在着安全隐患,比如查询字段name,当前是以字符串的形态书写的,万一写错,编译器还没有办法发现,只能将问题抛到运行器通过异常堆栈告诉开发者,不太友好。

MP针对字段检查进行了功能升级,全面支持Lambda表达式,就有了下面这组API。由QueryWrapper对象升级为LambdaQueryWrapper对象(推荐使用)。

1
2
3
4
5
6
7
8
@Test
void testGetBy2(){
String name = "1";
LambdaQueryWrapper<Book> lqw = new LambdaQueryWrapper<Book>();
// 书写形式:Book::getName
lqw.like(Book::getName,name);
bookDao.selectList(lqw);
}

此外,为了便于开发者动态拼写SQL,防止将null数据作为条件使用,MP还提供了动态拼装SQL的快捷书写方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testGetBy2() {
String name = null;
// 第二种查询条件——lambda表达式
LambdaQueryWrapper<Book> queryWrapper = new LambdaQueryWrapper<Book>();
// 为防止查询条件传过来的为null,添加一个判断条件
//方式一:JAVA代码控制
//if(name != null) lqw.like(Book::getName,name);

//方式二:API接口提供控制开关
// 如果name为null,则不进行where的拼接
queryWrapper.like(name != null,Book::getName, name);
bookDao.selectList(queryWrapper);
}

1.5.5 业务层开发

① 基本步骤

数据层开发告一段落,下面进行业务层开发,其实标准业务层开发很多初学者认为就是调用数据层,怎么说呢?这个理解是没有大问题的,更精准的说法应该是组织业务逻辑功能,并根据业务需求,对数据持久层发起调用。有什么差别呢?目标是为了组织出符合需求的业务逻辑功能,至于调不调用数据层还真不好说,有需求就调用,没有需求就不调用。

一个常识性的知识普及一下,业务层的方法名定义一定要与业务有关,例如登录操作

1
login(String username,String password);

而数据层的方法名定义一定与业务无关,是一定,不是可能,也不是有可能,例如根据用户名密码查询:

1
selectByUserNameAndPassword(String username,String password);

我们在开发的时候是可以根据完成的工作不同划分成不同职能的开发团队的。比如一个哥们制作数据层,他就可以不知道业务是什么样子,拿到的需求文档要求可能是这样的:

1
2
3
接口:传入用户名与密码字段,查询出对应结果,结果是单条数据
接口:传入ID字段,查询出对应结果,结果是单条数据
接口:传入离职字段,查询出对应结果,结果是多条数据

但是进行业务功能开发的哥们,拿到的需求文档要求差别就很大:

1
接口:传入用户名与密码字段,对用户名字段做长度校验,4-15位,对密码字段做长度校验,8到24位,对XXX字段做特殊字符校验,不允许存在空格,查询结果为对象。如果为null,返回BusinessException,封装消息码INFO_LOGON_USERNAME_PASSWORD_ERROR

  • 业务层接口定义如下:
1
2
3
4
5
6
7
8
public interface BookService {
Boolean save(Book book);
Boolean update(Book book);
Boolean delete(Integer id);
Book getById(Integer id);
List<Book> getAll();
IPage<Book> getPage(int currentPage, int pageSize);
}
  • 业务层实现类如下,转调数据层即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
public class BookServiceImpl implements BookService {

@Autowired
private BookDao bookDao;

@Override
public Boolean save(Book book) {
return bookDao.insert(book) > 0;
}

@Override
public Boolean update(Book book) {
return bookDao.updateById(book) > 0;
}

@Override
public Boolean delete(Integer id) {
return bookDao.deleteById(id) > 0;
}

@Override
public Book getById(Integer id) {
return bookDao.selectById(id);
}

@Override
public List<Book> getAll() {
return bookDao.selectList(null);
}

@Override
public IPage<Book> getPage(int currentPage, int pageSize) {
IPage<Book> page = new Page<>(currentPage, pageSize);
return bookDao.selectPage(page, null);
}
}
  • 测试类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@SpringBootTest
public class BookServiceTest {

@Autowired
private BookService bookService;

@Test
void testGetById() {
System.out.println(bookService.getById(4));
}

@Test
void testGetPage() {
IPage<Book> page = bookService.getPage(2, 5);
System.out.println(page.getCurrent()); //当前页码值
System.out.println(page.getSize()); //每页显示数
System.out.println(page.getTotal()); //数据总量
System.out.println(page.getPages()); //总页数
System.out.println(page.getRecords()); //详细数据
}
}

image-20220129172455590

② 业务层快速开发

其实MP技术不仅提供了数据层快速开发方案,业务层MP也给了一个通用接口,个人观点不推荐使用,凑合能用吧,其实就是一个封装+继承的思想,代码给出,实际开发慎用。

  • 业务层接口快速开发:
1
2
3
public interface IBookService extends IService<Book> {
// 可添加非通用操作API接口
}
  • 业务层接口实现类快速开发,关注继承的类需要传入两个泛型,一个是数据层接口,另一个是实体类
1
2
3
4
5
6
@Service
public class BookServiceImpl extends ServiceImpl<BookDao, Book> implements IBookService {
@Autowired
private BookDao bookDao;
// 可添加非通用操作API
}
  • 测试类:
1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
public class BookServiceTest {

@Autowired
private IBookService bookService;

@Test
void testGetById() {
System.out.println(bookService.getById(5));
}

}

总结

  1. 使用通用接口(ISerivce<T>)快速开发Service
  2. 使用通用实现类(ServiceImpl<M,T>)快速开发ServiceImpl
  3. 可以在通用接口基础上做功能重载或功能追加
  4. 注意重载时不要覆盖原始操作,避免原始提供的功能丢失

1.5.6 表现层开发

① 基本开发和测试

表现层开发使用Rest风格,利用Postman进行接口测试。

表现层代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@RestController
@RequestMapping("/books")
public class BookController {

@Autowired
private IBookService bookService;

@GetMapping
public List<Book> getAll() {
return bookService.list();
}

// 将参数封装到请求体中
@PostMapping
public Boolean save(@RequestBody Book book) {
return bookService.save(book);
}

@PutMapping
public Boolean update(@RequestBody Book book) {
return bookService.modify(book);
}

// 将参数封装到请求路径中
@DeleteMapping("{id}")
public Boolean delete(@PathVariable Integer id) {
return bookService.removeById(id);
}

@GetMapping("{id}")
public Book getById(@PathVariable Integer id) {
return bookService.getById(id);
}

@GetMapping("{currentPage}/{pageSize}")
public IPage<Book> getPage(@PathVariable int currentPage,@PathVariable int pageSize) {
return bookService.getPage(currentPage, pageSize);
}

}

业务层代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class BookServiceImpl extends ServiceImpl<BookDao, Book> implements IBookService {
@Autowired
private BookDao bookDao;

@Override
public Boolean modify(Book book) {
return bookDao.updateById(book) > 0;
}

@Override
public IPage<Book> getPage(int currentPage, int pageSize) {
IPage<Book> page = new Page<>(currentPage, pageSize);
bookDao.selectPage(page, null);
return page;
}
}

测试

只测试了分页功能:

image-20220131204227411

返回消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
"records": [
{
"id": 5,
"type": "计算机理论",
"name": "轻量级Java Web企业应用实战",
"description": "源码级剖析Spring框架,适合已掌握Java基础的读者"
},
{
"id": 6,
"type": "计算机理论",
"name": "Java核心技术 卷I 基础知识(原书第11版)",
"description": "Core Java 第11版,Jolt大奖获奖作品,针对Java SE9、10、11全面更新"
},
{
"id": 7,
"type": "计算机理论",
"name": "深入理解Java虚拟机",
"description": "5个维度全面剖析JVM,大厂面试知识点全覆盖"
},
{
"id": 8,
"type": "计算机理论",
"name": "Java编程思想(第4版)",
"description": "Java学习必读经典,殿堂级著作!赢得了全球程序员的广泛赞誉"
}
],
"total": 14,
"size": 4,
"current": 2,
"orders": [],
"optimizeCountSql": true,
"searchCount": true,
"countId": null,
"maxLimit": null,
"pages": 4
}

小结

  1. 基于Restful制作表现层接口
    • 新增:POST
    • 删除:DELETE
    • 修改:PUT
    • 查询:GET
  2. 接收参数
    • 实体数据:@RequestBody
    • 路径变量:@PathVariable
② 消息一致性处理

目前我们通过Postman测试后业务层接口功能时通的,但是这样的结果给到前端开发者会出现一个小问题。不同的操作结果所展示的数据格式差异化严重:

增删改操作结果

1
true

查询单个数据操作结果

1
2
3
4
5
6
{
    "id": 1,
    "type": "计算机理论",
    "name": "Spring实战 第5版",
    "description": "Spring入门经典教程"
}

查询全部数据操作结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
    {
        "id": 1,
        "type": "计算机理论",
        "name": "Spring实战 第5版",
        "description": "Spring入门经典教程"
    },
    {
        "id": 2,
        "type": "计算机理论",
        "name": "Spring 5核心原理与30个类手写实战",
        "description": "十年沉淀之作"
    }
]

每种不同操作返回的数据格式都不一样,而且还不知道以后还会有什么格式,这样的结果让前端人员看了是很容易让人崩溃的,必须将所有操作的操作结果数据格式统一起来,需要设计表现层返回结果的模型类,用于后端与前端进行数据格式统一,也称为前后端数据协议

表现层代码改造

  • 在controller包下新建一个包utils,并新建类R,用于封装后端向前端返回的数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
public class R {
// flag用于标识操作是否成功,data用于封装操作数据
private Boolean flag;
private Object data;

public R() {

}

public R(Boolean flag) {
this.flag = flag;
}

public R(Boolean flag, Object data) {
this.flag = flag;
this.data = data;
}
}
  • 表现层代码改造:将返回的结果都封装在R中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@RestController
@RequestMapping("/books")
public class BookController {

@Autowired
private IBookService bookService;

@GetMapping
public R getAll() {
return new R(true, bookService.list());
}

@PostMapping
public R save(@RequestBody Book book) {
return new R(bookService.save(book));
}

@PutMapping
public R update(@RequestBody Book book) {
return new R(bookService.modify(book));
}

@DeleteMapping("{id}")
public R delete(@PathVariable Integer id) {
return new R(bookService.removeById(id));
}

@GetMapping("{id}")
public R getById(@PathVariable Integer id) {
return new R(true, bookService.getById(id));
}

@GetMapping("{currentPage}/{pageSize}")
public R getPage(@PathVariable int currentPage,@PathVariable int pageSize) {
return new R(true, bookService.getPage(currentPage, pageSize));
}

}

小结

  1. 设计统一的返回值结果类型便于前端开发读取数据

  2. 返回值结果类型可以根据需求自行设定,没有固定格式

  3. 返回值结果模型类用于后端与前端进行数据格式统一,也称为前后端数据协议

1.5.7 前后端联通性测试

单体应用中,前端页面及其配置被放在resources下的static中:

image-20220131215100072

在进行具体的功能开发之前,先做联通性的测试,通过页面发送异步提交(axios),这一步调试通过后再进行进一步的功能开发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//钩子函数,VUE对象初始化完成后自动执行
created() {
// 调用查询全部数据的操作
this.getAll();
},
methods: {
//列表
getAll() {
// 发送异步请求
// 在控制台中显示所有数据
axios.get("/books").then((res) => {
console.log(res.data);
});
},
// code...
}

如果出现跨域请求错误,在controller类上添加注解:

1
@CrossOrigin(origins = "*", maxAge = 3600)

执行结果

image-20220131215357167

总结

  1. 单体项目中页面放置在resources/static目录下
  2. created钩子函数用于初始化页面时发起调用
  3. 页面使用axios发送异步请求获取数据后确认前后端是否联通

1.5.8 页面基础功能开发

① 列表功能(除分页)

列表功能主要操作就是加载完数据,将数据展示到页面上,此处要利用VUE的数据模型绑定,发送请求得到数据,然后页面上读取指定数据即可。

页面数据模型定义

1
2
3
4
data:{
dataList: [],//当前页要展示的列表数据
...
},

异步请求获取数据

1
2
3
4
5
6
7
8
getAll() {
// 发送异步请求
axios.get("/books").then((res) => {
console.log(res.data);
// 前面的data是res的属性,包含R中的flag和data
this.dataList = res.data.data;
});
}

这样在页面加载时就可以获取到数据,并且由VUE将数据展示到页面上了:

image-20220131220108306

② 添加功能

添加功能用于收集数据的表单是通过一个弹窗展示的,因此在添加操作前首先要进行弹窗的展示,添加后隐藏弹窗即可。因为这个弹窗一直存在,因此当页面加载时首先设置这个弹窗为不可显示状态,需要展示,切换状态即可。

默认状态

1
2
3
4
data:{
dialogFormVisible: false,//添加表单是否可见
...
},

切换为显示状态

1
2
3
4
5
6
7
//弹出添加窗口
handleCreate() {
// 打开弹层
this.dialogFormVisible = true;
// 重置表单
this.resetForm();
},

由于每次添加数据都是使用同一个弹窗录入数据,所以每次操作的痕迹将在下一次操作时展示出来,需要在每次操作之前清理掉上次操作的痕迹。

定义清理数据操作

1
2
3
4
5
//重置表单
resetForm() {
// 将表单置空
this.formData = {};
},

添加操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//添加
handleAdd () {
axios.post("/books", this.formData).then((res) => {
// 判断当前操作是否成功
if(res.data.flag) {
// 关闭弹层
this.dialogFormVisible = false;
this.$message.success("添加成功");
} else {
this.$message.error("添加失败");
}
}).finally(() => {
// 重新加载首页数据
this.getAll();
});
},
  1. 将要保存的数据传递到后台,通过post请求的第二个参数传递json数据到后台
  2. 根据返回的操作结果决定下一步操作
    • 如何是true就关闭添加窗口,显示添加成功的消息
    • 如果是false保留添加窗口,显示添加失败的消息
  3. 无论添加是否成功,页面均进行刷新,动态加载数据(对getAll操作发起调用)

取消添加操作

1
2
3
4
5
//取消
cancel(){
this.dialogFormVisible = false;
this.$message.info("当前操作取消");
},

总结

  1. 请求方式使用POST调用后台对应操作
  2. 添加操作结束后动态刷新页面加载数据
  3. 根据操作结果不同,显示对应的提示信息
  4. 弹出添加Div时清除表单数据
③ 删除功能

模仿添加操作制作删除功能,差别之处在于删除操作仅传递一个待删除的数据id到后台即可。

注意删除操作要添加提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 删除
handleDelete(row) {
this.$confirm("是否继续删除?", "提示", {type: "info"}).then(() => {
axios.delete("/books/" + row.id).then((res) => {
// 判断当前操作是否成功
if(res.data.flag) {
this.$message.success("删除成功");
} else {
this.$message.error("删除失败");
}
}).finally(() => {
// 重新加载首页数据
this.getAll();
});
}).catch(() =>{
this.$message.info("取消操作");
});
},
④ 修改功能

修改功能可以说是列表功能、删除功能与添加功能的合体。几个相似点如下:

  1. 页面也需要有一个弹窗用来加载修改的数据,这一点与添加相同,都是要弹窗

  2. 弹出窗口中要加载待修改的数据,而数据需要通过查询得到,这一点与查询全部相同,都是要查数据

  3. 查询操作需要将要修改的数据id发送到后台,这一点与删除相同,都是传递id到后台

  4. 查询得到数据后需要展示到弹窗中,这一点与查询全部相同,都是要通过数据模型绑定展示数据

  5. 修改数据时需要将被修改的数据传递到后台,这一点与添加相同,都是要传递数据

    所以整体上来看,修改功能就是前面几个功能的大合体

查询并展示数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//弹出编辑窗口
handleUpdate(row) {
axios.get("/books/" + row.id).then((res) => {
if(res.data.flag && res.data.data != null) {
this.dialogFormVisible4Edit = true;
this.formData = res.data.data;
} else {
this.$message.error("数据已不存在");
}
}).finally(() => {
// 重新加载首页数据
this.getAll();
});
},

修改操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//修改
handleEdit() {
axios.put("/books", this.formData).then((res) => {
// 判断当前操作是否成功
if(res.data.flag) {
// 关闭弹层
this.dialogFormVisible4Edit = false;
this.$message.success("修改成功");
} else {
this.$message.error("修改失败");
}
}).finally(() => {
// 重新加载首页数据
this.getAll();
});
},

取消操作的提示

1
2
3
4
5
6
//取消
cancel(){
this.dialogFormVisible = false;
this.dialogFormVisible4Edit = false;
this.$message.info("当前操作取消");
},

1.5.9 业务消息一致性处理

目前的功能制作基本上达成了正常使用的情况,什么叫正常使用呢?也就是这个程序不出BUG,如果我们搞一个BUG出来,你会发现程序马上崩溃掉。比如后台手工抛出一个异常,看看前端接收到的数据什么样子:

1
2
3
4
5
6
7
@GetMapping("{id}")
public R getById(@PathVariable Integer id) throws IOException {
if (true) {
throw new IOException();
}
return new R(true, bookService.getById(id));
}

利用postman测试该接口,获取到的结果:

1
2
3
4
5
6
{
    "timestamp": "2021-09-15T03:27:31.038+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/books/1"
}

面对这种情况,前端的同学又不会了,这又是什么格式?怎么和之前的格式不一样?

1
2
3
4
5
6
7
8
9
{
    "flag": true,
    "data":{
        "id": 1,
        "type": "计算机理论",
        "name": "Spring实战 第5版",
        "description": "Spring入门经典教程"
    }
}

看来不仅要对正确的操作数据格式做处理,还要对错误的操作数据格式做同样的格式处理

首先在当前的数据结果中添加消息字段,用来兼容后台出现的操作消息

1
2
3
4
5
6
7
8
@Data
public class R {
private Boolean flag;
private Object data;
private String msg; // 用于封装错误信息


}

然后在表现层做统一的异常处理,使用SpringMVC提供的异常处理器做统一的异常处理。在utils包下新建类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 作为SpringMVC的异常处理器
// 使用注解@RestControllerAdvice定义SpringMVC异常处理器用来处理异常的
@RestControllerAdvice
public class ProjectExceptionAdvice {

// 拦截所有的异常信息
@ExceptionHandler
public R doException(Exception e) {
// 记录日志
// 发送消息给运维
// 发送邮件给开发人员,ex对象发送给开发人员
e.printStackTrace();
return new R("服务器故障,请稍后再试!");
}
}

页面上得到数据后,先判定是否有后台传递过来的消息,标志就是当前操作是否成功,如果返回操作结果false,就读取后台传递的消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//添加
handleAdd () {
axios.post("/books", this.formData).then((res) => {
// 判断当前操作是否成功
if(res.data.flag) {
// 关闭弹层
this.dialogFormVisible = false;
this.$message.success("添加成功");
} else {
this.$message.error(res.data.msg);
}
}).finally(() => {
// 重新加载首页数据
this.getAll();
});
},

测试结果:

1
2
3
4
5
{
"flag": false,
"data": null,
"msg": "服务器故障,请稍后再试!"
}

1.5.10 页面其他功能开发

① 分页功能

分页功能的制作用于替换前面的查询全部,其中要使用到elementUI提供的分页组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!--分页组件-->
<div class="pagination-container">
<el-pagination
class="pagiantion"
<!--处理分页的方法-->
@current-change="handleCurrentChange"
<!--当前页-->
:current-page="pagination.currentPage"
<!--每页的数据量-->
:page-size="pagination.pageSize"
<!--按钮和显示-->
layout="total, prev, pager, next, jumper"
<!--总的数据量-->
:total="pagination.total">
</el-pagination>
</div>

为了配合分页组件,封装分页对应的数据模型:

1
2
3
4
5
6
7
8
data:{
pagination: {
//分页相关模型数据
currentPage: 1, //当前页码
pageSize:10, //每页显示的记录数
total:0, //总记录数
}
},

步骤①:修改查询全部功能为分页查询,通过路径变量传递页码信息参数,页面根据分页操作结果读取对应数据,并进行数据模型绑定:

1
2
3
4
5
6
7
8
9
10
//分页查询
getAll() {
// 发送异步请求
axios.get("/books/" + this.pagination.currentPage + "/" + this.pagination.pageSize).then((res) => {
// 获取数据的总数
this.pagination.total = res.data.data.total;
// records是后台根据前台的分页参数获取到的分页中的全部数据
this.dataList = res.data.data.records;
});
},

步骤②:对切换页码操作设置调用当前分页操作

1
2
3
4
5
6
7
//切换页码
handleCurrentChange(currentPage) {
// 修改页码值为当前选中的页码值
this.pagination.currentPage = currentPage;
// 执行分页查询
this.getAll();
},

删除功能的维护

由于使用了分页功能,当最后一页只有一条数据时,删除操作就会出现BUG,最后一页无数据但是独立展示,对分页查询功能进行后台功能维护,如果当前页码值大于最大页码值,重新执行查询。其实这个问题解决方案很多,这里给出比较简单的一种处理方案。

1
2
3
4
5
6
7
8
9
@GetMapping("{currentPage}/{pageSize}")
public R getPage(@PathVariable int currentPage,@PathVariable int pageSize) {
IPage<Book> page = bookService.getPage(currentPage, pageSize);
// 如果当前页码值大于总页码值,则重新执行查询操作,使用最大页码值作为当前页码值
if (currentPage > page.getPages()) {
page = bookService.getPage((int) page.getPages(), pageSize);
}
return new R(true, page);
}
② 条件查询

条件查询可以理解为分页查询的时候除了携带分页数据再多带几个数据的查询。这些多带的数据就是查询条件。比较一下不带条件的分页查询与带条件的分页查询差别之处,这个功能就好做了:

  • 页面封装的数据:带不带条件影响的仅仅是一次性传递到后台的数据总量,由传递2个分页相关的数据转换成2个分页数据加若干个条件

  • 后台查询功能:查询时由不带条件,转换成带条件,反正不带条件的时候查询条件对象使用的是null,现在换成具体条件,差别不大

  • 查询结果:不管带不带条件,出来的数据只是有数量上的差别,其他都差别,这个可以忽略

经过上述分析,看来需要在页面发送请求的格式方面做一定的修改,后台的调用数据层操作时发送修改,其他没有区别。

页面发送请求时,两个分页数据仍然使用路径变量,其他条件采用动态拼装url参数的形式传递。

页面封装查询条件字段

1
2
3
4
5
6
7
8
pagination: {//分页相关模型数据
currentPage: 1,//当前页码
pageSize:10,//每页显示的记录数
total:0,//总记录数
type: "",
name: "",
description: ""
}

页面添加查询条件字段对应的数据模型绑定名称:

1
2
3
4
5
6
7
<div class="filter-container">
<el-input placeholder="图书类别" v-model="pagination.type" style="width: 200px;" class="filter-item"></el-input>
<el-input placeholder="图书名称" v-model="pagination.name" style="width: 200px;" class="filter-item"></el-input>
<el-input placeholder="图书描述" v-model="pagination.description" style="width: 200px;" class="filter-item"></el-input>
<el-button @click="getAll()" class="dalfBut">查询</el-button>
<el-button type="primary" class="butT" @click="handleCreate()">新建</el-button>
</div>

将查询条件组织成url参数,添加到请求url地址中,这里可以借助其他类库快速开发,当前使用手工形式拼接,降低学习要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//分页查询
getAll() {
// 组织参数,拼接url请求地址
// 请求格式:XXXX/books/1/10?type=XXX&name=XXX&description=XXX
param = "?type=" + this.pagination.type;
param += "&name=" + this.pagination.name;
param += "&description=" + this.pagination.description;
// 发送异步请求
axios.get("/books/" + this.pagination.currentPage + "/" + this.pagination.pageSize + param).then((res) => {
// 获取数据的总数
this.pagination.total = res.data.data.total;
// records是后台根据前台的分页参数获取到的分页中的全部数据
this.dataList = res.data.data.records;
});
},

后台代码中定义实体类封查询条件:

1
2
3
4
5
6
7
8
9
@GetMapping("{currentPage}/{pageSize}")
public R getPage(@PathVariable int currentPage, @PathVariable int pageSize, Book book) {
IPage<Book> page = bookService.getPage(currentPage, pageSize, book);
// 如果当前页码值大于总页码值,则重新执行查询操作,使用最大页码值作为当前页码值
if (currentPage > page.getPages()) {
page = bookService.getPage((int) page.getPages(), pageSize, book);
}
return new R(true, page);
}

对应业务层接口与实现类进行修正:

1
IPage<Book> getPage(int currentPage, int pageSize, Book book);
1
2
3
4
5
6
7
8
9
10
@Override
public IPage<Book> getPage(int currentPage, int pageSize, Book book) {
LambdaQueryWrapper<Book> lqw = new LambdaQueryWrapper<Book>();
lqw.like(Strings.isNotEmpty(book.getType()), Book::getType, book.getType());
lqw.like(Strings.isNotEmpty(book.getName()), Book::getName, book.getName());
lqw.like(Strings.isNotEmpty(book.getDescription()), Book::getDescription, book.getDescription());
IPage<Book> page = new Page<>(currentPage, pageSize);
bookDao.selectPage(page, lqw);
return page;
}

2 运维篇

2.1 SpringBoot程序打包与运行

2.1.1 打包和运行指令

企业项目上线为了保障环境适配性会采用下面流程发布项目,这里不讨论此过程。

  1. 开发部门使用Git、SVN等版本控制工具上传工程到版本服务器
  2. 服务器使用版本控制工具下载工程
  3. 服务器上使用Maven工具在当前真机环境下重新构建项目
  4. 启动服务

继续说我们的打包和运行过程。所谓打包指将程序转换成一个可执行的文件(例如jar文件),所谓运行指不依赖开发环境执行打包产生的文件。上述两个操作都有对应的命令可以快速执行。

程序打包

SpringBoot程序是基于Maven创建的,在Maven中提供有打包的指令,叫做package。本操作可以在Idea环境下执行。

1
mvn package

打包后会产生一个与工程名类似的jar文件,其名称是由模块名+版本号+.jar组成的。

image-20220203210121019

打包好的文件:注意jar文件大小为30Mb左右

image-20220203210139334

程序运行

程序包打好以后,就可以直接执行了。在程序包所在路径下,执行指令。

1
java -jar 工程包名.jar

执行程序打包指令后,程序正常运行,与在Idea下执行程序没有区别。

image-20220203210308491

注意:在使用向导创建SpringBoot工程时,pom.xml文件中会有如下配置(maven的插件),这一段配置千万不能删除,否则打包后无法正常执行程序。这组配置决定了打包出来的程序包是否可以执行。

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

Linux下的运行命令

  • 后台运行并记录日志
1
nohup java -jar XXX.jar > XXX.log 2>&1 &
  • 查看进程
1
ps -ef | grep "java -jar"
  • 杀死进程
1
kill -9 pid

2.1.2 打包失败的处理

当注释掉上一节的maven插件再进行打包并运行时,发现报错:

image-20220203212024366

查看打包好的jar文件,发现大小只有1Mb左右:

image-20220203212142897

此外,观察两次打包后的程序包的差别,共有3处比较明显的特征:

  1. 打包后文件的大小不同
  2. 打包后所包含的内容不同
  3. 打包程序中个别文件内容不同
  • 不带配置:

image-20220203212250099

  • 带配置:

image-20220203212308101

内容也完全不一样,仅有一个目录是一样的,叫做META-INF。打开容量大的程序包中的BOOT-INF目录下的classes目录,我们发现其中的内容居然和容量小的程序包中的内容完全一样。

原来大的程序包中除了包含小的程序包中的所有内容,还有别的东西。都有什么呢?回到BOOT-INF目录下,打开lib目录,里面显示了很多个jar文件。

image-20220203212522462

仔细翻阅不难发现,这些jar文件都是我们制作这个工程时导入的坐标对应的文件。大概可以想明白了,SpringBoot程序为了让自己打包生成的程序可以独立运行,不仅将项目中自己开发的内容进行了打包,还把当前工程运行需要使用的jar包全部打包进来了。为什么这样做呢?就是为了可以独立运行。不依赖程序包外部的任何资源可以独立运行当前程序。这也是为什么大的程序包容量是小的程序包容量的30倍的主要原因。

再看看大程序包还有什么不同之处,在最外层目录包含一个org目录,进入此目录,目录名是org\springframework\boot\loader,在里面可以找到一个JarLauncher.class的文件,先记得这个文件。再看这套目录名,明显是一个Spring的目录名,为什么要把Spring框架的东西打包到这个程序包中呢?不清楚。

回到两个程序包的最外层目录,查看名称相同的文件夹META-INF下都有一个叫做MANIFEST.MF的文件,但是大小不同,打开文件,比较内容区别:

  • 小容量文件的MANIFEST.MF
1
2
3
4
5
Manifest-Version: 1.0
Implementation-Title: springboot_08_ssmp
Implementation-Version: 0.0.1-SNAPSHOT
Build-Jdk-Spec: 1.8
Created-By: Maven Jar Plugin 3.2.0
  • 大容量文件的MANIFEST.MF
1
2
3
4
5
6
7
8
9
10
11
12
Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Implementation-Title: springboot_08_ssmp
Implementation-Version: 0.0.1-SNAPSHOT
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Start-Class: com.itheima.SSMPApplication # 引导类
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.5.4
Created-By: Maven Jar Plugin 3.2.0
Main-Class: org.springframework.boot.loader.JarLauncher # jar启动器

大文件中明显比小文件中多了几行信息,其中最后一行信息是Main-Class: org.springframework.boot.loader.JarLauncher。这句话什么意思呢?如果使用java -jar执行此程序包,将执行Main-Class属性配置的类,这个类恰巧就是前面看到的那个文件。原来SpringBoot打包程序中出现Spring框架的东西是为这里服务的。而这个org.springframework.boot.loader.JarLauncher类内部要查找Start-Class属性中配置的类,并执行对应的类。这个属性在当前配置中也存在,对应的就是我们的引导类类名。

现在这组设定的作用就搞清楚了——要能是SpringBoot程序打包后能够独立运行

  1. SpringBoot程序添加配置后会打出一个特殊的包,包含Spring框架部分功能,原始工程内容,原始工程依赖的jar包
  2. 首先读取MANIFEST.MF文件中的Main-Class属性,用来标记执行java -jar命令后运行的类
  3. JarLauncher类执行时会找到Start-Class属性,也就是启动类类名
  4. 运行启动类时会运行当前工程的内容
  5. 运行当前工程时会使用依赖的jar包,从lib目录中查找

总之一句话:spring-boot-maven-plugin插件用于将当前程序打包成一个可以独立运行的程序包

2.1.3 端口占用及解决

在DOS环境下启动SpringBoot工程时,可能会遇到端口占用的问题。以下命令用于杀死占用某个端口的进程。

1
2
3
4
5
6
7
8
9
10
# 查询端口
netstat -ano
# 查询指定端口 推荐
netstat -ano | findstr ":端口号"
# 根据进程PID查询进程名称
tasklist |findstr "进程PID号"
# 根据PID杀死任务 推荐
taskkill /F /PID "进程PID号"
# 根据进程名称杀死任务
taskkill -f -t -im "进程名称"

示例——杀死运行在80端口的java进程

  • 查看80端口进程

image-20220203213604436

  • 杀死进程

image-20220203213658107

2.2 高级配置

2.2.1 临时属性配置

SpringBoot提供了灵活的配置方式,如果你发现你的项目中有个别属性需要重新配置,可以使用临时属性的方式快速修改某些配置。方法也特别简单,在启动的时候添加上对应参数就可以了。

例如,要求项目临时在80端口启动,则:

1
java –jar springboot.jar --server.port=80

如果要修改的属性不止一个,可以按照上述格式继续写,属性与属性之间使用空格分隔。

1
java –jar springboot.jar --server.port=80 --logging.level.root=debug
① 属性加载优先级

现在我们的程序配置受两个地方控制了,第一配置文件,第二临时属性。并且我们发现临时属性的加载优先级要高于配置文件的。那是否还有其他的配置方式呢?其实是有的,而且还不少,打开官方文档中对应的内容,就可以查看配置读取的优先顺序。

官网地址: https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-external-config

image-20220212194153758

② 开发环境中使用临时属性

临时使用目前是有了,但是上线的时候通过命令行输入的临时属性必须是正确的,那这些属性配置值我们必须在开发环境中测试好才行。下面说一下开发环境中如何使用临时属性,其实就是Idea界面下如何操作了。

打开SpringBoot引导类的运行界面,在里面找到配置项。其中Program arguments对应的位置就是添加临时属性的,可以加几个试试效果。

image-20220212195725316

注意这里的Program arguments是传递给了主启动类的main方法的String[] args形参,可以打印出来看一下:

1
2
3
4
5
6
7
8
9
@SpringBootApplication
public class SsmpApplication {

public static void main(String[] args) {
System.out.println("args: " + Arrays.toString(args));
SpringApplication.run(SsmpApplication.class, args);
}

}

image-20220212200011907

所以,这里如果不用这个args是不是就断开了外部传递临时属性的入口呢?是这样的,我们可以使用下面的调用方式,这样外部临时属性就无法进入到SpringBoot程序中了。

1
2
3
4
5
6
7
8
9
@SpringBootApplication
public class SsmpApplication {

public static void main(String[] args) {
// 可以在启动boot程序时断开读取外部临时配置对应的入口
SpringApplication.run(SsmpApplication.class);
}

}

此外,还可以这样玩:(本质上args就是字符串数组)

1
2
3
4
5
6
public static void main(String[] args) {
String[] arg = new String[1];
arg[0] = "--server.port=8082";
// 传入自己的arg
SpringApplication.run(SSMPApplication.class, arg);
}

2.2.2 配置文件分类

SpringBoot提供了配置文件和临时属性的方式来对程序进行配置。前面一直说的是临时属性,这一节要说说配置文件了。其实这个配置文件我们一直在使用,只不过我们用的是SpringBoot提供的4级配置文件中的其中一个级别。

4个级别分别是:

  • 类路径下配置文件(一直使用的是这个,也就是resources目录中的application.yml文件)classpath:application.yml
    • 服务于开发人员本机开发与测试。
  • 类路径下config目录下配置文件classpath:config/application.yml
    • 服务于项目经理整体调控
  • 程序打包后所在目录中配置文件file:application.yml
    • 服务于运维人员配置涉密线上环境
  • 程序打包后所在目录中config目录下配置文件file:config/application.yml
    • 服务于运维经理整体调控

其优先级从低到高。后者与前者相同的内容,后者覆盖前者。即:多层级配置文件间的属性采用叠加并覆盖的形式作用于程序

示例

在类路径下新建文件夹config,并新建配置文件application.yml

image-20220219105028290

1
2
server:
port: 81

resource下的配置文件内容为:

1
2
server:
port: 80

启动项目:

image-20220219105206431

可见前者覆盖了后者

2.2.3 自定义配置文件

之前做配置使用的配置文件都是application.yml,其实这个文件也是可以改名字的,这样方便维护。比如我2020年4月1日搞活动,走了一组配置,2020年5月1日活动取消,恢复原始配置,这个时候只需要重新更换一下配置文件就可以了。但是你总不能在原始配置文件上修改吧,不然搞完活动以后,活动的配置就留不下来了,不利于维护。

在resource下新建配置文件ebank.propertiesebank.yml

自定义配置文件方式有如下两种:

方式①:使用临时属性设置Program arguments配置文件名,注意仅仅是名称,不要带扩展名

1
--spring.congih.name=ebank

方式②:使用临时属性设置配置文件路径,这个是全路径名,此时要加扩展名

1
--spring.config.location=classpath:/ebank.properties

也可以设置加载多个配置文件

1
--spring.config.location=classpath:/ebank.properties,classpath:/ebank.yml

注意

我们现在研究的都是SpringBoot单体项目,就是单服务器版本。其实企业开发现在更多的是使用基于SpringCloud技术的多服务器项目。这种配置方式和我们现在学习的完全不一样,所有的服务器将不再设置自己的配置文件,而是通过配置中心获取配置,动态加载配置信息。为什么这样做?集中管理,便于维护。

2.3 多环境开发

什么是多环境?其实就是说你的电脑上写的程序最终要放到别人的服务器上去运行。每个计算机环境不一样,这就是多环境。常见的多环境开发主要兼顾3种环境设置,开发环境——自己用的,测试环境——自己公司用的,生产环境——甲方爸爸用的。因为这是绝对不同的三台电脑,所以环境肯定有所不同,比如连接的数据库不一样,设置的访问端口不一样等等。

image-20220219111514281

2.3.1 yml单一文件版

新建模块demo_profile,并向配置文件写入内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 应用环境
spring:
profiles:
active: pro
# ---为配置的边界
---
# 生产
spring:
profiles: pro
server:
port: 80
---
# 开发
spring:
profiles: dev
server:
port: 81
---
# 测试
spring:
config:
activate:
on-profile: test
server:
port: 82

其中测试的配置格式是推荐的,前两个的格式已经过时。

总结:

  1. 多环境开发需要设置若干种常用环境,例如开发、生产、测试环境
  2. yaml格式中设置多环境使用---区分环境设置边界
  3. 每种环境的区别在于加载的配置属性不同
  4. 启用某种环境时需要指定启动时使用该环境

2.3.2 yml多文件版

将所有的配置都放在一个配置文件中,尤其是每一个配置应用场景都不一样,这显然不合理,于是就有了将一个配置文件拆分成多个配置文件的想法。拆分后,每个配置文件中写自己的配置,主配置文件中写清楚用哪一个配置文件就好了。

将原来的配置文件备份,并新建四个配置文件,分别为主配置文件和三个不同环境的配置文件:

image-20220219114008272

  • 主配置文件
1
2
3
spring:
profiles:
active: pro # 启动pro环境
  • 环境配置文件,例如application-dev.yml
1
2
server:
port: 81

环境配置文件因为每一个都是配置自己的项,所以连名字都不用写里面了。那问题是如何区分这是哪一组配置呢?使用文件名区分

文件的命名规则为:application-环境名.yml

总结:

  • 主配置文件中设置公共配置(全局)
  • 环境分类配置文件中常用于设置冲突属性(局部)
  • 可以使用独立配置文件定义环境属性
  • 独立配置文件便于线上系统维护更新并保障系统安全性

2.3.3 多环境开发独立配置文件书写技巧

将所有的配置根据功能对配置文件中的信息进行拆分,并制作成独立的配置文件,命名规则如下。

  • application-devDB.yml
  • application-devRedis.yml
  • application-devMVC.yml
  • 即按照环境和配置文件的功能进行命名

使用

在主配置文件中,使用include属性在激活指定环境的情况下,同时对多个环境进行加载使其生效,多个环境间使用逗号分隔。

1
2
3
4
spring:
profiles:
active: dev
include: decMVC, devDB

注意:当主环境dev与其他环境有相同属性时,主环境属性生效;其他环境中有相同属性时,最后加载的环境属性生效,例如devDB会覆盖decMVC中相同的配置。

改进

但是上面的设置也有一个问题,比如我要切换dev环境为pro时,include也要修改。因为include属性只能使用一次,这就比较麻烦了。SpringBoot从2.4版开始使用group属性替代include属性,降低了配置书写量。

1
2
3
4
5
6
spring:
profiles:
active: dev
group:
"dev": devDB, devMVC
"pro": proDB, proMVC

此时,只需修改dev为pro即可。

2.3.4 多环境开发控制

最后说一个冲突问题,就是maven和SpringBoot同时设置多环境的话怎么搞。

要想处理这个冲突问题,要先理清一个关系,究竟谁在多环境开发中其主导地位。也就是说如果现在都设置了多环境,谁的应该是保留下来的,另一个应该遵从相同的设置。

maven是做什么的?项目构建管理的,最终生成代码包的,SpringBoot是干什么的?简化开发的。简化,又不是其主导作用。最终还是要靠maven来管理整个工程,所以SpringBoot应该听maven的。整个确认后下面就好做了。大体思想如下:

  • 先在maven环境中设置用什么具体的环境
  • 在SpringBoot中读取maven设置的环境即可

maven中设置多环境(使用属性方式区分环境)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<profiles>
<!--开发环境-->
<profile>
<!--id任意,见名知意-->
<id>env_dev</id>
<properties>
<profile.active>dev</profile.active>
</properties>
<activation>
<!--默认启动环境-->
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<!--生产环境-->
<profile>
<id>env_pro</id>
<properties>
<profile.active>pro</profile.active>
</properties>
</profile>
</profiles>

SpringBoot中读取maven设置值,指定环境

1
2
3
4
5
spring:
profiles:
# 用maven的配置来指定环境,而不是在springboot的配置中指定
# 指定默认启动环境
active: @profile.active@

总结:

  1. 当Maven与SpringBoot同时对多环境进行控制时,以Mavn为主,SpringBoot使用@..@占位符读取Maven对应的配置属性值
  2. 基于SpringBoot读取Maven配置属性的前提下,如果在Idea下测试工程时pom.xml每次更新需要手动compile方可生效

2.4 日志

2.4.1 日志框架

  • Log4j 2:Apache Log4j 2 是一个功能强大的日志框架,支持异步日志和各种配置格式。
  • Logback:这是 Spring Boot 默认的日志框架,由 Log4j 的作者开发,性能优越且配置灵活。
  • SLF4J (Simple Logging Facade for Java):这是一个日志门面框架,它本身并不负责日志记录,而是将日志调用转发给具体的日志实现。SLF4J 常用于将不同的日志框架(如 LogbackLog4j 2)结合在一起使用。

2.4.2 SLF4J

SLF4J日志的使用格式非常固定,直接上操作步骤:

步骤①:添加日志记录操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Rest模式
@RestController
@RequestMapping("/books")
public class BookController {
// 创建记录日志的对象 slf4j
private static final Logger log = LoggerFactory.getLogger(BookController.class);

@GetMapping
public String getById() {
System.out.println("SpringBoot is running...2");
log.debug("debug...");
log.info("info...");
log.warn("warn...");
log.error("error...");
return "SpringBoot is running...2";
}

}

上述代码中log对象就是用来记录日志的对象,下面的log.debuglog.info这些操作就是写日志的API了。

image-20220219133955516

步骤②:设置日志输出级别

日志设置好以后可以根据设置选择哪些参与记录。这里是根据日志的级别来设置的。日志的级别分为6种,分别是:

  • TRACE:运行堆栈信息,使用率低
  • DEBUG:程序员调试代码使用
  • INFO:记录运维过程数据
  • WARN:记录运维过程报警数据
  • ERROR:记录错误堆栈信息
  • FATAL:灾难信息,合并计入ERROR

一般情况下,开发时候使用DEBUG,上线后使用INFO,运维信息记录使用WARN即可。下面就设置一下日志级别:

1
2
# 开启debug模式,输出调试信息,常用于检查系统运行状况
debug: true

这么设置太简单粗暴了,日志系统通常都提供了细粒度的控制

1
2
3
4
5
6
7
# 开启debug模式,输出调试信息,常用于检查系统运行状况
debug: true

# 设置日志级别,root表示根节点,即整体应用日志级别
logging:
level:
root: debug

还可以再设置更细粒度的控制

步骤③:设置日志组,控制指定包对应的日志输出级别,也可以直接控制指定包对应的日志输出级别

1
2
3
4
5
6
7
8
9
10
11
logging:
# 设置日志组
group:
# 自定义组名,设置当前组中所包含的包
ebank: com.itheima.controller
level:
root: warn
# 为对应组设置日志级别
ebank: debug
# 为对包设置日志级别
com.itheima.controller: debug

优化

写代码的时候每个类都要写创建日志记录对象,这个可以优化一下,使用前面用过的lombok技术给我们提供的工具类即可。

1
2
3
4
5
6
@Slf4j		//这个注解替代了下面那一行
@RestController
@RequestMapping("/books")
public class BookController extends BaseClass{
// private static final Logger log = LoggerFactory.getLogger(BookController.class);
}

2.4.2 日志输出格式控制

image-20220219134645273

  • PID:进程ID,用于表明当前操作所处的进程,当多服务同时记录日志时,该值可用于协助程序员调试程序
  • 所属类/接口名:当前显示信息为SpringBoot重写后的信息,名称过长时,简化包名书写为首字母,甚至直接删除

对于单条日志信息来说,日期,触发位置,记录信息是最核心的信息。级别用于做筛选过滤,PID与线程名用于做精准分析。了解这些信息后就可以DIY日志格式了。

下面给出课程中模拟的官方日志模板的书写格式,便于大家学习。

设置日志输出格式

1
2
3
logging:
pattern:
console: "%d - %m%n"

其中:

  • %d: 日期
  • %m: 消息
  • %n: 换行

image-20220219135337538

官方日志格式

1
2
3
logging:
pattern:
console: "%d %clr(%p) --- [%16t] %clr(%-40.40c){cyan} : %m %n"
  • %p: 日志级别
  • %clr(...){...}: 彩色标注,前面是标注的内容,后面是颜色类型,例如cyan青色
  • %t:线程名
  • %c:类名

2.4.3 日志文件——与Logback结合使用

对于日志文件的使用存在各种各样的策略,例如每日记录,分类记录,报警后记录等。这里主要研究日志文件如何记录。

① yml格式

记录日志到文件中格式非常简单,设置日志文件名即可。

1
2
3
logging:
file:
name: server.log

生成的日志文件注意是在项目上一层文件夹中:

image-20220219140431369

image-20220219140447166

虽然使用上述格式可以将日志记录下来了,但是面对线上的复杂情况,一个文件记录肯定是不能够满足运维要求的,通常会每天记录日志文件,同时为了便于维护,还要限制每个日志文件的大小。下面给出日志文件的常用配置方式:

1
2
3
4
5
logging:
logback:
rollingpolicy:
max-file-size: 3KB
file-name-pattern: server.%d{yyyy-MM-dd}.%i.log

以上格式是基于logback日志技术设置每日日志文件的设置格式,要求容量到达3KB以后就转存信息到第二个文件中。

文件命名规则中的%d标识日期,%i是一个递增变量,用于区分日志文件。

② xml设置

在根目录下新建logback.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- property: 属性,使用${}引用 -->
<!-- 日志存放路径,这里是linux目录 -->
<property name="log.path" value="/home/airport/logs" />
<!-- 日志输出格式 -->
<property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />

<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
</appender>

<!-- 系统日志输出 -->
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-info.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>

<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-error.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>ERROR</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>

<!-- 用户访问日志输出 -->
<appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-user.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 按天回滚 daily -->
<fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
</appender>

<!-- 系统模块日志级别控制 -->
<logger name="com.airport" level="info" />
<!-- Spring日志级别控制 -->
<logger name="org.springframework" level="warn" />

<root level="info">
<appender-ref ref="console" />
</root>

<!--系统操作日志-->
<root level="info">
<appender-ref ref="file_info" />
<appender-ref ref="file_error" />
</root>

<!--系统用户操作日志-->
<logger name="sys-user" level="info">
<appender-ref ref="sys-user"/>
</logger>
</configuration>

3 开发篇

3.1 热部署

3.1.1 手动启动热部署

步骤

步骤①:在pom文件中引入开发者工具的坐标:

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

步骤②:激活热部署ctrl + F9

image-20220220172526829

关于热部署

  • 重启restart:自定义开发代码,包含类、页面、配置文件等,加载位置restart类加载器
  • 重载reload:jar包,加载位置base类加载器,例如maven的jar包

热部署仅包含重启过程,仅仅加载当前开发者自定义开发的资源,不加载maven的jar资源。

3.1.2 自动启动热部署

步骤

  • 开启选项①:

image-20220220173108048

  • 开启选项②:

image-20220220173413308

激活方式:Idea失去焦点5s后启动热部署

3.1.3 热部署范围配置

默认不触发重启的目录列表:

  • /META-INF/maven
  • /META-INF/resources
  • /resources
  • /static
  • /public
  • /templates

自定义不参与重启排除项,在application配置文件中:

1
2
3
4
5
6
7
8
9
10
11
12
spring:
datasource:
druid:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ssm_db
username: root
password: 12345678
# 自定义不参与重启排除项
# 在spring下级
devtools:
restart:
exclude: static/**, public/**, config/application.yml

3.1.4 关闭热部署

  • 方式一:在配置文件中设置
1
2
3
4
5
6
spring:
devtools:
restart:
exclude: static/**, public/**, config/application.yml
# 禁用热部署
enabled: false
  • 方式二:设置高优先级属性来禁用热部署

参加下图,方式一是图中第三个方法来进行配置,方式二是图中第六个方法来进行配置,优先级高于方式一。

image-20220212194153758

配置代码在主启动类中:

1
2
3
4
5
6
7
8
9
10
@SpringBootApplication
public class SsmpApplication {

public static void main(String[] args) {
// 配置禁用热部署的键值对
System.setProperty("spring.devtools.restart.enabled", "false");
SpringApplication.run(SsmpApplication.class, args);
}

}

3.2 配置高级

3.2.1 @ConfigurationProperties

回顾1.3.4.(5)小节自定义属性注入配置。

  • 新建模块demo_config,并新建配置类ServerConfig.java
1
2
3
4
5
6
7
8
@Data
@Component
@ConfigurationProperties(prefix = "servers")
public class ServerConfig {
private String ipAddress;
private int port;
private long timeout;
}
  • 书写配置文件
1
2
3
4
servers:
ipAddress: 192.168.0.1
port: 2345
timeout: -1
  • 启动类
1
2
3
4
5
6
7
8
9
10
@SpringBootApplication
public class DemoConfigApplication {

public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(DemoConfigApplication.class, args);
ServerConfig bean = ctx.getBean(ServerConfig.class);
System.out.println(bean);
}

}

执行结果:

1
ServerConfig(ipAddress=192.168.0.1, port=2345, timeout=-1)

接下来使用@ConfigurationProperties为第三方bean绑定属性

  • 首先引入一个外部资源,例如Druid
1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.16</version>
</dependency>
  • 书写配置文件
1
2
datasource:
driverClassName: com.mysql.jdbc.Driver
  • 启动类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@SpringBootApplication
public class DemoConfigApplication {

@Bean
@ConfigurationProperties(prefix = "datasource")
public DruidDataSource dataSource() {
DruidDataSource ds = new DruidDataSource();
return ds;
}

public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(DemoConfigApplication.class, args);
DruidDataSource ds = ctx.getBean(DruidDataSource.class);
System.out.println(ds);
System.out.println(ds.getDriverClassName());
}

}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
{
CreateTime:"2022-02-22 17:10:29",
ActiveCount:0,
PoolingCount:0,
CreateCount:0,
DestroyCount:0,
CloseCount:0,
ConnectCount:0,
Connections:[
]
}
com.mysql.jdbc.Driver

@EnableConfigurationProperties注解

@EnableConfigurationProperties注解可以将使用@ConfigurationProperties注解对应的类加入到Spring容器中。在启动类上书写。

注意:@ConfigurationProperties@Component不能同时使用,否则容器会报存在两个相同bean的错误。

例如:

1
2
3
4
5
6
7
8
@Data
//@Component 要注释掉
@ConfigurationProperties(prefix = "servers")
public class ServerConfig {
private String ipAddress;
private int port;
private long timeout;
}
1
2
3
4
5
@SpringBootApplication
@EnableConfigurationProperties(ServerConfig.class)
public class DemoConfigApplication {
// code...
}

解除使用@ConfigurationProperties注释警告

image-20220222172256252

方法:添加依赖

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

3.2.2 宽松绑定和松散绑定

  • 绑定前缀名命名规范:仅能使用纯小写字母、数字、下划线作为合法的字符
1
2
3
4
5
datasource: # 符合规范
driverClassName: com.mysql.jdbc.Driver

dataSource: # 不符合规范
driverClassName: com.mysql.jdbc.Driver

于是在读取配置类中,会报错:

image-20220222173738237

  • @ConfigurationProperties绑定属性支持属性名宽松绑定,即配置类中的属性和配置文件中的属性名不一定要一一对应,但有多种推荐的命名模式
1
2
3
4
5
6
7
8
9
servers:
# ipaddress: 192.168.0.1
# ip_address: 192.168.0.1 # 下划线模式
# ip_add-ress: 192.168.0.1 # 甚至可以这样写
# IPADDRESS: 192.168.0.1 # 常量模式
# ipAddress: 192.168.0.1 # 驼峰模式
ip-address: 192.168.0.1 # 中划线模式 烤肉串模式 kebab-case 官方推荐
port: 2345
timeout: -1

官方推荐中划线模式书写属性名。

在配置类中也可以任意使用各种模式书写属性名,但是推荐使用驼峰模式。

1
2
3
4
5
6
7
8
@Data
@ConfigurationProperties(prefix = "servers")
public class ServerConfig {
// 驼峰模式
private String ipAddress;
private int port;
private long timeout;
}
  • @Value不支持松散绑定
1
2
servers:
ip-address: 192.168.0.1 # 中划线模式 烤肉串模式 kebab-case 官方推荐\
1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
class DemoConfigApplicationTests {

@Value("${servers.ipaddress}")
private String s;

@Test
void contextLoads() {
System.out.println(s);
}

}

3.2.3 常用计量单位绑定

SpringBoot支持JDK8提供的时间与空间计量单位。

代码示例

  • ServerConfig.java
1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@ConfigurationProperties(prefix = "servers")
public class ServerConfig {
private String ipAddress;
private int port;
private long timeout;

@DurationUnit(ChronoUnit.HOURS)
private Duration serverTimeOut;

@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize dataSize;
}
  • 配置文件
1
2
3
4
5
6
7
servers:
ip-address: 192.168.0.1
port: 2345
timeout: -1
server-time-out: 3 # 基本单位为毫秒
data-size: 10 # 基本单位为字节Byte
# data-size: 10MB # 也可以直接写单位,此时配置类就不用加注解

执行结果:

1
ServerConfig(ipAddress=192.168.0.1, port=2345, timeout=-1, serverTimeOut=PT3H, dataSize=10485760B)

其中10485760B = 1024 * 1024 * 10B

3.2.4 数据校验

开启数据校验有助于系统安全性,J2EE规范中JSR303规范定义了一组关于数据校验的API。

  • 添加依赖:
1
2
3
4
5
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
  • 开启bean的校验功能,对配置类使用@Validated注解
  • 在字段上设置校验规则
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
@ConfigurationProperties(prefix = "servers")
// 开启对当前bean的属性注入校验
@Validated
public class ServerConfig {

private String ipAddress;

// 设置具体的规则
@Max(value = 8888, message = "端口最大值不能大于8888")
@Min(value = 202, message = "端口最小值不能小于222")
@NotEmpty(message = "端口不能为空")
private int port;

private long timeout;

@DurationUnit(ChronoUnit.HOURS)
private Duration serverTimeOut;

@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize dataSize;
}

执行报错:

1
2
3
4
5
6
7
8
9
10
11
***************************
APPLICATION FAILED TO START
***************************

Description:

The Bean Validation API is on the classpath but no implementation could be found

Action:

Add an implementation, such as Hibernate Validator, to the classpath

发现需要一个接口Hibernate Validator的实现,于是添加依赖:

1
2
3
4
5
6
<!--使用hibernate框架提供的校验器做实现类-->
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>7.0.1.Final</version>
</dependency>
  • 将端口设为1,并重新执行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target org.springframework.boot.context.properties.bind.BindException: Failed to bind properties under 'servers' to com.hongyi.config.ServerConfig failed:

Property: servers.port
Value: 1
Origin: class path resource [application.yml] - 3:9
Reason: 端口最小值不能小于222


Action:

Update your application's configuration


Process finished with exit code 1

发现校验不通过,并打印了提示信息

3.3 测试

3.3.1 加载测试专用属性

新建模块demo_test,删除原来的测试类,新增测试类PropertiesAndArgsTest.java

  • 启动测试环境时,可以通过propertiesargs属性为当前测试用例添加临时的属性配置,且都可以覆盖配置文件中的属性
    • 优点:比多环境开发中的测试环境影响范围更小,仅对当前测试类有效
1
2
test:
prop: testValue

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方式1:
// @SpringBootTest(properties = {"test.prop=testValue1"})
// 方式2:
@SpringBootTest(args = {"--test.prop=testValue2"})
public class PropertiesAndArgsTest {

@Value("${test.prop}")
private String msg;

@Test
void testProperties() {
System.out.println(msg); // testValue2
}
}

3.3.2 加载测试专用配置

  • 使用@import可以加载当前测试类专用的配置

在测试范围内新建一个专用的配置类config.MsgConfig.java和测试类ConfigTest.java

image-20220225162235942

1
2
3
4
5
6
7
8
9
@Configuration
public class MsgConfig {
// 模拟引入外部的bean
@Bean
public String msg(){
return "msg bean";
}

}

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SpringBootTest
// 引入专用的配置类
@Import(MsgConfig.class)
public class ConfigTest {

@Autowired
private String msg;

@Test
void testConfig() {
System.out.println(msg); // msg bean
}

}

3.3.3 Web环境模拟测试

需要达到的效果:代替Postman测试接口功能

① 测试端口启动

使用webEnvironment属性可以在某个端口启动Web服务器。

  • 首先将demo_test改造为一个Web项目
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • 按照默认形式书写测试类代码
1
2
3
4
5
6
7
8
@SpringBootTest
public class WebTest {

@Test
void test() {

}
}

运行后发现服务器并没有启动

1
2
3
Starting WebTest using Java 11.0.10 on MSI with PID 29112 (started by Hongyi in E:\develop\study\project-study\springboot\demo1\demo_test)
No active profile set, falling back to default profiles: default
Started WebTest in 1.513 seconds (JVM running for 2.382)
  • 添加属性webEnvironment,指定服务器启动端口,共有四种:
    • MOCK:自定义端口
    • DEFINED_PORT:默认端口8080
    • RANDOM_PORT:随机端口
    • NONE:无
1
2
3
4
5
6
7
8
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public class WebTest {

@Test
void test() {

}
}

image-20220225163842548

随机端口的情况:

image-20220225163927434

② 发送虚拟请求
  • 注解:

    • @AutoConfigureMockMvc:开启虚拟MVC调用
  • 创建控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/books")
public class BookController {

/**
* 模拟接收get请求
* @return 视图名称
*/
@GetMapping
public String getById() {
System.out.println("getById running...");
return "springboot";
}

}
  • 测试类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// 开启虚拟MVC调用
@AutoConfigureMockMvc
public class WebTest {

@Test
// 注入虚拟MVC调用对象
void testWeb(@Autowired MockMvc mvc) throws Exception {
// 模拟一个虚拟的http请求
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/books");
// 执行对应请求
mvc.perform(builder);
}
}

执行结果:

image-20220225170819637

③ 匹配响应执行状态
  • 测试代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
void testStatus(@Autowired MockMvc mvc) throws Exception {
// 故意将路径写错
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/books1");
// 获取调用执行结果
ResultActions action = mvc.perform(builder);

// 设定预期值,与真实值进行比较,成功测试通过,否则失败
// 定义本次调用的预期值
StatusResultMatchers status = MockMvcResultMatchers.status();
// 预计本次调用时成功的状态:200
ResultMatcher ok = status.isOk();
// 添加预计值到本次调用过程中进行匹配
action.andExpect(ok);
}

打印后的重要信息:

image-20220225172336278

image-20220225172402685

image-20220225172428877

④ 匹配响应体
1
2
3
4
5
6
7
8
9
10
11
@Test
void testBody(@Autowired MockMvc mvc) throws Exception {
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/books");
ResultActions action = mvc.perform(builder);
// 设定预期值,与真实值进行比较,成功测试通过,否则失败
// 定义本次调用的预期值
ContentResultMatchers content = MockMvcResultMatchers.content();
ResultMatcher result = content.string("springboot1");
// 添加预计值到本次调用过程中进行匹配
action.andExpect(result);
}

image-20220225173756172

⑤ 匹配响应体json
  • 新建一个实体类Book.java
1
2
3
4
5
6
7
@Data
public class Book {
private int id;
private String name;
private String type;
private String description;
}
  • 测试方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testJson(@Autowired MockMvc mvc) throws Exception {
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/books");
ResultActions action = mvc.perform(builder);
// 设定预期值,与真实值进行比较,成功测试通过,否则失败
// 定义本次调用的预期值
ContentResultMatchers content = MockMvcResultMatchers.content();
ResultMatcher result = content.json("{\"id\": 2,\n" +
" \"name\": \"Hongyi\",\n" +
" \"type\": \"Book\",\n" +
" \"description\": \"Hello World\"}");
// 添加预计值到本次调用过程中进行匹配
action.andExpect(result);
}

image-20220301154857903

⑥匹配响应头
1
2
3
4
5
6
7
8
9
10
11
@Test
void testContentType(@Autowired MockMvc mvc) throws Exception {
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/books");
ResultActions action = mvc.perform(builder);
// 设定预期值,与真实值进行比较,成功测试通过,否则失败
// 定义本次调用的预期值
HeaderResultMatchers header = MockMvcResultMatchers.header();
ResultMatcher result = header.string("Content-Type", "application/json");
// 添加预计值到本次调用过程中进行匹配
action.andExpect(result);
}

image-20220301155205448

3.3.4 业务层测试回滚

在进行测试时,会涉及到数据库的增删改查,不可避免地会对数据库中的数据进行操作,这样导致了测试代码对原来数据库的污染,例如删除或更新了原始数据或者增加了仅测试用的数据(实际开发中开发有开发数据库,生产环境有自己的数据库,这样也可以避免污染生产环境使用的数据库)。

image-20220301160029335

这里介绍如何让测试不污染数据库。

  • 为测试用例添加事务@Transactional,SpringBoot会对测试用例对应的事务提交操作进行回滚
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootTest
// 添加事务的注解
@Transactional
public class ServiceTest {

@Autowired
private BookService bookService;

@Test
void testSave() {
Book book = new Book();
book.setName("Hongyi2");
book.setType("Book2");
book.setDescription("Hello World2");
bookService.save(book);
}
}

image-20220301161359538

发现并没有提交的数据

  • 如果想在测试用例中提交事务,可以通过@Rollback注解设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootTest
@Transactional
@Rollback(value = false) // 默认为true,即回滚
public class ServiceTest {

@Autowired
private BookService bookService;

@Test
void testSave() {
Book book = new Book();
book.setName("Hongyi2");
book.setType("Book2");
book.setDescription("Hello World2");
bookService.save(book);
}
}

image-20220301161554154

3.3.5 测试用例数据设定

测试用例数据通常采用随机值进行测试,使用SpringBoot提供的随机数为其赋值。

  • 在配置文件中,配置测试用例的随机值
1
2
3
4
5
testcase:
book:
name: ${random.value}
type: ${random.value}
description: ${random.value}

其他的例子:

1
2
3
4
5
6
7
8
testcast:  
book:
id: ${random.int} # 随机整数
id2: ${random.int(10)} # 10以内随机数
type: ${random.int(10,20)} # 10到20随机数
uuid: ${random.uuid} # 随机uuid
name: ${random.value} # 随机字符串,MD5字符串,32位
publishTime: ${random.long} # 随机整数(long范围)
  1. {random.int}表示随机整数
  2. {random.int(10)}表示10以内的随机数
  3. {random.int(10,20)}表示10到20的随机数
  4. 其中()可以是任意字符,例如[]!!均可
  • test范围内新建实体类BookCase.java
1
2
3
4
5
6
7
8
@Component
@Data
@ConfigurationProperties(prefix = "testcase.book")
public class BookCase {
private String name;
private String type;
private String description;
}

3.4 统一接口封装

3.4.1 统一必要性

以查询某个用户接口而言,如果没有封装,返回结果如下:

1
2
3
4
{
"userId": 1,
"userName": "赵一"
}

如果封装了,则返回正常的结果:

1
2
3
4
5
6
7
8
9
{
"timestamp": 11111111111,
"status": 200,
"message": "success",
"data": {
"userId": 1,
"userName": "赵一"
}
}

异常返回结果:

1
2
3
4
5
6
{
"timestamp": 11111111111,
"status": 10001,
"message": "User not exist",
"data": null
}

3.4.2 实现案例

① 状态码封装

这里以常见的状态码为例,包含responseCode 和 description两个属性。

如果还有其它业务状态码,也可以放到这个类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Getter
@AllArgsConstructor
public enum ResponseStatus {
// 成功和失败
SUCCESS("200", "success"),
FAIL("500", "failed"),
// 其他状态码
HTTP_STATUS_200("200", "ok"),
HTTP_STATUS_400("400", "request error"),
HTTP_STATUS_401("401", "no authentication"),
HTTP_STATUS_403("403", "no authorities"),
HTTP_STATUS_500("500", "server error");

public static final List<ResponseStatus> HTTP_STATUS_ALL = Collections.unmodifiableList(
Arrays.asList(HTTP_STATUS_200, HTTP_STATUS_400, HTTP_STATUS_401, HTTP_STATUS_403, HTTP_STATUS_500
));
// 枚举实例的属性
// 返回码
private final String responseCode;
// 描述
private final String description;

}
② 返回内容封装

包含公共的接口返回时间,状态status,消息message, 以及数据data。

考虑到数据的序列化(比如在网络上传输),这里data有时候还会extends Serializable。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Data
@Builder
public class ResponseResult<T> {
// 接口返回时间
private long timestamp;
// 状态,对应responseCode
private String status;
// 消息,对应description
private String message;
// 数据(泛型)
private T data;

public static <T> ResponseResult<T> success() {
return success(null);
}

public static <T> ResponseResult<T> success(T data) {
return ResponseResult.<T>builder().data(data)
.message(ResponseStatus.SUCCESS.getDescription())
.status(ResponseStatus.SUCCESS.getResponseCode())
.timestamp(System.currentTimeMillis())
.build();
}

public static <T extends Serializable> ResponseResult<T> fail(String message) {
return fail(null, message);
}

public static <T> ResponseResult<T> fail(T data, String message) {
return ResponseResult.<T>builder().data(data)
.message(message)
.status(ResponseStatus.FAIL.getResponseCode())
.timestamp(System.currentTimeMillis())
.build();
}

}
③ 接口返回时调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RestController
@RequestMapping("/user")
public class UserController {

@Autowired
private IUserService userService;

@ApiOperation("Add/Edit User")
@PostMapping("add")
public ResponseResult<User> add(User user) {
if (user.getId()==null || !userService.exists(user.getId())) {
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userService.save(user);
} else {
user.setUpdateTime(LocalDateTime.now());
userService.update(user);
}
return ResponseResult.success(userService.find(user.getId()));
}

@ApiOperation("Query User One")
@GetMapping("edit/{userId}")
public ResponseResult<User> edit(@PathVariable("userId") Long userId) {
return ResponseResult.success(userService.find(userId));
}
}
1
2
3
4
5
6
7
8
9
{
"timestamp": 1698896257569,
"status": "200",
"message": "success",
"data": {
"name": "Hongyi",
"age": 20
}
}

3.5 参数校验

3.5.1 必要性

后端对前端传过来的参数也是需要进行校验的,如果在controller中直接校验需要用大量的if else做判断

以添加用户的接口为例,需要对前端传过来的参数进行校验, 如下的校验就是不优雅的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/user")
public class UserController {

@PostMapping("add")
public ResponseEntity<String> add(User user) {
if(user.getName()==null) {
return ResponseResult.fail("user name should not be empty");
} else if(user.getName().length()<5 || user.getName().length()>50){
return ResponseResult.fail("user name length should between 5-50");
}
if(user.getAge()< 1 || user.getAge()> 150) {
return ResponseResult.fail("invalid age");
}
// ...
return ResponseEntity.ok("success");
}
}

Spring Validation是对hibernate validation的二次封装,用于支持spring mvc参数自动校验。

3.5.2 实现案例

① POM依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
② 请求参数封装

单一职责,所以将查询用户的参数封装到UserParam中, 而不是User(数据库实体)本身。

对每个参数字段添加validation注解约束和message。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Data
@Getter
public class UserParam {
@NotEmpty(message = "could not be empty")
private String userId;

@NotEmpty(message = "could not be empty")
@Email(message = "invalid email")
private String email;

@NotEmpty(message = "could not be empty")
@Pattern(regexp = "^(\\d{6})(\\d{4})(\\d{2})(\\d{2})(\\d{3})([0-9]|X)$", message = "invalid ID")
private String cardNo;

@NotEmpty(message = "could not be empty")
@Length(min = 1, max = 10, message = "nick name should be 1-10")
private String nickName;

@NotNull(message = "could not be empty")
@Range(min = 0, max = 1, message = "sex should be 0-1")
private int sex;

@Max(value = 100, message = "Please input valid age")
private int age;

}
③ Controller中获取参数绑定结果

使用@Valid或者@Validated注解,参数校验的值放在BindingResult中:

1
2
3
4
5
6
7
8
9
10
11
12
@PostMapping("add")
public ResponseResult<String> add(@Valid @RequestBody UserParam userParam, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
List<ObjectError> errors = bindingResult.getAllErrors();
errors.forEach(p -> {
FieldError fieldError = (FieldError) p;
log.error("Invalid Parameter : object - {},field - {},errorMessage - {}", fieldError.getObjectName(), fieldError.getField(), fieldError.getDefaultMessage());
});
return ResponseResult.fail("invalid parameter");
}
return ResponseResult.success("success");
}

3.6 统一异常处理

3.6.1 必要性

如果我们不统一的处理异常,经常会在controller层有大量的异常处理的代码, 比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/user")
public class UserController {

@PostMapping("add")
public ResponseResult<String> add(@Valid @RequestBody UserParam userParam) {
// 每个接口充斥着大量的异常处理
try {
// do something
} catch(Exception e) {
return ResponseResult.fail("error");
}
return ResponseResult.success("success");
}
}

3.6.2 实现案例

通过@RestControllerAdvice进行统一异常处理。

  • 示例1:

全局异常处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@RestControllerAdvice
public class HandleException {

// validator当没有通过校验是报org.springframework.validation.BindException
@ExceptionHandler(BindException.class)
public ResultList handleException(BindException e) {
return new ResultList(2004, e.getBindingResult().getFieldError().getDefaultMessage(), null);
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResultList handleException(MethodArgumentTypeMismatchException e) {
return new ResultList(2004, "参数`" + e.getName() + "`类型转换错误-->" + e.getCause(), null);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultList handleException(MethodArgumentNotValidException e) {
return new ResultList(2004, e.getBindingResult().getFieldError().getDefaultMessage(), null);
}

// 请求方式不正确
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResultList handleException(HttpRequestMethodNotSupportedException e) {
return new ResultList(2004, e.getMessage(), null);
}

// 自定义异常
@ExceptionHandler(CustomException.class)
public ResultList handleException(CustomException e) {
return new ResultList(e.getCode(), e.getMsg(), null);
}

//SQL 语法异常
@ExceptionHandler(SQLSyntaxErrorException.class)
public ResultList handleException(SQLSyntaxErrorException e) {
return new ResultList(Code.EXECUTION_ERROR.getCode(), e.getMessage(), null);
}

@ExceptionHandler(ReflectionException.class)
public ResultList handleException(ReflectionException e) {
return new ResultList(Code.EXECUTION_ERROR.getCode(), e.getMessage(), null);
}

@ExceptionHandler(InvalidFormatException.class)
public ResultList handleException(InvalidFormatException e) {
return new ResultList(Code.EXECUTION_ERROR.getCode(), e.getOriginalMessage(), null);
}

}

自定义异常,继承RuntimeException运行时异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@ToString
@Getter
@Setter
public class CustomException extends RuntimeException {

public CustomException(int code, String msg) {
this.code = code;
this.msg = msg;
}
public CustomException(Code code) {
this.code = code.getCode();
this.msg = code.getMsg();
}

private int code; // 异常代码
private String msg; // 异常信息
}

异常代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@ToString
@Getter
public enum Code {

PARAM_FORMAT_ERROR(2004, "参数异常"),
DELETE_NULL(4002, "数据删除为空"),
DELETE_FAIL(4001, "数据删除失败"),
DELETE_SUCCESS(4000, "数据删除成功"),
UPDATE_NULL(5001, "更新数据为空"),
UPDATE_SUCCESS(5000, "更新成功"),
ADD_FAIL(5002, "数据添加失败"),
ADD_SUCCESS(5000, "数据添加成功"),
QUERY_NULL(2008, "数据查询为空"),
QUERY_FAIL(2009, "数据查询失败"),
CHECK_FAIL(6001, "接口检验失败"),
EXECUTION_ERROR(6002, "接口执行失败"),
QUERY_SUCCESS(2000, "数据查询成功"),
CHANGE_LOCK(8000, "改变设备锁状态"),
CONNECTION_ERROR(8004, "服务器连接异常"),
SUCC(9000, "定时任务执行成功"),
FAIL(9001, "定时任务执行失败");

//枚举的属性字段必须是私有且不可变
private final int code;
private final String msg;

Code(int code, String msg) {
this.code = code;
this.msg = msg;
}
}

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static List<String> getRecentTriggerTime(String cron) {
List<String> list = new ArrayList<>();
try {
CronTriggerImpl cronTriggerImpl = new CronTriggerImpl();
cronTriggerImpl.setCronExpression(cron);
List<Date> dateList = TriggerUtils.computeFireTimes(cronTriggerImpl, null, 5);
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
dateList.forEach(date -> list.add(dateFormat.format(date)));
} catch (ParseException e) {
throw new CustomException(Code.EXECUTION_ERROR);
}
return list;
}
  • 示例2

全局异常处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@RestControllerAdvice
public class GlobalExceptionHandler{
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

// 基础异常
@ExceptionHandler(BaseException.class)
public AjaxResult baseException(BaseException e){
return AjaxResult.error(e.getMessage());
}

// 业务异常
@ExceptionHandler(CustomException.class)
public AjaxResult businessException(CustomException e){
if (StringUtils.isNull(e.getCode())){
return AjaxResult.error(e.getMessage());
}
return AjaxResult.error(e.getCode(), e.getMessage());
}

@ExceptionHandler(NoHandlerFoundException.class)
public AjaxResult handlerNoFoundException(Exception e){
log.error(e.getMessage(), e);
return AjaxResult.error(HttpStatus.NOT_FOUND, "路径不存在,请检查路径是否正确");
}

@ExceptionHandler(AccessDeniedException.class)
public AjaxResult handleAuthorizationException(AccessDeniedException e){
log.error(e.getMessage());
return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权");
}

@ExceptionHandler(AccountExpiredException.class)
public AjaxResult handleAccountExpiredException(AccountExpiredException e){
log.error(e.getMessage(), e);
return AjaxResult.error(e.getMessage());
}

@ExceptionHandler(UsernameNotFoundException.class)
public AjaxResult handleUsernameNotFoundException(UsernameNotFoundException e){
log.error(e.getMessage(), e);
return AjaxResult.error(e.getMessage());
}

@ExceptionHandler(Exception.class)
public AjaxResult handleException(Exception e){
log.error(e.getMessage(), e);
return AjaxResult.error(e.getMessage());
}

// 自定义验证异常
@ExceptionHandler(BindException.class)
public AjaxResult validatedBindException(BindException e){
log.error(e.getMessage(), e);
String message = e.getAllErrors().get(0).getDefaultMessage();
return AjaxResult.error(message);
}

// 自定义验证异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public Object validExceptionHandler(MethodArgumentNotValidException e){
log.error(e.getMessage(), e);
String message = e.getBindingResult().getFieldError().getDefaultMessage();
return AjaxResult.error(message);
}

// 演示模式异常
@ExceptionHandler(DemoModeException.class)
public AjaxResult demoModeException(DemoModeException e) {
return AjaxResult.error("演示模式,不允许操作");
}
}

自定义异常:可以细分模块

1
2
3
4
5
6
7
8
// 省略构造方法、setter和getter
public class CustomException extends RuntimeException {
private static final long serialVersionUID = 1L;

private Integer code;

private String message;
}

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public void validateEdit(GenTable genTable){
if (GenConstants.TPL_TREE.equals(genTable.getTplCategory())){
String options = JSON.toJSONString(genTable.getParams());
JSONObject paramsObj = JSONObject.parseObject(options);
if (StringUtils.isEmpty(paramsObj.getString(GenConstants.TREE_CODE))){
throw new CustomException("树编码字段不能为空");
}
else if (StringUtils.isEmpty(paramsObj.getString(GenConstants.TREE_PARENT_CODE))){
throw new CustomException("树父编码字段不能为空");
}
else if (StringUtils.isEmpty(paramsObj.getString(GenConstants.TREE_NAME))){
throw new CustomException("树名称字段不能为空");
}
else if (GenConstants.TPL_SUB.equals(genTable.getTplCategory())){
if (StringUtils.isEmpty(genTable.getSubTableName())){
throw new CustomException("关联子表的表名不能为空");
}
else if (StringUtils.isEmpty(genTable.getSubTableFkName())){
throw new CustomException("子表关联的外键名不能为空");
}
}
}
}

3.7 定时任务

实现定时任务的方法:

  • Timer:这是java自带的java.util.Timer类,这个类允许你调度一个java.util.TimerTask任务。使用这种方式可以让你的程序按照某一个频度执行,但不能在指定时间运行。一般用的较少。
  • ScheduledExecutorService:也jdk自带的一个类;是基于线程池设计的定时任务类,每个调度任务都会分配到线程池中的一个线程去执行,也就是说,任务是并发执行,互不影响。
  • Spring Task:Spring3.0以后自带的task,可以将它看成一个轻量级的Quartz,而且使用起来比Quartz简单许多。
  • Quartz:这是一个功能比较强大的的调度器,可以让你的程序在指定时间执行,也可以按照某一个频度执行,配置起来稍显复杂。

3.7.1 Timer

  • timer.schedule(TimerTask, delay):延迟任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Slf4j  
public class TimeTest {
public static void main(String[] args) {
timerTest();
}

public static void timerTest() {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
public void run() {
log.info("timer-task @{}", LocalDateTime.now());
}
}, 1000); // 延迟1s执行

// waiting to process(sleep to mock)
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}

// stop timer
timer.cancel();
}
}
  • timer.schedule(TimerTask, delay, period):周期任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static void timerScheduleTest() {  
// start timer
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@SneakyThrows
public void run() {
log.info("timer-period-task @{}", LocalDateTime.now());
Thread.sleep(100); // 可以设置的执行时间, 来测试当执行时间大于执行周期时任务执行的变化
}
}, 500, 1000); // 延迟0.5s执行,每1s执行一次

// waiting to process(sleep to mock)
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}

// stop timer
timer.cancel();
}

测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
10:36:49.096 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:49.094084400
10:36:50.090 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:50.090673400
10:36:51.090 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:51.090960200
10:36:52.091 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:52.091758300
10:36:53.091 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:53.091636700
10:36:54.091 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:54.091817800
10:36:55.092 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:55.092264300
10:36:56.095 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:56.095661300
10:36:57.095 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:57.095840900
10:36:58.096 [Timer-0] INFO com.hongyi.apiintegration.time.TimeTest - timer-period-task @2024-07-15T10:36:58.096486300

Process finished with exit code 0

总结:每次执行完当前任务后,然后间隔一个period的时间再执行下一个任务; 当某个任务执行周期大于时间间隔时,依然按照间隔时间执行下个任务,即它保证了任务之间执行的间隔

Timer底层是使用一个单线程来实现多个Timer任务处理的,所有任务都是由同一个线程来调度,所有任务都是串行执行,意味着同一时间只能有一个任务得到执行,而前一个任务的延迟或者异常会影响到之后的任务。

3.7.2 ScheduledExecutorService

该方法跟Timer类似,而ScheduledExecutorService是基于线程池的,可以开启多个线程进行执行多个任务,每个任务开启一个线程; 这样任务的延迟和未处理异常就不会影响其它任务的执行了。

  • 延迟1s执行一个进程任务
1
2
3
4
5
6
7
8
9
10
11
12
private static void ScheduledExecutorServiceTest() {  
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
executor.schedule(() -> log.info("run schedule @ {}", LocalDateTime.now()), 1000, TimeUnit.MILLISECONDS);

try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}

executor.shutdown();
}

3.7.3 Spring Task

① cron表达式

Cron表达式是一个具有时间含义的字符串,字符串以5个空格隔开,分为6个域,格式为X X X X X X。其中X是一个域的占位符。单个域有多个取值时,使用半角逗号,隔开取值。每个域可以是确定的取值,也可以是具有逻辑意义的特殊字符。秒 分 时 日 月 星 年

下表为Cron表达式中六个域能够取的值以及支持的特殊字符。

是否必需 取值范围 特殊字符
秒 Seconds [0, 59] * , - /
分钟 Minutes [0, 59] * , - /
小时 Hours [0, 23] * , - /
日期 DayofMonth [1, 31] * , - / ? L W
月份 Month [1, 12]或[JAN, DEC] * , - /
星期 DayofWeek [1, 7]或[MON, SUN]。若使用[1, 7]表达方式,1代表星期一,7代表星期日。 * , - / ? L #
年 Year 1970+ - * /

其中:

  • *:表示匹配该域的任意值。假如在Minutes域使用*, 即表示每分钟都会触发事件。
  • ?:只能用在DayofMonth和DayofWeek两个域。它也匹配域的任意值,但实际不会。因为DayofMonth和DayofWeek会相互影响。例如想在每月的20日触发调度,不管20日到底是星期几,则只能使用如下写法: 13 13 15 20 * ?, 其中最后一位只能用?,而不能使用,如果使用表示不管星期几都会触发,实际上并不是这样。
  • -:表示范围。例如在Minutes域使用5-20,表示从5分到20分钟每分钟触发一次
  • /:表示起始时间开始触发,然后每隔固定时间触发一次。例如在Minutes域使用5/20,则意味着5分钟触发一次,而25,45等分别触发一次.
  • ,:表示列出枚举值。例如:在Minutes域使用5,20,则意味着在5和20分每分钟触发一次。
  • L:表示最后,只能出现在DayofWeek和DayofMonth域。如果在DayofWeek域使用5L,意味着在最后的一个星期四触发。
  • W:表示有效工作日(周一到周五),只能出现在DayofMonth域,系统将在离指定日期的最近的有效工作日触发事件。例如:在 DayofMonth使用5W,如果5日是星期六,则将在最近的工作日:星期五,即4日触发。如果5日是星期天,则在6日(周一)触发;如果5日在星期一到星期五中的一天,则就在5日触发。另外一点,W的最近寻找不会跨过月份 。
  • LW:这两个字符可以连用,表示在某个月最后一个工作日,即最后一个星期五。
  • #:用于确定每个月第几个星期几,只能出现在DayofMonth域。例如在4#2,表示某月的第二个星期三。
② 实现
  • 在配置类中,通过@EnableScheduling启用定时任务,@Scheduled定义任务

  • @Scheduled所支持的参数

  1. cron:cron表达式,指定任务在特定时间执行;
  2. fixedDelay:表示上一次任务执行完成后多久再次执行,参数类型为long,单位ms;
  3. fixedDelayString:与fixedDelay含义一样,只是参数类型变为String;
  4. fixedRate:表示按一定的频率执行任务,参数类型为long,单位ms;
  5. fixedRateString: 与fixedRate的含义一样,只是将参数类型变为String;
  6. initialDelay:表示延迟多久再第一次执行任务,参数类型为long,单位ms;
  7. initialDelayString:与initialDelay的含义一样,只是将参数类型变为String;
  8. zone:时区,默认为当前时区,一般没有用到。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@Component
@EnableScheduling
public class ScheduleTest {
/**
* 每隔1min执行1次
*/
@Scheduled(fixedRate = 1000 * 60)
public void runScheduledFixedRate() {
log.info("runScheduledFixedRate: current Datetime, {}", LocalDateTime.now());
}
}

问题:单线程串行执行,当某个任务超时或异常时,导致后面的任务阻塞。

3.7.4 Quartz

Quartz[kwɔːrts],石英。

① 体系结构

image-20240715111501999

  • Job 表示一个工作,要执行的具体内容,可以对应多个Trigger。
  • JobDetail 表示一个具体的可执行的调度程序,Job 是这个可执行程调度程序所要执行的内容,另外 JobDetail 还包含了这个任务调度的方案和策略。
  • Trigger 代表一个调度参数的配置,什么时候去调,只能对应一个Job。
  • Scheduler 代表一个调度容器,一个调度容器中可以注册多个 JobDetail 和 Trigger。当 Trigger 与 JobDetail 组合,就可以被 Scheduler 容器调度了。Schedule维护着一个定时任务和触发器的注册表,当两者注册之后,如果触发器到达规定时间触发的时候,Schedule负责执行与触发器关联的定时任务。值得注意的是一个定时任务可以对应多个触发器,当每个触发器触发的时候都会执行其关联的定时任务,但是一个触发器只能对应一个定时任务。
② 实现
  • 依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
  • 定义Job

继承QuartzJobBean,并重载executeInternal方法即可定义你自己的Job执行逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j
public class HelloJob extends QuartzJobBean {

@Override
protected void executeInternal(JobExecutionContext context) {
// get parameters
context.getJobDetail().getJobDataMap().forEach(
(k, v) -> log.info("param, key:{}, value:{}", k, v)
);
// your logics
log.info("Hello Job执行时间: " + new Date());
}

}

QuartzJobBean实现了Job接口:

1
2
3
public abstract class QuartzJobBean implements Job {
// ...
}
  • 配置Job:包含JobDetail,Trigger和Schedule

JobBuilder用于实例化一个定时任务,即JobDetail

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Configuration
public class QuartzConfig {

/**
* JobDetail
*/
@Bean("helloJob")
public JobDetail helloJobDetail() {
/**
* newJob: 设置将要执行的Job,该类需要实现Job接口
* withIdentity: JobDetail的id
* usingJobData: 可以通过键值对的方式将一些参数传到定时任务中,在定时任务job中使用
*/
return JobBuilder.newJob(HelloJob.class)
.withIdentity("DateTimeJob")
.usingJobData("msg", "Hello Quartz")
.storeDurably() //即使没有Trigger关联时,也不需要删除该JobDetail
.build();
}

/**
* 触发器
*/
@Bean
public Trigger printTimeJobTrigger() {
// 每秒执行一次
CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule("0/1 * * * * ?");
/**
* forJob: 应用在哪个JobDetail上
* withIdentity: Trigger的id
* withSchedule: 设置ScheduleBuilder,而ScheduleBuilder在负责真正实例化出一个Trigger
*/
return TriggerBuilder.newTrigger()
.forJob(helloJobDetail())
.withIdentity("quartzTaskService")
.withSchedule(cronScheduleBuilder)
.build();
}
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
2024-07-15 11:33:50.691  INFO 4604 --- [eduler_Worker-1] com.hongyi.apiintegration.time.HelloJob  : param, key:msg, value:Hello Quartz
2024-07-15 11:33:50.691 INFO 4604 --- [eduler_Worker-1] com.hongyi.apiintegration.time.HelloJob : Hello Job执行时间: Mon Jul 15 11:33:50 CST 2024
2024-07-15 11:33:50.693 INFO 4604 --- [ main] com.hongyi.apiintegration.time.Main : Started Main in 4.386 seconds (JVM running for 4.764)
2024-07-15 11:33:51.001 INFO 4604 --- [eduler_Worker-2] com.hongyi.apiintegration.time.HelloJob : param, key:msg, value:Hello Quartz
2024-07-15 11:33:51.001 INFO 4604 --- [eduler_Worker-2] com.hongyi.apiintegration.time.HelloJob : Hello Job执行时间: Mon Jul 15 11:33:51 CST 2024
2024-07-15 11:33:52.001 INFO 4604 --- [eduler_Worker-3] com.hongyi.apiintegration.time.HelloJob : param, key:msg, value:Hello Quartz
2024-07-15 11:33:52.001 INFO 4604 --- [eduler_Worker-3] com.hongyi.apiintegration.time.HelloJob : Hello Job执行时间: Mon Jul 15 11:33:52 CST 2024
2024-07-15 11:33:53.000 INFO 4604 --- [eduler_Worker-4] com.hongyi.apiintegration.time.HelloJob : param, key:msg, value:Hello Quartz
2024-07-15 11:33:53.000 INFO 4604 --- [eduler_Worker-4] com.hongyi.apiintegration.time.HelloJob : Hello Job执行时间: Mon Jul 15 11:33:53 CST 2024
2024-07-15 11:33:54.007 INFO 4604 --- [eduler_Worker-5] com.hongyi.apiintegration.time.HelloJob : param, key:msg, value:Hello Quartz
2024-07-15 11:33:54.008 INFO 4604 --- [eduler_Worker-5] com.hongyi.apiintegration.time.HelloJob : Hello Job执行时间: Mon Jul 15 11:33:54 CST 2024
2024-07-15 11:33:55.000 INFO 4604 --- [eduler_Worker-6] com.hongyi.apiintegration.time.HelloJob : param, key:msg, value:Hello Quartz
③ 案例

项目地址:QuartzManager

项目结构:

image-20240715143348643

  • 定时任务工具类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
public class QuartzUtil {

private static final SchedulerFactory schedulerFactory = new StdSchedulerFactory();
private static final Scheduler scheduler;
private final static String groupName = "DEFAULT";

static {
try {
scheduler = schedulerFactory.getScheduler();
} catch (SchedulerException e) {
throw new CustomException(Code.EXECUTION_ERROR);
}
}

/**
* 传入:任务名称、触发器名称、任务描述、要执行的任务类、cron表达式创建定时任务,
* 返回是否创建成功
* <p>
* 注:
* 在创建任务时未设置jobGroup和triggerGroup,Job创建后其均为默认值:DEFAULT,
* 因此新创建的任务的jobName和triggerName,均不能与之前任务的重复.
*
* @param jobName 任务名
* @param triggerName 触发器名
* @param description 对该任务的描述(非必须)
* @param jobClass 要执行的任务
* @param cron cron表达式
* @return true:创建Job成功,false:创建Job失败
*/
public static <T extends Job> boolean addJob(String jobName, String triggerName, String description,
Class<T> jobClass, String cron) {
try {
JobDetail job = JobBuilder.newJob(jobClass)
.withIdentity(jobName, groupName)
.withDescription(description)
.build();

Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(triggerName, groupName)
.startNow()
.withSchedule(CronScheduleBuilder.cronSchedule(cron))
.build();
scheduler.scheduleJob(job, trigger); // 注册到调度器
scheduler.start(); // 开启调度器
return scheduler.isStarted();
} catch (Exception e) {
throw new CustomException(Code.EXECUTION_ERROR);
}
}

/**
* 修改一个任务的触发时间
*
* @param jobName 任务名称
* @param triggerName 触发器名
* @param cron cron表达式
* @return true:修改Job成功,false:修改Job失败
*/
public static Boolean rescheduleJob(String jobName, String triggerName, String cron) {
try {
TriggerKey triggerKey = TriggerKey.triggerKey(triggerName);
CronTrigger trigger = (CronTrigger) scheduler.getTrigger(triggerKey);
if (trigger == null) {
return false;
}

checkJobNameAndTriggerName(jobName, trigger);

Date latestFireTime = new Date();
if (!trigger.getCronExpression().equalsIgnoreCase(cron)) {
trigger = TriggerBuilder.newTrigger()
.withIdentity(triggerName)
.startNow()
.withSchedule(CronScheduleBuilder.cronSchedule(cron))
.build();
//rescheduleJob()执行成功返回最近一次执行的时间,如果失败返回null
latestFireTime = scheduler.rescheduleJob(triggerKey, trigger);
}
return null != latestFireTime;
} catch (SchedulerException e) {
throw new CustomException(Code.EXECUTION_ERROR);
}
}

/**
* 根据jobName和triggerName删除该JOB
*
* @param jobName 任务名称
* @param triggerName 触发器名
* @return true:删除Job成功,false:删除Job失败
*/
public static boolean removeJob(String jobName, String triggerName) {
boolean flag;
try {

Trigger trigger = scheduler.getTrigger(new TriggerKey(triggerName));
if (null == trigger) {
return false;
}

checkJobNameAndTriggerName(jobName, trigger);

TriggerKey triggerKey = trigger.getKey();
scheduler.pauseTrigger(triggerKey);
flag = scheduler.unscheduleJob(triggerKey);

} catch (SchedulerException e) {
throw new CustomException(Code.EXECUTION_ERROR);
}
return flag;
}

/**
* 根据jobName和triggerName查询该JOB
*
* @param jobName 任务名称
* @param triggerName 触发器名
* @return 该Job相关信息[详见getJobInfo方法]
*/
public static HashMap<String, String> getJob(String jobName, String triggerName) {
try {
JobDetail jobDetail = scheduler.getJobDetail(new JobKey(jobName));
CronTrigger trigger = (CronTrigger) scheduler.getTrigger(new TriggerKey(triggerName));

if (null == jobDetail || null == trigger) {
return new HashMap<>();
}

checkJobNameAndTriggerName(jobName, trigger);

return getJobInfo(jobDetail, trigger);
} catch (SchedulerException e) {
throw new CustomException(Code.EXECUTION_ERROR);
}
}

/**
* 查询所有正在执行的JOB
*
* @return 该Job相关信息[详见getJobInfo方法]
*/
public static List<HashMap<String, String>> getJobs() {
List<HashMap<String, String>> list = new ArrayList<>();
try {
List<String> triggerGroupNames = scheduler.getTriggerGroupNames();
for (String groupName : triggerGroupNames) {

GroupMatcher<TriggerKey> groupMatcher = GroupMatcher.groupEquals(groupName);
//获取所有的triggerKey
Set<TriggerKey> triggerKeySet = scheduler.getTriggerKeys(groupMatcher);
for (TriggerKey triggerKey : triggerKeySet) {
//获取CronTrigger
CronTrigger trigger = (CronTrigger) scheduler.getTrigger(triggerKey);
//获取trigger对应的JobDetail
JobDetail jobDetail = scheduler.getJobDetail(trigger.getJobKey());

list.add(getJobInfo(jobDetail, trigger));
}
}
} catch (SchedulerException e) {
throw new CustomException(Code.EXECUTION_ERROR);
}
return list;
}

/**
* @param cron cron表达式
* @return 最近5次的执行时间
*/
public static List<String> getRecentTriggerTime(String cron) {
List<String> list = new ArrayList<>();
try {
CronTriggerImpl cronTriggerImpl = new CronTriggerImpl();
cronTriggerImpl.setCronExpression(cron);
List<Date> dateList = TriggerUtils.computeFireTimes(cronTriggerImpl, null, 5);
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
dateList.forEach(date -> list.add(dateFormat.format(date)));
} catch (ParseException e) {
throw new CustomException(Code.EXECUTION_ERROR);
}
return list;
}

/**
* 校验cron表达式是否正确
*
* @param cronExpression cron表达式
* @return true:正确,false:不正确
*/
@SuppressWarnings("all")
public static boolean checkCronExpression(String cronExpression) {
return CronExpression.isValidExpression(cronExpression);
}

/**
* 获取该Job对应的相关信息
*/
private static HashMap<String, String> getJobInfo(JobDetail jobDetail, CronTrigger trigger) {
HashMap<String, String> map = new HashMap<>();
map.put("jobName", jobDetail.getKey().getName());
map.put("jobGroup", jobDetail.getKey().getGroup());
map.put("corn", trigger.getCronExpression());
map.put("triggerName", trigger.getKey().getName());
map.put("description", jobDetail.getDescription());
return map;
}

/**
* 校验jobName和triggerName是否匹配
* 如不匹配抛出自定义异常
*/
private static void checkJobNameAndTriggerName(String jobName, Trigger trigger) {
String name = trigger.getJobKey().getName();
if (!name.equals(jobName)) {
throw new CustomException(Code.PARAM_FORMAT_ERROR.getCode(), "jobName与triggerName不匹配");
}
}

}
  • 定时器类接口及实现
1
2
3
4
5
6
7
8
9
10
11
12
public interface ITimedTaskService {

ResultList queryTimedTask(String jobName, String triggerName);

ResultList delTimedTask(String jobName, String triggerName);

ResultList queryAllTask();

ResultList getRecentTriggerTime(String cron);

ResultList rescheduleJob(String jobName, String triggerName, String cron);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Service
public class TimedTaskServiceImpl implements ITimedTaskService {

/**
* 查询正在运行的定时任务
*
* @param jobName 任务名称
* @param triggerName 触发器名
* @return JobDetail
*/
@Override
public ResultList queryTimedTask(String jobName, String triggerName) {
HashMap<String, String> jobTask = QuartzUtil.getJob(jobName, triggerName);
return ResultUtil.verifyQuery(jobTask);
}

/**
* @param jobName 任务名称
* @param triggerName 触发器名
* @return 是否删除成功
*/
@Override
public ResultList delTimedTask(String jobName, String triggerName) {
boolean isDelJob = QuartzUtil.removeJob(jobName, triggerName);
return ResultUtil.verifyDelete(isDelJob);
}

/**
* @param jobName 任务名称
* @param triggerName 触发器名
* @return 是否删除成功
*/
@Override
public ResultList rescheduleJob(String jobName, String triggerName,String cron) {
if (!QuartzUtil.checkCronExpression(cron)) {
return ResultUtil.execute(Code.PARAM_FORMAT_ERROR, "cron解析错误");
}
boolean isModifyJob = QuartzUtil.rescheduleJob(jobName, triggerName,cron);
return ResultUtil.verifyUpdate(isModifyJob);
}

/**
* 查询所有正在运行的定时任务
*
* @return JobDetail
*/
@Override
public ResultList queryAllTask() {
List<HashMap<String, String>> list = QuartzUtil.getJobs();
return ResultUtil.verifyQuery(list, list.size());
}

/**
* @param cron cron表达式
* @return 最近5次的执行时间
*/
@Override
public ResultList getRecentTriggerTime(String cron) {
if (!QuartzUtil.checkCronExpression(cron)) {
return ResultUtil.execute(Code.PARAM_FORMAT_ERROR, "cron解析错误");
}
List<String> list = QuartzUtil.getRecentTriggerTime(cron);
return new ResultList(Code.SUCC.getCode(), Code.SUCC.getMsg(), new Result<>(list.size(), list));
}
}
  • 业务类实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class MigratedUsedListImpl implements IMigrateUsedList {
/**
* 定时将历史订单的数据迁移到备份数据库
*
* @param jobName 任务名称
* @param triggerName 触发器名称
* @param cron cron表达式
*/
@Override
public ResultList migrateUsedList(String jobName, String triggerName, String cron) {
if (!QuartzUtil.checkCronExpression(cron)) {
return ResultUtil.execute(Code.PARAM_FORMAT_ERROR, "cron解析错误");
}
String description = "将90天以前的历史订单迁移到xiaoJi_bak";
boolean isStart = QuartzUtil.addJob(jobName, triggerName, description, MigrateUsedList.class, cron);
if (isStart) {
return ResultUtil.execute(Code.SUCC);
}
return ResultUtil.execute(Code.FAIL);

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class HelloServiceImpl implements IHelloService {
@Override
public ResultList HelloTest(String jobName, String triggerName, String cron) {
if (!QuartzUtil.checkCronExpression(cron)) {
return ResultUtil.execute(Code.PARAM_FORMAT_ERROR, "cron解析错误");
}
String description = "HelloWorld测试";
boolean isStart = QuartzUtil.addJob(jobName, triggerName, description, HelloJob.class, cron);
if (isStart) {
return ResultUtil.execute(Code.SUCC);
}
return ResultUtil.execute(Code.FAIL);
}
}
  • 控制器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@RestController
@Api(tags = "定时任务接口")
@RequestMapping(value = "timed")
public class TimedTaskController {

@Resource
ITimedTaskService timedTaskServiceImpl;

@Resource
IMigrateUsedList migrateUsedListImpl;

@Resource
IHelloService helloServiceImpl;

@ApiOperation(value = "迁移历史订单数据定时任务", httpMethod = "POST")
@PostMapping(value = "/migrateUsedList")
public ResultList migrateUsedList(@RequestParam String jobName,
@RequestParam String triggerName,
@RequestParam String cron) {
return migrateUsedListImpl.migrateUsedList(jobName, triggerName, cron);
}

@ApiOperation(value = "定时任务测试", httpMethod = "POST")
@PostMapping(value = "/hello")
public ResultList helloTest(@RequestParam String jobName,
@RequestParam String triggerName,
@RequestParam String cron) {
return helloServiceImpl.HelloTest(jobName, triggerName, cron);
}

@ApiOperation(value = "查询所有定时任务", httpMethod = "GET")
@GetMapping(value = "/job")
public ResultList queryAllTask() {
return timedTaskServiceImpl.queryAllTask();
}

@ApiOperation(value = "查询定时任务", httpMethod = "GET")
@GetMapping(value = "/job/{jobName}/{triggerName}")
public ResultList queryTimedTask(@PathVariable String jobName, @PathVariable String triggerName) {
return timedTaskServiceImpl.queryTimedTask(jobName, triggerName);
}

@ApiOperation(value = "查询最近5次执行时间", httpMethod = "GET")
@GetMapping(value = "/job/{cron}")
public ResultList queryAllTask(@PathVariable String cron) {
return timedTaskServiceImpl.getRecentTriggerTime(cron);
}

@ApiOperation(value = "删除定时任务", httpMethod = "DELETE")
@DeleteMapping(value = "/job/{jobName}/{triggerName}")
public ResultList delTimedTask(@PathVariable String jobName, @PathVariable String triggerName) {
return timedTaskServiceImpl.delTimedTask(jobName, triggerName);
}

@ApiOperation(value = "修改定时任务执行时间", httpMethod = "POST")
@PostMapping(value = "/job/{jobName}/{triggerName}/{cron}")
public ResultList rescheduleJob(@PathVariable String jobName, @PathVariable String triggerName, @PathVariable String cron) {
return timedTaskServiceImpl.rescheduleJob(jobName, triggerName, cron);
}
}

执行结果:

image-20240715144446659

1
2
3
4
5
6
2024-07-15 14:43:57.008  INFO 5776 --- [eduler_Worker-1] com.frost2.quartz.common.job.HelloJob    : Hello Job - time: 2024-07-15T14:43:57.008757700
2024-07-15 14:43:59.000 INFO 5776 --- [eduler_Worker-2] com.frost2.quartz.common.job.HelloJob : Hello Job - time: 2024-07-15T14:43:59.000915500
2024-07-15 14:44:01.006 INFO 5776 --- [eduler_Worker-3] com.frost2.quartz.common.job.HelloJob : Hello Job - time: 2024-07-15T14:44:01.006727400
2024-07-15 14:44:03.001 INFO 5776 --- [eduler_Worker-4] com.frost2.quartz.common.job.HelloJob : Hello Job - time: 2024-07-15T14:44:03.001733900
2024-07-15 14:44:05.000 INFO 5776 --- [eduler_Worker-5] com.frost2.quartz.common.job.HelloJob : Hello Job - time: 2024-07-15T14:44:05.000841600
2024-07-15 14:44:07.002 INFO 5776 --- [eduler_Worker-6] com.frost2.quartz.common.job.HelloJob : Hello Job - time: 2024-07-15T14:44:07.002672700