乐趣区

记一次拦截器配置

由于项目升级变更,开放给第三方接口参数中的时间参数由字符串类型变更为 Long 类型,为了第三方上传参数能像原来一样不受影响,解决的想法是在加一个过滤器,在请求体到达控制器之前,由过滤器将 json 对象里的时间字符串变更为时间戳。看起来简单,但实现过程中却遇到了不少问题。

提供过滤器

首先定义一个类实现 Filter 接口:

/**
 * 将时间戳由字符串过滤为 long
 */
@Component
@Order(1)
public class StringToLongOfTimeStampFilter implements Filter {private final static Logger logger = LoggerFactory.getLogger(StringToLongOfTimeStampFilter.class.getName());

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {logger.info("拦截链接" + ((HttpServletRequest) request).getRequestURI());

        chain.doFilter(request, response);
    }

}

接着提供过滤器:

    @Autowired
    StringToLongOfTimeStampFilter stringToLongOfTimeStampFilter;

   @Bean
    public FilterRegistrationBean<StringToLongOfTimeStampFilter> loggingFilter() {
        // 添加强检器具拦截器 并配置拦截 url
        FilterRegistrationBean<StringToLongOfTimeStampFilter> registrationBean
                = new FilterRegistrationBean<>();
        registrationBean.setFilter(stringToLongOfTimeStampFilter);
        registrationBean.addUrlPatterns("MandatoryInstrumentCheckApply/audit/*");
        return registrationBean;
    }

当我们访问 MandatoryInstrumentCheckApply/audit 这个路径时,看到控制台打印信息,就完成了过滤器的配置。但我在这里就遇到了问题,因为我一开始是用的单元测试进行过滤器的配置,但是一直没有看到拦截的信息,找了很久才发现原因。
单元测试用的是 mocmvn 模拟请求,它需要单独配置拦截器:

this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
                .addFilter(this.stringToLongOfTimeStampFilter, "MandatoryInstrumentCheckApply/audit/*") // 配置过滤器
                .apply(MockMvcRestDocumentation.documentationConfiguration(this.restDocumentation))
                .build();

参考链接

在过滤器中修改请求内容

注册好过滤器后,下一步就是修改 ServletRequest 里的内容了, 需要找到 ServletRequestjson数据并修改相应的字段值。
首先,可以通过 HttpServletRequestWrapper 包装 ServletRequest 来读取请求内容,但问题是他只提供了读取的方法,并没有提供修改的方法,解决办法是定义内部类继承HttpServletRequestWrapper,覆盖原本的 getInputStream 方法,达到修改请求体的目的。

/**
     * 自定义请求包装类
     * 由于 HttpServletRequestWrapper 没有提供重写请求体的方法
     * 因此使用自定义类继承 HttpServletRequestWrapper 覆盖 getInputStream()方法
     * 已达到重写请求体目的
     * https://stackoverflow.com/questions/34155480/how-to-change-servlet-request-body-in-java-filter
     */
    class CustomRequestWrapper extends HttpServletRequestWrapper {

        // 缓冲请求体数据
        private byte[] rawData;

        // HttpServletRequest
        private HttpServletRequest request;

        private ResettableServletInputStream servletStream;

        public CustomRequestWrapper(HttpServletRequest request) {super(request);
            this.request = request;
            this.servletStream = new ResettableServletInputStream();}

        // 重置请求体数据
        public void resetInputStream(byte[] newRawData) {servletStream.stream = new ByteArrayInputStream(newRawData);
        }

        // 覆盖 getInputStream() 达到修改请求体目的
        @Override
        public ServletInputStream getInputStream() throws IOException {if (rawData == null) {rawData = IOUtils.toByteArray(this.request.getReader());
                servletStream.stream = new ByteArrayInputStream(rawData);
            }
            return servletStream;
        }

        @Override
        public BufferedReader getReader() throws IOException {if (rawData == null) {rawData = IOUtils.toByteArray(this.request.getReader());
                servletStream.stream = new ByteArrayInputStream(rawData);
            }
            return new BufferedReader(new InputStreamReader(servletStream));
        }

        class ResettableServletInputStream extends ServletInputStream {

            private InputStream stream;

            @Override
            public int read() throws IOException {return stream.read();
            }

            @Override
            public boolean isFinished() {return false;}

            @Override
            public boolean isReady() {return false;}

            @Override
            public void setReadListener(ReadListener readListener) {}}
    }

自定义的内部类中提供了 resetInputStream 方法来修改请求体,所以在过滤器中可以使用自定义的内部类来实现请求内容的更改:

public String[] filterFileds = {"plannedCheckDate", "checkDate"};

@Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        CustomRequestWrapper wrappedRequest = new CustomRequestWrapper((HttpServletRequest) request);

        String body = IOUtils.toString(wrappedRequest.getReader());

        JSONObject oldJsonObject = JSON.parseObject(body);

        for (String filterFiled :
                filterFileds) {if (oldJsonObject.get(filterFiled) != null) {Date date = new Date((String) oldJsonObject.get(filterFiled));
                oldJsonObject.put(filterFiled, date.getTime());
            }
        }

        wrappedRequest.resetInputStream(oldJsonObject.toString().getBytes());

        chain.doFilter(wrappedRequest, response); // 修改后传递自定义的 HttpServletRequestWrapper
    }

参考链接

使用注解动态注册过滤器

本来以为大功告成,但经过潘老师提示,这样实现起始并不好,如果以后还需要在其他的路由上添加相应的字符串变时间戳的操作,就得去改源代码,并在注册拦截器里添加拦截的路由。期待的状态是添加注解 @StringDateToTimestampAnnotation(value = "过滤字段", url = "过滤路由"), 只要控制器方法上带有这个注解,就能在请求这个方法时自动的进行过滤操作,不需要多余的行为。
解决方法是在启动时扫描根包下所有带有 @Controller 注解的类,在类的方法上查找 @StringDateToTimestampAnnotation 这个注解,提取出 valueurl, 再注册到拦截器中。
spring 提供了 ClassPathScanningCandidateComponentProvider 这个类来供我们扫描, 修改过滤器注册代码:

@Bean
    public FilterRegistrationBean<StringToLongOfTimeStampFilter> loggingFilter() {
        // 扫描 com.mengyunzhi.measurement 包 找到所有 RestController 注解的类
        ClassPathScanningCandidateComponentProvider provider
                = new ClassPathScanningCandidateComponentProvider(false);
        provider.addIncludeFilter(new AnnotationTypeFilter(Controller.class)); // 添加包含的过滤信息
        for (BeanDefinition beanDef : provider.findCandidateComponents("com.mengyunzhi.measurement")) {
            Class<?> cl = null;
            try {cl = Class.forName(beanDef.getBeanClassName());
                // 查找 RestController 注解类下所有方法 包含 StringDateToTimestampAnnotation 提取出路径
                for (Method method :
                        cl.getDeclaredMethods()) {if (method.isAnnotationPresent(StringDateToTimestampAnnotation.class)) {StringDateToTimestampAnnotation annotation = method.getAnnotation(StringDateToTimestampAnnotation.class);
                        // 提取路由和过滤字段
                        stringToLongOfTimeStampFilter.pushPathAndFiles(annotation.url(), annotation.value());
                    }
                }
            } catch (ClassNotFoundException e) {e.printStackTrace();
            }
        }
        // 添加强检器具拦截器 并配置拦截 url
        FilterRegistrationBean<StringToLongOfTimeStampFilter> registrationBean
                = new FilterRegistrationBean<>();
        registrationBean.setFilter(stringToLongOfTimeStampFilter);
        for (String url : stringToLongOfTimeStampFilter.getFilterPath()) {registrationBean.addUrlPatterns(url);
        }
        return registrationBean;
    }

在注册拦截器时,先扫描根包下带有 @Controller 的类,再找到带有 @StringDateToTimestampAnnotation 注解的方法,提取出 valueurl保存到过滤器中,注册的时候再从过滤器中获得所有要过滤的路径。
参考链接:
Component Scan for custom annotation on Interface
深入 Spring: 自定义注解加载和使用
Spring find annotated classes

未解决的问题

问题是解决了,但是注解里的 url 实为冗余字段,因为它的值可以从 @RequestMapping@GetMapping@PutMapping里就能获取到,应该把它去除。
重新再查找资料,发现 spring 可以从 RequestMappingHandlerMapping 里获取到映射路由与处理方法的集合,可以直接从此集合中获取注解值与映射路由,这样一来,也不用扫描类了,因为可以直接使用 spirng 的扫描结果。

@Autowired
    private RequestMappingHandlerMapping requestMappingHandlerMapping;

@Bean
    public FilterRegistrationBean<StringToLongOfTimeStampFilter> loggingFilter() {Map<RequestMappingInfo, HandlerMethod> map = this.requestMappingHandlerMapping.getHandlerMethods();
        for (RequestMappingInfo requestMappingInfo: map.keySet()) {HandlerMethod handlerMethod = map.get(requestMappingInfo);

            StringDateToTimestampAnnotation annotation;
            if ((annotation = handlerMethod.getMethodAnnotation(StringDateToTimestampAnnotation.class)) != null) {
                // 提取路由和过滤字段
                for (String path: requestMappingInfo.getPatternsCondition().getPatterns()) {stringToLongOfTimeStampFilter.pushPathAndFiles(path, annotation.value());
                }
            }
        }
        // 添加强检器具拦截器 并配置拦截 url
        FilterRegistrationBean<StringToLongOfTimeStampFilter> registrationBean
                = new FilterRegistrationBean<>();
        registrationBean.setFilter(stringToLongOfTimeStampFilter);
        for (String url : stringToLongOfTimeStampFilter.getFilterPath()) {registrationBean.addUrlPatterns(url);
        }
        return registrationBean;
    }

本以为大功告成,但是它居然路由不匹配。。之前提供的路由是 MandatoryInstrumentCheckApply/audit/*, 它能够成功匹配MandatoryInstrumentCheckApply/audit/2, 然而映射的路由是MandatoryInstrumentCheckApply/audit/{id}, 提供到过滤器时居然不匹配。我其实在想,spring 为什么能将MandatoryInstrumentCheckApply/audit/{id}MandatoryInstrumentCheckApply/audit/2匹配上呢,如果修改过滤器路由匹配规则是不是就能匹配上了?然而找了半天也找不着修改过滤器路由匹配规则的办法。也不知道是没找着还是思路错了,只能暂时搁浅了。说到底,还是自己的能力太浅薄,对 spring 了解太浅。

退出移动版