NIKKEI TECHNOLOGY AND CAREER

ID基盤のバックエンドに関数型プログラミングを導入した話

はじめに

こんにちは。日経 ID 認証認可基盤チームの奥田・淵脇です。この記事は Nikkei Advent Calendar 2022 の記念すべき 1 日目の記事です。

日経 ID チームで開発している認証認可基盤ではバックエンドの技術スタックとして Kotlin + SpringBoot を用いています。最近この基盤に対し Kotlin の関数型プログラミング用のライブラリである Arrow-kt を導入しました。関数型プログラミングには様々な要素がありますが、既存のコードに適用しやすいところから始めようということで、Either を用いた例外処理を中心に導入してみました。

そこで本記事ではサーバサイド Kotlin における関数型プログラミングの導入事例として、日経 ID チームにおける Either の使われ方や実際のプロダクションコードでの規約などを紹介いたします。

Either 入門

まずは軽く Either そのものについて説明したいと思います。

基本

Either<A, B>A または B のどちらかの型の値を保持する型で、Arrow-kt においては下記のように sealed class で表現されています。これは一般的には失敗しうる処理の戻り値の型として使われ、 Right には成功した結果の値を、 Left には失敗の原因等を格納します。

sealed class Either<out A, out B> {
    data class Left<A>(val value: A): Either<A, Nothing>

    data class Right<B>(val value: B): Either<Nothing, B>
}

簡単な例として、整数の割り算を考えてみます。ここで divide は割り算に成功した場合はその結果の Int を格納し、失敗した場合にはエラーメッセージとして String を格納します。divide の結果を受け取った main 関数は、結果が RightLeft かで出力を変化させています。

import arrow.core.Either

fun main() {
    val a: Int = readLine()?.toInt()!!
    val b: Int = readLine()?.toInt()!!

    val result = divide(a, b)
    when (result) {
        is Either.Right -> {
            println("割り算に成功しました: ${result.value}")
        }
        is Either.Left -> {
            println("割り算に失敗しました。 message = ${result.value}")
        }
    }
}

fun divide(a: Int, b: Int): Either<String, Int> {
    return if (b != 0) {
        Either.Right(a / b)
    } else {
        Either.Left("divided by zero")
    }
}

fun input(): Int {
    return readLine()?.toInt()!!
}

基本的な処理

map

Either の値が Right だった場合には引数のラムダ式による変換を行い、 Left だった場合には何もしません。リストの各要素を別の値に変換していく map の親戚のような関数です。

// 割り算に成功した場合、その結果を 2 倍にする。
fun main() {
    val result1 = divide(10, 5).map { it * 2 }
    println(result1) // Either.Right(4)

    val result2 = divide(10, 0).map { it * 2 }
    println(result2) // Either.Left(divided by zero)
}

mapLeft

map とは逆に、Either の値が Left だった場合にのみ引数のラムダ式による変換を行います。

// 割り算の結果が失敗だった場合、エラーメッセージを編集する
fun main() {
    val result1 = divide(10, 5).mapLeft { "Calculation Failed. message = $it" }
    println(result1) // Either.Right(2)

    val result2 = divide(10, 0).mapLeft { "Calculation Failed. message = $it" }
    println(result2) // Either.Left(Calculation Failed. message = divided by zero)
}

flatMap, bind

大体の場合は処理に成功していた場合は、その値を用いて次の処理をしたいはずです。そして、次に行う処理もまた失敗しうる場合があります。こんなとき、単なる map を使うと次のようになります。

map を用いた場合

つまり、 Either で包まれた値に対して Either を返す関数を適用するので、結果は Either が入れ子になってしまうのです。これでは演算を繰り返すたびに Either の入れ子がどんどん深くなってしまいます。

そんなときのために以下の動作をする flatMap があります。

  • 最初の処理が失敗していれば、そのエラーを Left として返す
  • 最初の処理が成功していれば、その値を用いて続きの失敗しうる処理 f を行う。
    • f が成功すればその値を Right として返す。
    • f が失敗すれば、そのエラーを Left として返す。

この動作は定義を見たほうがわかりやすいかもしれません。

inline fun <A, B, C> Either<A, B>.flatMap(f: (B) -> Either<A, C>): Either<A, C> =
  when (this) {
    is Right -> f(this.value)
    is Left -> this
  }

イメージで書くとこんな感じで入れ子が解消されています。

flatMap を用いた場合

これなら常に Either 1 個だけの文脈で計算を続けることができシンプルになりました。例として、まず a/b を計算し、成功した場合はさらに c で割る、という計算をしてみましょう。

fun main() {
    val a: Int = input()
    val b: Int = input()
    val c: Int = input()
    val result: Either<String, Int> = divide(a, b).flatMap { v ->
        divide(v, c)
    }
    println(result)
    // (a, b, c) = (54, 2, 3) => Either.Right(9)
    // (a, b, c) = (54, 0, 3) => Either.Left(divided by zero), ここでは最初の divide で失敗している。
    // (a, b, c) = (54, 2, 0) => Either.Left(divided by zero), ここでは flatMap の中で行った divide で失敗している。
}

このように flatMap の便利さがわかるかなと思います。しかしまだ少し問題があります。それは Either の値が複数あるときに、それらを組み合わせるとネストがどんどん深くなっていくというものです。

例えば a/b , c/d , e/f , g/h をそれぞれ計算し、全て成功した場合にそれらの和を取りたい場合、下記のようなコードになります。

fun main() {
    // a~h の宣言は省略
    val result: Either<String, Int> = divide(a, b).flatMap { r1 ->
        divide(c, d).flatMap { r2 ->
            divide(e, f).flatMap { r3 ->
                divide(g, h).map { r4 ->
                    r1 + r2 + r3 + r4
                }
            }
        }
    }
}

そこで、より簡潔な記述が出来る糖衣構文が用意されています。

either.eager で開始できる EagerEffectScope の中では Either に対して bind() を行う事ができ、その際に下記の動作をします:

  • Left だった場合はそのエラーをそのまま Left として either.eager 全体の戻り値として返却します。
  • Right だった場合は、成功した値を取り出して処理を続行します。

こうすると、Either ではない普通の値を返却する関数を使って処理を書いていくのと同じ感覚で Either の値を処理する事ができます。

fun main() {
    // a~h の宣言は省略
    val result: Either<String, Int> = either.eager {
        val r1: Int = divide(a, b).bind()
        val r2: Int = divide(c, d).bind()
        val r3: Int = divide(e, f).bind()
        val r4: Int = divide(g, h).bind()
        r1 + r2 + r3 + r4
    }
}

Result を使わない理由

Kotlin では Either と似たような機能をもつ Result が標準で用意されていますが、主に 2 つの理由から Either のほうが使い勝手が良いと考えています。

失敗の場合の型を指定できず Throwable で固定される

例えば下記の関数で失敗時に返却される例外は実際には ArithmeticException に限られますが、 Result は失敗時の型を Throwable として持つため、ArithmeticException 以外が格納されている場合の処理を書かざるを得ません。

fun divideResult(a: Int, b: Int): Result<Int> {
    return if (b != 0) {
        Result.success(a / b)
    } else {
        Result.failure(ArithmeticException())
    }
}

Either の bind のような機構がない

Result には Eitherbind にあたるものが用意されていません。よって複数の Result の値を組み合わせたい場合には flatMap をネストさせることになります。

実際のコーディング規約

Either を実際に日経 ID の開発で適用するためにコーディング規約を整備しました。その内容をご紹介したいと思います。

  1. 例外が発生しうる関数の戻り値の型として Either を用いる。ここで Either を返す関数ごとに Left として返す例外を定義し、それらは Exception を継承させる。
  2. ただし、予期せぬ例外 (主にクライアントにはステータスコード 500 を返す場合) は throw し、 Either を用いた処理は行わない。
  3. また (ドメイン駆動設計における) アプリケーションサービス層において DB をロールバックしたい場合は例外を throw する。

1 は複数の関数で同じ例外を共有しないことを求めています。また Left の型は Either の仕様上は任意の型を指定できますが、我々は Exception を継承した例外クラスを指定しています。これはログ出力用にスタックトレースを取得したいケースがしばしばあるからです。

2 は例えば SQL が不正な場合の例外や、DB との接続に失敗した場合などの例外を全て Either で処理しているとボイラープレート的なコードが増えるため、ビジネスロジックとは無関係な予期せぬ例外は throw することでコードをシンプルにすることを狙いにしています。

3 は SpringBoot の @TransactionalEither の相性の悪さから来るものです。DB をロールバックしたいときには @Transactional の仕様により例外を throw する必要があるため、ここでは Either は使えません。それでも例外を throw して良い箇所は限定したいことと、日経 ID ではアプリケーションサービス層でトランザクションを開始していることからこのような規約になりました。主にビジネスロジック上の例外によりクライアントに 400 番台のステータスコードを返したい場合が該当します。

チームに Either や関数型プログラミングを浸透させる

関数型プログラミングは単に Either を使うだけではなく、乱数や時刻、I/O といった副作用に関する参照透過性などいろいろなエッセンスがあります。日経 ID 認証認可基盤では既存のコードベースも大きく、チームも関数型に慣れてないメンバーが多かったため、新規のコードからチームのスキルアップを兼ねて徐々に浸透させていきました。

チュートリアルの用意

ドメイン駆動設計でよく用いられるコントローラー層やドメイン層といったレイヤードアーキテクチャのスタブを用意し、そこからサンプルシステムを開発するためのチュートリアルを作成しチームメンバーでトライしてみました。その際にドメイン層を関数型プログラミング的に作ってみることで Either の使い方や参照透過性などの基礎をチームで勉強しました。

日経ID バックエンド チュートリアル

必要なところから、徐々に

関数型プログラミングにはたくさんの利点がありますが、既存のコード資産も大量にあるため一気に導入するのは非常にコストがかかります。プロダクト開発においてはすべてが綺麗に設計されているが稼働していないコードよりも、レガシーな積み重ねだが顧客に価値を提供し続けているコードのほうが何倍も重要です。そのため、関数型プログラミングも一気に導入するのではなく必要なところから徐々に導入していくという手法を取りました。そのために行った施策をいくつか紹介いたします。

パッケージの分離

新しいパラダイムを取り入れるべきパッケージの分離です。日経 ID はサーバサイド Kotlin で開発を行っているため、パッケージ構造は Java ほどではありませんが、ある程度ディレクトリ構造に一致しています。そこで、新たに関数型のパラダイムを導入するパッケージを切り、新規開発できる部分についてはその中にコードを書いていくようにしました。

具体的には今まで開発していたディレクトリの中に刷新したコードを書くための <開発コード名> というパッケージを切りその中では関数型のパラダイムを用いるというものです。

src/main
    ├── kotlin
    │   └── com
    │       └── nikkei
    │           └── id
    │               ├── iam
    │               │   ├── app
    │               │   ├── config
    │               │   ├── domain
    │               │   ├── ...
    │               │   ├── <開発コード名>
    │               │   │   ├── app
    │               │   │   └── ...
    │               │   └── ...
    │               └── ...
    └── resources

その他のパッケージにあるコードについては無理に導入せずに既存との互換性を保つようにしています。

取り入れるレイヤーの選択

日経 ID ではドメイン駆動設計を用いた開発をしています。ドメイン駆動設計ではアプリケーションの中で関心や責務の分離を疎結合にするためにレイヤードアーキテクチャを取ることが多いです。レイヤードアーキテクチャはパッケージを用いて表現されることが多く、日経 ID でもそのスタイルを取っています。レイヤードアーキテクチャは外界と中でビジネスロジックを表すドメインモデルの円で描かれることが多く、例えば下記のような図はどこかで見たことあるかもしれません。

レイヤードアーキテクチャの一例

日経 ID ではこの中でドメインモデルについてのみ関数型プログラミングを導入することにしました。これにはもちろん諸説あり、IO モナドなどを用いてインフラもすべて関数型で開発するべきなどの意見もありましたが、チームで議論した結果、導入しても一番違和感がなく、既存の非関数型パラダイムのコードベースと互換性が保てる点からドメイン層への導入が妥当という結論になりました。またドメイン層は純粋なロジックで構成することが容易であり、日時やネットワーク、DB といった副作用はユースケース層の DI により排除がやりやすかったという点も決め手になりました。

導入する Arrow-kt の機能の選択

Arrow-kt には Either 以外にも OptionalValidated、更にモノイドや Lens 圏といった機能やコルーチンやリソース管理の機能も存在します。しかし、 Kotlin はオプショナルチェーンが導入されていたり、イミュータブルな変数が使いやすいなど同じ JVM 言語である Java などに比べて関数型プログラミングに近いことはもとから可能でした。そこで一番の課題となっていたエラー処理に重点をおいて関数型パラダイムを導入するのが効果的という結論になりました。そのため、まずは Either を中心的に導入し、それ以外は Kotlin の Vanilla な書き方で参照透過性の確保や副作用の排除をすすめることになりました。今後はより複雑なデータ構造やエラー処理などの要件も増えてくると思われるため、 ValidatedLens の導入も予定しています。

実際に関数型プログラミングの要素を導入してみてどうだったか

以上の施策はかれこれ 1 年ほど続けてきましたが、下記の効果を実感しています。

  • 開発メンバーが Arrow-kt や関数型の考えに親しんできた。
  • 時刻の入力や乱数の状態が固定されるなど参照等価なテストを書くようになってきた。
  • 単体テストの品質も上がってきた。
  • ドメインで起こりうるエラーの処理の漏れがなくなった。

元々はミュータブルな状態を用いた導入例が多かった DDD のアーキテクチャに対してどのように純粋関数的なスタイルを入れていくかなど悩ましい点はいくつかありましたが、全体的には見通しの良いコードになったなと感じており引き続き来年も進めていきたいと考えています。

終わりに

本記事は NIKKEI Advent Calendar 2022 1 日目の記事として、日経 ID における関数型プログラミングの導入についてお話しました。

テクノロジーメディアを目指す日本経済新聞社では、最新のセキュリティ技術を日々キャッチアップし、セキュアなサービスを目指して日々開発しております。 セキュリティの技術を生かしてメディアの未来を作る仕事に興味のある方は、ぜひお気軽にご連絡ください。

https://hack.nikkei.com/jobs

明日は 2 日目、齊藤さんによる「OpenWebAdvocacy メンバーとしての活動が官邸 Web サイトに掲載された話」です。お楽しみに!

淵脇誠
ENGINEER淵脇誠
奥田和史
ENGINEER奥田和史

Entry

各種エントリーはこちらから

キャリア採用
Entry
新卒採用
Entry
カジュアル面談
Entry