项目笔记(五)
一、项目概述
本项目是一个面向开发者的 API 平台,提供 API 接口供开发者调用。用户通过注册登录,可以开通接口调用权限,并可以浏览和调用接口。每次调用都会进行统计,用户可以根据统计数据进行分析和优化。管理员可以发布接口、下线接口、接入接口,并可视化接口的调用情况和数据。本项目侧重于后端,涉及多种编程技巧和架构设计层面的知识。
二、本期时间点
三、本期计划(时间点 05:40-07:36)
- 实现统一的用户鉴权、统一的接口调用次数统计(把 API 网关应用到项目中)
- 完善功能
四、前期梳理(时间点 07:48-09:54)
首先,让我们梳理一下现在要完成的任务:
我们需要为 API 接口项目实现统一的用户鉴权和接口调用次数统计。
目前,这两个功能都被直接嵌入到模拟接口项目本身中。例如,当用户调用 getUserNameByPost 接口时,然后根据用户查询用户名。这里的代码中包含了鉴权逻辑和调用次数统计逻辑。正常情况下,调用成功后,我们还需要手动增加调用次数,这是一个业务流程。
如果我们把这个流程写在每个接口里面,这显然是不规范且不合理的做法。
例如,当我们新增一个接口时,我们就不得不再次复制粘贴这段代码。即使我们将其抽出成一个公共方法,其他开发者在提供接口时也必须调用这个公共方法,遵守我们的约定,这会增加一些理解成本。如果其他开发者不按规定操作,可能会导致接口调用次数没有统计,这就会产生安全漏洞,使接口被无限调用。
因此,**我们需要将这个逻辑放在哪里?**放在我们的 API 网关项目里。
上次给大家演示了如何搭建一个简单的 API 网关,使用的技术是 Spring Cloud Gateway。
我们之所以选择它,主要是因为它相比于原本的 Spring Cloud Zuul 网关,性能更高。Spring Cloud Gateway 利用了一些 Netty 和 WebFlux 的响应式编程技术,使得它在性能上有着显著的优势。
此外,Spring Cloud Gateway 对于 Java 开发者来说更加友好。相较于像 Nginx 或者 Kong 这类非全局的网关,Spring Cloud Gateway 的学习成本较低。这使得开发者能够更快上手并且轻松地使用它。
因此,我们通过这个 Spring Cloud Gateway 来为大家演示整个网关的使用方法,希望能够让大家更好地理解和应用 API 网关的相关知识。
五、必备特性(时间点 09:54-18:31)
要用到的特性:
- 路由(转发请求到模拟接口项目)
- 负载均衡(需要用到注册中心)
- 统一鉴权(accesskey, secretKey)
- 跨域
- 统一业务处理(每次请求接口后,接口调用次数 +1)
- 访问控制(黑白名单)
- 发布控制
- 流量染色(记录请求是否为网关来的)
- 接口保护
-
- 限制请求
- 信息脱敏
- 降级(熔断)
- 限流:学习令牌桶算法、学习漏桶算法,学习一下 RedisLimitHandler
- 超时时间
- 限制请求
- 统一日志(记录每次的请求和响应日志)
- 统一文档
进一步说明:
本期我们的主要目标是实现统一的用户鉴权和接口调用统计。提前告诉大家将会用到网关的哪些特性:
- **路由:**肯定会用到路由功能。为什么呢?因为现在用户原本是直接请求模拟接口,然后再进行鉴权。但现在我们要让用户请求我们的网关,然后由网关将请求重定向到模拟接口项目。在这个过程中,鉴权是在网关中完成的,所以路由转发请求肯定是要用到的。
- 负载均衡:(暂时不会演示) 负载均衡需要一个注册中心的支持。使用 Nachos 或者 Eureka 等不同的注册中心可能会稍微麻烦一些。这个可以在学习完 Spring Cloud 微服务之后再来实践,就修改一下服务地址而已。
- **统一鉴权:**还记得我们 API 开放平台项目是如何进行鉴权的吗?我们使用了 accessKey、secretKey 来进行鉴权,并非通过 session 获取用户信息。因为我们的鉴权是请求级别的,而不是基于会话级别的。
- **跨域:**如果需要用到跨域,将在适当的时候进行处理。跨域实现其实相对简单,可以通过配置方式或编程式配置实现,集中在网关层面解决。
- **统一业务处理:**例如给用户的所有查询添加一层缓存。对于我们的项目,我们主要用到两个统一的业务处理,用户鉴权和接口调用次数统计。我们需要记录每次调用接口成功后,调用次数加一,从而统计总共调用次数(在 API 网关中实现)。
- **访问控制:**这里更多指黑白名单的使用。举个例子,如果有用户发现系统漏洞并不断刷接口,一秒钟调用数十万次或数百万次,这显然不行的。这种情况下,我们可以将该用户的IP地址或访问密钥(AKSK)加入黑名单,进行访问控制,类似于防火墙的功能。访问控制也可以涉及不同权限的许可,与鉴权略有相似但也有区别,需要细细品味。
- 发布控制:(暂时不会演示,因为我们已经在之前的直播中讲过并实践过了,像灰度发布这类的访问控制) 免费版和企业版之间也可看作一种访问控制,鉴权一般是判断是否有权限执行某个操作,这些大家可以在上次的直播回放中学习了。
- **流量染色:**就是判断一个请求是否经过我们的网关,若用户绕过网关直接请求模拟接口,相当于绕过防火墙直接进入服务器,因此我们可以记录请求是否来自网关,并在请求中添加相应标识。但是要实现这个功能,还是需要在最终被调用的接口层面进行判断。是否实现流量染色要看实际情况,有利有弊。一般情况下,我们进行流量染色是为了链路追踪。举个例子,你的一个接口可能A调用B,B调用C,C再调用D,形成了一条很长的调用链。为了更好地追踪一个请求的完整过程,我们需要知道每次调用中的请求是从哪里发过来的。因此,我们通常会给请求打上一个唯一的标识,而流量染色的作用就是实现这种功能。尽管这个概念可能有点抽象,但只要在遇到对应的场景时,能想起这个东西就可以了。
- 接口保护:(不需要过于关注,因为会涉及一些微服务的知识,大家学完微服务之后再去深入了解会更好理解) 不过我们可以简单地理解它为一种兜底的策略,或者说是一种防止接口出现问题后的补救方法。举个例子,如果我的接口受到攻击,那么我们可以强制进行降级处理。原本接口是实际调用模拟接口的,但现在我们可以将其降级为直接返回一个失败结果或者给用户一个提示,告知他稍后再试。这样可以提供用户一个稍微友好的提示,也能在一定程度上增强用户体验。总比抛出一串500错误代码或者乱码要好得多。
- 统一日志:记录每次请求和响应的日志。
- 统一文档:(暂时不会演示,因为这个相对来说并不复杂) 以前可能会使用 Swagger 等整合网关的方式来实现统一集合文档。但现在有更简单的方式,不需要使用微服务网关进行整合。我们可以直接引入 Knife4j 文档库,并按照它的方式进行配置即可。所以这部分没什么特别需要讲解的,大家可以参考官方文档。
以上就是我们可能会用到的一些特性。
六、业务逻辑(时间点 18:31-23:10)
业务逻辑:
- 用户发送请求到 API 网关
- 请求日志
- (黑白名单)
- 用户鉴权(判断 ak、sk 是否合法)
- 请求的模拟接口是否存在?
- 请求转发,调用模拟接口
- 响应日志
- 调用成功,接口调用次数 + 1
- 调用失败,返回一个规范的错误码
进一步说明:
让我们先整理一下整个网关需要处理的事情,从用户发送请求开始,到最终调用我们的模拟接口,期间网关需要执行哪些步骤?
来梳理一下业务逻辑,在开始编写代码之前,要将这些步骤理清楚,因为代码不会可以上网查,但是业务逻辑需要我们深入理解,因为不同的业务肯定逻辑是不一样的,所以这是要重点关注和把握的地方,希望大家能养成这样的习惯:
首先用户发送请求到 API 网关,API 网关需要对用户进行鉴权,判断 ak、sk 是否合法。如果密钥合法,接下来要做的是判断请求的模拟接口是否存在。这一步和用户鉴权的先后顺序不用纠结,我们可以根据性能考虑,先判断哪个查库操作比较少,就放在前面,提前拦截掉不合法的请求。
接下来可能需要校验请求的参数是否合法,但是为了节省时间,我们暂时不校验,因为代码逻辑都是类似的。
然后,如果模拟接口存在,我们就调用模拟接口。这里涉及到请求转发,即调用模拟接口。如果调用成功,我们需要记录接口调用次数,需要操作数据库来自增 1。如果调用失败,我们要返回一个规范的错误码,比如自己定义的 5001。
此外,我们还要记录请求日志,在用户发起请求后记录日志,这里也涉及到黑白名单的问题,虽然黑白名单可以省略,但是建议大家都打上请求日志。
最后,调用完成后,我们需要记录响应日志,响应日志可以放在上面或者最下面,具体位置不影响逻辑。
以上就是业务逻辑的流程。
七、后端项目开发(时间点 23:10-02:12:38)
1. 查看官网示例(时间点 23:10-31:52)
我们接着在 yuapi-gateway 项目中实现,上次整了一些断路器,也初始化好了 API 网关的项目。
核心的包就是这个。
如果要用降级相关的包,要引入这个依赖(上次讲过)。
上次给大家布置了一个小作业,在上次的讲解中,我们介绍了声明式或配置式的方式,通过写死配置文件来使用过滤器和路由的方式。然而,光靠这种配置式的方法无法完全满足我们的需求。
举个例子,虽然 Spring Cloud Gateway 内置了很多过滤器,比如添加请求头的过滤器,但是如果我们想要统计接口调用次数这样的功能,它没有现成的过滤器供我们使用。因此,我们需要自己编写代码来实现这个功能。
官方提供了一个示例代码,通过这个示例代码,我们可以学习如何使用编程式的 Spring Cloud Gateway。它给了两个示例代码,先看第一个 —— Spring Cloud Gateway Sample:
进入之后,点击键盘上的 。(句号),跳转到 vscode 在线编辑器:
ps.这样看代码会舒服一点,也不用把它下载到本地。
等它初始化一下。
ps.没进入,就重新回到 github 点 。进入。
进入到 vscode 在线编辑器:

找到 GatewaySampleApplication.java。

查看它的示例代码,这里定义了一个自定义路由定位规则。

这其实就是我们之前演示过的内容,我们在这里定义了两个路由规则(如下图所示):
- 当用户访问百度时,将其重定向到百度的网站;
- 当用户访问于 yupi.icu 时,将其跳转到我们星球的宣传页面。
这些规则的主要目的是定义路由和过滤器。
回到示例代码,这些东西主要定义路由和过滤器。

这里的 filters 对请求做了一些处理,在这里添加了请求头。
所以我们也可以在 filters 以编程式的方式写一些方法、函数,去记录请求信息、过滤请求、添加请求头或响应头等等。
我们现在要定义的过滤器不仅仅针对某个特定的路由,而是针对一组路由进行统一的处理。这是因为我们的接口有很多,它们的地址可能都不相同。我们希望这一组路由都经过同样的业务处理流程,因此我们需要使用一种称为全局过滤器的方法。
来看第二个示例代码 —— Spring Cloud Samples Gateway:
进入之后,点击键盘上的 。(句号),跳转到 vscode 在线编辑器:
进入到 vscode 在线编辑器:
找到 DemogatewayApplication.java,这里例子也是编程式的。

这里定义了路由转发规则,例如将 get 地址转发到 "http://httpbin.org" 这个位置。同时,还设置了一个基于 Redis 的限流器和一个 Spring 的外部过滤链。
**这些配置的作用是什么呢?**它实现了用户鉴权和 CSRF(跨站请求伪造)的控制。如果你感兴趣的话,可以复制这段代码并运行一下,然后访问相应的地址,就可以了解如何使用这些功能。


2. 请求转发,调用模拟接口(时间点 31:52-51:42)
2.1 项目启动测试(时间点 31:52-34:03)
修改模拟接口项目的接口地址,然后以debug模式启动项目。

访问 http://localhost:8123/api/name/get。

然后给它加个 name 参数,访问 http://localhost:8123/api/name/get?name=yupi。
现在这个接口地址是可以访问通的。
2.2 实现请求转发(时间点 34:03-46:36)
我们要定义一个路由的规则,就是符合这个规则的请求转发到咱们的这种模拟接口项目中。
这里我们可以用什么规则呢?可以使用前缀匹配路由器。
假设我们提供的接口地址都是以: http://localhost:8123/api/ 开头,并且都有一个共同的路径前缀 /api。我们可以配置一个前缀匹配路由器,使得所有路径前缀为 /api/** 的请求都被匹配到,并且进行转发到对应的路径 http://localhost:8123/api/**。
举个例子,如果有一个请求网关的地址为:http://localhost:8090/api/name/get?name=yupi,我们可以使用前缀匹配路由器,将这个请求转发到:http://localhost:8123/api/name/get?name=yupi。这样我们就可以统一处理所有以 /api 开头的请求,并将它们转发到后端服务的相应路径上。
我们之前看 Spring Cloud Gateway 官方文档,有很多的断言,有一个叫 前缀匹配断言。
它的作用是可以匹配前缀,并且通过使用 {},可以获取动态的参数。通过 segment 参数拿到用户请求的地址。

我们来试一下,复制配置。

spring: cloud: gateway: routes: - id: path_route uri: https://example.org predicates: - Path=/red/{segment},/blue/{segment}
先把这个注释掉。

然后把配置粘贴到 yuapi-gateway 项目中,修改一下。然后启动项目。

server: port: 8090 spring: cloud: gateway: default-filters: - AddResponseHeader=source, yupi routes: # 定义了一个名为"api_route"的路由规则,该规则将匹配以"/api/"开头的路径,例如"/api/user", # 并将这些请求转发到"http://localhost:8123"这个目标地址 - id: api_route uri: http://localhost:8123 predicates: - Path=/api/{api_url} logging: level: org: springframework: cloud: gateway: trace
把模拟接口项目的地址换一下,然后重启项目。

访问 http://localhost:8090/api/name/?name=yupi,返回响应结果。

重新修改模拟接口项目的地址,重启项目。

修改 yuapi-gateway 项目的断言规则,让它可以匹配多个路径,然后重启项目。
Path=/api/**:表示匹配所有以"/api/"开头的路径,不管后面跟着什么路径片段。

访问 http://localhost:8123/api/name/get?name=yupi,返回响应结果。
访问 [http://localhost:8090/api/name/get?name=yupi(其实是调用了](http://localhost:8090/api/name/get?name=yupi(其实是调用了) 8123),返回响应结果。

现在转发就已经实现了。
2.3 添加全局过滤器(时间点 46:36-51:42)
现在转发已经完成了,我们要添加一些业务逻辑,怎么做呢?刚刚讲过,要使用全局过滤器。
然后去官方文档中找到 全局过滤器。
它的作用是可以自己定义对每个请求的规则,对所有经过网关的请求都执行相同的逻辑。

复制示例代码。

public class CustomGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { log.info("custom global filter"); return chain.filter(exchange); } @Override public int getOrder() { return -1; } }
点击 yuapi-gateway 项目中的 yuapigateway 包,按[Ctrl+V]粘贴。
大家看,这段代码的主要作用是什么呢?它主要实现了一个 GlobalFilter 和 Ordered 接口。
那么,**Ordered 是什么意思呢?**它表示一个执行顺序。

还记得之前的流程图吗?其实 Spring Cloud Gateway 在请求转发到真实接口之前会经过多个过滤器。通过 Ordered 就可以编排这些过滤器的优先级,确定哪个过滤器先拦截,哪个后拦截。这样,我们可以定义多个过滤器并且按照我们需要的顺序进行处理。

官方给我们提供的示例代码,实现一个叫 filter 的方法,这里还有一个日志,我们所有的业务逻辑都可以写在这个 filter 里。

然后把包都引入一下。

package com.yupi.yuapigateway; import lombok.extern.slf4j.Slf4j; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @Slf4j @Component public class CustomGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { log.info("custom global filter"); return chain.filter(exchange); } @Override public int getOrder() { return -1; } }
我们现在写了一个全局过滤器,那我们就要验证它有没有生效,重启项目。

访问 http://localhost:8090/api/name/get?name=yupi,返回响应结果。

控制台也显示了设置的日志信息,说明全局过滤器已经能拦截到我们的请求了。

3. 编写业务逻辑(时间点 51:42-02:12:38)
3.1 请求日志(时间点 51:42-58:37)
接下来我们的业务逻辑写在这里即可。

在 filter 方法里写一个单行注释。

复制业务逻辑,按[Ctrl+V]粘贴在单行注释的后面。

调整一下,加个空格。

首先,用户发送请求到 API 网关,只要请求到达网关,就接收到了,所以第一步已经完成了。
💡 有同学提到了使用 /api/{参数1}/{参数2} 这样的断言方式。
虽然这种断言可以实现,但是如果再增加一个参数,就需要再添加一个参数3,这样的方式相对不够灵活。
直接把请求日志作为第一步,顺便打个注释。

这里请求日志我们怎么拿到呢?🐟的后端模板写过一个权限校验的 AOP,看看这里是怎么拿到请求信息的。

比如说我要输出请求地址、输出请求参数,通过这里,拿到 request 对象。

从 request 里拿到它的请求地址、请求参数、请求头之类的。

同理,Spring Cloud Gateway 里也可以这么做,示例代码给我们提供了两个参数。
- **exchange(路由交换机):**我们所有的请求的信息、响应的信息、响应体、请求体都能从这里拿到。
- **chain(责任链模式):**因为我们的所有过滤器是按照从上到下的顺序依次执行,形成了一个链条。所以这里用了一个
chain,如果当前过滤器对请求进行了过滤后发现可以放行,就要调用责任链中的next方法,相当于直接找到下一个过滤器,这里称为filter。有时候我们需要在责任链中使用 next,而在这里它使用了 filter 来找到下一个过滤器,从而正常地放行请求。

现在我们打一个请求日志,然后看黄色的区域,看看这个 request 能拿到什么:
Cookies、Path(请求路径)、QueryParams(请求参数)、RemoteAddress(远程调用地址)、SslInfo(SSL信息)、Body(请求体)、Headers(请求头)等等。

各种各样的信息都能拿到,现在直接打日志。

重启项目。

访问 http://localhost:8090/api/name/get?name=yupi,返回响应结果。

查看控制台,它拿到了请求路径,说明不需要刚刚的前缀匹配断言器了。
这里的请求来源地址不太对,可能因为我们是本机,需要换一种取地址的方式。
信息都拿到了,我们的请求日志算打上了。

换成 127 试试,访问 127.0.0.1:8090/api/name/get?name=yupi,返回响应结果。

查看控制台,请求来源地址变成 127.0.0.1,换成 127 就能看见 IP。

把 127 先作为我们的请求来源地址,改一下请求来源地址。

3.2 黑白名单(时间点 58:37-01:04:40)
现在有了请求信息,就可以写黑白名单了。
通常情况下,🐟经常使用的是封禁 IP。例如,如果某个远程地址频繁访问,我们可以将其添加到黑名单并拒绝访问。现在我们来试试设置一个规则,如果请求的来源地址不是 127.0.0.1,就拒绝它的访问。
先写一个全局的常量。在这里我们用一个白名单,通常建议在权限管理中尽量使用白名单,少用黑名单。白名单的原则是只允许特定的调用,这样可能会更加安全,或者你可以默认情况下全禁止。

如果它不包含指定的地址,我们就要拒绝它。但怎样进行拒绝呢?
之前我们直接转发了响应值到原本要转发的模拟接口地址,但现在我们要直接拒绝它。通过刚刚用到的 exchange,获取到响应对象,从而控制该响应,直接设置状态码为 403(禁止访问),然后拦截掉。

按下 Ctrl 键,并将鼠标移动到setComplete上,可以看到它的返回值是一个 Mono(如下图所示)。
这个 Mono 其实是响应式编程的一种对象,类似于前端的 Promise。如果你熟悉前端的异步操作,那么可以将 Mono 理解为类似的概念。在这段代码中,我们直接返回了这个 Mono,它并不包含响应参数。相当于我们告诉程序,请求处理完成了,不需要再执行其他操作了。

打个断点,然后重启项目。
现在我们已经知道了如何通过exchange对象获取请求信息,并且控制响应的过程。只要掌握了这两点,接下来的步骤就变得非常简单了。

访问 127.0.0.1:8090/api/name/get?name=yupi,自动跳回项目,按[F8]执行下一步,或点击
按钮。

然后判断是否为 127.0.0.1,按[F8]执行下一步。

它是 127.0.0.1,就直接返回,直接按[F9]执行结束(因为没有下一个断点,所以就结束了)。

可以查看控制台的日志。

试一下,不是 127.0.0.1 访问,把!去掉,打个断点,然后重启项目。

访问 127.0.0.1:8090/api/name/get?name=yupi,自动跳回项目,按[F8]执行下一步。

然后判断是否为 127.0.0.1,按[F8]执行下一步。

不是 127.0.0.1,按[F8]执行下一步。

返回响应结果,按[F9]执行结束。

回到网页查看响应结果,显示了我们设置的状态码 403。

3.3 用户鉴权(时间点 01:04:40-01:12:16)
整完了,把断点取消掉,!重新添加上;
开始写第三步用户鉴权,接下来就要真正的判断 ak、sk 是否合法了。

我们刚刚成功从请求对象中拿到信息。之前我们通过 ak、sk 来进行鉴权的,现在这里也可以用同样的方式进行鉴权判断,复制模拟接口项目的判断。

// 从请求头中获取参数 String accessKey = request.getHeader("accessKey"); String nonce = request.getHeader("nonce"); String timestamp = request.getHeader("timestamp"); String sign = request.getHeader("sign"); String body = request.getHeader("body"); // todo 实际情况应该是去数据库中查是否已分配给用户 if (!accessKey.equals("yupi")){ throw new RuntimeException("无权限"); } // 直接校验如果随机数大于1万,则抛出异常,并提示"无权限" if (Long.parseLong(nonce) > 10000) { throw new RuntimeException("无权限"); } // todo 时间和当前时间不能超过5分钟 // if (timestamp) {} // todo 实际情况中是从数据库中查出 secretKey String serverSign = SignUtils.genSign(body, "abcdefgh"); // 如果生成的签名不一致,则抛出异常,并提示"无权限" if (!sign.equals(serverSign)) { throw new RuntimeException("无权限"); } // todo 调用次数 + 1 String result = "POST 用户名字是" + user.getUsername(); return result;
粘贴到这里。


修改一下, 首先从请求头中获取参数。

这里先这样判断,回头改成是否从数据库中查;
再往下判断随机数要大于一万,否则不抛出异常。

这里写一个方法,用于抛出 403 状态码。

修改之前的判断。

再往下设置时间和当前时间不能超过 5 分钟。

// 首先,获取当前时间的时间戳,以秒为单位 // System.currentTimeMillis()返回当前时间的毫秒数,除以1000后得到当前时间的秒数。 Long currentTime = System.currentTimeMillis() / 1000; // 定义一个常量FIVE_MINUTES,表示五分钟的时间间隔(乘以60,将分钟转换为秒,得到五分钟的时间间隔)。 final Long FIVE_MINUTES = 60 * 5L; // 判断当前时间与传入的时间戳是否相差五分钟或以上 // Long.parseLong(timestamp)将传入的时间戳转换成长整型 // 然后计算当前时间与传入时间戳之间的差值(以秒为单位),如果差值大于等于五分钟,则返回true,否则返回false if ((currentTime - Long.parseLong(timestamp)) >= FIVE_MINUTES) { // 如果时间戳与当前时间相差五分钟或以上,调用handleNoAuth(response)方法进行处理 return handleNoAuth(response); }
这里返回值可以添加额外的信息,等会做。

继续往下判断 secretKey 是否合法,这一步我们需要生成一个签名并将其与预期的签名进行比对。
然后,我们会验证用户传入的签名与我们根据真实密码生成的签名是否一致。

这里用到 SignUtils,它在 yuapi-client-sdk 中,在 pom.xml 中去引入这个依赖。
<dependency> <groupId>com.yupi</groupId> <artifactId>yuapi-client-sdk</artifactId> <version>0.0.1</version> </dependency>
这里改一下 maven 仓库,我的 yuapi-client-sdk 依赖包在设置好的 maven 仓库中。

然后引入依赖。

回到 CustomGlobalFilter.java 中,SignUtils 没有爆红了。

继续修改。

3.4 判断请求的模拟接口是否存在(时间点 01:12:16-01:17:10)
用户鉴权搞定,接下来判断请求的模拟接口是否存在。
这个模拟接口的地址信息是存储在 yuapi-backend 项目的数据库内,因此我们需要从数据库中查询是否有符合要求的接口。我们可以判断严谨一点,比如验证 method 是否匹配,地址是否相等来判断。如果需要更严格的校验,还可以再验证请求参数。建议像这种业务层面的请求参数最好不要放到全局网关中处理,而是在业务层面自己处理。

**为什么这样建议呢?**因为在我们的项目中,并没有引入操作数据库的依赖,如 MyBatis 等。我们之前在 yuapi-backend 项目中引入了这些依赖,因此在网关中再引入的话,可能会造成重复。个人建议是,如果我们已经有现成的访问数据库的方法,或者有可以操作数据库的现成接口,如果那个方法比较复杂,建议使用远程调用的方式调用那个可以操作数据库的项目提供的接口,这样会更方便。
**那怎么调用呢?**有好几种方法,其中包括 HTTP 请求和 RPC。对于 HTTP 请求,可以自己编写客户端,使用一些常见的库比如 HTTPClient、RestTemplate 或者 Feign。而对于 RPC,也有多种实现方式,例如 Java 中可以使用 Dubbo 框架。
先打个 todo,因为涉及到操作数据库,等会整。

3.5 剩余的业务逻辑(时间点 01:17:10-01:24:47)
接下来写请求转发,调用模拟接口,那就是 chain.filter 执行这个操作。

在接口调用成功后,我们需要对接口次数进行累加的操作非常简单,因为之前我们已经写过了,所以只要将这个项目的方法暴露出来,这样在网关里就可以直接调用它,避免重复编写这段逻辑,先留下这段伪代码。

打个 todo 预留一下,就当它已经 + 1。

再往下,如果调用失败,就返回一个规范的错误码,这里再写一个方法,用于返回错误码 500(调用失败)。
然后编写判断。
看样子整个流程通了,但是有没有问题呢?打个断点,重启项目试试。

访问 127.0.0.1:8090/api/name/get?name=yupi,自动跳回项目,按[F9]跳到下一个断点。
ps.肯定会报错,因为我们没有请求头🐶

地址 127.0.0.1,跳到从请求头中获取参数,按[F8]两次。

因为没有请求头,所以获取不到数据,数据为 null。

按[F8],一直按到去数据库中查是否已分配给用户,之前的从请求中获取参数都是为 null。

按[F9]结束执行,回到网页查看。

3.6 后端调用(时间点 01:24:47-01:40:53)
我们现在没有写请求头,它根本拿不到参数,现在直接从 yuapi-backend 项目里调用。
启动 redis、yuapi-backend 项目、yuapi-interface 项目、前端项目。
yuapi-backend 项目:

yuapi-interface 项目:

前端项目:

访问 http://localhost:8000,登录之后,点击查看。

之前我们调用的是 getUsernameByPost,现在我们也改成调用这个,点击调用。

调用成功。

我们去修改调用地址,之前是在客户端写的调用逻辑,去客户端修改一下,找到 yuapi-client-sdk 项目。

这里插个题外话:有 xd 打开 yuapi-client-sdk 项目,发现它的导入爆红了,但是导入也没有写错,重启也没用。

把报错的文件拖到其他包里,然后拖回原来的位置,就不爆红。这个问题,可能是 idea 发癫了🐶。
把 8123 改成 8090。

然后把地址提取成一个常量。

把用到地址的地方都改成新创建的常量。

重新打包,点击右侧菜单栏的 Maven。

打包成功。

回到 yuapi-backend 项目刷新依赖。

确认一下,有没有更新。

更新成功。
这里有个很有意思的现象,它直接把常量帮我们转换了,这是编译器对 final 常量的优化(泛型擦除同理)。

重启 yuapi-backend 项目、yuapi-gateway 项目。


在 yuapi-gateway 项目打个断点。

回到前端页面,编写请求参数,然后点击调用。

自动跳转回 yuapi-gateway 项目,现在请求到网关,也从请求头中拿到了数据。

按[F8]一路往下执行到调用模拟接口。

去 yuapi-interface 项目打个断点,我们的预期是再按[F8]会跳到 yuapi-interface 项目调用模拟接口。

回到 yuapi-gateway 项目,按[F8],发现它直接跳到打印日志,继续按[F8]到 return filter,还是没有调用模拟接口。

按[F9],发现模拟接口才被调用。

再按[F9]结束执行,回到前端页面,显示返回结果。

3.7 自定义响应处理(时间点 01:40:53-02:12:38)
问题:
预期是等模拟接口调用完成,才记录响应日志、统计调用次数。
但现实是 chain.filter 方法立刻返回了,直到 filter 过滤器 return 后才调用了模拟接口。
原因是:chain.filter 是个异步操作,理解为前端的 promise
**解决方案:**利用 response 装饰者,增强原有 response 的处理能力
参考博客:https://blog.csdn.net/qq_19636353/article/details/126759522(以这个为主)
其他参考:
- https://blog.csdn.net/m0_67595943/article/details/124667975
- https://blog.csdn.net/weixin_43933728/article/details/121359727
- https://blog.csdn.net/zx156955/article/details/121670681
- https://blog.csdn.net/qq_39529562/article/details/108911983
进一步说明:
💡 有学习前端的同学,可能已经注意到这是一个异步操作,类似前端的 Promise。这里有一个关键的点需要注意,我们现在使用的这个方法是反应式编程的一种方式,它的返回值是 Mono,表示一个异步操作,类似前端的 Promise。
然而,我们期望的是按照以下步骤执行:
首先执行第五步请求转发,调用模拟接口,然后再执行第六步打印响应日志,这样我们才能知道是否调用成功。但实际情况是,在调用第五步时,并没有真正触发调用模拟接口,而是直接继续往下执行。直到我们返回这个 filter,它才去调用模拟接口。因此,我们不能这样编写代码。

从这个模型图中可以观察到,Spring Cloud Gateway 的处理逻辑是等待所有过滤器都执行完毕后,才会继续向下走,直到最终调用被代理的服务,也就是我们的模拟接口。

我们现在面临一个问题,就是我们希望在调用完远程接口后,再输出响应日志,但由于异步操作的原因,当前的方法等待返回时,远程接口还没有被调用,导致顺序冲突。为了解决这个问题,Spring Cloud Gateway 提供了一个自定义响应处理的装饰器,我们可以查阅相关资料来了解如何使用它。
百度搜索:Spring Cloud Gateway 响应日志,这里有个打印请求响应日志。

看一下。

往下滑,这个 ServerHttpRequest 请求对象里,它可以定义装饰器,然后在这个装饰器里,它做了什么事情呢?大家有没有学过装饰器设计模式,或者叫装饰者设计模式。

装饰者设计模式的作用是在原本的类的基础上对其能力进行增强。
**举例:**我们有一个方法,本来这个 response 对象,它的作用是调用被代理的接口或者转发到模拟接口。
通过使用装饰者模式,我们可以给这个方法外面再包一层,给它添加额外的功能,就像给一个人穿戴武器一样,增加了特殊的能力。比如给他一把剑,他原本是个手无寸铁的普通人,现在有了剑,就能够砍人了。我们可以在这个装饰层中加入我们自己的逻辑,让这个方法按照我们预期的方式进行处理。
这样哪怕它是异步的,当最后执行这个方法时,装饰器也能做额外的事情。这就是装饰者利用装饰增强原有方法的处理能力。

继续浏览帖子,这里写了有个 Response log,给 repsonse 对象添加日志。
ps.代码不要背,因为它没有实际的意义,知道它做的事情就是给 response 对象做了增强即可。

复制代码。


try { ServerHttpResponse originalResponse = exchange.getResponse(); DataBufferFactory bufferFactory = originalResponse.bufferFactory(); HttpStatus statusCode = originalResponse.getStatusCode(); if(statusCode == HttpStatus.OK){ ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) { @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { //log.info("body instanceof Flux: {}", (body instanceof Flux)); if (body instanceof Flux) { Flux<? extends DataBuffer> fluxBody = Flux.from(body); // return super.writeWith(fluxBody.map(dataBuffer -> { byte[] content = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(content); DataBufferUtils.release(dataBuffer);//释放掉内存 // 构建日志 StringBuilder sb2 = new StringBuilder(200); sb2.append("<--- {} {} \n"); List rspArgs = new ArrayList<>(); rspArgs.add(originalResponse.getStatusCode()); //rspArgs.add(requestUrl); String data = new String(content, StandardCharsets.UTF_8);//data sb2.append(data); log.info(sb2.toString(), rspArgs.toArray());//log.info("<-- {} {}\n", originalResponse.getStatusCode(), data); return bufferFactory.wrap(content); })); } else { log.error("<--- {} 响应code异常", getStatusCode()); } return super.writeWith(body); } }; return chain.filter(exchange.mutate().response(decoratedResponse).build()); } return chain.filter(exchange);//降级处理返回数据 }catch (Exception e){ log.error("gateway log exception.\n" + e); return chain.filter(exchange); }
在 yuapi-gateway 项目单独写个方法理解,把代码粘贴进去。

开始理解这段代码。
/** * 处理响应 * * @param exchange * @param chain * @return */ public Mono<Void> handleResponse(ServerWebExchange exchange, GatewayFilterChain chain) { try { // 获取原始的响应对象 ServerHttpResponse originalResponse = exchange.getResponse(); // 获取数据缓冲工厂 DataBufferFactory bufferFactory = originalResponse.bufferFactory(); // 获取响应的状态码 HttpStatus statusCode = originalResponse.getStatusCode(); // 判断状态码是否为200 OK(按道理来说,现在没有调用,是拿不到响应码的,对这个保持怀疑 沉思.jpg) if(statusCode == HttpStatus.OK) { // 创建一个装饰后的响应对象(开始穿装备,增强能力) ServerHttpResponseDecorator decoratedResponse = new ServerHttpResponseDecorator(originalResponse) { // 重写writeWith方法,用于处理响应体的数据 // 这段方法就是只要当我们的模拟接口调用完成之后,等它返回结果, // 就会调用writeWith方法,我们就能根据响应结果做一些自己的处理 @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { log.info("body instanceof Flux: {}", (body instanceof Flux)); // 判断响应体是否是Flux类型 if (body instanceof Flux) { Flux<? extends DataBuffer> fluxBody = Flux.from(body); // 返回一个处理后的响应体 // (这里就理解为它在拼接字符串,它把缓冲区的数据取出来,一点一点拼接好) return super.writeWith(fluxBody.map(dataBuffer -> { // 读取响应体的内容并转换为字节数组 byte[] content = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(content); DataBufferUtils.release(dataBuffer);//释放掉内存 // 构建日志 StringBuilder sb2 = new StringBuilder(200); sb2.append("<--- {} {} \n"); List rspArgs = new ArrayList<>(); rspArgs.add(originalResponse.getStatusCode()); //rspArgs.add(requestUrl); String data = new String(content, StandardCharsets.UTF_8);//data sb2.append(data); log.info(sb2.toString(), rspArgs.toArray());//log.info("<-- {} {}\n", originalResponse.getStatusCode(), data); // 将处理后的内容重新包装成DataBuffer并返回 return bufferFactory.wrap(content); })); } else { log.error("<--- {} 响应code异常", getStatusCode()); } return super.writeWith(body); } }; // 对于200 OK的请求,将装饰后的响应对象传递给下一个过滤器链,并继续处理(设置repsonse对象为装饰过的) return chain.filter(exchange.mutate().response(decoratedResponse).build()); } // 对于非200 OK的请求,直接返回,进行降级处理 return chain.filter(exchange); }catch (Exception e){ // 处理异常情况,记录错误日志 log.error("gateway log exception.\n" + e); return chain.filter(exchange); } }
然后使用这个 handleResponse 方法,把它返回出去,其他的先注释掉。

打个断点,然后重启项目,我们测试一下。


返回前端页面,点击调用。

自动跳转回 yuapi-gateway 项目中,现在拿到缓存数据的工厂,按[F9]跳到下一个断点。

拿到响应码,再往下,按[F8]。

这里面的 writeWith 方法,应该不会被调用,因为这个方法是等调用完转发的接口后才会执行。

按[F9],跳到了模拟接口项目。

按[F9],跳回 yuapi-gateway 项目中,这个时候才调用这里面的方法,所以这个方法就是在我们拿到返回值之后才会处理。

这里打个断点,然后按[F9]。

可以看到前面获取的数据,来看一下这个 sb2 变量。

这个 sb2 就是我们的返回值,只不过还多了一些额外的东西:<--- {} {} (不管.jpg)。

这里的 data 就是真实的响应结果,这个 data 是从 content 拿的。

content 又从哪里来?就是从我们响应的数据流中取到的。

按[F9]结束执行,回到前端页面查看返回结果。

控制台也输出了打印的日志。

不需要这个,删掉。

在这些讨论中,只需要关注一个关键点,即我们如何获取这个返回值?
现在我们已经找到了答案,即content(实际上就是我们原本模拟接口返回的数据)。所以,我们的目的已经实现了。如果你要记录响应对象的话,那就这样:


打个断点,重启项目,看看能不能拿到响应结果。

返回前端页面,点击调用。

自动跳转到 yuapi-gateway 项目中,按[F9]一直执行到响应结果的位置,可以看见拿到响应结果了。

再按[F9]结束执行,查看控制台,输出了响应结果。

把这里。

改成:网关处理响应异常。

然后我们把要对响应之后的操作,全写到这个方法里。
把第七步的代码剪切。

粘贴到这里。

然后修改。

结束咯,下期再见ヾ( ̄▽ ̄)ByeBye
项目分区导航:⬅️ 11-项目笔记(二) | 12-项目笔记(五) | ➡️ 13-项目笔记(六)
💬 评论