攻城獅內功心法之軟件架構設計原則(設計模式前言篇)
本來這次想聊聊我們常用的幾個設計模式,以及我們當前核心系統適合使用哪幾種設計模式去優化(解耦),但是轉念一想,更應該先聊一聊軟件架構的設計原則,希望對新入行或者已經深耕業務開發多年而漸漸遺忘軟件設計原則相關知識點的老司機有所幫助。
軟件架構設計的六大設計原則
開閉原則
指的是一個軟件實體,比如類、模塊或者函數應該對擴展開放,對修改關閉。所謂的開閉是針對擴展和修改兩個行爲的一個原則,強調的是用抽象構建框架,用實現擴展細節。
開閉原則是面向對設計中最基礎的設計原則,目的是提供軟件系統可複用性及可維護性,它指導我們如何建立穩定靈活的系統。
在我們現實生活中,開閉原則最直觀的體現就是我們的彈性工作時間,每天工作8小時的規定是關閉的,不可修改。但是上班時間和下班時間是開放的,早來的早走,晚來的晚走。
接下來以我們的業務舉例,首先創建一個銀行機構的接口IBankService
- public interface IBankService {
- /**獲取銀行編碼*/
- String getBankCode();
- /**名稱*/
- String getName();
- /**貸款利率*/
- BigDecimal getInterestRate();
- }
我們已對接的銀行機構有很多,這裏隨便創建一個具體的銀行對象來實現以上接口,以建設銀行爲例:
- public class CBCBank implements IBankService {
- private String bankCode;
- private String name;
- private BigDecimal interestRate;
- public CBCBank(String bankCode, String name, BigDecimal interestRate) {
- this.bankCode = bankCode;
- this.name = name;
- this.interestRate = interestRate;
- }
- @Override
- public String getBankCode() {
- return this.bankCode;
- }
- @Override
- public String getName() {
- return this.name;
- }
- @Override
- public BigDecimal getInterestRate() {
- return this.interestRate;
- }
- }
通常情況下建行對大部分地區的貸款利率不會變化,突然有一天,建行推出針對偏遠地區中小企業的貸款優惠活動,利率爲原來利率的80%,(可以理解爲商品打折活動)。那這時候我們該如何滿足需求呢?如果直接修改getInterestRate()方法,可能會存在一定風險,影響到其他地方的調用結果。我們如何在不修改原來代碼前提下,實現利率優惠呢?那我們寫一個處理優惠活動的類繼承上面的類,對子類進行擴展。
- public class CBCBankDiscount extends CBCBank{
- public CBCBankDiscount(String bankCode, String name, BigDecimal interestRate) {
- super(bankCode, name, interestRate);
- }
- //原始利率
- public BigDecimal getOriginalInterestRate(){
- return super.getInterestRate();
- }
- //打折後當前利率
- public BigDecimal getInterestRate(){
- return super.getInterestRate().multiply(new BigDecimal("0.8"));
- }
- }
以上就達到了我們的目的,並對開閉原則做了展示。再看一下具體類圖結構:
實際上我們系統中的大部分代碼都遵循着該原則。面臨特殊需求的時候難免會對已有穩定的功能做擴展,該原則還是要牢記。
單一職責原則
約定一個類只做一件事,保證其功能的單一性,要對這個類做出改變的原因也只有一個。假設我們一個Class負責兩個職責,一旦發生需求變更,修改其中一個職責的代碼,導致另外職責功能發生故障。這樣這個Class存在兩個導致類變更的原因。如何解決?兩個職責拆分成兩個獨立Class實現,進行解耦。後去需求的變更維護互不影響。(當然拆分粒度需要結合業務、需求變動頻繁度等綜合分析)。
單一職責原則優點,降低類的複雜度、提供可讀性、提供系統可維護性、降低變更引起的風險。
接下來還是看具體例子:
- 方法級別單一職責
假設做一個針對用戶修改賬號名稱以及賬號密碼的功能。
實現方式一,定義一個修改用戶信息的方法,入參:用戶對象和修改內容類型。
- public interface IUserOperation {
- /**
- * @date 2020-03-21 15:13
- * @param user, operationType: 0 更新姓名 ,1 更新密碼
- * @return boolean
- */
- boolean updateUserInfo(User user, Integer operationType);
- }
- public class UserOperationImpl implements IUserOperation {
- @Override
- public boolean updateUserInfo(User user, Integer operationType) {
- if(0 == operationType){
- //更新賬戶姓名
- }else if(1 == operationType){
- //更新賬戶密碼
- }
- return true;
- }
- }
這種實現方式,修改姓名和修改密碼兩個邏輯放到一個方法中,通過operationType參數區分執行哪個邏輯,在使用該方法時稍不注意傳錯參數,悲劇發生。因此可見這個方法職責是不明確的。
實現方式二,接口中定義兩個方法,更新密碼和更新用戶姓名。
- public interface IUserOperationPlus {
- boolean updatePassword(User user, String password);
- boolean updateUserInfo(User user);
- }
- public class UserOperationPlusImpl implements IUserOperationPlus {
- @Override
- public boolean updatePassword(User user, String password) {
- //更新密碼操作
- return false;
- }
- @Override
- public boolean updateUserInfo(User user) {
- //更新 姓名操作
- return false;
- }
- }
這種方式實現修改賬戶姓名和修改密碼兩個方法,每個方法的職責單一。調用者可以明確知道方法實現的是什麼邏輯。
- 接口級別單一職責
假設一個做家務的場景吧,常見對的家務有買菜、倒垃圾等。我們讓王鐵錘只負責倒垃圾,讓王鋼彈負責買菜,買菜回來再讓他去洗衣服。
實現方式一:定義一個HomeWork接口,裏面包含 買菜、倒垃圾方法。
- public interface HomeWork {
- /**買菜*/
- void buyVegetables();
- /**扔垃圾*/
- void pourGarbage();
- }
- public class Tiechui implements HomeWork {
- @Override
- public void buyVegetables() {
- //從不買菜
- }
- @Override
- public void pourGarbage() {
- System.out.println("去倒垃圾");
- }
- }
- public class Gangdan implements HomeWork {
- @Override
- public void buyVegetables() {
- System.out.println("去菜市場買菜");
- }
- @Override
- public void pourGarbage() {
- //從不丟垃圾
- }
- }
鋼彈買菜回來,再去洗衣服,目前的接口不滿足了,需要添加WashClothes()的方法,然後鋼彈和鐵錘都需要實現該方法,只是鐵錘不做具體代碼實現。這樣就會很彆扭,不符合單一職責原則了。因爲修改一個地方,影響到了不需要做出修改的代碼。鐵錘說了,“我不洗衣服,也不想接觸這個東西”,說罷鑿牆而去。
實現方式二,講HomeWork細化,每項任務創建一個接口。
- //買菜
- public interface BuyVegetables {
- void doBuyVegetables();
- }
- //倒垃圾
- public interface PourGarbage {
- void doPourGarbage();
- }
- //洗衣服
- public interface WashClothes {
- void doWashClothes();
- }
- public class TiechuiPlus implements PourGarbage {
- @Override
- public void doPourGarbage() {
- System.out.println("倒垃圾");
- }
- }
- public class GangdanPlus implements BuyVegetables, WashClothes {
- @Override
- public void doBuyVegetables() {
- System.out.println("買菜");
- }
- @Override
- public void doWashClothes() {
- System.out.println("洗衣服");
- }
- }
這種實現方式,把家務細化隔離,王鐵錘和王鋼彈各取所需,相互不影響。
- 類級別單一職責
類強行按照單一職責原則創建的話會有點尷尬,因爲類的職責範圍可大可小,還是主要取決於業務場景和代碼邏輯複雜度。比如三個簡單的單一職責接口登錄、註冊、註銷實現,可以放到同一個類中;再比如我們使用的工作流,整個流程下來會涉及到多個功能,所有的功能邏輯放到一個類裏,會耦合的太緊,不利於系統穩定。類就不寫代碼舉例子了,只能意會一下了各位。
我們在實際開發中的項目會有依賴、組合、聚合等關係存在,另外項目的規模、週期、技術人員的水平等因素影響,系統中的很多類都存在不符合單一職責原則的情況。但是我們還是要儘可能的讓接口和方法保持單一職責,以便於我們項目後期的擴展和維護。
依賴倒置原則
指設計代碼結構時,通常我們會涉及到多層結構。高層模塊不應該依賴低層模塊,兩者都應該依賴其抽象。
通過依賴倒置,可以減少類之間的耦合性、提高系統穩定性、提高代碼的可讀性和可維護性。
以上描述不好懂,還是看具體例子吧。
偷個懶兒,我們還是來說做家務的事情吧。創建一個類Xiaohua:
- public class Xiaohua {
- public void doWashClothes(){
- System.out.println("小花洗衣服");
- }
- public void doPourGarbage(){
- System.out.println("小花扔垃圾");
- }
- public static void main(String[] args) {
- Xiaohua xiaohua = new Xiaohua();
- xiaohua.doWashClothes();
- xiaohua.doPourGarbage();
- }
- }
如上代碼,運行之後,小花洗衣服之後扔垃圾。但是家務活遠不止這些要做,小花之後還要買菜、做飯、刷碗等一系列任務。如果在Xiao類中逐一增加新的方法。這樣對已發佈功能的系統來說,這個類是不穩定的,會在修改代碼發佈上線後帶來意想不到的風險。接下來要對它就行優化。
創建一個家務活的抽象接口,IHomeWork:
- public interface IHomeWork {
- void doHomeWork();
- }
然後定義幾種類型的家務活類,如下:
- public class WashClothesHomeWork implements IHomeWork {
- @Override
- public void doHomeWork() {
- System.out.println("小花洗衣服");
- }
- }
- public class PourGarbageHomeWork implements IHomeWork {
- @Override
- public void doHomeWork() {
- System.out.println("小花扔垃圾");
- }
- }
然後小花做家務活這個類本身也要相應的修改,如下:
- public class XiaohuaPlus {
- public void doHomeWork(IHomeWork homeWork){
- homeWork.doHomeWork();
- }
- public static void main(String[] args) {
- XiaohuaPlus xiaohua = new XiaohuaPlus();
- xiaohua.doHomeWork(new WashClothesHomeWork());
- xiaohua.doHomeWork(new PourGarbageHomeWork());
- }
- }
這時候,無論以後小花有多少任務要做,對於新家務活,我們只需要新建一個類實現IHomeWork接口,通過傳參方式告訴小花,不需要修改邏輯主體代碼。這種方式其實叫做依賴注入。注入方式還包括構造器方式和setter方式別分看下代碼例子。
構造器注入方式:
- public class XiaohuaConstructor {
- private IHomeWork homeWork;
- public XiaohuaConstructor(IHomeWork homeWork){
- this.homeWork = homeWork;
- }
- public void doHomeWork(){
- homeWork.doHomeWork();
- }
- public static void main(String[] args) {
- XiaohuaConstructor xiaohua = new XiaohuaConstructor(new WashClothesHomeWork());
- xiaohua.doHomeWork();
- }
- }
然後這種方式有個缺點,在調用時,每次都要創建一個xiaohua實例。如果xiaohua要用全局單例,我們可以選擇用Setter方式來注入,如下:
- public class XiaohuaSetter {
- private IHomeWork homeWork;
- public void setHomeWork(IHomeWork homeWork){
- this.homeWork = homeWork;
- }
- public void doHomeWork(){
- homeWork.doHomeWork();
- }
- public static void main(String[] args) {
- XiaohuaSetter xiaohua = new XiaohuaSetter();
- xiaohua.setHomeWork(new WashClothesHomeWork());
- xiaohua.doHomeWork();
- xiaohua.setHomeWork(new PourGarbageHomeWork());
- xiaohua.doHomeWork();
- }
- }
接口隔離原則
指用多個專門的接口替代單一的總接口,客戶端調用者不應該依賴它不需要的接口。這個原則指導我們在設計接口時應該注意以下幾點:
- 類與類的依賴應該建立在最小的接口之上。
- 建立單一功能的接口,不要建立龐大臃腫的接口。
- 儘量細化接口,接口中的方法儘量少。(看業務情況,要適度)
接口隔離原則符合高內聚低耦合的設計思想,從而使類具有更好的可讀性、可擴展性和可維護性。
這裏具體例子可以參考 上面 單一職責原則的接口級別的代碼例子。我們通過類結構圖比較一下,可以看出下面的結構更利於以後擴展。
迪米特法則(最少知道原則)
指一個對象(類)應該對其他對象(類)保持最少的瞭解,目的是降低類與類之間的耦合。該法則還有一個更直觀的定義,只與直接朋友通信。那麼什麼是直接朋友?每個對象都會與其他對象有耦合關係,我們就說這兩個對象是朋友關係。類耦合的方式包括依賴、關聯、組合、聚合等。其中出現成員變量、方法參數、方法返回值中的類爲直接朋友關係,而出現在局部變量中的類則是非直接朋友關係。
爲了方便理解,還是舉例子吧。
假設現在Boss需要查看當前線上的成交訂單數,Boss首先找到的肯定是TeamLeader,TeamLeader統計完成後,把結果告知Boss。我們用代碼表達一下這個場景。
首先有個訂單類,Order
- public class Order {
- }
TeamLeader 類:
- public class TeamLeader {
- public void getTotalOrderNumber(List<Order> orderList){
- System.out.println("目前成交訂單數量爲"+orderList.size());
- }
- }
Boss類:
- public class Boss {
- public void checkOrderNumber(TeamLeader teamLeader){
- //創建有效訂單返回的列表
- List<Order> orderList = new ArrayList<>();
- //模擬100條數據
- for( int i = 0;i<100 ;i++){
- orderList.add(new Order());
- }
- teamLeader.getTotalOrderNumber(orderList);
- }
- }
如上代碼示例,我們找出Boss的直接朋友和非直接朋友,首先因爲TeamLeader爲Boss類方法中的入參,所以它是直接朋友。其次Boss在checkOrderNumber方法中創建了Order 列表對象,Order成了Boss的局部變量。所以它是非直接朋友關係。這樣就違反了迪米特法則。
爲了使上述示例符合迪米特法則,我們可以做如下修改:
TeamLeader類:
- public class TeamLeader {
- public void getTotalOrderNumber(){
- List<Order> orderList = new ArrayList<>();
- for(int i = 0;i<100;i++){
- orderList.add(new Order());
- }
- System.out.println("目前成交訂單數量爲"+orderList.size());
- }
- }
Boss類:
- public class Boss {
- public void checkOrderNumber(TeamLeader teamLeader){
- teamLeader.getTotalOrderNumber();
- }
- }
最新類結構:
如上,很明顯Boss與Order之間沒有依賴了。減少了類與類之間的耦合。但是凡事要有度,該法則也不能過度使用,類與類之間的業務關聯都要通過該法則進行約束的話,會產生大量的傳遞類,導致系統複雜度變大。
里氏替換原則
該原則主要針對子類與父類進行約束。子類與父類最大的關係就是繼承。
繼承優點:
-
- 子類可以擁有父類的屬性和方法,從而提供代碼重用性。
- 提高代碼可擴展性,子類本身可以自定義獨有方法。
缺點:
- 侵入性,子類繼承父類就必須擁有父類的屬性和方法,這對子類也是一種約束,降低了代碼靈活性。
- 高耦合,父類變量或者方法修改,一定會影響到子類。
此時里氏替換原則登場,它作爲繼承複用的基石,只有當衍生類可以替換基類,軟件單位的功能不受到影響時(基類如何改動,對子類不受影響),那麼基類才能真正被複用。
具體原則如下:
- 子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法。
- 子類中可以增加自己特有的方法。
- 當子類的方法重載父類的方法時,方法的前置條件(方法的入參)要比父類的方法入參更寬鬆。
- 當子類的方法實現父類的方法時(重寫/實現抽象方法),方法的後置條件(返回值)要比父類更新嚴格或者相等。
以上四點,第三點不好理解,這樣說有點乾巴巴,還是來點例子解解渴吧。
定義一個父類A doSomeThing方法參數HashMap範圍小於子類 doSomeThing方法參數Map,此時子類方法入參比父類入參更寬鬆了。
父類 A :
- public class A {
- public Collection doSomething(HashMap map){
- System.out.println("父類被執行......");
- return map.values();
- }
- }
子類B:
- public class B extends A {
- //重載父類方法
- public Collection doSomething(Map map) {
- System.out.println("子類被執行...");
- return map.values();
- }
- }
測試類執行:
- @Test
- public void invokerTest(){
- A father = new A();
- HashMap map= new HashMap();
- father.doSomething(map);
- }
執行結果輸出:
那麼根據里氏替換原則,父類出現的地方可以被子類替換,我們把自測類修改如下:
- @Test
- public void invokerTest(){
- B son = new B();
- HashMap map= new HashMap();
- son.doSomething(map);
- }
執行結果輸出:
如上符合預期,現在我們嘗試將父類和子類的入參範圍對換一下,使父類方法的入參範圍大於子類方法入參。
父類 A2:
- public class A2 {
- public Collection doSomething(Map map){
- System.out.println("父類被執行......");
- return map.values();
- }
- }
子類B2:
- public class B2 extends A2 {
- //重載父類方法
- public Collection doSomething(HashMap map) {
- System.out.println("子類被執行...");
- return map.values();
- }
- }
直接用子類替換父類執行的自測類如下:
- @Test
- public void invokerTest(){
- B2 son = new B2();
- HashMap map= new HashMap();
- son.doSomething(map);
- }
執行輸出結果:
預期輸出結果應該是:父類被執行......
顯然不符合里氏替換原則,這種程序不易維護,且出現問題也難以排查。
第四點原則,換一種說法就是,比如父類一個方法返回值類型爲T,子類重載或者重寫相同方法,返回值類型是S,那麼此時S必須小於等於T。只有這樣才能避免出現問題。
寫在最後
以上六個設計原則是設計模式的基礎,我們常用流行的開源框架基於各種設計模式爲我們做了很多封裝。只是我們在業務開發過程中沒過多注意。希望這篇文章能讓大家回想起這些基礎知識點,對大家以後的工作有些幫助作用。以上若有遺漏,敬請各位老師指正。