Перевод сервиса на Spring Boot Native

September 11, 2023

Я начал двигать Проект Э в сторону микроядерной архитектуры. Пока план такой что, в оригинальном монолите останутся ядерные модули, которые, частично в силу предметной области, а частично в силу дизайна оригинального бэка, который я перенёс как есть при реинжинирнге, довольно сильно сцеплены между собой, а интеграции (которых у нас уже три и предвидится ещё пачка) мы будем делать в отдельных сервисах.

Но есть нюанс - на дев стенде у нас дефицит памяти. Поэтому я пошёл смотреть на Spring Boot Native.

Посмотрел, и в этом микропосте поделюсь впечатлениями.

Сервис

Эксперементировать, естественно, я начал на котике - сервисе, реализующим интеграцию с ЕМИАС, о котором я уже писал. В сервисе 65 продовых котлин-файлов, он выставляет один HTTP-эндпоинт и слушает одну очередь в RabbitMQ и ходит в два HTTP-эндпоинта ЕМИАСа и публикует сообщения в один топик Кафки ЕМИАСа. Так же сервис пишет данные в три таблички через Spring Data JDBC.

Сервис сделан на JDK 17, Kotlin 1.9, Spring Boot 3.1.3 и собирается Гредлом 8.2.1 с билд-файлом на Kotlin DSL.

Зависимости проекта:

implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.boot:spring-boot-starter-amqp")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-common:2.0.2")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2")
implementation("org.springframework:spring-aspects:6.0.8")
implementation("org.flywaydb:flyway-core")
implementation("org.postgresql:postgresql")
implementation("org.apache.httpcomponents.client5:httpclient5:5.2.1")

// Для публикации в Кафку ЕМИАС выдаёт либу с джарником, которая работает с Кафкой напрямую...
implementation("org.apache.kafka:kafka-clients:3.2.1")
implementation(files("lib/client-lib-4.6.0.jar"))

api("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("com.github.therapi:therapi-runtime-javadoc:0.15.0")
kapt("com.github.therapi:therapi-runtime-javadoc-scribe:0.15.0")

Так же стоит уточнить, что я работаю на Linux (Manjaro, в частности) и если вы работаете на Windows, то мой опыт будет для вас ограничено полезен.

Процесс

Первым делом почитал матчасть, выяснилось, что для сборки в натив достаточно поставить GraalVM и добавить одну строку в билд-файл.

Пока читал матчасть где-то попалось на глаза, что GraalVM проще всего поставить sdkman-ом, так и сделал:

sdk install java 22.3.2.r17-nik

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

Далее, добавил строку в билд-файл:

build.gradle.kts
plugins {
    id("org.graalvm.buildtools.native") version "0.9.24"
}

Запустил nativeCompile в Идее - взорвался из-за того что Градл запустился не на Граале. Запустил в консоле - та же фигня. Сделал фейспалм, переключил консоль на Грааль:

sdk use java 22.3.2.r17-nik

Сборка пошла.

И прошла с первого раза.

Я радостный запускаю свежескомпилинный и банрник и…​

org.apache.kafka.common.config.ConfigException: Invalid value org.apache.kafka.common.serialization.StringSerializer for configuration key.serializer: Class org.apache.kafka.common.serialization.StringSerializer could not be found.
      at org.apache.kafka.common.config.ConfigDef.parseType(ConfigDef.java:744)
      at org.apache.kafka.common.config.ConfigDef.parseValue(ConfigDef.java:490)
      at org.apache.kafka.common.config.ConfigDef.parse(ConfigDef.java:483)

Пошёл гуглить, нагуглил пост ровно об этом. Взял оттуда kafka-client-reflection.json, но как его прописать в сборку - не понятно, в посте пример для Мавена. Потыкался через подсказки кода - ничего не нашёл, пошёл дальше гуглить.

Нагуглил официальную доку по плагину GraalVM для Грэдла. Попытался добавить как в примере:

binaries {
    main {
        buildArgs.add('--link-at-build-time')
    }
}

Не скомпилировалось. Потупил, понял что это в примере Groovy DSL, а у меня Kotlin DSL - переключил вкладку, поменял main на named("main"):

tasks {
    graalvmNative {
        binaries {
            named("main") {
                buildArgs.add("-H:ReflectionConfigurationFiles=../../../src/main/resources/META-INF/native-image/kafka-client-reflection.json,../../../src/main/resources/META-INF/native-image/project-e-reflection.json")
            }
        }
    }
}

собралось, запускаю…​

Фик, та же проблема. Смотрю в файлик - там нет StringSerializer. По аналогии добавляю его, собираю (и да, каждая сборка - 3 минуты) - успех! Проект стартанул.

Дёргаю эндпоинт - работает!

Публикую сообщение в кролика и опять взрыв:

kotlin.reflect.jvm.internal.KotlinReflectionInternalError: Unresolved class: class project_e.core.diary.api.dto.ActivityEventDto
      at kotlin.reflect.jvm.internal.KClassImpl.reportUnresolvedClass(KClassImpl.kt:328)
      at kotlin.reflect.jvm.internal.KClassImpl.access$reportUnresolvedClass(KClassImpl.kt:44)
      at kotlin.reflect.jvm.internal.KClassImpl$Data$descriptor$2.invoke(KClassImpl.kt:56)
      at kotlin.reflect.jvm.internal.KClassImpl$Data$descriptor$2.invoke(KClassImpl.kt:48)
      at kotlin.reflect.jvm.internal.ReflectProperties$LazySoftVal.invoke(ReflectProperties.java:93)
      at kotlin.reflect.jvm.internal.ReflectProperties$Val.getValue(ReflectProperties.java:32)
      at kotlin.reflect.jvm.internal.KClassImpl$Data.getDescriptor(KClassImpl.kt:48)
      at kotlin.reflect.jvm.internal.KClassImpl$Data$sealedSubclasses$2.invoke(KClassImpl.kt:154)
      at kotlin.reflect.jvm.internal.KClassImpl$Data$sealedSubclasses$2.invoke(KClassImpl.kt:153)
      at kotlin.reflect.jvm.internal.ReflectProperties$LazySoftVal.invoke(ReflectProperties.java:93)
      at kotlin.reflect.jvm.internal.ReflectProperties$Val.getValue(ReflectProperties.java:32)
      at kotlin.reflect.jvm.internal.KClassImpl$Data.getSealedSubclasses(KClassImpl.kt:153)
      at kotlin.reflect.jvm.internal.KClassImpl.getSealedSubclasses(KClassImpl.kt:259)
      at com.fasterxml.jackson.module.kotlin.KotlinAnnotationIntrospector.findSubtypes(KotlinAnnotationIntrospector.kt:97)

Теперь не нашёлся код из нашей кодовой базы. Потому что у нас эвенты (записи в дневнике) представляют из себя иерархию, маппинг которой в json настроен так:

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.EXISTING_PROPERTY,
    property = "type"
)
@JsonSubTypes(
    JsonSubTypes.Type(value = ActivityEventDto::class, name = "ACTIVITY"),
    // ...
)
sealed class EventDto(
    val type: EventType
) {
    // ...
}

И не все типы напрямую используются в коде сервиса.

Завёл по аналогии ещё один файлик с рефлекшеном, прописал его в гредле, запустил сборку, покурил, публикую сообщение в кролик и успех!

Результаты

На всё про всё - обзорный гуглёж, все тупняки, перекуры на сборки, прикручивание - у меня ушло часа 4. А результы следующие:

Обычный билдБилд в натив
Время сборки*8 c.170 с.
Потребление памяти**433мб205мб
Время запуска4.5 с.0.35 с.

* Сборка без тестов

** После обработки одного HTTP-запроса и RabbitMQ-сообщения

Пока что я проделал все эти упражнения локально и впереди у нас сборка этого добра в GitLab, деплой в k8s и тщательное тестирование командой QA, но я смотрю в будущее с оптимизмом.