AsyncLocalStorage 详解:在原生 Node.js 环境中的应用与避坑指南
你好,我是老码农。今天我们来聊聊 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。记住,多实践,多思考,才能真正掌握这项技术。
如果你在实践过程中遇到任何问题,欢迎随时提出。祝你编码愉快!