一、Tomcat里的管道阀门(Valve)到底是啥?
1.1 生活化类比:把Tomcat比作小区外卖配送系统
你可以把Tomcat处理请求的流程类比成小区的外卖配送:骑手送达后,要经过小区保安的身份校验、快递柜的管理员登记、最后才送到你手里,每个环节都是一个“节点”,不会直接从骑手到用户。Tomcat的核心处理逻辑也是如此,整个请求处理链路是一条“管道(Pipeline)”,每个中间节点就是“阀门(Valve)”,请求会按顺序穿过每个阀门,最后才会到达你写的业务代码(比如Servlet)。这个管道是Tomcat原生自带的,不用你自己搭建,只要往链条里加自定义阀门就行。
1.2 为啥要自己写自定义Valve?
默认的Tomcat访问日志只记录IP、请求路径、响应码这类基础信息,但业务系统往往需要额外的字段,比如订单ID、用户ID,要是改业务代码太麻烦,就可以用自定义Valve,不用改动任何业务代码,直接在管道里加一个“记录自定义字段”的阀门,就能满足需求,相当于不用改变外卖流程,只加一个“核对订单号”的节点,非常灵活。
二、写个自定义的访问日志Valve,分分钟搞定
2.1 准备工作:搞清楚需要的东西
要做自定义日志阀门,只需要两样:Tomcat的API依赖(Tomcat自带,不用额外下载)、一个Java类继承Tomcat的ValveBase(基础阀门类)。最后把打好的jar包放到Tomcat的lib目录,Tomcat就能加载这个类生效,不用重启Tomcat之外的应用。
2.2 代码示例:带自定义字段的访问日志Valve
这里的技术栈是Java,完整代码带详细注释:
import org.apache.catalina.ValveBase;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.logging.Logger;
// 自定义日志阀门,继承Tomcat的基础阀门类ValveBase,符合Tomcat的插件规范
public class CustomAccessLogValve extends ValveBase {
// 用日志记录器输出,实际项目可替换为SLF4J/Log4j,更灵活
private static final Logger log = Logger.getLogger(CustomAccessLogValve.class.getName());
// 核心处理方法,每个请求都会触发这个方法
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
try {
// 从请求里拿自定义字段:这里拿请求头里的用户ID、订单ID,实际可从会话、请求参数取
String userId = request.getHeader("X-User-Id");
String orderId = request.getHeader("X-Order-Id");
// 必须调用下一个阀门,不然请求会卡在这,业务代码拿不到请求,返回500错误
getNext().invoke(request, response);
// 自定义日志格式,加入业务需要的字段,比如时间、IP、路径、用户ID、订单ID
String customLog = String.format(
"CUSTOM_ACCESS|%s|%s|%s|%s|%s",
new java.util.Date(), // 请求到达时间
request.getRemoteAddr(), // 客户端IP
request.getRequestURI(), // 请求路径
userId != null ? userId : "Anonymous", // 用户ID,为空时默认值
orderId != null ? orderId : "NoOrder" // 订单ID,为空时默认值
);
// 输出日志,测试时用控制台即可
log.info(customLog);
} catch (Exception e) {
// 捕获异常,避免自定义阀门的bug影响整个Tomcat运行
log.severe("CustomValve Error: " + e.getMessage());
// 出异常时也要继续传递请求
getNext().invoke(request, response);
}
}
}
2.3 把阀门加到Tomcat,两步搞定
第一步:把上面的代码打成jar包,命名比如custom-valve-1.0.jar,放到Tomcat的lib目录(比如apache-tomcat-9.0.65/lib),这样Tomcat启动时就能加载这个类。
第二步:修改Tomcat的配置文件conf/server.xml,找到要加阀门的位置:如果要给所有虚拟主机加,就放到<Engine>标签里;如果只给单个虚拟主机加,就放到对应<Host>标签里,添加一行配置:
<!-- 自定义阀门的配置,className要换成你自己的类的全路径,比如com.example.CustomAccessLogValve -->
<Valve className="com.example.CustomAccessLogValve" />
保存后重启Tomcat,访问任意接口,就能在日志里看到自定义的字段了。
三、实现请求劫持?其实是请求的预处理和修改
3.1 啥是请求劫持?不是真的劫持请求,而是阀门里对请求做预处理
你可以理解为“提前拦截请求处理一下,再送给业务代码”,比如要统一把所有用户名转成大写,或者隐藏请求里的密码参数,都可以用这个功能,这就是题目里“请求劫持”的实际落地方式,本质是Valve对请求的修改和预处理。
3.2 代码示例:修改请求参数的自定义阀门
这里用到Tomcat提供的HttpServletRequestWrapper(请求包装类),用来修改请求内容,完整代码带注释:
import org.apache.catalina.ValveBase;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.IOException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
// 自定义请求处理阀门,用来修改请求参数
public class RequestPreprocessValve extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
// 把Tomcat的Request转成HTTP请求类型,方便用包装类
HttpServletRequest httpReq = (HttpServletRequest) request;
// 包装请求,修改参数
HttpServletRequest wrappedRequest = new CustomRequestWrapper(httpReq);
// 把包装后的请求传给下一个阀门,业务代码拿到的就是修改后的请求
getNext().invoke((Request) wrappedRequest, response);
}
// 静态内部类:自定义请求包装类,用来修改参数,继承Tomcat的HttpServletRequestWrapper
private static class CustomRequestWrapper extends HttpServletRequestWrapper {
// 存储修改后的参数,用Map实现
private final Map<String, String[]> modifiedParams;
public CustomRequestWrapper(HttpServletRequest request) {
super(request);
// 复制原来的参数,避免修改原请求
modifiedParams = new HashMap<>(request.getParameterMap());
// 把username参数转成大写,比如前端传tomcat,业务代码拿到TOMCAT
String username = request.getParameter("username");
if (username != null) {
modifiedParams.put("username", new String[]{username.toUpperCase()});
}
}
// 重写getParameter方法,返回修改后的参数
@Override
public String getParameter(String name) {
String[] values = modifiedParams.get(name);
return (values != null && values.length > 0) ? values[0] : null;
}
// 重写getParameterMap方法,返回修改后的参数集合
@Override
public Map<String, String[]> getParameterMap() {
return modifiedParams;
}
// 重写getParameterValues方法,返回修改后的参数数组
@Override
public String[] getParameterValues(String name) {
return modifiedParams.get(name);
}
// 重写getParameterNames方法,避免框架拿不到参数名
@Override
public Enumeration<String> getParameterNames() {
return java.util.Collections.enumeration(modifiedParams.keySet());
}
}
}
3.3 测试请求预处理的效果
你用Postman发一个请求,带username=test参数,业务代码拿到的username会变成TEST,全程业务代码不需要做任何改动,这就是请求预处理的魔力,适合统一处理参数格式、隐藏敏感参数等需求。
四、这个技术的实际应用、优缺点和注意事项
4.1 应用场景
第一个场景是自定义访问日志:比如加入订单ID、用户ID、请求耗时(在Valve里计算从请求到业务处理的时间);第二个场景是请求过滤:在Valve里检查IP是否在黑名单,是就直接返回403,不用到业务代码;第三个场景是请求统一修改:把敏感参数(比如password)替换成***,统一参数编码;第四个场景是埋点:统计接口调用次数、响应时间,不用改业务代码。
4.2 技术优缺点
优点:第一,全局生效,不用改业务代码,适合框架层面的需求;第二,性能优秀,Valve是Tomcat原生的处理逻辑,比过滤器优先级更高;第三,灵活,可配置在Engine/Host/Context层级,对应全局/虚拟主机/单个应用;缺点:第一,要懂Tomcat的Pipeline和Valve的执行顺序,放错位置会失效;第二,Valve是单例的,成员变量不能存请求相关的非线程安全数据;第三,类加载问题,多应用共用Tomcat时要注意jar包的位置。
4.3 注意事项
第一,必须调用getNext().invoke(request, response),不然请求会卡住返回500;第二,异常处理要做好,避免Valve的bug影响整个Tomcat;第三,线程安全:Valve是单例,不能在成员变量里存请求相关的变量,必须从request里取;第四,类路径:第三方依赖要放到Tomcat的lib目录,不要混在应用的jar里,避免冲突;第五,阀门顺序:先做IP过滤,再做日志,不要反过来,不然黑名单请求也会被记日志。
五、总结
Tomcat的Valve管道机制是扩展Tomcat能力的核心方式,自定义Valve既能满足自定义访问日志的需求,又能实现请求的预处理,全程不需要改动业务代码,非常适合做框架层面的功能。实战中很多公司的安全拦截、日志埋点都用这个技术,是后端开发者必须掌握的Tomcat进阶技能。
评论
围绕“Tomcat Valve管道机制深度揭秘:自定义访问日志过滤器与请求劫持实现的全流程解析及实战案例核心原理详细步骤分析”参与讨论