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


技术复盘:从 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 的访问机制​​。

  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,后续所有组件看到的都将是已经被修改过的请求。

代码示例概要

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 之后执行

​复盘后的最佳实践:​

  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 应用程序至关重要。


相关推荐
JaguarJack8 小时前
别再用 PHP 动态方法调用了!三个坑让你代码难以维护
后端·php
mudtools8 小时前
打造.NET平台的Lombok:实现构造函数注入、日志注入、构造者模式代码生成等功能
后端·.net
江上月5138 小时前
django与vue3的对接流程详解(下)
后端·python·django
Cikiss8 小时前
图解 bulkProcessor(调度器 + bulkAsync() + Semaphore)
java·分布式·后端·elasticsearch·搜索引擎
Mintopia8 小时前
Next.js 与 Serverless 架构思维:无状态的优雅与冷启动的温柔
前端·后端·全栈
mudtools8 小时前
.NET驾驭Word之力:基于规则自动生成及排版Word文档
后端·.net
王中阳Go8 小时前
面试官:“聊聊最复杂的项目?”90%的人开口就凉!我面过最牛的回答,就三句话
java·后端·面试
廖广杰8 小时前
java虚拟机-虚拟机栈OOM(StackOverflowError/OutOfMemoryError)
后端
MOON404☾8 小时前
Rust 与 传统语言:现代系统编程的深度对比
开发语言·后端·python·rust