WEBKT

AsyncLocalStorage 详解:在原生 Node.js 环境中的应用与避坑指南

30 0 0 0

为什么需要 AsyncLocalStorage?

AsyncLocalStorage 的基本概念

在原生 Node.js HTTP Server 中的应用

AsyncLocalStorage 的进阶应用

事务管理示例

请求上下文传递示例(简化版)

AsyncLocalStorage 的注意事项和常见问题

内存泄漏的例子

嵌套 run 的例子

与 Promise 的配合使用

AsyncLocalStorage 与 NestJS 的结合 (补充说明)

总结

你好,我是老码农。今天我们来聊聊 AsyncLocalStorage 这个在 Node.js 中用于异步上下文追踪的强大工具。特别是,我们会在原生 Node.js 环境中实战演练,让你彻底搞懂它。如果你对异步编程和上下文追踪还不太熟悉,别担心,我会用最通俗易懂的方式,辅以大量的代码示例,让你快速上手。

为什么需要 AsyncLocalStorage?

在单线程的 Node.js 中,异步操作无处不在。当我们处理 HTTP 请求、数据库查询、定时任务等时,代码的执行顺序会变得错综复杂。在这样的环境中,我们经常需要追踪一些上下文信息,例如:

  • 用户身份: 哪个用户发起了这次请求?
  • 请求 ID: 区分不同的请求,方便日志追踪。
  • 事务 ID: 保证数据库操作的原子性。
  • 语言偏好: 根据用户的设置返回不同语言的内容。

传统上,我们可能会使用以下方法来传递上下文信息:

  1. 函数参数: 将上下文信息作为参数传递给每个函数,但这会导致代码臃肿,可读性差。
  2. 全局变量: 使用全局变量存储上下文信息,这会带来线程安全问题,而且容易造成命名冲突。
  3. 手动维护上下文栈: 在每个异步操作开始和结束时,手动维护上下文栈,这非常复杂,容易出错。

AsyncLocalStorage 的出现,就是为了解决这些问题。它提供了一种更优雅、更安全的方式来管理异步上下文。

AsyncLocalStorage 的基本概念

AsyncLocalStorage 是 Node.js v12.17.0 版本引入的一个模块,它允许你在异步执行流程中存储和访问上下文信息。你可以把它想象成一个“全局作用域”,但它只在当前异步执行流程中有效。

关键概念:

  • 存储 (Store): AsyncLocalStorage 内部维护一个存储,用于保存键值对形式的上下文信息。每个异步执行流程都有自己的存储。
  • 作用域 (Scope): 上下文信息的作用域是当前异步执行流程。当流程切换到另一个异步任务时,AsyncLocalStorage 会切换到新的存储。
  • 创建与使用: 使用 AsyncLocalStorage 主要涉及以下几个步骤:
    1. 创建实例: const asyncLocalStorage = require('async_hooks').createHook()
    2. 运行代码: 使用 asyncLocalStorage.run(store, callback) 启动一个异步执行流程,并将 store 传递给这个流程。在 callback 函数中,你可以通过 asyncLocalStorage.getStore() 获取当前存储中的值。
    3. 设置上下文信息:run 函数中,你可以设置上下文信息,例如:asyncLocalStorage.run({ userId: 123 }, () => { ... })
    4. 访问上下文信息: 在异步操作中,你可以通过 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}`);
});

代码解释:

  1. 引入模块: 引入 http 模块和 async_hooks 模块中的 AsyncLocalStorage
  2. 创建 AsyncLocalStorage 实例: const asyncLocalStorage = new AsyncLocalStorage(); 创建一个 AsyncLocalStorage 实例。
  3. 创建 HTTP Server: http.createServer() 创建一个 HTTP 服务器,用于处理请求。
  4. 生成请求 ID 和用户 ID: 在每个请求处理函数中,生成一个唯一的请求 ID,并从请求头中获取用户 ID。
  5. 使用 asyncLocalStorage.run() 使用 asyncLocalStorage.run() 创建一个新的异步执行流程,并将 { requestId, userId } 作为存储传递给这个流程。这会将 requestIduserId 设置为当前上下文信息。
  6. 访问上下文信息:logRequestInfo 函数和 setTimeout 回调函数中,使用 asyncLocalStorage.getStore() 获取当前上下文信息。这样,我们就可以在异步操作中访问请求 ID 和用户 ID。
  7. 日志输出: 将请求 ID、用户 ID 输出到控制台,方便追踪请求。
  8. 启动服务器: server.listen() 启动 HTTP 服务器,监听指定的端口。

如何运行代码:

  1. 将代码保存为 server.js 文件。
  2. 在终端中运行 node server.js
  3. 使用 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);

代码解释:

  1. 模拟数据库操作: 定义了 db 对象,模拟数据库连接、事务管理和查询操作。
  2. processOrder 函数: 处理订单的函数,包含事务的开始、提交和回滚逻辑。
  3. 开始事务: db.beginTransaction() 开始一个事务,并获取事务 ID。
  4. 使用 asyncLocalStorage.run() 将事务 ID 存储在 AsyncLocalStorage 中,确保后续的数据库操作都在同一个事务上下文中执行。
  5. 数据库操作:asyncLocalStorage.run() 的回调函数中,执行一系列数据库查询,并将事务 ID 传递给 db.query()
  6. 提交或回滚事务: 如果所有数据库操作都成功,则提交事务。如果发生错误,则回滚事务。
  7. 错误处理: 使用 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}`);
});

代码解释:

  1. 模拟用户服务: userService 模拟从数据库或缓存中获取用户数据。
  2. 认证中间件 authMiddleware 从请求头中获取用户 ID,调用 userService.getUser() 获取用户数据,并将用户信息存储在 AsyncLocalStorage 中。同时,将用户信息附加到 req 对象上,传递给后续的处理程序。
  3. 请求处理程序 requestHandlerreq 对象中获取用户信息,并根据用户信息生成响应。
  4. 创建 HTTP Server: 创建 HTTP 服务器,并将 authMiddlewarerequestHandler 组合起来处理请求。

运行结果:

  • /profile 发送请求,并携带 user-id 请求头,服务器将返回用户个人资料信息。
  • 如果没有携带 user-id 请求头,则返回 401 Unauthorized 错误。
  • 如果 user-id 对应的用户不存在,则返回 404 Not Found 错误。

AsyncLocalStorage 的注意事项和常见问题

在使用 AsyncLocalStorage 时,需要注意以下几点:

  1. 性能: AsyncLocalStorage 的性能开销相对较小,但过度使用或在性能敏感的场景下,仍需要谨慎。建议只在必要时使用,避免不必要的上下文切换。
  2. 内存泄漏: 如果忘记在异步操作结束后清除 AsyncLocalStorage 中的数据,可能会导致内存泄漏。因此,在使用完后,务必确保数据被正确清除,可以使用 run 方法的第二个参数的 callback, 在 callback 执行完毕后, store 也会被自动清理。
  3. 嵌套 run AsyncLocalStorage 支持嵌套的 run 调用。在嵌套调用中,内部 run 会覆盖外部 run 设置的上下文信息,当内部 run 执行完毕后,会恢复到外部 run 的上下文信息。这使得我们可以在不同的异步流程中隔离上下文信息。
  4. 异步上下文的边界: AsyncLocalStorage 主要用于追踪 Node.js 的异步上下文。对于一些特殊的异步机制,例如 Web Workers 或跨进程通信,AsyncLocalStorage 可能无法直接使用,需要借助其他的技术手段来传递上下文信息。
  5. Promise 的配合: AsyncLocalStoragePromise 配合使用非常方便。在 Promise 链中,可以使用 asyncLocalStorage.getStore() 访问上下文信息。需要注意的是,在 Promisethencatch 中,上下文信息会保持不变。
  6. 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 等。然后,你可以在 ControllerService 等地方通过依赖注入获取上下文信息。

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 用于获取当前的请求和响应对象。
  • AsyncLocalStorageInterceptor 中,使用 AsyncLocalStorage 存储请求上下文信息。
  • 依赖注入:ControllerService 中,通过依赖注入获取 AsyncLocalStorage 实例,从而访问上下文信息。

总结

AsyncLocalStorage 是一个非常有用的工具,可以帮助你更好地管理 Node.js 中的异步上下文。通过本文,你已经了解了它的基本概念、应用场景、注意事项,以及在原生 Node.js 环境中的实战演练。希望这些内容能帮助你在实际项目中更好地应用 AsyncLocalStorage。记住,多实践,多思考,才能真正掌握这项技术。

如果你在实践过程中遇到任何问题,欢迎随时提出。祝你编码愉快!

老码农的程序人生 Node.jsAsyncLocalStorage异步编程上下文追踪JavaScript

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/7906