選自知乎問答:
系統開發中我們經常使用一些日志框架(如JAVA中的 log4j/logback/slf4j 等),用來調試、追蹤、輸出系統運行狀況等,這些日志通常是給程序員看的,暫且叫它”系統日志“;而對於普通用戶來說,也需要一個日志功能,可以方便查閱自己做過哪些操作,這些日志是面向普通用用戶的,暫且叫它 ”用戶操作日志“。
有木有大神講一講 “系統日志” 和 “用戶操作日志” 的關系。把 ”用戶操作日志“ 當作一個模塊去開發的話,如何分析,注意哪些方面?
有些地方沒說明白,補充一下:
1 我認為用戶操作日志應該記錄業務層面的日志,或者說是用例層面,可能涉及操作數據庫的多張表,甚至不僅操作數據庫,所以簡單記錄表的增刪改查我認為不妥
2 說到兩種日志的關系,因為之前見過某大廠定制擴展了 slf4j和logback,格式化都是約定好的,持久化后,很方便解析。所以我在想,是不是按照某個約定去輸出系統日志,持久化、解析后,就能拿到面向普通用戶的日志了呢?
3 用戶操作日志模塊應該比較常見,但凡重復開發率較高的模塊,都會有人去把它抽離,做成一個比較獨立的模塊或類庫,比如登錄注冊/權限管理/認證授權等,注冊方式多種,權限管理更是復雜,各具體項目差異太大,但還是有 shiro,spring security 這種安全框架,將不可變抽象成穩定的接口,將可變開放。
有人回答說去問客戶,按客戶需求來開發,這里我認為有一些通用的基礎技術或解決方案,提問的目的也在此。
本人確實軟工底子很差,但題目明顯不需要這個答案
高贊回答:https://www.zhihu.com/question/26848331
首先,實名反對各答非所問和調侃的回答。
題主的提問非常詳細,認真。實名贊揚題主提問。
事實上,這是一個非常不錯的題目。該題目涉及軟件架構設計與開發的多個方面,具有很強的通用性。研究好這個問題對於開發能力的提升很大。
今天有時間,我來解答一下這個問題。並且,最后還會附上實現代碼。
最終實現的效果如下所示:
實現的效果如下,這是實際截取的圖:

我用盡可能用深入淺出的解答和實實在在的代碼,支持每一個真誠的提問者。
整個回答不僅包含實現,還包括架構設計過程,會比較長。建議大家讀完。
如果有不清楚的地方,大家可以在評論區提問。
整個解答包括問題定義、模型設計、方案設計、最終實現等多個環節。展現了系統架構設計的全部流程。
目錄如下:
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屬性中存放的是用戶編號, 4、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

供大家參考。
感謝老鐵!
有開發者在我的日志模塊基礎上開發了React的前端組件!並獨立出了一個開源前端項目!
可以和我寫的日志模塊無縫銜接做日志展示!
實現的效果如下:

真有才!真漂亮。
老鐵,不用謝!
已經有人在生產項目中使用了這個日志系統。
效果如下:

還真不錯。
我也測過了,幾百萬條日志沒啥問題。
具體實現代碼、使用配置,大家去這個項目的README看吧,我好好維護,盡量寫的全面一點。
大家有什么意見建議也可以去開源項目頁面提issue。
最后,這是個好題目。
不過相比於我的其他回答,這個干貨回答反而點贊少。
點贊少,我也會一直維護和更新。
也可以關注我,比較忙,但是我會偶爾出沒解答架構設計和編程問題。