SpringBoot WebFlux记录接口访问日志
SpringBoot WebFlux 记录接口访问日志
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>
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)