程式碼走讀:一個 decorator 從標記到落庫的旅程
後台每個寫入端點掛著 @Audit(...) 就會自動留稽核紀錄。decorator 看起來像魔法,拆開只有三站:標記 → 讀取 → 執行。
第一站:decorator 只是貼標籤
audit.decorator.tsts
export const AUDIT_METADATA_KEY = 'audit:options';
export const Audit = (options: AuditOptions) =>
SetMetadata(AUDIT_METADATA_KEY, options);
就這麼短。SetMetadata 把 options 物件掛在 method 的 metadata 上——decorator 本身不做任何事,它只是把「這個端點要稽核、參數如此」寫在標籤上,等別人來讀。
第二站:interceptor 讀標籤決定要不要動
audit.interceptor.ts(節錄)ts
intercept(context: ExecutionContext, next: CallHandler) {
const options = this.reflector.get<AuditOptions>(
AUDIT_METADATA_KEY, context.getHandler(),
);
if (!options) return next.handle(); // 沒貼標籤=零成本路過
const response = context.switchToHttp().getResponse<Response>();
response.on('finish', () => {
if (response.statusCode >= 400) return; // 只記成功的動作
void this.write(options, ...);
});
return next.handle();
}
兩個值得停下來的細節:
- 掛在
response.on('finish')——不是 controller 回傳就記,而是回應完整送出、狀態碼定案後才記。例外路徑會先被全域 filter 改寫成 4xx/5xx 才寫 response,此時檢查statusCode >= 400才可靠。 void this.write(...)——稽核寫入是 fire-and-forget,它的耗時不算在請求的回應時間裡,它的失敗(自己 try/catch 吞掉記 log)也絕不拖垮業務請求。
第三站:疊層順序是隱形合約
這個 interceptor 需要讀 controller 的原始回傳值(有些端點的操作者要從回傳內容解析,比如註冊——請求發生時還沒有登入者)。而全域信封 interceptor會把回傳值包成 { data }——所以稽核必須疊在信封的內側,才能在包裝前看到原貌。這種順序依賴文件裡不寫,三個月後重構的人必踩——所以它被寫進了根文件的慣例區。
讀到這裡若有一點收穫,替這顆種子澆一次水——匿名、一人一次。