5

I have a Sumfony 4.3 command that processes some data and loops through a number of "processors" to do the processing. The code uses a factory (autowired) which then instantiates the command.

use App\Entity\ImportedFile;
use App\Service\Processor\Processor;

class Factory implements FactoryInterface
{
    /** @var  array */
    private $processors;

    /** @var TestClausesInterface  */
    private $testClauses;

    private $em;
    private $dataSetProvider;
    private $ndviFromNasaService;
    private $archivalHashService;
    private $mailer;
    private $projectDir;

    public function __construct(
        TestClausesInterface $testClauses,
        ValidProcessorList $processors,
        EntityManagerInterface $em,
        DataSetProvider $dataSetProvider,
        NDVIFromNasaService $ndviFromNasaService,
        ArchivalHashService $archivalHashService,
        \Swift_Mailer $mailer,
        $projectDir)
    {
        $this->processors = $processors;
        $this->testClauses = $testClauses;
        $this->em = $em;
        $this->dataSetProvider = $dataSetProvider;
        $this->ndviFromNasaService = $ndviFromNasaService;
        $this->archivalHashService = $archivalHashService;
        $this->mailer = $mailer;
        $this->projectDir = $projectDir;
    }

    public function findProcessorForFile(ImportedFile $file)
    {
        ...

        if ($found){
            $candidates = $this->recursive_scan( $this->projectDir.'/src/Processor');
            foreach ($candidates as $candidate){
                if (substr($candidate,0,strlen('Helper')) === 'Helper'){
                    continue;
                }
                try {
                    $candidate = str_replace($this->projectDir.'/src/Processor/', '', $candidate);
                    $candidate = str_replace('/','\\', $candidate);
                    $testClassName = '\\App\\Processor\\'.substr( $candidate, 0, -4 );
                    /* @var Processor $test */
                    if (!strstr($candidate, 'Helper')) {
                        $test = new $testClassName($this->testClauses, $this->em, $this->dataSetProvider, $this->ndviFromNasaService, $this->archivalHashService, $this->mailer, $this->projectDir);
                    }

However I still have to:

  • autowire all arguments both in the Factory and Processor top class
  • pass all arguments in correct order to the Processor

I have around 70 subclasses of Processor. All of them use EntityInterface, but only a couple use SwiftMailer and the other dependencies.

As I am adding services to be used only by a few Processors, I am looking for a way to autowire these arguments only at the Processor level. Ideally, also without adding service definitions to services.yml

In summary, I would like to be able to add a dependency to any subclass of Processor, even if it is a parent class of other subclasses and have the dependency automatically injected.

yivi
  • 42,438
  • 18
  • 116
  • 138
jdog
  • 2,465
  • 6
  • 40
  • 74
  • Not positive but maybe a ProcessServiceLocator instead of a process factory. Basically the process locator would contain a list of available process services. https://symfony.com/doc/current/service_container/service_subscribers_locators.html#defining-a-service-locator – Cerad Dec 12 '18 at 03:45
  • Symfony auto wiring injects whatever I put as parameters into the constructor. I want to achieve the same here. When I add a parameter to the constructor of a Processor class, I want this Service injected, otherwise not. I would then inject Entity Manager from the Factory still, but that is not relevant to the solution – jdog Jan 03 '20 at 07:57
  • Nearly there I think. Wiring up the service locator seems to work. I have an indirection with file prefixes in a database table determining which processor handles a file, so I am using strval($type->getId()) to match with public static function getDefaultIndexName(). However the service locator does not find the processor and when I debug, I cannot see how my getDefaultIndexName() return values are indexed anywhere – jdog Jan 07 '20 at 20:47

1 Answers1

3

There is much it is not immediately obvious in your code, but the typical way to resolve this is by using a "service locator". Docs.

Let's imagine you have several services implementing the interface Processor:

The interface:

interface Processor {
    public function process($file): void;
}

Couple implementation:

class Foo implements Processor
{
    public function __construct(DataSetProvider $dataSet, ArchivalHashService $archivalHash, \Swift_Mailer $swift) {
        // initialize properties
    }

    public function process($file) {
        // process implementation
    }

    public static function getDefaultIndexName(): string
    {
        return 'candidateFileOne';
    }
}

Couple implementations:

class Bar implements Processor
{
    public function __construct(\Swift_Mailer $swift, EntityManagerInterface $em) {
        // initialize properties
    }

    public function process($file) {
        // process implementation
    }

    public static function getDefaultIndexName(): string
    {
        return 'candidateFileTwo';
    }
}

Note that each of the processors have completely different dependencies, and can be auto-wired directly, and that each of them has a getDefaultIndexName() method.

Now we'll "tag" all services implementing the Processor interface:

# services.yaml
services:
    # somewhere below the _defaults and the part where you make all classes in `src` available as services
    _instanceof:
        App\Processor:
            tags:
                - { name: "processor_services", default_index_method: 'getDefaultIndexName' }

Attention here: The documentation says that if you define a public static function getDefaultIndexName() it will be picked by default. But I've found this not to be working at the moment. But if you define the default_index_method you can wire it to a method of your choice. I'm keeping the getDefaultIndexName for the time being, but you can pick something of your own choice.

Now, if you need this processes in a console command, for example:

use Symfony\Component\DependencyInjection\ServiceLocator;

class MyConsoleCommand
{
    private ServiceLocator $locator;

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

}

To inject the service locator you would do:

#services.yaml

services:
    App\HandlerCollection:
        arguments: [!tagged_locator { tag: 'processor_services' } ]

And to fetch any of the processors from the service locator you would do:

$fooProcessor = $this->locator->get('candidateFileOne');
$barProcessor = $this->locator->get('candidateFileTwo');

Summping up, basically what you need is:

  1. Define a shared interface for the processors
  2. Use that interface to tag all the processor services
  3. Define a getDefaultIndexName() for each processor, which helps you match files to processors.
  4. Inject a tagged service locator in the class that need to consume this services

And you can leave all services auto-wired.

Note: You could use an abstract class instead of an interface, and it would work the same way. I prefer using an interface, but that's up to you.

For completion sake, here is a repo with the above working for Symfony 4.3.

yivi
  • 42,438
  • 18
  • 116
  • 138
  • no it doesn't work. I can only get the processor by its fully qualified class name. HAve also updated composer to latest 4.3 – jdog Jan 08 '20 at 21:22
  • Have also tried to put getDefaultIndexName without quotes as per here: https://olvlvl.com/2019-09-symfony-tagged-service-locator – jdog Jan 08 '20 at 21:40
  • There must be another type of error in your code. I tested the above on Sf 4.3 and Sf 5. It works. I've setup [a repo](https://gitlab.com/yivi/service-locator-demo-sf-4.3) where you can see it in action. Just clone it, do `composer install`, and run `bin/console foobar` and it will work. There are only a couple of classes, so there is not much mystery to it. – yivi Jan 09 '20 at 07:05
  • Appendix: the method specified in getDefaultIndexName needs to return a string and it seems strval(number) is not enough. If the key returned is not a string, tagged services will be indexed by order found or full class name and may not match correctly, or the call to serviceLocator->get will return an error – jdog Feb 03 '20 at 03:25
  • You are also a hitting PHP limitation. Using numeric strings as keys in associative arrays is not safe nor wise. E.g. see [this](https://3v4l.org/TA9aD). – yivi Feb 03 '20 at 08:22