Spring--Bean的作用域

316 阅读12分钟

参考:Bean Scopes :: Spring Framework

在Spring框架中,Bean是指由Spring IoC容器实例化、组装和管理的对象。

后面的4个scope只在具有 Web 支持的 SpringApplicationContext中有效。

ScopeDescription
singleton(默认)单个bean定义在每个Spring IoC容器中只有一个实例
prototype单个bean定义可以有多个实例
request将单个 bean 定义的作用域限定为单个 HTTP 请求的生命周期。每次HTTP请求都会根据bean定义创建自己的bean实例。
session将单个 bean 定义的作用域限定为HTTPsession的生命周期
application将单个 bean 定义的作用域限定为ServletContext的生命周期
websocket将单个 bean 定义的作用域限定为WebSocket的生命周期

Spring提供了线程作用域,但默认情况下未注册。更多信息请查阅SimpleThreadScope

1. singleton作用域

当你定义一个singleton作用域的bean时,Spring IoC容器只会根据该定义创建一个实例,可称作单例。单例会被存储在缓存中,后续的请求和引用将直接从缓存中获取。

SpringIoC容器中的singleton作用域.png

Spring中的单例是指每个IoC容器中的每个bean只有一个实例。singleton作用域是Spring中默认的作用域,下面的代码中,两个定义是等价的:

@Bean
public DefaultAccountService defaultAccountService() {
    return new DefaultAccountService();
}

@Bean
@Scope("singleton")
public DefaultAccountService defaultAccountService2() {
    return new DefaultAccountService();
}

2. prototype作用域

拥有prototype作用域的bean会在每次被需要时创建一个新的实例。一般来说,有状态的bean使用prototype,无状态的bean使用singleton(状态可以指某个对象的属性和数据成员的集合)。

作用域为prototype意味着你可以使用一个bean创建多个实例,每个实例中保存不同的数据,它们之间不会互相影响,比如每个实例对应一个用户的数据。

SpringIoC容器中的prototype作用域.png

@Bean
@Scope("prototype")
public DefaultAccountService defaultAccountService() {
    return new DefaultAccountService();
}

Spring并不会管理prototype bean的完整生命周期。容器会初始化、配置,或者以其他方式组装prototype对象,将其交给客户端,而不会记录创建的实例。初始化阶段的生命周期回调方法会被调用;但使用prototype时,生命周期中销毁时的回调方法不会被调用。所以客户端代码必须对此类对象进行资源释放。 要想让Spring容器释放prototype bean的资源,请配置 bean post-processor,它会引用需要被清理的bean。

从某种意义上讲,Spring容器对于prototype bean的作用是替代Java中的new关键字。在初始化之后,这个bean实例的所有生命周期都需要由客户端来处理。(关于Spring容器中Bean生命周期的详细信息,请查阅Lifecycle Callbacks

3. 单例中注入prototype bean

singleton bean中注入prototype bean只会发生一次,即在singleton bean初始化的时候,所以singleton bean只会拥有prototype bean的一个实例。

如果你想在运行时使用prototype bean的多个不同实例,需要用到方法注入

可以参考:Spring--跨作用域的依赖注入方案:方法注入

4. Web环境下的作用域

只有当你使用了支持web的SpringApplicationContext实现(比如XmlWebApplicationContext),才能使用request,session,applicationwebsocket作用域。如果使用常规的Spring IoC容器,比如ClassPathXmlApplicationContext,程序会因为你了使用未知作用域而抛出IllegalStateException

4.1 初始化Web配置

为了使用request,session,applicationwebsocket作用域,你需要在定义bean之前进行一些初始化配置。如何进行初始化配置取决于你的Servlet环境。

如果使用了Spring Web MVC,你并不需要进行任何配置。SpringMVC中的请求将会由SpringDispatcherServlet处理,而DispatcherServlet已经进行了合适的配置。

如果你使用的Servlet web容器中的请求不是由DispatcherServlet处理的(比如JSF),你需要注册org.springframework.web.context.request.RequestContextListener ServletRequestListener。你可以通过WebApplicationListener接口来实现,也可以在web.xml中进行配置:

<web-app>
	...
	<listener>
		<listener-class>
			org.springframework.web.context.request.RequestContextListener
		</listener-class>
	</listener>
	...
</web-app>

如果listener的配置存在问题,也可以使用SpringRequestContextFilter。请根据需求对filter映射进行合适的修改:

<web-app>
	...
	<filter>
		<filter-name>requestContextFilter</filter-name>
		<filter-class>org.springframework.web.filter.RequestContextFilter</filter-class>
	</filter>
	<filter-mapping>
		<filter-name>requestContextFilter</filter-name>
		<url-pattern>/*</url-pattern>
	</filter-mapping>
	...
</web-app>

DispatcherServlet, RequestContextListenerRequestContextFilter的作用是一样的,即将 HTTP 请求对象绑定到为该请求提供服务的Thread

4.2 Request作用域

假设XML配置中有如下bean定义:

<bean id="loginAction" class="com.something.LoginAction" scope="request"/>

Spring容器会为每一个HTTP请求创建一个新的LoginAction实例,当请求处理完之后,bean也会被丢弃。

可以使用@RequstScope来指定作用域为request

@RequestScope
@Component
public class LoginAction {
	// ...
}

以下是@RequestScope的定义:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Scope("request")
public @interface RequestScope {
    @AliasFor(
        annotation = Scope.class
    )
    ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;
}

4.3 Session作用域

假设XML配置中有如下bean定义:

<bean id="userPreferences" class="com.something.UserPreferences" scope="session"/>

Spring容器会为每一个HTTPSession创建一个UserPreferences实例,当HTTPSession结束的时候,bean也会被丢弃。

可以使用@SessionScope来指定作用域为session

@SessionScope
@Component
public class UserPreferences {
	// ...
}

HTTP request和session的关系:每次请求是无状态的,session可以用于在多个请求之间保存状态。session是基于cookie实现的,session由服务端创建并存储,客户端会记录session的索引信息(如Session ID),之后的请求将携带cookie(比如SESSION=ce0f67ee-5240-4e02-a240-b17ef35b92c8)访问服务器,那么携带这个cookie的请求就可以看作是一个会话请求。

4.4 Application作用域

假设XML配置中有如下bean定义:

<bean id="appPreferences" class="com.something.AppPreferences" scope="application"/>

Spring容器会为整个Web应用创建一个新的AppPreferences,当然,只会创建一次。appPreferences的作用域是ServletContext,它将成ServletContext的一个属性。这和singleton类似,但有两点不同:它在每个ServletContext中有一个实例,而不是每个SpringApplicationContext,后者在一个web应用中可能存在多个;它是公开的,是ServletContext中的一个属性。

可以使用@ApplicationScope来指定作用域为application

@ApplicationScope
@Component
public class AppPreferences {
	// ...
}

4.5 WebSocket作用域

Websocket作用域和Websocket session的生命周期相关联,适用于STOMP over WebSocket应用程序。

作用域为websocket的bean可以被注入到controller中,或者任意注册在clientInboundChannel的channel拦截器中。这个bean通常是一个单例,会比任何Websocket session都要活的久。所以需要使用作用域代理。

假设有两个bean,controller和userInfo,它们的作用域分别为singletonwebsocket(使用了代理)。现在将userInfo注入到controller中,实际上,注入到controller中的是userInfo的代理对象,只会在controller初始化的时候被创建并注入一次。

之后,controller处理session请求的时候,当使用到userInfo中的方法时,Spring会通过CGLIB动态创建新的userInfo实例,通过代理去访问这个实例。所以,每个session都对应一个不同的userInfo实例,这个实例随着session的创建而创建,session结束之后也会随之销毁。

为了验证,我进行了简单的测试。

下面的案例可能不太合理(service层中的bean一般都是单例),这里只是为了测试。

现有以下配置:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/hello-websocket");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.setApplicationDestinationPrefixes("/app");
        config.enableSimpleBroker("/topic", "/queue");
    }
}

每当有客户端通过session连接向/topic/hello发送消息时,请求将交由 GreetingController中的greeting()方法进行处理:

@Controller
public class GreetingController {

    private CommonService commonService;

    @Autowired
    void setCommonService(CommonService commonService) {
        this.commonService = commonService;
    }


    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(HelloMessage message) throws Exception {
        commonService.sayHello();
        return new Greeting("Hello, " + message.getName() + "!");
    }
    
}

CommonService的作用域设置为websocket,sayHello()方法用于输出当前session中的实例信息(每个session对应一个实例):

@Slf4j
@Service
@Scope(scopeName = "websocket", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class CommonService {

    public void sayHello() {
        log.info("I am " + this.getClass().getName() + "@" + Integer.toHexString(hashCode()));
    }
}

如果不启用代理,运行时会报错:

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'greetingController': Unsatisfied dependency expressed through method 'setCommonService' parameter 0: Error creating bean with name 'commonService': Scope 'websocket' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton

我创建了3个session,其中红色框中的信息是由同一个session输出的。这意味着,不同session中的bean实例是不一样的,同一个session中使用的是同一个实例。

image-20230815212634554

接着,我修改了controller,在greeting()方法中输出commonService的类信息:

@Slf4j
@Controller
public class GreetingController {

    private CommonService commonService;

    @Autowired
    void setCommonService(CommonService commonService) {
        this.commonService = commonService;
    }


    @MessageMapping("/hello")
    @SendTo("/topic/greetings")
    public Greeting greeting(HelloMessage message) throws Exception {
        Thread.sleep(1000);
        log.info(commonService.getClass().getName() + "@" + Integer.toHexString(commonService.hashCode()));
        return new Greeting("Hello, " + message.getName() + "!");
    }

}

commonService只会在GreetingController初始化的时候被注入一次,而被注入的commonService实际上是一个代理对象。针对于每一个websocket session连接,都会创建一个新的CommonService实例,代理对象会动态获取当前session的CommonService实例。

我从多个用户session中访问/topic/greetings,输出的结果都一样,这说明注入到controller中的commonService是一个代理对象,而且是一个单例:

image-20230815220521236

4.6 将Scoped Bean作为依赖

当你将一个作用域较短的bean注入到一个作用域较长的bean时,你可能需要使用AOP代理来替换掉scoped bean。

当你在作用域为prototype的bean中声明<aop:scoped-proxy>时,会导致每次调用代理对象方法的时候都创建一个新的实例。

你可以将你的注入点声明为ObjectFactory<MyTargetBean>,每当需要当前实例时,通过getObject()方法获取。这样就不需要单独存储实例。

你也可以声明ObjectProvider<MyTargetBean>,它提供了几个额外的方法,包括getIfAvailablegetIfUnique

JSR-330中提供了Provider类,对应的声明为Provider<MyTargetBean>,通过调用get()方法可以获取当前实例。

接下来,我将通过一个具体的使用场景,来介绍为什么需要使用代理。

假设有以下配置,userService中需要注入userPreferences(以bean的id代指bean),userPreferences的作用域为sessionuserService的作用域为singleton

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:aop="http://www.springframework.org/schema/aop"
	xsi:schemaLocation="http://www.springframework.org/schema/beans
		https://www.springframework.org/schema/beans/spring-beans.xsd
		http://www.springframework.org/schema/aop
		https://www.springframework.org/schema/aop/spring-aop.xsd">

	<!-- an HTTP Session-scoped bean exposed as a proxy -->
	<bean id="userPreferences" class="com.something.UserPreferences" scope="session">
		<!-- instructs the container to proxy the surrounding bean -->
		<aop:scoped-proxy/>
	</bean>

	<!-- a singleton-scoped bean injected with a proxy to the above bean -->
	<bean id="userService" class="com.something.SimpleUserService">
		<!-- a reference to the proxied userPreferences bean -->
		<property name="userPreferences" ref="userPreferences"/>
	</bean>
</beans>

因为作用域为singletonuserService只会发生一次依赖注入,即它初始化的时候,所以只会有一个userPreferences实例注入到userService中。

现在,假设有多个session,每个session对应一个用户的请求。userService在处理第一个session的时候,userPreferences中将会保存当前用户的数据,那么当它处理第二个session时,就可能出现以下两种情况:1. userPreferences中记录的是上一个session的状态;2. userPreferences随着上一个session的结束被销毁了(受到作用域的限制),这显然不合理。

为了解决这个问题,我们采用动态代理的方式。真正的UserPreferences实例并不会被注入到userService中,取而代之的是它的动态代理。这个代理是一个单例,只会被注入一次,当userService调用它的方法时,它就会从当前的session中获取到真正的UserPreferences实例,并调用这个实例对应的方法,而userService是无法察觉到代理的。

选择代理类型

当Spring容器为<aop:scoped-proxy>标记的bean创建代理时,会创建基于CGLIB的类代理。

CGLIB代理只会拦截public方法的调用,非public方法不会代理到真正的实例。

你可以通过将<aop:scoped-proxy/>上的proxy-target-class属性设置为false,让Spring容器创建标准JDK中基于接口的代理。使用基于接口的代理意味着你无需在类路径中添加额外的依赖(jdk提供了支持)。但是,拥有作用域的bean在类定义时必须实现至少一个接口,需要注入这个bean的其他bean必须通过它的一个接口来引用它。

DefaultUserPreferences实现了UserPreferences接口。如果某个bean需要注入DefaultUserPreferences实例,需要通过它实现的接口UserPreferences来引用它:

public interface UserPreferences {

    String getPreference(String username);
}
public class DefaultUserPreferences implements UserPreferences{
    @Override
    public String getPreference(String username) {
        return "default preference";
    }
}

UserManager中需要注入DefaultUserPreferences实例。userPreferences字段的类型只能是接口类型,比如UserPreferences,如果改为实现类的类型,会报错。因为代理对象会实现这个接口然后注入到UserManager中,如果是实现类的类型,会导致类型不匹配。

public class UserManager {

    private UserPreferences userPreferences;

    public void setUserPreferences(UserPreferences userPreferences) {
        this.userPreferences = userPreferences;
    }

    public UserPreferences getUserPreferences() {
        return userPreferences;
    }
}

通过XML进行配置,userPreferences的作用域为session:

<!-- DefaultUserPreferences implements the UserPreferences interface -->
    <bean id="userPreferences" class="com.weedien.ioccfg.scope.DefaultUserPreferences" scope="session">
        <aop:scoped-proxy proxy-target-class="false"/>
    </bean>

    <bean id="userManager" class="com.weedien.ioccfg.scope.UserManager">
        <property name="userPreferences" ref="userPreferences"/>
    </bean>

在这个案例中,userManager中注入的bean实际上是一个代理对象,这个代理对象为jdk.proxy2.$Proxy60@668456d8,它实现了UserPreferences接口。代理对象唯一且只会被注入一次,通过代理对象访问真正的UserPreferences实例,每个session都有一个UserPreferences实例。

5. 自定义作用域

你可以定义自己的作用域,甚至可以重新定义已有的作用域(singletonprototype除外)。

5.1 创建自定义作用域

自定义的作用域需要实现org.springframework.beans.factory.config.Scope接口。

Scope接口有4个方法,可以从作用域中获取对象、移除对象和销毁对象。

从底层作用域中获取对象:

Object get(String name, ObjectFactory<?> objectFactory)

从底层作用域中移除对象:

Object remove(String name)

注册一个回调方法,当作用域结束或指定对象销毁时调用:

void registerDestructionCallback(String name, Runnable destructionCallback)

从底层作用域中获取对话标识符:

String getConversationId()

每个作用域的标识符都是不一样的,比如在session作用域的实现中,getConversationId()方法将返回session标识符。

5.2 使用自定义作用域

下面将以SimpleThreadScope为例,介绍如何使用自定义scope。SimpleThreadScope定义在org.springframework.context.support中,但默认情况下不启用。

在使用自定义的scope之前,需要在Spring容器中进行注册,注册需要用到以下方法:

void registerScope(String scopeName, Scope scope);

该方法定义在ConfigurableBeanFactory接口中,Spring附带的大部分ApplicationContext实现中都包含这个接口。

下面是一个可行的配置,用来注册自定义scope:

@Bean
public BeanFactory registerScope(ConfigurableBeanFactory beanFactory) {
    org.springframework.beans.factory.config.Scope threadScope = new SimpleThreadScope();
    beanFactory.registerScope("thread", threadScope);
    return beanFactory;
}

也可以通过CustomScopeConfigurer来注册,这与上面的配置是等效的:

@Bean
public CustomScopeConfigurer customScopeConfigurer() {
    CustomScopeConfigurer customScopeConfigurer = new CustomScopeConfigurer();
    customScopeConfigurer.addScope("thread", new SimpleThreadScope());
    return customScopeConfigurer;
}

等价的XML配置如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

	<bean class="org.springframework.beans.factory.config.CustomScopeConfigurer">
		<property name="scopes">
			<map>
				<entry key="thread">
					<bean class="org.springframework.context.support.SimpleThreadScope"/>
				</entry>
			</map>
		</property>
	</bean>
    
</beans>

接下来,你可以使用自定义的scope来定义bean:

@Bean
@Scope("thread")
public MyValueCalculator myValueCalculator() {
    return new MyValueCalculator();
}