【專案實踐】依賴注入用得好,設計模式隨便搞

RudeCrab發表於2021-01-21

首圖.png

以專案驅動學習,以實踐檢驗真知

前言

設計模式是我們程式設計道路上繞不開的一環,用好了設計模式能夠讓程式碼擁有良好的維護性、可讀性以及擴充套件性,它彷彿就是“優雅”的代名詞,各個框架和庫也都能見到它的身影。

正是因為它有種種好處,所以很多人在開發時總想將某個設計模式用到專案中來,然而往往會用得比較彆扭。其中一部分原因是業務需求並不太符合所用的設計模式,還有一部分原因就是在Web專案中我們物件都是交由Spring框架的Ioc容器來管理,很多設計模式無法直接套用。那麼在真正的專案開發中,我們就需要對設計模式做一個靈活的變通,讓其能夠和框架結合,在實際開發中發揮出真正的優勢。

當專案引入IoC容器後,我們一般是通過依賴注入來使用各個物件,將設計模式和框架結合的關鍵點就在於此!本文會講解如何通過依賴注入來完成以下三種設計模式:

  • 單例模式
  • 責任鏈模式
  • 策略模式

由淺入深,讓你在瞭解幾個設計模式的同時掌握依賴注入的一些妙用。

本文所有程式碼都放在Github上,克隆下來即可執行檢視效果。

實戰

單例模式

單例應該是很多人接觸的第一個設計模式,相比其他設計模式來說單例的概念非常簡單,即在一個程式中,某個類從始至終只有一個例項物件。不過概念就算再簡單,還是需要一點編碼才能實現,R 之前的文章 回字有四種寫法,那你知道單例有五種寫法嗎 就有詳細的講解,這裡對該設計模式就不過多介紹了,我們們直接來看看在實際開發中如何運用該模式。

交由Spring IoC容器管理的物件稱之為Bean,每個Bean都有其作用域(scope),這個作用域可以理解為Spring控制Bean生命週期的方式。建立和銷燬是生命週期中必不可少的節點,單例模式的重點自然是物件的建立。而Spring建立物件的過程對於我們來說是無感知的,即我們只需配置好Bean,然後通過依賴注入就可以使用物件了:

@Service //和@Component功能一樣,將該類宣告為Bean交由容器管理
public class UserServiceImpl implements UserService{
}

@Controller
public class UserController {
    @Autowired // 依賴注入
    private UserService userService;
}

那這個物件的建立我們該如何控制呢?

其實,Bean預設的作用範圍就是單例的,我們無需手寫單例。要想驗證Bean是否為單例很簡單,我們在程式各個地方獲取Bean後列印其hashCode就可以看是否為同一個物件了,比如兩個不同的類中都注入了UserService

@Controller
public class UserController {
    @Autowired
    private UserService userService;
    
    public void test() {
        System.out.println(userService.hashCode());
    }
}

@Controller
public class OtherController {
    @Autowired
    private UserService userService;
    
    public void test() {
        System.out.println(userService.hashCode());
    }
}

列印結果會是兩個相同的hashCode

為什麼Spring預設會用單例的形式來例項化Bean呢?這自然是因為單例可以節約資源,有很多類是沒必要例項化多個物件的。

如果我們就是想每次獲取Bean時都建立一個物件呢?我們可以在宣告Bean的時候加上@Scope註解來配置其作用域:

@Service
@Scope("prototype")
public class UserServiceImpl implements UserService{
}

這樣當你每次獲取Bean時都會建立一個例項。

Bean的作用域有以下幾種,我們可以根據需求配置,大多數情況下我們用預設的單例就好了:

名稱 說明
singleton 預設作用範圍。每個IoC容器只建立一個物件例項。
prototype 被定義為多個物件例項。
request 限定在HTTP請求的生命週期內。每個HTTP客戶端請求都有自己的物件例項。
session 限定在HttpSession的生命週期內。
application 限定在ServletContext的生命週期內。
websocket 限定在WebSocket的生命週期內。

這裡要額外注意一點,Bean的單例並不能完全算傳統意義上的單例,因為其作用域只能保證在IoC容器內保證只有一個物件例項,但是不能保證一個程式內只有一個物件例項。也就是說,如果你不通過Spring提供的方式獲取Bean,而是自己建立了一個物件,此時程式就會有多個物件存在了:

public void test() {
    // 自己new了一個物件
    System.out.println(new UserServiceImpl().hashCode());
}

這就是需要變通的地方,Spring可以說在我們日常開發中覆蓋了每一個角落,只要自己不故意繞開Spring,那麼保證IoC容器內的單例基本就等同於保證了整個程式內的單例。

責任鏈模式

概念比較簡單的單例講解完後,我們們再來看看責任鏈模式。

模式講解

該模式並不複雜:一個請求可以被多個物件處理,這些物件連線成一條鏈並且沿著這條鏈傳遞請求,直到有物件處理它為止。該模式的好處是讓請求者和接受者解耦,可以動態增刪處理邏輯,讓處理物件的職責擁有了非常高的靈活性。我們開發中常用的過濾器Filter和攔截器Interceptor就是運用了責任鏈模式。

光看介紹只會讓人云裡霧裡,我們直接來看下該模式如何運用。

就拿工作中的請假審批來說,當我們發起一個請假申請的時候,一般會有多個審批者,每個審批者都代表著一個責任節點,都有自己的審批邏輯。我們假設有以下審批者:

組長Leader:只能審批不超過三天的請假;
經理Manger:只能審批不超過七天的請假;
老闆Boss:能夠審批任意天數。

我們們先定義一個請假審批的物件:

public class Request {
    /**
     * 請求人姓名
     */
    private String name;
    /**
     * 請假天數。為了演示就簡單按整天來算,不弄什麼小時了
     */
    private Integer day;

    public Request(String name, Integer day) {
        this.name = name;
        this.day = day;
    }
    
    // 省略get、set方法
}

按照傳統的寫法是接受者收到這個物件後通過條件判斷來進行相應的處理:

public class Handler {
    public void process(Request request) {
        System.out.println("---");

        // Leader審批
        if (request.getDay() <= 3) {
            System.out.println(String.format("Leader已審批【%s】的【%d】天請假申請", request.getName(), request.getDay()));
            return;
        }
        System.out.println(String.format("Leader無法審批【%s】的【%d】天請假申請", request.getName(), request.getDay()));

        // Manger審批
        if (request.getDay() <= 7) {
            System.out.println(String.format("Manger已審批【%s】的【%d】天請假申請", request.getName(), request.getDay()));
            return;
        }
        System.out.println(String.format("Manger無法審批【%s】的【%d】天請假申請", request.getName(), request.getDay()));

        // Boss審批
        System.out.println(String.format("Boss已審批【%s】的【%d】天請假申請", request.getName(), request.getDay()));

        System.out.println("---");
    }
}

在客戶端模擬審批流程:

public class App {
    public static void main( String[] args ) {
        Handler handler = new Handler();
        handler.process(new Request("張三", 2));
        handler.process(new Request("李四", 5));
        handler.process(new Request("王五", 14));
    }
}

列印結果如下:

---
Leader已審批【張三】的【2】天請假申請
---
Leader無法審批【李四】的【5】天請假申請
Manger已審批【李四】的【5】天請假申請
---
Leader無法審批【王五】的【14】天請假申請
Manger無法審批【王五】的【14】天請假申請
Boss已審批【王五】的【14】天請假申請
---

不難看出Handler類中的程式碼充滿了壞味道!每個責任節點間的耦合度非常高,如果要增刪某個節點,就要改動這一大段程式碼,很不靈活。而且這裡演示的審批邏輯還只是列印一句話而已,在真實業務中處理邏輯可比這複雜多了,如果要改動起來簡直就是災難。

這時候我們的責任鏈模式就派上用場了!我們將每個責任節點封裝成獨立的物件,然後將這些物件組合起來變成一個鏈條,並通過統一入口挨個處理。

首先,我們要抽象出責任節點的介面,所有節點都實現該介面:

public interface Handler {
    /**
     * 返回值為true,則代表放行,交由下一個節點處理
     * 返回值為false,則代表不放行
     */
    boolean process(Request request);
}

以Leader節點為例,實現該介面:

public class LeaderHandler implements Handler{
    @Override
    public boolean process(Request request) {
        if (request.getDay() <= 3) {
            System.out.println(String.format("Leader已審批【%s】的【%d】天請假申請", request.getName(), request.getDay()));
            // 處理完畢,不放行
            return false;
        }
        System.out.println(String.format("Leader無法審批【%s】的【%d】天請假申請", request.getName(), request.getDay()));
        // 放行
        return true;
    }
}

然後定義一個專門用來處理這些Handler的鏈條類:

public class HandlerChain {
    // 存放所有Handler
    private List<Handler> handlers = new LinkedList<>();

    // 給外部提供一個增加Handler的入口
    public void addHandler(Handler handler) {
        this.handlers.add(handler);
    }

    public void process(Request request) {
        // 依次呼叫Handler
        for (Handler handler : handlers) {
            // 如果返回為false,中止呼叫
            if (!handler.process(request)) {
                break;
            }
        }
    }
    
}

現在我們來看下使用責任鏈是怎樣執行審批流程的:

public class App {
    public static void main( String[] args ) {
        // 構建責任鏈
        HandlerChain chain = new HandlerChain();
        chain.addHandler(new LeaderHandler());
        chain.addHandler(new ManagerHandler());
        chain.addHandler(new BossHandler());
        // 執行多個流程
        chain.process(new Request("張三", 2));
        chain.process(new Request("李四", 5));
        chain.process(new Request("王五", 14));
    }
}

列印結果和前面一致。

這樣帶來的好處是顯而易見的,我們可以非常方便地增刪責任節點,修改某個責任節點的邏輯也不會影響到其他的節點,每個節點只需關注自己的邏輯。並且責任鏈是按照固定順序執行節點,按照自己想要的順序新增各個物件即可方便地排列順序。

此外責任鏈有很多變體,比如像Servlet的Filter執行下一個節點時還需要持有鏈條的引用:

public class MyFilter implements Filter {
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
        if (...) {
            // 通過鏈條引用來放行
            chain.doFilter(req, resp);
        } else {
            // 如果沒有呼叫chain的方法則代表中止往下傳遞
            ...
        }
    }
}

各責任鏈除了傳遞的方式不同,整體的鏈路邏輯也可以有所不同。

我們剛才演示的是將請求交由某一個節點進行處理,只要有一個處理了,後續就不用處理了。有些責任鏈目的不是找到某一個節點來處理,而是每個節點都做一些事,相當於一個流水線。

比如像剛才的審批流程,我們可以將邏輯改為一個請假申請需要每一個審批人都同意才算申請通過,Leader同意了後轉給Manger審批,Manger同意了後轉給Boss審批,只有Boss最終同意了才生效。

形式有多種,其核心概念是將請求物件鏈式傳遞,不脫離這一點就都可以算作責任鏈模式,無需太死守定義。

配合框架

責任鏈模式中,我們都是自己建立責任節點物件,然後將其新增到責任鏈條中。在實際開發中這樣就會有一個問題,如果我們的責任節點裡依賴注入了其它的Bean,那麼手動建立物件的話則代表該物件就沒有交由Spring管理,那些屬性也就不會被依賴注入:

public class LeaderHandler implements Handler{
    @Autowired // 手動建立LeaderHandler則該屬性不會被注入
    private UserService userService;
}

此時我們就必須將各個節點物件也交由Spring來管理,然後通過Spring來獲取這些物件例項,再將這些物件例項放置到責任鏈中。其實這種方式大部分人都接觸過,Spring MVC的攔截器Interceptor就是這樣使用的:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 獲取Bean,新增到責任鏈中(注意哦,這裡是呼叫的方法來獲取物件,而不是new出物件)
        registry.addInterceptor(loginInterceptor());
        registry.addInterceptor(authInterceptor());
    }
    
    // 通過@Bean註解將自定義攔截器交由Spring管理
    @Bean
    public LoginInterceptor loginInterceptor() {return new LoginInterceptor();}
    @Bean
    public AuthInterceptor authInterceptor() {return new AuthInterceptor();}
}

InterceptorRegistry就相當於鏈條類了,該物件由Spring MVC傳遞給我們,好讓我們新增攔截器,後續Spring MVC會自行呼叫責任鏈,我們無需操心。

別人框架定義的責任鏈會由框架呼叫,那我們自定義的責任鏈該如何呼叫呢?這裡有一個更為簡便的方式,那就是將Bean依賴注入到集合中

我們日常開發時都是使用依賴注入獲取單個Bean,這是因為我們宣告的介面或者父類通常只需一個實現類就可以搞定業務需求了。而剛才我們自定義的Handler介面下會有多個實現類,此時我們就可以一次性注入多個Bean!我們們現在就來改造一下之前的程式碼。

首先,將每個Handler實現類加上@Service註解,將其宣告為Bean:

@Service
public class LeaderHandler implements Handler{
    ...
}

@Service
public class ManagerHandler implements Handler{
    ...
}

@Service
public class BossHandler implements Handler{
    ...
}

然後我們來改造一下我們的鏈條類,將其也宣告為一個Bean,然後直接在成員變數上加上@Autowired註解。既然都通過依賴注入來實現了,那麼就無需手動再新增責任節點,所以我們將之前的新增節點的方法給去除:

@Service
public class HandlerChain {
    @Autowired
    private List<Handler> handlers;

    public void process(Request request) {
        // 依次呼叫Handler
        for (Handler handler : handlers) {
            // 如果返回為false,中止呼叫
            if (!handler.process(request)) {
                break;
            }
        }
    }

}

沒錯,依賴注入非常強大,不止能夠注入單個物件,還可以注入多個!這樣一來就非常方便了,我們只需實現Handler介面,將實現類宣告為Bean,就會自動被注入到責任鏈中,我們甚至都不用手動新增。要想執行責任鏈也特別簡單,只需獲取HandlerChain然後呼叫即可:

@Controller
public class UserController {
    @Autowired
    private HandlerChain chain;

    public void process() {
        chain.process(new Request("張三", 2));
        chain.process(new Request("李四", 5));
        chain.process(new Request("王五", 14));
    }
    
}

執行效果如下:

---
Boss已審批【張三】的【2】天請假申請
---
Boss已審批【李四】的【5】天請假申請
---
Boss已審批【王五】的【14】天請假申請

咦,全部都是Boss審批了,為啥前面兩個節點沒有生效呢?因為我們還沒有配置Bean注入到集合中的順序,我們需要加上@Order註解來控制Bean的裝配順序,數字越小越靠前:

@Order(1)
@Service
public class LeaderHandler implements Handler{
    ...
}

@Order(2)
@Service
public class ManagerHandler implements Handler{
    ...
}

@Order(3)
@Service
public class BossHandler implements Handler{
    ...
}

這樣我們自定義的責任鏈模式就完美融入到Spring中了!

策略模式

乘熱打鐵,我們現在再來講解一個新的模式!

模式講解

我們開發中經常碰到這樣的需求:需要根據不同的情況執行不同的操作。比如我們購物最常見的郵費,不同的地區、不同的商品郵費都會不同。假設現在需求是這樣的:

包郵地區:沒有超過10KG的貨物免郵,10KG以上8元;

鄰近地區:沒有超過10KG的貨物8元,10KG以上16元;

偏遠地區:沒有超過10KG的貨物16元,10KG以上15KG以下24元, 15KG以上32元。

那我們計算郵費的方法大概是這樣的:

// 為了方便演示,重量和金額就簡單設定為整型
public long calPostage(String zone, int weight) {
    // 包郵地區
    if ("freeZone".equals(zone)) {
        if (weight <= 10) {
            return 0;
        } else {
            return 8;
        }
    }

    // 近距離地區
    if ("nearZone".equals(zone)) {
        if (weight <= 10) {
            return 8;
        } else {
            return 16;
        }
    }

    // 偏遠地區
    if ("farZone".equals(zone)) {
        if (weight <= 10) {
            return 16;
        } else if (weight <= 15) {
            return 24;
        } else {
            return 32;
        }
    }
	
    return 0;
}

這麼點郵費規則就寫了如此長的程式碼,要是規則稍微再複雜點簡直就更長了。而且如果規則有變,就要對這一大塊程式碼縫縫補補,久而久之程式碼就會變得非常難以維護。

我們首先想到的優化方式是將每一塊的計算封裝成方法:

public long calPostage(String zone, int weight) {
    // 包郵地區
    if ("freeZone".equals(zone)) {
        return calFreeZonePostage(weight);
    }

    // 近距離地區
    if ("nearZone".equals(zone)) {
        return calNearZonePostage(weight);
    }

    // 偏遠地區
    if ("farZone".equals(zone)) {
        return calFarZonePostage(weight);
    }
	
    return 0;
}

這樣確實不錯,大部分情況下也能滿足需求,可依然不夠靈活。

因為這些規則都是寫死在我們方法內的,如果呼叫者想使用自己的規則,或者經常修改規則呢?總不能動不動就修改我們寫好的程式碼吧。要知道郵費計算只是訂單價格計算的一個小環節,我們固然可以寫好幾種規則定式來提供服務,但也得允許別人自定義規則。此時,我們更應該將郵費計算操作高度抽象成一個介面,有不同的計算規則就實現不同的類。不同規則代表不同策略,這種方式就是我們的策略模式!我們來看下具體寫法:

首先,封裝一個郵費計算介面:

public interface PostageStrategy {
    long calPostage(int weight);
}

然後,我們將那幾個地區規則封裝成不同的實現類,拿包郵地區示例:

public class FreeZonePostageStrategy implements PostageStrategy{
    @Override
    public long calPostage(int weight) {
        if (weight <= 10) {
            return 0;
        } else {
            return 8;
        }
    }
}

最後,要應用策略的話我們還需要一個專門類:

public class PostageContext {
    // 持有某個策略
    private PostageStrategy postageStrategy = new FreeZonePostageStrategy();
    // 允許呼叫方設定新的策略
    public void setPostageStrategy(PostageStrategy postageStrategy) {
        this.postageStrategy = postageStrategy;
    }
    // 供呼叫方執行策略
    public long calPostage(int weight) {
        return postageStrategy.calPostage(weight);
    }
}

這樣,呼叫方既可以使用我們已有的策略,也可以非常方便地修改或自定義策略:

public long calPrice(User user, int weight) {
    PostageContext postageContext = new PostageContext();
    // 自定義策略
    if ("RudeCrab".equals(user.getName())) {
        // VIP客戶,20KG以下一律包郵,20KG以上只收5元
        postageContext.setPostageStrategy(w -> w <= 20 ? 0 : 5);
        return postageContext.calPostage(weight);
    }
    // 包郵地區策略
    if ("freeZone".equals(user.getZone())) {
        postageContext.setPostageStrategy(new FreeZonePostageStrategy());
        return postageContext.calPostage(weight);
    }
    // 鄰近地區策略
    if ("nearZone".equals(user.getZone())) {
        postageContext.setPostageStrategy(new NearZonePostageStrategy());
        return postageContext.calPostage(weight);
    }
    
    ...
    
    return 0;
}

可以看到,簡單的邏輯直接使用Lambda表示式就完成了自定義策略,若邏輯複雜的話也可以直接新建一個實現類來完成。

這就是策略模式的魅力所在,允許呼叫方使用不同的策略來得到不同的結果,以達到最大的靈活性!

儘管好處很多,但策略模式缺點也很明顯:

  • 可能會造成策略類過多的情況,有多少規則就有多少類
  • 策略模式只是將邏輯分發到不同實現類中,呼叫方的if、else一個都沒減少。
  • 呼叫方需要知道所有策略類才能使用現有的邏輯。

大部分缺點可以配合工廠模式或者反射來解決,但這樣又增加了系統的複雜性。那有沒有既能彌補缺點又不復雜的方案呢,當然是有的,這就是我接下來要講解的內容。在策略模式配合Spring框架的同時,也能彌補模式本身的缺點!

配合框架

經過責任鏈模式我們們就可以發現,其實所謂的配合框架就是將我們的物件交給Spring來管理,然後通過Spring呼叫Bean即可。策略模式中,我們們每個策略類都是手動例項化的,那我們們要做的第一步毫無疑問就是將這些策略類宣告為Bean:

@Service("freeZone") // 註解中的值代表Bean的名稱,這裡為什麼要這樣做,等下我會講解
public class FreeZonePostageStrategy implements PostageStrategy{
	...
}

@Service("nearZone")
public class NearZonePostageStrategy implements PostageStrategy{
	...
}

@Service("farZone")
public class FarZonePostageStrategy implements PostageStrategy{
	...
}

然後我們就要通過Spring獲取這些Bean。有人可能會自然聯想到,我們還是將這些實現類都注入到一個集合中,然後遍歷使用。這確實可以,不過太麻煩了。依賴注入可是非常強大的,不僅能將Bean注入到集合中,還能將其注入到Map中

來看具體用法:

@Controller
public class OrderController {
    @Autowired
    private Map<String, PostageStrategy> map;

    public void calPrice(User user, int weight) {
        map.get(user.getZone()).calPostage(weight);
    }
}

大聲告訴我,清不清爽!簡不簡潔!優不優雅!

依賴注入能夠將Bean注入到Map中,其中Key為Bean的名稱,Value為Bean物件,這也就是我前面要在@Service註解上設定值的原因,只有這樣才能將讓呼叫方直接通過Map的get方法獲取到Bean,繼而使用該Bean物件。

我們之前的PostageContext類可以不要了,什麼時候想呼叫某策略,直接在呼叫處注入Map即可。

通過這種方式,我們不僅讓策略模式完全融入到Spring框架中,還完美解決了if、else過多等問題!我們要想新增策略,只需新建一個實現類並將其宣告成Bean就行了,原有呼叫方無需改動一行程式碼即可生效。

小貼士:如果一個介面或者父類有多個實現類,但我又只想依賴注入單個物件,可以使用@Qualifier("Bean的名稱")註解來獲取指定的Bean。

總結

本文介紹了三種設計模式,以及各設計模式在Spring框架下是如何運用的!這三種設計模式對應的依賴注入方式如下:

  • 單例模式:依賴注入單個物件
  • 責任鏈模式:依賴注入集合
  • 策略模式:依賴注入Map

將設計模式和Spring框架配合的關鍵點就在於,如何將模式中的物件交由Spring管理。這是本文的核心,這一點思考清楚了,各個設計模式才能靈活使用。

講解到這裡就結束了,本文所有程式碼都放在Github,克隆下來即可執行。如果對你有幫助,可以點贊關注,我會持續更新更多原創【專案實踐】的!

相關文章