Design Pattern
Học Design Pattern, ai cũng từng có lúc đọc xong một mớ pattern rồi thấy cái nào cũng hay. Về project, chỗ nào cũng muốn áp dụng. Mình cũng từng như vậy. Đến vài tháng sau nhìn lại code, mình không hiểu tại sao mình lại tạo ba tầng abstraction chỉ để đọc một file config.
Vấn đề là nhiều tài liệu chỉ nói pattern là gì, nhưng ít nói khi nào nên dùng, khi nào không nên dùng.
Biết tên pattern thôi chưa đủ
Một pattern không chỉ là một cách giải quyết vấn đề. Nó luôn đi kèm với một cái giá phải trả. Đây là phần thường bị bỏ qua khi học Design Pattern.
Ví dụ, thấy nhiều if/elif thì nghĩ đến Strategy. Thấy nhiều tham số thì nghĩ đến Builder. Nếu cứ gặp vấn đề là nghĩ ngay đến một pattern, code rất dễ trở nên phức tạp hơn mức cần thiết.
Design Pattern không phải checklist để áp dụng vào project. Mục tiêu không phải là dùng càng nhiều pattern càng tốt, mà là làm cho code dễ hiểu và dễ thay đổi hơn.
Vậy học Design Pattern để làm gì?
Thứ nhất, nó giúp team nói chuyện với nhau dễ hơn. Khi nói “dùng Strategy ở đây”, cả team có thể hiểu nhanh ý tưởng thiết kế mà không cần giải thích từ đầu.
Thứ hai, pattern giúp mình nhận ra những vấn đề quen thuộc. Khi gặp một bài toán mới, nếu nó giống một vấn đề mình từng giải quyết, mình có thể bắt đầu từ một hướng đã biết thay vì nghĩ lại từ đầu.
Nhưng cần nhớ một điều: biết pattern không có nghĩa là phải dùng pattern.
Pattern luôn là một sự đánh đổi
Hầu hết pattern đều làm một việc giống nhau: chúng giúp giảm sự phụ thuộc giữa các phần của code, nhưng đổi lại code sẽ có thêm một lớp trung gian.
Ví dụ, thay vì gọi trực tiếp Stripe:
def create_order(cart):
stripe.charge(cart.total)
Ta có thể tách nó ra:
def create_order(cart, gateway: PaymentGateway):
gateway.charge(cart.total)
Cách thứ hai giúp create_order không còn phụ thuộc trực tiếp vào Stripe. Sau này muốn đổi sang VNPay hoặc một payment gateway khác sẽ dễ hơn. Khi test cũng có thể truyền vào một gateway giả.
Nhưng đổi lại, code khó đọc trực tiếp hơn. Muốn biết gateway thực sự là gì, bạn phải lần ngược lại nơi nó được tạo.
Vì vậy, trước khi dùng pattern, hãy hỏi một câu rất đơn giản: chỗ này có thường xuyên thay đổi không?
Nếu cả năm không ai thay payment gateway, việc tạo thêm abstraction có thể không đáng. Nhưng nếu hệ thống liên tục phải thay đổi gateway, abstraction đó lại rất hữu ích.
Nói cách khác, đừng hỏi “pattern này có dùng được không?”. Hãy hỏi “pattern này giúp code của mình dễ thay đổi hơn ở đâu?”
Nếu không trả lời được câu đó, có lẽ bạn chưa cần pattern.
SOLID nhìn từ code thực tế
SOLID thường được dạy bằng năm định nghĩa khá khó nhớ. Cách dễ hơn là nhìn vào những dấu hiệu xuất hiện trực tiếp trong code.
Single Responsibility có thể hiểu đơn giản là một class không nên làm quá nhiều việc khác nhau. Nếu UserManager vừa validate user, vừa lưu database, vừa gửi email thì có thể đã đến lúc tách nó ra.
Open/Closed nói rằng khi thêm một tính năng mới, nếu lúc nào cũng phải sửa vào code cũ thì có thể thiết kế hiện tại chưa tốt. Tuy nhiên, không cần chuẩn bị sẵn abstraction cho mọi khả năng có thể xảy ra. Có thể đợi đến khi thực sự xuất hiện nhu cầu mở rộng.
Liskov Substitution liên quan đến kế thừa. Một class con phải có thể thay thế class cha mà không làm chương trình hoạt động sai. Nếu class con phải phá vỡ những gì class cha đã cam kết, có thể quan hệ kế thừa đó không phù hợp.
Interface Segregation đơn giản là đừng bắt một class phải implement những thứ nó không cần. Interface nhỏ thường dễ sử dụng hơn một interface lớn chứa quá nhiều method.
Dependency Inversion là tách business logic khỏi những chi tiết kỹ thuật bên dưới. Ví dụ, code xử lý đơn hàng không nhất thiết phải biết nó đang làm việc trực tiếp với PostgreSQL hay một database cụ thể nào.
Điểm quan trọng nhất là SOLID không phải những luật phải tuân thủ một cách máy móc. Nó chỉ là những nguyên tắc giúp mình nhận ra khi code bắt đầu khó thay đổi hoặc khó hiểu.
DRY, KISS và YAGNI
Ba nguyên tắc này có thể hiểu rất đơn giản.
DRY là tránh lặp lại cùng một thông tin. Nhưng không phải cứ thấy hai đoạn code giống nhau là phải gom chúng lại.
Ví dụ, validation địa chỉ của khách hàng và validation địa chỉ của kho có thể giống nhau hôm nay. Nhưng nếu hai bên có khả năng thay đổi theo những hướng khác nhau, gom chúng vào cùng một function có thể khiến code khó sửa hơn về sau.
Vì vậy, đôi khi để code trùng một chút lại tốt hơn là tạo ra một abstraction sai.
KISS nghĩa là giữ mọi thứ đơn giản. Nếu hai cách đều giải quyết được vấn đề, hãy chọn cách mà lúc 2 giờ sáng có sự cố bạn vẫn có thể mở code ra và hiểu ngay.
YAGNI nghĩa là “You Aren’t Gonna Need It”. Đừng viết code cho những nhu cầu chưa tồn tại. Một abstraction được tạo ra cho một tương lai có thể không bao giờ xảy ra thường chỉ làm code hiện tại phức tạp hơn.
Anti-pattern
Không phải cách thiết kế nào được sử dụng nhiều cũng là cách thiết kế tốt. Có những cách làm xuất hiện thường xuyên nhưng lại khiến code khó hiểu và khó thay đổi hơn. Những cách làm như vậy thường được gọi là Anti-pattern .
Một ví dụ phổ biến là dùng pattern quá mức .
Khi học được một pattern mới, rất dễ muốn áp dụng nó ngay cả khi vấn đề chưa thực sự cần đến. Kết quả là một đoạn code đơn giản có thể biến thành nhiều class, interface và abstraction chỉ để giải quyết một vấn đề rất nhỏ.
Vấn đề không nằm ở pattern. Vấn đề là mình đang làm code phức tạp hơn mà không có một nhu cầu thực tế để đổi lại.
Cách tránh khá đơn giản: trước khi thêm một pattern, hãy hỏi nó giúp code dễ thay đổi hoặc dễ hiểu hơn ở đâu . Nếu không có câu trả lời rõ ràng, có thể bạn chưa cần nó.
Các bài tiếp theo
Các bài sau sẽ giữ cùng một cách tiếp cận. Mỗi pattern sẽ bắt đầu từ một vấn đề thực tế, mình sẽ giải thích ý tưởng bằng cách đơn giản nhất.
Quan trọng hơn, mỗi bài sẽ nói rõ khi nào nên dùng và khi nào không nên dùng. Nếu Python đã có sẵn một tính năng giải quyết được vấn đề mà không cần pattern, mình cũng sẽ nói thẳng. Vì mục tiêu cuối cùng không phải là sử dụng nhiều Design Pattern hơn, mà là viết được code đơn giản, dễ hiểu và dễ thay đổi.