本章是阶段四的上半部分,主要包括网关的搭建以及前面一些遗留下来的业务逻辑需要处理,还包括前端页面的展示。
网关(Gateway)
Spring Cloud Gateway 的底层使用的是响应式编程(WebFlux + Netty),而不是我们之前一直用的 Spring MVC(Tomcat)。 这意味着,在网关模块里,绝对不能引入 spring-boot-starter-web 依赖,否则项目启动会直接报错冲突!
配置网关类
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
spring: application: name: api-platform-gateway cloud: nacos: discovery: server-addr: localhost:8848 gateway: routes: - id: api_route uri: http://localhost:8102 predicates: - Path=/api/** filters: - StripPrefix=1 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); } }
|
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;
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); ServerHttpResponse response = exchange.getResponse();
log.info("请求唯一标识:" + request.getId());
log.info("请求路径:" + request.getPath().value());
log.info("请求来源地址:" + request.getLocalAddress().getHostString());
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");
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); }
return chain.filter(exchange); }
private Mono<Void> handleNoAuth(ServerHttpResponse response) { response.setStatusCode(HttpStatus.FORBIDDEN); return response.setComplete(); }
@Override public int getOrder() { 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
| public String getNameByGet(String name) { HashMap<String, Object> paramMap = new HashMap<>(); paramMap.put("name", name); String result = HttpUtil.get("http://localhost:8090/api/name/get", paramMap); System.out.println(result); return result; }
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; }
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 面板里重新双击 clean 和 install,把这个带有新地址的 jar 包安装到本地仓库! 否则接下来的测试还是会去调老地址。
重新打包好 SDK 后,打开 api-platform-interface 模块下的测试类 ApiInterfaceApplicationTests.java,会返回期望的结果:

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

到这里,这个复杂的跨进程链路就彻底跑通了:
SDK(客户端)发起请求 -> 打向 Gateway(8090) -> Gateway 进行签名拦截 -> Gateway 通过 Dubbo RPC 去 Backend 查数据库比对签名 -> 签名一致,Gateway 放行 -> Gateway 根据路由规则,将请求转发给 Interface(8102) -> Interface 执行业务逻辑并原路返回
接口调用次数统计
我们要实现这样的业务闭环:当用户成功调用一次 /name/user 接口后,系统会自动在数据库里扣除他该接口的 1 次调用配额,并把总调用次数加 1。
架构思考:这段代码应该写在哪里?
- 写在
Interface 模块? 不行,第三方接口服务只负责干活(比如返回天气),它不应该、也没有权限去管“平台扣费”这种核心业务。
- 写在网关
Gateway 模块? 也不行,网关应该尽可能轻量,只做路由和拦截。如果在网关里写长篇大论的数据库操作,会严重拖慢网关的并发性能。
- 正确答案:写在
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
|
public interface InnerUserInterfaceInvoke {
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; }
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,这一套(Mapper、Service、ServiceImpl)将在下一步实现,并把代码贴出来。
第三步:在 Gateway 网关模块发起 RPC 调用
这是最关键的一步。我们不能在网关转发请求之前扣费,万一接口服务挂了没返回结果,用户钱白扣了。必须在网关收到接口服务的成功响应后再扣费。
在实现这步之前,先把UserInterfaceInvoke这一套流程创建好
完成UserInterfaceInvoke流程
(1)新建 Mapper接口
在 api-platform-backend 模块的 mapper 包下新建 UserInterfaceInvokeMapper.java:
1 2 3 4 5 6 7
|
@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{ }
|
为什么要把 UserInterfaceInvokeServiceImpl和InnerUserInterfaceInvokeImpl分开来写?
有人会觉得,就在UserInterfaceInvokeServiceImpl类上同时加上@DubboService和@Service就行了,这样确实能跑通,但是同时也会带来很大的风险!
加上@DubboService意味着把这个业务类的接口全都暴露给其它微服务模块了,也就相当于这个业务类继承的Mybat-Plus里的底层接口也会暴露出去,例如save(),update()等方法,如果微服务其它模块被黑客入侵,那么他就可以直接用这些底层接口方法直接去操作你的数据库,及其不安全,所以要分开来写,只把必要的接口暴露出去。
接下来就在Gateway网关模块发起RPC调用
打开 api-platform-gateway 模块里的 CustomGlobalFilter.java
- 在类顶部引入刚刚写的 RPC 接口:
1 2 3 4
| @DubboReference(check = false) private InnerUserInterfaceInfoService innerUserInterfaceInfoService;
|
- 找到
// 4. 鉴权通过,放行请求! 这行代码,把它替换成下面的响应式处理逻辑:
1 2 3 4 5 6 7 8 9
| long interfaceInfoId = 1L; return handleResponse(exchange, chain, interfaceInfoId, invokeUser.getId());
|
- 在
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
|
private Mono<Void> handleResponse(ServerWebExchange exchange,GatewayFilterChain chain,long interfaceInfoId,long userId){ try{ ServerHttpResponse originalResponse = exchange.getResponse();
return chain.filter(exchange).then(Mono.fromRunnable(()->{ HttpStatusCode statusCode = originalResponse.getStatusCode(); if(statusCode != null && statusCode.is2xxSuccessful()){
try { boolean invokeResult = innerUserInterfaceInvoke.invokeCount(interfaceInfoId, userId);
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; }
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
|
String path = request.getPath().value().replace("/api",""); String method = request.getMethod().name();
InterfaceInfo interfaceInfo = innerInterfaceInfoService.getInterfaceInfo(path, method); if(interfaceInfo == null){
return handleNoAuth(response); }
long interfaceInfoId = interfaceInfo.getId(); long userId = invokeUser.getId();
return handleResponse(exchange, chain, interfaceInfoId, userId);
|
现在的闭环链路:
- SDK 向网关发请求。
- 网关 通过
InnerUserService 拿到 SK 完成验签。
- 网关 通过
InnerInterfaceInfoService 拿到该请求对应的 接口 ID。
- 网关 转发给真实服务。
- 网关 收到成功响应,通过
InnerUserInterfaceInfoService 给该 用户ID + 接口ID 的组合扣费。
但是:现在的网关每进一个请求都要打两次 RPC 到后台查库(一次查人,一次查接口),这在并发量大的时候会拖慢响应速度,后面会通过Redis缓存优化网关的查询性能。
现在重新启动整个项目,再用之前的测试类验证流程(注意,在测试前数据库需要用一条用户和接口的调用的关系的数据,并且用户id要对应测试类里的用户名,接口id对应测试类里的接口,调用配额要为有效数字)。
这次通过Gateway后台可以看到:

然后去观察数据库里的变化,会发现总调用次数+1,剩余配额-1。
到现在这个项目的核心计费与鉴权大动脉已经彻底贯通,这标志着后端的核心架构(接口提供、SDK 封装、微服务治理、网关统一拦截、RPC 通信计费)已经基本成型。
现在我们要去写前端页面,让用户有一个真正的页面去:
- 浏览所有的接口列表。
- 注册账号并查看自己的 AK/SK。
- 在网页上点击“申请调用”,分配调用次数。
- 在网页上填参数,一键实现在线测试(也就是去触发我们之前在
backend 里写的那个 /invoke 接口)。
在写前端的同时,还可以根据前端页面需要展示的东西去完善之前写过的接口,或者添加新的需要的接口。
等前后端彻底打通后,就开始对项目进行性能优化,引入中间件解决一系列问题。