Jakarta Faces и Spring Boot

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Spring Boot работает с Tomcat Embed. Tomcat не включает в себя поддержку Jakarta Faces и CDI. Не смотря на это, возможно добавить нужные зависимости и использовать Faces.

Эта статья о том, какая конфигурация нужна для запуска Jakarta Faces вместе со Spring Boot. Также я описал некоторые ошибки, которые могут встретится.

Tomcat

В первую очередь я хочу посмотреть как обычное веб-приложение с Jakarta Faces запустится на полном Tomcat.
Для этого случая Mojarra README рекомендует добавить зависимость для CDI

<dependency>
    <groupId>org.jboss.weld.servlet</groupId>
    <artifactId>weld-servlet-shaded</artifactId>
    <version>4.0.0.Final</version>
</dependency>

Эта библиотека включает Jakarta API. И это вызывает проблему. Tomcat уже включает в себя библиотеки с Jakarta API. Но код там отличается. Во время дебага IDE будет показывать неправильные строки на этих классах. Поэтому я должен исключить эти API из зависимостей. Моя версия выглядит так

<dependency>
    <groupId>org.apache.myfaces.core</groupId>
    <artifactId>myfaces-impl</artifactId>
    <version>4.0.0</version>
</dependency>
<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>org.jboss.weld.servlet</groupId>
    <artifactId>weld-servlet-core</artifactId>
    <version>5.1.0.Final</version>
    <exclusions>
        <exclusion>
            <groupId>jakarta.el</groupId>
            <artifactId>jakarta.el-api</artifactId>
        </exclusion>
        <exclusion>
            <groupId>jakarta.annotation</groupId>
            <artifactId>jakarta.annotation-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>

Зависимость weld-servlet-core включает листенер для контейнера, который инициализирует CDI. Зависимость myfaces-impl сама создает FacesServlet.

Мне остается создать инициалайзер, чтобы добавить параметры в контекст.

import java.util.Set;

import jakarta.faces.application.ProjectStage;
import jakarta.faces.component.UIInput;
import jakarta.servlet.ServletContainerInitializer;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebListener;

@WebListener
public class ConfigureListener implements ServletContainerInitializer {

    @Override
    public void onStartup(Set<Class<?>> classes, ServletContext context) throws ServletException {
        context.setInitParameter(UIInput.EMPTY_STRING_AS_NULL_PARAM_NAME, Boolean.TRUE.toString());

        context.setInitParameter(ProjectStage.PROJECT_STAGE_PARAM_NAME, ProjectStage.Development.name());
    }
}

На этом все. Теперь могу создавать xhtml и бины.

Spring Boot

Теперь можно пробовать Spring Boot. Я использовал Spring Initializr для создания jar-проекта.

Добавлю зависимость спринга для веба:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Можно удалить provided-зависимость для servlet api

<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version>
    <scope>provided</scope>
</dependency>

WEBROOT для Tomcat embedded - /META-INF/resources. Я добавил эту директорию в /src/main/resources. Теперь можно создать тестовую страницу /src/main/resources/META-INF/resources/hello.xhtml

<!DOCTYPE html>
<html
    xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:f="jakarta.faces.core"
    xmlns:h="jakarta.faces.html"
    xmlns:c="jakarta.tags.core">
<h:head>
    <title>Hello World</title>
    <h:outputScript library="js" name="demo.js" />
</h:head>
<h:body>
    <h1>Hello World</h1>
    <h:form id="helloForm">

        <h:inputText id="number"
            value="#{helloBacking.number}"
            onblur="demoLog(event.target.value)">
        </h:inputText>

        <h:commandButton id="submitBtn"
            value="Submit"
            type="button"
            action="#{helloBacking.submit}">
            <f:ajax execute="@form" render="output messages" />
        </h:commandButton>

        <div>
            <h:outputText id="output" value="#{helloBacking.number}" />
        </div>

        <h:messages id="messages" showSummary="true" showDetail="true"/>

    </h:form>
</h:body>
</html>

Вы можете видеть здесь скрипт

<h:outputScript library="js" name="demo.js" />

Это означает, что он в файле /META-INF/resources/resources/js/demo.js. Я вызываю функцию demoLog из этого файла на событие blur.

Бин для страницы:

package com.example.demo.backing;

import jakarta.enterprise.context.SessionScoped;
import jakarta.inject.Named;

@Named(value = "helloWorld")
@SessionScoped
public class HelloWorld implements Serializable {
    private BigDecimal number;

    public void submit() {
        System.out.println(this.number);
    }

    public BigDecimal getNumber() {
        return number;
    }

    public void setNumber(BigDecimal number) {
        this.number = number;
    }
}

Еще два файла для конфигураций

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

<?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"
>
    <!-- CDI configuration here. -->
</beans>

/src/main/resources/META-INF/resources/WEB-INF/faces-config.xml

<?xml version="1.0" encoding="UTF-8"?>
<faces-config
    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/web-facesconfig_4_0.xsd"
    version="4.0"
>
    <!-- Faces configuration here. -->
</faces-config>

И это все не работает.

Сервер возвращает мой xhtml. Это означает, что нет faces servlet. Здесь проявляется разница с Tomcat. Спринговая версия не вызывает классы jakarta.servlet.ServletContainerInitializer. Я должен создать спринговый бин org.springframework.boot.web.servlet.ServletContextInitializer для запуска их самостоятельно.

import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class Config {
    @Bean
    public ServletContextInitializer jsfInitializer() {
        return new JsfInitializer();
    }
}

Есть два ServletContainerInitializer, которые надо запустить. Для CDI - это org.jboss.weld.environment.servlet.EnhancedListener. Для MyFaces - org.apache.myfaces.webapp.MyFacesContainerInitializer.

import org.apache.myfaces.webapp.MyFacesContainerInitializer;
import org.jboss.weld.environment.servlet.EnhancedListener;
import org.springframework.boot.web.servlet.ServletContextInitializer;

import jakarta.faces.application.ProjectStage;
import jakarta.faces.component.UIInput;
import jakarta.servlet.ServletContainerInitializer;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;

public class JsfInitializer implements ServletContextInitializer {

    @Override
    public void onStartup(ServletContext context) throws ServletException {
        context.setInitParameter(UIInput.EMPTY_STRING_AS_NULL_PARAM_NAME, Boolean.TRUE.toString());
        context.setInitParameter(ProjectStage.PROJECT_STAGE_PARAM_NAME, ProjectStage.Development.name());

        EnhancedListener cdiInitializer = new EnhancedListener();
        cdiInitializer.onStartup(null, context);

        ServletContainerInitializer myFacesInitializer = new MyFacesContainerInitializer();
        myFacesInitializer.onStartup(null, context);
	}
}

После этого новая ошибка. Уже от FacesServlet:

Servlet.init() for servlet [FacesServlet] threw exception
java.lang.IllegalStateException: No Factories configured for this Application. This happens if the faces-initialization does not work at all - make sure that you properly include all configuration settings necessary for a basic faces application and that all the necessary libs are included. Also check the logging output of your web application and your container for any exceptions!
If you did that and find nothing, the mistake might be due to the fact that you use some special web-containers which do not support registering context-listeners via TLD files and a context listener is not setup in your web.xml.
A typical config looks like this;

<listener>
    <listener-class>
        org.apache.myfaces.webapp.StartupServletContextListener
    </listener-class>
</listener>

Это происходит, потому что Spring не сканирует classpath на наличие servlet-аннотаций и web-fragment.xml. Поэтому я должен добавить еще один спринговый бин в конфигурацию Config.java.

import org.apache.myfaces.webapp.StartupServletContextListener;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import jakarta.servlet.ServletContextListener;

@Bean
public ServletListenerRegistrationBean<ServletContextListener> facesStartupServletContextListener() {
    ServletListenerRegistrationBean<ServletContextListener> bean = new ServletListenerRegistrationBean<>();
    bean.setListener(new StartupServletContextListener());
    return bean;
}

Теперь страница работает.

Customize faces config

Добавлю что-нибудь в faces-config.xml для проверки. Я хочу сделать слушателя для ELContext и добавить событие в Java Flight Recorder.

import jdk.jfr.Event;
import jdk.jfr.Label;
import jdk.jfr.Name;

@Name("com.example.faces.EvaluateExpression")
@Label("Evaluate Expression")
public class EvaluateExpressionEvent extends Event {
	
	@Label("Expression")
	private String expression;

	public String getExpression() {
		return expression;
	}

	public void setExpression(String expression) {
		this.expression = expression;
	}
}
import jakarta.el.ELContext;
import com.example.demo.event.EvaluateExpressionEvent;

public class EvaluationListener extends jakarta.el.EvaluationListener {
	
	private ThreadLocal<EvaluateExpressionEvent> event = new ThreadLocal<>();

	@Override
    public void beforeEvaluation(ELContext context, String expression) {
		EvaluateExpressionEvent event = new EvaluateExpressionEvent();
		event.setExpression(expression);
		event.begin();
		this.event.set(event);
    }

	@Override
	public void afterEvaluation(ELContext context, String expression) {
		event.get().commit();
		event.remove();
    }
}

Теперь я должен как-то добавить его в ELContext. Я сделаю это во время создания. Еще один listener.

import jakarta.el.ELContext;
import jakarta.el.ELContextEvent;

public class ELContextListener implements jakarta.el.ELContextListener {

	@Override
	public void contextCreated(ELContextEvent event) {
		ELContext elContext = event.getELContext();
		elContext.addEvaluationListener(new EvaluationListener());
	}
}

Я должен добавить его в Application. Еще один listener:

import jakarta.faces.application.Application;
import jakarta.faces.event.AbortProcessingException;
import jakarta.faces.event.SystemEvent;
import jakarta.faces.event.SystemEventListener;

public class ApplicationCreatedListener implements SystemEventListener {

	@Override
	public boolean isListenerForSource(Object source) {
		return source instanceof Application;
	}

	@Override
	public void processEvent(SystemEvent event) throws AbortProcessingException {
		Application application = (Application) event.getSource();
		application.addELContextListener(new ELContextListener());
	}
}

Уже его я теперь могу прописать в faces-config.xml.

<application>
    <system-event-listener>
        <system-event-listener-class>com.example.demo.listener.ApplicationCreatedListener</system-event-listener-class>
        <system-event-class>jakarta.faces.event.PostConstructApplicationEvent</system-event-class>
        <source-class>jakarta.faces.application.Application</source-class>
    </system-event-listener>
</application>

Теперь я наблюдаю вычисления значений при рендеринге страницы в Java Mission Control. Это значит face-config.xml работает.

Валидатор и конвертер

Я могу добавить что-нибудь из аннотаций:

import jakarta.faces.component.FacesComponent;
import jakarta.faces.component.behavior.FacesBehavior;
import jakarta.faces.convert.FacesConverter;
import jakarta.faces.event.NamedEvent;
import jakarta.faces.render.FacesBehaviorRenderer;
import jakarta.faces.render.FacesRenderer;
import jakarta.faces.validator.FacesValidator;

Я попробую FacesValidator и FacesConverter для моего input.

Валидатор:

import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.validator.FacesValidator;
import jakarta.faces.validator.Validator;
import jakarta.faces.validator.ValidatorException;

@FacesValidator(value = "demo.NumberValidator")
public class NumberValidator implements Validator<BigDecimal>{

    private Random random = new Random();

    private BigDecimal getNumber() {
        return new BigDecimal(random.nextDouble(420)).setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public void validate(FacesContext context, UIComponent component, BigDecimal value)
            throws ValidatorException {
        if (value == null) {
            return;
        }
        BigDecimal orig = getNumber();
        if (value.compareTo(orig) < 0) {
            throw new ValidatorException(
                    new FacesMessage(FacesMessage.SEVERITY_ERROR,
                            "Wrong number",
                            "Number " + value + " less than " + orig));
        }
    }
}

Конвертер:

import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.convert.Converter;
import jakarta.faces.convert.ConverterException;
import jakarta.faces.convert.FacesConverter;

@FacesConverter(
        value = "demo.BigDecimalConverter",
        forClass = BigDecimal.class
        )
public class BigDecimalConverter implements Converter<BigDecimal> {

    private Random random = new Random();

    private BigDecimal getNumber() {
        return new BigDecimal(random.nextDouble(420)).setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public BigDecimal getAsObject(FacesContext context, UIComponent component, String value)
            throws ConverterException {
        if (value == null || value.trim().length() == 0) {
            return getNumber();
        }
        try {
            return new BigDecimal(value.trim()).setScale(2, RoundingMode.HALF_UP);
        } catch (NumberFormatException e) {
            throw new ConverterException(e.getMessage());
        }
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, BigDecimal value)
            throws ConverterException {
        if (value == null) {
            return "";
        }
        return value.toPlainString();
    }
}

hello.xhtml

<h:inputText id="number"
            value="#{helloBacking.number}"
            onblur="demoLog(event.target.value)">
    <f:converter converterId="demo.BigDecimalConverter" />
    <f:validator validatorId="demo.NumberValidator" /> 
</h:inputText>

И это не работает. Новые ошибки:

Could not find any registered converter-class by converterId : demo.BigDecimalConverter

Unknown validator id 'demo.NumberValidator'.

Потому что MyFaces ищет классы с аннотациями в /WEB-INF/classes или в jar-файлах.
Это не WAR и здесь нет /WEB-INF/classes. Я запускаю программу из IDE, поэтому мои классы не в jar. Если я соберу проект и запущу jar, тогда заработает. Но мне надо запускать в IDE тоже. Здесь три варианта решения проблемы.

Во-первых, я могу имплементировать org.apache.myfaces.spi.AnnotationProvider применяя ClassPathScanningCandidateComponentProvider из спринга.

import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;

import jakarta.faces.component.FacesComponent;
import jakarta.faces.component.behavior.FacesBehavior;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.convert.FacesConverter;
import jakarta.faces.event.NamedEvent;
import jakarta.faces.render.FacesBehaviorRenderer;
import jakarta.faces.render.FacesRenderer;
import jakarta.faces.validator.FacesValidator;

public class AnnotationProvider extends org.apache.myfaces.spi.AnnotationProvider {
    
    private static Set<Class<? extends Annotation>> annotationsToScan;

    static {
        annotationsToScan = new HashSet<>(7, 1f);
        annotationsToScan.add(FacesComponent.class);
        annotationsToScan.add(FacesBehavior.class);
        annotationsToScan.add(FacesConverter.class);
        annotationsToScan.add(FacesValidator.class);
        annotationsToScan.add(FacesRenderer.class);
        annotationsToScan.add(NamedEvent.class);
        annotationsToScan.add(FacesBehaviorRenderer.class);
    }

    @Override
    public Map<Class<? extends Annotation>, Set<Class<?>>> getAnnotatedClasses(ExternalContext ctx) {
        Map<Class<? extends Annotation>, Set<Class<?>>> result = new HashMap<>();
        
        ClassLoader cl = getClass().getClassLoader();
        Package[] packages = cl.getDefinedPackages();
        for (Package pack : packages) {
            pack.getName();
        }
        ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
        for (Class<? extends Annotation> a : annotationsToScan) {
            provider.addIncludeFilter(new AnnotationTypeFilter(a));
        }
        Set<BeanDefinition> components = provider.findCandidateComponents("com.example.demo");
        for (BeanDefinition c : components) {
            Class<?> clazz;
            try {
                clazz = cl.loadClass(c.getBeanClassName());
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
                continue;
            }
            for (Class<? extends Annotation> a : annotationsToScan) {
                Annotation an = clazz.getDeclaredAnnotation(a);
                if (an != null) {
                    Set<Class<?>> annotationSet = result.get(a);
                    if (annotationSet == null) {
                        annotationSet = new HashSet<>();
                        result.put(a, annotationSet);
                    }
                    annotationSet.add(clazz);
                }
            }
        }
        return result;
    }

    @Override
    public Set<URL> getBaseUrls(ExternalContext ctx) throws IOException {
        return null;
    }
}

Зарегистрировать мой провайдер в файле src/main/resources/META-INF/services/org.apache.myfaces.spi.AnnotationProvider

com.example.demo.config.AnnotationProvider

Второй вариант - это добавить параметр в контекст.

import org.apache.myfaces.config.webparameters.MyfacesConfig;

context.setInitParameter(MyfacesConfig.SCAN_PACKAGES, "com.example.demo");

Этот параметр активирует другую логику сканирования. Примерно как в моем провайдере, но без спринга. Почему здесь два способа сканирования? Потому что загружать классы из всех зависимостей для проверки аннотаций - это накладно. Поэтому по умолчанию MyFaces только читает заголовки из скомпилированных *.class файлов и загружает только классы с нужными аннотациями. Для второго способа нужно указать явно пакеты для сканирования.

Третий вариант - установить параметр:

context.setInitParameter(
    MyfacesConfig.USE_CDI_FOR_ANNOTATION_SCANNING, Boolean.TRUE.toString());

Теперь MyFaces обращается к CDI для поиска бинов с аннотациями. Но мои классы - не CDI-бины. Я должен добавить аннотацию @Dependent и тогда этот вариант заработает.

Инъекция бинов в конвертер и валидатор

Для тестирования я перенесу генератор чисел в репозиторий и сделаю его инъекцию в конвертер и валидатор.

@Named
@ApplicationScoped
public class DemoRepository {

	private Random random = new Random();

	public BigDecimal getNumber() {
		return new BigDecimal(random.nextDouble(420)).setScale(2, RoundingMode.HALF_UP);
	}
}
public class BigDecimalConverter implements Converter<BigDecimal> {
    @Inject
	private DemoRepository demoRepository;

Теперь у меня NPE. DemoRepository = null. Потому что конвертер не CDI-бин. Если вы используете CDI для сканирования, этого недостаточно. Сканирование только наполняет мапу с классами. Во время запроса MyFaces создает конвертер, используя конструктор без аргументов. Еще могут быть добавлены проперти из faces-config, когда конвертер регистрировался в конфиге. Это не мой случай. Чтобы экземпляр конвертера запрашивался из CDI, я должен добавить managed = true в аннотацию конвертера.

Сразу после этого я получаю новую ошибку:

Cannot invoke "jakarta.faces.convert.Converter.getAsString(jakarta.faces.context.FacesContext, jakarta.faces.component.UIComponent, Object)" because the return value of "org.apache.myfaces.cdi.wrapper.FacesConverterCDIWrapper.getWrapped()" is null

CDI не знает таких бинов. Конвертер должен быть помечен аннотацией @Dependent. Пробую снова - таже ошибка. Бин существует, но CDI не находит его. Потому что конвертер должен иметь ID

<f:converter converterId="demo.BigDecimalConverter" />

Это значит Myfaces ищет конвертер по аннотации

@FacesConverter(
        value = "demo.BigDecimalConverter",
        forClass = Object.class,
        managed = true
        )

Но у моего конвертера указано forClass = BigDecimal.class. В этом ошибка. Я должен удалить forClass у конвертера. Или я могу удалить тег f:converter и соответственно удалить value у конвертера. Что-то одно. Я удалил forClass. Это позволит мне использовать мой странный конвертер для инпутов с тегом и оставить стандартный конвертер для других BigDecimal полей.

Теперь моя инъекция работает.

Инъекция сущностей Faces

Jakarta Faces добавляет в контекст такие сущности как FacesContext, ExternalContext, ResourceHandler, и другие. Давайте попробуем засетить в бин инит-параметры, которые я добавлял в моем инициалайзере.

import jakarta.faces.annotation.InitParameterMap;

@Inject
@InitParameterMap
private Map<String, String> initParam;

System.out.println("Empty as null = "
    + this.initParam.get(UIInput.EMPTY_STRING_AS_NULL_PARAM_NAME));

Я получаю ошибку:

ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'helloBacking': Unsatisfied dependency expressed through field 'initParam': No qualifying bean of type 'java.util.Map<java.lang.String, java.lang.String>' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: @jakartaa.inject.Inject(), @jakarta.faces.annotation.InitParameterMap()}

Это уже от спринга. Spring создает бины и использует аннотации Jakarta для сканирования кандидатов в бины.
ClassPathScanningCandidateComponentProvider имеет includedFilters, который в Spring Boot 3 содержит по дефолту следующие аннотации:

    import org.springframework.stereotype.Component;
    import jakarta.annotation.ManagedBean;
    import jakarta.inject.Named;

Я поправлю этот конфликт, добавив @ComponentScan в конфигурацию приложения. Оставлю для спринга только @Component.

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.stereotype.Component;

@ComponentScan(
        useDefaultFilters = false,
        includeFilters = {
            @ComponentScan.Filter(
                type = FilterType.ANNOTATION,
                value = Component.class)
        }
    )
@SpringBootApplication

Использование Spring-бинов

Если вы используете Spring Boot, то скорее всего у вас есть спринговые бины. И скорее всего вы захотите их использовать для Faces. Давайте обратим DemoRepository в спринговый бин.

import org.springframework.stereotype.Repository;

@Repository
public class DemoRepository {

Теперь у меня ошибка. CDI не имеет нужного бина для инъекции в конвертер.

Caused by: org.jboss.weld.exceptions.DeploymentException: WELD-001408: Unsatisfied dependencies for type DemoRepository with qualifiers @Default
at injection point [BackedAnnotatedField] @Inject private com.example.demo.converter.BigDecimalConverter.demoRepository
at com.example.demo.converter.BigDecimalConverter.demoRepository(BigDecimalConverter.java:0)

Я могу написать CDI-продьюсер для спрингового ApplicationContext. Заинжектить его. И запрашивать у него нужный бин в @PostConstruct методе.

import org.springframework.context.ApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;

import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.inject.Produces;
import jakarta.servlet.ServletContext;

@Dependent
public class SpringBeanProducer {

	@Produces
	public ApplicationContext springContext(ServletContext sctx) {
		ApplicationContext ctx = WebApplicationContextUtils.getRequiredWebApplicationContext(sctx);
		return ctx;
	}
}

В конвертере:

import jakarta.annotation.PostConstruct;

private DemoRepository demoRepository;

@Inject
private ApplicationContext springContext;

@PostConstruct
private void init() {
    this.demoRepository = springContext.getBean(DemoRepository.class);
} 

Могу ли я написать продьюсер сразу для бинов? Попробую сделать следующее.

В конвертере добавлю аннотацию:

import com.example.demo.config.SpringBeanProducer.SpringBean;

@Inject
@SpringBean
@Qualifier("demoRepository")
private DemoRepository demoRepository;

И новый продьюсер, который ищет бин по классу и имени:

import jakarta.enterprise.inject.spi.InjectionPoint;
import jakarta.inject.Qualifier;

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
public @interface SpringBean {
}

@Produces
@SpringBean
public Object create(InjectionPoint ip, ServletContext sctx) {
    Class beanClass = (Class) ip.getType();
    Set<Annotation> qualifiers = ip.getAnnotated().getAnnotations();
    String beanName = null;
    for (Annotation a : qualifiers) {
        if (a instanceof org.springframework.beans.factory.annotation.Qualifier q) {
            beanName = q.value();
        }
    }

    ApplicationContext ctx = WebApplicationContextUtils.getRequiredWebApplicationContext(sctx);
    if (beanName != null && !beanName.isBlank()) {
        return ctx.getBean(beanName, beanClass);
    }
        
    return ctx.getBean(beanClass);
}

Но это не работает. Потому что CDI не может сопоставить DemoRepository.class и Object.class. Он будет использовать продьюсер, только если он возвращает нужный класс. Аннотации @SpringBean мало. Это означает, что я должен писать свой продьюсер для каждого бина. Если их много, то это не решение. Или я должен инжектить бин в конвертер как Object. Удобнее написать сеттер для каждой инъекции, чем продьюсер для каждого бина.

import org.springframework.beans.factory.annotation.Qualifier;

private DemoRepository demoRepository;

@Inject
public void setDemoRepository(
    @SpringBean @Qualifier("demoRepository") Object repository) {
    this.demoRepository = (DemoRepository) repository;
}

Теперь я могу использовать Spring в CDI.

Можно ли вставлять зависимости из CDI в Spring? Я могу делать это в @PostConstruct следующим способом:

import jakarta.enterprise.inject.spi.CDI;
import jakarta.enterprise.util.TypeLiteral;
import jakarta.faces.annotation.InitParameterMap;

private Map<String, String> initParam;

@PostConstruct
private void init() {
    this.initParam = CDI.current().select(
            new TypeLiteral<Map<String, String>>() {},
            new InitParameterMap.Literal()
        ).get();
}

Последний шаг - это использовать Spring-бины прямо в xhtml. Для этого нужен ELResolver. Spring уже имеет такой. Остается зарегистрировать его в faces-config.xml.

<application>
    <el-resolver>
        org.springframework.web.jsf.el.SpringBeanFacesELResolver
    </el-resolver>
</application>

Сборка JAR

Конфигурация выглядит рабочей. Можно собирать и проверять.

mvn package

В результате собранный jar не работает. Потому что Spring Boot делает особенный fat jar с зависимостями внутри и делает свой ClassLoader для загрузки всего этого.

Я могу поменять это

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
</plugin>

на это

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>copy-dependencies</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>copy-dependencies</goal>
            </goals>
            <configuration>
                <outputDirectory>
                    ${project.build.directory}/libs
                </outputDirectory>
            </configuration>
        </execution>
    </executions>
</plugin>

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <configuration>
        <archive>
            <manifest>
                <addClasspath>true</addClasspath>
                <classpathPrefix>libs/</classpathPrefix>
                <mainClass>
                    com.example.demo.DemoApplication
                </mainClass>
            </manifest>
        </archive>
    </configuration>
</plugin>

По-моему это лучший вариант. Спринговая сборка мудреная и является ещё одной точкой отказа. Не все библиотеки могут работать с такой структурой архива.

Если вы хотите сохранить упаковку от Spring Boot, то вы должны решить две проблемы.

1. CDI

CDI ищет бины, обходя файловую структуру архива. И имя класса в итоге собирается из пути. Таким образом имя класса будет
BOOT-INF.classes.com.example.demo.converter.BigDecimalConverter

Чтобы это исправить надо писать свою стратегию поиска. Я использую jandex. Буду переопределять стратегию из этой библиотеки.

<dependency>
    <groupId>org.jboss</groupId>
    <artifactId>jandex</artifactId>
    <version>3.1.1</version>
</dependency>

Имплементирую свой org.jboss.weld.environment.deployment.discovery.BeanArchiveHandler. И зарегистрирую его в META-INF/services.

import org.jboss.weld.environment.deployment.discovery.BeanArchiveBuilder;
import org.jboss.weld.environment.deployment.discovery.jandex.JandexFileSystemBeanArchiveHandler;
import jakarta.annotation.Priority;

@Priority(100)
public class SpringBootFileSystemBeanArchiveHandler extends JandexFileSystemBeanArchiveHandler {

    private static final String BOOT_INF_CLASSES = "BOOT-INF/classes/";

    @Override
    public BeanArchiveBuilder handle(String path) {
        // use only for spring boot build
        if (!path.toLowerCase().endsWith(".jar")) {
            // try other handlers
            return null;
        }
        return super.handle(path);
    }

    @Override
    protected void add(Entry entry, BeanArchiveBuilder builder) throws MalformedURLException {
        if (!entry.getName().startsWith(BOOT_INF_CLASSES)
                || !entry.getName().endsWith(CLASS_FILE_EXTENSION)) {
            // skip spring classes for loader and other resources 
            return;
        }
        entry = new SpringBootEntry(entry);
        super.add(entry, builder);
    }
    
    protected class SpringBootEntry implements Entry {

        private Entry delegate;
        
        public SpringBootEntry(Entry entry) {
            this.delegate = entry;
        }

        @Override
        public String getName() {
            String name = delegate.getName();
            // cut off prefix from class name
            name = name.substring(BOOT_INF_CLASSES.length());
            return name;
        }

        @Override
        public URL getUrl() throws MalformedURLException {
            return delegate.getUrl();
        }
        
    }
}

Аннотация @Priority(100) важна. Класс с большим значением будет использован в первую очередь. Если другой BeanArchiveBuilder найдет что-нибудь первым, то следующие вообще не вызываются.

2. Tomcat static resources

Tomcat читает статику из jar. Tomcat опрашивает все url из URLClassLoader на наличие /META-INF/resources. И использует эти директории как WEBROOT для статики. Spring Boot упаковывает /src/main/resources в корень архива. Но не добавляет корень в ClassLoader, который создает. Там оказываются только /BOOT-INF/classes и все архивы зависимостей. Почему спринг не помещает ресурсы в /BOOT-INF/classes тогда? Есть issue на это. И когда люди из спринга говорят о jar, они забываю, что он может быть исполняемым. По их мнению jar - это зависимость для другого веб-проекта.

Как вы понимаете решить эту проблему можно, выделив все для Faces в отдельный модуль и подключив как зависимость. Проблему с CDI это тоже может решить.

Я же напишу свою ConfigurableTomcatWebServerFactory со слушателем событий томката. Слушатель добавит ещё один ресурс для статики.

@Bean
public TomcatServletWebServerFactory tomcatFactory() {
    TomcatFactory factory = new TomcatFactory();
    return factory;
}
import org.apache.catalina.Context;
import org.apache.catalina.Lifecycle;
import org.apache.catalina.LifecycleEvent;
import org.apache.catalina.LifecycleListener;
import org.apache.catalina.WebResourceRoot.ResourceSetType;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;

public class TomcatFactory extends TomcatServletWebServerFactory {

    @Override
    protected void postProcessContext(Context context) {
        context.addLifecycleListener(new WebResourcesConfigurer(context));
    }
    
    public class WebResourcesConfigurer implements LifecycleListener {
        
        private static final String META_INF_RESOURCES = "META-INF/resources";
        private Context context;

        public WebResourcesConfigurer(Context context) {
            this.context = context;
        }

        @Override
        public void lifecycleEvent(LifecycleEvent event) {
            if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
                ClassLoader classLoader = getClass().getClassLoader();
                if (!(classLoader instanceof URLClassLoader)) {
                    return;
                }
                URL jarRoot = classLoader.getResource(META_INF_RESOURCES);
                if (jarRoot == null) {
                    logger.warn("Web resources not found");
                    return;
                }

                try {
                    int innerRootIndex = jarRoot.getPath().indexOf("!/");
                    String path = jarRoot.getPath().substring(0, innerRootIndex);
                    jarRoot = new URL(path);
                } catch (Exception e) {
                    logger.warn("Web resources URL error", e);
                    return;
                }
                context.getResources().createWebResourceSet(ResourceSetType.RESOURCE_JAR,
                        "/", jarRoot, "/" + META_INF_RESOURCES);
                logger.info("Web resources were added to Tomcat");
            }
        }
    }
}

Mojarra

Выше я экспериментировал с Apache MyFaces. Если вы хотите использовать Eclipse Mojarra, то две правки нужно сделать.

Замените зависимость на

<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.faces</artifactId>
    <version>4.0.0</version>
</dependency>
<dependency> <!-- Опционально для вебсокетов -->
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.json</artifactId>
    <version>2.0.0</version>
</dependency>

Замените ServletContainerInitializer.

import com.sun.faces.config.FacesInitializer;

ServletContainerInitializer facesInitializer = new FacesInitializer();
facesInitializer.onStartup(null, context);

Заключение

Запустить Jakarta Faces вместе со Spring Boot также просто как запустить инициалайзеры для встроенного контейнера сервлетов.

Но упаковка все усложняет.

Для WAR-пакета дополнительных настроек не требуется. Только инициалайзеры, и все работает как будто задеплоено в полноценный Tomcat. Проблема - запустить это из IDE. Запуск main-class DemoApplication из IDE - это не тоже самое, что запуск

java -jar demo.war

DX получется плохим.

Поэтому я предпочитаю собирать JAR. И зависимости класть снаружи в отдельный каталог. А при работе из IDE, я могу редактировать xhtml, java, и изменения видны без перезапуска. Надо только страницу обновить.

Ссылки

  1. Faces specification

  2. CDI specification

  3. Servlet specification

  4. Spring Boot

  5. Mojarra

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Что предпочитаете?
0% CDI 0
0% Spring 0
Никто еще не голосовал. Воздержавшихся нет.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как запускаете Spring Boot?
0% java -jar demo.jar 0
0% java -jar demo.war 0
0% Деплой на полноценный сервер. Spring Boot только для конфигурации спринга 0
Никто еще не голосовал. Воздержавшихся нет.
Источник: https://habr.com/ru/articles/736474/


Интересные статьи

Интересные статьи

Описывается, как можно инициировать автообновление клиентов Spring Cloud Config Server без использования Spring Cloud Bus или какой-либо иной вспомогательной технологии
Demo | GitHubЭксперименты с созданием редактора диаграмм на Blazor Webassembly (Blazor WebAssembly: Drag and Drop в SVG, Blazor WebAssembly: соединительные линии в SVG) показали что технология не годи...
Недавно была выпущена Java 17, и я очень рад появлению множества улучшений и новых функций. Вместо того, чтобы начинать с нового или недавнего проекта (где в этом азарт?), Мы собираемся обновить ...
Источник изображения: Shutterstock.com/photowind Добрый день, меня зовут Тараканов Анатолий, я senior java разработчик SberDevices. 2.5 года программирую на Java, до этого 6 лет писа...
Привет Хабр!Как известно, spring OAuth2.0.x переведен в режим поддержки уже почти как 2 года назад , а большая часть его функциональности теперь доступна в spring-securit...