一、项目开发规范
1.开发风格 Restful
案例是基于当前最为主流的前后端分离模式进行开发。
在前后端分离的开发模式中,前后端开发人员都需要根据提前定义好的接口文档,来进行前后端功能的开发。
什么是 REST 风格呢?
- REST(Representational State Transfer),表述性状态转换,它是一种软件架构风格。
传统 URL 风格如下:
- http://localhost:8080/user/getById?id=1 GET:查询 id 为 1 的用户
- http://localhost:8080/user/saveUser POST:新增用户
- http://localhost:8080/user/updateUser POST:修改用户
- http://localhost:8080/user/deleteUser?id=1 GET:删除 id 为 1 的用户
我们看到,原始的传统 URL 呢,定义比较复杂,而且将资源的访问行为对外暴露出来了。而且,对于开发人员来说,每一个开发人员都有自己的命名习惯,就拿根据 id 查询用户信息来说的,不同的开发人员定义的路径可能是这样的:getById
,selectById
,queryById
,loadById
... 。 每一个人都有自己的命名习惯,如果都按照各自的习惯来,一个项目组,几十号或上百号人,那最终开发出来的项目,将会变得难以维护,没有一个统一的标准。
基于 REST 风格 URL 如下:
- http://localhost:8080/users/1 GET:查询 id 为 1 的用户
- http://localhost:8080/users POST:新增用户
- http://localhost:8080/users PUT:修改用户
- http://localhost:8080/users/1 DELETE:删除 id 为 1 的用户
其中总结起来,就一句话:通过 URL 定位要操作的资源,通过 HTTP 动词(请求方式)来描述具体的操作。
在 REST 风格的 URL 中,通过四种请求方式,来操作数据的增删改查。
- GET : 查询
- POST :新增
- PUT : 修改
- DELETE :删除
我们看到如果是基于 REST 风格,定义 URL,URL 将会更加简洁、更加规范、更加优雅。
2.项目测试 Apifox
我们上面讲到,在这个案例中,我们将会基于 Restful 风格的接口进行交互,那么其中就涉及到常见的 4 中请求方式,包括:POST、DELETE、PUT、GET。
因为在浏览器地中所发起的所有的请求,都是 GET 方式的请求。那大家就需要思考两个问题:
- 前后端都在并行开发,后端开发完对应的接口之后,如何对接口进行请求测试呢?
- 前后端都在并行开发,前端开发过程中,如何获取到数据,测试页面的渲染展示呢?
那这里我们就可以借助一些接口测试工具,比如项:Postman、Apipost、Apifox 等。
那这些工具的使用基本类似,只不过 Apifox 工具的功能更强强大、更加完善,所以在课程中,我们会采用功能更为强大的 Apifox 工具。
介绍:Apifox 是一款集成了 Api 文档、Api 调试、Api Mock、Api 测试的一体化协作平台。
作用:接口文档管理、接口请求测试、Mock 服务。
二、项目开发技巧
1.在接口文档中,明确要求该接口的请求方式为 GET,那么如何限制请求方式
在 controller 方法上使用,@RequestMapping 的衍生注解 @GetMapping。 该注解就是标识当前方法,必须以 GET 方式请求
@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.数据封装
在上述测试中,我们发现部门的数据中,id、name 两个属性是有值的,但是 createTime、updateTime 两个字段值并未成功封装,而数据库中是有对应的字段值的,这是为什么呢?
原因如下:
- 实体类属性名和数据库表查询返回的字段名一致,mybatis 会自动封装。
- 如果实体类属性名和数据库表查询返回的字段名不一致,不能自动封装。
解决方案:
- 手动结果映射
- 起别名
- 开启驼峰命名
1). 手动结果映射
在 DeptMapper 接口方法上,通过 @Results 及 @Result 进行手动结果映射。
@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 语句中,对不一样的列名起别名,别名和实体类属性名一样。
@Select("select id, name, create_time createTime, update_time updateTime from dept")
public List<Dept> findAll();
3). 开启驼峰命名(推荐)
如果字段名与属性名符合驼峰命名规则,mybatis 会自动通过驼峰命名规则映射。驼峰命名规则: abc_xyz => abcXyz
- 表中字段名:abc_xyz
- 类中属性名:abcXyz
在 application.yml 中做如下配置,开启开关。
mybatis:
configuration:
map-underscore-to-camel-case: true
要使用驼峰命名前提是 实体类的属性 与 数据库表中的字段名严格遵守驼峰命名。
3.简单参数接收
我们看到,在 controller 中,需要接收前端传递的请求参数。 那接下来,我们就先来看看在服务器端的 Controller 程序中,如何获取这类简单参数。 具体的方案有如下三种:
- 方案一:通过原始的
HttpServletRequest
对象获取请求参数
/**
* 根据ID删除部门 - 简单参数接收: 方式一 (HttpServletRequest)
*/
@DeleteMapping("/depts")
public Result delete(HttpServletRequest request){
String idStr = request.getParameter("id");
int id = Integer.parseInt(idStr);
System.out.println("根据ID删除部门: " + id);
return Result.success();
}
这种方案实现较为繁琐,而且还需要进行手动类型转换。 【项目开发很少用】
- 方案二:通过 Spring 提供的
@RequestParam
注解,将请求参数绑定给方法形参
@DeleteMapping("/depts")
public Result delete(@RequestParam("id") Integer deptId){
System.out.println("根据ID删除部门: " + deptId);
return Result.success();
}
@RequestParam
注解的 value 属性,需要与前端传递的参数名保持一致 。
@RequestParam 注解 required 属性默认为 true,代表该参数必须传递,如果不传递将报错。 如果参数可选,可以将属性设置为 false。
- 方案三:如果请求参数名与形参变量名相同,直接定义方法形参即可接收。(省略 @RequestParam)
@DeleteMapping("/depts")
public Result delete(Integer id){
System.out.println("根据ID删除部门: " + deptId);
return Result.success();
}
对于以上的这三种方案呢,我们推荐第三种方案。
4.json 参数接收
我们看到,在 controller 中,需要接收前端传递的请求参数。 那接下来,我们就先来看看在服务器端的 Controller 程序中,如何获取 json 格式的参数。
- JSON 格式的参数,通常会使用一个实体对象进行接收 。
- 规则:JSON 数据的键名与方法形参对象的属性名相同,并需要使用
@RequestBody
注解标识。
前端传递的请求参数格式为 json,内容如下:{"name":"研发部"}
。这里,我们可以通过一个对象来接收,只需要保证对象中有 name 属性即可。
如果在 mapper 接口中,需要传递多个参数,可以把多个参数封装到一个对象中。 在 SQL 语句中获取参数的时候,#{...}
里面写的是对象的属性名【注意是属性名,不是表的字段名】。
/**
* 保存部门
*/
@Insert("insert into dept(name,create_time,update_time) values(#{name},#{createTime},#{updateTime})")
void insert(Dept dept);
5.路径参数接收
/depts/1
,/depts/2
这种在 url 中传递的参数,我们称之为路径参数。 那么如何接收这样的路径参数呢 ?
路径参数:通过请求 URL 直接传递参数,使用{…}来标识该路径参数,需要使用 @PathVariable
获取路径参数。如下所示:
如果路径参数名与 controller 方法形参名称一致,@PathVariable
注解的 value 属性是可以省略的。
6.@RequestMapping
到此呢,关于基本的部门的增删改查功能,我们已经实现了。 我们会发现,我们在 DeptController
中所定义的方法,所有的请求路径,都是 /depts
开头的,只要操作的是部门数据,请求路径都是 /depts
开头。
那么这个时候,我们其实是可以把这个公共的路径 /depts
抽取到类上的,那在各个方法上,就可以省略了这个 /depts
路径。 代码如下:
一个完整的请求路径,应该是类上的 @RequestMapping 的 value 属性 + 方法上的 @RequestMapping 的 value 属性。
7.Mapper 接口方法定义
@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 对象中,那么就必须保证查询返回的字段名与属性名是一一对应
的。
8.分页查询
上述我们在 Mapper 接口中定义了接口方法,完成了查询所有员工及其部门名称的功能,是将数据库中所有的数据查询出来了。 试想如果数据库中的数据有很多(假设有几千几万条)的时候,将数据全部展示出来肯定不现实,那如何解决这个问题呢?
使用分页解决这个问题。每次只展示一页的数据,比如:一页展示 10 条数据,如果还想看其他的数据,可以通过点击页码进行查询。
而在员工管理的需求中,就要求我们进行分页查询,展示出对应的数据。 具体的页面原型如下:
要想从数据库中进行分页查询,我们要使用 LIMIT
关键字,格式为:limit 开始索引 每页显示的条数。
1). 查询第 1 页数据的 SQL 语句是:
select * from emp limit 0,10;
2). 查询第 2 页数据的 SQL 语句是:
select * from emp limit 10,10;
3). 查询第 3 页的数据的 SQL 语句是:
select * from emp limit 20,10;
观察以上 SQL 语句,发现: 开始索引一直在改变 , 每页显示条数是固定的
开始索引的计算公式: 开始索引 = (当前页码 - 1) * 每页显示条数
我们继续基于页面原型,继续分析,得出以下结论:
-
前端在请求服务端时,传递的参数
- 当前页码 page
- 每页显示条数 pageSize
-
后端需要响应什么数据给前端
- 所查询到的数据列表(存储到 List 集合中)
- 总记录数
后台给前端返回的数据包含:List 集合(数据列表)、total(总记录数)
而这两部分我们通常封装到 PageResult 对象中,并将该对象转换为 json 格式的数据响应回给浏览器。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult {
private Long total; //总记录数
private List rows; //当前页数据列表
}
原始方式:
通过查看接口文档:员工列表查询
请求路径:/emps
请求方式:GET
请求参数:跟随在请求路径后的参数字符串。 例:/emps?page=1&pageSize=10
响应数据:json 格式
1). EmpController
@Slf4j
@RequestMapping("/emps")
@RestController
public class EmpController {
@Autowired
private EmpService empService;
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page ,
@RequestParam(defaultValue = "10") Integer pageSize){
log.info("查询员工信息, page={}, pageSize={}", page, pageSize);
PageResult pageResult = empService.page(page, pageSize);
return Result.success(pageBean);
}
}
@RequestParam(defaultValue="默认值") //设置请求参数默认值
2). EmpService
public interface EmpService {
/**
* 分页查询
* @param page 页码
* @param pageSize 每页记录数
*/
PageResult page(Integer page, Integer pageSize);
}
3). EmpServiceImpl
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public PageResult page(Integer page, Integer pageSize) {
//1. 获取总记录数
Long total = empMapper.count();
//2. 获取结果列表
Integer start = (page - 1) * pageSize;
List<Emp> empList = empMapper.list(start, pageSize);
//3. 封装结果
return new PageResult(total, empList);
}
}
4). EmpMapper
@Mapper
public interface EmpMapper {
/**
* 查询总记录数
*/
@Select("select count(*) from emp e left join dept d on e.dept_id = d.id ")
public Long count();
/**
* 查询所有的员工及其对应的部门名称
*/
@Select("select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id limit #{start}, #{pageSize}")
public List<Emp> list(Integer start , Integer pageSize);
}
PageHelper 分页插件
前面我们已经完了基础的分页查询,大家会发现:分页查询功能编写起来比较繁琐。 而分页查询的功能是非常常见的,我们查询员工信息需要分页查询,将来在做其他项目时,查询用户信息、订单信息、商品信息等等都是需要进行分页查询的。
而分页查询的思路、步骤是比较固定的。 在 Mapper 接口中定义两个方法执行两条不同的 SQL 语句:
- 查询总记录数
- 指定页码的数据列表
在 Service 当中,调用 Mapper 接口的两个方法,分别获取:总记录数、查询结果列表,然后在将获取的数据结果封装到 PageBean 对象中。
大家思考下:在未来开发其他项目,只要涉及到分页查询功能(例:订单、用户、支付、商品),都必须按照以上操作完成功能开发
结论:原始方式的分页查询,存在着"步骤固定"、"代码频繁"的问题
解决方案:可以使用一些现成的分页插件完成。对于 Mybatis 来讲现在最主流的就是 PageHelper。
PageHelper 是第三方提供的 Mybatis 框架中的一款功能强大、方便易用的分页插件,支持任何形式的单标、多表的分页查询。
官网:https://pagehelper.github.io/
那接下来,我们可以对比一下,使用 PageHelper 分页插件进行分页 与 原始方式进行分页代码实现的上的差别。
-
Mapper 接口层:
- 原始的分页查询功能中,我们需要在 Mapper 接口中定义两条 SQL 语句。
- PageHelper 实现分页查询之后,只需要编写一条 SQL 语句,而且不需要考虑分页操作,就是一条正常的查询语句。
-
Service 层:
- 需要根据页码、每页展示记录数,手动的计算起始索引。
- 无需手动计算起始索引,直接告诉 PageHelper 需要查询那一页的数据,每页展示多少条记录即可。
代码实现
当使用了 PageHelper 分页插件进行分页,就无需再 Mapper 中进行手动分页了。 在 Mapper 中我们只需要进行正常的列表查询即可。在 Service 层中,调用 Mapper 的方法之前设置分页参数,在调用 Mapper 方法执行查询之后,解析分页结果,并将结果封装到 PageResult 对象中返回。
1). 在 pom.xml 引入依赖
<!--分页插件PageHelper-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
2). EmpMapper
/**
* 查询所有的员工及其对应的部门名称
*/
@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
@Override
public PageResult page(Integer page, Integer pageSize) {
//1. 设置分页参数
PageHelper.startPage(page,pageSize);
//2. 执行查询
List<Emp> empList = empMapper.list();
Page<Emp> p = (Page<Emp>) empList;
//3. 封装结果
return new PageResult(p.getTotal(), p.getResult());
}
注意:
- PageHelper 实现分页查询时,SQL 语句的结尾一定一定一定不要加分号(;).。
- PageHelper 只会对紧跟在其后的第一条 SQL 语句进行分页处理。
9.条件分页查询
完了分页查询后,下面我们需要在分页查询的基础上,添加条件。
通过员工管理的页面原型我们可以看到,员工列表页面的查询,不仅仅需要考虑分页,还需要考虑查询条件。 分页查询我们已经实现了,接下来,我们需要考虑在分页查询的基础上,再加上查询条件。
我们看到页面原型及需求中描述,搜索栏的搜索条件有三个,分别是:
- 姓名:模糊匹配
- 性别:精确匹配
- 入职日期:范围匹配
参数名称 | 是否必须 | 示例 | 备注 |
---|---|---|---|
name | 否 | 张 | 姓名 |
gender | 否 | 1 | 性别 , 1 男 , 2 女 |
begin | 否 | 2010/1/1 | 范围匹配的开始时间(入职日期) |
end | 否 | 2020/1/1 | 范围匹配的结束时间(入职日期) |
page | 是 | 1 | 分页查询的页码,如果未指定,默认为 1 |
pageSize | 是 | 10 | 分页查询的每页记录数,如果未指定,默认为 10 |
在原有分页查询的代码基础上进行改造。
1). 在 EmpController 方法中通过多个方法形参,依次接收这几个参数
@GetMapping
public Result page(EmpQueryParam empQueryParam){
log.info("分页查询: {}", empQueryParam);
PageResult pageResult = empService.page(empQueryParam);
return Result.success(pageResult);
}
@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). 修改 EmpService 及 EmpServiceImpl 中的代码逻辑
EmpService:
PageResult page(EmpQueryParam empQueryParam);
EmpServiceImpl:
@Override
public PageResult<Emp> page(EmpQueryParam empQueryParam) {
//1. 设置PageHelper分页参数
PageHelper.startPage(empQueryParam.getPage(), empQueryParam.getPageSize());
//2. 执行查询
List<Emp> empList = empMapper.list(empQueryParam);
//3. 封装分页结果
Page<Emp> p = (Page<Emp>)empList;
return new PageResult(p.getTotal(), p.getResult());
}
3). 调整 EmpMapper 接口方法
List<Emp> list(EmpQueryParam empQueryParam);
4). 新增 Mapper 映射文件EmpMapper.xml
<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>
所谓动态 SQL,指的就是随着用户的输入或外部的条件的变化而变化的 SQL 语句。
在这里呢,我们用到了两个动态 SQL 的标签:<if>
<where>
。 这两个标签的具体作用如下:
<if>
:判断条件是否成立,如果条件为 true,则拼接 SQL。
<where>
:根据查询条件,来生成 where 关键字,并会自动去除条件前面多余的 and 或 or。
测试时,需要注意传递的查询条件,有些查询条件查不到数据的,因为数据库没有符合条件的记录。
10.基于多表操作的封装
在新增员工的时候,在表单中,我们既要录入员工的基本信息,又要录入员工的工作经历信息。 员工基本信息,对应的表结构是 emp 表,员工工作经历信息,对应的表结构是 emp_expr 表,所以这里我们要操作两张表,往两张表中保存数据。
-
接口文档规定:
- 请求路径:/emps
- 请求方式:POST
- 请求参数:Json 格式数据
- 响应数据:Json 格式数据
准备 EmpExprMapper
接口及映射配置文件 EmpExprMapper.xml
,并准备实体类接收前端传递的 json 格式的请求参数。
1). EmpExprMapper 接口
@Mapper
public interface EmpExprMapper {
}
2). EmpExprMapper.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.itheima.mapper.EmpExprMapper">
</mapper>
3). 需要在 Emp
员工实体类中增加属性 exprList
来封装工作经历数据。 最终完整代码如下:
@Data
public class Emp {
private Integer id; //ID,主键
private String username; //用户名
private String password; //密码
private String name; //姓名
private Integer gender; //性别, 1:男, 2:女
private String phone; //手机号
private Integer job; //职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师
private Integer salary; //薪资
private String image; //头像
private LocalDate entryDate; //入职日期
private Integer deptId; //关联的部门ID
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间
//封装部门名称数
private String deptName; //部门名称
//封装员工工作经历信息
private List<EmpExpr> exprList;
}
@Data
public class EmpExpr {
private Integer id; //ID
private Integer empId; //员工ID
private LocalDate begin; //开始时间
private LocalDate end; //结束时间
private String company; //公司名称
private String job; //职位
}
保存员工基本信息
1). EmpController
在 EmpController
中增加 save 方法。
/**
* 添加员工
*/
@PostMapping
public Result save(@RequestBody Emp emp){
log.info("请求参数emp: {}", emp);
empService.save(emp);
return Result.success();
}
2). EmpService & EmpServiceImpl
在 EmpService
中增加 save 方法
/**
* 添加员工
* @param emp
*/
void save(Emp emp);
在 EmpServiceImpl
中增加 save 方法 , 实现接口中的 save 方法
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
//3. 保存员工的工作经历信息 - 批量 (稍后完成)
}
3). EmpMapper
在 EmpMapper
中增加 insert 方法,新增员工的基本信息。
/**
* 新增员工数据
*/
@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 中提供的主键返回功能来获取。
批量保存工作经历
一个员工,是可以有多段工作经历的,所以在页面上将来用户录入员工信息时,可以自己根据需要添加多段工作经历。页面原型展示如下:
实现
1). EmpServiceImpl
完善 save 方法中保存员工信息的逻辑。完整逻辑如下:
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
2). EmpExprMapper
@Mapper
public interface EmpExprMapper {
/**
* 批量插入员工工作经历信息
*/
public void insertBatch(List<EmpExpr> exprList);
}
3). EmpExprMapper.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.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:遍历结束后拼接的片段
上述的属性,是可选的,并不是所有的都是必须的。 可以自己根据实际需求,来指定对应的属性。
11.Controller 方法中传递参数为批量数据的处理情况
方式一:在 Controller 方法中通过数组来接收
多个参数,默认可以将其封装到一个数组中,需要保证前端传递的参数名 与 方法形参名称保持一致。
/**
* 批量删除员工
*/
@DeleteMapping
public Result delete(Integer[] ids){
log.info("批量删除部门: ids={} ", Arrays.asList(ids));
return Result.success();
}
方式二:在 Controller 方法中通过集合来接收
也可以将其封装到一个 List<Integer> 集合中,如果要将其封装到一个集合中,需要在集合前面加上 @RequestParam
注解。
/**
* 批量删除员工
*/
@DeleteMapping
public Result delete(@RequestParam List<Integer> ids){
log.info("批量删除部门: ids={} ", ids);
empService.deleteByIds(ids);
return Result.success();
}
两种方式,选择其中一种就可以,我们一般推荐选择集合,因为基于集合操作其中的元素会更加方便。
12.Mapper 中 sql 语句对于多表查询时一对多的处理方法
在Mapper.xml
配置文件中定义对应的 SQL
<!--自定义结果集ResultMap-->
<resultMap id="empResultMap" type="com.itheima.pojo.Emp">
<id column="id" property="id" />
<result column="username" property="username" />
<result column="password" property="password" />
<result column="name" property="name" />
<result column="gender" property="gender" />
<result column="phone" property="phone" />
<result column="job" property="job" />
<result column="salary" property="salary" />
<result column="image" property="image" />
<result column="entry_date" property="entryDate" />
<result column="dept_id" property="deptId" />
<result column="create_time" property="createTime" />
<result column="update_time" property="updateTime" />
<!--封装exprList-->
<collection property="exprList" ofType="com.itheima.pojo.EmpExpr">
<id column="ee_id" property="id"/>
<result column="ee_company" property="company"/>
<result column="ee_job" property="job"/>
<result column="ee_begin" property="begin"/>
<result column="ee_end" property="end"/>
<result column="ee_empid" property="empId"/>
</collection>
</resultMap>
<!--根据ID查询员工的详细信息-->
<select id="getById" resultMap="empResultMap">
select e.*,
ee.id ee_id,
ee.emp_id ee_empid,
ee.begin ee_begin,
ee.end ee_end,
ee.company ee_company,
ee.job ee_job
from emp e left join emp_expr ee on e.id = ee.emp_id
where e.id = #{id}
</select>
在这种一对多的查询中,我们要想成功的封装的结果,需要手动的基于 <resultMap>
来进行封装结果。
三、日志技术
1.概述
-
什么是日志?
- 日志就好比生活中的日记,可以随时随地记录你生活中的点点滴滴。
- 程序中的日志,是用来记录应用程序的运行信息、状态信息、错误信息的。
-
为什么要在程序中记录日志呢?
- 便于追踪应用程序中的数据信息、程序的执行过程。
- 便于对应用程序的性能进行优化。
- 便于应用程序出现问题之后,排查问题,解决问题。
- 便于监控系统的运行状态。
- ... ...
-
之前我们编写程序时,也可以通过
System.out.println(...)
来输出日志,为什么我们还要学习单独的日志技术呢?
这是因为,如果通过 System.out.println(...)
来记录日志,会存在以下几点问题:
- 硬编码。所有的记录日志的代码,都是硬编码,没有办法做到灵活控制,要想不输出这个日志了,只能删除掉记录日志的代码。
- 只能输出日志到控制台。
- 不便于程序的扩展、维护。
所以,在现在的项目开发中,我们一般都会使用专业的日志框架,来解决这些问题。
2.日志框架
- JUL: 这是 JavaSE 平台提供的官方日志框架,也被称为 JUL。配置相对简单,但不够灵活,性能较差。
- Log4j: 一个流行的日志框架,提供了灵活的配置选项,支持多种输出目标。
- Logback: 基于 Log4j 升级而来,提供了更多的功能和配置选项,性能由于 Log4j。
- Slf4j: (Simple Logging Facade for Java)简单日志门面,提供了一套日志操作的标准接口及抽象类,允许应用程序使用不同的底层日志框架。
3.LogBcak 入门
1). 准备工作:引入 logback 的依赖(springboot 中无需引入,在 springboot 中已经传递了此依赖)
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.11</version>
</dependency>
2). 引入配置文件logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度 %msg:日志消息,%n是换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>
<!-- 日志输出级别 -->
<root level="ALL">
<appender-ref ref="STDOUT" />
</root>
</configuration>
3). 记录日志:定义日志记录对象 Logger,记录日志
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("结束计算...");
}
}
运行单元测试,可以在控制台中看到输出的日志,如下所示:
我们可以看到在输出的日志信息中,不仅输出了日志的信息,还包括:日志的输出时间、线程名、具体在那个类中输出的。
4.Logback 配置文件
Logback 日志框架的配置文件叫 logback.xml
。
该配置文件是对 Logback 日志框架输出的日志进行控制的,可以来配置输出的格式、位置及日志开关等。
常用的两种输出日志的位置:控制台、系统文件。
1). 如果需要输出日志到控制台。添加如下配置:
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%msg表示日志消息,%n表示换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>
2). 如果需要输出日志到文件。添加如下配置:
<!-- 按照每天生成日志文件 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 日志文件输出的文件名, %i表示序号 -->
<FileNamePattern>D:/tlias-%d{yyyy-MM-dd}-%i.log</FileNamePattern>
<!-- 最多保留的历史日志文件数量 -->
<MaxHistory>30</MaxHistory>
<!-- 最大文件大小,超过这个大小会触发滚动到新文件,默认为 10MB -->
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%msg表示日志消息,%n表示换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>
3). 日志开关配置 (开启日志(ALL),取消日志(OFF))
<!-- 日志输出级别 -->
<root level="ALL">
<!--输出到控制台-->
<appender-ref ref="STDOUT" />
<!--输出到文件-->
<appender-ref ref="FILE" />
</root>
5.Loback 日志级别
日志级别指的是日志信息的类型,日志都会分级别,常见的日志级别如下(优先级由低到高):
日志级别 | 说明 | 记录方式 |
---|---|---|
trace | 追踪,记录程序运行轨迹 【使用很少】 | log.trace("...") |
debug | 调试,记录程序调试过程中的信息,实际应用中一般将其视为最低级别 【使用较多】 | log.debug("...") |
info | 记录一般信息,描述程序运行的关键事件,如:网络连接、io 操作 【使用较多】 | log.info("...") |
warn | 警告信息,记录潜在有害的情况 【使用较多】 | log.warn("...") |
error | 错误信息 【使用较多】 | log.error("...") |
可以在配置文件 logback.xml
中,灵活的控制输出那些类型的日志。(大于等于配置的日志级别的日志才会输出)
<!-- 日志输出级别 -->
<root level="info">
<!--输出到控制台-->
<appender-ref ref="STDOUT" />
<!--输出到文件-->
<appender-ref ref="FILE" />
</root>
6.案例日志记录
/**
* 部门管理控制器
*/
@Slf4j
@RequestMapping("/depts")
@RestController
public class DeptController {
@Autowired
private DeptService deptService;
/**
* 查询部门列表
*/
//@RequestMapping(value = "/depts", method = RequestMethod.GET)
@GetMapping
public Result list(){
//System.out.println("查询部门列表");
log.info("查询部门列表");
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}
/**
* 根据id删除部门 - delete http://localhost:8080/depts?id=1
*/
@DeleteMapping
public Result delete(Integer id){
//System.out.println("根据id删除部门, id=" + id);
log.info("根据id删除部门, id: {}" , id);
deptService.deleteById(id);
return Result.success();
}
/**
* 新增部门 - POST http://localhost:8080/depts 请求参数:{"name":"研发部"}
*/
@PostMapping
public Result save(@RequestBody Dept dept){
//System.out.println("新增部门, dept=" + dept);
log.info("新增部门, dept: {}" , dept);
deptService.save(dept);
return Result.success();
}
/**
* 根据ID查询 - GET http://localhost:8080/depts/1
*/
@GetMapping("/{id}")
public Result getById(@PathVariable Integer id){
//System.out.println("根据ID查询, id=" + id);
log.info("根据ID查询, id: {}" , id);
Dept dept = deptService.getById(id);
return Result.success(dept);
}
/**
* 修改部门 - PUT http://localhost:8080/depts 请求参数:{"id":1,"name":"研发部"}
*/
@PutMapping
public Result update(@RequestBody Dept dept){
//System.out.println("修改部门, dept=" + dept);
log.info("修改部门, dept: {}" , dept);
deptService.update(dept);
return Result.success();
}
}
lombok 中提供的 @Slf4j 注解,可以简化定义日志记录器这步操作。添加了该注解,就相当于在类中定义了日志记录器,就下面这句代码:
private static Logger log = LoggerFactory. getLogger(Xxx. class);
四、事务管理
1.介绍
概念: 事务是一组操作的集合,它是一个不可分割的工作单位。事务会把所有的操作作为一个整体一起向系统==提交或撤销==操作请求,==即这些操作 要么同时成功,要么同时失败。==
就拿添加员工的这个业务为例,在这个业务操作中,包含了两个操作,那这两个操作是一个不可分割的工作单位。这两个操作,要么同时失败,要么同时成功。
默认 MySQL 的事务是自动提交的,也就是说,当执行一条 DML 语句,MySQL 会立即隐式的提交事务。
2.操作
事务控制主要三步操作:开启事务、提交事务/回滚事务。
-
需要在这组操作执行之前,先==开启事务== (
start transaction; / begin;
)。 -
所有操作如果全部都执行成功,则==提交事务== (
commit;
)。如果这组操作中,有任何一个操作执行失败,都应该==回滚事务== (rollback
)。commit 与 rollback 两个操作是互斥的!!!
如果执行了第一行,再执行两条 insert 语句,去查看数据库表,是无法看到新增的条目的,需要在本控制台(事物的隔离性)执行 select 才能看到新增的条目
本控制台执行了 commit 之后,在数据库表里才可以看到新增的条目
3.Spring 事务管理
在 spring 框架当中就已经把事务控制的代码都已经封装好了,并不需要我们手动实现。==我们使用了 spring 框架,它已经对事务管理功能进行了封装,所以我们只需要通过一个简单的注解 @Transactional 就搞定了。==
4.Transactional 注解
作用: 就是在当前这个方法执行开始之前来==开启事务==,方法执行完毕之后==提交事务==。如果在这个方法执行的过程当中出现了异常,就会进行事务的==回滚操作==。
**位置:** 业务层的方法上、类上、接口上开启、提交和回滚都是 Spring 自动完成的
- 方法上:当前方法交给 spring 进行事务管理【推荐】
- 类上:当前类中所有的方法都交由 spring 进行事务管理
- 接口上:接口下所有的实现类当中所有的方法都交给 spring 进行事务管理
接下来,我们就可以在业务方法 save 上加上 @Transactional
来控制事务。
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);int i = 1/0;<sup>(执行到这里会抛出除零异常,所以后面对数据库的操作会失败)</sup> //3. 保存员工的工作经历信息 - 批量 Integer empId = emp.getId(); List<EmpExpr> exprList = emp.getExprList(); if(!CollectionUtils.isEmpty(exprList)){ exprList.forEach(empExpr -> empExpr.setEmpId(empId)); empExprMapper.insertBatch(exprList); }
}
@Transactional 注解:我们一般会在业务层当中来控制事务,因为在业务层当中,一个业务功能可能会包含多个数据访问的操作。在业务层来控制事务,我们就可以将多个数据访问操作控制在一个事务范围内。
说明:可以在 application.yml
配置文件中开启事务管理日志,这样就可以在控制看到和事务相关的、底层的日志信息了
#spring 事务管理日志
logging:
level:
org.springframework.jdbc.support.JdbcTransactionManager: debug(如果对全部类都设置成 debug,会有很多很多冗余信息,所以单独对这个类进行设置【其余已经设置成了默认 info,info 时是看不到 debug 级别的日志的】)
接下来,我们再次添加员工,看看控制台输出的日志信息。
添加 Spring 事务管理后,由于服务端程序引发了除零异常,所以事务进行回滚。
打开数据库,我们会看到 emp
表 与 emp_expr
表中都没有对应的数据信息,保证了数据的一致性、完整性。
5.事务进阶
前面我们通过 spring 事务管理注解 @Transactional 已经控制了业务层方法的事务。接下来我们要来详细的介绍一下 @Transactional 事务管理注解的使用细节。我们这里主要介绍 @Transactional 注解当中的两个常见的属性:
- 异常回滚的属性:
rollbackFor
- 事务传播行为:
propagation
我们先来学习下 rollbackFor 属性。
rollbackFor【配置出现何种异常时才回滚】
我们在之前编写的业务方法上添加了 @Transactional 注解,来实现事务管理
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
int i = 1/0;
//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
以上业务功能 save 方法在运行时,会引发除 0 的算术运算异常 (运行时异常) ,出现异常之后,由于我们在方法上加了@Transactional
注解进行事务管理,所以发生异常会执行 rollback 回滚操作,从而保证事务操作前后数据是一致的。
下面我们在做一个测试,我们==修改业务功能代码,在模拟异常的位置上直接抛出 Exception 异常(编译时异常)==
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);//模拟:异常发生 if(true){<sup>(这里是为了骗过编译器)</sup> throw new Exception("出现异常了~~~"); } //3. 保存员工的工作经历信息 - 批量 Integer empId = emp.getId(); List<EmpExpr> exprList = emp.getExprList(); if(!CollectionUtils.isEmpty(exprList)){ exprList.forEach(empExpr -> empExpr.setEmpId(empId)); empExprMapper.insertBatch(exprList); }
}
-
-
说明:在 service 中向上抛出一个 Exception 编译时异常之后,由于是 controller 调用 service,所以在 controller 中要有异常处理代码,此时我们选择在 controller 中继续把异常向上抛。
重新启动服务后,打开 Apifox 进行测试,请求添加员工的接口:
通过 Apifox 返回的结果,==我们看到抛出异常了。==然后我们在回到 IDEA 的控制台来看一下。
我们看到==数据库的事务居然提交了,并没有进行回滚。==
通过以上测试可以得出一个==结论:==**==默认情况下,@==**==Transactional 注解==**==只有出现 RuntimeException(运行时异常)才会回滚事务。==**
==假如我们想让所有的异常都回滚,需要来配置 @Transactional 注解当中的 rollbackFor 属性==,通过 rollbackFor 这个属性可以指定出现何种异常类型回滚事务。
==@Transactional(====rollbackFor(参数类型:<? extends Throwable>[] )== === Exception.class)==
@Override
public void save(Emp emp) throws Exception {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);//int i = 1/0; if(true){ throw new Exception("出异常啦...."); } //3. 保存员工的工作经历信息 - 批量 Integer empId = emp.getId(); List<EmpExpr> exprList = emp.getExprList(); if(!CollectionUtils.isEmpty(exprList)){ exprList.forEach(empExpr -> empExpr.setEmpId(empId)); empExprMapper.insertBatch(exprList); }
}
接下来我们重新启动服务,测试新增员工的操作:
==控制台日志,可以看到因为出现了异常又进行了事务回滚。==
结论:
- 在 Spring 的事务管理中,默认只有运行时异常 RuntimeException 才会回滚。
- 如果还需要回滚指定类型的异常,可以通过 rollbackFor 属性来指定。
propagation
我们接着继续学习 @Transactional 注解当中的第二个属性 propagation,这个属性是用来配置事务的传播行为的。
什么是事务的传播行为呢?
- 就是当==一个事务方法==被==另一个事务方法==调用时,这个事务方法应该如何进行事务控制。
例如:两个事务方法,一个 A 方法,一个 B 方法。在这两个方法上都添加了 @Transactional 注解,就代表这两个方法都具有事务,而在 A 方法当中又去调用了 B 方法。
所谓事务的传播行为,指的就是在 A 方法运行的时候,首先会开启一个事务,在 A 方法当中又调用了 B 方法, B 方法自身也具有事务,那么 B 方法在运行的时候,到底是加入到 A 方法的事务当中来,还是 B 方法在运行的时候新建一个事务?这个就涉及到了事务的传播行为。
我们要想控制事务的传播行为,在 @Transactional 注解的后面指定一个属性 propagation,通过 propagation 属性来指定传播行为。
接下来我们就来介绍一下常见的事务传播行为。
属性值 | 含义 |
---|---|
REQUIRED | 【默认值】需要事务,有则加入,无则创建新事务 |
REQUIRES_NEW | 需要新事务,无论有无,总是创建新事务 |
SUPPORTS | 支持事务,有则加入,无则在无事务状态中运行 |
NOT_SUPPORTED | 不支持事务,在无事务状态下运行,如果当前存在已有事务,则挂起当前事务 |
MANDATORY | 必须有事务,否则抛异常 |
NEVER | 必须没事务,否则抛异常 |
… | … |
对于这些事务传播行为,我们只需要关注以下两个就可以了:
REQUIRED(默认值)
REQUIRES_NEW
- REQUIRED: 大部分情况下都是用该传播行为即可。
- REQUIRES_NEW: 当我们不希望事务之间相互影响时,可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够记录成功。
案例
需求:在新增员工信息的时候,无论是成功还是失败,都记录操作日志
-
在 EmpServiceImpl 里注入 EmpLogService【不是注入 EmpLogMapper,因为现在要研究的是事务的传播行为,所以涉及到的两个方法都要被事务控制,而事务的注解要加在 Service 上】
-
在 EmpServiceImpl 的 save() 方法里调用 EmpLogService 的 insertLog()
但是!!!真正执行的时候会发现,如果在 try 里面加入了一个 int i=1/0;
即 try 执行过程中会抛出除零异常,通过控制台信息可以看到,接下来虽然会执行 EmpLog 的 insert 方法,但因为声明了事务注解,插入的条目最终会被回滚撤销
即执行完整个 save 方法后查看数据库是无法看到 EmpLog 里面有新增条目的
即不符合案例的需求!!!!!
原因如下
点拨:EmpLog 条目更新的操作不能和前面的业务操作在同一个事务里面,因为这样一回滚就全部都回滚了
因此,insert 操作不仅要加上事务注解,还得配置其传播属性为 Required_new,使得 insert 事务一成功就进行提交,其他事务的失败不会影响到这个操作
6.事务四大特性
面试题:事务有哪些特性?
- 原子性(Atomicity):事务是不可分割的最小单元,要么全部成功,要么全部失败。
- 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
- 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
- 持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。
事务的四大特性简称为:ACID
-
原子性(Atomicity) :原子性是指事务包装的一组 sql 是一个不可分割的工作单元,事务中的操作要么全部成功,要么全部失败。
-
一致性(Consistency) :一个事务完成之后数据都必须处于一致性状态。
- 如果事务成功的完成,那么数据库的==所有变化将生效。==
- 如果事务执行出现错误,那么数据库的==所有变化将会被回滚(撤销),返回到原始状态。==
-
隔离性(Isolation) :多个用户并发的访问数据库时,一个用户的事务不能被其他用户的事务干扰,多个并发的事务之间要相互隔离。
- 一个事务的成功或者失败对于其他的事务是没有影响。
-
持久性(Durability) :一个事务一旦被提交或回滚,它对数据库的改变将是永久性的,哪怕数据库发生异常,重启之后数据亦然存在。
五、文件上传
文件上传技术这块我们主要讲解三个方面:首先我们先对文件上传做一个整体的介绍,接着再学习文件上传的本地存储方式,最后学习云存储方式。
接下来我们就先来学习下什么是文件上传。
1.简介
文件上传,是指将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。
文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。
在我们的案例中,在新增员工的时候,要上传员工的头像,此时就会涉及到文件上传的功能。在进行文件上传时,我们点击加号或者是点击图片,就可以选择手机或者是电脑本地的图片文件了。当我们选择了某一个图片文件之后,这个文件就会上传到服务器,从而完成文件上传的操作。
前端代码形式如下:
上传文件的原始 form 表单(前端要上传文件,得定义 <from 表单 >),==要求表单必须具备以下三点(上传文件页面三要素):==
-
表单必须有 file 域(即有 type 为 file 的 input 标签),有了这个文件上传的表单项,才可以在本地电脑上选择要上传的文件
-
表单提交方式 method 必须为 POST(不能是 get,因为 get 上传的请求大小有限制):通常上传的文件会比较大,所以需要使用 POST 提交方式
-
表单的编码类型 enctype 必须要设置为:multipart/form-data:否则文件的内容传不到服务器端,普通默认的编码格式是不适合传输大型的二进制数据的,所以在文件上传时,表单的编码格式必须设置为 multipart/form-data
-
-
-
ps:action 属性说明往哪个路径上提交
8080:因为是直接访问 Tomcat【前提:启动了 application 项目】
上面前端测试页面的代码直接放在 resources/static(之前说过,如果想要放 html/js/css/图片等静态资源文件,得放在这里【创建 springboot 项目的时候可能有自带,如果被删了重新创建一个 directory 即可】) 目录下即可
服务端代码形式如下
package com.itheima.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;import java.io.File;
import java.io.IOException;@Slf4j
@RestController
public class UploadController {/** * 上传文件 - 参数名file */ @PostMapping("/upload") public Result upload(String username, Integer age , MultipartFile<sup>(服务器端要想接收前端传过来的文件,得定义这么一个形参)</sup> file<sup>(参数名得匹配到前端input标签的name属性)</sup>) 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:如果表单项的名字和方法中形参名不一致,该怎么办?
public Result upload(String username,
Integer age,
MultipartFile image) //image形参名和请求参数名file不一致
解决:使用 @RequestParam 注解进行参数绑定
public Result upload(String username,
Integer age,
@RequestParam("file") MultipartFile image)
2.本地存储
上面我们已经完成了文件上传最基本的功能实现,已经可以在服务端接收到上传的文件,并将文件保存在本地服务器的磁盘目录中了。 但是我们测试的时候发现,如果上传的文件名相同,后面上传的会覆盖前面上传的文件,那接下来,我们就要来优化这一块的功能。
代码实现如下:
package com.itheima.controller;
import com.itheima.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.util.UUID;@Slf4j
@RestController
public class UploadController {private static final String UPLOAD_DIR = "D:/images/"; // 上传文件 - 参数名file @PostMapping("/upload") public Result upload(MultipartFile file) <span data-type="text" style="color: var(--b3-font-color13);">throws Exception</span> { log.info("上传文件:{}, {}, {}", username, age, file<sup>(debug模式下可以看到有fileItem、location、filename属性【如下图】)</sup>); if (!file.isEmpty()) { // 生成唯一文件名 String originalFilename <sup>(获取这个只是为了动态的设置文件的后缀名)</sup>= file.getOriginalFilename(); String extName = originalFilename.substring(originalFilename.lastIndexOf(".")); String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName; // 拼接完整的文件路径 File targetFile = new File<sup>(这里参数传了文件路径和名称)</sup>(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();
//获取接收到的文件内容的输入流
在解决了文件名唯一性的问题后,我们再次上传一个较大的文件(超出 1M)时发现,后端程序报错:
报错原因呢,是因为:在 SpringBoot 中,文件上传时默认单个文件最大大小为 1M
那么如果需要上传大文件,可以在 application.yml
进行如下配置:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 100MB
到时此,我们文件上传的本地存储方式已完成了。但是这种本地存储方式还存在一问题:
如果直接存储在服务器的磁盘目录中,存在以下缺点:
- 不安全:磁盘如果损坏,所有的文件就会丢失
- 容量有限:如果存储大量的图片,磁盘空间有限(且磁盘也不可能无限制扩容)
- 无法直接访问:上传到服务器端的文件没办法在浏览器端直接地访问
为了解决上述问题呢,通常有两种解决方案:
- 自己搭建存储服务器(这个服务器往往也是以集群的形式搭建的),如:fastDFS 、MinIO
- 使用现成的云服务,如:阿里云,腾讯云,华为云
3.云服务存储(阿里云 OSS)
阿里云提供了下图等各种各样的、非常多的云服务
==阿里云对象存储服务 OSS==(Object Storage Service),是一款海量、安全、低成本、高可靠的云存储服务(前四大提供商:亚马逊、微软、谷歌、阿里云)。使用 OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。
在我们使用了阿里云 OSS 对象存储服务之后,我们的项目当中如果涉及到文件上传这样的业务,在前端进行文件上传并请求到服务端时,在服务器本地磁盘当中就不需要再来存储文件了。我们直接将接收到的文件上传到 oss,由 oss 帮我们存储和管理,同时阿里云的 oss 存储服务还保障了我们所存储内容的安全可靠。
那我们学习使用这类云服务,我们主要学习什么呢?其实我们主要学习的是如何在项目当中来使用云服务完成具体的业务功能。==而无论使用什么样的云服务,阿里云也好,腾讯云、华为云也罢,在使用第三方的服务时,操作的思路都是一样的。==
-
准备工作:注册账号、实名认证、登录后台系统进行充值(如果要存储的文件很小,是免费的; 阿里云提供的服务很多,这里我们要开通的话是开通对象存储服务 OSS)以开通服务、创建 OSS 的存储空间(Bucket)(就是对象存储的容器(这里的对象就是指文件))、获取并配置秘钥 AccessKey(阿里云提供的身份认证,将来在代码中要是用到了阿里云 OSS 就要配置这个秘钥)
-
-
-
第三方服务使用的通用思路,我们做一个简单介绍之后,接下来我们就来介绍一下我们当前要使用的阿里云 oss 对象存储服务具体的使用步骤。
1.入门
开通了 OSS:
创建秘钥:
在环境变量里配置秘钥:
至此,对象存储服务的准备工作我们已经完成了
接下来我们就来完成第二步操作:参照官方所提供的 sdk(software development kit 软件开发工具包,包括辅助软件开发的依赖(jar 包)、代码示例等,都可以叫做 sdk)示例来编写入门程序。
首先我们需要来打开阿里云 OSS 的官方文档,在官方文档中找到 SDK 的示例代码:
参照文档,引入依赖:
<!--阿里云OSS依赖-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.3</version>
</dependency>
参照文档,编写入门程序:
将官方提供的入门程序,复制过来,将里面的参数值改造成我们自己的即可。代码如下:
以下代码通过流式上传的方式将文件上传到 OSS
package com.itheima;
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 java.io.ByteArrayInputStream;
import java.io.File;
import java.nio.file.Files;public class Demo {
public static void main(String[] args) throws Exception { // Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。 String endpoint <sup>(地域节点:表示OSS中Bucket的域名)</sup>= "https://oss-cn-beijing.aliyuncs.com"; // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。 EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();<sup>(读取环境变量,所以要重启idea)</sup> // 填写Bucket名称,例如examplebucket。 String bucketName = "java-ai"; // 填写Object完整路径,<span data-type="text" style="color: var(--b3-font-color9);">例如exampledir/exampleobject.txt。</span>Object完整路径中不能包含Bucket名称。 String objectName = "001.jpg"; // 填写Bucket所在地域。以华东1(杭州)为例,Region填写为cn-hangzhou。 String region = "cn-beijing"; // 创建==OSSClient实例/OSS的客户端对象==。<sup>(这块可以不动,不同的sdk版本这块创建的方式也有差异)</sup> ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration(); clientBuilderConfiguration.setSignatureVersion(SignVersion.V4); OSS ossClient = OSSClientBuilder.create() .endpoint(endpoint) .credentialsProvider(credentialsProvider) .clientConfiguration(clientBuilderConfiguration) .region(region) .build(); try { File file = new File("C:\\\Users\\\deng\\\Pictures\\\1.jpg"); ==byte[] content = Files.readAllBytes(file.toPath()); // file文件转成字节数组,然后才能上传== ossClient.putObject<sup>(进行文件上传)</sup>(bucketName, objectName, new ByteArrayInputStream<sup>(一个输入流,用来上传文件)</sup>(content<sup>(参数是文件对应的字节数组)</sup>)); } 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 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(); } } }
}
切记,大家需要将上面的 endpoint
,bucketName
,objectName
,file
都需要改成自己的。
运行以上程序后,会把本地的文件上传到阿里云 OSS 服务器上。
-
-
2.集成
阿里云 oss 对象存储服务的准备工作以及入门程序我们都已经完成了,接下来我们就需要在案例当中==集成 oss,即通过阿里云来存储和管理案例中上传的图片。==
在新增员工的时候,上传员工的图像,而之所以需要上传员工的图像,是因为将来我们需要在系统页面当中访问并展示员工的图像。而要想完成这个操作,需要做两件事:
- 需要上传员工的图像,并把图像保存起来(存储到阿里云 OSS)
- 访问员工图像(通过图像在阿里云 OSS 的存储地址访问图像)
- OSS 中的每一个文件都会分配一个访问的 url,通过这个 url 就可以访问到存储在阿里云上的图片。所以需要把 url 返回给前端,这样前端就可以通过 url 获取到图像。
实现
1). application.yml
#阿里云OSS aliyun: oss: endpoint: https://oss-cn-beijing.aliyuncs.com bucketName: java-ai region: cn-beijing
2). AliyunOSSOperator
package ==com.itheima.utils; // 这个工具类放在 utils 包下==
import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;@Component
public class AliyunOSSOperator {//方式一: 通过@Value注解一个属性一个属性的注入<sup>(方式二:声明属性的时候写默认值 private String bucketName = "java-ai";)</sup> ==@Value("${aliyun.oss.endpoint}")== private String endpoint; @Value("${aliyun.oss.bucketName}") private String bucketName; @Value("${aliyun.oss.region}") private String region; public String upload(byte[] content, String originalFilename<sup>(拿到原始的文件名还是为了动态设置文件的后缀)</sup>) throws Exception { // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。 EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider(); // 填写Object完整路径,例如2024/06/1.png。Object完整路径中不能包含Bucket名称。 //获取当前系统日期的字符串,格式为 yyyy/MM String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM")); //生成一个新的不重复的文件名 String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf(".")); String objectName = dir + "/<sup>(在bucket中,每个斜杠就是一个目录)</sup>" + newFileName; // 创建OSSClient实例。 ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration(); clientBuilderConfiguration.setSignatureVersion(SignVersion.V4); OSS ossClient = OSSClientBuilder.create() .endpoint(endpoint) .credentialsProvider(credentialsProvider) .clientConfiguration(clientBuilderConfiguration) .region(region) .build(); try { ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content)); } finally { ossClient.shutdown(); } return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;<sup>(返回的路径是基于endpoint、bucketName、objectName的组装)</sup> }
}
3). 修改 UploadController 代码
和之前本地存储的不一样了,是要将文件交给 OSS 存储管理,所以要引入第二步创建了个工具类 AliyunOSSOperator
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("上传失败"); }
}
4)优化:@Value、@ConfigurationProperties
如果只有一两个属性需要注入,而且不需要考虑复用性,使用``注解(是 Spring 包下的注解,不是 lombok 包下的注解)就可以了。
==但是使用 @Value 注解注入配置文件的配置项,如果配置项多,注入繁琐,不便于维护管理 和 复用。==
==如下所示:==
那么有没有一种方式可以简化这些配置参数的注入呢?答案是肯定有,==在 Spring 中给我们提供了一种简化方式,可以直接将配置文件中配置项的值自动的注入到对象的属性中。==
Spring 提供的简化方式套路:
1). 需要创建一个实现类,且实体类中的属性名和配置文件当中 key 的名字必须要一致
比如:配置文件当中叫 endpoint,实体类当中的属性也得叫 endpoint,另外实体类当中的属性还需要提供 getter / setter 方法(将来要获取到配置值直接注入这个 Bean,然后获取实体类的属性即可)
2). 需要将实体类交给 Spring 的 IOC 容器管理,成为 IOC 容器当中的 bean 对象
3). 在实体类上添加==@ConfigurationProperties
==注解
具体实现步骤:
1). 定义实体类 AliyunOSSProperties ,并交给 IOC 容器管理
package com.itheima.utils;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;==@Data // lombok 供的注解,用来生成 getter 和 setter==
@Component(这个实体类就交给了 IOC 容器管理了,成为了 IOC 容器里面的一个 Bean 对象)
@ConfigurationProperties(prefix = "aliyun.oss")public class AliyunOSSProperties {
private String endpoint;
private String bucketName;
private String region;
}
2). 修改 AliyunOSSOperator
package com.itheima.utils;
import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;@Component
public class AliyunOSSOperator {//方式一: 通过@Value注解一个属性一个属性的注入 //@Value("${aliyun.oss.endpoint}") //private String endpoint; //@Value("${aliyun.oss.bucketName}") //private String bucketName; //@Value("${aliyun.oss.region}") //private String region; ==@Autowired private AliyunOSSProperties aliyunOSSProperties;== public String upload(byte[] content, String originalFilename) throws Exception { String endpoint = aliyunOSSProperties.<span data-type="text" style="background-color: var(--b3-font-background5);">getEndpoint</span>(); String bucketName = aliyunOSSProperties.<span data-type="text" style="background-color: var(--b3-font-background5);">getBucketName</span>(); String region = aliyunOSSProperties.<span data-type="text" style="background-color: var(--b3-font-background5);">getRegion</span>(); // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。 EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider(); // 填写Object完整路径,例如2024/06/1.png。Object完整路径中不能包含Bucket名称。 //获取当前系统日期的字符串,格式为 yyyy/MM String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM")); //生成一个新的不重复的文件名 String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf(".")); String objectName = dir + "/" + newFileName; // 创建OSSClient实例。 ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration(); clientBuilderConfiguration.setSignatureVersion(SignVersion.V4); OSS ossClient = OSSClientBuilder.create() .endpoint(endpoint) .credentialsProvider(credentialsProvider) .clientConfiguration(clientBuilderConfiguration) .region(region) .build(); try { ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content)); } finally { ossClient.shutdown(); } return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName; }
}
六、异常处理
默认情况下,服务器端发生异常是返回下面的结果
这是一个 JSON 数据,但不是标准的 Result 结果,所以前端不知道出现了什么异常,只是没有正确的响应结果
那么在三层构架项目中,出现了异常,该如何处理?
1.解决方案
-
方案一:在所有 Controller 的所有方法中进行 try…catch 处理
缺点:代码臃肿(不推荐)
-
方案二:全局异常处理器
好处:简单、优雅(推荐)【是 Spring 提供的】
无论是正常还是异常的响应结果,都应该声明成统一的标准的 Result 结果
刚刚最上面的默认情况下,是把异常抛给了 Controller,Controller 中也没有对这个异常处理,所以会把这个异常继续往上抛,所以就把异常抛给了框架
框架捕获到异常之后就会给前端响应一个 JSON 格式的数据,但这个不是项目中规范的响应数据,所以前端没办法解析这个响应结果
2.全局异常处理器【@ExceptionHandler】
我们该怎么样定义全局异常处理器?
- 定义全局异常处理器非常简单,就是定义一个类,在==类上加上一个注解 @RestControllerAdvice==,加上这个注解就代表我们定义了一个全局异常处理器。
- 在全局异常处理器当中,需要定义一个方法来捕获异常,在这个==方法上需要加上注解 @ExceptionHandler==。通过 @ExceptionHandler 注解当中的 value 属性来指定我们要捕获的是哪一类型的异常。
==@RestControllerAdvice==
public class GlobalExceptionHandler {
//处理异常 ==@ExceptionHandler== public <span data-type="text" style="background-color: var(--b3-font-background5);">Result</span> ex(Exception e){//方法形参中指定能够处理的异常类型<sup>(Exception,说明要捕获所有类型的异常)</sup> <span data-type="text" style="color: var(--b3-font-color7);">e.printStackTrace();//打印堆栈中的异常信息</span> //捕获到异常之后,响应一个标准的Result return Result.error("对不起,操作失败,请联系管理员<sup>(会展现在前端)</sup>"); }
}
七、令牌验证(登录操作)
什么是登录校验?
所谓登录校验,指的是我们在服务器端接收到浏览器发送过来的请求之后,首先我们要对请求进行校验。先要校验一下用户登录了没有,如果用户已经登录了,就直接执行对应的业务操作就可以了;如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。
1.思路
了解完什么是登录校验之后,接下来我们分析一下登录校验大概的实现思路。
首先我们在宏观上先有一个认知:
目前前端浏览器和服务器在进行交互的时候采用的是 Http 协议
前面在讲解 HTTP 协议的时候,我们提到 HTTP 协议是基于请求响应模型的,是无状态的协议。什么又是无状态的协议?
所谓==无状态,指的是每一次请求都是独立的,下一次请求并不会携带上一次请求的数据。==而浏览器与服务器之间进行交互,基于 HTTP 协议也就意味着现在我们通过浏览器来访问了登陆这个接口,实现了登陆的操作,接下来我们在执行其他业务操作时,服务器也并不知道这个员工到底登陆了没有。因为 HTTP 协议是无状态的,两次请求之间是独立的,所以是无法判断这个员工到底登陆了没有。
==那应该怎么来实现登录校验的操作呢?==
具体的实现思路可以分为两部分:
- 在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记。
- 在浏览器发起请求时,需要获取这个登录标记,因为后续请求可能是任何一个服务,所以在服务端进行统一拦截,拦截前端发起的所有请求,拦截后进行登录校验。
==我们要完成以上操作,会涉及到 web 开发中的两个技术:==
-
==会话技术:==用户登录成功之后,在后续的每一次请求中,都可以获取到该标记。
-
==统一拦截技术:==
- W1 过滤器 Filter:是 javaweb 中提供的
- W2 拦截器 Interceptor:是 Spring 中提供的
下面我们先学习会话技术,然后再学习统一拦截技术。
2.会话技术
介绍
介绍了登录校验的大概思路之后,我们先来学习下会话技术。
然后再学习现代企业中会话技术的解决方案:JWT 令牌
什么是会话?
- 在我们日常生活当中,会话指的就是谈话、交谈。
- 在 web 开发当中,会话指的就是浏览器与服务器之间的一次连接,我们就称为一次会话。
==需要注意的是:会话是和浏览器关联的,==当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前==请求了==多次服务器,这==多次请求是属于同一个会话==。比如:1、2、3 这三个请求都是属于同一个会话。
如下图,是涉及到 ==三次会话【中间的请求 ①②③ 都属于同一次会话】==
当我们关闭浏览器之后,==连接断开,这次会话就结束了。==
而如果我们是直接把 web 服务器关了,那么所有的会话就都结束了。
知道了会话的概念了,接下来我们再来了解下会话跟踪。
会话跟踪: 一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在==同一次会话的多次请求==间共享数据。
服务器会接收很多的请求,但是服务器是需要识别出这些请求是不是同一个浏览器发出来的。比如:1 和 2 这两个请求是不是同一个浏览器发出来的,3 和 5 这两个请求不是同一个浏览器发出来的。如果是同一个浏览器发出来的,就说明是同一个会话。如果是不同的浏览器发出来的,就说明是不同的会话。而识别多次请求是否来自于同一浏览器的过程,我们就称为会话跟踪。
我们使用会话跟踪技术就是要完成在同一个会话中,多个请求之间进行共享数据。
为什么要共享数据呢?
由于 HTTP 是无状态协议,在后面请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享
场景举例:
用户登录的时候很多系统会要求输入验证码,这个验证码是服务器端动态生成的,前端要想呈现出这个验证码,就要发送请求给服务器端,这次请求完成之后,会话就建立了;
当用户输入完信息和验证码之后点击登录,就发起了第二次请求,服务器端要校验这个验证码
这就需要再同一次会话的多个请求之间共享数据了 ---> 就可以用到会话跟踪技术
会话跟踪技术有两种:
- Cookie(客户端会话跟踪技术):数据存储在客户端浏览器当中
- Session(服务端会话跟踪技术):数据存储在储在服务端
- 令牌技术
会话跟踪方案
上面我们介绍了什么是会话,什么是会话跟踪,并且也提到了会话跟踪 3 种常见的技术方案。接下来,我们就来对比一下这 3 种会话跟踪的技术方案,来看一下具体的实现思路,以及它们之间的优缺点。
方案一:Cookie
原理==【底层:响应头 Set-Cookie、请求头 Cookie】==
cookie 是客户端会话跟踪技术,它是存储在客户端浏览器的,我们使用 cookie 来跟踪会话,我们就可以在浏览器第一次发起请求来请求服务器的时候,我们在服务器端来设置一个 cookie。
比如第一次请求了登录接口,==登录接口执行完成之后,就可以在服务器端创建一个 cookie 对象==,在 cookie 当中我们就可以来存储用户相关的一些数据信息。比如我可以在 cookie 当中来存储当前登录用户的用户名,用户的 ID。
==服务器端在给客户端在响应数据的时候,会==**==自动==**==的将 cookie 响应给浏览器,浏览器接收到响应回来的 cookie 之后,会==**==自动==**==的将 cookie 的值存储在浏览器本地。接下来在后续的每一次请求当中,都会将浏览器本地所存储的 cookie== **==自动==**==地携带到服务端。==
==接下来在服务端我们就可以获取到 cookie 的值。我们可以去判断一下这个 cookie 的值是否存在,如果不存在这个 cookie【即携带过来的 cookie 并没有存储当前用户登录的信息】,就说明客户端之前是没有访问登录接口的;如果存在 cookie 的值,就说明客户端之前已经登录完成了。这样我们就可以基于 cookie 在同一次会话的不同请求之间来共享数据。==
我刚才在介绍流程的时候,用了 3 个自动:
- 服务器会 自动 的将 cookie 响应给浏览器。
- 浏览器接收到响应回来的数据之后,会 自动 的将 cookie 存储在浏览器本地。
- 在后续的请求当中,浏览器会 自动 的将 cookie 携带到服务器端。
==== **==为什么这一切都是自动化进行的?==**
==是因为 cookie 它是 HTP 协议当中所支持的技术==,而各大浏览器厂商都支持了这一标准。==在 HTTP 协议官方给我们提供了一个响应头和请求头:==
-
响应头 Set-Cookie :设置 Cookie 数据的【服务器端给前端响应 cookie 的时候,会自动地在响应头中添加 set-cookie 这个响应头】
- 浏览器在接收响应数据的时候,一旦发现响应头里面有一个 set-cookie 的响应头,它就会自动将这个 cookie 保存在浏览器本地
-
请求头 Cookie:携带 Cookie 数据的
- 后续每次发请求的时候,浏览器都会自动把保存在 本地 cookie 放在 Http 提供的请求头 Cookie 里,发送到服务器端
- 然后服务器端就可以获取到请求头传过来的 cookie,校验 cookie 中是否存储了当前登录用户的信息
cookie 里面存储的形式就是 key=value
代码
@Slf4j
@RestController
public class SessionController {//设置Cookie @GetMapping("/c1") public Result cookie1(<span data-type="text" style="background-color: var(--b3-font-background5);">HttpServletResponse response</span>){ <span data-type="text" style="background-color: var(--b3-font-background5);">// 这个对象是用来响应数据的</span> response.<span data-type="text" style="color: var(--b3-font-color13);">addCookie</span>(new Cookie("login_username","itheima"<sup>(cookie里存储的对象是key-value形式的)</sup>)); <span data-type="text" style="color: var(--b3-font-color13);">//设置Cookie/响应Cookie</span> return Result.success(); } //获取Cookie @GetMapping("/c2") public Result cookie2<span data-type="text" style="color: var(--b3-font-color7);">(HttpServletRequest request){ // cookie是从请求头中传递给后端的,所以要从请求对象中获取cookie</span> Cookie[]<sup>(可能会有很多cookie,所以getCookies()返回值类型是Cookie[])</sup> 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,抓包看一下服务器端响应回来的 Cookie:【在服务器端设置的 cookie 会在响应头中==自动地==响应给浏览器,而且这个响应头的名字是 Set-cookie】
-
-
-
已知浏览器一旦见到响应头的名字是 set-cookie,就会把这个响应头保存下来:
-
-
-
再访问 c2,可以看到 Cookie 都被携带过去了
-
-
优缺点:
-
优点:HTTP 协议中支持的技术(像 Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的)
-
缺点:
-
移动端 APP(Android、IOS)中无法使用 Cookie
-
不安全,用户可以自己随意删除或者禁用 Cookie
-
-
-
Cookie 不能跨域
-
跨域介绍:
==区分跨域的维度(三个维度有任何一个维度不同,那就是跨域操作):==
- 协议
- IP/协议
- 端口
方案二:Session==【底层:Cookie】==
JSESSIONID 是在 Cookie 中在浏览器和服务器之间传递的
前面介绍的时候,我们提到 Session,==它是服务器端会话跟踪技术,所以它是存储在服务器端的。==而 Session 的底层其实就是基于我们刚才所介绍的 Cookie 来实现的。
原理
-
获取 Session
- 如果我们现在要基于 Session 来进行会话跟踪,浏览器在第一次请求服务器的时候,我们就可以直接在服务器当中来获取到会话对象 Session。如果是第一次请求 Session ,会话对象是不存在的,这个时候==服务器会自动的创建一个会话对象 Session== 。而每一个会话对象 Session ,它都有一个 ID(示意图中 Session 后面括号中的 1,就表示 ID),我们称之为 Session 的 ID。
-
响应 Cookie (JSESSIONID)
- 接下来,==服务器端在给浏览器响应数据的时候,它会将 Session 的 ID 通过 Cookie 响应给浏览器==。其实在响应头当中增加了一个 Set-Cookie 响应头。这个 Set-Cookie 响应头对应的值是不是 cookie? ==cookie 的名字是固定的== ==**J**====SESSIONID, 值是代表的服务器端会话对象 Session 的 ID==。==浏览器会自动识别这个响应头,然后自动将 Cookie 存储在浏览器本地。==
-
查找 Session
- 接下来,在后续的每一次请求当中,都会将 Cookie 的数据获取出来,并且携带到服务端。接下来服务器拿到 JSESSIONID 这个 Cookie 的值,也就是 Session 的 ID。拿到 ID 之后,就会从众多的 Session 当中来找到当前请求对应的会话对象 Session。
- 这样我们是不是就可以通过 Session 会话对象在同一次会话的多次请求之间来共享数据了?好,这就是基于 Session 进行会话跟踪的流程。
代码
@Slf4j
@RestController
public class SessionController {
**// 往session里面存值** @GetMapping("/s1") public Result session1(HttpSession session<sup>(在服务器端要想获取到当前会话对应的会话对象,就直接在Controller方法形参中声明此类型的参数)</sup>){ log.info("HttpSession-s1: {}", <span data-type="text" style="background-color: var(--b3-font-background5);">session.hashCode()</span>); session.<span data-type="text" style="background-color: var(--b3-font-background11);">setAttribute</span>("loginUser", "tom"); <span data-type="text" style="background-color: var(--b3-font-background11);">//往session中存储数据</span> return Result.success(); }
**// 在Session会话对象里面取值** @GetMapping("/s2") public Result session2(HttpServletRequest request)<sup>(参数也可以写 (HttpSession session),那么下面第一行就可以省略)</sup>{ HttpSession session = request.==getSession==(); log.info("HttpSession-s2: {}", <span data-type="text" style="background-color: var(--b3-font-background5);">session.hashCode()</span>); Object loginUser = session.<span data-type="text" style="background-color: var(--b3-font-background10);">getAttribute</span>("loginUser");<span data-type="text" style="background-color: var(--b3-font-background10);"> //从session中获取数据</span> log.info("loginUser: {}", loginUser); return Result.success(loginUser); }
}
两个 session.hashCode() 可以检验获取到的 session 对象是否是同一个
浏览器验证
同上
-
服务器端返回
-
浏览器保存
-
浏览器传
ps:两次请求的 Session 的 hashCode 的值是一样的【意味着两次的 session 对象是同一个】
优缺点
-
优点:Session 是存储在服务端的,安全
-
缺点:(第一个是 session 独有的,后面三个都同 Cookie 有的缺点)
- 服务器集群环境下无法直接使用 Session
- 移动端 APP(Android、IOS)中无法使用 Cookie
- 用户可以自己禁用 Cookie
- Cookie 不能跨域
ps:nginx 就可以作为负载均衡服务器
大家会看到上面这两种传统的会话技术,在现在的企业开发当中是不是会存在很多的问题。 为了解决这些问题,在现在的企业开发当中,基本上都会采用第三种方案,通过令牌技术来进行会话跟踪。接下来我们就来介绍一下令牌技术,来看一下令牌技术又是如何跟踪会话的。
方案三 - 令牌技术【本质:一个字符串】【现在企业多用】
原理
这里我们所提到的令牌,其实它就是一个用户身份的标识,看似很高大上,很神秘,其实==本质就是一个字符串。==
如果通过令牌技术来跟踪会话,我们就可以在浏览器发起请求。在请求登录接口的时候,如果登录成功,==服务器端就会创建一个令牌==,令牌就是用户的合法身份凭证。接下来我在响应数据的时候,我就可以直接将令牌响应给前端。
接下来我们在==前端程序当中接收到令牌之后,就需要将这个令牌存储起来==。这个存储可以存储在 cookie 当中,也可以存储在其他的存储空间(比如:localStorage)当中。
==接下来,在后续的每一次请求当中,都需要将令牌携带到服务端。携带到服务端之后,接下来我们就需要来校验令牌的有效性。==如果令牌是有效的,就说明用户已经执行了登录操作,如果令牌是无效的,就说明用户之前并未执行登录操作。
此时,如果是在同一次会话的多次请求之间,我们想共享数据,我们就可以将共享的数据存储在令牌当中就可以了。
优缺点
-
优点:
- 支持 PC 端、移动端
- 解决集群环境下的认证问题
- 减轻服务器的存储压力(无需在服务器端存储)
-
==缺点:需要自己实现(包括令牌的====**生成、响应、存储、携带、校验**====)==
应用场景
JWT 令牌最典型的应用场景就是登录认证:
- 在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,我们需要生成一个 jwt 令牌,将生成的 jwt 令牌返回给前端。
- 前端拿到 jwt 令牌之后,会将 jwt 令牌存储起来。在后续的每一次请求中都会将 jwt 令牌携带到服务端。
- 服务端统一拦截请求之后,先来判断一下这次请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要校验一下令牌是否是有效。如果有效,就直接放行进行请求的处理。
在 JWT 登录认证的场景中我们发现,整个流程当中涉及到两步操作:
- 在登录成功之后,要生成令牌。
- 每一次请求当中,要接收令牌并对令牌进行校验。
稍后我们再来学习如何来生成 jwt 令牌,以及如何来校验 jwt 令牌。
3.JWT 令牌==【企业中用的最多的、功能最强大的令牌】==
1.介绍
-
JWT 全称 ==JSON Web Token== (官网:https://jwt.io/),定义了一种简洁的、自包含的格式,用于在通信双方以 json 数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。
JSON 是一种数据格式,说明 JWT 这种令牌和 JSON 这种数据格式有强相关性
- 简洁:是指 jwt 就是一个简单的字符串。可以在请求参数或者是请求头当中直接传递。
- 自包含:指的是 jwt 令牌,看似是一个随机的字符串,但是我们是可以根据自身的需求在 jwt 令牌中存储自定义的数据内容。如:可以直接在 jwt 令牌中存储用户的相关信息。
- 简单来讲,jwt 就是将原始的 json 数据格式进行了安全的封装,这样就可以直接基于 jwt 在通信双方安全的进行信息传输了。
-
JWT 官网演示数据
-
-
JWT 的组成:
(JWT 令牌由三个部分组成,三个部分之间使用英文的点来分割)【其长度不是固定的,取决于原始内容的多少】
-
Header(头)
记录令牌类型、签名算法等。 例如:{"alg":"HS256","type":"JWT"}
上图示中绿色的是 {"alg":"HS256","type":"JWT"} **==编码==**之后的结果
-
Payload(有效载荷)
携带一些自定义信息、令牌的默认信息等。 例如:{"id":"1","username":"Tom"}
默认信息:比如令牌的签发时间、主题等
-
Signature(签名)
防止 Token 被篡改、确保安全性。==将 header、payload,并加入指定秘钥 secret,通过指定签名算法计算而来。==
签名的目的就是为了防 jwt 令牌被篡改,而正是因为 jwt 令牌最后一个部分数字签名的存在,所以整个 jwt 令牌是非常安全可靠的。一旦 jwt 令牌当中任何一个部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。
只有签名不是通过 Base64 编码 得到的,而是基于一定算法计算出来的加密之后的字符串
JWT 是如何将原始的 JSON 格式数据,转变为字符串的呢?
-
其实在生成 JWT 令牌时,会对 JSON 格式的数据进行一次编码:进行**==base64 编码==**
- Base64:是一种基于 64 个可打印的字符(A-Z a-z 0-9 + /)来表示二进制数据的编码方式。【可以把任意一个二进制数据,包括图片、音频、视频直接进行 Base64 编码,编码之后就变成了一个字符串】
- 既然能编码,那也就意味着也能解码。所使用的 64 个字符分别是 A 到 Z、a 到 z、 0- 9,一个加号,一个斜杠,加起来就是 64 个字符。任何数据经过 base64 编码之后,最终就会通过这 64 个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位符号【上边图示紫色部分最后一个字符就是等号】
- 需要注意的是 Base64 是编码方式,而不是加密方式。
2.生成和校验
简单介绍了 JWT 令牌以及 JWT 令牌的组成之后,接下来我们就来学习基于 Java 代码如何生成和校验 JWT 令牌。
引入依赖
1). 首先我们先来实现 JWT 令牌的生成。要想使用 JWT 令牌,需要先引入 JWT 的依赖:
io.jsonwebtoken jjwt(指的是 java 代码来操作 jwt令牌) 0.9.1
利用官方提供的工具类 Jwts 生成 jwt 令牌
2). 生成 JWT 代码实现:
在引入完 JWT 来赖后,就可以调用工具包中提供的 API 来完成 JWT 令牌的生成和校验。
@Test
public void testGenJwt() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 10);
claims.put("username", "itheima");String jwt<sup>(compact()返回值是一个String)</sup> = Jwts.==builder()==.signWith<sup>(第一个参数类型是 SignatureAlgorithm, 第二个参数类型是 Key/String/byte[])</sup>(SignatureAlgorithm.HS256, "aXRjYXN0"<sup>(秘钥类型刚刚说了有三种,但对于字符串类型来说,也可以是一个二进制数据,那二进制数据怎么写成一个字符串呢?由signWith()方法签名中参数String base64EncodedSecretKey看出来,可以对秘钥进行base64编码【对任意二进制数据进行编码】,所以如果想要一个二进制数据作为秘钥的话,就可以对其使用base64编码 这里就用字符串itheima来当秘钥,但是对这个字符串又进行了base64编码(用base64在线编码工具得到对应的编码值即可))</sup>) .<span data-type="text" style="background-color: var(--b3-font-background11);">addClaims</span>(claims) <span data-type="text" style="background-color: var(--b3-font-background11);">// 添加自定义的信息</span> .setExpiration<sup>(对于我们生成的jwt令牌,是可以针对他设置有效期的)</sup>(new Date(System.currentTimeMillis()<sup>(当前系统时间的毫秒值:时间戳)</sup> + 12 * 3600 * 1000<sup>(是一个小时对应的毫秒值)</sup>)) .<span data-type="text" style="color: var(--b3-font-color13);">compact</span>(); <span data-type="text" style="color: var(--b3-font-color13);">// 构建令牌的方法</span> System.out.println(jwt);
}
dm:
"哈希不行吗?" "去玩玩 JS 逆向就能有更深的领悟" "哈希不能反向解密"
运行测试方法:
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk
输出的结果就是生成的 JWT 令牌,==通过英文的点分割对三个部分进行分割==,我们可以将生成的令牌复制一下,然后打开 JWT 的官网,将生成的令牌直接放在 Encoded 位置,此时就会自动的将令牌解析出来。
第一部分解析出来,看到 JSON 格式的原始数据,所使用的签名算法为 HS256。
第二个部分是我们自定义的数据,之前我们自定义的数据就是 id,还有一个 exp 代表的是我们所设置的过期时间。
==由于前两个部分是 base64 编码,所以是可以直接解码出来。但最后一个部分并不是 base64 编码,是经过签名算法计算出来的一个====**加密字符串**====,所以最后一个部分是不会解析的==
领用官方提供的工具类 Jwts 来解析 jwt 令牌
3). 实现了 JWT 令牌的生成,下面我们接着使用 Java 代码来校验 JWT 令牌(解析生成的令牌):
@Test
public void testParseJwt() {
Claims claims = Jwts.==parser()==.setSigningKey(解析的时候必须指定秘钥,这个秘钥和生成 jwt 的时候用的秘钥要完全一致)("aXRjYXN0")
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko")
.getBody();
System.out.println(claims);
}
运行测试方法:
{id=10, username=itheima, exp=1701909015}
令牌解析后,我们可以看到 id 和过期时间,如果在解析的过程当中没有报错,就说明解析成功了。
下面我们做一个测试:把令牌 header 中的数字 9 变为 8,运行测试方法后发现报错:
结论:篡改令牌中的任何一个字符,在对令牌进行解析时都会报错,所以 JWT 令牌是非常安全可靠的。
我们继续测试:修改生成令牌的时指定的过期时间,修改为 1 分钟。
@Test
public void genJwt(){
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() + 60 * 1000)) //有效期60s
.compact();
System.out.println(jwt);
//输出结果:eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjczMDA5NzU0fQ.RcVIR65AkGiax-ID6FjW60eLFH3tPTKdoK7UtE4A1ro
}
@Test
public void parseJwt(){
Claims claims = Jwts.parser()
.setSigningKey("aXRjYXN0")//指定签名密钥
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjczMDA5NzU0fQ.RcVIR65AkGiax-ID6FjW60eLFH3tPTKdoK7UtE4A1ro")
.getBody();
System.out.println(claims);
}
等待 1 分钟之后运行测试方法发现也报错了,说明:JWT 令牌过期后,令牌就失效了,解析的为非法令牌。
3.通过令牌技术实现会话跟踪
要想实现会话跟踪,得在用户登录的时候生成令牌,响应的时候下发给浏览器,浏览器存储这个令牌,并在后续的每一次存储中都把令牌携带到服务器端,服务器端拦截到用户的请求后,就校验令牌的有效性,有效才放行,无效就响应错误
下发给浏览器其实就是在返回结果中带上 jwt token:
-
对应服务器端的两步编码:定义 JWT 令牌操作工具类 + 登录完成后,调用工具类生成 JWT 令牌并返回
-
定义 JWT 令牌操作工具类:
-
-
-
登录完成后,调用工具类生成 JWT 令牌并返回:
-
-
-
进行测试,发送请求,可以看到响应中带有生成的 token:
浏览器访问的话会把返回的信息存起来:
在本项目中,接口文档里面已经做了如下声明
在后续的每次请求中,令牌都要在请求头 header 中携带到服务器端,请求头的名称为 token,值为登录时下发的 jwt 令牌:
所以登录成功之后再发起一个请求,可以看到浏览器携带了 token
服务器端==拦截==请求并对 token 进行校验
而统一拦截技术有两种:过滤器、拦截器
4.过滤器 Filter
快速入门
什么是 Filter?
-
Filter 表示过滤器,==是 JavaWeb====三大组件(现在企业中用的比较多的只剩下中间的 filter)====(====Servlet(是运行在服务器端的一个小程序,基于它可以开发动态的 web 资源)====、Filter、Listener)之一。==
-
过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能
- 使用了过滤器之后,要想访问 web 服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源。
-
过滤器一般完成一些通用的操作,比如:登录校验、统一编码处理、敏感字符处理等。
下面我们通过 Filter 快速入门程序掌握过滤器的基本使用操作:
-
==第 1 步,定义过滤器== :1.定义一个类,实现 Filter 接口,并重写其所有方法:init、doFliter、destroy
-
-
-
==第 2 步,配置过滤器==:Filter 类上加 @WebFilter 注解,配置拦截资源的路径。引导类上加 @ServletComponentScan 开启 Servlet 组件支持。
1). 定义过滤器
2). 配置过滤器
在定义完 Filter 之后,Filter 其实并不会生效,还需要完成 Filter 的配置,Filter 的配置非常简单,==只需要在 Filter 类上添加一个注解:== **==@WebFilter
==**
==,并指定属性==**==urlPatterns
==**,通过这个属性指定过滤器要拦截哪些请求
==@WebFliter(urlPatterns = "/*")==
public class DemoFilter implements Filter (init()、destroy()都提供了空实现【因为他们并不常用】){
//初始化方法, web 服务器启动, 创建 Filter 实例时调用, ==只调用一次==
public void init(一般是做资源、环境的准备工作)(FilterConfig filterConfig) throws ServletException {
System.out.println("init ...");
}//拦截到请求时,调用该方法,可以<span data-type="text" style="background-color: var(--b3-font-background5);">调用多次</span> public void doFilter<sup>(这个没有提供默认实现!!!)</sup>(<span data-type="text" style="background-color: var(--b3-font-background11);">ServletRequest servletRequest, ServletResponse servletResponse,</span> FilterChain chain<sup>(这个形参只有一个方法 doFliter(ServletRequest servletRequest, ServletResponse servletResponse),用来放行资源)</sup>) throws IOException, ServletException { System.out.println("拦截到了请求..."); } //销毁方法, <span data-type="text" style="color: var(--b3-font-color7);">web服务器关闭时</span>调用, ==只调用一次== public void destroy<sup>(一般是做资源的释放和环境的清理操作)</sup>(<span data-type="text" style="background-color: var(--b3-font-background11);"> </span>) { System.out.println("destroy ... "); }
}
当我们在 Filter 类上面加了 @WebFilter 注解之后,接下来我们==还需要在启动类上面加上一个注解== **==@ServletComponentScan
==**,通过这个 **@ServletComponentScan
**注解来开启 SpringBoot 项目对于 Servlet 组件的支持,之后 springboot 会自动扫描 Servlet 相关的注解,然后我们声明的组件就会生效了
因为本来是 springboot 项目,要想在 springboot 项目中使用传统 javaweb 开发的组件,就得在启动类上加上这个注解
重新启动服务,打开浏览器,多次发起执行部门管理的请求,可以看到控制台输出了过滤器中的内容:
即如果放行操作,浏览器端不会收到响应数据
登录/令牌校验过滤器
过滤器 Filter 的快速入门以及使用细节我们已经介绍完了,接下来最后一步,我们需要使用过滤器 Filter 来完成案例当中的登录校验功能。
我们先来回顾下前面分析过的登录校验的基本流程:
- 要进入到后台管理系统,我们必须先完成登录操作,此时就需要访问登录接口 login。
- 登录成功之后,我们会在服务端生成一个 JWT 令牌,并且把 JWT 令牌返回给前端,前端会将 JWT 令牌存储下来。
- 在后续的每一次请求当中,都会将 JWT 令牌携带到服务端,请求到达服务端之后,要想去访问对应的业务功能,此时我们必须先要校验令牌的有效性。
- 对于校验令牌的这一块操作,我们使用登录校验的过滤器,在过滤器当中来校验令牌的有效性。如果令牌是无效的,就响应一个错误的信息,也不会再去放行访问对应的资源了。如果令牌存在,并且它是有效的,此时就会放行去访问对应的 web 资源,执行相应的业务操作。
大概清楚了在 Filter 过滤器的实现步骤了,那在正式开发登录校验过滤器之前,我们思考两个问题:
-
所有的请求,拦截到了之后,都需要校验令牌吗 ?
- 答案:注册、登录请求例外【但本项目没有注册操作】
-
拦截到请求后,什么情况下才可以放行,执行业务操作 ?
- 答案:有令牌,且令牌校验通过(合法);否则都返回未登录错误结果【一个状态码 401】
具体流程
我们要完成登录校验,主要是利用 Filter 过滤器实现,而 Filter 过滤器的流程步骤:
基于上面的业务流程,我们分析出具体的操作步骤:
- 获取请求 url
- 判断请求 url 中是否包含 login,如果包含,说明是登录操作,放行
- 获取请求头中的令牌(token)
- 判断令牌是否存在,如果不存在,响应 401
- 解析 token,如果解析失败【即令牌非法】,响应 401
- 放行
代码实现
在 com.itheima.filter
包下创建 TokenFilter
,具体代码如下:
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 {//0. 强转为本身的类型@Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;
//1. 获取请求 url。
String url = request.getRequestURL(getRequestURI()也可以,URL 是完整的路径,URI 是资源的路径,例如 /employee/login,就是不包含前面的协议、IP 和端口)().toString();//2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行。 if(url.contains("login")){ //登录请求 log.info("登录请求 , 直接==放行=="); ==chain.doFilter(request, response);== ==return;== } //3. 获取请求头中的令牌(token)。
String jwt = request. getHeader("token")(获取请求头中键名称为 token 的值) ;
//4.<span data-type="text" style="background-color: var(--b3-font-background5);"> 判断令牌是否存在,如果不存在,返回错误结果(未登录)。</span> if(!StringUtils.hasLength(jwt)<sup>(也可以:token==null || token.equals("") 其中,token.equals("")又可以写成token.isEmpty())</sup>){ //jwt为空 log.info("获取到jwt令牌为空, 响应401"); response.setStatus<sup>(设置状态码)</sup>(HttpStatus.SC_UNAUTHORIZED<sup>(这是servlet.http包里面声明的常量:int SC_UNAUTHORIZED=401;)</sup>); return; } //5. <span data-type="text" style="background-color: var(--b3-font-background5);">解析token,如果解析失败,返回错误结果(未登录)。</span> try { JwtUtils.parseJWT(jwt); } catch (Exception e) { e.printStackTrace(); log.info("解析令牌失败, 响应401"); response.setStatus(HttpStatus.<span data-type="text" style="background-color: var(--b3-font-background5);">SC_UNAUTHORIZED</span>); return; } //6. 放行。 log.info("令牌合法, ==放行=="); ==chain.doFilter(request , response);== }
}
执行流程
首先我们先来看下过滤器的执行流程:
过滤器当中我们拦截到了请求之后,如果希望继续访问后面的 web 资源,就要执行放行操作,放行就是调用 FilterChain 对象当中的 doFilter()方法,在调用 doFilter()这个方法之前所编写的代码属于放行之前的逻辑。
==在放行后访问完 web 资源之后还会回到过滤器当中,回到过滤器之后如有需求还可以执行放行之后的逻辑,放行之后的逻辑我们写在 doFilter()这行代码之后!!!!!!!==
-
演示
-
具体应用场景
- 响应数据压缩:response.setHeader("Content-Encoding", "gzip");
- 记录日志:System.out.println("请求处理时间:" + (endTime - startTime) + "ms");
- 对内容进行加工:response.getWriter().write("\n<!-- 页面加载完毕 -->");
- 释放资源:if (connection != null) { connection.close(); }
拦截路径的配置
执行流程我们搞清楚之后,接下来再来介绍一下过滤器的拦截路径,Filter 可以根据需求,配置不同的拦截资源路径:
urlPatterns 值 | 含义 |
---|---|
/login | 只有访问 /login 路径时,才会被拦截 |
/emps/* | 访问/emps 下的所有资源,都会被拦截【只要以 emps 开头就可以了】 |
/* | 访问所有资源,都会被拦截 |
过滤器链
最后我们在来介绍下过滤器链,什么是过滤器链呢?所谓过滤器链指的是在一个 web 应用程序当中,可以配置多个过滤器,多个过滤器就形成了一个过滤器链。
比如:在我们 web 服务器当中,定义了两个过滤器,这两个过滤器就形成了一个过滤器链。
而这个链上的过滤器在执行的时候会一个一个的执行,会先执行第一个 Filter,放行之后再来执行第二个 Filter,如果执行到了最后一个过滤器放行之后,才会访问对应的 web 资源。
访问完 web 资源之后,按照我们刚才所介绍的过滤器的执行流程,还会回到过滤器当中来执行过滤器放行后的逻辑,而在执行放行后的逻辑的时候,顺序是反着的。
先要执行过滤器 2 放行之后的逻辑,再来执行过滤器 1 放行之后的逻辑,最后在给浏览器响应数据。
刚刚在实现 filter 实例的时候,实现了一个 doFliter(ServletRequest,ServletResponse,**==FilterChain chain==**) 方法,其中第三个参数就是过滤器链
放行的时候是调用过滤器链的 doFliter(ServletRequest,ServletResponse) 方法,其实就是沿着这个过滤器链放行到了下一个过滤器(如果已经是最后一个过滤器,就会放行到资源)
-
-
5.拦截器 Intercepter
快速入门
什么是拦截器?
- 是一种动态拦截方法调用的机制,类似于过滤器。
- 拦截器是 Spring 框架中提供的,==用来动态拦截控制器方法的执行。==
- 拦截器的作用:拦截请求,在指定方法==调用前后==,根据业务需要执行预先设定的代码。
在拦截器当中,我们通常也是做一些通用性的操作,比如:我们可以通过拦截器来拦截前端发起的请求,将登录校验的逻辑全部编写在拦截器当中。在校验的过程当中,如发现用户登录了(携带 JWT 令牌且是合法令牌),就可以直接放行,去访问 spring 当中的资源。如果校验时发现并没有登录或是非法令牌,就可以直接给前端响应未登录的错误信息。
下面我们通过快速入门程序,来学习下拦截器的基本使用。拦截器的使用步骤和过滤器类似,也分为两步:
- 定义拦截器
- 注册配置拦截器
1). 自定义拦截器
实现 HandlerInterceptor 接口,并重写其所有方法:preHandle、postHandle、afterCompletion【这些方法都有默认的空实现】
//自定义拦截器
@Component(因为拦截器是 Spring 提供的,所以要把这个拦截器交给 IOC 容器管理,所以加这个注解)
public class DemoInterceptor implements HandlerInterceptor {
==//目标资源方法执行前执行。 返回 true:放行 返回 false:不放行==
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("preHandle .... ");return true<sup>(如果这里直接改成false,那么前端访问是获得空响应【但是要注意!!因为什么都没设置所以状态码还是200 OK】!!!)</sup>; //true表示放行 } ==//目标资源方法执行后执行== @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { System.out.println("postHandle ... "); } ==//====视图渲染<sup>(主要指的是在早期前后端没有分离的情况下的)</sup>====完毕后执行,最后执行== @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
== ==方法==
==@Configuration==
public class WebConfig ==implements WebMvcConfigurer== {//自定义的拦截器对象 @Autowired private DemoInterceptor demoInterceptor; @Override public void ==addInterceptors==(InterceptorRegistry registry) { //注册自定义拦截器对象 registry.<span data-type="text" style="color: var(--b3-font-color13);">addInterceptor(demoInterceptor)</span>.addPathPatterns("/** **"); //设置拦截器拦截的请求路径(** **/**** <span data-type="text" style="background-color: var(--b3-font-background5);"> 表示拦截所有请求</span>) }
}
登录/令牌校验拦截器
和刚刚基于过滤器实现的令牌校验的逻辑是完全一致的!!!!!!
定义拦截器
-
注册拦截器
-
拦截路径
首先我们先来看拦截器的拦截路径的配置,在注册配置拦截器的时候,我们要指定拦截器的拦截路径,通过addPathPatterns("要拦截路径")
方法,就可以指定要拦截哪些资源。
在入门程序中我们配置的是 /**
,表示拦截所有资源,而==在配置拦截器时,不仅可以指定要拦截哪些资源,还可以指定不拦截哪些资源,==只需要调用excludePathPatterns("不拦截路径")
方法,指定哪些资源不需要拦截。
@Configuration
public class WebConfig implements WebMvcConfigurer {
//拦截器对象
@Autowired
private DemoInterceptor demoInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义拦截器对象
registry.addInterceptor(demoInterceptor)
.addPathPatterns("/**")//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
.excludePathPatterns("/login");//设置不拦截的请求路径
}
}
在拦截器中除了可以设置 /**
拦截所有资源外,还有一些常见拦截路径设置:
执行流程
介绍完拦截路径的配置之后,接下来我们再来介绍拦截器的执行流程。通过执行流程,大家就能够清晰的知道过滤器与拦截器的执行时机。
-
当我们打开浏览器来访问部署在 web 服务器当中的 web 应用时,此时我们所定义的过滤器会拦截到这次请求。
- 过滤器先,因为它是 Servlet 规范中提供的技术,它的范围更大
-
拦截到这次请求之后,它会先执行放行前的逻辑,然后再执行放行操作。而由于我们当前是基于 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 环境中的资源。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于