Fork me on GitHub

自研一套通俗易用的操作日志组件

原文链接:自研一套通俗易用的操作日志组件

背景

不管是软件,应用还是网站,只要有用户使用,就有用户的操作行为。而在那些需要多用户互相协作,或者是多用户共同使用的系统或者网站,用户是会非常关心对于别人的操作。因为别人的操作很有可能会影响到他自己所拥有的一些财产。例如一个电商网站,商家弄了几个管理员来打理店铺:管理员可以一定程度上管理用户、可以管理商品、管理订单等等;因为这都是涉及到商家的财产,所以商家肯定会非常注意管理员的操作,避免管理员的一些误操而导致店铺的金钱损失。

那么我们怎样提供用户操作呢?那肯定是要用到日志了,而我们往往在研发的时候,都会在一些重要步骤上面打上log,然后记录在日志文件中;那么,使用这些日志给用户提供操作查看合适吗?

我觉得不合适。

  • 首先,日志文件中记录的是整个系统或者整个服务的所有日志,我们需要自己进一步提取关心的业务日志。
  • 对于上面的日志提取,我们不但需要找,而且需要处理成通俗易懂的操作日志;因为研发记录的log一般都不是用户可读的log,所以还需要再进一步提取然后处理。
  • 对于最后处理好的日志,还需要入库,毕竟我们不可能一直都到日志文件里面找;因为日志文件是会每天递增的,我们难以定位用户查看的日志操作在哪个日志文件中。

因此,我们需要自研一个操作日志组件。

1 架构介绍

操作日志组件主要分为两个部分:

第一个是SDK,主要提供给需要使用操作日志功能的服务,服务只需要引入sdk依赖即可开始使用,sdk里面提供了基本的注解和切面功能,切面里面会进行操作日志的处理,并往操作日志服务发送请求用以保存操作日志;

第二个是操作日志组件的服务,我们需要单独部署一个服务作为操作日志组件的后勤,主要对外提供新增操作日志和查询操作日志的接口。

之所以我们需要单独部署一个操作日志服务,是因为我们要遵守单一职责的原则,不需要每个服务都在自己的库里面创建表来保存操作日志。而是由操作日志服务统一对外提供新增和查询的能力。当然了,这一版我只是做了 HTTP 的请求方式,如果大家的系统是微服务架构,服务之间使用的是 Dubbo 来通信的话,可以在 SDK 和 Server 中进行增强。

2 使用介绍

2.1 配置开启操作日志功能

# 开启操作日志组件功能
log.record.enabled=true
# 操作日志服务地址
log.record.url=http://ip:port

关于操作日志组件的配置还是比较少的,因为主要的配置在注解那,这里只负责配置是否启用。

但是要注意的是:如果开启了操作日志组件功能,那么一定要配置操作日志服务地址,因为 SDK 中,会调用操作日志服务的接口来新增操作日志,和提供了查询操作日志列表的接口

2.2 加入注解配置

开启操作日志组件功能后,我们接着在需要记录操作日志的类方法上加上@LogRecordAnno注解,然后配置我们需要记录的日志类型和日志内容。

下面是我自己提供的简单例子:

/**
 *
 * @author winfun
 * @date 2021/2/25 3:58 下午
 **/
@Service
public 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表达式来获取;特别是ID,一定要这么做,不然会出现异常。
  • 关于成功信息和失败信息,我们可以看到,在spel表达式外面我们会套多一层{{}},那是因为在成功信息和失败信息中,我们支持多个spel表达式,所以需要利用一定规则来进行读取,一定要按照这个规则写。还有就是失败信息,统一使用{{#_errorMsg}},因为失败信息是读取异常栈中的异常信息,所以都是统一填写统一获取。

3 简单介绍操作日志组件的实现

我们可以直接从注解入手:

/**
 * LogRecord 注解
 * @author winfun
 * @date 2021/2/25 4:32 下午
 **/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @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 "";
}

3.1 日志类型

首先,操作日志组件支持两种操作日志类型:第一种是记录操作前后的实体内容,这个会记录完整的信息,但是需要配合 MybatisPlus 使用,有一定的限制,并且最后显示的操作日志需要使用方做一定的处理;第二种是直接记录成功日志和失败日志,比较通用,适用方查询后直接回显即可。

3.1.1 记录实体内容

上面也说到,记录实体信息需要配合 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;
    }
}

3.1.2 记录成功/失败信息

我们如果不关心实体变更前后的内容,我们可以自定义接口调用成功后和失败后的信息。
主要是利用规则{{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);
    }
}

3.2 记录操作者

为了更方便获取到此操作是谁来执行的,操作日志组件也提供了操作者的存储功能,我们只需要在注解中添加 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";
    }
}

3.3 业务名

关于业务名,大家使用起来一定要配置,因为后续如果要提供操作日志列表给用户查看,是根据业务名查询的,也就是说,大家一定要保证业务名之间都是具有一定含义的,并且每个业务的操作日志的业务名都保持唯一,这样才不会查到别的业务的操作日志。

业务名在 sdk 中不做任何特殊处理,直接获取属性值保存。

3.4 调用保存操作日志记录接口

上面我们说到,操作日志组件由两部分组成:sdk&server,我们需要单独部署一套操作日志组件的服务,对外提供统一的保存和查询操作日志功能。

在上面介绍的 LogRecordAspect 中,在最后会调用 server 的接口来保存操作日志;这个保存动作是异步的,利用的是自定义线程池,保证不影响主业务的执行。

代码如下:

/***
 * 增加日志记录->异步执行,不影响主业务的执行
 * @author winfun
 * @param logRecord logRecord
 * @return {@link Integer }
 **/
@Async("AsyncTaskThreadExecutor")
@Override
public ApiResult<Integer> insertLogRecord(LogRecord logRecord) {
    // 发起HTTP请求
    return this.restTemplate.postForObject(url+"/log/insert",logRecord,ApiResult.class);
}

3.4 使用操作日志查询接口

在 sdk 中,我们已经在 LogRecordSDKService 中提供了根据 businessName 查询操作日志的接口,大家只需要在 controller 层或者 serivce 引入 LogRecordSDKService 然后调用方法即可。如果不需要任何处理则直接返回,否则遍历列表再做进一步的处理。

使用例子:

@Autowired
private LogRecordSDKService logRecordSDKService;

@GetMapping("/query/{businessName}")
public ApiResult<List<LogRecord>> query(@PathVariable("businessName") String businessName){
    return this.logRecordSDKService.queryLogRecord(businessName);
}

4 优化点

当然了,组件还有很多的优化点:

  • 记录实体信息的时候,我们其实只需要记录有变更的字段值,而不是整个实体记录下来。
  • sdk 中的新增和查询操作日志都是发起 HTTP 请求,但是每次 HTTP 请求都需要进行三次握手和四次挥手,这些都是操作都是耗时的;所以如果系统使用的是微服务架构,可以将此改为 dubbo 调用来避免频繁的三次握手和四次挥手。

详细代码可看:https://github.com/Howinfun/winfun-log-record

当然了,如果大家有更好的设计,欢迎大家一起来优化!

posted @ 2021-03-04 14:42  不送花的程序猿  阅读(1263)  评论(0编辑  收藏  举报