gRPCをSpringBootで実装する機会がありました。
いろいろなサイトがあるのですが、業務でやろうとすることをすべて理解するにはあちこちを探し回る必要があって大変でした。。。
そんな経験から、この記事では
実務で気にするであろうことを「網羅的に」「コピペで使えるように」まとめてみました!
前提
- ビルドツール:Gradle
- 端末:Mac
- SpringBoot:2.6.4
- 正確さよりも分かりやすさ重視
定義ファイル
gRPCでは.proto
拡張子のファイルでスキーマを定義する必要があります。
Rest APIでいうIF仕様書のようなものです。
Rest APIではドキュメントとしてアプリケーションとは別で管理をしますが、
gRPCではこの定義ファイル自体がアプリケーションコードの一部に変換されます。
定義ファイルの管理は、ここではserverともclientとも切り離したjarで管理することとします。
プロジェクト作成
https://start.spring.io/ でプロジェクトを生成します。
依存は特に追加しなくてOK
ダウンロード&解凍後、build.gradle
を修正します
(修正部分のみ抜粋)
// プラグインの追加
plugins {
id 'maven-publish'
id "io.github.lognet.grpc-spring-boot" version '4.6.0'
}
// jarファイル関連の設定
jar {
enabled = true
archiveClassifier = ''
}
bootJar {
enabled = false
}
// publishの設定
publishing {
publications {
mavenPlugin(MavenPublication) {
from components.java
}
}
}
io.github.lognet.grpc-spring-boot
pluginを追加するだけで、
grpcの実装に必要な依存がすべて自動で追加されます✨
参考:
.protoファイル作成
src/main/proto
配下に以下のような定義ファイルを作成します。
syntax = "proto3";
option java_multiple_files = true;
// 自動生成されるJavaクラスのpackegeに対応
option java_package = "rhirabay.grpc.sample";
// 自動生成されるJavaクラス名に使われる(server, client実装時には意識しないかも)
option java_outer_classname = "Greeter";
// 定義ファイル自体のpackage
package greet;
// サービスの定義
service Greet{
rpc greeting (GreetRequest) returns (GreetResponse);
}
// リクエスト定義(この名前でJavaクラスが自動生成される)
message GreetRequest{
// Javaクラスのフィールドに該当(右側の数字はタグ ※一意につければOK)
// Javaの型との対応: https://developers.google.com/protocol-buffers/docs/proto3#scalar
string name = 1;
}
// レスポンス定義(この名前でJavaクラスが自動生成される)
message GreetResponse{
// Javaクラスのフィールドに該当
string message = 1;
}
repositoryへupload
今回はlocalのmaven repositoryを使用します。
なのでpublishToMavenLocal
タスクを実行すればOK!
裏でクラスの自動生成(generateProto
)、jarの生成(jar
)をしてくれます。
生成されたjarをserver, clientで取り込めば、自動生成されたクラスを利用できます!
gRPCサーバー
gRPCのリクエストを受け付けるサーバアプリケーションを実装していきます。
プロジェクト作成
https://start.spring.io/ でプロジェクトを生成します。Spring Web
を依存に追加します。
ダウンロード&解凍後、build.gradle
を修正します
(修正部分のみ抜粋)
※補足
grpcサーバだけだとwebも要らないのですが、後述のメトリクス参照で使用するので先に追加しておきます。
// プラグインの追加
plugins {
id "io.github.lognet.grpc-spring-boot" version '4.6.0'
}
repositories {
mavenCentral()
mavenLocal() // local repository利用
}
dependencies {
// 先ほどuploadしたprotoの取り込み
implementation 'rhirabay:proto:+'
}
gRPCサーバの実装
<.proto service名>.<.proto service名>ImplBase
を継承して実装します。
今回であれば、service名にGreet
を指定しているのでGreetGrpc.GreetImplBase
を継承します。
import io.grpc.stub.StreamObserver;
import org.lognet.springboot.grpc.GRpcService;
import rhirabay.grpc.sample.GreetGrpc;
import rhirabay.grpc.sample.GreetRequest;
import rhirabay.grpc.sample.GreetResponse;
// @GRpcServiceを付与すると、grpcのserviceとして認識される
@GRpcService
public class SampleService extends GreetGrpc.GreetImplBase {
@Override
public void greeting(GreetRequest request, StreamObserver<GreetResponse> responseObserver) {
GreetResponse response = GreetResponse.newBuilder()
.setMessage("Hello, " + request.getName() + ".")
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}
Overrideするメソッドは、.protoのservice定義と対応しています。
今回は、リクエストで受け取った名前を使ってあいさつするような感じに実装してみました。
(デフォルトのままですが)portだけ指定しておきます。
grpc:
port: 6565
これだけ!
単体テスト
Serviceクラスの単体テストを実装します。
単体テストではresponseObserver.onNext()
をどんな引数で実行したかがテストできれば十分なはず🤔
import io.grpc.stub.StreamObserver;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import rhirabay.grpc.sample.GreetRequest;
import rhirabay.grpc.sample.GreetResponse;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class SampleServiceTest {
@InjectMocks
private SampleService sampleService;
@Mock
private StreamObserver<GreetResponse> responseObserver;
@Test
void mock_response() {
doNothing().when(responseObserver).onNext(any());
doNothing().when(responseObserver).onCompleted();
var request = GreetRequest.newBuilder()
.setName("Ryo")
.build();
sampleService.greeting(request, responseObserver);
var expected = GreetResponse.newBuilder()
.setMessage("Hello, Ryo.")
.build();
verify(responseObserver).onNext(expected);
}
}
結合試験
gRPCサーバアプリケーション全体の試験を実装してみます。
後続のClientの実装にも出てきますが、BlockingStubを使用してリクエストを送ります。
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.junit.jupiter.api.Test;
import org.lognet.springboot.grpc.context.LocalRunningGrpcPort;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import rhirabay.grpc.sample.GreetGrpc;
import rhirabay.grpc.sample.GreetRequest;
@SpringBootTest(
classes = GrpcApplication.class,
properties = "grpc.port=0" // ランダムポート
)
@ActiveProfiles("test")
class GrpcApplicationTests {
@LocalRunningGrpcPort // 起動したサーバのportをinjectしてくれる
private int runningPort;
@Test
void test() {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", runningPort)
.usePlaintext()
.build();
var stub = GreetGrpc.newBlockingStub(channel);
var request = GreetRequest.newBuilder()
.setName("Ryo")
.build();
var actual = stub.greeting(request).getMessage();
var expected = "Hello, Ryo.";
assertThat(actual).isEqualTo(expected);
}
}
動作確認
クラウドやサーバで稼働中の(ここではlocalでbootRunした)gRPCサーバの動作確認方法です。
ここではgrpcurlを使ってみます。
# 事前にgrpcurlをインストール
# $ brew install grpcurl
$ grpcurl -import-path ./proto/src/main/proto/ \
-proto sample.proto \
-plaintext \
-d '{"name":"Ryo"}' \
localhost:6565 greet.Greet/greeting
{
"message": "Hello, Ryo."
}
オプション | 説明 |
---|---|
-import-path | .protoファイルの場所の指定 |
-proto | .protoファイルの指定(ディレクトリ不要) |
-plaintext | tlsでない時に指定 |
-d | json形式でリクエストを指定 |
メトリクス
サーバのメトリクスは必要な依存さえ追加すれば自動で収集してくれます!
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation "io.micrometer:micrometer-registry-prometheus"
}
収集されるメトリクスはこんな感じ
# HELP grpc_server_calls_seconds
# TYPE grpc_server_calls_seconds summary
grpc_server_calls_seconds_count{method="greet.Greet/greeting",result="OK",} 1.0
grpc_server_calls_seconds_sum{method="greet.Greet/greeting",result="OK",} 0.024830153
# HELP grpc_server_calls_seconds_max
# TYPE grpc_server_calls_seconds_max gauge
grpc_server_calls_seconds_max{method="greet.Greet/greeting",result="OK",} 0.024830153
(パーセンタイルが見れなそうで残念…)
TLS
メッセージをTLSでやりとりするには、秘密鍵や証明書をpropertyで指定してあげればOK!
テスト用に証明書を自分で発行したい場合は以下が参考になるかと(自分も参考にしました)
grpc:
security:
private-key: "file:../cert/server.key"
cert-chain: "file:../cert/server.pem"
負荷試験
サーバに対しての負荷試験のやり方をまとめておきます
ここではLocustを使用します。
まずは環境構築です。(詳しく触れません🙇♂️)
$ brew install pyenv
$ brew install pipenv
$ pipenv --python 3.10.3
$ pipenv install locust
$ pipenv install grpcio
$ pipenv install grpcio-tools
$ pipenv shell
次にgRPCのクライアントコードを生成します。
gradleタスクのgenerateProto
に代わる部分です。
$ python -m grpc_tools.protoc -I <.protoファイル配置ディレクトリ> --python_out=. --grpc_python_out=. <.protoファイル配置ディレクトリ>/sample.proto
すると sample_pb2.py
とsample_pb2_grpc.py
が生成されます!
この生成されたモジュールをlocustfile.py
で使用します。
ドキュメントからほぼコピペですが、一旦動くものとして載せます。
(どこで何しているのか正直追えていないです😇)
import grpc
import sample_pb2_grpc
import sample_pb2
from locust import events, User, task
from locust.exception import LocustError
from locust.user.task import LOCUST_STATE_STOPPING
import gevent
import time
import grpc.experimental.gevent as grpc_gevent
grpc_gevent.init_gevent()
class GrpcClient:
def __init__(self, environment, stub):
self.env = environment
self._stub_class = stub.__class__
self._stub = stub
def __getattr__(self, name):
func = self._stub_class.__getattribute__(self._stub, name)
def wrapper(*args, **kwargs):
request_meta = {
"request_type": "grpc",
"name": name,
"start_time": time.time(),
"response_length": 0,
"exception": None,
"context": None,
"response": None,
}
start_perf_counter = time.perf_counter()
try:
request_meta["response"] = func(*args, **kwargs)
request_meta["response_length"] = len(request_meta["response"].message)
except grpc.RpcError as e:
request_meta["exception"] = e
request_meta["response_time"] = (time.perf_counter() - start_perf_counter) * 1000
self.env.events.request.fire(**request_meta)
return request_meta["response"]
return wrapper
class GrpcUser(User):
abstract = True
stub_class = None
def __init__(self, environment):
super().__init__(environment)
for attr_value, attr_name in ((self.host, "host"), (self.stub_class, "stub_class")):
if attr_value is None:
raise LocustError(f"You must specify the {attr_name}.")
self._channel = grpc.insecure_channel(self.host)
self._channel_closed = False
stub = self.stub_class(self._channel)
self.client = GrpcClient(environment, stub)
class HelloGrpcUser(GrpcUser):
host = "localhost:6565"
stub_class = sample_pb2_grpc.GreetStub
@task
def greeting(self):
if not self._channel_closed:
self.client.greeting(sample_pb2.GreetRequest(name="Ryo"))
time.sleep(1)
最後にlocustを起動します。
http://localhost:8089/ にアクセスするとLocustのUIが表示されます!
(UIの使い方はここでは省略)
gRPCクライアント
gRPCのリクエストを受け付けるクライアントアプリケーションを実装していきます。
httpでリクエストを受け付けて、gRPCでバックポストする構成にしてみます。
プロジェクト作成
https://start.spring.io/ でプロジェクトを生成します。Spring Web
を依存に追加します。
ダウンロード&解凍後、build.gradle
を修正します
(修正部分のみ抜粋)
// プラグインの追加
plugins {
id "io.github.lognet.grpc-spring-boot" version '4.6.0'
}
repositories {
mavenCentral()
mavenLocal() // local repository利用
}
dependencies {
// 先ほどuploadしたprotoの取り込み
implementation 'rhirabay:proto:+'
}
gRPCクライアントの実装
gRPCサーバとの通信に必要なstub(web mvcでいうRestTemplate)をBean登録しておきます。
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import rhirabay.grpc.sample.GreetGrpc;
@Configuration
public class GrpcAutoConfiguration {
@Bean
ManagedChannel managedChannel() {
return ManagedChannelBuilder.forAddress("localhost", 6565)
.usePlaintext() // TLSを利用しない場合
.build();
}
@Bean
GreetGrpc.GreetBlockingStub greetBlockingStub(ManagedChannel managedChannel) {
return GreetGrpc.newBlockingStub(managedChannel);
}
}
スタブのクラスは.protoの定義ファイルと対応していて、<service名>Grpc.<service名>BlockingStub
という命名です。
次にstubを用いてクライアントクラスを実装します
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import rhirabay.grpc.sample.GreetGrpc;
import rhirabay.grpc.sample.GreetRequest;
@Component
@RequiredArgsConstructor
public class SampleClient {
private final GreetGrpc.GreetBlockingStub greetBlockingStub;
public String greeting(String name) {
var request = GreetRequest.newBuilder()
.setName(name)
.build();
var response = this.greetBlockingStub.greeting(request);
return response.getMessage();
}
}
リクエスト・レスポンスのモデルクラスはこれも.protoファイルで定義したものと対応しています。
オブジェクトの生成はBuilerパターンで、直の参照はgetterでという感じ
最後にHTTPでリクエストを受け取るRestControllerを実装します(ここはgRPC要素なし)
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import rhirabay.grpc.client.SampleClient;
@RestController
@RequiredArgsConstructor
public class SampleController {
private final SampleClient sampleClient;
private final TlsSampleClient tlsSampleClient;
@GetMapping("/greeting")
public String greeting(@RequestParam(defaultValue = "anonymous", required = false) String name) {
return sampleClient.greeting(name);
}
}
単体テスト
gRPCクライアントの単体テストを実装します。
gRPCサーバをテスト内で起動し、ちゃんとサーバと通信できるかどうかテストします。
内容としては
- サーバで受け取ったリクエスト(想定通りリクエストができているのか?)
- メソッドの返り値(想定通りサーバのレスポンスをハンドリングできているのか?)
が検証できればいいはず
@ExtendWith(MockitoExtension.class)
class SampleClientTest {
private SampleClient sampleClient;
static String UNIQUE_SERVER_NAME = "server^name";
private static MutableHandlerRegistry serviceRegistry = new MutableHandlerRegistry();
private static Server inProcessServer = InProcessServerBuilder
.forName(UNIQUE_SERVER_NAME)
.fallbackHandlerRegistry(serviceRegistry)
.directExecutor()
.build();
@Spy
private GreetGrpcMock greetGrpcMock = new GreetGrpcMock();
@BeforeAll
@SneakyThrows
static void startServer() {
inProcessServer.start();
}
@AfterAll
static void shutdownServer() {
inProcessServer.shutdownNow();
}
@BeforeEach
void setup() {
var inProcessChannel = InProcessChannelBuilder
.forName(UNIQUE_SERVER_NAME)
.directExecutor()
.build();
serviceRegistry.addService(greetGrpcMock);
var stub = GreetGrpc.newBlockingStub(inProcessChannel);
sampleClient = new SampleClient(stub);
}
@Test
void test() {
// gRPCサービスの動作を定義
doAnswer(invocation -> {
// リクエストを検証する(ここで実装するのもどうかと思うが…)
var request = (GreetRequest)(invocation.getArgument(0));
assertThat(request.getName()).isEqualTo("test");
var responseObserver = (StreamObserver<GreetResponse>)(invocation.getArgument(1));
GreetResponse response = GreetResponse.newBuilder()
.setMessage("Hello, client test.")
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
return null;
}).when(greetGrpcMock).greeting(any(), any());
// テスト対象のメソッド呼び出しと検証
var actual = sampleClient.greeting("test");
var expected = "Hello, client test.";
assertThat(actual).isEqualTo(expected);
}
// spy化のためにgRPCサービスクラスを定義
static class GreetGrpcMock extends GreetGrpc.GreetImplBase {}
}
TLS
サーバとの通信をTLSで行いたい時の実装方法ですManagedChannel
だけ書き換えます
@Bean
@SneakyThrows
ManagedChannel managedChannel(GrpcProperties grpcProperties) {
var channelBuilder = ManagedChannelBuilder.forAddress("localhost", 6565);
var certChain = new FileUrlResource("../cert/server.pem");
var sslContext = GrpcSslContexts.forClient().trustManager(certChain.getInputStream()).build();
return ((NettyChannelBuilder)channelBuilder)
.useTransportSecurity()
.sslContext(sslContext)
.build();
}