feat: 添加&更新Feign调用日志切面和请求/响应日志过滤器
| New file |
| | |
| | | package com.xiaopeng.account.config.log; |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.aspectj.lang.ProceedingJoinPoint; |
| | | import org.aspectj.lang.annotation.Around; |
| | | import org.aspectj.lang.annotation.Aspect; |
| | | import org.aspectj.lang.reflect.MethodSignature; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.lang.reflect.Method; |
| | | import java.util.Arrays; |
| | | |
| | | /** |
| | | * Feign 调用日志切面 |
| | | * |
| | | * <p>拦截所有带 @FeignClient 注解的接口方法,记录: |
| | | * <ul> |
| | | * <li>调用的接口类名 + 方法名</li> |
| | | * <li>入参(JSON 序列化)</li> |
| | | * <li>出参(JSON 序列化)</li> |
| | | * <li>耗时(ms)</li> |
| | | * <li>异常信息(含完整堆栈)</li> |
| | | * </ul> |
| | | */ |
| | | @Slf4j |
| | | @Aspect |
| | | @Component |
| | | public class FeignLoggingAspect { |
| | | |
| | | /** |
| | | * 拦截所有带 @FeignClient 注解的接口的所有方法 |
| | | */ |
| | | @Around("within(@org.springframework.cloud.openfeign.FeignClient *)") |
| | | public Object logFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | private Object doLog(ProceedingJoinPoint pjp) throws Throwable { |
| | | MethodSignature signature = (MethodSignature) pjp.getSignature(); |
| | | Method method = signature.getMethod(); |
| | | // 使用接口声明类名,避免打印动态代理类名(如 $Proxy270) |
| | | String className = signature.getDeclaringType().getSimpleName(); |
| | | String methodName = method.getName(); |
| | | String fullMethod = className + "#" + methodName; |
| | | |
| | | Object[] args = pjp.getArgs(); |
| | | String argsJson = safeToJson(args); |
| | | |
| | | log.info("[Feign] >>> 调用开始 | method={} | args={}", fullMethod, argsJson); |
| | | |
| | | long start = System.currentTimeMillis(); |
| | | try { |
| | | Object result = pjp.proceed(); |
| | | long elapsed = System.currentTimeMillis() - start; |
| | | String resultJson = safeToJson(result); |
| | | log.info("[Feign] <<< 调用成功 | method={} | elapsed={}ms | result={}", fullMethod, elapsed, resultJson); |
| | | return result; |
| | | } catch (Throwable ex) { |
| | | long elapsed = System.currentTimeMillis() - start; |
| | | log.error("[Feign] <<< 调用异常 | method={} | elapsed={}ms | error={} | args={}", |
| | | fullMethod, elapsed, ex.getMessage(), argsJson, ex); |
| | | throw ex; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 安全序列化为 JSON,避免序列化失败导致日志切面本身抛异常 |
| | | */ |
| | | private String safeToJson(Object obj) { |
| | | if (obj == null) { |
| | | return "null"; |
| | | } |
| | | try { |
| | | if (obj instanceof Object[]) { |
| | | return JSON.toJSONString(Arrays.asList((Object[]) obj)); |
| | | } |
| | | return JSON.toJSONString(obj); |
| | | } catch (Exception e) { |
| | | return "[序列化失败: " + obj.getClass().getSimpleName() + "]"; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xiaopeng.account.config.log; |
| | | |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.slf4j.MDC; |
| | | import org.springframework.core.Ordered; |
| | | import org.springframework.core.annotation.Order; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.web.filter.OncePerRequestFilter; |
| | | import org.springframework.web.util.ContentCachingRequestWrapper; |
| | | import org.springframework.web.util.ContentCachingResponseWrapper; |
| | | |
| | | import javax.servlet.FilterChain; |
| | | import javax.servlet.ServletException; |
| | | import javax.servlet.http.HttpServletRequest; |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.io.IOException; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.Arrays; |
| | | import java.util.HashSet; |
| | | import java.util.Set; |
| | | |
| | | /** |
| | | * 请求/响应日志过滤器 |
| | | * 打印:traceId、method、uri、queryString、请求体、响应状态、耗时、响应体 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | @Order(Ordered.HIGHEST_PRECEDENCE + 10) |
| | | public class RequestResponseLoggingFilter extends OncePerRequestFilter { |
| | | |
| | | /** 不打印 body 的路径前缀(健康检查、actuator 等) */ |
| | | private static final Set<String> EXCLUDE_PATHS = new HashSet<>(Arrays.asList( |
| | | "/actuator", "/healthcheck", "/health", "/prometheus" |
| | | )); |
| | | |
| | | /** 只对这些 Content-Type 打印 body(避免打印二进制流) */ |
| | | private static final Set<String> LOGGABLE_CONTENT_TYPES = new HashSet<>(Arrays.asList( |
| | | MediaType.APPLICATION_JSON_VALUE, |
| | | MediaType.APPLICATION_JSON_UTF8_VALUE, |
| | | MediaType.TEXT_PLAIN_VALUE, |
| | | MediaType.TEXT_XML_VALUE, |
| | | MediaType.APPLICATION_XML_VALUE, |
| | | "application/x-www-form-urlencoded" |
| | | )); |
| | | |
| | | private static final int MAX_BODY_LENGTH = 2000; |
| | | |
| | | @Override |
| | | protected boolean shouldNotFilter(HttpServletRequest request) { |
| | | String uri = request.getRequestURI(); |
| | | return EXCLUDE_PATHS.stream().anyMatch(uri::startsWith); |
| | | } |
| | | |
| | | @Override |
| | | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, |
| | | FilterChain chain) throws ServletException, IOException { |
| | | // 初始化 MDC(Filter 先于 Interceptor 执行,在此统一设置) |
| | | String traceId = request.getHeader("X-Trace-Id"); |
| | | if (traceId == null || traceId.isEmpty()) { |
| | | traceId = java.util.UUID.randomUUID().toString().replace("-", ""); |
| | | } |
| | | MDC.put("traceId", traceId); |
| | | MDC.put("clientIp", getClientIp(request)); |
| | | MDC.put("requestUri", request.getRequestURI()); |
| | | response.setHeader("X-Trace-Id", traceId); |
| | | |
| | | ContentCachingRequestWrapper reqWrapper = new ContentCachingRequestWrapper(request); |
| | | ContentCachingResponseWrapper respWrapper = new ContentCachingResponseWrapper(response); |
| | | |
| | | long start = System.currentTimeMillis(); |
| | | try { |
| | | chain.doFilter(reqWrapper, respWrapper); |
| | | } finally { |
| | | long cost = System.currentTimeMillis() - start; |
| | | logRequest(reqWrapper); |
| | | logResponse(respWrapper, cost); |
| | | // 必须把响应体写回,否则客户端收不到数据 |
| | | respWrapper.copyBodyToResponse(); |
| | | MDC.clear(); |
| | | } |
| | | } |
| | | |
| | | private String getClientIp(HttpServletRequest request) { |
| | | String ip = request.getHeader("X-Forwarded-For"); |
| | | if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { |
| | | return ip.split(",")[0].trim(); |
| | | } |
| | | ip = request.getHeader("X-Real-IP"); |
| | | if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { |
| | | return ip; |
| | | } |
| | | return request.getRemoteAddr(); |
| | | } |
| | | |
| | | private void logRequest(ContentCachingRequestWrapper request) { |
| | | String traceId = MDC.get("traceId"); |
| | | String uri = request.getRequestURI(); |
| | | String query = request.getQueryString(); |
| | | String method = request.getMethod(); |
| | | String body = extractBody(request.getContentAsByteArray(), request.getContentType()); |
| | | |
| | | log.info(">>> [{}] {} {}{} | body={}", |
| | | traceId, |
| | | method, |
| | | uri, |
| | | query != null ? "?" + query : "", |
| | | body); |
| | | } |
| | | |
| | | private void logResponse(ContentCachingResponseWrapper response, long cost) { |
| | | String traceId = MDC.get("traceId"); |
| | | int status = response.getStatus(); |
| | | String body = extractBody(response.getContentAsByteArray(), response.getContentType()); |
| | | |
| | | log.info("<<< [{}] status={} cost={}ms | body={}", |
| | | traceId, status, cost, body); |
| | | } |
| | | |
| | | private String extractBody(byte[] bodyBytes, String contentType) { |
| | | if (bodyBytes == null || bodyBytes.length == 0) { |
| | | return ""; |
| | | } |
| | | if (!isLoggableContentType(contentType)) { |
| | | return "[binary, " + bodyBytes.length + " bytes]"; |
| | | } |
| | | String body = new String(bodyBytes, StandardCharsets.UTF_8); |
| | | if (body.length() > MAX_BODY_LENGTH) { |
| | | body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; |
| | | } |
| | | return body; |
| | | } |
| | | |
| | | private boolean isLoggableContentType(String contentType) { |
| | | if (contentType == null) return true; |
| | | String ct = contentType.toLowerCase(); |
| | | return LOGGABLE_CONTENT_TYPES.stream().anyMatch(ct::contains); |
| | | } |
| | | } |
| | |
| | | /** |
| | | * Feign 调用日志切面 |
| | | * |
| | | * <p>拦截 com.ros.feign 包下所有 Feign 接口方法,记录: |
| | | * <p>拦截所有带 @FeignClient 注解的接口方法,记录: |
| | | * <ul> |
| | | * <li>调用的接口类名 + 方法名</li> |
| | | * <li>入参(JSON 序列化)</li> |
| | |
| | | * <li>耗时(ms)</li> |
| | | * <li>异常信息(含完整堆栈)</li> |
| | | * </ul> |
| | | * |
| | | * <p>同时覆盖任何实现了 Feign 接口的 bean(包括 SecureGatewayFeignService 等外部引入的客户端), |
| | | * 通过第二个切点 {@code feignBeanMethods()} 拦截。 |
| | | */ |
| | | @Slf4j |
| | | @Aspect |
| | |
| | | public class FeignLoggingAspect { |
| | | |
| | | /** |
| | | * 切点1:com.ros.feign 包下所有类的所有方法 |
| | | * 覆盖 UserCenterFeign、RobotFeign、MappingServiceFeign、VmpRobotFeign 等 |
| | | */ |
| | | @Around("execution(* com.ros.feign..*(..))") |
| | | public Object logRosFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | /** |
| | | * 切点2:所有带 @FeignClient 注解的接口的实现 bean 的所有方法 |
| | | * 覆盖 SecureGatewayFeignService 等外部包引入的 Feign 客户端 |
| | | * 拦截所有带 @FeignClient 注解的接口的所有方法 |
| | | */ |
| | | @Around("within(@org.springframework.cloud.openfeign.FeignClient *)") |
| | | public Object logExternalFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | public Object logFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | private Object doLog(ProceedingJoinPoint pjp) throws Throwable { |
| | | MethodSignature signature = (MethodSignature) pjp.getSignature(); |
| | | Method method = signature.getMethod(); |
| | | String className = pjp.getTarget().getClass().getSimpleName(); |
| | | // 使用接口声明类名,避免打印动态代理类名(如 $Proxy270) |
| | | String className = signature.getDeclaringType().getSimpleName(); |
| | | String methodName = method.getName(); |
| | | String fullMethod = className + "#" + methodName; |
| | | |
| New file |
| | |
| | | package com.xiaopeng.maintenance.config.log; |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.aspectj.lang.ProceedingJoinPoint; |
| | | import org.aspectj.lang.annotation.Around; |
| | | import org.aspectj.lang.annotation.Aspect; |
| | | import org.aspectj.lang.reflect.MethodSignature; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.lang.reflect.Method; |
| | | import java.util.Arrays; |
| | | |
| | | /** |
| | | * Feign 调用日志切面 |
| | | * |
| | | * <p>拦截所有带 @FeignClient 注解的接口方法,记录: |
| | | * <ul> |
| | | * <li>调用的接口类名 + 方法名</li> |
| | | * <li>入参(JSON 序列化)</li> |
| | | * <li>出参(JSON 序列化)</li> |
| | | * <li>耗时(ms)</li> |
| | | * <li>异常信息(含完整堆栈)</li> |
| | | * </ul> |
| | | */ |
| | | @Slf4j |
| | | @Aspect |
| | | @Component |
| | | public class FeignLoggingAspect { |
| | | |
| | | /** |
| | | * 拦截所有带 @FeignClient 注解的接口的所有方法 |
| | | */ |
| | | @Around("within(@org.springframework.cloud.openfeign.FeignClient *)") |
| | | public Object logFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | private Object doLog(ProceedingJoinPoint pjp) throws Throwable { |
| | | MethodSignature signature = (MethodSignature) pjp.getSignature(); |
| | | Method method = signature.getMethod(); |
| | | // 使用接口声明类名,避免打印动态代理类名(如 $Proxy270) |
| | | String className = signature.getDeclaringType().getSimpleName(); |
| | | String methodName = method.getName(); |
| | | String fullMethod = className + "#" + methodName; |
| | | |
| | | Object[] args = pjp.getArgs(); |
| | | String argsJson = safeToJson(args); |
| | | |
| | | log.info("[Feign] >>> 调用开始 | method={} | args={}", fullMethod, argsJson); |
| | | |
| | | long start = System.currentTimeMillis(); |
| | | try { |
| | | Object result = pjp.proceed(); |
| | | long elapsed = System.currentTimeMillis() - start; |
| | | String resultJson = safeToJson(result); |
| | | log.info("[Feign] <<< 调用成功 | method={} | elapsed={}ms | result={}", fullMethod, elapsed, resultJson); |
| | | return result; |
| | | } catch (Throwable ex) { |
| | | long elapsed = System.currentTimeMillis() - start; |
| | | log.error("[Feign] <<< 调用异常 | method={} | elapsed={}ms | error={} | args={}", |
| | | fullMethod, elapsed, ex.getMessage(), argsJson, ex); |
| | | throw ex; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 安全序列化为 JSON,避免序列化失败导致日志切面本身抛异常 |
| | | */ |
| | | private String safeToJson(Object obj) { |
| | | if (obj == null) { |
| | | return "null"; |
| | | } |
| | | try { |
| | | if (obj instanceof Object[]) { |
| | | return JSON.toJSONString(Arrays.asList((Object[]) obj)); |
| | | } |
| | | return JSON.toJSONString(obj); |
| | | } catch (Exception e) { |
| | | return "[序列化失败: " + obj.getClass().getSimpleName() + "]"; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xiaopeng.maintenance.config.log; |
| | | |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.slf4j.MDC; |
| | | import org.springframework.core.Ordered; |
| | | import org.springframework.core.annotation.Order; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.web.filter.OncePerRequestFilter; |
| | | import org.springframework.web.util.ContentCachingRequestWrapper; |
| | | import org.springframework.web.util.ContentCachingResponseWrapper; |
| | | |
| | | import javax.servlet.FilterChain; |
| | | import javax.servlet.ServletException; |
| | | import javax.servlet.http.HttpServletRequest; |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.io.IOException; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.Arrays; |
| | | import java.util.HashSet; |
| | | import java.util.Set; |
| | | |
| | | /** |
| | | * 请求/响应日志过滤器 |
| | | * 打印:traceId、method、uri、queryString、请求体、响应状态、耗时、响应体 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | @Order(Ordered.HIGHEST_PRECEDENCE + 10) |
| | | public class RequestResponseLoggingFilter extends OncePerRequestFilter { |
| | | |
| | | /** 不打印 body 的路径前缀(健康检查、actuator 等) */ |
| | | private static final Set<String> EXCLUDE_PATHS = new HashSet<>(Arrays.asList( |
| | | "/actuator", "/healthcheck", "/health", "/prometheus" |
| | | )); |
| | | |
| | | /** 只对这些 Content-Type 打印 body(避免打印二进制流) */ |
| | | private static final Set<String> LOGGABLE_CONTENT_TYPES = new HashSet<>(Arrays.asList( |
| | | MediaType.APPLICATION_JSON_VALUE, |
| | | MediaType.APPLICATION_JSON_UTF8_VALUE, |
| | | MediaType.TEXT_PLAIN_VALUE, |
| | | MediaType.TEXT_XML_VALUE, |
| | | MediaType.APPLICATION_XML_VALUE, |
| | | "application/x-www-form-urlencoded" |
| | | )); |
| | | |
| | | private static final int MAX_BODY_LENGTH = 2000; |
| | | |
| | | @Override |
| | | protected boolean shouldNotFilter(HttpServletRequest request) { |
| | | String uri = request.getRequestURI(); |
| | | return EXCLUDE_PATHS.stream().anyMatch(uri::startsWith); |
| | | } |
| | | |
| | | @Override |
| | | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, |
| | | FilterChain chain) throws ServletException, IOException { |
| | | // 初始化 MDC(Filter 先于 Interceptor 执行,在此统一设置) |
| | | String traceId = request.getHeader("X-Trace-Id"); |
| | | if (traceId == null || traceId.isEmpty()) { |
| | | traceId = java.util.UUID.randomUUID().toString().replace("-", ""); |
| | | } |
| | | MDC.put("traceId", traceId); |
| | | MDC.put("clientIp", getClientIp(request)); |
| | | MDC.put("requestUri", request.getRequestURI()); |
| | | response.setHeader("X-Trace-Id", traceId); |
| | | |
| | | ContentCachingRequestWrapper reqWrapper = new ContentCachingRequestWrapper(request); |
| | | ContentCachingResponseWrapper respWrapper = new ContentCachingResponseWrapper(response); |
| | | |
| | | long start = System.currentTimeMillis(); |
| | | try { |
| | | chain.doFilter(reqWrapper, respWrapper); |
| | | } finally { |
| | | long cost = System.currentTimeMillis() - start; |
| | | logRequest(reqWrapper); |
| | | logResponse(respWrapper, cost); |
| | | // 必须把响应体写回,否则客户端收不到数据 |
| | | respWrapper.copyBodyToResponse(); |
| | | MDC.clear(); |
| | | } |
| | | } |
| | | |
| | | private String getClientIp(HttpServletRequest request) { |
| | | String ip = request.getHeader("X-Forwarded-For"); |
| | | if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { |
| | | return ip.split(",")[0].trim(); |
| | | } |
| | | ip = request.getHeader("X-Real-IP"); |
| | | if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { |
| | | return ip; |
| | | } |
| | | return request.getRemoteAddr(); |
| | | } |
| | | |
| | | private void logRequest(ContentCachingRequestWrapper request) { |
| | | String traceId = MDC.get("traceId"); |
| | | String uri = request.getRequestURI(); |
| | | String query = request.getQueryString(); |
| | | String method = request.getMethod(); |
| | | String body = extractBody(request.getContentAsByteArray(), request.getContentType()); |
| | | |
| | | log.info(">>> [{}] {} {}{} | body={}", |
| | | traceId, |
| | | method, |
| | | uri, |
| | | query != null ? "?" + query : "", |
| | | body); |
| | | } |
| | | |
| | | private void logResponse(ContentCachingResponseWrapper response, long cost) { |
| | | String traceId = MDC.get("traceId"); |
| | | int status = response.getStatus(); |
| | | String body = extractBody(response.getContentAsByteArray(), response.getContentType()); |
| | | |
| | | log.info("<<< [{}] status={} cost={}ms | body={}", |
| | | traceId, status, cost, body); |
| | | } |
| | | |
| | | private String extractBody(byte[] bodyBytes, String contentType) { |
| | | if (bodyBytes == null || bodyBytes.length == 0) { |
| | | return ""; |
| | | } |
| | | if (!isLoggableContentType(contentType)) { |
| | | return "[binary, " + bodyBytes.length + " bytes]"; |
| | | } |
| | | String body = new String(bodyBytes, StandardCharsets.UTF_8); |
| | | if (body.length() > MAX_BODY_LENGTH) { |
| | | body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; |
| | | } |
| | | return body; |
| | | } |
| | | |
| | | private boolean isLoggableContentType(String contentType) { |
| | | if (contentType == null) return true; |
| | | String ct = contentType.toLowerCase(); |
| | | return LOGGABLE_CONTENT_TYPES.stream().anyMatch(ct::contains); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xiaopeng.map.config.log; |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.aspectj.lang.ProceedingJoinPoint; |
| | | import org.aspectj.lang.annotation.Around; |
| | | import org.aspectj.lang.annotation.Aspect; |
| | | import org.aspectj.lang.reflect.MethodSignature; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.lang.reflect.Method; |
| | | import java.util.Arrays; |
| | | |
| | | /** |
| | | * Feign 调用日志切面 |
| | | * |
| | | * <p>拦截所有带 @FeignClient 注解的接口方法,记录: |
| | | * <ul> |
| | | * <li>调用的接口类名 + 方法名</li> |
| | | * <li>入参(JSON 序列化)</li> |
| | | * <li>出参(JSON 序列化)</li> |
| | | * <li>耗时(ms)</li> |
| | | * <li>异常信息(含完整堆栈)</li> |
| | | * </ul> |
| | | */ |
| | | @Slf4j |
| | | @Aspect |
| | | @Component |
| | | public class FeignLoggingAspect { |
| | | |
| | | /** |
| | | * 拦截所有带 @FeignClient 注解的接口的所有方法 |
| | | */ |
| | | @Around("within(@org.springframework.cloud.openfeign.FeignClient *)") |
| | | public Object logFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | private Object doLog(ProceedingJoinPoint pjp) throws Throwable { |
| | | MethodSignature signature = (MethodSignature) pjp.getSignature(); |
| | | Method method = signature.getMethod(); |
| | | // 使用接口声明类名,避免打印动态代理类名(如 $Proxy270) |
| | | String className = signature.getDeclaringType().getSimpleName(); |
| | | String methodName = method.getName(); |
| | | String fullMethod = className + "#" + methodName; |
| | | |
| | | Object[] args = pjp.getArgs(); |
| | | String argsJson = safeToJson(args); |
| | | |
| | | log.info("[Feign] >>> 调用开始 | method={} | args={}", fullMethod, argsJson); |
| | | |
| | | long start = System.currentTimeMillis(); |
| | | try { |
| | | Object result = pjp.proceed(); |
| | | long elapsed = System.currentTimeMillis() - start; |
| | | String resultJson = safeToJson(result); |
| | | log.info("[Feign] <<< 调用成功 | method={} | elapsed={}ms | result={}", fullMethod, elapsed, resultJson); |
| | | return result; |
| | | } catch (Throwable ex) { |
| | | long elapsed = System.currentTimeMillis() - start; |
| | | log.error("[Feign] <<< 调用异常 | method={} | elapsed={}ms | error={} | args={}", |
| | | fullMethod, elapsed, ex.getMessage(), argsJson, ex); |
| | | throw ex; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 安全序列化为 JSON,避免序列化失败导致日志切面本身抛异常 |
| | | */ |
| | | private String safeToJson(Object obj) { |
| | | if (obj == null) { |
| | | return "null"; |
| | | } |
| | | try { |
| | | if (obj instanceof Object[]) { |
| | | return JSON.toJSONString(Arrays.asList((Object[]) obj)); |
| | | } |
| | | return JSON.toJSONString(obj); |
| | | } catch (Exception e) { |
| | | return "[序列化失败: " + obj.getClass().getSimpleName() + "]"; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xiaopeng.map.config.log; |
| | | |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.slf4j.MDC; |
| | | import org.springframework.core.Ordered; |
| | | import org.springframework.core.annotation.Order; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.stereotype.Component; |
| | | import org.springframework.web.filter.OncePerRequestFilter; |
| | | import org.springframework.web.util.ContentCachingRequestWrapper; |
| | | import org.springframework.web.util.ContentCachingResponseWrapper; |
| | | |
| | | import javax.servlet.FilterChain; |
| | | import javax.servlet.ServletException; |
| | | import javax.servlet.http.HttpServletRequest; |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.io.IOException; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.Arrays; |
| | | import java.util.HashSet; |
| | | import java.util.Set; |
| | | |
| | | /** |
| | | * 请求/响应日志过滤器 |
| | | * 打印:traceId、method、uri、queryString、请求体、响应状态、耗时、响应体 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | @Order(Ordered.HIGHEST_PRECEDENCE + 10) |
| | | public class RequestResponseLoggingFilter extends OncePerRequestFilter { |
| | | |
| | | /** 不打印 body 的路径前缀(健康检查、actuator 等) */ |
| | | private static final Set<String> EXCLUDE_PATHS = new HashSet<>(Arrays.asList( |
| | | "/actuator", "/healthcheck", "/health", "/prometheus" |
| | | )); |
| | | |
| | | /** 只对这些 Content-Type 打印 body(避免打印二进制流) */ |
| | | private static final Set<String> LOGGABLE_CONTENT_TYPES = new HashSet<>(Arrays.asList( |
| | | MediaType.APPLICATION_JSON_VALUE, |
| | | MediaType.APPLICATION_JSON_UTF8_VALUE, |
| | | MediaType.TEXT_PLAIN_VALUE, |
| | | MediaType.TEXT_XML_VALUE, |
| | | MediaType.APPLICATION_XML_VALUE, |
| | | "application/x-www-form-urlencoded" |
| | | )); |
| | | |
| | | private static final int MAX_BODY_LENGTH = 2000; |
| | | |
| | | @Override |
| | | protected boolean shouldNotFilter(HttpServletRequest request) { |
| | | String uri = request.getRequestURI(); |
| | | return EXCLUDE_PATHS.stream().anyMatch(uri::startsWith); |
| | | } |
| | | |
| | | @Override |
| | | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, |
| | | FilterChain chain) throws ServletException, IOException { |
| | | // 初始化 MDC(Filter 先于 Interceptor 执行,在此统一设置) |
| | | String traceId = request.getHeader("X-Trace-Id"); |
| | | if (traceId == null || traceId.isEmpty()) { |
| | | traceId = java.util.UUID.randomUUID().toString().replace("-", ""); |
| | | } |
| | | MDC.put("traceId", traceId); |
| | | MDC.put("clientIp", getClientIp(request)); |
| | | MDC.put("requestUri", request.getRequestURI()); |
| | | response.setHeader("X-Trace-Id", traceId); |
| | | |
| | | ContentCachingRequestWrapper reqWrapper = new ContentCachingRequestWrapper(request); |
| | | ContentCachingResponseWrapper respWrapper = new ContentCachingResponseWrapper(response); |
| | | |
| | | long start = System.currentTimeMillis(); |
| | | try { |
| | | chain.doFilter(reqWrapper, respWrapper); |
| | | } finally { |
| | | long cost = System.currentTimeMillis() - start; |
| | | logRequest(reqWrapper); |
| | | logResponse(respWrapper, cost); |
| | | // 必须把响应体写回,否则客户端收不到数据 |
| | | respWrapper.copyBodyToResponse(); |
| | | MDC.clear(); |
| | | } |
| | | } |
| | | |
| | | private String getClientIp(HttpServletRequest request) { |
| | | String ip = request.getHeader("X-Forwarded-For"); |
| | | if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { |
| | | return ip.split(",")[0].trim(); |
| | | } |
| | | ip = request.getHeader("X-Real-IP"); |
| | | if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { |
| | | return ip; |
| | | } |
| | | return request.getRemoteAddr(); |
| | | } |
| | | |
| | | private void logRequest(ContentCachingRequestWrapper request) { |
| | | String traceId = MDC.get("traceId"); |
| | | String uri = request.getRequestURI(); |
| | | String query = request.getQueryString(); |
| | | String method = request.getMethod(); |
| | | String body = extractBody(request.getContentAsByteArray(), request.getContentType()); |
| | | |
| | | log.info(">>> [{}] {} {}{} | body={}", |
| | | traceId, |
| | | method, |
| | | uri, |
| | | query != null ? "?" + query : "", |
| | | body); |
| | | } |
| | | |
| | | private void logResponse(ContentCachingResponseWrapper response, long cost) { |
| | | String traceId = MDC.get("traceId"); |
| | | int status = response.getStatus(); |
| | | String body = extractBody(response.getContentAsByteArray(), response.getContentType()); |
| | | |
| | | log.info("<<< [{}] status={} cost={}ms | body={}", |
| | | traceId, status, cost, body); |
| | | } |
| | | |
| | | private String extractBody(byte[] bodyBytes, String contentType) { |
| | | if (bodyBytes == null || bodyBytes.length == 0) { |
| | | return ""; |
| | | } |
| | | if (!isLoggableContentType(contentType)) { |
| | | return "[binary, " + bodyBytes.length + " bytes]"; |
| | | } |
| | | String body = new String(bodyBytes, StandardCharsets.UTF_8); |
| | | if (body.length() > MAX_BODY_LENGTH) { |
| | | body = body.substring(0, MAX_BODY_LENGTH) + "...(truncated)"; |
| | | } |
| | | return body; |
| | | } |
| | | |
| | | private boolean isLoggableContentType(String contentType) { |
| | | if (contentType == null) return true; |
| | | String ct = contentType.toLowerCase(); |
| | | return LOGGABLE_CONTENT_TYPES.stream().anyMatch(ct::contains); |
| | | } |
| | | } |
| | |
| | | /** |
| | | * Feign 调用日志切面 |
| | | * |
| | | * <p>拦截 com.ros.feign 包下所有 Feign 接口方法,记录: |
| | | * <p>拦截所有带 @FeignClient 注解的接口方法,记录: |
| | | * <ul> |
| | | * <li>调用的接口类名 + 方法名</li> |
| | | * <li>入参(JSON 序列化)</li> |
| | |
| | | * <li>耗时(ms)</li> |
| | | * <li>异常信息(含完整堆栈)</li> |
| | | * </ul> |
| | | * |
| | | * <p>同时覆盖任何实现了 Feign 接口的 bean(包括 SecureGatewayFeignService 等外部引入的客户端), |
| | | * 通过第二个切点 {@code feignBeanMethods()} 拦截。 |
| | | */ |
| | | @Slf4j |
| | | @Aspect |
| | |
| | | public class FeignLoggingAspect { |
| | | |
| | | /** |
| | | * 切点1:com.ros.feign 包下所有类的所有方法 |
| | | * 覆盖 UserCenterFeign、RobotFeign、MappingServiceFeign、VmpRobotFeign 等 |
| | | */ |
| | | @Around("execution(* com.ros.feign..*(..))") |
| | | public Object logRosFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | /** |
| | | * 切点2:所有带 @FeignClient 注解的接口的实现 bean 的所有方法 |
| | | * 覆盖 SecureGatewayFeignService 等外部包引入的 Feign 客户端 |
| | | * 拦截所有带 @FeignClient 注解的接口的所有方法 |
| | | */ |
| | | @Around("within(@org.springframework.cloud.openfeign.FeignClient *)") |
| | | public Object logExternalFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | public Object logFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | private Object doLog(ProceedingJoinPoint pjp) throws Throwable { |
| | | MethodSignature signature = (MethodSignature) pjp.getSignature(); |
| | | Method method = signature.getMethod(); |
| | | String className = pjp.getTarget().getClass().getSimpleName(); |
| | | // 使用接口声明类名,避免打印动态代理类名(如 $Proxy270) |
| | | String className = signature.getDeclaringType().getSimpleName(); |
| | | String methodName = method.getName(); |
| | | String fullMethod = className + "#" + methodName; |
| | | |
| | |
| | | /** |
| | | * Feign 调用日志切面 |
| | | * |
| | | * <p>拦截 com.ros.feign 包下所有 Feign 接口方法,记录: |
| | | * <p>拦截所有带 @FeignClient 注解的接口方法,记录: |
| | | * <ul> |
| | | * <li>调用的接口类名 + 方法名</li> |
| | | * <li>入参(JSON 序列化)</li> |
| | |
| | | * <li>耗时(ms)</li> |
| | | * <li>异常信息(含完整堆栈)</li> |
| | | * </ul> |
| | | * |
| | | * <p>同时覆盖任何实现了 Feign 接口的 bean(包括 SecureGatewayFeignService 等外部引入的客户端), |
| | | * 通过第二个切点 {@code feignBeanMethods()} 拦截。 |
| | | */ |
| | | @Slf4j |
| | | @Aspect |
| | |
| | | public class FeignLoggingAspect { |
| | | |
| | | /** |
| | | * 切点1:com.ros.feign 包下所有类的所有方法 |
| | | * 覆盖 UserCenterFeign、RobotFeign、MappingServiceFeign、VmpRobotFeign 等 |
| | | */ |
| | | @Around("execution(* com.ros.feign..*(..))") |
| | | public Object logRosFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | /** |
| | | * 切点2:所有带 @FeignClient 注解的接口的实现 bean 的所有方法 |
| | | * 覆盖 SecureGatewayFeignService 等外部包引入的 Feign 客户端 |
| | | * 拦截所有带 @FeignClient 注解的接口的所有方法 |
| | | */ |
| | | @Around("within(@org.springframework.cloud.openfeign.FeignClient *)") |
| | | public Object logExternalFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | public Object logFeignCall(ProceedingJoinPoint pjp) throws Throwable { |
| | | return doLog(pjp); |
| | | } |
| | | |
| | | private Object doLog(ProceedingJoinPoint pjp) throws Throwable { |
| | | MethodSignature signature = (MethodSignature) pjp.getSignature(); |
| | | Method method = signature.getMethod(); |
| | | String className = pjp.getTarget().getClass().getSimpleName(); |
| | | // 使用接口声明类名,避免打印动态代理类名(如 $Proxy270) |
| | | String className = signature.getDeclaringType().getSimpleName(); |
| | | String methodName = method.getName(); |
| | | String fullMethod = className + "#" + methodName; |
| | | |