SpringBoot WebFlux 记录接口访问日志

前言

最近项目要做一个记录接口日志访问的需求,但是项目采用的SpringBoot WebFlux进行开发的,在获取请求的内容时,拿不到RequestBody中的内容。debug看的时候是空的。或者就是出现重复读取的异常。在网上找了一些解决方案,最终还是解决了,在此记录一下。

对已有的系统加一个加这个记录接口访问日志功能,不用说,AOP是比较适合的,除了用切点控制能进入切面的类,还可以通过注解来控制。aop就不多介绍了。

执行顺序:请求Request -> 网关Gateway -> 本服务WebFlux(过滤器Filter -> 切面方法Aspect -> Controller方法) -> 返回接口结果

1. 网关

请求到网关GateWay的时候是可以进行一些操作的,比如鉴权,比如可以把用户信息放入请求中传到WebFlux中,然后打印接口日志的时候可以加上用户的信息等等。这里不多说了。

2. WebFlux服务

2.1 过滤器Filter

创建上下文RequestInfoContext
@Getter
@Setter
public class RequestInfoContext {

    public static final String CONTEXT_KEY = "REQUEST_INFO";

    private String            url;
    private String            method;
    private HttpHeaders       headers;
    private String            requestBody;
    private ServerHttpRequest request;
}

上线文用于存放请求的一些信息,然后在切面中去拿上线文的信息来用。

创建过滤器RequestInfoWebFilter
@Slf4j
public class RequestInfoWebFilter implements WebFilter,Ordered {

    /**
     * Ordered 排序设为 Ordered.HIGHEST_PRECEDENCE 最高级别
     * 防止在 WebFlux 内部 Filter 过滤器处理链路上读取过 RequstBody,如:HiddenHttpMethodFilter 中,
     * 导制后面控制器中再一次读取该 RequstBody 就会出现异或者为空的情况。
     */
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {

        ServerHttpRequest  request            = exchange.getRequest();
        // 创建一个RequestInfoContext对象,用于存储请求相关的信息
        RequestInfoContext requestInfoContext = new RequestInfoContext();
        requestInfoContext.setRequest(request);
        // 从请求头中获取Content-Type
        String contentType = request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
        // 只对contentType=application/json的数据进行重写RequesetBody
        if (MediaType.APPLICATION_JSON_VALUE.equals(contentType)) {
            AtomicReference<String> bodyRef = new AtomicReference<>();
            return DataBufferUtils.join(exchange.getRequest().getBody())
                .flatMap(dataBuffer -> {
                    CharBuffer charBuffer = StandardCharsets.UTF_8.decode(dataBuffer.asByteBuffer());
                    // 保留DataBuffer,以便后续使用
                    DataBufferUtils.retain(dataBuffer);
                    bodyRef.set(charBuffer.toString());
                    String bodyStr = bodyRef.get();
                    requestInfoContext.setRequestBody(bodyStr);
                    // 缓存请求体的数据
                    Flux<DataBuffer> cachedFlux = Flux
                        .defer(() -> Flux.just(dataBuffer.slice(0, dataBuffer.readableByteCount())));
                    // 创建一个装饰后的ServerHttpRequest,重写其getBody方法,使其返回缓存的Flux<DataBuffer>
                    ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(
                        exchange.getRequest()) {
                        @Override
                        public Flux<DataBuffer> getBody() {
                            return cachedFlux;
                        }
                    };
                    return chain.filter(exchange.mutate().request(mutatedRequest).build())
                        .contextWrite(context -> context.putAll(
                            Context.of(RequestInfoContext.CONTEXT_KEY, Mono.just(requestInfoContext))
                                                                    .readOnly()));
                });
        }
        return chain.filter(exchange)
            .contextWrite(context -> context.putAll(Context.of(RequestInfoContext.CONTEXT_KEY, Mono.just(requestInfoContext))
                                                        .readOnly()));
    }

}

bodyRef:使用AtomicReference来存储请求体。

DataBufferUtils.join(exchange.getRequest().getBody()):读取请求体的数据并将其聚合成一个DataBuffer

charBuffer:将DataBuffer中的字节数据解码成CharBuffer

DataBufferUtils.retain(dataBuffer):保留DataBuffer,以便后续使用。

bodyStr:将CharBuffer转换为字符串。

requestInfoContext.setRequestBody(...):将请求体设置到RequestInfoContext中。

cachedFlux:创建一个Flux<DataBuffer>,用于缓存请求体的数据。

mutatedRequest:创建一个装饰后的ServerHttpRequest,重写其getBody方法,使其返回缓存的Flux<DataBuffer>

返回调用chain.filter,继续处理过滤链,并将RequestInfoContext放入Context中。

2.2 切面Aspect

创建注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SystemLog {
    // 描述
    String methodDesc() default "";

    // 模块
    String module() default "";

    //事件类型:LOGIN;LOGINOUT;ADD;DELETE;UPDATE;SELETE;UPLOAD;DOWNLOAD;OTHER
    OperateType operateType() default OperateType.OTHER;

    //日志类型:0:系统日志;1:业务日志
    String logType() default "0";
}
public enum OperateType {
    LOGIN, LOGINOUT, ADD, DELETE, UPDATE, SELETE, UPLOAD, DOWNLOAD, OTHER
}
创建切面
@Aspect
@Order(1)
public class LoggingAspect {

    private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);

    private final Environment env;

    public LoggingAspect(Environment env) {
        this.env = env;
    }

    @Pointcut(value = "@annotation(com.xx.production.annotation.SystemLog))")
    public void log(){
    }

    @Pointcut("execution(* com.xx.production.web.controller..*(..))")
    public void controllerPointcut(){
    }

    @Around(value = "log() || controllerPointcut()")
    public Publisher<?> logAround(ProceedingJoinPoint joinPoint) {

        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Class<?>        type      = signature.getReturnType();

        cn.yematech.costing.production.domain.SystemLog sysLog     = new cn.yematech.costing.production.domain.SystemLog();
        sysLog.setRequestTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")));

        // 方法相关
        Method method = signature.getMethod();
        // 获取方法参数
        Object[] args = joinPoint.getArgs();
        sysLog.setMethodName(method.getName());

        Mono<RequestInfoContext> contextMono = ReactorContextHolder.getMonoContext(RequestInfoContext.class)
            .zipWith(LoginUserContextHolder.getContext().defaultIfEmpty(new LoginUserContext()))
            .publishOn(Schedulers.boundedElastic())
            .map(tuple -> {
                RequestInfoContext requestInfoContext = tuple.getT1();
                LoginUserContext   loginUserContext   = tuple.getT2();

                // 用户信息
                sysLog.setUserId(loginUserContext.getId());
                sysLog.setUsername(loginUserContext.getUsername());
                sysLog.setUserRealName(loginUserContext.getName());

                // 注解信息
                boolean annotationPresent = method.isAnnotationPresent(SystemLog.class);
                if (annotationPresent) {
                    SystemLog annotation = method.getAnnotation(SystemLog.class);
                    // 获取注解参数
                    sysLog.setMethodDesc(annotation.methodDesc());
                    sysLog.setModule(annotation.module());
                    sysLog.setOperateType(annotation.operateType());
                    sysLog.setLogType(annotation.logType());
                }

                // 请求相关信息

                ServerHttpRequest request = requestInfoContext.getRequest();
                // 获取请求方法(GET, POST, PUT, DELETE等)
                sysLog.setReqMethod(request.getMethod().toString());
                // 获取请求URI
                sysLog.setReqUrl(request.getURI().toString());
                // 获取请求路径
                sysLog.setPath(request.getPath().value());
                // 获取查询字符串
                sysLog.setReqParams(JSON.toJSONString(request.getQueryParams()));
                // 获取请求体(如果是文本类型)
                sysLog.setReqBody(requestInfoContext.getRequestBody());
                // 获取客户端IP地址
                InetSocketAddress remoteAddress = request.getRemoteAddress();
                sysLog.setIpAddress(remoteAddress.getAddress().getHostAddress());

                log.debug("sysLog:"+JSON.toJSONString(sysLog, JSONWriter.Feature.WriteMapNullValue));
                return requestInfoContext;
            });

        // 根据方法的返回类型,构建相应的Publisher
        try {
            Object proceed = joinPoint.proceed();
            if (Mono.class.isAssignableFrom(type)) {
                return contextMono.flatMap(ctx -> (Mono<?>) proceed);
            }else {
                return contextMono.flatMapMany(ctx -> (Flux<?>) proceed);
            }
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }


}
获取上下文
public class ReactorContextHolder {
    private static final String CONTEXT_KEY_STATIC_FIELD_NAME = "CONTEXT_KEY";
    public static <T> Mono<T> getMonoContext(Class<T> clazz) {
        String contextKey = getContextKey(clazz);
        return Mono.deferContextual(Mono::just)
            .cast(Context.class)
            .filter(context -> context.hasKey(contextKey))
            .flatMap(context -> context.get(contextKey));
    }

    public static <T> Mono<T> getContext(Class<T> clazz) {
        String contextKey = getContextKey(clazz);
        return Mono.deferContextual(Mono::just)
            .cast(Context.class)
            .filter(context -> context.hasKey(contextKey))
            .map(context -> context.get(contextKey));
    }

    public static Function<Context, Context> clearContext(Class<?> clazz) {
        return (context) -> context.delete(getContextKey(clazz));
    }

    public static String getContextKey(Class<?> clazz) {
        try {
            return (String) clazz.getDeclaredField(CONTEXT_KEY_STATIC_FIELD_NAME).get(null);
        } catch (Exception e) {
            throw WrapperThrowsUtils.sneakyThrow(e);
        }
    }
}
@Configuration
public class LoggingAspectConfiguration {

    @Bean
    public RequestInfoWebFilter requestInfoWebFilter() {
        return new RequestInfoWebFilter();
    }

    @Bean
    public LoggingAspect loggingAspect(Environment env) {
        return new LoggingAspect(env);
    }
}

我的日志打印不仅获取了自己定义的RequestInfoContext,还获取了LoginUserContextHolder,获取了用户的相关信息。

我这只是在切面方法中打印了日志,如果有需要,可以在切面方法中进行存库操作。

2.3 日志配置logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration>

<configuration scan="true">
    <!-- Patterns based on https://github.com/spring-projects/spring-boot/blob/v2.7.2/spring-boot-project/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml -->
    <conversionRule conversionWord="crlf" converterClass="cn.yematech.costing.production.config.CRLFLogConverter" />
    <property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %crlf(%m){red} %n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
    <property name="FILE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %crlf(%m){red} %n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
    <property name="LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE" value="1gb"/>

<!--    <include resource="org/springframework/boot/logging/logback/base.xml"/>-->
    <!-- 默认的一些配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- 配置控制台输出 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- 使用默认的输出格式打印 -->
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
    </appender>


    <!-- 定义应用名称,区分应用 -->
    <springProperty name="APP_NAME" scope="context" source="spring.application.name"/>
    <!-- 定义日志文件的输出路径 -->
    <property name="FILE_PATH" value="${user.home}/logs/${APP_NAME}"/>

    <!-- 将日志滚动输出到application.log文件中 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 输出文件目的地 -->
        <file>${FILE_PATH}/application.log</file>
        <encoder>
            <!-- 使用默认的输出格式打印 -->
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 设置 RollingPolicy 属性,用于配置文件大小限制,保留天数、文件名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 文件命名格式 -->
            <fileNamePattern>${FILE_PATH}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <!-- 文件保留最大天数 -->
            <maxHistory>30</maxHistory>
            <!-- 文件大小限制 -->
            <maxFileSize>1gb</maxFileSize>
            <!-- 文件总大小 超过总大小会删除已归档的旧日志,例如一个日志是50MB,那在归档第11个日志文件时,会删除一个旧日志文件 -->
<!--            <totalSizeCap>500MB</totalSizeCap>-->
        </rollingPolicy>

        <!-- 配置压缩策略 -->
        <action class="ch.qos.logback.core.rolling.CompressionAction">
            <compressor class="ch.qos.logback.core.util.FileCompressor"/>
        </action>
    </appender>

    <!-- 摘取出WARN级别日志输出到warn.log中 -->
    <appender name="WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 输出文件目的地 -->
        <file>${FILE_PATH}/warn.log</file>
        <encoder>
            <!-- 使用默认的输出格式打印 -->
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 设置 RollingPolicy 属性,用于配置文件大小限制,保留天数、文件名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 文件命名格式 -->
            <fileNamePattern>${FILE_PATH}/warn.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <!-- 文件保留最大天数 -->
            <maxHistory>30</maxHistory>
            <!-- 文件大小限制 -->
            <maxFileSize>1gb</maxFileSize>
            <!-- 文件总大小 超过总大小会删除已归档的旧日志,例如一个日志是50MB,那在归档第11个日志文件时,会删除一个旧日志文件 -->
            <!--            <totalSizeCap>500MB</totalSizeCap>-->
        </rollingPolicy>
        <!-- 日志过滤器,将WARN相关日志过滤出来 -->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>WARN</level>
        </filter>
        <!-- 配置压缩策略 -->
        <action class="ch.qos.logback.core.rolling.CompressionAction">
            <compressor class="ch.qos.logback.core.util.FileCompressor"/>
        </action>
    </appender>

    <!-- 摘取出ERROR级别日志输出到error.log中 -->
    <appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 输出文件目的地 -->
        <file>${FILE_PATH}/error.log</file>
        <encoder>
            <!-- 使用默认的输出格式打印 -->
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
        <!-- 设置 RollingPolicy 属性,用于配置文件大小限制,保留天数、文件名格式 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 文件命名格式 -->
            <fileNamePattern>${FILE_PATH}/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <!-- 文件保留最大天数 -->
            <maxHistory>30</maxHistory>
            <!-- 文件大小限制 -->
            <maxFileSize>1gb</maxFileSize>
            <!-- 文件总大小 超过总大小会删除已归档的旧日志,例如一个日志是50MB,那在归档第11个日志文件时,会删除一个旧日志文件 -->
            <!--            <totalSizeCap>500MB</totalSizeCap>-->
        </rollingPolicy>
        <!-- 日志过滤器,将WARN相关日志过滤出来 -->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
        <!-- 配置压缩策略 -->
        <action class="ch.qos.logback.core.rolling.CompressionAction">
            <compressor class="ch.qos.logback.core.util.FileCompressor"/>
        </action>
    </appender>

    <!-- 定义异步Appender -->
    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
        <!-- 增加队列大小,防止队列溢出 -->
        <queueSize>512</queueSize>
        <!-- 添加RollingFileAppender作为子Appender -->
        <appender-ref ref="FILE"/>
    </appender>
    <appender name="ASYNC_WARN" class="ch.qos.logback.classic.AsyncAppender">
        <queueSize>512</queueSize>
        <appender-ref ref="WARN"/>
    </appender>
    <appender name="ASYNC_ERROR" class="ch.qos.logback.classic.AsyncAppender">
        <queueSize>512</queueSize>
        <appender-ref ref="ERROR"/>
    </appender>

    <appender name="GRPC_LOG" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender">
        <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
            <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">
                <Pattern>%contextName- %d{yyyy-MM-dd HH:mm:ss} ([%thread]) ([%tid]) %highlight(%-5level) (%logger{36}) - %gray(%msg%n)</Pattern>
            </layout>
        </encoder>
    </appender>


    <logger name="jakarta.activation" level="WARN"/>
    <logger name="jakarta.mail" level="WARN"/>
    <logger name="jakarta.management.remote" level="WARN"/>
    <logger name="jakarta.xml.bind" level="WARN"/>
    <logger name="ch.qos.logback" level="WARN"/>
    <logger name="com.netflix" level="WARN"/>
    <logger name="com.netflix.config.sources.URLConfigurationSource" level="ERROR"/>
    <logger name="com.netflix.discovery" level="INFO"/>
    <logger name="com.ryantenney" level="WARN"/>
    <logger name="com.sun" level="WARN"/>
    <logger name="com.zaxxer" level="WARN"/>
    <logger name="io.netty" level="WARN"/>
    <logger name="org.apache" level="WARN"/>
    <logger name="org.apache.catalina.startup.DigesterFactory" level="OFF"/>
    <logger name="org.bson" level="WARN"/>
    <logger name="org.hibernate.validator" level="WARN"/>
    <logger name="org.mongodb.driver" level="WARN"/>
    <logger name="org.reflections" level="WARN"/>
    <logger name="org.springframework" level="WARN"/>
    <logger name="org.springframework.web" level="WARN"/>
    <logger name="org.springframework.security" level="WARN"/>
    <logger name="org.springframework.boot.autoconfigure.logging" level="INFO"/>
    <logger name="org.springframework.cache" level="WARN"/>
    <logger name="org.synchronoss" level="WARN"/>
    <logger name="org.thymeleaf" level="WARN"/>
    <logger name="org.xnio" level="WARN"/>
    <logger name="reactor" level="WARN"/>
    <logger name="io.swagger.v3" level="INFO"/>
    <logger name="sun.rmi" level="WARN"/>
    <logger name="sun.net.www" level="INFO"/>
    <logger name="sun.rmi.transport" level="WARN"/>
    <logger name="Validator" level="INFO"/>
    <logger name="_org.springframework.web.reactive.HandlerMapping.Mappings" level="INFO"/>
    
    <shutdownHook class="ch.qos.logback.core.hook.DelayingShutdownHook"/>

    <contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator">
        <resetJUL>true</resetJUL>
    </contextListener>

    <!-- 配置输出级别,加入输出方式 -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="ASYNC"/>
        <appender-ref ref="ASYNC_WARN"/>
        <appender-ref ref="ASYNC_ERROR"/>
        <appender-ref ref="GRPC_LOG"/>
    </root>

</configuration>
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐