Spring方法注入的使用與實現原理

特務依昂發表於2020-05-13

一、前言

  這幾天為了更詳細地瞭解Spring,我開始閱讀Spring的官方文件。說實話,之前很少閱讀官方文件,就算是讀,也是讀別人翻譯好的。但是最近由於準備春招,需要了解很多知識點的細節,網上幾乎搜尋不到,只能硬著頭皮去讀官方文件。雖然我讀的這個Spring文件也是中文版的,但是很明顯是機翻,十分不通順,只能對著英文版本,兩邊對照著看,這個過程很慢,也很吃力。但是這應該是一個程式設計師必須要經歷的過程吧。

  在讀文件的時候,我讀到了一個叫做方法注入的內容,這是我之前學習Spring所沒有了解過的。所以,這篇部落格就參照文件中的描述,來講一講這個方法注入是什麼,在什麼情況下使用,以及簡單談一談它的實現原理。


二、正文

2.1 問題分析

  在說方法注入之前,我們先來考慮一種實際情況,通過實際案例,來引出我們為什麼需要方法注入。在我們的Spring程式中,可以將bean的依賴關係簡單分為四種:

  1. 單例bean依賴單例bean
  2. 多例bean依賴多例bean
  3. 多例bean依賴單例bean
  4. 單例bean依賴多例bean

  前三種依賴關係都很好解決,Spring容器會幫我們正確地處理,唯獨第四種——單例bean依賴多例beanSpring容器無法幫我們得到想要的結果。為什麼這麼說呢?我們可以通過Spring容器工作的方式來分析。

  我們知道,Springbean的作用域預設是單例的,每一個Spring容器,只會建立這個型別的一個例項物件,並快取在容器中,所以對這個bean的請求,拿到的都是同一個bean例項。而對於每一個bean來說,容器只會為它進行一次依賴注入,那就是在建立這個bean,為它初始化的時候。於是我們可以開始考慮上面說的第四種依賴情況了。假設一個單例bean A,它依賴於多例bean BSpring容器在建立A的時候,發現它依賴於B,且B是多例的,於是容器會建立一個新的B,然後將它注入到A中。A建立完成後,由於它是單例的,所以會被快取在容器中。之後,所有訪問A的程式碼,拿到的都是同一個A物件。而且,由於容器只會為bean執行一次依賴注入,所以我們通過A訪問到的B,永遠都是同一個,儘管B被配置為了多例,但是並沒有用。為什麼會這樣?因為多例的含義是,我們每次向Spring容器請求多例bean,都會建立一個新的物件返回。而B雖然是多例,但是我們是通過A訪問B,並不是通過容器訪問,所以拿到的永遠是同一個B。這時候,單例bean依賴多例bean就失敗了。

  那要如何解決這個問題呢?解決方案應該不難想到。我們可以放棄讓Spring容器為我們注入B,而是編寫一個方法,這個方法直接向Spring容器請求B;然後在A中,每次想要獲取B時,就呼叫這個方法獲取,這樣每次獲取到的B就是不一樣的了。而且我們這裡可以藉助ApplicationContextAware介面,將context物件(也就是容器)儲存在A中,這樣就可以方便地呼叫getBean獲取B了。比如,A的程式碼可以是這樣:

class A implements ApplicationContextAware {
    // 記錄容器的引用
    private ApplicationContext context;
    // A依賴的多例物件B
    private B b;

    /**
     * 這是一個回撥方法,會在bean建立時被呼叫
     */
    @Override
    public void setApplicationContext(ApplicationContext applicationContext)
            throws BeansException {
        this.context = applicationContext;
    }

    public B getB() {
        // 每次獲取B時,都向容器申請一個新的B
        b = context.getBean(B.class);
        return b;
    }
}

  但是,上面的做法真的好嗎?答案顯然是不好。Spring的一個很大的優點就是,它侵入性很低,我們在自己編寫的程式碼中,幾乎看不到Spring的元件,一般只會有一些註解。但是上面的程式碼中,卻直接耦合了Spring容器,將容器儲存在類中,並顯式地呼叫了容器的方法,這不僅增加了Spring的侵入性,也讓我們的程式碼變得不那麼容易管理,也變得不再優雅。而Spring提供的方法注入機制,就是用了實現和上面類似的功能,但是更加地優雅,侵入性更低。下面我們就來看一看。


2.2 方法注入的功能

  什麼是方法注入?其實方法注入和AOP非常類似,AOP用來對我們定義的方法進行增強,而方法注入,則是用來覆蓋我們定義的方法。通過Spring提供的方法注入機制,我們可以對類中定義的方法進行替換,比如說上面的getB方法,正常情況下,它的實現應該是這樣的:

public B getB() {
    return b;
}

  但是,為了實現每次獲取B時,能夠讓Spring容器建立一個新的B,我們在上面的程式碼中將它修改成了下面這個樣子:

public B getB() {
    // 每次獲取B時,都向容器申請一個新的B
    b = context.getBean(B.class);
    return b;
}

  但是,我們之前也說過,這種方式並不好,因為這直接依賴於Spring容器,增加了耦合性。而方法注入可以幫助我們解決這一點。方法注入能幫我們完成上面的替換,而且這種替換是隱式地,由Spring容器自動幫我們替換。我們並不需要修改編寫程式碼的方式,仍然可以將getB方法寫成第一種形式,而Spring容器會自動幫我們替換成第二種形式。這樣就可以在不增加耦合的情況下,實現我們的目的。


2.3 方法注入的實現原理

  那方法注入的實現原理是什麼呢?我之前說過,方法注入和AOP類似,不僅僅是功能類似,實際上它們的實現方式也是一樣的。方法注入的實現原理,就是通過CGLib的動態代理。關於AOP的實現原理,可以參考我的這篇部落格:淺析Spring中AOP的實現原理——動態代理

  如果我們為一個類的方法,配置了方法注入,那麼在Spring容器建立這個類的物件時,實際上建立的是一個代理物件。Spring會使用CGLib操作這個類的位元組碼,生成類的一個子類,然後覆蓋需要修改的那個方法,而在建立物件時,建立的就是這個子類(代理類)的物件。而具體覆蓋成什麼樣子,取決於我們的配置。比如說Spring提供了一個具體的方法注入機制——查詢方法注入,這種方法注入,可以將方法替換為一個查詢方法,它的功能就是去Spring容器中獲取一個特定的Bean,而獲取哪一個bean,取決於方法的返回值以及我們指定的bean名稱。

  比如說,上面的getB方法,如果我們對它使用了查詢方法注入,那麼Spring容器會使用CGLib生成A類的一個子類(代理類),覆蓋A類的getB方法,由於getB方法的返回值是B型別,於是這個方法的功能就變成了去Spring容器中獲取一個B,當然,我們也可以通過bean的名稱,指定這個方法查詢的bean。下面我就通過實際程式碼,來演示查詢方法注入。


2.4 查詢方法注入的使用

(一)通過xml配置

  為了演示查詢方法注入,我們需要幾個具體的類,假設我們有兩個類UserCar,而User依賴於Car,它們的定義如下:

public class User {

    private String name;
    private int age;
    // 依賴於car
    private Car car;

    // 為這個方法進行注入
   	public Car getCar() {
        return car;
    }
    
	// 省略其他setter和getter,以及toString方法
}

public class Car {
    private int speed;
    private double price;

    // 省略setter和getter,以及toString方法
}

  好,現在有了這兩個類,我們可以開始進行方法注入了。我們模擬之前說過的依賴關係——單例bean依賴於多例bean,將User配置為單例,而將User依賴的Car配置為多例。則配置檔案如下:

<!-- 將user的作用域定義為singleton -->
<bean id="user" class="cn.tewuyiang.pojo.User" scope="singleton">
    <property name="name" value="aaa" />
    <property name="age" value="28" />
    <!--
        配置查詢方法注入,替換getCar方法,讓他成為從spring容器中查詢car的一個工廠方法
        name指定了需要進行方法注入的方法,而bean則指定了這個方法被覆蓋後,是用來查詢哪個bean的
    -->
    <lookup-method name="getCar" bean="car" />
</bean>

<!-- 將car的作用域定義為prototype -->
<bean id="car" class="cn.tewuyiang.pojo.Car" scope="prototype">
    <property name="price" value="9999.35" />
    <property name="speed" value="100" />
</bean>

  好,到此為止,我們就配置完成了,下面就該測試一下通過usergetCar方法拿到的多個car,是不是不相同。如果方法注入沒有生效,那麼按理來講,我們呼叫getCar方法返回的應該是null,因為我們並沒有配置將car的值注入user中。但是如果方法注入生效,那麼我們通過getCar,就可以拿到car物件,因為它將去Spring容器中獲取,而且每次獲取到的都不是同一個。測試方法如下:

@Test
public void testXML() throws InterruptedException {
    // 建立Spring容器
    ClassPathXmlApplicationContext context =
        new ClassPathXmlApplicationContext("classpath:applicationContext.xml");
    // 獲取User物件
    User user = context.getBean(User.class);
    // 多次呼叫getCar方法,獲取多個car
    Car c1 = user.getCar();
    Car c2 = user.getCar();
    Car c3 = user.getCar();
    // 分別輸出car的hash值,看是否相等,以此判斷是否是同一個物件
    System.out.println(c1.hashCode());
    System.out.println(c2.hashCode());
    System.out.println(c3.hashCode());
    // 輸出user這個bean所屬型別的父類
    System.out.println(user.getClass().getSuperclass());
}

  上面的測試邏輯應該很好理解,除了最後一句,為什麼需要輸出user這個bean所屬型別的父類。因為我前面說過,方法注入通過CGLib動態代理實現,而CGLib動態代理的原理就是生成類的一個子類。我們為User類使用了方法注入,所以我們拿到的user這個bean,應該是一個代理bean,並且它的型別是User的子類。所以我們輸出這個bean的父類,來判斷是否和我們之前說的一樣。輸出結果如下:

1392906938
708890004
255944888
class cn.tewuyiang.pojo.User	// 父類果然是User

  可以看到,我們果然能夠通過getCar方法,獲取到bean,並且每一次獲取到的都不是同一個,因為hashcode不相等。同時,user這個bean的父型別果然是User,說明user這個bean確實是CGLib生成的一個代理bean。到此,也就證明了我們之前的敘述。


(二)通過註解配置

  上面通過xml的配置方式,大致瞭解了查詢方法注入的使用,下面我們再來看看使用註解,如何實現。其實使用註解的方式更加簡單,我們只需要在方法上使用@Lookup註解即可,UserCar的配置如下:

@Component
public class User {
    private String name;
    private int age;
    private Car car;

    // 使用Lookup註解,告訴Spring這個方法需要使用查詢方法注入
    // 這裡直接使用@Lookup,則Spring將會依據方法返回值
    // 將它覆蓋為一個在Spring容器中獲取Car這個型別的bean的方法
    // 但是也可以指定需要獲取的bean的名字,如:@Lookup("car")
    // 此時,名字為car的bean,型別必須與方法的返回值型別一致
    @Lookup
    public Car getCar() {
        return car;
    }
    
    // 省略其他setter和getter,以及toString方法
    
}

@Component
@Scope("prototype")	// 宣告為多例
public class Car {
    private int speed;
    private double price;

    // 省略setter和getter,以及toString方法
}

  可以看到,通過註解配置方法注入要簡單的多,只需要通過一個@Lookup註解即可實現。測試方法與之前類似,結果也一樣,我就不貼出來了。


(三)為抽象方法使用方法注入

  實際上,方法注入還可以應用於抽象方法。既然方法注入的目的是替換原來的方法,那麼原來的方法是否有實現,也就不重要了。所以方法注入也能用在抽象方法上面。但是有人可能會想一個問題:抽象方法只能在抽象類中,那這個類被定義為抽象類了,Spring容器如何為它建立物件呢?我們之前說過,使用了方法注入的類,Spring會使用CGLib生成它的一個代理類(子類),Spring建立的是這個代理類的物件,而不會去建立源類的物件,所以它是不是抽象的並不影響工作。如果配置了方法注入的類是一個抽象類,則方法注入機制的實現,就是去實現它的抽象方法。我們將User類改為抽象,如下所示:

// 就算為抽象類使用了@Component,Spring容器在建立bean時也會跳過它
@Component
public abstract class User {
    private String name;
    private int age;
    private Car car;

    // 將getCar宣告為抽象方法,它將會被代理類實現
    @Lookup
    public abstract Car getCar();
    
    // 省略其他setter和getter,以及toString方法
    
}

  以上方式,方法注入仍然可以工作。


(四)final方法和private方法無法使用方法注入

  CGLib實現動態代理的方法是建立一個子類,然後重寫父類的方法,從而實現代理。但是我們知道,final方法和private方法是無法被子類重寫的。這也就意味著,如果我們為一個final方法或者一個private方法配置了方法注入,那生成的代理物件中,這個方法還是原來那個,並沒有被重寫,比如像下面這樣:

@Component
public class User {
    private String name;
    private int age;
    private Car car;
    
    // 方法宣告為final,無法被覆蓋,代理類中的getCar還是和下面一樣
    @Lookup
    public final Car getCar() {
        return car;
    }
    
    // 省略其他setter和getter,以及toString方法
    
}

  我們依舊使用下面的測試方法,但是,在呼叫c1.hashCode方法時,丟擲了空指標異常。說明getCar方法並沒有被覆蓋,還是直接返回了car這個成員變數。但是由於我們並沒有為user注入car,所以car == null

@Test
public void testConfig() throws InterruptedException {
    AnnotationConfigApplicationContext context =
        new AnnotationConfigApplicationContext(AutoConfig.class);

    User user = context.getBean(User.class);
    Car c1 = user.getCar();
    Car c2 = user.getCar();
    Car c3 = user.getCar();
    // 執行到這裡,丟擲空指標異常
    System.out.println(c1.hashCode());
    System.out.println(c2.hashCode());
    System.out.println(c3.hashCode());
    user.spCar();
    user.spCar();
    user.spCar();
    System.out.println(user.getClass().getSuperclass());
}


三、總結

  以上大致介紹了一下方法注入的作用,實現原理,以及重點介紹了一下查詢方法注入的使用。查詢方法注入可以將我們的一個方法,覆蓋成為一個去Spring容器中查詢特定bean的方法,從而解決單例bean無法依賴多例bean的問題。其實,方法注入能夠注入任何方法,而不僅僅是查詢方法,但是由於任何方法注入使用的不多,所以這篇部落格就不提了,感興趣的可以自己去Spring文件中瞭解。最後,若以上描述存在錯誤或不足,歡迎指正,共同進步。


四、參考

相關文章