iBadGamer
iBadGamer

Reputation: 606

Symfony2: Choose a service depending on an object classname

I am building a quiz app with Symfony2. For this example, let's say I have two question Entities both extending a Question abstract class

I want to build services to check if a user response is correct or not. I will have TrueFalseQuestionChecker and a MultipleSelectQuestionChecker.

What is the better way to choose which one of the service to load ? In my controller I could do:

if($question instance of MultipleSelectQuestion::class){
    $checker = $this->get('multiple_select_question_checker');
} else if($question instance of TrueFalseQuestion::class){
    $checker = $this->get('true_false_question_checker');
}

$checker->verify($question);

But I find it very "ugly" to do this in my controller because I will have a list of like 10 question types and I will have to do this for a serializer service, for the answer checker service and maybe for other services. Is there a proper way to deal with an association between a service and an Entity.

I'm thinking of implementing my own annotation, something like that:

/**
* @MongoDB\Document
* @Question(serializer="AppBundle\Services\TrueFalseQuestionSerializer", 
* responseChecker="AppBundle\Services\TrueFalseQuestionChecker"
*/

public class TrueFalseQuestion extends Question 
{
...
}

Am I missing something already include in Symfony ? Or is my idea of doing one service by question type bad ?

Edit: Working solution thanks to @Tomasz Madeyski

src/AppBundle/Document/TrueFalseQuestion

/**
 * Class TrueFalseQuestion
 *
 * @MongoDB\Document(repositoryClass="AppBundle\Repository\TrueFalseQuestionRepository")
 * @QuestionServices(serializer="app.true_false_question_serializer")
 */
class TrueFalseQuestion extends Question
{ 
...

src/App/Bundle/Annotation/QuestionServices.php

<?php
namespace AppBundle\Annotation;

/**
 * @Annotation
 */
class QuestionServices
{
    private $checker;

    private $serializer;

    public function __construct($options)
    {
        foreach ($options as $key => $value) {
            if (!property_exists($this, $key)) {
                throw new \InvalidArgumentException(sprintf('Property "%s" does not exist', $key));
            }

            $this->$key = $value;
        }
    }

    public function getService($serviceName)
    {
        if (isset($this->$serviceName)) {
            return $this->$serviceName;
        }
        throw new \InvalidArgumentException(sprintf('Property "%s" does not exist', $serviceName));

    }

}

src/AppBundle/Services/QuestionServicesFactory.php

<?php

namespace AppBundle\Services;

use Doctrine\Common\Annotations\Reader;
use ReflectionClass;
use AppBundle\Annotation\QuestionServices;
use Symfony\Component\DependencyInjection\ContainerInterface;

class QuestionServicesFactory
{
    const SERVICE_SERIALIZER = 'serializer';
    const SERVICE_CHECKER = 'checker';

    public function __construct(Reader $reader, ContainerInterface $container)
    {
        $this->reader    = $reader;
        $this->container = $container;
    }

    /**
     * @param string $questionClass
     * @param        $serviceName
     *
     * @return object
     * @throws \Exception
     */
    public function getQuestionService($questionClass, $serviceName)
    {
        if (!class_exists($questionClass)) {
            throw new \Exception(sprintf("The class %s is not an existing class name", $questionClass));
        }

        $reflectionClass  = new ReflectionClass($questionClass);
        $classAnnotations = $this->reader->getClassAnnotations($reflectionClass);

        foreach ($classAnnotations as $annotation) {
            if ($annotation instanceof QuestionServices) {
                $serviceName = $annotation->getService($serviceName);

                return $this->container->get($serviceName);
            }
        }
        throw new \Exception(sprintf("Annotation QuestionServices does not exist in %s", $questionClass));
    }
}

service declaration

   <service id="app.question_services_factory"
             class="App\Services\QuestionServicesFactory">
        <argument type="service" id="doctrine_mongodb.odm.metadata.annotation_reader"/>
        <argument type="service" id="service_container"/>
    </service>

in controller

    $questionServiceFactory = $this->get('app.question_services_factory');
    $questionSerializer     = $questionServiceFactory->getQuestionService(
        TrueFalseQuestion::class,
        QuestionServicesFactory::SERVICE_SERIALIZER
    );

Upvotes: 2

Views: 455

Answers (3)

Tomasz Madeyski
Tomasz Madeyski

Reputation: 10890

This is basically a bit opinion based question but: @Allesandro Minoccheri answer will work but it has one downside: adding new type of question and question checker will require modifying ServiceClassSearcher for each new pair - so this is a bit against SOLID rules.

I think that Question should know what type of checker should check it, so ideally I would inject QuestionChecker into Question. Since Question is an entity and DI is not possible here I would say that using annotation to specify what type of checker is responsible for checking given question is a good way to do it. Once you have annotation you only need to add class which will parse it and get instance of your checker. This way, once you want to add new type of question you don't need to modify any of existing code.

Also I would suggest to use question checker service name instead of class name as it will be easier to use.

A class to parse annotation and get question checker can look something like:

use Doctrine\Common\Annotations\Reader;

class QuestionCheckerFactory
{
    public function __construct(Reader $reader, ContainerInterface $container)
    {
        $this->reader = $reader;
        $this->container = $container;
    }

    public function getQuestionChecker(Question $question)
    {
        $reflectionClass = new ReflectionClass(get_class($question));
        $classAnnotations = $annotationReader->getClassAnnotations($reflectionClass);

        foreach($classAnnotations as $annotation) {
            if ($annotation instanceof \Your\Annotation\Class) {
                //now depending on how your Annotation is defined you need to get question checker service name
                $serviceName = ...
                return $this->container->get($serviceName);
            }

        }

        throw new Exception();

    }
}

Note: I wrote this code out of my head so there might be some parsing errors, but the general idea is here.

You can check this blog post about using custom annotations

Upvotes: 1

RVandersteen
RVandersteen

Reputation: 2137

I believe you are looking for is the Factory Pattern

The factory:

class QuestionCheckerFactory
{
    public function __construct() // inject what you need to created the services
    {
    }

    public function get(Question $q)
    {
        if($q instance of TrueFalseQuestion)
        {
            return new TrueFalseQuestionChecker(...);
        }

        throw new \Exception('Not implemented ...');
    }
}

$checker = $this->get('question.checker.factory')->get($question);
$checker->verify($question);

Upvotes: 0

Alessandro Minoccheri
Alessandro Minoccheri

Reputation: 35973

I suggest to you to use a class to get the service something like this:

$serviceClass = new ServiceClassSearcher();
$serviceName = $serviceClass->getServiceName($question);
$checker = $this->get($serviceName);

Inside your class you can make something like this:

class ServiceClassSearcher
{
   private $service = [
      'MultipleSelectQuestion' => 'multiple_select_question_checker',
      'TrueFalseQuestion' => 'true_false_question_checker'
   ];

   public function __construct()
   {
   }       

   public function getServiceName($serviceInstance)
   {
       foreach ($this->service as $instance => $serviceName) {
           $className = instance . '::class';
           if($question instance of $className){
               return $value;
           }
       }

       return null; //is better to throw an exception for a class not found
   }
}

Upvotes: 1

Related Questions