用户操做日志模块如何开发?

选自知乎问答:前端

  系统开发中咱们常用一些日志框架(如JAVA中的 log4j/logback/slf4j 等),用来调试、追踪、输出系统运行情况等,这些日志一般是给程序员看的,暂且叫它”系统日志“;而对于普通用户来讲,也须要一个日志功能,能够方便查阅本身作过哪些操做,这些日志是面向普通用用户的,暂且叫它 ”用户操做日志“。
有木有大神讲一讲 “系统日志” 和 “用户操做日志” 的关系。把 ”用户操做日志“ 看成一个模块去开发的话,如何分析,注意哪些方面?

有些地方没说明白,补充一下:
1 我认为用户操做日志应该记录业务层面的日志,或者说是用例层面,可能涉及操做数据库的多张表,甚至不只操做数据库,因此简单记录表的增删改查我认为不妥
2 说到两种日志的关系,由于以前见过某大厂定制扩展了 slf4j和logback,格式化都是约定好的,持久化后,很方便解析。因此我在想,是否是按照某个约定去输出系统日志,持久化、解析后,就能拿到面向普通用户的日志了呢?
3 用户操做日志模块应该比较常见,但凡重复开发率较高的模块,都会有人去把它抽离,作成一个比较独立的模块或类库,好比登陆注册/权限管理/认证受权等,注册方式多种,权限管理更是复杂,各具体项目差别太大,但仍是有 shiro,spring security 这种安全框架,将不可变抽象成稳定的接口,将可变开放。
有人回答说去问客户,按客户需求来开发,这里我认为有一些通用的基础技术或解决方案,提问的目的也在此。
本人确实软工底子不好,但题目明显不须要这个答案git

 

高赞回答:https://www.zhihu.com/question/26848331程序员

 
高级软件系统架构师
 

首先,实名反对各答非所问和调侃的回答。github

题主的提问很是详细,认真。实名赞赏题主提问。spring

事实上,这是一个很是不错的题目。该题目涉及软件架构设计与开发的多个方面,具备很强的通用性。研究好这个问题对于开发能力的提高很大。数据库

今天有时间,我来解答一下这个问题。而且,最后还会附上实现代码。编程

最终实现的效果以下所示:安全

实现的效果以下,这是实际截取的图:架构

 

 


我用尽量用深刻浅出的解答和实实在在的代码,支持每个真诚的提问者。app

整个回答不只包含实现,还包括架构设计过程,会比较长。建议你们读完。

若是有不清楚的地方,你们能够在评论区提问。


整个解答包括问题定义、模型设计、方案设计、最终实现等多个环节。展示了系统架构设计的所有流程。

目录以下:

1 功能定义
2 模型设计
    2.1 上层切面
    2.2 下层切面
    2.3 混合切面
3 对象属性对比功能实现
4 对象属性处理
    4.1 普通属性
    4.2 特殊属性
    4.3 业务属性
5 易用性注解
6 存储设计
7 方案总结
8 系统实现
 

1 功能定义

在开发一个系统以前,咱们先要对系统进行明确的定义。

在一个软件系统中,一般存在增删改查四类操做。对于日志系统而已,这四类操做的处理难度不一样。

查询操做每每不须要记录日志,增长和删除操做涉及一个对象状态,编辑操做涉及对象编辑前和编辑后的两个状态。

编辑操做是整个日志模块中最难处理的。只要掌握了编辑操做,则新增操做、删除操做、查询操做都很简单了。由于,新增操做能够理解为null到新对象的编辑,删除操做能够理解为旧对象到null的编辑,查询操做能够理解为旧对象到旧对象的编辑。

所以,本文主要以编辑操做为例进行介绍。

为了便于描述,咱们假设一个学校卫生大扫除系统。

这个系统中包含不少方法,例如分配大扫除工做的assignTask方法,开始某个具体工做的startTask方法,验收某个具体工做的checkTask方法,增长新的人员的addUser方法等。每一个方法都有不一样的参数,涉及不一样的对象。

以startTask方法为例,开始一个任务须要在任务中记录开始时间、责任人、使用的工具,整个方法以下:

public String startTask(String taskId, Integer userId, Date startTime, Tool tool) {
     // 业务代码      
}

最简单的记录日志的方法即是在代码中直接根据业务逻辑写入日志操做语句,例如:

public String startTask(String taskId, Integer userId, Date startTime, Tool tool) {
    // 业务代码
    log.add("操做类型:开始任务。任务编号:" + taskId + ";责任人:" + userId ……);
    // 业务代码
}

若是你真的打算使用上述的方法记录日志,那已经没有什么能够教你的了。

 

你要作的就是提高本身Ctrl + C和Ctrl +V的速度,努力成为一个真正的CV大神直到顶级CRUD工程师。

而若是你想要设计一个较为专业、通用、易用的日志模块,那请继续向下阅读。咱们必须从模型设计开始慢慢展开。

2 模型设计

设计系统的第一部是抽象,抽象出一个简单的便于处理的模型。

咱们能够把用户操做抽象为下面的模型,即用户经过业务逻辑修改了持久层中的数据。

 

 

要想记录日志,那咱们须要在整个流程中设置一道切面,用以获取和记录操做的影响。

而这一道切面的位置十分关键,咱们下面探讨这一点。本章节的探讨与讨论一个问题:单一切面可否实现用户操做日志的记录。

  • • 若是使用单一的切面能实现日志记录功能,那就太好了。这意味着咱们只要在系统中定义一个日志切面,则全部的用户操做都会被记录。
  • • 而若是单一的切面没法作到,那咱们的日志操做就须要侵入业务逻辑。
在展开讨论以前要注意,这里只是模型设计,请忽略一些细节。例如,参数是英文变量名,不便于表意;某些参数是id,与系统强耦合等。这些都不是模型层须要考虑的,咱们会在后续的设计中解决这些问题。

2.1 上层切面

首先,咱们考虑在整个业务逻辑的最上层设置切面以下图所示:

 

 

这一层其实就是业务逻辑入口处,如下面的方法为例:

public String startTask(String taskId, Integer userId, Date startTime, Tool tool) {
     // 业务代码      
}

咱们能够获得的日志信息有:

startTask:方法的名称
    - taskId:方法的参数名,及其对应的参数值,例如15
    - userId:方法的参数名,及其对应的参数值,例如3
    - startTime:方法的参数名,及其对应的参数值,例如 2019-12-21 15:15
    - tool:方法的参数名,及其对应的参数值,例如14

可见这些信息的特色是贴近业务逻辑。由于startTask代表了咱们要进行的业务逻辑的操做类型,然后面的操做参数则代表了业务逻辑的参数。

然而缺点也很明显:

  • • 首先,没法得到编辑前的旧对象。即咱们不知道startTask执行前task对象的状态。
  • • 其次,它不能反映真正的数据变更。这一点是致命的。

好,咱们接下来讲明一下第二点。

由于咱们是上层切面,从入参处获取信息。可是,入参的信息却不必定是最终持久化的信息。假设方法中存在下面的业务逻辑:

public String startTask(String taskId, Integer userId, Date startTime, Tool tool) {
    // 其余业务代码
    while(taskBusiness.queryByTaskId(taskId).isFinished()) {
        taskId++;
    }
    if(userBusiness.queryByUserId().isLeave()) {
        return "任务启动失败";
    }
    // 其余业务代码
}

则上层切面得到的taskId信息多是无效的,甚至,整个操做都是无效的。

所以,上层切面的特色是:贴近业务逻辑、不能反映真实数据变更。

所以,上层切面没法直接采用。

2.2 下层切面

下层切面就是在业务逻辑的最下层设置切面,以下图所示:

 

 

这一层其实就是在持久层获取日志信息。

startTask方法可能在持久层对应了下面的update操做:

updateTask(TaskModel taskModel); // 该方法对应了MyBatis等工具中的SQL语句

经过这个方法能够获得的日志信息有:

updateTask:
    - taskId
    - userId
    - startTime
    - toolId
    - taskName
    - taskDescription

首先,以上信息是准确的。由于这些信息是从写入持久层的操做中获取的,例如从SQL语句的前一步获取。这里面的taskId、userId等值可能和入参的值不同,但必定是准确的。

可是,它仍然存在两个问题:

  • • 首先,没法得到编辑前的旧对象。同上。
  • • 其次,它脱离业务逻辑。

咱们仍是主要说明一下第二点,例如,日志信息中的updateTask反应了这是一次任务编辑操做,可是任务编辑操做是不少的:assignTask、startTask、checkTask、changeTaskName等不一样的业务操做可能都会映射为一次SQL操做中的update操做。在这里,咱们没法区分了。

而且,编辑操做通常写的大而全,例如常写为下面的形式:

<update id="updateTask">
        UPDATE task
        <set>
            <if test="userId!=null">userId= #{userId},</if>
            <if test="startTime!=null">startTime= #{startTime},</if>
            <if test="toolId!=null">toolId= #{toolId},</if>
            <if test="taskName!=null">taskName= #{taskName},</if>
            <if test="taskDescription!=null">taskDescription= #{taskDescription},</if>
        </set>
        where taskId= #{taskId}
    </update>

当咱们调用updateTask方法时,task对象的各个属性都会被传入。可是这些属性中,有不少并无发生变更,是没有必要被日志系统记录的。

可见,下层切面的特色是:反映真实数据变更,脱离业务逻辑。

所以,下层切面没法直接采用。

2.3 混合切面

上层切面和下层切面都不能单独使用,这意味着咱们不可能使用一个简单的切面完成日志操做。

我想,这也是题主提问的缘由,若是是一个切面可以解决的问题,就不用这样来提问了。

那最终怎么解决呢?

使用混合“切面”,即吸取下层切面的准确性、整合上层切面的业务逻辑信息,并顺便解决旧对象的获取问题。对“切面”加引号是由于这不是一个绝对纯粹的切面,它对业务逻辑存在必定的侵入性。但这是没有办法的。

咱们须要在业务逻辑中增长一行相似下面的代码:

logClient.logXXX(params...);

至于这行代码如何写,后面的逻辑如何,咱们后面细化。可是咱们知道,这行代码中传入的参数要既包含上层信息也包含下层信息。

如下层信息为主(由于它准确),以上层信息为辅(由于它包含业务信息)。以下图所示。

 

 

接下来咱们会一步一步介绍其实现。

3 对象属性对比功能实现

咱们说道在下面方法中,得到的信息如下层信息为主,以上层信息为辅。

那咱们先说下层信息,显然就是数据库中的老对象和修改后的新对象,所以,其入参形式以下:

logClient.logObject(oldObject,newObject);

而在处理日志的第一步,就是找出新对象和老对象之间属性的不一样。

假设tool对象的属性以下:

  • • toolId:编号
  • • toolName:工具名称
  • • price:价格
  • • position:存放位置

要想把新旧两个tool对象的属性不一样找出来,可使用相似下面的代码。

// 对比工具的名称toolName
if(!oldTool.getToolName().equals(newTool.getToolName())) {
    log.add("toolName",diff(oldTool.getToolName(),newTool.getToolName()));
}
// 对比工具的价格price
if(!oldTool.getPrice().equals(newTool.getPrice())) {
    log.add("toolPrice",diff(oldTool.getPrice(),newTool.getPrice()));
}
// 依次对比工具的各个其余属性

这种代码能够实现功能,可是……仅仅适用于tool对象。

若是换成了task对象,则又要从新写一套。假设task对象的属性以下:

  • • taskId:编号
  • • userId:责任人编号
  • • startTime:开始时间
  • • toolId:须要的工具的编号
  • • taskName:任务名
  • • taskDescription:任务描述

那是否是只能根据task对象的属性再写一套if……

 

若是你真的就是打算使用上述的方法记录日志,那我已经没有什么能够教你的了。

 

你要作的就是提高本身Ctrl + C和Ctrl +V的速度,努力成为一个真正的CV大神直到顶级CRUD工程师。

 

日志模块的使用场景不一样,要处理的对象(即oldObject和newObject)千奇百怪。所以,上面的这种代码显然也是不可取的。

因此说,咱们要自动分析对象的属性不一样,而后记录。即将对象拆解开来,逐一对比两个对象(来自同一个类)的各个属性,而后将不一样的记录下来。

显然,要用反射。

那这个问题就解决了,若是对反射不了解的,能够学习反射相关知识。这些比较基本,我就不赘述了。

使用反射以后,咱们要记录新老对象的变更则只须要以下调用:

logClient.logObject(oldObj,newObj);

而后在这个方法中采用反射找出对象的各个属性,而后依次进行比对。其实现代码以下:

/**
 * 比较两个任意对象的属性不一样
 * @param oldObj 第一个对象
 * @param newObj 第二个对象
 * @return 两个对象的属性不一样
 */
public static Map<String, String> diffObj(Object oldObj, Object newObj) {
    Map<String, String> diffMap = new HashMap<>();
    try {
        // 获取对象的类
        Class oldObjClazz = oldObj.getClass();
        Class newObjClazz = newObj.getClass();
        // 判断两个对象是否属于同一个类
        if (oldObjClazz.equals(newObjClazz)) {
            // 获取对象的全部属性
            Field[] fields = oldObjClazz.getDeclaredFields();
            // 对每一个属性逐一判断
            for (Field field : fields) {
                // 使得属性能够被反射访问
                field.setAccessible(true);
                // 拿到当前属性的值
                Object oldValue = field.get(oldObj);
                Object newValue = field.get(newObj);
                // 若是某个属性的值在两个对象中不一样,则进行记录
                if ((oldValue == null && newValue != null) || oldValue != null && !oldValue.equals(newValue)) {
                    diffMap.put(field.getName(), "from " + oldValue + " to " + newValue);
                }
            }
        }
    } catch (Exception ex) {
        ex.printStackTrace();
    }
    return diffMap;
}

这样,下层的新老对象信息就处理完成了。

咱们能够在方法中经过参数补充一些上层业务信息。所以,上述方法能够修改成:

logClient.logObject("操做方法", "操做方法别名","触发该操做的用户 等其余信息", oldObj, newObj);

logObject方法就是咱们要实现的方法,其核心操做逻辑就是分析对比新对象和旧对象的不一样,将不一样记录下来,做为这次操做引起的变更。

4 对象属性处理

咱们已经介绍了实现新旧对象属性比对的基本实现逻辑,可是一切并无这么简单。由于,对象的属性自己就很是复杂。

例如,有些属性(例如userId)是对其余对象的引用,把它们写入日志会让人觉着摸不着头脑(例如应该换成用户姓名或工号);有些属性(例如富文本)则十分复杂,在写入日志前须要进行特殊的处理。

在这一节,咱们将介绍这些特殊的属性处理逻辑。

4.1 普通属性

当咱们比较出新老对象的属性时,有一些属性能够直接计入日志。

直接记录为“从{oldValue}修改成{newValue}”的形式便可。

例如,tool对象的价格,能够计入为:

price:从47修改成51

其中47是属性的旧值,51是属性的新值。

4.2 特殊属性

可是有一些属性不能够,例如长文本。咱们采用新值旧值的形式记录其变更是不合理的。例如:

description:从“今每天气好\n真好\n哈哈嘿嘿哈哈”修改成“今每天气好\n哈哈嘿嘿哈哈”

这种形式显然很难看、很难懂。

咱们想要的结果应该是:

description:删除了第2行“真好”

这时,咱们能够设置一种机制,对复杂文本的属性进行特殊的处理。最终获得下面的结果。

 

这样一来,效果是否是好多了。

在具体实现上,咱们可使用注解来标明一个属性的值须要特殊处理的类型,以下:

@LogTag(innerType = InnerType.FullText) 
private String description;

这样,咱们在日志模块设计机制,识别出InnerType.FullText的属性后使用富文本处理方式对其进行新旧值的比对处理。

固然,这种机制不只仅适用于富文本,还有一些其余的属性,例如图片。咱们能够引用新旧图片的地址进行展现。

4.3 业务属性

还有一种属性,更为特殊。task对象中的责任人。咱们采用下面的方式记录显然不太友好:

userId:从4修改成5

在task对象的userId属性中存放的是用户编号, 四、5都是用户编号。但在日志中咱们更但愿看到人员姓名。

但是用户编号到姓名信息日志模块是没有的。

所以,这时候咱们须要业务模块实现日志模块提供的接口,来完成上述映射。获得以下结果:

userId:从“王二丫”修改成“李大笨”

不仅是userId,还有toolId等各类业务属性也适用这种处理方式。

这样处理还带了一个优势:解耦。

当一个日志系统记录下某个日志时,例如,记录下“小明删除了文件A”时,即便业务系统将小明的userId和小李的userId互换,则日志系统也不能将日志变为“小李删除了文件A”。所以,日志系统中的数据应该是一经落库马上封存。

在具体实现上,咱们可使用注解来标明一个属性的值须要由业务系统辅助处理,以下:

@LogTag(extendedType = "userIdType") 
private int userId;

这样,咱们在日志模块设计机制,识别出userId属性后使用userIdType处理方式调用业务模块提供的接口对其进行新旧值的比对处理。

5 易用性注解

通过上面的处理,咱们已经可以拿到相似下面的日志结果:

userId:从“王二丫”修改成“李大笨”
description:删除了第2行“真好”  
price:从47修改成51

其形式已经不错了。

可是这里的userId、description、price是一个属性名,当给用户展现时,用户并不知道其确切含义。

所以,咱们须要提高其易用性。

在具体实现上,咱们可使用注解来标明一个属性的值须要由业务系统辅助处理,以下:

@LogTag(alias = "责任人", extendedType = "userIdType") 
private int userId; 
@LogTag(alias = "说明",innerType = InnerType.FullText) 
private String description; 
@LogTag(alias = "价格") 
private double price;

而后在日志模块中,咱们对注解进行处理,能够获得下面形式的日志信息:

责任人:从“王二丫”修改成“李大笨” 
说明:删除了第2行“真好”  
价格:从47修改成51

这样,整个日志的输出形式就比较友好了。

6 存储设计

获取了对象的不一样以后,咱们应该将其存储起来。显然,最简单的:

CREATE TABLE `log` (
  `objectId` varchar(500) NOT NULL DEFAULT '',
  `operationName` varchar(500) NOT NULL,
  `diff` varchar(5000) DEFAULT NULL
);

这样就记录了objectId的对象由于operationName操做发生了diff的变更。

而后把下面的文字做为一个完整的字符串存入diff字段中。

责任人:从“王二丫”修改成“李大笨” 
说明:删除了一行“真好”  
价格:从47修改成51

若是你真的打算使用上述的方法记录日志,那我已经没有什么能够教你的了。

没,开玩笑。这个不至于,由于这个只是考虑不全面致使的个小问题。

咱们不能使用diff就简简单单地将各个属性杂糅在一块儿,将本来结构化的数据变为了非结构化的数据。

咱们能够采用操做表+属性表的形式来存储。一次操做会操做一个对象,这些都记录到操做表中;此次操做会变动多个属性,这些都记录到属性表中。

进一步,咱们能够在操做表中记录被操做对象的类型,这样,防止不一样对象具备相同的id而混淆。并且,咱们还能够设置一个appName字段,从而使得这个日志模块能够供多个应用共用,成为一个独立的日志应用。咱们也能够在记录操做名“startTask”的同时记录下其别名“开始任务”,等等。从而全面提高日志模块的功能性、易用性。

一样的,属性表中咱们能够记录各个属性的类型,便于咱们进行分别的展现。记录属性的旧值、新值、先后变化等。

很少说了,我直接给出两个表的DDL:

CREATE TABLE `operation` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `appName` varchar(500) DEFAULT NULL,
  `objectName` varchar(500) NOT NULL DEFAULT '',
  `objectId` varchar(500) NOT NULL DEFAULT '',
  `operator` varchar(500) NOT NULL,
  `operationName` varchar(500) NOT NULL DEFAULT '',
  `operationAlias` varchar(500) NOT NULL DEFAULT '',
  `extraWords` varchar(5000) DEFAULT NULL,
  `comment` mediumtext,
  `operationTime` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `appName` (`appName`) USING HASH,
  KEY `objectName` (`objectName`) USING HASH,
  KEY `objectId` (`objectId`) USING BTREE
);


CREATE TABLE `operation` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `appName` varchar(500) DEFAULT NULL,
  `objectName` varchar(500) NOT NULL DEFAULT '',
  `objectId` varchar(500) NOT NULL DEFAULT '',
  `operator` varchar(500) NOT NULL,
  `operationName` varchar(500) NOT NULL DEFAULT '',
  `operationAlias` varchar(500) NOT NULL DEFAULT '',
  `extraWords` varchar(5000) DEFAULT NULL,
  `comment` mediumtext,
  `operationTime` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `appName` (`appName`) USING HASH,
  KEY `objectName` (`objectName`) USING HASH,
  KEY `objectId` (`objectId`) USING BTREE
);

这样,能够完整地保存日志操做及此次操做引起的属性变更。

7 方案总结

整个日志模块的概要设计就完成了。

我直接画了一个简化的处理流程图:

 

不过,篇幅所限,有一些细节没能涉及到,包括注解的处理、业务操做接口的预留、日志的序列化与反序列化等。这都是小问题。大的设计概要有了,这些小问题不难解决。


8 系统实现

为了支持题主,也为了代表我不仅是扯。

也为了更清晰地表达没能在设计方案中介绍的注解的处理、业务操做接口的预留、日志的序列化与反序列化等问题。

虽然比较忙,可是说到作到。实现了上文设计的日志模块。

 

 

 

并且!!!

还开源了!!!

地址以下,你们自行取用阅读:

https://github.com/yeecode/ObjectLogger​github.com

供你们参考。


感谢老铁!

有开发者在个人日志模块基础上开发了React的前端组件!并独立出了一个开源前端项目!

能够和我写的日志模块无缝衔接作日志展现!

实现的效果以下:

 

 

真有才!真漂亮。


老铁,不用谢!

已经有人在生产项目中使用了这个日志系统。

效果以下:

 

 

还真不错。


我也测过了,几百万条日志没啥问题。

具体实现代码、使用配置,你们去这个项目的README看吧,我好好维护,尽可能写的全面一点。

你们有什么意见建议也能够去开源项目页面提issue。


最后,这是个好题目。

不过相比于个人其余回答,这个干货回答反而点赞少。

点赞少,我也会一直维护和更新。

 

 

也能够关注我,比较忙,可是我会偶尔出没解答架构设计和编程问题。

相关文章
相关标签/搜索