问题:
在日常开发过程中,如果使用微服务架构,那么日志查询就是一个问题,比如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;
}
}
上述代码中比较重要的部分是开头部分,入下图
- 从请求头中获取网关服务添加的traceId
- 将traceId设置到MDC中(用于该服务日志打印traceId,也需要logbak.xml)
- 将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进行日志搜索、问题排查。
备注:
- 除了网关服务外,其他服务均需要添加logbak.xml文件实现打印traceId。
- 各rpc微服务间通过dubbo的RpcContext来进行传递。
综上就可以实现通过一个traceId查询到全链路的日志了。