上一篇介紹瞭如何使用 JWT 進行單點登陸,接下來,要完善一下後端項目的一些基礎功能。git
首先,一個良好的服務端,應該有較完善的日誌收集功能,這樣才能在生產環境發生異常時,可以從日誌中覆盤,找出 Bug 所在。程序員
其次,要針對項目中拋出的異常進行歸類,並將信息反映在接口或日誌中。github
最後,請求接口的參數也應該被記錄,以便統計分析(主要用於大數據和惡意攻擊分析)。typescript
GitHub 項目地址,歡迎各位大佬 Star。數據庫
這裏使用的是 log4js
,前身是 log4j
,若是有寫過 Java 的大佬應該不會陌生。express
已經有大佬總結了 log4js 的用法,就不在贅述了:json
《Node.js 之 log4js 徹底講解》bootstrap
先安裝依賴包後端
$ yarn add log4js stacktrace-js -S
複製代碼
在 config 目錄下新建一個文件 log4js.ts
,用於編寫配置文件:bash
// config/log4js.ts
import * as path from 'path';
const baseLogPath = path.resolve(__dirname, '../../logs'); // 日誌要寫入哪一個目錄
const log4jsConfig = {
appenders: {
console: {
type: 'console', // 會打印到控制檯
},
access: {
type: 'dateFile', // 會寫入文件,並按照日期分類
filename: `${baseLogPath}/access/access.log`, // 日誌文件名,會命名爲:access.20200320.log
alwaysIncludePattern: true,
pattern: 'yyyyMMdd',
daysToKeep: 60,
numBackups: 3,
category: 'http',
keepFileExt: true, // 是否保留文件後綴
},
app: {
type: 'dateFile',
filename: `${baseLogPath}/app-out/app.log`,
alwaysIncludePattern: true,
layout: {
type: 'pattern',
pattern: '{"date":"%d","level":"%p","category":"%c","host":"%h","pid":"%z","data":\'%m\'}',
},
// 日誌文件按日期(天)切割
pattern: 'yyyyMMdd',
daysToKeep: 60,
// maxLogSize: 10485760,
numBackups: 3,
keepFileExt: true,
},
errorFile: {
type: 'dateFile',
filename: `${baseLogPath}/errors/error.log`,
alwaysIncludePattern: true,
layout: {
type: 'pattern',
pattern: '{"date":"%d","level":"%p","category":"%c","host":"%h","pid":"%z","data":\'%m\'}',
},
// 日誌文件按日期(天)切割
pattern: 'yyyyMMdd',
daysToKeep: 60,
// maxLogSize: 10485760,
numBackups: 3,
keepFileExt: true,
},
errors: {
type: 'logLevelFilter',
level: 'ERROR',
appender: 'errorFile',
},
},
categories: {
default: {
appenders: ['console', 'app', 'errors'],
level: 'DEBUG',
},
info: { appenders: ['console', 'app', 'errors'], level: 'info' },
access: { appenders: ['console', 'app', 'errors'], level: 'info' },
http: { appenders: ['access'], level: 'DEBUG' },
},
pm2: true, // 使用 pm2 來管理項目時,打開
pm2InstanceVar: 'INSTANCE_ID', // 會根據 pm2 分配的 id 進行區分,以避免各進程在寫日誌時形成衝突
};
export default log4jsConfig;
複製代碼
上面貼出了個人配置,並標註了一些簡單的註釋,請配合 《Node.js 之 log4js 徹底講解》 一塊兒食用。
有了配置,就能夠着手寫 log4js 的實例以及一些工具函數了。
在 src/utils
下新建 log4js.ts
:
// src/utils/log4js.ts
import * as Path from 'path';
import * as Log4js from 'log4js';
import * as Util from 'util';
import * as Moment from 'moment'; // 處理時間的工具
import * as StackTrace from 'stacktrace-js';
import Chalk from 'chalk';
import config from '../../config/log4js';
// 日誌級別
export enum LoggerLevel {
ALL = 'ALL',
MARK = 'MARK',
TRACE = 'TRACE',
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
FATAL = 'FATAL',
OFF = 'OFF',
}
// 內容跟蹤類
export class ContextTrace {
constructor( public readonly context: string, public readonly path?: string, public readonly lineNumber?: number, public readonly columnNumber?: number, ) {}
}
Log4js.addLayout('Awesome-nest', (logConfig: any) => {
return (logEvent: Log4js.LoggingEvent): string => {
let moduleName: string = '';
let position: string = '';
// 日誌組裝
const messageList: string[] = [];
logEvent.data.forEach((value: any) => {
if (value instanceof ContextTrace) {
moduleName = value.context;
// 顯示觸發日誌的座標(行,列)
if (value.lineNumber && value.columnNumber) {
position = `${value.lineNumber}, ${value.columnNumber}`;
}
return;
}
if (typeof value !== 'string') {
value = Util.inspect(value, false, 3, true);
}
messageList.push(value);
});
// 日誌組成部分
const messageOutput: string = messageList.join(' ');
const positionOutput: string = position ? ` [${position}]` : '';
const typeOutput: string = `[${logConfig.type}] ${logEvent.pid.toString()} - `;
const dateOutput: string = `${Moment(logEvent.startTime).format('YYYY-MM-DD HH:mm:ss')}`;
const moduleOutput: string = moduleName ? `[${moduleName}] ` : '[LoggerService] ';
let levelOutput: string = `[${logEvent.level}] ${messageOutput}`;
// 根據日誌級別,用不一樣顏色區分
switch (logEvent.level.toString()) {
case LoggerLevel.DEBUG:
levelOutput = Chalk.green(levelOutput);
break;
case LoggerLevel.INFO:
levelOutput = Chalk.cyan(levelOutput);
break;
case LoggerLevel.WARN:
levelOutput = Chalk.yellow(levelOutput);
break;
case LoggerLevel.ERROR:
levelOutput = Chalk.red(levelOutput);
break;
case LoggerLevel.FATAL:
levelOutput = Chalk.hex('#DD4C35')(levelOutput);
break;
default:
levelOutput = Chalk.grey(levelOutput);
break;
}
return `${Chalk.green(typeOutput)}${dateOutput} ${Chalk.yellow(moduleOutput)}${levelOutput}${positionOutput}`;
};
});
// 注入配置
Log4js.configure(config);
// 實例化
const logger = Log4js.getLogger();
logger.level = LoggerLevel.TRACE;
export class Logger {
static trace(...args) {
logger.trace(Logger.getStackTrace(), ...args);
}
static debug(...args) {
logger.debug(Logger.getStackTrace(), ...args);
}
static log(...args) {
logger.info(Logger.getStackTrace(), ...args);
}
static info(...args) {
logger.info(Logger.getStackTrace(), ...args);
}
static warn(...args) {
logger.warn(Logger.getStackTrace(), ...args);
}
static warning(...args) {
logger.warn(Logger.getStackTrace(), ...args);
}
static error(...args) {
logger.error(Logger.getStackTrace(), ...args);
}
static fatal(...args) {
logger.fatal(Logger.getStackTrace(), ...args);
}
static access(...args) {
const loggerCustom = Log4js.getLogger('http');
loggerCustom.info(Logger.getStackTrace(), ...args);
}
// 日誌追蹤,能夠追溯到哪一個文件、第幾行第幾列
static getStackTrace(deep: number = 2): string {
const stackList: StackTrace.StackFrame[] = StackTrace.getSync();
const stackInfo: StackTrace.StackFrame = stackList[deep];
const lineNumber: number = stackInfo.lineNumber;
const columnNumber: number = stackInfo.columnNumber;
const fileName: string = stackInfo.fileName;
const basename: string = Path.basename(fileName);
return `${basename}(line: ${lineNumber}, column: ${columnNumber}): \n`;
}
}
複製代碼
上面貼出了我實例化 log4js 的過程,主要是處理日誌的組成部分(包含了時間、類型,調用文件以及調用的座標),還能夠根據日誌的不一樣級別,在控制檯中用不一樣的顏色顯示。
這個文件,不但能夠單獨調用,也能夠作成中間件使用。
咱們但願每次用戶請求接口的時候,自動記錄請求的路由、IP、參數等信息,若是每一個路由都寫,那就太傻了,因此須要藉助中間件來實現。
Nest 中間件實際上等價於 express 中間件。
中間件函數能夠執行如下任務:
next()
將控制傳遞給下一個中間件函數。不然,請求將被掛起;執行下列命令,建立中間件文件:
$ nest g middleware logger middleware
複製代碼
而後,src
目錄下,就多出了一個 middleware
的文件夾,裏面的 logger.middleware.ts
就是接下來的主角,Nest 預設的中間件模板長這樣:
// src/middleware/logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
next();
}
}
複製代碼
這裏只是實現了 NestMiddleware
接口,它接收 3 個參數:
接下來,咱們將日誌功能寫入中間件:
// src/middleware/logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
import { Logger } from '../utils/log4js';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
const code = res.statusCode; // 響應狀態碼
next();
// 組裝日誌信息
const logFormat = `Method: ${req.method} \n Request original url: ${req.originalUrl} \n IP: ${req.ip} \n Status code: ${code} \n`;
// 根據狀態碼,進行日誌類型區分
if (code >= 500) {
Logger.error(logFormat);
} else if (code >= 400) {
Logger.warn(logFormat);
} else {
Logger.access(logFormat);
Logger.log(logFormat);
}
}
}
複製代碼
同時,Nest 也支持【函數式中間件】,咱們將上面的功能用函數式實現一下:
// src/middleware/logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
import { Logger } from '../utils/log4js';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
...
}
// 函數式中間件
export function logger(req: Request, res: Response, next: () => any) {
const code = res.statusCode; // 響應狀態碼
next();
// 組裝日誌信息
const logFormat = ` >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Request original url: ${req.originalUrl}
Method: ${req.method}
IP: ${req.ip}
Status code: ${code}
Parmas: ${JSON.stringify(req.params)}
Query: ${JSON.stringify(req.query)}
Body: ${JSON.stringify(req.body)} \n >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
`;
// 根據狀態碼,進行日誌類型區分
if (code >= 500) {
Logger.error(logFormat);
} else if (code >= 400) {
Logger.warn(logFormat);
} else {
Logger.access(logFormat);
Logger.log(logFormat);
}
}
複製代碼
上面的日誌格式進行了一些改動,主要是爲了方便查看。
至於使用 Nest 提供的仍是函數式中間件,能夠視需求決定。固然,Nest 原生的中間件高級玩法會更多一些。
作好中間件後,咱們只須要將中間件引入 main.ts 中就行了:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { logger } from './middleware/logger.middleware';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 監聽全部的請求路由,並打印日誌
app.use(logger);
app.setGlobalPrefix('nest-zero-to-one');
await app.listen(3000);
}
bootstrap();
複製代碼
保存代碼後,就會發現,項目目錄下就多了幾個文件:
這就是以前 config/log4js.ts
中配置的成果
接下來,咱們試着請求一下登陸接口:
發現雖然是打印了,可是沒有請求參數信息。
因而,咱們還要作一部操做,將請求參數處理一下:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { logger } from './middleware/logger.middleware';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(express.json()); // For parsing application/json
app.use(express.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
// 監聽全部的請求路由,並打印日誌
app.use(logger);
app.setGlobalPrefix('nest-zero-to-one');
await app.listen(3000);
}
bootstrap();
複製代碼
再請求一次,發現參數已經出來了:
上面的打印信息,IP 爲
::1
是由於我全部的東西都跑在本地,正常狀況下,會打印對方的 IP 的。
再去看看 logs/
文件夾下:
上圖能夠看到日誌已經寫入文件了。
前面已經示範了怎麼打印入參,可是光有入參信息,沒有出參信息確定不行的,否則怎麼定位 Bug 呢。
Nest 提供了一種叫作 Interceptors
(攔截器) 的東東,你能夠理解爲關卡,除非遇到關羽這樣的能夠過五關斬六將,不然全部的參數都會通過這裏進行處理,正所謂雁過拔毛。
詳細的使用方法會在後面的教程進行講解,這裏只是先大體介紹一下怎麼使用:
執行下列指令,建立 transform
文件
$ nest g interceptor transform interceptor
複製代碼
而後編寫出參打印邏輯,intercept 接受兩個參數,當前的上下文和傳遞函數,這裏還使用了 pipe
(管道),用於傳遞響應數據:
// src/interceptor/transform.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Logger } from '../utils/log4js';
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.getArgByIndex(1).req;
return next.handle().pipe(
map(data => {
const logFormat = ` <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< Request original url: ${req.originalUrl} Method: ${req.method} IP: ${req.ip} User: ${JSON.stringify(req.user)} Response data:\n ${JSON.stringify(data.data)} <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<`;
Logger.info(logFormat);
Logger.access(logFormat);
return data;
}),
);
}
}
複製代碼
保存文件,而後在 main.ts
中引入,使用 useGlobalInterceptors
調用全局攔截器:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { logger } from './middleware/logger.middleware';
import { TransformInterceptor } from './interceptor/transform.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(express.json()); // For parsing application/json
app.use(express.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
// 監聽全部的請求路由,並打印日誌
app.use(logger);
// 使用全局攔截器打印出參
app.useGlobalInterceptors(new TransformInterceptor());
app.setGlobalPrefix('nest-zero-to-one');
await app.listen(3000);
}
bootstrap();
複製代碼
咱們再試一次登陸接口:
能夠看到,出參的日誌已經出來了,User 爲 undefiend
是由於登陸接口沒有使用 JWT 守衛,若路由加了 @UseGuards(AuthGuard('jwt'))
,則會把用戶信息綁定在 req 上,具體操做可回顧上一篇教程。
在開發的過程當中,不免會寫出各式各樣的「八阿哥」,否則程序員就要失業了。一個富有愛心的程序員應該在輸出代碼的同時創造出3個崗位(手動狗頭)。
迴歸正題,光有入參出參日誌還不夠,異常的捕獲和拋出也須要記錄。
接下來,咱們先故意寫錯語法,看看控制檯打印什麼:
如圖,只會記錄入參以及控制檯默認的報錯信息,而默認的報錯信息,是不會寫入日誌文件的。
再看看請求的返回數據:
如圖,這裏只會看到 "Internal server error",其餘什麼信息都沒有。
這樣就會有隱患了,用戶在使用過程當中報錯了,可是日誌沒有記錄報錯的緣由,就沒法統計影響範圍,若是是簡單的報錯還好,若是涉及數據庫各類事務或者併發問題,就很難追蹤定位了,總不能一直看着控制檯吧。
所以,咱們須要捕獲代碼中未捕獲的異常,並記錄日誌到 logs/errors
裏,方便登陸線上服務器,對錯誤日誌進行篩選、排查。
Nest 不光提供了攔截器,也提供了過濾器,就代碼結構而言,和攔截器很類似。
內置的異常層負責處理整個應用程序中的全部拋出的異常。當捕獲到未處理的異常時,最終用戶將收到友好的響應。
咱們先新建一個 http-exception.filter
試試:
$ nest g filter http-exception filter
複製代碼
打開文件,默認代碼長這樣:
// src/filter/http-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
@Catch()
export class HttpExceptionFilter<T> implements ExceptionFilter {
catch(exception: T, host: ArgumentsHost) {}
}
複製代碼
能夠看到,和攔截器的結構大同小異,也是接收 2 個參數,只不過用了 @Catch()
來修飾。
Nest提供了一個內置的 HttpException 類,它從 @nestjs/common 包中導入。對於典型的基於 HTTP REST/GraphQL API 的應用程序,最佳實踐是在發生某些錯誤狀況時發送標準 HTTP 響應對象。
HttpException 構造函數有兩個必要的參數來決定響應:
默認狀況下,JSON 響應主體包含兩個屬性:
咱們先來編寫捕獲打印的邏輯:
// src/filter/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
import { Logger } from '../utils/log4js';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const logFormat = ` <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< Request original url: ${request.originalUrl} Method: ${request.method} IP: ${request.ip} Status code: ${status} Response: ${exception.toString()} \n <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< `;
Logger.info(logFormat);
response.status(status).json({
statusCode: status,
error: exception.message,
msg: `${status >= 500 ? 'Service Error' : 'Client Error'}`,
});
}
}
複製代碼
上面代碼表示如何捕獲 HTTP 異常,並組裝成更友好的信息返回給用戶。
咱們測試一下,先把註冊接口的 Token 去掉,請求:
上圖是尚未加過濾器的請求結果。
咱們在 main.ts 中引入 http-exception
:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { logger } from './middleware/logger.middleware';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { HttpExceptionFilter } from './filter/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(express.json()); // For parsing application/json
app.use(express.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
// 監聽全部的請求路由,並打印日誌
app.use(logger);
// 使用攔截器打印出參
app.useGlobalInterceptors(new TransformInterceptor());
app.setGlobalPrefix('nest-zero-to-one');
// 過濾處理 HTTP 異常
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
複製代碼
使用全局過濾器 useGlobalFilters
調用 http-exception
,再請求:
再看控制檯打印:
如此一來,就能夠看到未帶 Token 請求的結果了,具體信息的組裝,能夠根據我的喜愛進行修改。
爲了減小樣板代碼,Nest 提供了一系列繼承自核心異常 HttpException 的可用異常。全部這些均可以在 @nestjs/common包中找到:
結合這些,能夠自定義拋出的異常類型,好比後面的教程說到權限管理的時候,就能夠拋出 ForbiddenException
異常了。
除了 HTTP 相關的異常,還能夠捕獲項目中出現的全部異常,咱們新建 any-exception.filter
:
$ nest g filter any-exception filter
複製代碼
同樣的套路:
// src/filter/any-exception.filter.ts
/** * 捕獲全部異常 */
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Logger } from '../utils/log4js';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const logFormat = ` <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< Request original url: ${request.originalUrl} Method: ${request.method} IP: ${request.ip} Status code: ${status} Response: ${exception} \n <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< `;
Logger.error(logFormat);
response.status(status).json({
statusCode: status,
msg: `Service Error: ${exception}`,
});
}
}
複製代碼
和 http-exception
的惟一區別就是 exception
的類型是 unknown
咱們將 any-exception 引入 main.ts:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { logger } from './middleware/logger.middleware';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { HttpExceptionFilter } from './filter/http-exception.filter';
import { AllExceptionsFilter } from './filter/any-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(express.json()); // For parsing application/json
app.use(express.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
// 監聽全部的請求路由,並打印日誌
app.use(logger);
// 使用攔截器打印出參
app.useGlobalInterceptors(new TransformInterceptor());
app.setGlobalPrefix('nest-zero-to-one');
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
複製代碼
注意:AllExceptionsFilter 要在 HttpExceptionFilter 的上面,不然 HttpExceptionFilter 就不生效了,全被 AllExceptionsFilter 捕獲了。
而後,咱們帶上 Token (爲了跳過 401 報錯)再請求一次:
再看看控制檯:
已經有了明顯的區別,再看看 errors.log,也寫進了日誌中:
如此一來,代碼中未捕獲的錯誤也能從日誌中查到了。
本篇介紹瞭如何使用 log4js 來管理日誌,製做中間件和攔截器對入參出參進行記錄,以及使用過濾器對異常進行處理。
文中日誌的打印格式能夠按照本身喜愛進行排版,不必定侷限於此。
良好的日誌管理能幫咱們快速排查 Bug,減小加班,不作資本家的奴隸,把有限的精力投入到無限的可能上。
下一篇將介紹如何使用 DTO 對參數進行驗證,解脫各類 if - else。
參考資料:
`