phpsymfonydependency-injectionsymfony4symfony-dependency-injection

How to use AutoWiring when looping through Subclasses?


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:

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.


Solution

  • 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.