Über unsMediaKontaktImpressum
Stefan Tomm 14. August 2018

Von Java zu Kotlin: In kleinen Schritten zum Ziel

Als ich im vergangenen Jahr den Arbeitgeber wechselte, hatte ich vorher noch keine Zeile Kotlin-Code gesehen. Ich war in meiner Java-Welt eigentlich recht zufrieden. Das Projekt an dem ich nun arbeitete, befand sich gerade am Anfang der Migration von Java nach Kotlin. Was mich bereits in den ersten Tagen faszinierte, war die leichte Verständlichkeit des Kotlin-Codes. Auch das Schreiben von einfachem Kotlin-Code war nach sehr kurzer Einarbeitung möglich. Nach mittlerweile einem Jahr Praxiserfahrung mit Kotlin ist mir bewusst, dass ich nach und nach vieles dazu gelernt habe. Man kann sicherlich nicht vom ersten Tag an alle Vorteile von Kotlin nutzen. Die Lernkurve gestaltet sich meiner Meinung nach aber äußerst angenehm. In diesem Artikel möchte ich aufzeigen, wie jeder Java-Entwicker seine Applikation nach und nach zu Kotlin migrieren kann. Anhand von praxisnahen Beispielen gehe ich auf die Vorteile ein, die man durch die Migration gewinnt.

Der Artikel "Ist Kotlin das bessere Java?" von Lovis Möller [1] erklärt die grundlegenden Konzepte und Funktionalitäten von Kotlin sehr gut. Vom ersten Tag an konnte ich von den Vorteilen der Null-Safety und des Null Handlings, der Kotlin Collections API [2] und den impliziten Gettern und Settern, sowie den Data Classes [3], profitieren.

Wie beginne ich mit der Migration?

Der geeignete Einstiegspunkt ist ein Build-Tool wie Maven oder Gradle. Ich werde hier exemplarisch die Konfiguration mit Gradle zeigen. In Maven gestaltet sich das meiste aber analog dazu und es gibt auch eine gute offizielle Dokumentation [4] für verschiedene Build-Tools. Sie können einfach Ihre bestehende Build-Konfiguration nehmen und wie folgt erweitern.

buildscript {
  ext {
    kotlinVersion = '1.2.51'
  }
  dependencies {
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
  }
}

apply plugin: "java"
apply plugin: "kotlin"

sourceSets {
  main.kotlin.srcDirs += 'src/main/java'
  main.java.srcDirs += 'src/main/java'
}

dependencies {
  compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
}

Neben dem Hinzufügen der Dependency und des Plugins spielt v. a. das Setzen der SourceSets eine wichtige Rolle. Wir haben gute Erfahrungen damit gemacht, die Java- und Kotlin-Klassen zunächst noch im gleichen Verzeichnis zu halten. Somit sind die Klassen aus dem gleichen Package auch direkt nebeneinander angeordnet und der richtige Kontext wird dadurch abgebildet.

IDE-Unterstützung

Da Kotlin von JetBrains, den Machern von IntelliJ IDEA, entwickelt wird, gibt es auch eine hervorragende Unterstützung innerhalb der IDE. In meinem Team haben wir sehr gute Erfahrungen mit der IDE gemacht und würden sie jedem empfehlen. Aber auch für alle Eclipse-Anhänger gibt es ein Plugin, das von JetBrains zur Verfügung gestellt wird. In diesem Artikel werde ich im Weiteren aber nur auf IntelliJ IDEA eingehen, da wir dies bei uns im Einsatz haben.

Um unsere erste Java-Klasse nach Kotlin zu konvertieren, bietet uns IntelliJ IDEA eine einfache Funktionalität, welche den Großteil der Konvertierung automatisiert übernimmt. Über den Menüpunkt Code -> Convert Java File to Kotlin File wird die aktuell geöffnete Klasse direkt nach Kotlin konvertiert.

In einigen Fällen wird das volle Optimierungspotential jedoch noch nicht ausgeschöpft und manuelle Korrekturen verbessern die Konvertierung, wie die nächsten Abschnitte zeigen werden.

Die IDE unterstützt aber auch darüber hinaus mit hilfreichen Tipps direkt am Code. Optimierungen von Operationen auf Collections oder die empfohlene Verwendung von String Templates sind nur ein paar Beispiele dafür. Ein sehr nettes Feature ist auch das Einfügen von Java-Code in eine Kotlin-Klasse. Diesen könnte man sich z. B. von Stackoverflow oder aus einer bestehenden Java-Klasse kopiert haben. Beim Einfügen fragt die IDE direkt, ob man den Code nach Kotlin konvertieren möchte.

Tipps zum Konvertieren von Entitäten und anderen Datenklassen

Wenn wir Klassen, die Properties mit @NotNull-Annotationen enthalten, nach Kotlin konvertieren, wird die Stärke von Kotlin nicht ganz ausgespielt. Nach der Konvertierung einer Java-Klasse mit Default-Constructor und @NotNull-Annotation an einer name-Property, entsteht folgende Kotlin-Klasse.

class Resource {

  @NotNull
  var name: String? = null

  private constructor() : super() {
  }

  constructor(name: String) {
    this.name = name
  }
}

Bei der Klasse fallen zwei Dinge auf. Die name-Property wurde nicht zu einer not nullable-Variable umgewandelt. Außerdem sind aufgrund des Default Constructors die Konstruktoren explizit definiert. Der Default Contructor wird z. B. von JPA benötigt und betrifft somit in sehr vielen Java-Applikationen alle Entitäten. Gerade diese eignen sich aber besonders für Data Classes, bzw. die Optimierungen, die Kotlin im Allgemeinen für Properties in Klassen bietet.

Den Zwang zum Default Constructor können wir uns in Kotlin zum Glück recht einfach sparen. Dafür gibt es ein Gradle- bzw Maven-Plugin, das uns dies abnimmt.

plugins {
    id "org.jetbrains.kotlin.plugin.jpa" version "1.2.51"
}

Da der Default Constructor nun entfällt, benötigen wir nur einen Constructor. Dieser kann in Kotlin direkt an der Klassendefinition angegeben werden. Wenn wir zusätzlich die @NotNull-Annotation "kotlinisieren", sieht das Ergebnis wie folgt aus:

class Resource(
  val name: String
)

Wie das Beispiel zeigt, können wir uns für reine Datenklassen sogar den ganzen Klassen-Body sparen. Der Constructor an der Klassendefinition enthält alle nötigen Informationen zur Klasse.

Keine automatische Konvertierung zu Data Classes

Wenn unsere Datenklasse zusätzlich mit equals-, hashcode- und toString-Methoden versehen ist, bleiben diese Methoden auch nach der Konvertierung nach Kotlin vorhanden. Da diese Methoden individuell implementiert sein können, ist es der IDE auch nicht möglich, die Konvertierung durchzuführen. In vielen Fällen entspricht die Methode aber einfach der Standardimplementierung und es lohnt sich, die Klasse als Data Class zu definieren. Somit können die explizit definierten Methoden entfernt werden.

Java-Streams durch Kotlin-Collection-Funktionen ersetzen

Nehmen wir z. B. folgenden Java-Code:

projectRepository
  .findAll()
  .stream()
  .map(p -> new ProjectListDTO(p))
  .collect(Collectors.toList());

Dieser resultiert nach der Konvertierung in:

projectRepository
  .findAll()
  .stream()
  .map { p -> ProjectListDTO(p) }
  .collect<List<ProjectListDTO>, Any>(Collectors.toList())

Hiermit hätten wir leider noch nicht viel gewonnen. Dazu kommt der Kotlin-Compiler mit der Typisierung des Collectors nicht klar. Das Problem lässt sich aber einfach lösen, da wir den Code wie folgt reduzieren können:

projectRepository
  .findAll()
  .map { p -> ProjectListDTO(p) }

Die Verwendung von Funktionen auf Collections ist in Kotlin somit deutlich einfacher und in unserem Team eines der Highlights von Kotlin im Vergleich zu Java.

Die Kotlin-Collections bieten aber nicht nur die Methoden, die wir von Java-Streams kennen, sondern auch noch einige mehr. Wenn wir z. B. genau ein Ergebnis nach einer Filterung erwarten, bietet Kotlin die single()-Funktion an. Diese wirft eine Exception, falls nicht genau ein Element in der Liste vorhanden ist. In Java gestaltet sich diese Funktionalität deutlich komplexer, wie dieser Stackoverflow-Beitrag [5] zeigt. Wenn man spezielle Operationen auf Kotlin-Collections ausführen will, lohnt es sich auf jeden Fall ein wenig in der API zu stöbern. Wir haben damit schon häufiger unsere Operationen auf Listen deutlich vereinfachen können.

Scoping-Functions

Die Scoping-Functions let, run, apply und also dienen dazu, Operationen direkt auf Objekten durchzuführen, ohne sie einer Variablen zuzuweisen. Sie werden von der automatischen Konvertierung allerdings nicht angewendet. Gerade beim Null Handling bieten diese einige Vorteile und erlauben uns, ausdrucksstärkeren Code zu schreiben. Schauen wir uns als Beispiel einen JPA Converter für den Instant-Datentyp an.

public Date convertToDatabaseColumn(Instant instant) {
  return instant != null ? Date.from(instant) : null;
}

Wenn wir diese Methode nach Kotlin konvertieren, ändert sich nicht viel. Da es in Kotlin den ternären Operator nicht gibt, wird dieser lediglich durch ein inline if-then-else ersetzt.

override fun convertToDatabaseColumn(instant: Instant?): Date? {
  return if (instant != null) Date.from(instant) else null
}

Die let-Funktion erlaubt uns, das Null Handling hier deutlich lesbarer zu gestalten.

override fun convertToDatabaseColumn(instant: Instant?): Date? {
  return instant?.let { Date.from(it) }
}

Mit dem Safe-Operator ?. liefern wir direkt null zurück, falls instant null ist. Die let-Funktion selbst liefert das Ergebnis der Lambda-Expression in den geschweiften Klammern zurück. Außerdem können wir mit it auf das Objekt, auf dem let aufgerufen wurde, zugreifen.

Diese Art des Null-Handlings hat sich bei uns im gesamten Team etabliert und ist sicherlich eines der Kotlin-Konstrukte, die wir am häufigsten einsetzen.

Null-Handling beim Aufruf von Java-Klassen

Eine der großen Stärken von Kotlin ist das explizite Null-Handling. NullPointerExceptions werden dadurch von der Laufzeit in die Compilezeit verlagert. Diesen Vorteil kann man meiner Meinung nach gar nicht hoch genug bewerten! Direkt in der IDE sehen zu können, dass man eine Null-Behandlung vergessen hat, ist der frühestmögliche und ideale Zeitpunkt, um sich darüber Gedanken zu machen. Dies – wie in Java üblich – erst zur Laufzeit innerhalb einer Produktivumgebung zu bemerken, hat uns alle schon viele Nerven gekostet.

Von diesem Vorteil in Kotlin kann allerdings erst vollends nach der 100-prozentigen Konvertierung nach Kotlin profitiert werden. Wenn wir aus Kotlin heraus Java-Methoden aufrufen, sind die Ergebnisse dieser zunächst in einem Zwischenzustand zwischen Nullable und Not-Nullable. D. h. wir werden vom Compiler nicht dazu gezwungen, einen Null-Check einzubauen. Es ist möglich, mit dem Ergebnis so umzugehen, als wäre es ein Not-Nullable-Rückgabewert einer Kotlin-Funktion. Es ist aber dennoch möglich, den Rückgabewert zu behandeln, als ob er nullable wäre. Wir können also z. B. den Safe-Operator ?. verwenden. Dies klingt zunächst paradox, aber für die Interoperabilität und die einfachere Konvertierung von Java-Klassen nach Kotlin macht es durchaus Sinn. Bei der Verwendung von Java-Klassen gilt also bzgl. Null-Handling grundlegend das Verhalten, wie wir es aus Java kennen.

Mit etwas Zusatzaufwand können wir aber auch die Java-Seite für Kotlins explizites Null-Handling aufbereiten. Mit @NotNull- oder @Nullable-Annotationen versehene Java-Methoden und -Felder werden in Kotlin entsprechend korrekt behandelt. Da diese Annotationen als Best Practice in der Java-Welt immer mehr Einzug halten, profitieren wir auch beim Einsatz vieler Java-Libraries (z. B. Spring) davon.

Ohne die genannten Annotationen bedeutet das explizite Null-Handling für die Konvertierung nach Kotlin aber den größten manuellen Aufwand. Dies ist v. a. bei fortgeschrittenem Zustand der Migration zu beachten. Wenn wir jetzt eine Java-Klasse nach Kotlin umwandeln, die bereits aus vielen Kotlin-Klassen heraus verwendet wird, müssen diese Klassen oftmals mit angepasst werden. Die jetzt vorhandene explizite Definition von Nullable oder Not-Nullable erfordert eine entsprechende Behandlung in den Kotlin-Klassen. Dank des Safe-Operators, des Elvis-Operators, den Scoping Functions und weiteren Hilfsmitteln sind aber auch diese Klassen relativ schnell angepasst.

Checked Exceptions

In Kotlin gibt es keine Checked Exception. Ich finde dies eine gute Entscheidung, da die lange Kette an throws, throws, throws über mehrere Methoden meistens nur unnötiger Boilerplate-Code war. In den letzten Jahren habe ich in den Java-Projekten an denen ich gearbeitet habe sowieso fast nur noch RuntimeExceptions verwendet. Aus Kompatibilitätsgründen zu Java können aber auch in Kotlin noch Checked Exceptions über die @Throws-Annotation verwendet werden. Dies kann hilfreich sein, wenn die Kotlin-Klasse aus Java heraus aufgerufen wird und dort mit Checked Exceptions gearbeitet werden soll.

@Throws(ValidationException::class)
fun doFancyCalculation(a: Any): Int {
    if(!checkInput(a)) {
        throw ValidationException("Parameter does not match criteria!")
    }
    ...
}

Companion Objects

In Kotlin gibt es keine statischen Definitionen. Stattdessen kann man Companion-Objekte auf seinen Klassen definieren. Der Zugriff auf Felder des Companion-Objektes funktioniert aus Kotlin-Klassen heraus, wie der Zugriff auf statische Definitionen in Java. Man ruft sie einfach direkt auf der Klasse auf.

class MyClass {
    companion object {
        fun create(): MyClass = MyClass()
    }
}

Im Hintergrund sind diese Companion Objects deutlich mächtiger als die statischen Definitionen in Java. Sie sind echte Objekte und können somit z. B. Interfaces implementieren. Aus Java heraus kann auf sie mittels MyClass.Companion zugegriffen werden. Falls echte statische Felder und Funktionen benötigt werden, gibt es die Möglichkeit, diese mit der @JvmStatic-Annotation zu versehen. Damit kann auch aus Java-Klassen heraus wie gewohnt auf diese zugegriffen werden.

KotlinLogging nutzen

Da es keine statischen Felder in Kotlin gibt, sind wir zu der Diskussion gekommen, wo wir Logger am besten definieren. Entweder auf dem Companion Object oder doch besser auf Feldebene? Zweiteres kommt natürlich nur für Singletons oder Klassen, die selten instantiiert werden, in Frage. Mit beiden Lösungen waren wir nicht wirklich zufrieden. Zum Glück haben wir eine kleine Library gefunden, die uns erlaubt, einen Logger ganz einfach zu definieren.

private val logger = KotlinLogging.logger {}

class ClassWithLogging {
    val message = "text"

    fun testLogging() {
        logger.info { message }
    }
}

Die Library, die wir nutzen, ist die Kotlin Logging Library [6]. Neben der Definition des Loggers in einer Zeile müssen wir auch die Klasse, für die geloggt werden soll, nicht explizit angeben. Ein weiterer Vorteil ist es, die Message in Form einer Lambda Expression zu übergeben. Im Gegensatz zur Übergabe als Parameter wird diese nämlich lazy evaluiert und selbst etwas komplexere Aufrufe in der Log-Nachricht sind so kein Problem mehr. Ein gutes Beispiel aus der Praxis ist die Ausgabe auf Debug-Level von einem Objekt im JSON-Format. Diese durchaus etwas komplexere Operation sollte nur ausgeführt werden, wenn der Debug-Level auch aktiviert ist.

String-Templates werden nicht automatisch verwendet

Wenn String-Konkatenationen oder String.format() in Java-Klassen verwendet werden, wandelt die automatische Konvertierung diese oftmals nicht in Kotlins String-Templates um.

log("Cost Calculation for " + projectId)

Dieses Java-Beispiel bleibt auch nach der Konvertierung nach Kotlin unverändert. Eine manuelle Konvertierung zur Nutzung von String-Templates führen wir deshalb nach der Konvertierung immer durch.

log("Cost Calculation for $projectId")

Bei Objektvergleichen ist Vorsicht geboten

Wir als Java-Entwickler haben uns schon lange daran gewöhnt, die .equals()-Methode für String-Vergleiche zu nutzen. Von außen betrachtet ist dies aber nicht wirklich intuitiv. Wenn wir an unsere Anfangszeiten zurückdenken, haben wir doch alle stattdessen eher mal den ==-Operator genutzt. Um den Vergleich von Objekten intuitiver zu machen, steht dieser ==-Operator in Kotlin für einen null-safe equals-Vergleich. Diese Umsetzung ist nicht nur intuitiver, sondern führt auch zu robusterer Software. In Java führt z. B. der Vergleich von Zahlen, die im niedrigeren Bereich durch das Autoboxing noch mit == verglichen werden können, zu Problemen. Zum Beginn der Lebenszeit einer Applikation wird eventuell noch mit kleinen Zahlen (z. B. IDs) gearbeitet. Erst im Laufe der Zeit, wenn die Zahlen größer werden, treten hier Probleme auf, da nun plötzlich referentielle Vergleiche gemacht werden. Diese referentiellen Vergleiche sind in Kotlin mit dem ===-Operator auch weiterhin möglich. Diesen Operator wählt man allerdings sehr bewusst und läuft nicht in die eben genannten Probleme.

Wenn allerdings Java- und Kotlin-Klassen parallel in einer Anwendung gepflegt werden, ist es wichtig, bzgl. des ==-Operators sehr vorsichtig zu sein. Nachdem man sich in Kotlin an diesen gewöhnt hat, ist die Gefahr relativ hoch, diesen auch in den Java-Klassen einzusetzen und somit Bugs zu erzeugen.

Ablauf der Migration

Durch die herausragende Interoperabilität zwischen Kotlin und Java empfehlen wir eine Schritt-für-Schritt-Migration während normale Features weiter umgesetzt werden. Bei der Nutzung von Java-Frameworks und -Libraries sind wir bis auf wenige Sonderfälle in keine großen Probleme gelaufen. Diese sind auch mit Kotlin problemlos zu verwenden. Wir konnten die Migration unserer Spring-Boot-Applikationen zu Kotlin nun nach einem Jahr vollständig abschließen und haben keine Zeile Java-Code mehr. Wie Sie sehen können haben wir Kotlin und Java über einen sehr langen Zeitraum parallel in unserem produktiven System betrieben. Das bei uns etablierte Vorgehen für die Migration nach Kotlin war, vor der Umsetzung eines neues Features zu schauen, ob Java-Klassen bei diesem Feature angefasst werden müssen. Falls dies der Fall ist, haben wir für die Migration dieser Klassen einen ersten, separaten Pull-Request gestellt und anschließend das Feature selbst umgesetzt. Manchmal fällt einem aber auch erst während der Umsetzung auf, dass noch weitere Java-Klassen betroffen sind. Ein Pull-Request für das Refactoring nach der Feature-Umsetzung war somit auch eine mögliche Variante. Die Konvertierung im Kontext von Feature-Implementierungen zu machen hat sich aber im Allgemeinen sehr bewährt.

Beispiel-Anwendung

Auf Github [7] finden Sie eine kleine Spring-Boot-1.5-Anwendung, bei der ich die vollständige Konvertierung von Java nach Kotlin durchgeführt habe. Im master branch ist die Kotlin-Variante zu finden, im java branch entsprechend die Java-Variante. Anhand dieses Beispiels können Sie Eins zu Eins die Implementierung mit Kotlin gegenüber Java vergleichen.

Fazit

Ich hoffe, ich konnte mit diesem Artikel das Interesse wecken, von Java nach Kotlin zu migrieren. Mit den hier genannten Tipps und Erkenntnissen sollten Sie gewappnet sein, Ihre Anwendung umzustellen. Durch den sanften Schritt-für-Schritt-Umstieg gehen Sie kein Risiko ein, wenn Sie die Migration einfach ausprobieren. Unser gesamtes Team hat den Umstieg zu Kotlin nie bereut und wir würden uns freuen, wenn noch viel mehr Teams ihre Produktivität durch die Verwendung von Kotlin steigern können.

Autor

Stefan Tomm

Seit nun mehr über 10 Jahren ist Stefan Tomm als Softwareentwickler mit dem Schwerpunkt auf Java-Web-Anwendungen tätig. Seit Mitte 2017 ist er bei der Meshcloud GmbH als Senior Software-Engineer tätig und für das Backend…
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben