Technical Blog

4 Posts authored by: Andrew Lau

The promotion rules engine is one of the most common areas of customization in Elastic Path Commerce. In this article, we'll look at how to add a new rule element to the system. If you're a newcomer to the promotion rules engine, this should give you a pretty good idea of its flexibility. You'll also want to take a look at the developer documentation at http://docs.elasticpath.com/display/EP620DEV/Promotion+rule+engine.

 

One scenario I ran into recently was the ability to offer the fourth of the same item for free, up to a certain limit. OOTB, there are some close matches ("Get n free items of SKU x", "Get n% off item number Y of product Z"), but we want this promotion action to apply to all products in the cart, not just a specific SKU or product. Conceivably, we could add some exception products, but let's not get ahead of ourselves here.

 

This rule action would essentially look like "Get 100% off of item number 4 of the same product up to $100". If you recall, promotions in Elastic Path Commerce are comprised of Rule Elements, which are divided into three types: eligibilities (who can get the promotion), conditions (how they can get the promotion), and actions (what they get). In this case, we need to create a custom parameterized action that will capture the % off, which item to give for free, and the upper limit on the number of items to give. The customization will requires some changes to the core project (to add support for the action and a new discount type) and the Commerce Manager client (for displaying the action and allowing Marketing users to construct promotions with the new action).

 

Core Promotion Changes

Creating our new custom action consists of adding an action, which is the entity persisted in the rule element and rule parameter tables, and also the discount, which is the object that encapsulates the actual discount execution that is called by the Drools engine within the storefront when firing promotion rules during shopping cart updates. On top of this are some configuration activities that are needed to tie the custom classes into the Spring context and existing framework.

 

Adding our new action

We're creating an action called the CartNthSameProductPercentDiscountAction (sorry, not the most creative Bob Dylan-esque naming), which will extend the existing AbstractRuleAction. Our main goal here is to identify this new action as a shopping cart item discount that takes three parameters: the discount percent, which item number to discount, and the overall maximum discount amount. Most of this is mapped below. The part that is interesting is the ruleCode method, which essentially provides the Drools engine with the Drools code to be executed. The Drools code essentially delegates the call to a custom discount, which we will be adding next.

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/rules/impl/CartNthSameProductPercentDiscountActionImpl.java

/*
 * Copyright (c) Elastic Path Software Inc., 2006
 */
package com.elasticpath.domain.rules.impl;
 
import java.math.BigDecimal;
 
import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;
import javax.persistence.Transient;
 
import org.apache.renamed.openjpa.persistence.DataCache;
 
import com.elasticpath.domain.EpDomainException;
import com.elasticpath.domain.rules.DiscountType;
import com.elasticpath.domain.rules.RuleAction;
import com.elasticpath.domain.rules.RuleElementType;
import com.elasticpath.domain.rules.RuleExceptionType;
import com.elasticpath.domain.rules.RuleParameter;
import com.elasticpath.domain.rules.RuleScenarios;
 
/**
 * Rule action that discounts the Nth product with a given UID by the given percentage.
 */
@Entity
@DiscriminatorValue("cartNthSameProductPercentDiscountAction")
@DataCache(enabled = false)
public class CartNthSameProductPercentDiscountActionImpl extends AbstractRuleActionImpl implements RuleAction {
    /**
     * Serial version id.
     */
    public static final long serialVersionUID = 5000000001L;
 
    private static final int MAX_PERCENTAGE = 100;
 
    private static final RuleElementType RULE_ELEMENT_TYPE = RuleElementType.CART_NTH_SAME_PRODUCT_PERCENT_DISCOUNT_ACTION;
 
    private static final String[] PARAMETER_KEYS = new String[] { RuleParameter.DISCOUNT_PERCENT_KEY, RuleParameter.NUM_ITEMS_KEY,
            RuleParameter.DISCOUNT_AMOUNT_KEY };
 
    /** Set of <code>RuleExcetion</code> allowed for this <code>RuleAction</code>. */
    private static final RuleExceptionType[] ALLOWED_EXCEPTIONS = new RuleExceptionType[] { };
 
    private static final DiscountType DISCOUNT_TYPE = DiscountType.CART_ITEM_DISCOUNT;
 
    /**
     * Returns the <code>RuleElementType</code> associated with this <code>RuleElement</code> subclass. The <code>RuleElementType</code>'s
     * property key must match this class' discriminator-value and the spring context bean id for this <code>RuleElement</code> implementation.
     * 
     * @return the <code>RuleElementType</code> associated with this <code>RuleElement</code> subclass.
     */
    @Override
    @Transient
    public RuleElementType getElementType() {
        return RULE_ELEMENT_TYPE;
    }
 
    /**
     * Returns the kind of this <code>RuleElement</code> (e.g. eligibility, condition, action).
     * 
     * @return the kind
     */
    @Override
    @Transient
    protected String getElementKind() {
        return ACTION_KIND;
    }
 
    /**
     * Check if this rule element is valid in the specified scenario.
     * 
     * @param scenarioId the Id of the scenario to check (defined in RuleScenarios)
     * @return true if the rule element is applicable in the given scenario
     */
    @Override
    public boolean appliesInScenario(final int scenarioId) {
        return scenarioId == RuleScenarios.CART_SCENARIO;
    }
 
    /**
     * Return the array of the allowed <code>RuleException</code> types for the rule.
     * 
     * @return an array of RuleExceptionType of the allowed <code>RuleException</code> types for the rule.
     */
    @Override
    @Transient
    public RuleExceptionType[] getAllowedExceptions() {
        return ALLOWED_EXCEPTIONS.clone();
    }
 
    /**
     * Return the Drools code corresponding to this action.
     * 
     * @return the Drools code
     * @throws EpDomainException if the rule is not well formed
     */
    @Override
    @Transient
    public String getRuleCode() throws EpDomainException {
        validate();
        StringBuffer sbf = new StringBuffer();
        sbf.append("\n\t assert(new CartNthSameProductPercentDiscountImpl(\"").append(RULE_ELEMENT_TYPE).append("\", ");
        sbf.append(this.getRuleId()).append(", \"");
        sbf.append(this.getParamValue(RuleParameter.DISCOUNT_AMOUNT_KEY));
        sbf.append("\", \"").append(this.getParamValue(RuleParameter.DISCOUNT_PERCENT_KEY)).append("\", ");
        sbf.append(this.getParamValue(RuleParameter.NUM_ITEMS_KEY));
        sbf.append(", \"").append(this.getExceptionStr()).append("\"));\n");
        return sbf.toString();
    }
 
    /**
     * Checks that the rule set domain model is well formed. For example, rule conditions must have all required parameters specified.
     * 
     * @throws EpDomainException if the structure is not correct.
     */
    @Override
    public void validate() throws EpDomainException {
        super.validate();
 
        BigDecimal discountLimit = new BigDecimal(this.getParamValue(RuleParameter.DISCOUNT_AMOUNT_KEY));
        if (discountLimit.doubleValue() <= 0) {
            throw new EpDomainException("Limit parameter: + " + discountLimit + " must be greater than zero");
        }
        
        BigDecimal discountPercent = new BigDecimal(this.getParamValue(RuleParameter.DISCOUNT_PERCENT_KEY));
        if (discountPercent.doubleValue() > MAX_PERCENTAGE || discountPercent.doubleValue() <= 0) {
            throw new EpDomainException("Invalid discount percent: " + discountPercent + ". Must be greater than 0 and no more than 100.");
        }
    }
 
    /**
     * Return an array of parameter keys required by this rule action.
     *
     * @return the parameter key array
     */
    @Override
    @Transient
    public String[] getParameterKeys() {
        return PARAMETER_KEYS.clone();
    }
 
    /**
     * Must be implemented by subclasses to return their type. Get the <code>DiscountType</code> associated with this RuleAction.
     * 
     * @return the <code>DiscountType</code> associated with this RuleAction
     */
    @Transient
    public DiscountType getDiscountType() {
        return DISCOUNT_TYPE;
    }
}

 

The RuleService needs to have the new action added to the list of valid actions as part of the Spring context.

com.elasticpath.core/WEB-INF/conf/spring/service/service.xml

    <bean id="ruleServiceLocal" class="com.elasticpath.service.rules.impl.RuleServiceImpl">

        <property name="allActions">
            <list>

                <value>cartNthSameProductPercentDiscountAction</value>

            </list>
        </property>

</bean>

 

We are adding a new persistent entity as part of the RuleElement inheritance tree, thus we need OpenJPA to enhance this entity as part of the build, as part of our persistence configuration.

com.elasticpath.core/WEB-INF/src/main/java/META-INF/persistence.xml.vm

        <class>com.elasticpath.domain.rules.impl.CartNthSameProductPercentDiscountActionImpl</class>

And if your class is not in the com.elasticpath package, don't forget to make sure the ant -enhance target (see ant/target/openjpa.xml) is properly configured to include your class.

 

Finally, Elastic Path's bean factory needs to be able to instantiate this action when required. Thus new additions to our Context constants and the bean factory is required.

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/commons/constants/ContextIdNames.java

    /** bean id for implementation of com.elasticpath.domain.rules.CartNthSameProductPercentDiscountAction. */
    public static final String CART_NTH_SAME_PRODUCT_ACTION = "cartNthSameProductPercentDiscountAction";

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/impl/PrototypeBeanFactory.java

        addBean(ContextIdNames.CART_NTH_SAME_PRODUCT_ACTION, "com.elasticpath.domain.rules.impl.CartNthSameProductPercentDiscountActionImpl");

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/rules/RuleElementType.java

    /**
     * Cart Nth same product percent discount action.
     */
    CART_NTH_SAME_PRODUCT_PERCENT_DISCOUNT_ACTION("cartNthSameProductPercentDiscountAction"),
    

Adding our new discount

Discounts are called by the generated Drools code via the promotion rules engine during execution time. You'll notice the constructor signature matches the Drools code generated in the above action. The guts of the doApply action simply counts up to the correct cart item to discount and applies the maximum according to the limit. Certainly not complex logic, and easily testable in an independent fashion.

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/discounts/impl/CartNthSameProductPercentDiscountImpl.java

package com.elasticpath.domain.discounts.impl;
 
import java.math.BigDecimal;
 
import com.elasticpath.domain.discounts.DiscountItemContainer;
import com.elasticpath.domain.discounts.TotallingApplier;
import com.elasticpath.domain.shoppingcart.ShoppingItem;
 
/**
 *
 * 
 */
public class CartNthSameProductPercentDiscountImpl extends AbstractDiscountImpl {
    private final String percent;
    private final int nthItem;
    private final String limit;
 
    /**
     * @param ruleElementType rule element type.
     * @param ruleId the id of the rule executing this action
     * @param limit the maximum amount to discount
     * @param percent the percentage of the promotion X 100 (e.g. 50 means 50% off, 100 means free).
     * @param nthItem the number of items that must be present before one is discounted
     * @param exceptions exceptions to this rule element; to be used to populate the PromotionRuleExceptions.
     */
    public CartNthSameProductPercentDiscountImpl(final String ruleElementType,
            final long ruleId, final String limit, final String percent,
            final int nthItem, final String exceptions) {
        super(ruleElementType, ruleId);
        this.percent = percent;
        this.nthItem = nthItem;
        this.limit = limit;
    }
    
    /**
     * Apply discount when actuallyApply is true, and return total discount amount. 
     * @param actuallyApply true if actually apply discount.
     * @param discountItemContainer discountItemContainer that passed in. 
     * @return total discount amount of this rule action.
     */
    public BigDecimal doApply(final boolean actuallyApply, final DiscountItemContainer discountItemContainer) {
        BigDecimal discountPercent = new BigDecimal(percent);
        BigDecimal discountLimit = new BigDecimal(limit);
        
        TotallingApplier applier = getTotallingApplier(actuallyApply, 0);
        discountPercent = discountPercent.setScale(2, BigDecimal.ROUND_HALF_UP).divide(new BigDecimal(PERCENT_DIVISOR), BigDecimal.ROUND_HALF_UP);
 
        for (ShoppingItem currCartItem : discountItemContainer.getItemsLowestToHighestPrice()) {
            int itemQuantity = currCartItem.getQuantity();
            if (itemQuantity >= nthItem) {
                BigDecimal itemPrice = getItemPrice(discountItemContainer, currCartItem);
                BigDecimal discount = itemPrice.multiply(discountPercent);
                if (discount.compareTo(discountLimit) > 0) {
                    applier.apply(currCartItem, discountLimit, 1);                    
                } else {
                    applier.apply(currCartItem, discount, 1);                    
                }
                recordRuleApplied(discountItemContainer, actuallyApply, getRuleId());
            }
        }
        return applier.getTotalDiscount();
 
    }
}

 

The last piece that ties this together is to add into the Rule Set's Drools code generation an import so that our new Discount class is available in the Drools classpath.

 

com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/domain/rules/impl/RuleSetImpl.java

        IMPORTS.add("com.elasticpath.domain.discounts.impl.CartNthSameProductPercentDiscountImpl");

 

 

Commerce Manager Client Changes

 

The promotion rules editor is actually quite dynamic as long as we are reusing existing parameter types. Custom parameter types that require new UI elements (like custom search finders, or special UI elements beyond the standard text fields, combo boxes and product/category pickers) are obviously going to take a bit more work than this. For our purposes, we only need to add the localizable action template text, and the editor will piece together the parameters automatically.

 

com.elasticpath.cmclient/com.elasticpath.cmclient.store/src/main/com/elasticpath/cmclient/store/promotions/PromotionsMessages.java

public static String CartNthSameProductPercentDiscountAction;

 

Our default English text, matching our proposed action.

com.elasticpath.cmclient/com.elasticpath.cmclient.store/src/main/com/elasticpath/cmclient/store/promotions/PromotionsResources.properties

CartNthSameProductPercentDiscountAction=Get [{0}]% off item number [{1}] of the same product up to $[{2}]

 

Our resulting CM client changes look like this:

screen-capture-5.png

And finally, trying this promotion out for the maximum $100 limit results in the correct cart subtotal.

screen-capture-6.png

 

I hope I've given you a good first glimpse into the promotion rule engine and how easy it is to add custom rule elements. We definitely encourage customers to think out of the box and I'd be curious to see what other extensions are out in the wild. There are some exciting enhancements to promotions coming down the pipe that should make the rule engine even more powerful, so keep tuning in for the latest and greatest.

0 Comments Permalink

Targeted Selling is Elastic Path's personalization engine. It collects data about the shopper from various sources and exposes it for rule-based conditional evaluation. In Elastic Path, this is done via Business User rule configurations. The underlying technology put in place to support Targeted Selling is called the Tagging Framework, enabling the system to perform tag-based customer segmentation.

 

The following diagram shows how the building blocks fit together and form the basis of the various areas of the Storefront that is exposed to a customer for interaction. As you can see, price lists and dynamic content are both driven by these two frameworks.

Architecture Overview.jpg

Be sure to check out the Tagging Framework and Targeted Selling documentation on the Elastic Path documentation site for details. For our purposes, today's blog entry will consist of an example of how we can customize and extend these frameworks to provide rule-driven personalization for a straightforward use case.

 

Aa a Sales Engineer, I frequently get asked to personalize our storefront presentation to demonstrate some scenarios that are specific to a prospective client. Here are two recent examples where I needed to set up the system to be configurable and present specific dynamic content for two different customer personas:

  • Education Pricing: the system should be able to provide education-level pricing that differs according to which school a customer belongs to
  • Membership Levels: the system should be able to provide personalized merchandising and pricing to customers at Bronze, Silver and Gold tiers, as well as Staff or Affiliate pricing

 

Now, I'm quite lazy and would be quite happy to spend my day browsing The Daily WTF. I definitely do not want to start writing new code every time the business/marketing admins want to start segmenting customers via new profile attributes. Therefore, I want this customization to the system to be easily configurable. In this post, we'll look at how I did it.

 

The steps we'll be running through include:

  • Setting up the new customer profile and tagging data to support the customer segmentation
  • Setting up segment specific pricing and dynamic content rules
  • Extending the system to add a configurable Tagger in the storefront to perform the segmentation
  • Testing the new configurable Tagger
  • Tying everything together with Spring

 

Setting Up the Underlying Tag Data
Adding Customer Profile Attributes

I've elected to add two new String-based customer profile attributes via SQL rather than the Commerce Manager client. In this case, we're looking at two custom attributes that are automatically available for editing in the Commerce Manager's Customer editor, as seen below. Optionally, we could have made these attributes System attributes and added custom drop-down boxes with pre-defined values for valid membership levels and schools respectively for the Customer editor. I'll leave that as an optional exercise.

INSERT INTO TATTRIBUTE (UIDPK,ATTRIBUTE_KEY,LOCALE_DEPENDANT,ATTRIBUTE_TYPE,NAME,REQUIRED,VALUE_LOOKUP_ENABLED,ATTRIBUTE_USAGE,SYSTEM,ATTR_GLOBAL)
    VALUES (1000,'CP_MEMBERSHIP',0,1,'Membership Level',0,0,4,0,0);

INSERT INTO TATTRIBUTE (UIDPK,ATTRIBUTE_KEY,LOCALE_DEPENDANT,ATTRIBUTE_TYPE,NAME,REQUIRED,VALUE_LOOKUP_ENABLED,ATTRIBUTE_USAGE,SYSTEM,ATTR_GLOBAL)
    VALUES (1001,'CP_SCHOOL',0,1,'School',0,0,4,0,0);

screen-capture-3.png

 

Adding Custom Tag Definitions and Values

Next we're going to make the Tagging framework aware of new tags for Membership and Schools. We're also restricting the possible allowed values for each respective tag.

INSERT INTO TTAGVALUETYPE(uidpk, guid, java_type) values(20,'school','java.lang.String');

INSERT INTO TTAGVALUETYPE(uidpk, guid, java_type) values(21,'membership','java.lang.String');

INSERT INTO TTAGVALUETYPEOPERATOR(tagvaluetype_guid, tagoperator_guid)
    values('school', 'equalTo'), ('school', 'notEqualTo');

INSERT INTO TTAGVALUETYPEOPERATOR(tagvaluetype_guid, tagoperator_guid)
    values('membership', 'equalTo'), ('membership', 'notEqualTo');

INSERT INTO TTAGDEFINITION(uidpk, guid, name, description, tagvaluetype_guid, taggroup_uid)
    values(30, 'school', 'school', 'school', 'school', 3);

INSERT INTO TTAGDEFINITION(uidpk, guid, name, description, tagvaluetype_guid, taggroup_uid)
    values(31, 'membership', 'membership', 'membership', 'membership', 3);

insert into TTAGALLOWEDVALUE(uidpk, value, tagvaluetype_guid, description, ordering)
     values(42, 'gold', 'membership', 'Gold', 1),
     (43, 'staff', 'membership', 'Staff', 2),
     (44, 'affiliate', 'membership', 'Affiliates', 3);

insert into TTAGALLOWEDVALUE(uidpk, value, tagvaluetype_guid, description, ordering)
     values(45, 'sfu', 'school', 'Simon Fraser University', 1),
     (46, 'ubc', 'school', 'University of British Columbia', 2),
     (47, 'ua', 'school', 'University of Alberta', 3);

 

Adding Localized Content

For completeness, we're adding localized content so that our Pricelist Assignment and Dynamic Content Delivery rule editors will display our new tags appropriately for administrators.

INSERT INTO TLOCALIZEDPROPERTIES (UIDPK,LOCALIZED_PROPERTY_KEY,VALUE,TYPE,OBJECT_UID)
    values (200000,'tagDefinitionDisplayName_en','is from a school','TagDefinition',30);

INSERT INTO TLOCALIZEDPROPERTIES (UIDPK,LOCALIZED_PROPERTY_KEY,VALUE,TYPE,OBJECT_UID)
    values (200001,'tagDefinitionDisplayName_en','has a membership','TagDefinition',31);

INSERT INTO TLOCALIZEDPROPERTIES (UIDPK,LOCALIZED_PROPERTY_KEY,VALUE,TYPE,OBJECT_UID)
    values (200002,'tagDefinitionDisplayName_fr','is from a school','TagDefinition',30);

INSERT INTO TLOCALIZEDPROPERTIES (UIDPK,LOCALIZED_PROPERTY_KEY,VALUE,TYPE,OBJECT_UID)
    values (200003,'tagDefinitionDisplayName_fr','has a membership','TagDefinition',31);

 

Adding Tags to the Tag Dictionaries

The system groups tags into tag dictionaries, such as those for pricing and shopper segmentation. We're adding our new tags into these dictionaries so that they show up in the UI for configuration.

INSERT INTO TTAGDICTIONARYTAGDEFINITION(tagdictionary_guid, tagdefinition_guid)
    values('SHOPPER', 'school');

INSERT INTO TTAGDICTIONARYTAGDEFINITION(tagdictionary_guid, tagdefinition_guid)
    values('PLA_SHOPPER', 'school');

INSERT INTO TTAGDICTIONARYTAGDEFINITION(tagdictionary_guid, tagdefinition_guid)
    values('SHOPPER', 'membership');

INSERT INTO TTAGDICTIONARYTAGDEFINITION(tagdictionary_guid, tagdefinition_guid)
    values('PLA_SHOPPER', 'membership');

 

Configuring the School Pricing

Once this data is all loaded, we can now load up our Price List Assignment editor or Dynamic Content Delivery editor and be able to configure rules based on the new Membership or School tags, such as below.

 

schools.png

 

As such, for a demo I've configured school level pricing for a specific camera to provide a price that is discounted to $150.00 from $199.00. However, this won't show up right away on the storefront because we need to have the School memberships added into a customer's tagset when they are interacting with the storefront.

screen-capture-5.png

 

Creating our own Tagger

To enable the storefront to be aware of the new tags, we're extending the existing CustomerProfileTagger. We're making the custom tagger extensible to automatically add in any configured customer profile attributes into the tagset. Note that the CustomerProfileTagger implements two different listeners for login and session creation events, and thus is executed when these events are fired on the storefront.

package com.elasticpath.sfweb.listeners;
 
import java.util.Iterator;
import java.util.Map;
 
import javax.servlet.http.HttpServletRequest;
 
import org.apache.log4j.Logger;
 
import com.elasticpath.domain.customer.Customer;
import com.elasticpath.domain.customer.CustomerSession;
import com.elasticpath.tags.Tag;
import com.elasticpath.tags.TagSet;
 
/**
 * Applies configured list of tag values into tagset.
 * 
 * @author drewz
 *
 */
public class ConfigurableCustomerProfilerTagger extends CustomerProfileTagger {
 
     private static final Logger LOG = Logger.getLogger(ConfigurableCustomerProfilerTagger.class);
     
     private Map<String, String> profileAttributeTags;
 
     /**
      * Apply configured string-based tags into session's tagset.
      * 
      * @param session instance of CustomerSession
      * @param request the originating HttpServletRequest
      */
     public void execute(final CustomerSession session, final HttpServletRequest request) {
          super.execute(session, request);
          
          if (profileAttributeTags == null || profileAttributeTags.isEmpty()) {
               return;
          }
          
          TagSet tagCloud = session.getCustomerTagSet();
          Customer customer = session.getCustomer();
          
          Iterator<String> iter = profileAttributeTags.keySet().iterator();
          while (iter.hasNext()) {
               String attributeKey = iter.next();
               String tagKey = profileAttributeTags.get(attributeKey);
               String attrValue = customer.getCustomerProfile().getStringProfileValue(attributeKey);
               
               if (attrValue == null) {
                    LOG.debug("Customer attribute " + attributeKey + " not available. Tag value not added to tag cloud.");
               } else {
                    LOG.debug("Adding customer tag " + tagKey + " to tag cloud: " + attrValue);
                    tagCloud.addTag(tagKey, new Tag(attrValue));
               }
          }
     }
     
     public void setProfileAttributeTags(Map<String, String> profileAttributeTags) {
          this.profileAttributeTags = profileAttributeTags;
     }
}

 

Testing the Tagger

No coding is complete without valid test scenarios! In this case, we're testing that any configured profile attributes are added to the tagset correctly, and any missing/empty profile attributes are not added.

/**
 * 
 */
package com.elasticpath.sfweb.listeners;
 
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
 
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
 
import org.jmock.Expectations;
import org.jmock.Mockery;
import org.jmock.integration.junit4.JMock;
import org.jmock.integration.junit4.JUnit4Mockery;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.mock.web.MockHttpServletRequest;
 
import com.elasticpath.domain.customer.Customer;
import com.elasticpath.domain.customer.CustomerProfile;
import com.elasticpath.domain.customer.CustomerSession;
import com.elasticpath.tags.TagSet;
 
/**
 * Tests for CustomerProfileTagger.
 *
 */
@RunWith(JMock.class)
public class ConfigurableCustomerProfileTaggerTest {
 
     private static final String MEMBERSHIP_ATTR_KEY = "MEMBERSHIP";
     private static final String SCHOOL_ATTR_KEY = "SCHOOL";
     private static final String MEMBERSHIP_TAG_KEY = "membership";
     private static final String SCHOOL_TAG_KEY = "school";
 
     
     private final Mockery context = new JUnit4Mockery();
          
     private TagSet tagSet;
     private MockHttpServletRequest request;
     private CustomerSession session;
     private Customer customer;
     private CustomerProfile customerProfile;
     private ConfigurableCustomerProfilerTagger listener;
     
     /**
      * Setting up instances.
      */
     @Before
     public void setUp() {
          tagSet = new TagSet();
          request = new MockHttpServletRequest();
          session = context.mock(CustomerSession.class);
          customer = context.mock(Customer.class);
          customerProfile = context.mock(CustomerProfile.class);
          listener = new ConfigurableCustomerProfilerTagger();
     }
     
     /**
      * Test that configured list of profile attributes are added to the tagset. 
      * Testing two tags/attributes for membership and school.
      */
     @Test
     public void testAddExistingProfileAttributeToTagSet() {
          final String membershipValue = "GOLD";
          final String schoolValue = "SFU";
          
          context.checking(new Expectations() { {
               allowing(session).getCustomerTagSet(); will(returnValue(tagSet));
               allowing(session).getCustomer(); will(returnValue(customer));
               allowing(customer).getCustomerProfile(); will(returnValue(customerProfile));
               allowing(customer).getDateOfBirth(); will(returnValue(new Date()));
               allowing(customer).getGender(); will(returnValue('M'));
               allowing(customerProfile).getStringProfileValue(MEMBERSHIP_ATTR_KEY); will(returnValue(membershipValue));
               allowing(customerProfile).getStringProfileValue(SCHOOL_ATTR_KEY); will(returnValue(schoolValue));
          } });
          
          
          Map<String, String> profileAttributeTags = new HashMap<String, String>(2);
          profileAttributeTags.put(MEMBERSHIP_ATTR_KEY, MEMBERSHIP_TAG_KEY);
          profileAttributeTags.put(SCHOOL_ATTR_KEY, SCHOOL_TAG_KEY);
          listener.setProfileAttributeTags(profileAttributeTags);
          listener.execute(session, request);
          
          assertNotNull("Membership tag was null", tagSet.getTagValue(MEMBERSHIP_TAG_KEY));
          assertEquals("Failed to get the correct membership value from the tagset", 
                    membershipValue, tagSet.getTagValue(MEMBERSHIP_TAG_KEY).getValue());
          assertNotNull("School tag was null", tagSet.getTagValue(SCHOOL_TAG_KEY));
          assertEquals("Failed to get the correct membership value from the tagset", 
                    schoolValue, tagSet.getTagValue(SCHOOL_TAG_KEY).getValue());
     }
     
     /**
      * Test that if a customer profile attribute is missing for a customer, no tag value is added to the tagset.
      */
     @Test
     public void testMissingProfileAttributeToTagSet() {
 
          context.checking(new Expectations() { {
               allowing(session).getCustomerTagSet(); will(returnValue(tagSet));
               allowing(session).getCustomer(); will(returnValue(customer));
               allowing(customer).getCustomerProfile(); will(returnValue(customerProfile));
               allowing(customer).getDateOfBirth(); will(returnValue(new Date()));
               allowing(customer).getGender(); will(returnValue('M'));
               allowing(customerProfile).getStringProfileValue(MEMBERSHIP_ATTR_KEY); will(returnValue(null));
               allowing(customerProfile).getStringProfileValue(SCHOOL_ATTR_KEY); will(returnValue(null));
          } });
          
          
          Map<String, String> profileAttributeTags = new HashMap<String, String>(2);
          profileAttributeTags.put(MEMBERSHIP_ATTR_KEY, MEMBERSHIP_TAG_KEY);
          profileAttributeTags.put(SCHOOL_ATTR_KEY, SCHOOL_TAG_KEY);
          listener.setProfileAttributeTags(profileAttributeTags);
          listener.execute(session, request);
          
          assertEquals("Membership tag was not null", null, tagSet.getTagValue(MEMBERSHIP_TAG_KEY));
          assertEquals("School tag was not null", null, tagSet.getTagValue(SCHOOL_TAG_KEY));
     }
     
     
}

 

Tying it all Together

With our new tagger tested and verified, we now configure it into the system via the Storefront's serviceSF.xml spring configuration by replacing the default customerProfileTagger with our new custom tagger. Note that we're injecting a map of profile attributes that will be added to the tagset.

     <bean id="customerProfileTagger" class="com.elasticpath.sfweb.listeners.ConfigurableCustomerProfilerTagger" >
          <property name="profileAttributeTags">
               <map>
                    <entry key="CP_MEMBERSHIP" value="membership"/>
                    <entry key="CP_SCHOOL" value="school"/>
               </map>
          </property>
     </bean>

 

     Similarly, in the Commerce Manager application we need the UI to be able to handle the new tags, thus in serviceCM.xml we add in our value providers for the Tag Values.

     <bean id="selectableTagValueServiceLocator" class="com.elasticpath.tags.service.impl.SelectableTagValueServiceLocatorImpl">
          <property name="valueProviders">
               <map>
                              <!-- SNIPPED ... -->          
                    <entry><key><value>school</value></key>
                            <bean class="com.elasticpath.tags.service.impl.InternalSelectableStringTagValueProviderImpl"></bean>
                    </entry>
                    <entry><key><value>membership</value></key>
                            <bean class="com.elasticpath.tags.service.impl.InternalSelectableStringTagValueProviderImpl"></bean>
                    </entry>          
               </map>
          </property>
     </bean>

 

Tie this together and run the storefront with a test customer with a school membership, and suddenly we have the school level pricing we configured earlier. Custom customer segmentation is easy-peasy! Next time that marketer asks for new segmentation rules, we just go back to our XML file and then take a nice a KitKat break.

screen-capture-6.png

 

Obviously, there's plenty of room for improvement here. Ideally, we make the tags and UI select membership and schools from pre-defined lists. Also, I'm not too fond of having to edit XML files and redeploying the application to handle changes, so we could move the profile attribute configuration into Elastic Path's Settings Framework so that they can be modified at runtime without having to restart. But this is a good starting point, and hopefully that gives you an idea of how easy it is to extend the Tagging Framework to meet some common pricing and personalization scenarios.

 

Segment away!

0 Comments Permalink

If some of the recent performance-related posts by our much cherished Get Elastic blogger extraordinaire Linda are starting to worry you, fear not! With the newly released Elastic Path 6.2, the Product Development and Performance teams at Elastic Path have done a fantastic job ramping up the standard performance of the product. I'll let them brag about the numbers at a later date, but today's post is to dig into the guts of the caching introduced into the system, and how you can start tweaking some of the configuration settings to squeek out every millisecond for page responses.

 

Within Elastic Path, there are now two caches that are available to be tweaked:

  • Application-level: Sitting between the view layer and the data access layer
  • Persistence-level: A Level-2 cache, within the OpenJPA ORM framework

 

Application Level Cache

All products loaded within the Storefront application via the StoreProductService will be from an Ehcache-backed cache by default. Each application is responsible for loading products via a ProductRetrieveStrategy. You'll notice storefront has two new configurations to facilitate using Ehcache:

 

Cache.xml:
     <bean id="productCache" class="org.springframework.cache.ehcache.EhCacheFactoryBean">
          <property name="timeToLive" value="600"/>
          <property name="timeToIdle" value="600"/>
     </bean>

     <bean id="cachingProductRetrieveStrategy" class="com.elasticpath.sfweb.service.impl.EhCacheProductRetrieveStrategyImpl">
          <property name="productService" ref="productService" />
          <property name="cache" ref="productCache" />
     </bean>

 

ServiceSF.xml:
    <alias name="cachedSettingsReader" alias="settingsReader"/>
    <alias name="cachingProductRetrieveStrategy" alias="productRetrieveStrategy"/>

 

You'll note that the storefront is now using aliases in the Spring configuration to override the same bean definitions in the default core service.xml. This allows the storefront to setup caching-specific classes. For tweaking purposes, the productCache bean definition should be updated to optimize the Ehcache settings. By default, Spring's EhCacheFactoryBean will initialize the cache to allow overflow to disk, use LRU eviction and limit the in-memory size to 10k objects. For catalogs with a larger product mix, these settings should be optimized and potentially moved to an distributed cache via Terracotta to keep the JVM heap size to a reasonable level.

 

You can choose to add new properties here to tweak these values, or add an ehcache.xml configuration file to the classpath and define a specific cache name as part of the productCache definition to use. A quick tip on monitoring the cache statistics for tweaking the settings during load test is to setup JMX monitoring for the storefront cache. This can be done via two steps:

 

Running the appserver with remote JMX enabled (no authentication for a non-production environment):

-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=6969 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false

 

Configuring the JMX beans for Ehcache in cache.xml. Note in this case we are explicitly setting up a cache manager, which we can also use to specify a custom Ehcache configuration file instead of the default ehcache.xml:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd ">

    <bean id="productCache" class="org.springframework.cache.ehcache.EhCacheFactoryBean">
        <property name="timeToLive" value="600"/>
        <property name="timeToIdle" value="600"/>
        <property name="cacheManager" ref="cacheManager" />
    </bean>

    <bean id="cachingProductRetrieveStrategy" class="com.elasticpath.sfweb.service.impl.EhCacheProductRetrieveStrategyImpl">
        <property name="productService" ref="productService" />
        <property name="cache" ref="productCache" />
    </bean>

    <bean id="cacheManager"
        class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean">
    </bean>

    <!-- Spring initialization of ehCache's mbeans -->
    <bean id="ehCacheMBeanRegistration"
        class="org.springframework.beans.factory.config.MethodInvokingFactoryBean">
        <property name="staticMethod"
            value="net.sf.ehcache.management.ManagementService.registerMBeans" />
        <property name="arguments">
            <list>
                <ref bean="cacheManager" />
                <ref bean="mbeanServer" />
                <value>true</value>
                <value>true</value>
                <value>true</value>
                <value>true</value>
            </list>
        </property>
    </bean>

    <bean id="mbeanServer" class="org.springframework.jmx.support.MBeanServerFactoryBean">
        <property name="locateExistingServerIfPossible" value="true" />
    </bean>

</beans>

 

Connecting up with jConsole lets us check the cache settings are properly configured, and to check the statistics, as per the below image. We should see updates to statistics as you browse the storefront and load more products:

screen-capture-1.png

 

 

Persistence Level Cache

As part of the Elastic Path 6.2 release, the included OpenJPA library has been upgraded to a 1.2.1 version, which overcomes some data cache issues in the previous 1.0.1 version. Please see the OpenJPA 1.2.1 documentation for all details on the native OpenJPA datacache. We'll go over some of the changes that enable the data cache using the native implementation.

 

Annotations:

All transactional persistent entities (anything submitted or updated regularly as an online transaction such as orders, payments and customers) have a new data cache annotation so that they are non-cacheable. By default, entities are enabled in the data cache unless this annotation is present and explicitly disabled the caching. All relatively static data, such as catalog entities should be part of the cache and thus will be missing this new annotation.

 

@DataCache(enabled = false)

 

Persistence.xml Configuration:

As part of the persistence unit configuration, three new properties are configured by default:

            <property name="openjpa.DataCache" value="true"/>
            <property name="openjpa.RemoteCommitProvider" value="sjvm"/>
            <property name="openjpa.DataCacheTimeout" value="1000"/>

 

These values are used for:

  • openjpa.DataCache - enabling the cache, and specifying the cache properties. We recommend tweaking this value to accomodate the cache size according to the size of the underlying data: (true(CacheSize=25000, SoftReferenceSize=0))
  • openjpa.RemoteCommitProvider - specifying the commit provider. For a clustered setup, evictions should be handled via configuring this setting to use JMS or TCP-based evictions.
  • openjpa.DataCacheTimeout - maximum time to live for entities in the cache

 

Also suggested is to tune the query cache, which is enabled with default values when the DataCache property is set to true:

<property name="openjpa.DataCache" value="true(CacheSize=25000, SoftReferenceSize=0)"/>
<property name="openjpa.RemoteCommitProvider" value="sjvm"/>
<property name="openjpa.QueryCache" value="CacheSize=25000, SoftReferenceSize=0"/>

 

Tip: Catching Cache Hits and Misses

If caching is enabled and you're still seeing a large amount of database queries from the storefront server, you can enable data cache Log4j tracing and grep out the hits and misses logged to track down which entities and queries are mistakingly hitting the database. Most of the times, these can be tracked down to accidental evictions or entities within the inheritance hierachy being disabled from cache.

 

log4j.category.openjpa.DataCache=TRACE

 

This spits out some logs similar to below that's easily greppable to count and track which entities are creating problems:

DEBUG 2009-09-10 12:56:33,918 org.apache.renamed.openjpa.lib.log.CommonsLogFactory$LogAdapter.trace(CommonsLogFactory.java:76) - Cache hit while looking up key "com.elasticpath.domain.attribute.impl.ProductTypeProductAttributeImpl-1".
DEBUG 2009-09-10 12:56:33,926 org.apache.renamed.openjpa.lib.log.CommonsLogFactory$LogAdapter.trace(CommonsLogFactory.java:76) - Cache miss while looking up key "org.apache.renamed.openjpa.datacache.QueryKey@b7ee9cab[query:[SELECT ps.skuCodeInternal FROM ProductSkuImpl ps

 

OpenJPA Cache Plugins

The native data cache within OpenJPA is architected to be swappable via plugin configuration. This allows the ability to swap in alternative caching technologies like Ehcache or Oracle Coherence to support extensive scalability requirements, as both Ehcache/Terracotta and Oracle Coherence support distributed caching setups. We've tested Ehcache and Coherence plugins internally with favourable results.

 

An Ehcache plugin is provided by the Ehcache group, however this version must be repackaged to match the custom package names of the Elastic Path specific org.apache.renamed.openjpa jar. Once a repackaged instance of the Ehcache-OpenJPA jar is in the classpath, the configuration changes to:

<property name="openjpa.DataCacheManager" value="ehcache"/>

 

Similarily enough, OpenJPA commiter Pinaki has posted the initial workings of an Oracle Coherence plugin on his blog, along with some additional JPA caching insights. Elastic Path with OpenJPA and Oracle Coherence is probably worth an entire blog entry in itself (coming soon!), but the same configuration settings apply, along with the need to repackage the code to point at the Elastic Path OpenJPA jar:

 

<property name="openjpa.DataCacheManager" value="coherence"/>

 

So there it is, two new caching mechanisms to tweak and fine-tune as part of load tests that should favourably reflect storefront page load times, and hopefully conversion rates and green dollar signs. Feel free to ask away about some of our load testing and tuning war stories. We'd be happy to talk about our hands on experience with the recent caching work.

6 Comments Permalink

Alternative title: How to Make Your Life Easier and Not Go Bald from Pulling Your Hair out Trying to Keep Current on a Constantly Evolving and Shifting Codebase

 

This is probably the biggest and most avoided question from all of our successfully launched Professional Services projects. How can we upgrade to the greatest and latest on our heavily customized EP project? Let's be clear that performing a point release upgrade, or even consuming the latest sprint of code from our extremely productive Product Development group isn't trivial. A full version upgrade is even more daunting and usually heavily balanced against the cost of re-implementing from scratch.

 

First off, hopefully you are following our advice to try to avoid customizing EP code as far as possible, as noted in our documentation and tutorials. Following this methodology will make your life a lot easier, as upgrading core EP classes that have only minor changes will be a lot easier.

 

With that in mind, the best method through tried and tested experience, from brute force patching and merging, to re-implementing large customizations, is to take advantage of the merge tools from your chosen source control software (you are using source control I hope! If not, this book will be your best friend). Why do all the manual merge work when X does it so well already (X being subversion, perforce, cvs, vss, git, the next big flavor of the month). The key to leveraging your source control software to facilitate an upgrade is the concept of a vendor branch.

 

Working with a vendor branch is straight forward. This is applicable to any 3rd-party library you are using and modifying, not just EP. Take a quick gander at these various interweb sites for a better explanation than i can reasonably provide. For our purposes, a vendor branch for EP is an untainted, pristine copy of EP source. Don't just take our beloved zip of source code and stash it in your network drive! Check it in and show it off! You can branch your customized codeline off the vendor branch and start working away with the enthusiasm of a frenzied squirrel in the spring. The next time a new version of EP comes along, simply drop it directly on top of the current vendor branch of EP in your repository. With this new EP revision, you can perform your normal merge routine of just the diff between these EP revisions and merge the delta of changes into your customized eCommerce solution. And, if you have been following the procedures to avoid modifying EP source, conflicts should be kept to a minimum, files will just resolve themselves, and you can go out for a quick pint. Sure sounds easy huh? But how about in real life?

 

Well, let's see how we've taken this and made our lives easier at a client site in Perforce. I'm not the biggest Perforce fan, but I sure appreciate it's advanced branching and visual merging capabilities. We've set this up as follows:

 

  • EP_IMPORT, our EP vendor branch containing 100% pure EP 6.1 eCommerce goodness
  • Stable - our main development branch, full of customizations of the greatest and latest variety

 

Now, presume we're taking in change after massive change from our Product Development group. They've given us EP 6.1.1! Hooray! But how do we get this into our source control? Looks easy enough. Drop it on top of EP_IMPORT and check it in? Whoa, hold on there, code cowboy. This baby probably won't compile just with that.

 

The main things to remember is to collect three things with each new drop of code:

 

  1. All files that have been updated in 6.1.1
  2. All files that have been deleted in 6.1.1
  3. All files that have been added in 6.1.1

 

Once you have all three, you can mark the relevant files for delete, add and update. I've taken a short cut to calculate the files that fall into the above three buckets in this case. I have access to our internal subversion repository at EP, so I'm just going to take a log of files taken in by 'svn update' when updating from the 6.1 tag and the new 6.1.1 tag. You can essentially calculate the same bucket of files using the diff command. But in my case I'm just taking a shortcut and doing:

 

svn update > merge.txt

 

This logs out for us a lot of lines of this variety:

U    com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/service/rules/PromotionRuleDelegate.java
D    com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/service/misc/impl/WorkAroundOpenJPAFetchPlanHelperImpl.java
A    com.elasticpath.core/WEB-INF/src/main/java/com/elasticpath/service/misc/impl/OpenJPAEventListeningFetchPlanHelperImpl.java

 

With some help from sed (for the non-Unixers, feel free to grab and install cygwin, or your favorite port of the beloved Unix command line tools), I'm going to parse the log and split it into three files:

cat merge.txt | sed \-e '/D /d' | sed \-e '/U /d' | sed \-e 's/A[ ]*//' > adds.txt
cat merge.txt | sed \-e '/A /d' | sed \-e '/U /d' | sed \-e 's/D[ ]*//' > deletes.txt
cat merge.txt | sed \-e '/A /d' | sed \-e '/D /d' | sed \-e 's/[ ]*[U]*[ ]*//' > updates.txt

 

Now, Perforce is a bit finicky in that I need to mark which files I'm going to edit to remove the read-only permissions of relevant files. Sure, I can mark every file for editing, but let's play it safe:

pwd
/perforce/depot/branches/EP_IMPORT/
cat updates.txt | xargs p4 edit -f

 

Next, time to mark the deleted files for removal:

cat deletes.txt | xargs p4 delete -f

 

Finally, it's time to unzip on top all the files from the new EP 6.1.1 source zip and mark our new files for add:

tar -xvzf EP6.1.1_Export.tgz .
cat adds.txt | xargs p4 add -f

 

That does it! Now check that all in! Now we have EP 6.1.1 in our EP_IMPORT branch. Time to use the impressive P4V tool from Perforce to integrate from the EP_IMPORT branch into the Stable branch. Since we've avoided modify EP source as much as possible, the majority of the changes should auto-resolve. Clean up some of the conflicts and tweak the customizations as necessary, run the normal routine of compile, checkstyle, pmd, unit tests, integration tests and your project should now be ready to go. Okay, that's a gross over-simplication of the conflict resolution and regression testing process that goes hand in hand with a version upgrade, but hopefully you see how a vendor branch does wonders in facilitating an easier upgrade process with custom code. No more awkward patch files and brute force.

 

Assuming you don't have access to our subversion to get a hold of the list of changes, you can use diff to log file differences and files missing from the 6.1 and 6.1.1 codebases.

 

diff -r /perforce/depot/branches/EP_IMPORT /tmp/EP611/ > diffs.txt

 

This should give you a nice log that is easily parsed into the same add/delete/update buckets. So now you're freshly armed to do the upgrade that has been haunting you late at night. Vendor branches and merge tools are your new best friends! Happy upgrading and merging.

 

Drew

0 Comments Permalink