一、功能描述
? ? ? ? 用戶登錄后,可查看所有人的博客。點擊 “查看全文” 可查看該博客完整內容。如果該博客作者是登錄用戶,可以編輯或刪除博客。發表博客的頁面同編輯頁面。
? ? ? ? 本練習的博客網站,并沒有添加注冊功能,以及上傳作者頭像功能,頭像是寫死的。
? ? ? ? 用戶登錄:
? ? ? ? 博客列表:
? ? ? ? 博客詳情:
? ? ? ? 博客編輯:
二、準備工作
1、數據庫
????????用戶文章數量、文章分類數量不要放到用戶表中,因為如果添加在用戶表中,文章的刪除/添加(博客表)會影響文章數量也改變,導致用戶表也跟著改變。文章數量也沒辦法放到博客表中,應該實時統計才行。
? ? ? ? 一個用戶對應多個博客,一個博客對應一個用戶。用戶與博客是一對多的關系,博客 id(多個博客是一個 id 列表,沒有列表基礎類)無法放到用戶表,因此將用戶 id 放到博客表。
-- 建表SQL
create database if not exists spring_blog charset utf8mb4;use spring_blog;
-- 用戶表
DROP TABLE IF EXISTS spring_blog.user_info;
CREATE TABLE spring_blog.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 spring_blog.blog_info;
CREATE TABLE spring_blog.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 spring_blog.user_info (user_name, password,github_url)values("zhangsan","123456","https://gitee.com/piggy-mi");
insert into spring_blog.user_info (user_name, password,github_url)values("lisi","123456","https://gitee.com/piggy-mi");insert into spring_blog.blog_info (title,content,user_id) values("第一篇博客","111我是博客正文我是博客正文我是博客正文",1);
insert into spring_blog.blog_info (title,content,user_id) values("第二篇博客","222我是博客正文我是博客正文我是博客正文",2);
2、創建項目
? ? ? ? 創建 Spring Boot 項目,添加 lombok、Spring Web、MyBatis、MySQL Driver 依賴。配置 MyBatis-Plus 依賴。導入前端代碼。配置 yml 數據庫連接、MyBatis-Plus 數據庫操作日志和自動轉駝峰命名、Spring Boot 日志保存目錄。
? ? ? ? 為了演示依賴沖突排除的過程,創建項目時引入了 MyBatis 依賴(不用),后面又加了 MyBatis-Plus 依賴(用),它們會存在依賴沖突:
? ? ? ? 但實際把 mybatis 的依賴去掉就可以了。但是真實項目中,依賴會很多很多,并且我們創建項目的方式是直接復制粘貼舊的項目(避免繁瑣地重新配置東西),因此很容易遇到依賴沖突,我們通過這種方式排除不用的即可。
3、創建目錄
? ? ? ? controller(表現層)、service(業務層)、mapper(持久層)、pojo(實體類)、config(配置)、common(公共部分,如常量、統一處理、自定義工具包等)。
? ? ? ? 實體類:pojo、model、entity 都是實體類。pojo 還細分了 VO(視圖對象,返回的實體)、DO(數據對象,數據表對應的實體)、DTO(service 可能存在數據庫實體轉換成其它)、BO(業務對象)。這些屬于阿里的規范,其它公司會模仿,但是具體使用時具有偏差,按照公司之前的項目來就行。
? ? ? ? 此項目中只細分出 dataobject(數據庫的信息)、request(請求信息)、response(響應信息)。這樣寫的好處:不會暴露多余信息給前端、讓邏輯不混亂。比如 request 實體需要用到?jakarta.validation 參數校驗、@JsonProperty 前后端參數命名不一致時的映射;response 實體需要隱藏隱私信息、處理格式化數據(如把 create_time 格式化);dataobject 實體需要用到?@TableId、@TableNmae 等在實體屬性與表字段名不一致時的映射。
? ? ? ? SOA 理念:在 service 層通常會先寫 service 接口,再寫多個版本的繼承了同一個接口的 service 實體。這樣做的好處就是,可以輕松替換 controller 層調用的 service bean 版本(因為實現的同一個接口,所以方法也是一樣的,不需要修改調用方法處的源碼)(只需要修改 @Resource 中的 service bean 名即可。實際工作中,@Resource 替代了 @AutoWired,好處:@Resource 默認按命名注入,適用于一個類(一個接口類)有多個 bean (多個實現類的 bean)的情況;而 @AutoWired 默認按類注入,存在問題)(當 類只有一個 bean 時,可以不指定 @Resource 中的命名)
4、測試
? ? ? ? 運行程序,看前端頁面是否能正常訪問,排除錯誤。避免后續加了其它功能后,代碼復雜不好排查錯誤。
三、公共部分代碼
? ? ? ? 項目主要分為 controller、service、mapper、數據庫、實體類、公共部分(如統一處理)。數據庫已經建好了,我們先完成公共部分代碼。
1、統一數據返回格式
????????統一數據返回格式,返回 Result 實例:如果不統一,每個接口的返回結果非常定制化,前端不方便處理;并且想區分業務成功、業務失敗、程序異常、未登錄等情況還需要查特定接口返回值的含義,如果用 code 表示,含義就清晰很多。
(1)response 實體類
package com.edu.spring.blog.pojo.response;import com.edu.spring.blog.common.enums.ResultCodeEnums;
import lombok.Data;@Data
public class Result <T>{Integer code;String errMsg;T data;public static <T> Result<T> success(T data) {Result<T> result = new Result<>();result.setCode(ResultCodeEnums.SUCCESS.getCode());result.setData(data);return result;}public static <T> Result<T> unLogin() {Result<T> result = new Result<>();result.setCode(ResultCodeEnums.UN_LOGIN.getCode());return result;}public static <T> Result<T> error(String errMsg) {Result<T> result = new Result<>();result.setCode(ResultCodeEnums.ERROR.getCode());result.setErrMsg(errMsg);return result;}public static <T> Result<T> error(Integer code, String errMsg) {Result<T> result = new Result<>();result.setCode(code);result.setErrMsg(errMsg);return result;}
}
? ? ? ? 將返回值 code 設計成枚舉類,好處是:讓無含義的數字具有含義,使用時調用有含義的枚舉實例名。
package com.edu.spring.blog.common.enums;import lombok.AllArgsConstructor;
import lombok.Getter;@Getter
@AllArgsConstructor
public enum ResultCodeEnums {SUCCESS(200, "業務處理成功"),UN_LOGIN(-1, "未登錄"),ERROR(-2, "后端出錯");private final Integer code;private final String message;
}
(2)統一處理代碼
package com.edu.spring.blog.common.advice;import com.edu.spring.blog.pojo.response.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {@Resourceprivate ObjectMapper mapper;@Overridepublic boolean supports(MethodParameter returnType, Class converterType) {return true;}@SneakyThrows@Overridepublic Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {if (body instanceof Result<?>) {return body;}// body 通常不用 String,因為還得設置 response 的 content-type 為 application/json,比較麻煩if (body instanceof String) {return mapper.writeValueAsString(Result.success(body));}return Result.success(body);}
}
2、統一異常處理
????????定義自己的 Blog 異常類,也可以細分定義更多的異常,比如參數異常、內部錯誤異常等。我僅定義了 Blog 異常,通過 code、message 區分不同的異常。這里只是為了示范自定義異常。
? ? ? ? 因為父類也有 message 屬性,所以 getMessage 獲得的父類的 massage,所以要 @Getter 重寫 get 方法。
package com.edu.spring.blog.common.exception;import lombok.Getter;@Getter
public class BlogException extends RuntimeException {Integer code;String message;public BlogException(String message) {this.message = message;}public BlogException(Integer code, String message) {this.code = code;this.message = message;}
}
package com.edu.spring.blog.common.advice;import com.edu.spring.blog.common.exception.BlogException;
import com.edu.spring.blog.pojo.response.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;@ControllerAdvice
@ResponseBody
@Slf4j
public class ExceptionAdvice {@ExceptionHandlerpublic Result<?> error(Exception e) {log.error("服務器內部發生異常,e: ", e);return Result.error("服務器內部錯誤,請聯系管理員");}@ExceptionHandlerpublic Result<?> error(BlogException e) {log.error("發生異常,e: ", e);return Result.error(e.getMessage());}
}
3、攔截器
????????最后添加,因為其它功能開發過程中被攔截很麻煩,需要反復登錄。
? ? ? ?
四、業務代碼
1、持久層
?(1)dataobject 實體類
? ? ? ? 按照數據庫表創建:
package com.edu.spring.blog.pojo.dataobject;import lombok.Data;import java.util.Date;@Data
public class UserInfo {private Integer id;private String userName;private String password;private String githubUrl;private Byte deleteFlag;private Date createTime;private Date updateTime;
}
package com.edu.spring.blog.pojo.dataobject;import lombok.Data;import java.util.Date;@Data
public class BlogInfo {private Integer id;private String title;private String content;private Integer userId;private Byte deleteFlag;private Date createTime;private Date updateTime;
}
(2)mapper 接口
? ? ? ? 繼承 MayBatis-Plus 框架提供的 BaseMapper<T> 類,包含基礎的 mapper 操作方法。T 是操作的數據對象,一個表一個 mapper。
package com.edu.spring.blog.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.edu.spring.blog.pojo.dataobject.UserInfo;@Mapper
public interface UserInfoMapper extends BaseMapper<UserInfo> {
}
package com.edu.spring.blog.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.edu.spring.blog.pojo.dataobject.BlogInfo;@Mapper
public interface BlogInfoMapper extends BaseMapper<BlogInfo> {
}
2、博客列表
(1)接口設計
請求:
/blog/getList GET參數:
無響應
{code: 200,errMsg: null,data: [{"id": 1, // 需要根據 id 查詢博客詳情 "title": "我的第一篇博客","content": "我可正文博客正文...不能超過 100 字""createTime": "2025-09-04 18:44"},......]
}
(2)response 實體類(@JsonFormat)
? ? ? ? 實際開發中,關于時間數據,后端更傾向于返回時間戳,這樣的好處是:前端自行處理格式,若后續需要修改格式也很方便,與后端無關。使用 .getTime 便可以獲得時間戳。
? ? ? ? 為了學習后端的時間格式化,我們返回格式化的時間字符串。可以用 SimpleDateFormat 類,也可以使用 @JsonFormat 注解,更加方便。
? ? ? ? 格式查詢 Java8 官方文檔 SimpleDateFormat 類:SimpleDateFormat (Java Platform SE 8 )
? ? ? ? 在列表中,content 是顯示不全的。content 可以由前端處理,也可以由后端處理。后端處理更好,因為傳輸的數據量更少,性能更好。
package com.edu.spring.blog.pojo.response;import com.edu.spring.blog.pojo.dataobject.BlogInfo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.beans.BeanUtils;import java.text.SimpleDateFormat;
import java.util.Date;@Data
public class BlogListResponse {private Integer id;private String title;private String content;@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") // 糾正時區private Date createTime;// 后端更傾向于返回時間戳,前端可以自行轉換
// public Long getCreateTime() {
// return createTime.getTime();
// }// 2025-01-01 00:00:00
// public String getCreateTime() {
// SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// return sdf.format(createTime);
// }// 將 content 字段的長度限制為 100public String getContent() {return content.length() > 100? content.substring(0, 100) + "..." : content;}// 創建對象時,傳入 dataobject,自動轉換為 BlogListResponse 對象public BlogListResponse(BlogInfo blogInfo) {BeanUtils.copyProperties(blogInfo, this);}
}
(3)controller
package com.edu.spring.blog.controller;import com.edu.spring.blog.pojo.response.BlogListResponse;
import com.edu.spring.blog.service.BlogInfoService;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.List;@RestController
@RequestMapping("/blog")
public class BlogInfoController {@Resource(name = "blogInfoServiceImpl")private BlogInfoService blogInfoService;@GetMapping("/getList")public List<BlogListResponse> getBlogList() {return blogInfoService.getBlogList();}
}
(4)service
? ? ? ? 接口:
package com.edu.spring.blog.service;import com.edu.spring.blog.pojo.response.BlogListResponse;import java.util.List;public interface BlogInfoService {List<BlogListResponse> getBlogList();
}
? ? ? ? 實現類:
- .map:對流中的每個元素應用一個函數,將其映射成另一個元素,從而生成一個新的流。
.collect():
將流中的元素累積到一個可變的結果容器中,通過一個Collector
來指定如何進行累積操作。Collectors.toList():
將流中的元素收集到一個List
中。
????????BeanUtils.copyProperties 會自動將?blogInfo 復制到 response 對應屬性中。每次 new 了 response 都要轉換一次,代碼冗余。不如直接傳入 blogInfo 在構造函數里進行轉換。
package com.edu.spring.blog.service.impl;import java.util.List;
import java.util.stream.Collectors;@Service("blogInfoServiceImpl")
public class BlogInfoServiceImpl implements BlogInfoService {@Resource(name = "blogInfoMapper")private BlogInfoMapper blogInfoMapper;@Overridepublic List<BlogListResponse> getBlogList() {// 查詢出所有未刪除的博客信息,按創建時間倒序排列List<BlogInfo> blogInfoList = blogInfoMapper.selectList(new LambdaQueryWrapper<BlogInfo>().eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE).orderByDesc(BlogInfo::getCreateTime));// 將 BlogInfo 轉換為 BlogListResponsereturn blogInfoList.stream().map(blogInfo -> {
// BlogListResponse response = new BlogListResponse();
// // response.setId(blogInfo.getId()); 這種方法太麻煩了,還要一個個設置屬性
// // 使用 BeanUtils 工具類
// BeanUtils.copyProperties(blogInfo, response);
// return response;// 直接在構造方法里轉換return new BlogListResponse(blogInfo);}).collect(Collectors.toList());}
}
? ? ? ? 常量類:
package com.edu.spring.blog.common.constant;public class Constants {public static final Byte IS_DELETE = 1;public static final Byte NOT_DELETE = 0;
}
(5)前端 JS
<script>getList();function getList() {$.ajax({url: "/blog/getList",type: "GET",success: function (result) {if(result.code === 200 && result.data != null) {let blogs = result.data;let html = "";for(let blog of blogs) {html += "<div class=\"blog\">"html += "<div class=\"title\">" + blog.title + "</div>"html += "<div class=\"date\">" + blog.createTime + "</div>"html += "<div class=\"desc\">" + blog.content + "</div>"html += "<a class=\"detail\" href=\"blog_detail.html?id=" + blog.id + "\">查看全文>></a>"html += "</div>"}$(".right").html(html);} else {alert(result.errMsg)}}});}</script>
(6)測試
3、博客詳情
(1)接口設計
請求:
/blog/getBlogDetail?id=1 GET參數:
無響應:
{code: 200,errMsg: null,data: {"id": 1,"title": "我的第一篇博客","content": "我可正文博客正文...不能超過 100 字""userId": "zhangsan","createTime": "2025-09-04 18:44"}
}
(2)response 實體類
package com.edu.spring.blog.pojo.response;import com.edu.spring.blog.pojo.dataobject.BlogInfo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.beans.BeanUtils;import java.util.Date;@Data
public class BlogDetailResponse {private Integer id; // 用于編輯/刪除博客private String title;private String content;private Integer userId; // 用于顯示作者信息@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") // 糾正時區private Date createTime;public BlogDetailResponse(BlogInfo blogInfo) {BeanUtils.copyProperties(blogInfo, this);}
}
(3)controller
? ? ? ? Java 在編譯時默認 不會把參數名編譯進 .class 文件,只保留參數類型,所以訪問時找不到參數名去綁定,所以會報錯:
? ? ? ? 第一種方法:加?@RequestParam("id") 顯示綁定,但這個方法要求每個參數都要綁定,很麻煩。第二中方法配置 idea:給項目配置?-parameters,還不行就 clean 一下 target。
? ? ? ? 關于參數校驗,用 if-else 校驗很麻煩。我們使用?jakarta.validation 工具里的注解幫我們校驗。需要加入依賴:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId>
</dependency>
? ? ? ? 常見注解:
注解 | 說明 | 適用類型 |
@NotBlank | 不能為 null,而且調用 trim() 后,長度必須大于 0,即必須有實際字符。 | String 類型 |
@NotEmpty | 等不能為 null,且長度必須大于 0。 | 字符串、集合、數組 |
@NotNull | 不為空。 | 任何類型 |
@Min | 大等于指定的值 | Number 、 String |
@Size(min=, max=) | 長度在給定的范圍之內 | 字符串、集合、數組 |
@Length(min=, max=)? | 長度在給定的范圍之內 | String 類型 |
? ? ? ? controller:
@GetMapping("/getBlogDetail")public BlogDetailResponse getBlogDetail(@NotNull(message = "blogId 不能為 null")@Min(value = 1, message = "blogId 不能小于 1")Integer id) {log.info("獲得博客詳情,blogId: {}", id);return blogInfoService.getBlogDetail(id);}
????????前端參數不符合規范:拋出?HandlerMethodValidationException
? ? ? ? 異常信息:
? ? ? ? 統一異常處理:
@ExceptionHandlerpublic Result<?> error(HandlerMethodValidationException e) {List<String> errors = e.getAllErrors().stream().map(error -> error.getDefaultMessage()).toList();log.error("發生參數校驗異常,errors: {}", errors);return Result.error(Constants.REQUEST_PARAM_ERROR, String.join("; ", errors));}@ExceptionHandlerpublic Result<?> error(MethodArgumentNotValidException e) {List<String> errors = e.getBindingResult().getFieldErrors().stream().map(error -> error.getDefaultMessage()).toList();log.error("發生參數校驗異常,errors: {}", errors);return Result.error(Constants.REQUEST_PARAM_ERROR, String.join("; ", errors));}
(4)service
@Overridepublic BlogDetailResponse getBlogDetail(Integer id) {// 查詢出指定 id 的,未刪除的博客信息BlogInfo blogInfo = blogInfoMapper.selectOne(new LambdaQueryWrapper<BlogInfo>().eq(BlogInfo::getId, id).eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE));// 將 BlogInfo 轉換為 BlogDetailResponsereturn new BlogDetailResponse(blogInfo);}
(5)前端 JS
<script>getBlogDetail();function getBlogDetail() {$.ajax({url: "/blog/getBlogDetail" + location.search,type: "GET",success: function(result) {if (result.code === 200 && result.data != null) {let blog = result.data;$(".title").text(blog.title);$(".date").text(blog.createTime);$(".detail").text(blog.content);// TODO 顯示博客作者信息// TODO 編輯和刪除} else {alert(result.errMsg);}}});}</script>
(6)測試
4、用戶登錄
? ? ? ? Http 是無狀態的,客戶端第一次請求服務器,后續再請求,服務器無法識別該客戶端是否請求過。會話跟蹤就是為了讓服務器 “有記憶”。
(1)Session&Cookie 存在的問題
- Session 存儲在服務器內存中,服務器重啟后,內存中的 Session 就會丟失。(對于現實項目中,因程序版本更新而重啟服務器是很正常的需求,如果 session 丟失,某些用戶剛登錄又要求重新登陸,在用戶看來就是 bug。)
- Session 存儲在服務器內存中,增加了服務器的負擔。(登陸的用戶量龐大,session 占用內存大)
- 無法在集群環境下實現會話跟蹤。(現實中,一個公司至少有兩臺服務器,并且最好不要在同一機房甚至同一城市。一個單體應用的多個實例分別在這多個服務器上運行,這樣做的目的一是分擔服務器負擔、二是避免單服務器故障導致整個應用無法訪問。為了合理分配請求給不同的服務器上的應用,請求會先經過負載均衡算法,根據不同服務器的性能、請求訪問的接口重量等進行分配。還有就是微服務,將整個項目按功能、重量等拆分成多個微服務,一般服務中的每個接口越重,劃分的接口就越少。)(在此條件下,客戶端第一次的請求可能被分配到服務器1,session 保存在服務器1的內存中。客戶端第二次的請求可能被分配到其它服務器,其他服務器內存不含該 session,會話跟蹤失敗)
? ? ? ? 因此我們需要解決兩個問題:1、session 持久化(如果放到 MySQL 數據庫,即硬盤,硬盤存取速度慢。更優的是 Redis 緩存中間件,session 有緩存在內存提速,也有持久化防止丟失。但這些方法仍占用服務器內存,增加負擔)。2、集群環境共享 session(session 持久化后,也就解決了該問題。比如每個服務器都能從數據庫中獲取 session)。
(2)JWT 令牌
? ? ? ? 令牌就是用戶身份的標識,本質是一個字符串 token,類似身份證。
? ? ? ? 優點:
- session 存在客戶端(cookie 或者 localStorage 瀏覽器提供的客戶端本地存儲技術),減輕服務器壓力。
- 解決了集群環境下的會議跟蹤問題(客戶端第一次請求分配給服務器1,生成令牌返回,存儲在客戶端;客戶端第二次請求攜帶令牌分配給服務器2,令牌不可篡改,因為只有它持有密鑰加密成簽名,篡改了,當前令牌的簽名和之前的簽名就對不上)。
? ? ? ? 缺點:
- 需要自己實現令牌生成、傳輸、校驗技術。
? ? ? ? 常見的有 JWT 令牌,是第三方工具,幫我們實現了令牌。
JSON Web Tokens - jwt.iohttps://www.jwt.io/? ? ? ? JWT 令牌組成:
? ? ? ? 參考之前寫的 HTTPS 證書,令牌類似于證書:Header? + Payload + 僅服務端持有的密鑰加密校驗和生成唯一的簽名=令牌。因此令牌的 Header、Payload 無法篡改,改了的話校驗和就變了;校驗和也不能改,因為無法獲取服務端持有的密鑰加密。
? ? ? ? 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>
? ? ? ? 使用示例:
@SpringBootTest
public class JwtUtilTest {// 密鑰字符串String secretString = "1mF4QaoRhmt82qmv0fqP1BJ80OmRLI+8sFwUtscTLMM=";// 密鑰字符串轉為密鑰對象Key key = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));// 過期時間,單位:毫秒long EXPIRATION_TIME = 1000 * 60 * 60 * 24;// 測試生成令牌@Testpublic void generateToken() {// 自定義 PayloadMap<String, Object> payload = new HashMap<>();payload.put("id", 1);payload.put("username", "admin");// 生成令牌 TokenString token = Jwts.builder().setClaims(payload).signWith(key, SignatureAlgorithm.HS256) // 使用 HS256 算法進行簽名.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 設置過期時間.compact();System.out.println(token);}// 測試生成隨機密鑰字符串@Testpublic void generateSecretString() {// 生成隨機密鑰對象SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);// 將二進制密鑰轉換為 Base64 編碼的密鑰字符串String secretString = Encoders.BASE64.encode(key.getEncoded());System.out.println(secretString);}// 測試檢驗令牌@Testpublic void checkToken() {String token = "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImV4cCI6MTc1NzE1NTMzMX0.Kc0Bc41u5eYKJBN397Y9mV12PjwVHaK4ed1GpzGOBZE";// 檢驗令牌,密鑰不匹配、令牌被篡改、過期等都會拋出異常JwtParser builder = Jwts.parserBuilder().setSigningKey(key) // 設置簽名密鑰.build();// 解析令牌,獲取 PayloadClaims body = builder.parseClaimsJws(token).getBody();System.out.println(body);}
}
生成 token:
隨機生成密鑰字符串:
檢驗 token 并獲取 payload:Claims 繼承了 Map,可當作 Map 使用。
將生成的 token 解析:
(3)實現用戶登錄
? ? ? ? 思路:客戶端請求登錄,服務器訪問數據庫,驗證用戶名、密碼是否匹配,匹配則生成令牌,響應給客戶端,客戶端把令牌存到本地。
????????令牌保存在客戶端,服務器重啟不會丟失、不占內存,不同服務器上的應用實例都可以獲取到該令牌,實現集群環境下的登錄。
? ? ? ? 客戶端登陸后請求服務器,會攜帶本地存儲的令牌,服務器執行攔截器,解析令牌是否正確,正確則不攔截。解析令牌時需要獲取用戶信息,在令牌的 payload 中,用于識別不同的用戶會話,需要使用 TreadLocal 存儲 payload。因為在 Java Web 容器(如 Tomcat)中,每個請求會分配一個線程,請求處理完畢后線程歸還線程池,而 ThreadLocal 中的數據僅在當前請求的線程處理周期內有效。
? ? ? ? 接口設計:
請求:
/user/login POST參數:
{"userName": "zhangsan","password": "123456"
}響應:
{code: 200,errMsg: null,data: null
}token 放在 header 的 set-token 字段
? ? ? ? 使用 JWT 實現的令牌生成、校驗工具:
public class JwtUtil {// 密鑰字符串private static final String secretString = "1mF4QaoRhmt82qmv0fqP1BJ80OmRLI+8sFwUtscTLMM=";// 密鑰字符串轉為密鑰對象private static final Key key = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));// 過期時間,單位:毫秒,24小時private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 24;// 根據自定義 payload 生成令牌public static String generateToken(Map<String, Object> payload) {return Jwts.builder().setClaims(payload).signWith(key, SignatureAlgorithm.HS256) // 使用 HS256 算法進行簽名.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 設置過期時間.compact();}// 檢驗令牌,返回 payload 中的用戶信息public static Claims checkToken(String token) {JwtParser builder = Jwts.parserBuilder().setSigningKey(key) // 設置簽名密鑰.build();try {// 解析令牌,密鑰不匹配、令牌被篡改、過期等都會拋出異常。并獲取 Payloadreturn builder.parseClaimsJws(token).getBody();} catch (Exception e) {return null;}}// 將原數據 UserInfo 轉換為 Mappublic static Map<String, Object> convertMap(UserInfo userInfo) {Map<String, Object> map = new HashMap<>();map.put("userId", userInfo.getId());map.put("username", userInfo.getUserName());return map;}
}
? ? ? ? 單線程內共享(所有方法和接口)當前會話用戶信息工具:
package com.edu.spring.blog.common.util;import java.util.Map;public class UserContextUtil {private static final ThreadLocal<Map<String, Object>> userContext = new ThreadLocal<>();public static void setContext(Map<String, Object> context) {userContext.set(context);}public static Map<String, Object> getContext() {return userContext.get();}public static void clearContext() {userContext.remove();}
}
? ? ? ? ?request 實體類:使用參數校驗注解
package com.edu.spring.blog.pojo.request;import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.hibernate.validator.constraints.Length;@Data
public class UserLoginRequest {@NotBlank(message = "用戶名不能為空")@Length(max = 20, message = "用戶名長度不能超過20個字符")private String userName;@Length(max = 20, message = "密碼長度不能超過20個字符")@NotBlank(message = "密碼不能為空")private String password;
}
???????? controller:將令牌寫到 response 的 header 中,對象參數檢驗要加?@Validated
@PostMapping("/login")public Result<?> login(@Validated @RequestBody UserLoginRequest request, HttpServletResponse response) {log.info("用戶登錄請求 request: {}", request);String token = userInfoService.login(request);response.setHeader(Constants.RESPONSE_HEADER_TOKEN, token);return Result.success(null);}
? ? ? ? service:
@Overridepublic String login(UserLoginRequest request) {UserInfo userInfo = userInfoMapper.selectOne(new LambdaQueryWrapper<UserInfo>().eq(UserInfo::getUserName, request.getUserName()).eq(UserInfo::getDeleteFlag, Constants.NOT_DELETE));// 校驗登錄信息if(userInfo == null) {throw new BlogException("用戶不存在");}if(!userInfo.getPassword().equals(request.getPassword())){throw new BlogException("密碼錯誤");}// 校驗正確,根據 userInfo 生成令牌return JwtUtil.generateToken(JwtUtil.convertMap(userInfo));}
? ? ? ? 前端 JS:
function login() {$.ajax({url: "/user/login",type: "post",contentType: "application/json",data: JSON.stringify({"userName": $("#username").val(),"password": $("#password").val()}),success: function(result, textStatus, xhr) {if (result.code === 200) {// 把 token 存到 localStorage 中localStorage.setItem("token", xhr.getResponseHeader("set-token"));location.href = "blog_list.html";} else {alert(result.errMsg);}}});}
(4)實現強制登陸?
????????攔截器定義:
package com.edu.spring.blog.common.interceptor;@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 從 request 的 header 中獲取令牌String token = request.getHeader(Constants.REQUEST_HEADER_TOKEN);// 校驗令牌,獲取 payloadClaims payload = JwtUtil.checkToken(token);// 校驗無效,攔截請求if (payload == null) {log.error("無效的令牌 {},攔截請求", token);// 設置響應頭狀態碼,告訴瀏覽器未授權response.setStatus(HttpStatus.UNAUTHORIZED.value());return false;}// 校驗有效,將 payload 存入 ThreadLocal 中,后續可通過 ThreadLocal 獲取當前用戶信息UserContextUtil.setContext(payload);// 放行請求return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {// 請求處理之后,清除 ThreadLocal 中的用戶信息,防止內存泄漏UserContextUtil.clearContext();}
}
? ? ? ? 攔截器注冊:
package com.edu.spring.blog.common.config;@Configuration
public class WebConfig implements WebMvcConfigurer {@Resourceprivate LoginInterceptor loginInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {List<String> excludePathPatterns = List.of("/user/login","/**/*.html","/blog-editormd/**","/css/**","/js/**","/pic/**","/**/*.ico");registry.addInterceptor(loginInterceptor).addPathPatterns("/**").excludePathPatterns(excludePathPatterns);}
}
? ? ? ? 前端 JS:因為每次請求都要經過攔截器,都可能觸發 401 狀態碼,前端需要統一處理。登錄后,每次請求都要攜帶 token,也需要統一處理。放到 common.js 中,所有 html 都要加 common.js:
// 統一異常狀態碼處理
$(document).ajaxError(function(event,xhr){if(xhr.status === 401){location.href = "/blog_login.html";}
});// 統一攜帶 token
$(document).ajaxSend(function (e, xhr) {let userToken = localStorage.getItem("token");xhr.setRequestHeader("token", userToken);
});
5、顯示用戶信息
? ? ? ? 用戶發表文章數:每次直接用 SQL 查詢比較慢。優化方向:使用 redis 緩存。緩存沒有文章數量,則 SQL 查詢,有則讀取緩存。新增數據時,更新緩存值;刪除數據時,更新緩存值或者直接刪除緩存值。
? ? ? ? 可擴展,用戶博客分類:如果一個博客只有一個分類,則把分類加入博客表。如果一個博客可對應多個分類,則抽出一個分類表,分類 id、博客 id、分類名。
? ? ? ? 可擴展,用戶頭像:重寫 WebMvcConfigurer 類的 addResourceHandlers 方法,將 url 的路徑映射到存放靜態資源的路徑。有條件可以將文件單獨存放在一個服務器。然后在用戶表中加上圖片路徑字段。
public void addResourceHandlers(ResourceHandlerRegistry registry) {// 映射自定義靜態資源registry.addResourceHandler("/img/**").addResourceLocations("file:D:/pic/");
}訪問 url:http://127.0.0.1:8080/img/頭像.jpg獲取到文件資源:
本地找 D:/pic/頭像.jpg
(1)接口設計
請求:
/user/getUserInfo GET參數:
無響應:
{"code": 200,"errMsg": null,"data": {"userName": "zhangsan","githubUrl": "https://gitee.com/zhangsan","blogNum": 2}
}
(2)response 實體類
package com.edu.spring.blog.pojo.response;@Data
public class UserInfoResponse {private String userName;private String githubUrl;private Long blogNum;public UserInfoResponse(UserInfo userInfo, Long blogNum) {BeanUtils.copyProperties(userInfo, this);this.blogNum = blogNum;}
}
(3)后端
? ? ? ? controller:
@GetMapping("/getUserInfo")public UserInfoResponse getUserInfo() {return userInfoService.getUserInfo();}
? ? ? ? service:
@Overridepublic UserInfoResponse getUserInfo() {// 從上下文中獲取 userIdInteger userId = (Integer) UserContextUtil.getContext().get("userId");return getUserInfoById(userId);}public UserInfoResponse getUserInfoById(Integer userId) {// 根據 userId 查詢用戶信息UserInfo userInfo = userInfoMapper.selectById(userId);// 根據 userId 查詢博客數量Long blogNum = blogInfoMapper.selectCount(new LambdaQueryWrapper<BlogInfo>().eq(BlogInfo::getUserId, userId).eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE));return new UserInfoResponse(userInfo, blogNum);}
(4)前端 JS
function getUserInfo() {$.ajax({url: "/user/getUserInfo",type: "GET",success: function (result) {if(result.code === 200 && result.data != null) {let userInfo = result.data;$(".container .left .card h3").text(userInfo.userName);$(".container .left .card a").attr("href", userInfo.githubUrl);$(".container .left .card .blog-count").text(userInfo.blogNum);} else {alert(result.errMsg)}}});}
? ? ? ? 同理,顯示作者信息、編輯刪除按鈕:
? ? ? ? controller:
@GetMapping("/getAuthorInfo")public UserInfoResponse getAuthorInfo(Integer authorId) {return userInfoService.getAuthorInfo(authorId);}
? ? ? ? service:
@Overridepublic UserInfoResponse getAuthorInfo(Integer authorId) {// 獲得作者信息UserInfoResponse userInfoResponse = getUserInfoById(authorId);// 判斷當前登錄用戶是否為作者userInfoResponse.setIsUserVerified(authorId.equals(UserContextUtil.getContext().get("userId")));return userInfoResponse;}
? ? ? ? 前端 JS:
function getAuthorInfo(authorId) {$.ajax({url: "/user/getAuthorInfo?authorId=" + authorId,type: "GET",success: function(result) {if (result.code === 200 && result.data != null) {let author = result.data;$(".container .left .card h3").text(author.userName);$(".container .left .card a").attr("href", author.githubUrl);$(".container .left .card .blog-count").text(author.blogNum);// 如果登錄用戶是博客作者,顯示編輯、刪除按鈕if (author.isUserVerified === true) {let html = "<div class=\"operating\">";html += "<button onclick=\"window.location.href='blog_update.html'\">編輯</button>";html += "<button onclick=\"deleteBlog()\">刪除</button></div>";$(".right .content").append(html);}} else {alert(result.errMsg);}}});}
6、用戶退出
? ? ? ? 刪除本地 token,轉到登陸頁面。前端退出方法,放到 common.js:
// 注銷登錄
function logout() {localStorage.removeItem("token");location.href = "/blog_login.html";
}
7、發布博客
(1)接口設計
請求:
/blog/publishBlog POST參數:
application/json
{"id": 1,"title": "我的第一篇博客","content": "博客正文博客正文博客正文博客正文"
}響應:
{"code": 200,"errMsg": null,"data": true
}
(2)request 實體類
@Data
public class BlogPublishRequest {@NotBlank(message = "博客標題不能為空")private String title;@NotBlank(message = "博客內容不能為空")private String content;
}
(3)后端
? ? ? ? controller:
@PostMapping("/publishBlog")public Boolean publishBlog(@Validated @RequestBody BlogPublishRequest blogPublishRequest) {log.info("發布博客,blogPublishRequest: {}", blogPublishRequest);return blogInfoService.publishBlog(blogPublishRequest) == 1;}
? ? ? ? service:
@Overridepublic Integer publishBlog(BlogPublishRequest blogPublishRequest) {// blog 數據庫對象BlogInfo blogInfo = new BlogInfo();blogInfo.setUserId(UserContextUtil.getUserId());// 復制 blogPublishRequest 到 blogInfo 對象BeanUtils.copyProperties(blogPublishRequest, blogInfo);return blogInfoMapper.insert(blogInfo);}
(4)前端
????????editor.md 是?個開源的?? markdown 編輯器組件。
Editor.md - 開源在線 Markdown 編輯器http://editor.md.ipandao.com/? ? ? ? 使用:
? ? ? ? Markdown 文本轉?HTML 本文:
editormd.markdownToHTML("detail", markdown: blog.content});
? ? ? ? 同理編輯博客:
請求實體類:
@Data
public class BlogUpdateRequest {@NotNull(message = "博客ID不能為空")private Integer id;@NotBlank(message = "博客標題不能為空")private String title;@NotBlank(message = "博客內容不能為空")private String content;
}controller:@PostMapping("/updateBlog")public Boolean updateBlog(@Validated @RequestBody BlogUpdateRequest blogUpdateRequest) {log.info("更新博客,blogUpdateRequest: {}", blogUpdateRequest);return blogInfoService.updateBlog(blogUpdateRequest) == 1;}service:@Overridepublic Integer updateBlog(BlogUpdateRequest blogUpdateRequest) {BlogInfo blogInfo = new BlogInfo();// 按 id 更新博客BeanUtils.copyProperties(blogUpdateRequest, blogInfo);return blogInfoMapper.updateById(blogInfo);}// 獲取博客詳情并顯示function getBlogInfo() {$.ajax({type: "get",url: "/blog/getBlogDetail" + location.search,success: function (result) {if (result.code === 200 && result.data != null) {let blogInfo = result.data;$("#blogId").val(blogInfo.id);$("#title").val(blogInfo.title);// $("#content").val(blogInfo.content);let editor = editormd("editor", {width: "100%",height: "550px",path: "blog-editormd/lib/",// 刷新,避免緩存導致顯示不正確onload: function () {this.watch();this.setMarkdown(blogInfo.content);}});} else {alert(result.errMsg);}}});}getBlogInfo();前端JS: // 更新博客function submit() {$.ajax({url: "/blog/updateBlog",type: "POST",contentType: "application/json;charset=UTF-8",data: JSON.stringify({"id": $('#blogId').val(),"title": $('#title').val(),"content": $('#content').val()}),success: function (result) {if (result.code === 200 && result.data === true) {location.href = "blog_list.html";} else if (result.code === 200 && result.data === false) {alert("更新失敗!")} else {alert(result.errMsg)}}});}
8、刪除博客
(1)接口設計
請求:
/blog/deleteBlog?id=1參數:
無響應:
{"data": 200,"errMsg": null,"data": true
}
(2)代碼
controller:@DeleteMapping("/deleteBlog")public Boolean deleteBlog(@NotNull(message = "blogId 不能為 null") Integer id) {log.info("刪除博客,blogId: {}", id);return blogInfoService.deleteBlog(id) == 1;}service:@Overridepublic Integer deleteBlog(Integer id) {return blogInfoMapper.update(new LambdaUpdateWrapper<BlogInfo>().set(BlogInfo::getDeleteFlag, Constants.IS_DELETE).eq(BlogInfo::getId, id));}前端:function deleteBlog() {$.ajax({url: "/blog/deleteBlog" + location.search,type: "DELETE",success: function(result) {if (result.code === 200 && result.data === true) {location.href = "blog_list.html";} else if (result.code === 200 && result.data === false) {alert("刪除失敗!");} else {alert(result.errMsg);}}});}
9、加密/加鹽
(1)加密的作用
? ? ? ? 數據庫的信息非常隱私及重要,為了防止黑客獲取到數據庫信息后利用隱私信息,我們需要對隱私信息加密(比如身份證、密碼等。我們把項目部署到服務器上后,很可能就被黑客侵入了數據庫,以此找你要錢。)。
? ? ? ? 加密算法分為三類:
- 對稱加密:加密/解密的密鑰相同,常見的算法有:AES、DES。
- 非對稱加密:加密/解密的密鑰不同,通常用公鑰加密、私鑰解密,常見的算法有:RSE、DSE。
- 摘要算法:把任意長度的消息加密為固定長度的字符串,不論平臺、語言,相同消息加密后的摘要是相同的(排除小概率事件:消息不同,也可能摘要相同)。常見算法有:MD5、CRC。
? ? ? ? 對稱、非對稱加密算法可逆,摘要算法不可逆,但簡單消息的摘要可破解。破解過程:通過枚舉得到數字、英文字母組合的信息,然后計算相應的摘要,存儲到 map 中。只要服務器無限大,就能破解摘要。但代價很高,不法分子在利益和代價的權衡中,只能破解較簡單的信息的摘要。可以嘗試簡單/復雜信息的加/解密:
MD5在線加密/解密/破解—MD5在線https://www.sojson.com/encrypt_md5.html
(2)基礎加密思路? ? ? ?
????????基礎加密思路:可能用戶注冊時,使用了簡單密碼,容易被破解。但我們作為服務提供者,需要幫助用戶保護信息,增強密碼的復雜度。
(3)加鹽加密思路? ? ? ??
? ? ? ? 給用戶密碼加上一段隨機字符串,即加鹽,讓密碼更復雜,計算出來的摘要更難破解。為什么不用固定的鹽值?如果所有用戶密碼加上同一個鹽值,一旦黑客破解了這一個鹽值,就能破解所有簡單用戶的密碼了,因此每個密碼應加隨機鹽值,安全性更高。
? ? ? ? 既然是隨機的,那么就需要把隨機鹽值存到用戶表中,因為后續登錄也需要用該鹽值計算摘要對比是否與數據庫存儲的摘要相同。如果直接存放在表中的一個字段,顯然不行,這跟不加鹽沒區別。我們需要根據自定義的規則,將加鹽后的密碼摘要、鹽值摘要拼接,這個規則只有自己人知道:
(4)Spring 內置 MD5 工具使用
? ? ? ? 加密/解密工具包:
public class Md5Util {// 加鹽加密public static String md5(String password) {// 生成隨機鹽值// UUID 是唯一的標識,會生成帶"-"的字符串// 去掉"-"后,與 md5 加密后的摘要長度相同,無法分辨是鹽值還是摘要String salt = UUID.randomUUID().toString().replace("-", "");// md5(鹽值 + 明文)String md5 = DigestUtils.md5DigestAsHex((password + salt).getBytes(StandardCharsets.UTF_8));// 按自定義規則,拼接鹽值和摘要:鹽值 + 密文return salt + md5;}// 檢驗密碼是否正確public static Boolean checkPassword(String password, String sqlPassword) {// 密碼不能為空if (!StringUtils.hasLength(password)) {return false;}// 取出鹽值String salt = sqlPassword.substring(0, 32);// 取出數據庫摘要String md5 = sqlPassword.substring(32);// 生成輸入密碼的摘要String md5Input = DigestUtils.md5DigestAsHex((password + salt).getBytes(StandardCharsets.UTF_8));// 比較兩個摘要是否相同return md5.equals(md5Input);}
}
? ? ? ? service 登錄業務:
@Overridepublic String login(UserLoginRequest request) {UserInfo userInfo = userInfoMapper.selectOne(new LambdaQueryWrapper<UserInfo>().eq(UserInfo::getUserName, request.getUserName()).eq(UserInfo::getDeleteFlag, Constants.NOT_DELETE));// 校驗登錄信息if(userInfo == null) {throw new BlogException("用戶不存在");}
// if(!userInfo.getPassword().equals(request.getPassword())){
// throw new BlogException("密碼錯誤");
// }if(!Md5Util.checkPassword(request.getPassword(), userInfo.getPassword())) {throw new BlogException("密碼錯誤");}// 校驗正確,根據 userInfo 生成令牌return JwtUtil.generateToken(JwtUtil.convertMap(userInfo));}
? ? ? ? 補充:什么是 UUId? uuid 是唯一標識符,重復的概率很低,應用中,它可以用來標識未登陸的用戶。比如購物平臺,對于登陸的用戶,可以用 userId 來標識他,從而根據它的喜好推薦商品。但對于未登錄的用戶,也能推薦商品,就是靠 uuid,相當于 mac 地址根據設備標識。比如一個設備有一個 uuid,未登錄時會記錄搜索喜好;登陸時,也會把 userId 的喜好綁定給 uuid。