SpringCloudGateway通过traceId实现全链路日志追踪

问题:

在日常开发过程中,如果使用微服务架构,那么日志查询就是一个问题,比如A服务调用了B服务,B服务调用了C服务,这个时候C服务报错了,导致整个请求异常失败,如果想排查这个问题,没有日志整合的话,我们排查问题原因就变的很麻烦

解决方案:

在网关服务接收到请求的时候生成一个traceId,然后将traceId在每个服务间传递,同时日志打印的时候将traceId一起打印出来,这样在使用ELK去查询日志的时候,只需要搜索一个traceId,就可以查询的到整个请求的全链路日志信息了。

准备:

1:网关服务添加自定义拦截器
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.auth0.jwt.JWT;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;

@Slf4j
@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {
    
    

    private static final String TRACE_ID = "traceId";

    private static final AntPathMatcher matcher = new AntPathMatcher();

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    
    
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        //对请求对象request进行增强
        ServerHttpRequest req = request.mutate().headers(httpHeaders -> {
    
    
            //httpHeaders 封装了所有的请求头
            String traceId = UUID.randomUUID().toString(true);
            MDC.put(TRACE_ID, traceId);
            httpHeaders.set(TRACE_ID, traceId);
        }).build();
        //设置增强的request到exchange对象中
        exchange.mutate().request(req);
        String url = request.getURI().getPath();
        log.info("接收到请求:{}", url);
        // 跨域放行
        if (request.getMethod() == HttpMethod.OPTIONS) {
    
    
            response.setStatusCode(HttpStatus.OK);
            return Mono.empty();
        }
        // 不需要拦截的接口直接放行
        if (needLogin(request.getPath().toString())) {
    
    
            log.info("不拦截放行");
            return chain.filter(exchange);
        }
        // 授权验证
        if (!this.auth(exchange)) {
    
    
            return this.responseBody(exchange, 406, "请先登录");
        }
        log.info("认证成功,放行");
        return chain.filter(exchange);
    }

    /**
     * 是否需要登录
     *
     * @param uri 请求URI
     * @return boolean
     */
    public static boolean needLogin(String uri) {
    
    
        // test
        List<String> uriList = new ArrayList<>();
        uriList.add("/user/login");
        uriList.add("/demo/**");
        uriList.add("/**");

        for (String pattern : uriList) {
    
    
            if (matcher.match(pattern, uri)) {
    
    
                // 不需要拦截
                return true;
            }
        }
        return false;
    }

    /**
     * 认证拦截
     */
    private boolean auth(ServerWebExchange exchange) {
    
    
        String token = this.getToken(exchange.getRequest());
        log.info("token:{}", token);

        if (StrUtil.isBlank(token)) {
    
    
            return false;
        }
        JSONObject userInfo = getUserInfo(token);
        return !ObjectUtil.isNull(userInfo);
    }

    private JSONObject getUserInfo(String token) {
    
    
        JSONObject jsonObject;
        String tokenNew = token.substring(7);
        String ss = JWT.decode(tokenNew).getPayload();
        Base64.Decoder decoder = Base64.getDecoder();
        jsonObject = JSON.parseObject(new String(decoder.decode(ss)));
        return jsonObject;
    }

    /**
     * 获取token
     */
    public String getToken(ServerHttpRequest request) {
    
    
        String token = request.getHeaders().getFirst("Authorization");
        if (StrUtil.isBlank(token)) {
    
    
            return request.getQueryParams().getFirst("Authorization");
        }
        return token;
    }

    /**
     * 设置响应体
     **/
    public Mono<Void> responseBody(ServerWebExchange exchange, Integer code, String msg) {
    
    
        HashMap<Object, Object> hashMap = new HashMap<>();
        hashMap.put("code", code);
        hashMap.put("msg", msg);
        String message = JSON.toJSONString(hashMap);
        byte[] bytes = message.getBytes(StandardCharsets.UTF_8);
        return this.responseHeader(exchange).getResponse()
                .writeWith(Flux.just(exchange.getResponse().bufferFactory().wrap(bytes)));
    }

    /**
     * 设置响应体的请求头
     */
    public ServerWebExchange responseHeader(ServerWebExchange exchange) {
    
    
        ServerHttpResponse response = exchange.getResponse();
        response.getHeaders().add(HttpHeaders.CONTENT_TYPE, "application/json");
        return exchange.mutate().response(response).build();
    }

    @Override
    public int getOrder() {
    
    
        return 0;
    }

}

上述代码中可以只看下图部分,其他拦截授权等demo可以无视
在这里插入图片描述
通过上面我们自定义拦截器,对request进行了增强,在header中添加了一个traceId,值呢就是用UUID生成出来的随机字符串
同时使用MDC将traceId进行了put操作
下面我们会用MDC进行日志打印相关操作

2:网关配置文件
logging:
  file:
    path: /opt/log/gateway
  config: classpath:logbak-conf.xml
3:网关日志配置文件logbak
<?xml version="1.0" encoding="UTF-8" ?>
<configuration debug="false">
    <!--定义日志文件的存储地址 勿在 LogBack 的配置中使用相对路径-->
    <property name="LOG_HOME"
              value="${LOG_PATH:-.}"/>
    <!-- 控制台输出设置 -->
    <!-- 彩色日志格式,magenta:洋红,boldMagenta:粗红,yan:青色,·⊱══> -->
    <property name="CONSOLE_LOG_PATTERN"
              value="%boldMagenta([%d{yyyy-MM-dd HH:mm:ss.SSS}]) %cyan([%X{traceId}]) %boldMagenta(%-5level) %blue(%logger{15}) %magenta(==>) %cyan(%msg%n)"/>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
    </appender>
    <!-- 按天输出日志设置 -->
    <appender name="DAY_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">            <!-- 日志文件输出的文件名 -->
            <FileNamePattern>${LOG_HOME}/%d{yyyy-MM-dd}_ms_gateway.%i.log
            </FileNamePattern>
            <!-- 日志文件保留天数 -->
            <MaxHistory>7</MaxHistory>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>50MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
        </rollingPolicy>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>INFO</level>             <!-- 设置拦截的对象为INFO级别日志 -->
            <onMatch>ACCEPT</onMatch>       <!-- 当遇到了INFO级别时,启用改段配置 -->
            <onMismatch>DENY</onMismatch>   <!-- 没有遇到INFO级别日志时,屏蔽改段配置 -->
        </filter>
        <encoder
                class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">            <!-- 格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符 -->
            <pattern>%cyan([%X{traceId}]) %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
        </encoder>
    </appender>
    <!-- 按天输出ERROR级别日志设置 -->
    <appender name="DAY_ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">            <!-- 日志文件输出的文件名 -->
            <FileNamePattern>${LOG_HOME}/%d{yyyy-MM-dd}_ms_gateway_error.%i.log
            </FileNamePattern>
            <!-- 日志文件保留天数 -->
            <MaxHistory>7</MaxHistory>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>50MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
        </rollingPolicy>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>            <!-- 设置拦截的对象为ERROR级别日志 -->
            <onMatch>ACCEPT</onMatch>       <!-- 当遇到了ERROR级别时,启用改段配置 -->
            <onMismatch>DENY</onMismatch>   <!-- 没有遇到ERROR级别日志时,屏蔽改段配置 -->
        </filter>
        <encoder
                class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">            <!-- 格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符 -->
            <pattern>%cyan([%X{traceId}]) %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- 日志输出级别,OFF level > FATAL > ERROR > WARN > INFO > DEBUG > ALL level -->
    <logger name="com.sand" level="INFO"/>
    <logger name="com.apache.ibatis" level="INFO"/>
    <logger name="java.sql.Statement" level="INFO"/>
    <logger name="java.sql.Connection" level="INFO"/>
    <logger name="java.sql.PreparedStatement" level="INFO"/>
    <logger name="org.springframework" level="WARN"/>
    <logger name="com.baomidou.mybatisplus" level="WARN"/>

    <!-- 开发环境:打印控制台和输出到文件 -->
    <springProfile name="dev">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="DAY_FILE"/>
            <appender-ref ref="DAY_ERROR_FILE"/>
        </root>
    </springProfile>

    <!-- 生产环境:打印控制台和输出到文件 -->
    <springProfile name="pro">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="DAY_FILE"/>
            <appender-ref ref="DAY_ERROR_FILE"/>
        </root>
    </springProfile>
</configuration>

上述配置文件中的 [%X{traceId}] 可以将我们通过MDC.put操作设置的值带入进来,这样就可以将traceId打印到日志里了。

4:接口入口服务aop切面接收traceId
package com.weibo.platform.aop;

import com.alibaba.fastjson.JSON;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.weibo.common.enums.BizExceptionEnum;
import com.weibo.common.exception.BizException;
import com.weibo.common.resp.ApiResponse;
import org.apache.dubbo.rpc.RpcContext;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

/**
 * 日志aop
 * 记录对外,依赖服务的请求数据
 */
@Component
@Aspect
public class LogAspect {
    
    

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

    private static final String TRACE_ID = "traceId";

    ObjectMapper mapper = new ObjectMapper();


    /**
     * 外部接口调用的日志监控
     *
     * @param joinPoint 连接点
     * @return {@link Object}
     */
    @Around(value = "execution(* com.weibo.platform.controller..*.* (..))")
    public Object doRequestAround(ProceedingJoinPoint joinPoint) throws Throwable {
    
    
        try {
    
    
            // 日志链路
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            assert attributes != null;
            HttpServletRequest request = attributes.getRequest();
            String traceId = request.getHeader(TRACE_ID);
            MDC.put(TRACE_ID, traceId);
            RpcContext.getContext().setAttachment(TRACE_ID, traceId);
            // 参数打印
            Object result;
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            Method method = methodSignature.getMethod();
            String name = method.getName();
            Object[] args = joinPoint.getArgs();
            Object object = joinPoint.getTarget();
            log.info("class :{}, method :{}, param :{}", object.getClass().getName(), name, mapper.writeValueAsString(args));
            result = joinPoint.proceed();
            log.info("class :{}, method :{}, result :{}", object.getClass().getName(), name, genResultString(result));
            return result;
        } catch (Exception e) {
    
    
            log.error("Error :", e);
            if (!(e instanceof BizException)) {
    
    
                return new ApiResponse<>(BizExceptionEnum.SYS_ERROR);
            } else {
    
    
                return new ApiResponse<>(((BizException) e).getCode(), ((BizException) e).getMsg());
            }
        }
    }

    /**
     * 创结果字符串
     *
     * @param result 结果
     * @return {@link String}
     */
    private String genResultString(Object result) {
    
    
        //如果结果为空,只直接返回
        if (result == null) {
    
    
            return null;
        }
        String val = JSON.toJSONString(result);
        if (val.length() > 1024) {
    
    
            return val.substring(0, 1023);
        }
        return val;
    }
}

上述代码中比较重要的部分是开头部分,入下图
在这里插入图片描述

  1. 从请求头中获取网关服务添加的traceId
  2. 将traceId设置到MDC中(用于该服务日志打印traceId,也需要logbak.xml)
  3. 将traceId设置到dubbo的RpcContext中(用于将traceId传递到下个服务,后续微服务间联动都将traceId通过RpcContext传递)
5:统一全局返回值,返回值中加traceId字段

所有的接口均使用全局响应实体返回,返回的时候通过MDC自动将traceId设置到返回值中

package com.weibo.common.resp;

import com.weibo.common.enums.BizExceptionEnum;
import lombok.Data;
import org.slf4j.MDC;

import java.io.Serializable;

@Data
public class ApiResponse<T> implements Serializable {
    
    

    private static final long serialVersionUID = -6025817568658364567L;

    private static final String TRACE_ID = "traceId";

    private Integer code;

    private String msg;

    private T data;

    private String traceId;

    public ApiResponse(Integer code, String msg) {
    
    
        this.traceId = MDC.get(TRACE_ID);
        this.code = code;
        this.msg = msg;
        this.data = null;
    }

    public ApiResponse(Integer code, String msg, T data) {
    
    
        this.traceId = MDC.get(TRACE_ID);
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    public ApiResponse(T data) {
    
    
        this.traceId = MDC.get(TRACE_ID);
        this.code = BizExceptionEnum.SUCCESS.getCode();
        this.msg = BizExceptionEnum.SUCCESS.getMsg();
        this.data = data;
    }

    public ApiResponse(BizExceptionEnum enums) {
    
    
        this.traceId = MDC.get(TRACE_ID);
        this.code = enums.getCode();
        this.msg = enums.getMsg();
        this.data = null;
    }

}
6:通过aop全局捕获异常封装全局响应

在这里插入图片描述
aop执行方法时一旦发生异常,将捕获异常,然后封装全局响应对象返回给前端,不将异常外漏,如果是自定义业务异常,同样的道理将异常信息的code和msg返回给前端。

7:用户服务通过RpcContext获取traceId
package com.weibo.user.filter;

import org.apache.dubbo.rpc.RpcContext;
import org.slf4j.MDC;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class RpcFilter implements HandlerInterceptor {
    
    

   private static final String TRACE_ID = "traceId";

   /**
    * 目标方法执行前
    * 该方法在控制器处理请求方法前执行,其返回值表示是否中断后续操作
    * 返回 true 表示继续向下执行,返回 false 表示中断后续操作
    *
    * @param request  请求
    * @param response 响应
    * @param handler  处理程序
    * @return boolean
    */
   @Override
   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    
    
       String traceId = RpcContext.getContext().getAttachment(TRACE_ID);
       MDC.put(TRACE_ID, traceId);
       return true;
   }
}

效果:

网关服务日志:
traceId:fd6f8174714745f4a1ac7dada1b3949b
在这里插入图片描述
入口服务日志:
traceId:fd6f8174714745f4a1ac7dada1b3949b在这里插入图片描述
返回值:
traceId:fd6f8174714745f4a1ac7dada1b3949b
在这里插入图片描述
这样就可以在请求的返回值中获取traceId,一旦有异常或者错误,可以通过返回的这个traceId进行日志搜索、问题排查。

备注:

  1. 除了网关服务外,其他服务均需要添加logbak.xml文件实现打印traceId。
  2. 各rpc微服务间通过dubbo的RpcContext来进行传递。

综上就可以实现通过一个traceId查询到全链路的日志了。

猜你喜欢

转载自blog.csdn.net/Ellis_li/article/details/127083428