深入解析拦截 Controller 请求参数:实现与细节

189 阅读4分钟

在 Web 开发中,拦截请求参数是一个常见需求,特别是在需要记录、验证或处理请求的场景中。本文将详细解析通过拦截器、包装器和过滤器来获取 Controller 请求参数的实现方式,并介绍关键细节与注意事项。

1. 背景

  • 现代 Web 应用中,通常需要记录 Controller 接口的请求日志。对于 HTTP 请求,参数可能包括:
URL 路径
请求头信息
请求体(body)
  • 其中,body 数据尤其重要,但由于 HTTP 请求体只能读取一次的限制(流会被消费),读取请求体需要特殊处理。

2. 主要实现类

  • 本文代码实现分为三个关键部分:
  1. 拦截器(ControllerLogInterceptor):切面拦截请求日志。
  2. 请求包装器(BodyReaderHttpServletRequestWrapper):解决请求体只能读取一次的问题。
  3. 过滤器(BodyFilter):确保所有请求都能通过包装器读取多次。

2.1 拦截器:ControllerLogInterceptor

  • 通过 Spring AOP 实现切面,拦截带有 @RestController 或 @Controller 注解的类。
  • 核心代码解析:
@Aspect
public class ControllerLogInterceptor {

    private static final Logger LOGGER = LoggerFactory.getLogger(ControllerLogInterceptor.class);

    public ControllerLogInterceptor() {

    }

    @Pointcut("@within(org.springframework.web.bind.annotation.RestController)" +
        "||@within(org.springframework.stereotype.Controller)")
    private void businessLog() {

    }

    /**
     * 日志获取切面
     *
     * @param pjp 切点
     * @return 日志数据
     * @throws Throwable 异常
     */
    @Around("businessLog()")
    @Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
    public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
        // controller 参数
        Object[] args = pjp.getArgs();

        // 获取请求方法参数,目前只记录 HTTP 请求
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            LOGGER.debug("非 HTTP 请求跳过拦截");
            return pjp.proceed(args);
        }

        // 获取请求路径
        HttpServletRequest request = attributes.getRequest();
        String apiCode = request.getServletPath();

        // 获取请求body
        Object requestBody = null;
        String bodyStr = null;
        if ((request instanceof BodyReaderHttpServletRequestWrapper)) {
            BodyReaderHttpServletRequestWrapper requestWrapper = (BodyReaderHttpServletRequestWrapper) request;
            // 请求 body
            bodyStr = requestWrapper.getBodyString();
        }
        if (StringUtils.hasText(bodyStr)) {
            // 目前只支持 json body
            requestBody = JSON.parse(bodyStr);
        } else if (args != null) {
            requestBody = parseArgArray(args);
        }
        return requestBody;
    }

    private Object parseArgArray(Object[] args) {
        // 解析 controller 接口方法的参数列表
        Object[] filterArray = Arrays.stream(args)
            .filter(arg -> !(arg instanceof HttpServletRequest || arg instanceof HttpServletResponse)).toArray();
        return filterArray.length > 1 ? JSON.toJSON(args) : JSON.toJSON(args[0]);
    }

}
  • 功能细节
  1. 切入点声明:通过 @Pointcut 定义拦截规则,仅拦截 @RestController 或 @Controller 注解的类。
  2. 读取请求体:利用自定义包装器,确保请求体可多次读取。
  3. 日志记录:记录请求路径和请求体(支持 JSON)。
  4. 当请求体为空时,通过 parseArgArray 提取方法参数。过滤掉 HttpServletRequest 和 HttpServletResponse 等不必要的对象。
  5. 单参数优化:如果参数列表只有一个,直接序列化该参数,简化记录内容。

2.2 请求包装器:BodyReaderHttpServletRequestWrapper

  • 为了解决请求体只能读取一次的问题,自定义包装器类重写 HttpServletRequestWrapper
public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {

    private final byte[] body;

    public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
        this.body = getBodyString(request).getBytes(StandardCharsets.UTF_8);
    }

    public String getBodyString() {
        return new String(body, StandardCharsets.UTF_8);
    }

    /**
     * 获取请求Body
     *
     * @param request 请求
     * @return {@link String}
     */
    private String getBodyString(final ServletRequest request) {
        StringBuilder sb = new StringBuilder();
        InputStream inputStream = null;
        BufferedReader reader = null;
        try {
            inputStream = cloneInputStream(request.getInputStream());
            reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
        } catch (IOException e) {
            LogUtils.error(e.getMessage(), e);
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    LogUtils.error(e.getMessage(), e);
                }
            }
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    LogUtils.error(e.getMessage(), e);
                }
            }
        }
        return sb.toString();
    }

    /**
     * 复制输入流
     *
     * @param inputStream 输入流
     * @return {@link InputStream}
     */
    public InputStream cloneInputStream(ServletInputStream inputStream) {
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        byte[] buffer = new byte[1024];
        int len;
        try {
            while ((len = inputStream.read(buffer)) > -1) {
                byteArrayOutputStream.write(buffer, 0, len);
            }
            byteArrayOutputStream.flush();
        } catch (IOException e) {
            LogUtils.error(e.getMessage(), e);
        }
        return new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    @Override
    public ServletInputStream getInputStream() {

        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body);

        return new ServletInputStream() {

            @Override
            public int read() {
                return byteArrayInputStream.read();
            }

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

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

            @Override
            public void setReadListener(ReadListener readListener) {

            }
        };
    }
}
  • 功能细节
  1. 缓存请求体:在构造函数中读取并缓存请求体。
  2. 多次读取支持:重写 getInputStream 和 getReader,返回缓存流。

2.3 过滤器:BodyFilter

  • 将所有非文件上传的 HTTP 请求包装为 BodyReaderHttpServletRequestWrapper
public class BodyFilter implements Filter {

    private static final String MULTIPART = "multipart";

    private static final String X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded";

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
        throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        // 判断是否为文件上传
        if (isFileApi(httpRequest)) {
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }
        servletRequest = new BodyReaderHttpServletRequestWrapper(httpRequest);
        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }

    /**
     * 判断是否为文件上传接口
     *
     * @param request 请求
     * @return 是否为文件上传接口 boolean
     */
    private boolean isFileApi(HttpServletRequest request) {
        String contentType = request.getContentType();
        if (StringUtils.hasText(contentType) && contentType.contains(MULTIPART)) {
            return true;
        }
        if (StringUtils.hasText(contentType) && contentType.contains(X_WWW_FORM_URLENCODED)) {
            return true;
        }
        return false;
    }

}
  • 功能细节
  1. 文件上传排除:通过 isFileApi 判断是否为文件上传请求。
  2. 包装请求:对非文件请求进行包装,确保请求体可重复读取。

3. 实现细节与注意事项

  1. 请求体读取限制:HttpServletRequest 的请求体只能读取一次,使用包装器可解决此问题。
  2. JSON 解析:当前实现仅支持解析 JSON 格式的请求体,其他格式需扩展。
  3. 性能考虑:对于大体积请求体,缓存可能会占用更多内存,需根据业务场景权衡。
  4. 文件上传排除:文件流的读取与普通请求体不同,需单独处理。

4. 总结

通过拦截器、包装器和过滤器的组合,实现了对 Controller 请求参数的完整拦截与日志记录。该方案兼顾了可读性与可扩展性,为 Web 应用的日志管理和参数处理提供了有效支持。