:用接口消滅if-else,讓代碼擁抱變化)
1. 為什么說策略模式是消滅if-else的終極武器如果你寫過一段時間的業(yè)務代碼一定遇到過這種情況一個方法里密密麻麻排了十幾個if-else每來一個新需求就往里加一個分支。剛接手的時候還能看懂半年之后再回去看自己都想抽當時的自己。我有一個做電商系統(tǒng)的朋友光是訂單折扣這一塊的代碼就有兩百多行里面嵌套了七八層判斷。每次產品經理過來說加一個新活動他都要在那坨代碼里找半天該往哪里塞。這個問題的本質是你把算法的選擇和算法的實現(xiàn)耦合在了一起。而策略模式一個很樸素的想法就是把這個狀態(tài)拆開。在23種設計模式里策略模式屬于行為型模式名字叫Strategy。它做的事情一句話就能概括定義一族算法把它們分別封裝起來并且讓它們之間可以互相替換。這個模式的別名也叫Policy它的核心思想是把變化的邏輯抽出去讓穩(wěn)定的部分不再依賴具體實現(xiàn)而是依賴一個抽象接口。聽起來有點繞但對一個需要寫代碼掙錢吃飯的人來說理解它最好的方式就是動手寫一版對比。這個系列寫到這里前面的創(chuàng)建型、結構型模式已經覆蓋得差不多了到了行為型策略模式是一個繞不開的入口。我沒有按照正統(tǒng)教材的順序把它放在行為型的最后一篇是因為它在真實項目里的出場率實在太高了——支付渠道切換、折扣規(guī)則計算、校驗邏輯、排序策略、文件解析格式幾乎每一個涉及多種算法可替換的業(yè)務場景底子上都是策略模式那套骨架。這篇博文我會用一個電商訂單的折扣計算案例從頭到尾拆一遍包含完整C實現(xiàn)、Java實現(xiàn)和Python實現(xiàn)再聊一聊它在Spring里的典型應用形態(tài)最后把我在實際項目里踩過的坑一并交代清楚。內容適合設計模式入門的新手也適合寫了兩三年業(yè)務代碼想系統(tǒng)整理一下思路的同學。2. 策略模式的本質把變和不變分開2.1 一段最原始的代碼看看問題在哪先看一個最典型的反面教材。假設現(xiàn)在電商系統(tǒng)里有一個訂單類需要根據(jù)用戶類型計算折扣。剛起步的時候邏輯很簡單普通用戶不打折會員打九折VIP打八折。新手很容易寫出下面這種代碼public double calculatePrice(Order order, User user) { if (user.getType().equals(NORMAL)) { return order.getAmount() * 1.0; } else if (user.getType().equals(MEMBER)) { return order.getAmount() * 0.9; } else if (user.getType().equals(VIP)) { return order.getAmount() * 0.8; } else { return order.getAmount() * 1.0; } }這段代碼跑起來沒有問題但它有幾個隱患藏得很深。第一違背了開閉原則。哪天公司要做個88VIP雙重疊加折扣你得改這個方法而改一個到處被調用的公共方法有可能把其他分支的邏輯順帶弄掛。第二分支多到一定規(guī)模就不可維護。當判斷條件從3個漲到10個這個方法的圈復雜度已經高到任何測試都難以覆蓋全路徑。第三算法本身和調用方強綁定。折扣規(guī)則屬于業(yè)務策略有可能在多個模塊訂單、售后、對賬里都要復用現(xiàn)在它被困死在一個方法中。實際業(yè)務里我還見過更離譜的版本判斷用戶類型用的是字符串拼接條件里再套條件那已經不是if-else了那是俄羅斯方塊。2.2 策略模式怎么解開這個死結要解開這個死結策略模式引入了三個角色。Context上下文持有一個策略對象的引用負責調用策略。它不關心策略內部怎么實現(xiàn)只關心算法對外暴露的接口長什么樣。對你來說這個Context就是那個做計算的入口比如OrderService。Strategy抽象策略定義一個公共接口讓所有具體策略都實現(xiàn)這個接口。它約束了算法長什么樣但是不管算法怎么實現(xiàn)。ConcreteStrategy具體策略實現(xiàn)抽象策略接口的具體算法類。每個類封裝一種算法互不干擾。用一句話來理解這個結構Context是導演Strategy是劇本大綱ConcreteStrategy是不同版本的劇本。導演只看大綱演戲至于劇本里具體寫了什么臺詞導演不關心你給他哪本他就演哪本。以后來了新劇本導演不用改直接換一本就行。2.3 為什么這個模式能干掉if-else關鍵在于選擇邏輯被轉移了。原來的代碼里調用方自己做判斷自己選算法自己執(zhí)行算法。改造之后調用方只需要在創(chuàng)建Context的時候傳入一個具體的策略對象算法選擇的職責被移交給了外部。判斷從在方法內部變成了在調用前決定傳哪個對象進去這看起來像是把問題換了個位置但實際上完全不同。原來算法A和算法B擠在一個方法里它們共享同一個局部變量的命名空間改A容易碰壞B?,F(xiàn)在算法A和算法B是兩個物理隔離的類各自維護各自的邏輯測試互不影響新增算法不用動舊算法。另外一點很務實策略模式天然符合依賴倒置原則。上層模塊不再依賴底層具體實現(xiàn)而是依賴抽象接口。這在做單元測試的時候就舒服了——你可以很方便地替換一個Mock策略不用為了測試某條業(yè)務鏈路而把真實的算法類加載起來。3. 用一個電商案例帶你手寫一遍完整改造流程3.1 定義策略接口讓所有算法長得一樣先說設計思路?;诔R姷膶嵺`我先定義一個統(tǒng)一的折扣策略接口讓計算折扣后金額這個語義變得統(tǒng)一。無論后面來了多少種活動接口簽名不需要再變。public interface DiscountStrategy { /** * 計算折扣后的訂單金額 * param amount 原始訂單金額 * return 折扣后的金額 */ double applyDiscount(double amount); }這個接口里的方法就是所有策略的契約。這里有一個容易被忽略的點也是我想提醒你的接口方法命名要語義化最好帶上業(yè)務的意圖而非操作。我當時接手過一套代碼接口方法叫methodA三個實現(xiàn)類里分別對應三種完全無關的業(yè)務策略那才是災難。接口是給調用方看的起個好名字比什么都重要。3.2 實現(xiàn)三個具體策略每套算法一個類現(xiàn)在實現(xiàn)三個最基礎的打折策略普通用戶無折扣、會員打九折、VIP打八折。public class NormalDiscount implements DiscountStrategy { Override public double applyDiscount(double amount) { return amount; } } public class MemberDiscount implements DiscountStrategy { Override public double applyDiscount(double amount) { return amount * 0.9; } } public class VipDiscount implements DiscountStrategy { Override public double applyDiscount(double amount) { return amount * 0.8; } }每個類都單獨存在于自己的文件中互不影響。這一步看起來平平無奇價值在于后面如果超級會員要在VIP基礎上再打一次折你只需要新增一個SuperVipDiscount類其他任何代碼都不用改。這是策略模式最基本的一條紀律新算法來了不改老的實現(xiàn)類只新增一個類。如果你發(fā)現(xiàn)為了實現(xiàn)新策略得回頭改NormalDiscount或者MemberDiscount說明策略接口設計失敗了把公共不變的部分和變化的部分混在一起了。3.3 改造上下文封裝用策略這件事接下來是Context。它負責持有策略、執(zhí)行策略。這里有一個經驗之談Context不能只是簡單存一個策略字段它應該向調用方屏蔽策略調用的細節(jié)。public class OrderService { private DiscountStrategy strategy; // 可以通過構造方法注入也可以提供setter方法切換策略 public OrderService(DiscountStrategy strategy) { this.strategy strategy; } public void setStrategy(DiscountStrategy strategy) { this.strategy strategy; } public double calculate(Order order) { double amount order.getAmount(); // 這里可以加一些公共邏輯比如金額校驗、日志記錄等 if (amount 0) { throw new IllegalArgumentException(訂單金額不能為負數(shù)); } System.out.println(使用策略: strategy.getClass().getSimpleName()); return strategy.applyDiscount(amount); } }看到這里你會發(fā)現(xiàn)Context做的事情非常薄它只是”持有策略對象并調用“確實如此。但這恰恰是策略模式最重要的一個設計原則——把易變的策略隔離開不讓它污染穩(wěn)定邏輯。在真實項目里這個Context往往不是單獨存在的它可能是一個領域服務也可能是一段業(yè)務編排邏輯。無論如何穩(wěn)定的骨架和可替換的策略之間必須有一條清晰的邊界。3.4 客戶端調用和普通代碼有什么不一樣最后是客戶端的調用方式public class Client { public static void main(String[] args) { Order order new Order(1000.0); // 使用會員折扣策略 OrderService service new OrderService(new MemberDiscount()); System.out.println(會員價格: service.calculate(order)); // 動態(tài)切換為VIP策略 service.setStrategy(new VipDiscount()); System.out.println(VIP價格: service.calculate(order)); } }注意看客戶端的判斷邏輯消失了。原先的calculatePrice方法里的if-else全部消失了?,F(xiàn)在決定用哪個策略是在客戶端代碼里決定的而且這個決定可以發(fā)生在運行時——同一個OrderService對象可以隨時切換策略這是if-else做不到的。這一點往深處想它是策略模式區(qū)別于其他行為型模式比如狀態(tài)模式的關鍵。策略模式中切換策略通常是由調用方主動發(fā)起的策略之間是平行、互相獨立的關系。而狀態(tài)模式中狀態(tài)對象自己負責把Context切換到下一個狀態(tài)狀態(tài)之間是流轉的。4. 多語言視角下策略模式到底怎么落地4.1 Java語境從接口到函數(shù)式接口的演進Java里最經典的策略模式范例其實是JDK自帶的Comparator接口。你注意Comparator的定義就滿足一個算法一個類的結構——按年齡排序是一個Comparator實現(xiàn)按姓名排序是另一個實現(xiàn)。Collections.sort方法就是ContextComparator就是策略接口。Java 8引入了Lambda表達式之后策略模式在Java里的寫法發(fā)生了微妙的變化。對于只有一個抽象方法的接口函數(shù)式接口可以直接用Lambda表達式替換具體策略類不再需要為每個策略單獨建一個類public class LambdaClient { public static void main(String[] args) { Order order new Order(1000.0); // 直接用Lambda表達式作為策略對象 OrderService service new OrderService(amount - amount * 0.85); System.out.println(今日活動價: service.calculate(order)); } }這里有一個我實際用下來比較認同的建議策略邏輯如果只有一兩行用Lambda沒有毛病但策略邏輯超過三行或者這個策略可能被多處復用還是老老實實抽成類。Lambda寫起來爽但代碼里塞了七八個Lambda表達式之后可讀性照樣會崩而且沒法復用、沒法單獨測試。模板方法模式往往和策略模式混在一起出現(xiàn)。區(qū)別在于策略模式是通過組合委托替換算法模板方法是通過繼承重寫固定算法骨架。前者的關系是有一個后者的關系是是一個。在Java里盡量多用組合、少用繼承這本身就是Effective Java里的一條核心建議。4.2 Python語境函數(shù)本身就是最好的策略Python寫策略模式有一個天然優(yōu)勢——函數(shù)是一等公民??梢圆欢x策略接口直接拿函數(shù)當策略用。# 定義三個策略函數(shù)注意它們擁有相同簽名 def normal_discount(amount): return amount def member_discount(amount): return amount * 0.9 def vip_discount(amount): return amount * 0.8 class OrderService: def __init__(self, strategy): self.strategy strategy def calculate(self, order): amount order.get_amount() if amount 0: raise ValueError(訂單金額不能為負數(shù)) return self.strategy(amount)調用方式更直接order Order(1000) service OrderService(member_discount) print(service.calculate(order)) service.strategy vip_discount print(service.calculate(order))這里有個很多Python新手容易誤解的地方策略接口并不是必須存在的。Python的鴨子類型決定了只要兩個可調用對象簽名一致就可以互換使用。但我要給出一點出自實戰(zhàn)的提醒——如果項目里有大量字典分發(fā)或者多個策略彼此之間有復雜關聯(lián)仍然推薦建立一個基礎類哪怕不寫抽象方法只做類型標注至少能給后來的維護者一個明確的語義錨點。Python提供的是自由但自由需要靠紀律來約束。4.3 C語境多態(tài)策略與std::function雙路徑C實現(xiàn)策略模式最經典的方式用多態(tài)#include iostream #include memory class DiscountStrategy { public: virtual ~DiscountStrategy() default; virtual double applyDiscount(double amount) const 0; }; class NormalDiscount : public DiscountStrategy { public: double applyDiscount(double amount) const override { return amount; } }; class MemberDiscount : public DiscountStrategy { public: double applyDiscount(double amount) const override { return amount * 0.9; } }; class OrderService { private: std::shared_ptrDiscountStrategy strategy_; public: explicit OrderService(std::shared_ptrDiscountStrategy strategy) : strategy_(std::move(strategy)) {} void setStrategy(std::shared_ptrDiscountStrategy strategy) { strategy_ std::move(strategy); } double calculate(double amount) const { if (amount 0) { throw std::invalid_argument(訂單金額不能為負數(shù)); } return strategy_-applyDiscount(amount); } };C11之后有了更輕量的玩法用std::function存函數(shù)指針或者lambda。當算法足夠簡單時沒必要建類層級直接傳遞可調用對象#include functional class OrderServiceModern { private: std::functiondouble(double) strategy_; public: explicit OrderServiceModern(std::functiondouble(double) strategy) : strategy_(std::move(strategy)) {} double calculate(double amount) const { return strategy_(amount); } }; // 使用示例 auto vipDiscount [](double amount) { return amount * 0.8; }; OrderServiceModern service(vipDiscount);但請記住一個區(qū)分當策略需要持有狀態(tài)或資源時優(yōu)先用類實現(xiàn)策略完全無狀態(tài)且邏輯簡單用std::function更合適。這兩種風格并不沖突一個成熟的項目里兩種都會出現(xiàn)?;最惣雍瘮?shù)式回調的組合是C里策略模式的最佳實踐形態(tài)。5. 高級玩法當策略模式遇到工廠模式、配置文件和Spring5.1 用簡單工廠來管理策略對象的創(chuàng)建只看前面的示例可能你還會有一個困惑雖然是消除了if-else但客戶端在創(chuàng)建策略對象的時候不是還得自己判斷該new哪個類嗎這不就是把if-else從計算邏輯挪到了客戶端這個問題指向策略模式在實際業(yè)務中最常用的一種補充——把策略的選擇邏輯收攏到工廠里面。public class DiscountStrategyFactory { public static DiscountStrategy getStrategy(String userType) { switch (userType) { case MEMBER: return new MemberDiscount(); case VIP: return new VipDiscount(); case SUPER_VIP: return new SuperVipDiscount(); default: return new NormalDiscount(); } } }這個工廠方法可能是全局唯一的、會修改的地方。它承擔了”根據(jù)某種標識決定用哪個策略“的工作。配合策略模式后業(yè)務邏輯處的if-else確實沒了但工廠類里的switch在增加新策略時依然要改——不過這個改動集中在一個點而且沒有把算法邏輯和選擇邏輯混在一起是完全可以接受的。如果你用Spring連工廠都可以不手寫直接注入一個Map就好了Component public class DiscountStrategyMapper { private final MapString, DiscountStrategy strategyMap; public DiscountStrategyMapper(MapString, DiscountStrategy strategyMap) { this.strategyMap strategyMap; } public DiscountStrategy getStrategy(String userType) { return strategyMap.getOrDefault(userType, new NormalDiscount()); } }Spring會把所有DiscountStrategy類型的Bean注入到這個Map中key就是Bean的名字。新增策略時只需要加一個Component類其他代碼完全不用動。這是策略模式在Spring項目里最舒服的落地形態(tài)沒有之一。5.2 把策略的選擇權交給配置在一些更復雜的場景比如對賬系統(tǒng)、營銷系統(tǒng)中策略的選擇可能需要支持動態(tài)配置。這時候可以把策略對應的標識比如用戶類型字符串和策略類名寫到配置文件或者數(shù)據(jù)庫里用反射去加載策略類。Java里類似這種寫法// 配置文件: discount.policy.VIPcom.demo.discount.strategy.VipDiscount public class ConfigurableStrategyFactory { public DiscountStrategy create(String userType) throws Exception { Properties props new Properties(); try (InputStream in getClass().getResourceAsStream(/discount.properties)) { props.load(in); } String className props.getProperty(discount.policy. userType); return (DiscountStrategy) Class.forName(className).getDeclaredConstructor().newInstance(); } }這種方式的好處在于新策略上線甚至不需要重啟應用改一下配置即可。缺點也很明顯反射調用有性能損耗、報錯不好查、類名寫錯要到運行期才能發(fā)現(xiàn)。我的建議是除非你的策略真的需要頻繁熱更新否則老老實實用前面Spring注入Map或者工廠模式就夠了別為了炫技而用反射。5.3 和模板方法模式配合使用策略模式在設計上并不是孤立存在的。它經常和模板方法模式組合出現(xiàn)處理一類整體骨架不變個別步驟可變的場景。舉個例子在一個訂單金額計算鏈路上骨架可能是校驗訂單 - 計算原始總價 - 應用折扣策略 - 計算稅費 - 返回最終價格其中應用折扣策略這一個步驟就可以抽象為策略接口讓不同的折扣算法各自實現(xiàn)。這個組合的優(yōu)勢在于模板方法保證了流程的穩(wěn)定性策略模式保證了節(jié)點的靈活性。你既不用把整個流程推倒重來也不用在流程中間塞一堆if-else每個步驟各司其職后續(xù)排查問題時能沿著流程骨架逐步定位。6. 常見問題與排查技巧實錄6.1 策略類太多造成類爆炸怎么辦這是策略模式被批評最多的地方每增加一種策略就增加一個類策略多了之后自動生成幾十個類文件。這是一個真實存在的問題不必回避。我干過的一個項目里折扣策略有二十多個不同的類文件后來歸類發(fā)現(xiàn)很多策略其實是同一算法的不同參數(shù)配置。比如會員8.8折和會員9折區(qū)別只有一個折扣率數(shù)字。這種場景就不應該建兩個類而是提取成一個參數(shù)可配置的策略類把折扣率作為構造參數(shù)傳進去。參數(shù)化策略 工廠組合能把類數(shù)量壓縮得很明顯。6.2 策略切換后狀態(tài)殘留策略模式中一個Context對象會在一段時間內持有一個策略引用。如果策略本身是有狀態(tài)的比如緩存了一些內部數(shù)據(jù)在切換策略之后舊狀態(tài)可能泄漏到新策略中。一個比較穩(wěn)妥的做法是讓策略盡量保持無狀態(tài)如果確實需要維護狀態(tài)就保證狀態(tài)在每次調用開始時初始化或者每次創(chuàng)建新的策略實例來切換。有一回我在一個多線程環(huán)境下用過共享的策略對象結果兩個線程各自調了setStrategy那場面極其混亂——高并發(fā)場景下千萬不要讓策略對象持有可變的共享字段。6.3 策略接口設計得太大還有一些項目把策略接口設計成了上帝接口塞了十幾個方法實現(xiàn)類里一半方法是空實現(xiàn)。這是設計上的壞味道。策略接口應該只包含最核心的算法方法如果這些算法需要通過不同方法組織起來可以考慮把策略模式升級為模板方法模式或者把策略拆成多個小接口。6.4 調試技巧讓策略對象可觀測在實際開發(fā)里一個很實用的小技巧是——在Context中打印、記錄當前使用的策略類名。這樣線上排查問題時日志里直接能看到當前執(zhí)行的是哪個策略而不用對著代碼去猜。我在OrderService里加一行日志就是因為之前線上出了嚴重的價格計算異常排查時走了很多彎路。加了策略名的日志之后問題定位速度快了三倍。7. 寫在最后的經驗為什么策略模式值得你花一晚上搞明白我自己的體會是設計模式這個東西看書和上手完全兩碼事。策略模式看概念十分鐘就能懂但真正理解它妙在哪里是要在維護過一個充滿if-else的老項目之后才有的。拿我參與過的電商系統(tǒng)的例子來說最早那版折扣代碼就是兩百行的if-else后來每次上線新活動都要在代碼里小心翼翼找一個合適的位置插入新的分支生怕影響了之前的規(guī)則。用策略模式重構后核心計算邏輯只剩十行左右每次加新活動只是新增一個類的事。這種由內而外的輕松感只有經歷過的人才知道有多值得。如果你現(xiàn)在還在學校準備設計模式的期末考試或者正在寫設計模式的大作業(yè)我建議你別粘貼復制我上面的代碼而是自己動手寫一個和折扣完全無關的例子——比如文件導出策略PDF導出一套、Excel導出一套、CSV導出一套它們共用一個導出接口用一個Context切換格式。自己從零寫一遍比讀十遍別人的例子都有用。最后分享一個我個人的小習慣。剛學設計模式那會兒總覺得要背熟所有的模式才行。后來做了幾年開發(fā)慢慢發(fā)現(xiàn)真正常用的也就是策略模式、工廠模式、模板方法模式、觀察者模式那么幾個。策略模式是我最推薦的入門模式因為它很直觀——把一段不斷變化的邏輯從固定邏輯里抽出來用一個接口隔離然后讓它們可以互相替換這個思想不僅僅在編程里用得上日常做事也一樣把做什么和怎么做分開是最底層的一種抽象能力。剛開始可能會寫出一堆笨拙的類沒關系多踩幾次坑你就能找到那個最舒服的邊界了。