+10

Dependency Injection & PHP Reflection in Laravel

Dependency Injection

Những ai đã và đang sử dụng Laravel đều biết rằng Service Container là một trong những tính năng mạnh mẽ nhất của Laravel, ngay trên trang chủ họ cũng đã dành hẳn một chương để hướng dẫn cách sử dụng.

A deep understanding of the Laravel service container is essential to building a powerful, large application, as well as for contributing to the Laravel core itself.

Riêng câu này đã cho chúng ta thấy được tầm quan trọng của việc hiểu rõ Laravel service container. Tuy nhiên về service container thì trên viblo cũng có nhiều bài viết, trên trang chủ cũng đã có, nên trong bài viết này mình muốn đi theo một hướng khác, đó là tìm hiểu xem liệu thứ gì đã tạo nên sức mạnh đó. Theo lệ thì trước khi tìm hiểu thì chúng ta cũng nên biết một số kiến thức cơ bản, và để mọi người đỡ mất công tìm đọc (nếu lỡ có quên) thì mình xin trình bày lại định nghĩa về Dependency Injection cũng như Service Container. Hãy cùng xem một ví dụ về việc khởi tạo đối tượng theo 2 cách: dùng new và dùng dependency injection

  • Dùng new
class GoogleMaps
{
    public function getCoordinatesFromAddress($address) {
        // calls Google Maps webservice
    }
}

class OpenStreetMap
{
    public function getCoordinatesFromAddress($address) {
        // calls OpenStreetMap webservice
    }
}

class StoreService
{
    public function getStoreCoordinates($store) {
        $geolocationService = new GoogleMaps();

        return $geolocationService->getCoordinatesFromAddress($store->getAddress());
    }
}

Cái nhìn đầu tiên là đoạn code trông khá mượt, tuy nhiên nhìn lại nguyên tắc S.O.L.I.D ta có thể thấy đoạn code này có nhiều vấn đề:

  • Thứ nhất là nếu chúng ta muốn dùng OpenStreetMap thay vì GoogleMaps thì sao, rõ ràng chúng ta phải chỉnh sửa lại class StoreService và những class khác sử dụng GoogleMaps
  • Thứ hai là viết unit test. Chẳng hạn ta muốn test hàm getStoreCoordinates, điều đầu tiên nghĩ đến là mock object GoogleMaps, giả sử nó có tên là MockGoogleMaps đi, sau đó ta sẽ "inject" nó vào hàm getStoreCoordinates và ..., chợt nhìn lại dòng đầu tiên
    public function getStoreCoordinates($store) {
        $geolocationService = new GoogleMaps();
        ...

hay dễ hình dung hơn

    public function getStoreCoordinates(GoogleMaps $googleMaps, $store) {
        $geolocationService = $googleMaps;
        $geolocationService = new GoogleMaps();
        ...

Như bạn thấy, việc cấp phát thể hiện của class GoogleMaps qua từ khóa new trong hàm getStoreCoordinates khiến ta không thể inject mock object và thiết lập input/output cho hàm này như ta muốn, và dẫn đến rất khó để viết unit test (và có thể là không thể?). Không có dependency injection, class sẽ bị kết dính chặt với dependency Dùng dependency injection Giờ ta sẽ viết lại class StoreService sử dụng dependency injection

class StoreService {
    private $geolocationService;

    public function __construct(GeolocationService $geolocationService) {
        $this->geolocationService = $geolocationService;
    }

    public function getStoreCoordinates($store) {
        return $this->geolocationService->getCoordinatesFromAddress($store->getAddress());
    }
}

Và service sẽ được định nghĩa dưới dạng interface (giải quyết vấn đề thứ nhất):

interface GeolocationService {
    public function getCoordinatesFromAddress($address);
}

class GoogleMaps implements GeolocationService { ...

class OpenStreetMap implements GeolocationService { ...

Giờ thì người dùng StoreService sẽ toàn quyền quyết định họ muốn sử dụng GoogleMaps hay OpenStreetMaps mà không phải viết lại StoreService. Một cái hay nữa là nếu có thêm một service mới, chẳng hạn HereMaps, đơn giản là chỉ cần cài đặt thêm GeolocationService là có thể đem vào sử dụng. Ngon rồi, tuy nhiên lại nảy ính một vấn đề mới, đó cũng là một điểm yếu của dependency injection: bạn phải tự xử lý việc injdect dependency

$geolocationService = new GoogleMaps();
$storeService = new StoreService($geolocationService);

Sẽ ra sao nếu như class có nhiều dependency, và những dependency này lại có nhiều dependency khác? Đây chính là lúc mà sức mạnh của Service Container phát huy hiệu quả. Trong Laravel, bạn chỉ việc sử dụng bind để đăng ký service với service provider, và dùng make để gọi ra khi cần. Nhưng sức mạnh của Laravel service container là ở chỗ, bạn có thể "type-hint" dependency trong constructor của class, và Laravel sẽ tự động resolve và inject nó vào class cho bạn (how?):

namespace App\Http\Controllers;

use App\Users\Repository as UserRepository;

class UserController extends Controller
{
    protected $users;

    public function __construct(UserRepository $users)
    {
        $this->users = $users;
    }

    // ...
}

Sức mạnh và khả năng của Laravel service container thì không phải bàn nhiều, vấn đề mà mình vẫn luôn thắc mắc chính là cơ chế nào đằng sau tạo nên sức mạnh đó, và câu trả lời chính là Reflection (bộ công cụ thường được dùng cho metaprogramming). Ngay trên trang chủ tài liệu của Laravel có câu sau để nói về điều đó:

There is no need to bind classes into the container if they do not depend on any interfaces. The container does not need to be instructed on how to build these objects, since it can automatically resolve these objects using reflection.

Laravel Service Container & PHP Reflection

Nói qua thì reflection là khả năng để chương trình "inspect" chính nó, và show ra thuộc tính, phương thức hay là kiểu đối tượng. Vậy Laravel đã sử dụng Reflection như thế nào để tự động inject dependency chính xác như vậy? Câu trả lời là sử dụng ReflectionClass API của PHP. Đây là Laravel IoC container class Giờ chúng ta thử đọc code xem cách thức Laravel sử dụng như thế nào Class Container có 2 method quan trọng là bind()resolve(). Nhưng phần xử lý dependency lại nằm ở method build()

    public function build($concrete)
    {
        // If the concrete type is actually a Closure, we will just execute it and
        // hand back the results of the functions, which allows functions to be
        // used as resolvers for more fine-tuned resolution of these objects.
        if ($concrete instanceof Closure) {
            return $concrete($this, $this->getLastParameterOverride());
        }

        $reflector = new ReflectionClass($concrete);

        // If the type is not instantiable, the developer is attempting to resolve
        // an abstract type such as an Interface of Abstract Class and there is
        // no binding registered for the abstractions so we need to bail out.
        if (! $reflector->isInstantiable()) {
            return $this->notInstantiable($concrete);
        }

        $this->buildStack[] = $concrete;

        $constructor = $reflector->getConstructor();

        // If there are no constructors, that means there are no dependencies then
        // we can just resolve the instances of the objects right away, without
        // resolving any other types or dependencies out of these containers.
        if (is_null($constructor)) {
            array_pop($this->buildStack);

            return new $concrete;
        }

        $dependencies = $constructor->getParameters();

        // Once we have all the constructor's parameters we can create each of the
        // dependency instances and then use the reflection instances to make a
        // new instance of this class, injecting the created dependencies in.
        $instances = $this->resolveDependencies(
            $dependencies
        );

        array_pop($this->buildStack);

        return $reflector->newInstanceArgs($instances);
    }

Ta chú ý đến đoạn code này:

$reflector = new ReflectionClass($concrete);

nó cho phép ta tạo một thể hiện của class ReflectionClass. Đầu tiên nó sẽ check xem dependency này có khởi tạo được hay không, bằng một built-in function của Reflection:

if (! $reflector->isInstantiable()) {
return $this->notInstantiable($concrete);

hàm này đơn giản là throw exception:

protected function notInstantiable($concrete)
{
    if (! empty($this->buildStack)) {
        $previous = implode(', ', $this->buildStack);

        $message = "Target [$concrete] is not instantiable while building [$previous].";
    } else {
        $message = "Target [$concrete] is not instantiable.";
    }

    throw new BindingResolutionException($message);
}    

Tiếp tục, ở trên mình có nói một yếu điểm của dependency injection chính là việc khởi tạo depdency để inject sẽ rất phức tạp nếu dependency ấy lại phụ thuộc vào dependency khác. Và Laravel đã làm rất đẹp ở đây:

        $constructor = $reflector->getConstructor();

    // If there are no constructors, that means there are no dependencies then
    // we can just resolve the instances of the objects right away, without
    // resolving any other types or dependencies out of these containers.
    if (is_null($constructor)) {
        array_pop($this->buildStack);

        return new $concrete;
    }

Đầu tiên là check constructor, nếu constructor không có gì, tức là class này không phụ thuộc vào dependency nào khác, vậy thì dễ rồi, đơn giản là return new $concrete để tạo object mới. Còn nếu constructor có tham số thì sao:

        $dependencies = $constructor->getParameters();

    // Once we have all the constructor's parameters we can create each of the
    // dependency instances and then use the reflection instances to make a
    // new instance of this class, injecting the created dependencies in.
    $instances = $this->resolveDependencies(
        $dependencies
    );

Đầu tiên là lấy ra tham số. Dòng comment đã giải thích rõ, nếu constructor có tham số, ta có thể tạo instance cho những dependency này, và dùng reflection để tạo object của class này và inject những dependency instance vào. Sức mạnh của Laravel service container nằm ở đây, khi nó có thể resolve dependency cho đến khi nào không có nữa thì thôi.

protected function resolveDependencies(array $dependencies)
{
    $results = [];

    foreach ($dependencies as $dependency) {
        
        ...

        // If the class is null, it means the dependency is a string or some other
        // primitive type which we can not resolve since it is not a class and
        // we will just bomb out with an error since we have no-where to go.
        $results[] = is_null($dependency->getClass())
                        ? $this->resolvePrimitive($dependency)
                        : $this->resolveClass($dependency);
    }

    return $results;
}

getClass() là built-in function của Reflection, đơn giản là check xem dependency có phải class hay là một primitive rồi resolve tùy theo trường hợp. Tiếp tục lần theo đoạn code này:

protected function resolveClass(ReflectionParameter $parameter)
{
    try {
        return $this->make($parameter->getClass()->name);
    }
    ...
}

Hàm make là gì thì chắc mọi người biết rồi. Đây chính là điều mình nói ở trên, dùng đệ quy để resolve cho đến khi nào tạo được object mới hoặc là throw exception. Cuối cùng là tạo object cho những dependency:

return $reflector->newInstanceArgs($instances);

Có thể thấy cơ chế đằng sau sức mạnh của service container chỉ đơn giản là tận dụng khả năng "reverse-engineer" của PHP Reflection. Đấy là inject ở constructor, ngoài ra Laravel còn hỗ trợ dependency injection ở method, cơ chế nó cũng tương tự, thay vì dùng ReflectionClass thì dùng ReflectionMethod, ReflectionFunction, mọi người tự tìm hiểu nhé. Gợi ý nó ở file này BoundMethod

Thanks for reading.


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í