Преглед изворни кода

feat: 增强Feign客户端日志功能并优化日志输出格式

lixuesong пре 3 недеља
родитељ
комит
d9a32d0cc2

+ 1 - 1
pom.xml

@@ -5,7 +5,7 @@
 
 	<groupId>net.yyc.common</groupId>
 	<artifactId>yyc-common-parent</artifactId>
-	<version>1.0.1-SNAPSHOT</version>
+	<version>1.0.0-SNAPSHOT</version>
 	<name>yyc-common-parent</name>
 	<packaging>pom</packaging>
 

+ 8 - 0
yyc-common-bom/pom.xml

@@ -30,6 +30,8 @@
 		<aliyun.version>3.0.52.ALL</aliyun.version>
 		<commons-io.version>2.8.0</commons-io.version>
 		<itext.version>5.5.13.2</itext.version>
+		<!--JSON 日志 encoder:兼容 logback 1.2.3 / Spring Boot 2.3.12(升级 logback 1.3+/Boot 3 前不可升 7.x+)-->
+		<logstash-logback-encoder.version>6.6</logstash-logback-encoder.version>
 		<maven.deploy.skip>true</maven.deploy.skip>
 	</properties>
 
@@ -110,6 +112,12 @@
 				<artifactId>yyc-common-log</artifactId>
 				<version>${yyc.common.version}</version>
 			</dependency>
+			<!--JSON 日志 encoder(yyc-common-log 的 sys-log-file-appender.xml 使用)-->
+			<dependency>
+				<groupId>net.logstash.logback</groupId>
+				<artifactId>logstash-logback-encoder</artifactId>
+				<version>${logstash-logback-encoder.version}</version>
+			</dependency>
 			<dependency>
 				<groupId>com.baomidou</groupId>
 				<artifactId>mybatis-plus-extension</artifactId>

+ 7 - 5
yyc-common-log/pom.xml

@@ -34,16 +34,19 @@
 
 
 	<dependencies>
-		<!--工具类核心包-->
+		<!--工具类核心包(版本由 yyc-common-bom 统一管理)-->
 		<dependency>
 			<groupId>net.yyc.common</groupId>
 			<artifactId>yyc-common-core</artifactId>
-			<version>1.0.0-SNAPSHOT</version>
 		</dependency>
 		<dependency>
 			<groupId>net.yyc.common</groupId>
 			<artifactId>yyc-common-feign</artifactId>
-			<version>1.0.0-SNAPSHOT</version>
+		</dependency>
+		<!--Feign 负载均衡:日志客户端需包装为 LoadBalancerFeignClient,避免注册裸 Client 顶掉负载均衡导致服务名调用失败-->
+		<dependency>
+			<groupId>org.springframework.cloud</groupId>
+			<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
 		</dependency>
 		<!--安全依赖获取上下文信息-->
 		<dependency>
@@ -58,11 +61,10 @@
 			<groupId>org.springframework.cloud</groupId>
 			<artifactId>spring-cloud-starter-sleuth</artifactId>
 		</dependency>
-		<!--JSON 日志输出:阿里云 SLS 采集(LogstashEncoder,sys-log-file-appender.xml 使用)-->
+		<!--JSON 日志输出:阿里云 SLS 采集(LogstashEncoder,sys-log-file-appender.xml 使用),版本由 yyc-common-bom 管理-->
 		<dependency>
 			<groupId>net.logstash.logback</groupId>
 			<artifactId>logstash-logback-encoder</artifactId>
-			<version>6.6</version>
 		</dependency>
 	</dependencies>
 </project>

+ 63 - 6
yyc-common-log/src/main/java/net/yyc/common/log/FeignDefaultRequestLogging.java

@@ -18,6 +18,9 @@ import java.util.Objects;
 
 /**
  * Feign客户端调用日志输出
+ * <p>
+ * 由 {@link LogAutoConfiguration#feignClient} 包装为 LoadBalancerFeignClient 注册,
+ * 保留负载均衡能力;关闭开关(log.feign.enable=false)时行为与 Client.Default 完全一致。
  */
 @Slf4j
 public class FeignDefaultRequestLogging extends Client.Default {
@@ -27,6 +30,11 @@ public class FeignDefaultRequestLogging extends Client.Default {
      */
     private static final String LOG_EVENT = "FEIGN";
 
+    /**
+     * 调用失败时 LogMessage.httpStatus 的占位状态码(无响应可取)
+     */
+    private static final int HTTP_STATUS_ERROR = -1;
+
     /**
      * 打印日志开关
      */
@@ -51,7 +59,28 @@ public class FeignDefaultRequestLogging extends Client.Default {
 
         long startTime = System.currentTimeMillis();
 
-        Response response = super.execute(request, options);
+        Response response;
+        try {
+            response = super.execute(request, options);
+        }
+        catch (IOException e) {
+            // 连接超时/读超时等调用失败也要留痕:状态码置 -1,记录异常摘要后原样抛出
+            LogMessage logMsg = new LogMessage();
+            logMsg.setEvent(LOG_EVENT);
+            logMsg.setMethod(request.httpMethod().name());
+            logMsg.setRequestUri(request.url());
+            logMsg.setIp(NetUtil.getLocalhostStr());
+            logMsg.setReqHeaders(assembleHeaders(request.headers()));
+            byte[] reqBody = request.body();
+            if (Objects.nonNull(reqBody)) {
+                logMsg.setReqBody(buildBodyText(reqBody));
+            }
+            logMsg.setHttpStatus(HTTP_STATUS_ERROR);
+            logMsg.setRespBody("ERROR: " + e.getClass().getSimpleName() + ": " + e.getMessage());
+            logMsg.setDuration(System.currentTimeMillis() - startTime);
+            log.info(logMsg.buildJson());
+            throw e;
+        }
 
         //拼接日志
         LogMessage logMsg = new LogMessage();
@@ -63,22 +92,50 @@ public class FeignDefaultRequestLogging extends Client.Default {
 
         byte[] reqBody = request.body();
         if (Objects.nonNull(reqBody)) {
-            logMsg.setReqBody(new String(reqBody, 0, Math.min(reqBody.length, maxLength), CharsetUtil.CHARSET_UTF_8));
+            logMsg.setReqBody(buildBodyText(reqBody));
         }
         logMsg.setHttpStatus(response.status());
         logMsg.setRespHeaders(assembleHeaders(response.headers()));
 
+        // 响应体必须完整读取并回填(业务方依赖完整 body),仅日志字符串截断;
+        // 读取失败时流已损坏无法恢复,记录告警并继续(调用方会得到读流异常)
         Response.Body responseBody = response.body();
         if (responseBody != null) {
-            byte[] respBody = IoUtil.readBytes(responseBody.asInputStream());
-            logMsg.setRespBody(new String(respBody, 0, Math.min(respBody.length, maxLength), CharsetUtil.CHARSET_UTF_8));
-            response = response.toBuilder().body(respBody).build();
+            try {
+                byte[] respBody = readBody(responseBody);
+                response = response.toBuilder().body(respBody).build();
+                logMsg.setRespBody(buildBodyText(respBody));
+            }
+            catch (IOException e) {
+                log.warn("FEIGN 响应体读取失败, uri: {}", request.url(), e);
+            }
         }
         logMsg.setDuration(System.currentTimeMillis() - startTime);
-        log.info(logMsg.buildMessage());
+        log.info(logMsg.buildJson());
         return response;
     }
 
+    /**
+     * 读取响应体:Content-Length 已知时按长度一次性分配,避免 ByteArrayOutputStream 动态扩容放大内存占用
+     */
+    private byte[] readBody(Response.Body body) throws IOException {
+        long length = body.length();
+        return length > 0 && length <= Integer.MAX_VALUE
+                ? IoUtil.readBytes(body.asInputStream(), (int) length)
+                : IoUtil.readBytes(body.asInputStream());
+    }
+
+    /**
+     * body 转日志文本:超过 maxLength 截断并追加总长度标记,防止超大响应撑爆日志
+     */
+    private String buildBodyText(byte[] body) {
+        if (body.length > maxLength) {
+            return new String(body, 0, maxLength, CharsetUtil.CHARSET_UTF_8)
+                    + String.format("...[TRUNCATED,total=%d]", body.length);
+        }
+        return new String(body, CharsetUtil.CHARSET_UTF_8);
+    }
+
     private String assembleHeaders(Map<String, Collection<String>> headers) {
         if (CollectionUtils.isEmpty(headers)) {
             return null;

+ 31 - 0
yyc-common-log/src/main/java/net/yyc/common/log/LogAutoConfiguration.java

@@ -23,9 +23,16 @@ import net.yyc.common.log.aspect.SysLogAspect;
 import net.yyc.common.log.aspect.SysLogHandler;
 import net.yyc.common.log.config.LogConfig;
 import net.yyc.common.log.event.SysLogListener;
+import feign.Client;
 import lombok.AllArgsConstructor;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
 import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.cloud.netflix.ribbon.SpringClientFactory;
+import org.springframework.cloud.openfeign.FeignAutoConfiguration;
+import org.springframework.cloud.openfeign.ribbon.CachingSpringLoadBalancerFactory;
+import org.springframework.cloud.openfeign.ribbon.LoadBalancerFeignClient;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -43,6 +50,9 @@ import org.springframework.scheduling.annotation.EnableAsync;
 @AllArgsConstructor
 @ConditionalOnWebApplication
 @Import(LogConfig.class)
+// 必须先于 FeignAutoConfiguration 处理:其负载均衡 Client(RibbonFeignLoadBalancedConfiguration)
+// 为 @ConditionalOnMissingBean(Client.class),若其先注册,下方日志客户端会静默退位(feign 日志失效)
+@AutoConfigureBefore(FeignAutoConfiguration.class)
 public class LogAutoConfiguration {
 
     private final LogConfig logConfig;
@@ -57,6 +67,27 @@ public class LogAutoConfiguration {
         return new SysLogListener(handler);
     }
 
+    /**
+     * Feign 客户端:在保留负载均衡能力的前提下追加调用日志输出。
+     *
+     * <p>不能用裸的 {@link FeignDefaultRequestLogging}(继承 Client.Default)直接注册:
+     * 会顶掉 Spring Cloud 的 {@link LoadBalancerFeignClient},按服务名(http://service-name/xx)
+     * 的 Feign 调用会被当作域名解析,直接 UnknownHostException。因此包装为
+     * {@link LoadBalancerFeignClient},负载均衡行为与默认完全一致。</p>
+     *
+     * <p>若项目自行注册了 Client(okhttp / httpclient 等),本 Bean 自动退位,feign 日志随之失效,
+     * 届时需在自定义 Client 内自行包装日志逻辑。</p>
+     *
+     * @param cachingFactory ribbon 负载均衡工厂(FeignRibbonClientAutoConfiguration 提供)
+     * @param clientFactory  ribbon 客户端工厂(FeignRibbonClientAutoConfiguration 提供)
+     * @return 日志增强的负载均衡 Feign 客户端
+     */
+    @Bean
+    @ConditionalOnMissingBean(Client.class)
+    public Client feignClient(CachingSpringLoadBalancerFactory cachingFactory, SpringClientFactory clientFactory) {
+        return new LoadBalancerFeignClient(new FeignDefaultRequestLogging(logConfig), cachingFactory, clientFactory);
+    }
+
     @Bean
     public FilterRegistrationBean<RequestLoggingFilter> requestLoggingFilter() {
         RequestLoggingFilter filter = new RequestLoggingFilter(logConfig);

+ 1 - 1
yyc-common-log/src/main/java/net/yyc/common/log/RequestLoggingFilter.java

@@ -111,7 +111,7 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
             logMsg.setRespBody(new String(response.getContentAsByteArray(), CharsetUtil.CHARSET_UTF_8));
         }
         logMsg.setDuration(System.currentTimeMillis() - startTime);
-        return logMsg.buildMessage();
+        return logMsg.buildJson();
     }
 
     private String assembleReqHeaders(HttpServletRequest request) {

+ 12 - 2
yyc-common-log/src/main/java/net/yyc/common/log/event/SysLogListener.java

@@ -23,6 +23,8 @@ import cn.hutool.json.JSONUtil;
 import net.yyc.common.log.aspect.SysLogHandler;
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.context.event.EventListener;
 import org.springframework.core.annotation.Order;
 import org.springframework.scheduling.annotation.Async;
@@ -36,6 +38,13 @@ import org.springframework.scheduling.annotation.Async;
 @AllArgsConstructor
 public class SysLogListener {
 
+    /**
+     * 失败诊断日志通道:类上的 log 所属 logger(net.yyc.common.log.event)被 sys-log-file-appender
+     * 定向输出(additivity=false、LevelFilter 仅收 INFO),WARN/ERROR 不会进任何文件。
+     * 此处用独立 logger 名走 root(console/debug.log/error.log),保证失败可排障。
+     */
+    private static final Logger ERROR_LOG = LoggerFactory.getLogger("net.yyc.common.log.SysLogError");
+
     private final SysLogHandler sysLogHandler;  // 业务侧注入
 
     @Async
@@ -46,12 +55,13 @@ public class SysLogListener {
         try {
             log.info(JSONUtil.toJsonStr(event.getSysLog()));
         } catch (Exception e) {
-            log.warn("操作日志 JSON 输出失败, {}", JSONUtil.toJsonStr(event.getSysLog()), e);
+            // 不可再次序列化 event.getSysLog():失败原因多半是序列化本身,重复执行会二次抛异常并中断写库
+            ERROR_LOG.warn("操作日志 JSON 输出失败: {}", event.getSysLog(), e);
         }
         try {
             sysLogHandler.saveLog(event.getSysLog());
         } catch (Exception e) {
-            log.error("审计日志保存失败, {}", JSONUtil.toJsonStr(event.getSysLog()), e);
+            ERROR_LOG.error("审计日志保存失败: {}", event.getSysLog(), e);
         }
     }
 

+ 3 - 2
yyc-common-log/src/main/java/net/yyc/common/log/util/SysLogUtils.java

@@ -51,8 +51,9 @@ public class SysLogUtils {
 			}
 			return json;
 		}
-		catch (Exception e) {
-			// 非 JSON 对象等无法脱敏场景,保留原值,避免日志异常影响主流程
+		catch (Throwable e) {
+			// 非 JSON 对象等无法脱敏场景(含循环引用序列化可能抛出的 StackOverflowError),
+			// 保留原值,脱敏失败绝不能影响业务主流程
 			return arg;
 		}
 	}

+ 20 - 0
yyc-common-log/src/main/java/net/yyc/common/log/vo/LogMessage.java

@@ -1,6 +1,7 @@
 package net.yyc.common.log.vo;
 
 import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONObject;
 import lombok.Data;
 
 /**
@@ -90,4 +91,23 @@ public class LogMessage {
         };
         return DEFAULT_LOG_FIELD_SEPARATOR_1 + String.join(String.valueOf(DEFAULT_LOG_FIELD_SEPARATOR_1), strings);
     }
+
+    /**
+     * JSON 单行输出(SLS Logtail JSON 模式采集,字段自描述,空值沿用 "-" 占位与旧格式语义一致)
+     * <p>字段全部显式 set,保证行内字段稳定;body 等用户可控内容由 JSON 转义保证结构不被污染。</p>
+     */
+    public String buildJson() {
+        JSONObject json = new JSONObject();
+        json.set("event", event);
+        json.set("method", method);
+        json.set("requestUri", requestUri);
+        json.set("ip", StrUtil.emptyToDefault(ip, DEFAULT_FIELD_PLACEHOLDER));
+        json.set("reqHeaders", StrUtil.emptyToDefault(reqHeaders, DEFAULT_FIELD_PLACEHOLDER));
+        json.set("reqBody", StrUtil.emptyToDefault(reqBody, DEFAULT_FIELD_PLACEHOLDER));
+        json.set("httpStatus", httpStatus);
+        json.set("respHeaders", StrUtil.emptyToDefault(respHeaders, DEFAULT_FIELD_PLACEHOLDER));
+        json.set("respBody", StrUtil.emptyToDefault(respBody, DEFAULT_FIELD_PLACEHOLDER));
+        json.set("duration", duration);
+        return json.toString();
+    }
 }

+ 14 - 6
yyc-common-log/src/main/resources/net/yyc/common/log/request-logging-file-appender.xml

@@ -8,17 +8,19 @@
             <!-- 归档的日志文件的路径,%d{yyyy-MM-dd}指定日期格式 -->
 			<fileNamePattern>${log.path}/%d{yyyy-MM}/request-logging-%d{yyyy-MM-dd}-%i.log.gz</fileNamePattern>
             <cleanHistoryOnStart>true</cleanHistoryOnStart>
-            <!-- 保留10天的日志 -->
-            <maxHistory>30</maxHistory>
+            <!-- 保留7天的日志 -->
+            <maxHistory>7</maxHistory>
             <!--用来指定单个日志文件的上限大小,那么到了这个值,就会拆分日志-->
             <maxFileSize>50MB</maxFileSize>
         </rollingPolicy>
         <!-- 追加方式记录日志 -->
         <append>true</append>
-        <!-- 日志文件的格式 -->
-        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
-			<pattern>%date [%thread] %-5level [%X{traceId},%X{spanId}] [%logger{50}] %file:%line - %msg%n</pattern>
-            <charset>utf-8</charset>
+        <!-- 日志文件的格式:JSON 单行,供 SLS Logtail JSON 模式采集(与 sys-log/debug 等文件一致,透传 MDC traceId/spanId) -->
+        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
+            <fieldNames>
+                <version>[ignore]</version>
+                <levelValue>[ignore]</levelValue>
+            </fieldNames>
         </encoder>
         <!-- 此日志文件只记录info级别的 -->
         <filter class="ch.qos.logback.classic.filter.LevelFilter">
@@ -32,4 +34,10 @@
         <appender-ref ref="console" />
         <appender-ref ref="REQUEST_LOGGING_FILE" />
     </logger>
+
+    <!-- Feign 调用日志(EVENT=FEIGN,格式同 REQ),与请求日志同文件,便于 SLS 统一采集 -->
+    <logger name="net.yyc.common.log.FeignDefaultRequestLogging" level="INFO" additivity="false">
+        <appender-ref ref="console" />
+        <appender-ref ref="REQUEST_LOGGING_FILE" />
+    </logger>
 </included>