Nguyên lý SOLID: "Liều thuốc" kiến trúc cho mã nguồn cứng nhắc và dễ vỡ
Bài viết này phân tích 5 nguyên lý SOLID giúp chữa trị các vấn đề phần mềm phổ biến như tính cứng nhắc (Rigidity) và tính dễ vỡ (Fragility), biến code "STUPID" thành hệ thống dễ bảo trì.
Các nguyên lý thiết kế S.O.L.I.D bắt nguồn từ các hướng dẫn trong Lập trình Hướng đối tượng (OOP). Chúng được thiết kế để phát triển phần mềm dễ bảo trì và mở rộng, ngăn chặn code "có mùi" (code smells), hỗ trợ refactoring (tái cấu trúc), và thúc đẩy tính linh hoạt. Mục tiêu cuối cùng là cho phép thay đổi nhanh chóng và thường xuyên mà không phát sinh lỗi.
Thông thường, nợ kỹ thuật (technical debt) là kết quả của việc ưu tiên tốc độ bàn giao hơn là chất lượng code. Để kiểm soát điều này, các lập trình viên nên áp dụng các nguyên lý SOLID trong quá trình phát triển.
Robert Martin (Uncle Bob), người được ghi nhận đã hệ thống hóa các nguyên lý SOLID, đã chỉ ra 4 vấn đề lớn của phần mềm nếu không tuân thủ nghiêm ngặt các quy tắc này:
- Tính cứng nhắc (Rigidity): Thực hiện một thay đổi nhỏ cũng rất khó khăn vì nó kéo theo một chuỗi các thay đổi dây chuyền trong toàn bộ hệ thống.
- Tính dễ vỡ (Fragility): Bất kỳ thay đổi nào cũng có xu hướng làm hỏng phần mềm ở nhiều nơi, ngay cả ở những khu vực không liên quan về mặt logic.
- Tính bất động (Immobility): Chúng ta không thể tái sử dụng các module từ dự án này sang dự án khác (hoặc trong cùng một dự án) vì các module đó có quá nhiều phụ thuộc chằng chịt.
- Tính nhớt (Viscosity): Rất khó để triển khai tính năng mới đúng cách (đúng kiến trúc), khiến lập trình viên có xu hướng chọn con đường "dễ dãi" (hacky) nhưng sai lầm.
Lưu ý: SOLID là kim chỉ nam, không phải luật bất biến. Điều quan trọng là phải hiểu cốt lõi của nó và áp dụng với sự phán đoán sắc bén. Có những trường hợp chỉ cần một vài nguyên lý trong số đó là đủ.
S.O.L.I.D là viết tắt của:
- Single Responsibility Principle (Nguyên lý Đơn nhiệm)
- Open Closed Principle (Nguyên lý Đóng/Mở)
- Liskov Substitution Principle (Nguyên lý Thay thế Liskov)
- Interface Segregation Principle (Nguyên lý Phân tách Interface)
- Dependency Inversion Principle (Nguyên lý Đảo ngược phụ thuộc)
1. Single Responsibility Principle (SRP)
Mỗi hàm, class hoặc module nên có một, và chỉ một lý do để thay đổi. Điều này ngụ ý rằng nó chỉ nên có một nhiệm vụ duy nhất và đóng gói nhiệm vụ đó trong class (thúc đẩy độ kết dính - cohesion cao).
Nó hỗ trợ khái niệm "Phân tách các mối quan tâm" (Separation of Concerns) — làm một việc và làm thật tốt!
Ví dụ, xem xét class sau:
class Menu {
constructor(dish) {
this.dish = dish;
}
getDishName() {
return this.dish;
}
// Vi phạm SRP: Xử lý cả logic Database
saveDish(dish) {
// logic lưu vào database
}
}
Class này vi phạm SRP. Nó vừa quản lý các thuộc tính của menu vừa xử lý các thao tác database. Nếu có bất kỳ cập nhật nào trong các hàm quản lý database, nó sẽ ảnh hưởng đến cả các hàm quản lý thuộc tính, dẫn đến sự phụ thuộc chặt chẽ (tight coupling).
Dưới đây là ví dụ về class có độ kết dính cao hơn và ít phụ thuộc hơn:
// Chỉ chịu trách nhiệm quản lý thuộc tính Menu
class Menu {
constructor(dish) {
this.dish = dish;
}
getDishName() {
return this.dish;
}
}
// Chỉ chịu trách nhiệm quản lý Database
class MenuDB {
getDishes(dish) {
// logic lấy dữ liệu
}
saveDishes(dish) {
// logic lưu dữ liệu
}
}
2. Open Closed Principle (OCP)
Các class, hàm hoặc module nên mở cho việc mở rộng, nhưng đóng với việc sửa đổi. Nếu bạn tạo và publish một class, việc thay đổi chi tiết triển khai bên trong có thể làm hỏng code của những người đã sử dụng nó. Tính trừu tượng (Abstraction) là chìa khóa để thực hiện OCP đúng cách.
Xem xét ví dụ có vấn đề sau:
class Menu {
constructor(dish) {
this.dish = dish;
}
getDishName() { /*...*/ }
}
function getCuisines(dishes) {
for (let i = 0; i < dishes.length; i++) {
if (dishes[i].name === "Burrito") {
console.log("Mexican");
} else if (dishes[i].name === "Pizza") {
console.log("Italian");
}
}
}
Hàm getCuisines() không đáp ứng nguyên lý Đóng-Mở vì nó không thể "đóng" trước các loại món ăn mới. Nếu ta thêm món "Croissant", ta buộc phải sửa đổi hàm này:
// Buộc phải sửa code cũ để thêm tính năng mới
if (dishes[i].name === "Croissant") {
console.log("French");
}
Đây là cách chúng ta làm cho codebase tuân thủ OCP bằng cách sử dụng tính đa hình:
class Menu {
constructor(dish) {
this.dish = dish;
}
// Ý tưởng về phương thức trừu tượng
getCuisine() {
throw new Error("Method 'getCuisine()' must be implemented.");
}
}
class Burrito extends Menu {
getCuisine() {
return "Mexican";
}
}
class Pizza extends Menu {
getCuisine() {
return "Italian";
}
}
class Croissant extends Menu {
getCuisine() {
return "French";
}
}
function getCuisines(dishes) {
for (let i = 0; i < dishes.length; i++) {
// Hoạt động với mọi món ăn mới mà không cần sửa hàm này
console.log(dishes[i].getCuisine());
}
}
3. Liskov Substitution Principle (LSP)
Một lớp con phải có khả năng thay thế cho lớp cơ sở (base type) của nó. Điều này khẳng định rằng chúng ta có thể thay thế một lớp cha bằng lớp con mà không làm thay đổi hành vi đúng đắn của chương trình, xác nhận mối quan hệ "is-a" (là một).
Các lớp con phải tuân thủ một "hợp đồng" được định nghĩa bởi lớp cha.
Ví dụ, Menu có hàm getCuisines được sử dụng bởi Burrito, Pizza, và Croissant mà không phá vỡ logic ứng dụng.
class Menu {
constructor(dish) {
this.dish = dish;
}
getCuisine() {
return this.cuisine;
}
}
class Burrito extends Menu {
constructor() {
super("Burrito");
this.cuisine = "Mexican";
}
}
class Pizza extends Menu {
constructor() {
super("Pizza");
this.cuisine = "Italian";
}
}
// Cả hai lớp con đều có thể thay thế lớp cha Menu khi sử dụng
const burrito = new Burrito();
const pizza = new Pizza();
4. Interface Segregation Principle (ISP)
Một client không bao giờ nên bị buộc phải implement một interface mà nó không sử dụng, và các client không nên bị buộc phải phụ thuộc vào các phương thức mà chúng không cần.
Xem xét interface nguyên khối (monolithic) sau:
// Interface khái niệm
interface ICuisines {
mexican();
italian();
french();
}
// Bị ép buộc triển khai
class Burrito implements ICuisines {
mexican() { /* logic thực tế */ }
italian() { throw new Error("Không hỗ trợ"); }
french() { throw new Error("Không hỗ trợ"); }
}
Nếu thêm một phương thức mới vào interface, tất cả các class khác đều phải khai báo phương thức đó. Để giải quyết, hãy chia nhỏ interface:
interface MexicanFood {
mexican();
}
interface ItalianFood {
italian();
}
class Burrito implements MexicanFood {
mexican() { /* logic */ }
}
Nhiều interface cụ thể cho từng client (client-specific) sẽ tốt hơn là một interface đa năng (general-purpose).
5. Dependency Inversion Principle (DIP)
Các thực thể (Entities) phải phụ thuộc vào sự trừu tượng (abstractions), không phải sự cụ thể (concretions). Các module cấp cao không được phụ thuộc vào module cấp thấp; cả hai nên tách biệt (decouple) và sử dụng abstractions.
- Module cấp cao: Giải quyết các vấn đề thực tế và use cases. Chúng ánh xạ tới nghiệp vụ (phần mềm làm gì).
- Module cấp thấp: Chứa chi tiết triển khai cần thiết để thực thi chính sách nghiệp vụ (phần mềm làm như thế nào).
Ví dụ xấu (Phụ thuộc chặt):
const mysql = require('mysql');
const pool = mysql.createPool({});
class MenuDB {
constructor() {
// Phụ thuộc trực tiếp vào driver cụ thể của database
this.db = pool;
}
saveDishes() {
this.db.save();
}
}
Ví dụ tốt (Đã tách biệt):
// Sự trừu tượng (Abstraction)
interface IDatabase {
save();
}
class MenuDB {
// Phụ thuộc vào interface/abstraction, không phải MySQL cụ thể
constructor(db) {
this.db = db;
}
saveDishes() {
this.db.save();
}
}
Lời kết
Mã nguồn tuân thủ các nguyên lý S.O.L.I.D có thể dễ dàng chia sẻ, mở rộng, sửa đổi, kiểm thử và tái cấu trúc. Việc hiểu sai hoặc dùng các anti-patterns có thể dẫn đến mã nguồn STUPID:
- Singleton (Lạm dụng Singleton)
- Tight Coupling (Phụ thuộc chặt)
- Untestability (Không thể kiểm thử)
- Premature Optimization (Tối ưu hóa sớm)
- Indescriptive Naming (Đặt tên tối nghĩa)
- Duplication (Trùng lặp code)
SOLID giúp các nhà phát triển tránh xa những cạm bẫy này.
Phản Ứng Của Bạn Là Gì?
Thích
0
Không Thích
0
Yêu
0
Hài hước
0
Giận dữ
0
Buồn
0
Wow
0