如何提高代碼的擴展性

一、架構的高可用?(不是本文的重點)

(1)【客戶端層】到【反向代理層】的高可用,是通過反向代理層的冗餘來實現的。以 nginx 爲例:有兩臺 nginx,一臺對線上提供服務,另一臺冗餘以保證高可用,常見的實踐是 keepalived 存活探測,相同 virtual IP 提供服務。當 nginx 掛了的時候,keepalived 能夠探測到,會自動的進行故障轉移,將流量自動遷移到 shadow-nginx,由於使用的是相同的 virtual IP,這個切換過程對調用方是透明的。

(2)【反向代理層】到【站點層】的高可用,是通過站點層的冗餘來實現的。假設反向代理層是 nginx,nginx.conf 裏能夠配置多個 web 後端,並且 nginx 能夠探測到多個後端的存活性。當 web-server 掛了的時候,nginx 能夠探測到,會自動的進行故障轉移,將流量自動遷移到其他的 web-server,整個過程由 nginx 自動完成,對調用方是透明的。

(3)【站點層】到【服務層】的高可用,是通過服務層的冗餘來實現的。“服務連接池”會建立與下游服務多個連接,每次請求會 “隨機” 選取連接來訪問下游服務。當 service 掛了的時候,service-connection-pool 能夠探測到,會自動的進行故障轉移,將流量自動遷移到其他的 service,整個過程由連接池自動完成,對調用方是透明的(所以說 RPC-client 中的服務連接池是很重要的基礎組件)。

(4)【服務層】到【緩存層】的高可用,是通過緩存數據的冗餘來實現的。

緩存層的數據冗餘又有幾種方式:第一種是利用客戶端的封裝,service 對 cache 進行雙讀或者雙寫。緩存層也可以通過支持主從同步的緩存集羣來解決緩存層的高可用問題。當 redis 主掛了的時候,sentinel 能夠探測到,會通知調用方訪問新的 redis,整個過程由 sentinel 和 redis 集羣配合完成,對調用方是透明的。

    (5)【服務層】到【數據庫讀】的高可用,是通過讀庫的冗餘來實現的。

既然冗餘了讀庫,一般來說就至少有 2 個從庫,“數據庫連接池” 會建立與讀庫多個連接,每次請求會路由到這些讀庫。當讀庫掛了的時候,db-connection-pool 能夠探測到,會自動的進行故障轉移,將流量自動遷移到其他的讀庫,整個過程由連接池自動完成,對調用方是透明的(所以說 DAO 中的數據庫連接池是很重要的基礎組件)。

(6)【服務層】到【數據庫寫】的高可用,是通過寫庫的冗餘來實現的。

以 mysql 爲例,可以設置兩個 mysql 雙主同步,一臺對線上提供服務,另一臺冗餘以保證高可用,常見的實踐是 keepalived 存活探測,相同 virtual IP 提供服務。自動故障轉移:當寫庫掛了的時候,keepalived 能夠探測到,會自動的進行故障轉移,將流量自動遷移到 shadow-db-master,由於使用的是相同的 virtual IP,這個切換過程對調用方是透明的。

X 軸和 Z 軸已經趨於成熟。以後的發展方向必定是業務功能的發展,代碼的高可用。

二、軟件項目的變化(爲什麼要提高代碼的可擴展性【背景】)

軟件設計的唯一產出物 --- 代碼

面向對象的目的是模塊化

三、什麼是高內聚、低耦合

模塊就是從系統層次去分成不同的部分,每個部分就是一個模塊!分而治之, 將大型系統的複雜問題,分成不同的小模塊,去處理問題!

耦合:主要是講模塊與模塊之間的聯繫

例如:如果模塊 A 直接操作了模塊 B 的數據,這種操作模塊與模塊之間就爲強耦合,甚至可以認爲這種情況之下基本算沒有分模塊!如果 A 只是通過數據與 B 模塊交互,這種我們稱之爲弱耦合!微服務獨立的模塊,方便去維護,或者寫單元測試等等... 如果木塊之間的依賴非常嚴重,將會非常不易於維護。

內聚:主要指的是模塊內部【東西聚合在一起形成了一個模塊】例如方法,變量,對象,或者是功能模塊。

模塊內部的代碼, 相互之間的聯繫越強,內聚就越高, 模塊的獨立性就越好。一個模塊應該儘量的獨立,去完成獨立的功能!如果有代碼非得引入到獨立的模塊,建議拆分成多模塊!低內聚的代碼,不好維護,代碼也不夠健壯。

四、軟件設計的目的

1、如何評價代碼的質量

最重要的是:靈活性;可擴展性;可維護性;可讀性。

2、如何實現代碼的高質量?

遵循 SOLID 設計原則:(接口設計原則)參考依據高內聚、低耦合

單一職責原則:一個類值負責一個功能的職責

開閉原則:擴展開放,修改關閉。

里氏代換原則:使用父類的地方都能使用子類對象

依賴倒轉原則:針對接口編程,

接口隔離原則:針對不同部分用專門接口,不用總接口,需要哪些接口就用哪些接口

你認爲下圖的設計違反了哪一種設計原則?

(1)違反了單一職責原則(SRP)

畫圖和計算面積並不是單一職責,計算幾何學應用程序只計算面積不畫圖,但是還要引入 GUI。

應該有且僅有一個原因引起類的變更。簡單點說,一個類,最好只負責一件事,只有一個引起它變化的原因。也就是說引起類變化的原因只有一個。高內聚、低耦合是軟件設計追求的目標,而單一職責原則可以看做是高內聚、低耦合的引申,將職責定義爲引起變化的原因,以提高內聚性,以此來減少引起變化的原因。職責過多,可能引起變化的原因就越多,這將是導致職責依賴,相互之間就產生影響,從而極大的損傷其內聚性和耦合度。單一職責通常意味着單一的功能,因此不要爲類實現過多的功能點,以保證實體只有一個引起它變化的原因。

(2)開閉原則

對擴展開放。模塊對擴展開放,就意味着需求變化時,可以對模塊擴展,使其具有滿足那些改變的新行爲。換句話說,模塊通過擴展的方式去應對需求的變化。

對修改關閉。模塊對修改關閉,表示當需求變化時,關閉對模塊源代碼的修改,當然這裏的 “關閉” 應該是儘可能不修改的意思,也就是說,應該儘量在不修改源代碼的基礎上面擴展組件。

一個開閉原則的簡單實例(懂則不用看)

     對拓展開放,對修改關閉:比如當某個業務增加,不是在原類增加方法,而是增加原類的實現類。

下面的例子是一個非常典型的開閉原則及其實現。非常簡單,但卻能夠很好的說明開閉原則。

假設有一個應用程序,能夠計算任意形狀面積。這是幾年前我在明尼蘇達州農作物保險公司遇到的一個非常簡單問題。app 程序必須能夠計算出指定區域的農作物總的保險報價。正如你所知道的,農作物有各種形狀和大小,有可能是圓的,有可能是三角形的也可能是其他各種多邊形。

OK,讓我們回到我們之前的例子中.... 

作爲一名優秀的程序員,我們將這個面積計算類命名爲 AreaManager。這個 AreaManager 是單一職責的類:計算形狀的總面積 。

假設我們現在有一塊矩形的農作物,我 omen 用一個 Rectangle 類來表示。相關類代碼如下:

public class Rectangle {
    private double length;
    private double height; 
    // getters/setters ... 
}
public class AreaManager {
    public double calculateArea(ArrayList<Rectangle>... shapes) {
        double area = 0;
        for (Rectangle rect : shapes) {
            area += (rect.getLength() * rect.getHeight()); 
        }
        return area;
    }
}
AreaManager類現在運行良好,直到幾周之後,我們又有一種新的形狀——圓形:
public class Circle {
    private double radius; 
    // getters/setters ...
}
由於有新的形狀需要考慮,我們必須修改我們的AreaManager類:
public class AreaManager {
    public double calculateArea(ArrayList<Object>... shapes) {
        double area = 0;
        for (Object shape : shapes) {
            if (shape instanceof Rectangle) {
                Rectangle rect = (Rectangle)shape;
                area += (rect.getLength() * rect.getHeight());                
            } else if (shape instanceof Circle) {
                Circle circle = (Circle)shape;
                area += (circle.getRadius() * cirlce.getRadius() * Math.PI;
            } else {
                throw new RuntimeException("Shape not supported");
            }            
        }
        return area;
    }
}
從這段代碼開始,我們察覺到了問題。
如果我們遇到一個三角形,或者其他形狀呢,這時候我們就必須一次又一次的修改AreaManager類。
這個類的設計就違背了開閉原則,沒有做到對修改的封閉性以及對擴展的開放性。我們必須避免這種事情的發生~
基於繼承的開閉原則的實現
AreaManager類的職責是計算各種形狀的面積,而每一種形狀都有其獨特的計算面積的方法,因此將面積的計算放入到各個形狀類中是特別合理的。
AreaManager類仍然需要知道所有的形狀,否則它就無法判斷所有的形狀類是否都包含了計算面積的方法。當然了,我們可以通過反射來實現。其實有一種更簡單的方式也可以實現——讓所有的形狀類都繼承一個接口:Shape(也可以是抽象類)
public interface Shape {
    double getArea(); 
}
每一個形狀類都實現這個接口(如果接口無法滿足你的需求,也可以通過繼承某個抽象類):
public class Rectangle implements Shape {
   private double length;
   private double height; 
   // getters/setters ... 
   @Override
   public double getArea() {
       return (length * height);
   }
}
public class Circle implements Shape {
   private double radius; 
   // getters/setters ...
   @Override
   public double getArea() {
       return (radius * radius * Math.PI);
   }
}
現在,我們可以通過這個抽象方法將AreaManager構造成一個符合開閉原則的類。
public class AreaManager {
    public double calculateArea(ArrayList<Shape> shapes) {
        double area = 0;
        for (Shape shape : shapes) {
            area += shape.getArea();
        }
        return area;
    }
}
通過這種方式, AreaManager類符合了對修改關閉,對擴展開放的要求。如果我們需要增加一種新形狀,比如:八邊形。新的類只需要繼承Shape接口即可,AreaManager根本不需要做任何的修改。
作者:4d3bf4cac28c
鏈接:https://www.jianshu.com/p/6c8a9611b38b
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

通過擴展去應對需求變化,就要求我們必須要面向接口編程,或者說面向抽象編程。所有參數類型、引用傳遞的對象必須使用抽象(接口或者抽象類)的方式定義,不能使用實現類的方式定義;通過抽象去界定擴展,比如我們定義了一個接口 A 的參數,那麼我們的擴展只能是接口 A 的實現類。總的來說,開閉原則提高系統的可維護性和代碼的重用性。

指導建議:使用者先定義接口

(3)里氏替換原則

如上圖釋義,一個軟件實體如果使用的是一個基類的話,那麼一定適用於其子類,而且它根本不能察覺出基類對象和子類對象的區別。

比如,假設有兩個類,一個是 Base 類,另一個是 Child 類,並且 Child 類是 Base 的子類。那麼一個方法如果可以接受一個基類對象 b 的話: method1(Base b) 那麼它必然可以接受一個子類的對象 method1(Child c).

里氏替換原則是繼承複用的基石。只有當衍生類可以替換掉基類,軟件單位的功能不會受到影響時,基類才能真正的被複用,而衍生類也才能夠在基類的基礎上增加新的行爲。

問題由來:有一功能 P1,由類 A 完成。現需要將功能 P1 進行擴展,擴展後的功能爲 P,其中 P 由原有功能 P1 與新功能 P2 組成。新功能 P 由類 A 的子類 B 來完成,則子類 B 在完成新功能 P2 的同時,有可能會導致原有功能 P1 發生故障。

解決方案:當使用繼承時,遵循里氏替換原則。類 B 繼承類 A 時,除添加新的方法完成新增功能 P2 外,儘量不要重寫父類 A 的方法,也儘量不要重載父類 A 的方法。

里氏替換原則通俗的來講就是:子類可以擴展父類的功能,但不能改變父類原有的功能。它包含以下 4 層含義:

(4)依賴倒置原則

官方釋義:高層模塊不應該依賴低層模塊,兩者都應該依賴其抽象;抽象不應該依賴細節,細節應該依賴抽象。

     上面的定義主要包含兩次意思:

    1)高層模塊不應該直接依賴於底層模塊的具體實現,而應該依賴於底層的抽象。換言之,模塊間的依賴是通過抽象發生,實現類之間不發生直接的依賴關係,其依賴關係是通過接口或抽象類產生的。

     2)接口和抽象類不應該依賴於實現類,而實現類依賴接口或抽象類。這一點其實不用多說,很好理解,“面向接口編程” 思想正是這點的最好體現。

相比傳統的軟件設計架構,比如我們常說的經典的三層架構,UI 層依賴於 BLL 層,BLL 層依賴於 DAL 層。由於每一層都是依賴於下層的實現,這樣當某一層的結構發生變化時,它的上層就不得不也要發生改變,比如我們 DAL 裏面邏輯發生了變化,可能會導致 BLL 和 UI 層都隨之發生變化,這種架構是非常荒謬的!好,這個時候如果我們換一種設計思路,高層模塊不直接依賴低層的實現,而是依賴於低層模塊的抽象,具體表現爲我們增加一個 IBLL 層,裏面定義業務邏輯的接口,UI 層依賴於 IBLL 層,BLL 層實現 IBLL 裏面的接口,所以具體的業務邏輯則定義在 BLL 裏面,這個時候如果我們 BLL 裏面的邏輯發生變化,只要接口的行爲不變,上層 UI 裏面就不用發生任何變化。

在三層架構裏面增加一個接口層能實現依賴倒置,它的目的就是降低層與層之間的耦合,使得設計更加靈活。從這點上來說,依賴倒置原則也是 “松耦合” 設計的很好體現。

依賴倒置原則(DIP)例子:

未進行依賴倒置設計:司機只開一種車

遵循依賴倒置原則設計:司機可以開多種車

https://blog.csdn.net/yabay2208/article/details/73826719

(5)接口隔離原則

ISP:其一是不應該強行要求客戶端依賴於它們不用的接口;其二是類之間的依賴應該建立在最小的接口上面。簡單點說,客戶端需要什麼功能,就提供什麼接口,對於客戶端不需要的接口不應該強行要求其依賴;類之間的依賴應該建立在最小的接口上面,這裏最小的粒度取決於單一職責原則的劃分。

如果客戶端依賴了它們不需要的接口,那麼這些客戶端程序就面臨不需要的接口變更引起的客戶端變更的風險,這樣就會增加客戶端和接口之間的耦合程度,顯然與 “高內聚、低耦合” 的思想相矛盾。

類之間的依賴應該建立在最小的接口上面。何爲最小的接口,即能夠滿足項目需求的相似功能作爲一個接口,這樣設計主要就是爲了 “高內聚”。那麼我們如何設計最小的接口呢?那就要說說粒度的劃分了,粒度細化的程度取決於我們上一章講的的單一職責原則裏面接口劃分的粒度。從這一點來說,接口隔離和單一職責兩個原則有一定的相似性。

不同:

(1)單一職責原則更加偏向對業務的約束,接口隔離原則更加偏向設計架構的約束。

(2)從接口的細化程度來說,單一職責原則對接口的劃分更加精細,而接口隔離原則注重的是相同功能的接口的隔離。接口隔離裏面的最小接口有時可以是多個單一職責的公共接口。

(3)從原則約束的側重點來說,接口隔離原則更關注的是接口依賴程度的隔離,更加關注接口的 “高內聚”;而單一職責原則更加註重的是接口職責的劃分。

未遵循接口隔離原則的設計

遵循接口隔離原則的設計

 1、場景舉例分析

二、場景示例
下面就以我們傳統行業的訂單操作爲例來說明下接口隔離的必要性。
1、胖接口
軟件設計最初,我們的想法是相同功能的方法放在同一個接口裏面,如下,所有訂單的操作都放在訂單接口IOrder裏面。理論上來說,這貌似沒錯。我們來看看如何設計。
   public interface IOrder
    {
        //訂單申請操作
        void Apply(object order);
        //訂單審覈操作
        void Approve(object order);
        //訂單結束操作
        void End(object order);
    }
剛開始只有銷售訂單,我們只需要實現這個接口就好了。
    public class SaleOrder:IOrder
    {
        public void Apply(object order)
        {
            throw new NotImplementedException();
        }
        public void Approve(object order)
        {
            throw new NotImplementedException();
        }
        public void End(object order)
        {
            throw new NotImplementedException();
        }
    }
後來,隨着系統的不斷擴展,我們需要加入生產訂單,生產訂單也有一些單獨的接口方法,比如:排產、凍結、導入、導出等操作。於是我們向訂單的接口裏面繼續加入這些方法。於是訂單的接口變成這樣:
    public interface IOrder
    {
        //訂單申請操作
        void Apply(object order);
        //訂單審覈操作
        void Approve(object order);
        //訂單結束操作
        void End(object order);
        //訂單下發操作
        void PlantProduct(object order);
     //訂單凍結操作
        void Hold(object order);
        //訂單刪除操作
        void Delete(object order);
        //訂單導入操作
        void Import();
        //訂單導出操作
        void Export();
    }
我們生產訂單的實現類如下
    //生產訂單實現類
    public class ProduceOrder : IOrder
    {
        /// <summary>
        /// 對於生產訂單來說無用的接口
        /// </summary>
        /// <param ></param>
        public void Apply(object order)
        {
            throw new NotImplementedException();
        }
        /// <summary>
        /// 對於生產訂單來說無用的接口
        /// </summary>
        /// <param ></param>
        public void Approve(object order)
        {
            throw new NotImplementedException();
        }
        /// <summary>
        /// 對於生產訂單來說無用的接口
        /// </summary>
        /// <param ></param>
        public void End(object order)
        {
            throw new NotImplementedException();
        }
        public void PlantProduct(object order)
        {
            Console.WriteLine("訂單下發排產");
        }
     public void Hold(object order)
        {
            Console.WriteLine("訂單凍結");
        }
        public void Delete(object order)
        {
            Console.WriteLine("訂單刪除");
        }
        public void Import()
        {
            Console.WriteLine("訂單導入");
        }
        public void Export()
        {
            Console.WriteLine("訂單導出");
        }
    }
銷售訂單的實現類也要相應做修改
    //銷售訂單實現類
    public class SaleOrder:IOrder
    {
        public void Apply(object order)
        {
            Console.WriteLine("訂單申請");
        }
        public void Approve(object order)
        {
            Console.WriteLine("訂單審覈處理");
        }
        public void End(object order)
        {
            Console.WriteLine("訂單結束");
        }
        #region 對於銷售訂單無用的接口方法
        public void PlantProduct(object order)
        {
            throw new NotImplementedException();
        }
     public void Hold(object order)
        {
            throw new NotImplementedException();
        }
        public void Delete(object order)
        {
            throw new NotImplementedException();
        }
        public void Import()
        {
            throw new NotImplementedException();
        }
        public void Export()
        {
            throw new NotImplementedException();
        } 
        #endregion
    }
需求做完了,上線正常運行。貌似問題也不大。系統運行一段時間之後,新的需求變更來了,要求生成訂單需要一個訂單撤銷排產的功能,那麼我們的接口是不是就得增加一個訂單撤排的接口方法CancelProduct。於是乎接口變成這樣:
public interface IOrder
    {
        //訂單申請操作
        void Apply(object order);
        //訂單審覈操作
        void Approve(object order);
        //訂單結束操作
        void End(object order);
        //訂單下發操作
        void PlantProduct(object order);
        //訂單撤排操作
        void CancelProduct(object order);
        //訂單凍結操作
        void Hold(object order);
        //訂單刪除操作
        void Delete(object order);
        //訂單導入操作
        void Import();
        //訂單導出操作
        void Export();
    }
這個時候問題就來了,我們的生產訂單隻要實現這個撤銷的接口貌似就OK了,但是我們的銷售訂單呢,本來銷售訂單這一塊我們不想做任何的變更,可是由於我們IOrder接口裏面增加了一個方法,銷售訂單的實現類是不是也必須要實現一個無效的接口方法?這就是我們常說的“胖接口”導致的問題。由於接口過“胖”,每一個實現類依賴了它們不需要的接口,使得層與層之間的耦合度增加,結果導致了不需要的接口發生變化時,實現類也不得不相應的發生改變。這裏就凸顯了我們接口隔離原則的必要性,下面我們就來看看如何通過接口隔離來解決上述問題。
2、接口隔離
我們將IOrder接口分成兩個接口來設計
    //刪除訂單接口
    public interface IProductOrder
    {
        //訂單下發操作
        void PlantProduct(object order);
        //訂單撤排操作
        void CancelProduct(object order);
        //訂單凍結操作
        void Hold(object order);
        //訂單刪除操作
        void Delete(object order);
        //訂單導入操作
        void Import();
        //訂單導出操作
        void Export();
    }
    //銷售訂單接口
    public interface ISaleOrder
    {
        //訂單申請操作
        void Apply(object order);
        //訂單審覈操作
        void Approve(object order);
        //訂單結束操作
        void End(object order);
    }
對應的實現類只需要實現自己需要的接口即可
    //生產訂單實現類
    public class ProduceOrder : IProductOrder
    {
        public void PlantProduct(object order)
        {
            Console.WriteLine("訂單下發排產");
        }
        public void CancelProduct(object order)
        {
            Console.WriteLine("訂單撤排");
        }
        public void Hold(object order)
        {
            Console.WriteLine("訂單凍結");
        }
        public void Delete(object order)
        {
            Console.WriteLine("訂單刪除");
        }
        public void Import()
        {
            Console.WriteLine("訂單導入");
        }
        public void Export()
        {
            Console.WriteLine("訂單導出");
        }
    }
    //銷售訂單實現類
    public class SaleOrder : ISaleOrder
    {
        public void Apply(object order)
        {
            Console.WriteLine("訂單申請");
        }
        public void Approve(object order)
        {
            Console.WriteLine("訂單審覈處理");
        }
        public void End(object order)
        {
            Console.WriteLine("訂單結束");
        }
    }

這樣設計就能完美解決上述 “胖接口” 導致的問題,如果需要增加訂單操作,只需要在對應的接口和實現類上面修改即可,這樣就不存在依賴不需要接口的情況。通過這種設計,降低了單個接口的複雜度,使得接口的 “內聚性” 更高,“耦合性”更低。由此可以看出接口隔離原則的必要性。

另:

有稱六大設計原則:+ 迪米特法則。

迪米特法則的定義是:只與你的直接朋友交談,不跟 “陌生人” 說話(Talk only to your immediate friends and not to strangers)。其含義是:如果兩個軟件實體無須直接通信,那麼就不應當發生直接的相互調用,可以通過第三方轉發該調用。其目的是降低類之間的耦合度,提高模塊的相對獨立性。

舉例:明星由於全身心投入藝術,所以許多日常事務由經紀人負責處理,如與粉絲的見面會,與媒體公司的業務洽淡等。這裏的經紀人是明星的朋友,而粉絲和媒體公司是陌生人,所以適合使用迪米特法則,其類圖如圖 所示。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KHgaB3K5ikz1caZkpaFZGA