4.项目实战 tlias教务系统

在整个实战篇中,我们需要完成如下功能:

  • 部门管理:查询、新增、修改、删除
  • 员工管理:
    • 查询、新增、修改、删除
    • 文件上传
  • 报表统计
  • 登录认证
  • 日志管理
  • 班级管理(自己实战内容)
  • 学员管理(自己实战内容)

–部门管理模块

1.部门管理

1.1基础知识

1.1.1前后端分离开发

img

我们将原先的工程分为前端工程和后端工程这2个工程,然后前端工程交给专业的前端人员开发,后端工程交给专业的后端人员开发。

前端页面需要数据,可以通过发送异步请求,从后台工程获取。但是,我们前后台是分开来开发的,那么前端人员怎么知道后台返回数据的格式呢?后端人员开发,怎么知道前端人员需要的数据格式呢?

所以针对这个问题,我们前后台统一制定一套规范!我们前后台开发人员都需要遵循这套规范开发,这就是我们的接口****文档

那么接口文档的内容怎么来的呢?是我们后台开发者根据产品经理提供的产品原型和需求文档所撰写出来的

那么基于前后台分离开发的模式下,我们后台开发者开发一个功能的具体流程如何呢?如下图所示:

img

  1. 需求分析:首先我们需要阅读需求文档,分析需求,理解需求。
  2. 接口定义:查询接口文档中关于需求的接口的定义,包括地址,参数,响应数据类型等等
  3. 前后台并行开发:各自按照接口文档进行开发,实现需求
  4. 测试:前后台开发完了,各自按照接口文档进行测试
  5. 前后段联调测试:前段工程请求后端工程,测试功能

1.1.2Restful风格

而在前后端进行交互的时候,我们需要基于当前主流的REST风格的API接口进行交互。

什么是REST风格呢?

  • REST(Representational State Transfer),表述性状态转换,它是一种软件架构风格。

传统URL风格如下:

我们看到,原始的传统URL呢,定义比较复杂,而且将资源的访问行为对外暴露出来了。而且,对于开发人员来说,每一个开发人员都有自己的命名习惯,就拿根据id查询用户信息来说的,不同的开发人员定义的路径可能是这样的:getByIdselectByIdqueryByIdloadById… 。 每一个人都有自己的命名习惯,如果都按照各自的习惯来,一个项目组,几十号或上百号人,那最终开发出来的项目,将会变得难以维护,没有一个统一的标准。

基于REST风格URL如下:

其中总结起来,就一句话:通过URL定位要操作的资源,通过HTTP动词(请求方式)来描述具体的操作。

在REST风格的URL中,通过四种请求方式,来操作数据的增删改查。

  • GET : 查询
  • POST :新增
  • PUT : 修改
  • DELETE :删除

注:

  • REST是风格,是约定方式,约定不是规定,可以打破
  • 描述模块的功能通常使用复数,也就是加s的格式来描述,表示此类资源,而非单个资源。如:users、emps、books…

1.1.3Apifox的使用

  • 前后端都在并行开发,后端开发完对应的接口之后,如何对接口进行请求测试呢?
  • 前后端都在并行开发,前端开发过程中,如何获取到数据,测试页面的渲染展示呢?

那这里我们就可以借助一些接口测试工具,比如项:Postman、Apipost、Apifox等。

详细使用可以在b站官方账号自行学习

1.2工程搭建

1). 创建SpringBoot工程,并引入web开发起步依赖、mybatis、mysql驱动、lombok

*2). 创建数据库及对应的表结构,并在application.yml中配置数据库的基本信息。*

创建tlias数据库,并准备dept部门表。

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE dept (
id int unsigned PRIMARY KEY AUTO_INCREMENT COMMENT 'ID, 主键',
name varchar(10) NOT NULL UNIQUE COMMENT '部门名称',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间'
) COMMENT '部门表';

INSERT INTO dept VALUES (1,'学工部','2023-09-25 09:47:40','2024-07-25 09:47:40'),
(2,'教研部','2023-09-25 09:47:40','2024-08-09 15:17:04'),
(3,'咨询部','2023-09-25 09:47:40','2024-07-30 21:26:24'),
(4,'就业部','2023-09-25 09:47:40','2024-07-25 09:47:40'),
(5,'人事部','2023-09-25 09:47:40','2024-07-25 09:47:40'),
(6,'行政部','2023-11-30 20:56:37','2024-07-30 20:56:37');

application.yml 配置文件中配置数据库的连接信息。

1
2
3
4
5
6
7
8
9
10
11
12
spring:
application:
name: tlias-web-management
#mysql连接配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/tlias
username: root
password: 1234
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

3). 准备基础包结构,并引入实体类Dept及统一的响应结果封装类Result

img

实体类Dept

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.itheima.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Dept {
private Integer id;
private String name;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

统一响应结果Result

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
package com.itheima.pojo;

import lombok.Data;
import java.io.Serializable;

/**
* 后端统一返回结果
*/
@Data
public class Result {

private Integer code; //编码:1成功,0为失败
private String msg; //错误信息
private Object data; //数据

public static Result success() {
Result result = new Result();
result.code = 1;
result.msg = "success";
return result;
}

public static Result success(Object object) {
Result result = new Result();
result.data = object;
result.code = 1;
result.msg = "success";
return result;
}

public static Result error(String msg) {
Result result = new Result();
result.msg = msg;
result.code = 0;
return result;
}

}
  • 基础代码结构
    • DeptMapper
    • DeptService
    • DeptServiceImpl
    • DeptController

2.查询部门

2.1基本实现

2.1.1需求

查询所有的部门数据,查询出来展示在部门管理的页面中。页面原型效果如下:

img

img

img

2.1.2实现思路

  • Controller层,负责接收前端发起的请求,并调用service查询部门数据,然后响应结果。
  • Service层,负责调用Mapper接口方法,查询所有部门数据。
  • Mapper层,执行查询所有部门数据的操作。

2.1.3代码实现

1). Controller层

DeptController 中,增加 list 方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/** * 部门管理控制器 */
@RestController
public class DeptController {

@Autowired
private DeptService deptService;

/** * 查询部门列表 */@RequestMapping("/depts")
public Result list(){
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}
}

2). Service层

DeptService 中,增加 findAll方法,代码如下:

1
2
3
4
public interface DeptService {
/** * 查询所有部门 */
public List<Dept> findAll();
}

DeptServiceImpl 中,增加 findAll方法,代码如下:

1
2
3
4
5
6
7
8
9
10
@Service
public class DeptServiceImpl implements DeptService {

@Autowired
private DeptMapper deptMapper;

public List<Dept> findAll() {
return deptMapper.findAll();
}
}

3). Mapper层

DeptMapper 中,增加 findAll方法,代码如下:

1
2
3
4
5
6
7
@Mapper
public interface DeptMapper {
/** * 查询所有部门 */
@Select("select * from dept")
public List<Dept> findAll();

}

2.1.4接口测试

打开Apifox进行测试

img

我们发现,已经查询出了所有的部门数据,并且响应回来的就是json格式的数据,与接口文档一致。 那接下来,我们再来测试一下,这个查询操作,我们使用post、put、delete方式来请求,是否可以获取到数据。

img

经过测试,我们发现,现在我们其实是可以通过任何方式的请求来访问查询部门的这个接口的。 而在接口文档中,明确要求该接口的请求方式为GET,那么如何限制请求方式呢?

方式一:在controller方法的@RequestMapping注解中通过method属性来限定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class DeptController {

@Autowired
private DeptService deptService;

/**
* 查询部门列表
*/
@RequestMapping(value = "/depts", method = RequestMethod.GET)
public Result list(){
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}
}

方式二:在controller方法上使用,@RequestMapping的衍生注解 @GetMapping。 该注解就是标识当前方法,必须以GET方式请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class DeptController {

@Autowired
private DeptService deptService;

/**
* 查询部门列表
*/
@GetMapping("/depts")
public Result list(){
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}
}

推荐使用第二种方式

  • GET方式:@GetMapping
  • POST方式:@PostMapping
  • PUT方式:@PutMapping
  • DELETE方式:@DeleteMapping

2.1.5数据封装

在测试中,我们发现部门的数据中,id、name两个属性是有值的,但是createTime、updateTime两个字段值并未成功封装,而数据库中是有对应的字段值的,这是为什么呢?

img

原因如下:

  • 实体类属性名和数据库表查询返回的字段名一致,mybatis会自动封装。
  • 如果实体类属性名和数据库表查询返回的字段名不一致,不能自动封装。

解决方案:

  • 手动结果映射
  • 起别名
  • 开启驼峰命名

1). 手动结果映射

在DeptMapper接口方法上,通过 @Results及@Result 进行手动结果映射。

1
2
3
4
@Results({@Result(column = "create_time", property = "createTime"),
@Result(column = "update_time", property = "updateTime")})
@Select("select id, name, create_time, update_time from dept")
public List<Dept> findAll();

2). 起别名

在SQL语句中,对不一样的列名起别名,别名和实体类属性名一样。

3). 开启驼峰命名**(推荐)**

如果字段名与属性名符合驼峰命名规则,mybatis会自动通过驼峰命名规则映射。驼峰命名规则: abc_xyz => abcXyz

  • 表中字段名:abc_xyz
  • 类中属性名:abcXyz

在application.yml中做如下配置,开启开关。

1
2
3
mybatis:
configuration:
map-underscore-to-camel-case: true

2.2了解前后端联调时的请求访问过程(反向代理)

前端工程请求服务器的地址为 http://localhost:90/api/depts,是如何访问到后端的tomcat服务器的?

img

其实这里,是通过前端服务Nginx中提供的反向代理功能实现的。

img

1). 浏览器发起请求,请求的是localhost:90 ,那其实请求的是nginx服务器。

2). 在nginx服务器中呢,并没有对请求直接进行处理,而是将请求转发给了后端的tomcat服务器,最终由tomcat服务器来处理该请求。

这个过程就是通过nginx的反向代理实现的。

问:那为什么浏览器不直接请求后端的tomcat服务器,而是直接请求nginx服务器呢,主要有以下几点原因:

1). 安全:由于后端的tomcat服务器一般都会搭建集群,会有很多的服务器,把所有的tomcat暴露给前端,让前端直接请求tomcat,对于后端服务器是比较危险的。

2). 灵活:基于nginx的反向代理实现,更加灵活,后端想增加、减少服务器,对于前端来说是无感知的,只需要在nginx中配置即可。

3). 负载均衡:基于nginx的反向代理,可以很方便的实现后端tomcat的负载均衡操作。

img

具体的请求访问流程如下:

img

  1. location:用于定义匹配特定uri请求的规则。
  2. ^~ /api/:表示精确匹配,即只匹配以/api/开头的路径。
  3. rewrite:该指令用于重写匹配到的uri路径。
  4. proxy_pass:该指令用于代理转发,它将匹配到的请求转发给位于后端的指令服务器。

3.删除部门

3.1基本实现

3.1.1需求

删除部门数据。在点击 “删除” 按钮,会根据ID删除部门数据。

img

img

img

3.1.2实现思路

img

3.1.3简单参数接收

在controller中,需要接收前端传递的请求参数。 那接下来,就先来看看在服务器端的Controller程序中,如何获取这类简单参数。 具体的方案有如下三种:

方案一:通过原始的 HttpServletRequest 对象获取请求参数

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据ID删除部门 - 简单参数接收: 方式一 (HttpServletRequest)
*/
@DeleteMapping("/depts")
public Result delete(HttpServletRequest request){
String idStr = request.getParameter("id");
int id = Integer.parseInt(idStr);

System.out.println("根据ID删除部门: " + id);
return Result.success();
}

这种方案实现较为繁琐,而且还需要进行手动类型转换。【项目开发很少用】

方案二:通过Spring提供的 @RequestParam 注解,将请求参数绑定给方法形参

1
2
3
4
5
@DeleteMapping("/depts")
public Result delete(@RequestParam("id") Integer deptId){
System.out.println("根据ID删除部门: " + deptId);
return Result.success();
}

@RequestParam 注解的value属性,需要与前端传递的参数名保持一致 。

@RequestParam注解required属性默认为true,代表该参数必须传递,如果不传递将报错。 如果参数可选,可以将属性设置为false。

方案三:如果请求参数名与形参变量名相同,直接定义方法形参即可接收。(省略@RequestParam)

1
2
3
4
5
@DeleteMapping("/depts")
public Result delete(Integer id){
System.out.println("根据ID删除部门: " + deptId);
return Result.success();
}

对于以上的这三种方案,推荐第三种方案

3.1.4代码实现

1). Controller层

DeptMapper 中,增加 delete 方法,代码实现如下:

1
2
3
4
5
6
7
8
9
/**
* 根据id删除部门 - delete http://localhost:8080/depts?id=1
*/
@DeleteMapping("/depts")
public Result delete(Integer id){
System.out.println("根据id删除部门, id=" + id);
deptService.deleteById(id);
return Result.success();
}

2). Service层

DeptService 中,增加 deleteById 方法,代码实现如下:

1
2
/** * 根据id删除部门 */
void deleteById(Integer id);

DeptServiceImpl 中,增加 deleteById 方法,代码实现如下:

1
2
3
public void deleteById(Integer id) {
deptMapper.deleteById(id);
}

3). Mapper层

DeptMapper 中,增加 deleteById 方法,代码实现如下:

1
2
3
/** * 根据id删除部门 */
@Delete("delete from dept where id = #{id}")
void deleteById(Integer id);

对于 DML 语句来说,执行完毕,也是有返回值的,返回值代表的是增删改操作,影响的记录数,所以可以将执行 DML 语句的方法返回值设置为 Integer。 但是一般开发时,是不需要这个返回值的,所以也可以设置为void。

4.新增部门

4.1基本实现

4.1.1需求

点击 “新增部门” 的按钮之后,弹出新增部门表单,填写部门名称之后,点击确定之后,保存部门数据。

img

img

img

4.1.2实现思路

img

4.1.3json参数接收

在controller中,需要接收前端传递的请求参数。 那接下来,我们就先来看看在服务器端的Controller程序中,如何获取json格式的参数。

  • JSON格式的参数,通常会使用一个实体对象进行接收 。
  • 规则:JSON数据的键名与方法形参对象的属性名相同,并需要使用@RequestBody注解标识。

前端传递的请求参数格式为json,内容如下:{"name":"研发部"}。这里,我们可以通过一个对象来接收,只需要保证对象中有name属性即可。

4.1.4代码实现

1). Controller层

DeptController中增加方法save,具体代码如下:

1
2
3
4
5
6
7
/** * 新增部门 - POST http://localhost:8080/depts   请求参数:{"name":"研发部"} */
@PostMapping("/depts")
public Result save(@RequestBody Dept dept){
System.out.println("新增部门, dept=" + dept);
deptService.save(dept);
return Result.success();
}

2). Service层

DeptService中增加接口方法save,具体代码如下:

1
2
/** * 新增部门 */
void save(Dept dept);

DeptServiceImpl中增加save方法,完成添加部门的操作,具体代码如下:

1
2
3
4
5
6
7
public void save(Dept dept) {
//补全基础属性
dept.setCreateTime(LocalDateTime.now());
dept.setUpdateTime(LocalDateTime.now());
//保存部门
deptMapper.insert(dept);
}

3). Mapper层

1
2
3
/** * 保存部门 */
@Insert("insert into dept(name,create_time,update_time) values(#{name},#{createTime},#{updateTime})")
void insert(Dept dept);

如果在mapper接口中,需要传递多个参数,可以把多个参数封装到一个对象中。 在SQL语句中获取参数的时候,#{...} 里面写的是对象的属性名【注意是属性名,不是表的字段名】。

5.修改部门

对于任何业务的修改功能来说,一般都会分为两步进行:查询回显、修改数据。

5.1查询回显

5.1.1需求

当我们点击 “编辑” 的时候,需要根据ID查询部门数据,然后用于页面回显展示。

img

img

img

5.1.2实现思路

img

5.1.3路径参数接收

/depts/1/depts/2 这种在url中传递的参数,我们称之为路径参数。 那么如何接收这样的路径参数呢 ?

路径参数:通过请求URL直接传递参数,使用{…}来标识该路径参数,需要使用 **@PathVariable**获取路径参数。如下所示:

img

如果路径参数名与controller方法形参名称一致,@PathVariable注解的value属性是可以省略的

5.1.4代码实现

1). Controller层

DeptController 中增加 getById方法,具体代码如下:

1
2
3
4
5
6
7
/** * 根据ID查询 - GET http://localhost:8080/depts/1 */
@GetMapping("/depts/{id}")
public Result getById(@PathVariable Integer id){
System.out.println("根据ID查询, id=" + id);
Dept dept = deptService.getById(id);
return Result.success(dept);
}

2). Service层

DeptService 中增加 getById方法,具体代码如下:

1
2
/** * 根据id查询部门 */
Dept getById(Integer id);

DeptServiceImpl 中增加 getById方法,具体代码如下:

1
2
3
public Dept getById(Integer id) {
return deptMapper.getById(id);
}

3). Mapper层

DeptMapper 中增加 getById 方法,具体代码如下:

/** * 根据ID查询部门数据 */ @Select(“select id, name, create_time, update_time from dept where id = #{id}”) Dept getById(Integer id);

5.2修改数据

5.2.1需求

查询回显回来之后,就可以对部门的信息进行修改了,修改完毕之后,点击确定,此时,就需要根据ID修改部门的数据。

img

img

img

5.2.2实现思路

img

通过接口文档,我们可以看到前端传递的请求参数是json格式的请求参数,在Controller的方法中,我们可以通过 @RequestBody 注解来接收,并将其封装到一个对象中。

5.2.3代码实现

1). Controller层

DeptController 中增加 update 方法,具体代码如下:

1
2
3
4
5
6
7
/** * 修改部门 - PUT http://localhost:8080/depts  请求参数:{"id":1,"name":"研发部"} */
@PutMapping("/depts")
public Result update(@RequestBody Dept dept){
System.out.println("修改部门, dept=" + dept);
deptService.update(dept);
return Result.success();
}

2). Service层

DeptService 中增加 update 方法。

1
2
/** * 修改部门 */
void update(Dept dept);

DeptServiceImpl 中增加 update 方法。 由于是修改操作,每一次修改数据,都需要更新updateTime。所以,具体代码如下:

1
2
3
4
5
6
public void update(Dept dept) {
//补全基础属性
dept.setUpdateTime(LocalDateTime.now());
//保存部门
deptMapper.update(dept);
}

3). Mapper层

DeptMapper 中增加 update 方法,具体代码如下:

1
2
3
/** * 更新部门 */
@Update("update dept set name = #{name},update_time = #{updateTime} where id = #{id}")
void update(Dept dept);

5.3@RequestMapping

到此,关于基本的部门的增删改查功能,已经实现了。 我们会发现,我们在 DeptController 中所定义的方法,所有的请求路径,都是 /depts 开头的,只要操作的是部门数据,请求路径都是 /depts 开头。

那么这个时候,我们其实是可以把这个公共的路径 /depts 抽取到类上的,那在各个方法上,就可以省略了这个 /depts 路径。 代码如下:

img

一个完整的请求路径,应该是类上的 @RequestMapping 的value属性 + 方法上的 @RequestMapping的value属性。

6.日志技术(Logback)

6.1日志框架

img

  • JUL**:**这是JavaSE平台提供的官方日志框架,也被称为JUL。配置相对简单,但不够灵活,性能较差。
  • Log4j**:**一个流行的日志框架,提供了灵活的配置选项,支持多种输出目标。
  • **Logback:**基于Log4j升级而来,提供了更多的功能和配置选项,性能由于Log4j。
  • Slf4j**:**(Simple Logging Facade for Java)简单日志门面,提供了一套日志操作的标准接口及抽象类,允许应用程序使用不同的底层日志框架。

6.2Logback入门

1). 准备工作:引入logback的依赖(springboot中无需引入,在springboot中已经传递了此依赖)

2). 引入配置文件 logback.xml (直接AI生成)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度 %msg:日志消息,%n是换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>

<!-- 日志输出级别 -->
<root level="ALL">
<appender-ref ref="STDOUT" />
</root>
</configuration>

3). 记录日志:定义日志记录对象Logger,记录日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LogTest {

//定义日志记录对象
private static final Logger log = LoggerFactory.getLogger(LogTest.class);

@Test
public void testLog(){
log.debug("开始计算...");
int sum = 0;
int[] nums = {1, 5, 3, 2, 1, 4, 5, 4, 6, 7, 4, 34, 2, 23};
for (int i = 0; i < nums.length; i++) {
sum += nums[i];
}
log.info("计算结果为: "+sum);log.debug("结束计算...");
}

}

运行单元测试,可以在控制台中看到输出的日志,如下所示:

img

我们可以看到在输出的日志信息中,不仅输出了日志的信息,还包括:日志的输出时间、线程名、具体在那个类中输出的。

6.3Logback配置文件

Logback日志框架的配置文件叫 logback.xml

该配置文件是对Logback日志框架输出的日志进行控制的,可以来配置输出的格式、位置及日志开关等。

常用的两种输出日志的位置:控制台、系统文件。

1). 如果需要输出日志到控制台。添加如下配置:

1
2
3
4
5
6
7
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%msg表示日志消息,%n表示换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>

2). 如果需要输出日志到文件。添加如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 按照每天生成日志文件 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 日志文件输出的文件名, %i表示序号 -->
<FileNamePattern>D:/tlias-%d{yyyy-MM-dd}-%i.log</FileNamePattern>
<!-- 最多保留的历史日志文件数量 -->
<MaxHistory>30</MaxHistory>
<!-- 最大文件大小,超过这个大小会触发滚动到新文件,默认为 10MB -->
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>

<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%msg表示日志消息,%n表示换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>

3). 日志开关配置 (开启日志(ALL),取消日志(OFF))

1
2
3
4
5
6
7
<!-- 日志输出级别 -->
<root level="ALL">
<!--输出到控制台-->
<appender-ref ref="STDOUT" />
<!--输出到文件-->
<appender-ref ref="FILE" />
</root>

6.4日志级别

常见的日志级别如下(优先级由低到高):

日志级别 说明 记录方式
trace 追踪,记录程序运行轨迹 【使用很少】 log.trace(“…”)
debug 调试,记录程序调试过程中的信息,实际应用中一般将其视为最低级别 【使用较多】 log.debug(“…”)
info 记录一般信息,描述程序运行的关键事件,如:网络连接、io操作 【使用较多】 log.info(“…”)
warn 警告信息,记录潜在有害的情况 【使用较多】 log.warn(“…”)
error 错误信息 【使用较多】 log.error(“…”)

可以在配置文件logback.xml中,灵活的控制输出那些类型的日志。(大于等于配置的日志级别的日志才会输出)

1
2
3
4
5
6
7
<!-- 日志输出级别 -->
<root level="info">
<!--输出到控制台-->
<appender-ref ref="STDOUT" />
<!--输出到文件-->
<appender-ref ref="FILE" />
</root>

6.5案例日志记录

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
/**
* 部门管理控制器
*/
@Slf4j
@RequestMapping("/depts")
@RestController
public class DeptController {

@Autowired
private DeptService deptService;

/**
* 查询部门列表
*/
//@RequestMapping(value = "/depts", method = RequestMethod.GET)
@GetMapping
public Result list(){
//System.out.println("查询部门列表");
log.info("查询部门列表");
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}

/**
* 根据id删除部门 - delete http://localhost:8080/depts?id=1
*/
@DeleteMapping
public Result delete(Integer id){
//System.out.println("根据id删除部门, id=" + id);
log.info("根据id删除部门, id: {}" , id);
deptService.deleteById(id);
return Result.success();
}

/**
* 新增部门 - POST http://localhost:8080/depts 请求参数:{"name":"研发部"}
*/
@PostMapping
public Result save(@RequestBody Dept dept){
//System.out.println("新增部门, dept=" + dept);
log.info("新增部门, dept: {}" , dept);
deptService.save(dept);
return Result.success();
}

/**
* 根据ID查询 - GET http://localhost:8080/depts/1
*/
@GetMapping("/{id}")
public Result getById(@PathVariable Integer id){
//System.out.println("根据ID查询, id=" + id);
log.info("根据ID查询, id: {}" , id);
Dept dept = deptService.getById(id);
return Result.success(dept);
}

/**
* 修改部门 - PUT http://localhost:8080/depts 请求参数:{"id":1,"name":"研发部"}
*/
@PutMapping
public Result update(@RequestBody Dept dept){
//System.out.println("修改部门, dept=" + dept);
log.info("修改部门, dept: {}" , dept);
deptService.update(dept);
return Result.success();
}
}

lombok中提供的@Slf4j注解,可以简化定义日志记录器这步操作。添加了该注解,就相当于在类中定义了日志记录器,就下面这句代码:

1
private static Logger log = LoggerFactory. getLogger(Xxx. class);

注意:请指定稳定的lombok,因为最近jdk可能会出现兼容问题,通过指定版本 1.18.30防止报错

–员工管理模块

img

从页面原型中,我们可以看到,在查询员工信息的时候,除了要展示 姓名、性别、头像、职位、入职日期、最后操作时间这些员工信息外,还要展示出所属部门,那此时就需要从两张表中查询数据,一张是部门表,一张是员工表,此时就会涉及到多表操作。(具体见mysql内容,这里不花时间阐述)

1.员工列表查询

那接下来,我们要来完成的是员工列表的查询功能实现。 具体的需求如下:

img

img

img

img

在查询员工列表数据时,既需要查询 员工的基本信息,还需要查询员工所属的部门名称,所以这里,会涉及到多表查询的操作。

而且,在查询员工列表数据时,既要考虑搜索栏中的查询条件,还要考虑对查询的结果进行分页处理。

那么接下来,我们在实现这个功能时,将会分为三个部分来逐一实现:

  • 准备工作
  • 分页查询
  • 条件分页查询

1.1准备工作

需求:查询所有员工信息,并查询出部门名称。(涉及到的表:emp、dept)

1.1.1基础代码准备

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
49
50
51
52
53
54
55
56
57
58
59
60
-- 员工表
create table emp(
id int unsigned primary key auto_increment comment 'ID,主键',
username varchar(20) not null unique comment '用户名',
password varchar(50) default '123456' comment '密码',
name varchar(10) not null comment '姓名',
gender tinyint unsigned not null comment '性别, 1:男, 2:女',
phone char(11) not null unique comment '手机号',
job tinyint unsigned comment '职位, 1 班主任, 2 讲师 , 3 学工主管, 4 教研主管, 5 咨询师',
salary int unsigned comment '薪资',
image varchar(300) comment '头像',
entry_date date comment '入职日期',
dept_id int unsigned comment '部门ID',
create_time datetime comment '创建时间',
update_time datetime comment '修改时间'
) comment '员工表';


INSERT INTO emp VALUES
(1,'shinaian','123456','施耐庵',1,'13309090001',4,15000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2000-01-01',2,'2023-10-20 16:35:33','2023-11-16 16:11:26'),
(2,'songjiang','123456','宋江',1,'13309090002',2,8600,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2015-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:37'),
(3,'lujunyi','123456','卢俊义',1,'13309090003',2,8900,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2008-05-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:39'),
(4,'wuyong','123456','吴用',1,'13309090004',2,9200,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2007-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:41'),
(5,'gongsunsheng','123456','公孙胜',1,'13309090005',2,9500,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2012-12-05',2,'2023-10-20 16:35:33','2023-10-20 16:35:43'),
(6,'huosanniang','123456','扈三娘',2,'13309090006',3,6500,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2013-09-05',1,'2023-10-20 16:35:33','2023-10-20 16:35:45'),
(7,'chaijin','123456','柴进',1,'13309090007',1,4700,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2005-08-01',1,'2023-10-20 16:35:33','2023-10-20 16:35:47'),
(8,'likui','123456','李逵',1,'13309090008',1,4800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2014-11-09',1,'2023-10-20 16:35:33','2023-10-20 16:35:49'),
(9,'wusong','123456','武松',1,'13309090009',1,4900,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2011-03-11',1,'2023-10-20 16:35:33','2023-10-20 16:35:51'),
(10,'linchong','123456','林冲',1,'13309090010',1,5000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2013-09-05',1,'2023-10-20 16:35:33','2023-10-20 16:35:53'),
(11,'huyanzhuo','123456','呼延灼',1,'13309090011',2,9700,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2007-02-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:55'),
(12,'xiaoliguang','123456','小李广',1,'13309090012',2,10000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2008-08-18',2,'2023-10-20 16:35:33','2023-10-20 16:35:57'),
(13,'yangzhi','123456','杨志',1,'13309090013',1,5300,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2012-11-01',1,'2023-10-20 16:35:33','2023-10-20 16:35:59'),
(14,'shijin','123456','史进',1,'13309090014',2,10600,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2002-08-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:01'),
(15,'sunerniang','123456','孙二娘',2,'13309090015',2,10900,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2011-05-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:03'),
(16,'luzhishen','123456','鲁智深',1,'13309090016',2,9600,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2010-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:05'),
(17,'liying','12345678','李应',1,'13309090017',1,5800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2015-03-21',1,'2023-10-20 16:35:33','2023-10-20 16:36:07'),
(18,'shiqian','123456','时迁',1,'13309090018',2,10200,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2015-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:09'),
(19,'gudasao','123456','顾大嫂',2,'13309090019',2,10500,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2008-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:11'),
(20,'ruanxiaoer','123456','阮小二',1,'13309090020',2,10800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2018-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:13'),
(21,'ruanxiaowu','123456','阮小五',1,'13309090021',5,5200,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2015-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:15'),
(22,'ruanxiaoqi','123456','阮小七',1,'13309090022',5,5500,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2016-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:17'),
(23,'ruanji','123456','阮籍',1,'13309090023',5,5800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2012-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:19'),
(24,'tongwei','123456','童威',1,'13309090024',5,5000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2006-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:21'),
(25,'tongmeng','123456','童猛',1,'13309090025',5,4800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2002-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:23'),
(26,'yanshun','123456','燕顺',1,'13309090026',5,5400,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2011-01-01',3,'2023-10-20 16:35:33','2023-11-08 22:12:46'),
(27,'lijun','123456','李俊',1,'13309090027',2,6600,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2004-01-01',2,'2023-10-20 16:35:33','2023-11-16 17:56:59'),
(28,'lizhong','123456','李忠',1,'13309090028',5,5000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2007-01-01',3,'2023-10-20 16:35:33','2023-11-17 16:34:22'),
(30,'liyun','123456','李云',1,'13309090030',NULL,NULL,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2020-03-01',NULL,'2023-10-20 16:35:33','2023-10-20 16:36:31'),
(36,'linghuchong','123456','令狐冲',1,'18809091212',2,6800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2023-10-19',2,'2023-10-20 20:44:54','2023-11-09 09:41:04');


-- 员工工作经历信息
create table emp_expr(
id int unsigned primary key auto_increment comment 'ID, 主键',
emp_id int unsigned comment '员工ID',
begin date comment '开始时间',
end date comment '结束时间',
company varchar(50) comment '公司名称',
job varchar(50) comment '职位'
)comment '工作经历';

2). 准备emp表对应的实体类Emp、EmpExpr

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
package com.itheima.pojo;

import lombok.Data;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

@Data
public class Emp {
private Integer id; //ID,主键
private String username; //用户名
private String password; //密码
private String name; //姓名
private Integer gender; //性别, 1:男, 2:女
private String phone; //手机号
private Integer job; //职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师
private Integer salary; //薪资
private String image; //头像
private LocalDate entryDate; //入职日期
private Integer deptId; //关联的部门ID
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间

//封装部门名称数
private String deptName; //部门名称
}
package com.itheima.pojo;

import lombok.Data;

import java.time.LocalDate;

/**
* 工作经历
*/
@Data
public class EmpExpr {
private Integer id; //ID
private Integer empId; //员工ID
private LocalDate begin; //开始时间
private LocalDate end; //结束时间
private String company; //公司名称
private String job; //职位
}
package com.itheima.pojo;

import lombok.Data;

import java.time.LocalDate;

/**
* 工作经历
*/
@Data
public class EmpExpr {
private Integer id; //ID
private Integer empId; //员工ID
private LocalDate begin; //开始时间
private LocalDate end; //结束时间
private String company; //公司名称
private String job; //职位
}

3). 准备Emp员工管理的基础结构,包括****Controller、Service、Mapper

EmpMapper:

1
2
3
4
5
6
7
8
9
10
package com.itheima.mapper;

import com.itheima.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;

@Mapper
public interface EmpMapper {

}

EmpService:

1
2
3
4
package com.itheima.service;

public interface EmpService {
}

EmpServiceImpl:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.itheima.service.impl;

import com.itheima.mapper.EmpMapper;
import com.itheima.service.EmpService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/** * 员工管理 */
@Service
public class EmpServiceImpl implements EmpService {

@Autowired
private EmpMapper empMapper;

}

EmpController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.itheima.controller;

import com.itheima.service.EmpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RestController;

/** * 员工管理 */
@Slf4j
@RestController
public class EmpController {

@Autowired
private EmpService empService;

}

1.1.2SQL&Mapper接口

1). 对应的SQL语句

1
2
-- 查询所有的员工信息,如果员工关联了部门,也要查询出部门名称
select e.*, d.name as dept_name from emp e left join dept d on e.dept_id = d.id;

2). Mapper接口方法定义

那接下来,我们就定义一个员工管理的mapper接口 EmpMapper 并在其中完成员工信息的查询。 具体代码如下:

1
2
3
4
5
6
7
8
9
10
@Mapper
public interface EmpMapper {

/**
* 查询所有的员工及其对应的部门名称
*/
@Select("select e.*, d.name as deptName from emp e left join dept d on e.dept_id = d.id")
public List<Emp> list();

}

注意,上述SQL语句中,给 部门名称起了别名 deptName ,是因为在接口文档中,要求部门名称给前端返回的数据中,就必须叫 deptName。 而这里我们需要将查询返回的每一条记录都封装到Emp对象中,那么就必须保证查询返回的字段名与属性名是一一对应的。

此时,我们就需要在Emp中定义一个属性 deptName 用来封装部门名称。 具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Data
public class Emp {
private Integer id; //ID,主键
private String username; //用户名
private String password; //密码
private String name; //姓名
private Integer gender; //性别, 1:男, 2:女
private String phone; //手机号
private Integer job; //职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师
private Integer salary; //薪资
private String image; //头像
private LocalDate entryDate; //入职日期
private Integer deptId; //关联的部门ID
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间

//封装部门名称数
private String deptName; //部门名称
}

代码编写完毕后,我们可以编写一个单元测试,对上述的程序进行测试:

1.2分页查询

每次只展示一页的数据,比如:一页展示10条数据,如果还想看其他的数据,可以通过点击页码进行查询。

而在员工管理的需求中,就要求我们进行分页查询,展示出对应的数据。 具体的页面原型如下:

img

要想从数据库中进行分页查询,我们要使用LIMIT关键字,格式为:limit 开始索引 每页显示的条数。

查询第1,2,3页数据的SQL语句是:

1
2
3
select * from emp  limit 0,10;
select * from emp limit 10,10;
select * from emp limit 20,10;

观察以上SQL语句,发现: 开始索引一直在改变 , 每页显示条数是固定的

开始索引的计算公式: 开始索引 = (当前页码 - 1) \* 每页显示条数

我们继续基于页面原型,继续分析,得出以下结论:

  1. 前端在请求服务端时,传递的参数
    1. 当前页码 page
    2. 每页显示条数 pageSize
  2. 后端需要响应什么数据给前端
    1. 所查询到的数据列表(存储到List 集合中)
    2. 总记录数

img

后台给前端返回的数据包含:List集合(数据列表)、total(总记录数)

而这两部分我们通常封装到PageResult对象中,并将该对象转换为json格式的数据响应回给浏览器。

1
2
3
4
5
6
7
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult {
private Long total; //总记录数
private List rows; //当前页数据列表
}

大家会发现:分页查询功能编写起来比较繁琐。 而分页查询的功能是非常常见的,分页查询的思路、步骤是比较固定的。 在Mapper接口中定义两个方法执行两条不同的SQL语句:

  1. 查询总记录数
  2. 指定页码的数据列表

在Service当中,调用Mapper接口的两个方法,分别获取:总记录数、查询结果列表,然后在将获取的数据结果封装到PageBean对象中。

如果在未来开发其他项目,只要涉及到分页查询功能(例:订单、用户、支付、商品),都必须按照以上操作完成功能开发

结论:原始方式的分页查询,存在着"步骤固定"、"代码频繁"的问题

解决方案:可以使用一些现成的分页插件完成。对于Mybatis来讲现在最主流的就是PageHelper。

1.3PageHelper分页插件

1.3.1介绍

PageHelper是第三方提供的Mybatis框架中的一款功能强大、方便易用的分页插件,支持任何形式的单标、多表的分页查询。

官网:https://pagehelper.github.io/

那接下来,我们可以对比一下,使用PageHelper分页插件进行分页 与 原始方式进行分页代码实现的上的差别。

img

  • Mapper接口层:
    • 原始的分页查询功能中,我们需要在Mapper接口中定义两条SQL语句。
    • PageHelper实现分页查询之后,只需要编写一条SQL语句,而且不需要考虑分页操作,就是一条正常的查询语句。
  • Service层:
    • 需要根据页码、每页展示记录数,手动的计算起始索引。
    • 无需手动计算起始索引,直接告诉PageHelper需要查询那一页的数据,每页展示多少条记录即可。

1.3.2代码实现

当使用了PageHelper分页插件进行分页,就无需再Mapper中进行手动分页了。 在Mapper中我们只需要进行正常的列表查询即可。在Service层中,调用Mapper的方法之前设置分页参数,在调用Mapper方法执行查询之后,解析分页结果,并将结果封装到PageResult对象中返回。

1). 在pom.xml引入依赖

1
2
3
4
5
6
<!--分页插件PageHelper-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>

2). EmpMapper

1
2
3
4
5
/**
* 查询所有的员工及其对应的部门名称
*/
@Select("select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id")
public List<Emp> list();

3). EmpServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public PageResult page(Integer page, Integer pageSize) {
//1. 设置分页参数
PageHelper.startPage(page,pageSize);

//2. 执行查询
List<Emp> empList = empMapper.list();
Page<Emp> p = (Page<Emp>) empList;

//3. 封装结果
return new PageResult(p.getTotal(), p.getResult());
}

1.3.3pageHelper的实现机制

我们打开Idea的控制台,可以看到在进行分页查询时,输出的SQL语句。

img

我们看到执行了两条SQL语句,而这两条SQL语句,其实是从我们在Mapper接口中定义的SQL演变而来的。

  • 第一条SQL语句,用来查询总记录数。

img

其实就是将我们编写的SQL语句进行的改造增强,将查询返回的字段列表替换成了 count(0) 来统计总记录数。

  • 第二条SQL语句,用来进行分页查询,查询指定页码对应 的数据列表。

img

其实就是将我们编写的SQL语句进行的改造增强,在SQL语句之后拼接上了limit进行分页查询,而由于测试时查询的是第一页,起始索引是0,所以简写为limit ?。

而PageHelper在进行分页查询时,会执行上述两条SQL语句,并将查询到的总记录数,与数据列表封装到了 Page<Emp> 对象中,我们再获取查询结果时,只需要调用Page对象的方法就可以获取。

注意:

  • PageHelper实现分页查询时,SQL语句的结尾一定一定一定不要加分号(;).,容易出现拼接问题
  • PageHelper只会对紧跟在其后的第一条SQL语句进行分页处理。

1.4条件分页查询

完了分页查询后,下面我们需要在分页查询的基础上,添加条件。

1.4.1需求

img

通过员工管理的页面原型我们可以看到,员工列表页面的查询,不仅仅需要考虑分页,还需要考虑查询条件。 分页查询我们已经实现了,接下来,我们需要考虑在分页查询的基础上,再加上查询条件。

我们看到页面原型及需求中描述,搜索栏的搜索条件有三个,分别是:

  • 姓名:模糊匹配
  • 性别:精确匹配
  • 入职日期:范围匹配

img

img

img

1.4.2实现思路

img

1.4.3代码实现

在原有分页查询的代码基础上进行改造。

1). 在EmpController方法中通过多个方法形参,依次接收这几个参数

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

@Autowired
private EmpService empService;

@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
String name, Integer gender,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
log.info("查询请求参数: {}, {}, {}, {}, {}, {}", page, pageSize, name, gender, begin, end);
PageResult pageResult = empService.page(page, pageSize);
return Result.success(pageResult);
}
}

2). 修改EmpService及EmpServiceImpl中的代码逻辑

EmpService:

1
2
3
4
5
6
public interface EmpService {
/**
* 分页查询
*/
PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin, LocalDate end);
}

EmpServiceImpl:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/** 
* 员工管理
*/
@Service
public class EmpServiceImpl implements EmpService {

@Autowired
private EmpMapper empMapper;

@Override
public PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin, LocalDate end) {
//1. 设置PageHelper分页参数
PageHelper.startPage(page, pageSize);
//2. 执行查询
List<Emp> empList = empMapper.list(name, gender, begin, end);
//3. 封装分页结果
Page<Emp> p = (Page<Emp>) empList;
return new PageResult(p.getTotal(), p.getResult());
}
}

3). 调整EmpMapper接口方法

1
2
3
4
5
6
7
8
9
@Mapper
public interface EmpMapper {

/**
* 查询所有的员工及其对应的部门名称
*/
public List<Emp> list(String name, Integer gender, LocalDate begin, LocalDate end);

}

由于SQL语句比较复杂,建议将SQL语句配置在XML映射文件中。

4). 新增Mapper映射文件**EmpMapper.xml**

1
2
3
4
5
6
7
8
9
10
11
12
<!--定义Mapper映射文件的约束和基本结构-->
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.EmpMapper">
<select id="list" resultType="com.itheima.pojo.Emp">
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id
where e.name like concat('%',#{name},'%')
and e.gender = #{gender}
and e.entry_date between #{begin} and #{end}
</select>
</mapper>

1.4.4程序优化1(请求参数封装实体类简化代码)

在上述分页条件查询中,请求参数比较多,有6个,如下所示:

  • 请求参数:/emps?name=张&gender=1&begin=2007-09-01&end=2022-09-01&page=1&pageSize=10

那我们在controller层方法中,接收请求参数的时候,直接在controller方法中声明这样6个参数即可,这样做,功能可以实现,但是不方便维护和管理。

优化思路:定义一个实体类,来封装这几个请求参数。 【需要保证,前端传递的请求参数和实体类的属性名是一样的】

1). 定义实体类:EmpQueryParam

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.itheima.pojo;

import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;

@Data
public class EmpQueryParam {

private Integer page = 1; //页码
private Integer pageSize = 10; //每页展示记录数
private String name; //姓名
private Integer gender; //性别
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate begin; //入职开始时间
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate end; //入职结束时间

}

2). EmpController接收请求参数

1
2
3
4
5
6
@GetMapping
public Result page(EmpQueryParam empQueryParam) {
log.info("查询请求参数: {}", empQueryParam);
PageResult pageResult = empService.page(empQueryParam);
return Result.success(pageResult);
}

3). 修改EmpService接口方法

1
2
3
4
5
public interface EmpService {
/** * 分页查询 */
//PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin, LocalDate end);
PageResult page(EmpQueryParam empQueryParam);
}

4). 修改EmpServiceImpl中的page方法

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
@Service
public class EmpServiceImpl implements EmpService {

@Autowired
private EmpMapper empMapper;

/*@Override
public PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin, LocalDate end) {
//1. 设置PageHelper分页参数
PageHelper.startPage(page, pageSize);
//2. 执行查询
List<Emp> empList = empMapper.list(name, gender, begin, end);
//3. 封装分页结果
Page<Emp> p = (Page<Emp>) empList;
return new PageResult(p.getTotal(), p.getResult());
}*/

public PageResult page(EmpQueryParam empQueryParam) {
//1. 设置PageHelper分页参数
PageHelper.startPage(empQueryParam.getPage(), empQueryParam.getPageSize());
//2. 执行查询
List<Emp> empList = empMapper.list(empQueryParam);
//3. 封装分页结果
Page<Emp> p = (Page<Emp>)empList;
return new PageResult(p.getTotal(), p.getResult());
}
}

5). 修改EmpMapper接口方法

1
2
3
4
5
6
7
8
9
10
@Mapper
public interface EmpMapper {

/** * 查询所有的员工及其对应的部门名称 */
// @Select("select e.*, d.name as deptName from emp e left join dept d on e.dept_id = d.id")
// public List<Emp> list(String name, Integer gender, LocalDate begin, LocalDate end);

/** * 根据查询条件查询员工 */
List<Emp> list(EmpQueryParam empQueryParam);
}

EmpMapper.xml 中的配置无需修改。

但当我们在测试的时候,页码输入负数,查询是有问题的,查不到对应的数据了。

那其实在PageHelper中,我们可以通过合理化参数配置,来解决这个问题。直接在application.yml中,引入如下配置即可:

1
2
3
pagehelper:
reasonable: true
helper-dialect: mysql

reasonable:分页合理化参数,默认值为false。当该参数设置为true时,pageNum<=0时会查询第一页,pageNum>pages(超过总数时),会查询最后一页。默认false 时,直接根据参数进行查询。

1.4.5程序优化2(动态SQL改造)

当前,我们在查询的时候,Mapper映射配置文件中的SQL语句中,查询条件是写死的。 而我们在员工管理中,根据条件查询员工信息时,查询条件是可选的,可以输入也可以不输入。

img

  • 如果只输入 姓名 这个查询条件,则SQL语句中只根据name字段查询,SQL如下:
1
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id where e.name like concat('%',#{name},'%');
  • 如果只输入 性别 这个查询条件,则SQL语句中只根据gender字段查询,SQL如下:
1
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id where e.gender = #{gender};
  • 如果输入 姓名性别 这两个查询条件,则SQL语句中要根据name、gender两个字段查询,SQL如下:
1
2
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id
where e.name like concat('%',#{name},'%') and e.gender = #{gender};

我们看到,这个SQL语句不应该是写死的,而应该根据用户输入的条件的变化而变化。 那这里呢,就要通过Mybatis中的动态SQL来实现。

所谓动态SQL,指的就是随着用户的输入或外部的条件的变化而变化的SQL语句。

具体的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!--定义Mapper映射文件的约束和基本结构-->
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.EmpMapper">
<select id="list" resultType="com.itheima.pojo.Emp">
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id
<where>
<if test="name != null and name != ''">
e.name like concat('%',#{name},'%')
</if>
<if test="gender != null">
and e.gender = #{gender}
</if>
<if test="begin != null and end != null">
and e.entry_date between #{begin} and #{end}
</if>
</where>
</select>
</mapper>

在这里呢,我们用到了两个动态SQL的标签:<if>``<where>。 这两个标签的具体作用如下:

<if>:判断条件是否成立,如果条件为true,则拼接SQL。

<where>:根据查询条件,来生成where关键字,并会自动去除条件前面多余的and或or。

当我们输入不同的搜索条件时,会动态的根据查询条件,动态拼接SQL语句。

2.新增员工

2.1需求

img

在新增员工的时候,在表单中,我们既要录入员工的基本信息,又要录入员工的工作经历信息。 员工基本信息,对应的表结构是 emp表,员工工作经历信息,对应的表结构是 emp_expr 表,所以这里我们要操作两张表,往两张表中保存数据。

img

img

img

2.2实现思路

img

  • 接口文档规定:
    • 请求路径:/emps
    • 请求方式:POST
    • 请求参数:Json格式数据
    • 响应数据:Json格式数据
  • **问题1:**如何限定请求方式是POST? @PostMapping
  • 问题2:怎么在controller中接收json格式的请求参数?@RequestBody

2.3功能开发

2.3.1准备工作

准备EmpExprMapper接口及映射配置文件EmpExprMapper.xml,并准备实体类接收前端传递的json格式的请求参数。

1). EmpExprMapper接口

1
2
3
4
@Mapper
public interface EmpExprMapper {

}

2). EmpExprMapper.xml 配置文件

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.EmpExprMapper">

</mapper>

3). 需要在 Emp 员工实体类中增加属性 exprList 来封装工作经历数据。 最终完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
public class Emp {
private Integer id; //ID,主键
private String username; //用户名
private String password; //密码
private String name; //姓名
private Integer gender; //性别, 1:男, 2:女
private String phone; //手机号
private Integer job; //职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师
private Integer salary; //薪资
private String image; //头像
private LocalDate entryDate; //入职日期
private Integer deptId; //关联的部门ID
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间

//封装部门名称数
private String deptName; //部门名称

//封装员工工作经历信息 private List<EmpExpr> exprList;
}

2.3.2保存员工基本信息

1). EmpController

EmpController 中增加save方法。

1
2
3
4
5
6
7
8
9
/**
* 添加员工
*/
@PostMapping
public Result save(@RequestBody Emp emp){
log.info("请求参数emp: {}", emp);
empService.save(emp);
return Result.success();
}

2). EmpService & EmpServiceImpl

EmpService 中增加 save 方法

1
2
3
4
5
/**
* 添加员工
* @param emp
*/
void save(Emp emp);

EmpServiceImpl 中增加save方法 , 实现接口中的save方法

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());

//2.保存员工基本信息
empMapper.insert(emp);

//3. 保存员工的工作经历信息 - 批量 (稍后完成)

}

3). EmpMapper

EmpMapper 中增加insert方法,新增员工的基本信息。

1
2
3
4
5
6
7
/**
* 新增员工数据
*/
@Options(useGeneratedKeys = true, keyProperty = "id")
@Insert("insert into emp(username, name, gender, phone, job, salary, image, entry_date, dept_id, create_time, update_time) " +
"values (#{username},#{name},#{gender},#{phone},#{job},#{salary},#{image},#{entryDate},#{deptId},#{createTime},#{updateTime})")
void insert(Emp emp);

主键返回:@Options(useGeneratedKeys = true, keyProperty = "id")

由于稍后,我们在保存工作经历信息的时候,需要记录是哪位员工的工作经历。 所以,保存完员工信息之后,是需要获取到员工的ID的,那这里就需要通过Mybatis中提供的主键返回功能来获取

2.3.3批量保存工作经历

1.分析

一个员工,是可以有多段工作经历的,所以在页面上将来用户录入员工信息时,可以自己根据需要添加多段工作经历。页面原型展示如下:

img

那如果员工只有一段工作经历,我们就需要往工作经历表中保存一条记录。 执行的SQL如下:

img

如果员工有两段工作经历,我们就需要往工作经历表中保存两条记录。执行的SQL如下:

img

如果员工有三段工作经历,我们就需要往工作经历表中保存三条记录。执行的SQL如下:

img

所以,这里最终我们需要执行的是批量插入数据的insert语句。

2.代码实现

1). EmpServiceImpl

完善save方法中保存员工信息的逻辑。完整逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);

//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}

2). EmpExprMapper

1
2
3
4
5
6
7
8
@Mapper
public interface EmpExprMapper {

/**
* 批量插入员工工作经历信息
*/
public void insertBatch(List<EmpExpr> exprList);
}

3). EmpExprMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.EmpExprMapper">

<!--批量插入员工工作经历信息-->
<insert id="insertBatch">
insert into emp_expr (emp_id, begin, end, company, job) values
<foreach collection="exprList" item="expr" separator=",">
(#{expr.empId}, #{expr.begin}, #{expr.end}, #{expr.company}, #{expr.job})
</foreach>
</insert>

</mapper>

这里用到Mybatis中的动态SQL里提供的 <foreach> 标签,改标签的作用,是用来遍历循环,常见的属性说明:

  1. collection:集合名称
  2. item:集合遍历出来的元素/项
  3. separator:每一次遍历使用的分隔符
  4. open:遍历开始前拼接的片段
  5. close:遍历结束后拼接的片段

上述的属性,是可选的,并不是所有的都是必须的。 可以自己根据实际需求,来指定对应的属性。

Apifox请求完毕后,可以打开idea的控制台看到控制台输出的日志:

img

3.事务管理

3.1问题分析

目前我们实现的新增员工功能中,操作了两次数据库,执行了两次 insert 操作。

  • 第一次:保存员工的基本信息到 emp 表中。
  • 第二次:保存员工的工作经历信息到 emp_expr 表中。

如果说,保存员工的基本信息成功了,而保存员工的工作经历信息出错了,会发生什么现象呢?那接下来,我们来做一个测试 。 我们可以在代码中,人为在保存员工的service层的save方法中,构造一个错误:

img

那接下来,我们就重启服务,打开浏览器,来做一个测试:

img

点击 “保存” 之后,提示 “系统接口异常”。我们可以打开IDEA控制台看一下,报出的错误信息。 我们看到,保存了员工的基本信息之后,系统出现了异常。

img

我们再打开数据库,看看表结构中的数据是否正常。

1). emp 员工表中是有 shaseng 这条数据的。

img

2). emp_expr 表中没有该员工的工作经历信息。

img

最终,我们看到,程序出现了异常 ,员工表 emp 数据保存成功了, 但是 emp_expr 员工工作经历信息表,数据保存失败了。 那是否允许这种情况发生呢?

  • 不允许
  • 因为这属于一个业务操作,如果保存员工信息成功了,保存工作经历信息失败了,就会造成数据库数据的不完整、不一致。

那如何解决这个问题呢? 这需要通过数据库中的事务来解决这个问题。

3.2介绍

概念: 事务是一组操作的集合,它是一个不可分割的工作单位。事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作 要么同时成功,要么同时失败

就拿添加员工的这个业务为例,在这个业务操作中,包含了两个操作,那这两个操作是一个不可分割的工作单位。

这两个操作,要么同时失败,要么同时成功。

默认MySQL的事务是自动提交的,也就是说,当执行一条DML语句,MySQL会立即隐式的提交事务。

3.3操作

事务控制主要三步操作:开启事务、提交事务/回滚事务。

  • 需要在这组操作执行之前,先开启事务 ( start transaction; / begin;)。
  • 所有操作如果全部都执行成功,则提交事务 ( commit; )。
  • 如果这组操作中,有任何一个操作执行失败,都应该回滚事务 ( rollback )。

那接下来,我们就可以将添加员工的业务操作,进行事务管理。 具体的SQL如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 开启事务
start transaction; / begin;

-- 1. 保存员工基本信息
insert into emp values (39, 'Tom', '123456', '汤姆', 1, '13300001111', 1, 4000, '1.jpg', '2023-11-01', 1, now(), now());

-- 2. 保存员工的工作经历信息
insert into emp_expr(emp_id, begin, end, company, job) values (39,'2019-01-01', '2020-01-01', '百度', '开发'), (39,'2020-01-10', '2022-02-01', '阿里', '架构');

-- 提交事务(全部成功)
commit;

-- 回滚事务(有一个失败)
rollback;

事务管理的场景,是非常多的,比如:

  • 银行转账
  • 下单扣减库存

3.4Spring事务管理(@Transactional)

3.4.1分析

在上述实现的新增员工的功能中,一旦在保存员工基本信息后出现异常。 我们就会发现,员工信息保存成功,但是工作经历信息保存失败,造成了数据的不完整不一致。

img

产生原因:

  • 先执行新增员工的操作,这步执行完毕,就已经往员工表 emp 插入了数据。
  • 执行 1/0 操作,抛出异常
  • 抛出异常之前,下面所有的代码都不会执行了,批量保存工作经历信息,这个操作也不会执行 。

此时就出现问题了,员工基本信息保存了,员工的工作经历信息未保存,业务操作前后数据不一致。

而要想保证操作前后,数据的一致性,就需要让新增员工中涉及到的两个业务操作,要么全部成功,要么全部失败 。 那我们如何,让这两个操作要么全部成功,要么全部失败呢 ?

那就可以通过事务来实现,因为一个事务中的多个业务操作,要么全部成功,要么全部失败。

此时,我们就需要在新增员工功能中添加事务。

img

在方法运行之前,开启事务,如果方法成功执行,就提交事务,如果方法执行的过程当中出现异常了,就回滚事务。

**思考:**开发中所有的业务操作,一旦我们要进行控制事务,是不是都是这样的套路?

**答案:**是的。

所以在spring框架当中就已经把事务控制的代码都已经封装好了,并不需要我们手动实现。我们使用了spring框架,我们只需要通过一个简单的注解@Transactional就搞定了。

3.4.2Transactional注解

注解:@Transactional

**作用:**就是在当前这个方法执行开始之前来开启事务,方法执行完毕之后提交事务。如果在这个方法执行的过程当中出现了异常,就会进行事务的回滚操作。

**位置:**业务层的方法上、类上、接口上

接口上:接口下所有的实现类当中所有的方法都交给spring 进行事务管理

方法上:当前方法交给spring进行事务管理

类上:当前类中所有的方法都交由spring进行事务管理

接下来,我们就可以在业务方法save上加上 @Transactional 来控制事务 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);

int i = 1/0;

//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}

@Transactional注解:我们一般会在业务层当中来控制事务,因为在业务层当中,一个业务功能可能会包含多个数据访问的操作。在业务层来控制事务,我们就可以将多个数据访问操作控制在一个事务范围内。

说明:可以在application.yml配置文件中开启事务管理日志,这样就可以在控制看到和事务相关的日志信息了

1
2
3
4
#spring事务管理日志
logging:
level:
org.springframework.jdbc.support.JdbcTransactionManager: debug

3.4.3Transactional的两个重要属性及相关知识点

一个知识点:Spring的@Transactional会通过set autocommit=0在连接开始时关闭自动提交来禁用MySQL的默认自动提交(autocommit)模式。

这里主要介绍@Transactional注解当中的两个常见的属性:

  • 异常回滚的属性:rollbackFor
  • 事务传播行为:propagation

1.rollbackFor

我们在之前编写的业务方法上添加了@Transactional注解,来实现事务管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);

int i = 1/0;

//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}

以上业务功能save方法在运行时,会引发除0的算术运算异常(运行时异常),出现异常之后,由于我们在方法上加了@Transactional注解进行事务管理,所以发生异常会执行rollback回滚操作,从而保证事务操作前后数据是一致的。

下面我们在做一个测试,我们修改业务功能代码,在模拟异常的位置上直接抛出Exception异常(编译时异常)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);

//模拟:异常发生
if(true){
throw new Exception("出现异常了~~~");
}

//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}

说明:在service中向上抛出一个Exception编译时异常之后,由于是controller调用service,所以在controller中要有异常处理代码,此时我们选择在controller中继续把异常向上抛。

重新启动服务后,打开Apifox进行测试,请求添加员工的接口:

img

通过Apifox返回的结果,我们看到抛出异常了。然后我们在回到IDEA的控制台来看一下。

img

我们看到数据库的事务居然提交了,并没有进行回滚。

通过以上测试可以得出一个结论:默认情况下,只有出现RuntimeException(运行时异常)才会回滚事务。

假如我们想让所有的异常都回滚,需要来配置@Transactional注解当中的rollbackFor属性,通过rollbackFor这个属性可以指定出现何种异常类型回滚事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Transactional(rollbackFor = Exception.class)
@Override
public void save(Emp emp) throws Exception {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);

//int i = 1/0;
if(true){
throw new Exception("出异常啦....");
}

//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}

接下来我们重新启动服务,测试新增员工的操作:

img

控制台日志,可以看到因为出现了异常又进行了事务回滚。

img

结论:

  • 在Spring的事务管理中,默认只有运行时异常 RuntimeException才会回滚。
  • 如果还需要回滚指定类型的异常,可以通过rollbackFor属性来指定。

2.propagation

propagation,这个属性是用来配置事务的传播行为的。

什么是事务的传播行为呢?

  • 就是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。

例如:两个事务方法,一个A方法,一个B方法。在这两个方法上都添加了@Transactional注解,就代表这两个方法都具有事务,而在A方法当中又去调用了B方法。

img

所谓事务的传播行为,指的就是在A方法运行的时候,首先会开启一个事务,在A方法当中又调用了B方法, B方法自身也具有事务,那么B方法在运行的时候,到底是加入到A方法的事务当中来,还是B方法在运行的时候新建一个事务?这个就涉及到了事务的传播行为。

我们要想控制事务的传播行为,在@Transactional注解的后面指定一个属性propagation,通过 propagation 属性来指定传播行为。接下来我们就来介绍一下常见的事务传播行为。

属性值 含义
REQUIRED 【默认值】需要事务,有则加入,无则创建新事务
REQUIRES_NEW 需要新事务,无论有无,总是创建新事务
SUPPORTS 支持事务,有则加入,无则在无事务状态中运行
NOT_SUPPORTED 不支持事务,在无事务状态下运行,如果当前存在已有事务,则挂起当前事务
MANDATORY 必须有事务,否则抛异常
NEVER 必须没事务,否则抛异常

接下来我们就通过一个案例来演示下事务传播行为propagation属性的使用。

**需求:**在新增员工信息时,无论是成功还是失败,都要记录操作日志。

步骤:

  1. 准备日志表 emp_log、实体类EmpLog、Mapper接口EmpLogMapper
  2. 在新增员工时记录日志

准备工作:

1). 创建数据库表 emp_log 日志表

1
2
3
4
5
6
-- 创建员工日志表
create table emp_log(
id int unsigned primary key auto_increment comment 'ID, 主键',
operate_time datetime comment '操作时间',
info varchar(2000) comment '日志信息'
) comment '员工日志表';

2). 引入资料中提供的实体类:EmpLog

1
2
3
4
5
6
7
8
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmpLog {
private Integer id; //ID
private LocalDateTime operateTime; //操作时间
private String info; //详细信息
}

3). 引入资料中提供的Mapper接口:EmpLogMapper

1
2
3
4
5
6
@Mapper
public interface EmpLogMapper {
//插入日志
@Insert("insert into emp_log (operate_time, info) values (#{operateTime}, #{info})")
public void insert(EmpLog empLog);
}

4). 引入资料中提供的业务接口:EmpLogService

1
2
3
4
public interface EmpLogService {
//记录新增员工日志
public void insertLog(EmpLog empLog);
}

5). 引入资料中提供的业务实现类:EmpLogServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class EmpLogServiceImpl implements EmpLogService {

@Autowired
private EmpLogMapper empLogMapper;

@Transactional
@Override
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
}

代码实现:

业务实现类:EmpServiceImpl

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
@Autowired
private EmpMapper empMapper;
@Autowired
private EmpExprMapper empExprMapper;
@Autowired
private EmpLogService empLogService;

@Transactional(rollbackFor = {Exception.class})
@Override
public void save(Emp emp) {
try {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());

//2.保存员工基本信息
empMapper.insert(emp);

int i = 1/0;

//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
} finally {
//记录操作日志
EmpLog empLog = new EmpLog(null, LocalDateTime.now(), emp.toString());
empLogService.insertLog(empLog);
}

}

测试:

重新启动SpringBoot服务,测试新增员工操作 。我们可以看到控制台中输出的日志:

img

从日志中我们可以看到:

  • 执行了插入员工数据的操作
  • 执行了插入日志操作
  • 程序发生Exception异常
  • 执行事务回滚(保存员工数据、插入操作日志 因为在一个事务范围内,两个操作都会被回滚)

然后在 emp_log 表中没有记录日志数据 。

原因分析:

接下来我们就需要来分析一下具体是什么原因导致的日志没有成功的记录。

  • 在执行 save 方法时开启了一个事务
  • 当执行 empLogService.insertLog 操作时,insertLog设置的事务传播行是默认值REQUIRED,表示有事务就加入,没有则新建事务
  • 此时:saveinsertLog 操作使用了同一个事务,同一个事务中的多个操作,要么同时成功,要么同时失败,所以当异常发生时进行事务回滚,就会回滚 saveinsertLog 操作

解决方案:

EmpLogServiceImpl类中insertLog方法上,添加 @Transactional(propagation = Propagation.REQUIRES_NEW)

Propagation.REQUIRES_NEW :不论是否有事务,都创建新事务 ,运行在一个独立的事务中。

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class EmpLogServiceImpl implements EmpLogService {

@Autowired
private EmpLogMapper empLogMapper;

@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
}

重启SpringBoot服务,再次测试 新增员工的操作 ,会看到具体的日志如下:

img

那此时,EmpServiceImpl 中的 save 方法运行时,会开启一个事务。 当调用 empLogService.insertLog(empLog) 时,也会创建一个新的事务,那此时,当 insertLog 方法运行完毕之后,事务就已经提交了。 即使外部的事务出现异常,内部已经提交的事务,也不会回滚了,因为是两个独立的事务。

到此事务传播行为已演示完成,事务的传播行为我们只需要掌握两个:REQUIRED、REQUIRES_NEW。

  • **REQUIRED:**大部分情况下都是用该传播行为即可。
  • **REQUIRES_NEW:**当我们不希望事务之间相互影响时,可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够记录成功。

3.4.4事务四大特性

事务有哪些特性?

  • 原子性(Atomicity):事务是不可分割的最小单元,要么全部成功,要么全部失败。
  • 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
  • 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
  • 持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。

事务的四大特性简称为:ACID

img

持久性(Durability):一个事务一旦被提交或回滚,它对数据库的改变将是永久性的,哪怕数据库发生异常,重启之后数据亦然存在。

原子性(Atomicity) :原子性是指事务包装的一组sql是一个不可分割的工作单元,事务中的操作要么全部成功,要么全部失败。

一致性(Consistency):一个事务完成之后数据都必须处于一致性状态。

如果事务成功的完成,那么数据库的所有变化将生效。

如果事务执行出现错误,那么数据库的所有变化将会被回滚(撤销),返回到原始状态。

隔离性(Isolation):多个用户并发的访问数据库时,一个用户的事务不能被其他用户的事务干扰,多个并发的事务之间要相互隔离。

一个事务的成功或者失败对于其他的事务是没有影响。

4.文件上传

4.1简介

img

文件上传,是指将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。

文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。

上传文件的原始form表单,要求表单必须具备以下三点(上传文件页面三要素):

  • 表单必须有file域,用于选择要上传的文件
  • 表单提交方式必须为POST:通常上传的文件会比较大,所以需要使用 POST 提交方式
  • 表单的编码类型enctype必须要设置为:multipart/form-data:普通默认的编码格式是不适合传输大型的二进制数据的,所以在文件上传时,表单的编码格式必须设置为multipart/form-data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.itheima.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;

@Slf4j
@RestController
public class UploadController {

/** * 上传文件 - 参数名file */@PostMapping("/upload")
public Result upload(String username, Integer age , MultipartFile file) throws Exception {
log.info("上传文件:{}, {}, {}", username, age, file);
if(!file.isEmpty()){
file.transferTo(new File("D:\\images\\" + file.getOriginalFilename()));
}
return Result.success();
}

}

在定义的方法中接收提交过来的数据 (方法中的形参名和请求参数的名字保持一致)

  • 用户名:String name
  • 年龄: Integer age
  • 文件: MultipartFile file

img

Spring中提供了一个API:MultipartFile,使用这个API就可以来接收到上传的文件

问题1:如果表单项的名字和方法中形参名不一致,该怎么办?

1
2
3
public Result upload(String username, 
Integer age,
MultipartFile image) //image形参名和请求参数名file不一致

解决:使用@RequestParam注解进行参数绑定

1
2
3
public Result upload(String username,
Integer age,
@RequestParam("file") MultipartFile image)

4.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
package com.itheima.controller;

import com.itheima.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.util.UUID;

@Slf4j
@RestController
public class UploadController {

private static final String UPLOAD_DIR = "D:/images/";
/**
* 上传文件 - 参数名file
*/
@PostMapping("/upload")
public Result upload(String username, Integer age,MultipartFile file) throws Exception {
log.info("上传文件:{}, {}, {}", username, age, file);
if (!file.isEmpty()) {
// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));//获取后缀名
String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;
// 拼接完整的文件路径
File targetFile = new File(UPLOAD_DIR + uniqueFileName);

// 如果目标目录不存在,则创建它
if (!targetFile.getParentFile().exists()) {
targetFile.getParentFile().mkdirs();
}
// 保存文件
file.transferTo(targetFile);
}
return Result.success();
}
}

MultipartFile 常见方法:

  • String getOriginalFilename(); //获取原始文件名
  • void transferTo(File dest); //将接收的文件转存到磁盘文件中
  • long getSize(); //获取文件的大小,单位:字节
  • byte[] getBytes(); //获取文件内容的字节数组
  • InputStream getInputStream(); //获取接收到的文件内容的输入流

利用 Apifox 测试,注意:请求参数名和controller方法形参名保持一致。

img

通过 Apifox 测试,我们发现文件上传是没有问题的。

在解决了文件名唯一性的问题后,我们再次上传一个较大的文件(超出1M)时发现,后端程序报错:

img

报错原因呢,是因为:在SpringBoot中,文件上传时默认单个文件最大大小为1M

那么如果需要上传大文件,可以在 application.properties 进行如下配置:

1
2
3
4
5
spring
servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB

但如果直接存储在服务器的磁盘目录中,存在以下缺点:

  • 不安全:磁盘如果损坏,所有的文件就会丢失
  • 容量有限:如果存储大量的图片,磁盘空间有限(磁盘不可能无限制扩容)
  • 无法直接访问

3.3阿里云OSS(对象存储)

3.3.1介绍

img

**Bucket:**存储空间是用户用于存储对象(Object,就是文件)的容器,所有的对象都必须隶属于某个存储空间。

借助AI帮我生成了一个工具类如下:

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
package com.itheima;

import java.io.*;
import java.util.Random;
import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.model.OSSObject;
import com.aliyun.oss.model.ObjectListing;
import com.aliyun.oss.model.OSSObjectSummary;
import com.aliyun.oss.model.PutObjectRequest;

public class OssJavaSdkQuickStart {
/** 生成一个唯一的 Bucket 名称 */
public static String generateUniqueBucketName(String prefix) {
// 获取当前时间戳
String timestamp = String.valueOf(System.currentTimeMillis());
// 生成一个 0 到 9999 之间的随机数
Random random = new Random();
int randomNum = random.nextInt(10000); // 生成一个 0 到 9999 之间的随机数
// 连接以形成一个唯一的 Bucket 名称
return prefix + "-" + timestamp + "-" + randomNum;
}

public static void main(String[] args) throws com.aliyuncs.exceptions.ClientException {
// 设置 OSS Endpoint 和 Bucket 名称
String endpoint = "https://oss-cn-beijing.aliyuncs.com";
String bucketName = "java-geqian";
// 替换为您的 Bucket 区域
String region = "cn-beijing";
// 本地文件路径
String localFilePath = "D:/编程/001.png";
// 文件上传后的路径和名称
String objectName = "exampledir/001.png";
// 创建 OSSClient 实例
EnvironmentVariableCredentialsProvider credentialsProvider =
CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.region(region)
.build();

try {
// 1. 创建存储空间(Bucket)
ossClient.createBucket(bucketName);
System.out.println("1. Bucket " + bucketName + " 创建成功。");
// 2. 上传文件
File file = new File(localFilePath);
if (file.exists()) {
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, file);
ossClient.putObject(putObjectRequest);
System.out.println("2. 文件 " + objectName + " 上传成功。");
} else {
System.out.println("文件 " + localFilePath + " 不存在!");
}
// 3. 下载文件
OSSObject ossObject = ossClient.getObject(bucketName, objectName);
InputStream contentStream = ossObject.getObjectContent();
BufferedReader reader = new BufferedReader(new InputStreamReader(contentStream));
String line;
System.out.println("3. 下载的文件内容:");
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
contentStream.close();
// 4. 列出文件
System.out.println("4. 列出 Bucket 中的文件:");
ObjectListing objectListing = ossClient.listObjects(bucketName);
for (OSSObjectSummary objectSummary : objectListing.getObjectSummaries()) {
System.out.println(" - " + objectSummary.getKey() + " (大小 = " + objectSummary.getSize() + ")");
}
// 5. 删除文件
// ossClient.deleteObject(bucketName, objectName);
// System.out.println("5. 文件 " + objectName + " 删除成功。");
// 6. 删除存储空间(Bucket)
// ossClient.deleteBucket(bucketName);
// System.out.println("6. Bucket " + bucketName + " 删除成功。");
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException | IOException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}


}
}
}

3.3.2集成OSS

阿里云oss对象存储服务的准备工作以及入门程序我们都已经完成了,接下来我们就需要在案例当中集成oss对象存储服务,来存储和管理案例中上传的图片。

img

在新增员工的时候,上传员工的图像,而之所以需要上传员工的图像,是因为将来我们需要在系统页面当中访问并展示员工的图像。而要想完成这个操作,需要做两件事:

  1. 需要上传员工的图像,并把图像保存起来(存储到阿里云OSS)
  2. 访问员工图像(通过图像在阿里云OSS的存储地址访问图像)
    1. OSS中的每一个文件都会分配一个访问的url,通过这个url就可以访问到存储在阿里云上的图片。所以需要把url返回给前端,这样前端就可以通过url获取到图像。

我们参照接口文档来开发文件上传功能:

基本信息

  • 请求路径:/upload 请求方式:POST 接口描述:上传图片接口

请求参数

  • 参数格式:multipart/form-data
  • 参数说明:
参数名称 参数类型 是否必须 示例 备注
image file

3.3.3具体实现

1). 引入阿里云OSS上传文件工具类(由官方的示例代码改造而来)

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
package com.itheima.utils;

import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

@Component
public class AliyunOSSOperator {

private String endpoint = "https://oss-cn-beijing.aliyuncs.com";
private String bucketName = "java-ai";
private String region = "cn-beijing";

public String upload(byte[] content, String originalFilename) throws Exception {
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();

// 填写Object完整路径,例如202406/1.png。Object完整路径中不能包含Bucket名称。
//获取当前系统日期的字符串,格式为 yyyy/MM
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
//生成一个新的不重复的文件名
String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;

// 创建OSSClient实例。
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(region)
.build();

try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}

return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}

}

2). 修改UploadController代码

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
package com.itheima.controller;

import com.itheima.pojo.Result;
import com.itheima.utils.AliyunOSSOperator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.util.UUID;

@Slf4j
@RestController
public class UploadController {

@Autowired
private AliyunOSSOperator aliyunOSSOperator;

@PostMapping("/upload")
public Result upload(MultipartFile file) throws Exception {
log.info("上传文件:{}", file);
if (!file.isEmpty()) {
// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;
// 上传文件
String url = aliyunOSSOperator.upload(file.getBytes(), uniqueFileName);
return Result.success(url);
}
return Result.error("上传失败");
}

}

使用 Apifox 测试:

img

接口测试通过之后,我们就可以进行前后端联调了。

3.3.4功能优化

在刚才我们制作的AliyunOSS操作的工具类中,我们直接将 endpoint、bucketName参数直接在java文件中写死了。如下所示:

img

如果后续,项目要部署到测试环境、上生产环境,我们需要来修改这两个参数。 而如果开发一个大型项目,所有用到的技术涉及到的这些个参数全部写死在java代码中,是非常不便于维护和管理的。

那么对于这些容易变动的参数,我们可以将其配置在配置文件中,然后通过 @Value 注解来注解外部配置的属性。如下所示:

方式一:@Value注入(缺点:如果配置项多,注入繁琐,不便于维护管理和复用。)

img

方式二:自动注入 在Spring中给我们提供了一种简化方式,可以直接将配置文件中配置项的值自动的注入到对象的属性中。

Spring提供的简化方式套路:

1). 需要创建一个实现类,且实体类中的属性名和配置文件当中key的名字必须要一致

比如:配置文件当中叫endpoint,实体类当中的属性也得叫endpoint,另外实体类当中的属性还需要提供 getter / setter方法

2). 需要将实体类交给Spring的IOC容器管理,成为IOC容器当中的bean对象

3). 在实体类上添加**@ConfigurationProperties**注解,并通过perfect属性来指定配置参数项的前缀

img

具体实现步骤:

1). 定义实体类AliyunOSSProperties ,并交给IOC容器管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.itheima.utils;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSProperties {
private String endpoint;
private String bucketName;
private String region;
}

2). 修改AliyunOSSOperator

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
package com.itheima.utils;

import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

@Component
public class AliyunOSSOperator {

//方式一: 通过@Value注解一个属性一个属性的注入
//@Value("${aliyun.oss.endpoint}")
//private String endpoint;
//@Value("${aliyun.oss.bucketName}")
//private String bucketName;
//@Value("${aliyun.oss.region}")
//private String region;

@Autowired private AliyunOSSProperties aliyunOSSProperties;

public String upload(byte[] content, String originalFilename) throws Exception {
String endpoint = aliyunOSSProperties.getEndpoint();
String bucketName = aliyunOSSProperties.getBucketName();
String region = aliyunOSSProperties.getRegion();

// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();

// 填写Object完整路径,例如2024/06/1.png。Object完整路径中不能包含Bucket名称。
//获取当前系统日期的字符串,格式为 yyyy/MM
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
//生成一个新的不重复的文件名
String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;

// 创建OSSClient实例。
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(region)
.build();

try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}

return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}

}

3.3.5@ConfigurationProperties详解

@ConfigurationProperties是 Spring Boot 框架中用于将配置文件(如application.ymlapplication.properties)中的属性批量绑定到Java对象的注解。

1.解耦配置与代码

  • 敏感信息隔离:将数据库密码、API 密钥等从代码中剥离,存储于配置文件。
  • 环境差异化:通过 application-dev.yml、application-prod.yml 实现多环境配置切换。

2.提升可维护性

  • 集中管理:所有相关配置属性聚合在一个类中,便于查找和修改。
  • IDE 支持:字段自动补全、类型检查和配置提示(如 IntelliJ IDEA 的 “Spring Boot” 插件)。

3.验证与约束
结合 @Validated 和 JSR-303 注解(如 @NotBlank、@Min),实现配置合法性校验。

与其他注解的对比

注解 特点 适用场景
@Value 直接注入单个属性值,适合简单场景 少量分散配置
@ConfigurationProperties 批量绑定属性到对象,支持复杂结构和类型安全 结构化、模块化配置
@PropertySource 指定自定义配置文件路径 非默认配置文件加载

5.删除员工

5.1需求

img

当我们勾选列表前面的复选框,然后点击 “批量删除” 按钮,就可以将这一批次的员工信息删除掉了。也可以只勾选一个复选框,仅删除一个员工信息。

问题:我们需要开发两个功能接口吗?一个删除单个员工,一个删除多个员工

答案:不需要。 只需要开发一个功能接口即可(删除多个员工包含只删除一个员工)

img

img

5.2实现思路

img

5.3功能开发

5.3.1Controller接收参数

EmpController 中增加如下方法 delete ,来执行批量删除员工的操作。

方式一:在Controller方法中通过数组来接收

多个参数,默认可以将其封装到一个数组中,需要保证前端传递的参数名 与 方法形参名称保持一致。

1
2
3
4
5
6
7
8
/**
* 批量删除员工
*/
@DeleteMapping
public Result delete(Integer[] ids){
log.info("批量删除部门: ids={} ", Arrays.asList(ids));
return Result.success();
}

方式二:在Controller方法中通过集合来接收

也可以将其封装到一个List 集合中,如果要将其封装到一个集合中,需要在集合前面加上 @RequestParam 注解。

1
2
3
4
5
6
7
8
9
/**
* 批量删除员工
*/
@DeleteMapping
public Result delete(@RequestParam List<Integer> ids){
log.info("批量删除部门: ids={} ", ids);
empService.deleteByIds(ids);
return Result.success();
}

两种方式,选择其中一种就可以,我们一般推荐选择集合,因为基于集合操作其中的元素会更加方便。

5.3.2Service

1). 在接口中 EmpService 中定义接口方法 deleteByIds

1
2
3
4
/**
* 批量删除员工
*/
void deleteByIds(List<Integer> ids);

2). 在实现类 EmpServiceImpl 中实现接口方法 deleteByIds

在删除员工信息时,既需要删除 emp 表中的员工基本信息,还需要删除 emp_expr 表中员工的工作经历信息

1
2
3
4
5
6
7
8
9
@Transactional
@Override
public void deleteByIds(List<Integer> ids) {
//1. 根据ID批量删除员工基本信息
empMapper.deleteByIds(ids);

//2. 根据员工的ID批量删除员工的工作经历信息
empExprMapper.deleteByEmpIds(ids);
}

由于删除员工信息,既要删除员工基本信息,又要删除工作经历信息,操作多次数据库的删除,所以需要进行事务控制。

5.3.3Mapper

1). 在 EmpMapper 接口中增加 deleteByIds 方法实现批量删除员工基本信息

1
2
3
4
/**
* 批量删除员工信息
*/
void deleteByIds(List<Integer> ids);

2). 在 EmpMapper.xml 配置文件中, 配置对应的SQL语句

1
2
3
4
5
6
7
<!--批量删除员工信息-->
<delete id="deleteByIds">
delete from emp where id in
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</delete>

3). 在 EmpExprMapper 接口中增加 deleteByEmpIds 方法实现根据员工ID批量删除员工的工作经历信息

1
2
3
4
/**
* 根据员工的ID批量删除工作经历信息
*/
void deleteByEmpIds(List<Integer> empIds);

4). 在 EmpExprMapper.xml 配置文件中, 配置对应的SQL语句

1
2
3
4
5
6
7
<!--根据员工的ID批量删除工作经历信息-->
<delete id="deleteByEmpIds">
delete from emp_expr where emp_id in
<foreach collection="empIds" item="empId" open="(" close=")" separator=",">
#{empId}
</foreach>
</delete>

6.修改员工

img

在进行修改员工信息的时候,我们首先先要根据员工的ID查询员工的详细信息用于页面回显展示,然后用户修改员工数据之后,点击保存按钮,就可以将修改的数据提交到服务端,保存到数据库。 具体操作为:

  1. 根据ID查询员工信息
  2. 保存修改的员工信息

6.1查询回显

6.1.1需求

img

2.4.3 响应数据

参数格式:application/json

参数说明:

名称 类型 是否必须 备注
code number 必须 响应码, 1 成功 , 0 失败
msg string 非必须 提示信息
data object 必须 返回的数据
|- id number 非必须 id
|- username string 非必须 用户名
|- name string 非必须 姓名
|- password string 非必须 密码
|- entryDate string 非必须 入职日期
|- gender number 非必须 性别 , 1 男 ; 2 女
|- image string 非必须 图像
|- job number 非必须 职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师
|- salary number 非必须 薪资
|- deptId number 非必须 部门id
|- createTime string 非必须 创建时间
|- updateTime string 非必须 更新时间
|- exprList object[] 非必须 工作经历列表
|- id number 非必须 ID
|- company string 非必须 所在公司
|- job string 非必须 职位
|- begin string 非必须 开始时间
|- end string 非必须 结束时间
|- empId number 非必须 员工ID

img

6.1.2实现思路

在查询回显时,既需要查询出员工的基本信息,又需要查询出该员工的工作经历信息。

img

我们可以先通过一条SQL语句,查询出指定员工的基本信息,及其员工的工作经历信息。SQL如下:

1
2
3
4
5
6
7
select e.*,
ee.id ee_id,
ee.begin ee_begin,
ee.end ee_end,
ee.company ee_company,
ee.job ee_job
from emp e left join emp_expr ee on e.id = ee.emp_id where e.id = 39;

具体的实现思路如下:

img

6.1.3代码实现

1). EmpController 添加 getInfo 用来根据ID查询员工数据,用于页面回显

1
2
3
4
5
6
7
8
9
/**
* 查询回显
*/
@GetMapping("/{id}")
public Result getInfo(@PathVariable Integer id){
log.info("根据id查询员工的详细信息");
Emp emp = empService.getInfo(id);
return Result.success(emp);
}

2). EmpService 接口中增加 getInfo 方法

1
2
3
4
/**
* 根据ID查询员工的详细信息
*/
Emp getInfo(Integer id);

3). EmpServiceImpl 实现类中实现 getInfo 方法

1
2
3
4
@Override
public Emp getInfo(Integer id) {
return empMapper.getById(id);
}

4). EmpMapper 接口中增加 getById 方法

1
2
3
4
/**
* 根据ID查询员工详细信息
*/
Emp getById(Integer id);

5). EmpMapper.xml 配置文件中定义对应的SQL

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
<!--自定义结果集ResultMap-->
<resultMap id="empResultMap" type="com.itheima.pojo.Emp">
<id column="id" property="id" />
<result column="username" property="username" />
<result column="password" property="password" />
<result column="name" property="name" />
<result column="gender" property="gender" />
<result column="phone" property="phone" />
<result column="job" property="job" />
<result column="salary" property="salary" />
<result column="image" property="image" />
<result column="entry_date" property="entryDate" />
<result column="dept_id" property="deptId" />
<result column="create_time" property="createTime" />
<result column="update_time" property="updateTime" />

<!--封装exprList-->
<collection property="exprList" ofType="com.itheima.pojo.EmpExpr">
<id column="ee_id" property="id"/>
<result column="ee_company" property="company"/>
<result column="ee_job" property="job"/>
<result column="ee_begin" property="begin"/>
<result column="ee_end" property="end"/>
<result column="ee_empid" property="empId"/>
</collection>
</resultMap>

<!--根据ID查询员工的详细信息-->
<select id="getById" resultMap="empResultMap">
select e.*,
ee.id ee_id,
ee.emp_id ee_empid,
ee.begin ee_begin,
ee.end ee_end,
ee.company ee_company,
ee.job ee_job
from emp e left join emp_expr ee on e.id = ee.emp_id
where e.id = #{id}
</select>

在这种一对多的查询中,我们要想成功的封装的结果,需要手动的基于 <resultMap> 来进行封装结果。

Mybatis中封装查询结果,什么时候用 resultType,什么时候用resultMap ?

  • 如果查询返回的字段名与实体的属性名可以直接对应上,用resultType 。
  • 如果查询返回的字段名与实体的属性名对应不上,或实体属性比较复杂,可以通过resultMap手动封装

6.2修改员工

6.2.1需求

查询回显之后,就可以在页面上修改员工的信息了。

img

  • 当用户修改完数据之后,点击保存按钮,就需要将数据提交到服务端,然后服务端需要将修改后的数据更新到数据库中 。
  • 而此次更新的时候,既需要更新员工的基本信息; 又需要更新员工的工作经历信息 。

基本信息

请求路径:/emps

请求方式:PUT

接口描述:该接口用于修改员工的数据信息

请求参数

参数格式:application/json

参数说明:

名称 类型 是否必须 备注
id number 必须 id
username string 必须 用户名
name string 必须 姓名
gender number 必须 性别, 说明: 1 男, 2 女
image string 非必须 图像
deptId number 非必须 部门id
entryDate string 非必须 入职日期
job number 非必须 职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师
salary number 非必须 薪资
exprList object[] 非必须 工作经历列表
|- id number 非必须 ID
|- company string 非必须 所在公司
|- job string 非必须 职位
|- begin string 非必须 开始时间
|- end string 非必须 结束时间
|- empId number 非必须 员工ID

请求数据样例:

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
{
"id": 2,
"username": "zhangwuji",
"password": "123456",
"name": "张无忌",
"gender": 1,
"image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-53B.jpg",
"job": 2,
"salary": 8000,
"entryDate": "2015-01-01",
"deptId": 2,
"createTime": "2022-09-01T23:06:30",
"updateTime": "2022-09-02T00:29:04",
"exprList": [
{
"id": 1,
"begin": "2012-07-01",
"end": "2015-06-20"
"company": "中软国际股份有限公司"
"job": "java开发",
"empId": 2
},
{
"id": 2,
"begin": "2015-07-01",
"end": "2019-03-03"
"company": "百度科技股份有限公司"
"job": "java开发",
"empId": 2
},
{
"id": 3,
"begin": "2019-3-15",
"end": "2023-03-01"
"company": "阿里巴巴科技股份有限公司"
"job": "架构师",
"empId": 2
}
]
}

6.2.2实现思路

img

6.2.3代码实现

1). EmpController 增加 update 方法接收请求参数,响应数据

1
2
3
4
5
6
7
8
9
/**
* 更新员工信息
*/
@PutMapping
public Result update(@RequestBody Emp emp){
log.info("修改员工信息, {}", emp);
empService.update(emp);
return Result.success();
}

2). EmpService 接口增加 update 方法

1
2
3
4
5
/**
* 更新员工信息
* @param emp
*/
void update(Emp emp);

3). EmpServiceImpl实现类实现 update 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional
@Override
public void update(Emp emp) {
//1. 根据ID更新员工基本信息
emp.setUpdateTime(LocalDateTime.now());
empMapper.updateById(emp);

//2. 根据员工ID删除员工的工作经历信息 【删除老的】
empExprMapper.deleteByEmpIds(Arrays.asList(emp.getId()));

//3. 新增员工的工作经历数据 【新增新的】
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}

4). EmpMapper 接口中增加 updateById 方法

1
2
3
4
/**
* 更新员工基本信息
*/
void updateById(Emp emp);

5). EmpMapper.xml 配置文件中定义对应的SQL语句,基于动态SQL更新员工信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--根据ID更新员工信息-->
<update id="updateById">
update emp
<set>
<if test="username != null and username != ''">username = #{username},</if>
<if test="password != null and password != ''">password = #{password},</if>
<if test="name != null and name != ''">name = #{name},</if>
<if test="gender != null">gender = #{gender},</if>
<if test="phone != null and phone != ''">phone = #{phone},</if>
<if test="job != null">job = #{job},</if>
<if test="salary != null">salary = #{salary},</if>
<if test="image != null and image != ''">image = #{image},</if>
<if test="entryDate != null">entry_date = #{entryDate},</if>
<if test="deptId != null">dept_id = #{deptId},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</set>
where id = #{id}
</update>

7.异常处理

7.1介绍

img

当我们没有做任何的异常处理时,我们三层架构处理异常的方案:

  • Mapper接口在操作数据库的时候出错了,此时异常会往上抛(谁调用Mapper就抛给谁),会抛给service。
  • service 中也存在异常了,会抛给controller。
  • 而在controller当中,我们也没有做任何的异常处理,所以最终异常会再往上抛。最终抛给框架之后,框架就会返回一个JSON格式的数据,里面封装的就是错误的信息,但是框架返回的JSON格式的数据并不符合我们的开发规范。

7.2解决方案

那么在三层构架项目中,出现了异常,该如何处理?

  • 方案一:在所有Controller的所有方法中进行try…catch处理

缺点:代码臃肿(不推荐)

img

  • **方案二:**全局异常处理器

好处:简单、优雅(推荐)

img

  1. 全局异常处理器

我们该怎么样定义全局异常处理器?

  • 定义全局异常处理器非常简单,就是定义一个类,在类上加上一个注解@RestControllerAdvice,加上这个注解就代表我们定义了一个全局异常处理器。
  • 在全局异常处理器当中,需要定义一个方法来捕获异常,在这个方法上需要加上注解@ExceptionHandler。通过@ExceptionHandler注解当中的value属性来指定我们要捕获的是哪一类型的异常。
1
2
3
4
5
6
7
8
9
10
11
12
@RestControllerAdvice
public class GlobalExceptionHandler {

//处理异常
@ExceptionHandler
public Result ex(Exception e){//方法形参中指定能够处理的异常类型
e.printStackTrace();//打印堆栈中的异常信息
//捕获到异常之后,响应一个标准的Result
return Result.error("对不起,操作失败,请联系管理员");
}

}

@RestControllerAdvice = @ControllerAdvice + @ResponseBody

处理异常的方法返回值会转换为json后再响应给前端

重新启动SpringBoot服务,打开浏览器,再来测试一下 修改员工 这个操作,我们依然设置已存在的 13309090027这个手机号:

img

此时,我们可以看到,出现异常之后,异常已经被全局异常处理器捕获了。然后返回的错误信息,被前端程序正常解析,然后提示出了对应的错误提示信息。

以上就是全局异常处理器的使用,主要涉及到两个注解:

  • @RestControllerAdvice //表示当前类为全局异常处理器
  • @ExceptionHandler //指定可以捕获哪种类型的异常进行处理

8.员工信息统计

8.1介绍

对于这些图形报表的开发,其实呢,都是基于现成的一些图形报表的组件开发的,比如:Echarts、HighCharts等。

而报表的制作,主要是前端人员开发,引入对应的组件(比如:ECharts)即可。 服务端开发人员仅为其提供数据即可。

官网:https://echarts.apache.org/zh/index.html

img

8.2职位统计

8.2.1需求

对于这类的图形报表,服务端要做的,就是为其提供数据即可。 我们可以通过官方的示例,看到提供的数据其实就是X轴展示的信息,和对应的数据。

img

img

img

8.2.2代码实现

1). 定义****封装结果对象JobOption

com.itheima.pojo 包中定义实体类 JobOption

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.itheima.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobOption {
private List jobList;
private List dataList;
}

1). 定义ReportController,并添加方法。

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

@Autowired
private ReportService reportService;

/**
* 统计各个职位的员工人数
*/
@GetMapping("/empJobData")
public Result getEmpJobData(){
log.info("统计各个职位的员工人数");
JobOption jobOption = reportService.getEmpJobData();
return Result.success(jobOption);
}
}

2). 定义ReportService接口,并添加接口方法。

1
2
3
4
5
6
7
public interface ReportService {
/**
* 统计各个职位的员工人数
* @return
*/
JobOption getEmpJobData();
}

3). 定义ReportServiceImpl实现类,并实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class ReportServiceImpl implements ReportService {

@Autowired
private EmpMapper empMapper;

@Override
public JobOption getEmpJobData() {
List<Map<String,Object>> list = empMapper.countEmpJobData();
List<Object> jobList = list.stream().map(dataMap -> dataMap.get("pos")).toList();
List<Object> dataList = list.stream().map(dataMap -> dataMap.get("total")).toList();
return new JobOption(jobList, dataList);
}
}

4). 定义EmpMapper 接口

统计的是员工的信息,所以需要操作的是员工表。 所以代码我们就写在 EmpMapper 接口中即可。

1
2
3
4
5
/**
* 统计各个职位的员工人数
*/
@MapKey("pos")
List<Map<String,Object>> countEmpJobData();

如果查询的记录往Map中封装,可以通过@MapKey注解指定返回的map中的唯一标识是那个字段。【也可以不指定】

5). 定义EmpMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 统计各个职位的员工人数 -->
<select id="countEmpJobData" resultType="java.util.Map">
select
(case job when 1 then '班主任'
when 2 then '讲师'
when 3 then '学工主管'
when 4 then '教研主管'
when 5 then '咨询师'
else '其他' end) pos,
count(*) total
from emp group by job
order by total
</select>

case流程控制函数:

  • 语法一:case when cond1 then res1 [ when cond2 then res2 ] else res end ;

  • 含义:如果 cond1 成立, 取 res1。 如果 cond2 成立,取 res2。 如果前面的条件都不成立,则取 res。

  • 语法二(仅适用于等值匹配):case expr when val1 then res1 [ when val2 then res2 ] else res end ;

  • 含义:如果 expr 的值为 val1 , 取 res1。 如果 expr 的值为 val2 ,取 res2。 如果前面的条件都不成立,则取 res。

重新启动服务,打开Apifox进行测试。

img

联调测试

img

8.3性别统计

8.3.1需求

对于这类的图形报表,服务端要做的,就是为其提供数据即可。 我们可以通过官方的示例,看到提供的数据就是一个json格式的数据。

img

img

img

8.3.2代码实现

1). 在ReportController,添加方法。

1
2
3
4
5
6
7
8
9
/**
* 统计员工性别信息
*/
@GetMapping("/empGenderData")
public Result getEmpGenderData(){
log.info("统计员工性别信息");
List<Map> genderList = reportService.getEmpGenderData();
return Result.success(genderList);
}

2). 在ReportService接口,添加接口方法。

1
2
3
4
/**
* 统计员工性别信息
*/
List<Map> getEmpGenderData();

3). 在ReportServiceImpl实现类,实现方法

1
2
3
4
@Override
public List<Map> getEmpGenderData() {
return empMapper.countEmpGenderData();
}

4). 定义EmpMapper 接口

统计的是员工的信息,所以需要操作的是员工表。 所以代码我们就写在 EmpMapper 接口中即可。

1
2
3
4
5
/**
* 统计员工性别信息
*/
@MapKey("name")
List<Map> countEmpGenderData();

5). 定义EmpMapper.xml

1
2
3
4
5
6
7
<!-- 统计员工的性别信息 -->
<select id="countEmpGenderData" resultType="java.util.Map">
select
if(gender = 1, '男', '女') as name,
count(*) as value
from emp group by gender ;
</select>

if函数语法:if(条件, 条件为true取值, 条件为false取值)

ifnull函数语法:ifnull(expr, val1) 如果expr不为null,取自身,否则取val1

Apifox测试

img

联调测试

img

–班级管理

数据准备

在数据库中,创建学生表 clazz , student,SQL如下:

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
create table clazz(
id int unsigned primary key auto_increment comment 'ID,主键',
name varchar(30) not null unique comment '班级名称',
room varchar(20) comment '班级教室',
begin_date date not null comment '开课时间',
end_date date not null comment '结课时间',
master_id int unsigned null comment '班主任ID, 关联员工表ID',
subject tinyint unsigned not null comment '学科, 1:java, 2:前端, 3:大数据, 4:Python, 5:Go, 6: 嵌入式',
create_time datetime comment '创建时间',
update_time datetime comment '修改时间'
)comment '班级表';

INSERT INTO clazz VALUES (1,'JavaEE就业163期','212','2024-04-30','2024-06-29',10,1,'2024-06-01 17:08:23','2024-06-01 17:39:58'),
(2,'前端就业90期','210','2024-07-10','2024-01-20',3,2,'2024-06-01 17:45:12','2024-06-01 17:45:12'),
(3,'JavaEE就业165期','108','2024-06-15','2024-12-25',6,1,'2024-06-01 17:45:40','2024-06-01 17:45:40'),
(4,'JavaEE就业166期','105','2024-07-20','2024-02-20',20,1,'2024-06-01 17:46:10','2024-06-01 17:46:10'),
(5,'大数据就业58期','209','2024-08-01','2024-02-15',7,3,'2024-06-01 17:51:21','2024-06-01 17:51:21'),
(6,'JavaEE就业167期','325','2024-11-20','2024-05-10',36,1,'2024-11-15 11:35:46','2024-12-13 14:31:24');


create table student(
id int unsigned primary key auto_increment comment 'ID,主键',
name varchar(10) not null comment '姓名',
no char(10) not null unique comment '学号',
gender tinyint unsigned not null comment '性别, 1: 男, 2: 女',
phone varchar(11) not null unique comment '手机号',
id_card char(18) not null unique comment '身份证号',
is_college tinyint unsigned not null comment '是否来自于院校, 1:是, 0:否',
address varchar(100) comment '联系地址',
degree tinyint unsigned comment '最高学历, 1:初中, 2:高中, 3:大专, 4:本科, 5:硕士, 6:博士',
graduation_date date comment '毕业时间',
clazz_id int unsigned not null comment '班级ID, 关联班级表ID',
violation_count tinyint unsigned default '0' not null comment '违纪次数',
violation_score tinyint unsigned default '0' not null comment '违纪扣分',
create_time datetime comment '创建时间',
update_time datetime comment '修改时间'
) comment '学员表';


INSERT INTO student VALUES (1,'段誉','2022000001',1,'18800000001','110120000300200001',1,'北京市昌平区建材城西路1号',1,'2021-07-01',2,0,0,'2024-11-14 21:22:19','2024-11-15 16:20:59'),
(2,'萧峰','2022000002',1,'18800210003','110120000300200002',1,'北京市昌平区建材城西路2号',2,'2022-07-01',1,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(3,'虚竹','2022000003',1,'18800013001','110120000300200003',1,'北京市昌平区建材城西路3号',2,'2024-07-01',1,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(4,'萧远山','2022000004',1,'18800003211','110120000300200004',1,'北京市昌平区建材城西路4号',3,'2024-07-01',1,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(5,'阿朱','2022000005',2,'18800160002','110120000300200005',1,'北京市昌平区建材城西路5号',4,'2020-07-01',1,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(6,'阿紫','2022000006',2,'18800000034','110120000300200006',1,'北京市昌平区建材城西路6号',4,'2021-07-01',2,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(7,'游坦之','2022000007',1,'18800000067','110120000300200007',1,'北京市昌平区建材城西路7号',4,'2022-07-01',2,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(8,'康敏','2022000008',2,'18800000077','110120000300200008',1,'北京市昌平区建材城西路8号',5,'2024-07-01',2,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(9,'徐长老','2022000009',1,'18800000341','110120000300200009',1,'北京市昌平区建材城西路9号',3,'2024-07-01',2,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(10,'云中鹤','2022000010',1,'18800006571','110120000300200010',1,'北京市昌平区建材城西路10号',2,'2020-07-01',2,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(11,'钟万仇','2022000011',1,'18800000391','110120000300200011',1,'北京市昌平区建材城西路11号',4,'2021-07-01',1,0,0,'2024-11-14 21:22:19','2024-11-15 16:21:24'),
(12,'崔百泉','2022000012',1,'18800000781','110120000300200018',1,'北京市昌平区建材城西路12号',4,'2022-07-05',3,6,17,'2024-11-14 21:22:19','2024-12-13 14:33:58'),
(13,'耶律洪基','2022000013',1,'18800008901','110120000300200013',1,'北京市昌平区建材城西路13号',4,'2024-07-01',2,0,0,'2024-11-14 21:22:19','2024-11-15 16:21:21'),
(14,'天山童姥','2022000014',2,'18800009201','110120000300200014',1,'北京市昌平区建材城西路14号',4,'2024-07-01',1,0,0,'2024-11-14 21:22:19','2024-11-15 16:21:17'),
(15,'刘竹庄','2022000015',1,'18800009401','110120000300200015',1,'北京市昌平区建材城西路15号',3,'2020-07-01',4,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(16,'李春来','2022000016',1,'18800008501','110120000300200016',1,'北京市昌平区建材城西路16号',4,'2021-07-01',4,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(17,'王语嫣','2022000017',2,'18800007601','110120000300200017',1,'北京市昌平区建材城西路17号',2,'2022-07-01',4,0,0,'2024-11-14 21:22:19','2024-11-14 21:22:19'),
(18,'郑成功','2024001101',1,'13309092345','110110110110110110',0,'北京市昌平区回龙观街道88号',5,'2021-07-01',3,2,7,'2024-11-15 16:26:18','2024-11-15 16:40:10');

表结构关系说明:

img

实体类Clazz:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Clazz {
private Integer id; //ID
private String name; //班级名称
private String room; //班级教室
private LocalDate beginDate; //开课时间
private LocalDate endDate; //结课时间
private Integer masterId; //班主任
private Integer subject; //学科
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间

private String masterName; //班主任姓名
private String status; //班级状态 - 未开班 , 在读 , 已结课
}

实体类Student:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Student {
private Integer id; //ID
private String name; //姓名
private String no; //序号
private Integer gender; //性别 , 1: 男 , 2 : 女
private String phone; //手机号
private String idCard; //身份证号
private Integer isCollege; //是否来自于院校, 1: 是, 0: 否
private String address; //联系地址
private Integer degree; //最高学历, 1: 初中, 2: 高中 , 3: 大专 , 4: 本科 , 5: 硕士 , 6: 博士
private LocalDate graduationDate; //毕业时间
private Integer clazzId; //班级ID
private Short violationCount; //违纪次数
private Short violationScore; //违纪扣分
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间

private String clazzName;//班级名称
}

1.条件分页查询

1.1需求

img

img

img

img

1.2代码实现

1)在 ClazzController

1
2
3
4
5
6
7
8
9
@GetMapping
public Result page(String name ,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin ,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end,
@RequestParam(defaultValue = "1") Integer page ,
@RequestParam(defaultValue = "10")Integer pageSize){
PageResult pageResult = clazzService.page(name , begin , end , page , pageSize);
return Result.success(pageResult);
}

2).ClazzService & ClazzServiceImpl

ClazzService

1
2
3
4
5
6
7
8
9
10
/**
* 条件分页查询
* @param name
* @param begin
* @param end
* @param page
* @param pageSize
* @return
*/
PageResult page(String name, LocalDate begin, LocalDate end, Integer page, Integer pageSize);

ClazzServiceImpl中

1
2
3
4
5
6
7
8
9
@Override
public PageResult page(String name, LocalDate begin, LocalDate end, Integer page, Integer pageSize) {
PageHelper.startPage(page, pageSize);

List<Clazz> dataList = clazzMapper.list(name,begin,end);
Page<Clazz> p = (Page<Clazz>) dataList;

return new PageResult(p.getTotal(), p.getResult());
}

3)ClazzMapper中

1
2
3
4
/**
* 动态条件查询
*/
List<Clazz> list(String name, LocalDate begin, LocalDate end);

ClazzMapper.xml中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<select id="list" resultType="com.itheima.pojo.Clazz">
select
c.*,
e.name masterName,
(case when begin_date &gt; now() then '未开班' when now() &gt; end_date then '已结课' else '在读中' end ) status
from clazz c left join emp e on c.master_id = e.id
<where>
<if test="name != null and name != ''">
c.name like concat('%',#{name},'%')
</if>
<if test="begin != null and end != null">
and c.end_date between #{begin} and #{end}
</if>
</where>
order by c.update_time desc
</select>

2.查询所有员工

2.1需求

那其实,对于培训机构来说,班主任就是这个企业的员工。所以,班主任下拉列表中展示的就是所有的员工数据。

img

img

img

img

2.2代码实现

1)在EmpController

1
2
3
4
5
6
7
8
9
10
 /**
* 查询所有的员工
*/
@GetMapping("/list")
public Result list(){
log.info("查询所有的员工数据");
List<Emp> empList = empService.list();
return Result.success(empList);
}
}

2).EmpService &EmpServiceImpl

EmpService

1
2
3
4
/**
* 查询所有的员工数据
*/
List<Emp> list();

在 EmpServiceImpl中

1
2
3
4
@Override
public List<Emp> list() {
return empMapper.findAll();
}

3)EmpMapper 中

1
2
3
4
5
/**
* 查询全部员工
*/
@Select("select id, username, password, name, gender, phone, job, salary, image, entry_date, dept_id, create_time, update_time from emp")
List<Emp> findAll();

3.新增班级信息

3.1需求

img

img

img

3.2代码实现

1)在 ClazzController

1
2
3
4
5
6
7
8
/**
* 新增班级
*/
@PostMapping
public Result save(@RequestBody Clazz clazz){
clazzService.save(clazz);
return Result.success();
}

2).ClazzService & ClazzServiceImpl

ClazzService

1
2
3
4
5
/**
* 添加班级信息
* @param clazz
*/
void save(Clazz clazz);

ClazzServiceImpl 中

1
2
3
4
5
6
@Override
public void save(Clazz clazz) {
clazz.setCreateTime(LocalDateTime.now());
clazz.setUpdateTime(LocalDateTime.now());
clazzMapper.insert(clazz);
}

3)ClazzMapper 中

1
2
3
4
5
/**
* 添加班级信息
*/
@Insert("insert into clazz VALUES (null,#{name},#{room},#{beginDate},#{endDate},#{masterId}, #{subject},#{createTime},#{updateTime})")
void insert(Clazz clazz);

4.根据ID查询班级

4.1需求

img

img

img

img

4.2代码实现

1)在 ClazzController

1
2
3
4
5
6
7
8
/**
* 根据ID查询班级详情
*/
@GetMapping("/{id}")
public Result getInfo(@PathVariable Integer id){
Clazz clazz = clazzService.getInfo(id);
return Result.success(clazz);
}

2).ClazzService & ClazzServiceImpl

ClazzService

1
2
3
4
5
6
/**
* 根据ID查询班级详情
* @param id
* @return
*/
Clazz getInfo(Integer id);

ClazzServiceImpl 中

1
2
3
4
@Override
public Clazz getInfo(Integer id) {
return clazzMapper.getInfo(id);
}

3)ClazzMapper 中

1
2
3
4
5
/**
* 根据ID查询班级详情
*/
@Select("select * from clazz where id = #{id}")
Clazz getInfo(Integer id);

5.修改班级信息

5.1需求

img

img

img

5.2代码实现

1)在 ClazzController

1
2
3
4
5
6
7
8
/**
* 更新班级信息
*/
@PutMapping
public Result update(@RequestBody Clazz clazz){
clazzService.update(clazz);
return Result.success();
}

2).ClazzService & ClazzServiceImpl

ClazzService

1
2
3
4
5
/**
* 修改班级信息
* @param clazz
*/
void update(Clazz clazz);

ClazzServiceImpl 中

1
2
3
4
5
@Override
public void update(Clazz clazz) {
clazz.setUpdateTime(LocalDateTime.now());
clazzMapper.update(clazz);
}

3)ClazzMapper 中

1
2
3
4
/**
* 动态更新班级信息
*/
void update(Clazz clazz);

ClazzMapper.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
<!--动态更新班级信息-->
<update id="update">
update clazz
<set>
<if test="name != null and name != ''">
name = #{name},
</if>
<if test="room != null and room != ''">
room = #{room},
</if>
<if test="beginDate != null">
begin_date = #{beginDate},
</if>
<if test="endDate != null">
end_date = #{endDate},
</if>
<if test="masterId != null">
master_id = #{masterId},
</if>
<if test="subject != null">
subject = #{subject},
</if>
<if test="updateTime != null">
update_time = #{updateTime}
</if>
</set>
where id = #{id}
</update>

6.删除班级信息

6.1需求

img

注意:在页面原型中,要求如果该班级下关联的有学生,是不允许删除的,并提示错误信息:“对不起, 该班级下有学生, 不能直接删除”。 (提示:可以通过自定义异常 + 全局异常处理器实现)

img

img

img

6.2代码实现

1)在 ClazzController

1
2
3
4
5
6
7
8
/**
* 根据ID删除班级
*/
@DeleteMapping("/{id}")
public Result delete(@PathVariable Integer id){
clazzService.deleteById(id);
return Result.success();
}

2).ClazzService & ClazzServiceImpl

ClazzService

1
2
3
4
5
/**
* 删除班级
* @param id
*/
void deleteById(Integer id);

ClazzServiceImpl 中

1
2
3
4
5
6
7
8
9
@Override
public void deleteById(Integer id) {
//1. 查询班级下是否有学员
Integer count = studentMapper.countByClazzId(id);
if(count > 0){
throw new BusinessException("班级下有学员, 不能直接删除~");
}
//2. 如果没有, 再删除班级信息
clazzMapper.deleteById(id);

3)ClazzMapper 中

1
2
3
4
5
/**
* 根据ID删除班级
*/
@Delete("delete from clazz where id = #{id}")
void deleteById(Integer id);

–学员管理

1.查询所有班级

1.1需求

在新增学员的时候,要展示出所有的班级信息。

img

img

img

1.2代码实现

1)在 ClazzController

1
2
3
4
5
6
7
8
/**
* 查询全部班级
*/
@GetMapping("/list")
public Result findAll(){
List<Clazz> clazzList = clazzService.findAll();
return Result.success(clazzList);
}

2).ClazzService & ClazzServiceImpl

ClazzService

1
2
3
4
5
/**
* 查询全部班级
* @return
*/
List<Clazz> findAll();

ClazzServiceImpl 中

1
2
3
4
@Override
public List<Clazz> findAll() {
return clazzMapper.findAll();
}

3)ClazzMapper 中

1
2
3
4
5
/**
* 查询全部班级
*/
@Select("select * from clazz")
List<Clazz> findAll();

2.条件分页查询

2.1需求

img

img

img

img

2.2代码实现

1)在 StudentController

1
2
3
4
5
6
7
8
9
@GetMapping
public Result page(String name ,
Integer degree,
Integer clazzId,
@RequestParam(defaultValue = "1") Integer page ,
@RequestParam(defaultValue = "10") Integer pageSize){
PageResult pageResult = studentService.page(name,degree,clazzId,page,pageSize);
return Result.success(pageResult);
}

2).StudentService & StudentServiceImpl

StudentService

1
2
3
4
/**
* 条件分页查询
*/
PageResult page(String name, Integer degree, Integer clazzId, Integer page, Integer pageSize);

在StudentServiceImpl 中

1
2
3
4
5
6
7
8
9
@Override
public PageResult page(String name, Integer degree, Integer clazzId, Integer page, Integer pageSize) {
PageHelper.startPage(page, pageSize);

List<Student> studentList = studentMapper.list(name,degree,clazzId);
Page<Student> p = (Page<Student>) studentList;

return new PageResult(p.getTotal(), p.getResult());
}

3)StudentMapper 中

1
2
3
4
/**
* 动态条件查询
*/
List<Student> list(String name, Integer degree, Integer clazzId);

StudentMapper.xml 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 动态条件查询 -->
<select id="list" resultType="com.itheima.pojo.Student">
select s.*, c.name clazzName from student s left join clazz c on s.clazz_id = c.id
<where>
<if test="name != null and name != ''">
s.name like concat('%',#{name},'%')
</if>
<if test="degree != null">
and s.degree = #{degree}
</if>
<if test="clazzId != null">
and s.clazz_id = #{clazzId}
</if>
</where>
order by s.update_time desc
</select>

3.新增学生信息

3.1需求

img

img

img

3.2代码实现

1)在 StudentController

1
2
3
4
5
6
7
8
/**
* 添加学生
*/
@PostMapping
public Result save(@RequestBody Student student){
studentService.save(student);
return Result.success();
}

2).StudentService & StudentServiceImpl

StudentService

1
2
3
4
/**
* 添加学生信息
*/
void save(Student student);

在 StudentServiceImpl 中

1
2
3
4
5
6
@Override
public void save(Student student) {
student.setCreateTime(LocalDateTime.now());
student.setUpdateTime(LocalDateTime.now());
studentMapper.insert(student);
}

3)StudentMapper 中

1
2
3
4
5
6
/**
* 添加学生
*/
@Insert("insert into student(name, no, gender, phone,id_card, is_college, address, degree, graduation_date,clazz_id, create_time, update_time) VALUES " +
"(#{name},#{no},#{gender},#{phone},#{idCard},#{isCollege},#{address},#{degree},#{graduationDate},#{clazzId},#{createTime},#{updateTime})")
void insert(Student student);

4.根据ID查询学生

4.1需求

img

img

img

img

4.2代码实现

1)在 StudentController 中

1
2
3
4
5
6
7
8
/**
* 根据ID查询学生信息
*/
@GetMapping("/{id}")
public Result getInfo(@PathVariable Integer id){
Student student = studentService.getInfo(id);
return Result.success(student);
}

2).StudentService & StudentServiceImpl

在 StudentService 中

1
2
3
4
/**
* 根据ID查询学生信息
*/
Student getInfo(Integer id);

在 StudentServiceImpl 中

1
2
3
4
@Override
public Student getInfo(Integer id) {
return studentMapper.getById(id);
}

3)StudentMapper 中

1
2
3
4
5
/**
* 根据ID查询学生信息
*/
@Select("select * from student where id = #{id}")
Student getById(Integer id);

5.修改学生信息

5.1需求

img

img

img

img

5.2代码实现

1)在 StudentController 中

1
2
3
4
5
6
7
8
/**
* 修改学生信息
*/
@PutMapping
public Result update(@RequestBody Student student){
studentService.update(student);
return Result.success();
}

2).StudentService & StudentServiceImpl

在 StudentService 中

1
2
3
4
/**
* 修改学生信息
*/
void update(Student student);

在 StudentServiceImpl 中

1
2
3
4
5
@Override
public void update(Student student) {
student.setUpdateTime(LocalDateTime.now());
studentMapper.update(student);
}

3)StudentMapper 中

1
2
3
4
/**
* 修改学生信息
*/
void update(Student student);

StudentMapper.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
<!-- 修改学生信息 -->
<update id="update">
update student
<set>
<if test="name != null and name != ''">
name = #{name},
</if>
<if test="no != null and no != ''">
no = #{no},
</if>
<if test="gender != null">
gender = #{gender},
</if>
<if test="phone != null and phone != ''">
phone = #{phone},
</if>
<if test="idCard != null and idCard != ''">
id_card = #{idCard},
</if>
<if test="isCollege != null and isCollege != ''">
is_college = #{isCollege},
</if>
<if test="address != null and address != ''">
address = #{address},
</if>
<if test="graduationDate != null">
graduation_date = #{graduationDate},
</if>
<if test="degree != null">
degree = #{degree},
</if>
<if test="violationCount != null">
violation_count = #{violationCount},
</if>
<if test="violationScore != null">
violation_score = #{violationScore},
</if>
<if test="clazzId != null">
clazz_id = #{clazzId},
</if>
<if test="updateTime != null">
update_time = #{updateTime}
</if>
</set>
where id = #{id}
</update>

6.删除学生信息

6.1需求

img

img

img

6.2代码实现

1)在 StudentController 中

1
2
3
4
5
6
7
8
/**
* 删除学生信息
*/
@DeleteMapping("/{ids}")
public Result delete(@PathVariable List<Integer> ids){
studentService.delete(ids);
return Result.success();
}

2).StudentService & StudentServiceImpl

在 StudentService 中

1
2
3
4
/**
* 删除学生信息
*/
void delete(List<Integer> ids);

在 StudentServiceImpl 中

1
2
3
4
@Override
public void delete(List<Integer> ids) {
studentMapper.delete(ids);
}

3)StudentMapper 中

1
2
3
4
/**
* 批量删除学生信息
*/
void delete(List<Integer> ids);

StudentMapper.xml 中

1
2
3
4
5
6
7
<!--批量删除学生信息-->
<delete id="delete">
delete from student where id in
<foreach collection="ids" item="id" separator="," open="(" close=")">
#{id}
</foreach>
</delete>

7.违纪处理

7.1需求

img

img

img

7.2代码实现

1)在 StudentController 中

1
2
3
4
5
6
7
8
/**
* 违纪处理
*/
@PutMapping("/violation/{id}/{score}")
public Result violationHandle(@PathVariable Integer id , @PathVariable Integer score){
studentService.violationHandle(id, score);
return Result.success();
}

2).StudentService & StudentServiceImpl

在 StudentService 中

1
2
3
4
/**
* 违纪处理
*/
void violationHandle(Integer id, Integer score);

在 StudentServiceImpl 中

1
2
3
4
@Override
public void violationHandle(Integer id, Integer score) {
studentMapper.updateViolation(id, score);
}

3)StudentMapper 中

1
2
3
4
5
/**
* 违纪处理
*/
@Update("update student set violation_count = violation_count + 1 , violation_score = violation_score + #{score} , update_time = now() where id = #{id}")
void updateViolation(Integer id, Integer score);

8.学员信息统计

img

8.1班级人数统计接口开发

8.1.1需求

img

img

8.1.2代码实现

1)在 ReportController 中

1
2
3
4
5
6
7
8
9
/**
* 班级人数统计
*/
@GetMapping("/studentCountData")
public Result getStudentCountData(){
log.info("班级人数统计");
ClazzCountOption clazzCountOption = reportService.getStudentCountData();
return Result.success(clazzCountOption);
}

2). ReportService & ReportServiceImpl

在 ReportService 中

1
2
3
4
/**
* 统计班级人数
*/
ClazzCountOption getStudentCountData();

在 ReportServiceImpl 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public ClazzCountOption getStudentCountData() {
List<Map<String, Object>> countList = studentMapper.getStudentCount();
if(!CollectionUtils.isEmpty(countList)){
List<Object> clazzList = countList.stream().map(map -> {
return map.get("cname");
}).toList();

List<Object> dataList = countList.stream().map(map -> {
return map.get("scount");
}).toList();

return new ClazzCountOption(clazzList, dataList);
}
return null;
}

在StudentMapper中

1
2
3
4
5
/**
* 统计班级人数
*/
@Select("select c.name cname , count(s.id) scount from clazz c left join student s on s.clazz_id = c.id group by c.name order by count(s.id) desc ")
List<Map<String,Object>> getStudentCount();

8.2学员学历信息统计接口开发

8.2.1需求

img

img

8.2.2代码实现

1)在 ReportController 中

1
2
3
4
5
6
7
8
9
/**
* 统计学员的学历信息
*/
@GetMapping("/studentDegreeData")
public Result getStudentDegreeData(){
log.info("统计学员的学历信息");
List<Map> dataList = reportService.getStudentDegreeData();
return Result.success(dataList);
}

2). ReportService & ReportServiceImpl

在 ReportService 中

1
2
3
4
/**
* 统计学历人数
*/
List<Map> getStudentDegreeData();

在 ReportServiceImpl 中

1
2
3
4
@Override
public List<Map> getStudentDegreeData() {
return studentMapper.countStudentDegreeData();
}

3)StudentMapper 中

1
2
3
4
5
/**
* 统计学员学历
*/
@MapKey("name")
List<Map> countStudentDegreeData();

StudentMapper.xml 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--学员学历统计-->
<select id="countStudentDegreeData" resultType="java.util.Map">
select
(case degree
when 1 then '初中'
when 2 then '高中'
when 3 then '大专'
when 4 then '本科'
when 5 then '硕士'
when 6 then '博士'
else '其他' end) name,
count(*) value
from student group by degree
order by degree
</select>

9.删除部门完善

9.1需求

如果部门下有员工,则不允许删除该部门,并给前端提示错误信息:对不起,当前部门下有员工,不能直接删除!

9.2代码实现

1)在 DeptController 中

1
2
3
4
5
6
7
8
9
10
/**
* 删除部门 - 省略@RequestParam (前端传递的请求参数名与服务端方法形参名一致) [推荐]
*/
@DeleteMapping
public Result delete(Integer id){
//System.out.println("根据ID删除部门: " + id);
log.info("根据ID删除部门: {}", id);
deptService.deleteById(id);
return Result.success();
}

2). DeptService & DeptServiceImpl

在 DeptService 中

1
2
3
4
/**
* 根据ID删除部门
*/
void deleteById(Integer id);

在 DeptServiceImpl 中

1
2
3
4
5
6
7
8
9
10
11
@Override
public void deleteById(Integer id) {
//1. 判断部门下是否有员工, 如果有, 需要提示错误信息
Integer count = empMapper.countByDeptId(id);
if(count > 0){
throw new BusinessException("部门下有员工, 不能删除");
}

//2. 删除部门
deptMapper.deleteById(id);
}

3) DeptMapper 中

1
2
3
4
5
/**
* 根据ID删除部门
*/
@Delete("delete from dept where id = #{id}")
void deleteById(Integer id);

–登录认证

1.登录功能

1.1需求

在登录界面中,我们可以输入用户的用户名以及密码,然后点击 “登录” 按钮就要请求服务器,服务端判断用户输入的用户名或者密码是否正确。如果正确,则返回成功结果,前端跳转至系统首页面。

img

img

1.2实现思路

  • 怎么样才算登录成功了呢?
    • 用户名和密码都输入正确,登录成功
    • 否则,登录失败
  • 登录功能的本质是什么?
    • 查询
    • 根据用户名和密码查询员工信息

1.3代码实现

1). 准备实体类 LoginInfo, 封装登录成功后, 返回给前端的数据 。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 登录成功结果封装类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginInfo {
private Integer id; //员工ID
private String username; //用户名
private String name; //姓名
private String token; //令牌
}

2). 定义LoginController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
@RestController
public class LoginController {

@Autowired
private EmpService empService;

@PostMapping("/login")
public Result login(@RequestBody Emp emp){
log.info("员工来登录啦 , {}", emp);
LoginInfo loginInfo = empService.login(emp);
if(loginInfo != null){
return Result.success(loginInfo);
}
return Result.error("用户名或密码错误~");
}
}

**3).****EmpService**接口中增加 login 登录方法

1
2
3
4
/**
* 登录
*/
LoginInfo login(Emp emp);

4). EmpServiceImpl 实现login方法

1
2
3
4
5
6
7
8
9
@Override
public LoginInfo login(Emp emp) {
Emp empLogin = empMapper.getUsernameAndPassword(emp);
if(empLogin != null){
LoginInfo loginInfo = new LoginInfo(empLogin.getId(), empLogin.getUsername(), empLogin.getName(), null);
return loginInfo;
}
return null;
}

5). **EmpMapper**增加接口方法

1
2
3
4
5
/**
* 根据用户名和密码查询员工信息
*/
@Select("select * from emp where username = #{username} and password = #{password}")
Emp getUsernameAndPassword(Emp emp);

但是当我们在浏览器中新的页面上输入地址:http://localhost:90,发现没有登录仍然可以进入到后端管理系统页面。

而真正的登录功能应该是:登陆后才能访问后端系统页面,不登陆则跳转登陆页面进行登陆。

为什么会出现这个问题?其实原因很简单,就是因为针对于我们当前所开发的部门管理、员工管理以及文件上传等相关接口来说,我们在服务器端并没有做任何的判断,没有去判断用户是否登录了。所以无论用户是否登录,都可以访问部门管理以及员工管理的相关数据。所以我们目前所开发的登录功能,它只是徒有其表。而我们要想解决这个问题,我们就需要完成一步非常重要的操作:登录校验。

2.登录效验

什么是登录校验?

所谓登录校验,指的是我们在服务器端接收到浏览器发送过来的请求之后,首先我们要对请求进行校验。先要校验一下用户登录了没有,如果用户已经登录了,就直接执行对应的业务操作就可以了;如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。

该怎么来实现登录校验的操作呢?具体的实现思路可以分为两部分:

  1. 在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记。
  2. 在浏览器发起请求时,需要在服务端进行统一拦截,拦截后进行登录校验。

想要判断员工是否已经登录,我们需要在员工登录成功之后,存储一个登录成功的标记,接下来在每一个接口方法执行之前,先做一个条件判断,判断一下这个员工到底登录了没有。如果是登录了,就可以执行正常的业务操作,如果没有登录,会直接给前端返回一个错误的信息,前端拿到这个错误信息之后会自动的跳转到登录页面。

我们程序中所开发的查询功能、删除功能、添加功能、修改功能,都需要使用以上套路进行登录校验。为了简化这块操作,我们可以使用一种技术:统一拦截技术。

通过统一拦截的技术,我们可以来拦截浏览器发送过来的所有的请求,拦截到这个请求之后,就可以通过请求来获取之前所存入的登录标记,在获取到登录标记且标记为登录成功,就说明员工已经登录了。如果已经登录,我们就直接放行(意思就是可以访问正常的业务接口了)。

我们要完成以上操作,会涉及到web开发中的两个技术:

  1. 会话技术:用户登录成功之后,在后续的每一次请求中,都可以获取到该标记。
  2. 统一拦截技术:过滤器Filter、拦截器Interceptor

2.1会话技术

2.1.1基础知识

什么是会话?

在web开发当中,会话指的就是浏览器与服务器之间的一次连接,我们就称为一次会话。

举例:在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。在一次会话当中,是可以包含多次请求和响应的。

比如:打开了浏览器来访问web服务器上的资源(浏览器不能关闭、服务器不能断开)

  • 第1次:访问的是登录的接口,完成登录操作
  • 第2次:访问的是部门管理接口,查询所有部门数据
  • 第3次:访问的是员工管理接口,查询员工数据

只要浏览器和服务器都没有关闭,以上3次请求都属于一次会话当中完成的。

需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。

会话跟踪:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据。

我们使用会话跟踪技术就是要完成在同一个会话中,多个请求之间进行共享数据。

为什么要共享数据呢?

由于HTTP是无状态协议,在后面请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享

2.1.2三种会话追踪方案

会话跟踪技术有三种:

  1. Cookie(客户端会话跟踪技术):数据存储在客户端浏览器当中
  2. Session(服务端会话跟踪技术):数据存储在储在服务端
  3. 令牌技术

2.1.3方案一:Cookie

cookie 是客户端会话跟踪技术,它是存储在客户端浏览器的,我们使用 cookie 来跟踪会话,我们就可以在浏览器第一次发起请求来请求服务器的时候,我们在服务器端来设置一个cookie。

比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个cookie,在 cookie 当中我们就可以来存储用户相关的一些数据信息。比如我可以在 cookie 当中来存储当前登录用户的用户名,用户的ID。

img

  • 服务器会 自动 的将 cookie 响应给浏览器。
  • 浏览器接收到响应回来的数据之后,会 自动 的将 cookie 存储在浏览器本地。
  • 在后续的请求当中,浏览器会 自动 的将 cookie 携带到服务器端。

为什么这一切都是自动化进行的?

是因为 cookie 它是 HTTP 协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。在 HTTP 协议官方给我们提供了一个响应头和请求头:

  • 响应头 Set-Cookie :设置Cookie数据的
  • 请求头 Cookie:携带Cookie数据的

img

代码测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@RestController
public class SessionController {

//设置Cookie
@GetMapping("/c1")
public Result cookie1(HttpServletResponse response){
response.addCookie(new Cookie("login_username","itheima")); //设置Cookie/响应Cookie
return Result.success();
}

//获取Cookie
@GetMapping("/c2")
public Result cookie2(HttpServletRequest request){
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if(cookie.getName().equals("login_username")){
System.out.println("login_username: "+cookie.getValue()); //输出name为login_username的cookie
}
}
return Result.success();
}
}

A. 访问c1接口,设置Cookie,http://localhost:8080/c1

img

我们可以看到,设置的cookie,通过响应头Set-Cookie响应给浏览器,并且浏览器会将Cookie,存储在浏览器端。

img

B. 访问c2接口 http://localhost:8080/c2,此时浏览器会自动的将Cookie携带到服务端,是通过请求头Cookie,携带的。

img

优缺点:

  • 优点:HTTP协议中支持的技术(像Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的)
  • 缺点:
    • 移动端APP(Android、IOS)中无法使用Cookie
    • 不安全,用户可以自己禁用Cookie
    • Cookie不能跨域

2.1.4关于跨域

跨域介绍:

img

  • 现在的项目,大部分都是前后端分离的,前后端最终也会分开部署,前端部署在服务器 192.168.150.200 上,端口 80,后端部署在 192.168.150.100上,端口 8080
  • 我们打开浏览器直接访问前端工程,访问url:http://192.168.150.200/login.html
  • 然后在该页面发起请求到服务端,而服务端所在地址不再是localhost,而是服务器的IP地址192.168.150.100,假设访问接口地址为:http://192.168.150.100:8080/login
  • 那此时就存在跨域操作了,因为我们是在http://192.168.150.200/login.html这个页面上访问了http://192.168.150.100:8080/login接口
  • 此时如果服务器设置了一个Cookie,这个Cookie是不能使用的,因为Cookie无法跨域

区分跨域的维度(三个维度有任何一个维度不同,那就是跨域操作):

  • 协议
  • IP/协议
  • 端口

举例:

2.1.5方案二Session

它是服务器端会话跟踪技术,所以它是存储在服务器端的

  • 服务器端会话跟踪技术:所有会话数据都保存在服务器上,只在客户端/浏览器里存一个标识(ID)。

  • 基于 Cookie 实现:底层通过在 HTTP 响应里下发一个名为 JSESSIONID 的 Cookie,把 Session 的 ID 把给浏览器。

  • 创建并下发 Session

第一次请求

  • 浏览器向服务器发起请求。
  • 服务器检查当前请求是否带有 Session ID(Cookie),发现没有。
  • 此时服务器自动创建一个新的 Session 对象(如图中标注为 Session(1)),并给它分配一个唯一 ID。

img

响应中下发 Cookie

  • 服务器在 HTTP 响应头中加上: Set-Cookie: JSESSIONID=<session-id>
  • 浏览器收到后,会把这个 JSESSIONID 存到本地。

img

  • 查找并复用 Session

img

浏览器自动带上 Cookie

  • 之后每次发请求时,浏览器都会在头里加上:
1
Cookie: JSESSIONID=<session-id>

服务器取回 Session 数据

  • 服务器从 Cookie 里取出 JSESSIONID,根据这个 ID 从自身的 Session 存储中找到对应的会话对象。
  • 因此,多个请求就能共享同一个 Session 对象里的数据(如用户登录状态、临时变量等)。

代码测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@RestController
public class SessionController {

@GetMapping("/s1")
public Result session1(HttpSession session){
log.info("HttpSession-s1: {}", session.hashCode());

session.setAttribute("loginUser", "tom"); //往session中存储数据
return Result.success();
}

@GetMapping("/s2")
public Result session2(HttpServletRequest request){
HttpSession session = request.getSession();
log.info("HttpSession-s2: {}", session.hashCode());

Object loginUser = session.getAttribute("loginUser"); //从session中获取数据
log.info("loginUser: {}", loginUser);
return Result.success(loginUser);
}
}

A. 访问 s1 接口,http://localhost:8080/s1

img

请求完成之后,在响应头中,就会看到有一个Set-Cookie的响应头,里面响应回来了一个Cookie,就是JSESSIONID,这个就是服务端会话对象 Session 的ID。

B. 访问 s2 接口,http://localhost:8080/s2

img

接下来,在后续的每次请求时,都会将Cookie的值,携带到服务端,那服务端呢,接收到Cookie之后,会自动的根据JSESSIONID的值,找到对应的会话对象Session。

那经过这两步测试,大家也会看到,在控制台中输出如下日志:

img

两次请求,获取到的Session会话对象的hashcode是一样的,就说明是同一个会话对象。而且,第一次请求时,往Session会话对象中存储的值,第二次请求时,也获取到了。 那这样,我们就可以通过Session会话对象,在同一个会话的多次请求之间来进行数据共享了。

优缺点

  • 优点:Session是存储在服务端的,安全
  • 缺点:
    • 服务器集群环境下无法直接使用Session
    • 移动端APP(Android、IOS)中无法使用Cookie
    • 用户可以自己禁用Cookie
    • Cookie不能跨域

PS:Session 底层是基于Cookie实现的会话跟踪,如果Cookie不可用,则该方案,也就失效了。

2.1.6服务器集群环境为何无法使用Session?

在多机部署(集群)下直接用 Session 会碰到这样的问题:

  1. 集群部署
    • 为了避免单点故障,项目通常部署多份(例如 3 台 Tomcat),并在前端加一台负载均衡服务器来分发请求。
  2. Session 本地存储
    • 每台 Tomcat 都维护自己的一份 Session 存储,Session ID(JSESSIONID)和会话数据只保存在产生它的那台机器上。
  3. 请求不稳定路由
    • 浏览器第一次登录,经负载均衡到达 A 服务器,A 服务器创建 Session(1),并下发 JSESSIONID=1 给浏览器。
    • 浏览器第二次请求(带 JSESSIONID=1)又被路由到 B 服务器,B 服务器在自己的存储里找不到 ID=1 的 Session,就无法识别用户状态。
  4. 结果
    • 同一个浏览器连续请求,却因为被分到不同节点,拿不到同一个 Session,导致会话跟踪失效。

img

img

核心结论

  • 传统的服务器内存型 Session 只能在单机上使用;
  • 在集群环境下,必须引入“会话共享”或“粘性会话”等机制,才能保证每次请求都能取到同一个 Session 对象。

2.1.7方案三:令牌技术

这里提到的令牌,其实它就是一个用户身份的标识,本质就是一个字符串。

img

如果通过令牌技术来跟踪会话,我们就可以在浏览器发起请求。在请求登录接口的时候,如果登录成功,我就可以生成一个令牌,令牌就是用户的合法身份凭证。接下来我在响应数据的时候,我就可以直接将令牌响应给前端。

接下来我们在前端程序当中接收到令牌之后,就需要将这个令牌存储起来。这个存储可以存储在 cookie 当中,也可以存储在其他的存储空间(比如:localStorage)当中。

接下来,在后续的每一次请求当中,都需要将令牌携带到服务端。携带到服务端之后,接下来我们就需要来校验令牌的有效性。如果令牌是有效的,就说明用户已经执行了登录操作,如果令牌是无效的,就说明用户之前并未执行登录操作。

此时,如果是在同一次会话的多次请求之间,我们想共享数据,我们就可以将共享的数据存储在令牌当中就可以了。

优缺点

  • 优点:
    • 支持PC端、移动端
    • 解决集群环境下的认证问题
    • 减轻服务器的存储压力(无需在服务器端存储)
  • 缺点:需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)

2.2JWT令牌

2.2.1介绍

JWT的组成: (JWT令牌由三个部分组成,三个部分之间使用英文的点来分割)

  • 第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{“alg”:“HS256”,“type”:“JWT”}
  • 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{“id”:“1”,“username”:“Tom”}
  • 第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。

签名的目的就是为了防jwt令牌被篡改,而正是因为jwt令牌最后一个部分数字签名的存在,所以整个jwt 令牌是非常安全可靠的。一旦jwt令牌当中任何一个部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。

img

JWT是如何将原始的JSON格式数据,转变为字符串的呢?

  • 其实在生成JWT令牌时,会对JSON格式的数据进行一次编码:进行base64编码
  • Base64:是一种基于64个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的64个字符分别是A到Z、a到z、 0- 9,一个加号,一个斜杠,加起来就是64个字符。任何数据经过base64编码之后,最终就会通过这64个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号
  • 需要注意的是Base64是编码方式,而不是加密方式。

2.2.2生成和校验

1). 首先我们先来实现JWT令牌的生成。要想使用JWT令牌,需要先引入JWT的依赖:

1
2
3
4
5
6
<!-- JWT依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

在引入完JWT来赖后,就可以调用工具包中提供的API来完成JWT令牌的生成和校验。工具类:Jwts

2). 生成JWT代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void testGenJwt() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 10);
claims.put("username", "itheima");

String jwt = Jwts.builder().signWith(SignatureAlgorithm.HS256, "aXRjYXN0")
.addClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 12 * 3600 * 1000))
.compact();

System.out.println(jwt);
}

运行测试方法:

1
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk

输出的结果就是生成的JWT令牌,,通过英文的点分割对三个部分进行分割,我们可以将生成的令牌复制一下,然后打开JWT的官网,将生成的令牌直接放在Encoded位置,此时就会自动的将令牌解析出来。

img

第一部分解析出来,看到JSON格式的原始数据,所使用的签名算法为HS256。

第二个部分是我们自定义的数据,之前我们自定义的数据就是id,还有一个exp代表的是我们所设置的过期时间。

由于前两个部分是base64编码,所以是可以直接解码出来。但最后一个部分并不是base64编码,是经过签名算法计算出来的,所以最后一个部分是不会解析的。

3). 实现了JWT令牌的生成,下面我们接着使用Java代码来校验JWT令牌(解析生成的令牌):

1
2
3
4
5
6
7
@Test
public void testParseJwt() {
Claims claims = Jwts.parser().setSigningKey("aXRjYXN0")
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko")
.getBody();
System.out.println(claims);
}

运行测试方法:

1
{id=10, username=itheima, exp=1701909015}

令牌解析后,我们可以看到id和过期时间,如果在解析的过程当中没有报错,就说明解析成功了。

通过以上测试,我们在使用JWT令牌时需要注意:

  • JWT校验时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是配套的。
  • 如果JWT令牌解析校验时报错,则说明 JWT令牌被篡改 或 失效了,令牌非法。

2.2.3登录时下发令牌

  1. 生成令牌
    • 在登录成功之后来生成一个JWT令牌,并且把这个令牌直接返回给前端
  2. 校验令牌
    • 拦截前端请求,从请求中获取到令牌,对令牌进行解析校验

实现步骤:

  1. 引入JWT工具类:在项目工程下创建 com.itheima.util 包,并把提供JWT工具类复制到该包下
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
package com.itheima.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.Map;

public class JwtUtils {

private static String signKey = "SVRIRUlNQQ==";
private static Long expire = 43200000L;

/**
* 生成JWT令牌
* @return
*/
public static String generateJwt(Map<String,Object> claims){
String jwt = Jwts.builder()
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, signKey)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
return jwt;
}

/**
* 解析JWT令牌
* @param jwt JWT令牌
* @return JWT第二部分负载 payload 中存储的内容
*/
public static Claims parseJWT(String jwt){
Claims claims = Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(jwt)
.getBody();
return claims;
}
}
  1. 完善 EmpServiceImpl中的 login 方法逻辑, 登录成功,生成JWT令牌并返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public LoginInfo login(Emp emp) {
Emp empLogin = empMapper.getUsernameAndPassword(emp);
if(empLogin != null){
//1. 生成JWT令牌
Map<String,Object> dataMap = new HashMap<>();
dataMap.put("id", empLogin.getId());
dataMap.put("username", empLogin.getUsername());

String jwt = JwtUtils.generateJwt(dataMap);
LoginInfo loginInfo = new LoginInfo(empLogin.getId(), empLogin.getUsername(), empLogin.getName(), jwt);
return loginInfo;
}
return null;
}

2.3过滤器Filter

刚才通过浏览器的开发者工具,我们可以看到在后续的请求当中,都会在请求头中携带JWT令牌到服务端,而服务端需要统一拦截所有的请求,从而判断是否携带的有合法的JWT令牌。

那怎么样来统一拦截到所有的请求校验令牌的有效性呢?这里我们会学习两种解决方案:

  • Interceptor拦截器
  • Filter过滤器

2.3.1Filter快速入门

什么是Filter?

  • Filter表示过滤器,是 JavaWeb三大组件(Servlet、Filter、Listener)之一。
  • 过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能
    • 使用了过滤器之后,要想访问web服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源。
  • 过滤器一般完成一些通用的操作,比如:登录校验、统一编码处理、敏感字符处理等。

img

下面我们通过Filter快速入门程序掌握过滤器的基本使用操作:

  • 第1步,定义过滤器 :1.定义一个类,实现 Filter 接口,并重写其所有方法。
  • 第2步,配置过滤器:Filter类上加 @WebFilter 注解,配置拦截资源的路径。引导类上加 @ServletComponentScan 开启Servlet组件支持。

1). 定义过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class DemoFilter implements Filter {
//初始化方法, web服务器启动, 创建Filter实例时调用, 只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init ...");
}

//拦截到请求时,调用该方法,可以调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
System.out.println("拦截到了请求...");
}

//销毁方法, web服务器关闭时调用, 只调用一次
public void destroy() {
System.out.println("destroy ... ");
}
}
  • init方法:过滤器的初始化方法。在web服务器启动的时候会自动的创建Filter过滤器对象,在创建过滤器对象的时候会自动调用init初始化方法,这个方法只会被调用一次。
  • doFilter方法:这个方法是在每一次拦截到请求之后都会被调用,所以这个方法是会被调用多次的,每拦截到一次请求就会调用一次doFilter()方法。
  • destroy方法: 是销毁的方法。当我们关闭服务器的时候,它会自动的调用销毁方法destroy,而这个销毁方法也只会被调用一次。

2). 配置过滤器

在定义完Filter之后,Filter其实并不会生效,还需要完成Filter的配置,Filter的配置非常简单,只需要在Filter类上添加一个注解:@WebFilter,并指定属性urlPatterns,通过这个属性指定过滤器要拦截哪些请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
//初始化方法, web服务器启动, 创建Filter实例时调用, 只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init ...");
}

//拦截到请求时,调用该方法,可以调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
System.out.println("拦截到了请求...");
}

//销毁方法, web服务器关闭时调用, 只调用一次
public void destroy() {
System.out.println("destroy ... ");
}
}

当我们在Filter类上面加了@WebFilter注解之后,接下来我们还需要在启动类上面加上一个注解@ServletComponentScan,通过这个@ServletComponentScan注解来开启SpringBoot项目对于Servlet组件的支持。

1
2
3
4
5
6
7
@ServletComponentScan //开启对Servlet组件的支持
@SpringBootApplication
public class TliasManagementApplication {
public static void main(String[] args) {
SpringApplication.run(TliasManagementApplication.class, args);
}
}

重新启动服务,打开浏览器,执行部门管理的请求,可以看到控制台输出了过滤器中的内容:

img

**注意事项:**在过滤器Filter中,如果不执行放行操作,将无法访问后面的资源。 放行操作:chain.doFilter(request, response);

2.3.2登录校验过滤器

我们先来回顾下前面分析过的登录校验的基本流程:

  • 要进入到后台管理系统,我们必须先完成登录操作,此时就需要访问登录接口login。
  • 登录成功之后,我们会在服务端生成一个JWT令牌,并且把JWT令牌返回给前端,前端会将JWT令牌存储下来。
  • 在后续的每一次请求当中,都会将JWT令牌携带到服务端,请求到达服务端之后,要想去访问对应的业务功能,此时我们必须先要校验令牌的有效性。
  • 对于校验令牌的这一块操作,我们使用登录校验的过滤器,在过滤器当中来校验令牌的有效性。如果令牌是无效的,就响应一个错误的信息,也不会再去放行访问对应的资源了。如果令牌存在,并且它是有效的,此时就会放行去访问对应的web资源,执行相应的业务操作。
  1. 所有的请求,拦截到了之后,都需要校验令牌吗 ?
    • 答案:登录请求例外
  2. 拦截到请求后,什么情况下才可以放行,执行业务操作 ?
    • 答案:有令牌,且令牌校验通过(合法);否则都返回未登录错误结果

具体流程

img

基于上面的业务流程,我们分析出具体的操作步骤:

  1. 获取请求url
  2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行
  3. 获取请求头中的令牌(token)
  4. 判断令牌是否存在,如果不存在,响应 401
  5. 解析token,如果解析失败,响应 401
  6. 放行

2.3.3代码实现

com.itheima.filter 包下创建TokenFilter,具体代码如下:

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
package com.itheima.filter;

import com.itheima.utils.JwtUtils;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;
import org.springframework.util.StringUtils;
import java.io.IOException;

/** * 令牌校验过滤器 */
@Slf4j
@WebFilter(urlPatterns = "/*")
public class TokenFilter implements Filter {

@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;
//1. 获取请求url。
String url = request.getRequestURL().toString();

//2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if(url.contains("login")){ //登录请求
log.info("登录请求 , 直接放行");
chain.doFilter(request, response);
return;
}

//3. 获取请求头中的令牌(token)。
String jwt = request.getHeader("token");

//4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)。
if(!StringUtils.hasLength(jwt)){ //jwt为空
log.info("获取到jwt令牌为空, 返回错误结果");
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
return;
}

//5. 解析token,如果解析失败,返回错误结果(未登录)。
try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {
e.printStackTrace();
log.info("解析令牌失败, 返回错误结果");
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
return;
}

//6. 放行。
log.info("令牌合法, 放行");
chain.doFilter(request , response);
}

}

2.3.4Filter详解

Filter过滤器的快速入门程序我们已经完成了,接下来我们就要详细的介绍一下过滤器Filter在使用中的一些细节。主要介绍以下3个方面的细节:

  1. 过滤器的执行流程
  2. 过滤器的拦截路径配置
  3. 过滤器链
  • 执行流程

首先我们先来看下过滤器的执行流程:

img

过滤器当中我们拦截到了请求之后,如果希望继续访问后面的web资源,就要执行放行操作,放行就是调用 FilterChain对象当中的doFilter()方法,在调用doFilter()这个方法之前所编写的代码属于放行之前的逻辑。

在放行后访问完 web 资源之后还会回到过滤器当中,回到过滤器之后如有需求还可以执行放行之后的逻辑,放行之后的逻辑我们写在doFilter()这行代码之后。

测试代码:

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
@WebFilter(urlPatterns = "/*") 
public class DemoFilter implements Filter {

@Override //初始化方法, 只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

System.out.println("DemoFilter 放行前逻辑.....");

//放行请求
filterChain.doFilter(servletRequest,servletResponse);

System.out.println("DemoFilter 放行后逻辑.....");

}

@Override //销毁方法, 只调用一次
public void destroy() {
System.out.println("destroy 销毁方法执行了");
}
}

启动之后运行测试:

img

  • 拦截路径

执行流程我们搞清楚之后,接下来再来介绍一下过滤器的拦截路径,Filter可以根据需求,配置不同的拦截资源路径:

拦截路径 urlPatterns值 含义
拦截具体路径 /login 只有访问 /login 路径时,才会被拦截
目录拦截 /emps/* 访问/emps下的所有资源,都会被拦截
拦截所有 /* 访问所有资源,都会被拦截

下面我们来测试"拦截具体路径":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@WebFilter(urlPatterns = "/login")  //拦截/login具体路径
public class DemoFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("DemoFilter 放行前逻辑.....");

//放行请求
filterChain.doFilter(servletRequest,servletResponse);

System.out.println("DemoFilter 放行后逻辑.....");
}


@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}

@Override
public void destroy() {
Filter.super.destroy();
}
}
  • 过滤器链

最后我们在来介绍下过滤器链,什么是过滤器链呢?所谓过滤器链指的是在一个web应用程序当中,可以配置多个过滤器,多个过滤器就形成了一个过滤器链。

img

比如:在我们web服务器当中,定义了两个过滤器,这两个过滤器就形成了一个过滤器链。

而这个链上的过滤器在执行的时候会一个一个的执行,会先执行第一个Filter,放行之后再来执行第二个Filter,如果执行到了最后一个过滤器放行之后,才会访问对应的web资源。

访问完web资源之后,按照我们刚才所介绍的过滤器的执行流程,还会回到过滤器当中来执行过滤器放行后的逻辑,而在执行放行后的逻辑的时候,顺序是反着的。

先要执行过滤器2放行之后的逻辑,再来执行过滤器1放行之后的逻辑,最后在给浏览器响应数据。

过滤器链上过滤器的执行顺序:注解配置的Filter,优先级是按照过滤器类名(字符串)的自然排序。 比如:

  • AbcFilter
  • DemoFilter

这两个过滤器来说,AbcFilter 会先执行,DemoFilter会后执行。

2.4拦截器Interceptor

2.4.1快速入门

什么是拦截器?

  • 是一种动态拦截方法调用的机制,类似于过滤器。
  • 拦截器是Spring框架中提供的,用来动态拦截控制器方法的执行。
  • 拦截器的作用:拦截请求,在指定方法调用前后,根据业务需要执行预先设定的代码。

img

在拦截器当中,我们通常也是做一些通用性的操作,比如:我们可以通过拦截器来拦截前端发起的请求,将登录校验的逻辑全部编写在拦截器当中。在校验的过程当中,如发现用户登录了(携带JWT令牌且是合法令牌),就可以直接放行,去访问spring当中的资源。如果校验时发现并没有登录或是非法令牌,就可以直接给前端响应未登录的错误信息。

下面我们通过快速入门程序,来学习下拦截器的基本使用。拦截器的使用步骤和过滤器类似,也分为两步:

  1. 定义拦截器
  2. 注册配置拦截器

1). 自定义拦截器

实现HandlerInterceptor接口,并重写其所有方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//自定义拦截器
@Component
public class DemoInterceptor implements HandlerInterceptor {
//目标资源方法执行前执行。 返回true:放行 返回false:不放行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("preHandle .... ");

return true; //true表示放行
}

//目标资源方法执行后执行
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("postHandle ... ");
}

//视图渲染完毕后执行,最后执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("afterCompletion .... ");
}
}

注意:

  • preHandle方法:目标资源方法执行前执行。 返回true:放行 返回false:不放行
  • postHandle方法:目标资源方法执行后执行
  • afterCompletion方法:视图渲染完毕后执行,最后执行

2). 注册配置拦截器

com.itheima下创建一个包,然后创建一个配置类 WebConfig, 实现 WebMvcConfigurer 接口,并重写 addInterceptors 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration  
public class WebConfig implements WebMvcConfigurer {

//自定义的拦截器对象
@Autowired
private DemoInterceptor demoInterceptor;


@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义拦截器对象
registry.addInterceptor(demoInterceptor).addPathPatterns("/**");//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
}
}

重新启动SpringBoot服务,打开Apifox测试:

img

可以看到控制台输出的日志:

img

接下来我们再来做一个测试:将拦截器中返回值改为false

使用Apifox,再次点击send发送请求后,没有响应数据,说明请求被拦截了没有放行

img

2.4.2牌校验Interceptor

通过拦截器来完成案例当中的登录校验功能。

登录校验的业务逻辑以及操作步骤我们前面已经分析过了,和登录校验Filter过滤器当中的逻辑是完全一致的。现在我们只需要把这个技术方案由原来的过滤器换成拦截器interceptor就可以了。

1). TokenInterceptor

com.itheima.interceptor 包下创建 TokenInterceptor

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
@Slf4j
@Component
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取请求url。
String url = request.getRequestURL().toString();

//2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if(url.contains("login")){ //登录请求
log.info("登录请求 , 直接放行");
return true;
}

//3. 获取请求头中的令牌(token)。
String jwt = request.getHeader("token");

//4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)。
if(!StringUtils.hasLength(jwt)){ //jwt为空
log.info("获取到jwt令牌为空, 返回错误结果");
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
return false;
}

//5. 解析token,如果解析失败,返回错误结果(未登录)。
try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {
e.printStackTrace();
log.info("解析令牌失败, 返回错误结果");
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
return false;
}

//6. 放行。
log.info("令牌合法, 放行");
return true;
}

}

2). 配置拦截器

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration  
public class WebConfig implements WebMvcConfigurer {
//拦截器对象
@Autowired
private TokenInterceptor tokenInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义拦截器对象
registry.addInterceptor(tokenInterceptor).addPathPatterns("/**");
}
}

登录校验的拦截器编写完成后,接下来我们就可以重新启动服务来做一个测试: (关闭登录校验Filter过滤器

2.4.3nterceptor详解

拦截器的使用细节我们主要介绍两个部分:

  1. 拦截器的拦截路径配置
  2. 拦截器的执行流程
  • 拦截路径

首先我们先来看拦截器的拦截路径的配置,在注册配置拦截器的时候,我们要指定拦截器的拦截路径,通过addPathPatterns("要拦截路径")方法,就可以指定要拦截哪些资源。

在入门程序中我们配置的是/**,表示拦截所有资源,而在配置拦截器时,不仅可以指定要拦截哪些资源,还可以指定不拦截哪些资源,只需要调用excludePathPatterns("不拦截路径")方法,指定哪些资源不需要拦截。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration  
public class WebConfig implements WebMvcConfigurer {

//拦截器对象
@Autowired
private DemoInterceptor demoInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义拦截器对象
registry.addInterceptor(demoInterceptor)
.addPathPatterns("/**")//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
.excludePathPatterns("/login");//设置不拦截的请求路径
}
}

在拦截器中除了可以设置/**拦截所有资源外,还有一些常见拦截路径设置:

拦截路径 含义 举例
/* 一级路径 能匹配/depts,/emps,/login,不能匹配 /depts/1
/** 任意级路径 能匹配/depts,/depts/1,/depts/1/2
/depts/* /depts下的一级路径 能匹配/depts/1,不能匹配/depts/1/2,/depts
/depts/** /depts下的任意级路径 能匹配/depts,/depts/1,/depts/1/2,不能匹配/emps/1
  • 执行流程

介绍完拦截路径的配置之后,接下来我们再来介绍拦截器的执行流程。通过执行流程,就能够清晰的知道过滤器与拦截器的执行时机。

img

  • 当我们打开浏览器来访问部署在web服务器当中的web应用时,此时我们所定义的过滤器会拦截到这次请求。拦截到这次请求之后,它会先执行放行前的逻辑,然后再执行放行操作。而由于我们当前是基于springboot开发的,所以放行之后是进入到了spring的环境当中,也就是要来访问我们所定义的controller当中的接口方法。
  • Tomcat并不识别所编写的Controller程序,但是它识别Servlet程序,所以在Spring的Web环境中提供了一个非常核心的Servlet:DispatcherServlet(前端控制器),所有请求都会先进行到DispatcherServlet,再将请求转给Controller。
  • 当我们定义了拦截器后,会在执行Controller的方法之前,请求被拦截器拦截住。执行preHandle()方法,这个方法执行完成后需要返回一个布尔类型的值,如果返回true,就表示放行本次操作,才会继续访问controller中的方法;如果返回false,则不会放行(controller中的方法也不会执行)。
  • 在controller当中的方法执行完毕之后,再回过来执行postHandle()这个方法以及afterCompletion() 方法,然后再返回给DispatcherServlet,最终再来执行过滤器当中放行后的这一部分逻辑的逻辑。执行完毕之后,最终给浏览器响应数据。

以上就是拦截器的执行流程。通过执行流程分析,大家应该已经清楚了过滤器和拦截器之间的区别,其实它们之间的区别主要是两点:

  • 接口规范不同:过滤器需要实现Filter接口,而拦截器需要实现HandlerInterceptor接口。
  • 拦截范围不同:过滤器Filter会拦截所有的资源,而Interceptor只会拦截Spring环境中的资源。

5.AOP(面向切面编程)

1.AOP基础

1.1AOP入门

什么是AOP?

AOP:Aspect Oriented Programming(面向切面编程、面向方面编程),其实说白了,面向切面编程就是面向特定方法编程。

那什么又是面向方法编程呢,为什么又需要面向方法编程呢?

来,我们举个例子做一个说明:

比如,我们这里有一个项目,项目中开发了很多的业务功能。然而有一些业务功能执行效率比较低,执行耗时较长,我们需要针对于这些业务方法进行优化。 那首先第一步就需要定位出执行耗时比较长的业务方法,再针对于业务方法再来进行优化。

此时我们就需要统计当前这个项目当中每一个业务方法的执行耗时。那么统计每一个业务方法的执行耗时该怎么实现?

可能多数人首先想到的就是在每一个业务方法运行之前,记录这个方法运行的开始时间。在这个方法运行完毕之后,再来记录这个方法运行的结束时间。拿结束时间减去开始时间,不就是这个方法的执行耗时吗。

而这个功能如果通过AOP来实现,我们只需要单独定义下面这一小段代码即可,不需要修改原始的任何业务方法即可记录每一个业务方法的执行耗时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@Aspect //当前类为切面类
@Slf4j
public class RecordTimeAspect {

@Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
//记录方法执行开始时间
long begin = System.currentTimeMillis();

//执行原始方法
Object result = pjp.proceed();

//记录方法执行结束时间
long end = System.currentTimeMillis();

//计算方法执行耗时
log.info("方法执行耗时: {}毫秒",end-begin);
return result;
}
}

常见的应用场景如下:

  • 记录系统的操作日志
  • 权限控制
  • 事务管理:我们前面所讲解的Spring事务管理,底层其实也是通过AOP来实现的,只要添加@Transactional注解之后,AOP程序自动会在原始方法运行前先来开启事务,在原始方法运行完毕之后提交或回滚事务

这些都是AOP应用的典型场景。

所以,AOP的优势主要体现在以下四个方面:

  • 减少重复代码:不需要在业务方法中定义大量的重复性的代码,只需要将重复性的代码抽取到AOP程序中即可。
  • 代码无侵入:在基于AOP实现这些业务功能时,对原有的业务代码是没有任何侵入的,不需要修改任何的业务代码。
  • 提高开发效率
  • 维护方便

1.2AOP核心概念

  • 连接点:JoinPoint

    ,可以被AOP控制的方法(暗含方法执行时的相关信息)

    • 连接点指的是可以被aop控制的方法。例如:入门程序当中所有的业务方法都是可以被aop控制的方法。
    • 在SpringAOP提供的JoinPoint当中,封装了连接点方法在执行时的相关信息。(后面会有具体的讲解)

img

  • 通知:Advice

    ,指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)

    • 在入门程序中是需要统计各个业务方法的执行耗时的,此时我们就需要在这些业务方法运行开始之前,先记录这个方法运行的开始时间,在每一个业务方法运行结束的时候,再来记录这个方法运行的结束时间。
    • 是在AOP面向切面编程当中,我们只需要将这部分重复的代码逻辑抽取出来单独定义。抽取出来的这一部分重复的逻辑,也就是共性的功能。

img

  • 切入点:PointCut

    ,匹配连接点的条件,通知仅会在切入点方法执行时被应用。

    • 在通知当中,我们所定义的共性功能到底要应用在哪些方法上?此时就涉及到了切入点pointcut概念。切入点指的是匹配连接点的条件。通知仅会在切入点方法运行时才会被应用。
    • 在aop的开发当中,我们通常会通过一个切入点表达式来描述切入点(后面会有详解)。
    • 假如:切入点表达式改为DeptServiceImpl.list(),此时就代表仅仅只有list这一个方法是切入点。只有list()方法在运行的时候才会应用通知。

img

  • 切面:Aspect,描述通知与切入点的对应关系(通知+切入点)

当通知和切入点结合在一起,就形成了一个切面。通过切面就能够描述当前aop程序需要针对于哪个原始方法,在什么时候执行什么样的操作。

img

而切面所在的类,称之为切面类(被@Aspect注解标识的类)。

  • 目标对象:Target,通知所应用的对象

目标对象指的就是通知所应用的对象,我们就称之为目标对象。

img

AOP的核心概念我们介绍完毕之后,接下来我们再来分析一下我们所定义的通知是如何与目标对象结合在一起,对目标对象当中的方法进行功能增强的。

img

Spring的AOP底层是基于动态代理技术来实现的,也就是说在程序运行的时候,会自动的基于动态代理技术为目标对象生成一个对应的代理对象。在代理对象当中就会对目标对象当中的原始方法进行功能的增强。

SpringAOP 旨在管理bean对象的过程中,主要通过底层的动态代理机制,对特定的方法进行编程 。

2.AOP进阶

2.1通知类型

在入门程序当中,我们已经使用了一种功能最为强大的通知类型:Around环绕通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
@Aspect //当前类为切面类
@Slf4j
public class TimeAspect {

@Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
//记录方法执行开始时间
long begin = System.currentTimeMillis();
//执行原始方法
Object result = pjp.proceed();
//记录方法执行结束时间
long end = System.currentTimeMillis();
//计算方法执行耗时
log.info("方法执行耗时: {}毫秒",end-begin);
return result;
}
}

只要我们在通知方法上加上了@Around注解,就代表当前通知是一个环绕通知。

Spring AOP 通知类型
@Around 环绕通知,此注解标注的通知方法在目标方法前、后都被执行
@Before 前置通知,此注解标注的通知方法在目标方法前被执行
@After 后置通知,此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行
@AfterReturning 返回后通知,此注解标注的通知方法在目标方法后被执行,有异常不会执行
@AfterThrowing 异常后通知,此注解标注的通知方法发生异常后执行

下面我们通过代码演示,来加深对于不同通知类型的理解:

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
@Slf4j
@Component
@Aspect
public class MyAspect1 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(JoinPoint joinPoint){
log.info("before ...");

}

//环绕通知
@Around("execution(* com.itheima.service.*.*(..))")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("around before ...");

//调用目标对象的原始方法执行
Object result = proceedingJoinPoint.proceed();

//原始方法如果执行时有异常,环绕通知中的后置代码不会在执行了

log.info("around after ...");
return result;
}

//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(JoinPoint joinPoint){
log.info("after ...");
}

//返回后通知(程序在正常执行的情况下,会执行的后置通知)
@AfterReturning("execution(* com.itheima.service.*.*(..))")
public void afterReturning(JoinPoint joinPoint){
log.info("afterReturning ...");
}

//异常通知(程序在出现异常的情况下,执行的后置通知)
@AfterThrowing("execution(* com.itheima.service.*.*(..))")
public void afterThrowing(JoinPoint joinPoint){
log.info("afterThrowing ...");
}
}

重新启动SpringBoot服务,进行测试:

1). 没有异常情况下:

使用 Apifox 测试查询所有部门数据

img

查看idea中控制台日志输出:

img

程序没有发生异常的情况下,@AfterThrowing标识的通知方法不会执行。

2). 出现异常情况下:

修改DeptServiceImpl业务实现类中的代码: 添加异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
@Autowired
private DeptMapper deptMapper;

@Override
public List<Dept> list() {

List<Dept> deptList = deptMapper.list();
//模拟异常
int num = 10/0;
return deptList;
}

//省略其他代码...
}

重新启动SpringBoot服务,测试发生异常情况下通知的执行:

img

查看idea中控制台日志输出

img

程序发生异常的情况下:

  • @AfterReturning标识的通知方法不会执行,@AfterThrowing标识的通知方法执行了
  • @Around环绕通知中原始方法调用时有异常,通知中的环绕后的代码逻辑也不会在执行了 (因为原始方法调用已经出异常了)

在使用通知时的注意事项:

  • @Around环绕通知需要自己调用 ProceedingJoinPoint.proceed() 来让原始方法执行,其他通知不需要考虑目标方法执行
  • @Around环绕通知方法的返回值,必须指定为Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的。

五种常见的通知类型,我们已经测试完毕了,此时我们再来看一下刚才所编写的代码,有什么问题吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")

//环绕通知
@Around("execution(* com.itheima.service.*.*(..))")

//后置通知
@After("execution(* com.itheima.service.*.*(..))")

//返回后通知(程序在正常执行的情况下,会执行的后置通知)
@AfterReturning("execution(* com.itheima.service.*.*(..))")

//异常通知(程序在出现异常的情况下,执行的后置通知)
@AfterThrowing("execution(* com.itheima.service.*.*(..))")

我们发现啊,每一个注解里面都指定了切入点表达式,而且这些切入点表达式都一模一样。此时我们的代码当中就存在了大量的重复性的切入点表达式,假如此时切入点表达式需要变动,就需要将所有的切入点表达式一个一个的来改动,就变得非常繁琐了。

怎么来解决这个切入点表达式重复的问题? 答案就是:抽取

Spring提供了@PointCut注解,该注解的作用是将公共的切入点表达式抽取出来,需要用到时引用该切入点表达式即可。

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
@Slf4j
@Component
@Aspect
public class MyAspect1 {

//切入点方法(公共的切入点表达式)
@Pointcut("execution(* com.itheima.service.*.*(..))")
private void pt(){}

//前置通知(引用切入点)
@Before("pt()")
public void before(JoinPoint joinPoint){
log.info("before ...");

}

//环绕通知
@Around("pt()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("around before ...");

//调用目标对象的原始方法执行
Object result = proceedingJoinPoint.proceed();
//原始方法在执行时:发生异常
//后续代码不在执行

log.info("around after ...");
return result;
}

//后置通知
@After("pt()")
public void after(JoinPoint joinPoint){
log.info("after ...");
}

//返回后通知(程序在正常执行的情况下,会执行的后置通知)
@AfterReturning("pt()")
public void afterReturning(JoinPoint joinPoint){
log.info("afterReturning ...");
}

//异常通知(程序在出现异常的情况下,执行的后置通知)
@AfterThrowing("pt()")
public void afterThrowing(JoinPoint joinPoint){
log.info("afterThrowing ...");
}
}

需要注意的是:当切入点方法使用private修饰时,仅能在当前切面类中引用该表达式, 当外部其他切面类中也要引用当前类中的切入点表达式,就需要把private改为public,而在引用的时候,具体的语法为:

1
2
3
4
5
6
7
8
9
10
@Slf4j
@Component
@Aspect
public class MyAspect2 {
//引用MyAspect1切面类中的切入点表达式
@Before("com.itheima.aspect.MyAspect1.pt()")
public void before(){
log.info("MyAspect2 -> before ...");
}
}

2.2通知顺序

当在项目中,定义了多个切面类,而多个切面类中多个切入点都匹配到了同一个目标方法。此时当目标方法在运行的时候,这多个切面类当中的这些通知方法都会运行。

这多个通知方法到底哪个先运行,哪个后运行? 下面我们通过程序来验证(这里呢,我们就定义两种类型的通知进行测试,一种是前置通知@Before,一种是后置通知@After

定义多个切面类:

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
@Slf4j
@Component
@Aspect
public class MyAspect2 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(){
log.info("MyAspect2 -> before ...");
}

//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(){
log.info("MyAspect2 -> after ...");
}
}
@Slf4j
@Component
@Aspect
public class MyAspect3 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(){
log.info("MyAspect3 -> before ...");
}

//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(){
log.info("MyAspect3 -> after ...");
}
}
@Slf4j
@Component
@Aspect
public class MyAspect4 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(){
log.info("MyAspect4 -> before ...");
}

//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(){
log.info("MyAspect4 -> after ...");
}
}

重新启动SpringBoot服务,测试通知的执行顺序:

备注:

  1. 把DeptServiceImpl实现类中模拟异常的代码删除或注释掉。
  2. 注释掉其他切面类(把@Aspect注释即可),仅保留MyAspect2、MyAspect3、MyAspect4 ,这样就可以清晰看到执行的结果,而不被其他切面类干扰。

使用 Apifox 测试查询所有部门数据。

img

查看idea中控制台日志输出

img

  • 通过以上程序运行可以看出在不同切面类中,默认按照切面类的类名字母排序:
    • 目标方法前的通知方法:字母排名靠前的先执行
    • 目标方法后的通知方法:字母排名靠前的后执行

如果我们想控制通知的执行顺序有两种方式:

  1. 修改切面类的类名(这种方式非常繁琐、而且不便管理)
  2. 使用Spring提供的@Order注解

使用@Order注解,控制通知的执行顺序:

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
@Slf4j
@Component
@Aspect
@Order(2) //切面类的执行顺序(前置通知:数字越小先执行; 后置通知:数字越小越后执行)
public class MyAspect2 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(){
log.info("MyAspect2 -> before ...");
}

//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(){
log.info("MyAspect2 -> after ...");
}
}
@Slf4j
@Component
@Aspect
@Order(3) //切面类的执行顺序(前置通知:数字越小先执行; 后置通知:数字越小越后执行)
public class MyAspect3 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(){
log.info("MyAspect3 -> before ...");
}

//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(){
log.info("MyAspect3 -> after ...");
}
}
@Slf4j
@Component
@Aspect
@Order(1) //切面类的执行顺序(前置通知:数字越小先执行; 后置通知:数字越小越后执行)
public class MyAspect4 {
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")
public void before(){
log.info("MyAspect4 -> before ...");
}

//后置通知
@After("execution(* com.itheima.service.*.*(..))")
public void after(){
log.info("MyAspect4 -> after ...");
}
}

重新启动SpringBoot服务,测试通知执行顺序:

img

通知的执行顺序大家主要知道两点即可:

  1. 不同的切面类当中,默认情况下通知的执行顺序是与切面类的类名字母排序是有关系的
  2. 可以在切面类上面加上@Order注解,来控制不同的切面类通知的执行顺序

2.3切点表达式

切入点表达式:描述切入点方法的一种表达式

  • 作用:主要用来决定项目中的哪些方法需要加入通知
  • 常见形式:
    • execution(……):根据方法的签名来匹配
    • @annotation(……) :根据注解匹配

img

img

2.3.1execution

execution主要根据方法的返回值、包名、类名、方法名、方法参数等信息来匹配,语法为:

1
execution(访问修饰符?  返回值包名.类名.?方法名(方法参数) throws 异常?)

其中带?的表示可以省略的部分

  • 访问修饰符:可省略(比如: public、protected)
  • 包名.类名: 可省略
  • throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)

示例:

1
@Before("execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))")

可以使用通配符描述切入点

  • * :单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分
  • .. :多个连续的任意符号,可以通配任意层级的包,或任意类型、任意个数的参数

切入点表达式的语法规则:

  • 方法的访问修饰符可以省略
  • 返回值可以使用*号代替(任意返回值类型)
  • 包名可以使用*号代替,代表任意包(一层包使用一个*
  • 使用..配置包名,标识此包以及此包下的所有子包
  • 类名可以使用*号代替,标识任意类
  • 方法名可以使用*号代替,表示任意方法
  • 可以使用 * 配置参数,一个任意类型的参数
  • 可以使用.. 配置参数,任意个任意类型的参数

切入点表达式示例

  • 省略方法的修饰符号
1
execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
  • 使用*代替返回值类型
1
execution(* com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
  • 使用*代替包名(一层包使用一个*
1
execution(* com.itheima.*.*.DeptServiceImpl.delete(java.lang.Integer))
  • 使用..省略包名
1
execution(* com..DeptServiceImpl.delete(java.lang.Integer))  
  • 使用*代替类名
1
execution(* com..*.delete(java.lang.Integer))
  • 使用*代替方法名
1
execution(* com..*.*(java.lang.Integer))
  • 使用 * 代替参数
1
execution(* com.itheima.service.impl.DeptServiceImpl.delete(*))
  • 使用..省略参数
1
execution(* com..*.*(..))

注意事项:

  • 根据业务需要,可以使用 且(&&)、或(||)、非(!) 来组合比较复杂的切入点表达式。
1
execution(* com.itheima.service.DeptService.list(..)) || execution(* com.itheima.service.DeptService.delete(..))

切入点表达式的书写建议:

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:查询类方法都是 find 开头,更新类方法都是update开头
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//业务类
@Service
public class DeptServiceImpl implements DeptService {

public List<Dept> findAllDept() {
//省略代码...
}

public Dept findDeptById(Integer id) {
//省略代码...
}

public void updateDeptById(Integer id) {
//省略代码...
}

public void updateDeptByMoreCondition(Dept dept) {
//省略代码...
}
//其他代码...
}
  • //匹配DeptServiceImpl类中以find开头的方法
1
execution(* com.itheima.service.impl.DeptServiceImpl.find*(..))
  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性
1
execution(* com.itheima.service.DeptService.*(..))
  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名匹配尽量不使用 …,使用 * 匹配单个包
1
execution(* com.itheima.*.*.DeptServiceImpl.find*(..))

切入点表达式书写建议:

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:findXxx,updateXxx。
  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性。
  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名尽量不使用…,使用 * 匹配单个包。

2.3.2@annotation

已经学习了execution切入点表达式的语法。那么如果我们要匹配多个无规则的方法,比如:list()和 delete()这两个方法。这个时候我们基于execution这种切入点表达式来描述就不是很方便了。而在之前我们是将两个切入点表达式组合在了一起完成的需求,这个是比较繁琐的。

我们可以借助于另一种切入点表达式 @annotation 来描述这一类的切入点,从而来简化切入点表达式的书写。

实现步骤:

  1. 编写自定义注解
  2. 在业务类要做为连接点的方法上添加自定义注解

自定义注解LogOperation

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation{
}

业务类DeptServiceImpl

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
@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
@Autowired
private DeptMapper deptMapper;

@Override
@LogOperation //自定义注解(表示:当前方法属于目标方法)
public List<Dept> list() {
List<Dept> deptList = deptMapper.list();
//模拟异常
//int num = 10/0;
return deptList;
}

@Override
@LogOperation //自定义注解(表示:当前方法属于目标方法)
public void delete(Integer id) {
//1. 删除部门
deptMapper.delete(id);
}


@Override
public void save(Dept dept) {
dept.setCreateTime(LocalDateTime.now());
dept.setUpdateTime(LocalDateTime.now());
deptMapper.save(dept);
}

@Override
public Dept getById(Integer id) {
return deptMapper.getById(id);
}

@Override
public void update(Dept dept) {
dept.setUpdateTime(LocalDateTime.now());
deptMapper.update(dept);
}
}

切面类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
@Component
@Aspect
public class MyAspect6 {
//针对list方法、delete方法进行前置通知和后置通知

//前置通知
@Before("@annotation(com.itheima.anno.LogOperation)")
public void before(){
log.info("MyAspect6 -> before ...");
}

//后置通知
@After("@annotation(com.itheima.anno.LogOperation)")
public void after(){
log.info("MyAspect6 -> after ...");
}
}

重启SpringBoot服务,测试查询所有部门数据,查看控制台日志:

img

到此两种常见的切入点表达式已经介绍完了。

  • execution切入点表达式
    • 根据我们所指定的方法的描述信息来匹配切入点方法,这种方式也是最为常用的一种方式
    • 如果我们要匹配的切入点方法的方法名不规则,或者有一些比较特殊的需求,通过execution切入点表达式描述比较繁琐
  • annotation 切入点表达式
    • 基于注解的方式来匹配切入点方法。这种方式虽然多一步操作,我们需要自定义一个注解,但是相对来比较灵活。我们需要匹配哪个方法,就在方法上加上对应的注解就可以了

根据业务需要,可以使用 && ,||,! 来组合比较复杂的切入点表达式。

3.AOP案例

3.1需求

需求:将案例(Tlias智能学习辅助系统)中增、删、改相关接口的操作日志记录到数据库表中

  • 就是当访问部门管理和员工管理当中的增、删、改相关功能接口时,需要详细的操作日志,并保存在数据表中,便于后期数据追踪。

操作日志信息包含:

  • 操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长

所记录的日志信息包括当前接口的操作人是谁操作的,什么时间点操作的,以及访问的是哪个类当中的哪个方法,在访问这个方法的时候传入进来的参数是什么,访问这个方法最终拿到的返回值是什么,以及整个接口方法的运行时长是多长时间。

3.2分析

  • 问题1:项目当中增删改相关的方法是不是有很多?
    • 很多
  • 问题2:我们需要针对每一个功能接口方法进行修改,在每一个功能接口当中都来记录这些操作日志吗?
    • 这种做法比较繁琐

以上两个问题的解决方案:可以使用AOP解决(每一个增删改功能接口中要实现的记录操作日志的逻辑代码是相同)。

可以把这部分记录操作日志的通用的、重复性的逻辑代码抽取出来定义在一个通知方法当中,我们通过AOP面向切面编程的方式,在不改动原始功能的基础上来对原始的功能进行增强。目前我们所增强的功能就是来记录操作日志,所以也可以使用AOP的技术来实现。使用AOP的技术来实现也是最为简单,最为方便的。

  • 问题3:既然要基于AOP面向切面编程的方式来完成的功能,那么我们要使用 AOP五种通知类型当中的哪种通知类型?
    • 答案:@Around【因为在获取方法执行时长时,需要在目标方法的前后都运行】
  • 问题4:最后一个问题,切入点表达式我们该怎么写?
    • 答案:@annotation【因为Controller层增、删、改执行方法的名称没有固定前缀/后缀,直接用方法名的话比较麻烦】

img

3.3步骤

简单分析了一下大概的实现思路后,接下来我们就要来完成案例了。案例的实现步骤其实就两步:

定义切面类,完成记录操作日志的逻辑

  • 准备工作
    • 引入AOP的起步依赖
    • 导入资料中准备好的数据库表结构,并引入对应的实体类
  • 编码实现(基于AI实现)
    • 自定义注解@LogOperation
    • 定义切面类,完成记录操作日志的逻辑

3.4代码实现

1). 准备工作

  • 在 pom.xml 中引入AOP的依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
  • 创建数据库表结构
1
2
3
4
5
6
7
8
9
10
11
-- 操作日志表
create table operate_log(
id int unsigned primary key auto_increment comment 'ID',
operate_emp_id int unsigned comment '操作人ID',
operate_time datetime comment '操作时间',
class_name varchar(100) comment '操作的类名',
method_name varchar(100) comment '操作的方法名',
method_params varchar(1000) comment '方法参数',
return_value varchar(2000) comment '返回值, 存储json格式',
cost_time int comment '方法执行耗时, 单位:ms'
) comment '操作日志表';
  • 引入资料中准备的实体类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.itheima.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {
private Integer id; //ID
private Integer operateEmpId; //操作人ID
private LocalDateTime operateTime; //操作时间
private String className; //操作类名
private String methodName; //操作方法名
private String methodParams; //操作方法参数
private String returnValue; //操作方法返回值
private Long costTime; //操作耗时
}
  • 引入资料中准备的日志操作Mapper接口 OperateLogMapper
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.itheima.mapper;

import com.itheima.pojo.OperateLog;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface OperateLogMapper {

//插入日志数据
@Insert("insert into operate_log (operate_emp_id, operate_time, class_name, method_name, method_params, return_value, cost_time) " +
"values (#{operateEmpId}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime});")
public void insert(OperateLog log);

}

1). 自定义注解 @LogOperation

1
2
3
4
5
6
7
/**
* 自定义注解,用于标识哪些方法需要记录日志
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation {
}

2). 定义AOP记录日志的切面类

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
import com.itheima.anno.LogOperation;
import com.itheima.mapper.OperateLogMapper;
import com.itheima.pojo.OperateLog;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Arrays;

@Aspect
@Component
public class OperationLogAspect {

@Autowired
private OperateLogMapper operateLogMapper;

// 环绕通知
@Around("@annotation(log)")
public Object around(ProceedingJoinPoint joinPoint, LogOperation log) throws Throwable {
// 记录开始时间
long startTime = System.currentTimeMillis();
// 执行方法
Object result = joinPoint.proceed();
// 当前时间
long endTime = System.currentTimeMillis();
// 耗时
long costTime = endTime - startTime;

// 构建日志对象
OperateLog operateLog = new OperateLog();
operateLog.setOperateEmpId(getCurrentUserId()); // 需要实现 getCurrentUserId 方法
operateLog.setOperateTime(LocalDateTime.now());
operateLog.setClassName(joinPoint.getTarget().getClass().getName());
operateLog.setMethodName(joinPoint.getSignature().getName());
operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));
operateLog.setReturnValue(result.toString());
operateLog.setCostTime(costTime);

// 插入日志
operateLogMapper.insert(operateLog);
return result;
}

// 示例方法,获取当前用户ID
private int getCurrentUserId() {
// 这里应该根据实际情况从认证信息中获取当前登录用户的ID
return 1; // 示例返回值
}
}

3). 在需要记录的日志的Controller层的方法上,加上注解 @LogOperation

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

@Autowired
private ClazzService clazzService;

/** * 新增班级 */@LogOperation
@PostMapping
public Result save(@RequestBody Clazz clazz){
clazzService.save(clazz);
return Result.success();
}
}

重启SpringBoot服务,测试操作日志记录功能:

打开浏览器,针对于员工的数据、部门的数据进行增删改之后。我们打开数据库表结构可以来看一下:

img

我们会看到,在数据库表中,就清晰的记录了谁、什么时间点、调用了哪个类的哪个方法、传入了什么参数、返回了什么数据,都清晰的记录在数据库中了。

3.5连接点

连接点可以简单理解为可以被AOP控制的方法。

我们目标对象当中所有的方法是不是都是可以被AOP控制的方法。而在SpringAOP当中,连接点又特指方法的执行。

在Spring中用JoinPoint抽象了连接点,用它可以获得方法执行时的相关信息,如目标类名、方法名、方法参数等。

  • 对于@Around通知,获取连接点信息只能使用ProceedingJoinPoint类型

img

  • 对于其他四种通知,获取连接点信息只能使用JoinPoint,它是ProceedingJoinPoint的父类型

img

3.6获取当前登录员工

  • 员工登录成功后,哪里存储的有当前登录员工的信息? 给客户端浏览器下发的jwt令牌中
  • 如何从JWT令牌中获取当前登录用户的信息呢? 获取请求头中传递的jwt令牌,并解析
  • TokenFilter 中已经解析了令牌的信息,如何传递给AOP程序、Controller、Service呢?ThreadLocal

3.6.1ThreadLocal

  • ThreadLocal并不是一个Thread,而是Thread的局部变量。
  • ThreadLocal为每个线程提供一份单独的存储空间,具有线程隔离的效果,不同的线程之间不会相互干扰。

img

  • 常见方法:
    • public void set(T value) 设置当前线程的线程局部变量的值
    • public T get() 返回当前线程所对应的线程局部变量的值
    • public void remove() 移除当前线程的线程局部变量

3.6.2记录当前登录员工

img

具体操作步骤:

  1. 定义ThreadLocal操作的工具类,用于操作当前登录员工ID。

com.itheima.utils 引入工具类 CurrentHolder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.itheima.utils;

public class CurrentHolder {

private static final ThreadLocal<Integer> CURRENT_LOCAL = new ThreadLocal<>();

public static void setCurrentId(Integer employeeId) {
CURRENT_LOCAL.set(employeeId);
}

public static Integer getCurrentId() {
return CURRENT_LOCAL.get();
}

public static void remove() {
CURRENT_LOCAL.remove();
}
}
  1. TokenFilter中,解析完当前登录员工ID,将其存入ThreadLocal(用完之后需将其删除)。
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
package com.itheima.filter;

import com.itheima.utils.CurrentHolder;
import com.itheima.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;

@Slf4j
@WebFilter(urlPatterns = "/*")
public class TokenFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;

//1. 获取请求的url地址
String uri = request.getRequestURI(); // /employee/login
//String url = request.getRequestURL().toString(); // http://localhost:8080/employee/login

//2. 判断是否是登录请求, 如果url地址中包含 login, 则说明是登录请求, 放行
if (uri.contains("login")) {
log.info("登录请求, 放行");
filterChain.doFilter(request, response);
return;
}

//3. 获取请求中的token
String token = request.getHeader("token");

//4. 判断token是否为空, 如果为空, 响应401状态码
if (token == null || token.isEmpty()) {
log.info("token为空, 响应401状态码");
response.setStatus(401); // 响应401状态码
return;
}

//5. 如果token不为空, 调用JWtUtils工具类的方法解析token, 如果解析失败, 响应401状态码
try {
Claims claims = JwtUtils.parseJWT(token);
Integer empId = Integer.valueOf(claims.get("id").toString());
CurrentHolder.setCurrentId(empId);log.info("token解析成功, 放行");
} catch (Exception e) {
log.info("token解析失败, 响应401状态码");
response.setStatus(401);
return;
}

//6. 放行
filterChain.doFilter(request, response);

//7. 清空当前线程绑定的id
CurrentHolder.remove();
}
}
  1. 在AOP程序中,从ThreadLocal中获取当前登录员工的ID。
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
package com.itheima.aop;

import com.itheima.anno.LogOperation;
import com.itheima.mapper.OperateLogMapper;
import com.itheima.pojo.OperateLog;
import com.itheima.utils.CurrentHolder;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Arrays;

@Aspect
@Component
public class OperationLogAspect {

@Autowired
private OperateLogMapper operateLogMapper;

// 环绕通知
@Around("@annotation(log)")
public Object around(ProceedingJoinPoint joinPoint, LogOperation log) throws Throwable {
// 记录开始时间
long startTime = System.currentTimeMillis();
// 执行方法
Object result = joinPoint.proceed();
// 当前时间
long endTime = System.currentTimeMillis();
// 耗时
long costTime = endTime - startTime;

// 构建日志对象
OperateLog operateLog = new OperateLog();
operateLog.setOperateEmpId(getCurrentUserId()); // 需要实现 getCurrentUserId 方法
operateLog.setOperateTime(LocalDateTime.now());
operateLog.setClassName(joinPoint.getTarget().getClass().getName());
operateLog.setMethodName(joinPoint.getSignature().getName());
operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));
operateLog.setReturnValue(result.toString());
operateLog.setCostTime(costTime);

// 插入日志
operateLogMapper.insert(operateLog);
return result;
}

// 示例方法,获取当前用户ID
private int getCurrentUserId() {
return CurrentHolder.getCurrentId();
}
}

代码优化完毕之后,我们重新启动服务测试。就可以看到,可以获取到不同的登录用户信息了。

img

在同一个线程/同一个请求中,进行数据共享就可以使用 ThreadLocal。

完结