SpringBootWeb案例
前面我们已经讲解了Web前端开发的基础知识,也讲解了Web后端开发的基础(HTTP协议、请求响应),并且也讲解了数据库MySQL,以及通过Mybatis框架如何来完成数据库的基本操作。 那接下来,我们就通过一个案例,来将前端开发、后端开发、数据库整合起来。 而这个案例呢,就是我们前面提到的Tlias智能学习辅助系统。
在这个案例中,前端开发人员已经将前端工程开发完毕了。 我们需要做的,就是参考接口文档完成后端功能的开发,然后结合前端工程进行联调测试即可。
完成后的成品效果展示:
今天的主要内容如下:
准备工作
部门管理
员工管理
下面我们就进入到今天的第1个内容准备工作
的学习。
1. 准备工作
准备工作的学习,我们先从"需求"和"环境搭建"开始入手。
1.1 需求&环境搭建
1.1.1 需求说明
1、部门管理
部门管理功能开发包括:
-
查询部门列表
-
删除部门
-
新增部门
-
修改部门
2、员工管理
员工管理功能开发包括:
-
查询员工列表(分页、条件)
-
删除员工
-
新增员工
-
修改员工
1.1.2 环境搭建
步骤:
-
准备数据库表(dept、emp)
-
创建springboot工程,引入对应的起步依赖(web、mybatis、mysql驱动、lombok)
-
配置文件application.properties中引入mybatis的配置信息,准备对应的实体类
-
准备对应的Mapper、Service(接口、实现类)、Controller基础结构
第1步:准备数据库表
-- 部门管理 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 null 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步:创建一个SpringBoot工程,选择引入对应的起步依赖(web、mybatis、mysql驱动、lombok) (版本选择3.2.5版本,可以创建完毕之后,在pom.xml文件中更改版本号)
生成的pom.xml文件:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>tlias-web-management</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>tlias-web-management</name>
<description>tlias-web-management</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>3.0.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
创建项目工程目录结构:
第3步:配置文件application.properties中引入mybatis的配置信息,准备对应的实体类
-
application.properties (直接把之前项目中的复制过来)
spring.application.name=tlias-web-management
#驱动类名称
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 ----> aColumn
mybatis.configuration.map-underscore-to-camel-case=true
-
实体类
/*部门类*/
package com.example.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* @Auther lmy
* @date 2024-04-26 20:55
* @Description 部门实体类
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Dept {
private Integer id; //ID
private String name; //部门名称
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间
}
/*员工类*/
package com.example.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* @Auther lmy
* @date 2024-04-26 20:56
* @Description 员工实体类
*/
@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; //修改时间
}
第4步:准备对应的Mapper、Service(接口、实现类)、Controller基础结构
数据访问层:
-
DeptMapper
package com.example.mapper;
import com.example.pojo.Dept;
import org.apache.ibatis.annotations.*;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:56
* @Description 部门管理
*/
@Mapper
public interface DeptMapper {
}
-
EmpMapper
package com.example.mapper;
import org.apache.ibatis.annotations.Mapper;
/**
* @Auther lmy
* @date 2024-04-26 20:57
* @Description 员工管理
*/
@Mapper
public interface EmpMapper {
}
业务层:
-
DeptService
package com.example.service;
import com.example.pojo.Dept;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description This is description of code
*/
public interface DeptService {
}
-
DeptServiceImpl
package com.example.service.impl;
import com.example.service.DeptService;
import org.springframework.stereotype.Service;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description 部门管理service
*/
@Service
public class DeptServiceImpl implements DeptService {
}
-
EmpService
package com.example.service;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description This is description of code
*/
public interface EmpService {
}
-
EmpServiceImpl
package com.example.service.impl;
import com.example.service.EmpService;
import org.springframework.stereotype.Service;
/**
* @Auther lmy
* @date 2024-04-26 20:59
* @Description 员工管理service
*/
@Service
public class EmpServiceImpl implements EmpService {
}
控制层:
-
DeptController
package com.example.controller;
import org.springframework.web.bind.annotation.*;
/**
* @Auther lmy
* @date 2024-04-26 20:57
* @Description 部门管理controller
*/
@Slf4j
@RestController
public class DeptController {
}
-
EmpController
package com.example.controller;
import org.springframework.web.bind.annotation.RestController;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description 员工管理controller
*/
@RestController
public class EmpController {
}
项目工程结构:
1.2 开发规范
了解完需求也完成了环境搭建了,我们下面开始学习开发的一些规范。
开发规范我们主要从以下几方面介绍:
1、开发规范-REST
我们的案例是基于当前最为主流的前后端分离模式进行开发。
在前后端分离的开发模式中,前后端开发人员都需要根据提前定义好的接口文档,来进行前后端功能的开发。
后端开发人员:必须严格遵守提供的接口文档进行后端功能开发(保障开发的功能可以和前端对接)
而在前后端进行交互的时候,我们需要基于当前主流的REST风格的API接口进行交互。
什么是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呢,定义比较复杂,而且将资源的访问行为对外暴露出来了。
基于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将会更加简洁、更加规范、更加优雅。
注意事项:
REST是风格,是约定方式,约定不是规定,可以打破
描述模块的功能通常使用复数,也就是加s的格式来描述,表示此类资源,而非单个资源。如:users、emps、books…
2、开发规范-统一响应结果
前后端工程在进行交互时,使用统一响应结果 Result。
package com.example.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);
}
}
3、开发流程
我们在进行功能开发时,都是根据如下流程进行:
-
查看页面原型明确需求
-
根据页面原型和需求,进行表结构设计、编写接口文档(已提供)
-
-
阅读接口文档
-
思路分析
-
功能接口开发
-
就是开发后台的业务功能,一个业务功能,我们称为一个接口
-
-
功能接口测试
-
功能开发完毕后,先通过Postman进行功能接口测试,测试通过后,再和前端进行联调测试
-
-
前后端联调测试
-
和前端开发人员开发好的前端工程一起测试
-
2. 部门管理
我们按照前面学习的开发流程,开始完成功能开发。首先按照之前分析的需求,完成部门管理
的功能开发。
开发的部门管理功能包含:
-
查询部门
-
删除部门
-
新增部门
-
更新部门(不讲解,自己独立完成)
2.1 查询部门
2.1.1 原型和需求
查询的部门的信息:部门ID、部门名称、修改时间
通过页面原型以及需求描述,我们可以看到,部门查询,是不需要考虑分页操作的。
2.1.2 接口文档
部门列表查询
-
基本信息
请求路径:/depts 请求方式:GET 接口描述:该接口用于部门列表数据查询
-
请求参数
无
-
响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object[ ] 非必须 返回的数据 |- id number 非必须 id |- name string 非必须 部门名称 |- createTime string 非必须 创建时间 |- updateTime string 非必须 修改时间 响应数据样例:
{ "code": 1, "msg": "success", "data": [ { "id": 1, "name": "学工部", "createTime": "2022-09-01T23:06:29", "updateTime": "2022-09-01T23:06:29" }, { "id": 2, "name": "教研部", "createTime": "2022-09-01T23:06:29", "updateTime": "2022-09-01T23:06:29" } ] }
2.1.3 思路分析
2.1.4 功能开发
通过查看接口文档:部门列表查询
请求路径:/depts
请求方式:GET
请求参数:无
响应数据:json格式
DeptController
@Slf4j @RestController public class DeptController { @Autowired private DeptService deptService; //@RequestMapping(value = "/depts" , method = RequestMethod.GET) @GetMapping("/depts") public Result list(){ log.info("查询所有部门数据"); List<Dept> deptList = deptService.list(); return Result.success(deptList); } }
@Slf4j注解源码:
DeptService(业务接口)
public interface DeptService { /** * 查询所有的部门数据 * @return 存储Dept对象的集合 */ List<Dept> list(); }
DeptServiceImpl(业务实现类)
@Slf4j @Service public class DeptServiceImpl implements DeptService { @Autowired private DeptMapper deptMapper; @Override public List<Dept> list() { List<Dept> deptList = deptMapper.list(); return deptList; } }
DeptMapper
@Mapper public interface DeptMapper { //查询所有部门数据 @Select("select id, name, create_time, update_time from dept") List<Dept> list(); }
2.1.5 功能测试
功能开发完成后,我们就可以启动项目,然后打开postman,发起GET请求,访问 :http://localhost:8080/depts
2.2 前后端联调
完成了查询部门的功能,我们也通过postman工具测试通过了,下面我们再基于前后端分离的方式进行接口联调。具体操作如下:
1、将资料中提供的"前端环境"文件夹中的压缩包,拷贝到一个没有中文不带空格的目录下
2、拷贝到一个没有中文不带空格的目录后,进行解压(解压到当前目录)
3、启动nginx
4、打开浏览器,访问:http://localhost:90
5、测试:部门管理 - 查询部门列表
说明:只要按照接口文档开发功能接口,就能保证前后端程序交互
后端:严格遵守接口文档进行功能接口开发
前端:严格遵守接口文档访问功能接口
2.3 删除部门
查询部门的功能我们搞定了,下面我们开始完成删除部门
的功能开发。
2.3.1 需求
点击部门列表后面操作栏的 "删除" 按钮,就可以删除该部门信息。 此时,前端只需要给服务端传递一个ID参数就可以了。 我们从接口文档中也可以看得出来。
2.3.2 接口文档
删除部门
-
基本信息
请求路径:/depts/{id} 请求方式:DELETE 接口描述:该接口用于根据ID删除部门数据
-
请求参数 参数格式:路径参数
参数说明:
参数名 类型 是否必须 备注 id number 必须 部门ID 请求参数样例:
/depts/1
-
响应数据 参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据 响应数据样例:
{ "code":1, "msg":"success", "data":null }
2.3.3 思路分析
接口文档规定:
前端请求路径:/depts/{id}
前端请求方式:DELETE
问题1:怎么在controller中接收请求路径中的路径参数?
@PathVariable问题2:如何限定请求方式是delete?
@DeleteMapping
2.3.4 功能开发
通过查看接口文档:删除部门
请求路径:/depts/{id}
请求方式:DELETE
请求参数:路径参数 {id}
响应数据:json格式
DeptController
@Slf4j @RestController public class DeptController { @Autowired private DeptService deptService; @DeleteMapping("/depts/{id}") public Result delete(@PathVariable Integer id) { //日志记录 log.info("根据id删除部门"); //调用service层功能 deptService.delete(id); //响应 return Result.success(); } //省略... }
DeptService
public interface DeptService { /** * 根据id删除部门 * @param id 部门id */ void delete(Integer id); //省略... }
DeptServiceImpl
@Slf4j @Service public class DeptServiceImpl implements DeptService { @Autowired private DeptMapper deptMapper; @Override public void delete(Integer id) { //调用持久层删除功能 deptMapper.deleteById(id); } //省略... }
DeptMapper
@Mapper public interface DeptMapper { /** * 根据id删除部门信息 * @param id 部门id */ @Delete("delete from dept where id = #{id}") void deleteById(Integer id); //省略... }
2.3.5 功能测试
删除功能开发完成后,重新启动项目,使用postman,发起DELETE请求:
2.3.6 前后端联调
打开浏览器,测试后端功能接口:
2.4 新增部门
我们前面已完成了查询部门
、删除部门
两个功能,也熟悉了开发的流程。下面我们继续完成新增部门
功能。
2.4.1 需求
点击 "新增部门" 按钮,弹出新增部门对话框,输入部门名称,点击 "保存" ,将部门信息保存到数据库。
2.4.2 接口文档
添加部门
-
基本信息
请求路径:/depts 请求方式:POST 接口描述:该接口用于添加部门数据
-
请求参数
格式:application/json
参数说明:
参数名 类型 是否必须 备注 name string 必须 部门名称 请求参数样例:
{ "name": "教研部" }
-
响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据 响应数据样例:
{ "code":1, "msg":"success", "data":null }
2.4.3 思路分析
接口文档规定:
前端请求路径:/depts
前端请求方式:POST
前端请求参数 (Json格式):{ "name": "教研部" }
问题1:如何限定请求方式是POST?
@PostMapping问题2:怎么在controller中接收json格式的请求参数?
@RequestBody //把前端传递的json数据填充到实体类中
2.4.4 功能开发
通过查看接口文档:新增部门
请求路径:/depts
请求方式:POST
请求参数:json格式
响应数据:json格式
DeptController
@Slf4j @RestController public class DeptController { @Autowired private DeptService deptService; @PostMapping("/depts") public Result add(@RequestBody Dept dept){ //记录日志 log.info("新增部门:{}",dept); //调用service层添加功能 deptService.add(dept); //响应 return Result.success(); } //省略... }
DeptService
public interface DeptService { /** * 新增部门 * @param dept 部门对象 */ void add(Dept dept); //省略... }
DeptServiceImpl
@Slf4j @Service public class DeptServiceImpl implements DeptService { @Autowired private DeptMapper deptMapper; @Override public void add(Dept dept) { //补全部门数据 dept.setCreateTime(LocalDateTime.now()); dept.setUpdateTime(LocalDateTime.now()); //调用持久层增加功能 deptMapper.inser(dept); } //省略... }
DeptMapper
@Mapper public interface DeptMapper { @Insert("insert into dept (name, create_time, update_time) values (#{name},#{createTime},#{updateTime})") void inser(Dept dept); //省略... }
2.4.5 功能测试
新增功能开发完成后,重新启动项目,使用postman,发起POST请求:
2.4.6 前后端联调
打开浏览器,测试后端功能接口:
2.4.7 请求路径
我们部门管理的查询
、删除
、新增
功能全部完成了,接下来我们要对controller层的代码进行优化。
首先我们先来看下目前controller层代码:
以上三个方法上的请求路径,存在一个共同点:都是以
/depts
作为开头。(重复了)
在Spring当中为了简化请求路径的定义,可以把公共的请求路径,直接抽取到类上,在类上加一个注解@RequestMapping,并指定请求路径"/depts"。代码参照如下:
优化前后的对比:
注意事项:一个完整的请求路径,应该是类上@RequestMapping的value属性 + 方法上的 @RequestMapping的value属性
3. 员工管理
完成了部门管理的功能开发之后,我们进入到下一环节员工管理功能的开发。
基于以上原型,我们可以把员工管理功能分为:
-
分页查询(今天完成)
-
带条件的分页查询(今天完成)
-
删除员工(今天完成)
-
新增员工(后续完成)
-
修改员工(后续完成)
那下面我们就先从分页查询功能开始学习。
3.1 分页查询
3.1.1 基础分页
3.1.1.1 需求分析
我们之前做的查询功能,是将数据库中所有的数据查询出来并展示到页面上,试想如果数据库中的数据有很多(假设有十几万条)的时候,将数据全部展示出来肯定不现实,那如何解决这个问题呢?
使用分页解决这个问题。每次只展示一页的数据,比如:一页展示10条数据,如果还想看其他的数据,可以通过点击页码进行查询。
要想从数据库中进行分页查询,我们要使用LIMIT
关键字,格式为:limit 开始索引 每页显示的条数
查询第1页数据的SQL语句是:
select * from emp limit 0,10;查询第2页数据的SQL语句是:
select * from emp limit 10,10;查询第3页的数据的SQL语句是:
select * from emp limit 20,10;观察以上SQL语句,发现: 开始索引一直在改变 , 每页显示条数是固定的
开始索引的计算公式: 开始索引 = (当前页码 - 1) * 每页显示条数
我们继续基于页面原型,继续分析,得出以下结论:
-
前端在请求服务端时,传递的参数
-
当前页码 page
-
每页显示条数 pageSize
-
-
后端需要响应什么数据给前端
-
所查询到的数据列表(存储到List 集合中)
-
总记录数
-
后台给前端返回的数据包含:List集合(数据列表)、total(总记录数)
而这两部分我们通常封装到PageBean对象中,并将该对象转换为json格式的数据响应回给浏览器。
@Data @NoArgsConstructor @AllArgsConstructor public class PageBean { private Long total; //总记录数 private List rows; //当前页数据列表 }
3.1.1.2 接口文档
员工列表查询
-
基本信息
请求路径:/emps 请求方式:GET 接口描述:该接口用于员工列表数据的条件分页查询
-
请求参数
参数格式:queryString
参数说明:
参数名称 是否必须 示例 备注 name 否 张 姓名 gender 否 1 性别 , 1 男 , 2 女 begin 否 2010-01-01 范围匹配的开始时间(入职日期) end 否 2020-01-01 范围匹配的结束时间(入职日期) page 是 1 分页查询的页码,如果未指定,默认为1 pageSize 是 10 分页查询的每页记录数,如果未指定,默认为10 请求数据样例:
/emps?name=张&gender=1&begin=2007-09-01&end=2022-09-01&page=1&pageSize=10
-
响应数据
参数格式:application/json
参数说明:
名称 类型 是否必须 默认值 备注 其他信息 code number 必须 响应码, 1 成功 , 0 失败 msg string 非必须 提示信息 data object 必须 返回的数据 |- total number 必须 总记录数 |- rows object [] 必须 数据列表 item 类型: object |- id number 非必须 id |- username string 非必须 用户名 |- name string 非必须 姓名 |- password string 非必须 密码 |- entrydate string 非必须 入职日期 |- gender number 非必须 性别 , 1 男 ; 2 女 |- image string 非必须 图像 |- job number 非必须 职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师 |- deptId number 非必须 部门id |- createTime string 非必须 创建时间 |- updateTime string 非必须 更新时间 响应数据样例:
{ "code": 1, "msg": "success", "data": { "total": 2, "rows": [ { "id": 1, "username": "jinyong", "password": "123456", "name": "金庸", "gender": 1, "image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-53B.jpg", "job": 2, "entrydate": "2015-01-01", "deptId": 2, "createTime": "2022-09-01T23:06:30", "updateTime": "2022-09-02T00:29:04" }, { "id": 2, "username": "zhangwuji", "password": "123456", "name": "张无忌", "gender": 1, "image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-53B.jpg", "job": 2, "entrydate": "2015-01-01", "deptId": 2, "createTime": "2022-09-01T23:06:30", "updateTime": "2022-09-02T00:29:04" } ] } }
3.1.1.3 思路分析
分页查询需要的数据,封装在PageBean对象中:
3.1.1.4 功能开发
通过查看接口文档:员工列表查询
请求路径:/emps
请求方式:GET
请求参数:跟随在请求路径后的参数字符串。 例:/emps?page=1&pageSize=10
响应数据:json格式
EmpController
package com.example.controller;
import com.example.pojo.PageBean;
import com.example.pojo.Result;
import com.example.service.EmpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description 员工管理controller
*/
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer pageSize) {
log.info("分页查询,参数:{},{}", page, pageSize);
//调用service分页查询
PageBean pageBean = empService.page(page, pageSize);
return Result.success(pageBean);
}
}
@RequestParam(defaultValue="默认值") //设置请求参数默认值
EmpService
package com.example.service;
import com.example.pojo.PageBean;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description This is description of code
*/
public interface EmpService {
/*分页查询*/
PageBean page(Integer page, Integer pageSize);
}
EmpServiceImpl
package com.example.service.impl;
import com.example.mapper.EmpMapper;
import com.example.pojo.Emp;
import com.example.pojo.PageBean;
import com.example.service.EmpService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:59
* @Description 员工管理service
*/
@Service
public class EmpServiceImpl implements EmpService {
@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> rows = empMapper.page(start, pageSize);
//3.封装PageBean对象
PageBean pageBean = new PageBean(count, rows);
pageBean.setTotal(count);
return pageBean;
}
}
EmpMapper
package com.example.mapper;
import com.example.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:57
* @Description 员工管理
*/
@Mapper
public interface EmpMapper {
//获取总记录数
@Select("select count(*) from emp")
Long count();
//获取分页查询结果列表
@Select("select * from emp limit #{start},#{pageSize}")
List<Emp> page(Integer start, Integer pageSize);
}
3.1.1.5 功能测试
功能开发完成后,重新启动项目,使用postman,发起POST请求:
3.1.1.6 前后端联调
打开浏览器,测试后端功能接口:
3.1.2 分页插件
3.1.2.1 介绍
前面我们已经完了基础的分页查询,大家会发现:分页查询功能编写起来比较繁琐。
在Mapper接口中定义两个方法执行两条不同的SQL语句:
查询总记录数
指定页码的数据列表
在Service当中,调用Mapper接口的两个方法,分别获取:总记录数、查询结果列表,然后在将获取的数据结果封装到PageBean对象中。
大家思考下:在未来开发其他项目,只要涉及到分页查询功能(例:订单、用户、支付、商品),都必须按照以上操作完成功能开发
结论:原始方式的分页查询,存在着"步骤固定"、"代码频繁"的问题
解决方案:可以使用一些现成的分页插件完成。对于Mybatis来讲现在最主流的就是PageHelper。
PageHelper是Mybatis的一款功能强大、方便易用的分页插件,支持任何形式的单标、多表的分页查询。
在执行empMapper.list()方法时,就是执行:select * from emp 语句,怎么能够实现分页操作呢?
分页插件帮我们完成了以下操作:
先获取到要执行的SQL语句:select * from emp
把SQL语句中的字段列表,变为:count(*)
执行SQL语句:select count(*) from emp //获取到总记录数
再对要执行的SQL语句:select * from emp 进行改造,在末尾添加 limit ? , ?
执行改造后的SQL语句:select * from emp limit ? , ?
3.1.2.2 代码实现
当使用了PageHelper分页插件进行分页,就无需再Mapper中进行手动分页了。 在Mapper中我们只需要进行正常的列表查询即可。在Service层中,调用Mapper的方法之前设置分页参数,在调用Mapper方法执行查询之后,解析分页结果,并将结果封装到PageBean对象中返回。
1、在pom.xml引入依赖
注:springboot2.x版本引入1.4.2
springboot3.x版本引入1.4.6(3.x引入1.4.2会报错)
<!--PageHelper分页插件-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.6</version>
</dependency>
2、EmpMapper
package com.example.mapper;
import com.example.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:57
* @Description 员工管理
*/
@Mapper
public interface EmpMapper {
/*员工信息查询*/
@Select("select * from emp")
List<Emp> list();
}
3、EmpServiceImpl
package com.example.service.impl;
import com.example.mapper.EmpMapper;
import com.example.pojo.Emp;
import com.example.pojo.PageBean;
import com.example.service.EmpService;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:59
* @Description 员工管理service
*/
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public PageBean page(Integer page, Integer pageSize) {
//1.设置分页参数
PageHelper.startPage(page, pageSize);
//2.执行查询
List<Emp> empList = empMapper.list();
Page<Emp> p = (Page<Emp>) empList;
//3.封装PageBean对象
PageBean pageBean = new PageBean(p.getTotal(), p.getResult());
return pageBean;
}
}
3.1.2.3 测试
功能开发完成后,我们重启项目工程,打开postman,发起GET请求,访问 :http://localhost:8080/emps?page=1&pageSize=5
后端程序SQL输出:
3.2 分页查询(带条件)
完了分页查询后,下面我们需要在分页查询的基础上,添加条件。
3.2.1 需求
通过员工管理的页面原型我们可以看到,员工列表页面的查询,不仅仅需要考虑分页,还需要考虑查询条件。 分页查询我们已经实现了,接下来,我们需要考虑在分页查询的基础上,再加上查询条件。
我们看到页面原型及需求中描述,搜索栏的搜索条件有三个,分别是:
-
姓名:模糊匹配
-
性别:精确匹配
-
入职日期:范围匹配
select * from emp where name like concat('%','张','%') -- 条件1:根据姓名模糊匹配 and gender = 1 -- 条件2:根据性别精确匹配 and entrydate = between '2000-01-01' and '2010-01-01' -- 条件3:根据入职日期范围匹配 order by update_time desc;
而且上述的三个条件,都是可以传递,也可以不传递的,也就是动态的。 我们需要使用前面学习的Mybatis中的动态SQL 。
3.2.2 思路分析
3.2.3 功能开发
通过查看接口文档:员工列表查询
请求路径:/emps
请求方式:GET
请求参数:
参数名称 是否必须 示例 备注 name 否 张 姓名 gender 否 1 性别 , 1 男 , 2 女 begin 否 2010-01-01 范围匹配的开始时间(入职日期) end 否 2020-01-01 范围匹配的结束时间(入职日期) page 是 1 分页查询的页码,如果未指定,默认为1 pageSize 是 10 分页查询的每页记录数,如果未指定,默认为10
在原有分页查询的代码基础上进行改造:
EmpController
package com.example.controller;
import com.example.pojo.PageBean;
import com.example.pojo.Result;
import com.example.service.EmpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description 员工管理controller
*/
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
//条件分页查询
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
String name,
Short gender,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
//记录日志
log.info("分页条件查询,参数:{},{},{},{},{},{}", page, pageSize, name, gender, begin, end);
//调用service分页查询
PageBean pageBean = empService.page(page, pageSize, name, gender, begin, end);
//响应
return Result.success(pageBean);
}
}
EmpService
package com.example.service;
import com.example.pojo.PageBean;
import java.time.LocalDate;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description This is description of code
*/
public interface EmpService {
/**
* 条件分页查询
*
* @param page 页码
* @param pageSize 每页展示记录数
* @param name 姓名
* @param gender 性别
* @param begin 开始时间
* @param end 结束时间
* @return
*/
/*条件分页查询*/
PageBean page(Integer page, Integer pageSize, String name, Short gender, LocalDate begin, LocalDate end);
}
EmpServiceImpl
package com.example.service.impl;
import com.example.mapper.EmpMapper;
import com.example.pojo.Emp;
import com.example.pojo.PageBean;
import com.example.service.EmpService;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:59
* @Description 员工管理service
*/
@Service
public class EmpServiceImpl implements EmpService {
@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> p = (Page<Emp>) empList;
//3.封装PageBean对象
PageBean pageBean = new PageBean(p.getTotal(), p.getResult());
return pageBean;
}
}
EmpMapper
package com.example.mapper;
import com.example.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:57
* @Description 员工管理
*/
@Mapper
public interface EmpMapper {
List<Emp> list(String name, Short gender, LocalDate begin, LocalDate end);
}
EmpMapper.xml
目录结构:src/main/resource/com/example/mapper/EmpMapper.xml
与src/main/java/com/example/mapper.EmpMapper.java保持一致
<?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.example.mapper.EmpMapper">
<!--条件查询-->
<select id="list" resultType="com.example.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>
3.2.4 功能测试
功能开发完成后,重启项目工程,打开Apifox,发起GET请求:
控制台SQL语句:
说明:
<if test="name!=null and name!=''">
name!='' 表示前端传递的name为空字符串(即:不传name值),控制台sql语句会出现如下情况:
因此 需加上判断条件 name!=''
3.2.5 前后端联调
打开浏览器,测试后端功能接口:
3.3 删除员工
查询员完成之后,我们继续开发新的功能:删除员工。
3.3.1 需求
当我们勾选列表前面的复选框,然后点击 "批量删除" 按钮,就可以将这一批次的员工信息删除掉了。也可以只勾选一个复选框,仅删除一个员工信息。
问题:我们需要开发两个功能接口吗?一个删除单个员工,一个删除多个员工
答案:不需要。 只需要开发一个功能接口即可(删除多个员工包含只删除一个员工)
3.3.2 接口文档
删除员工
-
基本信息
请求路径:/emps/{ids} 请求方式:DELETE 接口描述:该接口用于批量删除员工的数据信息
-
请求参数
参数格式:路径参数
参数说明:
参数名 类型 示例 是否必须 备注 ids 数组 array 1,2,3 必须 员工的id数组 请求参数样例:
/emps/1,2,3
-
响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据 响应数据样例:
{ "code":1, "msg":"success", "data":null }
3.3.3 思路分析
接口文档规定:
前端请求路径:/emps/{ids}
前端请求方式:DELETE
问题1:怎么在controller中接收请求路径中的路径参数?
@PathVariable问题2:如何限定请求方式是delete?
@DeleteMapping问题3:在Mapper接口中,执行delete操作的SQL语句时,条件中的id值是不确定的是动态的,怎么实现呢?
Mybatis中的动态SQL:foreach
3.3.4 功能开发
通过查看接口文档:删除员工
请求路径:/emps/{ids}
请求方式:DELETE
请求参数:路径参数 {ids}
响应数据:json格式
EmpController
package com.example.controller;
import com.example.pojo.PageBean;
import com.example.pojo.Result;
import com.example.service.EmpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description 员工管理controller
*/
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
//批量删除员工
@DeleteMapping("/{ids}")
public Result delete(@PathVariable Integer[] ids) {
empService.delete(ids);
return Result.success();
}
}
EmpService
package com.example.service;
import com.example.pojo.PageBean;
import java.time.LocalDate;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description This is description of code
*/
public interface EmpService {
//批量删除员工
void delete(Integer[] ids);
}
EmpServiceImpl
package com.example.service.impl;
import com.example.mapper.EmpMapper;
import com.example.pojo.Emp;
import com.example.pojo.PageBean;
import com.example.service.EmpService;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:59
* @Description 员工管理service
*/
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public void delete(Integer[]ids) {
empMapper.delete(ids);
}
}
EmpMapper
package com.example.mapper;
import com.example.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:57
* @Description 员工管理
*/
@Mapper
public interface EmpMapper {
//批量删除员工
void delete(Integer[]ids);
}
EmpMapper.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.example.mapper.EmpMapper">
<!--批量删除员工-->
<delete id="delete">
delete from emp where id in
<foreach collection="ids" item="id" separator=","
open="(" close=")">
#{id}
</foreach>
</delete>
</mapper>
3.3.5 功能测试
功能开发完成后,重启项目工程,打开postman,发起DELETE请求:
控制台SQL语句:
3.3.6 前后端联调
打开浏览器,测试后端功能接口:
4. 新增员工
4.1 需求
在新增用户时,我们需要保存用户的基本信息,并且还需要上传的员工的图片,目前我们先完成第一步操作,保存用户的基本信息。
4.2 接口文档
我们参照接口文档来开发新增员工功能
-
基本信息
请求路径:/emps 请求方式:POST 接口描述:该接口用于添加员工的信息
-
请求参数
参数格式:application/json
参数说明:
名称 类型 是否必须 备注 username string 必须 用户名 name string 必须 姓名 gender number 必须 性别, 说明: 1 男, 2 女 image string 非必须 图像 deptId number 非必须 部门id entrydate string 非必须 入职日期 job number 非必须 职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师 请求数据样例:
{ "image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-03-07-37-38222.jpg", "username": "linpingzhi", "name": "林平之", "gender": 1, "job": 1, "entrydate": "2022-09-18", "deptId": 1 }
-
响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据 响应数据样例:
{ "code":1, "msg":"success", "data":null }
4.3 思路分析
新增员工的具体的流程:
接口文档规定:
请求路径:/emps
请求方式:POST
请求参数:Json格式数据
响应数据:Json格式数据
问题1:如何限定请求方式是POST?
@PostMapping问题2:怎么在controller中接收json格式的请求参数?
@RequestBody //把前端传递的json数据填充到实体类中
4.4 功能开发
EmpController
package com.example.controller;
import com.example.pojo.Emp;
import com.example.pojo.PageBean;
import com.example.pojo.Result;
import com.example.service.EmpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description 员工管理controller
*/
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
//新增员工
@PostMapping
public Result save(@RequestBody Emp emp) {
log.info("新增员工,emp:{}", emp);
empService.save(emp);
return Result.success();
}
}
EmpService
package com.example.service;
import com.example.pojo.Emp;
import com.example.pojo.PageBean;
import java.time.LocalDate;
/**
* @Auther lmy
* @date 2024-04-26 20:58
* @Description This is description of code
*/
public interface EmpService {
//新增员工
void save(Emp emp);
}
EmpServiceImpl
package com.example.service.impl;
import com.example.mapper.EmpMapper;
import com.example.pojo.Emp;
import com.example.pojo.PageBean;
import com.example.service.EmpService;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:59
* @Description 员工管理service
*/
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public void save(Emp emp) {
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
empMapper.save(emp);
}
}
EmpMapper
package com.example.mapper;
import com.example.pojo.Emp;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
import java.util.List;
/**
* @Auther lmy
* @date 2024-04-26 20:57
* @Description 员工管理
*/
@Mapper
public interface EmpMapper {
//新增员工
@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 save(Emp emp);
}
4.5 功能测试
代码开发完成后,重启服务器,打开Postman发送 POST 请求,请求路径:http://localhost:8080/emps
4.6 前后端联调
功能测试通过后,我们再进行通过打开浏览器,测试后端功能接口:
5. 文件上传
在我们完成的新增员工功能中,还存在一个问题:没有头像(图片缺失)
上述问题,需要我们通过文件上传技术来解决。下面我们就进入到文件上传技术的学习。
文件上传技术这块我们主要讲解三个方面:首先我们先对文件上传做一个整体的介绍,接着再学习文件上传的本地存储方式,最后学习云存储方式。
接下来我们就先来学习下什么是文件上传。
5.1 简介
文件上传,是指将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。
文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。
在我们的案例中,在新增员工的时候,要上传员工的头像,此时就会涉及到文件上传的功能。在进行文件上传时,我们点击加号或者是点击图片,就可以选择手机或者是电脑本地的图片文件了。当我们选择了某一个图片文件之后,这个文件就会上传到服务器,从而完成文件上传的操作。
想要完成文件上传这个功能需要涉及到两个部分:
-
前端程序
-
服务端程序
我们先来看看在前端程序中要完成哪些代码:
<form action="/upload" method="post" enctype="multipart/form-data"> 姓名: <input type="text" name="username"><br> 年龄: <input type="text" name="age"><br> 头像: <input type="file" name="image"><br> <input type="submit" value="提交"> </form>
上传文件的原始form表单,要求表单必须具备以下三点(上传文件页面三要素):
-
表单必须有file域,用于选择要上传的文件
<input type="file" name="image"/>
-
表单提交方式必须为POST
通常上传的文件会比较大,所以需要使用 POST 提交方式
-
表单的编码类型enctype必须要设置为:multipart/form-data
普通默认的编码格式是不适合传输大型的二进制数据的,所以在文件上传时,表单的编码格式必须设置为multipart/form-data
前端页面的3要素我们了解后,接下来我们就来验证下所讲解的文件上传3要素。
在提供的"课程资料"中有一个名叫"文件上传"的文件夹,直接将里的"upload.html"文件,复制到springboot项目工程下的static目录里面。
下面我们来验证:删除form表单中enctype属性值,会是什么情况?
-
在IDEA中直接使用浏览器打开upload.html页面
-
选择要上传的本地文件
3.点击"提交"按钮,进入到开发者模式观察
我们再来验证:设置form表单中enctype属性值为multipart/form-data,会是什么情况?
<form action="/upload" method="post" enctype="multipart/form-data"> 姓名: <input type="text" name="username"><br> 年龄: <input type="text" name="age"><br> 头像: <input type="file" name="image"><br> <input type="submit" value="提交"> </form>
知道了前端程序中需要设置上传文件页面三要素,那我们的后端程序又是如何实现的呢?
-
首先在服务端定义这么一个controller,用来进行文件上传,然后在controller当中定义一个方法来处理
/upload
请求 -
在定义的方法中接收提交过来的数据 (方法中的形参名和请求参数的名字保持一致)
-
用户名:String name
-
年龄: Integer age
-
文件: MultipartFile image
Spring中提供了一个API:MultipartFile,使用这个API就可以来接收到上传的文件
-
问题:如果表单项的名字和方法中形参名不一致,该怎么办?
public Result upload(String username, Integer age, MultipartFile file) //file形参名和请求参数名image不一致解决:使用@RequestParam注解进行参数绑定
public Result upload(String username, Integer age, @RequestParam("image") MultipartFile file)
UploadController代码:
package com.example.controller;
import com.example.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;
/**
* @Auther lmy
* @date 2024-04-30 3:27
* @Description This is description of code
*/
@Slf4j
@RestController
public class UploadController {
@PostMapping("/upload")
public Result upload(String username, Integer age, MultipartFile image) {
log.info("文件上传:{},{},{}", username, age, image);
return Result.success();
}
}
后端程序编写完成之后,打个断点,以debug方式启动SpringBoot项目
打开浏览器输入:http://localhost:8080/upload.html , 录入数据并提交
通过后端程序控制台可以看到,上传的文件是存放在一个临时目录
打开临时目录可以看到以下内容:
表单提交的三项数据(姓名、年龄、文件),分别存储在不同的临时文件中:
当我们程序运行完毕之后,这个临时文件会自动删除。
所以,我们如果想要实现文件上传,需要将这个临时文件,要转存到我们的磁盘目录中。
5.2 本地存储
前面我们已分析了文件上传功能前端和后端的基础代码实现,文件上传时在服务端会产生一个临时文件,请求响应完成之后,这个临时文件被自动删除,并没有进行保存。下面呢,我们就需要完成将上传的文件保存在服务器的本地磁盘上。
代码实现:
-
在服务器本地磁盘上创建images目录,用来存储上传的文件(例:E盘创建images目录)
-
使用MultipartFile类提供的API方法,把临时文件转存到本地磁盘目录下
MultipartFile 常见方法:
String getOriginalFilename(); //获取原始文件名
void transferTo(File dest); //将接收的文件转存到磁盘文件中
long getSize(); //获取文件的大小,单位:字节
byte[] getBytes(); //获取文件内容的字节数组
InputStream getInputStream(); //获取接收到的文件内容的输入流
package com.example.controller;
import com.example.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;
/**
* @Auther lmy
* @date 2024-04-30 3:27
* @Description This is description of code
*/
@Slf4j
@RestController
public class UploadController {
@PostMapping("/upload")
public Result upload(String username, Integer age, MultipartFile image) throws Exception {
log.info("文件上传:{},{},{}", username, age, image);
//获取原始文件名
String originalFilename = image.getOriginalFilename();
//将文件存储在服务器的磁盘目录
image.transferTo(new File("C:\\Users\\xzq\\Desktop\\" + originalFilename));
return Result.success();
}
}
利用Apifox测试:
注意:请求参数名和controller方法形参名保持一致
通过Apifox测试,我们发现文件上传是没有问题的。但是由于我们是使用原始文件名作为所上传文件的存储名字,当我们再次上传一个同名jpg文件时,发现会把之前已经上传成功的文件覆盖掉。
解决方案:保证每次上传文件时文件名都唯一的(使用UUID获取随机文件名)
package com.example.controller;
import com.example.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;
/**
* @Auther lmy
* @date 2024-04-30 3:27
* @Description This is description of code
*/
@Slf4j
@RestController
public class UploadController {
@PostMapping("/upload")
public Result upload(String username, Integer age, MultipartFile image) throws Exception {
log.info("文件上传:{},{},{}", username, age, image);
//获取原始文件名
String originalFilename = image.getOriginalFilename();
//获取文件扩展名
String extname = originalFilename.substring(originalFilename.lastIndexOf("."));
//随机名(不能重复)+文件扩展名--uuid(通用唯一识别码)
String newFileName = UUID.randomUUID().toString() + extname;
//将文件存储在服务器的磁盘目录
image.transferTo(new File("C:\\Users\\xzq\\Desktop\\" + newFileName));
return Result.success();
}
}
在解决了文件名唯一性的问题后,我们再次上传一个较大的文件(超出1M)时发现,后端程序报错:
报错原因呢是因为:在SpringBoot中,文件上传时默认单个文件最大大小为1M
那么如果需要上传大文件,可以在application.properties进行如下配置:
#配置单个文件最大上传大小 spring.servlet.multipart.max-file-size=10MB #配置单个请求最大上传大小(一次请求可以上传多个文件) spring.servlet.multipart.max-request-size=100MB
到时此,我们文件上传的本地存储方式已完成了。但是这种本地存储方式还存在一问题:
如果直接存储在服务器的磁盘目录中,存在以下缺点:
-
不安全:磁盘如果损坏,所有的文件就会丢失
-
容量有限:如果存储大量的图片,磁盘空间有限(磁盘不可能无限制扩容)
-
无法直接访问
为了解决上述问题呢,通常有两种解决方案:
-
自己搭建存储服务器,如:fastDFS 、MinIO
-
使用现成的云服务,如:阿里云,腾讯云,华为云
5.3 阿里云OSS
5.3.1 准备
阿里云是阿里巴巴集团旗下全球领先的云计算公司,也是国内最大的云服务提供商 。
云服务指的就是通过互联网对外提供的各种各样的服务,比如像:语音服务、短信服务、邮件服务、视频直播服务、文字识别服务、对象存储服务等等。
当我们在项目开发时需要用到某个或某些服务,就不需要自己来开发了,可以直接使用阿里云提供好的这些现成服务就可以了。比如:在项目开发当中,我们要实现一个短信发送的功能,如果我们项目组自己实现,将会非常繁琐,因为你需要和各个运营商进行对接。而此时阿里云完成了和三大运营商对接,并对外提供了一个短信服务。我们项目组只需要调用阿里云提供的短信服务,就可以很方便的来发送短信了。这样就降低了我们项目的开发难度,同时也提高了项目的开发效率。(大白话:别人帮我们实现好了功能,我们只要调用即可)
云服务提供商给我们提供的软件服务通常是需要收取一部分费用的。
阿里云对象存储OSS(Object Storage Service),是一款海量、安全、低成本、高可靠的云存储服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。
在我们使用了阿里云OSS对象存储服务之后,我们的项目当中如果涉及到文件上传这样的业务,在前端进行文件上传并请求到服务端时,在服务器本地磁盘当中就不需要再来存储文件了。我们直接将接收到的文件上传到oss,由 oss帮我们存储和管理,同时阿里云的oss存储服务还保障了我们所存储内容的安全可靠。
那我们学习使用这类云服务,我们主要学习什么呢?其实我们主要学习的是如何在项目当中来使用云服务完成具体的业务功能。而无论使用什么样的云服务,阿里云也好,腾讯云、华为云也罢,在使用第三方的服务时,操作的思路都是一样的。
SDK:Software Development Kit 的缩写,软件开发工具包,包括辅助软件开发的依赖(jar包)、代码示例等,都可以叫做SDK。
简单说,sdk中包含了我们使用第三方云服务时所需要的依赖,以及一些示例代码。我们可以参照sdk所提供的示例代码就可以完成入门程序。
第三方服务使用的通用思路,我们做一个简单介绍之后,接下来我们就来介绍一下我们当前要使用的阿里云oss对象存储服务具体的使用步骤。
Bucket:存储空间是用户用于存储对象(Object,就是文件)的容器,所有的对象都必须隶属于某个存储空间。
下面我们根据之前介绍的使用步骤,完成准备工作:
-
注册阿里云账户(注册完成后需要实名认证)
-
注册完账号之后,就可以登录阿里云
3.通过控制台找到对象存储OSS服务
如果是第一次访问,还需要开通对象存储服务OSS
4.开通OSS服务之后,就可以进入到阿里云对象存储的控制台
5.点击左侧的 "Bucket列表",创建一个Bucket
6.获取AccessKey(秘钥)
7.从环境变量中获取访问凭证。设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
大家可以参照"资料\04. 阿里云oss"中提供的文档,开通阿里云OSS服务。
5.3.2 入门
阿里云oss 对象存储服务的准备工作我们已经完成了,接下来我们就来完成第二步操作:参照官方所提供的sdk示例来编写入门程序。
首先我们需要来打开阿里云OSS的官方文档,在官方文档中找到 SDK 的示例代码:
如果是在实际开发当中,我们是需要从前往后仔细的去阅读这一份文档的,但是由于现在是教学,我们就只挑重点的去看。有兴趣的同学大家下来也可以自己去看一下这份官方文档。
参照官方提供的SDK,改造一下,即可实现文件上传功能:
package com.example;
import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.PutObjectResult;
import java.io.FileInputStream;
import java.io.InputStream;
/**
* @Auther lmy
* @date 2024-05-01 1:47
* @Description This is description of code
*/
public class Demo1 {
public static void main(String[] args) throws Exception {
// Endpoint以华北2(北京)为例,其它Region请按实际情况填写。
String endpoint = "oss-cn-beijing.aliyuncs.com";
// 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。
String accessKeyId = "LTAI5t7QjVfMWiw9SBvcafJc";
String accessKeySecret = "X7oGAeGzchpGFn2yE9Pq7fSRhFbwXa";
// 填写Bucket名称,例如examplebucket。
String bucketName = "web-lmy";
// 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。
String objectName = "1.jpg";
// 填写本地文件的完整路径,例如D:\\localpath\\examplefile.txt。
// 如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件流。
String filePath= "C:\\Users\\xzq\\Desktop\\1.jpg";
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
InputStream inputStream = new FileInputStream(filePath);
// 创建PutObjectRequest对象。
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, inputStream);
// 设置该属性可以返回response。如果不设置,则返回的response为空。
putObjectRequest.setProcess("true");
// 创建PutObject请求。
PutObjectResult result = ossClient.putObject(putObjectRequest);
// 如果上传成功,则返回200。
System.out.println(result.getResponse().getStatusCode());
} 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();
}
}
}
}
在以上代码中,需要替换的内容为:
accessKeyId:阿里云账号AccessKey
accessKeySecret:阿里云账号AccessKey对应的秘钥
bucketName:Bucket名称
objectName:对象名称,在Bucket中存储的对象的名称
filePath:文件路径
AccessKey :
运行以上程序后,会把本地的文件上传到阿里云OSS服务器上:
5.3.3 集成
阿里云oss对象存储服务的准备工作以及入门程序我们都已经完成了,接下来我们就需要在案例当中集成oss对象存储服务,来存储和管理案例中上传的图片。
在新增员工的时候,上传员工的图像,而之所以需要上传员工的图像,是因为将来我们需要在系统页面当中访问并展示员工的图像。而要想完成这个操作,需要做两件事:
需要上传员工的图像,并把图像保存起来(存储到阿里云OSS)
访问员工图像(通过图像在阿里云OSS的存储地址访问图像)
OSS中的每一个文件都会分配一个访问的url,通过这个url就可以访问到存储在阿里云上的图片。所以需要把url返回给前端,这样前端就可以通过url获取到图像。
我们参照接口文档来开发文件上传功能:
-
基本信息
请求路径:/upload 请求方式:POST 接口描述:上传图片接口
-
请求参数
参数格式:multipart/form-data
参数说明:
参数名称 参数类型 是否必须 示例 备注 image file 是 -
响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据,上传图片的访问路径 响应数据样例:
{ "code": 1, "msg": "success", "data": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-0400.jpg" }
引入阿里云OSS上传文件工具类(由官方的示例代码改造而来)
package com.example.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";
private String accessKeyId = "LTAI5t7QjVfMWiw9SBvcafJc";
private String accessKeySecret = "X7oGAeGzchpGFn2yE9Pq7fSRhFbwXa";
private String bucketName = "web-lmy";
/**
* 实现上传图片到OSS
*/
public String upload(MultipartFile file) throws IOException {
// 获取上传的文件的输入流
InputStream inputStream = file.getInputStream();
// 避免文件覆盖
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);
//文件访问路径
String url = endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + fileName;
// 关闭ossClient
ossClient.shutdown();
return url;// 把上传到oss的路径返回
}
}
修改UploadController代码:
package com.example.controller;
import com.example.pojo.Result;
import com.example.utils.AliOSSUtils;
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;
/**
* @Auther lmy
* @date 2024-04-30 3:27
* @Description This is description of code
*/
@Slf4j
@RestController
public class UploadController {
@Autowired
private AliOSSUtils aliOSSUtils;
@PostMapping("/upload")
public Result upload(MultipartFile image) throws Exception {
log.info("文件上传:{}", image.getOriginalFilename());
//调用阿里云OSS工具类进行文件上传,将上传上来的文件存入阿里云
String url = aliOSSUtils.upload(image);
log.info("文件上传完成,文件访问的url:{}", url);
//将图片上传完成后的url返回,用于浏览器回显展示
return Result.success(url);
}
}
使用postman测试:
6. 修改员工
需求:修改员工信息
在进行修改员工信息的时候,我们首先先要根据员工的ID查询员工的信息用于页面回显展示,然后用户修改员工数据之后,点击保存按钮,就可以将修改的数据提交到服务端,保存到数据库。 具体操作为:
-
根据ID查询员工信息
-
保存修改的员工信息
6.1 查询回显
6.1.1 接口文档
根据ID查询员工数据
-
基本信息
请求路径:/emps/{id} 请求方式:GET 接口描述:该接口用于根据主键ID查询员工的信息
-
请求参数
参数格式:路径参数
参数说明:
参数名 类型 是否必须 备注 id number 必须 员工ID 请求参数样例:
/emps/1
-
响应数据
参数格式:application/json
参数说明:
名称 类型 是否必须 默认值 备注 code number 必须 响应码, 1 成功 , 0 失败 msg string 非必须 提示信息 data object 必须 返回的数据 |- id number 非必须 id |- username string 非必须 用户名 |- name string 非必须 姓名 |- password string 非必须 密码 |- entrydate string 非必须 入职日期 |- gender number 非必须 性别 , 1 男 ; 2 女 |- image string 非必须 图像 |- job number 非必须 职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师 |- deptId number 非必须 部门id |- createTime string 非必须 创建时间 |- updateTime string 非必须 更新时间 响应数据样例:
{ "code": 1, "msg": "success", "data": { "id": 2, "username": "zhangwuji", "password": "123456", "name": "张无忌", "gender": 1, "image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-53B.jpg", "job": 2, "entrydate": "2015-01-01", "deptId": 2, "createTime": "2022-09-01T23:06:30", "updateTime": "2022-09-02T00:29:04" } }
6.1.2 实现思路
6.1.3 代码实现
-
EmpMapper
package com.example.mapper;
import com.example.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
import java.util.List;
@Mapper
public interface EmpMapper {
/*根据id查询员工*/
@Select("select * from emp where id=#{id}")
Emp getById(Integer id);
}
-
EmpService
package com.example.service;
import com.example.pojo.Emp;
public interface EmpService {
/*根据id查询员工*/
Emp getById(Integer id);
}
-
EmpServiceImpl
package com.example.service.impl;
import com.example.mapper.EmpMapper;
import com.example.pojo.Emp;
import com.example.service.EmpService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public Emp getById(Integer id) {
return empMapper.getById(id);
}
}
-
EmpController
package com.example.controller;
import com.example.pojo.Emp;
import com.example.pojo.Result;
import com.example.service.EmpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
/*根据id查询员工*/
@GetMapping("/{id}")
public Result getById(@PathVariable Integer id){
log.info("根据ID查询员工信息, id:{}",id);
Emp emp=empService.getById(id);
return Result.success(emp);
}
}
6.1.4 apifox测试
6.2 修改员工
当用户修改完数据之后,点击保存按钮,就需要将数据提交到服务端,然后服务端需要将修改后的数据更新到数据库中。
6.2.1 接口文档
-
基本信息
请求路径:/emps 请求方式:PUT 接口描述:该接口用于修改员工的数据信息
-
请求参数
参数格式:application/json
参数说明:
名称 类型 是否必须 备注 id number 必须 id username string 必须 用户名 name string 必须 姓名 gender number 必须 性别, 说明: 1 男, 2 女 image string 非必须 图像 deptId number 非必须 部门id entrydate string 非必须 入职日期 job number 非必须 职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师 请求数据样例:
{ "id": 1, "image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-03-07-37-38222.jpg", "username": "linpingzhi", "name": "林平之", "gender": 1, "job": 1, "entrydate": "2022-09-18", "deptId": 1 }
-
响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据 响应数据样例:
{ "code":1, "msg":"success", "data":null }
6.2.2 实现思路
6.2.3 代码实现
-
EmpMapper
package com.example.mapper;
import com.example.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EmpMapper {
/*修改员工*/
void update(Emp emp);
}
-
EmpMapper.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.example.mapper.EmpMapper">
<!--更新员工信息-->
<update id="update">
update emp
<set>
<if test="username != null">
username=#{username},
</if>
<if test="name != null">
name=#{name},
</if>
<if test="gender != null">
gender=#{gender},
</if>
<if test="image != null">
image=#{image},
</if>
<if test="deptId != null">
dept_id=#{deptId},
</if>
<if test="entrydate != null">
entrydate=#{entrydate},
</if>
<if test="job != null">
job=#{job},
</if>
<if test="updateTime != null">
update_time=#{updateTime}
</if>
</set>
where id=#{id}
</update>
</mapper>
-
EmpService
package com.example.service;
import com.example.pojo.Emp;
public interface EmpService {
/*修改员工*/
void update(Emp emp);
}
-
EmpServiceImpl
package com.example.service.impl;
import com.example.mapper.EmpMapper;
import com.example.pojo.Emp;
import com.example.service.EmpService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public void update(Emp emp) {
emp.setUpdateTime(LocalDateTime.now());
empMapper.update(emp);
}
}
-
EmpController
package com.example.controller;
import com.example.pojo.Emp;
import com.example.pojo.Result;
import com.example.service.EmpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
/*修改员工*/
@PutMapping
public Result update(@RequestBody Emp emp){
log.info("更新员工信息:{}",emp);
empService.update(emp);
return Result.success();
}
}
6.2.4 apifox测试
6.2.5 前后端联调测试
7. 配置文件
员工管理的增删改查功能我们已开发完成,但在我们所开发的程序中还一些小问题,下面我们就来分析一下当前案例中存在的问题以及如何优化解决。
7.1 参数配置化
在我们之前编写的程序中进行文件上传时,需要调用AliOSSUtils工具类,将文件上传到阿里云OSS对象存储服务当中。而在调用工具类进行文件上传时,需要一些参数:
-
endpoint //阿里云OSS域名
-
accessKeyID //用户身份ID
-
accessKeySecret //用户密钥
-
bucketName //存储空间的名字
关于以上的这些阿里云相关配置信息,我们是直接写死在java代码中了(硬编码),如果我们在做项目时每涉及到一个第三方技术服务,就将其参数硬编码,那么在Java程序中会存在两个问题:
-
如果这些参数发生变化了,就必须在源程序代码中改动这些参数,然后需要重新进行代码的编译,将Java代码编译成class字节码文件再重新运行程序。(比较繁琐)
-
如果我们开发的是一个真实的企业级项目, Java类可能会有很多,如果将这些参数分散的定义在各个Java类当中,我们要修改一个参数值,我们就需要在众多的Java代码当中来定位到对应的位置,再来修改参数,修改完毕之后再重新编译再运行。(参数配置过于分散,是不方便集中的管理和维护)
为了解决以上分析的问题,我们可以将参数配置在配置文件中。如下:
#阿里云OSS配置
aliyun.oss.endpoint=https://oss-cn-beijing.aliyuncs.com
aliyun.oss.accessKeyId=LTAI5t7QjVfMWiw9SBvcafJc
aliyun.oss.accessKeySecret=X7oGAeGzchpGFn2yE9Pq7fSRhFbwXa
aliyun.oss.bucketName=web-lmy
在将阿里云OSS配置参数交给properties配置文件来管理之后,我们的AliOSSUtils工具类就变为以下形式:
@Component public class AliOSSUtils { /*以下4个参数没有指定值(默认值:null)*/ private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; //省略其他代码... }
而此时如果直接调用AliOSSUtils类当中的upload方法进行文件上传时,这4项参数全部为null,原因是因为并没有给它赋值。
此时我们是不是需要将配置文件当中所配置的属性值读取出来,并分别赋值给AliOSSUtils工具类当中的各个属性呢?那应该怎么做呢?
因为application.properties是springboot项目默认的配置文件,所以springboot程序在启动时会默认读取application.properties配置文件,而我们可以使用一个现成的注解:@Value,获取配置文件中的数据。
@Value 注解通常用于外部配置的属性注入,具体用法为: @Value("${配置文件中的key}")
@Component public class AliOSSUtils { @Value("${aliyun.oss.endpoint}") private String endpoint; @Value("${aliyun.oss.accessKeyId}") private String accessKeyId; @Value("${aliyun.oss.accessKeySecret}") private String accessKeySecret; @Value("${aliyun.oss.bucketName}") private String bucketName; //省略其他代码... }
使用postman测试:
7.2 yml配置文件
前面我们一直使用springboot项目创建完毕后自带的application.properties进行属性的配置,那其实呢,在springboot项目当中是支持多种配置方式的,除了支持properties配置文件以外,还支持另外一种类型的配置文件,就是我们接下来要讲解的yml格式的配置文件。
-
application.properties
server.port=8080 server.address=127.0.0.1
-
application.yml
server: port: 8080 address: 127.0.0.1
-
application.yaml
server: port: 8080 address: 127.0.0.1
yml 格式的配置文件,后缀名有两种:
yml (推荐)
yaml
常见配置文件格式对比:
我们可以看到配置同样的数据信息,yml格式的数据有以下特点:
-
容易阅读
-
容易与脚本语言交互
-
以数据为核心,重数据轻格式
简单的了解过springboot所支持的配置文件,以及不同类型配置文件之间的优缺点之后,接下来我们就来了解下yml配置文件的基本语法:
-
大小写敏感
-
数值前边必须有空格,作为分隔符
-
使用缩进表示层级关系,缩进时,不允许使用Tab键,只能用空格(idea中会自动将Tab转换为空格)
-
缩进的空格数目不重要,只要相同层级的元素左侧对齐即可
-
#
表示注释,从这个字符一直到行尾,都会被解析器忽略
了解完yml格式配置文件的基本语法之后,接下来我们再来看下yml文件中常见的数据格式。在这里我们主要介绍最为常见的两类:
-
定义对象或Map集合
-
定义数组、list或set集合
对象/Map集合
user: name: zhangsan age: 18 password: 123456
数组/List/Set集合
hobby: - java - game - sport
熟悉完了yml文件的基本语法后,我们修改下之前案例中使用的配置文件,变更为application.yml配置方式:
-
修改application.properties名字为:
_application.properties
(名字随便更换,只要加载不到即可) -
创建新的配置文件:
application.yml
原有application.properties文件:
新建的application.yml文件:
#驱动类名称
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:
configuration:
#指定mybatis输出日志的位置, 输出控制台
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
#开启mybatis的驼峰命名自动映射开关 a_column ----> aColumn
map-underscore-to-camel-case: true
#阿里云OSS配置
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
accessKeyId: LTAI5t7QjVfMWiw9SBvcafJc
accessKeySecret: X7oGAeGzchpGFn2yE9Pq7fSRhFbwXa
bucketName: web-lmy
7.3 @ConfigurationProperties
讲解完了yml配置文件之后,最后再来介绍一个注解@ConfigurationProperties
。在介绍注解之前,我们先来看一个场景,分析下代码当中可能存在的问题:
我们在application.properties或者application.yml中配置了阿里云OSS的四项参数之后,如果java程序中需要这四项参数数据,我们直接通过@Value注解来进行注入。这种方式本身没有什么问题问题,但是如果说需要注入的属性较多(例:需要20多个参数数据),我们写起来就会比较繁琐。
那么有没有一种方式可以简化这些配置参数的注入呢?答案是肯定有,在Spring中给我们提供了一种简化方式,可以直接将配置文件中配置项的值自动的注入到对象的属性中。
Spring提供的简化方式套路:
-
需要创建一个实现类,且实体类中的属性名和配置文件当中key的名字必须要一致
比如:配置文件当中叫endpoints,实体类当中的属性也得叫endpoints,另外实体类当中的属性还需要提供 getter / setter方法
-
需要将实体类交给Spring的IOC容器管理,成为IOC容器当中的bean对象
-
在实体类上添加
@ConfigurationProperties
注解,并通过perfect属性来指定配置参数项的前缀
实体类:AliOSSProperties
package com.example.utils;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* @Auther lmy
* @date 2024-05-05 0:04
* @Description This is description of code
*/
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliOSSProperties {
//区域
private String endpoint;
//身份ID
private String accessKeyId;
//身份密钥
private String accessKeySecret;
//存储空间
private String bucketName;
}
AliOSSUtils工具类:
package com.example.utils;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;
/**
* 阿里云 OSS 工具类
*/
@Component
public class AliOSSUtils {
@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();
// 避免文件覆盖
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);
//文件访问路径
String url = endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + fileName;
// 关闭ossClient
ossClient.shutdown();
return url;// 把上传到oss的路径返回
}
}
在我们添加上注解后,会发现idea窗口上面出现一个红色警告:
这个警告提示是告知我们还需要引入一个依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> </dependency>
当我们在pom.xml文件当中配置了这项依赖之后,我们重新启动服务,大家就会看到在properties或者是yml配置文件当中,就会提示阿里云 OSS 相关的配置项。所以这项依赖它的作用就是会自动的识别被@Configuration Properties
注解标识的bean对象。
刚才的红色警告,已经变成了一个灰色的提示,提示我们需要重新运行springboot服务
@ConfigurationProperties注解我们已经介绍完了,接下来我们就来区分一下@ConfigurationProperties注解以及我们前面所介绍的另外一个@Value注解:
相同点:都是用来注入外部配置的属性的。
不同点:
-
@Value注解只能一个一个的进行外部属性的注入。
-
@ConfigurationProperties可以批量的将外部的属性配置注入到bean对象的属性中。
如果要注入的属性非常的多,并且还想做到复用,就可以定义这么一个bean对象。通过 configuration properties 批量的将外部的属性配置直接注入到 bin 对象的属性当中。在其他的类当中,我要想获取到注入进来的属性,我直接注入 bin 对象,然后调用 get 方法,就可以获取到对应的属性值了
8. 登录功能
8.1 需求
在登录界面中,我们可以输入用户的用户名以及密码,然后点击 "登录" 按钮就要请求服务器,服务端判断用户输入的用户名或者密码是否正确。如果正确,则返回成功结果,前端跳转至系统首页面。
8.2 接口文档
我们参照接口文档来开发登录功能
-
基本信息
请求路径:/login 请求方式:POST 接口描述:该接口用于员工登录Tlias智能学习辅助系统,登录完毕后,系统下发JWT令牌。
-
请求参数
参数格式:application/json
参数说明:
名称 类型 是否必须 备注 username string 必须 用户名 password string 必须 密码 请求数据样例:
{ "username": "jinyong", "password": "123456" }
-
响应数据
参数格式:application/json
参数说明:
名称 类型 是否必须 默认值 备注 其他信息 code number 必须 响应码, 1 成功 ; 0 失败 msg string 非必须 提示信息 data string 必须 返回的数据 , jwt令牌 响应数据样例:
{ "code": 1, "msg": "success", "data": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoi6YeR5bq4IiwiaWQiOjEsInVzZXJuYW1lIjoiamlueW9uZyIsImV4cCI6MTY2MjIwNzA0OH0.KkUc_CXJZJ8Dd063eImx4H9Ojfrr6XMJ-yVzaWCVZCo" }
8.3 思路分析
登录服务端的核心逻辑就是:接收前端请求传递的用户名和密码 ,然后再根据用户名和密码查询用户信息,如果用户信息存在,则说明用户输入的用户名和密码正确。如果查询到的用户不存在,则说明用户输入的用户名和密码错误。
8.4 功能开发
LoginController
package com.example.controller;
import com.example.pojo.Emp;
import com.example.pojo.Result;
import com.example.service.EmpService;
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;
/**
* @Auther lmy
* @date 2024-05-05 1:13
* @Description 员工登录
*/
@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("用户或密码错误");
}
}
EmpService
package com.example.service;
import com.example.pojo.Emp;
public interface EmpService {
/*用户登录*/
Emp login(Emp emp);
}
EmpServiceImpl
package com.example.service.impl;
import com.example.mapper.EmpMapper;
import com.example.pojo.Emp;
import com.example.service.EmpService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public Emp login(Emp emp) {
return empMapper.getByUsernameAndPassword(emp);
}
}
EmpMapper
package com.example.mapper;
import com.example.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface EmpMapper {
//员工登录
@Select("select * from emp where username=#{username} and password =#{password}")
Emp getByUsernameAndPassword(Emp emp);
}
8.5 测试
功能开发完毕后,我们就可以启动服务,打开apifox进行测试了。
发起POST请求,访问:http://localhost:8080/login
postman测试通过了,那接下来,我们就可以结合着前端工程进行联调测试。
先退出系统,进入到登录页面:
在登录页面输入账户密码:
登录成功之后进入到后台管理系统页面:
9. 登录校验
9.1 问题分析
我们已经完成了基础登录功能的开发与测试,在我们登录成功后就可以进入到后台管理系统中进行数据的操作。
但是当我们在浏览器中新的页面上输入地址:http://localhost:90/#/system/dept
,发现没有登录仍然可以进入到后端管理系统页面。
而真正的登录功能应该是:登陆后才能访问后端系统页面,不登陆则跳转登陆页面进行登陆。
为什么会出现这个问题?其实原因很简单,就是因为针对于我们当前所开发的部门管理、员工管理以及文件上传等相关接口来说,我们在服务器端并没有做任何的判断,没有去判断用户是否登录了。所以无论用户是否登录,都可以访问部门管理以及员工管理的相关数据。所以我们目前所开发的登录功能,它只是徒有其表。而我们要想解决这个问题,我们就需要完成一步非常重要的操作:登录校验。
什么是登录校验?
-
所谓登录校验,指的是我们在服务器端接收到浏览器发送过来的请求之后,首先我们要对请求进行校验。先要校验一下用户登录了没有,如果用户已经登录了,就直接执行对应的业务操作就可以了;如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。
了解完什么是登录校验之后,接下来我们分析一下登录校验大概的实现思路。
首先我们在宏观上先有一个认知:
前面在讲解HTTP协议的时候,我们提到HTTP协议是无状态协议。什么又是无状态的协议?
所谓无状态,指的是每一次请求都是独立的,下一次请求并不会携带上一次请求的数据。而浏览器与服务器之间进行交互,基于HTTP协议也就意味着现在我们通过浏览器来访问了登陆这个接口,实现了登陆的操作,接下来我们在执行其他业务操作时,服务器也并不知道这个员工到底登陆了没有。因为HTTP协议是无状态的,两次请求之间是独立的,所以是无法判断这个员工到底登陆了没有。
那应该怎么来实现登录校验的操作呢?具体的实现思路可以分为两部分:
-
在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记。
-
在浏览器发起请求时,需要在服务端进行统一拦截,拦截后进行登录校验。
想要判断员工是否已经登录,我们需要在员工登录成功之后,存储一个登录成功的标记,接下来在每一个接口方法执行之前,先做一个条件判断,判断一下这个员工到底登录了没有。如果是登录了,就可以执行正常的业务操作,如果没有登录,会直接给前端返回一个错误的信息,前端拿到这个错误信息之后会自动的跳转到登录页面。
我们程序中所开发的查询功能、删除功能、添加功能、修改功能,都需要使用以上套路进行登录校验。此时就会出现:相同代码逻辑,每个功能都需要编写,就会造成代码非常繁琐。
为了简化这块操作,我们可以使用一种技术:统一拦截技术。
通过统一拦截的技术,我们可以来拦截浏览器发送过来的所有的请求,拦截到这个请求之后,就可以通过请求来获取之前所存入的登录标记,在获取到登录标记且标记为登录成功,就说明员工已经登录了。如果已经登录,我们就直接放行(意思就是可以访问正常的业务接口了)。
我们要完成以上操作,会涉及到web开发中的两个技术:
-
会话技术
-
统一拦截技术
而统一拦截技术现实方案也有两种:
-
Servlet规范中的Filter过滤器
-
Spring提供的interceptor拦截器
下面我们先学习会话技术,然后再学习统一拦截技术。
9.2 会话技术
介绍了登录校验的大概思路之后,我们先来学习下会话技术。
9.2.1 会话技术介绍
什么是会话?
-
在我们日常生活当中,会话指的就是谈话、交谈。
-
在web开发当中,会话指的就是浏览器与服务器之间的一次连接,我们就称为一次会话。
在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。在一次会话当中,是可以包含多次请求和响应的。
比如:打开了浏览器来访问web服务器上的资源(浏览器不能关闭、服务器不能断开)
-
第1次:访问的是登录的接口,完成登录操作
-
第2次:访问的是部门管理接口,查询所有部门数据
-
第3次:访问的是员工管理接口,查询员工数据
只要浏览器和服务器都没有关闭,以上3次请求都属于一次会话当中完成的。
-
需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。比如:1、2、3这三个请求都是属于同一个会话。当我们关闭浏览器之后,这次会话就结束了。而如果我们是直接把web服务器关了,那么所有的会话就都结束了。
知道了会话的概念了,接下来我们再来了解下会话跟踪。
会话跟踪:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据。
服务器会接收很多的请求,但是服务器是需要识别出这些请求是不是同一个浏览器发出来的。比如:1和2这两个请求是不是同一个浏览器发出来的,3和5这两个请求不是同一个浏览器发出来的。如果是同一个浏览器发出来的,就说明是同一个会话。如果是不同的浏览器发出来的,就说明是不同的会话。而识别多次请求是否来自于同一浏览器的过程,我们就称为会话跟踪。
我们使用会话跟踪技术就是要完成在同一个会话中,多个请求之间进行共享数据。
为什么要共享数据呢?
由于HTTP是无状态协议,在后面请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享
会话跟踪技术有两种:
-
Cookie(客户端会话跟踪技术)
-
数据存储在客户端浏览器当中
-
-
Session(服务端会话跟踪技术)
-
数据存储在储在服务端
-
-
令牌技术
9.2.2 会话跟踪方案
上面我们介绍了什么是会话,什么是会话跟踪,并且也提到了会话跟踪 3 种常见的技术方案。接下来,我们就来对比一下这 3 种会话跟踪的技术方案,来看一下具体的实现思路,以及它们之间的优缺点。
9.2.2.1 方案一 - Cookie
cookie 是客户端会话跟踪技术,它是存储在客户端浏览器的,我们使用 cookie 来跟踪会话,我们就可以在浏览器第一次发起请求来请求服务器的时候,我们在服务器端来设置一个cookie。
比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个cookie,在 cookie 当中我们就可以来存储用户相关的一些数据信息。比如我可以在 cookie 当中来存储当前登录用户的用户名,用户的ID。
服务器端在给客户端在响应数据的时候,会自动的将 cookie 响应给浏览器,浏览器接收到响应回来的 cookie 之后,会自动的将 cookie 的值存储在浏览器本地。接下来在后续的每一次请求当中,都会将浏览器本地所存储的 cookie 自动地携带到服务端。
接下来在服务端我们就可以获取到 cookie 的值。我们可以去判断一下这个 cookie 的值是否存在,如果不存在这个cookie,就说明客户端之前是没有访问登录接口的;如果存在 cookie 的值,就说明客户端之前已经登录完成了。这样我们就可以基于 cookie 在同一次会话的不同请求之间来共享数据。
我刚才在介绍流程的时候,用了 3 个自动:
-
服务器会 自动 的将 cookie 响应给浏览器。
-
浏览器接收到响应回来的数据之后,会 自动 的将 cookie 存储在浏览器本地。
-
在后续的请求当中,浏览器会 自动 的将 cookie 携带到服务器端。
为什么这一切都是自动化进行的?
是因为 cookie 它是 HTP 协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。在 HTTP 协议官方给我们提供了一个响应头和请求头:
-
响应头 Set-Cookie :设置Cookie数据的
-
请求头 Cookie:携带Cookie数据的
代码测试
@Slf4j @RestController public class SessionController { //设置Cookie @GetMapping("/c1") public Result cookie1(HttpServletResponse response){ response.addCookie(new Cookie("login_username","itheima")); //设置Cookie/响应Cookie return Result.success(); } //获取Cookie @GetMapping("/c2") public Result cookie2(HttpServletRequest request){ Cookie[] cookies = request.getCookies(); for (Cookie cookie : cookies) { if(cookie.getName().equals("login_username")){ System.out.println("login_username: "+cookie.getValue()); //输出name为login_username的cookie } } return Result.success(); } }
A. 访问c1接口,设置Cookie,http://localhost:8080/c1
我们可以看到,设置的cookie,通过响应头Set-Cookie响应给浏览器,并且浏览器会将Cookie,存储在浏览器端。
B. 访问c2接口 http://localhost:8080/c2,此时浏览器会自动的将Cookie携带到服务端,是通过请求头Cookie,携带的。
优缺点
-
优点:HTTP协议中支持的技术(像Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的)
-
缺点:
-
移动端APP(Android、IOS)中无法使用Cookie
-
不安全,用户可以自己禁用Cookie
-
Cookie不能跨域
-
跨域介绍:
现在的项目,大部分都是前后端分离的,前后端最终也会分开部署,前端部署在服务器 192.168.150.200 上,端口 80,后端部署在 192.168.150.100上,端口 8080
我们打开浏览器直接访问前端工程,访问url:http://192.168.150.200/login.html
然后在该页面发起请求到服务端,而服务端所在地址不再是localhost,而是服务器的IP地址192.168.150.100,假设访问接口地址为:http://192.168.150.100:8080/login
那此时就存在跨域操作了,因为我们是在 http://192.168.150.200/login.html 这个页面上访问了http://192.168.150.100:8080/login 接口
此时如果服务器设置了一个Cookie,这个Cookie是不能使用的,因为Cookie无法跨域
区分跨域的维度:
协议
IP/协议
端口
只要上述的三个维度有任何一个维度不同,那就是跨域操作
举例:
http://192.168.150.200/login.html ----------> https://192.168.150.200/login [协议不同,跨域]
http://192.168.150.200/login.html ----------> http://192.168.150.100/login [IP不同,跨域]
http://192.168.150.200/login.html ----------> http://192.168.150.200:8080/login [端口不同,跨域]
http://192.168.150.200/login.html ----------> http://192.168.150.200/login [不跨域]
9.2.2.2 方案二 - Session
前面介绍的时候,我们提到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 的名字是固定的 JSESSIONID 代表的服务器端会话对象 Session 的 ID。浏览器会自动识别这个响应头,然后自动将Cookie存储在浏览器本地。
-
查找Session
接下来,在后续的每一次请求当中,都会将 Cookie 的数据获取出来,并且携带到服务端。接下来服务器拿到JSESSIONID这个 Cookie 的值,也就是 Session 的ID。拿到 ID 之后,就会从众多的 Session 当中来找到当前请求对应的会话对象Session。
这样我们是不是就可以通过 Session 会话对象在同一次会话的多次请求之间来共享数据了?好,这就是基于 Session 进行会话跟踪的流程。
代码测试
@Slf4j @RestController public class SessionController { @GetMapping("/s1") public Result session1(HttpSession session){ log.info("HttpSession-s1: {}", session.hashCode()); session.setAttribute("loginUser", "tom"); //往session中存储数据 return Result.success(); } @GetMapping("/s2") public Result session2(HttpServletRequest request){ HttpSession session = request.getSession(); log.info("HttpSession-s2: {}", session.hashCode()); Object loginUser = session.getAttribute("loginUser"); //从session中获取数据 log.info("loginUser: {}", loginUser); return Result.success(loginUser); } }
A. 访问 s1 接口,http://localhost:8080/s1
请求完成之后,在响应头中,就会看到有一个Set-Cookie的响应头,里面响应回来了一个Cookie,就是JSESSIONID,这个就是服务端会话对象 Session 的ID。
B. 访问 s2 接口,http://localhost:8080/s2
接下来,在后续的每次请求时,都会将Cookie的值,携带到服务端,那服务端呢,接收到Cookie之后,会自动的根据JSESSIONID的值,找到对应的会话对象Session。
那经过这两步测试,大家也会看到,在控制台中输出如下日志:
两次请求,获取到的Session会话对象的hashcode是一样的,就说明是同一个会话对象。而且,第一次请求时,往Session会话对象中存储的值,第二次请求时,也获取到了。 那这样,我们就可以通过Session会话对象,在同一个会话的多次请求之间来进行数据共享了。
优缺点
-
优点:Session是存储在服务端的,安全
-
缺点:
-
服务器集群环境下无法直接使用Session
-
移动端APP(Android、IOS)中无法使用Cookie
-
用户可以自己禁用Cookie
-
Cookie不能跨域
-
PS:Session 底层是基于Cookie实现的会话跟踪,如果Cookie不可用,则该方案,也就失效了。
服务器集群环境为何无法使用Session?
首先第一点,我们现在所开发的项目,一般都不会只部署在一台服务器上,因为一台服务器会存在一个很大的问题,就是单点故障。所谓单点故障,指的就是一旦这台服务器挂了,整个应用都没法访问了。
所以在现在的企业项目开发当中,最终部署的时候都是以集群的形式来进行部署,也就是同一个项目它会部署多份。比如这个项目我们现在就部署了 3 份。
而用户在访问的时候,到底访问这三台其中的哪一台?其实用户在访问的时候,他会访问一台前置的服务器,我们叫负载均衡服务器,我们在后面项目当中会详细讲解。目前大家先有一个印象负载均衡服务器,它的作用就是将前端发起的请求均匀的分发给后面的这三台服务器。
此时假如我们通过 session 来进行会话跟踪,可能就会存在这样一个问题。用户打开浏览器要进行登录操作,此时会发起登录请求。登录请求到达负载均衡服务器,将这个请求转给了第一台 Tomcat 服务器。
Tomcat 服务器接收到请求之后,要获取到会话对象session。获取到会话对象 session 之后,要给浏览器响应数据,最终在给浏览器响应数据的时候,就会携带这么一个 cookie 的名字,就是 JSESSIONID ,下一次再请求的时候,是不是又会将 Cookie 携带到服务端?
好。此时假如又执行了一次查询操作,要查询部门的数据。这次请求到达负载均衡服务器之后,负载均衡服务器将这次请求转给了第二台 Tomcat 服务器,此时他就要到第二台 Tomcat 服务器当中。根据JSESSIONID 也就是对应的 session 的 ID 值,要找对应的 session 会话对象。
我想请问在第二台服务器当中有没有这个ID的会话对象 Session, 是没有的。此时是不是就出现问题了?我同一个浏览器发起了 2 次请求,结果获取到的不是同一个会话对象,这就是Session这种会话跟踪方案它的缺点,在服务器集群环境下无法直接使用Session。
大家会看到上面这两种传统的会话技术,在现在的企业开发当中是不是会存在很多的问题。 为了解决这些问题,在现在的企业开发当中,基本上都会采用第三种方案,通过令牌技术来进行会话跟踪。接下来我们就来介绍一下令牌技术,来看一下令牌技术又是如何跟踪会话的。
9.2.2.3 方案三 - 令牌技术
这里我们所提到的令牌,其实它就是一个用户身份的标识,看似很高大上,很神秘,其实本质就是一个字符串。
如果通过令牌技术来跟踪会话,我们就可以在浏览器发起请求。在请求登录接口的时候,如果登录成功,我就可以生成一个令牌,令牌就是用户的合法身份凭证。接下来我在响应数据的时候,我就可以直接将令牌响应给前端。
接下来我们在前端程序当中接收到令牌之后,就需要将这个令牌存储起来。这个存储可以存储在 cookie 当中,也可以存储在其他的存储空间(比如:localStorage)当中。
接下来,在后续的每一次请求当中,都需要将令牌携带到服务端。携带到服务端之后,接下来我们就需要来校验令牌的有效性。如果令牌是有效的,就说明用户已经执行了登录操作,如果令牌是无效的,就说明用户之前并未执行登录操作。
此时,如果是在同一次会话的多次请求之间,我们想共享数据,我们就可以将共享的数据存储在令牌当中就可以了。
优缺点
-
优点:
-
支持PC端、移动端
-
解决集群环境下的认证问题
-
减轻服务器的存储压力(无需在服务器端存储)
-
-
缺点:需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)
针对于这三种方案,现在企业开发当中使用的最多的就是第三种令牌技术进行会话跟踪。而前面的这两种传统的方案,现在企业项目开发当中已经很少使用了。所以在我们的课程当中,我们也将会采用令牌技术来解决案例项目当中的会话跟踪问题。
9.3 JWT令牌
前面我们介绍了基于令牌技术来实现会话追踪。这里所提到的令牌就是用户身份的标识,其本质就是一个字符串。令牌的形式有很多,我们使用的是功能强大的 JWT令牌。
9.3.1 介绍
JWT全称:JSON Web Token (官网:JSON Web Tokens - jwt.io)
-
定义了一种简洁的、自包含的格式,用于在通信双方以json数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。
简洁:是指jwt就是一个简单的字符串。可以在请求参数或者是请求头当中直接传递。
自包含:指的是jwt令牌,看似是一个随机的字符串,但是我们是可以根据自身的需求在jwt令牌中存储自定义的数据内容。如:可以直接在jwt令牌中存储用户的相关信息。
简单来讲,jwt就是将原始的json数据格式进行了安全的封装,这样就可以直接基于jwt在通信双方安全的进行信息传输了。
JWT的组成: (JWT令牌由三个部分组成,三个部分之间使用英文的点来分割)
-
第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{"alg":"HS256","type":"JWT"}
-
第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"}
-
第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。
签名的目的就是为了防jwt令牌被篡改,而正是因为jwt令牌最后一个部分数字签名的存在,所以整个jwt 令牌是非常安全可靠的。一旦jwt令牌当中任何一个部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。
JWT是如何将原始的JSON格式数据,转变为字符串的呢?
其实在生成JWT令牌时,会对JSON格式的数据进行一次编码:进行base64编码
Base64:是一种基于64个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的64个字符分别是A到Z、a到z、 0- 9,一个加号,一个斜杠,加起来就是64个字符。任何数据经过base64编码之后,最终就会通过这64个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号
需要注意的是Base64是编码方式,而不是加密方式。
JWT令牌最典型的应用场景就是登录认证:
-
在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,我们需要生成一个jwt令牌,将生成的 jwt令牌返回给前端。
-
前端拿到jwt令牌之后,会将jwt令牌存储起来。在后续的每一次请求中都会将jwt令牌携带到服务端。
-
服务端统一拦截请求之后,先来判断一下这次请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要校验一下令牌是否是有效。如果有效,就直接放行进行请求的处理。
在JWT登录认证的场景中我们发现,整个流程当中涉及到两步操作:
-
在登录成功之后,要生成令牌。
-
每一次请求当中,要接收令牌并对令牌进行校验。
稍后我们再来学习如何来生成jwt令牌,以及如何来校验jwt令牌。
9.3.2 生成和校验
简单介绍了JWT令牌以及JWT令牌的组成之后,接下来我们就来学习基于Java代码如何生成和校验JWT令牌。
首先我们先来实现JWT令牌的生成。要想使用JWT令牌,需要先引入JWT的依赖:
<!-- JWT.set依赖--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
在引入完JWT来赖后,就可以调用工具包中提供的API来完成JWT令牌的生成和校验
工具类:Jwts
生成JWT代码实现:
/*
* 生成jwt
* */
@Test
public void genJwt() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 1);
claims.put("name", "tom");
String jwt = Jwts.builder()
.signWith(SignatureAlgorithm.HS256, "xzq717") //签名算法
.setClaims(claims) //自定义内容(载荷)
.setExpiration(new Date(System.currentTimeMillis() + 3600 * 1000)) //设置有效期为1h
.compact();
System.out.println(jwt);
}
运行测试方法:
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoieHpxIiwiaWQiOjEsImV4cCI6MTcxNDkwNDAzNH0.PWIT3ykDXipmgfUwgFgGyHXP7cwotfNi5G_OW67b6ZY
输出的结果就是生成的JWT令牌,,通过英文的点分割对三个部分进行分割,我们可以将生成的令牌复制一下,然后打开JWT的官网,将生成的令牌直接放在Encoded位置,此时就会自动的将令牌解析出来。
第一部分解析出来,看到JSON格式的原始数据,所使用的签名算法为HS256。
第二个部分是我们自定义的数据,之前我们自定义的数据就是id,还有一个exp代表的是我们所设置的过期时间。
由于前两个部分是base64编码,所以是可以直接解码出来。但最后一个部分并不是base64编码,是经过签名算法计算出来的,所以最后一个部分是不会解析的。
实现了JWT令牌的生成,下面我们接着使用Java代码来校验JWT令牌(解析生成的令牌):
/*
* 解析jwt
* */
@Test
public void parseJwt(){
Claims claims = Jwts.parser()
.setSigningKey("xzq717")
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoieHpxIiwiaWQiOjEsImV4cCI6MTcxNDkwNDAzNH0.PWIT3ykDXipmgfUwgFgGyHXP7cwotfNi5G_OW67b6ZY")
.getBody();
System.out.println(claims);
}
运行测试方法:
{name=xzq, id=1, exp=1714904034}
令牌解析后,我们可以看到id和过期时间,如果在解析的过程当中没有报错,就说明解析成功了。
下面我们做一个测试:把令牌header中的数字9变为8,运行测试方法后发现报错:
原header: eyJhbGciOiJIUzI1NiJ9
修改为: eyJhbGciOiJIUzI1NiJ8
结论:篡改令牌中的任何一个字符,在对令牌进行解析时都会报错,所以JWT令牌是非常安全可靠的。
我们继续测试:修改生成令牌的时指定的过期时间,修改为1分钟
@Test
public void genJwt() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 1);
claims.put("name", "xzq");
String jwt = Jwts.builder()
.signWith(SignatureAlgorithm.HS256, "xzq717") //签名算法
.setClaims(claims) //自定义内容(载荷)
.setExpiration(new Date(System.currentTimeMillis() + 60 * 1000)) //设置有效期为1min
.compact();
System.out.println(jwt);
}
@Test
public void parseJwt(){
Claims claims = Jwts.parser()
.setSigningKey("xzq717")
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoieHpxIiwiaWQiOjEsImV4cCI6MTcxNDkwMTM1M30.4OQY9O5X41Coofq7Kh7HhLhu4c6CfucPNoYAAarWrhY")
.getBody();
System.out.println(claims);
}
等待1分钟之后运行测试方法发现也报错了,说明:JWT令牌过期后,令牌就失效了,解析的为非法令牌。
通过以上测试,我们在使用JWT令牌时需要注意:
-
JWT校验时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是配套的。
-
如果JWT令牌解析校验时报错,则说明 JWT令牌被篡改 或 失效了,令牌非法。
9.3.3 登录下发令牌
JWT令牌的生成和校验的基本操作我们已经学习完了,接下来我们就需要在案例当中通过JWT令牌技术来跟踪会话。具体的思路我们前面已经分析过了,主要就是两步操作:
-
生成令牌
-
在登录成功之后来生成一个JWT令牌,并且把这个令牌直接返回给前端
-
-
校验令牌
-
拦截前端请求,从请求中获取到令牌,对令牌进行解析校验
-
那我们首先来完成:登录成功之后生成JWT令牌,并且把令牌返回给前端。
JWT令牌怎么返回给前端呢?此时我们就需要再来看一下接口文档当中关于登录接口的描述(主要看响应数据):
-
响应数据
参数格式:application/json
参数说明:
名称 类型 是否必须 默认值 备注 其他信息 code number 必须 响应码, 1 成功 ; 0 失败 msg string 非必须 提示信息 data string 必须 返回的数据 , jwt令牌 响应数据样例:
{ "code": 1, "msg": "success", "data": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoi6YeR5bq4IiwiaWQiOjEsInVzZXJuYW1lIjoiamlueW9uZyIsImV4cCI6MTY2MjIwNzA0OH0.KkUc_CXJZJ8Dd063eImx4H9Ojfrr6XMJ-yVzaWCVZCo" }
-
备注说明
用户登录成功后,系统会自动下发JWT令牌,然后在后续的每次请求中,都需要在请求头header中携带到服务端,请求头的名称为 token ,值为 登录时下发的JWT令牌。
如果检测到用户未登录,则会返回如下固定错误信息:
{ "code": 0, "msg": "NOT_LOGIN", "data": null }
解读完接口文档中的描述了,目前我们先来完成令牌的生成和令牌的下发,我们只需要生成一个令牌返回给前端就可以了。
实现步骤:
-
引入JWT工具类
-
在项目工程下创建com.itheima.utils包,并把提供JWT工具类复制到该包下
-
-
登录完成后,调用工具类生成JWT令牌并返回
JWT工具类
package com.example.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; //有效时间(12H)
/**
* 生成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) //指定令牌Token
.getBody();
return claims;
}
}
登录成功,生成JWT令牌并返回
@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) {
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包含了当前登录的员工信息
return Result.success(jwt);
}
//登录失败,返回错误信息
return Result.error("用户或密码错误");
}
}
重启服务,打开apifox测试登录接口:
打开浏览器完成前后端联调操作:利用开发者工具,抓取一下网络请求
登录请求完成后,可以看到JWT令牌已经响应给了前端,此时前端就会将JWT令牌存储在浏览器本地。
服务器响应的JWT令牌存储在本地浏览器哪里了呢?
-
在当前案例中,JWT令牌存储在浏览器的本地存储空间local storage中了。 local storage是浏览器的本地存储,在移动端也是支持的。
我们在发起一个查询部门数据的请求,此时我们可以看到在请求头中包含一个token(JWT令牌),后续的每一次请求当中,都会将这个令牌携带到服务端。
9.4 过滤器Filter
刚才通过浏览器的开发者工具,我们可以看到在后续的请求当中,都会在请求头中携带JWT令牌到服务端,而服务端需要统一拦截所有的请求,从而判断是否携带的有合法的JWT令牌。 那怎么样来统一拦截到所有的请求校验令牌的有效性呢?这里我们会学习两种解决方案:
-
Filter过滤器
-
Interceptor拦截器
我们首先来学习过滤器Filter。
9.4.1 快速入门
什么是Filter?
-
Filter表示过滤器,是 JavaWeb三大组件(Servlet、Filter、Listener)之一。
-
过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能
-
使用了过滤器之后,要想访问web服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源。
-
-
过滤器一般完成一些通用的操作,比如:登录校验、统一编码处理、敏感字符处理等。
下面我们通过Filter快速入门程序掌握过滤器的基本使用操作:
-
第1步,定义过滤器 :1.定义一个类,实现 Filter 接口,并重写其所有方法。
-
第2步,配置过滤器:Filter类上加 @WebFilter 注解,配置拦截资源的路径。引导类上加 @ServletComponentScan 开启Servlet组件支持。
定义过滤器
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 18:38
* @Description This is description of code
*/
public class DemoFilter implements Filter {
@Override //初始化方法,只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
@Override //拦截到请求后调用,调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("拦截到了请求");
//放行
filterChain.doFilter(servletRequest, servletResponse);
}
@Override //销毁方法,只调用一次
public void destroy() {
System.out.println("destory 销毁方法执行了");
}
}
init方法:过滤器的初始化方法。在web服务器启动的时候会自动的创建Filter过滤器对象,在创建过滤器对象的时候会自动调用init初始化方法,这个方法只会被调用一次。
doFilter方法:这个方法是在每一次拦截到请求之后都会被调用,所以这个方法是会被调用多次的,每拦截到一次请求就会调用一次doFilter()方法。
destroy方法: 是销毁的方法。当我们关闭服务器的时候,它会自动的调用销毁方法destroy,而这个销毁方法也只会被调用一次。
在定义完Filter之后,Filter其实并不会生效,还需要完成Filter的配置,Filter的配置非常简单,只需要在Filter类上添加一个注解:@WebFilter,并指定属性urlPatterns,通过这个属性指定过滤器要拦截哪些请求
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 18:38
* @Description This is description of code
*/
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
@Override //初始化方法,只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
@Override //拦截到请求后调用,调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("拦截到了请求");
//放行
filterChain.doFilter(servletRequest, servletResponse);
}
@Override //销毁方法,只调用一次
public void destroy() {
System.out.println("destory 销毁方法执行了");
}
}
当我们在Filter类上面加了@WebFilter注解之后,接下来我们还需要在启动类上面加上一个注解@ServletComponentScan,通过这个@ServletComponentScan注解来开启SpringBoot项目对于Servlet组件的支持。
package com.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
@ServletComponentScan //开启了对servlet组件的支持
@SpringBootApplication
public class TliasWebManagementApplication {
public static void main(String[] args) {
SpringApplication.run(TliasWebManagementApplication.class, args);
}
}
重新启动服务,打开浏览器,执行部门管理的请求,可以看到控制台输出了过滤器中的内容:
注意事项:
在过滤器Filter中,如果不执行放行操作,将无法访问后面的资源。 放行操作:filterChain.doFilter(servletRequest, servletResponse);
现在我们已完成了Filter过滤器的基本使用,下面我们将学习Filter过滤器在使用过程中的一些细节。
9.4.2 Filter详解
Filter过滤器的快速入门程序我们已经完成了,接下来我们就要详细的介绍一下过滤器Filter在使用中的一些细节。主要介绍以下3个方面的细节:
-
过滤器的执行流程
-
过滤器的拦截路径配置
-
过滤器链
9.4.2.1 执行流程
首先我们先来看下过滤器的执行流程:
过滤器当中我们拦截到了请求之后,如果希望继续访问后面的web资源,就要执行放行操作,放行就是调用 FilterChain对象当中的doFilter()方法,在调用doFilter()这个方法之前所编写的代码属于放行之前的逻辑。
在放行后访问完 web 资源之后还会回到过滤器当中,回到过滤器之后如有需求还可以执行放行之后的逻辑,放行之后的逻辑我们写在doFilter()这行代码之后。
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 18:38
* @Description This is description of code
*/
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
@Override //初始化方法,只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
@Override //拦截到请求后调用,调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("拦截到了请求...放行前逻辑");
//放行
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("拦截到了请求...放行前逻辑");
}
@Override //销毁方法,只调用一次
public void destroy() {
System.out.println("destory 销毁方法执行了");
}
}
9.4.2.2 拦截路径
执行流程我们搞清楚之后,接下来再来介绍一下过滤器的拦截路径,Filter可以根据需求,配置不同的拦截资源路径:
拦截路径 | urlPatterns值 | 含义 |
---|---|---|
拦截具体路径 | /login | 只有访问 /login 路径时,才会被拦截 |
目录拦截 | /emps/* | 访问/emps下的所有资源,都会被拦截 |
拦截所有 | /* | 访问所有资源,都会被拦截 |
下面我们来测试"拦截具体路径":
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 18:38
* @Description This is description of code
*/
@WebFilter(urlPatterns = "/login") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
@Override //初始化方法,只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
@Override //拦截到请求后调用,调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("拦截到了请求...放行前逻辑");
//放行
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("拦截到了请求...放行前逻辑");
}
@Override //销毁方法,只调用一次
public void destroy() {
System.out.println("destory 销毁方法执行了");
}
}
测试1:访问部门管理请求,发现过滤器没有拦截请求
测试2:访问登录请求/login,发现过滤器拦截请求
下面我们来测试"目录拦截":
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 18:38
* @Description This is description of code
*/
@WebFilter(urlPatterns = "/depts/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
@Override //初始化方法,只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
@Override //拦截到请求后调用,调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("拦截到了请求...放行前逻辑");
//放行
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("拦截到了请求...放行前逻辑");
}
@Override //销毁方法,只调用一次
public void destroy() {
System.out.println("destory 销毁方法执行了");
}
}
测试1:访问部门管理请求,发现过滤器拦截了请求
测试2:访问登录请求/login,发现过滤器没有拦截请求
9.4.2.3 过滤器链
最后我们在来介绍下过滤器链,什么是过滤器链呢?所谓过滤器链指的是在一个web应用程序当中,可以配置多个过滤器,多个过滤器就形成了一个过滤器链。
比如:在我们web服务器当中,定义了两个过滤器,这两个过滤器就形成了一个过滤器链。
而这个链上的过滤器在执行的时候会一个一个的执行,会先执行第一个Filter,放行之后再来执行第二个Filter,如果执行到了最后一个过滤器放行之后,才会访问对应的web资源。
访问完web资源之后,按照我们刚才所介绍的过滤器的执行流程,还会回到过滤器当中来执行过滤器放行后的逻辑,而在执行放行后的逻辑的时候,顺序是反着的。
先要执行过滤器2放行之后的逻辑,再来执行过滤器1放行之后的逻辑,最后在给浏览器响应数据。
以上就是当我们在web应用当中配置了多个过滤器,形成了这样一个过滤器链以及过滤器链的执行顺序。下面我们通过idea来验证下过滤器链。
验证步骤:
-
在filter包下再来新建一个Filter过滤器类:AbcFilter
-
在AbcFilter过滤器中编写放行前和放行后逻辑
-
配置AbcFilter过滤器拦截请求路径为:/*
-
重启SpringBoot服务,查看DemoFilter、AbcFilter的执行日志
AbcFilter过滤器
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 19:26
* @Description This is description of code
*/
@WebFilter(urlPatterns = "/*")
public class AbcFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("Abc 拦截到了请求...放行前逻辑");
//放行
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("Abc 拦截到了请求...放行前逻辑");
}
}
DemoFilter过滤器
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 18:38
* @Description This is description of code
*/
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
@Override //初始化方法,只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
@Override //拦截到请求后调用,调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("Demo 拦截到了请求...放行前逻辑");
//放行
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("Demo 拦截到了请求...放行前逻辑");
}
@Override //销毁方法,只调用一次
public void destroy() {
System.out.println("destory 销毁方法执行了");
}
}
打开浏览器访问登录接口:
通过控制台日志的输出,大家发现AbcFilter先执行DemoFilter后执行,这是为什么呢?
其实是和过滤器的类名有关系。以注解方式配置的Filter过滤器,它的执行优先级是按时过滤器类名的自动排序确定的,类名排名越靠前,优先级越高。
假如我们想让DemoFilter先执行,怎么办呢?答案就是修改类名。
测试:修改AbcFilter类名为XbcFilter,运行程序查看控制台日志
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 19:26
* @Description This is description of code
*/
@WebFilter(urlPatterns = "/*")
public class XbcFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("Abc 拦截到了请求...放行前逻辑");
//放行
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("Abc 拦截到了请求...放行前逻辑");
}
}
到此,关于过滤器的使用细节,我们已经全部介绍完毕了。
9.4.3 登录校验-Filter
9.4.3.1 分析
过滤器Filter的快速入门以及使用细节我们已经介绍完了,接下来最后一步,我们需要使用过滤器Filter来完成案例当中的登录校验功能。
我们先来回顾下前面分析过的登录校验的基本流程:
-
要进入到后台管理系统,我们必须先完成登录操作,此时就需要访问登录接口login。
-
登录成功之后,我们会在服务端生成一个JWT令牌,并且把JWT令牌返回给前端,前端会将JWT令牌存储下来。
-
在后续的每一次请求当中,都会将JWT令牌携带到服务端,请求到达服务端之后,要想去访问对应的业务功能,此时我们必须先要校验令牌的有效性。
-
对于校验令牌的这一块操作,我们使用登录校验的过滤器,在过滤器当中来校验令牌的有效性。如果令牌是无效的,就响应一个错误的信息,也不会再去放行访问对应的资源了。如果令牌存在,并且它是有效的,此时就会放行去访问对应的web资源,执行相应的业务操作。
大概清楚了在Filter过滤器的实现步骤了,那在正式开发登录校验过滤器之前,我们思考两个问题:
-
所有的请求,拦截到了之后,都需要校验令牌吗?
-
答案:登录请求例外
-
-
拦截到请求后,什么情况下才可以放行,执行业务操作?
-
答案:有令牌,且令牌校验通过(合法);否则都返回未登录错误结果
-
9.4.3.2 具体流程
我们要完成登录校验,主要是利用Filter过滤器实现,而Filter过滤器的流程步骤:
基于上面的业务流程,我们分析出具体的操作步骤:
-
获取请求url
-
判断请求url中是否包含login,如果包含,说明是登录操作,放行
-
获取请求头中的令牌(token)
-
判断令牌是否存在,如果不存在,返回错误结果(未登录)
-
解析token,如果解析失败,返回错误结果(未登录)
-
放行
9.4.3.3 代码实现
分析清楚了以上的问题后,我们就参照接口文档来开发登录功能了,登录接口描述如下:
-
基本信息
请求路径:/login 请求方式:POST 接口描述:该接口用于员工登录Tlias智能学习辅助系统,登录完毕后,系统下发JWT令牌。
-
请求参数
参数格式:application/json
参数说明:
名称 类型 是否必须 备注 username string 必须 用户名 password string 必须 密码 请求数据样例:
{ "username": "jinyong", "password": "123456" }
-
响应数据
参数格式:application/json
参数说明:
名称 类型 是否必须 默认值 备注 其他信息 code number 必须 响应码, 1 成功 ; 0 失败 msg string 非必须 提示信息 data string 必须 返回的数据 , jwt令牌 响应数据样例:
{ "code": 1, "msg": "success", "data": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoi6YeR5bq4IiwiaWQiOjEsInVzZXJuYW1lIjoiamlueW9uZyIsImV4cCI6MTY2MjIwNzA0OH0.KkUc_CXJZJ8Dd063eImx4H9Ojfrr6XMJ-yVzaWCVZCo" }
-
备注说明
用户登录成功后,系统会自动下发JWT令牌,然后在后续的每次请求中,都需要在请求头header中携带到服务端,请求头的名称为 token ,值为 登录时下发的JWT令牌。
如果检测到用户未登录,则会返回如下固定错误信息:
{ "code": 0, "msg": "NOT_LOGIN", "data": null }
登录校验过滤器:LoginCheckFilter
package com.example.filter;
import com.alibaba.fastjson.JSONObject;
import com.example.pojo.Result;
import com.example.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.springframework.util.StringUtils;
import java.io.IOException;
@Slf4j
@WebFilter(urlPatterns = "/*")
public class LoginCheckFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse resp = (HttpServletResponse) servletResponse;
//1.获取请求url。
String url = req.getRequestURL().toString();
log.info("请求的url:{}", url);
//2.判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if (url.contains("login")) {
log.info("登录操作,放行...");
filterChain.doFilter(servletRequest, servletResponse);
return;
}
//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);
return;
}
//5.解析token,如果解析失败,返回错误结果(未登录)。
try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {
e.printStackTrace();
log.info("解析令牌失败,返回未登录错误信息");
Result error = Result.error("NOT_LOGIN");
//手动转型 对象--json ------>阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
resp.getWriter().write(notLogin);
return;
}
//6.放行。
log.info("令牌合法,放行");
filterChain.doFilter(servletRequest, servletResponse);
}
}
在上述过滤器的功能实现中,我们使用到了一个第三方json处理的工具包fastjson。我们要想使用,需要引入如下依赖:
<dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.76</version> </dependency>
登录校验的过滤器我们编写完成了,接下来我们就可以重新启动服务来做一个测试:
测试前先把之前所编写的测试使用的过滤器,暂时注释掉。直接将@WebFilter注解给注释掉即可。
-
测试1:未登录是否可以访问部门管理页面
首先关闭浏览器,重新打开浏览器,在地址栏中输入:http://localhost:90/#/system/dept
由于用户没有登录,登录校验过滤器返回错误信息,前端页面根据返回的错误信息结果,自动跳转到登录页面了
-
测试2:先进行登录操作,再访问部门管理页面
登录校验成功之后,可以正常访问相关业务操作页面
9.5 拦截器Interceptor
学习完了过滤器Filter之后,接下来我们继续学习拦截器Interseptor。
拦截器我们主要分为三个方面进行讲解:
-
介绍下什么是拦截器,并通过快速入门程序上手拦截器
-
拦截器的使用细节
-
通过拦截器Interceptor完成登录校验功能
我们先学习第一块内容:拦截器快速入门
9.5.1 快速入门
什么是拦截器?
-
是一种动态拦截方法调用的机制,类似于过滤器。
-
拦截器是Spring框架中提供的,用来动态拦截控制器方法的执行。
拦截器的作用:
-
拦截请求,在指定方法调用前后,根据业务需要执行预先设定的代码。
在拦截器当中,我们通常也是做一些通用性的操作,比如:我们可以通过拦截器来拦截前端发起的请求,将登录校验的逻辑全部编写在拦截器当中。在校验的过程当中,如发现用户登录了(携带JWT令牌且是合法令牌),就可以直接放行,去访问spring当中的资源。如果校验时发现并没有登录或是非法令牌,就可以直接给前端响应未登录的错误信息。
下面我们通过快速入门程序,来学习下拦截器的基本使用。拦截器的使用步骤和过滤器类似,也分为两步:
-
定义拦截器
-
注册配置拦截器
自定义拦截器:实现HandlerInterceptor接口,并重写其所有方法
package com.example.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
/**
* @Auther lmy
* @date 2024-05-05 21:52
* @Description This is description of code
*/
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override //目标方法运行前运行,返回true:放行,返回false:不放行
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("preHandle...");
return true;
}
@Override //目标方法运行后运行
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("postHandle...");
}
@Override //视图渲染完毕后运行,最后运行
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("afterCompletion...");
}
}
注意:
preHandle方法:目标资源方法执行前执行。 返回true:放行 返回false:不放行
postHandle方法:目标资源方法执行后执行
afterCompletion方法:视图渲染完毕后执行,最后执行
注册配置拦截器:实现WebMvcConfigurer接口,并重写addInterceptors方法
package com.example.config;
import com.example.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;
/**
* @Auther lmy
* @date 2024-05-05 21:59
* @Description This is description of code
*/
@Configuration //配置类
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginCheckInterceptor loginCheckInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**");
}
}
重新启动SpringBoot服务,打开apifox测试:
接下来我们再来做一个测试:将拦截器中返回值改为false
使用postman,再次点击send发送请求后,没有响应数据,说明请求被拦截了没有放行
9.5.2 Interceptor详解
拦截器的入门程序完成之后,接下来我们来介绍拦截器的使用细节。拦截器的使用细节我们主要介绍两个部分:
-
拦截器的拦截路径配置
-
拦截器的执行流程
9.5.2.1 拦截路径
首先我们先来看拦截器的拦截路径的配置,在注册配置拦截器的时候,我们要指定拦截器的拦截路径,通过addPathPatterns("要拦截路径")
方法,就可以指定要拦截哪些资源。
在入门程序中我们配置的是/**
,表示拦截所有资源,而在配置拦截器时,不仅可以指定要拦截哪些资源,还可以指定不拦截哪些资源,只需要调用excludePathPatterns("不拦截路径")
方法,指定哪些资源不需要拦截。
package com.example.config;
import com.example.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;
/**
* @Auther lmy
* @date 2024-05-05 21:59
* @Description This is description of code
*/
@Configuration //配置类
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginCheckInterceptor loginCheckInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**").excludePathPatterns("/login");
}
}
在拦截器中除了可以设置/**
拦截所有资源外,还有一些常见拦截路径设置:
拦截路径 | 含义 | 举例 |
---|---|---|
/* | 一级路径 | 能匹配/depts,/emps,/login,不能匹配 /depts/1 |
/** | 任意级路径 | 能匹配/depts,/depts/1,/depts/1/2 |
/depts/* | /depts下的一级路径 | 能匹配/depts/1,不能匹配/depts/1/2,/depts |
/depts/** | /depts下的任意级路径 | 能匹配/depts,/depts/1,/depts/1/2,不能匹配/emps/1 |
下面主要来演示下/**
与/*
的区别:
-
修改拦截器配置,把拦截路径设置为
/*
package com.example.config;
import com.example.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;
/**
* @Auther lmy
* @date 2024-05-05 21:59
* @Description This is description of code
*/
@Configuration //配置类
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginCheckInterceptor loginCheckInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/*").excludePathPatterns("/login");
}
}
使用apifox测试:http://localhost:8080/emps/1
控制台没有输出拦截器中的日志信息,说明/*
没有匹配到拦截路径/emp/1
。
9.5.2.2 执行流程
介绍完拦截路径的配置之后,接下来我们再来介绍拦截器的执行流程。通过执行流程,大家就能够清晰的知道过滤器与拦截器的执行时机。
-
当我们打开浏览器来访问部署在web服务器当中的web应用时,此时我们所定义的过滤器会拦截到这次请求。拦截到这次请求之后,它会先执行放行前的逻辑,然后再执行放行操作。而由于我们当前是基于springboot开发的,所以放行之后是进入到了spring的环境当中,也就是要来访问我们所定义的controller当中的接口方法。
-
Tomcat并不识别所编写的Controller程序,但是它识别Servlet程序,所以在Spring的Web环境中提供了一个非常核心的Servlet:DispatcherServlet(前端控制器),所有请求都会先进行到DispatcherServlet,再将请求转给Controller。
-
当我们定义了拦截器后,会在执行Controller的方法之前,请求被拦截器拦截住。执行
preHandle()
方法,这个方法执行完成后需要返回一个布尔类型的值,如果返回true,就表示放行本次操作,才会继续访问controller中的方法;如果返回false,则不会放行(controller中的方法也不会执行)。 -
在controller当中的方法执行完毕之后,再回过来执行
postHandle()
这个方法以及afterCompletion()
方法,然后再返回给DispatcherServlet,最终再来执行过滤器当中放行后的这一部分逻辑的逻辑。执行完毕之后,最终给浏览器响应数据。
接下来我们就来演示下过滤器和拦截器同时存在的执行流程:
-
开启LoginCheckInterceptor拦截器
package com.example.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
/**
* @Auther lmy
* @date 2024-05-05 21:52
* @Description This is description of code
*/
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override //目标方法运行前运行,返回true:放行,返回false:不放行
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("preHandle...");
return true;
}
@Override //目标方法运行后运行
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("postHandle...");
}
@Override //视图渲染完毕后运行,最后运行
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("afterCompletion...");
}
}
package com.example.config;
import com.example.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;
/**
* @Auther lmy
* @date 2024-05-05 21:59
* @Description This is description of code
*/
@Configuration //配置类
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginCheckInterceptor loginCheckInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**").excludePathPatterns("/login");
}
}
-
开启DemoFilter过滤器
package com.example.filter;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import java.io.IOException;
/**
* @Auther lmy
* @date 2024-05-05 18:38
* @Description This is description of code
*/
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
@Override //初始化方法,只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
@Override //拦截到请求后调用,调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("Demo 拦截到了请求...放行前逻辑");
//放行
filterChain.doFilter(servletRequest, servletResponse);
System.out.println("Demo 拦截到了请求...放行前逻辑");
}
@Override //销毁方法,只调用一次
public void destroy() {
System.out.println("destory 销毁方法执行了");
}
}
重启SpringBoot服务后,清空日志,打开Postman,测试查询部门:
以上就是拦截器的执行流程。通过执行流程分析,大家应该已经清楚了过滤器和拦截器之间的区别,其实它们之间的区别主要是两点:
-
接口规范不同:过滤器需要实现Filter接口,而拦截器需要实现HandlerInterceptor接口。
-
拦截范围不同:过滤器Filter会拦截所有的资源,而Interceptor只会拦截Spring环境中的资源。
9.5.3 登录校验- Interceptor
讲解完了拦截器的基本操作之后,接下来我们需要完成最后一步操作:通过拦截器来完成案例当中的登录校验功能。
登录校验的业务逻辑以及操作步骤我们前面已经分析过了,和登录校验Filter过滤器当中的逻辑是完全一致的。现在我们只需要把这个技术方案由原来的过滤器换成拦截器interceptor就可以了。
登录校验拦截器
package com.example.interceptor;
import com.alibaba.fastjson.JSONObject;
import com.example.pojo.Result;
import com.example.utils.JwtUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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;
/**
* @Auther lmy
* @date 2024-05-05 21:52
* @Description This is description of code
*/
@Slf4j
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override //目标方法运行前运行,返回true:放行,返回false:不放行
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求url。
String url = request.getRequestURL().toString();
log.info("请求的url:{}", url);
//2.判断请求url中是否包含login,如果包含,说明是登录操作,放行。
if (url.contains("login")) {
log.info("登录操作,放行...");
return true;
}
//3.获取请求头中的令牌(token)。
String jwt = request.getHeader("token");
//4.判断令牌是否存在,如果不存在,返回错误结果(未登录)。
if (!StringUtils.hasLength(jwt)) {
log.info("请求头token为空,返回未登录的信息");
Result error = Result.error("NOT_LOGIN");
//手动转型 对象--json ------>阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
response.getWriter().write(notLogin);
return false;
}
//5.解析token,如果解析失败,返回错误结果(未登录)。
try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {
e.printStackTrace();
log.info("解析令牌失败,返回未登录错误信息");
Result error = Result.error("NOT_LOGIN");
//手动转型 对象--json ------>阿里巴巴fastJSON
String notLogin = JSONObject.toJSONString(error);
response.getWriter().write(notLogin);
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.example.config;
import com.example.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;
/**
* @Auther lmy
* @date 2024-05-05 21:59
* @Description This is description of code
*/
@Configuration //配置类
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginCheckInterceptor loginCheckInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**").excludePathPatterns("/login");
}
}
登录校验的拦截器编写完成后,接下来我们就可以重新启动服务来做一个测试: (关闭登录校验Filter过滤器)
-
测试1:未登录是否可以访问部门管理页面
首先关闭浏览器,重新打开浏览器,在地址栏中输入:http://localhost:90/#/system/dept
由于用户没有登录,校验机制返回错误信息,前端页面根据返回的错误信息结果,自动跳转到登录页面了
-
测试2:先进行登录操作,再访问部门管理页面
登录校验成功之后,可以正常访问相关业务操作页面
到此我们也就验证了所开发的登录校验的拦截器也是没问题的。登录校验的过滤器和拦截器,我们只需要使用其中的一种就可以了。
10. 异常处理
10.1 当前问题
登录功能和登录校验功能我们都实现了,下面我们学习下今天最后一块技术点:异常处理。首先我们先来看一下系统出现异常之后会发生什么现象,再来介绍异常处理的方案。
我们打开浏览器,访问系统中的新增部门操作,系统中已经有了 "就业部" 这个部门,我们再来增加一个就业部,看看会发生什么现象。
点击确定之后,窗口关闭了,页面没有任何反应,就业部也没有添加上。 而此时,大家会发现,网络请求报错了。
状态码为500,表示服务器端异常,我们打开idea,来看一下,服务器端出了什么问题。
上述错误信息的含义是,dept部门表的name字段的值 就业部 重复了,因为在数据库表dept中已经有了就业部,我们之前设计这张表时,为name字段建议了唯一约束,所以该字段的值是不能重复的。
而当我们再添加就业部,这个部门时,就违反了唯一约束,此时就会报错。
我们来看一下出现异常之后,最终服务端给前端响应回来的数据长什么样。
响应回来的数据是一个JSON格式的数据。但这种JSON格式的数据还是我们开发规范当中所提到的统一响应结果Result吗?显然并不是。由于返回的数据不符合开发规范,所以前端并不能解析出响应的JSON数据。
接下来我们需要思考的是出现异常之后,当前案例项目的异常是怎么处理的?
-
答案:没有做任何的异常处理
当我们没有做任何的异常处理时,我们三层架构处理异常的方案:
-
Mapper接口在操作数据库的时候出错了,此时异常会往上抛(谁调用Mapper就抛给谁),会抛给service。
-
service 中也存在异常了,会抛给controller。
-
而在controller当中,我们也没有做任何的异常处理,所以最终异常会再往上抛。最终抛给框架之后,框架就会返回一个JSON格式的数据,里面封装的就是错误的信息,但是框架返回的JSON格式的数据并不符合我们的开发规范。
10.2 解决方案
那么在三层构架项目中,出现了异常,该如何处理?
-
方案一:在所有Controller的所有方法中进行try…catch处理
-
缺点:代码臃肿(不推荐)
-
-
方案二:全局异常处理器
-
好处:简单、优雅(推荐)
-
10.3 全局异常处理器
我们该怎么样定义全局异常处理器?
-
定义全局异常处理器非常简单,就是定义一个类,在类上加上一个注解@RestControllerAdvice,加上这个注解就代表我们定义了一个全局异常处理器。
-
在全局异常处理器当中,需要定义一个方法来捕获异常,在这个方法上需要加上注解@ExceptionHandler。通过@ExceptionHandler注解当中的value属性来指定我们要捕获的是哪一类型的异常。
package com.example.exception;
import com.example.pojo.Result;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* @Auther lmy
* @date 2024-05-05 23:25
* @Description 全局异常处理器
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) //捕获所有异常
public Result ex(Exception ex) {
ex.printStackTrace(); //打印堆栈中的异常信息
//捕获到异常之后,响应一个标准的Result
return Result.error("对不起,操作失败,请联系管理员");
}
}
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
处理异常的方法返回值会转换为json后再响应给前端
重新启动SpringBoot服务,打开浏览器,再来测试一下添加部门这个操作,我们依然添加已存在的 "就业部" 这个部门:
此时,我们可以看到,出现异常之后,异常已经被全局异常处理器捕获了。然后返回的错误信息,被前端程序正常解析,然后提示出了对应的错误提示信息。
以上就是全局异常处理器的使用,主要涉及到两个注解:
-
@RestControllerAdvice //表示当前类为全局异常处理器
-
@ExceptionHandler //指定可以捕获哪种类型的异常进行处理