Облачные технологии открыли путь к множеству новых практических областей, среди которых есть и такие, которые ранее были совершенно невозможны. Среди них выделяется бессерверная парадигма:

Бессерверные вычисления – это модель выполнения вычислений в облаке, при которой облачный провайдер предоставляет машинные ресурсы по требованию и берет на себя всю работу с серверами, на что его уполномочивает клиент. При бессерверных вычислениях ресурсы не содержатся в энергозависимой памяти; напротив, вычисления выполняются краткими всплесками, после которых результаты сбрасываются в долговременное хранилище. Когда приложение не используется, никаких ресурсов на него не выделяется. - Википедия

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

В обоих случаях, особенно в первом, срок жизни пода/контейнера невелик. Следовательно, производительность всей системы в целом сильно зависит от того, сколько времени требуется на ее запуск. Определенно это как раз та область, которой JVM не блещет.

Именно для решения этой проблемы Oracle предлагает GraalVM, содержащую опережающий AOT-компилятор, преобразующий байт-код в двоичный код. Я слежу за улучшениями GraalVM на протяжении нескольких версий, как в одиночном исполнении, так и в интеграции с Spring Boot.

Фреймворк Spring проектировался более 10 лет назад, когда подобных опасений еще не существовало. С другой стороны, пару лет назад на свет появились конкуренты Spring, в которых были взяты на вооружение как облачные технологии, так и AOT-компиляция: Micronaut и Quarkus.

Этот пост будет организован в виде туториала, в котором будет рассмотрено:

  • Создание нового проекта

  • Конфигурирование компонента

  • Конфигурирование контроллера

  • Неблокирующий HTTP-клиент

  • Параметризация

  • Тестирование

  • Интеграция с Docker

  • Генерация образа для GraalVM i

  • Т.д.

Для этой цели на Kotlin будет написано приложение, способное запрашивать Marvel API при помощи неблокирующего кода.

Marvel API

Marvel предлагает REST API, позволяющий запрашивать предлагаемые ими данные. Для этого требуется сгенерировать ключ API и закрытый ключ.

Для аутентификации необходимо передать в качестве параметров запроса следующую информацию: 

  1. Ключ API как есть

  2. Метку времени

  3. Хеш конкатенации временной метки, закрытого ключа и ключа API, рассчитанный по алгоритму MD5.

curl http://gateway.marvel.com/v1/public/comics?ts=1&apikey=1234&hash=ffd275c5130566a2916217b101f26150

Более подробная информация изложена в документации.

Создание нового проекта

Команда Spring первой предложила веб-UI через который можно сконфигурировать такой проект: UI называется Spring Initializr.

В нем можно сконфигурировать следующие параметры:

  • Сборочный инструмент: Maven или Gradle

  • Язык: Java, Kotlin или Groovy

  • Версия Spring Boot

  • Некоторые метаданные

  • Зависимости

Кроме того, в приложении предоставляется REST API для использования интерфейса командной строки и автоматизации повторяющихся задач. IntelliJ IDEA интегрируется с этим REST API, поэтому можно создать новый проект, не покидая при этом IDE.

Наконец, приложение размещено на хостинге, а нужный для этого код предоставляется на GitHub по лицензии Apache v2, поэтому вы можете клонировать и конфигурировать его. Приложение спроектировано с расчетом на расширяемость, а также допускает внесение обновлений.

Конфигурация компонента

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

Хотя, Spring и предлагает специальный DSL для компонентов, мы напишем их «традиционным» образом: при помощи аннотаций.

Для аутентификации нам потребуется MD5-дайджест сообщения. Воспользовавшись Bean DSL, можно сконфигурировать его таким образом:

@Configuration
class MarvelConfig {

    @Bean
    fun md5(): MessageDigest = MessageDigest.getInstance("MD5")
}

Spring автоматически обнаружит этот класс на этапе запуска благодаря аннотации @SpringBootApplication и инстанцирует компоненты:

@SpringBootApplication
class BootNativeApplication

Конфигурирование компонентов

Spring – первый фреймворк, в котором был введен контроллер на основе аннотаций, реализованный поверх Servlet API. С тех пор аннотации порой сталкиваются с некоторым неприятием. Именно поэтому в Spring были введены декларативные маршруты. В Kotlin работать с ними еще приятнее при помощи Route DSL:

fun routes() = router {
    GET("/") { request ->
        ServerResponse.ok().build()
    }
}

Также нужно зарегистрировать маршрутизатор как компонент:

@Configuration
class MarvelConfig {

    @Bean
    fun routes() = router {
    GET("/") { request ->
        ServerResponse.ok().build()
    }

    // Другие компоненты
}

Неблокирующий HTTP-клиент

С незапамятных времен Spring предлагал блокирующий HTTP в виде RestTemplate в рамках Web MVC. С выходом версии 5 в Spring был введен WebFlux, реактивная альтернатива Web MVC. WebFlux построен на базе Project Reactor, в основе которого, в свою очередь, лежит концепция реактивных потоков. Вероятно, вы знакомы с основополагающими примитивами Project Reactor:

  • Mono: порождает максимум один элемент

  • Flux: порождает 0..N элементов

С переходом WebFlux в Spring стали выводить из употребления RestTemplate, отдавая вместо него предпочтение реактивному WebClient. Вот как сделать вызов внутри существующего маршрута:

fun routes() = router {
    GET("/") { _ ->
        val client = WebClient.create();
        val mono = client
            .get()
            .uri("https://gateway.marvel.com:443/v1/public/characters")
            .retrieve()
            .bodyToMono<String>()
        ServerResponse.ok().body(mono)
    }
}

Мы также хотим получить некоторые параметры и обеспечить их дальнейшее распространение. Среди всего множества параметров, предлагаемых в Marvel API, я решил предоставить три: limitoffset и orderBy.

Функция GET принимает (ServerRequest) → ServerResponse в качестве второго параметра. ServerRequest предлагает queryParam(String), чтобы проверить, существует ли параметр запроса. Возвращает Java Optional. С другой стороны, UriBuilder позволяет задавать параметры запроса при помощи функции queryParam(String, String).

Между ними можно создать расширение-мостик:

fun UriBuilder.queryParamsWith(request: ServerRequest) = apply {
    arrayOf("limit", "offset", "orderBy").forEach { param ->    // 1   
        request.queryParam(param).ifPresent {            // 2      
            queryParam(param, it)                  // 3                
        }
    }
}
  1. Для каждого из параметров

  2. Если он присутствует в запросе

  3. Поставить его имя и значение в построитель URI

Теперь можно соответствующим образом выполнить вызов:

fun routes(client: WebClient, props: MarvelProperties, digest: MessageDigest) = router {
    GET("/") { request ->
        val mono = client
            .get()
            .uri {
                it.path("/characters")
                  .queryParamsWith(request)
                  .build()
            }.retrieve()
            .bodyToMono<String>()
        ServerResponse.ok().body(mono)
    }
}

Параметризация

Далее требуется параметризовать приложение: Marvel API требует, чтобы мы проходили аутентификацию, а мы не хотим жестко вшивать в код наши учетные данные. Также в целях тестирования мы хотим быстро менять URL того сервера, на который направляем запрос.

Параметризация требует определиться с двумя вещами: как передавать параметры в приложение, и как использовать в нем эти параметры.

Spring Boot предусматривает много различных способов передачи параметров. Параметры можно группировать по профилям и активировать как единое целое. В данном случае я решил установить URL сервера в файле YAML внутри приложения как значение по умолчанию, а секретные данные передавать через командную строку.

application.yml

app:
  marvel:
    server-url: https://gateway.marvel.com:443
@ConfigurationProperties("app.marvel") 	// 1     
@ConstructorBinding                  // 2      
data class MarvelProperties(
    val serverUrl: String,             // 3    
    val apiKey: String,
    val privateKey: String
)
  1. Управляем префиксом, указывающим, откуда считывать

  2. Интеграция с классом данных Kotlin

  3. Spring покладист и допускает разные регистры: шашлычный, змеиный или верблюжий

Тестирование

База кода так велика, что не особенно располагает к тестированию, в особенности модульному. Но можно добавить интеграционный тест, который гарантирует, что отклик от API без маршалинга попадет в класс, а потом с применением маршалинга будет отправлен обратно от приложения. В тестах хочется обойтись без привлечения сторонней инфраструктуры: тест не должен проваливаться в случае, когда откажет какая-то зависимость, которую мы не контролируем.

Для интеграционных тестов в классе используется аннотация @SpringBootTest:

@SpringBootTest(
    webEnvironment = WebEnvironment.RANDOM_PORT,  // 1    
    properties = [
        "app.marvel.api-key=dummy",                // 2   
        "app.marvel.private-key=dummy"                
    ]
)
class BootNativeApplicationTests
  1. Запускаем приложение на случайном порту, чтобы избежать отказа, который мог бы быть спровоцирован конфликтом портов

  2. MarvelProperties требует параметра, но при тестировании не используется. Коль скоро параметр существует, мы передаем что угодно.

TestContainer – это библиотека Java, позволяющая запускать/останавливать контейнеры Docker. Чтобы воспользоваться ею, нам всего лишь требуется снабдить класс нужной для этого аннотацией. Также мы должны сконфигурировать, какие контейнеры хотим использовать:

@Testcontainers           // 1                                  
class BootNativeApplicationTests {

    companion object {      // 2                                

        @Container        // 3                                  
        val mockServer = MockServerContainer(
            DockerImageName.parse("mockserver/mockserver")	// 4  
        )
}
  1. Интеграция с Testcontainers

  2. В Java нам нужен член static. В Kotlin он преобразуется в свойство объекта-компаньона

  3. Конфигурируем Testcontainers

  4. Используем тот образ контейнера, на который поставлена ссылка

MockServer – это контейнер, который можно использовать в качестве заглушки, так, чтобы он возвращал полезную нагрузку, зависящую от входных данных.

А теперь начинается самое интересное:

  • Чтобы начать тест, нам нужен как IP-адрес, так и порт, на который нужно передавать параметры для инициализации MavelProperties

  • Чтобы получить IP и порт, нам нужно запустить контейнер, чей жизненный цикл зависит от теста, т.e., сначала нужно запустить тест.

Эту курино-яичную проблему можно решить при помощи источников динамических свойств.

companion object {

    @JvmStatic                   //1                                                 
    @DynamicPropertySource           //2                                             
    fun registerServerUrl(registry: DynamicPropertyRegistry) {  // 3                    
        registry.add("app.marvel.server-url") {            // 4                       
            "http://${mockServer.containerIpAddress}:${mockServer.serverPort}" // 5    
        }
    }
}
  1. Требуется для совместимости с Java

  2. Магия!

  3. Тест Spring внедряется во время исполнения

  4. Добавляется свойство this…​

  5. …​с этим значением, взятым из свойства mockServer 

Теперь переходим к тестовому методу:

@Test
fun `should deserialize JSON payload from server and serialize it back again`() { //1
    val mockServerClient =
        MockServerClient(mockServer.containerIpAddress, mockServer.serverPort)    //2
    val sample = ClassPathResource("/sample.json").file.readText()             // 3    
    mockServerClient.`when`(                                     // 4                 
        HttpRequest.request()
            .withMethod("GET")
            .withPath("/v1/public/characters")
    ).respond(                    				// 5                                                
        HttpResponse()
            .withStatusCode(200)
            .withHeader("Content-Type", "application/json")
            .withBody(sample)
    )
    // код теста
}
  1. Kotlin позволяет давать тестовым методам описательные текстовые названия

  2. Создание заглушки

  3. Абстракция Spring, позволяющая ссылаться на ресурсы пути класса. sample.json – это тестовый образец.

  4. When-часть заглушки

  5. Then-часть

Перейдем к самому тесту. Spring Test предлагает WebTestClient, неблокирующий клиент для тестирования. Он позволяет параметризовать HTTP-запросы, отправлять их и получать в ответ несколько  текучих утверждений.

class BootNativeApplicationTests {

    @Autowired
    private lateinit var webTestClient: WebTestClient                      

    @Test
    fun `should deserialize JSON payload from server and serialize it back again`() { // 1
        // Код заглушки
        webTestClient.get()
            .uri("/")
            .exchange()
            .expectStatus().isOk
            .expectBody()
            .jsonPath("\$.data.count").isEqualTo(1)                        // 2
            .jsonPath("\$.data.results").isArray                           // 2
            .jsonPath("\$.data.results[0].name").isEqualTo("Anita Blake")  		// 2	
    }
}
  1. Spring Test внедряет для вас WebTestClient 

  2. Ответные утверждения

Но на данном этапе тест оканчивается провалом. Мы сконфигурировали приложение при помощи Beans DSL; а значит, должны были явно вызывать beans на этапе запуска приложения. Значит, и тест нам нужно сконфигурировать соответствующим образом, явно:

class TestConfigInitializer : ApplicationContextInitializer<GenericApplicationContext> {
    override fun initialize(context: GenericApplicationContext) {
        beans.initialize(context)
    }
}

@SpringBootTest(
    properties = [
        "context.initializer.classes=ch.frankel.blog.TestConfigInitializer" // 1
    ]
)
class BootNativeApplicationTests {
  1. Ссылка на класс инициализации

Docker и интеграция с GraalVM

В этом разделе предполагается, что вы уже знакомы с нативной версией GraalVM.

1.      Системно-зависимый бинарник: при таком подходе требуется установить на локальной машине GraalVM с расширением native-image. Получившийся в результате бинарник будет зависеть от данной конкретной системы и не будет кроссплатформенным.

Для этой цели в Spring Boot есть специальный выделенный профиль:

./mvnw -Pnative package

2.      Образ Docker: при таком подходе собирается контейнеризованная версия приложения. Для нее требуется локальная сборка образа, с применением, например, Docker. На внутрисистемном уровне она опирается на CNCF Buildpacks (но не требует pack).

Для этого Spring Boot предоставляет цель Maven:

./mvnw spring-boot:native-image

Spring Boot сам позаботится о нативной конфигурации GraalVM и соответствующего кода, а также предусмотрит большинство его зависимостей. Если после этого вам потребуется дополнительно настроить конфигурацию, пожалуйста, пользуйтесь стандартными конфигурационными файлами, напр., e.g./META-INF/native-image/<groupId>/<artifactId>/reflect-config.json.

В качестве альтернативы Spring предлагает выполнить конфигурацию на основе аннотаций. Давайте это сделаем:

@SpringBootApplication
@NativeHint(options = ["--enable-https"])         // 1                     
@TypeHint(
    types = [
        Model::class, Data::class, Result::class, Thumbnail::class,
        Collection::class, Resource::class, Url::class, URI::class
    ],
    access = AccessBits.FULL_REFLECTION        // 2                        
)
class BootNativeApplication
  1. Сохранить код, относящийся к TLS

  2. Сохранить классы и разрешить рефлексию во время выполнения

При применении второго подхода получим следующий результат:

REPOSITORY      TAG       IMAGE ID         CREATED         SIZE
native-boot     1.0       c9284b7f99a6     41 years ago    104MB

Если подробнее рассмотреть этот образ, то найдем в нем следующие слои:

┃ ● Layers ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Cmp   Size  Command
     17 MB  FROM c09932ee5c22aa1     // 1           
     268 B                           // 2           
    3.4 MB                           // 3       
     81 MB                           // 4           
    2.5 MB                           // 5           
     12 kB
       0 B                            // 6          
  1. Родительский образ

  2. Системные разрешения

  3. Сборки Paketo, сертификаты CA

  4. Наш нативный бинарник

  5. Исходно облачный двоичный пусковой файл

  6. Псевдонимы пускового файла

Сгенерированный образ принимает параметры, поэтому работа идет точно так же, как если бы вы запускали приложение Java через командную строку.

docker run -it -p8080:8080 native-boot:1.0 --app.marvel.apiKey=xyz --app.marvel.privateKey=abc --logging.level.root=DEBUG

Теперь можно отправлять запросы, чтобы поиграть с приложением:

curl localhost:8080
curl 'localhost:8080?limit=1'
curl 'localhost:8080?limit=1&offset=50'

Заключение

Spring обладает долгой историей работы с шаблонным кодом, которую он берет на себя, тем самым позволяя разработчикам сосредоточиться на коде, важном для бизнес-логики. В последние годы Spring успешно интегрировался с языком Kotlin, и такая работа стала строиться фантастически удобно.

Да, с распространением облаков экосистеме Spring пришлось подстраиваться под работу с нативной виртуальной машиной GraalVM. Здесь до сих пор есть что улучшить, но с работой такая экосистема справляется.

Спасибо Себастьену Делезу за то, что отрецензировал оригинал этого поста.

Весь исходный код к этому посту выложен на Github в формате для Maven.

Комментарии (0)