目录
2.3.3.1 设置日期格式 方法1 - @JsonFormat
2.3.3.1 设置日期格式 方法2 - SimpleDateFormat
4.2.1.2 BeanUtils.copyProperties
项目效果演示
QQ2025412-231849-HD
代码 Gitee 地址
1. 准备工作
1.1 建表
本项目中, 涉及用户表和博客表两个数据库表:
-- 建表SQL
create database if not exists java_blog_spring charset utf8mb4;
use java_blog_spring;
-- 用户表
DROP TABLE IF EXISTS java_blog_spring.user_info;
CREATE TABLE java_blog_spring.user_info(
`id` INT NOT NULL AUTO_INCREMENT,
`user_name` VARCHAR ( 128 ) NOT NULL,
`password` VARCHAR ( 128 ) NOT NULL,
`github_url` VARCHAR ( 128 ) NULL,
`delete_flag` TINYINT ( 4 ) NULL DEFAULT 0,
`create_time` DATETIME DEFAULT now(),
`update_time` DATETIME DEFAULT now() ON UPDATE now(),
PRIMARY KEY ( id ),
UNIQUE INDEX user_name_UNIQUE ( user_name ASC )) ENGINE = INNODB DEFAULT CHARACTER
SET = utf8mb4 COMMENT = '用户表';
-- 博客表
drop table if exists java_blog_spring.blog_info;
CREATE TABLE java_blog_spring.blog_info (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(200) NULL,
`content` TEXT NULL,
`user_id` INT(11) NULL,
`delete_flag` TINYINT(4) NULL DEFAULT 0,
`create_time` DATETIME DEFAULT now(),
`update_time` DATETIME DEFAULT now() ON UPDATE now(),
PRIMARY KEY (id))
ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '博客表';
-- 新增用户信息
insert into java_blog_spring.user_info (user_name, password,github_url)values("zhangsan","123456","https://gitee.com/bubble-fish666/class-java45");
insert into java_blog_spring.user_info (user_name, password,github_url)values("lisi","123456","https://gitee.com/bubble-fish666/class-java45");
insert into java_blog_spring.blog_info (title,content,user_id) values("第一篇博客","111我是博客正文我是博客正文我是博客正文",1);
insert into java_blog_spring.blog_info (title,content,user_id) values("第二篇博客","222我是博客正文我是博客正文我是博客正文",2);
1.2 引入 MyBatis-plus 依赖
在上次的图书管理系统中, 我们使用的是 MyBatis 来操作数据库的, 那这次的博客系统, 我们就来使用 MyBatis-plus.
使用 MyBatis-plus, 需要引入 MyBatis-plus 依赖:
<!-- SpringBoot 3.x 版本 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
1.3 配置数据库连接
使用 MyBatis-plus 操作数据库, 必定要配置数据库:
spring:
application:
name: spring-blog-demo
# 配置数据库连接
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_blog_spring?characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 111111
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # MyBatis-Plus 日志打印
mapper-locations: "classpath*:/mapper/**.xml" # Mapper.xml
logging:
file:
name: spring-blog.log
1.4 项目架构
2. 实体类准备 - pojo 包
开发中, 以下命名统称为实体类:
- POJO
- Model
- Entity
在实际开发中, 实体类的划分要细的多:
- VO(value object): 表现层对象
- DO(Data Object)/PO(Persistant Object): 持久层/数据层对象
- DTO(Data Transfer Object): 数据传输对象
- BO(Business Object): 业务对象
细分实体类, 可以实现业务代码的解耦.
本次项目中, 我们创建 pojo 包来包含全部实体类.
在 pojo 下, 将实体类划分为 dataobject(DO), request 和 response 三大类.
2.1 dataobject 包
dataobject 下有 UserInfo, BlogInfo 两个 java 类, 表示直接和数据层交互拿到的对象(和数据库表中的属性高度统一).
@Data
public class BlogInfo {
@TableId(type = IdType.AUTO)
private Integer id;
private String title;
private String content;
private Integer userId;
// 0 -> 正常, 1 -> 删除
private Integer deleteFlag;
private Date createTime;
private Date updateTime;
}
@Data
public class UserInfo {
// 设置主键自增
@TableId(type = IdType.AUTO)
private Integer id;
private String userName;
private String password;
private String githubUrl;
private Integer deleteFlag;
private LocalDate createTime;
private LocalDate updateTime;
}
2.2 request 包
request 包下保存和用户请求相关的实体类.
比如用户登录时, 只会传输 userName 和 password 两个数据, UserInfo 虽然涵有这两个属性, 但是还有其他登录时未使用的属性, 为了解耦, 我们可以给用户登录用到的 userName 和 password 单独创建一个实体类:
@Data
public class UserLoginRequest {
@NotNull
private String userName;
@NotNull
private String password;
}
2.3 response 包
2.3.1 统一响应结果类 - Result
首先, 为了方便后续的统一功能处理, 我们定义一个通用的响应对象 Result, 在后续进行统一结果返回以及统一异常处理时, 使用该类型封装数据进行统一返回.
Result 中包含三个属性:
- code: 业务状态码
- errMsg: 错误信息描述
- data: 业务数据
此外, 在 Result 类中封装不同响应结果的方法:
@Data
public class Result {
// 业务状态码
private ResultCodeEnum code;
// 错误信息描述
private String errMsg;
// 真实的业务数据
private Object data;
public static Result success(Object data) {
Result result = new Result();
result.setCode(ResultCodeEnum.SUCCESS);
result.setData(data);
return result;
}
public static Result fail(String errMsg) {
Result result = new Result();
result.setCode(ResultCodeEnum.FAIL);
result.setErrMsg(errMsg);
return result;
}
public static Result fail(String errMsg, Object data) {
Result result = new Result();
result.setCode(ResultCodeEnum.FAIL);
result.setErrMsg(errMsg);
result.setData(data);
return result;
}
}
其中, 业务状态码 code, 使用枚举类枚举不同的业务状态:
(这里就是定义了 SUCCESS 和 Fail 两个类型, 大家可以根据需要细分更多)
@AllArgsConstructor
public enum ResultCodeEnum {
SUCCESS(200),
FAIL(-1);
private Integer data;
}
2.3.2 用户登录响应类
用户登录时, 我们需要返回用户登录的响应结果.
为了实现身份验证和授权, 让客户端在后续请求中证明自己的身份, 而无需每次都重新登录. 我们需要给用户返回 token 信息(token 下文细讲), 为此创建响应用户登录的实体类:
@Data
@AllArgsConstructor
public class UserLoginResponse {
private Integer id;
private String token;
}
2.3.3 博客信息响应类
当进入博客列表, 或者查询博客详细信息时, 我们需要从数据库中查询博客信息, 再将博客信息进行返回.
但是, 为了提升安全性并减少数据暴露, 同时提升代码简洁性, 并实现解耦, 我们不对外公开博客实体类的所有字段. 例如 deleteFlag 和 updateTime 等敏感字段不应返回给前端.
因此,可以创建一个专门的 DTO 或 VO(这里使用的是 BlogInfoResponse), 仅包含前端所需的必要字段, 并使用这个实体类将博客信息返回给前端:
@Data
public class BlogInfoResponse {
private Integer id;
private String title;
private String content;
private Integer userId;
// 文章创作时间
// @JsonFormat(pattern = "yyyy-MM-dd")
private Date createTime;
// 返回的对象时, Spring 是通过 get 方法将对象序列化为 JSON 字符串的.
// 序列化: 原本数据的格式 -> 数据传输格式
// 反序列化: 数据传输格式 -> 还原为原来的数据
public String getCreateTime() {
return DateUtil.dateFormat(this.createTime);
}
// 在博客列表页面上, 只展示部分内容(同时可以减少带宽消耗).
// 在博客详情页, 展示博客全部内容.
// public String getContent() {
// return this.content.substring(0, 50);
// }
}
2.3.3.1 设置日期格式 方法1 - @JsonFormat
返回的博客信息中, 包含了博客的创建时间, 我们可以使用 @JsonFormat 指定返回的日期格式:
@JsonFormat 注解只影响 JSON 序列化过程(也就是说, 只对前端返回数据时起作用), 不会影响 System.out.println() 或其他直接输出对象属性的行为.
2.3.3.1 设置日期格式 方法2 - SimpleDateFormat
除了使用 @JsonFormat 来设置日期格式, 我们还可以重写 get 方法, 使用 SimpleDateFormat 来设置日期格式.
为啥重写 get 方法呢, 就能改变前端接受到的数据呢??
- 当后端接口返回的是一个对象时,spring 会默认将这个对象序列化为 json 字符串,而 Spring 是通过反射机制,调用 get 方法获取属性值来生成 json 字符串的(因此,重写 get 方法可以让你在序列化时对属性值进行任意的自定义转换,包括日期/时间格式化)
@JsonFormat 和 SimpleDateFormat 设置日期的格式如下:
Letter Date or Time Component Presentation Examples G
Era designator Text AD
y
Year Year 1996
;96
Y
Week year Year 2009
;09
M
Month in year (context sensitive) Month July
;Jul
;07
L
Month in year (standalone form) Month July
;Jul
;07
w
Week in year Number 27
W
Week in month Number 2
D
Day in year Number 189
d
Day in month Number 10
F
Day of week in month Number 2
E
Day name in week Text Tuesday
;Tue
u
Day number of week (1 = Monday, ..., 7 = Sunday) Number 1
a
Am/pm marker Text PM
H
Hour in day (0-23) Number 0
k
Hour in day (1-24) Number 24
K
Hour in am/pm (0-11) Number 0
h
Hour in am/pm (1-12) Number 12
m
Minute in hour Number 30
s
Second in minute Number 55
S
Millisecond Number 978
z
Time zone General time zone Pacific Standard Time
;PST
;GMT-08:00
Z
Time zone RFC 822 time zone -0800
X
Time zone ISO 8601 time zone -08
;-0800
;-08:00
3. 通用工具 - common 包
在这个包下, 我们对统一功能(如拦截器, 统一结果返回, 统一异常返回等), 自定义异常, 一些工具类(如 实体类转换(BlogInfo 和 BlogInfoResponse), token 生成和校验, 设置日期格式等)进行开发.
3.1 自定义异常
@Data
public class BlogException extends RuntimeException{
private String errMsg;
private Integer code;
public BlogException(String errMsg, Integer code) {
this.errMsg = errMsg;
this.code = code;
}
public BlogException(String errMsg) {
this.errMsg = errMsg;
}
}
3.2 统一功能除处理
3.2.1 统一结果返回
上文中, 我们已经定义好了统一返回结果类 Result, 因此将结果统一封装为 Result 进行返回:
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
@Resource
ObjectMapper objectMapper;
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof String) {
// 对象转 JSON 字符串
return objectMapper.writeValueAsString(Result.success(body));
}
if (body instanceof Result) {
return body;
}
return Result.success(body);
}
}
需要注意, 当接口原本返回的是字符串时, 我们需要使用 ObjectMapper 将返回的 Result 对象转换为 JSON 字符串, 并且手动将这些接口的 content-type 设置为 applicatio/json.
3.2.2 统一异常处理
发生异常时, 对异常信息进行统一封装并进行返回:
为了保证 API 的一致性和可预测性, 统一异常返回和统一结果返回的返回类型必须保持一致, 这里使用的是上文定义的统一响应类 Result.
package com.study.springblogdemo.common.advice;
import com.study.springblogdemo.common.exception.BlogException;
import com.study.springblogdemo.pojo.response.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
@Slf4j
@ControllerAdvice
@ResponseBody
public class ExceptionAdvice {
@ExceptionHandler
public Result handle(Exception e) {
log.error("出现异常: ", e);
return Result.fail(e.getMessage());
}
@ExceptionHandler
public Result handle(BlogException e) {
log.error("出现异常: ", e);
return Result.fail(e.getErrMsg());
}
/**
* 处理 @NotNull 抛出的异常
* @param e
* @return
*/
@ExceptionHandler
public Result handle(HandlerMethodValidationException e) {
log.error("出现异常: ", e);
// 获取 HandlerMethodValidationException 中的异常信息
// 1. 泡泡姐讲的.
// String msg = e.getAllErrors().stream().findFirst().get().getDefaultMessage();
// 2. 我根据 debug 找路径拿的
String msg = e
.getParameterValidationResults()
.get(0)
.getResolvableErrors()
.get(0)
.getDefaultMessage();
return Result.fail(msg);
}
/**
* 处理 @Length 抛出的异常
* @param e
* @return
*/
@ExceptionHandler
public Result handle(MethodArgumentNotValidException e) {
log.error("出现异常: ", e);
// 获取 MethodArgumentNotValidException 中的异常信息
// 1. 泡泡姐讲的
// String msg = e.getAllErrors().stream().findFirst().get().getDefaultMessage();
// 2. 我的根据 debug 路径拿的
String msg = e
.getBindingResult()
.getAllErrors()
.get(0)
.getDefaultMessage();
return Result.fail(msg);
}
}
4. 获取博客列表
4.1 约定前后端交互接口
4.2 后端接口
后端 controller 接口收到前端请求, 调用 service 接口, service 调用 mapper, mapper 从数据库查询数据返回.
与图书系统不同的是, 博客系统使用的是 MyBatis-plus 从数据库查询数据, 因此需要在 service 层构造条件构造器, 将构造其传给 mapper, mapper 再调用 BaseMapper 中的方法从数据库查询数据.
此外, 为了更好地遵循接口隔离原则和实现代码的解耦, 本项目对 Service 层进行了细化. 具体来说, 我们为每个 Service 都定义了相应的 Interface 接口, 并创建了实现了该接口的具体实现类:
4.2.1 实体类转换
我们通过 Mapper 从数据库查询得到 BlogInfo 对象.
此时, BlogInfo 代表的是数据对象 (DO, Data Object), 它直接映射数据库表结构. 然而, 我们向前端 API 返回的是 BlogInfoResponse 对象, 它更关注前端展示的需求.
因此, 我们需要将 BlogInfo 对象转换为 BlogInfoResponse 对象, 以便适配前端的数据格式:
4.2.1.1 List.stream.map
List.stream.map, 是上文进行 Bean 转换时使用到的一个方法: 用于将一个 List
中的每个元素转换(映射)为另一种类型的元素,并生成一个新的 Stream
.
举个简单的例子:
public class Main { public static void main(String[] args) { List<Person> people = Arrays.asList( new Person("Alice", 25), new Person("Bob", 30), new Person("Charlie", 28) ); // 使用 lambda 表达式将 Person 对象映射为姓名 // 将 List<Person> 转换为 List<String> List<String> names = people.stream() .map(person -> person.getName()) // 使用 lambda 表达式作为映射函数 .collect(Collectors.toList()); System.out.println(names); // 输出: [Alice, Bob, Charlie] } }
代码中的链式调用解释如下:
blogInfos.stream(): 将 List 转换为一个 stream
blogInfos.stream().map(x -> {转换规则, return y}): 对 List 中的每一个元素根据指定规则进行转换(x: 原来的元素; y: 转换后的元素)
.collect(Collectors.toList()): 将转换后的
Stream
转换为一个新的List
4.2.1.2 BeanUtils.copyProperties
BeanUtils.copyProperties(源对象, 目标对象) 这也是上文进行 Bean 转换时使用到的一个方法, 可以实现两个 Bean 之间的拷贝.
它底层是使用源对象的 get 方法和目标对象的 set 方法进行拷贝的.
注: 两个 Bean 中的属性名以及属性类型要一致, 否则无法拷贝.
4.2.2 单元测试
4.2 前端代码
前端这里着重完成 JavaScript 代码的编写, 还是使用 Ajax 搭载 HTTP 请求的方式访问后端接口, 获取响应:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>博客列表页</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/list.css">
</head>
<body>
<div class="nav">
<img src="pic/logo2.jpg" alt="">
<span class="blog-title">我的博客系统</span>
<div class="space"></div>
<a class="nav-span" href="blog_list.html">主页</a>
<a class="nav-span" href="blog_edit.html">写博客</a>
<a class="nav-span" href="#" onclick="logout()">注销</a>
</div>
<div class="container">
<div class="left">
<div class="card">
<img src="pic/doge.jpg" alt="">
<h3>比特汤老师</h3>
<a href="#">GitHub 地址</a>
<div class="row">
<span>文章</span>
<span>分类</span>
</div>
<div class="row">
<span>2</span>
<span>1</span>
</div>
</div>
</div>
<div class="right">
<!-- <div class="blog">
<div class="title">我的第一篇博客</div>
<div class="date">2021-06-02</div>
<div class="desc">今天开始, 好好学习Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quas nesciunt, hic voluptatum, dolorem quisquam modi accusantium, commodi dolores architecto ratione vel exercitationem optio. Facere repellendus autem, obcaecati dolore sequi incidunt?</div>
<a class="detail" href="blog_detail.html">查看全文>></a>
</div>
<div class="blog">
<div class="title">我的第一篇博客</div>
<div class="date">2021-06-02</div>
<div class="desc">今天开始, 好好学习Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quas nesciunt, hic voluptatum, dolorem quisquam modi accusantium, commodi dolores architecto ratione vel exercitationem optio. Facere repellendus autem, obcaecati dolore sequi incidunt?</div>
<a class="detail" href="blog_detail.html">查看全文>></a>
</div> -->
</div>
</div>
<script src="js/jquery.min.js"></script>
<script src="js/common.js"></script>
<script>
$.ajax({
url: "/blog/getList",
type: "get",
success: function(result) {
if(result.code != "SUCCESS") {
alert(result.errMsg);
return;
}
if(result.code == "SUCCESS" && result.data.length > 0) {
var blogHtml = "";
for(var blogInfo of result.data) {
blogHtml += '<div class="blog">';
blogHtml += '<div class="title">' + blogInfo.title + '</div>';
blogHtml += '<div class="date">' + blogInfo.createTime + '</div>';
blogHtml += '<div class="desc">' + blogInfo.content + '</div>';
blogHtml += '<a class="detail" href="blog_detail.html?id=' + blogInfo.id + '">查看全文>></a>';
blogHtml += '</div>';
}
$(".container .right").html(blogHtml);
}
}
});
</script>
</body>
</html>
4.2.1 客户端界面测试
5. 获取博客详情
5.1 约定前后端交互接口
5.2 后端接口
后端接口接收博客 id, 根据 id 从数据库查询博客详情, 将博客详情返回给前端.
此外, 我们依然需要将从数据库查询到的数据(BlogInfo) 转换为 BlogInfoResponse 作为响应结果进行返回:
5.2.1 参数非空校验 - @NotNull
我们接收到的参数可能是 null, 这个参数是非法的.
我们可以借助 jakarta.validation 的 @NotNull 对参数进行非空的校验.
使用 validation, 需要引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
如果传递的 id 为 null, 则会抛出 HandlerMethodValidationException 异常, 因此我们可在统一异常返回时处理该异常:
若上图所示, 要想拿到异常中具体的 message 信息, 必须通过链式调用来获取, 如果直接 getMessage 来获取, 那获取到的只是笼统的异常信息:
常见的 validation 注解如下:
注解 | 适用对象 | 校验规则 |
@NotNull | 任何类型 | 不能为 null |
@NotEmpty | 集合/数组/字符串 | 不能为 null,且必须包含元素/字符 |
@NotBlank | 字符串 | 不能为 null,且去除首尾空格后长度必须大于 0 |
5.2.2 单元测试
5.3 前端代码
着重编写 JavaScript 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>博客列表页</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/list.css">
</head>
<body>
<div class="nav">
<img src="pic/huahua.jpg" alt="">
<span class="blog-title">我的博客系统</span>
<div class="space"></div>
<a class="nav-span" href="blog_list.html">主页</a>
<a class="nav-span" href="blog_edit.html">写博客</a>
<a class="nav-span" href="#" onclick="logout()">注销</a>
</div>
<div class="container">
<div class="left">
<div class="card">
<img src="pic/weixincat.jpg" alt="">
<h3>丁帅彪</h3>
<a href="#">GitHub 地址</a>
<div class="row">
<span>文章</span>
<span>分类</span>
</div>
<div class="row">
<span>2</span>
<span>1</span>
</div>
</div>
</div>
<div class="right">
<!-- <div class="blog">
<div class="title">我的第一篇博客</div>
<div class="date">2021-06-02</div>
<div class="desc">今天开始, 好好学习Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quas nesciunt, hic voluptatum, dolorem quisquam modi accusantium, commodi dolores architecto ratione vel exercitationem optio. Facere repellendus autem, obcaecati dolore sequi incidunt?</div>
<a class="detail" href="blog_detail.html">查看全文>></a>
</div>
<div class="blog">
<div class="title">我的第一篇博客</div>
<div class="date">2021-06-02</div>
<div class="desc">今天开始, 好好学习Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quas nesciunt, hic voluptatum, dolorem quisquam modi accusantium, commodi dolores architecto ratione vel exercitationem optio. Facere repellendus autem, obcaecati dolore sequi incidunt?</div>
<a class="detail" href="blog_detail.html">查看全文>></a>
</div> -->
</div>
</div>
<script src="js/jquery.min.js"></script>
<script src="js/common.js"></script>
<script>
$.ajax({
url: "/blog/getList",
type: "get",
success: function(result) {
if(result.code != "SUCCESS") {
alert(result.errMsg);
return;
}
if(result.code == "SUCCESS" && result.data.length > 0) {
var blogHtml = "";
for(var blogInfo of result.data) {
blogHtml += '<div class="blog">';
blogHtml += '<div class="title">' + blogInfo.title + '</div>';
blogHtml += '<div class="date">' + blogInfo.createTime + '</div>';
blogHtml += '<div class="desc">' + blogInfo.content + '</div>';
blogHtml += '<a class="detail" href="blog_detail.html?id=' + blogInfo.id + '">查看全文>></a>';
blogHtml += '</div>';
}
$(".container .right").html(blogHtml);
}
}
});
</script>
</body>
</html>
5.3.1 客户端界面测试
6. 用户登录
6.1 Session-Cookie 缺陷
在以往的练习中(包括图书系统), 当用户登录成功后, 我们都是通过 Cookie-Session 机制来存储用户信息并完成身份验证和授权的, 从而实现无需在每次请求时都重新进行身份验证.
但是 Cookie-Session 也存在明显缺陷:
- Session 存储于内存中, 服务器重启 Session 丢失
- Session 存储于内存中, 大量 Session 耗费服务器资源
- Session-Cookie 机制不可用于集群环境中
为什么 Session-Cookie 不可用于集群环境呢??
我们开发的项目, 在企业中很少会部署在一台机器上, 容易发生单点故障.(单点故障:一旦这台服务器挂了, 整个应用都没法访问了). 所以通常情况下, 一个Web应用会部署在多个服务器上, 通过Nginx等进行负载均衡. 此时, 一个用户的请求就根据服务器状态, 分发到空闲服务器上.
而 Cookie-Session 无法在集群环境中使用, 这是因为默认情况下, 每个服务器独立存储自己的 session, 服务器之间无法共享 session 数据. 如下图所示:
解决方式1, 我们可以再额外部署另一台服务器, 通过 Redis 来统一存储用户的 Session 信息.
用户首次请求被 Nginx 路由到服务器后, 服务器创建 Session, 并将 Session 存储到 Redis 中. 用户下次请求时, 不管被路由到哪台服务器, 服务器都会从 Redis 中查找 Sessionid 对应的 Session:
以上是一种解决方案. 但不是最优的.
接下来介绍第二种方案, 令牌技术.
6.2 令牌
令牌(token) 的主要目的是实现身份验证和授权. 它允许服务器验证用户的身份, 并授予用户访问特定资源的权限, 而无需在每次请求时都重新进行身份验证.
token 本质上就是一个字符串.
token 的使用流程如下:
用户登录: 用户向服务器提交用户名和密码.
服务器验证: 服务器验证用户的身份信息.
生成 token: 如果验证成功, 服务器根据密钥(key)生成一个 token. token 中包含了用户信息.
返回 token: 服务器将 token 返回给客户端.
客户端存储 token: 客户端将 token 存储在本地.
后续请求: 客户端在后续请求中会携带将 token.
服务器验证 token: 服务器接收到请求后, 通过密钥(key)验证 token 的签名和有效性.
授权访问: 如果 token 验证通过, 用户则无需重复登录.
和 Session 不同的是, token 存储在客户端, 而不是服务端. 服务器只需生成 token 和验证 token 是否有效即可, 并且只要集群中的所有服务器都配置了相同的密钥, 任何服务器都可以独立地验证由其他服务器签发的 token.
举个例子, 我们办理身份证时通常都是在居住地附近办理的, 但是当我们去外地时, 外地的公安局也可以通过身份证确认我们的身份.
因此, 令牌 token 解决了 Session-Cookie 的三个缺陷:
- token 存储在客户端, 服务器重启不会丢失.
- token 存储在客户端, 不会占用服务器资源.
- token 可以用于集群环境.
6.2.1 Jwt 令牌
Jwt(JSON Web Token) 是 token 的一种实现方式.
Jwt 由三部分构成:
- 头部: 包含令牌的类型(Jwt)以及使用的哈希算法
- 载荷(Payload): 包含实际要传输的业务数据(JSON 对象, 键值对结构)
- 签名(Signature): 由 key(密钥) 生成, 是验证 token 是否被篡改的关键要素.(相当于 "防伪码")
三部分之间使用 点(.) 进行分割:
6.2.1.1 Jwt 简单使用
我们可以先来看下 Jwt 的简单使用(下文会讲解其中的方法):
在上述代码中, 我们使用的是 Keys.secretKeyFor(SignatureAlgorithm.HS256) 来随机生成 key 的, 每次调用方法时, 生成的 key 都不一样.
因此, 每次调用时, 即使头部, 载荷都保持不变, 但是由于生成的 key 不同, 从而导致生成的签名也是不同的:
(这里只需知道结论即可: key 不同, 生成的签名不同, 当然 token 也不同)
6.2.2 密钥 key
在令牌 token 机制中, 密钥 key 起到了关键作用. key 用于生成签名和验证签名(通过验证签名来间接验证 Token).
key 如何生成签名??
将头部, 载荷, key 组合到一起, 通过复杂算法生成签名.
key 如何验证签名??
服务收到 token 后, 提取出 头部, 载荷, 再次将他们和相同的 key 组合, 再次通过相同算法生成签名, 将 token 中的签名和新生成的签名比较, 若一致, 则说明数据没有被篡改, 若不一致, 则说明信息被篡改.
6.3 约定前后端交互接口
6.4 后端接口
6.4.1 引入 Jwt 依赖
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
6.4.2 校验账号密码
后端收到用户登录请求后, 从数据库查询用户信息, 对账号密码进行校验.
若校验成功, 则创建 token 并将用户信息存储到 token 中, 最后返回 token(将 token 封装到 UserLoginResponse 中进行返回).
若校验失败, 则返回异常信息.
此外, 对于前端传来的参数(账号, 密码)我们也可以使用 jakarta.validation 提供的注解进行校验, 规定传递的参数不能为 null, 并对参数长度进行限制:
当传入的用户名和密码 null 时, 会被统一异常功能捕获到(上文中已经对 @NotNull 抛出的异常进行了统一处理):
当传入的用户名和密码长度不满足 1-20 位时, @Length 也会抛出异常(MethodArgumentNotValidException), 我们也可以对该异常进行统一处理, 并获取其中的异常信息, 展示给前端:
6.4.2.1 生成 key
我们是使用 HMAC 算法来生成 Jwt 令牌的(严谨来说, 是使用该算法生成和验证签名的), 而 HMAC 是一种 对称加密 算法.
JWT 通常使用非对称密钥 (例如: RSA、ECDSA) 进行签名和验证, 以提高安全性. 但是在某些简单场景下, 也可以使用对称密钥(例如, HMAC), 但这不如非对称密钥安全.
由于我们这里这是项目练习, 并且只是专攻服务器开发, 因此这里就使用简单的 HMAC 对称加密来完成.
因此, 我们需要保证, 生成 token 以及验证 token 时, 使用的是同一个密钥 key.
token 由 头部, 载荷, 签名构成. key 不同导致生成的签名不同. 从而验证 token 时生成的签名, 和原本 token 中的签名不一致, 从而导致 token 验证失败.
我们不能再像上文中, 随机生成 key. 而是需要将 key 值固定下来. 生成 token 时, 使用这个 key, 验证 token 时, 也使用这个 key.
获取对称密钥 key 的步骤如下:
- 先使用 Keys.secretKeyFor(SignatureAlgorithm.HS256) 随机生成一个 key
- 提取出 key 的原始字节数组
- 对字节数组进行 base64 编码, 获取 base64 字符串
@Test
void geneKey() {
// 1. 随机生成 key
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// 2. 提取出 key 的原始字节数组
byte[] encoded = key.getEncoded();
// 3. 对 key 的字节数组使用 base64 进行编码, 得到编码后的 base64 字符串.
String encode = Encoders.BASE64.encode(encoded);
System.out.println(encode);
}
获取到 base64 字符串后, 我们就可以根据这个字符串, 生成固定的 key 了:
6.4.2.2 生成 token
生成 Jwt token 有以下几个关键步骤:
- 构建头部, JWT 库会提供默认的头部设置, 但我们也可以而且应该根据需求配置头部.(这里就默认生成了)
- 构建载荷, 载荷中保存的是我们要传输的真实业务数据, 我们需要手动传入. 注意: 载荷需要使用 Map 这样的键值对结构来进行构建.
- 生成签名, 签名是使用密钥 key 根据头部、载荷组合到一起, 通过 header 中指定的算法计算出来的.
生成 token 时的链式调用解析如下:
- Jwts.builder() // 生成 JwtBuilder 对象(JWT 构造器)
- .setClaims(map) // 填充载荷信息(业务数据)
- .signWith(key) // 使用 key, 生成签名
- .compact(); // 将 header, 载荷, 签名 组合在一起, 生成 token
/**
* 生成 token
* @param map: 载荷(业务数据)
* @return
*/
public static String geneToken(Map<String, Object> map) {
// compact, 可以理解为就是 token 字符串
String compact = Jwts
.builder() // 生成 JwtBuilder 对象(JWT 构造器)
.setClaims(map) // 填充载荷(填写业务数据)
.signWith(key) // 使用 key, 生成签名
.compact(); // 将 header, 载荷, 签名 组合在一起, 生成 token
return compact;
}
6.4.2.3 验证 token
用户再次请求时, 会携带 token, 后端收到用户携带的 token 后, 从中提取出 头部, 载荷, 再次将他们和相同的 key 组合, 再次通过相同算法生成签名, 将 token 中的签名和新生成的签名比较, 若一致, 则说明数据没有被篡改, 若不一致, 则说明信息被篡改.
也就是说, 是通过验证签名, 间接验证 token 的.
若验证通过, 从 token 中获取载荷信息并返回.
/**
* 根据 key 验证 token, 返回 token 中的载荷数据(业务数据)
* @param token
* @return 验证成功, 返回真实业务数据; 验证失败, 返回 null.
*/
public static Claims parseToken(String token) {
if (!StringUtils.hasLength(token)) {
// token 为 null 或者 token 是空串
return null;
}
JwtParser build = Jwts
.parserBuilder() // 配置 Jwt token 解析器
.setSigningKey(key) // 将 key 设置到解析器中
.build(); // 生成解析器
Claims claims = null;
try {
// Claims, 本质就是一个 Map, 因此就是载荷中的数据(业务数据)
claims = build
.parseClaimsJws(token) // 验证签名, 验证成功返回完整的 JWT 对象(包含头部, 载荷, 签名)
.getBody(); // 从 JWT 中提取载荷
}catch (Exception exception) {
// 若数据被篡改, 或者 token 错误,
// 则 token 验证失败, 此时会抛出异常, 这里进行捕获.
log.error("token 验证失败, token: {}", token);
}
return claims;
}
(从 token 中取载荷(claims) 时, 如果 token 验证失败, 会抛出异常, 这里使用 try-catch 进行了捕获)
代码中, 我们使用的是对称加密, 因此生成签名和验证签名时, 使用的都是同一个 key.
6.4.3 单元测试
6.5 前端代码
6.5.1 LocalStorage 存储 token
用户登录时, 若账号密码正确, 后端会将 userid 和 token 作为响应结果返回给前端, 此时前端需要将 token 信息放置到客户端中(交给用户), 以便用户后续请求可以携带 token 进行身份验证.
由于我们使用的是 token 机制实现用户登录的验证, 而 token 机制和我们之前使用的 Session 机制是不同的:
- 在使用 Session 认证时, 服务端生成 Session ID 并通过 Set-Cookie 将 Sessionid 发送给浏览器(客户端), 浏览器会自动将其保存在 Cookie 中, 并在后续请求中自动携带 Sessionid, 前端基本无需额外处理.
- 而使用 Token 认证(如 JWT)时, 服务端生成 Token 并返回给前端后, 前端必须主动将其存储在客户端(如 LocalStorage 或者 Cookie)中, 并且在发起请求时, 需要手动从存储位置取出 Token, 并添加到请求头 header 中(或 QueryString, body , ...), 后端收到请求后, 再从 header 中取出 token, 再对 Token 进行验证, 校验用户是否登录.
因此, 前端需要手动将后端返回的 token 信息存储到客户端浏览器中.
前端实现 Jwt token 存储在客户端的方式有多种, 如:
- Cookie: 将 token 存储在 Cookie 中,浏览器会自动在后续请求的 HTTP Header 中携带 Cookie
- LocalStorage: 使用 JavaScript 的 localStorage API 将 token 存储在浏览器的本地存储中
前端可以将数据存储到客户端, 但是需要经过用户的授权.
用户是可以通过浏览器设置、隐私保护插件等方式来对前端存储的数据(如 Cookie, localStorage 等)进行控制的.
这里我们采取第二种, 将 token 存储到 LocalStorage 中:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>博客登陆页</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/login.css">
</head>
<body>
<div class="nav">
<img src="pic/logo2.jpg" alt="">
<span class="blog-title">我的博客系统</span>
<div class="space"></div>
<a class="nav-span" href="blog_list.html">主页</a>
<a class="nav-span" href="blog_edit.html">写博客</a>
</div>
<div class="container-login">
<div class="login-dialog">
<h3>登陆</h3>
<div class="row">
<span>用户名</span>
<input type="text" name="username" id="username">
</div>
<div class="row">
<span>密码</span>
<input type="password" name="password" id="password">
</div>
<div class="row">
<button id="submit" onclick="login()">提交</button>
</div>
</div>
</div>
<script src="js/jquery.min.js"></script>
<script>
function login() {
$.ajax({
url: "/user/login",
type: "post",
contentType: "application/json",
// 对象转 JSON
data: JSON.stringify({
userName: $("#username").val(),
password: $("#password").val()
}),
success: function(result) {
if(result == null) {
return;
}
// 密码正确
if(result.code == "SUCCESS" && result.data != null) {
// 存储用户 id 和 token
// 存储到 local storage 中
localStorage.setItem("useLoginId", result.data.id);
localStorage.setItem("userLoginToken", result.data.token);
location.assign("blog_list.html");
}else {
// 密码错误
alert(result.errMsg);
return;
}
}
});
}
</script>
</body>
</html>
6.5.2 客户端界面测试
7. 实现强制用户登录 - 配置拦截器
7.1 后端代码
在之前的图书管理系统中, 我们是根据 Cookie-Session 来配置拦截器实现用户强制登录的.
现在学习了 token 令牌机制后, 我们就根据 token 来配置拦截器, 实现用户的强制登录.
之前提到, 用户登录时, 前端会将用户信息以及 token 存放在 LocalStorage 中. 当用户后续发送请求时, 就会携带 token.
token 可以放到请求的 URL 中, 也可以放到请求 header 中, 也可以放到请求的 body 中. 这里我们约定把 token 放到请求的 header 中进行传输.
因此, 我们后端 拦截器 需要从请求的 header 中取出 token 进行校验:
- 若 token 验证成功, 则对请求放行
- 若 token 验证失败, 或请求根本没有携带 token, 说明用户未登录, 对请求进行拦截
7.1.1 定义拦截器
从 header 中取出 token 时, 存在两种情况:
- token 为 null, 则请求未携带 token, 说明用户未登录, 直接进行拦截
- token 不为 null, 则对 token 进行验证, 验证失败, 则说明 token 错误, 也进行拦截. 验证成功, 说明用户登录, 放行.
@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {
/**
* 定义拦截器
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从 header 中获取 token.
String userToken = request.getHeader(Constants.HEADER_TOKEN_KEY);
log.info("从请求 header 中获取 token, token: {}", userToken);
if (userToken == null) {
// 用户没有携带 token, 进行拦截
// 401 => 用户未登录; 403 => 用户没有权限
log.warn("未携带 token!! 用户未登录!!");
response.setStatus(401);
return false;
}
// 校验 token 是否合法.
Claims claims = JwtUtils.parseToken(userToken);
if (claims == null) {
// 用户传了 token, 但是 token 验证失败, 进行拦截
log.warn("携带无效 token!! 用户未登录!!");
response.setStatus(401);
return false;
}
return true;
}
}
7.1.2 注册拦截器
拦截除 "/user/login" 外的所有接口.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
LoginInterceptor loginInterceptor;
/**
* 注册拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 拦截除用户登录的所有接口
// 不能直接拦截 "**", 因为前端的页面在项目的 static 下, 这样会把 .html 页面也进行拦截
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/user/**", "/blog/**")
.excludePathPatterns("/user/login");
}
}
7.1.3 单元测试
7.2 前端代码
7.2.1 ajaxSend & ajaxError
往客户端 LocalStorage 存储 token 时, 我们就说到, 前端需要手动操作 token.
因此, 当用户发起请求时, 前端也需要手动将 LocalStorage 中的 token 放到请求的 header 中.
前端每发送一个 ajax 请求, 都需要进行将 token 放入 header 的操作.
因此, 我们将该操作进行封装, 将这个操作的代码放入一个 common.js 文件中, 在涉及 ajax 请求相关的 html 文件中引入该 js 文件(引入了该 .js 文件的 html 文件, 在发送 ajax 请求前, 都会执行该 js 文件中的代码), 实现对 token 放入 header 操作的统一处理:
此外, 当后端对 token 校验失败时(用户未登录), 返回的是错误码 401, 此时需要使用回调函数 error 处理响应结果.
同样, 涉及 ajax 请求相关的 html 文件都会对响应结果进行处理, 因此, 还是将处理错误响应的代码放到 common.js 文件中, 对错误状态码的响应结果进行统一处理:
在上述代码中, 使用了两个 JQuery 提供的函数:
- ajaxSend: 在每一个 ajax 请求发送前, 都会被执行.(可以在其中定义一些通用的操作)
- ajaxError: 在每一个 ajax 请求失败时(返回的是错误状态码), 都会被执行.
7.2.2 客户端界面测试
8. 实现显示用户信息
8.1 约定前后端交互接口
在博客列表页, 获取当前登陆的用户的用户信息:
在博客详情页,获取当前文章作者的用户信息:
8.2 后端接口
了解需求后, 我们后端需要实现两个接口:
- 根据用户 ID, 获取用户信息
- 根据博客 ID, 获取作者信息
第一个接口很好实现.
实现第二个接口, 需要我们多作一层处理:
- 先根据 blogId 获取 BlogInfo(包含作者 ID)
- 再根据作者 ID 获取作者信息
根据接口文档, 我们需要再创建一个用户信息的响应类:
在 controller 层, 对参数进行校验:
在 User 的 service 层中, 我们调用了 Blog 的 servic 层的接口来获取博客信息:
(service 调 service 是可行的, 但是 controller 不能调 controller)
/**
* 获取用户信息, 展示在博客列表页
* @param userId
* @return
*/
@Override
public UserInfoResponse getUserInfo(Integer userId) {
LambdaQueryWrapper<UserInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(UserInfo::getDeleteFlag, 0)
.eq(UserInfo::getId, userId);
UserInfo userInfo = userInfoMapper.selectOne(wrapper);
if(userInfo == null || userInfo.getId() <= 0) {
throw new BlogException("用户不存在!!");
}
// 将 UserInfo 转换为 UserInfoResponse
return BeanUtilsParse.trans(userInfo);
}
/**
* 获取作者信息, 展示在博客详情页
* @param blogId
* @return
*/
@Override
public UserInfoResponse getAuthorInfo(Integer blogId) {
// 1. 根据 blogId 获取 BlogInfo(包含了作者 ID).
BlogInfo blogInfo = blogService.getBlogInfo(blogId);
if(blogInfo == null || blogInfo.getUserId() <= 0) {
throw new BlogException("博客不存在!!");
}
// 2. 根据作者 ID 获取 UserInfo.
return getUserInfo(blogInfo.getUserId());
}
8.2.1 单元测试
8.3 前端代码
在博客列表和博客详情这两个页面上, 我们需要展示用户信息.
博客列表页面, 展示的是当前登录的用户的信息; 博客详情页面, 展示的作者的信息.
展示用户信息, 代码都是相同的, 只是访问后端的接口路径以及参数不同. 因此, 我们可以将这部分代码提取出来, 封装为一个函数, 将 URL 作为函数的参数来接收:
在博客列表页, 展示登录用户信息时, 我们可以从 LocalStorage 中获取用户 id, 将用户 id 传递给后端, 后端根据 userId 查找用户信息:
在博客详情页, 展示作者信息时, blogId 直接就在 URL 上展示了, 因此我们从 URL 中获取 blogId, 将 blogId 传递给后端, 后端根据 blogId 查找用户信息:
8.3.1 客户端界面测试
9. 实现用户退出
实现用户退出的操作很简单, 在用户点击 "退出登录" 按钮后, 只需要前端完成两件事即可:
- 删除 LocalStorage 中的 userId 和 token 信息
- 删除完毕后, 跳转到用户登录页面
继续在 common.js 中完善 logout 方法.
// 实现用户退出
function logout() {
// 1. 清除 LocalStorage 中的用户信息(userId 和 token)
localStorage.removeItem("loginUserId");
localStorage.removeItem("loginUserToken");
// 2. 跳转页面到用户登录页面
location.assign("blog_login.html");
}
9.1 客户端界面测试
10. 实现发布博客
10.1 约定前后端交互接口
10.2 后端代码
根据接口文档, 请求中的博客信息包含三个属性: userId, title, content.
为了遵循实体类架构分类, 并实现请求层对象 VO 与数据层对象 DO 的解耦, 需要为请求中的博客信息创建实体类 AddBlogRequest, 其包含 userId, title, content 三个属性:
接下来, 编写 controller, servic 的代码.
需要注意的是, 前端传来的是 JSON 对象, controller 需要使用 @RequestBody 注解进行接收.
此外, service 层需要将 AddBlogRequest(VO) 转化为 BlogInfo(DO), 以便 mapper 层往数据库插入数据:
10.2.1 单元测试
10.3 前端代码
10.3.1 Editor.md
Editor.md 是一个开源 Markdown 在线编辑器组件, 我们使用它来创建博客编辑页面中的 markdown 工具.
因为我们主攻后端, 这里就简单介绍一下使用 Editor.md 的关键步骤, 具体如何使用可以去官网查看:
引入了 Editor.md 后, 就可以使用 markdown 编辑器了:
我们开始编写前端代码.
我们需要获取以下信息:
- 用户 id(userId) => 从 LocalStorage 中获取
- 博客标题(title) => 从输入框中获取
- 博客正文(content) => 从 markdown 编辑器中获取
进行测试:
我们发现, 虽然博客发布成功, 但是在博客详情页, 展示的博客内容格式不是 markdown 格式的, 而是将 markdown 语法中的 "#" 当做字符串直接展示了出来.
我们需要将 content 中的 Markdown 语法转换为 HTML, 以便将 markdown 语法正确展示在页面上.
因此, 我们需要调整博客详情页的前端代码, 以正确展示转换后的 HTML 内容:
10.3.2 单元测试
11. 实现编辑/删除博客
打开博客详情页时, 如果当前登录的用户是该博客的作者时, 那么登录用户可以对该博客进行编辑和删除操作.
11.1 约定前后端交互接口
编辑博客:
删除博客:
11.2 编辑博客
11.2.1 后端代码
编辑博客, 就是根据用户的输入, 对数据库中的博客信息进行更新:
11.2.1.2 单元测试
11.2.2 前端代码
11.2.2.1 删除/编辑按钮的选择性显示
在博客列表页, 点击 "查看全文" 时, 会进入博客详情页, 在博客详情页中, 前端需要完成, 只有当博客作者是登录用户时, 才会显示 "编辑" 和 "删除" 按钮.
- 登录用户的 userId, 从 LocalStorage 中取出.
- 从博客信息中, 取出作者 id: 博客详情页, 本身就会获取博客信息, 博客信息中包含了作者 id(result.data.userId).
11.2.2.1.1 界面测试
11.2.2.2 编辑页先展示原来的博客信息
用户点击 "编辑" 按钮, 来到博客编辑页.
在编辑页中, 需要先展示博客原来的信息(标题, 正文), 用户修改信息后, 将修改后的信息发送给后端.
因此, 我们需要先调用后端接口, 将当前博客的 blogId 传给后端, 获取当前博客的博客信息, 将这些信息赋值到编辑页对应的标签上, 展示到页面上:
11.2.2.2.2 界面测试
11.2.2.3 实现信息修改
获取用户修改后的数据, 将这些数据传递给后端接口:
11.2.2.2.3 界面测试
11.3 删除博客
11.3.1 后端代码
删除博客, 采取逻辑删除的形式, 不是真正的将博客信息 delete 掉, 而是将博客的 delete_flag 设置为 1 即可. 因此, 删除博客底层代码的实现, 其实就是 update 操作.
11.3.2 前端代码
用户点击删除按钮时, 需要将当前博客的 blogId 传给后端, 获取 blogId 的方式有两种:
- 博客详情页, 本身就会获取博客信息, 从博客信息中获取 blogId
- 博客详情页, 由博客列表页跳转而来, URL 中就包含了 blogId
将 blogId 传给后端, 后端删除成功后, 前端将页面跳转到博客列表页.
12. 加密/加盐
12.1 加密
在之前的练习中, 我们是将密码 手机号等敏感信息直接存储到数据库中, 如果黑客成功入侵数据库, 那就可以轻松获取到用户的敏感信息. 因此, 这样做极不安全.
在实际开发中, 我们会对密码等敏感信息进行 加密/加盐 处理, 数据库中存储的都是经过加密后的数据, 这样就可以对用户信息进行保护.
12.1.1 加密算法分类
加密算法, 分为以下三类:
- 对称加密算法: AES, DES, 3DES, RC4, RC5, RC6
- 非对称加密算法: RSA, DSA, ECDSA, ECC
- 摘要算法: MD5, CRC
其中, 对称加密和非对称加密属于可逆加密. 可逆加密: 既可以将明文加密为密文, 也可以将密文解密为明文.
而 摘要算法属于不可逆加密, 即: 只能加密, 不能解密.
在本项目中, 使用摘要算法的 MD5 对用户密码进行加密操作.
虽然, MD5 是不可逆的, 但是当明文过于简单时, 即使通过 md5 加密, 黑客也是可以通过暴力枚举破解出来明文的:
因此, 为了全力保障用户数据的安全性, 我们再引入 "盐值".
12.2 加盐
当用户密码设置的过于简单时, 即使使用 MD5 加密, 也是可以破解出明文的.
因此, 我们对明文进行 "加盐" 处理: 生成一个复杂的随机盐值(盐值就是一个字符串), 将盐值和用户密码组合起来, 再对这个组合后的数据进行加密处理.
举个例子:
这样, 盐值 + 明文(密码) 就是一个复杂的数据, 加密后的密文也会非常复杂, 这样黑客就无法破解了.
为啥是生成一个随机盐值, 而不是所有用户使用一个固定的盐值呢?
--- 增加安全性. 黑客可以自己注册一个账号, 那么黑客入侵数据库后, 黑客就知道了他注册的这个账号的密文, 而黑客本身就知道他这个号的明文, 那么黑客就很可能根据密文+明文推断出盐值. 如果盐值是固定的, 那么黑客就知道了所有用户的盐值, 这样就危险了.
注意: 密码(明文)搭配的盐值必须保存到数据库中, 因为后续对用户输入的密码进行验证时, 也需要使用相同的盐值.
此外, 盐值不能直接存储在数据库中, 因为一旦数据库被黑客拖库, 盐值就会直接暴漏. 因此, 我们这里采取 密文和盐值 组合的方式来共同存储密文和盐值(根据一定规则进行组合, 后续验证用户密码时, 按照相同规则从组合中取出盐值进行验证)
12.3 加密整体思路
12.3.1 加密
- 用户注册, 输入账号密码.
- 生成一个随机盐值, 将盐值和密码按照一定规则进行组合.
- 对盐值和密码的组合进行 MD5 加密, 生成密文
- 再将盐值和密文按照一定规则进行组合, 将这个组合存储到数据库中.
简单使用:
如上图所示, 我们使用 UUID 来生成随机盐值.
什么是 UUID 呢??
UUID (Universally Unique Identifier), 中文是通用唯一识别码. UUID 是一个 128 位的数字, 理论上重复率极低, 几乎可以忽略不计, 因此我们用它当做盐值使用, 确保生成的盐值都不会重复.
12.3.2 解密
- 用户登录, 输入账号密码.
- 根据用户输入的用户名, 找到数据库中存储的盐值和密文的组合.
- 按照盐值和密文组合时的规则, 将盐值和密文提取出来.
- 将盐值和用户输入的密码使用相同的规则(加密步骤2中的规则)进行组合.
- 将这个组合使用 MD5 进行加密, 生成一个新的密文.
- 将新密文和从数据库中提取出来的密文进行比较, 若相同, 则说明密码相同, 登录成功. 反之, 密码输入错误.
简单使用:
- 若用户是输入的密码正确时, 只要保证使用的盐值和之前生成密文时用到的盐值是相同的, 那么新生成的密文和数据库中的密文一定相同.
- 若新生成密文时, 使用的盐值和之前生成密文时的盐值是相同的, 那么只要用户输入的密码是正确的, 那么生成的密文和数据库中的密文也一定是相同的.
12.4 代码编写
编写一个工具类, 实现对用户密码的加密, 以及用户登录时, 验证输入的密码是否正确.
(本项目没有使用用户注册的功能, 大家可以自行完善)
public class SecurityUtils {
/**
* 加密. 返回 盐值 + 密文, 存储到数据库中.(用于用户注册)
* @param password 敏感数据(用户密码)
* @return 盐值 + md5(盐值 + 明文)
*/
public static String encrypt(String password) {
// 生成随机盐值
String salt = UUID.randomUUID().toString().replace("-", "");
// 生成密文: md5(盐值 + 明文)
String securityPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes(StandardCharsets.UTF_8));
// 数据库中保存的数据: 盐值 + 密文
String sqlPassword = salt + securityPassword ;
return sqlPassword;
}
/**
* 校验. 用户登录时, 用户校验用户输入的密码是否正确.
* @param inputPassword 用户登录输入的密码
* @param sqlPassword 数据中存的数据: 盐值 + 密文
* @return 验证结果
*/
public static boolean verify(String inputPassword, String sqlPassword) {
if(inputPassword == null) {
return false;
}
// UUID 盐值为 32 位; MD5 密文为 32 位. 共 64 位.
if(sqlPassword == null || sqlPassword.length() != 64) {
return false;
}
// 提取盐值
String salt = sqlPassword.substring(0, 32);
// 提取密文
String securityPassword = sqlPassword.substring(32, 64);
// 根据用户输入和盐值, 生成新的密文
String newSecurity =
DigestUtils.md5DigestAsHex((salt + inputPassword).getBytes(StandardCharsets.UTF_8));
// 校验新密文和正确密文是否相等
return newSecurity.equals(securityPassword);
}
}
添加了加密功能后, 我们就对之前验证用户登录的代码进行修改了:
此外, 我们需要将数据库中的 password 字段内容, 换成 盐值 + md5(盐值 明文)密文后, 才能验证成功, 成功登录:
至此, 博客系统大功告成!!