実際のプロジェクトにおける Kotlin の評価手法

Read this post in other languages:

Urs Peter(シニアソフトウェアエンジニア、JetBrains 認定 Kotlin トレーナー)によるゲスト投稿です。 Urs は、より体系的な Kotlin スキルの構築を希望する開発者を対象とする KotlKotlin Upsukill Program(Kotlin スキルアッププログラム)も Xebia Academy で開講しています。

この記事は、実際のチームである開発者の好奇心をきっかけに会社全体が変革するまでの Kotlin の導入経緯を追った「Java 主体の環境で Kotlin の導入を成功させるための究極ガイド」連載記事の第 2 回です。

連載の第 1 回を読む: Java 開発者向け Kotlin の基礎


評価段階: プレイグラウンドでの Kotlin を脱却

テストで Kotlin に慣れたら、より本格的な評価を始めましょう。 これには主に 2 つの方法があります。

  1. Kotlin で新しいマイクロサービス / アプリケーションを構築する
  2. 既存の Java アプリケーションを拡張 / 変換する

1. Kotlin で新しいマイクロサービス / アプリケーションを構築する

新規のアプリケーションまたはマイクロサービスを使って新たに始めると、既存コードの制約を受けずに完全な Kotlin エクスペリエンスを提供できます。 この方法では最高の学習エクスペリエンスを得られることが多く、Kotlin の強みを最もよく理解することができます。

プロ開発者からのヒント: この段階では専門家からの支援を得ましょう。 開発者が自分の能力に自信を持つのは無理からぬことですが、Java ライクな Kotlin や Kotlin ベースのライブラリの不使用といったミスを早い段階で回避することで、数か月分の技術的負債をなくすことができます。

そうすることで、以下のような Java 経験者が Kotlin を使用する際のよくある落とし穴を回避することができます。

落とし穴: 自分が Java で使用しているフレームワークとは別のフレームワークを選択する。

ヒント: 既存のフレームワークをそのまま使用しましょう

Java で Spring Boot を使用していたのなら、Kotlin でもそれを使用しましょう。 Spring Boot は最高水準の Kotlin のサポートを提供しているため、他のフレームワークを使用するメリットは特にありません。 さらに、新しい言語も新しいフレームワークも学ばざるを得なくなるため、事が複雑化するだけで何のメリットもありません。

重要事項: Spring は拡張対象のクラスに open を明示的に指定することを要求する Kotlin の「意図的な継承」の原則に干渉します。

open キーワードをすべての Spring 関連クラス(@Configuration など)に追加しないようにするには、https://kotlinlang.org/docs/all-open-plugin.html#spring-support にあるビルドプラグインを使用できます。 有名なオンライン Spring initializr ツールで Spring プロジェクトを作成した場合は、このビルドプラグインはあらかじめ構成されています。

落とし穴: Java ライクな Kotlin を書く、Kotlin の標準ライブラリではなく一般的な Java API を使用する:

すべての落とし穴を列挙すると非常に長くなってしまうので、最もよくあるものに絞りましょう。

落とし穴 1: Kotlin のコレクションではなく Java のストリームを使用する

ヒント: 常に Kotlin のコレクションを使用しましょう。

Kotlin のコレクションは Java コレクションと完全に相互運用可能ですが、Java ストリームの必要性をなくす簡潔で機能豊富な高階関数が備わっています。

以下は収益別(単価 x 販売数)に上位 3 つの商品を選択し、商品カテゴリでグループ化することを目的とした例です。

Java

record Product(String name, String category, double price, int sold){}

List products = List.of(
           new Product("Lollipop", "sweets", 1.2, 321),
           new Product("Broccoli", "vegetable", 1.8, 5);

Map<String, List> top3RevenueByCategory =
       products.stream()
          .collect(Collectors.groupingBy(
                Product::category,
                Collectors.collectingAndThen(
                    Collectors.toList(),
                    list -> list.stream()
                              .sorted(Comparator.comparingDouble(
                                  (Product p) -> p.price() * p.sold())
                                   .reversed())
                                   .limit(3)
                                   .toList()
                       		)
          )
);

Kotlin

val top3RevenueByCategory: Map<String, List> =
   products.groupBy { it.category }
       .mapValues { (_, list) ->
           list.sortedByDescending { it.price * it.sold }.take(3)
       }

Kotlin の Java との相互運用性により、Java のクラスとレコードをネイティブな Kotlin であるかのように扱うことができます。ただし、Kotlin の(data)クラスを代わりに使用することも可能です。

落とし穴 2: Java の Optional を使用し続ける

ヒント: null 許容型を取り入れましょう

Java 開発者が Kotlin に移行する主な理由の 1 つには、Kotlin が null 許容性を組み込みでサポートしているため、NullPointerExceptions を使用する必要がなくなることがあります。 そのため、Optional ではなく、null 許容型を使用するようにしましょう。 まだインターフェースに Optional を使用していますか? 以下のようにすると、Optional を簡単に null 許容型に変換して削除できます。

Kotlin

//Let’s assume this repository is hard to change, because it’s a library you depend on
class OrderRepository {
      //it returns Optional, but we want nullable types
      fun getOrderBy(id: Long): Optional = …
}

//Simply add an extension method and apply the orElse(null) trick
fun OrderRepository.getOrderByOrNull(id: Long): Order? = 
                                    getOrderBy(id).orElse(null)

//Now enjoy the safety and ease of use of nullable types:

//Past:
 val g = repository.getOrderBy(12).flatMap { product ->
     product.goody.map { it.name }
}.orElse("No goody found")

//Future:
 val g = repository.getOrderByOrNull(12)?.goody?.name ?: "No goody found"

落とし穴 3: 静的ラッパーを使用し続ける

ヒント: 拡張メソッドを取り入れましょう

拡張メソッドには以下のような多くのメリットがあります。

  • ラッパーよりもコードの流れがスムーズになり、読みやすくなります。
  • コード補完の対象になります。ラッパーは対象になりません。
  • 拡張はインポートして使用する必要があるため、拡張した機能をアプリケーションの特定部分で選択的に使用することができます。

Java

//Very common approach in Java to add additional helper methods
public class DateUtils {
      public static final DateTimeFormatter DEFAULT_DATE_TIME_FORMATTER = 
           DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

      public String formatted(LocalDateTime dateTime, 
		              DateTimeFormatter formatter) {
         return dateTime.format(formatter);
      }

      public String formatted(LocalDateTime dateTime) {
         return formatted(dateTime, DEFAULT_DATE_TIME_FORMATTER);
      }
}

//Usage
 formatted(LocalDateTime.now());

Kotlin

val DEFAULT_DATE_TIME_FORMATTER: DateTimeFormatter = 
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

//Use an extension method, including a default argument, which omits the need for an overloaded method. 
fun LocalDateTime.asString(
   formatter: DateTimeFormatter = DEFAULT_DATE_TIME_FORMATTER): String = 
      this.format(formatter)

//Usage
LocalDateTime.now().formatted()

Kotlin はトップレベルのメソッドと変数を提供することに注意してください。 つまり、DEFAULT_DATE_TIME_FORMATTER などのトップレベルを Java のようにオブジェクトにバインドせずに簡単に宣言できるということです。

落とし穴 4: (不安定な)Java API を利用する

ヒント: Kotlin の安定した API を使用しましょう。 

Kotlin の標準ライブラリは今も Java の実装をベースにしていますが、拡張メソッドが使用されているため、Java ライブラリの使い勝手が良くなっています。 Spring など、ほぼすべての主要サードパーティライブラリとフレームワークでも同じことが行われています。

標準ライブラリの例:

Java

String text;
try (
       var reader = new BufferedReader(
                  new InputStreamReader(new FileInputStream("out.txt"), 
            StandardCharsets.UTF_8))) {
   text = reader
            .lines()
            .collect(Collectors.joining(System.lineSeparator()));
}
System.out.println("Downloaded text: " +  text + "\n");

Kotlin

//Kotlin has enhanced the Java standard library with many powerful extension methods, like on java.io.*, which makes input stream processing a snap due to its fluent nature, fully supported by code completion

val text = FileInputStream("path").use {
             it.bufferedReader().readText()
           }
println("Downloaded text: $text\n");

Spring の例:
Java

final var books =  RestClient.create()
       .get()
       .uri("http://.../api/books")
       .retrieve()
       .body( new ParameterizedTypeReference<List>(){}); // ⇦ inconvenient ParameterizedTypeReference

Kotlin

import org.springframework.web.client.body
val books = RestClient.create()
   .get()
   .uri("http://.../api/books")
   .retrieve()
   .body<List>() //⇦ Kotlin offers an extension that only requires the type without the need for a ParameterizedTypeReference

落とし穴 5: public クラスごとに個別のファイルを使用している

ヒント: 関連する public クラスを 1 つのファイルにまとめましょう。 

そうすることで、多数のファイル間を移動しなくても(サブ)ドメインの構造を十分に理解できます。

Java

Kotlin

//For domain classes consider data classes - see why below
data class User(val email: String,
            //Use nullable types for safety and expressiveness
           val avatarUrl: URL? = null, 
           var isEmailVerified: Boolean)

data class Account(val user:User,
              val address: Address,
              val mfaEnabled:Boolean,
              val createdAt: Instant)

data class Address(val street: String,
              val city: String,
              val postalCode: String)

落とし穴 6: 可変性を前提とするプログラミングパラダイムを利用している

ヒント: Kotlin の標準である不変性を取り入れましょう。

Java を含む多くのプログラミング言語で可変性よりも不変性が好まれる傾向にあることは明らかです。

その理由は明らかです。不変性によって意図しない副作用が未然に防がれ、コードの安全性と予測性が高まり、理解しやすくなるためです。 また、不変データはレースコンディションの発生リスクもなくスレッド間で自由に共有できるため、並行処理が単純化されます。

そのため、Kotlin を含む大多数のモダンな言語では、不変性が標準で強調されているか、強く推奨されています。 Kotlin では、可変性が本当に必要となった場合の選択肢として残されているものの、不変性が標準となっています。

以下に、Kotlin の強力な不変性一式について、簡単に説明します。

1. var ではなく val を使用する

var よりも val を優先しましょう。 IntelliJ IDEA は val を使用できる箇所で var を使用している場合に通知してくれます。

2. copy(...) で(不変)data クラスを使用する

ドメイン関連のクラスでは、data クラスを val と一緒に使用しましょう。 Kotlin の data クラスはよく Java の records と比較されます。 これらは共通する部分もありますが、data クラスはキラー機能である copy(...) を提供しています。これがない場合、ビジネスロジックで必要となることの多い record の変換が非常に面倒になります。

Java

//only immutable state
public record Person(String name, int age) {
   //Lack of default parameters requires overloaded constructor
   public Person(String name) { 
       this(name, 0);
   }
   //+ due to lack of String interpolation
  public String sayHi() {
       return "Hello, my name is " + name + " and I am " + age + " years old.";
   }
}

//Usage
final var jack = new Person("Jack", 42);
jack: Person[name=Jack, age=5]

//The issue is here: transforming a record requires manually copying the identical state to the new instance ☹️
final var fred = new Person("Fred", jack.name);

Kotlin

//also supports mutable state (var)
data class Person(val name: String,
                  val age: Int = 0) {
  //string interpolation
  fun sayHi() = "Hi, my name is $name and I am $age years old."
}
val jack = Person("Jack", 42)
jack: Person(name=Jack, age=42)

//Kotlin offers the copy method, which, due to the ‘named argument’ feature, allows you to only adjust the state you want to change 😃
val fred = jack.copy(name = "Fred")
fred: Person(name=Fred, age=42)

さらに、ドメイン関連のクラスにはできる限り data クラスを使用しましょう。 その不変性により、アプリケーションのコアを扱うのが安全、明快、簡単になります。

ヒント: 可変コレクションよりも不変コレクションを優先しましょう。

不変コレクションにはスレッド安全性に関する明確なメリットがあり、安全に渡すことができます。また、理解しやすいという特徴もあります。 Java のコレクションはコレクションに幾分かの不変性を与えますが、実行時例外を引き起こしやすいため、使用するのは危険です。

Java

List.of(1,2,3).add(4); ❌unsafe 😬! .add(...) compiles, but throws UnsupportedOperationException

Kotlin

//The default collections in Kotlin are immutable (read-only)
listOf(1,2,3).add(4);  //✅safe: does not compile

val l0 = listOf(1,2,3) 
val l1 = l0 + 4 //✅safe: it will return a new list containing the added element
l1 shouldBe listOf(1,2,3,4) //✅

同じことは Collections.unmodifiableList(...) を使用する場合にも当てはまります。安全でないだけでなく、追加の割り当ても必要です。

Java

class PersonRepo {
   private final List cache = new ArrayList<>();
   // Java – must clone or wrap every call
   public List getItems() {
       return Collections.unmodifiableList(cache);   //⚠️extra alloc
   }
}

//Usage
personRepo.getItems().add(joe) ❌unsafe 😬! .add(...) can be called but throws UnsupportedOperationException

Kotlin

class PersonRepo {

//The need to type ‘mutable’ for mutable collections is intentional: Kotlin wants you to use immutable ones by default. But sometimes you need them:

   private val cache: MutableList = mutableListOf()

   fun items(): List = cache //✅safe: though the underlying collection is mutable, by returning it as its superclass List<...>, it only exposes the read-only interface

}

//Usage
personRepo.items().add(joe) //✅safe:😬! Does not compile

並行処理に関しては、コレクションも含めて不変データ構造を優先すべきです。 Java の場合、CopyOnWriteArrayList のような別の API や制限のある API を提供する特別なコレクションでは要求される労力が比較的多くなります。 それに対し、Kotlin では読み取り専用の List<...> がほぼすべてのユースケースに対応します。

スレッドセーフな可変コレクションが必要な場合、Kotlin では永続コレクション(persistentListOf(...)persistentMapOf(...))を使用できます。どちらも同じ強力なインターフェースを共有しています。

Java

ConcurrentHashMap<String, Integer> persons = new ConcurrentHashMap<>();
persons.put("Alice", 23);
persons.put("Bob",   21);

//not fluent and data copying going on
Map<String, Integer> incPersons = new HashMap<>(persons.size());
persons.forEach((k, v) -> incPersons.put(k, v + 1));

//wordy and data copying going on
persons
   .entrySet()
   .stream()
   .forEach(entry -> 
      entry.setValue(entry.getValue() + 1));

Kotlin

persistentMapOf("Alice" to 23, "Bob" to 21)
         .mapValues { (key, value) -> value + 1 } //✅same rich API like any other Kotlin Map type and not data copying going on

落とし穴 7: Builder を使用し続ける(Lombok を使用しようとしているならなお悪い) 

ヒント: 名前付き引数を使用しましょう。

Builder は Java では非常に一般的です。 便利ではありますが、無駄なコードが追加され、安全ではなく、複雑さが増します。 Kotlin では単純な言語機能である名前付き引数があるため、Builder は無用の長物と化しています。

Java

public record Person(String name, int age) {

   // Builder for Person
   public static class Builder {
       private String name;
       private int age;

       public Builder() {}

       public Builder name(String name) {
           this.name = name;
           return this;
       }

       public Builder age(int age) {
           this.age = age;
           return this;
       }

       public Person build() {
           return new Person(name, age);
       }
   }
}

//Usage
new JPerson.Builder().name("Jack").age(36).build(); //compiles and succeeds at runtime

new JPerson.Builder().age(36).build(); //❌unsafe 😬: compiles but fails at runtime.

Kotlin

data class Person(val name: String, val age: Int = 0)

//Usage - no builder, only named arguments.
Person(name = "Jack") //✅safe: if it compiles, it always succeeds at runtime
Person(name = "Jack", age = 36) //✅

2. 既存の Java アプリケーションを拡張 / 変換する

まったく新しい環境で Kotlin を試す選択肢がない場合、新しい Kotlin 機能または Kotlin モジュール全体を既存の Java コードベースに追加することができます。 Kotlin のシームレスな Java との相互運用性により、Java の呼び出し元に Java のように見える Kotlin コードを書くことができます。 この方法では以下を実現できます。

  • 大規模な書き換えを行わずに段階的に移行する
  • 特定の状況で Kotlin を実際にテストする
  • 本番環境の Kotlin コードでチームの自信を築く

適当な場所から始めるのではなく、以下の導入方法を検討してみましょう。

外側から着手:

コントローラーやバッチジョブなど、アプリケーションの「末端」部分から着手し、コアドメインに向かって導入を進めます。 この場合、次のようなメリットがあります。

  • コンパイル時間の分離: 末端のクラスが末端のクラスに依存することはまれであるため、システムの他の部分を変更せずに末端のクラスのみを Kotlin に書き換えることが可能です。
  • 他に波及する編集がほとんどない。 シームレスな相互運用性があるため、ほぼ変更を加えることなく変換後の UI/コントローラーから既存の Java ドメインコードを呼び出せます。
  • プルリクエストが小さくなり、レビューが簡単になる。 ファイル単位または機能単位で移行できます。

内側から着手:

コアから着手して外側のレイヤーに向かって導入を進める方法は、往々にしてリスクが高まります。上記の外側から着手する方法のメリットが損なわれてしまうためです。 ただし、以下のような場合は実行可能な選択肢になります。

  • コアが非常に小さいか自己完結型である。 ドメインレイヤーに少数の POJO とサービスしかない場合、早めに書き換えるとコストが下がり、慣用的なコンストラクト(data クラス、値クラス、sealed 階層)をすぐに利用できるようになる場合があります。
  • いずれにしてもアーキテクチャを書き直す。 移行の際に非変クラスをリファクタリングしたり、DDD パターン(値オブジェクト、集計)を導入したりする予定がある場合は、最初に Kotlin でドメインを設計し直す方がクリーンになる場合があります。
  • 厳格な null 安全性コントラクトを使用する。 Kotlin を中心に据えるとドメインが「null 安全の要塞」になります。外側の Java レイヤーは null を送信できますが、境界が明示的になって監視しやすくなります。

モジュール単位で変換:

  • アーキテクチャがレイヤーではなく機能ごとにまとめられており、モジュールが管理可能なサイズである場合、1 つずつ変換していくのが最適な戦略です。

Java から Kotlin に変換するための言語機能

Kotlin には、Kotlin コードがネイティブの Java のように動作することを可能にする各種の機能(主にアノテーション)が備わっています。 これは、Kotlin と Java が同じコードベース内に共存するハイブリッド環境で特に有用です。
Kotlin

class Person @JvmOverloads constructor(val name: String,
                          var age: Int = 0) {
  companion object {

  @JvmStatic
  @Throws(InvalidNameException::class)
  fun newBorn(name: String): Person = if (name.isEmpty()) 
       throw InvalidNameException("name not set")
     else Person(name, 0)

   @JvmField
   val LOG = LoggerFactory.getLogger(KPerson.javaClass)
  }
}

Java

//thanks to @JvmOverloads an additional constructor is created, propagating Kotlin’s default arguments to Java
var john =  new Person("John");

//Kotlin automatically generates getters (val) and setters (var) for Java
john.setAge(23);
var name = ken.getName();

//@JvmStatic and @JvmField all accessing (companion) object fields and methods as statics in Java

//Without @JvmStatic it would be: Person.Companion.newBorn(...)
var ken =  Person.newBorn("Ken"); 

//Without @JvmField it would be: Person.Companion.LOG
Person.LOG.info("Hello World, Ken ;-)");

//@Throws(...) will put the checked Exception in the method signature 
try {
  Person ken =  Person.newBorn("Ken");
} catch (InvalidNameException e) {
  //…
}

Kotlin

@file:JvmName("Persons")
package org.abc

@JvmName("prettyPrint")

fun Person.pretty() =
      Person.LOG.info("$name is $age old")

Java

//@JvmName for files and methods makes accessing static fields look like Java: without it would be: PersonKt.pretty(...)
Persons.prettyPrint(ken)

IntelliJ IDEA の Java から Kotlin への変換ツール

IntelliJ IDEA には Java から Kotlin への変換ツールが備わっているため、理屈の上ではツールに変換を任せることができます。 ただし、生成されるコードは完璧と言えるものではないため、たたき台としてのみ使用するようにしましょう。 その後、より Kotlin 風の表現に変換できます。 これについては、この連載の最後となる「大規模な Kotlin の導入を実現する成功要因」で取り上げます。

Java から始めた場合は Java ライクな Kotlin を書く可能性が高くなり、幾分かのメリットはあるものの、Kotlin の潜在的な能力は発揮されません。 そのため、アプリケーションを新規に作成する方法をお勧めします。

この連載の次の記事

Java 主体の環境で Kotlin の導入を成功させるための究極ガイド」ブログ連載記事のこの回では、Kotlin の実験が本番環境のコードに進化する方法を紹介しました。 次の記事では、関係者の説得という導入時の人的側面に注目します。 明確でコード主導の議論を提示し、新しい開発者をガイドし、小規模ながらも持続可能な Kotlin コミュニティをチーム内に作る方法を説明します。

オリジナル(英語)ブログ投稿記事の作者:

Urs Peter

Urs は経験豊富なソフトウェアエンジニア、ソリューションアーキテクト、カンファレンス講演者、トレーナーであり、主に Kotlin と Scala が関わるレジリエントで拡張可能、かつミッションクリティカルなシステムの構築に 20 年以上携わってきました。

コンサルタント業の傍ら、Kotlin と Scala の言語コースからマイクロサービスやイベント駆動型アーキテクチャといったアーキテクチャ関連トレーニングに至る多種多様なコースのトレーナーと作成者としても情熱的に活動しています。

元々人との交流を好む性格であり、ミートアップやカンファレンスで他の開発者と知識を共有し、刺激し合うことを楽しんでいます。 Urs は JetBrains 認定 Kotlin トレーナーです。

Alyona Chernyaeva

Alyona Chernyaeva

image description