thephpleague / route

Fast PSR-7 based routing and dispatch component including PSR-15 middleware, built on top of FastRoute.
http://route.thephpleague.com
MIT License
651 stars 126 forks source link

Method Container Based Dependency Injection / Auto-Wiring #252

Closed TCB13 closed 5 years ago

TCB13 commented 5 years ago

Hello, I'm trying the router and it really is great, however I've a question about methods and dependency injection. I've created a Controller called Single.php as follows:

<?php
namespace Controller;
use Psr\Http\Message\ServerRequestInterface;

class Single 
{
    public function jsonArrayParameter(ServerRequestInterface $request, array $args): array
    {
        return ["args" => $args];
    }

}

And I can have a working router like:

<?php
include "vendor/autoload.php";

use Controller\Single;
use League\Route\Router;
use Zend\Diactoros\{ResponseFactory, ServerRequestFactory};
use Zend\HttpHandlerRunner\Emitter\SapiEmitter;

$request = ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES);
$router = new Router();

// json routes
$jsonStrategy = new League\Route\Strategy\JsonStrategy(new ResponseFactory());
$router->map('GET', '/jsonparam/{id}', [Single::class, "jsonArrayParameter"])->setStrategy($jsonStrategy);

// router dispatch & emit
$response = $router->dispatch($request);
(new SapiEmitter())->emit($response);

I can also add the league container and have it resolve dependencies on my controller. For instance if Single now is:

class Single 
{
    public $test;

    public function __construct(\Test $test)
    {
        $this->test = $test;
    }
(...)

I can make sure the router injects an instance of Test in the constructor in this way:

<?php

include "vendor/autoload.php";

use Controller\Single;
use League\Route\Router;
use Zend\Diactoros\{ResponseFactory, ServerRequestFactory};
use Zend\HttpHandlerRunner\Emitter\SapiEmitter;

// dependency injection
$container = new League\Container\Container;
$container->defaultToShared();
$container->delegate(
    (new League\Container\ReflectionContainer)->cacheResolutions()
);
$container->share(\Test::class, function () {
    return new Test();
});
// dependency injection

$request = ServerRequestFactory::fromGlobals($_SERVER, $_GET, $_POST, $_COOKIE, $_FILES);
$router = new Router();

// json routes
$jsonStrategy = new League\Route\Strategy\JsonStrategy(new ResponseFactory());
$jsonStrategy->setContainer($container);
$router->map('GET', '/jsonparam/{id}', [Single::class, "jsonArrayParameter"])->setStrategy($jsonStrategy);

// router dispatch & emit
$response = $router->dispatch($request);
(new SapiEmitter())->emit($response);

Now, I would like to be able to inject dependencies in my controller methods, example at Single add a AnotherDependency $dependency parameter to the jsonArrayParameter method:

public function jsonArrayParameter(ServerRequestInterface $request, array $args, AnotherDependency $dependency): array

After reading the documentation I managed to do this by created a custom strategy that extends JsonStrategy replacing the invokeRouteCallable method like this:

class JsonStrategyMethodDI extends JsonStrategy
{

    public function invokeRouteCallable(Route $route, ServerRequestInterface $request): ResponseInterface
    {

        $controller = $route->getCallable($this->getContainer());

        // Resolve method dependencies
        $reflectionMethod = new \ReflectionMethod($controller[0], $controller[1]);
        $params           = [
            "request" => $request,
            "args"    => $route->getVars()
        ];
        foreach (array_slice($reflectionMethod->getParameters(), 2) as $param) {
            $params[$param->getName()] = $this->container->get($param->getClass()->name);
        }

        // Call the method with the request, args and injected dependencies
        $response = call_user_func_array($controller, $params);
        //$response = $controller($request, $route->getVars());

        if ($this->isJsonEncodable($response)) {
            $body = json_encode($response);
            $response = $this->responseFactory->createResponse();
            $response->getBody()->write($body);
        }

        $response = $this->applyDefaultResponseHeaders($response);

        return $response;
    }

}

This solution works, however, is there a better way to do it? Can I use the container directly for this without modifying JsonStrategy or other strategies? Should I even do it at all?

Thank you.

philipobenito commented 5 years ago

I used to have something similar to this built in as one of the strategies but removed it for several reasons. Partly because reflection is slow.

To answer your question properly though, I'd ask for what reason you want to do this over constructor injection? If I understand your goal I may be able to point to a better solution.

TCB13 commented 5 years ago

@philipobenito I want this because most routes require different and specific dependencies. My custom strategy basically allows me to have those dependencies automatically injected in the methods / avoid having to resolve them manually from the container.

And then I've other cases where I do inject "shared" dependencies on the controller as you said.

philipobenito commented 5 years ago

Okay, so, if you wanna do that, the custom strategy is the way to go. However, I'd suggest a better option might be to split up your controllers more, better separation of concerns that way too.

PhantomArt commented 5 years ago

What if to cache a router with already resolved types of parameters of controller methods? In any case, if the same parameters are passed to the constructor, they still need to be resolved. If I understand correctly, it takes exactly the same amount of time.

I want to pass dependencies to methods like Laravel.

TCB13 commented 5 years ago

cache a router with already resolved types of parameters of controller methods

How do you go about that?

My way to go about this (poorly described before to @philipobenito) is to pass generic stuff like a logger to the controller's constructor and some specific stuff to the route, for example, a route to create new Posts will have a new instance of the Post class injected to its method. And yes, this might be a dumb example becuse I could easy do $post = new Post;but it explains my point.