记录项目日志,一个注解搞定

前段时间做了一个支线任务,现在还是回到我们的主线上面来,继续完成 TienChin 项目。

小伙伴们知道松哥最近在录制 TienChin 项目视频教程,这是一个基于 RuoYi-Vue 脚手架的项目,用过这个脚手架的小伙伴可能知道,这个脚手架有一个功能,就是如果你需要记录下来一个接口的操作信息,那么只需要在 Controller 接口中加上一个 @Log 注解即可,非常方便,最终记录到数据库中的日志类似下面这样:

可能有小伙伴要吐槽了,这不该记录到 Elasticsearch 中吗?上 ELK 啊!怎么说呢,如果你会将日志存入数据库,那么将来存 Elasticsearch 其实非常 Easy,结合松哥之前录制的 Es 视频(公号后台回复 es),我相信这个问题你能自己搞定。

今天我们主要来分析一下这个脚手架中 @Log 注解的玩法。

1. 日志表设计

先来看看日志表的设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE TABLE `sys_oper_log` (
`oper_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志主键',
`title` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '模块标题',
`business_type` int(2) DEFAULT '0' COMMENT '业务类型(0其它 1新增 2修改 3删除)',
`method` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '方法名称',
`request_method` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '请求方式',
`operator_type` int(1) DEFAULT '0' COMMENT '操作类别(0其它 1后台用户 2手机端用户)',
`oper_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '操作人员',
`dept_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '部门名称',
`oper_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '请求URL',
`oper_ip` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '主机地址',
`oper_location` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '操作地点',
`oper_param` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '请求参数',
`json_result` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '返回参数',
`status` int(1) DEFAULT '0' COMMENT '操作状态(0正常 1异常)',
`error_msg` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '错误消息',
`oper_time` datetime DEFAULT NULL COMMENT '操作时间',
PRIMARY KEY (`oper_id`)
) ENGINE=InnoDB AUTO_INCREMENT=280 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志记录';

这里各个字段的含义我来解释下:

  • oper_id:这是日志主键,自增的。
  • title:这个标题,一般就是用来说明这个操作是干嘛的,例如删除一个用户、添加一条线索等待。
  • business_type:这个是指业务类型,一般来说有:添加、修改、删除、导入、导出等等。
  • method:执行的接口方法名称。
  • request_method:这个是指请求的方法类型,如 GET、POST、PUT、DELETE 等。
  • operator_type:这个是指操作类别,分为三种:后台用户、手机用户以及其他。
  • oper_name:操作人员的名称。
  • dept_name:操作人员所属的部门。
  • oper_url:请求的 URL 地址。
  • oper_ip:请求的 IP 地址。
  • oper_location:请求 IP 地址所属的区域。
  • oper_param:请求的参数。
  • json_result:响应的 JSON 参数。
  • status:操作的状态,成功还是失败。
  • error_msg:如果是失败,失败的内容是什么。
  • oper_time:操作的时间。

这里给出来的就这些字段,基本上满足项目需求了,如果不够的话,也可以自定添加即可。

2. 注解的定义

再来看看 @Log 注解的定义,该注解位于 org.javaboy.tienchin.common.annotation.Log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
/**
* 模块
*/
public String title() default "";

/**
* 功能
*/
public BusinessType businessType() default BusinessType.OTHER;

/**
* 操作人类别
*/
public OperatorType operatorType() default OperatorType.MANAGE;

/**
* 是否保存请求的参数
*/
public boolean isSaveRequestData() default true;

/**
* 是否保存响应的参数
*/
public boolean isSaveResponseData() default true;
}

这个注解一共有五个属性,结合上面表的定义,五个属性含义很好理解,不多说。

3. 注解的解析

经典搭配:自定义注解+ AOP 切面。解析该注解的 AOP 切面是 LogAspect,位于 org.javaboy.tienchin.framework.aspectj.LogAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
@Aspect
@Component
public class LogAspect {
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);

/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) {
handleLog(joinPoint, controllerLog, null, jsonResult);
}

/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) {
handleLog(joinPoint, controllerLog, e, null);
}

protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
try {
// 获取当前的用户
LoginUser loginUser = SecurityUtils.getLoginUser();

// *========数据库日志=========*//
SysOperLog operLog = new SysOperLog();
operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
// 请求的地址
String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
operLog.setOperIp(ip);
operLog.setOperUrl(ServletUtils.getRequest().getRequestURI());
if (loginUser != null) {
operLog.setOperName(loginUser.getUsername());
}

if (e != null) {
operLog.setStatus(BusinessStatus.FAIL.ordinal());
operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
operLog.setMethod(className + "." + methodName + "()");
// 设置请求方式
operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
// 保存数据库
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
} catch (Exception exp) {
// 记录本地异常日志
log.error("==前置通知异常==");
log.error("异常信息:{}", exp.getMessage());
exp.printStackTrace();
}
}

/**
* 获取注解中对方法的描述信息 用于Controller层注解
*
* @param log 日志
* @param operLog 操作日志
* @throws Exception
*/
public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog, Object jsonResult) throws Exception {
// 设置action动作
operLog.setBusinessType(log.businessType().ordinal());
// 设置标题
operLog.setTitle(log.title());
// 设置操作人类别
operLog.setOperatorType(log.operatorType().ordinal());
// 是否需要保存request,参数和值
if (log.isSaveRequestData()) {
// 获取参数的信息,传入到数据库中。
setRequestValue(joinPoint, operLog);
}
// 是否需要保存response,参数和值
if (log.isSaveResponseData() && StringUtils.isNotNull(jsonResult)) {
operLog.setJsonResult(StringUtils.substring(JSON.toJSONString(jsonResult), 0, 2000));
}
}

/**
* 获取请求的参数,放到log中
*
* @param operLog 操作日志
* @throws Exception 异常
*/
private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog) throws Exception {
String requestMethod = operLog.getRequestMethod();
if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
String params = argsArrayToString(joinPoint.getArgs());
operLog.setOperParam(StringUtils.substring(params, 0, 2000));
} else {
Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 2000));
}
}

/**
* 参数拼装
*/
private String argsArrayToString(Object[] paramsArray) {
String params = "";
if (paramsArray != null && paramsArray.length > 0) {
for (Object o : paramsArray) {
if (StringUtils.isNotNull(o) && !isFilterObject(o)) {
try {
Object jsonObj = JSON.toJSON(o);
params += jsonObj.toString() + " ";
} catch (Exception e) {
}
}
}
}
return params.trim();
}

/**
* 判断是否需要过滤的对象。
*
* @param o 对象信息。
* @return 如果是需要过滤的对象,则返回true;否则返回false。
*/
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
for (Object value : map.entrySet()) {
Map.Entry entry = (Map.Entry) value;
return entry.getValue() instanceof MultipartFile;
}
}
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
|| o instanceof BindingResult;
}
}

大概跟小伙伴们捋一下这个切面的逻辑。

  1. 首先定义了两种不同类型的通知:返回通知和异常通知,正常流程就是在返回通知中处理日志写入操作,但是如果系统不幸抛出异常,就在异常通知中处理日志写入操作(此时就多了一个异常对象)。
  2. 日志数据保存在 SysOperLog 对象中,采集各种日志数据都是常规操作,不多说。
  3. 当收集接口参数的时候,有两个需要注意的地方:1.如果请求类型是 PUT 或者 POST,就直接从接口的参数中获取想要的数据,但是接口的参数中可能会存在一些并不需要记录的内容,例如 HttpServletRequest、HttpServletResponse 或者文件上传对象 MultipartFile 等,这些类型的内容是不需要记录到日志中的,这里通过一个 isFilterObject 方法完成了数据过滤操作;2. 如果请求类型是 GET 或者 DELETE,则请求参数就直接从请求对象提取了。为什么设计成这样呢?很明显,直接从请求对象中提取参数是最为方便的,一行代码就搞定,但是如果请求类型是 PUT 或者 POST,就意味着请求参数是在请求体中,请求参数有可能是二进制数据(例如上传的文件),二进制数据就不好保存了,所以对于 POST 和 PUT 还是从接口参数中提取,然后过滤掉二进制数据即可。
  4. 数据收集齐了,接下来就是往数据库中写入了。因为我们现在用的 SpringMVC 这一套是线程阻塞的,也就是得等到服务端都处理完毕之后,接口才会给客户端响应,而写日志是一个业务无关的操作,所以可以直接放在一个子线程中去完成。在 RuoYi-Vue 脚手架中,使用了 Java JUC 中的 ScheduledExecutorService 去完成这个延时任务,AsyncManager.me().execute 方法实际上就是去执行一个延时任务,这个延时任务就是一个往数据库中写入一条记录。

4. 记录日志

弄好之后,日志注解具体用法如下:

1
2
3
4
5
6
7
8
@Log(title = "参数管理", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('system:config:export')")
@PostMapping("/export")
public void export(HttpServletResponse response, SysConfig config) {
List<SysConfig> list = configService.selectConfigList(config);
ExcelUtil<SysConfig> util = new ExcelUtil<SysConfig>(SysConfig.class);
util.exportExcel(response, list, "参数数据");
}

好啦,这就是整体操作,实际上看起来并不难,后面要是有空的话,松哥把这个日志整成一个 ELK 的,和小伙伴们一起探讨。

喜欢这篇文章吗?扫码关注公众号【江南一点雨】【江南一点雨】专注于 SPRING BOOT+微服务以及前后端分离技术,每天推送原创技术干货,关注后回复 JAVA,领取松哥为你精心准备的 JAVA 干货!

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×