設計原則
- 一.理論一:對於單一職責原則,如何判定某個類的職責是否夠“單一”?
- 二. 如何做到“對擴展開放、修改關閉”?擴展和修改各指什麼?
- 三. 裏式替換(LSP)跟多態有何區別?哪些代碼違背了LSP?
- 四.接口隔離原則有哪三種應用?原則中的“接口”該如何理解?
- 五. 理論五:控制反轉、依賴反轉、依賴注入,這三者有何區別和聯繫?
- 六. 理論六:我爲何說KISS、YAGNI原則看似簡單,卻經常被用錯?
- 七. 理論七:重複的代碼就一定違背DRY嗎?如何提高代碼的複用性?
- 八.理論八:如何用迪米特法則(LOD)實現“高內聚、松耦合”?
- 1. 何爲“高內聚、松耦合”?
- 2. “迪米特法則”理論描述
- 3.“迪米特法則”理論之前半部分---{不該有直接依賴關係的類之間,不要有依賴}
- 4.“迪米特法則”理論之後半部分---{有依賴關係的類之間,儘量只依賴必要的接口}
- 5. 辯證思考與靈活應用
- 6. 總結
- 九. 實戰一(上):針對業務系統的開發,如何做需求分析和設計
- 十. 實戰一(下):如何實現一個遵從設計原則的積分兌換系統?
- 十一. 實戰二(上):針對非業務的通用框架開發,如何做需求分析和設計?
一.理論一:對於單一職責原則,如何判定某個類的職責是否夠“單一”?
1. 如何理解單一職責原則(SRP)?
- 單一職責原則
一個類或者模塊只負責完成一個職責(或者功能)
-
一個類包含了兩個或者兩個以上業務不相干的功能,那我們就說它職責不夠單一,應該將它拆分成多個功能更加單一、粒度更細的類。
-
例子:
比如,一個類裏既包含訂單的一些操作,又包含用戶的一些操作。而訂單和用戶是兩個獨立的業務領域模型,我們將兩個不相干的功能放到同一個類中,那就違反了單一職責原則。爲了滿足單一職責原則,我們需要將這個類拆分成兩個粒度更細、功能更加單一的兩個類:訂單類和用戶類
-
不同的應用場景、不同階段的需求背景下,對同一個類的職責是否單一的判定,可能都是不一樣的。在某種應用場景或者當下的需求背景下,一個類的設計可能已經滿足單一職責原則了,但如果換個應用場景或着在未來的某個需求背景下,可能就不滿足了,需要繼續拆分成粒度更細的類
-
解決方法:實際上,在真正的軟件開發中,我們也沒必要過於未雨綢繆,過度設計。所以,我們可以先寫一個粗粒度的類,滿足業務需求。隨着業務的發展,如果粗粒度的類越來越龐大,代碼越來越多,這個時候,我們就可以將這個粗粒度的類,拆分成幾個更細粒度的類。這就是所謂的持續重構
-
下面這幾條判斷原則,比起很主觀地去思考類是否職責單一,要更有指導意義、更具有可執行性:
- 類中的代碼行數、函數或屬性過多,會影響代碼的可讀性和可維護性,我們就需要考慮對類進行拆分
- 類依賴的其他類過多,或者依賴類的其他類過多,不符合高內聚、低耦合的設計思想,我們就需要考慮對類進行拆分;
- 私有方法過多,我們就要考慮能否將私有方法獨立到新的類中,設置爲 public 方法,供更多的類使用,從而提高代碼的複用性;
- 比較難給類起一個合適名字,很難用一個業務名詞概括,或者只能用一些籠統的 Manager、Context 之類的詞語來命名,這就說明類的職責定義得可能不夠清晰;
- 類中大量的方法都是集中操作類中的某幾個屬性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考慮將這幾個屬性和對應的方法拆分出來
- 如果拆分得過細,實際上會適得其反,反倒會降低內聚性,也會影響代碼的可維護性。
二. 如何做到“對擴展開放、修改關閉”?擴展和修改各指什麼?
- 如何理解“對擴展開放、修改關閉”?
開閉原則: 軟件實體(模塊、類、方法等)應該“對擴展開放、對修改關閉”。
添加一個新的功能應該是,在已有代碼基礎上擴展代碼(新增模塊、類、方法等),而非修改已有代碼(修改模塊、類、方法等)。
- 例子:
(1). api接口監控功能
public class Alert {
private AlertRule rule; //存儲告警規則,可以自由設置
//告警通知類,支持郵件、短信、微信、手機等多種通知渠道
private Notification notification;
public Alert(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
(2). 現需要添加一個功能,當每秒鐘接口超時請求個數,超過某個預先設置的最大閾值時,我們也要觸發告警發送通知。這個時候,我們該如何改動代碼呢?主要的改動有兩處:第一處是修改 check() 函數的入參,添加一個新的統計數據 timeoutCount,表示超時接口請求數;第二處是在 check() 函數中添加新的告警邏輯。具體的代碼改動如下所示:
public class Alert {
// ...省略AlertRule/Notification屬性和構造函數...
// 改動一:添加參數timeoutCount
public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
// 改動二:添加接口超時處理邏輯
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
(3). 問題:一方面,我們對接口進行了修改,這就意味着調用這個接口的代碼都要做相應的修改。另一方面,修改了 check() 函數,相應的單元測試都需要修改
(4). 上面的代碼改動是基於“修改”的方式來實現新功能的。如果我們遵循開閉原則,也就是“對擴展開放、對修改關閉”。那如何通過“擴展”的方式,來實現同樣的功能呢?
- 第一部分是將 check() 函數的多個入參封裝成 ApiStatInfo 類;
- 第二部分是引入 handler 的概念,將 if 判斷邏輯分散在各個 handler 中
(5). 具體實現
public class Alert {
private List<AlertHandler> alertHandlers = new ArrayList<>();
public void addAlertHandler(AlertHandler alertHandler) {
this.alertHandlers.add(alertHandler);
}
public void check(ApiStatInfo apiStatInfo) {
for (AlertHandler handler : alertHandlers) {
handler.check(apiStatInfo);
}
}
}
public class ApiStatInfo {//省略constructor/getter/setter方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
}
public abstract class AlertHandler {
protected AlertRule rule;
protected Notification notification;
public AlertHandler(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public abstract void check(ApiStatInfo apiStatInfo);
}
public class TpsAlertHandler extends AlertHandler {
public TpsAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds();
if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}
public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification){
super(rule, notification);
}
@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}
(6). 使用:
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;
public void initializeBeans() {
alertRule = new AlertRule(/*.省略參數.*/); //省略一些初始化代碼
notification = new Notification(/*.省略參數.*/); //省略一些初始化代碼
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
}
public Alert getAlert() { return alert; }
// 餓漢式單例
private static final ApplicationContext instance = new ApplicationContext();
private ApplicationContext() {
instance.initializeBeans();
}
public static ApplicationContext getInstance() {
return instance;
}
}
public class Demo {
public static void main(String[] args) {
ApiStatInfo apiStatInfo = new ApiStatInfo();
// ...省略設置apiStatInfo數據值的代碼
ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}
}
- 如何做到“對擴展開放、修改關閉”?
實際上,開閉原則講的就是代碼的擴展性問題,是判斷一段代碼是否易擴展的“金標準”。如果某段代碼在應對未來需求變化的時候,能夠做到“對擴展開放、對修改關閉”,那就說明這段代碼的擴展性比較好。所以,問如何才能做到“對擴展開放、對修改關閉”,也就粗略地等同於在問,如何才能寫出擴展性好的代碼
在講具體的方法論之前,我們先來看一些更加偏向頂層的指導思想。爲了儘量寫出擴展性好的代碼,我們要時刻具備擴展意識、抽象意識、封裝意識。這些“潛意識”可能比任何開發技巧都重要。
-
在寫代碼的時候後,我們要多花點時間往前多思考一下,這段代碼未來可能有哪些需求變更、如何設計代碼結構,事先留好擴展點,以便在未來需求變更的時候,不需要改動代碼整體結構、做到最小代碼改動的情況下,新的代碼能夠很靈活地插入到擴展點上,做到“對擴展開放、對修改關閉”
-
還有,在識別出代碼可變部分和不可變部分之後,我們要將可變部分封裝起來,隔離變化,提供抽象化的不可變接口,給上層系統使用。當具體的實現發生變化的時候,我們只需要基於相同的抽象接口,擴展一個新的實現,替換掉老的實現即可,上游系統的代碼幾乎不需要修改。
-
如何在項目中靈活應用開閉原則?
- 寫出支持“對擴展開放、對修改關閉”的代碼的關鍵是預留擴展點
- 最合理的做法是,對於一些比較確定的、短期內可能就會擴展,或者需求改動對代碼結構影響比較大的情況,或者實現成本不高的擴展點,在編寫代碼的時候之後,我們就可以事先做些擴展性設計。但對於一些不確定未來是否要支持的需求,或者實現起來比較複雜的擴展點,我們可以等到有需求驅動的時候,再通過重構代碼的方式來支持擴展的需求
- Alert 告警的例子中,如果告警規則並不是很多、也不復雜,那 check() 函數中的 if 語句就不會很多,代碼邏輯也不復雜,代碼行數也不多,那最初的第一種代碼實現思路簡單易讀,就是比較合理的選擇。相反,如果告警規則很多、很複雜,check() 函數的 if 語句、代碼邏輯就會很多、很複雜,相應的代碼行數也會很多,可讀性、可維護性就會變差,那重構之後的第二種代碼實現思路就是更加合理的選擇了。總之,這裏沒有一個放之四海而皆準的參考標準,全憑實際的應用場景來決定
- 如何理解“對擴展開放、對修改關閉”?
添加一個新的功能,應該是通過在已有代碼基礎上擴展代碼(新增模塊、類、方法、屬性等),而非修改已有代碼(修改模塊、類、方法、屬性等)的方式來完成。關於定義,我們有兩點要注意。第一點是,開閉原則並不是說完全杜絕修改,而是以最小的修改代碼的代價來完成新功能的開發。第二點是,同樣的代碼改動,在粗代碼粒度下,可能被認定爲“修改”;在細代碼粒度下,可能又被認定爲“擴展”
三. 裏式替換(LSP)跟多態有何區別?哪些代碼違背了LSP?
- 概念:子類對象(object of subtype/derived class)能夠替換程序(program)中父類對象(object of base/parent class)出現的任何地方,並且保證原來程序的邏輯行爲(behavior)不變及正確性不被破壞。
- 例子:
public class Transporter {
private HttpClient httpClient;
public Transporter(HttpClient httpClient) {
this.httpClient = httpClient;
}
public Response sendRequest(Request request) {
// ...use httpClient to send request
}
}
public class SecurityTransporter extends Transporter {
private String appId;
private String appToken;
public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
super(httpClient);
this.appId = appId;
this.appToken = appToken;
}
@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}
public class Demo {
public void demoFunction(Transporter transporter) {
Reuqest request = new Request();
//...省略設置request中數據值的代碼...
Response response = transporter.sendRequest(request);
//...省略其他邏輯...
}
}
// 裏式替換原則
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/*省略參數*/););
- 區別:
雖然從定義描述和代碼實現上來看,多態和裏式替換有點類似,但它們關注的角度是不一樣的。多態是面向對象編程的一大特性,也是面向對象編程語言的一種語法。它是一種代碼實現的思路。而裏式替換是一種設計原則,是用來指導繼承關係中子類該如何設計的,子類的設計要保證在替換父類的時候,不改變原有程序的邏輯以及不破壞原有程序的正確性。
-
子類在設計的時候,要遵守父類的行爲約定(或者叫協議)。父類定義了函數的行爲約定,那子類可以改變函數的內部實現邏輯,但不能改變函數原有的行爲約定。
-
違背LSP—子類違背父類聲明要實現的功能
父類中提供的 sortOrdersByAmount() 訂單排序函數,是按照金額從小到大來給訂單排序的,而子類重寫這個 sortOrdersByAmount() 訂單排序函數之後,是按照創建日期來給訂單排序的。那子類的設計就違背裏式替換原則。
- 子類違背父類對輸入、輸出、異常的約定
- 子類違背父類註釋中所羅列的任何特殊說明
子類違背父類註釋中所羅列的任何特殊說明父類中定義的 withdraw() 提現函數的註釋是這麼寫的:“用戶的提現金額不得超過賬戶餘額……”,而子類重寫 withdraw() 函數之後,針對 VIP 賬號實現了透支提現的功能,也就是提現金額可以大於賬戶餘額,那這個子類的設計也是不符合裏式替換原則的。
四.接口隔離原則有哪三種應用?原則中的“接口”該如何理解?
- 接口隔離原則:客戶端不應該被強迫依賴它不需要的接口。其中的“客戶端”,可以理解爲接口的調用者或者使用者
- 把“接口”理解爲一組 API 接口集合
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}
public class UserServiceImpl implements UserService {
//...
}
照接口隔離原則,調用者不應該強迫依賴它不需要的接口,將刪除接口單獨放到另外一個接口 RestrictedUserService 中,然後將 RestrictedUserService 只打包提供給後臺管理系統來使用
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}
public interface RestrictedUserService {
boolean deleteUserByCellphone(String cellphone);
boolean deleteUserById(long id);
}
public class UserServiceImpl implements UserService, RestrictedUserService {
// ...省略實現代碼...
}
五. 理論五:控制反轉、依賴反轉、依賴注入,這三者有何區別和聯繫?
1. 控制反轉(IOC)
- 控制反轉:
- 框架提供了一個可擴展的代碼骨架,用來組裝對象、管理整個執行流程。程序員利用框架進行開發的時候,只需要往預留的擴展點上,添加跟自己業務相關的代碼,就可以利用框架來驅動整個程序流程的執行。
- 這裏的“控制”指的是對程序執行流程的控制,而“反轉”指的是在沒有使用框架之前,程序員自己控制整個程序的執行。在使用框架之後,整個程序的執行流程可以通過框架來控制。流程的控制權從程序員“反轉”到了框架
2. 依賴注入
- 不通過 new() 的方式在類內部創建依賴類對象,而是將依賴的類對象在外部創建好之後,通過構造函數、函數參數等方式傳遞(或注入)給類使用。
- 依賴注入 && 非依賴注入區別例子
// 非依賴注入實現方式
public class Notification {
private MessageSender messageSender;
public Notification() {
this.messageSender = new MessageSender(); //此處有點像hardcode
}
public void sendMessage(String cellphone, String message) {
//...省略校驗邏輯等...
this.messageSender.send(cellphone, message);
}
}
public class MessageSender {
public void send(String cellphone, String message) {
//....
}
}
// 使用Notification
Notification notification = new Notification();
// 依賴注入的實現方式
public class Notification {
private MessageSender messageSender;
// 通過構造函數將messageSender傳遞進來
public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendMessage(String cellphone, String message) {
//...省略校驗邏輯等...
this.messageSender.send(cellphone, message);
}
}
//使用Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);
3. 依賴注入框架
- 依賴注入:
public class Demo {
public static final void main(String args[]) {
MessageSender sender = new SmsSender(); //創建對象
Notification notification = new Notification(sender);//依賴注入
notification.sendMessage("13918942177", "短信驗證碼:2346");
}
}
4. 依賴反轉原則(DIP)
- 依賴反轉原則也叫作依賴倒置原則。這條原則跟控制反轉有點類似,主要用來指導框架層面的設計。高層模塊不依賴低層模塊,它們共同依賴同一個抽象。抽象不要依賴具體實現細節,具體實現細節依賴抽象
六. 理論六:我爲何說KISS、YAGNI原則看似簡單,卻經常被用錯?
1. 如何理解“KISS 原則”?
- 儘量保持簡單
2. 代碼行數越少就越“簡單”嗎?
- 功能描述:檢查輸入的字符串 ipAddress 是否是合法的 IP 地址。
- 例子:
// 第一種實現方式: 使用正則表達式
public boolean isValidIpAddressV1(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}
// 第二種實現方式: 使用現成的工具類
public boolean isValidIpAddressV2(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i == 0 && ipUnitIntValue == 0) {
return false;
}
}
return true;
}
// 第三種實現方式: 不使用任何工具類
public boolean isValidIpAddressV3(String ipAddress) {
char[] ipChars = ipAddress.toCharArray();
int length = ipChars.length;
int ipUnitIntValue = -1;
boolean isFirstUnit = true;
int unitsCount = 0;
for (int i = 0; i < length; ++i) {
char c = ipChars[i];
if (c == '.') {
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (isFirstUnit && ipUnitIntValue == 0) return false;
if (isFirstUnit) isFirstUnit = false;
ipUnitIntValue = -1;
unitsCount++;
continue;
}
if (c < '0' || c > '9') {
return false;
}
if (ipUnitIntValue == -1) ipUnitIntValue = 0;
ipUnitIntValue = ipUnitIntValue * 10 + (c - '0');
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (unitsCount != 3) return false;
return true;
}
- 儘管第三種實現方式性能更高些,但我還是更傾向於選擇第二種實現方法。那是因爲第三種實現方式實際上是一種過度優化。除非 isValidIpAddress() 函數是影響系統性能的瓶頸代碼,否則,這樣優化的投入產出比並不高,增加了代碼實現的難度、犧牲了代碼的可讀性,性能上的提升卻並不明顯。
3. 代碼邏輯複雜就違背 KISS 原則嗎?
4. 如何寫出滿足 KISS 原則的代碼?
- 不要使用同事可能不懂的技術來實現代碼。比如前面例子中的正則表達式,還有一些編程語言中過於高級的語法等。
- 不要重複造輪子,要善於使用已經有的工具類庫。經驗證明,自己去實現這些類庫,出 bug 的概率會更高,維護的成本也比較高。
- 不要過度優化。不要過度使用一些奇技淫巧(比如,位運算代替算術運算、複雜的條件語句代替 if-else、使用一些過於底層的函數等)來優化代碼,犧牲代碼的可讀性。
七. 理論七:重複的代碼就一定違背DRY嗎?如何提高代碼的複用性?
1. DRY 原則
- 三種典型的代碼重複情況,它們分別是:實現邏輯重複、功能語義重複和代碼執行重複。這三種代碼重複,有的看似違反 DRY,實際上並不違反;有的看似不違反,實際上卻違反了
2. 實現邏輯重複
- 例如:校驗用戶姓名合法性方法isValidUsername(),校驗密碼合法性方法isValidPassword(),部分代碼重複,合併isValidUserNameOrPassword()。
- 合併之後的 isValidUserNameOrPassword() 函數,負責兩件事情:驗證用戶名和驗證密碼,違反了“單一職責原則”和“接口隔離原則”。實際上,即便將兩個函數合併成 isValidUserNameOrPassword(),代碼仍然存在問題
- 儘管代碼的實現邏輯是相同的,但語義不同,我們判定它並不違反 DRY 原則。對於包含重複代碼的問題,我們可以通過抽象成更細粒度函數的方式來解決。比如將校驗只包含 az、09、dot 的邏輯封裝成 boolean onlyContains(String str, String charlist); 函數。
3. 功能語義重複
- 例如:在同一個項目中會有兩個功能相同的函數,那是因爲這兩個函數是由兩個不同的同事開發的,其中一個同事在不知道已經有了 isValidIp() 的情況下,自己又定義並實現了同樣用來校驗 IP 地址是否合法的 checkIfIpValid() 函數。
4. 代碼執行重複
- 例如:UserService 中 login() 函數用來校驗用戶登錄是否成功。如果失敗,就返回異常;如果成功,就返回用戶信息
- 代碼:
public class UserService {
private UserRepo userRepo;//通過依賴注入或者IOC框架注入
public User login(String email, String password) {
boolean existed = userRepo.checkIfUserExisted(email, password);
if (!existed) {
// ... throw AuthenticationFailureException...
}
User user = userRepo.getUserByEmail(email);
return user;
}
}
public class UserRepo {
public boolean checkIfUserExisted(String email, String password) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
if (!PasswordValidation.validate(password)) {
// ... throw InvalidPasswordException...
}
//...query db to check if email&password exists...
}
public User getUserByEmail(String email) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
//...query db to get user by email...
}
}
3.分析:就是在 login() 函數中,email 的校驗邏輯被執行了兩次。一次是在調用 checkIfUserExisted() 函數的時候,另一次是調用 getUserByEmail() 函數的時候。這個問題解決起來比較簡單,我們只需要將校驗邏輯從 UserRepo 中移除,統一放到 UserService 中就可以了。
5. 代碼複用性
- 代碼複用 && 代碼複用性 && DRY原則
- 代碼複用表示一種行爲:我們在開發新功能的時候,儘量複用已經存在的代碼。代碼的可複用性表示一段代碼可被複用的特性或能力:我們在編寫代碼的時候,讓代碼儘量可複用。DRY 原則是一條原則:不要寫重複的代碼。
6. 怎麼提高代碼複用性
- 減少代碼耦合
對於高度耦合的代碼,當我們希望複用其中的一個功能,想把這個功能的代碼抽取出來成爲一個獨立的模塊、類或者函數的時候,往往會發現牽一髮而動全身。移動一點代碼,就要牽連到很多其他相關的代碼。所以,高度耦合的代碼會影響到代碼的複用性,我們要儘量減少代碼耦合
- 滿足單一職責原則
如果職責不夠單一,模塊、類設計得大而全,那依賴它的代碼或者它依賴的代碼就會比較多,進而增加了代碼的耦合。根據上一點,也就會影響到代碼的複用性。相反,越細粒度的代碼,代碼的通用性會越好,越容易被複用。
- 模塊化
這裏的“模塊”,不單單指一組類構成的模塊,還可以理解爲單個類、函數。我們要善於將功能獨立的代碼,封裝成模塊。獨立的模塊就像一塊一塊的積木,更加容易複用,可以直接拿來搭建更加複雜的系統
- 業務與非業務邏輯分離
- 通用代碼下沉
從分層的角度來看,越底層的代碼越通用、會被越多的模塊調用,越應該設計得足夠可複用。一般情況下,在代碼分層之後,爲了避免交叉調用導致調用關係混亂,我們只允許上層代碼調用下層代碼及同層代碼之間的調用,杜絕下層代碼調用上層代碼。所以,通用的代碼我們儘量下沉到更下層
- 繼承、多態、抽象、封裝
- 應用模板等設計模式
八.理論八:如何用迪米特法則(LOD)實現“高內聚、松耦合”?
1. 何爲“高內聚、松耦合”?
- 高內聚、松耦合
在這個設計思想中,“高內聚”用來指導類本身的設計,“松耦合”用來指導類與類之間依賴關係的設計。不過,這兩者並非完全獨立不相干。高內聚有助於松耦合,松耦合又需要高內聚的支持
- 高內聚
所謂高內聚,就是指相近的功能應該放到同一個類中,不相近的功能不要放到同一個類中。相近的功能往往會被同時修改,放到同一個類中,修改會比較集中,代碼容易維護
- 松耦合
所謂松耦合是說,在代碼中,類與類之間的依賴關係簡單清晰。即使兩個類有依賴關係,一個類的代碼改動不會或者很少導致依賴類的代碼改動。實際上,我們前面講的依賴注入、接口隔離、基於接口而非實現編程,以及今天講的迪米特法則,都是爲了實現代碼的松耦合
2. “迪米特法則”理論描述
- 迪米特法則理論翻譯:
不該有直接依賴關係的類之間,不要有依賴;有依賴關係的類之間,儘量只依賴必要的接口(也就是定義中的“有限知識”)。
3.“迪米特法則”理論之前半部分—{不該有直接依賴關係的類之間,不要有依賴}
- 例如:NetworkTransporter 類負責底層網絡通信,根據請求獲取數據;HtmlDownloader 類用來通過 URL 獲取網頁;Document 表示網頁文檔,後續的網頁內容抽取、分詞、索引都是以此爲處理對象。
- 例子代碼:
// ===> 負責底層網絡通信,根據請求獲取數據
public class NetworkTransporter {
// 省略屬性和其他方法...
public Byte[] send(HtmlRequest htmlRequest) {
//...
}
}
// ===> 通過 URL 獲取網頁
public class HtmlDownloader {
private NetworkTransporter transporter;//通過構造函數或IOC注入
public Html downloadHtml(String url) {
Byte[] rawHtml = transporter.send(new HtmlRequest(url));
return new Html(rawHtml);
}
}
// ===> 網頁文檔,後續的網頁內容抽取、分詞、索引都是以此爲處理對象
public class Document {
private Html html;
private String url;
public Document(String url) {
this.url = url;
HtmlDownloader downloader = new HtmlDownloader();
this.html = downloader.downloadHtml(url);
}
//...
}
- NetworkTransporter 類分析:
作爲一個底層網絡通信類,我們希望它的功能儘可能通用,而不只是服務於下載 HTML,所以,我們不應該直接依賴太具體的發送對象 HtmlRequest。從這一點上講,NetworkTransporter 類的設計違背迪米特法則,依賴了不該有直接依賴關係的 HtmlRequest 類。
我們應該把 address 和 content 交給 NetworkTransporter,而非是直接把 HtmlRequest 交給NetworkTransporter。
- NetworkTransporter 類重構代碼
public class NetworkTransporter {
// 省略屬性和其他方法...
public Byte[] send(String address, Byte[] data) {
//...
}
}
- HtmlDownloader 類分析:
這個類的設計沒有問題。不過,我們修改了 NetworkTransporter 的 send() 函數的定義,而這個類用到了 send() 函數,所以我們需要對它做相應的修改,修改後的代碼如下所示
public class HtmlDownloader {
private NetworkTransporter transporter;//通過構造函數或IOC注入
// HtmlDownloader這裏也要有相應的修改
public Html downloadHtml(String url) {
HtmlRequest htmlRequest = new HtmlRequest(url);
Byte[] rawHtml = transporter.send(
htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
return new Html(rawHtml);
}
}
- Document 類分析
(1). 構造函數中的 downloader.downloadHtml() 邏輯複雜,耗時長,不應該放到構造函數中,會影響代碼的可測試性。
(2). HtmlDownloader 對象在構造函數中通過 new 來創建,違反了基於接口而非實現編程的設計思想,也會影響到代碼的可測試性
(3). 從業務含義上來講,Document 網頁文檔沒必要依賴 HtmlDownloader 類,違背了迪米特法則
- Document類重構
public class Document {
private Html html;
private String url;
public Document(String url, Html html) {
this.html = html;
this.url = url;
}
//...
}
// 通過一個工廠方法來創建Document
public class DocumentFactory {
private HtmlDownloader downloader;
public DocumentFactory(HtmlDownloader downloader) {
this.downloader = downloader;
}
public Document createDocument(String url) {
Html html = downloader.downloadHtml(url);
return new Document(url, html);
}
}
4.“迪米特法則”理論之後半部分—{有依賴關係的類之間,儘量只依賴必要的接口}
- 例子:Serialization 類序列化和反序列化
public class Serialization {
public String serialize(Object object) {
String serializedResult = ...;
//...
return serializedResult;
}
public Object deserialize(String str) {
Object deserializedResult = ...;
//...
return deserializedResult;
}
}
- 分析
單看這個類的設計,沒有一點問題。不過,如果我們把它放到一定的應用場景裏,那就還有繼續優化的空間。假設在我們的項目中,有些類只用到了序列化操作,而另一些類只用到反序列化操作。那基於迪米特法則後半部分“有依賴關係的類之間,儘量只依賴必要的接口”,只用到序列化操作的那部分類不應該依賴反序列化接口。同理,只用到反序列化操作的那部分類不應該依賴序列化接口
- 拆分類:一個只負責序列化(Serializer 類),一個只負責反序列化(Deserializer 類)
public class Serializer {
public String serialize(Object object) {
String serializedResult = ...;
...
return serializedResult;
}
}
public class Deserializer {
public Object deserialize(String str) {
Object deserializedResult = ...;
...
return deserializedResult;
}
}
- 拆分後分析
儘管拆分之後的代碼更能滿足迪米特法則,但卻違背了高內聚的設計思想。高內聚要求相近的功能要放到同一個類中,這樣可以方便功能修改的時候,修改的地方不至於過於分散。對於剛剛這個例子來說,如果我們修改了序列化的實現方式,比如從 JSON 換成了 XML,那反序列化的實現邏輯也需要一併修改。在未拆分的情況下,我們只需要修改一個類即可。在拆分之後,我們需要修改兩個類。顯然,這種設計思路的代碼改動範圍變大了。
- 改進
如果我們既不想違背高內聚的設計思想,也不想違背迪米特法則,那我們該如何解決這個問題呢?實際上,通過引入兩個接口就能輕鬆解決這個問題
- 改進實現
public interface Serializable {
String serialize(Object object);
}
public interface Deserializable {
Object deserialize(String text);
}
public class Serialization implements Serializable, Deserializable {
@Override
public String serialize(Object object) {
String serializedResult = ...;
...
return serializedResult;
}
@Override
public Object deserialize(String str) {
Object deserializedResult = ...;
...
return deserializedResult;
}
}
public class DemoClass_1 {
private Serializable serializer;
public Demo(Serializable serializer) {
this.serializer = serializer;
}
//...
}
public class DemoClass_2 {
private Deserializable deserializer;
public Demo(Deserializable deserializer) {
this.deserializer = deserializer;
}
//...
}
- 總結
實際上,上面的的代碼實現思路,也體現了“基於接口而非實現編程”的設計原則,結合迪米特法則,我們可以總結出一條新的設計原則,那就是“基於最小接口而非最大實現編程”
5. 辯證思考與靈活應用
-
整個類只包含序列化和反序列化兩個操作,只用到序列化操作的使用者,即便能夠感知到僅有的一個反序列化函數,問題也不大。那爲了滿足迪米特法則,我們將一個非常簡單的類,拆分出兩個接口,是否有點過度設計的意思呢?
-
設計原則本身沒有對錯,只有能否用對之說。不要爲了應用設計原則而應用設計原則,我們在應用設計原則的時候,一定要具體問題具體分析。
-
對於剛剛這個 Serialization 類來說,只包含兩個操作,確實沒有太大必要拆分成兩個接口。但是,如果我們對 Serialization 類添加更多的功能,實現更多更好用的序列化、反序列化函數,我們來重新考慮一下這個問題。修改之後的具體的代碼如下:
public class Serializer { // 參看JSON的接口定義
public String serialize(Object object) { //... }
public String serializeMap(Map map) { //... }
public String serializeList(List list) { //... }
public Object deserialize(String objectString) { //... }
public Map deserializeMap(String mapString) { //... }
public List deserializeList(String listString) { //... }
}
- 改進分析
在這種場景下,第二種設計思路要更好些。因爲基於之前的應用場景來說,大部分代碼只需要用到序列化的功能。對於這部分使用者,沒必要了解反序列化的“知識”,而修改之後的 Serialization 類,反序列化的“知識”,從一個函數變成了三個。一旦任一反序列化操作有代碼改動,我們都需要檢查、測試所有依賴 Serialization 類的代碼是否還能正常工作。爲了減少耦合和測試工作量,我們應該按照迪米特法則,將反序列化和序列化的功能隔離開來。
6. 總結
- 單一職責原則
適用對象:模塊,類,接口
側重點:高內聚,低耦合
思考角度:自身
- 接口隔離原則
適用對象:接口,函數
側重點:低耦合
思考角度:調用者
- 基於接口而非實現編程
適用對象:接口,抽象類
側重點:低耦合
思考角度:調用者
- 迪米特法則
適用對象:模塊,類
側重點:低耦合
思考角度:類關係
九. 實戰一(上):針對業務系統的開發,如何做需求分析和設計
1. 需求分析
- 積分系統概述
積分系統無外乎就兩個大的功能點,一個是賺取積分,另一個是消費積分。賺取積分功能包括積分賺取渠道,比如下訂單、每日簽到、評論等;還包括積分兌換規則,比如訂單金額與積分的兌換比例,每日簽到贈送多少積分等。消費積分功能包括積分消費渠道,比如抵扣訂單金額、兌換優惠券、積分換購、參與活動扣積分等;還包括積分兌換規則,比如多少積分可以換算成抵扣訂單的多少金額,一張優惠券需要多少積分來兌換等等
- 積分賺取和兌換規則
積分的賺取渠道包括:下訂單、每日簽到、評論等。積分兌換規則可以是比較通用的。比如,簽到送 10 積分。再比如,按照訂單總金額的 10% 兌換成積分,也就是 100 塊錢的訂單可以積累 10 積分。除此之外,積分兌換規則也可以是比較細化的。比如,不同的店鋪、不同的商品,可以設置不同的積分兌換比例
- 積分消費和兌換規則
積分的消費渠道包括:抵扣訂單金額、兌換優惠券、積分換購、參與活動扣積分等。我們可以根據不同的消費渠道,設置不同的積分兌換規則。比如,積分換算成消費抵扣金額的比例是 10%,也就是 10 積分可以抵扣 1 塊錢;100 積分可以兌換 15 塊錢的優惠券等。
- 積分及其明細查詢
查詢用戶的總積分,以及賺取積分和消費積分的歷史記錄。
2. 系統設計
- 合理地將功能劃分到不同模塊---------原則:高內聚、低耦合
(1). 劃分方式一
積分賺取渠道及兌換規則、消費渠道及兌換規則的管理和維護(增刪改查),不劃分到積分系統中,而是放到更上層的----營銷系統----中。這樣積分系統就會變得非常簡單,只需要負責增加積分、減少積分、查詢積分、查詢積分明細等這幾個工作
(2). 劃分方式二
積分賺取渠道及兌換規則、消費渠道及兌換規則的管理和維護,分散在各個相關業務系統中,比如訂單系統、評論系統、簽到系統、換購商城、優惠券系統等。還是剛剛那個下訂單賺取積分的例子,在這種情況下,用戶下訂單成功之後,訂單系統根據商品對應的積分兌換比例,計算所能兌換的積分數量,然後直接調用積分系統給用戶增加積分。
(3). 劃分方式三
所有的功能都劃分到積分系統中,包括積分賺取渠道及兌換規則、消費渠道及兌換規則的管理和維護。還是同樣的例子,用戶下訂單成功之後,訂單系統直接告知積分系統訂單交易成功,積分系統根據訂單信息查詢積分兌換規則,給用戶增加積分
- 比較
(1). 我們可以反過來通過看它是否符合高內聚、低耦合特性來判斷。如果一個功能的修改或添加,經常要跨團隊、跨項目、跨系統才能完成,那說明模塊劃分的不夠合理,職責不夠清晰,耦合過於嚴重。
(2). 爲了避免業務知識的耦合,讓下層系統更加通用,一般來講,我們不希望下層系統(也就是被調用的系統)包含太多上層系統(也就是調用系統)的業務信息,但是,可以接受上層系統包含下層系統的業務信息。比如,訂單系統、優惠券系統、換購商城等作爲調用積分系統的上層系統,可以包含一些積分相關的業務信息。但是,反過來,積分系統中最好不要包含太多跟訂單、優惠券、換購等相關的信息。
不管選擇這兩種中的哪一種,積分系統所負責的工作是一樣的,只包含積分的增、減、查詢,以及積分明細的記錄和查詢。
- 設計模塊與模塊之間的交互關係
(1). 常見系統之間的交互方式:一種是同步接口調用,另一種是利用消息中間件異步調用。第一種方式簡單直接,第二種方式的解耦效果更好
- 設計模塊的接口、數據庫、業務模型
十. 實戰一(下):如何實現一個遵從設計原則的積分兌換系統?
1. 業務開發包括哪些工作?
- 業務系統的設計與開發
無外乎有這樣三方面的工作要做:接口設計、數據庫設計和業務模型設計(也就是業務邏輯)
- 重要性
數據庫和接口的設計非常重要,一旦設計好並投入使用之後,這兩部分都不能輕易改動。改動數據庫表結構,需要涉及數據的遷移和適配;改動接口,需要推動接口的使用者作相應的代碼修改。這兩種情況,即便是微小的改動,執行起來都會非常麻煩。因此,我們在設計接口和數據庫的時候,一定要多花點心思和時間,切不可過於隨意。相反,業務邏輯代碼側重內部實現,不涉及被外部依賴的接口,也不包含持久化的數據,所以對改動的容忍性更大。
2. 數據庫設計
- 積分明細表
3. 接口設計
- 設計積分系統的接口
接口設計要符合單一職責原則,粒度越小通用性就越好。但是,接口粒度太小也會帶來一些問題。比如,一個功能的實現要調用多個小接口,一方面如果接口調用走網絡(特別是公網),多次遠程接口調用會影響性能;另一方面,本該在一個接口中完成的原子操作,現在分拆成多個小接口來完成,就可能會涉及分佈式事務的數據一致性問題(一個接口執行成功了,但另一個接口執行失敗了)。所以,爲了兼顧易用性和性能,我們可以借鑑 facade(外觀)設計模式,在職責單一的細粒度接口之上,再封裝一層粗粒度的接口給外部使用
- 接口例子
4.業務模型設計
- DDD && OOP
前面我們還提到兩種開發模式,基於貧血模型的傳統開發模式和基於充血模型的 DDD 開發模式。前者是一種面向過程的編程風格,後者是一種面向對象的編程風格。不管是 DDD 還是 OOP,高級開發模式的存在一般都是爲了應對複雜系統,應對系統的複雜性。對於我們要開發的積分系統來說,因爲業務相對比較簡單,所以,選擇簡單的基於貧血模型的傳統開發模式就足夠了
5. 爲什麼要分 MVC 三層開發
- 分層能起到代碼複用的作用
- 分層能起到隔離變化的作用
- 分層能起到隔離關注點的作用
三層之間的關注點不同,分層之後,職責分明,更加符合單一職責原則,代碼的內聚性更好。
- 分層能提高代碼的可測試性
- 分層能應對系統的複雜性
6. BO、VO、Entity 存在的意義是什麼?
- 含義
針對 Controller、Service、Repository 三層,每層都會定義相應的數據對象,它們分別是 VO(View Object)、BO(Business Object)、Entity,例如 UserVo、UserBo、UserEntity。在實際的開發中,VO、BO、Entity 可能存在大量的重複字段,甚至三者包含的字段完全一樣。在開發的過程中,我們經常需要重複定義三個幾乎一樣的類,顯然是一種重複勞動
- 相對於每層定義各自的數據對象來說,是不是定義一個公共的數據對象更好些呢?
(1). 推薦每層都定義各自的數據對象這種設計思路
(2).原因一.
VO、BO、Entity 並非完全一樣。比如,我們可以在 UserEntity、UserBo 中定義 Password 字段,但顯然不能在 UserVo 中定義 Password 字段,否則就會將用戶的密碼暴露出去
(3).原因二.
VO、BO、Entity 三個類雖然代碼重複,但功能語義不重複,從職責上講是不一樣的。所以,也並不能算違背 DRY 原則。在前面講到 DRY 原則的時候,針對這種情況,如果合併爲同一個類,那也會存在後期因爲需求的變化而需要再拆分的問題。
(4).原因三
爲了儘量減少每層之間的耦合,把職責邊界劃分明確,每層都會維護自己的數據對象,層與層之間通過接口交互。數據從下一層傳遞到上一層的時候,將下一層的數據對象轉化成上一層的數據對象,再繼續處理。雖然這樣的設計稍微有些繁瑣,每層都需要定義各自的數據對象,需要做數據對象之間的轉化,但是分層清晰。對於非常大的項目來說,結構清晰是第一位的
- 既然 VO、BO、Entity 不能合併,那如何解決代碼重複的問題呢?
(1). 從設計的角度來說,VO、BO、Entity 的設計思路並不違反 DRY 原則,爲了分層清晰、減少耦合,多維護幾個類的成本也並不是不能接受的
(2).使用繼承,將公共的字段定義到父類中
(3).多用組合,少用繼承”設計思想,使用組合抽取公共的類
- 代碼重複問題解決了,那不同分層之間的數據對象該如何互相轉化呢?
(1). 現象
Service 層從 Repository 層獲取的 Entity 之後,將其轉化成 BO,再繼續業務邏輯的處理。所以,整個開發的過程會涉及“Entity 到 BO”和“BO 到 VO”這兩種轉化。
(2). 解決方法
最簡單的轉化方式是手動複製。自己寫代碼在兩個對象之間,一個字段一個字段的賦值。但這樣的做法顯然是沒有技術含量的低級勞動。Java 中提供了多種數據對象轉化工具,比如 BeanUtils、Dozer 等,可以大大簡化繁瑣的對象轉化工作。
十一. 實戰二(上):針對非業務的通用框架開發,如何做需求分析和設計?
1. 項目背景
我們希望設計開發一個小的框架,能夠獲取接口調用的各種統計信息,比如,響應時間的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile)、接口調用次數(count)、頻率(tps) 等,並且支持將統計結果以各種顯示格式(比如:JSON 格式、網頁格式、自定義顯示格式等)輸出到各種終端(Console 命令行、HTTP 網頁、Email、日誌文件、自定義輸出終端等),以方便查看。
2. 需求分析
- 功能性需求分析
(1). 接口統計信息:包括接口響應時間的統計信息,以及接口調用次數的統計信息等。
(2). 統計信息的類型:max、min、avg、percentile、count、tps 等
(3). 統計信息顯示格式:Json、Html、自定義顯示格式。
(4). 統計信息顯示終端:Console、Email、HTTP 網頁、日誌、自定義顯示終端
(5). 統計觸發方式:包括主動和被動兩種。主動表示以一定的頻率定時統計數據,並主動推送到顯示終端,比如郵件推送。被動表示用戶觸發統計,比如用戶在網頁中選擇要統計的時間區間,觸發統計,並將結果顯示給用戶
(6). 統計時間區間:框架需要支持自定義統計時間區間,比如統計最近 10 分鐘的某接口的 tps、訪問次數,或者統計 12 月 11 日 00 點到 12 月 12 日 00 點之間某接口響應時間的最大值、最小值、平均值等
(7). 統計時間間隔:對於主動觸發統計,我們還要支持指定統計時間間隔,也就是多久觸發一次統計顯示。比如,每間隔 10s 統計一次接口信息並顯示到命令行中,每間隔 24 小時發送一封統計信息郵件
3. 框架設計
- 針對需求做框架設計
對於稍微複雜系統的開發,很多人覺得不知從何開始。我個人喜歡借鑑 TDD(測試驅動開發)和 Prototype(最小原型)的思想,先聚焦於一個簡單的應用場景,基於此設計實現一個簡單的原型