1年半開発してきて実感したサーバーサイドKotlinのメリット
こんにちは、サーバーサイドエンジニアの竹端です。
昨年の1月に、なぜサーバーサイドKotlinを導入するのか?という記事を書きKotlinでの開発を始めてから、約1年半が経ちました。
その中で移行した理由について様々なメリットを書きましたが、実際に1年半開発して、その結論としても「移行して良かった」と日々感じています。
今回は移行したことによってKotlinのメリットはどう活かされているのか、どういった効果があったのか、実践での経験を踏まえて紹介します。
※今回比較で書かれているJavaのコードは、弊社でKotlin移行をする前に使用していたJava8のものを使用しています。最新バージョンのJavaとは書き方の違う部分も一部あるかと思いますので、ご了承ください。
コンパイルで防げるエラーが多く安全性が高まった
Kotlinの有用性の一つとして、「安全」ということを紹介しました。
ここに関してが一番効果を感じることが大きいところです。
Null安全の存在により、Null許容、非許容の不整合がなくなった
Javaでは関数の引数で使う型は、標準ではNull許容になります。
標準でNull非許容とする方法はなく、アプリボットでは後述するLombokというサードパーティライブラリのアノテーションを使用して、Nullの非許容を実現していました。
下記のようにアノテーションを付けることで、実行時にエラーとすることができます。
private void createUser(@NotNull Integer userId) {
// ・・・
}
しかし、エラーが出るのは実行時なので、このメソッドの呼び出し側は下記のように書くこともできてしまいます。
public void execute(Integer userId) {
createUser(userId);
}
execute
メソッドの引数のuserIdはアノテーションが付いていないため、Null許容です。
そしてそのまま createUser
メソッドの引数として渡しているため、execute
メソッドの呼び出し側でNullを渡してしまいエラーになる可能性があります。
JavaはデフォルトがNull許容となるため、アノテーションを付け忘れる、もしくは意識せず書いてしまいこうなっていることもあると思います。
そこがKotlinでは、型レベルでNull許容、非許容を明示し、コンパイルでチェックしてくれるため、実装時に防ぐことができます。
前述のJavaコードをアノテーションを信用してKotlinでそのまま置き換えると下記になります。
fun execute(userId: Int?) {
createUser(userId)
}
private fun createUser(userId: Int) {
// ・・・
}
これはNull許容型のuserIdを、Null非許容型の引数として渡そうとしているため、コンパイルエラーになります。
そのため書いた時点で気付き、仕様に応じてNullチェックを入れる、もしくは execute
メソッドの引数のuserIdもNull非許容にする等の対応を入れることができます。
fun execute(userId: Int?) {
if (userId != null) {
createUser(userId)
}
}
fun execute(userId: Int) {
createUser(userId)
}
Javaで作ったコードをKotlinに置き換えている時にも、この形は何度か見つけました。
今まで意識していないと起こっていたNull許容、非許容の不整合が、自然と防げる状態になっていることでとても安全性が上がっていると思います。
また、Javaでは意識しておらずNull許容にしていた処理でも、Null非許容で問題ないことは多いです。
Kotlinでは型のデフォルトがNull非許容になっており、必要な時に意識してNull許容へするという形になっているので、不要なNull許容の扱いも減りました。
Nullチェックを入れた変数は、Kotlinのスマートキャストという機能で暗黙的にキャストされ、その後の処理ではNull非許容型として扱うことができます。
変数定義は基本的にImmutableにできる
Javaでは変数を定義する際、Immutable(変更不可)にしたい場合は final
修飾子を付ける必要がありました。
しかし、実際によく使用するのは定数を定義する時くらいで、処理中の変数定義で final
を意識して付けていることは少ないのではないかと思います。
KotlinではImmutableの場合は val
、 Mutable(変更可能)の場合は var
のどちらかを必ず付けなくてはならないため、そこを意識するようになりました。
そして使っていると、実際には今までJavaで final
を付けずに定義してきた箇所も、 val
で十分なことがほとんどでした。
更新は基本的にオブジェクトのプロパティにすることが多い
例えば一番よく使っているのは主にO/RマッパーのEntityクラスをはじめとする、Beanクラスのオブジェクトの取得です。
例えば下記のようなパターン。
val user = getUser(userId)
user.name = "hoge"
User
というデータクラスのオブジェクトを取得し、 name
のプロパティを書き換えるパターン。
変更しているのはプロパティの値なので、変数 user
自体はImmutableで問題ありません。
また、取得したオブジェクトを使用してさらに別の処理を呼び出す場合なども同様です。
fun execute(userId: Int) {
val user = getUser(userId)
user.name = "hoge"
updateUser(user)
}
private fun updateUser(user: User) {
// ・・・
}
実際のプロダクトで使うようなサーバーサイドのロジック中に出てくる変数は、こういったオブジェクトを扱うことが多く、定義した変数自体を書き換える処理はそもそも多くありません。
もちろん Int
や String
の変数を定義することもありますが、こちらもプロパティへ設定する値や、関数、数式のパラメータとしてそのまま渡すことが多いため、やはり更新する機会は少ないと思います。
下記に例を示します。
// Productのユーザー数を取得し、Productのプロパティに設定する処理
val product = getProduct(productId)
val userCount = getUserCount(productId)
product.userCount = userCount
fun getSales(productId: Int): Int {
// 商品の単価とユーザー数を取得し、売上を計算する処理
val price = getPrice(productId)
val userCount = getUserCount(productId)
return price * userCount
}
fun execute(user: User) {
// オブジェクトのプロパティを複数の箇所で呼び出す場合の一時領域として
val userId = user.id
start(userId)
play(userId)
end(userId)
}
私も1年以上ずっとKotlinで開発をしていますが、基本的には val
で書き、たまにそれでは不都合がある処理で意識的に var
を使うということが多いです。
varを使うパターン
もちろん var
を使うことがないわけではありません。
例えば、前述のコード内で度々出てきた、データクラスのプロパティは初期化後に更新する処理があるものは var
で定義しておく必要があります。
val
で書いてしまうとSetterが作られず、インスタンスの生成時にコンストラクタで設定した後に変更できなくなってしまいます。
data class User(val id: Int, var name: String)
また、数字をインクリメントしていくカウンターのような変数も、使いどころです。
fun calcAmmount(itemList: List<Item>) {
// アイテムの金額を加算していき、最大購入可能額を超えたら処理を終了する
var sumAmmount = 0
itemList.forEach {
if (sumAmmount + it.price > MAX_BUY_AMMOUNT) {
return
}
sumAmmount += it.price
}
}
設計やプロジェクトの方針によっても変わりますが、 var
を使うのはこういった最低限の箇所に留め、基本は val
とし変更不可にするのが安全だと思います。
Collectionも基本はImmutable
Kotlinでは List
Map
Set
といったCollectionもデフォルトはImmutableになります。
例えば下記のような List
型の値を返却する関数があります。
private fun getUserList(productId: Int): List<User> {
// ・・・
}
そしてこの関数の戻り値を受け取る変数 userList
は、Immutableのため add
など変更を加える関数がないため、書き換えることができません。
val userList = getUserList(productId)
前述のvalの話と同様で、データベースからレコードのリストを取得して処理する場合など、実際はリストに対して変更を加えられない方が良い場合も多いので、安全性が増したと思います。
変更可能なListを定義したい場合は、 MutableList
という型を使います。
ただ、KotlinのCollection操作の関数(JavaでいうStream API)は充実していて、Collectionの変更もそちらを使う方が分かりやすく簡単に書けるため、 MutableList
を使う頻度は少ないです。
(Collection操作に関しては後述します)
今回は List
を例に紹介しましたが、 Map
や Set
も同様にImmutableで、変更可能にする場合は MutableMap
MutableSet
を使用します。
モダンな機能で保守性も上がり、コード量も2割削減された
もともとJavaで作られていた開発基盤をKotlinへ移行し、コード量(ステップ数)で言うと約2割削減することができました。
また、コードがシンプルになったことにより、実装自体もやりやすくなったと感じます。
不要なキーワードの削除
導入時の記事では、「コードがシンプル」という項目で下記を紹介していました。
- アクセス修飾子のデフォルトがpublic
- 型推論
- String Template
- newキーワードが不要
- セミコロンが不要
- プロパティ
など細かなところですが、これらが積み重なることでコードの読みやすさは増していると思います。
実装時もIDEでの入力補完を使うことを前提としても、やはりタイプ量が減ることで実装スピードも上がったと感じます。
例えば下記は、User
というクラスのインスタンスを生成し、 registerUser
という関数でユーザを登録し、ユーザーオブジェクトの内容を出力している例です。
たったこれだけのコードでも、かなり短くなったと感じれると思います。
public void execute() {
User user = new User();
user.setId(1);
user.setName("Applibot");
registerUser(user);
System.out.println("id=" + user.getId() + "name=" + user.getName());
}
fun execute() {
val user = User(1, "Applibot")
registerUser(user)
println("id=${user.id} name=${user.name}")
}
Kotlinに限った話ではないですが、タイプ量の少ない言語は頭で意識しなければならない項目が減るため、その分ロジックを書くことに集中できます。
IDEで入力補完をする場合も、あくまで補完してくれるだけで書くこと自体は意識しないとならないですが、省略されていればそもそも意識する必要すらありません。
例えば型推論に関しては、変数を定義する度に型が何かを考えタイプしなければならなかったところが、変数名だけ書けばよくなるので一つ考えることが減ります。
セミコロンやnewキーワードなども、コード全体でいうとかなりの回数出てくるものになるので、都度意識しなくて良くなったのは地味ですが楽になります。
「意識する」「考える」ということをやらなくても良くなった箇所が増えたのは、タイプ量が減ることの大きなメリットではないかと考えています。
Lombokが不要になったことでアノテーションの削減
Javaで開発していた時は、Lombokというサードパーティライブラリを使用していました。
これはいわゆるBeanクラスを作成する際、アクセサメソッド(getter、setter)をはじめとするボイラープレートを減らすことのできるライブラリです。
アクセサメソッドはプロパティ、データクラス
Kotlinでは、クラスにプロパティの変数を定義すると、内部的にアクセサメソッドを作成し、アクセスする時にはそこを経由して値の取得、変更を実行します。
これはLombokでいう @Getter
@Setter
を付けた時のコードと同等です。
// Java
public class User {
@Getter
@Setter
private Integer id;
@Getter
@Setter
private String name;
}
// Kotlin
class User {
var id: Int = 0
var name: String = ""
}
また、データクラスを使用することで @Data
のアノテーションの機能も実現できます。
クラスの定義で「data」というキーワードを付け、コンストラクタにプロパティを羅列することで、アクセサメソッド、equals()、toString()、hashCode()、さらにcomponentN()、copy()という関数が作られます。
// Java
@Data
public class User {
private Integer id;
private String name;
}
// Kotlin
data class User(val id: Int, val name: String)
Builderはデフォルト引数&名前付き引数で
Lombokでは @Builder
というアノテーションを使用することで、そのクラスのBuilderを作成することができました。
Beanクラスのインスタンスを生成する時、下記の形で書くことができます。
@Builder
@Data
public class User {
private Integer id;
private String name;
private String profile;
private Integer age;
}
return User().builder()
.id(1)
.name("Applibot")
.age(30)
.build();
コンストラクタで設定するよりも、どのプロパティに値を設定しているかが分かりやすく書けます。
また、処理に応じて設定する必要のないプロパティは省くことも、柔軟にできるようになります。
Kotlinでは、前述のデータクラスで「デフォルト引数」と「名前付き引数」を使用することで実現できます。
まず、下記でデータクラスにデフォルト引数を設定します。
data class User(val id: Int, val name: String, val profile: String = "", val age: Int)
これはもし初期化時にプロパティに値が設定されなかった場合に、デフォルトで入る値を設定しています。
そして、呼び出し側では下記のように「名前付き引数」を使用します。
val user = User(id = 1, name = "Applibot", age = 30)
上記の例では、「profile」を省いています。
こちらを使用することで、不要な引数を省くことができます。
また、Javaで生成されたBeanクラスに対しては、スコープ関数という機能を使うことで、同等のことが実現できます。
let、with、run、apply、alsoといったものがあるのですが、例えば下記のように実装することができます。
val user = User().also {
it.id = 1
it.name = "Applibot"
it.age = 30
}
生成したインスタンスに対して、プロパティの値を設定するまでを単一の式で実装することができます。
高階関数、Type Aliasesで関数型を使う
Javaでは「関数型インターフェース」という機能を使うことで擬似的に関数型を扱っていましたが、Kotlinでは正式に「関数型」が存在します。
以前の記事でも紹介しました。
言語仕様として正式に「関数型」が存在することで、実装の幅が広がりました。
Javaでは関数型を使用する度、1つの型に1つのインターフェースを定義する必要があったものが、 typealias
で一行定義するだけで良くなったため、コード量の削減という意味でも読みやすくなっています。
// Java
@FunctionalInterface
public interface UserCalcFunction {
public Integer calc(User user);
}
// Kotlin
typealias UserCalcFunction = (User) -> Int
また、再利用などを特に考えなければ、高階関数として直接引数に関数リテラルを書くことで、より簡略化することもできます。
fun calc(userCalecFunction: (User) -> Int) {
// ・・・
}
引数の名前は付けることができるので、関数リテラルが長くなり過ぎなければ、可読性が低下するということもないかなと思います。
Collectionの機能が充実している
前述したように、KotlinはCollectionの機能がとても充実しています。
Javaでも8のバージョンからは「Stream API」が実装され機能が充実していますが、Kotlinではさらに強力になっています。
- map
- fileter
- forEach
- first、firstOrNull、last、last
- distinct
- associateBy、associateWith
- etc…
map
filter
forEach
などJavaでもよく使われるものはもちろん揃っています。
これらの関数も、JavaのStream APIに比べて書き方がシンプルになっており、実装もしやすいです。
下記の例では、先ほど作成した User
クラスのListを作成し、年齢が30以上のレコードを抽出してIDのListに変換する処理をしています。
// Java
List<User> list = Arrays.asList(user1, user2);
List<Integer> idList = list.stream()
.filter(u -> u.getAge() >= 30)
.map(u -> u.getId())
.collect(Collectors.toList());
// Kotlin
val list = listOf(user1, user2)
val idList = list.asSequence()
.filter { it.age >= 30 }
.map { it.id }
.toList()
目に見えてシンプルになっているのが分かりますね。
引数の名前を指定せず暗黙の引数として it
が使えることもあり、必要最低限の情報だけ記述すれば実装できるようになっています。
その他にも、例えば associateBy
associateWith
といった機能があります。
これは、keyやvalueを生成するラムダ式を記述し、ListからMapを生成する機能です。
例えば下記のような実装ができます。
val list = listOf(User(1, "one"), User(2, "two"))
val map = list.associateBy { it.id }
println(map)
// 出力結果 {1=User(id=1, name=one), 2=User(id=2, name=two)}
associateBy
はListに対して実行し、Mapのkeyにする値を定義するラムダ式を記述することでMapを生成することができます。
例では先ほど作成したデータクラス User
のlistに対して実行し、idをkey、UserのオブジェクトをvalueとしたMapが生成されます。
関数で記述している it.id
がMapのキーを定義する処理です(itはリストの各要素です)。
JavaでもStream APIを使用して下記のように書くことができますが、やはり少し複雑です。
(リストの生成部分を書くとさらに長くなるため、省略しています。)
Map<Integer, User> map = list.stream()
.collect(Collectors.toMap(
u -> u.getId(),
u -> u
));
associateWith
は逆にListの各要素のオブジェクトをkeyとし、ラムダ式で記述した処理がが返却した値をvalueとする関数です。
こちらはKotlin1.3から追加された、比較的新しい機能になります。
val idList = listOf(1, 2)
val map = idList.associateWith {
User(it, "name_$it")
}
println(map)
// 出力結果 {1=User(id=1, name=name_1), 2=User(id=2, name=name_2)}
用途としては、keyのリストを元にそれぞれのkeyに該当するオブジェクトを取得し、Mapを生成する場合などでしょうか。
ただ、弊社では現状使っている機会はあまりありません。
やはりJavaからの移行コストは低く抑えられた
対応が難しいものはJavaのまま使える
KotlinはJavaとの相互互換があり、併用することができることも、導入の理由の一つとして挙げていました。
実際に現在も一部Javaで使用している箇所はあります。
ツールなどで自動生成するファイル
一番多いのはツールで自動生成するファイルです。
弊社ではO/RマッパーとしてMyBatisを使用しているのですが、フレームワーク側で用意しているジェネレーターを使うことにより、テーブルの構造に紐付いたDTOクラスや、クエリの実行をするMapperクラスなどいくつかのファイルを自動生成しています。
こちらはJavaのフレームワークであり、Kotlinでのファイル生成には対応していないため、Javaのまま使用しています。
おなじような形で、gRPCで使うProtocol Buffersのファイルから自動生成したファイルがあります。
Protocol Buffersのコード生成は様々な言語に対応していますが、Kotlinへはまだ対応されていないため、現状はJavaで生成し使用しています。
いずれも自動生成のファイルのため、基本的に実装者が手を加えず、生成したものをそのまま使います。
そのため、Javaのコード自体はあるものの、実装者がJavaのコードを書くことは一切なくなっています。
Javaで作成していた自前のライブラリ
これまでずっとJavaを使用して開発してきたこともあり、Javaで作られた自前のライブラリも多くあります。
こちらのライブラリも、アプリケーションの実装者が手を加えるものではないので、Javaのまま使用しています。
ただ、Kotlin化されている方がより扱いやすくなる面はあるので、いずれはKotlin対応をしていく考えはあります。
同じフレームワークが使えることの容易さ
KotlinではJavaと同じフレームワークが使えることについては触れていましたが、Spring Frameworkがバージョン5.0からKotlinを正式にサポートしたことにより、さらに使いやすくなりました。
これによりJavaをやっていたエンジニアがKotlinを使い始めた時も、構文など言語仕様の違いに慣れれば良いだけで、システム全体のアーキテクチャを学習し直す必要がありません。
アプリボットでも、この1年半でKotlinを始めたエンジニアは徐々に増えていますが、導入時になにか詰まることもなく実装に入れていました。
一斉に移行するのであればIntelliJ IDEAの変換は早い
アプリボットでは、もともとJavaで作られていた開発基盤をIntelliJ IDEAのJavaからKotlinへの変換機能で変換し、そこで上手く変換されずコンパイルエラーの出ていた部分は手動で直すという対応をしました。
実際に変換してからエラーを解消する対応には2ヶ月ほどの時間がかかりましたが、フルスクラッチで作り直すのに比べるとかなり早く終わったと思います。
また、全体のコード量の中では手を加えずとも正常に変換されていた箇所の方が多く、基本的には同等のコードができあがっているため、完成時の精度も作り直しに比べてかなり高かったと思います。
言語の変更は通常かなりの工数がかかるものなので、こういった機能が提供されていていることは移行のしやすさの面でも大きかったです。
最後に
冒頭でも紹介した通り、サーバーサイドKotlinへ移行したことについては日々「良かった」と感じています。
Kotlinはまだ若い言語で、これからより発展していくと考えています。
今年のGoogle I/Oでも、Android開発における「Kotlinファースト」を強めて行くことが発表されたりと、世界的にも今後への期待が高いです。
Javaから移行して開発するというところには現状成功しているので、新たなKotlinのフレームワークやライブラリの導入を検討したりと、今後も積極的に新しいノウハウを取り入れて行きたいと考えています。
この記事へのコメントはありません。