本章是阶段四的上半部分,主要包括网关的搭建以及前面一些遗留下来的业务逻辑需要处理。

网关(Gateway)

Spring Cloud Gateway 的底层使用的是响应式编程(WebFlux + Netty),而不是我们之前一直用的 Spring MVC(Tomcat)。 这意味着,在网关模块里,绝对不能引入 spring-boot-starter-web 依赖,否则项目启动会直接报错冲突!

配置网关类

  1. pom.xml (网关依赖)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<dependency>
<groupId>com.accycx</groupId>
<artifactId>api-platform-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

2. application.yml

用来配置网关的端口、Nacos 注册以及核心的路由转发规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server:
port: 8090 # 网关占用独立的 8090 端口

spring:
application:
name: api-platform-gateway
cloud:
nacos:
discovery:
server-addr: localhost:8848
gateway:
routes:
- id: api_route # 路由的 ID,随便起,保持唯一即可
# 当有人访问网关的 /api/** 路径时,网关会自动把请求转发给真实的 interface 服务 (8102)
uri: http://localhost:8102
predicates:
- Path=/api/**
filters:
- StripPrefix=1 # 去掉路径中的 /api 前缀,转发给后端服务
dubbo:
application:
name: api-platform-gateway
registry:
address: nacos://localhost:8848

3. 创建网关启动类

src/main/java/com/accycx/gateway 下新建 ApiGatewayApplication.java

1
2
3
4
5
6
7
8
9
//网关不需要连接数据库,所以排除数据库自动配置,防止报错
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
@EnableDubbo
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}

  1. CustomGlobalFilter.java (全局鉴权拦截器)

filter 包下新建这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@Slf4j
@Component
public class CustomGlobalFilter implements GlobalFilter, Ordered {

// 远程调用主后台获取用户信息
@DubboReference(check = false)
private InnerUserService innerUserService;

//Mono<Void> 是 Reactor 框架中的一个类型,表示一个异步操作的结果,这个操作可能会完成(成功或失败),但不会返回任何数据(Void)。
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();

// 1. 打印请求日志
log.info("请求唯一标识:" + request.getId());

//request.getPath() 返回的是一个 RequestPath 对象。这个对象里包含了很多解析好的路径信息,比如按 / 分割的各个部分。如果直接打印它,底层会调用 toString()。
//request.getPath().value() 则是直接把完整的路径提取成一个纯粹的 String 字符串(比如 "/api/user/login")。在写日志时,我们通常只需要纯字符串。
log.info("请求路径:" + request.getPath().value());

//request.getLocalAddress() 返回的是一个 InetSocketAddress 对象,里面包含了 IP 地址、端口号,甚至还有未解析的主机名。
//request.getLocalAddress().getHostString() 则是干净利落地只提取出 IP 地址字符串(比如 "192.168.1.100"),且它不会触发耗时的 DNS 反向解析,性能更好,写进日志也更清晰。
log.info("请求来源地址:" + request.getLocalAddress().getHostString());

// 2. 扒取请求头(核心鉴权部分)
HttpHeaders headers = request.getHeaders();
String accessKey = headers.getFirst("accessKey");
String nonce = headers.getFirst("nonce");
String timestamp = headers.getFirst("timestamp");
String sign = headers.getFirst("sign");
String body = headers.getFirst("body");

// 3. 校验逻辑 (防伪造、防重放、防过期)
if (accessKey == null) {
return handleNoAuth(response);
}

User invokeUser = innerUserService.getInvokeUser(accessKey);
if (invokeUser == null) {
return handleNoAuth(response);
}

if (nonce == null || nonce.length() < 4) {
return handleNoAuth(response);
}

long currentTime = System.currentTimeMillis() / 1000;
final long FIVE_MINUTES = 5 * 60;
if (timestamp == null || (currentTime - Long.parseLong(timestamp)) >= FIVE_MINUTES) {
return handleNoAuth(response);
}

String secretKey = invokeUser.getSecretKey();
String serverSign = SignUtils.genSign(body, secretKey);
if (sign == null || !sign.equals(serverSign)) {
return handleNoAuth(response);
}

// 4. 鉴权通过,放行请求!
return chain.filter(exchange);
}

/**
* 拦截并返回 403 无权限错误
*/
private Mono<Void> handleNoAuth(ServerHttpResponse response) {
response.setStatusCode(HttpStatus.FORBIDDEN);
return response.setComplete();
}

@Override
public int getOrder() {
// 返回 -1 保证这个过滤器拥有最高优先级,最先执行
return -1;
}
}

网关配置好之后,必须去 interface 模块,把之前写的 ApiAuthInterceptor(拦截器)和 MvcConfig 删掉 如果网关拦截一次,接口服务又拦截一次,会导致请求卡死或抛出异常。

简单测试

测试网关的“拦截”

现在全面启动项目,测试网关的拦截能力,按照下面顺序启动:

1.Nacos (注册中心):确保 localhost:8848/nacos 正在运行。

2.API 平台主后台 (Backend):点击 api-platform-backend 模块的启动类。确保它已成功注册到 Nacos

3.模拟的第三方接口 (Interface):点击 api-platform-interface 模块的启动类(注意:它现在是一个没有任何安检的“裸体”服务,跑在 8102 端口)。

4.统一 API 网关 (Gateway):点击我们刚刚新建的 api-platform-gateway 模块的启动类 ApiGatewayApplication(它跑在 8090 端口)。

然后现在可以直接访问网关的地址,并且故意不带任何签名信息,因为我们配置的路由规则是匹配/api/**,所以要这样访问:

http://localhost:8090/api/name/get?name=Test

预期结果:页面会返回一个403 Forbidden 状态码,并且看看 Gateway 的控制台日志,会显示打印的请求信息,并在验证头信息(accessKey == null)时被果断拦截了。

测试网关的“放行与路由”

这是最核心的一步:通过 SDK,带着合法的签名,向网关发起请求,看网关能不能把它正确地路由给隐藏在后面的 8102 服务。

注意! 之前我们测试 SDK,请求的地址是直接写死的接口地址(8102)。现在我们要通过网关,所以请求地址必须改成网关的地址(8090)加上路由前缀(/api)。

打开之前写的 SDK 模块 (api-platform-client-sdk),找到 ApiClient.java

把里面请求的 URL 地址全部替换为通过网关的地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 1. 调用 GET 接口
public String getNameByGet(String name) {
HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("name", name);
// 注意:这里改成了访问网关 (8090),并加上了 /api 前缀
String result = HttpUtil.get("http://localhost:8090/api/name/get", paramMap);
System.out.println(result);
return result;
}

// 2. 调用 POST URL 传参接口
public String getNameByPost(String name) {
HashMap<String, Object> paramMap = new HashMap<>();
paramMap.put("name", name);
// 注意:访问网关
String result = HttpUtil.post("http://localhost:8090/api/name/post", paramMap);
System.out.println(result);
return result;
}

// 3. 调用最核心的 POST JSON 接口
public String getUserNameByPost(User user) {
String json = JSONUtil.toJsonStr(user);
// 注意:访问网关
HttpResponse httpResponse = HttpRequest.post("http://localhost:8090/api/name/user")
.addHeaders(getHeaderMap(json))
.body(json)
.execute();
System.out.println(httpResponse.getStatus());
String result = httpResponse.body();
System.out.println(result);
return result;
}

注意:修改完 SDK 后,必须要在 Maven 面板里重新双击 cleaninstall,把这个带有新地址的 jar 包安装到本地仓库! 否则接下来的测试还是会去调老地址。

重新打包好 SDK 后,打开 api-platform-interface 模块下的测试类 ApiInterfaceApplicationTests.java,会返回期望的结果:

测试结果

网关模块后台会打印日志:

日志

到这里,这个复杂的跨进程链路就彻底跑通了:

SDK(客户端)发起请求 -> 打向 Gateway(8090) -> Gateway 进行签名拦截 -> Gateway 通过 Dubbo RPC 去 Backend 查数据库比对签名 -> 签名一致,Gateway 放行 -> Gateway 根据路由规则,将请求转发给 Interface(8102) -> Interface 执行业务逻辑并原路返回

重构签名逻辑SignUtils.java

之前的签名逻辑里面为了跑通主流程,只拼接了请求体和密钥,现在可以加上随机数nonce和时间戳timestamp了

打开之前写的SignUtils.java,更新为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* API签名工具类
*/
public class SignUtils {
/**
* 生成API调用签名
*
* @param body 请求体内容(或者请求参数)
* @param secretKey 用户的私钥
* @param nonce 随机数,防止重放攻击
* @param timestamp 时间戳,防止重放攻击
* @return 经过MD5加密的签名字符串
*/
public static String genSign(String body,String secretKey,String nonce, String timestamp){

// 防止明文拼接被破解,可以在body和secretKey之间加入一个固定的分隔符,增加破解难度
String content = body+ "." + secretKey+"."+ nonce + "." + timestamp;


Digester md5 = new Digester(DigestAlgorithm.MD5);
return md5.digestHex(content, StandardCharsets.UTF_8);

}
}

这里把DigestUtils换成了 Digester,之前用的 DigestUtils.md5DigestAsHexSpring 框架org.springframework.util)自带的工具类,但现在我们的SignUtils 是放在单独的 SDK 模块里的,SDK 模块是给第三方用的,它必须极其轻量,而 DigesterHutool (cn.hutool.crypto.digest) 里的轻量级工具。引入 Hutoolhutool-crypto 只有几百 KB,第三方开发者用起来毫无负担。

更新 SDK 里的 AuthUtils

我们需要修改 SDK 发送请求时生成请求头的方法。把生成的 noncetimestamp 传给 genSign

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
   public class AuthUtils {
/**
* 核心逻辑:组装请求头
* 把凭证全部放在Header里
*/
public static Map<String,String> getHeaderMap(String body, String accessKey, String secretKey){
Map<String,String> hashMap = new HashMap<>();
hashMap.put("accessKey",accessKey);

// 生成随机数(防重放)
hashMap.put("nonce", cn.hutool.core.util.RandomUtil.randomNumbers(4));

// 生成当前时间戳(防过期)
hashMap.put("timestamp",String.valueOf(System.currentTimeMillis() / 1000));

// 将请求体参与签名计算
hashMap.put("body",body);

// 生成签名
hashMap.put("sign", SignUtils.genSign(body,secretKey,hashMap.get("nonce"),hashMap.get("timestamp")));

return hashMap;
}
}

更新网关 CustomGlobalFilter (API 海关)

网关在拦截请求时,必须用这四个参数重新算一遍,如果算出来和前端传的不一样,就是非法请求。

1
2
3
4
5
6
7
8
9
10
// ... 前面获取头信息和校验的逻辑保持不变 ...

// 在网关里找到生成 serverSign 的这行代码,把 nonce 和 timestamp 加进去
String secretKey = invokeUser.getSecretKey();
//一定要保证这里的参数顺序和 SDK 里拼接的顺序一模一样
String serverSign = SignUtils.genSign(body, secretKey, nonce, timestamp);

if (sign == null || !sign.equals(serverSign)) {
return handleNoAuth(response);
}

然后重新打包SDK就好了。

接口调用次数统计

我们要实现这样的业务闭环:当用户成功调用一次 /name/user 接口后,系统会自动在数据库里扣除他该接口的 1 次调用配额,并把总调用次数加 1。

架构思考:这段代码应该写在哪里?

  1. 写在 Interface 模块? 不行,第三方接口服务只负责干活(比如返回天气),它不应该、也没有权限去管“平台扣费”这种核心业务。
  2. 写在网关 Gateway 模块? 也不行,网关应该尽可能轻量,只做路由和拦截。如果在网关里写长篇大论的数据库操作,会严重拖慢网关的并发性能。
  3. 正确答案:写在 Backend 模块,由网关通过 RPC 异步调用

接下来,我们要实现这一功能。

第一步:在 Common 模块建立契约 (RPC 接口)

要在网关和 Backend 之间打通一条新的“内线电话”,用于统计次数。

打开 api-platform-common 模块的 src/main/java/com/accycx/common/service 目录,新建 InnerUserInterfaceInvoke.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 内部用户接口信息服务(专门用于网关RPC调用)
*/
public interface InnerUserInterfaceInvoke {

/**
* 接口调用次数+1,剩余配额-1
*
* @param interfaceInfoId 被调用的接口ID
* @param userId 发起调用的用户ID
* @return 是否统计成功
*/
boolean invokeCount(long interfaceInfoId, long userId);
}

第二步:在 Backend 模块实现契约并暴露服务

打开 api-platform-backend 模块的 src/main/java/com/accycx/backend/service/impl 目录,新建 InnerUserInterfaceInvokeImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@DubboService
@SuppressWarnings("unused")
public class InnerUserInterfaceInvokeImpl implements InnerUserInterfaceInvoke {

@Resource
private UserInterfaceInvokeService userInterfaceInvokeService;

@Override
public boolean invokeCount(long interfaceInfoId,long userId){
// 检验参数合法性
if(interfaceInfoId <= 0 || userId <= 0){
return false;
}

// 用UserInterfaceInvoke实体类更新

UpdateWrapper<UserInterfaceInvoke> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("interface_info_id",interfaceInfoId);
updateWrapper.eq("user_id",userId);
updateWrapper.gt("left_num",0); //必须有剩余次数点才能扣除

// 子操作,防并发超卖
updateWrapper.setSql("left_num = left_num - 1, total_num = total_num + 1");

return userInterfaceInvokeService.update(updateWrapper);
}
}

这里用到了UserInterfaceInvokeService,但是之前并没有写过,只创建了UserInterfaceInvoke的entity,这一套(MapperServiceServiceImpl)将在下一步实现,并把代码贴出来。

第三步:在 Gateway 网关模块发起 RPC 调用

这是最关键的一步。我们不能在网关转发请求之前扣费,万一接口服务挂了没返回结果,用户钱白扣了。必须在网关收到接口服务的成功响应后再扣费。

在实现这步之前,先把UserInterfaceInvoke这一套流程创建好

完成UserInterfaceInvoke流程

(1)新建 Mapper接口

api-platform-backend 模块的 mapper 包下新建 UserInterfaceInvokeMapper.java

1
2
3
4
5
6
7
/**
* 用户接口调用表 Mapper接口
*/
@Mapper
public interface UserInterfaceInvokeMapper extends BaseMapper<UserInterfaceInvoke> {
}

(2)新建 Service 接口

service 包下新建 UserInterfaceInvokeService.java

1
2
3
4
5
6
/**
* 用户接口调用服务接口
*/
public interface UserInterfaceInvokeService extends IService<UserInterfaceInvoke> {
}

(3)实现 Service 接口

service/impl 包下新建 UserInterfaceInvokeServiceImpl.java

1
2
3
4
5
6
7
/**
* 用户接口调用服务实现类
*/
@Service
public class UserInterfaceInvokeServiceImpl extends ServiceImpl<UserInterfaceInvokeMapper,UserInterfaceInvoke> implements UserInterfaceInvokeService{
}

为什么要把 UserInterfaceInvokeServiceImplInnerUserInterfaceInvokeImpl分开来写?

有人会觉得,就在UserInterfaceInvokeServiceImpl类上同时加上@DubboService@Service就行了,这样确实能跑通,但是同时也会带来很大的风险!

加上@DubboService意味着把这个业务类的接口全都暴露给其它微服务模块了,也就相当于这个业务类继承的Mybat-Plus里的底层接口也会暴露出去,例如save(),update()等方法,如果微服务其它模块被黑客入侵,那么他就可以直接用这些底层接口方法直接去操作你的数据库,及其不安全,所以要分开来写,只把必要的接口暴露出去。

接下来就在Gateway网关模块发起RPC调用

打开 api-platform-gateway 模块里的 CustomGlobalFilter.java

  1. 在类顶部引入刚刚写的 RPC 接口:
1
2
3
4
@DubboReference(check = false)
private InnerUserInterfaceInfoService innerUserInterfaceInfoService;

// 之前引入的 innerUserService 应该也在这里
  1. 找到 // 4. 鉴权通过,放行请求! 这行代码,把它替换成下面的响应式处理逻辑:
1
2
3
4
5
6
7
8
9
// 4. 鉴权通过,放行请求,并绑定一个“响应后置拦截器”
// 注意:不要直接 return chain.filter(exchange); 了

// 假设我们已经查到了正在被调用的 interfaceInfoId(比如 ID 是 1)
// 这里需要通过 innerInterfaceInfoService 从数据库里动态查出来,等会儿会实现这一步
long interfaceInfoId = 1L;

// 发起异步的网关转发并处理结果
return handleResponse(exchange, chain, interfaceInfoId, invokeUser.getId());
  1. CustomGlobalFilter 类的最下方,加入这个处理响应的方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 处理响应,在响应成功后调用统计次数的RPC接口
*/
private Mono<Void> handleResponse(ServerWebExchange exchange,GatewayFilterChain chain,long interfaceInfoId,long userId){
try{
ServerHttpResponse originalResponse = exchange.getResponse();

// 这是一段Gateway装饰器模式代码,用于拦截后端服务的响应
// 这里不修改响应内容,只在响应完成后执行一个回调函数(Mono.fromRunnable),在这个回调函数里可以获取到响应的状态码等信息,进行统计或日志记录等操作
return chain.filter(exchange).then(Mono.fromRunnable(()->{
HttpStatusCode statusCode = originalResponse.getStatusCode();
if(statusCode != null && statusCode.is2xxSuccessful()){
// 如果后端接口返回了200,说明调用成功了,然后可以调用RPC接口来统计用户的调用次数
try {
// 1. 接住返回值
boolean invokeResult = innerUserInterfaceInvoke.invokeCount(interfaceInfoId, userId);

// 2. 判断是否扣减成功
if (!invokeResult) {
log.error("致命错误:接口调用成功,但扣减调用次数失败!接口ID: {}, 用户ID: {}", interfaceInfoId, userId);
// 这里可以配合未来的报警系统(比如发钉钉/企业微信机器人报警)
} else {
log.info("扣减调用次数成功。接口ID: {}, 用户ID: {}", interfaceInfoId, userId);
}
} catch (Exception e) {
log.error("invokeCount RPC调用出现异常", e);
}
}else{
// 接口报错了,做一些报警或日志记录
log.error("调用接口失败,状态码:{}", statusCode);
}
}));
}catch(Exception e){
log.error("网关处理异常",e);
return exchange.getResponse().setComplete();
}
}
}

这段代码其实在同步和异步上有问题:

网关基于 WebFlux,它的核心思想是非阻塞chain.filter(exchange).then(Mono.fromRunnable(...)) 这个回调函数是运行在 Netty 的非阻塞线程(EventLoop)中的。 而 innerUserInterfaceInvoke.invokeCount(...) 是一个同步阻塞的 Dubbo RPC 调用

如果我们在非阻塞的线程里执行一个可能耗时几百毫秒的同步网络请求,会导致网关的线程被卡住,吞吐量断崖式下跌,甚至直接抛出 block() is not allowed 类似的异常。

并发问题:

假设用户小明只剩下最后 1 次调用额度(leftNum = 1),他写了一个并发脚本,在同一微秒内,向网关发起了 2 个完全一样的请求(请求 A 和请求 B)

在极高并发下,A 和 B 几乎是同时刻并排冲进网关的:

  • 步骤 1:A 和 B 同时到达网关安检。 网关同时去查数据库,发现小明剩余次数都是 1,于是同时放行了 A 和 B。(因为此时没有任何一个请求走到了最终扣费那一步)。
  • 步骤 2:A 和 B 同时调用第三方接口。 两个接口都调用成功了(因为第三方不管你剩多少次,它只负责干活)。
  • 步骤 3:A 和 B 同时走到 invokeCount 准备扣费。
    • 请求 A 执行 updateWrapper.gt("leftNum", 0),发现当前是 1,符合条件,扣减成功!小明次数变成 0。返回 true
    • 紧接着,请求 B 也来执行 updateWrapper.gt("leftNum", 0) 但此时数据库里的 leftNum 刚刚已经被 A 变成了 0。因为条件不成立(0 不大于 0),MyBatis-Plus 没有更新任何数据,返回 false

结果是:第三方接口被调用了 2 次,但小明的账户里只被扣了 1 次,这就是典型的“白嫖”!!!

所以后续这部分会用Redis来处理

在网关模块动态查询接口ID

第一步:在 Backend 模块实现 RPC 契约

之前我们在 common 模块定义了 InnerInterfaceInfoService 接口,现在我们要去 api-platform-backend 模块把它给实现了。

service/impl 包下新建 InnerInterfaceInfoServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@DubboService
@SuppressWarnings("unused")
public class InnerInterfaceInfoServiceImpl implements InnerInterfaceInfoService {

@Resource
private InterfaceInfoMapper interfaceInfoMapper;

@Override
public InterfaceInfo getInterfaceInfo(String path,String method){
if(StringUtils.isAnyBlank(path,method)){
return null;
}
// 根据URL和请求方法 唯一确定一个接口
QueryWrapper<InterfaceInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("url",path);
queryWrapper.eq("method",method);
return interfaceInfoMapper.selectOne(queryWrapper);
}

}

第二步:在 Gateway 中实现动态查询

现在我们要重构 api-platform-gateway 里的 CustomGlobalFilter。我们需要把原来写死的 1L 替换成从数据库查询的结果。

1. 注入 RPC 服务

CustomGlobalFilter 类顶部新增:

1
2
3
@DubboReference(check = false)
@SuppressWarnings("unused")
private InnerInterfaceInfoService innerInterfaceInfoService;

2. 修改 filter 核心逻辑

找到之前 // 4. 鉴权通过 的位置,修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//        4.鉴权通过,放行请求,并绑定一个回调函数,在响应成功后调用统计次数的RPC接口
// 动态获取当前请求的接口信息
String path = request.getPath().value().replace("/api",""); //去掉/api前缀,得到真正的接口路径
String method = request.getMethod().name();

// 这里调用RPC查库
InterfaceInfo interfaceInfo = innerInterfaceInfoService.getInterfaceInfo(path, method);
if(interfaceInfo == null){
// 找不到接口,说明这是非法路径或未收录接口
return handleNoAuth(response);
}

// 拿到真实ID和用户ID
long interfaceInfoId = interfaceInfo.getId();
long userId = invokeUser.getId();

// 发起异步的网关转发并处理结果
return handleResponse(exchange, chain, interfaceInfoId, userId);
// return chain.filter(exchange);

现在的闭环链路:

  1. SDK 向网关发请求。
  2. 网关 通过 InnerUserService 拿到 SK 完成验签。
  3. 网关 通过 InnerInterfaceInfoService 拿到该请求对应的 接口 ID
  4. 网关 转发给真实服务。
  5. 网关 收到成功响应,通过 InnerUserInterfaceInfoService 给该 用户ID + 接口ID 的组合扣费。

但是:现在的网关每进一个请求都要打两次 RPC 到后台查库(一次查人,一次查接口),这在并发量大的时候会拖慢响应速度,后面会通过Redis缓存优化网关的查询性能。

现在重新启动整个项目,再用之前的测试类验证流程(注意,在测试前数据库需要用一条用户和接口的调用的关系的数据,并且用户id要对应测试类里的用户名,接口id对应测试类里的接口,调用配额要为有效数字)。

这次通过Gateway后台可以看到:

日志打结果

然后去观察数据库里的变化,会发现总调用次数+1,剩余配额-1。

到现在这个项目的核心计费与鉴权大动脉已经彻底贯通,这标志着后端的核心架构(接口提供、SDK 封装、微服务治理、网关统一拦截、RPC 通信计费)已经基本成型。

网关搭建好之后,我们还需要整顿之前遗留下来的问题,把所有逻辑打通。

整顿遗留问题

新添分页查询接口(支持模糊搜索)

我们使用 MyBatis PlusPage 对象和 QueryWrapper 来实现。

分页基类PageRequest.java

api-platform-model 模块下的 com.accycx.model.dto.common 包中,新建 PageRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 分页请求基类
*/
@Data
public class PageRequest {

// 当前页号
private long current = 1;

// 页面大小
private long pageSize = 10;

// 排序字段
private String sortField;

// 排序方式(默认升序)
private String sortOrder = "ascend";
}

接口信息查询请求体InterfaceInfoQueryRequest.java

api-platform-model 模块下的 com.accycx.model.dto.interfaceinfo 包中,新建 InterfaceInfoQueryRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 接口信息查询请求体
*/
@Data
@EqualsAndHashCode(callSuper = true) //让 Lombok 在自动生成 equals() 和 hashCode() 方法时,把父类(也就是 PageRequest)里的属性也一起算进去
public class InterfaceInfoQueryRequest extends PageRequest implements Serializable {

// 接口名称(支持模糊搜索)
private String name;

// 接口描述(支持模糊搜索)
private String description;

// 接口请求方法
private String method;

// 接口状态(0-关闭,1-开启)
private Integer status;

@Serial
private static final long serialVersionUID = 1L;
}

分页查询接口listInterfaceInfoByPage.java

api-platform-backend 模块下的 com.accycx.backend.controller 包中InterfaceInfoController.java,增加如下分页查询接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
 /**
* 分页查询接口列表(封装了模糊查询逻辑)
*/
@GetMapping("/list/page")
@Operation(summary = "分页查询接口列表")
public BaseResponse<Page<InterfaceInfo>> listInterfaceInfoByPage(InterfaceInfoQueryRequest interfaceInfoQueryRequest){

if(interfaceInfoQueryRequest == null){
return ResultUtils.error(ErrorCode.PARAMS_ERROR);
}


long current = interfaceInfoQueryRequest.getCurrent();
long size = interfaceInfoQueryRequest.getPageSize();
String sortField = interfaceInfoQueryRequest.getSortField();
String sortOrder = interfaceInfoQueryRequest.getSortOrder();
String name = interfaceInfoQueryRequest.getName(); //模糊查询参数
String description = interfaceInfoQueryRequest.getDescription(); //模糊查询参数

//限制:size不能大于50
if(size>50){
return ResultUtils.error(ErrorCode.PARAMS_ERROR,"每页条数不能超过50");
}

QueryWrapper<InterfaceInfo> queryWrapper = new QueryWrapper<>();
// 模糊查询:如果name不为空,则匹配数据库中包含该字符串的记录
queryWrapper.like(StringUtils.isNotBlank(name), "name", name);
queryWrapper.like(StringUtils.isNotBlank(description), "description", description);

// 排序逻辑
queryWrapper.orderBy(StringUtils.isNotBlank(sortField),
sortOrder.equals("ascend"), sortField);
Page<InterfaceInfo> interfaceInfoPage = interfaceInfoService.page(new Page<>(current,size),queryWrapper);
return ResultUtils.success(interfaceInfoPage);
}

@AuthCheck角色认证

因为有些接口只能由管理员使用,例如接口信息的增删改,所以我们要自定义写一个角色认证注解。

Common模块的src/main/java/com/accycx/common下新建AuthCheck.java

1
2
3
4
5
6
7
@Target(ElementType.METHOD) // 这个注解只能用在方法上
@Retention(RetentionPolicy.RUNTIME) // 这个注解在运行时仍然可用,可以通过反射读取
public @interface AuthCheck {

// 必须有某个角色
String mustRole() default ""; // 需要的角色
}

然后再写拦截器,在Backend模块的src/main/java/com/accycx/backend/interceptor下新建AuthInterceptor.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Aspect //标注这是一个切面类,里面定义了横切逻辑(拦截器)
@Component
public class AuthInterceptor {

@Resource
private UserService userService;

// 执行拦截
@Around("@annotation(authCheck)") //拦截所有被 @AuthCheck 注解标记的方法
public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable{
// ProceedingJoinPoint joinPoint: 代表被拦截的方法,可以通过它获取方法参数、方法签名等信息,并且可以调用 joinPoint.proceed() 来继续执行被拦截的方法。
// throws Throwable: 因为 joinPoint.proceed() 可能会抛出任何异常,所以我们需要在方法签名中声明 throws Throwable 来允许这些异常被传播。

String mustRole = authCheck.mustRole();
// 获取当前请求的 RequestAttributes 对象,这个对象包含了当前 HTTP 请求的上下文信息,比如请求参数、请求头、会话等。我们需要它来获取当前登录用户的信息。
// RequestContextHolder 是 Spring 提供的一个工具类,用于获取当前线程绑定的 RequestAttributes 对象。它提供了几个静态方法来访问这些对象:
// currentRequestAttributes():返回当前线程绑定的 RequestAttributes 对象,如果没有绑定则抛出 IllegalStateException。
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();

// 因为我们知道当前请求是一个 HTTP 请求,所以我们可以把 RequestAttributes 强制转换成 ServletRequestAttributes。
// ServletRequestAttributes 是 RequestAttributes 的一个子类,专门用于处理 HTTP 请求的上下文信息。它提供了一个 getRequest() 方法,可以直接获取当前的 HttpServletRequest 对象。
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();

// 1.获取当前登录用户
User loginUser = userService.getLoginUser(request);

// 2.必须有管理员权限
if(StringUtils.isNotBlank(mustRole)){
String userRole = loginUser.getUserRole();
if(!mustRole.equals(userRole)){
return ResultUtils.error(ErrorCode.NO_AUTH_ERROR,"无权限访问");
}
}
return joinPoint.proceed();

}
}

然后在backend模块的InterfaceController.java中,将需要管理员认证的接口统一加上注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@PostMapping
@Operation(summary = "创建新接口")
@AuthCheck(mustRole = "admin") //只有管理员才能发布新接口
public BaseResponse<Long> addInterfaceInfo(@RequestBody InterfaceInfoAddRequest interfaceInfoAddRequest,HttpServletRequest request){}

@DeleteMapping("/{id}")
@Operation(summary = "删除接口")
@AuthCheck(mustRole = "admin") //只有管理员才能删除接口
public BaseResponse<Boolean> deleteInterfaceInfo(@PathVariable("id") Long id){}

@PutMapping
@Operation(summary = "更新接口")
@AuthCheck(mustRole = "admin") //只有管理员才能更新接口
public BaseResponse<Boolean> updateInterfaceInfo(@RequestBody InterfaceInfoUpdateRequest interfaceInfoUpdateRequest){}



完善添加接口功能

之前的添加接口中,是写死了创建用户的id为1的,我们需要新添一个获取当前登录用户信息的方法,然后引入UserService调用这个方法获取用户ID

UserServiceImpl.java中新添:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//    获取当前登录用户的信息
@Override
public User getLoginUser(HttpServletRequest request){

// 1.从请求头获取Token(前端放在Authorization字段或token字段)
String token = request.getHeader("Authorization");
if(StringUtils.isBlank(token)){
// 兼容前端可能放在token字段里
token = request.getHeader("token");
}
if(StringUtils.isBlank(token)){
throw new RuntimeException("未登录:Token为空");
}

// 2.解析Token,获取用户ID
long userId;
try{
Claims claims = JwtUtils.parseToken(token);
userId = claims.get("userId", Number.class).longValue();
} catch (Exception e) {
throw new RuntimeException("未登录:Token不合法或已过期");
}

// 3.从数据库查询最新信息,确保AK/SK等敏感信息是最新的(如果用户被管理员禁用或删除了,这里也能查不到,保证安全性)
User currentUser = this.getById(userId);
if(currentUser == null){
throw new RuntimeException("用户不存在");
}

return currentUser;
}

其中,用到了JWT的一个parseToken的方法,功能是将Token解析出来,获取用户的id,我们现在将它补上:

在Common模块的JWTUtils.java里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    /**
* 解析Token
* @param token 客户端传来的JWT字符串
* @return Claims 载荷对象,里面包含用户信息
*/
public static Claims parseToken(String token) {
try {
// 使用parserBuilder设置密钥并解析token
return Jwts.parserBuilder()
.setSigningKey(KEY) //必须使用签发时的同一把钥匙,比对密钥
.build()//准备就绪,构造解析器
.parseClaimsJws(token)//解析token,如果token无效或过期会抛出异常
.getBody(); //获取token中的payload部分,也就是我们之前放入的claims
} catch (ExpiredJwtException e) {
// 如果Token已经过了EXPIRE_TIME,会抛出这个异常
throw new RuntimeException("Token已过期,请重新登录");
} catch (MalformedJwtException e) {
// 如果Token被人篡改过,或者根本不是一个合法的JWT,会抛出这个异常
throw new RuntimeException("Token不合法或被篡改");
} catch (Exception e) {
// 其他异常(如签名无效等)
throw new RuntimeException("Token解析失败");
}
}

然后在添加接口中实现动态获取登录用户id:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    @Resource
private UserService userService;

/**
* 创建接口
*/
@PostMapping
@Operation(summary = "创建新接口")
@AuthCheck(mustRole = "admin") //只有管理员才能发布新接口
public BaseResponse<Long> addInterfaceInfo(@RequestBody InterfaceInfoAddRequest interfaceInfoAddRequest,HttpServletRequest request){

if(interfaceInfoAddRequest == null){
return ResultUtils.error(ErrorCode.PARAMS_ERROR);
}

// DTO转实体类
InterfaceInfo interfaceInfo = new InterfaceInfo();
BeanUtils.copyProperties(interfaceInfoAddRequest,interfaceInfo);

// 校验参数
interfaceInfoService.validInterfaceInfo(interfaceInfo,true);

// 动态获取当前登录用户的ID
User loginUser = userService.getLoginUser(request);
interfaceInfo.setUserId(loginUser.getId());

interfaceInfo.setStatus(InterfaceInfoStatus.OFFLINE.getValue());
boolean result = interfaceInfoService.save(interfaceInfo);
if(!result){
return ResultUtils.error(ErrorCode.OPERATION_ERROR,"创建接口失败");
}
return ResultUtils.success(interfaceInfo.getId());
}

添加在线调用接口测试功能

我们要实现一个用户可以在平台在线测试接口的功能,实现方法如下:

由于调用接口都首先要发送请求到网关,所以我们在application.yml自定义网关路径,相较于直接在接口里面拼接url,出现错误的话更方便排查与纠正:

1
2
3
api:
gateway:
host: http://localhost:8090/api # API 网关地址

然后就可以在InterfaceInfoController.java中实现接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
     @Value("${api.gateway.host}")
private String gatewayHost; //动态获取网关地址

/**
* 在线调用(测试)接口
*/
@PostMapping("/invoke")
@Operation(summary = "在线调用测试接口")
@SuppressWarnings("Duplicates")
public BaseResponse<Object> invokeInterfaceInfo(@RequestBody InterfaceInfoInvokeRequest invokeRequest, HttpServletRequest request) {
// 1.校验参数
if (invokeRequest == null || invokeRequest.getId() <= 0) {
return ResultUtils.error(ErrorCode.PARAMS_ERROR);
}

// 2.判断接口是否存在
InterfaceInfo oldInterfaceInfo = interfaceInfoService.getById(invokeRequest.getId());
if (oldInterfaceInfo == null) {
return ResultUtils.error(ErrorCode.NOT_FOUND_ERROR, "接口不存在");
}

// 3.判断接口状态是否开启
if (!oldInterfaceInfo.getStatus().equals(InterfaceInfoStatus.ONLINE.getValue())) {
return ResultUtils.error(ErrorCode.PARAMS_ERROR, "接口已关闭,无法调用");
}
// 4.获取当前登录的用户信息
User loginUser = userService.getLoginUser(request);
String accessKey = loginUser.getAccessKey();
String secretKey = loginUser.getSecretKey();

// 5.获取前端用户输入的JSON参数
String userRequestParams = invokeRequest.getUserRequestParams();
// 如果用户没填参数,给个默认空JSON,防止签名报错
if(StringUtils.isBlank(userRequestParams)){
userRequestParams = "{}";
}
// 6.动态拼接网关URL
String url = gatewayHost+ oldInterfaceInfo.getUrl(); //网关地址 + 接口路径
String method = oldInterfaceInfo.getMethod(); //接口方法
log.info("开始进行接口在线测试, 目标URL: {}, 方法: {}", url, method);
// 动态网络调用与容错处理
try {
// 将字符串转化为Hutool认识的HTTP Method枚举
Method httpMethod = Method.valueOf(method.toUpperCase());
// 动态发起请求,可以适配所有Method方法
try (HttpResponse response = cn.hutool.http.HttpRequest.of(url)
.method(httpMethod) //动态塞入请求方法
.addHeaders(AuthUtils.getHeaderMap(userRequestParams, accessKey, secretKey))//塞入鉴权信息
.body(userRequestParams) //塞入用户请求参数
.execute()) {//发起请求
// 只要走出了上面的括号,网络流就会自动安全关闭
String result = response.body();
int status = response.getStatus();
log.info("接口在线测试完毕,响应状态码:{}", status);
// 将网关返回的真实内容透传给前端
return ResultUtils.success(result);
}
} catch (IllegalArgumentException e) {
log.error("不支持的HTTP方法:{}", method, e);
return ResultUtils.error(ErrorCode.PARAMS_ERROR, "不支持的HTTP方法:" + method);
} catch (Exception e) {
log.error("接口在线测试网络异常", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "接口调用失败,错误信息:" + e.getMessage());
}

}

添加发布、下线接口功能

管理员新添加的接口默认为下线功能,所以需要添加一个发布和下线接口的功能,便于接口信息的管理,实现如下:

在Common模块的enums包下添加接口状态信息枚举类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 接口信息状态枚举类
*/
@Getter
public enum InterfaceInfoStatus {

OFFLINE(0,"关闭"),
ONLINE(1,"上线");

private final int value;
private final String description;

InterfaceInfoStatus(int value, String description) {
this.value = value;
this.description = description;
}

/**
* 获取值列表
*/
public static List<Integer> getValues() {
// 流式API:将枚举值转换为流,映射为它们的整数值,并收集到一个列表中返回
return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList());
}

}

不能在判断接口是否上线时单单的使用“0”或“1”,所以创建这个枚举类,使得代码更加规范。

InterfaceInfoController.java中实现下线接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    /**
* 下线接口(Offline)
*/
@PostMapping("/{id}/offline")
@Operation(summary = "下线接口")
@AuthCheck(mustRole = "admin")
@SuppressWarnings("Duplicates")
public BaseResponse<Boolean> offlineInterfaceInfo(@PathVariable("id") Long id){
if(id == null || id <= 0){
return ResultUtils.error(ErrorCode.PARAMS_ERROR);
}
// 检验接口是否存在
InterfaceInfo oldInterfaceInfo = interfaceInfoService.getById(id);
if(oldInterfaceInfo == null){
return ResultUtils.error(ErrorCode.NOT_FOUND_ERROR,"接口不存在");
}
// 更新接口状态为下线
InterfaceInfo interfaceInfo = new InterfaceInfo();
interfaceInfo.setId(id);
interfaceInfo.setStatus(InterfaceInfoStatus.OFFLINE.getValue());
boolean result = interfaceInfoService.updateById(interfaceInfo);

log.info("管理员成功下线接口,接口ID:{}", id);
return ResultUtils.success(result);
}

实现发布接口时,需要注意,在发布前需要先测试一遍,通过了才能上线,总不能发布一个不可用的接口吧,所以逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
    /**
* 发布接口(Online)
*/
@PostMapping("/{id}/online")
@Operation(summary = "发布接口")
@AuthCheck(mustRole = "admin") //只有管理员才能发布接口
@SuppressWarnings("Duplicates")
public BaseResponse<Boolean> onlineInterfaceInfo(@PathVariable("id") Long id,HttpServletRequest request){
if(id == null || id <= 0){
return ResultUtils.error(ErrorCode.PARAMS_ERROR);
}
// 1.校验接口是否存在
InterfaceInfo oldInterfaceInfo = interfaceInfoService.getById(id);
if(oldInterfaceInfo == null){
return ResultUtils.error(ErrorCode.NOT_FOUND_ERROR,"接口不存在");
}

// 2.动态获取当前管理员的AK/SK
User loginUser = userService.getLoginUser(request);
String accessKey = loginUser.getAccessKey();
String secretKey = loginUser.getSecretKey();

// 动态拼接请求URL和方法
String url = gatewayHost + oldInterfaceInfo.getUrl();
String methodStr = oldInterfaceInfo.getMethod();

// 尝试从数据库获取该接口的标准请求参数,如果没有,给个空JSON兜底防报错
String requestParams = StringUtils.isNotBlank(oldInterfaceInfo.getRequestParams()) ? oldInterfaceInfo.getRequestParams() : "{}";

log.info("开始进行接口连通性测试,目标URL:{},请求方法:{}", url, methodStr);

// 动态网络调用与容错处理
try{
Method httpMethod = Method.valueOf(methodStr.toUpperCase());

try(HttpResponse response = HttpRequest.of(url)
.method(httpMethod)
.addHeaders(AuthUtils.getHeaderMap(requestParams,accessKey,secretKey))
.body(requestParams)
.timeout(5000) //设置超时时间为5秒,防止接口无响应导致发布卡死
.execute()){
int status = response.getStatus();
log.info("接口测试完毕,响应状态码:{}", status);

// 只要网关没有返回404、500、502、503等系统致命错误,说明接口是通的
if(status >= 404 && status != 405){
return ResultUtils.error(ErrorCode.SYSTEM_ERROR,"接口连通性测试失败,网关返回异常状态码: " + status);
}
}
} catch(IllegalArgumentException e){
log.error("不支持的HTTP方法:{}",methodStr,e);
return ResultUtils.error(ErrorCode.PARAMS_ERROR,"不支持的HTTP方法:"+ methodStr);
} catch(Exception e){
log.error("接口连通性测试网络异常",e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR,"接口连通性测试失败,网络异常: " + e.getMessage());
}
// 5.测试通过,更新接口状态为上线
InterfaceInfo interfaceInfo = new InterfaceInfo();
interfaceInfo.setId(id);
interfaceInfo.setStatus(InterfaceInfoStatus.ONLINE.getValue());
boolean result = interfaceInfoService.updateById(interfaceInfo);
return ResultUtils.success(result);
}

添加获取用户登录信息的接口

因为在前端中,如果用户不小心刷新了浏览器,那么登录信息就会清空,所以添加下面这个接口,保证信息不会丢失:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 获取当前登录用户信息
*/
@GetMapping("/current")
@Operation(summary="获取当前登录用户信息")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request){
User user = userService.getLoginUser(request);

LoginUserVO loginUserVO = new LoginUserVO();
BeanUtils.copyProperties(user,loginUserVO);

return ResultUtils.success(loginUserVO);
}

到现在一些明显的遗留的问题都已经差不多解决了,下一期主要讲前端页面的展示,以及联调测试。