ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

gRPC Java 双栈(Dual-stack)示例深入解析:自定义 NameResolver 实现 IPv4/IPv6 与轮询负载均衡

2026/9/15 12:59:19 拓冰建站 浏览量
gRPC Java 双栈(Dual-stack)示例深入解析:自定义 NameResolver 实现 IPv4/IPv6 与轮询负载均衡 gRPC Java 双栈Dual-stack示例深入解析自定义 NameResolver 实现 IPv4/IPv6 与轮询负载均衡【免费下载链接】grpc-javaThe Java gRPC implementation. HTTP/2 based RPC项目地址: https://gitcode.com/GitHub_Trending/gr/grpc-java本指南围绕 grpc-java 仓库中的example-dualstack示例展开讲解如何通过自定义 NameResolver 让同一个 gRPC 客户端在 IPv4/IPv6 双栈环境下连接多个服务实例并配合 round-robin 负载均衡实现请求均匀分发。读完本文你将掌握双栈服务端的三种绑定方式双栈、仅 IPv4、仅 IPv6、自定义 NameResolver/NameResolverProvider 的完整实现套路以及如何用 Gradle 与 Maven 构建并运行该示例。示例要解决什么问题在真实生产环境中服务实例可能同时监听 IPv4 与 IPv6 地址也可能只监听其中一种。gRPC 默认的 DNS NameResolver 会把一个域名解析出的全部地址视为一个地址组EquivalentAddressGroup客户端通常只连接到该组的第一个可用地址当使用默认的 pick_first 负载均衡策略时即使存在多个后端流量也会始终打向第一个实例。example-dualstack示例通过自定义 NameResolver 演示了另一种建模方式把每个服务实例视为一个独立的 endpointEquivalentAddressGroup每个 endpoint 内部再携带该实例的 IPv4 与 IPv6 两个 SocketAddress。配合 round_robin 策略后3 个服务实例会轮流承接请求而不是挤在第一台服务器上。示例的整体布局为1 个客户端、3 个 Greeter 服务端实例分别绑定实例 0端口 50051同时监听 IPv4 与 IPv6实例 1端口 50052仅监听 IPv4127.0.0.1实例 2端口 50053仅监听 IPv6::1。客户端分两个阶段演示差异先用默认 DNS 解析 默认负载均衡连localhost:50051只打第一台再切换为自定义 NameResolver round_robin让请求在 3 台实例之间轮流分发。构建与运行该示例需要 grpc-java 已构建完成。官方强烈建议先切换到某个 git release 标签再构建因为发布标签通常已经带有可用的 grpc 构建产物否则需要先按 COMPILING 的指引完整编译 grpc-java。使用 Gradle 构建在grpc-java/examples/example-dualstack目录下执行$ ../gradlew installDist该命令会生成两个可直接执行的启动脚本build/install/example-dualstack/bin/dual-stack-serverbuild/install/example-dualstack/bin/dual-stack-client启动服务端$ ./build/install/example-dualstack/bin/dual-stack-server服务端会依次启动 3 个 Greeter 实例日志输出Server started, listening on 50051/50052/50053并注册 JVM 关闭钩子用于优雅停机。启动客户端在另一个终端窗口中运行$ ./build/install/example-dualstack/bin/dual-stack-client客户端会先发 5 个请求到默认解析的localhost:50051再发 10 个请求到自定义解析的example:///lb.example.grpc.ioround_robin并把每次响应打印到日志。使用 Maven 构建运行如果你偏好 Maven可在示例目录README 中注释为 example-debug 目录实际即example-dualstack执行$ mvn verify然后在一个终端启动服务端$ mvn exec:java -Dexec.mainClassio.grpc.examples.dualstack.DualStackServer在另一个终端启动客户端$ mvn exec:java -Dexec.mainClassio.grpc.examples.dualstack.DualStackClientMaven 构建所需的依赖与插件版本见 pom.xml示例通过grpc-bom统一管理 grpc 依赖当前仓库版本为1.85.0-SNAPSHOT引入grpc-services、grpc-protobuf、grpc-stub、grpc-netty并在 runtime 阶段引入grpc-netty-shadedprotobuf 编译使用protoc 3.25.8与protoc-gen-grpc-java插件Java 编译目标为 JDK 1.8。两种构建方式共享的 proto 定义位于 helloworld.proto其中定义了Greeter.SayHello(HelloRequest) returns (HelloReply)。服务端源码剖析三种绑定方式DualStackServer.java 的start()方法遍历ExampleDualStackNameResolver.SERVER_PORTS即{50051, 50052, 50053}按下标选择不同的绑定方式case 0: serverBuilder ServerBuilder.forPort(port); // bind to both IPv4 and IPv6 addressType both IPv4 and IPv6; break; case 1begin▁of▁sentence// inetSocketAddress new InetSocketAddress(127.0.0.1, port); serverBuilder NettyServerBuilder.forAddress(inetSocketAddress); addressType IPv4 only; break; case 2: inetSocketAddress new InetSocketAddress(::1, port); serverBuilder NettyServerBuilder.forAddress(inetSocketAddress); addressType IPv6 only; break;三个要点值得注意ServerBuilder.forPort(port)会绑定到通配地址同时接收 IPv4 与 IPv6 连接这是“双栈”服务端的标准做法NettyServerBuilder.forAddress(new InetSocketAddress(127.0.0.1, port))显式绑定回环 IPv4 地址从而构造“仅 IPv4”的实例NettyServerBuilder.forAddress(new InetSocketAddress(::1, port))显式绑定回环 IPv6 地址构造“仅 IPv6”的实例。注意::1是 IPv6 的回环地址对应 IPv4 的127.0.0.1。每个实例都注册同一个GreeterImpl服务实现。sayHello的响应消息格式为String msg String.format(Hello %s from server%d type: %s, req.getName(), this.port, addressType);即回复中会带上实例端口和地址类型例如Hello request:3 from server50052 type: IPv4 only。这让观察负载均衡结果变得非常直观只要看响应里的端口号就能知道请求被分给了哪一台实例。服务端还注册了Runtime.getRuntime().addShutdownHookJVM 退出时会依次对每个 Server 执行shutdown().awaitTermination(30, TimeUnit.SECONDS)优雅停机main中通过blockUntilShutdown()阻塞直到所有实例终止。客户端源码剖析默认解析 vs 自定义解析DualStackClient.java 的主流程分两个阶段。阶段一默认 DNS 解析 默认负载均衡NameResolverRegistry.getDefaultRegistry() .register(new ExampleDualStackNameResolverProvider()); ManagedChannel channel ManagedChannelBuilder.forTarget(localhost:50051) .usePlaintext() .build();客户端先用localhost:50051走系统默认的 DNS NameResolver 建立通道然后连续发送 5 个greet(request: i)请求。由于没有显式指定负载均衡策略gRPC 使用默认的 pick_first只会连接到解析出的第一个地址因此这 5 个请求全部命中 50051 端口的那台“双栈”实例。阶段二自定义解析 round_robinpublic static final String channelTarget example:///lb.example.grpc.io; channel ManagedChannelBuilder.forTarget(channelTarget) .defaultLoadBalancingPolicy(round_robin) .usePlaintext() .build();关键点在于 target 字符串example:///lb.example.grpc.ioexample是自定义 scheme对应ExampleDualStackNameResolverProvider.getDefaultScheme()返回的example根据 URI 规则example:///lb.example.grpc.io的path 部分是/lb.example.grpc.io去掉开头的/后即lb.example.grpc.io这个值既是解析器查询addrStore的 key也作为getServiceAuthority()返回的服务权威名。通过.defaultLoadBalancingPolicy(round_robin)把负载均衡策略切换为轮询。此时自定义 NameResolver 返回 3 个EquivalentAddressGroup每个组内是同一实例的 IPv4IPv6 两个地址round_robin 会在 3 个 endpoint 之间轮流选择于是 10 个请求会均匀分布在 50051/50052/50053 三台实例上。每次请求通过阻塞式 stub 调用sayHello若抛出StatusRuntimeException会以 WARNING 级别记录e.getStatus()正常响应则打印Greeting: ...。自定义 NameResolver 与 Provider 的实现原理Provider把 scheme 映射到解析器ExampleDualStackNameResolverProvider.java 继承NameResolverProvider四个覆写方法共同构成了注册与选择的完整契约getDefaultScheme()返回examplegRPC 会挑选第一个支持目标 URI scheme 的 Provider 来创建解析器newNameResolver(URI targetUri, Args args)直接返回new ExampleDualStackNameResolver(targetUri)isAvailable()返回true声明该 Provider 在当前环境始终可用priority()返回5多个 Provider 竞争同一 scheme 时按优先级选择。客户端在创建任何通道之前通过NameResolverRegistry.getDefaultRegistry().register(...)把该 Provider 注册进全局默认注册表这是自定义 scheme 能被forTarget(example:///...)识别的前提。Resolver把“域名”解析成多组地址ExampleDualStackNameResolver.java 继承NameResolver用硬编码的addrStore模拟 DNS 记录private static final ImmutableMapString, ListListSocketAddress addrStore ImmutableMap.String, ListListSocketAddressbuilder() .put(lb.example.grpc.io, Arrays.stream(SERVER_PORTS) // 50051, 50052, 50053 .mapToObj(port - getLocalAddrs(port)) .collect(Collectors.toList())) .build(); private static ListSocketAddress getLocalAddrs(int port) { return Arrays.asList( new InetSocketAddress(127.0.0.1, port), // IPv4 new InetSocketAddress(::1, port)); // IPv6 }解析逻辑的核心在resolve()ListEquivalentAddressGroup eagList new ArrayList(); for (ListSocketAddress endpoint : addresses) { // every server is an EquivalentAddressGroup, so they can be accessed randomly eagList.add(new EquivalentAddressGroup(endpoint)); } this.listener.onResult(ResolutionResult.newBuilder().setAddresses(eagList).build());这里体现了双栈示例最核心的建模思想3 个服务实例被建模为 3 个EquivalentAddressGroupendpoint每个组内含有该实例的 IPv4127.0.0.1与 IPv6::1两个地址。对负载均衡器而言一个组代表“同一服务的多个等价地址”组与组之间才是可轮询的独立后端对连接器而言一个组内部的多个地址提供故障转移与双栈尝试能力——某个地址不可达时会尝试组内下一个地址。这正是该示例能实现“每个服务器都被轮流访问”的关键如果像默认解析那样把所有地址塞进一个组round_robin 也只能看到 1 个后端。其余生命周期方法也一应俱全start(Listener2 listener)保存 listener 并立即调用resolve()推送首份解析结果refresh()触发重新解析例如地址变化时getServiceAuthority()返回 URI path 去掉首字符后的lb.example.grpc.io作为 TLS 校验与鉴权的服务权威名shutdown()本例无资源需要释放为空实现解析过程中若发生异常通过listener.onError(Status.UNAVAILABLE.withDescription(Unable to resolve host ).withCause(e))通知上层使 RPC 以 UNAVAILABLE 状态快速失败。与核心 API 的对应关系NameResolver、NameResolverProvider、EquivalentAddressGroup、ResolutionResult、Listener2等类型均定义在 api 模块的 NameResolver.javaProvider 的getDefaultScheme()等抽象方法见该文件约 第 209 行 附近属于 grpc-java 公开 API独立于具体传输层。这意味着本示例的自定义解析器在 Netty、OkHttp、InProcess 等任何传输上都能复用。预期输出与结果验证以 Gradle 方式运行后服务端日志会显示 3 个监听端口客户端日志则应呈现如下特征阶段一默认解析的 5 个响应中server50051出现 5 次因为 pick_first 只连第一个地址阶段二自定义解析 round_robin的 10 个响应中server50051、server50052、server50053大致各出现 3~4 次且响应中的type:字段会分别显示both IPv4 and IPv6、IPv4 only、IPv6 only证明请求确实被轮流分发到了三种不同地址族绑定的实例上。这一对比直观展示了在 gRPC 中负载均衡的粒度由 NameResolver 返回的EquivalentAddressGroup划分决定同一个“域名”下多个实例既可以建模成一个组集中连接也可以建模成多个组分散轮询双栈能力则体现在每个组内部的地址族组合上。注意事项与适用前提需要先构建 grpc-java 本身本示例依赖当前仓库的 grpc 快照版本直接运行前请先按 COMPILING 完成构建或检出带有预构建产物的 release 标签双栈绑定依赖操作系统与网络栈::1与127.0.0.1的可用性取决于运行环境是否启用 IPv6在不支持 IPv6 的环境中“仅 IPv6”实例可能启动失败本示例为教学用途自定义 NameResolver 的地址表是硬编码的假数据代码注释也明确标注 “This is a fake name resolver”生产环境应基于 DNS、服务发现或 xDS 等真实来源生成解析结果负载均衡策略可替换把.defaultLoadBalancingPolicy(round_robin)换成pick_first或weighted_round_robin即可在同一套双栈建模下观察不同策略的行为差异。【免费下载链接】grpc-javaThe Java gRPC implementation. HTTP/2 based RPC项目地址: https://gitcode.com/GitHub_Trending/gr/grpc-java创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考