Tự tạo 1 mini Dependencies Injection Container với PHP

Overview

Có lẽ rất nhiều người trong chúng ta đã rất quen thuộc với khái niệm Dependencies Injection, cũng như hiệu quả của nó mang lại. Vậy DI hoạt động như thế nào trong PHP và các framework của PHP, để hiểu rõ cách tốt nhất là xây dựng 1 mini DI container dựa theo cách các framework triển khai nó. Container chúng ta xây dựng sẽ không hoàn toàn giống và chi tiết như Service Container trong Laravel (chẳng hạn) nhưng chắc chắn sẽ giúp bạn hiểu hơn về cách làm của họ.

Bắt đầu, tôi sẽ tóm lược lại 1 vài những điểm cơ bản nhất về DI và tất nhiên theo 1 cách dễ hiểu nhất, sau đó rất nhanh chúng ta sẽ bắt tay vào xây dựng 1 mini DI Container để ứng dụng ngay. Như thường lệ, code demo minh hoạ nằm ở cuối bài

Dependencies Injection là gì?

Dependencies Injection là 1 cách triển khai để ứng dụng Inversion Of Control Design Pattern vào chương trình.

Các bạn cần phân biệt nó với Dependencies Inversion là 1 nguyên lý của lập trình hướng đối tượng. Trong bài khi viết DI có nghĩa là đang nhắc tới Dependencies Injection

Mục đích của DI

Bắt đầu với 1 ví dụ kinh điển nhưng cũng rất dễ hiểu.

class Car {
    private $engine;
    
    public function __construct()
    {
        $this->engine = new HuyndaiEngine();
    }
}

Ta thấy rằng Car đang bị phụ thuộc vào HuyndaiEngine, và nếu muốn thay đổi động cơ engine như là V6Engine chẳng hạn cho nó, bắt buộc ta phải sửa code. Hoặc giả như sau này HuyndaiEngine có thêm đối số đầu vào thì ta cũng phải sửa lại code của Car cho phù hợp ví dụ như :

class HuyndaiEngine()
{
    private $extra;

    public function __contruct(Nitro $nitro)
    {
        $this->extra = $nitro
    }
}

class Car {
    private $engine;
    
    public function __construct(Nitro $nitro)
    {    
        $this->engine = new HuyndaiEngine($nitro);
    }
}

Vậy thì DI có mặt là để giải quyết problem này hay nói cách khác nó decoupling code của bạn. Các thành phần tương tác với nhau không còn chặt chẽ nữa. Khi sử dụng DI code của bạn ngắn gọn tương tự như sau:

    //Đầu tiên đăng ký Dependencies
    DIContainer->bind('engine', 'HuyndaiEngine');
    
    //Gọi ra thể hiện của Car
    $car = DIContainer->get(Car);

Đoạn code đầu tiên đăng ký Dependencies nếu nói theo human-language như thế này :

DIContainer, khi nào tôi gọi egne từ DIContainer thì đưa cho tôi HuyndaiEngine.

Điều kỳ diệu là khi bạn gọi ra thể hiện của Car thì tất cả các thành phần Car phụ thuộc vào sẽ được DIContainer tính toán và đưa vào cho bạn dựa vào danh sách dependencies đã được đăng ký. Mô hình DIContainer này được các framework triển khai và nếu bạn đang dùng 1 trong số đó thì công việc còn lại chỉ là đăng ký các dependencies và khởi tạo Class, tất cả cứ để DIContainer lo.

1 phần quan trọng trong IoC(Inversion of Control) là các class ràng buộc với nhau qua Interface không phải bằng Implementation, nên trong nhiều framework cách tốt nhất để ứng dụng DI là đăng ký Interface với DI. Khi đó trong class cần dùng (Car) sẽ giao tiếp với Dependencies (HuyndaiEngine) thông qua 1 interface là Engine.

Tất nhiên để nói về ứng dụng của DI thì còn rất rất nhiều như là dễ dàng hơn trong Unit Test hay mở rộng ứng dụng, tái sử dụng code ,... Nhưng để không vượt quá phạm vi của bài viết, tôi xin nhường phần này lại cho các bạn tìm hiểu 😃 Như đã nói thì DIContainer được các Framework triển khai để người code sử dụng được ngay, đối với các bạn Fresher thì chưa cần thiết phải hiểu tầng logic bên dưới nhưng khi trình độ đã cao hơn 1 chút thì tôi nghĩ bạn nên hiểu về mô hình hoạt động của nó nhỉ ^_^ Vậy để hiểu ẩn bên dưới nó là gì không thể nào tốt hơn việc bạn tự code 1 mini-DIContainer của mình và đây cũng sẽ là phần chính của bài viết.

A Mini DIContainer Implementation

Trước khi bắt tay vào code, thì tôi cũng tiết lộ luôn là chúng ta sẽ học theo cách các Framework để triển khai DIContainer, và cốt lõi của bài toán là dùng Reflection Class để giải quyết

Để tiết kiệm thời gian thì "mục đích và công dụng của Reflection Class trong DIContainer" tôi xin mượn tạm bài của bạn HVT =)) Phân tích khá kỹ về cách Laravel Framework sử dụng Reflection Class để vận hành Service Container (chính là tên của DI Container theo cách gọi của Laravel) . https://viblo.asia/p/dependency-injection-hoat-dong-the-nao-trong-laravel-3Q75wD3JKWb

Ứng dụng MVC cơ bản

Những gì ta cần có là 1 ứng dụng MVC để minh hoạ cho DI Container của mình.

//Đảm nhận nhiệm vụ xuất output ra màn hình, 
class FactoryView {
    public function show($str) {
        echo "<p>".$str."</p>";
    }
}
//Tương tác với Bussiness Model Object
class UsersModel {
    public function get() {
        return [
            (object) ['ho' => 'Nguyen', 'ten' => 'Van A'],
            (object) ['ho' => 'Nguyen', 'ten' => 'Thi B'],
        ];
    }
}
//Các class này sẽ sử dụng View như là 1 Dependencies, từ đó sẽ truyền nội dung của chúng đến FactoryView để xuất ra output, nói cách khác chúng đóng vai trò là những View trong MVC.
class Navigation {
    private $view;

    public function __construct() {
        $this->view = new FactoryView();
    }

    public function show() {
        $this->view->show('
            <a href="#" title="Home">Home</a> | 
            <a href="#" title="Home">Danh Muc</a> | 
            <a href="#" title="Home">Lien He</a>
        ');
    }
}

class Content {
    private $title;
    private $view;
    private $usersModel;
    
    public function __construct($title) {
        $this->title = $title;
        $this->view = new FactoryView();
        $this->usersModel = new UsersModel();
    }
    
    public function show() {  
        $users = $this->usersModel->get();
        $this->view->show($this->title);
        foreach($users as $user) {
            $this->view->show($user->ho." ".$user->ten);
        }
    }
}
//Controller làm nhiệm vụ điều hướng, quyết định view nào sẽ được gọi, và nội dung View là gì
class PageController {
    public function show() {
        $navigation = new Navigation();
        $content = new Content("Trang Danh Sach Nguoi Dung");
        $navigation->show();
        $content->show();
    }
}

//Cuối cùng dựa vào URL để khởi tạo Controller và action
$page = new PageController();
$page->show();

Về cơ bản, ứng dụng của chúng ta hoạt động bình thường trên trình duyệt, nhưng hãy phân tích 1 chút:

  • Class Content đang gắn rất chặt với Class FactoryView và Class UsersModel.
  • Khi muốn thay đổi FactoryView, tôi muốn nội dung hiển thị cầu kỳ hơn tôi bắt buộc phải thay đổi Content.
  • Điều tương tự khi tôi muốn thay đổi UsersModel bằng 1 lớp tương tác với CSDL thật không phải Mock như hiện tại.

Nguyên nhân của những điểm này là do tôi đã thực hiện tạo các instance của dependencies 1 cách thủ công bằng từ khoá new trong Content, và để giải quyết DI Container sẽ phải giúp tôi thực hiện quá trình tạo Content Instance 1 cách tự động và đưa vào nó các dependencies dựa vào 1 bản đăng ký.

Tôi tạm gọi đây là 1 interface

class DIContainer {
    //Lấy ra instance từ DIContainer
    public static function resolve($className, $arguments = null);
    //Thực hiện đăng ký Value / Class / Singleton Class với DIContainer
    public static function bindValue($key, $value);
    public static function bindClass($key, $value, $arguments = null);
    public static function bindClassAsSingleton($key, $value, $arguments = null);
}

Sau khi triển khai DIContainer của chúng ta như sau, tôi sẽ comment từng chức năng trong code

class DIContainer {
    //Lưu trữ các Dependencies đã được đăng ký
    private static $map;
    
    //Đưa vào biến lưu trữ
    private static function addToMap($key, $obj) {
        if(self::$map === null) {
            self::$map = (object) array();
        }
        self::$map->$key = $obj;
    }
    
    //Đơn giản là thay thế 1 key bằng 1 giá trị
    public static function bindValue($key, $value) {
        self::addToMap($key, (object) array(
            "value" => $value,
            "type" => "value"
        ));
    }
    
    //Thực hiện lưu vào bộ đăng ký thay thế key bằng 1 Class
    public static function bindClass($key, $value, $arguments = null) {
        self::addToMap($key, (object) array(
            "value" => $value,
            "type" => "class",
            "arguments" => $arguments
        ));
    }
    
    //Thực hiện lưu vào bộ đăng ký thay thế key bằng 1 Class, nhưng là 1 Singleton, cho dù resolve bao nhiêu lần, vẫn chỉ gọi ra 1 instance duy nhất
    public static function mapClassAsSingleton($key, $value, $arguments = null) {
        self::addToMap($key, (object) array(
            "value" => $value,
            "type" => "classSingleton",
            "instance" => null,
            "arguments" => $arguments
        ));
    }
}

Cuối cùng ta có method quan trọng nhất của DIContainer, nó sẽ phải có nhiệm vụ tạo ra 1 thể hiện mới từ những gì đã được lưu trong bộ đăng ký ở trên cũng như dựa vào Class thật. Để tìm hiểu Class thật tôi sẽ sử dụng Reflection Class để đọc nội dung Class.

Ở đây note 1 chút là tôi sẽ sử dụng annotation để quy định 1 Class cần dùng những dependencies nào để DIContainer của chúng ta có thể hiểu được và thực hiện Inject.

    public static function resolve($className, $arguments = null) {
        // Kiểm tra sự tồn tại của Class
        if(!class_exists($className)) {
            throw new Exception("DIContainer: missing class '".$className."'.");
        }
        
        // Khởi tạo Reflection của Class thật
        $reflection = new ReflectionClass($className);
        
        // Tạo ra 1 thể hiện của Class thật, class này sẽ chưa được inject dependencies vào. 
        if($arguments === null || count($arguments) == 0) {
           $obj = new $className;
        } else {
            if(!is_array($arguments)) {
                $arguments = array($arguments);
            }
           $obj = $reflection->newInstanceArgs($arguments);
        }
        
        // Thực hiện Inject
        //Đầu tiên đọc các Annotation để tạo ra các public property, đây sẽ là nơi lưu giữ các Dependency, bình thường Laravel sẽ đọc trong Agurment của Constructor.
        if($doc = $reflection->getDocComment()) {
            $lines = explode("\n", $doc);
            
            // Ta đọc từng dòng của annotation
            foreach($lines as $line) {
                if(count($parts = explode("@Inject", $line)) > 1) {
                    $parts = explode(" ", $parts[1]);
                    if(count($parts) > 1) {
                        $key = $parts[1];
                        $key = str_replace("\n", "", $key);
                        $key = str_replace("\r", "", $key);
                        
                        //Kiểm tra nếu tồn tại key trong bản đăng ký ($map) thì thực hiện inject vào Class
                        if(isset(self::$map->$key)) {
                            switch(self::$map->$key->type) {
                                case "value":
                                    $obj->$key = self::$map->$key->value;
                                break;
                                case "class":
                                    $obj->$key = self::resolve(self::$map->$key->value, self::$map->$key->arguments);
                                break;
                                case "classSingleton":
                                    if(self::$map->$key->instance === null) {
                                        $obj->$key = self::$map->$key->instance = self::resolve(self::$map->$key->value, self::$map->$key->arguments);
                                    } else {
                                        $obj->$key = self::$map->$key->instance;
                                    }
                                break;
                            }
                        }
                    }
                }
            }
        }
        
        // Trả về instance sau khi đã được inject các dependencies.
        return $obj;
    }

Cuối cùng bạn cần sửa lại các Class đang sử dụng Dependencies 1 cách thủ công, xoá hết những đoan code đó đi, chúng ta sẽ đăng ký nó với DIContainer thông qua bộ đăng ký và các annotation

//Báo cho DIContainer biết bạn cần Dependencies nào trong class này
/**
* @Inject view
*/
class Navigation {
    public function show() {
        $this->view->show('
            <a href="#" title="Home">Home</a> | 
            <a href="#" title="Home">Danh Muc</a> | 
            <a href="#" title="Home">Lien He</a>
        ');
    }
}
/**
* @Inject usersModel
* @Inject view
*/
class Content {
    private $title;
    
    public function __construct($title) {
        $this->title = $title;
    }

    public function show() {  
        $users = $this->usersModel->get();
        $this->view->show($this->title);
        foreach($users as $user) {
            $this->view->show($user->ho." ".$user->ten);
        }
    }
}
/**
* @Inject navigation
* @Inject content
*/
class PageController {
    public function show() {
        $this->navigation->show();
        $this->content->show();
    }
}
//Thực hiện đăng ký với DIContainer
DIContainer::bindClass("navigation", "Navigation");
DIContainer::bindClass("content", "Content", array("Danh sách Người Dùng"));
DIContainer::bindClass("view", "FactoryView");
DIContainer::bindClassAsSingleton("usersModel", "UsersModel");
Không tạo ra instance thông qua từ khoá new nữa, ta sẽ lấy instance của Controller từ DIContainer
$page = DIContainer::resolve("PageController");
$page->show();

Bây giờ refresh trình duyệt bạn sẽ thấy kết quả như ban đầu, tức là bạn đã làm đúng. Code bây giờ đã thực sự rất decoupling hay rất lỏng lẻo, không còn 1 thành phần nào yêu nhau không thể tách rời nữa =)) Ứng dụng trong Unit Test thì có thể thấy là dễ dàng thay thế UsersModel hoặc Content bằng 1 Dummy Object (điều dễ dàng hơn nhiều so với Mock Object) nhỉ.

Cuối cùng thì file minh hoạ mình gắn kèm ở đây cho bạn nào ngại code.Click here

Conclusion

Vậy là chúng ta đã cùng nhau xây dựng được 1 Dependencies Injection Container của riêng mình. Có thể thấy là mặc dù hiệu quả rất lớn, khái niệm thì loằng ngoằng nhưng bắt tay vào làm thử thì cũng không khó lắm nhỉ (đừng thấy dễ mà coi thường =)) ). Thực sự thì các Framework cũng làm như vậy thôi, tất nhiên họ có thêm nhiều feature râu ria đi kèm, nhưng về cơ bản cách giải quyết vấn đề thì là đọc Class bằng Reflection rồi Tạo ra các property như cách chúng ta đã làm thôi. Cảm ơn các bạn đã đọc, hẹn gặp lại vào tháng sau nhé. #save2dayswage