SpringBoot打印请求体与响应体

一、前言

在工作中,出现了需要打印每次请求中调用方传过来的requestBody的需求

出现这个需求的原因是我在和某平台做联调工作,出现了一个比较恶心的情况。

有一些事件通知需要由他们调用我们的http接口来实现事件通知,但是这个http接口的数据格式是由他们定义的(照搬其他地方的),而他们给的相关文档很烂,示例中缺乏某些字段,而字段表里的字段又没有分级,因此很难弄清楚他们请求的字段有哪些。

自己写的类不一定能正确反序列化它的所有字段,如果反序列化有误,不清楚它传来的xml长什么样子,也无法解决问题

总结一下问题原因:

  1. 我们写的接口,要由他们定义字段类型,但文档写的烂,字段定义的不清楚,不能提供维护以及答疑支持
  2. 配合程度有限,不能提供请求的xml

这两点带来的问题是当反序列化出现问题,不自己打印它们请求过来的xml,就没法快速找到问题原因,因此,需要我们通过某种手段打印出requestBody的内容

二、传统请求参数的打印

通常,最简单的HTTP GET请求可以通过写一个继承HandlerInterceptorAdapter的拦截器来实现,形如:

package com.chasel.interceptor;

import com.alibaba.fastjson.JSON;
import com.cmic.origin.internal.gateway.core.util.IpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Enumeration;
import java.util.Map;

/**
 * @author XieLongzhen
 * @date 2018/12/26 18:46
 */
@Slf4j
@Component
public class HttpInterceptor extends HandlerInterceptorAdapter {

    private ThreadLocal<Long> startTime = new ThreadLocal<>();

    /**
     * 预处理回调方法,实现处理器的预处理(如检查登陆),第三个参数为响应的处理器,自定义Controller
     * <p>
     * 返回值:
     * true表示继续流程(如调用下一个拦截器或处理器)
     * false表示流程中断(如登录检查失败),不会继续调用其他的拦截器或处理器
     * 此时我们需要通过response来产生响应;
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        startTime.set(System.currentTimeMillis());
        String uri = request.getRequestURI();
        Map paramMap = request.getParameterMap();
        log.info("用户访问地址:{}, 来路地址: {}, 请求参数: {}", uri, IpUtil.getRemoteIp(request), JSON.toJSON(paramMap));
        log.info("----------------请求头.start.....");
        Enumeration<String> enums = request.getHeaderNames();
        while (enums.hasMoreElements()) {
            String name = enums.nextElement();
            log.info(name + ": {}", request.getHeader(name));
        }
        log.info("----------------请求头.end!");
        return super.preHandle(request, response, handler);
    }


    /**
     * 在任何情况下都会对返回的请求做处理
     * <p>
     * 即在视图渲染完毕时回调,如性能监控中我们可以在此记录结束时间并输出消耗时间
     * 还可以进行一些资源清理,类似于try-catch-finally中的finally,但仅调用处理器执行链中
     *
     * @param request
     * @param response
     * @param handler
     * @param ex
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info("请求处理结束. 处理耗时: {}", System.currentTimeMillis() - startTime.get());
        startTime.remove();
        super.afterCompletion(request, response, handler, ex);
    }
}

三、为什么打印requestBody是一个问题?

请求参数可以通过 request.getParameterMap() 来获得,但要获取requestBody,只能通过request.getInputStream() 来获取输入流,但是由于request 的inputStream和response 的outputStream默认情况下是只能读一次,若在拦截器中读取打印了,后面业务就读取不到了(别想着读完还能写回去,死了这条心叭)

3.1 解决办法

在头痛烦闷的尝试了各种办法后偶然看了这篇文章受到了启发

https://stackoverflow.com/questions/10210645/http-servlet-request-lose-params-from-post-body-after-read-it-once?tdsourcetag=s_pctim_aiomsg

Spring为了解决这个问题,为Request与Response分别封装了 ContentCachingRequestWrapper 与 ContentCachingResponseWrapper 包裹类得这两个流信息可重复读(缓存机制,在读取输入流以后缓存下来)

3.1.1 初步解决方案

通过 ContentCachingRequestWrapper 这个类可以简单的实现requestBody的打印

package com.chasel.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author XieLongzhen
 * @date 2019/10/9 14:38
 */
@Slf4j
public class LogFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void destroy() {

    }

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

        ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request);
        ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper((HttpServletResponse) response);

        try {
            chain.doFilter(requestWrapper, responseWrapper);
        } finally {

            String requestBody = new String(requestWrapper.getContentAsByteArray());
            log.info("请求body: {}", requestBody);
        }

    }
}

然后就可以打印出请求body的内容了

3.1.2 解决方案优化

后来我又发现Spring提供了一个过滤器抽象类AbstractRequestLoggingFilter,它为请求日志的打印提供了更丰富的功能,但使用的时候也要注意一些小细节(小坑)

要使用这个过滤器,只要按照你的需要实现它的两个抽象类就可以

protected abstract void beforeRequest(HttpServletRequest request, String message);
protected abstract void afterRequest(HttpServletRequest request, String message);

核心代码如下

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

   boolean isFirstRequest = !isAsyncDispatch(request);
   HttpServletRequest requestToUse = request;

   if (isIncludePayload() && isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {
      requestToUse = new ContentCachingRequestWrapper(request, getMaxPayloadLength());
   }

   boolean shouldLog = shouldLog(requestToUse);
   if (shouldLog && isFirstRequest) {
      beforeRequest(requestToUse, getBeforeMessage(requestToUse));
   }
   try {
      filterChain.doFilter(requestToUse, response);
   }
   finally {
      if (shouldLog && !isAsyncStarted(requestToUse)) {
         afterRequest(requestToUse, getAfterMessage(requestToUse));
      }
   }
}

同样,你可以直接使用Spring提供的 AbstractRequestLoggingFilter 的实现类 ServletContextRequestLoggingFilter

public class ServletContextRequestLoggingFilter extends AbstractRequestLoggingFilter {

    /**
     * Writes a log message before the request is processed.
     */
    @Override
    protected void beforeRequest(HttpServletRequest request, String message) {
        getServletContext().log(message);
    }

    /**
     * Writes a log message after the request is processed.
     */
    @Override
    protected void afterRequest(HttpServletRequest request, String message) {
        getServletContext().log(message);
    }

}

使用Spring提供的过滤器的好处是,除了requestBody以外,还可以很方便的根据需要打印更详细请求信息,以下是 createMessage() 的完整代码

protected String createMessage(HttpServletRequest request, String prefix, String suffix) {
   StringBuilder msg = new StringBuilder();
   msg.append(prefix);
   msg.append("uri=").append(request.getRequestURI());

   if (isIncludeQueryString()) {
      String queryString = request.getQueryString();
      if (queryString != null) {
         msg.append('?').append(queryString);
      }
   }

   if (isIncludeClientInfo()) {
      String client = request.getRemoteAddr();
      if (StringUtils.hasLength(client)) {
         msg.append(";client=").append(client);
      }
      HttpSession session = request.getSession(false);
      if (session != null) {
         msg.append(";session=").append(session.getId());
      }
      String user = request.getRemoteUser();
      if (user != null) {
         msg.append(";user=").append(user);
      }
   }

   if (isIncludeHeaders()) {
      msg.append(";headers=").append(new ServletServerHttpRequest(request).getHeaders());
   }

   if (isIncludePayload()) {
      String payload = getMessagePayload(request);
      if (payload != null) {
         msg.append(";payload=").append(payload);
      }
   }

   msg.append(suffix);
   return msg.toString();
}

可以看到它能帮你生产的信息包含了uri、请求参数、客户端信息、会话信息、远程用户信息、headers以及payload,并且这些都是根据你的需要配置的

生成效果如下:

3.1.3 注册Filter

只需要在继承WebMvcConfigurationSupport的配置类中注册这个Filter即可

@Bean
public FilterRegistrationBean loggingFilterRegistration() {
    FilterRegistrationBean<ServletContextRequestLoggingFilter> registration = new FilterRegistrationBean<>();
    ServletContextRequestLoggingFilter filter = new ServletContextRequestLoggingFilter();
    filter.setIncludePayload(true);
    filter.setMaxPayloadLength(9999);
    registration.setFilter(filter);
    registration.setUrlPatterns(Collections.singleton("/notifications/*"));
    return registration;
}

3.1.4 遇到的坑

其中 setIncludePayload() 以及 setMaxPayloadLength() 就是我在使用中遇到的坑。因为AbstractRequestLoggingFilter 的includePayload属性的默认值是false,不会打印payload信息,同时maxPayloadLength默认值是50,会导致打印的requestBody不完整

贴一下它们的相关代码

protected String createMessage(HttpServletRequest request, String prefix, String suffix) {
   StringBuilder msg = new StringBuilder();
   msg.append(prefix);
   msg.append("uri=").append(request.getRequestURI());

   ...
    // 只有 includePayload 为true时才打印payload信息
   if (isIncludePayload()) {
      String payload = getMessagePayload(request);
      if (payload != null) {
         msg.append(";payload=").append(payload);
      }
   }

   msg.append(suffix);
   return msg.toString();
}

protected String getMessagePayload(HttpServletRequest request) {
   ContentCachingRequestWrapper wrapper =
         WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
   if (wrapper != null) {
      byte[] buf = wrapper.getContentAsByteArray();
      if (buf.length > 0) {
          // 取的是buf.length与maxPayloadLength的最小值
         int length = Math.min(buf.length, getMaxPayloadLength());
         try {
            return new String(buf, 0, length, wrapper.getCharacterEncoding());
         }
         catch (UnsupportedEncodingException ex) {
            return "[unknown]";
         }
      }
   }
   return null;
}

四、弊端

但是使用这两个包裹类会有一些潜在的问题,ContentCachingRequestWrapper类缓存请求是通过消耗输入流来进行缓存的,因此这是一个不小的代价,它使得过滤器链中的其他过滤器无法再读取输入流。

可见:https://github.com/spring-projects/spring-framework/issues/20577

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260