1. 程式人生 > >《如何做好軟體設計》:設計原則

《如何做好軟體設計》:設計原則

作者:yangwq 部落格:https://yangwq.cn # 前言 軟體設計是一門關注長期變化的學問,日常開發中需求不斷變化,那我們該怎麼編寫出可以支撐長期變化的程式碼呢?大多數人都認同的解決方案是利用設計模式,這裡就有一個問題:怎麼融匯貫通的將設計模式應用到實際專案中呢?這就是我們本篇文章的主題:設計原則。 個人認為設計原則是軟體設計的基石之一,所有語言都可以利用設計原則開發出可擴充套件性、可維護性、可讀性高的專案,學好設計原則,就等於我們擁有了指南針,不會迷失在各個設計模式的場景中。 鄭曄老師的《軟體設計之美》指出:設計模式是在特定問題上應用設計原則的解決方案。我們可以類比設計原則是心法,設計模式是招式,兩者相輔相成,雖然脫離對方都能使用,但是不能融會貫通。 本章主要涉及的設計原則有: 1. SOLID原則 2. KISS原則、YAGNI原則、DRY原則 接下來對各個原則進行詳細說明,有錯誤或語義不明確的地方歡迎大家指正。 ## 一、SOLID原則 1. S(Single Responsibility Principle,SRP):單一職責原則; 2. O(Open–closed principle,OCP):開放-關閉原則; 3. L(Liskov Substitution Principle,LSP):里氏替換原則; 4. I(Interface segregation principle,LSP):介面隔離原則; 5. D(Dependency inversion principle, DIP):依賴倒置原則。 ### 1、單一職責原則(Single Responsibility Principle,SRP) 本原則的定義經歷過一些變化。以前的定義是:**一個模組(模組、類、介面)僅有一個引起變化的原因**,後面升級為: **一個模組(模組、類、介面)對一類且僅對一類行為者負責**。 #### 怎麼理解一個模組(模組、類、介面)僅有一個引起變化的原因? 我們重點關注的是“變化”一詞。下面我們用程式碼來進行示例: 背景:設計一個訂單介面,能做到建立、編輯訂單和會員的贈送及過期。 ``` public interface OrderService { int createOrder(); int updateOrder(); // 下單完成後分配vip給使用者 int distributionVIP(); // vip過期 int expireVIP(); } ``` OrderService包含對訂單、VIP的操作,不管是訂單業務或VIP業務的改變,我們都需要改變這個類。這樣有什麼問題?有多個引起OrderService變化的原因導致這個類不能穩定下來,對VIP程式碼的改動有可能導致原本執行正常的訂單功能發生故障,沒有做到高內聚、低耦合。 **一個模組最理想的狀態是不改變,其次是少改變。**我們可以將對VIP的處理單獨放到一個類: ``` public interface OrderService { int createOrder(); int updateOrder(); } public interface VIPService{ // 下單完成後分配vip給使用者 int distributionVIP(); // vip過期 int expireVIP(); } ``` 這樣我們對訂單或VIP的改動都不會影響到對方正常的功能,極大程度上減少了問題發生的概率。 #### 該怎麼理解一個模組(模組、類、介面)對一類且僅對一類行為者負責? 這個定義比上面的定義多加了一個內容:變化的來源。 上面的例子可能區分不出來變化的來源,像vip這類功能一般都是訂單系統體系內的。從下面這個例子說明: 背景:在上面例子的背景下,增加對地址資訊的維護。 ```java public interface OrderService { int createOrder(); int updateOrder(); // 訂單地址的修改 int updateOrderAddress(); } ``` OrderService中對訂單地址的修改,可能是訂單負責人提出的需求,也可能是物流部門提出來:需要共用訂單地址。 這裡就需要區分兩種業務場景。 如果是訂單負責人提出的,那上面這個設計就是合理的,因為我們維護的是訂單附屬內容,而且變化的來源只有訂單系統。 但如果是物流部門提出共用訂單地址,那就需要將更改地址的介面抽離出來,因為這個需求變化的來源有兩撥人:可能是訂單,也可能是物流部門。改動如下: ``` public interface OrderService { int createOrder(); int editOrder(); } public interface AddressService { // 訂單修改地址 int updateAddressByOrder(); // 物流修改地址 int updateAddressByLogistics(); } ``` 為了職責明確我們有對介面的命名進行重構,這樣更容易被使用者接受,通過將地址的變化隔離在AddressService,後續維護地址只用修改這個類,提升了程式碼的可讀性和可維護性。 ### 2、開放-關閉原則(Open–closed principle,OCP) 定義:**對擴充套件開放,對修改關閉。**簡而言之: 不修改已有程式碼(儘可能不更改已有程式碼的情況下),新需求用新程式碼實現。 如何做到?**分離關注點,找出共性構建模型/抽象,設計擴充套件點。** 程式碼示例: 背景:設計一套通用的檔案上傳下載功能,需要支援本地盤和阿里雲OSS。一開始的設計可能是這樣的: ``` public void FileUtil { void upload(UploadParam uploadParam) { if(type == 1){ // 上傳檔案到本地盤 }else if (type == 2){ // 上傳檔案到阿里雲OSS } } void download(DownloadParam downloadParam){ if(type == 1){ // 從本地盤下載檔案 }else if (type == 2){ // 從阿里雲OSS下載檔案 } } } ``` 上面的設計有什麼問題?首先第一點UploadParam 和 DownloadParam 引數職責過重,不同方式的上傳、下載引數混合在一個類,可讀性不高,而且加入其他儲存方式的時候可能只加了上傳,漏掉了下載的改動,容易產生問題。 那我們先通過分離關注點:不同儲存方式都需要提供對應的上傳、下載操作。於是我們可以將動作拆分成上傳、下載,引數需要按不同場景選用不同的物件。改動後如下: ```java // 所有引數的父類介面 public interface BaseFileParam{ } // 統一的上傳下載介面類 public interface FileService{ /** * 上傳 */ void upload(); /** * 下載 */ void download(); } // 抽象實現,將引數作為屬性放到類中,子類可以使用 public abstract class AbstractFileService implements FileService{ protected U uploadParam; protected D downloadParam; public AbstractFileService() { } protected FileService buildUploadParam(U uploadParam){ this.uploadParam = uploadParam; return this; } protected FileService buildDownloadParam(D downloadParam){ this.downloadParam = downloadParam; return this; } protected U getUploadParam() { return uploadParam; } protected D getDownloadParam() { return downloadParam; } } // OSS實現 public class OssFileServe extends AbstractFileService { /** * 上傳到阿里雲 */ @Override public void upload() { } /** * 從阿里雲下載檔案 */ @Override public void download() { } public class OssUpload implements BaseFileParam{ } public class OssDownload implements BaseFileParam{ } } // 本地盤實現 public class LocalFileService extends AbstractFileService { /** * 上傳到本地磁碟 */ @Override public void upload() { } @Override public void download() { } public static class LocalFileUploadParams implements BaseFileParam { } public static class LocalFileDownloadParams implements BaseFileParam { } } // 使用入口 public class FileServiceDelegate { public FileService getFileService(String type, BaseFileParam upload, BaseFileParam download){ if("local".equals(type)){ return new LocalFileService().buildUploadParam(upload != null ? (LocalFileService.LocalFileUploadParams) upload : null) .buildDownloadParam(download != null ? (LocalFileService.LocalFileDownloadParams) download : null); }else if ("oss".equals(type)) { return new OssFileServe().buildUploadParam(upload != null ? (OssFileServe.OssUpload) upload : null) .buildDownloadParam(download != null ? (OssFileServe.OssDownload) download : null); }else { throw new RuntimeException("未知的上傳型別"); } } public void upload(String type, BaseFileParam baseFileParam){ getFileService(type,baseFileParam, null).upload(); } public void download(String type, BaseFileParam baseFileParam){ getFileService(type,null, baseFileParam).download(); } } ``` 以上是比較粗糙的方案,只做案例演示。後續如果需要加入亞馬遜S3儲存,我們需要改動的點: ```java // 加入S3實現 public class S3FileService extends AbstractFileService { /** * 上傳到S3 */ @Override public void upload() { } /** * 從S3下載檔案 */ @Override public void download() { } public class S3UploadParams implements BaseFileParam { } public class S3DownloadParams implements BaseFileParam { } } // 修改入口類 public class FileServiceDelegate { public FileService getFileService(String type, BaseFileParam upload, BaseFileParam download){ if("local".equals(type)){ return new LocalFileService().buildUploadParam(upload != null ? (LocalFileService.LocalFileUploadParams) upload : null) .buildDownloadParam(download != null ? (LocalFileService.LocalFileDownloadParams) download : null); }else if ("oss".equals(type)) { return new OssFileServe().buildUploadParam(upload != null ? (OssFileServe.OssUpload) upload : null) .buildDownloadParam(download != null ? (OssFileServe.OssDownload) download : null); } // 加入S3處理 else if("s3".equals(type)){ return new S3FileService().buildDownloadParam(upload != null ? (S3FileService.S3DownloadParams) upload : null) .buildDownloadParam(download != null ? (S3FileService.S3DownloadParams) download : null); }else { throw new RuntimeException("未知的上傳型別"); } } public void upload(String type, BaseFileParam baseFileParam){ getFileService(type,baseFileParam, null).upload(); } public void download(String type, BaseFileParam baseFileParam){ getFileService(type,null, baseFileParam).download(); } } ``` 上面我們修改了兩個地方,一個是加入了S3的實現類,另一個是更改入口類加入了S3的處理,這就符合新功能用新程式碼實現,但可能有人說改動了入口類,其實只要改動的程式碼沒有影響到原有的功能,小幅度的修改是可以接受的。 ### 3、里氏替換原則(Liskov Substitution Principle,LSP) 定義:**子類必須能夠替換其父類,並保證原來程式的邏輯行為不變及正確性不被破壞。** 如何實現?**站在父類的角度設計介面,子類需要滿足基於行為的IS-A關係**,更具體的來講:**子類遵守父類的行為約定**,約定包含:功能主旨,異常,輸入,輸出,註釋等。 違背功能主旨: ```java public interface OrderService { Order updateById(Order order); } public class OrderServiceImpl { public Order findById(Order order) { // 實際上是通過訂單編號進行更新的 return orderMapper.updateBySn(order); } } ``` 父類的定義原本是按訂單ID更新,在子類實現中卻變成了按訂單編號更新,這個方法就違背了功能主旨。會出現什麼問題?使用者會發現執行結果與自己期望的不一致,而且有隱藏BUG:一開始傳了訂單編號,後面訂單編號沒了,這個方法就報錯了,更嚴重一點,如果是使用mybatis的xml判斷了編號不為空進行條件拼接,此時由於編號為空就沒有了條件過濾然後更改了整個表的資料。 異常:父類規定介面不能丟擲異常,而子類丟擲了異常。 輸入:父類輸入整數型別就行,子類要求正整數才能執行。 輸出:父類執行方法要求有異常時返回null,子類重寫後直接將異常丟擲來了。 關於里氏替換原則,我們就只要記住一點:**從父類角度設計行為一致的子類**。 ### 4、介面隔離原則(Interface segregation principle,LSP) 定義:**不應強迫使用者依賴於它們不用的方法。** 通俗的理解:**對介面設計應用單一職責,根據呼叫者設計不同的介面。** 示例: ```java public class UserController{ int addUser(User user); int updateUser(User user); int deleteUser(int id); // 鎖定使用者 int lockUser(User user); } ``` 上面是一個對訂單crud的介面,現在有其他專案組的同事需要鎖定使用者的功能,然後你可能一拍腦袋直接把上面整個介面UserController扔給他(或者直接扔一個swagger文件),這樣同事會很懵逼:我只要鎖定使用者就行,為什麼還要這麼多介面? 這樣做暴露的問題: 1. 呼叫者關注了不需要的介面; 2. 多餘的介面暴露出來容易問題,每次更改介面你也不知道會不會影響其他模組的功能。 所以我們儘量要最小化暴露介面,根據不同的呼叫者僅提供他們當前需要的介面,提供的公共介面越多越難以維護。 介面隔離原則與單一職責的區別: 1、單一職責要求的的是模組、類、介面的職責單一, 2、介面隔離原則要求的是暴露給使用者的介面儘可能少。 可以這麼理解:一個類某個職責有10個介面都暴露給其他模組使用,按單一原則來講是合理的,但是按介面隔離來講是不允許的。 ### 5、依賴倒置原則(Dependency inversion principle, DIP) 定義:**高層模組不直接依賴底層模組,依賴於抽象,底層模組不依賴於細節,細節依賴於抽象。** 這一點如果我們是使用spring開發的專案就已經用到了。spring的依賴注入就是依賴倒置原則的體現。 ```java // 以前沒有使用spring的時候,我們是這樣初始化service的 // 存在的問題:1、如果需要替換成一個新的實現類,改動點太多,簡單點說就是高耦合; // 2、使用者不需要關注具體的實現類,只關注有哪些介面能用就行; // 3、物件例項不能共享,每個使用的地方都是新建的例項,實際上用同一個例項就行了。 UserService userService = new UserServiceImpl(); ``` 通過spring的IOC容器,我們只要定義好依賴關係,IOC容器就可以幫我們管理對應的例項,起到了鬆耦合的作用。 還有其他的使用場景嗎? 有,舉例: ```java public class UserServiceImpl { private KafkaProducer producer; public int addUser(User user){ // 建立使用者 // 傳送訊息到訊息佇列,由感興趣的系統訂閱並消費。 producer.send(msg); } } ``` 這裡初看沒有什麼問題,但如果後續我們更換了kafka為rabbitmq,那上面使用到kafka的類都需要重新調整。 我們利用"高層模組不直接依賴底層模組,依賴於抽象"對上面程式碼進行調整,讓我們的實現類UserServiceImpl不直接依賴KafkaProducer,而是依賴介面類MessageSender。 ```java public class UserServiceImpl { private MessageSender sender; public int addUser(User user){ // 建立使用者 // 傳送訊息到訊息佇列,由感興趣的系統訂閱並消費。 sender.send(msg); } } public interface MessageSender { void send(Map params); } // kafka 實現 public class KafkaProducer implements MessageSender{ public void send(Map params) { } } ``` 這樣一來,就算我們切換成RabbitMq,改動的點無非是對MessageSender實現的更改,而有了spring的IOC容器,我們很容易就可以更改例項實現。 ```java // rabbitmq 實現 public class RabbitmqProducer implements MessageSender{ public void send(Map params) { } } ``` 控制反轉:控制反轉是一個比較籠統的設計思想,並不是一種具體的實現方法,一般用來指導框架層面的設計。這裡的控制指的是程式執行流程的控制,反轉是從程式設計師變為框架控制。 依賴注入:一種具體的編碼技巧,不直接使用new建立物件,而是在外部將物件建立好後通過建構函式、方法、方法引數傳遞給類使用。 ## 二、KISS原則、YAGNI原則、DRY原則 這三個原則是偏理論性的概念,主要目的是指導我們學習設計原則後不要過度設計。 ## KISS(Keep it simple, stupid)原則 定義: **儘量保持簡單**。保持簡單可以讓我們的程式碼可讀性更高,維護起來也更容易。但這是一個比較抽象的概念:對於“簡單”的定義沒有統一規範,每個人的理解都不一致,這個時候就需要code review,同事有很多疑問的程式碼就要考慮是不是程式碼不夠“簡單”。 實踐過程中怎麼編寫滿足KISS原則的程式碼?以下幾點供大家參考: 1. 不要重複造輪子,複用已有的工具; 2. 方法寫得越小越好; 3. 不要使用同事可能不懂的技術來實現程式碼。 ## YAGNI(You aren’t gonna need it)原則 定義: **你不會需要它。**我們可以這樣理解:**如非必要,勿增功能。** 這一個原則我們可以用在兩個方面:需求和程式碼實現。 對於產品人員提出的需求,按照二八原則,80%的功能是用不上的,所以我們可以不做對使用者沒有價值的需求。 對於開發人員的程式碼實現,除非編寫的模組以後會頻繁變化,這種情況我們可以提前構建擴充套件點,但如果模組變化很少,我們就不需要做過多的擴充套件點,保持功能正常執行就行。 KISS原則和YAGNI原則區別: KISS原則關注的怎麼做,YAGNI原則關注的是需不需要做。 ## DRY(Don’t repeat yourself)原則: 定義:**不要重複自己**。廣泛的認知是不寫重複程式碼,更深入一點的理解是**不要對你的知識和意圖進行復制**。 在我看來:解決重複程式碼是每個程式設計師都會做的事情,但是重複的程式碼一定要解決嗎?首先要明白解決重複程式碼的重點是建立抽象,那這個抽象有沒有存在的意義?我們應該根據實際的業務場景,如果發現引起該抽象改變的原因超過一個,這說明該抽象沒有存在的意義。 例如,我們開發crud介面中常見的VO和Entity: ```java public class UserEntity { private String username; private String name; private Integer age; private String password; } public class UserVO { private String username; private String name; private Integer age; // 使用者擁有的選單 private List menuList; } ``` 我們如果按DRY原則將重複的程式碼合併到一個類: ```java public class BaseUser{ private String username; private String name; private Integer age; private String phone; } public class UserEntity extends BaseUser{ private String password; } public class UserVO { // 使用者擁有的選單 private List menuList; } ``` 改成這樣會有什麼問題?如果後續UserVO不允許暴露age屬性或者需要對手機號加密,這個時候就需要改動BaseUser和UserEntity,對UserVO的維護就會改動到BaseUser和UserEntity,一方面違反了單一職責,另一方面需要對發現所有使用BaseUser、UserEntity、UserVO的地方進行測試,增加了維護成本。 基於以上考慮,我們需要將對UserVO的改動隔離起來:還原成剛開始重複程式碼的場景。 實行DRY原則的方式: **三次法則(Rule of Three)**: 1. 第一次先寫了一段程式碼,不考慮複用性; 2. 第二次在另一個地方寫了一段相同的程式碼,可以標記為需清除重複程式碼,但是暫不處理; 3. 再次在另一個地方寫了同樣的程式碼,現在可以考慮解決重複程式碼了。 # 總結 本篇的宗旨是給大家樹立一個觀點:設計原則是設計模式的基礎,而不是設計模式的附屬物。設計模式是在特定問題應用設計原則的解決方案。但是隻用設計原則開發軟體離目標是有偏差的,所以我們也要借鑑設計模式:熟悉不同場景下設計原則的使用方式,這樣才能開發出可擴充套件性、可維護性、可讀性高的軟體。 本篇文章如有錯誤或語義不明確的地方歡迎大家