前端日志管理模塊的設計與實現
一、問題背景
在項目中,我們會頻繁用到 ??console.log()?
? 來輸出一些關鍵信息到控制臺中,有助于開發調試,以及問題的排查,待項目上線后,這些調試日志又得及時清除。
同時在前端質量要求下,我們會做“前端埋點”,用于遠程上報一些關鍵行為信息,用于在出問題時還原用戶的操作路徑,復現 BUG,從而解決問題,而各種各樣的上報若是能在業務開發中抹平差異,也有助于研發提效。
因此,有必要在團隊中封裝日志工具(Logger),用于統一管理日志輸出和格式化上報,降低開發者對多平臺上報差異的心智負擔。
二、需求概述
預期日志管理工具(Logger)需要有如下能力:
- 支持區分?
?info?
??、??warn?
??、??error?
? 三種本地調試類型日志 - 支持遠程上報自定義日志?
?report()?
? - 支持設置 namespace,用于區分代碼執行的 scope
- 支持鏈式操作
- 區分生產環境和開發環境,生產環境禁止輸出日志到控制臺
- 支持功能可擴展
三、方案設計
在閱讀完 Axios 的源碼后,個人認為 Axios 里對于設計模式的應用是非常靈活,同理,一個好的日志工具也應當遵守著一定的軟件設計模式原則。
作為項目中用到的日志工具,單例模式應當是更適合的選擇!
Logger 的打印輸出能力,本質上還是借助了 ??window.console?
? 對象中的方法:
Console 對象
在面向對象編程中,我們可以認為 ??console?
? 是一個已經初始化的實例,同時也是一個單例,因為它是全局唯一。
而單例模式的最大好處就是全局唯一,對于做日志統一管理有著天然的友好支持基礎。
四、實現細節 ??
接下來通過具體的代碼,來逐一實現并完善我們的 Logger 日志工具類。
1、ES Module 下的單例模式
在 ESM 規范下,我們可以直接通過直接導出實例方式(??export default new ClassName()?
?),來實現單例模式。
Logger 的基礎結構就有了:
/**
* 日志打印工具,統一管理日志輸出&上報
*/
class Logger {
/** 命名空間(scope),用于區分所在執行文件 */
private namespace: string
constructor(namespace = 'unknown') {
this.namespace = namespace
}
}
export default new Logger()
2、可擴展的單例模式
參考 Axios 的設計[1],因此我們還提供 ??create()?
? 方法,為創建新實例留一個入口方法。
/**
* 創建新的 Logger 實例
*
* @param namespace 命名空間
* @returns Logger
*/
public create(namespace = 'unknown') {
return new Logger(namespace);
}
當需要重新定義一個 logger 實例時,就可以參考如下方式:
import logger from '@/utils/logger'
const newLogger = logger.create('custom')
logger.info(newLogger === logger) // [unknown] false
3、定義“打印”類日志方法
需要區分 ??info?
??、??warn?
??、??error?
? 三種類型的日志,實現如下:
定義日志枚舉類型:
const enum LogLevel {
/** 普通日志 */
Log,
/** 警告日志 */
Warning,
/** 錯誤日志 */
Error,
}
const Styles = ['color: green;', 'color: orange;', 'color: red;']
const Methods = ['info', 'warn', 'error'] as const
private _log(level: LogLevel, args: unknown[]) {
if (!__DEV__) return
console[Methods[level]](`%c${this.namespace}`, Styles[level], ...args)
}
/**
* 打印輸出信息 ??
*
* @param args 任意參數
*/
public info(...args: unknown[]) {
this._log(LogLevel.Log, args)
return this
}
/**
* 打印輸出警告信息 ?
*
* @param args 任意參數
*/
public warn(...args: unknown[]) {
this._log(LogLevel.Warning, args)
return this
}
/**
* 打印輸出錯誤信息 ?
*
* @param args 任意參數
*/
public error(...args: unknown[]) {
this._log(LogLevel.Error, args)
return this
}
在 ??_log()?
?? 方法中,通過 ??__DEV__?
? 環境變量區分“生產”和“開發”:
if (!__DEV__) return
這種變量可以理解為“開關”:
生產環境則控制臺不輸出信息,在實際應用中,可以擴展“是否輸出信息”的變量,來針對性擴展,例如線上需要通過特定參數展示調試日志,用于線上定位問題,那么就可以綜合多個條件來決定是否輸出控制臺,畢竟編程最核心的問題是解決需求。
在開發模式下,針對不同的信息類型,會標注不同的顏色:
Chrome 瀏覽器下的效果
與此同時,在每個“輸出”方法中都返回了 ??this?
?(當前實例),因而便可以為鏈式調用方法提供了使用基礎。
4、支持修改 namespace
namespace 最重要的作用是:區分在不同組件或文件下的日志,便于問題定位排查。
由于 ??Logger?
?? 將所有的輸出集中到了統一文件,在 ??console.log()?
?? 中文件定位永遠是 ??Logger?
? 類定義實現所在文件,因此需要 namespace 來區分。
新增 ??setNamespace()?
? 方法:
/**
* 設置命名空間(日志前綴)
* @param namespace
*/
public setNamespace(namespace = '') {
this.namespace = `[${namespace}]`
return this
}
在 TypeScript 環境下,會提供代碼提示,例如某個文件下輸出錯誤信息的方式:
而 ??setNamespace()?
? 方法,并不是每次都需要調用的,只需在文件中調用一次即可。
5、埋點遠程上報
在一些關鍵時機,例如進入頁面、點擊“付費按鈕”等一些關鍵操作上,一般會加上一些上報到遠程,用于記錄用戶操作路徑,以此便于在出現問題后,復現 BUG 并“對癥下藥”。
而埋點上報一般有三類:代碼埋點、可視化埋點、無痕埋點。
我們這里通過給 Logger 增加遠程上報的方式就是代碼埋點。
一般情況下,埋點上報屬于“前端監控”方面,前端監控是一個獨立的管理系統,它的職能是負責前端項目的監控、異常報警等,因此通常會有用于項目集成的前端 SDK
有了 Logger 實例,我們可以在 Logger 中直接統一集成“前端監控 SDK”的主動上報方法即可!
在 Logger 類中新增三個方法:
- ?
?reportLog()?
?:上報日志。 - ?
?reportEvent()?
?:上報事件。 - ?
?reportException()?
?:上報異常。
/**
* 遠程上報
* TODO: 根據基建環境自定義擴展
*/
public reportLog() {
this.info() // 用于在本地輸出
}
public reportEvent() {
this.info()
}
public reportException() {
this.error()
}
至于為什么添加著兩個方法,實際是根據“前端監控 SDK”提供的 API 來決定
例如常見的 “Sentry - 應用監控錯誤溯源[2]” 平臺,針對主動上報,提供了三種方法,通常為了保持一致性,降低心智負擔,因此新增對應的三個上報方法。
具體的上報參數和邏輯,則需要大家根據自己的業務區擴展。
五、Logger 的可擴展性 ??
從上面 Logger 類的實現,可以發現一個明顯的問題,如果業務需要擴展功能,則需要修改 Logger 類內部的方法,Logger 類中的方法和邏輯,我們可以理解為是所有業務都通用的,業務定制化的功能應該通過額外擴展方式來完善。
那有沒有什么辦法,可以實現不修改方法,而擴展 Logger 的功能吶?
1、擴展方案
有幾個方案:
- 繼承 Logger 類擴展。
- 增加回調函數作為參數。
個人推薦第二個方案,但如果每一次調用,都按照如下方式:
logger.info('message', () => {})
但這種設計比較粗糙
2、攔截器
參考 Axios 的攔截器設計,也就是 AOP(面向切面編程模式)的設計思想,來擴展 ??_log()?
? 方法。
新增類型申明:
/**
* 日志的配置類型
*/
type LoggerConfigType = {
/** 命名空間 */
namespace?: string
}
/**
* 攔截器函數類型
*/
type InterceptorFuncType = (config: LoggerConfigType) => void
將 Logger 的配置集中的 ??config?
?? 私有變量中,并新增 ??addBeforeFunc()?
?? 和 ??addAfterFunc()?
? 兩個方法,用于新增自定義“攔截器”函數
其中一個細節是,日志打印之后的攔截器,按照FCLS(First Come Last Serve,先到后服務)的策略,和 Axios 的響應攔截器執行順序對齊,與此同時,攔截器函數中會注入當前 Logger 的 ??config?
? 配置。
通過簡單的“攔截器”,即可實現功能的擴展,這種方式的功能擴展不會影響到主體功能,后期的維護升級是無侵入性的,還算比較優雅的,是吧!
3、其他方案
這里還可以考慮更多設計,例如參考發布訂閱設計模式來改造,通過生命周期的關鍵點,被動觸發,主動通知并執行所有訂閱了對應消息的事件,可以參閱《聊一聊發布訂閱設計模式[3]》
也可以用插件模式方式來實現擴展,類似發布訂閱模式,給 ??_log()?
? 函數添加執行的鉤子函數??(回調函數),例如這種設計下,把“埋點上報”等功能拆分成插件,再實現一個簡單的事件隊列模型,集成一下子!
六、總結
至此,一個基本的日志工具就實現完成了,但并未完完全全遵守設計原則,這里在生產實踐中還需要封裝、抽離相應“職責”,增加可維護性。
在團隊中以此作為基礎結構,然后針對團隊、項目、業務的特點做適當的擴展,構建符合當前團隊特性的通用日志工具模塊,應該也不是什么難事!