Gateway 多种形态:Server MVC 与 Proxy Exchange
本页是 U32(#69)的 G15/G16 权威正文,来源类型为固定官方模块与课程推导。新增工程固定 Boot 4.0.9 / Cloud 2025.1.4 / Gateway 5.0.4,Gateway 源码提交 2780fa2117ce3f8af662aa3d485ed77743aa6fb0;查询与运行日期为 2026-10-26。本批实际运行环境为 Oracle JDK 21.0.5、Maven 3.9.9、macOS 26.7.1 x86_64;Framework 7.0.10、Tomcat 11.0.27 等解析版本与四模块计数见运行证据。此前候选基线的 Liberica 21.0.11/aarch64 未在本机复现,不写成运行事实。
学习目标是能独立选择形态、写出请求路径与头策略、识别失败发生在哪一层,并能计算请求体缓存、阻塞等待和重试放大成本。下文的源码、设计算例与实际 HTTP 观察分别标注;教材覆盖不表示全部扩展配置已经实跑。
产品边界与旧教材保全
| 产品 | 依赖与容器 | 请求执行链 | 配置与责任 |
|---|---|---|---|
| Server Web MVC | spring-cloud-starter-gateway-server-webmvc;Servlet/Tomcat 或 Jetty | Servlet filters → DispatcherServlet → WebMvc.fn RouterFunction → HandlerFilterFunction → RestClientProxyExchange → 下游 | spring.cloud.gateway.server.webmvc;路由、谓词、before/after、代理头策略由 Server 提供 |
| Proxy Exchange MVC | spring-cloud-gateway-proxyexchange-webmvc + MVC starter;本实验 Tomcat | 注解 controller → ProxyExchangeArgumentResolver → org.springframework.cloud.gateway.mvc.ProxyExchange → RestTemplate | spring.cloud.gateway.proxy-exchange.webmvc;目标 URI、路径、调用方法及安全策略由 controller 负责 |
| Proxy Exchange WebFlux | spring-cloud-gateway-proxyexchange-webflux + WebFlux starter;本实验 Reactor Netty | 注解 handler → 独立参数解析器 → org.springframework.cloud.gateway.webflux.ProxyExchange → WebClient → Mono<ResponseEntity<T>> | spring.cloud.gateway.proxy-exchange.webflux;没有自动继承 Server 路由/过滤器/限流/管理能力 |
| Server WebFlux(对照) | spring-cloud-starter-gateway-server-webflux;Reactor/Netty | RoutePredicateHandlerMapping → FilteringWebHandler → GatewayFilter/GlobalFilter → Netty 路由 | 由 U32 的 Server WebFlux 正文及 labs/cloud-gateway/webflux 主归讲授;不能当作 MVC 的执行链 |
同一个 JVM 混入两个 Server starter 会改变 Boot 栈选择与自动配置条件。三个模块分别有独立 POM、包名和测试进程;根 POM 只需聚合,不必成为它们的 parent。RouterFunction 使用的是 org.springframework.web.servlet.function,与 WebFlux 同名类型不通用。Server MVC 适合既有 Servlet/Security/filter 生态;Proxy Exchange 适合少量具有业务组合逻辑的 BFF/controller;上百条动态路由与统一策略应评估 Server 形态的治理成本,不能把每条规则复制成业务 controller。
旧权威保全:HTTP 客户端 的 Framework 6.1.12/JDK 17 机制继续有效,不悄悄换成 7.0 的运行结论;超时重试、熔断限流与降级保留 deadline、UNKNOWN、幂等与恢复模型;Servlet Security保留身份与过滤链权威;WebFlux 与取消保留响应式资源释放权威。本页补新版本的网关适配,不宣称 RestTemplate 已在所有产品中被替换。
Server MVC 的默认 bean 直接构造 RestClientProxyExchange;这并不决定 Proxy MVC 的客户端。
真实源码 GatewayServerMvcAutoConfiguration.java L116–L125;整文件 SHA-256 b87a53cab3569255d80bde8f4d18f286b7f95c20f9727aa2e0c7382c97f8a6ce。
}
@Bean
@ConditionalOnMissingBean(ProxyExchange.class)
public RestClientProxyExchange restClientProxyExchange(RestClient.Builder restClientBuilder,
GatewayMvcProperties properties) {
return new RestClientProxyExchange(restClientBuilder.build(), properties);
}
@BeanProxy MVC 参数解析器持有自己的 RestTemplate;自动配置还设置无操作错误处理器,让下游错误状态作为响应返回。
真实源码 ProxyResponseAutoConfiguration.java L65–L88;整文件 SHA-256 5aba9ca32a51e827a8835cdc8dcdd36a988f8600362595d4fe062228f14663f0。
@Bean
@ConditionalOnMissingBean
public ProxyExchangeArgumentResolver proxyExchangeArgumentResolver(Optional<RestTemplateBuilder> optional,
ProxyExchangeWebMvcProperties properties) {
RestTemplateBuilder builder = optional.orElse(new RestTemplateBuilder());
RestTemplate template = builder.build();
template.setErrorHandler(new NoOpResponseErrorHandler());
template.getMessageConverters().add(new ByteArrayHttpMessageConverter() {
@Override
public boolean supports(Class<?> clazz) {
return true;
}
});
ProxyExchangeArgumentResolver resolver = new ProxyExchangeArgumentResolver(template);
resolver.setHeaders(properties.convertHeaders());
resolver.setAutoForwardedHeaders(properties.getAutoForward());
Set<String> excludedHeaderNames = new HashSet<>();
if (!CollectionUtils.isEmpty(properties.getSensitive())) {
excludedHeaderNames.addAll(properties.getSensitive());
}
if (!CollectionUtils.isEmpty(properties.getSkipped())) {
excludedHeaderNames.addAll(properties.getSkipped());
}
resolver.setExcluded(excludedHeaderNames);独立构建与最小真实 HTTP 接缝
每个模块均以 Boot 4.0.9 为 parent(relativePath 为空),并 import Cloud 2025.1.4,Gateway 由 train BOM 选出 5.0.4,不手填另一套 Gateway 版本。以 MVC 模块为例,完整 POM 与应用见 mvc/pom.xml 和 MvcApplication.java。核心版本声明如下,其他两个模块只按产品表替换依赖,不把根 aggregator 当 parent:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.9</version><relativePath/>
</parent>
<properties><java.version>21</java.version></properties>
<dependencyManagement><dependencies><dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId><version>2025.1.4</version>
<type>pom</type><scope>import</scope>
</dependency></dependencies></dependencyManagement>从仓库根目录依次运行;每条都是公开的完整测试入口,不要求先启动 fixture,也不要求 Redis、注册中心或外部凭据:
mvn -B -ntp -f labs/cloud-gateway/mvc/pom.xml clean test
mvn -B -ntp -f labs/cloud-gateway/proxy-mvc/pom.xml clean test
mvn -B -ntp -f labs/cloud-gateway/proxy-webflux/pom.xml clean test测试使用 JDK HttpClient → 随机端口的真实 Tomcat/Netty → 随机端口的 JDK HttpServer 上游;所有监听地址为 127.0.0.1。fixture 返回它在 HTTP 边界收到的 path/query/body/headers,/__fixture/down、/__fixture/up 通过 fixture 自己的 HTTP 控制接口切换 503/200。它是真实 HTTP 对端,业务行为是合成 echo,不能据此声称真实订单服务、注册中心、TLS 或网络分区已验收。
应用自己的 application.properties 显式设置 spring.http.clients.connect-timeout=1s、read-timeout=2s;测试客户端每请求 10s。Boot 默认未设置这两个 Duration,不要写成所有客户端天然都有固定 1s/2s 上限;连接池获取等待、重试与整个业务 deadline 仍需另行约束。配置只属于本应用,不修改全局环境。启动会用 --lab.upstream=<本测试随机端口> 指定固定可信上游。
每个测试的 try-with-resources 关闭本 context、HttpClient、fixture,fixture stop(0) 并关闭自己的虚拟线程执行器。Reactor 资源由该独立测试 JVM/Boot context 管理,不调用面向其他进程的批量 kill,不清空浏览器或用户记录。任一断言失败同样进入关闭路径。本次入口运行的 Maven 日志目录记录在运行证据的 logs 字段;开发期间单独执行 Maven 的日志未入库,可重建的产物是各模块 target/surefire-reports 与观察行。
MVC 路由、谓词与属性装配
Server MVC 使用 WebMvc.fn 的 RouterFunctions.Builder。普通 RouterFunctions.route() 已能注册函数式路由;GatewayRouterFunctions.route("id") 还补 routeId 请求元数据和 Gateway 包装,供需要元数据的高级过滤器使用。http() 从请求属性拿目标 URI;用 .before(uri(...)) 设置,而不是继续使用已废弃的 http("http://...") 重载。先匹配路由,再执行该路由上的 filters;路径被改写后不会自动重新选择另一个入口路由。
本模块的完整可运行 Java DSL 在源码文件内;其路由段为:
return route("u32-mvc")
.route(path("/edge/**"), http())
.before(uri(upstream))
.before(rewritePath("/edge/(?<tail>.*)", "/api/${tail}"))
.before(removeRequestHeader("Authorization"))
.before(removeRequestHeader("Cookie"))
.before(addRequestHeader("X-Before", "mvc"))
.after(addResponseHeader("X-Gateway", "mvc"))
.build();等价的声明式选择可以单独使用以下配置;这里是配置教学示例,未与 Java 路由同时装载:
spring:
cloud:
gateway:
server:
webmvc:
routes:
- id: catalogue
uri: http://127.0.0.1:19090
predicates:
- Path=/edge/**
- Method=GET,POST
filters:
- RewritePath=/edge/(?<tail>.*), /api/$\{tail}
- RemoveRequestHeader=Authorization
- RemoveRequestHeader=Cookie
- AddRequestHeader=X-Before,mvc
- AddResponseHeader=X-Gateway,mvcYAML 使用 plain scalar 的 $\{tail} 避免被 Spring 当属性占位符,MVC rewritePath 会将 $ 后的反斜杠规范化;Java DSL 直接写 ${tail}。动态 URL 的 origin 必须是配置/服务发现结果,不接收调用者传来的任意 URL。路线选择不是认证:Header=X-Tenant,t1 只能匹配字符串,不能证明租户 t1 的身份。
| MVC 谓词族 | 输入、组合与反例 | 版本条件及成本 |
|---|---|---|
| After/Before/Between | ZonedDateTime;实现用严格 isAfter/isBefore,端点时刻不命中;时区与部署时钟必须明确 | 不把它当统一时钟或分布式一致性切换;灰度发布时间需时钟误差预算 |
| Path/Host/Method | PathPattern 路径、主机模式、HTTP 方法;多个模式可以 OR,多个独立 predicates 为 AND | /edge/** 不等于任意 URI;Host 可来自用户请求,不能作为授权 |
| Header/Cookie/Query | 名称存在或值的 Java regexp;缺失与空串分开 | 正则在请求路径上执行,限制长度和复杂度;不信任伪造身份头 |
| Weight | 组内路由权重,与 WeightCalculatorHandlerInterceptor 的请求属性协同 | 80/20 是统计路由选择;100 次请求不保证正好 80 次,故障摘流也不是权重的职责 |
| Version(5.0) | 委托 Framework API version predicate;先配置 MVC ApiVersionStrategy,决定头/参数等来源与支持版本 | 不能只写 Version=1.0 就假定所有来源已开启;版本路由不等于 LB 的 API_VERSION 实例筛选 |
| readBody / 自定义 RequestPredicate | 读取/缓存 body 后返回布尔,或实现 boolean test(ServerRequest) | 大 body/多次解码的内存成本必须计算;常规路由优先用 path/header |
| RemoteAddr/XForwardedRemoteAddr | 固定 MVC 文档中的相关段落被 AsciiDoc //// 注释,源码无对应 GatewayRequestPredicates 工厂 | 这是未实现/不适用的目录残留,不能直接复制 WebFlux resolver;需要在可信 Servlet/Security 层实现地址策略并真实验收 |
固定谓词文档中有注释区和少数漏 gateway 前缀示例;配置以 GatewayMvcProperties.PREFIX 与实际工厂为准。自定义 predicate/filter 经配置名调用还需分别注册 PredicateSupplier/FilterSupplier(META-INF/spring.factories),直接 Java DSL 则可传函数。不要把 YAML 函数名发现失败解释为“路由不匹配”。
routeId 是该 builder 实际补入的 before 元数据,不是注解 controller 自动拥有的属性。
真实源码 GatewayRouterFunctions.java L37–L45;整文件 SHA-256 b9a976602f8d48c2dd85ffad46c4291d8b819938ea87b468970a6339e55e0d3a。
public static Builder route(String routeId) {
Builder builder = RouterFunctions.route().before(routeId(routeId));
return new GatewayRouterFunctionsBuilder(builder, routeId);
}
public static <T extends ServerResponse> RouterFunction<T> route(RequestPredicate predicate,
HandlerFunction<T> handlerFunction) {
return RouterFunctions.route(predicate, handlerFunction);
}时间窗使用严格比较;这段真实代码给出了相等时刻的反例。
真实源码 GatewayRequestPredicates.java L84–L100;整文件 SHA-256 3bc72e52afcb94295d158e76ba0a6c69eb45fd23d72f5ac16d3f1352340eb33f。
@Shortcut
public static RequestPredicate after(ZonedDateTime dateTime) {
return request -> ZonedDateTime.now().isAfter(dateTime);
}
@Shortcut
public static RequestPredicate before(ZonedDateTime dateTime) {
return request -> ZonedDateTime.now().isBefore(dateTime);
}
@Shortcut
public static RequestPredicate between(ZonedDateTime dateTime1, ZonedDateTime dateTime2) {
return request -> {
ZonedDateTime now = ZonedDateTime.now();
return now.isAfter(dateTime1) && now.isBefore(dateTime2);
};
}MVC before、after、filter 与 Servlet 的顺序
before(Function<ServerRequest,ServerRequest>) 在 next 前变换请求;after(BiFunction<ServerRequest,ServerResponse,ServerResponse>) 在 next 返回后变换响应;filter(HandlerFilterFunction) 能围绕 next 执行、短路或捕获异常。before 必须返回供后继使用的新请求,不能只创建 builder 却丢弃结果。after 只在能获得响应时生效,不能撤回已写入 socket 的字节。
普通过滤器按声明组成嵌套:A-pre → B-pre → handler → B-post → A-post。路由级 filter 与整个 Servlet filter chain 是两个层次,不能拿 GlobalFilter.getOrder() 的数字来解释 MVC。Spring Security 和 Servlet filter 可能在路由选择前直接返回 400/401/403;有些 Servlet Filter 也会在回程处理响应,必须看完整调用栈。
本轮实测发现 Framework 7.0.10 的 RouterFunctions.Builder.onError 会被拼接在普通 filter 列表之后,它能处理 handler 内部的异常,却未捕获更外层的 LB filter 抛错。把 .onError(...) 在 builder 上上下移动都没有修复该例。模块改用放在 LB 之前的显式 wrapper,见下一节;这也是为什么要从真实 HTTP 观察外部状态。
表单的独立陷阱:Servlet 容器把 application/x-www-form-urlencoded 的 body 与 query 合并进 request parameters;Gateway FormFilter 为下游重建表单。会提前读参数的自定义 Servlet Filter 要按固定文档设为 FormFilter.FORM_FILTER_ORDER - 1,同时避免将审计日志中原始 body 与重建 body 混同。表单、多值字段、编码差异本轮没有实跑,不能将 text/plain 的通过扩张成表单兼容证明。
自定义安全请求头的完整局部函数可以在 DSL 中使用 .before(request -> ServerRequest.from(request).headers(h -> { h.remove("X-User"); h.set("X-User", trustedSubject); }).build()),前提是 trustedSubject 来自已认证上下文。若在入口信任请求自带的 X-User,即使之后命中了路由也没有身份保证。
过滤器功能按 MVC 实现归类,而不是用“WebFlux 同名”代替:
| 类别 | 本版本入口与使用边界 |
|---|---|
| 请求头/参数 | AddRequestHeader、AddRequestHeadersIfNotPresent、MapRequestHeader、RemoveRequestHeader、AddRequestParameter、RemoveRequestParameter、RewriteRequestParameter;执行前先决定来源可信度与多值覆盖/追加语义 |
| 路径 | RewritePath、SetPath、PrefixPath、StripPrefix、StripContextPath;重写顺序必须先于依赖目标 URL 的 LB,测试 query 与 context path |
| 响应头/重定向 | AddResponseHeader、RemoveResponseHeader、DedupeResponseHeader、RewriteLocationResponseHeader、RedirectTo;响应 CORS 重复不能简单一律保留第一个;Location 需防止暴露内部主机 |
| Host 与代理头 | PreserveHostHeader 只改变出站 Host 策略;Forwarded、XForwarded、RemoveHopByHop 由 HttpHeadersFilter 处理,不能靠 preserveHost 建立可信代理 |
| body 与大小 | ModifyRequestBody、ModifyResponseBody、RemoveJsonAttributesResponseBody;RequestSize、RequestHeaderSize;拒绝过大输入比缓存后才检查更节省内存;响应重写会影响长度/编码 |
| 状态与容错 | Retry、AdaptCachedBody、LoadBalancer;CircuitBreaker/FallbackHeaders 属 U33 的断路器机制,只保留 Gateway 接入位置,不在本轮续做 U33 |
| 身份与配额 | TokenRelay、Bucket4j RateLimiter;分别是 OAuth2 client token 获取/转发与令牌消耗,不能替代认证或权限检查;会话持久化按 Servlet/Security 语义处理,不能假定存在同名 WebFlux SaveSession 工厂 |
完整 MVC filter 目录与扩展说明、Servlet filter 顺序原文是配置核查入口,不表示表内所有组合已做集成测试。
MVC RestClient 代理与 LB 无实例的外部 503
ProxyExchangeHandlerFunction 用原请求 URI 的 path/query,加已配置目标的 scheme/host/port 生成出站 URL;不能期待在 uri("http://peer/base") 上写一个 /base 就替代入口路径。使用 rewritePath/setPath 显式定义路径。本实验 POST /edge/alpha?x=7 在上游收到 /api/alpha?x=7 和 payload,普通头 X-Client 保留,Cookie/Authorization 被路由显式移除。
RestClientProxyExchange 使用 exchange 而非面向业务的 retrieve().body(...),流式复制请求/响应,读取原下游状态;HTTP 503 可以是正常返回的 HTTP 响应,不一定是客户端异常。具体 ClientHttpRequestFactory 随 Boot/classpath 选择,不能因为应用入口是 Tomcat 就假定出口一定是 Apache 或 JDK。Gateway 装配将默认 redirects 改成 DONT_FOLLOW(用户已设置则尊重),在采用 JDK factory 时可能在本 JVM补 allowRestrictedHeaders 的 Host,不能把它描述为修改 macOS 全局设置。
lb(serviceId) 使用 BlockingLoadBalancerClient 选择实例、按 secure 标记重建 scheme,并写入后继代理所需的 URL。要有 spring-cloud-starter-loadbalancer,配置方式可为声明路由 uri: lb://catalogue,或者先完成路径改写再 .filter(lb("catalogue"))。Gateway 的 RestClient 不要再加 @LoadBalanced,否则可能发生双重 LB;serviceId 属于 LB,选出 IP:port 后交给 HTTP 客户端。
无实例时固定代码抛 HttpServerErrorException(SERVICE_UNAVAILABLE)。不能照搬文档写成“所有默认配置必然 503”:本模块不假定裸 Servlet 错误链的默认输出,而是显式加外层映射后由真实客户端断言 503;裸链行为未纳入本次自动验收。外层映射如下:
return route("u32-empty")
.route(path("/empty/**"), http())
.filter((request, next) -> {
try {
return next.handle(request);
} catch (HttpServerErrorException error) {
return ServerResponse.status(error.getStatusCode())
.body("No instance available");
}
})
.filter(lb("u32-no-instances"))
.build();该 wrapper 仅位于空实例教学路由,不把所有错误重写成 503。区分无路由 404、无实例异常、HTTP 上游返回 503、连接拒绝、超时和客户端断连:前三者在本模块已有断言,后面几种未在本轮实跑。修复空目录应恢复发现/候选实例后复验,而不是一律开启重试;本轮恢复验证是固定上游 503→200,不冒充服务发现新增实例恢复。
该分支给出“抛出 503 异常”的源码事实;最终 HTTP 状态必须再经过 Servlet 异常处理链验证。
真实源码 LoadBalancerFilterFunctions.java L88–L105;整文件 SHA-256 8d350a13340f36fa3ba99d4f717dc079ee0f385f5836303077cd2d50d3c55dad。
ServiceInstance retrievedInstance = loadBalancerClient.choose(serviceId, lbRequest);
if (retrievedInstance == null) {
supportedLifecycleProcessors.forEach(lifecycle -> lifecycle
.onComplete(new CompletionContext<>(CompletionContext.Status.DISCARD, lbRequest)));
throw new HttpServerErrorException(HttpStatus.SERVICE_UNAVAILABLE,
"Unable to find instance for " + serviceId);
// throw NotFoundException.create(properties.isUse404(), "Unable to find
// instance for " + serviceId);
}
URI uri = request.uri();
// if the `lb:<scheme>` mechanism was used, use `<scheme>` as the default,
// if the loadbalancer doesn't provide one.
String scheme = retrievedInstance.isSecure() ? "https" : "http";
DelegatingServiceInstance serviceInstance = new DelegatingServiceInstance(retrievedInstance, scheme);默认 RestClient 代理执行的是底层 exchange,保留错误响应的流和状态。
真实源码 RestClientProxyExchange.java L43–L53;整文件 SHA-256 bcf9faba0a0bbd2d3fe6eba12a5bc6c0d0b8ff995c4e6740be3745c4caa4236e。
public ServerResponse exchange(Request request) {
Objects.requireNonNull(request.getUri(), "uri cannot be null");
RestClient.RequestBodySpec requestSpec = restClient.method(request.getMethod())
.uri(request.getUri())
.headers(httpHeaders -> httpHeaders.putAll(request.getHeaders()));
if (isBodyPresent(request)) {
requestSpec.body(outputStream -> copyBody(request, outputStream));
}
return requestSpec.exchange((clientRequest, clientResponse) -> doExchange(request, clientResponse), false);
}MVC Retry 的依赖选择、次数与缓存责任
Retry 只有配置到路由上才执行。5.0.4 会检查 org.springframework.retry.annotation.Retryable:有 Spring Retry 时默认选 GatewayRetryFilterFunctions,没有时选 Framework 7 的 FrameworkRetryFilterFunctions。显式 spring.cloud.gateway.server.webmvc.use-framework-retry-filter=true 可以强制后者。Spring Retry 是可选依赖,本实验三个 POM 没有主动引入它,也未配置 Retry 路由;依赖树不等于运行重试已验收。
以下是独立的 GET 重试教学配置,固定 series 而不复制文档里未实现的 statuses 示例:
spring:
cloud:
gateway:
server:
webmvc:
use-framework-retry-filter: true
routes:
- id: read-retry
uri: http://127.0.0.1:19090
predicates:
- Path=/catalogue/**
- Method=GET
filters:
- name: Retry
args:
retries: 2
series: SERVER_ERROR
methods: GET
cacheBody: false
backoff:
firstBackoff: 20ms
maxBackoff: 100ms
factor: 2固定 RetryConfig 默认 retries=3、series=5xx、methods=GET、exceptions 含 IOException/TimeoutException/RetryException、cacheBody=false;但“无 backoff 配置”不能自动解释为两种实现都零等待:Framework 分支取 Framework RetryPolicy.withDefaults().getBackOff(),Spring Retry 分支使用自己的 policy。将依赖变动纳入配置审查与上游计数实验,不能只在 application.yml 看见 Retry 就宣布语义没变。
Framework 分支有一处注释称 retries 包含首次,但实际代码 attemptCount >= config.getRetries() 从 0 检查、每次允许重试才加 1;据分支推演 retries=2 会允许首次失败后再试两次(最多 3 次调用)。这是源码推演,未做次数 HTTP 验收;最终应让上游按 operationId 回显计数,分别在两个 classpath 上验证,不能引用注释消除矛盾。Spring Retry 分支也需按实际策略核算,不混用 WebFlux RetryConfig。
若 gateway 最多 3 次、下游 SDK 最多 2 次,则每个入口最多 6 次下游尝试;1000 请求/s 的失败风暴可能带来 6000 次/s,而不是预算中的 1000。总 deadline=500ms、每次下游 120ms、两段 backoff 20+40ms 时,3 次串行尝试占 420ms,剩余 80ms 还要涵盖排队/网络/序列化;不能将每层各自的 timeout 当总 deadline。
需要重试请求体时才打开 cacheBody=true 并在 Retry 后加 AdaptCachedBody。它会把整个 body 缓存在请求属性,重试其后的 filters,日志计数/令牌消耗/副作用都可能再次执行。限流放 Retry 外层按入口计费、放内层按尝试计费,是两种不同预算;必须指定一种。写请求要有业务幂等键和 UNKNOWN 查单,安全恢复不是看到 503 就无限重放。
入口选择取决于 classpath,新增一个传递依赖也可能改变所选实现。
真实源码 RetryFilterFunctions.java L49–L65;整文件 SHA-256 50e80bc115661742ab16766424a7f5f6f7ae6cbb97494b70caaf02cbc1c62c68。
private static final boolean USE_SPRING_RETRY = ClassUtils
.isPresent("org.springframework.retry.annotation.Retryable", ClassUtils.getDefaultClassLoader());
private static boolean useFrameworkRetry = false;
private RetryFilterFunctions() {
}
@Shortcut
@SuppressWarnings("deprecation")
public static HandlerFilterFunction<ServerResponse, ServerResponse> retry(int retries) {
return useSpringRetry() ? GatewayRetryFilterFunctions.retry(retries)
: FrameworkRetryFilterFunctions.frameworkRetry(retries);
}
@SuppressWarnings("deprecation")
public static HandlerFilterFunction<ServerResponse, ServerResponse> retry(Consumer<RetryConfig> configConsumer) {次数应根据判断与自增路径推演;这里保留相互矛盾的原注释供回查,不把注释当运行证据。
真实源码 FrameworkRetryFilterFunctions.java L143–L182;整文件 SHA-256 bab0172d6602cb71b79d2ff12163562f621445c6cce9836456a415a5fd6f44c3。
public boolean shouldRetry(Throwable throwable) {
// If no throwable, don't retry
if (throwable == null) {
return false;
}
// Check if we've exceeded max attempts
// Note: config.getRetries() represents max attempts (including initial
// attempt)
// attemptCount tracks the number of attempts made so far (0 = first attempt,
// 1 = second attempt, etc.)
// shouldRetry is called after each failed attempt, so:
// - First failure: attemptCount = 0, we can retry (will become attempt 1)
// - Second failure: attemptCount = 1, we can retry (will become attempt 2)
// - Third failure: attemptCount = 2, we can retry (will become attempt 3)
// - After third failure: attemptCount = 3, we've reached max attempts, stop
// So if retries=3, we allow attempts 0, 1, 2 (3 total attempts)
if (attemptCount >= config.getRetries()) {
return false;
}
boolean shouldRetry = false;
// Check if it's an HTTP status retry case
if (throwable instanceof RetryFilterFunctions.RetryException retryException) {
shouldRetry = isRetryableStatusCode(retryException.getResponse().statusCode(), config)
&& isRetryableMethod(retryException.getRequest().method(), config);
}
else {
// Check exception-based retry
shouldRetry = config.getExceptions()
.stream()
.anyMatch(exceptionClass -> exceptionClass.isInstance(throwable));
}
// If we should retry based on exception/status, increment counter
// The check above ensures we won't exceed max attempts
if (shouldRetry) {
attemptCount++;
return true;MVC Bucket4j 的配额、后端和故障
MVC Bucket4jFilterFunctions.rateLimit 的 keyResolver 类型是 Function<ServerRequest,String>,不是 WebFlux KeyResolver。需要显式提供 Bucket4j 8.15.0 及具体后端;Gateway BOM 没有替应用选出这个版本。下面是完整局部配置示例(源码核查,未纳入最小模块依赖/运行),为本地演示引入:
<dependency><groupId>com.bucket4j</groupId><artifactId>bucket4j_jdk17-core</artifactId><version>8.15.0</version></dependency>
<dependency><groupId>com.bucket4j</groupId><artifactId>bucket4j_jdk17-caffeine</artifactId><version>8.15.0</version></dependency>
<dependency><groupId>com.github.ben-manes.caffeine</groupId><artifactId>caffeine</artifactId></dependency>import com.github.benmanes.caffeine.cache.Caffeine;
import io.github.bucket4j.caffeine.CaffeineProxyManager;
import io.github.bucket4j.distributed.proxy.AsyncProxyManager;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerResponse;
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
import static org.springframework.cloud.gateway.server.mvc.filter.BeforeFilterFunctions.uri;
import static org.springframework.cloud.gateway.server.mvc.filter.Bucket4jFilterFunctions.rateLimit;
@Configuration
class LocalQuotaConfiguration {
@Bean
AsyncProxyManager<String> buckets() {
Caffeine<Object, Object> cache = Caffeine.newBuilder().maximumSize(10_000);
return new CaffeineProxyManager<String>(cache, Duration.ofMinutes(10)).asAsync();
}
@Bean
RouterFunction<ServerResponse> quotaRoute() {
return route("catalogue-quota").GET("/quota/**", http())
.before(uri("http://127.0.0.1:19090"))
.filter(rateLimit(c -> c.setCapacity(100).setPeriod(Duration.ofSeconds(1))
.setTokens(1).setTimeout(Duration.ofMillis(50))
.setKeyResolver(r -> r.principal()
.map(p -> "catalogue:" + p.getName()).orElse(""))))
.build();
}
}上述 CaffeineProxyManager 构造器在 Bucket4j 8.15.0 固定源码仍存在,但已标记 deprecated;新项目可改用其 Bucket4jCaffeine builder。这里保留与 Gateway 固定文档对照的构造方式。容量/周期必须显式给值;默认桶构造使用 refillGreedy(capacity, period)。每请求 tokens 默认为 1、空 key 返回 403、配额不足默认 429、剩余头默认 X-RateLimit-Remaining,timeout 默认没设。源码对 future 使用阻塞 get(),即使底层名称是 AsyncProxyManager,Servlet 等待线程仍会被占用。异常/timeout 会抛出;它没有 WebFlux RedisRateLimiter 那种 Lua fail-open 分支,也不能假定所有错误都是 429。
key 在后端直接使用 resolver 结果,路由隔离由应用加入 catalogue: 等命名空间;否则同一用户可能共享本不该共享的配额。Caffeine manager 的状态仅在一个进程,两个副本各 100/s 可放行接近 200/s;热桶逐出/进程重启会失去旧配额,maximumSize 不是全局配额准确性保证。共享后端能集中计数,但增加每请求 RTT、连接和故障责任;需要分别演练后端慢、不可用、恢复时是否产生突发,本轮未验证。
设计算例:1000 请求/s、桶后端平均 RTT=10ms,按 Little 定律额外约 10 个等待中的请求;RTT=200ms 则约 200 个,可能耗尽 200 条 Servlet 工作线程。50ms 超时需要与业务 deadline、拒绝策略和后端恢复告警一同设计。选择本地桶时接受每副本预算、低延迟;选择共享桶时由平台团队承担后端容量和故障恢复,业务团队确定超额/后端失败的用户语义。
真正的分支包含空 key、后端 get 等待、配额拒绝三类结果,不能把它们合并成 Redis Lua 行为。
真实源码 Bucket4jFilterFunctions.java L76–L105;整文件 SHA-256 b19c9ea6aab5c4d372841542f84767c5fd9bfae6ee7590b825df41ebe8fb73b0。
return (request, next) -> {
AsyncProxyManager proxyManager = MvcUtils.getApplicationContext(request).getBean(AsyncProxyManager.class);
String key = config.getKeyResolver() != null ? config.getKeyResolver().apply(request) : "";
if (!StringUtils.hasText(key)) {
// TODO: configurable empty key status code
return ServerResponse.status(HttpStatus.FORBIDDEN).build();
}
TaskExecutor taskExecutor = MvcUtils.getApplicationContext(request)
.getBean(APPLICATION_TASK_EXECUTOR_BEAN_NAME, TaskExecutor.class);
Supplier<CompletableFuture<BucketConfiguration>> configSupplier = () -> CompletableFuture
.supplyAsync(bucketConfigurationSupplier, taskExecutor);
AsyncBucketProxy bucket = proxyManager.builder().build(key, configSupplier);
CompletableFuture<ConsumptionProbe> bucketFuture = bucket.tryConsumeAndReturnRemaining(config.getTokens());
ConsumptionProbe consumptionProbe;
if (config.getTimeout() != null) {
consumptionProbe = bucketFuture.get(config.getTimeout().toMillis(), TimeUnit.MILLISECONDS);
}
else {
consumptionProbe = bucketFuture.get();
}
boolean allowed = consumptionProbe.isConsumed();
long remainingTokens = consumptionProbe.getRemainingTokens();
if (allowed) {
ServerResponse serverResponse = next.handle(request);
serverResponse.headers().add(config.getHeaderName(), String.valueOf(remainingTokens));
return serverResponse;
}
return ServerResponse.status(config.getStatusCode())
.header(config.getHeaderName(), String.valueOf(remainingTokens))
.build();MVC 请求体缓存与响应缓存的版本边界
需要区分三件事:MvcUtils.getOrCacheBody 保存一次请求体供重试/改写,AdaptCachedBody 让后继读取它,HTTP 响应缓存跨请求复用结果。前两者在本版本 MVC 有实现,既不是分布式缓存,也不是流式零拷贝。
固定官方目录中虽然有 local-cache-response-filter.adoc,正文却出现 WebFlux RouteLocatorBuilder、spring.cloud.gateway.filter.local-response-cache.enabled 和旧 spring.cloud.gateway.routes。固定 MVC 模块源码没有 LocalResponseCache filter/工厂/自动配置,不能把此页作为“Server MVC 已支持相同响应缓存”的证据。该能力在本页登记为不适用于此 MVC 实现,保留文档差异;不要为了让 YAML 绑定而引入 WebFlux Server starter。
可选方案一:MVC 网关只转发,缓存放在 CDN/独立反向代理;需要对 Cache-Control、Vary、Cookie、Authorization、private/no-store、ETag/304 建立一致策略,失效/回源由缓存层维护。方案二:在业务 BFF 中显式编写 Spring Cache/Caffeine 读模型,只缓存确定公开的 DTO,以 tenant+resource+version 等正确键和 TTL 区分;这会变成应用自己的缓存契约,需做命中、失效、错误恢复与多租户串读测试,不能称为 Gateway 自带过滤器。
算例:并发 200、每个重试 body 1MiB,单份缓存即约 200MiB,尚不含解析对象、复制与响应。限制请求为 64KiB 则同样并发下原始字节约 12.5MiB。响应缓存 10,000 项×64KiB 仅 payload 就约 625MiB;加上头、对象和索引不能装进 512MiB 预算。设置 body 大小上限、选择只缓存公开 GET、记录命中率/淘汰/回源速率后再增大容量;缓存失效后限速回源,避免恢复风暴。
请求体读取入口的具体行为应回查本版本源码;缓存不是响应共享功能。
真实源码 MvcUtils.java L142–L162;整文件 SHA-256 29ba8107abe2bba35ae336ff36cd89f62e518eb9bbf933cafc82675bf7aa4ffc。
public static ByteArrayInputStream cacheBody(ServerRequest request) {
try {
byte[] bytes = StreamUtils.copyToByteArray(request.servletRequest().getInputStream());
ByteArrayInputStream body = new ByteArrayInputStream(bytes);
putAttribute(request, CACHED_REQUEST_BODY_ATTR, body);
return body;
}
catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static ByteArrayInputStream getOrCacheBody(ServerRequest request) {
ByteArrayInputStream body = getAttribute(request, CACHED_REQUEST_BODY_ATTR);
if (body != null) {
return body;
}
return cacheBody(request);
}MVC Security、Firewall 与 SSL Bundle
Gateway starter 本身不代表请求已认证。引入 spring-boot-starter-security 后 Boot 默认要求认证;实际规则由应用提供的 Servlet SecurityFilterChain 决定。TokenRelay 还需要 OAuth2 client 的 authorized client/manager,只有原始 Bearer 字符串不等于已拥有可 relay 的 OAuth2 client。本页实验未引入 Security,使用的敏感头是合成字符串,移除头的断言不代表认证/授权已通过。
下面是独立部署方案的最小边界配置(需另加 spring-boot-starter-security 和 spring-boot-starter-oauth2-resource-server,没有纳入本轮运行)。JWT 公钥/issuer 必须来自隔离环境真实配置;若以 Cookie/session 身份承载写操作,CSRF 规则需在 Security 权威教材中确定,不能照搬 API 示例全局关闭:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
class RouteSecurity {
@Bean
SecurityFilterChain gatewaySecurity(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(a -> a.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())).build();
}
}Firewall 要在“Tomcat connector → Servlet/Security → route predicate → 出站 URI → 上游解码”逐层定位。StrictHttpFirewall 默认拒绝某些编码斜杠、重复斜杠、反斜杠等非规范 URL;入口 400 且上游没收到请求不证明路由写错。不要见 400 就同时放开 allowUrlEncodedSlash/DoubleSlash/BackSlash;攻击者可能利用两层不同的解码结果绕过路径授权。合理方案一是保持严格拒绝并约定业务 ID 不含路径分隔符;方案二是仅放开业务确需的单项,并用安全测试证明受保护路径、编码一次/两次、大小写、反斜杠与正常路径的端到端一致性。由安全负责人确认边界,平台负责人回滚 Firewall 配置并复验;本轮没有执行这些安全场景。
MVC 出站 TLS 经 Boot RestClient 基础设施,Boot 4.0.9 实际元数据使用复数 spring.http.clients;固定 Gateway TLS 页仍写单数 spring.http.client,属于旧属性/弃用迁移入口,不作为本页新配置。以下是完整 PEM 信任 bundle 设计例,PEM 文件需在隔离环境生成,未纳入 HTTP fixture 测试:
server:
address: 127.0.0.1
port: 18081
spring:
http:
clients:
connect-timeout: 1s
read-timeout: 2s
redirects: dont-follow
ssl:
bundle: downstream
ssl:
bundle:
pem:
downstream:
truststore:
certificate: classpath:tls/lab-ca.pem这里配置的是出站服务证书信任;入口 HTTPS 要另设 server.ssl.bundle,mTLS 再为相应 bundle 提供客户端证书与私钥,并配置服务端客户端认证。不能用信任所有证书/关闭主机名校验作为修复。证书恢复应测试旧证书、过期、不可信 CA、SAN 不匹配、换新证书后重建/轮转客户端连接;仅启动成功无法证明握手通过,HTTP 最小模块也没有证明 TLS。Boot 4.0.9 spring-boot-http-client 配置元数据与 SHA 留在 MVC evidence,旧 TLS 文档仍按其版本保留为查错证据。
Proxy Exchange 两栈的完整调用路径
MVC 参数解析器识别 ProxyExchange<T> 并从泛型 T 得到下游响应类型,构造带 RestTemplate、NativeWebRequest、ModelAndViewContainer 的实例;它还负责固定头、autoForward 和 excluded 集合。MVC controller 返回 ResponseEntity<T>。WebFlux 用自己的 HandlerMethodArgumentResolver、WebClient、ServerWebExchange、BindingContext,controller 返回 Mono<ResponseEntity<T>>。不要把 T 解释为“所有请求体必然被解析成 T”,也不要在 WebFlux handler 里调用 block()。
ProxyMvcApplication.java与 ProxyWebfluxApplication.java是完整可构建 controller。两者固定可信 origin,提取 /proxy/ 后缀、限制 [A-Za-z0-9/_-]+、显式拼接 raw query。这个窄字符集用于最小 HTTP 接缝,不支持所有合法编码 URL;生产需要把业务 ID/查询参数作为结构化输入编码,并单独做路径穿越与 SSRF 防护。
关键差异如下(均在本次 HTTP 接缝中实际观察):
// MVC:注解方法中按入口方法选择,结果是同步 ResponseEntity<byte[]>
proxy.uri(target(proxy.path("/proxy/"), request.getQueryString()))
.header("X-Before", "proxy-mvc");
return "POST".equals(request.getMethod())
? proxy.post(this::response) : proxy.get(this::response);
// WebFlux:无参数 forward() / 带 mapper 的 forward(mapper) 按入口动词调远端
return proxy.uri(target(proxy.path("/proxy/"), exchange.getRequest().getURI().getRawQuery()))
.header("X-Before", "proxy-webflux").forward(this::response);path() 是当前应用/handler 内路径,path(prefix) 只是检验 startsWith 后做 substring;它不自动改出站 URL,不自动复制 query,也不执行安全规范化。prefix 不匹配会抛 IllegalArgumentException;模块 /bad-prefix/alpha 通过显式 @ExceptionHandler 映射为 400,随后正常路径仍 200。尾串 //evil.example、../admin、编码分隔符不能交给字符串拼接后声称安全,实验字符白名单是主动收窄接口。
MVC path(prefix) 的判定与 substring 是独立路径步骤。
真实源码 ProxyExchange.java L275–L294;整文件 SHA-256 66bc0cc1432dc6485751710d285748f74fb7b65664b287f27b6b1be1cade36b7。
return (String) this.webRequest.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE,
WebRequest.SCOPE_REQUEST);
}
public @Nullable String path(String prefix) {
String path = path();
if (path == null) {
return null;
}
if (!path.startsWith(prefix)) {
throw new IllegalArgumentException("Path does not start with prefix (" + prefix + "): " + path);
}
return path.substring(prefix.length());
}
public void forward(String path) {
HttpServletRequest request = this.webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response = this.webRequest.getNativeResponse(HttpServletResponse.class);
try {
Objects.requireNonNull(request, "HttpServletRequest is required");WebFlux 用自己的 WebClient builder 和参数解析器,不能套用 MVC 的 RestTemplate 配置。
真实源码 ProxyResponseAutoConfiguration.java L53–L73;整文件 SHA-256 cab7ea6419a3b2f2e436176f4f49063b0222d06a116c32f4256948b3d168589d。
@Bean
@ConditionalOnMissingBean
public ProxyExchangeArgumentResolver proxyExchangeArgumentResolver(Optional<WebClient.Builder> optional,
ProxyExchangeWebfluxProperties properties) {
WebClient.Builder builder = optional.orElse(WebClient.builder());
WebClient template = builder.build();
ProxyExchangeArgumentResolver resolver = new ProxyExchangeArgumentResolver(template);
resolver.setHeaders(properties.convertHeaders());
resolver.setAutoForwardedHeaders(properties.getAutoForward());
Set<String> excludedHeaderNames = new HashSet<>();
if (!CollectionUtils.isEmpty(properties.getSensitive())) {
excludedHeaderNames.addAll(properties.getSensitive());
}
if (!CollectionUtils.isEmpty(properties.getSkipped())) {
excludedHeaderNames.addAll(properties.getSkipped());
}
resolver.setExcluded(excludedHeaderNames);
return resolver;
}Proxy 请求体、头与响应映射
不设置 body 时,MVC 通过已有 @RequestBody 绑定结果或消息转换器取入口 body;WebFlux 没有显式对象 body 时可转发入口 DataBuffer。两个模块 POST /proxy/alpha?x=7 的真实上游均收到 payload、正确 POST/path/query。调用 .body(Object) 后进入转换器/编码器,不再承诺原始字节透传;不要只比较入口 controller 参数。
MVC /replace/alpha 显式 .body("new:" + body).post(...),本次上游收到 new:payload。WebFlux .body(Object) 被保存为 Publisher,在 exchange 内以 Object.class 调 WebClient writer;本轮把 String 传入时实际编码成带引号 JSON 字符串。最终示例改成显式 Content-Type: application/json + Map.of("message", "new:" + body),上游断言 {"message":"new:payload"}。不同消息编码器/classpath 是边界的一部分,不能为了表面一致把 JSON 引号算作丢字节。
两栈默认敏感集合为 cookie、authorization;默认 skipped 为 content-length、host。auto configuration 合并两个集合交给 excluded。普通未排除头也会在实际 exchange 里从原请求补入,auto-forward 不是全局“只允许这些头”白名单。代码 header() 设置的是下游请求头;要改返回客户端的响应头,使用 get/post/forward 的 mapper。本实验 mapper 保留下游状态、头、body,再加 X-Proxy;不将任何 503 改成 200。
完整 Proxy 配置例(下方是 MVC;WebFlux 将 webmvc 改为 webflux;测试用同样的 skipped 参数覆盖):
spring:
cloud:
gateway:
proxy-exchange:
webmvc:
sensitive: [cookie, authorization]
skipped: [content-length, host, x-drop]
headers:
X-Proxy-Application: [u32]
auto-forward: [x-correlation-id]设置集合会替换原集合,需保留默认安全项。运行时 .excluded("x-drop") 会清空并替换此前集合,可能把 Authorization/Cookie/Host 又放行;若确需自定义应显式提供完整排除项。默认敏感头剥离只表示远端出站的复制规则,不是认证、脱敏日志或任意本地 forward 的访问控制。需要 relay 用户身份时由明确授权后的代码选择 token/audience,不能把全局 sensitive 清空来“修好登录”。
Host 必须重算为目标 authority;Content-Length 需依据新的编码或交给 HTTP 客户端决定 chunking。测试用随机且不同的 gateway/upstream 端口断言上游 Host 是上游的 authority;替换 body 后断言没有沿用旧的 length=7。没有假定所有 HTTP 客户端都必然发同一种 Content-Length/Transfer-Encoding。
Proxy 会生成/追加 Forwarded 等代理头,但它不是 Server MVC trusted-proxies 配置链。实验只断言生成的 host 与远端实际收到的普通头;没有把用户可输入的 forwarded/x-forwarded 当身份授权。面对公网入口应先建立可信代理与头清洗规则,并单独验证伪造头;本模块不演示全网信任。服务端响应中的 Set-Cookie 等也要按业务政策评估,不能由“请求 Cookie 被剥离”推导响应 Cookie 也已过滤。
excluded 是替换集合的 API;默认排除项可以被调用者清掉,必须由应用维护完整策略。
真实源码 ProxyExchange.java L216–L226;整文件 SHA-256 66bc0cc1432dc6485751710d285748f74fb7b65664b287f27b6b1be1cade36b7。
* Sets the names of excluded headers that are not passed downstream to the backend
* service.
* @param names the names of excluded headers
* @return this for convenience
*/
public ProxyExchange<T> excluded(String... names) {
if (this.excluded == null) {
this.excluded = new HashSet<>();
}
this.excluded.clear();WebFlux 显式对象、Publisher、原始 DataBuffer 是不同分支;onStatus 放行错误响应供 ResponseEntity 表达原状态。
真实源码 ProxyExchange.java L370–L402;整文件 SHA-256 06f62cd6773e8302ccb76dbbcfa7c5ab8ac0b9b89f934f859eecaa07d6e0b1be。
private Mono<ResponseEntity<T>> exchange(RequestEntity<?> requestEntity) {
Type type = this.responseType;
Objects.requireNonNull(requestEntity.getMethod(), "Method must not be null");
RequestBodySpec builder = rest.method(requestEntity.getMethod())
.uri(requestEntity.getUrl())
.headers(headers -> addHeaders(headers, requestEntity.getHeaders()));
WebClient.ResponseSpec result;
if (requestEntity.getBody() instanceof Publisher) {
@SuppressWarnings("unchecked")
Publisher<Object> publisher = (Publisher<Object>) requestEntity.getBody();
result = builder.body(publisher, Object.class).retrieve();
}
else if (requestEntity.getBody() != null) {
result = builder.body(BodyInserters.fromValue(requestEntity.getBody())).retrieve();
}
else {
if (hasBody) {
result = builder.headers(headers -> addHeaders(headers, exchange.getRequest().getHeaders()))
.body(exchange.getRequest().getBody(), DataBuffer.class)
.retrieve();
}
else {
result = builder.headers(headers -> addHeaders(headers, exchange.getRequest().getHeaders())).retrieve();
}
}
return result.onStatus(HttpStatusCode::isError, t -> Mono.empty())
.toEntity(ParameterizedTypeReference.forType(type));
}
private void addHeaders(HttpHeaders headers, HttpHeaders toAdd) {
Set<String> filteredKeys = filterHeaderKeys(toAdd);
filteredKeys.stream().filter(key -> !headers.containsHeader(key)).forEach(header -> {
java.util.List<String> headerValues = toAdd.get(header);forward 的两种含义与本地 body 限制
MVC proxy.forward("/local") 调 RequestDispatcher.forward,发生在同一个 Servlet 容器,返回类型 void,不发起对远程 origin 的 HTTP 请求。本模块 GET /dispatch → /local,本地 handler 返回 local:FORWARD,实际经过 Tomcat。生产需核对 DispatcherType.FORWARD 的 Security/filter 匹配,不能通过 forward 绕过内部 endpoint 的权限。
WebFlux 的 proxy.uri(remote).forward() 没有 Servlet RequestDispatcher 意义,它依据入口 HTTP 动词调用 get/head/options/post/delete/put/patch。当前测试用 POST 成功证明其远端方法与 body,并在 GET 故障/恢复链中验证同一个 API。不存在可以直接移植的 forward("/local") 用法。
实现期间还试过 MVC POST /dispatch 将 7 字节 payload 改成 forwarded:payload 后本地 forward:期望 local:forwarded:payload:FORWARD,当时收到 local:forward:FOR。这次尝试没有纳入自动测试、日志也未入库,只能作为待定因线索,不能当作可复现证据。固定实现 BodyForwardingHttpServletRequest 继承原 request 的长度相关方法,ServletOutputToInputConverter 又会将部分响应头传到原 response;这提供了截断的源码解释线索,但未做独立框架缺陷定因。最小交付只承诺已通过的无 body 本地分派,本地改写 body forward 不作为支持路径;远程 body 透传/改写各有独立通过断言。确需本地复用业务时可直接调用共享业务服务并返回 ResponseEntity,避免为复用逻辑制造二次 Servlet 分派;坚持 body forward 则需要修复/绕开框架的长度与编码契约后重新验收。
MVC 有路径参数的 forward,使用真实 Servlet RequestDispatcher。
真实源码 ProxyExchange.java L296–L309;整文件 SHA-256 66bc0cc1432dc6485751710d285748f74fb7b65664b287f27b6b1be1cade36b7。
request.getRequestDispatcher(path)
.forward(new BodyForwardingHttpServletRequest(request, response), response);
}
catch (Exception e) {
throw new IllegalStateException("Cannot forward request", e);
}
}
public ResponseEntity<T> get() {
Objects.requireNonNull(this.uri, "URI is required");
Object body = body();
BodyBuilder builder = headers((BodyBuilder) RequestEntity.get(uri));
if (body != null) {
return exchange(builder.body(body));WebFlux 的无路径 forward 是远端 HTTP 动词分派,这不是本地 Servlet forward。
真实源码 ProxyExchange.java L318–L342;整文件 SHA-256 06f62cd6773e8302ccb76dbbcfa7c5ab8ac0b9b89f934f859eecaa07d6e0b1be。
public Mono<ResponseEntity<T>> forward() {
if (httpMethod.equals(HttpMethod.GET)) {
return get();
}
else if (httpMethod.equals(HttpMethod.HEAD)) {
return head();
}
else if (httpMethod.equals(HttpMethod.OPTIONS)) {
return options();
}
else if (httpMethod.equals(HttpMethod.POST)) {
return post();
}
else if (httpMethod.equals(HttpMethod.DELETE)) {
return delete();
}
else if (httpMethod.equals(HttpMethod.PUT)) {
return put();
}
else if (httpMethod.equals(HttpMethod.PATCH)) {
return patch();
}
return Mono.empty();
}定量选型、观测与恢复责任
以两个副本、总入口 2000 请求/s、每次下游平均 80ms、峰值 body 64KiB、业务 deadline 500ms 为设计输入。这些不是压测结果:
| 选择 | 可计算成本 | 恢复与验证责任 |
|---|---|---|
| Server MVC | 每副本 1000/s×0.08s≈80 个等待中的下游请求;下游变慢到 300ms 时约 300 个,超过假设 200 工作线程预算 | 平台团队限制并发/排队/出站等待,观察 busy threads、队列、超时;恢复后逐步放量 |
| Proxy MVC | 相同阻塞预算,另加业务转换/聚合与 controller 维护成本;20 条复杂 BFF 接口可能合理,500 条同构转发会带来重复治理 | BFF 团队维护每条头/路径/错误契约与授权,演练 mapper 保状态、body 长度与多租户隔离 |
| Proxy WebFlux | 事件循环不按每个等待请求占一个 Servlet 线程,但连接、在途 body、对象缓存与编码仍占资源;禁止在事件循环阻塞 | reactive 团队监控 event-loop stall、pending connections、取消/缓冲释放,逐项验证连接上限与下游恢复 |
| 本地限流/共享限流 | 本地每副本 100/s 的总配额可能近 200/s;共享配额要支付每请求后端 RTT 和故障成本 | 产品确定总配额语义,平台承担后端恢复与突发控制;不以单副本测试冒充全局准确性 |
| 原样转发/重试缓存 | 不读整 body 更省内存;200×64KiB≈12.5MiB 只是单份字节,重写副本/对象另算;100% 重试可放大网络和在途内存 | 开发确认幂等、UNKNOWN,平台设置请求大小/在途上限;恢复不是无上限回放 |
先保证不变量:外部身份头不能直接授权、目标 origin 不能由请求任意控制、下游 HTTP 错误不被 mapper 伪装成业务成功、被接纳的写结果未知时可查单、每轮测试只关闭自己的资源。再决定同步还是响应式;“Netty 线程少”不能替代吞吐/内存/错误率观测。
最小排障步骤:记录 requestId/routeId(Proxy 用 controller 名)、入口方法/规范化路径、上游 authority、外部状态、下游状态或异常类型、耗时/尝试数,日志不得输出真实 Cookie/token/body。先看上游是否收到,再对照容器拒绝、Security、谓词、filter、LB、出站客户端、上游分层定位;不能根据一个 503 指标认定注册中心宕机。metrics 使用 route/controller 模板,避免原始用户路径、敏感值造成高基数。
本轮实际观察标记:U32 MVC HTTP、U32 PROXY MVC HTTP、U32 PROXY WEBFLUX HTTP。三者均验证正常 200、负例、固定上游同一路径 200→503→200,并在返回前后分别断言 path/headers/body。各模块 target/surefire-reports 可由 clean test 重建;四模块 surefire 计数、实际解析版本与观察行见运行证据,入口与边界见实验说明。
本页未声称运行:Retry 两实现次数、Bucket4j/共享后端、HTTP 响应缓存、表单与 multipart、Security/Firewall/JWT、SSL Bundle/证书轮转、生产流控或连接故障恢复。这些已有完整机制/配置与定量设计;实际验收状态须沿用上述限制。本次只完成 U32 写集,不开启 U33。
闭卷验收
本页 20 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。