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

yangwqonly發表於2021-02-06

作者: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這類功能一般都是訂單系統體系內的。從下面這個例子說明:

背景:在上面例子的背景下,增加對地址資訊的維護。

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 引數職責過重,不同方式的上傳、下載引數混合在一個類,可讀性不高,而且加入其他儲存方式的時候可能只加了上傳,漏掉了下載的改動,容易產生問題。

那我們先通過分離關注點:不同儲存方式都需要提供對應的上傳、下載操作。於是我們可以將動作拆分成上傳、下載,引數需要按不同場景選用不同的物件。改動後如下:

// 所有引數的父類介面
public interface BaseFileParam{
	
}

// 統一的上傳下載介面類
public  interface FileService<U,D>{
    
    /**
     * 上傳
     */
    void upload();

    /**
     * 下載
     */
    void  download();

}

// 抽象實現,將引數作為屬性放到類中,子類可以使用
public abstract class AbstractFileService<U,D> implements FileService<U,D>{
    protected U uploadParam;
    protected D downloadParam;

    public AbstractFileService() {
    }

    protected FileService<U, D> buildUploadParam(U uploadParam){
        this.uploadParam = uploadParam;
        return this;
    }
    
    protected FileService<U, D> buildDownloadParam(D downloadParam){
        this.downloadParam = downloadParam;
        return this;
    }
    
    protected U getUploadParam() {
        return uploadParam;
    }

    protected D getDownloadParam() {
        return downloadParam;
    }
}



// OSS實現
public class OssFileServe extends AbstractFileService<OssFileServe.OssUpload, OssFileServe.OssDownload> {

    /**
     * 上傳到阿里雲
     */
    @Override
    public void upload() {

    }

    /**
     * 從阿里雲下載檔案
     */
    @Override
    public void download() {

    }

    public class OssUpload implements BaseFileParam{

    }

    public class OssDownload implements BaseFileParam{

    }

}

// 本地盤實現
public class LocalFileService extends AbstractFileService<LocalFileService.LocalFileUploadParams, LocalFileService.LocalFileDownloadParams> {

    /**
     * 上傳到本地磁碟
     */
    @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<? extends BaseFileParam,? extends BaseFileParam> 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儲存,我們需要改動的點:

// 加入S3實現
public class S3FileService extends AbstractFileService<S3FileService.S3UploadParams, S3FileService.S3DownloadParams> {

    /**
     * 上傳到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<? extends BaseFileParam,? extends BaseFileParam> 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關係,更具體的來講:子類遵守父類的行為約定,約定包含:功能主旨,異常,輸入,輸出,註釋等。

違背功能主旨:

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)

定義:不應強迫使用者依賴於它們不用的方法。 通俗的理解:對介面設計應用單一職責,根據呼叫者設計不同的介面。

示例:

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的依賴注入就是依賴倒置原則的體現。

// 以前沒有使用spring的時候,我們是這樣初始化service的
// 存在的問題:1、如果需要替換成一個新的實現類,改動點太多,簡單點說就是高耦合;
// 2、使用者不需要關注具體的實現類,只關注有哪些介面能用就行;
// 3、物件例項不能共享,每個使用的地方都是新建的例項,實際上用同一個例項就行了。
UserService userService = new UserServiceImpl();

通過spring的IOC容器,我們只要定義好依賴關係,IOC容器就可以幫我們管理對應的例項,起到了鬆耦合的作用。

還有其他的使用場景嗎?

有,舉例:

public class UserServiceImpl {
    private KafkaProducer producer;
    
    public int addUser(User user){
        // 建立使用者
        
        // 傳送訊息到訊息佇列,由感興趣的系統訂閱並消費。
        producer.send(msg);
    }
}

這裡初看沒有什麼問題,但如果後續我們更換了kafka為rabbitmq,那上面使用到kafka的類都需要重新調整。

我們利用"高層模組不直接依賴底層模組,依賴於抽象"對上面程式碼進行調整,讓我們的實現類UserServiceImpl不直接依賴KafkaProducer,而是依賴介面類MessageSender。

public class UserServiceImpl {
    private MessageSender sender;
    
    public int addUser(User user){
        // 建立使用者
        
        // 傳送訊息到訊息佇列,由感興趣的系統訂閱並消費。
        sender.send(msg);
    }
}

public interface MessageSender {
    void send(Map<String,String> params);
}

// kafka 實現
public class KafkaProducer implements MessageSender{
    public void send(Map<String,String> params) {
        
    }
}

這樣一來,就算我們切換成RabbitMq,改動的點無非是對MessageSender實現的更改,而有了spring的IOC容器,我們很容易就可以更改例項實現。

// rabbitmq 實現
public class RabbitmqProducer implements MessageSender{
    public void send(Map<String,String> 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:

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原則將重複的程式碼合併到一個類:

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. 再次在另一個地方寫了同樣的程式碼,現在可以考慮解決重複程式碼了。

總結

本篇的宗旨是給大家樹立一個觀點:設計原則是設計模式的基礎,而不是設計模式的附屬物。設計模式是在特定問題應用設計原則的解決方案。但是隻用設計原則開發軟體離目標是有偏差的,所以我們也要借鑑設計模式:熟悉不同場景下設計原則的使用方式,這樣才能開發出可擴充套件性、可維護性、可讀性高的軟體。

本篇文章如有錯誤或語義不明確的地方歡迎大家指正。

相關文章