Introduction
In this tutorial, we’ll see how to use the MRules rule engine, by performing the necessary steps:
- Defining requirements
- Concieving Object model
- Configuring the engine. We’ll write here the configuration for the two XML grammar
- Simple
- Complete
- Executing the engine in a multi thread environment
- Writing code allowing to read configuration and to execute rules.
- Writing test code allowing to generate business case and simulate concurrent environment.
Business case
The goal here is to illustrate usage. So a case with a relatively simple configuration will be studied.
When subscribing to a web site, a user will recieve a welcome gift. The gift is chosen depending on user’s characteristics. Criterias and gifts evolve on a regular basis, so the rule engine will be responsible to read relevant data and attribute the correct gift.
User characteristics are:
- Birth date, from which is deduced his age
- Gender
- Town
Rules are:
- A man less than 25 years old recieves a t-shirt
- A woman less than 30 years old recieves a swimsuit
- A user more than 60 years old recieves a foie gras box
- Other users recieve a bottle of wine
Object model
The object model is really simple here. A single Java bean will hold input and output data.
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; } }
Rules configuration
Simple XML grammar configuration is the following:
<?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>
Complete XML grammar configuration is the following:
<?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>
Testing in a concurrent environment
Preparing
First, we’ll write two utility classes allowing to simulate multi threaded environment and to generate test cases.
The Rules Runner is an abstract class allowing to execute the rule engine on a list of beans. Implementations will hold the code dedicated to MRules invocation.
They might then be calles by a batch, a Servlet or a unit test like here, it does not change their purpose.
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; }
The RuleTestThread is a Thread which will create the test cases, execute the rule engine using an AbstractRulesRunner implementation and test results. If an Exception is thrown, it will be stored for further usage. This class will allow to simulate the concurrent environment.
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; } }
Implementation
Here, we’ill write the code which will effectively read configuration and execute the engine. Three possible implementations are proposed and compared here.
The main goal in this tutorial is to clarify target usage of the Rule Set:
- The Rule Set instance may (and should) be unique. In that case, it’s shared between all Threads. Configuration reading and compilation phases only have to be performed more than once if configuration is modified.
- The Execution Context is dedicated to one execution at a time an can’t be shared between processes. However, each Thread instanciates only one context and reuses it, as within it executions are not simultaneous and concurrent.
The first implementation allows to read the complete XML grammar, by using the centralized MRulesBuilder utility. Parameters must be provided to the builder, which will be responsible to build once and only once the instance, even if calls are concurrent.
Parameters are the same as when declaring the rule engine as a JNDI resource or injecting it with CDI.
The advantages of this code, even if slighlty more verbose, is that if configuration file is modified, it will be automatically reloaded thanks to the “checkhash” parameter.
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(); } } }
The second implemetation also allows to read complete grammar, but without using the builder.
A static field is declared in valued at class initialization. This technique guarantees, because of JVM specifications, that the engine building phase (including the compilation phase) will only be performed once.
But if configuration changes, it will not be reloaded, except if a specific system is developped manually.
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(); } } }
The last implementation allows to read the simple grammar, with the same static field technique.
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.
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(); } } }
Execution
Launching will be done using a standard JUnit test, which will create 100 Threads per Rules Runner (so 300 in total) and execute them.
Logs show clearly that only 3 compilations are performed, one per 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."); } }
The test project will be available soon for download.