Spring Cloud Gateway 做统一加解密,我在请求体上栽了三天

公司要在网关层给一批对外接口加统一加解密。理由很直白:支付、会员、消息、钱包、供应商这些对外接口,每个服务自己写一遍加解密,写十遍就有十种实现,改一次密钥要改十个地方。

方案定得很干脆------全部下沉到 Spring Cloud Gateway 做。业务服务完全不感知加解密,网关进来解密,出去加密。

但有几个例外要"豁免",比如支付回调(微信、支付宝、银行推过来的报文是明文或者用它们自己的验签方式),这些路径不能走我们的加解密,直接放行。

我负责实现,时间是三天。我当时觉得一天够了。

最初的设想

网关的加解密,逻辑上就这么几步:

  1. 进来的请求,判断路径是否在豁免名单里。在,直接放行。
  2. 不在,读取请求体,AES 解密,还原成明文 JSON。
  3. 把明文 JSON 塞回去,继续走后面的路由。
  4. 响应回来,再把明文 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 后面就没了"这种问题上打转。

相关推荐
Rain的Java大神之路44 分钟前
别再上来就堆 Redis!点赞系统从打挂数据库到 18w 写峰值,我把这套方案讲透了
java·后端·面试
SimonKing1 小时前
开源文档解析天花板 MinerU 4.0:PDF / 图片 / Office 等任意文档都支持
java·后端·程序员
咖啡八杯1 小时前
Spring MessageSource 国际化消息解析流程
java·后端·spring·架构·框架·若依
念何架构之路1 小时前
go-grpc逻辑架构图
开发语言·后端·golang
博.闻广见1 小时前
第10篇:Agent系统的性能优化 —— 缓存、压缩与动态模型路由
java·人工智能·后端·spring·agent
打工仔折腾 AI1 小时前
极空间部署Portainer实战:绕过Socket权限、解决9000端口冲突与固定内网访问
人工智能·后端·python·性能优化
Irene19912 小时前
Flink 学习日志 01:前端转大数据的实战避坑指南
java·jvm·flink
Thomas.Sir2 小时前
第1课:SpringBoot 4.x 快速上手:新特性、环境准备与第一个HelloWorld程序
java·spring boot
king13598745502 小时前
LiteRT Tensor API:C++ 编写端侧模型图,XNNPACK、GPU 与 NPU 怎么选?
开发语言·c++·人工智能·chatgpt·架构·aigc