從自定義一個作用域開始來了解SpringBean的作用域

落叶微风發表於2024-05-01

你好,這裡是codetrend專欄“Spring6全攻略”。

在 Spring 框架中,Bean 的作用域(Scope)定義了 Bean 例項在容器中如何建立、管理和銷燬的策略。

Spring 提供了多種 Bean 作用域,每種作用域都有其特定的生命週期和適用場景。

先試試不同的 Bean Scope

下面透過一個簡單的 Spring MVC Controller 示例來感受下 Bean 的作用域。

例子程式碼是這樣的:

import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.web.context.WebApplicationContext;

import java.util.UUID;

@Configuration
public class AppConfig {
    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
    public SingletonBean singletonBean() {
        return new SingletonBean();
    }

    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    public PrototypeBean prototypeBean() {
        return new PrototypeBean();
    }

    @Bean
    @Scope(WebApplicationContext.SCOPE_SESSION)
    public SessionBean sessionBean() {
        return new SessionBean();
    }
}

class SingletonBean {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

class PrototypeBean {
    private String id;

    public PrototypeBean() {
        this.id = UUID.randomUUID().toString();
    }

    public String getId() {
        return id;
    }
}

class SessionBean {
    private String id;

    public SessionBean() {
        this.id = UUID.randomUUID().toString();
    }

    public String getId() {
        return id;
    }
}

controller 程式碼:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ScopeController {
    @Autowired
    private SingletonBean singletonBean;

    @Autowired
    private ApplicationContext context;

    @GetMapping("/singleton")
    public String singletonCount() {
        singletonBean.increment();
        return "Singleton Count: " + singletonBean.getCount();
    }

    @GetMapping("/prototype")
    public String prototypeGet() {
        PrototypeBean prototypeBean = context.getBean(PrototypeBean.class);
        return "Prototype ID: " + prototypeBean.getId();
    }

    @GetMapping("/session")
    public String sessionGet() {
        SessionBean prototypeBean = context.getBean(SessionBean.class);
        return "Session ID: " + prototypeBean.getId();
    }
}
  • Singleton(單例)的屬性持續增加,也就是說訪問到的SingletonBean每次都是同一個物件。訪問/singleton介面的返回是這樣的:
1
2
3
  • Prototype(原型)的屬性每次都是不一樣的,也就是說明id每次都是呼叫構造器新建立的。訪問/prototype介面的返回是這樣的:

Prototype ID: 3ea5af10-ddce-4a89-ad3c-3f07a764f179
Prototype ID: 7e6e9fe8-c0dc-423e-b282-96b7f8087dac
Prototype ID: 7aca1000-484d-46e8-80f7-d444a5a04f49
  • Session(會話)的屬性同一視窗是一樣的,開啟無痕視窗或者其他瀏覽器就不一樣。訪問/session介面的返回是這樣的:
Prototype ID: 7aca1000-484d-46e8-80f7-d444a5a04f49
Prototype ID: 7aca1000-484d-46e8-80f7-d444a5a04f49
# 開啟新的視窗後
Session ID: bd22d310-29e5-4004-8555-9678c08275f0
Session ID: bd22d310-29e5-4004-8555-9678c08275f0

可以直接把樣例程式碼複製到例子裡面驗證測試。這樣我們就對BeanScope作用域有個直觀的感受。

自定義一個 Bean Scope

接下來透過實現一個自定義作用域來感受下Bean的作用域原理。

在 Spring 框架中,除了預定義的幾種作用域(如 singleton、prototype 等)外,使用者還可以自定義作用域以滿足特定的業務需求。

自定義作用域允許控制 Bean 的建立、快取和銷燬邏輯,以適應特定的場景,如基於特定條件的例項化策略、自定義生命週期管理等。

自定義步驟:

  • 定義作用域介面:首先,需要實現org.springframework.beans.factory.config.Scope介面,該介面定義了 Bean 作用域的基本行為。
  • 實現邏輯:在自定義的 Scope 介面實現中,需要覆蓋getremoveregisterDestructionCallback方法,分別用於獲取 Bean 例項、移除 Bean 例項以及註冊銷燬回撥。
  • 註冊作用域:在 Spring 配置中註冊的自定義作用域,使其可被容器識別和使用。
  • 使用自定義作用域:在 Bean 定義中透過@Scope註解指定使用自定義的作用域名稱。

自定義作用域實現

首先自定義作用域實現,也就是實現介面org.springframework.beans.factory.config.Scope

import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.config.Scope;

import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
import java.util.UUID;

public class CustomScope implements Scope {
    public final static String CUSTOM_SCOPE_NAME = "custom";
    private final Map<String, Object> scopedObjects = new ConcurrentHashMap<>();
    private final Map<String, Runnable> destructionCallbacks = new ConcurrentHashMap<>();

    @Override
    public Object get(String name, ObjectFactory<?> objectFactory) {
        Object scopedObject = scopedObjects.get(name);
        if (scopedObject == null) {
            scopedObject = objectFactory.getObject();
            scopedObjects.put(name, scopedObject);
        }
        return scopedObject;
    }

    @Override
    public Object remove(String name) {
        scopedObjects.remove(name);
        Runnable callback = destructionCallbacks.remove(name);
        if (callback != null) {
            callback.run();
        }
        return null;
    }

    @Override
    public void registerDestructionCallback(String name, Runnable callback) {
        destructionCallbacks.put(name, callback);
    }

    @Override
    public Object resolveContextualObject(String key) {
        // 可以根據需要實現上下文物件解析邏輯
        return null;
    }

    @Override
    public String getConversationId() {
        // 返回一個唯一的標識,用於區分作用域上下文
        return UUID.randomUUID().toString();
    }
}

可以看到Scope介面其實是對Bean的全生命週期進行管理,包括獲取get、快取和銷燬remove和銷燬回撥等邏輯。這也是作用域的核心原理。

Spring6怎麼實現的Scope

這裡以org.springframework.web.context.request.RequestScope 為例子來理解Spring6怎麼實現BeanScope的。

得益於Spring框架的抽象和封裝,這個類的實現程式碼並沒有多少。

  • RequestScope extends AbstractRequestAttributesScope 核心實現在這個類 AbstractRequestAttributesScope

  • get獲取物件方法,其中物件的儲存放在了ThreadLocal中,也就是RequestContextHolder這個類的核心。

/**
 * 根據名稱獲取物件,如果當前請求屬性中沒有該物件,則使用物件工廠建立一個物件,並將其設定到請求屬性中
 * 然後再次獲取該物件,以便進行隱式會話屬性更新。作為額外的好處,我們還允許在獲取屬性級別進行潛在的裝飾。
 * 如果再次獲取到的物件不為空(預期情況),則只使用該物件。如果它同時消失了,我們則返回本地建立的例項。
 */
public Object get(String name, ObjectFactory<?> objectFactory) {
    // 獲取當前請求的屬性
    RequestAttributes attributes = RequestContextHolder.currentRequestAttributes();
    // 根據名稱和作用域獲取物件
    Object scopedObject = attributes.getAttribute(name, getScope());
    if (scopedObject == null) {
        // 使用物件工廠建立物件
        scopedObject = objectFactory.getObject();
        // 將建立的物件設定到請求屬性中
        attributes.setAttribute(name, scopedObject, getScope());
        // 再次獲取物件,進行隱式會話屬性更新
        // 並允許進行潛在的裝飾
        Object retrievedObject = attributes.getAttribute(name, getScope());
        if (retrievedObject!= null) {
            // 只使用再次獲取到的物件(如果仍然存在,這是預期情況)
            // 如果它同時消失了,我們則返回本地建立的例項
            scopedObject = retrievedObject;
        }
    }
    // 返回獲取到的物件
    return scopedObject;
}
  • remove 方法也是差不多的。藉助工具類RequestContextHolder將快取在ThreadLocal中的物件移除。
/**
 * 移除指定名稱的物件,如果當前請求屬性中存在該物件,則將其從請求屬性中移除並返回該物件;否則返回 null
 */
public Object remove(String name) {
    // 獲取當前請求的屬性
    RequestAttributes attributes = RequestContextHolder.currentRequestAttributes();
    // 根據名稱和作用域獲取物件
    Object scopedObject = attributes.getAttribute(name, getScope());
    if (scopedObject!= null) {
        // 將該物件從請求屬性中移除
        attributes.removeAttribute(name, getScope());
        // 返回移除的物件
        return scopedObject;
    } else {
        // 返回 null
        return null;
    }
}

註冊自定義作用域

註冊作用域,需要透過BeanFactory的registerScope方法進行註冊。

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.stereotype.Component;

@Component
public class ScopeBeanFactoryPostProcessor implements BeanFactoryPostProcessor {
    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        beanFactory.registerScope(CustomScope.CUSTOM_SCOPE_NAME, new CustomScope());
    }
}

驗證自定義作用域效果

將Bean註冊到Spring容器中,並使用自定義作用域。

public class MyScopeBean {
    private String id;

    public MyScopeBean() {
        this.id = UUID.randomUUID().toString();
    }

    public String getId() {
        return id;
    }
}

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;

@Configuration
public class AppScopeConfig {
    @Bean
    @Scope(CustomScope.CUSTOM_SCOPE_NAME)
    public MyScopeBean myBean() {
        return new MyScopeBean();
    }
}

新建一個Controller,訪問/customScope介面,返回自定義作用域的Bean例項。

@RestController
public class CustomScopeController {
    @Autowired
    private ApplicationContext context;
    @GetMapping("/customScope")
    public String customScope() {
        MyScopeBean prototypeBean = context.getBean(MyScopeBean.class);
        return "Prototype ID: " + prototypeBean.getId();
    }
}

訪問的結果輸出如下:

Session ID: bd22d310-29e5-4004-8555-9678c08275f0
Session ID: bd22d310-29e5-4004-8555-9678c08275f0
Session ID: bd22d310-29e5-4004-8555-9678c08275f0

因為物件全域性快取到了一個MapscopedObjects,所以可以看到這個自定義作用域效果和單例模式基本一致的。

Bean Scope 的分類

Scope 描述
singleton (Default) 將單個 bean 定義作用域限定為 Spring IoC 容器中的單個物件例項。
prototype 將單個 bean 定義作用域限定為任意數量的物件例項。
request 將單個 bean 定義作用域限定為單個 HTTP 請求的生命週期。也就是說,每個 HTTP 請求都有自己的一個基於單個 bean 定義建立的 bean 例項。僅在 Web-aware Spring ApplicationContext 上下文中有效。
session 將單個 bean 定義作用域限定為 HTTP Session 的生命週期。僅在 Web-aware Spring ApplicationContext 上下文中有效。
application 將單個 bean 定義作用域限定為 ServletContext 的生命週期。僅在 Web-aware Spring ApplicationContext 上下文中有效。
websocket 將單個 bean 定義作用域限定為 WebSocket 的生命週期。僅在 Web-aware Spring ApplicationContext 上下文中有效。

其中singletonprototype是比較常用的資料。

Bean Scope 的使用

可以透過在Spring的配置檔案(如XML配置檔案或Java註解)中指定@Scope註解或<bean>元素的scope屬性來定義Bean的Scope。

其中@Scope註解可以是自定義的值或者如下常量:

  • ConfigurableBeanFactory.SCOPE_PROTOTYPE
  • ConfigurableBeanFactory.SCOPE_SINGLETON
  • org.springframework.web.context.WebApplicationContext.SCOPE_REQUEST
  • org.springframework.web.context.WebApplicationContext.SCOPE_SESSION

其中ConfigurableBeanFactory.SCOPE_PROTOTYPE是預設值。

例如:

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;

@Component
@Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
public class MyPrototypeBean {
    // Bean內容
}

或者使用XML配置:

<bean id="myBean" class="com.example.MyBean" scope="prototype">
    <!-- Bean的其他配置 -->
</bean>

選擇合適的Bean Scope取決於應用程式的需求。

為什麼設計 Bean Scope

Spring 框架設計 Bean 作用域(Scope)的原因主要是為了提供靈活性和資源管理能力,以適應不同應用場景的需求。

不同的 Bean 作用域會影響 Bean 的生命週期、建立方式和在容器中的共享程度,從而影響應用的效能、記憶體佔用和併發處理能力。

以下是 Spring 提供 Bean 作用域設計背後的主要原因:

  • 資源最佳化:透過作用域設計,Spring 能夠根據業務場景高效管理 Bean 的建立與銷燬。例如,單例(Singleton)模式可以減少頻繁建立例項的開銷,原型(Prototype)模式則確保每次請求都得到新的例項,避免了共享狀態問題。
  • 併發處理:對於 Web 應用,特定作用域如請求(Request)和會話(Session)使得每個使用者請求或會話都有獨立的 Bean 例項,解決了併發使用者資料隔離的問題,提高了應用的執行緒安全。
  • 生命週期管理:不同的作用域允許開發者控制 Bean 的生命週期,比如透過自定義作用域實現複雜的生命週期管理邏輯。Spring 容器在 Bean 的建立、初始化、銷燬等關鍵時刻呼叫生命週期回撥方法,增加了靈活性。
  • 可測試性:透過作用域的設計,特別是原型模式,可以更容易地建立獨立的測試環境,因為每次測試都能得到全新的例項,減少了測試間狀態干擾。
  • 擴充套件性:Spring 允許開發者自定義作用域,為特定的業務需求或架構設計提供定製化的 Bean 管理方式,增強了框架的擴充套件性和適應性。
  • 記憶體管理:合理使用作用域可以減少記憶體消耗,例如,原型模式避免了單例 Bean 累積大量狀態導致的記憶體洩漏風險,而請求作用域則確保請求結束後自動清理資源。

單例 bean 裡面注入了原型 bean

當單例 Bean 中注入原型(Prototype)Bean 時,會出現一個問題:

  • 單例 Bean 在整個應用生命週期中只建立一次。
  • 而原型 Bean 本應每次請求時建立新例項。
  • 但直接注入到單例 Bean 中時,實際上只會注入一次原型 Bean 的例項。
  • 後續對該原型 Bean 的使用都將複用首次注入的同一個例項,這可能並不符合預期。

以下demo可以復現這種情況。

SpringBean的配置:

import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;

@Configuration
public class FaultAppConfig {
    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    public PrototypeInjectBean prototypeInjectBean() {
        return new PrototypeInjectBean();
    }
}

單例SpringBean:

import java.util.UUID;

public class PrototypeInjectBean {
    private String id;

    public PrototypeInjectBean() {
        this.id = UUID.randomUUID().toString();
    }

    public String getId() {
        return id;
    }
}

測試程式碼如下:

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Lookup;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Scope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
public class PrototypeFaultController {

    final private PrototypeInjectBean prototypeInjectBean;

    protected PrototypeFaultController(PrototypeInjectBean prototypeInjectBean) {
        this.prototypeInjectBean = prototypeInjectBean;
    }

    /**
     * 原型作用域失效,每次返回同一個id
     * @return
     */
    @GetMapping("/prototypeDemo1")
    public String prototypeDemo1() {
        return "Prototype ID: " + prototypeInjectBean.getId();
    }

}

在不重啟應用或者垃圾回收的情況下,訪問介面 /prototypeDemo1 原型 Bean 的id值始終是相同的。

Prototype ID: 11893ed8-608c-452b-b2e6-82bc70b5cf97
Prototype ID: 11893ed8-608c-452b-b2e6-82bc70b5cf97
Prototype ID: 11893ed8-608c-452b-b2e6-82bc70b5cf97

那這種常用的使用場景遇到了該怎麼解決呢?別急,Spring早已經給出了幾種解決辦法。

透過完善上面的測試程式碼給出3中解決方法。

修改完善後的程式碼如下:


import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Lookup;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Scope;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
public abstract class PrototypeFaultController {

    @Autowired
    private ApplicationContext context;
    @Autowired
    private ObjectProvider<PrototypeInjectBean> prototypeBeanProvider;

    final private PrototypeInjectBean prototypeInjectBean;

    protected PrototypeFaultController(PrototypeInjectBean prototypeInjectBean) {
        this.prototypeInjectBean = prototypeInjectBean;
    }

    /**
     * 原型作用域失效,每次返回同一個id
     * @return
     */
    @GetMapping("/prototypeDemo1")
    public String prototypeDemo1() {
        return "Prototype ID: " + prototypeInjectBean.getId();
    }

    /**
     * 使用例項工廠方法注入獲取原型Bean,每次返回不同id
     * @return
     */
    @GetMapping("/prototypeDemo2")
    public String prototypeDemo2() {
        PrototypeInjectBean prototypeBean = context.getBean(PrototypeInjectBean.class);
        return "Prototype ID: " + prototypeBean.getId();
    }

    /**
     * Spring 提供了`ObjectProvider`介面(繼承自`Provider`介面),它允許延遲查詢和例項化 Bean,非常適合在單例 Bean 中按需獲取原型 Bean 的新例項。
     * @return
     */
    @GetMapping("/prototypeDemo4")
    public String prototypeDemo4() {
        return "Prototype ID: " + prototypeBeanProvider.getObject().getId();
    }

    /**
     * 使用`@Lookup`註解獲取原型Bean,每次返回不同id
     * @return
     */
    @GetMapping("/prototypeDemo5")
    public String prototypeDemo5() {
        return "Prototype ID: " + getPrototypeBean().getId();
    }
    @Lookup
    public abstract PrototypeInjectBean getPrototypeBean();

}
  • 解決辦法1: Spring 提供了ObjectProvider介面(繼承自Provider介面),它允許延遲查詢和例項化 Bean,非常適合在單例 Bean 中按需獲取原型 Bean 的新例項。

透過訪問介面/prototypeDemo4可以發現每次返回的id值是不同的。

  • 解決辦法2: 可以透過定義一個工廠方法來建立原型 Bean 的例項,然後在單例 Bean 中注入這個工廠方法,每次需要時呼叫工廠方法獲取新例項。

透過訪問介面/prototypeDemo2可以發現每次返回的id值是不同的。

  • 解決辦法3: 透過@Lookup註解,@Lookup註解是Spring框架中的一個特殊註解,用於在Spring容器中查詢另一個Bean,並將其注入到當前Bean中。注意使用@Lookup註解的方法必須是抽象的(abstract)。

透過訪問介面/prototypeDemo5可以發現每次返回的id值是不同的。

關於作者

來自一線全棧程式設計師nine的探索與實踐,持續迭代中。

歡迎關注或者點個小紅心~

相關文章