4

I'm developing an application in which I have some handlers as services I want to be able to invoke. Both of them implements an ItemHandlerInterface.

I would like to be able in a controller to retrieve all the ItemHandlerInterface services collection, without wiring them manually.

So far I tagged them specifically:

services.yaml

_instanceof:
    App\Model\ItemHandlerInterface:
        tags: [!php/const App\DependencyInjection\ItemHandlersCompilerPass::ITEM_HANDLER_TAG]
        lazy: true

And try to retrieve my service collection in a controller. It works if only one service implements ItemHandlerInterface, but as soon as I create several ones (like below TestHandler and Test2Handler, I end up with a The service "service_locator.03wqafw.App\Controller\ItemUpdateController" has a dependency on a non-existent service "App\Model\ItemHandlerInterface".

How can I retrieve dynamically all services implementing my interface?

One dirty solution would be to force all ItemHandlerInterface with public: true and pass Container to my controller constructor. But this is ugly and I would like to find a more elegant way.

ItemUpdateController

namespace App\Controller;

use App\Model\ItemHandlerInterface;
use App\Service\ItemFinder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Debug\Exception\ClassNotFoundException;
use Symfony\Component\DependencyInjection\ServiceSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use App\Model\Item;
use Psr\Container\ContainerInterface;

/**
 * Class ItemUpdateController
 *
 * @package App\Controller
 */
class ItemUpdateController extends AbstractController
{
    /**
     * @var ContainerInterface
     */
    protected $locator;

    public function __construct(ContainerInterface $locator)
    {
        $this->locator = $locator;
    }

    public static function getSubscribedServices()
    {
        // Try to subscribe to all ItemHandlerInterface services
        return array_merge(
                parent::getSubscribedServices(),
                ['item_handler' => ItemHandlerInterface::class]
        );
    }

    /**
     * @param string $id
     * @param RequestStack $requestStack
     * @param ItemFinder $itemFinder
     *
     * @return Item
     * @throws \Symfony\Component\Debug\Exception\ClassNotFoundException
     */
    public function __invoke(
        string $id,
        RequestStack $requestStack,
        ItemFinder $itemFinder
    ) {
        // Find item
        $item = $itemFinder->findById($id);

        // Extract and create handler instance
        $handlerName = $item->getHandlerName();

        if($this->locator->has($handlerName)) {

            $handler = $this->locator->get($handlerName);
            $request = $requestStack->getCurrentRequest();
            $payload = json_decode($request->getContent());

            call_user_func($handler, $payload, $request);

            return $item;
        }
    }
}

src/ItemHandler/TestHandler.php

namespace App\ItemHandler;

use App\Model\ItemHandlerInterface;
use Doctrine\ORM\EntityManagerInterface;

class TestHandler implements ItemHandlerInterface
{
// implementation
}

src/ItemHandler/Test2Handler.php

namespace App\ItemHandler;

use App\Model\ItemHandlerInterface;
use Doctrine\ORM\EntityManagerInterface;

class Test2Handler implements ItemHandlerInterface
{
// implementation
}
yivi
  • 42,438
  • 18
  • 116
  • 138
nbonniot
  • 1,034
  • 16
  • 33
  • Not sure how this would work without some sort of ItemHandlers locator class. How would Symfony know which locator to inject into your controller? Maybe try following the answer here and see what happens: https://stackoverflow.com/questions/54946647/symfony-get-service-via-class-name-from-iterable-injected-tagged-services/54949631#54949631 – Cerad Sep 19 '19 at 14:17
  • You want to inject all implementations of `ItemHandlerInterface` in your controller, is that it? But you say "without wiring them manually"... so you do not want to add code to services.yaml, for example? Or would that be fine? – yivi Sep 19 '19 at 14:17
  • @yivi that's exactly what I meant, at least not one by one. The idea is to ship the application and let developers add ItemHandlerInterface implementation only and let the app choose at runtime which one is concerned regarding the parameters given on controller invocation. – nbonniot Sep 19 '19 at 14:22

3 Answers3

6

You can inject all tagged services in one swoop, without having to use a compiler pass.

Configuration

Since you are already doing the tagging, as shown in the question, it's in only a matter of declaring the injection:

_instanceof:
    App\Model\ItemHandlerInterface:
        tags: ['item_handler']
        lazy: true

services:
    App\Controller\ItemUpdateController:
        arguments: !tagged 'item_handler'

Implementation

You would need to change the constructor for your controller so it accepts an iterable:

public function __construct(iterable $itemHandlers)
{
    $this->handlers = $itemHandlers;
}

In your class a RewindableGenerator will be injected with your services inside. You can simply iterate over it to get each of those.

This has been available since 3.4; and it is still supported.


Extra

Since 4.3 you can use a tagged service locator for this. The configuration is equally simple, but you get the advantage of being able to instantiate the services lazily, instead of having to instantiate all of them to begin with.

You can read more here.

yivi
  • 42,438
  • 18
  • 116
  • 138
  • I just mention that PHP constant usage after the !tagged seems not working on my side. I remove it for now. – nbonniot Sep 19 '19 at 14:42
  • @yivi You left out a call to iterator_to_array() which is needed to treat the handlers as an array. I had to change the typehint for handlers from iterable to Traversable to keep the IDE happy. You actually end up with a Symfony RewindableGenerator for $handlers which does not allow array access. It is all quite confusing. And of course using the iterator_to_array function instantiates all the individual handlers which is a bit sad. – Cerad Sep 19 '19 at 16:38
  • Plus, I was not able to get the static method key stuff to work. I had to explicitly declare a key for each handler in the services file and then use the index_by attribute. – Cerad Sep 19 '19 at 16:42
  • You are right, @Cerad. Not only I forgot to copy the array_to_iterator, but this doesn't seem to work exactly as intended. It does work for `tagged_locator`, though, so I added an answer in the question you linked. – yivi Sep 19 '19 at 18:10
2

As I was typing this I just saw an answer get accepted. Fair enough. In any event, this works and I'll just keep it as a reference for now:

services:
   _instanceof:
        # Tag all your item handlers
        App\Model\ItemHandlerInterface:
            tags: [app.item_handler]

    # inject as an iterable into the controller
    App\Controller\IndexController:
        arguments: [!tagged app.item_handler]

Same reference as the accepted answer: https://symfony.com/blog/new-in-symfony-3-4-simpler-injection-of-tagged-services

I'd also like to point out that this approach only supports iterable. If you want to randomly access a particular item handler (perhaps via a class name) without instantiating the rest then you need to make your own locator class which takes a bit more effort.

Cerad
  • 48,157
  • 8
  • 90
  • 92
0

A good way to do this would be to use a CompilerPass to gather all the tagged services and inject the result as an argument of your Controller.
From there, you have access to all the methods you need to find your services thanks to the ContainerBuilder class (using findTaggedServiceIds for example)

Sylius uses this trick a lot internally, and even has a pre-made compiler pass which does that (so you can check how it's done internally) in an abstract way.
To use it we just have to create a new one, extend this one, and call the parent __construct() with the right parameters. (an example here)

Check it there :

https://github.com/diimpp/Sylius/blob/master/src/Sylius/Bundle/ResourceBundle/DependencyInjection/Compiler/PrioritizedCompositeServicePass.php

evanesis
  • 260
  • 1
  • 9