摘要
在我們的系統中需要記錄日誌,包括接口、方法的調用用戶信息、用時、參數等。分佈式環境中通過dubbo調用rpc服務,需要提供全局traceId追蹤完整調用鏈路。
解決方案
- 日誌中心獨立部署,提供rpc服務,日誌統一記錄統一管理,可以記錄到數據庫或者log文件中
- request入口添加攔截器,採用slf4j提供的MDC記錄用戶信息
- 自定義註解和aspect,添加環繞切面,調用日誌中心的rpc服務記錄日誌
- 添加dubbo攔截器,使用戶信息,全局traceId可以跨服務傳輸
- mysql數據庫添加Interceptors,將mysql日誌記錄到ThreadLocal中
- log-chain-spring-boot-starter
實現
-
代碼結構
-
日誌中心
提供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; }
-
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日誌信息 -
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); }
-
自定義註解添加日誌描述
@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); }
-
切面記錄日誌信息
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(); }
-
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); } }
-
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; } }
效果
- 單個應用內部調用鏈
- 應用間調用鏈