本文共 9127 字,大约阅读时间需要 30 分钟。
不管是软件,应用还是网站,只要有用户使用,就有用户的操作行为。而在那些需要多用户互相协作,或者是多用户共同使用的系统或者网站,用户是会非常关心对于别人的操作。因为别人的操作很有可能会影响到他自己所拥有的一些财产。例如一个电商网站,商家弄了几个管理员来打理店铺:管理员可以一定程度上管理用户、可以管理商品、管理订单等等;因为这都是涉及到商家的财产,所以商家肯定会非常注意管理员的操作,避免管理员的一些误操而导致店铺的金钱损失。
那么我们怎样提供用户操作呢?那肯定是要用到日志了,而我们往往在研发的时候,都会在一些重要步骤上面打上log,然后记录在日志文件中;那么,使用这些日志给用户提供操作查看合适吗?
我觉得不合适。
因此,我们需要自研一个操作日志组件。
操作日志组件主要分为两个部分:
第一个是SDK,主要提供给需要使用操作日志功能的服务,服务只需要引入sdk依赖即可开始使用,sdk里面提供了基本的注解和切面功能,切面里面会进行操作日志的处理,并往操作日志服务发送请求用以保存操作日志;
第二个是操作日志组件的服务,我们需要单独部署一个服务作为操作日志组件的后勤,主要对外提供新增操作日志和查询操作日志的接口。
之所以我们需要单独部署一个操作日志服务,是因为我们要遵守单一职责的原则,不需要每个服务都在自己的库里面创建表来保存操作日志。而是由操作日志服务统一对外提供新增和查询的能力。当然了,这一版我只是做了 HTTP 的请求方式,如果大家的系统是微服务架构,服务之间使用的是 Dubbo 来通信的话,可以在 SDK 和 Server 中进行增强。
# 开启操作日志组件功能log.record.enabled=true# 操作日志服务地址log.record.url=http://ip:port
关于操作日志组件的配置还是比较少的,因为主要的配置在注解那,这里只负责配置是否启用。
但是要注意的是:如果开启了操作日志组件功能,那么一定要配置操作日志服务地址,因为 SDK 中,会调用操作日志服务的接口来新增操作日志,和提供了查询操作日志列表的接口
开启操作日志组件功能后,我们接着在需要记录操作日志的类方法上加上@LogRecordAnno注解,然后配置我们需要记录的日志类型和日志内容。
下面是我自己提供的简单例子:
/** * * @author winfun * @date 2021/2/25 3:58 下午 **/@Servicepublic class UserServiceImpl implements UserService { @Resource private UserMapper userMapper; /** * 新增用户记录 * @param user * @return */ @LogRecordAnno(logType = LogRecordConstant.LOG_TYPE_MESSAGE, sqlType = LogRecordConstant.SQL_TYPE_INSERT, businessName = "userBusiness", successMsg = "成功新增用户「{{#user.name}}」", errorMsg = "新增用户失败,错误信息:「{{#_errorMsg}}」", operator = "#operator") @Override public String insert(User user,String operator) { if (StringUtils.isEmpty(user.getName())){ throw new RuntimeException("用户名不能为空"); } this.userMapper.insert(user); return user.getId(); } /** * 更新用户记录 * @param user * @return */ @LogRecordAnno(logType = LogRecordConstant.LOG_TYPE_RECORD, sqlType = LogRecordConstant.SQL_TYPE_UPDATE, businessName = "userBusiness", mapperName = UserMapper.class, id = "#user.id", operator = "#operator") @Override public Boolean update(User user,String operator) { return this.userMapper.updateById(user) > 0; } /** * 删除用户记录 * @param id * @return */ @LogRecordAnno(logType = LogRecordConstant.LOG_TYPE_MESSAGE, sqlType = LogRecordConstant.SQL_TYPE_DELETE, businessName = "userBusiness", operator = "#operator", successMsg = "成功删除用户,用户ID「{{#id}}」", errorMsg = "删除用户失败,错误信息:「{{#_errorMsg}}」") @Override public Boolean delete(Serializable id,String operator) { return this.userMapper.deleteById(id) > 0; }}
在上面的例子中,其中的新增和删除用户,我们只关心新增了或删除了哪个用户;而更新用户,我们更加关心更新了什么信息;所以新增和删除方法,我们都直接记录了成功信息,而更新方法我们记录了更新前后的实体记录信息。
这里有几个需要注意的点:
{{}}
,那是因为在成功信息和失败信息中,我们支持多个spel表达式,所以需要利用一定规则来进行读取,一定要按照这个规则写。还有就是失败信息,统一使用{{#_errorMsg}}
,因为失败信息是读取异常栈中的异常信息,所以都是统一填写统一获取。我们可以直接从注解入手:
/** * LogRecord 注解 * @author winfun * @date 2021/2/25 4:32 下午 **/@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documented@Inheritedpublic @interface LogRecordAnno { /** * 操作日志类型 * @return */ String logType() default LogRecordContants.LOG_TYPE_MESSAGE; /** * sql类型:增删改 */ String sqlType() default LogRecordContants.SQL_TYPE_INSERT; /** * 业务名称 * @return */ String businessName() default ""; /** * 日志类型一:记录记录实体 * Mapper Class,需要配合 MybatisPlus 使用 */ Class mapperName() default BaseMapper.class; /** * 日志类型一:记录记录实体 * 主键 */ String id() default ""; /** * 操作者 */ String operator() default ""; /** * 日志类型二:记录日志信息 * 成功信息 */ String successMsg() default ""; /** * 日志类型二:记录日志信息 * 失败信息 */ String errorMsg() default "";}
首先,操作日志组件支持两种操作日志类型:第一种是记录操作前后的实体内容,这个会记录完整的信息,但是需要配合 MybatisPlus 使用,有一定的限制,并且最后显示的操作日志需要使用方做一定的处理;第二种是直接记录成功日志和失败日志,比较通用,适用方查询后直接回显即可。
上面也说到,记录实体信息需要配合 MyBatisPlus 使用,并且需要读取到 ID,即主键信息;然后利用 BaseMapper 和日志操作类型,进行操作日志的记录。
详细可看下面代码:
// 记录实体记录if (LogRecordContants.LOG_TYPE_RECORD.equals(logType)){ final Class mapperClass = logRecordAnno.mapperName(); if (mapperClass.isAssignableFrom(BaseMapper.class)){ throw new RuntimeException("mapperClass 属性传入 Class 不是 BaseMapper 的子类"); } final BaseMapper mapper = (BaseMapper) this.applicationContext.getBean(mapperClass); //根据spel表达式获取id final String id = (String) this.getId(logRecordAnno.id(), context); final Object beforeRecord; final Object afterRecord; switch (sqlType){ // 新增 case LogRecordContants.SQL_TYPE_INSERT: proceedResult = point.proceed(); final Object result = mapper.selectById(id); logRecord.setBeforeRecord(""); logRecord.setAfterRecord(JSON.toJSONString(result)); break; // 更新 case LogRecordContants.SQL_TYPE_UPDATE: beforeRecord = mapper.selectById(id); proceedResult = point.proceed(); afterRecord = mapper.selectById(id); logRecord.setBeforeRecord(JSON.toJSONString(beforeRecord)); logRecord.setAfterRecord(JSON.toJSONString(afterRecord)); break; // 删除 case LogRecordContants.SQL_TYPE_DELETE: beforeRecord = mapper.selectById(id); proceedResult = point.proceed(); logRecord.setBeforeRecord(JSON.toJSONString(beforeRecord)); logRecord.setAfterRecord(""); break; default: break; }}
我们如果不关心实体变更前后的内容,我们可以自定义接口调用成功后和失败后的信息。
主要是利用规则{{spel表达式}}
,我们在记录自定义操作日志信息时,如果使用到spel表达式,一定要用{{}}
给包着。 详细看如下代码:
// 规则正则表达式private static final Pattern PATTERN = Pattern.compile("(?<=\\{\\{)(.+?)(?=}})");// 记录信息}else if (LogRecordContants.LOG_TYPE_MESSAGE.equals(logType)){ try { proceedResult = point.proceed(); String successMsg = logRecordAnno.successMsg(); // 对成功信息做表达式提取 final Matcher successMatcher = PATTERN.matcher(successMsg); while(successMatcher.find()){ String temp = successMatcher.group(); final Expression tempExpression = this.parser.parseExpression(temp); final String result = (String) tempExpression.getValue(context); temp = "{{"+temp+"}}"; successMsg = successMsg.replace(temp,result); } logRecord.setSuccessMsg(successMsg); }catch (final Exception e){ String errorMsg = logRecordAnno.errorMsg(); final String exceptionMsg = e.getMessage(); errorMsg = errorMsg.replace(LogRecordContants.ERROR_MSG_PATTERN,exceptionMsg); logRecord.setSuccessMsg(errorMsg); // 插入记录 logRecord.setCreateTime(LocalDateTime.now()); this.logRecordSDKService.insertLogRecord(logRecord); // 回抛异常 throw new Exception(errorMsg); }}
为了更方便获取到此操作是谁来执行的,操作日志组件也提供了操作者的存储功能,我们只需要在注解中添加 operator 属性即可,一般是利用spel表达式从方法传参中获取,否则直接读取属性值。
代码如下:
/** * 获取操作者 * @param expressionStr * @param context * @return */private String getOperator(final String expressionStr, final EvaluationContext context){ try { if (expressionStr.startsWith("#")){ final Expression idExpression = this.parser.parseExpression(expressionStr); return (String) idExpression.getValue(context); }else { return expressionStr; } }catch (final Exception e){ log.error("Log-Record-SDK 获取操作者失败!,错误信息:{}",e.getMessage()); return "default"; }}
关于业务名,大家使用起来一定要配置,因为后续如果要提供操作日志列表给用户查看,是根据业务名查询的,也就是说,大家一定要保证业务名之间都是具有一定含义的,并且每个业务的操作日志的业务名都保持唯一,这样才不会查到别的业务的操作日志。
业务名在 sdk 中不做任何特殊处理,直接获取属性值保存。
上面我们说到,操作日志组件由两部分组成:sdk&server,我们需要单独部署一套操作日志组件的服务,对外提供统一的保存和查询操作日志功能。
在上面介绍的 LogRecordAspect 中,在最后会调用 server 的接口来保存操作日志;这个保存动作是异步的,利用的是自定义线程池,保证不影响主业务的执行。
代码如下:
/*** * 增加日志记录->异步执行,不影响主业务的执行 * @author winfun * @param logRecord logRecord * @return {@link Integer } **/@Async("AsyncTaskThreadExecutor")@Overridepublic ApiResultinsertLogRecord(LogRecord logRecord) { // 发起HTTP请求 return this.restTemplate.postForObject(url+"/log/insert",logRecord,ApiResult.class);}
在 sdk 中,我们已经在 LogRecordSDKService 中提供了根据 businessName 查询操作日志的接口,大家只需要在 controller 层或者 serivce 引入 LogRecordSDKService 然后调用方法即可。如果不需要任何处理则直接返回,否则遍历列表再做进一步的处理。
使用例子:
@Autowiredprivate LogRecordSDKService logRecordSDKService;@GetMapping("/query/{businessName}")public ApiResult
> query(@PathVariable("businessName") String businessName){ return this.logRecordSDKService.queryLogRecord(businessName);}
当然了,组件还有很多的优化点:
详细代码可看:
当然了,如果大家有更好的设计,欢迎大家一起来优化!
转载地址:http://jyrzz.baihongyu.com/