AsyncLocalStorage 详解:在原生 Node.js 环境中的应用与避坑指南
为什么需要 AsyncLocalStorage?
AsyncLocalStorage 的基本概念
在原生 Node.js HTTP Server 中的应用
AsyncLocalStorage 的进阶应用
事务管理示例
请求上下文传递示例(简化版)
AsyncLocalStorage 的注意事项和常见问题
内存泄漏的例子
嵌套 run 的例子
与 Promise 的配合使用
AsyncLocalStorage 与 NestJS 的结合 (补充说明)
总结
你好,我是老码农。今天我们来聊聊 AsyncLocalStorage
这个在 Node.js 中用于异步上下文追踪的强大工具。特别是,我们会在原生 Node.js 环境中实战演练,让你彻底搞懂它。如果你对异步编程和上下文追踪还不太熟悉,别担心,我会用最通俗易懂的方式,辅以大量的代码示例,让你快速上手。
为什么需要 AsyncLocalStorage?
在单线程的 Node.js 中,异步操作无处不在。当我们处理 HTTP 请求、数据库查询、定时任务等时,代码的执行顺序会变得错综复杂。在这样的环境中,我们经常需要追踪一些上下文信息,例如:
- 用户身份: 哪个用户发起了这次请求?
- 请求 ID: 区分不同的请求,方便日志追踪。
- 事务 ID: 保证数据库操作的原子性。
- 语言偏好: 根据用户的设置返回不同语言的内容。
传统上,我们可能会使用以下方法来传递上下文信息:
- 函数参数: 将上下文信息作为参数传递给每个函数,但这会导致代码臃肿,可读性差。
- 全局变量: 使用全局变量存储上下文信息,这会带来线程安全问题,而且容易造成命名冲突。
- 手动维护上下文栈: 在每个异步操作开始和结束时,手动维护上下文栈,这非常复杂,容易出错。
AsyncLocalStorage
的出现,就是为了解决这些问题。它提供了一种更优雅、更安全的方式来管理异步上下文。
AsyncLocalStorage 的基本概念
AsyncLocalStorage
是 Node.js v12.17.0 版本引入的一个模块,它允许你在异步执行流程中存储和访问上下文信息。你可以把它想象成一个“全局作用域”,但它只在当前异步执行流程中有效。
关键概念:
- 存储 (Store):
AsyncLocalStorage
内部维护一个存储,用于保存键值对形式的上下文信息。每个异步执行流程都有自己的存储。 - 作用域 (Scope): 上下文信息的作用域是当前异步执行流程。当流程切换到另一个异步任务时,
AsyncLocalStorage
会切换到新的存储。 - 创建与使用: 使用
AsyncLocalStorage
主要涉及以下几个步骤:- 创建实例:
const asyncLocalStorage = require('async_hooks').createHook()
- 运行代码: 使用
asyncLocalStorage.run(store, callback)
启动一个异步执行流程,并将 store 传递给这个流程。在 callback 函数中,你可以通过asyncLocalStorage.getStore()
获取当前存储中的值。 - 设置上下文信息: 在
run
函数中,你可以设置上下文信息,例如:asyncLocalStorage.run({ userId: 123 }, () => { ... })
- 访问上下文信息: 在异步操作中,你可以通过
asyncLocalStorage.getStore()
访问当前存储中的上下文信息。
- 创建实例:
在原生 Node.js HTTP Server 中的应用
让我们通过一个简单的例子来演示 AsyncLocalStorage
在原生 Node.js HTTP Server 中的应用。我们将模拟一个用户身份验证的场景,并为每个请求生成一个唯一的请求 ID。
const http = require('http'); const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); const server = http.createServer((req, res) => { // 1. 生成请求 ID const requestId = Math.random().toString(36).substring(2, 15); // 2. 模拟用户身份验证(假设从请求头中获取用户 ID) const userId = req.headers['user-id'] || null; // 3. 使用 AsyncLocalStorage 存储上下文信息 asyncLocalStorage.run({ requestId, userId }, () => { // 4. 在异步操作中使用上下文信息 logRequestInfo(req, res); // 模拟异步数据库查询 setTimeout(() => { // 在异步操作中访问上下文信息 const store = asyncLocalStorage.getStore(); const dbQueryLog = `[${store.requestId}] - 查询用户 ${store.userId || '匿名用户'} 的数据`; console.log(dbQueryLog); res.end('Hello, world!'); }, 100); }); }); function logRequestInfo(req, res) { const store = asyncLocalStorage.getStore(); const logMessage = `[${store.requestId}] - 接收到请求: ${req.method} ${req.url},用户ID: ${store.userId || '未登录'}`; console.log(logMessage); } const port = 3000; server.listen(port, () => { console.log(`Server listening on port ${port}`); });
代码解释:
- 引入模块: 引入
http
模块和async_hooks
模块中的AsyncLocalStorage
。 - 创建 AsyncLocalStorage 实例:
const asyncLocalStorage = new AsyncLocalStorage();
创建一个AsyncLocalStorage
实例。 - 创建 HTTP Server:
http.createServer()
创建一个 HTTP 服务器,用于处理请求。 - 生成请求 ID 和用户 ID: 在每个请求处理函数中,生成一个唯一的请求 ID,并从请求头中获取用户 ID。
- 使用
asyncLocalStorage.run()
: 使用asyncLocalStorage.run()
创建一个新的异步执行流程,并将{ requestId, userId }
作为存储传递给这个流程。这会将requestId
和userId
设置为当前上下文信息。 - 访问上下文信息: 在
logRequestInfo
函数和setTimeout
回调函数中,使用asyncLocalStorage.getStore()
获取当前上下文信息。这样,我们就可以在异步操作中访问请求 ID 和用户 ID。 - 日志输出: 将请求 ID、用户 ID 输出到控制台,方便追踪请求。
- 启动服务器:
server.listen()
启动 HTTP 服务器,监听指定的端口。
如何运行代码:
- 将代码保存为
server.js
文件。 - 在终端中运行
node server.js
。 - 使用
curl
或浏览器发送 HTTP 请求,例如:curl http://localhost:3000
(匿名用户,没有用户 ID)curl -H "user-id: 123" http://localhost:3000
(用户 ID 为 123)
观察输出:
你会在终端中看到类似如下的输出:
Server listening on port 3000 [a1b2c3d4e5f] - 接收到请求: GET /,用户ID: 未登录 [a1b2c3d4e5f] - 查询用户 null 的数据 [g6h7i8j9k0l] - 接收到请求: GET /,用户ID: 123 [g6h7i8j9k0l] - 查询用户 123 的数据
可以看到,每个请求都有一个唯一的请求 ID,并且可以在异步操作中正确地获取用户 ID。即使在 setTimeout
这样的异步回调函数中,我们也能正确地访问到上下文信息。
AsyncLocalStorage 的进阶应用
除了基本的上下文追踪,AsyncLocalStorage
还可以用于更复杂的场景,例如:
- 事务管理: 在数据库操作中,可以使用
AsyncLocalStorage
来追踪事务 ID,并确保所有操作都在同一个事务中执行。 - 请求上下文的传递: 在微服务架构中,可以使用
AsyncLocalStorage
来传递请求上下文信息,例如用户身份、跟踪 ID 等,从而实现跨服务的日志追踪和链路追踪。 - 中间件开发: 可以开发基于
AsyncLocalStorage
的中间件,例如身份验证中间件、日志中间件等,从而简化代码,提高可维护性。
事务管理示例
const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); // 模拟数据库连接和操作 const db = { beginTransaction: () => { console.log('开始事务'); return Promise.resolve(Math.random().toString(36).substring(2, 15)); // 模拟事务 ID }, commitTransaction: (transactionId) => { console.log(`提交事务 ${transactionId}`); return Promise.resolve(); }, rollbackTransaction: (transactionId) => { console.log(`回滚事务 ${transactionId}`); return Promise.resolve(); }, query: (sql, transactionId) => { console.log(`执行 SQL: ${sql} (事务ID: ${transactionId})`); // 模拟查询失败 if (Math.random() < 0.2) { return Promise.reject(new Error('数据库查询失败')); } return Promise.resolve({ rows: [{ id: 1, name: 'test' }] }); }, }; async function processOrder(order) { let transactionId; try { // 1. 开始事务 transactionId = await db.beginTransaction(); // 2. 使用 AsyncLocalStorage 存储事务 ID asyncLocalStorage.run({ transactionId }, async () => { // 3. 执行一系列数据库操作 await db.query('INSERT INTO orders ...', asyncLocalStorage.getStore().transactionId); await db.query('UPDATE products ...', asyncLocalStorage.getStore().transactionId); await db.query('SELECT ...', asyncLocalStorage.getStore().transactionId); // 4. 提交事务 await db.commitTransaction(asyncLocalStorage.getStore().transactionId); console.log('订单处理成功'); }); } catch (error) { console.error('订单处理失败:', error); // 5. 回滚事务 if (transactionId) { await db.rollbackTransaction(transactionId); } } } // 模拟订单数据 const order = { items: [{ productId: 1, quantity: 2 }] }; // 处理订单 processOrder(order);
代码解释:
- 模拟数据库操作: 定义了
db
对象,模拟数据库连接、事务管理和查询操作。 processOrder
函数: 处理订单的函数,包含事务的开始、提交和回滚逻辑。- 开始事务:
db.beginTransaction()
开始一个事务,并获取事务 ID。 - 使用
asyncLocalStorage.run()
: 将事务 ID 存储在AsyncLocalStorage
中,确保后续的数据库操作都在同一个事务上下文中执行。 - 数据库操作: 在
asyncLocalStorage.run()
的回调函数中,执行一系列数据库查询,并将事务 ID 传递给db.query()
。 - 提交或回滚事务: 如果所有数据库操作都成功,则提交事务。如果发生错误,则回滚事务。
- 错误处理: 使用
try...catch
块来捕获错误,并根据需要回滚事务。
运行结果:
你将会看到类似以下的输出,注意事务ID的传递。
开始事务 执行 SQL: INSERT INTO orders ... (事务ID: 8v9w0x1y2z) 执行 SQL: UPDATE products ... (事务ID: 8v9w0x1y2z) 执行 SQL: SELECT ... (事务ID: 8v9w0x1y2z) 提交事务 8v9w0x1y2z 订单处理成功
或者在模拟查询失败时,会看到类似这样的输出:
开始事务 执行 SQL: INSERT INTO orders ... (事务ID: qwer12345) 执行 SQL: UPDATE products ... (事务ID: qwer12345) 执行 SQL: SELECT ... (事务ID: qwer12345) 订单处理失败: Error: 数据库查询失败 回滚事务 qwer12345
请求上下文传递示例(简化版)
const http = require('http'); const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); const userService = { getUser: (userId) => { // 模拟从数据库或缓存中获取用户数据 return new Promise((resolve) => { setTimeout(() => { const user = { id: userId, name: `User ${userId}` }; resolve(user); }, 50); }); }, }; const authMiddleware = async (req, res, next) => { const userId = req.headers['user-id']; if (!userId) { return res.writeHead(401).end('Unauthorized'); } const user = await userService.getUser(userId); if (!user) { return res.writeHead(404).end('User not found'); } asyncLocalStorage.run({ user }, () => { req.user = asyncLocalStorage.getStore().user; // 将user信息附加到req对象上,传递给后续处理 next(); }); }; const requestHandler = async (req, res) => { if (req.url === '/profile') { const user = req.user; // 从req中获取user res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ message: `Hello, ${user.name}!` })); } else { res.writeHead(404).end('Not Found'); } }; const server = http.createServer((req, res) => { authMiddleware(req, res, () => { requestHandler(req, res); }); }); const port = 3000; server.listen(port, () => { console.log(`Server listening on port ${port}`); });
代码解释:
- 模拟用户服务:
userService
模拟从数据库或缓存中获取用户数据。 - 认证中间件
authMiddleware
: 从请求头中获取用户 ID,调用userService.getUser()
获取用户数据,并将用户信息存储在AsyncLocalStorage
中。同时,将用户信息附加到req
对象上,传递给后续的处理程序。 - 请求处理程序
requestHandler
: 从req
对象中获取用户信息,并根据用户信息生成响应。 - 创建 HTTP Server: 创建 HTTP 服务器,并将
authMiddleware
和requestHandler
组合起来处理请求。
运行结果:
- 向
/profile
发送请求,并携带user-id
请求头,服务器将返回用户个人资料信息。 - 如果没有携带
user-id
请求头,则返回401 Unauthorized
错误。 - 如果
user-id
对应的用户不存在,则返回404 Not Found
错误。
AsyncLocalStorage 的注意事项和常见问题
在使用 AsyncLocalStorage
时,需要注意以下几点:
- 性能:
AsyncLocalStorage
的性能开销相对较小,但过度使用或在性能敏感的场景下,仍需要谨慎。建议只在必要时使用,避免不必要的上下文切换。 - 内存泄漏: 如果忘记在异步操作结束后清除
AsyncLocalStorage
中的数据,可能会导致内存泄漏。因此,在使用完后,务必确保数据被正确清除,可以使用run
方法的第二个参数的 callback, 在 callback 执行完毕后, store 也会被自动清理。 - 嵌套
run
:AsyncLocalStorage
支持嵌套的run
调用。在嵌套调用中,内部run
会覆盖外部run
设置的上下文信息,当内部run
执行完毕后,会恢复到外部run
的上下文信息。这使得我们可以在不同的异步流程中隔离上下文信息。 - 异步上下文的边界:
AsyncLocalStorage
主要用于追踪 Node.js 的异步上下文。对于一些特殊的异步机制,例如Web Workers
或跨进程通信,AsyncLocalStorage
可能无法直接使用,需要借助其他的技术手段来传递上下文信息。 - 与
Promise
的配合:AsyncLocalStorage
与Promise
配合使用非常方便。在Promise
链中,可以使用asyncLocalStorage.getStore()
访问上下文信息。需要注意的是,在Promise
的then
或catch
中,上下文信息会保持不变。 - TypeScript 支持: 在 TypeScript 中使用
AsyncLocalStorage
时,需要安装@types/node
包,并导入AsyncLocalStorage
的类型定义,以便获得类型检查和代码提示。
内存泄漏的例子
const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); let globalStore = {}; async function leakyFunction() { asyncLocalStorage.run({ data: { value: 'some data' } }, async () => { globalStore = asyncLocalStorage.getStore(); // 错误!将store引用保存在全局变量 await new Promise(resolve => setTimeout(resolve, 1000)); console.log('leak: ', globalStore); }); } for (let i = 0; i < 10; i++) { leakyFunction(); }
问题分析:
在这个例子中,在 asyncLocalStorage.run
中,store的引用被赋值给了全局变量 globalStore
, 由于 globalStore
一直保持着对 store 的引用,当 asyncLocalStorage.run
结束时, store 无法被垃圾回收, 从而导致内存泄漏。解决方法是在 asyncLocalStorage.run
的回调函数执行完毕后,删除对 store 的引用,例如将 globalStore = {}
或 globalStore = null
。
嵌套 run 的例子
const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); async function outer() { asyncLocalStorage.run({ outer: 'outer value' }, async () => { console.log('Outer:', asyncLocalStorage.getStore()); // { outer: 'outer value' } await inner(); console.log('Outer after inner:', asyncLocalStorage.getStore()); // { outer: 'outer value' } }); } async function inner() { asyncLocalStorage.run({ inner: 'inner value' }, () => { console.log('Inner:', asyncLocalStorage.getStore()); // { inner: 'inner value' } }); } outer();
运行结果:
Outer: { outer: 'outer value' } Inner: { inner: 'inner value' } Outer after inner: { outer: 'outer value' }
与 Promise 的配合使用
const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); async function fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { const store = asyncLocalStorage.getStore(); if (store) { console.log('Fetch Data: ', store); resolve({ data: 'fetched data', store }); } else { reject(new Error('Store not available')); } }, 100); }); } async function processData() { asyncLocalStorage.run({ requestId: '123' }, async () => { try { const result = await fetchData(); console.log('Process Data Result: ', result); } catch (error) { console.error('Error: ', error); } }); } processData();
运行结果:
Fetch Data: { requestId: '123' } Process Data Result: { data: 'fetched data', store: { requestId: '123' } }
AsyncLocalStorage 与 NestJS 的结合 (补充说明)
虽然本篇主要讲解的是 AsyncLocalStorage
在原生 Node.js 环境中的应用,但 NestJS 作为流行的 Node.js 框架,也提供了对 AsyncLocalStorage
的集成。 NestJS 提供了 REQUEST
作用域的依赖注入,以及 ExecutionContext
等工具,使得上下文信息的传递更加便捷。
在 NestJS 中,通常使用 REQUEST
作用域来存储每个请求的上下文信息。你可以创建一个自定义的 Interceptor
或者 Middleware
,在其中使用 AsyncLocalStorage
存储请求相关的上下文信息,例如请求 ID、用户 ID 等。然后,你可以在 Controller
、Service
等地方通过依赖注入获取上下文信息。
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Scope } from '@nestjs/common'; import { AsyncLocalStorage } from 'async_hooks'; import { Observable } from 'rxjs'; const asyncLocalStorage = new AsyncLocalStorage(); @Injectable({ scope: Scope.REQUEST }) export class RequestContextInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const requestId = Math.random().toString(36).substring(2, 15); const userId = request.headers['user-id'] || null; return new Observable((subscriber) => { asyncLocalStorage.run({ requestId, userId }, () => { try { const result = next.handle().subscribe({ next: (value) => { subscriber.next(value); }, error: (err) => { subscriber.error(err); }, complete: () => { subscriber.complete(); }, }); } catch (err) { subscriber.error(err); } }); }); } } // 在你的 module 中注册这个 interceptor // @Module({ // providers: [ // { provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor, }, // ], // }) // 在你的 controller 或 service 中使用 // import { Injectable, Inject } from '@nestjs/common'; // import { AsyncLocalStorage } from 'async_hooks'; // @Injectable() // export class MyService { // constructor(@Inject(AsyncLocalStorage) private readonly asyncLocalStorage: AsyncLocalStorage) {} // doSomething() { // const store = this.asyncLocalStorage.getStore(); // console.log('Request ID:', store.requestId); // } // }
关键点:
Scope.REQUEST
: 确保RequestContextInterceptor
是请求作用域的,这样每个请求都会创建一个新的Interceptor
实例。ExecutionContext
: 用于获取当前的请求和响应对象。AsyncLocalStorage
: 在Interceptor
中,使用AsyncLocalStorage
存储请求上下文信息。- 依赖注入: 在
Controller
或Service
中,通过依赖注入获取AsyncLocalStorage
实例,从而访问上下文信息。
总结
AsyncLocalStorage
是一个非常有用的工具,可以帮助你更好地管理 Node.js 中的异步上下文。通过本文,你已经了解了它的基本概念、应用场景、注意事项,以及在原生 Node.js 环境中的实战演练。希望这些内容能帮助你在实际项目中更好地应用 AsyncLocalStorage
。记住,多实践,多思考,才能真正掌握这项技术。
如果你在实践过程中遇到任何问题,欢迎随时提出。祝你编码愉快!