Bỏ qua để đến nội dung
Knowledge Hub

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

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ì?

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

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.

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.

Wiring NestJS trong demo dựng cache(metrics(retry(timeout(api)))). API lỗi ở attempt đầu và thành công ở attempt thứ hai:

Một lần getPrice đi qua bốn decorator: metrics đếm 1 call trong khi API nhận 2; lần báo giá thứ hai dừng ở cache
Một lần getPrice đi qua bốn decorator: metrics đếm 1 call trong khi API nhận 2; lần báo giá thứ hai dừng ở cacheBước 1/6: CheckoutService gọi getPrice(sku-9) trên PRICE_SERVICE. Nó chỉ thấy interface PriceService.CheckoutServiceCacheTTL 60 smissMetricscalls, errorsRetry3 lần thửTimeout50 ms / lầnPrice APIgetPrice(sku-9)
CheckoutService → CachegetPrice(sku-9)
Bước 1/6

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:

  1. Request vào. Cache chưa có sku-9; chưa có gì tới API.
  2. 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.
  3. Attempt 1. Timeout bật đồng hồ 50 ms, API trả 503, api calls = 1.
  4. Attempt 2. Retry bắt lỗi 503 và gọi lại; API trả 129000, api calls = 2.
  5. Đi ra. Metrics ghi thành công: calls = 1, errors = 0. Cache lưu giá trong 60 s.
  6. Báo giá lần hai. Cache hit. Không lớp nào bên dưới cache chạy; api calls vẫ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.

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=3
2) 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=1
3) 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=1 cho thấy call bị bỏ rơi vẫn chạy xong. Promise.race chỉ thôi chờ, không dừng request. Hãy truyền AbortSignal xuố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.

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 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.

Terminal window
npx --prefix examples ts-node examples/src/decorator-pattern/price-decorators-demo.ts
npx --prefix examples ts-node examples/src/decorator-pattern/nest-wiring.ts

Fake 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.

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 AbortSignal dùng chung, lớp timeout và retry để lại các call mồ côi.
  1. Viết chồng lớp ở một composition root, kèm comment đọc từ trong ra ngoài.
  2. Quyết định mỗi vị trí đo metric gì; đặt tên khác nhau cho attempt và call.
  3. Timeout từng attempt đặt trong retry, deadline tổng đặt ngoài, tổng lớn hơn tổng các attempt.
  4. 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.
  5. Chỉ cache kết quả thành công và cache được; không bao giờ cache call thanh toán.
  6. Truyền AbortSignal để timeout huỷ được request bên dưới.
  7. Test wiring end-to-end một lần: số api calls mong đợi cho lỗi-rồi-thành-công và cho cache hit.

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
  1. Với cache(retry(api)), một cache hit tốn bao nhiêu API call, và vì sao?
  2. Vì sao timeout(retry(api)) với ngân sách 50 ms thất bại ở demo phần 2, còn retry(timeout(api)) thành công?
  3. Nên đặt MeteredPriceService ở vị trí nào để alert người trực, và ở vị trí nào cho capacity planning?