SOLID – Nguyên tắc 5: Tính tương thích động – Dependency Inversion principle (DIP)

Chúng ta tiếp tục tìm hiểu bộ nguyên tắc lập trình SOLID – là các nguyên tắc giúp chúng ta thiết kế chương trình và viết code tốt hơn: code trong sáng và rành mạch, dễ bảo trì, dễ mở rộng trong tương lai. SOLID gồm 5 nguyên tắc lập trình sau đây:

  1. Single Responsibility principle.
  2. Open-Closed principle.
  3. Liskov substitution principle.
  4. Interface segregation principle.
  5. Dependency inversion principle.

Ở các bài viết trước, chúng ta đã lần lượt tìm hiểu được 4 nguyên tắc. Hôm nay, chúng ta sẽ tiếp tục phân tích và tìm hiểu nguyên tắc cuối cùng, cũng là nguyên lí quan trọng hàng đầu trong SOLID, đó là: Dependency inversion principle – Tính tương thích động.

Nguyên tắc thứ 5 của SOLID nói gì?

Phát biểu: Mỗi thành phần hệ thống (class, module, …) chỉ nên phụ thuộc vào các abstractions, không nên phụ thuộc vào các concretions hoặc implementations cụ thể.

Nguyên tắc cũng khá đơn giản về mặt mô hình hoá, bạn hãy tưởng tượng một việc trong thực tế như sau: một hệ thống máy tính sẽ có mainboard là thành phần chính, bộ phận này kết nối các thành phần khác trong hệ thống (như CPU, Ram, ổ cứng, …) lại với nhau để tạo nên một hệ thống hoạt động hoàn chỉnh và thống nhất. Như bạn đã biết, một mainboard có khả năng kết nối nhiều loại Ram, nhiều loại ổ cứng, … Dù những nhà sản xuất Ram hay mainboard là độc lập nhau hoàn toàn, và cũng có rất nhiều loại mainboard và ổ cứng khác nhau, nhưng các bộ phận này được kết nối với nhau rất dễ dàng. Làm thế nào để các nhà sản xuất có thể làm được điều này?

Câu trả lời thực ra rất đơn giản: mọi linh kiện máy tính dù cho có cấu tạo chi tiết khác nhau (implement khác nhau), nhưng luôn giao tiếp với nhau thông qua các chuẩn đã định sẵn (abstraction), cụ thể ở đây là mainboard giao tiếp với ổ đĩa cứng thông qua chuẩn kết nối chung SATA.

Supermicro-X10SL7-F-SATA-and-SAS-connectors
Figure 1 – Mainboard không cần biết loại đĩa cứng nào được dùng, miễn là chúng “tương thích” với chuẩn giao tiếp SATA là được
screen-shot-2017-02-01-at-12-19-42-am
Figure 2 – Mặc dù có implement cụ thể khác nhau (ổ đĩa quay, ổ đĩa thể rắn), nhưng chỉ cần chúng “tương thích” với chuẩn giao tiếp SATA là có thể chạy tốt.

Đây chính là một ví dụ trong thực tế cho chúng ta hình dung được khái niệm về nguyên lí thiết kế thứ 5 trong SOLID: tính tương thích động – Dependency Inversion Priciple (DIP). Mọi thành phần hệ thống chỉ nên phụ thuộc vào abstraction, mà không hề phụ thuộc vào bất cứ một concretion cụ thể nào.

Áp dụng nguyên lí này trong lập trình thế nào?

Mình sẽ tiếp tục minh hoạ nguyên lí này bằng ví dụ về Student ở các bài trước nhé. Cụ thể ở đây mình sẽ lấy ngữ cảnh việc class Student cần thực hiện ghi log, và chúng ta vừa tách nhiệm vụ này thành một class khác đó là lớp ExportLog trong ví dụ về tính đơn nhiệm ở nguyên lí 1.

Code để xử lí cho logic đó như sau:

class Student
{
   // other functions and properties ...

   bool applyForScholarship()
   {
      // do something here
      // ...

      // Logging
      ExportLog log_control = new ExportLog();
      log_control.exportLogToFile( target, "Error getting scholarship");
   }
}


class ExportLog
{
   void exportLogToFile(string filename, string error)
   {
      //write error to log file
      system.Out.print( filename, error );
   }
}

Đoạn code trên chạy vẫn ổn với yêu cầu ban đầu, nhưng sẽ thế nào nếu chúng ta phát sinh thêm yêu cầu mới. Hãy hình dung tình huống như sau: hiện tại chúng ta chỉ ghi log vào file, bây giờ chúng ta sẽ phát sinh thêm tình huống là ghi log qua Email. Chúng ta sẽ giải quyết như thế nào để ứng dụng của ta có thể đáp ứng được yêu cầu mới này, và có thể dễ dàng mở rộng thêm nữa, mà lại không cần chỉnh sửa source code đang chạy đúng?

Có thể chúng ta sẽ nghĩ ngay tới giải pháp: tạo một class cha (hoặc interface) là HandleLog chứa một phương thức chung đó là exportLog(), sau đó tạo các lớp con là ExportEmailLog và ExportFileLog kế thừa từ lớp cha này.

interface HandleLog
{
   void exportLog(string filenameOrEmail, string log);
}

class ExportEmailLog implements HandleLog
{
   void exportLog(string email, string log)
   {
      //export log to email
      system.sendEmail( email, log );
   }
}

class ExportFileLog implements HandleLog
{
   void exportLog(string filename, string log)
   {
      //export log to file
      system.Out.print( filename, log );
   }
}

Cấu trúc lớp này có vẻ hợp lí, nhưng chúng ta sẽ tích hợp nhiệm vụ ghi log vào lớp Student như thế nào để có thể dễ dàng thay đổi option ghi log? Cách làm truyền thống là new một instance để ghi log trong hàm applyForScholarship() sẽ không giải quyết được vấn đề, bởi vì ta cần một cơ chế xử lí linh động hơn:

class Student
{
   // other functions and properties ...

   bool applyForScholarship()
   {
      // do something here
      // ...

      // Logging
      // Vấn đề nằm ở đây
      // Làm sao để biết sẽ phải new đối tượng gì???
      ExportLog log_control = new ExportLog();
      log_control.exportLogToFile( target, "Error getting scholarship");
   }
}

Để giải quyết vấn đề này, ta sẽ dùng một kĩ thuật gọi là dependency injection (lưu ý là “injection” chứ không phải “inversion” nhé). Khái niệm dependency inversion có ý bao hàm cả việc thiết kế các lớp chức năng riêng biệt để tái sử dụng + vận dụng dependency injection cách hợp lí, mọi người nên chú ý kẻo nhầm lẫn nhé.

Một cách đơn giản thì dependency injection là một kĩ thuật lập trình, trong đó có 2 đối tượng: đối tượng cung cấp service (dependency) và đối tượng sử dụng service, đối tượng cung cấp service sẽ được truyền vào đối tượng sử dụng từ bên ngoài (khác cách thông thường là đối tượng sử dụng sẽ phải new đối tượng cung cấp dịch vụ từ bên trong). Về khái niệm dependency injection, chúng ta phân làm 3 loại:

  • Constructor injection: Truyền đối tượng cung cấp service (dependency) vào hàm khởi tạo của đối tượng sử dụng.
  • Setter injection: Dependency được truyền vào thông qua hàm getter và setter của đối tượng sử dụng.
  • Interface injection: Dependency được truyền vào một hàm nào đó của đối tượng sử dụng, hàm này cần phải có input param là một interface của dependency.

Tuỳ từng trường hợp mà ta sẽ lựa chọn cách triển khai dependency injection cho phù hợp, vấn đề này mình sẽ nói sau, ở đây mình sẽ chỉ minh hoạ một cách để mọi người nắm được ý nghĩa của nguyên lí dependency inversion thôi nhé. Chúng ta sẽ thực hiện việc dependency injection thông qua hàm setter như sau:

class Student
{
   //thuộc tính giữ đối tượng dependency
   HandleLog *log_control;

   void setHandleLog(HandleLog *input_log_control)
   {
      this->log_control = input_log_control;
   }

   bool applyForScholarship()
   {
      // do something here
      // ...

      //thực hiện ghi log
      this->log_control.exportLog( target, "Applying for scholarship successfully!");
   }
}

Với thiết kế như trên, mỗi lần bạn cần ghi log theo bất cứ cách nào, bạn chỉ cần truyền vào một đối tượng ghi log như mong muốn. Lúc đó code sẽ trông như sau:

Student student_1 = new Student();

// Việc gọi hàm applyForScholarship() 
// và ghi log theo mong muốn rất dễ dàng
student_1.setHandleLog(new ExportEmailLog()); //hoặc có thể dùng ExportFileLog
student_1.applyForScholarship();

Một khi bạn thiết kế như trên, sau này nếu có phát sinh thêm nhiều loại log khác nhau (gửi mail, nhắn tin sms, ghi xuống DB, …) thì chúng ta rất dễ dàng sử dụng interface HandleLog để mở rộng tính năng, cũng như rất dễ dàng để thay đổi logic nếu như chương trình muốn đổi qua cách ghi log mới.

Nhận xét

Đúng với tiêu chí của SOLID – code phải rõ ràng, dễ bảo trì và mở rộng – nguyên lí này đưa chúng ta tới một cảnh giới mới của việc thiết kế phần mềm. Như bạn đã thấy ở ví dụ trên, chương trình của chúng ta bây giờ đã linh động và dễ mở rộng hơn rất nhiều.

Nguyên lí này – Dependency inversion – đi liền với kĩ thuật dependency injection, mọi người nên hiểu rõ bản chất của 2 khái niệm này để áp dụng vào code cho hợp lí. Nói rõ hơn: Dependency inversion là nguyên lý thiết kế, còn Dependency injection là một kĩ thuật giúp chúng ta triển khai được nguyên lý trên.

Nếu bạn muốn xem xét sâu hơn, hãy tìm kiếm mẫu thiết kế tương tự đó là strategy pattern, mẫu này được ứng dụng rất nhiều trong lập trình để tăng độ linh hoạt của một hàm (vd: hàm sort() để sắp xếp, nhưng có nhiều thuật toán sort khác nhau, áp dụng mẫu này để linh động chuyển qua lại giữa các thuật toán với nhau, trong lập trình game khi đối tượng lên level, ta vẫn giữ nguyên tên chiêu thức nhưng nâng cấp cách ra chiêu, khi đó mẫu này sẽ giúp ta làm điều này).

strategy2
Figure 3 – Mô hình tổ chức lớp của mẫu thiết kế Strategy.

Tổng kết series SOLID

Chúng ta đã cùng nhau tìm hiểu qua 5 nguyên lí lập trình với cái tên rút gọn là SOLID, tóm gọn lại, những nguyên tắc này sẽ giúp chúng ta: viết code trong sáng hơn, rõ ràng hơn, các module hệ thống tách bạch hơn, phần mềm sẽ dễ dàng kiểm thử, bảo trì và mở rộng hơn.

Như mọi người thấy, mặc dù SOLID giúp ta cải thiện chất lượng code, nhưng chúng ta cũng phải đánh đổi: nó làm code của ta dài hơn, đòi hỏi nhiều công sức của lập trình viên hơn khi thiết kế, lập trình viên mới đọc code tốn thời gian hơn, code của ta sẽ khó theo vết hơn nếu có quá nhiều abtractions, … Chung quy lại, SOLID cũng chỉ là một guideline, nó không phải là thuốc tiên, không nên mù quáng sử dụng mà không cân nhắc kĩ cái được và mất.

keep_calm.jpg

Để đưa code của mình đạt tới cảnh giới cao hơn thì developer chúng ta cần luyện tập và nỗ lực rất nhiều, bên cạnh rèn luyện nội lực thật tốt (kiến thức design, vận dụng OOP, khả năng sử dụng các design pattern, …), chúng ta cũng cần tìm hiểu và vận dụng các công nghệ mới (công nghệ mới, UI tốt, UX phù hợp, …), như vậy thì chương trình của ta có thể đạt hiệu quả cao được.

Nguyên lí lập trình cũng giống như nền móng của căn nhà, việc hiểu và sử dụng được nhiều công nghệ mới là cách chúng ta xây nên và trang trí ngôi nhà, chúng ta cần nắm rõ và cân bằng cả 2 thì mới có thể tạo ra một ngôi nhà hoàn chỉnh – vừa vững chắc trước sóng gió của sự thay đổi, lại đủ đẹp đẽ và tinh tế để mọi người thích thú khi sử dụng. Chúc mọi người ngày càng tiến bộ hơn và làm được nhiều phần mềm “xịn xò” hơn.

Tham khảo:

  1. https://www.codeproject.com/Articles/703634/SOLID-architecture-principles-using-simple-Csharp
  2. https://scotch.io/bar-talk/s-o-l-i-d-the-first-five-principles-of-object-oriented-design
  3. https://www.codeproject.com/articles/615139/an-absolute-beginners-tutorial-on-dependency-inver

18 thoughts on “SOLID – Nguyên tắc 5: Tính tương thích động – Dependency Inversion principle (DIP)

  1. Pingback: [ SOLID là gì ] Nguyên tắc 4: Chia nhỏ interface – Interface segregation principle (ISP) – Webbynat

  2. Pingback: [ SOLID là gì ] Nguyên tắc 3: Tính khả dĩ thay thế – Liskov substitution principle (LSP) – Webbynat

  3. Pingback: [ SOLID là gì ] Nguyên tắc 1: Đơn nhiệm – Single Responsibility principle (SRP). – Webbynat

  4. Pingback: [ SOLID là gì ] Nguyên tắc 2: Đóng và Mở – Open / Closed principle (OCP) – Webbynat

  5. Pingback: [ SOLID là gì ] Tìm hiểu SOLID để trở thành Dev chất! – Webbynat

  6. Pingback: Nếu bạn muốn học lập trình PHP với Laravel framework. – Những dòng code vui

  7. Tin Ho Quang

    Mình đã xem cả 5 bài của Series SOLID, đều rất hay dễ đọc dễ hiểu dễ ứng dụng.
    Cảm ơn bạn rất nhiều đã chia sẽ những thông tin này!

    Like

  8. Phần gần cuối bài viết hình như tác giả triển khai Strategy Pattern chứ đâu phải Design Injection Pattern nhỉ.
    Design Injection Pattern thường thì mình tiêm dependency từ bên ngoài (Injector) chứ ko sử dụng bất kỳ method của class để tiêm dependency

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s