UPSIDER Tech Blog

導入して実感したArrow-ktの良さ

こんにちは! 株式会社UPSIDERの支払い.comでバックエンドエンジニアを担当しているふっくです。

今回は、支払い.comバックエンドで導入した関数型にインスパイアされたKotlinライブラリ「Arrow-kt」を紹介します。

Arrow-ktとは?

Arrow-kt は、Kotlinに関数型プログラミングの強力な機能をもたらすライブラリセットです。特定の目的を持つ複数のライブラリとして提供されています (Overview of libraries)。

主なライブラリは以下の通りです。

  • arrow-core : 型安全なエラー処理を実現する EitherOptionRaise などを提供する中核的なライブラリです。 私たちのプロジェクトでは、現在この arrow-core を利用しています
  • arrow-fx-coroutines : Kotlin Coroutinesと連携し、高レベルな並行処理やサスペンドコンテキストでのリソース管理を提供します。
  • arrow-autoclose : arrow-fx-coroutines のリソース管理機能を、Kotlin Coroutinesに依存せずに利用可能にします。
  • arrow-optics : 不変データ構造に対するアクセスや変換を容易にするための抽象化( LensPrismTraversal など)を提供します。

Arrow-ktは、関数型プログラミングの概念をKotlinのイディオムに沿った形で提供することを目指しており、Kotlinプログラマーにとって自然に導入しやすいように設計されています。

💡私たちのプロジェクトでは当初エラー処理向けに kotlin-result を使っていましたが、更新作業(1系→2系)にて他ライブラリとの互換性の問題が発生しました 😢(issue) この問題に対処するために、同様の機能を提供するArrow-ktを導入しました。結果として、EitherはもちろんRaiseをはじめとするArrow-ktのエコシステムの拡張性はチーム内で好評であり、良い移行となったと感じています。

Arrow-ktを使ってみる

今回は簡単な「パスワード強度検証クラス」の実装を通して、arrow-core の中心的な機能を紹介します。

以下の PasswordStrengthValidator クラスは、パスワードが特定の要件(最低長、大文字・小文字・数字・特殊文字の有無)を満たしているかを検証します。 ここでは、エラーハンドリングのアプローチが異なる2つのメソッドを実装してみました。

  • validate : Kotlin標準のResultを使用した方法
  • validateArrowKt : Arrow-ktの Either を使用した方法

💡 Arrow-ktの Either<L, R> は、「L型またはR型のどちらかを持つ値である」ということを型レベルで保証するものです。慣習として、L(Left)をエラーケース、R(Right)を正常系の値を表すために用いることが多いです。

class PasswordStrengthValidator {
  // Arrow-kt使わないパターン
  fun validate(password: String): Result<String> {
    if (!hasMinLength(password)) {
        return Result.failure(ValidationException(ValidationError.TooShort))
    }
    if (!hasUpperCase(password)) {
        return Result.failure(ValidationException(ValidationError.NoUpperCase))
    }
    if (!hasLowerCase(password)) {
        return Result.failure(ValidationException(ValidationError.NoLowerCase))
    }
    if (!hasDigit(password)) {
        return Result.failure(ValidationException(ValidationError.NoDigit))
    }
    if (!hasSpecialChar(password)) {
        return Result.failure(ValidationException(ValidationError.NoSpecialChar))
    }
    return Result.success(password)
  }

  // Arrow-kt使うパターン
  fun validateArrowKt(password: String): Either<ValidationError, String> = either {
    ensure(hasMinLength(password)) { ValidationError.TooShort }
    ensure(hasUpperCase(password)) { ValidationError.NoUpperCase }
    ensure(hasLowerCase(password)) { ValidationError.NoLowerCase }
    ensure(hasDigit(password)) { ValidationError.NoDigit }
    ensure(hasSpecialChar(password)) { ValidationError.NoSpecialChar }
    // エラーを直接返すことも可能: raise(ValidationError.NoSpecialChar)
    password
  }

  private fun hasMinLength(password: String) = password.length >= 8
  private fun hasUpperCase(password: String) = password.any { it.isUpperCase() }
  private fun hasLowerCase(password: String) = password.any { it.isLowerCase() }
  private fun hasDigit(password: String) = password.any { it.isDigit() }
  private fun hasSpecialChar(password: String) = password.any { !it.isLetterOrDigit() }

  class ValidationException(val detail: ValidationError) : Exception(detail.message)
  sealed class ValidationError(val message: String) {
    data object TooShort : ValidationError("Too short message.")
    data object NoUpperCase : ValidationError("No uppercase message.")
    data object NoLowerCase : ValidationError("No lowercase message.")
    data object NoDigit : ValidationError("No digit message.")
    data object NoSpecialChar : ValidationError("No special char message.")
  }
}

次に、これらのメソッドをどのように呼び出し、結果を処理するかを見てみましょう。

Kotlin標準のResult

val response = validator.validate(password = "P@ssw0rd")
...
response.onSuccess { pwd -> /** 成功時の処理 */ } // onFailure { cause: Throwable -> }
response.fold(
  onSuccess = { pwd -> /** 成功時の処理 */ },
  onFailure = { cause: Throwable -> /** 失敗時の処理 */ },
)
response.map { pwd -> /** 成功時の処理 */ }

Arrow-ktのEither

val response = validator.validateArrowKt(password = "P@ssw0rd")
response.getOrNull() // leftOrNull()
response.onRight { pwd -> /** 成功時の処理 */ } // onLeft { cause: ValidationError -> }
response.fold(
  ifLeft = { cause: ValidationError -> /** 失敗時の処理 */ },
  ifRight = { pwd -> /** 成功時の処理 */ },
)
response.map { pwd -> /** 成功時の処理 */ } // mapLeft { cause: ValidationError -> }

// 👇 EitherをeitherのRaiseレシーバ付きラムダ内で利用する例 (ユーザ作成)
val userResponse: Either<RegistrationError, User> = either {
  ensureNotNull(param.email) { RegistrationError.InvalidEmail }

  val validatedPassword = validator.validateArrowKt(param.password)
    .mapLeft { cause -> RegistrationError.InvalidPassword(cause.message) }
    .bind()
  userRepository.insert(User.create(param.email, validatedPassword))
    .mapLeft { RegistrationError.SystemError }
    .bind()
}

Arrow-ktの Either を使うと、主に以下の点が嬉しいです 👌

  • エラーハンドリングの分離と可読性: either メソッドが引数に取る Raise レシーバ付きラムダ内では、ensurebind を使うことができます。これらを使うことで、連鎖的な処理の中でエラーを切り離し、ハッピーパスに焦点を当てた実装ができます 💪
  • 型安全なエラー表現: Either 型は任意の型をエラーとして扱えるため、ValidationError のような独自のエラー型を型安全に表現できます。また、foldmap などの関数型の操作も可能で、エラー処理の柔軟性が高いです 💪

このように、Either 型は型安全性と表現力の両方を兼ね備えているという点が大きなメリットです。

あとがき

今回は小さなサンプルコードを使ってArrow-ktを紹介しました。

Arrow-ktは関数型のライブラリですが、既存のコードベースにも段階的に導入しやすく、Kotlinの記述スタイルを損なうことなく型安全なエラーハンドリングのメリットを享受できます。もちろん、関数型に寄せたコーディングをするサポートとしても強力です。 私たちはKtorプロジェクトで導入しましたが、Androidやその他のKotlinプロジェクトでも同様の恩恵を受けられると思います。APIクライアントやデータベース操作など、エラー処理が重要な部分から段階的に取り入れると良いかもしれません。

まだArrow-ktはプロジェクトの一部のモジュールでしか導入できていませんが、いろいろな使い方を試しながら導入を進めていこうと思います 👍

We Are Hiring !!

支払い.comをはじめUPSIDERでは現在積極採用をしています。 ぜひお気軽にご応募ください。

herp.careers

カジュアル面談はこちら!

herp.careers

Culture Deckはこちら📣

speakerdeck.com