Spring Boot 4.1.1 深度源码解析-第 21 章 · gRPC 新纪元:`spring-boot-grpc-server`

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 怎么扫描 BindableService bean------这些都读不到。 我会把每一处"越界"的地方明确标出来,不拿推断冒充事实。

0.2 为什么 Boot 要"官方支持" gRPC

这个问题在源码里没有答案(Javadoc 不讲动机),我给出的是基于代码结构的推断,不是事实:

  1. gRPC 长期是"二等公民" :过去 Boot 用户要么用 grpc-spring-boot-starter(第三方), 要么自己写 @Bean 装配。spring-grpc 是 Spring 官方项目(与 spring-batch、spring-security 同级), Boot 提供自动配置是顺理成章的收编。
  2. HTTP/2 是 gRPC 的硬依赖 ,而 Boot 恰好在 4.x 把 server.http2.enabled 做成了一等配置(ServletGrpcServerConfiguration 里就有对它的校验,见 §5.4)。 两个能力的成熟期撞上了。
  3. 观测与安全需要深度集成 :从 §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-core
  • io.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 转成 ServerServiceDefinition
  • factory.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: 所有内部嵌套类的条件都满足才匹配。这里要求:

  • 没有 GrpcServletRegistration bean
  • 且 没有 GrpcServerFactory bean(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:

java 复制代码
ServerCredentials 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 不匹配(即"有 GrpcChannelFactory bean")
  • 且 条件 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 a GrpcChannelBuilderCustomizer since 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 时会怎样。 推断:两个 AuthenticationProcessInterceptor bean 都注册, 收集时可能有歧义或都应用。自测方法:同时配两种 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. 事实清单

本章从源码直接确认的事实(挑最容易被忽略的):

  1. spring-boot-grpc-server 与 spring-boot-grpc-client 全部类都是 @since 4.1.0------ Boot 4.1 全新模块,无历史包袱。
  2. 两个模块都 api("org.springframework.grpc:spring-grpc-core")------ 核心实现不在 Spring Boot 仓库,Boot 只提供自动配置层。
  3. server 侧 7 条 .imports,client 侧 3 条 ;另有 1 份 spring.factories 注册 Spring Security 的 AbstractHttpConfigurer。
  4. GrpcServerServicesAutoConfiguration 有 before = GrpcServerAutoConfiguration.class, 它注册的 ProtoReflectionServiceV1 本身是 BindableService。
  5. GrpcServerAutoConfiguration 类级 @ConditionalOnBean(BindableService.class)------ 没有 gRPC 服务就不起服务器(与 Web 服务器不同)。
  6. server 四种工厂优先级:servlet > shaded netty > netty (由 @Import 顺序决定), InProcessGrpcServerFactory 靠 ignored 不参与竞争。
  7. NettyGrpcServerConfiguration 与 ShadedNettyGrpcServerConfiguration 的 @ConditionalOnClass(NettyServerBuilder.class) 名字相同但 import 来自不同包。
  8. MissingNetworkGrpcServerCondition extends AllNestedConditions, phase 是 REGISTER_BEAN(必须,否则三个网络工厂会同时生效)。
  9. NettyAddress 用 toString() 作为业务方法 ;transport 显式指定时会 静默忽略 不一致的 address/port/domain-socket-path。
  10. ServerCredentials:ssl.enabled 未配但配了 ssl.bundle → SSL 自动启用 ; enabled=true 但无 bundle → 抛异常。
  11. ServletGrpcServerConfiguration 用 FailureAnalyzedException 做启动期校验, 要求 server.http2.enabled=true(可由 validate-http2=false 关闭)。
  12. GrpcServletRegistration 的 URL 映射是 "/" + serviceName + "/*",由服务描述符决定 ; 且调 serviceConfigurer.configure(spec, null)(传 null)。
  13. PropertiesServerBuilderCustomizer 用字符串类名 探测 builder 类型 (InProcessServerBuilder / ServletServerBuilder 不支持 keepalive)。
  14. client 侧通道是 Map<String, Channel>,PropertiesVirtualTargets 做 "名字 → 地址"三级回退 (名字 → default → 原样)。
  15. CompositeChannelFactoryAutoConfiguration 用 NoneNestedConditions 实现 "只有 0 个或 1 个时不注册,2+ 个时才注册 Composite"。
  16. GrpcClientDefaultServiceConfigCustomizer 的存在理由是 Javadoc 明说的: 避免 defaultServiceConfig(map) 的 "last wins"。
  17. ServiceConfig.LoadBalancingConfig 在 record 紧凑构造器里校验"四选一"。
  18. PropertiesGrpcChannelBuilderCustomizer 用 STOCK_DEFAULT_CHANNEL(空对象) 避免 NPE;deadline 靠 DefaultDeadlineSetupClientInterceptor 实现(builder 无此 API)。
  19. gRPC 健康用轮询 (默认 period=5s、delay=5s)桥接 Boot 拉取式健康模型。
  20. GrpcServerHealth 的 Cache 是方法局部 变量------只在单次 update() 内有效。
  21. servlet 模式下 Security 用 DelegatingSecurityContextExecutor 传播 SecurityContext。
  22. GrpcDisableCsrfHttpConfigurer 不是全关 CSRF ,而是用 GrpcCsrfRequestMatcher 只豁免 gRPC 请求。
  23. TestGrpcTransportAutoConfiguration 的 address 是 static final------ 同 JVM 内共享,服务器与客户端自动配对。
  24. GrpcServerPortInfoAutoConfiguration 是 @since 4.1.1 (本章分析的版本号), 属性名 local.grpc.server.port,属性源名复用 server.ports。
  25. @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") 之类)
相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 20 章 · WebFlux 与 Reactor Netty:响应式栈与编解码
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 13 章 · Bean 定义加载:`BeanDefinitionLoader`、组件扫描与 Bean 命名
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 18 章 内嵌 Web 服务器:`WebServer` 抽象与 Tomcat/Jetty/Netty
java·后端·开源
泡茶喝茶写代码1 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 1 篇):回测引擎从零搭建
java·python·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 10 章 · Profile 与多文档:`spring.config.activate`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 06 章 · Environment 构建:PropertySource 次序
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 09 章 · `@ConfigurationProperties` 全生命周期:注册、绑定、校验与 AOT
java·后端·开源
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 百度人脸识别:抽取自动装配组件
java·百度·交友
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 08 章 · Binder 绑定引擎:从字符串到 Java 对象的完整路径
java·后端·开源