輕松打造高效日志系統(tǒng)
作為開(kāi)發(fā)者,經(jīng)常需要在調(diào)試時(shí)查看檢查日志,缺乏日志或者不清楚如何通過(guò)日志分析問(wèn)題,就無(wú)法定位出錯(cuò)的代碼。
對(duì)于每天為成千上萬(wàn)甚至上百萬(wàn)用戶(hù)提供服務(wù)的系統(tǒng)來(lái)說(shuō),日志必不可少,因?yàn)椋?/p>
- 日志可以幫助我們找到影響最終用戶(hù)的錯(cuò)誤。
- 日志可以跟蹤系統(tǒng)的 "健康狀況",在系統(tǒng)出問(wèn)題之前察覺(jué)到某些 "異常跡象"。
- ……等等
由此可見(jiàn),在開(kāi)發(fā)或運(yùn)行系統(tǒng)時(shí),日志至關(guān)重要,因此,設(shè)計(jì)和實(shí)施完善的日志系統(tǒng)有助于簡(jiǎn)化監(jiān)控工作。
本文將分享我在設(shè)計(jì)和構(gòu)建日志系統(tǒng)方面的經(jīng)驗(yàn)和理解。希望通過(guò)這篇文章,你能:
- 了解在操作系統(tǒng)中記錄日志的重要性。
- 可以作為實(shí)施日志系統(tǒng)時(shí)的參考。
一、日志策略
下面列出了我們?cè)趯?shí)施日志系統(tǒng)之前應(yīng)該問(wèn)自己的問(wèn)題。
- Why(為什么):日志記錄的目的是什么?
- Who(誰(shuí)): 哪個(gè)模塊將生成日志?
- When(何時(shí)):何時(shí)輸出日志?
- Where(哪里):在哪里輸出日志(發(fā)送到 Slack 或 BigQuery 等)
- What(什么):日志能提供什么信息?
- How(如何): 如何輸出日志?
二、日志級(jí)別
了解日志的目的后,應(yīng)該對(duì)日志進(jìn)行分級(jí)
Log level | Concept | How to handle | Example |
FATAL | This Level hinder the operating of the system | Have to fix immediately | Can not connect to the DB |
ERROR | Unexpected errors occur | Should be fixed as soon as you can | Can not send the email |
WARN | Not an error, but are some problems like unexpected input or unexpected executing unexpected input or unexpected executing | Should be refactored regularly | Regularly delete data API |
INFO | Notification when starting or ending an executing or a transaction. | Maybe outputting another needed information | Do not need to fix Output the body of the request or response |
DEBUG | The information that relating to system status | Do not output in the production environment | Can be put inside a function |
TRACE | Information that is more detailed than DEBUG | Do not output in the production environment |
三、案例
定義日志級(jí)別后,必須明確要輸出的日志類(lèi)型。
本節(jié)將針對(duì)每種日志類(lèi)型回答以下六個(gè)問(wèn)題。
- Why(為什么)
- Who(誰(shuí))
- When(何時(shí))
- Where(哪里)
- What(什么)
- How(如何)
1. 系統(tǒng)日志(System Log)
(1) Why: 當(dāng)系統(tǒng)出現(xiàn)錯(cuò)誤時(shí),系統(tǒng)日志將用于調(diào)試。
(2) Who: 系統(tǒng)本身將輸出日志。
(3) When:出錯(cuò)時(shí)輸出日志。
(4) Where:
- FATAL / ERROR:通知開(kāi)發(fā)人員立即處理。
- WARN / INFO:在系統(tǒng)或日志管理工具中輸出。
- DEBUG / TRACE:輸出到預(yù)發(fā)環(huán)境中的 console.log。
(5) What:
- FATAL / ERROR:堆棧跟蹤。
- WARN / INFO / DEBUG/ TRACE:要通知的內(nèi)容。
(6) How:
- FATAL / ERROR:通過(guò)日志管理工具或 Slack、SMS......(推模式)輸出。
- WARN / INFO / DEBUG / TRACE:通過(guò)日志管理工具或系統(tǒng)內(nèi)部輸出(拉模式)。
2. 訪問(wèn)日志(Access Log)
- Why: 輸出日志以跟蹤發(fā)送和接收請(qǐng)求的過(guò)程。
- Who: 系統(tǒng)本身或基礎(chǔ)設(shè)施。
- When: 在發(fā)送或接收請(qǐng)求時(shí)輸出。
- Where: 在 INFO 級(jí)別和拉模式中。由于日志量可能很大,必須注意查找日志的速度。
- What: 輸出誰(shuí)、如何、何時(shí)進(jìn)入系統(tǒng)。
- How: 根據(jù)目的不同,可能會(huì)有一些差異。
3. 操作日志(Action Log)
- Why: 分析用戶(hù)操作,從而在此基礎(chǔ)上改進(jìn)服務(wù)。
- Who: 系統(tǒng)本身或外部工具。
- When: 某些操作發(fā)生時(shí)。
- Where: 日志分析工具(BigQuery 等)。
- What: 取決于目的。
- How: 根據(jù)目的不同,可能會(huì)有一些差異。
4. 認(rèn)證日志(Auth Log)
- Why: 跟蹤用戶(hù)驗(yàn)證的輸出。
- Who: 系統(tǒng)本身。
- When: 驗(yàn)證用戶(hù)。
- Where: 在 INFO 級(jí)別和拉模式中。
- What: 輸出認(rèn)證的時(shí)間、用戶(hù)、方式。
- How: 根據(jù)認(rèn)證方法不同,可能會(huì)有一些差異。
四、示例
概念就介紹到這里,下面來(lái)看一個(gè)示例項(xiàng)目。
有關(guān)代碼的更多詳情,請(qǐng)參閱Github[2]。
1. 選擇日志庫(kù)
我選擇 log4js[3] 庫(kù),原因很簡(jiǎn)單,因?yàn)?log4js 構(gòu)建日志級(jí)別的方式與我的想法一致。
2. 實(shí)施
步驟 1 - 定義日志類(lèi)
首先定義日志類(lèi):
class Logger {
public default: log4js.Logger;
public system: log4js.Logger;
public api: log4js.Logger;
public access_req: log4js.Logger;
public access_res: log4js.Logger;
public sql: log4js.Logger;
public auth: log4js.Logger;
public fatal: log4js.Logger;
public error: log4js.Logger;
public warn: log4js.Logger;
public info: log4js.Logger;
public debug: log4js.Logger;
public trace: log4js.Logger;
constructor() {
log4js.configure(loggerConfig);
this.system = log4js.getLogger('system');
this.api = log4js.getLogger('api');
this.access_req = log4js.getLogger('access_req');
this.access_res = log4js.getLogger('access_res');
this.sql = log4js.getLogger('sql');
this.auth = log4js.getLogger('auth');
this.fatal = log4js.getLogger('fatal');
this.fatal.level = log4js.levels.FATAL;
this.error = log4js.getLogger('error');
this.error.level = log4js.levels.ERROR;
this.warn = log4js.getLogger('warn');
this.warn.level = log4js.levels.WARN;
this.info = log4js.getLogger('info');
this.info.level = log4js.levels.INFO;
this.debug = log4js.getLogger('debug');
this.debug.level = log4js.levels.DEBUG;
this.trace = log4js.getLogger('trace');
this.trace.level = log4js.levels.TRACE;
}
}
在 Logger 類(lèi)中定義了日志級(jí)別:
- fatal
- error
- warn
- info
- debug
- trace
基于此,我又定義了日志類(lèi)型:
- system
- api
- access_req
- access_res
- sql
- auth
第 2 步 - 將 Logger 應(yīng)用到項(xiàng)目中
將 Logger 類(lèi)應(yīng)用到由 NestJS[4] 框架實(shí)現(xiàn)的項(xiàng)目中。
通過(guò) NestJS 的 Interceptor(攔截器[5])功能,將日志類(lèi)注入到項(xiàng)目中。
選擇 Interceptor 的原因是 NestJS 攔截器不僅能封裝請(qǐng)求流,還能封裝從 API 輸入和輸出的響應(yīng)流,因此使用攔截器是捕獲請(qǐng)求日志和響應(yīng)日志的最簡(jiǎn)單方法。我是這樣定義 LoggerInterceptor 類(lèi)的:
export class LoggerInterceptor implements NestInterceptor {
intercept(
context: ExecutionContext,
next: CallHandler<any>
): Observable<any> | Promise<Observable<any>> {
// intercept() method will "wrap" request/ response stream
/*
* Get request object from context
* After that, pass request object to "requestLogger" function
* to output the log
*/
const request = context.switchToHttp().getRequest();
requestLogger(request);
/*
* Get response object from context
* After that pass response object to "responseLogger" & "responseErrorLogger" functions for ouputting the log or
* error log
*/
const response = context.switchToHttp().getResponse();
return next.handle().pipe(
// 200 - Success Response
map((data) => {
responseLogger({ requestId: request._id, response, data });
}),
// 4xx, 5xx - Error Response
tap(null, (exception: HttpException | Error) => {
try {
responseErrorLogger({ requestId: request._id, exception });
} catch (e) {
logger.access_res.error(e);
}
})
);
}
}
定義了三種方法:
- requestLogger: 用于記錄請(qǐng)求信息。
- responseLogger: 用于記錄響應(yīng)信息。
- responseErrorLogger: 用于記錄錯(cuò)誤信息。
像這樣:
const MaskField = {
Email: 'email',
Password: 'password',
} asconst;
type MaskField = (typeof MaskField)[keyof typeof MaskField];
const _maskFields = (object: FixType, fields: MaskField[]): FixType => {
const maskOptions = {
maskWith: '*',
unmaskedStartCharacters: 0,
unmaskedEndCharacters: 0,
};
for (let i = 0; i < fields.length; i++) {
switch (fields[i]) {
case MaskField.Email: {
object[MaskField.Email] = maskData.maskEmail2(
object[MaskField.Email],
maskOptions
);
}
case MaskField.Password: {
object[MaskField.Password] = maskData.maskPassword(
object[MaskField.Password],
maskOptions
);
}
}
}
return object;
};
exportconst requestLogger = (request: Request) => {
const { ip, originalUrl, method, params, query, body, headers } = request;
// logTemplate includes: now(time), ip, http_method, url, request_object
const logTemplate = '%s %s %s %s %s';
const now = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');
const logContent = util.formatWithOptions(
{ colors: true },
logTemplate,
now,
ip,
method,
originalUrl,
JSON.stringify({
method,
url: originalUrl,
userAgent: headers['user-agent'],
body: _maskFields(body, [MaskField.Email, MaskField.Password]),
params,
query,
})
);
// Using access_req logger object have been defined before.
logger.access_req.info(logContent);
};
// Ouptput success response log
exportconst responseLogger = (input: {
requestId: number;
response: Response;
data: any;
}) => {
const { requestId, response, data } = input;
const log: ResponseLog = {
requestId,
statusCode: response.statusCode,
data,
};
// Using access_res logger object have been defined before.
logger.access_res.info(JSON.stringify(log));
};
// Ouptput error response log
exportconst responseErrorLogger = (input: {
requestId: number;
exception: HttpException | Error;
}) => {
const { requestId, exception } = input;
const log: ResponseLog = {
requestId,
statusCode:
exception instanceof HttpException ? exception.getStatus() : null,
message: exception?.stack || exception?.message,
};
// Using access_res logger object have been defined before.
logger.access_res.info(JSON.stringify(log));
logger.access_res.error(exception);
};
定義完 LoggerInterceptor 后,將此攔截器應(yīng)用到應(yīng)用程序中:
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggerInterceptor());
在 NestJS 應(yīng)用程序中應(yīng)用自定義攔截器并不難,因?yàn)檫@是 NestJS 的內(nèi)置功能。
對(duì)于 fatal 和 debug 日志,我將在用例層或基礎(chǔ)架構(gòu)層中使用,以達(dá)到以下目的:
- 通知無(wú)法連接數(shù)據(jù)庫(kù)等致命錯(cuò)誤。
- 當(dāng)用戶(hù)遇到問(wèn)題時(shí)進(jìn)行調(diào)試。
只要這樣做:
logger.fatal.error('Error message');
可以將 fatal 日志輸出到控制臺(tái)或 Slack 等通知管道......
結(jié)果如下:
首先是訪問(wèn)請(qǐng)求日志和響應(yīng)日志(當(dāng)沒(méi)有發(fā)生錯(cuò)誤時(shí))。
可以看到,與請(qǐng)求相關(guān)的信息,如 method、body 等都已清晰顯示。
如果出錯(cuò):
同時(shí)顯示錯(cuò)誤類(lèi)型和錯(cuò)誤信息。
fatal 日志會(huì)是這樣的:
同樣會(huì)輸出錯(cuò)誤信息和錯(cuò)誤類(lèi)型。
五、結(jié)論
本文分享了如何設(shè)計(jì)和實(shí)施一個(gè)基本的日志系統(tǒng)。
通過(guò)簡(jiǎn)單的示例,希望你能理解建立日志系統(tǒng)的重要性和必要性,這將有助于系統(tǒng)的運(yùn)行和調(diào)試。
參考資料:
- [1] Design And Building A Logging System: https://levelup.gitconnected.com/design-and-building-a-logging-system-fd5dcad110ed
- [2] NewAnigram-BE-DDD: https://github.com/tuananhhedspibk/NewAnigram-BE-DDD
- [3] log4js: https://github.com/log4js-node/log4js-node
- [4] NestJS: https://docs.nestjs.com
- [5] NestJS Interceptor: https://docs.nestjs.com/interceptors