doc 7 9 10 14

This commit is contained in:
cuijiawang 2025-02-17 11:57:55 +08:00
parent 971bc89ee9
commit 0d814a6e99
36 changed files with 10051 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,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,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)