Tutoriel

Introduction

Dans ce tutoriel, nous allons voir comment mettre en œuvre le moteur de règles MRules, en passant par les différentes étapes nécessaires :

  • Définition du besoin
  • Conception du modèle objet
  • Configuration du moteur. Nous allons ici établir la configuration pour les deux grammaires XML
    • Simple
    • Complète
  • Utilisation du moteur dans un environnement multi thread
    • Écriture du code permettant de lire la configuration et exécuter les règles
    • Écriture de code de test permettant de générer des cas métier et de simuler l’environnement multi-thread.

Cas métier

L’objet de ce tutorial étant de présenter l’utilisation, nous allons étudier un cas dont la configuration est relativement simple.

A l’inscription sur un site Web, l’utilisateur reçoit un cadeau de bienvenue. Le cadeau est différent selon les caractéristiques de l’utilisateur. Les critères et le cadeau évoluent régulièrement, le moteur de règles sera donc responsable de lire les caractéristiques pertinentes et de définir le cadeau attribué.

Les caractéristiques d’un utilisateur sont :

  • Sa date de naissance, de laquelle est déduite son age
  • Son sexe
  • Sa ville

Les règles sont :

  • Un homme de moins de 25 ans reçoit un t-shirt
  • Une femme de moins de 30 ans reçoit un maillot de bain
  • Un utilisateur de plus de 60 ans reçoit un coffret foie gras
  • Les autres utilisateurs reçoivent une bouteille de vin

Modèle objet

Le modèle objet est ici simpliste. Un seul Bean Java contiendra les éléments d’entrée / sortie.

package com.massa.mrules.demos.lifecycle;

import java.util.Calendar;
import java.util.Date;

/**
 * Holds user data.
 */
public class UserBean {
    //INPUT
    private Date birthDate;
    private char gender;
    private String town;
    
    //OUTPUT
    private String gift;

    public UserBean() {
    }

    /**
     * Utility getter to compute age from birth date.
     */
    public int getAge() {
        Calendar birthCal = Calendar.getInstance();  
        birthCal.setTime(birthDate);  
        Calendar today = Calendar.getInstance();  
        int age = today.get(Calendar.YEAR) - birthCal.get(Calendar.YEAR);  
        if (today.get(Calendar.MONTH) < birthCal.get(Calendar.MONTH)) {
          age--;  
        } else if (today.get(Calendar.MONTH) == birthCal.get(Calendar.MONTH)
            && today.get(Calendar.DAY_OF_MONTH) < birthCal.get(Calendar.DAY_OF_MONTH)) {
          age--;  
        }
        return age;
    }

    public Date getBirthDate() {
        return birthDate;
    }
    public void setBirthDate(Date birthDate) {
        this.birthDate = birthDate;
    }
    public char getGender() {
        return gender;
    }
    public void setGender(char gender) {
        this.gender = gender;
    }
    public String getTown() {
        return town;
    }
    public void setTown(String town) {
        this.town = town;
    }
    public String getGift() {
        return gift;
    }
    public void setGift(String gift) {
        this.gift = gift;
    }
}

Configuration des règles

La configuration de la grammaire XML simple est la suivante :

<?xml version="1.0" encoding="UTF-8"?>

<rules name="test1" stopAtFirstValidatedCondition="true">
    <context id="CONTEXT" classIn="com.massa.mrules.demos.lifecycle.UserBean" />
    <rule>
        <if>
            <age operator="LT">25</age>
            <gender>M</gender>
        </if>
        <then>
            <gift>T-SHIRT</gift>
        </then>        
    </rule>
    
    <rule>
        <if>
            <age operator="LT">30</age>
            <gender>F</gender>
        </if>
        <then>
            <gift>SWIMSUIT</gift>
        </then>        
    </rule>
    
    <rule>
        <if>
            <age operator="GT">60</age>
        </if>
        <then>
            <gift>FOIE GRAS BOX</gift>
        </then>        
    </rule>

    <rule>
        <then>
            <gift>BOTTLE OF WINE</gift>
        </then>        
    </rule>
</rules>

La configuration de la grammaire XML complète est la suivante :

<?xml version="1.0" encoding="UTF-8"?>

<rules implem="RULEEXECUTIONSET" name="advanced" stopAtFirstValidatedCondition="true" contextFactory="CONTEXT:com.massa.mrules.demos.lifecycle.UserBean">
    <executable implem="RULE">
        <condition implem="CONDSET" operator="AND">
            <condition implem="EVAL" source=":age" operator="LT" reference="25"/>
            <condition implem="EVAL" source=":gender" operator="EQ" reference="M"/>
        </condition>
        <then implem="SET" source="T-SHIRT" target=":gift" />
    </executable>
    
    <executable implem="RULE">
        <condition implem="CONDSET" operator="AND">
            <condition implem="EVAL" source=":age" operator="LT" reference="30"/>
            <condition implem="EVAL" source=":gender" operator="EQ" reference="F"/>
        </condition>
        <then implem="SET" source="SWIMSUIT" target=":gift" />
    </executable>
    
    <executable implem="RULE">
        <condition implem="EVAL" source=":age" operator="GT" reference="60"/>
        <then implem="SET" source="FOIE GRAS BOX" target=":gift" />
    </executable>
    
    <executable implem="SET" source="BOTTLE OF WINE" target=":gift" /> 
</rules>

Test en environnement multi thread

Préparation

Tout d’abord, nous allons créer deux classes utilitaires permettant de simuler l’environnement et de créer des cas de test.
Le Rules Runner est une classe abstraite permettant d’exécuter le moteur de règles sur une liste d’Objets. Ses implémentations contiendront le code dédié à invoquer MRules. Qu’elles soient ensuite appelées par un Batch, une Servlet ou comme ici un TU ne change pas leur finalité.

package com.massa.mrules.demos.lifecycle;

import java.util.ArrayList;

import com.massa.mrules.exception.MConfigurationException;
import com.massa.mrules.exception.MExecutionException;

/**
 * Abstract Class allowing to run a rule engine on beans.
 */
public abstract class AbstractRulesRunner {

    /**
     * Runs rule engine on a list of beans.
     */
    public abstract void runRules(ArrayList beans) throws MConfigurationException, MExecutionException;

}

Le RuleTestThread est un Thread qui va créer les cas de test, exécuter le moteur de règles via une implémentation de AbstractRulesRunner et tester les résultats. Si une exception est levée elle sera stockée. Cette classe va permettre d’émuler un environnement concurrent.

package com.massa.mrules.demos.lifecycle;

import java.util.ArrayList;
import java.util.Calendar;

import org.junit.Assert;

/**
 * Abstract Thread allowing to emulate concurrent environment.
 */
public class RunnerTestThread extends Thread {
    private Exception error;
    private final AbstractRulesRunner runner;

    public RunnerTestThread(final AbstractRulesRunner runner) {
        this.runner = runner;
    }

    @Override
    public final void run() {
        try {
            final ArrayList beans = RunnerTestThread.buildBeans();
            this.runner.runRules(beans);
            RunnerTestThread.testBeans(beans);
        } catch (final Exception e) {
            this.error = e;
        }
    }

    /**
     * Builds beans, testing all rules
     */
    public static ArrayList buildBeans() {
        final ArrayList beans = new ArrayList();
        final Calendar c = Calendar.getInstance();

        c.add(Calendar.YEAR, -20); //20 yo
        beans.add(new UserBean(c.getTime(), 'M', "LONDON"));

        c.add(Calendar.YEAR, -7); //27 yo
        beans.add(new UserBean(c.getTime(), 'M', "PARIS"));
        beans.add(new UserBean(c.getTime(), 'F', "NEW YORK"));

        c.add(Calendar.YEAR, -13); //40 yo
        beans.add(new UserBean(c.getTime(), 'F', "BERLIN"));

        c.add(Calendar.YEAR, -30); //70 yo
        beans.add(new UserBean(c.getTime(), 'M', "MADRID"));
        beans.add(new UserBean(c.getTime(), 'F', "MADRID"));

        return beans;
    }

    /**
     * Tests Beans list built by buildBeans
     */
    public static void testBeans(final ArrayList beans) {
        Assert.assertEquals("T-SHIRT", beans.get(0).getGift());
        Assert.assertEquals("BOTTLE OF WINE", beans.get(1).getGift());
        Assert.assertEquals("SWIMSUIT", beans.get(2).getGift());
        Assert.assertEquals("BOTTLE OF WINE", beans.get(3).getGift());
        Assert.assertEquals("FOIE GRAS BOX", beans.get(4).getGift());
        Assert.assertEquals("FOIE GRAS BOX", beans.get(5).getGift());
    }

    public Exception getError() {
        return this.error;
    }
}

Implémentation

Nous allons ici implémenter le code qui permettra de lire la configuration et d’exécuter le moteur. Trois implémentations possibles du Rules Runner sont présentées et comparées.

L’objectif principal de ce tutorial est de clarifier l’utilisation cible du Rule Set :

  • L’instance du Rule Set peut (et devrait) être unique et centralisée. Elle est dans ce cas partagé entre tous les Thread. Les phases de relecture de la configuration et de compilation ne doivent se faire qu’en cas de modification de la configuration.
  • Le Contexte d’Exécution est dédié à une exécution et ne peut pas être centralisé ni partagé entre les processus concurrent. Chaque Thread n’instantie cependant qu’un et un seul contexte, car en son sein les exécution ne sont pas simultanées et concurrentes.

La première implémentation présentée permet de lire la configuration de type grammaire complète, en utilisant l’utilitaire centralisé MRulesBuilder. Des paramètres doivent être transmis à l’utilitaire, qui va se charger de construire une et une seule fois l’instance, même s’il y a une concurrence d’appel.

Ces paramètres sont ceux que l’on retrouve pour la déclaration d’une ressource JNDI ou pour l’injection CDI.

L’avantage de ce code, même s’il est légèrement plus verbeux, est que si le fichier de configuration est modifié, il sera automatiquement rechargé grâce au paramètre « checkhash ».

package com.massa.mrules.demos.lifecycle;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;

import com.massa.mrules.CustomProperties;
import com.massa.mrules.builder.MRulesBuilder;
import com.massa.mrules.context.execute.MExecutionContext;
import com.massa.mrules.exception.MConfigurationException;
import com.massa.mrules.exception.MExecutionException;
import com.massa.mrules.set.IMruleExecutionSet;

/**
 * Launches rules read from complete grammar without builder.
 */
public class CompleteGrammarWithBuilderRunner extends AbstractRulesRunner {
    @Override
    public void runRules(final ArrayList beans) throws MConfigurationException, MExecutionException {
        final MRulesBuilder builder = new MRulesBuilder();
        final Map<String, String> parameters = new HashMap<String, String>();
        parameters.put(CustomProperties.ruleengine_uri.getLabel(), "completeGrammar");
        parameters.put(CustomProperties.configholder_impl.getLabel(), "com.massa.mrules.builder.XmlRuleEngineConfigHolder");
        parameters.put(CustomProperties.configholder_factoryimpl.getLabel(), "com.massa.mrules.factory.xml.RichXMLFactory");
        parameters.put(CustomProperties.xmlconfigholder_xmlfile.getLabel(), "com/massa/mrules/demos/lifecycle/complete.xml");
        parameters.put(CustomProperties.ruleenginebuilder_checkhash.getLabel(), "true");
        final IMruleExecutionSet set = builder.getRuleExecutionSetInstance(parameters);

        final MExecutionContext ctxt = new MExecutionContext(set);
        for (final UserBean bean: beans) {
            ctxt.setInput(bean);
            ctxt.execute();
        }
    }
}

La deuxième implémentation présentée permet également de lire la grammaire complète, mais sans passer par le builder.

Une champ statique est déclaré et valué à l’initialisation de la classe. Cette technique garantit, de par les spécifications de la JVM, que la construction du moteur (donc la phase coûteuse de compilation) ne sera effectuée qu’une seule fois.

package com.massa.mrules.demos.lifecycle;

import java.util.ArrayList;

import com.massa.mrules.context.execute.MExecutionContext;
import com.massa.mrules.exception.MConfigurationException;
import com.massa.mrules.exception.MExecutionException;
import com.massa.mrules.factory.xml.RichXMLFactory;
import com.massa.mrules.set.IMruleExecutionSet;

/**
 * Launches rules read from complete grammar without builder.
 */
public class CompleteGrammarWithoutBuilderRunner extends AbstractRulesRunner {

    private final static IMruleExecutionSet SET;
    static {
        try {
            SET = new RichXMLFactory().read(ClassLoader.getSystemResourceAsStream("com/massa/mrules/demos/lifecycle/complete.xml"));
        } catch (final MConfigurationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    @Override
    public void runRules(final ArrayList beans) throws MConfigurationException, MExecutionException {
        final MExecutionContext ctxt = new MExecutionContext(CompleteGrammarWithoutBuilderRunner.SET);
        for (final UserBean bean: beans) {
            ctxt.setInput(bean);
            ctxt.execute();
        }
    }
}

La dernière implémentation présentée permet de lire la grammaire simple, selon la même technique de champ statique.

Since the version 1.4.0 of MRules, the simple grammar allows to specify the context factory to use. Until 1.3.0, a compilation context had to be provided to the factory.

Depuis la version 1.4.0 de MRules, la grammaire simple permet de préciser la fabrique de contexte à utiliser. Jusqu’en version 1.3.0, un contexte de compilation devait obligatoirement être fourni à la fabrique.

package com.massa.mrules.demos.lifecycle;

import java.util.ArrayList;

import com.massa.mrules.context.execute.MExecutionContext;
import com.massa.mrules.exception.MConfigurationException;
import com.massa.mrules.exception.MExecutionException;
import com.massa.mrules.factory.xml.SimpleXMLFactory;
import com.massa.mrules.set.IMruleExecutionSet;

/**
 * Launches rules read from simple grammar without builder.
 */
public class SimpleGrammarWithoutBuilderRunner extends AbstractRulesRunner<UserBean> {
    private final static IMruleExecutionSet SET;
    static {
        try {
            SET = new SimpleXMLFactory().read(ClassLoader.getSystemResourceAsStream("com/massa/mrules/demos/lifecycle/simple.xml"));
        } catch (final MConfigurationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    @Override
    public void runRules(final ArrayList<UserBean> beans) throws MConfigurationException, MExecutionException {
        final MExecutionContext<UserBean> ctxt = new MExecutionContext<UserBean>(SimpleGrammarWithoutBuilderRunner.SET);
        for (final UserBean bean: beans) {
            ctxt.setInput(bean);
            ctxt.execute();
        }
    }
}

Lancement

Le lancement se fait par un test JUnit standard, qui va créer 100 Thread par Rules Runner (donc 300 en tout) et les exécuter.

On voit parfaitement bien dans les logs que seules 3 compilations sont effectuées, pour chaque Runner.

package com.massa.mrules.demos.lifecycle;

import java.util.ArrayList;

import org.junit.Assert;
import org.junit.Test;

import com.massa.log.Log;
import com.massa.log.LogFactory;

/**
 * Performs life cycle and concurrency demo.
 */
public class LifeCycleTest {

    private static final Log LOG = LogFactory.getLog(LifeCycleTest.class.getName());

    @Test
    public void runTest() throws Exception {
        LifeCycleTest.LOG.info("Preparing threads.");
        final ArrayList allThreads = new ArrayList(5000);
        for (int i = 0 ; i < 100 ; i++) {
            allThreads.add(new RunnerTestThread(new CompleteGrammarWithBuilderRunner()));
        }
        for (int i = 0 ; i < 100 ; i++) {
            allThreads.add(new RunnerTestThread(new CompleteGrammarWithoutBuilderRunner()));
        }
        for (int i = 0 ; i < 100 ; i++) {
            allThreads.add(new RunnerTestThread(new SimpleGrammarWithoutBuilderRunner()));
        }

        LifeCycleTest.LOG.info("Starting threads.");
        for (final RunnerTestThread t: allThreads) {
            t.setPriority(Thread.MIN_PRIORITY);
            t.start();
        }

        LifeCycleTest.LOG.info("Waiting threads.");
        for (final RunnerTestThread t: allThreads) {
            t.join();
        }

        LifeCycleTest.LOG.info("Testing threads results.");
        for (final RunnerTestThread t: allThreads) {
            if (t.getError() != null) {
                LifeCycleTest.LOG.error("Error in Thread.", t.getError());
                Assert.fail();
            }
        }

        LifeCycleTest.LOG.info("OK.");
    }
}

Le téléchargement du projet de test complet sera bientôt disponible.