Cơ bản về nguyên lý thiết kế SOLID

  • Phuong Dang
  • 02/Mar/2023
  1. Single Responsibility Principle (SRP)
  2. Open Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)
  6. Kết luận.

Phát triển phần mềm là một môi trường sẽ liên tục thay đổi, để thích nghi được với điều này thì mỗi sản phẩm cần phải có một kiến trúc thiết kế thật tốt. Với nền móng là kiến trúc tốt thì sản phẩm đó sẽ dễ dàng thay đổi và nâng cấp hơn.

Trong bài viết này cần các bạn có kiến thức cơ bản về Object Oriented Programming (OOP)

SOLID là từ viết tắt kết hợp từ 5 chữ cái đầu tiên của 5 nguyên lý, được đưa ra bởi Bob MartinMichael Feathers.

  • Single Responsibility Principle (SRP)
  • Open Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency inversion principle (DIP)

SOLID được coi như là một "must have skill" mà bất cứ software developer nào cũng cần phải biết. Trong bài viết này mình sẽ giới thiệu đến các bạn từng nguyên lý và sử dụng Java để demo.

SOLID principles
Figure 1: SOLID principles

1. Single Responsibility Principle (SRP)

Nguyên lý đầu tiên có nội dung như sau:

Every software component should have one and only one responsibility.

Mỗi software component chỉ nên có 1 và chỉ 1 trách nhiệm duy nhất. Trong phạm vi OOP thì chúng ta có thể hiểu software component là: một class, một method hoặc một module. Để hình dung rõ hơn chúng ta cùng xem hình ảnh sau:

Single Responsibility Principle (SRP)
Figure 2: Single Responsibility Principle (Refer from Accesto.com)

Bây giờ thì chắc bạn đã hình dung rõ hơn về khái niệm SRP, nhưng lại thấy khó hiểu rằng đáng ra một component mà làm được nhiều việc như kia thì càng tiết kiệm effort, càng đa năng và đáng ra phải tốt hơn chứ. Tuy nhiên một component mà thực hiện quá nhiều công việc thì dẫn component đó sẽ có nhiều lý do để thay đổi.

Việc thay đổi source code trong software development sẽ dẫn đến gia tăng chi phí cũng như tiềm ẩn nhiều rủi ro hoặc bug. Nên khi thiết kế chúng ta luôn cần đảm bảo việc chỉnh sửa cần được thực hiện dễ dàng với phạm vi ảnh hưởng ít nhất có thể. Ví dụ với một class User được thiết kế không tuân thủ SRP như sau:

tight coupling
Figure 3: Class User thiết kế không tuân thủ SRP khi thực hiện 2 nhiệm vụ.

Với thiết kế như trên thì class User sẽ có nhiều lý do dẫn đến cần phải thay đổi:

  1. Khi các attribute của user thay đổi, ví dụ: cần quản lý thêm age, hay tách fullName thành firstName và lastName.
  2. Khi service mail thay đổi.

Trong trường hợp hệ thống muốn dùng HotMail thay vì Gmail. Điều này dẫn đến class User phải thay đổi để đáp ứng yêu cầu mới. Bây giờ đối chiếu theo nguyên lý SRP ta chỉnh sửa class User như sau:

loose coupling
Figure 4: Hai classes thiết kế tuân thủ SRP khi mỗi class thực hiện 1 nhiệm vụ.

Với thiết kế này thì class User không phải thay đối với lý do [2] bên trên nữa, thay vào đó class EmailService sẽ bị thay đổi. Tuy nhiên, vẫn phải thay đổi thì thay đổi ở class User hay class EmailService có gì khác nhau đâu.

Câu trả lời là có khác nhau. Nếu chúng ta thiết kế theo cách được mô tả ở Figure 3, thì ngoài class User cần send mail thì có thể N những class khác như Ticket, Voucher, Booking... cũng cần send mail. Điều này dẫn đến cũng là thay đổi vì lý do [2], nhưng:

  • Thiết kế không chuẩn SRP (Figure 3) thì N classes sẽ phải thay đổi
  • Thiết kế chuẩn SRP (Figure 4) thì chỉ EmailService phải thay đổi

Khi mỗi software component chỉ có 1 trách nhiệm thì cũng chỉ có 1 lý do cần thay đổi. Tỉ lệ source phải thay đổi càng ít thì càng dễ thay đổi, tiết kiệm chi phí, giảm thiểu rủi ro.

2. Open Closed Principle (OCP)

Nguyên lý này có nội dung như sau:

Software components should be close for modification, but open for extension.

Có 2 ý chính trong nguyên lý này:

  • Hạn chế sửa đổi (close for modification): Khi thêm các tính năng mới thì hạn chế sửa source của những software component có sẵn
  • Ưu tiên mở rộng (open for extension): Khi thêm các tính năng mới thì ưu tiên mở rộng và kế thừa những component đã có sẵn

Để dễ tưởng tượng thì ví dụ bạn có 1 chiếc laptop, tuy nhiên bạn lại không thích bàn phìm trên chiếc laptop đó. Chúng ta sẽ không thể đem chiếc laptop đó ra sửa và lắp loại bàn phím mới vào được, mà thay vào đó hãy setup một chiếc bàn phím ngoài đúng loại mình thích. Vừa lựa chọn được lại bàn phím ưa thích mà không phải sửa gì chiếc laptop. Cũng tương tự như việc: thay lens máy ảnh, dùng loa ngoài cho laptop...

Open Closed Principle
Figure 5: External keyboard for laptop.

Giả sử chúng ta có một hệ thống bán hàng với chức năng tích điểm thường. Với mỗi đơn hàng, member sẽ được tích điểm thưởng tương đương 1% tổng giá trị đơn hàng.

Award point
Figure 6: Class Member.

Nghiệp vụ có thay đổi khi member chia làm 2 loại: normalplatinum. Với normal member thì nghiệp vụ giữ nguyên, tuy nhiên với platinum member thì sẽ tích điểm thưởng tuơng đương 2% tổng giá trị đơn hàng. Để đạt yêu cầu của nghiệp vụ mới ta có thể sửa class Member bằng cách thêm 1 field type và update method incrementAwardPoint() như sau:

OCP Solution 01
Figure 7: Sửa class Member để đạt được nghiệp vụ mới.

Tuy nhiên khi chúng ta sửa method incrementAwardPoint(), sẽ tiềm ẩn rủi ro phần tính toán cũ có thể bị sai. Cách sửa trên là chúng ta đang vi phạm vào ý "close for modification". Ta có 1 phương án mới theo hướng "open for extension" bằng cách tạo ra 1 class mới PlatinumMember và override lại method incrementAwardPoint() để cung cấp 1 cách thức tính mới cho loại member đó. Điều này sẽ giúp chung ta không hề chỉnh sửa vào việc tính toán của xử lý cũ.

OCP Solution 02
Figure 8: Thêm class PlatinumMember để đạt được nghiệp vụ mới.

Có thể bạn sẽ thắc mắc là việc chỉnh sửa nhỏ như kia thì có gì rủi ro mà phải phát sinh thêm 1 class mới. Đây chỉ là 1 ví dụ nhỏ để các bạn dễ hình dung hơn về nguyên lý. Còn khi vận dụng thực tế thì chúng ta sẽ cân nhắc phạm vi ảnh hưởng, độ khó để quyết định làm theo solution nào cho hợp lý.

3. Liskov Substitution Principle (LSP)

Nguyên lý này có nội dung như sau:

Objects should be replaceable with their subtypes without affecting the correctness of the program.

Các objects của class con có thể thay thế object của class cha mà vẫn đảm bảo tính đúng đắn của chương trình. Nói cách khác là object của class con có thể làm được tất cả những công việc mà object của class cha có thể làm.

Để giải thích rõ hơn chúng ta nói về tính chất kế thừa trong OOP. Tính chất kế thừa thể hiện cho mối quan hệ "is-a", ví dụ:

is-a
Figure 9: Chim cánh cụt và hải âu là 1 loài chim.

Với mối quan hệ như trên ta có thể triển khai source code có mối quan hệ kế thừa như sau:

is-a
Figure 10: Class Seagull, Penguin kế thừa Bird

Bạn có thể thấy vấn đề ở đây là chim cánh cụt thì đúng là 1 loài chim - thỏa mãn "is-a". Nhưng chim cánh cụt có cánh mà lại không thể bay được, dẫn đến nó không thể implement method fly của class cha Bird. Tồn tại 1 unimplemented method thì không phải là cách thiết kế tốt.

Nguyên nhân của việc này là do ban đầu người thiết kế cố gắng thực hiện tính kế thừa tuy nhiên lại định nghĩa sai hành vi tổng quát mà trong đó có những class con không thể thực hiện được => Vi phạm nguyên tắc vì "Các objects của class con KHÔNG THỂ thay thế object của class cha". Chúng ta có thể giải quyết vấn đề này như cách bên dưới. Tách hành động fly ra một interface riêng, và chỉ những loài chim nào có thể bay thì implementation

is-a
Figure 11: Tách hành động fly ra một interface riêng, và chỉ những loài chim nào có thể bay thì implementation

4. Interface Segregation Principle (ISP)

Nguyên lý này có nội dung như sau:

No client should be forced to depend on methods it does not use.

Nguyên lý này khá dễ hiểu, nó phát biểu rằng những implementation của interface không nên bị phụ thuộc vào những method mà nó không dùng đến. Điều này có nghĩa là chúng ta nên phân chia các method vào những interface hợp lý, không nên nhóm nhiều method vào một FAT interface.

Nếu bạn dùng iphone thì liệu Apple có nên tặng kèm 1 một loại dây sạc như này cho bạn không. Tất nhiên là không rồi vì nó dư thừa quá nhiều chức năng không cần thiết.

ISP
Figure 12: Một dây sạc dư thừa quá nhiều chức năng cho 1 chiếc iphone.

Ví dụ ta có 1 interface IExportable định nghĩa 2 method hỗ trợ export csv hoặc pdf. Tuy nhiên với thông tin sao kê tín dụng CreditStatement thì không hỗ trợ export csv, dẫn đến method exportCsv() sẽ throw exception. Cách thiết kế này đã vi phạm nguyên lý vì CreditStatement đang phải implement một method không liên quan.

ISP
Figure 13: CreditStatement phải implement method không liên quan.

Chúng ta có thể sửa bằng cách tách 2 interface như sau. Các class sẽ chọn implement những interface chưa các method mà nó cần thiết.

ISP solution
Figure 14: Các class sẽ chọn implement những interface chưa các method mà nó cần thiết.

Mục tiêu của nguyên tắc này giúp tránh được những implement dư thừa. Tuy nhiên cần phải nhớ rằng chúng ta tách interface một cách hợp lý chứ không phải tách nhỏ. Vì nếu tách quá nhỏ thì số lượng interface gia tăng dẫn đến khó để kiểm soát ứng dụng.

5. Dependency Inversion Principle (DIP)

Nguyên lý này có nội dung 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.

  • 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.
  • Abstractions không nên phụ thuộc vào chi tiết, mà ngược lại.

Với nguyên lý này chúng ta cần biết khái niệm như nào là module cấp caomodule cấp thấp. Khi module A gọi module B, lúc này module A phụ thuộc (depend) vào module B. Thì module A là module cấp cao và module B là module cấp thấp.

Ví dụ với chức năng đăng ký tài khoản, sau khi đăng ký thành công chúng ta sẽ cần gửi email kick hoạt tài khoản. Trường hợp này thì RegisterController sẽ là module cấp cao và GmailService là module cấp thấp.

Dependency Inversion Principle (DIP)
Figure 15: Sau khi đăng ký tài khoản sẽ gửi email bằng Google mail service.

Việc module cấp cao gọi trực tiếp module cấp thấp như Figure 15 sẽ dẫn đến RegisterController bị phụ thuộc vào GmailService. Trong trường hợp ứng dụng thay đổi sử dụng dịch vụ Hot mail thay vì dùng Gmail thì lúc này RegisterController cần thay đổi gọi sang HotMailService. Để giải quyết việc phụ thuộc này chúng ta sẽ thiết kế như sau:

Dependency Inversion Principle (DIP)
Figure 16: RegisterController sẽ liên kết trực tiếp đến interface EmailService

Khi RegisterController sẽ liên kết trực tiếp đến interface EmailService, điều này sẽ giúp cho RegisterController sẽ có thể linh động sử dụng bất kì service email nào mà bản thân nó không phải thay đổi.

Dependency Inversion Principle (DIP)
Figure 17: RegisterController sẽ không bị phụ thuộc vào 1 service mail cụ thể mà có thể linh động sử dụng

Ta có thể triển khai source code bằng cách RegisterController sẽ được inject EmailService thay vì trực tiếp GmailService:

Dependency Inversion Principle (DIP)
Figure 18: RegisterController inject EmailService thay vì trực tiếp GmailService

Trong quá trình execute RegisterController có thể switch email service khác nhau mà bản thân nó không phải chỉnh sửa gì.

Dependency Inversion Principle (DIP)
Figure 19: RegisterController không cần chỉnh sửa khi đổi từ GmailService sang dùng HotMailService

Cách triển khai code bên trên đang sử dụng Dependency Injection, một trong những cách implement Dependency Inversion Principle.

6. Kết luận

Gửi lời khen ngợi đến các bạn đã kiên trì cùng mình hoàn thành nội dung liên quan đến 5 nguyên lý trong SOLID. Hi vọng sau bài viết này các bạn sẽ hiểu cơ bản về SOLID và áp dụng vào cách thiết kế để giúp source code của mình dễ dàng mở rộng hơn.

Tuy nhiên cũng cần phải lưu ý rằng, bất cứ nguyên lý nào cũng có 2 mặt lợi và hại. Nếu cứng nhắc áp dụng thì đôi khi source code sẽ bị dài dòng, phức tạp và khó quản lý hơn. Nên bạn cần hiểu và áp dụng SOLID một cách hợp lý chứ không phải cứng nhắc.