Node.js AsyncLocalStorage:解決非同步環境下的「追蹤」痛點

在開發 Node.js 後端應用時,我們經常會遇到一個棘手的問題:如何在不逐層傳遞參數 (Prop Drilling) 的情況下,讓深層的函式也能知道目前的 Request 資訊?

例如,你想在各處的 Log 中標記目前的 requestId,或是讓資料庫底層自動帶入目前的 userId。傳統做法是把這些資訊封裝成一個物件,從 Controller 一路傳到 Service 再傳到 Repository,但這會讓程式碼變得極難維護。

AsyncLocalStorage 就是為了解決這個問題而生的原生解決方案。

核心概念:非同步的 Thread Local

在多執行緒的語言(如 Java)中,我們有 ThreadLocal。Node.js 雖然是單執行緒,但透過非同步勾子 (Async Hooks) 機制,AsyncLocalStorage 讓我們能在某個非同步執行的「生命週期」中,共享一個獨立的儲存空間。

實戰開發:建立全鏈路日誌 (Tracing)

下面我們實作一個簡單的機制:在進入 Request 時生成一個 id,並確保後續所有非同步操作都能讀取到它。

1. 建立 Store 模組

// context.js
const { AsyncLocalStorage } = require('node:async_hooks');

// 建立一個存取空間實例
const als = new AsyncLocalStorage();

module.exports = als;

2. 在入口處啟動生命週期

我們可以使用 Express 中間件(或是原生 HTTP Server 的處理函式)來啟動 als.run()

const http = require('http');
const als = require('./context');
const { v4: uuid } = require('uuid'); // 假設使用 uuid 生成 ID

http
  .createServer((req, res) => {
    const requestId = uuid();

    // 透過 run() 啟動一個隔離的上下文,第一個參數是資料,第二個是後續執行的邏輯
    als.run({ requestId }, () => {
      handleRequest(req, res);
    });
  })
  .listen(3000);

3. 在任何深層函數中讀取

不管中間經過了多少個 awaitsetTimeout,只要是在該 run() 的回呼路徑內,都能讀到正確的值。

// logger.js
const als = require('./context');

function log(message) {
  // 透過 getStore() 獲取當前上下文的資料
  const store = als.getStore();
  const id = store ? store.requestId : 'system';

  console.log(`[${id}] ${message}`);
}

// 某個深層的 Service
async function deepService() {
  log('正在處理業務邏輯...');
}

進階應用:資料庫交易管理

AsyncLocalStorage 也常用於自動化資料庫交易。當你進入一個 Transaction 時,將 Transaction 物件存入 ALS,底層的 Repository 就能自動檢查當前是否有進行中的交易,並自動加入,而不需要開發者手動傳遞 Client 物件。

注意事項與效能

  1. 僅限當前鏈路:ALS 的資料只在 run() 的回呼函數及其衍生的非同步操作中有效。如果你主動切斷了非同步鏈路(例如使用 process.nextTick 或某些不支援非同步追蹤的舊套件),資料可能會遺失。
  2. 記憶體管理:當 run() 指令碼結束後,儲存空間會被自動回收,不需要擔心 Memory Leak。
  3. 效能考量:Node.js 核心對 ALS 進行了極大的優化,對極大部分的 Web 應用程式來說,效能損耗幾乎可以忽略不計。

總結

  1. AsyncLocalStorage 讓你在非同步流程中擁有「全域變數」的便利性。
  2. 最常用於 Request Tracing (RequestId)User Context 以及 Database Transactions
  3. 它是讓 Node.js 程式碼變得簡潔、解耦的核心利器。