Dependency Inversion, Dev xịn là phải biết

Bài viết được sự cho phép của tác giả Lê Nhật Thanh

Bài viết này mình đề cập tới nguyên lý Dependency Inversion hay đảo ngược sự phụ thuộc khi dịch ra tiếng Việt.

Anh em tìm đọc được bài viết này, chứng tỏ anh em đang có nhu cầu tìm hiểu về các nguyên lý SOLID đúng không? Thôi thì chúng ta vào bài luôn và mình giới thiệu về SOLID một chút.

À bài viết này hơi dài một chút, hơi nhiều code một chút. Because dài và nhiều code thì mình mới có thể truyền tải hết được các kiến thức về nguyên lý Dependency Inversion cho anh em được. Chúc mừng anh em trước nếu anh em đọc và lĩnh hội được kiến thức trong bài viết này.

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 (S)
  • Open / close principle (O)
  • Liskov substitution principle (L)
  • Interface Segregation (I)
  • Dependency Inversion (D)

Bài viết này mình sẽ trình bày chi tiết nhất về nguyên lý cuối cùng – Dependency Inversion Principle (DIP). Sẽ có anh em thắc mắc là không biết gì mấy nguyên lý đầu thì thì quất luôn nguyên lý cuối sẽ hiểu không? Anh em đừng lo, sẽ hiểu bình thường, mình lo tất =]].

Giải thích nguyên lý Dependency Inversion Principle

Nguyên văn của DIP 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.

Đọc thôi là thấy khó hiểu ** rồi phải không anh em =]]? Đừng lo! Mình sẽ giải thích cho anh em hiểu. Chứ ban đầu khi đọc về nguyên lý này, mình cũng như anh em, thua và muốn chửi thề!

  Thông não Java Design Pattern – Dependency Injection

  SQL Injection là gì? Cách giảm thiểu và phòng ngừa SQL Injection

Bad Design khi không áp dụng Dependency Inversion 

Xem ví dụ bên dưới – Ví dụ về thiết kế một chương trình nghe nhạc siêu siêu đơn giản. Đây là một cách viết code rất phổ biến. Có thể anh em sẽ thấy quen, hoặc chính anh em đã từng viết như thế cũng nên.

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 chúng ta viết Unit test dễ hơn nhưng vẫn vi phạm DIP.

Lưu ý: Có một số từ ngữ như dependency, unit test, inject, high/low-level module, … Anh em nếu mới tiếp xúc lần đầu thì sẽ không hiểu. Anh em đừng lo lắng, mình sẽ cho anh em “tiếp xúc” nhiều lần hơn trong xuyên suốt bài viết để anh em hiểu.

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

Code như thế này thì …

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.

Đọc tới đây ổn đúng không anh em? Chúng ta cùng đi tiếp nào.

Tham khảo việc làm Java hấp dẫn trên TopDev

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 chúng ta thay đổi class MP3File thì class MusicPlayer cũng bị thay đổi theo. Điều này vi phạm Open / close principle (anh em có thể search google 1p29s để đọc sơ sơ và nắm được nguyên lý này một xíu xíu nhá). Và giả sử trong tương lai hệ thống của chúng ta có rất nhiều class như MusicPlayer (đang phụ thuộc vào MP3File). Mỗi khi chúng ta thay đổi class MP3File, thì phải thay đổi tất cả những nơi sử dụng class này. Dẫn đến khó bảo trì và dễ gây ra bug trong quá trình chỉnh sửa, phát triển hay bảo trì.

Mình lấy luôn một ví dụ cụ thể về sự thay đổi được đề cập ở trên cho anh em dễ nắm. Đọc đoạn code dưới sau đó đọc phần comment trong code để nắm nhá.

class MusicPlayer
{
    private $file;
    
    public function __construct()
    {
        // Thì 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 // 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 anh em 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 quái nào lại chỉ support cho duy nhất file MP3 cả, còn nhiều file khác như WAV, AAC, M4A, e.g. nữa chứ. Đây là vấn đề khó mở rộng cho hệ thống. Code theo phong cách này mà mở rộng cho nhiều file khác là anh em… há mỏ ngay =]].

Chính vì vậy thiết kế trên chính là Bad design. Một thiết kế như sh**.

Khó viết Unit Test

Sơ lược một chút nhé!

Ở thiết kế trên khi chúng ta sử dụng new MP3MusicFile(), đây là một hard dependency nên sẽ cực kì khó viết unit test. Làm sao biết là khó viết, thì chúng ta sẽ đi sơ lượt qua Unit test một chút. Mình sẽ giải thích cho cả những anh em chưa từng viết unit test vẫn có thể hiểu được. Nên sẽ hơi dài, anh em nào rành unit test rồi (và hiểu tại sao khó viết unit test) thì có thể bỏ qua phần này.

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 anh em và đội ngũ 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ó unit test, chắc chắn rằng anh em 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à một điều chắc chắn khi anh em viết code theo nguyên lý Dependency Inversion thì việc viết Unit test sẽ vô cùng đơn giản!

Ví dụ nè

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

// Đây là production code
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ề ý tưởng để test hàm downloadMusicFileById thì đơn giản làm sao khi hàm này được gọi. Chúng ta phải kiểm tra xem method download có được gọi đúng với $fileId không là đủ rồi. Chúng ta không cần quan tâm method download có chạy đúng hay không. Vì muốn biết nó chạy đúng hay không thì chúng ta sẽ viết unit test riêng cho hàm download.

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.

À, mock là một kĩ thuật được xài trong unit test rất nhiều. Anh em có 1p29s để search google và quay lại bài viết vì mình không thể giải thích quá nhiều sẽ dẫn tới một bài viết quá dài.

// Đây là unit test cho production code ở trên

// 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 dùng kĩ thuật inject dependency vào constructor hoặc method thì có thể dễ dàng mock MusicFileRepository với kĩ thuật như trên.
Bên dưới là 2 cách để có thể viết unit test đơn giản hơn: Constructor injection và method injection.

// Method injection
function downloadMusic(
    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);
}

Túm cái váy Unit Test

Túm cái váy lại, nếu trong code 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 vẫn có thể viết Unit test được. Đó chính là kĩ thuật Black Box. Nói đơn giản là chúng ta không cần quan tâm tới cấu trúc bên trong function/method viết gì. Mà chỉ cần quan tâm tới input, output thì khi đó có thể áp dụng kĩ thuật Black Box được.

Anh em có thể để lại comment nếu 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

Dependency Inversion Principle 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ờ hãy xem chương trình ở trên được thiết kế lại như sau:

class MusicPlayer
{
    private $file;
    
    // Chổ này bây giờ là phụ thuộc vào interface
    public function __construct(PlayerFile $file)
    {
        $this->file = $file;
    }

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

// Xuất hiện interface ở đây
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ù 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. Xong!

Đọc tới đây nếu anh em còn chưa hiểu thì có thể đọc lại vài lần nữa. Còn nếu vẫn không hiểu thì anh em để lại comment cho mình bên dưới. Mình sẽ giải đáp thắc mắc cho anh em. Chúc mừng và cám ơn anh em đã đọc tới

Lời chia sẻ “thật lòng”

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 anh em rèn luyện càng nhiều, thì khả năng sẽ càng tăng. 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ụ.

Bài viết gốc được đăng tải tại lenhatthanh.com