统一请求日志输出2.0

还记得前面我们通过AOP切面类实现的统一请求响应日志输出方案嘛,经过后面interceptorfilter实践发现,未放行的请求日志信息并没有记录,很显然,AOP没有充当第一道防线!本节我们就此功能,升级改造为2.0版本。开干!

实现思路

现在我们已经意识到统一日志记录存在的漏洞,这对生产环境排查问题所造成的困扰是致命的打击。为此我们必须把请求日志记录放在请求发起后的第一个检查卡口。很显然,我们将用filter来实现,直接将request中的信息输出。但这里有一个坑:request.getInputStream()方法获取请求体内容后,流就输出结束,无法再调用第二次。也就是说,如果我们在filter中通过读取流写入请求日志后,在映射到controller方法,由spring web模块进行请求体到@requestBody注解的DTO对象的转换时,会报错,究其原因,罪魁祸首就是request.getInputStream()已经没有内容返回了。

要解决这个问题,spring web模块提供了一个ContentCachingRequestWrapper类,采用了装饰器模式,这里我贴出部分源码:

对原始的HttpServletRequest进行了包装,我们在调用其getInputStream()方法时,其实内部调用的是原始request的相应方法获取输入流,其内部也是对流进行了一层包装,说到底最终操作的还是原始请求的流对象。它的使用场景归结为四个字:先斩后奏,当我们调用一次其getInputStream()方法后,就可以通过调用其getContentAsByteArray()方法得到缓存的内容。同样的问题还是没解决,我们的日志记录场景并不是"先斩后奏"的形式,关于"先斩后奏"形式的请求体日志输出可以参考这篇教程:在SpringBoot中缓存HTTP请求响应体(实现请求响应日志的记录)

现在我们必须换一种实现方式,思路不变,还是使用装饰器模式,只不过在内部,我们不会再对原始request获取的流对象进行装饰,而是基于已经读取的字节数组来新创建一个ByteArrayInputStream类型的流对象,对其装饰。

定义包装器类

按照前面的思路,我们定义如下包装器类:

java 复制代码
package com.xiaojuan.boot.common.web.util;

import ...

public class CachedHttpServletRequestWrapper extends HttpServletRequestWrapper {

    private byte[] cachedByteArray;

    @SneakyThrows
    public CachedHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
        InputStream in = request.getInputStream();
        this.cachedByteArray = StreamUtils.copyToByteArray(in);
    }

    @Override
    public ServletInputStream getInputStream() {
        return new CachedServletInputStream(this.cachedByteArray);
    }

    @Override
    public BufferedReader getReader() {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedByteArray);
        return new BufferedReader(new InputStreamReader(byteArrayInputStream));
    }
}

代码说明

这里我们的包装类从HttpServletRequestWrapper继承,重写了必要的getInputStream()getReader()方法。

在构造器中,我们就进行流的读取和拷贝,并将结果缓存到cachedByteArray中,这样,未来再调用包装器对象的getInputStream()getReader()方法时,将缓存的内容包装返回即可。

java 复制代码
package com.xiaojuan.boot.common.web.util;

import ...

@Slf4j
public class CachedServletInputStream extends ServletInputStream {

    private InputStream cachedInputStream;

    public CachedServletInputStream(byte[] cachedByteArray) {
        this.cachedInputStream = new ByteArrayInputStream(cachedByteArray);
    }

    @Override
    public boolean isFinished() {
        try {
            return cachedInputStream.available() == 0;
        } catch (IOException ex) {
            log.error(ex.getMessage());
        }
        return false;
    }

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

    @Override
    public void setReadListener(ReadListener readListener) {
        throw new UnsupportedOperationException();
    }

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

代码说明

这里我们采用了委托的设计思想,基于已读取得字节数组的缓存数据来构建一个内部的ByteArrayInputStream对象,在调用流的isFinished()或者read()方法时,实际上委托了给了依赖的对象来完成相同的操作的。

请求日志过滤器

java 复制代码
package com.xiaojuan.boot.web.filter;

import ...

@Slf4j
public class RequestLogFilter extends OncePerRequestFilter {

    @Resource
    private ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("====================================================================================================");
        log.info("========== {}: {}", request.getMethod(), request.getRequestURL());
        logParams(request);

        CachedHttpServletRequestWrapper requestWrapper = new CachedHttpServletRequestWrapper(request);
        logRequestBody(requestWrapper);

        filterChain.doFilter(requestWrapper, response);
    }

    private void logParams(HttpServletRequest request) {
        ...
    }

    @SneakyThrows
    private void logRequestBody(CachedHttpServletRequestWrapper request) {

        if (StringUtils.equals(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
            log.info("========== body: {}", objectMapper.readTree(request.getInputStream()));
        }
    }
}

代码说明

在所有请求到达后续的filterinterceptor并最终由映射到的controller处理前,先被RequestLogFilter拦截到,进行请求日志的输出,然后放行。在输出请求体内容时,我们对其进行包装,调用了new CachedHttpServletRequestWrapper(request)后,已经进行了request的流读取操作,然后进行了缓存,后续我们再在logRequestBody(request)中操作了包装对象的getInputStream(),其实就是从缓存中获取,并用objectMapper.readTree(inputStream)读取输出到日志,该方法可以去掉请求体中的空格、换行符等,以最紧凑的字符串形式输出。

注意,这里我们在logRequestBody(request)中的逻辑处理,只考虑请求的内容类型为application/json的形式,其他形式的请求体,当然有必要可以另外扩展输出。

不要忘了对其进行配置,这里我们过滤所有的请求地址,假设我们不考虑为我们的spring boot应用提供额外的静态资源服务,否则我们还得排除一些资源请求路径的模式。还要配置filter的执行顺序,确保在basicAuthFilter之前执行。

java 复制代码
package com.xiaojuan.boot.web;

import ...

@Configuration
public class WebConfig implements WebMvcConfigurer {

    ...

    @Bean
    public RequestLogFilter requestLogFilter() {
        return new RequestLogFilter();
    }

    @Bean
    public FilterRegistrationBean<BasicAuthFilter> basicAuthFilterBean(){
        ...
        bean.setOrder(2);
        return bean;
    }

    @Bean
    public FilterRegistrationBean<RequestLogFilter> requestLogFilterBean() {
        FilterRegistrationBean<RequestLogFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(requestLogFilter());
        bean.setName("requestLogFilter");
        bean.addUrlPatterns("/*"); // 这里不考虑spring boot应用提供静态资源访问功能
        bean.setOrder(1);
        return bean;
    }
}

测试

最后我们调整下先前的TestController的post方法,让它接受post请求体内容:

java 复制代码
package com.xiaojuan.boot.web.controller;

import ...

@Slf4j
@RestController
public class TestController implements TestAPI {
    ...

    @Override
    public void post(JSONObject obj) {
        log.info("post 入参:{}", obj);
        ...
    }

    ...
}

这里我们还要引入一个json相关的依赖:

groovy 复制代码
dependencies {
    ...
    implementation 'com.alibaba:fastjson:1.2.75'
}

web单元测试:

java 复制代码
package com.xiaojuan.boot.web.controller;

import ...

public class TestControllerTest extends WebTestBase {

    ...

    @Test
    public void testPostWithRequestBody() {
        JSONObject data = new JSONObject();
        data.put("username", "zhangsan");
        postObject("/test/post", new TypeReference<Void>() {}, data, null);
    }

}

运行单元测试看到控制台的日志输出,ok!

这样,我们的请求日志的输出总算是完善了,不会再丢失了,只不过我们只关心json请求体的日志输出,在现在的方案上扩展其他形式也不难了,可以交给小伙伴们自己完成,大家加油!

参考资料:Spring -- Log Incoming Requests

相关推荐
JH30738 小时前
SpringBoot 优雅处理金额格式化:拦截器+自定义注解方案
java·spring boot·spring
qq_124987075312 小时前
基于SSM的动物保护系统的设计与实现(源码+论文+部署+安装)
java·数据库·spring boot·毕业设计·ssm·计算机毕业设计
Coder_Boy_12 小时前
基于SpringAI的在线考试系统-考试系统开发流程案例
java·数据库·人工智能·spring boot·后端
2301_8187320612 小时前
前端调用控制层接口,进不去,报错415,类型不匹配
java·spring boot·spring·tomcat·intellij-idea
汤姆yu15 小时前
基于springboot的尿毒症健康管理系统
java·spring boot·后端
暮色妖娆丶15 小时前
Spring 源码分析 单例 Bean 的创建过程
spring boot·后端·spring
biyezuopinvip16 小时前
基于Spring Boot的企业网盘的设计与实现(任务书)
java·spring boot·后端·vue·ssm·任务书·企业网盘的设计与实现
JavaGuide17 小时前
一款悄然崛起的国产规则引擎,让业务编排效率提升 10 倍!
java·spring boot
figo10tf17 小时前
Spring Boot项目集成Redisson 原始依赖与 Spring Boot Starter 的流程
java·spring boot·后端
zhangyi_viva18 小时前
Spring Boot(七):Swagger 接口文档
java·spring boot·后端