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

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

grpc-javaのClient/ServerのテストをKotlinで書く - Client編

前回のエントリに続いて今回のエントリではgRPC Clientのテストの書き方をまとめていく。

naruto-io.hatenablog.com

テスト対象のproto

テスト対象のprotoは次のとおりSimple-RPCとする。

service TaskService {
  rpc GetTaskService (TaskInbound) returns (TaskOutbound) {
    option (google.api.http) = {
      get: "/v1/task"
    };
  }
}

message TaskInbound {
  uint32 task_id = 1;
}

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

finishedAtなどは本来であればgoogle/protobuf/timestamp.protoを使いたいところが今回はstringで定義している。

テストするgRPC Cleintとテスト内容

テスト対象のClientのコードは次のとおりである。

suspend fun getTask(taskId: Long): TaskOutbound =
        async(CommonPool) {
            try {
                val outbound = ShutdownLoan.using(getChannel(), { channel ->
                    val msg = TaskInbound.newBuilder().setTaskId(taskId.toInt()).build()
                    TaskServiceGrpc.newBlockingStub(channel).getTaskService(msg)
                })
                Result.Success<TaskOutbound, GrpcException>(outbound)
            } catch (e: Exception) {
                val status = Status.fromThrowable(e)
                logger.error(e) { "gRPC server error, code:{%d}, description:{%s}".format(status.code.value(), status.description) }
                Result.Failure<TaskOutbound, GrpcException>(status with status.description)
            }
        }.await().fold({ it }, { throw it })

private fun getChannel() = NettyChannelBuilder.forAddress(appProperties.grpc.backend.host, appProperties.grpc.backend.port)
        // for testing
        .usePlaintext(true)
        .build()

コルーチンをつかっているが、通常のgRPC Serverへリクエストするクライアントコードである。

  • val msg = TaskInbound.newBuilder().setTaskId(taskId.toInt()).build()でリクエスト変数を定義している。
  • TaskServiceGrpc.newBlockingStub(channel).getTaskService(msg)でgRPC serverへリクエストをしている。
  • gRPC Serverから受け取ったレスポンスを Result型に格納し返却する。
  • getChannel()はgRPCのチャネルを返すメソッドである。

テスト内容

テストする内容を次のようにまとめる。

  • getTask(taskId: Long)を呼び出すとタスクが1件取得できるか。
  • TaskServiceGrpc.newBlockingStub(channel).getTaskService(msg)ではgRPC Serverへリクエストが渡るがリクエスト結果がエラーだった場合に例外エラー(GrpcException)を受け取ることができるか。

テストコード

次にテストコードである。

前回のエントリでまとめたとおりテストコードのなかでServerを起動させる。Clientのテストではテスト内容に応じて 成功を返すgRPC Server Serviceエラーを返すgRPC Server Serviceを用意する。そして起動しているServerにテスト対象のgRPC Sereverをアサインする。コードとしては次のようになる。

@Before
fun setup() {

    serviceRegistry = MutableHandlerRegistry()
    inProcessServer = InProcessServerBuilder
            .forName(UNIQUE_SERVER_NAME).fallbackHandlerRegistry(serviceRegistry).directExecutor().build()
    inProcessChannel = InProcessChannelBuilder.forName(UNIQUE_SERVER_NAME).directExecutor().build()

    val appProperties = AppProperties()
    target = TaskBackendClient(appProperties)

    inProcessServer.start()
}

@After
fun shutdown() {
    inProcessChannel.shutdownNow()
    inProcessServer.shutdownNow()
}
  • @BeforeAfterはServerのテストのエントリでまとめたようにサーバーの起動と停止を行っている。

次のコードが重要である。

private class GetTaskServerOk: TaskServiceGrpc.TaskServiceImplBase() {

    override fun getTaskService(request: TaskInbound?, responseObserver: StreamObserver<TaskOutbound>?) {
        responseObserver?.onNext(TaskOutbound.newBuilder()
                .setTaskId(1)
                .setTitle("mocked Task")
                .setFinishedAt("2017-01-01T23:59:59Z")
                .setCreatedAt("2017-01-02T23:59:59Z")
                .setUpdatedAt("2017-01-02T23:59:59Z")
                .build()
        )
        responseObserver?.onCompleted()
    }
}

このGetTaskServerOkはテストするgRPC Serverが正常なレスポンスを返すServiceクラスである。
このServiceクラスをテストで起動したgRPC Serverにアサインすることでレスポンスをモックできる。

正常系のテスト

次にメインとなるテストコードをまとめる。こちらは正常系のテストである

@Test
fun getTask() {
    serviceRegistry.addService(GetTaskServerOk())

    // mock
    val instance = PowerMockito.spy(target)
    PowerMockito.doReturn(inProcessChannel).`when`(instance, "getChannel")

    runBlocking {

        // assertion
        val actual = instance.getTask(1L)

        actual.taskId shouldBe 1
        actual.title shouldBe "mocked Task"
        actual.finishedAt shouldBe "2017-01-01T23:59:59Z"
        actual.createdAt shouldBe "2017-01-02T23:59:59Z"
        actual.updatedAt shouldBe "2017-01-02T23:59:59Z"
    }
}
  • serviceRegistry.addService(GetTaskServerOk())でモック化したServiceクラスをgRPC Serverに追加している。
  • PowerMockito.doReturn(inProcessChannel).when(instance, "getChannel")では、ClientコードにあったgetChannel()をモック化している。getChannel()はgRPCのチャネルを返す関数であったが、テストコード内でモック化することでテストでビルドアップしたチャネルを適応している。

異常系のテスト

次に異常系のテストである。gRPC ServerからNot Foundのエラーを返すことを期待したテストコードである。

private class GetTaskServerNotFound : TaskServiceGrpc.TaskServiceImplBase() {

    override fun getTaskService(request: TaskInbound?, responseObserver: StreamObserver<TaskOutbound>?) {
        responseObserver?.onError(Status.NOT_FOUND.withDescription("task not found.").asRuntimeException())
        responseObserver?.onCompleted()
    }
}

@Test(expected = GrpcException::class)
fun getTask_then_NotFound() {
    serviceRegistry.addService(GetTaskServerNotFound())

    // mock
    val instance = PowerMockito.spy(target)
    PowerMockito.doReturn(inProcessChannel).`when`(instance, "getChannel")

    try {
        runBlocking {
            instance.getTask(1L)
        }
    } catch (e: GrpcException) {
        e.message shouldBe "task not found."
        e.status shouldBe HttpStatus.NOT_FOUND
        throw e
    }
}
  • エラーを返すGetTaskServerNotFoundクラスを定義してgRPC Serverにアサインしている。
  • テストコードではエラーが発生しているか、エラーメッセージ、コードが期待どおりかテストをしている。

まとめ

  • Server編とClient編に分けてgRPC ServerとClientのテストコードをまとめた。
  • 今回はSimple-RPCをまとめたが次の機会にはServerSideStreaming-RPCClientSideStreaming-RPCBidirectionalStreaming-RPCのStreamingのテストをまとめていきたい。

コード

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

github.com

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

grpc-javaのClient/ServerのテストをKotlinで書く - Server編

grpc-javaで実装されたgRPC ClientとgRPC Serverのテストコードについてまとめていきたい。

ClientとServerのどちらも大枠は同じである。テストコードのなかでgRPC Serverを起動させる。そしてリクエスト内のトランザクションを必要に応じてモック化しながら期待値が取得できているか、期待される関数が呼び出せれているかを検証する。

今回のエントリではServer側のテストをJUnitとKotlinを用いてまとめていく。

テスト対象のproto

テスト対象のprotoは次のとおりSimple-RPCとする。

service TaskService {
  rpc GetTaskService (TaskInbound) returns (TaskOutbound) {
    option (google.api.http) = {
      get: "/v1/task"
    };
  }
}

message TaskInbound {
  uint32 task_id = 1;
}

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

テストするgRPC Serverとテスト内容

テスト対象のServerのコードは次のとおりである

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())
    }
}
  • taskId: IntをパラメータにとるTaskBackendServerで TaskBackendServer#getTaskServiceはタスクを1件返す。
  • GRpcLogContextHandlerはリクエストパラメータをログ出力するためにio.grpc.Contextにログ情報を詰めこむ。
  • TaskBackendServer#getTaskServiceの中でエラーが発生した場合は、エラーに応じたgRPC Serverレスポンスを返す。

テスト内容

テストする内容を次のようにまとめる。

  • TaskBackendServer#getTaskServiceを呼び出すとタスクが1件取得できるか。
  • GRpcLogContextHandler.getLog()は正常に呼び出されているか。
  • TaskBackendServer#getTaskServiceの中で発生したエラーに応じたgRPC Serverレスポンスが返ってくるか。

テストコード

次にテストコードである。

先述したとおりテストコードのなかでServerを起動させる。そして起動しているServerにテスト対象のgRPC Sereverをアサインする。コードとしては次のようになる。

@Before
fun setUp() {
    getTaskService = mock(GetTaskServiceImpl::class)
    // 一部省略

    target = TaskBackendServer(getTaskService, getTaskListService, createTaskService, updateTaskService,
            deleteTaskService, finishTaskService)
    inProcessServer = InProcessServerBuilder
            .forName(UNIQUE_SERVER_NAME).addService(target).directExecutor().build()
    inProcessChannel = InProcessChannelBuilder.forName(UNIQUE_SERVER_NAME).directExecutor().build()

    inProcessServer.start()
}

@After
fun tearDown() {
    inProcessChannel.shutdownNow()
    inProcessServer.shutdownNow()
}
  • @BeforeInProcessServerBuilderを使いServerをビルドアップしている。ビルドしたServerに TaskBackendServeraddService関数を使いアサインする。(addService(target)
  • InProcessServerBuilderで起動したServerは UNIQUE_SERVER_NAMEという名称をつけている。このServerNameをInProcessChannelBuilderでビルドアップするChannelに関連付ける。
  • ビルドアップしたChannel(inProcessChannel)をテストコードでブロッキングすることでgRPC Serverのレスポンスを受け取ることができる。

正常系のテスト

次のコードは正常系をテストしたコードである。

@Test
fun getProducts_onCompleted() {

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

    val command = GetTaskCommand(taskId)
    val now = LocalDateTime.now()
    val task = Task(taskId.toInt(), "mocked Task", now, now, now)

    val log = GRpcLogBuilder()

    // mock
    mockStatic(GRpcLogContextHandler::class)
    Mockito.`when`(GRpcLogContextHandler.getLog()).thenReturn(log)
    Mockito.`when`(getTaskService(command)).thenReturn(task)

    // request server
    val blockingStub = TaskServiceGrpc.newBlockingStub(inProcessChannel)
    val actual = blockingStub.getTaskService(request) // ブロッキングしてgRPC Serverのレスポンスを受け取る

    // assertion
    actual.taskId shouldBe 1
    actual.title shouldBe "mocked Task"
}
  • このテストコードでは期待したタスクがTaskBackendServerから返ってきているか、GRpcLogContextHandler.getLog()が呼び出せれているかを検証している
  • TaskBackendServer#getTaskService内では GetTaskServiceImpl#invokeを呼び出しタスクを1件取得している。この処理をモック化することでテストコード内のgRPC Serverの挙動をコントロールしている。

異常系のテスト

次のコードは異常系をテストしたコードである。

@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`(getTaskService(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: task not found."
    }
}

@Test
fun getProducts_INVALID_ARGUMENT() {

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

    try {
        // request server
        val blockingStub = TaskServiceGrpc.newBlockingStub(inProcessChannel)
        blockingStub.getTaskService(request)
    } catch (e: StatusRuntimeException) {
        // assertion
        e.status.code shouldBe Status.INVALID_ARGUMENT.code
        e.message shouldBe "INVALID_ARGUMENT: invalid request."
    }
}
  • タスクが存在しない(NotFound)、リクエストパラメータが不整合(INVALID_ARGUMENT)のテストコードである。
  • responseObserveronErrorにエラーがセットされるとStatusRuntimeExceptionが発生する。テストコードでそれをキャッチしエラーコードとエラーメッセージを検証している。

まとめ

  • gRPC Serverのテストコードをまとめた。
  • テストコード内でgRPC Serverを起動させる方法とテストコードでChannel をブロッキングしgRPC Serverのレスポンスを受け取る方法を紹介した。
  • 必要に応じてモック化することでgRPC Serverのテストカバレッジを向上させることができる。
  • 次回のエントリではgRPC Clientのテスト方法をまとめていく。

コード

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

github.com

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