记一次自定义拦截器失效的问题排查

背景

项目中使用swagger来自动生成接口文档,为了防止接口文档地址在外网被访问,需要对swagger的静态资源链接以及动态接口请求根据host属性做一些处理,使得外网域名不能访问接口文档地址。一个容易想到的办法就是通过Spring的Interceptor来实现,在preHandle方法中对swagger的请求路径做拦截,如果是外网域名请求,直接返回403。

代码示例
public class SwaggerInterceptor implements HandlerInterceptor {

    // 外网访问时需要排除的Swagger URL,其中/swagger-ui.html是接口文档首页UI地址,/v2/api-docs是动态请求接口,返回json格式的接口详细信息,两个都需要拦截
    private static final List EXCLUDE_URL = Arrays.asList("tes1-kids.youdao.com/v2/api-docs", "test1-kids.youdao.com/swagger-ui.html");

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    String url = request.getRequestURL().toString();
    // 判断请求路径是否在排除列表中,如果在返回403
    if (EXCLUDE_URL.stream().anyMatch(item -> url.contains(item))) {
        response.sendError(403);
    }
    return HandlerInterceptor.super.preHandle(request, response, handler);
  }
}
出现的问题

SwaggerInterceptor可以拦截到/swagger-ui.html请求,但是始终无法拦截/v2/api-docs接口,用户还是可以通过直接访 /v2/api-docs获取所有接口的json格式信息。

问题排查

首先,检查拦截器的配置,发现SwaggerInterceptor配置是拦截全部请求addPathPatterns("/**"),并且可以拦截到/swagger-ui.html,确认不是拦截器全局配置的问题。

接下来,就具体看下/v2/api-docs这个接口的请求执行情况,基于Spring MVC请求的入口类为DispatchServlet,核心的入口方法doDispatch源码如下:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    ......
    try {
        // HandlerMapping根据请求路径选择对应的handler(controller下的某个方法)来处理当前请求
        // 补充下HandlerMapping:
        // 1. 根据当前请求的找到对应的 Handler,
        // 2. 将 Handler(执行程序)与一堆 HandlerInterceptor(拦截器)封装到 HandlerExecutionChain 对象中
        // 3. DispatcherServlet会从容器中取出所有HandlerMapping实例并遍历,让HandlerMapping实例根据自己实现类的方式去尝试查找Handler
        mappedHandler = getHandler(processedRequest);
        ......
        // 根据handler来找到支持它的HandlerAdapter,通过HandlerAdapter执行这个最后的代码处理逻辑得到具体的返回结果
        HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
        ......
        // 拦截器preHandle处理,按顺序执行
        if (!mappedHandler.applyPreHandle(processedRequest, response)) {
            return;
        }
        // handlerAdapter实际的执行逻辑
        mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
        ......
        // 拦截器postHandle处理
        mappedHandler.applyPostHandle(processedRequest, response, mv);
    } catch (Throwable err) {
            ......
    } finally {
            ......
    }
}

在doDispatch方法中,拦截器的preHandle执行逻辑在mappedHandler.applyPreHandle中,接下来我看下这个方法:

boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
    // 遍历handler绑定的所有interceptors,按顺序执行preHanlde方法
    for (int i = 0; i < this.interceptorList.size(); i++) {
        HandlerInterceptor interceptor = this.interceptorList.get(i);
        // 如果preHandle返回false,则触发afterCompletion方法的执行
        if (!interceptor.preHandle(request, response, this.handler)) {
            triggerAfterCompletion(request, response, null);
            return false;
        }
        this.interceptorIndex = I;
    }
    return true;
}

debug跟踪到这个方法,发现applyPreHandle方法中this.interceptorList的长度为0,即处理该请求的handler没有绑定任何interceptor。这个时候很容易想到问题可能出现在handlerMapping上,因为handlerMapping负责将handler与一堆 handlerInterceptor(拦截器)封装到 HandlerExecutionChain 对象中。

我们往前在具体看下doDispatch中的getHandler方法:

protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    // DispatcherServlet会从容器中取出所有HandlerMapping实例并遍历,让HandlerMapping实例根据自己实现类的方式去尝试查找Handler
    if (this.handlerMappings != null) {
        for (HandlerMapping mapping : this.handlerMappings) {
            HandlerExecutionChain handler = mapping.getHandler(request);
            if (handler != null) {
                return handler;
            }
        }
    }
    return null;
}

debug到这里,发现DispatcherServlet关联的handleMapping实例列表中,除了默认的四个HandleMapping实现,还多了一个自定义的HandlerMapping,即WebMvcPropertySourcedRequestMappingHandlerMapping,且优先级最高,这个handlerMapping是在Swagger的类库中定义的。

image-20210411112305354

默认的四个HandlerMapping实现都会在实例化的过程中绑定自定义的拦截器,下面的源码为WebMvcConfigurationSupport中RequestMappingHandlerMapping实例化的过程,可以看到创建对象后会设置Interceptors成员变量,从而绑定interceptors。

RequestMappingHandlerMapping mapping = createRequestMappingHandlerMapping();
mapping.setOrder(0);
// 绑定上下文中的自定义拦截器
mapping.setInterceptors(getInterceptors(conversionService, resourceUrlProvider));
mapping.setContentNegotiationManager(contentNegotiationManager);
mapping.setCorsConfigurations(getCorsConfigurations());

到现在我们就能够基本确定问题出在WebMvcPropertySourcedRequestMappingHandlerMapping这个自定义HandlerMapping上,它的实例化过程中应该没有绑定自定义的拦截器,同时/v2/api-docs接口是由这个HandlerMapping负责选择具体执行请求的handler,导致拦截器失效。下面我们再通过swagger的源码验证下:

// Swagger2DocumentationWebMvcConfiguration类
@Bean
public HandlerMapping swagger2ControllerMapping(
    Environment environment,
  DocumentationCache documentationCache,
  ServiceModelToSwagger2Mapper mapper,
  JsonSerializer jsonSerializer) {
  // 创建WebMvcPropertySourcedRequestMappingHandlerMapping实例,并没有调用setIntercetors设置拦截器
  // 同时构造方法第二个参数为处理/v2/api-docs接口的具体handler(Swagger2ControllerWebMVC)
  return new WebMvcPropertySourcedRequestMappingHandlerMapping(
    environment,
       new Swagger2ControllerWebMvc(environment, documentationCache, mapper, jsonSerializer));
}

至此,问题的原因已找到,在讨论具体解决方法之前,我们先大致的看下WebMvcPropertySourcedRequestMappingHandlerMapping这个自定义handlerMapping的作用。

我们查看Swagger2ControllerWebMvc的getDocumentation方法,原来这里可以动态地指定getDocumentation这个方法的请求路径,在application.properties设置springfox.documentation.swagger.v2.path这个key的值,即可覆盖掉默认的URL。

@RequestMapping(
    value = DEFAULT_URL,  // 这里DEFAULT_URL即为/v2/api-docs
    method = RequestMethod.GET,
    produces = { APPLICATION_JSON_VALUE, HAL_MEDIA_TYPE })
@PropertySourcedMapping(
    value = "${springfox.documentation.swagger.v2.path}",
    propertyKey = "springfox.documentation.swagger.v2.path")
@ResponseBody
public ResponseEntity getDocumentation(
    @RequestParam(value = "group", required = false) String swaggerGroup,
     HttpServletRequest servletRequest) {
        ......
}

看到这里我们不难发现,WebMvcPropertySourcedRequestMappingHandlerMapping的主要作用就是实现请求路径的动态配置,让请求更加灵活。

接下来我们来看看如何解决这个问题。

解决方法

主要有三种方式:

  1. ResponseBodyAdvice
  2. 通过拦截器实现,WebMvcPropertySourcedRequestMappingHandlerMapping实例化的过程中绑定自定义拦截器。
  3. Spring AOP LTW
方法1:ResponseBodyAdvice

既然自定的HanderMapping初始化未绑定拦截器,导致拦截器失效,那我们换一种思路,通过ResponseBodyAdvice配合@ControllerAdvice注解,在请求响应体返回之前,校验请求URL,若为外网请求的Swagger路径,返回403,代码如下:

@RestControllerAdvice
public class SwaggerAdvice implements ResponseBodyAdvice {

    private static final List EXCLUDE_URL = Arrays.asList("test1-kids.youdao.com/v2/api-docs");

    @Override
    public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
        String url = serverHttpRequest.getURI().getHost() + serverHttpRequest.getURI().getPath();
        if (EXCLUDE_URL.stream().anyMatch(item -> url.contains(item))) {
            serverHttpResponse.setStatusCode(HttpStatus.FORBIDDEN);
            return "403";
        }
        return o;
    }

    @Override
    public boolean supports(MethodParameter methodParameter, Class aClass) {
        return true;
    }
}
方法2:HandlerMapping初始化绑定拦截器

如果还是想用拦截器来解决这个问题,基本思路就是我们自己在WebMvcPropertySourcedRequestMappingHandlerMapping初始化的时候手动绑定拦截器,这样自定的拦截器就不会失效了,代码如下:

// 创建WebMvcPropertySourcedRequestMappingHandlerMapping的子类
// 这里的主要作用就是重写initHandlerMethods设置order的优先级为最高
public class SwaggerRequestMappingHandlerMapping extends WebMvcPropertySourcedRequestMappingHandlerMapping {

    public SwaggerRequestMappingHandlerMapping(Environment environment, Object handler) {
        super(environment, handler);
    }

    @Override
    protected void initHandlerMethods() {
        super.initHandlerMethods();
        // WebMvcPropertySourcedRequestMappingHandlerMapping的优先级为Ordered.HIGHEST_PRECEDENCE + 1000
        this.setOrder(Ordered.HIGHEST_PRECEDENCE + 999);
    }
}

// 参照RequestMappingHandlerMapping的初始化过程,这里需要注意的是需要继承DelegatingWebMvcConfiguration
// 直接继承WebMvcConfigurationSupport无法获取到上下文中的拦截器列表
@Configuration
public class HandlerMappingConfig extends DelegatingWebMvcConfiguration {

    @Bean
    public HandlerMapping newSwagger2ControllerMapping(
            Environment environment,
            DocumentationCache documentationCache,
            ServiceModelToSwagger2Mapper mapper,
            JsonSerializer jsonSerializer,
            @Qualifier("mvcConversionService") FormattingConversionService conversionService,
            @Qualifier("mvcResourceUrlProvider") ResourceUrlProvider resourceUrlProvider) {
        SwaggerRequestMappingHandlerMapping newSwagger2ControllerMapping =
                new SwaggerRequestMappingHandlerMapping(
                        environment,
                        new Swagger2ControllerWebMvc(environment, documentationCache, mapper, jsonSerializer));
        // 关联拦截器列表
        newSwagger2ControllerMapping.setInterceptors(
                getInterceptors(conversionService, resourceUrlProvider));
        return newSwagger2ControllerMapping;
    }
}
方法三:Spring AOP LTW

可以考虑基于Spring AOP机制来实现,在/v2/api-docs请求对应的方法执行前,判断请求是否为外网请求。示例代码如下:

// 开启基于CGLIB实现的动态代理
@SpringBootApplication(scanBasePackages = {"com.youdao.kids", "springfox"})
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class SwaggerExampleApplication {

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

}

// 定义一个切面,在方法执行前,拦截外网请求
@Aspect
@Component
public class SwaggerAspect {

    private static final List EXCLUDE_URL = Arrays.asList("test1-kids.youdao.com/v2/api-docs");

    @Before("execution(* springfox.documentation.swagger2.web.Swagger2ControllerWebMvc.getDocumentation(..))")
    public void switchDataSource() throws Exception {
        ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        HttpServletResponse response = attributes.getResponse();
        String url = request.getRequestURL().toString();
        if (EXCLUDE_URL.stream().anyMatch(item -> url.contains(item))) {
            response.sendError(403);
        }
    }
}

当我们运行上述代码的时候,会发现这个切面对于Swagger2ControllerWebMvc是失效的,无法拦截对应的getDocumentation方法。

原因是什么呢,这个时候我们可能就会想到,基于动态代理的AOP实现只能代理Spring容器管理的bean,在代码的运行过程中动态地为这些bean创建代理对象,从而达到增强bean的目的。

回到我们这个场景,查看Swagger2ControllerWebMvc的源码,发现这个类有controller的注解,应该可以被代理,但却为什么不生效呢。这个时候我们再回到WebMvcPropertySourcedRequestMappingHandlerMapping的初始化过程,发现这个handlerMapping不是通过Ioc的方式拿到Swagger2ControllerWebMvc的实例,而是直接new了一个Swagger2ControllerWebMvc对象。这个时候问题就清楚了,自己new的对象是不能被Spring容器管理的,导致AOP失效。

// 有controller注解,理应可以被代理
@Controller
@ConditionalOnClass(name = "javax.servlet.http.HttpServletRequest")
@ApiIgnore
public class Swagger2ControllerWebMvc {
 ......
}

// 直接new了一个Swagger2ControllerWebMvc对应与SwaggerRequestMappingHandlerMapping关联
SwaggerRequestMappingHandlerMapping newSwagger2ControllerMapping =
                new SwaggerRequestMappingHandlerMapping(
                        environment,
                        new Swagger2ControllerWebMvc(environment, documentationCache, mapper, jsonSerializer));

那么有没有什么方法可以对这种第三方类库的非spring bean对象进行AOP增强呢。

答案是基于AspectJ的AOP实现。基于AspectJ的实现有三种织入方式

  • 编译期织入:利用ajc编译器替代javac编译器,直接将源文件(Java和Aspect)编译成class文件并将切面织入代码中
  • 编译后织入:利用ajc编译器将javac编译器编译后的class文件或者jar文件织入切面代码
  • 加载时织入:不使用ajc编译器,利用aspectjweaver.jar工具,使用java agent代理在类加载阶段将切面织入代码

前两种不太适合我们的场景,这里我们着重看下类加载时织入的实现,即Spring AOP LTW(Load Time Weaving)。

主要步骤如下:

增加maven依赖

    org.aspectj
    aspectjrt
    1.8.13


    org.aspectj
    aspectjweaver
    1.8.13

添加aop.xml文件
image-20210412113515722

文件内容:




    
    
        
        
    
    
    
        
    

开启EnableLoadTimeWeaving
@SpringBootApplication(scanBasePackages = {"com.youdao.kids", "springfox"})
@EnableLoadTimeWeaving(aspectjWeaving = EnableLoadTimeWeaving.AspectJWeaving.AUTODETECT)
public class SwaggerExampleApplication {

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

}

@Aspect
public class SwaggerAspect {

    private static final List EXCLUDE_URL = Arrays.asList("test1-kids.youdao.com/v2/api-docs");

    @Before("execution(* springfox.documentation.swagger2.web.Swagger2ControllerWebMvc.getDocumentation(..))")
    public void switchDataSource() throws Exception {
        ServletRequestAttributes attributes = (ServletRequestAttributes)RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        HttpServletResponse response = attributes.getResponse();
        String url = request.getRequestURL().toString();
        if (EXCLUDE_URL.stream().anyMatch(item -> url.contains(item))) {
            response.sendError(403);
        }
    }
}
添加javaagent启动参数
// 这里需要把这两个jar包提前放到工程的lib的目录下
-javaagent:"lib/aspectjweaver-1.8.13.jar" -javaagent:"lib/spring-instrument-5.1.9.RELEASE.jar"

至此,基于LTW的实现完成了,也可以做到拦截/v2/api-docs接口的外网请求。

代码地址

需要demo代码地址请私信

你可能感兴趣的:(记一次自定义拦截器失效的问题排查)