Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

solid principles - tổng quan

Tung Bui Thanh (TungDaDev)10 mins read

Solid.webp

SOLID do Robert C. Martin (hay còn gọi là Uncle Bob) đúc kết, gồm những nguyên tắc thiết kế cơ bản để code có cấu trúc tốt và dễ bảo trì. Không phải nguyên tắc nào cũng do ông nghĩ ra: Open/Closed là của Bertrand Meyer (1988), Liskov Substitution là của Barbara Liskov (1987). Uncle Bob gom chúng lại trong bài viết "Design Principles and Design Patterns" (2000), còn cái tên viết tắt SOLID thì Michael Feathers đặt sau đó vài năm.

Bài này đi qua lần lượt 5 nguyên tắc, mỗi cái kèm một ví dụ.

Bắt đầu thôiii :)))

# single responsibility principle (srp)

  • "Một class chỉ nên có một lý do để thay đổi."
  • Mỗi class, method hay hàm chỉ nên phục vụ một mục đích rõ ràng, mọi thứ bên trong đều phải phục vụ mục đích đó.
  • Khi cần sửa, thay đổi chỉ ảnh hưởng tới đúng nhiệm vụ của class đó, không lan sang những phần không liên quan.
/*
* Violates SRP
*/
public class BankAccount {
    private double balance;
 
    public void deposit(double amount) {
        balance += amount;
    }
 
    public void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        } else {
            System.out.println("Insufficient funds");
        }
    }
 
    public void printBalance() {
        System.out.println("Current balance: " + balance);
    }
}

Đoạn code trên vi phạm SRP: chỉ cần yêu cầu đổi, chẳng hạn hiển thị số dư theo định dạng khác, là phải sửa class BankAccount, trong khi class này đáng ra chỉ lo chuyện tiền trong tài khoản.

"Lý do để thay đổi" ở đây nên hiểu là ai yêu cầu thay đổi. Về sau Uncle Bob nói rõ hơn: một module chỉ nên chịu trách nhiệm trước một nhóm người dùng (actor). Quy tắc tính tiền do bên nghiệp vụ quyết, còn cách hiển thị do bên làm giao diện hay báo cáo quyết. Hai nhóm này đổi ý vào những lúc khác nhau, nên tách ra thì sửa bên này không làm vỡ bên kia.

(Ví dụ dùng double cho gọn; tiền thật thì nên dùng BigDecimal hoặc lưu số nguyên theo đơn vị nhỏ nhất, vì double cộng trừ bị sai số làm tròn.)

Cách xử lý là tách thành hai class, mỗi class một nhiệm vụ.

/*
* Follows SRP
*/
public class BankAccount {
    private double balance;
 
    public void deposit(double amount) {
        balance += amount;
    }
 
    public void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        } else {
            throw new IllegalArgumentException("Insufficient funds");
        }
    }
 
    public double getBalance() {
        return balance;
    }
}
 
public class BalanceDisplayer {
    public void printBalance(BankAccount account) {
        System.out.println("Current balance: " + account.getBalance());
    }
}

# open/closed principle (ocp)

  • "Các thành phần của class nên thiết kế để thoải mái mở rộng nhưng không được chỉnh sửa."
  • Thêm chức năng mới mà không phải sửa code đang có.

Tiếp ví dụ trên, giả sử giờ mình muốn hiển thị số dư theo nhiều định dạng khác nhau. Để không phải sửa class BalanceDisplayer (tránh vi phạm OCP), ta thiết kế sao cho muốn thêm định dạng mới thì chỉ việc mở rộng, không đụng vào code cũ.

/*
* Interface for balance display
*/
public interface BalanceDisplay {
    void displayBalance(BankAccount account);
}
 
/*
* Implementation for displaying balance in a simple format
*/
public class SimpleBalanceDisplay implements BalanceDisplay {
    @Override
    public void displayBalance(BankAccount account) {
        System.out.println("Current balance: " + account.getBalance());
    }
}
 
/*
* Implementation for displaying balance in a fancy format
*/
public class FancyBalanceDisplay implements BalanceDisplay {
    @Override
    public void displayBalance(BankAccount account) {
        System.out.println("Fancy Balance: $" + account.getBalance() + " ~~~");
    }
}

Muốn thêm định dạng mới (JSON, HTML...) thì viết thêm một class implement BalanceDisplay, code đang gọi displayBalance không phải sửa dòng nào. "Đóng" ở đây là đóng với chỉnh sửa, chứ không phải không bao giờ được đụng vào. Cũng đừng dựng interface cho mọi thứ từ đầu: chỗ nào thật sự hay thay đổi theo kiểu "thêm một loại mới" thì mới đáng mở điểm mở rộng.

# liskov substitution principle (lsp)

  • "Các class dẫn xuất hoặc class con có thể thay thế cho class cơ sở hoặc class cha của chúng."
  • Nếu B là class con của A thì có thể đưa B vào chỗ của A mà chương trình vẫn chạy đúng.
// Giả sử BankAccount có thêm constructor BankAccount(double initialBalance)
 
/*
* Subclass SavingsAccount
*/
public class SavingsAccount extends BankAccount {
    public SavingsAccount(double balance) {
        super(balance);
    }
 
    /*
    * Additional functionality specific to SavingsAccount
    */
    public void calculateInterest() {
        // Calculate interest for savings account
    }
}
 
public class GoldAccount extends BankAccount {
    private double bonusPoints;
 
    public GoldAccount(double balance, double bonusPoints) {
        super(balance);
        this.bonusPoints = bonusPoints;
    }
 
    @Override
    public void deposit(double amount) {
        super.deposit(amount + (bonusPoints * 0.1)); // Adds bonus points to the deposit
    }
}

Class SavingsAccount tuân thủ LSP: nó chỉ thêm method riêng như calculateInterest, còn cách gửi và rút tiền vẫn y như class cha.

Class GoldAccount thì vi phạm LSP vì đổi hành vi của method deposit so với class cha BankAccount.

Nói cho chính xác thì override không tự nó là vi phạm. Vi phạm là khi class con phá lời hứa (contract) mà code gọi class cha đang dựa vào. Với BankAccount, lời hứa ngầm là "gửi x thì số dư tăng đúng x". Một đoạn đối soát kiểu assert after - before == amount chạy đúng với BankAccount, SavingsAccount, nhưng sai với GoldAccount. Muốn có thưởng thì nên tách ra thành thao tác riêng (ví dụ addBonus()), để deposit giữ đúng nghĩa ban đầu.

# interface segregation principle (isp)

  • "Không ép buộc bất kỳ client nào phải triển khai một interface không liên quan đến chúng."
  • Client không nên bị bắt implement những interface chứa method mà nó không dùng.
  • Thay vì một interface to đùng, nên chia thành nhiều interface nhỏ, mỗi cái gom các method của một chức năng cụ thể.
/*
* Violation of ISP
*/
public interface IBankAccount {
    void deposit(double amount);
    void withdraw(double amount);
    double getBalance();
    void printStatement();
    void requestLoan();
}
 
public class BankAccount implements IBankAccount {
    private double balance;
 
    public void deposit(double amount) {
        // implementation details
    }
 
    public void withdraw(double amount) {
        // implementation details
    }
 
    public double getBalance() {
        // implementation details
    }
 
    public void printStatement() {
        // implementation details
    }
 
    public void requestLoan() {
        // implementation details
    }
}

Interface IBankAccount vi phạm ISP vì chứa những method mà không phải class nào implement nó cũng cần.

Class BankAccount phải implement hết IBankAccount, dù có thể chẳng cần method requestLoan.

/*
* Adheres to ISP
* Interface for account management
*/
public interface AccountManager {
    void deposit(double amount);
    void withdraw(double amount);
    double getBalance();
}
 
/*
* Interface for account reporting
*/
public interface AccountReporter {
    void printStatement();
}
 
/*
* Interface for loan management
*/
public interface LoanManager {
    void requestLoan();
}

Giờ mỗi class chỉ phụ thuộc vào những interface đúng với trách nhiệm của nó, đúng tinh thần ISP. Ví dụ tài khoản thanh toán thông thường chỉ cần implements AccountManager, AccountReporter, còn class nào hỗ trợ vay mới implement thêm LoanManager. Lợi ích còn nằm ở phía người dùng interface: màn hình in sao kê chỉ cần nhận AccountReporter, nên đổi gì ở phần vay cũng không làm nó phải biên dịch hay test lại.

# dependency inversion principle (dip)

  • "Các mô-đun cấp cao không nên phụ thuộc vào các mô-đun cấp thấp. Cả hai nên phụ thuộc vào các trừu tượng."
  • "Trừu tượng không nên phụ thuộc vào chi tiết. Chi tiết nên phụ thuộc vào trừu tượng."

Thử nghĩ tới quán cà phê đầu hẻm nhà bạn. Chủ quán không ký hợp đồng riêng với Vietcombank, Techcombank, MoMo..., cũng không cần biết khách dùng ngân hàng nào. Quán chỉ dán một tấm mã QR lên quầy và nói: "muốn trả tiền thì quét mã". Ngân hàng hay ví điện tử nào muốn khách của mình trả được tiền ở đây thì tự lo hỗ trợ quét mã đó. Quán (module cấp cao) và ngân hàng (module cấp thấp) không biết gì về nhau, cả hai cùng dựa vào một thứ trừu tượng là "thanh toán qua mã QR". Mai có thêm một ví điện tử mới thì quán cũng chẳng phải đổi gì. Ví dụ code dưới đây đúng là chuyện thanh toán như vậy.

/*
* Violates DIP
*/
public class ShoppingMall {
    private BankAccount bankAccount;
 
    public ShoppingMall(BankAccount bankAccount) {
        this.bankAccount = bankAccount;
    }
 
    public void doPayment(String order, double amount) {
        bankAccount.withdraw(amount);
        // Process the payment
    }
}

Ở ví dụ này, ShoppingMall (module cấp cao) phụ thuộc trực tiếp vào BankAccount (module cấp thấp), nên vi phạm DIP.

Cách sửa là thêm một lớp trừu tượng để cả ShoppingMall và BankAccount cùng phụ thuộc vào.

/*
* Adhering to DIP
*/
public interface PaymentProcessor {
    void processPayment(double amount);
}
 
 
public class BankAccount implements PaymentProcessor {
    // implementation details
}
 
public class ShoppingMall {
    private PaymentProcessor paymentProcessor;
 
    public ShoppingMall(PaymentProcessor paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }
 
    public void doPayment(String order, double amount) {
        paymentProcessor.processPayment(amount);
        // Process the payment
    }
}

Chữ "inversion" nằm ở chỗ ai sở hữu interface. PaymentProcessor nên được định nghĩa theo nhu cầu của ShoppingMall và đặt cùng package với nó (vì thế mình bỏ getBalance() khỏi interface, ShoppingMall không dùng tới). Khi đó chiều phụ thuộc ở mức source code bị đảo lại: module cấp thấp phải theo interface của module cấp cao, chứ không phải ngược lại. Thực tế cũng ít khi bắt BankAccount implement thẳng interface thanh toán, thường sẽ có một adapter kiểu BankAccountPaymentProcessor bọc BankAccount bên trong, sau này thêm ví điện tử hay thẻ tín dụng thì chỉ viết thêm adapter.

Cũng đừng nhầm DIP với Dependency Injection. Constructor nhận PaymentProcessor từ ngoài vào là DI, một kỹ thuật giúp làm DIP cho tiện; còn DIP là nguyên tắc về hướng phụ thuộc. Truyền vào qua constructor mà kiểu vẫn là class cụ thể BankAccount, như ví dụ vi phạm ở trên, thì vẫn chưa đạt DIP.

# lời kết

SOLID là 5 nguyên tắc cơ bản khi thiết kế phần mềm, giúp mình tổ chức function, method, class gọn gàng và rõ ràng hơn.

Module, class viết theo SOLID thường độc lập với nhau, nên khi yêu cầu đổi hay cần mở rộng thì không tốn nhiều công sửa.

Ngoài ra, SOLID buộc mình nghĩ kỹ hơn về cách viết code, nhờ vậy code ra dễ đọc, dễ dùng.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.

Reference: