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 |
---|---|---|---|---|
1.4.1 |
2019 |
Oracle |
||
1.3.0 |
2018 |
JetBrains |
||
1.2.9 |
2018 |
Object Computing |
||
1.2.0 |
2019 |
Red Hat |
||
2.2.4 |
2014 |
Pivotal |
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):
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
-
Prerequisites
-
JDK 13
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) |
Java, Kotlin |
|
Ktor |
Kotlin |
||
Micronaut |
- (CLI) |
Groovy, Java, Kotlin |
|
Quarkus |
Java, Kotlin, Scala |
||
Spring Boot |
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:
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:
API testing
As an example, the results of testing the API of Helidon service:
-
GET http://localhost:8081/application-info
{ "name": "helidon-service", "framework": { "name": "Helidon SE", "releaseYear": 2019 }, "requestedService": null }
-
GET http://localhost:8081/application-info?request-to=ktor-service
{ "name": "helidon-service", "framework": { "name": "Helidon SE", "releaseYear": 2019 }, "requestedService": { "name": "ktor-service", "framework": { "name": "Ktor", "releaseYear": 2018 }, "requestedService": null } }
-
GET http://localhost:8081/application-info/logo
Returns an image
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 collection → Edit → Variables, 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
See Helidon SE
-
-
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
See Helidon MP
-
application parameters
Good results for all parameters
-
-
additionally
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.