Bài trước nói về hình dạng của những chỗ nối trong code. Năm 1994, bốn tác giả (Gamma, Helm, Johnson, Vlissides, hay được gọi là "Gang of Four") xuất bản cuốn Design Patterns, gom 23 hình dạng lặp đi lặp lại thành một bộ từ vựng: Strategy, Factory, Singleton, Observer, Adapter... Giá trị lớn nhất của cuốn sách là cái tên: nói "chỗ này dùng Strategy" thay cho ba đoạn giải thích.
Nhưng các ví dụ trong sách viết bằng C++ và Smalltalk, những ngôn ngữ mà hàm không truyền đi được như một giá trị bình thường (C++ thời đó) hoặc mọi thứ phải là lớp. Năm 1996, Peter Norvig chỉ ra rằng trong các ngôn ngữ động, 16 trên 23 pattern trở nên đơn giản hơn hẳn hoặc gần như biến mất vào ngôn ngữ. Python là một ngôn ngữ như vậy.
Lộ trình: ba nhóm pattern, những pattern mà Python đã làm sẵn (Strategy, Singleton, Iterator, Decorator, Command), những pattern vẫn đáng học (Factory với registry, Adapter, Observer, Template Method so với composition, Dependency Injection), tìm chúng trong code của chính trang web này, đo giá của mỗi kiểu gián tiếp, rồi khi nào pattern thành phản tác dụng.
Mọi con số trong bài đều đo trên máy thật, với Python 3.12, chạy đơn luồng, mỗi phép đo lấy kết quả tốt nhất trong 5 lần, mỗi lần một triệu lời gọi.
Ba nhóm
Gang of Four chia 23 pattern thành ba nhóm theo vấn đề chúng giải quyết:
| Nhóm | Câu hỏi | Ví dụ |
|---|---|---|
| Creational (khởi tạo) | Ai tạo đối tượng, tạo loại nào? | Factory, Builder, Singleton |
| Structural (cấu trúc) | Ghép các đối tượng lại với nhau thế nào? | Adapter, Decorator, Facade, Proxy |
| Behavioral (hành vi) | Các đối tượng chia việc và báo cho nhau thế nào? | Strategy, Observer, Command, Iterator, Template Method |
Không cần thuộc cả 23. Phần lớn code hằng ngày chỉ gặp khoảng một chục pattern, và trong Python, một nửa số đó chỉ dài một dòng.
Những pattern Python đã làm sẵn
Strategy: đóng gói một thuật toán để đổi được lúc chạy. Trong sách, đó là một interface SortStrategy cùng vài lớp cài đặt. Trong Python, hàm là giá trị, nên Strategy chỉ là truyền một hàm vào:
sorted(users, key=lambda u: u.last_login) # chiến lược so sánh là một tham số
Lớp OAuthProvider của trang này (gặp ở bài trước) cũng vậy: phần khác nhau giữa các nhà cung cấp là trường parse_user: Callable[[dict], ProviderUser], một hàm, không phải một cây lớp con.
Singleton: đảm bảo chỉ có một đối tượng. Trong Python, module đã là singleton: lần import đầu chạy module và cất vào sys.modules, các lần sau chỉ lấy lại đối tượng đó (bài Bên trong CPython đi qua cơ chế này). Cấu hình của trang này là một đối tượng tạo ra một lần ở cấp module, auth_settings, mail_settings, và ai cần thì import. Không cần __new__ hay khóa nào.
Iterator: duyệt một tập mà không lộ cấu trúc bên trong. Python có sẵn giao thức __iter__/__next__, và generator viết nó chỉ bằng yield:
def pages(items, size):
for start in range(0, len(items), size):
yield items[start:start + size]
(Bài Stream bằng async generator đi sâu vào loại này.)
Decorator: bọc một đối tượng để thêm hành vi mà không sửa nó. Cú pháp @ của Python không hoàn toàn giống Decorator của Gang of Four (nó bọc hàm, không bọc đối tượng), nhưng ý tưởng giống hệt: thêm một lớp vỏ, giữ nguyên lõi. Trang này dùng @lru_cache để nhớ mã băm nội dung của file tĩnh:
@lru_cache(maxsize=256)
def _content_hash(path: str, mtime_ns: int) -> str:
return hashlib.sha256((STATIC_DIR / path).read_bytes()).hexdigest()[:12]
Hàm bên trong không biết gì về cache. Và có một chi tiết khéo: thời điểm sửa file (mtime_ns) là một phần của khóa, nên khi file đổi, khóa đổi, và cache tự "mất hiệu lực" mà không cần ai xóa.
Command: đóng gói một hành động thành đối tượng để lưu lại, xếp hàng, hoặc hoàn tác. Trong Python, một hàm, một lambda hay functools.partial đã là một "lệnh" truyền đi được:
from functools import partial
undo = []
def set_title(doc, title):
undo.append(partial(setattr, doc, "title", doc.title)) # lệnh để quay lại tên cũ
doc.title = title
BackgroundTasks của FastAPI cũng là Command: route đăng ký xếp mailer.send cùng email xác nhận vào hàng bằng background.add_task(mailer.send, email), và lệnh đó chạy sau khi phản hồi đã gửi đi, nên một máy chủ SMTP chậm không giữ trang lại.
Những pattern vẫn đáng học
Factory, dưới dạng registry. Khi phải tạo "một loại nào đó" dựa trên dữ liệu (tên trong URL, trường type trong JSON), thay một chuỗi if/elif bằng một dict từ tên tới thứ cần tạo. get_providers() của trang này trả về đúng một dict như vậy:
return {p.name: p for p in providers} # "google" -> OAuthProvider(...)
Route /auth/oauth/{provider}/login nhận nhà cung cấp qua một dependency nhỏ, valid_provider, chỉ làm get_providers().get(provider) và báo lỗi "không tìm thấy" nếu không có. Thêm nhà cung cấp không đụng tới route.
Adapter (và người anh em Facade): bọc một API bên ngoài sau một interface của mình. Lớp Mailer của trang này bọc thư viện aiosmtplib sau một phương thức duy nhất send(email): phần còn lại của code không biết gì về cổng 587 hay 465, STARTTLS hay TLS, timeout 20 giây. Đổi sang một dịch vụ gửi email qua HTTP chỉ cần viết lại một lớp. Adapter cũng là chỗ tự nhiên để đặt đồ giả khi test, như đã thấy ở bài trước.
Observer: một đối tượng phát sự kiện, nhiều đối tượng khác đăng ký nhận mà bên phát không cần biết họ là ai:
listeners = {}
def on(event, fn):
listeners.setdefault(event, []).append(fn)
def emit(event, **data):
for fn in listeners.get(event, []):
fn(**data)
on("user_registered", lambda email: print("welcome mail to", email))
on("user_registered", lambda email: print("count +1"))
emit("user_registered", email="[email protected]")
Phần đăng ký không cần biết sẽ có email chào mừng hay thống kê. Giá phải trả: luồng chạy khó lần theo hơn (ai đang nghe sự kiện này?), và một listener chậm hay lỗi có thể làm chậm cả bên phát. Ở quy mô hệ thống, Observer lớn lên thành hàng đợi tin nhắn và pub/sub, chủ đề của bài sau.
Template Method so với composition. Template Method: lớp cha định nghĩa các bước của một thuật toán, lớp con ghi đè vài bước. Nó chạy được, nhưng gắn chặt bằng kế thừa, và bài trước đã thấy kế thừa dễ phá lời hứa thế nào. Lời khuyên lâu đời của chính Gang of Four là "ưu tiên composition hơn kế thừa": truyền những bước thay đổi vào dưới dạng hàm hay đối tượng, như OAuthProvider nhận parse_user, thay vì bắt mỗi nhà cung cấp kế thừa một lớp cha.
Dependency Injection. Không nằm trong 23 pattern gốc, nhưng là pattern dùng nhiều nhất trong một ứng dụng web hiện đại: đối tượng không tự tạo phụ thuộc của mình mà được đưa vào. FastAPI có sẵn cơ chế này qua Depends, và trang này dùng nó cho cả những việc nhỏ như gắn header cache:
async def cache_page(response: Response) -> Response:
response.headers["Cache-Control"] = PAGE
return response
CachedPage = Annotated[Response, Depends(cache_page)]
Route nào khai báo một tham số kiểu CachedPage sẽ được cache theo chính sách chung. Muốn đổi chính sách cache cho mọi trang HTML, chỉ sửa một hằng số.
Mỗi kiểu gián tiếp tốn bao nhiêu
Mọi pattern ở trên đều thêm một bước gián tiếp: gọi qua một hàm được truyền vào, tra một dict, gọi một phương thức thay vì viết thẳng. Mười kiểu "đổi hành vi theo loại", mỗi kiểu một phép cộng nhỏ, đo theo năm cách:
| Cách chọn hành vi | Mỗi lời gọi |
|---|---|
| Gọi thẳng một hàm | 13,6 ns |
if/elif, khớp nhánh đầu tiên |
17,5 ns |
if/elif, khớp nhánh thứ 10 |
62,3 ns |
match, khớp case thứ 10 |
66,6 ns |
| Dict tra ra hàm (registry) | 29,4 ns |
| Phương thức trên đối tượng (đa hình) | 15,9 ns |
Vài điều đáng chú ý:
- Gọi phương thức đa hình gần như miễn phí: 15,9 ns so với 13,6 ns gọi thẳng. Python tra phương thức qua kiểu của đối tượng, và việc đó đã được tối ưu kỹ.
- Chuỗi
ifchậm dần theo số nhánh: mỗi nhánh thêm khoảng 5 ns cho một phép so sánh chuỗi.matchvới các chuỗi hằng không nhanh hơn: trong CPython, nó vẫn so sánh lần lượt từng case. - Registry có giá cố định: 29,4 ns dù có 10 hay 1.000 loại, vì tra dict không phụ thuộc số phần tử.
Nhưng nhìn lại bảng: chênh lệch lớn nhất là khoảng 50 ns. Một truy vấn database có chỉ mục tốn cỡ micro giây, một request qua mạng tốn hàng chục mili giây. Với code chạy vài lần mỗi request, chọn pattern theo độ rõ ràng, không theo nano giây. Chỉ trong vòng lặp nóng chạy hàng chục triệu lần mới đáng đo lại.
Khi pattern thành phản tác dụng
- Pattern vì pattern. Một
AbstractNotificationStrategyFactorycho đúng một cách gửi thông báo không phải thiết kế tốt, mà là ba tầng phải đọc qua. Pattern là lời giải cho một vấn đề; chưa có vấn đề thì chưa cần lời giải. - Dịch nguyên văn từ Java. Getter/setter cho mọi thuộc tính (Python có
@propertykhi cần), lớp chỉ chứa một phương thứcexecute(một hàm là đủ), interface cho mọi thứ (duck typing vàProtocolđã đủ). - Singleton như biến toàn cục trá hình. Trạng thái dùng chung có thể thay đổi, ẩn sau
get_instance(), làm test phụ thuộc thứ tự chạy. Đối tượng cấu hình chỉ đọc là ổn; đối tượng giữ trạng thái thay đổi thì nên được đưa vào qua dependency injection để test thay được.
Tóm tắt
| Pattern | Trong Python | Trong code trang này |
|---|---|---|
| Strategy | Truyền một hàm | OAuthProvider.parse_user, sorted(key=...) |
| Singleton | Module, đối tượng cấp module | auth_settings, mail_settings |
| Iterator | Generator, yield |
Session database trong get_session |
| Decorator | @decorator |
@lru_cache với mtime_ns trong khóa |
| Command | Hàm, partial, BackgroundTasks |
Gửi email chạy nền |
| Factory | Dict registry | get_providers() |
| Adapter / Facade | Lớp bọc API ngoài | Mailer bọc aiosmtplib |
| Observer | Callback, event, về sau là pub/sub | |
| Dependency Injection | Tham số, Depends |
CachedPage, get_mailer |
Từ bài này trở đi, đơn vị thiết kế không còn là hàm hay lớp, mà là cả một máy chủ: một database, một cache, một hàng đợi, một bộ cân bằng tải. Bài sau: các khối xây dựng của system design, với những con số đo được để ước lượng, và vì sao một máy chủ chạy ở 90% công suất lại chậm gấp 5 lần lúc chạy ở 50%.