マイクロサービスの内部通信を組んでいると、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>
.proto は src/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の findById は Optional<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 コードでエラーを表現します。前掲のサーバ実装では orElseThrow で Status.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 で送信前にトークンを付与します。ポイントは、interceptCall は ClientCall を返す必要があり、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まで持ち込まず OpenFeign や HTTP 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本動かして、そこからストリーミングや認証を足していくのがおすすめです。