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 不理解默认参数。此注解生成额外的 overload Order(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 友好的方法。

kotlinjavaspring-bootmaveninterop