目录
前言
Springboot + restful规范 + yml配置文件 + 令牌技术JWT + 云存储OSS+拦截器Interceptor+全局异常处理器
前面我们已经讲解了Web前端开发的基础知识,也讲解了Web后端开发的基础(HTTP协议、请求响应),并且也讲解了数据库MySQL,以及通过Mybatis框架如何来完成数据库的基本操作。 那接下来,我们就通过一个案例,来将前端开发、后端开发、数据库整合起来。 而这个案例呢,就是我们前面提到的Tlias智能学习辅助系统。
在这个案例中,前端开发人员已经将前端工程开发完毕了。 我们需要做的,就是参考接口文档完成后端功能的开发,然后结合前端工程进行联调测试即可。
一、准备工作
1.需求&环境搭建
1.1需求说明
1、部门管理
部门管理功能开发包括:
查询部门列表
删除部门
新增部门
修改部门
2、员工管理
员工管理功能开发包括:
查询员工列表(分页、条件)
删除员工
新增员工
修改员工
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) (版本选择2.7.5版本,可以创建完毕之后,在pom.xml文件中更改版本号)
创建项目工程目录结构:
第3步:配置文件application.properties中引入mybatis的配置信息,准备对应的实体类
application.properties (直接把之前项目中的复制过来)
#数据库连接 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/tlias spring.datasource.username=root spring.datasource.password=1234 #开启mybatis的日志输出 mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl #开启数据库表字段 到 实体类属性的驼峰映射 mybatis.configuration.map-underscore-to-camel-case=true
实体类
/*部门类*/ @Data @NoArgsConstructor @AllArgsConstructor public class Dept { private Integer id; private String name; private LocalDateTime createTime; private LocalDateTime updateTime; }
/*员工类*/ @Data @NoArgsConstructor @AllArgsConstructor public class Emp { private Integer id; private String username; private String password; private String name; private Short gender; private String image; private Short job; private LocalDate entrydate; private Integer deptId; private LocalDateTime createTime; private LocalDateTime updateTime; }
第4步:准备对应的Mapper、Service(接口、实现类)、Controller基础结构
数据访问层:
DeptMapper
package com.itheima.mapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface DeptMapper { }
EmpMapper
package com.itheima.mapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface EmpMapper { }
业务层:
DeptService
package com.itheima.service; //部门业务规则 public interface DeptService { }
DeptServiceImpl
package com.itheima.service.impl; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; //部门业务实现类 @Slf4j @Service public class DeptServiceImpl implements DeptService { }
EmpService
package com.itheima.service; //员工业务规则 public interface EmpService { }
EmpServiceImpl
package com.itheima.service.impl; import com.itheima.service.EmpService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; //员工业务实现类 @Slf4j @Service public class EmpServiceImpl implements EmpService { }
控制层:
DeptController
package com.itheima.controller; import org.springframework.web.bind.annotation.RestController; //部门管理控制器 @RestController public class DeptController { }
EmpController
package com.itheima.controller; import org.springframework.web.bind.annotation.RestController; //员工管理控制器 @RestController public class EmpController { }
2.开发规范
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.2 开发规范-统一响应结果
前后端工程在进行交互时,使用统一响应结果 Result。
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.开发流程
二、部门管理
我们按照前面学习的开发流程,开始完成功能开发。首先按照之前分析的需求,完成
部门管理
的功能开发。开发的部门管理功能包含:
查询部门
删除部门
新增部门
更新部门
1.查询部门
(1)原型和需求
查询的部门的信息:部门ID、部门名称、修改时间
通过页面原型以及需求描述,我们可以看到,部门查询,是不需要考虑分页操作的。
(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" } ] }
(3)思路分析
(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); } }
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(简单SQL直接用注解开发就行)
@Mapper public interface DeptMapper { //查询所有部门数据 @Select("select id, name, create_time, update_time from dept") List<Dept> list(); }
(5)功能接口测试
打开后端接口测试工具postman测试
响应体有数据,测试成功
(6)前后端联调
完成了查询部门的功能,我们也通过postman工具测试通过了,下面我们再基于前后端分离的方式进行接口联调。具体操作如下:
1、将资料中提供的"前端环境"文件夹中的压缩包,拷贝到一个没有中文不带空格的目录下
2、拷贝到一个没有中文不带空格的目录后,进行解压(解压到当前目录)
3、启动nginx
4、打开浏览器,访问:http://localhost:90
5、测试:部门管理 - 查询部门列表
2.删除部门
(1)原型和需求
点击部门列表后面操作栏的 "删除" 按钮,就可以删除该部门信息。 此时,前端只需要给服务端传递一个ID参数就可以了。 我们从接口文档中也可以看得出来。
(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 }
(3)思路分析
接口文档规定:
前端请求路径:/depts/{id}
前端请求方式:DELETE
问题1:怎么在controller中接收请求路径中的路径参数?
@PathVariable问题2:如何限定请求方式是delete?
@DeleteMapping
(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); //省略... }
(5)功能测试
删除功能开发完成后,重新启动项目,使用postman,发起DELETE请求:
(6)前后端联调
打开浏览器,测试后端功能接口:
3.添加部门
(1)原型和需求
点击 "新增部门" 按钮,弹出新增部门对话框,输入部门名称,点击 "保存" ,将部门信息保存到数据库。名字重复就提示。
(2)接口文档
添加部门
基本信息
请求路径:/depts 请求方式:POST 接口描述:该接口用于添加部门数据请求参数
格式:application/json
参数说明:
参数名 类型 是否必须 备注 name string 必须 部门名称 请求参数样例:
{ "name": "教研部" }响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据 响应数据样例:
{ "code":1, "msg":"success", "data":null }
(3)思路分析
接口文档规定:
前端请求路径:/depts
前端请求方式:POST
前端请求参数 (Json格式):{ "name": "教研部" }
问题1:如何限定请求方式是POST?
@PostMapping问题2:怎么在controller中接收json格式的请求参数?
@RequestBody //把前端传递的json数据填充到实体类中
(4)功能开发
通过查看接口文档:新增部门
请求路径:/depts
请求方式:POST
请求参数:json格式
响应数据:json格式
注意:因为数据库设置部门名称name这一列是设置的unique的,所以要添加部门名称的判断。
DeptController
@Slf4j @RestController public class DeptController { @Autowired private DeptService deptService; @PostMapping("/depts") public Result add(@RequestBody Dept dept){ if (deptService.add(dept)>0){ return Result.error("添加部门失败"); } log.info("添加部门的信息为:{}",dept); return Result.success(); } //省略... }
DeptService
public interface DeptService { /** * 新增部门 * @param dept 部门对象 */ int add(Dept dept); //省略... }
DeptServiceImpl
@Slf4j @Service public class DeptServiceImpl implements DeptService { @Autowired private DeptMapper deptMapper; @Override public int add(Dept dept) { int count = deptMapper.selectByName(dept.getName()); if (count>0){ log.info("部门名称:{}已存在", dept.getName()); return count; } dept.setCreateTime(LocalDateTime.now()); dept.setUpdateTime(LocalDateTime.now()); deptMapper.add(dept); return 0; } //省略... }
DeptMapper
@Mapper public interface DeptMapper { @Insert("insert into dept(name,create_time,update_time) values(#{name},#{createTime},#{updateTime})") void add(Dept dept); @Select("select count(*) from dept where name=#{name}") int selectByName(String name); //省略... }
(5)功能测试
新增功能开发完成后,重新启动项目,使用postman,发起POST请求:
(6)前后端联调
打开浏览器,测试后端功能接口:
(7)优化请求路径
我们部门管理的查询
、删除
、新增
功能全部完成了,接下来我们要对controller层的代码进行优化。
首先我们先来看下目前controller层代码:
以上三个方法上的请求路径,存在一个共同点:都是以
/depts
作为开头。(重复了)
在Spring当中为了简化请求路径的定义,可以把公共的请求路径,直接抽取到类上,在类上加一个注解@RequestMapping,并指定请求路径"/depts"。代码参照如下:
注意事项:一个完整的请求路径,应该是类上@RequestMapping的value属性 + 方法上的 各种(@GetMapping、@PostMapping、@DeleteMapping、@PutMapping)的value属性
4.更新部门
(1)原型和需求
点击 "编辑部门" 按钮,弹出新增部门对话框,输入部门名称,点击 "确定" ,将部门信息保存到数据库。名字重复就提示。
(2)接口文档
修改部门
基本信息
请求路径:/depts
请求方式:PUT
接口描述:该接口用于修改部门数据
请求参数
格式:application/json
参数说明:
参数名 类型 是否必须 备注 id number 必须 部门ID name string 必须 部门名称 请求参数样例:
{ "id": 1, "name": "教研部" }响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据 响应数据样例:
{ "code":1, "msg":"success", "data":null }
(3)思路分析
浏览器发起请求,先到控制层接收请求信息,再到服务层判断是否重复名称,判断和更新的过程到数据库连接层Mapper连接数据库查看数据。
(4)功能开发
通过查看接口文档:新增部门
请求路径:/depts
请求方式:PUT
请求参数:json格式
响应数据:json格式
DeptController
@PutMapping("/depts") public Result update(@RequestBody Dept dept){ if (deptService.update(dept)>0){ return Result.error("部门更新失败"); } log.info("修改部门的信息为:{}",dept); return Result.success(); }
DeptService
@Override public int update(Dept dept) { int count = deptMapper.selectByName(dept.getName()); if (count>0){ log.info("部门名称:{}已存在", dept.getName()); return count; } dept.setUpdateTime(LocalDateTime.now()); deptMapper.update(dept); return 0;
DeviceMapper
@Update("update dept set name = #{name},update_time = #{updateTime} where id = #{id}") void update(Dept dept);
三、员工管理
基于以上原型,我们可以把员工管理功能分为:
分页查询(今天完成)
带条件的分页查询(今天完成)
删除员工(今天完成)
新增员工(后续完成)
修改员工(后续完成)
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语句,发现:表的索引下标从0开始 开始索引一直在改变 , 每页显示条数是固定的
开始索引的计算公式: 开始索引 = (当前页码 - 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)思路分析
(4)功能开发
通过查看接口文档:员工列表查询
请求路径:/emps
请求方式:GET
请求参数:跟随在请求路径后的参数字符串。 例:/emps?page=1&pageSize=10
响应数据:json格式
EmpController
@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); //调用业务层分页查询功能 PageBean pageBean = empService.page(page, pageSize); //响应 return Result.success(pageBean); } }
EmpService
public interface EmpService { /** * 条件分页查询 * @param page 页码 * @param pageSize 每页展示记录数 * @return */ PageBean page(Integer page, Integer pageSize); }
EmpServiceImpl
@Slf4j @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; //计算起始索引 , 公式: (页码-1)*页大小 List<Emp> empList = empMapper.list(start, pageSize); //3、封装PageBean对象 PageBean pageBean = new PageBean(count , empList); return pageBean; } }
EmpMapper
@Mapper public interface EmpMapper { //获取总记录数 @Select("select count(*) from emp") public Long count(); //获取当前页的结果列表 @Select("select * from emp limit #{start}, #{pageSize}") public List<Emp> list(Integer start, Integer pageSize); }
(5)功能测试
功能开发完成后,重新启动项目,使用postman,发起POST请求:
(6)前后端联调
打开浏览器,测试后端功能接口:
(7)分页插件
前面我们已经完了基础的分页查询,大家会发现:分页查询功能编写起来比较繁琐。
原理:
在执行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 ? , ?
代码改造实现
1.引入PageHelper依赖
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.6</version> </dependency>
2、EmpMapper
@Mapper public interface EmpMapper { //获取当前页的结果列表 @Select("select * from emp") public List<Emp> page(Integer start, Integer pageSize); }
3、EmpServiceImpl
@Override public PageBean page(Integer page, Integer pageSize) { // 设置分页参数 PageHelper.startPage(page, pageSize); // 执行分页查询 List<Emp> empList = empMapper.list(name,gender,begin,end); // 获取分页结果 Page<Emp> p = (Page<Emp>) empList; //封装PageBean PageBean pageBean = new PageBean(p.getTotal(), p.getResult()); return pageBean; }
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 。
(2)接口文档
请求路径:/emps
请求方式:GET
请求参数:
参数名称 是否必须 示例 备注 name 否 张 姓名 gender 否 1 性别 , 1 男 , 2 女 begin 否 2010-01-01 范围匹配的开始时间(入职日期) end 否 2020-01-01 范围匹配的结束时间(入职日期) page 是 1 分页查询的页码,如果未指定,默认为1 pageSize 是 10 分页查询的每页记录数,如果未指定,默认为10
(3)思路分析
(4)功能开发
在原有分页查询的代码基础上进行改造:
EmpController
@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); //调用业务层分页查询功能 PageBean pageBean = empService.page(page, pageSize, name, gender, begin, end); //响应 return Result.success(pageBean); } }
EmpService
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
@Slf4j @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) { //设置分页参数 PageHelper.startPage(page, pageSize); //执行条件分页查询 List<Emp> empList = empMapper.list(name, gender, begin, end); //获取查询结果 Page<Emp> p = (Page<Emp>) empList; //封装PageBean PageBean pageBean = new PageBean(p.getTotal(), p.getResult()); return pageBean; } }
EmpMapper
@Mapper public interface EmpMapper { //获取当前页的结果列表 public List<Emp> list(String name, Short gender, LocalDate begin, LocalDate end); }
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.itheima.mapper.EmpMapper"> <!-- 条件分页查询 --> <select id="list" resultType="com.itheima.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>
(5)功能测试
功能开发完成后,重启项目工程,打开postman,发起GET请求:
(6)前后端联调
3.删除员工
(1)原型和需求
当我们勾选列表前面的复选框,然后点击 "批量删除" 按钮,就可以将这一批次的员工信息删除掉了。也可以只勾选一个复选框,仅删除一个员工信息。
问题:我们需要开发两个功能接口吗?一个删除单个员工,一个删除多个员工
答案:不需要。 只需要开发一个功能接口即可(删除多个员工包含只删除一个员工)
(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)思路分析
接口文档规定:
前端请求路径:/emps/{ids}
前端请求方式:DELETE
问题1:怎么在controller中接收请求路径中的路径参数?
@PathVariable问题2:如何限定请求方式是delete?
@DeleteMapping问题3:在Mapper接口中,执行delete操作的SQL语句时,条件中的id值是不确定的是动态的,怎么实现呢?
Mybatis中的动态SQL:foreach
(4)功能开发
通过查看接口文档:删除员工
请求路径:/emps/{ids}
请求方式:DELETE
请求参数:路径参数 {ids}
响应数据:json格式
EmpController
@Slf4j @RestController @RequestMapping("/emps") public class EmpController { @Autowired private EmpService empService; //批量删除 @DeleteMapping("/{ids}") public Result delete(@PathVariable List<Integer> ids){ empService.delete(ids); return Result.success(); } }
EmpService
public interface EmpService { /** * 批量删除操作 * @param ids id集合 */ void delete(List<Integer> ids); //省略... }
EmpServiceImpl
@Slf4j @Service public class EmpServiceImpl implements EmpService { @Autowired private EmpMapper empMapper; @Override public void delete(List<Integer> ids) { empMapper.delete(ids); } //省略... }
EmpMapper
@Mapper public interface EmpMapper { //批量删除 void delete(List<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.itheima.mapper.EmpMapper"> <!--批量删除员工--> <select id="delete"> delete from emp where id in <foreach collection="ids" item="id" open="(" close=")" separator=","> #{id} </foreach> </select> <!-- 省略... --> </mapper>
(5)功能测试
功能开发完成后,重启项目工程,打开postman,发起DELETE请求:
(6)前后端联调
打开浏览器,测试后端功能接口:
4.添加员工
(1)原型和需求
在新增用户时,我们需要保存用户的基本信息,并且还需要上传的员工的图片,目前我们先完成第一步操作,保存用户的基本信息。
(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 }
(3)思路分析
接口文档规定:
请求路径:/emps
请求方式:POST
请求参数:Json格式数据
响应数据:Json格式数据
问题1:如何限定请求方式是POST?
@PostMapping问题2:怎么在controller中接收json格式的请求参数?
@RequestBody //把前端传递的json数据填充到实体类中
(4)功能开发
EmpController
@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
public interface EmpService { /** * 保存员工信息 * @param emp */ void save(Emp emp); //省略... }
EmpServiceImpl
@Slf4j @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.insert(emp); } //省略... }
EmpMapper
@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 insert(Emp emp); //省略... }
(5)功能测试
代码开发完成后,重启服务器,打开Postman发送 POST 请求,请求路径:http://localhost:8080/emps
(6)前后端联调
功能测试通过后,我们再进行通过打开浏览器,测试后端功能接口:
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
后端程序又是如何实现
UploadController代码:
@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 , 录入数据并提交
通过后端程序控制台的debug内容可以看到,上传的文件是存放在一个临时目录
打开临时目录可以看到以下内容:
表单提交的三项数据(姓名、年龄、文件),分别存储在不同的临时文件中:
当我们程序运行完毕之后,这个临时文件会自动删除。
所以,我们如果想要实现文件上传,需要将这个临时文件,要转存到我们的磁盘目录中。
所以熟悉文件上传的流程后,还要将上传到的临时文件保存下来,下面就要介绍
本地存储和云存储(阿里OSS)
(2)本地存储
前面我们已分析了文件上传功能前端和后端的基础代码实现,文件上传时在服务端会产生一个临时文件,请求响应完成之后,这个临时文件被自动删除,并没有进行保存。下面呢,我们就需要完成将上传的文件保存在服务器的本地磁盘上。
实现
代码实现:
在服务器本地磁盘上创建images目录,用来存储上传的文件(例:E盘创建images目录)
使用MultipartFile类提供的API方法,把临时文件转存到本地磁盘目录下
MultipartFile 常见方法:
String getOriginalFilename(); //获取原始文件名
void transferTo(File dest); //将接收的文件转存到磁盘文件中
long getSize(); //获取文件的大小,单位:字节
byte[] getBytes(); //获取文件内容的字节数组
InputStream getInputStream(); //获取接收到的文件内容的输入流
修改服务端接收文件的代码:
@Slf4j @RestController public class UploadController { @PostMapping("/upload") public Result upload(String username, Integer age, MultipartFile image) throws IOException { log.info("文件上传:{},{},{}",username,age,image); //获取原始文件名 String originalFilename = image.getOriginalFilename(); //构建新的文件名 String extname = originalFilename.substring(originalFilename.lastIndexOf("."));//文件扩展名 String newFileName = UUID.randomUUID().toString()+extname;//随机名+文件扩展名 //将文件存储在服务器的磁盘目录 image.transferTo(new File("E:/images/"+newFileName)); return Result.success(); } }
上面代码使用UUID解决了文件名唯一性的问题后,我们再次上传一个较大的文件(超出1M)时发现,后端程序报错:
报错原因呢是因为:在SpringBoot中,文件上传时默认单个文件最大大小为1M
那么如果需要上传大文件,可以在application.properties进行如下配置:
#配置单个文件最大上传大小 spring.servlet.multipart.max-file-size=10MB #配置单个请求最大上传大小(一次请求可以上传多个文件) spring.servlet.multipart.max-request-size=100MB
缺点
如果直接存储在服务器的磁盘目录中,存在以下缺点:
不安全:磁盘如果损坏,所有的文件就会丢失
容量有限:如果存储大量的图片,磁盘空间有限(磁盘不可能无限制扩容)
无法直接访问
为了解决上述问题呢,通常有两种解决方案:
自己搭建存储服务器,如:fastDFS 、MinIO
使用现成的云服务,如:阿里云,腾讯云,华为云
(3) 阿里OSS(云存储)
云服务指的就是通过互联网对外提供的各种各样的服务,比如像:语音服务、短信服务、邮件服务、视频直播服务、文字识别服务、对象存储服务等等。
当我们在项目开发时需要用到某个或某些服务,就不需要自己来开发了,可以直接使用阿里云提供好的这些现成服务就可以了。比如:在项目开发当中,我们要实现一个短信发送的功能,如果我们项目组自己实现,将会非常繁琐,因为你需要和各个运营商进行对接。而此时阿里云完成了和三大运营商对接,并对外提供了一个短信服务。我们项目组只需要调用阿里云提供的短信服务,就可以很方便的来发送短信了。这样就降低了我们项目的开发难度,同时也提高了项目的开发效率。(大白话:别人帮我们实现好了功能,我们只要调用即可)
云服务提供商给我们提供的软件服务通常是需要收取一部分费用的。
阿里云对象存储OSS(Object Storage Service),是一款海量、安全、低成本、高可靠的云存储服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。
3.1准备
进入阿里云官网注册登录,后在主页点击控制台
进入控制台找到对象存储OSS服务(没开通服务先开通)
进入到阿里云对象存储的控制台,点击左侧的 "Bucket列表",创建一个Bucket
点击头像,再点击AccessKey,创建Access账户,记录ID和密码(后面代码要用)
3.2入门
阿里云oss 对象存储服务的准备工作我们已经完成了,接下来我们就来完成第二步操作:参照官方所提供的sdk示例来编写入门程序。
首先我们需要来打开阿里云OSS的官方文档,在官方文档中找到 SDK 的示例代码:
参照官方提供的SDK,改造一下,即可实现文件上传功能:
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; public class AliOssTest { public static void main(String[] args) throws Exception { // Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。 String endpoint = "oss-cn-shanghai.aliyuncs.com"; // 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。 String accessKeyId = "LTAI5t9MZK8iq5T2Av5GLDxX"; String accessKeySecret = "C0IrHzKZGKqU8S7YQcevcotD3Zd5Tc"; // 填写Bucket名称,例如examplebucket。 String bucketName = "web-framework01"; // 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。 String objectName = "1.jpg"; // 填写本地文件的完整路径,例如D:\\localpath\\examplefile.txt。 // 如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件流。 String filePath= "C:\\Users\\Administrator\\Pictures\\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:文件路径
运行以上程序后,会把本地的文件上传到阿里云OSS服务器上:
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上传文件工具类(由官方的示例代码改造而来)
@Component public class AliOSSUtils { private String endpoint = "https://oss-cn-shanghai.aliyuncs.com"; private String accessKeyId = "LTAI5t9MZDxX"; private String accessKeySecret = "C0IrHzotD3Zd5Tc"; private String bucketName = "web-framework01"; /** * 实现上传图片到OSS */ public String upload(MultipartFile multipartFile) throws IOException { // 获取上传的文件的输入流 InputStream inputStream = multipartFile.getInputStream(); // 避免文件覆盖 String originalFilename = multipartFile.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代码:
@Slf4j @RestController public class UploadController { @Autowired private AliOSSUtils aliOSSUtils; @PostMapping("/upload") public Result upload(MultipartFile image) throws IOException { //调用阿里云OSS工具类,将上传上来的文件存入阿里云 String url = aliOSSUtils.upload(image); //将图片上传完成后的url返回,用于浏览器回显展示 return Result.success(url); } }
使用postman测试:
完善添加员工功能后,前后端联调(头像有图片了)
6.修改员工
在进行修改员工信息的时候,我们首先先要根据员工的ID查询员工的信息用于页面回显展示,然后用户修改员工数据之后,点击保存按钮,就可以将修改的数据提交到服务端,保存到数据库。 具体操作为:
根据ID查询员工信息
保存修改的员工信息
查询回显
(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" } }
(2)实现思路
(3)代码实现
EmpMapper
@Mapper public interface EmpMapper { //根据ID查询员工信息 @Select("select id, username, password, name, gender, image, job, entrydate, dept_id, create_time, update_time " + "from emp " + "where id = #{id}") public Emp findById(Integer id); //省略... }
EmpService
public interface EmpService { /** * 根据ID查询员工 * @param id * @return */ public Emp getById(Integer id); //省略... }
EmpServiceImpl
@Slf4j @Service public class EmpServiceImpl implements EmpService { @Autowired private EmpMapper empMapper; @Override public Emp getById(Integer id) { return empMapper.findById(id); } //省略... }
EmpController
@Slf4j @RestController @RequestMapping("/emps") public class EmpController { @Autowired private EmpService empService; //根据id查询 @GetMapping("/{id}") public Result getById(@PathVariable Integer id){ Emp emp = empService.getById(id); return Result.success(emp); } //省略... }
(4)postman测试
修改员工
(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 }
(2)实现思路
(3)代码实现
EmpMapper
@Mapper public interface EmpMapper { //修改员工信息 public 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.itheima.mapper.EmpMapper"> <!--更新员工信息--> <update id="update"> update emp <set> <if test="username != null and username != ''"> username = #{username}, </if> <if test="password != null and password != ''"> password = #{password}, </if> <if test="name != null and name != ''"> name = #{name}, </if> <if test="gender != null"> gender = #{gender}, </if> <if test="image != null and image != ''"> image = #{image}, </if> <if test="job != null"> job = #{job}, </if> <if test="entrydate != null"> entrydate = #{entrydate}, </if> <if test="deptId != null"> dept_id = #{deptId}, </if> <if test="updateTime != null"> update_time = #{updateTime} </if> </set> where id = #{id} </update> <!-- 省略... --> </mapper>
EmpService
public interface EmpService { /** * 更新员工 * @param emp */ public void update(Emp emp); //省略... }
EmpServiceImpl
@Slf4j @Service public class EmpServiceImpl implements EmpService { @Autowired private EmpMapper empMapper; @Override public void update(Emp emp) { emp.setUpdateTime(LocalDateTime.now()); //更新修改时间为当前时间 empMapper.update(emp); } //省略... }
EmpController
@Slf4j @RestController @RequestMapping("/emps") public class EmpController { @Autowired private EmpService empService; //修改员工 @PutMapping public Result update(@RequestBody Emp emp){ empService.update(emp); return Result.success(); } //省略... }
(4)postman测试
(5)前后端联调测试
7.配置文件
(1)参数配置化
直接将参数保存到springboot工程的application.properties这个配置文件中,用到的时候在属性上面添加@Value(${...})
(2)yml配置文件(推荐)
2.1比较
前面我们一直使用springboot项目创建完毕后自带的application.properties进行属性的配置,那其实呢,在springboot项目当中是支持多种配置方式的,除了支持properties配置文件以外,还支持另外一种类型的配置文件,就是我们接下来要讲解的yml格式的配置文件。
2.2格式
我们可以看到配置同样的数据信息,yml格式的数据有以下特点:
容易阅读
容易与脚本语言交互
以数据为核心,重数据轻格式
简单的了解过springboot所支持的配置文件,以及不同类型配置文件之间的优缺点之后,接下来我们就来了解下yml配置文件的基本语法:
大小写敏感
数值前边必须有空格,作为分隔符
使用缩进表示层级关系,缩进时,不允许使用Tab键,只能用空格(idea中会自动将Tab转换为空格)
缩进的空格数目不重要,只要相同层级的元素左侧对齐即可
#
表示注释,从这个字符一直到行尾,都会被解析器忽略
2.3数据格式
了解完yml格式配置文件的基本语法之后,接下来我们再来看下yml文件中常见的数据格式。在这里我们主要介绍最为常见的两类:
定义对象或Map集合
定义数组、list或set集合
对象/Map集合
user: name: zhangsan age: 18 password: 123456数组/List/Set集合
hobby: - java - game - sport
2.4原properties配置文件转换yml
新建的application.yml文件:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/tlias username: root password: 1234 servlet: multipart: max-file-size: 10MB max-request-size: 100MB mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true aliyun: oss: endpoint: https://oss-cn-hangzhou.aliyuncs.com accessKeyId: LTAI4GCH1vX6DKqJWxd6nEuW accessKeySecret: yBshYweHOpqDuhCArrVHwIiBKpyqSL bucketName: web-397
(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
import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; /*阿里云OSS相关配置*/ @Data @Component @ConfigurationProperties(prefix = "aliyun.oss") public class AliOSSProperties { //区域 private String endpoint; //身份ID private String accessKeyId ; //身份密钥 private String accessKeySecret ; //存储空间 private String bucketName; }
AliOSSUtils工具类:
@Component //当前类对象由Spring创建和管理 public class AliOSSUtils { //注入配置参数实体类对象 @Autowired private AliOSSProperties aliOSSProperties; /** * 实现上传图片到OSS */ public String upload(MultipartFile multipartFile) throws IOException { // 获取上传的文件的输入流 InputStream inputStream = multipartFile.getInputStream(); // 避免文件覆盖 String originalFilename = multipartFile.getOriginalFilename(); String fileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf(".")); //上传文件到 OSS OSS ossClient = new OSSClientBuilder().build(aliOSSProperties.getEndpoint(), aliOSSProperties.getAccessKeyId(), aliOSSProperties.getAccessKeySecret()); ossClient.putObject(aliOSSProperties.getBucketName(), fileName, inputStream); //文件访问路径 String url =aliOSSProperties.getEndpoint().split("//")[0] + "//" + aliOSSProperties.getBucketName() + "." + aliOSSProperties.getEndpoint().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对象。
区分一下@ConfigurationProperties注解以及我们前面所介绍的另外一个@Value注解:
相同点:都是用来注入外部配置的属性的。
不同点:
@Value注解只能一个一个的进行外部属性的注入。
@ConfigurationProperties可以批量的将外部的属性配置注入到bean对象的属性中。
如果要注入的属性非常的多,并且还想做到复用,就可以定义这么一个bean对象。通过 configuration properties 批量的将外部的属性配置直接注入到 bean 对象的属性当中。在其他的类当中,我要想获取到注入进来的属性,我直接注入 bean 对象,然后调用 get 方法,就可以获取到对应的属性值了
8.登录认证
(1)基础登录功能
在登录界面中,我们可以输入用户的用户名以及密码,然后点击 "登录" 按钮就要请求服务器,服务端判断用户输入的用户名或者密码是否正确。如果正确,则返回成功结果,前端跳转至系统首页面。
1.1接口文档
基本信息
请求路径:/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" }
1.2功能实现
LoginController
@RestController public class LoginController { @Autowired private EmpService empService; @PostMapping("/login") public Result login(@RequestBody Emp emp){ Emp e = empService.login(emp); return e != null ? Result.success():Result.error("用户名或密码错误"); } }
EmpService
public interface EmpService { /** * 用户登录 * @param emp * @return */ public Emp login(Emp emp); //省略其他代码... }
EmpServiceImpl
@Slf4j @Service public class EmpServiceImpl implements EmpService { @Autowired private EmpMapper empMapper; @Override public Emp login(Emp emp) { //调用dao层功能:登录 Emp loginEmp = empMapper.getByUsernameAndPassword(emp); //返回查询结果给Controller return loginEmp; } //省略其他代码... }
EmpMapper
@Mapper public interface EmpMapper { @Select("select id, username, password, name, gender, image, job, entrydate, dept_id, create_time, update_time " + "from emp " + "where username=#{username} and password =#{password}") public Emp getByUsernameAndPassword(Emp emp); //省略其他代码... }
(2)登录校验
2.1问题分析
我们已经完成了基础登录功能的开发与测试,在我们登录成功后就可以进入到后台管理系统中进行数据的操作。
但是当我们在浏览器中新的页面上输入地址:
http://localhost:9528/#/system/dept
,发现没有登录仍然可以进入到后端管理系统页面。而真正的登录功能应该是:登陆后才能访问后端系统页面,不登陆则跳转登陆页面进行登陆。
为什么会出现这个问题?其实原因很简单,就是因为针对于我们当前所开发的部门管理、员工管理以及文件上传等相关接口来说,我们在服务器端并没有做任何的判断,没有去判断用户是否登录了。所以无论用户是否登录,都可以访问部门管理以及员工管理的相关数据。所以我们目前所开发的登录功能,它只是徒有其表。而我们要想解决这个问题,我们就需要完成一步非常重要的操作:登录校验。
什么是登录校验?
所谓登录校验,指的是我们在服务器端接收到浏览器发送过来的请求之后,首先我们要对请求进行校验。先要校验一下用户登录了没有,如果用户已经登录了,就直接执行对应的业务操作就可以了;如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。
了解完什么是登录校验之后,接下来我们分析一下登录校验大概的实现思路。
首先我们在宏观上先有一个认知:
前面在讲解HTTP协议的时候,我们提到HTTP协议是无状态协议。什么又是无状态的协议?
所谓无状态,指的是每一次请求都是独立的,下一次请求并不会携带上一次请求的数据。而浏览器与服务器之间进行交互,基于HTTP协议也就意味着现在我们通过浏览器来访问了登陆这个接口,实现了登陆的操作,接下来我们在执行其他业务操作时,服务器也并不知道这个员工到底登陆了没有。因为HTTP协议是无状态的,两次请求之间是独立的,所以是无法判断这个员工到底登陆了没有。
那应该怎么来实现登录校验的操作呢?具体的实现思路可以分为两部分:
在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记。
在浏览器发起请求时,需要在服务端进行统一拦截,拦截后进行登录校验。
解说:
想要判断员工是否已经登录,我们需要在员工登录成功之后,存储一个登录成功的标记,接下来在每一个接口方法执行之前,先做一个条件判断,判断一下这个员工到底登录了没有。如果是登录了,就可以执行正常的业务操作,如果没有登录,会直接给前端返回一个错误的信息,前端拿到这个错误信息之后会自动的跳转到登录页面。
我们程序中所开发的查询功能、删除功能、添加功能、修改功能,都需要使用以上套路进行登录校验。此时就会出现:相同代码逻辑,每个功能都需要编写,就会造成代码非常繁琐。
为了简化这块操作,我们可以使用一种技术:统一拦截技术。
通过统一拦截的技术,我们可以来拦截浏览器发送过来的所有的请求,拦截到这个请求之后,就可以通过请求来获取之前所存入的登录标记,在获取到登录标记且标记为登录成功,就说明员工已经登录了。如果已经登录,我们就直接放行(意思就是可以访问正常的业务接口了)。
涉及技术
我们要完成以上操作,会涉及到web开发中的两个技术:
会话技术
统一拦截技术
而统一拦截技术现实方案也有两种:
Servlet规范中的Filter过滤器
Spring提供的interceptor拦截器
2.2会话技术
2.2.1介绍
什么是会话?
在我们日常生活当中,会话指的就是谈话、交谈。
在web开发当中,会话指的就是浏览器与服务器之间的一次连接,我们就称为一次会话。
在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。在一次会话当中,是可以包含多次请求和响应的。
比如:打开了浏览器来访问web服务器上的资源(浏览器不能关闭、服务器不能断开)
第1次:访问的是登录的接口,完成登录操作
第2次:访问的是部门管理接口,查询所有部门数据
第3次:访问的是员工管理接口,查询员工数据
只要浏览器和服务器都没有关闭,以上3次请求都属于一次会话当中完成的。
需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。比如:1、2、3这三个请求都是属于同一个会话。当我们关闭浏览器之后,这次会话就结束了。而如果我们是直接把web服务器关了,那么所有的会话就都结束了。
知道会话还要知道会话跟踪
会话跟踪:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据。
服务器会接收很多的请求,但是服务器是需要识别出这些请求是不是同一个浏览器发出来的。比如:1和2这两个请求是不是同一个浏览器发出来的,3和5这两个请求不是同一个浏览器发出来的。如果是同一个浏览器发出来的,就说明是同一个会话。如果是不同的浏览器发出来的,就说明是不同的会话。而识别多次请求是否来自于同一浏览器的过程,我们就称为会话跟踪。
我们使用会话跟踪技术就是要完成在同一个会话中,多个请求之间进行共享数据。
那为什么要共享数据呢?
由于HTTP是无状态协议,在后面请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享
会话跟踪技术有两种:
Cookie(客户端会话跟踪技术)
数据存储在客户端浏览器当中
Session(服务端会话跟踪技术)
数据存储在储在服务端
令牌技术
2.2.2会话跟踪技术——cookie和session(这次不用)
之前发过
会话管理cookie_Session_三大对象域-CSDN博客
2.2.3会话跟踪技术——令牌技术JWT
2.2.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依赖--> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
2.2.3.2生成
@Test public void genJwt(){ Map<String,Object> claims = new HashMap<>(); claims.put("id",1); claims.put("username","Tom"); String jwt = Jwts.builder() .setClaims(claims) //自定义内容(载荷) .signWith(SignatureAlgorithm.HS256, "itheima") //签名算法 .setExpiration(new Date(System.currentTimeMillis() + 24*3600*1000)) //有效期 .compact(); System.out.println(jwt); }
输出的结果就是生成的JWT令牌,,通过英文的点分割对三个部分进行分割,我们可以将生成的令牌复制一下,然后打开JWT的官网,将生成的令牌直接放在Encoded位置,此时就会自动的将令牌解析出来。
第一部分解析出来,看到JSON格式的原始数据,所使用的签名算法为HS256。
第二个部分是我们自定义的数据,之前我们自定义的数据就是id,还有一个exp代表的是我们所设置的过期时间。
由于前两个部分是base64编码,所以是可以直接解码出来。但最后一个部分并不是base64编码,是经过签名算法计算出来的,所以最后一个部分是不会解析的。
2.2.3.3校验
@Test public void parseJwt(){ Claims claims = Jwts.parser() .setSigningKey("itheima")//指定签名密钥(必须保证和生成令牌时使用相同的签名密钥) .parse("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk") .getBody(); System.out.println(claims); }
校验必须满足下面两个条件:
JWT校验时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是配套的。
如果JWT令牌解析校验时报错,则说明 JWT令牌被篡改 或 失效了,令牌非法。
2.3登录下发令牌(完善登录)
JWT令牌的生成和校验的基本操作我们已经学习完了,接下来我们就需要在案例当中通过JWT令牌技术来跟踪会话。具体的思路我们前面已经分析过了,主要就是两步操作:
生成令牌
在登录成功之后来生成一个JWT令牌,并且把这个令牌直接返回给前端
校验令牌
拦截前端请求,从请求中获取到令牌,对令牌进行解析校验
那我们首先来完成:登录成功之后生成JWT令牌,并且把令牌返回给前端。
接口文档响应实例
响应数据
参数格式:application/json
参数说明:
名称 类型 是否必须 默认值 备注 其他信息 code number 必须 响应码, 1 成功 ; 0 失败 msg string 非必须 提示信息 data string 必须 返回的数据 , jwt令牌
实现步骤:
引入JWT工具类
在项目工程下创建com.itheima.utils包,并把提供JWT工具类复制到该包下
登录完成后,调用工具类生成JWT令牌并返回
JWT工具类
public class JwtUtils { private static String signKey = "itheima";//签名密钥 private static Long expire = 43200000L; //有效时间 /** * 生成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; } }
改造原LoginController代码
@RestController @Slf4j public class LoginController { //依赖业务层对象 @Autowired private EmpService empService; @PostMapping("/login") public Result login(@RequestBody Emp emp) { //调用业务层:登录功能 Emp loginEmp = empService.login(emp); //判断:登录用户是否存在 if(loginEmp !=null ){ //自定义信息 Map<String , Object> claims = new HashMap<>(); claims.put("id", loginEmp.getId()); claims.put("username",loginEmp.getUsername()); claims.put("name",loginEmp.getName()); //使用JWT工具类,生成身份令牌 String token = JwtUtils.generateJwt(claims); return Result.success(token); } return Result.error("用户名或密码错误"); } }
注意:
用户登录成功后,系统会自动下发JWT令牌,然后在后续的每次请求中,都需要在请求头header中携带到服务端,请求头的名称为 token ,值为 登录时下发的JWT令牌。
所以后面还要讲解怎么拦截请求报文
2.4登录校验-Filter
过滤器Filter起到拦截作用,原理看之前发的
我们使用到了一个第三方json处理的工具包fastjson。我们要想使用,需要引入如下依赖:
<!-- result 字符串 等等 转Json--> <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.51</version> </dependency>
添加一个过滤器
@Slf4j @WebFilter(urlPatterns = "/*") //拦截所有请求 public class LoginCheckFilter implements Filter { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { //前置:强制转换为http协议的请求对象、响应对象 (转换原因:要使用子类中特有方法) HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; //1.获取请求url String url = request.getRequestURL().toString(); log.info("请求路径:{}", url); //请求路径:http://localhost:8080/login //2.判断请求url中是否包含login,如果包含,说明是登录操作,放行 if(url.contains("/login")){ chain.doFilter(request, response);//放行请求 return;//结束当前方法的执行 } //3.获取请求头中的令牌(token) String token = request.getHeader("token"); log.info("从请求头中获取的令牌:{}",token); //4.判断令牌是否存在,如果不存在,返回错误结果(未登录) if(!StringUtils.hasLength(token)){ log.info("Token不存在"); Result responseResult = Result.error("NOT_LOGIN"); //把Result对象转换为JSON格式字符串 (fastjson是阿里巴巴提供的用于实现对象和json的转换工具类) String json = JSONObject.toJSONString(responseResult); response.setContentType("application/json;charset=utf-8"); //响应 response.getWriter().write(json); return; } //5.解析token,如果解析失败,返回错误结果(未登录) try { JwtUtils.parseJWT(token); }catch (Exception e){ log.info("令牌解析失败!"); Result responseResult = Result.error("NOT_LOGIN"); //把Result对象转换为JSON格式字符串 (fastjson是阿里巴巴提供的用于实现对象和json的转换工具类) String json = JSONObject.toJSONString(responseResult); response.setContentType("application/json;charset=utf-8"); //响应 response.getWriter().write(json); return; } //6.放行 chain.doFilter(request, response); } }
2.5登录校验-Interceptor
2.5.1介绍
什么是拦截器?
是一种动态拦截方法调用的机制,类似于过滤器。
拦截器是Spring框架中提供的,用来动态拦截控制器方法的执行。
拦截器的作用:
拦截请求,在指定方法调用前后,根据业务需要执行预先设定的代码。
在拦截器当中,我们通常也是做一些通用性的操作,比如:我们可以通过拦截器来拦截前端发起的请求,将登录校验的逻辑全部编写在拦截器当中。在校验的过程当中,如发现用户登录了(携带JWT令牌且是合法令牌),就可以直接放行,去访问spring当中的资源。如果校验时发现并没有登录或是非法令牌,就可以直接给前端响应未登录的错误信息。
拦截器的使用步骤和过滤器类似,也分为两步:
定义拦截器
注册配置拦截器
2.5.2拦截路径
在拦截器中除了可以设置
/**
拦截所有资源外,还有一些常见拦截路径设置:
拦截路径 含义 举例 /* 一级路径 能匹配/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
2.5.3把过滤器换成拦截器代码
登录校验拦截器
//自定义拦截器 @Component //当前拦截器对象由Spring创建和管理 @Slf4j public class LoginCheckInterceptor implements HandlerInterceptor { //前置方式 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println("preHandle .... "); //1.获取请求url //2.判断请求url中是否包含login,如果包含,说明是登录操作,放行 //3.获取请求头中的令牌(token) String token = request.getHeader("token"); log.info("从请求头中获取的令牌:{}",token); //4.判断令牌是否存在,如果不存在,返回错误结果(未登录) if(!StringUtils.hasLength(token)){ log.info("Token不存在"); //创建响应结果对象 Result responseResult = Result.error("NOT_LOGIN"); //把Result对象转换为JSON格式字符串 (fastjson是阿里巴巴提供的用于实现对象和json的转换工具类) String json = JSONObject.toJSONString(responseResult); //设置响应头(告知浏览器:响应的数据类型为json、响应的数据编码表为utf-8) response.setContentType("application/json;charset=utf-8"); //响应 response.getWriter().write(json); return false;//不放行 } //5.解析token,如果解析失败,返回错误结果(未登录) try { JwtUtils.parseJWT(token); }catch (Exception e){ log.info("令牌解析失败!"); //创建响应结果对象 Result responseResult = Result.error("NOT_LOGIN"); //把Result对象转换为JSON格式字符串 (fastjson是阿里巴巴提供的用于实现对象和json的转换工具类) String json = JSONObject.toJSONString(responseResult); //设置响应头 response.setContentType("application/json;charset=utf-8"); //响应 response.getWriter().write(json); return false; } //6.放行 return true; }
注册配置拦截器
@Configuration public class WebConfig implements WebMvcConfigurer { //拦截器对象 @Autowired private LoginCheckInterceptor loginCheckInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { //注册自定义拦截器对象 registry.addInterceptor(loginCheckInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login"); } }
2.5.4测试
关闭之前的过滤器,就是注释掉@WebFilter注解。
测试1:未登录是否可以访问部门管理页面
首先关闭浏览器,重新打开浏览器,在地址栏中输入:http://localhost:9528/#/system/dept
由于用户没有登录,校验机制返回错误信息,前端页面根据返回的错误信息结果,自动跳转到登录页面了
测试2:先进行登录操作,再访问部门管理页面
登录校验成功之后,可以正常访问相关业务操作页面
2.6Filter和Interceptor的执行流程和区别
当我们打开浏览器来访问部署在web服务器当中的web应用时,此时我们所定义的过滤器会拦截到这次请求。拦截到这次请求之后,它会先执行放行前的逻辑,然后再执行放行操作。而由于我们当前是基于springboot开发的,所以放行之后是进入到了spring的环境当中,也就是要来访问我们所定义的controller当中的接口方法。
Tomcat并不识别所编写的Controller程序,但是它识别Servlet程序,所以在Spring的Web环境中提供了一个非常核心的Servlet:DispatcherServlet(前端控制器),所有请求都会先进行到DispatcherServlet,再将请求转给Controller。
当我们定义了拦截器后,会在执行Controller的方法之前,请求被拦截器拦截住。执行
preHandle()
方法,这个方法执行完成后需要返回一个布尔类型的值,如果返回true,就表示放行本次操作,才会继续访问controller中的方法;如果返回false,则不会放行(controller中的方法也不会执行)。在controller当中的方法执行完毕之后,再回过来执行
postHandle()
这个方法以及afterCompletion()
方法,然后再返回给DispatcherServlet,最终再来执行过滤器当中放行后的这一部分逻辑的逻辑。执行完毕之后,最终给浏览器响应数据。
9.全局异常处理器
当我们没有做任何的异常处理时,我们三层架构处理异常的方案:
Mapper接口在操作数据库的时候出错了,此时异常会往上抛(谁调用Mapper就抛给谁),会抛给service。
service 中也存在异常了,会抛给controller。
而在controller当中,我们也没有做任何的异常处理,所以最终异常会再往上抛。最终抛给框架之后,框架就会返回一个JSON格式的数据,里面封装的就是错误的信息,但是框架返回的JSON格式的数据并不符合我们的开发规范。
浏览器就会报500的服务端错误
所以使用全局异常处理器,最后的异常抛给它,代码实现捕获异常,并返回一个JSON格式数据。
下面是出现异常抛出的路线
怎么样定义全局异常处理器?
定义全局异常处理器非常简单,就是定义一个类,在类上加上一个注解@RestControllerAdvice,加上这个注解就代表我们定义了一个全局异常处理器。
在全局异常处理器当中,需要定义一个方法来捕获异常,在这个方法上需要加上注解@ExceptionHandler。通过@ExceptionHandler注解当中的value属性来指定我们要捕获的是哪一类型的异常。
全局异常处理器实现代码
@RestControllerAdvice public class GlobalExceptionHandler { //处理异常 @ExceptionHandler(Exception.class) //指定能够处理的异常类型 public Result ex(Exception e){ e.printStackTrace();//打印堆栈中的异常信息 //捕获到异常之后,响应一个标准的Result return Result.error("对不起,操作失败,请联系管理员"); } }
注意:
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
处理异常的方法返回值会转换为json后再响应给前端