SOLID – Nguyên lý hàng đầu trong thiết kế Hướng đối tượng PHP

Giới thiệu

Trước đây từ khi đi học thông thường cách làm của các anh chị em ít kinh nghiệm là làm sao cho chương trình chạy là tốt rồi, chứ chưa thực sự quan tâm nhiều tới việc mở rộng, hay tối ưu sau này. Và nguyên lý SOLID này cũng sẽ như là những kiến thức cơ bản để mọi lập trình viên có cơ hội review lại mình, từ đó tạo ra những mã nguồn chất lượng hơn.


Nguyên Lý SOLID là bộ 5 nguyên tắc, mà mục tiêu của những nguyên tắc nhằm tạo ra những đoạn mã (code) ít làm ảnh hưởng đến phần còn lại. Điều này có nghĩa là nâng cấp, sửa chữa gây ảnh hưởng đến các phần còn lại càng ít càng tốt. Từ đó sẽ giảm bớt chi phí thời gian, tiền bạc, công sức bảo trì nâng cấp về sau này.


Cụ thể 5 nguyên tắc trong SOLID là

+ Nguyên tắc đơn chức năng – Single Responsibility Principle – SRP

+ Nguyên tắc đóng mở – Open/Closed Principle – OCP

+ Nguyên tắc thay thế – Liskov Substitution Principle –  LSP

+ Nguyên tắc phân tách – Interface Segregation Principle – ISP

+ Nguyên tắc nghịch đảo phụ thuộc – Dependency Inversion Principle – DIP

Nguyên tắc đơn chức năng – Single Responsibility Principle – SRP

Định nghĩa

Mỗi class chỉ nên giữ 1 trách nhiệm duy nhất. Hay nói cách khác Một class chỉ nên có một và chỉ một lý do để thay đổi mà thôi.

<?php
namespace Toan\Solid\Example;
class User{
    //Lấy thông tin User từ Database
    public function getUserFromDatabase(){
        // Code lấy thông tin user
    }
    // Hàm Login
    public function login(){
        //Login với user hiện tại
    }
    //Hàm validate dữ liệu trước khi xử lý
    public function validate(){
        
    }
    // Hàm ghi log xử lý
    public function log(){
        // do something??
    }
}

Trong class này chúng ta thấy 1 số hàm như

Hàm getUserFromDatabase: Lấy thông tin User từ database

Hàm login: Thực hiện đăng nhập

Hàm validate: Thực hiện validate dữ liệu

Hàm log: Ghi log chương trình

Nếu đối chiếu với nguyên tắc đơn chức năng trong nguyên lý SOLID thì class User trên đây đang vi phạm rõ ràng. Khi mà trong class User đang thực hiện quá nhiều nhiệm vụ trong 1 class

Để thực hiện theo nguyên tắc được đưa ra thì chúng ta có thể tách class bên trên thành nhiều class nhở, với mỗi class thực hiện 1 chức năng riêng biệt

<?php
namespace Toan\Solid\Example;
class User{
    // Hàm Login
    public function login(){
        //Login với user hiện tại
    }
}
class UserResposity{
    //Lấy thông tin User từ Database
    public function getUserFromDatabase(){
        // Code lấy thông tin user
    }
}
class UserValidator{
    //Hàm validate dữ liệu trước khi xử lý
    public function validate(){
        
    }
}

class UserLog{
    // Hàm ghi log xử lý
    public function log(){
        // do something??
    }
}

Nguyên tắc đóng mở – Open-Closed Principle – OCP

Định nghĩa

Đối tượng, class có thể mở rộng 1 cách thoải mái, nhưng không được phép sửa đổi bên trong class đó

Mới đầu khi nghe qua điều trên thì chúng ta sẽ thấy thật vô lý, không hiểu kiểu gì mà mở rộng thoải mái nhưng lại không được phép sửa đổi. Nhưng việc này hoàn toàn xử lý được bằng các kĩ thuật khác, ví dụ như Kế Thừa các cần class mở rộng. Như vậy chúng ta sẽ không phải sửa đổi class đã có mà chỉ cần thao tác mở rộng trên class mới tạo ra.

Việc tạo class kế thừa quá nhiều cũng cần chú ý để tránh hiện tượng tạo ra sự sai khác quá nhiều so với class gốc, có thể dẫn tới nghiệp vụ xử lý bị ảnh hưởng.


Ví dụ Nguyên tắc Open-Closed Principle trong php

Để làm rõ hơn vấn đề này chúng ta xem ví dụ dưới đây

Chú ý các đoạn code viết trên nền PHP 7

Ví dụ bây giờ chúng ta xây dựng 1 lớp User để thực hiện lưu dữ liệu vào database. Tuy vậy nhiệm vụ của chúng ta là phải kiểm tra tính đúng đắn của dữ liệu trước khi lưu vào database.

Yêu cầu kiểm tra rất đơn giản là chỉ cần tên user khác rỗng là được lưu vào database.

Và đoạn code dưới đây thể hiện điều này

<?php
namespace Toan\Solid\OCP\Examples;
class UserValidator{
    public function validate($data):bool{
        if($data['name']!=''){
            return true;
        }
        return false;
    }
}
class User{
    
    public function save($data=['name'=>'Tuấn','age'=>18]){
        $userValidator = new UserValidator();
        if($userValidator->validate($data)){
            echo 'Dữ liệu thỏa mãn, lưu vào database'.PHP_EOL;
            //Lưu vào database chẳng hạn???
        }
        else{
            //Thông báo lỗi...
            echo "Lỗi rồi!".PHP_EOL;
        }
    }
}

$user = new User;
$user->save();//Dữ liệu thỏa mãn, lưu vào database

Tuy vậy có 1 trường hợp phát sinh ra sau này, bây giờ không cần kiểm tra name khác rỗng nữa (vì 1 lý do nào đó ví dụ như đã kiểm tra ở bước trước rồi) mà thay vào đó phải kiểm tra độ tuổi  lớn hơn 0 thì mới lưu database

Việc này dẫn đến chúng ta phải sửa class Validator ban đầu. Điều này thực sự tệ hại. Hãy tưởng tượng, vào 1 ngày kia yêu cầu mới đưa ra là quay về kiểm tra name nhập vào. Điều này dẫn tới việc chúng ta mất nhiều thời gian chỉnh sửa mà tạo ra nhiều tiềm ẩn rủi ro trong quá trình phát triển.

Để đảm bảo theo nguyên tắc đóng mở, chúng ta sửa lại đoạn code trên như sau

<?php
namespace Toan\Solid\OCP\Examples;
interface IValidator{
    public function validate($data):bool;
}


class NameValidator implements IValidator{
    public function validate($data):bool{
        if($data['name']!=''){
            return true;
        }
        return false;
    }
}
class AgeValidator implements IValidator{
    public function validate($data):bool{
        if($data['age']>0){
            return true;
        }
        return false;
    }
}
class User{
    protected $validator;
    public function __construct(IValidator $validator){
        $this->validator = $validator;
    }
    public function save($data=['name'=>'Tuấn','age'=>18]){
        if($this->validator->validate($data)){
            echo 'Dữ liệu thỏa mãn, lưu vào database'.PHP_EOL;
            //Lưu vào database chẳng hạn???
        }
        else{
            //Thông báo lỗi...
            echo "Lỗi rồi!".PHP_EOL;
        }
    }
}

$user = new User(new NameValidator);
$user->save();
$user = new User(new AgeValidator);
$user->save();

Nguyên tắc đóng mở – Open-Closed Principle có thể đạt được bằng nhiều cách khác nhau. Ví dụ như sử dụng thừa kế (inheritance) hoặc thông qua các mẫu thiết kế tổng hợp (compositional design patterns) như Strategy pattern.


Liskov Substitution Principle – Nguyên tắc thay thế

Định nghĩa

Trong một chương trình, các object của class con có thể thay thế class cha mà không làm thay đổi tính đúng đắn của chương trình.

Ví dụ class con thay đổi hành vi class cha

Yêu cầu đặt ra là viết class cho Hình chữ nhật và Hình vuông, trong đó có phương thức tính diện tích. Đây là cũng là 1 ví dụ kinh điển trong việc mô tả cho nguyên tắc LSP .

<?php
namespace Toan\Solid\LSP\Examples;
// Class hình chữ nhật
class Rectangle{
    protected $width;
    protected $height;
    public function setWidth(int $width):void{
        $this->width = $width;
    }
    public function setHeight(int $height):void{
        $this->height = $height;
    }
    public function calcArea(){
        return $this->width * $this->height;
    }
}

// Class Hình vuông
class Square extends Rectangle{
    protected $width;
    protected $height;
    public function setWidth(int $width):void{
        parent::setWidth($width);
        parent::setHeight($width);
    }
    public function setHeight(int $height):void{
        parent::setWidth($height);
        parent::setHeight($height);
    }
}

//Chạy thử

$rectangle = new Rectangle;
$rectangle->setWidth(5);
$rectangle->setHeight(10);
echo $rectangle->calcArea().PHP_EOL; // 50


$square = new Square;
$square->setWidth(5);
$square->setHeight(10);
echo $square->calcArea().PHP_EOL; //100

Khi xem ví dụ thì chúng ta thấy hoàn toàn chưa có vấn đề gì cả. Cụ thể việc tính diện tích của hình chữ nhật 2 cạnh là hoàn toàn chính xác. Đối với hình vuông, do 4 cạnh luôn bằng nhau, do vậy nên khi set độ dài 1 cạnh thì cạnh còn lại cũng bằng tương ứng.

Dó đó khi set Height của hình vuông là 10 thì Width cũng là 10, và diện tích chính xác là 100.

Tuy nhiên hãy xem thêm đoạn code dưới dây để đánh giá:

function getRectangle($width, $height):Rectangle{
    $rectangle = new Square;
    $rectangle->setWidth($width);
    $rectangle->setHeight($height);
    return $rectangle;
}

echo getRectangle(5,10)->calcArea(); //100

Tại đây ta có 1 phương thức để lấy ra Rectangle (Hình chữ nhật) với tham số là chiều rộng và dài. Và đương nhiên mong muốn nhận được của chúng ta là 1 đối tượng là Rectangle

Đoạn code trên hoàn toàn hợp lệ, do Square là 1 class kế thừa từ Rectangle, nhưng kết quả chúng ta nhận được lại không như mong muốn

Chính xác thì nếu kết quả mong muốn phải nhận về là 50 ( = 5 x 10 ) thay vì 100( = 10 x 10).

Đó chính là vấn đề phát sinh khi mở rộng hoặc cập nhật code nếu viết đi thiếu những quy tắc.

Giải quyết bài toán theo Nguyên Tắc Thay Thế

Cụ thể thì theo nguyên tắc thay thế, chúng ta phải bảo đảm rằng khi một lớp con kế thừa từ một lớp khác, nó sẽ không làm thay đổi hành vi của lớp đó. ( thay đổi hàm setWidth, set Height của lớp cha)

Để giải quyết bài toán này chúng ta sẽ viết lại đoạn code trên như sau

Chúng ta nên tạo 1 class Shape sau đó cho Rectangle và Square kế thừa class này

<?php
namespace Toan\Solid\LSP\Examples;
abstract class Shape{
    protected $width;
    protected $height;
    abstract function setWidth(int $width):void;
    abstract function setHeight(int $height):void;
    abstract function calcArea():int;
    
}
// Class hình chữ nhật
class Rectangle{
    
    public function setWidth(int $width):void{
        $this->width = $width;
    }
    public function setHeight(int $height):void{
        $this->height = $height;
    }
    public function calcArea():int{
        return $this->width * $this->height;
    }
}

// Class Hình vuông
class Square extends Shape{
    public function setWidth(int $width):void{
        $this->width = $width;
        $this->height = $width;
    }
    public function setHeight(int $height):void{
        $this->width = $height;
        $this->height = $height;
    }
    public function calcArea():int{
        return $this->width * $this->height;
    }
}

Ví dụ về class con throw Exception khi gọi hàm

<?php
namespace Toan\Solid\LSP\Examples;
class Animal{
    protected $name;
    // Động vật có thể chạy
    public function run():void{
        
    }
    //Động vật có thể bay
    public function fly():void{
        
    }
}


class Eagle extends Animal{
    protected $name = 'Đại bàng';
    public function run():void{
        echo $this->name." đang chạy...(search youtube có đấy :))) )";
    }
    public function fly():void{
        echo $this->name." đang bay...";
    }
}

class Cat extends Animal{
    protected $name = 'Mèo';
    public function run():void{
        echo $this->name." đang chạy...";
    }
    public function fly():void{
        throw new \Exception($this->name." Không thể bay");
    }
}

$cat = new Cat;
$cat->run();
$cat->fly();

Trong ví dụ trên ta thấy đối với Mèo – Cat không thể nào bay được, để đảm bảo tính đúng đắn của chương trình, chúng ta không thể cho phép phương thức này được chạy. Và thế là 1 Exception được ném ra khi gọi tới phương thức fly của class Cat.

Liskov Substitution Principle – Nguyên tắc thay thế do class con đã throw ra lỗi trong 1 hàm của lớp cha, ( tham khảo thêm ví dụ về hàm getRectangle để hiểu rõ hơn)

Để chỉnh sửa điều này chúng ta viết lại đoạn code như sau

<?php
namespace Toan\Solid\LSP\Examples;
class Animal{
    protected $name;
}
interface Runable{
    // Động vật có thể chạy
    public function run():void;
}
interface Flyable{
    //Động vật có thể bay
    public function fly():void;
}

class Eagle extends Animal implements Runable,Flyable{
    protected $name = 'Đại bàng';
    public function run():void{
        echo $this->name." đang chạy...(search youtube có đấy :))) )";
    }
    public function fly():void{
        echo $this->name." đang bay...";
    }
}

class Cat extends Animal implements Runable{
    protected $name = 'Mèo';
    public function run():void{
        echo $this->name." đang chạy...";
    }
}

Tách các hành động khác biệt ra các Interface nhỏ hơn. Việc này sẽ tạo ra khá nhiều file khi làm việc tuy vậy chúng đảm bảo tính đúng đắn của mã nguồn. Chính điều này chúng ta không phải implement những hàm thừa, mà bản chất không nên tồn tại


Những vi phạm về nguyên tắc LSP

Một số dấu hiệu nhận biết việc chúng ta đang vi phạm nguyên tắc LSP

+ Các lớp dẫn xuất có các phương thức ghi đè phương thức của lớp cha nhưng với chức năng hoàn toàn khác.

+ Các lớp dẫn xuất có phương thức ghi đè phương thức của lớp cha là một phương thức rỗng.

+ Các phương thức bắt buộc kế thừa từ lớp cha ở lớp dẫn xuất nhưng không được sử dụng.

+ Phát sinh (Ném) ngoại lệ trong phương thức của lớp dẫn xuất.

Nguyên tắc LSP cũng là một nguyên tắc dễ bị vi phạm nhất trong nguyên lý SOLID. Nó ẩn chứa trong hầu hết mọi đoạn code, ví dụ trong PHP cũng có các interface như Countable, Iterator … giúp cho việc sử dụng được linh hoạt hơn.


Nguyên tắc phân tách – Interface segregation principle – ISP

Định nghĩa

Thay vì dùng 1 interface lớn, ta nên tách thành nhiều interface nhỏ, với nhiều mục đích cụ thể.

Chúng ta cần hiểu đơn giản rằng nếu 1 interface có quá nhiều class cần implements thì có thể rằng trong đó sẽ có những hàm không phù hợp về mặt chức năng mà class kế thừa không nhất thiết phải có.

Việc tách nhỏ 1 interface lớn ra là cần thiết, như vậy sau này khi chỉnh sửa, cập nhật interface đó sẽ hạn chế việc ảnh hưởng lớn tới các class kế thừa.

Ví dụ nguyên tắc Interface Segregation Principle trong PHP

Dưới đây là 1 interface Repository

<?php
namespace Toan\Solid\ISP\Examples;
interface Repository{
// Lấy tất cả bản ghi
public function getAll();
//Lấy 1 bản ghi
public function getOne();
//Cập nhật
public function update();
//Phân trang
public function paginate();
//Xóa
public function delete();
//Cho vào thùng rác
public function trash();
}

Trong interface này có rất nhiều phương thức: lấy danh sách bản ghi, lấy 1 bản, sửa, xóa, phân trang…

Trong 1 bài toán cụ thể thì có thể các trường hợp phát sinh như

+ PostRepository sẽ có đủ các phương thức trên

Nhưng :

+ SettingRepository thì có thể không cần 1 số phương thức như delete, trash hay phân trang

Chính điều này nếu các class SettingRepository  nếu implement interface Repository sẽ có những class thừa thãi không dùng đến. Việc này sẽ ảnh hưởng tới nghiệp vụ của lớp đó. Hoặc sẽ vi phạm vào quy tắc Liskov Substitution Principle bên trên khi các hàm kế thừa là trống, hay ném ra ngoại lệ.

Để chỉnh phần này ta có thể viết lại dạng như sau

<?php
namespace Toan\Solid\ISP\Examples;
interface Repository{
    // Lấy tất cả bản ghi
    public function getAll();
    //Lấy 1 bản ghi
    public function getOne();
    //Cập nhật
    public function update();
    
    
}
interface RemovableRepository{
    //Xóa
    public function delete();
    //Cho vào thùng rác
    public function trash();
}
interface PagingRespository{
    //Phân trang
    public function paginate();
}

Khi tách interface lớn hơn thành các interface nhỏ hơn thì chúng ta sẽ chủ động sử dụng chúng vào từng trường hợp cụ thể một cách rành mạch hơn.


Nguyên tắc đảo ngược phụ thuộc – Dependency Inversion Principle – DIP

Định nghĩa

+ Các module cấp cao không nên phụ thuộc vào các modules cấp thấp. Cả 2 nên phụ thuộc vào abstraction.

+ Interface (abstraction) không nên phụ thuộc vào chi tiết, mà ngược lại. ( Các class giao tiếp với nhau thông qua interface, không phải thông qua implementation.)

Ví dụ nguyên tắc nghịch đảo/ đảo ngược phụ thuộc

Cùng xem ví dụ về việc lưu User vào database như sau

<?php
namespace Toan\Solid\DIP\Examples;

class UserDB {
  
    private $dbConnection;
    
    public function __construct(MySQLConnection $dbConnection) {
        $this->$dbConnection = $dbConnection;
    }
  
    public function store(User $user) {
        // Lưu user vào Database
    }
}

Như chúng ta thấy, lúc khởi tạo UserDB phải truyền vào 1 đối tượng MySQLConnection. Điều này khiến lớp UserDB phụ thuộc trực tiếp từ cơ sở dữ liệu MySQL. Điều đó có nghĩa là nếu chúng ta thay đổi cơ sở dữ liệu đang sử dụng (sang SQLite, SQL Server…). Chúng ta cần viết lại lớp này và vi phạm Nguyên tắc Đóng mở – Open – Closed Principle.

<?php
namespace Toan\Solid\DIP\Examples;

interface DBConnection{
    public function connect():void;
}
class MySQLConnection implements DBConnection{
    public function connect():void{
        
    }
}

class SQLiteConnection implements DBConnection{
    public function connect():void{
        
    }
}

class UserDB {
  
    private $dbConnection;
    
    public function __construct(DBConnection $dbConnection) {
        $this->$dbConnection = $dbConnection;
    }
  
    public function store(User $user) {
        // Lưu user vào Database
    }
}

$userDb = new UserDB(new MySQLConnection);
$userDb = new UserDB(new SQLiteConnection);

Như bạn thấy, khi đổi lại việc truyền vào là interface DBConnection, thì lúc đó chúng ta có thể thoải mái mở rộng. Thậm chí ta có thể thay đổi kiểu kết nối mà không làm ảnh hưởng tới mã nguồn hiện tại. Việc thay đổi code module này không làm ảnh hưởng đến code module khác.

Kết luận

Tuân theo các Nguyên tắc SOLID cho chúng ta nhiều lợi ích, chúng làm cho hệ thống của chúng ta có thể tái sử dụng, tái cấu trúc, dễ dàng maintain, mở rộng, phát triển và hơn thế nữa.

Hy vọng bài viết sẽ giúp bạn cải thiện chất lượng trong quá trình code hơn.

Tài liệu tham khảo

Tìm hiểu về nguyên lý SOLID
SOLID là từ viết tắt của 5 nguyên tắt thiết kế hướng đối tượng (OOP). Một project áp dụng những nguyên lý này sẽ có code dễ đọc, dễ test, rõ ràng. Và hơn hết nó giúp dev dễ maintain và phát triển proj...
Nguyên lý SOLID trong hướng đối tượng PHP là gì?
Nguyên lý SOLID là một trong các nguyên lý cơ bản giúp chúng ta xây dựng các ứng dụng hướng đối tượng một cách hiệu quả đặc biệt trong PHP