4.项目实战 tlias教务系统
在整个实战篇中,我们需要完成如下功能:
部门管理:查询、新增、修改、删除
员工管理:
报表统计
登录认证
日志管理
班级管理(自己实战内容)
学员管理(自己实战内容)
–部门管理模块
1.部门管理
1.1基础知识
1.1.1前后端分离开发
我们将原先的工程分为前端工程和后端工程这2个工程,然后前端工程交给专业的前端人员开发,后端工程交给专业的后端人员开发。
前端页面需要数据,可以通过发送异步请求,从后台工程获取。但是,我们前后台是分开来开发的,那么前端人员怎么知道后台返回数据的格式呢?后端人员开发,怎么知道前端人员需要的数据格式呢?
所以针对这个问题,我们前后台统一制定一套规范!我们前后台开发人员都需要遵循这套规范开发,这就是我们的接口****文档 。
那么接口文档的内容怎么来的呢?是我们后台开发者根据产品经理提供的产品原型和需求文档所撰写出来的
那么基于前后台分离开发的模式下,我们后台开发者开发一个功能的具体流程如何呢?如下图所示:
需求分析:首先我们需要阅读需求文档,分析需求,理解需求。
接口定义:查询接口文档中关于需求的接口的定义,包括地址,参数,响应数据类型等等
前后台并行开发:各自按照接口文档进行开发,实现需求
测试:前后台开发完了,各自按照接口文档进行测试
前后段联调测试:前段工程请求后端工程,测试功能
1.1.2Restful风格
而在前后端进行交互的时候,我们需要基于当前主流的REST风格的API接口进行交互。
什么是REST风格呢?
REST(Representational State Transfer),表述性状态转换,它是一种软件架构风格。
传统URL风格如下:
我们看到,原始的传统URL呢,定义比较复杂,而且将资源的访问行为对外暴露出来了。而且,对于开发人员来说,每一个开发人员都有自己的命名习惯,就拿根据id查询用户信息来说的,不同的开发人员定义的路径可能是这样的:getById,selectById,queryById,loadById… 。 每一个人都有自己的命名习惯,如果都按照各自的习惯来,一个项目组,几十号或上百号人,那最终开发出来的项目,将会变得难以维护,没有一个统一的标准。
基于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 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
实体类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; 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需求
查询所有的部门数据,查询出来展示在部门管理的页面中。页面原型效果如下:
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进行测试
我们发现,已经查询出了所有的部门数据,并且响应回来的就是json格式的数据,与接口文档一致。 那接下来,我们再来测试一下,这个查询操作,我们使用post、put、delete方式来请求,是否可以获取到数据。
经过测试,我们发现,现在我们其实是可以通过任何方式的请求来访问查询部门的这个接口的。 而在接口文档中,明确要求该接口的请求方式为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两个字段值并未成功封装,而数据库中是有对应的字段值的,这是为什么呢?
原因如下:
实体类属性名和数据库表查询返回的字段名一致,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服务器的?
其实这里,是通过前端服务Nginx中提供的反向代理功能实现的。
1). 浏览器发起请求,请求的是localhost:90 ,那其实请求的是nginx服务器。
2). 在nginx服务器中呢,并没有对请求直接进行处理,而是将请求转发给了后端的tomcat服务器,最终由tomcat服务器来处理该请求。
这个过程就是通过nginx的反向代理实现的。
问:那为什么浏览器不直接请求后端的tomcat服务器,而是直接请求nginx服务器呢,主要有以下几点原因:
1). 安全:由于后端的tomcat服务器一般都会搭建集群,会有很多的服务器,把所有的tomcat暴露给前端,让前端直接请求tomcat,对于后端服务器是比较危险的。
2). 灵活:基于nginx的反向代理实现,更加灵活,后端想增加、减少服务器,对于前端来说是无感知的,只需要在nginx中配置即可。
3). 负载均衡:基于nginx的反向代理,可以很方便的实现后端tomcat的负载均衡操作。
具体的请求访问流程如下:
location:用于定义匹配特定uri请求的规则。
^~ /api/:表示精确匹配,即只匹配以/api/开头的路径。
rewrite:该指令用于重写匹配到的uri路径。
proxy_pass:该指令用于代理转发,它将匹配到的请求转发给位于后端的指令服务器。
3.删除部门
3.1基本实现
3.1.1需求
删除部门数据。在点击 “删除” 按钮,会根据ID删除部门数据。
3.1.2实现思路
3.1.3简单参数接收
在controller中,需要接收前端传递的请求参数。 那接下来,就先来看看在服务器端的Controller程序中,如何获取这类简单参数。 具体的方案有如下三种:
方案一:通过原始的 HttpServletRequest 对象获取请求参数
1 2 3 4 5 6 7 8 9 10 11 @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 void deleteById (Integer id) ;
在 DeptServiceImpl 中,增加 deleteById 方法,代码实现如下:
1 2 3 public void deleteById(Integer id ) { deptMapper.deleteById(id ); }
3). Mapper层
在 DeptMapper 中,增加 deleteById 方法,代码实现如下:
1 2 3 @Delete ("delete from dept where id = #{id}" )void deleteById (Integer id);
对于 DML 语句来说,执行完毕,也是有返回值的,返回值代表的是增删改操作,影响的记录数,所以可以将执行 DML 语句的方法返回值设置为 Integer。 但是一般开发时,是不需要这个返回值的,所以也可以设置为void。
4.新增部门
4.1基本实现
4.1.1需求
点击 “新增部门” 的按钮之后,弹出新增部门表单,填写部门名称之后,点击确定之后,保存部门数据。
4.1.2实现思路
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 @PostMapping ("/depts" )public Result save (@RequestBody Dept dept){ System .out .println ("新增部门, dept=" + dept); deptService .save (dept); return Result .success (); }
2). Service层
在DeptService中增加接口方法save,具体代码如下:
在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查询部门数据,然后用于页面回显展示。
5.1.2实现思路
5.1.3路径参数接收
/depts/1,/depts/2 这种在url中传递的参数,我们称之为路径参数 。 那么如何接收这样的路径参数呢 ?
路径参数:通过请求URL直接传递参数,使用{…}来标识该路径参数,需要使用 **@PathVariable**获取路径参数。如下所示:
如果路径参数名与controller方法形参名称一致,@PathVariable注解的value属性是可以省略的
5.1.4代码实现
1). Controller层
在 DeptController 中增加 getById方法,具体代码如下:
1 2 3 4 5 6 7 @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 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修改部门的数据。
5.2.2实现思路
通过接口文档,我们可以看到前端传递的请求参数是json格式的请求参数,在Controller的方法中,我们可以通过 @RequestBody 注解来接收,并将其封装到一个对象中。
5.2.3代码实现
1). Controller层
在 DeptController 中增加 update 方法,具体代码如下:
1 2 3 4 5 6 7 @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 路径。 代码如下:
一个完整的请求路径,应该是类上的 @RequestMapping 的value属性 + 方法上的 @RequestMapping的value属性。
6.日志技术(Logback)
6.1日志框架
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" > <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("结束计算..." ); } }
运行单元测试,可以在控制台中看到输出的日志,如下所示:
我们可以看到在输出的日志信息中,不仅输出了日志的信息,还包括:日志的输出时间、线程名、具体在那个类中输出的。
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" > <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" > <FileNamePattern > D:/tlias-%d {yyyy-MM-dd} -%i.log</FileNamePattern > <MaxHistory > 30</MaxHistory > <maxFileSize > 10MB</maxFileSize > </rollingPolicy > <encoder class ="ch.qos.logback.classic.encoder.PatternLayoutEncoder" > <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); } @DeleteMapping public Result delete (Integer id){ //System .out .println("根据id删除部门, id=" + id); log .info ("根据id删除部门, id: {}" , id); deptService.deleteById(id); return Result.success(); } @PostMapping public Result save(@RequestBody Dept dept){ //System .out .println("新增部门, dept=" + dept); log .info ("新增部门, dept: {}" , dept); deptService.save(dept); return Result.success(); } @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); } @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防止报错
–员工管理模块
从页面原型中,我们可以看到,在查询员工信息的时候,除了要展示 姓名、性别、头像、职位、入职日期、最后操作时间这些员工信息外,还要展示出所属部门,那此时就需要从两张表中查询数据,一张是部门表,一张是员工表,此时就会涉及到多表操作。(具体见mysql内容,这里不花时间阐述)
1.员工列表查询
那接下来,我们要来完成的是员工列表的查询功能实现。 具体的需求如下:
在查询员工列表数据时,既需要查询 员工的基本信息,还需要查询员工所属的部门名称,所以这里,会涉及到多表查询的操作。
而且,在查询员工列表数据时,既要考虑搜索栏中的查询条件,还要考虑对查询的结果进行分页处理。
那么接下来,我们在实现这个功能时,将会分为三个部分来逐一实现:
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 , '1330909000 1', 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 , '1330909000 2', 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 , '1330909000 3', 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 , '1330909000 4', 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 , '1330909000 5', 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 , '1330909000 6', 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 , '1330909000 7', 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 , '1330909000 8', 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 , '1330909000 9', 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 , '1330909001 0', 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 , '1330909001 1', 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 , '1330909001 2', 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 , '1330909001 3', 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 , '1330909001 4', 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 , '1330909001 5', 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 , '1330909001 6', 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 , '1330909001 7', 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 , '1330909001 8', 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 , '1330909001 9', 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 , '1330909002 0', 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 , '1330909002 1', 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 , '1330909002 2', 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 , '1330909002 3', 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 , '1330909002 4', 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 , '1330909002 5', 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 , '1330909002 6', 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 , '1330909002 7', 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 , '1330909002 8', 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 , '1330909003 0', 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 , '1880909121 2', 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; private String username; private String password; private String name; private Integer gender; private String phone; private Integer job; private Integer salary; private String image; private LocalDate entryDate; private Integer deptId; 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; private Integer empId; 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; private Integer empId; 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; private String username; private String password; private String name; private Integer gender; private String phone; private Integer job; private Integer salary; private String image; private LocalDate entryDate; private Integer deptId; private LocalDateTime createTime; private LocalDateTime updateTime; private String deptName; }
代码编写完毕后,我们可以编写一个单元测试,对上述的程序进行测试:
1.2分页查询
每次只展示一页的数据,比如:一页展示10条数据,如果还想看其他的数据,可以通过点击页码进行查询。
而在员工管理的需求中,就要求我们进行分页查询,展示出对应的数据。 具体的页面原型如下:
要想从数据库中进行分页查询,我们要使用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) \* 每页显示条数
我们继续基于页面原型,继续分析,得出以下结论:
前端在请求服务端时,传递的参数
当前页码 page
每页显示条数 pageSize
后端需要响应什么数据给前端
所查询到的数据列表(存储到List 集合中)
总记录数
后台给前端返回的数据包含: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语句:
查询总记录数
指定页码的数据列表
在Service当中,调用Mapper接口的两个方法,分别获取:总记录数、查询结果列表,然后在将获取的数据结果封装到PageBean对象中。
如果在未来开发其他项目,只要涉及到分页查询功能(例:订单、用户、支付、商品),都必须按照以上操作完成功能开发
结论:原始方式的分页查询,存在着"步骤固定"、"代码频繁"的问题
解决方案:可以使用一些现成的分页插件完成。对于Mybatis来讲现在最主流的就是PageHelper。
1.3PageHelper分页插件
1.3.1介绍
PageHelper是第三方提供的Mybatis框架中的一款功能强大、方便易用的分页插件,支持任何形式的单标、多表的分页查询。
官网:https://pagehelper.github.io/
那接下来,我们可以对比一下,使用PageHelper分页插件进行分页 与 原始方式进行分页代码实现的上的差别。
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 <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) { PageHelper .startPage(page,pageSize); List <Emp > empList = empMapper.list(); Page <Emp > p = (Page <Emp >) empList; return new PageResult (p.getTotal(), p.getResult()); }
1.3.3pageHelper的实现机制
我们打开Idea的控制台,可以看到在进行分页查询时,输出的SQL语句。
我们看到执行了两条SQL语句,而这两条SQL语句,其实是从我们在Mapper接口中定义的SQL演变而来的。
其实就是将我们编写的SQL语句进行的改造增强,将查询返回的字段列表替换成了 count(0) 来统计总记录数。
第二条SQL语句,用来进行分页查询,查询指定页码对应 的数据列表。
其实就是将我们编写的SQL语句进行的改造增强,在SQL语句之后拼接上了limit进行分页查询,而由于测试时查询的是第一页,起始索引是0,所以简写为limit ?。
而PageHelper在进行分页查询时,会执行上述两条SQL语句,并将查询到的总记录数,与数据列表封装到了 Page<Emp> 对象中,我们再获取查询结果时,只需要调用Page对象的方法就可以获取。
注意:
PageHelper实现分页查询时,SQL语句的结尾一定一定一定不要加分号(;).,容易出现拼接问题
PageHelper只会对紧跟在其后的第一条SQL语句进行分页处理。
1.4条件分页查询
完了分页查询后,下面我们需要在分页查询的基础上,添加条件。
1.4.1需求
通过员工管理的页面原型我们可以看到,员工列表页面的查询,不仅仅需要考虑分页,还需要考虑查询条件。 分页查询我们已经实现了,接下来,我们需要考虑在分页查询的基础上,再加上查询条件。
我们看到页面原型及需求中描述,搜索栏的搜索条件有三个,分别是:
姓名:模糊匹配
性别:精确匹配
入职日期:范围匹配
1.4.2实现思路
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) { PageHelper.startPage(page, pageSize); List<Emp> empList = empMapper.list(name, gender, begin, end); 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 <!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 @ Servicepublic class EmpServiceImpl implements EmpService { @ Autowired private EmpMapper empMapper; /*@ Override public PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin , LocalDate end ) { PageHelper.startPage(page, pageSize); List< Emp> empList = empMapper.list(name, gender, begin , end ); Page< Emp> p = (Page< Emp> ) empList; return new PageResult(p.getTotal(), p.getResult()); }*/ public PageResult page(EmpQueryParam empQueryParam) { PageHelper.startPage(empQueryParam.getPage(), empQueryParam.getPageSize()); List< Emp> empList = empMapper.list(empQueryParam); 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语句中,查询条件是写死的。 而我们在员工管理中,根据条件查询员工信息时,查询条件是可选的,可以输入也可以不输入。
如果只输入 姓名 这个查询条件,则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.idwhere 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 <!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需求
在新增员工的时候,在表单中,我们既要录入员工的基本信息,又要录入员工的工作经历信息。 员工基本信息,对应的表结构是 emp表,员工工作经历信息,对应的表结构是 emp_expr 表,所以这里我们要操作两张表,往两张表中保存数据。
2.2实现思路
接口文档规定:
请求路径:/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; private String username; private String password; private String name; private Integer gender; private String phone; private Integer job; private Integer salary; private String image; private LocalDate entryDate; private Integer deptId; private LocalDateTime createTime; private LocalDateTime updateTime; private String deptName; }
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 方法
在 EmpServiceImpl 中增加save方法 , 实现接口中的save方法
1 2 3 4 5 6 7 8 9 10 11 12 @Override public void save(Emp emp) { emp.setCreateTime (LocalDateTime.now()); emp.setUpdateTime (LocalDateTime.now()); empMapper.insert (emp); }
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.分析
一个员工,是可以有多段工作经历的,所以在页面上将来用户录入员工信息时,可以自己根据需要添加多段工作经历。页面原型展示如下:
那如果员工只有一段工作经历,我们就需要往工作经历表中保存一条记录。 执行的SQL如下:
如果员工有两段工作经历,我们就需要往工作经历表中保存两条记录。执行的SQL如下:
如果员工有三段工作经历,我们就需要往工作经历表中保存三条记录。执行的SQL如下:
所以,这里最终我们需要执行的是批量插入数据的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) { emp.setCreateTime(LocalDateTime.now ()); emp.setUpdateTime(LocalDateTime.now ()); empMapper.insert (emp); 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> 标签,改标签的作用,是用来遍历循环,常见的属性说明:
collection:集合名称
item:集合遍历出来的元素/项
separator:每一次遍历使用的分隔符
open:遍历开始前拼接的片段
close:遍历结束后拼接的片段
上述的属性,是可选的,并不是所有的都是必须的。 可以自己根据实际需求,来指定对应的属性。
Apifox请求完毕后,可以打开idea的控制台看到控制台输出的日志:
3.事务管理
3.1问题分析
目前我们实现的新增员工功能中,操作了两次数据库,执行了两次 insert 操作。
第一次:保存员工的基本信息到 emp 表中。
第二次:保存员工的工作经历信息到 emp_expr 表中。
如果说,保存员工的基本信息成功了,而保存员工的工作经历信息出错了,会发生什么现象呢?那接下来,我们来做一个测试 。 我们可以在代码中,人为在保存员工的service层的save方法中,构造一个错误:
那接下来,我们就重启服务,打开浏览器,来做一个测试:
点击 “保存” 之后,提示 “系统接口异常”。我们可以打开IDEA控制台看一下,报出的错误信息。 我们看到,保存了员工的基本信息之后,系统出现了异常。
我们再打开数据库,看看表结构中的数据是否正常。
1). emp 员工表中是有 shaseng 这条数据的。
2). emp_expr 表中没有该员工的工作经历信息。
最终,我们看到,程序出现了异常 ,员工表 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 ;insert into emp values (39 , 'Tom' , '123456' , '汤姆' , 1 , '13300001111' , 1 , 4000 , '1.jpg' , '2023-11-01' , 1 , now(), now());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分析
在上述实现的新增员工的功能中,一旦在保存员工基本信息后出现异常。 我们就会发现,员工信息保存成功,但是工作经历信息保存失败,造成了数据的不完整不一致。
产生原因:
先执行新增员工的操作,这步执行完毕,就已经往员工表 emp 插入了数据。
执行 1/0 操作,抛出异常
抛出异常之前,下面所有的代码都不会执行了,批量保存工作经历信息,这个操作也不会执行 。
此时就出现问题了,员工基本信息保存了,员工的工作经历信息未保存,业务操作前后数据不一致。
而要想保证操作前后,数据的一致性,就需要让新增员工中涉及到的两个业务操作,要么全部成功,要么全部失败 。 那我们如何,让这两个操作要么全部成功,要么全部失败呢 ?
那就可以通过事务来实现,因为一个事务中的多个业务操作,要么全部成功,要么全部失败。
此时,我们就需要在新增员工功能中添加事务。
在方法运行之前,开启事务,如果方法成功执行,就提交事务,如果方法执行的过程当中出现异常了,就回滚事务。
**思考:**开发中所有的业务操作,一旦我们要进行控制事务,是不是都是这样的套路?
**答案:**是的。
所以在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) { emp.setCreateTime(LocalDateTime.now()); emp.setUpdateTime(LocalDateTime.now()); empMapper.insert(emp); int i = 1 /0 ; 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 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) { emp.setCreateTime(LocalDateTime.now()); emp.setUpdateTime(LocalDateTime.now()); empMapper.insert(emp); int i = 1 /0 ; 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) { emp.setCreateTime(LocalDateTime.now()); emp.setUpdateTime(LocalDateTime.now()); empMapper.insert(emp); if (true ){ throw new Exception ("出现异常了~~~" ); } 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进行测试,请求添加员工的接口:
通过Apifox返回的结果,我们看到抛出异常了。然后我们在回到IDEA的控制台来看一下。
我们看到数据库的事务居然提交了,并没有进行回滚。
通过以上测试可以得出一个结论:默认情况下,只有出现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 { emp.setCreateTime(LocalDateTime.now()); emp.setUpdateTime(LocalDateTime.now()); empMapper.insert(emp); if (true ){ throw new Exception ("出异常啦...." ); } Integer empId = emp.getId(); List<EmpExpr> exprList = emp.getExprList(); if (!CollectionUtils.isEmpty(exprList)){ exprList.forEach(empExpr -> empExpr.setEmpId(empId)); empExprMapper.insertBatch(exprList); } }
接下来我们重新启动服务,测试新增员工的操作:
控制台日志,可以看到因为出现了异常又进行了事务回滚。
结论:
在Spring的事务管理中,默认只有运行时异常 RuntimeException才会回滚。
如果还需要回滚指定类型的异常,可以通过rollbackFor属性来指定。
2.propagation
propagation,这个属性是用来配置事务的传播行为的。
什么是事务的传播行为呢?
就是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。
例如:两个事务方法,一个A方法,一个B方法。在这两个方法上都添加了@Transactional注解,就代表这两个方法都具有事务,而在A方法当中又去调用了B方法。
所谓事务的传播行为,指的就是在A方法运行的时候,首先会开启一个事务,在A方法当中又调用了B方法, B方法自身也具有事务,那么B方法在运行的时候,到底是加入到A方法的事务当中来,还是B方法在运行的时候新建一个事务?这个就涉及到了事务的传播行为。
我们要想控制事务的传播行为,在@Transactional注解的后面指定一个属性propagation,通过 propagation 属性来指定传播行为。接下来我们就来介绍一下常见的事务传播行为。
属性值
含义
REQUIRED
【默认值】需要事务,有则加入,无则创建新事务
REQUIRES_NEW
需要新事务,无论有无,总是创建新事务
SUPPORTS
支持事务,有则加入,无则在无事务状态中运行
NOT_SUPPORTED
不支持事务,在无事务状态下运行,如果当前存在已有事务,则挂起当前事务
MANDATORY
必须有事务,否则抛异常
NEVER
必须没事务,否则抛异常
…
接下来我们就通过一个案例来演示下事务传播行为propagation属性的使用。
**需求:**在新增员工信息时,无论是成功还是失败,都要记录操作日志。
步骤:
准备日志表 emp_log、实体类EmpLog、Mapper接口EmpLogMapper
在新增员工时记录日志
准备工作:
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 ; 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 { emp.setCreateTime(LocalDateTime.now()); emp.setUpdateTime(LocalDateTime.now()); empMapper.insert(emp); int i = 1 /0 ; 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服务,测试新增员工操作 。我们可以看到控制台中输出的日志:
从日志中我们可以看到:
执行了插入员工数据的操作
执行了插入日志操作
程序发生Exception异常
执行事务回滚(保存员工数据、插入操作日志 因为在一个事务范围内,两个操作都会被回滚)
然后在 emp_log 表中没有记录日志数据 。
原因分析:
接下来我们就需要来分析一下具体是什么原因导致的日志没有成功的记录。
在执行 save 方法时开启了一个事务
当执行 empLogService.insertLog 操作时,insertLog设置的事务传播行是默认值REQUIRED,表示有事务就加入,没有则新建事务
此时:save 和 insertLog 操作使用了同一个事务,同一个事务中的多个操作,要么同时成功,要么同时失败,所以当异常发生时进行事务回滚,就会回滚 save 和 insertLog 操作
解决方案:
在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服务,再次测试 新增员工的操作 ,会看到具体的日志如下:
那此时,EmpServiceImpl 中的 save 方法运行时,会开启一个事务。 当调用 empLogService.insertLog(empLog) 时,也会创建一个新的事务,那此时,当 insertLog 方法运行完毕之后,事务就已经提交了。 即使外部的事务出现异常,内部已经提交的事务,也不会回滚了,因为是两个独立的事务。
到此事务传播行为已演示完成,事务的传播行为我们只需要掌握两个:REQUIRED、REQUIRES_NEW。
**REQUIRED:**大部分情况下都是用该传播行为即可。
**REQUIRES_NEW:**当我们不希望事务之间相互影响时,可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够记录成功。
3.4.4事务四大特性
事务有哪些特性?
原子性(Atomicity):事务是不可分割的最小单元,要么全部成功,要么全部失败。
一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。
事务的四大特性简称为:ACID
持久性(Durability) :一个事务一旦被提交或回滚,它对数据库的改变将是永久性的,哪怕数据库发生异常,重启之后数据亦然存在。
原子性(Atomicity) :原子性是指事务包装的一组sql是一个不可分割的工作单元,事务中的操作要么全部成功,要么全部失败。
一致性(Consistency) :一个事务完成之后数据都必须处于一致性状态。
如果事务成功的完成,那么数据库的所有变化将生效。
如果事务执行出现错误,那么数据库的所有变化将会被回滚(撤销),返回到原始状态。
隔离性(Isolation) :多个用户并发的访问数据库时,一个用户的事务不能被其他用户的事务干扰,多个并发的事务之间要相互隔离。
一个事务的成功或者失败对于其他的事务是没有影响。
4.文件上传
4.1简介
文件上传,是指将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。
文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。
上传文件的原始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 { @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
Spring中提供了一个API:MultipartFile,使用这个API就可以来接收到上传的文件
问题1:如果表单项的名字和方法中形参名不一致,该怎么办?
1 2 3 public Result upload (String username, Integer age, MultipartFile image)
解决:使用@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/" ; @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方法形参名保持一致。
通过 Apifox 测试,我们发现文件上传是没有问题的。
在解决了文件名唯一性的问题后,我们再次上传一个较大的文件(超出1M)时发现,后端程序报错:
报错原因呢,是因为:在SpringBoot中,文件上传时默认单个文件最大大小为1M
那么如果需要上传大文件,可以在 application.properties 进行如下配置:
1 2 3 4 5 spring : servlet: multipart: max -file -size : 10 MB max -request-size : 100 MB
但如果直接存储在服务器的磁盘目录中,存在以下缺点:
不安全:磁盘如果损坏,所有的文件就会丢失
容量有限:如果存储大量的图片,磁盘空间有限(磁盘不可能无限制扩容)
无法直接访问
3.3阿里云OSS(对象存储)
3.3.1介绍
**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 { public static String generateUniqueBucketName (String prefix) { String timestamp = String.valueOf(System.currentTimeMillis()); Random random = new Random (); int randomNum = random.nextInt(10000 ); return prefix + "-" + timestamp + "-" + randomNum; } public static void main (String[] args) throws com.aliyuncs.exceptions.ClientException { String endpoint = "https://oss-cn-beijing.aliyuncs.com" ; String bucketName = "java-geqian" ; String region = "cn-beijing" ; String localFilePath = "D:/编程/001.png" ; String objectName = "exampledir/001.png" ; EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider(); OSS ossClient = OSSClientBuilder.create() .endpoint(endpoint) .credentialsProvider(credentialsProvider) .region(region) .build(); try { ossClient.createBucket(bucketName); System.out.println("1. Bucket " + bucketName + " 创建成功。" ); 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 + " 不存在!" ); } 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(); System.out.println("4. 列出 Bucket 中的文件:" ); ObjectListing objectListing = ossClient.listObjects(bucketName); for (OSSObjectSummary objectSummary : objectListing.getObjectSummaries()) { System.out.println(" - " + objectSummary.getKey() + " (大小 = " + objectSummary.getSize() + ")" ); } } 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对象存储服务,来存储和管理案例中上传的图片。
在新增员工的时候,上传员工的图像,而之所以需要上传员工的图像,是因为将来我们需要在系统页面当中访问并展示员工的图像。而要想完成这个操作,需要做两件事:
需要上传员工的图像,并把图像保存起来(存储到阿里云OSS)
访问员工图像(通过图像在阿里云OSS的存储地址访问图像)
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 { EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider(); String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM" )); String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("." )); String objectName = dir + "/" + newFileName; 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 测试:
接口测试通过之后,我们就可以进行前后端联调了。
3.3.4功能优化
在刚才我们制作的AliyunOSS操作的工具类中,我们直接将 endpoint、bucketName参数直接在java文件中写死了。如下所示:
如果后续,项目要部署到测试环境、上生产环境,我们需要来修改这两个参数。 而如果开发一个大型项目,所有用到的技术涉及到的这些个参数全部写死在java代码中,是非常不便于维护和管理的。
那么对于这些容易变动的参数,我们可以将其配置在配置文件中 ,然后通过 @Value 注解来注解外部配置的属性。如下所示:
方式一:@Value注入(缺点:如果配置项多,注入繁琐,不便于维护管理和复用。)
方式二:自动注入 在Spring中给我们提供了一种简化方式,可以直接将配置文件中配置项的值自动的注入到对象的属性中。
Spring提供的简化方式套路:
1). 需要创建一个实现类,且实体类中的属性名和配置文件当中key的名字必须要一致
比如:配置文件当中叫endpoint,实体类当中的属性也得叫endpoint,另外实体类当中的属性还需要提供 getter / setter方法
2). 需要将实体类交给Spring的IOC容器管理,成为IOC容器当中的bean对象
3). 在实体类上添加**@ConfigurationProperties**注解,并通过perfect属性来指定配置参数项的前缀
具体实现步骤:
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 { @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(); EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider(); String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM" )); String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("." )); String objectName = dir + "/" + newFileName; 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.yml或application.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需求
当我们勾选列表前面的复选框,然后点击 “批量删除” 按钮,就可以将这一批次的员工信息删除掉了。也可以只勾选一个复选框,仅删除一个员工信息。
问题:我们需要开发两个功能接口吗?一个删除单个员工,一个删除多个员工
答案:不需要。 只需要开发一个功能接口即可(删除多个员工包含只删除一个员工)
5.2实现思路
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) { empMapper .deleteByIds (ids); 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 void deleteByEmpIds (List<Integer> empIds) ;
4). 在 EmpExprMapper.xml 配置文件中, 配置对应的SQL语句
1 2 3 4 5 6 7 <delete id ="deleteByEmpIds" > delete from emp_expr where emp_id in <foreach collection ="empIds" item ="empId" open ="(" close =")" separator ="," > # {empId} </foreach > </delete >
6.修改员工
在进行修改员工信息的时候,我们首先先要根据员工的ID查询员工的详细信息用于页面回显展示,然后用户修改员工数据之后,点击保存按钮,就可以将修改的数据提交到服务端,保存到数据库。 具体操作为:
根据ID查询员工信息
保存修改的员工信息
6.1查询回显
6.1.1需求
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
6.1.2实现思路
在查询回显时,既需要查询出员工的基本信息,又需要查询出该员工的工作经历信息。
我们可以先通过一条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 ;
具体的实现思路如下:
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 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 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 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" /> <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 > <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需求
查询回显之后,就可以在页面上修改员工的信息了。
当用户修改完数据之后,点击保存按钮,就需要将数据提交到服务端,然后服务端需要将修改后的数据更新到数据库中 。
而此次更新的时候,既需要更新员工的基本信息; 又需要更新员工的工作经历信息 。
基本信息
请求路径:/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实现思路
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 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) { emp.setUpdateTime(LocalDateTime.now()); empMapper.updateById(emp); empExprMapper.deleteByEmpIds(Arrays.asList(emp.getId())); 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 <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介绍
当我们没有做任何的异常处理时,我们三层架构处理异常的方案:
Mapper接口在操作数据库的时候出错了,此时异常会往上抛(谁调用Mapper就抛给谁),会抛给service。
service 中也存在异常了,会抛给controller。
而在controller当中,我们也没有做任何的异常处理,所以最终异常会再往上抛。最终抛给框架之后,框架就会返回一个JSON格式的数据,里面封装的就是错误的信息,但是框架返回的JSON格式的数据并不符合我们的开发规范。
7.2解决方案
那么在三层构架项目中,出现了异常,该如何处理?
方案一:在所有Controller的所有方法中进行try…catch处理
缺点:代码臃肿(不推荐)
好处:简单、优雅(推荐)
全局异常处理器
我们该怎么样定义全局异常处理器?
定义全局异常处理器非常简单,就是定义一个类,在类上加上一个注解@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(); return Result.error ("对不起,操作失败,请联系管理员" ) ; } }
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
处理异常的方法返回值会转换为json后再响应给前端
重新启动SpringBoot服务,打开浏览器,再来测试一下 修改员工 这个操作,我们依然设置已存在的 13309090027这个手机号:
此时,我们可以看到,出现异常之后,异常已经被全局异常处理器捕获了。然后返回的错误信息,被前端程序正常解析,然后提示出了对应的错误提示信息。
以上就是全局异常处理器的使用,主要涉及到两个注解:
@RestControllerAdvice //表示当前类为全局异常处理器
@ExceptionHandler //指定可以捕获哪种类型的异常进行处理
8.员工信息统计
8.1介绍
对于这些图形报表的开发,其实呢,都是基于现成的一些图形报表的组件开发的,比如:Echarts、HighCharts等。
而报表的制作,主要是前端人员开发,引入对应的组件(比如:ECharts)即可。 服务端开发人员仅为其提供数据即可。
官网:https://echarts.apache.org/zh/index.html
8.2职位统计
8.2.1需求
对于这类的图形报表,服务端要做的,就是为其提供数据即可。 我们可以通过官方的示例,看到提供的数据其实就是X轴展示的信息,和对应的数据。
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 { 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进行测试。
联调测试
8.3性别统计
8.3.1需求
对于这类的图形报表,服务端要做的,就是为其提供数据即可。 我们可以通过官方的示例,看到提供的数据就是一个json格式的数据。
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测试
联调测试
–班级管理
数据准备
在数据库中,创建学生表 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 , '1880000000 1', '11012000030020 0001 ', 1 , '北京市昌平区建材城西路1号', 1 , '2021-07-01 ', 2 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-15 16:20:59') , ( 2 , '萧峰', '2022000002 ', 1 , '1880021000 3', '11012000030020 0002 ', 1 , '北京市昌平区建材城西路2号', 2 , '2022-07-01 ', 1 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 3 , '虚竹', '2022000003 ', 1 , '1880001300 1', '11012000030020 0003 ', 1 , '北京市昌平区建材城西路3号', 2 , '2024-07-01 ', 1 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 4 , '萧远山', '2022000004 ', 1 , '1880000321 1', '11012000030020 0004 ', 1 , '北京市昌平区建材城西路4号', 3 , '2024-07-01 ', 1 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 5 , '阿朱', '2022000005 ', 2 , '1880016000 2', '11012000030020 0005 ', 1 , '北京市昌平区建材城西路5号', 4 , '2020-07-01 ', 1 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 6 , '阿紫', '2022000006 ', 2 , '1880000003 4', '11012000030020 0006 ', 1 , '北京市昌平区建材城西路6号', 4 , '2021-07-01 ', 2 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 7 , '游坦之', '2022000007 ', 1 , '1880000006 7', '11012000030020 0007 ', 1 , '北京市昌平区建材城西路7号', 4 , '2022-07-01 ', 2 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 8 , '康敏', '2022000008 ', 2 , '1880000007 7', '11012000030020 0008 ', 1 , '北京市昌平区建材城西路8号', 5 , '2024-07-01 ', 2 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 9 , '徐长老', '2022000009 ', 1 , '1880000034 1', '11012000030020 0009 ', 1 , '北京市昌平区建材城西路9号', 3 , '2024-07-01 ', 2 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 10 , '云中鹤', '2022000010 ', 1 , '1880000657 1', '11012000030020 0010 ', 1 , '北京市昌平区建材城西路10号', 2 , '2020-07-01 ', 2 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 11 , '钟万仇', '2022000011 ', 1 , '1880000039 1', '11012000030020 0011 ', 1 , '北京市昌平区建材城西路11号', 4 , '2021-07-01 ', 1 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-15 16:21:24') , ( 12 , '崔百泉', '2022000012 ', 1 , '1880000078 1', '11012000030020 0018 ', 1 , '北京市昌平区建材城西路12号', 4 , '2022-07-05 ', 3 , 6 , 17 , '2024-11-14 21:22:19', '2024-12-13 14:33:58') , ( 13 , '耶律洪基', '2022000013 ', 1 , '1880000890 1', '11012000030020 0013 ', 1 , '北京市昌平区建材城西路13号', 4 , '2024-07-01 ', 2 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-15 16:21:21') , ( 14 , '天山童姥', '2022000014 ', 2 , '1880000920 1', '11012000030020 0014 ', 1 , '北京市昌平区建材城西路14号', 4 , '2024-07-01 ', 1 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-15 16:21:17') , ( 15 , '刘竹庄', '2022000015 ', 1 , '1880000940 1', '11012000030020 0015 ', 1 , '北京市昌平区建材城西路15号', 3 , '2020-07-01 ', 4 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 16 , '李春来', '2022000016 ', 1 , '1880000850 1', '11012000030020 0016 ', 1 , '北京市昌平区建材城西路16号', 4 , '2021-07-01 ', 4 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 17 , '王语嫣', '2022000017 ', 2 , '1880000760 1', '11012000030020 0017 ', 1 , '北京市昌平区建材城西路17号', 2 , '2022-07-01 ', 4 , 0 , 0 , '2024-11-14 21:22:19', '2024-11-14 21:22:19') , ( 18 , '郑成功', '2024001101 ', 1 , '1330909234 5', '11011011011011 0110 ', 0 , '北京市昌平区回龙观街道88号', 5 , '2021-07-01 ', 3 , 2 , 7 , '2024-11-15 16:26:18', '2024-11-15 16:40:10') ;
表结构关系说明:
实体类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 ; 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 ; private String name ; private String no ; private Integer gender ; private String phone ; private String idCard ; private Integer isCollege ; private String address ; private Integer degree ; private LocalDate graduationDate ; private Integer clazzId ; private Short violationCount ; private Short violationScore ; private LocalDateTime createTime ; private LocalDateTime updateTime ; private String clazzName ; }
1.条件分页查询
1.1需求
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 > now() then '未开班' when now() > 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需求
那其实,对于培训机构来说,班主任就是这个企业的员工。所以,班主任下拉列表中展示的就是所有的员工数据。
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 中
在 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需求
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 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需求
4.2代码实现
1)在 ClazzController 中
1 2 3 4 5 6 7 8 @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 @Select ("select * from clazz where id = #{id}" )Clazz getInfo (Integer id);
5.修改班级信息
5.1需求
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 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需求
注意:在页面原型中,要求如果该班级下关联的有学生,是不允许删除的,并提示错误信息:“对不起, 该班级下有学生, 不能直接删除”。 (提示:可以通过自定义异常 + 全局异常处理器实现)
6.2代码实现
1)在 ClazzController 中
1 2 3 4 5 6 7 8 @DeleteMapping ("/{id}" )public Result delete (@PathVariable Integer id){ clazzService .deleteById (id); return Result .success (); }
2).ClazzService & ClazzServiceImpl
在 ClazzService 中
1 2 3 4 5 void deleteById (Integer id) ;
在 ClazzServiceImpl 中
1 2 3 4 5 6 7 8 9 @Override public void deleteById (Integer id) { Integer count = studentMapper.countByClazzId(id); if (count > 0 ){ throw new BusinessException ("班级下有学员, 不能直接删除~" ); } clazzMapper.deleteById(id);
3)ClazzMapper 中
1 2 3 4 5 @Delete ("delete from clazz where id = #{id}" )void deleteById (Integer id);
–学员管理
1.查询所有班级
1.1需求
在新增学员的时候,要展示出所有的班级信息。
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 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需求
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需求
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需求
4.2代码实现
1)在 StudentController 中
1 2 3 4 5 6 7 8 @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 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 @Select ("select * from student where id = #{id}" )Student getById (Integer id);
5.修改学生信息
5.1需求
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需求
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需求
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.学员信息统计
8.1班级人数统计接口开发
8.1.1需求
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需求
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 @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 void deleteById (Integer id) ;
在 DeptServiceImpl 中
1 2 3 4 5 6 7 8 9 10 11 @Override public void deleteById (Integer id) { Integer count = empMapper.countByDeptId(id); if (count > 0 ){ throw new BusinessException ("部门下有员工, 不能删除" ); } deptMapper.deleteById(id); }
3) DeptMapper 中
1 2 3 4 5 @Delete ("delete from dept where id = #{id}" )void deleteById (Integer id);
–登录认证
1.登录功能
1.1需求
在登录界面中,我们可以输入用户的用户名以及密码,然后点击 “登录” 按钮就要请求服务器,服务端判断用户输入的用户名或者密码是否正确。如果正确,则返回成功结果,前端跳转至系统首页面。
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 ; 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.登录效验
什么是登录校验?
所谓登录校验,指的是我们在服务器端接收到浏览器发送过来的请求之后,首先我们要对请求进行校验。先要校验一下用户登录了没有,如果用户已经登录了,就直接执行对应的业务操作就可以了;如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。
该怎么来实现登录校验的操作呢?具体的实现思路可以分为两部分:
在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记。
在浏览器发起请求时,需要在服务端进行统一拦截,拦截后进行登录校验。
想要判断员工是否已经登录,我们需要在员工登录成功之后,存储一个登录成功的标记,接下来在每一个接口方法执行之前,先做一个条件判断,判断一下这个员工到底登录了没有。如果是登录了,就可以执行正常的业务操作,如果没有登录,会直接给前端返回一个错误的信息,前端拿到这个错误信息之后会自动的跳转到登录页面。
我们程序中所开发的查询功能、删除功能、添加功能、修改功能,都需要使用以上套路进行登录校验。为了简化这块操作,我们可以使用一种技术:统一拦截技术。
通过统一拦截的技术,我们可以来拦截浏览器发送过来的所有的请求,拦截到这个请求之后,就可以通过请求来获取之前所存入的登录标记,在获取到登录标记且标记为登录成功,就说明员工已经登录了。如果已经登录,我们就直接放行(意思就是可以访问正常的业务接口了)。
我们要完成以上操作,会涉及到web开发中的两个技术:
会话技术:用户登录成功之后,在后续的每一次请求中,都可以获取到该标记。
统一拦截技术:过滤器Filter、拦截器Interceptor
2.1会话技术
2.1.1基础知识
什么是会话?
在web开发当中,会话指的就是浏览器与服务器之间的一次连接,我们就称为一次会话。
举例:在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。在一次会话当中,是可以包含多次请求和响应的。
比如:打开了浏览器来访问web服务器上的资源(浏览器不能关闭、服务器不能断开)
第1次:访问的是登录的接口,完成登录操作
第2次:访问的是部门管理接口,查询所有部门数据
第3次:访问的是员工管理接口,查询员工数据
只要浏览器和服务器都没有关闭,以上3次请求都属于一次会话 当中完成的。
需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。
会话跟踪:一种维护浏览器状态的方法,服务器 需要识别多次请求是否来自于同一浏览器 ,以便在同一次会话的多次请求间共享数据。
我们使用会话跟踪技术就是要完成在同一个会话中,多个请求之间进行共享数据。
为什么要共享数据呢?
由于HTTP是无状态协议 ,在后面请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享
2.1.2三种会话追踪方案
会话跟踪技术有三种:
Cookie(客户端会话跟踪技术):数据存储在客户端浏览器当中
Session(服务端会话跟踪技术):数据存储在储在服务端
令牌技术
2.1.3方案一 :Cookie
cookie 是客户端会话跟踪技术,它是存储在客户端浏览器的 ,我们使用 cookie 来跟踪会话,我们就可以在浏览器第一次发起请求来请求服务器的时候,我们在服务器端来设置一个cookie。
比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个cookie,在 cookie 当中我们就可以来存储用户相关的一些数据信息。比如我可以在 cookie 当中来存储当前登录用户的用户名,用户的ID。
服务器会 自动 的将 cookie 响应给浏览器。
浏览器接收到响应回来的数据之后,会 自动 的将 cookie 存储在浏览器本地。
在后续的请求当中,浏览器会 自动 的将 cookie 携带到服务器端。
为什么这一切都是自动化进行的?
是因为 cookie 它是 HTTP 协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。在 HTTP 协议官方给我们提供了一个响应头和请求头:
响应头 Set-Cookie :设置Cookie数据的
请求头 Cookie:携带Cookie数据的
代码测试:
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 { @GetMapping("/c1" ) public Result cookie1(HttpServletResponse response){ response.addCookie(new Cookie("login_username" ,"itheima" )); return Result.success(); } @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()); } } return Result.success(); } }
A. 访问c1接口,设置Cookie,http://localhost:8080/c1
我们可以看到,设置的cookie,通过响应头Set-Cookie 响应给浏览器,并且浏览器会将Cookie,存储在浏览器端。
B. 访问c2接口 http://localhost:8080/c2,此时浏览器会自动的将Cookie携带到服务端,是通过请求头Cookie ,携带的。
优缺点:
优点:HTTP协议中支持的技术(像Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的)
缺点:
移动端APP(Android、IOS)中无法使用Cookie
不安全,用户可以自己禁用Cookie
Cookie不能跨域
2.1.4关于跨域
跨域介绍:
现在的项目,大部分都是前后端分离的,前后端最终也会分开部署,前端部署在服务器 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无法跨域
区分跨域的维度(三个维度有任何一个维度不同,那就是跨域操作):
举例:
2.1.5方案二Session
它是服务器端会话跟踪技术,所以它是存储在服务器端的 。
第一次请求
浏览器向服务器发起请求。
服务器检查当前请求是否带有 Session ID(Cookie),发现没有。
此时服务器自动创建一个新的 Session 对象(如图中标注为 Session(1)),并给它分配一个唯一 ID。
响应中下发 Cookie
服务器在 HTTP 响应头中加上: Set-Cookie: JSESSIONID=<session-id>
浏览器收到后,会把这个 JSESSIONID 存到本地。
浏览器自动带上 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
请求完成之后,在响应头中,就会看到有一个Set-Cookie的响应头,里面响应回来了一个Cookie,就是JSESSIONID,这个就是服务端会话对象 Session 的ID。
B. 访问 s2 接口,http://localhost:8080/s2
接下来,在后续的每次请求时,都会将Cookie的值,携带到服务端,那服务端呢,接收到Cookie之后,会自动的根据JSESSIONID的值,找到对应的会话对象Session。
那经过这两步测试,大家也会看到,在控制台中输出如下日志:
两次请求,获取到的Session会话对象的hashcode是一样的,就说明是同一个会话对象。而且,第一次请求时,往Session会话对象中存储的值,第二次请求时,也获取到了。 那这样,我们就可以通过Session会话对象,在同一个会话的多次请求之间来进行数据共享了。
优缺点
优点:Session是存储在服务端的,安全
缺点:
服务器集群环境下无法直接使用Session
移动端APP(Android、IOS)中无法使用Cookie
用户可以自己禁用Cookie
Cookie不能跨域
PS:Session 底层是基于Cookie实现的会话跟踪,如果Cookie不可用,则该方案,也就失效了。
2.1.6服务器集群环境为何无法使用Session?
在多机部署(集群)下直接用 Session 会碰到这样的问题:
集群部署
为了避免单点故障,项目通常部署多份(例如 3 台 Tomcat),并在前端加一台负载均衡服务器来分发请求。
Session 本地存储
每台 Tomcat 都维护自己的一份 Session 存储,Session ID(JSESSIONID)和会话数据只保存在产生它的那台机器上。
请求不稳定路由
浏览器第一次登录,经负载均衡到达 A 服务器,A 服务器创建 Session(1),并下发 JSESSIONID=1 给浏览器。
浏览器第二次请求(带 JSESSIONID=1)又被路由到 B 服务器,B 服务器在自己的存储里找不到 ID=1 的 Session,就无法识别用户状态。
结果
同一个浏览器连续请求,却因为被分到不同节点,拿不到同一个 Session,导致会话跟踪失效。
核心结论 :
传统的服务器内存型 Session 只能在单机上使用;
在集群环境下,必须引入“会话共享”或“粘性会话”等机制,才能保证每次请求都能取到同一个 Session 对象。
2.1.7方案三:令牌技术
这里提到的令牌,其实它就是一个用户身份的标识,本质就是一个字符串。
如果通过令牌技术来跟踪会话,我们就可以在浏览器发起请求。在请求登录接口的时候,如果登录成功,我就可以生成一个令牌,令牌就是用户的合法身份凭证。接下来我在响应数据的时候,我就可以直接将令牌响应给前端。
接下来我们在前端程序当中接收到令牌之后,就需要将这个令牌存储起来。这个存储可以存储在 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令牌当中任何一个部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。
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 <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位置,此时就会自动的将令牌解析出来。
第一部分解析出来,看到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登录时下发令牌
生成令牌
在登录成功之后来生成一个JWT令牌,并且把这个令牌直接返回给前端
校验令牌
拦截前端请求,从请求中获取到令牌,对令牌进行解析校验
实现步骤:
引入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; 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; } public static Claims parseJWT (String jwt ){ Claims claims = Jwts .parser () .setSigningKey (signKey) .parseClaimsJws (jwt) .getBody (); return claims; } }
完善 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 ){ 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令牌。
那怎么样来统一拦截到所有的请求校验令牌的有效性呢?这里我们会学习两种解决方案:
2.3.1Filter快速入门
什么是Filter?
Filter表示过滤器,是 JavaWeb三大组件(Servlet、Filter、Listener)之一。
过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能
使用了过滤器之后,要想访问web服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源。
过滤器一般完成一些通用的操作,比如:登录校验、统一编码处理、敏感字符处理等。
下面我们通过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 { 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("拦截到了请求..." ); } 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 { 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("拦截到了请求..." ); } public void destroy () { System.out.println("destroy ... " ); } }
当我们在Filter类上面加了@WebFilter注解之后,接下来我们还需要在启动类上面加上一个注解@ServletComponentScan,通过这个@ServletComponentScan注解来开启SpringBoot项目对于Servlet组件的支持。
1 2 3 4 5 6 7 @ServletComponentScan @SpringBootApplication public class TliasManagementApplication { public static void main (String[] args) { SpringApplication .run (TliasManagementApplication.class, args); } }
重新启动服务,打开浏览器,执行部门管理的请求,可以看到控制台输出了过滤器中的内容:
**注意事项:**在过滤器Filter中,如果不执行放行操作,将无法访问后面的资源。 放行操作:chain.doFilter(request, response);
2.3.2登录校验过滤器
我们先来回顾下前面分析过的登录校验的基本流程:
要进入到后台管理系统,我们必须先完成登录操作,此时就需要访问登录接口login。
登录成功之后,我们会在服务端生成一个JWT令牌,并且把JWT令牌返回给前端,前端会将JWT令牌存储下来。
在后续的每一次请求当中,都会将JWT令牌携带到服务端,请求到达服务端之后,要想去访问对应的业务功能,此时我们必须先要校验令牌的有效性。
对于校验令牌的这一块操作,我们使用登录校验的过滤器,在过滤器当中来校验令牌的有效性。如果令牌是无效的,就响应一个错误的信息,也不会再去放行访问对应的资源了。如果令牌存在,并且它是有效的,此时就会放行去访问对应的web资源,执行相应的业务操作。
所有的请求,拦截到了之后,都需要校验令牌吗 ?
拦截到请求后,什么情况下才可以放行,执行业务操作 ?
答案:有令牌,且令牌校验通过(合法);否则都返回未登录错误结果
具体流程
基于上面的业务流程,我们分析出具体的操作步骤:
获取请求url
判断请求url中是否包含login,如果包含,说明是登录操作,放行
获取请求头中的令牌(token)
判断令牌是否存在,如果不存在,响应 401
解析token,如果解析失败,响应 401
放行
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; String url = request.getRequestURL().toString(); if (url.contains("login" )){ log.info("登录请求 , 直接放行" ); chain.doFilter(request, response); return ; } String jwt = request.getHeader("token" ); if (!StringUtils.hasLength(jwt)){ log.info("获取到jwt令牌为空, 返回错误结果" ); response.setStatus(HttpStatus.SC_UNAUTHORIZED); return ; } try { JwtUtils.parseJWT(jwt); } catch (Exception e) { e.printStackTrace(); log.info("解析令牌失败, 返回错误结果" ); response.setStatus(HttpStatus.SC_UNAUTHORIZED); return ; } log.info("令牌合法, 放行" ); chain.doFilter(request , response); } }
2.3.4Filter详解
Filter过滤器的快速入门程序我们已经完成了,接下来我们就要详细的介绍一下过滤器Filter在使用中的一些细节。主要介绍以下3个方面的细节:
过滤器的执行流程
过滤器的拦截路径配置
过滤器链
首先我们先来看下过滤器的执行流程:
过滤器当中我们拦截到了请求之后,如果希望继续访问后面的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 销毁方法执行了" ); } }
启动之后运行测试:
执行流程我们搞清楚之后,接下来再来介绍一下过滤器的拦截路径,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" ) 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应用程序当中,可以配置多个过滤器,多个过滤器就形成了一个过滤器链。
比如:在我们web服务器当中,定义了两个过滤器,这两个过滤器就形成了一个过滤器链。
而这个链上的过滤器在执行的时候会一个一个的执行,会先执行第一个Filter,放行之后再来执行第二个Filter,如果执行到了最后一个过滤器放行之后,才会访问对应的web资源。
访问完web资源之后,按照我们刚才所介绍的过滤器的执行流程,还会回到过滤器当中来执行过滤器放行后的逻辑,而在执行放行后的逻辑的时候,顺序是反着的。
先要执行过滤器2放行之后的逻辑,再来执行过滤器1放行之后的逻辑,最后在给浏览器响应数据。
过滤器链上过滤器的执行顺序:注解配置的Filter,优先级是按照过滤器类名(字符串)的自然排序。 比如:
这两个过滤器来说,AbcFilter 会先执行,DemoFilter会后执行。
2.4拦截器Interceptor
2.4.1快速入门
什么是拦截器?
是一种动态拦截方法调用的机制,类似于过滤器。
拦截器是Spring框架中提供的,用来动态拦截控制器方法的执行。
拦截器的作用:拦截请求,在指定方法调用前后,根据业务需要执行预先设定的代码。
在拦截器当中,我们通常也是做一些通用性的操作,比如:我们可以通过拦截器来拦截前端发起的请求,将登录校验的逻辑全部编写在拦截器当中。在校验的过程当中,如发现用户登录了(携带JWT令牌且是合法令牌),就可以直接放行,去访问spring当中的资源。如果校验时发现并没有登录或是非法令牌,就可以直接给前端响应未登录的错误信息。
下面我们通过快速入门程序,来学习下拦截器的基本使用。拦截器的使用步骤和过滤器类似,也分为两步:
定义拦截器
注册配置拦截器
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 { @Override public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler ) throws Exception { System.out.println("preHandle .... " ); return 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测试:
可以看到控制台输出的日志:
接下来我们再来做一个测试:将拦截器中返回值改为false
使用Apifox,再次点击send发送请求后,没有响应数据,说明请求被拦截了没有放行
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 { String url = request.getRequestURL().toString(); if (url.contains("login" )){ log.info("登录请求 , 直接放行" ); return true ; } String jwt = request.getHeader("token" ); if (!StringUtils.hasLength(jwt)){ log.info("获取到jwt令牌为空, 返回错误结果" ); response.setStatus(HttpStatus.SC_UNAUTHORIZED); return false ; } try { JwtUtils.parseJWT(jwt); } catch (Exception e) { e.printStackTrace(); log.info("解析令牌失败, 返回错误结果" ); response.setStatus(HttpStatus.SC_UNAUTHORIZED); return false ; } 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详解
拦截器的使用细节我们主要介绍两个部分:
拦截器的拦截路径配置
拦截器的执行流程
首先我们先来看拦截器的拦截路径的配置,在注册配置拦截器的时候,我们要指定拦截器的拦截路径,通过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
介绍完拦截路径的配置之后,接下来我们再来介绍拦截器的执行流程。通过执行流程,就能够清晰的知道过滤器与拦截器的执行时机。
当我们打开浏览器来访问部署在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核心概念
切面:Aspect ,描述通知与切入点的对应关系(通知+切入点)
当通知和切入点结合在一起,就形成了一个切面。通过切面就能够描述当前aop程序需要针对于哪个原始方法,在什么时候执行什么样的操作。
而切面所在的类,称之为切面类(被@Aspect注解标识的类)。
目标对象指的就是通知所应用的对象,我们就称之为目标对象。
AOP的核心概念我们介绍完毕之后,接下来我们再来分析一下我们所定义的通知是如何与目标对象结合在一起,对目标对象当中的方法进行功能增强的。
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 测试查询所有部门数据
查看idea中控制台日志输出:
程序没有发生异常的情况下,@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服务,测试发生异常情况下通知的执行:
查看idea中控制台日志输出
程序发生异常的情况下:
@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 { @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服务,测试通知的执行顺序:
备注:
把DeptServiceImpl实现类中模拟异常的代码删除或注释掉。
注释掉其他切面类(把@Aspect注释即可),仅保留MyAspect2、MyAspect3、MyAspect4 ,这样就可以清晰看到执行的结果,而不被其他切面类干扰。
使用 Apifox 测试查询所有部门数据。
查看idea中控制台日志输出
通过以上程序运行可以看出在不同切面类中,默认按照切面类的类名字母排序:
目标方法前的通知方法:字母排名靠前的先执行
目标方法后的通知方法:字母排名靠前的后执行
如果我们想控制通知的执行顺序有两种方式:
修改切面类的类名(这种方式非常繁琐、而且不便管理)
使用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服务,测试通知执行顺序:
通知的执行顺序大家主要知道两点即可:
不同的切面类当中,默认情况下通知的执行顺序是与切面类的类名字母排序是有关系的
可以在切面类上面加上@Order注解,来控制不同的切面类通知的执行顺序
2.3切点表达式
切入点表达式:描述切入点方法的一种表达式
作用:主要用来决定项目中的哪些方法需要加入通知
常见形式:
execution(……):根据方法的签名来匹配
@annotation(……) :根据注解匹配
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..*.*(..))
注意事项:
根据业务需要,可以使用 且(&&)、或(||)、非(!) 来组合比较复杂的切入点表达式。
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 来描述这一类的切入点,从而来简化切入点表达式的书写。
实现步骤:
编写自定义注解
在业务类要做为连接点的方法上添加自定义注解
自定义注解 :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 (); return deptList; } @Override @LogOperation public void delete (Integer id ) { 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 { @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服务,测试查询所有部门数据,查看控制台日志:
到此两种常见的切入点表达式已经介绍完了。
execution切入点表达式
根据我们所指定的方法的描述信息来匹配切入点方法,这种方式也是最为常用的一种方式
如果我们要匹配的切入点方法的方法名不规则,或者有一些比较特殊的需求,通过execution切入点表达式描述比较繁琐
annotation 切入点表达式
基于注解的方式来匹配切入点方法。这种方式虽然多一步操作,我们需要自定义一个注解,但是相对来比较灵活。我们需要匹配哪个方法,就在方法上加上对应的注解就可以了
根据业务需要,可以使用 && ,||,! 来组合比较复杂的切入点表达式。
3.AOP案例
3.1需求
需求:将案例(Tlias智能学习辅助系统)中增、删、改相关接口的操作日志记录到数据库表中
就是当访问部门管理和员工管理当中的增、删、改相关功能接口时,需要详细的操作日志,并保存在数据表中,便于后期数据追踪。
操作日志信息包含:
操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长
所记录的日志信息包括当前接口的操作人是谁操作的,什么时间点操作的,以及访问的是哪个类当中的哪个方法,在访问这个方法的时候传入进来的参数是什么,访问这个方法最终拿到的返回值是什么,以及整个接口方法的运行时长是多长时间。
3.2分析
问题1:项目当中增删改相关的方法是不是有很多?
问题2:我们需要针对每一个功能接口方法进行修改,在每一个功能接口当中都来记录这些操作日志吗?
以上两个问题的解决方案:可以使用AOP解决(每一个增删改功能接口中要实现的记录操作日志的逻辑代码是相同)。
可以把这部分记录操作日志的通用的、重复性的逻辑代码抽取出来定义在一个通知方法当中,我们通过AOP面向切面编程的方式,在不改动原始功能的基础上来对原始的功能进行增强。目前我们所增强的功能就是来记录操作日志,所以也可以使用AOP的技术来实现。使用AOP的技术来实现也是最为简单,最为方便的。
问题3:既然要基于AOP面向切面编程的方式来完成的功能,那么我们要使用 AOP五种通知类型当中的哪种通知类型?
答案:@Around【因为在获取方法执行时长时,需要在目标方法的前后都运行】
问题4:最后一个问题,切入点表达式我们该怎么写?
答案:@annotation【因为Controller层增、删、改执行方法的名称没有固定前缀/后缀,直接用方法名的话比较麻烦】
3.3步骤
简单分析了一下大概的实现思路后,接下来我们就要来完成案例了。案例的实现步骤其实就两步:
定义切面类,完成记录操作日志的逻辑
准备工作
引入AOP的起步依赖
导入资料中准备好的数据库表结构,并引入对应的实体类
编码实现(基于AI实现)
自定义注解@LogOperation
定义切面类,完成记录操作日志的逻辑
3.4代码实现
1). 准备工作
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; private Integer operateEmpId; 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()); 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; } private int getCurrentUserId () { 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服务,测试操作日志记录功能:
打开浏览器,针对于员工的数据、部门的数据进行增删改之后。我们打开数据库表结构可以来看一下:
我们会看到,在数据库表中,就清晰的记录了谁、什么时间点、调用了哪个类的哪个方法、传入了什么参数、返回了什么数据,都清晰的记录在数据库中了。
3.5连接点
连接点可以简单理解为可以被AOP控制的方法。
我们目标对象当中所有的方法是不是都是可以被AOP控制的方法。而在SpringAOP当中,连接点又特指方法的执行。
在Spring中用JoinPoint抽象了连接点,用它可以获得方法执行时的相关信息,如目标类名、方法名、方法参数等。
对于@Around通知,获取连接点信息只能使用ProceedingJoinPoint类型
对于其他四种通知,获取连接点信息只能使用JoinPoint,它是ProceedingJoinPoint的父类型
3.6获取当前登录员工
员工登录成功后,哪里存储的有当前登录员工的信息? 给客户端浏览器下发的jwt令牌中
如何从JWT令牌中获取当前登录用户的信息呢? 获取请求头中传递的jwt令牌,并解析
TokenFilter 中已经解析了令牌的信息,如何传递给AOP程序、Controller、Service呢?ThreadLocal
3.6.1ThreadLocal
ThreadLocal并不是一个Thread,而是Thread的局部变量。
ThreadLocal为每个线程提供一份单独的存储空间,具有线程隔离的效果,不同的线程之间不会相互干扰。
常见方法:
public void set(T value) 设置当前线程的线程局部变量的值
public T get() 返回当前线程所对应的线程局部变量的值
public void remove() 移除当前线程的线程局部变量
3.6.2记录当前登录员工
具体操作步骤:
定义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 (); } }
在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; String uri = request.getRequestURI(); if (uri.contains("login" )) { log.info("登录请求, 放行" ); filterChain.doFilter(request, response); return ; } String token = request.getHeader("token" ); if (token == null || token.isEmpty()) { log.info("token为空, 响应401状态码" ); response.setStatus(401 ); return ; } 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 ; } filterChain.doFilter(request, response); CurrentHolder.remove(); } }
在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()); 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; } private int getCurrentUserId () { return CurrentHolder.getCurrentId(); } }
代码优化完毕之后,我们重新启动服务测试。就可以看到,可以获取到不同的登录用户信息了。
在同一个线程/同一个请求中,进行数据共享就可以使用 ThreadLocal。
完结