單一職責原則詳解

楊充發表於2022-11-27

目錄介紹

  • 00.問題思考分析
  • 01.前沿基礎介紹
  • 02.如何理解單一指責
  • 03.如何判斷是否單一
  • 04.單一判斷原則
  • 05.單一就更好麼
  • 06.總結回顧一下

00.問題思考分析

  • 01.如何理解類的單一指責,單一指責中這個單一是如何評判的?
  • 02.懂了,但是會用麼,或者實際開發中有哪些運用,能否舉例說明單一職責優勢?
  • 03.單一指責是否設計越單一,越好呢?說出你的緣由和論證的思路想法?
  • 04.單一職責原則,除了應用到類的設計上,還能延伸到哪些其他設計方面嗎?

01.前沿基礎介紹

  • 學習一些經典的設計原則,其中包括,SOLID、KISS、YAGNI、DRY、LOD等。這些設計原則,從字面上理解,都不難。你一看就感覺懂了,一看就感覺掌握了,但真的用到專案中的時候,你會發現,“看懂”和“會用”是兩回事,而“用好”更是難上加難。從工作經歷來看,很多同事因為對這些原則理解得不夠透徹,導致在使用的時候過於教條主義,拿原則當真理,生搬硬套,適得其反。

02.如何理解單一指責

  • 單一職責原則的英文是 Single Responsibility Principle,縮寫為 SRP。這個原則的英文描述是這樣的:A class or module should have a single reponsibility。如果我們把它翻譯成中文,那就是:一個類或者模組只負責完成一個職責(或者功能)
  • 注意,這個原則描述的物件包含兩個,一個是類(class),一個是模組(module)。關於這兩個概念,在專欄中,有兩種理解方式。一種理解是:把模組看作比類更加抽象的概念,類也可以看作模組。另一種理解是:把模組看作比類更加粗粒度的程式碼塊,模組中包含多個類,多個類組成一個模組。
  • 不管哪種理解方式,單一職責原則在應用到這兩個描述物件的時候,道理都是相通的。為了方便你理解,接下來我只從“類”設計的角度,來講解如何應用這個設計原則。對於“模組”來說,你可以自行引申。
  • 單一職責原則的定義描述非常簡單,也不難理解。一個類只負責完成一個職責或者功能。也就是說,不要設計大而全的類,要設計粒度小、功能單一的類。換個角度來講就是,一個類包含了兩個或者兩個以上業務不相干的功能,那我們就說它職責不夠單一,應該將它拆分成多個功能更加單一、粒度更細的類
  • 舉一個例子來解釋一下。比如,一個類裡既包含訂單的一些操作,又包含使用者的一些操作。而訂單和使用者是兩個獨立的業務領域模型,我們將兩個不相干的功能放到同一個類中,那就違反了單一職責原則。為了滿足單一職責原則,我們需要將這個類拆分成兩個粒度更細、功能更加單一的兩個類:訂單類和使用者類。
  • 舉一個例子來解釋一下。比如,一個類裡既包含訂單的一些操作,又包含使用者的一些操作。而訂單和使用者是兩個獨立的業務領域模型,我們將兩個不相干的功能放到同一個類中,那就違反了單一職責原則。為了滿足單一職責原則,我們需要將這個類拆分成兩個粒度更細、功能更加單一的兩個類:訂單類和使用者類。

03.如何判斷是否單一

  • 從剛剛這個例子來看,單一職責原則看似不難應用。那是因為我舉的這個例子比較極端,一眼就能看出訂單和使用者毫不相干。但大部分情況下,類裡的方法是歸為同一類功能,還是歸為不相關的兩類功能,並不是那麼容易判定的。在真實的軟體開發中,對於一個類是否職責單一的判定,是很難拿捏的。我舉一個更加貼近實際的例子來給你解釋一下。
  • 在一個社交產品中,我們用下面的 UserInfo 類來記錄使用者的資訊。你覺得,UserInfo 類的設計是否滿足單一職責原則呢?

    public class UserInfo {
      private long userId;
      private String username;
      private String email;
      private String telephone;
      private long createTime;
      private long lastLoginTime;
      private String avatarUrl;
      private String provinceOfAddress; // 省
      private String cityOfAddress; // 市
      private String regionOfAddress; // 區 
      private String detailedAddress; // 詳細地址
      // ...省略其他屬性和方法...
    }
  • 對於這個問題,有兩種不同的觀點。

    • 一種觀點是,UserInfo 類包含的都是跟使用者相關的資訊,所有的屬性和方法都隸屬於使用者這樣一個業務模型,滿足單一職責原則;
    • 另一種觀點是,地址資訊在 UserInfo 類中,所佔的比重比較高,可以繼續拆分成獨立的 UserAddress 類,UserInfo 只保留除 Address 之外的其他資訊,拆分之後的兩個類的職責更加單一。
  • 哪種觀點更對呢?實際上,要從中做出選擇,我們不能脫離具體的應用場景。

    • 如果在這個社交產品中,使用者的地址資訊跟其他資訊一樣,只是單純地用來展示,那 UserInfo 現在的設計就是合理的。
    • 如果這個社交產品發展得比較好,之後又在產品中新增了電商的模組,使用者的地址資訊還會用在電商物流中,那我們最好將地址資訊從 UserInfo 中拆分出來,獨立成使用者物流資訊(或者叫地址資訊、收貨資訊等)。
  • 我們再進一步延伸一下。如果做這個社交產品的公司發展得越來越好,公司內部又開發出了跟多其他產品(可以理解為其他 App)。公司希望支援統一賬號系統,也就是使用者一個賬號可以在公司內部的所有產品中登入。這個時候,我們就需要繼續對 UserInfo 進行拆分,將跟身份認證相關的資訊(比如,email、telephone 等)抽取成獨立的類。
  • 從剛剛這個例子,我們可以總結出,不同的應用場景、不同階段的需求背景下,對同一個類的職責是否單一的判定,可能都是不一樣的。在某種應用場景或者當下的需求背景下,一個類的設計可能已經滿足單一職責原則了,但如果換個應用場景或著在未來的某個需求背景下,可能就不滿足了,需要繼續拆分成粒度更細的類。
  • 除此之外,從不同的業務層面去看待同一個類的設計,對類是否職責單一,也會有不同的認識。比如,例子中的 UserInfo 類。如果我們從“使用者”這個業務層面來看,UserInfo 包含的資訊都屬於使用者,滿足職責單一原則。如果我們從更加細分的“使用者展示資訊”“地址資訊”“登入認證資訊”等等這些更細粒度的業務層面來看,那 UserInfo 就應該繼續拆分。
  • 綜上所述,評價一個類的職責是否足夠單一,我們並沒有一個非常明確的、可以量化的標準,可以說,這是件非常主觀、仁者見仁智者見智的事情。實際上,在真正的軟體開發中,我們也沒必要過於未雨綢繆,過度設計。所以,我們可以先寫一個粗粒度的類,滿足業務需求。隨著業務的發展,如果粗粒度的類越來越龐大,程式碼越來越多,這個時候,我們就可以將這個粗粒度的類,拆分成幾個更細粒度的類。這就是所謂的持續重構(後面的章節中我們會講到)。

04.單一判斷原則

  • 聽到這裡,你可能會說,這個原則如此含糊不清、模稜兩可,到底該如何拿捏才好啊?我這裡還有一些小技巧,能夠很好地幫你,從側面上判定一個類的職責是否夠單一。而且,我個人覺得,下面這幾條判斷原則,比起很主觀地去思考類是否職責單一,要更有指導意義、更具有可執行性:

    • 類中的程式碼行數、函式或屬性過多,會影響程式碼的可讀性和可維護性,我們就需要考慮對類進行拆分;
    • 類依賴的其他類過多,或者依賴類的其他類過多,不符合高內聚、低耦合的設計思想,我們就需要考慮對類進行拆分;
    • 私有方法過多,我們就要考慮能否將私有方法獨立到新的類中,設定為 public 方法,供更多的類使用,從而提高程式碼的複用性;
    • 比較難給類起一個合適名字,很難用一個業務名詞概括,或者只能用一些籠統的 Manager、Context 之類的詞語來命名,這就說明類的職責定義得可能不夠清晰;
    • 類中大量的方法都是集中操作類中的某幾個屬性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 資訊,那就可以考慮將這幾個屬性和對應的方法拆分出來。
  • 不過,你可能還會有這樣的疑問:在上面的判定原則中,我提到類中的程式碼行數、函式或者屬性過多,就有可能不滿足單一職責原則。那多少行程式碼才算是行數過多呢?多少個函式、屬性才稱得上過多呢?

    • 比較初級的工程師經常會問這類問題。實際上,這個問題並不好定量地回答,就像你問大廚“放鹽少許”中的“少許”是多少,大廚也很難告訴你一個特別具體的量值。
  • 實際上, 從另一個角度來看,當一個類的程式碼,讀起來讓你頭大了,實現某個功能時不知道該用哪個函式了,想用哪個函式翻半天都找不到了,只用到一個小功能要引入整個類(類中包含很多無關此功能實現的函式)的時候,這就說明類的行數、函式、屬性過多了。實際上,等你做多專案了,程式碼寫多了,在開發中慢慢“品嚐”,自然就知道什麼是“放鹽少許”了,這就是所謂的“專業第六感”。

05.單一就更好麼

  • 為了滿足單一職責原則,是不是把類拆得越細就越好呢?答案是否定的。我們還是透過一個例子來解釋一下。Serialization 類實現了一個簡單協議的序列化和反序列功能,具體程式碼如下:

    /**
     * Protocol format: identifier-string;{gson string}
     * For example: UEUEUE;{"a":"A","b":"B"}
     */
    public class Serialization {
      private static final String IDENTIFIER_STRING = "UEUEUE;";
      private Gson gson;
      
      public Serialization() {
        this.gson = new Gson();
      }
      
      public String serialize(Map<String, String> object) {
        StringBuilder textBuilder = new StringBuilder();
        textBuilder.append(IDENTIFIER_STRING);
        textBuilder.append(gson.toJson(object));
        return textBuilder.toString();
      }
      
      public Map<String, String> deserialize(String text) {
        if (!text.startsWith(IDENTIFIER_STRING)) {
            return Collections.emptyMap();
        }
        String gsonStr = text.substring(IDENTIFIER_STRING.length());
        return gson.fromJson(gsonStr, Map.class);
      }
    }
  • 如果我們想讓類的職責更加單一,我們對 Serialization 類進一步拆分,拆分成一個只負責序列化工作的 Serializer 類和另一個只負責反序列化工作的 Deserializer 類。拆分後的具體程式碼如下所示:

    public class Serializer {
      private static final String IDENTIFIER_STRING = "UEUEUE;";
      private Gson gson;
      
      public Serializer() {
        this.gson = new Gson();
      }
      
      public String serialize(Map<String, String> object) {
        StringBuilder textBuilder = new StringBuilder();
        textBuilder.append(IDENTIFIER_STRING);
        textBuilder.append(gson.toJson(object));
        return textBuilder.toString();
      }
    }
    
    public class Deserializer {
      private static final String IDENTIFIER_STRING = "UEUEUE;";
      private Gson gson;
      
      public Deserializer() {
        this.gson = new Gson();
      }
      
      public Map<String, String> deserialize(String text) {
        if (!text.startsWith(IDENTIFIER_STRING)) {
            return Collections.emptyMap();
        }
        String gsonStr = text.substring(IDENTIFIER_STRING.length());
        return gson.fromJson(gsonStr, Map.class);
      }
    }
  • 雖然經過拆分之後,Serializer 類和 Deserializer 類的職責更加單一了,但也隨之帶來了新的問題。如果我們修改了協議的格式,資料標識從“UEUEUE”改為“DFDFDF”,或者序列化方式從 JSON 改為了 XML,那 Serializer 類和 Deserializer 類都需要做相應的修改,程式碼的內聚性顯然沒有原來 Serialization 高了。而且,如果我們僅僅對 Serializer 類做了協議修改,而忘記了修改 Deserializer 類的程式碼,那就會導致序列化、反序列化不匹配,程式執行出錯,也就是說,拆分之後,程式碼的可維護性變差了。

06.總結回顧一下

  • 1.如何理解單一職責原則(SRP)?

    • 一個類只負責完成一個職責或者功能。不要設計大而全的類,要設計粒度小、功能單一的類。單一職責原則是為了實現程式碼高內聚、低耦合,提高程式碼的複用性、可讀性、可維護性。
  • 2.如何判斷類的職責是否足夠單一?

    • 不同的應用場景、不同階段的需求背景、不同的業務層面,對同一個類的職責是否單一,可能會有不同的判定結果。實際上,一些側面的判斷指標更具有指導意義和可執行性,比如,出現下面這些情況就有可能說明這類的設計不滿足單一職責原則:
    • 類中的程式碼行數、函式或者屬性過多;
    • 類依賴的其他類過多,或者依賴類的其他類過多;
    • 私有方法過多;
    • 比較難給類起一個合適的名字;
    • 類中大量的方法都是集中操作類中的某幾個屬性。
  • 3.類的職責是否設計得越單一越好?

    • 單一職責原則透過避免設計大而全的類,避免將不相關的功能耦合在一起,來提高類的內聚性。同時,類職責單一,類依賴的和被依賴的其他類也會變少,減少了程式碼的耦合性,以此來實現程式碼的高內聚、低耦合。但是,如果拆分得過細,實際上會適得其反,反倒會降低內聚性,也會影響程式碼的可維護性。

開源專案:https://github.com/yangchong2...

開源部落格:https://github.com/yangchong2...

相關文章