zoukankan      html  css  js  c++  java
  • 自研一套通俗易用的操作日志组件

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

    背景

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

    那么我们怎样提供用户操作呢?那肯定是要用到日志了,而我们往往在研发的时候,都会在一些重要步骤上面打上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

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

    今天,你学习了吗
  • 相关阅读:
    C++-struct类的新特性当class用
    rbenv、fish 與 VSCode 設置之路
    angularJS进阶阶段(4)
    插入排序
    Vimium
    Design Patterns 25
    Mysql(或者sqlite), Mongo中update Column + 1
    Hexo
    继承
    Gradle的依赖方式——Lombok在Gradle中的正确配置姿势
  • 原文地址:https://www.cnblogs.com/Howinfun/p/14480193.html
Copyright © 2011-2022 走看看