目錄
項目效果演示
代碼 Gitee 地址
1. 準備工作
1.1?建表
1.2 引入 MyBatis-plus 依賴
1.3 配置數據庫連接
?1.4 項目架構
2. 實體類準備 - pojo 包
2.1 dataobject 包
2.2 request 包
2.3 response 包
2.3.1 統一響應結果類 - Result
2.3.2 用戶登錄響應類
2.3.3 博客信息響應類
?2.3.3.1 設置日期格式 方法1 - @JsonFormat
?2.3.3.1 設置日期格式 方法2?- SimpleDateFormat
3. 通用工具 - common 包
3.1 自定義異常
3.2?統一功能除處理
3.2.1 統一結果返回
3.2.2 統一異常處理
4. 獲取博客列表
4.1 約定前后端交互接口
4.2 后端接口
4.2.1 實體類轉換
4.2.1.1?List.stream.map
4.2.1.2?BeanUtils.copyProperties
4.2.2 單元測試
4.2 前端代碼
4.2.1 客戶端界面測試
5. 獲取博客詳情
5.1 約定前后端交互接口
?5.2 后端接口
5.2.1 參數非空校驗 -?@NotNull
5.2.2 單元測試
5.3 前端代碼
5.3.1 客戶端界面測試
6. 用戶登錄
6.1 Session-Cookie 缺陷
6.2 令牌
6.2.1 Jwt 令牌
6.2.1.1 Jwt 簡單使用
6.2.2?密鑰 key
6.3 約定前后端交互接口
6.4?后端接口
6.4.1 引入 Jwt 依賴
6.4.2 校驗賬號密碼
6.4.2.1 生成 key
6.4.2.2?生成 token
6.4.2.3 驗證 token
6.4.3 單元測試
?6.5 前端代碼
6.5.1 LocalStorage 存儲 token
6.5.2 客戶端界面測試
7. 實現強制用戶登錄 - 配置攔截器
7.1 后端代碼
7.1.1 定義攔截器
7.1.2 注冊攔截器
7.1.3 單元測試
7.2 前端代碼
7.2.1?ajaxSend &?ajaxError
7.2.2 客戶端界面測試
?8. 實現顯示用戶信息
8.1 約定前后端交互接口
8.2 后端接口
8.2.1 單元測試
8.3 前端代碼
8.3.1 客戶端界面測試
9. 實現用戶退出
9.1 客戶端界面測試
?10. 實現發布博客
10.1 約定前后端交互接口
?10.2 后端代碼
?10.2.1 單元測試
10.3 前端代碼
10.3.1 Editor.md
10.3.2 單元測試
?11. 實現編輯/刪除博客
11.1 約定前后端交互接口
11.2 編輯博客
11.2.1?后端代碼
11.2.1.2?單元測試
11.2.2 前端代碼
?11.2.2.1 刪除/編輯按鈕的選擇性顯示
11.2.2.1.1 界面測試
11.2.2.2 編輯頁先展示原來的博客信息
11.2.2.2.2?界面測試
11.2.2.3 實現信息修改
11.2.2.2.3?界面測試
11.3 刪除博客
11.3.1 后端代碼
11.3.2 前端代碼
12. 加密/加鹽
12.1 加密
12.1.1 加密算法分類
12.2 加鹽
12.3 加密整體思路
12.3.1 加密
12.3.2 解密
12.4 代碼編寫
項目效果演示
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=trueusername: rootpassword: 111111driver-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.xmllogging: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 {@NotNullprivate String userName;@NotNullprivate 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 {@Overridepublic boolean supports(MethodParameter returnType, Class converterType) {return true;}@ResourceObjectMapper objectMapper;@SneakyThrows@Overridepublic 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 {@ExceptionHandlerpublic Result handle(Exception e) {log.error("出現異常: ", e);return Result.fail(e.getMessage());}@ExceptionHandlerpublic Result handle(BlogException e) {log.error("出現異常: ", e);return Result.fail(e.getErrMsg());}/*** 處理 @NotNull 拋出的異常* @param e* @return*/@ExceptionHandlerpublic 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*/@ExceptionHandlerpublic 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 中文網 官網 JSON Web Tokens
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 字符串
@Testvoid geneKey() {// 1. 隨機生成 keyKey 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, 載荷, 簽名 組合在一起, 生成 tokenreturn 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",// 對象轉 JSONdata: 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*/@Overridepublic 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 {@ResourceLoginInterceptor loginInterceptor;/*** 注冊攔截器* @param registry*/@Overridepublic 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*/@Overridepublic 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 轉換為 UserInfoResponsereturn BeanUtilsParse.trans(userInfo);}/*** 獲取作者信息, 展示在博客詳情頁* @param blogId* @return*/@Overridepublic 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 - 開源在線 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(鹽值 明文)密文后, 才能驗證成功, 成功登錄:
至此, 博客系統大功告成!!