Categories
程式開發

如何實現跨平台訂單優惠計算


一、背景

1.1 介紹

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

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

1.2 業務場景

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

如何實現跨平台訂單優惠計算 1

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

  • 屬於交易核心邏輯,涉及到資產,如果計算出問題,容易對商家或買家造成資損
  • 營銷活動較多,迭代速度快,業務邏輯複雜 耳熟能詳的有:限時折扣、優惠券、滿減送、買一送一、打包一口價、積分抵現等。為了促進消費,營銷玩法會不斷地更新,同時原有的活動也一直在往細緻化發展,貼合商家使用需求
  • 開發量冗餘 業務場景對應的客戶端多,使用的技術棧也是不同的。部分端實現了本地計算,部分端暫時依賴後端優惠計算
  • 各端實現細節可能不一致,維護起來費時費力
  • 如果發生計算錯誤,很難及時修復問題

1.3 前世

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

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

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

  1. 計算過程中依賴了共享全局變量,有並發問題,無法同時計算多筆訂單,對後端使用場景來說,雖然可以通過多個執行引擎實例來實現並發安全的計算,但此方案實屬下策
  2. 沒有領域模型,營銷活動模型各不相同,實現的計算邏輯差異較大,導致代碼重用度不高
  3. 沒有設計活動互斥,互斥邏輯是硬編碼在活動處理類中的
  4. 訂單的數據結構冗餘,商品和活動模型應該是獨立的,但實際上商品模型下掛載了可以使用的活動,這樣即增加理解成本,又增加了數據序列化的開銷
  5. 沒有類型約束,開發起來,代碼提示全憑記憶,對於初次接觸該系統的人,代碼理解成本較高,開發新功能也束手束腳
  6. 處理邏輯繁瑣,在商品特別多的情況下,性能不太理想

二、新生

2.1 設計目標

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

  • 能夠提供給現有場景的多個端使用,已有的活動都需要支持。
  • 統一的模型設計。商品和活動要有對應的模型,一個活動一個模型是不能接受的,這樣代碼復用率太低。
  • 擴展性強
    1. 對後續的需求迭代,能夠很輕鬆的擴展原有功能或新增營銷活動
    2. 多個端的使用差異需要滿足
  • 性能優化。就算在商品特別多的場景,也不能出現耗時長的問題。

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

2.2 重構還是重寫?

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

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

簡單介紹下 TypeScript特點:

  • 提供了靜態類型,編譯時靜態類型檢查可以避免不少低級錯誤
  • 對代碼重構和補全提示友好
  • 多人協作起來,降低了溝通成本
  • 可以編譯成 JavaScript運行在各端

2.3 靜態類型玩得更好

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

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

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

如何實現跨平台訂單優惠計算 2

2.4 業務模型分析

2.4.1 營銷活動模型

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

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

  • 門檻 如需要滿足多少金額或商品數量,是否原價使用等
  • 優惠 如直接打多少折,減多少錢,或者 3 件 100 元這種指定金額的玩法
  • 基本信息 包含了活動唯一標識、活動類型、活動名稱

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

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

  • 計算優先級 多個活動計算優惠時,需要有優先級定義,然後按照順序計算使用
  • 使用策略 多個活動都可以使用時,要考慮互斥、可疊加和選最優
  • 作用維度 單個、多個商品使用,或者整單立減

如何實現跨平台訂單優惠計算 3

2.4.2 擴展性

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

  1. 商家可以任意配置門檻和優惠來創建活動,萬能的營銷插件
  2. 商家可以任意配置優惠活動的使用順序和使用策略
  3. 增加優惠方式,如現有抹零分為抹分、抹角、四捨五入到角,商家想要新增四捨、五入等

如何實現跨平台訂單優惠計算 4

2.4.3 商品模型

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

如何實現跨平台訂單優惠計算 5

2.4.4 活動優先級問題

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

  1. 規範了活動處理流程
  2. 活動處理順序可配置化
  3. 活動處理之間可以任意插入邏輯節點

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

  • 多個 SKU 級別優惠算完後,比較優惠額度,選擇最優的方案
  • 所有活動處理完後,整理訂單概要數據

如何實現跨平台訂單優惠計算 6

2.4.5 活動互斥模型

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

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

如何實現跨平台訂單優惠計算 7

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

如何實現跨平台訂單優惠計算 8

無侵入的活動互斥

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

大致代碼如下:

// 活动互斥容器
class PromotionMutex {
  test(a: PromotionType, b: PromotionType): boolean;
}

const promotionMutex = new PromotionMutex();

// 活动优惠计算处理
abstract class Processor {
  process({ skuWrappers }) {
    // 获取处理器关心的活动类型
    const type = this.getType()
    // 迭代SKU列表, 筛选出可用的商品(没有参与互斥活动)
    const availableSkuList = skuWrappers.filter(
      sku =>
        !sku.allAvailablePlan.some(plan =>
          promotionMutex.test(plan.type, type)
        )
    );
    // 交给处理器
    this._process({ availableSkuList });
  }
}

2.5 整體設計

2.5.1 分層設計

輸入層

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

核心計算層

  1. 構建領域模型,實際是為輸入層的數據增加了自身能力的處理邏輯。如商品應有的能力:使用改價價格、計算總價、拆分一部分數量出來、應用優惠等
  2. 將合適的商品和活動交給處理器,計算出優惠結果

結果導出層

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

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

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

如何實現跨平台訂單優惠計算 9

2.5.2 核心類圖

如何實現跨平台訂單優惠計算 10

2.6 細節設計

2.6.0 寫在前面

總結下幾個設計原則

  • 領域模型應該擁有自身的能力,而不是交給 XXXManager處理
  • 內聚的模型,代碼復用率很高
  • 增加中間層,解耦活動模型變化和計算邏輯,提升擴展性
  • 多用組合。 compose(A,B)=>Foo,compose(A,C)=>Bar
  • 避免使用擴展字段(字典),看起來大而全,實則並不能節省開發量,還浪費了類型檢查,能明確的字段就直接定義出來
  • 通過包裝原有數據對象的方式,為其添加能力,始終不會修改源數據。

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 {
  abstract types(): PromotionType();
  abstract ownModelMappings(promotion: Promotion): T;
  abstract _process(ctx: ProcessorContext): void;
}

活動模型的擴展性:各個活動總是有差異的,不需要全部按照一個固定的模型去設計。把通用的部分定義出來,允許出現特性,同時不會對外部傳入的數據做限制。

這裡主要通過增加中間層來實現活動模型的擴展性。ownModelMappings()會將數據封裝為自身所需的泛型,即使外部活動的門檻或優惠有變化,之前的計算邏輯也不用修改。

例如有這麼一個場景:有門檻和優惠關係是 1:1的活動 Foo,定義如下:

// 门槛和优惠比例 1:1
{
  condition: {type, value},
  preferential: {type, value}
}

// 优惠计算处理
class FooProcessor extends Processor {
  // 将数据转换为活动对应的泛型
  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客戶端生產環境採集新老計算耗時的數據統計。為了避免影響觀感,去除了極端場景下老版本計算超時的記錄

如何實現跨平台訂單優惠計算 11

2.6.5 測試覆蓋

開發一個項目,測試代碼是必須要有的,更何況是涉及到資產,一定要穩。

除了在開發功能階段編寫的單元測試,測試同學還提供了一系列核心用例,加上線上真實訂單計算場景的數據,都補充到了集成測試當中。

項目的測試率覆蓋如下圖:

如何實現跨平台訂單優惠計算 12

三、後端計算場景

3.1 JavaScript 運行環境選型

J2V8 Google V8 高性能 JavaScript 引擎的 Java 封裝

Nashorn JDK 內置輕量級高性能 JavaScript 運行環境 ✅

如何實現跨平台訂單優惠計算 13

如何實現跨平台訂單優惠計算 14

基於不折騰和性能不差的原則,選擇了 JVM內置的 Nashorn引擎作為後端 JavaScript 運行環境.

3.2 熱更新

後端服務感知到有新版本的 JS 發布,需要創建新的 ScriptEngine,並加載 JS 文件,然後通過靜態的訂單數據預熱,預熱結束後替換掉老的版本,對外提供服務.

值得注意的是: 假如服務正在使用 ScriptEngine 處理計算,同時又有新版本發布,創建了新的ScriptEngine,此時直接暴露出去使用,會導致腳本未加載完成的錯誤。所以需要 ScriptEngine 所有準備過程(創建, 加載腳本和預熱)封閉在工廠方法內,準備階段完成,得到的就是完全可用的 ScriptEngine

如何實現跨平台訂單優惠計算 15

3.3 版本發布

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

  1. 將工程通過 webpack 以區分 Entry 的方式進行打包,並上傳至內部文件服務器
  2. 在發布管理頁面操作,創建一個新的版本,綁定文件下載地址
  3. 將新的版本信息發佈到配置中心
  4. 當前環境的服務端感知到配置變化,去文件服務器拉取腳本
  5. 加載新版本到計算服務中,預熱,替換老版本,開始對外提供服務
  6. 當前環境確認服務穩定,同步至下個環境。跳轉至 4
  7. 當前環境服務不穩定,通過配置中心歷史記錄回滾。跳轉至 4

如何實現跨平台訂單優惠計算 16

3.4 後續的挑戰

3.4.1 支持校驗不同版本的計算結果

對於不同版本腳本計算出來的結果,後端應該用什麼版本去校驗呢?

不同版本的差異可能體現在以下幾個情況:

  • 支持的營銷活動的疊加互斥規則
  • 不支持某些活動
  • 活動使用順序變了
  • ……

如何實現跨平台訂單優惠計算 17

3.4.2 如何向前兼容

方案 1:最新版本兼容所有老版本,需要很多 feature-flag。歷史包袱會越來越重,維護成本太高了。靠人腦去維護版本的兼容是不可靠的

方案 2:服務端按需加載相應版本在內存中,使用請求對應的版本計算。無歷史包袱,內存佔用會越來越大

3.4.3 內存壓力

先看看目前的 JS 文件大小和內存佔用情況,JS 編譯到 ES5 之後,文件大了一倍多。文件大小約187K

創建了 2 個計算引擎,加載完腳本佔用內存 22.2M。經過粗略的計算, JVMScriptEngine本身佔用約 3M,加載一個 JS 計算腳本需要 7M 左右的內存成本

如何實現跨平台訂單優惠計算 18

如何實現跨平台訂單優惠計算 19

3.4.4 目前 JS 腳本 的模塊分析

webpack 打包文件時,可以通過 webpack-bundle-analyzer 插件,分析出各個模塊文件大小。統計如下:

如何實現跨平台訂單優惠計算 20

文件佔比大頭在 node_modules第三方包上,當加載多個腳本時,其實有很大的冗餘,它們在內存中的表現如下:

如何實現跨平台訂單優惠計算 21

3.4.5 優化

可以通過 作用域隔離 的方式,分離不同的版本。第三方依賴的包,是所有版本共享的。前提是後面依賴不會有變化,以訂單優惠計算的業務來講,不會需要新的依賴了。

優化之後,加載了 11 個版本。內存佔用 13M,除去 JVMScriptEngine佔用的 3M, 加載一個 JS 計算腳只需不到 1M 內存成本

結合目前的各端發版週期和版本覆蓋率的情況來看,後端按需加載對應版本,不會有太大的內存壓力。

如何實現跨平台訂單優惠計算 22

四、已經遇到的問題

4.1 版本管理

一個代碼庫,多個平台發布。一般開發新功能,先拉特性分支,開發結束後合併到 master,然後用 master來發布版本。但是當兩端的開發需求同時進行,想要發布的內容,時間節點也不一樣。那麼代碼如何合併、發布就是個問題。目前採用的方式是,代碼仍然合併到 master,各端拉發布分支的方式去發布。有新的特性或者修復,可以摘取過來,然後定期和 master同步。缺點就是端的負責人需要關注新代碼的合併,需不需要合併到發布分支,有沒有衝突問題。這種方式只能算折中之舉,後續還需要繼續思考和探索如何處理會更好、更省事。

4.2 風險

收益與風險總是並存的。各客戶端統一的核心邏輯是:開發一次,到處運行。這樣可以很大程度上提升迭代速度和一致性。但是,如果有新功能開發,通常需要評估對不同場景的影響,回歸核心用例確保穩定性。雖然系統本身有單測/集成測試覆蓋,但依然增加了測試同學的工作量。慶幸的是,隨著客戶端自動化測試和後端沙盒錄製回放的用例覆蓋率增長,風險和工作量會逐漸減小。

五、總結與展望

截止目前,移動端和後端都已經穩定上線,投入使用。也就是說,有贊零售所有的線下收銀場景都使用了這套計算框架。在訂單優惠計算方面的研發效率,至少提升了4倍人效。後面需要做的是,將自身平台有能力,但目前依賴後端計算的場景(PC 收銀、自助收銀大屏版),集成這套計算框架。實現本地計算,優化用戶體驗。

對於上線後近期的產品迭代,目前的模型設計和擴展性能夠優雅的實現需求,如在「營銷疊加互斥項目」中,對業務來講屬於大改的,其實對訂單優惠計算的影響很小,很容易就實現了,得益於設計之初就將使用策略與計算邏輯分離。

在今後的迭代中,希望能保持項目的代碼質量和良好設計 (附上郵箱 [email protected] 可內推、聊技術和代碼整潔)

本文轉載自公眾號有贊coder(ID:youzan_coder)。

原文鏈接

https://mp.weixin.qq.com/s/SaY-nXGEKd7dG2G6Fix0zA