如何在 Node.js 中正確的使用日志對象
日志,是開發(fā)者排查問題的非常重要的手段,有時候甚至是唯一的,所以如何合理并正確的打印日志,成了開發(fā)時的重中之重。
Node.js 中打日志的方式,一般有幾種:
- 主動展示
- 被動記錄
這兩種方式都可以由不同的模塊來實現(xiàn),我們接下去就來看看怎么選擇。
**常見的主動展示
**
一般來說,主動一般發(fā)生在開發(fā)期,不確定狀態(tài)的時候,我們會打印一些消息,比如常見的。
- console.log('hello world');
這就是最簡單的主動打印的例子。
但是大多數(shù)場景下,我們都不會使用 console 來進行打印,畢竟除了內(nèi)置之外,在性能和功能方面沒有特別的優(yōu)勢。
除了大眾都知道的 console 模塊,在 Node.js 領(lǐng)域還有一個較為知名的 debug 模塊。
可以根據(jù)命名空間打印出不同顏色的輸出,但是最最有用的,則是他的環(huán)境變量控制能力。
默認情況下(不包含任何環(huán)境變量),控制臺不會有任何輸出,而當 DEBUG 環(huán)境變量被賦值的時候,對應的命名空間的輸出才會被打印到 stdout。
- $ DEBUG=* node app.js
由于 debug 模塊由 TJ 出品,并且在非常早的時候就投入,使用過于廣泛,至今仍有非常多的模塊使用了它。
Node.js 官方一直希望能夠內(nèi)置一個 debug 模塊。從 v0.11.3 開始,終于加上了一個 util.debuglog 方法。
它的功能和 debug 模塊類似,同時是內(nèi)置的模塊,所以逐步也有一些模塊開始過渡到它。
- const util = require('util');
- const debuglog = util.debuglog('foo');
- debuglog('hello from foo [%d]', 123);
它的開關(guān)也類似,使用的是 NODE_DEBUG 環(huán)境變量,應該是特意和 debug 模塊做了區(qū)分。
- $ NODE_DEBUG=foo node app.js
被動記錄的方式
除了上面提到的類 console 等方式,我們常見的就是各種日志庫默認記錄的日志,由于這些日志平時只是默默的記錄,并不會過多關(guān)注,只會在特殊需要的時候(比如差錯,定位,計算時)才會查看,所以我們歸類為 “被動的方式”。
大多的三方庫都有類似的功能,比如 log4j,winston,pino 等等。
這些庫的核心功能一般是:
- 將日志輸出到不同的渠道(比如控制臺、文本文件)
- 日志格式的自定義(文本或者 JSON)
- 日志的輸出等級(warn,debug,error)
- 其他的一些能力,比如切割和文件輪轉(zhuǎn),壓縮等等
這些庫用起來一般就比較簡單,獲取實例,調(diào)用方法輸出即可。
- logger.info('hello world');
注意,這里我們會觀察到輸出有一些不一樣的地方。
- 2021-07-22 14:50:59,388 INFO 7739 [xxx] hello world
整個日志是安裝上面類似標準的結(jié)構(gòu)來進行輸出的,計算是 Error,也是相同的類似格式,那么這個結(jié)構(gòu)包含了哪幾部分東西呢?
日志格式
其實整個日志格式追溯,可以到很久以前,不管是 JAVA 默認的 Simple Logger 結(jié)構(gòu)還是類似 nginx 等反向代理服務器的日志,都會包含一些固定的字段,這些固定的字段長久以來形成了一種輸出約定,將這些字段組合起來,形成了當今的日志格式。
當前的日志格式一般會包括幾個部分。
- 時間戳
- 日志等級
- 進程id(node)
- 日志的標簽(label,from xxx class)
- 消息體(字符串或者 error stack)
除此之外,可能還有一些自定義的內(nèi)容,比如執(zhí)行消耗的時間,用戶 id,文本長度等等內(nèi)容。
在文本結(jié)構(gòu)的輸出中,這些字段將被空格(space)分隔,以換行符作為結(jié)尾(\n),這樣可以方便外部的日志采集系統(tǒng)采集,比如阿里云的 SLS 等等。
每個公司會有自己的日志采集和輸出規(guī)范,所以一般常見的庫都會支持自定義的日志格式,但是不管如何變化,基礎(chǔ)的字段(上述)都還會存在。
隨著系統(tǒng)的迭代,先進使用 JSON 格式來記錄日志的方式也逐步出現(xiàn),以 Logstash 為首的一些數(shù)據(jù)(日志)采集分析一體的工具,也逐步的成熟,對結(jié)構(gòu)化的數(shù)據(jù)支持的也很好,所以現(xiàn)在常見的庫也會同步支持 JSON 格式輸出。
正確的打日志
在了解了基本的日志庫和體系之后,我們來具體看一看真正打日志的問題。
比如一個簡單調(diào)用遠端服務:
- async invokeRemoteAPI() {
- const result = await remoteService.got();
- return {
- result
- };
- }
一般,我們會有意識的加上錯誤處理。
- async invokeRemoteAPI() {
- try {
- const result = await remoteService.got();
- } catch(err) {
- logger.error('got a error, err=', err);
- throw err;
- }
- return {
- result
- };
- }
按照上面的標準格式,這個 logger 還需要其他的一些額外信息,比如:
- async invokeRemoteAPI() {
- const pid = process.pid;
- const startTime = Date.now();
- try {
- const result = await remoteService.got();
- } catch(err) {
- const endTime = Date.now();
- logger.error('pid=%s, rt=%s, got a error, err=', pid, Date.now() - startTime, err);
- throw err;
- }
- return {
- result
- };
- }
如果每個代碼都這么寫,就會變得無比冗余,所以,我們會提前將日志的輸出格式定義完畢,這樣,在實際輸出的時候就可以簡化,比如:
- const logger = new CustomLogger({
- format: '${timestamp} ${level} ' + process.pid + ${rt}'
- });
- async invokeRemoteAPI() {
- const startTime = Date.now();
- try {
- const result = await remoteService.got();
- } catch(err) {
- const endTime = Date.now();
- logger.error('got a error, err=', err, {
- rt: Date.now() - startTime
- });
- throw err;
- }
- return {
- result
- };
- }
所以在特定場景下,如果有固定的日志字段,在日志庫允許自定義的情況下,可以先定義好固定的日志格式。
上下文日志
除了最簡單的通用日志輸出之外,還有一種相對復雜的日志,我們稱之為和上下文(請求)綁定的日志,這類日志會輸出上下文相關(guān)聯(lián)的數(shù)據(jù),比如之前示例中的響應時間,用戶請求的 ip,請求的路由,甚至是鏈路的唯一 ID 等等。
比如:
- 2021-07-22 14:50:59,388 INFO 7739 [-/127.0.0.1/-/0ms GET /] hello world
這種情況下,再用普通日志的方式加入?yún)?shù)就不合適了。
當然,有些同學會說,我們直接定義一個新的,比如:
- class CustomCtxLogger extends CustomLogger {
- constructor(ctx, format) {
- this.ctx = ctx;
- this.format = format;
- }
- error(...args) {
- //xxx
- }
- info(...args) {
- //xxx
- }
- }
這樣的做法,每次都會讓基類做初始化,會影響部分性能。我們使用另一種方式來減少性能影響,代理傳統(tǒng)日志。
我們來看看最簡單的實現(xiàn)方式,以 koa 為例。
- // 普通日志
- const logger = new CustomLogger();
- class CtxLogger {
- constructor(ctx, logger) {
- this.ctx = ctx;
- this.logger = logger;
- }
- format() {
- return '${timestamp} ${level} ' + process.pid + '[${ctx.refer} ${ctx.rt}]'
- }
- }
- app.use(async (ctx, next) => {
- // 代理原始日志
- const ctxLogger = new CtxLogger(ctx, logger);
- ctx.logger = ctxLogger;
- await next();
- });
類似這種通過代理原始日志的方式,即減少了每次初始化新日志時的性能問題,又解決了 ctx 上字段透傳的問題。
這也是常見的上下文日志的實踐。
簡單總結(jié)一下
我們了解了常用的日志庫以及和日志打印的關(guān)系,也簡單的實現(xiàn)了日志庫以及上下文日志的實現(xiàn),是不是現(xiàn)在對日志打印了有了一個基本的了解?
這樣一套下來,相信你對 Node.js 打印日志的方式更加的了解,也在排錯時游刃有余了。