SpringMVC 中 RESTful API 的拦截

很多时候我们需要对每个 url 请求进行统一处理,比如记录每个 url 从开始请求到业务完成并返回所花费的时间,这需要在 url 请求到来的时候记录下来时间戳,完成后再记录下时间,二者的时间差值便是执行这次请求花费的时间。本篇以 SpringMVC 为例来讲解一下其中的三种拦截机制,他们分别是过滤器(Filter)、拦截器(Interceptor)和切片(Aspect)

项目搭建

初始项目如下,新建一个 maven 工程,pom 中添加 SpringBoot 的 parent 标签,这里使用的 1.5.7.RELEASE 版本。依赖中只添加了 web,也就是 SpringMVC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.7.RELEASE</version>
</parent>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<encoding>UTF-8</encoding>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

新建启动类,置于 lab.zlren.demo 包下

1
2
3
4
5
6
7
@SpringBootApplication
public class DemoApplication {

public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

新建一个 Controller 用于演示

1
2
3
4
5
6
7
8
@RestController
public class DemoController {

@GetMapping("/demo")
public String demo() {
return "hello world";
}
}

启动工程,执行 main 方法,访问 localhost:8080/demo,可以看到如下效果表明基础环境已经搭建完成

过滤器 Filter

新建 package:lab.zlren.demo.filter,在这个包下新建一个类叫做 TimeFilter,要继承 javax.servlet.Filter 这个接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package lab.zlren.demo.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import java.io.IOException;

/**
* @author zlren
* @date 2017-11-25
*/
@Slf4j
@Component
public class TimeFilter implements Filter {

@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("TimeFilter init");
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws
IOException, ServletException {

log.info("TimeFilter start");

long time = System.currentTimeMillis();
filterChain.doFilter(servletRequest, servletResponse);
log.info("{}", System.currentTimeMillis() - time);

log.info("TimeFilter finish");
}

@Override
public void destroy() {
log.info("TimeFilter destroy");
}
}

这里非常简单,在 doFilter 方法中记录下前后的时间戳,中间执行业务的逻辑就是用过滤器链继续调用就可以。在 SpringBoot 中使它生效很容易,只需要将其声明为一个 Component 即可

做好以后重启服务,再次访问 localhost:8080/demo,查看控制台的日志打印

可以看到在系统启动过程中便执行了 init 方法,访问 url 的前后分别打印了 start 和 finish 语句,并记录下了耗时。这样一个简答的过滤器就完成了。

这里补充一下,TimeFilter 是我们自己开发的过滤器,我们为其加上 @Component 注解就可以使之生效。很多时候我们需要使用第三方的 Filter,如果这些 Filter 没有加注解那么如何使之生效呢?

SpringBoot 颠覆了传统的 web 开发,其中一个表现就是没有了 web.xml,所以也没法在 web.xml 中进行配置。这里我们需要这样做:首先去掉 @Component 注解,现在将 TimeFilter 当做一个第三方的过滤器。在 filter 包下新建一个类叫做 WebConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package lab.zlren.demo.filter;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

/**
* @author zlren
* @date 2017-11-25
*/
@Configuration
public class WebConfig {

@Bean
public FilterRegistrationBean timeFilter() {
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
TimeFilter timeFilter = new TimeFilter();
registrationBean.setFilter(timeFilter);

List<String> urls = new ArrayList<>();
urls.add("/*");
registrationBean.setUrlPatterns(urls);

return registrationBean;
}
}

其实这样写和传统的在 web.xml 中配置是一样的,可以看到这样写的情况下还可以配置 TimeFilter 这个过滤器在哪些 url 中起作用。配置完成后重启服务,和刚刚的效果是一致的

在 Filter 中我们只能拿到请求的 request 和 response,但是这个请求到底交由哪个 Controller 的哪个方法去处理我们是不知道的。Filter 的接口是 j2ee 的规范来定义的,而它并不了解 SpringMVC 中的具体实现。如果需要拿到处理这个请求的具体的方法,我们需要使用 Interceptor

拦截器 Interceptor

新建 package:lab.zlren.demo.interceptor,在这个包下新建一个类叫做 TimeInterceptor,它需要实现的接口是 org.springframework.web.servlet.HandlerInterceptor。这个接口下有 3 个方法分别是 preHandle、postHandle 和 afterCompletion,其中 pre 和 post 就是在 Controller 中的方法被调用的前后分别执行的。需要注意的是,如果在 Controller 的方法中抛出了异常,post 就不会被调用了,但是无论异常与否 after 都是会被调用的。

在 Filter 中,我们可以在一个方法也就是 doFilter 中完成前后时间的记录并计算时间差值,这里拦截器有两个方法,所以需要在 request 中设置 key-value 的形式记录并传值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package lab.zlren.demo.interceptor;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

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

/**
* @author zlren
* @date 2017-11-25
*/
@Slf4j
@Component
public class TimeInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler)
throws
Exception {
log.info("preHandle");
httpServletRequest.setAttribute("START_TIME", System.currentTimeMillis());

// 这里打印了controller方法的类名和方法名
log.info("{}", ((HandlerMethod) handler).getBean().getClass().getName());
log.info("{}", ((HandlerMethod) handler).getMethod().getName());

return true;
}

@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o,
ModelAndView modelAndView) throws Exception {
log.info("postHandle");
long startTime = (long) httpServletRequest.getAttribute("START_TIME");
log.info("耗时:{}", System.currentTimeMillis() - startTime);
}

@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o,
Exception e) throws Exception {
log.info("afterCompletion");
long startTime = (long) httpServletRequest.getAttribute("START_TIME");
log.info("耗时:{}", System.currentTimeMillis() - startTime);
log.error("{}", e);
}
}

pre 方法中除了记录起始时间外还通过第三个参数拿到了被调用的 Controller 方法的具体信息,这也是它的优势。另外 post 和 after 方法都记录了时间,读者可以在 Controller 中尝试抛异常与不抛异常两种情况下查看日志的打印情况

Interceptor 只声明为 Component 还不够,为了让它生效我们需要改写之前写的 WebConfig,将其继承 org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter,并覆盖它的 addInterceptors 方法。这样一来就可以生效了,读者可以进行测试,这里只给出了正常情况下的 log 打印情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package lab.zlren.demo.filter;

import lab.zlren.demo.interceptor.TimeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

/**
* @author zlren
* @date 2017-11-25
*/
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

@Autowired
private TimeInterceptor timeInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timeInterceptor);
super.addInterceptors(registry);
}

// @Bean
// public FilterRegistrationBean timeFilter() {
// FilterRegistrationBean registrationBean = new FilterRegistrationBean();
// TimeFilter timeFilter = new TimeFilter();
// registrationBean.setFilter(timeFilter);
//
// List<String> urls = new ArrayList<>();
// urls.add("/*");
// registrationBean.setUrlPatterns(urls);
//
// return registrationBean;
// }
}

Interceptor 相比 Filter 可以拿到具体处理业务的方法的 Controller 和方法名,它还有一个不足,那就是拿不到这个方法的参数值(通过分析 SpringMVC 的源码可以看到拼接参数值的操作在 pre 之后),如果想拿到参数值会怎么样呢?这里就要使用切片(Aspect)了

切片 Aspect

什么是切片?这是 Spring 的核心功能之一(另一个是 IoC)。所谓切片实际上就是一个类,什么样的类可以称作是一个 Aspect 呢?需要具备以下两个条件

  • 切入点:使用注解进行配置,它决定了这个切片在哪些方法上起作用以及在什么时候起作用
  • 增强:听着这么不沾边实际上它就是一个方法,定义了起作用的时候的具体要执行的业务逻辑

开始前需要引入 aop 的依赖,在 pom.xml 中

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

同样我们新建一个 package 叫做 aspect,在这个包下新建一个类叫做 TimeAspect。在什么时候起作用的配置是由我们使用的注解来确定的,相关的注解有 4 个:@Before、@After、@AfterThrowing 以及 @Around,其中前三个对比 Interceptor 很容易理解,最后一个 Around 包含了上述三种情况,一般情况下我们自定义切片使用 Around 即可。如何定义在哪些方法上起作用?这是由表达式来决定的,这里不作具体的描述,可以参考这里

这里为了可以拿到 Controller 方法里面的参数我们对 demo 方法进行简单的改造,为其加上参数(name)

1
2
3
4
@GetMapping("/demo")
public String demo(@RequestParam String name) {
return "hello world";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package lab.zlren.demo.aspect;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

/**
* @author zlren
* @date 2017-11-25
*/
@Aspect
@Component
@Slf4j
public class TimeAspect {

@Around("execution(* lab.zlren.demo.controller.DemoController.*(..))")
public Object handleControllerMethod(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {

log.info("TimeAspect start");

// 方法参数
Object[] args = proceedingJoinPoint.getArgs();
for (Object arg : args) {
log.info("arg is {}", arg);
}

long startTime = System.currentTimeMillis();

// 执行被拦截的方法并拿到返回值
Object o = proceedingJoinPoint.proceed();

log.info("TimeAspect end:{}", System.currentTimeMillis() - startTime);

return o;
}
}

响应的访问的 url 也进行了简单的调整,加上 name 参数:localhost:8080/demo?name=zhangsan。可以看到控制台中已经打印出了此次请求携带的具体参数的信息

思考

上面的三种拦截机制我们进行了初步的了解,它们各有各的优势和不足:Interceptor 相比 Filter 可以拿到具体的方法,Aspect 相比 Interceptor 可以拿到具体的参数信息

实际上在上面的过程中我同一时刻只允许一种机制生效,如果把上面的三种拦截机制都打开,他们会同时生效吗?会发生冲突导致异常吗?他们的执行顺序是怎么样的?有兴趣的读者可以进行进一步的探索

这里告诉大家它们的执行顺序是 Filter -> Interceptor -> Aspect,而在 Controller 的方法中如果出现了异常,捕获的顺序和这里是相反的。实际上在 Interceptor 和 Aspect 中间还有一层叫做 ControllerAdvice,它是什么?留给读者思考