Kotlin und Java in einem Spring-Boot-Projekt mit Maven
1. Überblick
Viele Teams möchten Kotlin nutzen, können aber die gesamte Java-Codebasis nicht neu schreiben. Zum Glück läuft Kotlin auf der JVM, daher können beide Sprachen in einem Modul nebeneinander existieren und sich direkt aufrufen.
In diesem Artikel werden Sie Maven so konfigurieren, dass sowohl Kotlin als auch Java kompiliert werden. Anschließend behandeln wir das Thema Nullability, wenn Kotlin Java aufruft, und verwenden Annotationen wie @JvmStatic, @JvmOverloads und @JvmName, um Java die Aufrufe von Kotlin-Code zu erleichtern. Am Ende geht es um die Integration mit Spring Boot 3.x und häufig auftretende Fehler.
2. Maven-Konfiguration
2.1. Abhängigkeiten
Das Projekt nutzt spring-boot-starter-parent 3.x und Java 21. Fügen Sie die Standard-Kotlin-Bibliothek und kotlin-reflect hinzu. Spring benötigt kotlin-reflect, um Constructor-Parameter und Standard-Werte von Kotlin-Klassen zu lesen.
<properties>
<java.version>21</java.version>
<kotlin.version>2.1.0</kotlin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot verwaltet bereits die Versionen für Kotlin-Artefakte. Die Property kotlin.version dient nur dazu, die Standardversion zu überschreiben.
2.2. Kompilierreihenfolge
Dies ist der wichtigste Part. Der Kotlin-Compiler muss vor javac ausgeführt werden. Der Kotlin-Compiler kann Java-Quellcode verstehen. Er liest .java-Dateien, um Typen aufzulösen, kompiliert diese Dateien aber nicht. Umgekehrt kann javac überhaupt keine .kt-Dateien lesen. Java sieht Kotlin-Klassen nur, wenn deren .class-Dateien bereits vorhanden sind.
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<jvmTarget>21</jvmTarget>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>compile</id>
<goals><goal>compile</goal></goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<goals><goal>test-compile</goal></goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals><goal>compile</goal></goals>
</execution>
<execution>
<id>java-test-compile</id>
<phase>test-compile</phase>
<goals><goal>testCompile</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Einige wichtige Punkte in der obigen Konfiguration:
sourceDirsdeklariert auch das Verzeichnisjava. Dadurch kann der Kotlin-Compiler Java-Klassen lesen, wenn Kotlin Java aufruft.- Die beiden Executions
default-compileunddefault-testCompilewerden durch<phase>none</phase>deaktiviert. Anschließend werden sie mit neuen IDs neu deklariert. Maven führt Plugins in der gleichen Phase in der Reihenfolge ihrer Deklaration aus, daher läuftjavacnach dem Kotlin-Compiler. -Xjsr305=strictaktiviert strikte Nullability-Checks für Spring-Annotationen (siehe Abschnitt 3).<plugin>spring</plugin>ist das All-Open-Plugin für Spring (siehe Abschnitt 5).
Die Verzeichnisstruktur sieht folgendermaßen aus:
src/main/java/com/nonla/shop/Customer.java
src/main/java/com/nonla/shop/CheckoutController.java
src/main/kotlin/com/nonla/shop/Order.kt
src/main/kotlin/com/nonla/shop/PaymentService.kt
src/test/java/com/nonla/shop/OrderPricingTest.java
3. Java von Kotlin aus aufrufen
3.1. Getter werden zu Properties
Angenommen, Sie haben eine alte Java-Klasse:
package com.nonla.shop;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
public class Customer {
private final String name;
private final String email;
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
@NonNull
public String getName() {
return name;
}
@Nullable
public String getEmail() {
return email;
}
}
Von Kotlin aus werden Getter/Setter-Paare wie Properties behandelt:
package com.nonla.shop
fun greeting(customer: Customer): String {
val contact = customer.email ?: "noch keine E-Mail"
return "Hallo ${customer.name} ($contact)"
}
Kotlin ruft customer.name auf, aber tatsächlich ruft es getName() auf. Mit Customer("Lan", null) gibt die Funktion Folgendes zurück:
Hallo Lan (noch keine E-Mail)
3.2. Platform-Typ und Nullability
Java hat kein Konzept von Nullability im Typ-System. Wenn Kotlin auf einen Java-Typ ohne Annotation stößt, behandelt es diesen als Platform-Typ, geschrieben als String!. Bei Platform-Typen werden Fehler vom Compiler nicht gemeldet. Wenn der Wert null ist und Sie ihn als non-null verwenden, wirft das Programm zur Laufzeit eine NullPointerException.
@Nullable und @NonNull von Spring werden mit JSR-305 meta-annotiert. Mit -Xjsr305=strict versteht Kotlin, dass getEmail() String? zurückgibt. Der folgende Code wird nicht kompiliert:
val length = customer.email.length // Fehler: Only safe (?.) or non-null asserted (!!.) calls are allowed
Sie beheben dies mit customer.email?.length ?: 0.
Hinweis: Sie möchten nicht jede Methode mit Annotationen versehen? Erstellen Sie eine
package-info.javamit@NonNullApivon Spring. Diese Annotation behandelt alle Parameter und Rückgabewerte im Package als non-null, außer denen, die@Nullablehaben.
4. Kotlin von Java aus aufrufen
Kotlin hat viele Features, die Java nicht hat: Top-Level-Funktionen, Standardparameter, Companion Objects. Einige Annotationen helfen, die Kotlin-API natürlicher aussehen zu lassen, wenn Java sie aufruft.
4.1. Datenklasse und Top-Level-Funktionen
@file:JvmName("OrderPricing")
package com.nonla.shop
data class Order @JvmOverloads constructor(
val id: String,
val amount: Long,
val couponCode: String? = null
)
@JvmOverloads
fun totalWithShipping(
order: Order,
shippingFee: Long = 30_000,
freeShipThreshold: Long = 500_000
): Long =
if (order.amount >= freeShipThreshold) order.amount
else order.amount + shippingFee
Jede Annotation hat eine eigene Rolle:
@file:JvmName("OrderPricing"): Standardmäßig werden Top-Level-Funktionen inOrder.ktin die KlasseOrderKteingefügt. Diese Annotation ändert den Klassennamen zuOrderPricing.@JvmOverloadsbeim Constructor: Java versteht Standardparameter nicht. Diese Annotation erzeugt zusätzliche Overloads wieOrder(String, long), sodass Java nichtnullfürcouponCodeübergeben muss.@JvmOverloadsbei Funktionen: Erzeugt drei Overloads, die jeweils 1, 2 und 3 Parameter akzeptieren.
Java-Code ruft Kotlin so auf:
var order = new Order("ORD-1", 200_000L);
long total = OrderPricing.totalWithShipping(order);
System.out.println(order.getAmount() + " -> " + total);
System.out.println(order);
200000 -> 230000
Order(id=ORD-1, amount=200000, couponCode=null)
Die Kotlin-Property val amount erscheint in Java als getAmount(). Die Methoden toString(), equals(), hashCode() der Datenklasse funktionieren normal. Beachten Sie, dass Order unveränderbar ist: Java hat nur Getter, keine Setter.
4.2. Companion-Objekt und @JvmStatic
package com.nonla.shop
import java.util.concurrent.atomic.AtomicLong
class OrderIdGenerator {
companion object {
const val PREFIX = "ORD-"
private val counter = AtomicLong()
@JvmStatic
fun next(): String = PREFIX + counter.incrementAndGet()
}
}
Von Java aus:
String id = OrderIdGenerator.next(); // "ORD-1"
String prefix = OrderIdGenerator.PREFIX; // statische Konstante durch const
Ohne @JvmStatic müsste Java OrderIdGenerator.Companion.next() aufrufen. Dies funktioniert, ist aber länger und zeigt interne Details. Für nicht-const Properties verwenden Sie @JvmField, um sie als public static Feld zu exponieren.
4.3. Geprüfte Ausnahmen und @Throws
Kotlin hat keine geprüften Ausnahmen. Wenn eine Kotlin-Funktion IOException wirft, ohne dass Sie es deklarieren, kann Java nicht catch (IOException e) verwenden, weil javac meldet, dass diese Ausnahme nie geworfen wird. Verwenden Sie @Throws:
@Throws(java.io.IOException::class)
fun loadInvoice(path: String): String = java.io.File(path).readText()
5. Integration mit Spring Boot
Klassen und Methoden in Kotlin sind standardmäßig final. Gleichzeitig erstellt Spring Proxies (CGLIB) für Beans mit @Transactional, @Async, @Cacheable oder @Configuration. CGLIB-Proxies müssen die Original-Klasse erweitern. Das Plugin spring, das Sie in Abschnitt 2 deklariert haben, macht automatisch alle Klassen mit @Component, @Service, @Transactional usw. sowie deren Methoden open.
package com.nonla.shop
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class PaymentService {
@Transactional
fun pay(order: Order, customer: Customer): String {
val total = totalWithShipping(order)
return "Eingezogen $total VND von ${customer.name} für ${order.id}"
}
}
Ein Java-Controller injiziert diesen Kotlin-Bean ganz normal über den Constructor:
package com.nonla.shop;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CheckoutController {
private final PaymentService paymentService;
public CheckoutController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/checkout")
public String checkout() {
var order = new Order(OrderIdGenerator.next(), 600_000L);
return paymentService.pay(order, new Customer("Lan", null));
}
}
Ergebnis beim Aufruf von POST /checkout:
Eingezogen 600000 VND von Lan für ORD-1
Die Bestellung überschreitet die Schwelle von 500.000, daher kostenloser Versand.
Ohne das Plugin spring würde die Anwendung nicht starten, wenn Sie eine @Configuration-Klasse in Kotlin haben. Bei anderen Beans könnte @Transactional stillschweigend ignoriert werden, ohne Warnung.
6. Tests schreiben
Tests in Java zu schreiben, die Kotlin-Code aufrufen, ist eine gute Möglichkeit zu überprüfen, ob Ihre Kotlin-API wirklich benutzerfreundlich für Java ist. Wenn Tests schwer zu schreiben sind, werden Ihre Java-Benutzer auch Schwierigkeiten haben.
package com.nonla.shop;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OrderPricingTest {
@Test
void addsDefaultShippingFeeBelowThreshold() {
var order = new Order("ORD-1", 200_000L);
assertThat(OrderPricing.totalWithShipping(order)).isEqualTo(230_000L);
}
@Test
void freeShippingWhenAmountReachesThreshold() {
var order = new Order("ORD-2", 500_000L);
assertThat(OrderPricing.totalWithShipping(order)).isEqualTo(500_000L);
}
@Test
void customShippingFeeViaOverload() {
var order = new Order("ORD-3", 100_000L, "TET2026");
assertThat(OrderPricing.totalWithShipping(order, 15_000L)).isEqualTo(115_000L);
assertThat(order.getCouponCode()).isEqualTo("TET2026");
}
@Test
void dataClassEqualityWorksFromJava() {
assertThat(new Order("ORD-4", 1L)).isEqualTo(new Order("ORD-4", 1L));
}
}
Führen Sie mvn test aus. Im Log sehen Sie, dass Kotlin vor Java kompiliert wird:
[INFO] --- kotlin:2.1.0:compile (compile) @ shop ---
[INFO] --- compiler:3.13.0:compile (java-compile) @ shop ---
[INFO] --- kotlin:2.1.0:test-compile (test-compile) @ shop ---
[INFO] --- compiler:3.13.0:testCompile (java-test-compile) @ shop ---
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
7. Häufige Fehler
Java meldet cannot find symbol für eine Kotlin-Klasse. Die Ursache ist fast immer, dass javac vor dem Kotlin-Compiler läuft. Überprüfen Sie, ob Sie default-compile richtig deaktiviert haben und die Plugin-Reihenfolge in Abschnitt 2.2 korrekt ist.
NullPointerException wenn Kotlin Java aufruft. Dies ist eine Folge des Platform-Typs. Sie sollten @Nullable/@NonNull (oder @NonNullApi) zu Ihrem Java-Code hinzufügen und -Xjsr305=strict aktivieren. Eine andere Möglichkeit ist, den Typ am Rand explizit zu deklarieren, z. B. val email: String? = customer.email.
Kotlin sieht keinen von Lombok generierten Getter. Der Kotlin-Compiler liest Java-Quellcode, bevor Lombok ausgeführt wird. Daher weiß er nicht, welche Methoden @Getter oder @Data generieren werden. Sie können das Compiler-Plugin lombok für Kotlin hinzufügen (Artefakt kotlin-maven-lombok). Einfacher ist es, Lombok-Klassen, die Kotlin verwenden muss, zu Java Records oder Kotlin Data Classes zu konvertieren.
JPA-Entity in Kotlin. Hibernate benötigt einen parameterlosen Constructor und Klassen, die nicht final sind. Fügen Sie das Plugin jpa (no-arg, Artefakt kotlin-maven-noarg) neben dem Plugin spring hinzu. Verwenden Sie auch nicht data class für Entities, da die automatisch generierten equals/hashCode Probleme mit Lazy Loading verursachen.
Hinweis: Wann sollten die beiden Sprachen nicht gemischt werden? Wenn Ihr Team nur ein bis zwei Leute mit Kotlin kennt, steigen Review- und Wartungskosten. Die Build-Zeit wird auch langsamer, da es zwei Compiler gibt. Eine Alternative ist, Kotlin in ein separates Maven-Modul auszulagern. Die Grenze zwischen den Sprachen ist dann klarer, und Sie vermeiden gegenseitige Abhängigkeiten zwischen
.ktund.javaDateien.
Eine praktische Faustregel: Beginnen Sie mit Kotlin-Code mit wenigen Abhängigkeiten, wie DTOs, Hilfsfunktionen und Tests. Beginnen Sie nicht damit, die Kerngeschäftslogik umzuschreiben.
8. Fazit
Kotlin und Java können in einem Maven-Modul koexistieren. Sie brauchen nur eine Bedingung: Der Kotlin-Compiler muss vor javac laufen und beide Quellverzeichnisse lesen können. Wenn Kotlin Java aufruft, achten Sie auf Platform-Typen und verwenden Sie Nullability-Annotationen mit -Xjsr305=strict. Wenn Java Kotlin aufruft, helfen @JvmName, @JvmOverloads, @JvmStatic und @Throws, die API viel benutzerfreundlicher zu machen. Mit Spring Boot ist das Plugin spring (all-open) erforderlich, damit Proxies korrekt funktionieren. Schreiben Sie schließlich Tests in Java für Ihren Kotlin-Code, um schnell festzustellen, ob Ihre API wirklich Java-freundlich ist.