8

I am struggling to get a specific service via class name from group of injected tagged services.

Here is an example: I tag all the services that implement DriverInterface as app.driver and bind it to the $drivers variable.

In some other service I need to get all those drivers that are tagged app.driver and instantiate and use only few of them. But what drivers will be needed is dynamic.

services.yml

_defaults:
        autowire: true
        autoconfigure: true
        public: false
        bind:
            $drivers: [!tagged app.driver]

_instanceof:
        DriverInterface:
            tags: ['app.driver']

Some other service:

/**
 * @var iterable
 */
private $drivers;

/**
 * @param iterable $drivers
 */
public function __construct(iterable $drivers) 
{
    $this->drivers = $drivers;
}

public function getDriverByClassName(string $className): DriverInterface
{
    ????????
}

So services that implements DriverInterface are injected to $this->drivers param as iterable result. I can only foreach through them, but then all services will be instantiated.

Is there some other way to inject those services to get a specific service via class name from them without instantiating others?

I know there is a possibility to make those drivers public and use container instead, but I would like to avoid injecting container into services if it's possible to do it some other way.

yivi
  • 42,438
  • 18
  • 116
  • 138
povs
  • 93
  • 2
  • 6
  • 2
    Make each driver a lazy service. – Mike Doe Mar 01 '19 at 14:33
  • There is an upcoming feature that will help with this, called indexed services: https://symfony.com/blog/new-in-symfony-4-3-indexed-and-tagged-service-collections Unfortunately I think right now either have to create a CompilerPass if you want to solve this programmatically or add mutliple tags, e.g. based on the folder the services are stored in or by manually tagging each service. – dbrumann Mar 01 '19 at 14:56

2 Answers2

16

You no longer (since Symfony 4) need to create a compiler pass to configure a service locator.

It's possible to do everything through configuration and let Symfony perform the "magic".

You can make do with the following additions to your configuration:

services:
  _instanceof:
    DriverInterface:
      tags: ['app.driver']
      lazy: true

  DriverConsumer:
    arguments:
      - !tagged_locator
        tag: 'app.driver'

The service that needs to access these instead of receiving an iterable, receives the ServiceLocatorInterface:

class DriverConsumer
{
    private $drivers;
    
    public function __construct(ServiceLocatorInterface $locator) 
    {
        $this->locator = $locator;
    }
    
    public function foo() {
        $driver = $this->locator->get(Driver::class);
        // where Driver is a concrete implementation of DriverInterface
    }
}

And that's it. You do not need anything else, it just workstm.


Complete example

A full example with all the classes involved.

We have:

FooInterface:

interface FooInterface
{
    public function whoAmI(): string;
}

AbstractFoo

To ease implementation, an abstract class which we'll extend in our concrete services:

abstract class AbstractFoo implements FooInterface
{
    public function whoAmI(): string {
        return get_class($this);
    }   
}

Services implementations

A couple of services that implement FooInterface

class FooOneService extends AbstractFoo { }
class FooTwoService extends AbstractFoo { }

Services' consumer

And another service that requires a service locator to use these two we just defined:

class Bar
{
    /**
     * @var \Symfony\Component\DependencyInjection\ServiceLocator
     */
    private $service_locator;

    public function __construct(ServiceLocator $service_locator) {
        $this->service_locator = $service_locator;
    }

    public function handle(): string {
        /** @var \App\Test\FooInterface $service */
        $service = $this->service_locator->get(FooOneService::class);

        return $service->whoAmI();
    }
}

Configuration

The only configuration needed would be this:

services:
  _instanceof:
    App\Test\FooInterface:
      tags: ['test_foo_tag']
      lazy: true
    
  App\Test\Bar:
      arguments:
        - !tagged_locator
          tag: 'test_foo_tag'
            

Alternative to FQCN for service names

If instead of using the class name you want to define your own service names, you can use a static method to define the service name. The configuration would change to:

App\Test\Bar:
        arguments:
          - !tagged_locator
            tag: 'test_foo_tag'
            default_index_method: 'fooIndex'

where fooIndex is a public static method defined on each of the services that returns a string. Caution: if you use this method, you won't be able to get the services by their class names.

Community
  • 1
  • 1
yivi
  • 42,438
  • 18
  • 116
  • 138
  • 1
    This is an elegant solution, but should be noted this solution only works in Symfony >=4. For Symfony 3 you still have to use a compiler pass – Sean Feb 14 '20 at 17:37
  • 1
    You are right. The “no longer need” implied that, but I should make it explicit. Symfony <4 installations are going el be less and less every day, but it’s worth mentioning. – yivi Feb 14 '20 at 17:39
  • 1
    I'm working on a legacy (3.4) codebase and actually trying to do just this, got halfway through your solution before I realized this was a 4+ feature. Sad face. – Sean Feb 14 '20 at 17:47
5

A ServiceLocator will allow accessing a service by name without instantiating the rest of them. It does take a compiler pass but it's not too hard to setup.

use Symfony\Component\DependencyInjection\ServiceLocator;
class DriverLocator extends ServiceLocator
{
    // Leave empty
}
# Some Service
public function __construct(DriverLocator $driverLocator) 
{
    $this->driverLocator = $driverLocator;
}

public function getDriverByClassName(string $className): DriverInterface
{
    return $this->driverLocator->get($fullyQualifiedClassName);
}

Now comes the magic:

# src/Kernel.php
# Make your kernel a compiler pass
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
class Kernel extends BaseKernel implements CompilerPassInterface {
...
# Dynamically add all drivers to the locator using a compiler pass
public function process(ContainerBuilder $container)
{
    $driverIds = [];
    foreach ($container->findTaggedServiceIds('app.driver') as $id => $tags) {
        $driverIds[$id] = new Reference($id);
    }
    $driverLocator = $container->getDefinition(DriverLocator::class);
    $driverLocator->setArguments([$driverIds]);
}

And presto. It should work assuming you fix any syntax errors or typos I may have introduced.

And for extra credit, you can auto register your driver classes and get rid of that instanceof entry in your services file.

# Kernel.php
protected function build(ContainerBuilder $container)
{
    $container->registerForAutoconfiguration(DriverInterface::class)
        ->addTag('app.driver');
}
Cerad
  • 48,157
  • 8
  • 90
  • 92