当前位置:首页 > 知识

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

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

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

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

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

1. 日志表设计

先来看看日志表的设计。

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:

@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:

@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;

}

}

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

首先定义了两种不同类型的通知:返回通知和异常通知,正常流程就是在返回通知中处理日志写入操作,但是如果系统不幸抛出异常,就在异常通知中处理日志写入操作(此时就多了一个异常对象)。日志数据保存在 SysOperLog 对象中,采集各种日志数据都是常规操作,不多说。当收集接口参数的时候,有两个需要注意的地方:

如果请求类型是 PUT 或者 POST,就直接从接口的参数中获取想要的数据,但是接口的参数中可能会存在一些并不需要记录的内容,例如 HttpServletRequest、HttpServletResponse 或者文件上传对象 MultipartFile 等,这些类型的内容是不需要记录到日志中的,这里通过一个 isFilterObject 方法完成了数据过滤操作;

如果请求类型是 GET 或者 DELETE,则请求参数就直接从请求对象提取了。为什么设计成这样呢?很明显,直接从请求对象中提取参数是最为方便的,一行代码就搞定,但是如果请求类型是 PUT 或者 POST,就意味着请求参数是在请求体中,请求参数有可能是二进制数据(例如上传的文件),二进制数据就不好保存了,所以对于 POST 和 PUT 还是从接口参数中提取,然后过滤掉二进制数据即可。

数据收集齐了,接下来就是往数据库中写入了。因为我们现在用的 SpringMVC 这一套是线程阻塞的,也就是得等到服务端都处理完毕之后,接口才会给客户端响应,而写日志是一个业务无关的操作,所以可以直接放在一个子线程中去完成。在 RuoYi-Vue 脚手架中,使用了 Java JUC 中的 ScheduledExecutorService 去完成这个延时任务,AsyncManager.me().execute 方法实际上就是去执行一个延时任务,这个延时任务就是一个往数据库中写入一条记录。4. 记录日志

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

@Log(title = "参数管理", businessType = BusinessType.EXPORT)

@PreAuthorize("@ss.hasPermi(system:config:export)")

@PostMapping("/export")

public void export(HttpServletResponse response, SysConfig config) {

Listlist = configService.selectConfigList(config);

ExcelUtilutil = new ExcelUtil(SysConfig.class);

util.exportExcel(response, list, "参数数据");

}

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

分享到:

滇ICP备2023006006号-16