Xây dựng source code linh hoạt và dễ mở rộng với nguyên lý Dependency Inversion

Xây dựng source code linh hoạt và dễ mở rộng với nguyên lý Dependency Inversion

Xem nhanh

Chúng ta đều biết thế giới thật sự rất rộng lớn, thế giới lập trình cũng thế, cực kì rộng lớn. Chúng ta đều là những hạt cát trên sa mạc và không ngừng học hỏi sẽ giúp chúng ta tiến xa hơn trên thế giới lập trình.

Bạn tìm đọc được bài viết này, chứng tỏ bạn đang là một lập trình viên (developer) và đang mong muốn tìm hiểu về các nguyên lý lập trình (programming principles), cụ thể hơn là SOLID principle – một nguyên lý huyền thoại trong thế giới lập trình hướng đối tượng (Object Oriented Programming - OOP).

Sơ lược các nguyên lý SOLID

SOLID principles được giới thiệu lần đầu năm 2000 bởi Robert C. Martin (Uncle Bob), và có 5 nguyên lý sau:

  • Single Responsibility
  • Open / close principle
  • Lislov substitution principle
  • Interface Seregration
  • Dependency Inversion

Trong bài viết này bạn sẽ được tìm hiểu nguyên lý cuối cùng Dependency Inversion (gọi tắt là DIP) và những thứ liên quan về nó một cách chi tiết và toàn vẹn nhất.

Lưu ý: Bài viết này có khá nhiều code (nhưng không khó để hiểu), vì vậy bạn nên xem kĩ những code ví dụ để có hiểu được các thông tin cần truyền đạt của tác giả.

Dependency Inversion

Nguyên văn của Dependency Inversion như sau:

  • High-level modules should not depend on low-level modules. Both should depend on abstractions.
  • Abstractions should not depend on details. Details should depend on abstractions.

Dịch ra tiếng Việt:

  • Module cấp cao (high-level module) không nên phụ thuộc vào module cấp thấp (low-level module). Mà cả 2 nên phụ thuộc vào abstractions.
  • Abstraction không nên phụ thuộc vào chi tiết (implementation). Mà chi tiết (implementation) nên phụ thuộc vào abstraction.

Bây giờ chúng ta sẽ đi làm rõ hơn về Dependency Inversion, cách thông thường chúng ta hay viết code và lợi ích khi sử dụng DIP thông qua các ví dụ bên dưới.

Bad design

Bạn hãy xem ví dụ bên dưới, đây là một cách viết code rất phổ biến (có thể bạn sẽ thấy quen, hoặc chính bạn đã từng viết như thế rồi):

Copy
class MusicPlayer
{
    private $file;
    
    public function __construct()
    {
        $this->file = new MP3File(); // Vi phạm DIP
    }

    public function play()
    {
        return $this->file->play();
    }
}

class MP3File
{
    public function play()
    {
        return "Play MP3 file!";
    }
}

Hoặc một cách viết khác như bên dưới: Inject dependency thông qua constructor của class. Cách viết này cho phép bạn viết Unit test dễ hơn nhưng vẫn vi phạm DIP.

Copy
public function __construct(MP3File $file) // Vi phạm DIP
{
     $this->file = $file;
}

Note: Bạn đọc tới đây có thể có một số khái niệm chưa hiểu như dependency, unit test, inject, high/low-level module, …. Các khái niệm này dần dần sẽ được giải thích kĩ càng hơn xuyên suốt bài viết này.

Giải thích một chút về thiết kế chương trình ở trên, chúng ta bắt đầu phát triển một phần mềm nhỏ để có thể chơi nhạc, chúng ta có class MusicPlayer đang sử dụng class MP3File (Hay còn gọi là phụ thuộc - dependency).

Như vậy, với đoạn code trên chúng ta có thể giải thích một số khái niệm dưới đây:

  • High (low)-level module: Class A nào đó sử dụng class B thì class A chính là high-level module, và class B chính là low-level module. Như vậy ở ví dụ trên thì MusicPlayer là high-level module, còn MP3File chính là low-level module.
  • Dependency: MP3File chính là 1 dependency (hay còn gọi là sự phụ thuộc) của high-level module MusicPlayer
  • Hard dependency: Bạn có thể nhìn dòng code này $this->file = new MP3File(); ở trên, và đây được gọi là một hard dependency. Lưu ý thêm, khi trong code bạn có hard dependency thì bạn sẽ khó viết Unit test.

Tại sao thiết kế ở trên là Bad Design?

Khó bảo trì, khó mở rộng

  • Class MusicPlayer bị phụ thuộc vào class MP3File (dependency). Khi bạn thay đổi class MP3File thì class MusicPlayer cũng bị thay đổi theo. Điều này vi phạm Open / close principle. Và giả sử trong hệ thống của bạn có rất nhiều class như MusicPlayer (đang phụ thuộc vào MP3File). Mỗi khi bạn thay đổi class MP3File, bạn phải thay đổi tất cả những nơi sử dụng class này. Khó bảo trì và dễ gây ra bug trong quá trình refactor.

    direct dependency

Chúng ta cùng xem ví dụ bên dưới về việc thay đổi này.

Copy
class MusicPlayer
{
    private $file;
    
    public function __construct()
    {
        // Bạn phải đổi code ở đây để đối ứng cho việc thay đổi classname bên dưới
        $this->file = new MP3MusicFile(); 
    }

    public function play()
    {
        return $this->file->play();
    }
}

class MP3MusicFile // Bạn thay đổi classname ở đây
{
    public function play()
    {
        return "Play MP3 file!";
    }
}
  • Hiện tại thiết kế trên chỉ chạy cho file MP3, và khi hệ thống của bạn chỉ support cho duy nhất file MP3 thì đây là một thiết kế ổn. Nhưng trên thực tế, chẳng có cái máy chơi nhạc nào lại chỉ support cho duy nhất file MP3 cả, nếu bạn có các file khác như WAV, AAC, M4A, v.v. Đây là vấn đề khó mở rộng cho hệ thống của bạn.

Chính vì vậy, khi bạn muốn mở rộng thiết kế trên để hỗ trợ cho các loại file nhạc khác, thì thiết kế trên chính là Bad design.

Khó viết Unit test

Ở thiết kế trên khi bạn sử dụng new MP3MusicFile(), đây là một hard dependency nên sẽ cực kì khó viết unit test. Chúng ta sẽ đi sơ lượt qua Unit test một chút.

Tại sao chúng ta lại đề cập tới Unit test trong bài này, trên thực tế, khi bạn và đội ngũ của bạn làm ra một sản phẩm phần mềm, thì chúng ta thường gọi nó là production code (hay code sản phẩm). Bên cạnh production code, trong một số project “được đầu tư kĩ lưỡng hơn“ thì sẽ có thêm testing code (thông thường là unit test, và đôi khi có cả automation test nhưng trong bài viết này chúng ta chỉ đề cập tới unit test). Mục đích của unit test nói nôm na là kiểm tra tính đúng đắn theo từng đơn vị nhỏ như function, method, class, modules. Khi sản phẩm của bạn có unit test, chắc chắn rằng bạn sẽ hạn chế được bug một cách tối đa trong quá trình phát triển sản phẩm cũng như mở rộng, bảo trì, refactor.

Ví dụ chúng ta có một method downloadMusicFile và với những kiến thức bạn đã đọc tới bây giờ thì trong method này có một hard dependency là MusicFileRepository.

Copy
public function downloadMusicFileById(int $fileId)
{
    $musicFileRepository = new MusicFileRepository();
    $musicFileRepository->download($fileId);
}

Chúng ta sẽ thử viết Unit test như bên dưới, và rõ ràng unit test (viết bằng PHPUnit) sẽ không thể chạy được (failed case) bởi vì chúng ta không thể mock hard dependency MusicFileRepository.

Copy
// Tạo một mock object cho MusicFileRepository class, 
// chỉ mock cho method downloadMusicFileById()
$stub = $this->getMockBuilder(MusicFileRepository::class)
             ->setMethods(['downloadMusicFileById'])
             ->getMock();

// Expect method download được call 1 lần
$stub->expects($this->once())->method('downloadMusicFileById');

Nếu bạn dùng kĩ thuật inject dependency vào constructor hoặc method thì bạn có thể dễ dàng mock MusicFileRepository với kĩ thuật như đoạn code trên.
Bên dưới là 2 cách để bạn có thể viết unit test đơn giản hơn: Constructor injection và method injection.

Copy
// Method injection
public function downloadMusicFile(MusicFileRepository $musicFileRepository, int $fileId)
{
    $this->musicFileRepository->download($fileId);
}

// Constructor injection
private MusicFileRepository $musicFileRepository;

public function __construct(MusicFileRepository $musicFileRepository)
{
    $this->musicFileRepository= $musicFileRepository;
}

public function downloadMusicFile(int $fileId)
{
    $this->musicFileRepository->download($fileId);
}

Thông qua các ví dụ trên về Unit test, bạn có thể thấy rằng, nếu trong code bạn xuất hiện Hard Dependency thì việc viết Unit test sẽ trở nên khó khăn hơn. Chúng ta đề cập tới chữ khó ở đây để nói lên rằng, trong một số trường hợp bạn vẫn có thể viết Unit test được. Đó chính là kĩ thuật Black Box, nói đơn giản là bạn không cần quan tâm tới cấu trúc bên trong function/method viết gì, bạn chỉ cần quan tâm tới input, output thì khi đó bạn có thể áp dụng kĩ thuật Black Box được. Bạn có thể để lại comment nếu bạn muốn một bài viết chuyên sâu về Unit Test hoặc có thể tìm kiếm thêm thông tin trên Internet về các kĩ thuật Unit test

DIP design

Vậy câu hỏi đặt ra là làm thế nào để chương trình của chúng ta vừa dễ phát triển, vừa dễ dàng bảo trì và mở rộng. Bây giờ bạn hay xem chương trình ở trên được thiết kế lại như sau:

Copy
class MusicPlayer
{
    private $file;

    public function __construct(PlayerFile $file)
    {
        $this->file = $file;
    }

    public function play()
    {
        return $this->file->play();
    }
}

interface PlayerFile {
    public function play();
}

class MP3File implements PlayerFile
{
    public function play()
    {
        return "Play MP3 file!";
    }
}

class FLACFile implements PlayerFile
{
    public function play()
    {
        return "Play FLAC file!";
    }
}

Chúng ta đã tạo ra một interface PlayerFile để phá vỡ sự phụ thuộc. Bây giờ thiết kế mới sẽ là

dependency inversion

Hay nói cách khác, chúng ta đã tuân thủ nguyên tắc thứ nhất của DIP: High-level modules should not depend on low-level modules. Both should depend on abstractions.

Và 2 class MP3File và FLACFile đang phụ thuộc vào interface PlayerFile tuân thủ nguyên tắc thứ 2 của DIP: Abstractions should not depend on details. Details should depend on abstractions.

Bây giờ với thiết kế mới, MusicPlayer hoàn toàn chỉ phụ thuộc vào interface, nên dù bạn có thay đổi MP3, FLAC, hay thêm, mở rộng bất cứ loại file nhạc mới nào thì class MusicPlayer vẫn sẽ không bị thay đổi, hay nói cách khác là đã tuân thủ nguyên lý Open / close principle. Điều này sẽ làm cho các developer khác tiếp cận tốt hơn, code clean hơn, dễ bảo trì và mở rộng hơn.

Lời chia sẻ

Chúng ta là lập trình viên cũng có thể được xem là những kiến trúc sư trong thế giới lập trình. Việc thiết kế ra những chương trình/phần mềm ổn định, dễ bảo trì và mở rộng là điều cực kì quan trọng đối với một lập trình viên, không phải ai mới bước vào thế giới lập trình cũng có thể ngày một ngày hai trở nên giỏi giang. Nhưng có một điều chắc chắn rằng, nếu bạn rèn luyện càng nhiều, thì khả năng của bạn sẽ càng tăng, bạn sẽ ngày càng giỏi hơn và dần trở thành một lập trình viên thực thụ.

Và trong phần hai của bài viết này, Inversion of Control và Dependency Injection sẽ được trình bày chi tiết nhất có thể, mời các bạn đón đọc phần 2.