Introduction
While working on a Spring Boot based Faces project having memory issues, one of the solutions is to migrate the existing @Scope("view")
beans and/or @SessionScope
beans disguised as view scoped beans to use OmniFaces @ViewScoped
instead, so that they get destroyed immediately when the webpage unloads and hereby also immediately frees up memory occuppied by the managed bean state as well as the Faces view state instead of basically having them to accumulate during the rest of the HTTP session.
Install JoinFaces in any case
JoinFaces is a great library for easily integrating Jakarta Faces in a Spring Boot project. However it unfortunately only supports using the standard Faces view scope @jakarta.faces.view.ViewScoped
on a Spring managed bean. On the other hand, though, JoinFaces is already kind of the standard approach in order to effortlessly be able to use Jakarta Faces, Jakarta CDI and OmniFaces (and PrimeFaces, of course) in a Spring Boot project. It really takes away a lot of potential boilerplate in order to properly configure them in a Spring Boot based project. It's a matter of adding the following dependency (in Maven flavor) when you're already on Spring Boot 3.x or newer:
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>omnifaces-spring-boot-starter</artifactId>
<version>5.x.x</version>
</dependency>
Or when you're still on Spring Boot 2.x:
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>omnifaces3-spring-boot-starter</artifactId>
<version>4.x.x</version>
</dependency>
Getting OmniFaces @ViewScoped to work
One way would be to create a custom Spring Scope
for this and port all the existing logic behind OmniFaces @ViewScoped
over there, including the auto-inclusion of the unload script. However this seems to be unnecessary (not DRY) and brittle (sensitive to breaking when something incompatibly changes in the original OmniFaces @ViewScoped
implementation).
Another way would be to just use a CDI managed bean instead of a Spring managed bean. Thanks to JoinFaces, CDI is readily available. However, the project already has a lot of existing Spring managed beans/services/repositories which couldn't be injected in a CDI managed bean via CDI's own @Inject
. So they had to be injectable in a CDI managed bean in some way, without the need to convert these Spring managed beans to CDI managed beans and possibly losing any Spring-induced advantages/features on them and then requiring us to unnecessarily rewrite/migrate yet another bunch of code.
The idea (with help of Arjan Tijms) is to autodetect fields annotated with Spring-specific @Autowired
in a CDI managed bean and simply let CDI fetch it from Spring context while injecting. This can be done with help of a CDI Extension
. The major advantage is that we can then fully leverage to the original implementation of the CDI scope, including all of its originally intended behavior and features. In other words, we'll with a special CDI Extension
be able to use @Named
beans with @Autowired
properties. This way we can transparently use OmniFaces @ViewScoped
or any other CDI-specific scope without the need to copy/rewrite it.
Activate CDI
To activate CDI in a Spring Boot project after installing JoinFaces, which by default uses the Weld implementation, simply add a beans.xml
file to the src/main/resources/META-INF
folder of the Spring Boot project:
<?xml version="1.0" encoding="UTF-8"?>
<beans
xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/beans_4_0.xsd"
version="4.0" bean-discovery-mode="annotated">
</beans>
Or when you're still on Spring Boot 2.x:
<?xml version="1.0" encoding="UTF-8"?>
<beans
xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/beans_2_0.xsd"
version="2.0" bean-discovery-mode="annotated">
</beans>
Note that this doesn't affect any existing Spring managed bean configurations. By default, they still have higher precedence. Moreover, the key configuration is explicitly setting CDI bean discovery mode to annotated
so that CDI only tries to scan for beans with CDI's own bean defining annotations.
Also note that when running the Spring Boot application as a (fat) JAR file, the beans.xml
needs to be located in BOOT-INF/classes/META-INF
folder of the generated JAR file, not in the root META-INF
folder containing web resources.
Instruct Spring to ignore CDI managed beans
We need to explicitly instruct Spring to ignore CDI managed beans annotated by @Named
. Otherwise the Spring bean management facility may still have higher precedence to find and manage them, particularly if they are in the same package which is covered by @ComponentScan
. This can be achieved by adding excludeFilters=@Filter(Named.class)
to the @ComponentScan
annotation on your Spring @Configuration
class.
@Configuration
@ComponentScan(basePackages = YourSpringConfiguration.BEANS_PACKAGE, excludeFilters = @Filter(Named.class))
public class YourSpringConfiguration implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
public static final String BEANS_PACKAGE = "com.example.beans";
// ...
}
Make ApplicationContext available in static context
Next step is to make sure that Spring's ApplicationContext
is available in static context, so that the CDI extension can access it while trying to fetch Spring managed beans. There's namely no dedicated API for this in Spring Boot (yet?) such as Faces has with FacesContext.getCurrentInstance()
and as CDI has with CDI.current().getBeanManager()
. In a Spring Boot application, the canonical approach is to have a Spring managed bean implementing ApplicationContextAware
. The average Spring Boot project should already have one somewhere, but in case yours doesn't seem to have one, then you can add one as follows:
@Component
public class Spring implements ApplicationContextAware {
private static ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext context) throws BeansException {
this.context = context;
}
public static ApplicationContext getContext() {
return context;
}
}
Create CDI extension
Now we can create the CDI extension which autodetects fields annotated with Spring's @Autowired
in CDI managed beans annotated with @Named
and makes sure that these fields are injected with beans obtained from Spring context.
public class SpringAutowiredExtension implements Extension {
private Map<Class<?>, String> autowiredFields = new ConcurrentHashMap<>();
public <T> void processAnnotatedType(@Observes @WithAnnotations(Named.class) ProcessAnnotatedType<T> processAnnotatedType, BeanManager beanManager) {
Class<T> beanClass = processAnnotatedType.getAnnotatedType().getJavaClass();
if (!beanClass.getPackage().getName().startsWith(YourSpringConfiguration.BEANS_PACKAGE)) {
return; // This should filter out any CDI managed beans provided by e.g. Faces and OmniFaces themselves.
}
processAnnotatedType.configureAnnotatedType()
.filterFields(field -> field.isAnnotationPresent(Autowired.class))
.forEach(this::registerAutowiredField);
}
private void registerAutowiredField(AnnotatedFieldConfigurator<?> fieldConfigurator) {
fieldConfigurator.add(InjectLiteral.INSTANCE);
Field field = fieldConfigurator.getAnnotated().getJavaMember();
autowiredFields.put(field.getType(), field.getName());
}
public void afterBeanDiscovery(@Observes AfterBeanDiscovery event) {
autowiredFields.entrySet().forEach(autowiredField -> injectBeanViaSpringContext(event, autowiredField.getKey(), autowiredField.getValue()));
}
private static void injectBeanViaSpringContext(AfterBeanDiscovery event, Class<?> type, String name) {
event.addBean().addType(type).createWith(ignoreCdiContext -> getBeanFromSpringContext(type, name));
}
private static Object getBeanFromSpringContext(Class<?> type, String name) {
try {
try {
return Spring.getContext().getBean(type);
}
catch (NoUniqueBeanDefinitionException ignore) {
return Spring.getContext().getBean(name);
}
}
catch (Exception e) {
throw new IllegalStateException("Cannot get bean from Spring context", e);
}
}
}
Register CDI extension
In order to get it to run, create a file with the exact name jakarta.enterprise.inject.spi.Extension
in the src/main/resources/META-INF/services
folder of the Spring Boot project and add a line to it representing the FQN of the CDI extension:
com.example.cdi.SpringAutowiredExtension
When you're still on Spring Boot 2.x, then the file should be named javax.enterprise.inject.spi.Extension
To confirm, when running the Spring Boot application as a (fat) JAR file, this file needs to be located in BOOT-INF/classes/META-INF/services
folder of the generated JAR file.
Profit
That's basically all! You should now be able to use CDI managed beans with OmniFaces @ViewScoped
and still therein have Spring managed beans at your availability via @Autowired
.
@Named
@ViewScoped
public class YourViewScopedBean implements Serializable {
private static final long serialVersionUID = 1L;
@Autowired
private SomeSpringComponent someSpringComponent;
@PostConstruct
public void init() {
// SomeSpringComponent should be readily available at this point.
// This is the key task of the CDI extension.
}
@PreDestroy
public void destroy() {
// This should be correctly invoked on page unload.
// This is the key advantage over standard Faces @ViewScoped annotation.
}
}