當(dāng)我們在開發(fā)一個系統(tǒng)的時候,隨著規(guī)劃的功能越來越多浅悉,按照復(fù)雜度和穩(wěn)定性相反的原則趟据,為了保證系統(tǒng)能夠按照我們設(shè)想的目標運行,我們需要對系統(tǒng)的運行狀況進行監(jiān)控术健。
那么什么時候介入監(jiān)控比較好汹碱?在系統(tǒng)功能開發(fā)的前期(還沒有任何實質(zhì)性的功能),似乎不太合適荞估,那么在系統(tǒng)一切功能開發(fā)接近尾聲的時候好像也不太合適咳促,最好在這中間,選擇一個迭代不是很緊急的階段勘伺,系統(tǒng)已經(jīng)有那么一個成熟的功能在用的時候跪腹,并且隨著用戶量的不斷增大,我們需要對系統(tǒng)的運營情況進行一些了解的時候飞醉。
前期我們對系統(tǒng)日志進行設(shè)計的時候尺迂,可以不必考慮的那么周全,就一些必要的信息進行收集冒掌。日志大概分為兩種:1. 操作日志噪裕;2. 異常日志
操作日志用來監(jiān)控用戶在使用系統(tǒng)時候的一些行為,比如請求了什么接口可以推斷出他在系統(tǒng)前臺進行了什么操作股毫;異常日志則是用戶在請求某個接口的時候膳音,接口內(nèi)部出現(xiàn)了程序上的錯誤,這個日志主要提供給程序員進行問題追蹤的铃诬。隨后我們還可以從這些日志中分析出很多有需要的數(shù)據(jù)祭陷,包括系統(tǒng)的健康度,功能的使用頻率和頻次等趣席。
1. 日志表設(shè)計
梳理出一些必要的字段后兵志,我們可以設(shè)計出如下的兩張不同功能的日志表,它們有些字段是相同的宣肚,都需要記錄誰在請求某個接口想罕、請求的Uri、請求人的訪問IP等信息霉涨。
CREATE TABLE `sys_log_operation` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '自動編號',
`opera_module` VARCHAR(64) DEFAULT NULL COMMENT '功能模塊',
`opera_type` VARCHAR(64) DEFAULT NULL COMMENT '操作類型',
`opera_desc` VARCHAR(500) DEFAULT NULL COMMENT '操作描述',
`opera_req_param` TEXT DEFAULT NULL COMMENT '請求參數(shù)',
`opera_resp_param` TEXT DEFAULT NULL COMMENT '返回參數(shù)',
`opera_employee_account` VARCHAR(11) DEFAULT NULL COMMENT '操作人賬號',
`opera_method` VARCHAR(255) DEFAULT NULL COMMENT '操作方法',
`opera_uri` VARCHAR(255) DEFAULT NULL COMMENT '請求URI',
`opera_ip` VARCHAR(64) DEFAULT NULL COMMENT '請求IP',
`created_time` DATETIME DEFAULT NULL COMMENT '創(chuàng)建時間',
`modified_time` DATETIME DEFAULT NULL COMMENT '修改時間',
PRIMARY KEY (`id`) USING BTREE
) COMMENT = '操作日志表' ROW_FORMAT = COMPACT;
CREATE TABLE `sys_log_exception` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '自動編號',
`exc_req_param` TEXT DEFAULT NULL COMMENT '請求參數(shù)',
`exc_name` VARCHAR(255) DEFAULT NULL COMMENT '異常名稱',
`exc_message` TEXT DEFAULT NULL COMMENT '異常信息',
`opera_employee_account` VARCHAR(11) DEFAULT NULL COMMENT '操作人賬號',
`opera_method` VARCHAR(255) DEFAULT NULL COMMENT '操作方法',
`opera_uri` VARCHAR(255) DEFAULT NULL COMMENT '請求URI',
`opera_ip` VARCHAR(64) DEFAULT NULL COMMENT '請求IP',
`created_time` DATETIME DEFAULT NULL COMMENT '創(chuàng)建時間',
`modified_time` DATETIME DEFAULT NULL COMMENT '修改時間',
PRIMARY KEY (`id`) USING BTREE
) COMMENT = '異常日志表' ROW_FORMAT = COMPACT;
2. 后臺系統(tǒng)日志收集
到了后端編碼的階段按价,我們來構(gòu)思下這個代碼架構(gòu)如何去實現(xiàn)?因為日志代碼是需要穿插在業(yè)務(wù)代碼中的笙瑟,這樣必然帶來一個問題楼镐,導(dǎo)致代碼過于混亂的問題。有沒有一種途徑往枷,通過 Spring 里注解的方式框产,只需要在接口的入口增加攜帶參數(shù)的注解凄杯,然后在注解的代碼里就實現(xiàn)具體的日志收集功能。
Spring 里的特性 AOP(面向切面)就很適合用來實現(xiàn)日志記錄秉宿,性能統(tǒng)計戒突,安全控制,事務(wù)處理蘸鲸,異常處理等功能妖谴,將代碼從業(yè)務(wù)邏輯代碼中劃分出來。
我們在項目代碼的 Utils 目錄創(chuàng)建注解接口 OperLog
package com.lead.utils;
import java.lang.annotation.Retention;
import java.lang.annotation.ElementType;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Documented;
import java.lang.annotation.Target;
/**
* 自定義操作日志注解
* @author Fan
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperLog {
String operModule() default ""; // 操作模塊
String operType() default ""; // 操作類型
String operDesc() default ""; // 操作說明
}
具體的功能酌摇,我們創(chuàng)建一個 OperLogAspect 切面處理類來實現(xiàn)膝舅。這里面通過 @Pointcut
注解定義了日志的切入點和執(zhí)行范圍
package com.lead.utils;
import com.alibaba.fastjson.JSON;
import com.lead.entity.System.ExceptionLog;
import com.lead.entity.System.OperationLog;
import com.lead.service.System.IExceptionLogService;
import com.lead.service.System.IOperationLogService;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
/**
* 切面處理類,操作日志異常日志記錄處理
* @author Fan
*/
@Aspect
@Component
public class OperLogAspect {
private final IOperationLogService operationLogService;
private final IExceptionLogService exceptionLogService;
public OperLogAspect(
IOperationLogService operationLogService,
IExceptionLogService exceptionLogService) {
Assert.notNull(operationLogService, "operationLogService must not be null!");
Assert.notNull(exceptionLogService, "exceptionLogService must not be null!");
this.operationLogService = operationLogService;
this.exceptionLogService = exceptionLogService;
}
/**
* 設(shè)置操作日志切入點 記錄操作日志 在注解的位置切入代碼
*/
@Pointcut("@annotation(com.lead.utils.OperLog)")
public void operLogPoinCut() {
}
/**
* 設(shè)置操作異常切入點記錄異常日志 掃描所有controller包下操作
*/
@Pointcut("execution(* com.lead.controller..*.*(..))")
public void exceptionLogPoinCut() {
}
/**
* 正常返回通知窑多,攔截用戶操作日志仍稀,連接點正常執(zhí)行完成后執(zhí)行, 如果連接點拋出異常埂息,則不會執(zhí)行
* @param joinPoint 切入點
* @param keys 返回結(jié)果
*/
@AfterReturning(value = "operLogPoinCut()", returning = "keys")
public void saveOperLog(JoinPoint joinPoint, Object keys) {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
HttpServletRequest request = (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
OperationLog operlog = new OperationLog();
String token = request.getHeader("accessToken");
String employeeAccount = "";
Object[] args = joinPoint.getArgs();
Object params = args[0]; // 請求參數(shù)對象
if (token != null && !token.equals("")) {
employeeAccount = JwtUtil.getUserId(token);
} else {
Map argMap = (Map) params;
String resp = JSON.toJSONString(keys);
if (argMap.get("account") != null && argMap.get("passWord") != null && resp.indexOf("用戶登錄成功") > -1) {
employeeAccount = argMap.get("account").toString();
}
}
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
OperLog opLog = method.getAnnotation(OperLog.class);
if (opLog != null) {
String operaModule = opLog.operModule();
String operaType = opLog.operType();
String operaDesc = opLog.operDesc();
operlog.setOperaModule(operaModule);
operlog.setOperaType(operaType);
operlog.setOperaDesc(operaDesc);
}
// 獲取請求的類名
String className = joinPoint.getTarget().getClass().getName();
// 獲取請求的方法名
String methodName = method.getName();
methodName = className + '.' + methodName;
operlog.setOperaMethod(methodName);
// Map<String, String> rtnMap = converMap(request.getParameterMap());
// 將參數(shù)所在的數(shù)組轉(zhuǎn)換成json
operlog.setOperaReqParam(JSON.toJSONString(params));
operlog.setOperaRespParam(JSON.toJSONString(keys));
operlog.setOperaEmployeeAccount(employeeAccount);
operlog.setOperaUri(request.getRequestURI());
operlog.setOperaIp(IPUtil.getIpAddress(request));
operationLogService.addOperationLog(operlog);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 異常返回通知技潘,用于攔截異常日志信息 連接點拋出異常后執(zhí)行
*/
@AfterThrowing(pointcut = "exceptionLogPoinCut()", throwing = "e")
public void saveExceptionLog(JoinPoint joinPoint, Throwable e) {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
HttpServletRequest request = (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
ExceptionLog exceptLog = new ExceptionLog();
String token = request.getHeader("accessToken");
String employeeAccount = "";
Object[] args = joinPoint.getArgs();
Object params = args[0]; // 請求參數(shù)對象
if (token != null && !token.equals("")) {
employeeAccount = JwtUtil.getUserId(token);
} else {
employeeAccount = request.getParameter("account");
}
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 獲取請求的類名
String className = joinPoint.getTarget().getClass().getName();
// 獲取請求的方法名
String methodName = method.getName();
methodName = className + '.' + methodName;
exceptLog.setExcReqParam(JSON.toJSONString(params)); // 請求參數(shù)
exceptLog.setOperaMethod(methodName); // 請求方法名
exceptLog.setExcName(e.getClass().getName()); // 異常名稱
exceptLog.setExcMessage(stackTraceToString(e.getClass().getName(), e.getMessage(), e.getStackTrace())); // 異常信息
exceptLog.setOperaEmployeeAccount(employeeAccount);
exceptLog.setOperaUri(request.getRequestURI());
exceptLog.setOperaIp(IPUtil.getIpAddress(request));
exceptionLogService.addExceptionLog(exceptLog);
} catch (Exception e2) {
e2.printStackTrace();
}
}
/**
* 轉(zhuǎn)換request 請求參數(shù)
* @param paramMap request獲取的參數(shù)數(shù)組
*/
public Map<String, String> converMap(Map<String, String[]> paramMap) {
Map<String, String> rtnMap = new HashMap<String, String>();
for (String key : paramMap.keySet()) {
rtnMap.put(key, paramMap.get(key)[0]);
}
return rtnMap;
}
/**
* 轉(zhuǎn)換異常信息為字符串
* @param exceptionName 異常名稱
* @param exceptionMessage 異常信息
* @param elements 堆棧信息
*/
public String stackTraceToString(String exceptionName, String exceptionMessage, StackTraceElement[] elements) {
StringBuffer strbuff = new StringBuffer();
for (StackTraceElement stet : elements) {
strbuff.append(stet + "\n");
}
String message = exceptionName + ":" + exceptionMessage + "\n\t" + strbuff.toString();
return message;
}
}
具體的日志數(shù)據(jù)處理分別封裝在 saveOperLog
和 saveExceptionLog
方法中,這里主要說下他們共同的一些字段數(shù)據(jù)的獲取千康。用戶的賬號是通過請求頭參數(shù) accessToken
獲取的 token
信息然后解析出來的享幽,這里為了獲取未登錄情況也就是請求登錄接口的時候,直接獲取登錄提交的用戶賬號
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
HttpServletRequest request = (HttpServletRequest) requestAttributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
OperationLog operlog = new OperationLog();
String token = request.getHeader("accessToken");
String employeeAccount = "";
if (token != null && !token.equals("")) {
employeeAccount = JwtUtil.getUserId(token);
} else {
Map argMap = (Map) params;
String resp = JSON.toJSONString(keys);
if (argMap.get("account") != null && argMap.get("passWord") != null && resp.indexOf("用戶登錄成功") > -1) {
employeeAccount = argMap.get("account").toString();
}
}
獲取接口類名和方法名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 獲取請求的類名
String className = joinPoint.getTarget().getClass().getName();
// 獲取請求的方法名
String methodName = method.getName();
methodName = className + '.' + methodName;
operlog.setOperaMethod(methodName);
這里直接獲取的注解攜帶的參數(shù)拾弃,也就是我們在接口端添加的代碼
OperLog opLog = method.getAnnotation(OperLog.class);
if (opLog != null) {
String operaModule = opLog.operModule();
String operaType = opLog.operType();
String operaDesc = opLog.operDesc();
operlog.setOperaModule(operaModule);
operlog.setOperaType(operaType);
operlog.setOperaDesc(operaDesc);
}
在業(yè)務(wù)代碼中添加日志注解值桩,給每個參數(shù)賦予帶有一定含義的值:
@OperLog(operModule = "培訓(xùn)模塊", operType = "用戶獲取培訓(xùn)課程列表", operDesc = "用戶獲取培訓(xùn)課程列表")
@RequestMapping(value = "/get-training", method = RequestMethod.POST)
public Result getTrainings(@RequestBody Map<String, Object> params, HttpServletRequest request) {}
3. 前臺日志查詢
前臺拿到日志接口請求過來的數(shù)據(jù),我們用一張表格展示就好豪椿,在該接口中提供了操作人奔坟、操作模塊、操作類型和起始時間等查詢參數(shù)搭盾,前臺可以通過輸入?yún)?shù)值進行過濾咳秉。
為了展示的信息更全,我們在表格中增加展開行的功能鸯隅,點擊表格中的行任何位置澜建,出現(xiàn)該條日志的詳細信息。