做Java Web开发久了,你迟早会遇到那么两个需求:一个是用户带着链接直接闯进后台页面,另一个是用户提交的文本里藏着一堆不雅词汇。我在做企业内部后台系统的时候,这两个需求几乎是必配项。如果按照最原始的做法,在每个Servlet里手动判断Session、在每个接收请求的接口里循环替换敏感词,代码会重复到怀疑人生。Filter过滤器就是用来解决这类横切逻辑的,它在请求到达Servlet之前统一拦一道,既能把未登录用户挡在门外,也能把文本里的敏感词替换干净。这篇文章我把我实际落地的一套Filter方案完整拆开讲,包含登录验证Filter的URL放行策略、Ajax无痕处理,以及敏感词过滤Filter里的请求对象包装和DFA算法替换,适合正在做Java Web项目或者准备面试时被问到过滤器原理的同学参考。
1. 登录验证和敏感词过滤,为什么绕不开Filter
1.1 这两件事的本质是“横切关注点”
先说一个判断标准:如果某个逻辑需要在几乎所有接口里重复执行,只有几条白名单路径可以豁免,那它就适合用Filter来做。登录验证就是最典型的例子——后台系统的所有请求,除了登录页、验证码、登录接口之外,都应该检查当前用户是否已经登录。敏感词过滤也类似,凡是用户可输入的接口,比如注册、评论、修改昵称、发布文章,都需要对文本内容做一次清洗。这两个需求共同点是:它们不属于某个具体业务模块,而是横跨所有模块的通用逻辑。把这种逻辑写在业务Servlet里,会让每个接口的代码被迫关心与本身无关的事情,时间一长,漏判、漏过滤几乎是必然的。
Filter正好是Servlet规范里为这类“前置处理”设计的组件。它被定义在javax.servlet包中,核心方法就三个:init、doFilter、destroy。容器在启动时创建Filter实例,之后每个请求进入容器,都会先经过FilterChain(过滤器链),再到达Servlet。Filter可以修改请求和响应对象,也可以决定是继续放行还是直接终止请求。我用一个通俗的比喻:业务Servlet像是公司里的各个部门,Filter就是大楼进门处的安保和前台,所有访客先过安检、登记,然后才被引导到对应科室。这种“先统一处理、再分发业务”的模型,天然就适合登录验证和敏感词过滤。
1.2 Filter在请求链上的位置:选型先看时机
在Java Web体系里能拦截请求的方案不止Filter一个,常见还有Servlet本身和Spring的Interceptor。很多同学会问这两件事我能不能用拦截器做?答案是能,但要看你的项目是否依赖Spring MVC。Interceptor是Spring MVC框架层的东西,它生效的前提是请求已经进入DispatcherServlet分发的流程,如果你用的是纯Servlet项目,或者框架里有一些不走DispatcherServlet的路径,Interceptor就管不到。Filter则是Servlet容器层面就介入的组件,它的执行点比Interceptor更靠前,因此连静态资源、框架内部转发这种更底层的请求都能覆盖到。
再换一个角度考虑:这两个需求都要求修改请求内容。敏感词过滤里,原本的HttpServletRequest对象的getParameter返回的是原始文本,我们要把替换后的文本塞回请求里,就必须借助HttpServletRequestWrapper重写getParameter方法。这种请求对象的“换血操作”在Filter阶段做最顺手,因为你可以在调用chain.doFilter之前就把包装后的请求对象传进去,后续的Servlet和Interceptor拿到手的就是已经清洗过的数据。如果放到Interceptor阶段,处理起来反而别扭,还得考虑和Controller层参数的适配问题。
1.3 三者的分工边界
我的习惯是:能用Filter解决的横切逻辑,绝不上移到Interceptor。登录态校验属于安全边界控制,需要作用在一切受保护资源上,用Filter。敏感词清洗属于输入预处理,要在业务代码接触数据前完成,用Filter。Interceptor更适合做Controller层的前置逻辑,比如权限细粒度校验、请求参数的二次组装、埋点日志,这些需要拿到HandlerMethod信息的场景。
下面这张表是我在团队内部讲课时常用的对比,核心区别就是“生效阶段不同”。
| 方案 | 生效位置 | 能修改请求对象 | 适用范围 | 典型场景 |
|---|---|---|---|---|
| Filter | Servlet容器,Servlet之前 | 能(通过Wrapper) | 所有Web请求 | 登录验证、编码设置、敏感词过滤、日志 |
| Servlet | 容器内业务入口 | 本身就是接收方 | 单一映射路径 | 具体接口逻辑 |
| Interceptor | Spring MVC DispatcherServlet之后 | 不能直接改 | 进入Handler的请求 | 用户权限细查、Handler参数处理 |
2. 登录验证Filter的完整落地:从session校验到静态资源放行
2.1 最基础的一版写法:别急着复杂化
登录验证Filter第一版代码其实非常简单。doFilter里做的第一件事就是从request里拿Session,然后判断Session里有没有用户信息。这里有个细节:request.getSession(false)和request.getSession()有区别,前者是“如果没有Session就返回null”,后者是“如果没有Session就创建一个新的”。在做登录校验时必须用getSession(false),否则每个未登录请求都会被系统“好心”地创建一个新Session,你的判断永远找不到用户,还会白白增加服务端内存压力。
public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; Object loginUser = req.getSession().getAttribute("loginUser"); if (loginUser == null) { resp.sendRedirect(req.getContextPath() + "/login"); return; } chain.doFilter(request, response); } }这段代码跑起来,大部分请求都会被正确拦到登录页,但很快你就会发现一个要命的问题:登录页面本身也被拦了。用户访问/login的时候,系统判断没有登录,就重定向到/login,而/login依然没有登录态,于是又重定向到/login,浏览器会提示“重定向次数过多”。所以任何登录验证Filter都必须有一个白名单机制。
2.2 白名单管理:放行登录页、登录接口和静态资源
解决死循环的思路是在校验Session之前,先判断当前请求的URL是否属于“不需要登录也能访问”的路径。我一般会维护一个白名单集合,里面放两类东西:一类是登录相关的URL,比如/login、/login.do、/captcha;另一类是静态资源后缀,比如.css、.js、.png、.jpg、.ico。判断URL时要注意,request.getRequestURI()返回的是包含项目上下文路径的完整路径,比如/yourproject/login,所以要么用startsWith匹配,要么先把上下文路径去掉再匹配。
private static final Set<String> WHITE_LIST = new HashSet<>(Arrays.asList( "/login", "/login.do", "/captcha", "/register" )); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI().substring(req.getContextPath().length()); if (isStaticResource(uri) || WHITE_LIST.contains(uri)) { chain.doFilter(request, response); return; } Object loginUser = req.getSession().getAttribute("loginUser"); if (loginUser == null) { resp.sendRedirect(req.getContextPath() + "/login"); return; } chain.doFilter(request, response); } private boolean isStaticResource(String uri) { return uri.endsWith(".css") || uri.endsWith(".js") || uri.endsWith(".png") || uri.endsWith(".jpg") || uri.endsWith(".ico"); }这里有几个容易被忽略的地方。第一,白名单匹配建议用精确匹配,不建议用contains,否则“/login”会把“/loginHistory”这种接口也放掉。第二,静态资源放行一定要做,否则整个页面的样式和脚本都会被重定向到登录页,最后登录页自己都长得乱七八糟。第三,如果你的系统里有文件上传后预览的接口,比如访问“/file/2024/xxx.pdf”,这个路径往往是动态的,不能靠后缀白名单解决,更合理的做法是让静态资源由独立的Nginx或CDN伺服,应用层只处理动态接口,直接把所有非接口路径放行到默认的静态资源Servlet。
2.3 Ajax请求的特殊处理:不能直接重定向
重定向方案对普通页面跳转没问题,但对Ajax请求就是一场灾难。假设前端页面用fetch调了一个“/api/user/list”接口,登录态过期,后端返回302重定向到登录页。浏览器会发现这次请求的响应是一个HTML页面,状态码是200(经过重定向之后的最终状态),而前端期望的是JSON数据,于是你的列表接口拿到一堆HTML源码,JSON解析直接白屏报错。这个问题在我实际项目中至少出现过三次,每次都是前端同学先发现。
正确的做法是在重定向之前先判断这个请求是不是Ajax请求。判断方式有几种,最常见的是看请求头X-Requested-With,如果值是XMLHttpRequest,就说明是Ajax。也可以用Accept响应头判断,如果Accept里包含application/json,那也是Ajax或者至少是期望JSON的请求。我更推荐同时使用两种方式,因为不同前端框架设置的请求头习惯不太一样。
boolean isAjax = "XMLHttpRequest".equals(req.getHeader("X-Requested-With")); if (loginUser == null) { if (isAjax) { resp.setStatus(401); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"登录已过期,请重新登录\"}"); } else { resp.sendRedirect(req.getContextPath() + "/login"); } return; }在前端层面,Axios这类库会默认在响应状态码为401的时候进入error回调,你可以在拦截器里统一跳转登录页,这样切面逻辑就在前后端各做了一层,体验会好很多。有一说一,很多项目里Ajax预检请求(OPTIONS)也会被Filter拦截,最好也把OPTIONS方法放行,否则跨域场景下前端会先收到404或401,真正的业务请求根本发不出去。
2.4 登录后的状态维持与防重复提交
登录验证通过后还要考虑“别每次都查一遍用户信息”。Session本身就是用户状态的载体,登录成功后你应该把用户ID、账号、角色列表放入Session,Filter里只做“Session里有没有登录标识”的判断,不要反查数据库,更不要在Filter里做权限枚举查询。权限判断应该是后续业务模块的事,Filter管得太宽会拖慢每一个请求的速度。
还有一个我踩过的坑:有些同学为了“安全”,每次请求都重新把用户信息放进Session,导致Session里的对象引用不断变化,在高并发场景下容易引发Session同步开销。正确做法是在用户登录成功时放入一次,之后Filter只读不写。如果用户信息被修改,要么在修改接口里主动刷新Session,要么就接受“旧数据保留到Session过期”的短暂不一致。这也是为什么很多系统会把用户头像之类的非关键信息放在前端cookie里,而不是频繁更新Session。
3. 敏感词过滤Filter:请求对象被“换血”的秘密
3.1 敏感词库加载策略与存储设计
敏感词过滤的第一步是把敏感词库加载到内存里。词库从哪来?小型项目可以放一个敏感词.txt文件放classpath下,每行一个词,启动时加载。中型项目建议放数据库表,字段就两个:id和word,启动时全量读入。我习惯的做法是封装一个SensitiveWordService,启动时把词库加载到一个Set里,并提供reload方法,方便运营后台在不重启服务的情况下重新加载词库。这里有一个平衡问题:词库全部放内存,查询快但更新麻烦;词库每次实时查数据库,更新方便但每个请求多一次IO。对于大多业务系统来说,启动时加载到内存,配合定时刷新(比如每5分钟查一次变更)就足够了。
词库加载完成后,核心的数据结构选择就很关键。很多人第一反应是用List,然后遍历做contains判断。这在词只有几十个的时候没问题,但如果词库有几千条,每篇长文本又有几千字,时间复杂度会飙到O(词库大小×文本长度),表现为文本稍长就明显卡顿。正确的做法是构建一个字典树(Trie),也叫DFA(确定性有限自动机)的雏形。我实现的思路是:把每个敏感词的每个字作为节点,插入一棵树中,每个节点保存“这个字后面可能出现的下一个字”的映射,以及“是否到达一个敏感词终点”的标记。
public class SensitiveWordTrie { private final Map<Character, SensitiveWordTrie> children = new HashMap<>(); private boolean end; public void addWord(String word) { SensitiveWordTrie current = this; for (char c : word.toCharArray()) { current = current.children.computeIfAbsent(c, k -> new SensitiveWordTrie()); } current.end = true; } public boolean isEnd() { return end; } public SensitiveWordTrie next(char c) { return children.get(c); } }3.2 直接修改请求参数是行不通的:用包装器“偷梁换柱”
Filter里拿到request.getParameter("content"),得到的是一个字符串。你用DFA扫描出敏感词,替换成“***”,然后呢?麻烦来了:HttpServletRequest的getParameter方法内部是容器解析请求体后的结果,这个结果是只读的,你不能直接从外面修改它。如果Filter只负责打印日志还好说,但你要让后续的业务Servlet拿到替换后的文本,就必须动请求对象本身。
Servlet规范早就想到了这个场景,提供了一套非常巧妙的扩展机制:javax.servlet.http.HttpServletRequestWrapper。这个包装类实现了HttpServletRequest接口,内部持有一个原始request对象,你继承它后可以只重写你关心的方法。在敏感词过滤场景里,我们需要重写的是getParameter(String name)。当外部代码调用getParameter("content")时,实际执行的是我们在包装器里写好的逻辑:先拿到原始值,再做敏感词过滤,最后返回替换结果。这样业务层完全无感知,它拿到的就是“已经被洗过”的数据。
不过这里有个大坑必须提醒:request.getParameter只对POST表单提交(Content-Type为application/x-www-form-urlencoded)有效,如果前端提交的是JSON数据(Content-Type为application/json),Tomcat容器一般不会解析成parameter,getParameter拿到的是null,那么你重写的getParameter就拦截不到任何东西。对于JSON请求体,必须重写getInputStream方法,把原始的InputStream读出来,替换后再包装成一个新的ServletInputStream返回。这个操作比getParameter复杂得多,因为InputStream只能读一次,被Filter读过后后续的Servlet就读不到原始内容了。业界成熟方案是用ContentCachingRequestWrapper之类的工具,先把请求体缓存到内存,再解析替换。我的建议是:敏感词过滤的完整方案必须同时处理这两种请求格式,如果只做一半,线上大概率会有漏网之鱼。
3.3 核心替换逻辑:单次扫描完成匹配与替换
敏感词替换的算法实现是整个Filter的精华。我们不能简单地遍历词库然后一个一个replace,因为在大量词、长文本场景下性能太差,而且replace还会出现重叠词替换后的连锁覆盖问题。用字典树配合“扫描哨兵”指针可以做到一次遍历完成全量匹配。我从实现上拆成三步:
第一步,构建字典树。初始化时把所有敏感词逐字插入树中,这一步在应用启动时执行一次。第二步,设置一个可变长度的自定义替换掩码,比如默认用“”作为替换符。注意一个问题:敏感词长度不同,替换符长度如果固定,比如“abc”替换成“”是三个星号,“abcd”替换成“”也是三个星号,那么敏感词和掩码的字节长度就不一致了。很多平台的做法是替换成同长度的星号,即“abcd”替换成“”,这样能保留原文的排版宽度。我偏向前者,掩码统一为“**”,因为从用户角度看,星号是遮挡符,不需要保留原长,统一还能避免某些极长词生成超长星号串。
第三步就是核心扫描了。用一个指针从头到尾遍历文本,同时维护另一重循环:从当前字符开始,试图沿着字典树往下走。走到某个节点发现这个字是某个词的终点,就记录当前结束位置;如果继续往下走发现没有子节点了,就退出内层循环,把区间替换成掩码。替换完,指针跳到终点位置的下一个字符,继续外层扫描。
public String filter(String text) { if (text == null || text.isEmpty()) { return text; } StringBuilder sb = new StringBuilder(text); int i = 0; while (i < sb.length()) { SensitiveWordTrie node = root; int j = i; int lastEnd = -1; while (j < sb.length()) { node = node.next(sb.charAt(j)); if (node == null) { break; } if (node.isEnd()) { lastEnd = j; } j++; } if (lastEnd != -1) { for (int k = i; k <= lastEnd; k++) { sb.setCharAt(k, '*'); } i = lastEnd + 1; } else { i++; } } return sb.toString(); }这段代码里比较容易写错的是替换区间的起点和终点。如果某次匹配以当前位置开始能匹配到敏感词,那这段区间内所有字符都要替换,而“区间”边界是动态变化的。我一开始写的时候总喜欢在循环里删除和插入字符,结果要么要小心处理索引回退,要么频繁创建新字符串。后来改成直接用StringBuilder.setCharAt逐个改成星号,完全避免了对原字符串长度的改动,索引管理瞬间清爽很多。
3.4 中文编码与词库边界问题
敏感词替换过程中还要考虑大小写和全半角问题。比如敏感词是“赌博”,但用户输入“赌 博”中间加了个空格,或者用的是全角字符“赌 博”,直接匹配就失效了。我的做法是在加载敏感词时同时存一份“规范化”后的版本:把全角转半角、大写转小写、去除内部空白字符。但这里要权衡,因为去除空格后可能把正常的用户输入误伤,比如“工作者”和“工 作者”,后者明显不是敏感词。所以更稳妥的方案是:敏感词过滤过程中,对原文做归一化匹配,替换时仍还原回原始坐标区间,而不是修改原词。这个实现复杂度会高一些,如果你的业务对误判容忍度不高,建议先把基本的大小写和全角处理做了,空格处理单独做一层配置开关。
词库本身的维护同样重要。一个上线半年的系统,敏感词会不断变化,新出的不雅词、变体词都要持续补充。如果词库加载逻辑写在Filter的init方法里且只加载一次,那么更新词库就得重启应用。我建议在敏感词库Service里设计一个reload方法,配合一个后台管理的按钮或者定时任务调用,Filter每次filter时直接用Service维护的字典树引用即可。
4. 两个Filter怎么注册、怎么排序、怎么排查重定向死循环
4.1 web.xml配置 vs 注解配置 vs Spring Boot注册
登录Filter和敏感词Filter都编写完成后,接下来的问题是:怎么让容器知道它们的存在?传统Servlet项目可以在web.xml里声明filter和filter-mapping。注意filter-mapping的url-pattern写“/*”表示拦截所有请求,顺序就是声明顺序。用@WebFilter注解也可以,但注解方式无法精确控制Filter之间的执行顺序,这一点在后面会单独讲。
<filter> <filter-name>characterEncodingFilter</filter-name> <filter-class>com.example.filter.CharacterEncodingFilter</filter-class> </filter> <filter-mapping> <filter-name>characterEncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>loginFilter</filter-name> <filter-class>com.example.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>sensitiveWordFilter</filter-name> <filter-class>com.example.filter.SensitiveWordFilter</filter-class> </filter> <filter-mapping> <filter-name>sensitiveWordFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>如果是Spring Boot项目,更优雅的方式是注册FilterRegistrationBean。通过setOrder方法可以精确控制多个Filter的执行顺序,数字越小越先执行。这里要特别提醒:执行顺序和执行链方向是不同的。假设三个Filter注册顺序是A、B、C,那么请求进入的时候先执行A的doFilter前置逻辑,再执行B的前置逻辑,再C的前置逻辑,然后调用chain.doFilter进入Servlet;Servlet处理完,返回时再走C的后置逻辑、B的后置逻辑、A的后置逻辑。整个流程像一个洋葱,从一端穿进去,从另一端退出来。登录验证显然应该在最外层,也就是最先执行,所以它的order值应该最小;敏感词过滤和解码应该在它之后。如果敏感词Filter设置在登录Filter之前,未登录用户随便发个评论也能触发敏感词替换逻辑,多少有点浪费,而且理论上如果系统设计得比较粗糙,未登录请求甚至可能在敏感词阶段就消耗了性能。
@Configuration public class FilterConfig { @Bean public FilterRegistrationBean<CharacterEncodingFilter> encodingFilter() { FilterRegistrationBean<CharacterEncodingFilter> bean = new FilterRegistrationBean<>(); bean.setFilter(new CharacterEncodingFilter()); bean.addUrlPatterns("/*"); bean.setOrder(1); return bean; } @Bean public FilterRegistrationBean<LoginFilter> loginFilter() { FilterRegistrationBean<LoginFilter> bean = new FilterRegistrationBean<>(); bean.setFilter(new LoginFilter()); bean.addUrlPatterns("/*"); bean.setOrder(2); return bean; } @Bean public FilterRegistrationBean<SensitiveWordFilter> sensitiveWordFilter() { FilterRegistrationBean<SensitiveWordFilter> bean = new FilterRegistrationBean<>(); bean.setFilter(new SensitiveWordFilter()); bean.addUrlPatterns("/*"); bean.setOrder(3); return bean; } }从这张表可以直观看到我在项目里最终形成的执行链路:请求到达 → 字符编码Filter → 登录验证Filter → 敏感词过滤Filter → 业务Servlet。
4.2 过滤顺序的坑:字符编码Filter必须放最前
先讲一个我早期项目里的惨痛教训。当时我把字符编码Filter排在了登录Filter之后,结果用户输入中文用户名时,登录接口收到的参数全是乱码,即使Filter内部用了UTF-8解析,那一层已经到了登录Filter后面,乱码已经生成了。后来我把编码Filter挪到最前,一切恢复正常。原因很简单:request.setCharacterEncoding必须在读取任何请求参数之前调用,一旦某个组件提前调用了getParameter,编码就已经被固定了,之后再设置无效。所以,所有Filter里,字符编码处理永远排第一位。
敏感词Filter和登录Filter之间的顺序就得看业务导向了。我现在的习惯是登录验证在前、敏感词在后,因为一个连登录都没完成的用户,它的输入本来就不应该进入业务系统,先拦住它,后续的敏感词替换就是白省下来的功夫。反过来想,如果敏感词在前,虽然能更早清洗数据,但要为白名单以外的路径多跑一遍扫描,多数场景并不划算。
4.3 常见踩坑快速排查清单
我梳理几个在Filter调试中高概率遇到的问题,按出现频率排序。
第一个,日志不打印,Filter不执行。检查Filter类有没有加@Component或者正确注册到容器中。Spring Boot里如果Filter本身加了@Component又注册了FilterRegistrationBean,会造成重复注册,Filter会执行两次。解决办法:要么去掉@Component,要么注册时setEnabled(false)。
第二个,静态资源被重定向到登录页。确认白名单是否覆盖了所有静态资源后缀,以及你的请求URI是否包含项目上下文路径。我见过一个项目,前端所有资源都走/static前缀,但白名单里写的是/resources,导致页面样式全崩。
第三个,重定向死循环。这是登录Filter最常见的翻车现场,原因就是白名单没包含登录页本身,或者登录成功后跳转地址还是需要登录的路径。调试时可以在重定向之前加日志输出当前URI,快速定位是哪条路径没被白名单覆盖。
第四个,敏感词过滤后参数没有生效。排查方向是:确认请求的Content-Type是否被Filter正确读取;确认包装器类是否传给了chain.doFilter;确认业务代码是否从request.getParameter读取,而不是从某个全局静态变量读取。另外,如果项目里用了@RequestParam且框架在某些场合直接解析并缓存了参数列表,包装器就可能失效,这种情况要按框架的扩展机制单独处理。
第五个,有人会问登录验证Filter里能不能直接用response.sendRedirect到带参URL,比如“/login?redirect=/user/list”,这样登录成功就能跳回原页面。当然可以,但有一个细节:URL参数里的目标地址必须做URL编码,否则地址里带着参数拼接符会导致登录成功后的重定向地址解析错乱。我一般是用URLEncoder.encode(target)编码后拼进redirect参数,登录成功后再解码。
实际开发里,我最后还会在所有Filter的doFilter入口处加一条debug日志,记录方法、URI和是否放行。这个习惯帮我解决过很多“神秘的请求没走到业务代码”的问题。日志打印内容不用太复杂,一行就行:filterName: uri=xxx, action=pass/block/replace。过滤器链的排查就是这么简单粗暴:每一步都留痕,问题自然浮出水面——当然前提是Filter的顺序和放行规则已经写好,日志只是帮你验证预期。