1. 概述
本次案例模拟公司后端人员开发场景:
- 当前案例的前端工程,
前端人员已经帮我们开发好了
,我们只需要关注服务端接口的开发。
1.1 页面原型
- 产品经理绘制的的页面原型的展示效果:
- 成品展示:完成部门管理和员工管理的所有功能。
2. 准备工作
2.1 需求说明
2.2 环境搭建:
2.2.1 准本数据库表
- 创建数据库tlias:略(使用SQLyog)
- 在数据库中创建部门和员工表:在询问窗口中执行sql脚本即可
-- 部门管理
create table dept(
id int unsigned primary key auto_increment comment '主键ID',
name varchar(10) not null unique comment '部门名称',
create_time datetime not null comment '创建时间',
update_time datetime not null comment '修改时间'
) comment '部门表';
insert into dept (id, name, create_time, update_time) values(1,'学工部',now(),now()),(2,'教研部',now(),now()),(3,'咨询部',now(),now()), (4,'就业部',now(),now()),(5,'人事部',now(),now());
-- 员工管理(带约束)
create table emp (
id int unsigned primary key auto_increment comment 'ID',
username varchar(20) not null unique comment '用户名',
password varchar(32) default '123456' comment '密码',
name varchar(10) not null comment '姓名',
gender tinyint unsigned not null comment '性别, 说明: 1 男, 2 女',
image varchar(300) comment '图像',
job tinyint unsigned comment '职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师',
entrydate date comment '入职时间',
dept_id int unsigned comment '部门ID',
create_time datetime not nul`dept``emp`l comment '创建时间',
update_time datetime not null comment '修改时间'
) comment '员工表';
INSERT INTO emp
(id, username, password, name, gender, image, job, entrydate,dept_id, create_time, update_time) VALUES
(1,'jinyong','123456','金庸',1,'1.jpg',4,'2000-01-01',2,now(),now()),
(2,'zhangwuji','123456','张无忌',1,'2.jpg',2,'2015-01-01',2,now(),now()),
(3,'yangxiao','123456','杨逍',1,'3.jpg',2,'2008-05-01',2,now(),now()),
(4,'weiyixiao','123456','韦一笑',1,'4.jpg',2,'2007-01-01',2,now(),now()),
(5,'changyuchun','123456','常遇春',1,'5.jpg',2,'2012-12-05',2,now(),now()),
(6,'xiaozhao','123456','小昭',2,'6.jpg',3,'2013-09-05',1,now(),now()),
(7,'jixiaofu','123456','纪晓芙',2,'7.jpg',1,'2005-08-01',1,now(),now()),
(8,'zhouzhiruo','123456','周芷若',2,'8.jpg',1,'2014-11-09',1,now(),now()),
(9,'dingminjun','123456','丁敏君',2,'9.jpg',1,'2011-03-11',1,now(),now()),
(10,'zhaomin','123456','赵敏',2,'10.jpg',1,'2013-09-05',1,now(),now()),
(11,'luzhangke','123456','鹿杖客',1,'11.jpg',5,'2007-02-01',3,now(),now()),
(12,'hebiweng','123456','鹤笔翁',1,'12.jpg',5,'2008-08-18',3,now(),now()),
(13,'fangdongbai','123456','方东白',1,'13.jpg',5,'2012-11-01',3,now(),now()),
(14,'zhangsanfeng','123456','张三丰',1,'14.jpg',2,'2002-08-01',2,now(),now()),
(15,'yulianzhou','123456','俞莲舟',1,'15.jpg',2,'2011-05-01',2,now(),now()),
(16,'songyuanqiao','123456','宋远桥',1,'16.jpg',2,'2007-01-01',2,now(),now()),
(17,'chenyouliang','123456','陈友谅',1,'17.jpg',NULL,'2015-03-21',NULL,now(),now());
- 创建好的2张表效果:
一对多
2.2.2 创建工程,引入依赖
2.2.3 编写配置文件,实体类
- 配置文件
#驱动类名称
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#数据库连接的url
spring.datasource.url=jdbc:mysql://localhost:3306/tlias
#连接数据库的用户名
spring.datasource.username=root
#连接数据库的密码
spring.datasource.password=root
#配置mybatis的日志, 指定输出到控制台
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
#开启mybatis的驼峰命名自动映射开关 a_column ------> aCloumn
mybatis.configuration.map-underscore-to-camel-case=true
- 实体类:
- dept:
package com.cn.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 部门实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Dept {
private Integer id; //ID
private String name; //部门名称
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间
}
- emp:
package com.cn.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 员工实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Emp {
private Integer id; //ID
private String username; //用户名
private String password; //密码
private String name; //姓名
private Short gender; //性别 , 1 男, 2 女
private String image; //图像url
private Short job; //职位 , 1 班主任 , 2 讲师 , 3 学工主管 , 4 教研主管 , 5 咨询师
private LocalDate entrydate; //入职日期
private Integer deptId; //部门ID
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间
}
2.2.4 编写三层架构
- 控制层,Service层,Mapper层
2.3 开发规范
-
前后端分离开发,依照接口文档进行开发,一般是后端人员编写开发文档。
-
前后端基于RestFul风格的接口进行交互
-
统一响应结果:静态方法是为了更加方便的调用
package com.cn.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private Integer code;//响应码,1 代表成功; 0 代表失败
private String msg; //响应信息 描述字符串
private Object data; //返回的数据
//增删改 成功响应
public static Result success(){
return new Result(1,"success",null);
}
//查询 成功响应
public static Result success(Object data){
return new Result(1,"success",data);
}
//失败响应
public static Result error(String msg){
return new Result(0,msg,null);
}
}
2.4 思考开发流程
3. 部门管理
3.1 查询部门
3.1.1 需求分析&开发思路
- 查看页面原型明确需求:
- 查看接口文档
- 查询部门思路分析
3.1.2 接口开发
- Controller:
/**
*部门
*/
@RestController
@Slf4j
public class DeptController {
@Autowired
private DeptService deptService;
//@RequestMapping(value = "/depts",method = RequestMethod.GET)//指定请求方式为get
@GetMapping("/depts")
public Result list(){
//项目开发中最好不要使用System输出日志,不够专业,可以使用日志框架
//System.out.println("查询全部部门数据");
log.info("查询全部部门数据");
//调用service查询部门数据
List<Dept> list= deptService.list();
return Result.success(list);
}
}
- Service:
/**
* 部门
*/
public interface DeptService {
//查询全部门数据
List<Dept> list();
}
- Serviceimpl:
/**
*部门
*/
@Service
public class DeptServiceImpl implements DeptService {
@Autowired
private DeptMapper deptMapper;
@Override
public List<Dept> list() {
return deptMapper.list();
}
}
- Mapper:
/**
*部门
*/
@Mapper
public interface DeptMapper {
//查询全部门数据
@Select("select * from dept")
List<Dept> list();
}
- 运行程序使用Postman进行测试
3.1.3 前后端联调
- 将前端工程和后端工程都启动起来,访问前端工程通过前端工程来访问服务端程序,进而进行调试。
- 前端工程已经提供好了,我们只需要启动测试即可。
-
前端程序一般都是部署在nginx服务器中,在资料当中的nginx压缩包已经部署好这个前端程序
-
步骤:
- 解压到一个没有中文和空格的目录并启动
- 查看任务管理器,启动成功
- 解压到一个没有中文和空格的目录并启动
-
访问测试:
http://localhost:90/
,ngnix占用的是90端口号
-
3.2 删除部门
3.2.1 需求分析
- 原型需求分析:
- 接口文档:
- 删除部门----思路分析
3.2.2 接口开发
- Controller:
/**
*部门
*/
@RestController
@Slf4j
public class DeptController {
@Autowired
private DeptService deptService;
/**
* 删除部门
* @return
*/
@DeleteMapping("/depts/{id}")
public Result delete(@PathVariable Integer id){
log.info("根据id删除部门:{}",id);
//调用service删除部门
deptService.delete(id);
return Result.success();
}
}
- Service:
/**
* 删除部门
* @param id
*/
void delete(Integer id);
- Serviceimpl:
@Override
public void delete(Integer id) {
deptMapper.deleteById(id);
}
- Mapper:
/**
* 根据ID删除部门
* @param id
*/
@Delete("delete from dept where id = #{id}")
void deleteById(Integer id);
- 运行程序使用Postman进行测试
3.2.3 前后端联调
3.3 新增部门
3.3.1 需求分析&开发思路
-
查看页面原型明确需求:
-
新增接口文档
- post请求
- 前端发送的请求参数类型为json类型(
后端使用实体类+@RequestBody注解接收
) - 后端返回类型为json类型(方法使用@responseBody注解标识)
-
新增部门思路分析
- 补充基础属性:添加部门时页面传递的参数只有一个
部门名称
,在往数据库表中插入数据时还需要插入创建时间
和修改时间
参数,可以设置为当前时间,在代码中或者sql语句中都可以。
- 补充基础属性:添加部门时页面传递的参数只有一个
3.3.2 接口开发
- Controller:
/**
*部门
*/
@RestController
@Slf4j
public class DeptController {
@Autowired
private DeptService deptService;
/**
* 新增部门
* @return
*/
@PostMapping("/depts")
public Result add(@RequestBody Dept dept){
log.info("新增部门: {}" , dept);
//调用service新增部门
deptService.add(dept);
return Result.success();
}
}
- Service:
/**
* 新增部门
* @param dept
*/
void add(Dept dept);
- Serviceimpl:
@Override
public void add(Dept dept) {
dept.setCreateTime(LocalDateTime.now());
dept.setUpdateTime(LocalDateTime.now());
deptMapper.insert(dept);
}
- Mapper:
/**
* 新增部门
* @param dept
*/
@Insert("insert into dept(name, create_time, update_time) values(#{name},#{createTime},#{updateTime})")
void insert(Dept dept);
- 运行程序使用Postman进行测试
3.3.3 前后端联调
3.4 代码优化
4. 员工管理
4.1 分页查询(不带条件)
4.1.1 需求分析&开发思路
-
查看页面原型明确需求:完成分页查询功能(不带查询条件的分页)
- 分页查询服务端返回的数据:服务端
需要返回
给前端的数据 - 每页显示的记录数:前端页面在预设好的选项供用户来选择的,并不
需要
服务端返回 - 总记录数:数据库表结构中符合条件的总记录数 ,
需要
服务端查询返回 - 总页数:
不需要
服务端返回,前端只需要拿到总记录数可以自己计算出总页数 总页数=总记录数/每页展示的记录数(向上取整)- eg:500/10=50条、 501/10=51条
- 前端传递给后台的参数:当前页码值、每页显示的记录数
- 服务端响应给前端的数据:数据列表(使用list集合封装)、总记录数(使用变量接收)
- 问题:服务端响应给前端的数据就是控制层方法的返回值,一个方法只能有一个返回值,现在需要返回给前端两项数据并且类型还不一样,那么此时该如何返回呢???
- 解决:使用Map集合或者实体类(常用)把两项数据进行封装后返回。
- 分页查询服务端返回的数据:服务端
-
接口文档:
-
思路分析
4.1.2 接口开发
- 封装分页查询数据和总记录数的实体类
package com.cn.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分页查询结果封装类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageBean {
private Long total; //总记录数,可能比较多选择long类型
private List rows; //数据列表
}
- Controller:
/**
* 员工管理Controller
*/
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
/**
* 查看接口文档可知,2个参数要求有默认值
* page:分页查询的页码,如果未指定,默认为1
* pageSize:分页查询的每页记录数,如果未指定,默认为10
* 解决:使用@RequestParam(defaultValue = "xxx")注解,设置默认值
*/
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize){
//{} {} 传递2个参数使用2个占位符,不使用占位符后面参数代表的值无法输出
log.info("分页查询, 参数: {},{}",page,pageSize);
//调用service分页查询
PageBean pageBean = empService.page(page,pageSize);
return Result.success(pageBean);
}
}
- Service:
/**
* 分页查询
* @param page
* @param pageSize
* @return
*/
PageBean page(Integer page, Integer pageSize);
- Serviceimpl:
@Autowired
private EmpMapper empMapper;
@Override
public PageBean page(Integer page, Integer pageSize) {
//1. 获取总记录数
Long count = empMapper.count();
//2. 获取分页查询结果列表
Integer start = (page - 1) * pageSize;
List<Emp> empList = empMapper.page(start, pageSize);
//3. 封装PageBean对象
PageBean pageBean = new PageBean(count, empList);
return pageBean;
}
- Mapper:
/**
* 查询总记录数
* @return
*/
@Select("select count(*) from emp")
public Long count();
/**
* 分页查询,获取列表数据
* @param start
* @param pageSize
* @return
*/
@Select("select * from emp limit #{start},#{pageSize}")
public List<Emp> page(Integer start, Integer pageSize);
- 运行程序使用Postman进行测试
4.1.3 前后端联调
4.1.4 分页插件PageHelper
- 使用背景:项目中的很多功能(员工信息的分页查询、订单信息的分页查询、商品信息的分页查询)都会进行分页查询,步骤固定且代码繁琐。
- 解决:使用分页插件,MyBatis框架中最为流行的就是PageHelper分页插件。
改造代码步骤:
- 引入依赖
<!--PageHelper分页插件-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.2</version>
</dependency>
- 改造Mapper接口:只需要执行正常的查询即可,所有与分页插件有关的操作都是由分页插件来完成的。 把原先手动查询总记录和手动查询分页结果的方式注释掉。
/**
* 员工信息查询
* @return
*/
@Select("select * from emp")
public List<Emp> list();
- 改造Service层实现类:通用把原始的查询总记录数和查询分页结果的方式注释掉。
@Override
public PageBean page(Integer page, Integer pageSize) {
//1. 设置分页参数 :当前页码值 每页显示的记录数
PageHelper.startPage(page,pageSize);
//2. 执行查询
List<Emp> empList = empMapper.list();
//把查询的结果强转为Page<Emp>类型,之后才可以调用Page的方法
Page<Emp> p = (Page<Emp>) empList;
//3. 封装PageBean对象 p.getTotal():获取总记录数 p.getResult():获取结果列表
PageBean pageBean = new PageBean(p.getTotal(), p.getResult());
return pageBean;
}
- 运行测试
4.2 分页查询(带条件)
- 在使用分页插件的基础上改造代码,进行带条件的查询。
4.2.1 需求分析&开发思路
- 需求分析
- 实例分析
4.2.2 接口开发----改造代码
- Controller:
/**
* 员工管理Controller
*/
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
/**
* 查看接口文档可知,2个参数要求有默认值
* page:分页查询的页码,如果未指定,默认为1
* pageSize:分页查询的每页记录数,如果未指定,默认为10
* 解决:使用@RequestParam(defaultValue = "xxx")注解,设置默认值
*/
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
String name, Short gender,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){
//{} {} 传递2个参数使用2个占位符,不使用占位符后面参数代表的值无法输出
log.info("分页查询, 参数: {},{},{},{},{},{}",page,pageSize,name,gender,begin,end);
//调用service分页查询
PageBean pageBean = empService.page(page,pageSize,name,gender,begin,end);
return Result.success(pageBean);
}
}
- Service:
/**
* 分页查询
* @param page
* @param pageSize
* @return
*/
PageBean page(Integer page, Integer pageSize,String name, Short gender,LocalDate begin,LocalDate end);
- Serviceimpl:
@Autowired
private EmpMapper empMapper;
@Override
public PageBean page(Integer page, Integer pageSize,String name, Short gender,LocalDate begin,LocalDate end) {
//1. 设置分页参数 :当前页码值 每页显示的记录数
PageHelper.startPage(page,pageSize);
//2. 执行查询
List<Emp> empList = empMapper.list(name, gender, begin, end);
//把查询的结果强转为Page<Emp>类型,之后才可以调用Page的方法
Page<Emp> p = (Page<Emp>) empList;
//3. 封装PageBean对象 p.getTotal():获取总记录数 p.getResult():获取结果列表
PageBean pageBean = new PageBean(p.getTotal(), p.getResult());
return pageBean;
}
- Mapper:
/**
* 员工信息查询
* @return
*/
//@Select("select * from emp") 使用动态sql编写分页条件查询
public List<Emp> list(String name, Short gender,LocalDate begin,LocalDate end);
- 配置文件:添加映射文件位置
mybatis.mapper-locations=classpath:mapper/*.xml
- 映射文件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cn.mapper.EmpMapper">
<!--条件查询
模糊匹配:想要使用#{}占位符生成预编译的Sql,可以借助mysql中的字符串拼接函数concat进行拼接
即:参数是写死的情况下不需要,如果是占位符需要通过concat进行拼接-->
<select id="list" resultType="com.cn.pojo.Emp">
select * from emp
<where>
<if test="name != null and name != ''">
name like concat('%',#{name},'%')
</if>
<if test="gender != null">
and gender = #{gender}
</if>
<if test="begin != null and end != null">
and entrydate between #{begin} and #{end}
</if>
</where>
order by update_time desc
</select>
</mapper>
4.2.3 前后端联调
4.3 删除员工
4.3.1 需求分析&开发思路
- 页面原型需求分析:删除一个、批量删除,可以只开发一个批量删除的接口,因为删除单个也可以看成是一个特殊的批量删除功能。
- 开发文档
- 开发思路
4.3.2 接口开发
- Controller:
@DeleteMapping("/{ids}")
public Result delete(@PathVariable List<Integer> ids){
log.info("批量删除操作, ids:{}",ids);
empService.delete(ids);
return Result.success();
}
- Service:
/**
* 批量删除
* @param ids
*/
void delete(List<Integer> ids);
- Serviceimpl:
@Override
public void delete(List<Integer> ids) {
empMapper.delete(ids);
}
- Mapper:
/**
* 批量删除
* @param ids
*/
void delete(List<Integer> ids);
- 映射文件
<!--批量删除员工 (1, 2, 3)-->
<delete id="delete">
delete
from emp
where id in
<foreach collection="ids" item="id" separator="," open="(" close=")">
#{id}
</foreach>
</delete>
4.3.3 前后端联调
4.4 新增员工
4.4.1 需求分析&开发思路
-
原型分析
暂时不考虑图片
-
接口文档
-
开发思路
4.4.2 接口开发
- Controller:
@PostMapping
public Result save(@RequestBody Emp emp){
log.info("新增员工, emp: {}",emp);
empService.save(emp);
return Result.success();
}
- Service:
/**
* 新增员工
* @param emp
*/
void save(Emp emp);
- Serviceimpl:
@Override
public void save(Emp emp) {
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
empMapper.insert(emp);
}
- Mapper:
/**
* 新增员工
* id:设置的主键自增,不需要插入
* password:设置的有默认约束,不需要插入
* @param emp
*/
@Insert("insert into emp(username, name, gender, image, job, entrydate, dept_id, create_time, update_time) " +
" values(#{username},#{name},#{gender},#{image},#{job},#{entrydate},#{deptId},#{createTime},#{updateTime})")
void insert(Emp emp);
4.4.3 前后端联调
4.5 文件上传----阿里云OSS集成
4.5.1 需求分析&开发实录
- 原型分析:阿里云返回的url,在浏览器中输入+回车是下载,把地址写在html中的image标签上是显示图片。
- 流程:
- 文件上传:新增员工时服务端通过MultipartFile 类型来接收文件,之后调用OSS的API把文件保存在阿里云OSS中,阿里云OSS会为每个文件设置一个url,我们需要在服务端获取这个url,最终将这个url返回给前端。
- 回显图像:把url地址返回给前端后,前端会自动向阿里云OSS发送请求获取到图片,在页面中展示出来
- 保存参数到数据库:在页面表单填写完参数后进行保存,服务端会调用新增方法,把包括图片url在内的参数保存在数据库的员工表中,这样添加的员工就和图片关联起来了
- 流程:
- 接口文档
4.5.2 接口开发
-
步骤
- 引入阿里云OSS上传文件工具类(由官方的示例代码改造而来)
- 上传图片接口开发
-
添加依赖
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.15.1</version>
</dependency>
- 工具类
package com.cn.utils;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.UUID;
/**
* 阿里云 OSS 工具类
*/
@Component
public class AliOSSUtils {
private String endpoint = "https://oss-cn-beijing.aliyuncs.com";//OSS对象存储服务的地址
private String accessKeyId = "LTAI5tSHskmRrsoA2C1sikMm";//miyao(。。。违反社区规所以这样写)
private String accessKeySecret = "d6WvJcYYhfsdq36Pl4jAt72wkxGO1N";//米妖(。。。)
private String bucketName = "web-wenjian-shangchuan";//工作空间名
/**
* 实现上传图片到OSS
*/
public String upload(MultipartFile file) throws IOException {
// 获取上传的文件的输入流
InputStream inputStream = file.getInputStream();
// 避免文件覆盖 :uuid生成的前缀+原始文件名的后缀
String originalFilename = file.getOriginalFilename();
String fileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf("."));
//上传文件到 OSS
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
ossClient.putObject(bucketName, fileName, inputStream);
//文件访问路径:OSS地址前缀(//前的内容)+存储空间+OSS地址后缀(//后的内容)+文件名
String url = endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + fileName;
// 关闭ossClient
ossClient.shutdown();
return url;// 把上传到oss的路径返回
}
}
- 控制层方法
package com.cn.controller;
import com.cn.pojo.Result;
import com.cn.utils.AliOSSUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
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;
import java.util.UUID;
@Slf4j
@RestController
public class UploadController {
@Autowired
private AliOSSUtils aliOSSUtils;
@PostMapping("/upload")
public Result upload(MultipartFile image) throws IOException {
log.info("文件上传, 文件名: {}", image.getOriginalFilename());
/**
* 调用阿里云OSS工具类,将上传来的文件保存在阿里云中:
* 这个提供的阿里云OSS工具类方法并不是一个静态方法,所以调用这个方法区还需要new一个工具类对象,
* 现在使用的都是Spring环境,所以建议把工具类交给Spring容器管理(在工具类上添加@Component),
* 在注入即可
*/
String url = aliOSSUtils.upload(image);
log.info("文件上传完成,文件访问的url: {}", url);
return Result.success(url);//将图片上传后的url返回,用于浏览器回显展示。
}
}
- 运行测试:
- 查看阿里云OSS的工作空间:已经保存了图片。
- 查看数据库:图片的url地址已经保存到数据库的表中
4.6 修改员工
4.6.1 查询回显
1)需求分析&开发思路
- 页面原型分析:
- 开发文档
- 思路分析
2)接口开发
- Controller层
@GetMapping("/{id}")
public Result getById(@PathVariable Integer id){
log.info("根据ID查询员工信息, id: {}",id);
Emp emp = empService.getById(id);
return Result.success(emp);
}
- Service层接口
/**
* 根据ID查询员工
* @param id
* @return
*/
Emp getById(Integer id);
- Service层实现类
@Override
public Emp getById(Integer id) {
return empMapper.getById(id);
}
- Mapper层
/**
* 根据ID查询员工
* @param id
* @return
*/
@Select("select * from emp where id = #{id}")
Emp getById(Integer id);
3)前后端联调
4.6.2 提交修改
1)需求分析&开发思路
-
页面原型分析:更新的条件只能是id,其它字段都有可能发生变化
-
开发文档
-
思路分析:
- 使用动态sql,字段传递了就更新,字段没有传递就不更新。
- 因为更新操作数据已经回显过了,所以不用动态Sql也可以。
2)接口开发
- Controller层
@PutMapping
public Result update(@RequestBody Emp emp){
log.info("更新员工信息 : {}", emp);
empService.update(emp);
return Result.success();
}
- Service层接口
/**
* 更新员工
* @param emp
*/
void update(Emp emp);
- Service层实现类
@Override
public void update(Emp emp) {
emp.setUpdateTime(LocalDateTime.now());
empMapper.update(emp);
}
- Mapper层
/**
* 更新员工
* @param emp
*/
void update(Emp emp);
- 映射文件
<!--更新员工:使用动态Sql标签,如果最后一个条件不成立会多一个逗号,
可以使用Set标签代替set关键字(去掉set关键字,之后使用Set标签把需要更新的字段全部包裹起来)-->
<update id="update">
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="image != null and image != ''">
image = #{image},
</if>
<if test="job != null">
job = #{job},
</if>
<if test="entrydate != null">
entrydate = #{entrydate},
</if>
<if test="deptId != null">
dept_id = #{deptId},
</if>
<if test="updateTime != null">
update_time = #{updateTime}
</if>
</set>
where id = #{id}
</update>
- 使用Postman测试:
-
后端代码没有使用动态Sql:Postman没有传递所有的参数,发现没有更新的参数会赋值为null。
-
后端的sql:
-
Postman编写的参数:
-
发送请求前的数据:
-
发送请求后的数据:没有修改的数据会被null值覆盖。
-
-
后端代码使用动态Sql:
-
后端编写的sql文件
-
Postman编写的请求:
-
发送请求前的数据:
-
发送请求后的数据:提交的有参数的数据才会修改,没有提交的参数不会修改保留原始数据。
-
-
3)前后端联调
- 因为更新业务是先回显在更新,更新表单中已经回显了原始数据,所以即便后端不用动态Sql仍然可以正确插入。
5. 配置文件
5.1 参数配置化(@Value)
5.1.1 背景分析
-
当前项目中代码所存在的一些问题:完成文件上传功能时调用阿里云OSS工具类是把参数直接写死在类中。这样项目中每涉及到一个技术或者第三方的服务就将参数硬编码在java代码中,
就会存在2个问题
。
- 如果参数发生变化,每次修改代码后都需要重新编译(
.java--》.class
),重新运行,这样非常的繁琐。 - 开发一个真实的企业级项目那么这个java类可能会有很多,如果将这些参数分散的定义在各个java类中,那么此时修改一个参数值就先需要定位到这个参数的位置,然后在进行修改,重新编译 重新运行,这样项目是不方便维护和管理的。
- 如果参数发生变化,每次修改代码后都需要重新编译(
-
解决:把参数定义在配置文件中,之后在代码中引入即可。
- key=value形式,k自己定义 最好做到见名知意,Value为参数值。
- 想要修改参数值只需要找到配置文件进行修改即可,并且
properties文件中的配置进行了修改不用编译
5.1.2 修改代码
- 修改项目中的文件上传功能的代码:
- 配置文件
- 配置文件
#阿里云OSS配置
# properties配置文件中信息本身就是一个字符串所以不需要引号,不需要分号,等号两边的空格也不需要。
aliyun.oss.endpoint=https://oss-cn-beijing.aliyuncs.com
aliyun.oss.accessKeyId=LTAI5tSHskmRrsoA2C1sikMm
aliyun.oss.accessKeySecret=d6WvJcYYhfsdq36Pl4jAt72wkxGO1N
aliyun.oss.bucketName=wenjian
- 修改工具类代码
//写的是properties文件中的key,注意是Spring家的注解
@Value("${aliyun.oss.endpoint}")
private String endpoint; //OSS对象存储服务的地址
@Value("${aliyun.oss.accessKeyId}")
private String accessKeyId; //秘钥
@Value("${aliyun.oss.accessKeySecret}")
private String accessKeySecret; //秘钥
@Value("${aliyun.oss.bucketName}")
private String bucketName; //工作空间名
- 启动项目断点调试,在项目中使用添加功能可以看到后端确实获取到了参数。
5.2 yml配置文件
5.2.1 背景分析
- 配置格式
- 常见的三类配置文件格式对比
- yml文件的基本语法
- yml常见的数据格式
- 修改项目中的配置,使用yml替换掉properties
5.2.2 修改代码
- 修改代码
- 删除掉properties文件或者注释掉里面的内容
- yml文件中的内容
- 删除掉properties文件或者注释掉里面的内容
spring:
#数据库连接信息
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/tlias
username: root
password: root
#文件上传的配置
servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB
#Mybatis配置
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
mapper-locations: classpath:mapper/*.xml
#阿里云OSS
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
accessKeyId: LTAI5tSHskmRrsoA2C1sikMm
accessKeySecret: d6WvJcYYhfsdq36Pl4jAt72wkxGO1N
bucketName: web-wenjian-shangchuan
- 运行测试:仍能访问成功。
5.3 @ConfigurationProperties
5.3.1 背景分析
- 问题分析:通过@Value注入外部配置文件中的属性值,如果需要注入的属性值比较多,在每一个属性上都需要加上此注解进行注入,这样会变得非常的繁琐。
- 解决:Spring提供了一种简化的方式,可以直接将配置文件中的值自动的注入到对象的属性中。
- 前提条件:
- 配置文件中key的名字必须和实体类中的属性名一致。
- 需要将实体类交给ioc容器管理,即加上@Component注解
- 使用@ConfigurationProperties指定每个字段相同的前缀
- 前提条件:
- 注入流程
- 定义一个Bean对象,之后批量的将外部属性配置直接注入到Bean对象的属性中,在其它的类当中想要获取注入进来的属性直接注入这个Bean对象,然后调用对应的get方法就可以获取到了(实体类提供的有对应的get方法)。
- 注意:不能直接注入到这个工具类中,因为其他类也注入这个变量时,此时2个不同的类由相同的变量,无法区分注入给谁。如果单独的只注入给一个实体类,其它类要是使用的时候只需要注入对应的Bean对象,之后调用set方法就可以区分使用了。
5.3.2 修改代码
- 创建实体类
package com.cn.utils;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliOSSProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}
- 修改工具类:
package com.cn.utils;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.UUID;
/**
* 阿里云 OSS 工具类
*/
@Component
public class AliOSSUtils {
/* //写的是properties文件中的key,注意是Spring家的注解
@Value("${aliyun.oss.endpoint}")
private String endpoint; //OSS对象存储服务的地址
@Value("${aliyun.oss.accessKeyId}")
private String accessKeyId; //秘钥
@Value("${aliyun.oss.accessKeySecret}")
private String accessKeySecret; //秘钥
@Value("${aliyun.oss.bucketName}")
private String bucketName; //工作空间名*/
@Autowired
private AliOSSProperties aliOSSProperties;
/**
* 实现上传图片到OSS
*/
public String upload(MultipartFile file) throws IOException {
//获取阿里云OSS参数
String endpoint = aliOSSProperties.getEndpoint();
String accessKeyId = aliOSSProperties.getAccessKeyId();
String accessKeySecret = aliOSSProperties.getAccessKeySecret();
String bucketName = aliOSSProperties.getBucketName();
// 获取上传的文件的输入流
InputStream inputStream = file.getInputStream();
// 避免文件覆盖 :uuid生成的前缀+原始文件名的后缀
String originalFilename = file.getOriginalFilename();
String fileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf("."));
//上传文件到 OSS
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
ossClient.putObject(bucketName, fileName, inputStream);
//文件访问路径:OSS地址前缀(//前的内容)+存储空间+OSS地址后缀(//后的内容)+文件名
String url = endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + fileName;
// 关闭ossClient
ossClient.shutdown();
return url;// 把上传到oss的路径返回
}
}
- Debug调试,发现仍然可以注入成功。
- 警告信息说明:加上@ConfigurationProperties(prefix = “aliyun.oss”)注解后就会提示我们需要引入
spring-boot-configuration-processor
依赖,此依赖的作用是,自动的识别被@ConfigurationProperties注解标识的Bean对象,之后再配置文件中进行配置的时候,就会自动的提示与这个Bean对象的属性名相对应的配置项名字。即:在配置文件中配置阿里云OSS的配置信息时就会有对应的提示,不影响程序运行是可选的操作。
- 引入依赖
- 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
- 重新启动项目,查看实体类发现红色警告信息变为灰色提示信息,点击隐藏即可。
5.3.3 @Value和@ConfigurationProperties的对比
6. 登录
- 现在直接在浏览器地址栏中输入地址就可以直接进入到这个系统中随意操作数据,这样非常的不安全。
- 解决:登录成功后才可以访问系统中的数据
6.1 基础登录功能
6.1.1 页面分析&接口文档
- 页面原型分析
- 操作的是员工表
- 登录的时候如何校验用户名密码是否正确:根据用户名和密码查询员工,如果查询到员工则表明用户名和密码正确,如果没有查询到则表明输入的用户名和密码错误。
登录的时候校验用户名和密码是否正确该如何写这条Sql
:根据用户名和密码查询这条员工,如果查询到了就证明用户名和密码是正确的,没有查询到证明用户名或密码错误。思考根据这条sql查询到的员工有没有可能是多个
:不可能,因为在创建员工表emp时针对这个username字段添加了唯一约束,所以username不可能重复,所以最终查询到的数据最多有一条。
- 接口文档
- 思路分析:
登录的请求都是/login开头的,所以需要新定义一个控制层
service层使用的都是emp员工,所以不需要创建一个新的业务层。
6.1.2 接口开发
- 控制层
@Slf4j
@RestController
public class LoginController {
@Autowired
private EmpService empService;
@PostMapping("/login")
public Result login(@RequestBody Emp emp){
log.info("员工登录: {}", emp);
Emp e = empService.login(emp);
//登录失败, 返回错误信息
return e !=null? Result.success():Result.error("用户名或密码错误");
}
}
- 业务层
/**
* 员工登录
* @param emp
* @return
*/
Emp login(Emp emp);
- 业务层实现类
@Override
public Emp login(Emp emp) {
return empMapper.getByUsernameAndPassword(emp);
}
- 数据层
/**
* 根据用户名和密码查询员工
* @param emp
* @return
*/
@Select("select * from emp where username = #{username} and password = #{password}")
Emp getByUsernameAndPassword(Emp emp);
- Postman测试
6.1.3 前后端联调
6.2 登录校验
6.2.1 概述
- 问题:复制员工管理模块的访问地址,之后退出登录在浏览器输入复制的地址,发现在没有登录的时候就可以访问。
-
复制地址
-
退出登录
-
退出后直接输入访问员工管理界面的地址,发现可以直接访问
-
- 正常执行流程:登录成功后才能访问,没有登录的时候访问首先要跳转到登录页面。
- 原因:当前开发的部门管理和员工管理,在后端接口的代码中只是正常的操作数据库中的数据,完成数据的增删改查操作并没有判断用户是否登陆,所以无论用户是否登录都可以去访问部门管理和员工管理的相关数据。
- 解决:需要在后端代码中完成登录校验功能。
登录校验
:服务端接收客户端发送过来的请求,首先需要对请求进行校验用户是否登录,如果登录了就执行对应的业务操作,如果没有登录就不允许执行相关的业务操作,而是返回给前端一个错误的结果最终跳转到登录页面。
- 登录校验实现思路:
- http是无状态的协议(每次请求都是独立的,下一次请求不会携带上一次请求的数据),浏览器与服务器之间的交互就是基于http协议的,即:通过浏览器访问登录接口实现了登录操作,接下来执行其它业务操作时服务器并不知道员工是否登录,因为http协议是无状态、独立的,所以它无法判断员工是否登录。
- 在服务端想要判断员工是否登录,那么就需要在员工登录成功后存储一个登录成功的标记,之后在每一个接口方法之前进行判断员工是否登录,如果登陆成功则执行对应的业务操作,如果登陆失败返回一个错误信息给前端,前端拿到这个错误信息后会自动的跳转到登录页面。
- 查询的接口方法、更新的接口方法、新增的接口方法等等都需要进行判断员工是否登录,每个方法写一遍判断登录的代码非常麻烦。
解决
:可以使用统一拦截的技术(拦截器、过滤器)拦截浏览器发送的所有请求,之后获取登录成功后的标记并且这个标记也没有问题,那么说明员工已经登录进行放行,否则响应给前端一个错误信息,前端就会自动的跳转到登录页面。
6.2.2 会话技术
1)会话相关概念
- 会话:用户打开浏览器,访问web服务器的资源,会话建立,直到有一方断开连接,会话结束。在一次会话中可以包含
多次
请求和响应。- 总结:浏览器和服务器之间的一次连接就称为一次会话。同一个浏览器在未关闭之前请求了多次服务器,那么这多次请求属于同一个会话,关闭这个浏览器这次会话就结束了,直接把web服务器关闭了那么所有的会话都结束了。
- 会话跟踪:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间
共享数据
。- 总结:识别多次请求是否来自于同一个浏览器的过程就称为会话跟踪。如果跟踪到的多次请求来自于同一个会话,那么此时就可以在多个请求之间来共享数据了。
- 会话跟踪方案:
- 客户端会话跟踪技术:Cookie
- 服务端会话跟踪技术:Session
- 令牌技术
2)Cookie介绍
- 执行流程:
- Cookie是客户端会话跟踪技术,在浏览器第一次发起请求访问服务器的时候设置Cookie,在Cookie当中存入一些数据信息比如用户的id,服务端在给客户端响应数据的时候会
自动的
将Cookie响应给浏览器。 - 浏览器接收到Cookie之后会
自动的
存储在浏览器本地,之后的每一次请求都会携带本地存储的Cookie自动的
携带到服务端。 - 在服务端获取到Cookie的值在进行判断这个Cookie的值是否存在。如果不存在说明用户没有登录,如果携带了证明已经登陆过了。
- 这样就可以基于同一次会话的不同请求之间来共享数据。
- Cookie是客户端会话跟踪技术,在浏览器第一次发起请求访问服务器的时候设置Cookie,在Cookie当中存入一些数据信息比如用户的id,服务端在给客户端响应数据的时候会
- 注意执行流程中的三个自动:
- 因为Cookie是http协议支持的技术,而各大浏览器厂商都支持了这一标准,在http协议中就给我们提供了一个响应头(Set-Cookie)和请求头(Cookie)。
- 因为Cookie是http协议支持的技术,而各大浏览器厂商都支持了这一标准,在http协议中就给我们提供了一个响应头(Set-Cookie)和请求头(Cookie)。
3)Cookie案例演示
- 代码:
package com.cn.controller;
import com.cn.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* Cookie、HttpSession演示
*/
@Slf4j
@RestController
public class SessionController {
//设置Cookie
@GetMapping("/c1")
public Result cookie1(HttpServletResponse response){
//创建Cookie对象,并知客户端保存Cookie
response.addCookie(new Cookie("login_username","itheima")); //设置Cookie/响应Cookie
return Result.success();
}
//获取Cookie
@GetMapping("/c2")
public Result cookie2(HttpServletRequest request){
//通过request获取浏览器请求头中携带的Cookie
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if(cookie.getName().equals("login_username")){
System.out.println("login_username: "+cookie.getValue()); //输出name为login_username的cookie
}
}
return Result.success();
}
}
- 运行测试:
- 访问c1:可以看到浏览器拿到了响应回来的响应头Set-Cookie,之后会自动的存储到浏览器本地。
- 访问c2:可以看到请求头会自动的携带Cookie到服务端。
- 访问c1:可以看到浏览器拿到了响应回来的响应头Set-Cookie,之后会自动的存储到浏览器本地。
4)Session介绍
- 概述:
- Session是服务端会话跟踪技术是存储在服务器端的,Session的底层是基于Cookie来实现的。
- 执行流程:
- 浏览器第一次请求服务器的时候,就可以直接在服务器中获取到会话对象Session(
两种方式获取Session
),如果是第一次请求Session会话对象是不存在的,这个时候服务器会自动的创建一个会话对象Session,每一个会话对象Session都会有一个Session的Id,之后服务器端在给浏览器响应数据的时候,他会将这个Session的id通过Cookie响应给浏览器,即:在响应头中加了Set-Cookie这个响应头,这个Cookie的名字是固定的JSESSIONID
- 之后自动的将Cookie保存在浏览器本地,在每次发送请求的时候都会自动的携带Cookie到服务端,在服务端拿到Session的id之后,就会从众多的Session当中拿到当前请求对应的会话对象Session,这样就可以在同一次会话的多次请求之间来共享数据了。
- 浏览器第一次请求服务器的时候,就可以直接在服务器中获取到会话对象Session(
5)Session案例演示
- 代码:
package com.cn.controller;
import com.cn.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* Cookie、HttpSession演示
*/
@Slf4j
@RestController
public class SessionController {
/**
* 方式一:直接在控制层方法中设置HttpSession类型的参数获取Session
*
* 在控制层方法中声明一个Session,之后服务器端会进行判断当前请求
* 对应的Session是否存在,如果不存在他会创建一个新的Session,如果存在
* 他会获取到当前请求对应的Session
*/
@GetMapping("/s1")
public Result session1(HttpSession session){
log.info("HttpSession-s1: {}", session.hashCode());
session.setAttribute("loginUser", "tom"); //往session中存储数据
return Result.success();
}
/**
* 方式二:在方法中声明一个HttpServletRequest对象类型的参数,
* 之后通过request对象的方法来获取当前请求对应的Session
*/
@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);
}
}
-
可以看到响应给浏览器的Cookie并且会自动的保存到浏览器本地
-
每次发起请求都会携带Cookie,在服务端通过输出Session的id发现前后获取到的值是一样的,说明两次请求获取到的是同一个会话对象。
6.2.3 JWT令牌
1)执行流程&优缺点
- 令牌:用户身份的标识,其实就是一个字符串。
- 优点解释:
支持PC端、移动端
:因为现在并不需要把令牌必须保存在Cookie中,其他任何的存储空间当中都是可以的,你只需要在客户端当中将这个令牌存储起来就可以了。解决集群环境下的认证问题
:因为我在服务器端啊并不需要存储任何的数据减轻服务器端存储压力
:正是因为在服务端不需要存储任何的数据,所以他也减轻了服务器端的存储压力。
- 缺点解释:
需要自己实现
:怎么样生成令牌,怎么样将令牌存储在客户端浏览器,怎么样将令牌携带到服务端这些都需要我们自己实现。当然在实际的开发中也需要前端的开发人员来配合实现。
- 思考令牌存储在客户端会不会不安全,用户是否可以伪造令牌???
- 不会,一旦令牌伪造了,那我们在服务器端校验令牌的时候就会报错,是会检测到的。
- 执行流程:
- 浏览器发送请求在请求登录接口的时候,如果登录成功就可以生成一个令牌,之后在响应数据的时候把令牌响应给前端
- 在前端程序中可以通过Cookie存储,也可以存储在其他的存储空间中,之后在每一次的请求中都会通过请求头token携带令牌到服务端
- 在服务端中校验令牌的有效性。如果令牌是有效的则证明用户之前进行了登录操作,如果令牌是无效的则证明用户之前没有进行登录操作。
- 此时想要在同一次会话的多次请求之间共享数据,就可以把共享的数据存储在领牌中就可以了。
2)JWT令牌介绍
-
简介:
https://jwt.io/
- 即:JWT就是将原始的json数据格式进行了安全的封装,这样就可以直接基于JWT在通信双方安全的传输信息了。
- 简洁的:指的是JWT是一个简单地字符串,可以在请求参数或者是请求头当中直接来传递这个JWT令牌
- 自包含:JWT令牌看似是一个随机的字符串,但是可以根据自身的需要在JWT令牌中来储存自定义的内容。比如直接在JWT令牌中存储用户的相关信息。
-
应用场景:登录认证
3)JWT令牌-生成和校验(演示)
3.1)概述
-
JWT令牌-生成
-
JWT令牌-校验
3.2)测试
- 引入依赖
<!--JWT令牌-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
- 调用工具包提供的Api来完成JWT令牌的生成和校验(在测试类中进行的调试)
package com.cn;
import com.cn.mapper.EmpMapper;
import com.cn.pojo.Emp;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* @SpringBootTest的作用:如果想要使用注入组件功能,需要类上添加@SpringBootTest注解标识,
* 这里显然不需要使用,所以可以把此注解注释掉。好处是在运行的时候不用加载整个Spring的运行环境,
* 可以提高运行速度。
*/
//@SpringBootTest
class SpringBootCrudHeimaApplicationTests {
/**
* 生成JWT
*/
@Test
public void testGenJwt(){
Map<String, Object> claims = new HashMap<>();
claims.put("id",1);
claims.put("name","tom");
/* Jwts.builder():构建JWT令牌,之后通过链式编程来调用里面的方法来设置gwt令牌在生成的时候所需要设置的一些参数:
* signWith():指定签名算法、秘钥(秘钥就是一个字符串,自己设置一个)
* setClaims():JWT令牌所存储的内容,也就是自定义的数据(载荷 第二部分数据)。参数类型为map集合,
* 可以把自定义的数据封装到map集合中
* setExpiration():设置令牌的有效期,在有效时间范围内生效,超出有效期就失效了。
* 参数类型为Date类型:拿到当前时间在加上一个小时,表示当前时间向后推迟一个小时。
* System...获取到的是当前时间的时毫秒值,所以把3600秒乘以一千转化为毫秒值后在相加。
* compact():调用此方法后就可以拿到一个字符串类型的返回值,即生成的jwt令牌。
*/
String jwt = Jwts.builder()
.signWith(SignatureAlgorithm.HS256, "itheima")
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 3600 * 1000))//设置有效期为1h
.compact();
System.out.println(jwt);
}
/**
* 解析JWT
*/
@Test
public void testParseJwt(){
/* Jwts.parser():进行令牌解析
* setSigningKey():指定之前设置的签名秘钥
* parseClaimsJws():指定需要解析的令牌字符串
* getBody():调用此方法就可以拿到自定义的内容,也就是jwt令牌的第二部分内容。
* */
Claims claims = Jwts.parser()
.setSigningKey("itheima")
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidG9tIiwiaWQiOjEsImV4cCI6MTY4NTk2MTY5NH0.YDNV-sLr28gKzCSYaknefgftVMAM2VvIIPaW26qGu68")
.getBody();
//解析后的结果是一个Map集合
System.out.println(claims);
}
}
-
运行jwt令牌生成单元测试方法:
-
运行jwt令牌解析单元测试方法:使用的是java代码进行解析,得到自定义的数据,过期时间
-
也可以直接把生成的令牌字符串放到JWT令牌官网进行解析:
-
一旦令牌字符串被篡改了就不能在进行解析,证明jwt令牌是安全可靠的。
4)JWT令牌-登录后下发令牌(运用到项目中)
4.1) 思路分析&接口文档
-
思路:
-
接口文档:
4.2) 改造登录接口代码
- 引入JWT令牌操作工具类(前面已经演示过了基础的Api,真正在项目中使用的时候都是调用现成的工具类)
package com.cn.utils;
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 = "itheima"; //指定秘钥
private static Long expire = 43200000L; //指定过期时间为12小时
/**
* 生成JWT令牌
* @param claims JWT第二部分负载 payload 中存储的内容
* @return
*/
public static String generateJwt(Map<String, Object> claims){
String jwt = Jwts.builder()
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, signKey)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
return jwt;
}
/**
* 解析JWT令牌
* @param jwt JWT令牌
* @return JWT第二部分负载 payload 中存储的内容
*/
public static Claims parseJWT(String jwt){
Claims claims = Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(jwt)
.getBody();
return claims;
}
}
- 登录完成后,调用工具类生成JWT令牌,并返回。
package com.cn.controller;
import com.cn.pojo.Emp;
import com.cn.pojo.Result;
import com.cn.service.EmpService;
import com.cn.utils.JwtUtils;
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.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
public class LoginController {
@Autowired
private EmpService empService;
@PostMapping("/login")
public Result login(@RequestBody Emp emp){
log.info("员工登录: {}", emp);
Emp e = empService.login(emp);
//登录成功,生成令牌,下发令牌
if (e != null){ //调用login()查询到了员工的信息说明登录成功了。
Map<String, Object> claims = new HashMap<>();
claims.put("id", e.getId());
claims.put("name", e.getName());
claims.put("username", e.getUsername());
String jwt = JwtUtils.generateJwt(claims); //调用工具类生成jwt令牌,jwt包含了当前登录的员工信息
return Result.success(jwt);//登陆成功后下发令牌。由接口文档可知返回的是json类型的数据
}
//登录失败, 返回错误信息 由接口文档可知返回的是json类型的数据
return Result.error("用户名或密码错误");
}
}
- 使用Postman进行测试
- 前后端联调:
6.2.4 过滤器Filter(令牌校验)
- 概念:
Filter过滤器
,是JavaWeb三大组件(Servlet、Filter、Listener)之一。 - 过滤器可以把对资源的请求
拦截
下来,从而实现一些特殊的功能。 - 过滤器一般完成一些
通用
的操作,比如:登录校验、统一编码处理、敏感字符处理等。
1)快速入门
步骤:
- 定义Filter:定义一个类,实现Filter接口,并重写其所有方法。
- 配置Filter:Fiter类上加
@WwebFilter
注解,配置拦截资源的路径。启动类类上加@ServletComponentScan
开启Servlet组件支持。
案例测试
- 创建过滤器
package com.cn.filter;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;
@WebFilter(urlPatterns = "/*") //拦截所有请求
/*注意实现过滤器之后需要重写的3个方法,只有doFilter方法是必须要重写
的,而其他的2个方法在父接口中已经做了默认实现所以如果不用的话就不
需要重写了(因为这2个方法不经常用,所以底层使用了default标识,jdk8)*/
public class DemoFilter implements Filter {
/*
* 初始化方法, 只调用一次
* web服务器启动时自动创建Filter对象并调用初始化方法,通常
* 完成一些资源以及环境的准备操作。
* */
@Override
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
//每次拦截到请求之后都会调用, 调用多次
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("Demo 拦截到了请求...放行前逻辑");
//放行
chain.doFilter(request,response);
System.out.println("Demo 拦截到了请求...放行后逻辑");
}
//销毁方法, 只调用一次 关闭服务器时候调用,通常做些环境的清理以及资源的释放操作
@Override
public void destroy() {
System.out.println("destroy 销毁方法执行了");
}
}
- 主启动类上加上
@ServletComponentScan
开启Servlet组件支持
- 运行测试:启动web服务器时调用初始化方法,每次发送请求都会调用doFilter方法,当关闭服务器时调用销毁方法。
2)详解:执行流程、拦截路径、过滤器链
- 执行流程
- 拦截路径
- 过滤器链
3)登录校验-Filter(运用到项目中)
- 基本流程
- 实现思路
改造代码:- 创建过滤器
- 创建过滤器
package com.cn.filter;
import com.alibaba.fastjson.JSONObject;
import com.cn.pojo.Result;
import com.cn.utils.JwtUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Slf4j
@WebFilter(urlPatterns = "/*")
public class LoginCheckFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
//下面用的方法都是HttpServletRequest对象中的方法,而这里重写后的方法是ServletRequest类型,所以需要强转
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
//1.获取请求url。
String url = req.getRequestURL().toString();
log.info("请求的url: {}",url);
//2.判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if(url.contains("login")){
log.info("登录操作, 放行...");
chain.doFilter(request,response);
//判断是登录操作后放行,放行后不需要在接着执行以下业务逻辑代码,所以使用return结束方法执行。
return;
}
//3.不是登录操作,获取请求头中的令牌(token)。
String jwt = req.getHeader("token");
//4.判断令牌是否存在,如果不存在,返回错误结果(未登录)。
//Spring提供的工具类,判断字符串是否有长度,如果没有长度证明为null或空串,说明令牌不存在
if(!StringUtils.hasLength(jwt)){
log.info("请求头token为空,返回未登录的信息");
/*由接口文档可知未登录需要返回给前端一个错误的信息,并且是json类型,
* 之前是在控制层方法中加上@ResponseBody注解,就会自动将方法的返回值转化为json并返回给前端,
* 现在在过滤器中并不是在Controller中,所以需要手动的进行转换。
* 转换步骤:引入阿里巴巴提供的json转换依赖fastJSON
* 调用里面的工具Api方法进行转换
* */
Result error = Result.error("NOT_LOGIN");
//手动转换 对象--json --------> 阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
//把转化后的错误信息json对象返回给前端,注意使用的是原生的方式,因为这是过滤器中不是在控制层方法
resp.getWriter().write(notLogin);
//结束方法,不需要执行下面的代码
return;
}
//5.如果令牌存在,解析token,如果解析失败,返回错误结果(未登录)。
try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {//jwt解析失败回报异常,所以需要进行try-catch
e.printStackTrace();
log.info("解析令牌失败, 返回未登录错误信息");
//通用解析失败需要返回一个json类型的错误信息,有接口文档可知
Result error = Result.error("NOT_LOGIN");
//手动转换 对象--json --------> 阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
resp.getWriter().write(notLogin);
return;
}
//6.令牌存在且解析成功,说明登陆过了,放行。
log.info("令牌合法, 放行");
chain.doFilter(request, response);
}
}
- 主启动类上加上
@ServletComponentScan
开启Servlet组件支持
- 添加阿里巴巴fastJSON依赖:用于转化为json
<!--fastJSON-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>
-
把之前测试功能的过滤器注释掉
-
使用Postman进行debug测试:
- 登录功能:可以看到使用登录功能时直接放行,之后点击放行按钮可以看到返回令牌字符串。
- 测试部门查询功能:在Postman中添加请求头token将令牌携带到服务端,可以看到不是登录请求会接着执行下面的代码,最终放行后查询到了部门信息
- 篡改令牌测试返回错误信息:发现执行了令牌解析失败的代码,在postman中可以看到返回的错误信息。
其中NOT_LOGIN是我们和前后端约定好的一个标识,前端看到这个标识后就会自动的跳转到登录页面
- 登录功能:可以看到使用登录功能时直接放行,之后点击放行按钮可以看到返回令牌字符串。
-
前后端联调测试:
- 刷新浏览器查询所有的部门信息,此时没有登录会强制我们跳转到登录页面。
- 点击登录,此时已经登录后携带的有令牌会放行,所以查询成功。
- 刷新浏览器查询所有的部门信息,此时没有登录会强制我们跳转到登录页面。
6.2.5 拦截器Interceptor(令牌校验)
- 概念:是一种动态拦截方法调用的机制,类似于过滤器。Spring框架中提供的,用来动态拦截控制器方法的执行。
- 作用:拦截请求,在指定的方法调用前后,根据业务需要执行预先设定的代码。
1)快速入门
步骤:
- 定义拦截器,实现Handlerlnterceptor接口,并重写其所有方法。
- 注册拦截器
案例测试:
- 创建拦截器
package com.cn.interceptor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override //目标资源方法运行前运行, 返回true: 放行, 放回false, 不放行
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, 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...");
}
}
- 配置拦截器
package com.cn.config;
import com.cn.interceptor.LoginCheckInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration //标识当前类是一个配置类
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginCheckInterceptor loginCheckInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
/*注册拦截器,参数为之前创建的拦截器对象。因为拦截器交给了spring管理所以直接注入即可.
如果没有在拦截器的类上添加@Component注解,此时使用new的方式即可。
*/
registry.addInterceptor(loginCheckInterceptor)
//指定需要拦截的资源,拦截所有在过滤器器是/*,在拦截器中是/**。
.addPathPatterns("/**");
}
}
- 使用Postman测试登录功能:
- 把原先的过滤器的方式注释掉
- 由于在拦截器的preHandle方法中配置的为true放行,所以会返回登陆成功后的下发令牌信息
- 控制台:目标方法和3个方法都执行了
- 设置返回值为false不放行,使用postman进行登录访问测试,查看控制台可以看到知识输出了preHandle,目标方法和之后的2个方法都没有执行。
- 把原先的过滤器的方式注释掉
2)详解:拦截路径、过滤器拦截器执行流程
-
拦截路径:
-
执行流程
3)登录校验-Interceptor(运用到项目中)
- 创建拦截器:
package com.cn.interceptor;
import com.alibaba.fastjson.JSONObject;
import com.cn.pojo.Result;
import com.cn.utils.JwtUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override //目标资源方法运行前运行, 返回true: 放行, 放回false, 不放行
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception {
//1.获取请求url。这个地方传递过来的就是HttpServletRequest对象,不需要在强转了
String url = req.getRequestURL().toString();
log.info("请求的url: {}",url);
//这一步操作因为在拦截器配置类中,设置了不拦截登录请求,所以加不加都可以。
//2.判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if(url.contains("login")){
log.info("登录操作, 放行...");
return true;
}
//3.获取请求头中的令牌(token)。
String jwt = req.getHeader("token");
//4.判断令牌是否存在,如果不存在,返回错误结果(未登录)。
if(!StringUtils.hasLength(jwt)){
log.info("请求头token为空,返回未登录的信息");
Result error = Result.error("NOT_LOGIN");
//手动转换 对象--json --------> 阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
resp.getWriter().write(notLogin);
//令牌不存在说明没有登录不能放行,放行的话回去执行Controller的接口方法了
return false;
}
//5.解析token,如果解析失败,返回错误结果(未登录)。
try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {//jwt解析失败
e.printStackTrace();
log.info("解析令牌失败, 返回未登录错误信息");
Result error = Result.error("NOT_LOGIN");
//手动转换 对象--json --------> 阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
resp.getWriter().write(notLogin);
//同样令牌解析失败说明没有登录不能放行,放行的话回去执行Controller的接口方法了
return false;
}
//6.放行。
log.info("令牌合法, 放行");
//令牌存在且解析正确说明已经登录,请求放行
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...");
}
}
- 配置拦截器:
package com.cn.config;
import com.cn.interceptor.LoginCheckInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration //标识当前类是一个配置类
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginCheckInterceptor loginCheckInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
/*注册拦截器,参数为之前创建的拦截器对象。因为拦截器交给了spring管理所以直接注入即可.
如果没有在拦截器的类上添加@Component注解,此时使用new的方式即可。
*/
registry.addInterceptor(loginCheckInterceptor)
//指定需要拦截的资源,拦截所有在过滤器器是/*,在拦截器中是/**。
.addPathPatterns("/**")
//配置不需要拦截的路径
.excludePathPatterns("/login");
}
}
- 使用Postman访问测试:
-
注册掉原先使用过滤器的方式
-
登录功能:可以看到返回值拿到了令牌
-
查询功能:添加请求头token,每次发送请求都会携带令牌信息,令牌存在且解析正确所以放行,可以看到查询成功。
-
- 前后端联调:之前使用过滤器已经保存了token说明已经登陆过了,所以现在想要测试需要先把token删除掉,在刷新浏览器,可以看到在没有登录的情况下跳转到登录页面。之后点击登录可以看到查询部门信息成功。
7 异常处理-全局异常处理类
7.1 问题分析&异常执行流程&解决办法
当前项目中存在的问题:
- 问题一:没有做异常处理
- 在部门管理模块新增销售部部门,由于数据库表中已经存储了销售部,在数据库设计部门表时,规定了部门名称字段为唯一约束,所以此时在控制台会报异常。
- 在部门管理模块新增销售部部门,由于数据库表中已经存储了销售部,在数据库设计部门表时,规定了部门名称字段为唯一约束,所以此时在控制台会报异常。
- 问题二:异常结果的返回值不符合开发规范
- F12打开开发者工具,查看出现异常后服务端返回给前端的数据格式是一个json格式,这个json格式并不是我们开发规范中说规定的统一响应结果Result(接口文档中规定的),而仅仅是封装了错误的相关信息。由于返回的数据不符合开发规范,所以前端并不能解析这个数据。
- F12打开开发者工具,查看出现异常后服务端返回给前端的数据格式是一个json格式,这个json格式并不是我们开发规范中说规定的统一响应结果Result(接口文档中规定的),而仅仅是封装了错误的相关信息。由于返回的数据不符合开发规范,所以前端并不能解析这个数据。
思考:程序开发过程中不可避免的会遇到异常现象,此时在当前项目中我们并未做异常处理,那么一旦出现异常项目的流程是如何执行的呢???
- 控制层调用业务层,业务层调用数据层,如果Mapper接口此时在操作数据库出现异常,那么这个异常会向上抛出,谁调用Mapper就抛给谁。此时抛给Service层,service层未做任何处理出现异常会继续抛给Controller,Controller也没有做任何异常处理,所以最终异常会抛给框架,框架就会给我们返回一个json类型的错误信息数据,但是这个json类型的数据并不符合我们接口文档中规定的开发规范。
出现异常的解决方案:
-
方案一(不推荐):由于最终异常会抛给控制层的代码,所以只需要在每一个控制层方法中做异常处理(try-catch)就行了。
- 缺点:在每个控制层方法中都要进行try-catch操作,这样
非常麻烦而且代码臃肿
。
- 缺点:在每个控制层方法中都要进行try-catch操作,这样
-
方案二(推荐):全局异常处理器,只要在项目中定义全局异常处理器就可以处理项目中所有的异常。
- 优点:更加简单、优雅
- 使用全局异常处理器执行顺序:
- 没有出现异常:此时会走Controller中响应给前端一个正确的结果。
- 出现异常:Mapper中出现异常不用处理抛给Service,Service出现异常不用处理抛给Controller,Controller出现异常不用处理最终交给全局异常处理器来处理,全局异常处理器处理完成后在给前端响应统一响应的结果Result,在Result中封装错误信息。
7.2 测试(运用到项目中)
- 创建全局异常处理器:
package com.cn.exception;
import com.cn.pojo.Result;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理器:
* 定义一个类添加@RestControllerAdvice注解,此时就代表我们定义了一个全局异常处理器
* @RestControllerAdvice = @ControllerAdvice + @ResponseBody 所以它会将方法的返回值转化为json在
* 返回,符合接口文档中规定的需要返回json类型的数据格式。
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/*在全局异常处理器中定义一个方法来捕获异常,在此方法上通过@ExceptionHandler注解
的value属性来设置需要捕获那些异常*/
@ExceptionHandler(Exception.class)//捕获所有异常
public Result ex(Exception ex){
/*捕获到异常后,就可以在这个方法中处理异常,这里只是简单地输出了下异常
的堆栈信息,然后响应一个标准的Result,在Result中封装错误的提示信息,
最终将result响应给前端*/
ex.printStackTrace();//输出异常的堆栈信息
return Result.error("对不起,操作失败,请联系管理员");
}
}
-
运行测试:再次向部门表中添加已经存在的部门数据,可以看到页面提示出来了错误信息,控制台输出异常的堆栈信息。
-
在浏览器的开发者工具中,可以看到响应回来的数据是一个标准的Result,这个结果前端是可以解析出来的,所以前端拿到这个错误信息后就直接把这个错误的提示信息提示出来了。
8 事务管理
8.1 事务回顾
8.2 Spring事务管理-完善删除部门功能
8.2.1 删除功能存在的问题
-
案例需求:删除部门,同时删除该部门下的员工。
-
说明:部门解散了,我们不仅要把部门删除了,还需要把该部门下对应的员工数据删除。
-
当前项目中的删除部门功能,只是完成了根据id删除部门数据,并没有删除该部门下的员工数据,这个逻辑是不完整的。如果仅仅是把部门删除了那么部门下的员工还会关联着这个部门,此时就会造成数据的不完整不一致。
-
改造代码:添删除部门,同时删除该部门下的员工数据的代码。
- 部门业务层:
- 部门业务层:
@Autowired
private DeptMapper deptMapper;
@Autowired
private EmpMapper empMapper;
@Override
public void delete(Integer id) {
//根据id删除部门数据
deptMapper.deleteById(id);
//根据部门ID删除该部门下的员工
empMapper.deleteByDeptId(id);
}
- 员工数据层:
/**
* 根据部门ID删除该部门下的员工数据
* @param deptId
*/
@Delete("delete from emp where dept_id = #{deptId}")
void deleteByDeptId(Integer deptId);
- 运行测试:
正常执行流程
-
删除之前的部门表和对应的员工表数据:
-
启动项目,删除人事部
-
删除之前的部门表和对应的员工表数据:可以看到部门数据和部门对应的员工数据都删除了。
-
8.2.2 演示不使用事务出现的问题
演示异常执行流程:
-
业务层中添加异常代码:
-
删除前的部门表和员工表中的数据:
-
删除文学部:
-
删除后的部门表和员工表中的数据:发现部门表中的数据删除了,而部门对应的员工信息没有删除。
-
为什么会出现这种情况呢???
-
原因:删除部门成功后出现异常,
一旦程序出现异常后面的代码就不会在执行了
,所以部门对应的员工信息并未删除。(throws的方式不会执行,try-catch的方式会执行) -
解决:使用事务,让这两步操作处于同一个事务中,一个事务中的操作要么全部成功要么全部失败。
-
8.2.3 使用事务
- Spring框架已经把事务控制的代码封装好了,我们只需要使用@Transactional注解就可以完成事务的控制。
- @Transactional注解介绍:
- 位置:业务( service)层的方法上、类上、接口上
- 作用:将当前方法交给spring进行事务管理,方法执行前,开启事务; 成功执行完毕,提交事务; 出现异常,回滚事务
- 说明:
- 1):一般会把这个注解
添加到业务层中来控制事物
,因为在业务层中,一个业务功能中可能会包含多个数据访问的操作,这样就可以将多个数据访问操作控制在一个事务范围内。 - 2):
作用在方法上
表示当前方法交给Spring进行事务管理,作用在类上
表示当前类中的所有方法交给Spring进行事务管理,作用在接口上
表示当前接口的所有实现类中的所有实现方法交给Spring进行事务管理。 - 3)虽然说这三个位置都可以使用这个注解,但是通常来讲我们一般会加在业务层的增删改这一类的方法上。更准确的说是
加在业务层执行多次数据访问操作这一类的增删改方法上
。- 因为
查询操作
并不会影响数据的变更所以无需控制事务
,如果在业务层只是执行了一步的增删改操作
我们也不需要控制事务
,因为Mysql的数据库事务是自动提交的,我们DML(数据操作语言-crud)语句执行完后事务已经自动提交了,如果执行失败数据库中的数据也不会发生变化。
- 因为
- 1):一般会把这个注解
运行测试:删除学工部,发现删除操作出现异常事务会进行回滚,数据库表中的数据并未被删除掉。
- 删除学工部:
- 配置spring事务管理日志开关
#spring事务管理日志
logging:
level:
org.springframework.jdbc.support.JdbcTransactionManager: debug
- 可以看到一旦出现异常,事务进行了回滚,数据库表中的数据没有被删除。
删除之前:
删除之后:
8.3 事务进阶
8.3.1 rollbackFor:异常回滚
8.3.2 propagation:事务传播行为
1)概述
- 事务传播行为:指的就是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。
- 场景:有2个方法A、B,这两个方法都加了@Transactional注解代表这2个方法都具有事务,此时在A方法中去调用B方法事务是如何管理的呢???
- 问题:首先A方法在运行的时候会开启事务,之后在A方法中调用B方法,B方法也具有事务,那么此时B方法是加入到A方法这个事务中还是B方法在运行的时候新建一个事物????
- 解决:使用@Transactional注解的propagation属性指定传播行为。
- 常见的事务传播行为:
REQUIRED(默认)
:b方法在运行的时候需要事务,如果有事务就加入到事务中,没有就新创建一个事务。REQUIRES_NEW:
需要新事物,在调用b方法的时候无论a方法是否有事务,b方法都会在一个新的事务中运行。- SUPPORTS:在调用b方法的时候发现有事务就加入,如果在现有的环境下没有事务就在无事务的状态下运行。
- NOT_SUPPORTED: 不支持事务,如果在调用b方法的时候发现a已经存在事务了,那么他会先把这个事务挂起再去执行b方法,b方法运行完毕后在来执行当前事务。
- MANDATORY: 必须有事务,否则抛出异常
- NEWVER: 必须没有事务,否则抛出异常
- 只需要掌握2个常用的属性使用场景即可:
2)案例演示:日志操作
-
说明:解散部门是一个非常重要非常危险的操作,所以在业务当中要求每一次执行解散部门的操作时候,都需要留下痕迹 即:
无论执行成功了还是失败了都需要记录日志
。
-
第一步的操作已经完成过了,所以我们只需要完成第二步操作即可。
-
准备部门操作日志记录表:
CREATE TABLE dept_log(
id INT AUTO_INCREMENT COMMENT '主键ID' PRIMARY KEY,
create_time DATETIME NULL COMMENT '操作时间',
description VARCHAR(300) NULL COMMENT '操作描述'
)COMMENT '部门操作日志表';
- 日志pojo
package com.cn.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DeptLog {
private Integer id;
private LocalDateTime createTime;
private String description;
}
- 部门实现类DeptServiceImpl:删除功能
package com.cn.service.impl;
import com.cn.mapper.DeptMapper;
import com.cn.mapper.EmpMapper;
import com.cn.pojo.Dept;
import com.cn.pojo.DeptLog;
import com.cn.service.DeptLogService;
import com.cn.service.DeptService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
*部门
*/
@Service
public class DeptServiceImpl implements DeptService {
@Autowired
private DeptMapper deptMapper;
@Autowired
private EmpMapper empMapper;
@Autowired
private DeptLogService deptLogService;
@Transactional(rollbackFor = Exception.class) //spring事务管理
@Override
public void delete(Integer id) {
/*因为要求日志操作无论程序是否执行成功,都需要记录日志,所以放到finally当中,
又因为异常已经统一处理过了,所以不需要catch代码部分 */
try {
//根据id删除部门数据
deptMapper.deleteById(id);
//模拟异常
int i = 1/0;
//根据部门ID删除该部门下的员工
empMapper.deleteByDeptId(id);
} finally {
//记录删除操作的日志
DeptLog deptLog = new DeptLog();
deptLog.setCreateTime(LocalDateTime.now());
deptLog.setDescription("执行了解散部门的操作,此次解散的是"+id+"号部门");
deptLogService.insert(deptLog);
}
}
}
- 日志业务层
package com.cn.service;
import com.cn.pojo.DeptLog;
public interface DeptLogService {
void insert(DeptLog deptLog);
}
- 日志业务层实现类
package com.cn.service.impl;
import com.cn.mapper.DeptLogMapper;
import com.cn.pojo.DeptLog;
import com.cn.service.DeptLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class DeptLogServiceImpl implements DeptLogService {
@Autowired
private DeptLogMapper deptLogMapper;
@Transactional
@Override
public void insert(DeptLog deptLog) {
deptLogMapper.insert(deptLog);
}
}
- 日志数据层
package com.cn.mapper;
import com.cn.pojo.DeptLog;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DeptLogMapper {
@Insert("insert into dept_log(create_time,description) values(#{createTime},#{description})")
void insert(DeptLog log);
}
3)运行测试
-
启动项目执行部门删除操作:发现出现异常后没有执行日志记录操作,之前要求是无论部门删除成功还是失败都会被记录日志,为此记录日志的代码放到了finally中,那么为什么日志没有记录成功呢????
-
原因:
- 因为DeptServiceImpl这个部门删除的方法中使用了@Transactional注解,DeptLogServiceImpl这个记录日志的方法中也是用了@Transactional注解,也就说明这两个方法都是事务方法。
- 日志记录的方法使用的是默认的事务传播行为属性
@Transactional(propagation = Propagation.REQUIRED)
,表示部门删除的方法在调用日志记录的方法时候,如果A方法有事务那么B方法加入到A的事务中,如果A方法没有事务那么B方法开启一个新的事物,所以这里显然是日志记录的方法加入到了删除部门的事务方法中。 - 删除部门的操作一旦出现异常,后续的代码不会执行,由于日志记录代码放在finallly中所以会执行,但是删除部门的操作、删除部门对应的员工、日志操作此时都在一个事务中,一旦事务中出现了异常会进行回滚,所以不但删除部门的操作会进行回滚,日志操作也跟着回滚了。
-
解决:
- 使用
@Transactional(propagation = Propagation.REQUIRES_NEW)
:需要新事物,在调用b方法的时候无论a方法是否有事务,b方法都会在一个新的事务中运行。 - 此时a方法和b方法分别处于不同的事务中,a方法出现异常会进行回滚,而b方法处于另个事务中不会受到影响,
- 也就是说删除部门操作时进行了回滚,不会影响到放在finally中必定执行的日志记录代码。
- 使用
-
修改注解,再次运行测试:发现即便删除操作出现异常,仍然会进行日志记录操作。
9 AOP
9.1 AOP基础-概述
-
AOP
:Aspect Oriented Programming (面向切面编程、面向方面编程
),其实就是面向特定方法编程。 -
场景: 在一个项目中开发了很多功能,但是有一些功能的运行速度比较慢,所以现在需要优化这个项目。那么第一步就需要先定位出执行比较耗时的业务方法,然后在针对这些业务方法进行优化。
传统方式
:统计项目中的每一个业务方法的耗时,可以在每一个业务方法运行之前记录这个方法的开始时间,在这个方法运行完毕后再来记录这个方法的结束时间,然后那这个方法的结束时间减去方法的开始时间就是这个方法的执行耗时。缺点
:一个项目中包含很多的业务模块,每一个业务模块都会包含很多增删改查的方法,这样修改每一个业务方法的代码来记录时间,会变得非常繁琐。
使用AOP的方式
:可以做到再不改变原始方法的基础上来针对原始方法进行编程,这个编程可以是对原始方法的增强也可以是改变原始方法的功能。此时想要统计各个方法的耗时的需求只需要定义一个模版方法,然后将记录方法执行耗时的这一部分公共的逻辑代码定义在这个模版方法当中,在这个方法开始运行之前记录方法开始运行的时间,在方法执行之后记录方法的结束时间,中间来运行原始的业务方法。使用AOP的执行流程
:假如现在项目要进行查询所有的部门,此时就会调用部门管理中的list方法,但是它并不会直接执行这个原始的list方法,而是会自动执行这个模版方法,模版方法在运行的时候就会先去统计这个方法运行的开始时间,然后在来执行原始的业务方法list方法,原始方法执行完毕后在来执行方法的结束时间,结束时间减去开始时间就是原始方法的执行耗时。
-
实现:
- 动态代理是面向切面编程最主流的实现。而SpringAOP是Spring框架的高级技术,旨在管理bean对象的过程中,主要通过底层的
动态代理机制,对特定的方法进行编程。
- 动态代理是面向切面编程最主流的实现。而SpringAOP是Spring框架的高级技术,旨在管理bean对象的过程中,主要通过底层的
-
AOP常见的使用场景:
-
优势:
9.2 AOP基础-快速入门
需求:统计各个业务层方法执行耗时
步骤:
- 导入依赖:在pom.xml中导入AOP的依赖
- 编写AOP程序:针对于特定方法根据业务需要进行编程
案例测试:
- 准备一个Springboot项目,把之前项目中部门管理模块的crud功能复制过来,用来演示AOP的相关知识,这样就可以保证之前项目开发的工程比较干净。
- 在pom文件中引入AOP的相关依赖
<!--AOP-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
- 编写AOP程序:
package com.itheima.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Slf4j
@Component //把当前类交给Spring容器去管理
@Aspect //标识当前类是一个AOP类
public class TimeAspect {
//切入点表达式:指定当前这一部分共性逻辑的代码会作用在哪些方法上
//表示service下的所有接口或者类里面的所有方法,都会运行这个公共方法所封装的公共逻辑代码
@Around("execution(* com.itheima.service.*.*(..))")
public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
//1. 记录开始时间
long begin = System.currentTimeMillis();
/* 2. 调用原始方法运行
* 需要借助AOP给我们提供的一个Api,在方法的行参中声明一个ProceedingJoinPoint类型
* 的参数,之后调用proceed方法就代表运行原始方法。
* 原始方法运行过程中可能会出现异常,所以我们需要处理,这里使用throws抛出。
* 原始方法在运行的时候会有返回值,所以需要使用Object类型来接收。
* 现在方法执行完毕后只是统计方法的执行耗时,所以需要把原始方法的返回值返回回去。
* */
Object result = joinPoint.proceed();
//3. 记录结束时间, 计算方法执行耗时
long end = System.currentTimeMillis();
/*joinPoint这个对象会封装原始方法的相关信息,所以可以调用getSignature()
方法来获取原始方法的签名,这样就可以知道是那个方法执行的耗时时间。
*/
log.info(joinPoint.getSignature()+"方法执行耗时: {}ms", end-begin);
return result;
}
}
- 运行测试:发现只要执行部门操作中的业务,都会进行记录耗时操作。
9.3 AOP基础-核心概念
- 常用术语说明:
- AOP执行流程:在程序运行的时候会自动的基于动态代理技术,为这个目标对象生成一个代理对象,在这个代理对象中对目标对象进行功能的增强,
之后在程序注入的时候就不再是注入目标对象了而是注入了代理对象
,在调用list方法之后其实就是调用的代理对象中的list方法,而这个方法已经进行了功能的增强、
9.4 AOP进阶-通知类型
9.4.1 五大类型
- 类型:
- 注意事项:
9.4.2 案例测试
没有异常执行:
AOP代码
:
package com.itheima.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@Aspect
public class MyAspect1 {
@Before("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
public void before(){
log.info("before ...");
}
@Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
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.impl.DeptServiceImpl.*(..))")
public void after(){
log.info("after ...");
}
@AfterReturning("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
public void afterReturning(){
log.info("afterReturning ...");
}
@AfterThrowing("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
public void afterThrowing(){
log.info("afterThrowing ...");
}
}
运行测试
:发现除了异常通知没有执行,其它通知都执行了。
有异常执行:
添加异常代码
:在更新功能的业务层添加
运行测试
:可以看到异常通知执行了,后置通知、环绕后通知(出现异常代码就不会在向下执行了)的代码没有执行。
9.4.3 案例测试-切入点表达式优化
- 问题:当前切面当中通知的切入点表达式都是相同的,如果将来要变动每个通知方法都要修改。
- 解决:可以进行抽取。
测试:
修改AOP代码
:
package com.itheima.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@Aspect
public class MyAspect1 {
/**
* 相同切入点抽取:1.定义一个无返回值类型、无参、无方法体的方法,名字随意,
* 使用@Pointcut注解描述,值为切入点表达式
* 2.之后在具体使用的切入点表达式中引用方法名即可
*/
@Pointcut("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
public void pt(){}
@Before("pt()")
public void before(){
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(){
log.info("after ...");
}
@AfterReturning("pt()")
public void afterReturning(){
log.info("afterReturning ...");
}
@AfterThrowing("pt()")
public void afterThrowing(){
log.info("afterThrowing ...");
}
}
运行测试
:仍能正确执行通知
9.4.4 在其它切面类中引用
说明:
- 这个抽取的切入点表达式不单单在当前切面类中引用,还可以在其它切面类中引用,前提是这个抽取切入点的方法设置为public。
测试:
-
设置抽取切入点的方法为public
-
在别的切面类中引用
-
运行测试:发现耗时功能正确执行,说明引用成功。
9.5 AOP进阶-通知顺序
- 当有多个切面的切入点都匹配到了目标方法,目标方法运行时,多个通知方法都会被执行。
测试:
- 为了输出的结果更加干净,把之前测试的AOP注释掉
- 添加3个切面类:在每个切面类当中所定义的通知方法基本相同,只是输出的日志标识不同,每个通知的切入点表达式相同。
- 查询操作
9.6 AOP进阶-切入点表达式
9.6.1 分类
- 切入点表达式:描述切入点方法的一种表达式
- 作用:主要用来决定项目中的哪些方法需要加入通知
- 常见形式:
execution(....)
︰根据方法的签名来匹配
@annotation(...)
︰根据注解匹配
9.6.2 execution-根据方法的签名匹配
- 方法的参数:为目标方法参数类型的全类名
测试:
- 注释掉其它切面类,为了更好的显示测试结果
- 常见的切入点表达式写法:
package com.itheima.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
//切面类
@Slf4j
@Aspect
@Component
public class MyAspect6 {
//完整写法:
//@Pointcut("execution(public void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))")
//省略权限修饰符:
//@Pointcut("execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))")
//省略权限修饰符、包名类名: 但是包名.类名不建议省略,因为匹配的范围过大影响效率
//@Pointcut("execution(void delete(java.lang.Integer))")
//基于接口匹配,上面的都是基于实现类匹配:
//@Pointcut("execution(void com.itheima.service.DeptService.delete(java.lang.Integer))")
//使用通配符:*
//@Pointcut("execution(void com.itheima.service.DeptService.*(java.lang.Integer))")
//@Pointcut("execution(* com.*.service.DeptService.*(*))")
//@Pointcut("execution(* com.itheima.service.*Service.delete*(*))")
//使用通配符:..
//@Pointcut("execution(* com.itheima.service.DeptService.*(..))")
//@Pointcut("execution(* com..DeptService.*(..))") com开头任意层级下的包
//@Pointcut("execution(* com..*.*(..))") //com开头任意层级下的包中,所有类中的所有方法
//@Pointcut("execution(* *(..))") //慎用 当前环境下的所有方法,方法的形参也是任意的
// 使用一个切入点表达式匹配2个方法:这2个方法的返回值不同,方法名也没有相同的前缀后缀,一个是有参的一个是无参的
// 解决:一个一个的匹配,中间使用||连接,表示满足其中一个表达式都会运行通知
@Pointcut("execution(* com.itheima.service.DeptService.list()) || " +
"execution(* com.itheima.service.DeptService.delete(java.lang.Integer))")
private void pt(){}
@Before("pt()")
public void before(){
log.info("MyAspect6 ... before ...");
}
}
- 注意:使用一个切入点表达式匹配2个不同的方法的写法
9.6.3 @annotation-根据注解匹配
- 背景:像上面匹配多个无规则的方法,使用execution描述比较繁琐,可以使用@annotation-根据注解匹配。
- 创建注解
package com.itheima.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME) //注解什么时候生效
@Target(ElementType.METHOD) //注解作用在哪些位置上
public @interface MyLog {
}
- 在目标方法上添加此注解
- 编写切面类
package com.itheima.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
//切面类
@Slf4j
@Aspect
@Component
public class MyAspect7 {
//匹配DeptServiceImpl中的 list() 和 delete(Integer id)方法
//@Pointcut("execution(* com.itheima.service.DeptService.list()) || execution(* com.itheima.service.DeptService.delete(java.lang.Integer))")
@Pointcut("@annotation(com.itheima.aop.MyLog)") //指定注解的全类名,表示匹配方法上加的有MyLog这个注解的方法
private void pt(){}
@Before("pt()")
public void before(){
log.info("MyAspect7 ... before ...");
}
}
- 运行删除的测试方法:发现before前置通知执行了
9.7 AOP进阶-连接点
- 连接点:类中所有可以被增强的方法
- 在Spring中用
JoinPoint
抽象了连接点,用它可以获得方法执行时的相关信息,如目标类名、方法名、方法参数等。-
对于@Around通知,获取连接点信息只能使用ProceedingJoinPoint
-
对于其他四种通知,获取连接点信息只能使用JoinPoint,它是 ProceedingJoinPoint的父类型
-
测试:
- 切面类:
package com.itheima.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import java.util.Arrays;
//切面类
@Slf4j
@Aspect
@Component
public class MyAspect8 {
@Pointcut("execution(* com.itheima.service.DeptService.*(..))")
private void pt(){}
@Before("pt()")
public void before(JoinPoint joinPoint){ //注意是lang包下
log.info("MyAspect8 ... before ...");
}
//JoinPoint和ProceedingJoinPoint的APi都是一样的,所以以环绕通知为例
@Around("pt()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("MyAspect8 around before ...");
//1. 获取 目标对象的类名 .
String className = joinPoint.getTarget().getClass().getName();
log.info("目标对象的类名:{}", className);
//2. 获取 目标方法的方法名 .
String methodName = joinPoint.getSignature().getName();
log.info("目标方法的方法名: {}",methodName);
//3. 获取 目标方法运行时传入的参数 .
Object[] args = joinPoint.getArgs();
log.info("目标方法运行时传入的参数: {}", Arrays.toString(args));
//4. 放行 目标方法执行 .
Object result = joinPoint.proceed();
//5. 获取 目标方法运行的返回值 .
log.info("目标方法运行的返回值: {}",result);
log.info("MyAspect8 around after ...");
return result;
}
}
- 运行测试方法:发现取到了目标方法的相关信息
9.8 改造项目-记录操作日志
9.8.1 分析
说明:
- 之前:在事务中记录的是解散部门的日志操作,因为解散部门是一个非常重要非常危险的操作,所以在业务当中要求每一次执行解散部门的操作时候,都需要留下痕迹。
- 现在的业务需求:
- 思路分析:
- 实现步骤:
9.8.2 具体实现
准备:
- 添加依赖:
<!--AOP-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
- 导入资料当中准备好后的数据表结构,并引入对应的实体类,Mapper接口:
数据库文件:
-- 操作日志表
create table operate_log(
id int unsigned primary key auto_increment comment 'ID',
operate_user 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 '返回值',
cost_time bigint comment '方法执行耗时, 单位:ms'
) comment '操作日志表';
实体类:
package com.cn.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {
private Integer id; //ID
private Integer operateUser; //操作人ID
private LocalDateTime operateTime; //操作时间
private String className; //操作类名
private String methodName; //操作方法名
private String methodParams; //操作方法参数
private String returnValue; //操作方法返回值
private Long costTime; //操作耗时
}
Mapper文件:
package com.cn.mapper;
import com.cn.pojo.OperateLog;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OperateLogMapper {
//插入日志数据
@Insert("insert into operate_log (operate_user, operate_time, class_name, method_name, method_params, return_value, cost_time) " +
"values (#{operateUser}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime});")
public void insert(OperateLog log);
}
编码:
- 定义注解
package com.cn.anno;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Log {
}
- 编写切面类
package com.cn.aop;
import com.alibaba.fastjson.JSONObject;
import com.cn.mapper.OperateLogMapper;
import com.cn.pojo.OperateLog;
import com.cn.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
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 javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.Arrays;
@Slf4j
@Component
@Aspect //切面类
public class LogAspect {
@Autowired
private HttpServletRequest request;
@Autowired
private OperateLogMapper operateLogMapper;
@Around("@annotation(com.cn.anno.Log)")
public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
/* 1.操作人ID - 当前登录员工ID:
* 思路分析:登陆成功后服务端会生成一个jwt令牌给前端,之后前端每次发送请求
* 都会通过请求头token携带令牌到服务端,而令牌当中包含了员工的信息,
* 所以我们可以通过请求头来获取令牌,然后在令牌中获取员工的id即可
*
* 请求头需要通过request对象获取:
* 之前:在控制层方法中可以直接声明一个HttpServletRequest类型的参数即可
* 现在:在切面类中获取request对象,而切面类当中通知方法的参数又不能随便定义,
* 可以直接 @Autowired注入这个HttpServletRequest对象。
* */
String jwt = request.getHeader("token");//获取请求头中的令牌
Claims claims = JwtUtils.parseJWT(jwt); //解析令牌,值为Map集合
Integer operateUser = (Integer) claims.get("id");//根据k获取v,结果为Object类型,所以可以强转为Integer
//2.操作时间
LocalDateTime operateTime = LocalDateTime.now();//当前时间
//3.操作类名
String className = joinPoint.getTarget().getClass().getName();
//4.操作方法名
String methodName = joinPoint.getSignature().getName();
//5.操作方法参数
Object[] args = joinPoint.getArgs();//获取的结果是数组
String methodParams = Arrays.toString(args);//pojo对象中使用的是String类型的变量接收,所以需要进行转化
long begin = System.currentTimeMillis();
//调用原始目标方法运行
Object result = joinPoint.proceed();
long end = System.currentTimeMillis();
//6.方法返回值:pojo对象中使用的是String类型接收的返回值,所以需要把获取的对象
// 转化为json格式的字符串存储起来。---通过阿里巴巴提供的工具包fastJSON
String returnValue = JSONObject.toJSONString(result);
//7.操作耗时
Long costTime = end - begin;
//记录操作日志: 通过有参构造给对象属性赋值,之后把数据插入到日志表当中。id设置的有主键自增所 以不需要获取
OperateLog operateLog = new OperateLog(null,operateUser,operateTime,className,methodName,methodParams,returnValue,costTime);
operateLogMapper.insert(operateLog);
log.info("AOP记录操作日志: {}" , operateLog);
return result;
}
}
- 在增、删、改的目标方法上添加注解:指定哪些方法在运行时需要记录日志
- 运行测试:点击新增,删除功能,可以看到在日志记录表中已经记录了日志操作。