Java(Kotlin)におけるgRPCライブラリの選定と実装および速度比較検証

はじめに

Applibotで内定者アルバイトをしている18卒の杉浦です。
今回、Applibotの基盤制作チーム A.R.T.(Applibot Root Technologies)でのタスクとして、Java (Kotlin)におけるgRPCライブラリの選定と、gRPCと既存の運用でよく使われているシリアライズフォーマットであるJSONを用いたAPIとの速度比較をしたのでその結果を書きます。


今回の検証は、Kotlinを用いて行いましたが、同様の方法でJavaでも動作すると思います。

gRPCやProtocolBufferに詳しい方、間違っている点や改善点があれば教えてください。
検証の際に利用したサーバーサイドのプロジェクトはこちら、クライアントサイドのプロジェクトはこちらです。

gRPCとは

公式サイトで、

gRPC is a modern open source high performance RPC framework that can run in any environment

とあるように、オープンソースでどんな環境でも動作するハイパフォーマンスなRPCフレームワークです。
特徴として、標準でHTTP/2をサポートしていることと、シリアライズフォーマットとしてProtocolBufferを利用(変更も可能)していることが挙げられます。

対応言語は、C++、Java、Python、Go、Ruby、C#、Node.js、PHPなど幅広く、ゲーム開発で利用されているサーバーサイド、クライアントサイドの言語は概ねカバーできているのかなと思います。

ライブラリ選定

Kotlinにおいて現状メジャーなgRPCが使用できるライブラリとしては、以下の2つのライブラリがありました。

LogNet/grpc-spring-boot-starter

  • 現在KotlinでgRPCを動かす場合一番メジャーなライブラリ
  • デフォルトで8080ポートがHTTP/1.1(REST API等)用、6565ポートがHTTP/2(gRPC)用で開かれるので一つのプロジェクトで共存可能
  • クラスに@GrpcServiceというアノテーションをつけるだけでgRPCのサービスとして登録できるので楽
  • Interceptorや認証に関してなど、実際の運用で必要になる機能の日本語記事もある

line/armeria

  • まだ記事が少ない
  • サービスをaddService()のように一つ一つ登録する必要がありそう

上記のような理由と、2つのライブラリを用いて実際に実装をしてみて、なおかつネットにある情報量も考慮して、grpc-spring-boot-starterを採用することにしました。

検証プロジェクトの開発環境

サーバー

  • Kotlin 1.2.30
  • Apache Tomcat/8.5.28

クライアント

  • Unity 2018 1.0b11

今回クライアントに関しての詳細は省きますが、公式のgRPCライブラリ を使用する場合、.NET4.6が使用できるUnity 2017以降でないと動作しないので注意してください。

実装

build.gradleは以下のようになります。
詳しい内容は省略しますが、gRPCを利用するためにgRPCとProtocolBufferの依存ライブラリやプラグインを導入しています。
また、ProtocolBufferで書かれた.proto定義ファイルから.javaにgradleで自動的に変換するため、protobufに対する処理も記述されていることが特徴です。

builld.gradle
group 'com.applibot'
version '1.0-SNAPSHOT'
buildscript {
    ext.kotlin_version = '1.2.30'

    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version"
        classpath 'org.springframework.boot:spring-boot-gradle-plugin:2.0.0.RELEASE'
        classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlin_version"
        classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.5'
    }
}
apply plugin: 'kotlin'
apply plugin: 'kotlin-spring'
apply plugin: 'kotlin-noarg'
apply plugin: 'org.springframework.boot'
apply plugin: 'com.google.protobuf'
apply plugin: 'application'
def grpcVersion = '1.10.0'
repositories {
    mavenCentral()
    jcenter()
}
sourceSets {
    main.kotlin.srcDirs += 'src/main/kotlin'
    main.java.srcDirs += 'src/main/java'
    main.java.srcDirs += 'src/main/generated-proto'
}
noArg {
    annotation("com.applibot.OpenClass")
}
dependencies {
    compile "io.grpc:grpc-netty:${grpcVersion}"
    compile "io.grpc:grpc-protobuf:${grpcVersion}"
    compile "io.grpc:grpc-stub:${grpcVersion}"

    compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"

    // spring-boot
    compile "org.springframework.boot:spring-boot-starter-web:2.0.0.RELEASE"
    compile "org.springframework.boot:spring-boot-actuator:2.0.0.RELEASE"
    compile "org.lognet:grpc-spring-boot-starter:2.1.5"
}
protobuf {
    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 {
                java {
                    outputSubDir = 'generated-proto'
                }
            }
            task.plugins {
                grpc {
                    outputSubDir = 'generated-proto'
                }
            }
        }
    }
    generatedFilesBaseDir = "${projectDir}/src/"
}
clean {
    delete "./src/main/generated-proto"
}
mainClassName = 'com.applibot.grpc.service.Applicationkt:'

次に、gRPCで利用するメッセージとサービスの実装を書いた.protoファイルが以下です。
先に述べたように、gRPCではProtocolBufferを利用するのが一般的です。
以下のようにserviceとmessageを定義することで、各サービスがどのような内容を返すのかを決めます。
クライアントとサーバーの共通な定義として記述することができ、このファイルから各言語のソースコードを出力して利用します。
optionで出力するjavaファイルのパッケージ名を指定したり、ファイルを分割するかどうかを決めたりもできます。

several.proto
syntax = "proto3";
option java_multiple_files = true;
option java_package = "com.applibot.gen.several";
option java_outer_classname = "SeveralServiceProto";
package several;
service SeveralService {
    rpc GetSeveralData (SeveralData) returns (SeveralData) {
    }
}
message SeveralData {
    float floatData = 1;
    double doubleData = 2;
    int32 intData = 3;
    int64 longData = 4;
    bool boolData = 5;
    string stringData = 6;
    repeated string list = 8;
}

次に、エントリーポイントとなるApplication.ktの実装は以下です。
Kotlinで書かれていること以外は、一般的にSpringBootを利用する場合と同じです。
OpenClassアノテーションを作成しているのは、検証のために必要だっただけなので、本来は必要ありません。

Application.kt
package com.applibot
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
annotation class OpenClass
@SpringBootApplication
class Application {
    companion object {
        @JvmStatic
        fun main(args: Array<String>)  {
            SpringApplication.run(Application::class.java, *args)
        }
    }
}

最後に、先に記述したProtocolBufferの定義から出力されたファイルを利用して、サービスクラスを実装したものが以下です。
今回は、RequestとReponseの型が同じなので、来たRequestをそのままResponseに流しています。

SeveralServer.kt
package com.applibot.grpc.service
import com.applibot.gen.several.SeveralData
import com.applibot.gen.several.SeveralServiceGrpc
import io.grpc.stub.StreamObserver
import org.lognet.springboot.grpc.GRpcService
import java.io.File
@GRpcService
class SeveralServer : SeveralServiceGrpc.SeveralServiceImplBase() {

    val file = File("./result/server.txt").absoluteFile

    override fun getSeveralData(request: SeveralData?, responseObserver: StreamObserver<SeveralData>?) {
        val time = System.currentTimeMillis()
        file.appendText("$time\n")
        responseObserver?.onNext(request)
        responseObserver?.onCompleted()
    }
}

検証

今回検証のため、SpringBootを用いたJSONを返すAPIを作成し、そのAPIとgRPCに対するリクエストにかかる時間、レスポンスにかかる時間を比較しました。

検証に対する実装

今回実装したJSONを返すAPIコントローラーは以下です。
Beanクラスについては、gRPCで利用したものを併用しようとしたところ、JSONのシリアライズ、デシリアライズが出来なかったため、同様の内容を含んだものを作成しました。
やっていることは、gRPCのサービスクラスと同様に、来たリクエストをそのまま返しています。

SeveralController.kt
package com.applibot.controller
import com.applibot.data.SeveralDataBean
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.bind.annotation.RestController
import java.io.File
@RestController
@RequestMapping(path = ["several"])
class SeveralController {

    val file = File("./jsonResult/server.txt").absoluteFile

    @RequestMapping(path = ["data"], method = arrayOf(RequestMethod.POST))
    fun getSeveralData(@RequestBody model: SeveralDataBean): SeveralDataBean {
        val time = System.currentTimeMillis()
        file.appendText("$time\n")
        return model
    }
}

また、クライアントの実装の一部についても説明します。
検証のために送るデータはある程度大きいことが望ましいので、以下のようなメソッドで、多数の文字列を含んだリストを作成し、これをリクエストの際に送りました。

DataCreator.cs
public List<string> GetBigData() {
    List<string> list = new List<string>();
    for (int i = 0; i < 5000; ++i) {
        list.Add(System.Guid.NewGuid().ToString("N").Substring(0, 20));
    }
    return list;
}

今回、gRPCのリクエストを送っている部分は以下です。
チャンネルを作成して、その作ったチャンネルを利用して、50回リクエストを送っています。
リクエストを送るときに、Unix時間を記録し、レスポンスを取得したときにもUnix時間を記録しています。

SeveralClient.cs
 private void Tap() {
        var channel = new Channel("127.0.0.1" + kPortNum, ChannelCredentials.Insecure);

        //中身をひたすら作る
        var req = new SeveralData();
        req.FloatData = 1.23f;
        req.DoubleData = 3.14;
        req.IntData = 2;
        req.LongData = 2222222222222;
        req.BoolData = true;
        req.StringData = "applibot@test";
        req.List.AddRange(dataCreator_.data);

        for (int i = 0; i < 50; ++i) {
            var client = new SeveralService.SeveralServiceClient(channel);
            reqWriter_.WriteLine(DateTimeOffset.Now.ToUnixTimeMilliseconds());
            reqWriter_.Flush();
            var reply = client.GetSeveralData(req);
            resWriter_.WriteLine(DateTimeOffset.Now.ToUnixTimeMilliseconds());
            resWriter_.Flush();
        }
        channel.ShutdownAsync().Wait();
    }

通常のUnityWebRequestを用いて、JSONを用いたAPIを叩いている実装が以下です。
JSONUtilityを用いて、JSONのシリアライズ、デシリアライズを行っています。
また、gRPCと同様に50回リクエストを送っています。

SeveralJSONClient.cs
private IEnumerator GetSeveralData() {
                var model = new json.model.SeveralModel();
                model.floatData = 1.23f;
                model.doubleData = 3.14;
                model.intData = 2;
                model.longData = 2222222222222;
                model.boolData = true;
                model.stringData = "applibot@test";
                model.list = dataCreator_.data;

            for (int i = 0; i < 50; ++i) {
                reqWriter_.WriteLine(DateTimeOffset.Now.ToUnixTimeMilliseconds());
                reqWriter_.Flush();
                string jsonData = JsonUtility.ToJson(model);

                var request = new UnityWebRequest();
                request.url = url;
                byte[] body = System.Text.Encoding.UTF8.GetBytes(jsonData);
                request.uploadHandler = new UploadHandlerRaw(body);
                request.downloadHandler = new DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json; charset=UTF-8");
                request.method = UnityWebRequest.kHttpVerbPOST;
                yield return request.SendWebRequest();

                if (request.isNetworkError) {
                    Debug.Log(request.error);
                } else {
                    if (request.responseCode == 200) {
                        string json = request.downloadHandler.text;
                        var res = JsonUtility.FromJson<json.model.SeveralModel>(json);
                        resWriter_.WriteLine(DateTimeOffset.Now.ToUnixTimeMilliseconds());
                        resWriter_.Flush();
                    }
                }
            }
        }

検証結果

実際に計測した結果は以下のようになりました。
計測の都合上、今回のリクエスト時間とは、各フォーマットにシリアライズをするところから、サーバーにそのデータが届くまでであり、レスポンス時間とは、サーバーに届いたところから、サーバーから帰ってきたデータをデシリアライズするところまでとします。

  平均リクエスト時間(ms) 平均レスポンス時間(ms)
gRPC 2.92 3.6
JSON 7.16 12.78

結果を見ると、gRPCのほうがJSONに比べてリクエストは2倍程度、レスポンスは4倍程度早いことがわかります。

今回の検証で、当初は3万回リクエストをループで送って計測をしていたのですが、途中から謎のスパイクが入り、レスポンス時間がgRPCだと30ms程度、JSONだと90ms程度になってしまっているタイミングがありました。
今回ローカル環境で検証を行っていることもあり、原因の究明が難しそうだったため、今回はスパイクの入らない50回までで計測を行った結果を示しました。

まとめ

今回利用したgRPCのライブラリを用いれば、比較的簡単にgRPCサーバーが立てられました。
HTTP/1.1の通信を受けるポートと、HTTP/2の通信を受けるポートが開かれるので、今までの実装に加えてgRPCサーバーを立てるということもできそうだなと思いました。
また、速度も十分に早く、期待ができそうです。
クライアントの実装に関しては、まだ調べきれていないことと、広く使われているようなライブラリがまだ無さそうなので、勉強を進めつつこれからの発展に期待です。
余談ですが、UnityクライアントのためのgRPCのライブラリでMagicOnionというものがあり、これはシリアライズフォーマットにMessagePackを使用していてProtocolBufferによるIDL定義が不要となっているので、興味のある人は試してみてください!

未検証の気になること

  • ローカルでない環境で実験を行った場合の結果
    • 今回はローカル環境で実験を行いました。
  • gRPCの1回1回チャンネルを作成し直したらどうなるか?
  • JSON以外の形式、MessagePack等とくらべてどうか?
  • grpc-gateway を用いた場合、通常のAPIリクエストとくらべてどうなのか?

上記に関しては今後個人的に調べて見れたらよいなと思います。

参考

gRPC公式サイト
KotlinでSpringBootでgRPCサーバーを立てた時の設定メモ
Spring BootでgRPCする