来源: https://time.geekbang.org/dailylesson/detail/100075733 在面向请求 / 响应式的系统中,我们经常会在多个请求之间实现一些集中而通用处理的需求,比如要对每个请求检查数据编码方式、记录日志信息、压缩输出等。要满足这些需求,就要在请求响应代码的主流程中嵌入一些定制化组件。 所以,从架构设计上讲,为了容易添加或删除定制化组件,而不干扰主流程,我们需要在定制化组件与请求处理主流程之间实现松耦合; 同时,从提升它们的可重用性上讲,我们也需要确保每个组件都能自主存在,而且组件之间能相互独立。如下图所示:

管道 - 过滤器模式 - 图1

如何实现这样的效果呢?幸好,在架构设计领域中存在一种类似的模式,就是管道 - 过滤器模式 接下来我具体讲一讲什么是管道 - 过滤器模式,以及如何实现。

什么是管道 - 过滤器模式?

管道 - 过滤器在结构上是一种组合行为,通常以切面(Aspect)的方式在主流程上添加定制化组件。当我们在阅读一些开源框架和代码,看到 Filter(过滤器)或 Interceptor(拦截器)等名词时,往往就是碰到了管道 - 过滤器模式。 管道 - 过滤器结构主要包括过滤器(Filter)和管道(Pipe)两种组件:

管道 - 过滤器模式 - 图2

在管道 - 过滤器结构中,过滤器负责执行具体的业务逻辑,每个过滤器都会接收来自主流程的请求,并返回一个响应结果到主流程中。而管道则用来获取来自过滤器的请求和响应,并传递到后续的过滤器中,相当于是一种通道。 管道 - 过滤器风格的一个典型应用是 Web 容器的 Filter 机制。你可以看到,在生成最终的 HTTP 响应之前,Web 容器通过添加多个 Filter 对 HTTP 响应结果进行处理:

管道 - 过滤器模式 - 图3

管道 - 过滤器模式示例

在介绍完管道 - 过滤器模式的基本概念之后,我们来看一个它的简单示例,帮你更深入地认识这一模式。设想我们存在一个 Order 对象,代表现实世界中的订单概念,包含一些常规属性比如订单编号(orderNumber)、联系方式(contactInfo)、收货地址(address)和货物信息(goods):
  1. public class Order {
  2. private String orderNumber;
  3. private String contactInfo;
  4. private String address;
  5. private String goods;
  6. }
基于这个 Order 对象,我们构建一个 Filter 链来分别完成 Order 中核心属性的校验:
  • GoodsNotEmptyFilter,验证 Order 对象中“goods”字段是否为空;
  • OrderNumberNotInvalidFilter,验证 Order 对象中“orderNumber”字段是否符合特定的命名规则;
  • AddressNotInvalidFilter,验证 Order 对象中“address”字段是否是一个合法的收货地址。
这些 Filter 通过对应的 Pipe 组件构成一个完成的处理链路:

管道 - 过滤器模式 - 图4

从这个例子中,你可以看到管道 - 过滤器模式的特点在于:把一系列的定制化需求转换成一种类似数据流的处理方式,数据通过管道流经一系列的过滤器,在每个过滤器中完成特定的业务逻辑。 显然,每个过滤器能够独立完成自身的职责,不需要依赖于其他过滤器,过滤器之间没有耦合度。 这种特性使得系统的扩展性得到了巨大的提升,我们很容易就能对现有的过滤器进行替换,而且对过滤器进行动态添加和删除也不会对整个处理流程产生任何影响。

管道 - 过滤器模式在开源框架中的应用

现在,相信你对管道 - 过滤器的基本结构和实现方式已经有了基本了解。接下来,我来讲解开源框架中的管道 - 过滤器设计方法和实现细节,进一步加深你的理解。 事实上,很多开源框架中都应用了管道 - 过滤器这个架构模式,也都提供了基于过滤器链的实现方式,例如 Dubbo 中的过滤器概念就基本符合我们对这一模式的理解。 接下来我们以 Dubbo 为例,先看一下过滤器链的构建过程,再介绍 Dubbo 中现有过滤器的实现方法。

管道 - 过滤器模式 - 图5

Dubbo 中对 Filter 的处理基于一种 Wrapper 机制。所谓 Wrapper,顾名思义,是对 Dubbo 中各种扩展点的一种包装。(如上图) 目前,纵观整个 Dubbo 框架,只存在一个 Wrapper,即 ProtocolFilterWrapper。 在该类中,存在这样一个用来构建调用链的 <font style="color:rgb(51, 51, 51);">buildInvokerChain</font> 方法:
  1. private static <T> Invoker<T> buildInvokerChain(final Invoker<T> invoker, String key, String group) {
  2. //获取Invoker对象
  3. Invoker<T> last = invoker;
  4. //加载过滤器列表
  5. List<Filter> filters = ExtensionLoader.getExtensionLoader(Filter.class).getActivateExtension(invoker.getUrl(), key, group);
  6. if (filters.size() > 0) {
  7. for (int i = filters.size() - 1; i >= 0; i--) {
  8. final Filter filter = filters.get(i);
  9. final Invoker<T> next = last;
  10. last = new Invoker<T>() {
  11. //讲Filter作用于Invoker对象
  12. };
  13. }
  14. }
  15. return last;
  16. }
我们可以看到用于获取扩展点的 <font style="color:rgb(51, 51, 51);">ExtensionLoader.getExtensionLoader(Filter.class).getActivateExtension() </font>方法。注意,这里对通过扩展点加载的过滤器进行了排序,从而确保过滤器链按设想的顺序进行执行。 看完过滤器链,我们来看一下过滤器。Dubbo 中的 Filter 接口定义是这样的:
  1. @SPI
  2. public interface Filter {
  3. Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException;
  4. }
可以看到 Filter 接口能够获取传入的 Invoker,从而对其进行拦截和处理。针对 Filter 接口,Dubbo 中一共存在一大批个实现类,类层结构如下图所示:

管道 - 过滤器模式 - 图6

这些过滤器可以大致分成两类:
  • 面向服务提供者的过滤器
  • 面向服务消费者的过滤器
其中面向服务提供者的过滤器只会在服务暴露时对 Invoker 进行过滤,例如上图中的 TimeoutFilter 和 TraceFilter。 而面向服务消费者的过滤器发生作用的阶段是在服务引用时,例如上图中的 ConsumerContextFilter 和 FutureFilter。 一般的过滤器只能属于这两种类型中的一种,但是 MonitorFilter 是个例外,它可以同时作用于服务暴露和服务引用阶段,因为它需要对这两个阶段都进行监控。 我挑选一个有代表性的 TokenFilter 给你介绍一下。 TokenFilter 的作用很明确,就是通过 Token 进行访问鉴权,通过对比 Invoker 中的 Token 和传入参数中的 Token 来判断是否是合法的请求,其代码如下所示:
  1. @Activate(group = Constants.PROVIDER, value = Constants.TOKEN_KEY)
  2. public class TokenFilter implements Filter {
  3. public Result invoke(Invoker<?> invoker, Invocation inv)
  4. throws RpcException {
  5. //获取Token
  6. String token = invoker.getUrl().getParameter(Constants.TOKEN_KEY);
  7. if (ConfigUtils.isNotEmpty(token)) {
  8. Class<?> serviceType = invoker.getInterface();
  9. //获取请求的辅助信息
  10. Map<String, String> attachments = inv.getAttachments();
  11. //获取远程Token
  12. String remoteToken = attachments == null ? null : attachments.get(Constants.TOKEN_KEY);
  13. //判断本地Token和远程Token是否一致
  14. if (!token.equals(remoteToken)) {
  15. throw new RpcException("Invalid token! Forbid invoke remote service " + serviceType + " method " + inv.getMethodName() + "() from consumer " + RpcContext.getContext().getRemoteHost() + " to provider " + RpcContext.getContext().getLocalHost());
  16. }
  17. }
  18. return invoker.invoke(inv);
  19. }
  20. }
在代码中可以看到,通过 invoker.getUrl() 方法获取了 Invoker 中的 URL 对象,而我们知道 Dubbo 中的 URL 作为统一数据模型,它包含了所有服务调用过程中的参数,这里的 Invocation 对象则封装了请求数据。 这样,一方面我们通过 URL 对象获取本地 Token 参数,另一方面通过 Invocation 的 Attachments 也获取了 RemoteToken,可以执行对比和校验操作。这也是在 Dubbo 中处理调用信息传递的很常见的一种做法,你可以在很多地方看到类似的代码。

总结

可以说,如何动态把握请求的处理流程是任何系统开发面临的一大问题,而今天讲解的管道 - 过滤器模式就为解决这一问题提供了有效的方案。 在日常开发过程中,我们可以在确保主流程不受影响的基础上,通过管道 - 过滤器模式添加各种定制化的附加流程,满足不同的应用场景。

从架构设计上,管道 - 过滤器可以说是高内聚、低耦合思想的典型实现方案,也符合开放 - 关闭原则。各个过滤器各司其职,相互独立,多个过滤器之间集成也比较简单,在功能重用性和维护性上都具备优势。

另一方面,我们也应该认识到管道 - 过滤器的最大问题在于,导致系统形成一种成批操作行为,因此在使用过程中需要设计并协调数据的流向。