设计模式预计阅读 19 分钟7452 字1038 次浏览

设计模式修炼之路 ㉛ 拦截过滤器模式——一个过滤器,只管一件事

一个过滤器一个横切关注点、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 蓝皮书成员——和 ㉚ 是亲兄弟:前端控制器是门(全应用唯一入口),拦截过滤器是门上的安检流程(可插拔的关卡序列)。㉚ 解决"请求从哪儿进",㉛ 解决"进门路上过哪几道检查"——现实框架里两个几乎总是同框。

二、优点与缺点

优点:

  1. 一个过滤器一个横切关注点:新增限流 = 写一个 RateLimitFilter + 装配处加一行,谁也不碰谁——三件套长成三十件套也不怕,每个需求都有了自己的形状。
  2. 可排序:链就是 List,顺序即声明顺序——编码必须最前?放第一个。想审计包住一切?也放第一个(细节三见)。
  3. 前置/后置天然表达:chain.next 之前的代码在"去程"执行,之后的代码在"回程"执行——审计的"结束"不再依赖请求一路走运,这是胖门神结构上做不到的。
  4. 可挑路径:限流只想挂 /api?过滤器自己看 path 决定干不干活,不用任何人配合,没有一个 if 长在别人家。

缺点:

  1. 顺序没有编译器保护:把授权排在认证前面,授权拿到的是没认证的请求——链的正确性全靠装配的人想明白,排错了就是安全事故,编译器一声不吭。
  2. 不调 next = 静默拦截:新人写过滤器忘了 chain.next,全站请求无声消失在某一关——契约简单到没有任何东西监督它。
  3. 链长了就是迷宫:十几二十个过滤器,"这个请求到底过了哪几关"变成考古题,只能靠日志倒推。

三、什么时候用

判断标准:横切关注点不止三两个、各有顺序讲究、还要挑路径——塞进一个方法里已经排不开队了。

  • 典型场景: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 放第一个,连编码都在它筷子里;放最后,只包住目标。"编码必须最前""权限必须在认证后"翻译成链语言就是:用声明顺序表达依赖关系。这也是缺点一的来源——顺序即语义,语义错了编译器不吭声,装配的人必须想明白。

五、在框架里找它

  1. Servlet Filter:javax.servlet.Filter 的 doFilter(request, response, chain) + FilterChain——本文 Filter 接口的亲爹,连"不调 chain.doFilter 就是拦截"这条契约都一字不差;@WebFilter 的 urlPatterns 就是"挑路径"。你跑的每个 Web 应用,最底下都趴着这条链。

  2. Spring MVC 的 HandlerInterceptor:preHandle / postHandle / afterCompletion 三段式——本文"去程/回程"的工业化版本:preHandle 返回 false 相当于不调 next,afterCompletion 连异常场景都兜住,比本文的裸回程更周全。

  3. Spring Security:一整个框架就是一条 SecurityFilterChain——UsernamePasswordAuthenticationFilter 认证、授权过滤器放后面,十几个过滤器各就各位,顺序就是安全语义本身。想自定义登录?往链上插一个过滤器,别人的一个都不用动——"可插拔"三个字的工业级兑现。

  4. Express / Koa 的 middleware:JavaScript 世界同款——app.use(fn) 挂上去,fn 里调 next() 放行;Django 中间件、Gin 的 handler 链,全球框架抄的是同一份作业。

  5. 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 各造一份。有没有一个"服务注册处",服务登记一次、全应用按名取用?——服务定位器模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论