在面向请求-响应式的系统中,我们经常需要在多个请求之间实现一些集中而通用的处理,比如检查每个请求的数据编码方式、为每个请求记录日志信息或压缩输出等。这些需求相当于在请求-响应的代码主流程中嵌入了一些定制化组件。从架构设计上讲,我们就需要在这些定制化处理组件与请求处理主流程之间实现松耦合,这样添加或删除这些定制化组件就很容易,不会干扰主流程。同时,我们也需要确保这些组件之间相互独立,每个组件都能自主存在,从而提升它们的重用性,如下图所示。
那么,如何实现图中的效果呢?幸好,在架构模式中存在一种类似于上图的结构,即管道-过滤器模式。管道-过滤器在结构上是一种组合行为,通常以切面(Aspect)的方式在主流程上添加定制化组件。当我们在阅读一些开源框架和代码时,看到Filter(过滤器)或Interceptor(拦截器)等名词时,往往就是碰上了管道-过滤器模式。
什么是管道-过滤器模式?
管道-过滤器结构主要包括过滤器和管道两种元素。
在管道-过滤器结构中,执行定制化功能的组件被称为过滤器,负责执行具体的业务逻辑。每个过滤器都会接收来自主流程的请求,并返回一个响应结果到主流程中。而另一方面,管道用来获取来自过滤器的请求和响应,并把他们传递到后续的过滤器中,相当于是一种通道。
管道-过滤器风格的一个典型应用是Web容器的Filter机制。可以看到在生成最终的HTTP响应之前,可以通过添加多个Filter对由处理器所产生的输出内容进行处理。
管道-过滤器模式示例
介绍完管道-过滤器模式的基本概念之后,我们来看它的一个简单示例,帮助你对这一模式有更加感性的认识。
设想我们存在这样一个Order对象,代表现实世界中的订单概念,包含了一些常规属性,如下所示。
public class Order {
private String orderNumber;
private String contactInfo;
private String address;
private String goods;
}
基于这个Order对象,我们想要构建一个Filter链来分别完成Order中核心属性的校验。我们可以构建一个GoodsNotEmptyFilter来验证Order对象中“goods”字段是否为空,可以构建一个OrderNumberNotInvalidFilter来验证Order对象中“orderNumber”字段是否符合特定的命名规则,可以构建一个AddressNotInvalidFilter来验证Order对象中“address”字段是否是一个合法的收获地址。然后,这些Filter通过对应的Pipe组件构成一个完成的处理链路,如下所示。
从这个示例中,我们可以看到管道-过滤器模式的特点在于把一系列的定制化需求转换成一种类似数据流的处理方式,数据通过管道流经一系列的过滤器,在每个过滤器中完成特定的业务逻辑。显然,每个过滤器能够独立完成自身的职责而不需要依赖于其他过滤器。这种特性使得系统的扩展性得到了巨大的提升,因为过滤器之间没有耦合度,所以我们可以很容易对现有的过滤器进行替换,而动态添加和删除过滤器也不会对整个处理流程产生任何影响。
管道-过滤器模式在开源框架中的应用
然而,为了实现这些特点,前面介绍的示例过于简单,无法用于实际生产环境。所以我们需要通过学习开源框架中的管道-过滤器设计方法和实现细节来提升对这一架构模式的理解。事实上,包含Dubbo在内的很多开源框架中都应用了该模式,而且也都提供了基于过滤器链的实现方式,接下来让我们来看看它在Dubbo中的应用。
Dubbo中的过滤器概念基本上符合我们对管道-过滤器模式的理解。我们先来看一下Dubbo中过滤器链的构建过程,然后介绍Dubbo中现有过滤器的实现方法。
Dubbo的Filter实现入口是在ProtocolFilterWrapper,在服务暴露和服务引用时都会使用到过滤器链。所谓的Wrapper,顾名思义,是对扩展类的一种包装,如下图所示。
Dubbo中的包装类同样实现扩展点接口,具有与扩展点一样的方法。目前,纵观整个Dubbo框架,只存在一个Wrapper,即ProtocolFilterWrapper。在该类中,存在如下所示的一个buildInvokerChain的方法,从命名上看,该方法就是用来构建调用链,如下所示:
private static <T> Invoker<T> buildInvokerChain(final Invoker<T> invoker, String key, String group) {
Invoker<T> last = invoker;
List<Filter> filters = ExtensionLoader.getExtensionLoader(Filter.class).getActivateExtension(invoker.getUrl(), key, group);
if (filters.size() > 0) {
for (int i = filters.size() - 1; i >= 0; i--) {
final Filter filter = filters.get(i);
final Invoker<T> next = last;
last = new Invoker<T>() {
//这里构造一个最简化的Invoker作为调用链的载体Invoker
};
}
}
return last;
}
我们可以看到了用于获取扩展点的ExtensionLoader.getExtensionLoader(Filter.class).getActivateExtension()方法。注意,这里对通过扩展点加载的过滤器进行了排序从而确保过滤器链按设想的顺序进行执行。
看完过滤器链,我们反过来看一下过滤器。Dubbo中的Filter接口定义如下:
@SPI
public interface Filter {
Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException;
}
可以看到Filter接口能够对获取传入的Invoker,从而对其进行拦截和处理。针对Filter接口,Dubbo中一共存在一大批个实现类,类层结构如下图所示。
而且这些过滤器可以大致分成两类,即面向服务提供者的过滤器和面向服务消费者的过滤器。其中面向服务提供者的过滤器只会在服务暴露时对Invoker进行过滤,而面向服务消费者的过滤器发生作用的阶段自服务引用时。一般的过滤器只能属于这两种类型中的一种,但是MonitorFilter是个例外,它可以同时作用于服务暴露和服务引用阶段,因为它需要对这两个阶段都进行监控。
我们无意对所有这些过滤器组件做详细展开,而是挑选一个代表性的Filter进行介绍,这里我们选择TokenFilter。TokenFilter的作用很明确,即通过Token进行访问鉴权,通过对比Invoker中的Token和初入参数中的Token来判断是否是合法的请求,其代码如下所示。
@Activate(group = Constants.PROVIDER, value = Constants.TOKEN_KEY)
public class TokenFilter implements Filter {
public Result invoke(Invoker<?> invoker, Invocation inv)
throws RpcException {
String token = invoker.getUrl().getParameter(Constants.TOKEN_KEY);
if (ConfigUtils.isNotEmpty(token)) {
Class<?> serviceType = invoker.getInterface();
Map<String, String> attachments = inv.getAttachments();
String remoteToken = attachments == null ? null : attachments.get(Constants.TOKEN_KEY);
if (!token.equals(remoteToken)) {
throw new RpcException("Invalid token! Forbid invoke remote service " + serviceType + " method " + inv.getMethodName() + "() from consumer " + RpcContext.getContext().getRemoteHost() + " to provider " + RpcContext.getContext().getLocalHost());
}
}
return invoker.invoke(inv);
}
}
上述代码中,我们关注两点。首先我们看到可以通过invoker.getUrl()方法获取Invoker中的URL对象,而我们知道Dubbo中的URL作为统一数据模型Key-Value对的形式包含了所有服务调用过程中的参数。而这里的Invocation对象则封装了请求数据。这样,一方面我们通过URL对象获取本地token参数,另一方面,我们通过Invocation的Attachments也获取了remoteToken,从而可以执行对比和校验操作。这也是Dubbo中处理调用信息传递的非常常见的一种做法,我们可以在很多地方看到类似的代码。
总结
可以说,如何动态把握请求的处理流程是任何系统开发所面临的一大问题,而管道-过滤器模式为解决这一问题提供了有效的方案。在日常开发过程中,我们可以在确保主流程不收影响的基础上,通过管道-过滤器模式添加各种定制化的附加流程,从而满足不同的应用场景。