基于注解脱敏+链路追踪traceId 快速定位错误

日常开发中,你如何快速定位问题的?

a. 排除条件

特别是处理并发请求时,通过对每个方法生成traceId(自定义唯一标识符)用于快速定位目标日志,追踪整个系统中的请求流程。

b. 日志脱敏增强

返回值带着敏感信息,要么不返回,要么加密

请求参数带着敏感信息,要么不记录,要么加密

c. 解决方案

服务端入口处可以生成唯一的id,叫做:traceId

日志中均需要输出traceId的值

接口返回值中,添加一个通用的字段: traceld,将上面的traceld作为这个字段的值

这样前端发现接口有问题的时候,直接将这个traceld提供给我们,我们便可以在日志中快速查询出对应的日志。

MDC组件

使用Slf4j.MDC日志功能增强

org.slf4j.MDC是 SLF4J库中的一个组件,MDC提供了一种机制,允许在日志消息中插入上下文信息,这些信息可以跨多个方法调用和线程边界传播。这对于跟踪和调试分布式系统或多线程应用程序中的请求非常有用。

重点:**MDC 允许将键值对与当前线程关联起来(类似ThreadLocal)**然后,可以在我们的日志语句中引用这些值,从而能够更容易地识别和理解日志消息产生的上下文。

例如,你可能会在 Web 应用程序的每个请求开始时,将用户的 ID 或会话 ID 放入 MDC,然后在你的日志语句中引用这个值。这样,当你查看日志时,你可以很容易地看到哪个用户的哪个请求产生了哪些日志消息。

使用方式

Logback学习系列4(Pattern使用方法)https://www.jianshu.com/p/5cfc26caf50d

  • 设置值: MDC.put("userId", "12345");
  • 在日志语句中使用值: logger.info("Processing request for user: {}", MDC.get("userId"));
  • 清除值 MDC.remove("userId")
例子

假设我们正在开发一个电商网站,该网站由多个微服务组成,包括用户服务、订单服务、支付服务等。每个服务都运行在独立的服务器上,并通过REST API相互调用。为了方便调试和监控,我们需要在整个请求处理过程中跟踪每一个请求,并确保所有相关的日志条目都能关联起来。

  1. 当请求到达第一个服务(例如用户服务)时,TraceFilter生成一个唯一的traceId并将其放入MDC中。

  2. 当用户服务需要调用订单服务时,将当前线程中的traceId作为HTTP请求头的一部分传递给下游服务(订单服务)。在订单服务接收到请求时,从请求头中读取traceId并再次放入MDC中。

    // 客户端拦截器:将TraceId注入HTTP头
    public class OpenFeignRequestInterceptor implements RequestInterceptor {
        @Override
        public void apply(RequestTemplate requestTemplate) {
            String traceId = MDC.get("traceId"); // 从当前线程上下文中获取TraceId
            requestTemplate.header("X-Trace-ID", traceId); // 注入到HTTP头
        }
    }
    
    // 服务端处理:从HTTP头提取TraceId
    public class TraceFilter implements Filter {
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
            String traceId = request.getParameter("X-Trace-ID"); // 从HTTP头获取TraceId
            MDC.put("traceId", traceId); // 写入当前线程上下文
            chain.doFilter(request, response);
        }
    }
    
  3. 在每个服务的日志配置文件中,添加traceId到日志格式中,以便每条日志都包含这个信息。开发人员只需搜索traceId: xyz789即可快速定位问题根源。

    [订单服务] [traceId: xyz789] - 开始创建订单
    [库存服务] [traceId: xyz789] - 扣减库存成功
    [支付服务] [traceId: xyz789] - 支付失败,数据库连接超时
    
  4. 现在,无论请求跨越了多少个服务,只要查看日志,就可以通过traceId轻松地将所有相关的日志条目关联起来,从而快速定位问题或理解系统的运行情况。

核心代码
自定义注解

脱敏注解@NoLogAnnotation 相关方法加上注解后隐藏参数信息,脱敏前 密码没加密时会暴露用户密码等敏感信息

@Target({ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoLogAnnotation {
    //可以用在参数列表或者方法上,屏蔽不愿意记录的参数
}

日志配置logback.xml展示TraceId

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- 日志输出格式 将MDC中的traceId自动嵌入每条日志(唯一)-->
            <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] [traceId:%X{traceId}] - %msg%n</pattern>
        </encoder>
    </appender>

    <logger name="com.zr.study" level="info" />
    <root level="info">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>
链路追踪组件

TraceConfiguration,配置类注入过滤器和切面类进ioc容器

package com.zr.study.trace;
import com.zr.study.aop.ResultTraceIdAspect;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
	
/**
 * proxyBeanMethods属性
 * 指定@Bean注解标注的方法是否使用代理,
 *  默认是true使用代理,直接从IOC容器之中取得对象;
 *  false:每次调用@Bean标注的方法获取到的对象和IOC容器中的都不一样,是一个新的对象,以此提高性能
 */
@Configuration(proxyBeanMethods = false)
public class TraceConfiguration {
    //注册过滤器与切面
    @Bean
    public TraceFilter traceFilter() {
        return new TraceFilter();
    }

    @Bean
    public ResultTraceIdAspect fillRequestIdAspect() {
        return new ResultTraceIdAspect();
    }
}

链路工具类

TraceUtils工具类封装了ThreadLocal存储获取删除traceId和MDC增删traceId的方法

  • 存到ThreadLocal:每个请求都运行在其自己的线程上,通过将traceId绑定到线程本地存储(ThreadLocal)
    • 可以确保每个线程都有其独立的traceId,避免了多线程环境下数据混淆问题。
    • 在请求任意过程中都可以获取traceId
  • 存到MDC中:可以在日志条目中自动包含这个traceId(底层也是基于ThreadLocalMap实现)
import org.slf4j.MDC;

public class TraceUtils {
    //结合线程本地存储和日志上下文,实现 traceId 的传递与日志关联
    public static final String TRACE_ID = "traceId";
    public static ThreadLocal<String> traceIdThreadLocal = new ThreadLocal<>();

    //设置 traceId 到当前线程的上下文中
    public static void setTraceId(String traceId) {
        traceIdThreadLocal.set(traceId);
        MDC.put(TRACE_ID, traceId); // 同步到 MDC
    }

    public static String getTraceId() {
        return traceIdThreadLocal.get();
    }

    public static void removeTraceId() {
        traceIdThreadLocal.remove();
        MDC.remove(TRACE_ID);
    }
}
链路过滤器

TraceFilter解耦业务,通过过滤器生成TraceId+插入TreadLocal和MDC中,并通过时间戳记录方法执行时间打印到控制台

import cn.hutool.core.util.IdUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

//确保该过滤器是第一个执行的,优先设置 traceId
@Order(Ordered.HIGHEST_PRECEDENCE)
//Web 过滤器,匹配所有请求路径
@WebFilter(urlPatterns = "/**", filterName = "TraceFilter")
public class TraceFilter extends OncePerRequestFilter {
    private Logger log = LoggerFactory.getLogger(this.getClass());

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        //尝试 从HTTP头获取TraceId
        String traceID =request.getParameter("X-Trace-ID"); 
        if (traceID == null) {
            traceID = IdUtil.fastSimpleUUID(); //生成唯一traceId
        }
        TraceUtils.setTraceId(traceID); //同步到线程上下文和MDC日志中

        long startTime = System.currentTimeMillis();
        try {
            filterChain.doFilter(request, response);
        } finally {
            //请求后续处理完成或异常,都会返回这里清理traceId
            TraceUtils.removeTraceId();
        }
        long endTime = System.currentTimeMillis();  //记录请求耗时
        log.info("请求地址:{},耗时(毫秒):{}", request.getRequestURL().toString(), (endTime - startTime));
    }
}
切面类

ResultTraceIdAspect:环绕通知切点为Controller和全局异常处理器,目的是无论方法是否异常,给统一方法返回类插入traceId

🔁 执行流程如下:

  1. 进入切面
  2. 调用 proceed() 方法 → 实际上就是调用目标方法(Controller 或 ExceptionHandler 的方法)
  3. 获取返回值 result
  4. 判断是否是 ResultData 类型(自定义统一返回包装类)
  5. 如果是,则设置 traceId
  6. 返回增强后的结果
@Order
@Aspect
@Component
public class ResultTraceIdAspect {
    //拦截所有 Controller 方法和全局异常处理器方法
    //对所有控制器层和异常处理器的返回结果进行统一增强处理
    @Pointcut("execution(* com.zr..*Controller.*(..)) || execution(* com.zr.study.exp.GlobalExceptionHandler.*(..))")
    public void pointCut() {
    }

    @Around("pointCut()")  //环绕通知,如针对controller方法的前后进行增强
    public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        Object result = null;

        result = proceedingJoinPoint.proceed(); //放行

        if (result instanceof ResultData) {
            ((ResultData<?>) result).setTraceId(TraceUtils.getTraceId());
        }
        return result;
    }
}

ResultData是统一返回类,这块相比于之前的,多了traceId链路追踪ID字段给前端

@Data
@Accessors(chain = true)
public class ResultData<T> {

    private String code;/** 结果状态 ,具体状态码参见枚举类ReturnCodeEnum.java*/
    private String message;
    private T data;
    private long timestamp ;

    private String traceId;


    public ResultData (){
        this.timestamp = System.currentTimeMillis();
    }

    public static <T> ResultData<T> success(T data) {
        ResultData<T> resultData = new ResultData<>();
        resultData.setCode(ReturnCodeEnum.RC200.getCode());
        resultData.setMessage(ReturnCodeEnum.RC200.getMessage());
        resultData.setData(data);
        return resultData;
    }

    public static <T> ResultData<T> fail(String code, String message) {
        ResultData<T> resultData = new ResultData<>();
        resultData.setCode(code);
        resultData.setMessage(message);

        return resultData;
    }

}
增强Controller

ControllerLogAspect:环绕通知切点为Controller对日志参数做封装并打印

//增强接口的日志监控能力,提升调试和排查问题的效率,同时提供灵活的控制开关(如脱敏、禁用日志等)
@Order(value = Ordered.HIGHEST_PRECEDENCE)  //最高优先级
@Aspect
@Component
public class ControllerLogAspect {
    private Logger log = LoggerFactory.getLogger(this.getClass());
    private static final String SALT = "zr"; // 可以使用随机盐或固定盐

    //拦截以 Controller 结尾的所有方法
    @Around("execution(* com.zr..*Controller.*(..))")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object result = null;
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

        try {
            //打印处理当前请求的完整类名和方法名称
            //例如:  接口方法:UserController.login
            log.info("接口方法:{}.{}", methodSignature.getDeclaringTypeName(), methodSignature.getName());

            //获取所有要打印的参数,丢到map中,key为参数名称,value为参数的值,然后会将这个map以json格式输出
            Map<String, Object> logParamsMap = new LinkedHashMap<>();
            String[] parameters = methodSignature.getParameterNames();
            Object[] args = joinPoint.getArgs();
            if(!ArrayUtil.isEmpty(parameters)){
                for (int i = 0; i < args.length; i++) {
                    //判断是否需要脱敏
                    if (parameterIsLog(methodSignature, i)) {
                        //参数名称
                        String parameterName = parameters[i];
                        //参数值
                        Object parameterValue = args[i];

                        //将其放入到map中,稍后会以json格式输出
                        logParamsMap.put(parameterName, parameterValue);
                    }
                }
            }
            //输出示例:  方法参数列表:{"username":"admin","password":"secret"}
            log.info("方法参数列表:{}", JSONUtil.toJsonStr(logParamsMap));

            result = joinPoint.proceed();   //程序放行......

            return result;
        } finally {
            //判断方法的返回值是否需要打印?方法上有 @NoLogAnnotation 注解的,表示结果不打印方法返回值
            if (this.resultIsLog(methodSignature)) {
                log.info("方法返回值:{}", JSONUtil.toJsonStr(result));
            }
        }
    }

    /**
     * 指定位置的参数是否需要打印出来?(脱敏操作)
     */
    private boolean parameterIsLog(MethodSignature methodSignature, int paramIndex) {
        if (methodSignature.getMethod().getParameterCount() == 0) {
            return false;
        }

        // 参数上有 @NoLogAnnotation注解的不会打印
        Annotation[] parameterAnnotation = methodSignature.getMethod().getParameterAnnotations()[paramIndex];
        if (parameterAnnotation != null && parameterAnnotation.length > 0) {
            for (Annotation annotation : parameterAnnotation) {
                if (annotation.annotationType() == NoLogAnnotation.class) {
                    return false;
                }
            }
        }

        //参数类型是 ServletRequest / ServletResponse / 其他配置的敏感类型?→ 不打印
        Class parameterType = methodSignature.getParameterTypes()[paramIndex];
        for (Class<?> type : noLogTypes) {
            if (type.isAssignableFrom(parameterType)) {
                return false;
            }
        }
        return true;
    }

    // 参数类型是下面这些类型的,也不会打印,比如:ServletRequest、ServletResponse,大家可以扩展
    private static List<Class<?>> noLogTypes = Arrays.asList(ServletRequest.class, ServletResponse.class);

    /**
     * 判断方法的返回值是否需要打印?方法上有 @NoLogAnnotation 注解的,表示结果不打印方法返回值
     */
    private boolean resultIsLog(MethodSignature methodSignature) {
        return methodSignature.getMethod().getAnnotation(NoLogAnnotation.class) == null;
    }
}
测试

查询数据效果
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

空说

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值