Decorator pattern: thêm cache, metrics, retry quanh một client, và vì sao thứ tự làm đổi con số Trung cấp
Thời gian đọc ước tính: 11 phút
Vấn đề
Phần tiêu đề “Vấn đề”Checkout gọi PriceService.getPrice(sku), do PriceApiClient cài đặt bằng cách gọi một price API từ xa. API này thỉnh thoảng trả 503, thỉnh thoảng treo. Product yêu cầu ba việc: cache giá 60 giây, retry lỗi tạm thời, và vẽ biểu đồ số call cùng số lỗi.
Nhét cả ba vào PriceApiClient biến một HTTP call thành method 150 dòng. Muốn test cache phải dựng fake HTTP server, còn client thanh toán (PaymentGateway) không dùng lại được phần retry.
Decorator giữ nguyên PriceApiClient và bọc nó bằng các object nhỏ có cùng interface. Câu hỏi khó hơn mà bài này trả lời: xếp các lớp theo thứ tự nào, và mỗi thứ tự đo được cái gì?
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 |
|---|---|---|
| Thêm cache, metrics, retry hay timeout quanh một dependency | Decorator (wrapper cùng interface) | Mỗi lớp test riêng được; wiring nằm một chỗ |
| Cùng một việc cho mọi HTTP route (log request, map response) | Interceptor của NestJS | Việc gắn với request, không gắn với một dependency gọi ra ngoài |
| Interface của client không khớp thứ checkout cần | Adapter trước, decorator đặt trên port | Xem Adapter |
| Cần biết tải và tỉ lệ lỗi phía upstream | Metrics bên trong retry và cache | Đếm từng attempt chạm tới API |
| Cần biết latency và lỗi người dùng thấy | Metrics bên ngoài retry và cache | Đếm số call checkout thực hiện |
| Ngân sách latency cho cả lần gọi | Timeout bên ngoài retry, kèm timeout từng attempt bên trong | Tổng ≥ số attempt × timeout mỗi attempt + backoff, nếu không các attempt sau bị cắt |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Decorator có ba vai:
| Vai | Trong ví dụ | Quy tắc |
|---|---|---|
| Component (interface) | PriceService.getPrice(sku) |
Mọi lớp đều implement |
| Concrete component | PriceApiClient |
Không biết gì về các lớp bọc |
| Decorator | CachedPriceService, MeteredPriceService, RetryingPriceService, TimeoutPriceService |
Giữ một PriceService bên trong và chuyển tiếp lời gọi |
Một decorator trong demo:
class MeteredPriceService implements PriceService { calls = 0; errors = 0; constructor(private readonly inner: PriceService) {}
async getPrice(sku: string): Promise<number> { this.calls++; try { return await this.inner.getPrice(sku); } catch (err) { this.errors++; throw err; } }}Vì mọi lớp đều nhận và trả PriceService, chúng lồng được vào nhau: new Cached(new Metered(new Retrying(new Timeout(api)))).
Refactoring.Guru lưu ý rằng client quyết định cách ghép, và rất khó viết một decorator mà hành vi không phụ thuộc vị trí của nó trong chồng lớp. Vị trí đó là quyết định thiết kế, không phải chi tiết.
Bốn thứ cùng tên “decorator”
Phần tiêu đề “Bốn thứ cùng tên “decorator””| Tên | Bản chất | Chạy khi nào |
|---|---|---|
| Decorator pattern | Object bọc một object khác, cùng interface | Mỗi lần gọi |
@Something của TypeScript |
Hàm áp lên class, method hoặc parameter | Một lần, lúc định nghĩa class |
@Injectable(), @Get() của NestJS |
TypeScript decorator ghi metadata cho Nest đọc | Một lần lúc định nghĩa; Nest đọc metadata lúc bootstrap |
| Interceptor của NestJS | Wrapper quanh route handler, kiểu AOP | Mỗi request |
Cú pháp @ chỉ trùng tên với pattern. Một method decorator @Cacheable() có thể cài đặt pattern bằng cách thay method bằng wrapper, nhưng thứ tự bị giấu trong annotation. TypeScript 5.0 hỗ trợ decorator chuẩn ECMAScript; NestJS 11 và 12 vẫn dựa trên experimentalDecorators, xem Custom decorators & Reflector.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Wiring NestJS trong demo dựng cache(metrics(retry(timeout(api)))). API lỗi ở attempt đầu và thành công ở attempt thứ hai:
getPrice(sku-9)CheckoutService gọi getPrice(sku-9) trên PRICE_SERVICE. Nó chỉ thấy interface PriceService.
Trạng thái sau từng bước:
- Request vào. Cache chưa có
sku-9; chưa có gì tới API. - Metrics đứng trước retry.
calls = 1. Metrics nằm ngoài retry nên sẽ không bao giờ thấy riêng attempt 1. - Attempt 1. Timeout bật đồng hồ 50 ms, API trả 503,
api calls = 1. - Attempt 2. Retry bắt lỗi 503 và gọi lại; API trả
129000,api calls = 2. - Đi ra. Metrics ghi thành công:
calls = 1, errors = 0. Cache lưu giá trong 60 s. - Báo giá lần hai. Cache hit. Không lớp nào bên dưới cache chạy;
api callsvẫn là 2.
Đọc chồng lớp từ trong ra ngoài: lớp trong cùng gần network nhất, lớp ngoài cùng gần checkout nhất. Mỗi lớp chỉ thấy những gì các lớp bên trong cho lọt qua. Metrics ngoài retry không thấy lỗi đã được retry; cache ngoài metrics giấu cache hit khỏi metrics.
Đo tác động của thứ tự
Phần tiêu đề “Đo tác động của thứ tự”Demo chạy cùng các lớp nhưng đảo thứ tự:
1) Metrics outside vs inside retry (API plan: fail, fail, ok) metrics(retry(api)): price 129000 | metrics calls=1 errors=0 | api calls=3 retry(metrics(api)): price 129000 | metrics calls=3 errors=2 | api calls=32) Timeout per attempt vs overall (API plan: slow 100ms, ok; timeout 50ms; 3 attempts) retry(timeout(api)): price 129000 | api calls=2 timeout(retry(api)): error: timeout after 50ms | api calls=1 finished=0 ...80ms later the abandoned call has finished anyway: api calls=1 finished=13) Cache outside vs inside metrics (two requests for the same SKU) cache(metrics(api)): metrics calls=1 cache hits=1 api calls=1 metrics(cache(api)): metrics calls=2 cache hits=1 api calls=1Đang tải diagram…
Ý nghĩa từng kết quả:
- Metrics và retry. Đặt ngoài: thứ checkout trải qua (1 call, không lỗi). Đặt trong: thứ API phải chịu (3 attempt, 2 lỗi). Production thường cần cả hai, dưới hai tên metric khác nhau.
- Timeout và retry. Timeout từng attempt đặt trong retry cho attempt 2 cơ hội thành công. Timeout tổng 50 ms đặt ngoài retry thất bại trước khi kịp retry. Đặt ngân sách tổng lớn hơn số attempt × timeout mỗi attempt cộng backoff.
- Timeout không huỷ call.
finished=1cho thấy call bị bỏ rơi vẫn chạy xong.Promise.racechỉ thôi chờ, không dừng request. Hãy truyềnAbortSignalxuống HTTP client. - Cache và metrics. Cache đặt ngoài giấu hit khỏi metrics: hợp để đo tải upstream, sai lệch nếu muốn đo traffic. Cache ngoài retry cũng có nghĩa cache hit không tốn retry.
Thêm một quy tắc từ code: CachedPriceService chỉ cache kết quả thành công. Nếu cache lưu cả lỗi (negative caching), retry đặt bên ngoài sẽ nhận lại đúng lỗi đã cache ở mọi attempt.
Wiring trong NestJS
Phần tiêu đề “Wiring trong NestJS”Bên dùng inject một token; composition root quyết định thứ tự (trích nest-wiring.ts, đã chạy trong demo):
@Module({ providers: [ { provide: PriceApiClient, useFactory: () => new PriceApiClient(['fail', 'ok']) }, { provide: MeteredPriceService, useFactory: (api: PriceApiClient) => new MeteredPriceService(new RetryingPriceService(new TimeoutPriceService(api, 50), 3)), inject: [PriceApiClient], }, { provide: PRICE_SERVICE, useFactory: (metered: MeteredPriceService) => new CachedPriceService(metered, 60_000), inject: [MeteredPriceService], }, CheckoutService, ],})export class PricingModule {}CheckoutService chỉ xin @Inject(PRICE_SERVICE) và không hề biết có bốn lớp. Các lớp này bọc được cả PaymentGateway, nhưng không thể bọc mù: retry một lần charge cần idempotency key, và không được cache thanh toán.
Decorator, interceptor, Proxy, Adapter
Phần tiêu đề “Decorator, interceptor, Proxy, Adapter”| Decorator | Interceptor NestJS | Proxy | Adapter | |
|---|---|---|---|---|
| Bọc gì | Một dependency (PriceService) |
Một route handler | Một object | Một object |
| Interface | Giữ nguyên | Kết quả handler dạng Observable |
Giữ nguyên | Khác |
| Ai ghép | Client / composition root | Nest, theo scope (global, controller, route) | Thường là chính proxy, kể cả vòng đời object | Composition root |
| Xếp chồng | Có, theo thiết kế | Có, theo thứ tự bind | Làm được, không phải mục đích | Không |
Interceptor hợp với “log mọi request”. Nó không hợp với “retry price API”: checkout có thể gọi API đó hai lần trong một request, hoặc gọi từ cron job không có request nào.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”npx --prefix examples ts-node examples/src/decorator-pattern/price-decorators-demo.tsnpx --prefix examples ts-node examples/src/decorator-pattern/nest-wiring.tsFake API không gọi network; lệnh thứ hai in metrics calls=1 errors=0 | api calls=2, đúng trạng thái ở bước 6 của hình. Production vẫn cần backoff có jitter, deadline, và chỉ retry lỗi retry được.
Trade-offs
Phần tiêu đề “Trade-offs”Khi Decorator có ích
- Mỗi mối quan tâm là một class nhỏ có test riêng, dùng lại được cho client khác.
- Concrete client vẫn chỉ là một HTTP call đơn giản.
- Bật tắt từng lớp ở một file wiring, có thể theo môi trường.
Chi phí
- Hành vi phụ thuộc thứ tự, mà thứ tự nằm xa chỗ gọi.
- Stack trace và debug phải đi qua nhiều lớp mỏng.
- Mỗi lớp phải chuyển tiếp toàn bộ interface; thêm method là sửa tất cả.
- Thiếu
AbortSignaldùng chung, lớp timeout và retry để lại các call mồ côi.
Checklist production
Phần tiêu đề “Checklist production”- Viết chồng lớp ở một composition root, kèm comment đọc từ trong ra ngoài.
- Quyết định mỗi vị trí đo metric gì; đặt tên khác nhau cho attempt và call.
- Timeout từng attempt đặt trong retry, deadline tổng đặt ngoài, tổng lớn hơn tổng các attempt.
- Chỉ retry lỗi đã phân loại là retry được, có backoff và jitter; call không idempotent cần idempotency key trước.
- Chỉ cache kết quả thành công và cache được; không bao giờ cache call thanh toán.
- Truyền
AbortSignalđể timeout huỷ được request bên dưới. - Test wiring end-to-end một lần: số
api callsmong đợi cho lỗi-rồi-thành-công và cho cache hit.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Retry nằm trong một timeout tổng duy nhất
Hay gặp
new TimeoutPriceService(new RetryingPriceService(api, 3), 50); // 50 ms cho tất cảNên viết
new TimeoutPriceService(new RetryingPriceService(new TimeoutPriceService(api, 50), 3), 200);Vì sao
Chỉ có timeout tổng 50 ms thì một attempt chậm đã ăn hết ngân sách và retry không bao giờ chạy (demo phần 2).
Sai lầm khác nằm trong hệ kiểu chứ không nằm ở thời gian:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
“@Cacheable() chính là Decorator pattern” |
Thứ tự bị giấu trong annotation, khó test | Ghép wrapper tường minh trong module |
| “Thứ tự không quan trọng, các lớp độc lập” | Dashboard báo 0% hoặc 67% lỗi cho cùng lưu lượng | Chọn thứ tự theo metric, ghi lại |
| “Timeout huỷ call” | Request mồ côi tiếp tục đè tải lên API | Truyền AbortSignal |
| “Interceptor thay được decorator” | Retry chạy theo request, không theo từng call dependency | Interceptor cho route, decorator cho client |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Với
cache(retry(api)), một cache hit tốn bao nhiêu API call, và vì sao? - Vì sao
timeout(retry(api))với ngân sách 50 ms thất bại ở demo phần 2, cònretry(timeout(api))thành công? - Nên đặt
MeteredPriceServiceở vị trí nào để alert người trực, và ở vị trí nào cho capacity planning?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Decorator — Refactoring.Guru: mục đích, sự phụ thuộc thứ tự, quan hệ với Adapter, Proxy và Chain of Responsibility.
- Interceptors — NestJS: interceptor làm được gì, ví dụ cache và timeout, lưu ý unsubscribe không huỷ việc đang chạy.
- Custom providers — NestJS:
useFactoryvàinjectđể ghép wrapper. - TypeScript 5.0 — Decorators: decorator chuẩn so với
experimentalDecorators.