Friday, December 27, 2024

Smallest Working Spring Boot 3 Application with Faces 4, OmniFaces 4 and CDI 4

For reference, here is the minimal Maven project structure for a Spring Boot 3 application utilizing Faces 4, OmniFaces 4, and CDI 4.

While Spring can function without CDI, the OmniFaces @ViewScoped annotation, with its powerful unload feature, doesn't work out of the box without CDI. Along with the CDI managed bean, we're also creating a simple @ApplicationScoped CDI service for demonstration purposes.

pom.xml

Below is the minimal Maven configuration, including the repackage configuration necessary to produce a Fat JAR:

<?xml version="1.0" encoding="UTF-8"?>
<project
    xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"
>
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.1</version>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>springboot3-omnifaces4</artifactId>
    <version>1.0.0</version>

    <dependencies>
        <dependency>
            <groupId>org.joinfaces</groupId>
            <artifactId>omnifaces-spring-boot-starter</artifactId>
            <version>5.4.1</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

We're using JoinFaces to auto-configure a Spring Boot 3.x application with Faces 4.x (Mojarra), OmniFaces 4.x, and CDI 4.x (Weld) with minimal effort.

src/main/java/com/example/ExampleApplication.java

The mandatory Spring Boot application configurer and launcher:

package com.example;

import jakarta.inject.Named;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.Configuration;

@Configuration
@SpringBootApplication
@ComponentScan(excludeFilters = @Filter(Named.class))
public class ExampleApplication extends SpringBootServletInitializer {

    public static void main(String[] args) {
        SpringApplication.run(ExampleApplication.class, args);
    }
}

Note that the @ComponentScan is configured to exclude all classes annotated with CDI's @Named. This basically prevents Spring from auto-registering all @Named-annotated classes as Spring managed beans, hereby completely overriding CDI. Without it, any @Named-annotated class would behave as a Spring singleton bean, which is like a CDI @ApplicationScoped.

src/main/java/com/example/backing/ExampleBacking.java

The example CDI managed bean representing a Jakarta Faces backing bean utilizing OmniFaces powerful @ViewScoped with memory-saving unload feature:

package com.example.backing;

import java.io.Serializable;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Inject;
import jakarta.inject.Named;

import org.omnifaces.cdi.ViewScoped;

import com.example.service.ExampleService;

@Named
@ViewScoped
public class ExampleBacking implements Serializable {

    private static final long serialVersionUID = 1L;

    private String input;
    private String output;

    @Inject
    private ExampleService service;

    public void submit() {
        output = service.process(input);
    }

    public String getInput() {
        return input;
    }

    public void setInput(String input) {
        this.input = input;
    }

    public String getOutput() {
        return output;
    }
}

src/main/java/com/example/service/ExampleService.java

The example CDI service:

package com.example.service;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class ExampleService {

    public String process(String input) {
        return "Hello! You have typed: " + input;
    }
}

Note, in case you wish this class to be a Spring-managed bean such as @Service and inject it via @AutoWired in your CDI managed bean, then you need a custom CDI extension. Detail can be found in this related article: Using OmniFaces CDI @ViewScoped with unload/destroy in a Spring Boot project.

src/main/resources/META-INF/resources/index.xhtml

The example Facelet file with a basic Jakarta Faces form:

<!DOCTYPE html>
<html lang="en"
    xmlns:f="jakarta.faces.core"
    xmlns:h="jakarta.faces.html"
>
    <h:head>
        <title>Hello!</title>
    </h:head>
    <h:body>
        <h1>Hello!</h1>
        <h:form>
            <h:outputLabel for="input" value="Type something: " />
            <h:inputText id="input" value="#{exampleBacking.input}" />
            <h:commandButton value="Submit" action="#{exampleBacking.submit}">
                <f:ajax execute="@form" render=":output" />
            </h:commandButton>
        </h:form>
        <h:outputText id="output" value="#{exampleBacking.output}" />
    </h:body>
</html>

Do note that web resources such as Facelets files need to be placed in the src/main/resources/META-INF/resources folder as if it were a web fragment JAR project instead of the src/main/webapp folder as if it were a WAR project!

src/main/resources/BOOT-INF/classes/META-INF/beans.xml

The mandatory beans.xml file to activate all the things CDI such as @Named beans:

<?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>

Do note that the beans.xml needs to go into the src/main/resources/BOOT-INF/classes/META-INF folder in order to get the Fat JAR execution using java -jar command to work. In case you wish to be able to execute/debug the application by directy executing the main() method using an IDE or the mvn spring-boot:run command, then you need to place a copy of the beans.xml in the src/main/resources/META-INF folder as well.

Also note that the bean-discovery-mode is explicitly set to annotated so that CDI leaves any Spring managed beans alone, even though this is the default behavior since CDI version 4.0.

In case you wish to use a src/main/webapp folder like in a standard Maven WAR project structure, even if only in order to have a clear oversight of all the web resources and WAR-related deployment descriptors, or to avoid the need to create copies of beans.xml files, then you can always reconfigure the <build> section of your pom.xml accordingly so that all these files ultimately end up in the right locations of the produced Fat JAR file as expected by Spring Boot. A concrete example can be found in this related Stack Overflow answer: src/main/webapp is IGNORED when packaging is JAR instead of WAR.

src/main/java/com/example/SpringBoot32FatJarBeanArchiveHandler.java

Unfortunately, since Spring Boot version 3.2 the CDI beans.xml scanning has gone astray when the application is launched as a Fat JAR. This will probably be fixed sooner or later via Spring or JoinFaces. For the time being you'll need a custom BeanArchiveHandler as below:

package com.example;

import jakarta.annotation.Priority;

import org.jboss.weld.environment.deployment.discovery.BeanArchiveBuilder;
import org.jboss.weld.environment.deployment.discovery.FileSystemBeanArchiveHandler;

/**
 * Since Spring Boot 3.2 the BOOT-INF/classes/META-INF/beans.xml isn't anymore correctly handled.
 * This handler corrects this misbehavior.
 */
@Priority(Integer.MAX_VALUE)
public class SpringBoot32FatJarBeanArchiveHandler extends FileSystemBeanArchiveHandler {

    private static final String SB_32_NESTED_JAR_PREFIX = "jar:nested:";
    private static final String WRONG_SUFFIX = "/!BOOT-INF/classes/!/META-INF/beans.xml";
    private static final String CORRECT_SUFFIX = "!/BOOT-INF/classes";

    @Override
    public BeanArchiveBuilder handle(String path) {
        if (path.startsWith(SB_32_NESTED_JAR_PREFIX) && path.endsWith(WRONG_SUFFIX)) {
            path = path.substring(SB_32_NESTED_JAR_PREFIX.length(), path.length() - WRONG_SUFFIX.length()) + CORRECT_SUFFIX;
        }

        return super.handle(path);
    }
}

In order to activate it, create a src/main/resources/META-INF/services/org.jboss.weld.environment.deployment.discovery.BeanArchiveHandler file with the following content:

com.example.SpringBoot32FatJarBeanArchiveHandler

Note that this is not necessary when executing the project using an IDE or the mvn spring-boot:run command.

Build and run it!

First cd into the folder where the pom.xml is located.

Now create the Fat JAR:

mvn clean package

Then execute the Fat JAR:

java -jar target/springboot3-omnifaces4-1.0.0.jar

Finally launch your default web browser on http://localhost:8080/index.xhtml:

browse http://localhost:8080/index.xhtml