iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 9 篇

Day 9|全域機制的隱形陷阱:為什麼用 app.useGlobal*(new ...) 會讓 DI 斷掉?

  • 分享至 

  • xImage
  •  

在 Day 3 中,我們拆解過手動 new Service() 如何讓實例逃離 Nest Container 的管轄。當時我們關注的是 Service 與 Controller 之間的依賴;然而到了應用程式全域入口,這個問題常以更隱晦的方式重演。

不論是 Pipe、Interceptor、Filter 還是 Guard,我們經常會看到類似這樣的註冊方式:

// main.ts
app.useGlobalGuards(new RolesGuard(...));

路由確實都被保護了,Guard 也能正常執行,看起來沒有任何問題。但這行程式碼藏著一個容易被忽略的邊界:RolesGuard 是我們自己 new 出來的。Nest 只是接收並使用這個已經建立完成的實例,它本身並不在 Nest Container 的管理之下。

當 Guard 開始依賴 Reflector、UserRolesService 或其他 provider,main.ts 就得自己從容器取出這些依賴,再親手組裝 RolesGuard。

今天我們將以 RolesGuard 進行實作示範,深入探討這類全域元件在架構上的核心問題:全域元件的建立與組裝責任,到底應該歸誰?

問題怎麼發生?

我們透過一個簡化的角色授權流程來做示範:

https://ithelp.ithome.com.tw/upload/images/20260923/20184306UTQsJdCznp.png

為了讓範例更貼近實務,這裡把認證與授權拆開:

  • FakeAuthenticationMiddleware:模擬 JWT 或 session 已完成驗證,只負責把使用者 ID 放進 request。
  • RolesGuard:根據路由上的 metadata 與使用者目前的角色,決定是否放行。
  • UserRolesService:用記憶體資料模擬實務上的角色查詢。

資料固定有兩個使用者:

x-user-id 角色
1 user
2 user、admin

UserRolesService 模擬角色查詢:

@Injectable()
export class UserRolesService {
  private readonly roles = new Map<number, Role[]>([
    [1, [Role.User]],
    [2, [Role.User, Role.Admin]],
  ]);

  getRoles(userId: number): Role[] {
    return this.roles.get(userId) ?? [];
  }
}

AppController 提供兩支路由,/profile 沒有角色限制,/admin 則透過自訂的 @Roles(Role.Admin) 宣告只有管理員可以進入:

@Controller()
export class AppController {
  @Get('profile')
  getProfile(): string {
    return 'No specific role required';
  }

  @Roles(Role.Admin)
  @Get('admin')
  getAdmin(): string {
    return 'Admin only';
  }
}

接著是今天真正的主角 RolesGuard。它的建構子包含兩個依賴,分別負責查詢路由需求與使用者權限:

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly userRolesService: UserRolesService,
  ) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles) {
      return true;
    }

    const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
    const userId = request.user?.id;

    if (userId === undefined) {
      return false;
    }

    const userRoles = this.userRolesService.getRoles(userId);
    return requiredRoles.some((role) => userRoles.includes(role));
  }
}

前置情境準備完成,現在我們在 main.ts 把 RolesGuard 掛成全域 Guard:

app.useGlobalGuards(
  new RolesGuard(app.get(Reflector), app.get(UserRolesService)),
);

實際發送請求也完全正常:

# 一般使用者進入 /profile (允許)
curl -i http://localhost:3000/profile -H 'x-user-id: 1'
# HTTP/1.1 200 OK

# 一般使用者進入 /admin (阻擋)
curl -i http://localhost:3000/admin -H 'x-user-id: 1'
# HTTP/1.1 403 Forbidden

# 管理員進入 /admin (允許)
curl -i http://localhost:3000/admin -H 'x-user-id: 2'
# HTTP/1.1 200 OK

測試一路綠燈,存取控制也都正確。但這恰恰是陷阱所在:問題從來不是 canActivate() 的業務邏輯,而是實例建立的責任邊界。

根因:手動建立讓 Guard 脫離了容器的管轄

正如在 Day 3 所討論的,@Injectable() 只是為類別留下 metadata,並不會攔截或改寫 JavaScript 原生的 new。

重新拆解這行程式: new RolesGuard(app.get(Reflector), app.get(UserRolesService)),雖然 Reflector 與 UserRolesService 是透過 app.get() 從容器取得,但 RolesGuard 本身卻是手動建立的。整條依賴鏈的建立過程因此被拆成兩半:

Nest Container
├── Reflector
└── UserRolesService

main.ts
└── RolesGuard
    ├── 手動接上 Reflector
    └── 手動接上 UserRolesService

useGlobalGuards() 接收到的是一個已經建立完成的 Guard 實例。它只負責將這個實例套用到全域路由,不負責解析 constructor 並將實例納入 Nest Container 管理。

當未來 RolesGuard 隨業務成長需要注入更多依賴(如 AuditService、PermissionService、FeatureFlagService):

app.useGlobalGuards(
  new RolesGuard(
    app.get(Reflector),
    app.get(UserRolesService),
    app.get(AuditService),
    app.get(PermissionService),
  ),
);

此時 RolesGuard 本身不受 Nest Container 管理,因此無法直接套用 Nest 對該 Guard 的作用域與生命週期鉤子(lifecycle hooks)等機制。需要注意的是,透過 app.get() 取得的其他依賴仍然是容器管理的實例。

排雷指南:把建立責任交還給 Nest

解法一:讓容器建立,再交給 useGlobalGuards()

首先將 RolesGuard 註冊進模組的 providers:

@Module({
  controllers: [AppController],
  providers: [UserRolesService, RolesGuard],
})
export class Day09GlobalEnhancerDiModule {}

接著在 main.ts 中,不要自己 new,而是透過 app.get() 取得 Nest 已經建立好並完成組裝的實例:

app.useGlobalGuards(app.get(RolesGuard));

這個改動讓 RolesGuard 的實例建立交還給容器管轄。不過,main.ts 仍然需要手動將其取出並宣告為全域元件。

更重要的是,app.get() 僅適用於單例(Singleton Scope)。若 Guard 或其依賴鏈包含了請求作用域的 provider,app.get() 便會無法解析,必須改用 moduleRef.resolve()。

解法二:使用 APP_GUARD(推薦)

為了讓「全域 Guard」這項組裝規則徹底回歸模組系統,Nest 提供了 APP_GUARD 這個專用 token:

import { APP_GUARD } from '@nestjs/core';

@Module({
  controllers: [AppController],
  providers: [
    UserRolesService,
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class Day09GlobalEnhancerDiModule {}

這段宣告告訴 Nest:

  1. provide: APP_GUARD:這是一個應用程式層級的全域 Guard。
  2. useClass: RolesGuard:由 Nest Container 負責解析 RolesGuard 的依賴並建立實例。

此時 main.ts 回歸單純的啟動職責,不需要處理 Guard 的建立與其依賴關係:

async function bootstrap() {
  const app = await NestFactory.create(Day09GlobalEnhancerDiModule);
  await app.listen(process.env.PORT ?? 3000);
}

未來即使 RolesGuard 新增再多依賴,只要該依賴在 module 作用域內可見,Nest 都會自動完成注入,完全不需改動 main.ts。

⚠️ 注意:切勿重複註冊
改用 APP_GUARD 後,務必移除原本在 main.ts 裡的 app.useGlobalGuards()。否則同一個 Guard 會被觸發兩次,造成重複查詢權限資料庫、重複紀錄日誌等不必要的效能損耗與副作用。

實測對比:行為完全一致,改變的是管理權

註冊方式 實例建立者 容器自動注入 宣告全域位置
useGlobalGuards(new RolesGuard(...)) main.ts ❌ main.ts
useGlobalGuards(app.get(RolesGuard)) Nest Container ✅ main.ts
APP_GUARD + useClass Nest Container ✅ module 設定

常見誤解:看到 new 就代表寫錯嗎?

除了 Guard,這個原則同樣適用於全域 Pipe (APP_PIPE)、Interceptor (APP_INTERCEPTOR) 與 Filter (APP_FILTER)。

但這並不代表專案中出現 new 就是錯誤。例如常見的全域 ValidationPipe:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    transform: true,
  }),
);

在這裡手動 new ValidationPipe(...) 是完全合理的。因為它只需要傳入靜態的設定選項(options),內部並沒有依賴任何由 Nest Container 管理的 provider(例如 UserService 或 ConfigService)。

真正的判斷關鍵不是「有沒有看到 new 」,而是評估 「這個全域元件是否需要 Nest Container 帶來的價值(如 DI、作用域、生命週期鉤子)?」

總結

  1. useGlobalGuards() 只負責註冊,不負責建立:傳入手動 new 出來的實例,並不會讓該實例自動納入 Nest Container 的管理。
  2. APP_GUARD 讓全域宣告回歸模組系統:它將 Guard 的實例建立、依賴解析與全域註冊規則,統一收斂至模組設定中,保持 main.ts 職責單純。
  3. 區分「業務邏輯」與「元件管理」:手動 new 的 Guard 授權邏輯雖能正常執行,但會讓 main.ts 承擔手動組裝依賴的責任,並使該全域元件無法直接使用容器的生命週期管理與依賴注入機制。
  4. 依據是否需要容器能力選擇建立方式:若全域元件僅接收靜態設定(如常見的 ValidationPipe),手動 new 是簡潔且合理的做法;若需要注入其他 provider 或依賴容器生命週期,則建議將元件交由 Nest Container 建立與管理。

參考資料


上一篇
Day 8|蔓延的病毒:Scope.REQUEST 是如何悄悄吞噬你的效能?
下一篇
Day 10|消失的路由:為什麼靜態路徑總是被動態參數給攔截?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言