iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

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

Day 10|消失的路由:為什麼靜態路徑總是被動態參數給攔截?

  • 分享至 

  • xImage
  •  

解決了前面「啟動期」那些繁瑣的 DI 與模組依賴,今天我們正式進入「路由與請求進入」的新階段。請求進到 NestJS 進行路由匹配時,如果使用預設的 Express Adapter 與路由解析策略,控制器中的宣告順序可能直接影響 API 最後會進入哪一個 Handler。而今天我們要拆解的,正是實務上極易發生的靜態路由被動態路徑攔截的地雷。

這類屬於邏輯層面的盲區,連 TypeScript 和 ESLint 都無法察覺。只要應用程式能正常啟動、且未主動啟用路由衝突檢查,系統就不會給出任何警告——直到請求發出,你才發現它早已被前面的動態路徑給截胡,根本進不到預期的 Handler。

問題怎麼發生?

假設我們正在開發使用者相關的 API,初期需求是提供一支給前端查詢特定 ID 的路由(/users/:id)。隨著功能迭代,我們後來又補上了一支查詢當前登入者個人資料的路由(/users/profile)。

這兩支路由共用的 UsersService 如下,分別回傳個人資料與指定 ID 的使用者資料:

import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
  getProfile() {
    return {
      name: 'John Doe',
      email: 'john.doe@example.com',
      membership: 'Gold',
      message: 'This is your profile information.',
    };
  }

  getUserById(userId: string) {
    return {
      id: userId,
      name: `User ${userId}`,
      age: 25,
      email: `user${userId}@example.com`,
      message: 'This is the information for user with ID.',
    };
  }
}

若是照著需求的順序開發,很自然會寫出這樣的排列:

import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from '../users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // 地雷:動態路由宣告在前,會把 'profile' 當作 :id 吃掉
  @Get(':id')
  getUserById(@Param('id') userId: string) {
    return this.usersService.getUserById(userId);
  }

  @Get('profile')
  getProfile() {
    return this.usersService.getProfile();
  }
}

開發當下你可能會預期:

「profile 是一個很明確的字串,NestJS 應該能把請求正確導到對應的路由吧?」

但實際發出請求後,結果完全不是這樣:

GET /users/profile

拿到的回應是:

{
  "id": "profile",
  "name": "User profile",
  "age": 25,
  "email": "userprofile@example.com",
  "message": "This is the information for user with ID."
}

'profile' 這個字串被當作 :id 的值傳進去了,getProfile() 根本不會被執行。

根因:NestJS 預設的路由註冊順序造成遮蔽

造成這個現象的根本原因,在於 NestJS 預設的路由註冊與匹配機制:

預設情況下,NestJS 使用 declaration 策略,依照控制器中的宣告順序註冊路由。當搭配預設的 Express Adapter 時,由於 Express 的路由匹配會受到註冊順序影響,因此前面的參數化路由可能會遮蔽後方較具體的靜態路由。

簡單來說,就是「越早宣告,優先權越高」,一旦請求被前面的規則匹配到,NestJS 就不會再往下尋找了。

:id 屬於路由參數,可以匹配所在位置上的任意單一路徑片段,除了數字 123 之外,像 profile 或 settings 這類字串同樣符合條件。

當 @Get(':id') 擺在 @Get('profile') 上方時,GET /users/profile 進來的瞬間就會優先符合 /users/:id 的規則,profile 隨即被解析為 id 參數傳入 getUserById(),排在下方的 @Get('profile') 自然就成了永遠無法抵達的無效 Handler。

排雷指南

解法一:調整宣告順序——靜態路由置於參數化路由之上

最直接且不依賴框架設定的排雷方式,就是調整宣告順序:將具體的靜態路徑優先宣告,讓參數化路徑退居後方。

import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from '../users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // 靜態路由放前面,讓精確匹配先被命中
  @Get('profile')
  getProfile() {
    return this.usersService.getProfile();
  }

  // 動態路由放後面,作為最後的匹配
  @Get(':id')
  getUserById(@Param('id') userId: string) {
    return this.usersService.getUserById(userId);
  }
}

現在當請求進來時:

  • GET /users/profile 會先命中 @Get('profile'),回傳個人資料。
  • GET /users/123 不符合 @Get('profile'),往下匹配到 @Get(':id'),回傳 ID 為 123 的使用者。

僅僅調整宣告順序,兩支 API 就能如期運作。

這種把具體路徑擺前面的排列方式,是最通用的防雷寫法。即使未來切換 HTTP Adapter 或升級框架版本,良好的路由順序依然能維持極高的程式碼可讀性與穩定度。

解法二:啟用 NestJS v12 的 Specificity 解析策略

從 NestJS v12 開始,框架提供了 routeResolutionStrategy 設定,讓我們能夠擺脫「宣告順序決定一切」的限制,改由路由路徑的具體程度(Specificity)來決定註冊的優先順序。

在 main.ts 初始化應用程式時,可以傳入此設定:

const app = await NestFactory.create(AppModule, {
  routeResolutionStrategy: 'specificity',
});

為了確保向下相容,NestJS v12 的預設值依然是 'declaration'(亦即維持依宣告順序註冊)。因此必須像這樣顯式指定為 'specificity',這套按路徑專一程度排序的機制才會生效。

啟用 'specificity' 後,NestJS 會優先註冊較具體的路由,優先權如下:

https://ithelp.ithome.com.tw/upload/images/20260924/20184306sR3825fpzF.png

在此策略下,即使控制器維持著原本的排列:

@Controller('users')
export class UsersController {
  @Get(':id')
  getUserById(@Param('id') userId: string) {
    return this.usersService.getUserById(userId);
  }

  @Get('profile')
  getProfile() {
    return this.usersService.getProfile();
  }
}

當 GET /users/profile 請求進來時,系統依然會精準命中 /users/profile,不會再被 /users/:id 攔截。

💡 補充說明:使用 routeConflictPolicy 提前發現路由衝突
除了改變匹配優先序外,NestJS v12 還同步推出了 routeConflictPolicy。它能在應用程式啟動階段檢查可能互相衝突的路由,一旦發現潛在的路由遮蔽風險,就能提早預警:

const app = await NestFactory.create(AppModule, {
  routeConflictPolicy: {
    shadow: 'warn',
  },
});

需注意的是 routeConflictPolicy 本身不會改變路由的比對結果,它的核心價值在於把「排雷防線」提前到了應用程式啟動階段。

總結

  1. 預設路由採「先宣告優先」原則:NestJS 在預設的 declaration 策略與 Express Adapter 下,會嚴格依據控制器內的宣告順序註冊路由,一旦找到首個符合條件的路徑即停止搜尋。
  2. 首選排雷法則「靜態在上、參數化在下」:調整宣告順序是最直覺、跨 Adapter 且不依賴框架額外設定的最佳實務,將參數化路由作為同層級的最後防線。
  3. 可善用路由策略:在 NestJS v12 專案中,可透過設定 routeResolutionStrategy: 'specificity' 改由路徑具體程度決定優先序,並搭配 routeConflictPolicy 在應用程式啟動時提早偵測路由遮蔽風險。

參考資源


上一篇
Day 9|全域機制的隱形陷阱:為什麼用 app.useGlobal*(new ...) 會讓 DI 斷掉?
下一篇
Day 11|跨不過的牆:CORS 設定明明開了,前端卻還是拿不到回應?
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言