有贊:跨平臺訂單優惠計算案例

作者:翁小飛

團隊:零售技術

一、背景

1.1 介紹

訂單優惠計算是指買家選擇商品加入購物車,交易系統根據會員等級,會員資產 (優惠券 / 碼、積分、權益卡),商家優惠活動,計算出訂單實際需要支付的金額。

在有贊零售業務板塊中,線上線下都有訂單優惠計算場景。線上使用場景是買家在 H5 / 小程序端選品加車、下單結算,中臺在這部分已經有很充分的沉澱,所以主要使用中臺提供的能力實現。而在線下使用場景深度契合垂直行業,業務場景比較特殊,不適合放在中臺去實現,所以這部分能力由零售業務自己完成。

1.2 業務場景

在線下開單收銀場景,零售提供了多種客戶端供商家選擇,買家使用的端:門店小程序、自助收銀大屏版。商家收銀的端:PC 收銀 (瀏覽器 / 桌面),Phone/Pad 收銀端等。

總結下零售線下場景優惠計算的難點和痛點

1.3 前世

零售移動端團隊在每次營銷項目迭代中,Android、iOS 兩端小組都需要投入開發資源,影響團隊整體的項目迭代效率。

於是,移動端團隊基於 JavaScript 開發了第一版跨平臺訂單優惠計算,它統一了 Android/iOS 訂單本地優惠計算和優惠詳情展示的邏輯,還有動態熱更的能力。

在後續迭代中,後端也希望能夠接入這套能力,並共建這套系統,但是發現了有一些問題急需解決。

  1. 計算過程中依賴了共享全局變量,有併發問題,無法同時計算多筆訂單,對後端使用場景來說,雖然可以通過多個執行引擎實例來實現併發安全的計算,但此方案實屬下策

  2. 沒有領域模型,營銷活動模型各不相同,實現的計算邏輯差異較大,導致代碼重用度不高

  3. 沒有設計活動互斥,互斥邏輯是硬編碼在活動處理類中的

  4. 訂單的數據結構冗餘,商品和活動模型應該是獨立的,但實際上商品模型下掛載了可以使用的活動,這樣即增加理解成本,又增加了數據序列化的開銷

  5. 沒有類型約束,開發起來,代碼提示全憑記憶,對於初次接觸該系統的人,代碼理解成本較高,開發新功能也束手束腳

  6. 處理邏輯繁瑣,在商品特別多的情況下,性能不太理想

二、新生

2.1 設計目標

新的方案需要滿足以下幾種需求:

  1. 對後續的需求迭代, 能夠很輕鬆的擴展原有功能或新增營銷活動

  2. 多個端的使用差異需要滿足

其實,最重要的還是提升研發效率,相同的營銷計算邏輯不需要在多端都開發一遍。

2.2 重構還是重寫?

方案 1: 重構活動模型成本巨大,改動貫穿所有文件,加上動態語言一時爽。

方案 2: 從長遠看,用 TypeScript 重寫對後期開發效率提升會很大,同時也會大大降低代碼理解成本。✅

簡單介紹下 TypeScript特點:

2.3 靜態類型玩得更好

Native這邊的泛型,經過序列化之後,在 JS Runtime反序列化得到的是普通對象,沒有了自身行爲和類型約束

當然這不是語言層面的問題,但我們仍然可以設計得更完善。

我們可以通過合併對象的方式,讓對象實例既有數據,又有行爲和類型檢查。

2.4 業務模型分析

2.4.1 營銷活動模型

“滿 300 減 30、2 件 8 折,3 件 7 折、全場 100 元任選 3 件……”

其實營銷活動本身最核心的三個部分是:

仔細想想,對這個門檻和優惠擴展一下,然後組合起來,就是一個新的營銷活動玩法。

除此之外,營銷活動優惠計算處理邏輯還有:

2.4.2 擴展性

通過對營銷活動的模型分析,可以預見的是,未來營銷活動需求迭代,會出現以下幾種場景:

  1. 商家可以任意配置門檻和優惠來創建活動,萬能的營銷插件

  2. 商家可以任意配置優惠活動的使用順序和使用策略

  3. 增加優惠方式,如現有抹零分爲抹分、抹角、四捨五入到角,商家想要新增四舍、五入等

2.4.3 商品模型

商品本質是一個純數據的模型,包含一些基本屬性:標識符、類型、單位、數量、單價等,但是在實際開發過程中,需要爲其增加自身能力。

2.4.4 活動優先級問題

將營銷活動的計算邏輯抽象成處理器,串聯起來使用,這樣的方式可以解決活動優先級問題,也比較適合我們的業務場景,可以很好地實現以下目標:

  1. 規範了活動處理流程

  2. 活動處理順序可配置化

  3. 活動處理之間可以任意插入邏輯節點

在實際開發中,可以插入 2 個 「數據調整」 的處理器。

2.4.5 活動互斥模型

活動之間有一定的使用策略:疊加、互斥、選最優。

目前的使用策略主要是由產品設計決定的,部分活動互斥情況如下所示:

對於活動之間的互斥關係,需要一個合適的數據結構來存儲,然後封裝起來,簡化外部對其的使用。最終選擇使用無向圖來存儲,在實際開發中,使用鄰接鏈表的方式實現。

無侵入的活動互斥

爲了避免活動互斥的邏輯硬編碼在活動處理類中,在執行營銷活動計算的處理方法時,排除掉了已經參與互斥活動的商品,這樣活動處理器不用感知活動互斥,只需要關心自己的處理邏輯。

大致代碼如下:

2.5 整體設計

2.5.1 分層設計

輸入層

主要把外部傳入的數據做整理轉換。這部分是可選的,可以在 Native層就做好適配,不同的端可以通過擴展 Entry 來實現自己的處理。

核心計算層

  1. 構建領域模型,實際是爲輸入層的數據增加了自身能力的處理邏輯。如商品應有的能力:使用改價價格、計算總價、拆分一部分數量出來、應用優惠等

  2. 將合適的商品和活動交給處理器,計算出優惠結果

結果導出層

Native端不再需要做多餘的模型轉換,減少了很多工作量。JS 這邊針對不同場景,**數據直出。**JS 做起來簡單且合適(擁有所有數據)

例如:移動端需要的不僅僅是訂單優惠詳情,還有移動端兩端之間約定的渲染模板(什麼地方用啥顏色, 字體大小等)

通過擴展輸入層和結果導出層,共享核心計算層的方式,滿足不同端的業務場景需求。

2.5.2 核心類圖

2.6 細節設計

2.6.0 寫在前面

總結下幾個設計原則

2.6.1 內聚的模型

將核心邏輯放在對應的模型上,模型聚焦自身能力,隱藏實現細節,簡化外部的使用。

這裏舉幾個栗子:

  1. 計算商品總價的能力,隱藏改價、商品單位、附加屬性的計算邏輯

  2. 應用 SKU 級別優惠的能力,隱藏使用優惠之後,價格變動的處理

  1. SKU 級別門檻提供:商品能否使用優惠,隱藏全選、部分選中、分組選、反選和無碼商品的邏輯

  2. 組合級別門檻提供:生成商品統計概要之後,是否滿足了要求,湊單還需什麼或者已經超過門檻了多少倍

// 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 處理器抽象模板類

對於不同的活動,需要實現活動處理模板類中的抽象方法:

  1. 關心的活動類型

  2. 處理活動數據(基礎信息 + 門檻 + 優惠)到活動泛型的映射

  3. 處理自身活動泛型和商品,生成和應用優惠方案

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 商品活動匹配

一個商品能不能使用活動的優惠,主要有以下幾種匹配方式:

通過以上的幾種情況可以看出,如果純粹按照需求來開發這塊功能,會有很大的冗餘。爲了減少重複開發量,使用組合的方式來實現。

// 定義匹配函數
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 版本發佈

各端的版本發佈流程大致相同:

  1. 將工程通過 webpack 以區分 Entry 的方式進行打包,並上傳至內部文件服務器

  2. 在發佈管理頁面操作,創建一個新的版本,綁定文件下載地址

  3. 將新的版本信息發佈到配置中心

  4. 當前環境的服務端感知到配置變化,去文件服務器拉取腳本

  5. 加載新版本到計算服務中,預熱,替換老版本,開始對外提供服務

  6. 當前環境確認服務穩定,同步至下個環境。跳轉至 4

  7. 當前環境服務不穩定,通過配置中心歷史記錄回滾。跳轉至 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