Développer vos Addons

Nous allons voir ici comment développer votre propre Addon, à travers un exemple simple mais réaliste. Le cas est le suivant : un site de e-commerce souhaite, selon certaines conditions, générer un code de réduction pour un client. Ce code peut être un montant fixe ou un pourcentage de réduction.

Pour générer le code lors de l’exécution du moteur de règles, la solution idéale est de développer un Addon spécifique, opération rapide et simple car tous les outils sont fournis pour en faciliter la réalisation.

Dans le cas de cet exemple, l’Addon sera du type Accessor de lecture (IReadAccessor), c’est à dire un composant permettant de récupérer une donnée.

Il acceptera quatre paramètres de configuration :

  • L’ID client auquel le code est rattaché
  • Un type (pourcentage ou montant)
  • Une valeur
  • Une durée de validité éventuelle

Commençons par créer le squelette de l’implémentation. Pour ceci, il est possible d’implémenter directement l’interface ou d’utiliser la classe abstraite AbstractReadAccessor, donnant un certain nombre de briques permettant de simplifier le code final.

Le code ci-dessous donne la trame de la classe, avec les champs de propriétés de configuration, les getter / setter associés ainsi que le code des méthodes les plus simples.

package com.massa.mrules.demos.site;

// imports

/**
 * Simple example of a Read Accessor implementation.
 * Extends {@link AbstractReadAccessor} which provides a useful base.
 */
@PersistantProperties(properties={
		//Declare here all properties that must or might be provided by configuration.
		@PersistantProperty(property="clientId"),
		@PersistantProperty(property="type"),
		@PersistantProperty(property="value"),
		@PersistantProperty(property="validity")
})
public class DiscountGenerator extends AbstractReadAccessor {

	/** All addons are Serializable. */
	private static final long serialVersionUID = 1L;

	/** Unique ID is defined as a Constant. */
	public static final String DISCOUNT_GENERATOR_ID = "DISCOUNTGEN";

	/** Mandatory: client ID. */
	private IReadAccessor clientId;

	/** Mandatory: PERCENT or AMOUNT. */
	private String type;

	/** Mandatory: Discount value. */
	private Double value;

	/** Optional: validity (in days). */
	private Integer validity;

	/**
	 * TODO: Compilation
	 */
	@Override
	public void compileValue(final ICompilationContext ctxt, final Class<?> expectedType) throws MRuleValidationException {
	}

	/**
	 * TODO: Compilation
	 */
	@Override
	public <G, C extends Collection> void compileCollection(final ICompilationContext ctxt, final Class expectedType,
			final Class expectedGenericType) throws MRuleValidationException {
		throw new MRuleValidationException(new MessageInfo(MRulesMessages.MRE_VALUE_ONLY_ACCESSOR), this);
	}

	/**
	 * TODO: Execution
	 */
	public Object get(final IExecutionContext ctxt) throws MAccessorException {
	}

	/**
	 * TODO: Cost
	 */
	public CostInfo getEstimatedReadCost(final ICompilationContext ctxt) throws MRuleValidationException {
	}

	/**
	 * As a discount code is always a String, directly returning this type.
	 */
	public Class<?> getType(final IContext ctxt) throws MAccessorException {
		return String.class;
	}

	/**
	 * No Collection is handled here.
	 */
	public Class<?> getGenericCollectionType(final IContext ctxt) throws MAccessorException {
		return null;
	}

	/**
	 * Returns the unique identifier which is defined also as a Constant.
	 */
	public String getImplementationId() {
		return DiscountGenerator.DISCOUNT_GENERATOR_ID;
	}

	/**
	 * Builds a human readable representation using all input values.
	 */
	public void toString(final OuterWithLevel outer) throws IOException {
		outer.write(this.getImplementationId()).write('(').write(this.type).write(", ").write(this.value).write(", ").write(this.validity).write(')');
	}

	/**
	 * No collection handled, no iteration will be necessary.
	 */
	public boolean isShouldIterate() {
		return false;
	}

	/**
	 * Returns a clone of this instance. Only the clientId sub value should be cloned in our case.
	 */
	@Override
	public DiscountGenerator clone() {
		final DiscountGenerator ret = (DiscountGenerator) super.clone();
		ret.clientId = MAddonsUtils.cloneAddon(this.clientId);
		return ret;
	}

	/**
	 * No caching should be used here, as a new discount code should be generated at each call.
	 */
	public boolean isCacheUsed() {
		return false;
	}

	//////////////////////////////////////////
	////////// Getter / Setter part //////////
	//////////////////////////////////////////

	public IReadAccessor getClientId() {
		return this.clientId;
	}

	public void setClientId(final IReadAccessor clientId) {
		this.clientId = clientId;
	}

	public String getType() {
		return this.type;
	}

	public void setType(final String type) {
		this.type = type;
	}

	public Double getValue() {
		return this.value;
	}

	public void setValue(final Double value) {
		this.value = value;
	}

	public Integer getValidity() {
		return this.validity;
	}

	public void setValidity(final Integer validity) {
		this.validity = validity;
	}
}

Cette trame étant réalisée, il est nécessaire maintenant de réaliser le code des quatre méthode restantes (commentées en TODO) :

  • Deux méthodes de compilation. Celle gérant les Collections d’objets n’est pas pertinente pour ce cas, elle sera donc gérée en renvoyant une exception. La compilation pour les objets unitaire fera elle la validation des données de configuration.
  • La méthode d’exécution: appellera un Helper permettant de générer le code, réutilisable par d’autres modules de l’application potentiellement.
  • La méthode d’évaluation du coût d’exécution, utilisée pour optimiser l’ordre des appels.
	/**
	 * Here, the compilation will check the input validity and compile the client ID sub accessor.
	 * Note the last instructions, which allows to improve memory impact by deduplicating identical compiled objects.
	 */
	@Override
	public void compileValue(final ICompilationContext ctxt, final Class<?> expectedType) throws MRuleValidationException {
		if (this.clientId == null) {
			throw new MRuleValidationException(new MessageInfo(MRulesMessages.MRE_NULL_PARAMETER, "clientId"), this);
		}
		if (this.type == null) {
			throw new MRuleValidationException(new MessageInfo(MRulesMessages.MRE_NULL_PARAMETER, "type"), this);
		}
		if (this.value == null) {
			throw new MRuleValidationException(new MessageInfo(MRulesMessages.MRE_NULL_PARAMETER, "value"), this);
		}
		if (!"PERCENT".equals(this.type) && !"AMOUNT".equals(this.type)) {
			throw new MRuleValidationException(new MessageInfo(DiscountGeneratorMessages.INVALID_TYPE, this.type), this);
		}
		if ("PERCENT".equals(this.type) && (this.value > 100 || this.value < 0)) {
			throw new MRuleValidationException(new MessageInfo(DiscountGeneratorMessages.INVALID_PERCENT, this.value), this);
		}
		if ("AMOUNT".equals(this.type) && this.value < 0) {
			throw new MRuleValidationException(new MessageInfo(DiscountGeneratorMessages.INVALID_AMOUNT, this.value), this);
		}
		if (this.validity != null && this.validity <= 0) {
			throw new MRuleValidationException(new MessageInfo(DiscountGeneratorMessages.INVALID_VALIDITY, this.validity), this);
		}
		this.clientId.compileRead(ctxt);
		this.clientId = MAddonsUtils.deduplicate(ctxt, this.clientId);
	}

	/**
	 * It's unuseful here to handle returning Collections. So this compilation access point can be ignored, and simply
	 * handled with a dedicated error message indicating that this accessor only returns single values.
	 */
	@Override
	public <G, C extends Collection> void compileCollection(final ICompilationContext ctxt, final Class expectedType,
			final Class expectedGenericType) throws MRuleValidationException {
		throw new MRuleValidationException(new MessageInfo(MRulesMessages.MRE_VALUE_ONLY_ACCESSOR), this);
	}

	/**
	 * The main code of your Addon is here. It will generate the discount code (using for example an external Helper) and return
	 * the result to the caller.
	 */
	public Object get(final IExecutionContext ctxt) throws MAccessorException {
		final String cId = (String) this.clientId.get(ctxt);
		if ("AMOUNT".equals(this.type)) {
			return DiscountGeneratorHelper.generateAmountDiscount(cId, this.value, this.validity);
		} else {
			return DiscountGeneratorHelper.generatePercentDiscount(cId, this.value, this.validity);
		}
	}

	/**
	 * CostInfo is just an indication to reorder actions in optimization phase. Here, we consider
	 * that the fact of generating the code costs "one operation", and we add it to the cost of retrieving
	 * the client ID.
	 */
	public CostInfo getEstimatedReadCost(final ICompilationContext ctxt) throws MRuleValidationException {
		final CostInfo ret = new CostInfo(1);
		if (this.clientId != null) {
			ret.addEstimatedCost(this.clientId.getEstimatedReadCost(ctxt).getEstimatedCost());
		}
		return ret;
	}

On notera dans la méthode de validation la présence de message d’erreur dédié à l’Addon. Ces messages doivent être déclarés dans une classe Java et implémenter l’interface MessageCode.

package com.massa.mrules.demos.site;

//imports

/**
 * All message codes for the discount generator addon.
 */
public final class DiscountGeneratorMessages {

	public static final MessageCode INVALID_TYPE = new MessageCodeFromField();
	public static final MessageCode INVALID_PERCENT = new MessageCodeFromField();
	public static final MessageCode INVALID_AMOUNT = new MessageCodeFromField();
	public static final MessageCode INVALID_VALIDITY = new MessageCodeFromField();

	//Initialization of messages. Note the "discountgen" prefix for message keys.
	static {
		MessageCode.initMessages(DiscountGeneratorMessages.class,
				ResourceBundle.getBundle("com/massa/mrules/demos/site/DiscountGeneratorMessages"),
				"discountgen");
	}

	private DiscountGeneratorMessages() {
	}
}

Puis également dans un fichier d’internationalisation, nommé ici DiscountGeneratorMessages.properties :

discountgen.INVALID_TYPE=Type must have one of these two values: 'AMOUNT' or 'PERCENT'. Found '{0}'.
discountgen.INVALID_PERCENT=Percentage must be between 0 and 100. Found '{0}'.
discountgen.INVALID_AMOUNT=Amount must be superior to 0. Found '{0}'.
discountgen.INVALID_VALIDITY=Validity must be superior to 0. Found '{0}'.

Egalement, le Helper de test associé à l’exemple et appelé dans la méthode « get » :

package com.massa.mrules.demos.site;

/**
 * A class generating an arbitrary code, just for the example.
 */
public final class DiscountGeneratorHelper {

	private static int IDX = 1;

	public static String generatePercentDiscount(final String clientId, final double percent, final Integer validity) {
		return "P" + DiscountGeneratorHelper.IDX++ + "DISC-" + clientId + (validity == null ? "" : "-" + validity);
	}

	public static String generateAmountDiscount(final String clientId, final double amount, final Integer validity) {
		return "A" + DiscountGeneratorHelper.IDX++ + "DISC-" + clientId + (validity == null ? "" : "-" + validity);
	}
}

Enfin, n’oublions pas de déclarer cet Addon afin qu’il puisse être chargé au lancement. Pour ceci, il est nécessaire de créer un fichier mrules-addons.xml directement accessible dans le CLASSPATH et d’y déclarer l’Addon. L’information de priorité permet d’ordonner le chargement de ces fichiers, afin de pouvoir surcharger des déclarations (notamment concernant les formats de conversions de données).

<?xml version="1.0" encoding="UTF-8" standalone='no' ?>
<!DOCTYPE mrulesaddons SYSTEM "mrules-addons.dtd">
<mrulesconfig priority="1">
    <addon class="com.massa.mrules.demos.site.DiscountGenerator" />
</mrulesconfig>	

Un simple lanceur main permet de tester cet exemple :

package com.massa.mrules.demos.site;

// imports

public class DiscountGeneratorMain {
	public static void main(final String[] args) throws Exception {
		final Map<String, String> input = new HashMap<String, String>();
		input.put("customerId", "CUST-ID");
		final MCompilationContext<Map<String, String>> compctxt = new MCompilationContext<Map<String, String>>();
		compctxt.setInput(input);
		final RichXMLFactory factory = new RichXMLFactory(compctxt);
		final IMruleExecutionSet eng = factory.read(ClassLoader.getSystemResourceAsStream("DiscountGenerator.xml"));

		final MExecutionContext<Map<String, String>> exectxt = new MExecutionContext<Map<String, String>>(eng);
		exectxt.setInput(input);
		exectxt.execute();
	}
}

En conclusion, nous avons vu ici les étapes nécessaires à la création d’un Addon personnalisé, afin d’adapter la librairie MRules aux besoins métiers de l’application dans laquelle elle est intégrée. Grâce à ce système d’Addon simple à mettre en place, toutes les possibilités sont ouvertes aux développeurs pour intégrer facilement et rapidement tout type de comportements.

Le code source complet de l’exemple peut être téléchargé ici.