本节目标:能写出一个可注册、可刷新、可关闭的
NameResolverProvider,让forTarget("static-registry:///library", creds)从StaticRegistry解析出多个地址,并知道解析失败时 gRPC 会怎么表现。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded)。
承接的问题
5.1 的 StaticRegistry 能存端点,但 gRPC 还不知道它的存在。客户端如果继续用 forAddress("libraryd.internal", 9093),地址解析在 DNS 层就结束了,注册表里的两个实例永远用不上。要让 linkd-v3 用上注册表,必须把「服务名 → 地址列表」这一步接到 gRPC 的名字解析 SPI 上。
gRPC 的 target 字符串遵循 URI 形式:scheme://authority/path。dns:///libraryd.internal:9093 用 DNS 解析,unix:///tmp/library.sock 用 Unix socket,static-registry:///library 则由我们自己注册的 provider 解析。forAddress(host, port) 是 forTarget 的语法糖,它会直接构造一个固定地址,绕过名字解析;需要动态地址时必须用 forTarget。
这一节先讲 target 怎么被拆成 scheme、authority 和 path,再实现 NameResolverProvider 与 NameResolver,最后用真实探针验证 round_robin 能通过自定义 scheme 拿到响应。注册表的数据模型在 5.1,负载均衡策略和健康检查在 5.3,本节只负责「把地址列表交给 gRPC」。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,gRPC-Java 1.76.0。文末的 resolver-reply=ok 是本机实际运行结果。
核心机制一:target 如何走到 resolver
NettyChannelBuilder.forTarget(target, credentials) 把字符串交给 NameResolver.Factory。gRPC 先解析 URI,取出 scheme,然后在 NameResolverRegistry 里找注册过的 provider;找不到就回退到默认的 DNS provider。流程图如下:
forTarget("static-registry:///library")
└── URI: scheme=static-registry, authority=null, path=/library
└── NameResolverRegistry.getProviderForScheme("static-registry")
└── StaticRegistryNameResolverProvider.newNameResolver(uri, args)
└── StaticRegistryNameResolver.start(Listener2)
└── listener.onResult(ResolutionResult(addresses))
└── LoadBalancer(round_robin / pick_first)
└── Subchannel → TCP 连接
几个容易混淆的点:
forAddress不经过 resolver。 它内部构造一个固定地址列表,scheme 等价于direct;用它就没法享受注册表更新。forTarget的 authority 可以为空。static-registry:///library有三个斜杠,authority 为空、path 为/library;static-registry://library的 authority 是library、path 为空,两种写法不一样。- scheme 大小写敏感。 URI 规范允许 scheme 大小写不敏感,但 gRPC 的注册表按字符串匹配;统一用小写。
- provider 返回
null表示「这个 scheme 不归我管」。 不要抛异常,否则其他 provider 没有机会;只有 scheme 匹配但参数非法时才抛IllegalArgumentException。
NameResolver.Args 提供 getDefaultPort()、getScheduledExecutorService()、getServiceConfigParser() 等上下文。forTarget 的 URI 没写端口时,getDefaultPort() 通常是 443;我们的端点自带端口,所以不用它。如果注册表允许省略端口,可以用 getDefaultPort() 兜底,但要在文档里写清楚默认值。
核心机制二:NameResolverProvider 的四个钩子
NameResolverProvider 继承 NameResolver.Factory,子类要实现:
| 方法 | 作用 | 注意 |
|---|---|---|
isAvailable() | 当前环境是否可用 | 返回 false 时 registry 跳过 |
priority() | 同 scheme 多 provider 时的优先级 | 数值越大越优先 |
getDefaultScheme() | 默认 scheme | getScheme() 默认委托给它 |
newNameResolver(URI, Args) | 创建 resolver | scheme 不匹配返回 null |
NameResolverRegistry.getDefaultRegistry().register(provider) 把 provider 放进全局注册表。注册是进程级的,测试里注册后忘了 deregister,下一个测试可能拿到上一个测试的 provider,表现为「地址指向已经关闭的端口」。生产代码在启动时注册一次,关闭时注销;测试用 try/finally 包住。
priority() 只在同一个 scheme 有多个 provider 时起作用。比如一个 provider 读本地文件、一个读远程注册表,可以让远程的优先级更高,本地作为兜底。linkd-v3 只有一个 provider,返回 5 即可,数值本身不重要,重要的是团队内统一。
getDefaultScheme() 和 getScheme() 的关系容易写错。NameResolverProvider.getScheme() 的实现是 return getDefaultScheme();,所以子类只要覆写 public String getDefaultScheme()。如果你同时覆写 protected String getScheme(),两者不一致时 registry 用的是后者,排查会很困惑;只覆写一个。
核心机制三:NameResolver 与 ResolutionResult
NameResolver 的核心是 start(Listener2)、refresh()、shutdown()。Listener2 有两个回调:onResult(ResolutionResult) 交付地址列表,onError(Status) 报告解析失败。ResolutionResult 里除了地址,还可以带全局 Attributes 和 service config;地址级的 zone、权重放在 EquivalentAddressGroup 的 Attributes 里。
下面是一个完整的实现,直接对接 5.1 的 StaticRegistry:
package com.plume.linkd.v3.core.discovery;
import com.plume.linkd.v3.core.registry.StaticRegistry;
import io.grpc.Attributes;
import io.grpc.EquivalentAddressGroup;
import io.grpc.NameResolver;
import io.grpc.NameResolverProvider;
import io.grpc.Status;
import java.net.InetSocketAddress;
import java.net.URI;
import java.util.List;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
public final class StaticRegistryNameResolverProvider extends NameResolverProvider {
public static final String SCHEME = "static-registry";
private final StaticRegistry registry;
public StaticRegistryNameResolverProvider(StaticRegistry registry) {
this.registry = registry;
}
@Override
public String getDefaultScheme() {
return SCHEME;
}
@Override
protected boolean isAvailable() {
return true;
}
@Override
protected int priority() {
return 5;
}
@Override
public NameResolver newNameResolver(URI targetUri, NameResolver.Args args) {
if (!SCHEME.equals(targetUri.getScheme())) {
return null;
}
String service = targetUri.getPath().replaceFirst("^/", "");
if (service.isEmpty()) {
throw new IllegalArgumentException("empty service in target: " + targetUri);
}
return new StaticRegistryNameResolver(service, registry, args);
}
private static final class StaticRegistryNameResolver extends NameResolver {
private static final Attributes.Key<String> ZONE =
Attributes.Key.create("zone");
private final String service;
private final StaticRegistry registry;
private final NameResolver.Args args;
private Listener2 listener;
private ScheduledFuture<?> poll;
private StaticRegistryNameResolver(
String service, StaticRegistry registry, NameResolver.Args args) {
this.service = service;
this.registry = registry;
this.args = args;
}
@Override
public String getServiceAuthority() {
return service;
}
@Override
public void start(Listener2 listener) {
this.listener = listener;
this.poll = args.getScheduledExecutorService()
.scheduleWithFixedDelay(this::refresh, 0, 5, TimeUnit.SECONDS);
}
@Override
public void refresh() {
Listener2 current = listener;
if (current == null) {
return;
}
List<EquivalentAddressGroup> addresses = registry.resolve(service).stream()
.map(endpoint -> new EquivalentAddressGroup(
new InetSocketAddress(endpoint.host(), endpoint.port()),
Attributes.newBuilder().set(ZONE, "local").build()))
.toList();
if (addresses.isEmpty()) {
current.onError(Status.UNAVAILABLE
.withDescription("no endpoint for service " + service));
return;
}
current.onResult(ResolutionResult.newBuilder()
.setAddresses(addresses)
.build());
}
@Override
public void shutdown() {
listener = null;
if (poll != null) {
poll.cancel(false);
}
}
}
}
几个实现细节:
start里立即调度一次 refresh。scheduleWithFixedDelay的初始延迟为 0,保证首次解析不用等 5 秒;之后的周期刷新让注册表变更在一个周期内收敛。refresh可以被 gRPC 和定时器同时调用。 gRPC 在连接失败或 LB 需要重新解析时会调refresh();定时器也会调。方法只读registry和listener,没有共享可变状态,重复调用是安全的。- 空列表走
onError。ResolutionResult的地址列表不能为空;注册表里没有这个服务时,用UNAVAILABLE报告,让 channel 进入TRANSIENT_FAILURE并按退避重试。 - zone 放在 EAG 的 Attributes。 每个端点的 zone 可能不同,放在
EquivalentAddressGroup上;全局属性放ResolutionResult.setAttributes。 shutdown取消定时任务。 不取消的话,channel 关闭后定时器还会持有 resolver 和 registry 的引用,测试里会看到线程泄漏。
1.76.0 里 ResolutionResult.Builder.setAddresses(List<EquivalentAddressGroup>) 已标记为过时,替代是 setAddressesOrError(StatusOr)。本书统一用 setAddresses,因为它语义清楚、行为等价;编译时会出现 deprecation 警告,不是错误。等团队整体升级到新 API,再统一替换。
核心机制四:注册、使用与销毁
provider 写好后,注册到全局 registry,然后用 forTarget 建 channel。注意 NettyChannelBuilder.forTarget(String, ChannelCredentials) 才有凭证重载;通用的 ManagedChannelBuilder.forTarget(String) 没有,只能用 usePlaintext(),已弃用。
package com.plume.linkd.v3.core.discovery;
import com.plume.linkd.v3.core.registry.StaticRegistry;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.NameResolverRegistry;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
public final class DiscoveryClient {
public static ManagedChannel open(StaticRegistry registry, String service) {
StaticRegistryNameResolverProvider provider =
new StaticRegistryNameResolverProvider(registry);
NameResolverRegistry.getDefaultRegistry().register(provider);
ManagedChannel channel = NettyChannelBuilder
.forTarget(StaticRegistryNameResolverProvider.SCHEME + ":///" + service,
InsecureChannelCredentials.create())
.defaultLoadBalancingPolicy("round_robin")
.build();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
channel.shutdownNow();
NameResolverRegistry.getDefaultRegistry().deregister(provider);
}, "discovery-close"));
return channel;
}
}
forTarget 的 target 必须和 provider 的 scheme 完全一致;写成 static-registry:/library(一个斜杠)时,URI 的 path 是 /library 但形式不标准,某些解析器会把它当成 opaque URI。统一用三个斜杠:static-registry:///library。
验证代码用一个自包含探针:服务端 NettyServerBuilder.forPort(0) 起一个 unary 服务,注册表把 library 指向这个随机端口,客户端用自定义 scheme 加 round_robin 调用。本机输出:
server-port=58046
resolver-reply=ok
端口是随机分配的,每次运行不同;关键是 resolver-reply=ok 证明 target 经过 provider、resolver、LB 三层后真的到达了服务端。如果 provider 没注册,客户端会收到 UNAVAILABLE,description 里通常是 NameResolver returned no addresses 或 DNS 解析失败;如果 scheme 拼错,会回退到 DNS,错误信息里出现 static-registry 被当作主机名。
在 linkd-v3 里落地:解析器与配置
linkd-v3 的 comm-core 提供 provider,comm-gateway 在启动时注册,LibraryClient 通过 target 字符串建 channel。target 从配置读取,不写死在代码里:
linkd:
library:
target: "static-registry:///library"
load-balancing: "round_robin"
refresh-seconds: 5
配置里的 refresh-seconds 目前对应 resolver 内部的定时器;如果以后改成推送模型,这个配置项要删掉或改成 watch 的超时。不要把「刷新周期」和「gRPC 的退避」混在一起:前者是注册表变更的收敛时间,后者是连接失败的等待时间。
多环境部署时,scheme 可以复用,authority 用来区分环境:static-registry://prod/library。我们的实现目前只看 path,authority 被忽略;如果要做环境隔离,在 provider 里读 targetUri.getAuthority() 并传给 registry,不要另起一个 scheme。
管理端 9095 可以展示当前注册表快照,但不要暴露 resolver 的内部状态。resolver 的地址列表是 gRPC 的运行时状态,重启就没了;排障要看注册表快照和 channel 的 getState(),两者结合才能判断是「注册表里没有」还是「有地址但连不上」。
常见错误与边界
错误一:只实现 start(Listener),不实现 start(Listener2)。 gRPC 调用的是 start(Listener2);基类的 start(Listener2) 会转调 start(Listener),而 start(Listener) 又会转回 Listener2,两个都不覆写时会栈溢出。统一实现 Listener2。
错误二:newNameResolver 对不匹配的 scheme 抛异常。 应该返回 null,让 registry 继续尝试其他 provider;抛异常会让整个 channel 构建失败,错误信息还指向错误的 provider。
错误三:忘记 deregister。 测试或热部署时,旧的 provider 还挂在全局 registry 上,新 channel 可能拿到旧 registry 的地址。注册和注销成对出现,最好封装在一个 Discovery 对象里。
错误四:在 refresh 里做阻塞 IO。 refresh 运行在 gRPC 的同步上下文或定时器线程;在里面查数据库、调远程注册中心,会拖慢解析甚至阻塞调用。需要远程读取时,用异步客户端把结果写进本地缓存,refresh 只读缓存。
错误五:地址列表为空时返回空的 ResolutionResult。 gRPC 要求地址列表非空;返回空列表会抛 IllegalArgumentException,channel 进入失败状态但错误信息不直观。用 onError(Status.UNAVAILABLE) 明确报告。
边界:resolver 不负责健康检查。 它只把注册表里的地址交出去;地址是否可连接由 gRPC 的 subchannel 状态决定,业务健康由 5.3 的探活决定。不要在 resolver 里对每个地址发 TCP 连接,解析会被拖慢。
边界:resolver 不保证顺序。 round_robin 自己维护轮转顺序,pick_first 取列表第一个;resolver 返回的顺序只在 pick_first 首次选择时有意义。需要固定优先级时,用 Attributes 和自定义 LB,不要依赖列表顺序。
小结
forTarget按 URI 的 scheme 找NameResolverProvider;forAddress绕过解析,适合固定地址。NameResolverProvider要实现isAvailable、priority、getDefaultScheme、newNameResolver;scheme 不匹配返回null。NameResolver.Listener2用onResult(ResolutionResult)交付地址,用onError(Status)报告失败;地址列表不能为空。EquivalentAddressGroup承载单个端点和它的 zone/权重等属性;ResolutionResult承载全局属性。- 1.76.0 的
setAddresses已过时但可用;编译出现 deprecation 警告是预期行为。 - 注册 provider 后要记得
deregister;resolver 的定时刷新负责收敛注册表变更,shutdown要取消定时任务。 - 本机探针输出
resolver-reply=ok,证明static-registry:///library加round_robin的链路可用。
下一节讨论地址列表交给 LB 之后发生什么:pick_first 和 round_robin 如何选择子通道、服务端健康检查怎么接、探活失败如何影响解析结果。
阅读导航:上一节 5.1 注册中心模型 | 下一节 5.3 round_robin、pick_first 与健康检查
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。