Thursday, November 23, 2023

Using OmniFaces CDI @ViewScoped with unload/destroy in a Spring Boot project

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.
    }
}

No comments: