Java实现gRPC完整实例:从proto设计到四种调用模式实战

发布时间:2026/11/2 2:52:12
Java实现gRPC完整实例:从proto设计到四种调用模式实战

gRPC 这几年在微服务和跨语言通信里出现得越来越频繁,很多团队从 REST 切过来,第一道坎就是"接口怎么定义、代码怎么生成、服务怎么跑起来"。我见过不少同学照着文档抄了一遍 proto,编译能过,但一到联调就卡在序列化、超时、流式调用这些细节上。这篇就围绕 Java 实现 gRPC 的完整实例展开,从 proto 契约设计、Maven 工程搭建、四种调用模式的代码落地,到实际跑起来之后那些文档里不写的坑,全部摊开讲。适合已经会写 Java、想快速把 gRPC 跑通并理解其工作机制的开发者,也适合正在做技术选型、想搞清楚 gRPC 相比传统 HTTP 接口到底强在哪的同学。下面所有代码都是可复现的最小实例,你可以直接照着搭一遍。

1. 先把 gRPC 的通信模型想明白再动手

1.1 为什么不是"又一个 RPC 框架"

很多人第一次接触 gRPC,会下意识把它当成 Dubbo 或者 Feign 的替代品,觉得无非是换个调用方式。这个理解会让你在后面踩坑。gRPC 的本质是"用 IDL 定义契约 + 用 HTTP/2 承载 + 用 Protobuf 序列化"这三件事的组合,三者缺一不可,理解了这个组合,你才知道它为什么快、为什么支持流、为什么跨语言这么顺。

先说契约。传统 REST 接口的契约往往散落在文档、注解、甚至口头约定里,前端拿到的字段和后端返回的对不上是家常便饭。gRPC 强制你先写.proto文件,把消息结构和服务方法全部声明清楚,然后由工具生成强类型的客户端和服务端代码。这意味着接口一旦定义,双方拿到的类型是编译期就对齐的,字段名写错根本编译不过。这个约束在多人协作里价值极大,代价是你得先学会 proto 的语法。

再说传输。gRPC 默认跑在 HTTP/2 上,多路复用、头部压缩、服务端推送这些特性是它支持双向流的基础。你在一个连接上可以同时跑几十个请求,不用像 HTTP/1.1 那样排队。这一点在高并发场景下对连接数的节省非常明显,我实测过一个服务从 REST 迁到 gRPC 后,客户端连接池的压力下降了一个量级。

最后说序列化。Protobuf 是二进制编码,字段用编号标识而不是名字,所以体积比 JSON 小很多,解析也更快。但它的代价是可读性差,抓包看到的是一堆字节,调试时得靠工具反解。这个取舍你要提前有心理准备。

1.2 四种调用模式分别解决什么问题

gRPC 定义了四种服务方法,这是它区别于普通 RPC 的核心能力,选型时想清楚用哪种很关键:

模式proto 关键字典型场景注意事项
一元调用无普通请求响应,如查用户信息最常用,和 REST 体验接近
服务端流stream在返回值服务端推送大量数据,如日志拉取客户端要主动消费完或取消
客户端流stream在参数批量上传,如分片传文件服务端要处理半关闭时机
双向流两边都stream实时对话、协同编辑流控和生命周期管理最复杂

一元调用就是"发一个请求收一个响应",和平时写接口没区别,入门先跑通它。服务端流适合"一次请求、多次响应"的场景,比如客户端要拉一批数据,服务端边查边推,不用等全部查完再返回。客户端流反过来,客户端分多次发,服务端最后汇总返回,典型是文件分片上传。双向流最灵活也最难,两边都能随时发消息,聊天室、实时协作都靠它,但你要自己管好流的关闭和异常。

我建议新手按"一元 → 服务端流 → 客户端流 → 双向流"的顺序逐个跑通,每跑通一个就理解一类问题,别一上来就啃双向流,容易劝退。

1.3 版本与依赖的选型逻辑

Java 侧实现 gRPC,绕不开三块依赖:grpc-netty-shaded(传输层)、grpc-protobuf(序列化)、grpc-stub(存根)。版本上要保证 gRPC、Protobuf、protoc 插件三者兼容,这是最容易出问题的地方。我的经验是:gRPC 用较新的稳定版(比如 1.6x 系列),Protobuf 跟着 gRPC 的依赖传递版本走,不要自己乱指定,否则会出现生成代码和运行时类不匹配的NoSuchMethodError。

传输层为什么用grpc-netty-shaded而不是自己引 Netty?因为 shaded 版本把 Netty 的包名重定位了,避免和你项目里其他 Netty 版本冲突。这个细节很多人不知道,一旦项目里已经有别的组件依赖 Netty,不 shaded 就会打架。代价是 shaded 版本不能和原生 Netty 混用,你得统一。

构建工具用 Maven 还是 Gradle 都行,本文用 Maven,因为配置直观、排查方便。protoc 编译插件用protobuf-maven-plugin,它负责在编译期把.proto转成 Java 代码,省去手动执行 protoc 的麻烦。这里有个关键点:插件需要下载对应平台的 protoc 可执行文件,网络不通时会卡在下载阶段,这是新手第一个高频卡点,后面会专门讲怎么处理。

2. 从零搭一个能跑的 Maven 工程

2.1 目录结构怎么摆才不别扭

Maven 工程的标准结构里,.proto文件放哪是个常见疑问。官方推荐放在src/main/proto下,protobuf-maven-plugin默认会去这个目录找。生成的 Java 代码默认输出到target/generated-sources/protobuf,编译时自动加入源码路径,你不需要手动拷贝。

一个干净的结构长这样:

grpc-demo/ ├── pom.xml └── src/ └── main/ ├── proto/ │ └── demo.proto └── java/ └── com/example/demo/ ├── server/ └── client/

我见过有人把 proto 放在src/main/resources下,然后手动配插件路径,能跑但没必要,徒增维护成本。还有人把生成的代码提交到 Git,这更是大忌——生成物应该由构建过程产出,提交进去只会导致版本漂移和冲突。记住一条:proto 是源,生成代码是产物,产物不进版本库。

2.2 pom.xml 的关键配置逐段拆解

pom 里最容易配错的是插件部分,我把它拆成几段讲清楚每段在干什么。

先看依赖管理,用dependencyManagement统一版本,避免子模块版本打架:

<properties> <grpc.version>1.62.2</grpc.version> <protobuf.version>3.25.3</protobuf.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>io.grpc</groupId> <artifactId>grpc-bom</artifactId> <version>${grpc.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

用 BOM 的好处是 gRPC 那一堆子模块版本自动对齐,你只声明grpc-netty-shaded、grpc-protobuf、grpc-stub三个 artifact 就行,不用逐个写版本号。Protobuf 的版本要和 gRPC 传递依赖的版本一致,写高了低了都可能出问题,稳妥做法是先不写,让 gRPC 带进来,跑通后再看mvn dependency:tree确认实际版本。

再看编译插件,这是核心:

<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:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact> <pluginId>grpc-java</pluginId> <pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact> </configuration> <executions> <execution> <goals> <goal>compile</goal> <goal>compile-custom</goal> </goals> </execution> </executions> </plugin> </plugins> </build>

os-maven-plugin的作用是探测当前操作系统和架构,生成${os.detected.classifier}变量,比如linux-x86_64、windows-x86_64、osx-aarch_64。protoc 和 grpc 插件都是平台相关的可执行文件,靠这个变量才能下载到正确的版本。少了这个扩展,插件会报找不到 classifier 的错误,这是新手第二个高频卡点。

compile目标生成消息类(DemoRequest、DemoResponse这些),compile-custom目标配合pluginId=grpc-java生成服务存根(DemoServiceGrpc这个类)。两个 goal 都要,少一个就缺一半代码。

2.3 proto 文件怎么写才经得起演进

proto 语法本身不难,难的是写出"以后能改"的契约。先看一个覆盖四种模式的完整示例:

syntax = "proto3"; package demo; option java_package = "com.example.demo.proto"; option java_outer_classname = "DemoProto"; option java_multiple_files = true; message DemoRequest { string name = 1; int32 id = 2; } message DemoResponse { string message = 1; int32 code = 2; } service DemoService { rpc unaryCall(DemoRequest) returns (DemoResponse); rpc serverStream(DemoRequest) returns (stream DemoResponse); rpc clientStream(stream DemoRequest) returns (DemoResponse); rpc bidiStream(stream DemoRequest) returns (stream DemoResponse); }

几个关键选项解释一下。java_multiple_files = true让每个 message 生成独立的 Java 文件,而不是全部塞进一个外部类,代码更清晰,强烈建议开。java_package指定生成代码的包名,和 proto 的package是两回事,前者管 Java,后者管 proto 命名空间。java_outer_classname在multiple_files开启后作用减弱,但保留着没坏处。

字段编号(= 1、= 2)是 proto 演进的生命线。编号一旦发布就永远不能改,也不能复用。删字段时要把编号和名字用reserved标记,防止后人误用:

message DemoRequest { reserved 3, 5; reserved "old_field"; string name = 1; int32 id = 2; }

为什么这么严格?因为 Protobuf 的二进制编码里只存编号不存名字,如果旧客户端发来编号 3 的数据,新服务端把编号 3 解释成了别的字段,数据就错乱了。reserved就是给编译器和你自己立的警示牌。这个规矩我在一个项目里吃过亏:早期图省事删了个字段没 reserved,半年后新人复用了那个编号,线上出现偶发的字段错位,排查了两天才定位到,教训很深。

3. 四种调用模式的代码落地

3.1 一元调用:最像普通接口的那一种

先写服务端实现。生成的DemoServiceGrpc.DemoServiceImplBase是个抽象类,你继承它并覆盖对应方法:

public class DemoServiceImpl extends DemoServiceGrpc.DemoServiceImplBase { @Override public void unaryCall(DemoRequest request, StreamObserver<DemoResponse> responseObserver) { String msg = "hello, " + request.getName() + " (id=" + request.getId() + ")"; DemoResponse response = DemoResponse.newBuilder() .setMessage(msg) .setCode(200) .build(); responseObserver.onNext(response); responseObserver.onCompleted(); } }

这里有个和普通接口完全不同的地方:方法没有返回值,结果通过StreamObserver回写。onNext发一条响应,onCompleted表示结束。这个设计是为了统一四种模式的编程模型——一元调用本质上是"只发一条就结束的流"。新手常犯的错是忘了调onCompleted,客户端会一直挂着等,直到超时。

服务端启动代码:

public class GrpcServer { public static void main(String[] args) throws IOException, InterruptedException { Server server = ServerBuilder.forPort(9090) .addService(new DemoServiceImpl()) .build() .start(); System.out.println("server started on 9090"); Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.shutdown(); })); server.awaitTermination(); } }

ServerBuilder.forPort指定端口,addService注册实现,start启动。awaitTermination阻塞主线程,否则 main 方法一结束进程就退了。加个 shutdown hook 是为了优雅停机,生产环境必须要有,不然重启时正在处理的请求会被硬切断。

客户端调用:

public class UnaryClient { public static void main(String[] args) { ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 9090) .usePlaintext() .build(); DemoServiceGrpc.DemoServiceBlockingStub stub = DemoServiceGrpc.newBlockingStub(channel); DemoResponse response = stub.unaryCall( DemoRequest.newBuilder().setName("world").setId(1).build()); System.out.println(response.getMessage()); channel.shutdown(); } }

usePlaintext()表示不加密,本地调试用,生产环境要去掉并配 TLS。newBlockingStub生成阻塞式存根,调用会同步等待结果。还有newStub(异步,基于回调)和newFutureStub(返回ListenableFuture),按场景选。channel是长连接,要复用,别每次调用都新建,用完记得shutdown。

3.2 服务端流:一次请求,多次响应

服务端流的实现里,onNext可以调多次:

@Override public void serverStream(DemoRequest request, StreamObserver<DemoResponse> responseObserver) { for (int i = 0; i < 5; i++) { DemoResponse response = DemoResponse.newBuilder() .setMessage("chunk-" + i + " for " + request.getName()) .setCode(200) .build(); responseObserver.onNext(response); } responseObserver.onCompleted(); }

客户端用阻塞存根拿到的是Iterator:

Iterator<DemoResponse> it = stub.serverStream( DemoRequest.newBuilder().setName("stream").build()); while (it.hasNext()) { System.out.println(it.next().getMessage()); }

这里有个坑:Iterator的hasNext会阻塞直到下一条数据到达或流结束。如果服务端迟迟不发也不结束,客户端就卡在那。所以服务端要保证要么持续发,要么及时onCompleted,别让流悬着。另外客户端如果中途不想收了,要主动取消,否则连接资源不释放。

3.3 客户端流:分片发送,最后汇总

客户端流的服务端方法收到的是一个"请求流",你要在onNext里累积,在onCompleted里返回结果:

@Override public StreamObserver<DemoRequest> clientStream(StreamObserver<DemoResponse> responseObserver) { return new StreamObserver<DemoRequest>() { private int count = 0; @Override public void onNext(DemoRequest value) { count++; } @Override public void onError(Throwable t) { System.err.println("client stream error: " + t.getMessage()); } @Override public void onCompleted() { responseObserver.onNext(DemoResponse.newBuilder() .setMessage("received " + count + " messages") .setCode(200).build()); responseObserver.onCompleted(); } }; }

注意返回的是StreamObserver<DemoRequest>,服务端通过它接收客户端发来的每一条。onCompleted是客户端发完所有数据后触发的,在这里做汇总返回。onError必须处理,否则异常会被吞掉。

客户端用异步存根:

CountDownLatch latch = new CountDownLatch(1); StreamObserver<DemoRequest> requestObserver = asyncStub.clientStream(new StreamObserver<DemoResponse>() { @Override public void onNext(DemoResponse value) { System.out.println("result: " + value.getMessage()); } @Override public void onError(Throwable t) { latch.countDown(); } @Override public void onCompleted() { latch.countDown(); } }); for (int i = 0; i < 3; i++) { requestObserver.onNext(DemoRequest.newBuilder().setName("part-" + i).build()); } requestObserver.onCompleted(); latch.await(5, TimeUnit.SECONDS);

CountDownLatch是为了等异步结果回来再退出 main,不然主线程跑完就结束了。这个模式在批量上传里很常见,但要注意:客户端发得太快、服务端处理不过来时,流控会反压,onNext可能阻塞,别在单线程里死循环猛发。

3.4 双向流:两边都能随时说话

双向流两边都是StreamObserver,服务端实现和客户端流类似,只是返回的 observer 里也能onNext给客户端发消息:

@Override public StreamObserver<DemoRequest> bidiStream(StreamObserver<DemoResponse> responseObserver) { return new StreamObserver<DemoRequest>() { @Override public void onNext(DemoRequest value) { responseObserver.onNext(DemoResponse.newBuilder() .setMessage("echo: " + value.getName()) .setCode(200).build()); } @Override public void onError(Throwable t) { System.err.println("bidi error: " + t.getMessage()); } @Override public void onCompleted() { responseObserver.onCompleted(); } }; }

客户端每发一条,服务端就回一条,形成对话。这个模式最灵活,但生命周期管理最麻烦:任何一方onCompleted后,另一方还能不能继续发?答案是单向关闭后,另一方向仍可继续,但实际用起来容易乱。我的建议是约定好关闭顺序,比如客户端发完先onCompleted,服务端处理完剩余消息再onCompleted,别两边同时关。

4. 跑起来之后才会遇到的真实问题

4.1 protoc 下载卡住与平台不匹配

第一次执行mvn compile,十有八九会卡在下载 protoc 上。插件会去 Maven 中央仓库拉protoc-3.25.3-linux-x86_64.exe这类平台包,网络不好时一直转圈。处理办法有两个:一是配好 Maven 镜像源,二是手动下载对应平台的 exe 放到本地仓库对应路径。手动放的时候路径要精确匹配com/google/protobuf/protoc/3.25.3/,文件名带 classifier,放错了插件还是找不到。

平台不匹配的报错更隐蔽,典型是os.detected.classifier探测成了linux-x86_64但实际是 ARM 机器,下载下来的 protoc 跑不起来,报Exec format error。这时候要确认os-maven-plugin的版本够新,老版本对 ARM 支持不好。我遇到过在 ARM 开发机上折腾半天,最后升级插件版本才解决。

4.2 生成代码找不到类的排查链路

编译过了但运行时报ClassNotFoundException: com.example.demo.proto.DemoRequest,这个问题的排查要按链路走:

  1. 先看target/generated-sources/protobuf下有没有生成文件。没有说明插件没执行,检查mvn compile日志里 protobuf 插件那段。
  2. 有文件但 IDE 标红,说明 IDE 没把生成目录加入源码路径。IDEA 里右键target/generated-sources/protobuf标记为 Generated Sources Root,或者执行mvn idea:idea/ 重新导入。
  3. 文件在、IDE 也认,但运行时报错,多半是打包时没把生成类打进去。检查maven-compiler-plugin的source目录配置,确保生成目录被包含。

这个链路我建议背下来,因为换台机器、换个 IDE 就可能复现,按顺序查比瞎试快得多。

4.3 超时、连接与流控的默认值陷阱

gRPC 的默认超时是"无超时",也就是调用方不设 deadline 就一直等。这在生产环境是灾难,一个下游卡住会把上游线程池拖垮。正确做法是每次调用都设 deadline:

stub.withDeadlineAfter(3, TimeUnit.SECONDS).unaryCall(request);

连接层面,ManagedChannel默认会保持长连接并自动重连,但idleTimeout默认是 30 分钟,长时间不用的连接会被回收,下次调用重新建连会有延迟。高频服务可以调小或调大这个值,按实际流量来。

流控是另一个隐形坑。HTTP/2 有窗口机制,接收方处理慢时窗口不释放,发送方就会被反压。一元调用感知不明显,流式调用里如果客户端消费慢,服务端onNext会阻塞。解决办法是客户端及时消费,或者用ServerCallStreamObserver的isReady判断再发。这个细节文档里一笔带过,实际写流式代码时必须考虑。

4.4 序列化兼容与字段演进的血泪教训

前面提过字段编号不能复用,这里补充一个更隐蔽的:改字段类型。比如把int32改成string,编译能过,但线上新旧版本混跑时数据会解析错乱。Protobuf 允许部分类型兼容转换(比如int32和int64之间),但跨大类(数字和字符串)绝对不行。演进原则是:只加不改,要改就加新字段、废弃旧字段。

还有个坑是optional和默认值。proto3 里标量字段没有"是否设置"的概念,int32不设就是 0,你分不清是用户传了 0 还是没传。需要区分时用optional关键字(proto3 较新版本支持)或者用google.protobuf.Int32Value这类包装类型。这个区别在业务判断里很关键,比如"价格未填"和"价格为 0"是两回事。

5. 让实例更接近生产环境的几个改造

5.1 拦截器统一处理日志与鉴权

裸跑通之后,下一步是把横切逻辑抽出来。gRPC 的拦截器(ServerInterceptor/ClientInterceptor)就是干这个的。服务端拦截器可以统一打日志、校验 token、统计耗时:

public class LogInterceptor implements ServerInterceptor { @Override public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall( ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) { long start = System.currentTimeMillis(); ServerCall.Listener<ReqT> listener = next.startCall(call, headers); return new SimpleForwardingServerCallListener<>(listener) { @Override public void onComplete() { System.out.println(call.getMethodDescriptor().getFullMethodName() + " cost " + (System.currentTimeMillis() - start) + "ms"); super.onComplete(); } }; } }

注册时用ServerBuilder.intercept(new LogInterceptor())。鉴权类似,从Metadata里取 header 校验,不通过就call.close(Status.UNAUTHENTICATED, new Metadata())直接拒绝。拦截器链的顺序要注意,先注册的先执行,鉴权应该放在日志前面还是后面,取决于你想不想记录被拒绝的请求。

5.2 用 TLS 替换明文传输

usePlaintext()只适合本地。生产环境要配 TLS,服务端用证书,客户端信任对应 CA。简化版配置:

Server server = ServerBuilder.forPort(9090) .addService(new DemoServiceImpl()) .sslContext(GrpcSslContexts.forServer(certFile, keyFile).build()) .build();

客户端对应ManagedChannelBuilder.forAddress(...).sslContext(...)或直接用useTransportSecurity()配合系统信任链。证书管理是个大话题,团队里通常有统一的证书签发流程,这里不展开,但要记住:明文传输的 gRPC 在内网也建议加密,因为二进制协议一旦被抓包,反解出来的信息比 JSON 更完整。

5.3 健康检查与优雅停机的落地

生产服务要能被探活。gRPC 有标准的健康检查协议,引入grpc-services依赖后注册HealthStatusManager:

HealthStatusManager health = new HealthStatusManager(); Server server = ServerBuilder.forPort(9090) .addService(new DemoServiceImpl()) .addService(health.getHealthService()) .build() .start(); health.setStatus("", HealthCheckResponse.ServingStatus.SERVING);

优雅停机则是先server.shutdown()停止接收新请求,等已有请求处理完(awaitTermination带超时),超时再shutdownNow()强制关。这个顺序不能反,直接shutdownNow会切断正在处理的请求,客户端收到UNAVAILABLE。

6. 一些踩过之后才明白的经验

第一个经验关于 proto 的组织。项目大了之后,所有 proto 塞一个文件会失控,要按领域拆分,用import互相引用。但 import 的路径是相对 proto 根目录的,配错路径编译报错很常见。建议在 pom 里显式配<protoSourceRoot>,保持目录结构和 package 一致,减少心智负担。

第二个经验关于 stub 的复用。ManagedChannel是重量级对象,要全局复用;stub 是轻量的,可以每次 new,但withDeadlineAfter这类方法返回的是新 stub,原 stub 不变,别指望调一次就全局生效。我见过有人在循环里反复withDeadlineAfter,其实每次都是新对象,没意义。

第三个经验关于错误处理。gRPC 的错误通过Status传递,服务端用responseObserver.onError(Status.INVALID_ARGUMENT.withDescription("...").asRuntimeException())抛,客户端从StatusRuntimeException里取getStatus().getCode()判断。别用普通异常往外抛,那样客户端只能拿到UNKNOWN,丢失了错误语义。

第四个经验关于调试。二进制协议不好抓包,推荐在客户端和服务端都开拦截器打日志,或者用支持 gRPC 的调试工具看请求响应。线上排查问题时,Status的 description 字段要写清楚,这是你唯一能传给对方排查线索的地方,别只写"出错了"。

最后说个性能上的体会:gRPC 的优势在高频、低延迟、多语言的场景,如果你的接口是低频的、返回大段 HTML 的、或者团队完全不想学 proto,那 REST 更合适。技术选型没有银弹,把 gRPC 用在它擅长的地方,收益才明显。我一般建议新项目里对内的核心服务通信用 gRPC,对外的开放接口仍用 REST,两者并存,各取所长。