技术复盘:从 Interceptor 到 Filter —— 正确修改 HTTP Request 字段的探索之路

99 阅读6分钟

技术复盘:从 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方法中实现这一逻辑:

@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 的访问机制​​。

  1. ​请求体 (Request Body) 的“一次性”读取​​:

    • HttpServletRequest 的 InputStreamReader只能被读取​​一次​​。一旦被 Controller 中的 @RequestBody注解参数或其它拦截器读取,流就会到达末尾,无法再次读取。
    • 在 Interceptor 中读取流以解析 JSON/Form 数据,会消耗掉这个流,导致后续 Controller 无法再获取到请求体数据,从而引发 HttpMessageNotReadableException等异常。
  2. ​Interceptor 的职责定位​​:

    • Interceptor 是 ​​Spring MVC 层面​​的组件,它工作在​​DispatcherServlet​​ 之后。它的主要职责是进行面向 Handler(Controller 方法)的横切处理,如日志、权限检查、模型加工等。
    • 它并非设计用来对原始的 HTTP 请求报文进行“破坏性”的修改。修改请求体属于更底层、更基础的 HTTP 报文处理范畴。
  3. ​修改 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 最先接触到请求,它可以在流被后续组件读取之前,对其进行读取、包装和替换。

核心实现思路:内容缓存和请求包装

  1. ​缓存请求体​​:首先将原始的 HttpServletRequest中的 InputStream读取并缓存到一个字节数组或字符串中。
  2. ​修改内容​​:解析缓存的数据(如使用 Jackson 解析 JSON),找到目标字段并进行替换。
  3. ​包装请求​​:创建一个新的 HttpServletRequestWrapper子类,重写 getInputStream()getReader()方法,使其返回包含​​修改后数据​​的新流。
  4. ​传递包装器​​:将包装后的 Request 对象传入 Filter Chain,后续所有组件看到的都将是已经被修改过的请求。

代码示例概要

@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 FilterSpring Interceptor
​工作层面​Servlet 容器层面,更底层Spring MVC 层面,更上层
​请求体访问​​可以​​在流被消耗前读取和修改​不可以​​,流通常已被消耗或无法安全修改
​主要职责​日志、压缩、加解密、修改请求/响应内容、全局权限校验业务逻辑权限校验、日志、模型加工、预处理 Handler
​执行顺序​最先执行在 DispatcherServlet 之后执行

​复盘后的最佳实践:​

  1. ​明确边界​​:需要修改​​原始 HTTP 报文​​(Header、Body)时,优先考虑 ​​Filter​​。需要处理​​MVC 上下文​​信息(@RequestBody对象、模型、会话等)时,使用 ​​Interceptor​​。
  2. ​性能考量​​:读取和缓存请求体会带来额外的内存和 CPU 开销。应在 Filter 中条件性地执行此操作(例如,只对特定 URL 模式的请求进行处理)。
  3. ​健壮性​​:务必做好异常处理。如果修改过程中发生错误(如 JSON 解析失败),应决定是直接抛出错误响应,还是原封不动地传递原始请求。
  4. ​使用成熟组件​​:对于简单的操作,可以考虑使用 Spring 提供的 ContentCachingRequestWrapper作为基础。对于复杂的 API 网关类操作,则可研究更专业的组件,如 Netflix Zuul、Spring Cloud Gateway 的 Filter 机制。

通过这次“踩坑”和复盘,我们更加深刻地理解了 Servlet 规范中 Filter 和 Interceptor 的职责划分,这对于设计出正确、高效的 Web 应用程序至关重要。