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に変更します。@JvmOverloadson constructor: Java はデフォルトパラメータを理解しません。このアノテーションは追加のオーバーロードOrder(String, long)を生成するため、Java はcouponCodeにnullを渡す必要はありません。@JvmOverloadson 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 にとってユーザーフレンドリーであるかどうかを確認する最速の方法です。