Tầm quan trọng của Loose Coupling trong hệ thống Backend

Bài viết được sự cho phép của tác giả Lê Nhật Thanh

Những dòng chữ bên dưới là một trong những kiến thức cực kì quan trọng để build một hệ thống backend. Bạn nếu muốn đi con đường backend hoặc fullstack developer thì hãy đọc một cách cẩn thận. Có thể đọc đi đọc lại nhiều lần, vì bài viết về Loose Coupling này mình thật sự rất tâm huyết. Trong bài viết này sẽ nhiều khái niệm như Dependency injection, DIP, IoC hi vọng các bạn sẽ thật sự hiểu được tụi này.

Bản thân là một software engineer, mình đã làm ở trong ngành cũng được một thời gian dài. Mình đã từng trãi rất nhiều hệ thống lớn có nhỏ có, và trong số đó đa phần đều bị vướng một lỗi rất lớn (Lỗi gì thì mình nói sau). Nhưng với lỗi đó, mỗi khi bạn muốn sửa một đoạn code nào đó thì sẽ ảnh hưởng để rất nhiều nơi khác. (Điều này bạn nào có kinh nghiệm rồi làm việc rồi chắc chắn sẽ từng gặp). Mình hay gọi nó với cái từ là impact list, kiểu mình fix một cái bug A nào đó, thì sau khi fix xong, 1 đống bug B, C, D, E, F xuất hiện.

Nói một cách ví von, cái nhà (hệ thống) mình xây lên, chỉ cần ai đó rung nhẹ cái là sập nguyên căn. Đó là một vấn đề rất lớn của những lập trình viên chưa có kinh nghiệm.

Tìm việc làm Backend Intern HCM trên TopDev ngay!

TÌM HIỂU MỘT CHÚT NHÉ.

Loose Coupling là một khái niệm dễ hiểu, nhưng khó đạt được. Và khái niệm này cũng khá rộng lớn. Ở trong phạm vi bài viết này, mình chỉ đề cập tới Loose Coupling OOP  (lập trình hướng đối tượng). Và cụ thể hơn là Loose Coupling trong việc quản lý dependency. Chứ khái niệm này khá rộng, ví dụ như việc quản lý state, flow,… . Ngoài ra khái niệm này còn xuất hiện trong cả khi thiết kế hệ thống lớn, database distributed,… khi mà các node… phụ thuộc lẫn nhau. Hoặc các module lớn phụ thuộc lẫn nhau.

Ok, quay lại vấn đề của chúng ta là Loose Coupling trong OOP.

Đi qua khái niệm một chút.

  Low Coupling và High Cohesion là gì?

  Top 6 nguyên lý thiết kế microservices cho lập trình viên có kinh nghiệm

TIGHT COUPLING VÀ LOOSE COUPLING LÀ GÌ?

Trong lập trình hướng đối tượng Tight Coupling và Loose Coupling là hai thuật ngữ liên quan đến mức độ mà một lớp (class hay module) phụ thuộc vào lớp khác.

  • Tight Coupling (phụ thuộc chặt chẽ): Khi một lớp phụ thuộc chặt chẽ vào lớp khác, nghĩa là bất kỳ thay đổi nào trong lớp này cũng có thể ảnh hưởng đến lớp kia. Điều này làm giảm khả năng maintenance, tái sử dụng (reusable) và mở rộng (extendable) của mã nguồn (source code).
  • Loose Coupling (phụ thuộc lỏng lẻo): Khi một lớp phụ thuộc lỏng lẻo vào lớp khác, nghĩa là nó chỉ phụ thuộc vào một phần nhỏ của lớp kia, thường là thông qua interface hoặc lớp trừu tượng (abstraction). Điều này giúp mã nguồn dễ dàng mở rộng (extendable), tái sử dụng (reusability) và bảo dưỡng (maintenance) hơn.

Xem ví dụ bên dưới:

class UserService {
    private MySQLUserRepository $userRepository;

    public function __construct() {
        // Dòng ở dưới là tạo ra một hard dependency
        // Dẫn tới tight coupling
        $this->userRepository = new MySQLUserRepository();
    }

    public function getById(int $id): string {
        // Application logic here

        return $this->userRepository->getById($id);
    }
}

class MySQLUserRepository {
    public function getById(int $id): string {
        // Code to interact with DB
    }
}

Bạn hãy nhìn từ khoá new!

Như ví dụ này thì UserService phụ thuộc chặt vào MySQLUserRepository với new keywork.

Đây là tight coupling. Vì khi chúng ta thay đổi class MySQLUserRepository (ví dụ đổi tên, thay đổi param constructor) thì sẽ dẫn tới class UserService bị đổi theo. Giả sử có hàng trăm class sử dụng MySQLUserRepository thì chúng ta phải sửa hàng trăm class đó trong khi mục đích là chỉ sửa đúng 1 class.

Và trong giới lập trình, class MySQLUserRepository được gọi là một dependency của UserService.

Và khi chúng ta khởi tạo trực tiếp class (với từ khoá new) thì đây được gọi là hard dependency.

Tham khảo việc làm Back-End lương hấp dẫn trên TopDev!

VẤN ĐỀ?

Thử tưởng tượng hệ thống của bạn có hàng ngàn class và những class này chằng chịch hard dependency thì chuyện gì xảy ra? Hệ thống của bạn sẽ cực kì khó maintain, mở rộng và tái sử dụng, điều dễ thấy nhất đối với hệ thống bị tight coupling là khi refactor, hoặc fix cái gì đó rất nhỏ, nhưng impact list của nó thì rất lớn, và người ta rất ngại refactor, đôi khi phải đập đi xây lại hoàn toàn. Ngoài ra viết unit test với những trường hợp như vậy là cực hình. Và hard dependency gây ra Tight Coupling – cũng chính là cái lỗi mình đề cập ở đầu bài viết.

Bây giờ chúng ta sẽ đi theo flow bên dưới từng bước từng bước một. Để từ Tight Coupling là ví dụ trên, chúng ta sẽ tiến đến Loose Coupling.

Loose Coupling quan trọng như thế nào trong hệ thống backend

FLOW TRÊN BAO GỒM 6 BƯỚC:

  • Step 1: Chúng ta có 1 đoạn code Tight Coupling (ví dụ nên lấy 1 đoạn thôi, nhiều quá khó đọc lém)
  • Step 2: Implement IoC sử dụng Factory Pattern
  • Step 3: Apply DIP (chữ D trong SOLID)
  • Step 4: Implement DI – Dependancy Injection
  • Step 5: Sử dụng IoC Container
  • Step 6: Chúng ta đạt được Loose Coupling (Vui tại đây)

Step 1 đã giới thiệu xong, bây giờ đến step 2 nhé.

À, 6 step ở trên chắc các bạn đã hiểu được step 1 và step 6 rồi, còn 4 step còn lại, chúng ta sẽ cùng nhau đi qua từng bước một. Các bạn đừng lo, nó thật sự dễ hiểu lắm nếu bạn thật sự tập trung.

IMPLEMENT IOC USING FACTORY PATTERN

TRƯỚC TIÊN CÙNG TÌM HIỂU IOC LÀ GÌ?

Inversion of Control (IoC) là một design principle (mặc dù, một số người gọi nó là design pattern), nhớ nha, là design principle. Như tên gọi, nó được sử dụng để đảo ngược sự điều khiển (control) trong thiết kế hướng đối tượng để đạt được sự kết hợp lỏng lẻo (loose coupling). Ở đây, điều khiển bao gồm điều khiển flow của việc tạo ra một đối tượng (object creation) hoặc tạo đối tượng phụ thuộc (dependency creation) và việc quản lý, cung cấp đối tượng được tạo ra cho ứng dụng trong quá trình ứng dụng running.

Nguyên lý IoC giúp thiết kế các class trở nên lỏng lẻo hơn (loose coupling), giúp chúng có thể unit test, maintain và mở rộng dễ dàng hơn.

IoC hoàn toàn về việc đảo ngược điều khiển. Ái chà, cái “sự giải thích” ở trên khá là khoai và khó hiểu nhỉ?

Để mình giải thích điều này theo ngôn ngữ thông thường. Giả sử bạn lái xe đến nơi làm việc, điều này có nghĩa là bạn điều khiển chiếc xe. Nguyên lý IoC đề xuất đảo ngược điều khiển, có nghĩa là thay vì tự lái xe, bạn thuê một chiếc taxi, nơi một người khác sẽ lái xe. Do đó, điều này được gọi là đảo ngược điều khiển – từ bạn sang tài xế taxi. Bạn không phải tự lái xe và bạn có thể để tài xế lái xe để bạn có thể tập trung vào công việc chính của mình. Cái đích cuối cùng là bạn muốn đến nơi làm việc thôi, việc còn lại không cần quan tâm nữa.

Tới đây có thể bạn có hình dung ra phần nào rồi. Tới đoạn dưới bạn đọc code thì sẽ hiểu 100%.

TIẾP TỤC THÔI!

À xem cái hình trước.

Loose Coupling quan trọng như thế nào trong hệ thống backend

Ở hình trên thì chúng ta có khá nhiều design pattern để có thể implement IoC. Trong phần này, chúng ta sẽ sử dụng Factory pattern để implement IoC. Đằng sau chúng ta sẽ dùng pattern Dependency injection rất là bá :)))

Ok lets go!

Loose Coupling quan trọng như thế nào trong hệ thống backend

Chúng ta sẽ thử sử dụng Factory Pattern để “Invert control” hay gọi là đảo ngược sự điều khiển.

class UserService {
    // (Ở đây còn Tight coupling)
    private MySQLUserRepository $userRepository;

    public function __construct() {
        // Đảo ngược chổ này nè
        // (Ở đây cũng còn một chút Tight coupling)
        $this->userRepository = UserRepositoryFactory::getUserRepository();
    }

    public function getById(int $id): string {
        // Application logic here

        return $this->userRepository->getById($id);
    }
}

public class UserRepositoryFactory
{
    public static function getUserRepository(): MySQLUserRepository
    {
        return new MySQLUserRepository();
    }
}

Ở ví dụ ban đầu, UserService đang điều khiển (control) MySQLUserRepository các việc như tạo, quản lý object bằng từ khoá new đó nhớ không? Nhưng khi chúng ta apply Factory thì việc điều khiển class MySQLUserRepository đã được đẩy về Factory. Nên UserService không còn điều khiển MySQLUserRepository nữa. Đây là concept của IoC. Đảo ngược sự điều khiển. Tới đây hiểu rồi chứ? Không hiểu thì quay lại nha. Quay đi quay lại 10 lần không hiểu thì mình chịu, để lại comment dưới mình sẽ giải thích thắc mắc.

Tuy nhiên, với việc apply IoC sử dụng Factory pattern, đoạn code của chúng ta vẫn chưa hoàn toàn đạt được loose coupling. Bởi vì nhìn dòng số 3, và số 8 ở đoạn code trên, UserService vẫn còn … dính tới MySQLUserRepository và UserRepositoryFactory.

Bây giờ chúng ta sẽ đi tới step tiếp theo?

Loose Coupling quan trọng như thế nào trong hệ thống backend

APPLY DEPENDENCY INVERSION PRINCIPLE (DIP)

DIP LÀ GÌ?

Là nguyên lý D trong SOLID – Dependency Inversion Principle. Mình có viết 1 bài về chữ D này ở đây. Nguyên lý này nói rằng:

  • High-level modules should not depend on low-level modules. Both should depend on the abstraction.
  • Abstractions should not depend on details. Details should depend on abstractions.

Nghe có vẻ hơi lú thật, nhưng bạn sẽ hiểu ngay thôi. Mình hứa!

Quay lại đoạn code ban đầu, thì một cách rất rất dễ hiểu:

  • High-level modules chính là UserService
  • Low-level modules chính là MySQLUserRepository

Ở ví dụ trên:

  • High-level modules và Low-level modules đang phụ thuộc cứng vào nhau (mặc dù đã dùng Factory implement IoC)
  • Và cũng chẳng có cái gì trừu tượng (abstractions) ở đây cả.

Bây giờ chúng ta sẽ làm sao đó, tìm cách implement DIP.

class UserService {
    // Bây giờ dùng interface ở đây
    private UserRepositoryInterface $userRepository;

    public function __construct() {
        $this->userRepository = UserRepositoryFactory::getUserRepository();
    }

    public function getById(int $id): string {
        // Application logic here

        return $this->userRepository->getById($id);
    }
}

public class UserRepositoryFactory
{
    public static function getUserRepository(): UserRepositoryInterface
    {
        return new MySQLUserRepository();
    }
}

// MySQLUserRepository implement UserRepositoryInterface

Chúng ta đã tạo ra UserRepositoryInterface và bây giờ:

  • High-level modules và Low-level modules hiện tại chỉ đang phụ thuộc vào 1 interface (UserRepositoryInterface).
  • Và MySQLUserRepository phụ thuộc vào interface chứ interface không phụ thuộc vào MySQLUserRepository.

Ghê, đổi có xíu mà thoã mãn luôn cả 2 cái câu trên của DIP, thiệt là quá đỉnh :)))

ĐÃ ĐẠT ĐƯỢC LOOSE COUPLING?

Nhưng chúng ta vẫn còn cấn UserRepositoryFactory::getUserRepository(). Thử tưởng tượng khi chúng ta cần inject động vài depedency vào MySQLUserRepository thì chúng ta phải change UserServicenhư sau:

$this->userRepository 
= UserRepositoryFactory::getUserRepository($dependencyA, $dependencyB);

Hoặc đơn giản là muốn sửa đổi thằng UserRepositoryFactory thì chúng ta vẫn đôi khi phải modify thằng UserService.

Chúng ta vẫn chưa hoàn toàn đạt được LOOSE COUPLING!!

Hummmm, bây giờ phải làm sao nhỉ? Quay lại cái hình nào, chúng ta vẫn còn step 4.

Loose Coupling quan trọng như thế nào trong hệ thống backend

Mình sắp tới đích rồi các bạn. Bây giờ chúng ta sẽ thử implement Dependency Injection.

IMPLEMENT DEPENDENCY INJECTION ĐỂ HOÀN TOÀN ĐẠT ĐƯỢC LOOSE COUPLING

DEPENDENCY INJECTION – DI LÀ GÌ?

DI – Dependency Injection nghĩ đơn giản là inject (tiêm, chích, …) một dependency vào một class. Và có 3 loại inject:

  • Constructor injection (cách này hay xài)
  • Method injection (cái này cũng hay xài)
  • Property injection (cái này ít xài hơn)

Ví dụ:

// UserService.php
// Cái này gọi là inject (tiêm) $repo vào UserService
public function __construct(Repo $repo)

Quay lại ví dụ trên.

Thay vì dùng Factory pattern ở Step 2 (tới đây thì bạn hãy quên đi step 2 là được), bây giờ chúng ta thử dùng … Dependency Injection. Và bạn cần nhớ là DI là một design pattern giúp mềnh hiện thức hoá IoC.

class UserService {
    private UserRepositoryInterface $userRepository;

    // Chổ này là Dependency injection nè
    public function __construct(UserRepositoryInterface $userRepository) {
        $this->userRepository = $userRepository;
    }

    public function getById(int $id): string {
        // Application logic here

        return $this->userRepository->getById($id);
    }
}

Đừng quên là chúng ta inject vào nhưng vẫn kết hợp DIP cùng với Dependency injection ở step 3 (nghĩa là vẫn dùng interface nhé)

Ok done! Giờ chúng ta kiểm tra lại code.

Khi muốn đổi MySQLUserRepository: Ví dụ thay đổi class name, thay đổi constructor params, hay đổi luôn thành MongoUserRepository, hay làm ABC XYZ nào đó, thì class UserService vẫn không thay đổi.

Và khi muốn viết unit test cho UserService, quá đơn giản, UserRepositoryInterface mock mốt phát một là xong! (Ở đây bạn nào chưa biết viết unit test và chưa biết mock là gì thì search google 1 chút nhé)

VẬY LÀ NGON CHƯA?

Với việc Dùng DI, kết hợp với DIP, chúng ta đã hoàn toàn đạt được Loose Coupling. Đây chính là trạng thái LOOUSE COUPLING mà chúng ta cố gắng đạt được từ ban đầu.

Loose Coupling quan trọng như thế nào trong hệ thống backend

Ấy, sẽ có bạn nhắc là ủa, sao từ step 4 nhảy cóc qua step 6 luôn gồi? À đợi mình chút nhé.

Từ đầu đến giờ, chúng ta luôn thao tác với class UserService. Mà chưa 1 lần thực sự xài nó. Bây giờ xài nha. Giả sử chúng ta xài ở UserController nha.

// Giả sử ở đây là UserController
$userService = new UserService(new MySQLUserRepository());

Ở controller, khi chúng ta muốn xài UserService, vì UserService nó cần một đối tượng nào đó phải implement cái interface UserRepositoryInterface để có thể inject vào đúng không. Ở đây chúng ta khởi tạo MySQLUserRepository để inject vào thôi.

Nếu đoạn đó ở controller thì chúng ta cứ tạm gọi là “ổn” đi cho tới khi chúng ta làm thực tế :)))

public function __construct(
        PluginService $plugin_service,
        FileRepositoryInterface $file_repository,
        MemberListService $member_list_service,
        TargetService $target_service,
        ConfigRepositoryInterface $config_repository,
        paramRepositoryInterface $param_repository
    ) {
        $this->pluginService = $plugin_service;
        $this->fileRepository = $file_repository;
        $this->memberListService = $member_list_service;
        $this->targetService = $target_service;
        $this->configRepository = $config_repository;
        $this->paramRepository = $param_repository;
    }

Thực tế thì 1 class nó phụ thuộc vào 5, 7 class hoặc nhiều hơn là chuyện bình thường. Và mỗi class dependency nó lại phụ thuộc vài class nữa

Và đây là controller trong thực tế case trên :))).

$use_case = new ViewPlugin(
            new PluginService(
                new MySQLPluginRepository(), 
                new MySQLUserRepository()),
            new InMemoryFileRepository(),
            new MemberListService(),
            new TargetService(
                new TargetRepository(),
                new MemberListService(new MySQLUserRepository())
            ),
            new ConfigRepository(),
            new ParamRepository()
        );

$use_case->execute($plugin_id);

Nhìn hoảng thật!!!!

Làm sao để giải quyết vấn đề trên? IoC Container sẽ giúp chúng ta giải quyết vấn đề nan giải này.

Loose Coupling quan trọng như thế nào trong hệ thống backend

SỬ DỤNG IOC CONTAINER

IOC CONTAINER LÀ GÌ?

IoC thì hiểu rồi đó, thế nó thêm chữ container vào làm gì nhỉ? Xe container chăng?

Chúng ta hiểu đơn giản, nó là cái hộp (box hay container) thật.

IoC Container (hay còn được gọi là DI Container) là một framework để thực hiện việc tiêm phụ thuộc tự động (automatic dependency injection). Nó quản lý việc tạo đối tượng (object creation) và thời gian sống của đối tượng, cũng như tiêm các phụ thuộc vào lớp khi cần.

Nghĩa là sao?

  • IoC —> Đảo ngược sự điều khiển
  • Dependency injection —> Implement thằng IoC để đảo ngược sự điều khiển, nhưng ông developer phải làm bằng tay (dùng new ở controller ở trên đó)
  • Và IoC Container —> nó vừa đảo ngược sự điều khiển và nó làm luôn nhiệm vụ tạo ra đối tượng và tự inject vào luôn. Chúng ta sẽ không thấy chữ “new” nữa

Nói dài hơn là thế này: IoC container tạo một đối tượng của lớp được chỉ định và cũng tiêm tất cả các đối tượng phụ thuộc thông qua một constructor, một thuộc tính hoặc một phương thức tại thời gian chạy và hủy nó vào thời điểm thích hợp. Điều này được thực hiện để chúng ta không phải tạo và quản lý các đối tượng một cách thủ công.

Cho nên khi chúng ta apply IoC Container, chúng ta hoàn toàn chuyển quyền điều khiển “control“ về framework. Container sẽ quản lý dependency và tiêm nó vào lúc chúng ta cần.

Thử apply 1 vài cái container xem nào :)))

MỘT VÀI VÍ DỤ

Ví dụ khi apply PHP-DI Container:

// binding / mapping, ...
[
    'PluginRepositoryInterface' => MySQLPluginRepository::class,
    'UserRepositoryInterface'   => MySQLUserRepository::class,
    'FileRepositoryInterface'   => InMemoryFileRepository::class,
    ...
];

$container = new DI\Container();
$use_case = $container->get('ViewPlugin'); // Đúng 1 dòng này

Cái ViewPlugin ở trên nó phụ thuộc cả chục dependency nhớ không các bạn? Bây giờ với việc apply PHP-DI container, chúng ta chỉ cần config chỉ định cho container biết là interface nào sẽ được map với class nào trong quá trình running. Còn việc new new new chục cái dependency và tự động inject vào khi cần đã thuộc về container, chúng ta không cần làm gì nữa. Chỉ cần đúng 1 dòng: $use_case = $container->get('ViewPlugin');

Còn đây là ví dụ khi dùng Spring IoC Container:

@Service
public class ViewPlugin {

    private PluginService pluginService;
    private FileRepositoryInterface fileRepository;

    ...
    @Autowired
    public ViewPlugin(
        PluginService pluginService, 
        @Qualifier("InMemoryFileRepository") FileRepositoryInterface fileRepository
    ) {
        this.pluginService = pluginService;
        this.fileRepository = fileRepository;
    }

    public void execute(String pluginId) {
        // ...
    }
}

Tương tự trong Spring IoC Container, chúng ta cần chỉ định class nào sẽ được map với interface bằng Anotation @Qualifier, và dùng @Autowired để thông báo cho container biết rằng chổ này tôi cần tiêm 1 số dependency vào.

Ok tới đây cũng gần kết rồi, bài viết thì cũng dài, bạn nào đọc được dòng này và “wow” lên thì xin chúc mừng bạn đã thấu hiểu được 1 trong những concept quá là đỉnh khi làm việc với OOP và cụ thể hơn là thiết kế backend.

TÚM CÁI VÁY LẠI MỘT XÍU CHO DỄ NHỚ VỀ LOOSE COUPLING

LOOSE COUPLING GIÚP HỆ THỐNG CỦA CHÚNG TA XỊN HƠN!!!!

  • IoC: Đảo ngược sự điều khiển, thường là đùn đẩy trách nhiệm điều khiển cho framework làm luôn
  • DI: Tiêm đối tượng vào class/module
  • DIP: Xem xét xử dụng abstraction (interface)
  • IoC Container: Một cái hộp – tạo, quản lý, và tự động inject dependency khi cần

Và một điều cũng quan trọng, loose coupling là trạng thái lỏng lẽo thôi, chứ không phải là hoàn toàn không phụ thuộc nữa. Đó chính là đặc tính của lập trình hướng đổi tượng vì interface thường ít bị thay đổi hơn….

Và không phải lúc nào chúng ta cũng phải sử dụng interface đâu nhé. Việc này là phải cân nhắc, ví dụ interface thường được dùng ở tầng repository và tầng service. Và một số nơi khác khi chúng ta muốn switch qua lại giữa các implementation (giống như lúc thì xài cái này, lúc xài cái kia nhưng cả 2 cái đều có hành vi là giống nhau, chỉ khác nhau phần logic ruột – method body).

KẾT LUẬN

Để quản lý tốt dependency khi thiết kế phần mềm (đạt được loose coupling), chúng ta có thể dùng:

  • DIP (hiểu đơn giản là dùng interface hoặc abstract khi cần thiết)
  • Dependency injection (DI là 1 design pattern của IoC) để inject dependency vào class.
  • Khi đã sử dụng 2 ông trên chúng ta đã đạt được loose coupling. Và để quản lý việc khởi tạo, tự động inject dependency, chúng ta sử dụng thêm IoC Container là mọi thứ … ỔN

VÀ ĐÂY LÀ KIẾN TRÚC CỦA HẦU HẾT BACKEND FRAMEWORK HIỆN TẠI.

Cám ơn các bạn đã đọc bài viết.

Bài viết gốc được đăng tải tại lenhatthanh.com