タケハタのブログ

プログラマの生き方、働き方、技術について雑多に書いていくブログです。

サーバーサイドKotlin×gRPCコトハジメ 〜⑤プロジェクト作成から基本的なAPI実装までまとめ〜

Kotlin Advent Calendar 2019 2日目の記事です。

過去4回に渡って「サーバー再度Kotlin×gRPCコトハジメ」というタイトルでKotlinを使ったgRPCのサーバー実装方法について紹介してきたのですが、ここまでバラバラと書いてきたものをまとめ、Githubに公開しました。
(時間がなくてREADMEとか全然書けてないのはご容赦ください・・・)

github.com

そちらを使いつつ、今回はその実装について通しで説明していきます。

過去の記事たちはこちら↓

サーバーサイドKotlin×gRPCコトハジメ 〜①プロジェクト作成から起動確認まで〜
サーバーサイドKotlin×gRPCコトハジメ 〜②Protocol Buffersの色々なオプション〜
サーバーサイドKotlin×gRPCコトハジメ 〜③インターセプターとメタデータ(前編)〜
サーバーサイドKotlin×gRPCコトハジメ 〜④インターセプターとメタデータ(後編)〜

この記事の各所にもリンクを貼りますが、詳細な部分はこちらも参考にしていただければと思います。

gRPCとは?

Kotlinの説明は不要かと思うので、一応gRPCの説明だけ。
gRPCはGoogle製のRPCフレームワークで、通信プロトコルとしてHTTP/2、IDLとしてProtocol Buffersを使用し、ハイパフォーマンスな通信を実現しています。

grpc.io

まだまだ国内での事例は少ないですが、GraphQLとともにRESTの次の通信の技術として注目されています。
詳しくは↓こちらの資料なども見ていただければと思います。

https://speakerdeck.com/n_takehata/kuraiantotong-xin-haipahuomansunatong-xin-ji-pan-falsekai-fa-tomagiconionniyoruriarutaimutong-xin-falseshi-xian

実行方法

先にサンプルの実行方法を簡単に説明します。
下記のリポジトリをcloneしてください。

https://github.com/n-takehata/kotlin-grpc-example

Intellij IDEAを使用する場合は、プロジェクトをインポートしてビルドが終わった後、Gradleビューから Tasks -> application -> bootrun を実行すれば起動できます。

ターミナルから実行する場合は、プロジェクトのルートディレクトリで下記のコマンドを実行して、Gradleタスクを実行してください。

./gradlew bootRun

いずれも起動が成功すると、下記ようなログが出力されます。

INFO 59249 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
INFO 59249 --- [           main] c.e.g.k.k.KotlinGrpcExampleApplicationKt : Started KotlinGrpcExampleApplicationKt in 2.22 seconds (JVM running for 2.538)
INFO 59249 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : Starting gRPC Server ...
INFO 59249 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : 'com.example.grpc.kotlin.kotlingrpcexample.grpcservice.ExampleGrpcService' service has been registered.
INFO 59249 --- [           main] o.l.springboot.grpc.GRpcServerRunner     : gRPC Server started, listening on port 6565.

Tomcatが8080ポートで立ち上がり、gRPCサーバーが6565ポートでlisteningしている旨が表示されていますね。

そしてcurlコマンドでリクエストパラメータを渡し実行すると、下記のようにレスポンスが返ってきます。

$ curl -H 'Content-Type:application/json' -X POST -d '{"name":"sarina"}' http://localhost:8080/createmessage
Hello sarina

アプリケーションのログを見ると、インターセプターなどの処理順にそって下記の内容が出力されます。

INFO 59021 --- [ault-executor-1] c.e.g.k.k.i.LoggingInterceptor           : method name=Example/CreateMessage
INFO 59021 --- [ault-executor-1] c.e.g.k.k.i.MessageInterceptor           : call createMessage
INFO 59021 --- [ault-executor-1] c.e.g.k.k.i.MetadataInterceptor          : header=Example Header
INFO 59021 --- [ault-executor-1] c.e.g.k.k.g.ExampleGrpcService           : execute createMessage
INFO 59021 --- [ault-executor-1] c.e.g.k.k.i.MetadataInterceptor          : execute close
INFO 59021 --- [nio-8080-exec-3] c.e.g.k.k.c.i.MetadataClientInterceptor  : trailer=Example Trailer

それでは、ここから実行しているサンプルについての内容を説明していきます。
(この実行結果の意味は全て読み終わると理解できると思います)

プロジェクトの構成

簡単に書くと、下記のような構成、実行のフローになっています。

f:id:take7010:20191201214924p:plain

  • ClientController → gRPCで通信するクライアント(REST API)
  • GrpcService → gRPCの通信のインターフェースとなるメソッドが定義されたクラス
  • Service → SpringでDIするビジネスロジック層のクラス
  • ServerInterceptor → gRPC通信時のサーバー側のインターセプター
  • ClientInterceptor → gRPC通信時のクライアント側のインターセプター

Springアプリケーションの中で通常のHTTP通信(8080ポート)とgRPC通信(6565ポート)の接続が両方できるようになっていて、「REST APIへのHTTP通信→gRPCのServiceを実行」という流れで動作確認できるようになっています。
(マイクロサービスアーキテクチャでよくあるやつです)

今回はサンプルなので同じアプリケーションに立てていますが、もちろんREST APIとgRPCのServiceで別アプリケーション、別サーバーで立てることができます。
それでは、それぞれのコードについても説明していきます。

grpc-spring-boot-starterの使用

build.gradleは下記になります。

build.gradle

plugins {
    id("org.springframework.boot") version "2.1.6.RELEASE"
    id("org.jetbrains.kotlin.jvm") version "1.3.21"
    id("org.jetbrains.kotlin.plugin.spring") version "1.3.21"
    id("io.spring.dependency-management") version "1.0.7.RELEASE"
    id("com.google.protobuf") version "0.8.9" // Protocol Buffersを扱うためのプラグイン
    id( "java")
}

sourceSets {
    main {
        proto {
            srcDir 'src/main/protobuf'
        }

        java {
            srcDirs 'src/main/generated'
        }
    }
}

group = "com.example.grpc.kotlin"
version = "0.0.1-SNAPSHOT"
sourceCompatibility = JavaVersion.VERSION_1_8

def grpcVersion = "1.10.0"

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    compile("io.github.lognet:grpc-spring-boot-starter:3.3.0") // gRPCをSpring Bootで扱うためのStarter
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

compileKotlin {
    kotlinOptions {
        freeCompilerArgs = ['-Xjsr305=strict']
        jvmTarget = '1.8'
    }
}

protobuf { // Protocol Buffersのコードジェネレータに関する設定
    protoc {
        artifact = "com.google.protobuf:protoc:3.5.1-1"
    }

    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
        }
    }

    generateProtoTasks {
        all().each { task ->
            task.builtins {
                // Protocol Buffers関連のファイル出力先
                java {
                    outputSubDir = "generated"
                }
            }
            task.plugins {
                // gRPC関連のファイル出力先
                grpc {
                    outputSubDir = "generated"
                }
            }
        }
    }

    generatedFilesBaseDir = "$projectDir/src/"
}

clean {
    delete "$protobuf.generatedFilesBaseDir/main/generated"
}

ポイントは grpc-spring-boot-starter の依存関係の追加です。
こちらを使うことで、Spring BootでのgRPCサーバーの実装が簡単にできるようになります。

Protocol Buffersの定義

まずはProtocol Buffersの定義。
基本は第1回の記事で紹介しています。
また、各種オプションについては第2回で紹介しています。

message

リクエスト、レスポンスで使うオブジェクトの定義です。
下記のように設定します。

Message.proto

syntax = "proto3";
option java_package = "com.example.grpc.kotlingrpcexample.proto";
option java_outer_classname = "MessageProtobuf";
option java_multiple_files = true;

message CreateMessageRequest {
    string name = 1;
}

message CreateMessageResponse {
    Message message = 1;
    MessageType type = 2;
}

message Message {
    string text = 1;
    int32 length = 2;
}

enum MessageType {
    NONE = 0;
    NORMAL = 1;
    SPECIAL = 2;
}

service

こちらは通信のインターフェースとなるメソッドの定義をします。
messageで定義したオブジェクトを引数、戻り値として使います。

Example.proto

syntax = "proto3";
option java_package = "com.example.grpc.kotlingrpcexample.proto";
option java_outer_classname = "GreeterProtobuf";
option java_multiple_files = true;

import "Messages.proto";

service Example {
    rpc CreateMessage (CreateMessageRequest) returns (CreateMessageResponse);
}

ファイルの自動生成

プロジェクトのルートディレクトリで下記コマンドの実行、もしくはIDEからGradleの generateProto タスクを実行してください。

./gradlew generateProto

src/main/generated ディレクトリ配下に、生成されたファイルたちが出力されます。
また、 clean タスクを実行することで生成したコードは全て削除されるので、生成の動きをもう一度見たい場合は削除後、再度 generateProto タスクを実行してください。

gRPCの実装クラス

こちらも基本は第1回の記事の内容です。
gRPCで自動生成したファイルを使って、gRPCのサーバーサイド処理を実装します。

GrpcService

自動生成の「ExampleImplBase」を継承して実装します。
grpc-spring-boot-staterに含まれる @GrpcService のアノテーションを付けることで、gRPCのServiceとして認識されます。

ExampleGrpcService.kt

@GRpcService(interceptors = [MessageInterceptor::class])
class ExampleGrpcService(
        private val exampleService: ExampleService
) : ExampleGrpc.ExampleImplBase() {
    companion object {
        private val log = LoggerFactory.getLogger(ExampleGrpcService::class.java)
    }

    override fun createMessage(request: CreateMessageRequest, responseObserver: StreamObserver<CreateMessageResponse>) {
        val text = exampleService.creteMessageText(request.name)
        val message = Message.newBuilder()
                .setText(text)
                .setLength(text.length)
                .build()
        val responseBuilder = CreateMessageResponse.newBuilder()
                .setMessage(message)
                .setType(MessageType.NORMAL)

        log.info("execute createMessage")

        responseObserver.onNext(responseBuilder.build())
        responseObserver.onCompleted()
    }
}

ServerInterceptor

サーバー側のgRPCのインターセプターです。
ServerInterceptor というインターフェースを実装し、 interceptCall というメソッドをオーバーライドすることでgRPCのInterceptorでの前処理を定義できます。
全ての処理で通したいインターセプターには、@GRpcGlobalInterceptor のアノテーションを付けます。

LoggingInterceptor.kt

@GRpcGlobalInterceptor
@Order(10)
class LoggingInterceptor : ServerInterceptor {
    companion object {
        private val log = LoggerFactory.getLogger(LoggingInterceptor::class.java)
    }

    override fun <ReqT : Any, RespT : Any> interceptCall(call: ServerCall<ReqT, RespT>, headers: Metadata, next: ServerCallHandler<ReqT, RespT>): ServerCall.Listener<ReqT> {
        log.info("method name=${call.methodDescriptor.fullMethodName}")
        return next.startCall(call, headers)
    }
}

また、複数ある場合は、Springの Order アノテーションで実行順を指定できます。 ヘッダ情報の取得などもこちらで書きます。

MetadataInterceptor.kt

@GRpcGlobalInterceptor
@Order(30)
class MetadataInterceptor : ServerInterceptor {
    companion object {
        private val log = LoggerFactory.getLogger(MetadataInterceptor::class.java)
    }

    override fun <ReqT : Any, RespT : Any> interceptCall(call: ServerCall<ReqT, RespT>, headers: Metadata, next: ServerCallHandler<ReqT, RespT>): ServerCall.Listener<ReqT> {
        val exampleHeader = headers.get(Metadata.Key.of("example_header", Metadata.ASCII_STRING_MARSHALLER))

        log.info("header=$exampleHeader")

        return next.startCall(object : ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>(call) {
            override fun close(status: Status?, trailers: Metadata) {
                log.info("execute close")

                trailers.put(Metadata.Key.of("example_trailer", Metadata.ASCII_STRING_MARSHALLER), "Example Trailer")
                super.close(status, trailers)
            }
        }, headers)
    }
}

また、後処理に関しては SimpleForwardingServerCall クラスの close メソッドをオーバーライドし、 next.startCall に渡すことで定義できます。
こちらではトレーラーを処理することができます。

前処理とヘッダに関して第3回、後処理とトレーラーに関しては第4回の記事で紹介しています。

Serviceクラス

GrpcServiceから呼び出しているServiceクラス(名前がややこしい・・・)のインターフェースと実装クラスです。
ここはSpringのDIのサンプルとして一応入れています。

ExampleServiceImpl.kt

@Service
class ExampleServiceImpl : ExampleService {
    override fun creteMessageText(name: String): String {
        return "Hello $name"
    }
}

サンプルでは名前の情報を受け取ってメッセージ文を作って返すだけの単純な処理にしていますが、いわゆるビジネスロジックの部分はここに記述するイメージです。

gRPCのクライアントになるREST API

最後に、実行確認のためクライアント側の実装をします。
これは第4回の記事の前半で紹介しています。

クライアントと言っても、実行のインターフェースとしては# REST APIなので、サーバー to サーバー間通信でのクライアントという意味ですね。
マイクロサービスアーキテクチャのゲートウェイ的なイメージです。

ちなみにまだ試せてないですが、gRPC通信の実行部分に関しては、AndroidのKotlinクライアントと接続する時も基本的には同じように使えると思います。

ClientController

実装にはProtocol Buffersの中にあるgRPCのstubを使います。 Channel(gRPCのConnectionのようなもの)を生成して、生成されたファイルにあるstubへ渡すことで、gRPCのServiceを実行できます。

@RestController
class ExampleClientController {
    @RequestMapping("/createmessage")
    fun createMessage(@RequestBody request: RestCreateMessageRequest): String {
        val createMessageRequest = CreateMessageRequest.newBuilder().setName(request.name).build()
        val channel = ManagedChannelBuilder.forAddress("localhost", 6565)
                .intercept(MetadataClientInterceptor())
                .usePlaintext().build()
        val stub = ExampleGrpc.newBlockingStub(channel)
        val response = stub.createMessage(createMessageRequest)

        return response.message.text
    }
}

処理はシンプルで、名前の情報をリクエストを受け付け、gRPCのServiceのリクエストにそのまま設定して実行した結果から、メッセージのテキストを取り出しレスポンスとして返しているだけです。
intercept で設定しているクラスは、後述するクライアント側のInterceptorです。

ClientInterceptor

こちらはクライアント側のgRPCのインターセプターです。
ClientInterceptor というインターフェースを実装することで、サーバー側と同じようにgRPC通信時の前処理、後処理が定義できます。

class MetadataClientInterceptor : ClientInterceptor {
    companion object {
        private val log = LoggerFactory.getLogger(MetadataClientInterceptor::class.java)
    }

    override fun <ReqT, RespT> interceptCall(method: MethodDescriptor<ReqT, RespT>?,
                                             callOptions: CallOptions?, next: Channel): ClientCall<ReqT, RespT>? {
        return object : SimpleForwardingClientCall<ReqT, RespT>(next.newCall(method, callOptions)) {
            override fun start(responseListener: Listener<RespT>?, headers: Metadata) {
                headers.put(Metadata.Key.of("example_header", Metadata.ASCII_STRING_MARSHALLER), "Example Header")
                super.start(MetadataClientCallListener(responseListener), headers)
            }
        }
    }

    internal class MetadataClientCallListener<RespT>(responseListener: ClientCall.Listener<RespT>?) : SimpleForwardingClientCallListener<RespT>(responseListener) {
        override fun onClose(status: Status?, trailers: Metadata?) {
            try {
                val exampleTrailer = trailers?.get(Metadata.Key.of("example_trailer", Metadata.ASCII_STRING_MARSHALLER))
                log.info("trailer=$exampleTrailer")
            } finally {
                super.onClose(status, trailers)
            }
        }
    }
}

start メソッドをオーバーライドして実装している処理が前処理で、ヘッダの送信などもここで書いています。
internal classで定義している「MetadataClientCallListener」で onClose メソッドをオーバーライドして実装している処理が後処理で、トレーラーの受診もここで書いています。

ぜひKotlinとgRPCを使うキッカケに!

最近はサーバーサイドKotlinでマイクロサービスを構成するプロダクトも出てきているため、KotlinでgRPCを使う方も徐々に増えてきていると思います。
とは言え国内ではまだまだ事例が少なかったり、gRPCという技術に馴染みがない方も多いでしょう。
Android、iOSやWebのクライアントとの通信という意味ではもっと事例が少ないです。

しかし、いずれも次世代のサーバーサイド開発においてとても可能性がある技術スタックだと思います。
パフォーマンス面など様々なメリットもありますが、この記事がまずは一旦触ってみるキッカケになれば幸いです。

今後はORMでDB接続を入れたり、ストリーミング通信などgRPCのまだ紹介していない機能、さらにスマートフォンやWebのクライアントとの接続などサンプルも拡充して、引き続きこのブログで紹介していければと考えています。
そちらもお楽しみに!