Michaël Gallego

This is my blog. What can you expect here? Well... Zend Framework 2, Amazon AWS...

Twitter

Google+

LinkedIn

Github

Last.fm

Une architecture flexible pour gérer plusieurs Db adapters dans des modules génériques ZF 2

For the English version, please follow this link !

Je développe actuellement un module Forum pour Zend Framework 2. Comme souvent, la principale contrainte lors de l’élaboration d’un module générique est d’éviter d’écrire trop de code inutile et, surtout, éviter d’imposer à réécrire 90% du module si l’on souhaite, par exemple, modifier la manière dont les informations sont récupérées (Zend\Db, Doctrine ORM, Doctrine Mongo…).

En effet, à l’heure actuelle, le module est basé sur Doctrine ORM mais, à terme, j’aimerais pouvoir avoir un module ZfrForum utilisant Zend\Db (afin de limiter les dépendances pour ceux n’utilisant pas Doctrine 2), un module ZfrForumDoctrineORM utilisant Doctrine ORM et un autre module ZfrForumDoctrineMongo utilisant Doctrine ODM.

Dans cet article, je vais tenter de vous expliquer comment j’ai résolu ce problème, qui repose, entre autre, sur l’utilisation des fabriques abstraites, fonctionnalité peu connue de Zend Framework 2 mais ô combien puissante !

Le principe

L’idée est plutôt simple : nous allons créer des interfaces Mapper, puis des implémentations concrètes de ces interfaces pour tous les adapteurs de stockage que nous souhaitons (Zend\Db, Doctrine…).

Les services vont consommer ces mappers en utilisant non pas les instances concrètes, mais les interfaces, ce qui permettra au service manager d’injecter, au choix, un mapper utilisant Zend\Db, ou un mapper Doctrine ORM qui étend d’un entity repository.

Les interfaces

Pour répondre à ce problème, nous allons tout d’abord créer une interface représentant un mapper. Un mapper est, en gros, une classe qui va effectuer les opérations dans la base de données, et retourner des objets. Par exemple, voici un mapper pour récupérer des catégories :

namespace ZfrForum\Mapper;

use ZfrForum\Entity\Category;

interface CategoryMapperInterface
{
    /**
     * @param  Category $category
     * @return mixed
     */
    public function create(Category $category);

    /**
     * @param  Category $category
     * @return mixed
     */
    public function update(Category $category);
}

Les implémentations concrètes

Maintenant que nous avons une interface, ne reste plus qu’à écrire les implémentations concrètes.

Pour reprendre mon exemple du forum, cela signifie que, par défaut, l’implémentation utilise Zend\Db, et devrait ressembler à quelque chose comme ça :

namespace ZfrForum\Repository;

use Zend\Db\TableGateway\TableGatewayInterface;
use ZfrForum\Entity\Category;
use ZfrForum\Mapper\CategoryMapperInterface;

class CategoryMapper implements CategoryMapperInterface
{
    /**
     * @var TableGatewayInterface
     */
    protected $tableGateway;

    public function __construct(TableGatewayInterface $tableGateway)
    {
        $this->tableGateway = $tableGateway;
    }

    /**
     * @param  Category $category
     * @return mixed
     */
    public function create(Category $category)
    {
        // TODO: Implement create() method.
    }

    /**
     * @param  Category $category
     * @return mixed
     */
    public function update(Category $category)
    {
        // TODO: Implement update() method.
    }
}

Un hypothétique module ZfrForumDoctrineORM disposera, elle, d’une implémentation qui ressemble à ceci (notez que la classe étend ici de EntityRepository) :

namespace ZfrForum\Repository;

use Doctrine\ORM\EntityRepository;
use ZfrForum\Entity\Category;
use ZfrForum\Mapper\CategoryMapperInterface;

class CategoryRepository extends EntityRepository implements CategoryMapperInterface
{
    /**
     * @param  Category $category
     * @return mixed
     */
    public function create(Category $category)
    {
        // TODO: Implement create() method.
    }

    /**
     * @param  Category $category
     * @return mixed
     */
    public function update(Category $category)
    {
        // TODO: Implement update() method.
    }
}

 Les services

Maintenant, il reste à écrire la couche service. La couche service va avoir une dépendance avec un mapper (car le service n’a pas directement accès à la base de données). Evidemment, l’idée est d’utiliser uniquement les interfaces (CategoryMapperInterface), afin de pouvoir “switcher” entre les différentes implémentations.

Voici par exemple le CategoryService :

namespace ZfrForum\Service;

use ZfrForum\Entity\Category;
use ZfrForum\Mapper\CategoryMapperInterface;

class CategoryService
{
    /**
     * @var CategoryMapperInterface
     */
    protected $categoryMapper;

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

 Gérer les dépendances

Notre service CategoryService dépend d’un mapper CategoryMapperInterface. Nous allons donc ajouter les lignes suivantes dans le fichier Module.php afin de définir les dépendances :

public function getServiceConfig()
{
    return array(
        'factories' => array(
            'ZfrForum\Service\CategoryService' => function($serviceManager) {
                $categoryMapper = $serviceManager->get('ZfrForum\Mapper\CategoryMapperInterface');
                return new Service\CategoryService($categoryMapper);
            },

            'ZfrForum\Mapper\CategoryMapper' => function($serviceManager) {
                 $dbAdapter = $sm->get('Zend\Db\Adapter\Adapter');
                 $resultSetPrototype = new ResultSet();
                 $resultSetPrototype->setArrayObjectPrototype(new Category());

                 return new TableGateway('category', $dbAdapter, null, $resultSetPrototype);
            },
        ),
        /**
         * Abstract factories
         */
        'abstract_factories' => array(
            'ZfrForum\ServiceFactory\MapperAbstractFactory'
        )
    );
}

Evidemment, survient un nouveau problème : le service manager ne sait pas créer un “ZfrForum\Mapper\CategoryMapperInterface”. On pourrait éventuellement ajouter l’alias suivant :

// Dans ZfrForum :
return array(
    'aliases' => array(
        'ZfrForum\Mapper\CategoryMapperInterface' => 'ZfrForum\Mapper\CategoryMapper'
    )
);

// Dans ZfrForumDoctrine :
return array(
    'aliases' => array(
        'ZfrForum\Mapper\CategoryMapperInterface' => 'ZfrForum\Repository\CategoryRepository'
    )
);

Mais imaginez que vous ayez 10, 15 mappers. vous pouvez remarquer dans l’exemple précédent que j’ai ajouté une fabrique abstraite, avec la clé ‘abstract_factories’.

En effet, lorsque l’on appelle la fonction “get” du service manager, il va tenter d’instancier l’objet de trois manières différentes :

  1. D’abord, il va vérifier si le nom existe dans la liste des “invokables”. Si c’est le cas, il créé l’instance immédiatement, sinon il passe à l’étape 2.
  2. Il vérifie si le nom est associé à l’une des fabriques. Si oui, il utilise la fabrique pour instancier l’objet, sinon il passe à l’étape 3.
  3. Il itère à travers toutes les fabriques abstraites, et demande à chaque fabrique abstraite si elle est “apte” à créer un objet portant ce nom. Si l’une des fabriques répond “oui”, alors la fabrique abstraite instancie l’objet.

C’est justement ce que l’on va utiliser. En effet, on va partir du principe que toutes nos interfaces de mapper sont sous la forme suivante : ZfrForum\Mapper\xxxxMapperInterface, où xxxx correspond au nom du mapper.

Notre fabrique abstraite est donc, pour Zend\Db :

namespace ZfrForum\ServiceFactory;

use Zend\ServiceManager\AbstractFactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;

class MapperAbstractFactory implements AbstractFactoryInterface
{
    /**
     * Determine if we can create a service with name
     *
     * @param ServiceLocatorInterface $serviceLocator
     * @param                         $name
     * @param                         $requestedName
     * @return bool
     */
    public function canCreateServiceWithName(ServiceLocatorInterface $serviceLocator, $name, $requestedName)
    {
        return (substr($requestedName, -15) === 'MapperInterface');
    }

    /**
     * Create service with name
     *
     * @param ServiceLocatorInterface $serviceLocator
     * @param                         $name
     * @param                         $requestedName
     * @return mixed
     */
    public function createServiceWithName(ServiceLocatorInterface $serviceLocator, $name, $requestedName)
    {
        // Mapper names given are under the form "ZfrForum\Mapper\SomethingMapperInterface", so we need to
        // remove the MapperInterface part
        $parts       = explode('\\', $requestedName);
        $entityName  = substr(end($parts), 0, -15);
        $entityClass = 'ZfrForum\\Mapper\\' . $entityName . 'Mapper';

        return $serviceLocator->get($entityClass);
    }
}

Comme vous pouvez le voir, la fonction “canCreateServiceWithName” vérifie si le nom se termine par “MapperInterface”. Si c’est le cas, on affirme que l’on peut faire quelque chose, et on retourne donc vrai. De l’autre côté, la fonction createServiceWithName effectue quelques manipulations avec la chaine de caractères pour extraire le nom de l’entité, et construire le mapper.

Et pour Doctrine, c’est encore plus simple : ici, on récupère l’entity manager, et on renvoie l’entity repository, pour peu que vous ayez correctement configuré vos entités, Doctrine renverra automatiquement votre propre implémentation du repository, ici ZfrForum\Repository\CategoryRepository.

namespace ZfrForum\ServiceFactory;

use Zend\ServiceManager\AbstractFactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;

class MapperAbstractFactory implements AbstractFactoryInterface
{
    /**
     * Determine if we can create a service with name
     *
     * @param ServiceLocatorInterface $serviceLocator
     * @param                         $name
     * @param                         $requestedName
     * @return bool
     */
    public function canCreateServiceWithName(ServiceLocatorInterface $serviceLocator, $name, $requestedName)
    {
        return (substr($requestedName, -15) === 'MapperInterface');
    }

    /**
     * Create service with name
     *
     * @param ServiceLocatorInterface $serviceLocator
     * @param                         $name
     * @param                         $requestedName
     * @return mixed
     */
    public function createServiceWithName(ServiceLocatorInterface $serviceLocator, $name, $requestedName)
    {
        /** @var \Doctrine\ORM\EntityManager $entityManager */
        $entityManager = $serviceLocator->get('Doctrine\ORM\EntityManager');

        // Mapper names given are under the form "ZfrForum\Mapper\SomethingMapperInterface", so we need to
        // remove the MapperInterface part
        $parts       = explode('\\', $requestedName);
        $entityName  = substr(end($parts), 0, -15);
        $entityClass = 'ZfrForum\\Entity\\' . $entityName;

        return $entityManager->getRepository($entityClass);
    }
}

Conclusion

Travailler avec les interfaces au niveau des services nous permet de simplement “switcher” entre différents adapters. Quant à la fabrique abstraite, elle permet de grandement simplifier l’usage en évitant d’avoir à ajouter manuellement de nombreuses dépendances.

Si demain je souhaite supporter Doctrine Mongo, il me suffira d’écrire un module ZfrForumDoctrineMongo, de fournir des implémentations spécifiques pour toutes les interfaces mappers, et de fournir une fabrique abstraite qui renverra des object document (plutôt que des entity repository pour Doctrine ORM).