日志记录是每个开发人员从第一天编写代码时就要做的事情,但很少有人知道它可以产生的价值和最佳实践。

在本文中,我们将讨论以下主题:

  • 什么是日志,为什么很重要性?
  • 记录日志的最佳做法
  • 日志的重要部分
  • 正确使用日志级别
  • 为什么选择 Winston?

什么是日志,为什么很重要?

日志是反映程序各个方面的事件,如果能够正确编写,那么它就是最简单的故障排除和诊断程序的模式。

当你启动 Node.js 服务器时,如果数据库由于某些问题而没有运行,或服务器端口已经被占用时,如果没有日志,你将永远不知道服务器失败的原因。

作为开发人员,你经常需要调试一些问题,我们很喜欢用调试器和断点来定位故障的位置和内容。

当你的程序在生产环境中运行时,你会做些什么?你能在那里附加调试器并重现 bug 吗?显然没有。因此,这是日志记录能够帮助你的地方。

在不使用调试器的情况下,你可以通过浏览日志找到问题并了解出现问题的原因和位置。

最佳实践

1)日志的三个重要部分

程序日志既适用于人类,也适用于机器。人类参考日志来调试问题,机器用日志生成各种图表,并通过数据分析来产生关于客户使用的各种结论。

每个日志都应包含三个最重要的部分:

  • 日志源

当我们有一个微服务架构时,这对于了解日志的来源、服务名称、区域、主机名等信息非常重要(有关管理微服务中的公共代码的更多信息请在此处阅读

有关源的详细元数据主要由日志 agent 进行处理,日志 agent 将日志从所有微服务推送到集中式日志系统。 ELK 栈的 Filebeat 是日志 agent 的最佳选择之一。

  • 时间戳

事件发生或生成日志的时间非常重要。所以要确保每个日志都有时间戳,以便我们进行排序和筛选。

  • 级别和上下文

在通过查看日志查找错误时,如果日志没有提供足够的信息,你就必须回到代码中,那将非常令人沮丧。因此在记录时我们应该传递足够的上下文

例如。没有上下文的日志将如下所示:

The operation failed!

有意义的上下文应该是是:

Failed to create user, as the user id already exist

2)日志的使用方法

  • 日志方法和输入:

在调试的同时,如果我们知道调用了哪个函数以及传递了哪些参数,它就能发挥真正的作用。

import logger from '../logSetup';
getInstallment(month: number, count: number ): number {
    logger.debug(`>>>> Entering getInstallment(month = ${month}, count= ${count}");
    // process
    const installment: number = 3;
    log.debug("<<<< Exiting getIntallment()");
    return installment;
}

通过日志 >>>><<<< 将给出函数输入和退出的信息。这是受到了 git merge 冲突的启发。

  • 日志不应该评估抛出异常

在第7行中,userService.getUser() 可以返回 null,且 .getId() 可以抛出异常,所以要避免这些情况。

import logger from '../logSetup';
processLoan(...) {
    logger.debug(">>>> Entering processLoan()");
    
    // ... process

    logger.debug(`Processing user loan with id ${userService.getUser().getId()}`);
    // this might throw error, when getUser returns undefined

    logger.debug("<<<< Exiting processLoan()");
    return true;
}

你应该用 Aspect js 自动执行函数级日志。

  • 日志不应产生副作用

日志应该是无状态的,不应产生任何副作用。例如,下面第 7 行的日志将在数据库中创建新资源。

import logger from '../logSetup';
createUser() {
  logger.debug(">>>> Entering createUser");

  // ... process

  logger.debug("Saving user loan {}", userInfoRepository.save(userInfo)) // don't do this

  return true;
}
  • 记录错误和详细信息

当描述错误时,请提及尝试的内容及其失败的原因。

记录*哪些是失败的*和*你接下来做什么*。

import logger from '../logSetup';
processLoan(id: number, userId: number) {
    try {
        getLoanDeatilsById()
    } catch(error) {
        log.error(`Failed to do getLoanDetails with id ${id}, ignoring it and trying to getLoanDetailsByUserId`, error);
        // good example: provide what failed, and how you are handling. 
        // e.g here on fail I am trying to call other function
        getLoanDetailsByUserId();
    }
}

如果你在 catch 部分中丢弃错误,请记录哪个操作失败并提及你正在抛出错误。

import logger from '../logSetup';
processLoan(id: number, userId: number) {
    try {
        getLoanDeatilsById()
    } catch(error) {
        log.error(`Failed to do getLoanDetails with id ${id} hence throwing error`, error);
        // good example: provide what failed, and how you are handling. 
        // e.g here on fail I am throwing
        throw error;
    }
}

3)敏感信息

该系列日志应该反映用户在程序中的活动以便调试更容易,并且应该记录错误以便尽快采取措施。日志包含一些信息,例如调用哪些函数,输入的内容,发生的位置和错误等。

记录时我们必须确保不去记录用户名和密码等敏感信息,例如信用卡号、CVV 号码等财务信息。

作为开发人员,我们应该通过与产品团队沟通,来准备敏感信息的列表并在记录之前将其屏蔽。

4)正确使用日志级别

如果生产环境下的程序具有相当多的用户事务,那么理想的日志设置可能每天会生成 GB 级别的日志,因此我们需要将日志分组为多个组。根据受众,我们可以在运行时切换日志级别,并仅获取适当的日志。

例如,如果产品经理希望在我们的日志记录仪表板中查看有多少客户交易成功或失败,则不应向他展示各种功能调用的杂乱信息,这些信息仅供开发人员使用。当生产环境中存在错误时,开发人员应该看到各种函数成功执行和失败的详细日志。这样就可以尽快发现并修复问题。

要实现这种设置,我们需要更好地了解每个日志级别。

让我们讨论最重要的级别及其用法:

  • INFO:一些重要的消息,描述一个任务完成时的事件消息。

例如:New User created with id xxx

这表示*仅记录进度信息*。

  • DEBUG:此级别适用于开发人员,这类似于记录你在使用调试器或断点时看到的信息,例如调用了哪个函数以及传递了哪些参数等。它应该记录当前状态,这样在调试和查找确切问题时会很有用。

  • WARN:这些日志是警告并且不阻止应用程序继续运行,这些日志会在出现问题并使用变通方法时发出警报。例如错误的用户输入、重试等。管理员将来应该修复这些警告。

  • ERROR:发生了错误时,应在*优先*在这里进行调查。例如数据库与其他微服务的通信失败,或所需要的输入未定义。

主要受众是系统操作员或监控系统。

理想情况下,生产环境下的程序应该具有接近零的错误日志。

5)不要使用console.log

大多数开发人员使用控制台模块作为获取日志或调试代码的第一个工具,因为它简单容易且全局可用,无需设置。在 Node.Js 中,控制台的实现方式与浏览器不同,控制台模块在使用 console.log 时会在 stdout 中打印消息,如果使用 console.error 它将打印到 stderr。

console.logconsole.debugconsole.info 都在 stdout 中打印,因此我们将无法关闭或打开调试和及信息。同样,`console.warnconsole.error 都在 stderr 中打印。

生产环境程序很难切换各种级别。

我们还需要不同类型的配置,如标准格式、把JSON 输出格式发送到 ELK 栈,这些在开箱即用的控制台中不可用。

要克服所有这些问题,可以使用 Winston 日志框架,还有其他一些选项,如BunyanPino等。

为什么需要像 Winston 这样的日志库?

在上一节中我们讨论了控制台的一些缺陷,让我们列出 Winston 提供的一些重要功能:

  • 级别: Winston 提供了几组日志级别,并且还将级别打印为日志的一部分,这可以使我们能够在集中式仪表板中过滤日志。

例如 {message: “something wrong”, level: “error"}

如果需要,你也可以创建自定义级别。

  • 格式: Winston 有一些高级配置,比如给日志着色,输出 JSON 格式等等。

  • 动态更改日志级别:我们将在生产环境程序中启用警告和错误,并可以根据需要将日志级别更改为调试并返回错误,而无需重新启动程序。 Winston 具有这种开箱即用的功能。

// log setup
import winston from 'winston';
const transports = {
  console: new winston.transports.Console({ level: 'warn' }),
};

const logger = winston.createLogger({
  transports: [transports.console, transports.file]
});

logger.info('This will not be logged in console transport because warn is set!');

transports.console.level = 'info'; // changed the level

logger.info('This will be logged in now!');

export default {logger, transport}

我们还可以公开 API 动态更改级别,公开 REST API 并在处理程序中执行第 13 行以更改级别。

  • 传输:对于生产环境,我们希望有一个集中式日志记录系统,所有的微服务都会推送日志,我们将通过仪表板过滤和搜索日志。这是标准的 ELK 设置或等效设置。
import winston from 'winston';
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    //
    new winston.transports.File({ filename: 'stdout.log' })
  ]
});

export default logger;

通过配置 Winston 将我们的日志写入文件,以便任何日志托运代理都可以将日志推送到集中式系统。但是,这超出了本文的范围,我们会在另一篇文章中详细讨论。

6)性能影响

如果程序写日志的频率很高,则可能直接影响程序性能。

DEBUG 和 INFO 级别的日志可占到整体的 95% 以上,这就是为什么应该只启用 ERROR 和 WARN 级别,并在想要找出问题时将级别更改为DEBUG,之后再将其切换回 ERROR 。

当应用程序出现问题时,日志就是救星。如果你当前还没有很好的使用日志,请实施日志记录实践并将日志添加到代码审查核对表中。