SOLID - Dependency Inversion principle (DIP)

trieu.dev.da

Nguyễn Thanh Triề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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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:
1
2
3
4
5
6
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.
 
Bên trên