Не Spring Boot’ом единым: обзор альтернатив

almw1ghoqrvuf2nbw_awcwqvqe4.png

В настоящее время нет недостатка во фреймворках для создания микросервисов на Java и Kotlin.

В статье рассматриваются следующие:


На их основе созданы четыре сервиса, которые могут взаимодействовать друг с другом посредством HTTP API с использованием паттерна Service Discovery, реализованного с помощью Consul. Таким образом они формируют гетерогенную (на уровне фреймворков) микросервисную архитектуру (далее МСА):

tvmvkpurtljgpl3yhxhtpmse4_g.png

Определим набор требований к каждому сервису:

  • стек технологий:
    • JDK 12;
    • Kotlin;
    • Gradle (Kotlin DSL);
    • JUnit 5.
  • функциональность (HTTP API):
    • GET /application-info{?request-to=some-service-name}
      Возвращает некоторую базовую информация о микросервисе (название, фреймворк, год релиза фреймворка); при указании в параметре request-to названия одного из четырёх микросервисов к его HTTP API выполняется аналогичный запрос, возвращающий базовую информацию;
    • GET /application-info/logo
      Возвращает изображение.
  • реализация:
    • настройка с использованием конфигурационного файла;
    • использование внедрения зависимостей;
    • тесты, проверяющие работоспособность HTTP API.
  • МСА:
    • использование паттерна Service Discovery (регистрация в Consul, обращение к HTTP API другого микросервиса по его названию с использованием клиентской балансировки нагрузки);
    • формирование артефакта uber-JAR.


Далее рассматривается реализация микросервиса на каждом из фреймворков и сравниваются параметры полученных приложений.

Helidon service


Каркас разработки был создан в Oracle для внутреннего использования, впоследствии став open-source«ным. Существует две модели разработки на основе этого фреймворка: Standard Edition (SE) и MicroProfile (MP). В обоих случаях сервис будет обычной Java SE программой. Подробнее о различиях можно узнать на этой странице.

Если коротко, то Helidon MP — это одна из реализаций Eclipse MicroProfile, что даёт возможность использования множества API, как ранее известных разработчикам на Java EE, так и более новых (Health Check, Metrics, Fault Tolerance и т. д.). В варианте Helidon SE разработчики руководствовались принципом «No magic», что выражается, в частности, в меньшем количестве или полном отсутствии аннотаций, необходимых для создания приложения.

Для разработки микросервиса выбран Helidon SE. Помимо прочего в нём отсутствуют средства для реализации Dependency Injection, поэтому для внедрения зависимостей использован Koin. Далее приведён класс, содержащий main-метод. Для реализации Dependency Injection класс наследуется от KoinComponent. Сначала стартует Koin, далее инициализируются требуемые зависимости и вызывается метод startServer(), где создаётся объект типа WebServer, которому предварительно передаётся конфигурация приложения и настройка роутинга; после старта приложение регистрируется в Consul:

object HelidonServiceApplication : KoinComponent {

   @JvmStatic
   fun main(args: Array) {
       val startTime = System.currentTimeMillis()
       startKoin {
           modules(koinModule)
       }

       val applicationInfoService: ApplicationInfoService by inject()
       val consulClient: Consul by inject()
       val applicationInfoProperties: ApplicationInfoProperties by inject()
       val serviceName = applicationInfoProperties.name

       startServer(applicationInfoService, consulClient, serviceName, startTime)
   }
}

fun startServer(
   applicationInfoService: ApplicationInfoService,
   consulClient: Consul,
   serviceName: String,
   startTime: Long
): WebServer {
   val serverConfig = ServerConfiguration.create(Config.create().get("webserver"))

   val server: WebServer = WebServer
       .builder(createRouting(applicationInfoService))
       .config(serverConfig)
       .build()

   server.start().thenAccept { ws ->
       val durationInMillis = System.currentTimeMillis() - startTime
       log.info("Startup completed in $durationInMillis ms. Service running at: http://localhost:" + ws.port())
       // register in Consul
       consulClient.agentClient().register(createConsulRegistration(serviceName, ws.port()))
   }

   return server
}


Роутинг настраивается следующим образом:

private fun createRouting(applicationInfoService: ApplicationInfoService) = Routing.builder()
   .register(JacksonSupport.create())
   .get("/application-info", Handler { req, res ->
       val requestTo: String? = req.queryParams()
           .first("request-to")
           .orElse(null)

       res
           .status(Http.ResponseStatus.create(200))
           .send(applicationInfoService.get(requestTo))
   })
   .get("/application-info/logo", Handler { req, res ->
       res.headers().contentType(MediaType.create("image", "png"))
       res
           .status(Http.ResponseStatus.create(200))
           .send(applicationInfoService.getLogo())
   })
   .error(NotFoundException::class.java) { req, res, ex ->
       log.error("NotFoundException:", ex)
       res.status(Http.Status.BAD_REQUEST_400).send()
   }
   .error(Exception::class.java) { req, res, ex ->
       log.error("Exception:", ex)
       res.status(Http.Status.INTERNAL_SERVER_ERROR_500).send()
   }
   .build()


В приложении используется конфиг в формате HOCON:

webserver {
 port: 8081
}

application-info {
 name: "helidon-service"
 framework {
   name: "Helidon SE"
   release-year: 2019
 }
}


Для конфигурирования возможно также использовать файлы в форматах JSON, YAML и properties (подробнее здесь).

Ktor service


Фреймворк написан на Kotlin. Новый проект можно создать несколькими способами: используя систему сборки, start.ktor.io или плагин к IntelliJ IDEA (подробнее здесь).

Как и в Helidon SE, в Ktor отсутствует DI «из коробки», поэтому перед стартом сервера с помощью Koin осуществляется внедрение зависимостей:

val koinModule = module {
   single { ApplicationInfoService(get(), get()) }
   single { ApplicationInfoProperties() }
   single { MicronautServiceClient(get()) }
   single { Consul.builder().withUrl("http://localhost:8500").build() }
}

fun main(args: Array) {
   startKoin {
       modules(koinModule)
   }
   val server = embeddedServer(Netty, commandLineEnvironment(args))
   server.start(wait = true)
}


Необходимые приложению модули указываются в конфигурационном файле (возможно использование только формата HOCON; подробнее о конфигурировании Ktor-сервера здесь), содержимое которого представлено ниже:

ktor {
 deployment {
   host = localhost
   port = 8082
   watch = [io.heterogeneousmicroservices.ktorservice]
 }
 application {
   modules = [io.heterogeneousmicroservices.ktorservice.module.KtorServiceApplicationModuleKt.module]
 }
}

application-info {
 name: "ktor-service"
 framework {
   name: "Ktor"
   release-year: 2018
 }


В Ktor и Koin используется термин «модуль», обладающий при этом разными значениями. В Koin модуль — это аналог контекста приложения в Spring Framework. Модуль Ktor — это определённая пользователем функция, которая принимает объект типа Application и может осуществлять конфигурирование пайплайна, установку фич (features), регистрацию роутов, обработку
запросов и т. д.:

fun Application.module() {
   val applicationInfoService: ApplicationInfoService by inject()

   if (!isTest()) {
       val consulClient: Consul by inject()
       registerInConsul(applicationInfoService.get(null).name, consulClient)
   }

   install(DefaultHeaders)
   install(Compression)
   install(CallLogging)
   install(ContentNegotiation) {
       jackson {}
   }

   routing {
       route("application-info") {
           get {
               val requestTo: String? = call.parameters["request-to"]
               call.respond(applicationInfoService.get(requestTo))
           }
           static {
               resource("/logo", "logo.png")
           }
       }
   }
}


В этом фрагменте кода настраивается роутинг запросов, в частности, статический ресурс logo.png. Ktor-сервис может содержать фичи. Фича — это функциональность, встраиваемая в пайплайн запрос-ответ (DefaultHeaders, Compression и другие в примере кода выше). Возможна реализация собственных фич, например, ниже приведён код, имплементирующий паттерн Service Discovery в сочетании с клиентской балансировкой нагрузки на основе алгоритма Round-robin:

class ConsulFeature(private val consulClient: Consul) {

   class Config {
       lateinit var consulClient: Consul
   }

   companion object Feature : HttpClientFeature {

       var serviceInstanceIndex: Int = 0

       override val key = AttributeKey("ConsulFeature")

       override fun prepare(block: Config.() -> Unit) = ConsulFeature(Config().apply(block).consulClient)

       override fun install(feature: ConsulFeature, scope: HttpClient) {
           scope.requestPipeline.intercept(HttpRequestPipeline.Render) {
               val serviceName = context.url.host
               val serviceInstances =
                   feature.consulClient.healthClient().getHealthyServiceInstances(serviceName).response
               val selectedInstance = serviceInstances[serviceInstanceIndex]
               context.url.apply {
                   host = selectedInstance.service.address
                   port = selectedInstance.service.port
               }
               serviceInstanceIndex = (serviceInstanceIndex + 1) % serviceInstances.size
           }
       }
   }
}


Основная логика находится в методе install: во время фазы запроса Render (которая выполняется перед фазой Send) сначала определяется название вызываемого сервиса, далее у consulClient запрашивается список инстансов этого сервиса, после чего вызывается инстанс, определённый с помощью алгоритма Round-robin. Таким образом становится возможным следующий вызов:

fun getApplicationInfo(serviceName: String): ApplicationInfo = runBlocking {
   httpClient.get("http://$serviceName/application-info")
}


Micronaut service


Micronaut разрабатывается создателями фреймворка Grails и вдохновлён опытом построения сервисов с использованием Spring, Spring Boot и Grails. Фреймворк является полиглотом, поддерживая языки Java, Kotlin и Groovy; возможно, будет поддержка Scala. Внедрение зависимостей в Micronaut осуществляется на этапе компиляции, что приводит к меньшему потреблению памяти и более быстрому запуску приложения по сравнению со Spring Boot.

Main-класс имеет следующий вид:

object MicronautServiceApplication {

   @JvmStatic
   fun main(args: Array) {
       Micronaut.build()
           .packages("io.heterogeneousmicroservices.micronautservice")
           .mainClass(MicronautServiceApplication.javaClass)
           .start()
   }
}


Некоторые компоненты приложения на основе Micronaut похожи на свои аналоги в приложении на Spring Boot, например, ниже приведён код контроллера:

@Controller(
   value = "/application-info",
   consumes = [MediaType.APPLICATION_JSON],
   produces = [MediaType.APPLICATION_JSON]
)
class ApplicationInfoController(
   private val applicationInfoService: ApplicationInfoService
) {

   @Get
   fun get(requestTo: String?): ApplicationInfo = applicationInfoService.get(requestTo)

   @Get("/logo", produces = [MediaType.IMAGE_PNG])
   fun getLogo(): ByteArray = applicationInfoService.getLogo()
}


Поддержка Kotlin в Micronaut реализована на основе плагина компилятора kapt (подробнее здесь). Сборочный скрипт при этом конфигурируется так:

plugins {
   ...
   kotlin("kapt")
   ...
}

dependencies {
   kapt("io.micronaut:micronaut-inject-java")
   ...
   kaptTest("io.micronaut:micronaut-inject-java")
   ...
}


Далее показано содержимое конфигурационного файла:

micronaut:
 application:
   name: micronaut-service
 server:
   port: 8083

consul:
 client:
   registration:
     enabled: true

application-info:
 name: ${micronaut.application.name}
 framework:
   name: Micronaut
   release-year: 2018 


Конфигурирование микросервиса возможно также файлами форматов JSON, properties и Groovy (подробнее здесь).

Spring Boot service


Фреймворк был создан с целью упростить разработку приложений, использующих экосистему Spring Framework. Это достигается посредством механизмов автоконфигурации при подключении библиотек. Ниже приведён код контроллера:

@RestController
@RequestMapping(path = ["application-info"], produces = [MediaType.APPLICATION_JSON_UTF8_VALUE])
class ApplicationInfoController(
   private val applicationInfoService: ApplicationInfoService
) {

   @GetMapping
   fun get(@RequestParam("request-to") requestTo: String?): ApplicationInfo = applicationInfoService.get(requestTo)

   @GetMapping(path = ["/logo"], produces = [MediaType.IMAGE_PNG_VALUE])
   fun getLogo(): ByteArray = applicationInfoService.getLogo()
}


Микросервис конфигурируется файлом формата YAML:

spring:
 application:
   name: spring-boot-service

server:
 port: 8084

application-info:
 name: ${spring.application.name}
 framework:
   name: Spring Boot
   release-year: 2014


Также для конфигурирования возможно использовать файлы формата properties (подробнее здесь).

Запуск


Проект работает на JDK 12, хотя, вероятно, и на 11-й версии тоже, требуется только соответствующим образом поменять в сборочных скриптах параметр jvmTarget:

withType {
   kotlinOptions {
       jvmTarget = "12"
       ...
   }
}


Перед запуском микросервисов нужно установить Consul и запустить агент — например, так: consul agent -dev.

Запуск микросервисов возможен из:


После старта всех микросервисов на http://localhost:8500/ui/dc1/services вы увидите:

qilmbv1op4a6xgy6b31tnsfjpmm.png

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


В качестве примера приведены результаты тестирования API Helidon service:

  1. GET http://localhost:8081/application-info
    {
      "name": "helidon-service",
      "framework": {
    	"name": "Helidon SE",
    	"releaseYear": 2019
      },
      "requestedService": null
    }
  2. GET http://localhost:8081/application-info?requestTo=ktor-service
    {
      "name": "helidon-service",
      "framework": {
    	"name": "Helidon SE",
    	"releaseYear": 2019
      },
      "requestedService": {
    	"name": "ktor-service",
    	"framework": {
      	"name": "Ktor",
      	"releaseYear": 2018
    	},
    	"requestedService": null
      }
    }
  3. GET http://localhost:8081/application-info/logo
    Возвращает изображение.


Протестировать API произвольного микросервиса можно с помощью Postman (коллекция запросов), IntelliJ IDEA HTTP client (коллекция запросов), браузера или другого инструмента. В случае использования первых двух клиентов требуется указать порт вызываемого микросервиса в соответствующей переменной (в Postman она находится в меню коллекции → Edit → Variables, а в HTTP Client — в переменной среды, указываемой в этом файле), а при тестировании метода 2) API также нужно указать название запрашиваемого «под капотом» микросервиса. Ответы при этом будут аналогичны приведённым выше.

Сравнение параметров приложений


Размер артефакта

C целью сохранения простоты настройки и запуска приложений в сборочных скриптах не были исключены какие-либо транзитивные зависимости, поэтому размер uber-JAR сервиса на Spring Boot значительно превышает размеры аналогов на других фреймворках (т. к. при использовании стартеров импортируются не только нужные зависимости; при желании размер можно существенно уменьшить):


Время запуска
Время запуска каждого приложения непостоянно и попадает в некоторое «окно»; в таблице ниже приведено время запуска артефакта без указания каких-либо дополнительных параметров:
Стоит отметить, что если «почистить» приложение на Spring Boot от ненужных зависимостей и уделить внимание настройке запуска приложения (например, сканировать только нужные пакеты и использовать ленивую инициализацию бинов), то можно значительно сократить время запуска.

Нагрузочное тестирование
Для проведения тестирования были использованы Gatling и скрипт на Scala. Генератор нагрузки и тестируемый сервис были запущены на одной машине (Windows 10, четырёхъядерный процессор 3,2 ГГц, 24 Гбайт RAM, SSD). Порт этого сервиса указывается в Scala-скрипте.

Для каждого микросервиса определяется:

  • минимальный объём heap-памяти (-Xmx), необходимый для запуска работоспособного (отвечающего на запросы) микросервиса
  • минимальный объём heap-памяти, необходимый для прохождения нагрузочного теста 50 пользователей * 1000 запросов
  • минимальный объём heap-памяти, необходимый для прохождения нагрузочного теста 500 пользователей * 1000 запросов


Под прохождением нагрузочного теста понимается то, что микросервис ответил на все запросы за любое время.
Стоит заметить, что все микросервисы используют HTTP-сервер Netty.

Заключение


Поставленную задачу — создание простого сервиса с HTTP API и возможностью функционировать в МСА — удалось выполнить на всех рассматриваемых фреймворках. Пришло время подвести итоги и рассмотреть их плюсы и минусы.

Helidоn
Standard Edition

  • плюсы
    • параметры приложения
      По всем параметрам показал хорошие результаты и отлично справился с нагрузочным тестированием;
    • «no magic»
      Фреймворк оправдал заявленный разработчиками принцип: для создания приложения потребовалась всего одна аннотация (@JvmStatic — для интеропа Java-Kotlin).
  • минусы
    • микрофреймворк
      Отсутствуют «из коробки» некоторые необходимые для промышленной разработки компоненты, например, внедрение зависимостей и реализация Service Discovery.


MicroProfile
Микросервис на этом фреймворке реализован не был, поэтому отмечу лишь пару известных мне пунктов:

  • плюсы
    • имплементация Eclipse MicroProfile
      По сути, MicroProfile — это Java EE, оптимизированная для МСА. Таким образом, во-первых, вы получаете доступ ко всему многообразию Java EE API, в том числе, разработанному специально для МСА, во-вторых, вы можете изменить имплементацию MicroProfile на любую другую (Open Liberty, WildFly Swarm и т. д.).
  • дополнительно
    • на MicroProfile Starter вы можете с нуля создать проект с нужными параметрами по аналогии с похожими инструментами для других фреймворков (например, Spring Initializr). На момент написания статьи Helidon реализует MicroProfile 1.2, тогда как последняя версия спецификации — 2.2.


Ktor

  • плюсы
  • минусы
    • «заточен» под Kotlin, то есть, разрабатывать на Java, вроде, можно, но не нужно;
    • микрофреймворк (см. аналогичный пункт для Helidon SE).
  • дополнительно
    С одной стороны, концепция разработки на фреймворке не входит в две наиболее популярных модели разработки на Java (Spring-подобную (Spring Boot/Micronaut) и Java EE/MicroProfile), что может привести к:
    • проблеме с поиском специалистов;
    • увеличению времени на выполнение задач по сравнению со Spring Boot из-за необходимости явного конфигурирования требуемой функциональности.

    С другой, непохожесть на «классические» Spring и Java EE позволяет взглянуть на процесс разработки под другим углом, возможно, более осознанно.


Micronaut

  • плюсы
    • AOT
      Как ранее было отмечено, AOT позволяет уменьшить время старта и потребляемую приложением память по сравнению с аналогом на Spring Boot;
    • Spring-подобная модель разработки
      У программистов с опытом разработки на Spring не займёт много времени освоение этого фреймворка;
    • проект Micronaut for Spring позволяет в том числе изменить среду выполнения имеющегося Spring Boot приложения на Micronaut (с ограничениями);
    • параметры приложения
      Хорошие результаты по всем параметрам;
    • полиглот
      Поддержка на уровне first-class citizen языков Java, Kotlin, Groovy; возможно, будет поддержка Scala. На мой взгляд, это может положительно повлиять на рост сообщества. К слову, на июнь 2019 Groovy в рейтинге популярности языков программирования TIOBE занимает 14-е место, взлетев с 60-го годом ранее, таким образом, находясь на почётном втором месте среди JVM-языков.


Spring Boot

  • плюсы
    • зрелость платформы и экосистема
      Фреймворк «на каждый день». Для бОльшей части повседневных задач уже есть решение в парадигме программирования Spring, т. е. привычным для многих программистов способом. Разработку упрощают концепции стартеров и автоконфигураций;
    • наличие большого количества специалистов на рынке труда, а также значительная база знаний (включая документацию и ответы на Stack Overflow);
    • перспектива
      Думаю, многие согласятся, что в ближайшем будущем Spring останется лидирующим каркасом разработки.
  • минусы
    • параметры приложения
      Приложение на этом фреймворке не было в числе лидеров, однако некоторые параметры, как было отмечено ранее, могут быть оптимизированы самостоятельно. Также стоит вспомнить о наличии находящегося в активной разработке проекта Spring Fu, использование которого позволяет уменьшить эти параметры.


Также можно выделить общие проблемы, связанные с новыми фреймворками и отсутствующие у Spring Boot:

  • менее развитая экосистема;
  • малое количество специалистов с опытом работы с этими технологиями;
  • большее время выполнения задач;
  • неясные перспективы.


Рассмотренные фреймворки принадлежат к разным весовым категориям: Helidon SE и Ktor — это микрофреймворки, Spring Boot — full-stack фреймворк, Micronaut, скорее, тоже full-stack; ещё одна категория — MicroProfile (например, Helidon MP). В микрофреймворках функциональность ограничена, что может замедлить выполнение задач; для уточнения возможности реализации той или иной функциональности на основе какого-либо каркаса разработки рекомендую ознакомиться с его документацией.

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

В то же время, как было показано в статье, новые фреймворки выигрывают у Spring Boot по рассмотренным параметрам полученных приложений. Если для какого-то из ваших микросервисов критически важны какие-либо из этих параметров, то, возможно, стоит обратить внимание на фреймворки, показавшие по ним лучшие результаты. Однако, не стоит забывать, что Spring Boot, во-первых, продолжает совершенствоваться, во-вторых, имеет огромную экосистему и с ним знакомы значительное количество Java-программистов. Есть и другие фреймворки, не освещённые в настоящей статье: Javalin, Quarkus и т. д.

С кодом проекта вы можете ознакомиться на GitHub. Благодарю за внимание!

P.S.: Спасибо artglorin за помощь в подготовке статьи.

© Habrahabr.ru