Close search
Hoa

Hack book de Hoa\Ruler

Les règles métiers (comme « tous les clients qui ont dépensé plus de 100€ en une fois reçoivent une réduction de 10% sur leur prochain achat ») sont la plupart du temps définies à l'extérieur de l'application. Elles sont même souvent exprimées dans un langage différent que le langage de programmation utilisé. La bibliothèque Hoa\Ruler offre un moteur permettant d'exécuter des règles métiers simplement et de manière performante tout en restant très extensible.

Table des matières

  1. Introduction
  2. Fonctionnement général
    1. Grammaire (par l'exemple)
    2. Contexte
    3. Asserteur
      1. Ajout de fonctions
  3. Transformation du langage
    1. Interpréteur : langage vers modèle objet
    2. Compilateur : modèle objet vers PHP
    3. Désassembleur : modèle objet vers langage
  4. Performances
    1. Sérialiser le modèle objet
    2. Enregistrer et exécute du code PHP
  5. Conclusion

Introduction

La logique métier est très différente de la logique Informatique. « Tous les clients qui ont dépensé plus de 100€ en une fois reçoivent une réduction de 10% sur leur prochain achat ». Cette règle permet d'accéder à certaines parties du programme si elle est validée. Toutefois, elle peut changer à n'importe quel moment. Très souvent, dans une équipe, ce ne sera pas au développeur d'implémenter cette règle. Elle proviendra probablement d'un dépôt de règles métiers, qui auront été écrites par d'autres personnes, soit manuellement, soit à l'aide d'un programme tiers. Cela implique que le langage utilisé pour exprimer une règle n'est pas le langage utilisé pour développer le programme. Un exemple encore plus évident avec l'utilisation d'une règle pour filtrer des éléments : un élément est accepté si « son groupe est customer ou guest et son nombre de points est supérieur à 30 ». Cette règle peut très bien être écrite par un utilisateur via une interface en ligne de commande pour filtrer des résultats d'une base de données ou de logs.

Il est important de comprendre que les règles doivent être écrites dans un langage dédié. Néanmoins, l'usage qui est fait des règles est très vaste et imprédictible. C'est pourquoi il est primordiale d'avoir des règles souples et extensibles dans leur syntaxe. Par exemple, il doit être permis d'ajouter des opérateurs ou des fonctions : « tous les clients de l'hôtel avec un pass Gold auront une réduction de 10% ». Le « pass Gold » peut être un opérateur ou une fonction spécifique au métier du programme concerné.

Le langage utilisé par la bibliothèque Hoa\Ruler pour décrire des règles respecte ces contraintes d'extensibilité. Les règles ne seront pas proches du langage humain mais resterons naturelles à lire. Si nous reprenons l'exemple de la règle « son groupe est customer ou guest et son nombre de points est supérieur à 30 », elle s'écrira : group in ["customer", "guest"] and points > 30. Les éléments group et points sont des variables de la règle. Leurs valeurs seront définies par un contexte.

D'un point de vue plus formel, une règle est un prédicat, c'est à dire que son résultat est toujours un booléen : true ou false. Comme ces règles sont destinées à être manipulées (modifiées) et exécutées, la bibliothèque Hoa\Ruler propose plusieurs outils pour travailler efficacement avec ces contraintes, présentés dans les sections suivantes.

Fonctionnement général

Le fonctionnement général de la bibliothèque Hoa\Ruler se déroule en 3 étapes :

  1. définition d'une règle ;
  2. définition d'un contexte ;
  3. usage d'un asserteur pour l'exécution.

La règle est une chaîne de caractères respectant une syntaxe précise, décrite par la grammaire du langage définie par la bibliothèque Hoa\Ruler (détaillée ci-après). Cette règle contient des variables dont les valeurs sont définies par le contexte. Le contexte peut contenir des valeurs scalaires, des tableaux ou même des fonctions et des objets. Enfin, l'asserteur associe le contexte à la règle pour pouvoir l'exécuter et obtenir un résultat. Nous rappelons que ce résultat est nécessairement un booléen. C'est alors un prédicat.

Le contexte est représenté par la classe Hoa\Ruler\Context. L'asserteur est représenté par la classe Hoa\Ruler\Visitor\Asserter. Nous pouvons employer la méthode Hoa\Ruler\Ruler::assert qui facilite son utilisation. Ainsi :

$ruler = new Hoa\Ruler\Ruler();

// 1. Write a rule.
$rule  = 'group in ["customer", "guest"] and points > 30';

// 2. Create a context.
$context           = new Hoa\Ruler\Context();
$context['group']  = 'customer';
$context['points'] = function () {
    return 42;
};

// 3. Assert!
var_dump(
    $ruler->assert($rule, $context)
);

/**
 * Will output:
 *     bool(true)
 */

La règle est définie dans la variable $rule. Le contexte, quant à lui, est défini dans la variable $context. Il contient 2 variables : group et points, respectivement avec les valeurs 'customer' et 42 (retournée par une fonction). Enfin, la dernière étape utilise la méthode Hoa\Ruler\Ruler::assert pour exécuter la règle $rule avec le contexte $context (ce dernier est optionnel). Le résultat est true car group est bien dans la liste customer ou guest, et point est bien supérieur à 30. Changez les valeurs dans le contexte ou la règle pour observer un résultat différent.

Les sections suivantes détaillent le fonctionnement de chaque partie mais l'usage classique reste aussi simple que ça !

Grammaire (par l'exemple)

La grammaire du langage des règles est décrite dans le fichier hoa://Library/Ruler/Grammar.pp. Cette grammaire est exprimée avec le langage PP. Pour plus d'informations, voir la bibliothèque Hoa\Compiler. Nous précisons que le langage supporte Unicode. Nous n'allons pas expliquer le langage alors que la grammaire donne tous les détails nécessaires. En revanche, nous allons donner plusieurs exemples de syntaxe.

Langage de Hoa\Ruler par l'exemple.
syntaxesémantique
'foo', "foo", 'f\'oo'des chaînes de caractères
true, false, nulldes constantes pré-définies
4.2un réel
42un entier
['foo', true, 4.2, 42]un tableau (hétérogène)
sum(1, 2, 3)un appel de la fonction sum avec 3 arguments
pointsune variable
points['x']un accès tableau
line.pointAun accès objet (attribut)
line.length()un appel à une méthode
and, or, xor, notdes opérateurs logiques
=, !=, >, <, >=, <=des opérateurs de comparaisons
is, indes opérateurs d'appartenance

Bien sûr, ces exemples représentent des parties atomiques de la grammaire que nous pouvons combiner. Ainsi : userA.allows(groups[groupId][userB]) est valide. De même que f(user, points > 7 and points < 42) est également valide.

En réalité, les fonctions, les opérateurs de comparaisons et les opérateurs d'appartenance ne sont pas définis par la grammaire mais par l'asserteur (détaillé ci-après). Hoa\Ruler ne fait pas la différence entre un opérateur et une fonction. Les opérateurs sont considérés comme des fonctions ; un opérateur n'étant qu'une fonction d'arité 1 ou 2. Ainsi, nous pouvons écrire 2 = 2 ou =(2, 2), cela produira strictement le même résultat. Tout comme le nom des fonctions qui n'est pas défini par la grammaire, le nom des opérateurs n'est lui non plus pas défini par la grammaire, excepté pour les opérateurs logiques qui ont un traitement particuliers (à cause de la précédence des opérateurs). Cela a pour effet de pouvoir créer nos propres opérateurs ou fonctions. Nous pouvons imaginer aA, √(42) ou encore userA allows userB comme étant des expressions valides.

Contexte

Le contexte définit les valeurs des variables présentes dans des règles. Ces valeurs peuvent être :

Par défaut, les valeurs calculées ne le sont qu'une seule fois. En effet, elles sont placées dans un cache pour des raisons de performance. Si nous voulons les recalculer à chaque lecture, il faudra les encapsuler dans un objet de type Hoa\Ruler\DynamicCallable.

Avant de détailler cette partie, présentons le contexte. Un contexte est une instance de la classe Hoa\Ruler\Context qui implémente l'interface ArrayAccess. Ainsi nous utilisons le contexte comme un tableau :

$context        = new Hoa\Ruler\Context();
$context['key'] = 'value';

var_dump(
    isset($context['key']),
    $context['key']
);

/**
 * Will output:
 *     bool(true)
 *     string(5) "value"
 */

Pour y déposer des valeurs calculées, nous pouvons le faire via une fonction ; ainsi :

$context['computed'] = function () {
    return 42;
};

var_dump($context['computed']);

/**
 * Will output:
 *     int(42)
 */

Nous avons dit que les valeurs calculées sont placées en cache dès la première lecture. Pour l'illustrer, nous allons utiliser une fonction qui incrémente un entier à chaque appel :

$i              = 0;
$context['int'] = function () use (&$i) {
    return ++$i;
};

var_dump(
    $context['int'],
    $context['int'],
    $context['int'],
    $i
);

/**
 * Will output:
 *     int(1)
 *     int(1)
 *     int(1)
 *     int(1)
 */

La variable $i a été incrémentée une seule fois pour passer de 0 à 1, puis elle a été placée en cache. Maintenant, si nous encapsulons cette fonction dans une instance de la classe Hoa\Ruler\DynamicCallable, observons ce qu'il se passe :

$i              = 0;
$context['int'] = new Hoa\Ruler\DynamicCallable(
    function () use (&$i) {
        return ++$i;
    }
);

var_dump(
    $context['int'],
    $context['int'],
    $context['int'],
    $i
);

/**
 * Will output:
 *     int(1)
 *     int(2)
 *     int(3)
 *     int(3)
 */

Le résultat n'est plus mis en cache.

Nous pouvons également utiliser une fonction déclarée grâce à son nom. Attention toutefois, il sera impossible d'appeler les fonctions natives de PHP pour des raisons de sécurité. Le contexte n'a pas une telle portée. Ainsi :

function answer()
{
    return 42;
}

$context['the_answer'] = 'answer';

var_dump($context['the_answer']);

/**
 * Will output:
 *     int(42)
 */

C'est tout ce qu'il faut savoir sur le contexte. Ce n'est pas plus compliqué que ça !

Asserteur

Étant donné une règle et un contexte, l'asserteur est chargé de calculer le résultat de cette règle, dont les valeurs des variables sont dans le contexte.

La règle peut avoir deux formes possibles :

Si c'est une chaîne de caractère, elle sera transformée en modèle objet automatiquement par la méthode Hoa\Ruler\Ruler::assert. Ce modèle objet implémente les interfaces de la bibliothèque Hoa\Visitor et peut donc être visité. C'est pourquoi l'asserteur Hoa\Ruler\Visitor\Asserter est un visiteur. Le contexte quant à lui est défini auprès de l'asserteur avec la méthode Hoa\Ruler\Visitor\Asserter::setContext. Ainsi :

$ruler             = new Hoa\Ruler\Ruler();
$rule              = 'points > 30';
$context           = new Hoa\Ruler\Context();
$context['points'] = 42;

// Define an asserter.
$asserter          = new Hoa\Ruler\Visitor\Asserter();

// Set this asserter on the ruler.
$ruler->setAsserter($asserter);

// Assert!
var_dump(
    $ruler->assert($rule, $context)
);

/**
 * Will output:
 *     bool(true)
 */

La méthode Hoa\Ruler\Ruler::assert va automatiquement définir le contexte auprès de l'asserteur.

Ajout de fonctions

Nous avons précisé que les noms des opérateurs et des fonctions dans les règles sont libres. Ainsi, nous avons évoqué la possibilité de définir nos propres opérateurs et fonctions. Ajoutons la fonction logged qui teste si un objet de type User est connecté. Voici cet objet :

class User
{
    const DISCONNECTED = 0;
    const CONNECTED    = 1;
    protected $_status = 1;

    public function getStatus()
    {
        return $this->_status;
    }
}

L'implémentation de la fonction logged serait alors :

$logged = function (User $user) {
    return $user::CONNECTED === $user->getStatus();
};

Enfin, pour déclarer cette fonction, nous allons utiliser la méthode Hoa\Ruler\Visitor\Asserter::setOperator. Nous pouvons aussi citer les méthodes operatorExists, getOperator et getOperators qui permettent respectivement de tester si un opérateur existe, d'obtenir un opérateur précédemment déclaré et d'obtenir tous les opérateurs déclarés. Ainsi :

$ruler             = new Hoa\Ruler\Ruler();
$rule              = 'logged(user) and points > 30';
$context           = new Hoa\Ruler\Context();
$context['user']   = new User();
$context['points'] = 42;

// Declare the `logged` function.
$asserter = new Hoa\Ruler\Visitor\Asserter();
$asserter->setOperator('logged', $logged);

$ruler->setAsserter($asserter);

// Assert!
var_dump(
    $ruler->assert($rule, $context)
);

/**
 * Will output:
 *     bool(true)
 */

La classe Hoa\Ruler\Ruler ne contient que des méthodes pour travailler plus vite et cacher le mécanisme sous-jacent (détaillé ci-après). Elle contient entre autre la méthode statique getDefaultAsserter qui retourne une instance unique de la classe Hoa\Ruler\Visitor\Asserter. Nous pouvons utiliser cette instance unique pour définir des nouveaux opérateurs pour toutes les règles. Son utilisation est très similaire à ce que nous avons vu précédemment :

$ruler             = new Hoa\Ruler\Ruler();
$rule              = 'logged(user) and points > 30';
$context           = new Hoa\Ruler\Context();
$context['user']   = new User();
$context['points'] = 42;

// Declare the `logged` function.
$ruler->getDefaultAsserter()->setOperator('logged', $logged);

// Assert!
var_dump(
    $ruler->assert($rule, $context)
);

/**
 * Will output:
 *     bool(true)
 */

La méthode Hoa\Ruler\Visitor\Asserter::setOperator utilise n'importe quel callable valide.

Les opérateurs and, or, xor, not, =, !=, sum etc. sont définis dans la classe Hoa\Ruler\Visitor\Asserter. N'hésitez pas à vous en inspirer !

Transformation du langage

Le mécanisme sous-jacent caché par la classe Hoa\Ruler\Ruler est simple et très modulaire. Les sections suivantes détaillent les transformations possibles du langage et leur utilisation.

Tout d'abord, la règle est interprétée par un interpréteur pour être transformée en modèle objet. Ensuite, ce modèle objet est utilisé par l'asserteur, ou peut être transformé en code PHP ou transformé en une règle. Le modèle objet est le point central du langage, c'est sa forme la plus avancée.

Interpréteur : langage vers modèle objet

Une règle est une chaîne de caractères. Pour la transformer en modèle objet, nous utilisons la bibliothèque Hoa\Compiler.

Grâce à la grammaire des règles (définie dans le fichier hoa://Library/Ruler/Grammar.pp), nous allons obtenir un AST : un arbre abstrait. Par exemple, pour la règle points > 30, son AST est :

$ echo 'points > 30' | hoa compiler:pp hoa://Library/Ruler/Grammar.pp 0 --visitor dump
>  #expression
>  >  #operation
>  >  >  token(identifier, points)
>  >  >  token(identifier, >)
>  >  >  token(integer, 30)

Pour que cet arbre soit exploitable plus facilement, il va être transformé en modèle objet. Cette transformation est assurée par la classe Hoa\Ruler\Visitor\Interpreter. Ainsi, si nous devions le faire manuellement :

$compiler    = Hoa\Compiler\Llk::load(
    new Hoa\File\Read('hoa://Library/Ruler/Grammar.pp')
);
$ast         = $compiler->parse('points > 30');
$interpreter = new Hoa\Ruler\Visitor\Interpreter();
$model       = $interpreter->visit($ast);

var_dump(
    get_class($model)
);

/**
 * Will output:
 *     string(21) "Hoa\Ruler\Model\Model"
 */

Nous apprenons alors que le modèle est représenté par les classes appartenant à l'espace de nom Hoa\Ruler\Model.

Toutes ces opérations sont remplacées par la méthode statique Hoa\Ruler\Ruler::interpret :

$model = Hoa\Ruler\Ruler::interpret('points > 30');

Nous pouvons obtenir le compilateur avec la méthode Hoa\Ruler\Ruler::getCompiler.

Nous verrons comment cette étape peut être importante pour améliorer les performances.

Compilateur : modèle objet vers PHP

Le modèle objet peut être créé manuellement en instanciant tous les objets de type Hoa\Ruler\Model\* et en les combinant ensemble.

Le code PHP nécessaire à cette opération peut être automatiquement généré grâce à la classe Hoa\Ruler\Visitor\Compiler. Ainsi :

$compiler = new Hoa\Ruler\Visitor\Compiler();
echo $compiler->visit($model);

/**
 * Will output:
 *     $model = new \Hoa\Ruler\Model();
 *     $model->expression =
 *         $model->{'>'}(
 *             $model->variable('points'),
 *             30
 *         );
 */

Le code généré est simplifié et optimisé pour être le plus court possible tout en restant lisible par un être humain.

Nous verrons comment cette étape peut être importante pour améliorer les performances.

Désassembleur : modèle objet vers langage

Jusqu'à maintenant, nous avons vu comment passer d'une règle vers son modèle objet. Le désassembleur applique l'opération inverse : il transforme un modèle objet vers une chaîne de caractères.

La règle générée peut différer un peu de la règle originale syntaxiquement (parenthèses, guillemets, espacements…) mais jamais sémantiquement. Ainsi :

$disassembly = new Hoa\Ruler\Visitor\Disassembly();
echo $disassembly->visit($model);

/**
 * Will output:
 *     (points > 30)
 */

Performances

Transformer une règle vers un modèle objet a un coût. Ce coût devient significatif si l'opération est appliquée des milliers de fois à la minute. En revanche, appliquer un asserteur sur un modèle objet n'est pas coûteux. Nous allons donc présenter deux façons d'éviter la transformation d'une règle vers un modèle objet.

Sérialiser le modèle objet

Une fois le modèle objet obtenu, nous pouvons le sérialiser à l'aide de la fonction PHP serialize. Nous allons obtenir une chaîne de caractères représentant les instances des objets constituant le modèle objet. Pour obtenir à nouveau le modèle objet et être capable d'y appliquer un asserteur, nous utiliserons la fonction PHP unserialize. Le résultat de la sérialisation peut être stocké en base de données à la place des règles. Cela nécessite un peu plus de place mais rappelons que nous pouvons transformer le modèle objet vers sa règle facilement grâce à la classe Hoa\Ruler\Visitor\Disassembly, cette information n'est donc pas perdue. Ainsi :

$ruler             = new Hoa\Ruler\Ruler();
$rule              = 'points > 30';
$context           = new Hoa\Ruler\Context();
$context['points'] = 42;

// Nothing in the database.
if (null === $serialized = $database->get($ruleId)) {
    // We transform the rule into an object model.
    $model = Hoa\Ruler\Ruler::interpret($rule);

    // We serialize and save the object model.
    $database->save($ruleId, serialize($model));
} else {
    // We have a serialization! We unserialize it to get the object model.
    $model = unserialize($serialized);
}

// We can assert by using a model instead of a rule!
var_dump(
    $ruler->assert($model, $context)
);

/**
 * Will output:
 *     bool(true)
 */

De cette manière, la règle n'est transformée en modèle objet qu'une seule fois !

Enregistrer et exécute du code PHP

Une autre façon d'éviter la transformation d'une règle en modèle objet est d'enregistrer le code PHP permettant de construire le modèle objet grâce à la classe Hoa\Ruler\Visitor\Compiler. Une fois ce code PHP enregistré et exécuté, nous retrouverons notre modèle objet.

Toutefois, exécuter un tel code PHP s'avèrera légèrement plus lent et plus difficile à mettre en œuvre que la technique précédente.

Conclusion

La bibliothèque Hoa\Ruler définit un langage de règles métiers simple et inspiré du langage SQL dans sa syntaxe. Le langage peut être transformé de plusieurs façons : vers un modèle objet pour qu'il puisse être exécuté, ou à partir de ce modèle, vers du code PHP ou vers le langage d'origine. L'instanciation des variables dans ce langage se fait à travers un contexte. Toutes ces opérations sont cachées à travers une interface simple et claire.

La question des performances a été abordée et deux solutions sont proposées. Aujourd'hui utilisé dans l'industrie sur de gros projets, Hoa\Ruler est capable de supporter de lourdes charges si ces pratiques simples sont mises en œuvre.

Une erreur ou une suggestion sur la documentation ? Les contributions sont ouvertes !

Comments

menu