引言
我在幾個電子商務的項目中碰到這樣的問題,網站因頻繁推出各種商業促銷活 動,並隨著活動臨近上線,技術開發人員不得不著急添加新的代碼或修改程序以 滿足新活動的要求。更“可怕”的是,這些活動上線的時機恰逢在周六周日和重 要節假日,開發人員和測試人員苦不堪言。記得七月份連續幾個周六日,網站接 連上了三個促銷活動:(一) 指定一些商品滿三百減一百;( 二 ) 在活動一的 基礎上再全場積分五十倍返還;( 三 ) 鑽石用戶在以上活動基礎上再全場商品七 五折。促銷活動的流程是這樣的,活動的期間、參與活動的商品范圍和活動規則 由市場和運營部一起定制,由技術部門實現和測試,最後由市場和運營進行驗收 ,然後部署到生產環境。但由於活動規則的無規律性,技術部門在設計活動時很 難做到擴展性和復用性俱佳的設計,調整程序是不可避免的,使得每次上活動必 須相應調整很多代碼。經初步分析,程序調整將涉及到商品展示,購物車和下訂 單,這些調整幾乎涉及到了網站的所有展示前台。這對技術部門提出了一個不小 的挑戰。
從下圖中我們可以看到活動實施前後的主要階段和所花費的人力和時間。
圖 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 是隊列中封裝的元素類型:
清單 2. 解析完後的 最小單元類,封裝了操作數和操作符
public class ExecutionItem {
private String itemString;// 字符串形態
private int itemType;// 類型,見下面的類型定義
private int itemOperator;// 操作符類型(如果 itemType 是 itOperator 的話)
private List itemParams;// 參數
}
類型定義如下:
清單 3. 運算單元類型定義
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 以滿足功能設計的需要。 通過運用公式系統,我們將把各種查詢條件的構造過程封裝到系統架構中,使開 發人員的代碼更簡潔更穩定,從而極大提高開發效率。
數據交換接口方面舉例
電子商務系統作為供應鏈中的一個環節,與其它系統之間會有一些接口,通常 我們會寫很多適配程序來處理數據交換,作為一個新的嘗試我們可以運用公式來 適配不同來源的數據,從而提高系統的靈活性和穩定性。在以後的文章中,我們 會在當前的公式系統基礎上增加循環控制語句,先前的適配器程序將被新的公式 腳本代替。