Kotlin と Java を Spring Boot の同一プロジェクトで使う

1. 概要

多くのチームが Kotlin を使いたいが、Java のコードベース全体を書き直すことはできません。幸いなことに、Kotlin は JVM 上で実行されるため、2 つの言語は同じモジュールに配置でき、相互に直接呼び出すことができます。

この記事では、Kotlin と Java の両方をコンパイルするように Maven を設定します。その後、Kotlin が Java を呼び出すときの null 許容性を処理し、@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 クラスを読むことができます。
  • default-compile と default-testCompile の 2 つの execution は <phase>none</phase> で無効化されます。その後、新しい id で再宣言されます。Maven は同じ phase のプラグインを宣言順に実行するため、javac は Kotlin の後 に実行されます。
  • -Xjsr305=strict は Spring アノテーションの null 許容性チェックを厳密に有効にします(セクション 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. ゲッターがプロパティになる

古い 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 ?: "chưa có email"
    return "Xin chào ${customer.name} ($contact)"
}

Kotlin は customer.name を呼び出していますが、実際には getName() を呼び出しています。Customer("Lan", null) の場合、関数は以下を返します:

Xin chào Lan (chưa có email)

3.2. Platform type と null 許容性

Java には型システムに null 許容性の概念がありません。したがって、アノテーションのない Java 型に遭遇した場合、Kotlin はそれを platform type と見なします。これは String! として記述されます。platform type では、コンパイラはエラーを報告しません。値が null で、それを非 null として使用すると、プログラムは実行時に NullPointerException をスローします。

Spring の @Nullable および @NonNull は JSR-305 でメタアノテーションされます。-Xjsr305=strict を有効にすると、Kotlin は getEmail() が String? を返すことを理解します。次のコード片は コンパイルできません:

val length = customer.email.length // エラー: Only safe (?.) or non-null asserted (!!.) calls are allowed

customer.email?.length ?: 0 で修正します。

注意: 各メソッドにアノテーションを付けたくないですか?Spring の @NonNullApi を package-info.java に配置してください。このアノテーションは、パッケージ内のすべてのパラメータと戻り値を非 null と見なします。ただし、@Nullable がある場所は除きます。

4. Java から Kotlin を呼び出す

Kotlin には Java にない多くの機能があります:トップレベル関数、デフォルトパラメータ、companion object。いくつかのアノテーションは、Java から呼び出すときに Kotlin API がより「自然に」見えるのに役立ちます。

4.1. Data class とトップレベル関数

@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 のトップレベル関数は OrderKt クラスに配置されます。このアノテーションはそのクラスの名前を OrderPricing に変更します。
  • @JvmOverloads on constructor: Java はデフォルトパラメータを理解しません。このアノテーションは追加のオーバーロード Order(String, long) を生成するため、Java は couponCode に null を渡す必要はありません。
  • @JvmOverloads on function: 3 つのオーバーロードを生成し、それぞれ 1、2、3 のパラメータを受け入れます。

Java から Kotlin を呼び出すコードは次の通りです:

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 は immutable であることに注意してください:Java にはゲッターのみがあり、セッターはありません。

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 のおかげで static 定数

@JvmStatic がない場合、Java は OrderIdGenerator.Companion.next() を呼び出す必要があります。この呼び出し方法は機能しますが、長く、実装の詳細が明らかになります。const ではないプロパティの場合、@JvmField を使用してそれを public static フィールドとして公開します。

4.3. Checked exception と @Throws

Kotlin には checked exception がありません。IOException をスローする Kotlin 関数を宣言しない場合、javac はその例外が発生しないと報告するため、Java は catch (IOException e) を実行できません。@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 プラグインは、@Component、@Service、@Transactional などでマークされたすべてのクラスとそのメソッドを自動的に 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 "Đã thu $total VND của ${customer.name} cho ${order.id}"
    }
}

Java コントローラは、コンストラクタを通じてこの 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 を呼び出したときの結果:

Đã thu 600000 VND của Lan cho ORD-1

注文が 500,000 のしきい値を超えているため、送料は無料です。

spring プラグインがない場合、Kotlin で書かれた @Configuration クラスがあると、アプリケーションは起動できません。他の bean の場合、@Transactional は警告なしに無視される可能性があります。

6. テストの作成

Kotlin コードを呼び出す Java で書かれたテストは、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 によって生成されたゲッターを認識しません。 Kotlin コンパイラは Lombok が実行される前に Java ソースコードを読みます。したがって、@Getter または @Data がどのメソッドを生成するかを認識しません。Kotlin に lombok コンパイラプラグインを追加できます(artifact kotlin-maven-lombok)。より簡潔な方法は、Kotlin が使用する必要のある Lombok クラスを Java record または Kotlin data class に変換することです。

Kotlin で書かれた JPA entity。 Hibernate は、パラメータのないコンストラクタと final ではないエンティティが必要です。spring プラグインの横に jpa プラグイン(no-arg、artifact kotlin-maven-noarg)を追加します。さらに、entity に data class を使用しないでください。自動生成される equals/hashCode は遅延ロードで問題を引き起こします。

注意: いつ 2 つの言語を混在させてはいけないのですか?チームに Kotlin を知っている人が 1 ~ 2 人しかいない場合、レビューと保守のコストが増加します。2 つのコンパイラがあるため、ビルド時間も遅くなります。別の選択肢として、Kotlin を別の Maven モジュールに分けることができます。その場合、2 つの言語間の境界が明確になり、.kt ファイルと .java ファイル間の循環依存を回避できます。

実践的なルール:DTO、ユーティリティ関数、テストなど、依存関係の少ない Kotlin コードから始めてください。コア・ビジネス・ロジックの書き直しから始めないでください。

8. まとめ

Kotlin と Java は 1 つの Maven モジュール内で共存できます。必要なのは 1 つの条件だけです:Kotlin コンパイラが javac の前に実行され、両方のソースコード ディレクトリを読むことができる必要があります。Kotlin が Java を呼び出すときは、platform type に注意し、-Xjsr305=strict と組み合わせて null 許容性アノテーションを使用してください。Java から Kotlin を呼び出すときは、@JvmName、@JvmOverloads、@JvmStatic、@Throws が API をはるかに使いやすくするのに役立ちます。Spring Boot では、プロキシが正しく機能するために spring プラグイン(all-open)が必須です。最後に、Kotlin コード用の Java テストを作成することは、API が本当に Java にとってユーザーフレンドリーであるかどうかを確認する最速の方法です。

kotlinjavaspring-bootmaveninterop