如何用 Design Patterns 優雅設計優惠券功能?
今天我們從實戰的角度來聊聊設計模式中使用頻率非常高的裝飾者模式。
你可以把裝飾器模式想象爲項目轉包,
甲
與乙
簽訂了項目合同,乙
轉頭又將該項目轉包給丙
,而丙
同樣可以將項目交給外包團隊丁
來實施。丁
作爲項目的真實實施者,最終得到了項目合同金額的 25%。其餘的利潤被轉包者瓜分,丙
抽取 35%,乙
抽取 40%。項目的層層轉包及抽取佣金的過程,就可以用裝飾器模式來描述。
一、問題引入
假如你和你的團隊已經開發了一款線上購物平臺,商家在平臺上發佈商品,用戶可以瀏覽感興趣的商品並進行購買。儘管你們在貨源上已經儘量壓低商品價格,並且平臺並未收取任何的服務費,但銷售額始終不太理想。經過公司高層的商議,決定採取產品組同事推出的新方案:以優惠來刺激消費者。最終,你收到產品組同事提出的如下商品優惠方案。
-
滿減券:比如滿 100 減 20,如果金額沒有達到門檻額度,則不能使用該滿減券;
-
抵用券:直接抵用,當金額低於抵用券金額時,以金額值爲抵用額度;
-
折扣券:在原始價格基礎上進行折扣;
以上三種優惠券可相互疊加使用,並且同類型的優惠券也可疊加。除此之外,需求中還要求了用戶在購物車內瀏覽商品時,可計算出選中商品的原始總價、優惠後價格和優惠的計算依據。
我們該如何描述購物車內商品所使用的優惠呢?
或許可以按照優惠類型進行分類,併爲其提供特定的優惠計算器對象表示。比如我們可以將優惠分爲使用滿減計算器
、抵用計算器
和折扣計算器
。
但同時,我們也要考慮多種優惠的組合,由此衍生出來有:滿減&抵用計算器
、滿減&折扣計算器
、抵用&折扣計算器
、滿減&抵用&折扣計算器
。
此時,我們似乎意識到一些如下的問題。
(問題一)如何描述同一種優惠類型的疊加
對於同類型優惠券的疊加,上述的設計無法解決。比如,商品使用了兩張抵用券和一張滿減券的優惠組合,我們無法藉助上述的任何一種優惠計算器來表述。
(問題二)擴展將帶來類爆炸
到目前爲止,我們僅有 3 種優惠類型,採用上面的方式來表述所有的可能,我們需要定義 7 個類(3 一種優惠 + 3 兩種優惠組合 + 1 三種優惠組合)。但如果此時需要新增一種優惠類型,那麼就需要【4 (一種優惠)+ 6(兩種優惠組合)+ 4(三種優惠組合)+ 1(四種優惠組合)】15 個類進行表示。隨着優惠類型的擴展,系統中類的數量將呈現幾何式的上升,這是典型的類爆炸。
(問題三)優惠的順序很重要
對於組合優惠來說,優惠順序很重要。假如現有滿減 & 折扣類型的商品,商品總價爲 100 元,滿減券爲滿 50 減 20,折扣券爲七折。
-
先使用滿減券:實際價格 = (100 - 20) * 0.7 = 56
-
先使用折扣券:實際價格 = (100 * 0.7) - 20 = 50
對於同樣的優惠組合來說,不同的使用順序將有不一樣的最終價格。在這個例子中,優惠的使用順序很重要。而上面的案例則無法表示同樣優惠組合的不同使用順序。
二、解決方案
2.1 組合代替繼承
在前面我們嘗試使用繼承來擴展類,但在解決這個問題時明顯行不通。或許我們一開始就陷入了錯誤的方向,我們無法表示所有商品使用的優惠類型,因爲我們無法窮盡所有的優惠類型的可能。在這個問題的解決方案上面,我們不得不另闢蹊徑。如果你已經在面向對象的圈子中混跡過一段時日,那麼你應該聽過一句廣爲流傳的名言:組合優於繼承。是的,採用組合代替繼承,能完美解決繼承所帶來的類爆炸問題。
2.2 遞歸向下求解
現在,我們僅剩下問題一和問題三。回想一下,在上面我們是如何計算購物車內商品的最終價格的?
正如我們在問題中描述的那樣,優惠的先後順序可能影響最終的價格,所以價格計算的步驟是預定義且不允許修改的,否則我們將得到錯誤的最終結果。而對於任意一個組合優惠來說,最終的價格就是優惠的順序疊加。如上表所示,一個優惠組合的價格計算可以總結爲:從當前的最終價格中扣除即將優惠的價格,而當前的最終價格等於前一個優惠的最終價格扣錢前一個優惠的價格。這樣說可能有些繞,或許下面的圖更有助於理解。
正如該計算過程圖所示,商品的最終價格從商品的原始總價一步一步的計算得出。如果我們有辦法將每一次優惠的價格計算都封裝在對象內部處理,並且各個對象可以相互替換,那剩下的兩個問題也都能迎刃而解。
我們可以動態的爲現有的優惠組合插入新的優惠類型,而不用關心該優惠的具體類型,儘管組合中已經有該優惠的類型了,問題一得到解決。優惠的順序就是處理鏈的順序,按照特定方式的構建,必將按照同樣的方式進行計算,問題三也不復存在。
而上面的要求正是裝飾器模式所提倡的,裝飾器模式建議我們將複雜問題的求解過程拆分開來,並且在對象直接進行層層委託,直到該對象已經無法再委託給任何其他對象爲止。並且,裝飾器模式要求所有的對象都具有同樣的行爲定義,這樣就可以輕鬆的在運行時對所有對象進行任意的排列組合。
三、案例實現
按照解決方案中的分析,我們來逐步實現該案例。在前面我們說道:裝飾器模式要求所有的對象都必須具有同樣的行爲定義,所以,我們定義一個抽象的費用計算器CostCalculator
,案例中的所有對象(各種優惠類型計算器、購物車)都必須實現費用計算器的行爲。
除此之外,我們注意到購物車本身和其他優惠類型有些細微差別,購物車本身不具備任何優惠行爲,購物車的費用計算就是各種商品列表的原價總和。而各個優惠計算器對象必須依賴於購物車或者其他的優惠計算器對象,因爲他們的原始費用來源於所依賴的對象。總而言之,所有的優惠計算器對象必須依賴一個已有的費用計算器對象。
3.1 案例類圖
按照上訴的分析,我們得到如下的類圖結構。
在該類圖中,CostCalculator
表示抽象的費用計算器,定義了兩個行爲,分別是優惠的描述description()
和計算費用finalCost()
。ShoppingCart
代表購物車,除了實現CostCalculator
中定義的行爲外,還有添加商品addGoods()
和獲取商品列表的詳細信息getDetails()
,GoodDetail
爲商品類。AbstractCostDecorator
爲抽象的優惠計算器,統一定義了所有優惠計算器所需的依賴calculator
,三個實現類分別是折扣計算器DiscountDecorator
、滿減計算器FullDiscountDecorator
和抵用計算器VoucherDecorator
。在滿減計算器中,提供了獲取當前總金額是否跨過滿減門檻的方法aboveThreshold()
。
3.2 代碼附錄
代碼層次及類說明如上所示,更多內容請參考案例代碼。客戶端示例代碼如下
public class Client {
public static void main(String[] args) {
System.out.println("|==> Start --------------------------------------------------------------|");
ShoppingCart cart = new ShoppingCart();
// 添加商品
cart.addGoods(new ShoppingCart.GoodsDetail("夏季T恤", BigDecimal.valueOf(59.9), 2));
cart.addGoods(new ShoppingCart.GoodsDetail("網球拍", BigDecimal.valueOf(100), 1));
cart.addGoods(new ShoppingCart.GoodsDetail("網紅款家用驅蚊液", BigDecimal.valueOf(28.5), 2));
System.out.println(MessageFormat.format(" 購物車商品明細:\n{0}", cart.getDetails()));
System.out.println(MessageFormat.format(" 商品原價:【{0}元】", cart.finalCost()));
// 添加優惠:一張折扣券(8.5折)、一張滿減券(滿100減20)、兩張抵用券(20元、5元)
VoucherDecorator decorator =
new VoucherDecorator(
new VoucherDecorator(
new FullDiscountDecorator(
new DiscountDecorator(cart, BigDecimal.valueOf(0.85)), BigDecimal.valueOf(20), BigDecimal.valueOf(100)
), BigDecimal.valueOf(20)
), BigDecimal.valueOf(5)
);
System.out.println(MessageFormat.format(" 優惠後價格:【{0}元】,優惠說明:【{1}】",
decorator.finalCost(),
decorator.description()));
}
}
運行結果如下
|==> Start --------------------------------------------------------------|
購物車商品明細:
商品名:【夏季T恤】,商品單價:【59.9元】,商品數量:【2】
商品名:【網球拍】,商品單價:【100元】,商品數量:【1】
商品名:【網紅款家用驅蚊液】,商品單價:【28.5元】,商品數量:【2】
商品原價:【276.8元】
優惠後價格:【190.28元】,優惠說明:【商品總費用 -> 8.5折扣 -> 滿100減20 -> 20元抵用券 -> 5元抵用券】
四、裝飾器模式
4.1 意圖
指在不改變原有對象結構的基礎情況下,動態地給該對象增加一些額外功能的職責。
裝飾器模式的核心就是在運行時,不停的給一個對象添加一些功能。操作手法就是把一個包裝好的對象作爲原始對象再次進行包裝。
想象一下我們煮了一碗麪條,往麪條裏面放入一勺牛肉,這樣就變成了牛肉麪;再往麪條裏面放入一勺酸菜,這樣就變成了酸菜牛肉麪。但不管往麪條裏面加入多少的配菜,主食必不可少。這也很像中文裏面的形容詞和名詞的關係,名詞可以被多個形容詞進行修飾。
4.2 類圖結構
讓我們看一下更加通用的裝飾器模式的類圖結構:
裝飾器模式的參與角色如下:
-
Component:組件,定義一個對象接口;
-
ConcreteComponent:具體的組件,無法包裝組件對象的原始組件;
-
Decorator:抽象的裝飾器,本身是組件的實現,同時也包裝了一個組件;
-
ConcreteDecorator:具體的裝飾器實現;
五、深入
5.1 使用技巧
(1)省略抽象的裝飾器
當你僅需一個 ConcreteDecorator 時,沒有必要定義抽象的 Decorator。
(2)儘量保證 Component 的簡單性
在裝飾器模式中,高層的組件(Component)是所有對象的根源,因此應該儘量保證 Component 的簡單性。否則,Component 難以被大量複用,越簡單的定義複用性的可能性越高。建議僅僅在 Component 中定義接口,而不要存儲任何數據。如果現有的 Component 已經相當龐大了,此時使用裝飾器模式可能爲此付出的代價過高,可以考慮使用策略模式(Strategy)而不是裝飾器模式。
六、在源碼中看裝飾器模式
我們在各種源碼中都能看到裝飾器模式的影子,例如:
(1)java.io.InputStream
(2)javax.servlet.ServletRequestWrapper
ServletRequestWrapper 是 ServletRequest 接口的裝飾器實現,開發者可以繼承 ServletRequestWrapper 去擴展原有的 ServletRequest。
附錄
- 案例代碼 https://github.com/i-tracy/patterns-for-me/blob/master/cases-structural/src/main/java/com/patterns/decorator
作者:i-tracy
來源:github.com/i-tracy/patterns-for-me/blob/master/doc/s/Decorator.md
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/5yf_27IDOkk-UE9Wd1OwLw