【初心者向け】SpringBootでgRPCを実装してみた

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-bootpluginを追加するだけで、
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ファイルの指定(ディレクトリ不要)
-plaintexttlsでない時に指定
-djson形式でリクエストを指定

メトリクス

サーバのメトリクスは必要な依存さえ追加すれば自動で収集してくれます!

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.pysample_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();
    }

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です