有贊:跨平臺訂單優惠計算案例
作者:翁小飛
團隊:零售技術
一、背景
1.1 介紹
訂單優惠計算是指買家選擇商品加入購物車,交易系統根據會員等級,會員資產 (優惠券 / 碼、積分、權益卡),商家優惠活動,計算出訂單實際需要支付的金額。
在有贊零售業務板塊中,線上線下都有訂單優惠計算場景。線上使用場景是買家在 H5 / 小程序端選品加車、下單結算,中臺在這部分已經有很充分的沉澱,所以主要使用中臺提供的能力實現。而在線下使用場景深度契合垂直行業,業務場景比較特殊,不適合放在中臺去實現,所以這部分能力由零售業務自己完成。
1.2 業務場景
在線下開單收銀場景,零售提供了多種客戶端供商家選擇,買家使用的端:門店小程序、自助收銀大屏版。商家收銀的端:PC 收銀 (瀏覽器 / 桌面),Phone/Pad 收銀端等。
總結下零售線下場景優惠計算的難點和痛點:
-
屬於交易核心邏輯,涉及到資產,如果計算出問題,容易對商家或買家造成資損
-
營銷活動較多,迭代速度快,業務邏輯複雜 耳熟能詳的有:限時折扣、優惠券、滿減送、買一送一、打包一口價、積分抵現等。爲了促進消費,營銷玩法會不斷地更新,同時原有的活動也一直在往細緻化發展,貼合商家使用需求
-
開發量冗餘 業務場景對應的客戶端多,使用的技術棧也是不同的。部分端實現了本地計算,部分端暫時依賴後端優惠計算
-
各端實現細節可能不一致,維護起來費時費力
-
如果發生計算錯誤,很難及時修復問題
1.3 前世
零售移動端團隊在每次營銷項目迭代中,Android、iOS 兩端小組都需要投入開發資源,影響團隊整體的項目迭代效率。
於是,移動端團隊基於 JavaScript
開發了第一版跨平臺訂單優惠計算,它統一了 Android/iOS
訂單本地優惠計算和優惠詳情展示的邏輯,還有動態熱更的能力。
在後續迭代中,後端也希望能夠接入這套能力,並共建這套系統,但是發現了有一些問題急需解決。
-
計算過程中依賴了共享全局變量,有併發問題,無法同時計算多筆訂單,對後端使用場景來說,雖然可以通過多個執行引擎實例來實現併發安全的計算,但此方案實屬下策
-
沒有領域模型,營銷活動模型各不相同,實現的計算邏輯差異較大,導致代碼重用度不高
-
沒有設計活動互斥,互斥邏輯是硬編碼在活動處理類中的
-
訂單的數據結構冗餘,商品和活動模型應該是獨立的,但實際上商品模型下掛載了可以使用的活動,這樣即增加理解成本,又增加了數據序列化的開銷
-
沒有類型約束,開發起來,代碼提示全憑記憶,對於初次接觸該系統的人,代碼理解成本較高,開發新功能也束手束腳
-
處理邏輯繁瑣,在商品特別多的情況下,性能不太理想
二、新生
2.1 設計目標
新的方案需要滿足以下幾種需求:
-
能夠提供給現有場景的多個端使用,已有的活動都需要支持。
-
統一的模型設計。商品和活動要有對應的模型,一個活動一個模型是不能接受的,這樣代碼複用率太低。
-
擴展性強
-
對後續的需求迭代, 能夠很輕鬆的擴展原有功能或新增營銷活動
-
多個端的使用差異需要滿足
- 性能優化。就算在商品特別多的場景,也不能出現耗時長的問題。
其實,最重要的還是提升研發效率,相同的營銷計算邏輯不需要在多端都開發一遍。
2.2 重構還是重寫?
方案 1: 重構活動模型成本巨大,改動貫穿所有文件,加上動態語言一時爽。
方案 2: 從長遠看,用 TypeScript 重寫對後期開發效率提升會很大,同時也會大大降低代碼理解成本。✅
簡單介紹下 TypeScript
特點:
-
提供了靜態類型,編譯時靜態類型檢查可以避免不少低級錯誤
-
對代碼重構和補全提示友好
-
多人協作起來,降低了溝通成本
-
可以編譯成
JavaScript
運行在各端
2.3 靜態類型玩得更好
在 Native
這邊的泛型,經過序列化之後,在 JS Runtime
反序列化得到的是普通對象,沒有了自身行爲和類型約束。
當然這不是語言層面的問題,但我們仍然可以設計得更完善。
我們可以通過合併對象的方式,讓對象實例既有數據,又有行爲和類型檢查。
2.4 業務模型分析
2.4.1 營銷活動模型
“滿 300 減 30、2 件 8 折,3 件 7 折、全場 100 元任選 3 件……”
其實營銷活動本身最核心的三個部分是:
-
門檻 如需要滿足多少金額或商品數量,是否原價使用等
-
優惠 如直接打多少折,減多少錢,或者 3 件 100 元這種指定金額的玩法
-
基本信息 包含了活動唯一標識、活動類型、活動名稱
仔細想想,對這個門檻和優惠擴展一下,然後組合起來,就是一個新的營銷活動玩法。
除此之外,營銷活動優惠計算處理邏輯還有:
-
計算優先級 多個活動計算優惠時,需要有優先級定義,然後按照順序計算使用
-
使用策略 多個活動都可以使用時,要考慮互斥、可疊加和選最優
-
作用維度 單個、多個商品使用,或者整單立減
2.4.2 擴展性
通過對營銷活動的模型分析,可以預見的是,未來營銷活動需求迭代,會出現以下幾種場景:
-
商家可以任意配置門檻和優惠來創建活動,萬能的營銷插件
-
商家可以任意配置優惠活動的使用順序和使用策略
-
增加優惠方式,如現有抹零分爲抹分、抹角、四捨五入到角,商家想要新增四舍、五入等
2.4.3 商品模型
商品本質是一個純數據的模型,包含一些基本屬性:標識符、類型、單位、數量、單價等,但是在實際開發過程中,需要爲其增加自身能力。
2.4.4 活動優先級問題
將營銷活動的計算邏輯抽象成處理器,串聯起來使用,這樣的方式可以解決活動優先級問題,也比較適合我們的業務場景,可以很好地實現以下目標:
-
規範了活動處理流程
-
活動處理順序可配置化
-
活動處理之間可以任意插入邏輯節點
在實際開發中,可以插入 2 個 「數據調整」 的處理器。
-
多個 SKU 級別優惠算完後,比較優惠額度,選擇最優的方案
-
所有活動處理完後,整理訂單概要數據
2.4.5 活動互斥模型
活動之間有一定的使用策略:疊加、互斥、選最優。
目前的使用策略主要是由產品設計決定的,部分活動互斥情況如下所示:
對於活動之間的互斥關係,需要一個合適的數據結構來存儲,然後封裝起來,簡化外部對其的使用。最終選擇使用無向圖來存儲,在實際開發中,使用鄰接鏈表的方式實現。
無侵入的活動互斥
爲了避免活動互斥的邏輯硬編碼在活動處理類中,在執行營銷活動計算的處理方法時,排除掉了已經參與互斥活動的商品,這樣活動處理器不用感知活動互斥,只需要關心自己的處理邏輯。
大致代碼如下:
2.5 整體設計
2.5.1 分層設計
輸入層
主要把外部傳入的數據做整理轉換。這部分是可選的,可以在 Native
層就做好適配,不同的端可以通過擴展 Entry 來實現自己的處理。
核心計算層
-
構建領域模型,實際是爲輸入層的數據增加了自身能力的處理邏輯。如商品應有的能力:使用改價價格、計算總價、拆分一部分數量出來、應用優惠等
-
將合適的商品和活動交給處理器,計算出優惠結果
結果導出層
Native
端不再需要做多餘的模型轉換,減少了很多工作量。JS 這邊針對不同場景,**數據直出。**JS 做起來簡單且合適(擁有所有數據)
例如:移動端需要的不僅僅是訂單優惠詳情,還有移動端兩端之間約定的渲染模板(什麼地方用啥顏色, 字體大小等)
通過擴展輸入層和結果導出層,共享核心計算層的方式,滿足不同端的業務場景需求。
2.5.2 核心類圖
2.6 細節設計
2.6.0 寫在前面
總結下幾個設計原則
-
領域模型應該擁有自身的能力,而不是交給
XXXManager
處理 -
內聚的模型,代碼複用率很高
-
增加中間層,解耦活動模型變化和計算邏輯,提升擴展性
-
多用組合。
compose(A,B)=>Foo,compose(A,C)=>Bar
-
避免使用擴展字段(字典),看起來大而全,實則並不能節省開發量,還浪費了類型檢查,能明確的字段就直接定義出來
-
通過包裝原有數據對象的方式,爲其添加能力,始終不會修改源數據。
2.6.1 內聚的模型
將核心邏輯放在對應的模型上,模型聚焦自身能力,隱藏實現細節,簡化外部的使用。
這裏舉幾個栗子:
- 商品模型:除開商品自身的數據,應提供
-
計算商品總價的能力,隱藏改價、商品單位、附加屬性的計算邏輯
-
應用 SKU 級別優惠的能力,隱藏使用優惠之後,價格變動的處理
- 門檻模型:
-
SKU 級別門檻提供:商品能否使用優惠,隱藏全選、部分選中、分組選、反選和無碼商品的邏輯
-
組合級別門檻提供:生成商品統計概要之後,是否滿足了要求,湊單還需什麼或者已經超過門檻了多少倍
- 優惠模型: 提供 計算應該優惠多少金額的能力, 隱藏打折、減錢、指定價格、抹零這些優惠方式
// SKU的包裝類
class SkuWrapper {
// 應用SKU級別的優惠方案
applySkuPlan(skuPlan: SkuPlan);
// 計算SKU的總價
reCalcTotalPrice();
}
// 對一組商品參與活動的統計
interface ItemStats {
// 數量
totalCount: number;
// 價格
totalPrice: number;
// 使用原價?
useOriginPrice: boolean;
// 可以參與商品的列表
suitableSkuList: SkuWrapper[];
// 源數據
sourceSkuLit: SkuWrapper[];
}
// 活動門檻
class Condition {
// 包含 SKU
isContains(sku:Sku);
}
// 組合級活動的門檻
class CombineCondition extends Condition {
// 是否滿足門檻
hasMeet(itemStats: ItemStats);
// 超過門檻多少倍
overTimes(itemStats:ItemStats);
// 還缺多少滿足門檻
calcRemainValue(itemStats: ItemStats);
}
// 活動優惠
class Preferential {
// 計算優惠價格
calcPreferentialPrice(originPrice: number);
}
通過這些核心模型的設計,處理一個 SKU 級別活動將變得非常簡單,核心代碼不會超過 20
行, 大致如下:
_process({ skuList, promotions }) {
// 迭代活動
promotions.forEach(p => {
// 取出活動門檻和優惠
const {
conditionPreferentialPairs: [{ condition, preferential }]
} = p;
// 迭代SKU列表
skuList.forEach(sku => {
// 如果門檻包含SKU
if (condition.isContains(sku)) {
// 計算優惠後的價格
const preferentialPrice = preferential.calcPreferentialPrice(
sku.salePrice
);
// 生成優惠方案
const plan = {
preferentialPrice
// other properties
};
// 應用SKU級別優惠方案
sku.applySkuPlan(plan);
}
});
});
}
2.6.2 處理器抽象模板類
對於不同的活動,需要實現活動處理模板類中的抽象方法:
-
關心的活動類型
-
處理活動數據(基礎信息 + 門檻 + 優惠)到活動泛型的映射
-
處理自身活動泛型和商品,生成和應用優惠方案
abstract class Processor<T> {
abstract types(): PromotionType[];
abstract ownModelMappings(promotion: Promotion): T;
abstract _process(ctx: ProcessorContext<T>): void;
}
活動模型的擴展性:各個活動總是有差異的,不需要全部按照一個固定的模型去設計。把通用的部分定義出來,允許出現特性,同時不會對外部傳入的數據做限制。
這裏主要通過增加中間層來實現活動模型的擴展性。ownModelMappings()
會將數據封裝爲自身所需的泛型,即使外部活動的門檻或優惠有變化,之前的計算邏輯也不用修改。
例如有這麼一個場景:有門檻和優惠關係是 1:1
的活動 Foo,定義如下:
// 門檻和優惠比例 1:1
{
condition: {type, value},
preferential: {type, value}
}
// 優惠計算處理
class FooProcessor extends Processor<Foo> {
// 將數據轉換爲活動對應的泛型
ownModelMappings(p: Promotion): Foo {
return new Foo(p);
}
_process({foo}){
// do sth
}
}
// 門檻模型
class Condition {
constructor(c) {
// 合併數據和行爲
Object.assign(this, c);
}
isContains(sku:Sku);
}
class Foo {
constructor(p: Promotion) {
this.condition = new Condition(p.cs.condition)
}
}
需求變更爲:多個門檻滿足一個即可享受優惠。那麼,其實只需要擴展原有 condition
的封裝方式,實際對原來的計算邏輯沒有任何影響。
// 門檻和優惠變更爲 n:1
{
conditions: [{type, value}, ...],
preferential: {type, value}
}
// 一個門檻滿足即可
const anyCondition = conditions => ({
isContains: s => conditions.some(c => new Condition(c).isContains(s))
});
class Foo {
constructor(p: Promotion) {
// 活動門檻的匹配方式修改爲 anyCondition 即可
this.condition = anyCondition(p.cs.conditions)
}
}
2.6.3 商品活動匹配
一個商品能不能使用活動的優惠,主要有以下幾種匹配方式:
-
SPU 級別(商品 ID)
-
SKU 級別(商品 ID + SkuID)
-
原價才能使用
-
原價 SPU 級別(商品 ID)
-
原價 SKU 級別(商品 ID + SkuID)
-
非稱重商品才能使用
-
全部能用
-
等
通過以上的幾種情況可以看出,如果純粹按照需求來開發這塊功能,會有很大的冗餘。爲了減少重複開發量,使用組合的方式來實現。
// 定義匹配函數
type Matcher = (condition: Condition, sku: Sku) => boolean;
// 原價才能使用
const originPriceMatcher = (condition: Condition, sku: Sku) => true
// SKU維度標識匹配
const skuIdentityMatcher = (condition: Condition, { goodsId, skuId }: Sku) => false
// 組合匹配
const composeMatcher = (a: Matcher, b: Matcher): Matcher => (
condition: Condition,
sku: Sku
) => a(condition, sku) && b(condition, sku);
// SKU維度標識匹配且使用原價
const originPriceWithSkuIdentityMatcher = composeMatcher(originPriceMatcher, skuIdentityMatcher)
2.6.4 性能優化
對於系統的性能優化,做了幾點微小的事:
-
簡化輸入輸出數據結構,減少邊界開銷
-
儘量避免深度複製,尤其是結構層次深的對象
-
選擇合適的算法,通過剪枝的方式,縮小計算量。在商品特別多的情況下,時間複雜度依然能保持常數階
以下是 iOS
客戶端生產環境採集新老計算耗時的數據統計。爲了避免影響觀感,去除了極端場景下老版本計算超時的記錄
2.6.5 測試覆蓋
開發一個項目,測試代碼是必須要有的,更何況是涉及到資產,一定要穩。
除了在開發功能階段編寫的單元測試,測試同學還提供了一系列核心用例,加上線上真實訂單計算場景的數據,都補充到了集成測試當中。
項目的測試率覆蓋如下圖:
三、後端計算場景
3.1 JavaScript 運行環境選型
J2V8 Google V8 高性能 JavaScript 引擎的 Java 封裝
Nashorn JDK 內置輕量級高性能 JavaScript 運行環境 ✅
基於不折騰和性能不差的原則,選擇了 JVM
內置的 Nashorn
引擎作爲後端 JavaScript 運行環境.
3.2 熱更新
後端服務感知到有新版本的 JS 發佈,需要創建新的 ScriptEngine
,並加載 JS 文件,然後通過靜態的訂單數據預熱,預熱結束後替換掉老的版本,對外提供服務.
值得注意的是: 假如服務正在使用 ScriptEngine
處理計算,同時又有新版本發佈,創建了新的 ScriptEngine,此時直接暴露出去使用,會導致腳本未加載完成的錯誤。所以需要 ScriptEngine 所有準備過程 (創建, 加載腳本和預熱) 封閉在工廠方法內,準備階段完成,得到的就是完全可用的 ScriptEngine
。
3.3 版本發佈
各端的版本發佈流程大致相同:
-
將工程通過
webpack
以區分Entry
的方式進行打包,並上傳至內部文件服務器 -
在發佈管理頁面操作,創建一個新的版本,綁定文件下載地址
-
將新的版本信息發佈到配置中心
-
當前環境的服務端感知到配置變化,去文件服務器拉取腳本
-
加載新版本到計算服務中,預熱,替換老版本,開始對外提供服務
-
當前環境確認服務穩定,同步至下個環境。跳轉至 4
-
當前環境服務不穩定,通過配置中心歷史記錄回滾。跳轉至 4
3.4 後續的挑戰
3.4.1 支持校驗不同版本的計算結果
對於不同版本腳本計算出來的結果,後端應該用什麼版本去校驗呢?
不同版本的差異可能體現在以下幾個情況:
-
支持的營銷活動的疊加互斥規則
-
不支持某些活動
-
活動使用順序變了
-
……
3.4.2 如何向前兼容
方案 1:最新版本兼容所有老版本,需要很多 feature-flag
。歷史包袱會越來越重,維護成本太高了。靠人腦去維護版本的兼容是不可靠的
方案 2:服務端按需加載相應版本在內存中,使用請求對應的版本計算。無歷史包袱,內存佔用會越來越大 ✅
3.4.3 內存壓力
先看看目前的 JS 文件大小和內存佔用情況,JS 編譯到 ES5 之後,文件大了一倍多。文件大小約 187K
創建了 2 個計算引擎,加載完腳本佔用內存 22.2M。經過粗略的計算, JVMScriptEngine
本身佔用約 3M,加載一個 JS 計算腳本需要 7M 左右的內存成本
3.4.4 目前 JS 腳本 的模塊分析
在 webpack
打包文件時,可以通過 webpack-bundle-analyzer 插件,分析出各個模塊文件大小。統計如下:
文件佔比大頭在 node_modules
第三方包上,當加載多個腳本時,其實有很大的冗餘,它們在內存中的表現如下:
3.4.5 優化
可以通過 作用域隔離 的方式,分離不同的版本。第三方依賴的包,是所有版本共享的。前提是後面依賴不會有變化,以訂單優惠計算的業務來講,不會需要新的依賴了。
優化之後,加載了 11 個版本。內存佔用 13M,除去 JVMScriptEngine
佔用的 3M, 加載一個 JS 計算腳只需不到 1M 內存成本。
結合目前的各端發版週期和版本覆蓋率的情況來看,後端按需加載對應版本,不會有太大的內存壓力。
四、已經遇到的問題
4.1 版本管理
一個代碼庫,多個平臺發佈。一般開發新功能,先拉特性分支,開發結束後合併到 master
,然後用 master
來發布版本。但是當兩端的開發需求同時進行,想要發佈的內容,時間節點也不一樣。那麼代碼如何合併、發佈就是個問題。目前採用的方式是,代碼仍然合併到 master
,各端拉發佈分支的方式去發佈。有新的特性或者修復,可以摘取過來,然後定期和 master
同步。缺點就是端的負責人需要關注新代碼的合併,需不需要合併到發佈分支,有沒有衝突問題。這種方式只能算折中之舉,後續還需要繼續思考和探索如何處理會更好、更省事。
4.2 風險
收益與風險總是並存的。各客戶端統一的核心邏輯是:開發一次,到處運行。這樣可以很大程度上提升迭代速度和一致性。但是,如果有新功能開發,通常需要評估對不同場景的影響,迴歸核心用例確保穩定性。雖然系統本身有單測 / 集成測試覆蓋,但依然增加了測試同學的工作量。慶幸的是,隨着客戶端自動化測試和後端沙盒錄製回放的用例覆蓋率增長,風險和工作量會逐漸減小。
五、總結與展望
截止目前,移動端和後端都已經穩定上線,投入使用。也就是說,有贊零售所有的線下收銀場景都使用了這套計算框架。在訂單優惠計算方面的研發效率,至少提升了 4 倍人效。後面需要做的是,將自身平臺有能力,但目前依賴後端計算的場景(PC 收銀、自助收銀大屏版),集成這套計算框架。實現本地計算,優化用戶體驗。
對於上線後近期的產品迭代,目前的模型設計和擴展性能夠優雅的實現需求,如在「營銷疊加互斥項目」中,對業務來講屬於大改的,其實對訂單優惠計算的影響很小,很容易就實現了,得益於設計之初就將使用策略與計算邏輯分離。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Yc_yTeOzX13QVP6Py_KhSA