+2

Series Dependency Injection | Bài 4: Cảnh giới cao nhất - Nguyên lý Dependency Inversion và Tuyệt kỹ Mocking

Chào anh em, nếu anh em đã theo dõi từ Bài 1 đến Bài 3, hẳn anh em đã biết cách cấu hình và sử dụng DI Container một cách rành mạch. Nhưng kỹ năng dùng tool (framework) chỉ là bề nổi. Cảnh giới cao nhất của một kỹ sư phần mềm là hiểu được Nguyên lý cốt lõi (Principle) đứng sau tool đó.

Hôm nay, chúng ta sẽ bóc trần chữ D trong bộ nguyên lý thiết kế SOLID trứ danh, và xem cách Dependency Injection giúp chúng ta thực thi chữ D đó như thế nào. Cuối cùng, chúng ta sẽ áp dụng nó vào một nỗi ám ảnh của mọi thợ code: Viết Unit Test.

1. Bức tường thành mang tên Dependency Inversion Principle (DIP)

Nhiều anh em nhầm lẫn giữa Dependency Injection (DI) và Dependency Inversion Principle (DIP).

  • DI là một kỹ thuật (cách bạn truyền object vào class).
  • DIP là một tư tưởng thiết kế (cách bạn tư duy về kiến trúc).

Tư tưởng DIP phát biểu 2 ý cực kỳ kinh điển:

  1. Các module cấp cao (chứa logic nghiệp vụ) KHÔNG được phụ thuộc vào các module cấp thấp (chứa logic giao tiếp data/hệ thống). Cả hai phải phụ thuộc vào Abstraction (Trừu tượng / Interface).
  2. Abstraction không được phụ thuộc vào chi tiết. Chi tiết phải phụ thuộc vào Abstraction.

Nghe lùng bùng lỗ tai đúng không? Hãy xem ví dụ thực tế này.

Sai lầm kinh điển:

class StripePayment {
    public function charge(float $amount) { /* Gọi API Stripe */ }
}

class OrderService {
    public function checkout(StripePayment $payment) {
        $payment->charge(100);
    }
}

Ở đây, OrderService (Cấp cao - Nghiệp vụ lõi) đang phụ thuộc chặt chẽ vào StripePayment (Cấp thấp - Thư viện gọi API). Ngày mai sếp bảo: "Chuyển qua dùng VNPay cho rẻ em ơi". Lúc đó, bạn phải sửa tanh bành file OrderService. Nhỡ bạn code lỗi, chức năng thanh toán của toàn hệ thống "đi bụi".

Áp dụng DIP để giải cứu: Chúng ta tạo ra một "Bản hợp đồng" (Interface) đứng ở giữa.

// 1. Tạo Abstraction (Interface)
interface PaymentGateway {
    public function charge(float $amount);
}

// 2. Module Cấp thấp tuân thủ Interface
class StripePayment implements PaymentGateway {
    public function charge(float $amount) { /* Gọi API Stripe */ }
}
class VNPayPayment implements PaymentGateway {
    public function charge(float $amount) { /* Gọi API VNPay */ }
}

// 3. Module Cấp cao chỉ làm việc với Interface
class OrderService {
    // DI: Tiêm Interface vào thay vì Class cụ thể!
    public function __construct(protected PaymentGateway $payment) {}

    public function checkout() {
        $this->payment->charge(100);
    }
}

Lúc này, OrderService không cần biết bên dưới đang dùng Stripe hay VNPay. Nó chỉ tin tưởng rằng: "Bất cứ thằng nào được truyền vào đây đều có hàm charge(), tao cứ thế mà gọi". Việc đổi cổng thanh toán giờ chỉ là đổi file cấu hình trong IoC Container (như ta đã học ở Bài 2).

2. Tuyệt kỹ Mocking: Lý do lớn nhất để dùng DI

Có một sự thật là: Nếu không có DI và DIP, bạn gần như không thể viết Unit Test một cách tử tế.

Khi test hàm checkout() của OrderService, nguyên tắc của Unit Test là: Nhanh, độc lập và không gọi các dịch vụ bên ngoài (như gọi API thật hay chọc vào Database thật). Nếu OrderService tự new StripePayment(), mỗi lần chạy test là bạn lại mất tiền thật, hoặc mất 3-4 giây để chờ API phản hồi. Quá tệ!

Nhờ có DI và việc phụ thuộc vào Interface, trong môi trường Test, chúng ta có thể truyền vào một "Kẻ đóng thế" (Mock Object).

// File: OrderServiceTest.php (Sử dụng PHPUnit)

public function test_checkout_thanh_cong() 
{
    // 1. Tạo ra một "Kẻ đóng thế" giả danh PaymentGateway
    $mockPayment = $this->createMock(PaymentGateway::class);
    
    // 2. Dạy cho Kẻ đóng thế biết phải làm gì: 
    // "Khi hàm charge() được gọi, hãy luôn trả về TRUE (thành công)"
    $mockPayment->expects($this->once())
                ->method('charge')
                ->willReturn(true);

    // 3. Truyền Kẻ đóng thế vào OrderService (Đỉnh cao của DI là đây!)
    $orderService = new OrderService($mockPayment);
    
    // 4. Chạy hàm cần test
    $result = $orderService->checkout();

    // 5. Kiểm tra kết quả
    $this->assertTrue($result);
}

Bài test này chạy mất đúng 0.001 giây, không hề chọc ra internet, không cần database, và có thể chạy mọi lúc mọi nơi kể cả khi rớt mạng! Đó chính là sức mạnh vô song của Dependency Injection.

Tổng kết toàn Series

Chúc mừng anh em đã đi đến cuối con đường! Nhìn lại hành trình, Dependency Injection không phải là một "phép thuật" gì quá cao siêu. Nó bắt nguồn từ một mong muốn rất đỗi bình dị của lập trình viên: Muốn code linh hoạt hơn, không dính chặt vào nhau, dễ thay đổi và dễ test.

  1. Bắt đầu từ việc ngừng gõ chữ new trong Controller (Manual DI).
  2. Giao phó việc khởi tạo cho IoC Container.
  3. Quản lý chặt chẽ Lifecycle (Transient, Singleton, Scoped) để tối ưu tài nguyên.
  4. Tuân thủ Dependency Inversion bằng cách tiêm Interface thay vì Class cụ thể, mở đường cho Mocking trong Unit Test.

Khi đã làm chủ được DI, anh em sẽ thấy việc đọc hiểu mã nguồn của các framework lớn (như Laravel, Symfony, Spring Boot) trở nên dễ như ăn kẹo. Code của anh em từ nay sẽ "sạch" và chuyên nghiệp hơn rất nhiều.

Cảm ơn anh em đã đồng hành cùng series này. Chúc anh em code khỏe, ít bug và server luôn "xanh"!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí