分享是最有效的學習方式。 博客:https://blog.ktdaddy.com/ 老貓的設計模式專欄已經偷偷發車了。不甘願做crud boy?看了好幾遍的設計模式還記不住?那就不要刻意記了,跟上老貓的步伐,在一個個有趣的職場故事中領悟設計模式的精髓吧。還等什麼?趕緊上車吧 故事 這段時間以來,小貓 ...
分享是最有效的學習方式。
博客:https://blog.ktdaddy.com/
老貓的設計模式專欄已經偷偷發車了。不甘願做crud boy?看了好幾遍的設計模式還記不住?那就不要刻意記了,跟上老貓的步伐,在一個個有趣的職場故事中領悟設計模式的精髓吧。還等什麼?趕緊上車吧
故事
這段時間以來,小貓按照之前的系統梳理方案【系統梳理大法&代碼梳理大法】一直在整理著文檔。
系統中涉及的業務以及模型也基本瞭然於胸,但是這代碼寫的真的是...
小貓也終於知道了為什麼每天都有客訴,為什麼每天都要去調用curl語句去訂正生產的數據,為什麼每天都在Hotfix...
整理了一下,大概出於這些原因,業務流程複雜暫且不議,光從技術角度來看,整個代碼體系臃腫不堪,出問題之後定位困難,後面接手的幾任開發為瞭解決問題都是“曲線救國”,不從正面去解決問題,為瞭解決一時的客訴問題而去解決問題,於是定義了各種新的修複流程去解決問題,這麼一來,軟體系統“無序”總量一直在增加,整個系統體系其實在初版之後就已經在“腐爛”了,如此?且拋開運維穩定性不談,就系統本身穩定性而言,能好?
整個系統,除了堆業務還是堆業務,但凡有點軟體設計原則,系統也不會寫成這樣了。
關於設計原則
大家在產品提出需求之後,一般都會去設計數據模型,還有系統流程。但是各位有沒有深度去設計一下代碼的實現呢?還是說上手就直接照著流程圖開始擼業務了?估計有很多的小伙伴由於各種原因不會去考慮代碼設計,其實老貓很多時候也一樣。主要原因比如:項目催的緊,哪有時間考慮那麼多,功能先做出來,剩下的等到後面慢慢優化。然而隨著時間的推移,我們會發現我們一直很忙,說好的把以前的代碼重構好一點,哪有時間!於是,就這樣“技術債”越來越多,就像滾雪球一樣,整個系統逐漸“腐爛”到了根。最終坑的可能是自己,也有可能是“下一個他”。
雖然在日常開發的時候項目進度比較緊張,我們很多時候也不去深度設計代碼實現,但是我們在寫代碼的時候保證心中有一桿秤其實還是必要的。
那咱們就結合各種案來聊聊“這桿秤”————軟體設計原則。
下麵我們通過各種小例子來協助大家理解軟體設計原則,案例是老貓構想的,有的時候不要太過較真,主要目的是講清楚原則。另外後文中也會有相關的類圖表示實體之間的關係,如果大家對類圖不太熟悉的,也可以看一下這裡【類圖傳送門】
開閉原則
開閉原則,英文(Open-Closed Principle,簡稱:OCP)。只要指一個軟體實體(例如,類,模塊和函數),應該對擴展開放,對修改關閉。其重點強調的是抽象構建框架,實現擴展細節,從而提升軟體系統的可復用性以及可維護性。
概念是抽象,但是案例是具體的,所以咱們直接看案例,通過案例去理解可能更容易。
由於小貓最近在維護商城類業務,所以咱們就從商品折價售賣這個案例出發。業務是這樣的,商城需要對商品進行做打折活動,目前針對不同品類的商品可能打折的力度不一樣,例如生活用品和汽車用品的打折情況不同。
創建一個基礎商品介面:
public interface IProduct {
String getSpuCode(); //獲取商品編號
String getSpuName(); //獲取商品名稱
BigDecimal getPrice(); //獲取商品價格
}
基礎商品實現該介面,於是我們就有瞭如下代碼:
/**
* @Author: 公眾號:程式員老貓
* @Date: 2024/2/7 23:39
*/
public class Product implements IProduct {
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;
public Product(String spuCode, String spuName, BigDecimal price, Integer categoryTag) {
this.spuCode = spuCode;
this.spuName = spuName;
this.price = price;
this.categoryTag = categoryTag;
}
public Integer getCategoryTag() {
return categoryTag;
}
@Override
public String getSpuCode() {
return spuCode;
}
@Override
public String getSpuName() {
return spuName;
}
@Override
public BigDecimal getPrice() {
return price;
}
}
按照上面的業務,現在搞活動,咱們需要針對不同品類的商品進行促銷活動,例如生活用品需要進行折扣。當然我們有兩種方式實現這個功能,如果咱們不改變原有代碼,咱們可以如下實現。
public class DailyDiscountProduct extends Product {
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private static final Integer DAILY_PRODUCT = 1;
public DailyDiscountProduct(String spuCode, String spuName, BigDecimal price) {
super(spuCode, spuName, price, DAILY_PRODUCT);
}
public BigDecimal getOriginPrice() {
return super.getPrice();
}
@Override
public BigDecimal getPrice() {
return super.getPrice().multiply(daily_discount_factor);
}
}
上面我們看到直接打折的日常用品的商品繼承了標準商品,並且對其進行了價格重寫,這樣就完成了生活用品的打折。當然這種打折繫數的話我們一般可以配置到資料庫中。
對汽車用品的打折其實也是一樣的實現。繼承之後重寫價格即可。咱們並不需要去基礎商品Product中根據不同的品類去更改商品的價格。
錯誤案例,
如果我們一味地在原始類別上去做邏輯應該就是如下這樣:
public class Product implements IProduct {
private static final Integer DAILY_PRODUCT = 1;
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;
....
@Override
public BigDecimal getPrice() {
if(categotyTag.equals(DAILY_PRODUCT)){
return price.multiply(daily_discount_factor);
}
return price;
}
}
後續隨著業務的演化,後面如果提出對商品名稱也要定製,那麼咱們可能還是會動當前的代碼,我們一直在改當前類,代碼越堆越多,越來越臃腫,這種實現方式就破壞了開閉原則。
咱們看一下開閉原則的類圖。如下:
依賴倒置原則
依賴倒置原則,英文名(Dependence Inversion Principle,簡稱DIP),指的是高層模塊不應該依賴低層模塊,二者都應該依賴其抽象。通過依賴倒置,可以減少類和類之間的耦合性,從而提高系統的穩定性。這裡主要強調的是,咱們寫代碼要面向介面編程,不要面向實現去編程。
定義看起來不夠具體,咱們來看一下下麵這樣一個業務。針對不同的大客戶,我們定製了很多商城,有些商城是專門售賣電器的,有些商城是專門售賣生活用品的。有個大客,由於對方是電器供應商,所以他們想售賣自己的電器設備,於是,我們就有了下麵的業務。
//定義了一個電器設備商城,並且支持特有的電器設備下單流程
public class ElectricalShop {
public String doOrder(){
return "電器商城下單";
}
}
//用戶進行下單購買電器設備
public class Consumer extends ElectricalShop {
public void shopping() {
super.doOrder();
}
}
我們看到,當客戶可選擇的只有一種商城的時候,這種實現方式確實好像沒有什麼問題,但是現在需求變了,馬上要過年了,大客戶不想僅僅給他們的客戶提供電器設備,他們還想賣海鮮產品,這樣,以前的這種下單模式好像會有點問題,因為以前我們直接繼承了ElectricalShop,這樣寫的話,業務可拓展性就太差了,所以我們就需要抽象出一個介面,然後客戶在下單的時候可以選擇不同的商城進行下單。於是改造之後,咱們就有瞭如下代碼:
//抽象出一個更高維度的商城介面
public interface Shop {
String doOrder();
}
//電器商城實現該介面實現自有下單流程
public class ElectricalShop implements Shop {
public String doOrder(){
return "電器商城下單";
}
}
//海鮮商城實現該介面實現自有下單流程
public class SeaFoodShop implements Shop{
@Override
public String doOrder() {
return "售賣一些海鮮產品";
}
}
//消費者註入不同的商城商品信息
public class Consumer {
private Shop shop;
public Consumer(Shop shop) {
this.shop = shop;
}
public String shopping() {
return shop.doOrder();
}
}
//消費者在不同商城隨意切換下單測試
public class ConsumerTest {
public static void main(String[] args) {
//電器商城下單
Consumer consumer = new Consumer(new ElectricalShop());
System.out.println(consumer.shopping());
//海鮮商城下單
Consumer consumer2 = new Consumer(new SeaFoodShop());
System.out.println(consumer2.shopping());
}
}
上面這樣改造之後,原本繼承詳細商城實現的Consumer類,現在直接將更高維度的商城介面註入到了類中,這樣相信後面再多幾個新的商城的下單流程都可以很方便地就完成拓展。
這其實也就是依賴倒置原則帶來的好處,咱們最終來看一下類圖。
![DIP]](https://img2024.cnblogs.com/blog/2200669/202402/2200669-20240209225838161-1213501851.png)
單一職責原則
單一職責原則,英文名(SimpleResponsibility Pinciple,簡稱SRP)指的是不要存在多餘一個導致類變更的原因。這句話看起來還是比較抽象的,老貓個人的理解是單一職責原則重點是區分業務邊界,做到合理地劃分業務,根據產品的需求不斷去重新規劃設計當前的類信息。關於單一職責老貓其實之前已經和大家分享過了,在此不多贅述,大家可以進入這個傳送門【單一職責原則】
介面隔離原則
介面隔離原則(Interface Segregation Principle,簡稱ISP)指的是指儘量提供專門的介面,而非使用一個混合的複雜介面對外提供服務。
聊到介面隔離原則,其實這種原則和單一職責原則有點類似,但是又不同:
- 聯繫:介面隔離原則和單一職責原則都是為了提高代碼的可維護性和可拓展性以及可重用性,其核心的思想都是“高內聚低耦合”。
- 區別:針對性不同,介面隔離原則針對的是介面,而單一職責原則針對的是類。
下麵,咱們用一個業務例子來說明一下吧。
我們用簡單的動物行為這樣一個例子來說明一下,動物從大的方面有能飛的,能吃,能跑,有的也會游泳等等。如果我們定義一個比較大的介面就是這樣的。
public interface IAnimal {
void eat();
void fly();
void swim();
void run();
...
}
我們用貓咪實現了該方法,於是就有了。
public class Cat implements IAnimal{
@Override
public void eat() {
System.out.println("老貓喜歡吃小魚乾");
}
@Override
public void fly() {
}
@Override
public void swim() {
}
@Override
public void run() {
System.out.println("老貓還喜歡奔跑");
}
}
我們很容易就能發現,如果老貓不是“超人貓”的話,老貓就沒辦法飛翔以及游泳,所以當前的類就有兩個空著的方法。
同樣的如果有一隻百靈鳥,那麼實現Animal介面之後,百靈鳥的游泳方法也是空著的。那麼這種實現我們發現只會讓代碼變得很臃腫,所以,我們發現IAnimal這個介面的定義太大了,我們需要根據不同的行為進行二次拆分。
拆分之後的結果如下:
//所有的動物都會吃東西
public interface IAnimal {
void eat();
}
//專註飛翔的介面
public interface IFlyAnimal {
void fly();
}
//專註游泳的介面
public interface ISwimAnimal {
void swim();
}
那如果現在有一隻鴨子和百靈鳥,咱們分別去實現的時候如下:
public class Duck implements IAnimal,ISwimAnimal{
@Override
public void eat() {
System.out.println("鴨子吃食");
}
@Override
public void swim() {
System.out.println("鴨子在河裡游泳");
}
}
public class Lark implements IAnimal,IFlyAnimal{
@Override
public void eat() {
System.out.println("百靈鳥吃食");
}
@Override
public void fly() {
System.out.println("百靈鳥會飛");
}
}
我們可以看到,這樣在我們具體的實現類中就不會存在空方法的情況,代碼隨著業務的發展也不會變得過於臃腫。
咱們看一下最終的類圖。
迪米特原則
迪米特原則(Law of Demeter,簡稱 LoD),指的是一個對象應該對其他對象保持最少的瞭解,如果上面這個原則名稱不容易記,其實這種設計原則還有兩外一個名稱,叫做最少知道原則(Least Knowledge Principle,簡稱LKP)。其實主要強調的也是降低類和類之間的耦合度,白話“不要和陌生人說話”,或者也可以理解成“讓專業的人去做專業的事情”,出現在成員變數,方法輸入、輸出參數中的類都可以稱為成員朋友類,而出現在方法體內部的類不屬於朋友類。
通過具體場景的例子來看一下。
由於小貓接手了商城類的業務,目前他對業務的實現細節應該是最清楚的,所以領導在向老闆彙報相關SKU銷售情況的時候總是會找到小貓去統計各個品類的sku的銷售額以及銷售量。於是就有了領導下命令,小貓去做統計的業務流程。
//sku商品
public class Sku {
private BigDecimal price;
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
}
//小貓統計總sku數量以及總銷售金額
public class Kitty {
public void doSkuCheck(List<Sku> skuList) {
BigDecimal totalSaleAmount =
skuList.stream().map(sku -> sku.getPrice()).reduce(BigDecimal::add).get();
System.out.println("總sku數量:" + skuList.size() + "sku總銷售金額:" + totalSaleAmount);
}
}
//領導讓小貓去統計各個品類的商品
public class Leader {
public void checkSku(Kitty kitty) {
//模擬領導指定的各個品類
List<Sku> difCategorySkuList = new ArrayList<>();
kitty.doSkuCheck(difCategorySkuList);
}
}
//測試類
public class LodTest {
public static void main(String[] args) {
Leader leader = new Leader();
Kitty kitty = new Kitty();
leader.checkSku(kitty);
}
}
從上面的例子來看,領導其實並沒有參與統計的任何事情,他只是指定了品類讓小貓去統計。從而降低了類和類之間的耦合。即“讓專門的人做專門的事”
我們看一下最終的類圖。
里氏替換原則
里氏替換原則(Liskov Substitution Principle,英文簡稱:LSP),它由芭芭拉·利斯科夫(Barbara Liskov)在1988年提出。里氏替換原則的含義是:如果一個程式中所有使用基類的地方都可以用其子類來替換,而程式的行為沒有發生變化,那麼這個子類就遵守了里氏替換原則。換句話說,一個子類應該可以完全替代它的父類,並且保持程式的正確性和一致性。
上述的定義還是比較抽象的,老貓試著重新理解一下,
- 子類可以實現父類的抽象方法,但是不能覆蓋父類的抽象方法。
- 子類可以增加自己特有的方法。
- 當子類的方法重載父類的方法的時,方法的前置條件(即方法的輸入/入參)要比父類方法的輸入參數更加寬鬆。
- 當子類的方法實現父類的方法時,方法的後置條件比父類更嚴格或者和父類一樣。
里氏替換原則準確來說是上述提到的開閉原則的實現方式,但是它剋服了繼承中重寫父類造成的可復用性變差的缺點。它是動作正確性的保證。即類的擴展不會給已有的系統引入新的錯誤,降低了代碼出錯的可能性。
下麵咱們用里式替換原則比較經典的例子來說明“鴕鳥不是鳥”。我們看一下咱們印象中的鳥類:
class Bird {
double flySpeed;
//設置飛行速度
public void setSpeed(double speed) {
flySpeed = speed;
}
//計算飛行所需要的時間
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//燕子
public class Swallow extends Bird{
}
//由於鴕鳥不能飛,所以我們將鴕鳥的速度設置為0
public class Ostrich extends Bird {
public void setSpeed(double speed) {
flySpeed = 0;
}
}
光看這個實現的時候好像沒有問題,但是我們調用其方法計算其指定距離飛行時間的時候,那麼這個時候就有問題了,如下:
public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
System.out.println(ostrich.getFlyTime(distance));
Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}
結果輸出:
Infinity
4.0
顯然鴕鳥出問題了,
- 鴕鳥重寫了鳥類的 setSpeed(double speed) 方法,這違背了里氏替換原則。
- 燕子和鴕鳥都是鳥類,但是父類抽取的共性有問題,鴕鳥的飛行不是正常鳥類的功能,需要特殊處理,應該抽取更加共性的功能。
於是我們進行對其進行優化,咱們取消鴕鳥原來的繼承關係,定義鳥和鴕鳥的更一般的父類,如動物類,它們都有奔跑的能力。鴕鳥的飛行速度雖然為 0,但奔跑速度不為 0,可以計算出其奔跑指定距離所要花費的時間。優化之後代碼如下:
//抽象出更高層次的動物類,定義內部的奔跑行為
public class Animal {
double runSpeed;
//設置奔跑速度
public void setSpeed(double speed) {
runSpeed = speed;
}
//計算奔跑所需要的時間
public double getRunTime(double distance) {
return (distance / runSpeed);
}
}
//定義飛行的鳥類
public class Bird extends Animal {
double flySpeed;
//設置飛行速度
public void setSpeed(double speed) {
flySpeed = speed;
}
//計算飛行所需要的時間
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//此時鴕鳥直接繼承動物介面
public class Ostrich extends Animal {
}
//燕子繼承普通的鳥類介面
public class Swallow extends Bird {
}
簡單測試一下:
public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
ostrich.setSpeed(40);
System.out.println(ostrich.getRunTime(distance));
Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}
結果輸出:
3.0
4.0
優化之後,優點:
- 代碼共用,減少創建類的工作量,每個子類都擁有父類的方法和屬性;
- 提高代碼的重用性;
- 提高代碼的可擴展性;
- 提高產品或項目的開放性;
缺點:
- 繼承是侵入性的。只要繼承,就必須擁有父類的所有屬性和方法;
- 降低代碼的靈活性。子類必須擁有父類的屬性和方法,讓子類自由的世界中多了些約束;
- 增強了耦合性。當父類的常量、變數和方法被修改時,需要考慮子類的修改,而且在缺乏規範的環境下,這種修改可能帶來非常糟糕的結果————大段的代碼需要重構。
最終我們看一下類圖:
老貓覺得里氏替換原則是最難把握好的,所以到後續咱們再進行深入涉及模式回歸的時候再做深入探究。
合成復用原則
合成復用原則(Composite/Aggregate Reuse Principle,英文簡稱CARP)是指咱們儘量要使用對象組合而不是繼承關係達到軟體復用的目的。這樣的話系統就可以變得更加靈活,同時也降低了類和類之間的耦合度。
看個例子,當我們剛學java的時候都是從jdbc開始學起來的。所以對於DBConnection我們並不陌生。那當我們實現基本產品Dao層的時候,我們就有瞭如下寫法:
public class DBConnection {
public String getConnection(){
return "獲取資料庫鏈接";
}
}
//基礎產品dao層
public class ProductDao {
private DBConnection dbConnection;
public ProductDao(DBConnection dbConnection) {
this.dbConnection = dbConnection;
}
public void saveProduct(){
String conn = dbConnection.getConnection();
System.out.println("使用"+conn+"新增商品");
}
}
上述就是最簡單的合成服用原則應用場景。但是這裡有個問題,DBConnection目前只支持mysql一種連接DB的方式,顯然不合理,有很多企業其實還需要支持Oracle資料庫鏈接,所以為了符合之前說到的開閉原則,我們讓DBConnection交給子類去實現。於是我們可以將其定義成抽象方法。
public abstract class DBConnection {
public abstract String getConnection();
}
//mysql鏈接
public class MySqlConnection extends DBConnection{
@Override
public String getConnection() {
return "獲取mysql鏈接";
}
}
//oracle鏈接
public class OracleConnection extends DBConnection{
@Override
public String getConnection() {
return "獲取Oracle鏈接方式";
}
}
最終的實現方式我們一起看一下類圖。
總結
之前看過一個故事,一棟樓的破敗往往從一扇破窗戶開始,慢慢腐朽。其實代碼的腐爛其實也是一樣,往往是一段拓展性極差的代碼開始。所以這要求我們研發人員還是得心中有桿“設計原則”的秤,咱們可能不會去做刻意的代碼設計,但是相信有這麼一桿原則的秤,代碼也不致於會寫得太爛。
當然我們也不要刻意去追求設計原則,要權衡具體的場景做出合理的取捨。
設計原則是設計模式的基礎,相信大家在瞭解完設計原則之後對後續的設計模式會有更加深刻的理解。