关于Spring的两三事:傻傻分不清楚的filter和interceptor

8,223 阅读11分钟

人生苦短,不如养狗

作者:闲宇

公众号:Brucebat的伪技术鱼塘

一、前言

  从接触Spring开始我们就经常能听到filter(过滤器)interceptor(拦截器) 这两个概念,但当我们真正要去使用它们的时候却又时常傻傻分不清楚两者的异同。这其中最大的原因就在于两者的职能(权限校验、日志处理、数据解压/压缩处理等)过于相似,filter可以实现的场景interceptor同样也可以实现,导致两者的边界感非常模糊。为了弄清楚两者的异同,让我们追根溯源,从源头上开始了解一下两者的起源和设计理念。

以下讲解基于SpringBoot 2.7.5版本

二、舶来品和原住民

Filter:舶来品

1. 基本概念

  当我们仔细阅读源码之后会发现filter这个概念竟然是一个源自于Servlet的舶来品(遵循Servlet规范),这里可以看一下Filter类的全限定名:

javax.servlet.Filter

  可以看到Filter本身是用在Tomcat等Web容器进行Servlet相关处理时使用的工具,并非是Spring原生的工具。从这一发现中我们不难揣测在Spring中为什么Filter和Interceptor在职能上是如此的相近,因为这两者的作者并非一人,在构建各自体系时产生相同的想法和思路也是可以理解的,毕竟君子所见略同也是时有发生的事情。后续Spring为了引入和兼容Tomcat容器的处理逻辑,将两个较为相似地概念放置在同一个应用上下文中(注意,Spring并没有做合并处理,只是兼容),导致开发者时常迷糊也变得情有可原。

  为了更好地了解Filter的职能,这里我们引入官方注释来帮助理解:

A filter is an object that performs filtering tasks on either the request to a resource (a servlet or static content), or on the response from a resource, or both.

过滤器是对资源请求(servlet 或静态内容)或来自资源的响应或两者执行过滤任务的对象。

  从上面的定义中我们可以得到两点有用信息:

  • 执行时机Filter的执行时机有两个,分别是对资源的请求被执行前将来自资源的响应返回前
  • 执行内容:过滤器本质是在执行一个过滤任务,而过滤条件需要根据对资源的请求或者来自资源的响应进行判断。

  除了上面的两点信息以外,在结合Tomcat中关于Servlet容器的结构设计之后我们可以得到下面关于Filter执行过程的流程图:

  在实际开发场景中,对于资源请求的预处理或者资源响应的后置处理可能不单只会有一类过滤任务,所以Tomcat在编码设计中使用了责任链模式来完成对于需要使用多个不同类型过滤器处理请求或者响应的场景,这一点在上面的流程图中也有所体现。这里需要注意一点,由于使用了链式结构这一线性数据结构,在filter的实际执行过程中就会存在执行顺序的问题,这就意味着我们在实现自定义过滤器时不能出现过滤器依赖颠倒的情况,当然如果过滤器之间不存在依赖关系则无需考虑顺序问题。在Tomcat中使用org.apache.catalina.core.ApplicationFilterChain来实现上面提到的责任链模式,这里我们可以结合部分代码简单了解一下:

public final class ApplicationFilterChain implements FilterChain {
  
  public void doFilter(ServletRequest request, ServletResponse response)
        throws IOException, ServletException {
​
        if( Globals.IS_SECURITY_ENABLED ) {
            final ServletRequest req = request;
            final ServletResponse res = response;
            try {
                java.security.AccessController.doPrivileged(
                        (java.security.PrivilegedExceptionAction<Void>) () -> {
                          // 进行实际的filter执行
                            internalDoFilter(req,res);
                            return null;
                        }
                );
            } catch( PrivilegedActionException pe) {
                ...
            }
        } else {
          // 进行实际的filter执行
            internalDoFilter(request,response);
        }
    }
  
  
  private void internalDoFilter(ServletRequest request,
                                  ServletResponse response)
        throws IOException, ServletException {
​
        // Call the next filter if there is one
        if (pos < n) {
            ApplicationFilterConfig filterConfig = filters[pos++];
            try {
                Filter filter = filterConfig.getFilter();
                ...
                if( Globals.IS_SECURITY_ENABLED ) {
                    ...
                } else {
                  // 这里需要结合Filter类一起分析,实际上这里执行的是一个回调函数,
                  // 该方法的第三个参数将当前applicationFilterChain对象传入,结合上面的pos指针来判断是否已经将过滤器链执行完成
                    filter.doFilter(request, response, this);
                }
            } catch (IOException | ServletException | RuntimeException e) {
                throw e;
            } catch (Throwable e) {
                ...
            }
            return;
        }
​
        // We fell off the end of the chain -- call the servlet instance
        try {
            ...
              // 实际执行servlet对应服务,注意这里只是进入到servlet实例当中,并没有真正进入到某个handle当中
                servlet.service(request, response);
            ...
        } catch (IOException | ServletException | RuntimeException e) {
            throw e;
        } catch (Throwable e) {
            ...
        } finally {
            ...
        }
    }
}

  从上面的代码中我们可以看到Tomcat使用了pos指针来完成对于过滤器链中过滤器执行位置的记录,在完成链中所有过滤器执行并且通过之后,requestresponse对象才会提交给servlet实例进行对应服务的处理。需要注意,此时并没有涉及到某个具体handler,也就是说filter的处理并不能细化到某一类具体的handler请求/响应,只能较为模糊处理整个servlet实例维度的请求/响应。

  当然,从上面的代码我们可以发现另外一个问题:代码中好像只有针对资源请求维度的过滤处理而没有对于资源响应的过滤处理。其实对于资源响应的过滤处理被隐藏在每个过滤器的doFilter方法中了,在实现自定义过滤器时我们需要按照以下逻辑来编写代码才能完成对于资源响应的处理:

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // TODO 前置处理
        // 调用applicationFilterChain对象的doFilter方法(这里实际上是一个回调逻辑),这里一定要加上,否则链式结构就会从这里断开。
        chain.doFilter(request, response);
        // TODO 后置处理
    }

  结合ApplicationFilterChaininternalDoFilter方法可以发现这里隐含了一个入栈出栈(其实就是方法栈)的逻辑。对于资源请求的预处理过程实际上是一个入栈的过程,当所有的预处理过滤器入栈完毕则就会开始执行servlet.service(request, response)。在完成servlet服务处理之后,就会进入到出栈过程,此时会从最后一个过滤器的后置处理逻辑(也就是上面代码中最后一行的位置)逐一执行并退出方法。

  不得不说,这里的逻辑对于刚入门的新手来说确实不是非常友好。由于Filter本身只是一个接口,并不能像抽象类一样提供一个模板方法,导致初学者在使用时如果没有一个比较好的案例参照,只是单纯看源码的话可能会产生和上面一样的疑问。这里也要提醒大家在实现自定义过滤器时一定要按照上面的模板完成,否则会出现链式过程断开或者后置逻辑无法实现的情况。

2. Spring中的使用

  虽然写着Spring,但实际上闲宇这里要讲的是在SpringBoot当中的使用方法。结合SpringBoot来实现自定义过滤器实际上只需要在原有的流程中加上注入到Spring容器中的逻辑就可以了,在SpringBoot中提供了两种方法完成这一操作:

  • 在自定义过滤器上使用@Component注解;
  • 在自定义过滤器上使用@WebFilter注解,并在启动类上使用@ServletComponentScan注解;

  这里闲宇更推荐使用第二种方式来完成过滤器的注入,因为Spring在兼容过滤器的处理过程时还提供了原有Tomcat不存在的功能,即url匹配能力。结合@WebFilter注解中的urlPattern字段,Spring能够将过滤器的处理粒度进一步细化,让开发人员在使用上变得更加灵活。除此之外,为了确定过滤器注入的顺序,我们还可以使用Spring提供的@Order注解来自定义过滤器的顺序。

Interceptor:Spring的原住民

1. 基本概念

  看完了filter,我们再将目光转回到Interceptor上。这一次我们可以发现Interceptor这样一个概念是Spring原创的,其对应的具体接口类为HandlerInterceptor(当然还有一个异步拦截器接口类,这里我们就不做扩展,有兴趣的同学可以自行阅读源码学习)。在阅读完对应的源码之后我们可以发现,区别于Filter只提供了一个简单的doFilter方法, 在HandlerInterceptor当中明确提供了三个与执行时机相关的方法:

  • preHandle: 在执行对应handler之前会执行该方法进行前置处理;
  • postHandle: 在对应handler完成请求处理之后且在ModelAndView对象被渲染之前会执行该方法来进行一些关于ModelAndView对象的后置处理;
  • afterCompletion: 在ModelAndView对象完成渲染之后且在响应返回之前会执行该方法对结果进行后置处理;

  相比Filter类中只是简单提供了一个doFilter方法,HandlerInterceptor中的方法定义显得更加明确和友好。在不阅读源码和参考使用范例的情况下,我们也能大致猜测到需要如何实现自定义拦截器。

  结合org.springframework.web.servlet.DispatcherServlet#doDispatch中的源码我们可以绘制出如下的流程图(这里就不贴出具体代码了,有兴趣的同学可以自行阅读):

  可以看到此时interceptor的执行逻辑都是包含在servlet实例当中,结合上面filter的执行过程我们不难发现,filter就像夹心饼干的两个饼干一样将servlet和interceptor夹在中间,interceptor的执行时机是要晚于filter的前置处理并且早于filter的后置处理的。除此以外,在阅读源码过程中我们可以发现Spring在使用interceptor时同样也是用了责任链模式,不得不说在这种需要逐个执行不同任务处理逻辑的场景下责任链模式还是非常好用的。需要注意,由于Spring在定义拦截器时已经明确了不同阶段执行的方法,所以在实际执行拦截器时并没有采用和过滤器一样的入栈出栈方式。

2. Spring中的使用

  在SpringBoot当中使用interceptor除了需要实现HandlerInterceptor接口,还需要显示注册Spring的Web配置当中,具体代码如下:

@Configuration
public class WebConfig implements WebMvcConfigurer {
​
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new DemoInterceptor()).addPathPatterns("/api/*").excludePathPatterns("/api/ok");
    }
}

  从上面的代码中我们可以看到,Spring也给自定义拦截器提供了和filter一样路径匹配功能,通过这样一个功能自定义拦截器可以针对更细粒度的handler请求和响应处理。(再一次和filter撞功能,当然这里是Spring内部提供的能力)

三、常见使用场景

  其实在文章的最开始我们已经介绍过一部分两者的职能,这里我们再来简单总结一下。从上面的分析中我们不难发现filter和interceptor的设计者创造这两个工具的目的都是为了将针对请求的预处理和针对响应的后置处理从业务代码中剥离开来,将两者作为一个通用处理逻辑提供给开发人员自行扩展实现,从这一思想中我们很容易看到AOP的身影(果然优秀的思想总是相通的)。

  在实际的开发场景中,我们经常会使用自定义过滤器或拦截器来完成如下操作:

  • 用户登录校验;
  • 权限校验;
  • 日志拦截处理;
  • 数据压缩/解压处理;
  • 加密/解密处理;
  • ......

  这里我们就不展示每个场景的编码实现了,有兴趣的同学可以自行搜索了解一下。这里可以给出一点建议,虽然上述场景看起来很多,但其本质上还是针对请求参数或者响应结果在进行一些数据处理,基于这样一个认知我们去设计实现上述这些场景就会显得相对轻松。

四、总结

  在我们仔细分析之后可以看到filter和interceptor并没有本质上的区别,作为一个工具来说,他们两者能够提供的能力基本是一样的。唯一需要注意的就是在执行时机上两者的不同(一个是在servlet执行前后处理,一个是在servlet内部执行),其他方面并没有显著的不同。这这也就意味着我们在实际开发使用时并不需要太过于纠结,毕竟老话说的好:黑猫白猫,抓到老鼠的就是好猫。

  最后,一如既往,祝愿大家身体健康,早日升值加薪。近期好像疫情开始反复,出门在外注意防护,保重身体。