January 10, 2020

Not only Spring Boot: a review of alternatives

Preface

At present, there is no lack of frameworks for creating miсroservices in Java and Kotlin. The following are being considered in the article:

Name Version Year of a first release Developer GitHub

Helidon SE

1.4.1

2019

Oracle

link

Ktor

1.3.0

2018

JetBrains

link

Micronaut

1.2.9

2018

Object Computing

link

Quarkus

1.2.0

2019

Red Hat

link

Spring Boot

2.2.4

2014

Pivotal

link

Based on them, four services were created that can interact with each other through the HTTP API using the Service Discovery pattern implemented by Consul. Thus, they form a heterogeneous (at the level of frameworks) microservice architecture (hereafter referred to as MSA):

target architecture

In this article, the implementation of microservice on each of the frameworks is briefly considered (for more details check monorepo’s source code on GitHub: https://github.com/rkudryashov/heterogeneous-microservices) and the parameters of the obtained applications are compared.

Let’s define a set of requirements for each service:

  • technology stack:

    • JDK 13

    • Kotlin

    • Gradle (Kotlin DSL)

    • JUnit 5

  • functionality (HTTP API):

    • GET /application-info{?request-to=some-service-name}

      Returns some basic information about microservice (name, framework, year of release of the framework); when you specify in the request-to parameter name of another microservice then an analogous request is executed to its HTTP API

    • GET /application-info/logo

      Returns an image

  • implementation:

    • configuration using a text file

    • using of Dependency Injection

    • tests that check the HTTP API is working properly

  • MSA:

    • using of Service Discovery pattern (registration in Consul, request to the HTTP API of another microservice by its name with the client-side load balancing)

    • building an uber-JAR artifact

Creating an application from scratch

To generate a new project on one of the frameworks you can use web starter or other options (for instance, build tool or IDE) considered in a guide:

Name Web starter Guide Supported languages

Helidon

link (MP)

link (SE)

link (MP)

Java, Kotlin

Ktor

link

link

Kotlin

Micronaut

- (CLI)

link

Groovy, Java, Kotlin

Quarkus

link

link

Java, Kotlin, Scala

Spring Boot

link

link

Groovy, Java, Kotlin

Helidon service

The framework was created in Oracle for internal use, subsequently becoming open-source. There are two development models based on this framework: Standard Edition (SE) and MicroProfile (MP). In both cases, the service will be a regular Java SE program. Learn more about the differences on this page.

In short, Helidon MP is one of the implementations of Eclipse MicroProfile, which makes it possible to use many APIs, both previously known to Java EE developers (JAX-RS, CDI, for example) and newer ones (Health Check, Metrics, Fault Tolerance, etc.). In the Helidon SE model, the developers were guided by the principle of “No magic”, which is expressed, in particular, in the smaller number or complete absence of annotations necessary to create the application.

Helidon SE was selected for the development of microservice. Among other things, it lacks the means for Dependency Injection, so for this purpose, Koin was used. The following is the class containing the main method. To implement Dependency Injection, the class is inherited from KoinComponent. First, Koin starts, then the required dependencies are initialized and startServer() method is called, where an object of the WebServer type is created to which the application configuration and routing settings are passed; after starting the application is registered in Consul:

object HelidonServiceApplication : KoinComponent {

    @JvmStatic
    fun main(args: Array<String>) {
        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
}

Routing is configured as follows:

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(Exception::class.java) { req, res, ex ->
        log.error("Exception:", ex)
        res.status(Http.Status.INTERNAL_SERVER_ERROR_500).send()
    }
    .build()

The application uses the config in HOCON format:

webserver {
  port: 8081
}

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

It is also possible to use files in the formats JSON, YAML, and properties for configuration (in more detail on Helidon config docs).

Ktor service

The framework is written in and designed for Kotlin. As in Helidon SE, Ktor hasn’t DI out of the box, so before starting the server dependencies should be injected using Koin:

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

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

The modules needed by the application are specified in the configuration file (only the HOCON format is possible; more details on configuring the Ktor server on Ktor config docs), content of which is presented below:

ktor {
  deployment {
    host = localhost
    port = 8082
    environment = prod
    // for dev purpose
    autoreload = true
    watch = [io.heterogeneousmicroservices.ktorservice]
  }
  application {
    modules = [io.heterogeneousmicroservices.ktorservice.module.KtorServiceApplicationModuleKt.module]
  }
}

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

In Ktor and Koin the term “module” is used with different meanings. In Koin, a module is an analog of the application context in the Spring Framework. The Ktor module is a user-defined function that accepts an object of type Application and can configure the pipeline, install features, register routes, process requests, etc.:

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")
            }
        }
    }
}

This code snippet configures the routing of requests, in particular, the static resource logo.png.

Ktor-service may contain features. A feature is a functionality that is embedded in the request-response pipeline (DefaultHeaders, Compression, and others in the example code above). You can implement own features, for example, below is the code implementing the Service Discovery pattern in conjunction with client-side load balancing based on the Round-robin algorithm:

class ConsulFeature(private val consulClient: Consul) {

    class Config {
        lateinit var consulClient: Consul
    }

    companion object Feature : HttpClientFeature<Config, ConsulFeature> {
        var serviceInstanceIndex: Int = 0

        override val key = AttributeKey<ConsulFeature>("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
            }
        }
    }
}

The main logic is in install method: in a time of Render request phase (which is performed before Send phase) firstly a name of a called service is determined, then at consulClient list of instances of the service is requested, then an instance defined by Round-robin algorithm is calling. Thus, the following call becomes possible:

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

Micronaut service

Micronaut is developed by the creators of the Grails framework and inspired by the experience of building services using Spring, Spring Boot, and Grails. The framework supports Java, Kotlin, and Groovy languages; maybe it will be Scala support. Dependencies are injected at compile time, which results in less memory consumption and faster application launch compared to Spring Boot.

The main class looks like this:

object MicronautServiceApplication {

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

Some components of an application based on Micronaut are similar to their counterparts in the Spring Boot application, for example, below is the controller code:

@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()
}

Support for Kotlin in Micronaut is built upon the kapt compiler plugin (more details on Micronaut Kotlin guide). The build script is configured as follows:

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

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

The following is the contents of the configuration file:

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, and Groovy file formats can also be used for configuration (in more detail on Micronaut config guide).

Quarkus service

The framework is introduced as a tool appropriate to present challenges such as new deployment environments and application architectures; application written on the framework will have low memory consumption and boot time. Also, there are benefits for a developer, for instance, live reload out of the box.

There is no main method in the source code of your Quarkus application, but maybe somewhen it will be (issue on GitHub).

Controller looks very typical for those who are familiar with Spring or Java EE:

@Path("/application-info")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
class ApplicationInfoResource(
    @Inject private val applicationInfoService: ApplicationInfoService
) {

    @GET
    fun get(@QueryParam("request-to") requestTo: String?): Response =
        Response.ok(applicationInfoService.get(requestTo)).build()

    @GET
    @Path("/logo")
    @Produces("image/png")
    fun logo(): Response = Response.ok(applicationInfoService.getLogo()).build()
}

As you can see beans are injected by @Inject annotation, and for injected bean you might specify a scope, for instance:

@ApplicationScoped
class ApplicationInfoService(
    ...
) {
...
}

Creating of REST clients to other services is as simple as creating an interface using the proper JAX-RS and MicroProfile annotations:

@ApplicationScoped
@Path("/")
interface ExternalServiceClient {
    @GET
    @Path("/application-info")
    @Produces("application/json")
    fun getApplicationInfo(): ApplicationInfo
}

@RegisterRestClient(baseUri = "http://helidon-service")
interface HelidonServiceClient : ExternalServiceClient

@RegisterRestClient(baseUri = "http://ktor-service")
interface KtorServiceClient : ExternalServiceClient

@RegisterRestClient(baseUri = "http://micronaut-service")
interface MicronautServiceClient : ExternalServiceClient

@RegisterRestClient(baseUri = "http://quarkus-service")
interface QuarkusServiceClient : ExternalServiceClient

@RegisterRestClient(baseUri = "http://spring-boot-service")
interface SpringBootServiceClient : ExternalServiceClient

As you can see for the baseUri parameter services' names are used. But now there is no built-in support of Service Discovery (Eureka) or it doesn’t work properly (Consul) because the framework mainly targets cloud environments. Thus, as in Helidon and Ktor services, Consul Client for Java library is used. At first, it is needed to register the application:

@ApplicationScoped
class ConsulRegistrationBean(
    @Inject private val consulClient: ConsulClient
) {

    fun onStart(@Observes event: StartupEvent) {
        consulClient.register()
    }
}

Then it is needed to resolve services' names to its particular location; the resolution is implemented simply by replacement URI of requestContext with the location of a service obtained from Consul client:

@Provider
@ApplicationScoped
class ConsulFilter(
    @Inject private val consulClient: ConsulClient
) : ClientRequestFilter {

    override fun filter(requestContext: ClientRequestContext) {
        val serviceName = requestContext.uri.host
        val serviceInstance = consulClient.getServiceInstance(serviceName)
        val newUri: URI = URIBuilder(URI.create(requestContext.uri.toString()))
            .setHost(serviceInstance.address)
            .setPort(serviceInstance.port)
            .build()

        requestContext.uri = newUri
    }
}

Quarkus supports configuration via properties or YAML files (more details on Quarkus config guide).

Spring Boot service

The framework was created to simplify the development of applications using the Spring Framework ecosystem. This is achieved through auto-configuration mechanisms for used libraries.

The following is the controller code:

@RestController
@RequestMapping(path = ["application-info"], produces = [MediaType.APPLICATION_JSON_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()
}

The microservice is configured by a YAML file:

spring:
  application:
    name: spring-boot-service

server:
  port: 8085

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

It is also possible to use properties files for configuration (more on Spring Boot config docs).

Launch

Before launching microservices, you need to install Consul and start the agent — for example, like this: consul agent -dev.

You can start microservices from:

  • IDE

    Users of IntelliJ IDEA may see something like the following:

    services dashboard

    To launch Quarkus service you need to start quarkusDev Gradle task.

  • console

    Execute in the project’s root folder:

    java -jar helidon-service/build/libs/helidon-service-all.jar

    java -jar ktor-service/build/libs/ktor-service-all.jar

    java -jar micronaut-service/build/libs/micronaut-service-all.jar

    java -jar quarkus-service/build/quarkus-service-1.0.0-runner.jar

    java -jar spring-boot-service/build/libs/spring-boot-service.jar

After starting of all microservices on http://localhost:8500/ui/dc1/services you will see:

consul screen

API testing

As an example, the results of testing the API of Helidon service:

You can test API of any microservice using Postman (collection of requests), IntelliJ IDEA HTTP client (collection of requests), browser or other tools. In the case of using the first two clients, you need to specify the port of the called microservice in the corresponding variable (in Postman it is in the menu of the collectionEditVariables, and in the HTTP Client — in the environment variable specified in this file). When testing the second method of the API you also need to specify the name of the microservice requested “under the hood”. The answers will be similar to those given above.

Comparison of applications' parameters

After releases of the new versions of the frameworks, the following results (which are, of course, unscientific and can be reproduced only on my machine) will inevitably become outdated; you can check updated results by yourself using this GitHub project and the frameworks' new versions (which are specified in gradle.properties).

Artifact size

To preserve the simplicity of setting up applications, no transitive dependencies were excluded from the build scripts, therefore the size of the Spring Boot service uber-JAR significantly exceeds the size of analogs on other frameworks (since using starters not only necessary dependencies are imported; if desired, the size can be significantly reduced):

Microservice Artifact size, MB

Helidon service

17,3

Ktor service

22,4

Micronaut service

17,1

Quarkus service

24,4

Spring Boot service

45,2

Start time

The start time of each application is inconstant and falls into some “window”; the table below shows the start time of the artifact without specifying any additional parameters:

Microservice Start time, seconds

Helidon service

2,0

Ktor service

1,5

Micronaut service

2,8

Quarkus service

1,9

Spring Boot service

10,7

It is worth noting that if you “clean” the application on Spring Boot from unnecessary dependencies and pay attention to setting up the launch of the application (for example, scan only the necessary packages and use lazy initialization of beans), then you can significantly reduce the launch time.

Memory usage

For each microservice, the following was determined:

  • a minimum amount of heap memory (determined by using -Xmx parameter) required to run a healthy (responding to different types of requests) microservice

  • a minimum heap memory required to pass a load test 50 users * 1000 requests

  • a minimum heap memory required to pass a load test 500 users * 1000 requests

Heap memory is only a part of the total memory allocated for an application. If you want to measure overall memory usage, you can use, for example, this guide.

For load testing, Gatling and Scala script were used. The load generator and the service being tested were run on the same machine (Windows 10, 3.2 GHz quad-core processor, 24 GB RAM, SSD). The port of the service is specified in the Scala script. Passing a load test means that microservice has responded to all requests for any time.

Microservice

A Minimum amount of heap-memory, MB

For start a healthy service

For a load of 50 * 1000

For a load of 500 * 1000

Helidon service

11

9

11

Ktor service

13

11

15

Micronaut service

17

15

19

Quarkus service

13

17

21

Spring Boot service

18

19

23

It should be noted that all microservices use Netty HTTP server.

Conclusion

The required functionality — a simple service with the HTTP API and the ability to function in MSA — was succeeded in all the frameworks considered. It’s time to take stock and consider their pros and cons.

Helidon

Standard Edition

  • pros

    • application parameters

      Good results for all parameters

    • “No magic”

      Framework justified the developers' principle: for the created application only one annotation was needed (@JvmStatic — for Java-Kotlin interop)

  • cons

    • microframework

      Some components necessary for industrial development are missing out of the box (for example, dependency injection and interaction with Service Discovery server)

MicroProfile

Microservice has not been implemented on this framework, so I will note only one point.

  • pros

    • Eclipse MicroProfile implementation

      Essentially, MicroProfile is Java EE optimized for MSA. Thus, firstly, you have access to the whole variety of Java EE API, including that developed specifically for MSA, secondly, you can change the implementation of MicroProfile to any other (Open Liberty, WildFly Swarm, etc.)

Ktor

  • pros

    • lightweight

      Allows you to add only those functions that are directly needed to perform the task

    • application parameters

      Good results for all parameters

  • cons

    • “sharpened” under Kotlin, that is, development in other languages can be impossible or is not worthwhile

    • microframework

  • additionally

    • development concept

      • On the one hand, the framework is not included in the two most popular Java development models (Spring-like (Spring Boot/Micronaut) and Java EE/MicroProfile), which can lead to:

        • a problem with finding specialists

        • an increase in the time to perform tasks compared to the Spring Boot due to the need to explicitly configure the required functionality

      • On the other hand, dissimilarity to “classic” Spring and Java EE lets look at the process of development from a different angle, perhaps more consciously

Micronaut

  • pros

    • AOT

      As previously noted, the AOT can reduce the start time and memory consumption of the application as compared to the analog on Spring Boot

    • Spring-like development model

      Programmers with experience with Spring framework won’t take much time to master this framework

    • application parameters

      Good results for all parameters

  • additionally

    • the Micronaut for Spring project allows, among other things, changing the execution environment of the existing Spring Boot application to the Micronaut (with restrictions)

Quarkus

  • pros

    • Eclipse MicroProfile implementation

    • application parameters

      Good results for all parameters

  • additionally

    • the framework provides compatibility layers for several Spring technologies: DI, Web, Security, Data JPA

Spring Boot

  • pros

    • platform maturity and ecosystem

      Framework for every day. For most everyday tasks, there is already a solution in the programming paradigm of Spring, that is, in the way that many programmers are used to. Also, development is simplified by the concept of starters and auto-configuration

    • a large number of specialists in the labor market, as well as a significant knowledge base (including documentation and answers on Stack Overflow)

    • perspective

      I think many will agree that Spring will remain the leading Java/Kotlin framework in the near future

  • cons

    • parameters of application

      Application in this framework wasn’t among the leaders, however, some parameters, as noted earlier, you can optimize yourself. It is also worth remembering about the presence of the project Spring Fu, which is in active development and the use of which reduces these parameters

Also, we can highlight common problems that new frameworks have, but Spring Boot lacks:

  • less developed ecosystem

  • few specialists with experience with these technologies

  • longer time of implementation of tasks

  • unclear prospects

The considered frameworks belong to different weight divisions: Helidon SE and Ktor are microframeworks, Spring Boot and Micronaut are full-stack frameworks, Quarkus and Helidon MP are MicroProfile frameworks. Microframework’s functionality is limited, which can slow down the development; to clarify the possibility of implementing a particular functionality based on a particular framework, I recommend that you familiarize yourself with its documentation.

I don’t dare to judge whether this or that framework will “shoot” in the near future, so in my opinion, for now, it’s best to continue to observe developments using the existing framework for solving work tasks.

At the same time, as was shown in the article, new frameworks win Spring Boot on the considered parameters of the applications. If any of these parameters are critically important for one of your microservices, then perhaps it’s worth to pay attention to the frameworks that showed the best results. However, we should not forget that Spring Boot, firstly, continues to improve, and secondly, it has a huge ecosystem and a significant number of Java programmers are familiar with it. Also, there are other frameworks not covered in this article: Vert.x, Javalin, etc.

Thank you for attention!

P.S. Thanks to artglorin for helping with this article.

© Roman Kudryashov 2019-2024

Powered by Hugo & Kiss.