先交代一个我亲身踩过的坑:订单服务每次调用物流服务,双方都在赌对方没改接口。HTTP 面上跑的是 JSON:请求字段靠字符串拼,响应字段靠手抄文档。物流团队把响应里的 waybillNo 改名成 shipmentId,旧字段悄悄删掉,订单服务的解析代码拿到 null 照常往下走,三天后客服才发现运单号全空。改字段这种事在强类型世界有编译器拦着,在 JSON 世界只有运行时的 null 和日志里一条没人看的 WARN。那次之后我就认准了一件事:跨服务的字段契约不能靠人肉维护。
gRPC 把契约钉死在 proto 文件里:字段名、类型、序号、方法签名全部进 schema,两端从同一份文件生成代码,改名字直接编译报错。这篇文章记录我用 Spring Boot 4.1.1 的官方 gRPC starter 落地这套方案的全过程:物流服务用 gRPC 暴露查询运单、通知发货两个 unary 接口加一个服务端流式的轨迹接口,订单服务作为客户端经真实网络调用,业务异常映射成 Status,deadline 实配并实测触发。文里的数字全部来自同一次端到端运行,原始日志我都留了底。
集成方案与自动配置的事实
Spring Boot 4.1 起官方原生集成 gRPC,依赖来自 spring-grpc 项目(org.springframework.grpc),自动配置由 Boot 自己的模块承载。写代码之前我习惯先把坐标与类位置钉死,免得写到一半发现引的是旧坐标。以下全部来自 Central 制品解包与 BOM 实测。
spring-boot-dependencies:4.1.1 管理的版本清单:
1
2
3
4
spring-grpc 1.1.1 (org.springframework.grpc:spring-grpc-core)
grpc-java 1.83.1 (io.grpc:grpc-bom)
protobuf-java 4.35.1
protobuf-maven-plugin 5.1.8 (io.github.ascopes,pluginManagement 已管版本)
三个 starter 的组成(POM 实测):
1
2
3
4
5
spring-boot-starter-grpc-server = spring-boot-starter + spring-boot-grpc-server
+ io.grpc:grpc-netty + io.grpc:grpc-services
spring-boot-grpc-server = spring-boot + spring-grpc-core
spring-boot-starter-grpc-client = spring-boot-starter + spring-boot-grpc-client
+ io.grpc:grpc-netty + io.grpc:grpc-stub
服务端 starter 自带 grpc-services(Health 与 Reflection 服务),客户端 starter 自带 grpc-stub。网络传输默认 Netty,与 HTTP 端口完全分离。
自动配置清单(AutoConfiguration.imports 实测),server 模块 7 条:
1
2
3
4
5
6
7
org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.GrpcServerObservationAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthSchedulerAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerOAuth2ResourceServerAutoConfiguration
7 条我逐个过了一遍,对本篇起作用的是三条。GrpcServerAutoConfiguration 装配定制器集合与全局异常拦截器(下文 @GrpcAdvice 靠它生效);GrpcServerServicesAutoConfiguration 把容器里全部 BindableService bean 绑定到服务器,反射服务也在这里挂上;GrpcServerHealthAutoConfiguration 在工程有至少一个 gRPC 服务时默认开启健康检查。客户端模块 3 条,核心是 GrpcClientAutoConfiguration:装配 GrpcChannelFactory(Netty 实现),供创建命名通道。
属性前缀这件事我还专门去字节码里验了一手:GrpcServerProperties 绑定 spring.grpc.server(35 条属性),GrpcClientProperties 绑定 spring.grpc.client。前缀带着 spring. 开头,属性元数据里也没有任何 grpc.server.* 裸前缀的条目。这个细节后面避坑指南里还会再提,配置写错前缀是没有任何报错提示的。
依赖和配置
工程三块依赖我一次配齐:starter-webmvc 支撑订单服务的 HTTP 面,starter-grpc-server 与 starter-grpc-client 各管一头:
1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-grpc-server</artifactId>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-grpc-client</artifactId>
</dependency>
测试侧两个 starter:webmvc-test 管 HTTP 切片,grpc-server-test 自带进程内传输设施(测试一节展开):
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-webmvc-test</artifactId>
<scope> test</scope>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-grpc-server-test</artifactId>
<scope> test</scope>
</dependency>
proto3 代码生成用 BOM 管理的 ascopes 插件,版本号不用写。插件 v5 的配置模型用 kind 属性声明来源,与旧版写法完全不同:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<plugin>
<groupId> io.github.ascopes</groupId>
<artifactId> protobuf-maven-plugin</artifactId>
<configuration>
<protoc kind= "binary-maven" >
<version> ${protobuf-java.version}</version>
</protoc>
<plugins>
<plugin kind= "binary-maven" >
<groupId> io.grpc</groupId>
<artifactId> protoc-gen-grpc-java</artifactId>
<version> ${grpc-java.version}</version>
</plugin>
</plugins>
</configuration>
<executions>
<execution>
<goals>
<goal> generate</goal>
</goals>
</execution>
</executions>
</plugin>
两个 ${...} 属性都继承自 Boot parent,protoc 与 grpc 插件的 Windows 二进制构建时自动从 Central 下载,我本机 mvn compile 实测生成成功。生成物落在 target/generated-sources/protobuf/,Maven 自动加入编译源目录。插件版本千万别手写,写死一个与 BOM 不一致的号,九成会在 protoc 二进制下载那一步卡住。
proto 文件放在 src/main/proto/shipment.proto,java_package 指向独立包避免与手写代码混淆:
1
2
3
4
5
6
7
8
9
10
11
12
13
syntax = "proto3" ;
option java_multiple_files = true ;
option java_package = "com.xncoding.grpc.shipment" ;
option java_outer_classname = "ShipmentProto" ;
package shipment ;
service ShipmentService {
rpc GetShipment ( GetShipmentRequest ) returns ( ShipmentReply );
rpc NotifyShipped ( NotifyShippedRequest ) returns ( ShipmentReply );
rpc TrackShipment ( TrackShipmentRequest ) returns ( stream TrackEvent );
}
TrackShipment 的 returns (stream TrackEvent) 是服务端流式:一次请求,服务端逐条推送。生成代码的类名有命名规则要留意:消息类直接取 message 名,RPC 相关类挂在 ShipmentServiceGrpc 下,阻塞桩叫 ShipmentServiceBlockingStub、实现基类叫 ShipmentServiceImplBase,全部以 proto 里的 service 名为前缀。
配置文件里 server 与 client 两段,client 的通道按名字分组:
1
2
3
4
5
6
7
8
9
10
11
12
13
server :
port : ${SERVER_PORT:18150}
spring :
grpc :
server :
port : ${GRPC_SERVER_PORT:18151}
reflection :
enabled : true
client :
channel :
logistics :
target : localhost:${GRPC_SERVER_PORT:18151}
spring.grpc.server.port 是 gRPC 自己的监听端口,与 Tomcat 的 18150 无关。客户端 channel 是一个 Map:键 logistics 是通道名,target 是这个通道连的地址,按 host:port 字面解析。代码里 createChannel("logistics") 就按这个名字取地址。
服务端:@GrpcService、拦截器与异常映射
服务实现继承生成代码的基类,标上 @GrpcService 注解。物流服务的内存运单存储加三个 RPC 实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@GrpcService
public class ShipmentGrpcService extends ShipmentServiceGrpc . ShipmentServiceImplBase {
private final Map < String , Shipment > store = new ConcurrentHashMap <> ();
private final AtomicLong seq = new AtomicLong ( 1000 );
private final FaultControl fault ;
public ShipmentGrpcService ( FaultControl fault ) {
this . fault = fault ;
}
@Override
public void notifyShipped ( NotifyShippedRequest request ,
StreamObserver < ShipmentReply > responseObserver ) {
if ( fault . isSlow ()) {
sleep ( 1200 );
}
String id = "SHP" + seq . incrementAndGet ();
store . put ( id , new Shipment ( id , request . getOrderNo (), request . getCarrier (), "DISPATCHED" ));
Shipment saved = store . get ( id );
responseObserver . onNext ( ShipmentReply . newBuilder ()
. setShipmentId ( saved . id ())
. setOrderNo ( saved . orderNo ())
. setCarrier ( saved . carrier ())
. setStatus ( saved . status ())
. build ());
responseObserver . onCompleted ();
}
@Override
public void getShipment ( GetShipmentRequest request ,
StreamObserver < ShipmentReply > responseObserver ) {
Shipment saved = store . get ( request . getShipmentId ());
if ( saved == null ) {
throw new ShipmentNotFoundException ( request . getShipmentId ());
}
responseObserver . onNext ( toReply ( saved ));
responseObserver . onCompleted ();
}
}
unary 方法拿到请求对象后填充响应、调 onNext 推给客户端、onCompleted 收尾。查不到运单我直接抛业务异常 ShipmentNotFoundException,异常怎么变成 gRPC 的 Status 由 advice 层负责,服务方法里没有一行 try-catch。这一点是我对这套集成最满意的地方:业务代码里干干净净,错误处理全部外置。
@GrpcService 注解在 org.springframework.grpc.server.service 包,携带 interceptors 与 interceptorNames 属性做服务级拦截器。全局拦截器另有一条路:实现 ServerInterceptor 后加 @GlobalServerInterceptor。日志拦截器记录每个 RPC 的方法名、终态与耗时,计时我放在 close 钩子上,能同时覆盖正常完成与异常关流两种结局:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
@GlobalServerInterceptor
public class GrpcCallLogInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory . getLogger ( GrpcCallLogInterceptor . class );
@Override
public < ReqT , RespT > ServerCall . Listener < ReqT > interceptCall (
ServerCall < ReqT , RespT > call , Metadata headers ,
ServerCallHandler < ReqT , RespT > next ) {
long t0 = System . nanoTime ();
String method = call . getMethodDescriptor (). getFullMethodName ();
ServerCall < ReqT , RespT > wrapped = new ForwardingServerCall
. SimpleForwardingServerCall <> ( call ) {
@Override
public void close ( Status status , Metadata trailers ) {
long ms = ( System . nanoTime () - t0 ) / 1_000_000 ;
log . info ( "GRPC_CALL method={} code={} elapsedMs={}" ,
method , status . getCode (), ms );
super . close ( status , trailers );
}
};
return new ForwardingServerCallListener
. SimpleForwardingServerCallListener <> ( next . startCall ( wrapped , headers )) {
};
}
}
实测一轮八个场景后的拦截器日志(7 行,同一次运行):
1
2
3
4
5
6
7
GRPC_CALL method=shipment.ShipmentService/NotifyShipped code=OK elapsedMs=13
GRPC_CALL method=shipment.ShipmentService/GetShipment code=OK elapsedMs=0
GRPC_CALL method=shipment.ShipmentService/NotifyShipped code=OK elapsedMs=0
GRPC_CALL method=shipment.ShipmentService/GetShipment code=OK elapsedMs=0
GRPC_CALL method=shipment.ShipmentService/TrackShipment code=OK elapsedMs=313
GRPC_CALL method=shipment.ShipmentService/NotifyShipped code=OK elapsedMs=0
GRPC_CALL method=shipment.ShipmentService/NotifyShipped code=OK elapsedMs=1210
首调 13 ms 里有通道建连开销,复用之后降到 0 ms 量级;流式的 TrackShipment 313 ms 推完 5 个事件。ForwardingServerCall 包装的 close 把每个 RPC 的终态都留了痕,排查线上问题时这 7 行比堆栈有用。
业务异常映射用 @GrpcAdvice 组件类,方法级 @GrpcExceptionHandler 按异常类型注册,返回 StatusException:
1
2
3
4
5
6
7
8
9
10
11
12
@GrpcAdvice
public class GrpcExceptionAdvice {
@GrpcExceptionHandler ( ShipmentNotFoundException . class )
public StatusException handleNotFound ( ShipmentNotFoundException e ) {
Metadata trailers = new Metadata ();
Metadata . Key < String > key =
Metadata . Key . of ( "shipment-id" , Metadata . ASCII_STRING_MARSHALLER );
trailers . put ( key , e . getShipmentId ());
return Status . NOT_FOUND . withDescription ( e . getMessage ()). asException ( trailers );
}
}
两个注解都在 org.springframework.grpc.server.advice 包。Boot 的自动配置会把容器里的 advice 收进一个全局异常拦截器,业务方法保持干净,客户端拿到的就是标准的 NOT_FOUND。我实测 GET /api/shipments/SHP-NOPE:客户端收到 StatusRuntimeException 后映射成 HTTP 404,trailer 里带着出问题的运单号。
客户端:命名通道、deadline 与降级
spring-grpc 1.1 移除了旧版的 @GrpcClient 注解,stub 装配走两条路:手写 bean,或者 @ImportGrpcClients 扫描注册。我选了手写 bean,通道名与配置文件里的 Map 键对应,看得见摸得着:
1
2
3
4
5
6
7
8
@Configuration
public class GrpcClientConfig {
@Bean
ShipmentServiceGrpc . ShipmentServiceBlockingStub shipmentStub ( GrpcChannelFactory channels ) {
return ShipmentServiceGrpc . newBlockingStub ( channels . createChannel ( "logistics" ));
}
}
GrpcChannelFactory(org.springframework.grpc.client 包)由自动配置提供,createChannel("logistics") 按 VirtualTargets 规则去 spring.grpc.client.channel.logistics.target 取地址。生成的阻塞桩注入后就是普通 bean,与本地服务无差别。
调用侧的核心是 deadline 与 Status 分流。下单链路里的物流通知允许失败:deadline 到了就走降级,订单照常落库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public Optional < ShipmentView > notifyShipped ( String orderNo , String carrier , int itemCount ) {
metrics . incNotifyCalls ();
long t0 = System . nanoTime ();
try {
ShipmentReply reply = stub . withDeadlineAfter ( NOTIFY_DEADLINE_MS , TimeUnit . MILLISECONDS )
. notifyShipped ( NotifyShippedRequest . newBuilder ()
. setOrderNo ( orderNo )
. setCarrier ( carrier )
. setItemCount ( itemCount )
. build ());
long ms = elapsed ( t0 );
metrics . recordNotifyLatency ( ms );
metrics . incNotifyOk ();
return Optional . of ( ShipmentView . of ( reply ));
} catch ( StatusRuntimeException e ) {
long ms = elapsed ( t0 );
metrics . recordNotifyLatency ( ms );
metrics . incNotifyDegrades ();
if ( e . getStatus (). getCode () == Status . Code . DEADLINE_EXCEEDED ) {
metrics . incDeadlineExceeded ();
}
log . warn ( "GRPC_DEGRADE op=notifyShipped code={} elapsedMs={}" ,
e . getStatus (). getCode (), ms );
return Optional . empty ();
}
}
withDeadlineAfter 每次调用都要挂:stub 是无状态的可复用对象,withXxx 返回新实例,原桩不受影响。unary 的 deadline 我配了 500 ms,超时抛 StatusRuntimeException,getCode() 区分是超时还是服务端报错。getShipment 的分流更细:NOT_FOUND 是业务结论不是故障,单独映射成空值交给 HTTP 层回 404,其余异常原样上抛。
deadline 降级的实测一轮(我用故障注入把服务端拖到 1.2 s):
1
2
GRPC_DEGRADE op=notifyShipped code=DEADLINE_EXCEEDED elapsedMs=512
GRPC_CALL method=shipment.ShipmentService/NotifyShipped code=OK elapsedMs=1210
这两行日志里有本次集成最反直觉的一个结果,我单独拎出来说:客户端 512 ms 就放弃并给订单打上 DEGRADE_DEADLINE 标记照常完成,服务端却对自己被放弃这件事毫无感知,1.2 s 后照常执行完并把运单建了出来。deadline 只约束客户端的等待,不取消服务端的执行。我的判断是:慢操作占用资源的时长必须按服务端视角评估,客户端的超时数字给不了你任何保障,要做熔断就得让服务端也读 deadline。
服务端流式的消费比想象中朴素,就是迭代器:
1
2
3
4
5
6
7
8
9
10
11
12
public List < TrackEventView > trackShipment ( String shipmentId ) {
metrics . incTrackCalls ();
Iterator < TrackEvent > events = stub . withDeadlineAfter ( TRACK_DEADLINE_MS , TimeUnit . MILLISECONDS )
. trackShipment ( TrackShipmentRequest . newBuilder ()
. setShipmentId ( shipmentId )
. build ());
List < TrackEventView > list = new ArrayList <> ();
events . forEachRemaining ( e -> list . add ( new TrackEventView (
e . getStatus (), e . getLocation (), e . getOccurredAt ())));
metrics . addStreamEvents ( list . size ());
return list ;
}
阻塞桩的流式方法返回 Iterator,逐条拉取服务端推送的事件。deadline 覆盖整条流的生命周期:5 个事件每个间隔 60 ms,总耗时 313 ms,deadline 我给到 2 s 留出余量。流式调用比 unary 多一个失败维度,中途断流会在迭代时抛异常,收集完的条数要留进计数器。
完整案例:一轮八个场景的原始记录
端到端验证我起了真实应用:HTTP 在 18150,gRPC 在 18151,订单接口内部经真实网络 gRPC 调用物流服务。八个场景一轮跑完,HTTP 状态码全部符合预期,应用日志 ERROR 0 行。
场景一是正常下单,POST /api/orders 请求体 {"orderNo":"SK-9001","amount":"299.00","itemCount":3},内部走 notifyShipped:
场景二查订单详情,GET /api/orders/SK-9001 内部走 getShipment 刷新运单状态,返回 DISPATCHED。场景三第二单,SHP1002 正常生成。场景四做服务端流式验证,POST /api/shipments/SHP1001/dispatch 一次调用拉回完整轨迹,5 个事件按 PICKED_UP 到 DELIVERED 顺序推达,服务端侧耗时 313 ms。场景五做异常映射,GET /api/shipments/SHP-NOPE 客户端收到 NOT_FOUND,HTTP 层返回 404。
场景六做 deadline 降级。控制面把故障模式切成 slow,服务端 notifyShipped 拖 1.2 s,超过客户端 500 ms deadline:
场景七恢复故障模式后复下一单,SHP1004 正常生成。场景八做计数对账,GET /api/metrics 输出全部计数器:
这笔账我对了一遍:4 次下单 = 3 次物流通知成功 + 1 次 deadline 降级;3 次 getShipment = 2 次正常 + 1 次 NOT_FOUND 映射;流式 1 次调用 5 个事件。平均通知延迟 194 ms 被降级那次的 512 ms 等待拉高,恢复后末次延迟 3 ms 是链路回到健康水位的铁证。日志侧 GRPC_DEGRADE 1 行、ORDER_PLACED 4 行,与场景数一致。
测试怎么写
gRPC 面的测试用 Boot 自带的进程内传输,不占端口也不起网络栈,说实话这是我本次集成里最喜欢的一块设计。@AutoConfigureTestGrpcTransport 把服务端工厂与客户端通道工厂全换成 in-process 实现,@SpringBootTest 起来之后 stub bean 照常注入、照常调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@SpringBootTest
@AutoConfigureTestGrpcTransport
class ShipmentGrpcInProcessTest {
@Autowired
ShipmentServiceGrpc . ShipmentServiceBlockingStub stub ;
@Test
void notifyThenGet_roundTrip () {
ShipmentReply created = stub . withDeadlineAfter ( 500 , TimeUnit . MILLISECONDS )
. notifyShipped ( NotifyShippedRequest . newBuilder ()
. setOrderNo ( "SK-T001" ). setCarrier ( "STO" ). setItemCount ( 2 ). build ());
assertThat ( created . getShipmentId ()). startsWith ( "SHP" );
assertThat ( created . getStatus ()). isEqualTo ( "DISPATCHED" );
ShipmentReply fetched = stub . withDeadlineAfter ( 500 , TimeUnit . MILLISECONDS )
. getShipment ( GetShipmentRequest . newBuilder ()
. setShipmentId ( created . getShipmentId ()). build ());
assertThat ( fetched . getOrderNo ()). isEqualTo ( "SK-T001" );
}
@Test
void unknownShipment_adviceMapsToNotFound () {
assertThatThrownBy (() -> stub . withDeadlineAfter ( 500 , TimeUnit . MILLISECONDS )
. getShipment ( GetShipmentRequest . newBuilder ()
. setShipmentId ( "SHP-NOPE" ). build ()))
. isInstanceOfSatisfying ( StatusRuntimeException . class , e ->
assertThat ( e . getStatus (). getCode ())
. isEqualTo ( Status . Code . NOT_FOUND ));
}
}
测试里注入的 stub 与生产是同一个 bean:通道工厂被测试自动配置替换成进程内实现,通道名、stub 类型、业务代码全部不变。第二个用例顺带验证了 advice 的映射在进程内同样生效,异常类型与 Status 码都在断言里。流式的用例迭代计数断言 5 个事件、末态 DELIVERED。
降级编排用 Mockito 直接测订单服务,不起 gRPC 上下文:
1
2
3
4
5
6
7
8
9
10
11
12
@Test
void notifyFails_orderStillPlacedWithDegradeMarker () {
LogisticsClient logistics = mock ( LogisticsClient . class );
when ( logistics . notifyShipped ( anyString (), anyString (), anyInt ()))
. thenReturn ( Optional . empty ());
OrderService service = new OrderService ( logistics , new CallMetrics ());
OrderService . Order order = service . placeOrder ( "SK-T003" , new BigDecimal ( "42.00" ), 1 );
assertThat ( order . status ()). isEqualTo ( "DEGRADE_DEADLINE" );
assertThat ( order . shipment ()). isNull ();
}
客户端把异常收敛成 Optional.empty() 之后,订单服务的降级逻辑就有了稳定的输入契约:mock 返回空值,断言订单照常落库且标记正确。3 个进程内切片测试加 2 个降级单测,5 个测试全绿,整轮测试 4 秒。
测试依赖有一个坑位要交待:gRPC 的测试 starter 拆成 server 与 client 两个(spring-boot-starter-grpc-server-test / spring-boot-starter-grpc-client-test),@AutoConfigureTestGrpcTransport 注解与进程内工厂都在 spring-boot-grpc-test 模块里,两个测试 starter 都会带上它。4.1.1 里没有名为 spring-boot-starter-grpc-test 的统一制品。
避坑指南
坑一,两个 starter 各管一头,别想着一个顶两个。 spring-boot-starter-grpc-server 与 spring-boot-starter-grpc-client 是独立制品,服务端不带来 grpc-stub,客户端不带来 grpc-services。同工程既做服务端又做客户端就两个都引,缺哪个哪个的类找不到。
坑二,属性前缀是 spring.grpc.*,不是 grpc.*。 字节码里 @ConfigurationProperties 的值是 spring.grpc.server 与 spring.grpc.client,配置写在 grpc.server.port 下会静默不绑定,端口回落默认值 9090,排查起来毫无提示,容易闹笑话。
坑三,@GrpcClient 注解已经不存在。 spring-grpc 0.x 的注解在 1.x 里被移除,Central 上 1.1.1 的 jar 里搜不到这个类。stub 装配走手写 bean(注入 GrpcChannelFactory)或 @ImportGrpcClients,网上照抄旧注解的代码编译不过。
坑四,通道 target 按 host:port 字面解析。 Netty 通道工厂不支持带 scheme 的地址,static://localhost:18151 这类写法会在建连时被拒。要按名寻址就用 spring.grpc.client.channel.<名>.target,进程内寻址用 in-process: 前缀。
坑五,@GlobalServerInterceptor 不带组件语义。 它是纯标记注解,类上不补 @Component 拦截器就进不了容器,所有 RPC 照常工作、日志一行不打。全局拦截器也可以改用 @Bean 方法声明,@Order 控制顺序。
坑六,生成类名以 service 名为前缀。 ShipmentService 生成的阻塞桩是 ShipmentServiceBlockingStub,想当然写成 ShipmentBlockingStub 编译直接报错。java_multiple_files = true 只拆消息类,RPC 桩与基类仍挂在 ShipmentServiceGrpc 外壳类下。
坑七,deadline 不取消服务端执行。 客户端超时放弃后,服务端方法照常跑完(实测客户端 512 ms 放弃、服务端 1210 ms 完成)。用 deadline 做熔断时要评估慢操作在服务端堆积的后果,必要时在服务端读 deadline 并主动中断。
坑八,advice 拦截器在调用链外层接异常。 业务异常被 @GrpcAdvice 转成 Status 后直接在原始 call 上关流,位置更靠内的拦截器收不到这次调用的 close 通知。按日志对账调用数时要把这一层算进去:实测 8 次 RPC 只有 7 行调用日志,差的 1 次就是 NOT_FOUND 那单。
坑九,ascopes 插件 v5 的配置模型换了。 BOM 管理的 5.1.8 用 kind="binary-maven" 属性声明 protoc 与插件来源,旧版 0.6.x 的 <executions> + <configuration.protoFile> 写法照搬过来不生效。版本号交给 BOM,只写坐标。
小结
选型建议:内部服务之间的调用,只要字段变更频繁或者调用方不止一个,proto 契约越早引入越省心。Boot 4.1 把引入成本压到了三个 starter 加一个插件配置,proto 生成代码进 target 不进版本库,契约变更的反馈从运行期的 null 提前到编译期报错;测试侧进程内传输不占端口不碰网络,gRPC 面的切片测试可以放心写进日常回归。服务端 @GrpcService 加基类实现、@GrpcAdvice 映射异常、客户端命名通道加 deadline 分流,这套骨架你可以直接照搬。
没解决的事有两件。一是 deadline 只约束客户端等待,服务端照常执行,我目前只能在拦截器里读 deadline 主动中断慢操作,怎么和业务降级策略统一编排还没想清楚。二是流式中途断流的断点续拉,要在 proto 里自己设计游标协议,这次没做,等真出现长轨迹场景我再补。
参考链接