《Java 服务端通信工程》5.2 自定义 NameResolver

forTarget 里的 scheme 决定谁来解析地址。本节实现 static-registry scheme 的 NameResolverProvider 与 Listener2,把 StaticRegistry 的端点转成 EquivalentAddressGroup,并用 round_robin 跑出真实的 resolver-reply=ok。

本节目标:能写出一个可注册、可刷新、可关闭的 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()默认 schemegetScheme() 默认委托给它
newNameResolver(URI, Args)创建 resolverscheme 不匹配返回 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 与健康检查

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收