如何从0到1实现一个日志打印库

62,117 阅读7分钟

前言

日常开发中我们都需要打印日志,例如 debug 日志可以辅助我们分析程序运行过程,error 日志可以帮助我们定位错误发生的原因,同时我们还可以基于日志做数据分析,例如我们可以对错误日志进行监控,当日志产生数量大于我们事先设定的阈值时,发出一条告警,以便第一时间进行跟进修复。因此,在自己的项目中加入一些日志打点代码是很有必要的。本篇文章将从0到1带你一步步实现一个生产环境可使用的 node 端的日志打印库,期间不仅仅会展示代码,还会给您讲解为什么要这样设计,让您方便将这样的设计思想带入到自己的项目中去。

功能拆解

如果要用一句话来描述日志库的核心功能,我想是“将各种不同类型的日志输出到各种指定的容器“,这里有两个关键核心 不同类型日志各种容器,下面将分别讲讲这两个核心。

不同类型日志

日志是有区分的,不同等级的日志所传递的含义是不一样的,如下:

  1. Error: 系统发生了错误事件,但仍然不影响系统的继续运行。系统需要将错误或异常细节记录 Error 日志中,方便后续人工回溯解决。错误日志后期将会发送告警,将会落实到人排查解决
  2. Warn: 系统在业务处理时触发了异常流程,但系统可恢复到正常态,下一次业务可以正常执行。如程序调用了一个旧版本的接口,可选参数不合法,非业务预期的状态但仍可继续处理等
  3. Info: 记录系统关键信息,旨在保留系统正常工作期间关键运行指标,开发人员可以将初始化系统配置、业务状态变化信息,或者用户业务流程中的核心处理记录到 info 日志中,方便日常运维工作以及错误回溯时上下文场景复现
  4. Debug: 可以将各类详细信息记录到 debug 里,起到调试的作用,包括参数信息,调试细节信息,返回值信息等等
  5. Trace: 更详细的跟踪信息

上述日志级别从高到低排列,是开发中最常用的五种。生产系统一般只打印 Info 级别以上的日志,对于 Debug 级别的日志,只在测试环境中打印。打印错误日志时,需要区分是业务异常(如:用户名不能为空)还是系统异常(如:调用 会员核心异常),业务异常使用 Warn 级别记录,系统异常使用 Error 记录。关于更多的日志等级定义,可以查看Wiki

不同容器

就像上面提到的一样的,不同等级的日志输出的目的地是不一样的,可以是输出到终端,也可以是输出到文件里面,一般的处理方式是开发环境通过 Console输出到终端,生产环境输出到指定的文件夹

如何设计我们的代码

在正式开始编码前,我们首先需要梳理清楚我们的应用场景,然后在设计我们的代码结构,最后才通过编码实现。回到我们的日志库,可能的场景如下:

  1. 需要支持在不同的环境下输出不同等级的日志到不同的目的地 appender
  2. appender是可配置的的,例如输出到文件,是按小时输出呢,还是按天输出呢
  3. 日志输出的格式是变化的,可以是[时间][等级][日志内容],也可以是[等级][时间][日志内容]

有了场景,该如何设计我们的代码?我觉得我们可以从开发者使用角度出发,假如我们提供了一个日志库,他们怎么用会比较爽呢?因为需要支持在不同的环境下输出不同等级的日志到不同的目的地,所以我们肯定需要支持按环境进行配置输出到各自的目的地 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 LoggerMidLog作为容器类依赖Category来管理策略以及Appender类管理输出目标,同时依赖Logger类来做具体的日志打印,可以看到MidLog类主要职责就是协调各个类之间的调用,而实际日志打印是依赖Logger这个类,MidLog会给Logger传递具体的appenderlevel,以便Logger执行具体的日志打印动作,类之间的关系可查看如下 UML 图

uml-Page-1.drawio.png

代码实现

想清楚了我们的代码如何设计,接下来就是具体的编码了,这里我采用了 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

该类是具体打印日志的类,执行流程如下:

  1. 依赖 Category实例,取出具体的策略信息,策略信息包含 appenderslevel
  2. 依赖 Appender实例,遍历appenders, 根据appender 值取出对应的 appender 函数和参数
  3. 将当前调用的方法(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

总结

通过实现一个日志打印库,希望能够给大家带来以下几点思考:

  1. 学会从开发者使用角度去思考代码设计
  2. 单个模块最好只做自己那一块的事情,需要有边界
  3. 学会将不变的代码与变化的代码进行分离以提升代码的可扩展性
  4. 多实践,代码看的多,设计思想了解的多都不如自己亲自动手实现

参考

log4j-node

winston

支持我

创作不易,如果您喜欢我的文章,麻烦点赞、关注、评论。如果您想看英文版,可以点击How to implement a log printing library from 0 to 1进行阅读,喜欢的话同样帮忙点赞关注下,感谢您的支持。