25.1 拦截器的作用

到目前为止,我们在Controller层定义的API都是完全开放而不设防的,既没有去识别(验证)访问者的身份,更没有判断访问者是否有合适的权限。设计范例的时候这样作是没问题的,但在实际的项目开发中则绝对不允许这样做——识别身份(Authentication)、判断权限(Authorization)、记录访问(Accounting),这三A缺一不可。

在早期的Java应用开发中,工程师一般会在每个Servlet(或JSP)的开始设计鉴权代码。这些代码可能是直接编写、调用公共方法、或者包含JSP公共代码片段。这种作法代码繁琐、维护量大,并且一旦发生遗漏且没有被审查出来,可能会导致极为严重的安全泄露事故。这样的事故在我们的工作中确实发生过。

那是否有办法建立和统一异常处理类似的统一认证鉴权机制呢?答案是可以,我们可以通过建立合适的拦截器实现这一目标。

25.2 Spring中的拦截器

在拦截器是Web开发中经常用到的功能。我们可以用它来拦截数据做相应的处理,比如验证是否登陆、判断用户权限、预先设置数据以及统计方法的执行效率等。

Spring中拦截器主要分两种:一个是HandlerInterceptor,另外一个是MethodInterceptor。

25.1.1 HandlerInterceptor拦截器

HandlerInterceptor拦截器为我们提供了一些方法,使我们能够拦截控制器类正在处理的传入请求或控制器类已经处理的响应。也就是说它拦截的目标是请求的地址,它比下文的MethodInterceptor先执行。

构造这种拦截器通过直接实现HandlerInterceptor接口来完成,在Spring 5.3.7之前也可以通过继承HandlerInterceptorAdapter类来实现,但此种方式已经被标记为deprecated,之前内部的定义也都被移除,因此不要使用这种方法。

下面是HandlerInterceptor的核心代码:

  1. public interface HandlerInterceptor {
  2. default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  3. return true;
  4. }
  5. default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
  6. }
  7. default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
  8. }
  9. }

下图是Spring执行controller中方法前后的流程:

25. 应用程序拦截器 - 图1
从方法名和上面的流程图中我们都可以看出接口中三个方法的调用时机和作用:

  • preHandle()
    在业务处理器处理请求之前被调用,返回值为boolean型。如果返回true,则交预先登记的后续拦截器继续处理(若干是最后一个拦截器,则交控制器类处理),如果返回false表示拒绝访问,后续的Interceptor 和Controller 都不会再执行处理。可以在这个方法中做各种预处理,如进行编码、安全控制、权限校验等,这里对Request和Response的修改都可以传递到后面去。
  • postHandle()
    在当前请求进行处理之后(即Controller类的方法调用之后),DispatcherServlet 进行视图返回渲染之前被调用,因为捕获了Controller方法返回的ModelAndView,所以可以继续往ModelAndView里添加一些通用数据,很多页面需要的全局数据如Copyright信息等都可以放到这里,无需在每个Controller方法中重复添加。此时修改Response(如给Header中添加内容)都不会影响传递到前端的内容。
    postHandle 方法被调用的方向跟preHandle 是相反的,也就是说先声明的Interceptor 的postHandle 方法反而会后执行。
  • afterCompletion()
    在 DispatcherServlet 完全处理完请求生成视图之后调用后(也就是在DispatcherServlet 渲染了对应的视图之后)被调用,无论Controller方法是否抛异常都会执行。这个方法的主要作用是用于进行资源清理工作。

    25.1.2 MethodInterceptor拦截器

    MethodInterceptor是AOP项目中的拦截器,它拦截的目标是类的 方法,并且不局限于controller中的方法。实现此种拦截器也有为两种手段:一种是实现MethodInterceptor接口,另一种利用AspectJ的注解或配置。

AOPA是spect Oriented Programming的缩写,意为“面向切面编程”。它通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。对它的讨论已经超过本教程的与其范围,因此不予展开说明。

25.1.3 应用场景

通常说来,拦截器被广泛用于如下场景:

  • 性能监控:检测方法的执行时间;
  • 日志记录:记录请求信息的日志,以便进行信息监控、信息统计、计算PV(Page View)等;
  • 登录鉴权:如登录检测,进入处理器前检测登录和权限状态(不过后文教程中的 Spring Security 用的是 Filter);
  • 其他通用行为。

    25.1.4 谈一谈区别

    上面的两种拦截器都能起到拦截的效果,但是他们拦截的目标不一样,实现的机制也不同,所以分别适用不同的场景:

  • HandlerInterceptoer拦截的是请求地址,可以获取到URL的路径方和法的名字,不能获取方法的参数,所以适用于针对请求地址做一些验证、预处理等操作比较合适。

  • MethodInterceptor利用的是AOP的实现机制,能获取到URL的路径、方法的名字和参方法数,更适合用于对对一些普通的方法的拦截,可用于类似系统日志的处理。当你将它用于统计请求的响应时间时将不够准确,因为它可能跨越很多方法或者只涉及到已经定义好的方法中一部分代码。


此外,还有一个和拦截器类似的东西——Filter(过滤器)。Filter是Servlet规范规定的,不属于Spring框架,也可用于请求的拦截。但是它适合更粗粒度的拦截(只能获取URL,不能获取方法名字),在请求前后做一些编解码处理、日志记录等。而拦截器则可以提供更细粒度的,更加灵活的,针对某些请求、某些方法的组合的解决方案。

下面是的拦截器和过滤器的主要区别:

  1. 拦截器是基于Java的反射机制的,而过滤器是基于函数回调。
  2. 拦截器不依赖于Servlet容器,而过滤器依赖于Servlet容器。
  3. 拦截器是Spring容器内的,是Spring框架支持的;而过滤器是Servlet规范中定义的,是Servlet容器支持的。
  4. 拦截器只能对Controller请求起作用,而过滤器则可以对几乎所有的请求起作用。
  5. 拦截器可以访问Action上下文、值栈里的对象,而过滤器不能访问。
  6. 在Controller的生命周期中,拦截器可以多次被调用,而过滤器只能在容器初始化时被调用一次。
  7. 拦截器可以获取IOC容器中的各个bean,而过滤器不行(这点很重要,在拦截器里注入一个service,可以调用业务逻辑)。
  8. 拦截器是Spring的一个组件,归Spring管理配置在Spring的文件中,可以使用Spring内的任何资源、对象(可以粗浅的认为是IOC容器中的Bean对象),而过滤器则不能使用访问这些资源。
  9. 拦截器可以深入到方法的前后、异常抛出前后等更深层次的程度作处理,而过滤器只在Servlet前后起作用,

    25.3 拦截器原理性示范

    本节将对拦截器的用法做一些原理性的示范,后续章节将讨论更完善的应用。

    25.3.1 单个拦截器

    1.新建拦截器

    我们创建下面第一个拦截器 ```java package com.longser.union.cloud.interceptor;

import org.springframework.lang.NonNull; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;

public class FirstInterceptor implements HandlerInterceptor {

  1. @Override
  2. public boolean preHandle(
  3. HttpServletRequest request,
  4. HttpServletResponse response,
  5. @NonNull Object handler) {
  6. long startTime = System.currentTimeMillis();
  7. System.out.println("[First Interceptor]: preHandle");
  8. System.out.println("Request URL: " + request.getRequestURL());
  9. System.out.println("Start Time: " + startTime);
  10. request.setAttribute("startTime", startTime);
  11. response.setHeader("Previous-Message" ,"This is preHandle of First Interceptor");
  12. return true;
  13. }
  14. @Override
  15. public void postHandle(
  16. HttpServletRequest request,
  17. HttpServletResponse response,
  18. @NonNull Object handler,
  19. ModelAndView modelAndView) {
  20. System.out.println("[First Interceptor]: postHandle");
  21. System.out.println("Request URL: " + request.getRequestURL());
  22. response.setHeader("Post-Message" ,"This is postHandle of First Interceptor");
  23. }
  24. @Override
  25. public void afterCompletion(
  26. HttpServletRequest request,
  27. HttpServletResponse response,
  28. @NonNull Object handler,
  29. Exception ex) {
  30. long startTime = (Long) request.getAttribute("startTime");
  31. long endTime = System.currentTimeMillis();
  32. System.out.println("[First Interceptor]: afterCompletion");
  33. System.out.println("Request URL: " + request.getRequestURL());
  34. System.out.println("End Time: " + endTime);
  35. System.out.println("Time Taken: " + (endTime - startTime));
  36. response.setHeader("After-Message" ,"This is afterCompletion of First Interceptor");
  37. }

}

<a name="UXgPf"></a>
#### 2.配置拦截器
定义好的拦截器尚未添加到 Spring 配置中,必须在`@Configuration`标准的类中添加拦截器。前文我们已经定义了一个全局的配置类com.longser.union.cloud.config.WebMvcConfig,现在给他增加一个方法
```java
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册自定义拦截器,添加拦截路径和排除拦截路径
        registry.addInterceptor(new FirstInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/api/hello")
                .order(0);
    }

3.测试拦截器

用浏览器或者Postman访问除 /test/hello以外的任何一个地址,控制台可以看到类似如下的输出:

[First Interceptor]: preHandle
Request URL: http://localhost:8088/test/first
Start Time: 1636809536216
[First Interceptor]: postHandle
Request URL: http://localhost:8088/test/first
[First Interceptor]: afterCompletion
Request URL: http://localhost:8088/test/first
End Time: 1636809536557
Time Taken: 341

在Postman中查看Response Header,可以看到只有 preHandle 的修改被传递到了客户端:
image.png

25.3.2 多个拦截器

现在我们看两个拦截器串联起来是什么效果

1.新建第二个拦截器

package com.longser.union.cloud.interceptor;

import org.springframework.lang.NonNull;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class SecondInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull Object handler) {
        System.out.println("[Second Interceptor]: preHandle");

        return true;
    }

    @Override
    public void postHandle(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull Object handler,
            ModelAndView modelAndView) {
        System.out.println("[Second Interceptor]: postHandle");
    }

    @Override
    public void afterCompletion(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull Object handler,
            Exception ex) {
        System.out.println("[Second Interceptor]: afterCompletion");
    }
}

2.配置拦截器

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册自定义拦截器,添加拦截路径和排除拦截路径
        registry.addInterceptor(new FirstInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/test/hello")
                .order(0);

        registry.addInterceptor(new SecondInterceptor())
                .addPathPatterns("/**")
                .order(1);
    }

3.测试拦截器

访问除 /test/hello以外的任何一个地址,控制台可以看到如下的输出:

[First Interceptor]: preHandle
Request URL: http://localhost:8088/test/first
Start Time: 1636809881855
[Second Interceptor]: preHandle
[Second Interceptor]: postHandle
[First Interceptor]: postHandle
Request URL: http://localhost:8088/test/first
[Second Interceptor]: afterCompletion
[First Interceptor]: afterCompletion
Request URL: http://localhost:8088/test/first
End Time: 1636809882185
Time Taken: 330

根据上面的结果,简单来说多个拦截器执行流程就是先进后出

接下来用Postman访问 /test/hello 控制台可以看到如下的输出:

[Second Interceptor]: preHandle
[Second Interceptor]: postHandle
[Second Interceptor]: afterCompletion

因为在定义 FirstInterceptor 的时候排除了这个URL,所以我们只能看到第二个拦截器输出的信息。

25.4 用拦截器监控 API 性能

本节展示一个使用拦截器监控 API 运行性能的示例。

首先定义一个新的拦截器

package com.longser.union.cloud.interceptor;

import org.jetbrains.annotations.NotNull;
import org.springframework.core.NamedThreadLocal;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;

public class ApiPerformanceInterceptor implements HandlerInterceptor {
    private final NamedThreadLocal<Instant> startTimeThreadLocal =
            new NamedThreadLocal<>("API-performance-monitor");

    // 这里是为了测试,所以把时间设置成了10毫秒,实际上不应该这么短

    private static final long LOW_PERFORMANCE_THRESHOLD = 10;
    private static final int MB = 1024 * 1024;

    @Override
    public boolean preHandle(
            @NotNull HttpServletRequest request,
            @NotNull HttpServletResponse response,
            @NotNull Object handler) {
        // 记录开始时间
        Instant startInstant = Instant.now();

        //线程绑定变量(该数据只有当前请求的线程可见)
        startTimeThreadLocal.set(startInstant);

        return true;
    }

    @Override
    public void afterCompletion(
            @NotNull HttpServletRequest request,
            @NotNull HttpServletResponse response,
            @NotNull Object handler,
            Exception ex) throws UnknownHostException {
        //2、结束时间
        Instant endInstant = Instant.now();

        //得到线程绑定的局部变量(开始时间)
        Instant startInstant = startTimeThreadLocal.get();
        startTimeThreadLocal.remove();

        //3、消耗的时间
        long consumeTime = Duration.between(startInstant, endInstant).toMillis();

        HandlerMethod method = (HandlerMethod) handler;

        //此处认为处理时间超过500毫秒的请求为慢请求
        if(consumeTime > LOW_PERFORMANCE_THRESHOLD) {
            System.out.format("%s consume %d millis\n", request.getRequestURI(), consumeTime);

            System.out.println(generateAlarmMessage(consumeTime, method, request));
        }
    }

    private String generateAlarmMessage(long processTime, HandlerMethod method, HttpServletRequest request
    ) throws UnknownHostException {

        String requestUri = request.getRequestURI();
        Map<String, String[]> parameterMap = request.getParameterMap();

        StringBuilder sb = new StringBuilder();
        sb.append("RequestUri: ").append(requestUri).append("\n");
        sb.append("ControllerMethod: ").append(method.getBean().getClass().getCanonicalName()).append(".").append(method.getMethod().getName());
        for (Map.Entry<String, String[]> en : parameterMap.entrySet()) {
            sb.append("   ");
            sb.append(en.getKey()).append("=");
            String[] v = en.getValue();
            if (v.length > 1) {
                for (String vv : v) {
                    sb.append(vv).append(",");
                }
            } else {
                sb.append(v[0]);
            }
            sb.append("\n");
        }
        sb.append("ProcessTime: ").append(processTime).append(" ms\n");

        sb.append("LocalNetWorkIp: ").append(InetAddress.getLocalHost().getHostAddress()).append("\n");

        // Total number of processors or cores available to the JVM
        sb.append("Available processors (cores): ")
                .append(Runtime.getRuntime().availableProcessors()).append("\n");

        long totalMemory = Runtime.getRuntime().totalMemory();
        long freeMemory = Runtime.getRuntime().freeMemory();

        // Total memory currently available to the JVM
        sb.append("Total memory available to JVM (MB): ").append(totalMemory / MB).append("\n");
        // used memory
        sb.append("Used Memory (MBs): ").append((totalMemory - freeMemory) / MB).append("\n");
        // Total amount of free memory available to the JVM
        sb.append("Free memory (MBs): ").append(freeMemory / MB).append("\n");
        // Maximum amount of memory the JVM will attempt to use
        sb.append("Maximum memory (MBs): ").append(Runtime.getRuntime().maxMemory() / MB)
                .append("\n");

        return sb.toString();
    }
}

上面的代码在 preHandle 中记录了当前的时间,并且保存在一个 NamedThreadLocal 对象中,然后在afterCompletion 取出这个时间值和当前时间做比较,如果超过预先定义的时间,则输出当前的各项数据。

为了简化,代码中使用 request.getParameterMap() 来获取请求的参数。如果做周全设计的话,应该先判断一个请求传递的方式,即判断是 GET 还是 POST 。通过request.getInputStream() 方法获取 POST 请求参数更适合一些。

下面只注册这个拦截器:

public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new ApiPerformanceInterceptor())
                .addPathPatterns("/**")
                .order(0);
}

测试可以得到类似下面这样的结果

/test/first consume 288 millis
RequestUri: /test/first
ControllerMethod: com.longser.union.cloud.controller.TestController.getFirstProcessTime: 288 ms
LocalNetWorkIp: 127.0.0.1
Available processors (cores): 8
Total memory available to JVM (MB): 308
Used Memory (MBs): 29
Free memory (MBs): 278
Maximum memory (MBs): 4096

25.5 总结与提示

Spring 提供了 Interceptor 组件来拦截 Controller 方法,使用时要注意 Interceptor 的作用范围。

Spring 中的 request.getInputStream()request.getReader() 只能被获取到一次。如果在截器中用这些方法读取了 Request 的信息,在 controller 里面再读会发现已经空了,所以如果需要在拦截器中读取,需要自己专门处理。

本章展示的用拦截器做性能监控的作法只是用来讨论拦截器的作用效果。在实际的项目中,你应该使用后文教程讲述的 Spring Actuator 来监控 Spring 运行的性能。即便需要自己监控,也应该设置一个开关,确保只有在必要的时候启用。此外, 示例代码中只是用 System.out 把信息输出在控制台上,实际正确的作法应该是记录在专门的日志中。

最后需要说明的是,我们后文的教程会有很大的篇幅来讨论 Spring Security,它使用的是过滤器(Filter)而不是拦截器(Interceptor),这个概念和名词要分清楚,网络上很多的文档都把中文名称用错了。

现在把添加过滤器的方法 addInterceptors() 给删除了吧,要不然这些输出会让后面教程的输入内容看起来很乱。

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。