Bài trước dọn một hàm. Nhưng phần lớn chi phí của một phần mềm lớn không nằm trong từng hàm, mà nằm ở chỗ nối giữa chúng: lớp này biết quá nhiều về lớp kia, sửa một module làm vỡ ba module khác, muốn test một quy tắc nhỏ thì phải dựng cả database.
SOLID là năm nguyên lý về những chỗ nối đó, được Robert C. Martin gom lại vào đầu những năm 2000 (cái tên viết tắt do Michael Feathers đặt). Chúng ra đời trong thế giới Java và C++, nên nhiều bài viết áp nguyên văn vào Python và sinh ra một rừng interface không cần thiết. Bài này đi qua từng nguyên lý với câu hỏi: nó bảo vệ mình khỏi kiểu thay đổi nào, và trong Python thì trông nó ra sao.
Lộ trình: S (một lý do để thay đổi), O (thêm mà không sửa), L (lớp con không được làm người gọi bất ngờ, kèm hai cái bẫy chạy được), I (interface nhỏ, với Protocol), D (phụ thuộc vào trừu tượng, đo bằng tốc độ test), rồi khi nào SOLID thành gánh nặ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. Vài ví dụ lấy từ chính code của trang web này.
S: một lý do để thay đổi
Single Responsibility Principle: một module chỉ nên có một lý do để thay đổi. Câu này hay bị hiểu thành "một lớp chỉ làm một việc", rồi bị đẩy tới mức mỗi lớp một phương thức. Martin về sau nói rõ hơn: "lý do để thay đổi" là một nhóm người yêu cầu thay đổi. Kế toán muốn đổi cách tính lương, bộ phận vận hành muốn đổi định dạng báo cáo, DBA muốn đổi schema: ba nhóm, ba lý do.
Một lớp vi phạm SRP trông như thế này:
class UserService:
def register(self, email, name): ... # quy tắc nghiệp vụ: email trùng, tên hợp lệ
def save_to_postgres(self, user): ... # chi tiết lưu trữ
def send_welcome_email(self, user): ... # nội dung email, SMTP
def render_profile_html(self, user): ... # giao diện
Đội marketing muốn sửa nội dung email chào mừng, và họ phải chạm vào cùng một file với quy tắc đăng ký. Đổi database cũng vậy. Mỗi lần sửa, mọi thứ còn lại trong file có nguy cơ vỡ theo, và mọi người dẫm chân nhau trong cùng một chỗ.
Ở mức module, code của trang web này chia theo cùng nguyên tắc: mỗi miền (auth, blog, mail, games) là một thư mục, trong đó router.py lo HTTP, service.py lo quy tắc, schemas.py lo hình dạng dữ liệu. Đổi giao diện một trang không cần mở file chứa quy tắc đăng nhập.
O: thêm mà không sửa
Open/Closed Principle: một module nên mở để mở rộng, đóng với sửa đổi. Thêm một hành vi mới nên là việc thêm code mới, không phải sửa code cũ đã chạy ổn.
Phiên bản vi phạm quen thuộc là một chuỗi if/elif theo loại:
def login_url(provider, ...):
if provider == "google":
... # 15 dòng riêng của Google
elif provider == "github":
... # 15 dòng riêng của GitHub
Thêm nhà cung cấp thứ ba nghĩa là mở hàm này ra, chen thêm một nhánh, và hi vọng không làm hỏng hai nhánh cũ. Phần đăng nhập bằng tài khoản ngoài của trang này làm ngược lại: luồng OAuth viết một lần trong lớp OAuthProvider, còn mỗi nhà cung cấp chỉ là một bản ghi dữ liệu với những gì khác nhau (URL, scope, hàm đọc thông tin người dùng):
"""SSO providers. Adding one = an OAuthProvider entry in `get_providers` plus its client id/secret in config."""
@dataclass(frozen=True)
class OAuthProvider:
name: str
authorize_url: str
token_url: str
userinfo_url: str
scope: str
parse_user: Callable[[dict[str, Any]], ProviderUser] # phần duy nhất mỗi bên tự viết
...
Thêm nhà cung cấp mới là thêm một bản ghi, còn đoạn code xử lý luồng OAuth không cần chạm tới. Lưu ý chữ "đóng" không tuyệt đối: hàm get_providers vẫn phải thêm một mục. OCP không đòi không bao giờ sửa gì, nó đòi phần dễ vỡ và đã kiểm chứng (ở đây là luồng OAuth với PKCE) không phải mở ra mỗi lần có yêu cầu mới.
L: lớp con không được làm người gọi bất ngờ
Liskov Substitution Principle (Barbara Liskov, 1987): ở mọi chỗ dùng một kiểu, phải thay được bằng kiểu con của nó mà chương trình vẫn đúng. Không chỉ là "cùng tên phương thức", mà là cùng lời hứa: kiểu con không được đòi hỏi nhiều hơn, không được trả về ít hơn, không được phá những điều người gọi được phép giả định.
Ví dụ kinh điển: về mặt toán học, hình vuông là một hình chữ nhật. Vậy cho Square kế thừa Rectangle:
class Rectangle:
def __init__(self, w, h): self.w, self.h = w, h
def set_width(self, w): self.w = w
def set_height(self, h): self.h = h
def area(self): return self.w * self.h
class Square(Rectangle):
def __init__(self, side): super().__init__(side, side)
def set_width(self, w): self.w = self.h = w # hình vuông thì hai cạnh phải bằng nhau
def set_height(self, h): self.w = self.h = h
def stretch(shape):
shape.set_width(5)
shape.set_height(4)
return shape.area()
print(stretch(Rectangle(1, 1)), stretch(Square(1))) # 20 16
stretch được viết với một giả định hợp lý cho mọi hình chữ nhật: đặt chiều rộng xong, đặt chiều cao không làm đổi chiều rộng. Square phá giả định đó, nên cùng một hàm trả về 20 với hình chữ nhật và 16 với hình vuông. Không có lỗi nào được báo, chỉ có một con số sai. Quan hệ "là một" trong đời thực không đảm bảo quan hệ thay thế được trong code, đặc biệt khi đối tượng thay đổi được. Nếu hình là bất biến (Rectangle(5, 4) tạo hình mới thay vì sửa), vấn đề biến mất.
Cái bẫy thứ hai nằm ngay trong thư viện chuẩn của Python. Viết một dict không phân biệt hoa thường bằng cách kế thừa dict và ghi đè __getitem__:
class Lower(dict):
def __getitem__(self, key):
return super().__getitem__(key.lower())
d = Lower(a=1)
print(d["A"]) # 1
print(d.get("A")) # None
d["A"] chạy đúng, d.get("A") thì không: dict.get được viết bằng C và đọc thẳng bảng băm, không đi qua __getitem__ của bạn. Cùng một đối tượng, hai cách hỏi, hai câu trả lời. Đây là lý do thư viện chuẩn có collections.UserDict (mọi phương thức đi qua __getitem__) và collections.abc.Mapping (bạn viết vài phương thức cơ bản, phần còn lại được suy ra nhất quán từ đó). Kế thừa một lớp mà bạn không kiểm soát cách nó gọi chính mình là cách dễ nhất để phá LSP.
I: interface nhỏ, đúng thứ người dùng cần
Interface Segregation Principle: đừng bắt ai phụ thuộc vào những phương thức họ không dùng. Một interface Storage có 12 phương thức nghĩa là mọi lớp muốn đóng vai storage, kể cả đồ giả trong test, phải viết đủ 12 phương thức, dù hàm gọi nó chỉ cần get.
Python có cách diễn đạt nhẹ nhàng cho việc này: typing.Protocol. Hàm khai báo đúng thứ nó cần, và bất kỳ đối tượng nào có những phương thức đó đều dùng được, không cần kế thừa gì:
from typing import Protocol
class UserLookup(Protocol):
def get(self, email: str) -> str | None: ...
def is_taken(users: UserLookup, email: str) -> bool:
return users.get(email) is not None
is_taken nhận được một kho người dùng thật, một dict, hay một đồ giả hai dòng, miễn là có get. Trình kiểm tra kiểu (mypy, pyright) vẫn bắt lỗi nếu bạn truyền nhầm thứ không có get. Đây là duck typing, nhưng được ghi ra giấy.
D: phụ thuộc vào trừu tượng, không vào chi tiết
Dependency Inversion Principle: quy tắc nghiệp vụ (cấp cao) không nên phụ thuộc trực tiếp vào chi tiết hạ tầng (cấp thấp: database, SMTP, HTTP). Cả hai nên phụ thuộc vào một trừu tượng ở giữa, và chi tiết được đưa vào từ ngoài thay vì được tạo ra bên trong.
def register(users, email, name): # users: bất cứ thứ gì có get/add
if users.get(email):
raise ValueError("taken")
users.add(email, name.strip())
Hàm này không biết users là SQLite, Postgres hay một dict. Trong production, truyền vào một kho thật. Trong test, truyền vào một đồ giả:
class FakeUsers:
def __init__(self): self.rows = {}
def add(self, email, name): self.rows[email] = name
def get(self, email): return self.rows.get(email)
Lợi ích dễ thấy nhất là tốc độ test. Cùng một bài test (đăng ký một người, kiểm tra tên được cắt khoảng trắng, đăng ký trùng phải báo lỗi), chạy 200 lần với kho SQLite trên file và 20.000 lần với đồ giả:
| Kho người dùng | Mỗi lần test |
|---|---|
| SQLite trên file (tạo bảng, ghi, đọc, xóa file) | ~520–560 µs |
| Đồ giả trong bộ nhớ | 0,29 µs |
Nhanh hơn khoảng 1.800–1.900 lần. Với một bộ test vài nghìn bài, đó là khác biệt giữa vài giây và vài phút. Lợi ích lớn hơn nữa là sự cô lập: bài test kiểm tra quy tắc đăng ký, không kiểm tra ổ đĩa hay cấu hình database, nên khi nó đỏ, bạn biết ngay lỗi nằm ở đâu.
Trang web này dùng đúng cách đó cho việc gửi email. Các route không tạo Mailer trực tiếp mà nhận nó qua Depends(get_mailer), và bộ test thay nó bằng một đồ giả chỉ ghi lại email vào danh sách:
class FakeMailer:
"""Collects emails instead of sending them."""
def __init__(self) -> None:
self.sent = []
async def send(self, email) -> None:
self.sent.append(email)
app.dependency_overrides[get_mailer] = lambda: mailer
Test quên mật khẩu kiểm tra được "đã gửi đúng một email, tới đúng địa chỉ, có đường link /forgot-password" mà không cần máy chủ SMTP nào. Đồ giả nên giữ thật đơn giản: một đồ giả có logic phức tạp thì chính nó lại cần test.
Một lưu ý: đồ giả kiểm tra quy tắc, không kiểm tra hạ tầng. Câu truy vấn SQL sai cú pháp sẽ không bao giờ lộ ra khi test với dict. Vì vậy vẫn cần một số ít test chạy với database thật, chỉ là không phải mọi test.
Khi SOLID thành gánh nặng
- Interface cho thứ chỉ có một cài đặt. Một
IUserRepositoryvới đúng một lớpUserRepository, không bao giờ có lớp thứ hai, không có đồ giả, chỉ thêm một tầng để nhảy qua. Trong Python, bắt đầu bằng một hàm hay một lớp cụ thể; khi thật sự cần cài đặt thứ hai, mộtProtocolthêm vào sau không tốn gì. - SRP tới mức vụn. Mỗi lớp một phương thức, mỗi file một lớp, một luồng đơn giản trải qua 15 file. Một lý do để thay đổi là một nhóm người, không phải một dòng code.
- OCP cho thay đổi không bao giờ tới. Dựng hệ thống plugin cho "lỡ sau này có nhà cung cấp thứ năm" khi hiện chỉ có một, là trả trước một chi phí chắc chắn cho một lợi ích có thể không bao giờ tới. Chuỗi
ifhai nhánh là ổn; tới nhánh thứ ba, thứ tư hãy đổi.
Cả năm nguyên lý có chung một mục đích: khoanh vùng thay đổi. Mỗi khi phải quyết định, hỏi: thay đổi nào có khả năng tới nhất, và cấu trúc nào làm cho nó chỉ chạm vào một chỗ?
Tóm tắt
| Nguyên lý | Câu hỏi thật | Trong Python |
|---|---|---|
| S: Single responsibility | Ai sẽ đòi đổi module này? | Một thư mục mỗi miền; router, service, schemas tách nhau |
| O: Open/closed | Thêm loại mới có phải mở code cũ không? | Bảng dữ liệu, bản ghi OAuthProvider, thay chuỗi if |
| L: Liskov substitution | Kiểu con có giữ lời hứa của kiểu cha? | Square trả 16 thay vì 20; dict.get bỏ qua __getitem__ |
| I: Interface segregation | Người gọi thật sự cần những gì? | Protocol nhỏ, chỉ khai báo thứ được dùng |
| D: Dependency inversion | Quy tắc có biết tới database không? | Đưa phụ thuộc vào từ ngoài; đồ giả nhanh ~1.900 lần |
SOLID nói về hình dạng của những chỗ nối. Có những hình dạng lặp đi lặp lại nhiều tới mức người ta đặt tên cho chúng: Strategy, Factory, Adapter, Observer... Bài sau: design pattern, cái nào Python đã có sẵn trong ngôn ngữ, cái nào vẫn đáng học, và một lời gọi qua mỗi kiểu "gián tiếp" tốn bao nhiêu nano giây.