doc 4 5 6 8

This commit is contained in:
cuijiawang
2025-02-17 10:05:44 +08:00
parent f15073160f
commit 971bc89ee9
36 changed files with 8398 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
**友情提示** : 推荐使用**谷歌浏览器**来阅读本专栏,其他浏览器可能存在兼容性问题。
![](https://img.quanxiaoha.com/quanxiaoha/171394609501076)
本小节中,我们将通过 Docker 来快速搭建一个 MySQL 8.0 版本的数据库环境。
> **TIP** : 本文假设你本地已经搭建好了 Docker 环境,不清楚如何搭建的童鞋,可以翻阅上个项目《前后端分离博客》 的下面小节, 中间有讲解如何搭建 Docker 环境:
>
> [《本地开发环境搭建 、开发工具安装》](https://www.quanxiaoha.com/column/10000.html)
## 1\. 为什么要从 MySQL 5.7 升级到 8.0 版本?
[上个项目](https://www.quanxiaoha.com/column/10000.html) 中,我们使用的是 5.7 版本的 MySQL, 这次,我们将使用 MySQL 8.0 版本。
MySQL 8.0 相对于 5.7 版本带来了许多新特性和改进,这些改进涵盖了性能、安全性、可用性、功能性等方面。以下是 MySQL 8.0 相对于 5.7 的一些优势:
+ **JSON 支持的改进:** MySQL 8.0 提供了更加完善的 JSON 支持,包括 JSON 数据类型的完全支持、JSON 函数和操作的增强、JSON 路径表达式的支持等,使得在 MySQL 中处理 JSON 数据更加方便和高效。
+ **窗口函数:** MySQL 8.0 引入了窗口函数的支持,使得在 SQL 查询中可以更灵活地进行聚合和分析操作,例如在窗口内计算排名、累积和等。
+ **CTECommon Table Expressions** MySQL 8.0 支持公共表表达式CTE允许在查询中定义临时结果集并在后续查询中引用提高了查询的可读性和可维护性。
+ **更强大的安全性功能:** MySQL 8.0 引入了更多的安全性功能,包括密码策略、密码过期、密码历史记录、角色管理等,加强了对数据库的访问控制和身份验证。
+ **新的数据字典:** MySQL 8.0 引入了新的数据字典架构,将系统表重新组织为 InnoDB 存储引擎的表,提高了性能和可扩展性,并且降低了元数据操作的锁定竞争。
+ **更好的性能和优化:** MySQL 8.0 对查询优化器进行了改进,包括新的查询规划器、索引算法的改进、多版本并发控制等,提高了查询性能和并发处理能力。
+ **GIS 功能增强:** MySQL 8.0 增强了对地理信息系统GIS功能的支持包括新的地理数据类型、空间索引的改进、地理空间分析函数的增强等。
+ **事务管理的改进:** MySQL 8.0 引入了原子数据定义语句DDL的事务性允许将 DDL 操作作为一个事务提交或回滚,提高了数据库的可靠性和一致性。
+ **支持更多的 SQL 标准:** MySQL 8.0 增加了对SQL标准的支持包括窗口函数、CTE、空间数据类型等使得 MySQL 更加符合 SQL 标准,提高了跨平台的兼容性。
## 2\. 下载 MySQL 8.0 镜像
打开 PowerShell 命令行,执行如下命令:
```undefined
docker pull mysql:8.0
```
![](https://img.quanxiaoha.com/quanxiaoha/171394496366132)
拉取 MySQL 镜像完成后,执行如下命令,即可在本地镜像列表中看到下载好的 `8.0` 版本镜像了:
```undefined
docker images
```
![](https://img.quanxiaoha.com/quanxiaoha/171394503275896)
## 3\. 启动 MySQL 容器
有了镜像后,通过该镜像,来启动一个 MySQL 容器,执行如下命令:
```css
docker run -d --name mysql8.0 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql:8.0
```
![](https://img.quanxiaoha.com/quanxiaoha/171506285579642)
解释一下参数的含义:
+ `-d`:以后台的方式运行;
+ `--name mysql`:指定容器的名称为 `mysql8.0`;
+ `-p 3306:3306` : 将容器中的 3306 端口挂载到宿主机的 3306 端口上(前面是宿主机的端口号,后面是容器的端口号);
+ `-e MYSQL_ROOT_PASSWORD=123456`:指定 `root` 用户的密码为 123456;
> **注意** : 这里演示使用的密码较为简单,你也可以整一个安全性较高的密码。
## 4\. 查看容器是否启动成功
容器启动后,可通过执行如下命令来查看正在运行中的 Docker 容器:
```undefined
docker ps
```
![](https://img.quanxiaoha.com/quanxiaoha/171506291988718)
可以看到列表中有个 MySQL 8.0 的容器正在运行了。
## 5\. 通过工具连接数据库
这里小哈使用的 Navicat, 输入主机、端口、用户名、密码后,点击*测试连接*按钮,如果如下图所示, 看到提示*连接成功*
![](https://img.quanxiaoha.com/quanxiaoha/171394556414334)
则表示本地的 MySQL 8.0 数据库环境,搭建成功啦~

View File

@@ -0,0 +1,249 @@
![](https://img.quanxiaoha.com/quanxiaoha/169207196002211)
本小节中,我们继续完善项目的基础功能骨架 —— *为认证服务添加全局异常捕获、接口参数校验*,定义统一的代码规范,以方便后续更高效率的进行业务开发。
> **TIP** : 由于这两块的内容在[星球第一个项目](https://www.quanxiaoha.com/column/10000.html) 中,已经讲解过了,对于相关概念,细节不了解的小伙伴,可翻阅之前的内容,本节内容直接上手实操:
>
> + [《Spring Boot 实现全局异常管理》](https://www.quanxiaoha.com/column/10013.html)
> + [《全局异常处理器+参数校验(最佳实践)》](https://www.quanxiaoha.com/column/10014.html)
## 1\. 添加全局异常捕获
编辑 `xiaohashu-auth` 认证服务,如下图所示,分别添加 :
+ `/enums` :枚举包, 统一放置相关枚举类;
+ `/exception` : 异常包,放置异常相关的功能;
> 考虑到这块的功能和业务本身关联性比较强,所以没有单独提取到 `framework` 基础框架层,直接放在了业务项目内部。
![](https://img.quanxiaoha.com/quanxiaoha/171593048427863)
代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.enums;
import com.quanxiaoha.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("AUTH-10000", "出错啦,后台小哥正在努力修复中..."),
PARAM_NOT_VALID("AUTH-10001", "参数错误"),
// ----------- 业务异常状态码 -----------
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
> **TIP** : 针对各个微服务,每个服务的异常状态码可以带上服务名(具有唯一性),比如 `AUTH-10000` , 这样当某个接口报错时,能一样看出来异常是从哪个子服务抛出来的。
```java
package com.quanxiaoha.xiaohashu.auth.exception;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
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 java.util.Optional;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 捕获自定义业务异常
* @return
*/
@ExceptionHandler({ BizException.class })
@ResponseBody
public Response<Object> handleBizException(HttpServletRequest request, BizException e) {
log.warn("{} request fail, errorCode: {}, errorMessage: {}", request.getRequestURI(), e.getErrorCode(), e.getErrorMessage());
return Response.fail(e);
}
/**
* 捕获参数校验异常
* @return
*/
@ExceptionHandler({ MethodArgumentNotValidException.class })
@ResponseBody
public Response<Object> handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) {
// 参数错误异常码
String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();
// 获取 BindingResult
BindingResult bindingResult = e.getBindingResult();
StringBuilder sb = new StringBuilder();
// 获取校验不通过的字段,并组合错误信息,格式为: email 邮箱格式不正确, 当前值: '123124qq.com';
Optional.ofNullable(bindingResult.getFieldErrors()).ifPresent(errors -> {
errors.forEach(error ->
sb.append(error.getField())
.append(" ")
.append(error.getDefaultMessage())
.append(", 当前值: '")
.append(error.getRejectedValue())
.append("'; ")
);
});
// 错误信息
String errorMessage = sb.toString();
log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);
return Response.fail(errorCode, errorMessage);
}
/**
* 其他类型异常
* @param request
* @param e
* @return
*/
@ExceptionHandler({ Exception.class })
@ResponseBody
public Response<Object> handleOtherException(HttpServletRequest request, Exception e) {
log.error("{} request error, ", request.getRequestURI(), e);
return Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
}
```
### 1.1 自测一下
以上代码添加完毕后,编辑 `/test2` 接口,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171593059385635)
手动模拟一个运行时异常 —— *分母不能为 0* ,以测试全局异常捕获功能是否正常:
```cpp
int i = 1 / 0
```
重启认证服务,通过 Apipost 调试一波 `/test2` 接口:
![](https://img.quanxiaoha.com/quanxiaoha/171593007883130)
如上图所示,成功捕获到了异常,并返回了通用的异常状态码,以及友好的错误提示信息。
## 2\. 添加接口参数校验
![img](https://img.quanxiaoha.com/quanxiaoha/169208885048863)img
### 2.1 添加依赖
由于参数校验的依赖,是比较通用的,可以添加到 `xiaoha-common` 公共模块中:
![](https://img.quanxiaoha.com/quanxiaoha/171593099684624)
编辑其 `pom.xml` , 添加如下依赖:
```php-template
<!-- 入参校验 -->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
```
### 2.2 自测一下
依赖添加完毕后,重新刷一下 `maven` 依赖,然后,我们来自测一下参数校验功能是否好使。编辑 `xiaohashu-auth` 认证服务中的 `User` 用户类,为昵称字段添加`@NotBlank` 校验注解,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.controller;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {
/**
* 昵称
*/
@NotBlank(message = "昵称不能为空")
private String nickName;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
```
接着,为 `/test2` 接口的入参实体类,添加 `@Validated` 校验注解,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
@RestController
public class TestController {
// 省略...
@PostMapping("/test2")
@ApiOperationLog(description = "测试接口2")
public Response<User> test2(@RequestBody @Validated User user) {
int i = 1 / 0;
return Response.success(user);
}
}
```
重启项目,再次自测一波 `/test2` 接口,将 `nickName` 昵称字段值设置为空字符串:
![](https://img.quanxiaoha.com/quanxiaoha/171593039087243)
可以看到,注解形式的参数校验功能也是没有问题的~
## 本小节源码下载
[https://t.zsxq.com/PM4jv](https://t.zsxq.com/PM4jv)

View File

@@ -0,0 +1,465 @@
![](https://img.quanxiaoha.com/quanxiaoha/171507530321363)
本小节中,我们来为后端服务整合数据库持久层框架 —— **MyBatis** ,实现对数据库的增删改查。
## 1\. 什么是 MyBatis
MyBatis 是一个用 Java 编写的持久层框架,它简化了数据库交互的过程,将 SQL 语句与 Java 方法相映射,从而使得开发者能够更轻松地操作数据库。
MyBatis 的优点包括:
+ **简化的 SQL 语句:** MyBatis 允许开发者使用简单的 XML 文件或者注解来编写 SQL 语句,而不需要手动拼接 SQL 语句,大大简化了数据库操作的流程。
+ **灵活性:** MyBatis 提供了丰富的配置选项和灵活的映射方式,开发者可以根据需求自定义映射关系,适应各种复杂的业务场景。
+ **与 SQL 的紧密结合:** MyBatis 将 SQL 语句与 Java 代码紧密结合,开发者可以直观地理解代码的执行逻辑,同时也方便了 SQL 优化和调试。
+ **良好的性能:** MyBatis 通过预编译 SQL 语句和数据库连接池等机制,提升了数据库操作的性能,使得系统能够更高效地处理大量数据。
+ **与现有项目的兼容性:** MyBatis 不会对现有的项目结构和代码产生太大的影响,易于集成到已有的项目中,并且可以与其他框架(如 Spring无缝整合提高了开发效率。
总的来说MyBatis 是一个功能强大且易于使用的持久层框架,适用于各种规模的项目,能够帮助开发者简化数据库操作,提高开发效率。
## 2\. 新建库与表
为了等会演示通过 MyBatis 操作数据库,首先,我们先来建一个测试库。打开 Navicat, 连接上[之前小节](https://www.quanxiaoha.com/column/10249.html) 中搭建的 MySQL 8.0 数据库,*右键 | 新建数据库*
![](https://img.quanxiaoha.com/quanxiaoha/171506306696899)
填写相关选项,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171506319202211)
> + 填写新建的数据库名称;
>
> + 字符集选择 `utf8mb4`
>
> > **拓展** utf8mb4 是 utf8 的超集,支持更广泛的字符范围,包括一些不常见的表情符号、特殊符号以及辅助性的 Unicode 字符。这样可以确保你的数据库能够存储和处理各种语言的文本信息,而不会出现乱码或字符截断等问题。
>
> + 排序规则选择 `utf8mb4_unicode_ci` ,
>
数据库新建完成后,添加一张*用户测试表*,建表语句如下:
```sql
CREATE TABLE `t_user` (
`id` BIGINT (20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键id',
`username` VARCHAR (32) NOT NULL COMMENT '用户名',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = INNODB COMMENT = '用户测试表';
```
![](https://img.quanxiaoha.com/quanxiaoha/171506571897730)
## 3\. 整合 MyBatis
环境准备好了以后,准备开始为 `xiaohashu-auth` 认证服务整合 MyBatis 框架。编辑项目最外层的 `pom.xml` 声明 MySQL 驱动版本,以及 `mybatis-spring-boot-starter` , 代码如下:
```php-template
<properties>
// 省略...
<mysql-connector-java.version>8.0.29</mysql-connector-java.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- Mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql-connector-java.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
接着,编辑 `xiaohashu-auth` 服务的 `pom.xml` 引入上面的依赖:
```php-template
<dependencies>
// 省略...
<!-- Mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
```
别忘了最后刷新一下 Maven 依赖,将依赖包下载到本地仓库中:
![](https://img.quanxiaoha.com/quanxiaoha/171506849842132)
## 4\. 项目多环境配置
关于 Spring Boot 项目如何配置多环境,可翻阅星球第一个项目中的《[Spring Boot 项目多环境配置](https://www.quanxiaoha.com/column/10006.html) 》小节,这里不再赘述,直接上代码。
![](https://img.quanxiaoha.com/quanxiaoha/171506805069016)
> **注意**:这里在 `/resources` 目录下,新建一个 `/config` 配置文件夹,统一放置 `applicaiton` 多环境配置文件。
编辑 `application.yml` 父配置,内容如下:
```yaml
server:
port: 8080 # 项目启动的端口
spring:
profiles:
active: dev # 默认激活 dev 本地开发环境
```
编辑 `application-dev.yml` 本地开发环境配置文件,配置本地的数据库链接相关信息,如下:
```yaml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver # 指定数据库驱动类
# 数据库连接信息
url: jdbc:mysql://127.0.0.1:3306/xiaohashu?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai
username: root # 数据库用户名
password: 123456 # 数据库密码
```
> **TIP** : 数据库连接池暂时不做配置,直接用默认的,后续单独开一小节讲这一块。
## 5\. 新建相关文件夹
编辑 `xiaohashu-auth` 服务,创建以下文件夹:
+ `/domain/dataobject` : 用于统一放置 `DO` 类,对应数据库表;
+ `/domain/mapper` : 用于放置 `Mapper` 接口;
+ `/resources/mapper` : 用于放置 MyBatis `XML` 文件;
![](https://img.quanxiaoha.com/quanxiaoha/171506842520165)
## 6\. 配置 MyBatis
接着,在 `Application` 启动类的头部,添加 `@MapperScan` 注解,值填写 `mapper` 接口所处的包路径,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171506860637091)
```less
@SpringBootApplication
@MapperScan("com.quanxiaoha.xiaohashu.auth.domain.mapper")
public class XiaohashuAuthApplication {
public static void main(String[] args) {
SpringApplication.run(XiaohashuAuthApplication.class, args);
}
}
```
另外,编辑 `application.yml` , 为 MyBatis 配置 `xml` 文件所在位置:
![](https://img.quanxiaoha.com/quanxiaoha/171506867745596)
```yaml
mybatis:
# MyBatis xml 配置文件路径
mapper-locations: classpath:/mapper/**/*.xml
```
> 解释:`classpath`: 表示 `/resources` 目录; `/mapper/**/*.xml` 表示在 mapper 目录及其所有子目录下查找以 `.xml` 结尾的文件。这种表达式允许你指定一个包含通配符的路径模式,以方便地匹配多个文件。
## 7\. 新建 DO 类、 Mapper 接口、Xml 文件
文章开头的时候,我们创建了一个简单的 `t_user` 用户测试表,接下来,创建对应的 DO 类、Mapper 接口、Xml 文件,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171506942140296)
**UserDO 类:**
```java
package com.quanxiaoha.xiaohashu.auth.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDO {
private Long id;
private String username;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
```
**UserDOMapper 接口:**
```java
package com.quanxiaoha.xiaohashu.auth.domain.mapper;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
public interface UserDOMapper {
/**
* 根据主键 ID 查询
* @param id
* @return
*/
UserDO selectByPrimaryKey(Long id);
/**
* 根据主键 ID 删除
* @param id
* @return
*/
int deleteByPrimaryKey(Long id);
/**
* 插入记录
* @param record
* @return
*/
int insert(UserDO record);
/**
* 更新记录
* @param record
* @return
*/
int updateByPrimaryKey(UserDO record);
}
```
**UserDOMapper.xml 文件:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper">
<resultMap id="BaseResultMap" type="com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO">
<id column="id" jdbcType="BIGINT" property="id" />
<result column="username" jdbcType="VARCHAR" property="username" />
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime" />
</resultMap>
<select id="selectByPrimaryKey" parameterType="java.lang.Long" resultMap="BaseResultMap">
select * from t_user where id = #{id}
</select>
<delete id="deleteByPrimaryKey" parameterType="java.lang.Long">
delete from t_user where id = #{id}
</delete>
<insert id="insert" parameterType="com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO">
insert into t_user (username, create_time, update_time)
values (#{username}, #{createTime}, #{updateTime})
</insert>
<update id="updateByPrimaryKey" parameterType="com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO">
update t_user
set username = #{username},
create_time = #{createTime},
update_time = #{updateTime}
where id = #{id}
</update>
</mapper>
```
## 8\. MyBatis 插件安装
如果你的持久层框架是 MyBatis, 那么经常需要在 `Mapper` 接口与 `xml` 文件之间互相跳转,如果手动跳转,会非常影响开发效率。这里推荐一个 MyBatis 插件:*Free MyBatis Tool* , 可以从 IDEA 的插件市场来搜索并安装,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171506959520420)
安装完成后,在 `Mapper` 接口和 `xml` 文件中,就会出现对应的小箭头,点击箭头,即可快捷的跳转到对应的位置, 如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171506978255630)
> **TIP** : 类似的插件有很多,不一定需要和我用同一款,可自行选择,满足跳转需求即可。
## 9\. 配置 SQL 日志打印
开发中为了方便调试,想要打印实际执行的 SQL 语句, 可编辑 `application-dev.yml` 配置文件,配置 `mapper` 接口所在包的日志级别为 `debug` , 即可实现此功能:
```yaml
logging:
level:
com.quanxiaoha.xiaohashu.auth.domain.mapper: debug
```
![](https://img.quanxiaoha.com/quanxiaoha/171507050840570)
## 10\. 编写单元测试
以上内容均配置完毕后,我们来编辑 `XiaohashuAuthApplicationTests` 单元测试类,编写几个单元测试,来测试一下通过 MyBatis 来操作数据库好不好使:
![](https://img.quanxiaoha.com/quanxiaoha/171508495255927)
### 10.1 新增数据
单元测试方法如下:
```\
package com.quanxiaoha.xiaohashu.auth;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
@SpringBootTest
@Slf4j
class XiaohashuAuthApplicationTests {
@Resource
private UserDOMapper userDOMapper;
/**
* 测试插入数据
*/
@Test
void testInsert() {
UserDO userDO = UserDO.builder()
.username("犬小哈")
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.build();
userDOMapper.insert(userDO);
}
}
```
点击左侧的*运行按钮* 来运行此测试方法:
![](https://img.quanxiaoha.com/quanxiaoha/171507015505269)
观察控制台日志,确认没有任何报错,然后再查看 `t_user` 表,看看数据是否插入成功:
![](https://img.quanxiaoha.com/quanxiaoha/171507020314867)
### 10.2 查询数据
再编写一个查询数据的测试方法:
```java
package com.quanxiaoha.xiaohashu.auth;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
@SpringBootTest
@Slf4j
class XiaohashuAuthApplicationTests {
@Resource
private UserDOMapper userDOMapper;
// 省略...
/**
* 查询数据
*/
@Test
void testSelect() {
// 查询主键 ID 为 4 的记录
UserDO userDO = userDOMapper.selectByPrimaryKey(4L);
log.info("User: {}", JsonUtils.toJsonString(userDO));
}
}
```
如下图所示,实际执行的 SQL 成功被打印了出来,同时,查询出来的数据也是正确的:
![](https://img.quanxiaoha.com/quanxiaoha/171507062267581)
### 10.3 更新、删除数据
最后是更新、删除数据,这里直接贴具体代码,小伙伴们可自行测试一下,就不再截图了:
```csharp
// 省略...
/**
* 更新数据
*/
@Test
void testUpdate() {
UserDO userDO = UserDO.builder()
.id(4L)
.username("犬小哈教程")
.updateTime(LocalDateTime.now())
.build();
// 根据主键 ID 更新记录
userDOMapper.updateByPrimaryKey(userDO);
}
// 省略...
```
```csharp
// 省略...
/**
* 删除数据
*/
@Test
void testDelete() {
// 删除主键 ID 为 4 的记录
userDOMapper.deleteByPrimaryKey(4L);
}
// 省略...
```
## 11\. 结语
本小结中,我们为 `xiaohashu-auth` 认证服务整合完成了 MyBatis 持久层框架,最后,编写了增删改查共 4 个单元测试方法,成功通过 MyBatis 框架操作了数据库。
## 本小节源码下载
[https://t.zsxq.com/6WaAs](https://t.zsxq.com/6WaAs)

View File

@@ -0,0 +1,293 @@
[上小节](https://www.quanxiaoha.com/column/10262.html) 中,我们已经把 MyBatis 数据库持久层框架整合完成了,但是数据库连接池这块,还没有做配置。和[星球第一个项目](https://www.quanxiaoha.com/column/10000.html) 不同的是,这次的数据库连接池,我们将选型国内比较火的 —— *阿里开源的 Druid 德鲁伊*
## 1\. 为什么需要数据库连接池?
![](https://img.quanxiaoha.com/quanxiaoha/171524095073599)
**数据库连接池是一种用于管理数据库连接的技术**。在传统的数据库连接方式中,每次与数据库建立连接都需要经过一系列的网络通信和身份验证过程,这会消耗大量的系统资源和时间。而数据库连接池则通过预先创建一定数量的数据库连接并将其保存在池中,以供需要时复用,从而避免了重复建立和关闭连接的开销。
使用数据库连接池有如下优点:
+ **提高性能和效率**:数据库连接池可以复用已经建立的数据库连接,减少了每次连接数据库的开销,提高了系统的性能和响应速度。
+ **资源管理**:数据库连接池可以限制系统中同时存在的连接数量,防止数据库连接过多导致系统资源不足或性能下降。
+ **连接复用**:数据库连接池可以管理连接的生命周期,确保连接在需要时处于可用状态,并在不再需要时释放资源,从而减少了系统资源的浪费。
+ **连接池监控**:数据库连接池通常提供了监控和管理功能,可以实时监控连接的使用情况、连接的状态和性能指标,帮助管理员及时发现和解决问题。
## 2\. 什么是 Druid 连接池?
![](https://img.quanxiaoha.com/quanxiaoha/171523995762043)
**Druid 是阿里巴巴开源的一个高性能的数据库连接池**GitHub 地址:[https://github.com/alibaba/druid](https://github.com/alibaba/druid) 。它不仅提供了传统数据库连接池的连接管理功能还提供了一系列强大的监控和扩展功能。Druid 的优势主要体现在以下几个方面:
+ **高性能**Druid 是基于 Java 平台开发的,使用了高效的连接池算法和多线程技术,能够提供高性能的数据库连接管理服务。
+ **丰富的监控功能**Druid 提供了丰富的监控功能包括连接池状态监控、SQL 执行性能监控、SQL 执行分析等,可以实时监控数据库连接的使用情况和性能指标,并生成详细的报表和图表。
+ **安全性**Druid 内置了防 SQL 注入功能和黑名单功能,能够有效防止恶意 SQL 注入攻击和非法访问。
+ **灵活的配置**Druid 提供了丰富的配置选项,可以灵活地配置连接池的参数和行为,满足不同场景下的需求。
+ **可扩展性**Druid 提供了插件机制,支持自定义插件和扩展功能,开发人员可以根据需要自定义监控指标、扩展连接池的功能等。
+ **完善的文档和社区支持**Druid 有完善的官方文档和活跃的社区支持,开发人员可以方便地获取帮助和解决问题。
## 3\. 开始整合
### 3.1 添加依赖
编辑小哈书项目最外层的 `pom.xml` 声明 Druid 版本号以及依赖:
```php-template
// 省略...
<properties>
// 省略...
<druid.version>1.2.21</druid.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- Druid 数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>${druid.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
// 省略...
```
接着,编辑 `xiaohashu-auth` 认证服务,添加依赖:
```php-template
// 省略...
<dependencies>
// 省略...
<!-- Druid 数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
</dependency>
</dependencies>
// 省略...
```
依赖添加完毕后,别忘了点击 IDEA 右侧栏的 Reload 按钮,刷新一下 Maven 依赖,将包下载到本地仓库中。
### 3.2 连接池配置
然后就是配置连接池相关参数了,编辑 `application-dev.yml` 文件,先为本地开发环境配置一下,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171523660061331)
配置代码如下:
```yaml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver # 指定数据库驱动类
# 数据库连接信息
url: jdbc:mysql://127.0.0.1:3306/xiaohashu?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false&serverTimezone=Asia/Shanghai
username: root # 数据库用户名
password: 123456 # 数据库密码
type: com.alibaba.druid.pool.DruidDataSource
druid: # Druid 连接池
initial-size: 5 # 初始化连接池大小
min-idle: 5 # 最小连接池数量
max-active: 20 # 最大连接池数量
max-wait: 60000 # 连接时最大等待时间(单位:毫秒)
test-while-idle: true
time-between-eviction-runs-millis: 60000 # 配置多久进行一次检测,检测需要关闭的连接(单位:毫秒)
min-evictable-idle-time-millis: 300000 # 配置一个连接在连接池中最小生存的时间(单位:毫秒)
max-evictable-idle-time-millis: 900000 # 配置一个连接在连接池中最大生存的时间(单位:毫秒)
validation-query: SELECT 1 FROM DUAL # 配置测试连接是否可用的查询 sql
test-on-borrow: false
test-on-return: false
pool-prepared-statements: false
web-stat-filter:
enabled: true
stat-view-servlet:
enabled: true
url-pattern: /druid/* # 配置监控后台访问路径
login-username: admin # 配置监控后台登录的用户名、密码
login-password: admin
filter:
stat:
enabled: true
log-slow-sql: true # 开启慢 sql 记录
slow-sql-millis: 2000 # 若执行耗时大于 2s则视为慢 sql
merge-sql: true
wall: # 防火墙
config:
multi-statement-allow: true
```
> 解释一下上面各项配置,都是干啥的:
>
> 1. `type: com.alibaba.druid.pool.DruidDataSource`:指定使用 Druid 连接池。
> 2. `initial-size`:初始化连接池大小,即连接池启动时创建的初始化连接数。
> 3. `min-idle`:最小连接池数量,连接池中保持的最小空闲连接数。
> 4. `max-active`:最大连接池数量,连接池中允许的最大活动连接数。
> 5. `max-wait`:连接时最大等待时间,当连接池中的连接已经用完时,等待从连接池获取连接的最长时间,单位是毫秒。
> 6. `test-while-idle`:连接空闲时是否执行检查。
> 7. `time-between-eviction-runs-millis`:配置多久进行一次检测,检测需要关闭的连接,单位是毫秒。
> 8. `min-evictable-idle-time-millis`:一个连接在连接池中最小生存的时间,单位是毫秒。
> 9. `max-evictable-idle-time-millis`:一个连接在连接池中最大生存的时间,单位是毫秒。
> 10. `validation-query`:测试连接是否可用的查询 SQL。
> 11. `test-on-borrow`:连接从连接池获取时是否测试连接的可用性。
> 12. `test-on-return`:连接返回连接池时是否测试连接的可用性。
> 13. `pool-prepared-statements`:是否缓存 PreparedStatement默认为 false。
> 14. `web-stat-filter`:用于配置 Druid 的 Web 监控功能。在这里,`enabled` 表示是否开启 Web 监控功能。如果设置为 true就可以通过浏览器访问 Druid 的监控页面。
> 15. `stat-view-servlet`:配置 Druid 的监控后台访问路径、登录用户名和密码。
> + `enabled` 表示是否开启监控后台功能。
> + `url-pattern` 指定了监控后台的访问路径,即通过浏览器访问监控页面时的 URL。
> + `login-username` 和 `login-password` 分别指定了监控后台的登录用户名和密码,用于访问监控后台时的身份验证。
> 16. `filter`:用于配置 Druid 的过滤器,包括统计过滤器和防火墙过滤器。
> + `stat`:配置 Druid 的统计过滤器。`enabled` 表示是否开启统计功能,`log-slow-sql` 表示是否开启慢 SQL 记录,`slow-sql-millis` 指定了执行时间超过多少毫秒的 SQL 语句会被认为是慢 SQL`merge-sql` 表示是否开启 SQL 合并功能。
> + `wall`:配置 Druid 的防火墙过滤器。防火墙用于防止 SQL 注入攻击。在这里,`config` 配置了防火墙的规则,`multi-statement-allow` 表示是否允许执行多条 SQL 语句。
### 3.3 测试一波
上述配置完成后,我们来执行一下上小节的单元测试,看看加入连接池后,查询功能是否还是正常的:
![](https://img.quanxiaoha.com/quanxiaoha/171523685879450)
> **TIP** : 如果控制台日志中,有输出 `Init DruidDataSource` 信息,说明当前我们使用的数据库连接池,已经是 Druid 德鲁伊了。
### 3.4 监控后台
重启认证服务,访问地址:[http://localhost:8080/druid](http://localhost:8080/druid) ,即可登录 Druid 监控后台, 如下图所示,用户名和密码填写刚刚 `yml` 文件中手动配置的:
![](https://img.quanxiaoha.com/quanxiaoha/171523694932858)
登录成功后,就能看到各项监控信息了,有兴趣的小伙伴可以自己点点各个页面,探索探索:
![](https://img.quanxiaoha.com/quanxiaoha/171523721423466)
## 4\. 数据库密码加密
### 4.1 为什么配置文件中的密码需要加密?
数据库连接密码加密是为了增强系统的安全性。在配置文件中,明文存储数据库连接密码存在以下几个潜在风险:
+ **泄露风险:** 如果配置文件被不当地公开或者泄露,其中包含的数据库连接密码也会暴露给不可信的第三方,从而造成数据库的安全威胁。
+ **权限滥用:** 如果系统中的某个用户拥有访问配置文件的权限,那么他就可以直接获取到数据库连接密码。如果这个用户是不可信的,就有可能滥用这个权限,对数据库进行非法操作。
+ **审计追踪:** 明文存储密码会降低系统的审计追踪能力。一旦出现安全问题,无法准确追踪是谁在何时何地使用了数据库连接密码。
为了避免以上风险,我们可以采取数据库连接密码加密的方式。**加密后的密码可以在配置文件中存储,即使被泄露也不会直接暴露真实的密码,增加了攻击者破解密码的难度。**
### 4.2 Druid 内置工具加密密码
接下来,我们将通过 Druid 内置的密码加密工具 `ConfigTools`,来对明文密码进行加密处理。在 `xiaohashu-auth` 认证服务中,新建一个 `DruidTests` 单元测试,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171523770074333)
代码如下:
```typescript
package com.quanxiaoha.xiaohashu.auth;
import com.alibaba.druid.filter.config.ConfigTools;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@Slf4j
class DruidTests {
/**
* Druid 密码加密
*/
@Test
@SneakyThrows
void testEncodePassword() {
// 你的密码
String password = "123456";
String[] arr = ConfigTools.genKeyPair(512);
// 私钥
log.info("privateKey: {}", arr[0]);
// 公钥
log.info("publicKey: {}", arr[1]);
// 通过私钥加密密码
String encodePassword = ConfigTools.encrypt(arr[0], password);
log.info("password: {}", encodePassword);
}
}
```
> 解释一下上述代码:
>
> 1. `String password = "123456";`:定义了要加密的密码。
> 2. `String[] arr = ConfigTools.genKeyPair(512);`:调用 `ConfigTools` 类的 `genKeyPair` 方法生成 RSA 密钥对。RSA 是一种非对称加密算法,`512` 表示密钥长度为 512 位。
> 3. `log.info("privateKey: {}", arr[0]);` 和 `log.info("publicKey: {}", arr[1]);`:分别打印生成的私钥和公钥。私钥用于加密,公钥用于解密。
> 4. `String encodePassword = ConfigTools.encrypt(arr[0], password);`:调用 `ConfigTools` 类的 `encrypt` 方法,使用生成的私钥对密码进行加密。这里将生成的私钥和密码作为参数传入,返回加密后的密码。
> 5. `log.info("password: {}", encodePassword);`:打印加密后的密码。
运行该单元测试,控制台输入如下:
![](https://img.quanxiaoha.com/quanxiaoha/171523797036344)
### 4.3 配置加密后的密码
接下来,编辑 `applicaiton-dev.yml` 文件,配置密码加密相关配置,如下图标注所示:
![](https://img.quanxiaoha.com/quanxiaoha/171523831307111)
核心配置如下:
```yaml
spring:
datasource:
// 省略...
password: A2qT03X7KlL4v/F2foD6kV/Ch9gpNBWOh1qoCywanjv1AsI7f9x3iAyR9NkUKeV+FMo+halCTzy5Llbk2VOrVQ== # 数据库密码
type: com.alibaba.druid.pool.DruidDataSource
druid: # Druid 连接池
// 省略...
connectionProperties: config.decrypt=true;config.decrypt.key=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIaJmhsfN14oM+bghiOfARP6YgIiArekviyAOEa9Dt8spf4W38kSJShGs0NkzT3btqJB0O2o0X/yfVE8kqme1jMCAwEAAQ==
// 省略...
filter:
config:
enabled: true
// 省略...
```
> 解释一下上述配置项:
>
> 1. `password: A2qT03X7KlL4v/F2foD6kV/Ch9gpNBWOh1qoCywanjv1AsI7f9x3iAyR9NkUKeV+FMo+halCTzy5Llbk2VOrVQ==`:这里的密码改为加密后的密码。
> 2. `connectionProperties: config.decrypt=true;config.decrypt.key=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIaJmhsfN14oM+bghiOfARP6YgIiArekviyAOEa9Dt8spf4W38kSJShGs0NkzT3btqJB0O2o0X/yfVE8kqme1jMCAwEAAQ==`:这里配置了连接属性,其中 `config.decrypt=true` 表示开启密码解密功能,`config.decrypt.key` 是用于解密的密钥,即上面单元测试生成**公钥**。在 Druid 连接池中,如果我们的密码已经经过了加密处理,就需要在连接属性中配置解密相关的参数,以便 Druid 能够正确解密密码,然后连接到数据库。
> 3. `filter.config.enabled: true`:这里配置了 Druid 连接池的 `filter`,其中 `config` 是一个配置项,`enabled: true` 表示开启该配置项。这个配置项通常用于配置 Druid 连接池的一些额外功能,比如密码解密等。
Druid 加解密配置项搞定后,再次运行上小节中的单元测试方法,测试整体功能是否好使:
![](https://img.quanxiaoha.com/quanxiaoha/171523846102695)
可以看到,密码加密后,查询数据也是没有问题的,说明 Druid 加解密配置正确。至此,本小节我们就将 Druid 数据库连接池整合完毕啦~
## 本小节源码下载
[https://t.zsxq.com/5K2h0](https://t.zsxq.com/5K2h0)

View File

@@ -0,0 +1,261 @@
![](https://img.quanxiaoha.com/quanxiaoha/171532570563773)
在企业级后端项目开发中,每当迭代新功能时,通常都会先进行**表设计**、**接口设计**这一步,评审通过后,就正式进入了开发阶段,第一步就是为每张表创建对应的实体类、`Mapper` 接口,如果你使用的持久层框架是 MyBatis , 还需要额外创建 `XML` 映射文件。这些步骤都是模板式的,完全可以自动化生成相关代码,以提升开发效率
本小节中,小哈就将演示如何通过 `mybatis-generator-maven-plugin` 插件,来生成这些样板式的代码。
## 1\. mybatis-generator 是什么?
![](https://img.quanxiaoha.com/quanxiaoha/171532561574341)
`mybatis-generator-maven-plugin` 是一个 Maven 插件,用于生成 MyBatis 的代码(如 Mapper 接口、Mapper XML 文件等),官方文档地址: [https://mybatis.org/generator/](https://mybatis.org/generator/) 。它可以根据数据库表自动生成相应的 Java 实体类、Mapper 接口和 XML 映射文件,大大减少了手动编写这些重复且机械化的代码的工作量。以下是它的一些优点:
+ **自动化生成代码:** 可以根据数据库表结构自动生成与之对应的 Java 实体类、Mapper 接口和 XML 映射文件,省去了手动编写这些代码的繁琐过程。
+ **提高开发效率:** 通过自动生成代码,开发人员可以更专注于业务逻辑的实现,而不必花费大量时间在重复的 CRUD 操作上。
+ **保持数据一致性:** 自动生成的代码与数据库表结构保持一致,避免了手动编写代码时可能出现的字段名拼写错误或数据类型不匹配等问题。
+ **易于维护:** 自动生成的代码结构清晰,易于阅读和理解,便于后续的维护和修改。
+ **支持定制化配置:** 可以通过配置文件或插件参数对生成的代码进行定制,满足不同项目的需求。
## 2\. 开始整合
编辑小哈书项目最外层 `pom.xml` 声明 `mybatis-generator-maven-plugin` 插件的版本号以及依赖,代码如下:
```javascript
<properties>
// 省略...
<mybatis-generator-maven-plugin.version>1.3.5</mybatis-generator-maven-plugin.version>
// 省略...
</properties>
// 省略...
<build>
<!-- 统一插件管理 -->
<pluginManagement>
<plugins>
// 省略...
<!-- 代码生成器 -->
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>${mybatis-generator-maven-plugin.version}</version>
<configuration>
<!-- 允许移动生成的文件 -->
<verbose>true</verbose>
<!-- 允许覆盖生成的文件 -->
<overwrite>true</overwrite>
</configuration>
<!-- 此插件需要连接数据库所以需要依赖 MySQL 驱动 -->
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql-connector-java.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</pluginManagement>
</build>
```
接着,编辑 `xiaohashu-auth` 认证服务,在 `pom.xml` 文件中添加该插件:
```php-template
<build>
<plugins>
// 省略...
<!-- 代码生成器 -->
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
```
添加完成后,重新刷新一下 Maven 依赖。大概率还会爆红,暂时先不管,观察右侧栏,确认一下认证服务中 *Plugins* 下是否有该插件, 如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171530849570292)
## 3\. 添加配置文件
![](https://img.quanxiaoha.com/quanxiaoha/171530844268670)
插件添加完毕后,在 `/resources` 目录下,创建名为 `generatorConfig.xml` 的配置文件,内容如下:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
<context id="mysqlTables" targetRuntime="MyBatis3" defaultModelType="flat">
<!-- 自动检查关键字,为关键字增加反引号,如:`type` -->
<property name="autoDelimitKeywords" value="true"/>
<property name="beginningDelimiter" value="`"/>
<property name="endingDelimiter" value="`"/>
<!-- 指定生成的 Java 文件编码 -->
<property name="javaFileEncoding" value="UTF-8"/>
<!-- 对生成的注释进行控制 -->
<commentGenerator>
<!-- 由于此插件生成的注释不太美观,这里设置不生成任何注释 -->
<property name="suppressAllComments" value="true"/>
</commentGenerator>
<!-- 数据库链接 -->
<jdbcConnection driverClass="com.mysql.cj.jdbc.Driver"
connectionURL="jdbc:mysql://127.0.0.1:3306/xiaohashu"
userId="root"
password="123456">
<!-- 解决多个重名的表生成表结构不一致问题 -->
<property name="nullCatalogMeansCurrent" value="true"/>
</jdbcConnection>
<!-- 不强制将所有的数值类型映射为 Java 的 BigDecimal 类型 -->
<javaTypeResolver>
<property name="forceBigDecimals" value="false"/>
</javaTypeResolver>
<!-- DO 实体类存放路径 -->
<javaModelGenerator targetPackage="com.quanxiaoha.xiaohashu.auth.domain.dataobject"
targetProject="src/main/java"/>
<!-- Mapper xml 文件存放路径-->
<sqlMapGenerator targetPackage="mapper"
targetProject="src/main/resources"/>
<!-- Mapper 接口存放路径 -->
<javaClientGenerator type="XMLMAPPER" targetPackage="com.quanxiaoha.xiaohashu.auth.domain.mapper"
targetProject="src/main/java"/>
<!-- 需要生成的表-实体类 -->
<table tableName="t_user" domainObjectName="UserDO"
enableCountByExample="false"
enableUpdateByExample="false"
enableDeleteByExample="false"
enableSelectByExample="false"
selectByExampleQueryId="false"/>
</context>
</generatorConfiguration>
```
> 解释一下各项配置的含义:
>
> + `generatorConfiguration` 标签是根标签,用于定义整个配置文件的内容。
> + `context` 标签定义了一个上下文,用于指定生成代码的一些全局配置和规则。
> + `id` 属性指定了上下文的唯一标识符,这里是 `mysqlTables`。
> + `targetRuntime` 属性指定了生成代码的目标运行时环境,这里是 `MyBatis3`。
> + `defaultModelType` 属性指定了生成代码时默认的模型类型,这里是 `flat`,表示生成的实体类是扁平化的。
> + `property` 标签用于配置一些属性。
> + `autoDelimitKeywords` 属性设置为 `true` 表示自动检查关键字,为关键字增加反引号。
> + `beginningDelimiter` 和 `endingDelimiter` 属性指定了起始和结束的引号。
> + `javaFileEncoding` 属性指定了生成的 Java 文件的编码为 UTF-8。
> + `commentGenerator` 标签用于配置注释生成器,这里设置 `suppressAllComments` 属性为 `true` 表示不生成任何注释。
> + `jdbcConnection` 标签用于配置数据库连接信息,包括驱动类、连接 URL、用户名和密码。
> + `javaTypeResolver` 标签用于配置 Java 类型解析器,这里设置 `forceBigDecimals` 属性为 `false` 表示不强制将所有的数值类型映射为 Java 的 BigDecimal 类型。
> + `javaModelGenerator` 标签用于配置生成的 DO 实体类存放路径。
> + `sqlMapGenerator` 标签用于配置生成的 Mapper XML 文件存放路径。
> + `javaClientGenerator` 标签用于配置生成的 Mapper 接口存放路径。
> + `table` 标签用于指定需要生成的表和对应的实体类。
> + `tableName` 属性指定了数据库中的表名。
> + `domainObjectName` 属性指定了生成的实体类的名称。
> + `enableCountByExample`、`enableUpdateByExample`、`enableDeleteByExample`、`enableSelectByExample` 属性用于指定是否启用相关方法的生成。
> + `selectByExampleQueryId` 属性用于指定是否生成查询方法的 ID。
配置完成后,正常会看到下面标注的链接爆红,光标移动到上面,按 `ALT + 回车键` 点击 *Fetch external resource* 将资源下载下来, 这个时候,刚才 `pom.xml` 文件中插件爆红情况就消失了:
![](https://img.quanxiaoha.com/quanxiaoha/171530875508938)
## 4\. 生成 DO 实体类、Mapper 接口、XML 映射文件
一切准备就绪后,我们先将之前小节中,手动创建的 `t_user` 表对应的 DO 实体类、Mapper 接口、XML 映射文件都删除掉:
![](https://img.quanxiaoha.com/quanxiaoha/171530891155131)
点击右侧栏 `mybatis-generator` 插件的 `generate` 方法,开始生成:
![](https://img.quanxiaoha.com/quanxiaoha/171530904628627)
若在控制台看到 *BUILD SUCCESS* 信息,则表示生成成功了,小伙伴们可以到对应目录检验一下。
## 5\. 稍许修改
通过此插件生成的 `DO` 实体类,是没有使用 Lombok 注解的,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171530913707194)
小哈一般在生成完毕后,手动对 DO 实体类的代码修改一下,如下:
+ `get`、`set` 方法删除掉,添加上相关 `Lombok` 注解;
+ `Date` 日期类型,换成 `LocalDateTime` JDK 1.8 新的日期 API;
+ 其他类型的适配等等, 看情况而定;
> **TIP** : Mapper 接口和 XML 映射文件,则不需要做修改。
```java
package com.quanxiaoha.xiaohashu.auth.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDO {
private Long id;
private String username;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
```
再来看看生成的 Mapper 接口代码,会自动添加一下常用的增删改查方法,大致如下:
```csharp
package com.quanxiaoha.xiaohashu.auth.domain.mapper;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
public interface UserDOMapper {
int deleteByPrimaryKey(Long id);
int insert(UserDO record);
int insertSelective(UserDO record);
UserDO selectByPrimaryKey(Long id);
int updateByPrimaryKeySelective(UserDO record);
int updateByPrimaryKey(UserDO record);
}
```
总的来说通过此插件还是能提升不少开发效率的只需要在配置文件中配置对应的目标表即可生成相关的 DO 实体类Mapper 接口XML 映射文件后续也只需要稍微修改一下 DO 实体类即可
## 本小节源码下载
[https://t.zsxq.com/kEXJo](https://t.zsxq.com/kEXJo)

View File

@@ -0,0 +1,316 @@
![](https://img.quanxiaoha.com/quanxiaoha/169227108029256)
本小节中,我们将为认证服务自定义 Jackson 配置,以支持 Java 8 中新的日期 API 。这块的内容,在星球第一个项目中的 [3.13](https://www.quanxiaoha.com/column/10016.html) 节中有讲过,关于理论这块就不再赘述,不清楚的小伙伴可以翻阅一下,直接上手实操。
## 1\. 不支持 LocalDateTime 问题演示
![](https://img.quanxiaoha.com/quanxiaoha/171566306841432)
首先,我们来演示一个问题,假设我们想让 `/test2` 接口支持传入参数 `User` 实体类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
@RestController
public class TestController {
// 省略...
@PostMapping("/test2")
@ApiOperationLog(description = "测试接口2")
public Response<User> test2(@RequestBody User user) {
return Response.success(user);
}
}
```
并且,在入参 `User` 实体类中定义了一个 `LocalDateTime` 的日期类型,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.controller;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {
/**
* 昵称
*/
private String nickName;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
```
重启项目,通过 Apipost 来请求一下 `/test2` 接口, `JSON` 入参如下:
```json
{
"nickName" : "犬小哈",
"createTime": "2024-05-14 12:00:00"
}
```
效果如下图所示,可以看到报了一个 400 错误:
![](https://img.quanxiaoha.com/quanxiaoha/171566113343166)
再来看看后端控制台信息,有如下一行警告信息:
```lua
JSON parse error: Cannot deserialize value of type `java.time.LocalDateTime` from String "2024-05-14 12:00:00": Failed to deserialize java.time.LocalDateTime: (java.time.format.DateTimeParseException) Text '2024-05-14 12:00:00' could not be parsed at index 10]
```
> 提示我们 JSON 解析错误,无法将 `2024-05-14 12:00:00` 字符串解析为 `java.time.LocalDateTime` 日期类。
![](https://img.quanxiaoha.com/quanxiaoha/171566121265754)
## 2\. 自定义 Jackson 配置
为了解决上述问题,需要自定义 Jackson 配置类。创建 `/config` 配置包,将[星球第一个项目](https://www.quanxiaoha.com/column/10000.html) 中的 Jackson 配置类 `JacksonConfig` 直接复制过来:
![](https://img.quanxiaoha.com/quanxiaoha/171566142399011)
代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.YearMonthDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.YearMonthSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.TimeZone;
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
// 初始化一个 ObjectMapper 对象,用于自定义 Jackson 的行为
ObjectMapper objectMapper = new ObjectMapper();
// 忽略未知属性
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
// 设置凡是为 null 的字段,返参中均不返回,请根据项目组约定是否开启
// objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
// 设置时区
objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
// JavaTimeModule 用于指定序列化和反序列化规则
JavaTimeModule javaTimeModule = new JavaTimeModule();
// 支持 LocalDateTime、LocalDate、LocalTime
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
// 支持 YearMonth
javaTimeModule.addSerializer(YearMonth.class, new YearMonthSerializer(DateTimeFormatter.ofPattern("yyyy-MM")));
javaTimeModule.addDeserializer(YearMonth.class, new YearMonthDeserializer(DateTimeFormatter.ofPattern("yyyy-MM")));
objectMapper.registerModule(javaTimeModule);
return objectMapper;
}
}
```
以上配置类添加完成后,重启项目再次测试接口,出入参实体类中就能支持定义 LocalDateTime 日期 API 了。
## 3\. 重复定义 JavaTimeModule 问题
但是,现在有个问题,在之前 `xiaoha-common` 公共模块中,我们封装了一个 `JsonUtils` 工具类,里面的序列化、反序列是有重复定义的,*怎么能统一复用上面的呢?* 这样就不用适配多处了,代码看起来也优雅很多。
![](https://img.quanxiaoha.com/quanxiaoha/171566462317192)
为了解决上面提到的问题,我们可以在 `JsonUtils` 工具类中,定义一个 `init()` 初始化方法,代码如下:
```java
package com.quanxiaoha.framework.common.util;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.quanxiaoha.framework.common.constant.DateConstants;
import lombok.SneakyThrows;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-08-14 16:27
* @description: JSON 工具类
**/
public class JsonUtils {
private static ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static {
OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
OBJECT_MAPPER.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
OBJECT_MAPPER.registerModules(new JavaTimeModule()); // 解决 LocalDateTime 的序列化问题
}
/**
* 初始化:统一使用 Spring Boot 个性化配置的 ObjectMapper
*
* @param objectMapper
*/
public static void init(ObjectMapper objectMapper) {
OBJECT_MAPPER = objectMapper;
}
// 省略...
}
```
> 核心修改的地方如下:
>
> + 将之前适配 `LocalDateTime` 的相关代码删除;
> + 静态的 `OBJECT_MAPPER` 类去除 `final` 不可变修饰;
> + 定义一个静态的 `init()` 初始化方法,入参为 `ObjectMapper` 类,以对 `OBJECT_MAPPER` 静态变量进行赋值覆盖;
接着,编辑 `JacksonConfig` 配置类,在最后主动调用 `JsonUtils.init()` 方法即可,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171566163572750)
完成上述修改后,重启项目,再次测试一下 `/test2` 接口,可以看到功能正常:
![](https://img.quanxiaoha.com/quanxiaoha/171566170107697)
并且切面日志中,调用 `JsonUtils.toJsonStr()` 方法打印 `LocalDateTime`,也是按指定的日期格式来打印的:
![](https://img.quanxiaoha.com/quanxiaoha/171566528077440)
## 4\. 提取全局日期格式化常量
最后,我们再来优化一下代码。在 `JacksonConfig` 配置类中,创建了很多 `DateTimeFormatter` , 这种日期格式化,后续业务中也会经常被用到,完全可以提取到全局常量中去:
![](https://img.quanxiaoha.com/quanxiaoha/171566180111580)
编辑 `xiaoha-common` 通用模块中的 `DateConstants` 日期常量接口,修改代码如下:
```java
package com.quanxiaoha.framework.common.constant;
import java.time.format.DateTimeFormatter;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2024/5/5 15:40
* @description: 日期全局常量
**/
public interface DateConstants {
/**
* DateTimeFormatter年-月-日 时:分:秒
*/
DateTimeFormatter DATE_FORMAT_Y_M_D_H_M_S = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* DateTimeFormatter年-月-日
*/
DateTimeFormatter DATE_FORMAT_Y_M_D = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/**
* DateTimeFormatter
*/
DateTimeFormatter DATE_FORMAT_H_M_S = DateTimeFormatter.ofPattern("HH:mm:ss");
/**
* DateTimeFormatter年-月
*/
DateTimeFormatter DATE_FORMAT_Y_M = DateTimeFormatter.ofPattern("yyyy-MM");
}
```
`DateTimeFormatter` 全局常量类都抽取出来后,再来重构一下 `JacksonConfig` 配置类,代码如下,清爽多了:
```php
// 省略...
// 支持 LocalDateTime、LocalDate、LocalTime
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateConstants.DATE_FORMAT_Y_M_D_H_M_S));
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateConstants.DATE_FORMAT_Y_M_D));
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateConstants.DATE_FORMAT_Y_M_D));
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateConstants.DATE_FORMAT_H_M_S));
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateConstants.DATE_FORMAT_H_M_S));
// 支持 YearMonth
javaTimeModule.addSerializer(YearMonth.class, new YearMonthSerializer(DateConstants.DATE_FORMAT_Y_M));
javaTimeModule.addDeserializer(YearMonth.class, new YearMonthDeserializer(DateConstants.DATE_FORMAT_Y_M));
// 省略...
```
至此,认证服务的自定义 `Jackson` 配置就完成了,后续我们就可以在接口出入参实体类中,随性的使用 Java 8 新的日期 API 了,舒服~
## 5\. 最后一点思索~
![](https://img.quanxiaoha.com/quanxiaoha/171567099534785)
最后,再来思索一下,看似功能是没有问题,但是在认证服务的 `JacksonConfig` 配置类中,手动调用框架层中 `xiaoha-common`通用模块的 `JsonUtils.init()` 方法,已经是有**代码侵入**了,业务部门重点应关注本身业务,而不是还想着怎么来初始化框架层的 `objectMapper` 对象, 所以,自定义 Jackson 配置完成抽取一个 `starter` 出来,以组件的形式提供给业务线使用更为优雅。
## 6\. 小作业:将自定义的 Jackson 封装成 starter
留个小作业,小伙伴们可以参考之前小节的自定义 `starter`,尝试自己动手来重构一下项目。当然,过程中碰到问题,也可以下载本小节源码参考一下,源码中是已经封装好了的。
![](https://img.quanxiaoha.com/quanxiaoha/171567133119788)
## 本小节源码下载
[https://t.zsxq.com/d3p7Q](https://t.zsxq.com/d3p7Q)

View File

@@ -0,0 +1,234 @@
![](https://img.quanxiaoha.com/quanxiaoha/169149908670746)
在构建任何应用程序时,良好的日志管理都是必不可少的。**日志可以帮助我们监控、调试和跟踪代码的运行情况。** 本小节中,我们继续完善 `xiaohashu-auth` 认证服务的项目骨架,为其整合 `Logback` 日志框架,并将配置日志的异步写入文件,以提升应用性能。
> **TIP** : 关于 Logback 理论介绍部分,可翻阅星球第一个项目 [3.5 小节](https://www.quanxiaoha.com/column/10008.html) ,这里不再赘述,直接上手实操。
## 1\. 添加日志配置文件
编辑 `xiaohashu-auth` 认证服务,在 `/resources` 资源目录下,创建名为 `logback-spring.xml` 日志配置文件:
![](https://img.quanxiaoha.com/quanxiaoha/171576199599229)
文件内容如下:
```php-template
<configuration>
<!-- 引用 Spring Boot 的 logback 基础配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<!-- 应用名称 -->
<property scope="context" name="appName" value="auth"/>
<!-- 自定义日志输出路径,以及日志名称前缀 -->
<property name="LOG_FILE" value="./logs/${appName}.%d{yyyy-MM-dd}"/>
<!-- 每行日志输出的格式 -->
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/>
<!-- 文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 日志文件的命名格式 -->
<fileNamePattern>${LOG_FILE}-%i.log</fileNamePattern>
<!-- 保留 30 天的日志文件 -->
<maxHistory>30</maxHistory>
<!-- 单个日志文件最大大小 -->
<maxFileSize>10MB</maxFileSize>
<!-- 日志文件的总大小0 表示不限制 -->
<totalSizeCap>0</totalSizeCap>
<!-- 重启服务时,是否清除历史日志,不推荐清理 -->
<cleanHistoryOnStart>false</cleanHistoryOnStart>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 本地 dev 开发环境 -->
<springProfile name="dev">
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<root level="INFO">
<appender-ref ref="CONSOLE"/> <!-- 输出控制台日志 -->
<appender-ref ref="FILE"/> <!-- 打印日志到文件中。PS: 本地环境下,如果不想打印日志到文件,可注释掉此行 -->
</root>
</springProfile>
<!-- 其它环境 -->
<springProfile name="prod">
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<root level="INFO">
<appender-ref ref="FILE"/> <!-- 生产环境下,仅打印日志到文件中 -->
</root>
</springProfile>
</configuration>
```
> 说一下日志配置文件中,每项配置都是干啥的:
>
> + **基础配置和属性定义**
>
> ```makefile
> <include resource="org/springframework/boot/logging/logback/defaults.xml" />
> ```
>
> 上述配置用于引用 Spring Boot 的默认 Logback 基础配置。
>
> ```perl
> <property scope="context" name="appName" value="auth"/>
> <property name="LOG_FILE" value="./logs/${appName}.%d{yyyy-MM-dd}"/>
> <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/>
> ```
>
> + 定义了一些全局属性:
>
> + `appName`:应用名称,这里值填写为 `auth` ,表示认证服务。
>
> + `LOG_FILE`:日志文件的路径和文件名模板, `./logs` 表示输出到项目的同级目录下的 `/logs` 文件夹下。
>
> + `LOG_PATTERN`:日志输出格式。
>
> + **日志文件 Appender 配置**
>
>
> ```php-template
> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
> <fileNamePattern>${LOG_FILE}-%i.log</fileNamePattern>
> <maxHistory>30</maxHistory>
> <maxFileSize>10MB</maxFileSize>
> <totalSizeCap>0</totalSizeCap>
> <cleanHistoryOnStart>false</cleanHistoryOnStart>
> </rollingPolicy>
> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
> <pattern>${LOG_PATTERN}</pattern>
> <charset>UTF-8</charset>
> </encoder>
> </appender>
> ```
>
> + `appender`:用于将日志输出到文件,并且使用滚动策略来管理日志文件。
>
> + `rollingPolicy`:定义了日志滚动策略,使用 `SizeAndTimeBasedRollingPolicy` 以时间和大小为基准进行滚动。
>
> + `fileNamePattern`:定义了日志文件的命名模式。
>
> + `maxHistory`:保留 30 天的日志文件。
>
> + `maxFileSize`:每个日志文件最大 10MB。
>
> + `totalSizeCap`:总日志文件大小没有限制。
>
> + `cleanHistoryOnStart`:项目启动时不清理历史日志文件。
>
> + `encoder`:定义了日志的输出格式,以及文件编码格式。
>
> + **Spring Profile 配置**:用于配置各环境的日志行为。这里主要定义了 `dev` 和 `prod` 两个环境:
>
>
> ```php-template
> <springProfile name="dev">
> <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
> <root level="INFO">
> <appender-ref ref="CONSOLE"/>
> <appender-ref ref="FILE"/>
> </root>
> </springProfile>
> ```
>
> `dev` 本地开发环境中,包含控制台输出 `CONSOLE` 和文件输出 `FILE`。`CONSOLE` 配置通过包含 Spring Boot 默认的 `console-appender.xml` 实现。
>
> ```php-template
> <springProfile name="prod">
> <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
> <root level="INFO">
> <appender-ref ref="FILE"/>
> </root>
> </springProfile>
> ```
>
> `prod` 生产环境中,仅包含文件输出 `FILE`,不输出到控制台。这是为了生产环境中减少控制台日志输出,避免影响性能。
>
> > **拓展小知识** : 如果你想同时设置多个环境,假设咱们除了本地开发环境、生产环境外,还有个 `test` 测试环境, 也仅需要输出日志到文件。则可以配置如下,通过逗号 `,` 分隔开来就行:
> >
> > ```php-template
> > <springProfile name="test,prod">
> > // 省略...
> > </springProfile>
> > ```
## 2\. 测试看看效果
因为我们上面通过 `springProfile` , 配置了 `dev` 开发环境中,打印日志到文件中。接下来,重启项目,实测一下看看功能是否正常:
![](https://img.quanxiaoha.com/quanxiaoha/171576233844388)
项目启动成功后,如上图所示,进入到项目的 `/logs` 文件夹下,可以看到日志输出是 ok 的。
## 3\. 异步日志
异步打印日志Asynchronous Logging是一种日志记录方式它将日志写入操作放在一个单独的线程中执行而不是在主线程中进行。这意味着日志写入的过程不会阻塞主线程的执行主线程可以继续执行其余的业务逻辑增强了应用的性能和响应速度。
### 3.1 为什么需要异步打印日志
+ **性能提升**:同步日志记录在高并发情况下会显著影响应用性能,因为每一次日志写入操作都可能导致磁盘 I/O 操作,主线程必须等待这些操作完成才能继续执行。异步日志记录将这些操作放在单独的线程中进行,避免了主线程的阻塞,提高了整体性能。
+ **响应时间**:异步日志记录可以减少应用的响应时间,尤其是在需要记录大量日志信息的时候。用户请求得到快速响应,而日志记录在后台处理。
+ **资源利用**:通过异步日志记录,应用可以更有效地利用 CPU 资源。同步日志记录可能导致线程频繁等待 I/O 操作完成,而异步记录可以让这些线程去执行其他任务,提高资源利用率。
+ **系统稳定性**:在极端情况下(例如,日志量非常大时),同步日志记录可能会导致应用出现性能瓶颈甚至崩溃。异步日志记录通过缓冲和队列机制,能够更好地应对突发的大量日志请求,增强系统稳定性。
### 3.2 Logback 配置异步日志
Logback 提供了 `AsyncAppender` 来支持异步日志记录。通过 `AsyncAppender` 可以将日志事件发送到一个队列中,并由一个独立的线程池来处理这些日志事件。编辑 `logback-spring.xml` 文件,添加配置如下:
```php-template
// 省略...
<!-- 文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
// 省略...
</appender>
<!-- 异步写入日志,提升性能 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<!-- 是否丢弃日志, 0 表示不丢弃。默认情况下,如果队列满 80%, 会丢弃 TRACE、DEBUG、INFO 级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 队列大小。默认值为 256 -->
<queueSize>256</queueSize>
<appender-ref ref="FILE"/>
</appender>
<!-- 本地 dev 开发环境 -->
<springProfile name="dev">
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<root level="INFO">
<appender-ref ref="CONSOLE"/> <!-- 输出控制台日志 -->
<appender-ref ref="ASYNC_FILE"/> <!-- 打印日志到文件中。PS: 本地环境下,如果不想打印日志到文件,可注释掉此行 -->
</root>
</springProfile>
<!-- 其它环境 -->
<springProfile name="prod">
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<root level="INFO">
<appender-ref ref="ASYNC_FILE"/> <!-- 生产环境下,仅打印日志到文件中 -->
</root>
</springProfile>
// 省略...
```
> 解释一下修改的地方,主要添加了一个名称为 `ASYNC_FILE` 异步输出日志的 `Appender`
>
> + `AsyncAppender` 使用内部队列来异步处理日志事件。
> + `queueSize`:队列的大小。
> + `discardingThreshold`:是否丢弃日志, 0 表示不丢弃。
>
> 最后,将各个环境中的 `FILE` 更改为 `ASYNC_FILE` 异步写入日志。别忘了,再次重启一下项目,自测一波日志功能是否好使~
## 本小节源码下载
[https://t.zsxq.com/Um9Gd](https://t.zsxq.com/Um9Gd)

View File

@@ -0,0 +1,162 @@
本小节中,我们来解决 Maven 多模块工程中,如果在父 `pom` 中定义了统一版本号 `revision` ,单独对某个子模块执行 `clean package` 打包失败的问题。
![](https://img.quanxiaoha.com/quanxiaoha/171584803657734)
其实,这个问题在[星球第一个项目](https://www.quanxiaoha.com/column/10000.html) 中,就有小伙伴问过我,我当时给的回复是,多模块工程需要针对父 `pom` 进行打包,这种解决方式,针对一个单体项目是合适的。但是,针对微服务多模块工程,每个服务都需要单独打 `Jar` 包,而整个应用又划分了很多个的微服务,当我想对其中某个服务打包时,如果对父 `pom` 打包,所有服务都会打包一次,*那岂不是我要等半天?* 显然是不合适的。
## 1\. 复现子模块打包失败问题
接下来,我们来亲自感受一下这个问题。首先,对父类 `pom` 执行 `clean package` 打包命令,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171584764087524)
可以看到,控制台中提示 `BUILD SUCCESS` , 项目构建成功,进入到认证服务的 `/target` 目录下,确实是打包成功了:
![](https://img.quanxiaoha.com/quanxiaoha/171584770094565)
接下来,打开 IDEA 右侧栏,对 `xiaohashu-auth` 模块单独进行打包:
![](https://img.quanxiaoha.com/quanxiaoha/171585310137580)
恭喜你,控制台会获得如下报错信息:
```csharp
[INFO] Scanning for projects...
[INFO]
[INFO] -------------------< com.quanxiaoha:xiaohashu-auth >--------------------
[INFO] Building xiaohashu-auth 0.0.1-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
Downloading from huaweicloud: https://mirrors.huaweicloud.com/repository/maven/com/quanxiaoha/xiaoha-framework/$%7Brevision%7D/xiaoha-framework-$%7Brevision%7D.pom
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 0.776 s
[INFO] Finished at: 2024-05-16T16:22:17+08:00
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal on project xiaohashu-auth: Could not resolve dependencies for project com.quanxiaoha:xiaohashu-auth:jar:0.0.1-SNAPSHOT: Failed to collect dependencies at com.quanxiaoha:xiaoha-common:jar:0.0.1-SNAPSHOT: Failed to read artifact descriptor for com.quanxiaoha:xiaoha-common:jar:0.0.1-SNAPSHOT: The following artifacts could not be resolved: com.quanxiaoha:xiaoha-framework:pom:${revision} (absent): Could not transfer artifact com.quanxiaoha:xiaoha-framework:pom:${revision} from/to huaweicloud (https://mirrors.huaweicloud.com/repository/maven/): status code: 400, reason phrase: (400) -> [Help 1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/DependencyResolutionException
Process finished with exit code 1
```
查看上面报错信息,提示无法读取公共模块的依赖:`com.quanxiaoha:xiaoha-common:jar:0.0.1-SNAPSHOT` 。这就奇怪了,之前我们对父 `pom` 执行过 `clean install` 命令,已经将公共模块打包到本地仓库了,进入本地仓库,对应的 `jar` 包也确实是有的,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171584796587742)
## 2\. 打包失败原因
继续挖掘报错信息,看看能不能获取更有用的提示。可以看到下面的内容:
```bash
Could not transfer artifact com.quanxiaoha:xiaoha-framework:pom:${revision} from/to huaweicloud (https://mirrors.huaweicloud.com/repository/maven/)
```
> 提示我们无法从中央仓库下载 `com.quanxiaoha:xiaoha-framework:pom:${revision}` 。*版本号不对劲 * 怎么是 `${revision}` !!!
![](https://img.quanxiaoha.com/quanxiaoha/171584803657734)
> **问题原因**:在多模块项目中,如果使用到 `revision` 占位符进行版本号管理。此时,如果单独打包子项目时,是不能将 `${revision}` 替换成父 `pom` 中的版本号的,最终打包时,就会提示找不到依赖。
## 3\. 引入 flatten-maven-plugin 插件
### 3.1 什么是 flatten-maven-plugin 插件?
`flatten-maven-plugin` 将项目的 `pom.xml` 文件转换成一个更简单的扁平版本,包含消费者所需的关键信息。这个扁平的 POM 文件会去除构建相关的配置和不必要的细节,留下一个更干净、简单的 POM便于理解和管理。
使用该插件有如下优势:
+ **简化 POM 文件** 扁平化后的 POM 去除了构建插件、配置文件等构建过程中的不必要细节,使其更简单、更易于下游项目消费。
+ **提高可重复性** 通过扁平化,确保消费者获得一致且可重复的项目依赖和元数据,避免构建时的变异。
+ **减少大小和复杂性** 该插件有助于减少 POM 文件的大小和复杂性,便于理解和排除故障。对于包含复杂构建配置的大型项目尤其有用。
+ **优化分发** 在将项目分发到 Maven 中央仓库或其他仓库时,扁平化 POM 确保只包含必要的信息,避免由于构建时配置导致的潜在问题。
### 3.2 开始整合
编辑项目最外层的 `pom.xml` 文件,声明 `flatten-maven-plugin` 版本号并添加该插件:
```xml
<properties>
// 省略...
<flatten-maven-plugin.version>1.5.0</flatten-maven-plugin.version>
// 省略...
</properties>
// 省略...
<build>
<!-- 统一插件管理 -->
<pluginManagement>
<plugins>
// 省略...
</plugins>
</pluginManagement>
<plugins>
<!-- 统一 revision 版本, 解决子模块打包无法解析 ${revision} 版本号问题 -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>${flatten-maven-plugin.version}</version>
<configuration>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
<updatePomFile>true</updatePomFile>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals>
<goal>flatten</goal>
</goals>
</execution>
<execution>
<id>flatten.clean</id>
<phase>clean</phase>
<goals>
<goal>clean</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
```
> **注意**:这里 `flatten-maven-plugin` 是定义在 `pluginManagement` 节点外的,子模块无需再手动引入,直接让其全局生效。
插件添加完毕后,再次对父 `pom` 执行打包:
![](https://img.quanxiaoha.com/quanxiaoha/171584856390281)
可以看到,对应各模块 `pom.xml` 文件的同级目录下,额外生成了一个 `.flattened-pom.xml` 文件,打开该文件看一下,可以看到 `${revision}` 被替换成了实际的版本号:
![](https://img.quanxiaoha.com/quanxiaoha/171584862210450)
### 3.3 测试一波
再次对 `xiaohashu-auth` 子模块进行打包,`maven` 就会解析 `.flattened-pom.xml` 文件进行打包,可以看到,这次就打包成功了:
![](https://img.quanxiaoha.com/quanxiaoha/171584875133512)
认证服务子模块的 `/targets` 文件下打包文件也是有的:
![](https://img.quanxiaoha.com/quanxiaoha/171584881644787)
至此,多模块项目中,无法对子模块单独打包的问题,也就解决了~
## 本小节源码下载
[https://t.zsxq.com/kCnCF](https://t.zsxq.com/kCnCF)

View File

@@ -0,0 +1,72 @@
上一章中,我们已经将小哈书项目的基础骨架搭建完成了。本章中,正式进入到第一个业务模块的开发 —— *用户认证*
## 1\. 什么是用户认证?
**用户认证就是指用户登录的意思,是一个用户身份验证过程,确保用户提供的凭据(如用户名和密码)与系统中存储的数据匹配,从而允许用户访问系统。**
## 2\. 原型图分析
以下截图自小红书 APP 的登录界面:
![](https://img.quanxiaoha.com/quanxiaoha/171465023756832)
> 可以看到小红书 APP 支持的登录方式比较多,有微信授权登录、手机号登录、其他第三方平台授权登录。**不过,现阶段我们只实现手机号方式登录。**
想要实现用户登录,数据库层面,则必然需要一张**用户表**,用于存储用户的账号,小红书则是使用手机号充当账号。另外,用户表中还需要存储额外的一些信息,如下图所示,编辑用户资料,还有头像、昵称、小红书号等等:
![](https://img.quanxiaoha.com/quanxiaoha/171601130529790)
## 3\. 表设计
通过原型图,我们来设计一下 `t_user` 用户表, 建表语句如下:
```sql
CREATE TABLE `t_user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`xiaohashu_id` varchar(15) NOT NULL COMMENT '小哈书号(唯一凭证)',
`password` varchar(64) DEFAULT NULL COMMENT '密码',
`nickname` varchar(24) NOT NULL COMMENT '昵称',
`avatar` varchar(120) DEFAULT NULL COMMENT '头像',
`birthday` date DEFAULT NULL COMMENT '生日',
`background_img` varchar(120) DEFAULT NULL COMMENT '背景图',
`phone` varchar(11) NOT NULL COMMENT '手机号',
`sex` tinyint DEFAULT '0' COMMENT '性别(0女 1男)',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态(0启用 1禁用)',
`introduction` varchar(100) DEFAULT NULL COMMENT '个人简介',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除(0未删除 1已删除)',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_xiaohashu_id` (`xiaohashu_id`),
UNIQUE KEY `uk_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
```
> 解释一下以上语句:
>
> **列:**
>
> + `id`:自增主键,类型为无符号大整数(`bigint unsigned`),自动递增(`AUTO_INCREMENT`)。
> + `xiaohashu_id`:用户的小哈书号(`varchar(15)`),不允许为空(`NOT NULL`),是用户的唯一标识,默认由系统自动生成,后续用户也可以手动修改,类似于微信号。
> + `password`:用户密码(`varchar(64)`),默认为空;
> + `nickname`:用户昵称(`varchar(24)`),不允许为空,初次注册时,系统会默认生成一个昵称,比如:*小红薯001*。
> + `avatar`:用户头像的 URL`varchar(120)`),默认为空。
> + `birthday`:用户生日(`date`),默认为空。
> + `background_img`:用户背景图的 URL`varchar(120)`),默认为空。
> + `phone`:用户手机号(`varchar(11)`),不允许为空。
> + `sex`:用户性别(`tinyint`),默认为 00 表示女1 表示男)。
> + `status`:用户状态(`tinyint`),默认为 00 表示启用1 表示禁用)。
> + `introduction`:个人简介(`varchar(100)`),默认为空。
> + `create_time`:记录创建时间(`datetime`),默认值为当前时间(`CURRENT_TIMESTAMP`),不允许为空。
> + `update_time`:记录更新时间(`datetime`),默认值为当前时间(`CURRENT_TIMESTAMP`),不允许为空。
> + `is_deleted`:逻辑删除标志(`bit(1)`),默认为 00 表示未删除1 表示已删除)。
>
> **索引与约束:**
>
> + `PRIMARY KEY (id) USING BTREE`:主键为 `id` 列,使用 B+Tree 进行索引。
> + `UNIQUE KEY uk_xiaohashu_id (xiaohashu_id)`:唯一索引,对 `xiaohashu_id` 列设置,确保每个小哈书号唯一性,同时该列也是后续业务逻辑中常用的查询条件,添加索引以提升查询性能。
> + `UNIQUE KEY uk_phone (phone)`:唯一索引,对 `phone` 列设置,确保每个手机号唯一,同时该列也是后续业务逻辑中常用的查询条件,添加索引以提升查询性能。
表设计完成后,别忘了连接上之前创建的 `xiaohashu` 数据库,将之前测试用的 `t_user` 表删除掉,再执行一下该建表语句:
![](https://img.quanxiaoha.com/quanxiaoha/171601856297977)

View File

@@ -0,0 +1,85 @@
在之前的 [5.2 小节](https://www.quanxiaoha.com/column/10270.html) 中,我们已经将 SaToken 权限认证框架整合进了认证服务中。但是有个问题,当我们调用 `TestController` 中的登录接口 `/user/doLogin?username=zhang&password=123456` ,登录成功后:
![img](https://img.quanxiaoha.com/quanxiaoha/171602045285122)img
再*重启项目*,调用 `/user/isLogin` 接口,验证一下用户是否登录的时候,会发现登录已经失效了,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171697350117679)
## 登录失效原因?
为啥重启项目后,登录状态就失效了呢?以下来自官网的解释:
> Sa-Token 默认将数据保存在内存中,此模式读写速度最快,且避免了序列化与反序列化带来的性能消耗,但是此模式也有一些缺点,比如:
>
> 1. 重启后数据会丢失。
> 2. 无法在分布式环境中共享数据。
>
> 为此Sa-Token 提供了扩展接口,你可以轻松将会话数据存储在一些专业的缓存中间件上(比如 Redis 做到重启数据不丢失,而且保证分布式环境下多节点的会话一致性。
## SaToken 整合 Redis
编辑项目最外层的 `pom.xml` , 添加以下依赖:
```php-template
// 省略...
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>${sa-token.version}</version>
</dependency>
// 省略...
</dependencies>
</dependencyManagement>
// 省略...
```
接着,编辑 `xiaohashu-auth` 认证服务的 `pom.xml` , 引入如下依赖:
```php-template
// 省略...
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
</dependency>
<!-- Redis 连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
// 省略...
```
> **注意** `commons-pool2`连接池依赖无需再次引入,因为之前小节中,我们已经添加过了。
## 自测一波
关于 Redis 链接配置啥的,我们已经配置过了。完成依赖添加的工作后,这里直接重启项目,再次调用登录接口,登录成功后,观察 Redis 中的数据,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171697383390318)
如上图所示,可以看到 Redis 中多了一些 SaToken 相关的登录令牌、会话信息,它们用于保存用户的登录状态数据。
再次重启项目,调用 `/user/isLogin` 接口,看看这次用户登录成功后重启项目,登录状态是否失效,如下图所示,这次就 OK 了,依然是处于登录状态:
![](https://img.quanxiaoha.com/quanxiaoha/171697483791246)
## 结语
本小节中,我们主要为 SaToken 权限框架整合了 Redis , 让会话数据存储在了缓存中间件中,以保证项目重启后,登录状态不会失效。
## 本小节源码下载
[https://t.zsxq.com/G6hTx](https://t.zsxq.com/G6hTx)

View File

@@ -0,0 +1,498 @@
本小节开始,正式进入到小哈书的*用户注册/登录功能*开发工作。
![](https://img.quanxiaoha.com/quanxiaoha/171705854421326)
## 流程分析
小伙伴们打开小红书 APP观察一下就会发现小红书其实是没有用户注册页的。即使是新用户也可以直接通过手机号验证码登录成功非常方便新用户登录后系统会自动根据你的手机号帮你把账号注册好。那么我们来理一下登录接口的实现逻辑大致如下图所示
![](https://img.quanxiaoha.com/quanxiaoha/171707293500919)
## 接口定义
接口的业务逻辑分析完毕后,接下来,定义一下此接口的请求地址、入参,以及出参。
### 接口地址
```bash
POST /user/login
```
### 入参
```json
{
"phone": "18011119108", // 手机号
"code": "218603", // 登录验证码,验证码登录时,需要填写
"password": "xx", // 密码登录时,需要填写
"type": 1 // 登录类型1表示手机号验证码登录2表示账号密码登录
}
```
### 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": "xxxxx" // 登录成功后,返回 Token 令牌
}
```
## 权限数据准备
由于现在还没有管理后台,我们先把一些的基础权限数据,初始化到数据库中,如普通用户的角色-权限数据。
### 权限数据
```go
INSERT INTO `xiaohashu`.`t_permission` (`id`, `parent_id`, `name`, `type`, `menu_url`, `menu_icon`, `sort`, `permission_key`, `status`, `create_time`, `update_time`, `is_deleted`) VALUES (1, 0, '发布笔记', 3, '', '', 1, 'app:note:publish', 0, now(), now(), b'0');
INSERT INTO `xiaohashu`.`t_permission` (`id`, `parent_id`, `name`, `type`, `menu_url`, `menu_icon`, `sort`, `permission_key`, `status`, `create_time`, `update_time`, `is_deleted`) VALUES (2, 0, '发布评论', 3, '', '', 2, 'app:comment:publish', 0, now(), now(), b'0');
```
> 先新增两条权限:
>
> + 发布笔记;
> + 发布评论
>
> 后续如果还有别的权限需要控制,到时候咱们再添加。
### 角色数据
```go
INSERT INTO `xiaohashu`.`t_role` (`id`, `role_name`, `role_key`, `status`, `sort`, `remark`, `create_time`, `update_time`, `is_deleted`) VALUES (1, '普通用户', 'common_user', 0, 1, '', now(), now(), b'0');
```
> 新增一个*普通用户*角色。
### 关联数据
然后是该角色与权限的关联关系:
```go
INSERT INTO `xiaohashu`.`t_role_permission_rel` (`id`, `role_id`, `permission_id`, `create_time`, `update_time`, `is_deleted`) VALUES (1, 1, 1, now(), now(), b'0');
INSERT INTO `xiaohashu`.`t_role_permission_rel` (`id`, `role_id`, `permission_id`, `create_time`, `update_time`, `is_deleted`) VALUES (2, 1, 2, now(), now(), b'0');
```
## 删除无用的测试类
开始进入编码工作。先将之前测试用的类全部删除掉,如下图标注的这些:
![](https://img.quanxiaoha.com/quanxiaoha/171705124397931)
## 重新生成 DO 、Mapper 接口、XML 文件
重新通过 MyBatis 代码生成器,生成 `t_user` 表的 `DO` 实体类、`Mapper` 接口,以及 `XML` 文件。生成完毕后,编辑 `UserDO` 实体类, 为其添加上 Lombok 注解,以及相关字段类型修正,代码如下;
```typescript
package com.quanxiaoha.xiaohashu.auth.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDO {
private Long id;
private String xiaohashuId;
private String password;
private String nickname;
private String avatar;
private LocalDateTime birthday;
private String backgroundImg;
private String phone;
private Integer sex;
private Integer status;
private String introduction;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Boolean isDeleted;
}
```
## 入参 VO
接着,创建登录接口的入参 `VO` 类,在 `/vo` 包下,新增 `/user` 包,并创建 `UserLoginReqVO` 入参实体类:
![](https://img.quanxiaoha.com/quanxiaoha/171705162631463)
代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.model.vo.user;
import com.quanxiaoha.framework.common.validator.PhoneNumber;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:17
* @version: v1.0.0
* @description: 用户登录(支持密码或验证码两种方式)
**/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserLoginReqVO {
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@PhoneNumber
private String phone;
/**
* 验证码
*/
private String code;
/**
* 密码
*/
private String password;
/**
* 登录类型:手机号验证码,或者是账号密码
*/
@NotNull(message = "登录类型不能为空")
private Integer type;
}
```
## 登录类型枚举类
![](https://img.quanxiaoha.com/quanxiaoha/171705174213617)
在接口的入参类中,定义了一个 `type` 字段,用于表示登录类型。这里我们创建一个枚举类,方便后续获取 `type` 值,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-08-15 10:33
* @description: 登录类型
**/
@Getter
@AllArgsConstructor
public enum LoginTypeEnum {
// 验证码
VERIFICATION_CODE(1),
// 密码
PASSWORD(2);
private final Integer value;
public static LoginTypeEnum valueOf(Integer code) {
for (LoginTypeEnum loginTypeEnum : LoginTypeEnum.values()) {
if (Objects.equals(code, loginTypeEnum.getValue())) {
return loginTypeEnum;
}
}
return null;
}
}
```
## 验证码错误状态码
接着,在 `ResponseCodeEnum` 全局业务状态码枚举类中,新增一个*验证码错误*的枚举值,代码如下,等会在业务层中,如果用户提交的验证码与保存在 Redis 中的验证码不一致,则抛出该错误提示信息:
```java
package com.quanxiaoha.xiaohashu.auth.enums;
import com.quanxiaoha.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// 省略...
// ----------- 业务异常状态码 -----------
// 省略...
VERIFICATION_CODE_ERROR("AUTH-20001", "验证码错误"),
;
// 省略...
}
```
## mapper 查询方法
另外,编辑 `UserDOMapper` 接口,定义一个*根据手机号查询记录*的方法,业务层等会判断用户是否已经注册,需要用到此方法,代码如下、:
```java
package com.quanxiaoha.xiaohashu.auth.domain.mapper;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
public interface UserDOMapper {
// 省略...
/**
* 根据手机号查询记录
* @param phone
* @return
*/
UserDO selectByPhone(String phone);
// 省略...
}
```
方法声明完毕后,编辑 `UserDOMapper.xml` 文件,编写对应的 `sql` , 代码如下:
```csharp
// 省略...
<select id="selectByPhone" resultMap="BaseResultMap">
select id, password from t_user where phone = #{phone}
</select>
// 省略...
```
## 编写 service 业务层
![](https://img.quanxiaoha.com/quanxiaoha/171705271117613)
前置工作都完成后,在 `/service` 包下,创建 `UserService` 业务接口,并声明一个*登录与注册*的方法:
```java
package com.quanxiaoha.xiaohashu.auth.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: TODO
**/
public interface UserService {
/**
* 登录与注册
* @param userLoginReqVO
* @return
*/
Response<String> loginAndRegister(UserLoginReqVO userLoginReqVO);
}
```
然后,在 `/impl` 包下,创建其实现类,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.service.impl;
import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.auth.enums.LoginTypeEnum;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Objects;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: TODO
**/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
@Resource
private UserDOMapper userDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 登录与注册
*
* @param userLoginReqVO
* @return
*/
@Override
public Response<String> loginAndRegister(UserLoginReqVO userLoginReqVO) {
String phone = userLoginReqVO.getPhone();
Integer type = userLoginReqVO.getType();
LoginTypeEnum loginTypeEnum = LoginTypeEnum.valueOf(type);
Long userId = null;
// 判断登录类型
switch (loginTypeEnum) {
case VERIFICATION_CODE: // 验证码登录
String verificationCode = userLoginReqVO.getCode();
// 校验入参验证码是否为空
if (StringUtils.isBlank(verificationCode)) {
return Response.fail(ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode(), "验证码不能为空");
}
// 构建验证码 Redis Key
String key = RedisKeyConstants.buildVerificationCodeKey(phone);
// 查询存储在 Redis 中该用户的登录验证码
String sentCode = (String) redisTemplate.opsForValue().get(key);
// 判断用户提交的验证码,与 Redis 中的验证码是否一致
if (!StringUtils.equals(verificationCode, sentCode)) {
throw new BizException(ResponseCodeEnum.VERIFICATION_CODE_ERROR);
}
// 通过手机号查询记录
UserDO userDO = userDOMapper.selectByPhone(phone);
log.info("==> 用户是否注册, phone: {}, userDO: {}", phone, JsonUtils.toJsonString(userDO));
// 判断是否注册
if (Objects.isNull(userDO)) {
// 若此用户还没有注册,系统自动注册该用户
// todo
} else {
// 已注册,则获取其用户 ID
userId = userDO.getId();
}
break;
case PASSWORD: // 密码登录
// todo
break;
default:
break;
}
// SaToken 登录用户,并返回 token 令牌
// todo
return Response.success("");
}
}
```
> 解释一波业务逻辑:
>
> + 拿到入参实体类中的 `type` 字段,通过 `LoginTypeEnum.valueOf()` 方法,获取具体的类型枚举值;
> + 对枚举进行 `switch` 判断,若是手机号验证码登录;
> + 获取提交上来的验证码,并与存储在 Redis 中的验证码进行比对;
> + 若不一致,返回验证码错误提示信息;
> + 否则,通过手机号查询数据库;
> + 若 `userDO` 为空,说明是新用户,系统需要自动为该用户注册用户信息。这里代码块中,先写个 `todo` , 后面小节中,再写具体的逻辑;
> + 若 `userDO` 不为空,则说明是老用户,获取其用户 ID;
> + 若是账号密码登录,校验密码是否正确;
> + 代码块中,先写个 `todo` , 后面小节中,再写具体的逻辑;
> + SaToken 登录用户,并返回 token 令牌,暂时先写个 `todo`
至此,登录接口的业务大体逻辑骨架就完成了。
## controller
![](https://img.quanxiaoha.com/quanxiaoha/171705282797616)
最后,在 `/controller` 包下,创建 `UserController` 控制器,新增 `/user/login` 接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author: 犬小哈
* @date: 2024/5/29 15:32
* @version: v1.0.0
* @description: TODO
**/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Resource
private UserService userService;
@PostMapping("/login")
@ApiOperationLog(description = "用户登录/注册")
public Response<String> loginAndRegister(@Validated @RequestBody UserLoginReqVO userLoginReqVO) {
return userService.loginAndRegister(userLoginReqVO);
}
}
```
本小节中,登录接口的业务逻辑还有缺失,就先不测试了,等下面小节中,完全开发完毕后,再来自测一波,看看接口功能是否正常。
## 本小节源码下载
[https://t.zsxq.com/TaNKa](https://t.zsxq.com/TaNKa)

View File

@@ -0,0 +1,380 @@
本小节中,我们继续开发 —— *注册/登录接口* 将手机号验证码方式登录的剩余逻辑补充完整。
## 添加公共枚举类
![](https://img.quanxiaoha.com/quanxiaoha/171714189138023)
编辑 `xiaoha-common` 公共模块,添加 `/eumns` 包,用于放置全局通用的枚举类。并添加以下两个枚举,等会业务层中,自动注册用户需要用到:
+ *逻辑删除枚举;*
+ *开启/禁用状态枚举;*
```kotlin
package com.quanxiaoha.framework.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-08-15 10:33
* @description: 逻辑删除
**/
@Getter
@AllArgsConstructor
public enum DeletedEnum {
YES(true),
NO(false);
private final Boolean value;
}
```
```java
package com.quanxiaoha.framework.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-08-15 10:33
* @description: 状态
**/
@Getter
@AllArgsConstructor
public enum StatusEnum {
// 启用
ENABLE(0),
// 禁用
DISABLED(1);
private final Integer value;
}
```
## 生成 RBAC 模型其他表的 DO、Mapper 接口、XML 文件
[上小节](https://www.quanxiaoha.com/column/10281.html) 中,已经将 `t_user` 表对应的 `DO` 实体类、`Mapper` 接口、`XML` 文件已经生成好了,这里顺手将其他几张表,也通过 Mybatis 代码生成器插件生成一下,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171714274198727)
> **TIP** : 这里就不贴代码了,小伙伴们有需要的话,可下载本小节源码来查看。
## Redis 全局 ID 自增
当新用户登录时,系统需要为该手机号自动注册一个用户,同时,还需要分配一个小红书 ID, 如*小红薯10000、小红薯10001* ,一直自增的方式,并且需要保证全局唯一。**要如何实现呢?**
这里我们可以借助 Redis 实现,执行如下命令,设置一个 `key``xiaohashu_id_generator` 的生成器,初始值设置为 `10000`
```bash
set xiaohashu_id_generator 10000
```
![](https://img.quanxiaoha.com/quanxiaoha/171714361271167)
然后,通过 `INCR` 命令即可实现每次对其自增 1 , 命令如下:
```undefined
INCR xiaohashu_id_generator
```
![](https://img.quanxiaoha.com/quanxiaoha/171714406086659)
## 添加全局 ID 生成器常量 KEY
方案定好后,编辑 `RedisKeyConstants` 全局常量类,添加*小哈书全局 ID 生成器 KEY* 代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.constant;
public class RedisKeyConstants {
// 省略...
/**
* 小哈书全局 ID 生成器 KEY
*/
public static final String XIAOHASHU_ID_GENERATOR_KEY = "xiaohashu_id_generator";
// 省略...
}
```
## 添加角色全局常量类
![](https://img.quanxiaoha.com/quanxiaoha/171714437007431)
接着,在 `/constant` 常量包下,再创建一个 `RoleConstants` 角色全局常量类,用于放置角色相关的全局常量,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.constant;
/**
* @author: 犬小哈
* @date: 2024/5/21 15:04
* @version: v1.0.0
* @description: 角色全局常量
**/
public class RoleConstants {
/**
* 普通用户的角色 ID
*/
public static final Long COMMON_USER_ROLE_ID = 1L;
}
```
> 定义一个普通用户的角色 ID, 该角色数据,已经在上小节中提前准备好了。等会自动注册用户时,需要为用户自动分配上该角色。
## 用户角色 Redis Key
在系统自动注册用户完成后,还需要将该用户的角色,存储到 Redis 缓存中,方便后续鉴权使用。编辑 `RedisKeyConstants` 常量类,添加*用户角色数据 KEY 前缀* 代码如下:
```typescript
package com.quanxiaoha.xiaohashu.auth.constant;
public class RedisKeyConstants {
// 省略...
/**
* 用户角色数据 KEY 前缀
*/
private static final String USER_ROLES_KEY_PREFIX = "user:roles:";
/**
* 构建验证码 KEY
* @param phone
* @return
*/
public static String buildUserRoleKey(String phone) {
return USER_ROLES_KEY_PREFIX + phone;
}
}
```
## 完善用户注册逻辑
前置工作完成后,准备编辑 `UserServiceImpl` 业务实现类,补充上自动注册用户的逻辑,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.service.impl;
import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.quanxiaoha.framework.common.enums.DeletedEnum;
import com.quanxiaoha.framework.common.enums.StatusEnum;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.constant.RoleConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserRoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserRoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.enums.LoginTypeEnum;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
@Resource
private UserRoleDOMapper userRoleDOMapper;
/**
* 登录与注册
*
* @param userLoginReqVO
* @return
*/
@Override
public Response<String> loginAndRegister(UserLoginReqVO userLoginReqVO) {
// 省略...
// 判断登录类型
switch (loginTypeEnum) {
case VERIFICATION_CODE: // 验证码登录
// 省略...
// 判断是否注册
if (Objects.isNull(userDO)) {
// 若此用户还没有注册,系统自动注册该用户
userId = registerUser(phone);
} else {
// 已注册,则获取其用户 ID
userId = userDO.getId();
}
break;
case PASSWORD: // 密码登录
// todo
break;
default:
break;
}
// SaToken 登录用户,并返回 token 令牌
// todo
return Response.success("");
}
/**
* 系统自动注册用户
* @param phone
* @return
*/
@Transactional(rollbackFor = Exception.class)
public Long registerUser(String phone) {
// 获取全局自增的小哈书 ID
Long xiaohashuId = redisTemplate.opsForValue().increment(RedisKeyConstants.XIAOHASHU_ID_GENERATOR_KEY);
UserDO userDO = UserDO.builder()
.phone(phone)
.xiaohashuId(String.valueOf(xiaohashuId)) // 自动生成小红书号 ID
.nickname("小红薯" + xiaohashuId) // 自动生成昵称, 如小红薯10000
.status(StatusEnum.ENABLE.getValue()) // 状态为启用
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.isDeleted(DeletedEnum.NO.getValue()) // 逻辑删除
.build();
// 添加入库
userDOMapper.insert(userDO);
// 获取刚刚添加入库的用户 ID
Long userId = userDO.getId();
// 给该用户分配一个默认角色
UserRoleDO userRoleDO = UserRoleDO.builder()
.userId(userId)
.roleId(RoleConstants.COMMON_USER_ROLE_ID)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.isDeleted(DeletedEnum.NO.getValue())
.build();
userRoleDOMapper.insert(userRoleDO);
// 将该用户的角色 ID 存入 Redis 中
List<Long> roles = Lists.newArrayList();
roles.add(RoleConstants.COMMON_USER_ROLE_ID);
String userRolesKey = RedisKeyConstants.buildUserRoleKey(phone);
redisTemplate.opsForValue().set(userRolesKey, JsonUtils.toJsonString(roles));
return userId;
}
}
```
> 解释一下 `registerUser` 用户注册方法的逻辑:
>
> + 为方法添加 `@Transactional(rollbackFor = Exception.class)` 事务注解,保证方法块内代码的原子性,要么全部成功,要么全部失败;
>
> + 操作 Redis , 获取全局自增的小哈书 ID
>
> + 构建 `UserDO` 实体类,包括分配小红书 ID, 昵称等;
>
> + 插入用户数据,并获取其主键 ID ;
>
> + 给该用户分配一个普通用户角色,并入库;
>
> + 最后将给用户的角色数据,存储到 Redis 中,供后续鉴权使用;
>
> + 返回用户 ID ;
>
### Mybatis 获取自增 ID
上面的业务逻辑中,用户数据插入表中后,需要获取到该用户的主键 ID, *Mybatis 要如何获取呢?* 可编辑 `xml` 文件中的 `insert` 方法,添加 `useGeneratedKeys="true" keyProperty="id"` 代码如下。当数据新增成功后Mybatis 会自动将该条记录的主键 ID 设置到入参中,我们直接从入参 `UserDO` 中,即可获取主键 `ID`
```perl
<insert id="insert" parameterType="com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO" useGeneratedKeys="true" keyProperty="id">
insert into t_user (xiaohashu_id, `password`,
nickname, avatar, birthday,
background_img, phone, sex,
`status`, introduction, create_time,
update_time, is_deleted)
values (#{xiaohashuId,jdbcType=VARCHAR}, #{password,jdbcType=VARCHAR},
#{nickname,jdbcType=VARCHAR}, #{avatar,jdbcType=VARCHAR}, #{birthday,jdbcType=DATE},
#{backgroundImg,jdbcType=VARCHAR}, #{phone,jdbcType=VARCHAR}, #{sex,jdbcType=TINYINT},
#{status,jdbcType=TINYINT}, #{introduction,jdbcType=VARCHAR}, #{createTime,jdbcType=TIMESTAMP},
#{updateTime,jdbcType=TIMESTAMP}, #{isDeleted,jdbcType=BIT})
</insert>
```
> **TIP** 自动生成的 `xml` 文件中 `insert` SQL 中的 `id` 项可以删掉,让其自增即可,无需手动填入。
## 返回 Token 令牌
![](https://img.quanxiaoha.com/quanxiaoha/171714512132686)
注册用户逻辑编写完毕后,再来补充一下返回 Token 令牌部分代码。前面我们已经获取到了登录用户的 `ID` ,可直接通过 `StpUtil.login()` 方法完成登录,并从 `SaTokenInfo` 对象中获取 Token 令牌,代码如下:
```java
// 省略...
// SaToken 登录用户, 入参为用户 ID
StpUtil.login(userId);
// 获取 Token 令牌
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
// 返回 Token 令牌
return Response.success(tokenInfo.tokenValue);
// 省略...
```
至此,手机号验证码登录注册的整体功能就开发完毕了。
## 自测一波
重启项目,自测一波登录接口。先调用*获取验证码接口*,拿到一个新的验证码,然后,将该验证码填入到*登录接口*的入参中,点击*发送*,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171714530987695)
可以看到,成功返回了一个 Token 令牌,另外,确认一下 `t_user` 表中,是否有自动注册该手机号的用户信息:
![](https://img.quanxiaoha.com/quanxiaoha/171715177380299)
以及 `t_user_role` 表中,是否有该用户的角色信息:
![](https://img.quanxiaoha.com/quanxiaoha/171715332701780)
OK, 一切正常。
## 本小节源码下载
[https://t.zsxq.com/t03dT](https://t.zsxq.com/t03dT)

View File

@@ -0,0 +1,241 @@
上小节中,系统自动注册用户逻辑,是需要保证其原子性的,要么所有操作全部失败,要么全部成功,绝对不允许一部分成功,一部分失败,这样会导致脏数据。我们通过在方法头上添加 `@Transacational` 事务注解,以实现事务的控制。
![](https://img.quanxiaoha.com/quanxiaoha/171723136846779)
*但是,这块其实是有坑的,不知道小伙伴们发现了没有。*
## 模拟注册用户中间发生了错误
我们来模拟一下,在新增用户入库后,分配用户角色之前,手动添加一个运行时异常 —— **分母不能为零**。如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171723145912869)
运行时异常添加完毕后,重启项目,通过 Apipost 测试一下登录接口,记得登录一个数据库中不存在的手机号,看看是个什么情况。
![](https://img.quanxiaoha.com/quanxiaoha/171723298288555)
可以看到提示系统错误查看数据库会发现用户数据插入成功了但是角色数据、Redis 缓存都添加失败了,**事务并没有回滚 **
## 声明式注解事务,为啥失效了?
声明式注解事务失效原因,主要由以下几点:
+ **方法可见性**`@Transactional` 仅在 `public` 方法上生效。
+ **自调用**:当类中的方法调用同一个类中的另一个 `@Transactional` 方法时,事务可能不会生效。这是因为事务注解是通过 AOP 实现的,而 Spring 的 AOP 代理机制在这种情况下不会被触发。
+ **异常处理**:只有 `RuntimeException``Error` 类型的异常会触发事务回滚。如果你抛出的是 `checked exception`,事务不会回滚,除非你明确指定 `rollbackFor` 属性。
+ **代理对象**:确保你是在 Spring 管理的代理对象上调用方法。如果你直接使用 `new` 关键字实例化对象Spring 的 AOP 代理机制将不会被应用。
很显然,我们是**自调用**这种情况。
## 使用编程式事务
### 什么是编程式事务?有哪些优点?
编程式事务Programmatic Transaction是一种**通过代码显式地管理事务的方式**而不是依赖声明式事务Declarative Transaction中使用的注解或 XML 配置。在编程式事务中,开发人员通过编写代码来开启、提交和回滚事务,以精细控制事务的边界和行为。
使用编程式事务优点如下:
+ **精细控制**:编程式事务允许开发者通过代码精细地控制事务的生命周期,包括开始、提交和回滚。可以根据具体业务需求,灵活地管理事务。
+ **动态处理**:在运行时可以根据业务逻辑的不同情况动态决定事务的行为。特别适合需要在代码执行过程中,根据某些条件来开启、提交或回滚事务的场景。
+ **适用于复杂事务**:在一个方法中需要多次开启和关闭事务,或需要嵌套事务的复杂场景中,编程式事务可以提供更大的灵活性和控制力。
+ **灵活性高**:能够在代码中实现复杂的事务逻辑,可以精确控制事务的边界和行为。这在需要多个步骤或调用之间共享事务上下文时非常有用。
+ **性能提升**:通过精细控制事务的边界,减少不必要的事务开启和提交,从而减少事务开销;通过明确控制事务的开始和结束,可以确保事务范围尽可能小,减少长时间占用数据库资源,提高系统的并发性;通过灵活的事务管理,可以在必要时才进行事务回滚,减少回滚操作带来的性能开销。
### 使用示例
以下是 Spring Boot 中使用编程式事务两种方式,示例代码如下:
#### 第一种方式
```java
@Service
public class MyService {
@Resource
private PlatformTransactionManager transactionManager;
@Resource
private MyMapper myMapper;
public void myTransactionalMethod() {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 业务逻辑代码
myMapper.insertSomething(...);
transactionManager.commit(status); // 提交事务
} catch (Exception ex) {
transactionManager.rollback(status); // 回滚事务
throw ex; // 重新抛出异常
}
}
}
```
> 着重解释一下下面这行代码:
>
> ```java
> TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
> ```
>
> + 这行代码通过 `transactionManager.getTransaction` 方法获取一个新的事务状态。`DefaultTransactionDefinition` 用于定义事务的默认属性,例如传播行为和隔离级别。该方法会返回一个`TransactionStatus`对象,用于管理事务的状态。
#### 第二种方式
`TransactionTemplate`是一个简化了事务管理的工具类,可以避免直接处理 `TransactionStatus`
```typescript
@Service
public class MyService {
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private MyMapper myMapper;
public void myTransactionalMethod() {
transactionTemplate.execute(status -> {
try {
// 业务逻辑代码
myMapper.insertSomething(...);
} catch (Exception ex) {
status.setRollbackOnly(); // 标记事务为回滚
throw ex; // 重新抛出异常
}
return null;
});
}
}
```
### 为项目整上编程式事务
了解如何使用后,我们为*用户注册方法*添加上编程式事务,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.service.impl;
import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.quanxiaoha.framework.common.enums.DeletedEnum;
import com.quanxiaoha.framework.common.enums.StatusEnum;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.constant.RoleConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.UserRoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.UserRoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.enums.LoginTypeEnum;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
@Resource
private TransactionTemplate transactionTemplate;
// 省略...
/**
* 系统自动注册用户
* @param phone
* @return
*/
private Long registerUser(String phone) {
return transactionTemplate.execute(status -> {
try {
// 获取全局自增的小哈书 ID
Long xiaohashuId = redisTemplate.opsForValue().increment(RedisKeyConstants.XIAOHASHU_ID_GENERATOR_KEY);
UserDO userDO = UserDO.builder()
.phone(phone)
.xiaohashuId(String.valueOf(xiaohashuId)) // 自动生成小红书号 ID
.nickname("小红薯" + xiaohashuId) // 自动生成昵称, 如小红薯10000
.status(StatusEnum.ENABLE.getValue()) // 状态为启用
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.isDeleted(DeletedEnum.NO.getValue()) // 逻辑删除
.build();
// 添加入库
userDOMapper.insert(userDO);
int i = 1 / 0;
// 获取刚刚添加入库的用户 ID
Long userId = userDO.getId();
// 给该用户分配一个默认角色
UserRoleDO userRoleDO = UserRoleDO.builder()
.userId(userId)
.roleId(RoleConstants.COMMON_USER_ROLE_ID)
.createTime(LocalDateTime.now())
.updateTime(LocalDateTime.now())
.isDeleted(DeletedEnum.NO.getValue())
.build();
userRoleDOMapper.insert(userRoleDO);
// 将该用户的角色 ID 存入 Redis 中
List<Long> roles = Lists.newArrayList();
roles.add(RoleConstants.COMMON_USER_ROLE_ID);
String userRolesKey = RedisKeyConstants.buildUserRoleKey(phone);
redisTemplate.opsForValue().set(userRolesKey, JsonUtils.toJsonString(roles));
return userId;
} catch (Exception e) {
status.setRollbackOnly(); // 标记事务为回滚
log.error("==> 系统注册用户异常: ", e);
return null;
}
});
}
}
```
## 自测一波
编写完毕后,重启项目,再次测试登录接口,看看这次*分母不能为零*运行时错误发生时,事务控制是否生效。不出意外,这次就没问题了,我就不截图了,小伙伴们可以自己测试一波。测试完毕后,记得将 `int i = 1 / 0;` 这行代码删除掉哟~
## 本小节源码下载
[https://t.zsxq.com/JCs50](https://t.zsxq.com/JCs50)

View File

@@ -0,0 +1,103 @@
![](https://img.quanxiaoha.com/quanxiaoha/171741937623953)
在某些情况下,对入参中的字段进行参数校验时,没有办法用到**校验注解**,如登录接口中验证码的判空。因为这个接口是个二合一接口,需要同时支持手机号验证码登录,以及账号密码登录。只能在业务层中,手动进行参数校验,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171739874484023)
现在就校验一个字段,你会感觉代码还不是太难看。试想一下,当有多个字段需要校验时,就会有一堆的 `if` 判断,伪代码如下:
```kotlin
// 校验入参验证码是否为空
if (StringUtils.isBlank(verificationCode)) {
return Response.fail(ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode(), "验证码不能为空");
}
// 参数校验2
if (条件判断2) {
return Response.fail(ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode(), "xxx");
}
// 参数校验3
if (条件判断3) {
return Response.fail(ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode(), "xxx");
}
// 省略...
```
*那么,有优化的方法吗? 看着很丑陋 * 本小节中,我们就将使用谷歌 Guava 库中的 Preconditions 工具类,搭配全局异常捕获,来将这块的代码重构一下。
## Guava 库中的 Preconditions
![](https://img.quanxiaoha.com/quanxiaoha/171740014680249)
Guava 是一个广泛使用的 Java 库提供了许多有用的工具和实用程序其中包括参数校验工具。Guava 的参数校验功能主要通过 `com.google.common.base.Preconditions` 类来实现。`Preconditions` 提供了一组静态方法,用于在方法执行前验证参数的有效性。这些方法在条件不满足时抛出异常,从而确保方法得到合法的输入。
## 开始使用
![](https://img.quanxiaoha.com/quanxiaoha/171739913777551)
将之前的 `if` 验证码校验代码删除掉,改用 `Preconditions` , 代码如下:
```less
// 省略...
// 校验入参验证码是否为空
Preconditions.checkArgument(StringUtils.isNotBlank(verificationCode), "验证码不能为空");
// 省略...
```
> *3 行代码变成 1 行,清爽多了 *
## 搭配全局异常捕获
光这样还不行,查看一下 `checkArgument()` 方法的源码,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171739904970190)
可以看到,该方法会主动抛出一个 `IllegalArgumentException` 异常。编辑 `GlobalExceptionHandler` 全局异常捕获类,对该异常进行捕获,并统一处理:
![](https://img.quanxiaoha.com/quanxiaoha/171739946297233)
代码如下:
```typescript
// 省略...
/**
* 捕获 guava 参数校验异常
* @return
*/
@ExceptionHandler({ IllegalArgumentException.class })
@ResponseBody
public Response<Object> handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException e) {
// 参数错误异常码
String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();
// 错误信息
String errorMessage = e.getMessage();
log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);
return Response.fail(errorCode, errorMessage);
}
// 省略...
```
> + 错误异常码,统一使用已经定义好的 `PARAM_NOT_VALID` 参数错误枚举;
> + 通过 `e.getMessage()` 拿到异常提示信息;
> + 返回响应数据;
## 自测一波
代码重构完毕后,重启项目,再来测试一波登录接口,将验证码填写为空字符串,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171739942010352)
可以看到,错误码为 `AUTH-10001` 提示信息为*验证码不能为空*。OK , 代码重构完毕。
## 本小节源码下载
[https://t.zsxq.com/U2nrD](https://t.zsxq.com/U2nrD)

View File

@@ -0,0 +1,621 @@
在之前小节中,当新用户登录成功后,系统会默认为该手机号注册好用户,并分配一个角色,同时将用户-角色的关联关系,同步到了 Redis 缓存中。如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171750077164423)
但是,光有角色 ID 是不够的,因为每个角色对应的权限数据,还没有同步到 Redis 中。这块的工作,可以放到项目启动后,同时也将角色-权限数据同步到 Redis 中。
## 项目启动时,做些事情!
![](https://img.quanxiaoha.com/quanxiaoha/171750139555059)
在 Spring Boot 项目中,可以通过多种方式在项目启动时执行初始化工作。以下是一些常见的方法:
### 1\. 使用 `@PostConstruct` 注解
`@PostConstruct` 注解可以用于在 Spring 容器初始化 bean 之后立即执行特定的方法。
```kotlin
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Component
public class MyInitializer {
@PostConstruct
public void init() {
// 初始化工作
System.out.println("初始化工作完成");
}
}
```
### 2\. 实现 `ApplicationRunner` 接口
`ApplicationRunner` 接口提供了一种在 Spring Boot 应用启动后执行特定代码的方式。
```java
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class MyApplicationRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
// 初始化工作
System.out.println("初始化工作完成");
}
}
```
### 3\. 实现 `CommandLineRunner` 接口
`CommandLineRunner` 接口类似于 `ApplicationRunner`,可以在应用启动后执行代码。
```java
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class MyCommandLineRunner implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
// 初始化工作
System.out.println("初始化工作完成");
}
}
```
### 4\. 使用 `@EventListener` 注解监听 `ApplicationReadyEvent`
通过监听 `ApplicationReadyEvent` 事件,可以在 Spring Boot 应用完全启动并准备好服务请求时执行初始化工作。
```java
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.boot.context.event.ApplicationReadyEvent;
@Component
public class MyApplicationReadyListener {
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
// 初始化工作
System.out.println("初始化工作完成");
}
}
```
### 5\. 使用 `SmartInitializingSingleton` 接口
`SmartInitializingSingleton` 接口提供了一种在所有单例 bean 初始化完成后执行代码的方式。
```typescript
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.stereotype.Component;
@Component
public class MySmartInitializingSingleton implements SmartInitializingSingleton {
@Override
public void afterSingletonsInstantiated() {
// 初始化工作
System.out.println("初始化工作完成");
}
}
```
### 6\. 使用 Spring Boot 的 `InitializingBean` 接口
通过实现 `InitializingBean` 接口的 `afterPropertiesSet` 方法,可以在 bean 的属性设置完成后执行初始化工作。
```java
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
@Component
public class MyInitializingBean implements InitializingBean {
@Override
public void afterPropertiesSet() throws Exception {
// 初始化工作
System.out.println("初始化工作完成");
}
}
```
### 7\. 总结
以上这些方法各有优缺点,可以根据具体的初始化需求选择合适的方法。
+ **@PostConstruct**:适合简单的初始化逻辑,执行时机较早。
+ **ApplicationRunner 和 CommandLineRunner**:适合需要访问命令行参数的初始化逻辑,执行时机在 Spring Boot 应用启动完成后。
+ **ApplicationReadyEvent 监听器**:适合在整个应用准备好后执行的初始化逻辑。
+ **SmartInitializingSingleton**:适合需要在所有单例 bean 初始化完成后执行的初始化逻辑。
+ **InitializingBean**:适合需要在 bean 属性设置完成后执行的初始化逻辑。
## 开始编码
![](https://img.quanxiaoha.com/quanxiaoha/171749903450751)
在认证服务中,新建 `/runner` 包,用于统一放置项目启动时的逻辑类,并创建 `PushRolePermissions2RedisRunner`, 表示推送角色权限数据到 Redis 中,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.runner;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RolePermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.PermissionDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RolePermissionDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author: 犬小哈
* @date: 2024/6/4 16:41
* @version: v1.0.0
* @description: 推送角色权限数据到 Redis 中
**/
@Component
@Slf4j
public class PushRolePermissions2RedisRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
log.info("==> 服务启动,开始同步角色权限数据到 Redis 中...");
// todo
log.info("==> 服务启动,成功同步角色权限数据到 Redis 中...");
}
}
```
到这里,小伙伴们可以重启一下项目,观察控制台日志,看看能否执行 `run()` 方法中的日志打印,逻辑代码先暂时不写。
## 业务逻辑分析
控制台成功打印日志后,我们来分析一下同步角色-权限集合的业务逻辑,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171750211732655)
## 编写 Mapper 查询方法
想好代码逻辑要如何实现后,开始封装 `run()` 方法中需要用到的查询方法。
### 查询所有被启用的角色
首先是,查询出所有被启用的角色,编辑 `RoleDOMapper` 接口,声明方法如下:
```java
package com.quanxiaoha.xiaohashu.auth.domain.mapper;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import java.util.List;
public interface RoleDOMapper {
// 省略...
/**
* 查询所有被启用的角色
*
* @return
*/
List<RoleDO> selectEnabledList();
}
```
接着,在其 `xml` 映射文件中编写具体的查询 SQL , 代码如下:
```csharp
// 省略...
<select id="selectEnabledList" resultMap="BaseResultMap">
select id, role_key, role_name
from t_role
where status = 0 and is_deleted = 0
</select>
// 省略...
```
> **TIP** : 只查询需要的字段,而不是 `select *` , 以提升查询性能。
### 根据角色 ID 集合批量查询
获取到所有角色后,再来编写一个根据角色 ID 集合批量查询 `t_role_permission_rel` 表的方法,用于将对应的权限 ID 查询出来,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.domain.mapper;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RolePermissionDO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface RolePermissionDOMapper {
// 省略...
/**
* 根据角色 ID 集合批量查询
*
* @param roleIds
* @return
*/
List<RolePermissionDO> selectByRoleIds(@Param("roleIds") List<Long> roleIds);
}
```
编辑对应的 `xml` 文件,代码如下:
```csharp
// 省略...
<select id="selectByRoleIds" resultMap="BaseResultMap">
select role_id, permission_id
from t_role_permission_rel
where role_id in
<foreach collection="roleIds" item="roleId" separator="," open="(" close=")">
#{roleId}
</foreach>
</select>
// 省略...
```
> 上面的代码用于批量查询,以实现 `where role_id in (1, 2, 3)` 的效果。
>
> **代码解析**
>
> ```perl
> <foreach collection="roleIds" item="roleId" separator="," open="(" close=")">
> #{roleId}
> </foreach>
> ```
>
> + `<foreach>`MyBatis 提供的一个标签,用于在 SQL 语句中循环处理集合(如 List、数组等。它可以动态地生成 SQL 片段。
> + `collection="roleIds"`:指定要循环处理的集合名称。在这个例子中,`roleIds` 是传递给 MyBatis 映射方法的一个参数,它是一个包含多个角色 ID 的集合。
> + `item="roleId"`:指定在循环过程中每次迭代的当前项的变量名。在每次迭代中,集合中的当前元素会赋值给 `roleId`。
> + `separator=","`:指定在生成的 SQL 片段中,每个元素之间的分隔符。在这里,每个 `roleId` 之间会用逗号分隔。
> + `open="("` 和 `close=")"`:指定生成的 SQL 片段的开头和结尾。在这里,生成的 SQL 片段会被括号括起来。
### 查询 APP 端所有被启用的权限
在 [5.9 小节](https://www.quanxiaoha.com/column/10279.html) 中,我们已经定下了方案,网关中只对普通用户的操作进行鉴权,其他角色,如管理员等等,到时候在具体的管理后台服务中再鉴权,以保证网关做最少的工作,实现最大的吞吐量。编辑 `PermissionDOMapper` 接口,声明一个*查询 APP 端所有被启用的权限*方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.domain.mapper;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import java.util.List;
public interface PermissionDOMapper {
// 省略...
/**
* 查询 APP 端所有被启用的权限
*
* @return
*/
List<PermissionDO> selectAppEnabledList();
}
```
在对应的 `xml` 文件中,带上 `type = 3` 条件3 表示按钮权限,因为普通用户目前来看,只有按钮权限需要控制,如笔记发布、评论发布等。只同步这块的数据到 Redis 缓存中:
```bash
// 省略...
<select id="selectAppEnabledList" resultMap="BaseResultMap">
select id, name, permission_key from t_permission
where status = 0 and type = 3 and is_deleted = 0
</select>
// 省略...
```
## 定义角色-权限 Redis Key
接着,编辑 `RedisKeyConstants` 常量类,定义角色-权限的 Redis Key代码如下
```java
package com.quanxiaoha.xiaohashu.auth.constant;
public class RedisKeyConstants {
// 省略...
/**
* 角色对应的权限集合 KEY 前缀
*/
private static final String ROLE_PERMISSIONS_KEY_PREFIX = "role:permissions:";
/**
* 构建角色对应的权限集合 KEY
* @param roleId
* @return
*/
public static String buildRolePermissionsKey(Long roleId) {
return ROLE_PERMISSIONS_KEY_PREFIX + roleId;
}
}
```
## 编写 Runner 业务逻辑
业务层需要的查询方法封装完毕后,开始编写 `PushRolePermissions2RedisRunner` 的具体逻辑。最终要实现的 Redis 数据存储效果,如下图所示,每个角色 ID 下,保存其对应的权限 DO 数据,并且是以 JSON 字符串格式存储的:
![](https://img.quanxiaoha.com/quanxiaoha/171750342692096)
具体代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.runner;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RolePermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.PermissionDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RolePermissionDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author: 犬小哈
* @date: 2024/6/4 16:41
* @version: v1.0.0
* @description: 推送角色权限数据到 Redis 中
**/
@Component
@Slf4j
public class PushRolePermissions2RedisRunner implements ApplicationRunner {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private RoleDOMapper roleDOMapper;
@Resource
private PermissionDOMapper permissionDOMapper;
@Resource
private RolePermissionDOMapper rolePermissionDOMapper;
@Override
public void run(ApplicationArguments args) {
log.info("==> 服务启动,开始同步角色权限数据到 Redis 中...");
try {
// 查询出所有角色
List<RoleDO> roleDOS = roleDOMapper.selectEnabledList();
if (CollUtil.isNotEmpty(roleDOS)) {
// 拿到所有角色的 ID
List<Long> roleIds = roleDOS.stream().map(RoleDO::getId).toList();
// 根据角色 ID, 批量查询出所有角色对应的权限
List<RolePermissionDO> rolePermissionDOS = rolePermissionDOMapper.selectByRoleIds(roleIds);
// 按角色 ID 分组, 每个角色 ID 对应多个权限 ID
Map<Long, List<Long>> roleIdPermissionIdsMap = rolePermissionDOS.stream().collect(
Collectors.groupingBy(RolePermissionDO::getRoleId,
Collectors.mapping(RolePermissionDO::getPermissionId, Collectors.toList()))
);
// 查询 APP 端所有被启用的权限
List<PermissionDO> permissionDOS = permissionDOMapper.selectAppEnabledList();
// 权限 ID - 权限 DO
Map<Long, PermissionDO> permissionIdDOMap = permissionDOS.stream().collect(
Collectors.toMap(PermissionDO::getId, permissionDO -> permissionDO)
);
// 组织 角色ID-权限 关系
Map<Long, List<PermissionDO>> roleIdPermissionDOMap = Maps.newHashMap();
// 循环所有角色
roleDOS.forEach(roleDO -> {
// 当前角色 ID
Long roleId = roleDO.getId();
// 当前角色 ID 对应的权限 ID 集合
List<Long> permissionIds = roleIdPermissionIdsMap.get(roleId);
if (CollUtil.isNotEmpty(permissionIds)) {
List<PermissionDO> perDOS = Lists.newArrayList();
permissionIds.forEach(permissionId -> {
// 根据权限 ID 获取具体的权限 DO 对象
PermissionDO permissionDO = permissionIdDOMap.get(permissionId);
if (Objects.nonNull(permissionDO)) {
perDOS.add(permissionDO);
}
});
roleIdPermissionDOMap.put(roleId, perDOS);
}
});
// 同步至 Redis 中,方便后续网关查询鉴权使用
roleIdPermissionDOMap.forEach((roleId, permissionDO) -> {
String key = RedisKeyConstants.buildRolePermissionsKey(roleId);
redisTemplate.opsForValue().set(key, JsonUtils.toJsonString(permissionDO));
});
}
log.info("==> 服务启动,成功同步角色权限数据到 Redis 中...");
} catch (Exception e) {
log.error("==> 同步角色权限数据到 Redis 中失败: ", e);
}
}
}
```
## 集群部署Runner 多次同步的问题
![](https://img.quanxiaoha.com/quanxiaoha/171750391741087)
因为咱们的项目是微服务架构,在生产环境中,子服务必然是以集群的方式部署,那么,就会带来一个问题,每个服务启动后,都会跑一次 `PushRolePermissions2RedisRunner` , 就来带来多次同步 Redis 缓存的问题。虽然说,咱们这个业务场景下,多次同步问题也不大。但是,多少还是得控制一下,保证认证服务在一段时间内,如果多个服务多次启动,只能有一个服务去同步权限数据到 Redis 中。
### Redis 分布式锁
分布式锁是确保在分布式系统中多个节点能够协调一致地访问共享资源的一种机制。Redis 分布式锁通过 Redis 的原子操作,确保在高并发情况下,对共享资源的访问是互斥的。
> **实现思路**
>
> + 可以使用 Redis 的 `SETNX` 命令来实现。如果键不存在,则设置键值并返回 1表示加锁成功如果键已存在则返回 0表示加锁失败
> + 多个子服务同时操作 Redis , 第一个加锁成功,则可以同步权限数据;后续的子服务都会加锁失败,若加锁失败,则不同步权限数据;
> + 另外,结合 `EXPIRE` 命令为锁设置一个过期时间,比如 1 天,防止死锁。则在 1 天内,无论启动多少次认证服务,均只会同步一次数据。
### 开始实现
编辑 `PushRolePermissions2RedisRunner` 类,添加加锁控制,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.runner;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RolePermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.PermissionDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RolePermissionDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* @author: 犬小哈
* @date: 2024/6/4 16:41
* @version: v1.0.0
* @description: 推送角色权限数据到 Redis 中
**/
@Component
@Slf4j
public class PushRolePermissions2RedisRunner implements ApplicationRunner {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private RoleDOMapper roleDOMapper;
@Resource
private PermissionDOMapper permissionDOMapper;
@Resource
private RolePermissionDOMapper rolePermissionDOMapper;
// 权限同步标记 Key
private static final String PUSH_PERMISSION_FLAG = "push.permission.flag";
@Override
public void run(ApplicationArguments args) {
log.info("==> 服务启动,开始同步角色权限数据到 Redis 中...");
try {
// 是否能够同步数据: 原子操作,只有在键 PUSH_PERMISSION_FLAG 不存在时,才会设置该键的值为 "1",并设置过期时间为 1 天
boolean canPushed = redisTemplate.opsForValue().setIfAbsent(PUSH_PERMISSION_FLAG, "1", 1, TimeUnit.DAYS);
// 如果无法同步权限数据
if (!canPushed) {
log.warn("==> 角色权限数据已经同步至 Redis 中,不再同步...");
return;
}
// 查询出所有角色
List<RoleDO> roleDOS = roleDOMapper.selectEnabledList();
// 省略...
log.info("==> 服务启动,成功同步角色权限数据到 Redis 中...");
} catch (Exception e) {
log.error("==> 同步角色权限数据到 Redis 中失败: ", e);
}
}
}
```
### 自测一波
至此,项目初始化时,同步角色-权限数据到 Redis 中的功能就开发完毕了。重启项目,并查看 Redis 中的数据,来校验一下功能是否好使吧~
![](https://img.quanxiaoha.com/quanxiaoha/171750342692096)
![](https://img.quanxiaoha.com/quanxiaoha/171750503965128)
### 重构一下之前的代码
由于本小节中定义的 `PUSH_PERMISSION_FLAG` Redis Key 是使用 `.` 来连接的,而之前的小哈书全局 ID 生成器,又是使用 `_` 下划线来连接的,这里统一改为 `.` 连接,保证命名的一致性。
> **TIP** : 个人对于 Redis Key 的命名,如果有上下级的关系,在 Redis 中,能够以文件夹的形式,如用户的角色,就会以 `:` 分隔;否则以 `.` 分隔。
修改 `RedisKeyConstants` 全局常量类,如下图标注所示:
![](https://img.quanxiaoha.com/quanxiaoha/171750036452452)
```python
xiaohashu.id.generator
```
同时,查看之前 `xiaohashu_id_generator` 存储的 `value` 值,我这里的全局 ID 已经自增到了 10013。复制这个值再以 `xiaohashu.id.generator``key` , 将这个值保存一下,防止到时候新用户注册时,全局 ID 错乱:
```python
set xiaohashu.id.generator 10013
```
存储成功后,将老的删除掉,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171750054874739)
## 本小节源码下载
[https://t.zsxq.com/dYp1c](https://t.zsxq.com/dYp1c)

View File

@@ -0,0 +1,201 @@
在[星球第一个项目](https://www.quanxiaoha.com/column/10000.html) 中,我们实现用户认证鉴权功能,是通过 Spring Security 框架来开发的,很多小伙伴反馈,过程繁琐且不易理解,弯弯绕绕的,敲完整个脑壳子嗡嗡的。
![](https://img.quanxiaoha.com/quanxiaoha/171602105123912)
本项目中,我们将使用国产的 SaToken 权限认证框架,来实现这块的功能。
## 1\. 什么是 SaToken ?
**Sa-Token** 是一个轻量级 Java 权限认证框架,官网地址:[https://sa-token.cc/](https://sa-token.cc/) ,主要解决:**登录认证**、**权限认证**、**单点登录**、**OAuth2.0**、**分布式Session会话**、**微服务网关鉴权** 等一系列权限相关问题。
当你受够 Shiro、SpringSecurity 等框架的三拜九叩之后你就会明白相对于这些传统老牌框架Sa-Token 的 API 设计是多么的简单、优雅!
## 2\. Sa-Token 功能一览
Sa-Token 目前主要五大功能模块登录认证、权限认证、单点登录、OAuth2.0、微服务鉴权。
+ **登录认证** —— 单端登录、多端登录、同端互斥登录、七天内免登录。
+ **权限认证** —— 权限认证、角色认证、会话二级认证。
+ **踢人下线** —— 根据账号id踢人下线、根据Token值踢人下线。
+ **注解式鉴权** —— 优雅的将鉴权与业务代码分离。
+ **路由拦截式鉴权** —— 根据路由拦截鉴权,可适配 restful 模式。
+ **Session会话** —— 全端共享Session,单端独享Session,自定义Session,方便的存取值。
+ **持久层扩展** —— 可集成 Redis重启数据不丢失。
+ **前后台分离** —— APP、小程序等不支持 Cookie 的终端也可以轻松鉴权。
+ **Token风格定制** —— 内置六种 Token 风格,还可:自定义 Token 生成策略。
+ **记住我模式** —— 适配 \[记住我\] 模式,重启浏览器免验证。
+ **二级认证** —— 在已登录的基础上再次认证,保证安全性。
+ **模拟他人账号** —— 实时操作任意用户状态数据。
+ **临时身份切换** —— 将会话身份临时切换为其它账号。
+ **同端互斥登录** —— 像QQ一样手机电脑同时在线但是两个手机上互斥登录。
+ **账号封禁** —— 登录封禁、按照业务分类封禁、按照处罚阶梯封禁。
+ **密码加密** —— 提供基础加密算法,可快速 MD5、SHA1、SHA256、AES 加密。
+ **会话查询** —— 提供方便灵活的会话查询接口。
+ **Http Basic认证** —— 一行代码接入 Http Basic、Digest 认证。
+ **全局侦听器** —— 在用户登陆、注销、被踢下线等关键性操作时进行一些AOP操作。
+ **全局过滤器** —— 方便的处理跨域,全局设置安全响应头等操作。
+ **多账号体系认证** —— 一个系统多套账号分开鉴权(比如商城的 User 表和 Admin 表)
+ **单点登录** —— 内置三种单点登录模式同域、跨域、同Redis、跨Redis、前后端分离等架构都可以搞定。
+ **单点注销** —— 任意子系统内发起注销,即可全端下线。
+ **OAuth2.0认证** —— 轻松搭建 OAuth2.0 服务支持openid模式 。
+ **分布式会话** —— 提供共享数据中心分布式会话方案。
+ **微服务网关鉴权** —— 适配Gateway、ShenYu、Zuul等常见网关的路由拦截认证。
+ **RPC调用鉴权** —— 网关转发鉴权RPC调用鉴权让服务调用不再裸奔
+ **临时Token认证** —— 解决短时间的 Token 授权问题。
+ **独立Redis** —— 将权限缓存与业务缓存分离。
+ **Quick快速登录认证** —— 为项目零代码注入一个登录页面。
+ **标签方言** —— 提供 Thymeleaf 标签方言集成包,提供 beetl 集成示例。
+ **jwt集成** —— 提供三种模式的 jwt 集成方案,提供 token 扩展参数能力。
+ **RPC调用状态传递** —— 提供 dubbo、grpc 等集成包在RPC调用时登录状态不丢失。
+ **参数签名** —— 提供跨系统API调用签名校验模块防参数篡改防请求重放。
+ **自动续签** —— 提供两种Token过期策略灵活搭配使用还可自动续签。
+ **开箱即用** —— 提供SpringMVC、WebFlux、Solon 等常见框架集成包,开箱即用。
+ **最新技术栈** —— 适配最新技术栈:支持 SpringBoot 3.xjdk 17。
功能结构图:
![](https://img.quanxiaoha.com/quanxiaoha/171602123203943)
## 3\. 添加依赖
了解完了 SaToken 以及其优势后,准备将它整合到我们的项目中。编辑项目的最外层 `pom.xml` 文件,声明 SaToken 的版本号以及依赖:
> **TIP** : 写此篇文档时SaToken 的最新版本是 `1.38.0` 。
```php-template
// 省略...
<properties>
// 省略...
<sa-token.version>1.38.0</sa-token.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
// 省略...
```
接着,编辑 `xiaohashu-auth` 认证服务的 `pom.xml` , 添加该依赖:
```php-template
<dependencies>
// 省略...
<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
</dependencies>
```
添加完成后,记得刷新一下 Maven 依赖,将包下载到本地仓库中。
## 4\. 添加配置
依赖添加完毕后,编辑 `application.yml` 文件,添加 SaToken 基础配置项:
![](https://img.quanxiaoha.com/quanxiaoha/171602029226757)
内容如下:
```yaml
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: satoken
# token 有效期(单位:秒) 默认30天-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token
is-share: true
# token 风格默认可取值uuid、simple-uuid、random-32、random-64、random-128、tik
token-style: uuid
# 是否输出操作日志
is-log: true
```
## 5\. 添加登录接口、查询登录状态接口
然后,编辑 `TestController` 测试类,分别创建用户登录接口、查询登录状态接口,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171602039458161)
代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2024/5/4 12:53
* @description: TODO
**/
@RestController
public class TestController {
// 省略...
// 测试登录,浏览器访问: http://localhost:8080/user/doLogin?username=zhang&password=123456
@RequestMapping("/user/doLogin")
public String doLogin(String username, String password) {
// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对
if("zhang".equals(username) && "123456".equals(password)) {
StpUtil.login(10001);
return "登录成功";
}
return "登录失败";
}
// 查询登录状态,浏览器访问: http://localhost:8080/user/isLogin
@RequestMapping("/user/isLogin")
public String isLogin() {
return "当前会话是否登录:" + StpUtil.isLogin();
}
}
```
## 6\. 自测一波
以上为官方提供的示例接口,添加完成后,重启认证服务,观察控制台日志,若看到下图标注的部分,说明 SaToken 框架整合成功了:
![](https://img.quanxiaoha.com/quanxiaoha/171602057040177)
打开浏览器,访问地址 `http://localhost:8080/user/doLogin?username=zhang&password=123456` , 测试一下登录接口,若如下图所示,成功返回提示信息,说明登录成功了:
![](https://img.quanxiaoha.com/quanxiaoha/171602045285122)
登录成功后,访问地址 `http://localhost:8080/user/isLogin` , 验证一下登录状态,如下图所示,可以看到会话登录是成功的。
![](https://img.quanxiaoha.com/quanxiaoha/171602050007330)
初步尝鲜 SaToken 权限认证框架后,你可以明显感觉到,一个登录功能,如果使用 Spring Security 来实现,就会写一堆代码与配置,而 SaToken 几行代码就搞定了,很舒服有木有~
## 本小节源码下载
[https://t.zsxq.com/UzwcM](https://t.zsxq.com/UzwcM)

View File

@@ -0,0 +1,222 @@
![](https://img.quanxiaoha.com/quanxiaoha/171627964367648)
之前 [2.4 小节](https://www.quanxiaoha.com/column/10272.html) 中,我们已经将本地的 Redis 环境搭建好了。本小节中,将为认证服务整合 `RedisTemplate` 客户端,从而实现操作 Redis 缓存。
## 什么是 RedisTemplate ?
`RedisTemplate` 是 Spring Data Redis 提供的一个模板类,用于简化与 Redis 数据库的常见操作。它封装了与 Redis 交互的底层细节,提供了一套高层次的 API使得开发人员可以更加方便地进行数据存储、检索和管理操作。
使用它的优势如下:
+ **简化开发**`RedisTemplate` 提供了一套简单易用的 API封装了底层细节减少了开发人员的工作量使得与 Redis 的交互更加直观和高效。
+ **统一接口**:提供了统一的接口来操作不同的数据结构,使得代码更加简洁和易读。
+ **强大的配置能力**:通过 Spring 的配置文件或注解,开发人员可以轻松配置 `RedisTemplate` 的各项属性,如序列化方式、连接池配置等。
+ **高性能**:支持管道和批量操作,能够极大地提高 Redis 操作的性能,特别是在需要进行大量数据操作时。
+ **扩展性强**:可以结合 Spring 的其他功能模块,如 Spring AOP、Spring Security 等,构建更加复杂和功能丰富的应用程序。
+ **可靠的序列化机制**:通过配置不同的序列化方式,如 `StringRedisSerializer``Jackson2JsonRedisSerializer` 等,确保数据存储和检索的效率和可读性。
+ **与 Spring Boot 的无缝集成**Spring Boot 提供了自动配置,使用 `RedisTemplate` 变得更加方便,只需简单配置即可使用。
## 添加依赖
编辑 `xiaohashu-auth` 认证服务的 `pom.xml` 文件,添加相关依赖:
```php-template
// 省略...
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Redis 连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
// 省略...
```
## 添加配置
![](https://img.quanxiaoha.com/quanxiaoha/171627638068072)
接着,编辑 `xiaohashu-auth` 的 `applicaiton-dev.yml` 本地开发环境的配置文件,添加如下配置:
```yaml
spring:
datasource:
// 省略...
data:
redis:
database: 0 # Redis 数据库索引(默认为 0
host: 127.0.0.1 # Redis 服务器地址
port: 6379 # Redis 服务器连接端口
password: qwe123!@# # Redis 服务器连接密码(默认为空)
timeout: 5s # 读超时时间
connect-timeout: 5s # 链接超时时间
lettuce:
pool:
max-active: 200 # 连接池最大连接数
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
min-idle: 0 # 连接池中的最小空闲连接
max-idle: 10 # 连接池中的最大空闲连接
```
> **注意**:配置项位于 `spring` 节点下,并且和 `datasource` 同级,小伙伴们别配错位置了~
## 自定义 RedisTemplate
![](https://img.quanxiaoha.com/quanxiaoha/171627649669464)
然后,在认证服务中创建 `/config` 配置包,并添加 `RedisTemplateConfig` 配置类,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @author: 犬小哈
* @date: 2024/4/6 15:51
* @version: v1.0.0
* @description: RedisTemplate 配置
**/
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置 RedisTemplate 的连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 使用 StringRedisSerializer 来序列化和反序列化 redis 的 key 值,确保 key 是可读的字符串
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值, 确保存储的是 JSON 格式
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashValueSerializer(serializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
```
## 测试一波
配置工作完成后,添加一个 `RedisTests` 单元测试类,准备来测试一波通过 `RedisTemplate` 操作 Redis 是否好使:
![](https://img.quanxiaoha.com/quanxiaoha/171627680717323)
### 新增 key
首先编写一个新增 `key` 的单元测试,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
@SpringBootTest
@Slf4j
class RedisTests {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* set key value
*/
@Test
void testSetKeyValue() {
// 添加一个 key 为 name, value 值为 犬小哈
redisTemplate.opsForValue().set("name", "犬小哈");
}
}
```
运行该单元测试,观察控制台日志,如无报错信息,打开 Redis 图形客户端,刷新一下,不出意外,就可以看到新增的 `key` 了,并且值为犬小哈:
![](https://img.quanxiaoha.com/quanxiaoha/171627674043729)
### 其他命令测试
再来测试一下其他比较常用的操作,如判断某个 `key` 是否存在、根据 `key` 获取 `value` 值、删除 `key` ,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.auth;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
@SpringBootTest
@Slf4j
class RedisTests {
@Resource
private RedisTemplate<String, Object> redisTemplate;
// 省略...
/**
* 判断某个 key 是否存在
*/
@Test
void testHasKey() {
log.info("key 是否存在:{}", Boolean.TRUE.equals(redisTemplate.hasKey("name")));
}
/**
* 获取某个 key 的 value
*/
@Test
void testGetValue() {
log.info("value 值:{}", redisTemplate.opsForValue().get("name"));
}
/**
* 删除某个 key
*/
@Test
void testDelete() {
redisTemplate.delete("name");
}
}
```
小伙伴可以自己运行一下每个单元测试,看看是否能够操作成功,就不再一一截图了。
## 本小节源码下载
[https://t.zsxq.com/PYY54](https://t.zsxq.com/PYY54)

View File

@@ -0,0 +1,367 @@
本小节中,正式进入到第一个业务接口的开发工作 —— *获取短信验证码接口*。如下图所示,小红书是支持通过验证码来直接登录的:
![](https://img.quanxiaoha.com/quanxiaoha/171636259935259)
## 业务逻辑设计
我们来分析一下该接口的业务逻辑要如何写?流程图如下:
![](https://img.quanxiaoha.com/quanxiaoha/171636549794928)
> 解释一波逻辑:
>
> + 前端将手机号作为入参,请求获取短信验证码接口;
> + 后端拿到手机号,根据手机号构建 Redis Key , 如 `verification_code:18012349108`;
> + 查询 Redis , 判断该 Key 值是否存在;
> + 若已经存在说明验证码还在有效期内设置了3分钟有效期并提示用户*请求验证码太过频繁*
> + 若不存在,则生成 6 位随机数字作为验证码;
> + 调用第三方短信发送服务,比如阿里云的,将验证码发送到用户手机上;
> + 同时,将该验证码存储到 Redis 中,过期时间为 3 分钟,用于后续用户点击登录时,判断填写的验证码和缓存中的是否一致,以及判断用户获取验证码是否太过频繁。
## 接口定义
业务逻辑设计完毕后,接下来,定义一下此接口的请求地址、入参,以及出参。
### 接口地址
```bash
POST /verification/code/send
```
### 入参
```json
{
"phone": "18019939108" // 手机号
}
```
### 出参
```json
{
"success": false,
"message": "请求太频繁请3分钟后再试",
"errorCode": "AUTH-20000",
"data": null
}
```
## 新建入参 VO
![](https://img.quanxiaoha.com/quanxiaoha/171636285173559)
开始动手编码。编辑 `xiaohashu-auth` 认证服务,添加包 `/model/vo/verificationcode` , 并创建 `SendVerificationCodeReqVO` 入参实体类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.model.vo.verificationcode;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SendVerificationCodeReqVO {
@NotBlank(message = "手机号不能为空")
private String phone;
}
```
## 添加 guava、hutool、commons-lang3 工具
接着,编辑项目最外层的 `pom.xml` , 将一些比较流行的工具包依赖整合到项目中,如 `guava``hutool``commongs-lang3` , 比如等会就将使用 `hutool` 快捷的生成 6 位数字验证码,代码如下:
```php-template
<properties>
// 省略...
<guava.version>33.0.0-jre</guava.version>
<hutool.version>5.8.26</hutool.version>
<commons-lang3.version>3.12.0</commons-lang3.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- 相关工具类 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
// 省略...
```
版本号声明完毕后,编辑 `xiaoha-common` 模块的 `pom.xml` , 引入这些工具类:
```php-template
<dependencies>
// 省略...
<!-- 相关工具类 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
```
添加完毕后,记得刷新一下 Maven 将相关包下载到本地仓库中。
## 定义 Redis Key 常量类
![](https://img.quanxiaoha.com/quanxiaoha/171636356969058)
回到认证服务中,添加 `/constant` 常量类包,并创建 `RedisKeyConstants` 常量类,用于统一管理 Redis Key代码如下:
```typescript
package com.quanxiaoha.xiaohashu.auth.constant;
/**
* @author: 犬小哈
* @date: 2024/5/21 15:04
* @version: v1.0.0
* @description: TODO
**/
public class RedisKeyConstants {
/**
* 验证码 KEY 前缀
*/
private static final String VERIFICATION_CODE_KEY_PREFIX = "verification_code:";
/**
* 构建验证码 KEY
* @param phone
* @return
*/
public static String buildVerificationCodeKey(String phone) {
return VERIFICATION_CODE_KEY_PREFIX + phone;
}
}
```
> + 定义一个短信验证码 `key` 的前缀:`verification_code:` ;
> + 并封装一个拼接验证码完整 `key` 的静态构建方法,入参为手机号,方便等会在业务层中便捷的生成 `key`
## 定义获取短信验证码频繁错误码
编辑 `ResponseCodeEnum` 全局错误枚举类,添加 *请求太频繁请3分钟后再试* 对应的错误码枚举值,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.enums;
import com.quanxiaoha.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-08-15 10:33
* @description: 响应异常码
**/
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// 省略...
// ----------- 业务异常状态码 -----------
VERIFICATION_CODE_SEND_FREQUENTLY("AUTH-20000", "请求太频繁请3分钟后再试"),
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
## 编写 service 业务代码
![](https://img.quanxiaoha.com/quanxiaoha/171636412834093)
以上准备工作完成后,开始编写业务层代码。首先,添加 `/service/impl` 包,并创建 `VerificationCodeService` 业务接口,以及其实现类:
```kotlin
package com.quanxiaoha.xiaohashu.auth.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.model.vo.verificationcode.SendVerificationCodeReqVO;
public interface VerificationCodeService {
/**
* 发送短信验证码
*
* @param sendVerificationCodeReqVO
* @return
*/
Response<?> send(SendVerificationCodeReqVO sendVerificationCodeReqVO);
}
```
> 业务接口中定义一个发送短信验证码的方法。
```typescript
package com.quanxiaoha.xiaohashu.auth.service.impl;
import cn.hutool.core.util.RandomUtil;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.auth.model.vo.verificationcode.SendVerificationCodeReqVO;
import com.quanxiaoha.xiaohashu.auth.service.VerificationCodeService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class VerificationCodeServiceImpl implements VerificationCodeService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 发送短信验证码
*
* @param sendVerificationCodeReqVO
* @return
*/
@Override
public Response<?> send(SendVerificationCodeReqVO sendVerificationCodeReqVO) {
// 手机号
String phone = sendVerificationCodeReqVO.getPhone();
// 构建验证码 redis key
String key = RedisKeyConstants.buildVerificationCodeKey(phone);
// 判断是否已发送验证码
boolean isSent = redisTemplate.hasKey(key);
if (isSent) {
// 若之前发送的验证码未过期,则提示发送频繁
throw new BizException(ResponseCodeEnum.VERIFICATION_CODE_SEND_FREQUENTLY);
}
// 生成 6 位随机数字验证码
String verificationCode = RandomUtil.randomNumbers(6);
// todo: 调用第三方短信发送服务
log.info("==> 手机号: {}, 已发送验证码:【{}】", phone, verificationCode);
// 存储验证码到 redis, 并设置过期时间为 3 分钟
redisTemplate.opsForValue().set(key, verificationCode, 3, TimeUnit.MINUTES);
return Response.success();
}
}
```
> 业务逻辑已经在文章开头说明过了,不再赘述。需要注意的是,调用第三方短信发送服务的逻辑,这里先写个 `todo` , 等后续小节中再单独讲解如何接入。
## 编写 controller 控制层
![](https://img.quanxiaoha.com/quanxiaoha/171636427274815)
最后,创建 `/controller` 包,并添加 `VerificationCodeController` 控制器以及定义 `/verification/code/send` 接口,代码如下:
```less
package com.quanxiaoha.xiaohashu.auth.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.model.vo.verificationcode.SendVerificationCodeReqVO;
import com.quanxiaoha.xiaohashu.auth.service.VerificationCodeService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class VerificationCodeController {
@Resource
private VerificationCodeService verificationCodeService;
@PostMapping("/verification/code/send")
@ApiOperationLog(description = "发送短信验证码")
public Response<?> send(@Validated @RequestBody SendVerificationCodeReqVO sendVerificationCodeReqVO) {
return verificationCodeService.send(sendVerificationCodeReqVO);
}
}
```
## 自测一波
重启项目,通过 Apipost 工具来测试一下接口功能是否正常。如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171636448397433)
填写对应的手机号,点击*发送*按钮,可以看到响参提示 `success` 成功。再查看后台控制台日志,成功打印了生成了的验证码:
![](https://img.quanxiaoha.com/quanxiaoha/171647682620083)
连接到 Redis 中,验证一下对应的 Key 是否存在,以及值是否和控制台日志中的验证码一样,如下图所示,是没有问题的,同时右上角的 `ttl` 值就是该 Key 值还剩多长时间过期,过期后该 Key 会被自动删除:
![](https://img.quanxiaoha.com/quanxiaoha/171647713028313)
如果 Redis 中,该 Key 还未过期,再次请求获取验证码接口,也会正常提示【*请求太频繁请3分钟后再试*】信息:
![](https://img.quanxiaoha.com/quanxiaoha/171636459283576)
OK, 至此 ,获取手机短信验证码接口的大体逻辑,就开发完成啦~
## 本小节源码下载
[https://t.zsxq.com/vt5iP](https://t.zsxq.com/vt5iP)

View File

@@ -0,0 +1,173 @@
![](https://img.quanxiaoha.com/quanxiaoha/171645806573588)
[上小节](https://www.quanxiaoha.com/column/10274.html) 中,我们已经将*获取手机验证码接口*的大体逻辑开发完毕了,但是还留了个发送短信功能没写。这块需要调用第三方服务,属于网络 IO, 相对来说是比较耗时的操作,完全可以放到异步线程中来处理,以提升接口的响应速度。本小节中,我们就来为认证服务自定义一个线程池。
## 1\. 编写配置类
![](https://img.quanxiaoha.com/quanxiaoha/171645601197551)
`xiaohashu-auth` 认证服务中的 `/config` 包下,新建 `ThreadPoolConfig` 线程池配置类,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* @author: 犬小哈
* @date: 2024/5/23 15:40
* @version: v1.0.0
* @description: 自定义线程池
**/
@Configuration
public class ThreadPoolConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(50);
// 队列容量
executor.setQueueCapacity(200);
// 线程活跃时间(秒)
executor.setKeepAliveSeconds(30);
// 线程名前缀
executor.setThreadNamePrefix("AuthExecutor-");
// 拒绝策略:由调用线程处理(一般为主线程)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
// 设置等待时间,如果超过这个时间还没有销毁就强制销毁,以确保应用最后能够被关闭,而不是被没有完成的任务阻塞
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}
```
> 解释一下每行的代码的作用:
>
> 这段代码是用来配置和初始化一个线程池任务执行器 (`ThreadPoolTaskExecutor`) 的。在 Spring Boot 应用中,线程池可以用来处理并发任务,尤其是需要执行大量异步任务时,可以提高系统的响应速度和吞吐量。
>
> ```java
> ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
> ```
>
> + 创建一个 `ThreadPoolTaskExecutor` 实例。`ThreadPoolTaskExecutor` 是 **Spring 提供的一个方便的线程池封装类**,基于 JDK 的 `ThreadPoolExecutor` 实现。
>
> ```scss
> executor.setCorePoolSize(10);
> ```
>
> + 设置核心线程池的大小。核心线程池的线程数是线程池的基本大小,这些线程会一直存在,即使它们处于空闲状态。这里设置为 10意味着最少会有 10 个线程一直存活。
>
> ```scss
> executor.setMaxPoolSize(50);
> ```
>
> + 设置线程池最大线程数。这个值表示线程池中允许创建的最大线程数。当核心线程池的线程都在忙时,会创建新的线程来处理任务,但不会超过这个最大值。这里设置为 50。
>
> ```scss
> executor.setQueueCapacity(200);
> ```
>
> + 设置队列的容量。任务队列用于保存等待执行的任务。这里设置为 200意味着如果所有核心线程都在工作新任务会被放在这个队列中等待执行直到队列满为止。
>
> ```scss
> executor.setKeepAliveSeconds(30);
> ```
>
> + 设置线程的空闲时间。当线程池中线程数大于核心线程数时,多余的空闲线程的存活时间,超过这个时间会被销毁。这里设置为 30 秒。
>
> ```bash
> executor.setThreadNamePrefix("AuthExecutor-");
> ```
>
> + 设置线程名称的前缀。设置后,线程池中的线程名称会以这个前缀开头,便于在调试和监控时识别这些线程。
>
> ```cpp
> executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
> ```
>
> + 设置拒绝策略。当线程池达到最大线程数并且队列已满时,任务会被拒绝。`CallerRunsPolicy` 是一种拒绝策略,它会将任务返回给调用者线程执行,避免任务丢失。
>
> ```bash
> executor.setWaitForTasksToCompleteOnShutdown(true);
> ```
>
> + 设置线程池在关闭时是否等待所有任务完成。设置为 `true`,意味着线程池会等待所有任务完成再关闭。
>
> ```scss
> executor.setAwaitTerminationSeconds(60);
> ```
>
> + 设置线程池在关闭时等待任务完成的最大时间。这里设置为 60 秒,超过这个时间后,线程池会强制关闭,即使有任务未完成。
>
> ```scss
> executor.initialize();
> ```
>
> + 初始化线程池。必须调用此方法才能使配置生效并启动线程池。
## 2\. 测试一波
![](https://img.quanxiaoha.com/quanxiaoha/171645609487576)
自定义线程池配置类添加完毕后,创建一个名为 `ThreadPoolTaskExecutorTests` 的单元测试类,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* @author: 犬小哈
* @date: 2024/5/23 15:56
* @version: v1.0.0
* @description: TODO
**/
@SpringBootTest
@Slf4j
public class ThreadPoolTaskExecutorTests {
@Resource
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
/**
* 测试线程池
*/
@Test
void testSubmit() {
threadPoolTaskExecutor.submit(() -> log.info("异步线程中说: 犬小哈专栏"));
}
}
```
> 解释一下:
>
> + 注入 `ThreadPoolTaskExecutor` 类,注意别导错类了,该类的包路径为 `org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor`
> + 在单元测试方法中,异步打印一行日志;
运行该单元测试,观察控制台日志,可以看到成功打印了对应的日志,同时,该线程名为 `AuthExecutor-1` 说明自定义的异步线程池工作正常:
![](https://img.quanxiaoha.com/quanxiaoha/171645619369448)
## 本小节源码下载
[https://t.zsxq.com/NivmX](https://t.zsxq.com/NivmX)

View File

@@ -0,0 +1,339 @@
本小节中,我们将集成阿里云的短信发送服务,实现真正意义上的*发送验证码到手机*。
## 1\. 接入阿里云短信服务
访问[阿里云官网](https://www.aliyun.com/) 并登陆,在搜索框中搜索关键词:*短信发送* 搜索列表中即可看到对应的产品:
![](https://img.quanxiaoha.com/quanxiaoha/171653727050159)
点击它即可跳转产品介绍页,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171653731133822)
点击*免费开通*,跳转如下页面:
![](https://img.quanxiaoha.com/quanxiaoha/171653771893854)
> 想要正式使用短信发送,需要验证资质、申请签名、申请短信模板等步骤,申请资质这块比较麻烦,就不演示了。本小节直接使用官方提供的*测试签名/模板*,作个人测试使用,如果想上生产环境时,只需要将签名/模板申请好,后续代码开发阶段是一样的:
>
> + ①:点击左侧菜单栏的**快速学习和测试**
>
> + ②:**绑定测试手机号码**,填写的自己的手机号;
>
> + ③:勾选 **【专用】测试签名/模板**
>
> > *什么是短信模板?*
> >
> > 如下图所示,当你在 APP 端点击获取登录验证码后,发送到你手机上的短信格式,其实就是个固定的模板,动态变化的只有**验证码部分**。在阿里云后台配置好模板内容,验证码部分用一个**占位符**替代,接入阿里云 API 发送短信时,只需告诉阿里云你的验证码,短信服务会自行替换,并发送短信到手机。
> >
> > ![](https://img.quanxiaoha.com/quanxiaoha/171653840969982)
>
> + ④:点击**调用 API 发送短信**按钮;
>
## 2\. 添加阿里云 SDK
页面跳转后,大致如下,可以看到相关语言的接入示例代码,这里选择 *Java* ,同步发送短信的方式,不需要使用异步 API, 咱们项目中有自己的线程池。接着,点击右上角的 *SDK信息*
![](https://img.quanxiaoha.com/quanxiaoha/171653784309033)
即可看到短信 SDK 对应依赖以及版本号,将其复制出来:
![](https://img.quanxiaoha.com/quanxiaoha/171653789090365)
编辑项目的最外层 `pom.xml` , 添加短信服务 SDK 的版本号以及依赖:
```php-template
<properties>
// 省略...
<dysmsapi.version>2.0.24</dysmsapi.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- 阿里云短信发送 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>${dysmsapi.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
接着,编辑 `xiaohashu-auth` 认证服务的 `pom.xml` , 引入该依赖:
```php-template
// 省略...
<dependencies>
// 省略...
<!-- 阿里云短信发送 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
</dependency>
</dependencies>
// 省略...
```
最后,别忘了刷新一下 Maven 依赖,将 Jar 包下载到本地仓库中。
## 3\. 添加 AccessKey
查看发送短信的示例代码,你会发现需要填写阿里云的 `Access Key`,它是接入凭证。点击回到阿里云首页,将鼠标移动到登录用户的头像上,即可看到 `AccessKey` 选项,点击即可查看:
![](https://img.quanxiaoha.com/quanxiaoha/171653822624493)
> **TIP** : 记得给你的账号充值一点钱,比如 1 块钱,因为等会发送测试短信需要费用。
将你的 `AccessKeyID` 以及 `AccessKey Secret` 复制出来:
![](https://img.quanxiaoha.com/quanxiaoha/171653829408381)
![](https://img.quanxiaoha.com/quanxiaoha/171653902413231)
编辑 `xiaohashu-auth` 认证服务的 `application-dev.yml`, 为本地开发环境添加如下配置:
```yaml
aliyun: # 接入阿里云(发送短信使用)
accessKeyId: xxx # 填写你自己的
accessKeySecret: xxx # 填写你自己的
```
## 4\. 封装短信发送工具类
![](https://img.quanxiaoha.com/quanxiaoha/171653919287371)
前置工作完成后,开始封装短信发送工具类。创建 `/sms` 包,用于统一放置短信发送相关的代码。接着,新建 `AliyunAccessKeyProperties` 配置类,用于接收配置文件中填写的 `AccessKey` 信息:
```kotlin
package com.quanxiaoha.xiaohashu.auth.sms;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "aliyun")
@Component
@Data
public class AliyunAccessKeyProperties {
private String accessKeyId;
private String accessKeySecret;
}
```
然后,新建 `AliyunSmsClientConfig` 配置类,用于初始化一个短信发送客户端,注入到 Spring 容器中,以便后续使用:
> **TIP** : 客户端如何初始化,直接参考官方提供的示例代码即可,这里稍做封装。
```kotlin
package com.quanxiaoha.xiaohashu.auth.sms;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.teaopenapi.models.Config;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: 犬小哈
* @date: 2024/5/24 15:06
* @version: v1.0.0
* @description: 短信发送客户端
**/
@Configuration
@Slf4j
public class AliyunSmsClientConfig {
@Resource
private AliyunAccessKeyProperties aliyunAccessKeyProperties;
@Bean
public Client smsClient() {
try {
Config config = new Config()
// 必填
.setAccessKeyId(aliyunAccessKeyProperties.getAccessKeyId())
// 必填
.setAccessKeySecret(aliyunAccessKeyProperties.getAccessKeySecret());
// Endpoint 请参考 https://api.aliyun.com/product/Dysmsapi
config.endpoint = "dysmsapi.aliyuncs.com";
return new Client(config);
} catch (Exception e) {
log.error("初始化阿里云短信发送客户端错误: ", e);
return null;
}
}
}
```
最后,再创建一个 `AliyunSmsHelper` 短信发送工具类,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.auth.sms;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teautil.models.RuntimeOptions;
import com.quanxiaoha.framework.common.util.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @date: 2024/5/24 15:05
* @version: v1.0.0
* @description: 短信发送工具类
**/
@Component
@Slf4j
public class AliyunSmsHelper {
@Resource
private Client client;
/**
* 发送短信
* @param signName
* @param templateCode
* @param phone
* @param templateParam
* @return
*/
public boolean sendMessage(String signName, String templateCode, String phone, String templateParam) {
SendSmsRequest sendSmsRequest = new SendSmsRequest()
.setSignName(signName)
.setTemplateCode(templateCode)
.setPhoneNumbers(phone)
.setTemplateParam(templateParam);
RuntimeOptions runtime = new RuntimeOptions();
try {
log.info("==> 开始短信发送, phone: {}, signName: {}, templateCode: {}, templateParam: {}", phone, signName, templateCode, templateParam);
// 发送短信
SendSmsResponse response = client.sendSmsWithOptions(sendSmsRequest, runtime);
log.info("==> 短信发送成功, response: {}", JsonUtils.toJsonString(response));
return true;
} catch (Exception error) {
log.error("==> 短信发送错误: ", error);
return false;
}
}
}
```
## 5\. 业务层异步发送短信
回到 `VerificationCodeServiceImpl` 业务实现类中,将之前添加了 `todo` 注释,还没写完的代码补充上,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.auth.service.impl;
import cn.hutool.core.util.RandomUtil;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.auth.model.vo.verificationcode.SendVerificationCodeReqVO;
import com.quanxiaoha.xiaohashu.auth.service.VerificationCodeService;
import com.quanxiaoha.xiaohashu.auth.sms.AliyunSmsHelper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class VerificationCodeServiceImpl implements VerificationCodeService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource(name = "taskExecutor")
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
@Resource
private AliyunSmsHelper aliyunSmsHelper;
/**
* 发送短信验证码
*
* @param sendVerificationCodeReqVO
* @return
*/
@Override
public Response<?> send(SendVerificationCodeReqVO sendVerificationCodeReqVO) {
// 手机号
String phone = sendVerificationCodeReqVO.getPhone();
// 构建验证码 redis key
String key = RedisKeyConstants.buildVerificationCodeKey(phone);
// 判断是否已发送验证码
boolean isSent = redisTemplate.hasKey(key);
if (isSent) {
// 若之前发送的验证码未过期,则提示发送频繁
throw new BizException(ResponseCodeEnum.VERIFICATION_CODE_SEND_FREQUENTLY);
}
// 生成 6 位随机数字验证码
String verificationCode = RandomUtil.randomNumbers(6);
log.info("==> 手机号: {}, 已生成验证码:【{}】", phone, verificationCode);
// 调用第三方短信发送服务
threadPoolTaskExecutor.submit(() -> {
String signName = "阿里云短信测试";
String templateCode = "SMS_154950909";
String templateParam = String.format("{\"code\":\"%s\"}", verificationCode);
aliyunSmsHelper.sendMessage(signName, templateCode, phone, templateParam);
});
// 存储验证码到 redis, 并设置过期时间为 3 分钟
redisTemplate.opsForValue().set(key, verificationCode, 3, TimeUnit.MINUTES);
return Response.success();
}
}
```
> **注意**:通过 `@Resource` 注解注入 `ThreadPoolTaskExecutor` 线程池时,需要指定 `name = "taskExecutor"` , 否则可能会报错。
## 6\. 自测一波
OK , 异步发送短信逻辑补充完毕后,重启项目,再次测试获取短信验证码接口,手机号填写阿里云后台绑定的测试手机号:
![](https://img.quanxiaoha.com/quanxiaoha/171653950821478)
如上图所示,服务端响参成功,查看一下控制台日志,看看有无报错的情况。可以看到一切正常,控制台打印了发送的验证码:
![](https://img.quanxiaoha.com/quanxiaoha/171653959839427)
不出意外,这会你的手机就会收到一条信息,正是阿里云后台配置的测试模板的内容,验证码和控制台中打印的一致:*859041* 。
![](https://img.quanxiaoha.com/quanxiaoha/171653963007352)
至此,集成阿里云短信服务,并异步发送短信的功能就搞定啦~
## 本小节源码下载
[https://t.zsxq.com/xWNlA](https://t.zsxq.com/xWNlA)

View File

@@ -0,0 +1,208 @@
![](https://img.quanxiaoha.com/quanxiaoha/171663713925663)
在后端开发中数据校验是确保数据正确性非常重要的一个环节。Jakarta Validation以前是Bean ValidationJSR 380提供了一套丰富的标准注解来校验数据比如 `@NotNull``@NotBlank`等,想必小伙伴们已经不陌生了。
比如,前面小节开发的*获取手机验证码接口*中,就对入参的 `phone` 字段添加了 `@NotBlank` 字符串非空校验注解,如下图所示。然而,单单校验字符串非空是不够的,如果用户提交的手机号格式有问题呢,比如提交了非数字、或者数字不满 11 位等等,这类的校验是没有现成的校验注解以供使用的,这个时候,就需要自定义校验注解了。
![](https://img.quanxiaoha.com/quanxiaoha/171662305277550)
## 1\. 自定义校验规则
![](https://img.quanxiaoha.com/quanxiaoha/171662493038157)
接下来,我们就来亲手实现一个手机号校验注解。编辑 `xiaoha-common` 公共模块,添加 `/validator` 包,用于统一放置校验注解相关代码。首先,创建 `PhoneNumberValidator` 自定义校验类:
```typescript
package com.quanxiaoha.framework.common.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* @author: 犬小哈
* @date: 2024/4/15 22:23
* @version: v1.0.0
* @description: TODO
**/
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
@Override
public void initialize(PhoneNumber constraintAnnotation) {
// 这里进行一些初始化操作
}
@Override
public boolean isValid(String phoneNumber, ConstraintValidatorContext context) {
// 校验逻辑:正则表达式判断手机号是否为 11 位数字
return phoneNumber != null && phoneNumber.matches("\\d{11}");
}
}
```
> 解释一下:
>
> `PhoneNumberValidator` 是一个用于自定义校验注解 `@PhoneNumber` 的验证器类。它实现了 `ConstraintValidator` 接口,用于验证一个字符串是否符合特定的手机号格式。
>
> #### 1\. 实现 `ConstraintValidator` 接口
>
> ```typescript
> public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String>
> ```
>
> 这行代码表明 `PhoneNumberValidator` 类实现了 `ConstraintValidator<PhoneNumber, String>` 接口。`ConstraintValidator` 接口有两个泛型参数:
>
> + `PhoneNumber`:自定义注解类型。
> + `String`:被校验的属性类型。
>
> #### 2\. `initialize` 方法
>
> ```typescript
> @Override
> public void initialize(PhoneNumber constraintAnnotation) {
> }
> ```
>
> `initialize` 方法是用来执行初始化操作的。这个方法在校验器实例化后会被调用,通常用来读取注解中的参数来设置校验器的初始状态。在这里,我们没有任何初始化操作,所以方法体是空的。
>
> #### 3\. `isValid` 方法
>
> ```typescript
> @Override
> public boolean isValid(String phoneNumber, ConstraintValidatorContext context) {
> return phoneNumber != null && phoneNumber.matches("\\d{11}");
> }
> ```
>
> `isValid` 方法包含了实际的校验逻辑。它有两个参数:
>
> + `phoneNumber`:需要验证的字符串,即被注解的属性值。
> + `context`:提供了一些校验的上下文信息,通常用来设置错误消息等。
>
> 校验逻辑的详细解释如下:
>
> + `phoneNumber != null`:首先检查 `phoneNumber` 是否为 `null`。如果为 `null`,则返回 `false`,表示无效。这里也可以选择返回 `true`,具体取决于业务需求是否允许空值。
> + `phoneNumber.matches("\\d{11}")`:如果 `phoneNumber` 不为 `null`,接着使用正则表达式 `\\d{11}` 验证字符串是否为 11 位的数字。`\\d` 表示匹配一个数字字符,`{11}` 表示匹配前面的模式正好 11 次。因此,这个正则表达式确保字符串是一个长度为 11 的纯数字字符串。
## 2\. 自定义注解
接着,创建自定义注解 `@PhoneNumber`
> *如何创建自定义注解 `@interface` 类型的类?*
>
> 上个项目中,就有很多小伙伴提问,如何通过 IDEA 创建 `@interface` 类型的?在高版本的 IDEA 中,有 Annotation 类型可供选择,如下图所示。低版本中如果没有,也可以先创建一个 `class` 类,再手动将 `class` 关键字改成 `@interface` 即可。
>
> ![](https://img.quanxiaoha.com/quanxiaoha/171663402835656)
代码如下:
```java
package com.quanxiaoha.framework.common.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* @author: 犬小哈
* @date: 2024/4/15 22:22
* @version: v1.0.0
* @description: 自定义手机号校验注解
**/
@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface PhoneNumber {
String message() default "手机号格式不正确, 需为 11 位数字";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
```
> 解释一下注解的各个部分的作用:
>
> #### 1\. `@Target`
>
> ```java
> @Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.PARAMETER })
> ```
>
> `@Target` 注解用于指定自定义注解可以应用的 Java 元素类型。在 `@PhoneNumber` 中,`@Target` 的参数包括以下几个元素类型:
>
> + `ElementType.METHOD`:可以应用于方法。
> + `ElementType.FIELD`:可以应用于字段。
> + `ElementType.ANNOTATION_TYPE`:可以应用于其他注解。
> + `ElementType.PARAMETER`:可以应用于方法参数。
>
> 这种组合使得 `@PhoneNumber` 注解可以被广泛使用在方法、字段、注解和参数上。
>
> #### 2\. `@Retention`
>
> ```java
> @Retention(RetentionPolicy.RUNTIME)
> ```
>
> `@Retention` 注解用于指定自定义注解的保留策略。`RetentionPolicy.RUNTIME` 表示该注解在运行时仍然可用(可以通过反射机制访问)。这对于校验注解非常重要,因为校验框架需要在运行时读取注解并执行相应的校验逻辑。
>
> #### 3\. `@Constraint`
>
> ```python
> @Constraint(validatedBy = PhoneNumberValidator.class)
> ```
>
> `@Constraint` 注解用于指定关联的验证器类。在 `@PhoneNumber` 中,`validatedBy` 属性指向 `PhoneNumberValidator.class`,即自定义注解 `@PhoneNumber` 使用 `PhoneNumberValidator` 类进行校验。
>
> #### 4\. `message`
>
> ```csharp
> String message() default "手机号格式不正确, 需为 11 位数字";
> ```
>
> `message` 元素用于定义验证失败时的错误消息。在使用注解时可以覆盖默认消息。`default` 关键字用于提供该元素的默认值。
## 3\. 使用 @PhoneNumber 注解
![](https://img.quanxiaoha.com/quanxiaoha/171662516575933)
自定义注解开发完毕后,我们为*获取手机验证码接口*的入参实体类中的 `phone` 字段,添加上此注解,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.model.vo.verificationcode;
import com.quanxiaoha.framework.common.validator.PhoneNumber;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SendVerificationCodeReqVO {
@NotBlank(message = "手机号不能为空")
@PhoneNumber
private String phone;
}
```
## 3\. 自测一波
最后,重启项目,来自测一波功能好不好使。重新请求接口,同时故意将 `phone` 手机号少写一位,点击*发送*
![](https://img.quanxiaoha.com/quanxiaoha/171662526843014)
可以看到,自定义的 `@PhoneNumer` 校验注解工作正常,服务端成功返回了*手机号格式不正确,需为 11 位数字*的默认提示~
## 本小节源码下载
[https://t.zsxq.com/RMxmf](https://t.zsxq.com/RMxmf)

View File

@@ -0,0 +1,119 @@
![](https://img.quanxiaoha.com/quanxiaoha/171681124675582)
之前小节中,我们已经创建了用户表,然而,在一个系统中,往往都需要对用户进行权限控制。比如在小红书系统中,普通用户拥有发笔记、点赞、评论的权限;管理员有更高级的权限,比如某个用户违反了社区规则,管理员能够禁用某个用户发笔记、评论的权限等等。那么,针对此类功能要如何实现呢?
本小节中,我们就来介绍一下流行的 RBAC 权限控制模型。
## 什么是 RBAC 模型?
RBACRole-Based Access Control是一种**基于角色的访问控制**。它通过角色来管理用户的权限。RBAC 的核心思想是将用户与角色进行关联,并将权限分配给角色,而不是直接分配给用户。这样,通过改变用户的角色,就可以灵活地控制用户的权限。
RBAC 的主要组成部分包括:
+ **用户User**:系统的使用者。
+ **角色Role**:权限的集合,一个角色可以包含多个权限。
+ **权限Permission**:对系统资源的访问操作,如读取、写入、删除等。
![](https://img.quanxiaoha.com/quanxiaoha/171678511259190)
## 模型拓展
在实际的业务场景中,往往有着更复杂的权限控制需求,于是乎,又扩展出了 RBAC 1、RBAC 2 和 RBAC 3。这些模型在 RBAC 的基础上,增加了更多的功能,以适应不同的业务场景。
### RBAC 0
即上面所讲的 RBAC 模型,基于用户-角色-权限的模型。
![](https://img.quanxiaoha.com/quanxiaoha/171678532182829)
### RBAC 1基于角色的层次模型Role Hierarchies
RBAC 1 在 RBAC 0 的基础上增加了角色**层次结构**Role Hierarchies。角色层次结构允许角色之间存在**继承关系**,一个角色可以继承另一个角色的权限。
#### 主要特点
+ **角色继承**一个角色可以继承另一个角色的所有权限。比如角色B继承角色 A 的权限,那么角色 B 不仅拥有自己定义的权限,还拥有角色 A 的所有权限。
+ **权限传递**:继承关系是传递的,如果角色 C 继承角色 B而角色 B 继承角色 A那么角色 C 将拥有角色 A 和角色 B 的所有权限。
#### 优点
+ **简化权限管理**:通过角色继承,可以减少重复定义权限的工作。
+ **提高灵活性**:可以方便地对角色进行分层管理,满足不同层次用户的权限需求。
#### 场景举例
在一个企业系统中高级经理Senior Manager角色继承经理Manager角色的权限经理角色继承员工Employee角色的权限。这样高级经理角色不仅拥有自己的权限还拥有经理和员工的所有权限。
### RBAC 2基于约束的 RBAC 模型Constraints
![](https://img.quanxiaoha.com/quanxiaoha/171681085901249)
RBAC 2 同样建立在 RBAC 0 基础之上,但是增加了**约束**Constraints。约束是用于加强访问控制策略的规则或条件可以限制用户、角色和权限的关联方式。
#### 主要特点
+ **互斥角色**:某些角色不能同时赋予同一个用户。例如,审计员和财务员角色不能同时赋予同一个用户,以避免暗黑交易。
+ **先决条件**:用户要获得某个角色,必须先拥有另一个角色。例如,公司研发人员要成为高级程序员,必须先成为中级程序员。
+ **基数约束**:限制某个角色可以被赋予的用户数量。例如,某个项目的经理角色只能赋予一个用户,以确保项目的唯一责任人。
#### 优点:
+ **加强安全性**:通过约束规则,可以避免权限滥用和利益冲突。
+ **精细化管理**:可以更精细地控制用户的角色分配和权限管理。
#### 场景举例
在一个金融系统中,为了避免利益冲突,定义了互斥角色规则:审计员和财务员角色不能同时赋予同一个用户。这样可以确保审计员和财务员的职责分离,增强系统的安全性。
### RBAC 3统一模型Consolidated Model
![](https://img.quanxiaoha.com/quanxiaoha/171679658424329)
RBAC 3 是最全面的 RBAC 模型,它结合了 RBAC1 的角色层次结构和 RBAC2 的约束,形成一个统一的模型,提供了最大程度的灵活性和安全性。
#### 主要特点
+ **包含RBAC 1的所有功能**:角色层次结构,角色继承和权限传递。
+ **包含RBAC 2的所有功能**:互斥角色、先决条件角色和角色卡数限制等约束规则。
+ **综合管理**:可以同时利用角色继承和约束规则,提供最全面的权限管理解决方案。
#### 优点
+ **高灵活性**:可以满足各种复杂的权限管理需求。
+ **高安全性**:通过约束规则,进一步加强权限管理的安全性。
#### 场景举例
在一个大型企业系统中需要复杂的权限管理策略。RBAC 3 模型可以通过角色层次结构定义不同层级的员工权限,通过约束规则确保权限分配的安全性。例如,高级经理角色继承经理角色的权限,但为了避免利益冲突,财务员和审计员角色互斥,不能同时赋予同一个用户。
### 基于 RBAC 的延展:用户组
*管理员手动为每一个用户分配角色,也太繁琐了!*
![](https://img.quanxiaoha.com/quanxiaoha/171679671421833)
在实际业务场景中,举个栗子,比如销售部门,分配到此部门的员工都是销售员,拥有同一类角色。如果要为每一个员工手动分配角色,就显得非常繁琐了,而且容易出错。于是乎,在系统设计上,引入了**用户组**的概念,我们可以把销售部看成一个用户组,对用户组提前分配好角色,这样后续只需将员工拉入该部门,即可拥有该部门已分配的权限。
## 要控制哪些权限?
RBAC 模型是为了更加灵活的控制权限。那么问题来了,*需要控制的权限通常都有哪些?*
在系统设计时,通常你需要考虑以下几类权限:
1. **菜单权限**:控制用户在管理后台中,可以看到的菜单项与页面。
2. **操作权限**:控制用户可以执行的具体操作。比如新增、删除、修改按钮的权限。
3. **数据权限**:控制用户可以访问的数据范围。比如只能看到本部门的数据,其他部门的员工登录则无法查看。
4. **字段权限**:控制用户可以查看或编辑的字段。
5. **等等...**
具体还得结合你的业务来,没有绝对,毕竟技术服务于业务。
## 结语
因为马上就要开发用户注册、登录功能了,所以在本小节中,小哈带着大家了解了什么是 RBAC 权限模型,以及为了满足更复杂的业务场景,后续又延伸出来的几个模型,包括 RBAC 1、RBAC 2、RBAC 3、用户组概念最后通过 RBAC 模型,通常需要控制的权限都有哪些。

View File

@@ -0,0 +1,195 @@
[上小节](https://www.quanxiaoha.com/column/10278.html) 中,我们已经了解了 RBAC 权限模型。本小节中,我们先来将 RBAC 模型对应的表现设计好。
## 表设计
![](https://img.quanxiaoha.com/quanxiaoha/171687580535249)
除了已经创建好的 `t_user` 用户表外,还需另外创建 4 张表,如上图所示:
+ **角色表;**
+ **权限表;**
+ **角色权限关联表;**
+ **用户角色关联表;**
### 角色表、权限表
角色表与权限表的建表语句如下:
```sql
CREATE TABLE `t_role` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`role_name` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色名',
`role_key` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色唯一标识',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态(0启用 1禁用)',
`sort` int unsigned NOT NULL DEFAULT 0 COMMENT '管理系统中的显示顺序',
`remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后一次更新时间',
`is_deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除(0未删除 1已删除)',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_role_key` (`role_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';
```
```sql
CREATE TABLE `t_permission` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`parent_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '父ID',
`name` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '权限名称',
`type` tinyint unsigned NOT NULL COMMENT '类型(1目录 2菜单 3按钮)',
`menu_url` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜单路由',
`menu_icon` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜单图标',
`sort` int unsigned NOT NULL DEFAULT 0 COMMENT '管理系统中的显示顺序',
`permission_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '权限标识',
`status` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '状态(0启用1禁用)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除(0未删除 1已删除)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='权限表';
```
> 着重讲解一下权限表中,几个字段的作用:
>
> + `parent_id` : 父 `ID` , 用于构建权限的树结构;
>
> + `type` : 权限类型,前台模块,主要是按钮,也可以理解为操作,即用户有没有发笔记、发评论等等的权限,这块比较好理解;后台管理则是控制*目录 -> 菜单 -> 按钮*的权限,如下图所示:
>
> ![](https://img.quanxiaoha.com/quanxiaoha/171689046816345)
>
> + `menu_url` 当权限类型为菜单时,配置前端路由地址;
>
> + `icon`: 当权限类型为目录、菜单时,自定义图标;
>
> + `permission_key` : 权限唯一标识,如 `system:role:add` , 用于表示后台角色新增的权限 ,供权限框架使用;
>
> **TIP** : 目前相关表定义的字段,只是最基础的 RBAC 模型,后续随着业务功能的迭代,如果当前表结构无法满足业务需求,到时候,我们再继续更新表结构。
### 关联表
接着是,用户角色关联表、角色权限关联,建表语句如下:
```sql
CREATE TABLE `t_user_role_rel` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint unsigned NOT NULL COMMENT '用户ID',
`role_id` bigint unsigned NOT NULL COMMENT '角色ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除(0未删除 1已删除)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户角色表';
```
```sql
CREATE TABLE `t_role_permission_rel` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`role_id` bigint unsigned NOT NULL COMMENT '角色ID',
`permission_id` bigint unsigned NOT NULL COMMENT '权限ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除(0未删除 1已删除)',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户权限表';
```
## 架构设计
表设计完成后,我们来说说鉴权的架构设计。通常来说,生产环境中,微服务通常都会部署在内网环境中,外网无法直接访问,想要访问相关服务,必须通过暴露在公网的 Ngnix 集群来反向代理到网关,再由网关统一进行转发,打到具体的服务上,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171689420526718)
### 鉴权放哪里合适?
关于用户认证(登录),我们已经知道是通过认证服务来处理。那么问题来了,鉴权在哪一层处理呢?通常来说,有以下 3 种方案:
1. **每个微服务各自鉴权**
2. **网关统一鉴权**
3. **混合策略**
#### 缺点对比
##### 第一种方案:每个微服务各自鉴权
| 优点 | 缺点 |
| --- | --- |
| **分散负载**:每个微服务自己处理鉴权请求,可以分散系统负载,避免单点瓶颈。 | **重复代码**:每个微服务都需要实现鉴权逻辑,导致代码重复,维护成本增加。 |
| **独立性强**:微服务独立进行鉴权,不依赖于其他服务,减少了系统之间的耦合度。 | **不一致性风险**:不同服务间可能存在鉴权逻辑不一致的风险,影响系统整体安全性。 |
| **灵活性高**:每个微服务可以根据自身特点和需求定制鉴权逻辑,灵活性更高。 | **增加开发和部署复杂度**:每个微服务都需要处理鉴权,增加了开发和部署的复杂度。 |
##### 第二种方案:网关统一鉴权
| 优点 | 缺点 |
| --- | --- |
| **集中管理**:鉴权逻辑集中在网关,易于管理和维护,避免了代码重复和不一致性风险。 | **单点瓶颈**:网关成为鉴权的单点,如果网关出现性能瓶颈或故障,会影响整个系统的可用性。 |
| **简化微服务**:微服务不需要处理鉴权逻辑,专注于业务逻辑开发,简化了微服务的开发和维护。 | **复杂性增加**:网关需要处理大量的鉴权请求,增加了网关的实现和维护复杂度。 |
| **性能优化**:统一鉴权可以利用缓存、负载均衡等技术优化性能,提升系统整体效率。 | **延迟增加**:所有请求都要经过网关进行鉴权,可能会增加请求的响应时间。 |
##### 第三种方案:混合策略
在实际项目中,也可以采用混合策略,即在网关进行初步鉴权,进行粗粒度的控制,然后在关键微服务中进行细粒度的二次鉴权。这种方式可以兼顾性能和安全性。
#### 最终选择的方案
本项目中,我们将采用**混合策略**的方案。因为小红书属于 `To C` 的产品,普通用户是核心,我们需要保证这块的服务尽可能的稳定。而普通用户的鉴权策略又比较简单,基本上线后就不用太频繁迭代了,所以将普通用户的鉴权统一放到网关层。至于管理后台,到时候我们再拆分一个服务,管理员、运营人员的鉴权较为复杂的,单独放到这个服务中去处理。
### 权限数据获取方案?
大致有如下 4 种方案:
+ 1. 网关自己集成 ORM 框架,如 MyBatis 直接操作数据库查询;
> **优点:**
>
> + **实时性强**:每次请求都会从数据库中获取最新的权限数据,保证数据的实时性和准确性。
> + **简化架构**:不需要额外的缓存层或远程调用,直接通过 ORM 框架操作数据库,结构简单。
>
> **缺点:**
>
> + **性能瓶颈**:每次请求都要访问数据库,容易导致数据库压力过大,性能下降,特别是并发量高时。
> + **可扩展性差**:随着用户和权限数据的增多,数据库可能成为瓶颈,扩展性较差。
+ 2. 网关先从 Redis 中获取权限数据,若获取不到,再从数据库查询;
> **优点:**
>
> + **提高性能**:大部分请求可以直接从 Redis 中获取权限数据,减轻数据库负担,提高响应速度。
> + **降低数据库压力**:减少直接访问数据库的频率,通过缓存分流,提高系统稳定性。
>
> **缺点:**
>
> + **数据一致性问题**:缓存中的数据可能不是最新的,需要设计缓存更新机制(如缓存失效策略)。
> + **复杂性增加**:需要额外处理缓存的维护和更新逻辑,增加了系统的复杂性。
+ 3. 网关先从 Redis 中获取权限数据,若获取不到,走 RPC 调用权限服务获取数据;
> **优点:**
>
> + **提高性能**:大部分请求可以从 Redis 获取,减轻数据库和权限服务的负担。
> + **服务解耦**:通过 RPC 调用权限服务获取数据,网关和权限服务解耦,提高系统的灵活性和可维护性。
>
> **缺点:**
>
> + **网络开销**:每次缓存未命中的情况下,需要进行远程调用,增加了网络通信的开销。
> + **系统复杂性**:引入了 RPC 调用机制,需要处理服务之间的通信和容错机制,增加了系统的复杂性。
+ 4. 网关只从 Redis 中获取权限数据;
> **优点:**
>
> + **极高性能**:所有请求都从 Redis 中获取数据,极大提升了响应速度和系统性能。
> + **减轻数据库压力**:完全不访问数据库,数据库的负载压力最小。
>
> **缺点:**
>
> + **数据一致性**:必须确保 Redis 中的权限数据及时更新,否则可能出现数据不一致问题。
> + **单点故障风险**:如果 Redis 出现问题(如宕机),整个系统的权限获取都会受到影响,需要考虑高可用和容灾机制。
#### 最终选择的方案
本项目中,我们将**采用第四种方案**,只从 Redis 中获取权限数据,以保证网关拥有更高的吞吐量。

View File

@@ -0,0 +1,197 @@
![](https://img.quanxiaoha.com/quanxiaoha/171757270953495)
在微服务架构中,随着服务的数量和复杂度不断增加,服务发现和配置管理成为了开发和运维中的重要挑战。本小节中,我们就将学习 NacosDynamic Naming and Configuration Service它是阿里巴巴开源的一个服务发现、配置管理和服务管理平台。
## 什么是 Nacos ?
> 以下介绍性文字摘取自 Nacos 官网:[https://nacos.io/](https://nacos.io/) ,更多信息请访问官网了解。
Nacos `/nɑ:kəʊs/` 是 Dynamic Naming and Configuration Service 的首字母简称,**它是一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。**
Nacos 致力于帮助您发现、配置和管理微服务。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理。
Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。 Nacos 是构建以“服务”为中心的现代应用架构 (例如微服务范式、云原生范式) 的服务基础设施。
## Nacos 主要特性
+ **服务发现和服务健康监测**
> Nacos 支持基于 DNS 和基于 RPC 的服务发现。服务提供者使用 [原生SDK](https://nacos.io/docs/latest/guide/user/sdk/) 、[OpenAPI](https://nacos.io/docs/latest/guide/user/open-api/) 、或一个[独立的Agent TODO](https://nacos.io/docs/latest/guide/user/other-language/) 注册 Service 后,服务消费者可以使用[DNS TODO](https://nacos.io/docs/latest/ecology/use-nacos-with-coredns/) 或[HTTP&API](https://nacos.io/docs/latest/guide/user/open-api/) 查找和发现服务。
>
> Nacos 提供对服务的实时的健康检查阻止向不健康的主机或服务实例发送请求。Nacos 支持传输层 (PING 或 TCP)和应用层 (如 HTTP、MySQL、用户自定义的健康检查。 对于复杂的云环境和网络拓扑环境中(如 VPC、边缘网络等服务的健康检查Nacos 提供了 agent 上报模式和服务端主动检测2种健康检查模式。Nacos 还提供了统一的健康检查仪表盘,帮助您根据健康状态管理服务的可用性及流量。
+ **动态配置服务**
> 动态配置服务可以让您以中心化、外部化和动态化的方式管理所有环境的应用配置和服务配置。
>
> 动态配置消除了配置变更时重新部署应用和服务的需要,让配置管理变得更加高效和敏捷。
>
> 配置中心化管理让实现无状态服务变得更简单,让服务按需弹性扩展变得更容易。
>
> Nacos 提供了一个简洁易用的UI ([控制台样例 Demo](http://console.nacos.io/nacos/index.html) ) 帮助您管理所有的服务和应用的配置。Nacos 还提供包括配置版本跟踪、金丝雀发布、一键回滚配置以及客户端配置更新状态跟踪在内的一系列开箱即用的配置管理特性,帮助您更安全地在生产环境中管理配置变更和降低配置变更带来的风险。
+ **动态 DNS 服务**
> 动态 DNS 服务支持权重路由让您更容易地实现中间层负载均衡、更灵活的路由策略、流量控制以及数据中心内网的简单DNS解析服务。动态DNS服务还能让您更容易地实现以 DNS 协议为基础的服务发现,以帮助您消除耦合到厂商私有服务发现 API 上的风险。
>
> Nacos 提供了一些简单的 [DNS APIs TODO](https://nacos.io/docs/latest/ecology/use-nacos-with-coredns/) 帮助您管理服务的关联域名和可用的 IP 列表.
+ **服务及其元数据管理**
> Nacos 能让您从微服务平台建设的视角管理数据中心的所有服务及元数据,包括管理服务的描述、生命周期、服务的静态依赖分析、服务的健康状态、服务的流量管理、路由及安全策略、服务的 SLA 以及最首要的 metrics 统计数据。
## Nacos 地图
![](https://img.quanxiaoha.com/quanxiaoha/171757313844829)
## 安装 Nacos
接下来,我们就先把本地的 Nacos 环境搭建好,下面分别演示两种安装方式,小伙伴们任选其一即可。
### 版本选择
查看官方文档,写这篇文章的时候,目前 Nacos 的稳定版本为 *2.2.3* 如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171757379244040)
### 方式1安装包安装 Nacos推荐
#### 环境准备
Nacos 依赖 Java 环境来运行,所以,需要确保你的机器上已经安装好了 JDK 1.8+ 版本。
#### 下载安装包
浏览器访问地址:[https://nacos.io/download/release-history/](https://nacos.io/download/release-history/) ,找到 2.2.3 版本,点击并下载对应版本的安装包:
![](https://img.quanxiaoha.com/quanxiaoha/173727038315776)
#### 解压安装包
下载完成后,解压到某个文件夹下,然后进入 `/bin` 目录下,打开终端:
![](https://img.quanxiaoha.com/quanxiaoha/173727126293955)
#### 启动 Nacos
在终端中,运行如下启动命令 ( `standalone` 代表着单机模式运行,非集群模式) :
```bash
./startup.cmd -m standalone
```
当看到 `Tomcat started on port(s): 8848 (http) with context path '/nacos'` 提示信息,说明 Nacos 启动成功了:
![](https://img.quanxiaoha.com/quanxiaoha/173727137430620)
#### 访问控制台
浏览器访问地址:[http://localhost:8848/nacos](http://localhost:8848/nacos) ,即可进入到 Nacos 的控制后台,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171758000252568)
至此,通过安装包的方式,已经将本地 Nacos 环境搭建好了!
### 方式2Docker 安装 Nacos
接下来, 再演示一下第二种方式,通过 Docker 将本地 Nacos 环境搭建起来。
#### 准备挂载的文件夹
在拉取 Nacos 镜像之前,在 `E:\docker` 文件夹下,创建一个 `/nacos` 文件夹,等会运行容器时,用于将 Nacos 容器中的配置文件、持久化文件挂载出来,防止容器重启时数据丢失的问题:
![](https://img.quanxiaoha.com/quanxiaoha/171757364473066)
#### 拉取镜像
那么,我们就下载 2.2.3 版本的 Nacos 镜像,运行命令如下:
```bash
docker pull nacos/nacos-server:v2.2.3
```
![](https://img.quanxiaoha.com/quanxiaoha/171757384857169)
执行完成后,执行如下命令,查看本地已下载的镜像列表,确认一下镜像是否下载成功了:
```undefined
docker images
```
![](https://img.quanxiaoha.com/quanxiaoha/171757389992244)
#### 运行一个简单的容器
镜像下载成功后,运行如下命令,运行一个 Nacos 容器:
```bash
docker run -d --name nacos --env MODE=standalone -p 8848:8848 -p 9848:9848 nacos/nacos-server:v2.2.3
```
> 解释一下各项参数的作用:
>
> 1. **docker run**:这是启动一个新的容器的命令。
> 2. **\-d**:这个选项告诉 Docker 在后台detached mode运行容器这样你就不会看到容器的输出日志它将在后台运行。
> 3. **\--name nacos**:为新创建的容器指定一个名字,方便后续的管理和操作,这里命名为 `nacos`。
> 4. **\--env MODE=standalone**:设置容器内环境变量。这里设置 `MODE=standalone`,表示 Nacos 以单机模式运行而不是集群模式。Nacos 支持两种模式:单机模式和集群模式。单机模式适用于开发和测试环境,集群模式适用于生产环境。
> 5. **\-p 8848:8848**:将宿主机的 8848 端口映射到容器内的 8848 端口。Nacos 的默认服务端口是 8848外部访问时需要通过该端口。
> 6. **\-p 9848:9848**:将宿主机的 9848 端口映射到容器内的 9848 端口。这个端口通常用于 Nacos 的监控和管理。
> 7. **nacos/nacos-server.2.3**:指定要运行的镜像和版本。这里使用的是 `nacos/nacos-server` 镜像的 `v2.2.3` 版本。
命令执行完毕后,通过 `docker ps` 命令查看一下正在运行中的 Docker 容器,确认一下容器是否正常跑起来了,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171758072053230)
#### 访问控制台
浏览器访问地址:[http://localhost:8848/nacos](http://localhost:8848/nacos) ,即可进入到 Nacos 的控制后台,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171758000252568)
#### 复制配置文件、数据文件
Nacos 容器跑起来后,我们将容器中的配置文件,以及数据文件复制到宿主机中。执行如下命令:
```bash
docker cp nacos:/home/nacos/conf E:\docker\nacos
docker cp nacos:/home/nacos/data E:\docker\nacos
```
> **TIP** : 复制到前面准备好的 `E:\docker\nacos` 文件夹下。
![](https://img.quanxiaoha.com/quanxiaoha/171757539834934)
复制成功后,打开对应文件夹,看看是否复制成功了:
![](https://img.quanxiaoha.com/quanxiaoha/171757551250469)
#### 重新跑一个 Nacos 容器
最后,执行如下命令,强制删除正在运行中的 Nacos 容器:
```bash
# 删除 nacos 容器
docker rm -f nacos
```
![](https://img.quanxiaoha.com/quanxiaoha/171757587650078)
重新跑一个正式的 Nacos 容器,运行命令如下:
```perl
docker run -d --name nacos --privileged -e MODE=standalone -e JVM_XMX=300m -e JVM_XMS=300m -p 8848:8848 -p 9848:9848 -v E:\docker\nacos\conf:/home/nacos/conf -v E:\docker\nacos\data:/home/nacos/data -v E:\docker\nacos\logs:/home/nacos/logs nacos/nacos-server:v2.2.3
```
> 解释一下这次命令中,额外添加的参数的含义:
>
> 1. **\--privileged**:使容器以特权模式运行,给予容器更多的权限,这通常用于需要更高权限的操作。
> 2. **\-e JVM\_XMX=300m**:设置环境变量 `JVM_XMX`,指定 JVM 最大堆内存为 300MB。
> 3. **\-e JVM\_XMS=300m**:设置环境变量 `JVM_XMS`,指定 JVM 初始堆内存为 300MB。
> 4. **\-v E:\\docker\\nacos\\conf:/home/nacos/conf**:将宿主机的 `E:\docker\nacos\conf` 目录挂载到容器内的 `/home/nacos/conf` 目录。这样,宿主机上的配置文件可以在容器内使用。
> 5. **\-v E:\\docker\\nacos\\data:/home/nacos/data**:将宿主机的 `E:\docker\nacos\data` 目录挂载到容器内的 `/home/nacos/data` 目录。这样Nacos 的数据可以持久化到宿主机上。
> 6. **\-v E:\\docker\\nacos\\logs:/home/nacos/logs**:将宿主机的 `E:\docker\nacos\logs` 目录挂载到容器内的 `/home/nacos/logs` 目录。这样Nacos 的日志可以持久化到宿主机上。
![](https://img.quanxiaoha.com/quanxiaoha/171758142074128)
至此,通过 Docker 方式,就将本地 Nacos 环境就搭建好啦~

View File

@@ -0,0 +1,213 @@
![](https://img.quanxiaoha.com/quanxiaoha/171767065637124)
[上小节](https://www.quanxiaoha.com/column/10286.html) 中,我们已经将本地的 Nacos 环境搭建好了。这小节中,我们将感受一下 Nacos 中一个非常重要的功能 —— *配置中心*
## 什么是配置中心?
在微服务架构下,配置中心是一个专门用来集中管理和分发配置的服务。它通过提供统一的接口,帮助开发人员将所有微服务的配置项集中存储、管理和分发,确保微服务在不同环境下(如开发、测试、生产环境)能够方便地获取到对应的配置。
## 为什么需要配置中心?
1. **集中管理,简化运维** 在传统的单体应用中,配置项通常存储在本地文件中,管理和维护相对简单。但在微服务架构下,配置项分散在多个服务中,如果每个服务都单独管理自己的配置项,会导致管理复杂性增加。配置中心通过集中管理配置项,极大简化了运维工作。
2. **环境隔离,配置灵活** 不同的环境(开发、测试、生产等)通常需要不同的配置项。配置中心支持按环境隔离配置项,使得相同的微服务在不同环境中可以方便地获取对应的配置,而无需手动修改配置文件。
3. **动态更新,实时生效** 在业务需求变化较快的场景中,配置项的频繁修改是常态。配置中心支持配置项的动态更新和实时生效,减少了服务重启的次数,提高了系统的可用性和灵活性。
4. **安全管理** 某些敏感配置项如数据库密码、API 密钥等)不适合写在代码中或本地文件中。配置中心提供了安全的存储和访问机制,确保敏感信息的安全性。
5. **统一监控,提升稳定性** 配置中心可以对所有配置项进行统一监控和管理,方便运维人员及时发现和处理配置问题,提升系统的稳定性和可靠性。
## 本地配置演示
在单体项目开发中,配置项通常都会写死在 `application.yml` 文件中。比如说,我们需要对接口的进行限流控制,同时呢,要求阈值是能够手动配置的,那么,你可能会在 `application.yml` 文件中,自定义如下配置项:
![](https://img.quanxiaoha.com/quanxiaoha/171766240684555)
代码如下:
```yaml
rate-limit:
api:
limit: 100 # 接口限流阈值
```
### 获取配置
为了方便查看配置项的值,在 `/controller` 包下,创建一个 `TestController` 测试控制器,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class TestController {
@Value("${rate-limit.api.limit}")
private Integer limit;
@GetMapping("/test")
public String test() {
return "当前限流阈值为: " + limit;
}
}
```
> + 通过 `@Value("${rate-limit.api.limit}")` 获取配置文件的限流阈值;
> + 并定义一个 `GET` 请求的 `/test` 接口,用于打印 `limit` 阈值;
重启项目,浏览器访问该接口,可以看到成功打印出了配置文件中限流阈值 *100*
![](https://img.quanxiaoha.com/quanxiaoha/171766304845675)
## 使用 Nacos 配置中心
接下来,让我们来实际感受一下 Nacos 配置中心的魅力。
### 进入 Nacos 管理后台,创建配置
浏览器访问: [http://localhost:8848/nacos](http://localhost:8848/nacos) 进入到 Nacos 控制台,如下图所示,点击*创建配置*按钮:
![](https://img.quanxiaoha.com/quanxiaoha/171766311205795)
填写相关配置项:
![](https://img.quanxiaoha.com/quanxiaoha/171766367284078)
> + ①:**Data Id** : 配置的唯一标识,这里我们填写 `xiaohashu-auth`
> + ②:**Group**: 所属组,这里默认组即可;
> + ③:**配置格式**:咱们项目中使用的 YAML 格式配置, 这里也选择 YAML;
> + ④:**配置内容**,将限流阈值配置复制进去;
> + ⑤:点击**发布**按钮;
### 添加依赖
然后,编辑项目的最外层 `pom.xml` , 添加 Nacos 配置需要使用的依赖:
```php-template
// 省略...
<properties>
// 省略...
<nacos-config.version>0.3.0-RC</nacos-config.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
// 省略...
<!-- Nacos 配置中心 -->
<dependency>
<groupId>com.alibaba.boot</groupId>
<artifactId>nacos-config-spring-boot-starter</artifactId>
<version>${nacos-config.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
// 省略...
```
> **注意**:版本 [0.2.x.RELEASE](https://mvnrepository.com/artifact/com.alibaba.boot/nacos-discovery-spring-boot-starter) 对应的是 Spring Boot 2.x 版本,版本 [0.1.x.RELEASE](https://mvnrepository.com/artifact/com.alibaba.boot/nacos-discovery-spring-boot-starter) 对应的是 Spring Boot 1.x 版本。我们是 Spring Boot 3.x, 故使用最新的 *0.3.x* 版本。
接着,编辑 `xiaohashu-auth` 认证服务的 `pom.xml` 文件,引入该依赖,代码如下:
```php-template
<!-- Nacos 配置中心 -->
<dependency>
<groupId>com.alibaba.boot</groupId>
<artifactId>nacos-config-spring-boot-starter</artifactId>
</dependency>
```
依赖引入完毕后,刷新一个 Maven 将依赖包下载到本地 Maven 仓库中。
### 项目配置 Nacos
依赖添加完毕后,编辑 `applicaiton.yml` 文件,准备添加 Nacos 相关配置,因为认证服务需要与 Nacos 配置中心进行通信:
![](https://img.quanxiaoha.com/quanxiaoha/171767087840069)
配置项如下:
```yaml
nacos:
config: # Nacos 配置中心
access-key: # 身份验证
secret-key: # 身份验证
data-id: xiaohashu-auth # 指定要加载的配置数据的 Data Id
group: DEFAULT_GROUP # 指定配置数据所属的组
type: yaml # 指定配置数据的格式
server-addr: http://127.0.0.1:8848/ # 指定 Nacos 配置中心的服务器地址
auto-refresh: true # 是否自动刷新配置
remote-first: true # 是否优先使用远程配置
bootstrap:
enable: true # 启动时,预热配置
```
> **TIP** : 由于本地 Nacos 环境没有设置必须登录才能使用,所以这里身份验证相关配置填空即可。
### 使用 @NacosValue 注解
编辑 `TestController` 控制器,将之前的 Spring 框架提供的 `@Value` 注解,替换为 Nacos 的 `@NacosValue` 注解, 代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.controller;
import com.alibaba.nacos.api.config.annotation.NacosValue;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Slf4j
public class TestController {
@NacosValue(value = "${rate-limit.api.limit}", autoRefreshed = true)
private Integer limit;
// 省略...
}
```
> 解释一下:
>
> + **@NacosValue** 这是 Nacos 提供的一个注解,用于从 Nacos 配置中心中获取配置项,并将其注入到字段中。
>
> + **value** 这是注解的一个参数,用于指定要获取的配置项的键。这里使用了占位符 `${rate-limit.api.limit}`,表示要从 Nacos 配置中心中获取键为 `rate-limit.api.limit` 的配置项。
>
> + **autoRefreshed** 这是注解的另一个参数,用于指定是否自动刷新配置项。当配置中心中的配置项发生变化时,如果 `autoRefreshed` 设置为 `true`,则该字段的值会自动更新,保持与配置中心中的最新值一致。
>
### 重启项目
代码编写完毕后,记得**重启项目**,开始测试 Nacos 配置是否好使。
### Nacos 管理后台动态修改配置
进入到 Nacos 管理后台中,点击*编辑*按钮,将限流阈值修改为 *888*
![](https://img.quanxiaoha.com/quanxiaoha/171766454852477)
点击*发布*按钮:
![](https://img.quanxiaoha.com/quanxiaoha/171766460922343)
发布成功后,查看控制台日志,你会发现认证服务已经实时感知到了配置的变化,并将具体的配置信息以日志的方式,打印了出来,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171766466276844)
再次浏览器访问 `/test` 接口,可以看到限流阈值已经变成了最新修改的 *888*,实现了配置的动态刷新:
![](https://img.quanxiaoha.com/quanxiaoha/171766471619759)
通过本小节内容,相信小伙伴们对于 Nacos 做为配置中心的能力,已经亲身感受了一波,有木有非常强大
## 本小节源码下载
[https://t.zsxq.com/ExYki](https://t.zsxq.com/ExYki)

View File

@@ -0,0 +1,53 @@
在之前小节中,我们看到了在 Nacos 管理后台中,有*命名空间*这么一个菜单,并且 Nacos 搭建起来后,会默认初始化一个 `public` 的命名空间。
## 什么是命名空间?干嘛的?
命名空间Namespace是 Nacos 提供的**一种逻辑隔离手段,用于对配置和服务进行分组和隔离**。在 Nacos 中,命名空间通常被用于做**业务隔离**。
> *什么是业务隔离?*
>
> 不同业务线的配置和服务可以放在不同的命名空间中,方便管理和维护。
>
> 以上这种做法属于逻辑隔离,适用于小公司,服务器资源有限的情况。如果是不差钱的公司,可能会买多个服务器,分别搭建不同 Nacos 的环境,以实现不同业务线配置与服务的物理隔离。
## 创建命名空间
了解相关概念后,接下来,我们为小哈书这个项目,单独创建一个命名空间。首先,进入到 Nacos 管理后台:[http://localhost:8848/nacos](http://localhost:8848/nacos)
![](https://img.quanxiaoha.com/quanxiaoha/171791047593575)
点击*命名空间*菜单,点击*新建命名空间*按钮:
![](https://img.quanxiaoha.com/quanxiaoha/171791058440062)
填写命名空间相关配置项,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171791068154111)
> 解释一下:
>
> + **①:命名空间 ID** 可不填,不填的话,会自动生成一长串的唯一 ID , 这里为了方便识别,手动填写为 `xiaohashu`
> + **②:命名空间名称**:也可以写中文,这里填 `xiaohashu` 项目的拼音;
> + **③:描述**:命名空间描述性文字;
最后点击*确定*按钮,完成命名空间的创建。
## 克隆配置
创建完成后,进入到*配置管理 | 配置列表*,在上方会发现除了 `public` 外,多出了一个咱们刚刚创建的 `xiaohashu` 命名空间,选择该命名空间,会发现该命名空间下,还没有任何配置:
![](https://img.quanxiaoha.com/quanxiaoha/171791074940473)
`public` 命名空间下的所有配置*勾选*,点击*克隆*,选择*目标空间 | 开始克隆* 即可将 `public` 空间下的配置,一键复制到 `xiaohashu` 命名空间下:
![](https://img.quanxiaoha.com/quanxiaoha/171791084740340)
效果如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171791090031632)
## 项目中使用新的命名空间
最后,编辑认证服务中 `bootstrap.yml` 配置文件,将 `namespace` 配置项改为 `xiaohashu` , 后续咱们项目中,将统一使用新创建的这个命名空间。
![](https://img.quanxiaoha.com/quanxiaoha/171791099974299)

View File

@@ -0,0 +1,56 @@
在[上小节](https://www.quanxiaoha.com/column/10288.html) 中,我们通过一个小示例,演示了如何在项目中通过 Nacos , 实现动态加载 Bean 功能。但是,小伙伴们有没有发现一个问题,那就是,当在 Nacos 后台修改了配置并发布后,项目中监听配置刷新前,出现了 Druid 连接池被关闭了的日志,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171784624485792)
## 确认 Druid 连接池是否被关闭
为了验证 Druid 连接池是否真的被关闭了,我们通过 Apipost 请求 `/login` 登录接口验证一下,因为这个接口需要进行数据库查询操作:
![](https://img.quanxiaoha.com/quanxiaoha/171784632283538)
果然,接口提示*出错*了, 再观察一下控制台日志,如下图所示,可以看到已经无法拿到数据库链接了,提示连接池已经被关闭了:
![](https://img.quanxiaoha.com/quanxiaoha/171784639478984)
## 尝试找解决方案
由于咱们使用的是 *Spring Boot 3.x* 版本,对应的 Spring Cloud Alibaba 组件版本也会比较新。直接在搜索引擎中,搜索这个报错时,能够查的文档有限。这个时候,不妨访问 Druid GitHub 仓库官方地址:[https://github.com/alibaba/druid](https://github.com/alibaba/druid) ,点击 *Issues* 栏:
![](https://img.quanxiaoha.com/quanxiaoha/171784648114789)
尝试在官方 Issues 中搜索关键词,如 *Nacos* , 看看有没有其他小伙伴也遇到这种错误:
![](https://img.quanxiaoha.com/quanxiaoha/171784663419554)
如上图所示,果不其然,真的有人遇到了同样的问题 !查看链接详情:[https://github.com/alibaba/druid/issues/5740](https://github.com/alibaba/druid/issues/5740) ,看看有没有解决方案:
![](https://img.quanxiaoha.com/quanxiaoha/171784668528822)
Druid 官方给出的方案是,切换到 *1.2.22* 版本,再进行验证。
## 查看 Druid 最新版本
访问 Maven 中央仓库,找到 `Druid Spring Boot Starter` 库:[https://mvnrepository.com/artifact/com.alibaba/druid-spring-boot-starter](https://mvnrepository.com/artifact/com.alibaba/druid-spring-boot-starter) ,看一下最新的版本号:
![](https://img.quanxiaoha.com/quanxiaoha/171784674998171)
如上图所示,最新的版本已经到 *1.2.23* 了,索性,咱们直接切换到最新版本,防止还有别的已经被官方修复的 Bug 发生。
## 切换 Druid 版本
编辑项目最外层的 `pom.xml` 文件,将 `druid.version` 改为最新的 *1.2.23* 版本:
```javascript
<properties>
// 省略...
<druid.version>1.2.23</druid.version>
// 省略...
</properties>
```
并点击右侧栏的 *Reload* 按钮,重新刷新一下 Maven 依赖,将 1.2.23 版本依赖下载到本地仓库中:
![](https://img.quanxiaoha.com/quanxiaoha/171784684911089)
重启项目,再次验证 Nacos 发布配置后Druid 连接池被关闭问题,发现问题没有再出现了。

View File

@@ -0,0 +1,53 @@
在之前小节中,我们看到了在 Nacos 管理后台中,有*命名空间*这么一个菜单,并且 Nacos 搭建起来后,会默认初始化一个 `public` 的命名空间。
## 什么是命名空间?干嘛的?
命名空间Namespace是 Nacos 提供的**一种逻辑隔离手段,用于对配置和服务进行分组和隔离**。在 Nacos 中,命名空间通常被用于做**业务隔离**。
> *什么是业务隔离?*
>
> 不同业务线的配置和服务可以放在不同的命名空间中,方便管理和维护。
>
> 以上这种做法属于逻辑隔离,适用于小公司,服务器资源有限的情况。如果是不差钱的公司,可能会买多个服务器,分别搭建不同 Nacos 的环境,以实现不同业务线配置与服务的物理隔离。
## 创建命名空间
了解相关概念后,接下来,我们为小哈书这个项目,单独创建一个命名空间。首先,进入到 Nacos 管理后台:[http://localhost:8848/nacos](http://localhost:8848/nacos)
![](https://img.quanxiaoha.com/quanxiaoha/171791047593575)
点击*命名空间*菜单,点击*新建命名空间*按钮:
![](https://img.quanxiaoha.com/quanxiaoha/171791058440062)
填写命名空间相关配置项,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171791068154111)
> 解释一下:
>
> + **①:命名空间 ID** 可不填,不填的话,会自动生成一长串的唯一 ID , 这里为了方便识别,手动填写为 `xiaohashu`
> + **②:命名空间名称**:也可以写中文,这里填 `xiaohashu` 项目的拼音;
> + **③:描述**:命名空间描述性文字;
最后点击*确定*按钮,完成命名空间的创建。
## 克隆配置
创建完成后,进入到*配置管理 | 配置列表*,在上方会发现除了 `public` 外,多出了一个咱们刚刚创建的 `xiaohashu` 命名空间,选择该命名空间,会发现该命名空间下,还没有任何配置:
![](https://img.quanxiaoha.com/quanxiaoha/171791074940473)
`public` 命名空间下的所有配置*勾选*,点击*克隆*,选择*目标空间 | 开始克隆* 即可将 `public` 空间下的配置,一键复制到 `xiaohashu` 命名空间下:
![](https://img.quanxiaoha.com/quanxiaoha/171791084740340)
效果如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171791090031632)
## 项目中使用新的命名空间
最后,编辑认证服务中 `bootstrap.yml` 配置文件,将 `namespace` 配置项改为 `xiaohashu` , 后续咱们项目中,将统一使用新创建的这个命名空间。
![](https://img.quanxiaoha.com/quanxiaoha/171791099974299)

View File

@@ -0,0 +1,74 @@
[上小节](https://www.quanxiaoha.com/column/10290.html) 中,我们已经学习了命名空间的使用。本小节中,我们来学习 Nacos 另一项非常重要的功能 —— *服务注册*
![](https://img.quanxiaoha.com/quanxiaoha/171808604586473)
## 什么是服务注册?有啥用?
在微服务架构中,服务注册是一种机制,**用于将服务实例的信息(如地址、端口、健康状态等)注册到服务注册中心**。服务实例启动时,会向注册中心登记自己的信息,停止时则注销。
它的作用如下:
+ **提供服务元数据**:注册中心保存了所有服务实例的元数据,供其他服务或负载均衡器查询。
+ **健康检查**:注册中心通常会定期检查注册的服务实例的健康状况,以确保它们可用并将不可用的实例从注册列表中移除。
## 添加依赖
编辑 `xiaohashu-auth` 认证服务中的 `pom.xml` 文件,添加服务注册发现的依赖:
```php-template
<!-- 服务注册发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
```
> **注意**:依赖添加完毕后,点击右侧的 Maven 菜单栏,点击 *Reload* ,将包下载到本地仓库中。
## 添加配置
![](https://img.quanxiaoha.com/quanxiaoha/171808685593051)
接着,编辑 `xiaohashu-auth` 认证服务中的 `bootstrap.yml` 文件,添加如下配置:
```yaml
spring:
// 省略 ...
cloud:
nacos:
config:
// 省略...
discovery:
enabled: true # 启用服务发现
group: DEFAULT_GROUP # 所属组
namespace: xiaohashu # 命名空间
server-addr: 127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
```
> **注意**`discovery` 节点和 `config` 同级,位置别配置错了哟~
## 重启项目
以上工作完成后,重启 `xiaohashu-auth` 认证服务,观察控制台日志:
![](https://img.quanxiaoha.com/quanxiaoha/171808693327611)
你会看到以上标注的这行,大致如下:
```csharp
[REGISTER-SERVICE] xiaohashu registering service xiaohashu-auth with instance Instance{instanceId='null', ip='192.168.1.3', port=8080, weight=1.0, healthy=true, enabled=true, ephemeral=true, clusterName='DEFAULT', serviceName='null', metadata={IPv6=[240e:b67:569:3100:2a39:b66:5832:ee5b], preserved.register.source=SPRING_CLOUD}}
```
表明已经将 `xiaohashu-auth` 认证服务注册到了 Nacos 上了。
## 查看服务列表
进入到 Nacos 管理后台:[http://localhost:8848/nacos](http://localhost:8848/nacos) ,在*服务列表*中 ,先选择对应的命名空间,即可看到注册成功的 `xiaohashu-auth` 服务啦:
![](https://img.quanxiaoha.com/quanxiaoha/171808698872849)
至此,服务注册到 Nacos 上就搞定了。目前还只是注册一个服务,后续我们还会创建更多的服务,如网关服务、对象存储服务等,到时候统一都会注册到 Nacos 上,从而实现服务间发现,服务间便捷的通信,随着项目功能的迭代,这些都将一一亲身感受到。
## 本小节源码下载
[https://t.zsxq.com/YvKrP](https://t.zsxq.com/YvKrP)

View File

@@ -0,0 +1,15 @@
书接上文,咱们继续进行下一个接口的开发工作 —— **用户信息修改接口**
![](https://img.quanxiaoha.com/quanxiaoha/171937574653595)
如上图所示,此接口会涉及到图片上传,如头像上传、背景图上传。
## 本地 Minio 环境搭建
所以,在本章节中的任务,就是先将本地对象存储环境搭建好,如 `Minio` 对象存储,或者是使用收费的对象存储云产品,如阿里云 `OSS` 等。 另外,还需要新建一个对象存储微服务,专门用于处理图片相关功能。
关于本地如何搭建 `Minio` 对象存储,可翻阅星球第一个项目的 [《9.2 节Docker 本地安装 Minio 对象存储》](https://www.quanxiaoha.com/column/10080.html) ,这里不再赘述。
![](https://img.quanxiaoha.com/quanxiaoha/171937531195627)
唯一区别的是,你需要单独为小哈书项目创建一个 `Bucket` 桶,如上图所示。

View File

@@ -0,0 +1,259 @@
本小节中,我们来为小哈书创建一个新的微服务 —— **对象存储微服务**,用于提供图片上传存储功能。
## 项目基本结构
和之前创建的 `xiaohashu-auth` 认证服务的项目结构有所区别,这次创建的对象存储服务,是个**多模块结构**,大致如下:
```undefined
xiaohashu-oss/ (父项目)
|- xiaohashu-oss-api (Api层)
|- xiaohashu-oss-biz (业务层)
```
> 可以看到,有两个子模块:
>
> + `xiaohashu-oss-api` : API 层,用于放置 `Feign` 接口配置,服务间调用的 `DTO` 出入参实体类等;
>
> > 举个栗子,如下图所示,前端请求修改用户信息接口,如果修改了用户头像、背景图,用户服务需要调用下游对象存储服务,将文件传输过去,由对象存储将图片上传至 `Minio` , 或者是别的对象存储中间件中。
> >
> > 注意了,这中间用户服务需要调用对象存储服务的上传图片接口,就需要有明确的接口地址,出入参实体类,如果在这两个服务中都定义一份,代码就非常冗余。通过提取一个 `API` 模块,将通用的代码都放置在此模块里,后续用户服务只需引入 `xiaohashu-oss-api` 模块即可,无需二次定义。
>
> ![](https://img.quanxiaoha.com/quanxiaoha/171940870819538)
>
> + `xiaohashu-oss-biz` : 对象存储的核心业务层,如上传图片至 `Minio` 的具体实现。
>
## 新建父模块
明确了对象存储服务的大致结构后,接下来开始动手创建该微服务。在父项目上*右键 | New | Module* , 新建一个子模块:
![](https://img.quanxiaoha.com/quanxiaoha/171940246156573)
填写项目相关配置项,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171940253906474)
> 解释一下:
>
> + ①:选择 `Maven Archetype` 来创建一个 `Maven` 项目;
> + ②:项目名称;
> + ③:父项目指定为 `xiaohashu`
> + ⑤IDEA 需要知道 Maven Archetype Catalog 的位置,以便从中获取可用的 Archetype 列表。这个 Catalog 文件通常包含了 Maven 官方仓库或其他远程仓库中可用的 Archetype 信息。选择 `Internal` 即可。
> + ⑥:通过使用 Archetype你可以基于已有的项目模板创建一个新项目。这里选择 `maven-archetype-quickstart`。
点击 *Create* 按钮创建项目。等待创建完成后,将`/src` 目录删除掉,只保留下图部分:
![](https://img.quanxiaoha.com/quanxiaoha/171940312615632)
同时,如果你打开项目最外层的 `pom.xml` 文件,会发现对象存储模块已经自动加入 `<modules>` 节点下管理了:
```php-template
<!-- 子模块管理 -->
<modules>
// 省略...
<!-- 对象存储服务 -->
<module>xiaohashu-oss</module>
</modules>
```
接着,编辑 `xiaohashu-oss` 服务的 `pom.xml` 文件,修改相关配置、依赖如下:
```php-template
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu</artifactId>
<version>${revision}</version>
</parent>
<!-- 多模块项目需要配置打包方式为 pom -->
<packaging>pom</packaging>
<!-- 子模块管理 -->
<modules>
</modules>
<artifactId>xiaohashu-oss</artifactId>
<!-- 项目名称 -->
<name>${project.artifactId}</name>
<!-- 项目描述 -->
<description>对象存储服务</description>
</project>
```
## 新建 xiaohashu-oss-api 子模块
继续创建对象存储服务的子模块。在 `xiaohashu-oss` 上*右键 | New | Module*, 创建子模块:
![](https://img.quanxiaoha.com/quanxiaoha/171940340836814)
填写相关配置项,如下图所示,注意,`Parent` 需要勾选为 `xiaohashu-oss`
![](https://img.quanxiaoha.com/quanxiaoha/171940349069813)
点击 *Create* 按钮,等待子模块创建完成后,编辑其 `pom.xml` 文件,修改如下:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-oss</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>xiaohashu-oss-api</artifactId>
<name>${project.artifactId}</name>
<description>RPC层, 供其他服务调用</description>
<dependencies>
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-common</artifactId>
</dependency>
</dependencies>
</project>
```
同时将模块中无用的类、文件夹删除掉,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171940368678648)
删除完毕后,目前 `xiaohashu-oss-api` 模块下就基本全空了,先不用管,等后续再来填充内容。
## 新建 xiaohashu-oss-biz 业务模块
继续创建 `xiaohashu-oss-biz` 业务模块,配置项填写如下:
![](https://img.quanxiaoha.com/quanxiaoha/171940377875357)
等待项目创建完毕后,编辑其 `pom.xml` , 修改如下:
```php-template
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-oss</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>xiaohashu-oss-biz</artifactId>
<name>${project.artifactId}</name>
<description>对象存储业务层</description>
<dependencies>
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
```
`xiaohashu-oss-biz` 模块是项目启动模块,所以要添加 `spring-boot-starter-web` 依赖 , 以及打包插件。同样的,删除掉一些无用的类,并创建项目启动类、`application.yml` 配置文件、`logback` 配置文件,创建完成后,结构如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171940480051784)
### 项目启动类
```typescript
package com.quanxiaoha.xiaohashu.oss.biz;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class XiaohashuOssBizApplication {
public static void main(String[] args) {
SpringApplication.run(XiaohashuOssBizApplication.class, args);
}
}
```
### application.yml 配置文件
```yaml
server:
port: 8081 # 项目启动的端口
spring:
profiles:
active: dev # 默认激活 dev 本地开发环境
```
注意,端口要和认证服务区别开,不能共用一个,这里用的 `8081`。
### logback 日志配置
`logback-spring.xml` 直接从认证服务中复制一份过来,只需将应用名称修改成 `oss` 即可 , 如下:
```php-template
<configuration>
// 省略...
<!-- 应用名称 -->
<property scope="context" name="appName" value="oss"/>
// 省略...
</configuration>
```
## 测试一波
以上都配置完成后,点击 `XiaohashuOssBizApplication` 启动类中的**启动图标**,看看服务能否正常运行起来:
![](https://img.quanxiaoha.com/quanxiaoha/171940503446635)
OK, 一切正常。至此,对象存储服务项目基本骨架都搭建完毕啦~
## 本小节源码下载
[https://t.zsxq.com/kuB2B](https://t.zsxq.com/kuB2B)

View File

@@ -0,0 +1,309 @@
[上小节](https://www.quanxiaoha.com/column/10305.html) 中,我们已经将对象存储服务项目的基础骨架搭建好了。本小节中,进入到代码层的实现,通过设计模式中的`策略模式 + 工厂模式`实现文件处理的可扩展性。
## 业务背景
![](https://img.quanxiaoha.com/quanxiaoha/171949558695059)
从业务层面分析,对象存储微服务底层用到的产品,暂时规划的是,要使用 `Minio` 和阿里云 OSS但是后续随着业务的推进可能还会引入新的产品如七牛云等等。那么当引入新的产品时如何保证代码的可扩展性、可维护性呢代码上要如何设计这就得今天的主角登场了。
## 什么是策略模式?什么是工厂模式?
> 策略模式: 定义一组算法类,将每个算法分别封装,让它们可以互相替代,属于**行为型设计模式**的一种。而工厂模式则属于**创建型设计模式**的一种,用于解耦对象的创建和使用。
听上去好像比较难理解,接下来通过实操,来感受一下它们的好处,先看下代码大致结构,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171949331215068)
## 策略接口
### 策略的定义
> 策略的定义比较简单,包含一个策略接口和一组实现该接口的策略类。因为所有的策略类都实现了相同的接口,调用者基于接口使用,所以可以灵活的替换不同策略,调用者是无感知的。
### 定义接口
编辑 `xiaohashu-oss-biz` 模块,创建一个 `/strategy` 包,用于存放策略相关代码,并创建 `FileStrategy` 文件策略接口,代码如下:
```java
package com.quanxiaoha.xiaohashu.oss.biz.strategy;
import org.springframework.web.multipart.MultipartFile;
/**
* @author: 犬小哈
* @date: 2024/6/27 19:47
* @version: v1.0.0
* @description: 文件策略接口
**/
public interface FileStrategy {
/**
* 文件上传
*
* @param file
* @param bucketName
* @return
*/
String uploadFile(MultipartFile file, String bucketName);
}
```
> **TIP** : 目前该接口内,只声明了一个当前业务需要的上传文件方法,等后续随着业务的迭代,如果有需要,我们再另行创建别的方法。
## Strategy 策略实现类
### Minio 策略类
接着,在 `/strategy` 包下创建 `/impl` 包,用于放置一组策略实现类。首先是 `Minio` 策略类,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.oss.biz.strategy.impl;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.FileStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
/**
* @author: 犬小哈
* @date: 2024/6/27 19:47
* @version: v1.0.0
* @description: TODO
**/
@Slf4j
public class MinioFileStrategy implements FileStrategy {
@Override
public String uploadFile(MultipartFile file, String bucketName) {
log.info("## 上传文件至 Minio ...");
return null;
}
}
```
> **TIP** : 先只是把架子搭好,具体逻辑实现先不写,打印一行日志,用于等会测试时,观察正在使用的策略类型。
### 阿里云 OSS 策略类
接着是阿里云 `OSS` 策略类,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.oss.biz.strategy.impl;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.FileStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
/**
* @author: 犬小哈
* @date: 2024/6/27 19:47
* @version: v1.0.0
* @description: TODO
**/
@Slf4j
public class AliyunOSSFileStrategy implements FileStrategy {
@Override
public String uploadFile(MultipartFile file, String bucketName) {
log.info("## 上传文件至阿里云 OSS ...");
return null;
}
}
```
## 添加配置
为了能够按需初始化具体的策略实现类,编辑 `application.yml` 文件,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171949316370530)
添加一个类型标识,代码如下:
```yaml
storage:
type: aliyun # 对象存储类型
```
## 创建 Factory 工厂类
接着,再创建一个 `/fatory` 包,并在其中创建一个 `FileStrategyFactory` 文件策略工厂类,用于按需初始化具体的策略实现类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.factory;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.FileStrategy;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.impl.AliyunOSSFileStrategy;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.impl.MinioFileStrategy;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: 犬小哈
* @date: 2024/6/27 19:44
* @version: v1.0.0
* @description: TODO
**/
@Configuration
public class FileStrategyFactory {
@Value("${storage.type}")
private String strategyType;
@Bean
public FileStrategy getFileStrategy() {
if (StringUtils.equals(strategyType, "minio")) {
return new MinioFileStrategy();
} else if (StringUtils.equals(strategyType, "aliyun")) {
return new AliyunOSSFileStrategy();
}
throw new IllegalArgumentException("不可用的存储类型");
}
}
```
逻辑比较简单,读取配置文件中的 `storage.type` ,根据不同类型,初始化不同的策略实现类,并注入到 Spring 容器中。这种方式可以保证, Spring 容器中只有自己需要的策略实现类,而不是都注入到 Spring 容器中去。
## 编写 service 业务层
![](https://img.quanxiaoha.com/quanxiaoha/171949373273636)
再创建一个 `/service` 业务层包,并创建 `FileService` 文件业务接口,声明一个上传文件方法,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import org.springframework.web.multipart.MultipartFile;
/**
* @author: 犬小哈
* @date: 2024/4/11 17:12
* @version: v1.0.0
* @description: TODO
**/
public interface FileService {
/**
* 上传文件
*
* @param file
* @return
*/
Response<?> uploadFile(MultipartFile file);
}
```
`/service` 包下创建 `/impl` 包,用于存放实现类,并创建 `FileServiceImpl` , 代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.service.impl;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.oss.biz.service.FileService;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.FileStrategy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* @author: 犬小哈
* @date: 2024/4/11 17:12
* @version: v1.0.0
* @description: TODO
**/
@Service
@Slf4j
public class FileServiceImpl implements FileService {
@Resource
private FileStrategy fileStrategy;
@Override
public Response<?> uploadFile(MultipartFile file) {
// 上传文件到
fileStrategy.uploadFile(file, "xiaohashu");
return Response.success();
}
}
```
调用者直接面向 `FileStrategy` 接口,至于其底层具体的实现策略类,无需关心,直接调用相关方法就行了。这里依然只是搭个架子,具体的业务逻辑先不写,等后续小节中再来补充。
## 编写 controller 控制器
接下来创建 `/controller` 包,并新建 `FileController` 类,定义一个 `/file/upload` 接口,注意,提交方式是 `MULTIPART_FORM_DATA_VALUE`, 代表此接口通过**表单方式提交**,而不是 `JSON` 方式,因为涉及到文件上传。代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.controller;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.oss.biz.service.FileService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* @author: 犬小哈
* @date: 2024/4/4 13:22
* @version: v1.0.0
* @description: 文件
**/
@RestController
@RequestMapping("/file")
@Slf4j
public class FileController {
@Resource
private FileService fileService;
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Response<?> uploadFile(@RequestPart(value = "file") MultipartFile file) {
return fileService.uploadFile(file);
}
}
```
## 自测一波
整个编写完成后,**重启认证服务**。通过 Apipost 测试一波 `localhost:8081/file/upload` 接口,注意,这里我们直接调用的对象存储服务,而不是通过网关转发,因为服务注册发现还没有配置上。
![](https://img.quanxiaoha.com/quanxiaoha/171949412910630)
> + 表单方式提交,需要在 `Header` 头中指定 `Content-Type` 为 `application/x-www-form-urlencoded`;
![](https://img.quanxiaoha.com/quanxiaoha/171949444384955)
表单提交需勾选 `form-data` 选项,添加一个 `file` 字段,并选择一个本地图片,点击发送按钮上传文件。观察后台控制台输出,因为当前配置的对象存储类型为 `aliyun`, 可以看到,具体干活的实际上是 `AliyunOSSFileStrategy` 策略实现类:
![](https://img.quanxiaoha.com/quanxiaoha/171949448270796)
编辑 `applicaiton.yml` , 将类型修改为 `minio`, 重启服务,再次测试文件上传接口:
```yaml
storage:
type: minio # 对象存储类型
```
![](https://img.quanxiaoha.com/quanxiaoha/171949459232852)
可以看到,服务是能够根据配置的类型,实例化不同的策略实现类的。后续如果有新的产品引入,如七牛云,只需再新建一个七牛云的策略实现类,并在 `FileStrategyFactory` 工厂类中初始化即可,`service` 层无需修改任何东西,易于扩展与维护。
## 本小节源码下载
[https://t.zsxq.com/X0m7o](https://t.zsxq.com/X0m7o)

View File

@@ -0,0 +1,411 @@
本小节中,我们继续完善**对象存储服务**的相关功能。
## 服务注册到 Nacos
### 添加依赖
首先是将服务注册到 Nacos 中。编辑 `xiaohashu-oss-biz` 模块的 `pom.xml` 文件,添加如下依赖:
```php-template
// 省略...
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!-- 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
// 省略...
```
### 创建配置
![](https://img.quanxiaoha.com/quanxiaoha/171957554330099)
依赖添加完毕后,在 `/config` 文件下,创建 `bootstrap.yml` 文件,配置如下:
```yaml
spring:
application:
name: xiaohashu-oss # 应用名称
profiles:
active: dev # 默认激活 dev 本地开发环境
cloud:
nacos:
discovery:
enabled: true # 启用服务发现
group: DEFAULT_GROUP # 所属组
namespace: xiaohashu # 命名空间
server-addr: 127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
```
> **TIP** : 小伙伴们可以直接从认证服务中复制过来,将 `spring.application.name` 应用名称改一下就行。
配置完成后,**重启服务**。登录到 Nacos 控制台,查看服务列表,确认一下 `xiaohashu-oss` 服务是否注册成功:
![](https://img.quanxiaoha.com/quanxiaoha/171956969913065)
## Nacos 配置中心
在 [《6.3节》](https://www.quanxiaoha.com/column/10288.html) 中,我们做了个测试,通过 Nacos 配置中心的能力,实现了动态加载 `Bean` 。刚好,上小节中正需要按需初始化文件策略类到 Spring 容器中,必须整上。继续编辑 `pom.xml` 文件,添加配置中心依赖,如下:
```php-template
<!-- 配置中心 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
```
接着,登录到 Nacos 控制台,进入到**配置列表**,选择 `xiaohashu` **命名空间**,并创建一个 `xiaohashu-oss-dev.yaml` 对象存储配置,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171957044655110)
![](https://img.quanxiaoha.com/quanxiaoha/171957053065894)
> 填写相关配置项:
>
> + `Data ID` : 填写 `xiaohashu-oss-dev.yaml`
>
> + `Group` : 默认即可;
>
> + 添加配置相关描述,方便后续维护;
>
> + **配置格式**:选择 `YAML` 格式;
>
> + **配置内容**,如下:
>
> ```yaml
> storage:
> type: minio # 对象存储类型
> ```
>
### 添加配置
接着,编辑 `bootstrap.yml` 文件,添加配置中心相关配置:
```yaml
spring:
// 省略...
cloud:
nacos:
config:
server-addr: http://127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
prefix: ${spring.application.name} # 配置 Data Id 前缀,这里使用应用名称作为前缀
group: DEFAULT_GROUP # 所属组
namespace: xiaohashu # 命名空间
file-extension: yaml # 配置文件格式
refresh-enabled: true # 是否开启动态刷新
```
### 添加 @RefreshScope 注解
然后,编辑 `FileStrategyFactory` 策略工厂类,分别为**类和方法**都添加上 `@RefreshScope` 注解:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.factory;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.FileStrategy;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.impl.AliyunOSSFileStrategy;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.impl.MinioFileStrategy;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: 犬小哈
* @date: 2024/6/27 19:44
* @version: v1.0.0
* @description: TODO
**/
@Configuration
@RefreshScope
public class FileStrategyFactory {
@Value("${storage.type}")
private String strategyType;
@Bean
@RefreshScope
public FileStrategy getFileStrategy() {
if (StringUtils.equals(strategyType, "minio")) {
return new MinioFileStrategy();
} else if (StringUtils.equals(strategyType, "aliyun")) {
return new AliyunOSSFileStrategy();
}
throw new IllegalArgumentException("不可用的存储类型");
}
}
```
这里就不再截图测试效果了,小伙伴们可以自行**重启服务**,测试一波动态加载 `Bean` 功能是否正常。
## Minio 策略类上传文件
最后,我们再来把 `MinioFileStrategy` 策略类的上传文件逻辑补充一下。
### 添加 Minio 依赖
编辑项目最外层的 `pom.xml` 文件,声明 `minio` 的版本号与依赖,代码如下:
```php-template
<properties>
// 省略...
<minio.version>8.2.1</minio.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- 对象存储 Minio -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
然后,编辑 `xiaohashu-oss-biz` 的 `pom.xml`, 引入该依赖:
```php-template
<!-- 对象存储 Minio -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
```
### 添加配置
依赖添加完毕后,编辑 `application-dev.yml` 本地测试环境配置,添加 `minio` 相关配置:
![](https://img.quanxiaoha.com/quanxiaoha/171957129595287)
如下:
```yaml
#=================================================================
# minio (上传图片需要,需配置成自己的地址)
#=================================================================
minio:
endpoint: http://127.0.0.1:9000
accessKey: quanxiaoha
secretKey: quanxiaoha
```
> **注意**:关于整合如何 `Minio`,在星球第一个项目的 [《9.3 节》](https://www.quanxiaoha.com/column/10081.html) 已经讲的比较详细了,不清楚的小伙伴们,可翻阅一下,这里就直接把相关代码复制过来用了。
### 添加配置类
创建 `/config` 包,添加 `minio` 相关配置类,如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-05-11 8:49
* @description: TODO
**/
@ConfigurationProperties(prefix = "minio")
@Component
@Data
public class MinioProperties {
private String endpoint;
private String accessKey;
private String secretKey;
}
```
初始化 `MinioClient` 客户端配置类如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.config;
import io.minio.MinioClient;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-05-11 8:49
* @description: TODO
**/
@Configuration
public class MinioConfig {
@Resource
private MinioProperties minioProperties;
@Bean
public MinioClient minioClient() {
// 构建 Minio 客户端
return MinioClient.builder()
.endpoint(minioProperties.getEndpoint())
.credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey())
.build();
}
}
```
### 策略类逻辑完善
编辑 `MinioFileStrategy` 文件策略类,补充上文件上传至 `minio` 的逻辑代码,如下:
```java
package com.quanxiaoha.xiaohashu.oss.biz.strategy.impl;
import com.quanxiaoha.xiaohashu.oss.biz.config.MinioProperties;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.FileStrategy;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
import java.util.UUID;
/**
* @author: 犬小哈
* @date: 2024/6/27 19:47
* @version: v1.0.0
* @description: TODO
**/
@Slf4j
public class MinioFileStrategy implements FileStrategy {
@Resource
private MinioProperties minioProperties;
@Resource
private MinioClient minioClient;
@Override
@SneakyThrows
public String uploadFile(MultipartFile file, String bucketName) {
log.info("## 上传文件至 Minio ...");
// 判断文件是否为空
if (file == null || file.getSize() == 0) {
log.error("==> 上传文件异常:文件大小为空 ...");
throw new RuntimeException("文件大小不能为空");
}
// 文件的原始名称
String originalFileName = file.getOriginalFilename();
// 文件的 Content-Type
String contentType = file.getContentType();
// 生成存储对象的名称(将 UUID 字符串中的 - 替换成空字符串)
String key = UUID.randomUUID().toString().replace("-", "");
// 获取文件的后缀,如 .jpg
String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
// 拼接上文件后缀,即为要存储的文件名
String objectName = String.format("%s%s", key, suffix);
log.info("==> 开始上传文件至 Minio, ObjectName: {}", objectName);
// 上传文件至 Minio
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(contentType)
.build());
// 返回文件的访问链接
String url = String.format("%s/%s/%s", minioProperties.getEndpoint(), bucketName, objectName);
log.info("==> 上传文件至 Minio 成功,访问路径: {}", url);
return url;
}
}
```
### 业务层返回文件访问链接
编辑 `FileServiceImpl` 文件业务实现类,将上传成功后,图片的访问链接进行返回,代码如下:
```java
package com.quanxiaoha.xiaohashu.oss.biz.service.impl;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.oss.biz.service.FileService;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.FileStrategy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* @author: 犬小哈
* @date: 2024/4/11 17:12
* @version: v1.0.0
* @description: TODO
**/
@Service
@Slf4j
public class FileServiceImpl implements FileService {
@Resource
private FileStrategy fileStrategy;
private static final String BUCKET_NAME = "xiaohashu";
@Override
public Response<?> uploadFile(MultipartFile file) {
// 上传文件
String url = fileStrategy.uploadFile(file, BUCKET_NAME);
return Response.success(url);
}
}
```
### 自测一波
**重启服务**,测试一波文件上传接口,如下图所示,可以看到成功返回了图片的访问链接:
![](https://img.quanxiaoha.com/quanxiaoha/171957210459375)
## 小作业:全局异常捕获器
最后,给大家留个小作业,为对象存储服务添加**全局异常捕获器**,结构如下,代码可以从认证服务中复制一份过来,稍作修改即可:
![](https://img.quanxiaoha.com/quanxiaoha/171957233843147)
注意,记得将对象存储的**错误状态码标识**修改一下,如 `OSS-10000`, 每个服务的错误码都应具有唯一性,不能搞混了,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171957724136752)
哪里不清楚的小伙伴,也可以下载本小节的源码,与自己写的代码对比对比。
## 本小节源码下载
[https://t.zsxq.com/A3iUk](https://t.zsxq.com/A3iUk)

View File

@@ -0,0 +1,350 @@
![](https://img.quanxiaoha.com/quanxiaoha/171966868995970)
[上小节](https://www.quanxiaoha.com/column/10307.html) 中,我们已经将 Minio 文件策略类的**上传文件逻辑**编写好了,本小节中,把剩下的上传文件到阿里云 OSS 的功能也敲一下。
## 开通服务
首先,咱们需要登录阿里云官网,并访问对象存储 OSS 产品首页:[https://www.aliyun.com/product/oss](https://www.aliyun.com/product/oss) ,如下图所示,点击**立即开通**按钮,开通服务:
![](https://img.quanxiaoha.com/quanxiaoha/171966469394290)
开通成功后,进入到对象存储 OSS 控制台, 点击 **Bucket 列表**
![](https://img.quanxiaoha.com/quanxiaoha/171966477091474)
在 Bucket 列表中,点击上方的**创建 Bucket** 按钮,准备创建桶:
![](https://img.quanxiaoha.com/quanxiaoha/171966486249366)
填写 Bucket 相关配置项,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171966503065230)
> + **模式选择**:勾选自定义创建;
> + **Bucket 名称**:这里填写 `xiaohashu`
> + **地域**:推荐选择离你产品使用者较近的地方,有助于提升访问速度。
> + **阻止公共访问**:关闭掉,并将读写权限修改为*公共读*
配置填写完毕后,点击**完成创建**按钮,并**确认要创建**。
![](https://img.quanxiaoha.com/quanxiaoha/171966512236920)
Bucket 创建成功后,在列表页中就可以看到新创建的 `xiaohashu` 桶了,如上图所示。
## 获取 AccessKey 接入凭证
通过代码上传文件到 Bucket ,阿里云需要校验你的身份,还需要获取一下接入凭证。点击回到阿里云首页,将鼠标移动到登录用户的头像上,即可看到 `AccessKey` 选项,点击即可查看:
![img](https://img.quanxiaoha.com/quanxiaoha/171653822624493)img
> **TIP** : 记得给你的账号充值一点钱,比如 1 块钱,因为图片的访问会产生流量费用。
将你的 `AccessKeyID` 以及 `AccessKey Secret` 复制出来:
![img](https://img.quanxiaoha.com/quanxiaoha/171653829408381)img
## 修改配置格式
编辑 `xiaohashu-oss-biz` 模块的 `application-dev.yml` 开发环境配置,修改一下 `minio` 配置项的结构,统一放置到 `storage` 节点下,方便统一维护。再额外加一下阿里云 OSS 需要用到的配置项,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171966565414281)
```yaml
#=================================================================
# 对象存储配置
#=================================================================
storage:
minio:
endpoint: http://127.0.0.1:9000
accessKey: quanxiaoha
secretKey: quanxiaoha
aliyun-oss:
endpoint: oss-cn-hangzhou.aliyuncs.com # 改成你自己的
accessKey: xxx # 改成你自己的
secretKey: xxx # 改成你自己的
```
因为配置文件中 `minio` 配置项的结构变动了,对应的,`MinioProperties` 配置类的 `@ConfigurationProperties` 注解的值也需要修正一下,修改为 `storage.minio`, 代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-05-11 8:49
* @description: Minio 配置项
**/
@ConfigurationProperties(prefix = "storage.minio")
@Component
@Data
public class MinioProperties {
private String endpoint;
private String accessKey;
private String secretKey;
}
```
## 添加阿里云 OSS 配置类
顺便把阿里云 OSS 配置项对应的配置类也创建一下,代码如下:
![](https://img.quanxiaoha.com/quanxiaoha/171966576421892)
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-05-11 8:49
* @description: 阿里云 OSS 配置项
**/
@ConfigurationProperties(prefix = "storage.aliyun-oss")
@Component
@Data
public class AliyunOSSProperties {
private String endpoint;
private String accessKey;
private String secretKey;
}
```
## 添加依赖
前置工作完成后,开始添加阿里云 OSS 对象存储的 SDK 依赖,可访问官方文档:[https://help.aliyun.com/zh/oss/](https://help.aliyun.com/zh/oss/) 查看详细内容。这里直接演示如何操作,编辑项目最外层的 `pom.xml` 文件,声明相关版本号与依赖,代码如下:
```php-template
<properties>
// 省略...
<minio.version>8.2.1</minio.version>
<aliyun-sdk-oss.version>3.17.4</aliyun-sdk-oss.version>
<jaxb-api.version>2.3.1</jaxb-api.version>
<activation.version>1.1.1</activation.version>
<jaxb-runtime.version>2.3.3</jaxb-runtime.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- 阿里云 OSS -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>${aliyun-sdk-oss.version}</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>${jaxb-api.version}</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>${activation.version}</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>${jaxb-runtime.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
接着,编辑 `xiaohashu-oss-biz` 模块的 `pom.xml` 文件,引入上述依赖:
```php-template
<dependencies>
// 省略...
<!-- 阿里云 OSS -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
</dependency>
</dependencies>
```
依赖添加完毕后,点击右侧栏 *Reload* 图标,重新刷新一下 Maven 依赖,将包下载到本地 Maven 仓库中。
## 初始化客户端
![](https://img.quanxiaoha.com/quanxiaoha/171966627378511)
然后,在 `/config` 包下创建 `AliyunOSSConfig` 配置类,代码如下,用于初始化 `OSS` 客户端实体类,并注入到 Spring 容器中:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.config;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.DefaultCredentialProvider;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-05-11 8:49
* @description: 阿里云 Client 配置
**/
@Configuration
public class AliyunOSSConfig {
@Resource
private AliyunOSSProperties aliyunOSSProperties;
/**
* 构建 阿里云 OSS 客户端
*
* @return
*/
@Bean
public OSS aliyunOSSClient() {
// 设置访问凭证
DefaultCredentialProvider credentialsProvider = CredentialsProviderFactory.newDefaultCredentialProvider(
aliyunOSSProperties.getAccessKey(), aliyunOSSProperties.getSecretKey());
// 创建 OSSClient 实例
return new OSSClientBuilder().build(aliyunOSSProperties.getEndpoint(), credentialsProvider);
}
}
```
## 补充阿里云策略实现类
客户端初始化完毕后,编辑 `AliyunOSSFileStrategy` 阿里云策略实现类,补充上传文件功能,代码如下:
```java
package com.quanxiaoha.xiaohashu.oss.biz.strategy.impl;
import com.aliyun.oss.OSS;
import com.quanxiaoha.xiaohashu.oss.biz.config.AliyunOSSProperties;
import com.quanxiaoha.xiaohashu.oss.biz.strategy.FileStrategy;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.util.UUID;
/**
* @author: 犬小哈
* @date: 2024/6/27 19:47
* @version: v1.0.0
* @description: 阿里云 OSS 文件上传策略
**/
@Slf4j
public class AliyunOSSFileStrategy implements FileStrategy {
@Resource
private AliyunOSSProperties aliyunOSSProperties;
@Resource
private OSS ossClient;
@Override
@SneakyThrows
public String uploadFile(MultipartFile file, String bucketName) {
log.info("## 上传文件至阿里云 OSS ...");
// 判断文件是否为空
if (file == null || file.getSize() == 0) {
log.error("==> 上传文件异常:文件大小为空 ...");
throw new RuntimeException("文件大小不能为空");
}
// 文件的原始名称
String originalFileName = file.getOriginalFilename();
// 生成存储对象的名称(将 UUID 字符串中的 - 替换成空字符串)
String key = UUID.randomUUID().toString().replace("-", "");
// 获取文件的后缀,如 .jpg
String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
// 拼接上文件后缀,即为要存储的文件名
String objectName = String.format("%s%s", key, suffix);
log.info("==> 开始上传文件至阿里云 OSS, ObjectName: {}", objectName);
// 上传文件至阿里云 OSS
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(file.getInputStream().readAllBytes()));
// 返回文件的访问链接
String url = String.format("https://%s.%s/%s", bucketName, aliyunOSSProperties.getEndpoint(), objectName);
log.info("==> 上传文件至阿里云 OSS 成功,访问路径: {}", url);
return url;
}
}
```
> 大体上和 `minio` 差不太多,细节部分需要注意一下,如:
>
> + **返回文件访问链接**:阿里云 OSS 的访问链接有些不一样,小伙伴们可以在控制台中,手动上传一个图片,然后查看详情,观察一下图片 `URL` 地址格式,如下图标注所示,所以,咱们的访问链接拼接格式,也需要保持一致,否则会无法访问图片。
>
> ![](https://img.quanxiaoha.com/quanxiaoha/171966703434264)
>
## 自测一波
功能敲写完毕后,**重启对象存储服务**。登录到 Nacos 控制台,将对象存储类型切换到 `aliyun` , 以便测试对应功能。打开 Apipost 工具,测试一波上传图片接口,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171966680497447)
可以看到,成功返回了阿里云 OSS 的图片访问地址。也可以进入到阿里云后台**文件列表**中,确认一下图片是否真的上传成功了:
![](https://img.quanxiaoha.com/quanxiaoha/171966690686257)
## 关于图片浏览器无法预览的问题
如果你直接将图片链接复制到浏览器中进行访问,会发现图片直接就下载了,而不是预览模式。这个不是代码写的有问题,官方有解释这个问题:
[https://help.aliyun.com/zh/oss/user-guide/how-to-ensure-an-object-is-previewed-when-you-access-the-object?spm=a2c6h.13066369.question.9.1346431aVBckWS](https://help.aliyun.com/zh/oss/user-guide/how-to-ensure-an-object-is-previewed-when-you-access-the-object?spm=a2c6h.13066369.question.9.1346431aVBckWS) ,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171966721894836)
官方解释如下:
> 使用OSS默认域名或传输加速域名访问。出于数据传输安全考虑当使用OSS默认域名或传输加速域名访问某个时间点创建的Bucket内的特定类型文件时例如Content-Type为text/html、image/jpeg等OSS会强制在返回头中增加下载Header`x-oss-force-download: true`和`Content-Disposition: attachment`)。标准浏览器检测到`Content-Disposition: attachment`时,会出现强制下载而不是预览行为。
解决方案是:
> 您需要使用自定义域名访问。
可以先不用管,不影响到时候前端 `<img>` 标签图片展示。
## 本小节源码下载
[https://t.zsxq.com/EbPCF](https://t.zsxq.com/EbPCF)

View File

@@ -0,0 +1,166 @@
![](https://img.quanxiaoha.com/quanxiaoha/169663913789430)
因为博客设置模块涉及到图片上传,如上传博客 LOGO 图片、作者头像,以及后续发布文章也需要支持图片上传。所以,我们首先需要搭建一个图床服务,这里小哈选型的是 Minio 对象存储,它不光可以存储图片,还能存储文件、视频等,非常强大。
## 1\. 什么是 MinIO
MinIO 是一个开源的对象存储服务器。这意味着它允许你在互联网上存储大量数据比如文件、图片、视频等而不需要依赖传统的文件系统。MinIO 的特点在于它非常灵活、易于使用,同时也非常强大,可以在你的应用程序中方便地集成。
## 2\. 为什么使用 MinIO
+ **可伸缩性和性能:** MinIO 允许你在需要时轻松地扩展存储容量,无需中断服务。它具有出色的性能,可以处理大量的并发读取和写入请求。
+ **开源和自由:** MinIO 是开源软件,遵循 Apache License 2.0 许可证,这意味着你可以自由地使用、修改和分发它。
+ **容器化部署:** MinIO 提供了容器化部署的支持,可以在各种平台上快速部署和运行,包括本地开发机、云服务器和容器编排环境(如 Docker
+ **兼容性:** MinIO 提供了 S3 兼容的 API这意味着它可以与任何兼容 Amazon S3 的应用程序无缝集成,为你的应用程序提供强大的对象存储能力。
+ **易用性:** MinIO 的配置和管理非常简单它提供了直观的Web控制台和命令行工具帮助你方便地管理存储桶和对象。
总的来说MinIO 是一个灵活、高性能、易用且开源的对象存储解决方案,适用于各种规模的应用程序,特别是那些需要大规模数据存储和访问的项目。
## 3\. Docker 搭建 Minio 服务
了解了 `Minio` 是什么后,接下来我们开始安装它。这里小哈使用的 Docker 来安装,更加简单一些。
首先,你需要确保你的机器已经安装成功了 Docker不清楚如何安装 Docker 的童鞋,可以翻阅前面的[《后端环境安装》](https://www.quanxiaoha.com/column/10003.html) 小节。
### 3.1 选择一个 Minio 镜像
然后,我们在浏览器中访问地址:[https://hub.docker.com/](https://hub.docker.com/) 输入关键词 *minio/minio*, 找到 `Minio` 镜像:
![](https://img.quanxiaoha.com/quanxiaoha/169660073969927)
点击进去,点击 *Tags* 标签选项,小哈这里选择的是最新的一个发行版本:
![](https://img.quanxiaoha.com/quanxiaoha/169660085180120)
### 3.2 下载 Minio 镜像
*点击右侧复制命令*,打开命令行,执行该命令拉取镜像:
```bash
docker pull minio/minio:RELEASE.2023-09-30T07-02-29Z
```
![](https://img.quanxiaoha.com/quanxiaoha/169660167085551)
镜像下载成功后,执行 `docker images` , 如果列表中有 `minio/minio` 镜像,则表示镜像下载成功了:
![](https://img.quanxiaoha.com/quanxiaoha/169660176136231)
### 3.3 新建数据挂载目录
下载镜像成功后,我们在某个盘下,小哈这里选择的是 `E:` 盘,新建一个 `/docker` 文件夹,然后在该文件夹中再新建一个 `/minio` 文件夹,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/169660207107021)
新建该文件夹的目的是,后面通过镜像运行 `Minio` 容器时,可以将容器内的数据目录,挂载到宿主机的 `E:\docker\minio` 目录下,防止容器重启后,会导致数据丢失的问题。
### 3.4 运行 Docker Minio 容器
然后,通过该镜像运行 `Minio` 容器,命令如下:
```bash
docker run -d \
-p 9000:9000 \
-p 9090:9090 \
--name minio \
-v E:\docker\minio\data:/data \
-e "MINIO_ROOT_USER=quanxiaoha" \
-e "MINIO_ROOT_PASSWORD=quanxiaoha" \
minio/minio:RELEASE.2023-09-30T07-02-29Z server /data --console-address ":9090"
```
注意,执行的时候需要将 `\` 替换成空格,放到一行中来执行,最终命令如下:
```bash
docker run -d -p 9000:9000 -p 9090:9090 --name minio -v E:\docker\minio\data:/data -e "MINIO_ROOT_USER=quanxiaoha" -e "MINIO_ROOT_PASSWORD=quanxiaoha" minio/minio:RELEASE.2023-09-30T07-02-29Z server /data --console-address ":9090"
```
解释一下上述命令各选项的含义:
+ `docker run`: 运行 Docker 容器的命令。
+ `-d` : 表示后台运行该容器;
+ `-p 9000:9000`: 将宿主机的 9000 端口映射到容器的 9000 端口。MinIO 默认的 HTTP API 端口是 9000。
+ `-p 9090:9090`: 将宿主机的 9090 端口映射到容器的 9090 端口。这是 MinIO 的 Web 控制台的端口。
+ `--name minio`: 给容器取了一个名字,这里是 "minio"。
+ `-v E:\docker\minio\data:/data`: 将宿主机上的 `E:\docker\minio\data` 目录映射到容器内的 `/data`目录。这是 MinIO 存储数据的地方。如果你希望数据在容器删除后仍然保存,可以将数据目录映射到宿主机。
+ `-e "MINIO_ROOT_USER=quanxiaoha"`: 设置 MinIO 的管理员用户名为 "quanxiaoha"。这是用于 MinIO Web 控制台和 API 的初始管理员用户名。
+ `-e "MINIO_ROOT_PASSWORD=quanxiaoha"`: 设置 MinIO 的管理员密码为 "quanxiaoha"。这是用于 MinIO Web 控制台和 API 的初始管理员密码。
+ `minio/minio:RELEASE.2023-09-30T07-02-29Z`: 这是 MinIO 的 Docker 镜像版本。
+ `server /data --console-address ":9090"`: 启动 MinIO 服务器,并将数据存储在容器内的`/data`目录。`--console-address ":9090"`表示 MinIO 的Web 控制台将在容器的 9090 端口上运行。
![](https://img.quanxiaoha.com/quanxiaoha/169660281303664)
执行该命令后,再执行 `docker ps` 命令,可查看正在运行的容器,若如下图所示,容器列表中出现了 `minio` ,则表示 `Minio` 后台运行成功了:
![](https://img.quanxiaoha.com/quanxiaoha/169752736845773)
> TIP : 如果 `Minio` 容器运行未成功,就需要通过日志来定位问题了,可以重新执行 `docker run` 命令,并将 `-d` 参数去掉,以前台的方式运行容器,即可看到启动日志了。
## 4\. 访问 Minio 控制台
浏览器访问地址 `http://localhost:9090` ,可访问 MinIO 的 Web 控制台:
![](https://img.quanxiaoha.com/quanxiaoha/169660295505364)
输入运行容器时,指定的用户名/密码:`quanxiaoha/quanxiaoha` , 进入到 `Minio` 的管理后台:
![](https://img.quanxiaoha.com/quanxiaoha/169660302571945)
## 5\. 新建一个 Bucket 桶
进入后台后,点击 *Create a Bucket* 创建一个 `Bucket` 桶,用于存储图片:
![](https://img.quanxiaoha.com/quanxiaoha/169660310261259)
输入 Bucket Name, 我们将其命名为 *weblog* 然后点击 *Create Bucket* 按钮:
![](https://img.quanxiaoha.com/quanxiaoha/169660317986367)
创建成功后,在 Buckets 列表中就可以看到刚刚新建的桶了:
![](https://img.quanxiaoha.com/quanxiaoha/169660333521057)
## 6\. 设置 Bucket 为公共读
因为我们上传的图片需要被公网访问到,所以,还需要设置 `Bucket` 为公共读,默认为 `Private` 私有。点击想要设置的桶,然后编辑 `Access Policy`:
![](https://img.quanxiaoha.com/quanxiaoha/169660445905313)
将 Access Policy 选项选择为 *Public* 公共读,点击 *Set* 设置按钮:
![](https://img.quanxiaoha.com/quanxiaoha/169660454548379)
设置成功后,就可以看到 Access Policy 一栏变更为 *Public* 了,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/169660466779619)
## 7\. 上传一张图片
相关设置完成后,我们直接在后台上传一张本地的图片,测试看看能够正常上传成功。点击 *Object Browser -> Upload* 上传图片:
![](https://img.quanxiaoha.com/quanxiaoha/169660482349794)
这里小哈选择了一张妹子图片来做测试:
![](https://img.quanxiaoha.com/quanxiaoha/169660529340964)
可以看到上传成功了:
![](https://img.quanxiaoha.com/quanxiaoha/169660493984915)
接下来,我们直接在浏览器中,来访问该图片的直链:`http://127.0.0.1:9000/weblog/111.jpg` , 看看能否被正常访问:
> 💡 TIP: 本地访问路径格式为 *请求地址:端口号 + 桶名称 + 图片的名称*,上线后会申请域名,格式为 *域名 + 桶名称 + 图片的名称*,例如 https://img.quanxiaoha.com/weblog/111.jpg。
![](https://img.quanxiaoha.com/quanxiaoha/169660534867053)
OK , 可以看到该图片能够被正常访问。到这里,本地的 `Minio` 对象存储服务就搭建好了,后续博客中的相关图片,都会上传到 `Minio` 中,然后,数据库中会直接存储图片的直链地址。
## 8\. 结语
本小节中,小哈带着大家通过 Docker 容器,在本地环境中,将 Minio 对象存储服务搭建起来了,以作图床使用。 这样,也可以方便的进行本地图片上传,当然,如果小伙伴们有服务器,也可以安装 Linux 服务器中来安装使用。在项目最终上线时,小哈会再次演示如何在 Linux 服务器中来安装它。

View File

@@ -0,0 +1,343 @@
上小节中,我们已经在本地搭建好了 `Minio` 对象存储的环境。本小节中,我们就来为后端服务添加一个 —— 文件上传接口。
## 1\. 添加 Minio 依赖
首先,在父项目的 `pom.xml` 文件中添加 `Minio` 版本管理声明:
```php-template
<!-- 版本号统一管理 -->
<properties>
// 省略...
<minio.version>8.2.1</minio.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- 对象存储 Minio -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
然后,在 `weblog-module-admin` 模块中的 `pom.xml` 文件中,添加该依赖:
```php-template
<!-- 对象存储 Minio -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
```
## 2\. 添加 Minio 配置
编辑 `applicaiton-dev.yml` 配置文件,添加 `minio` 相关的配置项:
```yaml
#=================================================================
# minio
#=================================================================
minio:
endpoint: http://127.0.0.1:9000
accessKey: quanxiaoha
secretKey: quanxiaoha
bucketName: weblog
```
解释一下这些配置都是干啥的:
1. **`endpoint: http://127.0.0.1:9000`**:指定 MinIO 服务器的地址。实际部署时,您需要将它替换为您 MinIO 服务器的地址。
2. **`accessKey: quanxiaoha`**:运行容器时,指定的接入 `key`。
3. **`secretKey: quanxiaoha`**:运行容器时,指定的秘钥 `key`。
4. **`bucketName: weblog`**存储桶bucket的名称。
## 3\. 新增 Minio 配置类
接着,在 `weblog-module-admin` 子模块的 `/config` 包下,创建一个 `MinioProperties` 配置类,用来读取刚刚手动配置的 `Minio` 选项:
![](https://img.quanxiaoha.com/quanxiaoha/169664924538554)
```less
@ConfigurationProperties(prefix = "minio")
@Component
@Data
public class MinioProperties {
private String endpoint;
private String accessKey;
private String secretKey;
private String bucketName;
}
```
> ⚠️ 注意:配置类的前缀需要指定为 `minio`, 且字段名要与配置项名称保持一致。
## 4\. 新增 Minio 客户端配置
继续在 `/config` 包中,创建一个 `MinioConfig` 客户端配置类:
```\
@Configuration
public class MinioConfig {
@Autowired
private MinioProperties minioProperties;
@Bean
public MinioClient minioClient() {
// 构建 Minio 客户端
return MinioClient.builder()
.endpoint(minioProperties.getEndpoint())
.credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey())
.build();
}
}
```
上述代码中,我们构建了一个 `MinioClient` 客户端,并使用 `@Bean` 注解注入到了 `Spring` 容器中。
## 5\. 封装图片上传工具类
初始化好 `Minio` 客户端后,在 `weblog-module-admin` 子模块中,新增一个 `/utils` 工具包,然后,新建 `MinioUtil` 工具类,并添加一个上传文件的方法,代码如下:
![](https://img.quanxiaoha.com/quanxiaoha/169664969411963)
```java
@Component
@Slf4j
public class MinioUtil {
@Autowired
private MinioProperties minioProperties;
@Autowired
private MinioClient minioClient;
/**
* 上传文件
* @param file
* @return
* @throws Exception
*/
public String uploadFile(MultipartFile file) throws Exception {
// 判断文件是否为空
if (file == null || file.getSize() == 0) {
log.error("==> 上传文件异常:文件大小为空 ...");
throw new RuntimeException("文件大小不能为空");
}
// 文件的原始名称
String originalFileName = file.getOriginalFilename();
// 文件的 Content-Type
String contentType = file.getContentType();
// 生成存储对象的名称(将 UUID 字符串中的 - 替换成空字符串)
String key = UUID.randomUUID().toString().replace("-", "");
// 获取文件的后缀,如 .jpg
String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
// 拼接上文件后缀,即为要存储的文件名
String objectName = String.format("%s%s", key, suffix);
log.info("==> 开始上传文件至 Minio, ObjectName: {}", objectName);
// 上传文件至 Minio
minioClient.putObject(PutObjectArgs.builder()
.bucket(minioProperties.getBucketName())
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(contentType)
.build());
// 返回文件的访问链接
String url = String.format("%s/%s/%s", minioProperties.getEndpoint(), minioProperties.getBucketName(), objectName);
log.info("==> 上传文件至 Minio 成功,访问路径: {}", url);
return url;
}
}
```
上述代码中,我们首先对 `MultipartFile` 进行了判空,防止上传空的文件。然后拿到了该文件的相关属性值,如原始文件名、`Content-Type` ,通过对原始文件名截取出文件后缀,以及通过 `UUID` 生成一个随机的文件名,最终通过 `minioClient` 客户端的 `putObject` 方法来上传文件,上传成功后,将文件的访问链接拼接好并返回。
## 6\. 接口出入参格式
前置工作都完成后,我们准备开始开发文件上传接口。
### 6.1 接口地址
```bash
POST /admin/file/upload 表单格式提交
```
### 6.2 入参
| 字段名 | 描述 |
| --- | --- |
| `file` | 选择一个本地文件 |
### 6.3 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": {
"url": "http://127.0.0.1:9000/weblog/d73ec5ffb1074aacb07c0899663068dd.jpg" // 文件的访问地址
}
}
```
## 7\. 新增上传文件返参 VO
在 `weblog-module-admin` 模块中的 `/model/vo` 包下,新增名为 `/file` 的包,用于放置文件模块相关的 `VO` 类,然后,创建 `UploadFileRspVO` 文件上传返参实体类:
```less
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UploadFileRspVO {
/**
* 文件的访问链接
*/
private String url;
}
```
## 8\. 新增上传文件 service
上传文件的工具类封装好后,我们在 `weblog-module-admin` 模块中的 `service` 包中添加 `AdminFileService` 接口,并在其中定义一个文件上传方法:
```java
public interface AdminFileService {
/**
* 上传文件
* @param file
* @return
*/
Response uploadFile(MultipartFile file);
}
```
然后,在 `/impl` 包中,创建该接口的实现类 `AdminFileServiceImpl` , 代码如下:
```typescript
@Service
@Slf4j
public class AdminFileServiceImpl implements AdminFileService {
@Autowired
private MinioUtil minioUtil;
/**
* 上传文件
*
* @param file
* @return
*/
@Override
public Response uploadFile(MultipartFile file) {
try {
// 上传文件
String url = minioUtil.uploadFile(file);
// 构建成功返参,将图片的访问链接返回
return Response.success(UploadFileRspVO.builder().url(url).build());
} catch (Exception e) {
log.error("==> 上传文件至 Minio 错误: ", e);
// 手动抛出业务异常,提示 “文件上传失败”
throw new BizException(ResponseCodeEnum.FILE_UPLOAD_FAILED);
}
}
}
```
代码比较简单,我们将 `MinioUtil` 注入后,直接调用工具方法中的 `uploadFile()` 方法,并构建成功返参,将图片的访问链接返回。
### 8.1 添加文件上传失败全局枚举
上述代码中,当上传文件捕获到异常时,则手动抛出了上传文件失败异常。这里,我们还需要编辑 `weblog-module-common` 模块中的 `ResponseCodeEnum` 全局枚举类,添加对应的枚举值,如下:
```bash
FILE_UPLOAD_FAILED("20008", "文件上传失败!"),
```
## 9\. 新增 Controller 接口
接着,在 `/controller` 包下,创建 `AdminFileController` 控制器,并新增文件上传接口 `/amdin/file/upload`, 代码如下:
```less
@RestController
@RequestMapping("/admin")
@Api(tags = "Admin 文件模块")
public class AdminFileController {
@Autowired
private AdminFileService fileService;
@PostMapping("/file/upload")
@ApiOperation(value = "文件上传")
@ApiOperationLog(description = "文件上传")
public Response uploadFile(@RequestParam MultipartFile file) {
return fileService.uploadFile(file);
}
}
```
注意,文件上传接口并非使用 `JSON` 格式进行提交,而是使用 `Form` 表达来提交的,所以入参的注解使用的 `@RequestParam` , 并使用了 `MultipartFile` 类接收上传的文件。
## 10\. 测试看看
完成以上工作后,文件上传接口就开发完毕了。接下来,我们通过浏览器访问 `localhost:8080/doc.html` , 调试一波此接口,看看功能是否正常:
![](https://img.quanxiaoha.com/quanxiaoha/169666007678963)
选择一张本地图片后,点击*发送*,可以看到后端响参成功,并返回了图片的访问地址。复制该图片的访问链接,直接在浏览器中,访问该图片地址,看看能否正常访问:
![](https://img.quanxiaoha.com/quanxiaoha/169666021844918)
OK图片访问没有任何问题表明文件上传接口功能正常。
## 11\. 大文件上传失败问题
当你尝试上传一个体积较大的文件时,你可能会在控制台中看到如下异常信息:
```css
Caused by: org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException: The field file exceeds its maximum permitted size of 1048576 bytes.
at org.apache.tomcat.util.http.fileupload.impl.FileItemStreamImpl$1.raiseError(FileItemStreamImpl.java:117) ~[tomcat-embed-core-9.0.56.jar:9.0.56]
at org.apache.tomcat.util.http.fileupload.util.LimitedInputStream.checkLimit(LimitedInputStream.java:76) ~[tomcat-embed-core-9.0.56.jar:9.0.56]
at org.apache.tomcat.util.http.fileupload.util.LimitedInputStream.read(LimitedInputStream.java:135) ~[tomcat-embed-core-9.0.56.jar:9.0.56]
at java.io.FilterInputStream.read(FilterInputStream.java:107) ~[na:1.8.0_311]
at org.apache.tomcat.util.http.fileupload.util.Streams.copy(Streams.java:97) ~[tomcat-embed-core-9.0.56.jar:9.0.56]
at org.apache.tomcat.util.http.fileupload.FileUploadBase.parseRequest(FileUploadBase.java:288) ~[tomcat-embed-core-9.0.56.jar:9.0.56]
at org.apache.catalina.connector.Request.parseParts(Request.java:2932) ~[tomcat-embed-core-9.0.56.jar:9.0.56]
... 96 common frames omitted
```
这个错误表明在尝试上传文件时,文件的大小超过了 Spring Boot 设置的最大允许大小限制。具体而言,错误消息中的 "The field file exceeds its maximum permitted size of 1048576 bytes" 意味着上传的文件大小超过了 1048576 字节1MB。为了解决这个问题可以编辑 `application.yml` 文件,添加如下配置, 来手动设置上传文件的大小:
```yaml
spring:
servlet:
multipart:
max-file-size: 10MB # 限制单个上传文件的最大大小为 10MB。如果上传的文件大小超过这个值将会被拒绝上传。
max-request-size: 10MB # 限制整个上传请求的最大大小为 10MB。这包括所有上传文件的大小之和。如果请求总大小超过这个值将会被拒绝。
```
## 12\. 本小节对应源码下载
[https://t.zsxq.com/12T2XU3sv](https://t.zsxq.com/12T2XU3sv)
## 13\. 结语
本小节中,小哈带着大家手动封装了一个 `Minio` 的文件上传工具类,并新增了一个文件上传接口,最后,通过 `Knife4j` 调试了该接口,选择了一张本地图片进行上传,接口调试通过。