Kotlin 和 Java 在同一个 Spring Boot Maven 项目中
1. 概述
许多团队想使用 Kotlin 但无法重写整个 Java 代码库。幸运的是,Kotlin 运行在 JVM 上,所以这两种语言可以存在于同一个模块中并直接相互调用。
在本文中,你将配置 Maven 来编译 Kotlin 和 Java。然后你将处理 Kotlin 调用 Java 时的可空性问题,并使用 @JvmStatic、@JvmOverloads、@JvmName 等注解使 Java 调用 Kotlin 更容易。最后是与 Spring Boot 3.x 集成的方法和常见错误。
2. Maven 配置
2.1. 依赖
项目使用 spring-boot-starter-parent 3.x 和 Java 21。你需要添加 Kotlin 标准库和 kotlin-reflect。Spring 需要 kotlin-reflect 来读取 Kotlin 类的构造参数和默认值。
<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 已经管理了 Kotlin 工件的版本。kotlin.version 属性仅用于覆盖默认版本。
2.2. 编译顺序
这是最重要的部分。Kotlin 必须在 Java 之前编译。 Kotlin 编译器可以理解 Java 源代码。它读取 .java 文件来解析类型,但不编译这些文件。相反,javac 完全无法读取 .kt 文件。Java 只有在 Kotlin 的 .class 文件已经存在时才能看到 Kotlin 类。
<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>
上面配置中需要注意的几点:
sourceDirs声明了java目录。这样 Kotlin 编译器就能读到 Java 类,当 Kotlin 调用 Java 时。- 两个 execution
default-compile和default-testCompile被<phase>none</phase>禁用。然后用新 id 重新声明。Maven 按声明顺序运行同一 phase 的插件,所以javac会在 Kotlin 之后运行。 -Xjsr305=strict为 Spring 的注解启用严格的可空性检查(见第 3 部分)。<plugin>spring</plugin>是 Spring 的 all-open 插件(见第 5 部分)。
目录结构如下:
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. 从 Kotlin 调用 Java
3.1. Getter 变成属性
假设你有一个旧的 Java 类:
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;
}
}
从 Kotlin 中,getX()/setX() 对可以像属性一样被访问:
package com.nonla.shop
fun greeting(customer: Customer): String {
val contact = customer.email ?: "未设置邮箱"
return "您好 ${customer.name} ($contact)"
}
Kotlin 调用 customer.name,但实际上是在调用 getName()。对于 Customer("Lan", null),函数返回:
您好 Lan (未设置邮箱)
3.2. Platform type 和可空性
Java 在其类型系统中没有 nullable 的概念。因此,当遇到没有注解的 Java 类型时,Kotlin 将其视为 platform type,记为 String!。对于 platform type,编译器不会捕获错误。如果值是 null 而你把它当作 non-null 使用,程序会在运行时抛出 NullPointerException。
Spring 的 @Nullable 和 @NonNull 被 JSR-305 meta-annotate。启用 -Xjsr305=strict 时,Kotlin 理解 getEmail() 返回 String?。以下代码将无法编译:
val length = customer.email.length // 错误:只允许安全的 (?.) 或 non-null 断言 (!!.) 调用
你可以用 customer.email?.length ?: 0 修复。
注意: 不想为每个方法都添加注解?在
package-info.java中放置 Spring 的@NonNullApi。此注解认为 package 中的所有参数和返回值都是 non-null,除非有@Nullable标记。
4. 从 Java 调用 Kotlin
Kotlin 有许多 Java 没有的特性:top-level function、默认参数、companion object。某些注解可以使 Kotlin API 在从 Java 调用时看起来更“自然”。
4.1. Data class 和 top-level function
@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
每个注解都有各自的作用:
@file:JvmName("OrderPricing"):默认情况下,Order.kt中的 top-level function 被放入名为OrderKt的类中。此注解将该类的名称更改为OrderPricing。- 构造函数上的
@JvmOverloads:Java 不理解默认参数。此注解生成额外的 overloadOrder(String, long),这样 Java 不必为couponCode传递null。 - 函数上的
@JvmOverloads:生成三个 overload,分别接收 1、2 和 3 个参数。
调用 Kotlin 的 Java 代码如下:
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)
Kotlin 的属性 val amount 在 Java 中显示为 getAmount()。data class 的方法 toString()、equals()、hashCode() 都可以正常使用。注意 Order 是不可变的:Java 只有 getter,没有 setter。
4.2. Companion object 和 @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()
}
}
从 Java 中:
String id = OrderIdGenerator.next(); // "ORD-1"
String prefix = OrderIdGenerator.PREFIX; // 由于是 const,所以是静态常量
缺少 @JvmStatic 的话,Java 必须调用 OrderIdGenerator.Companion.next()。这种调用方式可以运行,但很冗长且暴露了实现细节。对于非 const 的属性,可以使用 @JvmField 将其公开为静态字段。
4.3. Checked exception 和 @Throws
Kotlin 没有 checked exception。如果 Kotlin 函数抛出 IOException 但你没有声明,Java 将无法 catch (IOException e),因为 javac 会报告该异常永远不会被抛出。你可以使用 @Throws 来解决这个问题:
@Throws(java.io.IOException::class)
fun loadInvoice(path: String): String = java.io.File(path).readText()
5. 与 Spring Boot 集成
Kotlin 中的类和方法默认是 final 的。同时,Spring 为具有 @Transactional、@Async、@Cacheable 或 @Configuration 的 bean 创建代理(CGLIB)。CGLIB 代理需要继承原始类。你在第 2 部分声明的 spring 插件会自动 open 所有标有 @Component、@Service、@Transactional 等的类及其方法。
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 "已向 ${customer.name} 收取 $total 越南盾,用于订单 ${order.id}"
}
}
一个 Java controller 可以通过构造函数正常注入这个 Kotlin bean:
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));
}
}
调用 POST /checkout 的结果:
已向 Lan 收取 600000 越南盾,用于订单 ORD-1
订单超过 50 万的阈值,所以免运费。
如果没有 spring 插件,如果你有用 Kotlin 编写的 @Configuration 类,应用将无法启动。对于其他 bean,@Transactional 可能会被悄悄忽略。
6. 编写测试
用 Java 编写调用 Kotlin 代码的测试是检查 Kotlin API 是否对 Java 友好的好方法。如果测试难以编写,你的 Java 用户也会遇到困难。
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));
}
}
运行 mvn test。在日志中,你会看到 Kotlin 先于 Java 被编译:
[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. 常见错误
Java 报告找不到 Kotlin 类的 cannot find symbol。 原因几乎总是 javac 在 Kotlin 编译器之前运行。检查第 2.2 部分中 default-compile 的禁用和插件声明的顺序。
当 Kotlin 调用 Java 时出现 NullPointerException。 这是 platform type 的后果。你应该在 Java 代码中添加 @Nullable/@NonNull(或 @NonNullApi)并启用 -Xjsr305=strict。另一种方式是在边界处显式声明类型,例如 val email: String? = customer.email。
Kotlin 看不到 Lombok 生成的 getter。 Kotlin 编译器在 Lombok 运行之前读取 Java 源代码。因此,它不知道 @Getter 或 @Data 会生成哪些方法。你可以为 Kotlin 添加 lombok 编译器插件(artifact kotlin-maven-lombok)。更简洁的方式是将 Kotlin 需要使用的 Lombok 类转换为 Java record 或 Kotlin data class。
用 Kotlin 编写 JPA entity。 Hibernate 需要 entity 有无参构造函数且不是 final 的。在 spring 插件旁边添加 jpa 插件(no-arg,artifact kotlin-maven-noarg)。另外,不应该为 entity 使用 data class,因为自动生成的 equals/hashCode 会对 lazy loading 产生问题。
注意: 什么时候 不应该 混合这两种语言?如果你的团队只有一两个人懂 Kotlin,代码审查和维护的成本会增加。由于有两个编译器,构建时间也会变慢。另一个选择是将 Kotlin 分离到单独的 Maven 模块中。两种语言之间的边界会更加明确,你也可以避免
.kt和.java文件之间的循环依赖。
一条实用的规则:从低依赖的 Kotlin 代码开始,比如 DTO、实用函数和测试。不要从重写核心业务逻辑开始。
8. 总结
Kotlin 和 Java 可以在同一个 Maven 模块中共存。你只需要满足一个条件:Kotlin 编译器必须在 javac 之前运行并能读取两个源代码目录。当 Kotlin 调用 Java 时,注意 platform type 并结合 -Xjsr305=strict 使用可空性注解。当 Java 调用 Kotlin 时,@JvmName、@JvmOverloads、@JvmStatic 和 @Throws 可以使 API 更易用。对于 Spring Boot,spring 插件(all-open)是必须的,以确保代理能正常工作。最后,为 Kotlin 代码编写 Java 测试是快速验证你的 API 是否真正对 Java 友好的方法。