我在几个电子商务的项目中碰到这样的问题,网站因频繁推出各种商业促销活动,并随着活动临近上线,技术开发人员不得不着急添加新的代码或修改程序以满足新活动的要求。更“可怕”的是,这些活动上线的时机恰逢在周六周日和重要节假日,开发人员和测试人员苦不堪言。记得七月份连续几个周六日,网站接连上了三个促销活动:(一) 指定一些商品满三百减一百;( 二 ) 在活动一的基础上再全场积分五十倍返还;( 三 ) 钻石用户在以上活动基础上再全场商品七五折。促销活动的流程是这样的,活动的期间、参与活动的商品范围和活动规则由市场和运营部一起定制,由技术部门实现和测试,最后由市场和运营进行验收,然后部署到生产环境。但由于活动规则的无规律性,技术部门在设计活动时很难做到扩展性和复用性俱佳的设计,调整程序是不可避免的,使得每次上活动必须相应调整很多代码。经初步分析,程序调整将涉及到商品展示,购物车和下订单,这些调整几乎涉及到了网站的所有展示前台。这对技术部门提出了一个不小的挑战。
从下图中我们可以看到活动实施前后的主要阶段和所花费的人力和时间。
图 1. 传统实现方式的周期示意
以上过程中开发和测试所花的时间将是整个过程中最长的,压力无疑也是最大的。为了“挽救”这种对技术部门的不利局面,技术部门提出了一种新的解决办法:将各种活动规则由运营人员“翻译”成公式,开发人员提供一个能影响活动结果的公式“插件”,“插入”到需要受活动影响的地方。
从下图中我们可以看到按新的解决方案执行前后的主要阶段和所花费的人力和时间。相对先前的模式,公式的编写和测试的周期相对技术部门的开发周期要短很多,所以不会导致市场和运营部门过大的压力,但是对运营者角色提出了更高的要求,他们需要学习掌握怎样编写公式。但相对于整体实现周期的大大缩短,这些付出是很值得的。
图 2. 新的实现方式的周期示意
我们会注意到 Sun JDK6+ 增加了对脚本语言的支持 (JSR 223),实现包含了一个基于 Mozilla Rhino 的 脚本语言引擎(支持 JSR 223 规范),即支持 JavaScript,如果有必要甚至可以自己写一个与 JSR 223 兼容的其它脚本语言引擎,如 Ruby 等。JDK6+ 支持脚本的基本原理是将脚本语言在运行时编译成 bytecode,因此脚本上下文能融入到 JVM 环境中,即能使用或改变 Java Bean 的状态。热心的同学还会注意到一些其它开源项目如 DynamicJ, BeanShell 等都能达到类似的目的。脚本的动态性和强大的功能自然能满足各种复杂的需求,但在实际操作环节,让市场和运营人员掌握脚本语言会花费巨大的学习成本,而且按照技术开发人员的要求去要求运营人员也不太实际。另外,如果使用脚本引擎,我们可能只会用到脚本引擎中不到百分之一的功能和特性,但如果使用自定义公式系统,我们可以确保公式的解析和执行效率都是程序员可控的,整体效率会比脚本引擎高很多。因此我们非常有必要自定制一套高效的公式系统。
- 表达式运算(赋值,运算和逻辑),表达式中支持函数,变量
- 支持控制结构:if condition1 {}else if condition2{} else if .. else{}
- 支持循环控制结构:while(condition){loop}
- 支持/**/注解
- 支持从外部注册函数,函数支持参数数目动态可变
- 支持与外部交换公式变量(注:这里的外部指运行公式的具体项目环境)
实现以上目标后我们就能对类似如下的几行公式进行处理:
清单 1. 公式示例
/* 订单金额超过 1000 送 500 元优惠券 */
IF [S_ORDERSUM] - 1000 > -0.001 {giveCoupon([MB_ID],200910);}
/* 送积分 */
givePoint([MB_ID],[S_POINTSUM]);
|
通过公式解析执行后能执行这样一段业务:参加某活动的订单金额满 1000 元的送 500 元优惠券 , 积分 100 倍返还。
围绕我们的目标,我列出了以下几个主要实现步骤:
-
解析
扫描被解析字符串,将中缀式(便于人类理解的)转化为后缀式(便于计算机“理解”), 拆解为最小运算单元,然后将拆解的运算单元压入队列。在这个过程中需要提前确定运算符号和逻辑符号,空符号(’ ’ , ’ \t ’ , ’ \n ’), 以及算符优先级。在掌握了相关数据结构和编译原理的基础知识后,我们就不难理解怎样做到将目标文本翻译成由最小运算单元组成的后缀式(又称为逆波兰式)了。根据对公式系统定制的实现目标,我们首先归纳出扫描关键字,自定义项目所需的运算符,以下列出一些常见的运算符:+( 加 ),-( 减 ),*( 乘 ),/( 除 ),=( 等 ),>( 大于 ),<( 小于 ) >=( 大于等于 ),<=( 小于等于 ),!=( 不等 ),(( 左括号 ),)( 右括号 ),!(not),&(and),|(or),==( 逻辑等 ),=( 赋值 )
自定义一些常用函数:
- isEmpty 判断字符串是否空
- decode switch..case.. 的代替函数
- length 求字符串长度
- upper 将字符串转化为大写
- lower 将字符串转化为小写
- indexOf 得到指定的子字符串在字符串中的位置
- substring 求子字符串
- 还有其它一些常用的函数,如数学函数 floor,round 等
- 后面我们会提到用如何根据项目需要 扩展函数
为了识别 操作数,我们定义一组起始符和终止符,起始符:
- '\"' // 字符串起始
- ’ \ ’’ // 字符串起始
- ’ [ ’ // 变量起始
- ’ # ’ // 日期起始
- 我们还可以根据使用习惯定义其它的操作数起始符
终止符:
- '+', '-', '*', '/', '(', ')', '<', '>', '\n', '\t', ' ', '!', '&', '|'
根据以上定义,我们对被解析字符串逐字符扫描,识别出 操作数和 操作符,并存入队列,ExecutionItem 是队列中封装的元素类型:
public class ExecutionItem { private String itemString;// 字符串形态 private int itemType;// 类型,见下面的类型定义 private int itemOperator;// 操作符类型(如果 itemType 是 itOperator 的话) private List itemParams;// 参数 }
类型定义如下:
public class ItemType { public final static int itUnknow =0;// 未知类型 public final static int itString =1;// 字符串 public final static int itDigit =2;// 数值 public final static int itDate =3;//Date public final static int itVariable =4;// 变量 public final static int itFunction =5;// 函数 public final static int itOperator =6;// 算符 public final static int itBool =7;//Boolean }
-
运算
对后缀式队列中的运算单元进行计算。我们借助堆栈这种数据结构能很方便地实现运算处理。我们封装了 MetaElement 对象作为计算过程中的运算最小单位。
清单 4. 对运算的中间结果操作数的封装类public class MetaElement { public int valueType; public Object value; public Object params; private VariantContext ctx;/* 当 valueType 为变量类型时,ctx 作为变量上下文 */ /* 构造 */ MetaElement(Object); MetaElement(Object, Object, VariantContext); /* 主要方法 */ public String toString(int, Object){}; public String toString(){}; public Boolean getAsBoolean(int, Object){}; public Boolean getAsBoolean(){}; public Integer getAsInt(int, Object){}; public Integer getAsInt(){}; public Double getAsDouble(){}; public Long getAsLong(){}; public Long getAsLong(int, Object){}; public Double getAsDouble(int, Object){}; public Date getAsDateTime(){}; public Date getAsDateTime(int, Object){}; public String getAsString(){}; public boolean equals(Object){}; }
-
处理异常
一个完整的公式系统必须得有一套完备的异常体系来支撑,异常体系的设计好坏决定了公式系统的可用性。因此我们有必要分别为解析过程定义一套解析时异常,为运算过程定义一套运算时异常类。完备的公式异常使公式调试、测试更加轻松,让公式系统更加完整可靠。 - 具备一定的数据结构和编译原理方面的基础知识,我们不难实现上面的过程 , 由于涉及的代码太多,本文不一一列出。
-
建立公式帮助类
最后为方便在实际项目环境中运用公式系统,我们还建立了一个公式帮助类 FormulaUtil,以方便处理各种存在形式的公式。为增强公式的表达力,引入了对 if condition1 {} else if condition2{} else if .. else{} 的控制结构的支持 , 如果有需要,还可以加入对 for 循环等 loop 结构的支持。
清单 5. 公式帮助类public class FormulaUtil { public CalculatorUtil(VariantContext ctx) {} public void process(InputStream is) throws CalcException, IOException {} public void process(File file) throws CalcException, IOException {} public void process(List expList) throws CalcException {} public MetaElement execute(String syntax)throws CalcException {} public void registerFunction(String funcName, FunctionIntf func){}; public void deRegisterFunction(String funcName){}; }
小结:从以上我们知道,自定义公式系统包含两个关键部分:Parser 和 Execuctor,前者负责扫描公式文本,识别出操作数和操作符并封装为 ExecuteItem 对象,然后按后缀式的遍历顺序存入队列;后者将借助栈对 Parser 产生的队列进行运算。为了让自定义表达式能‘融入’具体项目中,我们预留了两类扩展:一,对变量上下文 VariantContext 进行扩展。二,当内嵌的函数,如 isEmpty,indexOf,decode 等不够用时,我们还可以向公式环境中注册自定义函数。一类扩展能让我们的项目与公式交换变量,二类扩展提供了让公式直接操作项目 Bean 的能力。
至此我们已经建立好了一个公式系统。
-
影响分析
我们从如下促销手段中
- 降低销售价格:降价的方式可以很复杂,直接折扣,如 7 折;按条件折扣,如满 100 打九折,满 200 减 100,成交的前 5 件商品 5 折,等等。
- 赠送商品:如满 1000 送一件指定的牛仔裤。
- 赠积分,优惠券,抽奖机会。
- 免运费。
- 提升用户会员级别,如从普通到 VIP。
可以初步分析出活动的影响面:
影响面 影响事项 展示货架 - 商品折扣额度
- 参加活动名称
购物车 - 满足活动条件的活动列表
- 参加活动名称
- 总折扣额度
- 赠送的商品列表
- 总折扣额度
- 用户相关(必要条件是预先已登录):免运费,提升会员等级,积分,优惠券,抽奖机会
下订单 - 满足活动条件的活动列表
- 参加活动名称
- 总折扣额度
- 赠送的商品列表
- 总折扣额度
- 用户相关(必要条件是预先已登录):免运费,提升会员等级,积分,优惠券,抽奖机会
其中展示货架和购物车环节是只读模式的,即仅是提示作用。促销活动的结果不会持久化,与购物车不同的是,下订单时促销活动的结果不仅要提示,而且必须记录下来 , 并跟客户定单号关联,在支付成功后生效,这样我们可以考虑用一个活动结果类用来收集不同场合下的活动结果,至于结果的处理(只读,可写),我们再根据具体情况而定。
-
业务抽取
-
分析影响面中名词类型的关键字我们可以析出如下公式可调用的变量:
商品单价,订购数目,参与活动的商品总金额、折扣总额、积分总额
-
从以上影响面中过滤出动词类型的关键字,编目为公式可调用的外部函数
送积分,送优惠券,从总额总减除折扣
-
在分析完活动的影响后,我们需要将活动以公式的形式编写出来,并让一个执行机构(我们暂时称其为活动‘插件’)在需要活动的地方执行。这些受影响的地方分别是:
- 商品展示管理
- 购物车管理
- 订单管理
-
-
数据模型概要
图 3. 促销数据模型
-
促销活动举例
比如国庆之前,市场部准备策划了一个大型商业活动“国庆大派送”:参与此活动的商品一律 5 折,积分 100 倍返还(金额乘以 100),如果订单金额超过 1000 元送 500 元优惠券(优惠券编号为 200910)。
运营部为此次活动做了如下工作:
- 添加一条活动记录:活动名称 :” 09 国庆大派送活动”,活动时间 2009.09.30 21:00 -2009.10.06 23:59 。
- 编写 活动内商品公式:
清单 6. 活动内商品公式[E_SUM] = [P_PRICED]*[P_ORDER_NUM];/*E_SUM 是临时变量 */ [S_DISCOUNTSUM]=[S_DISCOUNTSUM]+[E_SUM]*0.5;/* 统计当前商品折扣总额 */ [S_POINTSUM] = [S_POINTSUM] + [E_SUM]*100;/* 统计当前商品所送总积分 */ [S_ORDERSUM] = [S_ORDERSUM] + [E_SUM];/* 统计活动商品的订单总额 */
- 编写 活动汇总公式:
清单 7. 活动汇总公式/* 订单金额超过 1000 送 500 元优惠券 */ IF [S_ORDERSUM] - 1000 > -0.001 {giveCoupon([MB_ID],200910);} /* 送积分 */ givePoint([MB_ID], [S_POINTSUM]); /* 折扣 */ discount([S_DISCOUNTSUM]);
根据对促销活动的分析,开发部需要设计一个执行活动的‘插件’,以执行各种活动。作为参考,以下是类设计:
图 4. 开发模型类图
通过以上类图不难理解各个类之间的关系,下面补充说明
EventExecutor的两个实现类在具体项目中的作用和关系:EventExecutorImpl 是调用公式执行业务的实现类,被 EventProxy 关联,EventProxy 直接面向活动受体(如:购物车管理类,订单管理类),由活动受体调用 EventProxy 的 execute 触发活动的执行。EventProxy 持有一个上下文 SyntaxContext 实例,EventProxy 接口方法被调用时,通过参数 EventCommand 将上下文传递给 EventExecutorImpl。
EventCommand接口的实现类中将存放公式能直接调用的外部函数的实现,由 EventProxy 统一注册到 SyntaxConext 上下文中,当公式调用 SyntaxConext 中注册的外部函数时,也同时提供了操作 EventCommand 对象的受体 (通过 getTarget() 接口方法获取)并改变其状态的可能性。
以下以购物车为例说明调用时序 :
图 5. 时序图
以下是 EventCommad 接口的一个实现类举例:
清单 8. 活动命令类
public class EventCommandImpl implements EventCommand {
private VariantContext context;// 公式活动上下文
private Object target;// 绑定对象:由公式函数操纵以改变其状态
private Map<String, FunctionIntf> funcMap = new HashMap<String, FunctionIntf>();
public EventCommandImpl() {
funcMap.put("addPoint", ADD_POINT);
funcMap.put("addCoupon", ADD_COUPON);
funcMap.put("addProduct", ADD_PRODUCT);
}
public EventCommandImpl(VariantContext context) {
this();
this.context = context;
}
/**
* 为指定用户添加积分
* params[0]: 用户 ID, params[1]: 积分数 , params[2]: 到期时间(可选)
*/
public final FunctionIntf ADD_POINT = new FunctionIntf() {
public Object execute(java.util.List params) {
if(null != this.getTarget() && null != params && params.size() > 1){
ShopCartManager scm = (ShopCartManager) this.getTarget();
MetaElement pmMbId = (MetaElement) params.get(0);
MetaElement pmPointSum = (MetaElement) params.get(1);
scm.addPoint(pmMbId.getAsLong(),pmPointSum.getAsDouble());
}
return null;
}
};
/**
* 送优惠券
* params[0]: 用户 ID,params[1]: 优惠券 ID
*/
public final FunctionIntf ADD_COUPON = new FunctionIntf() {
public Object execute(java.util.List params) {
if(null != this.getTarget() && null != params && params.size() > 1){
ShopCartManager scm = (ShopCartManager) this.getTarget();
MetaElement pmMbId = (MetaElement) params.get(0);
MetaElement pmCouponId = (MetaElement) params.get(1);
scm.addCoupon(pmMbId.getAsLong(),pmCouponId.getAsLong());
}
return null;
}
};
/**
* 送赠品
* params[0]: 用户 ID,params[1]: 商品 ID
*/
public final FunctionIntf ADD_PRODUCT = new FunctionIntf() {
public Object execute(java.util.List params) {
if(null != this.getTarget() && null != params && params.size() > 1){
ShopCartManager scm = (ShopCartManager) this.getTarget();
MetaElement pmMbId = (MetaElement) params.get(0);
MetaElement pmProductId = (MetaElement) params.get(1);
scm.addProduct(pmMbId.getAsLong(),pmProductId.getAsLong());
}
return null;
}
}
public Object getTarget() {
return this.target;
}
public void setTarget(Object target) {
this.target = target;
}
public VariantContext getContext() {
return this.context;
}
public void setContext(VariantContext context) {
this.context = context;
}
@Override
public Map<String, FunctionIntf> getFunctionMap() {
return this.funcMap;
}
}
|
以下是 EventExecutor 接口的实现举例:
清单 9 活动执行类
public class EventExecutorImpl implements EventExecutor {
private EventManager eventManager;
@Override
public Object execute(EventCommand command) {
ShopCartManager scm = (ShopCartManager) command.getTarget();
if (null == scm)
return null;
List<ProductInfo> pdLst = scm.getProducts();
List<Long> pIdLst = new ArrayList<Long>();
Map<Long, ProductInfo> pdMap = new HashMap<Long, ProductInfo>();
for (ProductInfo pd : pdLst) {
pIdLst.add(pd.getId());
pdMap.put(pd.getId(), pd);
}
// 根据购物车中的商品查找活动 EventModel 对应一条商品和活动的关联
List<EventModel> eventModels
= this.eventManager.queryEventModelByProducts(pIdLst);
Map<String, List<EventModel>> evtPdMap
= new HashMap<String, List<EventModel>>();
if (null != eventModels && eventModels.size() > 0) {
// 按活动聚合商品
for (EventModel md : eventModels) {
List<EventModel> lst = evtPdMap.get(md.getEventCode());
if (null == lst) {
lst = new ArrayList<EventModel>();
evtPdMap.put(md.getEventCode(), lst);
}
lst.add(md);
}
// 初始化公式帮助类
SyntaxContext ctx = command.getContext();
FormulaUtil fu = new FormulaUtil(ctx);
// 公式变量:用户 ID
ctx.put("MB_ID", null == sca.getMember() ? "" : sca.getMember().getId());
// 遍历活动
for (Iterator<String> it = evtPdMap.keySet().iterator(); it.hasNext();) {
// 初始化公式变量 基于活动的公式
// 折扣金额
ctx.put("S_DISCOUNTSUM", 0L);
// 积分金额
ctx.put("S_POINTSUM", 0D);
// 参与活动的订单金额
ctx.put("S_ORDERSUM", 0D);
String evtCd = it.next();
List<EventModel> epdLst = evtPdMap.get(evtCd);
// 遍历活动中的商品
for (EventModel em : epdLst) {
// 初始化公式变量 基于活动中的商品公式
// 商品 ID
ctx.put("P_ID", em.getPdId());
// 商品价格
ctx.put("P_PRICE", pdMap.get(em.getPdId()).getPriced());
// 商品定购数目
ctx.put("P_ORDER_NUM", pdMap.get(em.getPdId()).getOrderNum());
// 运行基于活动中商品的公式
fu.execute(em.getForEachPdInEvent());
}
// 运行基于活动的公式
fu.execute(epdLst.get(0).getForEachEvent());
}
}
evtPdMap.clear();
evtPdMap = null;
eventModels.clear();
eventModels = null;
pdMap.clear();
pdMap = null;
return null;
}
|
我们可以在类似如 springframework 的框架里注入活动相关类。
清单 10. 注入活动相关类
<!-- 促销活动管理类 -->
<bean id="eventCommand"
class="org.wzh.common.event.impl.EventCommandImpl" scope="prototype">
<property name="eventDAO" ref="eventDAOI"/>
</bean>
<bean id="eventManager" class="org.wzh.service.impl.EventManagerImpl">
<property name="eventDAO" ref="eventDAOI"/>
</bean>
<bean id="eventExecutor"
class="org.wzh.common.event.impl.EventExecutorImpl" scope="prototype">
<property name="eventManager" ref="eventManager"/>
</bean>
<bean id="eventProxy" class="org.wzh.common.event.EventProxy" scope="prototype">
<property name="executor" ref="eventExecutor"/>
<property name="eventCommand" ref="eventCommand"/>
</bean>
<!-- end of 促销活动管理类 -->
<!-- 购物车中插入活动示例 -->
<bean id="shopCartAct" scope="prototype"
class="org.wzh.web.ShopCartAction">
<property name="eventProxy">
<ref bean="eventProxy" />
</property>
</bean>
<!-- end of 购物车中插入活动插件 -->
|
通过 EventProxy 示例执行活动。
清单 11. 活动执行代码示例
...
this.getEventProxy().getEventCommand().setTarget(this);
EventResult evtRet = (EventResult) this.getEventProxy().execute();
...
|
对于电子商务网站,促销活动是一类重要的业务,就像商场和超市离不开促销一样,其重要性是不言而喻的。在准备构建电子商务网站项目之初,我们应该充分考虑促销业务对于项目架构的影响,选择合适的实现方案。本文在遗留系统的基础上,给出了促销另一种实现方案,考虑到促销手段的多样性以及多变性,我们通过建立一个公式体系,并将这段业务抽取出来,通过我们的促销活动‘插件’来执行,从而达到让业务独立于程序开发,实现业务公式化,缩短了业务实现周期。
本篇我们讲述了公式系统应用于业务的过程,以促销为例,为此我们设计了一个公式系统,并结合公式系统我们设计了一个促销活动的应用模型,通过这个模型,业务人员能直接将业务公式化,在不间断系统运营的情况下将业务公式作用于系统。在接下的几篇里,我们会分别讲到其在系统架构和数据交换接口中的应用。
-
架构方面举例
电子商务系统在考虑搭建架构的时候,我们常常会碰到很多功能设计中都需要查询功能,各种复杂的查询让我们想搭建一个简单高效的开发模型成为泡影,编码人员在实现时往往需要自己动态构造相当复杂的 SQL 以满足功能设计的需要。通过运用公式系统,我们将把各种查询条件的构造过程封装到系统架构中,使开发人员的代码更简洁更稳定,从而极大提高开发效率。
-
数据交换接口方面举例
电子商务系统作为供应链中的一个环节,与其它系统之间会有一些接口,通常我们会写很多适配程序来处理数据交换,作为一个新的尝试我们可以运用公式来适配不同来源的数据,从而提高系统的灵活性和稳定性。在以后的文章中,我们会在当前的公式系统基础上增加循环控制语句,先前的适配器程序将被新的公式脚本代替。
学习
- 了解开源项目CFC-通用公式计算包
- 了解关于 BeanShell 的知识。
- “用BeanShell实现公式管理”(developerWorks,2003 年 7 月):本文用BeanShell(一种 Java 解释器)实现了一个公式管理系统。从该系统的实现我们可以了解到 BeanShell 带给我们灵活的 Java 脚本机制;并且,我们还可以在该系统的基础上,定制自己的公式管理系统。
- “动态调用动态语言,第 1 部分: 引入 Java 脚本 API”(developerWorks,2007 年 9 月):介绍 Java 脚本 API 的各种特性。文章将使用一个简单的 Hello World 应用程序展示 Java 代码如何执行脚本代码以及脚本如何反过来执行 Java 代码。
- “动态调用动态语言,第 2 部分: 在运行时寻找、执行和修改脚本”(developerWorks,2007 年 9 月):将深入研究 Java 脚本 API 的强大功能,演示如何在无需停止并重新启动应用程序的情况下,在运行时执行外部 Ruby、Groovy 和 JavaScript 脚本以修改业务逻辑。
-
developerWorks Java 技术专区:查找关于 Java 编程各方面的数百篇文章。
- 随时关注 developerWorks 技术活动和 网络广播。
- 查看免费的 developerWorks 演示中心。
获得产品和技术
- 从开源项目CFC 中获取 源码以及 Jar 包。
讨论