Spring Bean Scopes: Singleton con Prototypes
Introducción
Esta es la segunda publicación en la seria acerca de Spring Bean Scopes. En el tutorial anterior vimos que cuando se inyectaban Beans de ámbito Prototype dentro de un Singleton aparecían algunas trabas. El principal problema es que las dependencias Autowired se inyectan en el Singleton cuando este se instancia (evento que ocurre una sola vez) por lo que aunque el Bean inyectado se haya declarado como Prototype, en realidad se comportará como un Singleton.
El siguiente código resalta este comportamiento:
1@Test
2public void getAutowiredSample_na_shouldBeSameInstance() {
3 // Given
4 ClassWithSamplePrototypes singleton1 = applicationContext.getBean(ClassWithSamplePrototypes.class);
5 ClassWithSamplePrototypes singleton2 = applicationContext.getBean(ClassWithSamplePrototypes.class);
6
7 // When
8 Sample autowiredSample1 = singleton1.getAutowiredSample();
9 Sample autowiredSample2 = singleton2.getAutowiredSample();
10
11 // Then
12 assertEquals(singleton1, singleton2);
13 assertEquals(autowiredSample1, autowiredSample2);
14 assertEquals(autowiredSample1.getUuid(), autowiredSample2.getUuid());
15}
En el ejemplo anterior, a pesar de que el objeto devuelto por el método getAutowiredSample
se ha declarado como un Bean de ámbito Prototype, la instancia devuelta en las dos peticiones es la misma.
Para resolver este problema disponemos de diversas alternativas.
Anotación @Lookup
La anotación @Lookup
nos permite anotar métodos tipo getter para un Bean específico. El contenedor de Spring se encargará de anular este método en tiempo de ejecución y redirigirlo al BeanFactory para realizar una llamada estándar al método getBean
, devolviendo el Bean que este método devolvería (Singleton, Prototype, Request, Session…). En este caso, al haber definido el Bean con ámito Prototype, para cada petición se devolverá una nueva instancia del Bean.
El siguiente fragmento de código muestra como emplear la anotación @Lookup
:
1@Lookup
2public Sample getSampleUsingLookup() {
3 return null;
4}
El principal problema con esta alternativa es que sólo funcionara con Beans de ámbito Singleton que se hayan definido empleando anotaciones de tipo @Component
y que se hayan instanciado mediante escaneo de componentes (@ComponentScan
) o autodetección. No funcionará con Beans que se hayan definido con anotaciones @Bean
.
El siguiente test muestra el comportamiento de la anotación @Lookup
en un singleton definido con una anotación @Component
:
1@Test
2public void getSampleUsingLookupInComponent_na_shouldBeDifferent() {
3 // Given
4 SingletonWithLookupPrototype singleton1 = applicationContext.getBean(SingletonWithLookupPrototype.class);
5 SingletonWithLookupPrototype singleton2 = applicationContext.getBean(SingletonWithLookupPrototype.class);
6
7 // When
8 Sample lookedupSample1 = singleton1.getSampleUsingLookup();
9 Sample lookedupSample2 = singleton2.getSampleUsingLookup();
10
11 // Then
12 assertEquals(singleton1, singleton2);
13 assertNotNull(singleton1);
14 assertNotNull(singleton2);
15 assertNotEquals(lookedupSample1, lookedupSample2);
16 assertNotEquals(lookedupSample1.getUuid(), lookedupSample2.getUuid());
17}
Tal como se muestra en el fragmento anterior, este es el comportamiento que estamos esperando. Hay dos objetos creados a partir de un Singleton Bean que en realidad son la misma instancia a pesar de haberse pedido dos veces (comportamiento de los Singleton). Cuando se hacen peticiones al método getSampleUsingLookup()
se devuelven instancias diferentes de la clase Sample
ya que se ha definido como un Bean de ámbito Prototype.
Tal como ya se ha comentado, hay un problema con esta técnica y es que sólo funcionará con Beans de ámbito Singleton que se hayan definido con una anotación de tipo @Component
. Si el Bean se declara utilizando un procedimiento como el siguiente:
1@Bean
2@Scope(SCOPE_SINGLETON)
3public ClassWithSamplePrototypes singletonWithSamplePrototypes(Sample autowiredSample, ObjectFactory<Sample> sampleObjectFactory) {
4 return new ClassWithSamplePrototypes(autowiredSample, sampleObjectFactory);
5}
El test fallará ya que el método getSampleUsingLoopup()
no se anulará por el contenedor de Spring y por lo tanto devolverá siempre null
. Podemos comprobar este comportamiento con el test anterior aplicando algunas modificaciones:
1@Test
2public void getSampleUsingLookupInRegularBean_na_shouldBeNull() {
3 // Given
4 ClassWithSamplePrototypes singleton1 = applicationContext.getBean(ClassWithSamplePrototypes.class);
5 ClassWithSamplePrototypes singleton2 = applicationContext.getBean(ClassWithSamplePrototypes.class);
6
7 // When
8 Sample lookedupSample1 = singleton1.getSampleUsingLookup();
9 Sample lookedupSample2 = singleton2.getSampleUsingLookup();
10
11 // Then
12 assertEquals(singleton1, singleton2);
13 assertNull(lookedupSample1);
14 assertNull(lookedupSample2);
15}
Utilizando ApplicationContext
Otro sistema para sobreponerse al problema de la inyección de beans con ámbitos es inyectar ApplicationContext
en el el Bean de ámbito Singleton. Siempre que se necesite una instancia del Bean de ámbito Prototype, el Singleton hará una petición a la instancia de ApplicationContext
.
El procedimiento para el Singleton sería el siguiente:
1/** ... **/
2public class ClassWithSamplePrototypes implements ApplicationContextAware {
3/** ... **/
4 private ApplicationContext applicationContext;
5
6 @Override
7 public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
8 this.applicationContext = applicationContext;
9 }
10/** ... **/
11 public Sample getSampleUsingApplicationContext() {
12 return applicationContext.getBean(Sample.class);
13 }
14/** ... **/
15}
Podemos verificar el comportamiento esperado con el siguiente test:
1@Test
2public void getSampleUsingApplicationContext_na_shouldBeDifferent() {
3 // Given
4 ClassWithSamplePrototypes singleton1 = applicationContext.getBean(ClassWithSamplePrototypes.class);
5 ClassWithSamplePrototypes singleton2 = applicationContext.getBean(ClassWithSamplePrototypes.class);
6
7 // When
8 Sample applicationContextSample1 = singleton1.getSampleUsingApplicationContext();
9 Sample applicationContextSample2 = singleton2.getSampleUsingApplicationContext();
10
11 // Then
12 assertEquals(singleton1, singleton2);
13 assertNotNull(singleton1);
14 assertNotNull(singleton2);
15 assertNotEquals(applicationContextSample1, applicationContextSample2);
16 assertNotEquals(applicationContextSample1.getUuid(), applicationContextSample2.getUuid());
17}
Aunque esta técnica es efectiva, claramente vulnera los principios de SOLID al contradecir el principio de la inversión de control. Las dependencias se solicitan directamente al contenedor de Spring en lugar de inyectarse en la clase por lo que el código queda completamente acoplado a Spring (e.g. no hay forma de lanzar el test anterior sin emplear SpringJUnit4ClassRunner
).
Interfaz ObjectFactory de Spring
Por último, aunque en mi opinión, la forma más limpia de resolver el problema del Bean inyectado con ámbito es utilizar la interfaz ObjectFactory
de Spring. Mediante este procedimiento, en lugar de inyectar el Bean en sí mismo, lo que hacemos es inyectar una factoría para el Bean empleando esta interfaz.
1/** ... **/
2public class ClassWithSamplePrototypes implements ApplicationContextAware {
3/** ... **/
4 private final ObjectFactory<Sample> sampleObjectFactory;
5 @Autowired
6 public ClassWithSamplePrototypes(Sample autowiredSample, ObjectFactory<Sample> sampleObjectFactory) {
7 this.autowiredSample = autowiredSample;
8 this.sampleObjectFactory = sampleObjectFactory;
9 }
10/** ... **/
11 public Sample getSampleUsingObjectFactory() {
12 return sampleObjectFactory.getObject();
13 }
14/** ... **/
15}
En lugar de que el Bean se inyecte durante la instanciación de la clase con ámbito Singleton, se inyecta una instancia de la interfaz ObjectFactory
para un tipo concreto de Bean. Dependiendo del tipo y ámbito configurado para ObjectFactory
, su método getObject
devolverá una instancia del Bean de acuerdo con dicha configuración.
El siguiente test muestra el comportamiento esperado:
1@Test
2public void getSampleUsingObjectFactory_na_shouldBeDifferent() {
3 // Given
4 ClassWithSamplePrototypes singleton1 = applicationContext.getBean(ClassWithSamplePrototypes.class);
5 ClassWithSamplePrototypes singleton2 = applicationContext.getBean(ClassWithSamplePrototypes.class);
6
7 // When
8 Sample objectFactorySample1 = singleton1.getSampleUsingObjectFactory();
9 Sample objectFactorySample2 = singleton2.getSampleUsingObjectFactory();
10
11 // Then
12 assertEquals(singleton1, singleton2);
13 assertNotNull(singleton1);
14 assertNotNull(singleton2);
15 assertNotEquals(objectFactorySample1, objectFactorySample2);
16 assertNotEquals(objectFactorySample1.getUuid(), objectFactorySample2.getUuid());
17}
El empleo de ObjectFactory
no violará ninguno de los principios de la programación orientada a objetos ya que siempre se puede inyectar una implementación específica de ObjectFactory
sin depender de Spring ni ningún otro contenedor:
1new ClassWithSamplePrototypes(new Sample(),
2 () -> new Sample());
Conclusión
Esta publicación muestra como resolver el problema de los Beans inyectados con ámbito que ocurre cuando se inyecta un Bean con ámbito Prototype dentro de un Bean de ámbito Singleton. Hemos visto las diferentes técnicas para resolver dicho problema y las ventajas y desventajas de cada una de ellas.
El código fuente completo de este artículo puede encontrarse en GitHub.