Spring Boot 4.1.1 深度源码解析-第 21 章 · gRPC 新纪元:spring-boot-grpc-server / grpc-client
本章面向的是 Spring Boot 4.1 里最"新"的东西。 前面二十章讲的类,绝大多数都有
@since 2.x、@since 3.x的血统, 你在 Boot 3 上积累的经验大体能平移。但spring-boot-grpc-server与spring-boot-grpc-client这两个模块,每一个类的 Javadoc 上都写着@since 4.1.0------ 它们是 Boot 4.1 才引入的、从零开始写的新模块。这意味着两件事: 第一 ,这里没有历史包袱,代码是最"现代 Boot"的样子------条件注解的用法、 定制器链的组织、
PropertyMapper的写法,都可以当作 Boot 4.x 的范本 来读。 第二 ,也正因为新,很多设计还带着"未定型"的痕迹 (比如@ConditionalOnBooleanProperty在 server 与 client 两侧的用法并不一致, 我会在 §3.3 展开),这些不一致本身就是极好的观察素材。
0. 引子:一个"不属于 Boot"的模块
先看一眼模块清单,确认它的位置:
vbscript
module/
├── spring-boot-grpc-client/ ← 客户端(18 个主源文件)
├── spring-boot-grpc-server/ ← 服务端(43 个主源文件)
└── spring-boot-grpc-test/ ← 测试切片(7 个主源文件)
starter/
├── spring-boot-starter-grpc-client/
├── spring-boot-starter-grpc-client-test/
├── spring-boot-starter-grpc-server/
└── spring-boot-starter-grpc-server-test/
smoke-test/
├── spring-boot-smoke-test-grpc-client/
├── spring-boot-smoke-test-grpc-client-test/
├── spring-boot-smoke-test-grpc-server/
├── spring-boot-smoke-test-grpc-server-netty-shaded/
├── spring-boot-smoke-test-grpc-server-oauth/
├── spring-boot-smoke-test-grpc-server-secure/
├── spring-boot-smoke-test-grpc-server-servlet/
└── spring-boot-smoke-test-grpc-server-test/
8 个 smoke test 。对比一下:第 18 章讲内嵌服务器时,Tomcat/Jetty/Netty 加起来 也就那么几个 smoke test。gRPC 一个能力就配了 8 个------ 这是"新能力在 Boot 里的待遇":每个可选项(netty / netty-shaded / servlet / oauth / secure / test) 都单独开一个冒烟测试验证。
0.1 但真正让它"不属于 Boot"的是这一行
打开 module/spring-boot-grpc-server/build.gradle:
gradle
dependencies {
api(project(":core:spring-boot"))
api("org.springframework.grpc:spring-grpc-core") // ★ 注意这一行
optional(project(":core:spring-boot-autoconfigure"))
optional(project(":module:spring-boot-health"))
optional("io.grpc:grpc-netty")
optional("io.grpc:grpc-netty-shaded")
optional("io.grpc:grpc-servlet-jakarta")
optional("io.grpc:grpc-services")
optional("io.grpc:grpc-inprocess")
optional("io.grpc:grpc-kotlin-stub")
...
}
org.springframework.grpc:spring-grpc-core ------ 这是 spring-grpc 项目,不是 Spring Boot。
Boot 的 gRPC 模块,本质上是一个**"自动配置层"**: 真正的 gRPC 服务器抽象(GrpcServerFactory、GrpcServerLifecycle、NettyGrpcServerFactory、 ShadedNettyGrpcServerFactory、InProcessGrpcServerFactory)、 服务发现(GrpcServiceDiscoverer)、服务配置(GrpcServiceConfigurer)、 异常体系(GrpcExceptionHandler、@GrpcAdvice)、 以及客户端的 GrpcChannelFactory、VirtualTargets、ClientInterceptorsConfigurer, 全部来自 spring-grpc-core,不在本仓库。
我们在 GrpcServerAutoConfiguration 的 import 里能清楚看到这条界线:
java
import io.grpc.BindableService; // grpc-java
import io.grpc.CompressorRegistry; // grpc-java
import io.grpc.Grpc; // grpc-java
import org.springframework.grpc.server.GrpcServerFactory; // spring-grpc-core ★
import org.springframework.grpc.server.GlobalServerInterceptor; // spring-grpc-core ★
import org.springframework.grpc.server.ServerBuilderCustomizer; // spring-grpc-core ★
import org.springframework.grpc.server.advice.GrpcAdvice; // spring-grpc-core ★
import org.springframework.grpc.server.service.GrpcServiceConfigurer; // spring-grpc-core ★
import org.springframework.grpc.server.service.GrpcServiceDiscoverer; // spring-grpc-core ★
⚠️ 本章的"未核实"密度会明显高于前二十章 ,原因就在这里: 我只能读 Boot 这一侧的源码。
GrpcServerFactory怎么启动服务器、GrpcServerLifecycle的 phase 是多少、DefaultGrpcServiceDiscoverer怎么扫描BindableServicebean------这些都读不到。 我会把每一处"越界"的地方明确标出来,不拿推断冒充事实。
0.2 为什么 Boot 要"官方支持" gRPC
这个问题在源码里没有答案(Javadoc 不讲动机),我给出的是基于代码结构的推断,不是事实:
- gRPC 长期是"二等公民" :过去 Boot 用户要么用
grpc-spring-boot-starter(第三方), 要么自己写@Bean装配。spring-grpc是 Spring 官方项目(与 spring-batch、spring-security 同级), Boot 提供自动配置是顺理成章的收编。 - HTTP/2 是 gRPC 的硬依赖 ,而 Boot 恰好在 4.x 把
server.http2.enabled做成了一等配置(ServletGrpcServerConfiguration里就有对它的校验,见 §5.4)。 两个能力的成熟期撞上了。 - 观测与安全需要深度集成 :从 §8/§10 会看到,gRPC 的观测与 Spring Security 都需要"全局拦截器"(
@GlobalServerInterceptor)这种机制, 这只有在 Boot 掌握装配权时才好做。
**这三条是推断。**可以确证的只有:模块是 4.1.0 引入的,且高度依赖 spring-grpc-core。
0.3 本章的阅读路线
vbscript
§1 模块版图与 imports 清单 ------ 先看"有什么"
§2 依赖边界:哪些类不在本仓库 ------ 建立"能核实/不能核实"的心理地图
§3 @ConditionalOnBooleanProperty ------ gRPC 模块专用的新条件注解
§4 server 主配置 ------ GrpcServerAutoConfiguration
§5 四种 server 工厂的互斥 ------ 本章最精妙的一节
§6 GrpcServerProperties 全景
§7 client 侧:命名通道与 Composite ------ VirtualTargets 是灵魂
§8 观测 §9 健康 §10 安全 §11 测试
§12 事实清单 §13 未核实清单 §14 方法论
1. 模块版图:两份 *.imports 说了什么
按第一定律(先查 *.imports),先看注册清单。
1.1 server 侧:7 条
vbscript
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
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.GrpcServerOAuth2ResourceServerAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration
7 条,但分工极不均:
| 自动配置 | 职责 | 复杂度 |
|---|---|---|
GrpcServerAutoConfiguration |
核心:定制器、服务发现、异常拦截 | 高(@Import 了 5 个内部配置) |
GrpcServerServicesAutoConfiguration |
只做一件事:注册反射服务 | 低(1 个 bean) |
GrpcServerObservationAutoConfiguration |
观测拦截器 | 中 |
GrpcServerHealthAutoConfiguration |
gRPC 健康服务 | 高 |
GrpcServerHealthSchedulerAutoConfiguration |
健康状态定时刷新 | 低 |
GrpcServerSecurityAutoConfiguration |
Spring Security 集成 | 高(4 个内部配置) |
GrpcServerOAuth2ResourceServerAutoConfiguration |
OAuth2 资源服务器 | 中 |
GrpcServerServicesAutoConfiguration 只注册一个 ProtoReflectionServiceV1 就单独占一条 imports------这个粒度值得琢磨。答案在它的 @AutoConfiguration 里:
java
@AutoConfiguration(before = GrpcServerAutoConfiguration.class)
public final class GrpcServerServicesAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ProtoReflectionServiceV1.class)
@ConditionalOnBean(BindableService.class)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.reflection.enabled", matchIfMissing = true)
static class GrpcServerReflectionServiceConfiguration {
@Bean
BindableService grpcServerReflectionService() {
return ProtoReflectionServiceV1.newInstance();
}
}
}
before = GrpcServerAutoConfiguration.class ------ 它必须排在核心配置之前。
为什么?因为 GrpcServerAutoConfiguration 上有:
java
@ConditionalOnBean(BindableService.class)
核心配置要求"至少有一个 BindableService bean"才生效。 而反射服务 ProtoReflectionServiceV1.newInstance() 本身就是一个 BindableService。
于是形成一个自举(bootstrap)关系 : 反射服务先注册 → 满足 GrpcServerAutoConfiguration 的 @ConditionalOnBean → 核心配置才生效。
这是一个"用配置顺序解决鸡生蛋问题"的例子。 如果你的应用里一个
@GrpcService都没有,但又想让 gRPC 服务器起来 (比如只为提供反射服务),这条before就是关键。
顺带一个反直觉的推论 :由于 reflection.enabled 默认 matchIfMissing = true , 只要你 classpath 上有 grpc-services(spring-boot-starter-grpc-server 就 api 了它), 反射服务默认就注册。这又意味着------
GrpcServerAutoConfiguration的@ConditionalOnBean(BindableService.class)其实几乎总是满足的 , 因为反射服务自己就是BindableService。⚠️ 未核实 :我的推断链是"反射服务默认注册 →
BindableService存在 → 核心配置生效"。 但GrpcServerServicesAutoConfiguration还有@ConditionalOnBean(BindableService.class)------ 它自己也要求BindableService存在 ! 所以真正的启动条件仍然是"用户至少有一个@GrpcService", 反射服务只是追加 上去的,不构成自举。 那before的意义就变成:让反射服务在服务器工厂收集服务之前就被发现 ------ 因为NettyGrpcServerConfiguration里会调serviceDiscoverer.findServices()。 这才是更合理的解释。两种解释我无法从源码区分,都列在这里。
1.2 client 侧:3 条
org.springframework.boot.grpc.client.autoconfigure.CompositeChannelFactoryAutoConfiguration
org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration
org.springframework.boot.grpc.client.autoconfigure.GrpcClientObservationAutoConfiguration
只有 3 条,且 GrpcClientAutoConfiguration 上有 @AutoConfiguration(before = CompositeChannelFactoryAutoConfiguration.class)------ 又是靠 before 表达"先有基础的 channel factory,再决定要不要包一层 Composite"。
1.3 一份 spring.factories:Boot 里越来越少见的东西
server 模块里还有一份非 *.imports 的注册:
ini
META-INF/spring.factories
org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer=\
org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer
第 03 章讲过,Boot 4.x 把自动配置从 spring.factories 迁到了 META-INF/spring/*.imports。但 spring.factories 并没有被废弃 ------ 它仍然用于其它 SPI。这里注册的是 org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer, 这是 Spring Security 自己的 SPI ,所以只能放 spring.factories (*.imports 是 Boot 的自动配置专用通道)。
方法论提醒 :判断一个扩展点该放哪,看谁是消费方 。 消费方是 Boot 的自动配置机制 →
*.imports; 消费方是别的框架(这里是 Spring Security)→spring.factories。
2. 依赖边界:本章的"能核实/不能核实"地图
这一节很短,但对读本章极其重要。
2.1 三个坐标
| 坐标 | 来源 | 能否在本仓库核实 |
|---|---|---|
io.grpc.* |
grpc-java(Google) | ❌ 不在本仓库(除非 Gradle 缓存有源码包) |
org.springframework.grpc.* |
spring-grpc-core(Spring 官方,独立项目) | ❌ 不在本仓库 |
org.springframework.boot.grpc.* |
本仓库 | ✅ |
2.2 影响到读源码的具体后果
读 NettyGrpcServerConfiguration 时:
java
NettyGrpcServerFactory factory = new NettyGrpcServerFactory(address.toString(),
grpcServerBuilderCustomizers.forFactory(), credentials.keyManagerFactory(),
credentials.trustManagerFactory(), credentials.clientAuth());
我能确认:
address.toString()是什么(§5.2 详述,NettyAddress在本仓库)credentials的三个字段怎么来的(§5.3,ServerCredentials在本仓库)grpcServerBuilderCustomizers.forFactory()返回什么(§4.2,在本仓库)
我不能确认:
NettyGrpcServerFactory的构造参数语义(第 4 个参数clientAuth到底怎么用的)- 它何时真正
bind()端口 factory.addService(...)之后服务如何被注册
本章凡是涉及后者的地方,我都会标注。
2.3 一个能"绕过边界"的技巧:读测试
虽然实现不在,但 Boot 的测试描述行为 。例如 NettyAddressTests:
java
@Test
void whenNoTransportAndOnlyPortBindsToAllAddressesUsingPort() {
NettyAddress address = new NettyAddress(null, null, 1234, null);
assertThat(address).hasToString(GrpcUtils.ANY_IP_ADDRESS + ":1234");
}
这是 NettyAddress 的行为契约,不看 GrpcUtils 的实现也能确认语义。 本章大量使用这类"从测试反推行为"的方法。
再看 GrpcServerAutoConfigurationTests:
java
@Test
void whenShadedAndNonShadedNettyOnClasspathShadedNettyFactoryIsAutoConfigured() {
this.contextRunner.run((context) -> assertThat(context).getBean(GrpcServerFactory.class)
.isInstanceOf(ShadedNettyGrpcServerFactory.class));
}
一行测试,确认了"两者都在 classpath 时 Shaded 胜出"这个关键行为 ------ 这是纯靠读注解很难确定的(要推理 @Import 顺序 + 条件求值顺序)。 第 3 定律(用测试确认行为)在本章会反复用到。
3. @ConditionalOnBooleanProperty:gRPC 模块的条件注解
第 15 章讲过 Boot 的条件注解体系,但没讲 @ConditionalOnBooleanProperty------ 因为它在 gRPC 模块之外用得不多。这里补上。
3.1 定义与用法
java
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.METHOD })
@Documented
@ConditionalOnBooleanProperty(name = "spring.grpc.server.factory.enabled", matchIfMissing = true)
@interface ConditionalOnGrpcServerFactoryEnabled {
}
它本身是一个"元注解包装" :@ConditionalOnBooleanProperty + 固定的 name 组合成一个语义化的自定义条件。
为什么要包一层?因为 spring.grpc.server.factory.enabled 这个属性名 在 4 个地方被检查(Netty / ShadedNetty / InProcess 三个配置类 + 可能的其他地方), 每次都写全名容易出错,包一层后变成:
java
@ConditionalOnGrpcServerFactoryEnabled
class NettyGrpcServerConfiguration { ... }
@ConditionalOnGrpcServerFactoryEnabled
class ShadedNettyGrpcServerConfiguration { ... }
@ConditionalOnGrpcServerFactoryEnabled
class InProcessGrpcServerConfiguration { ... }
语义清晰,且改属性名只改一处。
这是"第二十定律"(可选依赖靠
afterName)之外的另一类模式: 重复出现的条件,包成一个命名注解 。第 19 章的@ConditionalOnBooleanProperty本身是直接用的,而 gRPC 模块把它再包一层。 两个层级,两种用途。
3.2 matchIfMissing 的三态语义
@ConditionalOnBooleanProperty 与 @ConditionalOnProperty 的关键区别 (第 15 章讲过 @ConditionalOnProperty):
| 注解 | 未配置时 | matchIfMissing |
|---|---|---|
@ConditionalOnProperty |
默认不匹配 | 需显式 matchIfMissing = true |
@ConditionalOnBooleanProperty |
由 matchIfMissing 决定 |
语义相同,但它专为布尔值设计 |
gRPC 模块里绝大多数 开关都写成 matchIfMissing = true:
java
@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.servlet.enabled", matchIfMissing = true)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.factory.enabled", matchIfMissing = true)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.reflection.enabled", matchIfMissing = true)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.observation.enabled", matchIfMissing = true)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.health.enabled", matchIfMissing = true)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.health.schedule.enabled", matchIfMissing = true)
七个开关,七个"默认开" 。这是 gRPC 模块的设计取向: 只要依赖在 classpath 上,功能就默认启用。
对比一下第 19 章 MVC 的 HiddenHttpMethodFilter------默认关。 差异的理由是:MVC 的 filter 是"兼容老浏览器的 hack",默认开有副作用; gRPC 的这些开关都是"核心功能",默认开才符合"约定优于配置"。
3.3 ★ 一处不一致:client 侧没用 @ConditionalOnBooleanProperty
看 client 侧同样的开关:
java
// GrpcClientAutoConfiguration
@ConditionalOnProperty(name = "spring.grpc.client.enabled", matchIfMissing = true)
用的是 @ConditionalOnProperty ,不是 @ConditionalOnBooleanProperty!
而 client 的 InProcessGrpcClientConfiguration 里又是:
java
@ConditionalOnProperty(name = "spring.grpc.client.inprocess.enabled", havingValue = "true", matchIfMissing = true)
还多了一个 havingValue = "true" ------这个在 @ConditionalOnBooleanProperty 语义下是冗余的。
三处写法,三种风格:
| 位置 | 注解 | 备注 |
|---|---|---|
| server 主配置 | @ConditionalOnBooleanProperty |
干净 |
| server 的 factory.enabled | 包装成 @ConditionalOnGrpcServerFactoryEnabled |
最干净 |
| client 主配置 | @ConditionalOnProperty |
不一致 |
| client inprocess | @ConditionalOnProperty(havingValue="true") |
冗余 |
⚠️ 未核实 :
@ConditionalOnBooleanProperty与@ConditionalOnProperty在语义上是否有实质差异(比如前者是否对非布尔值更严格)。 从名字看,前者应该是"只接受 true/false,其它值报错", 但我没读OnBooleanPropertyCondition(或它在@ConditionalOnBooleanProperty背后 复用的OnPropertyCondition)的实现。 自测方法 :配spring.grpc.server.enabled=maybe,看是否抛异常; 再配spring.grpc.client.enabled=maybe,对比行为是否不同。无论如何,"同一个模块里两套写法"本身是事实, 这更像是模块开发过程中的演化痕迹,而非刻意设计。
4. GrpcServerAutoConfiguration:核心配置逐段拆解
java
@AutoConfiguration
@ConditionalOnClass({ GrpcServerFactory.class, Grpc.class })
@ConditionalOnBean(BindableService.class)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true)
@EnableConfigurationProperties(GrpcServerProperties.class)
@Import({ GrpcServerCodecConfiguration.class, ServletGrpcServerConfiguration.class,
ShadedNettyGrpcServerConfiguration.class, NettyGrpcServerConfiguration.class,
InProcessGrpcServerConfiguration.class })
public final class GrpcServerAutoConfiguration {
4.1 类级四条件
① @ConditionalOnClass({ GrpcServerFactory.class, Grpc.class })
两个类,来自两个不同项目:
GrpcServerFactory→spring-grpc-coreio.grpc.Grpc→grpc-java
两个都要有。 只有 grpc-java 没有 spring-grpc-core → 不生效 (用户可能只引了 grpc 做客户端)。反之亦然。
测试确认了这一点:
java
@Test
void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { ... }
@Test
void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { ... }
两个独立的测试 ,一个过滤 io.grpc.*,一个过滤 org.springframework.grpc.*。 这就是"条件注解即文档"(第二定律)的最佳注脚------条件里列了几个类,就有几个测试。
② @ConditionalOnBean(BindableService.class)
这是最大的"存在性开关":没有 gRPC 服务实现,就不起 gRPC 服务器。
这一点与内嵌 Web 服务器(第 18 章)形成鲜明对比: Web 服务器是无条件起的 (只要有 spring-boot-starter-web), 而 gRPC 服务器必须有服务才起。
为什么?推测:gRPC 服务器默认占 9090 端口(见 §5.2), 如果每个引了 starter 的应用都无条件占一个端口,会很讨厌。 "有服务才起"是很合理的设计。(这是推断,Javadoc 未说明。)
③ @ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", ...)
总开关。注意它出现在 5 个自动配置类上(server 主配置、services、 observation、health、security)------每个能力都能单独关。
④ @EnableConfigurationProperties(GrpcServerProperties.class)
4.2 @Import 的五个内部配置:顺序即优先级
java
@Import({ GrpcServerCodecConfiguration.class, // ① 编解码器(无条件)
ServletGrpcServerConfiguration.class, // ② servlet 模式
ShadedNettyGrpcServerConfiguration.class, // ③ shaded netty
NettyGrpcServerConfiguration.class, // ④ netty
InProcessGrpcServerConfiguration.class }) // ⑤ in-process
这个顺序是刻意的,而且至关重要。
ServletGrpcServerConfiguration 排第二,ShadedNetty 第三,Netty 第四。 后三者互斥 (都靠 @ConditionalOnMissingNetworkGrpcServer), 那么谁先注册谁赢。于是优先级是:
arduino
servlet 模式 > shaded netty > netty > (in-process 不参与竞争)
为什么 servlet 优先? 因为在 servlet 应用里(spring-boot-starter-web + Tomcat 已起在 8080), 复用现有 HTTP/2 端口比再开一个 9090 更合理。 ServletGrpcServerConfiguration 上有 @ConditionalOnWebApplication(type = Type.SERVLET), 所以只有在 servlet 应用里它才参与竞争。
为什么 shaded 优先于非 shaded? 这是最反直觉的一条。grpc-java 提供两种 Netty 分发:
| 分发 | artifactId | 特点 |
|---|---|---|
grpc-netty |
io.grpc:grpc-netty |
依赖你自己的 netty 版本 |
grpc-netty-shaded |
io.grpc:grpc-netty-shaded |
netty 被重定位到 io.grpc.netty.shaded.io.netty |
两者可以同时在 classpath 上 (比如某个传递依赖引入了 shaded 版)。 如果同时存在,用哪个?Boot 选 shaded。
理由(推断):shaded 版不会与你自己的 netty 版本冲突 , 作为默认选择更安全。但这不是源码事实,测试只确认了"shaded 胜出"这个行为。
注意
GrpcServerAutoConfiguration的@ConditionalOnClass(GrpcServerFactory.class)并没有区分 netty 与 shaded netty------区分是在内层的两个配置类里做的:
java// NettyGrpcServerConfiguration import io.grpc.netty.NettyServerBuilder; @ConditionalOnClass(NettyServerBuilder.class) // ShadedNettyGrpcServerConfiguration import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; @ConditionalOnClass(NettyServerBuilder.class) // ★ 是 shaded 包里的同名类!两个类的
@ConditionalOnClass名字一模一样,但 import 的包不同。 这是读源码时极易看漏的地方------必须看 import 才能区分。
4.3 GrpcServerBuilderCustomizers:定制器链的"组装器"
这是本章最值得细读的一个类,因为它是Boot 4.x 定制器模式的教科书式实现。
java
class GrpcServerBuilderCustomizers {
private final List<ServerBuilderCustomizer<?>> customizers;
GrpcServerBuilderCustomizers(GrpcServerProperties grpcServerProperties,
ObjectProvider<CompressorRegistry> compressorRegistry,
ObjectProvider<DecompressorRegistry> decompressorRegistry,
ObjectProvider<GrpcServerExecutorProvider> executorProvider,
ObjectProvider<ServerBuilderCustomizer<?>> customizers) {
this(grpcServerProperties, compressorRegistry.getIfAvailable(),
decompressorRegistry.getIfAvailable(), executorProvider.getIfAvailable(),
customizers.orderedStream().toList());
}
GrpcServerBuilderCustomizers(@Nullable GrpcServerProperties grpcServerProperties,
@Nullable CompressorRegistry compressorRegistry,
@Nullable DecompressorRegistry decompressorRegistry,
@Nullable GrpcServerExecutorProvider executorProvider,
List<? extends ServerBuilderCustomizer<?>> customizers) {
List<ServerBuilderCustomizer<?>> all = new ArrayList<>();
addCustomizer(all, compressorRegistry, ServerBuilder::compressorRegistry);
addCustomizer(all, decompressorRegistry, ServerBuilder::decompressorRegistry);
addCustomizer(all, executorProvider, (builder, bean) -> builder.executor(bean.getExecutor()));
if (grpcServerProperties != null) {
all.add(new PropertiesServerBuilderCustomizer<>(grpcServerProperties));
}
all.addAll(customizers);
this.customizers = List.copyOf(all);
}
链的顺序:
① compressorRegistry(如果有 bean) ← bean 定制
② decompressorRegistry(如果有 bean) ← bean 定制
③ executor(如果有 GrpcServerExecutorProvider bean) ← bean 定制
④ PropertiesServerBuilderCustomizer ← 属性定制
⑤ 用户自定义的所有 ServerBuilderCustomizer(orderedStream)
属性在 bean 之后、用户之前 ------这个顺序是 Boot 定制器链的通用约定 (第 18 章 WebServerFactoryCustomizerBeanPostProcessor 也是类似思路): 默认值最先,属性次之,用户最后(能覆盖一切)。
addCustomizer 的泛型技巧值得单独看:
java
private static <B extends ServerBuilder<B>, T> void addCustomizer(List<ServerBuilderCustomizer<?>> customizers,
@Nullable T bean, BiConsumer<B, T> action) {
if (bean != null) {
ServerBuilderCustomizer<B> customizer = (builder) -> action.accept(builder, bean);
customizers.add(customizer);
}
}
T是被应用的 bean 类型(CompressorRegistry/DecompressorRegistry/GrpcServerExecutorProvider)B是 builder 类型BiConsumer<B, T>是"把这个 bean 应用到这个 builder"的动作
三个不同类型的 bean,用同一个方法转成定制器。 这比写三遍 if-null 判断优雅得多。
⚠️ 一个类型安全问题 :
List<ServerBuilderCustomizer<?>>里混装了 不同B的定制器(有的针对NettyServerBuilder,有的针对ServletServerBuilder)。 真正应用时靠LambdaSafe跳过不匹配的:
java<T extends ServerBuilder<?>> void apply(T builder) { LambdaSafe.callbacks(ServerBuilderCustomizer.class, this.customizers, builder) .withLogger(GrpcServerBuilderCustomizers.class) .invoke((customizer) -> customizer.customize(builder)); }第 18 章
WebServerFactoryCustomizerBeanPostProcessor里见过完全相同的手法------ Boot 对"泛型不匹配的定制器"的统一处理就是LambdaSafe+ 日志,不抛异常。
forFactory() 的"套娃"技巧:
java
<T extends ServerBuilder<T>> List<ServerBuilderCustomizer<T>> forFactory() {
return List.of(this::apply);
}
工厂需要的是 List<ServerBuilderCustomizer<T>>(一个列表), 但 GrpcServerBuilderCustomizers 内部已经把所有定制器合成了一个列表。 于是它返回一个只含 this::apply 的单元素列表 ------ apply 本身会遍历内部列表。这样对工厂来说就是"一个定制器"。
为什么这么绕? 因为工厂要的是"有序列表",而 Boot 想保留"内部顺序 + LambdaSafe"。 返回一个 wrapper 是最简单的兼容方式。
4.4 服务发现与配置:两个 @ConditionalOnMissingBean
java
@Bean
@ConditionalOnMissingBean(GrpcServiceConfigurer.class)
DefaultGrpcServiceConfigurer grpcServiceConfigurer(ApplicationContext applicationContext) {
return new DefaultGrpcServiceConfigurer(applicationContext);
}
@Bean
@ConditionalOnMissingBean(GrpcServiceDiscoverer.class)
DefaultGrpcServiceDiscoverer grpcServiceDiscoverer(ApplicationContext applicationContext) {
return new DefaultGrpcServiceDiscoverer(applicationContext);
}
两个默认实现,都 @ConditionalOnMissingBean(用户可替换)。 它们来自 spring-grpc-core,所以行为不可核实。
但从 调用点 能推断出它们的职责:
java
// NettyGrpcServerConfiguration
serviceDiscoverer.findServices()
.stream()
.map((spec) -> serviceConfigurer.configure(spec, factory))
.forEach(factory::addService);
findServices()返回List<GrpcServiceSpec>(spec.service()能bindService())serviceConfigurer.configure(spec, factory)把 spec 转成ServerServiceDefinitionfactory.addService(definition)注册
这是一个典型的"发现 → 配置 → 注册"三段式。 GrpcServiceConfigurer 的存在意义是:在"发现"与"注册"之间插入用户的定制点 (比如加拦截器、改序列化)。
注意
configure(spec, factory)的第二个参数传的是factory(可能为 null------ 见 §5.4 的 servlet 分支传的是null)。 ⚠️ 未核实 :configure在 factory 为 null 时的行为差异。
4.5 异常处理的三件套
java
@Bean
@GlobalServerInterceptor
@ConditionalOnMissingBean
GrpcExceptionHandlerInterceptor grpcGlobalExceptionHandlerInterceptor(
List<GrpcExceptionHandler> exceptionHandlers) {
CompositeGrpcExceptionHandler compositeHandler = new CompositeGrpcExceptionHandler(
exceptionHandlers.toArray(GrpcExceptionHandler[]::new));
return new GrpcExceptionHandlerInterceptor(compositeHandler);
}
CompositeGrpcExceptionHandler ------ 把所有 GrpcExceptionHandler bean 组合成一个。这与第 16 章讲过的"Composite 模式收集同类型 bean"是同一手法 (CompositeHealthContributor、CompositeMeterRegistry 等)。
@GlobalServerInterceptor 来自 spring-grpc-core------ 它应该是标记"这个拦截器应用到所有服务"的注解 , 收集者大概率是 DefaultGrpcServiceConfigurer 或工厂本身。 ⚠️ 未核实 :@GlobalServerInterceptor 的收集机制(它不在本仓库)。
然后是条件化的 @GrpcAdvice 支持:
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(annotation = GrpcAdvice.class)
static class GrpcAdviceConfiguration {
@Bean @ConditionalOnMissingBean
GrpcAdviceDiscoverer grpcAdviceDiscoverer(ApplicationContext applicationContext) { ... }
@Bean @ConditionalOnMissingBean
GrpcExceptionHandlerMethodResolver grpcExceptionHandlerMethodResolver(
GrpcAdviceDiscoverer grpcAdviceDiscoverer) { ... }
@Bean @ConditionalOnMissingBean
GrpcAdviceExceptionHandler grpcAdviceExceptionHandler(
GrpcExceptionHandlerMethodResolver grpcExceptionHandlerMethodResolver) { ... }
}
@ConditionalOnBean(annotation = GrpcAdvice.class) ------ 第 15 章讲过这个用法:**"classpath/容器里存在带 @GrpcAdvice 注解的 bean"**才生效。
这与 Spring MVC 的 @ControllerAdvice + @ExceptionHandler 是完全同构的:
| MVC | gRPC |
|---|---|
@ControllerAdvice |
@GrpcAdvice |
@ExceptionHandler |
由 GrpcExceptionHandlerMethodResolver 解析 |
ExceptionHandlerExceptionResolver |
GrpcAdviceExceptionHandler |
| --- | 多一层:GrpcAdviceDiscoverer(发现 advice bean) |
"多一层"的原因 :MVC 的 advice 由 ExceptionHandlerExceptionResolver 在 Framework 内部统一发现;gRPC 没有这套基础设施, 所以 spring-grpc 自己提供了 GrpcAdviceDiscoverer。
4.6 ReactiveStubConfiguration:仅按类名判断的"影子配置"
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(name = "com.salesforce.reactivegrpc.common.Function")
@Import(ReactiveStubBeanDefinitionRegistrar.class)
static class ReactiveStubConfiguration {
}
@ConditionalOnClass(name = "...") ------ 注意是 name 不是 value 。 第 15 章讲过:value 用类字面量(编译期依赖),name 用字符串(无编译期依赖)。 这里用字符串,说明 Boot 不想对这个第三方库(salesforce reactive-grpc)产生编译期依赖。
配置类体是空的 ,真正的逻辑在 @Import(ReactiveStubBeanDefinitionRegistrar.class) (来自 spring-grpc-core,不可核实)。
这是"第二十定律"(
afterName+@Import= 可选依赖标准写法)的又一次出现。
5. ★ 四种 server 工厂的互斥:本章最精妙的一节
这一节是本章的技术核心。Boot 要处理四种 gRPC 服务器形态:
| 形态 | 配置类 | 触发条件 |
|---|---|---|
| servlet(复用 Tomcat) | ServletGrpcServerConfiguration |
servlet 应用 + grpc-servlet-jakarta |
| shaded netty | ShadedNettyGrpcServerConfiguration |
grpc-netty-shaded |
| netty | NettyGrpcServerConfiguration |
grpc-netty |
| in-process | InProcessGrpcServerConfiguration |
spring.grpc.server.inprocess.name |
前三者互斥 (只能有一个网络服务器),第四个可共存(它不占端口)。
5.1 @ConditionalOnMissingNetworkGrpcServer:自定义组合条件
java
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.METHOD })
@Documented
@Conditional(MissingNetworkGrpcServerCondition.class)
@interface ConditionalOnMissingNetworkGrpcServer {
}
java
class MissingNetworkGrpcServerCondition extends AllNestedConditions {
MissingNetworkGrpcServerCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnMissingBean(GrpcServletRegistration.class)
static class MissingGrpcServletRegistrationBean {
}
@ConditionalOnMissingBean(value = GrpcServerFactory.class, ignored = InProcessGrpcServerFactory.class)
static class MissingGrpcFactoryBean {
}
}
三重信息量:
① AllNestedConditions ------ 第 15 章讲过,AllNestedConditions 是 AND: 所有内部嵌套类的条件都满足才匹配。这里要求:
- 没有
GrpcServletRegistrationbean - 且 没有
GrpcServerFactorybean(InProcessGrpcServerFactory除外)
② ConfigurationPhase.REGISTER_BEAN ------ 这是关键。 第 15 章讲过 SpringBootCondition 的三个 phase: PARSE_CONFIGURATION(解析配置类时)、REGISTER_BEAN(注册 bean 定义时)。
这里必须是 REGISTER_BEAN,因为条件问的是"有没有 GrpcServerFactory bean "------ bean 只有在 REGISTER_BEAN 阶段才陆续注册进来。 如果用 PARSE_CONFIGURATION,此时还没有任何 bean 定义,条件恒为真, 三个配置类会全部生效,冲突。
③ ignored = InProcessGrpcServerFactory.class ------ 这是"排除法"的精髓。
@ConditionalOnMissingBean 的 ignored 属性(第 15 章讲过)表示: "查找 GrpcServerFactory 类型的 bean 时,忽略这些子类型"。
因为 InProcessGrpcServerFactory 是 GrpcServerFactory 的子类, 如果不忽略,那么"起了 in-process 服务器"就会被误判为"已经有网络服务器", 导致 netty/servlet 都不生效。
这一行代码,把"网络服务器"与"进程内服务器"的语义区分开了。
5.2 NettyAddress:一个 record 承担的地址决策
java
record NettyAddress(@Nullable Transport transport, @Nullable InetAddress address, @Nullable Integer port,
@Nullable String domainSocketPath) {
@Override
public String toString() {
Transport transport = (this.transport != null) ? this.transport : deduceTransport();
return switch (transport) {
case TCP -> tcpAddress();
case DOMAIN_SOCKET -> domainSocketAddress();
};
}
把 toString() 当业务方法用 ------这不常见,但在这里很自然: record 的 toString 语义就是"对外表现",而工厂要的正是地址字符串。
deduceTransport() 的推断逻辑:
java
private Transport deduceTransport() {
MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
entries.put("spring.grpc.server.address", this.address);
entries.put("spring.grpc.server.netty.domain-socket-path", this.domainSocketPath);
});
MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
entries.put("spring.grpc.server.port", this.port);
entries.put("spring.grpc.server.netty.domain-socket-path", this.domainSocketPath);
});
if (this.address != null || this.port != null) {
return Transport.TCP;
}
if (this.domainSocketPath != null) {
return Transport.DOMAIN_SOCKET;
}
return Transport.TCP;
}
MutuallyExclusiveConfigurationPropertiesException ------ 这是 Boot 4.x 的属性校验设施 (第 08/09 章讲 Binder 与 @ConfigurationProperties 时应该见过类似的)。 它在这里的用法很有意思:不是靠 Binder 的校验机制,而是在业务代码里主动抛。
为什么?因为这是跨字段校验(address 与 domain-socket-path 互斥), 而 Binder 的校验器通常是单字段的。
测试确认的完整行为表 (NettyAddressTests):
| transport | address | port | domainSocketPath | 结果 |
|---|---|---|---|---|
| null | null | null | null | ANY_IP_ADDRESS:DEFAULT_PORT |
| null | null | 1234 | null | ANY_IP_ADDRESS:1234 |
| null | localhost | null | null | localhost:DEFAULT_PORT |
| null | null | null | /ds |
unix:/ds |
| null | null | 1234 | /ds |
抛异常(互斥) |
| null | 192.168.1.0 | null | /ds |
抛异常(互斥) |
| TCP | 192.168.1.0 | 1234 | /ds |
192.168.1.0:1234(domainSocketPath 被忽略) |
| DOMAIN_SOCKET | null | null | "" |
抛异常(缺路径) |
| DOMAIN_SOCKET | 192.168.1.0 | 1234 | /ds |
unix:/ds(address/port 被忽略) |
★ 关键观察:transport 显式指定时,互斥校验被跳过。
注意 deduceTransport() 只在 transport == null 时调用------ 所以 TCP + domainSocketPath 和 DOMAIN_SOCKET + address 都不会报错 , 只是安静地忽略不一致的字段。
这是一个"显式优先于推断"的设计 :你说了
TCP,我就信你,不再校验。 好处是灵活(可以留着 domain-socket-path 配置不删), 坏处是配置写错了不报错 (配了DOMAIN_SOCKET却只设 address,会得到一个无路径错误------ 不,这个会报错,因为domainSocketAddress()里有StringUtils.hasText检查)。实际风险场景:配了
transport: tcp+domain-socket-path, 结果 domain socket 静默失效。这是可以实测的行为(见 §13)。
默认端口 :GrpcUtils.DEFAULT_PORT 与 GrpcUtils.ANY_IP_ADDRESS 都来自 spring-grpc-core,不在本仓库。
⚠️ 未核实 :具体数值。但 client 侧的
GrpcClientProperties.Channel.DEFAULT_TARGET = "static://localhost:9090"与NettyAddressTests的方法名whenNoTransportAndOnlyAddressBindsToAddressUsingPort9090强烈暗示是 9090 。 自测方法 :起一个 gRPC server 应用,看日志里的端口;或打印GrpcUtils.DEFAULT_PORT。
5.3 ServerCredentials:SSL 的三态判定
java
static ServerCredentials get(GrpcServerProperties.Ssl properties, SslBundles bundles,
TrustManagerFactory insecureTrustManagerFactory) {
Boolean enabled = properties.getEnabled();
String bundle = properties.getBundle();
ClientAuth clientAuth = properties.getClientAuth();
if (Boolean.FALSE.equals(enabled) || (enabled == null && bundle == null)) {
return new ServerCredentials(null, null, clientAuth);
}
Assert.state(bundle != null,
() -> "SSL bundle-name is requested when 'spring.grpc.server.ssl.enabled' is true");
SslManagerBundle managers = bundles.getBundle(bundle).getManagers();
KeyManagerFactory keyManagerFactory = managers.getKeyManagerFactory();
TrustManagerFactory trustManagerFactory = (!properties.isSecure()) ? insecureTrustManagerFactory
: managers.getTrustManagerFactory();
return new ServerCredentials(keyManagerFactory, trustManagerFactory, clientAuth);
}
判定表:
ssl.enabled |
ssl.bundle |
结果 |
|---|---|---|
false |
任意 | 无凭据(明文) |
null(未配) |
null |
无凭据(明文) |
null |
有值 | 启用 SSL(bundle 存在即视为启用) |
true |
null |
抛异常 (Assert.state) |
true |
有值 | 启用 SSL |
★ 最反直觉的一行 :ssl.enabled 未配置但配了 ssl.bundle → SSL 自动启用。
这是"存在即意图 "的判定:你都给了 bundle 名字,显然是想要 SSL。 对比 enabled == null && bundle == null 才走明文分支。
ssl.secure 的作用:
java
TrustManagerFactory trustManagerFactory = (!properties.isSecure()) ? insecureTrustManagerFactory
: managers.getTrustManagerFactory();
secure 默认 true。设为 false 时用 InsecureTrustManagerFactory.INSTANCE(grpc-netty 提供的"信任一切"实现)。 属性上的 Javadoc 写得很直白:
"Whether client authentication is secure (that is certificates are checked). Do not set this to false in production."
SslBundles 是 Boot 的设施(第 30 章会展开),gRPC 直接复用了它------ 又一次看到"新模块复用现有基础设施"。
⚠️ 一个可质疑的设计 :
ServerCredentials.get(...)的第三个参数insecureTrustManagerFactory在调用点 被硬编码为InsecureTrustManagerFactory.INSTANCE:
javaServerCredentials credentials = ServerCredentials.get(properties.getSsl(), bundles, InsecureTrustManagerFactory.INSTANCE);而
InsecureTrustManagerFactory在NettyGrpcServerConfiguration里是io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory(从 §2 的 import 清单能看到 shaded 包)。 这意味着spring-boot-grpc-server对 shaded netty 有编译期依赖 , 尽管build.gradle里它是optional。 推断 :这可能是因为InsecureTrustManagerFactory只是个常量单例, 用哪个包的实现都行,开发者图省事用了 shaded 版。 ⚠️ 未核实:非 shaded 环境下这个类的可用性。
5.4 ServletGrpcServerConfiguration:复用 Tomcat 的 HTTP/2
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnClass(GrpcServlet.class)
@ConditionalOnMissingNetworkGrpcServer
@ConditionalOnBooleanProperty(name = "spring.grpc.server.servlet.enabled", matchIfMissing = true)
class ServletGrpcServerConfiguration {
@Bean
GrpcServletRegistration grpcServletRegistration(Environment environment, GrpcServerProperties properties,
GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer,
GrpcServerBuilderCustomizers grpcServerBuilderCustomizers) {
if (properties.getServlet().isValidateHttp2()
&& !Boolean.TRUE.equals(environment.getProperty("server.http2.enabled", Boolean.class))) {
throw new FailureAnalyzedException(
"Configuration property 'server.http2.enabled' should be set to true for gRPC support",
"Update your application to correct the invalid configuration.\n"
+ "You can also set 'spring.grpc.server.servlet.validate-http2' to false to disable the validation.");
}
return new GrpcServletRegistration(serviceDiscoverer, serviceConfigurer,
grpcServerBuilderCustomizers::apply);
}
}
★ FailureAnalyzedException ------ 这个类在第 16 章讲 FailureAnalyzer 时应该出现过。 它的作用:直接携带"分析结论"(description + action),不需要单独的 FailureAnalyzer 类。
这是 Boot 4.x 里"轻量失败分析"的做法: 异常自己实现/携带分析信息,FailureAnalyzers 直接展示。
这是"启动期校验"的范例 :gRPC over servlet 需要 HTTP/2, 而 servlet 容器的 HTTP/2 是默认关 的(server.http2.enabled)。 如果不校验,用户会得到一个"能启动但 gRPC 请求全部失败"的应用------极难排查 。 Boot 选择启动即失败,并给出精确的修复建议。
这体现了"诊断能力提前布局"(前面章节提过的定律): 与其让错误在运行时以模糊形式暴露,不如在启动时用精确文案拒绝。
GrpcServletRegistration 的实现(本仓库):
java
public GrpcServletRegistration(GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer,
@Nullable Consumer<ServletServerBuilder> serverBuilderCustomizer) {
ServletServerBuilder builder = new ServletServerBuilder();
List<String> urlMappings = new ArrayList<>();
for (GrpcServiceSpec spec : serviceDiscoverer.findServices()) {
ServiceDescriptor descriptor = spec.service().bindService().getServiceDescriptor();
logger.info(LogMessage.format("Registering servlet gRPC service: %s", descriptor.getName()));
urlMappings.add("/" + descriptor.getName() + "/*"); // ★ URL 映射规则
ServerServiceDefinition definition = serviceConfigurer.configure(spec, null); // ★ null!
builder.addService(definition);
}
...
}
两个关键点:
① URL 映射是 /{serviceName}/* ------ 由 gRPC 服务描述符的名字决定, 不可配置 。例如服务 com.example.Greeter → /com.example.Greeter/*。
② serviceConfigurer.configure(spec, null) 传 null ------ 对比 netty 分支传的是 factory。 ⚠️ 未核实 :null 的含义。推测是"没有 factory 可定制", 但 GrpcServiceConfigurer 的实现不在本仓库,无法确认它是否对 null 做特殊处理。
GrpcServletRegistration 继承 DynamicRegistrationBean<Dynamic> ------ 第 19 章讲 DispatcherServletRegistrationBean 时见过这一族。 所以它会被 servlet 容器(Tomcat)的 ServletContextInitializer 机制自动注册。
5.5 三种配置的"生命周期"差异
| 配置 | 是否创建 GrpcServerLifecycle |
端口 |
|---|---|---|
| Servlet | 否 | 复用 Tomcat 的 |
| ShadedNetty | 是(shadedNettyGrpcServerLifecycle) |
独立(默认 9090) |
| Netty | 是(nettyGrpcServerLifecycle) |
独立 |
| InProcess | 是(inProcessGrpcServerLifecycle) |
无 |
Servlet 模式不需要 Lifecycle ------ 服务器由 Tomcat 管, gRPC 只是挂在上面的一个 servlet。
三个 Lifecycle bean 都带 @ConditionalOnMissingBean(name = "..."):
java
@Bean
@ConditionalOnMissingBean(name = "nettyGrpcServerLifecycle")
GrpcServerLifecycle nettyGrpcServerLifecycle(...) { ... }
按名字判定------第 19 章讲过这个模式(第十九定律): Boot 用固定 bean 名作为"用户覆盖点"。
InProcessGrpcServerConfiguration 的 Lifecycle 多了一个条件:
java
@Bean
@ConditionalOnBean(InProcessGrpcServerFactory.class) // ★ 多这一行
@ConditionalOnMissingBean(name = "inProcessGrpcServerLifecycle")
GrpcServerLifecycle inProcessGrpcServerLifecycle(...) { ... }
为什么?因为 InProcessGrpcServerConfiguration 本身有 @ConditionalOnMissingBean(InProcessGrpcServerFactory.class)------ 用户可能自己定义了 InProcessGrpcServerFactory,此时 Boot 不创建 factory, 也不该创建 lifecycle。 但配置类本身可能因为其它条件被处理, 所以加一道 @ConditionalOnBean 保险。
这有点"防御性编程"的味道。 严格说,如果用户提供了 factory,整个配置类应该被
@ConditionalOnMissingBean(InProcessGrpcServerFactory.class)排除, lifecycle 也不会创建。加@ConditionalOnBean是冗余保险 。 ⚠️ 未核实:是否真的存在"配置类生效但 factory 不存在"的场景。
6. GrpcServerProperties 全景:503 行的属性树
GrpcServerProperties 有 503 行 ,比 WebMvcProperties 还长。 前缀 spring.grpc.server。
6.1 顶层结构
java
@ConfigurationProperties("spring.grpc.server")
public class GrpcServerProperties {
private @Nullable Integer port; // 默认由 GrpcUtils.DEFAULT_PORT 决定(见 §5.2)
private @Nullable InetAddress address; // 默认 ANY_IP_ADDRESS
private final Shutdown shutdown = new Shutdown();
private final Inbound inbound = new Inbound();
private final Inprocess inprocess = new Inprocess();
private final Keepalive keepalive = new Keepalive();
private final Ssl ssl = new Ssl();
private final Netty netty = new Netty();
private final Servlet servlet = new Servlet();
}
八个分组 。注意 port 和 address 是可为 null 的包装类型 ------ 这不是随便写的:null 意味着"用默认值",而 int port = 9090 就无法区分"用户配了 0"和"用户没配"。
0 有特殊含义 :Javadoc 写 "Use '0' to bind to a dynamic port" 。 所以
port = 0与port = null语义不同:前者"随机端口",后者"默认端口"。 这正是用Integer而非int的理由。
6.2 三个"单位注解"的用法
java
@DurationUnit(ChronoUnit.SECONDS)
private Duration gracePeriod = Duration.ofSeconds(30);
@DataSizeUnit(DataUnit.BYTES)
private DataSize maxSize = DataSize.ofBytes(4194304);
第 08/09 章讲 Binder 时应该见过这两个注解。这里值得再记一次的是 @DurationUnit 在这里是必要的:
spring.grpc.server.shutdown.grace-period=30 ------ 没有后缀时是 30 秒 (因为有 @DurationUnit(SECONDS))。 如果没有这个注解,默认单位是毫秒。
gracePeriod 的三态语义(Javadoc 明确写了):
"When the value is negative , the server waits forever . When the value is 0, the server will force shutdown immediately."
| 值 | 行为 |
|---|---|
30s(默认) |
优雅停机最多等 30 秒 |
-1s |
永久等待 |
0 |
立即强制关闭 |
"负值 = 永远" 这个约定在 Boot 里反复出现 (第 18 章 Netty 的 disposeNow(Duration.ofNanos(Long.MAX_VALUE)) 是同一思想的另一种表达)。
6.3 Keepalive:gRPC 特有的复杂度
java
public static class Keepalive {
@DurationUnit(ChronoUnit.SECONDS)
private @Nullable Duration time = Duration.ofHours(2); // 多久没读活动就发 ping
@DurationUnit(ChronoUnit.SECONDS)
private @Nullable Duration timeout = Duration.ofSeconds(20); // 发 ping 后多久没 ack 就断连
private final Permit permit = new Permit();
private final Connection connection = new Connection();
}
Permit(服务端允许客户端做什么):
| 属性 | 默认 | 含义 |
|---|---|---|
permit.time |
5 分钟 | 客户端被允许配置的最大 keepalive 时间 |
permit.without-calls |
false |
是否允许客户端在没有活跃 RPC 时发 ping |
这是 gRPC 的"防滥用"机制 :客户端可以发 keepalive ping, 但服务端可以限制频率------否则恶意客户端能用 ping 打满服务端。 permit.time = 5m 意味着客户端如果配了 time = 1s,服务端会拒绝并主动断连。
Connection(连接寿命管理):
| 属性 | 默认 | 含义 |
|---|---|---|
max-idle-time |
null(无限) |
空闲多久后优雅终止 |
max-age |
null(无限) |
连接最长存在多久 |
grace-period |
30 秒 | 优雅终止的宽限期 |
为什么需要 max-age? 长连接在负载均衡环境下会导致流量分布不均------ 客户端与某个后端建立了连接,就一直连着。 max-age 强制连接定期重建,让负载均衡重新生效。 这是生产环境必备的配置,但默认是 null(无限)。
实践提示 :在 K8s / 云环境部署 gRPC 时,
spring.grpc.server.keepalive.connection.max-age通常要设成一个值(如 30m), 否则扩缩容时老连接不会迁移到新实例。
6.4 Inbound:消息大小的两个限制
java
public static class Inbound {
private final Message message = new Message(); // maxSize = 4194304 (4MB)
private final Metadata metadata = new Metadata(); // maxSize = 8192 (8KB)
}
| 属性 | 默认 | gRPC 的默认值 |
|---|---|---|
inbound.message.max-size |
4 MB | grpc-java 默认也是 4MB |
inbound.metadata.max-size |
8 KB | grpc-java 默认也是 8KB |
Boot 与 grpc-java 的默认值一致 ------ 说明 Boot 只是"显式化"了默认值,没有改变行为。
对比第 20 章的
spring.http.codecs.max-in-memory-size(256KB) : 两个"入站大小限制",数量级差了 16 倍,而且作用在不同的协议栈上。 这是两个栈配置不对称的又一例(第二十二定律)。
6.5 Servlet 分组:只有一个 validate-http2
java
public static class Servlet {
private boolean validateHttp2 = true; // ★ 推断,见下
}
⚠️ 未核实 :我没有读到
Servlet内部类的完整定义(只读到调用点properties.getServlet().isValidateHttp2())。 从调用点看,它是一个boolean,默认应该是true(因为 §5.4 的校验默认执行)。自测方法 :读GrpcServerProperties.Servlet源码, 或者在 servlet 应用里不配server.http2.enabled看是否启动失败。
这是本章唯一一个"只从调用点反推"的属性,单列出来提醒自己。
6.6 属性映射的实现:PropertiesServerBuilderCustomizer
java
record PropertiesServerBuilderCustomizer<T extends ServerBuilder<T>>(
GrpcServerProperties properties) implements ServerBuilderCustomizer<T> {
@Override
public void customize(T builder) {
mapInboundProperties(this.properties.getInbound(), builder);
if (supportsKeepAliveProperties(builder)) {
mapKeepaliveProperties(this.properties.getKeepalive(), builder);
}
}
private void mapInboundProperties(Inbound properties, T builder) {
PropertyMapper map = PropertyMapper.get();
map.from(properties.getMessage()::getMaxSize).asInt(DataSize::toBytes).to(builder::maxInboundMessageSize);
map.from(properties.getMetadata()::getMaxSize).asInt(DataSize::toBytes).to(builder::maxInboundMetadataSize);
}
private void mapKeepaliveProperties(Keepalive properties, T builder) {
PropertyMapper map = PropertyMapper.get();
map.from(properties::getTime).to(durationProperty(builder::keepAliveTime));
map.from(properties::getTimeout).to(durationProperty(builder::keepAliveTimeout));
map.from(properties.getConnection()::getMaxIdleTime).to(durationProperty(builder::maxConnectionIdle));
map.from(properties.getConnection()::getMaxAge).to(durationProperty(builder::maxConnectionAge));
map.from(properties.getConnection()::getGracePeriod).to(durationProperty(builder::maxConnectionAgeGrace));
map.from(properties.getPermit()::getTime).to(durationProperty(builder::permitKeepAliveTime));
map.from(properties.getPermit()::isWithoutCalls).to(builder::permitKeepAliveWithoutCalls);
}
private Consumer<Duration> durationProperty(BiConsumer<Long, TimeUnit> setter) {
return (duration) -> setter.accept(duration.toNanos(), TimeUnit.NANOSECONDS);
}
durationProperty 的技巧 :grpc-java 的 API 是 keepAliveTime(long, TimeUnit), 而 Boot 拿到的属性是 Duration。这个 adapter 把 Duration 统一转成纳秒。
为什么转纳秒而不是毫秒? 纳秒是
Duration的最高精度,转成纳塞不会丢精度。 但要注意:grpc-java 内部大概率会再转一次(到毫秒或纳秒)。 ⚠️ 未核实 :Long.MAX_VALUE纳秒传入 grpc-java 是否溢出 (maxConnectionAge = null时map.from(null)会跳过 ,所以不会传)。 实际上PropertyMapper对 null 值默认跳过 ,所以maxIdleTime = null不会调用 setter。
supportsKeepAliveProperties 的"字符串类名"技巧:
java
private boolean supportsKeepAliveProperties(T builder) {
return !isInstance("io.grpc.inprocess.InProcessServerBuilder", builder)
&& !isInstance("io.grpc.servlet.jakarta.ServletServerBuilder", builder);
}
private boolean isInstance(String className, T builder) {
try {
return ClassUtils.forName(className, builder.getClass().getClassLoader()).isInstance(builder);
}
catch (ClassNotFoundException | LinkageError ex) {
return false;
}
}
为什么用字符串类名而不是 builder instanceof InProcessServerBuilder?
因为 spring-boot-grpc-server 对 grpc-inprocess 和 grpc-servlet-jakarta 都是 optional 依赖 ------编译期可能不在 classpath 上。 如果直接写 instanceof,模块在缺少这些依赖时会 NoClassDefFoundError。
这是一个"能力探测"模式:
我不知道你是不是某种 builder,我用类名问一下; 如果你连这个类都没有,那肯定不是。
为什么 in-process 和 servlet 不支持 keepalive? 因为 keepalive 是网络层概念:
- in-process:没有网络,无所谓 keepalive
- servlet:HTTP/2 连接由 Tomcat 管理,gRPC 层管不了
调用 builder.keepAliveTime(...) 在这两种 builder 上可能不存在该方法 (如果是不同接口分支)或无意义。Boot 选择跳过。
这是"多实现的差异点 = 底层 API 能力差"(第十八定律)的又一次验证: 三个 builder 的能力不同,Boot 用运行时探测来适配,而不是强行统一接口。
7. client 侧:命名通道与 VirtualTargets
client 侧比 server 侧小(18 个源文件 vs 43 个),但有一个 server 没有的核心概念: 命名通道(named channels)。
7.1 GrpcClientProperties:以"通道名"为 key 的 Map
java
@ConfigurationProperties("spring.grpc.client")
public class GrpcClientProperties {
private final Map<String, Channel> channel = new LinkedHashMap<>();
}
Map<String, Channel> ------ key 是通道名。于是配置长这样:
yaml
spring:
grpc:
client:
channel:
default: # ← 名为 "default" 的通道
target: "static://localhost:9090"
users: # ← 名为 "users" 的通道
target: "static://users.example.com:9090"
keepalive:
time: 30s
orders:
target: "dns:///orders.example.com:9090"
default:
deadline: 5s
用哪一个?在 @GrpcClient("users") 之类的注解里指定(注解在 spring-grpc-core, ⚠️ 未核实具体形式)。
Channel.DEFAULT_TARGET = "static://localhost:9090" ------ 与 server 的 9090 对上了。
7.2 VirtualTargets:把"名字"解析成"地址"
java
record PropertiesVirtualTargets(PropertyResolver propertyResolver,
GrpcClientProperties properties) implements VirtualTargets {
@Override
public String getTarget(String target) {
Channel channel = this.properties.getChannel().get(target);
if (channel != null) {
return clean(this.propertyResolver.resolvePlaceholders(channel.getTarget()));
}
if ("default".equals(target)) {
return clean(Channel.DEFAULT_TARGET);
}
target = this.propertyResolver.resolvePlaceholders(target);
if (target.contains(":/") || target.startsWith("unix:")) {
return clean(target);
}
return target;
}
private String clean(String target) {
if (target.startsWith("static:") || target.startsWith("tcp:")) {
String withoutScheme = target.substring(target.indexOf(":") + 1);
return withoutScheme.replaceFirst("/*", "");
}
return target;
}
}
"Virtual" 的含义 :传入的 target 不一定是真实地址, 可能是一个在配置里定义的名字。
解析顺序:
arduino
① 把 target 当通道名查 map → 命中:返回 channel.target(解析占位符后)
② target == "default" 但 map 里没有 → 返回硬编码 DEFAULT_TARGET
③ target 含 ":/" 或以 "unix:" 开头 → 当成真实地址,直接用
④ 其它 → 原样返回(交给 grpc-java 自己解释)
clean() 的作用 :剥掉 static: / tcp: 前缀和多余的斜杠。
perl
static://localhost:9090 → localhost:9090
tcp:///localhost:9090 → localhost:9090
dns:///example.com:443 → dns:///example.com:443(不是 static/tcp,原样)
注意
replaceFirst("/*", "")只替换一次,而且是"零个或多个 /" 。static://localhost→ 去掉static:后是//localhost,replaceFirst("/*", "")匹配开头的//并删除 →localhost。✓ 如果是static:/localhost→replaceFirst匹配/→localhost。✓这个正则写得有点取巧(
"/*"是"零个或多个斜杠",可以匹配空串)。 匹配空串时replaceFirst在位置 0 插入空串,等于没变。 所以实际效果正确。但这是我读代码推的,不是测试确认的。 ⚠️ 未核实 :PropertiesVirtualTargetsTests里应该有对应断言,我未逐条读。
PropertyResolver 而非 Environment:
构造参数类型是 PropertyResolver(接口),调用点传的是 Environment:
java
factory.setVirtualTargets(new PropertiesVirtualTargets(environment, properties));
用最宽的接口类型 ------这是好设计:测试时传个 PropertySourcesPropertyResolver 就行。
7.3 ★ CompositeChannelFactoryAutoConfiguration:NoneNestedConditions 的妙用
这是 client 侧最精妙的一个条件。
java
@AutoConfiguration
@ConditionalOnClass({ AbstractStub.class, GrpcChannelBuilderCustomizer.class })
@ConditionalOnProperty(name = "spring.grpc.client.enabled", matchIfMissing = true)
@Conditional(CompositeChannelFactoryAutoConfiguration.MultipleNonPrimaryChannelFactoriesCondition.class)
public final class CompositeChannelFactoryAutoConfiguration {
@Bean
@Primary
CompositeGrpcChannelFactory compositeChannelFactory(
ObjectProvider<GrpcChannelFactory> channelFactoriesProvider) {
return new CompositeGrpcChannelFactory(channelFactoriesProvider.orderedStream().toList());
}
static class MultipleNonPrimaryChannelFactoriesCondition extends NoneNestedConditions {
MultipleNonPrimaryChannelFactoriesCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnMissingBean(GrpcChannelFactory.class)
static class NoChannelFactoryCondition {
}
@ConditionalOnSingleCandidate(GrpcChannelFactory.class)
static class SingleInjectableChannelFactoryCondition {
}
}
}
问题背景 :client 侧可能有多个 GrpcChannelFactory
(比如 netty 的 + in-process 的,就像 server 侧可以有 netty factory + in-process factory)。 那么 @Autowired GrpcChannelFactory 会因为多个候选而失败。
Boot 的解法 :当有多个时,注册一个 CompositeGrpcChannelFactory 并标 @Primary, 它内部持有所有 factory 并按 target 委派。
条件的语义(这是关键):
java
class MultipleNonPrimaryChannelFactoriesCondition extends NoneNestedConditions {
// NoneNestedConditions = 所有内部条件都【不】匹配时才匹配(NOR)
@ConditionalOnMissingBean(GrpcChannelFactory.class) // 条件 A
@ConditionalOnSingleCandidate(GrpcChannelFactory.class) // 条件 B
}
NoneNestedConditions = NOR(全否):
- 条件 A 不匹配(即"有
GrpcChannelFactorybean") - 且 条件 B 不匹配(即"不是只有一个候选")
- → 条件成立
翻译:
| 场景 | A(Missing 匹配?) | B(SingleCandidate 匹配?) | NOR 结果 | Composite 注册? |
|---|---|---|---|---|
| 0 个 factory | ✅ 匹配 | ❌ | ❌ | 否(没东西可组合) |
| 1 个 factory | ❌ | ✅ 匹配 | ❌ | 否(无需组合) |
| 2+ 个 factory | ❌ | ❌ | ✅ | 是 |
完美:只有"多个"时才注册 Composite。
@ConditionalOnSingleCandidate是 Boot 的一个特殊条件(第 15 章讲过): 它匹配"恰好一个候选,或者有多个但其中一个是@Primary"。 这正是"注入会不会歧义"的判据------与 Spring 的注入语义对齐。这里用它是精准的:只要注入不歧义,就不需要 Composite。
@Primary 的位置 :在 compositeChannelFactory() 方法上。 所以即使 Composite 本身也是一个 GrpcChannelFactory, 它也是唯一的注入候选。
一个微妙的自引用问题 : Composite 注册后,GrpcChannelFactory 的数量从 2 变成 3, 且其中一个带 @Primary------此时 @ConditionalOnSingleCandidate 会匹配。 但条件已经在 REGISTER_BEAN 阶段求值过了,不会重复触发。 ⚠️ 未核实 :NoneNestedConditions 在 REGISTER_BEAN 阶段的具体求值时机 (是在所有 bean 定义注册完之后,还是逐个注册时)。这影响"是否稳定"。
7.4 三种 channel factory 的互斥
java
// NettyGrpcClientConfiguration
@ConditionalOnClass({ Channel.class, NettyChannelBuilder.class })
@ConditionalOnMissingBean(value = GrpcChannelFactory.class, ignored = InProcessGrpcChannelFactory.class)
@ConditionalOnGrpcClientChannelFactoryEnabled
// ShadedNettyGrpcClientConfiguration
@ConditionalOnClass({ io.grpc.netty.shaded.io.netty.channel.Channel.class,
io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class })
@ConditionalOnMissingBean(value = GrpcChannelFactory.class, ignored = InProcessGrpcChannelFactory.class)
@ConditionalOnGrpcClientChannelFactoryEnabled
与 server 侧几乎同构 :ignored = InProcessGrpcChannelFactory 让 in-process 不参与竞争。
但有一个重要差异 :client 侧没有 @ConditionalOnMissingNetworkGrpcServer 那样的 组合条件注解------它直接把 @ConditionalOnMissingBean(..., ignored = ...) 写在配置类上。 同样的语义,两种组织方式。(server 侧包了一层注解,client 侧直接写。)
Shaded vs 非 Shaded 的顺序:
java
@Import({ GrpcClientCodecConfiguration.class, ShadedNettyGrpcClientConfiguration.class,
NettyGrpcClientConfiguration.class, InProcessGrpcClientConfiguration.class })
Shaded 在前 → Shaded 优先,与 server 侧一致。✓
In-process 的额外条件:
java
@ConditionalOnMissingBean(InProcessGrpcChannelFactory.class) // ← 注意:没有 ignored
@ConditionalOnGrpcClientChannelFactoryEnabled
@ConditionalOnProperty(name = "spring.grpc.client.inprocess.enabled",
havingValue = "true", matchIfMissing = true)
它用的是 @ConditionalOnMissingBean(InProcessGrpcChannelFactory.class) (不带 ignored), 而不是 server 侧的 @ConditionalOnProperty("spring.grpc.server.inprocess.name")。
为什么 server 用属性触发、client 用开关?
- server:in-process 服务器要有个名字 (
inprocess.name),没名字就没意义 → 用属性触发 - client:in-process 客户端不需要名字(连到同进程内的服务器即可)→ 用布尔开关
这是合理的差异(底层约束不同,第二十二定律)。
另外注意
spring.grpc.client.inprocess.enabled的默认值是matchIfMissing = true, 意味着只要有grpc-inprocess在 classpath,in-process channel factory 就注册 。 而 server 侧的 in-process 默认不注册 (需要显式配inprocess.name)。 不对称,但有理由:客户端多一个 factory 无害(有 Composite 兜底), 服务端多一个 factory 就要多占资源。
7.5 GrpcChannelBuilderCustomizers:比 server 侧多一层
java
@Order(Ordered.LOWEST_PRECEDENCE)
class GrpcChannelBuilderCustomizers {
private final List<GrpcChannelBuilderCustomizer<?>> customizers;
GrpcChannelBuilderCustomizers(@Nullable GrpcClientProperties grpcClientProperties,
@Nullable CompressorRegistry compressorRegistry,
@Nullable DecompressorRegistry decompressorRegistry,
List<? extends GrpcChannelBuilderCustomizer<?>> customizers,
List<? extends GrpcClientDefaultServiceConfigCustomizer> defaultServiceConfigCustomizers) {
List<GrpcChannelBuilderCustomizer<?>> all = new ArrayList<>();
addCustomizer(all, compressorRegistry, ManagedChannelBuilder::compressorRegistry);
addCustomizer(all, decompressorRegistry, ManagedChannelBuilder::decompressorRegistry);
if (grpcClientProperties != null) {
all.add(new PropertiesGrpcChannelBuilderCustomizer<>(grpcClientProperties));
}
all.addAll(customizers);
all.add(customizeDefaultServiceConfig(grpcClientProperties, defaultServiceConfigCustomizers));
this.customizers = List.copyOf(all);
}
链的顺序:
① compressorRegistry ② decompressorRegistry
③ PropertiesGrpcChannelBuilderCustomizer(属性)
④ 用户自定义 GrpcChannelBuilderCustomizer(orderedStream)
⑤ ★ defaultServiceConfig 定制(永远最后)
⑤ 为什么要单独放最后? 看它的实现:
java
private <B extends ManagedChannelBuilder<B>> GrpcChannelBuilderCustomizer<B> customizeDefaultServiceConfig(
@Nullable GrpcClientProperties properties,
List<? extends GrpcClientDefaultServiceConfigCustomizer> customizers) {
PropertiesGrpcClientDefaultServiceConfigCustomizer propertiesCustomizer = (properties != null)
? new PropertiesGrpcClientDefaultServiceConfigCustomizer(properties) : null;
return (target, builder) -> {
Map<String, Object> defaultServiceConfig = new LinkedHashMap<>();
if (propertiesCustomizer != null) {
propertiesCustomizer.customize(target, defaultServiceConfig);
}
customizers.forEach((customizer) -> customizer.customize(target, defaultServiceConfig));
if (!defaultServiceConfig.isEmpty()) {
builder.defaultServiceConfig(defaultServiceConfig);
}
};
}
它是在"积累一个 Map",最后一次性 builder.defaultServiceConfig(map)。
这就是 §7.6 要讲的"为什么需要 GrpcClientDefaultServiceConfigCustomizer 而不是直接用 GrpcChannelBuilderCustomizer"的答案------ 我在 Javadoc 里找到了明确说明:
"This customizer should be used instead of calling
ManagedChannelBuilder#defaultServiceConfig(Map)from aGrpcChannelBuilderCustomizersince it allows multiple customizers to update the same default service config, rather than having a 'last wins' outcome."
翻译 :如果多个定制器都调 builder.defaultServiceConfig(map), 后一个会整体覆盖 前一个("last wins")。 而 GrpcClientDefaultServiceConfigCustomizer 是往同一个 Map 里加条目, 所以多个定制器可以协作。
★ 这是本章最值得记住的一条设计经验 : 当"配置对象"是 Map 且多个来源都要往里写时,不要用 setter 语义(覆盖), 要用 accumulator 语义(累积)。 Boot 为此专门造了一个接口。
@Order(Ordered.LOWEST_PRECEDENCE) 在类上------ 这个 @Order 影响的是这个 bean 在同类 bean 中的位置 。 ⚠️ 未核实 :GrpcChannelBuilderCustomizers 是一个 bean, 它的 @Order 在什么场景下被消费(可能是 CompositeGrpcChannelFactory 排序用)。
7.6 ServiceConfig:一份类型安全的 proto 映射
ServiceConfig 是一个 record,对应 gRPC 的 service_config.proto:
java
public record ServiceConfig(@Nullable List<LoadBalancingConfig> loadbalancing, @Nullable List<MethodConfig> method,
@Nullable RetryThrottlingPolicy retrythrottling, @Nullable HealthCheckConfig healthcheck) {
public void applyTo(Map<String, Object> grpcJavaConfig) {
applyTo(new GrpcJavaConfig(grpcJavaConfig));
}
private void applyTo(GrpcJavaConfig config) {
PropertyMapper map = PropertyMapper.get();
map.from(this::loadbalancing).as(listOf(LoadBalancingConfig::grpcJavaConfig))
.to(config.in("loadBalancingConfig"));
map.from(this::method).as(listOf(MethodConfig::grpcJavaConfig)).to(config.in("methodConfig"));
map.from(this::retrythrottling).as(RetryThrottlingPolicy::grpcJavaConfig).to(config.in("retryThrottling"));
map.from(this::healthcheck).as(HealthCheckConfig::grpcJavaConfig).to(config.in(HEALTH_CHECK_CONFIG_KEY));
}
它的价值 :gRPC 的 service config 原生是 Map<String, Object>(JSON 结构), Boot 提供类型安全的 Java record 来绑定它,再转回 Map。
负载均衡配置的校验:
java
public LoadBalancingConfig {
if (pickfirst == null && roundrobin == null && weightedroundrobin == null && grpc == null) {
throw new InvalidConfigurationPropertyValueException("loadbalancing", null,
"Missing load balancing strategy");
}
MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
entries.put("loadbalancing.pickfirst", pickfirst);
entries.put("loadbalancing.roundrobin", roundrobin);
entries.put("loadbalancing.weightedroundrobin", weightedroundrobin);
entries.put("loadbalancing.grpc", grpc);
});
}
在 record 的紧凑构造器里做校验 ------ 四个策略必须选一个且只能选一个。
这是 record 的一个绝佳用法: 不可变 + 构造即校验 。比"先 new 再 validate"安全得多。 第 09 章讲
@ConfigurationProperties校验时见过 JSR-303 的用法, 这里是"编程式校验"------因为这是跨字段约束(四选一),注解表达不了。
四种策略映射到 gRPC 的 key:
| Boot 属性 | gRPC key |
|---|---|
loadbalancing.pickfirst |
pick_first |
loadbalancing.roundrobin |
round_robin |
loadbalancing.weightedroundrobin |
weighted_round_robin |
loadbalancing.grpc |
grpclb |
7.7 PropertiesGrpcChannelBuilderCustomizer 的三个细节
java
record PropertiesGrpcChannelBuilderCustomizer<T extends ManagedChannelBuilder<T>>(
GrpcClientProperties properties) implements GrpcChannelBuilderCustomizer<T> {
private static final Channel STOCK_DEFAULT_CHANNEL = new Channel();
@Override
public void customize(String target, T builder) {
Channel channel = getChannel(target);
PropertyMapper map = PropertyMapper.get();
map.from(channel::getUserAgent).to(builder::userAgent);
map.from(channel.getInbound().getMessage()::getMaxSize).asInt(this::maxSize)
.to(builder::maxInboundMessageSize);
...
map.from(channel.getDefault()::getDeadline)
.when((deadline) -> deadline.toMillis() > 0L)
.as(DefaultDeadlineSetupClientInterceptor::new)
.to(builder::intercept);
map.from(channel.getDefault()::getLoadBalancingPolicy)
.when((policy) -> supportsLoadBalancing(target, channel))
.to(builder::defaultLoadBalancingPolicy);
...
}
① STOCK_DEFAULT_CHANNEL ------ 一个"空"的 Channel 实例作为兜底。
java
private Channel getChannel(String target) {
Channel channel = this.properties.getChannel().get(target);
channel = (channel != null) ? channel : this.properties.getChannel().get("default");
return (channel != null) ? channel : STOCK_DEFAULT_CHANNEL;
}
三级回退 :target 名 → default → 全新默认实例(所有字段用类默认值)。
为什么要一个 new Channel() 而不是让 Channel 可为 null? 因为下面要链式调 channel.getInbound().getMessage().getMaxSize(), null 会 NPE。用"空对象"替代 null 检查(Null Object 模式)。
② .when(...) 的用法
java
map.from(channel.getDefault()::getDeadline)
.when((deadline) -> deadline.toMillis() > 0L)
.as(DefaultDeadlineSetupClientInterceptor::new)
.to(builder::intercept);
PropertyMapper.when(Predicate) ------ 第 08 章讲 Binder 时应该见过。 这里的意思是"只有 deadline > 0ms 才应用"。
deadline 被转成一个拦截器 DefaultDeadlineSetupClientInterceptor------ 因为 gRPC 的 channel builder 没有 deadline(...) 方法, deadline 只能在调用时设置。Boot 用一个拦截器在每次调用前注入 deadline。
这是"API 缺失时用拦截器补"的模式 。 第 19 章 MVC 里
HiddenHttpMethodFilter也是同类(用 filter 补 HTTP 方法)。
③ supportsLoadBalancing 的排除
java
private boolean supportsLoadBalancing(String target, Channel channel) {
return !(isUnixOrInProcessTarget(target) || isUnixOrInProcessTarget(channel.getTarget()));
}
private boolean isUnixOrInProcessTarget(String target) {
return target.startsWith("unix:") || target.startsWith("in-process:");
}
domain socket 和 in-process 通道不支持负载均衡------ 因为它们只有一个目标地址,没有"多个实例可选"。
注意它检查了两次 :target(虚拟名)和 channel.getTarget()(实际地址)。 因为 target 可能是名字(如 users),而实际地址在 channel.getTarget() 里。 只查一个会漏。
④ maxSize 的边界处理
java
private int maxSize(DataSize maxSize) {
long bytes = maxSize.toBytes();
Assert.state(bytes >= 0 || bytes == -1, () -> "Unsupported max size value " + maxSize);
return (bytes >= 0 && bytes <= Integer.MAX_VALUE) ? (int) bytes : Integer.MAX_VALUE;
}
-1是"最大限制"(Javadoc 说 "Set to '-1' to use the highest possible limit (not recommended)")- 超过
Integer.MAX_VALUE的值被截断 为Integer.MAX_VALUE(因为 grpc-java 的 API 是int)
为什么 -1 要单独判? 因为 Assert.state(bytes >= 0 || bytes == -1) ------ 除了 -1,任何负数都报错。
7.8 凭据:PropertiesChannelCredentialsProvider
java
@Override
public ChannelCredentials getChannelCredentials(String target) {
Channel channel = this.properties.getChannel().get(target);
channel = (channel != null) ? channel : this.properties.getChannel().get("default");
if (channel == null || isInsecure(channel.getSsl())) {
return InsecureChannelCredentials.create();
}
Builder builder = TlsChannelCredentials.newBuilder();
if (channel.getSsl().getBundle() != null) {
SslBundle bundle = this.bundles.getBundle(channel.getSsl().getBundle());
builder.trustManager(bundle.getManagers().getTrustManagerFactory().getTrustManagers());
builder.keyManager(bundle.getManagers().getKeyManagerFactory().getKeyManagers());
}
if (channel.isBypassCertificateValidation()) {
builder.trustManager(InsecureTrustManagerFactory.INSTANCE.getTrustManagers());
}
return builder.build();
}
private boolean isInsecure(Ssl ssl) {
return Boolean.FALSE.equals(ssl.getEnabled()) || (ssl.getBundle() == null && ssl.getEnabled() == null);
}
与 server 侧 ServerCredentials 的判定逻辑完全一致 (§5.3): enabled == null && bundle == null → 不安全。
一个差异 :client 多了一个 bypass-certificate-validation 开关, 它覆盖 bundle 里的 trustManager:
java
if (channel.getSsl().getBundle() != null) { builder.trustManager(...); }
if (channel.isBypassCertificateValidation()) { builder.trustManager(INSECURE); }
顺序:先 bundle 后 bypass → bypass 生效。 属性 Javadoc:"Should not be enabled in production."
注意 client 侧的三级回退少了"空对象" :
channel == null时直接返回InsecureChannelCredentials。 而 §7.7 的PropertiesGrpcChannelBuilderCustomizer用的是STOCK_DEFAULT_CHANNEL。 同一个"找不到 channel"的情况,两个类处理不同------一个返回不安全凭据,一个用空对象。 这是实现风格的不一致,不是 bug。
8. 观测:@GlobalServerInterceptor 与"只注册拦截器"
8.1 server 侧
java
@AutoConfiguration(
afterName = "org.springframework.boot.micrometer.observation.autoconfigure.ObservationAutoConfiguration")
@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, ObservationRegistry.class,
ObservationGrpcServerInterceptor.class })
@ConditionalOnBean(ObservationRegistry.class)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.observation.enabled", matchIfMissing = true)
public final class GrpcServerObservationAutoConfiguration {
@Bean
@Order(0)
@GlobalServerInterceptor
@ConditionalOnMissingBean
ObservationGrpcServerInterceptor grpcServerObservationInterceptor(ObservationRegistry observationRegistry,
ObjectProvider<GrpcServerObservationConvention> customConvention) {
ObservationGrpcServerInterceptor interceptor = new ObservationGrpcServerInterceptor(observationRegistry);
customConvention.ifAvailable(interceptor::setCustomConvention);
return interceptor;
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(name = { "io.grpc.kotlin.AbstractCoroutineStub", "io.micrometer.context.ContextRegistry" })
static class GrpcServerCoroutineStubConfiguration {
@Bean
@Order(10)
@GlobalServerInterceptor
ObservationCoroutineContextServerInterceptor grpcServerObservationCoroutineContextServerInterceptor(
ObservationRegistry observationRegistry) {
return new ObservationCoroutineContextServerInterceptor(observationRegistry);
}
}
}
① @Order 的作用 :观测拦截器 @Order(0),Kotlin 协程上下文拦截器 @Order(10)。
为什么协程的要排在后面? 因为协程拦截器负责把 observation 的 context 传播到协程里------它必须在观测拦截器建立 context 之后运行。 拦截器按 order 执行,所以 0(建立)先于 10(传播)。
⚠️ 未核实 :
@GlobalServerInterceptor是否真的按@Order排序收集。 推断是(Boot 的同类机制都排序),但收集者在spring-grpc-core,读不到。
② 两个 ObservationGrpcServerInterceptor 的差异 (@ConditionalOnMissingBean)
主拦截器有 @ConditionalOnMissingBean(用户可替换), 协程没有 。理由:协程拦截器是补充能力, 如果用户自己提供了观测拦截器,协程传播仍然需要。
③ afterName = "...ObservationAutoConfiguration"
第 20 章讲过这个模式(第二十定律):用字符串类名表达顺序,避免编译期依赖。 spring-boot-micrometer-observation 是 optional 依赖。
注意 :这里 @ConditionalOnClass 里直接用了 ObservationRegistry.class (类字面量!),说明对 micrometer 有编译期依赖。 那为什么顺序还要用 afterName?
因为
ObservationAutoConfiguration是spring-boot-micrometer-observation模块里的 自动配置类 ,而spring-boot-grpc-server只依赖了 micrometer 的ObservationRegistry(来自io.micrometer:micrometer-core,一个第三方库 ), 没有依赖 Boot 的ObservationAutoConfiguration所在的模块。 依赖的是库,不是 Boot 模块 → 顺序只能用字符串。 这与第 20 章WebFluxAutoConfiguration的情况完全一样。
8.2 client 侧:一个"多余"的 havingValue
java
@ConditionalOnProperty(name = "spring.grpc.client.observation.enabled",
havingValue = "true", matchIfMissing = true)
对比 server 侧的 @ConditionalOnBooleanProperty(..., matchIfMissing = true) (没有 havingValue)。
又一次不一致 (§3.3 已列出)。这里再记一次,因为它出现在几乎相同的位置 (都是观测开关),对比更鲜明。
8.3 第二十四定律的验证:只注册"拦截器"= 功能内建
回顾第 20 章末尾的"第二十四定律":
Boot 只注册"配置对象" = 功能内建在框架里。
gRPC 观测是这条定律的又一个验证 : GrpcServerObservationAutoConfiguration 只注册了拦截器 bean,没有任何 Function 组件 。 观测能力(计时、打点、span)内建在 ObservationGrpcServerInterceptor(来自 spring-grpc-core)里, Boot 只负责"把它注册进去并让它是 @GlobalServerInterceptor"。
对比 MVC 的 WebMvcObservationAutoConfiguration(第 19 章要注册 Filter), gRPC 更"轻"------因为 gRPC 有原生拦截器机制,不需要外挂。
9. 健康:Boot 健康 → gRPC ServingStatus 的映射
这是 server 侧最复杂的一块,也是唯一有独立定时任务的部分。
9.1 两个自动配置的分工
| 自动配置 | 职责 |
|---|---|
GrpcServerHealthAutoConfiguration |
建 HealthStatusManager + GrpcServerHealth(计算健康状态) |
GrpcServerHealthSchedulerAutoConfiguration |
用 TaskScheduler 定时刷新 |
java
@AutoConfiguration(after = GrpcServerHealthAutoConfiguration.class)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.health.schedule.enabled", matchIfMissing = true)
@Import(DefaultTaskSchedulerConfiguration.class)
@ConditionalOnClass({ GrpcServerFactory.class, Grpc.class, HealthStatusManager.class })
public final class GrpcServerHealthSchedulerAutoConfiguration {
@Bean
@ConditionalOnBean({ TaskScheduler.class, GrpcServerHealth.class })
GrpcServerHealthScheduler grpcServerHealthScheduler(ConfigurableApplicationContext applicationContext,
GrpcServerHealth grpcServerHealth, HealthStatusManager grpcServerHealthStatusManager,
TaskScheduler taskScheduler, GrpcServerHealthProperties properties) {
Schedule schedule = properties.getSchedule();
return new GrpcServerHealthScheduler(applicationContext, grpcServerHealth,
grpcServerHealthStatusManager, taskScheduler, schedule.getPeriod(), schedule.getDelay());
}
}
默认:每 5 秒刷新一次,延迟 5 秒开始 (period = 5s、delay = 5s)。
为什么是"轮询"而不是"事件驱动"? 因为 Boot 的健康指示器(如数据库健康检查)本身是拉取式 的------ 你不主动调
health()就不知道状态。 gRPC 的HealthStatusManager需要被"推送",所以只能定时拉。 这是两个模型的阻抗不匹配,轮询是唯一的桥接方式。
9.2 两个条件注解的嵌套:AnyNestedCondition 里的 AllNestedConditions
java
static class NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition extends AnyNestedCondition {
NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnBean(BindableService.class)
@ConditionalOnBooleanProperty(name = "spring.grpc.server.health.enabled", matchIfMissing = true)
static class NotDisabledAndHasBindableService {
}
@ConditionalOnBooleanProperty(name = "spring.grpc.server.health.enabled")
static class ExplicitlyEnabled {
}
}
AnyNestedCondition = OR。两个内部类的语义:
| 内部条件 | 含义 |
|---|---|
NotDisabledAndHasBindableService |
有 BindableService 且 health 未显式禁用(@ConditionalOnBean + @ConditionalOnBooleanProperty 是 AND) |
ExplicitlyEnabled |
health 被显式启用 (enabled=true,无 matchIfMissing) |
翻译成人话:
"只要有 gRPC 服务且没关健康 → 启用; 或者你显式写了
spring.grpc.server.health.enabled=true→ 也启用(哪怕没有服务)。"
第二个分支的意义:允许"只有健康服务、没有业务服务"的场景。 (与 §1.1 讨论的反射服务自举问题呼应。)
注意第二个分支没有 matchIfMissing : @ConditionalOnBooleanProperty(name = "...") 默认 matchIfMissing = false (第 15 章讲过 @ConditionalOnProperty 的默认值)。 所以必须是显式 true。✓
9.3 GrpcServerHealth:Boot Status → gRPC ServingStatus
java
public void update(BiConsumer<String, ServingStatus> updater) {
Cache cache = new Cache();
HealthCheckedGrpcComponent serverComponent = this.components.getServer();
if (serverComponent != null) {
updater.accept("", getServingStatus(cache, serverComponent));
}
for (String serviceName : this.components.getServiceNames()) {
HealthCheckedGrpcComponent serviceComponent = this.components.getService(serviceName);
if (!serviceName.isEmpty() && serviceComponent != null) {
updater.accept(serviceName, getServingStatus(cache, serviceComponent));
}
}
}
gRPC 健康协议允许按服务名查询健康状态 。 ""(空字符串)表示"整个服务器"。Boot 同时维护两者。
递归收集状态:
java
private void collectStatuses(Cache cache, HealthCheckedGrpcComponent component, Set<Status> statuses,
HealthContributors contributors, String prefix) {
for (HealthContributors.Entry entry : contributors) {
String name = (prefix.isEmpty()) ? entry.name() : prefix + "/" + entry.name();
if (entry.contributor() instanceof CompositeHealthContributor composite) {
collectStatuses(cache, component, statuses, composite, name);
}
else if (component.isMember(name)) {
Health health = cache.getHealth(name, (HealthIndicator) entry.contributor());
if (health != null) {
statuses.add(health.getStatus());
}
}
}
}
三个要点:
① 路径用 / 拼接 ------ db/db/primary/diskSpace, 与 Actuator 的健康路径命名一致。
② CompositeHealthContributor 递归 ------ 组合节点不产生状态,只递归。
③ Cache 避免重复调用
java
static class Cache {
private final Map<String, Health> health = new HashMap<>();
@Nullable Health getHealth(String name, HealthIndicator indicator) {
return this.health.computeIfAbsent(name, (key) -> indicator.health(false));
}
}
indicator.health(false) ------ 参数是 includeDetails。 传 false 表示"不要详细信息",因为这里只关心 Status。 性能优化 :健康检查可能很贵(查数据库), 而同一个指示器可能被多个 gRPC 服务引用(server 级 + 多个 service 级), 缓存避免重复执行。
Cache是方法局部变量 (new Cache()在update()里), 所以每次update()都新建------缓存只在单次 update 内 有效。 这是正确的:跨 update 缓存会导致状态不刷新。
9.4 两个"策略"接口:StatusAggregator 与 StatusMapper
| 接口 | 输入 | 输出 | 默认实现 |
|---|---|---|---|
StatusAggregator |
Set<Status>(多个 Boot 状态) |
一个 Status |
aggregate(Set),可按 order 排序 |
StatusMapper |
Status |
ServingStatus(gRPC) |
of(Map<String, ServingStatus>) |
为什么要两个? 因为"多个状态聚合成一个"和"一个状态映射到 gRPC 语义"是两个不同的决策:
scss
health("db") = UP ┐
health("disk") = DOWN ┴→ StatusAggregator → Status.DOWN → StatusMapper → ServingStatus.NOT_SERVING
配置:
yaml
spring:
grpc:
server:
health:
status:
order: [DOWN, OUT_OF_SERVICE, UP, UNKNOWN] # 聚合优先级
mapping: # 映射到 gRPC
UP: SERVING
DOWN: NOT_SERVING
OUT_OF_SERVICE: NOT_SERVING
UNKNOWN: UNKNOWN
order 的语义 :聚合时按 order 里最先出现 的状态返回。 上例中 DOWN 排最前,所以只要有一个 DOWN 就是 DOWN。 这与 Actuator 的 management.endpoint.health.status.order 是同一套概念。
StatusMapper.of(mapping) ------ 从 Map 构造。 ⚠️ 未核实 :StatusMapper.of 对未列出 的 Status 怎么处理 (抛异常?用默认映射?)。从 SimpleStatusAggregator/SimpleServingStatusMapper 的命名看应该有兜底。
9.5 按服务定制:@Qualifier 驱动的多实例
java
private <T> T getQualifiedBean(BeanFactory beanFactory, Class<T> type, String qualifier, Supplier<T> fallback) {
try {
return BeanFactoryAnnotationUtils.qualifiedBeanOfType(beanFactory, type, qualifier);
}
catch (NoSuchBeanDefinitionException ex) {
return fallback.get();
}
}
每个 gRPC 服务可以有自己的 StatusAggregator / StatusMapper , 通过 @Qualifier("serviceName") 匹配:
java
@Bean
@Qualifier("users")
StatusMapper usersStatusMapper() {
return StatusMapper.of(Map.of("UP", ServingStatus.SERVING, "DOWN", ServingStatus.SERVING));
// ↑ 例:users 服务出问题时仍然 SERVING(让负载均衡慢慢摘流量)
}
而"无限定符"的 bean 怎么找? 看 getNonQualifiedBean:
java
private <T> T getNonQualifiedBean(ListableBeanFactory beanFactory, Class<T> type, Supplier<T> fallback) {
List<String> candidates = new ArrayList<>();
for (String beanName : BeanFactoryUtils.beanNamesForTypeIncludingAncestors(beanFactory, type)) {
String[] aliases = beanFactory.getAliases(beanName);
if (!BeanFactoryAnnotationUtils.isQualifierMatch(
(qualifier) -> !qualifier.equals(beanName) && !ObjectUtils.containsElement(aliases, qualifier),
beanName, beanFactory)) {
candidates.add(beanName);
}
}
if (candidates.isEmpty()) {
return fallback.get();
}
if (candidates.size() == 1) {
return beanFactory.getBean(candidates.get(0), type);
}
return beanFactory.getBean(type);
}
逻辑 :遍历所有该类型的 bean,找出没有任何限定符的那些。
isQualifierMatch(predicate, ...) 的 predicate 是 !qualifier.equals(beanName) && !contains(aliases, qualifier)------ 即"存在不等于自己名字/别名的限定符"才算"有 qualifier"。
这段逻辑比较绕 ,我的理解是: Spring 里 bean 名本身可以当限定符用(
@Qualifier("myBean")), 所以要排除"限定符恰好等于 bean 名"的情况------那不算"真正的限定符"。 ⚠️ 未核实:这段代码的边界情况。我只读了一遍,没有逐分支推演。
最后 beanFactory.getBean(type) :多个候选时交给 Spring 自己解析 (会考虑 @Primary)。
9.6 成员校验:HealthContributorMembershipValidator
java
@Bean
@ConditionalOnBooleanProperty(name = VALIDATE_MEMBERSHIP_PROPERTY, matchIfMissing = true)
HealthContributorMembershipValidator grpcServerHealthServiceMembershipValidator(
GrpcServerHealthProperties properties, HealthContributorRegistry healthContributorRegistry,
ObjectProvider<ReactiveHealthContributorRegistry> reactiveHealthContributorRegistry) {
return new HealthContributorMembershipValidator(healthContributorRegistry,
reactiveHealthContributorRegistry.getIfAvailable(), VALIDATE_MEMBERSHIP_PROPERTY,
(members) -> properties.getService().forEach((serviceName, service) -> {
String property = "spring.grpc.server.health.service." + serviceName;
members.member(property + ".include", service.getInclude());
members.member(property + ".exclude", service.getExclude());
}));
}
作用 :如果你在 spring.grpc.server.health.service.users.include 里 写了一个不存在 的健康指示器名字,启动时报错。
这又是"诊断能力提前布局": 拼写错误的 include/exclude 会导致"该服务永远健康"(因为匹配不到任何指示器), 这是极其危险的静默失败------健康检查形同虚设但没人知道。 Boot 选择启动时校验。
默认开启 (matchIfMissing = true)。
10. 安全:gRPC 与 Spring Security 的三条集成路径
GrpcServerSecurityAutoConfiguration 有 4 个内部配置类,是本章结构最复杂的自动配置。
java
@AutoConfiguration(after = GrpcServerAutoConfiguration.class,
afterName = "org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration")
@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true)
@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, ObjectPostProcessor.class })
@Import({ ExceptionHandlerConfiguration.class, GrpcNativeSecurityConfigurerConfiguration.class,
GrpcServletSecurityConfigurerConfiguration.class })
public final class GrpcServerSecurityAutoConfiguration {
ObjectPostProcessor.class 在 @ConditionalOnClass 里 ------ 这是 Spring Security 的标志性类(@since 3.2),用它判断"Security 在 classpath"。
10.1 三条路径
| 内部配置 | 触发条件 | 机制 |
|---|---|---|
ExceptionHandlerConfiguration |
无条件 | 注册 SecurityGrpcExceptionHandler(把 AccessDeniedException 转成 gRPC 状态) |
GrpcNativeSecurityConfigurerConfiguration |
@ConditionalOnBean(GrpcServerFactory.class) |
独立 gRPC 服务器的 GrpcSecurity |
GrpcServletSecurityConfigurerConfiguration |
@ConditionalOnBean({GrpcServletRegistration, SecurityFilterChain}) |
servlet 模式:靠 SecurityContextServerInterceptor + 线程池传播 |
GrpcServerCoroutineStubConfiguration |
io.grpc.kotlin.CoroutineContextServerInterceptor |
Kotlin 协程上下文传播 |
第二条与第三条互斥 (一个需要 GrpcServerFactory,一个需要 GrpcServletRegistration), 正好对应"独立服务器"与"挂 servlet"两种部署形态。
10.2 ★ servlet 模式的线程池技巧
java
@Bean
@ConditionalOnMissingBean
GrpcServerExecutorProvider grpcServerExecutorProvider() {
return () -> new DelegatingSecurityContextExecutor(GrpcUtil.SHARED_CHANNEL_EXECUTOR.create());
}
这是本章最值得单独记的一行代码。
问题:servlet 模式下,gRPC 方法在 Tomcat 的线程 上执行。 Spring Security 的 SecurityContext 存在 ThreadLocal 里。
SecurityContextServerInterceptor 在拦截器线程 上设置 SecurityContext------ 但 gRPC 的方法调用可能被派发到另一个线程(executor)。
解法 :把 executor 包一层 DelegatingSecurityContextExecutor------ 每次提交任务时,把当前线程的 SecurityContext "带过去",执行完再还原。
这是 Spring Security 的标准工具类,难得的是 Boot 想到了在 gRPC 场景用它。
GrpcServerExecutorProvider在这里被用作扩展点 : 它本来是"给 gRPC 服务器提供 executor 的策略接口"(§4.3 里被GrpcServerBuilderCustomizers当成定制器之一),Security 模块用它来替换 executor 。 一个接口,两种用途------这是"扩展点复用"的好例子。
10.3 GrpcDisableCsrfHttpConfigurer:通过 spring.factories 注入 Security
java
class GrpcDisableCsrfHttpConfigurer extends AbstractHttpConfigurer<GrpcDisableCsrfHttpConfigurer, HttpSecurity> {
@Override
public void init(HttpSecurity http) {
ApplicationContext context = http.getSharedObject(ApplicationContext.class);
if (context != null && isCsrfConfigurerPresent(http) && hasBean(context, GrpcServiceDiscoverer.class)
&& hasBean(context, GrpcServletRegistration.class) && !isCsrfEnabled(context)) {
http.csrf(this::disable);
}
}
private void disable(CsrfConfigurer<HttpSecurity> csrf) {
csrf.requireCsrfProtectionMatcher(GrpcCsrfRequestMatcher.INSTANCE);
}
static class GrpcCsrfRequestMatcher implements RequestMatcher {
static final GrpcCsrfRequestMatcher INSTANCE = new GrpcCsrfRequestMatcher();
@Override
public boolean matches(HttpServletRequest request) {
return CsrfFilter.DEFAULT_CSRF_MATCHER.matches(request) && !GrpcRequest.toAnyService().matches(request);
}
}
}
为什么 gRPC 要关 CSRF?
CSRF 保护要求请求带 token。gRPC 客户端(尤其是非浏览器客户端) 不会带 CSRF token。如果 CSRF 开启,所有 gRPC 请求都会被拒。
但它没有"全关" ------GrpcCsrfRequestMatcher 的逻辑是:
需要 CSRF 保护 = 默认匹配器匹配 && 不是 gRPC 请求
只对 gRPC 请求豁免,其它 HTTP 请求仍然受 CSRF 保护。 这是正确的做法。
四个条件缺一不可:
java
isCsrfConfigurerPresent(http) // ① Security 里启用了 CSRF
&& hasBean(context, GrpcServiceDiscoverer.class) // ② 有 gRPC 服务
&& hasBean(context, GrpcServletRegistration.class) // ③ 是 servlet 模式(独立服务器不需要!)
&& !isCsrfEnabled(context) // ④ spring.grpc.server.security.csrf.enabled != true
③ 是关键 :独立 gRPC 服务器(netty)不走 servlet 过滤器链, 压根不会被 CSRF 拦,所以不需要这个 configurer。
④ 默认 :isCsrfEnabled 默认 false → 默认豁免 gRPC 的 CSRF。
10.4 OAuth2:两个互斥的 @Bean
java
@Bean
@ConditionalOnBean(OpaqueTokenIntrospector.class)
@GlobalServerInterceptor
AuthenticationProcessInterceptor opaqueTokenAuthenticationProcessInterceptor(GrpcSecurity grpcSecurity) ...
@Bean
@ConditionalOnBean(JwtDecoder.class)
@GlobalServerInterceptor
AuthenticationProcessInterceptor jwtAuthenticationProcessInterceptor(GrpcSecurity grpcSecurity) ...
两个 @Bean 方法返回同一类型 AuthenticationProcessInterceptor , 靠 @ConditionalOnBean 互斥(有 opaque token introspector 就用第一个, 有 JWT decoder 就用第二个)。
如果两个都有? 两个 bean 都注册 → 但类上有 @ConditionalOnMissingBean(AuthenticationProcessInterceptor.class), 那是类级 条件,在方法级条件之前求值,所以防不住方法级的多实例。
⚠️ 未核实 :同时有
OpaqueTokenIntrospector和JwtDecoder时会怎样。 推断:两个AuthenticationProcessInterceptorbean 都注册, 收集时可能有歧义或都应用。自测方法:同时配两种 resource server 看启动日志。
两个方法体是一样的模式:
java
grpcSecurity.authorizeRequests((requests) -> requests.allRequests().authenticated());
grpcSecurity.oauth2ResourceServer((resourceServer) -> resourceServer.opaqueToken(withDefaults()));
return grpcSecurity.build();
"所有请求都要认证" ------ 这是最保守的默认。
11. 测试支持:spring-boot-grpc-test 与 in-process 传输
11.1 @AutoConfigureTestGrpcTransport:一个"反向切片"
java
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@ImportAutoConfiguration
public @interface AutoConfigureTestGrpcTransport {
@PropertyMapping("spring.grpc.server.servlet.enabled")
boolean enableServlet() default false;
@PropertyMapping("spring.grpc.server.factory.enabled")
boolean enableServerFactory() default false;
@PropertyMapping("spring.grpc.client.channelfactory.enabled")
boolean enableChannelFactory() default false;
}
★ @ImportAutoConfiguration 上的 @PropertyMapping ------ 这个组合我在前二十章没见过。
@PropertyMapping 的作用是:把注解的属性值 映射成配置项。 于是:
java
@AutoConfigureTestGrpcTransport // 三个都是 false
// 等价于:
spring.grpc.server.servlet.enabled=false
spring.grpc.server.factory.enabled=false
spring.grpc.client.channelfactory.enabled=false
三个默认 false 的含义 : "用 in-process 传输测试时,不需要 servlet 注册、不需要 额外的服务器工厂、 不需要 额外的通道工厂------TestGrpcTransportAutoConfiguration 已经提供了。"
这是"切片测试"的思路(第 29 章会系统讲): 测试时把不必要的组件关掉,只留必要的。
11.2 TestGrpcTransportAutoConfiguration:一个静态地址连接两端
java
@AutoConfiguration(before = { GrpcServerAutoConfiguration.class, GrpcClientAutoConfiguration.class })
@ConditionalOnClass({ InProcessServerBuilder.class, InProcessGrpcServerFactory.class })
public final class TestGrpcTransportAutoConfiguration {
private static final String address = InProcessServerBuilder.generateName();
private static final String address ------ 静态字段!
这意味着:同一个 JVM 内,所有 TestGrpcTransportAutoConfiguration 实例共享同一个地址 。 服务器注册在这个名字上,客户端连这个名字------自动配对,不需要配置端口。
这是一个巧妙的设计 ,但也带来一个限制: ⚠️ 未核实 :如果测试里需要两个独立的 in-process 服务器(多上下文场景), 这个静态地址会不会冲突。推断:会 ------两个服务器都注册到同一个名字。 实际上
@AutoConfigureTestGrpcTransport的enableServerFactory就是给用户 "再加一个自己的工厂"用的,但地址还是同一个。
before = { GrpcServerAutoConfiguration, GrpcClientAutoConfiguration } ------ 必须排在最前,因为它的 TestGrpcServerFactory / TestGrpcChannelFactory 要抢在真正的工厂之前注册,靠 @ConditionalOnMissingNetworkGrpcServer 把 netty/shaded 的挤掉。
这也解释了为什么它的 bean 都带 @Order(Ordered.HIGHEST_PRECEDENCE)。
11.3 GrpcServerPortInfoAutoConfiguration:@since 4.1.1
java
@AutoConfiguration(before = GrpcServerAutoConfiguration.class)
@ConditionalOnClass(GrpcServerStartedEvent.class)
public final class GrpcServerPortInfoAutoConfiguration {
public static final String PROPERTY_NAME = "local.grpc.server.port";
这一章写的就是当前版本的功能 ------@since 4.1.1, 而本章分析的是 Spring Boot 4.1.1。这是"刚出炉"的代码。
它与第 18 章的 ServerPortInfoApplicationContextInitializer 是同构的:
| Web(第 18 章) | gRPC(本章) | |
|---|---|---|
| 属性名 | local.server.port / local.management.port |
local.grpc.server.port |
| 事件 | WebServerInitializedEvent |
GrpcServerStartedEvent |
| 属性源名 | server.ports |
server.ports(同名) |
| 插入方式 | addFirst |
addFirst |
| 向父上下文传播 | ✅ | ✅ |
属性源名也用了 server.ports ------ 如果同一个应用里既有 Web 服务器又有 gRPC 服务器, 两者共用同一个 server.ports 属性源。
java
private void setPortProperty(ConfigurableEnvironment environment, int port) {
MutablePropertySources sources = environment.getPropertySources();
PropertySource<?> source = sources.get(PROPERTY_SOURCE_NAME);
if (source == null) {
source = new MapPropertySource(PROPERTY_SOURCE_NAME, new HashMap<>());
sources.addFirst(source);
}
setPortProperty(port, source);
}
复用而非覆盖 ------如果 Web 那边已经建了 server.ports, gRPC 直接往同一个 Map 里 put local.grpc.server.port。这是有意的。
跳过条件:
java
if (factory instanceof InProcessGrpcServerFactory || factory instanceof TestGrpcServerFactory
|| event.getPort() == -1) {
return;
}
in-process 与 test 服务器不设置端口属性 ------它们没有真实端口。 event.getPort() == -1 是兜底(服务器报告"无端口")。
⚠️ 未核实 :
GrpcServerStartedEvent.getPort()在独立服务器模式下 是否一定 > 0。-1的判定说明存在"拿不到端口"的情况。
12. 事实清单
本章从源码直接确认的事实(挑最容易被忽略的):
spring-boot-grpc-server与spring-boot-grpc-client全部类都是@since 4.1.0------ Boot 4.1 全新模块,无历史包袱。- 两个模块都
api("org.springframework.grpc:spring-grpc-core")------ 核心实现不在 Spring Boot 仓库,Boot 只提供自动配置层。 - server 侧 7 条
.imports,client 侧 3 条 ;另有 1 份spring.factories注册 Spring Security 的AbstractHttpConfigurer。 GrpcServerServicesAutoConfiguration有before = GrpcServerAutoConfiguration.class, 它注册的ProtoReflectionServiceV1本身是BindableService。GrpcServerAutoConfiguration类级@ConditionalOnBean(BindableService.class)------ 没有 gRPC 服务就不起服务器(与 Web 服务器不同)。- server 四种工厂优先级:servlet > shaded netty > netty (由
@Import顺序决定),InProcessGrpcServerFactory靠ignored不参与竞争。 NettyGrpcServerConfiguration与ShadedNettyGrpcServerConfiguration的@ConditionalOnClass(NettyServerBuilder.class)名字相同但 import 来自不同包。MissingNetworkGrpcServerCondition extends AllNestedConditions, phase 是REGISTER_BEAN(必须,否则三个网络工厂会同时生效)。NettyAddress用toString()作为业务方法 ;transport显式指定时会 静默忽略 不一致的address/port/domain-socket-path。ServerCredentials:ssl.enabled未配但配了ssl.bundle→ SSL 自动启用 ;enabled=true但无 bundle → 抛异常。ServletGrpcServerConfiguration用FailureAnalyzedException做启动期校验, 要求server.http2.enabled=true(可由validate-http2=false关闭)。GrpcServletRegistration的 URL 映射是"/" + serviceName + "/*",由服务描述符决定 ; 且调serviceConfigurer.configure(spec, null)(传 null)。PropertiesServerBuilderCustomizer用字符串类名 探测 builder 类型 (InProcessServerBuilder/ServletServerBuilder不支持 keepalive)。- client 侧通道是
Map<String, Channel>,PropertiesVirtualTargets做 "名字 → 地址"三级回退 (名字 →default→ 原样)。 CompositeChannelFactoryAutoConfiguration用NoneNestedConditions实现 "只有 0 个或 1 个时不注册,2+ 个时才注册 Composite"。GrpcClientDefaultServiceConfigCustomizer的存在理由是 Javadoc 明说的: 避免defaultServiceConfig(map)的 "last wins"。ServiceConfig.LoadBalancingConfig在 record 紧凑构造器里校验"四选一"。PropertiesGrpcChannelBuilderCustomizer用STOCK_DEFAULT_CHANNEL(空对象) 避免 NPE;deadline靠DefaultDeadlineSetupClientInterceptor实现(builder 无此 API)。- gRPC 健康用轮询 (默认
period=5s、delay=5s)桥接 Boot 拉取式健康模型。 GrpcServerHealth的Cache是方法局部 变量------只在单次update()内有效。- servlet 模式下 Security 用
DelegatingSecurityContextExecutor传播 SecurityContext。 GrpcDisableCsrfHttpConfigurer不是全关 CSRF ,而是用GrpcCsrfRequestMatcher只豁免 gRPC 请求。TestGrpcTransportAutoConfiguration的address是static final------ 同 JVM 内共享,服务器与客户端自动配对。GrpcServerPortInfoAutoConfiguration是@since 4.1.1(本章分析的版本号), 属性名local.grpc.server.port,属性源名复用server.ports。@ConditionalOnBooleanProperty在 server 侧 7 处、client 侧用@ConditionalOnProperty------同一模块两套写法。
13. 未核实清单
以下条目我没有从源码确认(按验证成本排序):
因为 spring-grpc-core / grpc-java 不在本仓库 (无法核实): GrpcServerFactory 的启动时机与端口绑定;GrpcServerLifecycle 的 phase; DefaultGrpcServiceDiscoverer 如何发现 BindableService; GrpcServiceConfigurer.configure(spec, null) 传 null 的含义; @GlobalServerInterceptor 的收集机制与是否按 @Order 排序; CompositeGrpcChannelFactory 按什么规则委派; GrpcUtils.DEFAULT_PORT / ANY_IP_ADDRESS 的具体值(推断 9090 ); GrpcServerStartedEvent.getPort() 何时返回 -1; GrpcServerHealthScheduler 的实现;Ssl/Servlet 内部类的完整字段; StatusMapper.of 对未列出 Status 的兜底;GrpcServerExecutorProvider 在第 4 参数位置的语义。
因为没读测试或没推演 (可以核实): PropertiesVirtualTargets.clean() 的正则边界("/*" 匹配空串); getNonQualifiedBean 的 isQualifierMatch 各分支; @ConditionalOnBooleanProperty 与 @ConditionalOnProperty 的实质差异; GrpcServerProperties.Servlet.validateHttp2 的默认值(推断 true ); NoneNestedConditions 在 REGISTER_BEAN 阶段的求值时机; GrpcChannelBuilderCustomizers 类上 @Order(LOWEST_PRECEDENCE) 的消费方; 同时有 OpaqueTokenIntrospector 与 JwtDecoder 时的行为; TestGrpcTransportAutoConfiguration 静态地址在多上下文下是否冲突。
遗留未解决 : 第 17--20 章累积的 WAR 部署 WebApplicationContextInitializer 两次调用问题仍未解决。 第 19 章遗留的 Jackson 3 WRITE_DATES_AS_TIMESTAMPS 默认值仍未实测。
14. 方法论:本章沉淀的三条定律
14.1 第二十五定律:条件里的"phase"决定条件能否正确工作
当一个条件问的是"有没有某个 bean"时,它的
ConfigurationPhase必须是REGISTER_BEAN;用PARSE_CONFIGURATION会恒为真,导致本该互斥的配置全部生效。
MissingNetworkGrpcServerCondition 就是这条定律的正面例子: super(ConfigurationPhase.REGISTER_BEAN) 这一行, 是"三种服务器工厂互斥"能成立的唯一原因。
判断方法:问自己"我问的信息在所求值的阶段已经存在了吗" 。 bean 定义 → REGISTER_BEAN;注解/类 → PARSE_CONFIGURATION 就够。
14.2 第二十六定律:可选依赖的能力探测用"字符串类名"
当模块对某个类是 optional 依赖、却又需要在运行时判断"你是不是这个类"时, 固定写法是
ClassUtils.forName("fqcn", classLoader).isInstance(x)+ catch 返回 false。
本章出现两处: PropertiesServerBuilderCustomizer.supportsKeepAliveProperties(探测 builder 类型)、 GrpcServerAutoConfiguration 的 @ConditionalOnClass(name = "com.salesforce...")。
对照第 19 章 mvcValidator() 的字符串类名 ClassUtils.isPresent------ 同一问题的三种变体 : isPresent(类在不在)、isInstance(对象是不是)、@ConditionalOnClass(name=)(注解版)。
14.3 第二十七定律:配置是 Map 时,用 accumulator 而非 setter
当多个来源要往同一个"配置 Map"里写条目时, 提供"往 Map 里加条目"的扩展点(accumulator), 而不是让每个来源各自调 setter(后者是 last-wins,会互相覆盖)。
GrpcClientDefaultServiceConfigCustomizer 的存在理由被 Javadoc 明说了: "rather than having a 'last wins' outcome"。
推广 :任何"配置最终形态是 Map/JSON/Properties"的场景都适用。 Boot 里同类的还有 EnvironmentPostProcessor 往 Environment 加属性源 (而不是直接改某个属性源)、HttpHeaders 的 add vs set。 add vs set 的区分,本质就是 accumulator vs setter。
15. 下一章预告
第 22 章进入卷六 · 数据访问:数据源、连接池与事务。
从这里开始,module/ 下的模块数量会突然膨胀------ spring-boot-jdbc、spring-boot-jpa、spring-boot-hibernate、 spring-boot-data-*(十几个)、spring-boot-flyway、spring-boot-liquibase、 spring-boot-jooq、spring-boot-r2dbc......
与 gRPC 的"两个模块写透一个协议"不同, 数据访问是"几十个模块共享一套契约 ", 重点会从"某个能力的实现"转向"Boot 如何组织如此庞大的可选依赖矩阵"。
第 21 章留下的两个伏笔会在那里得到呼应:
SslBundles------ 数据源也用它(第 30 章展开)- 第二十六定律的"字符串类名探测" ------ 数据访问模块里会大量出现 (
@ConditionalOnClass(name = "com.zaxxer.hikari.HikariDataSource")之类)