Веб-сервис на 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
XML

<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
Java
@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
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
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
XML
<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.

Java Spring-Boot Reactjs Self-Contained System Devmix