技术复盘:从 Interceptor 到 Filter ------ 正确修改 HTTP Request 字段的探索之路
引言
在最近的开发中,我们遇到了一个需求:需要统一处理传入的 HTTP 请求,将请求体(Body)中多个可能的用户名字段(如 userName
, user
, gitName
, name
)的值,统一替换为从请求头 Authorization
中解析出的真实 Git 用户名。 我们的第一直觉是使用熟悉的 Spring Interceptor 来实现这一预处理逻辑,但发现此路不通。本文将详细复盘这个问题,解释为什么 Interceptor 无法胜任,并阐述最终采用 Servlet Filter 方案的原因和实现要点。
一、问题场景与初步方案
目标:
- 请求体可能为 JSON 或 Form-Data。
- 需要检查其中是否包含
"userName"
,"user"
,"gitName"
,"name"
等字段。 - 若存在,则用认证信息(如 JWT 解析后得到的
gitName
)覆盖其值,确保后续业务逻辑使用的是可信的用户身份。
初步方案:使用 Interceptor 我们首先尝试在 preHandle
方法中实现这一逻辑:
java
@Component
public class UsernameOverrideInterceptor implements HandlerInterceptor {
private static final String[] SUPPORTED_FIELD_NAMES = {"userName", "user", "gitName", "name"};
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 从 Authorization 头解析出 gitName
String gitName = extractGitNameFromAuthorization(request);
if (gitName == null) {
return true; // 无法解析则跳过处理
}
// 2. 尝试读取并修改请求体
// ... (代码逻辑)
// 3. 发现无法直接修改 request 的 InputStream
return true;
}
}
二、为何 Interceptor 行不通?------ 复盘与深度解析
很快,我们遇到了无法逾越的障碍,根本原因在于 Servlet 架构中 Request/Response 的访问机制。
-
请求体 (Request Body) 的"一次性"读取:
- HttpServletRequest 的
InputStream
或Reader
只能被读取一次 。一旦被 Controller 中的@RequestBody
注解参数或其它拦截器读取,流就会到达末尾,无法再次读取。 - 在 Interceptor 中读取流以解析 JSON/Form 数据,会消耗掉这个流,导致后续 Controller 无法再获取到请求体数据,从而引发
HttpMessageNotReadableException
等异常。
- HttpServletRequest 的
-
Interceptor 的职责定位:
- Interceptor 是 Spring MVC 层面 的组件,它工作在DispatcherServlet 之后。它的主要职责是进行面向 Handler(Controller 方法)的横切处理,如日志、权限检查、模型加工等。
- 它并非设计用来对原始的 HTTP 请求报文进行"破坏性"的修改。修改请求体属于更底层、更基础的 HTTP 报文处理范畴。
-
修改 Request 参数的局限性:
HttpServletRequest
提供了setAttribute(String name, Object o)
方法,但这设置的是属性(Attribute) ,而非参数(Parameter)。- 请求参数(Parameter)主要来自 URL 查询字符串或 Form-Data,对于 JSON Body 中的内容,
getParameter
方法是无法获取的。因此,想通过 Interceptor 的request.setParameter()
方法来修改 JSON 数据是完全不可能的(该方法本身也不存在)。
结论复盘: Interceptor 适合处理已经解析好的、存在于 MVC 上下文中的信息(如认证对象、模型数据),但不适合处理原始的、未解析的 HTTP 请求报文。
三、正确方案:使用 Servlet Filter
Filter 是解决此问题的正确位置 ,因为它在 Servlet 容器级别工作,是请求进入 Spring MVC 之前的第一个关卡。 Filter 的工作流程: HTTP Request -> Filter Chain -> DispatcherServlet -> Interceptor -> Controller
正因为 Filter 最先接触到请求,它可以在流被后续组件读取之前,对其进行读取、包装和替换。
核心实现思路:内容缓存和请求包装
- 缓存请求体 :首先将原始的
HttpServletRequest
中的InputStream
读取并缓存到一个字节数组或字符串中。 - 修改内容:解析缓存的数据(如使用 Jackson 解析 JSON),找到目标字段并进行替换。
- 包装请求 :创建一个新的
HttpServletRequestWrapper
子类,重写getInputStream()
和getReader()
方法,使其返回包含修改后数据的新流。 - 传递包装器:将包装后的 Request 对象传入 Filter Chain,后续所有组件看到的都将是已经被修改过的请求。
代码示例概要
java
@Component
public class UsernameOverrideFilter extends OncePerRequestFilter {
private static final String[] SUPPORTED_FIELD_NAMES = {"userName", "user", "gitName", "name"};
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. 缓存原始请求
CachedBodyHttpServletRequest cachedBodyRequest = new CachedBodyHttpServletRequest(request);
// 2. 从缓存中获取请求体字符串
String requestBody = IOUtils.toString(cachedBodyRequest.getInputStream(), StandardCharsets.UTF_8);
// 3. 从 Authorization 头解析 gitName
String gitName = extractGitNameFromAuthorization(request);
// 4. 如果请求体非空且成功解析出 gitName,则进行字段替换
if (StringUtils.isNotBlank(requestBody) && gitName != null) {
ObjectNode jsonNode = (ObjectNode) JsonUtils.parse(requestBody);
for (String field : SUPPORTED_FIELD_NAMES) {
if (jsonNode.has(field)) {
jsonNode.put(field, gitName);
}
}
// 获取修改后的 JSON 字符串
requestBody = jsonNode.toString();
}
// 5. 创建新的请求包装器,使用修改后的 body
ModifiedBodyHttpServletRequestWrapper modifiedRequestWrapper = new ModifiedBodyHttpServletRequestWrapper(cachedBodyRequest, requestBody);
// 6. 继续执行过滤器链,传入包装后的请求
filterChain.doFilter(modifiedRequestWrapper, response);
}
// Helper 方法:从 Authorization 头提取信息...
private String extractGitNameFromAuthorization(HttpServletRequest request) {
// ... 实现逻辑,例如解析 JWT
return extractedName;
}
}
// 自定义 RequestWrapper,用于提供修改后的 Body
class ModifiedBodyHttpServletRequestWrapper extends HttpServletRequestWrapper {
private final String modifiedBody;
private final byte[] modifiedBodyBytes;
public ModifiedBodyHttpServletRequestWrapper(HttpServletRequest request, String modifiedBody) {
super(request);
this.modifiedBody = modifiedBody;
this.modifiedBodyBytes = modifiedBody.getBytes(StandardCharsets.UTF_8);
}
@Override
public ServletInputStream getInputStream() {
// 返回一个包含修改后数据的 ServletInputStream
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(modifiedBodyBytes);
return new ServletInputStream() {
@Override
public int read() {
return byteArrayInputStream.read();
}
// ... 其他需要实现的方法
@Override
public boolean isFinished() { return false; }
@Override
public boolean isReady() { return true; }
@Override
public void setReadListener(ReadListener listener) { }
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
注意 :上述示例中的 CachedBodyHttpServletRequest
是另一个用于首次缓存请求体的包装器,实践中常用 ContentCachingRequestWrapper
(Spring)或自行实现。完整实现需考虑性能、异常处理以及非 JSON Content-Type 的情况。
四、总结与最佳实践
特性 | Servlet Filter | Spring Interceptor |
---|---|---|
工作层面 | Servlet 容器层面,更底层 | Spring MVC 层面,更上层 |
请求体访问 | 可以在流被消耗前读取和修改 | 不可以,流通常已被消耗或无法安全修改 |
主要职责 | 日志、压缩、加解密、修改请求/响应内容、全局权限校验 | 业务逻辑权限校验、日志、模型加工、预处理 Handler |
执行顺序 | 最先执行 | 在 DispatcherServlet 之后执行 |
复盘后的最佳实践:
- 明确边界 :需要修改原始 HTTP 报文 (Header、Body)时,优先考虑 Filter 。需要处理MVC 上下文 信息(
@RequestBody
对象、模型、会话等)时,使用 Interceptor。 - 性能考量:读取和缓存请求体会带来额外的内存和 CPU 开销。应在 Filter 中条件性地执行此操作(例如,只对特定 URL 模式的请求进行处理)。
- 健壮性:务必做好异常处理。如果修改过程中发生错误(如 JSON 解析失败),应决定是直接抛出错误响应,还是原封不动地传递原始请求。
- 使用成熟组件 :对于简单的操作,可以考虑使用 Spring 提供的
ContentCachingRequestWrapper
作为基础。对于复杂的 API 网关类操作,则可研究更专业的组件,如 Netflix Zuul、Spring Cloud Gateway 的 Filter 机制。
通过这次"踩坑"和复盘,我们更加深刻地理解了 Servlet 规范中 Filter 和 Interceptor 的职责划分,这对于设计出正确、高效的 Web 应用程序至关重要。