新規参加メンバーにも優しいKotlinによる型安全な世界
はじめまして、5月にログラスに入社した三田(@eichisanden)です。
ログラスに入社してから、型の恩恵を強く感じる場面が多くありました。
今回は、私が特に良いと感じた実装方法をいくつか紹介したいと思います。
※この記事のコードは、説明のために書いたサンプルコードになります
sealed クラスによるパターンマッチで実装漏れを防ぐ
Kotlinのsealedクラスとインターフェース(以下「sealedクラス」という)は、クラスの継承やインターフェイスの実装を制限することでサブクラスがコンパイル時に決まるため型安全な実装が可能になります。
sealedクラスは、代数的データ型のように型を列挙し、それぞれの型に応じた処理を記述できるようになり、特にパターンマッチングにおいて非常に強力な力を発揮します。
例えば、エラーを表現するsealedインターフェースとそれを実装したクラスを定義したとします
sealed interface UserNameError {
val message: String
data class EmptyError(override val message: String) : UserNameError
}
fun handleError(userNameError: UserNameError) {
when (userNameError) {
is UserNameError.EmptyError -> ...エラーハンドリング処理
}
}
次にエラーの種類が増えたと想定してsealedインターフェースを実装したクラスを追加します
sealed interface UserNameError {
val message: String
data class EmptyError(override val message: String) : UserNameError
data class LengthError(override val message: String) : UserNameError // 追加
}
すると、when式で条件を網羅できなくなりコンパイルエラーになります
private fun handleError(userNameError: UserNameError) {
when (userNameError) {
- ~~~~ 'when' expression must be exhaustive, add necessary 'is LengthError' branch or 'else' branch instead
is UserNameError.EmptyError -> ...エラーハンドリング処理
}
}
実際、担当した機能の実装でエラーの種類を追加した場面で、コンパイルエラーにより影響箇所の対応漏れに即座に気づくことができました。
特に入社直後でコードベースの理解が浅く、影響箇所の把握ができていなかったため、型による恩恵を特に感じた場面でした。
共有ライブラリとして提供するようなものでなければ常にsealedをつける癖をつけておくと良いかと思います。
kotlin-resultによるエラーハンドリング
自分はJavaでの開発経験が長く、エラー処理=例外を投げる というのが普通のことでした。
Javaではそれでも何とかやっていましたが、Kotlinには検査例外の概念がなく、シグネイチャでエラーを明示してエラーハンドリングを強要する仕組みがなく困ることもありました。
過去に困ったのは、Spring FrameworkでトランザクションをAOPで管理する@Transactional
を付与した関数以下の処理で検査例外をcatchし忘れるとトランザクションがコミットされてしまうことでした [1][2]
この仕様を知らないと、ある日例外発生時にDBが中途半端な状態でコミットされるような恐ろしいバグを踏む可能性があります。
kotlin-resultでエラー処理を行う
kotlin-resultは、関数の成功と失敗を戻り値を型で表現するライブラリです。
実際のコードで説明します
- 関数の戻り値は
Result<成功時の戻り値の型、失敗時の戻り値の型>
と宣言する - 処理成功時は
Result<成功時の戻り値、Nothing>
を返却する(Ok()を呼び出すとやってくる) - 処理失敗時は
Result<Nothing、失敗時の戻り値>
を返却する(Err()を呼び出すとやってくる)
@JvmInline
value class UserName(val value: String) {
companion object {
fun of(value: String): Result<UserName, String> {
if (value.isEmpty()) {
return Err("名前が空です")
}
if (value.length > 20) {
return Err("名前は20文字以内で入力してください")
}
return Ok(UserName(value))
}
}
}
Resultの実装がどうなっているか見ると分かりやすいと思います
- Result型はinternal constructorなので直接生成することはできずOk、またはErrのユーティリティ関数で生成する必要がある。それによって正常値と異常値の両方入っているような不整合なオブジェクトは生成できません
public fun <V> Ok(value: V): Result<V, Nothing> {
return Result(value)
}
public fun <E> Err(error: E): Result<Nothing, E> {
return Result(Failure(error))
}
public value class Result<out V, out E> internal constructor(
private val inlineValue: Any?,
) {
public val value: V
get() = inlineValue as V
public val error: E
get() = (inlineValue as Failure<E>).error
// 省略...
}
余談ですが、使わない方の型をNothingにできるのはResult<out V, out E>
のように共変(outが付与されている)で宣言されているからで、すべての型のサブタイプであるNothingが代入できるからで、kotlin-resultのコードを読んでいると勉強になることも多かったです。
サンプルコードに戻ります。呼び出し側は戻り値のResultを扱わざるを得ないので、嫌でもエラー処理を意識させることができます。また検査例外を使わないことで最初に書いた@Transactional
の問題を回避することができてます(ただし、使用しているライブラリがIOExcepitonなどを返すことがあるのでそこは個別にcatchが必要ですのでご注意ください)
// 処理を呼び出し、結果をresultで受ける
val result : Result<UserName, String> = UserName.of(name)
// エラーハンドリング
result
.onSuccess { userName ->
// 正常時の処理
}
.onFailure { errorMessage ->
// エラー処理
}
複数のエラーをまとめるのが便利
kotlin-resultのメリットとして、複数のエラーをまとめるのが便利というのもあります。
複数の入力チェックをした結果をまとめてフロントエンドに返却したいケースは良くありますが、例外をキャッチしてエラーを溜め込む機構を自分で作るも大変ですし、例外クラスを生成してスローするコストは高いため大量のエラーが発生する場面では使いたくありません。
例えば、kotlin-resultのzipOrAccumulateを使うとこのような仕組みを使えばこのような実装が簡単にできます
/**
CSVの1行を表すクラス
*/
data class Row(val rowNo: Int, val userCode: String, val userName: String) {
/**
指定された列の値が正しければをOk(列の値)返す
列の値が不正だったらErr(ParseError)を返す
*/
fun parseRow(columnName: String): Result<String, ParseError> {
// 実装は省略
}
}
// 実際はCSVから読み込むがサンプルコードなので直で生成する
val list = listOf(
Row(userCode = "001", userName = "山田太郎"),
Row(userCode = "", userName = ""), // 空文字はエラー
)
// 説明のため過剰に型を明記しています
val results: List<Result<CsvRecord, List<ParseError>>> = list.map { row: Row ->
zipOrAccumulate(
// 「ユーザコード」列を読み込み。エラーだったらParseErrorが蓄積される。
{ row.parseRow("userCode") },
// 「ユーザ名」列を読み込み
{ row.parseRow("userName") },
)
// 1行分すべての列の読み込みが正常だったらCsvRecordを返す
{ validatedUserCode: String, validatedUserName: String ->
CsvRecord(validatedUserCode, validatedUserName)
}
}
// List<Result<CsvRecord, List<ParseError>>>をpartionで分解して処理する
results.partition().let { (values: List<CsvRecord>, errors: List<List<ParseError>>) ->
// errorsには全てのエラーが蓄積されているので、1つでもあったら例外をスローする
if (errors.isNotEmpty()) {
throw ApplicationException(errors.flatten().joinToString(separator = "\n") { it.message })
}
values.map {
// すべての行が正常だった場合はvaluesを使って処理を継続する
}
}
この実装の良いところは
- parseRowの実装としては、どこから呼ばれるかやエラーを溜めるかを意識せずにResultを返せば良い
- 全てのチェックがパスしたかの分岐はzipAndAccumulateがやってくれる
- これがないと、1つ1つエラーだったらリストに溜めることをやらないといけず、処理がごちゃつく
ただし、zipOrAccumulateが扱えるのは5個のResultまでなので、それ以上の数を扱いたい場合は別の実装をする必要があります(bindingやerrorsOfなど、他にも便利な関数が提供されています)
型によって引数の取り違えを防ぐ
StringやIntのように同じ型の仮引数が並んでいるときに、渡す順番を間違えるミスは良くあると思います。更に悪いことに、下記のテストコードはdepartmentId、userIdのどちらにも同じ値をセットしているために、せっかくテストを書いているのに問題を検知することができません。
fun find(departmentId: String, userId: String): User {
// Userを返す処理
}
@Test
fun test() {
val departmentId = "1"
val userId = "1"
// 渡す引数を間違っていることに気づきにくい
find(userId, departmentId)
}
Kotlinには1つのイミュータブルな値しか持つことのできないvalue classという仕組みがあり、それを使うことで独自のプリミティブを作る感覚で型を使うことができます。余談ですが、せっかくクラスを定義したのであれば、型自身に不正な値を受け付けないチェック機構を設けるのも良いでしょう。
// それぞれ専用の型を宣言する
@JvmInline
value class DepartmentId(val value: String) {
init {
require(value.length in 1.. 30) { "部署IDは1文字以上、30文字以内で入力してください" }
}
}
@JvmInline
value class UserId(val value: String) {
init {
require(value.length in 1 .. 20) { "ユーザIDは1文字以上、30文字以内で入力してください" }
}
}
先ほど作成した型を仮引数に使用することでコンパイル時点で間違いに気づくことができます。
// 仮引数の型をstringから、作成した型に変更
fun find(departmentId: DepartmentId, userId: UserId): User {
// Userを返す処理
}
@Test
fun test() {
val departmentId = DepartmentId("1")
val userId = UserId("2")
// 型が異なるのでコンパイルエラーになる!
find(userId, departmentId)
}
名前付き引数によって取り違えを防ぐ
更に名前を指定して引数を渡す「名前付き引数」を使用することで、引数を渡す順番を間違えても問題なく動作させることができます(とはいえ、実際は仮引数の定義順に渡した方が可読性が良いでしょう)
先ほどvalue objectを定義する方法を紹介しましたが、実際にはすべての型を宣言することは現実的ではなく、プリミティブを扱う場面は多いので基本は名前付き引数にするのが良いのではと思います。
@Test
fun test() {
val departmentId = DepartmentId("1")
val userId = UserId("2")
// 名前を指定しているので順番を間違えても大丈夫
find(userId = userId, departmentId = departmentId)
}
ユニットテストのデータを生成する
以前の仕事では、ユニットテストでDBに事前データを登録する際、エクセルやyamlファイルで管理していました。しかし、整合性の取れたデータを作成・維持するのは大変でした。
ログラスでは、エンティティやリポジトリを使用してデータをロードする仕組みがあり、それが非常に便利です。
@Component
class UserTestDataCreator(
private val userRepository: UserRepository,
) {
fun create(
userId: Int = UserId.generate(),
userCode: String,
userName: String = "山田太郎",
birthDay: LocalDate = LocalDate.of(2000, 1, 1),
): User {
val user = User(userId, userCode, userName, birthDay)
userRepository.insert(user)
return user
}
}
この方法の良い点は、プロダクトのエンティティやリポジトリを使用するため、正しいデータが登録されることが担保されることです。エクセルなどでデータを管理する場合、意図せずあり得ないデータを作成してしまうことがありましたが、それを防ぐことができます。
また、デフォルト値をうまく使うことで、テストで注目したい項目の値だけを再設定する使い方が簡単にできます。例えば、誕生日に関するテストでは、明示的に誕生日を指定することで、何に注目したテストなのかが一目瞭然です。
// 基本的にデフォルト値で良い場合
userTestDataCreator.create(userCode = "0001")
// 誕生日に特化したテストがしたい場合
userTestDataCreator.create(userCode = "0002", birthDay = LocalDate.of(2000, 2, 29))
まとめ
ログラスに入社してから、型の表現力によってコードが仕様を語り、不具合を防ぐことができる点を強く感じています。
また、それを支えるKotlinの型システム自体は本当によくできていますし、堅牢なプログラムを書ける良い言語だと改めて感じました。
この記事で紹介したテクニックが読者の皆様のお役に立ててれば幸いです。
Discussion