gRPC-WebをKotlinバックエンドで試した時のメモ - 2. Service編

# About

このテーマの連載、Service編です。

https://qiita.com/hosopy/items/d7afd9cf85bd6e155815

# はじめに

まず、自分はSpring Boot未経験な状態で露頭に迷いそうな状態でしたが、下記ブログ記事の内容が非常に参考になりました。筆者様に感謝です。

* [サーバーサイドKotlin×gRPCコトハジメ 〜①プロジェクト作成から起動確認まで〜](https://blog.takehata-engineer.com/entry/server-side-kotlin-grpc-project-creation-to-start-confirmation)

なお、筆者のスペックはこんな感じなので、Java,Kotlin,Gradleについては触れずにドライにスキップしています。

* Java : 業務での開発経験あり
* Kotlin : 趣味でライブラリ開発経験あり
* Gradle : Androidアプリの開発で経験あり (詳しくはない)
* Spring Boot : 未経験

# サービス構成

おさらいになりますが、バックエンドのサービスはフロントのサービスである`GreeterService`と、内部サービスである`TextAnalyzerService`の2つから構成されています。

<img width="733" alt="sequence.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1489/cf782216-afa3-1300-fe16-a3b4b54fe731.png">

実装上はどちらも変わりないので、GreeterServiceを見ていきます。

# GreeterService

GreeterServiceの実装は、サンプルトの[service](https://github.com/hosopy/grpc-web-kotlin-sample/tree/master/service)ディレクトリに入っています。

以下、どんな流れで実装していったかのメモです。

## プロジェクトの生成

例えばRailsであれば`rails g`でプロジェクトを生成したりしますが、Spring Bootではどうするのか。

一般的にどうするのが主流なのか分かりませんが、[Spring Initializer](https://start.spring.io/)というWebサービスを使って、下記の条件でプロジェクトを生成しました。

* Project: Gradle Project
* Language: Kotlin
* Spring Boot: 2.0.0 (SNAPSHOT)
* Dependencies: Spring Web

## build.gradle

[Spring Initializer](https://start.spring.io/)が生成するプロジェクトでは、デフォルトでは`build.gradle.kts` (Kotlin Script形式)で`build.gradle`が生成されます。

ここは、[参考サイト様](https://qiita.com/hosopy/items/d2c87b6489991091ddab)の情報に従って、Groovy形式の`build.gradle`に書き換えました。
(Kotlin形式での設定に調整しましたが、同様にハマってしまったので...)

内容はリポジトリの[build.gradle](https://github.com/hosopy/grpc-web-kotlin-sample/blob/master/service/build.gradle)を参照して下さい。

## GreeterServiceの定義

公開するGreeterServiceの定義(`.proto`)を作成します。

```proto:service/src/main/proto/Greeter.proto
syntax = "proto3";
option java_package = "com.hosopy.kotlingrpcsample.greeter.proto";
option java_outer_classname = "GreeterProtobuf";

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
    string name = 1;
}

message HelloReply {
    string message = 1;
    int32 nameLength = 2;
}
```

この状態で、`generateProto`タスクが成功すればOKです。

```
./gradlew generateProto
```

## GreeterServiceの実装

`generateProto`タスクで生成されたスタブクラスを元にして、GreeterServiceの実装を行います。

なお、依存する内部サービスのClientである`TextAnalyzerClient`の呼び出し実装に含まれていますが、混乱するので後述します。

[grpc-spring-boot-starter](https://github.com/LogNet/grpc-spring-boot-starter)のドキュメントを読めば、すんなりと実装できると思います。

```kotlin:service/src/main/kotlin/com/hosopy/kotlingrpcsample/grpc/service/GreeterGRpcService.kt
package com.hosopy.kotlingrpcsample.grpc.service

/*
 * GreeterServiceの関連クラス.
 * generateProtoタスクで自動生成されたもの.
 */
import com.hosopy.kotlingrpcsample.greeter.proto.GreeterGrpc
import com.hosopy.kotlingrpcsample.greeter.proto.GreeterProtobuf
/*
 * TextAnalyzerService(依存する別サービス)を呼び出すためのClient(後述).
 */
import com.hosopy.kotlingrpcsample.grpc.client.TextAnalyzerClient

import io.grpc.stub.StreamObserver
import org.lognet.springboot.grpc.GRpcService

@GRpcService
class GreeterGRpcService(
    // Spring Bootの仕組みでTextAnalyzerClientをDI
    private val textAnalyzerClient: TextAnalyzerClient
): GreeterGrpc.GreeterImplBase() {
    /**
     * rpc SayHelloの実装.
     */
    override fun sayHello(
        request: GreeterProtobuf.HelloRequest,
        responseObserver: StreamObserver<GreeterProtobuf.HelloReply>
    ) {
        val replyBuilder = GreeterProtobuf.HelloReply.newBuilder()
        replyBuilder.message = "Hello " + request.name
        // TextAnalyzerServiceの呼び出し結果をnameLengthに設定
        replyBuilder.nameLength = textAnalyzerClient.analyzeText(request.name).length
        responseObserver.onNext(replyBuilder.build())
        responseObserver.onCompleted()
    }
}
```

## TextAnalyzerServiceの呼び出し

`GreeterService`が依存する別サービスで`TextAnalyzerService`を呼び出すためのClientを、`TextAnalyzerClient`として実装しました。

実装にあたり、別サービスである`TextAnalyzerService`のIDL(`.proto`)から、関連するスタブクラスを生成する必要があります。

`TextAnalyzerService`の実装は、サンプルの[internal-service](https://github.com/hosopy/grpc-web-kotlin-sample/tree/master/internal-service)ディレクトリに入っているので、次のようなコマンドで生成しました。

```sh
$ protoc -I=internal-service/src/main/proto \
         --java_out=out \
         src/main/proto/TextAnalyzer.proto
```

自動生成されたスタブクラスを利用して、[gRPCのJava公式チュートリアル](https://grpc.io/docs/tutorials/basic/java/)を参考に次のようなClient実装になりました。

```kotlin:service/src/main/kotlin/com/hosopy/kotlingrpcsample/grpc/client/TextAnalyzerClient.kt
package com.hosopy.kotlingrpcsample.grpc.client

/*
 * 依存する内部サービス(TextAnalyzerService)の関連クラス.
 * protocで自動生成されたもの.
 */
import com.hosopy.kotlingrpcsample.textanalyzer.proto.TextAnalyzerGrpc
import com.hosopy.kotlingrpcsample.textanalyzer.proto.TextAnalyzerProtobuf

import io.grpc.ManagedChannelBuilder
import org.springframework.stereotype.Component

@Component
class TextAnalyzerClient(
    // Clientの設定情報(ホスト/ポート)をDI
    private val configuration: TextAnalyzerClientConfiguration
) {

    fun analyzeText(text: String): TextAnalysisResult {
        // NOTE: usePlaintext only for development/test
        val channel =
            ManagedChannelBuilder.forAddress(
                configuration.host, configuration.portInt
            ).usePlaintext(true).build()
        val blockingStub = TextAnalyzerGrpc.newBlockingStub(channel)
        val requestBuilder = TextAnalyzerProtobuf.TextAnalysisRequest.newBuilder()
        requestBuilder.text = text
        val response = blockingStub.analizeText(requestBuilder.build())
        return TextAnalysisResult(response.length)
    }
}

data class TextAnalysisResult(val length: Int)
```

なお、接続先のホスト/ポートの情報は`TextAnalyzerClientConfiguration`として別クラスで実装し、Spring Bootの仕組み？で注入されるようになっています。

このあたりの仕組みはSpring Boot初心者の自分にとってブラックボックスすぎるので、そのうち勉強したいところです。

```kotlin
package com.hosopy.kotlingrpcsample.grpc.client

import org.springframework.boot.context.properties.ConfigurationProperties

// src/main/resources/application.propertiesの内容が読み込まれる.
@ConfigurationProperties(prefix="internal-service.text-analyzer")
class TextAnalyzerClientConfiguration {
    lateinit var host: String
    // NOTE: primitiveが使えないので仕方なくStringで読む
    lateinit var port: String

    val portInt: Int
      get() = Integer.parseInt(port)
}
```

```ini:src/main/resources/application.properties
internal-service.text-analyzer.host=internal-service
internal-service.text-analyzer.port=6565
```

hostが`internal-service`なのは、[docker-compose.yaml](https://github.com/hosopy/grpc-web-kotlin-sample/blob/master/docker-compose.yaml)で定義しているサービス名に起因します。

## 補足: Logging

最低限のログ出力を行うために、サービスグローバルなLogInterceptorを実装しました ([参考](https://github.com/LogNet/grpc-spring-boot-starter#42-interceptors-support))

```kotlin:service/src/main/kotlin/com/hosopy/kotlingrpcsample/grpc/service/interceptor/LogInterceptor.kt
package com.hosopy.kotlingrpcsample.grpc.service.interceptor

import io.grpc.Metadata
import io.grpc.ServerCall
import io.grpc.ServerCallHandler
import io.grpc.ServerInterceptor
import org.lognet.springboot.grpc.GRpcGlobalInterceptor
import org.slf4j.LoggerFactory

@GRpcGlobalInterceptor
class LogInterceptor: ServerInterceptor {
    companion object {
        private val logger = LoggerFactory.getLogger(LogInterceptor::class.java)
    }

    override fun <ReqT : Any, RespT : Any> interceptCall(
        call: ServerCall<ReqT, RespT>,
        headers: Metadata,
        next: ServerCallHandler<ReqT, RespT>
    ): ServerCall.Listener<ReqT> {
        logger.info(call.methodDescriptor.fullMethodName)
        return next.startCall(call, headers)
    }
}
```

# Dockerfile

最後に、`GreeterService`をコンテナ化するための`Dockerfile`を準備しました。

```
# TODO: https://github.com/google/protobuf-gradle-plugin/issues/265
# FROM openjdk:8-jdk-alpine
FROM openjdk:8

EXPOSE 6565

VOLUME /tmp
RUN mkdir /work
COPY . /work
WORKDIR /work
RUN /work/gradlew build
RUN mv /work/build/libs/*.jar /work/app.jar

ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/work/app.jar"]
```

1点ハマったのは、最初は`openjdk:8-jdk-alpine`のイメージをベースにしていたのですが、イメージのビルド中に`generateProto`タスクがエラーになってしまいました。

```
Execution failed for task ':generateProto'.
> java.io.IOException: Cannot run program "/root/.gradle/caches/modules-2/files-2.1/com.google.protobuf/protoc/3.5.1-1/647d360fdbf38e4f92f8cb4854e3d92b62599766/protoc-3.5.1-1-linux-x86_64.exe": error=2, No such file or directory
```

[protobuf-gradle-plugin](https://github.com/google/protobuf-gradle-plugin)のIssueにもあったのですが、[protobuf-gradle-plugin](https://github.com/google/protobuf-gradle-plugin)のMeavenリポジトリに同包されている`protoc`のバイナリに、alpineに対応したバイナリが含まれていないためのようです。

https://github.com/google/protobuf-gradle-plugin/issues/265

[protobufを個別にインストールしてpathを指定することで解決出来るという情報](https://github.com/google/protobuf-gradle-plugin/issues/265#issuecomment-421508779)もありましたが、ビルド設定としてのポータビリティに欠けるような気がして、今回は大人しく`openjdk:8`のイメージをベースにしました。

# TextAnalyzerService

サンプルの[internal-service](https://github.com/hosopy/grpc-web-kotlin-sample/tree/master/internal-service)ディレクトリ配下に実装がありますが、`GreeterService`の簡易版でほぼ同じ構成なので説明は省略します。

# 次回

次回はReverseProxy編です。