资讯详情

Java Filter过滤器原理与实战:登录验证与敏感词过滤全解析

📅 2026/10/15 2:09:28 | 华诺云谱 👁 阅读
Java Filter过滤器原理与实战:登录验证与敏感词过滤全解析
1. 过滤器到底解决什么问题为什么每个项目都绕不开它先说点实际的。Filter过滤器这个技术点在Java Web开发里早就不是什么新鲜概念了但凡写过Servlet、Spring Boot项目的人肯定都配置过或见过它。但奇怪的是真正能把它用得透彻、把它的执行机制讲明白的人比我预想的要少得多。很多朋友写代码时只会在web.xml里抄一段过滤器配置或者Spring Boot里加一个WebFilter注解然后就不管了。真遇到“登录验证失效”“静态资源也被拦截了”“多个过滤器执行顺序不对”这类问题时排查起来往往一头雾水。这篇文章我用两个最常见的业务场景来做完整案例拆解第一个是登录验证第二个是敏感词过滤。这两个场景覆盖面极广几乎每个网站系统都会用到而且它们分别代表了Filter的两大核心能力——请求的准入控制和请求内容的内容加工/改写。配合代码实操、参数说明、遇到坑的复盘来写把这个看似基础的技术点讲到可以直接拿去用。先说结论过滤器最适合干的事情有三类校验类登录态校验、权限校验、接口签名校验、请求频次控制。加工类字符集编码设置、请求内容过滤替换、日志记录、响应内容压缩或加密。路由分发类根据特定条件将请求转发到不同的处理入口。登录验证属于典型的校验类敏感词过滤属于典型的加工类。把这两个案例打通你对Filter的认知就不只是“会配个过滤器”而已而是真正明白它的工作原理和应用边界。这个案例适合刚学完Java Web基础知识正在做项目练手的学生或刚转行的初级开发者也适合写了一段时间CRUD、但对容器底层执行机制似懂非懂、想补齐过滤器这块短板的从业者。如果你能独立写一遍这个案例动手调通中间的几个细节后面再看Spring MVC的拦截器看Shiro、Spring Security的过滤链设计思路会通畅很多。2. 先从原理说起过滤器的内核是一根链不是一层网很多人对过滤器的理解是“每个请求都会经过一个过滤器处理”这个说法不算错但不够准确。准确地说多个过滤器之间是通过一个链式结构依次执行的这个链条叫FilterChain也就是过滤器链。你可以把它想象成工厂流水线上的几个质检工位请求这个“产品”依次从第一个工位走到最后一个工位每个工位都有权限决定两件事——这个产品能不能继续往下流放行或者这个产品要经过怎样的处理再流向下一个工位加工、改写、替换。Filter的接口定义很简单核心方法就三个public interface Filter { default void init(FilterConfig filterConfig) throws ServletException {} void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException; default void destroy() {} }init过滤器被容器创建后执行一次适合做一些资源初始化的动作。注意它默认是空的需要时候才重写。doFilter每一个请求到达时都会执行这里是你的核心业务逻辑所在。destroy容器销毁过滤器时执行一次通常用于释放资源。三个方法里doFilter是重中之重。它接收三个参数其中FilterChain特别关键。你的处理逻辑写完之后必须调用chain.doFilter(request, response)这个请求才会继续流向下一个过滤器或者最终到达Servlet/Controller。如果忘了调用这一行请求就会卡死在这里前端一直等待响应后台也没有任何明确报错这个坑我后面详细讲。再看一下过滤器的生命周期。容器启动时根据配置创建过滤器实例并调用init。之后每次请求命中该过滤器的映射范围时都会走doFilter。容器关闭时调用destroy。这里跟Servlet一样过滤器在整个生命周期中是单实例多线程的也就是说多个请求会并发共用同一个Filter实例。所以你在doFilter里使用实例变量时务必要小心线程安全问题强烈建议把状态都放在方法内部或者保证实例变量只读。那为什么用Filter做登录验证和敏感词过滤而不是直接在Controller里做核心原因有两个一是复用性无论哪个Controller、哪个接口只要请求路径匹配过滤器映射就自动套上这套逻辑不需要每个接口手动调用校验代码二是关注点分离登录校验、编码处理、内容过滤这类横切逻辑本身就不应该跟业务代码掺和在一起放在Filter里更干净、更好维护。这就是所谓横切关注点最适合用过滤器解决的道理。延伸出一个常被问到的知识点Filter和Spring Interceptor有什么区别这俩确实容易混。过滤器的级别更靠前它在Servlet容器层执行链中每个请求都必然经过它跟Servlet代码无关拦截器则是Spring MVC框架层面的组件在HandlerMapping找到处理器之后执行。拦截器能干的事过滤器大多能干但反之不一定——比如修改请求体这样的能力过滤器反而更强因为拦截器拿到的Request已经被容器包装过了。此外还有执行顺序Filter总是在Interceptor之前执行。面试时能把这个层次关系说清楚面试官大概率不会觉得你只是背了概念。3. 登录验证案例从能用到好用登录验证是所有Web系统几乎都逃不掉的一环。没有它用户随便换个URL就能进入后台管理页等于把大门敞开着。但很多初版实现都写得比较粗糙比如在每个Controller方法里重复写一堆if (session.getAttribute(user) null) { return redirect:/login; }然后每个接口都来一遍代码重复率极高。Filter方案的好处就是把这段逻辑从所有接口里剥离出来集中处理。3.1 基础版实现一个类搞定登录校验先看一个最简版本。假设我们的系统是普通的ServletJSP结构用户登录成功后把用户信息存进SessionWebFilter(urlPatterns /*) public class LoginFilter implements Filter { private static final String LOGIN_URI /login; private static final String LOGIN_PAGE /login.jsp; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq (HttpServletRequest) request; HttpServletResponse httpResp (HttpServletResponse) response; String requestUri httpReq.getRequestURI(); // 登录接口本身和登录页面直接放行 if (requestUri.endsWith(LOGIN_URI) || requestUri.endsWith(LOGIN_PAGE)) { chain.doFilter(request, response); return; } // 其他请求统一检查session中是否存在用户 Object user httpReq.getSession().getAttribute(loginUser); if (user null) { // 未登录重定向到登录页 httpResp.sendRedirect(httpReq.getContextPath() LOGIN_PAGE); return; } // 已登录放行 chain.doFilter(request, response); } }代码逻辑不复杂核心就三步判断是否为登录相关资源是就直接放行不是则看Session里有没有用户有就放行没有就重定向到登录页。但请你注意一个问题上面这个写法/*拦截了所有请求这意味着JS、CSS、图片等静态资源也会被拦截。一旦被拦截用户打开登录页时页面样式全丢看起来就跟纯文本一样体验极差。所以下一步必须处理静态资源放行。3.2 细节优化静态资源放行、路径匹配规则、上下文路径坑点静态资源放行最常见的做法是加一个白名单把常见的静态文件后缀比如.js、.css、.png、.jpg、.ico、.woff2等排除掉。写一个简单的判断逻辑public class LoginFilter implements Filter { private static final SetString STATIC_SUFFIX new HashSet(Arrays.asList( .js, .css, .png, .jpg, .jpeg, .gif, .ico, .svg, .woff, .woff2, .ttf, .eot, .map )); Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq (HttpServletRequest) request; HttpServletResponse httpResp (HttpServletResponse) response; String requestUri httpReq.getRequestURI(); String contextPath httpReq.getContextPath(); // 去掉上下文路径后判断不同项目的上下文路径可能不同 String path requestUri.substring(contextPath.length()); // 是否为静态资源带有后缀名且后缀名在白名单里 int dotIndex path.lastIndexOf(.); if (dotIndex 0) { String ext path.substring(dotIndex).toLowerCase(); if (STATIC_SUFFIX.contains(ext)) { chain.doFilter(request, response); return; } } // 放行登录相关的接口和页面 if (path.equals(/login) || path.equals(/login.jsp)) { chain.doFilter(request, response); return; } Object user httpReq.getSession().getAttribute(loginUser); if (user null) { httpResp.sendRedirect(httpReq.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }这里有几个细节特别容易踩坑我逐个说。第一判断路径时一定要从requestURI里去掉contextPath。如果你部署的应用上下文路径不是根路径比如是http://localhost:8080/myapp/所有请求URI都会以/myapp开头。如果直接拿整个URI跟/login比较那永远不相等登录页也会被拦截。我在不少项目代码里都见过这种低级错误原因就是本地跑用根路径没问题一部署到对应前缀就挂。第二重定向时也要记得加contextPath。httpResp.sendRedirect如果只传/login.jsp在没有上下文路径的应用里没问题一旦有上下文路径重定向就指向根应用去了一样会404。用httpReq.getContextPath() /login.jsp才是最稳的写法。第三关于放行静态资源的判断用后缀名白名单是兼容性最好的做法。也有人说用Spring Security里的antMatchers(/static/**)那个思路不是不行但很多项目静态资源并不一定都放在/static目录下而且Filter层本身不依赖Spring后缀名判断更通用。当然如果你在Spring Boot项目里用了自定义后缀的资源记得加进集合。3.3 增强方案记住登录状态、AJAX请求区分、页面路径归一化基础登录验证只是起步真实项目中还得考虑几个体验问题。第一个是“记住登录状态”这个不展开细讲核心就是在用户勾选“7天内免登录”时签发一个带过期时间的Cookie过滤器里同时校验Session和这个Cookie两者有其一就放行。不过这种方案的安全性需要重点考虑。Cookie里不能直接存用户名或用户ID要存一个随机token数据库或Redis里存一份token与用户ID的映射关系并设置过期时间。这样即使Cookie被截获也无法反向推导用户信息。第二个是AJAX请求的处理。现在的Web应用大量使用前端框架页面跳转和接口请求都是异步的。如果用户在操作时Session过期过滤器重定向一个登录页HTML回去前端拿到200状态码但响应内容其实是登录页页面不仅不跳转还会被解析成奇怪的渲染结果。更好的方案是过滤器检测到请求头里有X-Requested-With: XMLHttpRequest并且用户未登录时直接返回401状态码前端收到401后统一跳转登录页。过滤器里处理方式很简单if (user null) { if (XMLHttpRequest.equals(httpReq.getHeader(X-Requested-With))) { httpResp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpResp.setContentType(application/json;charsetUTF-8); httpResp.getWriter().write({\code\:401,\message\:\登录已过期请重新登录\}); } else { httpResp.sendRedirect(httpReq.getContextPath() /login.jsp); } return; }第三是路径归一化。如果你的接口支持末尾斜杠比如请求/user和/user/都能访问那过滤器里的白名单就要考虑这两种形态。比较稳妥的方案是所有路径比对前都用path.replaceAll(/$, )去掉末尾斜杠或者统一加斜杠再比对反正保持一致就行。另外再谈一个容易被忽略的细节Filter中放行的“免登录接口”不能只考虑登录页本身还要考虑注册、找回密码、图形验证码等接口。如果你做的是前后端分离项目还要排除验证码接口、公共配置接口等。我建议把所有不需要登录就能访问的路径整理成一个list集中管理而不是散落在过滤器各个if里维护起来会舒服得多。4. 敏感词过滤案例从简单replace到高性能实现再到请求包装器的力量讲完登录验证接着说敏感词过滤。这个场景和登录验证有很大的不同登录验证是纯拦截动作不通过则阻断敏感词过滤则是加工动作需要对请求内容做检测和替换。它带来了两个新问题一个是性能问题一个是“请求体读一次就没了”的问题。这两个问题处理不好方案就是残废的。4.1 最基础方案循环遍历敏感词表逐个替换很多人第一版写出来的敏感词过滤是这样的String content request.getParameter(content); for (String word : sensitiveWords) { if (content.contains(word)) { content content.replace(word, ***); } }逻辑确实是对的但效果很差。问题出在两个地方如果敏感词表里有几千个词每篇文章都要循环几千次时间复杂度是词表长度乘以文本长度另一个问题是含有单字、多字组合时比如“赌博”和“赌”都算敏感词先替换“赌博”再替换“赌”会产生重复替换或替换后依旧命中其他词的情况。而且这个方案还有个大前提必须通过getParameter先拿到请求内容。对于表单提交类型的请求这确实没问题但如果是application/json类型请求体在输入流里getParameter根本拿不到。真实项目中前端提交的往往是JSON所以这种方案过于理想化。4.2 进阶方案Trie树实现高效敏感词匹配为了处理上万级敏感词表我们不能用普通循环要引入Trie树也叫前缀树或字典树。它能一次性遍历文本在O(n)时间内完成所有敏感词的扫描n是文本长度跟词表大小无关。原理可以简单理解为把每个敏感词按字符路径放进一颗树里比如“赌博”“赌友”共用“赌”这个公共前缀匹配时一次遍历既能看到“赌博”也能看到“赌友”。Trie树的构建代码大概长这样public class SensitiveTrie { private static class TrieNode { private final MapCharacter, TrieNode children new HashMap(); private boolean isEnd false; } private final TrieNode root new TrieNode(); // 构建敏感词树 public void addWord(String word) { TrieNode node root; for (char c : word.toCharArray()) { node node.children.computeIfAbsent(c, k - new TrieNode()); } node.isEnd true; } // 扫描文本返回命中词列表 public ListString match(String text) { ListString result new ArrayList(); int len text.length(); for (int i 0; i len; i) { TrieNode node root; int j i; while (j len node.children.containsKey(text.charAt(j))) { node node.children.get(text.charAt(j)); j; if (node.isEnd) { result.add(text.substring(i, j)); } } } return result; } }原理并不复杂从文本每个位置出发顺着Trie树往下匹配一旦某个节点有isEnd标记就说明匹配到一个敏感词。初始构建一次树之后多次匹配都是纯内存操作性能提升非常明显。这里有一个细节很容易被忽略上面这种匹配只支持敏感词与文本中完全连续匹配。如果用户发的是“赌 博”中间隔了空格、标点或缩写这棵树就匹配不到了。稍微完善的方案是支持跳词匹配也就是遇到非字母数字字符时跳过继续匹配下一个字。实现思路是在match时遇到跳词字符不退出匹配直接跳过继续这会稍微增加实现复杂度但敏感词过滤的覆盖面会大很多。另外如果你处理的文本很长应用内存又有限可以把Trie树的节点存储换成数组或者使用AC自动机Aho-Corasick算法后者可以同时处理多模式匹配效率更高。但大多数业务场景下Trie树已经足够用了别一开始就上重武器。4.3 请求包装器解决JSON请求体的读取难题这是敏感词Filter案例里真正的核心难点。Filter的doFilter中如果直接request.getInputStream()去读请求体那就出大事了。因为Servlet的ServletInputStream只能被读取一次读完之后后面的Controller再去读拿到的是空流参数全没了整个请求就废了。解决方案是使用HttpServletRequestWrapper包装原始请求在包装器里预读并缓存请求体重写getInputStream和getReader方法让后续读取从缓存中获取。这就是装饰器模式在Servlet规范中的经典应用。public class CachedBodyRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public CachedBodyRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 把原始流的全部字节读出来缓存 try (InputStream is request.getInputStream()) { body is.readAllBytes(); } } Override public ServletInputStream getInputStream() { ByteArrayInputStream buffer new ByteArrayInputStream(body); return new ServletInputStream() { Override public int read() { return buffer.read(); } Override public boolean isFinished() { return buffer.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener listener) { // 非异步场景留空即可 } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }这个包装器做了一件事在创建包装类时把请求体的字节全部缓存到内存中。之后Controller拿到的请求体来自内存缓冲而不是原始输入流从而解决了读后失效问题。相应的Filter中要做的事就变成了读原始请求体做内容过滤再把替换后的内容塞进一个全新的CachedBodyRequestWrapper然后将包装后的请求继续往下传public class SensitiveWordFilter implements Filter { private SensitiveTrie trie; Override public void init(FilterConfig filterConfig) { trie new SensitiveTrie(); // 初始化敏感词表从配置文件或数据库读取 loadSensitiveWords(filterConfig.getServletContext()); } Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq (HttpServletRequest) request; if (isJsonRequest(httpReq)) { // 读取原始请求体 String originalBody new String(httpReq.getInputStream().readAllBytes(), StandardCharsets.UTF_8); // 检测和替换敏感词 String newBody filterText(originalBody); // 使用包装器包装新内容继续放行 CachedBodyRequestWrapper wrappedRequest new CachedBodyRequestWrapper(newBody.getBytes(StandardCharsets.UTF_8)); chain.doFilter(wrappedRequest, response); } else { // 非JSON请求走参数形式处理或者直接放行由业务层处理 chain.doFilter(request, response); } } private String filterText(String content) { // 调用Trie树匹配将命中词替换为*** ListString hitList trie.match(content); for (String word : hitList) { content content.replace(word, maskWord(word)); } return content; } private String maskWord(String word) { StringBuilder sb new StringBuilder(); for (int i 0; i word.length(); i) { sb.append(*); } return sb.toString(); } private boolean isJsonRequest(HttpServletRequest req) { String contentType req.getContentType(); return contentType ! null contentType.toLowerCase().contains(application/json); } }这里有几个小点再补充一下。maskWord方法故意让掩码长度跟原词一致比固定替换成三个*更稳妥因为有些场景下系统会统计处理后的文本长度长度变化会造成数据不一致。把“赌博”替换为“”和替换成“*”含义一样但长度不同对下游入库字段长度的影响也不同提前想清楚更省事。缓存请求体的字节数组时有内存开销。如果是超大文件上传就不适合放在Filter里缓存了需要区分场景。所以这里的if (isJsonRequest)判断非常有用它可以筛选掉文件上传类型的请求避免大文件被整个读进内存。4.4 这个案例带出的关键认知Filter可以改写请求不只是阻断请求通过敏感词过滤案例你应该能领悟到一个更深层的认知过滤器除了“拦截”之外还能“加工”。它可以替换请求头、改写请求体、增加参数、修改响应内容甚至可以通过Response包装器对输出内容做统一处理。这意味着它不只是安全层的工具更是一个通用的请求处理管线。打个比方来说Filter层就像一个安检通道外加一个装修队安检通道决定你可以去哪里装修队可以给你的行李换一层包装。登录验证是安检敏感词过滤是装修。实际项目中响应内容压缩、统一添加响应头、CORS跨域配置很多都可以在Filter层完成而不需要侵入业务代码。值得注意的是修改请求体的能力是有代价的它破坏了Servlet流的“一次读取”特性必须在包装器层面做好缓存否则所有依赖流的业务全部出错。这是新手最容易忽略的地方也是这篇文章里最值得反复琢磨的一段内容。5. 多个过滤器协作与执行顺序井井有条的机制前面我们一直在讨论单个Filter。实际项目中一个应用往往会配置多个Filter常见的有字符编码Filter、登录验证Filter、敏感词过滤Filter、日志记录Filter、CORS跨域Filter。多个Filter同时存在时它们的执行顺序是如何确定的这个问题如果搞不清楚轻则功能异常重则出现严重的安全漏洞。5.1 Spring Boot中的过滤器顺序Order注解与配置类先说Spring Boot项目中的典型做法。使用WebFilter注解时过滤器本身是没有顺序概念的它的顺序由容器扫描到的顺序决定而这种顺序在不同环境中并不保证稳定。正确做法是使用FilterRegistrationBean来注册过滤器并显式指定setOrder值Configuration public class FilterConfig { Bean public FilterRegistrationBeanLoginFilter loginFilter() { FilterRegistrationBeanLoginFilter bean new FilterRegistrationBean(); bean.setFilter(new LoginFilter()); bean.addUrlPatterns(/*); bean.setOrder(1); return bean; } Bean public FilterRegistrationBeanSensitiveWordFilter sensitiveWordFilter() { FilterRegistrationBeanSensitiveWordFilter bean new FilterRegistrationBean(); bean.setFilter(new SensitiveWordFilter()); bean.addUrlPatterns(/*); bean.setOrder(2); return bean; } }这里setOrder的值越小优先级越高也就是越先执行。比如上面配置的LoginFilter(Order1)会先于SensitiveWordFilter(Order2)执行。这意味着请求会先做登录校验再做敏感词处理。这样的顺序符合业务直觉一个没登录的用户不需要送到敏感词过滤环节去消耗资源。5.2 过滤器链的执行顺序图解与“责任链模式”多个Filter之间天然是责任链模式的实例。我们想象三个Filter排成一队请求先到Filter AA调用chain.doFilter后请求进入Filter BB调用chain.doFilter后请求进入Filter C最后到达Controller。响应返回时顺序完全反过来Controller处理完毕先回到C再回到B最后回到A。所以如果有需求在响应阶段追加内容比如统一加响应头你写在chain.doFilter后面的代码会在响应阶段执行。这个特性用好了很有价值比如统一记录接口耗时long start System.currentTimeMillis(); chain.doFilter(request, response); long end System.currentTimeMillis(); System.out.println(cost: (end - start) ms);chain.doFilter是同步调用它后面的代码要等整条过滤器链和业务逻辑全部走完才执行所以计时代码放在它后面能拿到完整的耗时数据。5.3 混淆点整理Filter、Interceptor、AOP的执行时机差异很多初学者会把过滤器、拦截器和AOP混为一谈这里顺便理清一下。Servlet Filter在Servlet容器内执行范围最大先于一切框架代码。它不知道你用的是Spring MVC还是纯Servlet也不认识Controller。请求格式和编码问题通常在这一层处理。Spring Interceptor在Spring MVC的HandlerMapping找到对应处理器之后、执行处理器方法之前执行。它认识Handler但还没进到业务方法里。AOP直接在业务方法层面进行拦截通常配合注解做方法切面可以精确控制到某个业务方法甚至某个参数。这三者本质上解决的是三个不同层面上的横切需求Filter管请求流的“进口关”Interceptor管Spring MVC处理流程的“前置后置”AOP管方法调用的“通用逻辑注入”。项目中经常配合使用Filter处理编码和登录白名单Interceptor做精确到URL模式的权限校验AOP做方法级日志和事务处理。顺序也基本固定Filter最先、Interceptor次之、AOP最后进入方法内部。理解这个顺序排查有些接口为什么被拦截了、为什么日志没打出来的问题时思路会清晰得多。6. 常见问题与排查技巧我踩过的坑都在这了下面这些内容是我在项目开发和阅读别人代码时反复遇到的真实问题整理成速查表方便直接对照排查。6.1 过滤器没生效配置了过滤器但访问接口时完全没有触发任何逻辑是最常见也是最好排查的一类问题。可能原因排查方向WebFilter注解但启动类没加ServletComponentScanSpring Boot中扫描不到Servlet组件注解式过滤器不会注册用FilterRegistrationBean但setUrlPatterns写错路径匹配不上比如写/api/而不是/api/*Filter被某个全局异常捕获或吞掉异常过滤器里抛出异常会被容器处理但日志不一定打印先加日志确认是否执行内嵌容器版本或配置不同导致注解未扫描多模块项目里Spring Boot扫描范围外的包不会自动注册组件排查建议先在doFilter入口第一行加一段日志输出确认方法是否真的被调用。如果触发都没有问题一定在注册或映射路径上。6.2 过滤器执行多次过滤器方法被重复执行常见原因有两个。一是多个环境重复注册比如既用了WebFilter又用FilterRegistrationBean注册了同类型过滤器造成两份实例。解决办法是检查配置二选一。二是chain.doFilter之后又继续处理请求比如在doFilter里条件判断不严谨导致同一个请求在过滤器里被递归传递。写代码时每一条路径都必须明确要么return要么chain.doFilter不能两者都不做。另一种情况是网关和Servlet应用同时配置过滤器比如网关层做了一次身份认证应用层又做一次看起来就像过滤器执行了多次其实只是两个层级都生效。要确认链路层级需要看请求整体遍历了哪些组件。6.3 未登录但静态资源被重定向这是登录Filter案例的高频问题打开登录页时页面光秃秃的样式全丢。控制台能看到一堆Failed to load resource: the server responded with a status of 302。原因就是静态资源的请求也被登录校验拦截。解决思路前面已经说过了维护一份静态资源后缀名白名单并在path判断时优先放行。如果项目用了CDN加速那么CDN回源请求也可能被过滤器拦截尤其要注意LICENSE、robots.txt等特殊资源也要放行。6.4 敏感词过滤后请求体持久化被清空这个坑我在4.3节已经重点提示过。原因就是直接读取了原始getInputStream()导致输入流被消费后续Controller拿不到body。解决办法就是使用包装器缓存请求体字节。这里再补一句即便用了包装器也别忘了页面响应的编码与Content-Type保持一致否则替换后的中文在Controller层解析仍然可能是乱码。6.5 页面乱码与编码设置顺序如果在Filter里做字符集编码设置设为UTF-8就要注意它的执行顺序必须早于Controller的参数解析。如果编码Filter执行太晚比如排在登录Filter之后Controller拿到请求参数时可能已经用默认编码解析完你再设置编码就没用了。这个问题的本质和敏感词过滤一样是“读一次之后不可逆”。因此编码Filter通常要设置成第一个执行的过滤器Order值最小。6.6 请求包装器导致接口响应异常在调试敏感词过滤器时如果接口收到请求体为空的报错不要第一时间怀疑包装器写错了。先检查CachedBodyRequestWrapper里是不是用request.getReader()而不是getInputStream()读取的。有的Servlet容器里getReader()会锁住输入流的状态之后再getInputStream()可能拿不到完整数据。稳妥起见包装器里统一用getInputStream()读取。提示如果你需要支持getReader()和getInputStream()两种读取方式务必让它们都指向同一个缓存的ByteArrayInputStream不要二次包装时又去读一次原始流。6.7 URL匹配规则细节精确、前缀、后缀最后补一点Filter的映射配置规则。路径匹配支持三种形式精确匹配/user/login只有该路径触发。目录匹配/user/*/user下所有路径触发。扩展名匹配*.do所有以.do结尾的路径触发。/*代表匹配所有路径但要注意它与/的语义是不同的前者匹配所有后者匹配根路径。配置过滤器拦截范围时一定要想清楚你需不需要把静态资源、Swagger文档、监控端点也拦截掉。很多安全测试中暴露出来的未授权访问都源于这一层的粗心。7. 整个案例的工程化建议项目里应该怎么组织过滤器如果把你自己的项目当成一个持续演进的产品过滤器模块的代码组织也需要有章法这里分享几条我的工程实践。第一个建议是一个过滤器只做一件事。不要在登录Filter里顺手做敏感词替换也不要在敏感词Filter里顺手做日志打印。单一职责原则在过滤器这层同样适用否则后面加需求、排查问题时你会疯掉的。多个需求就是多个过滤器通过Order串成链逻辑清晰不会相互污染。第二个建议是白名单和敏感词库集中管理。不要把免登录路径分散写在过滤器代码里建议维护一个独立的配置类或配置表。敏感词库也一样不要硬编码在代码里建议系统启动时从配置文件、数据库或远程配置中心加载并做好热更新的接口。很多项目因为敏感词表更新不及时导致新词漏过滤用户投诉频繁发生这种问题不该出现在代码层。第三个建议是记录过滤器执行日志。每个过滤器至少要在本地日志中记录一个请求标识、处理动作、处理结果。过滤器执行中报错时特别是包装器场景日志里要能看到当时请求体的长度和简要内容这对线上问题排查的帮助极大。但注意不能把完整body都打出来涉及用户隐私和敏感信息日志中要脱敏。第四个建议是关注性能损耗。Filter链越长每个请求的开销就越大。如果登录验证依赖于数据库查询就必须加缓存如果敏感词匹配文本量很大就要评估内存与CPU代价。实际项目里曾经因为敏感词过滤处理每一篇长文章都要几百毫秒导致接口性能严重下降最后靠限制处理文本长度和引入并行处理才解决。过滤器虽好但不能变成性能瓶颈。8. 写在最后的几句真心话回头看看这个案例。从Filter的基础原理到登录验证的完善细节到敏感词过滤的包装器技巧再到多Filter协作和问题排查这条线串起来基本能把Servlet里的过滤器机制理解得比较完整了。我个人在实际开发中的体会是过滤器这个知识点往往是在出了问题的时候才真正显露威力。平时写CRUD的时候它很不起眼一旦遇到登录失效、请求体读不到、静态资源被拦截、Filter执行了两次这些问题能不能快速定位直接反映出你对这套链机制理解的深浅。建议有时间的同学自己动手跑一遍这个案例哪怕只是最基础版的登录验证和最简单的敏感词替换。只要你把包装器那一段真正调通理解输入流只能读一次的约束以后再碰到Shiro、Spring Security那些复杂的过滤器链设计你就能一眼看穿它是怎么实现的。最后再分享一个小技巧调试Filter时在浏览器开发者工具里看Network面板中的请求与响应状态码往往比看Log还直观。302、401、200每一种状态都对应着过滤器中的哪段逻辑心里有数后你会觉得Filter链既清晰又可预测。这种掌控感就是这几年做Web后台开发里最让人踏实的东西之一。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑