grpc-kotlinの実装をinterfaceの定義によってテンプレート化する

はじめに

過去にも何度か紹介しておりますが、現在開発中の新しい教材システムでは新たにgRPC通信によるKotlinサーバーを採用して開発が進められています。

blog.nnn.dev

そして、技術選定の方針としてWebアプリケーションフレームワークはなるべくシンプルで薄くすることを決めたので、Spring Boot のような別フレームワークを介せずに、grpc-kotlin の出力するサーバークラスをそのまま使用しています。

だたし、grpc-kotlin の仕組みで愚直に全部すると不便な点も多かったので、独自のインターフェースを定義して簡易的なフレームワークとして利用できるようにしました。 今回はそれをご紹介します。

grpc-kotlin の実装に必要な要素

最初に前提知識として、grpc-kotlin を使ったエンドポイントの実装方法について簡単な解説をします。 実装例では、以下のproto定義を使用します

syntax = "proto3";

option java_multiple_files = true;
option java_package = "sampleproto";

service GreetingService {
  rpc sayHello(sayHelloRequest) returns (sayHelloResponse);
}

message SayHelloRequest {
  // 挨拶をする相手の名前
  string name = 1;
}

message SayHelloResponse {
  // 挨拶の文面
  string greeting = 1;
}

リクエスト・レスポンスクラス

リクエスト・レスポンスの型を含む各種 message を表現するクラスはJavaの実装がそのまま使われており、そのため data class のようなKotlin独自の言語機能は利用されていません。

各メッセージのクラスはコンストラクタを持たず、次のようなBuilderパターンを使ってオブジェクトの生成を行います。

fun buildResponse(greeting: String): sampleproto.SayHelloResponse {
    return sampleproto.SayHelloResponse.newBuilder()
      .setGreeting()
      .build()
}

サービスクラス

gRPCの各 service に対応するインターフェースはKotlinで新たに定義されています。

引数のObserverを介してレスポンスを送る形式だったJavaの実装と比較すると、Kotlinではメソッドの戻り値として直接レスポンスオブジェクトを返す形式になっているので、テストがしやすくなりました。

また、各エンドポイントは suspend fun で定義されているので、コルーチンを使った非同期処理が書きやすくなっています。

class GreetingServiceImpl : sampleproto.SchoolTermServiceGrpcKt.SchoolTermServiceCoroutineImplBase() {
    override suspend fun sayHello(
        request: sampleproto.SayHelloRequest,
    ): sampleProto.SayHelloResponse {
        val greeting = "Hello! ${request.name}!"
        return buildResponse(greeting) // 先の例で定義された関数
    }
}

サーバークラス

リクエストやレスポンスのオブジェクトと同様に、サーバー処理を起動させるクラスも grpc-Java の実装がそのまま使われています。

上記のサービスクラスはKotlinで定義されていますが、Javaのインターフェースを実装しているため、そのままJavaのサーバークラスに渡すことができます。

fun main() {
    val server = io.grpc.ServerBuilder.forPort(6565)
        .addService(GreetingServiceImpl())
        .build()

    server.start()

    server.awaitTermination()
}

普通に実装したときの不満点

上記のgRPCの実装を愚直に書いていくことを考えたとき、主に次の3つのような不満を覚えました。

今回紹介する独自インタフェース定義による簡易フレームワークは、これらの不満を解消することを目的にしています。

不満1: 異なる関心事の混在

grpc-kotlin では各エンドポイントを「protoのリクエスト型を受け取って、protoのレスポンス 型を返す関数」 として実装しますが、ビジネスロジックの実行に関心を持つドメイン層 (またはユースケース層) とgrpcの実装に関心を持つアプリケーション層の区別に着目したとき、それは次の3つの関心事へと分解できます。

  • protoのリクエスト型の内容をバリデーションし、ドメインオブジェクトへ変換する
  • ビジネスロジックの実行し、実行結果を返す
  • 実行結果のドメインオブジェクトからprotoのレスポンス型へ変換する

異なる関心事は別々のテストケースで検証したいですが、実装が分離されていないとテストに関係ないコードも常に抱き合わせで実行されることになるので、テスト観点とは関係ない理由での失敗やテスト効率の低下につながります。

ValidatorやUseCaseのクラスをそれぞれ作ってそれらを単体でテストした上で、validator.validate(request).andThen { useCase.call(it) }.andThen { ... みたいに実装する方針も考えられますが、各エンドポイントで同じコードを何度も書くのは楽しくないですしミスの混入する余地も残ります。

Template Method パターンのように、分離された関心事が自動的に結合される仕組みが望ましいです。

不満2: 共通処理

多くのWebアプリケーションフレームワークではアクセス概要のログ記録など、運用を助けるための様々な共通処理が用意されていますが、grpc-kotlin の実装を直接使う場合はそれらを自前で実装する必要があります。

共通処理を実装する仕組みとしてインターセプタが用意されていますが、それにも次のような不満を覚えます。

  • 処理が複数のクラスやメソッドに散らばるため、流れが追いづらくなる
  • サーバークラスに addService() するたびに、毎回サービスクラスをインターセプタで包むのが面倒 (実装例)
  • 共通処理とメイン処理の間でデータをやり取りしようとすると、grpcのメタデータを使う必要があり、取り回しが悪い
  • 最初と最後以外 (例えばリクエストバリデーションとメイン処理の間など) に共通処理を入れることができない

共通処理はできるだけ一連の流れとして手続き的に書きたく思います。

不満3: エラーハンドリング

grpc-java およびそれに依存する grpc-kotlin の実装では、各サービスからStatusExceptionの例外を投げれば自動でそれをgRPCの仕様に合わせたエラーレスポンスに変換してくれるように作られています。

しかし、ドメイン層がgRPCに依存しない設計を採用した場合、ドメイン層はStatusException を知らないため、ドメイン層が投げる様々な例外をアプリケーション層で変換して投げ直すような共通処理が必要になります。

例外であればインターセプタで一括処理するという選択も採れますが、Result型によるエラーハンドリングを採用している場合には、エラー型をインターセプタに渡すことはできないため、各サービス内でエラー型の共通処理が実行されなければなりません。

また、StatusException にはステータスコードやメタデータのセットなどの種類に応じた処理分岐が必要なので、sealed型を使ってエラー種別の考慮漏れがなくせると嬉しいでしょう。

インターフェースを定義する

現在開発中の新Kotlinサーバーでは、gRPCの各エンドポイントの実装に以下のようなインターフェース定義を使用しています。

なお、エラーハンドリングにはkotlin-resultのResult型を使用しています。 詳しくは以下の記事で紹介しています。

blog.nnn.dev

/**
 * GRPCのrpc関数実装を定義するためのインタフェース
 * @param PReq protoファイルから自動生成されたリクエスト型
 * @param Req [validateAndConvertRequest]において[PReq]に対して基本的なバリデーションと型変換を行った結果の型
 * @param Res ドメインレイヤーで処理結果を表す型。[convertResponse]によって[PRes]に変換される
 * @param PRes protoファイルから自動生成されたレスポンス型
 */
interface GrpcImplementation<PReq : com.google.protobuf.GeneratedMessageV3, Req, Res, PRes : com.google.protobuf.GeneratedMessageV3> {
    /**
     * protoのリクエストに対してバリデーションを実行した上で、ドメイン層で使える型に変換する
     */
    fun validateAndConvertRequest(proto: PReq): Result<Req, ValidationError>

    /**
     * エンドポイントのメイン処理を実行する
     */
    suspend fun process(request: Req): Result<Res, MyDomainError>

    /**
     * [process]の結果をprotoから自動生成されたレスポンス型に変換する
     */
    fun convertResponse(response: Res): PRes
}

上記のインターフェースの実装は以下のような拡張関数によって結合されます。

サンプルコードはわかりやすさのために簡略化していますが、実際にはログ出力などの共通処理が他にも組み込まれています。

private suspend fun <PReq : GeneratedMessageV3, Req, Res, PRes : GeneratedMessageV3> GrpcImplementation<PReq, Req, Res, PRes>.runRpc(
    requestProto: PReq,
): PRes {
    return try {
        validateAndConvertRequest(requestProto)
            .andThen { req -> process(req) }
            .map { res -> convertResponse(res) }
            .getOrThrow { err -> err.toStatusException() } // ResultのエラーをStatusExceptionの変換して投げる
    } catch (e: CancellationException) {
        throw e // coroutineのキャンセルはエラーではないので、そのままcoroutineの呼び出し元にそのまま投げる
    } catch (e: Exception) {
        throw e.toStatusException() // 予期せぬ例外もStatusExceptionに変換し、INTERNALのステータスコードで投げ直す
    }
}

このインターフェースを実装したクラスは、1つのgRPCエンドポイントに対応するので、これを使ったgRPCサービスの実装は以下のような形になります。

class SayHelloServiceImpl : sampleproto.SchoolTermServiceGrpcKt.SchoolTermServiceCoroutineImplBase() {
    override suspend fun sayHello(request: sampleproto.SayHelloRequest): sampleProto.SayHelloResponse {
        val impl: GrpcImplementation = SayHelloImpl()
        return impl.runRpc(request)
    }
}

以下ではインターフェースの各メソッドとエラーハンドリングの方法について順に説明していきます。

validateAndConvertRequest

リクエストパラメータのバリデーションとドメインオブジェクトへの型変換を行います。

ただし、既存データ取得のためのDB等の外部システムへのアクセスは続く process() の中に閉じ込めたいので、文字列の長さやパターンマッチ・数値の単一パラメータの検証のみです。 (重複や参照整合性の確認は process() 内で行う)

型変換は、日時を表す文字列を適切なクラスにしたり、protoファイルから生成されたenum値をドメイン層で定義されたenum値にしたりします。

また、ドメイン・プリミティブな型を定義して文字列や数値をそれでラップすることで、バリデーション済みであることがコンパイラで保証された状態で、ドメイン層に値を渡すことができます。 以下の例では PersonName クラスを定義して、改行が含まれないことと長さが30文字以下であることを確認しています。

検知したエラーは独自定義のデータ型に変換して、後述のエラーハンドリングの仕組みで共通処理できるようにします。

class GreetingServiceImpl : GrpcImplementation<sampleproto.SayHelloRequest, PersonName, String, sampleproto.SayHelloResponse> {
    override fun validateAndConvertRequest(proto: PReq): Result<sampleproto.SayHelloRequest, ValidationError> {
        return PersonName.tryNew(proto.name).mapError { msg ->
            ValidationError("name", msg)
        }
    }
}

@JvmInline
value class PersonName private constructor (val value: String) {
    companion object {
        fun tryNew(value: String): Result<PersonName, String> {
            if (value.contains('\n')) {
                retun Err("人名に改行文字は含められません")
            }
            if (value.length > 30) {
              return Err("人名は30文字以下で設定してください") 
            }
            return Ok(PersonName(value))
        }
    }
}

process

validateAndConvertRequest()convertResponse() 以外のメイン処理を行う場所です。 ユースケース層と呼ばれるものに相当します。

protoからリクエスト型から変換された後のオブジェクトを受け取ってprotoのレスポンス型へ変換する前のオブジェクトを返すため、個々ではprotoの型定義には直接依存せずに実装可能です。 これによって、データ生成などのテスト向けのユーティリティコードも多くをドメイン層のテストから流用することが可能になっています。

以下の例には含まれませんが、エラーは独自定義の型に変換して後述のエラーハンドリングの仕組みで共通処理できるようにします。 例外はキャッチせずにそのまま投げて、Internal Server Error として処理させます。

class GreetingServiceImpl : GrpcImplementation<sampleproto.SayHelloRequest, PersonName, String, sampleproto.SayHelloResponse> {
    override suspend fun process(request: PersonName): Result<String, MyDomainError> {
        val greeting = "Hello! ${request.value}!"
        return Ok(greeting)
    }
}

convertResponse

process() の処理結果をprotoファイルから自動生成されたレスポンスクラスに変換します。

単純な値のセットと型変換だけなので、ここでのエラー発生は想定されていません。

例では process() の戻り値が単純な String だけなのでそれをセットするだけですが、UserCourse のような構造化されたデータ型が戻り値になる場合は、User.toProto() のような型変換のための拡張関数をそれぞれ定義して共通化を行います。

class GreetingServiceImpl : GrpcImplementation<sampleproto.SayHelloRequest, PersonName, String, sampleproto.SayHelloResponse> {
    override fun convertResponse(response: String): sampleproto.SayHelloResponse {
        return sampleproto.SayHelloResponse.newBuilder()
          .setGreeting()
          .build()
    }
}

エラーハンドリング

まず、網羅的なエラーハンドリングを実現するために、次のようなsealed型を定義します。

これらのエラー型はドメイン層に定義しますが、このような区分は特定のフレームワークなどに依存しないため、レイヤーの責務としても問題は無いでしょう。

sealed interface MyDomainError {
    /** エラーレスポンスとして返すメッセージ */
    val userMessage: String
}

/**
 * アクセス認可に失敗したときのエラー
 */
data class AuthorizationError(
    override val logMessage: String,
) : MyDomainError

/**
 * 重複するレコードを作成しようとしたときのエラー
 */
data class DuplicateResourceError(
    val resourceType: String,
    val resourceId: Int,
) : MyDomainError {
    override val userMessage = "$resourceType<$resourceId>はすでに存在します"
}

/**
 * 指定されたレコードが見つからなかったときのエラー
 */
data class NotFoundError(
    val resourceType: String,
    val resourceId: Int,
) : MyDomainError {
    override val userMessage = "$resourceType<$resourceId>が見つかりませんでした"
}


/**
 * リクエストパラメータの検証に失敗したときのエラー
 */
data class ValidationError(
    val paramName: String,
    val errorMessage: String,
) : MyDomainError {
    override val userMessage = "$paramName: $errorMessage"
}

/**
 * 分類不能なその他のエラー。自由に派生させることが可能
 */
 interface OtherRequestError : MyDomainError

そして、これらのエラーをgRPCサーバーが処理できるように、StatusException に変換する関数を定義します。

この関数は io.grpccom.google.rpc に依存するのでドメイン層で定義することはできません。 そのためインターフェースによるポリモーフィズムは利用できず、下記のような when による分岐が必要となりますが、sealed型を利用しているため考慮漏れの心配なしに実装できるようになっています。

fun MyDomainError.toStatusException(): io.grpc.StatusException {
        val (status, errorDetails) = when (this) {
        is AuthorizationError -> this.getStatusAndDetail()
        is DuplicateResourceError -> this.getStatusAndDetail()
        is NotFoundError -> this.getStatusAndDetail()
        is ValidationError -> this.getStatusAndDetail()
        is OtherRequestError -> this.getStatusAndDetail()
    }

    return StatusException(status.withDescription(userMessage()), errorDetails)
}

private fun NotFoundError.getStatusAndDetail(): Pair<io.grpc.Status, Metadata> {
        val details = Metadata()
        val resourceInfo = com.google.rpc.ResourceInfo.newBuilder()
            .setResourceType(err.expectedResourceName.inJapanese)
            .setResourceName(err.keys.toString())
            .build()
        details.put(io.groc.protobuf.ProtoUtils.keyForProto(resourceInfo), resourceInfo)
        return io.grpc.Status.NOT_FOUND to details
}

// 他のエラークラスの `getStatusAndDetail()` は省略

例外に対しても同様に StatusException への変換関数を定義します。

これらによって、ExceptionErr の処理の定義を1箇所にまとめることができました。

fun Exception.toStatusException(): io.grpc.StatusException {
    if (this is io.grpc.StatusException) {
        return this
    }

    val status = io.grpc.Status.INTERNAL
    val userMessage = "サーバーでエラーが発生しました"

    return StatusException(status.withDescription(userMessage))
}

おわりに

著名なフレームワークを利用せずにWebアプリケーションを実装するという少し挑戦的な選択をしましたが、今のところ大きな問題は無く快適に開発を進めることができています。

フレームワーク部分を薄く保つことはサーバー起動の大幅に高速化につながったり、ブラックボックス部分を少なくしてコードの理解を助けたりするなどの利点があります。

gRPCサーバーで軽量なWebアプリケーションフレームワークを探している方は、「フレームワークを使わない」という選択肢も検討してみてはいかがでしょう。

We are hiring! & Kotlin Fest出展のお知らせ

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

また、ドワンゴは6/22に行われるKotlin Fest 2024にもスポンサーしており、教育事業のメンバーがブースを出展します。

本記事などを執筆しているサーバーサイドKotlinの開発リーダーも参加しますので、記事内容への質問やその他聞きたいことがありましたら、お気軽にドワンゴブースまでお越しください! (サーバーサイドKotlinの開発者がセッション視聴で離席している場合がある旨、ご了承願います)

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com