Custom decorator và Reflector trong NestJS: vì sao @Public() trên controller không có tác dụng Trung cấp
Thời gian đọc ước tính: 9 phút
Vấn đề
Phần tiêu đề “Vấn đề”API đơn hàng có một guard xác thực đăng ký global. GET /catalog phải công khai, GET /orders/101 cần role support, còn POST /orders/101/refund chỉ cho admin. Handler đang đọc req.user trực tiếp ở hàng chục chỗ.
Đội thêm @Public() lên CatalogController, nhưng request không token vẫn nhận 401. Đặt @Roles(['support']) ở class và @Roles(['admin']) ở refund() thì không rõ user sam (role support) có được hoàn tiền hay không.
Bài trả lời: decorator lưu thông tin ở đâu, guard đọc lại thế nào, và handler ghi đè hay gộp với class?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
Cờ bật/tắt (@Public()), method thắng class |
getAllAndOverride(key, [handler, class]) |
Đọc cả handler và class |
| Danh sách quyền, method cụ thể hơn class | getAllAndOverride |
Method thay hẳn yêu cầu của class |
| Danh sách cộng dồn (tag, scope bổ sung) | getAllAndMerge |
Kiểm tra mảng rỗng, không dùng như boolean |
| Decorator metadata mới | Reflector.createDecorator<T>() |
Nest ≥ 10.2; key tự sinh, không đụng nhau |
| Lấy user trong handler | createParamDecorator → @CurrentUser() |
Guard đã gắn req.user trước đó |
| Gộp nhiều decorator hay đi cùng nhau | applyDecorators(...) |
Không có logic điều kiện bên trong |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Với experimentalDecorators (cấu hình NestJS dùng), decorator chỉ là một hàm. TypeScript gọi nó một lần, lúc class được định nghĩa, tức lúc file được nạp, không phải mỗi request. Demo in dòng này trước khi có app nào được tạo:
[định nghĩa class] decorator chạy cho OrdersController.get[định nghĩa class] xong, chưa có app nào được tạoVì vậy decorator không biết request hay user. Việc của nó là ghi metadata: SetMetadata(key, value) gọi Reflect.defineMetadata (thư viện reflect-metadata). Đặt ở method thì metadata gắn vào hàm handler (descriptor.value); đặt ở class thì gắn vào constructor.
| Thành phần | Chạy lúc | Việc |
|---|---|---|
SetMetadata / Reflector.createDecorator |
Định nghĩa class | Ghi key → value lên handler hoặc class |
Reflector (provider của @nestjs/core) |
Mỗi request, trong guard/interceptor | Đọc metadata theo key |
createParamDecorator |
Định nghĩa: đăng ký tham số; request: chạy factory | Tạo giá trị tham số handler |
applyDecorators |
Định nghĩa class | Gọi lần lượt nhiều decorator |
Các cách đọc thường dùng của Reflector. Ví dụ dưới là kết quả thật với refund() mang [admin], class mang [support]:
| Lời gọi | Kết quả | Quy tắc |
|---|---|---|
get(Roles, handler) |
['admin'] |
Chỉ một target |
get(Roles, class) |
['support'] |
Chỉ một target |
getAllAndOverride(Roles, [handler, class]) |
['admin'] |
Giá trị khác undefined đầu tiên theo thứ tự mảng |
getAllAndMerge(Roles, [handler, class]) |
['admin', 'support'] |
Mảng nối nhau, object gộp nông ({...a, ...b}) |
Thứ tự [handler, class] là quy ước, không phải mặc định ngầm. Đảo thành [class, handler] thì class thắng.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Hình dưới trả lời: metadata được ghi lúc nào, và với sam gọi refund(), guard quyết định dựa trên giá trị nào.
defineMetadataNạp file controller: decorator chạy, ghi roles [support] lên class và [admin] lên refund().
| Bước | Metadata | Guard thấy | Kết quả |
|---|---|---|---|
| 1. Nạp file | class [support], refund [admin] |
Chưa có guard | |
| 2–3. Bootstrap, request đến | Không đổi | req.user = sam (roles [support]) |
|
| 4–5. Override | Không đổi | [admin] (dừng ở handler) |
403 |
| 6–7. Merge | Không đổi | [admin, support] |
200 |
Metadata không đổi theo request. Muốn quyết định theo user thì guard kết hợp metadata với req.user, không ghi ngược vào metadata.
Code decorator trong demo:
export const IS_PUBLIC_KEY = 'isPublic';export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);export const Roles = Reflector.createDecorator<string[]>();export const AdminOnly = () => applyDecorators(Roles(['admin']), HttpCode(200));
export const CurrentUser = createParamDecorator((data: keyof User | undefined, ctx: ExecutionContext) => { const req = ctx.switchToHttp().getRequest<{ user?: User }>(); return data ? req.user?.[data] : req.user;});Guard đọc lại:
// AuthGuard (APP_GUARD)const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);if (isPublic) return true;
// RolesGuard: truyền chính decorator, kiểu trả về là string[]const required = this.reflector.getAllAndOverride(Roles, [ctx.getHandler(), ctx.getClass()]);Reflector.createDecorator<string[]>() (thêm ở v10.2.0, PR #12237) trả về decorator có thuộc tính KEY. Nếu không truyền key, Nest sinh chuỗi ngẫu nhiên 21 ký tự, nên hai module không thể trùng key. Truyền decorator vào reflector.get(Roles, ...) thì TypeScript suy ra string[], không cần generic thủ công.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”ExecutionContext.switchToHttp().getRequest()trả request của adapter (Express mặc định). Với GraphQL hay microservice, context khác;getHandler()vàgetClass()vẫn dùng được.- Pipe áp dụng cho tham số custom như
@Body(). NhưngValidationPipemặc định bỏ qua chúng; cầnvalidateCustomDecorators: true. createParamDecoratornhận pipe saudata:@CurrentUser('id', ParseIntPipe), hoặc pipe ở vị trí đầu nếu không códata.createDecoratorgọi không tham số thì lưu{}(code:value ?? {}), truthy. Cờ boolean nên truyền giá trị rõ ràng hoặc dùngSetMetadata(key, true).getAllAndMergevới một giá trị nguyên thủy duy nhất trả về mảng ([true]); không có giá trị nào thì trả[].- Guard global qua
APP_GUARDđược DI injectReflector. Đăng ký bằngapp.useGlobalGuards(new AuthGuard(...))thì phải tự truyềnReflector(xem DI container).
@CurrentUser() chạy ở bước pipe, sau mọi guard. Hình dưới cho thấy vì sao guard không dùng được decorator này:
Đang tải diagram…
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo orders-demo.ts (NestJS 11.2.6) dựng app thật, listen(0), gọi bằng fetch. Token là tên user (chỉ cho demo):
== AuthGuard đọc handler + class, RolesGuard dùng getAllAndOverrideGET /catalog - -> 200 {"items":["sku-1","sku-2"]}GET /orders/health - -> 200 {"ok":true}GET /orders/101 - -> 401 {"message":"Unauthorized","statusCode":401}GET /orders/101 alice -> 403 {"message":"cần một trong [support]",...}GET /orders/101 sam -> 200 {"id":"101","viewedBy":"sam"}POST /orders/101/refund sam -> 403 {"message":"cần một trong [admin]",...}POST /orders/101/refund root -> 200 {"id":"101","refundedBy":"root"}GET /orders/me sam -> 200 {"id":2}GET /orders/me ghost -> 400 {"message":["id must be an integer number"],...}
== RolesGuard dùng getAllAndMergePOST /orders/101/refund sam -> 200 {"id":"101","refundedBy":"sam"}
== Bug: AuthGuard chỉ đọc handlerGET /catalog - -> 401 {"message":"Unauthorized","statusCode":401}GET /orders/health - -> 200 {"ok":true}Ba điều đọc ra:
@Public()ở class (/catalog) và ở method (/orders/health) đều có tác dụng chỉ khi guard đọc[handler, class].- Cùng metadata, override cho
sam403, merge cho 200. Ngữ nghĩa quyền nằm ở cách guard đọc, không ở decorator. @CurrentUser(new ValidationPipe({ validateCustomDecorators: true }))chặn claim hỏng (id: 'x') bằng 400.
npx --prefix examples ts-node examples/src/nestjs-custom-decorators/orders-demo.tsTrade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Quy tắc truy cập nằm cạnh route, đọc code là thấy route nào public, cần role gì.
-
Một guard global xử lý mọi route; handler không lặp
if (!req.user). -
createDecoratorcho kiểu chặt và key không trùng. -
@CurrentUser()tách handler khỏi Expressreq, dễ test.
< slot="cons"> >
-
Quy tắc ẩn: thiếu decorator là route bị chặn (hoặc lộ) mà không có lỗi compile.
-
Ngữ nghĩa override/merge do guard quyết định; đổi guard là đổi quyền của mọi route.
-
Metadata tĩnh, không diễn đạt được quyền theo dữ liệu (đơn của user này).
-
Decorator chồng nhiều lớp khó debug khi không biết thứ tự đọc.
Nhược điểm
Quyền theo dữ liệu (user chỉ xem đơn của mình) vẫn phải kiểm tra trong service, xem Secure coding.
Checklist production
Phần tiêu đề “Checklist production”- Guard global đọc metadata bằng
[ctx.getHandler(), ctx.getClass()], có test cho@Public()ở cả class và method. - Chọn override hoặc merge theo nghĩa nghiệp vụ, ghi rõ trong comment của guard.
- Dùng
Reflector.createDecorator<T>()cho decorator mới; key string thì đặt tiền tố module (orders:roles). - Mặc định từ chối: route không có metadata vẫn phải qua xác thực.
- Bật
validateCustomDecoratorsnếu muốnValidationPipekiểm tra giá trị từ@CurrentUser(). - Gom bộ decorator lặp lại (
Roles+HttpCode+ Swagger) bằngapplyDecorators. - Có test e2e liệt kê route public để phát hiện route bị lộ khi thêm decorator mới.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi phổ biến nhất là guard global chỉ đọc handler, đúng như phần “Bug” của demo:
@Public() trên controller bị bỏ qua
Hay gặp
const isPublic = this.reflector.get<boolean>(IS_PUBLIC_KEY, ctx.getHandler());Nên viết
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);Vì sao
@Public() trên class ghi metadata vào constructor, không vào từng method. Đọc mỗi handler thì /catalog vẫn 401.
Lỗi thứ hai nguy hiểm hơn vì nó mở route thay vì chặn:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Decorator chạy mỗi request | Đặt logic theo user trong decorator | Decorator chỉ ghi metadata; logic ở guard |
Key 'roles' chỉ module mình dùng |
Thư viện khác ghi đè, guard đọc nhầm | createDecorator hoặc tiền tố key |
Dùng @CurrentUser() trong guard |
Không có: param factory chạy sau guard | Guard đọc req.user |
| Merge an toàn hơn override | Merge mở rộng quyền (sam refund được) |
Chọn theo nghĩa, có test |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Đảo targets thành
[ctx.getClass(), ctx.getHandler()],samgọi refund nhận mã gì với override? - Vì sao
@Public()trên class không có tác dụng khi guard dùngget(..., ctx.getHandler())? - Vì sao
@Roles()từcreateDecoratorở hai module khác nhau không bao giờ đè metadata của nhau? - Guard cần user id. Đọc từ đâu, và vì sao không phải
@CurrentUser()?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Custom decorators — NestJS:
createParamDecorator, pipe với tham số custom,applyDecorators. - Execution context — NestJS:
Reflector,createDecorator, ví dụ override và merge. - Guards — NestJS: RolesGuard đọc metadata.
- Authentication — NestJS: mẫu
@Public()với guard global. - NestJS v10.2.0 release: phiên bản thêm decorator factory có kiểu của Reflector.
- TypeScript — Decorators: thời điểm decorator được gọi.