Bàn về Dependency Inversion trong Python

Giảm dependency với DI (17/03/2024, 09:25)

Bàn về Dependency Inversion trong Python

Mình mới đi phỏng vấn một công ty (sử dụng Python) thì được hỏi về Dependency Inversion. Nếu đến từ các ngôn ngữ static-typed như Java hay C# thì có lẽ mình sẽ tương đối quen thuộc với những concepts này.

Mình đã trả lời được một chút về concept này khi phỏng vấn, vì thực sự, khi chúng ta sử dụng các ngôn ngữ dynamic-typed, type của một object chỉ được xác định ở runtime hơn là compile time (vì có compile đâu). Tuy nhiên, khi nhìn rộng ra, Dependency Inversion và Dependency Injection là những patterns để thiết kế ứng dụng, không phụ thuộc vào ngôn ngữ.

Do đó, hôm nay mình sẽ thử Dependecy Inversion nó trong Python.

Dependency Inversion

Đặt vấn đề:

Mình là một người thường xuyên sử dụng Django và hệ sinh thái của nó, đặc biết là Django ORM. Django ORM cung cấp cho chúng ta những API để tương tác với database, điều này vô cùng tiện lợi và chúng ta có thể sử dụng nó ở bất cứ đâu chúng ta muốn.

Trong dự án mình từng làm, có một giai đoạn, họ muốn thay thế loại bỏ một số models để thay thế bằng những Microservices. Tuy nhiên điều này tương đối khó khi:

  • Business Logic đang phụ thuộc trực tiếp lên Django Model/ORM: không có Service Layer riêng cho Business Logic nên việc thay thế các Model query thành các API từ Microservices sẽ cần được kiểm thử cẩn thận. Điều chúng ta có thể dễ nhận thấy nhất đó chính là Model Query sẽ trả về QuerySet[Model] trong khi các API có thông thường sẽ nhận về JSON (và được chuyển đổi thành dict) hay ở dạng binary (ví dụ: Avro).
  • Testing: không chỉ QA sẽ phải kiểm thử lại các chức năng bị ảnh hưởng, mà các lập trình viên cũng sẽ phải cập nhật các tests (unit tests, integration test, E2E tests, automation tests, etc).

Ý tưởng của Dependency Inversion

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.

Bài viết này tóm gọn patterns trong 2 câu như sau:

Dependency Inversion == “Someone take care of this for me, somehow.”

Dependency Injection == “Gimme it”

Mình rất thường hay nghe về dependency injection, đây là một trong những kỹ thuật phổ biến nhất để đạt được dependency inversion. Hãy cùng xem một số ví dụ để nhìn rõ hơn ý tưởng này:

class OrderService:
    email_service = None
    
    def __init__(self):
        self.email_service = EmailService()

    def validate_order(self, data: dict) -> None:
        ...

    def create_order(self, data: dict) -> dict:
        self.validate_order(data)

        new_order = Order.objects.create(**data)

        self.message_service.send_notification(
            new_order.owner.email,
            context=data,
            template_name='new_order_created'
        )

Trong ví dụ trên hàm create_order sẽ "depend" lên EmailService, và khi chúng ta

Điều chúng ta thực sự cần từ EmailService đó chính là method send_notification, và thực sự chúng ta không nhất thiết phải là EmailService chúng ta chỉ cần "một cái gì đó" có method send_notification.

"Một cái gì đó", vì chúng ta không thực sự quan tâm đó là gì, do đó chúng ta có thể trừu tượng hoá EmailService bằng một interface/abstract class.`

Ví dụ trên có thể viết lại như sau với Dependency Inversion

class OrderService:
    email_service = None
    
    def __init__(self, messaging_service):
        self.messaging_service = messaging_service

    def validate_order(self, data: dict) -> None:
        ...

    def create_order(self, data: dict) -> dict:
        self.validate_order(data)

        new_order = Order.objects.create(**data)

        self.message_service.send_notification(
            new_order.owner.email,
            context=data,
            template_name='new_order_created'
        )

Hãy cùng nhìn ví dụ này, messaging_service sẽ được khởi tạo và truyền vào class OrderService khi class được sử dụng. Khác với các ngôn ngữ như Java hay C#, chúng ta không bắt buộc phải tạo interface vì kiểu dữ liệu của một object chỉ được xác định ở runtime, tuy nhiên, để code được thêm tường minh và rõ ràng, chúng ta hoàn toàn vẫn có thể tạo một abstract class và dùng type-hint hoặc docstrings để người sử dụng biết kiểu dữ liệu của object.

from abc import  ABC, abstractmethod
class AbstractEmailService(ABC):
    @abstractmethod
    def send_notification():
        raise NotImplementedError()

class OrderService:
    def __init__(self, messaging_service: AbstractEmailService):
        self.messaging_service = messaging_service

    # the rest of the implementation are the same

Note: có thể nói type-hint cũng là một dependency nhỏ, mặc dù nó không có tác dụng ở runtime, nhưng nếu chúng ta remove class EmailService đi, hay đổi tên, lỗi sẽ xảy ra ở start up time.

Quay lại với ví dụ, như mình đã đề cập ở trên, chúng ta chỉ quan tâm send_notification, do đó, nếu ứng dụng muốn gửi SMS thay vì Email, thì lập trình viên có thể thay thế EmailService bằng SMSService mà không cần cập nhật code của OrderService:

# Send email
email_service = EmailService()
order_service = OrderService(messaging_service=email_service)

# Send sms
sms_service = SMSService()
order_service = OrderService(messaging_service=sms_service)

# Or using factory pattern
# Factory Pattern also a pattern to achieve Dependency Inversion
messaging_service = MessagingFactory().get_messenger('mobile')
order_service = OrderService(messaging_service=messaging_service)

Khi dùng DI câu hỏi mình thường đặt ra là: sau cùng, chúng ta vẫn phải khởi tạo EmailService ở một nơi nào đó, để truyền vào trong OrderService, do đó, chúng ta (thực ra) chỉ đang chuyển việc khởi tạo dependency từ một nơi sang một nơi khác phù hợp hơn, hoặc khi thực sự cần thiết. Mình có sử dùng các framework của .Net, hầu hết đều phục vụ Dependency Injection khá tốt, khi chúng ta chỉ cần khai báo interface và register scope + interface vào service, sau đó .Net sẽ tự khởi tạo cho chúng ta.

Testing

Khi phỏng vấn, người phỏng vấn viên cũng có nói mình là mình có thể business logic là một pure Python class không depends lên bất cứ thứ gì (khi sử dụng nguyên lý Dependency Inversion), và khi viết test những lập trình viên có thể thuần tuý viết kiểm tra những logic của mình, thay vì thực sự phải quan tâm đến dữ liệu hay những dependencies xung quanh.

Mình khá tin là người phỏng vấn viên đang nói về Business Layer trong Clean Architecture, với mong muốn là tách Database Access Layer (Django ORM) ra khỏi Business Logic. Cá nhân mình chưa có nhiều kinh nghiệm về phần này, nên trong bài viết này mình sẽ không đề cập nhiều hoặc mình sẽ update khi có thêm nhiều thông tin hơn.

Nhưng cá nhân mình thấy đây là một ý tưởng khá hay, khi việc viết tests có thể sẽ được đơn giản hoá đi tương đối nhiều, nếu đang phụ thuộc vào Django ORM.

Đôi lời kết:

Mình là một người sử dụng nhiều Django hơn hẳn các framework khác, và mình thường sử dụng những cách tương đối "standard" để viết Views/APIs, chủ yếu là theo document và theo chân những source code đã có sẵn. Hiện tại mình vẫn chưa thấy nhiều ví dụ thực tế (đủ lớn) được tìm hiểu kỹ thêm về nguyên lý này. Dù vậy, chúng có thể sử dụng FastAPI có trực tiếp hỗ trợ Dependency Injection để tìm hiểu thêm về pattern này.

FastAPI has a very powerful but intuitive Dependency Injection system.

Mặc dù lợi ích có thể thấy rõ, có tương đối nhiều tranh cãi xung quanh việc nguyên lý Dependency Inversion sẽ làm cho codebase trở nên khó đọc và tiếp tiếp cận, khi với mỗi dependency được truyền vào, chúng ta cần tạo một interface hoặc một abstraction layer mỗi khi muốn "inject" bất cứ điều thứ gì.

P/s: hãy ôn bài trước khi đi phỏng vấn 😭.

Go live on 11/07/2023 by Huy Vu