1. 程式人生 > 前端設計 >什麼是 CLS?在瀏覽器和 Node.js 中實現 CLS

什麼是 CLS?在瀏覽器和 Node.js 中實現 CLS

在寫 Flutter 和 Serverless 查資料的時候,在某個部落格裡看到了 CLS 的相關內容,感覺其實是個很不錯的軟體工程的解耦想法,於是儲存了下來。今天回過頭來仔細研究了一下並決定給自己留下一些記錄。

場景

無論是在瀏覽器,還是在服務端 Node.js,我們經常會碰到打點上報,追蹤錯誤這樣的需求,即使不對特定使用者進行追蹤,我們也會給某個 session 分配唯一 ID 以在 log / analytics 介面能夠看到使用者的完整行為,對於產品分析與錯誤再現是十分重要的。

假設我們需要寫一個 error handling ,這個 error handling 會 hold 住所有的請求的異常,我們如何分辨哪個錯誤是哪個請求造成的呢?

log.error("Error occured",req);
複製程式碼

那麼這個 error handling 就跟 req 耦合了

假設我們需要追蹤某個錯誤,是哪個 user 產生的,又或者是哪個錯誤,user 幹了什麼導致的?

log.info("User has done xxx",user);
log.error("Error occured by",user);
複製程式碼

於是跟 user 也深深的耦合了。

單單這樣的例子好像沒有什麼大問題,不過多兩個引數嘛。但寫過大型應用的同學,後期不斷增加功能的時候,你一定寫過那種長長的引數列表的函式,又或者是好幾百行的一個函式,實在是太不優雅,重構起來也太難。

嘗試解決

函式如果是同步的,那麼我們可以直接掛到全域性變數(某個物件)下

const global = {};
$("button").click((event) => {
  global.event = event;
  log("button clicked");
});

function log(...args) {
  console.log(global.event,...args); // { x: xxx,y: xxx,target: xxx } 'button clicked'
  // other logic
}
複製程式碼

顯然這在非同步中行不通

const global = {};
$("button"
).click((event) => { global.event = event; setTimeout(() => { log("button clicked"); },1000); }); function log(...args) { console.log(global.event,...args); // other logic } 複製程式碼

你會發現列印的 global.event 全變成了同一個物件

我們需要能夠從始至終在同一個非同步呼叫鏈中一個持續化的儲存, 又或者是我們需要能夠辨識當前的非同步函式他的唯一辨識符,以和同樣內容的非同步函式但並不是本身的執行的這個作區分。

CLS 登場

在其他語言中,有一個叫做 Thread-local storage 的東西,然而在 Javascript 中,並不存在多執行緒這種概念(相對而言,Web Worker 等與主程序並不衝突),於是 CLS ,Continuation-local Storage,一個類似於 TLS,得名於函數語言程式設計中的 Continuation-passing style,旨在鏈式函式呼叫過程中維護一個持久的資料。

瀏覽器的解決方案 Zone.js

先看看是怎麼解決的

$('button').click(event => {
  Zone.current.fork({
    name: 'clickZone',properties: {
      event
    }
  }).run(
    setTimeout(() => {
      log('button clicked');
    },1000);
  );
});

function log(...args) {
  console.log(global.event,...args);
  // other logic
}
複製程式碼

Zone.js 是 Angular 2.0 引入的,當然它的功能不只是提供 CLS,他還有其他相關 API。

一個並不完美的解決方案

我們試著思考一下, Zone.js 是如何做到這些的。如果瀏覽器沒有提供非同步函式執行環境的唯一標識,那麼只剩下唯一的一條路,改寫所有會產生非同步的函式,包裝了一層後也就能加入hook了。

我嘗試自己寫了一下 zone-simulate.js

const Zone = {
  _currentZone: {},get current() {
    return {
      ...this._currentZone,fork: (zone) => {
        this._currentZone = {
          ...this._currentZone,...zone,};
        return this;
      },set: (key,value) => {
        this._currentZone[key] = value;
      },};
  },};

(() => {
  const _setTimeout = global.setTimeout;
  global.setTimeout = (cb,timeout,...args) => {
    const _currentZone = Zone._currentZone;
    _setTimeout(() => {
      const __after = Zone._currentZone;
      Zone._currentZone = _currentZone;
      cb(...args);
      Zone._currentZone = __after;
    },timeout);
  };
})();

for (let i = 0; i < 10; i++) {
  const value = Math.floor(Math.random() * 100);
  console.log(i,value);
  Zone.current.fork({ i,value });
  setTimeout(() => {
    console.log(Zone.current.i,Zone.current.value);
  },value);
}
複製程式碼

看似好像沒什麼問題,不過

angular with tsconfig target ES2017 async/await will not work with zone.js

瀏覽器中現在並沒有完美的解決方案

我們可以做個實驗,在 console 裡敲下如下程式碼

const _promise = Promise;
Promise = function () { console.log('rewrite by ourselves') };
new Promise(() => {}) instanceof Promise
// rewrite by ourselves
// true

async function test() {}
test() instanceof Promise
// false
test() instanceof _promise
// true

async function test() { return new Promise() }

test() instanceof Promise
// rewrite by ourselves
// false
test() instanceof _promise
// rewrite by ourselves
// true
複製程式碼

也就是說瀏覽器會把 async 函式的返回值用原生 Promise 包裝一層,因為是原生語法,也就無法 hook async 函式。 當然我們可以用 transpiler 把 async 函式改寫成 generator 或者 Promise,不過這並不代表是完美的。

Node.js 的解決方案 async_hooks

Node.js 8後出現的 async_hook 模組,到了版本14仍然沒有移去他身上的 Experimental 狀態。以及在剛出現的時候是有效能問題的討論(3年後的今天雖然不知道效能怎麼樣,不過既然沒有移去 Experimental 的標籤,如果追求高效能的話還是應該保持觀望)

雖然沒有移去 Experimental 的狀態,但是穩定性應該沒有什麼太大問題,大量的 Node.js 的追蹤庫 / APM 依賴著 async_hooks 模組,如果有重大問題,應該會及時上報並修復

對於效能問題,不展開篇幅討論,取決於你是否願意花一點點的效能下降來換取程式碼的低耦合。

如何使用

async_hooks 提供了一個 createHook 的函式,他可以幫助你監聽非同步函式的執行時建立以及退出等狀態,並且附帶了這個執行時的唯一辨識id,我們可以簡單地用它來建立一個 CLS。

cls.js

const {
  executionAsyncId,createHook,} = require("async_hooks");

const { writeSync: fsWrite } = require("fs");
const log = (...args) => fsWrite(1,`${args.join(" ")}\n`);

const Storage = {};
Storage[executionAsyncId()] = {};

createHook({
  init(asyncId,_type,triggerId,_resource) {
    // log(asyncId,Storage[asyncId]);
    Storage[asyncId] = {};
    if (Storage[triggerId]) {
      Storage[asyncId] = { ...Storage[triggerId] };
    }
  },after(asyncId) {
    delete Storage[asyncId];
  },destroy(asyncId) {
    delete Storage[asyncId];
  },}).enable();

class CLS {
  static get(key) {
    return Storage[executionAsyncId()][key];
  }
  static set(key,value) {
    Storage[executionAsyncId()][key] = value;
  }
}


// --- seperate line ---

function timeout(id) {
  CLS.set('a',id)
  setTimeout(() => {
    const a = CLS.get('a')
    console.log(a)
  },Math.random() * 1000);
}

timeout(1)
timeout(2)
timeout(3)
複製程式碼

Node.js 13 後的官方實現

在社群中已經有了那麼多優秀實現的前提下,Node.js 13.10 後新增了一個 AsyncLocalStorage 的 API

nodejs.org/api/async_h…

實際上他已經是開箱可用的 CLS 了

const {
  AsyncLocalStorage,} = require("async_hooks");

const express = require("express");
const app = express();

const session = new AsyncLocalStorage();

app.use((_req,_res,next) => {
  let userId = Math.random() * 1000;
  console.log(userId);
  session.enterWith({ userId });
  setTimeout(() => {
    next();
  },userId);
});

app.use((_req,res,next) => {
  const { userId } = session.getStore();
  res.json({ userId });
});

app.listen(3000,() => {
  console.log("Listen 3000");
});


const fetch = require('node-fetch')

new Array(10).fill(0).forEach((_,i) => fetch('http://localhost:3000/test',{
  method: 'GET',}).then(res => res.json()).then(console.log))

// Output:
// Listen 3000
// 355.9573987560112
// 548.3773445851497
// 716.2437886469793
// 109.84756385607896
// 907.6261832949347
// 308.34659685842513
// 407.0145853469649
// 525.820449114568
// 76.91502437038133
// 997.8611964598299
// { userId: 76.91502437038133 }
// { userId: 109.84756385607896 }
// { userId: 308.34659685842513 }
// { userId: 355.9573987560112 }
// { userId: 407.0145853469649 }
// { userId: 525.820449114568 }
// { userId: 548.3773445851497 }
// { userId: 716.2437886469793 }
// { userId: 907.6261832949347 }
// { userId: 997.8611964598299 }
複製程式碼

參考