Compare commits

...

2 Commits
main ... md

Author SHA1 Message Date
cuijiawang
0d814a6e99 doc 7 9 10 14 2025-02-17 11:57:55 +08:00
cuijiawang
971bc89ee9 doc 4 5 6 8 2025-02-17 10:05:44 +08:00
72 changed files with 18449 additions and 0 deletions

View File

@ -0,0 +1,91 @@
---
title: 短文本存储技术选型 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10318.html
publishedTime: null
---
发布笔记是小红书核心功能之一,它允许用户创建、编辑和分享内容,以图文并茂的形式展示给其他用户,如下图所示。接下来,让我们来思考一个问题,对于笔记详情的展示,除了笔记的标题、图片链接、发布时间等等基础信息外,**短文本数据,如笔记内容、评论内容存储在 MySQL 数据库中合适吗?**
![](https://img.quanxiaoha.com/quanxiaoha/172171072391674)
## 文本存储在 MySQL 合适吗?
探讨此问题时,需结合产品的体量来分析。举个栗子,如果是一个访问量不大的博客,存储在 MySQL 当然是没有任何问题的。但是,小红书拥有超过 2 亿的用户数,就需要考虑到高并发写入与读的性能问题。
大家都知道MySQL 是关系型数据库更擅长于存储关系数据另外InnoDB 作为 MySQL 的默认存储引擎虽然其功能强大但是涉及到大规模存储和管理文本数据时InnoDB 存储引擎就会遇到性能瓶颈。
**在 InnoDB 中,是使用行存储结构来存储数据的,每一行存储在数据页中**,数据页的默认大小为 16 KB。当存储的文本数据很长时InnoDB 会将部分数据存储在溢出页中,并在原数据页中保留一个指针指向溢出页。这种机制会导致更多的磁盘 I/O 操作,因为访问一条长文本数据需要读取多个数据页。
其效率问题,大致可总结如下:
+ **磁盘 I/O 开销**:由于 InnoDB 的行溢出机制长文本数据需要多个页来存储和访问。这意味着读取和写入长文本数据时需要更多的磁盘I/O操作从而降低了性能。
+ **数据页填充率** InnoDB 的数据页大小固定为 16KB。当文本数据长度不固定时可能会导致数据页的填充率不高浪费存储空间并且增加了页碎片。
+ **索引效率**对于长文本数据InnoDB 的 B+ 树索引可能会变得低效。尤其是对 TEXT 类型列进行索引时,索引只会包含前几个字符,无法利用索引进行高效的全文检索。
+ **事务开销** InnoDB 支持 ACID 事务,事务日志和回滚日志需要额外的存储和处理。对于频繁更新的大量长文本数据,这些日志会带来显著的性能开销。
> 所以,我们可以得出结论:**性能要求较高场景下,关系型数据库不适合存储文本内容。**
## 短文本存储技术选型
既然关系型数据库不合适,我们可以从非关系型数据库中来技术选型,如下:
+ **内存型 KV 数据库**:比较典型的如 Redis
![](https://img.quanxiaoha.com/quanxiaoha/172172073813330)
+ **特点**
+ 内存存储:数据保存在内存中,读写速度非常快。
+ 丰富的数据类型:包括字符串、哈希、列表、集合、有序集合等。
+ 内置计数功能:可以使用字符串类型的 `INCR``DECR` 命令进行计数操作。
+ **适用场景**
+ 实时统计,如在线用户数、页面访问计数。
+ 需要高并发读写的场景。
+ 临时数据或缓存,不需要持久化数据。
+ **分布式 KV 存储系统**:如基于 RocksDB 存储引擎上构建的 [Cassandra](https://cassandra.apache.org/) 、TiKV 等,都有非常好的扩展性和数据持久化能力,并且数据保存在磁盘上,磁盘相较于内存要廉价很多,非常适合用于存储海量的短文本数据:
![](https://img.quanxiaoha.com/quanxiaoha/172172434544269)
+ **特点**
+ 分布式存储:高可用性和可扩展性强。
+ 支持时间序列数据:适合处理大规模时间序列数据。
+ 可线性扩展:增加节点可以线性提高读写性能。
+ **适用场景**
+ 大规模数据存储和分析。
+ 需要高可用性的系统。
+ 横向扩展需求强烈的场景。
+ **分布式文档数据库**:数据以文档的形式存储,通常使用 JSON、BSONBinary JSON或 XML 格式。这使得数据结构非常灵活,适合存储半结构化或无结构的数据,典型的代表就是 MongDB ,具有强大的查询和索引功能。
![](https://img.quanxiaoha.com/quanxiaoha/172172470347665)
+ **特点:**
+ 文档存储:以 JSON 格式存储数据,灵活性强。
+ 索引支持:支持多种索引,查询性能好。
+ 分片机制:支持水平分片,数据量大时扩展性强。
+ **适用场景:**
+ 半结构化数据存储,如日志、文档管理。
+ 需要快速开发迭代的应用。
+ 数据模式不固定或变化频繁的场景。
## 最终选择
针对海量的短文本存储,这里不考虑内存型 KV 数据库,一是内存非常昂贵,另外,数据持久化表现也比较一般。所以在 KV 存储系统或者分布式文档数据库中选择,若更关注于易用性,可以考虑分布式文档数据库。而针对小哈书项目,我们更在意性能,所以最终选型 KV 存储系统 Apache Cassandra。
它是一个强大、灵活且可扩展的分布式 NoSQL 数据库系统,适用于需要高可用性、高吞吐量和低延迟的应用场景。其无中心化架构、可调一致性、以及列族存储模型,使其成为处理大规模数据和实时分析的理想选择。

View File

@ -0,0 +1,105 @@
---
title: Docker 安装 Cassandra - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10319.html
publishedTime: null
---
![](https://img.quanxiaoha.com/quanxiaoha/172179052498364)
本小节中,我们将通过 Docker 把本地的 Cassandra 测试环境搭建起来。
## 什么是 Cassandra ?
Apache Cassandra 是一个开源的分布式 NoSQLNot Only SQL数据库管理系统专为处理大规模数据量和高写入频率的工作负载而设计。它最初由 Facebook 开发,后来贡献给了 Apache 软件基金会,成为了 Apache 的一个顶级项目。
Cassandra 结合了 Google Bigtable 的数据模型和 Amazon Dynamo 的完全分布式架构,提供了以下关键特性:
+ **高可用性**Cassandra 是一个无单点故障的系统,它通过数据复制和一致性级别选择,确保即使在节点失败的情况下数据仍然可访问。
+ **水平可扩展性**Cassandra 能够通过添加更多节点到集群中轻松扩展,无需停机,这使得它能够处理不断增长的数据量和用户负载。
+ **分布式数据存储**:数据在集群中的多个节点上分布存储,每个节点都是平等的,没有主从之分,这有助于提高性能和可靠性。
+ **最终一致性**Cassandra 允许开发者选择数据的一致性和可用性之间的权衡,通过可配置的一致性级别,可以在强一致性和高可用性之间找到合适的平衡点。
+ **数据模型**Cassandra 使用列族column-family的数据模型允许以宽列的方式存储数据非常适合存储半结构化或非结构化数据。
+ **数据压缩和索引**Cassandra 支持数据压缩和创建二级索引,以提高存储效率和查询性能。
+ **多数据中心复制**Cassandra 支持跨多个地理区域的数据中心复制,以实现数据的地理分布和灾难恢复。
Cassandra 被广泛应用于需要处理大量数据和高写入负载的场景例如社交网络、物联网IoT、实时数据分析和推荐系统等。由于其强大的可扩展性和高可用性Cassandra 成为了许多大型企业如 Netflix、Digg、Twitter 等的选择。
## 拉取镜像
打开命令行工具,执行如下命令,开始拉取 `cassandra` 最新的镜像:
```undefined
docker pull cassandra:latest
```
![](https://img.quanxiaoha.com/quanxiaoha/172178479483795)
拉取成功后,可执行 `docker images` 命令查看本地已下载的镜像,确认一下 `cassandra` 是否下载成功。
![](https://img.quanxiaoha.com/quanxiaoha/172179816079588)
## 准备挂载的文件夹
`E:/docker` 路径下,创建一个 `/cassandra` 文件夹,用于等会启动容器时,将需要持久化的数据挂载到宿主机中,防止容器重启时数据丢失:
![](https://img.quanxiaoha.com/quanxiaoha/172178529093754)
## 运行容器
前置工作完成后,执行如下命令,运行一个 `cassandra` 容器:
```css
docker run --name cassandra -d -p 9042:9042 -v E:\docker\cassandra\data:/var/lib/cassandra cassandra:latest
```
> 解释一下各项参数的含义:
>
> + `docker run`: 这是 Docker 用来启动一个新的容器的命令。
> + `--name cassandra`: 这个选项指定了容器的名称为 "cassandra"。给容器命名可以帮助你在将来更容易地识别和管理它。
> + `-d`: 这个标志表示在后台(守护进程模式)运行容器,不会阻塞你的终端会话。
> + `-p 9042:9042`: 这个选项进行了端口映射,将宿主机的 9042 端口映射到容器内的 9042 端口。这意味着你可以在宿主机上通过 9042 端口访问 Cassandra 容器提供的服务。Cassandra 默认使用 9042 端口作为 CQL shell 的接入点。
> + `-v E:\docker\cassandra\data:/var/lib/cassandra`: 这是一个卷volume映射将宿主机上的目录 `E:\docker\cassandra\data` 挂载到容器内的 `/var/lib/cassandra` 目录。这个目录是 Cassandra 用来存储数据和日志的地方。通过这种方式,即使容器被删除,数据也会保留在宿主机上,因为数据存储在持久化的卷中。
> + `cassandra:latest`: 这指定了要使用的 Docker 镜像。在这里,镜像是 Cassandra 的官方镜像,并且使用了 `latest` 标签,意味着拉取 Cassandra 的最新版本镜像。
![](https://img.quanxiaoha.com/quanxiaoha/172178546487891)
执行完毕后,执行 `docker ps` 命令,可查看本地正在运行中的 `Docker` 容器,确认一下 `cassandra` 是否启动成功了:
![](https://img.quanxiaoha.com/quanxiaoha/172178551207685)
## 打开 cqlsh 命令行
`cassandra` 容器运行成功后,执行如下命令,可进入到容器中:
```bash
docker exec -it cassandra /bin/sh
```
接着,执行如下命令,可打开 `cqlsh` 命令行工具:
```yaml
cqlsh
cqlsh 127.0.0.1 9042
```
> **科普: 什么是 `cqlsh` ?**
>
> `cqlsh` 是 Cassandra Query Language Shell 的缩写,它是一个命令行工具,允许你向 Cassandra 数据库发送查询、创建表、插入数据、检索数据等。
若如下图所示,提示 `Connected to Test Cluster at 127.0.0.1:9042` , 则说明已经成功连接上了 `cassandra`:
![](https://img.quanxiaoha.com/quanxiaoha/172178598883716)
> **注意**:如果执行 `cqlsh` 提示无法连接 cassandra, 如下图所示,我这边是先执行 `exit` 退出容器,让后删除 cassandra 容器,重启再次运行一个 cassandra 容器,再次进入容器执行 `cqlsh`, 就连上了,遇到同样问题的小伙伴不妨试一试。
>
> ![](https://img.quanxiaoha.com/quanxiaoha/172215972734766)
至此,本地的 `cassandra` 环境就安装完成了。下小节中,我们将通过 `cqlsh` 命令行工具,上手学习 `cassandra` 数据库的一些基本操作,如创建表、插入数据、更新数据、删除数据等。

View File

@ -0,0 +1,150 @@
---
title: CQL 基本命令 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10320.html
publishedTime: null
---
![](https://img.quanxiaoha.com/quanxiaoha/172181346764536)
Cassandra Query Language (CQL) 是 Apache Cassandra 数据库的一种专用查询语言,它简化了与 Cassandra 的交互,提供了一种类似 SQL 的语法来管理数据。本小节中,我们就来学习一下如何使用 CQL 命令来创建、管理和查询 Cassandra 数据库。
## Cassandra 基本概念
在学习 CQL 命令之前,先理解一下 Cassandra 中几个基本概念:
+ **节点Node**Cassandra 集群中的每个服务器称为一个节点。每个节点都存储数据,并且相互之间没有主从关系,所有节点都是对等的。
+ **集群Cluster**:由多个节点组成的分布式系统称为集群。集群中的节点共同工作,处理读写请求并存储数据。
+ **数据中心Data Center**:集群中的节点可以分布在多个数据中心,每个数据中心包含若干个节点。数据中心的划分有助于实现跨地域的高可用性。
+ **键空间Keyspace**:键空间是一个逻辑容器,用于管理多个表,可以理解为 MySQL 中的库。另外,键空间定义了数据复制的策略。
+ **表Table**:表是数据存储的基本单位,由行和列组成。每张表都有一个唯一的名称和定义。
+ **主键Primary Key**:每行数据都有一个唯一的主键。主键由分区键和可选的列组成,用于唯一标识数据行。
+ **分区键Partition Key**Cassandra 使用分区键的哈希值将数据分布到不同的节点上,从而实现负载均衡和数据的水平扩展。分区键可以是单个列或多列的组合(复合分区键)。
## 键空间Keyspace
### 创建
打开 `cqlsh` 命令行,执行下面语句,来创建一个 `Keyspace`:
```java
CREATE KEYSPACE IF NOT EXISTS xiaohashu
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};
```
![](https://img.quanxiaoha.com/quanxiaoha/172180972798744)
> 详细解释一下:
>
> + `CREATE KEYSPACE IF NOT EXISTS`: 这是创建 keyspace 的命令,`IF NOT EXISTS` 是一个条件语句,确保只有当 keyspace 还未创建时才执行创建操作。这样可以防止重复创建 keyspace 导致的错误。
> + `xiaohashu`: 这是即将创建的 keyspace 的名称。keyspace 类似于传统关系型数据库中的“数据库”,是 Cassandra 中数据的最高层级容器。
> + `WITH replication`: 这里指定了 keyspace 的复制策略和配置。复制策略决定了数据如何在集群中复制和分布。
> + `'class': 'SimpleStrategy'`: 这里指定了复制策略的类型为 `SimpleStrategy``SimpleStrategy` 是一种基本的复制策略,适用于单数据中心的部署。它将数据均匀地分布到集群中的节点上。
> + `'replication_factor': 1`: 这是复制因子,表示每个数据分区的副本数量。在这个例子中,`replication_factor` 设置为 1意味着每个数据分区只有一个副本这通常用于测试或开发环境但在生产环境中可能不是最佳实践因为缺乏冗余会导致数据丢失的风险增加。
### 查看
键空间创建完成后,执行如下命令,可查看所有的 `Keyspace` :
```sql
DESCRIBE KEYSPACES;
```
效果图如下:
![](https://img.quanxiaoha.com/quanxiaoha/172180978603718)
### 删除
如果想删除某个键空间,以及其中的所有数据,可执行如下语句:
```sql
DROP KEYSPACE IF EXISTS xiaohashu;
```
![](https://img.quanxiaoha.com/quanxiaoha/172180969179148)
### 选择
键空间创建完成后,可通过 `USE` 命令,选择该 `Keyspace` ,以便后续操作它:
```undefined
USE xiaohashu;
```
![](https://img.quanxiaoha.com/quanxiaoha/172180987385281)
## 创建表
执行如下语句,创建一张 `note_content` 笔记内容表。这里注意,由于我们是拿 Cassandra 充当 K-V 键值存储数据库,所以表中只包含两个字段(实际可以支持多字段),`id` 主键充当 `Key` , 笔记内容 `content` 充当 `Value` :
```sql
CREATE TABLE note_content (
id UUID PRIMARY KEY,
content TEXT
);
```
![](https://img.quanxiaoha.com/quanxiaoha/172181190540074)
> 解释一下:
>
> + `CREATE TABLE`: 这是 Cassandra 中创建新表的命令。
> + `note_content`: 表的名称。
> + `(``)`:这些括号包含了表的列定义和主键定义。
> + `id UUID PRIMARY KEY`: 这里定义了表中的一个列 `id`,其数据类型是 `UUID`(通用唯一标识符)。`PRIMARY KEY` 指示 `id` 列是表的主键。在 Cassandra 中,主键用于唯一标识表中的每一行,同时也是数据在集群中分区的依据。
>
> > **为什么这里要用 UUID? 而不是笔记本身的 ID?**
> >
> > UUID 生成的值具有较高的随机性,因此在集群中可以提供良好的数据分布,避免热点问题。
>
> + `content TEXT`: 这里定义了另一个列 `content`,其数据类型是 `TEXT``TEXT` 类型用于存储文本字符串。
## 插入记录
笔记内容表创建完成后,执行如下语句,插入一条数据:
```sql
INSERT INTO note_content (id, content) VALUES (uuid(), '这是一条测试笔记');
```
![](https://img.quanxiaoha.com/quanxiaoha/172181199885972)
## 查询记录
执行如下语句,查询 `note_content` 表中所有数据:
```sql
SELECT * FROM note_content;
```
![](https://img.quanxiaoha.com/quanxiaoha/172181206086754)
执行如下语句,查询 `id``728c9c82-c64b-410b-8970-0dcae49efaa7` 的记录:
```sql
SELECT * FROM note_content WHERE id = 728c9c82-c64b-410b-8970-0dcae49efaa7;
```
![](https://img.quanxiaoha.com/quanxiaoha/172181232599224)
## 更新记录
执行如下语句,以 `id` 为条件来更新对应笔记内容:
```sql
UPDATE note_content SET content = '更新后的评论内容' WHERE id = 728c9c82-c64b-410b-8970-0dcae49efaa7;
```
![](https://img.quanxiaoha.com/quanxiaoha/172181241479997)
## 删除记录
执行如下语句,将 `id``728c9c82-c64b-410b-8970-0dcae49efaa7` 的记录删除掉:
```sql
DELETE FROM note_content WHERE id = 728c9c82-c64b-410b-8970-0dcae49efaa7;
```
![](https://img.quanxiaoha.com/quanxiaoha/172181312462776)
至此CQL 基本命令就学习完了,是不是感觉上手非常简单,和操作 MySQL 相差无几。

View File

@ -0,0 +1,290 @@
---
title: KV 键值存储微服务搭建 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10321.html
publishedTime: null
---
在之前小节中,我们已经将 Cassandra 的本地环境搭建起来了。本小节中,着手把 KV 键值存储微服务建好。
## 新建父模块
打开 IDEA, 在 `xiaohashu` 文件上右键,依次点击 *New | Module*, 新建一个子模块项目:
![](https://img.quanxiaoha.com/quanxiaoha/172188403782757)
弹出框中,填写项目相关信息:
![](https://img.quanxiaoha.com/quanxiaoha/172188410421958)
> 解释一下相关配置项的作用:
>
> + ①:选择 `Maven Archetype` 来创建一个 `Maven` 项目;
> + ②:项目名称,填写 `xiaohashu-kv`
> + ③: 通过使用 Archetype你可以基于已有的项目模板创建一个新项目。这里选择 `maven-archetype-site-simple`
点击 *Create* 按钮,开始创建子模块项目。等待控制台提示 `Build Success` , 说明项目创建完成。这里将 `/src` 目录删除掉,如下图所示,只保留一个 `pom.xml`
![](https://img.quanxiaoha.com/quanxiaoha/172188416006049)
同时,`xiaohashu-kv` 键值存储服务项目创建完成后,打开项目的最外层 `pom.xml` , 你会发现 `<modules>` 节点下已经自动将此模块添加进来管理了:
```php-template
<!-- 子模块管理 -->
<modules>
// 省略...
<!-- KV 键值存储服务 -->
<module>xiaohashu-kv</module>
</modules>
```
编辑 `xiaohashu-kv` 中的 `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</artifactId>
<version>${revision}</version>
</parent>
<!-- 多模块项目需要配置打包方式为 pom -->
<packaging>pom</packaging>
<!-- 子模块管理 -->
<modules>
</modules>
<artifactId>xiaohashu-kv</artifactId>
<!-- 项目名称 -->
<name>${project.artifactId}</name>
<!-- 项目描述 -->
<description>Key-Value 键值存储服务</description>
</project>
```
## 新建 api 子模块
父模块新建完毕后。在 `xiaohashu-kv` 文件夹上 *右键 | New | Modules* , 在键值存储微服务中,新建一个子模块:
![](https://img.quanxiaoha.com/quanxiaoha/172188437762214)
填写子模块相关信息:
![](https://img.quanxiaoha.com/quanxiaoha/172188976779833)
> 解释一下相关配置项的作用:
>
> + ①:选择 `Maven Archetype` 来创建一个 `Maven` 项目;
> + ②:子模块名称,填写 `xiaohashu-kv-api`
> + ③: 通过使用 Archetype你可以基于已有的项目模板创建一个新项目。这里选择 `maven-archetype-quickstart`
> + ④: 包名称填写 `com.quanxiaoha.xiaohash.kv`;
点击 *Create* 按钮开始创建子模块。创建完成后,如下图所示,将不需要的 `App``/test` 删除掉:
![](https://img.quanxiaoha.com/quanxiaoha/172188986240489)
编辑 `xiaohashu-kv-api` 模块的 `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-kv</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>xiaohashu-kv-api</artifactId>
<name>${project.artifactId}</name>
<description>RPC层, 供其他服务调用</description>
<dependencies>
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-common</artifactId>
</dependency>
</dependencies>
</project>
```
## 新建 biz 子模块
继续在 `xiaohashu-kv` 键值服务上 *右键 | New | Module*, 创建一个 `biz` 业务子模块:
![](https://img.quanxiaoha.com/quanxiaoha/172188437762214)
填写相关配置项:
![](https://img.quanxiaoha.com/quanxiaoha/172189003925554)
> 解释一下相关配置项的作用:
>
> + ①:选择 `Maven Archetype` 来创建一个 `Maven` 项目;
> + ②:子模块名称,填写 `xiaohashu-kv-biz`
> + ③: 通过使用 Archetype你可以基于已有的项目模板创建一个新项目。这里选择 `maven-archetype-quickstart`
> + ④: 包名称填写 `com.quanxiaoha.xiaohash.kv.biz`;
点击 *Create* 按钮,开始创建子模块项目。等待项目创建完成后,将不需要的类删除,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172189014143389)
编辑 `xiaohashu-kv-biz` 模块的 `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-kv</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>xiaohashu-kv-biz</artifactId>
<name>${project.artifactId}</name>
<description>Key-Value 键值存储业务层</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>
</project>
```
### 添加 Spring Boot 项目所需文件
接下来,我们将为 `xiaohashu-kv-biz` 模块添加 Spring Boot 项目所需的一些文件,如下图标注所示:
![](https://img.quanxiaoha.com/quanxiaoha/172189036265584)
#### 启动类
`com.quanxiaoha.xiaohashu.kv.biz` 包下,创建 Spring Boot 的启动类,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.kv.biz;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class XiaohashuKVBizApplication {
public static void main(String[] args) {
SpringApplication.run(XiaohashuKVBizApplication.class, args);
}
}
```
#### 配置类
`/main` 文件夹下,创建 `/resource` 资源目录,并添加相关 `application.yml`配置类,可以直接从别的服务中复制一份过来,修改一下,如启动端口。其他暂时不需要的配置先删除,等需要的时候再添加:
```yaml
server:
port: 8084 # 项目启动的端口
spring:
profiles:
active: dev # 默认激活 dev 本地开发环境
```
#### 日志配置
复制过来的 `logback-spring.xml` 日志配置文件,记得将应用名称修改为 `kv` , 代码如下:
```php-template
// 省略...
<!-- 应用名称 -->
<property scope="context" name="appName" value="kv"/>
// 省略...
```
### 启动项目
以上添加完成后,点击 `xiaohashu-kv-biz` 模块中启动类左侧的**启动图标**,测试一下项目是否能够正常跑起来:
![](https://img.quanxiaoha.com/quanxiaoha/172189044028786)
若控制台提示如上图所示,则表示项目启动正常。
## 注册到 Nacos
最后,顺便将 KV 键值存储服务注册到 Nacos 上。编辑 `xiaohashu-kv-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>
```
`/resources` 资源目录下创建 `bootstrap.yml` 配置文件,同样的,可以直接从别的项目复制过来:
![](https://img.quanxiaoha.com/quanxiaoha/172189057862822)
记得修改应用名称,这里修改为了 `xiaohashu-kv` , 与其他服务的名称区分开来,其他不用动:
```yaml
spring:
application:
name: xiaohashu-kv # 应用名称
profiles:
active: dev # 默认激活 dev 本地开发环境
cloud:
nacos:
discovery:
enabled: true # 启用服务发现
group: DEFAULT_GROUP # 所属组
namespace: xiaohashu # 命名空间
server-addr: 127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
```
再次重启服务,并登陆到 Nacos 管理后台,确认一下服务列表中, `xiaohashu` 命名空间下,键值存储服务是否注册成功,若如下图所示,表示注册成功了。
![](https://img.quanxiaoha.com/quanxiaoha/172189064017098)
## 本小节源码下载
[https://t.zsxq.com/MsNe2](https://t.zsxq.com/MsNe2)

View File

@ -0,0 +1,291 @@
---
title: Spring Boot 3.x 整合 Cassandra
url: https://www.quanxiaoha.com/column/10322.html
publishedTime: null
---
本小节中,我们将为 KV 键值存储服务整合 Cassandra , 并通过编写单元测试 ,实现对 Cassandra 的增删改查功能。
## 添加依赖
编辑 `xiaohashu-kv-biz` 模块中的 `pom.xml` 文件,添加如下依赖:
```php-template
<!-- Cassandra 存储 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-cassandra</artifactId>
</dependency>
```
## 添加配置
接着,编辑 `application-dev.yml` 开发环境配置文件,添加连接 Cassandra 所需的相关配置,如键空间、连接地址、端口,大致如下:
![](https://img.quanxiaoha.com/quanxiaoha/172197164413151)
```yaml
spring:
cassandra:
keyspace-name: xiaohashu
contact-points: 127.0.0.1
port: 9042
```
## 新建 DO 实体类与 Repository 接口
![](https://img.quanxiaoha.com/quanxiaoha/172197185850914)
`xiaohashu-kv-biz` 模块中,新建 `/domain/dataobject` 包,用于放置数据库实体类,并在里面创建 [《10.3 节》](https://www.quanxiaoha.com/column/10320.html) 中 `note_content` 表对应的数据库实体类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.biz.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.cassandra.core.mapping.PrimaryKey;
import org.springframework.data.cassandra.core.mapping.Table;
import java.util.UUID;
/**
* @author: 犬小哈
* @date: 2024/7/14 16:19
* @version: v1.0.0
* @description: 笔记内容
**/
@Table("note_content")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class NoteContentDO {
@PrimaryKey("id")
private UUID id;
private String content;
}
```
然后,新建 `/domain/repository` 包,创建 `NoteContentRepository` 接口,代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.biz.domain.repository;
import com.quanxiaoha.xiaohashu.kv.biz.domain.dataobject.NoteContentDO;
import org.springframework.data.cassandra.repository.CassandraRepository;
import java.util.UUID;
/**
* @author: 犬小哈
* @date: 2024/7/14 16:21
* @version: v1.0.0
* @description: TODO
**/
public interface NoteContentRepository extends CassandraRepository<NoteContentDO, UUID> {
}
```
> 解释一下:
>
> + `CassandraRepository`: 这是 Spring Data Cassandra 提供的一个泛型接口,它为 Cassandra 数据库提供了 CRUD创建、读取、更新、删除和其他一些基本的操作方法。
> + `<NoteContentDO, UUID>`: 这里有两个类型参数:
> + `NoteContentDO`: 表示与 Cassandra 数据库交互时使用的数据对象类型。通常情况下,这是一个 Java 类,它映射到数据库中的表。
> + `UUID`: 表示 `NoteContentDO` 对象的主键类型。根据表的实际情况来定义,这里使用 `UUID` 作为主键类型。
## 声明配置类
![](https://img.quanxiaoha.com/quanxiaoha/172199043409696)
接着,新建 `/config` 包,并创建 `CassandraConfig` 数据源配置类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.biz.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.cassandra.config.AbstractCassandraConfiguration;
/**
* @author: 犬小哈
* @date: 2024/7/14 16:03
* @version: v1.0.0
* @description: Cassandra 配置类
**/
@Configuration
public class CassandraConfig extends AbstractCassandraConfiguration {
@Value("${spring.cassandra.keyspace-name}")
private String keySpace;
@Value("${spring.cassandra.contact-points}")
private String contactPoints;
@Value("${spring.cassandra.port}")
private int port;
/*
* Provide a keyspace name to the configuration.
*/
@Override
public String getKeyspaceName() {
return keySpace;
}
@Override
public String getContactPoints() {
return contactPoints;
}
@Override
public int getPort() {
return port;
}
}
```
> 解释一下关键的地方:
>
> + `extends AbstractCassandraConfiguration`: `AbstractCassandraConfiguration` 是 Spring Data Cassandra 提供的一个抽象基类,它包含了一些默认的方法实现,用于配置 Cassandra 连接。
> + `getKeyspaceName()`, `getContactPoints()`, 和 `getPort()` 方法:
> + 这些方法都是覆盖override自父类 `AbstractCassandraConfiguration` 的抽象方法。它们分别返回 keyspace 名称、连接和端口号。
> + 当 Spring 初始化 Cassandra 连接时,会调用这些方法来获取配置信息。
## 添加单元测试
### 新增
![](https://img.quanxiaoha.com/quanxiaoha/172197245281076)
最后,我们在 `/test` 包下,创建一个单元测试类 `CassandraTests` , 测试一波是否能够正常操作 Cassandra 代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.biz;
import com.quanxiaoha.xiaohashu.kv.biz.domain.dataobject.NoteContentDO;
import com.quanxiaoha.xiaohashu.kv.biz.domain.repository.NoteContentRepository;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.UUID;
@SpringBootTest
@Slf4j
class CassandraTests {
@Resource
private NoteContentRepository noteContentRepository;
/**
* 测试插入数据
*/
@Test
void testInsert() {
NoteContentDO nodeContent = NoteContentDO.builder()
.id(UUID.randomUUID())
.content("代码测试笔记内容插入")
.build();
noteContentRepository.save(nodeContent);
}
}
```
编写一个插入数据的测试方法,点击左侧运行按钮,查看控制台日志,确认单元测试运行成功,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172197252705419)
接着,打开命令行,依次执行如下命令,进入到 Cassandra 数据库中:
```bash
# 进入 cassandra 容器内部
docker exec -it cassandra /bin/sh
# 打开 cqlsh 命令行工具
cqlsh
# 选择键空间
use xiaohashu;
```
![](https://img.quanxiaoha.com/quanxiaoha/172197264094760)
执行 `select` 查询语句,确认一下数据是否正常插入了:
![](https://img.quanxiaoha.com/quanxiaoha/172197288667135)
### 修改
再来测试一波更新记录,添加单元测试方法如下:
```csharp
/**
* 测试修改数据
*/
@Test
void testUpdate() {
NoteContentDO nodeContent = NoteContentDO.builder()
.id(UUID.fromString("eaad1222-f091-40be-b824-0c9f275724a7"))
.content("代码测试笔记内容更新")
.build();
noteContentRepository.save(nodeContent);
}
```
> TIP : 更新记录同样是使用 `save()` 方法,保持主键一致,即可完成更新操作。
执行单元测试方法,再次执行查询语句,确认数据是否更新成功:
![](https://img.quanxiaoha.com/quanxiaoha/172197304841792)
### 查询
添加查询记录的单元测试,代码如下:
```cpp
/**
* 测试查询数据
*/
@Test
void testSelect() {
Optional<NoteContentDO> optional = noteContentRepository.findById(UUID.fromString("eaad1222-f091-40be-b824-0c9f275724a7"));
optional.ifPresent(noteContentDO -> log.info("查询结果:{}", JsonUtils.toJsonString(noteContentDO)));
}
```
根据主键来查询,当数据不为空,则打印日志。运行该单元测试,确认查询记录是否正常:
![](https://img.quanxiaoha.com/quanxiaoha/172197327479064)
### 删除
最后,添加一个删除记录的单元测试,代码如下:
```csharp
/**
* 测试删除数据
*/
@Test
void testDelete() {
noteContentRepository.deleteById(UUID.fromString("eaad1222-f091-40be-b824-0c9f275724a7"));
}
```
根据主键来删除某条笔记内容,运行单元测试,并确认记录是否删除成功:
![](https://img.quanxiaoha.com/quanxiaoha/172197339041732)
## 本小节源码下载
[https://t.zsxq.com/DuEmA](https://t.zsxq.com/DuEmA)

View File

@ -0,0 +1,321 @@
---
title: 笔记内容新增接口开发 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10323.html
publishedTime: null
---
本小节中,我们将在 KV 键值存储服务中,将**笔记内容新增接口**开发完成,并添加对应的 Feign 客户端接口,以方便后续笔记服务直接调用。
## 接口定义
### 接口地址
```bash
POST /kv/note/content/add
```
### 入参
```json
{
"noteId": 1, // 笔记 ID由笔记服务生成 ID, 并传给 KV 键值存储服务
"content": "笔记内容测试" // 笔记内容
}
```
### 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": null
}
```
## 创建 DTO 实体类
![](https://img.quanxiaoha.com/quanxiaoha/172215890245452)
接口定义完毕后,在 `xiaohashu-kv-api` 模块中,创建 `/dto/req` 包,并添加接口入参实体类 `AddNoteContentReqDTO` 代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.dto.req;
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 AddNoteContentReqDTO {
@NotNull(message = "笔记 ID 不能为空")
private Long noteId;
@NotBlank(message = "笔记内容不能为空")
private String content;
}
```
## 添加依赖
因为在 `xiaohashu-kv-biz` 模块中开发笔记内容新增接口时,需要使用到上述定义的 `DTO` 实体类,所以,还需要在模块的 `pom.xml` 中引入 `xiaohashu-kv-api` 模块。首先,在项目最外层的 `pom.xml` 中,声明 `xiaohashu-kv-api` 的依赖,如下:
```php-template
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-kv-api</artifactId>
<version>${revision}</version>
</dependency>
// 省略...
</dependencies>
</dependencyManagement>
```
接着,在 `xiaohashu-kv-biz` 业务模块中,引入该依赖:
```php-template
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-kv-api</artifactId>
</dependency>
```
依赖添加完毕后,记得刷新一下 Maven。
## 编写 service 业务层
![](https://img.quanxiaoha.com/quanxiaoha/172215886515592)
编辑 `xiaohashu-kv-biz` 模块,添加 `/service` 包,并新增 `NoteContentService` 业务接口,以及声明一个添加笔记内容的方法,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记内容存储业务
**/
public interface NoteContentService {
/**
* 添加笔记内容
*
* @param addNoteContentReqDTO
* @return
*/
Response<?> addNoteContent(AddNoteContentReqDTO addNoteContentReqDTO);
}
```
继续添加 `/service/impl` 包,并添加上述业务接口的实现类 `NoteContentServiceImpl` , 代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.biz.service.impl;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.biz.domain.dataobject.NoteContentDO;
import com.quanxiaoha.xiaohashu.kv.biz.domain.repository.NoteContentRepository;
import com.quanxiaoha.xiaohashu.kv.biz.service.NoteContentService;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: Key-Value 业务
**/
@Service
@Slf4j
public class NoteContentServiceImpl implements NoteContentService {
@Resource
private NoteContentRepository noteContentRepository;
@Override
public Response<?> addNoteContent(AddNoteContentReqDTO addNoteContentReqDTO) {
// 笔记 ID
Long noteId = addNoteContentReqDTO.getNoteId();
// 笔记内容
String content = addNoteContentReqDTO.getContent();
// 构建数据库 DO 实体类
NoteContentDO nodeContent = NoteContentDO.builder()
.id(UUID.randomUUID()) // TODO: 暂时用 UUID, 目的是为了下一章讲解压测,不用动态传笔记 ID。后续改为笔记服务传过来的笔记 ID
.content(content)
.build();
// 插入数据
noteContentRepository.save(nodeContent);
return Response.success();
}
}
```
> **TIP** : 逻辑比较简单,就是保存笔记内容到 Cassandra 中。需要注意的是,这里的主键暂时用的 UUID, 而不是上游服务传过来的笔记 ID, 目的是为了在下一章中方便的演示 Jmeter 压力测试。等后续开发笔记服务时,会改成存储由笔记服务传过来的笔记 ID。
## 新增 controller
![](https://img.quanxiaoha.com/quanxiaoha/172216510741681)
`xiaohashu-kv-biz` 模块中,添加 `/controller` 包,并新增 `NoteContentController` 控制器,创建 `/kv/note/content/add` 接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.biz.controller;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.biz.service.NoteContentService;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
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/4/4 13:22
* @version: v1.0.0
* @description: 笔记内容
**/
@RestController
@RequestMapping("/kv")
@Slf4j
public class NoteContentController {
@Resource
private NoteContentService noteContentService;
@PostMapping(value = "/note/content/add")
public Response<?> addNoteContent(@Validated @RequestBody AddNoteContentReqDTO addNoteContentReqDTO) {
return noteContentService.addNoteContent(addNoteContentReqDTO);
}
}
```
## 自测一波
接口开发完成后,重启 KV 键值服务。并调试一波笔记内容新增接口,看看功能是否正常,如下:
![](https://img.quanxiaoha.com/quanxiaoha/172216263651789)
可以看到服务端响应成功,打开 Cassandra 命令行工具,查询数据是否真的插入成功了,如下图所示,没有问题。
![](https://img.quanxiaoha.com/quanxiaoha/172216267702524)
## Feign 客户端接口
为了方便其他服务调用,还需要将 Feign 接口提供出来。编辑 `xiaohashu-kv-api` 模块中的 `pom.xml` , 添加 Feign 相关依赖,如下:
```php-template
<!-- OpenFeign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- 负载均衡 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
```
依赖添加完成后,刷新一下 Maven。
![](https://img.quanxiaoha.com/quanxiaoha/172216428712826)
继续编辑 `xiaohashu-kv-api` 模块,添加 `/constant` 常量包,并新增 `ApiConstants` 接口,代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.constant;
/**
* @author: 犬小哈
* @date: 2024/4/13 23:23
* @version: v1.0.0
* @description: TODO
**/
public interface ApiConstants {
/**
* 服务名称
*/
String SERVICE_NAME = "xiaohashu-kv";
}
```
添加 `/api` 包,并创建 `KeyValueFeignApi` 客户端接口,将上面开发好的笔记内容新增接口提供出来,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.api;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.constant.ApiConstants;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* @author: 犬小哈
* @date: 2024/4/13 22:56
* @version: v1.0.0
* @description: K-V 键值存储 Feign 接口
**/
@FeignClient(name = ApiConstants.SERVICE_NAME)
public interface KeyValueFeignApi {
String PREFIX = "/kv";
@PostMapping(value = PREFIX + "/note/content/add")
Response<?> addNoteContent(@RequestBody AddNoteContentReqDTO addNoteContentReqDTO);
}
```
至此,笔记内容新增接口就大致开发完成了。
## 本小节源码下载
[https://t.zsxq.com/wnzu7](https://t.zsxq.com/wnzu7)

View File

@ -0,0 +1,471 @@
---
title: 笔记内容查询接口开发 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10324.html
publishedTime: null
---
本小节中,我们为 KV 键值服务添加一个 —— **笔记内容查询接口**,上游服务传入笔记 ID即可获取内容文本。
## 接口定义
### 接口地址
```bash
POST /kv/note/content/find
```
### 入参
```json
{
"noteId": "15382b55-b351-4d11-ac1a-860d7bc005fb" // 笔记 ID
}
```
### 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": {
"noteId": "15382b55-b351-4d11-ac1a-860d7bc005fb", // 笔记 ID
"content": "笔记内容测试" // 笔记内容
}
}
```
## 新建 DTO 实体类
编辑 `xiaohashu-kv-api` 模块,在里面分别新建接口对应的出入参 `DTO` 实体类,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172223385423834)
`/dto/req` 包下创建 `FindNoteContentReqDTO` 入参类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.dto.req;
import jakarta.validation.constraints.NotBlank;
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 FindNoteContentReqDTO {
@NotBlank(message = "笔记 ID 不能为空")
private String noteId;
}
```
`/dto/rsp` 包下,创建接口出参实体类 `FindNoteContentRspDTO`, 代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.dto.rsp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:17
* @version: v1.0.0
* @description: 笔记内容
**/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindNoteContentRspDTO {
/**
* 笔记 ID
*/
private UUID noteId;
/**
* 笔记内容
*/
private String content;
}
```
## 异常状态码枚举
![](https://img.quanxiaoha.com/quanxiaoha/172224077158313)
新建 `/enums` 枚举包,并添加一个异常状态码枚举类,代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.biz.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 {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("KV-10000", "出错啦,后台小哥正在努力修复中..."),
PARAM_NOT_VALID("KV-10001", "参数错误"),
// ----------- 业务异常状态码 -----------
NOTE_CONTENT_NOT_FOUND("KV-20000", "该笔记内容不存在"),
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
> 除通用异常枚举值外,还声明了一个 `NOTE_CONTENT_NOT_FOUND` 枚举值,用于等会业务层中,若判断笔记内容不存在时使用。
## 全局异常捕获器
![](https://img.quanxiaoha.com/quanxiaoha/172224109019398)
从其他服务中,复制一个全局异常捕获器过来,复制过来后,`ResponseCodeEnum` 的包路径会爆红,修正一下即可,其他不用动,代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.biz.exception;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.biz.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;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2023-08-15 10:14
* @description: 全局异常处理
**/
@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);
}
/**
* 捕获 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 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);
}
}
```
## 编写 service 业务层
回到 `xiaohashu-kv-biz` 模块中,编辑 `NoteContentService` 业务接口,声明一个**查询笔记内容**方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.FindNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.rsp.FindNoteContentRspDTO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记内容存储业务
**/
public interface NoteContentService {
// 省略...
/**
* 查询笔记内容
*
* @param findNoteContentReqDTO
* @return
*/
Response<FindNoteContentRspDTO> findNoteContent(FindNoteContentReqDTO findNoteContentReqDTO);
}
```
接着,在其实现类 `NoteContentServiceImpl` 中,实现上述声明的方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.biz.service.impl;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.biz.domain.dataobject.NoteContentDO;
import com.quanxiaoha.xiaohashu.kv.biz.domain.repository.NoteContentRepository;
import com.quanxiaoha.xiaohashu.kv.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.kv.biz.service.NoteContentService;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.FindNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.rsp.FindNoteContentRspDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: Key-Value 业务
**/
@Service
@Slf4j
public class NoteContentServiceImpl implements NoteContentService {
@Resource
private NoteContentRepository noteContentRepository;
// 省略...
/**
* 查询笔记内容
*
* @param findNoteContentReqDTO
* @return
*/
@Override
public Response<FindNoteContentRspDTO> findNoteContent(FindNoteContentReqDTO findNoteContentReqDTO) {
// 笔记 ID
String noteId = findNoteContentReqDTO.getNoteId();
// 根据笔记 ID 查询笔记内容
Optional<NoteContentDO> optional = noteContentRepository.findById(UUID.fromString(noteId));
// 若笔记内容不存在
if (!optional.isPresent()) {
throw new BizException(ResponseCodeEnum.NOTE_CONTENT_NOT_FOUND);
}
NoteContentDO noteContentDO = optional.get();
// 构建返参 DTO
FindNoteContentRspDTO findNoteContentRspDTO = FindNoteContentRspDTO.builder()
.noteId(noteContentDO.getId())
.content(noteContentDO.getContent())
.build();
return Response.success(findNoteContentRspDTO);
}
}
```
> 解释一波逻辑:
>
> + 获取入参中的笔记 ID;
> + 查询 Cassandra 存储数据库,获取该条笔记对应的内容;
> + 判断 `Optional` , 若为空,抛出**笔记不存在**业务异常,交给全局异常捕获器统一处理;
> + 否则,则构建返参 `DTO` 返回;
## 添加 controller 接口
编辑 `NoteContentController` 控制器,添加 `/kv/note/content/find` 接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.biz.controller;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.biz.service.NoteContentService;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.FindNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.rsp.FindNoteContentRspDTO;
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/4/4 13:22
* @version: v1.0.0
* @description: 笔记内容
**/
@RestController
@RequestMapping("/kv")
@Slf4j
public class NoteContentController {
@Resource
private NoteContentService noteContentService;
// 省略...
@PostMapping(value = "/note/content/find")
public Response<FindNoteContentRspDTO> findNoteContent(@Validated @RequestBody FindNoteContentReqDTO findNoteContentReqDTO) {
return noteContentService.findNoteContent(findNoteContentReqDTO);
}
}
```
## 自测一波
接口编写完毕后,重启 KV 键值服务。调试一波接口,如下图所示,看看能否正常查询出笔记内容文本:
> **TIP** : 入参中的笔记 ID ,记得从 Cassandra 中获取一个表中已存在的 UUID。
>
> ![](https://img.quanxiaoha.com/quanxiaoha/172223976689035)
![](https://img.quanxiaoha.com/quanxiaoha/172223609900199)
如上图所示,响参成功,接口调试通过。
## 添加 Feign 客户端接口
最后,编辑 `xiaohashu-kv-api` 模块中的 `KeyValueFeignApi` 接口,将笔记内容查询接口提供出去,以供其他服务调用。代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.api;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.constant.ApiConstants;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.FindNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.rsp.FindNoteContentRspDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* @author: 犬小哈
* @date: 2024/4/13 22:56
* @version: v1.0.0
* @description: K-V 键值存储 Feign 接口
**/
@FeignClient(name = ApiConstants.SERVICE_NAME)
public interface KeyValueFeignApi {
String PREFIX = "/kv";
// 省略...
@PostMapping(value = PREFIX + "/note/content/find")
Response<FindNoteContentRspDTO> findNoteContent(@RequestBody FindNoteContentReqDTO findNoteContentReqDTO);
}
```
## 本小节源码下载
[https://t.zsxq.com/sz6us](https://t.zsxq.com/sz6us)

View File

@ -0,0 +1,257 @@
---
title: 笔记内容删除接口开发 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10325.html
publishedTime: null
---
本小节中,我们将为 KV 键值存储服务添加 —— **笔记内容删除接口**
## 接口定义
### 接口地址
```bash
POST /kv/note/content/delete
```
### 入参
```json
{
"noteId": "15382b55-b351-4d11-ac1a-860d7bc005fb" // 笔记 ID
}
```
### 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": null
}
```
## 创建入参 DTO
![](https://img.quanxiaoha.com/quanxiaoha/172224788904774)
编辑 `xiaohashu-kv-api` 模块,在 `/dto/req` 包下创建 `DeleteNoteContentReqDTO` 入参实体类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.dto.req;
import jakarta.validation.constraints.NotBlank;
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 DeleteNoteContentReqDTO {
@NotBlank(message = "笔记 ID 不能为空")
private String noteId;
}
```
## 编写 service 业务层
接着,回到 `xiaohashu-kv-biz` 业务模块中,编辑 `NoteContentService` 业务接口,声明一个**删除笔记内容**方法,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.DeleteNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.FindNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.rsp.FindNoteContentRspDTO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记内容存储业务
**/
public interface NoteContentService {
// 省略...
/**
* 删除笔记内容
*
* @param deleteNoteContentReqDTO
* @return
*/
Response<?> deleteNoteContent(DeleteNoteContentReqDTO deleteNoteContentReqDTO);
}
```
在其实现类 `NoteContentServiceImpl` 中,实现上述方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.kv.biz.service.impl;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.biz.domain.dataobject.NoteContentDO;
import com.quanxiaoha.xiaohashu.kv.biz.domain.repository.NoteContentRepository;
import com.quanxiaoha.xiaohashu.kv.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.kv.biz.service.NoteContentService;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.DeleteNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.FindNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.rsp.FindNoteContentRspDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记内容 Key-Value 业务
**/
@Service
@Slf4j
public class NoteContentServiceImpl implements NoteContentService {
@Resource
private NoteContentRepository noteContentRepository;
// 省略...
/**
* 删除笔记内容
*
* @param deleteNoteContentReqDTO
* @return
*/
@Override
public Response<?> deleteNoteContent(DeleteNoteContentReqDTO deleteNoteContentReqDTO) {
// 笔记 ID
String noteId = deleteNoteContentReqDTO.getNoteId();
// 删除笔记内容
noteContentRepository.deleteById(UUID.fromString(noteId));
return Response.success();
}
}
```
## 添加 controller 接口
编辑 `NoteContentController` 控制器,添加 `/kv/note/content/delete` 接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.kv.biz.controller;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.biz.service.NoteContentService;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.DeleteNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.FindNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.rsp.FindNoteContentRspDTO;
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/4/4 13:22
* @version: v1.0.0
* @description: 笔记内容
**/
@RestController
@RequestMapping("/kv")
@Slf4j
public class NoteContentController {
@Resource
private NoteContentService noteContentService;
// 省略...
@PostMapping(value = "/note/content/delete")
public Response<?> deleteNoteContent(@Validated @RequestBody DeleteNoteContentReqDTO deleteNoteContentReqDTO) {
return noteContentService.deleteNoteContent(deleteNoteContentReqDTO);
}
}
```
## 自测一波
**重启 KV 键值服务**,并调试一波接口功能是否好使,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172224830345981)
服务端响应成功,然后打开 Cassandra 命令行,确认一下该笔记 ID 对应的记录是否真的删除了,如下图所示,查询结果为空表示功能正常:
![](https://img.quanxiaoha.com/quanxiaoha/172224839731133)
## 添加 Feign 客户端接口
最后,编辑 `xiaohashu-kv-api` 模块中的 `KeyValueFeignApi` 接口,将笔记内容删除接口提供出去,方便后续其他服务直接调用:
```kotlin
package com.quanxiaoha.xiaohashu.kv.api;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.kv.constant.ApiConstants;
import com.quanxiaoha.xiaohashu.kv.dto.req.AddNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.DeleteNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.req.FindNoteContentReqDTO;
import com.quanxiaoha.xiaohashu.kv.dto.rsp.FindNoteContentRspDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* @author: 犬小哈
* @date: 2024/4/13 22:56
* @version: v1.0.0
* @description: K-V 键值存储 Feign 接口
**/
@FeignClient(name = ApiConstants.SERVICE_NAME)
public interface KeyValueFeignApi {
String PREFIX = "/kv";
// 省略...
@PostMapping(value = PREFIX + "/note/content/delete")
Response<?> deleteNoteContent(@RequestBody DeleteNoteContentReqDTO deleteNoteContentReqDTO);
}
```
## 本小节源码下载
[https://t.zsxq.com/tEmOF](https://t.zsxq.com/tEmOF)

View File

@ -0,0 +1,91 @@
---
title: 消息中间件MQ 介绍与技术选型 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10351.html
publishedTime: null
---
在[上小节](https://www.quanxiaoha.com/column/10350.html) 中的末尾,给大家留了一个小思考,关于笔记更新接口中,缓存更新是否存在问题?现在揭晓答案,是有问题的 —— 目前的方案,**本地缓存更新会存在数据一致性的问题**,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172475941049120)
假设生产环境中,我们部署了三台笔记服务,形成了一个集群,当用户更新笔记时,请求打到网关,网关将该请求转发到集群中某个实例上,比如转发到了服务实例 1按照此逻辑只有实例1会将本地缓存删除另外两个实例的本地缓存依然存在缓存里还是更新之前的老数据
后续,当请求笔记详情接口时,如果被转发到另外两台服务实例上时,数据还是本地缓存中的老数据,就出现了数据不一致的问题。
如何解决呢?我们可以在笔记更新成功后,发送一条 MQ 消息, 三个实例均订阅该消息,它们都能接收到笔记更新成功事件,以将本地缓存删除。
## 什么是 MQ
消息中间件Message Queue, MQ是一种软件基础设施**用于在分布式系统中实现异步通信和解耦合**。它允许应用程序通过消息队列发送和接收数据,从而实现不同组件之间的通信。
## 基本概念
+ **消息**:数据的最小单位,通常是应用程序之间传递的信息。
+ **队列**:一种数据结构,用于存储消息。消息按顺序进入队列,并按顺序被处理。
+ **生产者Producer**:发送消息的一方。
+ **消费者Consumer**:接收和处理消息的一方。
## 通信模型
消息中间件通常遵循两种通信模型:
+ **点对点模型Point-to-Point, P2P**在P2P模型中每条消息只能被一个消费者消费。一旦消息被某个消费者处理后就不再可用。这种模型适用于一对一的情况比如发送任务给工作者节点。类似于单独给某人发送手机短信只有指定的人才能收到
![](https://img.quanxiaoha.com/quanxiaoha/172474415592758)
+ **发布/订阅模型广播Publish/Subscribe, Pub/Sub**在Pub/Sub模型中生产者发布消息到主题任何订阅了该主题的消费者都会接收到消息。这种模型允许一对多的通信即多个消费者可以监听同一个主题。类似于学校广播通知所有同学都能接收到
![](https://img.quanxiaoha.com/quanxiaoha/172474399780343)
## 适用场景
+ **异步处理**当任务需要较长时间处理时MQ 允许应用程序将任务放入队列,并立即返回响应。任务将由消费者异步处理,避免阻塞主线程。
+ **解耦应用程序**在一个复杂的系统中不同的模块或服务可能需要进行通信。MQ 可以帮助解耦这些模块,使它们独立运行,彼此之间通过消息传递进行通信。典型的场景就是用户下单,下单后需要扣减库存、增加用户积分、通知发货等等,订单系统只需要发送一条 MQ, 即可通知对应系统处理相关逻辑。
![](https://img.quanxiaoha.com/quanxiaoha/172474908150924)
+ **流量削峰**在高并发场景下瞬时请求量可能会超过系统的处理能力。MQ 可以暂时缓存这些请求,平滑处理压力。比较典型的场景就是短视频点赞,某条短视频爆火,短时间内引起大量用户点赞,流量巨大,直接操作数据,会打垮数据库。可以通过引入 MQ, 用户点赞后,直接发送一条消息,消费者按一定速率慢慢处理(削峰),防止数据库压力过大。
![](https://img.quanxiaoha.com/quanxiaoha/172474945289210)
+ **日志处理**MQ 常用于收集和处理日志信息,例如分布式系统中的日志数据集中处理。
+ **事件驱动系统**MQ 可以在事件驱动架构中作为事件触发的中介,使系统能够对事件做出实时响应。
## 流行的 MQ 对比
| **维度** | **ActiveMQ** | **RabbitMQ** | **RocketMQ** | **Kafka** |
| --- | --- | --- | --- | --- |
| **协议支持** | AMQP, MQTT, STOMP, OpenWire, etc. | AMQP, MQTT, STOMP, HTTP, WebSockets | RocketMQ native protocol, HTTP, etc. | Kafka native protocol |
| **消息模型** | Queue, Topic, Virtual Topic, etc. | Queue, Topic | Queue, Topic | Topic, Partition, Consumer Groups |
| **持久化机制** | 支持多种持久化方案,如 KahaDB, JDBC | 支持持久化,通过插件可支持多种方式 | 文件存储,支持高效的顺序写 | 基于磁盘的日志存储,高效顺序写 |
| **吞吐量** | 中等,适合中小规模业务 | 中等偏高,适合中小规模业务 | 高吞吐量,适合大规模业务 | 极高,适合超大规模实时流处理 |
| **消息延迟** | 低至中,视配置而定 | 低至中,视配置而定 | 低延迟,适合实时消息传输 | 极低,适合实时数据流 |
| **消息顺序** | 支持,但需配置 | 支持,需配置 | 支持严格顺序 | 支持分区内严格顺序 |
| **消息保序性** | 支持,按 Topic 或 Queue 保序 | 支持,按 Queue 保序 | 支持,按 Topic 保序 | 支持,按 Partition 保序 |
| **事务支持** | 支持分布式事务 (XA) | 支持,带内嵌事务 | 原生支持分布式事务 | 部分支持,通过幂等实现 |
| **扩展性** | 中等,支持集群扩展 | 中等,支持集群扩展 | 高扩展性,支持水平扩展 | 极高,天然支持水平扩展 |
| **易用性** | 简单,文档丰富 | 简单,文档丰富 | 需要一定的学习成本 | 学习成本高 |
| **社区支持与生态** | 社区活跃不太高 | 社区非常活跃,插件极多 | 国内社区活跃,阿里巴巴开发维护 | 社区活跃,生态极为丰富 |
| **可靠性** | 高,支持多种持久化及备份策略 | 高,具备多种消息确认机制 | 高,企业级消息中间件,支持消息堆积 | 非常高,分布式架构,支持冗余和故障恢复 |
| **消息丢失风险** | 较低,可配置保证 | 较低,支持消息持久化及确认机制 | 非常低,支持事务消息 | 非常低,支持副本机制 |
| **管理工具** | 提供 Web 管理界面 | 提供 Web 管理界面 | 提供命令行工具和 Web 界面 | 提供 CLI 工具及第三方 Web 界面 |
| **典型应用场景** | 适合中小企业的应用集成及轻量级任务 | 广泛用于微服务架构与实时数据处理 | 适合大规模分布式系统及企业级应用 | 适合大数据、日志收集、实时分析 |
| **开发语言** | Java | Erlang | Java | Scala, Java |
| **商业支持** | 提供商业支持 | 提供商业支持 | 提供商业支持 | 提供商业支持Confluent 提供支持 |
### 选择建议:
+ **ActiveMQ** 适合中小企业的应用集成和轻量级任务,支持多种协议,配置和使用相对简单。
+ **RabbitMQ** 适合微服务架构和实时数据处理,具有高可用性和丰富的插件支持,社区非常活跃。
+ **RocketMQ** 适合大规模分布式系统,支持事务和消息堆积,阿里巴巴主导,适合需要高性能和低延迟的场景。
+ **Kafka** 适合超大规模的实时流数据处理,大数据处理的首选,具有极高的吞吐量和低延迟,但学习成本较高。
在小哈书这个项目中,关于消息中间件,我们最终技术选型 RocketMQ 。

View File

@ -0,0 +1,88 @@
---
title: RocketMQ 本地环境搭建 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10352.html
publishedTime: null
---
本小节中,我们先将 RocketMQ 的本地环境搭建好。
## RocketMQ 基本概念
关于 RocketMQ 相关的基本概念,小伙伴们可翻阅官网:[https://rocketmq.apache.org/zh/docs/4.x/producer/01concept1](https://rocketmq.apache.org/zh/docs/4.x/producer/01concept1) ,如下图框中标注的,写的很详细,建议都阅读一下:
![](https://img.quanxiaoha.com/quanxiaoha/172482681460520)
## 下载 RocketMQ 二进制包
浏览器访问地址:[https://archive.apache.org/dist/rocketmq/4.9.4/rocketmq-all-4.9.4-bin-release.zip](https://archive.apache.org/dist/rocketmq/4.9.4/rocketmq-all-4.9.4-bin-release.zip) 下载 RocketMQ 编译完成后的二进制包:
![](https://img.quanxiaoha.com/quanxiaoha/172484265642832)
下载完成后,进行解压。
## 设置环境变量
解压完成后放着,先把相关环境变量设置一下:
![](https://img.quanxiaoha.com/quanxiaoha/172484276874267)
添加一个 `ROCKETMQ_HOME` 变量,值为刚刚我们二进制包解压的具体路径:
![](https://img.quanxiaoha.com/quanxiaoha/172484297000666)
另外,将 `JAVA_HOME` 系统变量修改为 `jdk1.8`, 这边我测试了 `jdk 17` 会有报错的情况。
![](https://img.quanxiaoha.com/quanxiaoha/172484314788835)
设置完成后保存,打开一个新的命令窗口,执行 `java -version` 命令,确认当前使用的版本是 `Java 8`
![](https://img.quanxiaoha.com/quanxiaoha/172484321050341)
## 启动 RocketMQ
接着,准备正式启动 RocketMQ。
### 启动 namesrv
打开 `cmd` 命令行工具,进入到 RocketMQ 安装包的 `/bin` 文件夹下,执行如下命令,先将 `namesrv` 启动起来:
```sql
start .\mqnamesrv.cmd
```
![](https://img.quanxiaoha.com/quanxiaoha/172484332655682)
命令执行完成后,会打开一个新的窗口,若提示 `The Name Server boot sucess.` , 则表示 `namesrv` 启动成功了。
> **注意**:窗口打开后,不要关闭,关闭后 `namesrv` 也会随之关闭。
### 启动 broker
接着,执行如下命令,准备启动 `broker` :
```bash
.\mqbroker -n 127.0.0.1:9876 autoCreateTopicEnable=true
```
> 解释一下启动参数的含义:
>
> + `-n 127.0.0.1:9876`:用来指定网络连接参数的标志,绑定到本地 IP 地址 `127.0.0.1` 并监听端口 `9876`
> + `autoCreateTopicEnable=true`当发送的消息主题topic不存在时则自动创建。
![](https://img.quanxiaoha.com/quanxiaoha/172484369739840)
命令执行完成后,若提示 `The broker .. boot success.` , 则表示运行成功了。同样的,窗口不要关闭。
## RocketMQ 控制台
为了更方便的管控 RocketMQ, 还需要一个控制台。浏览器访问:[https://github.com/apache/rocketmq-dashboard](https://github.com/apache/rocketmq-dashboard) 如下图所示,下载 `zip` 源码:
![](https://img.quanxiaoha.com/quanxiaoha/172484376656135)
下载完成后解压,并使用 IDEA 打开项目。修改 `/resources/application.yml` 配置文件,将端口号换一下,默认是 8080 会与咱们项目的端口起冲突:
![](https://img.quanxiaoha.com/quanxiaoha/172484399358038)
修改完成后,运行 `App` 启动类,将项目跑起来。启动成功后,浏览器访问 `localhost:7000`, 即可打开 RocketMQ 的控制台了,小伙伴们可以每个菜单都点点,了解一下:
![](https://img.quanxiaoha.com/quanxiaoha/172484405423886)

View File

@ -0,0 +1,63 @@
---
title: IDEA 启动多个服务,本地模拟集群 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10353.html
publishedTime: null
---
在 [《13.13小节》](https://www.quanxiaoha.com/column/10350.html) 中,我们讲到了在生产环境中,服务是以集群的方式部署的,当前笔记更新的逻辑,会导致本地缓存数据不一致的问题。但是,还是有小伙伴不太理解,这小节中,我们就将通过 IDEA 启动多个笔记服务,本地模拟一下集群部署,并测试笔记更新接口,还原一下车祸现场。
## 复制多个服务
在本地当前开发环境中,每个服务的实例都只会跑一个,而不是多个,比如笔记服务:
![](https://img.quanxiaoha.com/quanxiaoha/172500245323968)
那在 IDEA 中,要如何方便的同时跑多个笔记服务呢?如下图所示,点击*右上角 | Edit Configurations....*
![](https://img.quanxiaoha.com/quanxiaoha/172500252615967)
选中想要跑多个实例的服务(笔记服务),点击上方的**复制**图标:
![](https://img.quanxiaoha.com/quanxiaoha/172500263069093)
## 添加参数
这里我们复制 2 个服务出来,接着,依次选中新建的服务实例,点击右侧的 *Modify options*:
![](https://img.quanxiaoha.com/quanxiaoha/172500272954792)
在弹出框中,将 `Environment variables` 选项勾选上:
![](https://img.quanxiaoha.com/quanxiaoha/172500276937489)
完成后,在对应输入框内,输入当前服务想要启动的端口,注意,需要保证和其他服务的端口区别开,防止冲突(三个笔记服务跑在不同端口上):
```ini
--server.port=8087
```
![](https://img.quanxiaoha.com/quanxiaoha/172500287399292)
配置完成后,点击 *Run* 按钮启动一个新的服务。3个实例都跑起来后大致如下
![](https://img.quanxiaoha.com/quanxiaoha/172500294592258)
登录到 Nacos 管理后台,在服务列表中,可以看到该服务的实例数已经变成了 3表示当前有三个笔记服务同时在工作
![](https://img.quanxiaoha.com/quanxiaoha/172500300248338)
## 车祸现场还原
为了还原车祸现场,我们**多次调用笔记详情查询接口**,以保证每个实例都初始化了本地缓存:
![](https://img.quanxiaoha.com/quanxiaoha/172500311990318)
接着,我们执行笔记更新接口,如下:
![](https://img.quanxiaoha.com/quanxiaoha/172500323548324)
由于请求会被网关转发到 3 个笔记服务中的其中一个,那么只有那一个实例,会将自己的本地缓存删除,另外两个服务的本地缓存依然存在。
为了验证这一点,我们再次请求笔记详情接口,如下图所示,如果请求被转发到了另外两个本地缓存还没删除的实例上,返回的笔记详情数据就还是老的数据,导致了数据不一致问题:
![](https://img.quanxiaoha.com/quanxiaoha/172500333514291)

View File

@ -0,0 +1,367 @@
---
title: Spring Boot 3.x 整合 RocketMQ实现广播消息
url: https://www.quanxiaoha.com/column/10354.html
publishedTime: null
---
本小节中,我们将为笔记服务整合 RocketMQ并在笔记更新成功时发送广播消息通知所有的笔记服务实例以完成各自对本地缓存的删除。
## 添加依赖
编辑项目最外层的 `pom.xml`, 添加 RocketMQ 依赖以及版本号:
```php-template
<properties>
// 省略...
<rocketmq.version>2.2.3</rocketmq.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- Rocket MQ -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>${rocketmq.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
接着,编辑 `xiaohashu-note-biz` 模块的 `pom.xml`, 引入上述依赖:
```php-template
<!-- Rocket MQ -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
```
刷新一下 Maven将依赖下载到本地仓库中。
## 添加配置
编辑 `application-dev.yml` 配置文件,添加 RocketMQ 相关配置, 如下:
![](https://img.quanxiaoha.com/quanxiaoha/172527372760166)
```yaml
rocketmq:
name-server: 127.0.0.1:9876 # name server 地址
producer:
group: xiaohashu_group
send-message-timeout: 3000 # 消息发送超时时间,默认 3s
retry-times-when-send-failed: 3 # 同步发送消息失败后,重试的次数
retry-times-when-send-async-failed: 3 # 异步发送消息失败后,重试的次数
max-message-size: 4096 # 消息最大大小(单位:字节)
consumer:
group: xiaohashu_group
pull-batch-size: 5 # 每次拉取的最大消息数
```
![](https://img.quanxiaoha.com/quanxiaoha/172506905919751)
由于 `rocketmq-spring-boot-starter` 本身是基于 `Spring Boot 2.x` 开发的,`2.x``3.x` 在定义 `starter` 时的格式是有区别的,存在兼容性问题。为了能够在 `Spring Boot 3.x` 中使用它,需要在 `/config` 包下,创建一个 `RocketMQConfig` 配置类,并添加 `@Import(RocketMQAutoConfiguration.class)` , 手动引入一下自动配置类:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.config;
import org.apache.rocketmq.spring.autoconfigure.RocketMQAutoConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2024/8/30 11:16
* @description: RocketMQ 配置
**/
@Configuration
@Import(RocketMQAutoConfiguration.class)
public class RocketMQConfig {
}
```
## 定义常量类
![](https://img.quanxiaoha.com/quanxiaoha/172506922022926)
接着,在 `/constant` 常量包下,创建一个 `MQConstants` 常量类,用于定义一些 MQ 相关的常量,如主题等等,代码如下:
```java
package com.quanxiaoha.xiaohashu.note.biz.constant;
/**
* @author: 犬小哈
* @date: 2024/5/21 15:04
* @version: v1.0.0
* @description: TODO
**/
public interface MQConstants {
/**
* Topic 主题:删除笔记本地缓存
*/
String TOPIC_DELETE_NOTE_LOCAL_CACHE = "DeleteNoteLocalCacheTopic";
}
```
## 发送消息
前置工作完成后,准备开始发送 MQ。编辑 `NoteServiceImpl` 业务类中的 `updateNote()` 方法,如下图所示,将删除本地缓存的代码注释掉,改为发送广播消息:
![](https://img.quanxiaoha.com/quanxiaoha/172506956241441)
代码如下:
```java
package com.quanxiaoha.xiaohashu.note.biz.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
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.note.biz.constant.MQConstants;
import com.quanxiaoha.xiaohashu.note.biz.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.note.biz.domain.dataobject.NoteDO;
import com.quanxiaoha.xiaohashu.note.biz.domain.mapper.NoteDOMapper;
import com.quanxiaoha.xiaohashu.note.biz.domain.mapper.TopicDOMapper;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteStatusEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteTypeEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteVisibleEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.FindNoteDetailReqVO;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.FindNoteDetailRspVO;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.PublishNoteReqVO;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.UpdateNoteReqVO;
import com.quanxiaoha.xiaohashu.note.biz.rpc.DistributedIdGeneratorRpcService;
import com.quanxiaoha.xiaohashu.note.biz.rpc.KeyValueRpcService;
import com.quanxiaoha.xiaohashu.note.biz.rpc.UserRpcService;
import com.quanxiaoha.xiaohashu.note.biz.service.NoteService;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记业务
**/
@Service
@Slf4j
public class NoteServiceImpl implements NoteService {
// 省略...
@Resource
private RocketMQTemplate rocketMQTemplate;
// 省略...
/**
* 笔记更新
*
* @param updateNoteReqVO
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Response<?> updateNote(UpdateNoteReqVO updateNoteReqVO) {
// 省略...
// // 删除本地缓存
// LOCAL_CACHE.invalidate(noteId);
// 同步发送广播模式 MQ将所有实例中的本地缓存都删除掉
rocketMQTemplate.syncSend(MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE, noteId);
log.info("====> MQ删除笔记本地缓存发送成功...");
// 省略...
}
// 省略...
}
```
> TIP `syncSend()` 方法表示同步发送 MQ 。
## 创建消费者
![](https://img.quanxiaoha.com/quanxiaoha/172506969672427)
MQ 消息发送出去了,还需要定义一个消费者,来消费消息。创建 `/consumer` 包,并新建 `DeleteNoteLocalCacheConsumer` 消费者,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.note.biz.consumer;
import com.quanxiaoha.xiaohashu.note.biz.constant.MQConstants;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2024/8/30 11:27
* @description: 删除本地笔记缓存
**/
@Component
@Slf4j
@RocketMQMessageListener(consumerGroup = "xiaohashu_group", // Group
topic = MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE, // 消费的主题 Topic
messageModel = MessageModel.BROADCASTING) // 广播模式
public class DeleteNoteLocalCacheConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String body) {
Long noteId = Long.valueOf(body);
log.info("## 消费者消费成功, noteId: {}", noteId);
}
}
```
> 上述类实现了 `RocketMQListener<String>` 接口,它用于监听并处理来自 Apache RocketMQ 消息队列的消息。下面是各个部分的解释:
>
> + `@RocketMQMessageListener`:这是来自于 `spring-cloud-stream-binder-rocketchat` 库的一个注解,用于配置 RocketMQ 的消息监听器。它指定了消息消费者组、主题topic以及消息模型。
> + `consumerGroup="xiaohashu_group"`定义了消息的消费者组名RocketMQ 使用消费者组来区分不同的消息订阅者。
> + `topic=MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE`:消息的主题。
> + `messageModel=MessageModel.BROADCASTING`:定义了消息模式为广播模式,在这种模式下,所有的消费者都会接收到所有发布到主题的消息。
> + `public class DeleteNoteLocalCacheConsumer implements RocketMQListener<String>`:这个类实现了 `RocketMQListener<String>` 接口,指定该消费者将接收类型为 `String` 的消息。
> + `@Override public void onMessage(String body)`:这是 `RocketMQListener` 接口中必须实现的方法,当接收到消息时,此方法会被调用。
## 测试看看
**重启三个笔记服务实例**,并调用笔记更新接口,测试一下,看看消息是否能够发送成功,以及每个实例是否能够接收的到。如下图所示,分别查看每个实例的控制台日志,若都打印了 `## 消费者消费成功` 则表示广播消息工作正常:
![](https://img.quanxiaoha.com/quanxiaoha/172507106922457)
![](https://img.quanxiaoha.com/quanxiaoha/172507112183498)
![](https://img.quanxiaoha.com/quanxiaoha/172507117526933)
## 删除本地缓存
既然广播消息工作正常,就可以继续填充消费者 `onMesssage()` 方法中的逻辑了,最终是要完成对本地缓存的删除。编辑 `NoteService` 接口,定义一个**删除本地笔记缓存**的方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.note.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.FindNoteDetailReqVO;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.FindNoteDetailRspVO;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.PublishNoteReqVO;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.UpdateNoteReqVO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记业务
**/
public interface NoteService {
// 省略...
/**
* 删除本地笔记缓存
* @param noteId
*/
void deleteNoteLocalCache(Long noteId);
}
```
在其实现类中,实现上述方法,如下:
```typescript
/**
* 删除本地笔记缓存
* @param noteId
*/
public void deleteNoteLocalCache(Long noteId) {
LOCAL_CACHE.invalidate(noteId);
}
```
这样,只需要在 `DeleteNoteLocalCacheConsumer` 消费者类中,注入 `NoteService`, 并调用 `deleteNoteLocalCache()` 方法,即可完成对目标笔记本地缓存的删除,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.consumer;
import com.quanxiaoha.xiaohashu.note.biz.constant.MQConstants;
import com.quanxiaoha.xiaohashu.note.biz.service.NoteService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @url: www.quanxiaoha.com
* @date: 2024/8/30 11:27
* @description: 删除本地笔记缓存
**/
@Component
@Slf4j
@RocketMQMessageListener(consumerGroup = "xiaohashu_group", // Group
topic = MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE, // 消费的主题 Topic
messageModel = MessageModel.BROADCASTING) // 广播模式
public class DeleteNoteLocalCacheConsumer implements RocketMQListener<String> {
@Resource
private NoteService noteService;
@Override
public void onMessage(String body) {
Long noteId = Long.valueOf(body);
log.info("## 消费者消费成功, noteId: {}", noteId);
noteService.deleteNoteLocalCache(noteId);
}
}
```
最后,小伙伴们别忘了再次重启所有笔记服务,再次测试,看看这次更新笔记成功后,是否有缓存数据不一致的问题,这里我就不再截图演示了。
## 本小节源码下载
[https://t.zsxq.com/Ew4ow](https://t.zsxq.com/Ew4ow)

View File

@ -0,0 +1,262 @@
---
title: 笔记删除接口开发 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10355.html
publishedTime: null
---
本小节中,我们将完成**笔记删除接口**的开发工作。
## 接口定义
### 接口地址
```bash
POST /note/delete
```
### 入参
```json
{
"id": "1829410872473157676" // 笔记 ID
}
```
### 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": null
}
```
## 创建接口入参 VO
![](https://img.quanxiaoha.com/quanxiaoha/172508687605238)
编辑 `xiaohashu-note-biz` 模块,在 `/model/vo` 包下,创建 `DeleteNoteReqVO` 入参 VO 实体类:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.model.vo;
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 DeleteNoteReqVO {
@NotNull(message = "笔记 ID 不能为空")
private Long id;
}
```
## 编写 service 层
接着,编辑 `NoteService` 业务接口,声明一个**删除笔记**方法,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记业务
**/
public interface NoteService {
// 省略...
/**
* 删除笔记
* @param deleteNoteReqVO
* @return
*/
Response<?> deleteNote(DeleteNoteReqVO deleteNoteReqVO);
}
```
在其实现类中,实现上述方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.note.biz.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
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.note.biz.constant.MQConstants;
import com.quanxiaoha.xiaohashu.note.biz.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.note.biz.domain.dataobject.NoteDO;
import com.quanxiaoha.xiaohashu.note.biz.domain.mapper.NoteDOMapper;
import com.quanxiaoha.xiaohashu.note.biz.domain.mapper.TopicDOMapper;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteStatusEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteTypeEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteVisibleEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
import com.quanxiaoha.xiaohashu.note.biz.rpc.DistributedIdGeneratorRpcService;
import com.quanxiaoha.xiaohashu.note.biz.rpc.KeyValueRpcService;
import com.quanxiaoha.xiaohashu.note.biz.rpc.UserRpcService;
import com.quanxiaoha.xiaohashu.note.biz.service.NoteService;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记业务
**/
@Service
@Slf4j
public class NoteServiceImpl implements NoteService {
// 省略...
/**
* 删除笔记
*
* @param deleteNoteReqVO
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Response<?> deleteNote(DeleteNoteReqVO deleteNoteReqVO) {
// 笔记 ID
Long noteId = deleteNoteReqVO.getId();
// 逻辑删除
NoteDO noteDO = NoteDO.builder()
.id(noteId)
.status(NoteStatusEnum.DELETED.getCode())
.updateTime(LocalDateTime.now())
.build();
int count = noteDOMapper.updateByPrimaryKeySelective(noteDO);
// 若影响的行数为 0则表示该笔记不存在
if (count == 0) {
throw new BizException(ResponseCodeEnum.NOTE_NOT_FOUND);
}
// 删除缓存
String noteDetailRedisKey = RedisKeyConstants.buildNoteDetailKey(noteId);
redisTemplate.delete(noteDetailRedisKey);
// 同步发送广播模式 MQ将所有实例中的本地缓存都删除掉
rocketMQTemplate.syncSend(MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE, noteId);
log.info("====> MQ删除笔记本地缓存发送成功...");
return Response.success();
}
// 省略...
}
```
> 解释一下业务逻辑:
>
> + 拿到入参中的笔记 ID 后,构建更新笔记记录需要的 `DO` 实体类,注意,这里是逻辑删除,并非是物理删除,所以只需要将该条笔记的 `status` 字段修改为 2即代表该篇笔记被删除了
> + 执行更新语句,若影响的行数为 0则表示该笔记不存在抛出业务异常
> + 若更新成功,则删除 Redis 中的缓存;
> + 然后同步发送广播模式 MQ将所有实例中的本地缓存都删除掉
## 添加 controller 接口
最后,编辑 `NoteController` 控制器,添加 `/note/delete` 接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
import com.quanxiaoha.xiaohashu.note.biz.service.NoteService;
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/4/4 13:22
* @version: v1.0.0
* @description: 笔记
**/
@RestController
@RequestMapping("/note")
@Slf4j
public class NoteController {
@Resource
private NoteService noteService;
// 省略...
@PostMapping(value = "/delete")
@ApiOperationLog(description = "删除笔记")
public Response<?> deleteNote(@Validated @RequestBody DeleteNoteReqVO deleteNoteReqVO) {
return noteService.deleteNote(deleteNoteReqVO);
}
}
```
## 自测一波
**重启笔记服务**,自测一波接口功能是否正常,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172508739115350)
可以看到,服务端响应成功,没有问题。但是,也别忘了确认一下数据库中的数据是否更新了,以及二级缓存是否被删除。
## 本小节源码下载
[https://t.zsxq.com/ISBfd](https://t.zsxq.com/ISBfd)

View File

@ -0,0 +1,327 @@
---
title: 笔记仅对自己可见接口开发 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10356.html
publishedTime: null
---
本小节中,我们将把**笔记仅对自己可见接口**开发完成,对应的原型图如下:
![img](https://img.quanxiaoha.com/quanxiaoha/172344627480751)img
## 接口定义
### 接口地址
```bash
POST /visible/onlyme
```
### 入参
```json
{
"id": "1829410872473157676" // 笔记 ID
}
```
### 出参
```json
{
"success": true, // true 表示更新成功
"message": null,
"errorCode": null,
"data": null
}
```
## 创建接口入参 VO
![](https://img.quanxiaoha.com/quanxiaoha/172517869772219)
编辑 `xiaohashu-note-biz` 模块,在 `/model/vo` 包下,创建接口入参实体类 `UpdateNoteVisibleOnlyMeReqVO` ,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.model.vo;
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 UpdateNoteVisibleOnlyMeReqVO {
@NotNull(message = "笔记 ID 不能为空")
private Long id;
}
```
## 新增 mapper 接口
为了能够将数据库中,指定的笔记权限修改为仅自己可见,还需要编辑 `NoteDOMapper` 接口,添加如下方法:
```java
package com.quanxiaoha.xiaohashu.note.biz.domain.mapper;
import com.quanxiaoha.xiaohashu.note.biz.domain.dataobject.NoteDO;
public interface NoteDOMapper {
// 省略...
int updateVisibleOnlyMe(NoteDO noteDO);
}
```
接着,在对应的 `xml`映射文件中,添加上述方法对应的 `SQL`, 代码如下:
```bash
<update id="updateVisibleOnlyMe" parameterType="com.quanxiaoha.xiaohashu.note.biz.domain.dataobject.NoteDO">
update t_note
set visible = #{visible,jdbcType=TINYINT},
update_time = #{updateTime,jdbcType=TIMESTAMP}
where id = #{id,jdbcType=BIGINT} and `status` = 1
</update>
```
> 更新条件中, `status = 1` 表示仅更新笔记状态为正常展示的记录。
## 添加异常枚举值
编辑 `ResponseCodeEnum` 全局枚举类,添加如下异常码枚举值,等会业务层判断需要用到:
```java
package com.quanxiaoha.xiaohashu.note.biz.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 {
// 省略...
// ----------- 业务异常状态码 -----------
// 省略...
NOTE_CANT_VISIBLE_ONLY_ME("NOTE-20006", "此笔记无法修改为仅自己可见"),
;
// 省略...
}
```
## 编辑 service 业务层
修改 `NoteService` 业务接口,添加一个**笔记仅对自己可见**方法:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记业务
**/
public interface NoteService {
// 省略...
/**
* 笔记仅对自己可见
* @param updateNoteVisibleOnlyMeReqVO
* @return
*/
Response<?> visibleOnlyMe(UpdateNoteVisibleOnlyMeReqVO updateNoteVisibleOnlyMeReqVO);
}
```
在其实现类中,实现上述方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.note.biz.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
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.note.biz.constant.MQConstants;
import com.quanxiaoha.xiaohashu.note.biz.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.note.biz.domain.dataobject.NoteDO;
import com.quanxiaoha.xiaohashu.note.biz.domain.mapper.NoteDOMapper;
import com.quanxiaoha.xiaohashu.note.biz.domain.mapper.TopicDOMapper;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteStatusEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteTypeEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteVisibleEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
import com.quanxiaoha.xiaohashu.note.biz.rpc.DistributedIdGeneratorRpcService;
import com.quanxiaoha.xiaohashu.note.biz.rpc.KeyValueRpcService;
import com.quanxiaoha.xiaohashu.note.biz.rpc.UserRpcService;
import com.quanxiaoha.xiaohashu.note.biz.service.NoteService;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记业务
**/
@Service
@Slf4j
public class NoteServiceImpl implements NoteService {
// 省略...
/**
* 笔记仅对自己可见
*
* @param updateNoteVisibleOnlyMeReqVO
* @return
*/
@Override
public Response<?> visibleOnlyMe(UpdateNoteVisibleOnlyMeReqVO updateNoteVisibleOnlyMeReqVO) {
// 笔记 ID
Long noteId = updateNoteVisibleOnlyMeReqVO.getId();
// 构建更新 DO 实体类
NoteDO noteDO = NoteDO.builder()
.id(noteId)
.visible(NoteVisibleEnum.PRIVATE.getCode()) // 可见性设置为仅对自己可见
.updateTime(LocalDateTime.now())
.build();
// 执行更新 SQL
int count = noteDOMapper.updateVisibleOnlyMe(noteDO);
// 若影响的行数为 0则表示该笔记无法修改为仅自己可见
if (count == 0) {
throw new BizException(ResponseCodeEnum.NOTE_CANT_VISIBLE_ONLY_ME);
}
// 删除 Redis 缓存
String noteDetailRedisKey = RedisKeyConstants.buildNoteDetailKey(noteId);
redisTemplate.delete(noteDetailRedisKey);
// 同步发送广播模式 MQ将所有实例中的本地缓存都删除掉
rocketMQTemplate.syncSend(MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE, noteId);
log.info("====> MQ删除笔记本地缓存发送成功...");
return Response.success();
}
// 省略...
}
```
> 解释一下业务逻辑:
>
> + 从入参中拿到需要修改的笔记 ID 后,构建 DO 实体类;
> + 调用 `updateVisibleOnlyMe()` 方法,传入入参 DO实体类执行更新 SQL, 将指定笔记的权限更新为仅自己可见;
> + 判断更新语句影响的行数,若为 0则表示该笔记无法修改为仅自己可见
> + 删除 Redis 缓存;
> + 发送广播 MQ, 将所有实例中的本地缓存都删除掉;
## 新增 controller 接口
`NoteController` 控制器,添加 `/visible/onlyme` 接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
import com.quanxiaoha.xiaohashu.note.biz.service.NoteService;
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/4/4 13:22
* @version: v1.0.0
* @description: 笔记
**/
@RestController
@RequestMapping("/note")
@Slf4j
public class NoteController {
// 省略...
@PostMapping(value = "/visible/onlyme")
@ApiOperationLog(description = "笔记仅对自己可见")
public Response<?> visibleOnlyMe(@Validated @RequestBody UpdateNoteVisibleOnlyMeReqVO updateNoteVisibleOnlyMeReqVO) {
return noteService.visibleOnlyMe(updateNoteVisibleOnlyMeReqVO);
}
}
```
## 自测一波
最后,**重启笔记服务**,自测一波接口功能是否好使,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172518933445356)
可以看到,服务端响应成功,再确认一下数据库中对应的笔记,其权限字段是否已经被修改为了仅自己可见,以及二级缓存是否被删除。
## 本小节源码下载
[https://t.zsxq.com/lhO0D](https://t.zsxq.com/lhO0D)

View File

@ -0,0 +1,335 @@
---
title: 笔记置顶/取消置顶接口开发 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10357.html
publishedTime: null
---
本小节中,我们将把**笔记置顶、取消置顶接口**开发完成,注意,这个接口同时支持置顶与取消置顶操作。
![](https://img.quanxiaoha.com/quanxiaoha/172344609322812)
## 接口定义
### 接口地址
```bash
POST /note/top
```
### 入参
```json
{
"id": 1829410872473157676, // 笔记 ID
"isTop": true // 是否置顶true 表示置顶操作false 表示取消置顶操作
}
```
### 出参
```json
{
"success": true, // true 表示更新成功
"message": null,
"errorCode": null,
"data": null
}
```
## 创建接口入参 VO
![](https://img.quanxiaoha.com/quanxiaoha/172524842291094)
编辑 `xiaohashu-note-biz` 模块,在 `/model/vo` 包下,新增 `TopNoteReqVO` 入参实体类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.model.vo;
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 TopNoteReqVO {
@NotNull(message = "笔记 ID 不能为空")
private Long id;
@NotNull(message = "置顶状态不能为空")
private Boolean isTop;
}
```
## 新增 mapper 更新方法
接着,编辑 `NoteDOMapper` 接口,添加一个**更新笔记置顶状态**的方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.note.biz.domain.mapper;
import com.quanxiaoha.xiaohashu.note.biz.domain.dataobject.NoteDO;
public interface NoteDOMapper {
// 省略...
int updateIsTop(NoteDO noteDO);
}
```
在接口对应的 `xml` 映射文件中,添加更新 `SQL` 语句,代码如下:
```bash
<update id="updateIsTop" parameterType="com.quanxiaoha.xiaohashu.note.biz.domain.dataobject.NoteDO">
update t_note
set is_top = #{isTop,jdbcType=BIT},
update_time = #{updateTime,jdbcType=TIMESTAMP}
where id = #{id,jdbcType=BIGINT} and creator_id = #{creatorId,jdbcType=BIGINT}
</update>
```
> **TIP** : 更新条件中,除了需要传入笔记 ID, 还需要传入笔记的发布者 ID, 用于判断当前登录的用户,是否和发布者是同一个人,只有笔记的作者才能置顶、取消置顶自己的笔记。
## 添加异常码枚举值
编辑 `ResponseCodeEnum` 全局枚举类,添加一个*您无法操作该笔记*的异常码枚举值,业务层判断需要用到:
```java
package com.quanxiaoha.xiaohashu.note.biz.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 {
// 省略...
// ----------- 业务异常状态码 -----------
// 省略...
NOTE_CANT_OPERATE("NOTE-20007", "您无法操作该笔记"),
;
// 省略...
}
```
## 编辑 service 业务层
`NoteService` 笔记业务层接口中,声明一个**笔记置顶 / 取消置顶**方法,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记业务
**/
public interface NoteService {
// 省略...
/**
* 笔记置顶 / 取消置顶
* @param topNoteReqVO
* @return
*/
Response<?> topNote(TopNoteReqVO topNoteReqVO);
}
```
在其实现类中,实现上述方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.note.biz.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
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.note.biz.constant.MQConstants;
import com.quanxiaoha.xiaohashu.note.biz.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.note.biz.domain.dataobject.NoteDO;
import com.quanxiaoha.xiaohashu.note.biz.domain.mapper.NoteDOMapper;
import com.quanxiaoha.xiaohashu.note.biz.domain.mapper.TopicDOMapper;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteStatusEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteTypeEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.NoteVisibleEnum;
import com.quanxiaoha.xiaohashu.note.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
import com.quanxiaoha.xiaohashu.note.biz.rpc.DistributedIdGeneratorRpcService;
import com.quanxiaoha.xiaohashu.note.biz.rpc.KeyValueRpcService;
import com.quanxiaoha.xiaohashu.note.biz.rpc.UserRpcService;
import com.quanxiaoha.xiaohashu.note.biz.service.NoteService;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 笔记业务
**/
@Service
@Slf4j
public class NoteServiceImpl implements NoteService {
// 省略...
/**
* 笔记置顶 / 取消置顶
*
* @param topNoteReqVO
* @return
*/
@Override
public Response<?> topNote(TopNoteReqVO topNoteReqVO) {
// 笔记 ID
Long noteId = topNoteReqVO.getId();
// 是否置顶
Boolean isTop = topNoteReqVO.getIsTop();
// 当前登录用户 ID
Long currUserId = LoginUserContextHolder.getUserId();
// 构建置顶/取消置顶 DO 实体类
NoteDO noteDO = NoteDO.builder()
.id(noteId)
.isTop(isTop)
.updateTime(LocalDateTime.now())
.creatorId(currUserId) // 只有笔记所有者,才能置顶/取消置顶笔记
.build();
int count = noteDOMapper.updateIsTop(noteDO);
if (count == 0) {
throw new BizException(ResponseCodeEnum.NOTE_CANT_OPERATE);
}
// 删除 Redis 缓存
String noteDetailRedisKey = RedisKeyConstants.buildNoteDetailKey(noteId);
redisTemplate.delete(noteDetailRedisKey);
// 同步发送广播模式 MQ将所有实例中的本地缓存都删除掉
rocketMQTemplate.syncSend(MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE, noteId);
log.info("====> MQ删除笔记本地缓存发送成功...");
return Response.success();
}
// 省略...
}
```
> 解释一波业务逻辑:
>
> + 获取入参传过来的笔记 ID, 操作标识,以及从上下文中获取当前登录用户的 ID;
> + 构建置顶/取消置顶 DO 实体类;
> + 执行更新语句;
> + 判断影响的行数,若为 0 说明当前笔记不允许被操作;
> + 否则,删除 Redis 缓存,并同步发送广播模式 MQ将本地缓存也删除
## 新增 controller 接口
最后,编辑 `NoteController` 控制器,添加 `/note/top` 接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.note.biz.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.note.biz.model.vo.*;
import com.quanxiaoha.xiaohashu.note.biz.service.NoteService;
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/4/4 13:22
* @version: v1.0.0
* @description: 笔记
**/
@RestController
@RequestMapping("/note")
@Slf4j
public class NoteController {
// 省略...
@PostMapping(value = "/top")
@ApiOperationLog(description = "置顶/取消置顶笔记")
public Response<?> topNote(@Validated @RequestBody TopNoteReqVO topNoteReqVO) {
return noteService.topNote(topNoteReqVO);
}
}
```
## 自测一波
**重启笔记服务**,自测一下接口功能是否正常,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172526196869819)
服务端响应成功,同时,别忘了确认一下数据库中的笔记数据是否更新成功,以及二级缓存是否被删除。
## 本小节源码下载
[https://t.zsxq.com/sHf7b](https://t.zsxq.com/sHf7b)

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,234 @@
![](https://img.quanxiaoha.com/quanxiaoha/171818026081674)
本章节开始,正式进入到 Gateway 网关服务的搭建工作。
## 什么是 Gateway 网关?
> Gateway 网关API Gateway是一个服务器充当所有客户端请求的单一入口点。它接收来自客户端的所有请求处理这些请求然后将它们转发给下游适当的微服务。
Gateway 网关通常具有以下功能:
+ **路由转发**:将路由请求转发到适当的微服务实例。
+ **负载均衡**:在多个服务实例之间分配请求,以实现负载均衡。
+ **认证和授权**:对请求进行身份验证和授权,以确保只有授权的客户端才能访问服务。
> PS : 咱们这个项目中,用户认证的工作,是由具体的认证服务来处理的。
+ **日志和监控**:记录请求和响应的日志,并监控流量和性能指标。
+ **限流和熔断**:控制流量,以防止服务过载,并提供熔断机制来应对服务故障。
## 新建网关服务
打开 IDEA, 在项目上*右键 | New | Module* 新建一个网关子模块:
![](https://img.quanxiaoha.com/quanxiaoha/171817725272784)
填写网关服务的相关配置项,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171817734151974)
> 解释一下标注的地方:
>
> + ①:选择 `Maven Archetype` 来创建一个 `Maven` 项目;
> + ②:项目名称;
> + ③IDEA 需要知道 Maven Archetype Catalog 的位置,以便从中获取可用的 Archetype 列表。这个 Catalog 文件通常包含了 Maven 官方仓库或其他远程仓库中可用的 Archetype 信息。选择 `Internal` 即可。
> + ④:通过使用 Archetype你可以基于已有的项目模板创建一个新项目。这里选择 `maven-archetype-quickstart`
> + ⑤:填写 Group 组织名称,这里填写 `com.quanxiaoha.xiaohashu.gateway`
点击 *Create* 按钮,开始创建网关服务,等待控制台提示 `BUILD SUCCESS` 后,则表示子模块创建完成,项目结构大致如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171817751120819)
这个时候,查看项目最外层的 `pom.xml` 文件,可以看到 `<modules>` 节点下,网关服务模块已经被添加进去了:
![](https://img.quanxiaoha.com/quanxiaoha/171817780061138)
## 新建 /resources 资源目录
通过 `quickstart` 模板生成的项目,还缺失了一些东西,如 `/resources` 资源目录。在网关服务中的 `/main` 包上*右键 | New | Directory* , 新建一个文件夹:
![](https://img.quanxiaoha.com/quanxiaoha/171817762694232)
文件夹名填写 `resources`
![](https://img.quanxiaoha.com/quanxiaoha/171817766138113)
创建完成后,结构如下:
![](https://img.quanxiaoha.com/quanxiaoha/171817769533990)
## 修改 pom 文件
接着,编辑网关服务的 `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>
<artifactId>xiaohashu-gateway</artifactId>
<name>${project.artifactId}</name>
<description>网关服务(负责路由转发、接口鉴权等功能)</description>
<dependencies>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
```
> + 指定父项目;
> + 项目名称、描述信息;
> + 以及添加 `spring-boot-maven-plugin` 项目构建插件;
## 添加依赖
添加网关服务需要的相关依赖:
```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>
<!-- 网关 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- 负载均衡 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
```
## 创建配置文件
![](https://img.quanxiaoha.com/quanxiaoha/171817803715336)
依赖添加完成后,在 `/resources` 资源目录下,分别创建上图标注的两个配置文件。
### bootstrap.yml
```yaml
spring:
application:
name: xiaohashu-gateway # 应用名称
profiles:
active: dev
cloud:
nacos:
discovery:
enabled: true # 启用服务发现
group: DEFAULT_GROUP # 所属组
namespace: xiaohashu # 命名空间
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
```
> 填写应用名称,同时将网关服务注册到 Nacos 上。
### application.yml
```yaml
server:
port: 8000 # 指定启动端口
spring:
cloud:
gateway:
routes:
- id: auth
uri: lb://xiaohashu-auth
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
```
> 解释一下:
>
> + `spring.cloud.gateway.routes` 用于定义网关的路由规则。
>
> + `- id: auth`
>
> `id` 用于唯一标识这个路由。这里将路由的 id 设置为 `auth`,表示这个路由的配置是针对认证服务的。
>
> + `uri: lb://xiaohashu-auth`
>
> `uri` 定义了请求将被路由到的目标服务地址。这里使用 `lb://xiaohashu-auth`,其中 `lb` 代表的是**负载均衡**Load Balancer`xiaohashu-auth` 是认证服务的名称。Spring Cloud Gateway 会使用注册中心(如 Nacos )来解析并负载均衡到具体的服务实例。
>
> + `predicates`: 用于定义匹配规则,决定哪些请求会被路由到这个目标服务。每个 `predicate` 都是一个条件表达式,可以用来匹配请求路径、请求方法、请求头等信息。
>
> + `- Path=/auth/**` 这个 `Path` 断言用于匹配请求路径。这里 `/auth/**` 表示所有以 `/auth/` 开头的路径都会匹配这个路由规则。例如,`/auth/login``/auth/register` 都会被这个路由处理。`/**` 是一个通配符,表示任意的后续路径。
> + `filters` : 定义路由过滤器。
>
> + `- StripPrefix=1`: 表示去掉路径中的第一个部分。例如,当请求路径为 `/auth/verification/code/send` 时,去掉第一个前缀部分后,实际传递给 `xiaohashu-auth` 服务的路径将变成 `/verification/code/send`
## 创建启动类
![](https://img.quanxiaoha.com/quanxiaoha/171817817107811)
路由转发相关配置完成后,将网关服务中,自动生成的 `App.java` 文件删除掉。手动创建一个 Spring Boot 启动类 `XiaohashuGatewayApplicaiton` , 代码如下:
```typescript
package com.quanxiaoha.xiaohashu.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class XiaohashuGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(XiaohashuGatewayApplication.class, args);
}
}
```
## 自测一波
以上工作都完成后,启动网关服务项目。启动成功后,进入到 Nacos 管理后台,若看到服务列表中,多出了 `xiaohashu-gateway` 服务,说明网关服务已经成功注册到了 Nacos 上:
![](https://img.quanxiaoha.com/quanxiaoha/171817828892055)
接下来,我们再来测试一下路由转发是否好使。打开 Apipost 接口测试工具,再次测试*获取验证码接口*,不过,这次的请求是网关,由网关来转发到具体的服务上,这里是认证服务:
![](https://img.quanxiaoha.com/quanxiaoha/171817837085124)
> **注意**:请求地址为 `localhost:8000/auth/verification/code/send` , 端口为网关的 `8000` 端口,接口地址加上 `/auth` 前缀,网关通过此前缀标识,转发到具体服务上。
点击*发送*按钮,可以看 `success``true`,响参是成功的,说明网关转发路由成功了。再观察一下 `xiaohashu-auth` 认证服务的控制台日志,确认一下是否真的接受到了请求:
![](https://img.quanxiaoha.com/quanxiaoha/171817844054243)
如上图所示接口切面日志打印出来了OK ,网关服务工作良好。
## 本小节源码下载
[https://t.zsxq.com/wOmud](https://t.zsxq.com/wOmud)

View File

@ -0,0 +1,158 @@
![](https://img.quanxiaoha.com/quanxiaoha/171921661769437)
在[上小节](https://www.quanxiaoha.com/column/10300.html) 的末尾,小哈给大家留了个小思考:`ThreadLocal` 应对上下文数据传递的场景,真的就一劳永逸的吗?**其实不是**,再来看一下 `ThreadLocal` 的定义:
> **什么是 `ThreadLocal` ?**
>
> `ThreadLocal` 是 Java 中用于创建线程局部变量的工具,每个线程都有自己的独立变量副本,不会相互干扰。
## ThreadLocal 存在的问题
如果说,我们写业务代码的时候,有些逻辑是需要在异步线程中去执行,如下图所示,异步线程中,再通过 `ThreadLocal` 去获取上下文数据,就失效了:
![](https://img.quanxiaoha.com/quanxiaoha/171913409654955)
小伙伴们,可以编辑 `UserServiceImpl` 实现类,修改代码如下,亲测一波看看:
```kotlin
@Resource(name = "taskExecutor")
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
// 省略...
/**
* 退出登录
*
* @return
*/
@Override
public Response<?> logout() {
Long userId = LoginUserContextHolder.getUserId();
log.info("==> 用户退出登录, userId: {}", userId);
threadPoolTaskExecutor.submit(() -> {
Long userId2 = LoginUserContextHolder.getUserId();
log.info("==> 异步线程中获取 userId: {}", userId2);
});
// 退出登录 (指定用户 ID)
StpUtil.logout(userId);
return Response.success();
}
```
重启认证服务,调用*登出接口*,看看效果,如下图所示,异步线程中获取用户 ID 显示为 `null` :
![](https://img.quanxiaoha.com/quanxiaoha/171912945142352)
## InheritableThreadLocal 好使不?
有的小伙伴会说,用 `InheritableThreadLocal` 应该就行了吧!
> **什么是 `InheritableThreadLocal` ?**
>
> `InheritableThreadLocal` 是 Java 提供的另一个特殊的类,它是 `ThreadLocal` 的子类。与 `ThreadLocal` 不同,`InheritableThreadLocal` 允许线程将其父线程中的值传递给其子线程。这对于一些需要在父子线程之间共享数据的场景非常有用。
同样的,我们来测试一下,写个 `main` 方法,代码如下:
```csharp
public static void main(String[] args) {
// 初始化 InheritableThreadLocal
ThreadLocal<Long> threadLocal = new InheritableThreadLocal<>();
// 假设用户 ID 为 1
Long userId = 1L;
// 设置用户 ID 到 InheritableThreadLocal 中
threadLocal.set(userId);
System.out.println("主线程打印用户 ID: " + threadLocal.get());
// 异步线程
new Thread(() -> {
System.out.println("异步线程打印用户 ID: " + threadLocal.get());
}).start();
}
```
执行该方法,观察控制台输出,可以看到异步线程中成功打印了用户 ID
![](https://img.quanxiaoha.com/quanxiaoha/171913615562589)
> 哎,既然如此,把 `LoginUserContextHolder` 中的 `ThreadLocal` 改成 `InheritableThreadLocal` , 问题不就解决了吗!其实不然,`InheritableThreadLocal` 同样有它的弊端,如果我们在使用线程池的情况下,它就不好使了。
## 阿里 TransmittableThreadLocal
> `TransmittableThreadLocal` 是阿里巴巴开源的一个库,专门为了解决在使用线程池或异步执行框架时,`InheritableThreadLocal` 不能传递父子线程上下文的问题。`TransmittableThreadLocal` 能够将父线程中的上下文在子线程或线程池中执行时也能够保持一致。
### 添加依赖
编辑项目最外层 `pom.xml` 文件,声明 `TransmittableThreadLocal` 的依赖,以及版本号,代码如下:
```php-template
<properties>
// 省略...
<transmittable-thread-local.version>2.14.2</transmittable-thread-local.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>${transmittable-thread-local.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
接着,编辑认证服务的 `pom.xml` 文件,引入该依赖:
```php-template
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
</dependency>
```
重新刷新一下 Maven 依赖,将包下载到本地 Maven 仓库中。
### 修改 LoginUserContextHolder
然后,编辑 `LoginUserContextHolder` 上下文工具类,将 `ThreadLocal` 修改为 `TransmittableThreadLocal` , 如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171913281720137)
代码如下:
```typescript
// 初始化一个 ThreadLocal 变量
private static final ThreadLocal<Map<String, Object>> LOGIN_USER_CONTEXT_THREAD_LOCAL
= TransmittableThreadLocal.withInitial(HashMap::new);
```
### 测试一波
重启认证服务,再次请求登出接口,观察控制台日志,看看这次异步线程中能否正确获取到用户 ID, 如下图所示OK, 问题解决
![](https://img.quanxiaoha.com/quanxiaoha/171913422751160)
## 小作业
最后,给大家留个小作业,由于后续其他服务也需要用到上下文工具类,完全可以将它抽取成一个 `Starter` 组件,到时候其他服务只需引入 `Starter` 组件,即可拥有此能力。
关于如何自定义 `Starter` , 小伙伴们可参考[《4.3 小节》](https://www.quanxiaoha.com/column/10260.html) ,自己动手尝试做一下,遇到问题的,也可以下载本小节的源码参考,源码已经抽取完毕,大体结构如下:
![](https://img.quanxiaoha.com/quanxiaoha/171921641331692)
## 本小节源码下载
[https://t.zsxq.com/iTYHA](https://t.zsxq.com/iTYHA)

View File

@ -0,0 +1,224 @@
![](https://img.quanxiaoha.com/quanxiaoha/171862977606123)
本小节中,我们将为 Gateway 网关服务添加全局异常处理,统一一波接口出参格式。
## 问题复现
首先,我们来看看目前网关服务存在的问题,编辑 `SaTokenConfigure` 配置类,对登出接口添加权限校验,校验登录的用户是否拥有 `admin` 角色:
![](https://img.quanxiaoha.com/quanxiaoha/171862445386135)
代码如下:
```rust
SaRouter.match("/auth/user/logout", r -> StpUtil.checkRole("admin"));
```
重启网关服务,请求一波登出接口,由于目前已注册的用户,只有一个**普通用户**角色,于是,网关会提示你:*无此角色admin*:
![](https://img.quanxiaoha.com/quanxiaoha/171862461031130)
\*发现问题了没有?\*返参的格式是下面这样的:
```json
{
"code": 500,
"msg": "无此角色admin",
"data": null
}
```
和我们已经定好的接口出参格式,是不一致的:
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": null
}
```
接下来,我们就将为网关服务添加全局异常捕获器,统一一下 `JSON` 出参格式。
## 删除 SaToken 默认的异常处理
添加全局异常捕获之前,首先编辑 `SaTokenConfigure` 配置类,将 `.setError()` 方法**删除**掉,防止等会自定义的全局异常处理失效:
![](https://img.quanxiaoha.com/quanxiaoha/171862612221475)
代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.reactor.filter.SaReactorFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 鉴权方法:每次访问进入
.setAuth(obj -> {
// 登录校验
SaRouter.match("/**") // 拦截所有路由
.notMatch("/auth/user/login") // 排除登录接口
.notMatch("/auth/verification/code/send") // 排除验证码发送接口
.check(r -> StpUtil.checkLogin()) // 校验是否登录
;
// 权限认证 -- 不同模块, 校验不同权限
SaRouter.match("/auth/user/logout", r -> StpUtil.checkRole("admin"));
// 更多匹配 ... */
})
;
}
}
```
## 添加错误响应枚举类
![](https://img.quanxiaoha.com/quanxiaoha/171862627472198)
接着,在网关服务中新建 `/enums` 枚举包,并创建 `ResponseCodeEnum` 异常枚举类,代码如下:
```java
package com.quanxiaoha.xiaohashu.gateway.enums;
import com.quanxiaoha.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("500", "系统繁忙,请稍后再试"),
UNAUTHORIZED("401", "权限不足"),
// ----------- 业务异常状态码 -----------
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
> 在异常响应枚举类中,先定义两个现阶段需要用到的错误码枚举值:
>
> + `SYSTEM_ERROR` :系统繁忙错误;
> + `UNAUTHORIZED` : 权限不足,若 SaToken 权限校验失败,则统一返回此错误码;
## 网关全局异常捕获
![](https://img.quanxiaoha.com/quanxiaoha/171862637199167)
接着,在网关服务中创建一个 `/exception` 异常包,用于统一放置异常相关的代码。然后,创建 `GlobalExceptionHandler` 全局异常处理器,代码如下:
```java
package com.quanxiaoha.xiaohashu.gateway.exception;
import cn.dev33.satoken.exception.SaTokenException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.gateway.enums.ResponseCodeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* @author: 犬小哈
* @date: 2024/4/5 19:18
* @version: v1.0.0
* @description: 全局异常处理
**/
@Component
@Slf4j
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
@Resource
private ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
// 获取响应对象
ServerHttpResponse response = exchange.getResponse();
log.error("==> 全局异常捕获: ", ex);
// 响参
Response<?> result;
// 根据捕获的异常类型,设置不同的响应状态码和响应消息
if (ex instanceof SaTokenException) { // Sa-Token 异常
// 权限认证失败时,设置 401 状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 构建响应结果
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(), ResponseCodeEnum.UNAUTHORIZED.getErrorMessage());
} else { // 其他异常,则统一提示 “系统繁忙” 错误
result = Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
// 设置响应头的内容类型为 application/json;charset=UTF-8表示响应体为 JSON 格式
response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);
// 设置 body 响应体
return response.writeWith(Mono.fromSupplier(() -> { // 使用 Mono.fromSupplier 创建响应体
DataBufferFactory bufferFactory = response.bufferFactory();
try {
// 使用 ObjectMapper 将 result 对象转换为 JSON 字节数组
return bufferFactory.wrap(objectMapper.writeValueAsBytes(result));
} catch (Exception e) {
// 如果转换过程中出现异常,则返回空字节数组
return bufferFactory.wrap(new byte[0]);
}
}));
}
}
```
> 解释一波异常处理器的代码:
>
> + 和 Spring Boot 中使用 `@ControllerAdvice` 注解,来定义全局异常捕获器不同。在网关中,你需要创建 `ErrorWebExceptionHandler` 的实现类,并将其注入到 Spring 容器中;
> + 在 `handle()` 异常处理方法中,对方法的入参 `ex` 异常进行类型判断,从而设置不同的响应状态码和响应消息:
> + 如果异常类型为 `SaTokenException`,则设置 401 状态码,并构建响应结果;
> + 其他异常,则统一提示 “系统繁忙” 错误;
> + 设置响应头的内容类型为 `application/json;charset=UTF-8`,表示响应体为 JSON 格式;
> + 设置 `body` 响应体,并返回响参;
## 自测一波
全局异常捕获器添加完毕后,**重启网关服务**,再次测试一波登出接口,看看出参效果:
![](https://img.quanxiaoha.com/quanxiaoha/171862648457721)
如上图所示,响参的 `JSON` 格式已经被统一了,错误提示信息为**权限不足**,并且状态码为 *401*
## 本小节源码下载
[https://t.zsxq.com/wMlWj](https://t.zsxq.com/wMlWj)

View File

@ -0,0 +1,194 @@
在[上小节](https://www.quanxiaoha.com/column/10302.html) 中,我们已经完成了用户密码修改功能的开发。本小节中,回首掏,把登录接口中,还未完成的账号/密码登录逻辑补充完整。
![](https://img.quanxiaoha.com/quanxiaoha/171931220084239)
## 添加错误状态码
首先,编辑认证服务中的 `ResponseCodeEnum` 全局枚举类,定义 3 个枚举值,如下,等会业务层需要做一些判断提示:
+ *登录类型错误*;
+ *该用户不存在*
+ *手机号或密码错误*;
代码如下:
```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 {
// 省略...
LOGIN_TYPE_ERROR("AUTH-20002", "登录类型错误"),
USER_NOT_FOUND("AUTH-20003", "该用户不存在"),
PHONE_OR_PASSWORD_ERROR("AUTH-20004", "手机号或密码错误"),
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
## 完善账号/密码登录逻辑
```java
package com.quanxiaoha.xiaohashu.auth.service.impl;
import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.ttl.TransmittableThreadLocal;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
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.RoleDOMapper;
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.UpdatePasswordReqVO;
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.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: TODO
**/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
@Resource
private PasswordEncoder passwordEncoder;
/**
* 登录与注册
*
* @param userLoginReqVO
* @return
*/
@Override
public Response<String> loginAndRegister(UserLoginReqVO userLoginReqVO) {
String phone = userLoginReqVO.getPhone();
Integer type = userLoginReqVO.getType();
LoginTypeEnum loginTypeEnum = LoginTypeEnum.valueOf(type);
// 登录类型错误
if (Objects.isNull(loginTypeEnum)) {
throw new BizException(ResponseCodeEnum.LOGIN_TYPE_ERROR);
}
Long userId = null;
// 判断登录类型
switch (loginTypeEnum) {
case VERIFICATION_CODE: // 验证码登录
// 省略...
break;
case PASSWORD: // 密码登录
String password = userLoginReqVO.getPassword();
// 根据手机号查询
UserDO userDO1 = userDOMapper.selectByPhone(phone);
// 判断该手机号是否注册
if (Objects.isNull(userDO1)) {
throw new BizException(ResponseCodeEnum.USER_NOT_FOUND);
}
// 拿到密文密码
String encodePassword = userDO1.getPassword();
// 匹配密码是否一致
boolean isPasswordCorrect = passwordEncoder.matches(password, encodePassword);
// 如果不正确,则抛出业务异常,提示用户名或者密码不正确
if (!isPasswordCorrect) {
throw new BizException(ResponseCodeEnum.PHONE_OR_PASSWORD_ERROR);
}
userId = userDO1.getId();
break;
default:
break;
}
// 省略...
// 返回 Token 令牌
return Response.success(tokenInfo.tokenValue);
}
// 省略...
}
```
> 解释一波代码逻辑:
>
> + 判断登录类型是否正确,若不正确,则抛出业务异常;
> + 账号/密码登录:
> + 通过手机号,也就是账号来查询数据库;
> + 若记录为空,说明登录的用户不存在,抛出对应的业务异常;
> + 密码比对;
> + 若不正确,则抛出业务异常,提示用户名或者密码不正确;
## 自测一波
逻辑补充完毕后,重启认证服务。自测一波*登录接口*,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171931239166229)
入参如下:
```json
{
"phone": "18019939111", // 手机号
"password": "123456", // 密码
"type": 2 // 2 代表账号密码登录
}
```
可以看到,通过账号/密码的方式登录,同样能够正确拿到登录 `Token` 令牌。
## 本小节源码下载
[https://t.zsxq.com/xAekD](https://t.zsxq.com/xAekD)

View File

@ -0,0 +1,337 @@
![](https://img.quanxiaoha.com/quanxiaoha/171827188280723)
[上小节](https://www.quanxiaoha.com/column/10292.html) 中,我们已经将网关服务初步搭建好了,并且支持上了**路由转发**。但是,光有转发肯定还是不够的,转发之前应该还需要进行**接口鉴权**,比如说,前端请求*用户退出登录接口*,在转发路由之前,应该先校验该用户是否已经登录过了,如果没有登录,则不应进行转发操作,而是提示用户先去登录。
## 添加用户登出接口
![](https://img.quanxiaoha.com/quanxiaoha/171826472187029)
为了等会方便演示,编辑 `xiaohashu-auth` 认证服务,添加一个 `/user/logout` 登出接口,逻辑先不写,代码如下:
```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;
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Resource
private UserService userService;
// 省略...
@PostMapping("/logout")
@ApiOperationLog(description = "账号登出")
public Response<?> logout() {
// todo 账号退出登录逻辑待实现
return Response.success();
}
}
```
接口添加完毕后,重启认证服务。并通过 Apipost 工具,通过网关来请求该接口,看看效果:
![](https://img.quanxiaoha.com/quanxiaoha/171826536030030)
可以看到,直接就转发到子服务上了,网关层目前没有任何鉴权操作。
## 添加 SaToken 依赖
接下来,我们就将为网关添加 SaToken 相关依赖,以实现接口鉴权。关于 Gateway 网关集成 SaToken, 详细文档可访问:[https://sa-token.cc/doc.html#/micro/gateway-auth](https://sa-token.cc/doc.html#/micro/gateway-auth) 小哈这里就直接上手演示,如何在小哈书项目集成它。
编辑项目最外层 `pom.xml` 文件,除了之前声明的 SaToken 相关依赖外,网关如果想整合 SaToken , 还需额外添加如下依赖:
```javascript
// 省略...
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
// 省略...
```
接着,编辑 `xiaohashu-gateway` 网关服务的 `pom.xml` 文件,添加依赖如下:
```php-template
<!-- Sa-Token 权限认证在线文档https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot3-starter</artifactId>
</dependency>
<!-- 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>
```
> **TIP** : 依赖添加完成后,别忘了重新 *Reload* 一下 Maven 依赖,将包下载到本地仓库中。
## 配置 SaToken
![](https://img.quanxiaoha.com/quanxiaoha/171826653005355)
然后,将 `xiaohashu-auth` 服务中,已经配置好的 SaToken 相关配置项,复制一份到网关服务的 `applicaiton.yml` 文件中,内容如下,要注意层级格式哟,别配置错了:
```yaml
spring:
cloud:
// 省略...
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 # 连接池中的最大空闲连接
############## 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
```
## 实现鉴权接口
![](https://img.quanxiaoha.com/quanxiaoha/171826946146494)
配置完成后,在网关服务中创建一个 `/auth` 包,用于统一放置鉴权相关的代码。然后,在包内创建一个 SaToken 自定义权限验证接口扩展类 `StpInterfaceImpl`, 代码如下:
```typescript
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.stp.StpInterface;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* @author: 犬小哈
* @date: 2024/4/5 18:04
* @version: v1.0.0
* @description: 自定义权限验证接口扩展
**/
/**
* 自定义权限验证接口扩展
*/
@Component
public class StpInterfaceImpl implements StpInterface {
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 返回此 loginId 拥有的权限列表
// todo 从 redis 获取
return Collections.emptyList();
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 返回此 loginId 拥有的角色列表
// todo 从 redis 获取
return Collections.emptyList();
}
}
```
> 上面这个自定义权限验证接口扩展类中,有两个方法:
>
> + `getPermissionList()` : 根据 `loginId` 获取该用户的权限列表,这里暂时返回一个空集合,后续需要从 Redis 中查询;
>
> *入参中的 loginId 是什么?*
>
> ![](https://img.quanxiaoha.com/quanxiaoha/171826981685761)
>
> `loginId` 即登录接口中,`StpUtil.login()` 方法的入参,我们传入的是用户 ID, 那么,这里的 `loginId` 对应的即是用户 ID 。
>
> + `getRoleList()` : 根据 `loginId` 获取该用户的角色列表,这里暂时返回一个空集合;
>
## 注册全局过滤器
接着,在 `/auth` 包下,再创建一个配置类 `SaTokenConfigure`,用于将全局过滤器注入到 Spring 容器中。下面是摘自官方的示例代码:
```rust
/**
* [Sa-Token 权限认证] 配置类
* @author click33
*/
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 开放地址
.addExclude("/favicon.ico")
// 鉴权方法:每次访问进入
.setAuth(obj -> {
// 登录校验 -- 拦截所有路由,并排除/user/doLogin 用于开放登录
SaRouter.match("/**", "/user/doLogin", r -> StpUtil.checkLogin());
// 权限认证 -- 不同模块, 校验不同权限
SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
// 更多匹配 ... */
})
// 异常处理方法每次setAuth函数出现异常时进入
.setError(e -> {
return SaResult.error(e.getMessage());
})
;
}
}
```
> 官方示例中,注释写的很详细,包含需要拦截的地址,以及登录校验,权限校验部分,就不具体解释了,还是比较好理解的。
结合小哈书项目的现状,对示例代码稍作改动,最终代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.reactor.filter.SaReactorFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: 犬小哈
* @date: 2024/6/13 14:48
* @version: v1.0.0
* @description: [Sa-Token 权限认证] 配置类
**/
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 鉴权方法:每次访问进入
.setAuth(obj -> {
// 登录校验
SaRouter.match("/**") // 拦截所有路由
.notMatch("/auth/user/login") // 排除登录接口
.notMatch("/auth/verification/code/send") // 排除验证码发送接口
.check(r -> StpUtil.checkLogin()) // 校验是否登录
;
// 权限认证 -- 不同模块, 校验不同权限
// SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
// SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
// SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
// SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
// 更多匹配 ... */
})
// 异常处理方法每次setAuth函数出现异常时进入
.setError(e -> {
return SaResult.error(e.getMessage());
})
;
}
}
```
> 修改的地方如下:
>
> + **登录校验**:对所有接口进行拦截,通过 `StpUtil.checkLogin()` 方法校验是否已经登录,注意,这里仅排除掉*登录接口*、*验证码发送接口*,这两个接口无需登录就能被请求;
> + **权限认证**:权限认证部分,暂时全部注释掉,后续写具体接口时,再回过头来修改这块,比如笔记发布接口,需要校验是否拥有笔记发布的权限。
## 自测一波
编码工作完成后,**重启网关服务**。再次请求*用户登出*接口,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171826691094792)
哎,怎么返参 `success``true` , 再查看一下认证服务的控制台日志,会发现接口还是被请求到了。*不是已经在网关层配置了登录校验了吗,啥子情况?*
莫慌!在 Apipost 工具中,看一下接口的具体请求头信息,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171826700676525)
可以看到,请求头中携带了 `token` 令牌。并且这个后缀为 `88513` 的令牌,在 Redis 中是存在的,如下图所示,由于咱们设置的令牌过期时间较长,该令牌还没失效,导致网关认为此次请求的用户,已经登录过了,才会将该请求转发到认证服务上:
![](https://img.quanxiaoha.com/quanxiaoha/171826705192649)
为了避免这个问题,可以点击 Apipost 顶部的 *Cookie 管理器* 将*全局 Cookie 关闭*掉:
![](https://img.quanxiaoha.com/quanxiaoha/171826711636380)
再次测试*登录接口*,注意,现在请求头中还未设置 `token` 令牌,可以看到这次返回的是 SaToken 默认的提示信息:*未能读取到有效 token* ,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171826719033730)
再来测试一下携带上令牌的请求效果。依次请求*获取验证码接口 | 登录接口* 拿到最新的 `token` 令牌,并设置到请求头中,令牌的名称为 `satoken` ,值为刚刚获取的令牌值,点击*发送*
![](https://img.quanxiaoha.com/quanxiaoha/171826725014410)
OK , 携带上令牌再请求*登出接口*,可以看到返参提示成功,再观察一下认证服务的控制台日志,确认一下,看看有没有打印请求切面日志。不出意外是正常打印日志了,说明网关服务校验登录功能正常。
## 本小节源码下载
[https://t.zsxq.com/DDTBQ](https://t.zsxq.com/DDTBQ)

View File

@ -0,0 +1,81 @@
![](https://img.quanxiaoha.com/quanxiaoha/171835699894957)
本小节中,我们来自定义一波 SaToken 权限框架的 Token 生成风格以及请求格式。
## 自定义 Token 风格
在 SaToken 中Token 风格可以通过 `sa-token.token-style` 配置项来定制,默认的生成策略是 `uuid` 风格,值类似于 `623368f0-ae5e-4475-a53f-93e4225f16ae` 如下图所示, 看上去比较短,有木有~
![](https://img.quanxiaoha.com/quanxiaoha/171833647943066)
> 其实SaToken 内置支持的风格有多种,大致如下:
>
> ```cpp
> / 1. token-style=uuid —— uuid风格 (默认风格)
> "623368f0-ae5e-4475-a53f-93e4225f16ae"
>
> // 2. token-style=simple-uuid —— 同上uuid风格, 只不过去掉了中划线
> "6fd4221395024b5f87edd34bc3258ee8"
>
> // 3. token-style=random-32 —— 随机32位字符串
> "qEjyPsEA1Bkc9dr8YP6okFr5umCZNR6W"
>
> // 4. token-style=random-64 —— 随机64位字符串
> "v4ueNLEpPwMtmOPMBtOOeIQsvP8z9gkMgIVibTUVjkrNrlfra5CGwQkViDjO8jcc"
>
> // 5. token-style=random-128 —— 随机128位字符串
> "nojYPmcEtrFEaN0Otpssa8I8jpk8FO53UcMZkCP9qyoHaDbKS6dxoRPky9c6QlftQ0pdzxRGXsKZmUSrPeZBOD6kJFfmfgiRyUmYWcj4WU4SSP2ilakWN1HYnIuX0Olj"
>
> // 6. token-style=tik —— tik风格
> "gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__"
> ```
这里我们选择长一点的,即 `random-128` 风格,修改网关服务与认证服务的 `applicaiton.yml` 配置文件,修改 `token-style``random-128`, 如下:
![](https://img.quanxiaoha.com/quanxiaoha/171833668914056)
修改完成后,重启认证服务与网关服务,通过一个新的手机号来获取登录验证码,并调用登录接口拿到新的令牌,因为老手机号的登录 `Token` 存放在 Redis 中,大概率还没有过期,再次请求还会拿到之前老的令牌值:
![](https://img.quanxiaoha.com/quanxiaoha/171835496116030)
如上图所示,现在生成的 `Token` 风格就是 `random-128` 类型了。
## 自定义 Token 请求风格
在[星球上个项目](https://www.quanxiaoha.com/column/10000.html) 中,请求需要认证鉴权的接口,需要在请求头中携带上 `Token` 令牌,如下图标注所示:
![](https://img.quanxiaoha.com/quanxiaoha/171835642685330)
格式如下,**这种格式是比较通用、规范的 Token 请求格式**
```ini
Authorization = Bearer + 空格 + 令牌值
```
那么,如何将 SaToken 也定制为上述格式呢?修改认证服务与网关服务的 `application.yml` 文件,修改内容如下:
![](https://img.quanxiaoha.com/quanxiaoha/171835513639130)
```yaml
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: Authorization
# token前缀
token-prefix: Bearer
// 省略...
```
> + `token-name`: 指定 Token 令牌的名称,默认为 `satoken` , 这里修改为 `Authorization`;
> + `token-prefix`: 指定 `token` 前缀,默认为空,这里修改为 `Bearer`
以上配置完成后,重启认证服务与网关服务,再来测试一波*登出接口*,如果你还是以 `satoken` 的请求头来携带令牌,网关会提示你:*未能读取到有效 token* :
![](https://img.quanxiaoha.com/quanxiaoha/171835533539489)
只有在请求头中,按正确的 Token 请求格式携带令牌,才能够请求成功*登出接口*,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171835549418056)
## 本小节源码下载
[https://t.zsxq.com/Hd9I9](https://t.zsxq.com/Hd9I9)

View File

@ -0,0 +1,695 @@
在前面小节中,我们对网关中的 SaToken ,配置了拦截所有路由,都需要进行登录校验,仅排除了登录接口,以及获取验证码接口。但是,光是有登录校验肯定不行的,还需要进行权限校验,比如普通用户即使登录了,也无法执行管理员独有的一些操作。
## 配置接口鉴权
那么,在本小节中,我们继续完善网关接口的鉴权功能。这里为了测试,如下图所示,针对*用户登出*接口,假设除了要进行登录校验外,还需要校验该用户是否拥有 `user` 权限:
![](https://img.quanxiaoha.com/quanxiaoha/171851887024030)
代码如下:
```rust
SaRouter.match("/auth/user/logout", r -> StpUtil.checkPermission("user"));
```
> **TIP** : `StpUtil.checkPermission("user"))` 的入参 `user` 代表的是**权限标识字符串**,即咱们权限表中定义的 `permission_key` 列, 如下图所示:
>
> ![](https://img.quanxiaoha.com/quanxiaoha/171853020134142)
以上接口鉴权配置完成后,重启网关服务,测试一波*登出接口*,记得请求头中携带上令牌,如下图所示。由于目前咱们的用户还没有 `user` 权限,网关会提示:*无此权限user* :
![](https://img.quanxiaoha.com/quanxiaoha/171851901770541)
到这里,说明网关服务中 SaToken 的接口鉴权功能,已经是正常工作了。
## StpInterface 接口实现类说明
问题来了,*SaToken 中,是怎么获取用户的角色和权限数据的?*
![](https://img.quanxiaoha.com/quanxiaoha/171851919037138)
在 SaToken 框架中,是通过 `StpInterface` 的实现类来拉取相关数据的,即之前创建的 `StpInterfaceImpl` 类。
### 添加依赖
为了验证,我们在 `StpInterfaceImpl` 类中打印一些日志,来看看效果。为了能够使用 Lombok 的 `@Slf4j` 日志注解,编辑 `xiaohashu-gateway` 网关的 `pom.xml` 文件,添加通用模块的依赖,如下,该模块中已添加 `Lombok` 的依赖:
> **TIP** : 依赖具有传递性,子模块中已经添加的依赖,父模块中也能直接使用。
```php-template
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-common</artifactId>
</dependency>
```
依赖添加完成后,重新刷新一下 Maven 依赖。
### 打印日志
编辑 `StpInterfaceImpl` 类,添加一些日志,看看 SaToken 在权限校验时,具体执行了哪个方法,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.stp.StpInterface;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* @author: 犬小哈
* @date: 2024/4/5 18:04
* @version: v1.0.0
* @description: 自定义权限验证接口扩展
**/
@Component
@Slf4j
public class StpInterfaceImpl implements StpInterface {
/**
* 获取用户权限
*
* @param loginId
* @param loginType
* @return
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 返回此 loginId 拥有的权限列表
log.info("## 获取用户权限列表, loginId: {}", loginId);
// todo 从 redis 获取
return Collections.emptyList();
}
/**
* 获取用户角色
*
* @param loginId
* @param loginType
* @return
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
log.info("## 获取用户角色列表, loginId: {}", loginId);
// 返回此 loginId 拥有的角色列表
// todo 从 redis 获取
return Collections.emptyList();
}
}
```
> + `getPermissionList()` : **获取用户权限列表**,要求返参是 `List<String>` 字符串集合,数据格式类似如 `["app:note:publish", "app:comment:publish"]` ;
> + `getRoleList()`: **获取用户角色列表**,要求返参是 `List<String>` 字符串集合,数据格式类似如 `["common_user", "admin"]` ;
再次重启网关服务,测试登出接口,观察控制台日志打印:
![](https://img.quanxiaoha.com/quanxiaoha/171851944942363)
可以看到,由于咱们针对登出接口,配置的是 `checkPermission("user")` , 检查是否拥有 `user` 标识符的权限。SaToken 实际上会主动调用 `StpInterfaceImpl.getPermissionList()` 方法,去查询当前用户实际拥有的权限集合,并与之做对比来做判断,入参 `loginId` 即用户 ID。由于咱们这个方法里面目前返回的是空集合即判为*无此权限*。同理,如何你配置的是 `checkRole()` , 则会调用 `StpInterfaceImpl.getRoleList()` 获取角色列表方法。
## 修改 Redis 中角色-权限数据格式
了解完 SaToken 鉴权执行流程后,我们需要修改一下 Redis 中存储的角色-权限数据格式。
### 角色
首先是角色,如下图所示,之前,当时用户注册成功后,存储到 Redis 中用户-角色数据的`key`,是通过手机号来区分的。而目前网关会把 Token 解析为用户 ID, 这就对应不起来了,所以要修正一下。
![](https://img.quanxiaoha.com/quanxiaoha/171851972034046)
编辑 `xiaohashu-auth` 认证服务中的 `RedisKeyConstants` 常量类,修改 `buildUserRoleKey()` 方法,改为拼接用户 ID:
```kotlin
package com.quanxiaoha.xiaohashu.auth.constant;
public class RedisKeyConstants {
// 省略...
/**
* 用户对应的角色集合 KEY
* @param userId
* @return
*/
public static String buildUserRoleKey(Long userId) {
return USER_ROLES_KEY_PREFIX + userId;
}
// 省略...
}
```
除了 `key` 值外,`value` 值也修改一下,目前我们存储的是,权限 ID 集合,如下:
```bash
"[1]"
```
为了方便后续维护,以及网关服务查询,将其修改为字符串标识,如下:
```css
["common_user", "admin"]
```
#### 开始编码
清楚要做的事情后,编辑认证服务中 `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.RoleDOMapper;
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.ArrayList;
import java.util.List;
import java.util.Objects;
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略..
@Resource
private RoleDOMapper roleDOMapper;
// 省略..
/**
* 系统自动注册用户
* @param phone
* @return
*/
private Long registerUser(String phone) {
return transactionTemplate.execute(status -> {
try {
// 省略..
// 给该用户分配一个默认角色
// 省略...
RoleDO roleDO = roleDOMapper.selectByPrimaryKey(RoleConstants.COMMON_USER_ROLE_ID);
// 将该用户的角色 ID 存入 Redis 中,指定初始容量为 1这样可以减少在扩容时的性能开销
List<String> roles = new ArrayList<>(1);
roles.add(roleDO.getRoleKey());
String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
redisTemplate.opsForValue().set(userRolesKey, JsonUtils.toJsonString(roles));
return userId;
} catch (Exception e) {
// 省略...
}
});
}
}
```
#### 自测一波
修改完毕后,我们来自测一波。先将 Redis 中 `user:roles:` 缓存删除掉:
![](https://img.quanxiaoha.com/quanxiaoha/171852055075838)
另外,将 `t_user` 用户表,以及 `t_user_role_rel` 用户角色关联表中的数据删除掉,确保请求登录接口时,当前手机号是个未注册的用户,以推送新的用户-角色数据到 Redis 中。测试后,观察 Redis 中的数据是否符合预期的格式:
![](https://img.quanxiaoha.com/quanxiaoha/171852068669058)
### 权限
角色改了,权限也需要适配一下。如下图所示,目前角色-权限的 `key` 格式是通过角色 ID 来区别的,需要改成角色唯一标识,如下:
```makefile
role:permissions:common_user
```
另外,当前 `value` 存储的是权限实体类集合。这样会导致网关层查询的时候,非常不方便。
![](https://img.quanxiaoha.com/quanxiaoha/171852031299846)
如果直接存储的是权限标识的字符串集合,如下所示,这样数据查出来就能用,无需再额外提取 `permissionKey` 字段值。性能上也高很多。
```css
["app:note:publish", "app:comment:publish"]
```
#### 开始编码
编辑认证服务中的 `RedisKeyConstants` 常量类,修改 `buildRolePermissionsKey()` 方法,通过角色唯一标识符来拼接 Redis 的 `key` , 代码如下:
```typescript
package com.quanxiaoha.xiaohashu.auth.constant;
public class RedisKeyConstants {
// 省略...
/**
* 构建角色对应的权限集合 KEY
* @param roleKey
* @return
*/
public static String buildRolePermissionsKey(String roleKey) {
return ROLE_PERMISSIONS_KEY_PREFIX + roleKey;
}
}
```
最后,`PushRolePermissions2RedisRunner` 启动任务中,推送角色-权限数据到 Redis 中的逻辑,也需要适配一下,修改的地方我都标注出来了:
![](https://img.quanxiaoha.com/quanxiaoha/171852104205157)
代码如下:
```rust
// 省略..
// 组织 角色-权限 关系
Map<String, List<String>> roleKeyPermissionsMap = Maps.newHashMap();
// 循环所有角色
roleDOS.forEach(roleDO -> {
// 当前角色 ID
Long roleId = roleDO.getId();
// 当前角色 roleKey
String roleKey = roleDO.getRoleKey();
// 当前角色 ID 对应的权限 ID 集合
List<Long> permissionIds = roleIdPermissionIdsMap.get(roleId);
if (CollUtil.isNotEmpty(permissionIds)) {
List<String> permissionKeys = Lists.newArrayList();
permissionIds.forEach(permissionId -> {
// 根据权限 ID 获取具体的权限 DO 对象
PermissionDO permissionDO = permissionIdDOMap.get(permissionId);
permissionKeys.add(permissionDO.getPermissionKey());
});
roleKeyPermissionsMap.put(roleKey, permissionKeys);
}
});
// 同步至 Redis 中,方便后续网关查询 Redis, 用于鉴权
roleKeyPermissionsMap.forEach((roleKey, permissions) -> {
String key = RedisKeyConstants.buildRolePermissionsKey(roleKey);
redisTemplate.opsForValue().set(key, JsonUtils.toJsonString(permissions));
});
// 省略..
```
#### 自测一波
代码适配完成后,依然是自测一波。将 Redis 中角色-权限缓存先删除掉,以及 `push.permission.flag` 锁也删除。重启认证服务,等待角色-权限数据推送完成,观察 Redis 中的数据,看看是否是前面定义好的格式:
![](https://img.quanxiaoha.com/quanxiaoha/171852123960281)
若如上所示,则 Redis 中角色、权限的基础数据格式就修正完毕了,可以开始正式编写 `StpInterfaceImpl` 类中,查询鉴权数据的逻辑代码了。
## SaToken 查询权限数据
### 添加依赖
编辑 `xiaohashu-gateway` 网关服务的 `pom.xml` 文件,添加如下依赖,因为等会需要查询 Redis 中的权限数据:
```php-template
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Jackson 组件 -->
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-spring-boot-starter-jackson</artifactId>
</dependency>
```
### 配置 RedisTemplate
![](https://img.quanxiaoha.com/quanxiaoha/171852139823284)
创建 `/config` 包,并将认证服务中,已经配置好的 `RedisTemplateConfig` 配置类复制过来:
```typescript
package com.quanxiaoha.xiaohashu.gateway.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;
}
}
```
### Redis Key 全局常量类
![](https://img.quanxiaoha.com/quanxiaoha/171852150281275)
创建 `/constant` 常量包,并新建 `RedisKeyConstants` 常量类,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.gateway.constant;
/**
* @author: 犬小哈
* @date: 2024/5/21 15:04
* @version: v1.0.0
* @description: TODO
**/
public class RedisKeyConstants {
/**
* 用户对应角色集合 KEY 前缀
*/
private static final String USER_ROLES_KEY_PREFIX = "user:roles:";
/**
* 角色对应的权限集合 KEY 前缀
*/
private static final String ROLE_PERMISSIONS_KEY_PREFIX = "role:permissions:";
/**
* 构建角色对应的权限集合 KEY
* @param roleKey
* @return
*/
public static String buildRolePermissionsKey(String roleKey) {
return ROLE_PERMISSIONS_KEY_PREFIX + roleKey;
}
/**
* 构建用户-角色 KEY
* @param userId
* @return
*/
public static String buildUserRoleKey(Long userId) {
return USER_ROLES_KEY_PREFIX + userId;
}
}
```
### 查询角色
前置工作完成后,添加 `StpInterfaceImpl``getRoleList()` 方法中,获取当前用户对应的角色集合的逻辑代码:
```typescript
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.stp.StpInterface;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quanxiaoha.xiaohashu.gateway.constant.RedisKeyConstants;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* @author: 犬小哈
* @date: 2024/4/5 18:04
* @version: v1.0.0
* @description: 自定义权限验证接口扩展
**/
@Component
@Slf4j
public class StpInterfaceImpl implements StpInterface {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private ObjectMapper objectMapper;
// 省略...
/**
* 获取用户角色
*
* @param loginId
* @param loginType
* @return
*/
@SneakyThrows
@Override
public List<String> getRoleList(Object loginId, String loginType) {
log.info("## 获取用户角色列表, loginId: {}", loginId);
// 构建 用户-角色 Redis Key
String userRolesKey = RedisKeyConstants.buildUserRoleKey(Long.valueOf(loginId.toString()));
// 根据用户 ID ,从 Redis 中获取该用户的角色集合
String useRolesValue = redisTemplate.opsForValue().get(userRolesKey);
if (StringUtils.isBlank(useRolesValue)) {
return null;
}
// 将 JSON 字符串转换为 List<String> 集合
return objectMapper.readValue(useRolesValue, new TypeReference<>() {});
}
}
```
查询用户对应的角色逻辑比较简单,通过入参中的用户 ID, 直接构建 用户-角色 Redis Key, 查询出数据后,先判空,若为空,直接返回 `null`; 否则将角色集合数据返回。
#### 自测一波
接下来自测一波。编辑 `SaTokenConfigure` 配置类,将登出接口的权限校验,修改为校验登录用户是否拥有 `admin` 角色,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171852176196283)
代码如下:
```rust
SaRouter.match("/auth/user/logout", r -> StpUtil.checkRole("admin"));
```
重启网关服务,测试一波登出接口,由于目前用户只有 `common_user` 普通用户角色,可以看到,返参提示:*无此角色admin* :
![](https://img.quanxiaoha.com/quanxiaoha/171852187125113)
如果你手动修改 Redis 中该用户的角色数据,添加上 `admin` 角色后,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171852194019413)
再次测试登录接口,就能权限校验通过了。测试完后,记得将 Redis 中该用户的 `admin` 角色删掉哟~
### 查询权限
接下来,编写查询用户权限的逻辑,即 `getPermissionList()` 方法中的逻辑,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.stp.StpInterface;
import cn.hutool.core.collection.CollUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import com.quanxiaoha.xiaohashu.gateway.constant.RedisKeyConstants;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* @author: 犬小哈
* @date: 2024/4/5 18:04
* @version: v1.0.0
* @description: 自定义权限验证接口扩展
**/
@Component
@Slf4j
public class StpInterfaceImpl implements StpInterface {
@Resource
private RedisTemplate<String, String> redisTemplate;
@Resource
private ObjectMapper objectMapper;
/**
* 获取用户权限
*
* @param loginId
* @param loginType
* @return
*/
@SneakyThrows
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
log.info("## 获取用户权限列表, loginId: {}", loginId);
// 构建 用户-角色 Redis Key
String userRolesKey = RedisKeyConstants.buildUserRoleKey(Long.valueOf(loginId.toString()));
// 根据用户 ID ,从 Redis 中获取该用户的角色集合
String useRolesValue = redisTemplate.opsForValue().get(userRolesKey);
if (StringUtils.isBlank(useRolesValue)) {
return null;
}
// 将 JSON 字符串转换为 List<String> 角色集合
List<String> userRoleKeys = objectMapper.readValue(useRolesValue, new TypeReference<>() {});
if (CollUtil.isNotEmpty(userRoleKeys)) {
// 查询这些角色对应的权限
// 构建 角色-权限 Redis Key 集合
List<String> rolePermissionsKeys = userRoleKeys.stream()
.map(RedisKeyConstants::buildRolePermissionsKey)
.toList();
// 通过 key 集合批量查询权限,提升查询性能。
List<String> rolePermissionsValues = redisTemplate.opsForValue().multiGet(rolePermissionsKeys);
if (CollUtil.isNotEmpty(rolePermissionsValues)) {
List<String> permissions = Lists.newArrayList();
// 遍历所有角色的权限集合,统一添加到 permissions 集合中
rolePermissionsValues.forEach(jsonValue -> {
try {
// 将 JSON 字符串转换为 List<String> 权限集合
List<String> rolePermissions = objectMapper.readValue(jsonValue, new TypeReference<>() {});
permissions.addAll(rolePermissions);
} catch (JsonProcessingException e) {
log.error("==> JSON 解析错误: ", e);
}
});
// 返回此用户所拥有的权限
return permissions;
}
}
return null;
}
// 省略...
}
```
> 查询用户权限数据的逻辑,稍微复杂一点,这里解释一下:
>
> + 通过用户 ID 查询 Redis, 先获取当前用户所有的角色;
> + 若角色集合不为空,再次查询 Redis, 通过 `multiGet` 方法,一次性将这些角色对应的权限标识符查询出来,保证最少的 IO 次数(与 Redis 只交互 2 次),提升查询性能。最后,统一放到一个 `List<String>` 集合中,作为返参返回;
#### 自测一波
代码编写完毕后,再次自测一波。为登出接口配置权限校验,校验用户是否拥有 `app:note:delete` 笔记删除权限,目前所有用户是没有这个权限的:
![](https://img.quanxiaoha.com/quanxiaoha/171852247611129)
代码如下:
```rust
SaRouter.match("/auth/user/logout", r -> StpUtil.checkPermission("app:note:delete"));
```
重启网关服务,测试接口,如下图所示,网关正确鉴权,提示:*无此权限*
![](https://img.quanxiaoha.com/quanxiaoha/171852254805578)
如果你将登出接口配置成普通用户拥有的权限,如 `app:note:publish`
```rust
SaRouter.match("/auth/user/logout", r -> StpUtil.checkPermission("app:note:publish"));
```
重启网关服务,再次测试,如下图所示,鉴权就通过啦:
![](https://img.quanxiaoha.com/quanxiaoha/171852261046978)
至此,我们就已经完整的体验了,如何在微服务网关中,通过 SaToken 框架对接口进行统一鉴权,希望小伙伴们学完后能够有所收获~
## 本小节源码下载
[https://t.zsxq.com/Q09ct](https://t.zsxq.com/Q09ct)

View File

@ -0,0 +1,224 @@
![](https://img.quanxiaoha.com/quanxiaoha/171862977606123)
本小节中,我们将为 Gateway 网关服务添加全局异常处理,统一一波接口出参格式。
## 问题复现
首先,我们来看看目前网关服务存在的问题,编辑 `SaTokenConfigure` 配置类,对登出接口添加权限校验,校验登录的用户是否拥有 `admin` 角色:
![](https://img.quanxiaoha.com/quanxiaoha/171862445386135)
代码如下:
```rust
SaRouter.match("/auth/user/logout", r -> StpUtil.checkRole("admin"));
```
重启网关服务,请求一波登出接口,由于目前已注册的用户,只有一个**普通用户**角色,于是,网关会提示你:*无此角色admin*:
![](https://img.quanxiaoha.com/quanxiaoha/171862461031130)
\*发现问题了没有?\*返参的格式是下面这样的:
```json
{
"code": 500,
"msg": "无此角色admin",
"data": null
}
```
和我们已经定好的接口出参格式,是不一致的:
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": null
}
```
接下来,我们就将为网关服务添加全局异常捕获器,统一一下 `JSON` 出参格式。
## 删除 SaToken 默认的异常处理
添加全局异常捕获之前,首先编辑 `SaTokenConfigure` 配置类,将 `.setError()` 方法**删除**掉,防止等会自定义的全局异常处理失效:
![](https://img.quanxiaoha.com/quanxiaoha/171862612221475)
代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.reactor.filter.SaReactorFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 鉴权方法:每次访问进入
.setAuth(obj -> {
// 登录校验
SaRouter.match("/**") // 拦截所有路由
.notMatch("/auth/user/login") // 排除登录接口
.notMatch("/auth/verification/code/send") // 排除验证码发送接口
.check(r -> StpUtil.checkLogin()) // 校验是否登录
;
// 权限认证 -- 不同模块, 校验不同权限
SaRouter.match("/auth/user/logout", r -> StpUtil.checkRole("admin"));
// 更多匹配 ... */
})
;
}
}
```
## 添加错误响应枚举类
![](https://img.quanxiaoha.com/quanxiaoha/171862627472198)
接着,在网关服务中新建 `/enums` 枚举包,并创建 `ResponseCodeEnum` 异常枚举类,代码如下:
```java
package com.quanxiaoha.xiaohashu.gateway.enums;
import com.quanxiaoha.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("500", "系统繁忙,请稍后再试"),
UNAUTHORIZED("401", "权限不足"),
// ----------- 业务异常状态码 -----------
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
> 在异常响应枚举类中,先定义两个现阶段需要用到的错误码枚举值:
>
> + `SYSTEM_ERROR` :系统繁忙错误;
> + `UNAUTHORIZED` : 权限不足,若 SaToken 权限校验失败,则统一返回此错误码;
## 网关全局异常捕获
![](https://img.quanxiaoha.com/quanxiaoha/171862637199167)
接着,在网关服务中创建一个 `/exception` 异常包,用于统一放置异常相关的代码。然后,创建 `GlobalExceptionHandler` 全局异常处理器,代码如下:
```java
package com.quanxiaoha.xiaohashu.gateway.exception;
import cn.dev33.satoken.exception.SaTokenException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.gateway.enums.ResponseCodeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* @author: 犬小哈
* @date: 2024/4/5 19:18
* @version: v1.0.0
* @description: 全局异常处理
**/
@Component
@Slf4j
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
@Resource
private ObjectMapper objectMapper;
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
// 获取响应对象
ServerHttpResponse response = exchange.getResponse();
log.error("==> 全局异常捕获: ", ex);
// 响参
Response<?> result;
// 根据捕获的异常类型,设置不同的响应状态码和响应消息
if (ex instanceof SaTokenException) { // Sa-Token 异常
// 权限认证失败时,设置 401 状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 构建响应结果
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(), ResponseCodeEnum.UNAUTHORIZED.getErrorMessage());
} else { // 其他异常,则统一提示 “系统繁忙” 错误
result = Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
// 设置响应头的内容类型为 application/json;charset=UTF-8表示响应体为 JSON 格式
response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);
// 设置 body 响应体
return response.writeWith(Mono.fromSupplier(() -> { // 使用 Mono.fromSupplier 创建响应体
DataBufferFactory bufferFactory = response.bufferFactory();
try {
// 使用 ObjectMapper 将 result 对象转换为 JSON 字节数组
return bufferFactory.wrap(objectMapper.writeValueAsBytes(result));
} catch (Exception e) {
// 如果转换过程中出现异常,则返回空字节数组
return bufferFactory.wrap(new byte[0]);
}
}));
}
}
```
> 解释一波异常处理器的代码:
>
> + 和 Spring Boot 中使用 `@ControllerAdvice` 注解,来定义全局异常捕获器不同。在网关中,你需要创建 `ErrorWebExceptionHandler` 的实现类,并将其注入到 Spring 容器中;
> + 在 `handle()` 异常处理方法中,对方法的入参 `ex` 异常进行类型判断,从而设置不同的响应状态码和响应消息:
> + 如果异常类型为 `SaTokenException`,则设置 401 状态码,并构建响应结果;
> + 其他异常,则统一提示 “系统繁忙” 错误;
> + 设置响应头的内容类型为 `application/json;charset=UTF-8`,表示响应体为 JSON 格式;
> + 设置 `body` 响应体,并返回响参;
## 自测一波
全局异常捕获器添加完毕后,**重启网关服务**,再次测试一波登出接口,看看出参效果:
![](https://img.quanxiaoha.com/quanxiaoha/171862648457721)
如上图所示,响参的 `JSON` 格式已经被统一了,错误提示信息为**权限不足**,并且状态码为 *401*
## 本小节源码下载
[https://t.zsxq.com/wMlWj](https://t.zsxq.com/wMlWj)

View File

@ -0,0 +1,160 @@
在[上小节](https://www.quanxiaoha.com/column/10296.html) 中,我们为 Gateway 网关添加了全局异常处理器,实现了接口出参格式的统一。但是,依然还存在一个小问题 —— *提示信息不够友好*
## 登录校验不通过,提示不友好问题
接下来,演示一下登录校验不通过,提示不友好的问题。当我们调用登出接口,并且未携带 Token 令牌,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171869687673087)
可以看到,提示信息为*权限不足*。这个提示信息不够友好,应该提示*请先登录*,或者*未携带 Token 令牌*之类,这个提示,会让调用者一脸闷逼状态~
同时,观察网关服务的控制台日志,如下所示,从全局异常处理器打印的异常信息分析,明显是检测到了未提交 Token 令牌的问题,只不过需要对 `SaTokenException` 异常进行细化处理,而不是统一都返回*权限不足*提示:
![](https://img.quanxiaoha.com/quanxiaoha/171869695839393)
## SaToken 异常
在 SaToken 框架中,`SaTokenException` 是一个非常核心的异常,诸如未登录异常、权限不足异常、不具备对应角色异常,均继承于它:
```lua
-- SaTokenException
-- NotLoginException // 未登录异常
-- NotPermissionException // 权限不足异常
-- NotRoleException // 不具备对应角色异常
-- ...
```
## 抛出异常
在上小节中,我们在 SaToken 配置类中,将 `.setError()` 方法删除掉了此时当登录校验不通过、权限校验不通过等情况SaToken 都会统一抛出一个 `SaTokenException` 父异常,这会导致全局异常处理器无法判断具体的异常类型:
![](https://img.quanxiaoha.com/quanxiaoha/171869796238415)
再次将 `.setError()` 方法添加上,并抛出具体异常类型,代码如下:
```java
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.exception.SaTokenException;
import cn.dev33.satoken.reactor.filter.SaReactorFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: 犬小哈
* @date: 2024/6/13 14:48
* @version: v1.0.0
* @description: [Sa-Token 权限认证] 配置类
**/
@Configuration
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 鉴权方法:每次访问进入
.setAuth(obj -> {
// 省略..
})
// 异常处理方法每次setAuth函数出现异常时进入
.setError(e -> {
// return SaResult.error(e.getMessage());
// 手动抛出异常,抛给全局异常处理器
if (e instanceof NotLoginException) { // 未登录异常
throw new NotLoginException(e.getMessage(), null, null);
} else if (e instanceof NotPermissionException || e instanceof NotRoleException) { // 权限不足,或不具备角色,统一抛出权限不足异常
throw new NotPermissionException(e.getMessage());
} else { // 其他异常,则抛出一个运行时异常
throw new RuntimeException(e.getMessage());
}
})
;
}
}
```
> 解释一下 `setError()` 方法中的逻辑:
>
> + `NotLoginException` 当用户未登录时,手动抛出 `NotLoginException` 异常;
> + `NotPermissionException``NotRoleException` : 权限不足,或不具备对应角色,统一抛出权限不足异常;
> + 其他异常,则手动抛出一个运行时异常;
## 异常处理
![](https://img.quanxiaoha.com/quanxiaoha/171869808915546)
接着,编辑 `GlobalExceptionHandler` 全局异常处理器,对不同的异常类型,返回不同的错误提示,代码如下:
```java
// 省略...
// 根据捕获的异常类型,设置不同的响应状态码和响应消息
if (ex instanceof NotLoginException) { // 未登录异常
// 设置 401 状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 构建响应结果
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(), "未携带 Token 令牌");
} else if (ex instanceof NotPermissionException) { // 无权限异常
// 权限认证失败时,设置 401 状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 构建响应结果
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(), ResponseCodeEnum.UNAUTHORIZED.getErrorMessage());
} else { // 其他异常,则统一提示 “系统繁忙” 错误
result = Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
// 省略...
```
## 测试一波
### 登录校验不通过
编码完成后,重启网关服务。测试一波*登出接口*,先在请求头中不添加 Token 令牌,如下图所示,可以看到这次提示信息就正常了:*未携带 Token 令牌*
![](https://img.quanxiaoha.com/quanxiaoha/171869815063381)
### 权限校验不通过
再来测试一下权限校验不通过的情况,由于已经配置了登出接口必须具有 `admin` 角色才能请求,而目前登录的用户只具备普通角色:
![](https://img.quanxiaoha.com/quanxiaoha/171869824010515)
携带上 Token 令牌请求接口,效果如下:
![](https://img.quanxiaoha.com/quanxiaoha/171869820677583)
成功提示*权限不足*。至此网关服务的认证鉴权提示就相对比较友好了后续如果还有调整的地方我们再来改。OK, 本小节优化完毕~
## 补充:令牌过期提示问题
2024.6.22 补充:测试的时候,漏掉了令牌过期的问题,当令牌过期时,提示如下:
![](https://img.quanxiaoha.com/quanxiaoha/171904341863162)
提示信息依然还有问题当令牌失效时SaToken 同样会抛出 `NotLoginException`,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171904359557292)
这就不能写死提示为:*未携带令牌*了,干脆直接用异常本身的错误信息,修改 `GlobalExceptionHandler` 全局异常处理器如下:
```java
if (ex instanceof NotLoginException) { // 未登录异常
// 设置 401 状态码
response.setStatusCode(HttpStatus.UNAUTHORIZED);
// 构建响应结果
result = Response.fail(ResponseCodeEnum.UNAUTHORIZED.getErrorCode(), ex.getMessage());
}
```
修改完毕后,重启网关再次测试,提示信息就正常了~
## 本小节源码下载
[https://t.zsxq.com/y6XCb](https://t.zsxq.com/y6XCb)

View File

@ -0,0 +1,231 @@
本小节中,我们将为网关服务添加一个**过滤器**,实现将当前用户 ID 透传给下游服务。
## 为什么需要透传用户 ID ?以什么方式传?
![](https://img.quanxiaoha.com/quanxiaoha/171878419235119)
*那么,问题来了,为什么网关在路由转发时,需要透传用户 ID 给下游服务呢?* 就以认证服务中的*用户退出登录接口*为例,当该接口被请求时,如果网关不告诉当前请求的用户 ID, **认证服务不知道实际是要退出哪个用户的**
![](https://img.quanxiaoha.com/quanxiaoha/171878529082281)
*第二个问题来了,要以什么样的方式传给子服务呢?* 由于服务之间的通信方式是 `HTTP` , 网关服务在接受到请求时,可以依据 `Token` 令牌解析到当前用户 ID, 转发路由时,再将用户 ID 添加到 `Header` 请求头中,这样,下游服务接受到请求时,直接从请求头中获取用户 ID 即可。
## 添加过滤器
![](https://img.quanxiaoha.com/quanxiaoha/171878191137857)
接下来,我们就来实现这个功能。编辑网关服务,创建一个 `/filter` 包,用于统一放置过滤器相关代码,并新建 `AddUserId2HeaderFilter` 过滤器,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.gateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* @author: 犬小哈
* @date: 2024/4/9 15:52
* @version: v1.0.0
* @description: 转发请求时,将用户 ID 添加到 Header 请求头中,透传给下游服务
**/
@Component
@Slf4j
public class AddUserId2HeaderFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("==================> TokenConvertFilter");
// 将请求传递给过滤器链中的下一个过滤器进行处理。没有对请求进行任何修改。
return chain.filter(exchange);
}
}
```
> 解释一波:
>
> + `GlobalFilter` : 这是一个全局过滤器接口,会对所有通过网关的请求生效。
> + `filter()` 入参解释:
> + **`ServerWebExchange exchange`**:表示当前的 HTTP 请求和响应的上下文,包括请求头、请求体、响应头、响应体等信息。可以通过它来获取和修改请求和响应。
> + **`GatewayFilterChain chain`**:代表网关过滤器链,通过调用 `chain.filter(exchange)` 方法可以将请求传递给下一个过滤器进行处理。
> + `chain.filter(exchange)` : 将请求传递给过滤器链中的下一个过滤器进行处理。当前没有对请求进行任何修改。
## 过滤器执行顺序
细心的小伙伴,应该注意到了,在之前的 SaToken 配置类中,也是配置了一个过滤器 `SaReactorFilter` , 如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171878215455720)
当同时定义了两个过滤器,执行顺序是怎么样的?查看 `SaReactorFilter`源码,如下图所示,可以看到此过滤器添加了一个 `@Order(-100)` 注解:
![](https://img.quanxiaoha.com/quanxiaoha/171878201496838)
> + `@Order` 注解的作用:用于定义 Spring 组件的加载顺序。在过滤器的上下文中它用来指定过滤器的执行顺序。Spring 容器会按照 `@Order` 注解中定义的顺序执行过滤器。
> + `@Order` 注解的数值含义:注解后面的数值表示优先级,数值越小,优先级越高。即:
> + 数值越小,过滤器的执行顺序越靠前(优先执行)。
> + 数值越大,过滤器的执行顺序越靠后(后执行)。
由于添加了 `@Order(-100)` 注解,`SaReactorFilter` 过滤器的优先级非常之高,相对于未指定数值的 `AddUserId2HeaderFilter` 过滤器。为了验证一下,咱们在 `SaTokenConfigure` 过滤器中添加一行日志,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.gateway.auth;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import cn.dev33.satoken.exception.SaTokenException;
import cn.dev33.satoken.reactor.filter.SaReactorFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@Slf4j
public class SaTokenConfigure {
// 注册 Sa-Token全局过滤器
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**") /* 拦截全部path */
// 鉴权方法:每次访问进入
.setAuth(obj -> {
log.info("==================> SaReactorFilter, Path: {}", SaHolder.getRequest().getRequestPath());
// 省略...
})
// 省略...
;
}
}
```
日志添加完毕后,重启网关服务,通过 Apipost 工具调试一波登出接口:
![](https://img.quanxiaoha.com/quanxiaoha/171878266173060)
观察网关服务的控制台日志,可以看到确实是 `SaReactorFilter` 先被执行,然后才执行到 `AddUserId2HeaderFilter` 过滤器:
![](https://img.quanxiaoha.com/quanxiaoha/171878252274410)
这样就可以保证,权限校验在前面,真正执行到 `AddUserId2HeaderFilter` 过滤器中时,**要么是接口校验通过了,要么是该接口无需校验两种情况**。
## 自定义过滤器逻辑
接着,编辑 `AddUserId2HeaderFilter` 过滤器,开始编写添加用户 ID 到请求头的逻辑,代码如下:
```java
package com.quanxiaoha.xiaohashu.gateway.filter;
import cn.dev33.satoken.stp.StpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* @author: 犬小哈
* @date: 2024/4/9 15:52
* @version: v1.0.0
* @description: 转发请求时,将用户 ID 添加到 Header 请求头中,透传给下游服务
**/
@Component
@Slf4j
public class AddUserId2HeaderFilter implements GlobalFilter {
/**
* 请求头中,用户 ID 的键
*/
private static final String HEADER_USER_ID = "userId";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("==================> TokenConvertFilter");
// 用户 ID
Long userId = null;
try {
// 获取当前登录用户的 ID
userId = StpUtil.getLoginIdAsLong();
} catch (Exception e) {
// 若没有登录,则直接放行
return chain.filter(exchange);
}
log.info("## 当前登录的用户 ID: {}", userId);
Long finalUserId = userId;
ServerWebExchange newExchange = exchange.mutate()
.request(builder -> builder.header(HEADER_USER_ID, String.valueOf(finalUserId))) // 将用户 ID 设置到请求头中
.build();
return chain.filter(newExchange);
}
}
```
> 解释一波代码:
>
> + `StpUtil.getLoginIdAsLong();` : 通过 SaToken 工具类获取当前用户 ID。如果请求中携带了 Token 令牌,则会获取成功;如果未携带,能执行到这里,说明请求的接口无需权限校验,这个时候获取用户 ID 会报抛异常, `catch()` 到异常后,不做任何修改,将请求传递给过滤器链中的下一个过滤器,发行请求即可;
> + 如果成功拿到了用户 ID, 则开始添加用户 ID 到请求头操作:
> + **`ServerWebExchange newExchange = exchange.mutate()`**:通过 `mutate()` 方法创建一个新的 `ServerWebExchange` 对象,用于修改当前请求。
> + **`.request(builder -> builder.header(HEADER_USER_ID, String.valueOf(finalUserId)))`**:修改请求头,添加用户 ID;
> + **`.build();`**:构建修改后的 `ServerWebExchange` 对象。
> + `return chain.filter(newExchange);`:将修改后的 `newExchange` 对象传递给过滤器链中的下一个过滤器进行处理。
## 下游服务获取用户 ID
接着,编辑认证服务中的 `/logout` 登出接口,获取一下请求头中的用户 ID , 并打印日志,代码如下:
```less
@PostMapping("/logout")
@ApiOperationLog(description = "账号登出")
public Response<?> logout(@RequestHeader("userId") String userId) {
// todo 账号登录逻辑待实现
log.info("==> 网关透传过来的用户 ID: {}", userId);
return Response.success();
}
```
## 测试一波
完成以上编码工作后,**分别重启网关服务与认证服务**。通过 Apipost 工具,先测试一下登出接口,目前对登出接口配置了校验如下权限:
```rust
SaRouter.match("/auth/user/logout", r -> StpUtil.checkPermission("app:note:publish"));
```
看看认证服务接受到转发的请求时,是否能够拿到请求头中透传过来的用户 ID, 如下图所示通过日志信息OK, 成功拿到了用户 ID, 功能正常:
![](https://img.quanxiaoha.com/quanxiaoha/171878346486957)
![](https://img.quanxiaoha.com/quanxiaoha/171878350932478)
再来测试一下接口不携带 `Token` 令牌的情况,如获取验证码接口,看看网关转发功能是否正常:
![](https://img.quanxiaoha.com/quanxiaoha/171878378842124)
![](https://img.quanxiaoha.com/quanxiaoha/171878383786266)
从上图可以看出,请求没有携带 Token 令牌,并且 `SaReactorFilter` 权限校验通过的请求,`AddUserId2HeaderFilter` 也是工作正常良好,并将路由请求转发到了认证服务上。
## 本小节源码下载
[https://t.zsxq.com/F34yQ](https://t.zsxq.com/F34yQ)

View File

@ -0,0 +1,193 @@
![](https://img.quanxiaoha.com/quanxiaoha/171895910499389)
在[上小节](https://www.quanxiaoha.com/column/10298.html) 中,我们已经实现了网关透传用户 ID 给下游服务,下游服务知道了请求对应的用户 ID才能方便的处理相关业务比如用户退出登录。本小节中就来把**用户退出登录接口**开发完成。
## 接口定义
### 接口地址
```bash
POST /user/logout
```
### 入参
无入参。**只需请求头中携带上 Token 即可。**
### 出参
```json
{
"success": true, // true 表示退出登录成功
"message": null,
"errorCode": null,
"data": null
}
```
## 编写业务逻辑
之前测试权限校验的时候,退出登录接口已经在认证服务中定义好了,接下来,仅需补充对应逻辑即可。
![](https://img.quanxiaoha.com/quanxiaoha/171895720287575)
### service 业务层
编辑 `UserService` 业务接口,声明一个退出登录方法,入参为请求头透传过来的用户 ID, 代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
public interface UserService {
// 省略...
/**
* 退出登录
* @return
*/
Response<?> logout(Long userId);
}
```
接着,编辑其实现类 `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.RoleDOMapper;
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.ArrayList;
import java.util.List;
import java.util.Objects;
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
/**
* 退出登录
*
* @param userId
* @return
*/
@Override
public Response<?> logout(Long userId) {
// 退出登录 (指定用户 ID)
StpUtil.logout(userId);
return Response.success();
}
// 省略...
}
```
> 逻辑很简单,通过 SaToken 的工具类方法 `StpUtil.logout(userId);` ,传入想要退出登录的用户 ID 即可。
## Controller 层
最后,编辑 `UserController` 控制器,修改代码如下:
```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.*;
/**
* @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("/logout")
@ApiOperationLog(description = "账号登出")
public Response<?> logout(@RequestHeader("userId") String userId) {
return userService.logout(Long.valueOf(userId));
}
}
```
## 测试一波
代码编写完毕后,重启一下认证服务。在测试登出接口之前,观察 Redis 中存储的会话信息,先确认一下等会要退出登录的 Token 是否存在,存在则说明对应的用户处于登录状态,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171895558207011)
打开 Apipost 工具, 测试一波退出登录接口,如下所示:
![](https://img.quanxiaoha.com/quanxiaoha/171895540576321)
响参提示操作成功,再来观察一下 Redis 中该 Token 是否还在,如下图所示,可以看到之前存在的 Token ,已经被删除了,会话信息也被删除了:
![](https://img.quanxiaoha.com/quanxiaoha/171895582386033)
为了再次验证一下,用刚才的 Token 令牌,再次调用退出登录接口,响参也提示当前 Token 已经无效了:
![](https://img.quanxiaoha.com/quanxiaoha/171895588446821)
至此,用户退出登录接口就开发完成啦,是不是很简单~
## 本小节源码下载
[https://t.zsxq.com/GudWD](https://t.zsxq.com/GudWD)

View File

@ -0,0 +1,300 @@
![](https://img.quanxiaoha.com/quanxiaoha/171905341538448)
[上小节](https://www.quanxiaoha.com/column/10299.html) 中,我们完成了**退出登录接口**的开发工作。但是,获取当前请求对应的用户 ID是通过在 `Controller` 层的方法上添加 `@RequestHeader` 注解来完成的,难道每次都需要手动添加它,*那也太不方便了!*
![](https://img.quanxiaoha.com/quanxiaoha/171904096103567)
本小节中,我们就将使用 `过滤器 + ThreadLocal` ,更加方便地获取当前用户 ID 。
## 添加全局变量
![](https://img.quanxiaoha.com/quanxiaoha/171904516827721)
首先,编辑 `xiaoha-common` 公共模块,在 `/constant` 包下创建一个 `GlobalConstants` 全局常量类,用于放置一些各个模块中常用的变量,如请求头用户 ID 键 `userId` 网关服务中有用到,等会认证服务中也需要用到,干脆提取到公共模块中,省得到处都定义一份。代码如下:
```java
package com.quanxiaoha.framework.common.constant;
public interface GlobalConstants {
/**
* 用户 ID
*/
String USER_ID = "userId";
}
```
> **TIP** : 该变量创建完毕后,就可以将网关中的变量重构一下,统一用上面这个了。
## 创建过滤器
![](https://img.quanxiaoha.com/quanxiaoha/171904300931512)
接着,编辑 `xiaohashu-auth` 认证服务,创建 `/filter` 包,并创建过滤器类 `HeaderUserId2ContextFilter` , 代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.filter;
import com.quanxiaoha.framework.common.constant.GlobalConstants;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* @author: 犬小哈
* @date: 2024/4/15 14:01
* @version: v1.0.0
* @description: 提取请求头中的用户 ID 保存到上下文中,以方便后续使用
**/
@Component
@Slf4j
public class HeaderUserId2ContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 从请求头中获取用户 ID
String userId = request.getHeader(GlobalConstants.USER_ID);
log.info("## HeaderUserId2ContextFilter, 用户 ID: {}", userId);
// 将请求和响应传递给过滤链中的下一个过滤器。
chain.doFilter(request, response);
}
}
```
> 解释一波:
>
> + `HeaderUserId2ContextFilter` 继承自 `OncePerRequestFilter`,确保每个请求只会执行一次过滤操;
> + `request.getHeader(GlobalConstants.USER_ID);` : 从请求头中获取用户 ID
> + 打印一行日志,看看等会测试的时候,能否拿的到请求头中的用户 ID;
> + `chain.doFilter(request, response);` : 将请求和响应传递给过滤链中的下一个过滤器。如果没有下一个过滤器,则请求会到达控制器。
## 测试一波
过滤器添加完毕后,重启认证服务。通过 Apipost 工具请求登出接口,看看过滤器是否工作正常:
![](https://img.quanxiaoha.com/quanxiaoha/171904398918946)
如上图所示,成功打印了请求头中的用户 ID说明过滤器工作正常。
## 封装上下文工具类
![](https://img.quanxiaoha.com/quanxiaoha/171904584234680)
然后,在 `/filter` 包下创建 `LoginUserContextHolder` 类,用于设置与获取上下文数据,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.filter;
import com.quanxiaoha.framework.common.constant.GlobalConstants;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* @author: 犬小哈
* @date: 2024/4/9 18:19
* @version: v1.0.0
* @description: 登录用户上下文
**/
public class LoginUserContextHolder {
// 初始化一个 ThreadLocal 变量
private static final ThreadLocal<Map<String, Object>> LOGIN_USER_CONTEXT_THREAD_LOCAL
= ThreadLocal.withInitial(HashMap::new);
/**
* 设置用户 ID
*
* @param value
*/
public static void setUserId(Object value) {
LOGIN_USER_CONTEXT_THREAD_LOCAL.get().put(GlobalConstants.USER_ID, value);
}
/**
* 获取用户 ID
*
* @return
*/
public static Long getUserId() {
Object value = LOGIN_USER_CONTEXT_THREAD_LOCAL.get().get(GlobalConstants.USER_ID);
if (Objects.isNull(value)) {
return null;
}
return Long.valueOf(value.toString());
}
/**
* 删除 ThreadLocal
*/
public static void remove() {
LOGIN_USER_CONTEXT_THREAD_LOCAL.remove();
}
}
```
> 解释一下上述核心的部分代码:
>
> + `ThreadLocal.withInitial(HashMap::new)`:使用 `ThreadLocal``withInitial` 方法,传入一个 `HashMap` 的构造方法引用,初始化每个线程的 `ThreadLocal` 变量时都会创建一个新的 `HashMap` 实例。之所以用 `HashMap` 数据类型,是为了方便后续扩展,如果还有新的数据,只接往里面添加即可。
>
> > **什么是 `ThreadLocal` ?**
> >
> > `ThreadLocal` 是 Java 中用于创建线程局部变量的工具,每个线程都有自己的独立变量副本,不会相互干扰。
>
> + `remove()` : 移除当前线程的 `ThreadLocal` 变量。
>
## 过滤器中设置用户 ID 到上下文
工具类编写完毕后,继续完善 `HeaderUserId2ContextFilter` 过滤器的逻辑,代码如下:
```java
package com.quanxiaoha.xiaohashu.auth.filter;
import com.quanxiaoha.framework.common.constant.GlobalConstants;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* @author: 犬小哈
* @date: 2024/4/15 14:01
* @version: v1.0.0
* @description: 提取请求头中的用户 ID 保存到上下文中,以方便后续使用
**/
@Component
@Slf4j
public class HeaderUserId2ContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 从请求头中获取用户 ID
String userId = request.getHeader(GlobalConstants.USER_ID);
log.info("## HeaderUserId2ContextFilter, 用户 ID: {}", userId);
// 判断请求头中是否存在用户 ID
if (StringUtils.isBlank(userId)) {
// 若为空,则直接放行
chain.doFilter(request, response);
return;
}
// 如果 header 中存在 userId则设置到 ThreadLocal 中
log.info("===== 设置 userId 到 ThreadLocal 中, 用户 ID: {}", userId);
LoginUserContextHolder.setUserId(userId);
try {
chain.doFilter(request, response);
} finally {
// 一定要删除 ThreadLocal ,防止内存泄露
LoginUserContextHolder.remove();
log.info("===== 删除 ThreadLocal userId: {}", userId);
}
}
}
```
> 解释一波:
>
> + 拿到请求头中的用户 ID 后,先判空,若为空,则直接放行;
> + 如果 `Header` 头中存在 `userId`,则设置到 `ThreadLocal` 中;
> + 最后,执行完请求后,通过 `remove();` 方法删除 `ThreadLocal` ,防止内存泄露;
## 重构代码
删除掉 `/logout` 登出接口中的 `@RequestHeader` 注解,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171904865495452)
代码如下:
```less
// 省略...
@PostMapping("/logout")
@ApiOperationLog(description = "账号登出")
public Response<?> logout() {
return userService.logout();
}
// 省略...
```
相对应的,之前`service``logout()` 方法的入参也需要删除掉:
```php
public interface UserService {
// 省略...
/**
* 退出登录
* @return
*/
Response<?> logout();
}
```
在其实现方法中,就可以直接通过封装好的工具方法来获取用户 ID 啦:
```kotlin
// 省略...
/**
* 退出登录
*
* @return
*/
@Override
public Response<?> logout() {
Long userId = LoginUserContextHolder.getUserId();
log.info("==> 用户退出登录, userId: {}", userId);
// 退出登录 (指定用户 ID)
StpUtil.logout(userId);
return Response.success();
}
// 省略...
```
更方便了,有木有~ 重启项目,自测一波,看看 `logout()` 方法中添加的日志,是否能够正确打印当前登录用户的 ID, 如下图所示,一切正常,并且在请求执行完成后,删除了 `ThreadLocal` 变量:
![](https://img.quanxiaoha.com/quanxiaoha/171904902600219)
## 思考
最后,我们来想一下,使用 `ThreadLocal` 来传递上下文,就真的能一劳永逸吗?它有什么样的问题呢?小伙伴们可以先思考一下,下小节中来着重说一说~
## 本小节源码下载
[https://t.zsxq.com/CrriB](https://t.zsxq.com/CrriB)

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` 调试了该接口,选择了一张本地图片进行上传,接口调试通过。

View File

@ -0,0 +1,316 @@
---
title: 用户微服务搭建 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10309.html
publishedTime: 用户微服务搭建
---
![](https://img.quanxiaoha.com/quanxiaoha/171984056913386)
在[上一章](https://www.quanxiaoha.com/column/10304.html) 中,我们已经将**对象存储服务**搭建完成了。也就是说,如下图所示,**修改用户信息接口**所需的下游服务已经搞定了,现在开始搭建上游的**用户服务**。
![](https://img.quanxiaoha.com/quanxiaoha/171940870819538)
## 为什么要单独拆分一个用户服务?
为什么不直接在认证服务中写修改用户信息接口,而是单独拆分一个用户服务呢?优势如下:
+ **单一职责原则SRP**:每个服务只处理特定的职责,代码更清晰,维护更容易。用户服务专门处理用户相关的功能,如用户资料等,而认证服务用户专门处理令牌授权、安全相关的。
+ **独立扩展**:可以根据不同的负载需求独立扩展认证服务和用户服务。例如,登录和认证的请求量可能会比用户管理的请求量高得多。
+ **安全性**:认证服务可以特别加强安全措施,因为它处理敏感的认证信息和令牌管理。
+ **独立开发和部署**:团队可以独立开发和部署这两个服务,减少相互之间的影响,提高开发效率。
+ **灵活性**:可以根据需要选择不同的技术栈和工具。例如,可以使用专门的身份认证框架来构建认证服务。
## 新建用户服务
`xiaohashu` 文件夹上*右键 | New | Module* ,新建用户服务模块:
```sql
xiaohashu/
|- xiaohashu-oss
|- xiaohashu-user
|- 省略...
```
填写相关配置项,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171982292700494)
点击 *Create* 按钮,开始创建服务。等待项目生成完毕后,观察最外层 `pom.xml` 文件的 `<modules>` 节点,会发现节点下已经自动添加好了该模块:
![](https://img.quanxiaoha.com/quanxiaoha/171982303796487)
将用户服务项目的 `/src` 文件夹删除掉,只保留 `pom.xml` 文件,因为我们依然要创建和对象存储服务一样的结构:
```sql
xiaohashu-user/
|- xiaohashu-user-api
|- xiaohashu-user-biz
```
删除完毕后,目前文件结构如下:
![](https://img.quanxiaoha.com/quanxiaoha/171982315864258)
编辑 `xiaohashu-user``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-user</artifactId>
<!-- 项目名称 -->
<name>${project.artifactId}</name>
<!-- 项目描述 -->
<description>用户服务</description>
</project>
```
## 新建 xiaohashu-user-api 子模块
接着,在 `xiaohashu-user` 文件上*右键 | New | Module* , 新建 `xiaohashu-user-api` 子模块。填写相关选项,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171982356767229)
等待模块创建完毕后,将 `App` 类、`/test` 文件夹删除掉:
![](https://img.quanxiaoha.com/quanxiaoha/171982337232329)
编辑其 `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-user</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>xiaohashu-user-api</artifactId>
<name>${project.artifactId}</name>
<description>RPC层, 供其他服务调用</description>
<dependencies>
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-common</artifactId>
</dependency>
</dependencies>
</project>
```
## 新建 xiaohashu-user-biz 子模块
接着开始创建 `xiaohashu-user-biz` 业务模块,填写相关选项,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171982372565578)
模块创建完成后,将 `App` 类,以及单元测试类删除,保留结构如下:
![](https://img.quanxiaoha.com/quanxiaoha/171982453750964)
编辑其 `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-user</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>xiaohashu-user-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>
```
别忘了刷新一下 Maven, 将包下载到本地仓库中。
### 添加 /resources 目录
`/java` 的同级目录下新建一个 `/resources` 资源目录,并将对象存储服务的相关配置文件,直接复制过来,如下:
![](https://img.quanxiaoha.com/quanxiaoha/171982472540135)
`application.yml` 配置文件中,将端口修改一下,防止在本地测试时,和其他服务的端口冲突:
```yaml
server:
port: 8082 # 项目启动的端口
spring:
profiles:
active: dev # 默认激活 dev 本地开发环境
```
日志配置 `logback-spring.xml` 的应用名称修改为 `user` , 其他都不用动,如下:
```php-template
<configuration>
// 省略...
<!-- 应用名称 -->
<property scope="context" name="appName" value="user"/>
// 省略...
</configuration>
```
### 添加启动类
![](https://img.quanxiaoha.com/quanxiaoha/171982485489869)
配置文件创建完成后,创建 Spring Boot 项目的启动类 `XiaohashuUserBizApplication` , 代码如下:
```typescript
package com.quanxiaoha.xiaohashu.user.biz;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class XiaohashuUserBizApplication {
public static void main(String[] args) {
SpringApplication.run(XiaohashuUserBizApplication.class, args);
}
}
```
### 启动服务
点击启动类 `main()` 方法左侧的**启动按钮**,看看服务是否能够正常跑起来,如下所示,没有问题:
![](https://img.quanxiaoha.com/quanxiaoha/171982492814670)
### 注册到 Nacos 上
接下来,我们将用户服务注册到 Nacos 上。编辑 `xiaohashu-user-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>
// 省略...
```
依赖添加完毕后,刷新一下 Maven 。在 `/resources/config` 文件夹下,创建 `bootstrap.yml` 配置文件:
![](https://img.quanxiaoha.com/quanxiaoha/171982508129775)
配置如下:
```yaml
spring:
application:
name: xiaohashu-user # 应用名称
profiles:
active: dev # 默认激活 dev 本地开发环境
cloud:
nacos:
discovery:
enabled: true # 启用服务发现
group: DEFAULT_GROUP # 所属组
namespace: xiaohashu # 命名空间
server-addr: 127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
```
> **TIP** : 将应用名称修改为 `xiaohashu-user` , 其他不用动。
重启用户服务,观察控制台日志,看看服务是否正常跑起来了,若运行正常,再登陆到 Nacos 控制台,确认一下服务是否注册成功,如下所示,说明认证服务的项目就大致搭建好了:
![](https://img.quanxiaoha.com/quanxiaoha/171982513938670)
## 小作业:全局异常捕获器
最后,给小伙伴们留个小作业,自行将**全局异常捕获器**添加到刚刚创建完成的用户服务中,结构如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171982737491222)
记得将异常状态码枚举类的**服务标识**修改为 `USER` , 如下:
![](https://img.quanxiaoha.com/quanxiaoha/171982743242830)
遇到问题的小伙伴,也可以下载本小节的源码进行比对,或者在星球提问哟~
## 本小节源码下载
[https://t.zsxq.com/RdbqW](https://t.zsxq.com/RdbqW)

View File

@ -0,0 +1,283 @@
---
title: 用户微服务搭建2 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10310.html
publishedTime: null
---
本小节中,我们继续完善**用户微服务**,将 Druid 数据连接池与 MyBatis 整合进来,因为**用户信息修改接口**最终的目的是,将修改的用户信息保存到数据库中,需要操作数据库。
## 整合 Druid 与 MyBatis
> **TIP**: 关于如何整合阿里 Druid 连接池,在[《4.5节》](https://www.quanxiaoha.com/column/10263.html) 已经详细说过了,本小节在用户微服务中,再次演示一波,后续如果还有新创建的服务,就不再做演示了,相信小伙伴们也已经非常熟悉了。
### 添加依赖
编辑 `xiaohashu-user-biz` 模块的 `pom.xml` 文件,添加数据源相关依赖,如下:
```php-template
// 省略...
<!-- 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>
<!-- Druid 数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
</dependency>
// 省略...
```
依赖添加完毕后,刷新一波 Maven 依赖,将包下载到本地仓库中。
### 添加配置
#### Mybatis 配置
编辑 `application.yml` 本地开发环境配置文件, 配置 MyBatis `xml` 文件路径:
```yaml
mybatis:
# MyBatis xml 配置文件路径
mapper-locations: classpath:/mapper/**/*.xml
```
#### Druid 数据源配置
编辑 `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: A2qT03X7KlL4v/F2foD6kV/Ch9gpNBWOh1qoCywanjv1AsI7f9x3iAyR9NkUKeV+FMo+halCTzy5Llbk2VOrVQ== # 数据库密码
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
connectionProperties: config.decrypt=true;config.decrypt.key=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIaJmhsfN14oM+bghiOfARP6YgIiArekviyAOEa9Dt8spf4W38kSJShGs0NkzT3btqJB0O2o0X/yfVE8kqme1jMCAwEAAQ==
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:
config:
enabled: true
stat:
enabled: true
log-slow-sql: true # 开启慢 sql 记录
slow-sql-millis: 2000 # 若执行耗时大于 2s则视为慢 sql
merge-sql: true
wall: # 防火墙
config:
multi-statement-allow: true
logging:
level:
com.quanxiaoha.xiaohashu.user.biz.domain.mapper: debug
```
## 整合代码生成器
接下来,为用户服务整合代码生成器 Maven 插件。编辑 `xiaohashu-user-biz` 模块的 `pom.xml` 文件,添加对应插件依赖,如下:
```php-template
<build>
<plugins>
// 省略...
<!-- 代码生成器 -->
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
```
### 预创建相关文件夹
![](https://img.quanxiaoha.com/quanxiaoha/171991171616679)
插件添加好后,分别创建 `/domain/dataobject` 实体类包、`/domain/mapper` 接口包、`/resources/mapper` 映射文件文件夹,如上图所示。
### 创建配置文件
并在 `/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.user.biz.domain.dataobject"
targetProject="src/main/java"/>
<!-- Mapper xml 文件存放路径-->
<sqlMapGenerator targetPackage="mapper"
targetProject="src/main/resources"/>
<!-- Mapper 接口存放路径 -->
<javaClientGenerator type="XMLMAPPER" targetPackage="com.quanxiaoha.xiaohashu.user.biz.domain.mapper"
targetProject="src/main/java"/>
<!-- 需要生成的表-实体类 -->
<table tableName="t_user" domainObjectName="UserDO"
enableCountByExample="false"
enableUpdateByExample="false"
enableDeleteByExample="false"
enableSelectByExample="false"
selectByExampleQueryId="false"/>
</context>
</generatorConfiguration>
```
> **TIP** : 每个服务的 `DO` 实体类、`mapper` 接口、`xml` 映射文件的包路径会有所区别,小伙伴们可以直接从别的服务中,复制过来然后稍作修改一下。
### 生成代码
点击右侧栏中 `xiaohashu-user-biz` 模块中 `Plugins` 插件下的 `generate` , 开始自动生成相关代码,操作如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171991114310957)
生成完毕后,就能在刚刚创建的文件夹中,看到生成好的代码啦,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171991120810978)
修改一下 `UserDO` 实体类,删除掉 `get/set` 方法,并添加上 Lombok 相关注解,以及将 `Date` 日期类修改为 `LocalDateTime` Java8 新的日期类, 最终代码如下:
```typescript
package com.quanxiaoha.xiaohashu.user.biz.domain.dataobject;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
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 LocalDate 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;
}
```
## 添加 @MapperScan 注解
在启动类头上添加 `@MapperScan` 注解,并配置 `mapper` 接口的包路径,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.quanxiaoha.xiaohashu.user.biz.domain.mapper")
public class XiaohashuUserBizApplication {
public static void main(String[] args) {
SpringApplication.run(XiaohashuUserBizApplication.class, args);
}
}
```
## 测试一波
最后,**重启用户服务**,观察控制台日志,若打印了 `Init DruidDataSource` 信息,说明 Druid 连接池整合成功了。
![](https://img.quanxiaoha.com/quanxiaoha/171991188229749)
## 本小节源码下载
[https://t.zsxq.com/yOBnR](https://t.zsxq.com/yOBnR)

View File

@ -0,0 +1,576 @@
---
title: 用户信息修改接口开发 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10311.html
publishedTime: null
---
![](https://img.quanxiaoha.com/quanxiaoha/171999123822199)
本小节中,我们正式进入**用户信息修改接口**的开发工作。
## 接口定义
### 接口地址
```bash
POST /user/update
```
> **注意**: 因为此接口涉及到文件上传,所以需要使用**表单提交**。即 `Content-Type``application/x-www-form-urlencoded`
### 入参
| 字段名 | 含义 |
| --- | --- |
| `avatar` | 头像 |
| `birthday` | 生日,如 2024-12-12 |
| `nickname` | 昵称 |
| `xiaohashuId` | 小哈书 ID |
| `sex` | 性别 |
| `backgroundImg` | 背景图 |
| `introduction` | 个人介绍 |
### 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": null
}
```
## 创建入参 VO 实体类
![](https://img.quanxiaoha.com/quanxiaoha/171999140718355)
本小节中涉及添加的类,大致如上图所示。首先,创建 `/model/vo` 包,并新建 `UpdateUserInfoReqVO` 入参实体类,代码如下:
```swift
package com.quanxiaoha.xiaohashu.user.biz.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:17
* @version: v1.0.0
* @description: 修改用户信息
**/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UpdateUserInfoReqVO {
/**
* 头像
*/
private MultipartFile avatar;
/**
* 昵称
*/
private String nickname;
/**
* 小哈书 ID
*/
private String xiaohashuId;
/**
* 性别
*/
private Integer sex;
/**
* 生日
*/
private LocalDate birthday;
/**
* 个人介绍
*/
private String introduction;
/**
* 背景图
*/
private MultipartFile backgroundImg;
}
```
> **TIP** : 由于用户修改信息时,可能只修改某一项,所以单独在业务层进行条件判断,而不是每个字段都添加校验注解。
## 封装参数校验工具类
在小红书中,修改用户信息时,也会有相关校验,如昵称,必须是设置 `2-24个字符不能使用@《/等字符` 等等,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171999216009327)
接下来,我们来封装一个参数校验工具类。编辑 `xiaohashu-common` 公共模块,在 `/utils` 包下新建 `ParamUtils` 工具类:
![](https://img.quanxiaoha.com/quanxiaoha/171997081316976)
代码如下:
```java
package com.quanxiaoha.framework.common.util;
import java.util.regex.Pattern;
/**
* @author: 犬小哈
* @date: 2024/4/15 16:42
* @version: v1.0.0
* @description: 参数条件工具
**/
public final class ParamUtils {
private ParamUtils() {
}
// ============================== 校验昵称 ==============================
// 定义昵称长度范围
private static final int NICK_NAME_MIN_LENGTH = 2;
private static final int NICK_NAME_MAX_LENGTH = 24;
// 定义特殊字符的正则表达式
private static final String NICK_NAME_REGEX = "[!@#$%^&*(),.?\":{}|<>]";
/**
* 昵称校验
*
* @param nickname
* @return
*/
public static boolean checkNickname(String nickname) {
// 检查长度
if (nickname.length() < NICK_NAME_MIN_LENGTH || nickname.length() > NICK_NAME_MAX_LENGTH) {
return false;
}
// 检查是否含有特殊字符
Pattern pattern = Pattern.compile(NICK_NAME_REGEX);
return !pattern.matcher(nickname).find();
}
// ============================== 校验小哈书号 ==============================
// 定义 ID 长度范围
private static final int ID_MIN_LENGTH = 6;
private static final int ID_MAX_LENGTH = 15;
// 定义正则表达式
private static final String ID_REGEX = "^[a-zA-Z0-9_]+$";
/**
* 小哈书 ID 校验
*
* @param xiaohashuId
* @return
*/
public static boolean checkXiaohashuId(String xiaohashuId) {
// 检查长度
if (xiaohashuId.length() < ID_MIN_LENGTH || xiaohashuId.length() > ID_MAX_LENGTH) {
return false;
}
// 检查格式
Pattern pattern = Pattern.compile(ID_REGEX);
return pattern.matcher(xiaohashuId).matches();
}
/**
* 字符串长度校验
*
* @param str
* @param length
* @return
*/
public static boolean checkLength(String str, int length) {
// 检查长度
if (str.isEmpty() || str.length() > length) {
return false;
}
return true;
}
}
```
> 定义了 3 个后续业务层需要用到的校验方法,主要是使用正则表达式来校验:
>
> + 昵称,对长度与特殊字符的进行校验;
> + 小哈书 ID仅能使用英文、数字、下划线;
> + 个人介绍,需校验字符串长度,不能多于 100 字;
## 定义错误枚举
接着,编辑 `ResponseCodeEnum` , 添加一些等会要用到的错误枚举值,代码如下:
```java
package com.quanxiaoha.xiaohashu.user.biz.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 {
// 省略..
// ----------- 业务异常状态码 -----------
NICK_NAME_VALID_FAIL("USER-20001", "昵称请设置2-24个字符不能使用@《/等特殊字符"),
XIAOHASHU_ID_VALID_FAIL("USER-20002", "小哈书号请设置6-15个字符仅可使用英文必须、数字、下划线"),
SEX_VALID_FAIL("USER-20003", "性别错误"),
INTRODUCTION_VALID_FAIL("USER-20004", "个人简介请设置1-100个字符"),
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
另外,在 `/eumns` 包下,再创建一个性别枚举,并封装一个校验性别的方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.user.biz.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 SexEnum {
WOMAN(0),
MAN(1);
private final Integer value;
public static boolean isValid(Integer value) {
for (SexEnum loginTypeEnum : SexEnum.values()) {
if (Objects.equals(value, loginTypeEnum.getValue())) {
return true;
}
}
return false;
}
}
```
## 添加依赖
编辑 `xiaohashu-user-biz``pom.xml` 文件,添加以下之前封装好的业务组件依赖:
```php-template
// 省略...
<!-- 业务接口日志组件 -->
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-spring-boot-starter-biz-operationlog</artifactId>
</dependency>
<!-- 上下文组件 -->
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-spring-boot-starter-biz-context</artifactId>
</dependency>
<!-- Jackson 组件 -->
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-spring-boot-starter-jackson</artifactId>
</dependency>
// 省略...
```
依赖添加完毕后,记得刷新一下 Maven 依赖。
## 编写 service 业务层
创建 `/service` 包,并新建 `UserService` 接口,声明一个**更新用户信息方法**,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
public interface UserService {
/**
* 更新用户信息
*
* @param updateUserInfoReqVO
* @return
*/
Response<?> updateUserInfo(UpdateUserInfoReqVO updateUserInfoReqVO);
}
```
`/service` 包下创建 `/impl` 实现类包,并创建上述接口的实现类,代码如下:
```java
package com.quanxiaoha.xiaohashu.user.biz.service.impl;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.framework.common.util.ParamUtils;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.user.biz.enums.SexEnum;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
@Resource
private UserDOMapper userDOMapper;
/**
* 更新用户信息
*
* @param updateUserInfoReqVO
* @return
*/
@Override
public Response<?> updateUserInfo(UpdateUserInfoReqVO updateUserInfoReqVO) {
UserDO userDO = new UserDO();
// 设置当前需要更新的用户 ID
userDO.setId(LoginUserContextHolder.getUserId());
// 标识位:是否需要更新
boolean needUpdate = false;
// 头像
MultipartFile avatarFile = updateUserInfoReqVO.getAvatar();
if (Objects.nonNull(avatarFile)) {
// todo: 调用对象存储服务上传文件
}
// 昵称
String nickname = updateUserInfoReqVO.getNickname();
if (StringUtils.isNotBlank(nickname)) {
Preconditions.checkArgument(ParamUtils.checkNickname(nickname), ResponseCodeEnum.NICK_NAME_VALID_FAIL.getErrorMessage());
userDO.setNickname(nickname);
needUpdate = true;
}
// 小哈书号
String xiaohashuId = updateUserInfoReqVO.getXiaohashuId();
if (StringUtils.isNotBlank(xiaohashuId)) {
Preconditions.checkArgument(ParamUtils.checkXiaohashuId(xiaohashuId), ResponseCodeEnum.XIAOHASHU_ID_VALID_FAIL.getErrorMessage());
userDO.setXiaohashuId(xiaohashuId);
needUpdate = true;
}
// 性别
Integer sex = updateUserInfoReqVO.getSex();
if (Objects.nonNull(sex)) {
Preconditions.checkArgument(SexEnum.isValid(sex), ResponseCodeEnum.SEX_VALID_FAIL.getErrorMessage());
userDO.setSex(sex);
needUpdate = true;
}
// 生日
LocalDate birthday = updateUserInfoReqVO.getBirthday();
if (Objects.nonNull(birthday)) {
userDO.setBirthday(birthday);
needUpdate = true;
}
// 个人简介
String introduction = updateUserInfoReqVO.getIntroduction();
if (StringUtils.isNotBlank(introduction)) {
Preconditions.checkArgument(ParamUtils.checkLength(introduction, 100), ResponseCodeEnum.INTRODUCTION_VALID_FAIL.getErrorMessage());
userDO.setIntroduction(introduction);
needUpdate = true;
}
// 背景图
MultipartFile backgroundImgFile = updateUserInfoReqVO.getBackgroundImg();
if (Objects.nonNull(backgroundImgFile)) {
// todo: 调用对象存储服务上传文件
}
if (needUpdate) {
// 更新用户信息
userDO.setUpdateTime(LocalDateTime.now());
userDOMapper.updateByPrimaryKeySelective(userDO);
}
return Response.success();
}
}
```
> 解释一下代码逻辑:
>
> + 初始化一个 `UserDO` , 用于更新数据库;
> + 通过 `LoginUserContextHolder.getUserId()` 工具类,从上下文中拿到当前需要更新的用户 ID;
> + 初始化一个 `needUpdate` 标识位,用于判断最终是否需要更新数据库;
> + 然后,就是各项用户信息的校验与设置了。针对头像、背景图需要调用对象存储服务上传文件,先写个 `todo` , 后面小节会讲如何通过 `Feign` 进行服务间调用。
> + 最后,如果 `needUpdate` 字段为 `true` , 说明用户更新了某些信息,则最终更新数据库。
## 新建 controller 层
新建 `/controller` 包,并新建 `UserController` 控制器,添加 `/user/update` 接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz.controller;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author: 犬小哈
* @date: 2024/4/4 13:22
* @version: v1.0.0
* @description: 用户
**/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Resource
private UserService userService;
/**
* 用户信息修改
*
* @param updateUserInfoReqVO
* @return
*/
@PostMapping(value = "/update", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Response<?> updateUserInfo(@Validated UpdateUserInfoReqVO updateUserInfoReqVO) {
return userService.updateUserInfo(updateUserInfoReqVO);
}
}
```
> **注意**:请勿添加切面日志注解 `@ApiOperationLog`此接口包含文件流上传Jackson 序列化会有问题!!!
## 调整服务 Url
因为我们又单独拆分了一个用户服务,用户服务的接口以 `/user/**` 为前缀,是很合适的。但是之前认证服务中,也有用到 `/user/**` 为前缀,就显得不太规范了。这里重构一下,编辑 `xiaohashu-auth` 认证服务中的 `UserController` , 将 `/user` 前缀删除掉,区分开来,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/171997349463132)
同时,网关服务中的相关接口校验也需要适配一下, 将 `/user` 删除:
![](https://img.quanxiaoha.com/quanxiaoha/171997366773427)
## 配置网关路由转发
编辑 `xiaohashu-gateway` 网关中的 `application.yml` 文件,将用户服务的路由转发也添加上,等会直接通过网关转发来测试接口:
![](https://img.quanxiaoha.com/quanxiaoha/171997532716228)
配置项如下:
```yaml
spring:
cloud:
gateway:
routes:
// 省略...
- id: user
uri: lb://xiaohashu-user
predicates:
- Path=/user/**
filters:
- StripPrefix=1
```
## 自测一波
最后,我们来测试一下。将认证服务、网关服务、用户服务同时跑起来,如下所示:
![](https://img.quanxiaoha.com/quanxiaoha/171997543725918)
测试一波 `/user/update` 接口,因为是走网关转发,所以实际的地址为 `localhost:8000/user/user/update` , 如下:
![](https://img.quanxiaoha.com/quanxiaoha/171997586709494)
> **注意**:以表单的方式请求,同时携带上 `Token` 令牌。
勾选 `form-data` 选项,并填写接口所需入参:
![](https://img.quanxiaoha.com/quanxiaoha/171997608944386)
点击发送按钮可以看到服务端响应成功。打开数据库确认一下该用户信息是否更新成功了如下OK, 接口调试通过~
![](https://img.quanxiaoha.com/quanxiaoha/171997605270847)
## 本小节源码下载
[https://t.zsxq.com/oev24](https://t.zsxq.com/oev24)

View File

@ -0,0 +1,230 @@
---
title: 引入 OpenFeign 组件:实现服务间调用 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10312.html
publishedTime: null
---
![](https://img.quanxiaoha.com/quanxiaoha/172009835898022)
在[上小节](https://www.quanxiaoha.com/column/10311.html) 中,我们已经完成了**用户信息修改接口**的大致逻辑,但是,调用对象存储服务上传图片这块的代码还没写。本小节中,就将引入 `OpenFeign` 组件,先解决服务间调用问题。
## 什么是 OpenFeign ?
OpenFeign 是一个声明式的 HTTP 客户端,它使得我们可以非常方便地调用 HTTP 接口。OpenFeign 是 Netflix 开源的 Feign 项目的扩展,旨在与 Spring Cloud 紧密集成。它通过注解来定义接口,类似于 Spring MVC 的控制器,使得开发者可以专注于业务逻辑,而不需要关注 HTTP 请求的具体实现。
## 提供一个测试接口
接下来,我们就来实际感受一下 `OpenFeign` 的魅力。在 `xiaohashu-oss` 对象存储服务,为其添加一个测试接口,用于等会测试使用。首先,在 `xiaohashu-oss-biz` 模块的 `pom.xml` 文件中,添加接口日志组件,用于等会打印接口调用日志,以便观察:
```php-template
<!-- 业务接口日志组件 -->
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-spring-boot-starter-biz-operationlog</artifactId>
</dependency>
<!-- Jackson 组件 -->
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-spring-boot-starter-jackson</artifactId>
</dependency>
```
`/controller` 包下新建 `TestFeignController` 测试控制器,添加一个 `/file/test` 接口:
![](https://img.quanxiaoha.com/quanxiaoha/172007786793622)
代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.biz.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author: 犬小哈
* @date: 2024/4/4 13:22
* @version: v1.0.0
* @description: Feign 测试接口
**/
@RestController
@RequestMapping("/file")
@Slf4j
public class TestFeignController {
@PostMapping(value = "/test")
@ApiOperationLog(description = "Feign 测试接口")
public Response<?> test() {
return Response.success();
}
}
```
## 添加 OpenFeign 相关依赖
测试接口添加完毕后,编辑 `xiaohashu-oss-api` 模块的 `pom.xml` 文件,添加 `OpenFeign` 相关依赖,如下:
```php-template
<!-- OpenFeign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- 负载均衡 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
```
> **TIP** : `OpenFeign` 通常和 `loadbalancer` 接口负载均衡组件搭配使用。
## 声明 FeignClient 客户端接口
![](https://img.quanxiaoha.com/quanxiaoha/172007864240764)
`xiaohashu-oss-api` 模块中,新建 `/api` 包,专门用于放置 `FeignClient` 客户端接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.api;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.oss.constant.ApiConstants;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
/**
* @author: 犬小哈
* @date: 2024/4/13 22:56
* @version: v1.0.0
* @description: TODO
**/
@FeignClient(name = ApiConstants.SERVICE_NAME)
public interface FileFeignApi {
String PREFIX = "/file";
@PostMapping(value = PREFIX + "/test")
Response<?> test();
}
```
> + `@FeignClient` 是用来标记这个接口是一个 Feign 客户端的注解。
> + `name = ApiConstants.SERVICE_NAME` 指定了这个 Feign 客户端所调用的服务名称。这个名称通常是在注册中心(如 Eureka 或 Nacos中注册的服务名称。
> + `String PREFIX = "/file";` : 定义了一个前缀常量,用于接口中 URI 的路径前缀。
> + `@PostMapping` 注解标记这个方法将执行一个 HTTP POST 请求。
> + `value = PREFIX + "/test"` 指定了这个 POST 请求的路径,这里是 `"/file/test"`
再新建一个 `/constant` 常量包,创建 `ApiConstants` 常量类,定义一个**服务名称**常量,即本身注册到 Nacos 中的服务名称:
```java
package com.quanxiaoha.xiaohashu.oss.constant;
/**
* @author: 犬小哈
* @date: 2024/4/13 23:23
* @version: v1.0.0
* @description: TODO
**/
public interface ApiConstants {
/**
* 服务名称
*/
String SERVICE_NAME = "xiaohashu-oss";
}
```
到这里,对象存储服务就将 `/file/test` 接口的 `Feign` 客户端,封装到 `api` 模块中了,有其他服务想要调用对象存储服务的 `/file/test` 接口,只需引入 `xiaohashu-oss-api` 模块即可。
## 统一管理
编辑小哈书最外层的 `pom.xml` 文件,将 `xiaohashu-oss-api`模块统一管理管理起来:
```php-template
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-oss-api</artifactId>
<version>${revision}</version>
</dependency>
```
然后,在需要调用对象存储服务的 `xiaohashu-user-biz` 模块的 `pom.xml` 文件中,引入该依赖:
```php-template
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-oss-api</artifactId>
</dependency>
```
依赖引入后,记得刷新 Maven 依赖。
## 启用 Feign 客户端
编辑 `XiaohashuUserBizApplication` 启动类,添加 `@EnableFeignClients` 注解,以启用引入的 `xiaohashu-oss-api` 模块中定义好的 `Feign` 客户端:
![](https://img.quanxiaoha.com/quanxiaoha/172007931933934)
代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
// 省略...
@EnableFeignClients(basePackages = "com.quanxiaoha.xiaohashu")
public class XiaohashuUserBizApplication {
// 省略...
}
```
## 服务间调用
以上工作完成后,就可以在用户服务中,注入 `FileFeignApi` 客户端,来调用对象存储服务的 `/file/test` 接口了:
![](https://img.quanxiaoha.com/quanxiaoha/172008001043013)
在更新用户信息逻辑方法中,添加代码如下:
```scss
// 省略...
@Resource
private FileFeignApi fileFeignApi;
// 省略...
if (Objects.nonNull(avatarFile)) {
// todo: 调用对象存储服务上传文件
fileFeignApi.test();
}
// 省略...
```
## 测试一波
重启**用户服务**和**对象存储服务**,通过 `Apipost` 工具调用一下用户信息修改接口,看看用户服务是否能够调用的通 `oss` 服务。观察对象存储服务的控制台日志,如下图所示,成功打印了接口出入参日志,说明调用成功了:
![](https://img.quanxiaoha.com/quanxiaoha/172008043123643)
## 本小节源码下载
[https://t.zsxq.com/ONGpj](https://t.zsxq.com/ONGpj)

View File

@ -0,0 +1,307 @@
---
title: OpenFeign 支持表单请求 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10313.html
publishedTime: null
---
[上小节](https://www.quanxiaoha.com/column/10312.html) 中,我们已经完成了 `OpenFeign` 组件的整合工作,并测试通过了一个简单的服务间调用。但是,针对于图片上传,普通的调用方式还不行,需要额外配置表单提交。本小节中,我们就将完成这一步,并将调用对象存储服务上传图片的代码补充完整。
## 添加依赖
首先,编辑项目最外层的 `pom.xml` , 声明 `feign-form` 依赖以及版本号,代码如下:
```php-template
<properties>
// 省略...
<feign-form.version>3.8.0</feign-form.version>
</properties>
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<!-- Feign 表单提交 -->
<dependency>
<groupId>io.github.openfeign.form</groupId>
<artifactId>feign-form</artifactId>
<version>${feign-form.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
> **feign-form 依赖是干嘛的?**
>
> `feign-form` 是一个 Feign 扩展库,专门用于处理表单数据的编码。它提供了一些增强功能,使 Feign 客户端能够更方便地处理表单提交和文件上传等操作。
接着,编辑 `xiaohashu-oss-api` 模块的 `pom.xml`, 引入如下两个依赖:
![](https://img.quanxiaoha.com/quanxiaoha/172015958829441)
```php-template
// 省略...
<!-- Feign 表单提交 -->
<dependency>
<groupId>io.github.openfeign.form</groupId>
<artifactId>feign-form-spring</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign.form</groupId>
<artifactId>feign-form</artifactId>
</dependency>
// 省略...
```
刷新一下 Maven , 将包下载到本地仓库中。
## 添加表单配置类
![](https://img.quanxiaoha.com/quanxiaoha/172015998737715)
`xiaohashu-oss-api` 模块下创建 `/config` 包,并新建一个 `FeignFormConfig` 表单配置类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.config;
import feign.codec.Encoder;
import feign.form.spring.SpringFormEncoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author: 犬小哈
* @date: 2024/4/14 13:57
* @version: v1.0.0
* @description: TODO
**/
@Configuration
public class FeignFormConfig {
@Bean
public Encoder feignFormEncoder() {
return new SpringFormEncoder();
}
}
```
> + `SpringFormEncoder` 是 Feign 提供的一个编码器,用于处理表单提交。它将对象编码为表单数据格式(如 `application/x-www-form-urlencoded``multipart/form-data`),以便在 HTTP 请求中使用。
## 修改客户端接口
编辑 `FileFeignApi` 客户端接口,将上小节中,用于测试的接口删除掉,改为实际要调用的 `/file/upload` 图片上传接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.oss.api;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.oss.config.FeignFormConfig;
import com.quanxiaoha.xiaohashu.oss.constant.ApiConstants;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@FeignClient(name = ApiConstants.SERVICE_NAME, configuration = FeignFormConfig.class)
public interface FileFeignApi {
String PREFIX = "/file";
/**
* 文件上传
*
* @param file
* @return
*/
@PostMapping(value = PREFIX + "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
Response<?> uploadFile(@RequestPart(value = "file") MultipartFile file);
}
```
> + 在 `@FeignClient` 注解中,配置上刚刚声明的表单提交配置类;
## 添加错误状态码枚举
`Feign` 客户端接口搞定后,回到 `xiaohashu-user-biz` 用户服务中,为 `ResponseCodeEnum` 添加如下两个枚举值,等会业务层上传文件方法中,判断是否上传成功需要用到:
```java
package com.quanxiaoha.xiaohashu.user.biz.enums;
import com.quanxiaoha.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// 省略...
UPLOAD_AVATAR_FAIL("USER-20005", "头像上传失败"),
UPLOAD_BACKGROUND_IMG_FAIL("USER-20006", "背景图上传失败"),
;
// 省略...
}
```
## 封装 Feign 调用
![](https://img.quanxiaoha.com/quanxiaoha/172017198353857)
编辑 `xiaohashu-user-biz` 模块,创建一个 `/rpc` 包,统一放置服务间调用代码,并新建 `OssRpcService` 类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz.rpc;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.oss.api.FileFeignApi;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
/**
* @author: 犬小哈
* @date: 2024/4/13 23:29
* @version: v1.0.0
* @description: 对象存储服务调用
**/
@Component
public class OssRpcService {
@Resource
private FileFeignApi fileFeignApi;
public String uploadFile(MultipartFile file) {
// 调用对象存储服务上传文件
Response<?> response = fileFeignApi.uploadFile(file);
if (!response.isSuccess()) {
return null;
}
// 返回图片访问链接
return (String) response.getData();
}
}
```
> 将调用对象存储服务上传文件的逻辑,单独封装一层 `service`,并声明一个 `uploadFile()` 方法。方法中,若调用对象存储服务成功,则返回图片链接;否则,返回 `null`
## 补充 service 层
编辑 `UserServiceImpl` , 注入 `OssRpcService`, 并将头像上传、背景头上传的代码补充完整,代码如下:
```java
package com.quanxiaoha.xiaohashu.user.biz.service.impl;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
import com.quanxiaoha.framework.common.exception.BizException;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.framework.common.util.ParamUtils;
import com.quanxiaoha.xiaohashu.oss.api.FileFeignApi;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.user.biz.enums.SexEnum;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.rpc.OssRpcService;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
@Resource
private OssRpcService ossRpcService;
/**
* 更新用户信息
*
* @param updateUserInfoReqVO
* @return
*/
@Override
public Response<?> updateUserInfo(UpdateUserInfoReqVO updateUserInfoReqVO) {
// 省略...
// 头像
MultipartFile avatarFile = updateUserInfoReqVO.getAvatar();
if (Objects.nonNull(avatarFile)) {
String avatar = ossRpcService.uploadFile(avatarFile);
log.info("==> 调用 oss 服务成功上传头像url{}", avatar);
// 若上传头像失败,则抛出业务异常
if (StringUtils.isBlank(avatar)) {
throw new BizException(ResponseCodeEnum.UPLOAD_AVATAR_FAIL);
}
userDO.setAvatar(avatar);
needUpdate = true;
}
// 省略...
// 背景图
MultipartFile backgroundImgFile = updateUserInfoReqVO.getBackgroundImg();
if (Objects.nonNull(backgroundImgFile)) {
String backgroundImg = ossRpcService.uploadFile(backgroundImgFile);
log.info("==> 调用 oss 服务成功上传背景图url{}", backgroundImg);
// 若上传背景图失败,则抛出业务异常
if (StringUtils.isBlank(backgroundImg)) {
throw new BizException(ResponseCodeEnum.UPLOAD_BACKGROUND_IMG_FAIL);
}
userDO.setBackgroundImg(backgroundImg);
needUpdate = true;
}
// 省略...
}
}
```
## 自测一波
最后,**重启用户服务与对象存储服务**,再次测试一波用户信息修改接口,看看这次能否实现真正意义上,把图片上传成功,并将图片链接保存到数据库中:
![](https://img.quanxiaoha.com/quanxiaoha/172016090909496)
如上图所示,服务端提示操作成功,观察用户服务的控制台日志,可以看到成功打印了对象存储服务返回的图片链接:
![](https://img.quanxiaoha.com/quanxiaoha/172016118939265)
打开数据库,确认一下 `t_user` 用户表中,头像和背景图数据是否更新成功了,如下所示,一切正常~
![](https://img.quanxiaoha.com/quanxiaoha/172016126485824)
## 本小节源码下载
[https://t.zsxq.com/f62YV](https://t.zsxq.com/f62YV)

View File

@ -0,0 +1,153 @@
---
title: Feign 请求拦截器:实现 userId 服务间透传 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10314.html
publishedTime: null
---
在进入本小节之前,先来思考一个问题,如果说,我们想在下游服务中获取当前请求对应的用户 ID , 比如,修改用户信息接口,会调用对象存储微服务,在对象存储微服务中,通过前面封装的上下文组件获取当前用户 ID能拿的到吗
## 车祸现场
让我们来实测一下,编辑 `xiaohashu-oss-biz` 模块的 `pom.xml` , 添加上下文组件依赖:
```php-template
<!-- 上下文组件 -->
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaoha-spring-boot-starter-biz-context</artifactId>
</dependency>
```
刷新 Maven 依赖,然后,在文件上传接口中打印一行测试日志,如下:
![](https://img.quanxiaoha.com/quanxiaoha/172026439378298)
```less
log.info("当前用户 ID: {}", LoginUserContextHolder.getUserId());
```
**重启对象存储服务**,请求一波用户信息修改接口,观察对象存储服务的控制台日志,会看到无法获取当前用户 ID, 值为 `null` , 如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172026452770563)
## 为什么获取不到?
原因是网关路由转发到服务时,网关层会设置用户 ID 到请求头中,但是,服务之间调用是通过 `Feign` 来完成的,是没有经过网关的,下游服务再去从请求头中获取用户 ID自然是拿不到。
> **如何解决上面这个问题呢?**
>
> 可以为 `Feign` 单独配置一个请求拦截器,在调用其他服务时,将当前用户 ID 添加到请求头中,保证下游服务也能够通过上下文组件拿到用户 ID。
## 配置 Feign 请求拦截器
我们将这个功能,一并放到上下文组件中。首先,编辑 `xiaoha-spring-boot-starter-biz-context` 上下文组件的 `pom.xml`, 添加 `feign` 核心依赖:
```php-template
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
</dependency>
```
接着,在上下文组件中,创建一个 `/interceptor` 包,用于放置拦截器,并新建 `FeignRequestInterceptor` 请求拦截器:
![](https://img.quanxiaoha.com/quanxiaoha/172026395418566)
代码如下:
```java
package com.quanxiaoha.framework.biz.context.interceptor;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
import com.quanxiaoha.framework.common.constant.GlobalConstants;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import java.util.Objects;
/**
* @author: 犬小哈
* @date: 2024/4/14 17:48
* @version: v1.0.0
* @description: Feign 请求拦截器
**/
@Slf4j
public class FeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
// 获取当前上下文中的用户 ID
Long userId = LoginUserContextHolder.getUserId();
// 若不为空,则添加到请求头中
if (Objects.nonNull(userId)) {
requestTemplate.header(GlobalConstants.USER_ID, String.valueOf(userId));
log.info("########## feign 请求设置请求头 userId: {}", userId);
}
}
}
```
> + 自定义 `Feign` 请求拦截器需继承自 `RequestInterceptor` 接口;
> + 在 `apply()` 方法中,先通过 `LoginUserContextHolder.getUserId();` 拿到当前请求对应的用户 ID;
> + 判断若不为空,则将用户 ID 添加到请求头中,以便下游服务再次获取;
## 自动化配置
然后,在 `/config` 包下,新建一个 `FeignContextAutoConfiguration` 自动化配置类:
![](https://img.quanxiaoha.com/quanxiaoha/172026412373099)
代码如下:
```kotlin
package com.quanxiaoha.framework.biz.context.config;
import com.quanxiaoha.framework.biz.context.interceptor.FeignRequestInterceptor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
/**
* @author: 犬小哈
* @date: 2024/4/15 13:50
* @version: v1.0.0
* @description: Feign 请求拦截器自动配置
**/
@AutoConfiguration
public class FeignContextAutoConfiguration {
@Bean
public FeignRequestInterceptor feignRequestInterceptor() {
return new FeignRequestInterceptor();
}
}
```
> + 将刚刚自定义的 `FeignRequestInterceptor` 请求拦截器,自动注入到 Spring 容器中。
同时,别忘了在 `org.springframework.autoconfigure.AutoConfiguration.imports` 文件中,添加上 `FeignContextAutoConfiguration` 的完整包路径,如下所示:
![](https://img.quanxiaoha.com/quanxiaoha/172026476016369)
```lua
com.quanxiaoha.framework.biz.context.config.FeignContextAutoConfiguration
```
## 重新打包
组件更新完毕后,还需要执行 `clean install` 命令,将其打包到本地仓库中,以便其他服务能够使用最新版本的组件:
![](https://img.quanxiaoha.com/quanxiaoha/172026462072871)
## 自测一波
重新刷一下 Maven 依赖,并**重启用户服务和对象存储服务**。再次测试用户信息修改接口,调用成功后,观察对象存储控制台日志,就可以看到,下游服务也成功拿到了当前用户 ID 了,自此,*网关 - 上游服务 - 下游服务* 透传用户 ID 整个链路就打通了。
![](https://img.quanxiaoha.com/quanxiaoha/172026483982549)
## 本小节源码下载
[https://t.zsxq.com/MVY2u](https://t.zsxq.com/MVY2u)

View File

@ -0,0 +1,679 @@
---
title: 代码重构:用户注册功能 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10315.html
publishedTime: null
---
本小节中,我们来重构一下**登录接口**中的自动注册用户部分代码,将这部分功能放置到**用户服务**中,以接口的形式提供出来,由认证服务通过 `Feign` 去调用,而不是自己去操作 `t_user` 用户表。
## 认证服务与用户服务的职责划分
*为什么要拆分到用户服务中呢?* 先来明确划分一下,两个服务各自应该负责的部分:
+ **认证服务**负责处理所有与用户身份验证相关的操作。它主要确保用户身份的真实性和合法性管理用户登录、登出以及令牌token的生成与密码加密等。
+ **用户服务**:负责用户的管理操作,包括用户的注册、个人信息更新与查询、用户角色和权限等等。它主要处理与用户数据相关的业务逻辑。
既然如此,那么之前登录接口中,操作用户表的相关操作,都是不规范的,需要单独剥离到用户服务中,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172042943265030)
从项目结构角度看,下面标注的所有代码,都需要移动到用户服务中,而认证服务中的,在抽离完毕后,需要最终删除掉:
![](https://img.quanxiaoha.com/quanxiaoha/172042963364083)
明确目标后,这就来逐步重构一下项目。
## 复制 DO 类、Mapper 接口、XML 映射文件
首先,将认证服务中的相关 `DO` 数据库实体类、`Mapper` 接口、`XML` 映射文件,复制一份到用户服务中,如下图标注所示:
![](https://img.quanxiaoha.com/quanxiaoha/172043011758068)
复制到 `xiaohashu-user-biz` 模块中的 `/domain/dataobject``/domain/mapper` 包下后,文件会有爆红情况,将包路径改正确即可解决。
![](https://img.quanxiaoha.com/quanxiaoha/172043024502575)
复制过去的 `XML` 映射文件,同样有爆红情况,也是包路径的问题,将路径中的 `auth` 修改为 `user.biz` 即可解决,注意,每个文件都需要改一下,改完后,重新刷新一下 Maven 依赖。
## 重构启动任务
基础工作完成后,首先将认证服务中 `/runner` 包下的 `PushRolePermissions2RedisRunner` 推送角色权限数据到 Redis 启动任务,转移到用户服务中。
### 整合 Redis
该功能需要操作 Redis, 还需要为用户服务整合一波 Redis, 编辑 `xiaohashu-user-biz` 模块的 `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>
```
### 添加配置
编辑 `xiaohashu-user-biz` 模块中的 `application.yml` 文件,添加 Redis 相关配置项:
```yaml
spring:
// 省略...
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 # 连接池中的最大空闲连接
```
### 添加相关类
![](https://img.quanxiaoha.com/quanxiaoha/172043121157296)
将认证服务中的 Redis 配置类、常量类,以及 `Runner` 启动任务类,如上图标注所示,复制到用户服务中。复制过去后,无需改动代码,仅需将 `RedisKeyConstants` 修改一下,只保留用户服务需要用到的常量定义,验证码归认证服务管,这部分代码都删除掉,代码如下:
```typescript
package com.quanxiaoha.xiaohashu.user.biz.constant;
/**
* @author: 犬小哈
* @date: 2024/5/21 15:04
* @version: v1.0.0
* @description: TODO
**/
public class RedisKeyConstants {
/**
* 小哈书全局 ID 生成器 KEY
*/
public static final String XIAOHASHU_ID_GENERATOR_KEY = "xiaohashu.id.generator";
/**
* 用户角色数据 KEY 前缀
*/
private static final String USER_ROLES_KEY_PREFIX = "user:roles:";
/**
* 角色对应的权限集合 KEY 前缀
*/
private static final String ROLE_PERMISSIONS_KEY_PREFIX = "role:permissions:";
/**
* 用户对应的角色集合 KEY
* @param userId
* @return
*/
public static String buildUserRoleKey(Long userId) {
return USER_ROLES_KEY_PREFIX + userId;
}
/**
* 构建角色对应的权限集合 KEY
* @param roleKey
* @return
*/
public static String buildRolePermissionsKey(String roleKey) {
return ROLE_PERMISSIONS_KEY_PREFIX + roleKey;
}
}
```
至此,推送角色权限数据到 Redis 启动任务就重构完毕了。小伙伴记得重启用户服务,自测一下,看看代码运行有没有问题,确保没有问题了,再进行下一步的重构,一步一个脚印。另外,功能测试正常,就可以将已经转移过来的相关代码删除掉了,现在这部分工作,已经交接给用户服务来做了。
## 重构用户注册
接着,开始重构用户注册部分。
### 添加 xiaohashu-user-api 依赖
编辑项目最外层的 `pom.xml`, 添加如下依赖,将 `xiaohashu-user-api` 模块的依赖统一管理起来:
```php-template
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-user-api</artifactId>
<version>${revision}</version>
</dependency>
```
编辑 `xiaohashu-auth` 认证服务的 `pom.xml`, 添加该模块,因为需要调用用户服务:
```php-template
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-user-api</artifactId>
</dependency>
```
记得刷新一波 Maven 依赖。
### 添加 DTO 实体类
> **什么是 DTO 实体类?和 VO 实体类有什么区别?**
>
> + `DTO` 是一个用于数据传输的对象,通常服务之间传递数据时,会定义一个 `DTO` 实体类。
> + `VO` 是一个用于表现层的对象,通常用来封装页面数据或前端显示的数据。它用于控制层和视图层之间的数据传递。
![](https://img.quanxiaoha.com/quanxiaoha/172043163697864)
编辑 `xiaohashu-user-api` 模块,创建 `/dto/req` 包,用于存放请求相关的 `DTO` 实体类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.dto.req;
import com.quanxiaoha.framework.common.validator.PhoneNumber;
import jakarta.validation.constraints.NotBlank;
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 RegisterUserReqDTO {
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@PhoneNumber
private String phone;
}
```
### biz 模块添加 api 模块依赖
为了能够在 `xiaohashu-user-biz` 模块中使用 `RegisterUserReqDTO` 实体类,还需要编辑小哈书最外层的 `pom.xml` 文件,先将 `xiaohashu-user-api` 模块的版本号管理起来:
```php-template
<!-- 统一依赖管理 -->
<dependencyManagement>
<dependencies>
// 省略...
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-user-api</artifactId>
<version>${revision}</version>
</dependency>
// 省略...
</dependencies>
</dependencyManagement>
```
接着,编辑 `xiaohashu-user-biz` 模块的 `pom.xml`, 添加 `xiaohashu-user-api` 依赖:
```php-template
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-user-api</artifactId>
</dependency>
```
### 编写 service 业务层
回到 `xiaohashu-user-biz` 模块中,编辑 `UserService` , 声明一个用户注册方法:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
public interface UserService {
// 省略...
/**
* 用户注册
*
* @param registerUserReqDTO
* @return
*/
Response<Long> register(RegisterUserReqDTO registerUserReqDTO);
}
```
在其实现类中,实现上述方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.user.biz.service.impl;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
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.framework.common.util.ParamUtils;
import com.quanxiaoha.xiaohashu.oss.api.FileFeignApi;
import com.quanxiaoha.xiaohashu.user.biz.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.user.biz.constant.RoleConstants;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.UserRoleDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.UserRoleDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.user.biz.enums.SexEnum;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.rpc.OssRpcService;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
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.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
@Resource
private UserRoleDOMapper userRoleDOMapper;
@Resource
private RoleDOMapper roleDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
// 省略...
/**
* 用户注册
*
* @param registerUserReqDTO
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Response<Long> register(RegisterUserReqDTO registerUserReqDTO) {
String phone = registerUserReqDTO.getPhone();
// 先判断该手机号是否已被注册
UserDO userDO1 = userDOMapper.selectByPhone(phone);
log.info("==> 用户是否注册, phone: {}, userDO: {}", phone, JsonUtils.toJsonString(userDO1));
// 若已注册,则直接返回用户 ID
if (Objects.nonNull(userDO1)) {
return Response.success(userDO1.getId());
}
// 否则注册新用户
// 获取全局自增的小哈书 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);
RoleDO roleDO = roleDOMapper.selectByPrimaryKey(RoleConstants.COMMON_USER_ROLE_ID);
// 将该用户的角色 ID 存入 Redis 中
List<String> roles = new ArrayList<>(1);
roles.add(roleDO.getRoleKey());
String userRolesKey = RedisKeyConstants.buildUserRoleKey(userId);
redisTemplate.opsForValue().set(userRolesKey, JsonUtils.toJsonString(roles));
return Response.success(userId);
}
}
```
> 直接从认证服务中,将用户注册部分的代码复制过来。复制过来后,需要做如下改动:
>
> + `selectByPhone` 爆红问题:这个方法还没定义,同样的,从认证服务中复制过来;
>
> + 另外,为用户 `insert()` 对应的映射文件中的方法,添加 `useGeneratedKeys="true" keyProperty="id"`,以获取插入后记录的主键 ID;
>
### 编写 controller
编辑 `UserController` 控制器,添加 `/user/register` 用户注册接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
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/4/4 13:22
* @version: v1.0.0
* @description: 用户
**/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Resource
private UserService userService;
// 省略...
// ===================================== 对其他服务提供的接口 =====================================
@PostMapping("/register")
@ApiOperationLog(description = "用户注册")
public Response<Long> register(@Validated @RequestBody RegisterUserReqDTO registerUserReqDTO) {
return userService.register(registerUserReqDTO);
}
}
```
### 编写 Feign 客户端接口
接口编写完毕后,就需要在 `api` 模块中,将 Feign 客户端接口封装好,以便其他服务使用。首先,编辑 `xiaohashu-user-api` 模块的 `pom.xml` 文件,添加 OpenFeign 相关依赖,如下:
```php-template
<!-- OpenFeign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- 负载均衡 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
```
创建 Feign 客户端接口,以及常量类,如下图所示:
![](https://img.quanxiaoha.com/quanxiaoha/172043241126573)
代码如下:
```java
package com.quanxiaoha.xiaohashu.user.constant;
/**
* @author: 犬小哈
* @date: 2024/4/13 23:23
* @version: v1.0.0
* @description: TODO
**/
public interface ApiConstants {
/**
* 服务名称
*/
String SERVICE_NAME = "xiaohashu-user";
}
```
```kotlin
package com.quanxiaoha.xiaohashu.user.api;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.constant.ApiConstants;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* @author: 犬小哈
* @date: 2024/4/13 22:56
* @version: v1.0.0
* @description: TODO
**/
@FeignClient(name = ApiConstants.SERVICE_NAME)
public interface UserFeignApi {
String PREFIX = "/user";
/**
* 用户注册
*
* @param registerUserReqDTO
* @return
*/
@PostMapping(value = PREFIX + "/register")
Response<Long> registerUser(@RequestBody RegisterUserReqDTO registerUserReqDTO);
}
```
### auth 服务引入 api 模块
然后,编辑 `xiaohashu-auth` 认证服务的 `pom.xml`, 引入 `xiaohashu-user-api` 模块:
```php-template
<dependency>
<groupId>com.quanxiaoha</groupId>
<artifactId>xiaohashu-user-api</artifactId>
</dependency>
```
并编辑 `auth` 服务的启动类,添加 `@EnableFeignClients` 注解:
```kotlin
package com.quanxiaoha.xiaohashu.auth;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
// 省略...
@MapperScan("com.quanxiaoha.xiaohashu.auth.domain.mapper")
@EnableFeignClients(basePackages = "com.quanxiaoha.xiaohashu")
public class XiaohashuAuthApplication {
public static void main(String[] args) {
SpringApplication.run(XiaohashuAuthApplication.class, args);
}
}
```
### 封装 rpc 调用层
![](https://img.quanxiaoha.com/quanxiaoha/172043336614996)
在认证服务中创建 `/rpc` 调用层,并创建一个 `UserRpcService` 类,将调用用户服务的注册接口封装起来,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.rpc;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.api.UserFeignApi;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @date: 2024/4/13 23:29
* @version: v1.0.0
* @description: 用户服务
**/
@Component
public class UserRpcService {
@Resource
private UserFeignApi userFeignApi;
/**
* 用户注册
*
* @param phone
* @return
*/
public Long registerUser(String phone) {
RegisterUserReqDTO registerUserReqDTO = new RegisterUserReqDTO();
registerUserReqDTO.setPhone(phone);
Response<Long> response = userFeignApi.registerUser(registerUserReqDTO);
if (!response.isSuccess()) {
return null;
}
return response.getData();
}
}
```
### 添加全局枚举
编辑 `ResponseCodeEnum` 全局枚举类,添加一个登录失败的异常状态枚举,代码如下:
```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 {
// 省略...
LOGIN_FAIL("AUTH-20005", "登录失败"),
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
### 调用用户服务
![](https://img.quanxiaoha.com/quanxiaoha/172043378452192)
最后,编辑认证服务的 `UserServiceImpl` 类,将用户注册重构成调用用户服务,代码如下:
```java
@Resource
private UserRpcService userRpcService;
// 省略...
// RPC: 调用用户服务,注册用户
Long userIdTmp = userRpcService.registerUser(phone);
// 若调用用户服务,返回的用户 ID 为空,则提示登录失败
if (Objects.isNull(userIdTmp)) {
throw new BizException(ResponseCodeEnum.LOGIN_FAIL);
}
userId = userIdTmp;
// 省略...
```
至此,用户注册功能的重构工作就搞定了,别忘了,自己再测试一波用户注册接口,保证重构完成后,功能也是正常的。
## 本小节源码下载
[https://t.zsxq.com/JwvyZ](https://t.zsxq.com/JwvyZ)

View File

@ -0,0 +1,461 @@
---
title: 代码重构:手机号查询用户信息接口开发 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10316.html
publishedTime: null
---
[上小节](https://www.quanxiaoha.com/column/10315.html) 中,我们已经将自动注册用户的代码,重构到了用户服务中,以接口的形式提供出来。本小节中,将为用户服务添加一个新的接口——**手机号查询用户信息**,如下图所示,并将账号密码登录中标注的部分,重构成通过 `Feign` 来查询用户信息。
![](https://img.quanxiaoha.com/quanxiaoha/172051236611826)
## 接口定义
手机号查询用户信息接口定义如下:
### 接口地址
```bash
POST /user/findByPhone
```
### 入参
```json
{
"phone": "18011119108", // 手机号
}
```
### 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": {
"id": 1, // 用户 ID
"password": "xxx" // 密码
}
}
```
## 创建出入参 DTO
编辑 `xiaohashu-user-api` 模块,添加接口对应的出入参 `DTO` 实体类:
![](https://img.quanxiaoha.com/quanxiaoha/172052607560219)
### 入参
```kotlin
package com.quanxiaoha.xiaohashu.user.dto.req;
import com.quanxiaoha.framework.common.validator.PhoneNumber;
import jakarta.validation.constraints.NotBlank;
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 FindUserByPhoneReqDTO {
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@PhoneNumber
private String phone;
}
```
### 出参
```kotlin
package com.quanxiaoha.xiaohashu.user.dto.resp;
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 FindUserByPhoneRspDTO {
private Long id;
private String password;
}
```
## 添加全局枚举
编辑 `ResponseCodeEnum` 异常码枚举类,添加一个**该用户不存在**的枚举值,等会业务层判空需要用到:
```java
package com.quanxiaoha.xiaohashu.user.biz.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 {
// 省略...
USER_NOT_FOUND("USER-20007", "该用户不存在"),
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMessage;
}
```
## 编写 service 业务层
接着,编辑 `xiaohashu-user-biz` 模块中的 `UserService` 接口,声明一个**根据手机号查询用户信息**的方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.user.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
public interface UserService {
// 省略...
/**
* 根据手机号查询用户信息
*
* @param findUserByPhoneReqDTO
* @return
*/
Response<FindUserByPhoneRspDTO> findByPhone(FindUserByPhoneReqDTO findUserByPhoneReqDTO);
}
```
接着,在接口的实现类中实现上述方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.user.biz.service.impl;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
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.framework.common.util.ParamUtils;
import com.quanxiaoha.xiaohashu.oss.api.FileFeignApi;
import com.quanxiaoha.xiaohashu.user.biz.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.user.biz.constant.RoleConstants;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.UserRoleDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.UserRoleDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.user.biz.enums.SexEnum;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.rpc.OssRpcService;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
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.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
/**
* 根据手机号查询用户信息
*
* @param findUserByPhoneReqDTO
* @return
*/
@Override
public Response<FindUserByPhoneRspDTO> findByPhone(FindUserByPhoneReqDTO findUserByPhoneReqDTO) {
String phone = findUserByPhoneReqDTO.getPhone();
// 根据手机号查询用户信息
UserDO userDO = userDOMapper.selectByPhone(phone);
// 判空
if (Objects.isNull(userDO)) {
throw new BizException(ResponseCodeEnum.USER_NOT_FOUND);
}
// 构建返参
FindUserByPhoneRspDTO findUserByPhoneRspDTO = FindUserByPhoneRspDTO.builder()
.id(userDO.getId())
.password(userDO.getPassword())
.build();
return Response.success(findUserByPhoneRspDTO);
}
}
```
## 编写 controller 层
然后,编辑 `UserController` 控制器,添加 `/user/findByPhone` 手机号查询用户信息接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
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/4/4 13:22
* @version: v1.0.0
* @description: 用户
**/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Resource
private UserService userService;
// 省略...
// ===================================== 对其他服务提供的接口 =====================================
// 省略...
@PostMapping("/findByPhone")
@ApiOperationLog(description = "手机号查询用户信息")
public Response<FindUserByPhoneRspDTO> findByPhone(@Validated @RequestBody FindUserByPhoneReqDTO findUserByPhoneReqDTO) {
return userService.findByPhone(findUserByPhoneReqDTO);
}
}
```
## 封装 Feign 客户端接口
接口创建完成后,编辑 `xiaohashu-user-api` 模块,封装 Feign 客户端接口,以便其他服务直接使用:
```kotlin
package com.quanxiaoha.xiaohashu.user.api;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.constant.ApiConstants;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* @author: 犬小哈
* @date: 2024/4/13 22:56
* @version: v1.0.0
* @description: TODO
**/
@FeignClient(name = ApiConstants.SERVICE_NAME)
public interface UserFeignApi {
String PREFIX = "/user";
// 省略...
/**
* 根据手机号查询用户信息
*
* @param findUserByPhoneReqDTO
* @return
*/
@PostMapping(value = PREFIX + "/findByPhone")
Response<FindUserByPhoneRspDTO> findByPhone(@RequestBody FindUserByPhoneReqDTO findUserByPhoneReqDTO);
}
```
## 封装 rpc 调用
回到 `xiaohashu-auth` 认证服务中,编辑 `UserRpcService` ,将**根据手机号查询用户信息**封装成一个方法,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.auth.rpc;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.api.UserFeignApi;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @date: 2024/4/13 23:29
* @version: v1.0.0
* @description: 用户服务
**/
@Component
public class UserRpcService {
@Resource
private UserFeignApi userFeignApi;
// 省略...
/**
* 根据手机号查询用户信息
*
* @param phone
* @return
*/
public FindUserByPhoneRspDTO findUserByPhone(String phone) {
FindUserByPhoneReqDTO findUserByPhoneReqDTO = new FindUserByPhoneReqDTO();
findUserByPhoneReqDTO.setPhone(phone);
Response<FindUserByPhoneRspDTO> response = userFeignApi.findByPhone(findUserByPhoneReqDTO);
if (!response.isSuccess()) {
return null;
}
return response.getData();
}
}
```
## 重构 service 层
封装完成后,编辑认证服务的 `UserServiceImpl` 类,将之前的代码重构成通过 RPC 调用,来查询用户信息,如下图标注所示:
![](https://img.quanxiaoha.com/quanxiaoha/172051328322873)
代码如下:
```java
// 省略...
case PASSWORD: // 密码登录
String password = userLoginReqVO.getPassword();
// RPC: 调用用户服务,通过手机号查询用户
FindUserByPhoneRspDTO findUserByPhoneRspDTO = userRpcService.findUserByPhone(phone);
// 判断该手机号是否注册
if (Objects.isNull(findUserByPhoneRspDTO)) {
throw new BizException(ResponseCodeEnum.USER_NOT_FOUND);
}
// 拿到密文密码
String encodePassword = findUserByPhoneRspDTO.getPassword();
// 匹配密码是否一致
boolean isPasswordCorrect = passwordEncoder.matches(password, encodePassword);
// 如果不正确,则抛出业务异常,提示用户名或者密码不正确
if (!isPasswordCorrect) {
throw new BizException(ResponseCodeEnum.PHONE_OR_PASSWORD_ERROR);
}
userId = findUserByPhoneRspDTO.getId();
break;
// 省略...
```
## 自测一波
至此,通过手机号查询用户信息功能就重构完成了,最后,别忘了重新自测一波账号/密码登录接口,保证代码修改过后,功能也是正常的。
![](https://img.quanxiaoha.com/quanxiaoha/172051400455656)
## 本小节源码下载
[https://t.zsxq.com/opihp](https://t.zsxq.com/opihp)

View File

@ -0,0 +1,506 @@
---
title: 代码重构:密码更新接口 - 犬小哈专栏
url: https://www.quanxiaoha.com/column/10317.html
publishedTime: null
---
本小节中,我们继续来重构认证服务,将通过操作数据库来更新密码的部分,改写为通过 `Feign` 调用用户服务来实现。
## 接口定义
首先,需要为用户服务添加密码更新接口,以便认证服务调用。
### 接口地址
```bash
POST /user/password/update
```
### 入参
```json
{
"encodePassword": "xxx", // 加密后的密码
}
```
### 出参
```json
{
"success": true,
"message": null,
"errorCode": null,
"data": null
}
```
## 创建 DTO 实体类
![](https://img.quanxiaoha.com/quanxiaoha/172059710676952)
编辑 `xiaohashu-user-api` 模块,在 `/dto/req` 包下创建 `UpdateUserPasswordReqDTO` 入参实体类,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.dto.req;
import jakarta.validation.constraints.NotBlank;
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 UpdateUserPasswordReqDTO {
@NotBlank(message = "密码不能为空")
private String encodePassword;
}
```
## 编辑 service 业务层
编辑 `xiaohashu-user-biz` 模块中的 `UserService` 接口,声明一个密码更新方法,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz.service;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.UpdateUserPasswordReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
public interface UserService {
// 省略...
/**
* 更新密码
*
* @param updateUserPasswordReqDTO
* @return
*/
Response<?> updatePassword(UpdateUserPasswordReqDTO updateUserPasswordReqDTO);
}
```
接着,在其实现类中实现上述方法,代码如下:
```java
package com.quanxiaoha.xiaohashu.user.biz.service.impl;
import com.google.common.base.Preconditions;
import com.quanxiaoha.framework.biz.context.holder.LoginUserContextHolder;
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.framework.common.util.ParamUtils;
import com.quanxiaoha.xiaohashu.oss.api.FileFeignApi;
import com.quanxiaoha.xiaohashu.user.biz.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.user.biz.constant.RoleConstants;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.dataobject.UserRoleDO;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.UserDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.domain.mapper.UserRoleDOMapper;
import com.quanxiaoha.xiaohashu.user.biz.enums.ResponseCodeEnum;
import com.quanxiaoha.xiaohashu.user.biz.enums.SexEnum;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.rpc.OssRpcService;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.UpdateUserPasswordReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
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.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: 用户业务
**/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
/**
* 更新密码
*
* @param updateUserPasswordReqDTO
* @return
*/
@Override
public Response<?> updatePassword(UpdateUserPasswordReqDTO updateUserPasswordReqDTO) {
// 获取当前请求对应的用户 ID
Long userId = LoginUserContextHolder.getUserId();
UserDO userDO = UserDO.builder()
.id(userId)
.password(updateUserPasswordReqDTO.getEncodePassword()) // 加密后的密码
.updateTime(LocalDateTime.now())
.build();
// 更新密码
userDOMapper.updateByPrimaryKeySelective(userDO);
return Response.success();
}
}
```
## 编辑 controller 层
业务层代码编写完毕后,在 `UserController` 控制器中添加 `/user/password/update` 密码更新接口,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.biz.controller;
import com.quanxiaoha.framework.biz.operationlog.aspect.ApiOperationLog;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.biz.model.vo.UpdateUserInfoReqVO;
import com.quanxiaoha.xiaohashu.user.biz.service.UserService;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.UpdateUserPasswordReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
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/4/4 13:22
* @version: v1.0.0
* @description: 用户
**/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Resource
private UserService userService;
// 省略...
// ===================================== 对其他服务提供的接口 =====================================
// 省略...
@PostMapping("/password/update")
@ApiOperationLog(description = "密码更新")
public Response<?> updatePassword(@Validated @RequestBody UpdateUserPasswordReqDTO updateUserPasswordReqDTO) {
return userService.updatePassword(updateUserPasswordReqDTO);
}
}
```
## 封装 Feign 客户端接口
回到 `xiaohashu-user-api` 模块,将 Feign 客户端接口封装好,代码如下:
```kotlin
package com.quanxiaoha.xiaohashu.user.api;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.constant.ApiConstants;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.UpdateUserPasswordReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* @author: 犬小哈
* @date: 2024/4/13 22:56
* @version: v1.0.0
* @description: TODO
**/
@FeignClient(name = ApiConstants.SERVICE_NAME)
public interface UserFeignApi {
String PREFIX = "/user";
// 省略...
/**
* 更新密码
*
* @param updateUserPasswordReqDTO
* @return
*/
@PostMapping(value = PREFIX + "/password/update")
Response<?> updatePassword(@RequestBody UpdateUserPasswordReqDTO updateUserPasswordReqDTO);
}
```
## 封装 rpc 层
编辑 `xiaohashu-auth` 认证服务中的 `UserRpcService` , 将 Feign 调用用户服务的密码更新接口,封装成一个方法:
```java
package com.quanxiaoha.xiaohashu.auth.rpc;
import com.quanxiaoha.framework.common.response.Response;
import com.quanxiaoha.xiaohashu.user.api.UserFeignApi;
import com.quanxiaoha.xiaohashu.user.dto.req.FindUserByPhoneReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.RegisterUserReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.req.UpdateUserPasswordReqDTO;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
/**
* @author: 犬小哈
* @date: 2024/4/13 23:29
* @version: v1.0.0
* @description: 用户服务
**/
@Component
public class UserRpcService {
@Resource
private UserFeignApi userFeignApi;
// 省略...
/**
* 密码更新
*
* @param encodePassword
*/
public void updatePassword(String encodePassword) {
UpdateUserPasswordReqDTO updateUserPasswordReqDTO = new UpdateUserPasswordReqDTO();
updateUserPasswordReqDTO.setEncodePassword(encodePassword);
userFeignApi.updatePassword(updateUserPasswordReqDTO);
}
}
```
## 重构 service 层
编辑 `xiaohashu-auth` 认证服务中 `UserServiceImpl` 类的修改密码方法,如下图标注所示,重构成通过 RPC 调用用户服务,来更新用户密码,代码如下:
![](https://img.quanxiaoha.com/quanxiaoha/172059859394828)
```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.biz.context.holder.LoginUserContextHolder;
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.domain.dataobject.UserDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RoleDOMapper;
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.UpdatePasswordReqVO;
import com.quanxiaoha.xiaohashu.auth.model.vo.user.UserLoginReqVO;
import com.quanxiaoha.xiaohashu.auth.rpc.UserRpcService;
import com.quanxiaoha.xiaohashu.auth.service.UserService;
import com.quanxiaoha.xiaohashu.user.dto.resp.FindUserByPhoneRspDTO;
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.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
/**
* @author: 犬小哈
* @date: 2024/4/7 15:41
* @version: v1.0.0
* @description: TODO
**/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
// 省略...
/**
* 修改密码
*
* @param updatePasswordReqVO
* @return
*/
@Override
public Response<?> updatePassword(UpdatePasswordReqVO updatePasswordReqVO) {
// 新密码
String newPassword = updatePasswordReqVO.getNewPassword();
// 密码加密
String encodePassword = passwordEncoder.encode(newPassword);
// RPC: 调用用户服务:更新密码
userRpcService.updatePassword(encodePassword);
return Response.success();
}
}
```
## 重命名与删除代码
目前,我们已经将本应属于用户服务的相关功能,都剥离到了用户服务中, 以接口的形式提供出来。但是,还有一些遗留工作需要处理一下,首先是认证服务中的相关类命名, 如下图标注所示,再叫 `UserXXX` 显得有些不太合适:
![](https://img.quanxiaoha.com/quanxiaoha/172059869198368)
重命名一下,如下:
```rust
UserController -> AuthController
UserService -> AuthService
UserServiceImpl -> AuthServiceImpl
```
> 另外,将之前测试用的 `TestController` 删除掉。
接着,编辑 `application.yml` 配置文件,将测试用的相关配置删除,如下图标注:
![](https://img.quanxiaoha.com/quanxiaoha/172059876896792)
现在,认证服务已经不用操作数据库了,而是通过调用用户服务。所以,删除掉数据库相关的代码,如 `/domain` 包、`/resources/mapper` 映射文件、`generatorConfig.xml` 代码生成器配置:
![](https://img.quanxiaoha.com/quanxiaoha/172059899064714)
同时,将 `application.yml` 配置文件中,`mybatis` 的配置项删除,如下贴出来的部分:
```yaml
mybatis:
# MyBatis xml 配置文件路径
mapper-locations: classpath:/mapper/**/*.xml
```
编辑 `application-dev.yml` 配置文件,将数据源相关的配置删除,只保留下图部分:
![](https://img.quanxiaoha.com/quanxiaoha/172059910867957)
将认证服务启动类头上的 `@MapperScan("com.quanxiaoha.xiaohashu.auth.domain.mapper")` 注解删除,只保留如下:
```typescript
package com.quanxiaoha.xiaohashu.auth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableFeignClients(basePackages = "com.quanxiaoha.xiaohashu")
public class XiaohashuAuthApplication {
public static void main(String[] args) {
SpringApplication.run(XiaohashuAuthApplication.class, args);
}
}
```
还需要将认证服务 `pom.xml` 中,如下依赖、插件都删除:
```php-template
<!-- 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>
<!-- Druid 数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
</dependency>
<build>
<plugins>
// 省略...
<!-- 代码生成器 -->
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
```
最后,`AuthServiceImpl` 业务类中注入的相关 `mapper` 引用、以及编程式事务 `transactionTemplate` 注入,统统删除干净~
## 自测一波
清理完毕后,重启认证服务,保证没有任何问题。然后,再重启用户服务,通过 ApiPost 测试一波密码更新接口,确认接口功能也是正常的。
![](https://img.quanxiaoha.com/quanxiaoha/172059949476099)
至此,整个重构工作就基本结束了。
## 本小节源码下载
[https://t.zsxq.com/MDbVJ](https://t.zsxq.com/MDbVJ)