设计模式预计阅读 15 分钟5825 字2755 次浏览

设计模式修炼之路 ㉚ 前端控制器模式——所有请求,先过我这道门

横切关注点写一次全站无死角、抄漏一处校验就是一个洞、门神必须薄、路由分散声明集中执行、与 MVC 的 C 对账

目录

J2EE 模式第五站。

㉙ 的 DAO 把"怎么存"收走了,业务层终于干净。可 Web 应用的麻烦不止在存储——请求进门的第一个问题是:谁接的活?

Servlet 时代的默认答案:一个 URL 一个 Servlet,各自为政。于是"进门三件套"——设置编码、登录校验、访问日志——只能靠复制粘贴在每个 Servlet 里传播:老员工抄得齐,新来的不知道有模板,抄漏一处校验,就是一个安全洞。想要"全局加个限流"?挨个文件改一遍,改漏一个就白干。

用一个控制器收口所有请求:进门的事集中办,业务的事分发办——前端控制器模式,J2EE 八式里的门神。

一、前端控制器模式是什么

J2EE 蓝皮书的定义大意:前端控制器(Front Controller)为 Web 应用提供一个集中的请求处理入口,所有请求先到这里,统一的认证、授权、编码、日志在此完成,再由它分发给具体的处理器。

四个角色:

角色 干什么 本文对应
Front Controller(前端控制器) 全应用唯一入口:横切关注点 + 分发 FrontController
Dispatcher(分发器) 维护 URL 到处理器的映射,负责路由 本文并入 FrontController 的 routes
Handler / Controller(业务控制器) 一个 URL 一家,只管业务 OrderController / UserController
View(视图) 渲染响应 本文 println 代劳

一句话理解分工:前端控制器说"所有请求先过我这道门",业务控制器说"这个请求的业务我管"。

它同样不是 GoF 二十三式,是 J2EE 蓝皮书成员——诞生动机就是上面那个复制粘贴惨案:横切关注点(所有请求都要做、且做法一样的事)散落在 N 个 Servlet 里,纪律靠自觉,漏洞靠运气。

二、优点与缺点

优点:

  1. 横切关注点写一次,全站无死角:编码、认证、日志、限流——写在门神身上,任何 URL 都逃不掉,新同事想漏都漏不了。
  2. 集中分发,路由一目了然:URL 到哪个处理器,一张路由表看全——"这个接口谁在管"从考古问题变成查表问题。
  3. 统一错误处理:404、401、500 在一个地方兜底,不会有的页面报错白屏、有的页面报错误码。
  4. 安全审计有唯一卡口:想拦截所有请求做审计、限流、灰度——只需在门口加,不用满楼跑。

缺点:

  1. 唯一入口 = 唯一故障点:门神挂了全站挂——所以现实里它必须薄(只做进门三件事加分发),重活不许干。
  2. 所有请求串一遍相同检查:静态资源请求也走登录校验,部分检查对部分路径是浪费——解药是 ㉛ 按路径配过滤器。
  3. 路由表会膨胀:大应用几百条路由堆在一个类里——现实框架用注解把路由拆回各个控制器(第五节见)。

三、什么时候用

判断标准:有多条入口 URL + 存在对所有请求一视同仁的横切关注点——就用前端控制器,这几乎是 Web 应用的出厂设置。

  • 典型场景:Web 应用、HTTP API——认证、日志、编码、限流,哪个应用躲得开?
  • 微服务 API 网关:跨服务版前端控制器——认证、限流、路由,在集群门口统一办
  • 反面对照:桌面应用、单文件脚本——没有"请求进门"这回事,硬套是给自己立门神;纯内部工具只有一个入口时,前端控制器退化成一个 main 方法,模式已经在了,只是不值得命名

四、实现方式:谁能看个人资料

场景:一个迷你 Web 应用,两个页面——订单列表 /order/list、个人资料 /user/profile。进门三件套:编码、日志、登录校验。看两套世界。

4.1 反面教材:每个 Servlet 各自为政

老员工写的 OrderServlet,三件套抄得整整齐齐:

public class BadOrderServlet {

    public void service(Request request) {
        System.out.println("   [OrderServlet] 设置编码 UTF-8");
        System.out.println("   [OrderServlet] 检查登录:" + request.getUserId() + " 已登录");
        System.out.println("   [OrderServlet] 记访问日志:" + request.getUserId() + " -> " + request.getPath());
        System.out.println("   [OrderServlet] 业务:拉取订单列表");
    }
}

新同事写的 UserServlet——不知道有模板,只写了业务,编译照样过,功能照样跑:

public class BadUserServlet {

    public void service(Request request) {
        System.out.println("   [UserServlet] 业务:拉取个人资料");
    }
}

运行输出:

========== 反面教材:每个 Servlet 各自为政 ==========
-- 请求 /order/list(u001 已登录,OrderServlet 接活) --
   [OrderServlet] 设置编码 UTF-8
   [OrderServlet] 检查登录:u001 已登录
   [OrderServlet] 记访问日志:u001 -> /order/list
   [OrderServlet] 业务:拉取订单列表

-- 请求 /user/profile(u001 已登录,UserServlet 接活) --
   [UserServlet] 业务:拉取个人资料
   UserServlet 少了三件套——三件套是靠复制粘贴传播的,抄没抄全靠自觉

-- 请求 /user/profile(匿名用户,UserServlet 接活) --
   [UserServlet] 业务:拉取个人资料
   安全事故:未登录直接拉到个人资料——抄漏一处校验,就是一个洞

三个病灶全齐:纪律靠自觉(三件套的正确性不靠结构保证,靠每个写代码的人记得抄);抄漏一处就是一个洞(UserServlet 没拦住匿名请求——复制粘贴传播的校验,传播不到的地方就是裸奔);全局改动 N 处(哪天校验规则要改、要加限流,挨个 Servlet 翻,改漏一个就前功尽弃)。注意 OrderServlet 本身没写错——反面教材从来不是谁的错,是结构把正确的希望寄托在自觉上。

4.2 正解:前端控制器

业务控制器干干净净,只有业务:

public class UserController implements Controller {

    @Override
    public void handle(Request request) {
        System.out.println("   [UserController] 业务:拉取个人资料");
    }
}

门神收口一切——三件套写一次,路由一张表:

public class FrontController {

    private final Map<String, Controller> routes = new HashMap<>();

    public FrontController() {
        routes.put("/order/list", new OrderController());
        routes.put("/user/profile", new UserController());
    }

    /** 所有请求都从这里进 */
    public void handle(Request request) {
        System.out.println("   [前端控制器] 统一设置编码 UTF-8");
        System.out.println("   [前端控制器] 统一访问日志:" + request.displayUser() + " -> " + request.getPath());

        if (!request.isLoggedIn()) {
            System.out.println("   [前端控制器] 统一登录检查:未登录,拒绝:401 请先登录");
            return;
        }
        System.out.println("   [前端控制器] 统一登录检查:" + request.getUserId() + " 已登录");

        Controller controller = routes.get(request.getPath());
        if (controller == null) {
            System.out.println("   [前端控制器] 404:没有这个页面 " + request.getPath());
            return;
        }
        controller.handle(request);
    }
}

运行输出:

========== 正解:前端控制器统一入口 ==========
-- 请求 /order/list(u001 已登录) --
   [前端控制器] 统一设置编码 UTF-8
   [前端控制器] 统一访问日志:u001 -> /order/list
   [前端控制器] 统一登录检查:u001 已登录
   [OrderController] 业务:拉取订单列表

-- 请求 /user/profile(u001 已登录) --
   [前端控制器] 统一设置编码 UTF-8
   [前端控制器] 统一访问日志:u001 -> /user/profile
   [前端控制器] 统一登录检查:u001 已登录
   [UserController] 业务:拉取个人资料

-- 请求 /user/profile(匿名用户再试) --
   [前端控制器] 统一设置编码 UTF-8
   [前端控制器] 统一访问日志:匿名 -> /user/profile
   [前端控制器] 统一登录检查:未登录,拒绝:401 请先登录

-- 请求 /no/such/page(乱敲的 URL) --
   [前端控制器] 统一设置编码 UTF-8
   [前端控制器] 统一访问日志:u001 -> /no/such/page
   [前端控制器] 统一登录检查:u001 已登录
   [前端控制器] 404:没有这个页面 /no/such/page

三处对照读出含金量:UserController 里一行校验都没有,但匿名请求进不来——正确性从"每个人记得抄"变成"结构保证";被拒绝的请求也先记了日志(匿名 -> /user/profile)——审计无死角,攻击尝试留痕;乱敲的 URL 统一 404——错误处理有了兜底的地方。反面教材那个安全洞,在这里结构上就不可能存在。

4.3 三处细节

细节一:门神必须薄。 前端控制器只干两件事:进门三件套 + 分发。业务逻辑一旦写进门神,它就从"全站唯一入口"堕落成"全站唯一上帝类"——缺点一的故障点风险直接翻倍。判断标准:门神里的代码应该对所有请求成立,"只对订单成立"的代码立刻请去 OrderController。

细节二:分发的形态会进化。 本文路由表是构造器里硬编码的 Map.put——够用,但加页面要改门神。下一步进化是命令模式(⑱):请求参数直接映射处理器类;再进一步是注解扫描:Spring 的 @RequestMapping("/user/profile") 把路由声明放回各个控制器,框架启动时自动汇总成路由表——路由分散声明、集中执行,膨胀问题就这么解的。

细节三:三件套会长大成三十件套。 编码、认证、日志只是开胃菜:限流、审计、灰度、租户识别、链路追踪……全往 handle() 方法里塞,门神眼看撑爆。而且它们各有各的讲究:编码必须最前、权限校验得在认证之后、限流只想挂部分路径、顺序还得可配置。横切关注点"可插拔、可排序"的需求,就是 ㉛ 拦截过滤器模式的出生证明——下一篇见分晓。

五、在框架里找它

  1. Spring MVC 的 DispatcherServlet:名字都写在脸上了——Front Dispatcher Servlet。所有请求先进它,HandlerMapping 是路由表,@Controller 是业务控制器,@RequestMapping 是路由声明。Interceptor(拦截器)则是它给横切关注点留的插槽。本文 FrontController 的工业化完整版。

  2. Struts 的 ActionServlet:老一代答案——ActionServlet 收口一切,按配置把请求分发给各个 Action。前端控制器模式教会了整整一代 Java 框架"怎么当门神"。

  3. Django / Flask / FastAPI:Python 世界同款思想——WSGI/ASGI 统一入口 + URL 路由表分发到视图函数;中间件(middleware)承担三件套职责。框架换了语言,门神的岗位职责全球统一。

  4. API 网关(Kong、Spring Cloud Gateway):微服务时代的前端控制器——几十个服务前面立一扇大门,认证、限流、灰度、路由到下游服务,全都门口办。从"一个应用的门神"放大到"一个集群的门神",模式没变,编制升了。

六、两个"控制器"对账——㉖ MVC 的 C vs ㉚ 前端控制器

㉖ 讲 MVC 时留过一个尾巴:MVC 的 Controller 和前端控制器是什么关系?摊开对账:

MVC 的 Controller(㉖) Front Controller(㉚)
数量 一个用例一个:LoginController、OrderController 全应用唯一
管什么 这个请求的**业务编排**:调模型、选视图 所有请求的**共同前奏**:认证、日志、编码,加分发
谁写 你写 框架写(DispatcherServlet、ActionServlet)
视角 业务视角 基建视角
一句话 接到活以后怎么干 活怎么统一地接到

Spring MVC 里两者同框:DispatcherServlet 是前端控制器,@Controller 是 MVC 的 C——你的每个 @Controller 方法干的是 ㉖ 的活,而把它们串起来的那扇门是 ㉚。所以别再问"MVC 的 C 是不是前端控制器"——狭义 MVC 只说"控制器接收输入、调用模型";落到 Web 上,这活被拆成两半:门口的统一前奏(㉚,框架的)和屋里的一单一办(㉖,你的)。

写在最后

  • 前端控制器 = 全应用唯一入口:横切关注点写一次,分发路由一张表
  • 疗效:校验无死角、审计有卡口、错误有兜底——正确性从靠自觉变成靠结构
  • 铁律:门神必须薄——对所有请求成立的代码才配进门神
  • 分发的进化:硬编码路由表 → 命令映射 → 注解扫描,路由分散声明、集中执行
  • 当代转世:DispatcherServlet、Django 中间件、API 网关——门神从应用门口站到集群门口
  • 对账:㉖ 的 C 是"接到活以后怎么干",㉚ 是"活怎么统一地接到"——Spring MVC 里两者同框

前端控制器把进门三件套写死在 handle() 里,顺序固定、全体适用。可现实很快翻脸:编码必须在最前、权限校验得排在认证后、限流只想挂 API 路径、静态资源什么检查都不想要——横切关注点越长越多,还各有各的顺序和地盘,门神的 handle() 方法眼看撑爆。谁让这些横切逻辑"可插拔、可排序、可挑路径"?——拦截过滤器模式,下一篇见。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论