公司要在网关层给一批对外接口加统一加解密。理由很直白:支付、会员、消息、钱包、供应商这些对外接口,每个服务自己写一遍加解密,写十遍就有十种实现,改一次密钥要改十个地方。
方案定得很干脆------全部下沉到 Spring Cloud Gateway 做。业务服务完全不感知加解密,网关进来解密,出去加密。
但有几个例外要"豁免",比如支付回调(微信、支付宝、银行推过来的报文是明文或者用它们自己的验签方式),这些路径不能走我们的加解密,直接放行。
我负责实现,时间是三天。我当时觉得一天够了。
最初的设想
网关的加解密,逻辑上就这么几步:
- 进来的请求,判断路径是否在豁免名单里。在,直接放行。
- 不在,读取请求体,AES 解密,还原成明文 JSON。
- 把明文 JSON 塞回去,继续走后面的路由。
- 响应回来,再把明文 JSON 加密,写回客户端。
听起来不难,难的是第 2 步和第 3 步。
因为 Spring Cloud Gateway 是响应式的,请求体是一个 Flux<DataBuffer>,是一个流,默认只能被消费一次。
也就是说,我在 Filter 里读了请求体,后面转发给业务服务的时候就读不到了。
这就是所有坑的源头。
第一次尝试:直接读
我一开始写的是这样的:
java
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return DataBufferUtils.join(exchange.getRequest().getBody())
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
String body = new String(bytes, StandardCharsets.UTF_8);
String plain = decrypt(body);
// 然后......我拿什么把 plain 传下去?
return chain.filter(exchange);
});
}
问题一目了然:读完了,但没地方存。
而且这代码还有个更严重的问题------DataBufferUtils.join 之后,如果没有调 DataBufferUtils.release(dataBuffer),堆外内存不会释放。压测跑一会儿就能看到 LEAK: ByteBuf.release() was not called before it's garbage-collected,然后直接 OOM。
我第一版就是这么写的,本地测没问题,压到 500 并发的时候堆外内存直接爆了。
修的方式是加上 release:
java
DataBufferUtils.join(exchange.getRequest().getBody())
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer); // 必须的
...
});
这一句不写,你的网关就是一个内存泄漏器。 我当时翻了不少博客,很多示例都没写这句,直接抄过去会出事。
正确姿势:改写请求体
把请求体读出来解密之后,要重新构造一个 ServerHttpRequest:
java
private ServerHttpRequest buildDecryptedRequest(ServerHttpRequest request, byte[] plainBytes) {
return request.mutate()
.body(new String(plainBytes, StandardCharsets.UTF_8)) // 注意这里是 String 重载
.build();
}
然后:
java
ServerWebExchange newExchange = exchange.mutate().request(newRequest).build();
return chain.filter(newExchange);
request.mutate() 返回的是 ServerHttpRequest.Builder,它的 body(String) 方法会重新创建一个 Flux<DataBuffer>,这样就解决了"读一次"的问题。
这段代码本身没问题,但我当时踩了个更阴的坑:body() 方法有两个重载,一个接收 String,一个接收 Publisher<? extends DataBuffer>。
我一开始传的是 byte[],想着反正它内部会转。结果编译不过,因为 byte[] 两个都不匹配。改成 String 之后又遇到编码问题------我们的明文 JSON 里有中文(商品名称、备注),如果你解密出来的字节不是 UTF-8,转成 String 就乱码了。
我们客户端那边用的是国密 SM4,出来的字节默认按 UTF-8 编码,所以还好。但这里是一个很值得警惕的点:加解密链路一定要在协议层就把编码定死,别指望 Java 帮你猜。
响应加密:装饰器模式
请求这边搞定之后,轮到响应。
Gateway 里改响应要用 ServerHttpResponseDecorator:
java
ServerHttpResponse originalResponse = exchange.getResponse();
ServerHttpResponseDecorator responseDecorator = new ServerHttpResponseDecorator(originalResponse) {
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
if (body instanceof Flux) {
Flux<DataBuffer> fluxBody = (Flux<DataBuffer>) body;
return super.writeWith(fluxBody.buffer().map(dataBuffers -> {
DataBuffer joined = new DefaultDataBufferFactory().join(dataBuffers);
byte[] content = new byte[joined.readableByteCount()];
joined.read(content);
DataBufferUtils.release(joined);
String plain = new String(content, StandardCharsets.UTF_8);
byte[] encrypted = encrypt(plain).getBytes(StandardCharsets.UTF_8);
return originalResponse.bufferFactory().wrap(encrypted);
}));
}
return super.writeWith(body);
}
};
return chain.filter(exchange.mutate().response(responseDecorator).build());
这段代码我贴出来,是因为它有两个非常容易翻车的点。
第一个点:fluxBody.buffer() 会把整个响应体攒在一个 List<DataBuffer> 里。 对大响应来说这是灾难------我们有一个接口返回 5MB 的报表数据,加密的时候直接把网关的内存干上去了。
解决办法是给这个装饰器加一个"响应体大小上限",超过就不加密,或者干脆走流式加密。我们的做法比较土但有效:对超过 1MB 的响应直接走透传,不加密。 因为我们的对外接口返回都是小 JSON,大响应基本都是文件下载,本来就不需要加密。
第二个点:Content-Length。
加密之后,响应体的字节长度变了(AES 加密后长度会变,Base64 编码后还膨胀 33%)。如果你不在响应头里把 Content-Length 改成新的长度,客户端会按老的长度读,表现就是响应被截断。
这个 bug 极其难查,因为你在浏览器里看是好的(浏览器会宽容处理),只有用严格的 HTTP 客户端才会暴露。我们是对接方反馈"你们的响应少了一截",我才发现。
修法:
java
// 写回之前先算好新的 content-length
HttpHeaders headers = originalResponse.getHeaders();
headers.setContentLength(encrypted.length);
豁免机制看上去简单,其实有个顺序问题
豁免这块,我一开始就是写了个路径前缀匹配:
java
private static final List<String> EXEMPT_PREFIXES = List.of(
"/callback/wechat/pay",
"/callback/alipay",
"/callback/icbc",
"/actuator"
);
然后 path.startsWith(prefix) 就放行。
但运行起来发现,支付回调有时候还是被解密了,直接报解密失败。
原因是我们有一些回调路径是 /callback/wechat/payNotify 这种形式,前缀匹配是能命中的。但有一条路径是 /open/callback/bank,我漏写了。而更根本的问题是------路径匹配做在 Filter 里,但 Gateway 的路由断言也会对路径做处理(比如 StripPrefix)。
后来我把豁免规则统一成了 Ant 风格的匹配器,并且把它放在了最前面:
java
private final PathMatcher pathMatcher = new AntPathMatcher();
private boolean isExempt(String path) {
return EXEMPT_PATTERNS.stream().anyMatch(p -> pathMatcher.match(p, path));
}
EXEMPT_PATTERNS 用的是 /callback/** 这种通配,比 startsWith 稳。
Filter 的 order,比想象中重要
Spring Cloud Gateway 里 GlobalFilter 靠 Ordered 排序。加解密 Filter 的 order 如果不对,会出现"加密了两次"或者"路由还没匹配就已经加密了"这类问题。
我最后定的 order 是 Ordered.HIGHEST_PRECEDENCE + 100。
为什么不直接 HIGHEST_PRECEDENCE?因为要在它前面留出位置给最基础的一些 Filter(比如 AdaptCachedBodyGlobalFilter,这个后面说)。留 100 的余量,是给自己以后加东西用的。
一个官方彩蛋:AdaptCachedBodyGlobalFilter
后来我翻 Gateway 源码才发现,Spring 自己提供了一个 AdaptCachedBodyGlobalFilter,专门干"缓存请求体"这件事。配合 ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR,可以不用自己 join。
我当时的实现是自己 join 的,功能上没问题,但官方的方案在内存管理上更讲究一点,它会结合 maxInMemorySize 做限制。
这里给我一个很实在的教训:动手之前先翻一遍框架自带的能力。 我花了半天自己造轮子,结果人家早就有现成的,而且比我写得好。
不过我也不后悔。因为自己 join 一遍,我才真正搞懂 DataBuffer 的引用计数和释放时机------这个知识点,用官方的封装是学不到的。
最后
整个加解密链路上线之后,稳定跑了几个月,中间只有一次小事故:某个新接口的路径没加进豁免名单,导致一个第三方回调 500 了半小时。修完加了个启动时打印豁免名单的日志,方便核对。
回顾一下最关键的几个点:
| 问题 | 表现 | 解法 |
|---|---|---|
| 请求体只能读一次 | 转发后 body 为空 | request.mutate().body(...) 重建 |
| DataBuffer 未释放 | 堆外内存泄漏 / OOM | DataBufferUtils.release() |
| 响应体加密后长度变化 | 响应被截断 | 重设 Content-Length |
| 大响应体加密 | 网关内存暴涨 | 加大小上限,超限透传 |
| 豁免路径漏配 | 回调解密失败 | Ant 匹配 + 启动打印清单 |
| Filter 顺序错乱 | 重复加解密 | 显式指定 order |
网关层做统一加解密是个好方向,收口做一件事,比十个服务各做一遍靠谱得多。但它也是"牵一发而动全身"的位置------上面任何一个细节处理错了,影响的都是所有对外接口。
我的建议是:先把请求体和响应体这两条数据流彻底搞明白,再动手写加解密。 顺序搞反了,你会用大量时间在"为什么我读了 body 后面就没了"这种问题上打转。