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.version1.62.2/grpc.version protobuf.version3.25.3/protobuf.version /properties dependencyManagement dependencies dependency groupIdio.grpc/groupId artifactIdgrpc-bom/artifactId version${grpc.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement用 BOM 的好处是 gRPC 那一堆子模块版本自动对齐你只声明grpc-netty-shaded、grpc-protobuf、grpc-stub三个 artifact 就行不用逐个写版本号。Protobuf 的版本要和 gRPC 传递依赖的版本一致写高了低了都可能出问题稳妥做法是先不写让 gRPC 带进来跑通后再看mvn dependency:tree确认实际版本。再看编译插件这是核心build extensions extension groupIdkr.motd.maven/groupId artifactIdos-maven-plugin/artifactId version1.7.1/version /extension /extensions plugins plugin groupIdorg.xolstice.maven.plugins/groupId artifactIdprotobuf-maven-plugin/artifactId version0.6.1/version configuration protocArtifactcom.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}/protocArtifact pluginIdgrpc-java/pluginId pluginArtifactio.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}/pluginArtifact /configuration executions execution goals goalcompile/goal goalcompile-custom/goal /goals /execution /executions /plugin /plugins /buildos-maven-plugin的作用是探测当前操作系统和架构生成${os.detected.classifier}变量比如linux-x86_64、windows-x86_64、osx-aarch_64。protoc 和 grpc 插件都是平台相关的可执行文件靠这个变量才能下载到正确的版本。少了这个扩展插件会报找不到 classifier 的错误这是新手第二个高频卡点。compile目标生成消息类DemoRequest、DemoResponse这些compile-custom目标配合pluginIdgrpc-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, StreamObserverDemoResponse 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, StreamObserverDemoResponse 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(); }客户端用阻塞存根拿到的是IteratorIteratorDemoResponse 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 StreamObserverDemoRequest clientStream(StreamObserverDemoResponse responseObserver) { return new StreamObserverDemoRequest() { 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(); } }; }注意返回的是StreamObserverDemoRequest服务端通过它接收客户端发来的每一条。onCompleted是客户端发完所有数据后触发的在这里做汇总返回。onError必须处理否则异常会被吞掉。客户端用异步存根CountDownLatch latch new CountDownLatch(1); StreamObserverDemoRequest requestObserver asyncStub.clientStream(new StreamObserverDemoResponse() { 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 StreamObserverDemoRequest bidiStream(StreamObserverDemoResponse responseObserver) { return new StreamObserverDemoRequest() { 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这个问题的排查要按链路走先看target/generated-sources/protobuf下有没有生成文件。没有说明插件没执行检查mvn compile日志里 protobuf 插件那段。有文件但 IDE 标红说明 IDE 没把生成目录加入源码路径。IDEA 里右键target/generated-sources/protobuf标记为 Generated Sources Root或者执行mvn idea:idea/ 重新导入。文件在、IDE 也认但运行时报错多半是打包时没把生成类打进去。检查maven-compiler-plugin的source目录配置确保生成目录被包含。这个链路我建议背下来因为换台机器、换个 IDE 就可能复现按顺序查比瞎试快得多。4.3 超时、连接与流控的默认值陷阱gRPC 的默认超时是无超时也就是调用方不设 deadline 就一直等。这在生产环境是灾难一个下游卡住会把上游线程池拖垮。正确做法是每次调用都设 deadlinestub.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.ListenerReqT interceptCall( ServerCallReqT, RespT call, Metadata headers, ServerCallHandlerReqT, RespT next) { long start System.currentTimeMillis(); ServerCall.ListenerReqT 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依赖后注册HealthStatusManagerHealthStatusManager 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两者并存各取所长。