用戶操做日誌模塊如何開發?

選自知乎問答:前端

  系統開發中咱們常用一些日誌框架(如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。


最後,這是個好題目。

不過相比於個人其餘回答,這個乾貨回答反而點贊少。

點贊少,我也會一直維護和更新。

 

 

也能夠關注我,比較忙,可是我會偶爾出沒解答架構設計和編程問題。

相關文章
相關標籤/搜索