Développer des moteurs de plugins backend avec Symfony2

Le développement backend est un aspect important de notre métier. Nous devons travailler de sorte à ce que ces backends soient le plus facilement maintenables et extensibles que possible. Le composant Dependency Injection de Symfony2 permet de mettre en place des mécanismes dédiés qui nous permettent de réaliser des moteurs de fonctionnalités extensibles et réutilisables.

Nos objectifs sont multiples:

  • Rendre l'application extensible
  • Faciliter le travail de test et d'intégration
  • Faciliter le travail de développement avec une segmentation plus souple
  • Améliorer la maintenance de nos applications
  • Faciliter les évolutions de nos applications

Il existe bien sur de multiples façons de faire et ce que je présente dans ce post n'est qu'un exemple qui doit être réadapté à vos besoins au cas par cas. Néenmoins, le patron de conception reste très similaire.

Nous allons donc voir comment mettre un tel système de plugin en place à l'aide du framework Symfony2. Pour celà nous allons avoir besoin de bien savoir:

  • Comment fonctionne le DIC (Dependency Injection Container) de Symfony2
  • Ce qu'est un service Taggé
  • Ce qu'est une interface en PHP
  • Ce qu'est une passe de compilation (Compiler Pass) dans Symfony2

Objectifs

Dans cette partie on cherche à permettre à un utilisateur de définir ses propres règles de validation de mot de passe. Il s'agit d'une chaîne de traitement, nous devons passer dans toutes les règles et elles doivent toutes valider le mot de passe pour que celui-ci soit accepté par notre application.

Ce système à son intérêt au sens où il permettra à terme de n'avoir qu'une seule contrainte de validation et permettra d'appliquer l'ensemble des règles. Il est aussi évolutif. Il ne sera pas nécessaire d'ajouter des contraintes à chaque changement de règles et les règles appliquées peuvent être configurées via la configuration de Symfony2.

Spécification de la règle de validation

Pour que notre système fonctionne il faut que toutes les règles soient alignées sur le même modèle. Il nous faut donc spécifier une interface sur laquelle toutes nos règles devront s'aligner. Voici son prototype:

<?php
/*
 * This file is part of the UCS package.
 *
 * Copyright 2015 Nicolas Macherey <nicolas.macherey@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace UCS\Component\SecurityPolicy\Password;

/**
 * Interface PasswordValidationRuleInterface
 *
 * Implement this class to add a password validation rule
 *
 * @author Nicolas Macherey <nicolas.macherey@gmail.com>
 */
interface PasswordValidationRuleInterface
{
    /**
     * Returns a unique name for your password rule
     *
     * @return string
     */
    public function getName();

    /**
     * Validate the current password accordingly to specific rules the password is **
     * accessible within the context
     *
     * @param string $password
     *
     * @return boolean
     */
    public function validate($password);
}

Notre objectif est relativement simple:

  • Une première méthode validate permet de valider le mot de passe
  • Une seconde méthode getName permet d'identifier de manière unique le mote de passe.

Bien sur, nous pouvons plus que largement améliorer ce modèle en utilisant un contexte de validation qui permettra de récupérer des messages d'erreurs et autres informations contextuelles nécessaires à l'utilisateur.

Le validateur de mot de passe

Maintenant que nous avons spécifié notre règle de validation de mot de passe, nous avons besoin de centraliser leur gestion au sein d'un service que notre application appellera indépendemment du nombre de règles que nous avons définie. Il s'agit du point central de communication avec notre application web, il est unique et ne change pas. Il est responsable de l'application et du traitement de toutes les règles.

Au final pour l'application il s'agit simplement d'une classe qui valide notre mot de passe.

Prototype du validateur

Pour l'instant le prototype est le même que celui de la règle, mais en cas d'évolution il sera différent notamment pour la gestion du contexte de validation. Cette classe va finalement appeler toutes les règles de validation. Il lui faudra donc avoir connaissance de ces dernières. Voici le prototype de la classe :

<?php
/*
 * This file is part of the UCS package.
 *
 * Copyright 2015 Nicolas Macherey <nicolas.macherey@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace UCS\Component\SecurityPolicy\Password;

/**
 * Interface PasswordValidatorInterface
 *
 * Specification for the password validator, it should validate the password and return 
 * the password validation context accordingly.
 *
 * @author Nicolas Macherey <nicolas.macherey@gmail.com>
 */
interface PasswordValidatorInterface
{
    /**
     * @param string $password
     *
     * @return boolean
     */
    public function validate($password);
}

Implémentation du validateur

Dans notre cas nous avons choisi de passer toutes les règles de validation dans le constructeur du validateur. Il s'agit d'un ensemble fixe configurable qui ne bouge pas au runtime. Mais vous pouvez tout à fait choisir de faire autrement ! Voici le code de la classe de validation:

<?php
/*
 * This file is part of the UCS package.
 *
 * Copyright 2015 Nicolas Macherey <nicolas.macherey@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace UCS\Component\SecurityPolicy\Password;

/**
 * Class PasswordValidator
 *
 * @author Nicolas Macherey <nicolas.macherey@gmail.com>
 */
class PasswordValidator implements PasswordValidatorInterface
{
    /**
     * @var PasswordValidationRuleInterface[]
     */
    private $rules;

    /**
     * Constructor
     *
     * @param array               $rules
     */
    public function __construct(array $rules = [])
    {
        $this->rules = $rules;
    }

    /**
     * {@inheritdoc}
     */
    public function validate($password)
    {
        foreach ($this->rules as $rule) {
            if (!$rule->validate($password)) {
                return false;
            };
        }

        return true;
    }

    /**
     * @return PasswordValidationRuleInterface[]
     */
    public function getRules()
    {
        return $this->rules;
    }

    /**
     * @param string                          $name
     * @param PasswordValidationRuleInterface $rule
     *
     * @return $this
     */
    public function addRule($name, PasswordValidationRuleInterface $rule)
    {
        unset($this->rules[$name]);
        $this->rules[$name] = $rule;

        return $this;
    }

    /**
     * @param string $name
     *
     * @return $this
     */
    public function removeRule($name)
    {
        unset($this->rules[$name]);

        return $this;
    }

    /**
     * @param string $name
     *
     * @return bool
     */
    public function hasRule($name)
    {
        return isset($this->rules[$name]);
    }

    /**
     * @param string $name
     *
     * @return null|PasswordValidationRuleInterface
     */
    public function getRule($name)
    {
        return isset($this->rules[$name]) ? $this->rules[$name] : null;
    }
}

Bien sur, il s'agit d'un modèle très simple qui ne correspondra pas à un cas réel d'utilisation pour valider ce que fait un utilisateur. Pour celà il nous faudrait une classe de contexte de validation qui permet de récupérer les erreurs et leur traduction à chaque exécution d'une règle. Nous ne traiterons pas ce sujet ici.

Ce que nous pouvons notter c'est que nous pouvons définir une règle de validation personnalisée et l'enregistrer dans notre validateur.

Exemple de règle

Voici par exemple une règle qui permet de valider que le mote de passe ne contient que des caractères alphanumériques:

<?php
/*
 * This file is part of the UCS package.
 *
 * Copyright 2015 Nicolas Macherey <nicolas.macherey@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace UCS\Component\SecurityPolicy\Password\Rule;

/* Imports */
use UCS\Component\SecurityPolicy\Password\PasswordValidationContext;
use UCS\Component\SecurityPolicy\Password\PasswordValidationRuleInterface;

/**
 * Interface PasswordValidationRuleInterface
 *
 * Implement this class to add a password validation rule
 *
 * @author Nicolas Macherey <nicolas.macherey@gmail.com>
 */
class AlphanumericChars implements PasswordValidationRuleInterface
{
    /**
     * @var string
     */
    const PATTERN = '/^[\w\-]+$/';

    /**
     * @var string
     */
    private $pattern;

    /**
     * @param string $pattern
     */
    public function __construct($pattern = null)
    {
        $this->pattern = $pattern ? $pattern : self::PATTERN;
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return 'alphanumeric_chars';
    }

    /**
     * {@inheritdoc}
     */
    public function validate($password)
    {
        if (!preg_match($this->pattern, $password))) {
            return false;
        }

        return true;
    }
}

Exemple d'utilisation

Maintenant notre système est fonctionnel, nous pouvons dans un programme en faire l'utilisation suivante:

<?php

$passwordValidator = new PasswordValidation([
    'alphanumeric_chars' => new AlphanumericChars(),
]);

$passwordValidation->validate('myPassword !');

Nous pouvons aussi appeler la fonction register à la demande:

<?php

$passwordValidator = new PasswordValidation([]);
$passwordValidation->addRule('alphanumeric_chars', new AlphanumericChars());

// Validate a password
$passwordValidation->validate('myPassword !');

Intégration dans Symfony

Maintenant que nous avons notre patron de conception, que nous pouvons l'utiliser, il nous faut un moyen de l'intégrer de manière générique dans Symfony2 et d'exploiter le container d'injection de dépendance pour ajouter nos règles au fil de l'eau et spécifiquement pour chacune de nos applications. Pour celà nous allons faire une passe de compilation:

<?php

/*
 * This file is part of the UCS package.
 *
 * Copyright 2015 Nicolas Macherey <nicolas.macherey@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace UCS\Bundle\SecurityPolicyBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

/**
 * Compiler pass for password validation rules
 *
 * @author Nicolas Macherey <nicolas.macherey@gmail.com>
 */
class PasswordValidationRuleCompilerPass implements CompilerPassInterface
{
    const SERVICE_ID = 'ucs.password_validator';
    const TAG_ID = 'ucs.password_validation_rule';

    /**
     * {@inheritdoc}
     */
    public function process(ContainerBuilder $container)
    {
        if (!$container->hasDefinition(self::SERVICE_ID)) {
            return;
        }

        $definition = $container->getDefinition(self::SERVICE_ID);
        $taggedServices = [];

        foreach ($container->findTaggedServiceIds(self::TAG_ID) as $serviceId => $tags) {
            foreach ($tags as $tag) {
                $alias = isset($tag['alias'])
                    ? $tag['alias']
                    : $serviceId;

                $taggedServices[$alias] = new Reference($serviceId);
            }
        }

        $definition->replaceArgument(0, $taggedServices);
    }
}

Le rôle de la passe de compilation est très simple, elle va expliquer au framework comment construire le code nécessaire à l'instanciation de notre validateur. Dans notre cas nous voulons récupérer tous les services taggés "ucs.passwordvalidationrule" et les injecter dans le constructeur de notre validateur.

De cette manière nous avons toutes les règles taggées et déclarées qui seront appelées lors de notre appel au validateur nous n'avons pas besoin de modifier son code. ET C'EST LA TOUT L'INTERET! C'est facile à maintenir et à faire écoluer et nous pouvons maintenir un set de règles réutilisables !!!!!

Enregistrement de nos règles

Il ne nous reste plus qu'à déclarer nos règles sous forme de service taggés dans al configuration de nos bundles:

<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <parameters>
        <parameter key="ucs.alaphanumeric_chars_rule.class">UCS\Component\SecurityPolicy\Password\Rule\AlphanumericChars</parameter>
    </parameters>

    <services>
        <service id="ucs.alaphanumeric_chars_rule" class="%ucs.alaphanumeric_chars_rule.class%">
            <tag name="ucs.password_validation_rule" alias="alphanumeric_chars"/>
        </service>
    </services>

</container>

Et voilà!!! Aussi simple que ça...

Comments

comments powered by Disqus