很多时候我们需要对每个 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;@Slf 4j@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;@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;@Slf 4j@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()); 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;@Configuration public class WebConfig extends WebMvcConfigurerAdapter { @Autowired private TimeInterceptor timeInterceptor; @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(timeInterceptor); super .addInterceptors(registry); } }
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;@Aspect @Component @Slf 4jpublic 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,它是什么?留给读者思考