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, и изменения видны без перезапуска. Надо только страницу обновить.