设计模式修炼之路 ㉛ 拦截过滤器模式——一个过滤器,只管一件事
一个过滤器一个横切关注点、return 走不出的审计盲区、契约两句话放行就 next、后置代码包住整条链、顺序即范围、与责任链对账
目录
J2EE 模式第六站。
㉚ 结尾埋了个雷:三件套会长大成三十件套。本篇就让它长——横切需求从三件涨到五件:编码、审计(还得"开始/结束"各记一笔)、访问日志、登录校验、限流(只挂 /api 路径)。全塞进门神的 handle() 里会怎样?
一个方法,六年需求,年年往里塞。把横切关注点拆成一个个独立的过滤器,串成链,让请求依次穿过——拦截过滤器模式,J2EE 八式里的安检门。
一、拦截过滤器模式是什么
J2EE 蓝皮书的定义大意:拦截过滤器(Intercepting Filter)在请求到达目标处理器之前和之后,通过一串可插拔的过滤器对请求做检查与加工;过滤器彼此独立,由过滤器链负责串联,新增、删除、调整横切逻辑都不必动处理器一根毛。
四个角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| Filter(过滤器) | 一个横切关注点一个类;放行还是拦截自己说了算 | Encoding / Audit / Logging / Authentication / RateLimitFilter |
| Filter Chain(过滤器链) | 串过滤器 + 管"传给下一个";链尾是目标 | FilterChain |
| Target(目标资源) | 链尾真正的业务处理 | Router + OrderController / UserController |
| Filter Manager(过滤器管理器) | 装配链、定顺序 | 本文并入测试类的装配代码 |
一句话理解分工:过滤器说"我这关过不过",链说"下一关是谁",目标说"都过了再来找我"。
它同样不是 GoF 二十三式,是 J2EE 蓝皮书成员——和 ㉚ 是亲兄弟:前端控制器是门(全应用唯一入口),拦截过滤器是门上的安检流程(可插拔的关卡序列)。㉚ 解决"请求从哪儿进",㉛ 解决"进门路上过哪几道检查"——现实框架里两个几乎总是同框。
二、优点与缺点
优点:
- 一个过滤器一个横切关注点:新增限流 = 写一个 RateLimitFilter + 装配处加一行,谁也不碰谁——三件套长成三十件套也不怕,每个需求都有了自己的形状。
- 可排序:链就是 List,顺序即声明顺序——编码必须最前?放第一个。想审计包住一切?也放第一个(细节三见)。
- 前置/后置天然表达:chain.next 之前的代码在"去程"执行,之后的代码在"回程"执行——审计的"结束"不再依赖请求一路走运,这是胖门神结构上做不到的。
- 可挑路径:限流只想挂 /api?过滤器自己看 path 决定干不干活,不用任何人配合,没有一个 if 长在别人家。
缺点:
- 顺序没有编译器保护:把授权排在认证前面,授权拿到的是没认证的请求——链的正确性全靠装配的人想明白,排错了就是安全事故,编译器一声不吭。
- 不调 next = 静默拦截:新人写过滤器忘了 chain.next,全站请求无声消失在某一关——契约简单到没有任何东西监督它。
- 链长了就是迷宫:十几二十个过滤器,"这个请求到底过了哪几关"变成考古题,只能靠日志倒推。
三、什么时候用
判断标准:横切关注点不止三两个、各有顺序讲究、还要挑路径——塞进一个方法里已经排不开队了。
- 典型场景:Web 请求处理——编码、认证、审计、限流,条条都要、顺序敏感、各管各的地盘
- 框架基建:Spring Security 整个安全体系就是一条过滤器链(第五节见)
- 反面对照:只有一两个横切关注点且顺序无讲究——直接写在前端控制器里更直观。三件套时代用 ㉚ 写死就挺好,模式是横切关注点长大以后才请出来的——为一个编码设置立一条链,是杀鸡动了机场安检
四、实现方式:安检门怎么搭
场景承接 ㉚:同一个迷你应用,横切需求长到五件——编码、审计(开始/结束各记一笔)、访问日志、登录校验、限流(只挂 /api)。看两套世界。
4.1 反面教材:门神被六年需求喂胖
每年来一个新需求,每个都往 handle() 里塞一段:
/** 所有需求都往这一个方法里塞 */
public void handle(Request request) {
// 第 1 年上线时:编码
System.out.println(" [胖门神] 设置编码 UTF-8");
// 第 2 年新需求:审计要"开始/结束"各记一笔
System.out.println(" [胖门神] 审计 >>> " + request.displayUser() + " -> " + request.getPath());
// 第 3 年新需求:登录检查
if (!request.isLoggedIn()) {
System.out.println(" [胖门神] 未登录,拒绝:401 请先登录");
return;
}
// 第 4 年新需求:访问日志
System.out.println(" [胖门神] 访问日志:" + request.displayUser() + " -> " + request.getPath());
// 第 5 年新需求:API 限流——只挂 /api 路径
if (request.getPath().startsWith("/api")) {
System.out.println(" [胖门神] " + request.getPath() + " 是 API:限流检查(每秒 100 次)");
}
// 分发本职,已经被挤到方法尾部
Controller controller = routes.get(request.getPath());
if (controller == null) {
System.out.println(" [胖门神] 404:没有这个页面 " + request.getPath());
return;
}
controller.handle(request);
// 审计的"结束"记在这里——只有全程顺利的请求才配拥有
System.out.println(" [胖门神] 审计 <<< 请求处理结束");
}
运行输出:
========== 反面教材:门神被需求喂胖 ==========
-- 请求 /api/order/list(u001 已登录,全程顺利) --
[胖门神] 设置编码 UTF-8
[胖门神] 审计 >>> u001 -> /api/order/list
[胖门神] 访问日志:u001 -> /api/order/list
[胖门神] /api/order/list 是 API:限流检查(每秒 100 次)
[OrderController] 业务:拉取订单列表
[胖门神] 审计 <<< 请求处理结束
-- 请求 /api/order/list(匿名用户,401 拒绝) --
[胖门神] 设置编码 UTF-8
[胖门神] 审计 >>> 匿名 -> /api/order/list
[胖门神] 未登录,拒绝:401 请先登录
审计只有 >>> 没有 <<<:return 一走,后置代码全部跳过
-- 请求 /no/such/page(u001,404) --
[胖门神] 设置编码 UTF-8
[胖门神] 审计 >>> u001 -> /no/such/page
[胖门神] 访问日志:u001 -> /no/such/page
[胖门神] 404:没有这个页面 /no/such/page
又丢一笔:每多一个 return,审计盲区就大一块
三个病灶全齐:return 走不出审计盲区——审计的"结束"写在方法尾部,可方法里每个横切关注点都有提前 return 的权利:401 一个 return、404 一个 return,每多一个 return,盲区就大一块;想补救就在每个 return 前补一行审计,可 return 还会越加越多,每个都是新的漏点。挑路径靠 if 越积越多——限流只挂 /api 是一个 if,哪天"静态资源免检"又是一个 if,每个需求留下自己的形状,handle() 长成一碗面条。需求没有形状——六年的需求挤在一个方法里,靠注释分块、靠人脑维持秩序。注意:每一关都干了活、功能一切正常——反面教材从来不是功能错误,是结构错误。
病根一句话:顺序型收尾("结束"写在最后,人人可以跳过它)和短路型控制流(不过关就走人)在同一个方法里天生打架。
4.2 正解:拆成过滤器,串成链
过滤器接口,契约只有两句话:
public interface Filter {
void doFilter(Request request, FilterChain chain);
}
放行就调 chain.next,拦截就不调——链的一切行为都从这两句推出。
链管"传给下一个",游标走到头就是目标:
public void next(Request request) {
if (index < filters.size()) {
filters.get(index++).doFilter(request, this);
} else {
endpoint.accept(request); // 链尾:分发器(Router)兜 404
}
}
每个过滤器只管一件事——登录校验,拦截就是简简单单不调 next:
public class AuthenticationFilter implements Filter {
@Override
public void doFilter(Request request, FilterChain chain) {
if (!request.isLoggedIn()) {
System.out.println(" [登录过滤器] 未登录,拦截:401 请先登录");
return;
}
System.out.println(" [登录过滤器] " + request.getUserId() + " 已登录,放行");
chain.next(request);
}
}
审计过滤器——后置代码包住整条链的关键:
public class AuditFilter implements Filter {
@Override
public void doFilter(Request request, FilterChain chain) {
System.out.println(" [审计过滤器] >>> 收到 " + request.displayUser() + " -> " + request.getPath());
chain.next(request);
System.out.println(" [审计过滤器] <<< 请求处理结束");
}
}
装配——顺序即声明顺序,想调就调 List:
Router router = new Router()
.add("/api/order/list", new OrderController())
.add("/user/profile", new UserController());
FilterChain chain = new FilterChain(
List.of(new EncodingFilter(),
new AuditFilter(),
new LoggingFilter(),
new AuthenticationFilter(),
new RateLimitFilter()),
router::route);
运行输出:
========== 正解:横切关注点拆成过滤器串成链 ==========
-- 请求 /api/order/list(u001 已登录,走完全链) --
[编码过滤器] 统一设置编码 UTF-8
[审计过滤器] >>> 收到 u001 -> /api/order/list
[日志过滤器] 访问日志:u001 -> /api/order/list
[登录过滤器] u001 已登录,放行
[限流过滤器] /api/order/list 是 API:限流检查(每秒 100 次)
[分发器] /api/order/list -> OrderController
[OrderController] 业务:拉取订单列表
[审计过滤器] <<< 请求处理结束
-- 请求 /api/order/list(匿名用户,被登录过滤器拦截) --
[编码过滤器] 统一设置编码 UTF-8
[审计过滤器] >>> 收到 匿名 -> /api/order/list
[日志过滤器] 访问日志:匿名 -> /api/order/list
[登录过滤器] 未登录,拦截:401 请先登录
[审计过滤器] <<< 请求处理结束
拦截归拦截,审计 <<< 照样记——后置代码包住了后面的整条链
-- 请求 /no/such/page(u001,链尾 404) --
[编码过滤器] 统一设置编码 UTF-8
[审计过滤器] >>> 收到 u001 -> /no/such/page
[日志过滤器] 访问日志:u001 -> /no/such/page
[登录过滤器] u001 已登录,放行
[分发器] 404:没有这个页面 /no/such/page
[审计过滤器] <<< 请求处理结束
404 也逃不掉审计 <<<
-- 请求 /user/profile(u001,非 API 路径) --
[编码过滤器] 统一设置编码 UTF-8
[审计过滤器] >>> 收到 u001 -> /user/profile
[日志过滤器] 访问日志:u001 -> /user/profile
[登录过滤器] u001 已登录,放行
[分发器] /user/profile -> UserController
[UserController] 业务:返回个人资料
[审计过滤器] <<< 请求处理结束
限流过滤器只认 /api:不是自己的地盘,直接放行
四处对照读出含金量:匿名请求被登录过滤器拦下,审计的 <<< 照样记——反面教材里 return 走不出的盲区,在链上结构上就不存在;404 发生在链尾,同样逃不掉审计——后置代码包住的是"后面的整条链",链上发生什么都拦不住它执行;/user/profile 全程没出现限流的影子——RateLimitFilter 自己看路径,不是自己的地盘直接放行;㉚ 门神身上的横切关注点被整链收走后,剩下的本职瘦成了链尾的分发器——门与安检流程,就此分家。
4.3 三处细节
细节一:契约只有两句话。 放行就调 chain.next,拦截就不调。所有行为从这两句推出:不调 next,请求到此为止,后面的过滤器和目标全不执行;next 之后的代码要等回程才执行。这套契约没有任何东西监督(缺点二),写过滤器第一件事是把它焊在脑子里。
细节二:后置代码包住的是"后面的整条链"。 next 之前的代码在去程执行,之后的代码在回程执行——AuditFilter 的 >>> 和 <<< 像一双筷子,夹住了它后面的一切:后面的过滤器、拦截、404、目标业务,全在筷子中间,一个都溜不掉。胖门神的病根是顺序型收尾(写在方法尾,谁 return 都能跳过它),链的解法是对称包裹(写在 next 两侧,回程必经)——本质是 try/finally 的亲戚,但表达力强得多:链上每一层都能这么包,一层套一层,洋葱圈一样。
细节三:顺序即范围。 过滤器越靠前,它包住的范围越大:AuditFilter 放第一个,连编码都在它筷子里;放最后,只包住目标。"编码必须最前""权限必须在认证后"翻译成链语言就是:用声明顺序表达依赖关系。这也是缺点一的来源——顺序即语义,语义错了编译器不吭声,装配的人必须想明白。
五、在框架里找它
-
Servlet Filter:javax.servlet.Filter 的
doFilter(request, response, chain)+ FilterChain——本文 Filter 接口的亲爹,连"不调 chain.doFilter 就是拦截"这条契约都一字不差;@WebFilter 的 urlPatterns 就是"挑路径"。你跑的每个 Web 应用,最底下都趴着这条链。 -
Spring MVC 的 HandlerInterceptor:preHandle / postHandle / afterCompletion 三段式——本文"去程/回程"的工业化版本:preHandle 返回 false 相当于不调 next,afterCompletion 连异常场景都兜住,比本文的裸回程更周全。
-
Spring Security:一整个框架就是一条 SecurityFilterChain——UsernamePasswordAuthenticationFilter 认证、授权过滤器放后面,十几个过滤器各就各位,顺序就是安全语义本身。想自定义登录?往链上插一个过滤器,别人的一个都不用动——"可插拔"三个字的工业级兑现。
-
Express / Koa 的 middleware:JavaScript 世界同款——app.use(fn) 挂上去,fn 里调 next() 放行;Django 中间件、Gin 的 handler 链,全球框架抄的是同一份作业。
-
Netty 的 ChannelPipeline:入站/出站事件在 handler 链上双向穿过,编码器、解码器、业务 handler 排队站——过滤器链思想搬到了网络层,一条 pipeline 就是安检门的传送带版。
六、两条链对账——⑰ 责任链 vs ㉛ 拦截过滤器
⑰ 讲责任链时留过一个口子:第三节第 4 条场景"请求预处理管线"。两条链长得实在太像——都是"传给下一个",都靠节点自觉。摊开对账:
| 责任链(⑰) | 拦截过滤器(㉛) | |
|---|---|---|
| 链上的人 | 候选处理者:谁有权限谁签 | 关卡:每个都要过一遍 |
| 谁终结链 | 处理者命中即终结(组长签了就不劳经理) | 拦截者,或链尾目标 |
| 链尾 | 开放的——可能没人处理(⑰ 缺点一:200 万报销单落空) | 一定有目标等着(Router 兜 404) |
| 链的语义 | 这个请求**谁来处理** | 这个请求**怎么穿过** |
| 典型 | 报销审批、异常处理 catch | 编码、认证、审计、限流 |
一句话:责任链是"谁认领这个活",拦截过滤器是"这个活要过哪几道关"。Java 的异常处理是责任链——catch 命中一个就结束,后面再合适也不看;Servlet Filter 是拦截过滤器——每个请求所有过滤器依次穿过,谁都不能跳过。顺带一笔:⑩ 装饰器也"包住",但装饰器包的是能力(同一个接口的增强),过滤器包的是请求(横切关注点的加工)——包的东西不同,人生就不同。
写在最后
- 拦截过滤器 = 横切关注点一人一格排成链:可插拔、可排序、可挑路径
- 契约只有两句话:放行就 chain.next,拦截就不调——链的一切行为由此推出
- 后置代码写在 next 两侧,包住后面的整条链——return 走不出的审计盲区,链上结构上不存在
- 顺序即范围:越靠前包得越宽;用声明顺序表达依赖,但顺序没有编译器保护
- 当代转世:Servlet Filter、HandlerInterceptor、Spring Security、Express middleware、Netty Pipeline——安检门全球连锁
- 对账:⑰ 责任链挑人(谁认领),㉛ 拦截过滤器过卡(怎么穿过);⑩ 装饰器包能力,㉛ 包请求
过滤器链把横切关注点安排得明明白白——可凑近看装配代码:五个过滤器、两个控制器,全是 new 出来摆好的。真实应用何止这几个角色:DAO、缓存、消息队列、支付网关……谁需要什么服务,要么构造函数一路传到底,要么满地 new 各造一份。有没有一个"服务注册处",服务登记一次、全应用按名取用?——服务定位器模式,下一篇见。