decorator pattern
Tung Bui Thanh (TungDaDev)11 mins read

Sách vỡ lòng hay lấy ví dụ thêm sữa, thêm đường vào cốc cà phê để dạy Decorator Pattern. Ngoài đời, nó là nền của thư viện I/O trong Java (
BufferedInputStream(GZIPInputStream(FileInputStream))) và là họ hàng gần của cách Spring cài đặt Lập trình Hướng Khía Cạnh (Aspect-Oriented Programming - AOP) bằng proxy. Khi nào nên tự viết Decorator bằng tay, khi nào để Spring AOP Dynamic Proxy lo? Và bẫy 'Self-Invocation' nổi tiếng trong Spring thì Decorator có thoát được không?
Thêm các hành vi phụ (Cross-Cutting Concerns) như Caching, đo thời gian (Metric Timing), Retry hay log bảo mật mà không làm bẩn code nghiệp vụ là bài toán quen thuộc của Clean Architecture.
Bài này so sánh GoF Decorator truyền thống với Spring AOP Proxies (JDK Dynamic Proxy vs CGLIB), rồi xây một Resilient Caching Decorator làm ví dụ, kèm những chỗ phải gia cố thêm nếu muốn đem lên production.
# vấn đề: "vụ nổ lớp" (class explosion)
Giả sử bạn có một PaymentService cơ bản, rồi yêu cầu mới phát sinh:
- Thêm mã hóa dữ liệu (Encryption).
- Thêm log kiểm toán (Audit Logging).
- Thêm bộ nhớ đệm (Caching).
Dùng Kế thừa (Inheritance) thì số class nổ theo tổ hợp (Combinatorial Class Explosion): mỗi cách bật/tắt N tính năng là một class riêng, tức cỡ 2^N class. Chưa kể Java chỉ cho kế thừa đơn, nên EncryptedAndLoggedPaymentService không thể "lấy" code từ cả hai class cha mà phải chép lại một trong hai.
# nguyên lý của Decorator
- Triển khai cùng một Interface với đối tượng gốc.
- Giữ một tham chiếu (
private final T delegate) tới đối tượng bên trong. - Bọc lồng nhau thoải mái lúc Runtime:
PaymentService service = new LoggingDecorator(new EncryptionDecorator(new CorePaymentService()));
Ba tính năng giờ chỉ cần ba class Decorator, ghép kiểu gì cũng được. Cái giá phải trả là thứ tự bọc trở thành một phần của logic: log trước hay sau khi mã hoá cho ra hai kết quả khác nhau, và người lắp ráp phải biết điều đó.
# ví dụ kinh điển: package java.io
Ví dụ rõ nhất của Decorator Pattern trong Java Standard Library là gói java.io:
// Nghệ thuật lồng ghép Decorator của JDK:
InputStream is = new BufferedInputStream( // Decorator 2: Thêm bộ đệm RAM 8KB
new GZIPInputStream( // Decorator 1: Giải nén luồng Gzip on-the-fly
new FileInputStream("data.gz") // Component gốc: Đọc byte thô từ Disk
)
);Mỗi lớp chỉ lo đúng một việc (Single Responsibility Principle - SRP):
FileInputStream: mở file qua hệ điều hành.GZIPInputStream: giải nén.BufferedInputStream: gom các lần đọc lẻ tẻ thành từng mẻ 8 KB (DEFAULT_BUFFER_SIZE = 8192), để code gọiread()từng byte không phải chui vào bộ giải nén mỗi lần.
Cái "khung" Decorator trong java.io là FilterInputStream: nó giữ một InputStream in và mặc định chuyển mọi lời gọi xuống đó. BufferedInputStream và InflaterInputStream (cha của GZIPInputStream) đều kế thừa nó.
Có một chi tiết hay bị hiểu nhầm: với thứ tự bọc ở trên, bộ đệm 8 KB nằm ngoài bộ giải nén, nên nó đệm dữ liệu đã giải nén. Phần đọc file thì do GZIPInputStream tự lo, mỗi lần đọc từ FileInputStream theo buffer riêng của nó (mặc định chỉ 512 byte), tức mỗi 512 byte là một system call. Muốn giảm system call xuống đĩa thì đặt buffer sát file (new GZIPInputStream(new BufferedInputStream(new FileInputStream(...)))) hoặc truyền size lớn hơn qua constructor GZIPInputStream(in, size). Đây cũng là minh hoạ rõ nhất cho chuyện thứ tự bọc Decorator ảnh hưởng tới hành vi.
# Decorator thủ công vs Spring AOP Proxy
Trong Spring, phần lớn việc của Decorator đã được Spring AOP làm hộ (@Transactional, @Cacheable, @Retryable). Thật ra proxy của Spring cũng là một Decorator, chỉ khác là class bọc được sinh ra lúc chạy thay vì do bạn viết. Hai cách vì vậy khác nhau chủ yếu ở cách chạy bên dưới:
JDK Dynamic Proxy chỉ bọc được qua interface; CGLIB tạo class con nên bọc được cả class không có interface, nhưng không chặn được method final, private hay static. Spring Boot từ bản 2.0 mặc định spring.aop.proxy-target-class=true, tức dùng CGLIB kể cả khi bean có interface.
# so sánh chi tiết kỹ thuật
| Tiêu chí | Manual GoF Decorator | Spring AOP Dynamic Proxy |
|---|---|---|
| Cơ chế hoạt động | Ủy nhiệm tường minh (Explicit Delegation) | Can thiệp động bằng Bytecode (CGLIB / JDK Proxy) |
| Tính minh bạch (Debugging) | Cao: stack trace gọn, dễ đặt breakpoint | Thấp: stack trace chứa nhiều lớp proxy nội bộ của Spring |
| Cạm bẫy Self-Invocation | Cũng bị, nhưng dễ thấy vì lớp bọc nằm ngay trong code | Bị lỗi: gọi this.internalMethod() sẽ bỏ qua toàn bộ Proxy |
| Boilerplate Code | Phải viết class wrapper và override toàn bộ methods | Rất ít code: chỉ cần gắn annotation (vd: @Cacheable) |
| Khuyến nghị kiến trúc | Hợp cho Core SDK, luồng hiệu năng cao cần kiểm soát chặt | Hợp cho các tác vụ Cross-cutting dùng chung trong Spring Boot |
# bẫy self-invocation trong Spring AOP và cách Decorator xử lý
Một lỗi kinh điển của dev Spring Boot:
// LỖI CHÍ MẠNG TRONG SPRING AOP:
@Service
public class OrderService {
public void processOrder(Long orderId) {
// ... Logic chuẩn bị
this.chargeCard(orderId); // GỌI NỘI BỘ (SELF-INVOCATION)!
}
@Transactional // ANNOTATION NÀY HOÀN TOÀN BỊ VÔ HIỆU HÓA!
public void chargeCard(Long orderId) {
// Do gọi qua con trỏ 'this', luồng đi trực tiếp trong target object,
// HOÀN TOÀN BỎ QUA Spring CGLIB Proxy! Transaction KHÔNG ĐƯỢC MỞ!
}
}Decorator có thoát được không? Không hẳn. Decorator thủ công cũng chỉ chặn được lời gọi đi từ bên ngoài vào (
outer.operation()). Nếu bên trong object gốc,processOrder()gọithis.chargeCard()thì lời gọi đó cũng không đi qua Decorator, y hệt proxy. Cái lợi của Decorator là lớp bọc nằm ngay trong code, nên đọc là thấy lời gọi nào được bọc, lời gọi nào không; còn với annotation thì@Transactionalnằm trên method khiến người đọc tưởng nó luôn có hiệu lực.
Cách xử lý thực tế, xếp theo mức mình thấy nên ưu tiên:
- Tách
chargeCard()sang một bean khác (vdPaymentGateway) rồi inject vào. Lời gọi đi qua proxy của bean đó nên annotation chạy đúng. Đây cũng thường là thiết kế sạch hơn. - Đặt annotation ở method được gọi từ bên ngoài (
processOrder()), nếu ngữ nghĩa cho phép cả luồng chạy trong một transaction. - Tự inject chính mình (
@Lazy+ injectOrderService) hoặc dùngAopContext.currentProxy()(cần bậtexposeProxy = true). Chạy được nhưng code phụ thuộc vào cơ chế proxy, nên chỉ dùng khi không tách được. - Chuyển sang AspectJ weaving (compile-time hoặc load-time): aspect được dệt thẳng vào bytecode của class nên gọi qua
thisvẫn bị chặn, đổi lại build và cấu hình phức tạp hơn.
# ví dụ: resilient caching Decorator
Ví dụ dưới tách hẳn tầng truy vấn database (CoreCustomerAccountService) khỏi tầng Caching Redis và đo metric, bằng Decorator Pattern kết hợp Spring Bean Configuration:
# interface chính
public interface CustomerAccountService {
CustomerAccount getAccount(Long customerId);
}# component gốc (chỉ tập trung vào SQL database)
@Service("coreCustomerService")
public class CoreCustomerAccountService implements CustomerAccountService {
private final JdbcTemplate jdbcTemplate;
public CoreCustomerAccountService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public CustomerAccount getAccount(Long customerId) {
// Query thuần SQL nặng nề
return jdbcTemplate.queryForObject("SELECT ... WHERE id = ?", mapper, customerId);
}
}# resilient caching Decorator (trang bị Redis cache + fallback)
@Service
@Primary // Đánh dấu là Bean ưu tiên để Controller tự động inject Decorator
public class CachingCustomerAccountDecorator implements CustomerAccountService {
private static final Logger log = LoggerFactory.getLogger(CachingCustomerAccountDecorator.class);
private final CustomerAccountService delegate; // Đối tượng được bọc
private final RedisTemplate<String, Object> redisTemplate;
private final MeterRegistry meterRegistry;
public CachingCustomerAccountDecorator(
@Qualifier("coreCustomerService") CustomerAccountService delegate,
RedisTemplate<String, Object> redisTemplate,
MeterRegistry meterRegistry) {
this.delegate = delegate;
this.redisTemplate = redisTemplate;
this.meterRegistry = meterRegistry;
}
@Override
public CustomerAccount getAccount(Long customerId) {
String cacheKey = "customer:account:" + customerId;
// 1. Kiểm tra cache Redis
try {
CustomerAccount cached = (CustomerAccount) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
meterRegistry.counter("customer.cache.hit").increment();
return cached;
}
} catch (Exception ex) {
log.warn("Redis sập! Bỏ qua cache, fallback sang Database: {}", ex.getMessage());
}
meterRegistry.counter("customer.cache.miss").increment();
// 2. Đo thời gian thực thi của đối tượng gốc
Timer.Sample sample = Timer.start(meterRegistry);
CustomerAccount account = delegate.getAccount(customerId);
sample.stop(meterRegistry.timer("customer.db.query.duration"));
// 3. Ghi vào Redis (đồng bộ; lỗi thì bỏ qua, không làm hỏng request)
try {
redisTemplate.opsForValue().set(cacheKey, account, Duration.ofMinutes(15));
} catch (Exception ex) {
log.error("Không thể ghi cache: {}", ex.getMessage());
}
return account;
}
}# được gì từ cách làm này
CoreCustomerAccountServicesạch, không có dòng nào về Redis, Prometheus hay Fallback.- Muốn thay Redis bằng Caffeine Cache hoặc thêm tầng mã hóa AES thì chỉ cần thêm một Decorator mới bọc ngoài, không sửa dòng code Database nào (đúng OCP & SRP).
Trước khi mang ra production, còn vài chỗ cần tính thêm:
- Redis chết thì vẫn chậm.
try-catchgiữ cho request không lỗi, nhưng mỗi request vẫn phải chờ Redis tới hết timeout rồi mới xuống DB. Nên đặt command timeout ngắn cho client Redis (vd vài chục ms), và nếu muốn bỏ qua Redis hẳn khi nó đang chết thì bọc thêm một Circuit Breaker (vd Resilience4j). Đó lại là một Decorator nữa. - Serializer. Với
RedisTemplate<String, Object>, phép ép kiểu(CustomerAccount)chỉ chạy khi serializer lưu kèm thông tin kiểu (vdGenericJackson2JsonRedisSerializerhoặc JDK serialization). Dùng Jackson thường thì giá trị đọc ra làLinkedHashMap, ép kiểu némClassCastException, bịcatchnuốt và log thành "Redis sập", nên rất khó phát hiện. DùngRedisTemplate<String, CustomerAccount>vớiJackson2JsonRedisSerializer<>(CustomerAccount.class)thì an toàn hơn. - Cache stampede. Một key hot hết hạn đúng lúc nhiều request ập tới thì tất cả cùng miss và cùng đập xuống DB. Có thể cộng thêm TTL ngẫu nhiên vài phút, hoặc khoá theo key để chỉ một request đi lấy dữ liệu.
- Invalidate. Decorator này chỉ lo phía đọc. Chỗ nào cập nhật
CustomerAccountthì phải xoá key tương ứng, nếu không người dùng sẽ thấy dữ liệu cũ tới 15 phút.
# checklist
- 1. Giữ Interface Contract: mọi Decorator phải implements cùng interface với component được bọc để giữ tính đa hình.
- 2. Thứ tự lồng nhau (Decorator Ordering): xác định rõ thứ tự bọc (ví dụ: Logging ngoài cùng, Caching ở giữa, Retry bên trong, rồi tới Core Service).
- 3. Đừng bọc quá sâu: không nên lồng quá 4 - 5 lớp Decorator, vì Method Call Stack dài hơn và truy vết lỗi khó hơn.
- 4. Xử lý Self-Invocation: nếu các method có
@Transactionalhoặc@Cacheablegọi nhau trong cùng 1 class, hãy tách sang service khác. Decorator tường minh cũng không chặn được lời gọi quathis, chỉ giúp nhìn thấy nó rõ hơn. - 5. Graceful Degradation: trong các Decorator phụ (Caching, Audit), luôn bọc
try-catchđể lỗi của tầng phụ không làm sập luồng nghiệp vụ chính.
# kết luận
Decorator Pattern là ví dụ điển hình cho nguyên lý "Ưu tiên Thành phần hơn Kế thừa" (Favor Composition over Inheritance).
Dù bạn tự bọc các lớp ủy nhiệm trong Clean Architecture hay để Spring AOP Dynamic Proxy làm giúp, thì vẫn nên hiểu cơ chế bọc lớp, biết nó chạy ở đâu và rủi ro hiệu năng nằm chỗ nào.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.