《如何作好軟件設計》:設計原則

做者:yangwq
博客:https://yangwq.cnjava

前言

軟件設計是一門關注長期變化的學問,平常開發中需求不斷變化,那咱們該怎麼編寫出能夠支撐長期變化的代碼呢?大多數人都認同的解決方案是利用設計模式,這裏就有一個問題:怎麼融匯貫通的將設計模式應用到實際項目中呢?這就是咱們本篇文章的主題:設計原則。程序員

我的認爲設計原則是軟件設計的基石之一,全部語言均可以利用設計原則開發出可擴展性、可維護性、可讀性高的項目,學好設計原則,就等於咱們擁有了指南針,不會迷失在各個設計模式的場景中。spring

鄭曄老師的《軟件設計之美》指出:設計模式是在特定問題上應用設計原則的解決方案。咱們能夠類比設計原則是心法,設計模式是招式,二者相輔相成,雖然脫離對方都能使用,可是不能融會貫通。設計模式

本章主要涉及的設計原則有:mybatis

  1. SOLID原則
  2. KISS原則、YAGNI原則、DRY原則

接下來對各個原則進行詳細說明,有錯誤或語義不明確的地方歡迎你們指正。app

1、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):依賴倒置原則。

一、單一職責原則(Single Responsibility Principle,SRP)

本原則的定義經歷過一些變化。之前的定義是:一個模塊(模塊、類、接口)僅有一個引發變化的緣由,後面升級爲: 一個模塊(模塊、類、接口)對一類且僅對一類行爲者負責框架

怎麼理解一個模塊(模塊、類、接口)僅有一個引發變化的緣由?

咱們重點關注的是「變化」一詞。下面咱們用代碼來進行示例:ide

背景:設計一個訂單接口,能作到建立、編輯訂單和會員的贈送及過時。函數

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,後續維護地址只用修改這個類,提高了代碼的可讀性和可維護性。

二、開放-關閉原則(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的處理,這就符合新功能用新代碼實現,但可能有人說改動了入口類,其實只要改動的代碼沒有影響到原有的功能,小幅度的修改是能夠接受的。

三、里氏替換原則(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,子類重寫後直接將異常拋出來了。

關於里氏替換原則,咱們就只要記住一點:從父類角度設計行爲一致的子類

四、接口隔離原則(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. 多餘的接口暴露出來容易問題,每次更改接口你也不知道會不會影響其餘模塊的功能。

因此咱們儘可能要最小化暴露接口,根據不一樣的調用者僅提供他們當前須要的接口,提供的公共接口越多越難以維護。

接口隔離原則與單一職責的區別:

一、單一職責要求的的是模塊、類、接口的職責單一,

二、接口隔離原則要求的是暴露給使用者的接口儘量少。

能夠這麼理解:一個類某個職責有10個接口都暴露給其餘模塊使用,按單一原則來說是合理的,可是按接口隔離來說是不容許的。

五、依賴倒置原則(Dependency inversion principle, DIP)

定義:高層模塊不直接依賴底層模塊,依賴於抽象,底層模塊不依賴於細節,細節依賴於抽象。

這一點若是咱們是使用spring開發的項目就已經用到了。spring的依賴注入就是依賴倒置原則的體現。

// 之前沒有使用spring的時候,咱們是這樣初始化service的
// 存在的問題:一、若是須要替換成一個新的實現類,改動點太多,簡單點說就是高耦合;
// 二、使用者不須要關注具體的實現類,只關注有哪些接口能用就行;
// 三、對象實例不能共享,每一個使用的地方都是新建的實例,實際上用同一個實例就好了。
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建立對象,而是在外部將對象建立好後經過構造函數、方法、方法參數傳遞給類使用。

2、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. 再次在另外一個地方寫了一樣的代碼,如今能夠考慮解決重複代碼了。

總結

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

本篇文章若有錯誤或語義不明確的地方歡迎你們指正。

相關文章
相關標籤/搜索