
当您想到登录NodeJS时,最困扰您的是什么? 如果您问我,我会说缺乏创建跟踪ID的行业标准。 在本文中,我们将概述如何创建这些跟踪ID(这意味着我们将简要检查连续本地存储(即CLS)的工作原理),并深入研究如何利用代理使其与ANY logger一起使用。
为什么在NodeJS中为每个请求都具有跟踪ID甚至是一个问题?
好吧,在使用多线程并为每个请求生成新线程的平台上。 有一种称为线程本地存储(又称为TLS)的东西 ,它允许将任意数据保留给线程内的任何对象使用。 如果您有本机API,那么为每个请求生成一个随机ID相当简单,将其放入TLS,然后在控制器或服务中使用它。 那么与NodeJS有什么关系呢?
如您所知,NodeJS是一个单线程平台(现在我们已经有了工作线程,现在不再是真的了,但这不会改变全局),这使得TLS变得过时了。 与其运行不同的线程,NodeJS不在同一线程中运行不同的回调(如果您有兴趣,可以在NodeJS上有很多关于事件循环的文章 ),NodeJS为我们提供了一种唯一标识这些回调并跟踪它们之间关系的方法。 。
在过去(v0.11.11)中,我们使用addAsyncListener来跟踪异步事件。 基于此, Forrest Norvell构建了连续本地存储(即CLS)的第一个实现。 由于我们作为开发人员已经在v0.12中删除了该API,因此我们将不讨论CLS的实现。
在NodeJS 8之前,我们还没有正式的方法来连接NodeJS的异步事件处理。 最后,NodeJS 8通过async_hooks赋予了我们失去的力量 (如果您想更好地了解async_hooks,请参阅本文 )。 这将我们带到了基于async_hooks的现代CLS- cls-hooked实现 。
CLS概述
这是CLS工作原理的简化流程:

让我们将其逐步分解:
- 说,我们有一个典型的Web服务器。 首先,我们必须创建一个CLS命名空间。 在我们应用程序的整个生命周期中一次。
- 其次,我们必须配置一个中间件来为每个请求创建一个新的CLS上下文。 为简单起见,我们假设此中间件只是在收到新请求后调用的回调。
- 因此,当新请求到达时,我们将调用该回调函数。
- 在该函数中,我们创建一个新的CLS上下文(方法之一是使用run API调用)。
- 此时,CLS通过当前执行ID将新上下文放入上下文映射中。
- 每个CLS命名空间都具有
active
属性。 在此阶段,CLS将active
分配给上下文。 - 在上下文内部,我们调用异步资源,例如,我们从数据库中请求一些数据。 我们将回调传递给该调用,该调用将在对数据库的请求完成后运行。
- 将为新的异步操作触发init异步钩子。 它通过异步ID将当前上下文添加到上下文映射(将其视为新异步操作的标识符)。
- 由于我们的第一个回调没有更多的逻辑,它会退出并有效结束我们的第一个异步操作。
- 在为第一个回调触发异步钩子之后 。 它将名称空间上的活动上下文设置为
undefined
(并非总是如此,因为我们可能有多个嵌套上下文,但是在最简单的情况下,它是正确的)。 - 销毁钩子被发射到第一个操作。 它通过其异步ID(与我们的第一个回调的当前执行ID相同)从上下文映射中删除上下文。
- 对数据库的请求已完成,我们的第二个回调即将被触发。
- 此时异步钩子开始起作用。 它的当前执行ID与第二个操作(数据库请求)的异步ID相同。 它将名称空间的
active
属性设置为通过其当前执行ID找到的上下文。 这是我们之前创建的上下文。 - 现在,我们运行第二个回调。 在内部运行一些业务逻辑。 在该函数中,我们可以从CLS中通过键获取任何值,并且它将返回在先前创建的上下文中通过键找到的任何值 。
- 假设这是请求处理的结束,我们的函数将返回。
- 在为第二个回调触发异步钩子之后 。 它将名称空间上的活动上下文设置为
undefined
。 - 将为第二个异步操作触发
destroy
钩子。 它通过其异步ID从上下文映射中删除我们的上下文,使其绝对为空。 - 由于我们不再保留对上下文对象的任何引用,因此垃圾回收器将释放与其关联的内存。
它是幕后工作的简化版本,但涵盖了所有主要步骤。 如果您想深入研究,可以看一下源代码 。 少于500行。
生成跟踪ID
因此,一旦我们对CLS有了一个全面的了解,就让我们思考如何为自己的利益利用它。 我们可以做的一件事是创建一个中间件,该中间件将每个请求包装在上下文中,生成一个随机标识符,并通过键traceID
将其放入CLS中。 后来,在我们庞大的控制器和服务之一中,我们可以从CLS获得该标识符。
为了表达这种中间件,可能看起来像这样:
const cls = require('cls-hooked') const uuidv4 = require('uuid/v4') const clsNamespace = cls.createNamespace('app') const clsMiddleware = (req, res, next) => {
然后在我们的控制器中,我们可以获取生成的跟踪ID,如下所示:
const controller = (req, res, next) => { const traceID = clsNamespace.get('traceID') }
除非我们将其添加到日志中,否则不会过多使用此跟踪ID。
让我们将其添加到我们的winston中 。
const { createLogger, format, transports } = require('winston') const addTraceId = printf((info) => { let message = info.message const traceID = clsNamespace.get('taceID') if (traceID) { message = `[TraceID: ${traceID}]: ${message}` } return message }) const logger = createLogger({ format: addTraceId, transports: [new transports.Console()], })
好吧,如果所有记录器都以某种形式的函数支持格式化程序(其中许多原因并非出于充分原因),那么本文将不存在。 那么如何将跟踪ID添加到我心爱的pino ? 代理救援!
结合代理和CLS
代理是包装原始对象的对象,允许我们在某些情况下覆盖其行为。 这些情况的列表(实际上称为陷阱)是有限的,您可以在此处查看整个集合,但是我们只对trap get感兴趣。 它使我们能够拦截属性访问。 这意味着,如果我们有一个对象const a = { prop: 1 }
并将其包装在Proxy中,则使用get
trap可以返回a.prop
任何a.prop
。
因此,其想法是为每个请求生成一个随机跟踪ID,并使用跟踪ID创建一个子Pino记录器并将其放入CLS。 然后,我们可以使用代理将原始记录器包装起来,如果找到代理,它将把所有记录请求重定向到CLS中的子记录器,否则继续使用原始记录器。
在这种情况下,我们的代理可能如下所示:
const pino = require('pino') const logger = pino() const loggerCls = new Proxy(logger, { get(target, property, receiver) {
我们的中间件将变成这样:
const cls = require('cls-hooked') const uuidv4 = require('uuid/v4') const clsMiddleware = (req, res, next) => {
我们可以像这样使用记录器:
const controller = (req, res, next) => { loggerCls.info('Long live rocknroll!')
基于上述想法,创建了一个名为cls-proxify的小型库 。 它具有与express , koa和开箱即用的集成功能。
它不仅适用于get
陷印应用于原始对象,而且还适用于许多其他对象。 因此,存在无限可能的应用程序。 您可以代理函数调用,类构造,几乎任何事情! 您只受您的想象力限制!
看一下将其与pino和fastify,pino和express结合使用的现场演示 。
希望您已经找到了对您的项目有用的东西。 随时向我传达您的反馈! 我非常感谢任何批评和疑问。