前言
日常开发中我们都需要打印日志,例如 debug 日志可以辅助我们分析程序运行过程,error 日志可以帮助我们定位错误发生的原因,同时我们还可以基于日志做数据分析,例如我们可以对错误日志进行监控,当日志产生数量大于我们事先设定的阈值时,发出一条告警,以便第一时间进行跟进修复。因此,在自己的项目中加入一些日志打点代码是很有必要的。本篇文章将从0到1带你一步步实现一个生产环境可使用的 node 端的日志打印库,期间不仅仅会展示代码,还会给您讲解为什么要这样设计,让您方便将这样的设计思想带入到自己的项目中去。
功能拆解
如果要用一句话来描述日志库的核心功能,我想是“将各种不同类型的日志输出到各种指定的容器“,这里有两个关键核心 不同类型日志 与 各种容器,下面将分别讲讲这两个核心。
不同类型日志
日志是有区分的,不同等级的日志所传递的含义是不一样的,如下:
- Error: 系统发生了错误事件,但仍然不影响系统的继续运行。系统需要将错误或异常细节记录 Error 日志中,方便后续人工回溯解决。错误日志后期将会发送告警,将会落实到人排查解决
- Warn: 系统在业务处理时触发了异常流程,但系统可恢复到正常态,下一次业务可以正常执行。如程序调用了一个旧版本的接口,可选参数不合法,非业务预期的状态但仍可继续处理等
- Info: 记录系统关键信息,旨在保留系统正常工作期间关键运行指标,开发人员可以将初始化系统配置、业务状态变化信息,或者用户业务流程中的核心处理记录到 info 日志中,方便日常运维工作以及错误回溯时上下文场景复现
- Debug: 可以将各类详细信息记录到 debug 里,起到调试的作用,包括参数信息,调试细节信息,返回值信息等等
- Trace: 更详细的跟踪信息
上述日志级别从高到低排列,是开发中最常用的五种。生产系统一般只打印 Info 级别以上的日志,对于 Debug 级别的日志,只在测试环境中打印。打印错误日志时,需要区分是业务异常(如:用户名不能为空)还是系统异常(如:调用 会员核心异常),业务异常使用 Warn 级别记录,系统异常使用 Error 记录。关于更多的日志等级定义,可以查看Wiki
不同容器
就像上面提到的一样的,不同等级的日志输出的目的地是不一样的,可以是输出到终端,也可以是输出到文件里面,一般的处理方式是开发环境通过 Console
输出到终端,生产环境输出到指定的文件夹
如何设计我们的代码
在正式开始编码前,我们首先需要梳理清楚我们的应用场景,然后在设计我们的代码结构,最后才通过编码实现。回到我们的日志库,可能的场景如下:
- 需要支持在不同的环境下输出不同等级的日志到不同的目的地
appender
- appender是可配置的的,例如输出到文件,是按小时输出呢,还是按天输出呢
- 日志输出的格式是变化的,可以是[时间][等级][日志内容],也可以是[等级][时间][日志内容]
有了场景,该如何设计我们的代码?我觉得我们可以从开发者使用角度出发,假如我们提供了一个日志库,他们怎么用会比较爽呢?因为需要支持在不同的环境下输出不同等级的日志到不同的目的地,所以我们肯定需要支持按环境进行配置输出到各自的目的地 appender
new MidLog({
categories: {
// 开发环境全部输出到控制台
dev: {
level: 'all',
appenders: ['console']
},
// 线上环境全部输出到文件
production: {
level: 'all',
appenders: ['dateFile']
}
}
})
OK,更进一步不同环境对应的不同 appender
也有可能是多个的,所以我们需要支持数组。然后每个 appender
也会有一些自己的配置,例如输出到文件,可以配置是按天输出,还是按小时输出,另外日志输出的格式也是千变万化的。因此,代码可能演化成下面这样:
const midLog = new MidLog({
appenders: {
console: {
type: 'console',
layout:{
type:'pattern',
pattern:'%s %s [%s] %s -'
}
},
dateFile: {
type: 'dateFile',
fileName: 'application.log',
nameBackups: 7
}
},
categories: {
dev: {
level: 'all',
appenders: ['console']
},
portal: {
level: 'all',
appenders: ['dateFile']
}
}
})
由于最终我们在代码中使用的是一些类似 logger.debug(message)
logger.error(message)
这样的方法,因此上述我们实例化的类还需要暴露出这些方法,比如可以这样:
// 通过 midLog 获取到一个 内部logger 实例
const logger = midLog.getLogger(
config.dev === 'dev' ? 'fe-webconfig' : 'portal'
)
// logger 实例上会挂载各种等级的日志打印方法
logger.info('I am a message')
想明白了开发者怎么使用我们的日志库,具体再分析下哪些是变化的,参考[SOLID](SOLID: The First 5 Principles of Object Oriented Design | DigitalOcean) 设计原则,每个模块只负责一块逻辑,各自独立,例如 MidLog
Category``Appender
Logger
。MidLog
作为容器类依赖Category
来管理策略以及Appender
类管理输出目标,同时依赖Logger
类来做具体的日志打印,可以看到MidLog
类主要职责就是协调各个类之间的调用,而实际日志打印是依赖Logger
这个类,MidLog
会给Logger
传递具体的appender
和 level
,以便Logger
执行具体的日志打印动作,类之间的关系可查看如下 UML 图
代码实现
想清楚了我们的代码如何设计,接下来就是具体的编码了,这里我采用了 TypeScript
进行编写,由于它有着丰富的类型定义和类型推导,能够帮助我们减少常见的 bug
同时在 vscode
等编辑器上有更好的编码体验。
MidLog
首先我们来实现MidLog
, 如前面所说MidLog
用来协调各个类之间的调用,依赖Category
来收集策略名称和策略详细,我们会用Map
数据结构进行管理,依赖Appender
收集输出器名称和对应的配置信息,依赖Logger
打印日志,代码如下:
import { Appenders, Categories, Level } from './types'
import { merge, forEach } from 'lodash'
import {
CONSOLE_APPENDER_TYPE,
DEFAULT_APPENDER_NAME,
DEFAULT_CATEGORY_NAME
} from './constants'
import { Category } from './category'
import { Appender } from './appenders'
import { Logger } from './logger'
interface MidLogConstructorOptions {
categories?: Categories
appenders?: Appenders
}
export class MidLog {
public options: MidLogConstructorOptions
private category: Category
private appender: Appender
constructor(options: MidLogConstructorOptions) {
this.options = merge(
{
appenders: {
[DEFAULT_APPENDER_NAME]: {
type: CONSOLE_APPENDER_TYPE
}
},
categories: {
[DEFAULT_CATEGORY_NAME]: {
appenders: [DEFAULT_APPENDER_NAME],
level: Level.ALL
}
}
},
options
)
this.category = new Category()
this.appender = new Appender()
this.setup()
}
public getLogger(categoryName: string) {
return new Logger({
categoryName,
category: this.category,
appender: this.appender
})
}
private setup() {
forEach(this.options, (_value, key) => {
if (key === 'categories') {
forEach(this.options[key], (categoryValue, categoryKey) => {
this.category.setCategory(categoryKey, categoryValue)
})
} else if (key === 'appenders') {
forEach(this.options[key], (appenderValue, appenderKey) => {
this.appender.setAppenderOptions(appenderKey, appenderValue)
})
}
})
}
}
Category
Category
通过 Map 数据结构来管理策略名称和策略详细
import { CategoriesOptions } from './types'
export class Category {
public categories: Map<string, CategoriesOptions>
constructor() {
this.categories = new Map()
}
public getCategory(categoryName: string) {
return this.categories.get(categoryName)
}
public setCategory(categoryName: string, categoryValue: CategoriesOptions) {
this.categories.set(categoryName, categoryValue)
}
}
Appender
Appender
同样通过 Map 数据结构来管理输出器名称和配置信息,方便后面Logger
实例根据策略名称取出对应的配置信息,传递给实际工作的appender
函数
/* eslint-disable @typescript-eslint/no-var-requires */
import { forEach } from 'lodash'
import { AppendersFunction, AppendersOptions } from '../types'
const APPENDERS_FUNCTIONS = ['console', 'dateFile']
export class Appender {
public appendersFunction: Map<string, AppendersFunction>
public appendersOptions: Map<string, AppendersOptions>
constructor() {
this.appendersFunction = new Map()
this.appendersOptions = new Map()
forEach(APPENDERS_FUNCTIONS, appenderFunctionName => {
this.appendersFunction.set(
appenderFunctionName,
require(`./${appenderFunctionName}`).default
)
})
}
public getAppenderFunction(appenderName: string) {
return this.appendersFunction.get(appenderName)!
}
public getAppenderOptions(optionName: string) {
return this.appendersOptions.get(optionName)!
}
public setAppenderOptions(
optionName: string,
optionValue: AppendersOptions
) {
this.appendersOptions.set(optionName, optionValue)
}
}
Logger
该类是具体打印日志的类,执行流程如下:
- 依赖
Category
实例,取出具体的策略信息,策略信息包含appenders
和level
- 依赖
Appender
实例,遍历appenders
, 根据appender
值取出对应的appender
函数和参数 - 将当前调用的方法(error、debug等)和
level
进行对比,如果高于配置的level
就打印日志
详细代码如下:
import { forEach } from 'lodash'
import { Appender } from './appenders'
import { Category } from './category'
import { APPENDERS, LEVEL, LEVEL_COLOUR } from './constants'
import { Log } from './log'
import { CategoriesOptions, Level } from './types'
interface LoggerConstructorOptions {
categoryName: string
category: Category
appender: Appender
}
export class Logger {
private appender: Appender
private categoryName: string
private level: string
private appenderNames: string[]
private categoriesOptions: CategoriesOptions
constructor(options: LoggerConstructorOptions) {
const { appender, category, categoryName } = options
this.appender = appender
this.categoryName = categoryName
this.categoriesOptions = category.getCategory(options.categoryName)!
this.level = this.categoriesOptions[LEVEL]
this.appenderNames = this.categoriesOptions[APPENDERS]
}
public trace(...args: any[]) {
this.log(Level.TRACE, args)
}
public debug(...args: any[]) {
this.log(Level.DEBUG, args)
}
public info(...args: any[]) {
this.log(Level.INFO, args)
}
public warn(...args: any[]) {
this.log(Level.WARN, args)
}
public error(...args: any[]) {
this.log(Level.ERROR, args)
}
public fetal(...args: any[]) {
this.log(Level.FETAL, args)
}
private log(level: string, args: any[]) {
if (this.shouldOutput(level)) {
forEach(this.appenderNames, appenderName => {
const { getAppenderFunction, getAppenderOptions } =
this.appender
const appenderFunction = getAppenderFunction.call(
this.appender,
appenderName
)
const appenderOptions = getAppenderOptions.call(
this.appender,
appenderName
)
appenderFunction(appenderOptions)(
new Log({
data: args,
categoryName: this.categoryName,
level
})
)
})
}
}
private shouldOutput(levelStr: string) {
return LEVEL_COLOUR[levelStr].value >= LEVEL_COLOUR[this.level].value
}
}
Appender Function
这里再介绍下前面多次提到的 appender function , 它主要用来执行日志打印逻辑,是一个个独立的函数,因而可以随时扩展,例如 consoleAppender
如下:
import { Log } from '../types'
import { basicLayout } from '../layout'
// eslint-disable-next-line no-console
const consoleLog = console.log.bind(console)
export default function consoleAppender() {
return (log: Log) => {
consoleLog(basicLayout(log))
}
}
DateFileAppender
实现如下:
DateFileAppender 用于将日志输出到文件中,能够基于时间进行分片,例如按天进行分片,文件系统中就会产生 2022.05.10.application.log、2022.05.11.application.log 、application.log 等多个文件,其中 application.log 存储的都是当天的日志
import { DateFileAppenderOptions, Log } from '../types'
import { ensureFile, outputFile } from 'fs-extra'
import { statSync, existsSync, appendFile, Stats, renameSync, unlink } from 'fs'
import dayjs from 'dayjs'
import { join } from 'path'
import { basicLayout } from '../layout'
import { FILE_DATE_PATTERN } from '../constants'
import globby from 'globby'
function meetFileRollCondition(fileStat: Stats, pattern) {
const { ctime } = fileStat
return dayjs().isAfter(ctime, pattern)
}
function getCurrentLogFileStat(filePath: string) {
return statSync(filePath)
}
function getRollFileName(fileStat: Stats, fileName: string, pattern: string) {
return `${dayjs(fileStat.ctime).format(
FILE_DATE_PATTERN[pattern]
)}.${fileName}`
}
function getLogFilePath(fileName: string, cwd: string) {
return join(cwd, fileName)
}
export default function dateFileAppender(
dateFileAppender: DateFileAppenderOptions
) {
return (log: Log) => {
const {
fileName = 'application.log',
cwd = process.cwd(),
pattern = 'day',
nameBackups
} = dateFileAppender
const filePath = getLogFilePath(fileName, cwd)
const logData = basicLayout(log, false)
if (existsSync(filePath)) {
const fileStat = getCurrentLogFileStat(filePath)
if (meetFileRollCondition(fileStat, pattern)) {
const rollFilePath = getLogFilePath(
getRollFileName(fileStat, fileName, pattern),
cwd
)
// 1. rename application.log with date
renameSync(filePath, rollFilePath)
// 2. create a new application.log
ensureFile(filePath).then(() => {
appendFile(filePath, logData, error => {
if (error) {
throw error
}
// 3. exceed max backups ,delete fathest
if (nameBackups) {
globby([`*.${fileName}`, `${fileName}`], {
cwd
}).then(logFiles => {
if (logFiles.length > nameBackups) {
const needUnlinkPaths = logFiles.splice(
0,
logFiles.length - nameBackups
)
needUnlinkPaths.forEach(path => {
unlink(
getLogFilePath(path, cwd),
error => {
if (error) {
throw error
}
}
)
})
}
})
}
})
})
} else {
appendFile(filePath, logData, error => {
if (error) {
throw error
}
})
}
} else {
ensureFile(filePath).then(() => {
outputFile(filePath, logData)
})
}
}
}
至此,核心的代码都已实现完成,我已将完整的代码上传到 Github
总结
通过实现一个日志打印库,希望能够给大家带来以下几点思考:
- 学会从开发者使用角度去思考代码设计
- 单个模块最好只做自己那一块的事情,需要有边界
- 学会将不变的代码与变化的代码进行分离以提升代码的可扩展性
- 多实践,代码看的多,设计思想了解的多都不如自己亲自动手实现
参考
支持我
创作不易,如果您喜欢我的文章,麻烦点赞、关注、评论。如果您想看英文版,可以点击How to implement a log printing library from 0 to 1进行阅读,喜欢的话同样帮忙点赞关注下,感谢您的支持。