《如何做好軟體設計》:設計原則
阿新 • • 發佈: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這類功能一般都是訂單系統體系內的。從下面這個例子說明:
背景:在上面例子的背景下,增加對地址資訊的維護。
```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 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儲存,我們需要改動的點:
```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 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關係**,更具體的來講:**子類遵守父類的行為約定**,約定包含:功能主旨,異常,輸入,輸出,註釋等。
違背功能主旨:
```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. 再次在另一個地方寫了同樣的程式碼,現在可以考慮解決重複程式碼了。
# 總結
本篇的宗旨是給大家樹立一個觀點:設計原則是設計模式的基礎,而不是設計模式的附屬物。設計模式是在特定問題應用設計原則的解決方案。但是隻用設計原則開發軟體離目標是有偏差的,所以我們也要借鑑設計模式:熟悉不同場景下設計原則的使用方式,這樣才能開發出可擴充套件性、可維護性、可讀性高的軟體。
本篇文章如有錯誤或語義不明確的地方歡迎大家