マイクロサービスの内部通信を組んでいると、RESTだとオーバーヘッドが気になる場面が出てきますよね。JSONのシリアライズ、ヘッダの往復、スキーマがドキュメント頼りになりがちな点など、社内サービス間の高頻度な呼び出しでは地味に効いてきます。

そこで第3の選択肢として挙がるのが gRPC です。REST(Spring BootでREST APIのCRUDを実装する)やGraphQL(Spring BootでGraphQLを始める)とは根本的に異なるアプローチで、社内の低レイテンシ通信に強みがあります。この記事では依存構成から.protoの定義、サーバ・クライアント実装、エラー変換、インターセプタ、そして使い分けの判断まで一通り追いかけます。

gRPCとProtocol Buffersの基礎

gRPCは HTTP/2 上でバイナリをやり取りするRPCフレームワークです。ペイロードのシリアライズには Protocol Buffers(Protobuf)を使い、.proto というスキーマファイルがサーバとクライアントの契約になります。この契約からコードを生成するので、型安全でありながらペイロードも小さく、JSONベースのRESTより低レイテンシに寄せやすいのが特徴です。

通信方式は4種類あります。

  • unary(1リクエスト・1レスポンス、RESTに近い基本形)
  • server streaming(1リクエストに対して複数レスポンス)
  • client streaming(複数リクエストに対して1レスポンス)
  • bidirectional streaming(双方向)

この記事では最も使う unary を中心に進めます。まず押さえておきたいのは、スキーマ駆動である点です。.proto を共有すればサーバとクライアントで同じ型が生成されるので、フィールド追加や型変更の食い違いをコンパイル時に検知できます。

依存構成とビルド設定

この記事は Spring Boot 3.x / Java 17以上 を前提に、grpc-spring(net.devh)の 3.1.0.RELEASE、protoc 3.25.5、protoc-gen-grpc-java 1.68.0 で動作を確認しています。gRPC本体(grpc-java)は1.68系です。starterはメジャーバージョンで対応するSpring BootやgRPCが変わるので、導入時は自分の環境に合うバージョンをリリースノートで確認してから固定するのがおすすめです。

もう一点、混同しやすいのがstarterの構成です。net.devhのstarterは サーバ用の grpc-server-spring-boot-starterクライアント用の grpc-client-spring-boot-starter に分かれています。サーバもクライアントも1アプリで担うなら両方入れます。

<dependencies>
  <dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-server-spring-boot-starter</artifactId>
    <version>3.1.0.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-client-spring-boot-starter</artifactId>
    <version>3.1.0.RELEASE</version>
  </dependency>
</dependencies>

次に .proto からJavaコードを生成するプラグインです。protoc本体とgRPCプラグインを実行するため、protobuf-maven-plugin を設定します。

<build>
  <extensions>
    <extension>
      <groupId>kr.motd.maven</groupId>
      <artifactId>os-maven-plugin</artifactId>
      <version>1.7.1</version>
    </extension>
  </extensions>
  <plugins>
    <plugin>
      <groupId>org.xolstice.maven.plugins</groupId>
      <artifactId>protobuf-maven-plugin</artifactId>
      <version>0.6.1</version>
      <configuration>
        <protocArtifact>com.google.protobuf:protoc:3.25.5:exe:${os.detected.classifier}</protocArtifact>
        <pluginId>grpc-java</pluginId>
        <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.68.0:exe:${os.detected.classifier}</pluginArtifact>
      </configuration>
      <executions>
        <execution>
          <goals>
            <goal>compile</goal>
            <goal>compile-custom</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

.protosrc/main/proto に置きます。ビルドすると target/generated-sources/protobuf 以下にメッセージクラスとスタブが生成され、ソースセットとして自動で認識されます。Gradleを使う場合は com.google.protobuf プラグインで同等の設定ができます。IDEで生成物が見えないときは一度ビルドしてからGenerated Sourcesを再読み込みすると解決することが多いです。

.protoでサービスとメッセージを定義する

実装対象として、ユーザー情報を1件返すシンプルなサービスを定義します。

syntax = "proto3";

package user;

option java_package = "com.example.grpc.user";
option java_multiple_files = true;

service UserService {
  rpc GetUser (GetUserRequest) returns (UserResponse);
}

message GetUserRequest {
  int64 id = 1;
}

message UserResponse {
  int64 id = 1;
  string name = 2;
  string email = 3;
}

フィールドの = 1 = 2 はフィールド番号で、ワイヤ上の識別子になります。一度使った番号は変えない、というのが後方互換を保つコツです。java_multiple_files = true を付けると、メッセージごとに別ファイルで生成されて扱いやすくなります。文法の網羅はここでは踏み込みませんが、実装に必要な範囲はこれでほぼ足ります。ビルドを実行すると UserServiceGrpc というスタブ基底クラスと各メッセージクラスが生成されます。

@GrpcServiceでサーバを実装する

生成された UserServiceGrpc.UserServiceImplBase を継承し、RPCメソッドをオーバーライドします。クラスに @GrpcService を付けるとBeanとして登録され、自動的にgRPCサーバへ公開されます。

ここで注意したいのがリポジトリの戻り値です。Spring Data JPAの findByIdOptional<User> を返すので、orElseThrow で存在しないケースを分岐しておきます。この分岐が次のStatus変換にそのままつながります。

@GrpcService
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {

    private final UserRepository repository;

    public UserGrpcService(UserRepository repository) {
        this.repository = repository;
    }

    @Override
    public void getUser(GetUserRequest request,
                        StreamObserver<UserResponse> responseObserver) {
        User user = repository.findById(request.getId())
                .orElseThrow(() -> Status.NOT_FOUND
                        .withDescription("user not found: " + request.getId())
                        .asRuntimeException());

        UserResponse response = UserResponse.newBuilder()
                .setId(user.getId())
                .setName(user.getName())
                .setEmail(user.getEmail())
                .build();

        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
}

レスポンスは戻り値ではなく StreamObserver に渡します。onNext で結果を送り、onCompleted で完了を通知する流れです。

server streamingへの発展

unaryなら onNext は1回ですが、server streamingにしたい場合は .proto の戻り値を returns (stream UserResponse) にして、onNext をループで複数回呼んでから onCompleted を呼ぶだけです。まずはunaryに慣れておけば、ストリーミングは自然に拡張できます。

@GrpcClientでクライアントから呼び出す

別サービスから呼び出す側は @GrpcClient でスタブを注入します。接続先チャネルの設定は application.properties に書きます。

# サーバ側のポート
grpc.server.port=9090

# クライアント側の接続先("user-service" は論理名)
grpc.client.user-service.address=static://localhost:9090
grpc.client.user-service.negotiation-type=plaintext

negotiation-type は開発中なら plaintext で十分ですが、本番ではTLSを有効にします。次にクライアント実装です。

@Service
public class UserClient {

    @GrpcClient("user-service")
    private UserServiceGrpc.UserServiceBlockingStub stub;

    public UserResponse fetchUser(long id) {
        GetUserRequest request = GetUserRequest.newBuilder()
                .setId(id)
                .build();
        return stub.getUser(request);
    }
}

BlockingStub は同期呼び出しで、REST感覚で書けるのでまずはこれが扱いやすいです。非同期にしたいときは UserServiceGrpc.UserServiceFutureStub を注入すると ListenableFuture<UserResponse> を受け取れます。複数サービスへの呼び出しを並行させたい、待ち時間を詰めたいという場面ではFutureStub側を選ぶ、という使い分けになります。

例外ハンドリングとgRPC Statusへの変換

gRPCはHTTPステータスではなく、独自の Status コードでエラーを表現します。前掲のサーバ実装では orElseThrowStatus.NOT_FOUND を投げていました。これがgRPCのエラー表現の基本形です。

ただ、毎回メソッド内で try/catch や分岐を書くのは煩雑です。grpc-spring-boot-starterの @GrpcExceptionHandler を使うと、Spring MVCの @ExceptionHandler のように横断的に変換できます。

@GrpcAdvice
public class GrpcExceptionAdvice {

    @GrpcExceptionHandler(UserNotFoundException.class)
    public StatusRuntimeException handleNotFound(UserNotFoundException e) {
        return Status.NOT_FOUND
                .withDescription(e.getMessage())
                .asRuntimeException();
    }
}

こうしておけば、サービス実装側はドメインの UserNotFoundException を素直に投げるだけで済み、Status変換のロジックが一箇所に集まります。エラーの追加情報を渡したいときは Metadata に載せて送れます。クライアント側では StatusRuntimeException を捕捉し、コードで分岐します。

try {
    return stub.getUser(request);
} catch (StatusRuntimeException e) {
    if (e.getStatus().getCode() == Status.Code.NOT_FOUND) {
        return null;
    }
    throw e;
}

NOT_FOUND INVALID_ARGUMENT UNAUTHENTICATED あたりを押さえておくと、RESTの404/400/401に対応づけて考えられます。

インターセプタで認証・ロギングを差し込む

認証やロギングのような横断処理は、各メソッドに書かず ServerInterceptor にまとめます。サーバ側でMetadataからトークンを取り出して検証する例です。

@GrpcGlobalServerInterceptor
public class AuthServerInterceptor implements ServerInterceptor {

    private static final Metadata.Key<String> TOKEN_KEY =
            Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call, Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        String token = headers.get(TOKEN_KEY);
        if (token == null || !isValid(token)) {
            call.close(Status.UNAUTHENTICATED
                    .withDescription("invalid token"), new Metadata());
            return new ServerCall.Listener<>() {};
        }
        return next.startCall(call, headers);
    }
}

@GrpcGlobalServerInterceptor を付けると全サービスへ一括適用されます。ロギングや処理時間の計測も、interceptCall の前後で時刻を測ればここに集約できます。

クライアント側は ClientInterceptor で送信前にトークンを付与します。ポイントは、interceptCallClientCall を返す必要があり、Metadataへの書き込みは実際に送信が始まる start のタイミングで行うことです。ForwardingClientCall.SimpleForwardingClientCall を継承して start をオーバーライドします。

@GrpcGlobalClientInterceptor
public class TokenClientInterceptor implements ClientInterceptor {

    private static final Metadata.Key<String> TOKEN_KEY =
            Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);

    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
            MethodDescriptor<ReqT, RespT> method, CallOptions options, Channel channel) {

        ClientCall<ReqT, RespT> call = channel.newCall(method, options);
        return new ForwardingClientCall.SimpleForwardingClientCall<>(call) {
            @Override
            public void start(Listener<RespT> responseListener, Metadata headers) {
                headers.put(TOKEN_KEY, "Bearer " + currentToken());
                super.start(responseListener, headers);
            }
        };
    }
}

start の中で headers.put(...) してから super.start(...) を呼ぶことで、実際に送信されるリクエストにトークンが載ります。個別のサービスにだけ効かせたい場合は、グローバルではなく @GrpcClient("user-service", interceptors = ...) のように登録範囲を絞れます。

REST・GraphQL・gRPCの使い分け

最後に判断軸を整理しておきます。gRPCが向くのは、社内マイクロサービス間の低レイテンシ・高頻度な通信で、スキーマ駆動の型安全を効かせたいケースです。.proto が契約になるので、チーム間のインターフェース合意も取りやすくなります。

一方、ブラウザから直接叩く外部公開APIには不向きで、そこはRESTかGraphQLが素直です。シンプルなCRUDの公開ならREST、クライアントが必要なフィールドを柔軟に選びたい・複数リソースを1リクエストで集約したいならGraphQLが有利です。

サービス間のHTTP呼び出しを型安全に書きたいだけなら、gRPCまで持ち込まず OpenFeignHTTP Interface で宣言的クライアントにする選択肢もあります。プロトコルをHTTP/1.1のまま保ちたい、既存のREST資産を活かしたいならこちらが軽量です。gRPCは「低レイテンシとスキーマ駆動を両取りしたい内部通信」に絞って投入する、と考えるとブレません。

まとめ

gRPCはHTTP/2とProtobufによる型安全で軽量なRPCで、サーバ用のgrpc-server-spring-boot-starterとクライアント用のgrpc-client-spring-boot-starterを使えばSpring Bootらしく @GrpcService @GrpcClient で実装できます。.proto を契約にコード生成し、Status でエラーを表し、インターセプタで認証やロギングを横串にする、という流れが掴めれば導入のハードルはぐっと下がります。まずはunaryのサービスを1本動かして、そこからストリーミングや認証を足していくのがおすすめです。