日誌鏈路追蹤

摘要

在我們的系統中需要記錄日誌,包括接口、方法的調用用戶信息、用時、參數等。分佈式環境中通過dubbo調用rpc服務,需要提供全局traceId追蹤完整調用鏈路。


解決方案

  • 日誌中心獨立部署,提供rpc服務,日誌統一記錄統一管理,可以記錄到數據庫或者log文件中
  • request入口添加攔截器,採用slf4j提供的MDC記錄用戶信息
  • 自定義註解和aspect,添加環繞切面,調用日誌中心的rpc服務記錄日誌
  • 添加dubbo攔截器,使用戶信息,全局traceId可以跨服務傳輸
  • mysql數據庫添加Interceptors,將mysql日誌記錄到ThreadLocal中
  • log-chain-spring-boot-starter

實現

  1. 代碼結構調用鏈架構圖

  2. 日誌中心
    提供RPC服務記錄日誌,demo中採用的dubbo服務,日誌記錄到數據庫中

    @Component
    @Service(interfaceClass = LogApi.class, timeout = 10000)
    public class LogApiImpl implements LogApi{
        @Autowired
        private LogServiceImpl logService;
        public void save(LogDTO dto){
            LogEntity entity=new LogEntity();
            BeanUtils.copyProperties(dto,entity);
            logService.save(entity);
        }
    }
    

    日誌內容

    @Data
    public class LogDTO implements Serializable{
        private static final long serialVersionUID = 4069882290787051188L;
        private Integer id;
        private String traceId;
        private String appName;
        private String userId;
        private String userName;
        private String methodName;
        private String type;
        private String param;
        private String result;
        private String description;
        private Long spendTime;
        private Date optTime;
    }
    
  3. log-chain-spring-boot-start

    類名 作用
    LogChainConfiguration starter配置類
    LogChainProperties 配置文件類,配置appName
    LogRecord 註解,配置日誌描述以及方法是否記錄Mysql日誌
    LogRecordAspect 從MDC和ThreadLocal中讀取信息並記錄日誌
    LogRecordManager ThreadLocal記錄日誌鏈
    MysqlLogManager ThreadLocal記錄Mysql日誌信息
    TraceFilter dubbo過濾器
    MySQLStatementInterceptor mysql攔截器
    LogRecordVO 日誌VO,記錄方法唯一標識以及是否記錄mysql
    MysqlLogVO Mysql日誌信息

    在這裏插入圖片描述

  4. servlet攔截器
    攔截請求,生成全局TraceId、記錄用戶信息至MDC中
    由於各個應用獲取用戶信息方式不一樣,所以攔截器由各個項目實現

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
            String userId= SysUtil.getCurUserId((HttpServletRequest)request);
            if(!StringUtils.isEmpty(userId)){
                UserDTO user= userService.getUserById(userId);
                if(user!=null){
                    MDCUtil.setUserName(user.getUserName());
                }
            }
            MDCUtil.setUserId(userId);
            MDCUtil.setTraceId();
            chain.doFilter(request, response);
        }
    
  5. 自定義註解添加日誌描述

    @Documented
    @Target({ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface LogRecord {
        String description() default "" ;
        Boolean recordMysql() default true;
    }
    

    需要記錄日誌的方法添加註解

    @LogRecord(description = "根據用戶id查詢用戶角色列表")
    public List<RoleDTO> listUserRole(String userId){
        UserDTO user=getUserById(userId);
        if(user==null){
            return new ArrayList<>();
        }
        //獲取用戶關聯的角色id
        List<String> roleIds=getUserService().listUserRoleIds(userId);
        //根據角色id查詢角色詳情
        return getUserService().listRoleByRoleId(roleIds);
    }
    
  6. 切面記錄日誌信息
    Aspect爲所有添加了@LogRecord註解的方法添加環繞切面

    @Aspect
    @Order(-6)
    @Component
    public class LogRecordAspect {
        @Reference
        private LogApi logApi;
        private String appName;
        public LogRecordAspect(String appName){
            this.appName=appName;
        }
        private final String MYSQL="-->mysql";
        private final String START="開始";
        private final String END="結束";
        @Pointcut("@annotation(com.ym.logchain.aop.LogRecord)")
        public void logAspect(){
            //記錄日誌
        }
        @Around("logAspect()")
        public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
            //記錄前序sql日誌
            recordPreLog();
            //獲取方法信息、註解信息
            MethodSignature signature = (MethodSignature)joinPoint.getSignature();
            Method method = signature.getMethod();
            Map<String,Object> param=formatParam(joinPoint);
            LogRecord logRecord = method.getAnnotation(LogRecord.class);
            String guid= UUID.randomUUID().toString();
            String description= logRecord.description();
          	addLogRecord(guid,logRecord.recordMysql(),method.getName(),description);
            //記錄開始日誌
            saveLog(JSONObject.toJSONString(param),"",method.getName(), OptTypeEnum.start.name(),
                    description+START,new Long(-1),new Date());
            //執行方法,記錄用時
            Long startTime=System.currentTimeMillis();
            Object proceed = joinPoint.proceed();
            Long endTime=System.currentTimeMillis();
            String result="void";
            if(!signature.getReturnType().equals(void.class)){
                result=JSONObject.toJSONString(proceed);
            }
            //記錄當前方法的mysql日誌
            recordCurLog(guid,method.getName(),description);
            //記錄結束日誌
            saveLog(JSONObject.toJSONString(param),result,method.getName(), OptTypeEnum.end.name(),
                    description+END,endTime-startTime,new Date());
            return proceed;
        }
    
        /**
         * 保存日誌
         * @param param
         * @param result
         * @param methodName
         * @param type
         * @param description
         * @param spendTime
         * @param optTime
         */
        private void saveLog(String param,String result,String methodName,String type,String description,Long spendTime,Date optTime){
            LogDTO log = new LogDTO();
            log.setAppName(appName);
            log.setUserId(MDCUtil.getUserId());
            log.setUserName(MDCUtil.getUserName());
            log.setMethodName(methodName);
            log.setType(type);
            log.setDescription(description);
            log.setTraceId(MDCUtil.getTraceId());
            log.setSpendTime(spendTime);
            log.setParam(param);
            log.setResult(result);
            log.setOptTime(optTime);
            logApi.save(log);
        }
    
        /**
         * 格式化參數
         * @param joinPoint
         * @return
         */
        private Map<String,Object> formatParam(JoinPoint joinPoint){
            Map<String,Object> res=new HashMap();
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            String[] paramNames = signature.getParameterNames();
            if(paramNames==null || paramNames.length==0){
                return res;
            }
            Object[] params=joinPoint.getArgs();
            for (int i = 0; i < paramNames.length; i++) {
                res.put(paramNames[i],params[i]);
            }
            return res;
        }
    }
    

    保存前序方法中存儲的mysql日誌記錄

    /**
    * 保存之前未保存的日誌
    */
    private void recordPreLog(){
       List<LogRecordVO> preLogs = LogRecordManager.getLogRecord();
       for (LogRecordVO preLog : preLogs) {
           List<MysqlLogVO> preMysqlLogs = MysqlLogManager.getMysqlLog(preLog.getId());
           for (MysqlLogVO preMysqlLog : preMysqlLogs) {
               String sqlMethodName=preLog.getMethodName()+MYSQL;
               String sqlDesc=preLog.getDescription()+MYSQL;
               saveLog(preMysqlLog.getSql(),preMysqlLog.getResult(),sqlMethodName,OptTypeEnum.mysql.name(),sqlDesc,preMysqlLog.getSpendTime(),preMysqlLog.getOptTime());
           }
           MysqlLogManager.remove(preLog.getId());
       }
    }
    

    添加是否記錄mysql以及生成guid至ThreadLocal中
    方便後續mysql檢查是否記錄日誌以及對應歸屬那個方法

    /**
    * 添加日誌信息
    * @param guid
    * @param recordMysql
    * @param methodName
    * @param description
    */
    private void addLogRecord(String guid,Boolean recordMysql,String methodName,String description){
       LogRecordVO vo=new LogRecordVO();
       vo.setId(guid);
       vo.setRecordSql(recordMysql);
       vo.setMethodName(methodName);
       vo.setDescription(description);
       LogRecordManager.addLogRecord(vo);
    }
    

    保存當前方法mysql日誌
    該日誌由mysql攔截器保存至mysql中並通過guid關聯對應方法

    /**
    * 記錄當前方法的數據庫日子
    * @param guid
    * @param methodName
    * @param description
    */
    private void recordCurLog(String guid,String methodName,String description){
       List<MysqlLogVO> logs=MysqlLogManager.getMysqlLog(guid);
       for (MysqlLogVO log : logs) {
           String sqlMethodName=methodName+MYSQL;
           String sqlDesc=description+MYSQL;
           saveLog(log.getSql(),log.getResult(),sqlMethodName,OptTypeEnum.mysql.name(),sqlDesc,log.getSpendTime(),log.getOptTime());
       }
       MysqlLogManager.remove(guid);
       LogRecordManager.remove();
    }
    
  7. dubbo攔截器
    通過dubbo攔截器
    資源文件夾下創建 META-INF/dubbo 文件夾,創建com.alibaba.dubbo.rpc.Filter 文件,並編輯文件內容traceIdFilter=com.ym.filter.TraceFilter
    調用服務時將traceId及用戶信息添加至RpcContext中
    服務被調用時從RpcContext中獲取traceId及用戶信息轉存至MDC中

    @Activate(group = {Constants.CONSUMER, Constants.PROVIDER} , order = -9999)
    public class TraceIdFilter implements Filter {
        @Override
        public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
            String traceId = RpcContext.getContext().getAttachment("traceId");
            if (!StringUtils.isEmpty(traceId) ) {
                MDCUtil.setTraceId(traceId);
                MDCUtil.setUserId(RpcContext.getContext().getAttachment("userId"));
                MDCUtil.setUserName(RpcContext.getContext().getAttachment("userName"));
            } else {
                RpcContext.getContext().setAttachment("traceId", MDCUtil.getTraceId());
                RpcContext.getContext().setAttachment("userId", MDCUtil.getUserId());
                RpcContext.getContext().setAttachment("userName", MDCUtil.getUserName());
            }
            return invoker.invoke(invocation);
        }
    }
    
  8. Mysql攔截器
    記錄sql以及執行時間
    數據庫url中添加攔截器

    spring.datasource.url=jdbc:mysql://127.0.0.1:3306/test_ym?characterEncoding=UTF-8&useSSL=false&statementInterceptors=com.ym.logchain.interceptor.MySQLStatementInterceptor

    public class MySQLStatementInterceptor implements StatementInterceptorV2 {
        private ThreadLocal<Long> timeHolder = new ThreadLocal<Long>();
    
        @Override
        public void destroy() {
        }
    
        @Override
        public boolean executeTopLevelOnly()
        {
            return true;
        }
    
        @Override
        public void init(Connection arg0, Properties arg1) throws SQLException {
        }
    
        @Override
        public ResultSetInternalMethods postProcess(String sql, Statement statement, ResultSetInternalMethods methods,
                                                    Connection connection, int warningCount, boolean noIndexUsed, boolean noGoodIndexUsed, SQLException statementException) throws SQLException {
            LogRecordVO vo = LogRecordManager.getCurrentLogRecord();
            if(vo!=null && vo.getRecordSql()){
                String exeSql=getSql(statement);
                if (StringUtils.isNotBlank(exeSql)){
                    Long useTime = System.currentTimeMillis() - timeHolder.get();
                    MysqlLogVO log=new MysqlLogVO();
                    log.setSql(exeSql);
                    log.setSpendTime(useTime);
                    log.setResult("");
                    log.setOptTime(new Date());
                    MysqlLogManager.addMysqlLog(vo.getId(),log);
                }
            }
            return null;
        }
    
        @Override
        public ResultSetInternalMethods preProcess(String sql, Statement statement, Connection connection) throws SQLException {
            timeHolder.set(System.currentTimeMillis());
            return null;
        }
    
        private String getSql(Statement statement) {
            String sql = null;
            if (statement instanceof PreparedStatement)
            {
                try
                {
                    sql = ((PreparedStatement)statement).asSql();
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
            return sql;
        }
    }
    

效果

  1. 單個應用內部調用鏈
    日誌
  2. 應用間調用鏈
    日誌
發佈了36 篇原創文章 · 獲贊 28 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章