系统的操作日志如何做成通用的模块一直以来是一个经久不衰的课题.
在开发一个系统时, 或多或少都有对数据的完整性有要求, 比如
- 要求业务数据不能物理删除记录,
- 要求添加每一条数据时都要有系统记录,
- 或者更新某条数据都需要跟踪到变化的内容,
- 或者删除数据时需要记录谁删除了、何时删除了, 以便误删后可以通过系统的某些功能来恢复误删的数据.
可以将这种功能称为系统操作日志.
哪些地方需要记录操作日志?
执行 insert、update、delete 这 3 个操作的时候, 就需要进行日志记录. 而日志执行的先后顺序如下:
- insert: 在 insert 后执行
- update: 在 update 前后都要执行, 操作前获取操作数据, 操作后获取操作数据
- delete: 在 delete 前执行
为什么要做操作日志?
主要的目的就是跟踪到每一个用户在系统的操作行为, 如对数据进行查询、新增、编辑或删除、甚至是登录等行为.
更近一步的理解可以说是对用户使用系统情况的跟踪, 对数据的跟踪防止数据意外删除、更改时有所记录, 有所依据, 以便数据的还原, 从某种程序上可以保护数据的完整性.
用户操作日志模块如何开发?
系统开发中我们经常使用一些日志框架 (如 JAVA 中的 log4j / logback / slf4j 等), 用来调试、追踪、输出系统运行状况等, 这些日志通常是给程序员看的, 暂且叫它 “系统日志”;
而对于普通用户来说, 也需要一个日志功能, 可以方便查询自己做过哪些操作, 用来记录业务层面的日子, 这些日志是相对普通用户的, 暂且叫它 “用户操作日志”.
“系统日志” 和 “用户操作日志” 的关系。把 ”用户操作日志“ 当作一个模块去开发的话,如何分析,注意哪些方面?
日志系统架构设计
整个解答包括问题定义、模型设计、方案设计、最终实现等多个环节。展现了系统架构设计的全部流程。
功能定义
在开发一个系统之前,我们先要对系统进行明确的定义。
在一个软件系统中,通常存在增删改查四类操作。对于日志系统而言,这四类操作的处理难度不同。
查询操作往往不需要记录日志,增加和删除操作涉及一个对象状态,编辑操作涉及对象编辑前和编辑后的两个状态。
编辑操作是整个日志模块中最难处理的。只要掌握了编辑操作,则新增操作、删除操作、查询操作都很简单了。因为,新增操作可以理解为 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 ...); // 业务代码 }
而如果你想要设计一个较为专业、通用、易用的日志模块,那请继续向下阅读。我们必须从模型设计开始慢慢展开。
模型设计
设计系统的第一步是抽象, 抽象出一个简单的鳊鱼处理的模型.
我们可以把用户操作抽象为下面的模型, 即用户通过业务逻辑修改了持久层中的数据.
要想记录日志, 那我们需要在整个流程中设置一道切面, 用以获取和记录操作的影响.
而这一道切面的位置十分关键, 我们下面探讨这一点.
单一切面能否实现用户操作日志的记录?
- 如果使用单一切面能实现日志记录功能, 那就太好了. 这意味着我们只要在系统中定义一个日志切面, 则所有的用户操作都会被记录.
- 而如果单一的切面无法做到, 那我们的日志操作就需要侵入业务逻辑.
在展开讨论之前要注意, 这里只是模型设计, 请忽略一些细节. 例如, 参数是英文变量名, 不便于表意; 某些参数是 id, 与系统强耦合等. 这些都不是模型层需要考虑的, 我们会在后续的设计中解决这些问题.
上层切面
首先, 我们考虑在整个业务逻辑的最上层设置切面, 如下图所示:
这一层其实就是业务逻辑入口处, 以下面的方法为例:
public String startTask(String taskId, Integer userId, Date startTime, Tool tool) { // 业务代码 }
我们可以得到的日志信息有:
startTask: 方法的名称 - taskId: 方法的参数名, 及其对应的参数值, 例如 15 - userId: 方法的参数名, 及其对应的参数值, 例如 3 - startTime: 方法的参数名, 及其对应的参数值, 例如 2022-12-12 12:12 - tool: 方法的参数名, 及其对应的参数值, 例如 14
可见这些信息的特点是贴近业务逻辑. 因为 startTask 表明了我们要进行的业务逻辑的操作类型, 而后面的操作参数则表明了业务逻辑的参数.
然而缺点也很明显:
- 首先, 无法获得编辑前的旧对象. 即我们不知道 startTask 执行前 task 对象的状态.
- 其次, 它不能反映真正的数据变动. 这一点是致命的.
好, 我们接下来说明一下第二点.
因为我们是上层切面, 从入参处获取信息. 但是入参的信息却不一定是最终持久化的信息. 假设方法中存在下面的业务逻辑:
public String startTask(String taskId, Integer userId, Date startTime, Tool tool) { // 其他业务代码 while (taskBusiness.queryByTaskId(taskId).isFnished()) { taskId++; } if (userBusiness.queryByUserId().isLeave()) { return "任务启动失败"; } }
则上层切面获得的taskId信息可能是无效的,甚至,整个操作都是无效的。
因此,上层切面的特点是:贴近业务逻辑、不能反映真实数据变动。
因此,上层切面无法直接采用。
下层切面
下层切面就是在业务逻辑的最下层设置切面,如下图所示:
这一层其实就是在持久层获取日志信息.
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 对象的各个属性都会被传入. 但是这些属性中, 有很多并没有发生变动, 是没有必要被日志系统记录的.
可见, 下层切面的特点是: 反应真实数据变动, 脱离业务逻辑.
因此, 下层切面无法直接采用.
混合切面
上层切面和下层切面都不能单独使用, 这意味着我们不可能使用一个简单的切面完成日志操作.
那最终怎么解决呢?
使用混合 “切面”, 即吸收下层切面的准确性、整合上层切面的业务逻辑信息, 并顺便解决旧对象的获取问题.
对 “切面” 加引号是因为这不是一个绝对纯粹的切面, 它对业务逻辑存在一定的侵入性. 但这是没有办法的.
我们需要在业务逻辑中增加一行类似下面的代码:
logClient.logXXX(params...);
至于这行代码如何写,后面的逻辑如何,我们后面细化。但是我们知道,这行代码中传入的参数要既包含上层信息也包含下层信息。
以下层信息为主(因为它准确),以上层信息为辅(因为它包含业务信息)。如下图所示。
接下来我们会一步一步介绍其实现。
对象属性对比功能实现
我们说道在下面方法中,获得的信息以下层信息为主,以上层信息为辅。
那我们先说下层信息,显然就是数据库中的老对象和修改后的新对象,因此,其入参形式如下:
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……
日志模块的使用场景不同,要处理的对象(即 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);
对象属性处理
我们已经介绍了实现新旧对象属性比对的基本实现逻辑,但是一切并没有这么简单。因为,对象的属性本身就非常复杂。
例如,有些属性(例如userId)是对其他对象的引用,把它们写入日志会让人觉着摸不着头脑(例如应该换成用户姓名或工号);有些属性(例如富文本)则十分复杂,在写入日志前需要进行特殊的处理。
在这一节,我们将介绍这些特殊的属性处理逻辑。
普通属性
当我们比较出新老对象的属性时,有一些属性可以直接计入日志。
直接记录为 “从{oldValue}修改为{newValue}” 的形式即可。
例如,tool 对象的价格,可以计入为:
price:从47修改为51
其中47是属性的旧值,51是属性的新值。
特殊属性
但是有一些属性不可以,例如长文本。我们采用新值旧值的形式记录其变动是不合理的。例如:
description:从“今天天气好\n真好\n哈哈嘿嘿哈哈”修改为“今天天气好\n哈哈嘿嘿哈哈”
description:删除了第2行“真好”
这时,我们可以设置一种机制,对复杂文本的属性进行特殊的处理。最终得到下面的结果。
这样一来,效果是不是好多了。
在具体实现上,我们可以使用注解来标明一个属性的值需要特殊处理的类型,如下:
@LogTag(innerType = InnerType.FullText) private String description;
这样,我们在日志模块设计机制,识别出 InnerType.FullText 的属性后使用富文本处理方式对其进行新旧值的比对处理。
当然,这种机制不仅仅适用于富文本,还有一些其他的属性,例如图片。我们可以引用新旧图片的地址进行展示。
业务属性
还有一种属性,更为特殊。task 对象中的责任人。我们采用下面的方式记录显然不太友好:
userId:从4修改为5
在 task 对象的 userId 属性中存放的是用户编号, 4、5 都是用户编号。但在日志中我们更希望看到人员姓名。
可是用户编号到姓名信息日志模块是没有的。
因此,这时候我们需要业务模块实现日志模块提供的接口,来完成上述映射。得到如下结果:
userId:从“王二丫”修改为“李大笨”
不只是 userId,还有 toolId 等各种业务属性也适用这种处理方式。
这样处理还带了一个优点:解耦。
当一个日志系统记录下某个日志时,例如,记录下“小明删除了文件A”时,即使业务系统将小明的 userId 和小李的 userId 互换,则日志系统也不能将日志变为“小李删除了文件A”。因此,日志系统中的数据应该是一经落库立刻封存。
在具体实现上,我们可以使用注解来标明一个属性的值需要由业务系统辅助处理,如下:
@LogTag(extendedType = "userIdType") private int userId;
这样,我们在日志模块设计机制,识别出 userId 属性后使用 userIdType 处理方式调用业务模块提供的接口对其进行新旧值的比对处理。
易用性注解
经过上面的处理,我们已经能够拿到类似下面的日志结果:
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
存储设计
获取了对象的不同之后,我们应该将其存储起来。显然,最简单的:
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:
-- ---------------------------- -- Table structure for operation -- ---------------------------- DROP TABLE IF EXISTS `operation`; 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 ); -- ---------------------------- -- Table structure for attribute -- ---------------------------- DROP TABLE IF EXISTS `attribute`; CREATE TABLE `attribute` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `operationId` bigint(20) unsigned NOT NULL, `attributeType` varchar(500) NOT NULL DEFAULT '', `attributeName` varchar(500) NOT NULL DEFAULT '', `attributeAlias` varchar(500) NOT NULL DEFAULT '', `oldValue` mediumtext, `newValue` mediumtext, `diffValue` mediumtext, PRIMARY KEY (`id`), KEY `operationId` (`operationId`) USING BTREE );
这样,可以完整地保存日志操作及这次操作引发的属性变动。
方案总结
整个日志模块的概要设计就完成了。
不过,篇幅所限,有一些细节没能涉及到,包括注解的处理、业务操作接口的预留、日志的序列化与反序列化等。这都是小问题。大的设计概要有了,这些小问题不难解决。
系统实现
https://link.zhihu.com/?target=https%3A//github.com/yeecode/ObjectLogger
reference
https://www.zhihu.com/question/26848331