Веб-сервис на Spring Boot со встраиваемым приложением ReactJS
опубликовано: ⏰ 5Недавно потребовалось сделать веб-приложение – Spring + ReactJS. Обычно это два отдельных контейнера: веб-сервер, отдающий статику (NGINX, NodeJS, что-то другое) и BFF (либо сразу в API бэкенда). Но в простых случаях всё можно объединить в рамках одного сервиса, чтобы не усложнять развёртывание.
Ну или история о том, как собрать Self-contained system на мейнстрим-фреймворках.
Введение
Как и было сказано в преамбуле, не всегда требуется масштабируемый вариант с отдельными контейнерами. В простых случаях можно объединить Spring Boot + ReactJS в один сервис. Как минимум это упрощает развертывание и конфигурацию. При необходимости, их можно разделить позднее.
В таком варианте мы получаем:
- UI в backend в рамках одного проекта.
- UI и backend в одном контейнере, как одна единица развёртывания.
- Актуальный UI без каких-либо ограничений, максимально быстрая разработка за счёт огромного количества готовых компонентов и базы знаний сообщества. Плюс большие возможности развития за счёт повторного использования внутренних библиотек компонентов.
- Актуальный бэкенд, опять же под определённую задачу, с необходимыми интеграциями и возможностью использования своих наработок не оглядываясь на сторонние ограничения.
- Возможность использовать любые фреймворки, не ограничиваясь какими-либо платформами и их архитектурным дизайном. То есть, полная гибкость в выборе технологий под проект и заказчика.
Пример будет с использованием Maven + Spring Boot, Vite + ReactJS.
UI
Здесь всё довольно просто. Само приложение собирается с использованием любого фреймворка, в моём случае
был ReactJS + Vite, но это не особо важно. Важно получить папку dist со всеми статическими файлами.
Получится что-то вроде такого:
assets
├─favicon-NfJJaaRo.ico
├─index-Bkn1a5ql.js
├─index-BzxSu595.css
├─vendor-BSpWnBBm.css
└─vendor-gTVBeMpW.js
locales
├─en
└─ru
index.html
logo.pngЭти файлы необходимо собрать в виде JAR-файла, чтобы далее их можно было подключить в виде зависимости при сборке бэкенда. В случае с Maven, это можно сделать с помощью плагина exec-maven-plugin, вызвав сборку на этапе генерации исходных кодов (generate-sources).
Допустим, что наш артефакт будет собраться с координатами dev.sample.myapp:myapp-ui:0.1.0. Ниже пример конфигурации
плагина.
Конфигурация exec-maven-plugin
<build>
<resources>
<resource>
<directory>dist</directory>
<targetPath>static</targetPath>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<id>install-frontend-dependencies</id>
<phase>generate-sources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>src</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>install</argument>
</arguments>
</configuration>
</execution>
<execution>
<id>build-and-include-frontend</id>
<phase>generate-sources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>src</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>run</argument>
<argument>build</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>При сборке проекта будет собираться веб-приложение и включаться в jar-файл.
Сервер
С сервером немного сложнее, так как необходимо будет еще настроить обработку запросов к статическим ресурсам.
Настроить это можно через WebMvcConfigurer, добавив обработчики в ResourceHandlerRegistry. Ниже пример кода:
WebMvcConfigurer
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final KeycloakConfig keycloakConfig;
@Override
public void addResourceHandlers(final ResourceHandlerRegistry registry) {
this.serveDirectory(registry, "/", "classpath:/static/");
}
private void serveDirectory(
final ResourceHandlerRegistry registry, final String endpoint,
final String location
) {
final var endpointPatterns = endpoint.endsWith("/")
? new String[]{endpoint.substring(0, endpoint.length() - 1), endpoint, endpoint + "**"}
: new String[]{endpoint, endpoint + "/", endpoint + "/**"};
registry
.addResourceHandler(endpointPatterns)
.addResourceLocations(location.endsWith("/") ? location : location + "/")
.resourceChain(false)
.addResolver(new PathResourceResolver() {
@Override
public Resource resolveResource(
final HttpServletRequest request, final String requestPath,
final List<? extends Resource> locations, final ResourceResolverChain chain
) {
if (requestPath.equals("static/runtime.js")) {
return new InMemoryResource(
"window.keycloak = { url: \"%s\", realm: \"%s\", clientId: \"%s\"}"
.formatted(keycloakConfig.getUrl(), keycloakConfig.getRealm(), keycloakConfig.getClientId())
.getBytes(StandardCharsets.UTF_8),
"static/runtime.js");
}
final var resource = super.resolveResource(request, requestPath, locations, chain);
if (nonNull(resource)) {
return resource;
}
return super.resolveResource(request, "/index.html", locations, chain);
}
});
}
}Здесь мы отслеживаем все запросы к /static и проверяем, есть ли в ресурсах соответствующий файл. Если нет – возвращаем
index.html, чтобы React Router мог обработать маршрут на клиенте.
Дополнительно, как пример, используется InMemoryResource для передачи динамически формируемого JavaScript с параметрами
подключения к Keycloak. Со стороны UI, в файле index.html, необходимо включить скрипт /static/runtime.js.
InMemoryResource.java
public static final class InMemoryResource extends ByteArrayResource {
private final String filename;
private final long lastModified;
public InMemoryResource(final byte[] content, final String filename) {
super(content);
this.filename = filename;
this.lastModified = System.currentTimeMillis();
}
@Override
public long lastModified() throws IOException {
return lastModified;
}
@Override
public String getFilename() {
return filename;
}
}ℹ️ Примечание
Здесь важно переопределить методы
lastModifiedиgetFilename. По ним Spring определяет время изменения ресурса и Content-Type (из расширения)
index.html
<!DOCTYPE html>
<html lang="en">
<head>
...
<script src="/static/runtime.js"></script>
</head>
<body>
...
</body>
</html>Сборка
Чтобы наше приложение попадало в финальную сборку, необходимо подключить ранее собранный JAR как зависимость (
dev.sample.myapp:myapp-ui:0.1.0) в проект и сконфигурировать сборку приложения.
Конфигурация maven-dependency-plugin
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
<executions>
<execution>
<id>unpack-dependencies</id>
<phase>process-resources</phase>
<goals>
<goal>unpack-dependencies</goal>
</goals>
<configuration>
<includeGroupIds>org.tekblueprint.kbase</includeGroupIds>
<includeArtifactIds>kbase-ui</includeArtifactIds>
<includes>static/**</includes>
<outputDirectory>${project.build.directory}/classes</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>При запуске сборки необходимая статика будет распакована в ресурсы приложения (/static), и Spring Boot приложение
сможет использовать её как статические файлы.
Заключение
Собственно и всё. Можно собирать, например с spring-boot:build-image, и разворачивать как self-contained system (SCS).
Не хочу называть такое приложение монолитом, так как многие боятся их и это то имя, которое нельзя произносить :)
ℹ️ Примечание
Пример проекта доступен в GitHub.