
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に対する処理も記述されていることが特徴です。
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ファイルのパッケージ名を指定したり、ファイルを分割するかどうかを決めたりもできます。
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アノテーションを作成しているのは、検証のために必要だっただけなので、本来は必要ありません。
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に流しています。
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("$timen")
responseObserver?.onNext(request)
responseObserver?.onCompleted()
}
}
検証
今回検証のため、SpringBootを用いたJSONを返すAPIを作成し、そのAPIとgRPCに対するリクエストにかかる時間、レスポンスにかかる時間を比較しました。
検証に対する実装
今回実装したJSONを返すAPIコントローラーは以下です。
Beanクラスについては、gRPCで利用したものを併用しようとしたところ、JSONのシリアライズ、デシリアライズが出来なかったため、同様の内容を含んだものを作成しました。
やっていることは、gRPCのサービスクラスと同様に、来たリクエストをそのまま返しています。
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("$timen")
return model
}
}
また、クライアントの実装の一部についても説明します。
検証のために送るデータはある程度大きいことが望ましいので、以下のようなメソッドで、多数の文字列を含んだリストを作成し、これをリクエストの際に送りました。
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時間を記録しています。
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回リクエストを送っています。
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リクエストとくらべてどうなのか?
上記に関しては今後個人的に調べて見れたらよいなと思います。
参考
KotlinでSpringBootでgRPCサーバーを立てた時の設定メモ
この記事へのコメントはありません。