1. Tổng quan
Trong hướng dẫn này, chúng ta sẽ thảo luận về các nguyên tắc SOLID của thiết kế hướng đối tượng.
Đầu tiên, chúng ta sẽ bắt đầu bằng cách khám phá lý do chúng xuất hiện và tại sao chúng ta nên xem xét chúng khi thiết kế phần mềm. Sau đó, chúng tôi sẽ phác thảo từng nguyên tắc cùng với một số mã ví dụ.
2. Lý do cho các nguyên tắc SOLID
Các nguyên tắc SOLID đã được giới thiệu bởi Robert C. Martin trong bài báo năm 2000 của ông “Design Principles and Design”. Những khái niệm này sau đó được xây dựng bởi Michael Feathers, người đã giới thiệu cho chúng ta từ viết tắt SOLID. Và trong 20 năm qua, năm nguyên tắc này đã cách mạng hóa thế giới lập trình hướng đối tượng, thay đổi cách chúng ta viết phần mềm.
Vậy, SOLID là gì và nó giúp chúng ta viết code tốt hơn như thế nào? Nói một cách đơn giản, các nguyên tắc thiết kế của Martin và Feathers khuyến khích chúng tôi tạo ra phần mềm dễ bảo trì, dễ hiểu và linh hoạt hơn. Do đó, khi các ứng dụng của chúng tôi phát triển về kích thước, chúng tôi có thể giảm độ phức tạp của chúng và tiết kiệm cho mình rất nhiều vấn đề đau đầu hơn nữa!
Năm khái niệm sau đây tạo nên các nguyên tắc SOLID của chúng tôi:
- Single Responsibility
- Open/Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
Mặc dù các khái niệm này có vẻ khó khăn, nhưng chúng có thể dễ dàng hiểu được với một số ví dụ mã đơn giản. Trong các phần sau, chúng ta sẽ đi sâu vào các nguyên tắc này, với một ví dụ Java nhanh để minh họa từng nguyên tắc.
3. Single Responsibility
Hãy bắt đầu với nguyên tắc trách nhiệm duy nhất. Như chúng ta có thể mong đợi, nguyên tắc này nói rằng một class chỉ nên có một trách nhiệm. Hơn nữa, nó chỉ nên có một lý do để thay đổi.
Nguyên tắc này giúp chúng ta xây dựng phần mềm tốt hơn như thế nào? Hãy xem một vài lợi ích của nó:
- Testing – Một lớp với một trách nhiệm sẽ có ít trường hợp kiểm thử hơn.
- Lower coupling – Ít chức năng hơn trong một lớp duy nhất sẽ có ít phụ thuộc hơn.
- Organization – Các lớp học nhỏ hơn, được tổ chức tốt dễ tìm kiếm hơn các lớp nguyên khối.
Ví dụ, chúng ta hãy nhìn vào một lớp học để đại diện cho một cuốn sách đơn giản:
public class Book {
private String name;
private String author;
private String text;
//constructor, getters and setters
}
Trong mã này, chúng tôi lưu trữ tên, tác giả và văn bản được liên kết với một thể hiện của Sách.
Bây giờ chúng ta hãy thêm một vài phương thức để truy vấn văn bản:
public class Book {
private String name;
private String author;
private String text;
//constructor, getters and setters
// methods that directly relate to the book properties
public String replaceWordInText(String word, String replacementWord){
return text.replaceAll(word, replacementWord);
}
public boolean isWordInText(String word){
return text.contains(word);
}
}
Bây giờ lớp Sách của chúng tôi hoạt động tốt và chúng tôi có thể lưu trữ bao nhiêu sách tùy thích trong ứng dụng của mình.
Nhưng lưu trữ thông tin có ích gì nếu chúng ta không thể xuất văn bản ra bảng điều khiển của mình và đọc nó?
Hãy thận trọng với gió và thêm một phương pháp in:
public class BadBook {
//...
void printTextToConsole(){
// our code for formatting and printing the text
}
}
Tuy nhiên, quy tắc này vi phạm nguyên tắc trách nhiệm duy nhất mà chúng tôi đã nêu trước đó.
Để sửa chữa mớ hỗn độn của chúng ta, chúng ta nên triển khai một lớp riêng biệt chỉ liên quan đến việc in các văn bản của chúng ta:
public class BookPrinter {
// methods for outputting text
void printTextToConsole(String text){
//our code for formatting and printing the text
}
void printTextToAnotherMedium(String text){
// code for writing to any other location..
}
}
Tuyệt vời. Chúng tôi không chỉ phát triển một lớp học giúp giảm bớt nhiệm vụ in ấn của Sách mà còn có thể tận dụng lớp BookPrinter của mình để gửi văn bản của chúng tôi đến các phương tiện khác.
Cho dù đó là email, ghi nhật ký hay bất cứ điều gì khác, chúng tôi có một lớp riêng dành riêng cho mối quan tâm này.
4. Open for Extension, Closed for Modification
Bây giờ là lúc cho O trong SOLID, được gọi là nguyên tắc mở-đóng. Nói một cách đơn giản, các lớp nên được mở để mở rộng nhưng đóng cửa để sửa đổi. Khi làm như vậy, chúng tôi ngăn mình sửa đổi mã hiện có và gây ra các lỗi mới tiềm ẩn trong một ứng dụng vui vẻ khác.
Tất nhiên, một ngoại lệ đối với quy tắc là khi sửa lỗi trong mã hiện có.
Hãy khám phá khái niệm này với một ví dụ mã nhanh. Là một phần của một dự án mới, hãy tưởng tượng chúng ta đã triển khai một lớp học Guitar.
Nó hoàn toàn chính thức và thậm chí có một núm âm lượng:
public class Guitar {
private String make;
private String model;
private int volume;
//Constructors, getters & setters
}
Chúng tôi khởi chạy ứng dụng và mọi người đều yêu thích nó. Nhưng sau một vài tháng, chúng tôi quyết định Guitar hơi nhàm chán và có thể sử dụng mô hình ngọn lửa mát mẻ để làm cho nó trông rock and roll hơn.
Tại thời điểm này, có thể rất hấp dẫn khi chỉ mở lớp Guitar và thêm một mẫu ngọn lửa – nhưng ai biết được những lỗi nào có thể xảy ra trong ứng dụng của chúng tôi.
Thay vào đó, hãy tuân thủ nguyên tắc open-closed và chỉ cần mở rộng lớp Guitar của chúng ta:
public class SuperCoolGuitarWithFlames extends Guitar {
private String flameColor;
//constructor, getters + setters
}
Bằng cách mở rộng lớp Guitar, chúng ta có thể chắc chắn rằng ứng dụng hiện tại của chúng ta sẽ không bị ảnh hưởng.
5. Liskov Substitution
Tiếp theo trong danh sách của chúng tôi là thay thế Liskov, được cho là phức tạp nhất trong năm nguyên tắc. Nói một cách đơn giản, nếu lớp A là một kiểu con của lớp B, chúng ta sẽ có thể thay thế B bằng A mà không làm gián đoạn hành vi của chương trình của chúng ta.
Hãy đi thẳng vào code để giúp chúng ta hiểu khái niệm này:
public interface Car {
void turnOnEngine();
void accelerate();
}
Ở trên, chúng tôi định nghĩa một giao diện Car đơn giản với một vài phương pháp mà tất cả các xe ô tô đều có thể thực hiện: bật động cơ và tăng tốc về phía trước.
Hãy triển khai giao diện của chúng tôi và cung cấp một số mã cho các phương thức:
public class MotorCar implements Car {
private Engine engine;
//Constructors, getters + setters
public void turnOnEngine() {
//turn on the engine!
engine.on();
}
public void accelerate() {
//move forward!
engine.powerOn(1000);
}
}
Như mã của chúng tôi mô tả, chúng tôi có một công cụ mà chúng tôi có thể bật và chúng tôi có thể tăng sức mạnh.
Nhưng chờ đã – chúng ta hiện đang sống trong kỷ nguyên của ô tô điện:
public class ElectricCar implements Car {
public void turnOnEngine() {
throw new AssertionError("I don't have an engine!");
}
public void accelerate() {
//this acceleration is crazy!
}
}
Bằng cách ném một chiếc xe không có động cơ vào hỗn hợp, chúng tôi vốn đã thay đổi hành vi của chương trình của chúng tôi. Đây là một sự vi phạm trắng trợn sự thay thế Liskov và khó sửa chữa hơn một chút so với hai nguyên tắc trước đây của chúng tôi.
Một giải pháp khả thi là làm lại mô hình của chúng tôi thành các giao diện có tính đến trạng thái không có động cơ của Ô tô của chúng tôi.
6. Interface Segregation
I trong SOLID là viết tắt của interface segregation, và nó chỉ đơn giản có nghĩa là các giao diện lớn hơn nên được chia thành các giao diện nhỏ hơn. Bằng cách đó, chúng ta có thể đảm bảo rằng việc triển khai các lớp học chỉ cần quan tâm đến các phương pháp mà họ quan tâm.
Đối với ví dụ này, chúng tôi sẽ thử sức mình với tư cách là người trông coi sở thú. Và cụ thể hơn, chúng tôi sẽ làm việc trong chuồng gấu.
Hãy bắt đầu với một giao diện phác thảo vai trò của chúng ta với tư cách là người nuôi gấu:
public interface BearKeeper {
void washTheBear();
void feedTheBear();
void petTheBear();
}
Là những người chăm sóc vườn thú khao khát, chúng tôi rất vui khi được tắm rửa và cho những chú gấu yêu quý của chúng tôi ăn. Nhưng tất cả chúng ta đều nhận thức được sự nguy hiểm của việc vuốt ve chúng. Thật không may, giao diện của chúng tôi khá lớn và chúng tôi không có lựa chọn nào khác ngoài việc triển khai mã để nuôi gấu.
Hãy khắc phục điều này bằng cách chia giao diện lớn của chúng ta thành ba giao diện riêng biệt:
public interface BearCleaner {
void washTheBear();
}
public interface BearFeeder {
void feedTheBear();
}
public interface BearPetter {
void petTheBear();
}
Bây giờ, nhờ sự phân tách giao diện, chúng ta có thể tự do triển khai chỉ các phương thức quan trọng đối với chúng ta:
public class BearCarer implements BearCleaner, BearFeeder {
public void washTheBear() {
//I think we missed a spot...
}
public void feedTheBear() {
//Tuna Tuesdays...
}
}
Và cuối cùng, chúng ta có thể để lại những thứ nguy hiểm cho những người liều lĩnh:
public class CrazyPerson implements BearPetter {
public void petTheBear() {
//Good luck with that!
}
}
Đi xa hơn, chúng ta thậm chí có thể tách lớp BookPrinter của chúng ta khỏi ví dụ trước đó để sử dụng phân tách giao diện theo cùng một cách. Bằng cách triển khai giao diện Printer với một phương thức in duy nhất, chúng ta có thể khởi tạo các lớp ConsoleBookPrinter và OtherMediaBookPrinter riêng biệt.
7. Dependency Inversion
Nguyên tắc đảo ngược phụ thuộc đề cập đến việc tách các mô-đun phần mềm. Bằng cách này, thay vì các mô-đun cấp cao phụ thuộc vào các mô-đun cấp thấp, cả hai sẽ phụ thuộc vào sự trừu tượng.
Để chứng minh điều này, chúng ta hãy đi học cũ và mang đến một máy tính Windows 98 với mã:
public class Windows98Machine {}
Nhưng một máy tính không có màn hình và bàn phím có gì tốt? Hãy thêm một trong mỗi cái vào hàm tạo của chúng ta để mọi Máy tính Windows98 mà chúng ta khởi tạo đều được đóng gói sẵn Màn hình và Bàn phím tiêu chuẩn:
public class Windows98Machine {
private final StandardKeyboard keyboard;
private final Monitor monitor;
public Windows98Machine() {
monitor = new Monitor();
keyboard = new StandardKeyboard();
}
}
Mã này sẽ hoạt động và chúng ta sẽ có thể sử dụng StandardKeyboard và Monitor một cách tự do trong lớp Windows98Computer của chúng ta.
Vấn đề được giải quyết? Gần. Bằng cách khai báo StandardKeyboard và Monitor với từ khóa new, chúng tôi đã kết hợp chặt chẽ ba lớp này với nhau.
Điều này không chỉ làm cho Windows98Computer của chúng tôi khó kiểm tra mà còn mất khả năng chuyển đổi lớp StandardKeyboard của mình bằng một lớp khác nếu có nhu cầu. Và chúng tôi cũng bị mắc kẹt với lớp Monitor của mình.
Hãy tách máy của chúng ta khỏi StandardKeyboard bằng cách thêm giao diện Bàn phím tổng quát hơn và sử dụng giao diện này trong lớp của chúng ta:
public interface Keyboard { }
public class Windows98Machine{
private final Keyboard keyboard;
private final Monitor monitor;
public Windows98Machine(Keyboard keyboard, Monitor monitor) {
this.keyboard = keyboard;
this.monitor = monitor;
}
}
Ở đây, chúng ta đang sử dụng dependency injection pattern để tạo điều kiện thêm Keyboard dependency vào class Windows98Machine.
Chúng ta cũng hãy sửa đổi lớp StandardKeyboard của chúng ta để triển khai giao diện Bàn phím sao cho phù hợp để đưa vào lớp Windows98Machine:
public class StandardKeyboard implements Keyboard { }
Bây giờ các lớp học của chúng tôi được tách rời và giao tiếp thông qua trừu tượng Bàn phím. Nếu muốn, chúng ta có thể dễ dàng chuyển đổi loại bàn phím trong máy của mình với một triển khai giao diện khác. Chúng ta có thể làm theo nguyên tắc tương tự cho lớp Monitor.
Tuyệt vời! Chúng tôi đã tách rời các phụ thuộc và có thể tự do kiểm tra Windows98Machine của chúng tôi với bất kỳ khung kiểm tra nào chúng tôi chọn.
8. Kết luận
Trong bài viết này, chúng ta đã đi sâu vào các nguyên tắc SOLID của thiết kế hướng đối tượng.
Chúng tôi bắt đầu với một chút lịch sử SOLID và lý do những nguyên tắc này tồn tại.
Từng chữ một, chúng tôi đã chia nhỏ ý nghĩa của từng nguyên tắc bằng một ví dụ mã nhanh vi phạm nó. Sau đó, chúng tôi đã thấy cách sửa mã của chúng tôi và làm cho nó tuân thủ các nguyên tắc SOLID.