gRPC 这几年在微服务和跨语言通信场景里出现的频率越来越高,很多团队在服务拆分、内部接口对齐、移动端与后端联调时都会优先考虑它。但真到动手写的时候,不少人会卡在几个地方:proto 文件怎么组织、Maven 插件怎么配、同步和异步调用怎么选、流式接口到底怎么用。这篇内容就围绕一个完整的 Java 版 gRPC 实例展开,把从环境准备到服务端实现、客户端调用、再到常见问题排查的整条链路讲清楚,附带可直接运行的源码结构。不管你是刚接触 RPC 框架的新手,还是想把手上的 REST 接口迁移到 gRPC 的老手,都能从里面找到能直接抄作业的部分。
1. 整体设计与技术选型思路
1.1 为什么这个实例选择 gRPC 而不是 REST
先说说选型这件事。很多人一上来就问“gRPC 和 REST 到底选哪个”,其实这个问题没有标准答案,得看场景。我这个实例模拟的是一个内部服务之间高频调用的场景:一个订单服务需要频繁查询用户信息、扣减库存、推送状态变更。这种场景有几个特点——调用量大、数据结构固定、对延迟敏感、需要双向实时通知。REST 在这种场景下会暴露几个短板:JSON 序列化体积大、HTTP/1.1 的队头阻塞、接口契约靠文档维护容易漂移。
gRPC 恰好在这几点上都有针对性设计。它用 Protocol Buffers 做序列化,二进制编码比 JSON 紧凑得多,实测同样的数据结构体积能压到三分之一左右;底层跑在 HTTP/2 上,多路复用天然支持,一个连接上可以并发跑多个请求;接口契约直接写在.proto文件里,服务端和客户端都从同一份文件生成代码,从根本上杜绝了“文档和实现不一致”的问题。再加上它原生支持四种通信模式——一元调用、服务端流、客户端流、双向流,覆盖了我这个实例里“状态推送”这种需要服务端主动推数据的场景。
当然 gRPC 也不是没有代价。它的可读性不如 REST,抓包调试没那么直观,浏览器直接调用也需要额外的代理层。所以我的建议是:对外暴露的接口用 REST,内部服务间的高频调用用 gRPC,两者并不冲突,很多团队就是这么混用的。这个实例聚焦在内部调用这一块。
1.2 实例的整体架构与模块划分
这个实例我设计成三个 Maven 模块,结构清晰,方便你直接拆到自己项目里用:
- grpc-api 模块:只放
.proto文件和生成的 Java 代码,被服务端和客户端共同依赖。这样做的原因是契约要单一来源,谁都不能私自改接口。 - grpc-server 模块:服务端实现,负责启动 gRPC 服务、注册具体的服务实现类。
- grpc-client 模块:客户端调用示例,演示一元调用和流式调用两种方式。
技术栈上,我用的是 JDK 17、Maven 3.8、gRPC Java 1.60 左右的版本、protobuf 3.24。这几个版本搭配比较稳,网上资料也多。为什么不选更新的版本?因为 gRPC Java 在 1.6x 之后对某些依赖的版本要求变严,新手容易在依赖冲突上卡住,1.60 这个区间是社区验证过比较平滑的。
提示:模块划分不是必须的,你也可以做成单模块。但只要你打算长期维护,强烈建议把 proto 单独抽出来,否则服务端和客户端各存一份 proto,早晚会不同步。
1.3 通信模式的选择逻辑
gRPC 有四种通信模式,我这个实例里用到了其中三种,这里说清楚为什么这么选:
| 通信模式 | 适用场景 | 本实例中的用途 |
|---|---|---|
| 一元调用(Unary) | 请求-响应,一问一答 | 查询用户信息、创建订单 |
| 服务端流(Server Streaming) | 客户端发一次,服务端持续推多条 | 订单状态变更推送 |
| 客户端流(Client Streaming) | 客户端持续发多条,服务端汇总返回 | 批量上报日志 |
| 双向流(Bidirectional) | 双方都能持续收发 | 本实例未用,实时聊天类场景适用 |
选一元调用是因为大部分业务接口本质就是问答式的,没必要上流式增加复杂度。选服务端流是因为状态推送这个场景,客户端不可能一直轮询,服务端有变更就推,天然契合。客户端流用在批量上报,避免客户端发一百条日志就建一百次请求。双向流我没在这个实例里用,因为它对连接管理和背压处理要求更高,新手容易踩坑,等前面三种玩熟了再上不迟。
2. 环境准备与 proto 契约设计
2.1 开发环境与依赖版本锁定
动手之前先把环境对齐,这一步偷懒后面全是坑。我列一下这个实例验证过的版本组合:
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <grpc.version>1.60.0</grpc.version> <protobuf.version>3.24.4</protobuf.version> <protoc.version>3.24.4</protoc.version> </properties>JDK 用 17 是因为 gRPC Java 新版本对 8 的支持在逐步弱化,17 是当前的 LTS,长期维护有保障。protobuf 和 protoc 版本必须一致,这点很多人忽略——protoc 编译器版本和运行时 protobuf 库版本不一致时,生成的代码可能编译不过或者运行时抛异常。我踩过一次,protoc 用的 3.21,运行时库是 3.24,结果生成的代码里引用了新版本才有的方法,编译直接报错,排查了半天才发现是版本错配。
Maven 插件这块,核心是两个:protobuf-maven-plugin负责把.proto编译成 Java 代码,os-maven-plugin负责识别当前操作系统架构,因为 protoc 编译器是分平台的。配置如下:
<plugin> <groupId>org.xolstice.maven.plugins</groupId> <artifactId>protobuf-maven-plugin</artifactId> <version>0.6.1</version> <configuration> <protocArtifact>com.google.protobuf:protoc:${protoc.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>compile目标生成消息类,compile-custom配合pluginId生成 gRPC 的服务桩代码。两个都要,少一个你就只有消息类没有服务类。
2.2 proto 文件的组织与命名规范
proto 文件我放在grpc-api/src/main/proto/目录下,这是 Maven 插件的默认扫描路径,放别处得额外配。文件命名上我用的是服务名.proto的格式,比如user_service.proto、order_service.proto,一个服务一个文件,别把所有服务塞一个文件里,后期维护会疯。
包名这块有个细节要注意:proto 里的package和 Java 的java_package是两回事。package是 protobuf 命名空间,防止不同 proto 文件里的消息重名;java_package才是生成 Java 代码的包路径。我一般让两者保持一致,减少心智负担:
syntax = "proto3"; package com.example.grpc.api; option java_package = "com.example.grpc.api"; option java_outer_classname = "UserServiceProto"; option java_multiple_files = true;java_multiple_files = true这个选项很关键。默认情况下所有消息类都塞在一个外部类里,类名就是java_outer_classname,用起来是UserServiceProto.UserRequest这种形式,很啰嗦。设成 true 之后每个消息独立成类,直接UserRequest就能用,清爽很多。java_outer_classname在设了 multiple_files 之后主要用来放 proto 文件本身的描述符,随便起个不冲突的名字就行。
2.3 消息结构定义与字段编号的讲究
先定义用户服务的消息。字段编号这事看着简单,其实有讲究:
message UserRequest { int64 user_id = 1; } message UserResponse { int64 user_id = 1; string username = 2; string email = 3; int32 age = 4; repeated string tags = 5; }字段编号一旦上线就不能改,这是 protobuf 的硬规矩。因为二进制编码里存的是编号不是字段名,你把username = 2改成username = 6,老客户端发过来的数据里编号 2 对应的还是用户名,新服务端按编号 6 去解析,直接读空。要废弃某个字段,正确做法是把编号用reserved标记保留,防止后人复用:
message UserResponse { reserved 6, 7; reserved "old_field"; }编号分配上我习惯 1-15 留给高频字段,因为 protobuf 编码时 1-15 的编号只占一个字节的 tag,16 以上要占两个字节,高频字段用小编号能省点体积。这个优化在数据量大的时候有意义,小规模场景感知不明显,但养成习惯没坏处。
repeated关键字对应 Java 里的List,注意 proto3 里 repeated 字段默认是空列表不是 null,取值时不用判空,直接遍历就行。这点和 Java 对象字段的习惯不同,新手容易多写一层判空。
2.4 服务接口与流式方法声明
服务定义部分,我把三种通信模式都放进去:
service UserService { rpc GetUser(UserRequest) returns (UserResponse); rpc WatchUserStatus(UserRequest) returns (stream UserStatusEvent); rpc BatchReportLog(stream LogEntry) returns (BatchReportResult); }GetUser是一元调用,returns前面没有stream关键字。WatchUserStatus的返回值前有stream,表示服务端流——客户端发一个请求,服务端可以持续返回多条UserStatusEvent。BatchReportLog的参数前有stream,表示客户端流——客户端持续发LogEntry,服务端收完后返回一个汇总结果。
这里有个容易混淆的点:stream关键字的位置决定了流的方向。放在returns后面的括号里是服务端流,放在参数位置是客户端流,两边都放就是双向流。写的时候看清楚位置,写错了生成的桩代码方法签名完全不同。
3. 服务端实现与核心逻辑
3.1 服务实现类的骨架搭建
proto 编译完之后,target/generated-sources下会生成UserServiceGrpc这个类,里面有个UserServiceImplBase抽象类,服务端实现就继承它。骨架长这样:
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase { private final Map<Long, UserResponse> userStore = new ConcurrentHashMap<>(); @Override public void getUser(UserRequest request, StreamObserver<UserResponse> responseObserver) { // 一元调用实现 } @Override public void watchUserStatus(UserRequest request, StreamObserver<UserStatusEvent> responseObserver) { // 服务端流实现 } @Override public StreamObserver<LogEntry> batchReportLog(StreamObserver<BatchReportResult> responseObserver) { // 客户端流实现,注意返回值是 StreamObserver } }注意一元调用和服务端流的方法签名是void 方法名(请求, StreamObserver<响应>),而客户端流的方法签名是StreamObserver<请求> 方法名(StreamObserver<响应>)——它返回一个观察者,用来接收客户端陆续发来的消息。这个差异是新手最容易写错的地方,编译报错时先检查这里。
StreamObserver是 gRPC Java 的核心抽象,你可以把它理解成一个“回调出口”。服务端处理完业务逻辑后,通过responseObserver.onNext(响应对象)把结果推出去,然后调onCompleted()表示这次调用结束。如果出错,调onError(Throwable)。这三个方法构成了一次调用的完整生命周期,顺序不能乱,onCompleted和onError只能调其中一个且只能调一次。
3.2 一元调用的完整实现与线程模型
getUser的实现:
@Override public void getUser(UserRequest request, StreamObserver<UserResponse> responseObserver) { long userId = request.getUserId(); UserResponse user = userStore.get(userId); if (user == null) { responseObserver.onError( Status.NOT_FOUND .withDescription("用户不存在: " + userId) .asRuntimeException() ); return; } responseObserver.onNext(user); responseObserver.onCompleted(); }这段代码逻辑简单,但有几个点值得说。第一,错误处理用Status构造StatusRuntimeException,客户端收到后能拿到标准的 gRPC 状态码,比抛个普通异常友好得多。第二,onError之后必须return,不能再调onNext或onCompleted,否则会抛IllegalStateException。第三,userStore用的是ConcurrentHashMap,因为 gRPC 服务端默认用线程池处理请求,多个请求可能并发访问这个 Map,用普通HashMap会有线程安全问题。
线程模型这块展开说一下。gRPC Java 服务端默认的 executor 是缓存线程池,每个请求可能在不同线程上处理。这意味着你的服务实现类必须是线程安全的。我见过有人在实现类里放了个成员变量int counter做计数,并发一上来数据就乱了。正确做法是用AtomicInteger或者干脆把状态放到外部存储。这个实例里userStore用并发容器就是为了规避这个问题。
3.3 服务端流的推送实现与背压处理
watchUserStatus是服务端流,模拟的是用户状态变更推送:
@Override public void watchUserStatus(UserRequest request, StreamObserver<UserStatusEvent> responseObserver) { long userId = request.getUserId(); ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); AtomicInteger seq = new AtomicInteger(0); ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(() -> { try { if (seq.get() >= 5) { responseObserver.onCompleted(); scheduler.shutdown(); return; } UserStatusEvent event = UserStatusEvent.newBuilder() .setUserId(userId) .setStatus(seq.get() % 2 == 0 ? "ONLINE" : "BUSY") .setSequence(seq.incrementAndGet()) .setTimestamp(System.currentTimeMillis()) .build(); responseObserver.onNext(event); } catch (Exception e) { responseObserver.onError(Status.INTERNAL .withDescription("推送失败") .withCause(e) .asRuntimeException()); scheduler.shutdown(); } }, 0, 1, TimeUnit.SECONDS); }这里用定时任务每秒推一条,推 5 条后结束。实际业务里可能是监听某个消息队列或者数据库变更,逻辑换掉就行,骨架不变。
背压是流式接口绕不开的话题。所谓背压,就是消费端处理不过来时,生产端得放慢速度,否则消息在缓冲区堆积,内存迟早爆掉。gRPC Java 的流式接口本身有流量控制机制,底层基于 HTTP/2 的窗口,但应用层如果无节制地onNext,还是可能出问题。这个实例里我用固定条数(5 条)来规避,实际生产中如果推送量不可控,得自己加限流或者用ServerCallStreamObserver的isReady()方法判断:
ServerCallStreamObserver<UserStatusEvent> serverObserver = (ServerCallStreamObserver<UserStatusEvent>) responseObserver; if (serverObserver.isReady()) { serverObserver.onNext(event); }isReady()返回 false 表示缓冲区满了,这时候别硬推,等它变 true 再推。这个细节很多教程不讲,但真到高并发场景就是救命的东西。
注意:服务端流的方法里如果启动了额外线程(比如上面的定时任务),一定要在流结束或出错时把线程池关掉,否则线程泄漏,服务跑久了就 OOM。我见过一个线上事故就是流式接口没关线程池,跑了三天内存打满。
3.4 客户端流的聚合处理
batchReportLog是客户端流,客户端发多条日志,服务端收完返回汇总:
@Override public StreamObserver<LogEntry> batchReportLog(StreamObserver<BatchReportResult> responseObserver) { List<LogEntry> buffer = new ArrayList<>(); return new StreamObserver<LogEntry>() { @Override public void onNext(LogEntry entry) { buffer.add(entry); } @Override public void onError(Throwable t) { System.err.println("客户端流异常: " + t.getMessage()); } @Override public void onCompleted() { BatchReportResult result = BatchReportResult.newBuilder() .setTotalCount(buffer.size()) .setSuccessCount(buffer.size()) .build(); responseObserver.onNext(result); responseObserver.onCompleted(); } }; }这个模式的关键在于:方法本身立即返回一个StreamObserver,客户端通过这个观察者往里灌数据,每次onNext服务端就收到一条。等客户端调onCompleted,服务端在onCompleted回调里做汇总并返回结果。buffer用ArrayList是因为同一个流的回调在 gRPC 里是串行执行的,不会有并发问题,这点可以放心。
但要注意buffer的大小。如果客户端发一百万条日志,全堆在内存里照样爆。实际生产里我会设个上限,超过就分批处理或者落盘。这个实例为了演示简洁没做,但你抄的时候心里要有数。
3.5 服务启动与端口配置
服务启动类:
public class GrpcServerBootstrap { public static void main(String[] args) throws IOException, InterruptedException { Server server = ServerBuilder .forPort(9090) .addService(new UserServiceImpl()) .addService(new OrderServiceImpl()) .executor(Executors.newFixedThreadPool(20)) .build() .start(); System.out.println("gRPC 服务已启动,端口: " + server.getPort()); Runtime.getRuntime().addShutdownHook(new Thread(() -> { System.out.println("正在关闭 gRPC 服务..."); server.shutdown(); })); server.awaitTermination(); } }端口用 9090 是惯例,避开 HTTP 的 8080。executor我显式指定了固定 20 线程的池子,而不是用默认的缓存线程池。原因是缓存线程池在突发流量下会无限创建线程,虽然空闲会回收,但峰值时线程数可能失控。固定线程池配合队列更可控,20 这个数根据你的业务 IO 密集程度调,IO 密集可以大点,CPU 密集就设成核数左右。
addShutdownHook这段别省。没有它,你 Ctrl+C 停服务时,正在处理的请求会被硬切断,客户端收到的是连接重置而不是正常的错误码。加了钩子之后,server.shutdown()会先停止接收新请求,等存量请求处理完再退出,优雅得多。
4. 客户端调用与流式交互
4.1 通道管理与连接复用
客户端第一步是建通道(Channel)。通道是 gRPC 连接的核心抽象,一个通道可以复用给多个服务桩,底层共享同一个 HTTP/2 连接:
ManagedChannel channel = ManagedChannelBuilder .forAddress("localhost", 9090) .usePlaintext() .build();usePlaintext()表示不加密,内网调试用。生产环境要换成 TLS,用useTransportSecurity()配合证书。这里提醒一句,usePlaintext在公网环境绝对不能用,数据裸奔。
通道是重量级对象,创建一次复用,别每次调用都 new 一个。我见过有人在方法里每次调用都建通道,跑一会儿就报“too many open files”,因为通道底层是 socket 连接,建多了文件描述符耗尽。正确做法是把通道做成单例或者用依赖注入管理,应用关闭时调channel.shutdown()释放。
4.2 一元调用的同步与异步写法
拿到通道后生成桩(Stub),gRPC 提供两种桩:阻塞式(BlockingStub)和异步式(Stub):
UserServiceGrpc.UserServiceBlockingStub blockingStub = UserServiceGrpc.newBlockingStub(channel); UserRequest request = UserRequest.newBuilder().setUserId(1001L).build(); UserResponse response = blockingStub.getUser(request); System.out.println("用户名: " + response.getUsername());阻塞式桩写起来像普通方法调用,简单直接,适合命令行工具或者非高并发场景。但它的缺点是调用线程会被挂起,在 Web 请求处理线程里用阻塞桩,并发一高线程池就耗尽了。
异步桩适合高并发:
UserServiceGrpc.UserServiceStub asyncStub = UserServiceGrpc.newStub(channel); asyncStub.getUser(request, new StreamObserver<UserResponse>() { @Override public void onNext(UserResponse response) { System.out.println("异步收到: " + response.getUsername()); } @Override public void onError(Throwable t) { System.err.println("调用失败: " + t.getMessage()); } @Override public void onCompleted() { System.out.println("调用完成"); } });异步桩的回调在 gRPC 的内部线程上执行,别在回调里做耗时操作,否则会阻塞其他请求的处理。要处理复杂逻辑,把数据丢到自己的业务线程池里。
4.3 服务端流的消费方式
消费服务端流,用异步桩:
asyncStub.watchUserStatus(request, new StreamObserver<UserStatusEvent>() { @Override public void onNext(UserStatusEvent event) { System.out.printf("收到推送: 用户=%d 状态=%s 序号=%d%n", event.getUserId(), event.getStatus(), event.getSequence()); } @Override public void onError(Throwable t) { System.err.println("流异常: " + t.getMessage()); } @Override public void onCompleted() { System.out.println("推送流结束"); } });服务端每推一条,客户端的onNext就被调一次,直到服务端调onCompleted。这个模式适合订阅类的场景,比如配置变更通知、订单状态跟踪。要注意的是,流是长连接,客户端得处理断线重连。gRPC 本身不会自动重连流,断了就是断了,得在onError里判断错误类型,可重试的(比如网络抖动)就重新发起订阅。这个重连逻辑得自己写,别指望框架包办。
4.4 客户端流的发送与结束控制
客户端流用异步桩,但调用方式不同——它返回一个请求观察者:
StreamObserver<BatchReportResult> responseObserver = new StreamObserver<BatchReportResult>() { @Override public void onNext(BatchReportResult result) { System.out.println("汇总结果: 总数=" + result.getTotalCount()); } @Override public void onError(Throwable t) { System.err.println("上报失败: " + t.getMessage()); } @Override public void onCompleted() { System.out.println("上报完成"); } }; StreamObserver<LogEntry> requestObserver = asyncStub.batchReportLog(responseObserver); for (int i = 0; i < 10; i++) { LogEntry entry = LogEntry.newBuilder() .setLevel("INFO") .setMessage("日志内容 " + i) .setTimestamp(System.currentTimeMillis()) .build(); requestObserver.onNext(entry); } requestObserver.onCompleted();关键在最后两行:发完所有数据后必须调requestObserver.onCompleted(),服务端才会触发汇总逻辑并返回结果。忘了调这个,服务端会一直等,客户端也收不到响应,最后超时。这个坑我踩过,调试了半天以为是网络问题,其实是自己忘了结束流。
发送过程中如果出错,调requestObserver.onError(t)通知服务端中止。注意onNext和onCompleted之间不能穿插onError,一个流只能以一种方式结束。
5. 常见问题排查与避坑实录
5.1 编译期问题速查
proto 编译阶段的问题占了新手踩坑的一大半,我整理成表:
| 现象 | 原因 | 解决 |
|---|---|---|
找不到UserServiceGrpc类 | 没配compile-custom目标 | 检查插件 executions 里两个 goal 都在 |
| protoc 执行报平台不匹配 | 缺os-maven-plugin | 加上 os 插件,用${os.detected.classifier} |
| 生成的代码编译报方法不存在 | protoc 与运行时库版本不一致 | 两者版本号对齐 |
| proto 文件没被扫描到 | 不在默认目录 | 放src/main/proto或配protoSourceRoot |
还有一个隐蔽的:IDEA 里代码标红但 Maven 命令行能编译。这通常是 IDEA 没把target/generated-sources标记成源码目录。解决办法是在 Maven 面板里点一下“Reload All Maven Projects”,或者手动在项目结构里把生成目录标记为 Sources Root。
5.2 运行期典型异常与定位思路
运行期最常见的是UNAVAILABLE和DEADLINE_EXCEEDED两类错误。UNAVAILABLE一般是连不上服务端——端口没起、防火墙挡了、地址写错了。排查顺序:先telnet 主机 端口看通不通,通了再看服务端日志有没有启动成功,都正常再检查客户端通道地址。
DEADLINE_EXCEEDED是超时。gRPC 默认没有超时,意味着调用可能无限等待。生产环境一定要设 deadline:
blockingStub.withDeadlineAfter(3, TimeUnit.SECONDS).getUser(request);3 秒这个值根据业务定,查询类可以短点,写操作长点。设了 deadline 之后,超时客户端会收到DEADLINE_EXCEEDED,服务端那边如果还在处理,结果会被丢弃。这里有个细节:deadline 是端到端的,从客户端发起那一刻算起,包括网络传输和服务端处理时间,不是只算服务端处理时间。
5.3 流式接口的常见故障
流式接口的坑比一元调用多。第一个典型问题是“流卡住不结束”。原因通常是服务端忘了调onCompleted,或者客户端忘了调requestObserver.onCompleted。排查时在两端都打日志,看onCompleted有没有被触发。
第二个问题是“流被意外取消”。客户端主动取消(比如用户关了页面)会触发服务端的onError,错误类型是CANCELLED。服务端在onError里要把占用的资源释放掉,比如关线程池、取消订阅。不处理的话资源泄漏。
第三个是消息乱序。gRPC 保证同一个流内的消息有序,但如果你在服务端用多线程并发onNext,顺序就乱了。StreamObserver不是线程安全的,多线程写同一个观察者会出问题。要么单线程写,要么自己加锁。
实操心得:调试流式接口时,我会在客户端和服务端的每个回调里都打上带时间戳的日志,包括
onNext、onError、onCompleted。流的问题靠猜很难定位,日志一铺开,哪个环节断了清清楚楚。
5.4 性能调优的几个关键参数
服务端和客户端都有几个参数值得调。服务端ServerBuilder可以设maxInboundMessageSize,默认 4MB,超过就报RESOURCE_EXHAUSTED。如果你的消息体大(比如传文件分片),得调大:
ServerBuilder.forPort(9090) .maxInboundMessageSize(16 * 1024 * 1024)客户端通道也有对应设置,两边要匹配,客户端设小了照样收不下。
连接保活参数keepAliveTime和keepAliveTimeout用于检测死连接。内网环境可以设短点,比如 30 秒发一次心跳,10 秒没响应就判定连接失效。但别设太激进,太频繁的心跳包反而增加负担,还可能被对端判定为异常流量。
线程池大小前面提过,再强调一次:服务端 executor 的线程数要根据业务类型定。纯内存计算设成 CPU 核数加一就行,有数据库或远程调用的 IO 等待,可以设成核数的几倍。设太小请求排队,设太大线程切换开销大,得压测找平衡点。
6. 源码结构与复现指引
6.1 完整目录结构说明
把整个实例的目录结构列出来,方便你对照搭建:
grpc-demo/ ├── pom.xml # 父 POM,管理版本 ├── grpc-api/ │ ├── pom.xml │ └── src/main/proto/ │ ├── user_service.proto │ └── order_service.proto ├── grpc-server/ │ ├── pom.xml │ └── src/main/java/com/example/grpc/server/ │ ├── GrpcServerBootstrap.java │ ├── UserServiceImpl.java │ └── OrderServiceImpl.java └── grpc-client/ ├── pom.xml └── src/main/java/com/example/grpc/client/ ├── UnaryClientDemo.java ├── ServerStreamClientDemo.java └── ClientStreamClientDemo.java父 POM 里用dependencyManagement统一管理 gRPC 和 protobuf 的版本,子模块引用时不写版本号,避免版本漂移。grpc-api模块的 POM 里配 protobuf 插件,grpc-server和grpc-client依赖grpc-api,同时引入grpc-netty-shaded、grpc-protobuf、grpc-stub三个运行时依赖。
6.2 从零跑通的步骤清单
按这个顺序操作,基本不会卡:
- 建父工程,配好
properties和dependencyManagement。 - 建
grpc-api模块,写 proto 文件,配 protobuf 插件,执行mvn compile确认生成代码。 - 建
grpc-server模块,依赖grpc-api,写服务实现和启动类,mvn exec:java跑起来。 - 建
grpc-client模块,依赖grpc-api,写调用示例,跑起来看输出。 - 先测一元调用,通了再测服务端流,最后测客户端流。
这个顺序的道理是先验证契约生成没问题,再验证服务端能起,最后验证客户端能连。每一步都是下一步的前提,出问题容易定位。反过来先写客户端再写服务端,连不上时你分不清是客户端写错了还是服务端没起。
6.3 二次开发的扩展方向
这个实例是个骨架,实际项目里通常还要补几块。第一是拦截器(Interceptor),用来做统一鉴权、日志、链路追踪。gRPC 的拦截器机制和 Web 框架的过滤器类似,服务端用ServerInterceptor,客户端用ClientInterceptor,在ServerBuilder或通道构建时注册。
第二是错误码体系。gRPC 内置的状态码比较粗(NOT_FOUND、INVALID_ARGUMENT这些),业务错误需要更细的区分。常见做法是在Status的 metadata 里塞业务错误码,客户端解析 metadata 拿到具体错误。或者用google.rpc.Status配合details字段传结构化错误信息。
第三是服务发现和负载均衡。单机实例直接连地址就行,多实例部署就得配服务发现。gRPC Java 支持基于名称解析的负载均衡,配合注册中心(比如 Nacos、Consul 这类)使用。这块配置稍复杂,建议先把单机跑通再上。
6.4 生产落地的几点经验
最后分享几个从实际项目里攒下来的经验。proto 文件一定要纳入版本管理并且做兼容性检查,团队里有人改了字段编号或者删了字段,得在 CI 阶段就拦住,别等上线才发现老客户端全挂。可以用buf这类工具做 breaking change 检测。
服务端实现类尽量保持无状态,状态放外部存储。gRPC 服务端可能多实例部署,也可能动态扩缩容,有状态的服务实现会带来一致性问题。这个实例里userStore用内存 Map 只是为了演示,真实场景应该换成数据库或缓存。
监控指标别省。gRPC 调用量、延迟分布、错误率这些指标要采集,出问题时才有数据支撑。gRPC Java 内置了io.grpc.services包可以暴露指标,配合 Micrometer 之类的库接到监控系统。没有监控的 RPC 服务,出了问题就是盲人摸象。
我个人在实际操作中的体会是,gRPC 的上手门槛主要不在框架本身,而在 proto 的契约思维和流式的异步编程模型。一元调用写顺了之后,流式接口得多练几遍才能形成肌肉记忆,尤其是StreamObserver的生命周期管理,什么时候onNext、什么时候onCompleted、出错怎么收尾,这些细节光看文档记不住,得自己动手跑几遍、故意制造些错误场景,才能真正吃透。