🚥

障害を防ぐための関数型エラーハンドリング(前編)

2024/12/08に公開

いつの間にか始まっていたアドベントカレンダー、本日は8日目になります。

気がつけばもう12月ですね。迫りくる年末年始、システム障害に振り回されずゆっくり過ごしたいものですよね。

さて本日はそんな障害に立ち向かうための、エラーハンドリングについてお話したいと思います。

ソフトウェア開発においてエラー処理は避けて通れない課題です。しかし、適切でないエラー処理はシステム全体の障害を引き起こしやすく、特に例外(Exception)の濫用は重大な問題を生みがちです。お客様に損害を与えてしまったときは目も当てられません。

本記事では、例外の問題点を明らかにし、なぜ関数型スタイルのエラーハンドリングが障害を抑え込むのに効果的であるかを述べ、後編ではKotlin + Arrow( + Ktor)を使った実装例を紹介します。

例外処理( try-catch)の限界

例外は多くのプログラミング言語で標準的なエラー処理の仕組みとして提供されていますが、以下のような問題があります:

  • 制御フローの中断
    try-catch ブロック内で例外が発生すると、制御フローは直ちにブロックに転送され、アプリケーションの予期されるフローが中断される可能性があります。
  • 合成可能性の欠如
    try-catch ブロックは本質的に合成可能ではありません。複数の例外を処理したり、例外が発生しやすい操作を組み合わせたりすると、面倒で複雑になる可能性があります。
  • 冗長な定型コード
    try-catch ブロックは定型コードになることが多く、コードベースが冗長になり、保守が困難になります。これは、複数のネストされた try-catch ブロックを処理する場合に特に顕著になります。
  • エラーの曖昧さ
    スローされる例外の種類が明確でない場合、呼び出し元でのエラーハンドリングが困難になります。呼び出し元は、どのような例外がスローされるのか把握しづらく、防御的コードを生みやすくなります。
  • 非同期処理との非互換性
    コルーチンや非同期処理を多用するKotlinでは、例外が非同期フローに悪影響を及ぼします。例外をスローするとエラーハンドリングが非直感的になります。
  • 例外ハンドリングの濫用
    try-catchが濫用されると、エラーの原因や発生箇所が不明瞭になります。

これらの問題を克服するためには、エラーハンドリングを「第一級市民」として扱う必要があります。特に、上記の問題の中でも合成可能性は重要で、この特性が欠如すると複雑性が増大します。合成可能性については本記事では割愛するため、詳しくはOOC2024の関数型DDDの発表をご参考いただければと思います。

関数型スタイルのエラーハンドリング

関数型スタイルのエラーハンドリングは型システムを活用して、エラーを言語のルールとして明示的に表現します。これにより、以下のような利点が得られます:

  • エラーを値として扱う
    関数型エラーハンドリングでは、例外ではなく後述する ResultEither の値でエラーを表現します。このアプローチにより、エラーが制御不能な例外として飛び出すことを防ぎ、コードが常に予測可能な状態を保つことができます。
  • エラーの型安全性
    エラーを型で表現することで、エラーが処理されていない箇所をコンパイル時に検出しやすくなります。特に代数的データ型を使うことで、パターンマッチの網羅性検査により、後からエラーの種類を追加した場合に、影響する箇所を特定しやすくなります。
  • エラーの伝搬と局所化
    エラーを型で表現し、値として扱うことで、エラーが関数内または特定のスコープに局所化され、エラーを発生箇所に閉じ込め、伝搬を制御しやすくします。これにより、システム全体に影響を与えるような大規模な障害を防ぎます。
  • 再現性とテスト容易性
    関数型プログラミングでは純粋関数(全域関数)が推奨されるため、エラーハンドリングも副作用のない形で記述されます。副作用のない関数は同じ入力に対して常に同じ結果を返します。これにより、エラーの再現性が高まり、エラーケースを容易にテストできます。
    さらに、エラーケースを網羅的にテストするために、入力データをランダム生成するProperty-based Testingが容易になります。
  • 耐障害性とリカバリ設計の向上
    エラーを扱う処理の予測可能性が高まり、リカバリ設計が容易になります。特に、エラーを値として扱えることで、リトライ処理やフォールバック処理をエラーパス内で直接記述できます。
  • 開発者の意識向上
    明示的なエラーハンドリングにより、エラーケースを意識する習慣が醸成されます(経験則)。
  • 障害対応の効率化
    エラーが予測可能でログに記録されるため、運用中の障害対応が迅速化。

他に、非同期処理等の副作用との相性も良い点があげられますが、副作用全般については今回は割愛し、いずれ別の記事でご紹介したいと思います。

Result型

Kotlin 1.3から導入されたResult型について簡単に紹介しておきます。

Result型は以下のように定義されています。

public value class Result<out T> @PublishedApi internal constructor(
    @PublishedApi
    internal val value: Any?
) : Serializable {
    public val isSuccess: Boolean get() = ...
    public val isFailure: Boolean get() = ...

    public companion object {
        public inline fun <T> success(value: T): Result<T> =
            Result(value)

        public inline fun <T> failure(exception: Throwable): Result<T> =
            Result(createFailure(exception))
    }

    internal class Failure(
        @JvmField
        val exception: Throwable
    ) : Serializable { ... }
}

処理が成功した値と失敗した値( Throwable )のいずれかを保持することができます。Result型が受け取る型パラメータTは、成功時の値の型を示しています。失敗した場合は Throwable の例外を保持するため、例外を保持しているということ以外、エラーの内容は型に現れません。

val successResult: Result<Int> = Result.success(123)
val failureResult: Result<Int> = Result.failure(Exception("エラーが発生しました"))

Either型

Arrow-ktという関数型プログラミングを支援するKotlinライブラリでは、標準のResult型とは別に、関数型プログラミングではお馴染みのEither型を提供しています。
Either型は、2つの可能性を持つデータ型で、左 (Left) と右 (Right) のどちらか一方の値を保持します。

Either型は以下のように定義されています。

sealed class Either<out L, out R>
data class Left<out L>(val value: L) : Either<L, Nothing>()
data class Right<out R>(val value: R) : Either<Nothing, R>()
  • 通常、Left はエラーを表現し、Right は成功を表現します。
  • 「エラーと成功」という文脈以外でも、2つの異なる可能性を表現するために使用できます。エラー ( Left ) と成功 ( Right ) の区別は慣習的なもので、必ずしもエラー処理専用ではありません。

Eitherの利点

try-catch 例外処理と比較すると、Either型を使用するといくつかの利点があります。

  • 表現力の高いエラーモデリング
    エラーを明示的にモデル化して値として表現できます。例えば、特定のユースケースで発生しうる業務例外を代数的データ型として表現することで、型をみることで発生しうるエラーが判別できるようになります。
    // エラーをモデル化した型
    sealed class BusinessError {
        data class CustomerNotFound(val customerId: String) : 
    BusinessError()
        data class InsufficientStock(val productId: String, val requested: Int, val available: Int) : BusinessError()
        data class InvalidPaymentMethod(val method: String) : BusinessError()
        data class ExternalServiceError(val serviceName: String, val details: String) : BusinessError()
    }
    
    // 業務処理
    fun purchaseProduct(
        customerId: String,
        productId: String,
        quantity: Int,
        paymentMethod: String
    ): Either<BusinessError, Transaction> { ...
    
    上記のpurchaseProduct関数は、戻り型として Either<BusinessError, Transaction> が定義されており、発生しうるエラーが型で判断できるようになっています。
  • 関数型スタイルでのエラー処理と、エラー回復
    try-catch で制御フローを中断する代わりに、mapflatMap 等のコンビネーターを使用して、エラーが発生する操作を変換・合成できます。
    また、orElserecovery 等のコンビネーターを使用すると、特定のエラーから回復したり、代替処理を実行できます。
  • 複数エラーの蓄積
    CSVファイルからデータをインポートする場面等、複数行の入力データを一括処理する場合に、それぞれの行ごとの異なるエラーをまとめて扱える(Arrow-ktには、以前はValidated型という専用の型がありましたが、Either型でも表現できることから、統合されました)
  • 関心の分離
    Eitherによってエラー処理をカプセル化することで、エラー処理の関心をコアドメインロジックから分離できます。この関心の分離によって、コードのモジュール性やテスト容易性が向上します。

Clean Architectureによるエラーハンドリングの棲み分け

純粋関数型プログラミング言語では実行時例外が発生しないことを前提に、プログラムを構成しますが、例外機構がある言語ではそうもいきません。
特に外部APIの呼び出しや、データベースへのアクセス等で使用するライブラリは、例外を投げることがあるでしょう。このような例外を一つ一つ分析し、エラーの型としてモデル化していくのは結構大変です。


引用: Robert C. Martin 著、角征典、髙木正弘 訳(2018). "Clean Architecture 達人に学ぶソフトウェアの構造と設計". アスキードワンゴ.

そこで、Clean Architectureを使って、エラーハンドリングの仕方を次のように棲み分けしてみます。

  • アダプタ層(インフラ層)では、Result型を使う
  • ユースケースの戻り値はEither型を使う

上記のような棲み分けを考えた場合、いわゆるRepository等の実体がアダプタ層にあるオブジェクトのインタフェースを、ユースケース層でどう見せるかが問題になります。これについては、

  • Clean Architectureの依存の方向性に従い、アダプタ層固有の詳細なエラーはユースケース層からは不可知(agnostic)とする

としてみたいと思います。
次のようなイメージになります。

interface TaskRepository {
    fun save(task: Task): Either<RepositoryError, Unit>
}

Kotlin + Arrow + Ktorでの実装例

実装例を記載していこうと思いましたが、記事が長くなってしまいそうなので、続きは後編で!

まとめ

関数型エラーハンドリングを用いると、以下のような利点が得られます:

  1. エラーの扱いが明確: Either型やOption型を利用することで、エラーが明示的に表現される。
  2. 障害抑制: 副作用が排除され、エラー処理が安全に伝播する。
  3. 保守性の向上: エラー処理が一貫性を持ち、予測可能なコードが実現。

コードを書いていると、ややもすると、正常系のみに着目しがちになりますが、異常系を型で表現することで意識が異常系に向き、障害に繋がるようなバグを回避しやすくなります。

年末が近づくこの時期、異常系を見直して大掃除してみるのもいいかもしれませんね。
後編は12/24の公開を予定しています。お楽しみに。

株式会社ログラス テックブログ

Discussion