JAX Blog

Mit Ktor und Spring Boot Server- und Clientanwendungen entwickeln

Spring Boot und Ktor in Kotlin

Mar 15, 2023

Wie können mit Spring Boot und Ktor in Kotlin Server- und Clientanwendungen entwickelt werden? Dieser Artikel begleitet durch das Zusammenspiel von Spring Boot und Ktor. Kotlin eignet sich hervorragend, um Non-blocking-Server- und -Clientanwendungen zu entwickeln. Dafür gibt es mit Ktor sogar ein spezielles Framework. Doch auch der Platzhirsch für Anwendungen im JVM-Ökosystem, Spring Boot, muss sich hier nicht verstecken, sondern kann hervorragend im Zusammenspiel mit Kotlin verwendet werden.

Das Kotlin-Ökosystem ist umfangreich. Entwickler JetBrains arbeitet nicht nur an der Sprache selbst, sondern veröffentlicht diverse Frameworks, die speziell für Kotlin entwickelt werden. Ktor [1] ist ein solches Framework, das die Entwicklung von Server- und Clientanwendungen in Kotlin ermöglicht. Ktor sticht vor allem dadurch hervor, dass es auch für native Builds mit Kotlin Native geeignet ist. Das zweite Hauptargument für Ktor ist, dass es ausschließlich non-blocking arbeitet und daher asynchrone Verarbeitung der Standard ist.

Ktor-Server

Der Anfang einer Entwicklung mit Ktor ist dabei so einfach wie bei den meisten anderen aktuellen Frameworks im Umfeld der Java Virtual Machine (JVM). Die Entwickler bieten einen Starter [2], mit dessen Hilfe ein Maven- oder Gradle-Projekt erstellt werden kann. Hierbei können der verwendete Webserver und benötigte Plug-ins direkt ausgewählt werden. Abhängigkeiten zwischen den verschiedenen Komponenten löst der Projektgenerator auf und fügt notwendige, abhängige Plug-ins hinzu, sodass ein lauffähiges Projekt entsteht.

Für ein einfaches „Hello World“-Beispiel verwenden wir Netty als Webserver und die Plug-ins Routing und kotlinx.serialization, um zum einen Routing anhand von Pfaden zu ermöglichen und zum anderen das Kotlin-eigene Serialisierungsmodul zu nutzen. Letzteres fügt als zusätzliches Plug-in noch ContentNegotiation hinzu, das es Ktor ermöglicht, ContentType- und Accept-Header auszuwerten und darauf zu reagieren. Ktor bietet weitere Plug-ins, die für fortgeschrittene Anwendungsfälle geeignet sind, wie zum Beispiel Authentifizierung, Session-Management, LDAP-Anbindung, Template-Engines und WebSocket-Unterstützung, um nur einige Beispiele zu nennen. Die vollständige „Hello World“-Anwendung zeigt Listing 1.

import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
 
fun main() {
  embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
    install(ContentNegotiation) {
      json()
    }
    configureRouting()
  }.start(wait = true)
}
 
fun Application.configureRouting() {
  routing {
    route("/hello") {
      get("/{name?}") {
        val name = call.parameters["name"] ?: "World"
 
        call.respond("Hello $name!")
      }
    }
  }
}

In der main-Methode wird über die Methode embeddedServer die Ktor-Anwendung erzeugt und mit der start-Methode gestartet. Der Parameter wait = true gibt dabei an, dass der Hauptanwendungs-Thread blockiert werden soll, die Anwendung also so lange läuft, bis sie beendet wird.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Über die embeddedServer-Methode werden die Server-Engine, der Port und auch die Ktor-Plug-ins konfiguriert. Das ContentNegotiation-Plug-in wird zur Anwendung hinzugefügt und für JSON konfiguriert. Die Konfiguration des Routings könnte ebenso inline erfolgen, wird hier aber in eine Extension Function ausgelagert. Generell kann das Routing in mehrere Extension Functions ausgelagert werden und so eine Gruppierung nach Funktionalität, Pfad oder Ähnlichem erfolgen.

Das Routing selbst geschieht über die Ktor-DSL. Das Beispiel aus Listing 1 konfiguriert die Route / hello/$name, wobei $name ein optionaler Path-Parameter (erkennbar am ?) ist. Mit call.parameters kann auf die Request-Parameter zugegriffen werden, mit call.respond wird die Serverantwort geschrieben. Ein Aufruf von /hello gibt „Hello World!“ zurück, ein Aufruf von / hello/Ktor gibt „Hello Ktor!“ zurück.

Damit ist eine erste, sehr einfache Ktor-Anwendung fertig. Der Non-blocking-Aspekt von Ktor scheint in diesem simplen Beispiel verborgen zu sein, ist aber tatsächlich schon vorhanden. Die Signaturen der Methoden call.parameters und call.respond haben jeweils das Schlüsselwort suspend in ihrer Deklaration, was bedeutet, dass sie auf besondere Weise ausgeführt werden. Zunächst jedoch ein Blick darauf, wofür Non-Blocking relevant ist.

Non-blocking Serveranwendungen

Webserver stellen eine Menge Threads zur Verfügung. Jeder dieser Threads verarbeitet dabei einen Request nach dem anderen. Die Anzahl paralleler Requests ist also durch die Zahl der Threads limitiert. Komplexere Anwendungen können jedoch eine Anfrage oftmals nicht ohne externe Kommunikation verarbeiten. Externe Zugriffe sind nötig, zum Beispiel auf eine Datenbank, das Dateisystem oder auf externe HTTP-APIs. Beim Warten auf diese externen Zugriffe wird der Webserver-Thread blockiert. Der Skalierung der Webserver-Threads sind dabei schnell Grenzen gesetzt, da Threads generell ressourcenintensiv sind und der Bedarf an Arbeitsspeicher schnell ansteigt. Optimierungen im Code selbst, die zum Beispiel Zugriffe auf Datenbanken oder externe APIs via Threads parallelisieren, bringen nicht nur das Ressourcenproblem mit, sondern erhöhen zusätzlich die Komplexität des Codes signifikant, da die Threads synchronisiert werden müssen. Auch wird dabei nicht das Problem gelöst, dass der Webserver-Thread blockiert werden muss, um auf das Beenden aller gestarteten Threads zu warten.

Ein häufig von Frameworks angebotener Lösungsansatz hierfür ist Reactive Programming. Dabei wird durch ein entsprechendes Framework, wie zum Beispiel Project Reactor [3], das Scheduling übernommen. Statt zu blockieren, wird dabei der Thread genutzt, um weitere Anfragen zu verarbeiten. So ist ein höherer Parallelisierungsgrad möglich, ohne zusätzliche Threads zu benötigen. Mit Reactive Programming ist jedoch eine steile Lernkurve verbunden. Das Programmiermodell unterscheidet sich grundlegend von der imperativen Programmierung, da es für fast jedes Problem spezielle Operatoren gibt.

Ktor nutzt für die parallelisierte Verarbeitung einen anderen Ansatz, der es erlaubt, den imperativen Programmierstil beizubehalten: Kotlin Coroutines.

Kotlin Coroutines

Koroutinen [4] sind spezielle Funktionen, die unterbrochen und wieder fortgesetzt werden können. Kotlin unterstützt Koroutinen über eine Core Library namens Kotlin Coroutines [5]. Den Kern von Kotlin Coroutines bilden suspendierbare Funktionen, die mit dem Schlüsselwort suspend fun deklariert werden. Diese können nur innerhalb eines Coroutine Scopes ausgeführt werden. Das einfache Beispiel in Listing 2 startet einen Coroutine Scope mit der Methode runBlocking. Durch diese Methode wird der Hauptthread pausiert, bis alle gestarteten Koroutinen abgeschlossen sind. Mittels launch werden innerhalb einer for-Schleife 1 000 000 Koroutinen gestartet, die jeweils zehn Sekunden pausieren und anschließend einen Wert ausgeben. Die Ausführung zeigt, dass nach einer initialen Verzögerung von zehn Sekunden alle Zahlen kurz nacheinander ausgegeben werden. Die Ausführung – und damit auch die zehn Sekunden Wartezeit – wird also parallelisiert.

fun main() {
  runBlocking {
    for (i in 0..1_000_000) {
      launch { printIn10Seconds(i) }
    }
  }
}
 
suspend fun printIn10Seconds(value: Int) {
  delay(10_000)
  println(value)
}

Listing 3 zeigt denselben Code mit Threads. Dieser läuft (auf durchschnittlicher Hardware) spürbar weniger performant, da er deutlich mehr Systemressourcen benötigt.

fun main() {
  val threads = mutableListOf<Thread>()
  for (i in 0..1_000_000) {
    val thread = Thread { printIn10Seconds(i) }
    threads.add(thread)
    thread.start()
  }
  threads.forEach { it.join() }
}
 
fun printIn10Seconds(value: Int) {
  Thread.sleep(10_000)
  println(value)
}

suspend funs können nur innerhalb eines Coroutine Scopes ausgeführt werden. Das bedeutet in unserem initialen Ktor-Beispiel, das bereits zwei suspend funs benutzt hat, dass Ktor den gesamten Code in einem Coroutine Scope ausführt. Konkret startet Ktor für jeden Request eine eigene Koroutine und ermöglicht somit eine deutlich höhere Parallelisierung, ohne dass der Entwickler sich umstellen oder irgendetwas beachten muss. Jedoch bleibt ihm die Flexibilität erhalten, selbst suspendierbare Funktionen aufzurufen.

NEUES AUS DER JAVA-ENTERPRISE-WELT

Serverside Java-Track entdecken

 

Ktor-Client

Ktor bietet nicht nur eine einfache Möglichkeit, eine non-blocking Serveranwendung zu implementieren. Auch non-blocking Clientanwendungen sind möglich. Dass hierbei auch die Clients konsequent mit Hilfe von Coroutines implementiert werden, bringt auch hier den Vorteil, dass der Thread, der die Requests ausführt, nicht blockiert werden muss, während auf den Server gewartet wird, sondern weiterverwendet werden kann.

Als Beispiel wollen wir unsere „Hello World“-Anwendung erweitern, sodass sie User, die bei einem anderen Service via ID abgefragt werden, mit Namen begrüßen kann.

Hierfür benötigen wir weitere Abhängigkeiten, die wir in unsere Build-Konfiguration (Maven oder Gradle) aufnehmen: io.ktor:ktor-client-core, io.ktor:ktor-client-cio und io.ktor:ktor-client-content-negotiation. Den Client implementieren wir so, wie in Listing 4 dargestellt.

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
 
class UserService(
  val client: HttpClient = HttpClient(CIO) {
    expectSuccess = true
    install(ContentNegotiation) {
      json()
    }
  }
) {
 
  suspend fun findUser(userId: String): User =
    client.get("http://localhost:8181/users/$userId").body<User>()
}
 
@Serializable
data class User(
  val id: String,
  val name: String,
)

Wir erzeugen zunächst einen HTTP-Client, der JSON Accept-Header verschickt und JSON-Antworten deserialisieren kann. Außerdem konfigurieren wir ihn via expectSuccess = true so, dass alle nicht 2xx Response Codes zu einer Exception führen.

Die Methode findUser ruft ein HTTP API auf und deserialisiert die JSON-Antwort in ein User-Objekt. Dieses wird mit @Serializeable für das Kotlin-Serialisierungsmodul (kotlinx.serializable) serialisierbar gemacht. client.get ist dabei eine suspend fun, also muss entweder die Methode findUser ebenfalls eine suspend fun sein oder explizit einen Coroutine Scope starten. Da wir die Methode aus dem Ktor-Server heraus nutzen wollen, der bereits einen Coroutine Scope zur Verfügung stellt, definieren wir die Methode als suspend fun. Listing 5 zeigt die Integration des Clients in den Server.

fun Application.configureRouting() {
  routing {
    route("/hello") {
      get("/{name?}") {
        val name = call.parameters["name"] ?: "World"
 
        call.respond("Hello $name!")
      }
      get("/user/{userId}") {
        try {
          val user = userService.findUser(call.parameters.getOrFail("userId"))
          call.respond("Hello ${user.name}!")
        } catch (e: ClientRequestException) {
          if (e.response.status == HttpStatusCode.NotFound) {
            call.respond(HttpStatusCode.BadRequest)
          } else {
            call.respond(HttpStatusCode.InternalServerError)
          }
        }
      }
    }
  }
}

Hierfür wird die Funktion configureRouting erweitert. Wir definieren eine zusätzliche Route als GET Request auf /hello/user/$userId. Ein Aufruf versucht, die übermittelte User-ID aufzulösen und den Namen auszulesen. Wird der User nicht gefunden, wird ein passender Fehler zurückgegeben. Der zugehörige Server ist in Listing 6 dargestellt.

val userService = UserService()
 
fun main() {
  embeddedServer(Netty, port = 8181, host = "0.0.0.0") {
    install(ContentNegotiation) {
      json()
    }
    configureRouting()
  }.start(wait = true)
}
 
fun Application.configureRouting() {
  routing {
    route("/users") {
      get() {
        call.respond(
          userService.users
            .map { User(id = it.key, name = it.value) }
        )
      }
      get("/{id}") {
        val id = call.parameters.getOrFail("id")
        try {
          val name = userService.users.getValue(id)
 
          call.respond(User(id = id, name = name))
        } catch (_: NoSuchElementException) {
          call.respond(HttpStatusCode.NotFound)
        }
 
      }
      post("/{name}") {
      call.respond(userService.createUser(call.parameters.getOrFail("name")))
        }
    }
  }
}
 
@Serializable
data class User(
  val id: String,
  val name: String,
)
 
class UserService(
  val users: MutableMap<String, String> = mutableMapOf(),
) {
  fun createUser(
    name: String,
  ): String {
    val id = "${UUID.randomUUID()}"
    users[id] = name
 
    return id
  }
}

Neben GET Requests wird auch ein POST Request definiert. Außerdem können @Serializable-Klassen von Ktor ebenfalls zu JSON serialisiert werden, wenn diese als Antwort zurückgegeben werden sollen.

Mit Ktor ist es, wie die Beispiele zeigen, sehr einfach möglich, HTTP-Kommunikation zu implementieren. Diese ist standardmäßig non-blocking und damit sehr ressourceneffizient umgesetzt.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Kotlin Coroutines und Spring Boot

Den großen Vorteil von Kotlin Coroutines, einfach parallelisierbaren Code zu entwickeln, nutzt auch das am weitesten verbreitete Framework für die JVM, Spring Boot. Mit Version 5 hat das Spring Framework Unterstützung für non-blocking Verarbeitung der Requests erhalten: Spring Webflux [6]. Während Java-Entwickler hier auf den Reactive Programming Stack von Project Reactor angewiesen sind und damit ihre Entwicklungspattern umstellen müssen, können Kotlin-Entwickler ihr gewohntes Entwicklungspattern beibehalten, da jegliche Parallelität auch über Kotlin Coroutines abgebildet werden kann. Verantwortlich hierfür ist das Kotlin-Modul org.jetbrains.kotlinx:kotlinx-coroutines-reactor. Es bindet Kotlin Coroutines an Project Reactor an.

Der Einstieg in die Entwicklung ist dabei genauso einfach wie bei jedem anderen Spring-Projekt. Über den Spring Boot Starter [7] wird das Projekt konfiguriert. Statt der Abhängigkeit Spring Web wird Spring Reactive Web verwendet. Die Anbindung von Kotlin Coroutines an Project Reactor wird für Kotlin-Projekte automatisch hinzugefügt.

Die Implementierung erfolgt dann fast wie gewohnt (Listing 7). Der einzige Unterschied ist die Methodensignatur der Methode hello. Diese ist als suspend fun, also als Koroutine deklariert. Ist eine Methode mit RequestMapping als suspend fun definiert, so startet Spring automatisch einen Coroutine Scope für die Ausführung des Requests.

@RestController
@RequestMapping("/hello")
class HelloController(
  val userService: UserService
) {
 
  @GetMapping(value = ["" ,"/{name}"])
  suspend fun hello(
    @PathVariable(required = false) name: String?
  ) =
    "Hello ${name ?: "World"}!"
}

Der Coroutine Scope ermöglicht es auch, weitere suspend funs aufzurufen. Das ist insbesondere dann von Vorteil, wenn auch die aufgerufenen Funktionen non-blocking implementiert sind. Das ist zum Beispiel beim Spring WebClient der Fall. Dieser basiert auf dem Reactive Stack von Project Reactor und kann auch mit Kotlin Coroutines verwendet werden. Der UserService aus dem Ktor-Beispiel (Listing 4) kann mit dem Spring WebClient implementiert werden, wie in Listing 8 gezeigt.

@Service
class UserService(
  val webClient: WebClient = WebClient.create()
) {
  suspend fun findUser(userId: String): User {
    return webClient.get().uri("http://localhost:8181/users/$userId")
      .accept(APPLICATION_JSON)
      .awaitExchange {
        if (it.statusCode() == HttpStatus.NOT_FOUND) {
          throw UserIdNotFoundException()
        }
        it.awaitBody<User>()
      }
  }
}
 
data class User(
  val id: String,
  val name: String,
)
 
class UserIdNotFoundException: Exception()

Der Controller aus Listing 7 kann dann um den Code aus Listing 9 erweitert werden, sodass dieselbe Funktionalität entsteht wie im Ktor-Beispiel aus Listing 5.

@GetMapping("/user/{userId}")
suspend fun helloUser(
  @PathVariable userId: String
) =
    "Hello ${userService.findUser(userId).name}!"
 
@ExceptionHandler(UserIdNotFoundException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
suspend fun handleUserIdNotFoundException(e: UserIdNotFoundException) { }

Auch mit Spring Boot ist es sehr einfach möglich, die Vorteile von Kotlin Coroutines zur besseren Parallelisierung zu nutzen.

 

Fazit: Spring Boot und Ktor in Kotlin

Kotlin bietet mit Kotlin Coroutines eine sehr einfache Möglichkeit, parallelen Code zu schreiben, ohne die damit verbundene Komplexität explizit selbst verwalten zu müssen. Außerdem benötigen Kotlin Coroutines deutlich weniger Ressourcen als Threads. Coroutines haben außerdem eine sehr geringe Einstiegshürde, da die gewohnten Entwicklungspattern beibehalten werden können.

Basierend auf Kotlin Coroutines können Client- und Serveranwendungen sehr einfach entwickelt werden. Hierfür gibt es mit Ktor ein Kotlin-natives Framework, das einen unkomplizierten Einstieg ermöglicht. Ktor ist dabei ein kleines Framework dessen Funktionsumfang auf das limitiert ist, was zur Entwicklung eines Servers oder Clients benötigt wird. Dafür ist eine Einbindung in Kotlin Native möglich, die Build-Artefakte bleiben kompakt und es kann einfacher mit anderen Libraries oder Frameworks kombiniert werden. Außerdem gibt es die non-blocking Verwendung der Webserver-Threads automatisch dazu.

Non-blocking Anwendungen können aber auch über Spring Boot mit Kotlin Coroutines entwickelt werden. Hier muss man sich allerdings explizit für den Non-blocking-Ansatz entscheiden. Im Gegenzug gibt es das komplette Spring-Ökosystem dazu: Dependency Injection, Spring Data und vieles mehr.


Links & Literatur

[1] https://ktor.io/

[2] https://start.ktor.io/

[3] https://projectreactor.io/

[4] https://de.wikipedia.org/wiki/Koroutine

[5] https://kotlinlang.org/docs/coroutines-guide.html

[6] https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html

[7] https://start.spring.io

Alle News der Java-Welt:

Behind the Tracks

Agile, People & Culture
Teamwork & Methoden

Clouds & Kubernetes
Alles rund um Cloud

Core Java & Languages
Ausblicke & Best Practices

Data & Machine Learning
Speicherung, Processing & mehr

DevOps & CI/CD
Deployment, Docker & mehr

Microservices
Strukturen & Frameworks

Performance & Security
Sichere Webanwendungen

Serverside Java
Spring, JDK & mehr

Software-Architektur
Best Practices

Web & JavaScript
JS & Webtechnologien

Digital Transformation & Innovation
Technologien & Vorgehensweisen

Domain-driven Design
Grundlagen und Ausblick

Spring Ecosystem
Wissen in Spring-Technologien

Web-APIs
API-Technologie, Design und Management