過去にも何度か紹介しておりますが、現在開発中の新しい教材システムでは新たにgRPC通信によるKotlinサーバーを採用して開発が進められています。 そして、技術選定の方針としてWebアプリケーションフレームワークはなるべくシンプルで薄くすることを決めたので、Spring Boot のような別フレームワークを介せずに、grpc-kotlin の出力するサーバークラスをそのまま使用しています。 だたし、 最初に前提知識として、 リクエスト・レスポンスの型を含む各種 各メッセージのクラスはコンストラクタを持たず、次のようなBuilderパターンを使ってオブジェクトの生成を行います。 gRPCの各 引数のObserverを介してレスポンスを送る形式だったJavaの実装と比較すると、Kotlinではメソッドの戻り値として直接レスポンスオブジェクトを返す形式になっているので、テストがしやすくなりました。 また、各エンドポイントは リクエストやレスポンスのオブジェクトと同様に、サーバー処理を起動させるクラスも 上記のサービスクラスはKotlinで定義されていますが、Javaのインターフェースを実装しているため、そのままJavaのサーバークラスに渡すことができます。 上記のgRPCの実装を愚直に書いていくことを考えたとき、主に次の3つのような不満を覚えました。 今回紹介する独自インタフェース定義による簡易フレームワークは、これらの不満を解消することを目的にしています。 異なる関心事は別々のテストケースで検証したいですが、実装が分離されていないとテストに関係ないコードも常に抱き合わせで実行されることになるので、テスト観点とは関係ない理由での失敗やテスト効率の低下につながります。 ValidatorやUseCaseのクラスをそれぞれ作ってそれらを単体でテストした上で、 Template Method パターンのように、分離された関心事が自動的に結合される仕組みが望ましいです。 多くのWebアプリケーションフレームワークではアクセス概要のログ記録など、運用を助けるための様々な共通処理が用意されていますが、 共通処理を実装する仕組みとしてインターセプタが用意されていますが、それにも次のような不満を覚えます。 共通処理はできるだけ一連の流れとして手続き的に書きたく思います。 しかし、ドメイン層がgRPCに依存しない設計を採用した場合、ドメイン層は 例外であればインターセプタで一括処理するという選択も採れますが、Result型によるエラーハンドリングを採用している場合には、エラー型をインターセプタに渡すことはできないため、各サービス内でエラー型の共通処理が実行されなければなりません。 また、 現在開発中の新Kotlinサーバーでは、gRPCの各エンドポイントの実装に以下のようなインターフェース定義を使用しています。 なお、エラーハンドリングにはkotlin-resultのResult型を使用しています。
詳しくは以下の記事で紹介しています。 上記のインターフェースの実装は以下のような拡張関数によって結合されます。 サンプルコードはわかりやすさのために簡略化していますが、実際にはログ出力などの共通処理が他にも組み込まれています。 このインターフェースを実装したクラスは、1つのgRPCエンドポイントに対応するので、これを使ったgRPCサービスの実装は以下のような形になります。 以下ではインターフェースの各メソッドとエラーハンドリングの方法について順に説明していきます。 リクエストパラメータのバリデーションとドメインオブジェクトへの型変換を行います。 ただし、既存データ取得のためのDB等の外部システムへのアクセスは続く 型変換は、日時を表す文字列を適切なクラスにしたり、protoファイルから生成されたenum値をドメイン層で定義されたenum値にしたりします。 また、ドメイン・プリミティブな型を定義して文字列や数値をそれでラップすることで、バリデーション済みであることがコンパイラで保証された状態で、ドメイン層に値を渡すことができます。
以下の例では 検知したエラーは独自定義のデータ型に変換して、後述のエラーハンドリングの仕組みで共通処理できるようにします。 protoからリクエスト型から変換された後のオブジェクトを受け取ってprotoのレスポンス型へ変換する前のオブジェクトを返すため、個々ではprotoの型定義には直接依存せずに実装可能です。
これによって、データ生成などのテスト向けのユーティリティコードも多くをドメイン層のテストから流用することが可能になっています。 以下の例には含まれませんが、エラーは独自定義の型に変換して後述のエラーハンドリングの仕組みで共通処理できるようにします。
例外はキャッチせずにそのまま投げて、Internal Server Error として処理させます。 単純な値のセットと型変換だけなので、ここでのエラー発生は想定されていません。 例では まず、網羅的なエラーハンドリングを実現するために、次のようなsealed型を定義します。 これらのエラー型はドメイン層に定義しますが、このような区分は特定のフレームワークなどに依存しないため、レイヤーの責務としても問題は無いでしょう。 そして、これらのエラーをgRPCサーバーが処理できるように、 この関数は 例外に対しても同様に これらによって、 著名なフレームワークを利用せずにWebアプリケーションを実装するという少し挑戦的な選択をしましたが、今のところ大きな問題は無く快適に開発を進めることができています。 フレームワーク部分を薄く保つことはサーバー起動の大幅に高速化につながったり、ブラックボックス部分を少なくしてコードの理解を助けたりするなどの利点があります。 gRPCサーバーで軽量なWebアプリケーションフレームワークを探している方は、「フレームワークを使わない」という選択肢も検討してみてはいかがでしょう。 株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。
カジュアル面談も行っています。
お気軽にご連絡ください! また、ドワンゴは6/22に行われるKotlin Fest 2024にもスポンサーしており、教育事業のメンバーがブースを出展します。 本記事などを執筆しているサーバーサイドKotlinの開発リーダーも参加しますので、記事内容への質問やその他聞きたいことがありましたら、お気軽にドワンゴブースまでお越しください!
(サーバーサイド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独自の言語機能は利用されていません。fun buildResponse(greeting: String): sampleproto.SayHelloResponse {
return sampleproto.SayHelloResponse.newBuilder()
.setGreeting()
.build()
}
サービスクラス
service
に対応するインターフェースは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
の実装がそのまま使われています。fun main() {
val server = io.grpc.ServerBuilder.forPort(6565)
.addService(GreetingServiceImpl())
.build()
server.start()
server.awaitTermination()
}
普通に実装したときの不満点
不満1: 異なる関心事の混在
grpc-kotlin
では各エンドポイントを「protoのリクエスト型を受け取って、protoのレスポンス 型を返す関数」 として実装しますが、ビジネスロジックの実行に関心を持つドメイン層 (またはユースケース層) とgrpcの実装に関心を持つアプリケーション層の区別に着目したとき、それは次の3つの関心事へと分解できます。
validator.validate(request).andThen { useCase.call(it) }.andThen { ...
みたいに実装する方針も考えられますが、各エンドポイントで同じコードを何度も書くのは楽しくないですしミスの混入する余地も残ります。不満2: 共通処理
grpc-kotlin
の実装を直接使う場合はそれらを自前で実装する必要があります。
addService()
するたびに、毎回サービスクラスをインターセプタで包むのが面倒 (実装例)不満3: エラーハンドリング
grpc-java
およびそれに依存する grpc-kotlin
の実装では、各サービスからStatusExceptionの例外を投げれば自動でそれをgRPCの仕様に合わせたエラーレスポンスに変換してくれるように作られています。StatusException
を知らないため、ドメイン層が投げる様々な例外をアプリケーション層で変換して投げ直すような共通処理が必要になります。StatusException
にはステータスコードやメタデータのセットなどの種類に応じた処理分岐が必要なので、sealed型を使ってエラー種別の考慮漏れがなくせると嬉しいでしょう。インターフェースを定義する
/**
* 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のステータスコードで投げ直す
}
}
class SayHelloServiceImpl : sampleproto.SchoolTermServiceGrpcKt.SchoolTermServiceCoroutineImplBase() {
override suspend fun sayHello(request: sampleproto.SayHelloRequest): sampleProto.SayHelloResponse {
val impl: GrpcImplementation = SayHelloImpl()
return impl.runRpc(request)
}
}
validateAndConvertRequest
process()
の中に閉じ込めたいので、文字列の長さやパターンマッチ・数値の単一パラメータの検証のみです。
(重複や参照整合性の確認は process()
内で行う)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()
以外のメイン処理を行う場所です。
ユースケース層と呼ばれるものに相当します。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
だけなのでそれをセットするだけですが、User
や Course
のような構造化されたデータ型が戻り値になる場合は、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 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
StatusException
に変換する関数を定義します。io.grpc
や com.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
への変換関数を定義します。Exception
や Err
の処理の定義を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))
}
おわりに
We are hiring! & Kotlin Fest出展のお知らせ