Trong quá trình phát triển phần mềm, nguyên lý SOLID được xem là kim chỉ nam cho việc tổ chức và viết code. Tuy nhiên nó là những nguyên lý hết sức trừu tượng và khó hiểu nếu như không có những ví dụ cụ thể. Vậy hôm nay chúng ta hãy cùng nhau phân tích những trường hợp vi phạm nguyên lý SOLID để có thể viết mã dễ bảo trì và sửa đổi nhé.
1. Single Responsibility Principle — SRP:
- Một lớp chỉ nên có một lý do để thay đổi.
Mô tả nguyên lý này có nghĩa là mỗi lớp trong hệ thống nên chỉ chịu trách nhiệm cho một khía cạnh cụ thể của chức năng hoặc logic. Nếu một lớp có quá nhiều trách nhiệm, khi có thay đổi trong hệ thống, sẽ tạo ra rủi ro cao là lớp đó cần phải được sửa đổi. Điều này có thể dẫn đến vấn đề khi bảo trì mã nguồn và mở rộng chức năng.
Ví dụ để minh họa nguyên lý SRP:
class User {
String? name;
String? mail;
String? password;
User(this.name, this.mail, this.password);
bool isValid() {
if (name == null || name!.isEmpty) {
return false;
}
if (mail == null || mail!.isEmpty) {
return false;
}
if (password == null || password!.isEmpty) {
return false;
}
return true;
}
void saveToFirebase() {
// save to database
}
void sendVerificationMail() {
// send verification mail
}
}
Trong ví dụ trên, lớp User có ba trách nhiệm: Lưu trữ thông tin người dùng, quản lý việc lưu trữ dữ liệu vào database và gửi email. Điều này không tuân theo nguyên lý SRP. Thay vào đó, chúng ta có thể tạo ra các lớp riêng biệt cho từng trách nhiệm:
class User {
String? name;
String? mail;
String? password;
User(this.name, this.mail, this.password);
bool isValid() {
if (name == null || name!.isEmpty) {
return false;
}
if (mail == null || mail!.isEmpty) {
return false;
}
if (password == null || password!.isEmpty) {
return false;
}
return true;
}
}
class UserRepository {
void saveUserToFirebase(User user) {
}
}
class Authentication {
final UserRepository userRepository = UserRepository();
void registerUser(User user) {
if (!user.isValid()) {
userRepository.saveUserToFirebase(user);
sendEmailVerification(user);
}
}
void sendEmailVerification(User user) {
// Send email verification
}
}
Bằng cách này, mỗi lớp chỉ chịu trách nhiệm cho một công việc cụ thể, làm cho mã nguồn trở nên dễ đọc, dễ bảo trì và linh hoạt hơn.
2. Open/Closed Principle — OCP:
- Các lớp là mở với việc mở rộng nhưng đóng với việc sửa đổi
Nếu chúng ta có một Class và chúng ta đã viết test để test Class đó ngon lành rồi, việc sử đổi Class sẽ làm chúng ta phải test lại thêm lần nữa, vừa tốn thời gian vừa làm tăng khả năng xảy ra lỗi. Cụ thể, một lớp nên được thiết kế sao cho có thể mở rộng chức năng mà không cần phải sửa đổi mã nguồn của lớp đó.
Nhìn vào ví dụ trên, nếu khách hàng đột nhiên không muốn lưu thông tin người dùng trên Firebase nữa mà muốn chuyển qua một CSDL khác thì chúng ta chắc chắn phải sửa hết code Authentication và cả UserRepository.
Điều này có thể được khắc phục bằng việc sử dụng Interface
interface class UserRepository {
void saveUser(User user) {
// save to db
}
}
class UserRepositoryWithFirebase implements UserRepository {
void saveUser(User user) {
// save to firebase with Firebase SDK
}
}
class UserRepositoryWithMongoDB implements UserRepository {
void saveUser(User user) {
// save to MongoDB with MongoDB SDK
}
}
class Authentication {
UserRepository userRepository;
Authentication(this.userRepository);
void register(User user) {
if (user.isValid()) {
userRepository.saveUser(user);
sendVerificationMail(user);
}
}
void sendVerificationMail(User user) {
}
}
Lúc này dù khách hàng muốn đổi sang loại Database nào, việc của chúng ta cần làm chỉ là tạo một UserRepository mới kế thừa từ Interface cơ sở. Authentication chỉ cần lưu User còn lưu bằng cách nào và như thế nào không phải là nhiệm vụ của class này.
3. Liskov Substitution Principle — LSP:
- Đối tượng của một lớp con nên có thể thay thế đối tượng của lớp cơ sở mà không làm thay đổi tính đúng đắn của chương trình.
Mô tả nguyên lý này có nghĩa là nếu một lớp con được sử dụng thay thế cho lớp cơ sở của nó, chương trình vẫn phải hoạt động đúng mà không cần sửa đổi. Điều này đặt ra yêu cầu cho các lớp con phải “hoàn toàn thay thế” (substitute) được cho lớp cơ sở mà nó kế thừa.
Một ví dụ về vi phạm LSP:
abstract class Bird {
void fly();
}
class Eagle extends Bird {
@override
void fly() {
print('Eagle is flying');
}
}
class Penguin extends Bird {
@override
void fly() {
throw Exception('Penguin cannot fly');
}
}
Trong ví trụ này, Eagle có thể bay và tất cả hành vi của Eagle có thể thay thế được cho Bird. Tuy nhiên khi đưa tham chiếu Penguin cho Bird sẽ xảy ra lỗi khi chúng ta gọi làm fly() vì hàm này ném ra một Exception. Vậy Penguin không thể là sub class của Bird.
Cách giải quyết:
abstract class Animal {
}
abstract class Bird extends Animal {
void fly();
}
class Eagle extends Bird {
@override
void fly() {
print('Eagle is flying');
}
}
class Penguin extends Animal {
}
Trong lập trình, việc vi phạm nguyên lý Linkov Substitution khá phổ biến khi chúng ta code vội, không lên kế hoạch và suy nghĩ tỉ mỉ trước khi code, và những bug nó gây ra lại rất nguy hiểm có thể dẫn tới crash chương trình nên các bạn cần hết sức lưu ý vấn đề này.
4. Interface Segregation Principle — ISP:
- Một lớp không nên buộc phải triển khai các phương thức mà nó không sử dụng.
Nguyên lý này nhấn mạnh việc tạo ra các giao diện nhỏ chứa ít phương thức càng tốt, đảm bảo rằng các lớp chỉ cần triển khai những phương thức liên quan đến chức năng của chúng.
Ví dụ để minh họa nguyên lý ISP:
abstract class Bird {
void fly();
void sleep();
}
class Eagle extends Bird {
@override
void fly() {
print('Eagle is flying');
}
@override
void sleep() {
print('Eagle is sleeping');
}
}
class MechanicalBird implements Bird {
@override
void fly() {
print('Mechanical bird is flying');
}
@override
void sleep() {
print('Mechanical bird is sleeping');
}
}
Việc Implement phương thức sleep() trên MechanicalBird là hoàn toàn vô nghĩa vì chim máy không bao giờ cần ngủ. Điều này không gây ra lỗi nhưng lại tạo ra các đoạn Dead Code không đáng xuất hiện làm chương trình trở nên khó hiểu.
Để tuân theo nguyên lý ISP, chúng ta có thể chia nhỏ giao diện thành các phần nhỏ hơn:
abstract class Sleepable {
void sleep();
}
abstract class Flyable {
void fly();
}
class Eagle implements Sleepable, Flyable {
@override
void fly() {
print('Eagle is flying');
}
@override
void sleep() {
print('Eagle is sleeping');
}
}
class MechanicalBird implements Flyable {
@override
void fly() {
print('Mechanical bird is flying');
}
@override
void sleep() {
print('Mechanical bird is sleeping');
}
}
Bằng cách này, chúng ta có thể kết hợp các giao diện để tạo ra các lớp có đặc tính riêng của chúng mà không phải triển khai những phương thức không cần thiết.
Trong dart có khái niệm Mixin thực hiện rất tốt nhiệm vụ này, tuy nhiên mình sẽ không đề cập ở đây.
Sử dụng từ khóa implement giúp bạn có thể triển khai nhiều lớp và các bạn sẽ phải override lại tất cả các hàm mặc dù hàm đó có phần thân hay không.
5. Dependency Inversion Principle — DIP:
- Các module cấp cao không nên phụ thuộc vào các module cấp thấp. Cả hai nên phụ thuộc vào các abstractions (giao diện).
Mô tả nguyên lý này có nghĩa là khi thiết kế hệ thống, chúng ta nên phụ thuộc vào các giao diện (abstractions) thay vì các chi tiết cụ thể. Các module cấp cao không nên biết chi tiết về cách module cấp thấp thực hiện, mà thay vào đó, cả hai nên phụ thuộc vào một giao diện chung.
Ví dụ để minh họa nguyên lý DIP:
import 'package:apple_music_library/apple_music_library.dart';
class MusicPlayerWidget {
AppleMusicLibrary appleMusicLibrary = AppleMusicLibrary();
// UI
// Logic
void playButtonClicked() {
if(appleMusicLibrary.isPlaying) {
return;
}
appleMusicLibrary.play();
}
void pauseButtonClicked() {
if(!appleMusicLibrary.isPlaying) {
return;
}
appleMusicLibrary.pause();
}
void stopButtonClicked() {
appleMusicLibrary.stop();
}
void nextButtonClicked() {
appleMusicLibrary.next();
}
void previousButtonClicked() {
appleMusicLibrary.previous();
}
}
Trong ví dụ này, lớp MusicPlayer phụ thuộc trực tiếp vào lớp AppleMusicLibrary. Trong thực tế còn rất nhiều lớp khác cũng phụ thuộc vào AppleMusicLibrary, tạo ra một mối quan hệ cứng giữa chúng. Điều này không tuân theo nguyên lý DIP.
Giả sử một này AppleMusicLibrary không còn có thể sử dụng, bạn phải tìm một thư viên phát nhạc mới nhưng thư viện này không có các hàm giống AppleMusicLibrary (chẳng hạn thư viện mới sẽ có hàm playOrPause() thay vì hai hàm play() và pause() riêng biệt). Lúc này, tất cả các module phụ thuộc vào AppleMusicLibrary đều phải thay đổi.
Để tuân theo nguyên lý DIP, chúng ta có thể sử dụng giao diện (abstraction) để giảm mức độ phụ thuộc:
abstract class MusicLibrary {
void play();
void pause();
void stop();
void next();
void previous();
}
class AppleMusicLibraryImpl implements MusicLibrary {
// AppleMusicLibrary has play(), pause(), stop(), next(), and previous() methods
final AppleMusicLibrary _appleMusicLibrary;
const AppleMusicLibraryImpl(this._appleMusicLibrary);
@override
void play() {_appleMusicLibrary.play();}
@override
void pause() {_appleMusicLibrary.pause();}
@override
void stop() {_appleMusicLibrary.stop();}
@override
void next() {_appleMusicLibrary.next();}
@override
void previous() {_appleMusicLibrary.previous();}
}
class OtherMusicLibraryImpl implements MusicLibrary {
// OtherMusicLibrary is a class from another package
// that has playOrPause(), stop(), next(), and previous() methods
final OtherMusicLibrary _otherMusicLibrary;
const OtherMusicLibraryImpl(this._otherMusicLibrary);
@override
void play() {
if(!_otherMusicLibrary.isPlaying) {
// Unless the music is already playing, playOrPause() will play the music
_otherMusicLibrary.playOrPause();
}
}
@override
void pause() {
if(_otherMusicLibrary.isPlaying) {
_otherMusicLibrary.playOrPause();
}
}
@override
void stop() {_otherMusicLibrary.stop();}
@override
void next() {_otherMusicLibrary.next();}
@override
void previous() {_otherMusicLibrary.previous();}
}
class MusicPlayer {
final MusicLibrary _library;
MusicPlayer(this._library);
void play() {_library.play();}
void pause() {_library.pause();}
void stop() {_library.stop();}
void next() {_library.next();}
void previous() {_library.previous();}
}
Bằng cách này, lớp MusicPlayer không phụ thuộc trực tiếp vào các lớp của module MusicLibrary, mà thay vào đó nó phụ thuộc vào một giao diện chung. Điều này tạo ra một mức độ phụ thuộc giảm xuống, làm tăng tính linh hoạt và tái sử dụng của hệ thống.
Đối với nguyên lý Dependency Inversion, chúng ta cần dự đoán các module có khả năng sẽ thay đổi trong tương lai. Điều này cũng đảm bảo cho nguyên lý Open/Closed.
Bằng cách tuân theo những nguyên lý này, phần mềm có thể trở nên dễ đọc, linh hoạt và dễ mở rộng, giảm thiểu ảnh hưởng của các thay đổi và làm tăng tính linh hoạt.
Đoạn Code trên mô tả bằng ngôn ngữ Java