前段时间做了一个支线任务,现在还是回到我们的主线上面来,继续完成 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); @AfterReturning (pointcut = "@annotation(controllerLog)" , returning = "jsonResult" ) public void doAfterReturning (JoinPoint joinPoint, Log controllerLog, Object jsonResult) { handleLog(joinPoint, controllerLog, null , jsonResult); } @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(); } } public void getControllerMethodDescription (JoinPoint joinPoint, Log log, SysOperLog operLog, Object jsonResult) throws Exception { operLog.setBusinessType(log.businessType().ordinal()); operLog.setTitle(log.title()); operLog.setOperatorType(log.operatorType().ordinal()); if (log.isSaveRequestData()) { setRequestValue(joinPoint, operLog); } if (log.isSaveResponseData() && StringUtils.isNotNull(jsonResult)) { operLog.setJsonResult(StringUtils.substring(JSON.toJSONString(jsonResult), 0 , 2000 )); } } 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(); } 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; } }
大概跟小伙伴们捋一下这个切面的逻辑。
首先定义了两种不同类型的通知:返回通知和异常通知 ,正常流程就是在返回通知中处理日志写入操作,但是如果系统不幸抛出异常,就在异常通知中处理日志写入操作(此时就多了一个异常对象)。
日志数据保存在 SysOperLog 对象中,采集各种日志数据都是常规操作,不多说。
当收集接口参数的时候,有两个需要注意的地方:1.如果请求类型是 PUT 或者 POST,就直接从接口的参数中获取想要的数据,但是接口的参数中可能会存在一些并不需要记录的内容,例如 HttpServletRequest、HttpServletResponse 或者文件上传对象 MultipartFile 等,这些类型的内容是不需要记录到日志中的,这里通过一个 isFilterObject 方法完成了数据过滤操作;2. 如果请求类型是 GET 或者 DELETE,则请求参数就直接从请求对象提取了。为什么设计成这样呢?很明显,直接从请求对象中提取参数是最为方便的,一行代码就搞定,但是如果请求类型是 PUT 或者 POST,就意味着请求参数是在请求体中,请求参数有可能是二进制数据(例如上传的文件),二进制数据就不好保存了,所以对于 POST 和 PUT 还是从接口参数中提取,然后过滤掉二进制数据即可。
数据收集齐了,接下来就是往数据库中写入了。因为我们现在用的 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 的,和小伙伴们一起探讨。