Tản mạn về Software Principle và Design Pattern

Tìm hiểu các khái niệm căn cơ trong lập trình: Inversion of Control, Dependency Injection, Dependency Inversion Principle

Photo by Zhang Kenny on Unsplash

phần 2phần 3, khi bàn về lý thuyết KTPT (kiến trúc phân tầng/layer architecture) và áp dụng vào việc coding mã nguồn cho bài toán Todos cụ thể, đâu đó bạn sẽ nhìn thấy những nguyên lý/(design) pattern ẩn hiện đằng sau. Có thể kể ra một vài cái tên như: Inversion of Control (IoC), Dependency Inversion Principle (DIP), Dependency Injection (DI).

Nếu làm “phận coder” đủ lâu thì hẳn bạn đã biết, đây là những concept hết sức căn cơ, là nền tảng sẽ giúp bạn viết ra những đoạn mã nguồn có tính module hóa cao, độ phụ thuộc thấp và dễ bảo trì. Giang hồ thường ám chỉ mã nguồn như vậy bằng thuật ngữ loosely coupled. Đối lập với nó là tightly coupled, những đoạn mã nguồn rối tung rối mù được ví như 1 bản remix “4 chữ khó" (khó hiểu, khó test, khó debug, khó maintain).

Bản thân mình đã va vấp với các concept này từ khá lâu rồi, lúc đầu thì cứ đinh mạnh ninh là đã nắm rõ nhưng đến lúc mang ra trao đổi/giải thích cho anh em Dev thì mới biết là mình còn đang mơ hồ. Chính vì vậy, mình quay lại tìm hiểu kỹ các khái niệm, rồi viết lại blog post này để sharpen kiến thức một lần nữa.

Trong phần này:

  • Principle vs Pattern
  • IoC with DI
  • Dependency Inversion Principle
  • DI vs DIP

Principle vs Pattern

Principle

Là những nguyên lý thiết kế software được khuyên dùng. Chúng đề cập đến những best practice đã được đúc kết qua kinh nghiệm của các lão làng trong nghề lập trình giúp cho mã nguồn của software làm ra đạt được tính loosely coupled, từ đó góp phần nâng cao chất lượng của software. Tuy nhiên principle chỉ dừng ở mức “lời khuyên”. Kiểu như các cụ hay khuyên bậc phụ huynh cách ứng xử nói chung: “". Còn đâu, cách “bắc cầu kiều” như thế nào?, làm sao để “yêu lấy thầy”?… thì ko có nói đến chi tiết.

Có thể kể ra vài principle nổi tiếng như: IoC, DIP (thuộc SOLID), KISS, …

Pattern

Là những đề xuất/hướng dẫn cụ thể cho các vấn đề/bài toán hay gặp phải trong lập trình. Những hướng dẫn này chi tiết đến mức có thể implement được bằng một hoặc nhiều loại ngôn ngữ lập trình cụ thể.

Ví dụ về pattern thì nhiều vô kể, có thể kể tên: DI, Factory, Strategy, Singleton, Adapter, …

IoC with DI

Concept

Trích dẫn định nghĩa từ Wiki:

Nghe qua thì có vẻ đơn giản, nhưng để nắm bắt được IoC chúng ta cần làm rõ ra xem cái control mà IoC invert nó là cái gì và nó được invert ntn? Trong chương trình software thường có 2 loại flow control, hãy cùng đi vào phân tích chi tiết và viết code implement để làm rõ hơn.

Control flow chương trình

Thông thường, khi viết code software, bạn sẽ viết các dòng code mệnh lệnh (gọi là imperative code) để đưa ra các steps chương trình cần chạy qua nhằm đạt mục đích mong muốn, đôi khi bạn sẽ cần call vào library/framework (do bạn tự viết hoặc của bên thứ 3) để thực thi tác vụ common nào đó. Về bản chất, những dòng code bạn viết này sẽ control toàn bộ flow (các sequence steps) của chương trình.

Theo IoC thì quá trình này nên được đảo ngược, tức là thay vì những đoạn custom-code gọi vào library/framework thì library/framework sẽ gọi vào custom-code. Lấy ví dụ, nếu bạn viết web bằng Java Servlet thì đơn giản là framework đã lo phần flow của tác vụ xử lý chính mà bất kỳ hệ thống web server nào cũng đều phải xử lý (đón HTTP request từ client, gọi custom-code mà bạn implement để thực thi, rồi trả HTTP response cho client). Việc còn lại của bạn là viết custom-code để thực thi logic mà bạn muốn.

Nếu bạn ko dùng framework mà tự viết toàn bộ source code thì sao? Theo IoC, bạn nên xem xét tách code ra làm 2 phần: phần generic (có thể tái sử dụng nhiều lần) vs phần tailored code (xử lý business logic cụ thể) và implement chúng sao cho:

  • Flow của chương trình sẽ do phần generic điều khiển;
  • Khi cần thực thi logic chức năng cụ thể, phần generic sẽ gọi đến phần tailored code.

Mình lấy ví dụ về Chương trình Tính toán phép tính số học (cộng/trừ) đơn giản (implement bằng Java) để cụ thể hóa idea này như sau:

Control việc quản lý các đối tượng dependency

Giả sử bạn cần viết chức năng login/logout trong class CustomerLogic và bạn thấy cần phải gọi đến dịch vụ của class CustomerDB để update thông tin vào Database, ngoài ra cần phải gọi đến dịch vụ của class QueueMSG để gửi message vào queue nhằm gửi email/notification… khi đó class CustomerLogic phụ thuộc (depend) vào class CustomerDBQueueMSG. Ở chiều ngược lại, hai class CustomerDBQueueMSG được gọi là các dependency của class CustomerLogic.

Thường thì bạn sẽ quản lý (khởi tạo, cấu hình) instance của các class dependency ngay trong class mà bạn cần sử dụng đến chúng. Nhưng theo IoC, quá trình quản lý này nên được đảo ngược. Cụ thể là các đối tượng dependency sẽ ko được khởi tạo ngay trong depend class nữa mà sẽ được quản lý ở đâu đó và đưa cho depend class để sử dụng khi cần. Điều này giúp cho mã nguồn trở nên loosely coupled hơn do đã tách các đoạn code quản lý dependency khỏi các đoạn code sử dụng các function mà đối tượng dependency cung cấp.

Đến đây, bạn băn khoăn làm thế nào để đảo ngược việc quản lý các đối tượng dependency? Đáp án chính là sử dụng các design pattern đã được tạo ra để giải quyết bài toán này, có thể kể ra 1 vài cái tên: Service Locator, Dependency Injection, Contextualized Lookup, Strategy, Factory.

Trong khuôn khổ bài post này, ta sẽ chỉ nói riêng về DI. Với DI, chúng ta có 3 cách inject: thông qua constructor, thông qua setter method và thông qua interface. Trong đó 2 cách đầu là thông dụng nhất và same same nhau nên mình sẽ chọn ví dụ về dependency injection qua constructor.

Trong ví dụ này, chúng ta cần cài đặt chức năng cho class CustomerManagement và chức năng đó nó phụ thuộc vào đối tượng logger.

Dependency Inversion Principle

Concept

DIP là một trong năm nguyên lý thuộc SOLID (), đc định nghĩa như sau:

Chúng ta hãy break down từng phần trong định nghĩa trên thông qua ví dụ sau: giả sử bạn viết code hệ thống Rest API bao gồm 2 cấu phần chính (business service và database service):

High-level vs Low-level

Khi bạn dùng Google Map, nếu zoom ở mức high bạn sẽ thấy những hình ảnh quan trọng, ít khi thay đổi (ví như các lục địa); khi zoom càng sâu (low), bạn càng thấy chi tiết và những chi tiết này rất dễ biến đổi (ví dụ đường phố mới, tòa nhà mới, khu đô thị mới…).

Tương tự như vậy, với hệ thống Rest API mà bạn xây dựng, khi zoom ở mức high bạn sẽ thấy Business Service là module quan trọng. Nó chứa đựng những khái niệm, những tương tác ở mức nghiệp vụ logic của bài toán mà bạn đang cần lập trình để xử lý. Những concept ở module này có thể là đề tài nói chuyện của nhiều bên tham gia dự án, từ khách hàng cho đến BA rồi đến QA. Khi zoom ở mức low hơn, bạn sẽ thấy module Database Service, là cái cụ thể mà hầu như chỉ có Dev mới hiểu và mang ra thảo luận với nhau. Module thấp dạng này thường sẽ dễ bị thay đổi hơn module ở mức cao, thậm chí Dev có thể sử dụng 2 hệ quản trị CSDL cùng lúc (NoSQL & SQL) để đáp ứng cho business logic.

Thường khi bạn viết code, lúc cài đặt business function, bạn nhận thấy cần phải kết nối đến DB để lưu/đọc dữ liệu, bạn sẽ khai báo instance của DB (MySQL chẳng hạn) để sử dụng trực tiếp trong business class. Theo vế 1 trong định nghĩa DIP, bạn nên sử dụng interface:

Boundary

Sau khi có interface, điều tiếp theo mà chúng ta băn khoăn là interface này sẽ thuộc về boundary nào. Theo vế 2 của định nghĩa, detail DB implement nên phụ thuộc vào abtraction, tức là DB implement sẽ là phần nằm ngoài, DB interface sẽ cùng với business logic service kết hợp thành 1 thể thống nhất (trên khía cạnh concept):

Khi đó, bạn sẽ có được module high-level mang tính độc lập rất cao, có thể tái sử dụng mà ko bị phụ thuộc vào các module implement chi tiết. Giả sử nghiệp vụ của bài toán tiến triển, bạn cần bổ sung thêm MessageQueue concept vào phần business, áp dụng DIP:

Với kiến trúc như vậy, bạn có thể thoải mái chọn các công nghệ DB (MySQL, MongoDB…) hay các công nghệ Queue (RabbitMQ, ZeroMQ…) để implement mà ko sợ ảnh hưởng đến nghiệp vụ core business. Hơn nữa, khi ko còn dính dáng đến các đoạn code implement chi tiết, các đoạn code core business sẽ trở nên rất tường minh về logic nghiệp vụ, giúp người đọc dễ dàng nắm bắt.

Implement

Cách cài đặt DIP “đơn giản” nhất là bạn thiết kế interaction của business function theo hướng trừu tượng hóa (abtraction) rồi viết interface vào high-level boundary, sau đó implement chúng ở các low-level modules. Ngoài ra bạn có thể sử dụng các design pattern chuyên dụng: Repository, Ports & Adapters, …

Các bạn có thể tham khảo thêm cách implement trong phần trước của seri.

DI vs DIP

Cho đến đây hẳn là bạn đã nhìn ra sự khác biệt:

  • DI tách bạch việc quản lý (khởi tạo, cấu hình) các đối tượng dependency khỏi việc sử dụng các phương thức/thuộc tính mà đối tượng đó cung cấp;
  • DIP đề cập đến tư duy phân chia ra module mức cao, mức thấp và chú trọng việc định nghĩa ra interface phù hợp cho các tương tác giữa chúng.

Các principle/pattern thường được kết hợp với nhau, DI và DIP cũng ko ngoại lệ. Quay trở lại ví dụ về DI đã nói phía trên, bạn có thể thấy tham số của cấu tử dùng để inject dependency đang khai báo là concrete class (ConsoleLogger). Giờ sẽ ra sao nếu bạn muốn dùng FileLogger thay vì ConsoleLogger? Có phải code sẽ cần sửa khá nhiều ko? Giờ bạn hãy thử implement lại theo hướng sử dụng DIP và thay vì FileLogger hãy dùng thêm loại logger khác (DBLogger chẳng hạn) để xem kết quả sau khi áp dụng DIP.

Lời Kết

Các ngôn ngữ, framework lập trình mới ngày càng nhiều, nhưng hầu như đều có thể áp dụng các principle/pattern mà bài post này đề cập. Đặc biệt hơn nữa, các principle/pattern khi kết hợp với sau sẽ mang lại những cải tiến đáng giá cho chất lượng software, qua đó góp phần làm cho công việc của Dev trở nên thú vị hơn. Thay vì gặp trở ngại với những đoạn code “4 chữ khó”, Dev sẽ có không gian để sáng tạo nhiều hơn với những đoạn code “clean” về mặt kiến trúc.

Cám ơn các bạn đã theo dõi! Hãy follow tài khoản này để nhận thông tin về các bài viết tiếp theo.

Happy DDD \(^_^)/

Backend Leader @ Pingcom, Runner