SpringBoot 2.0とapiDocを連携させてみた

SpringFrameworkでAPIドキュメントを準備する手段としてSpringFoxやSpring REST Docsなどが候補にあがる。

SpringFox by springfox

Spring REST Docs

両者とも導入敷居が低くSpringFoxはコントローラクラスやレスポンスモデルにアノテーションをつけていきSpringを起動すればドキュメントエンドポイントが生まれる。APIの開発序盤からDocsを整理していけるスピード感がある。対してSpring REST DocsはテストコードにDocs生成手段を仕込みHttpStatus:200 OKHttpStatus:404 NotFoundなどレスポンス毎にDocsを整理できるので網羅的なドキュメントを整理することができる。

Spring Boot 2.0でも同様にSpringFoxやSpring REST Docsを使いAPIドキュメントを整理したかったが現時点では未対応な状況である。(2017/09/29)

github.com

github.com


何か別の手段でと思いを馳せて居たところで同僚にapiDocを教えてもらった。

apiDoc - Inline Documentation for RESTful web APIs

apiDocはJavadoc-Styleやコメントコードを駆使してAPIドキュメントをまとめapidocのコマンドを叩けばドキュメント一式のHTMLが生成される。詳細は公式を確認いただきたい。

http://apidocjs.com/#run

コメントコードが生成できればドキュメントHTMLが生成される仕組みなのでSpring REST DocsのようにテストコードにapiDoc-Params(コメントコード)を生成する仕組みを取り入れてみた。

ここからは開発したテストコードを元にドキュメントHTMLが生成されるまでをまとめていきたい。

apiDocのコメントコードを吐くテストコード

/**
 * @api {GET} /api/task/:taskId タスクの取得
 * @apiName GetTask
 * @apiGroup Task
 * @apiVersion 1.0.0
 *
 * @apiUse GetTaskOk
 * @apiUse GetTaskNotFound
 *
 */
@Test
fun `GET Task`() {

    val define = DefineBuilder({
        version { "1.0.0" }
        name { "GetTaskOk" }
    })

    // @apiParamのコメントコードを吐く
    define.param { ApiParam("taskId", "タスクID", "23445", false, "7") }

    // mock
    `when`(taskHandler.fetchByTaskId(any())).thenReturn(ok().json().body(Mono.just(mockModel)))

    client.get().uri("/api/task/1")
            .accept(MediaType.APPLICATION_JSON_UTF8)
            .exchange().expectStatus().isOk
            .expectBody()
            .consumeWith {
                val actual: TaskModel = mapper.readValue(it.responseBody)
                actual.id shouldBe 1L
                actual.title shouldBe "task title"
                actual.createdAt shouldBe "2017-06-13T16:22:52Z"
                actual.updatedAt shouldBe "2017-06-13T16:22:52Z"

                // @apiSuccessのコメントコードを吐く
                define.success { actual }
                // @apiSuccessExampleのコメントコードを吐く
                define.successExample { ApiSuccessExample("Success", HttpStatus.OK, actual) }
            }

    define.genDoc()
}

タスクを1件取得するエンドポイントのテストコードに仕込んだ。最初のコメントコードでapiDocの仕様に基づきAPIの概要をまとめている。@apiUseについては後述する。
テストコードのブロック内ではDefineBuilderのインスタンスからDocをビルドアップしている。このDefineBuilderをお手製でつくってみた。

define.success { actual }

このsuccessに渡るインスタンスクラスのプロパティに次のような@ApiDocPropertyがついていれば@apiSuccess {String} firstname Firstname of the User.のようなコメントコードが吐かれるようにした。

data class TaskModel(
        @ApiDocProperty(value = "タスクID", example = "23445")
        val id: Long,
        @ApiDocProperty(value = "タイトル", example = "牛乳を買う")
        val title: String,
        @ApiDocProperty(value = "完了日時", example = "yyyy-MM-dd'T'HH:mm:ss'Z'")
        val finishedAt: String?,
        @ApiDocProperty(value = "作成日時", example = "yyyy-MM-dd'T'HH:mm:ss'Z'", nullable = true)
        val createdAt: String,
        @ApiDocProperty(value = "更新日時", example = "yyyy-MM-dd'T'HH:mm:ss'Z'")
        val updatedAt: String)

上記のインスタンスは次のようなコメントコードを吐く。JSONレスポンスの各項目にも名称やサンプル値などが整理できる。

@apiSuccess {String} createdAt 作成日時, Nullable:TRUE, Example:yyyy-MM-dd'T'HH:mm:ss'Z'
@apiSuccess {String} finishedAt 完了日時, Nullable:FALSE, Example:yyyy-MM-dd'T'HH:mm:ss'Z'
@apiSuccess {long} id タスクID, Nullable:FALSE, Example:23445
@apiSuccess {String} title タイトル, Nullable:FALSE, Example:牛乳を買う
@apiSuccess {String} updatedAt 更新日時, Nullable:FALSE, Example:yyyy-MM-dd'T'HH:mm:ss'Z'

そして最後に@apiUseである。冒頭のコメントコードは次のようになっていた。

@apiUse GetTaskOk
@apiUse GetTaskNotFound

apiDocの特徴的な機能で@apiUseに宣言された変数は@apiDefineに宣言された変数と紐づくことになる。GetTaskOkは正常系のテストコードに紐づき、GetTaskNotFoundは404エラーになるテストコードに紐付いている。404エラーのテストコードは次のようになっている。

fun `GET Task NotFound`() {

    val define = DefineBuilder({
        version { "1.0.0" }
        name { "GetTaskNotFound" } // `@apiDefine GetTaskNotFound`を吐く
    })

    // mock
    `when`(taskHandler.fetchByTaskId(any())).thenThrow(WebAppException.NotFoundException("task notfound."))

    client.get().uri("/api/task/1")
            .accept(MediaType.APPLICATION_JSON_UTF8)
            .exchange().expectStatus().isNotFound
            .expectBody()
            .consumeWith {
                val actual: ErrorItem = mapper.readValue(it.responseBody)

                define.error { actual }
                define.errorExample { ApiErrorExample("BadRequest", HttpStatus.BAD_REQUEST, actual) }
            }

    define.genDoc()
}

@apiUse@apiDefineの仕組みをつかえば複数のAPIレスポンスをドキュメント化することができる。
エンドポイントのあらゆる異常系を書き@apiUse@apiDefineで紐付けることでドキュメントが整備されていく仕掛けとなる。Spring REST Docsのエッセンスを上手く取り入れられた。

まとめ

apiDocが生成したドキュメントの画面キャプチャは次のようになった。

正常系

f:id:n_soushi:20170929214250p:plain

エラー

f:id:n_soushi:20170929214321p:plain


SpringFoxやSpring REST DocのSpring Boot 2.0.0(Spring5)のサポートを待つ間の暫定対応としてapiDocを連携させてみたが、思いの外ドキュメントにする手間もなくドキュメント化が実現できた。

サポートが完了したSpringFoxやSpring REST Docの新しい機能に期待をしながら、今回つくったDefineBuilderに対応していないapiDoc-Paramを追加していきライブラリとして成熟させていきたい。

コード

今回紹介したコードはgithubに置いていますので良ければ参照してください。

github.com

テストコードはこちらです。

gRPC serverのmetadataをテストする方法をまとめる

gRPC ServerのレスポンスにはMetadataを含めることができる。gRPCのレスポンス・ステータス(io.grpc.Status.OKio.grpc.Status.INVALID_ARGUMENT)やDescription(エラーメッセージ)に加え、例えば400系のエラーでも異なるエラー内容をクライアントに伝えるのに役立つ。HTTP/1.1ではHTTPステータスに加えレスポンス・ボディにJSON形式でエラーメッセージとエラーコードを含めるようなことで実現していたが、gRPCではMetadataを活用すればDescription(エラーメッセージ)をJSON形式にする必要はない。

今回のエントリではMetadataを含むレスポンスを返すgRPC Serverのテスト方法をまとめていく。

Metadataを含むレスポンスを返すgRPC Serverを準備する

gRPC Serverで起きたエラーをインターセプトしてレスポンスを返すInterceptorを以前のエントリで紹介した。

blog.soushi.me

このインターセプターで返すレスポンスにcustom_statusというKeyのMetadataを含めるようにした。コードとしては次のようにしている。

private fun <ReqT, RespT> handleException(call: ServerCall<ReqT, RespT>?, headers: Metadata?, ex: Exception) {

    when (ex) {
// ・・・省略
        is WebAppException.NotFoundException -> {
            Metadata.Key.of("custom_status", Metadata.ASCII_STRING_MARSHALLER).let {
                headers?.put(it, HttpStatus.NOT_FOUND.value().toString())
            }
            call?.close(Status.fromCode(Status.NOT_FOUND.code).withDescription(ex.message), headers)
        }
// ・・・省略
    }
}

WebAppException.NotFoundExceptionのExceptionクラスであればcustom_statusのMetadataには404の文字列が含まれるようにした。

metadataをテストする方法

metadataをテストするためにはテストコードで起動しているgRPC Serverのインターセプターに新しいインターセプター・クラスを加える。そのインターセプター・クラスのmetadataをキャプチャすることでmetadataが含まれているか検証する。

コードとしては次のようになる。

private val metadataCaptor = ArgumentCaptor.forClass(io.grpc.Metadata::class.java)
private val mockServerInterceptor = Mockito.spy(TestInterceptor())
private class TestInterceptor : ServerInterceptor {
    override fun <ReqT : Any?, RespT : Any?> interceptCall(call: ServerCall<ReqT, RespT>?, headers: io.grpc.Metadata?,
                                                           next: ServerCallHandler<ReqT, RespT>?): ServerCall.Listener<ReqT> {
        return next!!.startCall(call, headers)
    }
}

@Before
fun setUp() {
    delegateTaskService = mock(DelegateTaskService::class)

    target = TaskBackendServer(delegateTaskService)
    inProcessServer = InProcessServerBuilder
            .forName(UNIQUE_SERVER_NAME)
            .addService(target)
            .intercept(exceptionInterceptor)
            .intercept(mockServerInterceptor)  // このインターセプター・クラスのmetadataをキャプチャする
            .directExecutor()
            .build()
    inProcessChannel = InProcessChannelBuilder.forName(UNIQUE_SERVER_NAME).directExecutor().build()

    inProcessServer.start()
}

テストコードは次のようになった。

@Test
fun getProducts_NOT_FOUND() {

    val taskId = 1L
    val request = TaskInbound.newBuilder().setTaskId(taskId.toInt()).build()

    val command = GetTaskCommand(taskId)

    // mock
    mockStatic(GRpcLogContextHandler::class)
    Mockito.`when`(GRpcLogContextHandler.getLog()).thenReturn(GRpcLogBuilder())
    Mockito.`when`(delegateTaskService.getTask(command)).thenThrow(WebAppException.NotFoundException("not found"))

    try {
        // request server
        val blockingStub = TaskServiceGrpc.newBlockingStub(inProcessChannel)
        blockingStub.getTaskService(request)
    } catch (e: StatusRuntimeException) {
        // assertion
        e.status.code shouldBe Status.NOT_FOUND.code
        e.message shouldBe "NOT_FOUND: not found"

        // metadataの`custom_status`が"404" であること
        Mockito.verify(mockServerInterceptor).interceptCall(
                MockHelper.any<ServerCall<TaskInbound, TaskOutbound>>(),
                metadataCaptor.capture(),
                MockHelper.any<ServerCallHandler<TaskInbound, TaskOutbound>>())
        metadataCaptor.value.get(
                Metadata.Key.of("custom_status", Metadata.ASCII_STRING_MARSHALLER)) shouldBe "404"
    }
}

MockitoのArgumentCaptorを活用すればmetadataの検証も手軽に行うことができた。

コード

githubにコードがありますので合わせて参照ください。

github.com

テストコードはこちらです。

jackson.datatypeをつかってiOSのレシートの日付文字列をデシアライズする

iOSアプリで購入したアイテムのレシート検証を実装する過程でレシートに含まれる日付文字列をdata classにデシアライズしてみた。

レシートに含まれる日付は次のような形となっている。

"expires_date": "2016-06-17 01:32:28 Etc/GMT"

見慣れないタイムゾーンを表すEtc/GMTの文字列があった。

DateTimeFormatter (Java Platform SE 8 )

上記を参照すると次のように定義されている。

V       time-zone ID                zone-id           America/Los_Angeles; Z; -08:30

この定義からDateTimeFormatter は次のように定義する必要がある。※ Vは2つVV

"yyyy-MM-dd HH:mm:ss VV"

このフォーマットをもとにJson文字列から定義したdata classへデシアライズしてみよう。

デシアライズするdata classを定義する

デシアライズするdata classは次のようなに定義した。

data class MyState(
        @JsonProperty("expiration_date")
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss VV")
        val expirationDate: ZonedDateTime
)

JSONにあるexpiration_dateはdata classでは文字列ではなく日付系の型に変換したい。
ここで重要な点がある。
日付文字列にはEtc/GMTのタイムゾーンが含まれるためLocalDateTimeには変換できない。LocalDateTimeはタイムゾーンを持たないからである。
そのためZonedDateTimeに変換し expirationDate.toLocalDateTime()とすることでLocalDateTimeが取得することができる。

data classの準備はこれで整った。

jackson.datatypeをつかう

ここでエントリのタイトルにもあるjackson.datatypeが登場する。
Json <-> DataClassのシリアライズ/デシアライズにはkotlinモジュールも整っているのでjacksonを採用したい。

日付型に変換が必要であればjackson.datatypeが必要である。gradleに依存を追加する。

compile 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.1'

そしてObjectMapperの初期化にJavaTimeModuleを追加する。

val objectMapper = ObjectMapper()
        .registerModule(KotlinModule())
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        .registerModule(JavaTimeModule())

これでiOSのレシートでつかわれる日付文字列を日付型に定義したdata classにデシアライズできる。

class SandboxTest {

    @Test
    fun deserialize() {
        Sandbox.deserialize<MyState>(json).let {
            format(formatter)(it.expirationDate.toLocalDateTime()) shouldBe "2016-06-17 01:27:28"
        }
    }

    private fun format(f: DateTimeFormatter): (LocalDateTime) -> String {
        return { date -> f.format(date) }
    }

    private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

    private val json = """
{
  "expiration_date": "2016-06-17 01:27:28 Etc/GMT"
}
"""
}

まとめ

  • 2016-06-17 01:32:28 Etc/GMTのようなフォーマットをjacksonでデシアライズする方法をまとめた。
  • タイトルのナレッジの日本語エントリが少なかったのとレシート検証している人も多いだろうし備忘録として溜めておく。

コード

コードはgithubにあります。

github.com

soushin.sandbox.kt.jackson を参照してください。

KotlinでConstructor Injectionが増えてきたからDelegateをつかってリファクタリングしてみた

今回のエントリはDelegateの使い方をまとめる。
次のようなConstructor Injectionに複数のサービスクラスが並んだTaskBackendServerクラスがある。

@GRpcService
class TaskBackendServer(private val getTaskService: GetTaskService,
                        private val findTaskService: FindTaskService,
                        private val createTaskService: CreateTaskService,
                        private val updateTaskService: UpdateTaskService,
                        private val deleteTaskService: DeleteTaskService,
                        private val finishTaskService: FinishTaskService) : TaskServiceGrpc.TaskServiceImplBase() {

TaskBackendServerは複数のTaskServiceに依存していることが分かる。これくらいの数になると多くの依存が明確でり煩雑な印象を受ける。またテスト時にも依存クラスのインスタンスをつくりTaskBackendServerをつくりあげるのは骨が折れる。

そんなときには Delegate(委譲)をつかって処理を委譲させるとよい。
DelegateをつかえばAクラスにBクラスのpublicな関数を委譲させることができる。委譲されたAクラスはBクラスの関数を使えるようになる。AクラスはCクラス、Dクラスなど委譲させる関数名にコンフリクトがなければ複数のクラスを委譲することができる。

上記のコードではTaskにまつわる複数のサービスクラスが TaskBackendServerに依存しているので DelegateTaskServiceクラスをつくり委譲をしてみる。

委譲させるクラスはインターフェースを実装したクラスであること

委譲のコードの前にGetTaskServiceFindTaskServiceなどそれぞれの委譲させるクラスは次のようにインターフェースを実装したクラスであることを整理しておく。

interface CreateTaskService {
    fun createTask(command: CreateTaskCommand): Task
}

@Service("createTaskService")
class CreateTaskServiceImpl(private val taskRepository: TaskRepository) : CreateTaskService {

    @Transactional
    override fun createTask(command: CreateTaskCommand): Task {
        return taskRepository.create(command.title).fold({
            task -> task
        }, {
            error -> throw handle(error)
        })
   }
}

DelegateTaskServiceをつくる

@Service
class DelegateTaskService(private val getTaskService: GetTaskService,
                          private val findTaskService: FindTaskService,
                          private val createTaskService: CreateTaskService,
                          private val updateTaskService: UpdateTaskService,
                          private val deleteTaskService: DeleteTaskService,
                          private val finishTaskService: FinishTaskService) :
        GetTaskService by getTaskService,
        FindTaskService by findTaskService,
        CreateTaskService by createTaskService,
        UpdateTaskService by updateTaskService,
        DeleteTaskService by deleteTaskService,
        FinishTaskService by finishTaskService

委譲させるにはクラス名の宣言のあとに、 byキーワードをつかいDelegateを明示する。

TaskBackendServerのConstructor Injectionは次のようにスッキリした。

@GRpcService
class TaskBackendServer(private val delegateTaskService: DelegateTaskService) : TaskServiceGrpc.TaskServiceImplBase() {

また、delegateTaskServiceGetTaskServiceの関数を呼び出せるようになった。

val task = delegateTaskService.getTask(GetTaskCommand(taskId.toLong()))

まとめ

  • Delegate(委譲)についてまとめた。
  • 複数のインターフェースを実装する場合、委譲を使えばクラスの肥大化を抑えることができる。
  • 今回のようにConstructor Injectionが増えてきて依存度が増しと感じれば委譲を使い整理することができる。

コード

githubにコードがありますので合わせて確認できます。

github.com

protobuf typeに値が入っているか確認するにはgoogle/protobuf/wrappers.protoをつかうとよい

今回のエントリも前回に続きprotobufの数あるtypeの中から1つの使い方まとめていきたい。今回は google/protobuf/wrappers.protoをまとめていく。
protobufのtypeのなかにはプリミティブなtypeとしてstringuint32, uint64などが用意されている。プリミティブなtypeをリクエストに定義した場合、リクエストに値を定義しないとデフォルト値がセットされる。

Language Guide (proto3)  |  Protocol Buffers  |  Google Developers

gRPC Clientが明示的に値をセットしないとuint32では0値をgRPC Serverで受け取ることになる。

次のようなリクエストにつかうprotoを定義するケースを考えていく。

message TaskListInbound {
  uint32 page = 1;
}

gRPC Clientからは pageに値がセットされていなければpageの値を10にServer側でセットするとしよう。

val page = when {
    request.page == UInt32Value.getDefaultInstance().value -> DEFAULT_PAGE_LIMIT
    else -> request.page
}

UInt32Value.getDefaultInstance().value からuint32のデフォルト値をとることで判定をしている。pageのケースであればこれでも良いかもしれないが、デフォルト値の0でも良いケースではデフォルト値判定をする必要がなくなるし、Patchのようなリクエストメソッドを提供したい場合などはprotoに定義したフィールドに値がセットされたのかどうかを素直に判定したい。

そのようなケースに有効なprotobufのtypeにgoogle/protobuf/wrappers.protoが用意されている。

protobuf typeに値が入っているか確認できるwrappers.proto

wrappers.protoはプリミティブなtypeに対応している。

protoの定義は次のように書ける

message TaskListInbound {
  google.protobuf.UInt32Value page = 1;
}

値がセットされているかどうかもhasFiledName()の関数が用意されている。

val page = when {
    request.hasPage() -> request.page.value
    else -> DEFAULT_PAGE_LIMIT
}

wrappers.protoのUInt32Valueとプリミティブなuint32を使い分けることでgRPC Clientのリクエストパラメータをオプションか必須であるか明示的に定義することができる。

コード

コードは次のレポジトリにコミットしてあります。

github.com

protobuf typeに日付型のcom.google.protobuf.Timestampをつかってみた

protobufのtypeに日付型をつかいたいケースがあったので com.google.protobuf.Timestampをつかってみた。

google/protobufを覗いてみると公式に載っているtypeの他にも使えそうなものがあるので定義に迷ったときは一読をおすすめする。

github.com

ここからは順にprotoの定義からgrpc-server, grpc-clientにおけるcom.google.protobuf.Timestampの使い方をまとめていく。※コードはすべてkotlinで、grpc-javaをつかっている

protoの定義方法

protoファイルの抜粋になるが importを追加してtypeを定義するだけで使える。

import "google/protobuf/timestamp.proto";

message TaskOutbound {
  uint32 task_id = 1;
  string title = 2;
  google.protobuf.Timestamp finishedAt = 3;
  google.protobuf.Timestamp createdAt = 4;
  google.protobuf.Timestamp updatedAt = 5;
}

finishedAt, createdAt, updatedAtに google.protobuf.Timestampを定義していることが分かる。

protoの全体はこちらから確認できるので参考にしてほしい。

grpc-serverではどうするか

LocalDateTimeからgoogle.protobuf.Timestampの型へ変換しているコードは次のようになる。

private fun getOutbound(entity: Task): TaskOutbound {
    val builder = TaskOutbound.newBuilder()
            .setTaskId(entity.id!!)
            .setTitle(entity.title)
            .setCreatedAt(getTimestamp(entity.createdAt))
            .setUpdatedAt(getTimestamp(entity.updatedAt))

    if (entity.finishedAt != null)
        builder.setFinishedAt(getTimestamp(entity.finishedAt))

    return builder.build()
}

private fun getTimestamp(date: LocalDateTime): Timestamp.Builder {
    return Timestamp.newBuilder().setSeconds(date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())
}

grpc-serverはレスポンスを返すことになるが、google.protobuf.Timestampを適応したfinishedAt, createdAt, updatedAtはgoogle.protobuf.TimestampのBuilderクラスのインスタンスオブジェクトをセットする必要がある。

grpc-clinetではどうするか

clientはserverから受け取ったgoogle.protobuf.Timestampの値をyyyy-MM-dd'T'HH:mm:ss'Z'な文字列のString型でエンドポイントへ返却したい。コードは次のようになる。

data class TaskModel(val id: Long, val title: String, val finishedAt: String?, val createdAt: String, val updatedAt: String) {
    constructor(entity: TaskOutbound) : this(
            id = entity.taskId.toLong(),
            title = entity.title,
            finishedAt = entity.finishedAt.let {
                if (it != null)
                    Instant.ofEpochMilli(it.seconds).atZone(ZoneId.systemDefault()).toLocalDateTime().convert(DateUtil.Format.FULL_UTC)
                else null
            },
            createdAt = Instant.ofEpochMilli(entity.createdAt.seconds).atZone(ZoneId.systemDefault()).toLocalDateTime().convert(DateUtil.Format.FULL_UTC),
            updatedAt = Instant.ofEpochMilli(entity.updatedAt.seconds).atZone(ZoneId.systemDefault()).toLocalDateTime().convert(DateUtil.Format.FULL_UTC)
    )
}

google.protobuf.TimestampにはgetSeconds()のメソッドが用意されていてUNIXタイムが取得できる。この値からエンドポイントに必要な型へ変換することができる。

まとめ

  • google.protobuf.Timestampのつかいかたをまとめた
  • Timestamp以外にも実案件につかえるtypeは他にもあるので機会があればまとめていきたい

コード

コードは次のレポジトリにコミットしてあるので良かったら確認してみてください。

github.com

gRPC ServerのExceptionFilterの方法をまとめた(grpc-java)

前回のエントリでは認証処理やメトリクス計測、ログ出力などをインターセプターをつかい横断的に処理する方法をまとめた。

naruto-io.hatenablog.com

今回のエントリではgRPC Serverのトランザクション内で発生した例外処理を横断的にキャッチしてレスポンスを返す方法をまとめていきたい。

try-catchして例外クラスに応じた処理を施す

愚直に書けば次のようになるだろう。

override fun getTaskService(request: TaskInbound?, responseObserver: StreamObserver<TaskOutbound>?) {
    try {
        val taskId = GRpcInboundValidator.validTaskInbound(request)

        val log = GRpcLogContextHandler.getLog()
        log.elem { "taskId" to taskId }

        val task = getTaskService(GetTaskCommand(taskId.toLong()))
        val msg = getOutbound(task)
        responseObserver?.onNext(msg)
        responseObserver?.onCompleted()
    } catch (e: WebAppException.NotFoundException) {
        logger.error { "gRPC server error, task not found." }
        responseObserver?.onError(
                Status.NOT_FOUND.withDescription("task not found.").asRuntimeException())
    } catch (e: WebAppException.BadRequestException) {
        logger.error { "gRPC server error, invalid request." }
        responseObserver?.onError(
                Status.INVALID_ARGUMENT.withDescription("invalid request.").asRuntimeException())
    }
}

タスクを1件取得するgRPC Serverのコード例である。
タスクがなかった場合にはNOT FOUNDのレスポンス、リクエストに不備があればINVALID_ARGUMENTのレスポンスを返していることがわかる。
レスポンスを返す直前にはExceptionをキャッチしている。そしてExceptionごとにエラーレスポンスを並べている形だ。

このような書き方を複数のgRPC Serverで続けるとcatch節が冗長になってしまう。
gRPC ServerのインターセプターをつかいExceptionのキャッチを共通処理としていきたい。そうすれば上記のコードからtry-catchが無くなりコードの見通しが良くなる。

インターセプターをつくる

gRPC Serverのトランザクションで発生した例外をキャッチするには次のようなインターセプターを用意する。

@Component
class ExceptionFilter : ServerInterceptor {

    private val logger = KotlinLogging.logger {}

    override fun <ReqT : Any?, RespT : Any?> interceptCall(call: ServerCall<ReqT, RespT>?, headers: Metadata?,
                                                           next: ServerCallHandler<ReqT, RespT>?): ServerCall.Listener<ReqT> {
        return object : ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(next?.startCall(call, headers)!!) {

            override fun onHalfClose() {
                try {
                    super.onHalfClose()
                } catch (ex: RuntimeException) {
                    handleException(call, headers, ex)
                    throw ex
                }
            }

            override fun onReady() {
                try {
                    super.onReady()
                } catch (ex: RuntimeException) {
                    handleException(call, headers, ex)
                    throw ex
                }

            }
        }
    }
  • onHalfCloseonReadyをoverrideしたリスナーを用意する。onHalfCloseonReadyはクライアントからリクエストが送信された後、つまりServer内の処理のリスナーである。
  • この2つリスナー関数をtry-catchで囲むことでgRPC Server内の例外エラーを横断的にキャッチすることができる。
private fun <ReqT, RespT> handleException(call: ServerCall<ReqT, RespT>?, headers: Metadata?, ex: Exception) {

    logger.error(ex) { ex.message }

    when (ex) {
        is RepositoryException.NotFoundException -> call?.close(
                Status.fromCode(Status.NOT_FOUND.code).withDescription(ex.message), headers)
        is RepositoryException.ConflictException -> call?.close(
                Status.fromCode(Status.ALREADY_EXISTS.code).withDescription(ex.message), headers)
        is WebAppException.BadRequestException -> call?.close(
                Status.fromCode(Status.INVALID_ARGUMENT.code).withDescription(ex.message), headers)
        is WebAppException.NotFoundException -> call?.close(
                Status.fromCode(Status.NOT_FOUND.code).withDescription(ex.message), headers)
        is EmptyResultDataAccessException -> call?.close(
                Status.fromCode(Status.NOT_FOUND.code).withDescription("data not found."), headers)
        else -> call?.close(Status.fromCode(Status.INTERNAL.code).withDescription(ex.message), headers)
    }
}
  • handleException関数では例外クラスに応じたれステータスコードを定義してcall?.closeを呼び出している。
  • これでExceptionFilterが例外エラーをすべてキャッチしてレスポンスを返すことができる。

gRPC Serverのテスト時にExceptionFilterをつかう

gRPC ServerでExceptionFilterクラスをテストしたい。その場合にはテスト時にInProcessServerのビルダーにinterceptを加えるとよい。

inProcessServer = InProcessServerBuilder
        .forName(UNIQUE_SERVER_NAME)
        .addService(target)
        .intercept(ExceptionFilter())  ← こちら
        .directExecutor()
        .build()

まとめ

  • 今回の例外エラーのキャッチ方法は情報が少なくgrpc-javaのissueのコメントで少し言及されていたところからヒントを得た。
  • 振り返るとServerCallListenerのドキュメントを読んだうえで、それぞれのリスナーの役割を理解できれば実装できるものだった。
  • gRPCの実用化に向けてインターセプターのネタも揃ってきた。

コード

エントリで紹介したコードは一部分のためコード全体はgithubを参照してください。

github.com

インターセプターのコードはこちらです。