學會這 10 個設計原則,離架構師又進了一步!!!

知識分享,以技會友。大家好,我是 Tom 哥。閱讀本文大約需要 15 分鐘。

閒言碎語:

一個懂設計原則的程序猿,寫出來的代碼可擴展性就是強,後續的人看代碼如沐春風。相反,如果代碼寫的跟流水賬似的,完全一根筋平鋪下來,後續無論換誰接手維護都要罵娘。

做軟件開發多年,CRUD彷彿已經形成一種慣性,深入骨髓,按照常規的結構拆分:表現層業務邏輯層數據持久層,一個功能只需要個把小時代碼就擼完了。

再結合CTRL+CCTRL+V 絕世祕籍,一個個功能點便如同雨後春筍般被快速克隆實現。

是不是有種雄霸天下的感覺,管他什麼業務場景,大爺我一梭到底,天下無敵!!!

可現實真的是這樣?

答案不言而喻!!!

初入軟件行業,很多人都會經歷這個階段。時間久了,很多人便產生困惑,能力並沒有隨着工作年限得到同比提升,焦慮失眠,如何改變現狀?

悟性高的人,很快能從一堆亂麻中找到線索,並不斷的提升自己的能力。

什麼能力?

當然是軟件架構能力,一名優秀的軟件架構師,要具備複雜的業務系統的吞吐設計能力抽象能力擴展能力穩定性

如何培養這樣能力?

**我將常用的軟件架構原則,做了彙總,目錄如下:
**

當然這些原則有些是相互輔助,有些是相互矛盾的。實際項目開發中,要根據具體業務場景,靈活應對。千萬不能教條主義,生搬硬套

單一職責

我們在編碼的時候,爲了省事,總是喜歡在一個類中添加各種各樣的功能。未來業務迭代時,再不斷的修改這個類,導致後續的維護成本很高,耦合性大。牽一髮而動全身。

爲了解決這個問題,我們在架構設計時通常會考慮單一職責

定義:

單一職責(SRP:Single Responsibility Principle),面向對象五個基本原則(SOLID)之一。每個功能只有一個職責,這樣發生變化的原因也會只有一個。通過縮小職責範圍,儘量減少錯誤的發生。

單一職責原則和一個類只幹一件事之間,最大的差別就是,將變化納入了考量。

代碼要求:

一個接口、類、方法只負責一項職責,簡單清晰。

優點:

降低了類的複雜度,提高類的可讀性、可維護性。進而提升系統的可維護性,降低變更引起的風險

示例:

有一個用戶服務接口UserService,提供了用戶註冊、登錄、查詢個人信息的方法,主要還是圍繞用戶相關的服務,看似合理。

public interface UserService{
    // 註冊接口
    Object register(Object param);
    // 登錄接口
    Object login(Object param);
    // 查詢用戶信息
    Object queryUserInfoById(Long uid);
}

過了幾天,業務方提了一個需求,用戶可以參加項目。簡單的做法是在UserService類中增加一個joinProject()方法

又過了幾天,業務方又提了一個需求,統計一個用戶參加過多少個項目,我們是不是又在UserService類中增加一個countProject()方法。

這樣導致的後果是,UserService類的職責越來越重,類會不斷膨脹,內部的實現會越來越複雜。既要負責用戶相關還有負責項目相關,後續任何一塊業務變動,都會導致這個類的修改。

兩類不同的需求,都改到同一個類。正確做法是,把不同的需求引起的變動拆分開,單獨構建一個ProjectService類,專門負責項目相關的功能

public interface ProjectService{
    // 加入一個項目
    void addProject (Object param);
    // 統計一個用戶參加過多少個項目
    void countProject(Object param);
}

這樣帶來的好處是,用戶相關的需求只要改動UserService。如果是項目管理的需求,只需要改動ProjectService。二者各自變動的理由就少了很多。

開閉原則

開閉原則(OCP:Open-Closed Principle),主要指一個類、方法、模塊 等 對擴展開放,對修改關閉。簡單來講,一個軟件實體應該通過擴展來實現變化,而不是通過修改已有的代碼來實現變化。

個人感覺,開閉原則在所有的原則中最重要,像我們耳熟能詳的 23 種設計模式,大部分都是遵循開閉原則,來解決代碼的擴展性問題。

實現思路:

採用抽象構建框架主體,用實現擴展細節。不同的業務採用不用的子類,儘量避免修改已有代碼。

優點:

示例:

比如有這樣一個業務場景,我們的電商支付平臺,需要接入一些支付渠道,項目剛啓動時由於時間緊張,我們只接入微信支付,那麼我們的代碼這樣寫:

class WeixinPay {
    public Object pay(Object requestParam) {
        // 請求微信完成支付
        // 省略。。。。
        return new Object();
    }
}

隨着業務擴展,後期開始逐步接入一些其他的支付渠道,比如支付寶雲閃付紅包支付零錢包支付積分支付等,要如何迭代?

class PayGateway {
    public Object pay(Object requestParam) {

        if(微信支付){
            // 請求微信完成支付
            // 省略。。。。
        }esle if(支付寶){
            // 請求支付寶完成支付
            // 省略。。。。
        }esle if(雲閃付){
            // 請求雲閃付完成支付
            // 省略。。。。
        }
         // 其他,不同渠道的個性化參數的抽取,轉換,適配
         // 可能有些渠道一次支付需要多次接口請求,獲取一些前置準備參數
         // 省略。。。。
        return new Object();
    }
}

所有的業務邏輯都集中到一個方法中,每一個支付渠道本身的業務邏輯又相當複雜,隨着更多支付渠道的接入,pay方法中的代碼邏輯會越來越重,維護性只會越來越差。每一次改動都要回歸測試所有的支付渠道,勞民傷財。那麼有沒有什麼好的設計原則,來解決這個問題。我們可以嘗試按開閉原則重新編排代碼

首先定義一個支付渠道的抽象接口類,把所有的支付渠道的骨架抽象出來。設計一系列的插入點,並對若干插入點流程關聯。

關於插入點,用過 OpenResty 的同學都知道,通過 set_by_lua、rewrite_by_lua、body_filter_by_lua 等不同階段來處理請求在對應階段的邏輯,有效的避免各種衍生問題。

abstract class AbstractPayChannel {
    public Object pay(Object requestParam) {
        // 抽象方法
    }
}

逐個實現不同支付渠道的子類,如:AliayPayChannelWeixinPayChannel,每個渠道都是獨立的,後期如果做渠道升級維護,只需修改對應的子類即可,降低修改代碼的影響面。

class AliayPayChannel extends  AbstractPayChannel{
    public Object pay(Object requestParam) {
        // 根據請求參數,如果選擇支付寶支付,處理後續流程
        // 支付寶處理
    }
}
class WeixinPayChannel extends  AbstractPayChannel{
    public Object pay(Object requestParam) {
        // 根據請求參數,如果選擇微信支付,處理後續流程
        // 微信處理
    }
}

總調度入口,遍歷所有的支付渠道,根據requestParam裏的參數,判斷當前渠道是否處理本次請求。

當然,也有可能採用組合支付的方式,比如,紅包支付+微信支付,可以通過上下文參數,傳遞一些中間態的數據。

class PayGateway {

    List<AbstractPayChannel> payChannelList;

    public Object pay(Object requestParam) {
        for(AbstractPayChannel channel:payChannelList){
            channel.pay(requestParam);
        }
    }
}

里氏替換

里氏替換原則(LSP:Liskov Substitution Principle):所有引用基類的地方必須能透明地使用其子類的對象

簡單來講,子類可以擴展父類的功能,但不能改變父類原有的功能(如:不能改變父類的入參,返回),跟面向對象編程的多態性類似。

多態是面向對象編程語言的一種語法,是一種代碼實現的思路。而里氏替換是一種設計原則,是用來指導繼承關係中子類如何設計,子類的設計要保證在替換父類的時候,不改變原有程序的邏輯以及不破壞原有程序的正確性。

實現思路:

接口隔離

接口隔離原則(ISP:Interface Segregation Principle)要求程序員儘量將臃腫龐大的接口拆分成更小的和更具體的接口,讓接口中只包含調用方感興趣的方法,而不應該強迫調用方依賴它不需要的接口。

實現思路:

示例:

用戶中心封裝了一套UserService接口,給上層調用(業務端以及管理後臺)提供用戶基礎服務。

public interface UserService{
    // 註冊接口
    Object register(Object param);
    // 登錄接口
    Object login(Object param);
    // 查詢用戶信息
    Object queryUserInfoById(Long uid);
}

但隨着業務衍化,我們需要提供一個刪除用戶功能,常規的做法是直接在UserService接口中增加一個deleteById方法,比較簡單。

但這樣會帶來一個安全隱患,如果該方法被普通權限的業務方誤調用,容易導致誤刪用戶,引發災難。

如何避免這個問題,我們可以採用**接口隔離的原則**

定義一個全新的接口服務,並提供deleteById方法,BopsUserService接口只提供給 Bops 管理後臺系統使用。

public interface BopsUserService{
    // 刪除用戶
    Object deleteById(Long uid);
}

總結一下,在設計微服務接口時,如果其中一些方法只限於部分調用者使用,我們可以將其拆分出來,獨立封裝,而不是強迫所有的調用方都能看到它。

依賴倒置

軟件設計中的細節具有多變性,但是抽象相對穩定,爲了利用好這個特性,我們引入了依賴倒置原則。

依賴倒置原則(DIP:Dependence Inversion Principle):高層模塊不應直接依賴低層模塊,二者應依賴於抽象;抽象不應該依賴實現細節;而實現細節應該依賴於抽象。

依賴倒置原則的主要思想是要面向接口編程,不要面向具體實現編程。

示例:

定義一個消息發送接口MessageSender,具體的實例 Bean 注入到Handler,觸發完成消息的發送。

interface MessageSender {
  void send(Message message);
}

class Handler {

  @Resource
  private MessageSender sender;
  
  void execute() {
     sender.send(message);
  }
}

假如消息的發送採用Kafka消息中間件,我們需要定義一個KafkaMessageSender實現類來實現具體的發送邏輯。

class KafkaMessageSender implements MessageSender {
  private KafkaProducer producer;
  
  public void send(final Message message) {
     producer.send(new KafkaRecord<>("topic", message));
  }
}

這樣實現的好處,將高層模塊與低層實現解耦開來。假如,後期公司升級消息中間件框架,採用 Pulsar,我們只需要定義一個PulsarMessageSender類即可,藉助 Spring 容器的@Resource會自動將其Bean實例依賴注入。

優點:

最後,要玩溜依賴倒置原則,必須要熟悉控制反轉依賴注入,如果你是 java 後端,這兩個詞語你一定不陌生,Spring框架核心設計就是依賴這兩個原則。

簡單原則

複雜系統的終極架構思路就是化繁爲簡,此簡單非彼簡單,簡單意味着靈活性的無限擴展,接下來我們來了解下這個簡單原則。

簡單原則(KISS:Keep It Simple and Stupid)。翻譯過來,保持簡單,保持愚蠢。

我們深入剖析下這個 “簡單”:

1、簡單不等於簡單設計或簡單編程。軟件開發中,爲了趕時間進度,很多技術方案簡化甚至沒有技術方案,認爲後面再找時間重構,編碼時,風格隨意,追求本次項目快速落地,導致欠下一大堆技術債。長此以往,項目維護成本越來越高。

保持簡單並不是只能做簡單設計或簡單編程,而是做設計或編程時要努力以最終產出簡單爲目標,過程可能非常複雜也沒關係。

2、簡單不等於數量少。這兩者沒有必然聯繫,代碼行少或者引入不熟悉的開源框架,看似簡單,但可能引入更復雜的問題。

如何寫出 “簡單” 的代碼?

最少原則

最少原則也稱迪米特法則(LoD:Law of Demeter)。迪米特法則定義只與你的直接朋友交談,不跟 “陌生人” 說話。

如果兩個軟件實體無須直接通信,那麼就不應當發生直接的相互調用,可以通過第三方轉發該調用。其目的是降低類之間的耦合度,提高模塊的相對獨立性。

核心思路:

示例:

現在的軟件採用分層架構,比如常見的Web --> Service --> Dao 三層結構。如果中間的Service層沒有什麼業務邏輯,但是按照迪米特法則保持層之間的密切聯繫,也要定義一個類,純粹用於Web層Dao層之間的調用轉發。

這樣傳遞效率勢必低下,而且存在大量代碼冗餘。面對此問題,我們需靈活應對,早期可以允許Web層直接調用Dao。後面隨着業務複雜度的提高,我們可以慢慢將Controller中的重業務邏輯收攏沉澱到Service層中。隨着架構的衍化,清晰的分層開始慢慢沉澱下來。

寫在最後,迪米特法則關心局部簡化,這樣很容易忽視整體的簡化。

表達原則

代碼的可維護性也是考驗工程師能力的一個重要標準。試問一個人寫的代碼,每次 code review 時都是一堆問題,你會覺得他靠譜嗎?

這時候我們就需要引入一個表達原則

表達原則(Program Intently and Expressively,簡稱 PIE),起源於敏捷編程,是指編程時應該有清晰的編程意圖,並通過代碼明確地表達出來。

表達原則的核心思想:代碼即文檔,通過代碼清晰地表達我們的真實意圖。

那麼如何提高代碼的可讀性?

1、優化代碼表現形式

無論是變量名、類名還是方法名,要命名合理,要能清晰準確的表達含義。再配合一定的中文註釋,基本不用看設計文檔就能快速的熟悉項目代碼,理解原作者的意圖。

2、改進控制流和邏輯

控制嵌套代碼的深度,比如if else的深度最好不要超多三層。外層最好提前做否定式判斷,提前終止操作或返回。這樣的代碼邏輯清晰。下面示例便是正確的處理:

public List<User> getStudents(int uid) {
    List<User> result = new ArrayList<>();
    User user = getUserByUid(uid);
    if (null == user) {
        System.out.println("獲取員工信息失敗");
        return result;
    }
    
    Manager manager = user.getManager();
    if (null == manager) {
        System.out.println("獲取領導信息失敗");
        return result;
    }

    List<User> users = manager.getUsers();
    if (null == users || users.size() == 0) {
        System.out.println("獲取員工列表失敗");
        return result;
    }

    for (User user1 : users) {
        if (user1.getAge() > 35 && "MALE".equals(user1.getSex())) {
            result.add(user1);
        }
    }
    return result;
}

分離原則

天下大事,分久必合合久必分。面對複雜的問題,考慮人腦的處理能力有限,有效的解決方案,就是大事化小,小事化了,將複雜問題拆分爲若干個小問題,通過解決小問題進而解決大問題。

分離的核心思路:

1、架構視角

結合業務場景對整個系統內若干組件進行邊界劃分,如,層與層(MVC)、模塊與模塊、服務與服務等。像現在流行的 DDD 領域驅動設計指導的微服務就是一種很好的拆解方式,通過水平分離的策略達到服務與服務之間的分離。

架構設計視角下的關注點分離更重視組件之間的分離,並通過一定的通信策略來保證架構內各個組件間的相互引用。

2、編碼視角

編碼視角主要側重於某個具體類或方法間的邊界劃分。比如Stream流的filtermaplimit,數據集在不同階段按照不同的邏輯處理,並將輸出內容作爲下一個方法的輸入,當所有的流程處理完後,最後彙總結果。

一些不錯分層案例:

1、MVC 模型

2、網絡 OSI 七層模型

一個好的架構一定具有不錯的分層,各層之間通過定義好的規範通訊 ,一旦系統中的某一部分發生了改變,並不會影響其他部分(前提,系統容錯做的足夠好)。

契約原則

天下事無規矩不成方圓,軟件架構也是一樣道理。動輒千日的大項目,如何分工協作,保證大家的工作能有條不紊的向前推進,靠的就是契約原則。

契約式原則(DbC:Design by Contract)。軟件設計時應該爲軟件組件定義一種精確和可驗證的接口規範,這種規範要包括使用的預置條件、後置條件和不變條件,用來擴展普通抽象數據類型的定義。

契約原則關注重點:

如何做好 API 接口設計?

1、接口職責分離。設計 API 的時候,應該儘量讓每一個 API 只做一個職責的事情,保證 API 的簡單和穩定性。避免相互干擾。

2、 API 命名。通過命名基本能猜出接口的功能,另外儘量使用小寫英文

3、接口具有冪等性。當一個操作執行多次所產生的影響與一次執行的影響相同

4、安全策略。如果 API 是外部使用,要考慮黑客攻擊、接口濫用,比如採用限流策略。

5、版本管理。API 發佈後不可能一成不變,很可能因爲升級導致新、舊版本的兼容性問題,解決辦法就是對API 進行版本控制和管理。

寫在最後

軟件架構原則的核心精髓,儘可能把變的部分和不變的部分分開,讓不變的部分穩定下來。我們知道,模型是相對穩定的,實現細節則是容易變動的部分。所以,構建出一個穩定的模型層,對任何一個系統而言,都是至關重要的。


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