如何使用WebFlux与Netty HttpClient获取请求体以计算请求摘要
WebFlux WebClient 请求体摘要计算实现方案
核心问题本质
WebFlux的WebClient与传统Spring MVC的同步拦截器逻辑差异,本质来自异步流式的模型设计:
- ExchangeFilterFunction拦截到的
ClientRequest中,请求体是以BodyInserter的形式存在的,是一个待执行的序列化逻辑,而非已经生成的可直接读取的字节内容 - 手动提前用Jackson序列化业务对象计算摘要的方案,存在两个不可避免的缺陷:一是与Spring实际发送请求时的序列化逻辑完全独立,容易因为配置差异导致内容不一致;二是重复执行序列化流程,增加不必要的性能开销
- 依赖Netty低层API的方案虽然可行,但需要手动处理Pipeline顺序、编解码器兼容问题,维护成本极高,并非必须的选择
最优实现思路
不需要直接操作Netty底层抽象层,完全基于Spring WebFlux的接口能力即可实现需求,核心逻辑是:
- 拦截到请求后,提前触发原请求绑定的
BodyInserter序列化逻辑 - 用自定义的捕获式请求对象接住序列化产生的全量请求字节,不实际触发网络发送
- 基于捕获到的字节计算内容摘要,将摘要值写入自定义的请求头
- 重新构造发送请求,将捕获到的全量字节作为请求体直接发送,复用已经完成的序列化结果,避免二次序列化
协议层面的固有约束:因为HTTP请求头在协议结构上是先于请求体发送的,因此要将请求体摘要放在请求头中,必须在发送请求之前完成全量请求体的生成与计算,无法做到边发送边计算摘要。
核心实现代码
首先实现通用的摘要计算工具:
public class DigestUtils { private static final String DEFAULT_ALGORITHM = "SHA-256"; public static String calculateContentDigest(byte[] content) throws NoSuchAlgorithmException { MessageDigest digest = MessageDigest.getInstance(DEFAULT_ALGORITHM); byte[] hash = digest.digest(content); // 转成十六进制字符串作为摘要值 return HexFormat.of().formatHex(hash); } }
然后实现自定义的ExchangeFilterFunction,完成请求体捕获、摘要计算、请求重写的全流程:
public class ContentDigestExchangeFilter implements ExchangeFilterFunction { private static final String DIGEST_HEADER = "X-Content-Digest"; @Override public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) { // 空请求体直接兜底,跳过捕获流程 if (request.body() instanceof BodyInserters.EmptyInserter) { return next.exchange(request); } // 捕获原请求的序列化输出 CapturingClientHttpRequest captureRequest = new CapturingClientHttpRequest(request); return request.body() .insert(captureRequest, request.log()) .then(captureRequest.getCapturedContent()) // 拿到全量捕获字节计算摘要 .flatMap(fullContent -> { String digest; try { digest = DigestUtils.calculateContentDigest(fullContent); } catch (NoSuchAlgorithmException e) { return Mono.error(e); } // 重写请求,添加摘要头,直接使用捕获的字节作为请求体 ClientRequest finalRequest = ClientRequest.from(request) .header(DIGEST_HEADER, digest) .body(BodyInserters.fromValue(fullContent)) .build(); return next.exchange(finalRequest); }); } }
最后实现自定义的捕获式ClientHttpRequest,只负责接住序列化产生的字节,不做实际网络发送:
class CapturingClientHttpRequest implements ClientHttpRequest { private final ClientRequest originalRequest; private final Sinks.Many<DataBuffer> bufferSink = Sinks.many().unicast().onBackpressureBuffer(); public CapturingClientHttpRequest(ClientRequest originalRequest) { this.originalRequest = originalRequest; } /** * 暴露捕获到的全量请求字节流 */ public Mono<byte[]> getCapturedContent() { return DataBufferUtils.join(bufferSink.asFlux()) .map(buffer -> { byte[] content = new byte[buffer.readableByteCount()]; buffer.read(content); DataBufferUtils.release(buffer); return content; }); } // 实现接口要求的方法,直接代理原请求配置,仅处理写入逻辑 @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { return Flux.from(body) .doOnNext(bufferSink::tryEmitNext) .doOnError(bufferSink::tryEmitError) .doOnComplete(bufferSink::tryEmitComplete) .then(); } @Override public HttpMethod getMethod() { return originalRequest.method(); } @Override public URI getURI() { return originalRequest.url(); } @Override public HttpHeaders getHeaders() { return originalRequest.headers(); } @Override public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) { return writeWith(Flux.from(body).flatMap(Function.identity())); } @Override public Mono<Void> setComplete() { return bufferSink.asFlux().then(); } }
方案优势与注意点
- 优势说明:
- 完全复用Spring的序列化逻辑:捕获到的字节就是Spring原本要发送到网络的字节,不会因为Jackson配置、字段顺序、自定义序列化规则的差异导致摘要计算错误
- 无重复序列化开销:捕获完成后直接以字节数组作为请求体发送,Jackson序列化逻辑只会执行一次
- 维护成本低:所有逻辑都基于Spring提供的抽象接口实现,不需要和Netty底层的编解码器、Pipeline逻辑耦合,版本升级不会出现兼容问题
- 注意事项:
- 内存权衡:因为需要将全量请求体读入内存计算摘要,该方案适合请求体大小在可控范围(建议单请求不超过10MB)的场景;如果是GB级的大文件上传,因为内存占用过高,不适合该方案,此时需要使用Netty低层API边读边计算摘要,且摘要需要放在HTTP分块传输的Trailer尾部,而非请求头中(受协议结构限制,请求头无法在请求体生成后补写)
- 缓冲区调整:可以根据实际的请求体大小,适当调整WebClient的最大内存缓冲区大小,避免因为序列化的字节数超过默认限制抛出异常
内容的提问来源于stack exchange,提问作者Claudio Tasso
相关产品推荐
相关产品推荐

