博客
关于我
自研一套通俗易用的操作日志组件
阅读量:396 次
发布时间:2019-03-05

本文共 9127 字,大约阅读时间需要 30 分钟。

原文链接:

背景

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

那么我们怎样提供用户操作呢?那肯定是要用到日志了,而我们往往在研发的时候,都会在一些重要步骤上面打上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 下午 **/@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表达式来获取;特别是ID,一定要这么做,不然会出现异常。
  • 关于成功信息和失败信息,我们可以看到,在spel表达式外面我们会套多一层{{}},那是因为在成功信息和失败信息中,我们支持多个spel表达式,所以需要利用一定规则来进行读取,一定要按照这个规则写。还有就是失败信息,统一使用{{#_errorMsg}},因为失败信息是读取异常栈中的异常信息,所以都是统一填写统一获取。

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

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

/** * 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 "";}

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")@Overridepublic ApiResult
insertLogRecord(LogRecord logRecord) { // 发起HTTP请求 return this.restTemplate.postForObject(url+"/log/insert",logRecord,ApiResult.class);}

3.4 使用操作日志查询接口

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

使用例子:

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

4 优化点

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

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

详细代码可看:

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

转载地址:http://jyrzz.baihongyu.com/

你可能感兴趣的文章
上周热点回顾(1.16-1.22)
查看>>
上周热点回顾(1.23-1.29)
查看>>
上周热点回顾(3.20-3.26)
查看>>
上周热点回顾(4.24-4.30)
查看>>
[故障公告]博客站点1台负载均衡遭遇流量攻击,造成联通与移动用户无法正常访问
查看>>
上周热点回顾(5.1-5.7)
查看>>
云计算之路-阿里云上:14:20-14:55博客后台2台服务器都CPU 100%引发的故障
查看>>
上周热点回顾(6.19-6.25)
查看>>
云计算之路-阿里云上:docker swarm 集群故障与异常
查看>>
上周热点回顾(2.19-2.25)
查看>>
云计算之路-阿里云上:博客web服务器轮番CPU 100%
查看>>
云计算之路-阿里云上:服务器CPU 100%问题是memcached连接数限制引起的
查看>>
上周热点回顾(3.26-4.1)
查看>>
上周热点回顾(6.25-7.1)
查看>>
【故障公告】10:30-10:45 左右 docker swarm 集群节点问题引发故障
查看>>
工作半年的思考
查看>>
不可思议的纯 CSS 滚动进度条效果
查看>>
【CSS进阶】伪元素的妙用--单标签之美
查看>>
开始CN的生活
查看>>
惊闻NBC在奥运后放弃使用Silverlight
查看>>