先说一个最近被问得最多的场景:有个哥们儿的系统要和下游联调,需求不复杂,所有请求进 Controller 之前自动加一个source=app参数,他直接在过滤器里写了一句request.getParameterMap().put("source", "app"),结果启动一跑,直接UnsupportedOperationException,当场傻眼。其实在 Java Web 开发里,“往 Request 参数里添加或修改值”这件事,几乎每个项目都会遇到,统一注入渠道标识、流水号、用户 ID、默认分页参数、灰度标记,都是典型用法。这篇文章我就把过滤器、拦截器这两条路拆开讲清楚,重点说为什么要包装 Request,包装类的代码怎么写,以及我实际项目里踩过哪些坑。
这不是一个多高深的技术点,但它的坑非常隐蔽——Servlet 规范把请求参数设计成只读,普通写业务代码的人很少会意识到这一点。我会从 Servlet 容器里参数到底存在哪儿开始讲,一步步把原理、代码、排查方法捋明白,最后附一张问题速查表,如果你也正准备在项目里做参数增强,这篇可以直接照抄。
1. 为什么修改Request参数要包装对象,不能直接put?
1.1 参数在Servlet容器里的真实存储方式
HTTP 请求到达后,Servlet 容器(Tomcat、Jetty、Undertow)负责解析报文,把 QueryString 和表单内容按 name 解析成一组 value 数组,最终存到容器自身的 Request 对象内部。对业务代码来说,我们拿到的HttpServletRequest只是一个接口,它暴露出来的参数读取方法就三个:getParameter、getParameterValues、getParameterMap,以及一个getParameterNames。这三个方法只读,没有对应的 set 方法。
这就是问题的根源。Servlet 规范刻意让参数对上层应用只读,目的是保证请求数据在容器和框架之间传递时不失控。但真实业务里我们又经常需要“临时补参数”,比如网关把 Header 里的用户编号转成参数传给下游,或者给某个活动页接口统一追加渠道号。这两件事撞到一起,就需要一个合法的变通手段。
网上很多答案会告诉你“用 HttpServletRequestWrapper”,但没讲为什么。HttpServletRequestWrapper 是标准 Servlet API 里带的一个抽象类,它本身内部持有一个原始 Request 对象,所有方法默认都是把调用转发给原始 Request。它存在的意义就是让你继承它,然后只覆写你想改的那几个方法,其他方法继续走原始逻辑。这其实就是装饰器模式,参数修改不是暴力破坏容器的参数表,而是你在请求外面套了一层“代理”,让后续读取参数的人都经过你这层过滤。
1.2 为什么容器返回的ParameterMap不能直接改
Tomcat 里getParameterMap()返回的 Map 是容器内部类org.apache.tomcat.util.http.Parameters.FailReadOnlyException控制的,检查到只读标记后会直接抛异常。所以单纯在过滤器里对原始 Request 的 Map 做 put,几乎没有可能成功。
就算你换了个思路,通过反射强行修改内部字段(网上真有这种教程),我劝你也别用。反射方案和具体容器版本强耦合,Tomcat 换个小版本可能字段名就变了;而且参数 Map 里存的 String 数组,修改后会不会被容器重新解析覆盖,完全没保证。生产环境维护这样的代码,等于埋定时炸弹。
正确思路是:不要动容器的原始参数,而是自己维护一个 HashMap 副本,包装类里所有参数读取方法优先从副本拿数据,拿不到再退回原始 Request。这样既达到了“添加/修改参数”的效果,又把对容器内部结构的侵犯降到最低。
2. 参数修改包装类的核心代码与四个关键覆写点
2.1 最小可用的 ParamModifyWrapper
我平时用的包装类长这样,考虑到要解决添加和替换两种需求,我会额外暴露两个对外方法:
public class ParamModifyWrapper extends HttpServletRequestWrapper { private final Map<String, String[]> parameters; public ParamModifyWrapper(HttpServletRequest request) { super(request); // 拷贝一份原始参数,后续所有修改都在这份副本上进行 parameters = new HashMap<>(); Map<String, String[]> originalMap = request.getParameterMap(); if (originalMap != null) { for (Map.Entry<String, String[]> entry : originalMap.entrySet()) { String[] value = entry.getValue(); parameters.put(entry.getKey(), value == null ? null : Arrays.copyOf(value, value.length)); } } } /** 新增参数;如果同名参数已存在,用新值覆盖 */ public void putParameter(String name, String... values) { if (values == null || values.length == 0) { parameters.remove(name); } else { parameters.put(name, Arrays.copyOf(values, values.length)); } } /** 仅当参数不存在时才添加,常用于默认值注入 */ public void putIfAbsent(String name, String... values) { if (!parameters.containsKey(name)) { putParameter(name, values); } } @Override public String getParameter(String name) { String[] values = parameters.get(name); return values != null && values.length > 0 ? values[0] : null; } @Override public String[] getParameterValues(String name) { String[] values = parameters.get(name); return values == null ? null : Arrays.copyOf(values, values.length); } @Override public Map<String, String[]> getParameterMap() { // 返回只读Map,避免外部拿到Map后绕过包装类直接puts,破坏副本的受控性 return Collections.unmodifiableMap(parameters); } @Override public Enumeration<String> getParameterNames() { return Collections.enumeration(parameters.keySet()); } }这里有几个细节值得强调。第一,拷贝时我用了Arrays.copyOf,不是直接把原始 String 数组引用放进来。原参数数组在极端场景下可能被容器复用或修改,拷贝一份能避免共享引用导致的怪问题。第二,getParameterMap()返回的是不可修改的 Map,原因是如果你把内部副本直接返回给调用方,别人拿到 Map 后绕过putParameter直接改,副本就失去统一入口了。第三,getParameterNames()很多人会漏掉,不覆写的话后续代码通过getParameterNames遍历参数,你会发现新加的参数“不存在”,因为默认实现走的是原始 Request 的枚举。
2.2 修改参数时最容易忽略的数组语义
一个事实是,Servlet 参数本身是支持多值的,比如 checkbox、多选下拉,所以getParameterValues返回数组。你在putParameter的时候,要意识到你传的 values 是一个数组,同一个 name 下面可以有多个值。如果你调addParameter("hobby", "篮球", "跑步"),那么getParameter("hobby")返回第一个值“篮球”,getParameterValues("hobby")返回两个值。
这一点对业务逻辑有实际影响。比如你给下游接口注入一个tag参数,但原始 URL 里可能已经有tag=1了,你用putParameter会整体覆盖成tag=2,这符合“修改”语义。但如果你只想在“参数不存在时给个默认值”,应该用putIfAbsent而不是无脑覆盖。我见过有人把用户已传参数覆盖掉导致线上事故的,所以包装类的 API 设计上最好把“覆盖”和“不存在才添加”分成两个方法,调用方根据业务场景选择,避免在过滤器里写一堆 if 条件。
2.3 包装类要不要处理getInputStream和getReader?
如果你的目标是修改 QueryString 或表单参数,只覆写上面四个方法就够了。但如果你遇到的是 JSON body 请求(Content-Type: application/json),情况完全不同,因为 JSON 数据在getParameter体系里根本取不到,参数藏在request.getInputStream()的字节流里。这种情况下,你还得覆写getInputStream和getReader,把原始 body 读出来,替换 JSON 内容后再包装回去。这个我在第 4 节单独拆开讲,因为它牵扯的坑最多。
3. Filter与Interceptor两种方案怎么选,附落地代码
3.1 Filter方案:包装后放行是重点
过滤器是在 Servlet 容器层面生效的,执行时机比 Spring MVC 的 DispatcherServlet 更早,所以它在整个请求链路里最早、最安全。过滤器里做参数修改的标准流程是三步:拿到 HttpServletRequest、包装成 ParamModifyWrapper、用包装后的对象放行。
@Component public class GlobalParamFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; // 只为指定路径做参数增强,避免影响所有请求 if (httpRequest.getRequestURI().startsWith("/api/")) { ParamModifyWrapper wrapper = new ParamModifyWrapper(httpRequest); // 下游接口规范要求source参数,前端没传就给默认值 wrapper.putIfAbsent("source", "app"); // 把Header里携带的userId提升为通用参数,方便下游统一读取 String userId = httpRequest.getHeader("X-User-Id"); if (userId != null && !userId.isEmpty()) { wrapper.putIfAbsent("userId", userId); } chain.doFilter(wrapper, response); } else { chain.doFilter(httpRequest, response); } } }注意这里的关键点:chain.doFilter传的一定要是wrapper,不是原始httpRequest。很多人把包装类写好之后,下意识地 continue 放行原始 Request,参数增强自然不会生效,这是这个方案里最隐蔽的低级错误。我之前排查过一个问题,过滤器里明明写了putIfAbsent("source", "app"),下游却始终拿不到 source,最后发现就是 doFilter 那行传了原始 request。一行之差,整个方案报废。
Filter 的注册方式有两种。一种是启动类加@ServletComponentScan,过滤器类加@WebFilter(urlPatterns = "/*");另一种是 Spring Boot 项目里用FilterRegistrationBean手动注册。我推荐第二种,因为可以调节 Filter 的 order,并且不依赖包扫描。
@Configuration public class FilterConfig { @Bean public FilterRegistrationBean<GlobalParamFilter> globalParamFilter() { FilterRegistrationBean<GlobalParamFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(new GlobalParamFilter()); registration.addUrlPatterns("/api/*"); registration.setOrder(1); return registration; } }遇到多过滤器串行时,order 决定执行顺序。如果你的项目里已经有一堆过滤器,参数增强这类“通用”过滤器尽量优先执行,保证后续过滤器也能看到新参数。
3.2 Interceptor方案:它的局限要比你想象的大
拦截器是 Spring MVC 级别的组件,在 DispatcherServlet 分发请求之后执行。这里我必须直接说结论:HandlerInterceptor并不适合做 Request 参数修改。原因在于preHandle方法里你能拿到 ServletRequest,但就算你把 request 转成包装对象,这个包装对象也传不回后面真正执行 Controller 的那一段流程。Spring MVC 在执行 HandlerAdapter 时用的是 DispatcherServlet 自己持有的 HttpServletRequest,拦截器改的只是一个局部引用,对后续链路没有任何影响。
所以你在网上看到“用拦截器往 request 里塞参数”的写法,基本都停留在表面。想让 Controller 的@RequestParam拿到新参数,最干净的方案还是用过滤器。
不过如果你只是想在拦截器里根据 Header 或业务上下文自己算出一个值,然后通过别的方式传给后续逻辑,也是有替代方案的:
public class TraceContextHandlerInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isEmpty()) { traceId = UUID.randomUUID().toString(); } // 放入ThreadLocal或MDC,供本请求内任意层读取 TraceContextHolder.set(traceId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TraceContextHolder.clear(); } }这种方式并不会修改 Request 参数,但解决了很多“想要传递上下文”的真需求。如果你非要让 Controller 的方法签名直接多一个参数,Spring MVC 生态里还有一个更契合的思路:自定义HandlerMethodArgumentResolver,在调用 Controller 方法前解析出一个参数对象,这个方案本质上也是“在进入 Controller 之前修改参数”,并且比拦截器改 Request 干净很多。
public class TraceContextArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType().equals(TraceContext.class); } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); String traceId = request.getHeader("X-Trace-Id"); return new TraceContext(StringUtils.hasText(traceId) ? traceId : UUID.randomUUID().toString()); } }把这个 Resolver 注册进去之后,Controller 里只要写:
@GetMapping("/order/list") public Result list(TraceContext traceContext) { // 直接用,参数进Controller前就被解析好了 }这套写法的优势在于:不污染原始 Request,下游接口不会因为突然多出参数而产生签名校验问题;缺点是要引入新的参数类型,改造范围稍大。你在技术选型时可以这样划线:简单统一加参数用 Filter;需要给特定 Controller 注入加工后的上下文对象用 ArgumentResolver;拦截器里的 ThreadLocal 适合做日志链路追踪。
3.3 实战组合:一个自动注入链路标识的案例
我实际做过一个跨系统调用项目,A 系统调用 B 系统的接口,B 系统要求每个请求带上traceId、channel、clientVersion三个参数。三个参数都不是用户主动传的,必须由入口统一注入。当时我的过滤器实现逻辑是:先从 Header 里取X-Request-Id,取不到就生成 UUID 作为 traceId;然后从请求域名判断 channel,比如小程序入口填mini,H5 入口填h5;最后把clientVersion默认成1.0.0。
这段逻辑看起来简单,但真正麻烦的是参数有优先级:用户明确传了 channel 就不能覆盖,没传才用域名判断填默认。所以我前面强调要把“覆盖”和“不存在才添加”分开设计,实际场景里这两种语义都会用到。不少人在这一步踩坑——用putParameter直接把用户传值覆盖掉,导致 App 端上报的渠道信息全被重写,收益分析数据直接失真。这类问题不炸则已,一炸就是报表级别的灾难。
4. 参数、Body、编码:生产环境最容易踩的三个坑
4.1 坑一:postman测没问题,真实请求全失效——路径过滤太粗糙
过滤器的 URL 匹配是前缀匹配,/api/*只匹配以/api/开头的路径。如果你的接口实际是/v1/api/order开头写成了/api/*,Postman 测试一条 OK 的接口可能根本没走过滤器,等你在生产环境看日志才发现参数没注入。排查方式很简单:在过滤器里加一行日志,打印每次请求的 URI 和是否命中:
log.info("GlobalParamFilter uri={}, matched={}", httpRequest.getRequestURI(), pathMatcher.matches(httpRequest.getRequestURI()));另外也要注意,Spring Boot 内置的 FilterRegistrationBean 的 urlPatterns 用/*和/api/*这两个写法区别很大,/*匹配所有,而/api/*只匹配路径前缀。建议先确认框架的匹配规则再做配置,不要想当然。
4.2 坑二:包装了Request但忘了放行包装对象
这个是这个技术点里最普遍的低级错误,我在 3.1 已经强调过。这里再补充一个排查技巧:如果你确定包装逻辑正确,下游就是看不到新参数,可以先在包装类的getParameter方法里打日志,确认参数是否真的进入了包装类。如果日志显示参数已经在包装类里,但 Controller 还是拿不到,99% 是放行了原始 Request。
4.3 坑三:POST JSON请求,参数改了但Controller还是拿不到
这个问题最有迷惑性。上面所有代码处理的是表单和 URL 参数,但当你用@RequestBody接收 JSON 时,Spring 是从输入流里反序列化的,跟getParameter体系完全无关。所以就算你在包装类的getParameter里返回新参数,Controller 的@RequestBody User user拿到的还是原 JSON。
解决方法是:包装类要额外维护一份 body 字节数组,重写getInputStream和getReader,让下游读取时拿到的是“经过修改后的 JSON 字符串”。大致思路是先缓存原始 body,然后在过滤器里解析 JSON、注入参数、再序列化成 bytes。
为了不把篇幅拉得太长,我给出最常见的做法——重写getInputStream和getReader:
public class BodyCachingRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public BodyCachingRequestWrapper(HttpServletRequest request) throws IOException { super(request); this.body = IOUtils.toByteArray(request.getInputStream()); } @Override public ServletInputStream getInputStream() { final ByteArrayInputStream bais = new ByteArrayInputStream(body); return new ServletInputStream() { @Override public int read() { return bais.read(); } @Override public boolean isFinished() { return bais.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener readListener) { // 不做异步监听,按同步处理即可 } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }然后过滤器里的处理逻辑是:
BodyCachingRequestWrapper wrapper = new BodyCachingRequestWrapper(httpRequest); String body = new String(wrapper.getBodyBytes(), StandardCharsets.UTF_8); JSONObject json = JSON.parseObject(body); json.put("source", "app"); // 用新的JSON字节数组重新构造一个包装对象放行这里要记住:一旦你读取过输入流,原始输入流就被消费掉了,所以必须在一开始就缓存字节数组,不能一边读一边改。如果你只覆写了getInputStream而没有缓存 body,后续任何组件再调用getInputStream时都会得到空流,那问题就更隐蔽了。
每次请求都解析 JSON 是有性能损耗的,所以别在全局过滤器里对所有路径做 JSON 包装,建议只对/api/json/这类确定使用 JSON 的路径处理。
4.4 坑四:中文参数乱码——编码问题与过滤器顺序
修改参数时如果注入的是中文,比如putParameter("operatorName", "张三"),后续getParameter输出很可能是乱码。这个问题的根因往往不在包装类,而在整个请求链路里有没有在 Servlet 解析参数之前设置好字符编码。Spring Boot 项目一般有CharacterEncodingFilter,默认 UTF-8,它能一劳永逸解决 GET、POST 表单、body 的编码问题。但如果你的项目里 Filter 执行顺序不对,比如参数增强过滤器先于编码过滤器执行,你在包装类里硬塞进去的中文字符串没有经过任何编码转换,到了下游解析那一步还是会乱。
最稳的做法是:保证项目里有一个CharacterEncodingFilter且 order 值最小,比如 0;参数增强过滤器 order 在它之后,比如 1。这样原始参数和注入参数的编码环境是一致的。
4.5 坑五:参数增强与签名校验互相打架
如果你下游接口带有签名校验机制,你无脑往 Request 里塞参数,十有八九会让签名验不过。因为签名是对原始报文算的,下游拿到的报文里多了一个参数,重算签名必然不一致。这种场景下的处理策略有三个:
- 在网关层统一解签,去掉签名相关参数后再注入新参数,下游不再做签名校验;
- 新参数只用于内部链路,不参与签名,下游校验时排除该字段;
- 如果是 SDK 对接且对方接口要求必传,那必须在联调前同步对方,把新增参数纳入签名规则。
我见过一个项目因为过滤器统一加了debug=true,导致下游验签全挂,最后排查了整整一天。凡是自动加参数,一定要先确认下游有没有校验请求完整性的机制。
5. 高频问题排查速查表与几句经验
下面这套速查表是我在实际排障用过很多次的,直接照着定位就行。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
getParameterMap().put抛 UnsupportedOperationException | 容器返回只读Map | 不能直接修改,改用Wrapper副本 |
| 包装类写了但Controller拿不到新参数 | doFilter放行了原始Request | 改为放行包装类 |
| 新参数在过滤器里有,但日志链路取不到 | 没覆写getParameterNames | 包装类补上getParameterNames覆写 |
| Postman测试正常,线上不生效 | Filter注册路径不匹配 | 检查urlPatterns和@ServletComponentScan |
| JSON接口加参不生效 | 参数在body里,不在getParameter体系 | 缓存body并重写getInputStream/getReader |
| 注入中文后乱码 | 过滤器顺序导致编码不一致 | 编码过滤器order置为0,参数增强置为后 |
| 下游签名校验失败 | 自动加参导致报文变化 | 确认签名排除规则或网关统一处理 |
| 同一个参数出现两个值 | 误用了覆盖逻辑只add不put | 明确putParameter和putIfAbsent的语义 |
再补充两个小技巧。第一,在线排查参数修改问题时,尽量别用request.getParameter("x")一条条打日志,直接在包装类里打parameters.toString(),一眼就能看到整体参数情况,效率高很多。第二,如果项目里有多个过滤器,建议在包装类构造时打一条 debug 日志,记录原始参数的 Map 快照;后面如果有人新加了过滤器导致参数被二次覆盖,对比快照能快速定位是谁动的手脚。
最后说点个人体会。做这种“改动 Request”的功能,最难的不是写包装类,而是想清楚“改了之后对下游意味着什么”。参数增强看似只要在入口加几行代码,但它在整个调用链里属于“看不见的变更”,下游接口的调用方并不知道你默默塞了新字段。一旦下游有严格的参数校验、有签名、有敏感字段过滤,你的改动就可能变成线上事故的导火索。我的习惯是:任何自动加参逻辑,都不做无差别的一声不吭加参,而是分类处理——新增参数用putIfAbsent,让用户传的值优先;覆盖参数前先确认下游真的需要覆盖;涉及签名的接口,宁可绕开也不要硬闯。这些细节看着小,但真到线上排查故障时,每一条都能帮你少熬一个通宵。