彻底搞懂gRPC名称解析

彻底搞懂gRPC名称解析

  1. code
  2. 1 day ago
  3. 11 min read

通过测试gRPC的DNS解析,以及实现自定义名称解析服务来了解gRPC的名称解析。

什么是名称解析(Name Resolution)

信息世界中,名称解析本质上是用于服务发现的。在gRPC中也是如此,客户端必须确定服务端具体的IP+端口。最常见的名称解析服务就是DNS,我们在浏览器输入一个域名,这个域名会被DNS这个名称解析服务识别,并返回该域名对应的IP地址,从而浏览器可以被路由到正确的服务。

使用 gRPC 客户端发出请求时,默认使用 DNS 名称解析。但是,也可以使用其他各种名称解析机制:

Resolver解析器ExampleNote
DNSgrpc.io:50051默认情况下采用 DNS。
DNSdns:///grpc.io:50051额外的斜线用于提供权限
Unix Domain Socketunix:///run/containerd/containerd.sock
xDSxds:///wallet.grpcwallet.io
IPv4ipv4:198.51.100.123:50051

默认情况下gRPC使用DNS作为名称解析服务,是dns://grpc.io:50051的简写。第二种dns:///才是更明确的指定方式,表示dns:[//authority/]grpc.io:40051, dns:///表示authority为空,指没有具体的DNS解析服务器,交给系统决定。跟第一种写法其实一样,只是表示的更明确。authority其实就是user:password@host:port

一些语言的接口支持用户自定义他们的名称解析器,这意味你可以自己定义任意的名字,然后解析出你想要的IP地址,就像修改操作系统的hosts文件一样。

DNS和IP作为名称解析服务

我们来演示一个demo,使用DNS作为名称解析服务来实现gRPC的server和client通信。

这个演示将包含以下几个部分: .proto 文件定义:定义服务和消息。 服务端代码:启动一个 gRPC 服务,监听一个本地地址。 客户端代码:使用dns:///和域名来连接服务端。 DNS 配置(本地模拟):我们会修改本地的 hosts 文件来模拟一个 DNS 条目,使得一个自定义的域名能指向我们的本地服务地址。

代码结构如下

grpc-dns-demo/
├── go.mod
├── proto/
│   ├── greet.pb.go
│   ├── greet.proto       //proto文件
│   └── greet_grpc.pb.go
├── server/
│   └── server.go         // 服务端
├── client/
│   └── client.go         // 新的客户端

首先,创建一个grpc-dns-demo的目录,使用go mod init grpc-dns-demo来初始化项目。

定义proto文件

grpc-dns-demo目录下创建一个proto目录,在proto目录中创建geert.proto文件。写入如下代码。

syntax = "proto3";

option go_package = "./proto";

package proto;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
} 

该proto文件定义了一个Greeter的服务,用于接收客户端发过来的HelloRequest消息类型,返回HelloReply的消息类型。

然后在grpc-dns-demo目录运行如下命令

protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    proto/greet.proto

实现server服务

创建一个 server 目录,并在其中添加 server.go 文件。

该server监听50051端口,实现了一个SayHello方法,该方法接收HelloRequest消息,返回“Hello ‘client name’”。

代码如下:

package main

import (
	"context"
	"log"
	"net"

	pb "grpc-dns-demo/proto"

	"google.golang.org/grpc"
)

const (
	port = ":50051"
)

type server struct {
	pb.UnimplementedGreeterServer
}

// SayHello implements proto.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	log.Printf("Server listening at %v", lis.Addr())

	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})

	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

实现client

client在初始化的时候直接传入一个dns:///myserver.local:50051的地址,然后调用SayHello方法给服务端发送消息。

package main

import (
	"context"
	"log"
	"time"

	pb "grpc-dns-demo/proto"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

const (
	address    = "dns:///myserver.local:50051"
	clientName = "Colin"
)

func main() {
	conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	c := pb.NewGreeterClient(conn)

	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
	defer cancel()

	r, err := c.SayHello(ctx, &pb.HelloRequest{Name: clientName})
	if err != nil {
		log.Fatalf("could not greet: %v", err)
	}
	log.Printf("Greeting: %s", r.GetMessage())
}

配置本地DNS

为了让 dns:///myserver.local:50051 工作,你需要让你的操作系统能够将 myserver.local 解析到服务端运行的 IP 地址。

  • Linux/macOS: 编辑 /etc/hosts 文件,添加一行:
127.0.0.1 myserver.local
  • Windows: 编辑 C:\Windows\System32\drivers\etc\hosts 文件,添加同样的一行。

测试DNS解析

在终端中分别运行server和client。

客户端会接收到如下消息:

[root@debian client]# go run client.go
2025/05/17 16:35:13 Greeting: Hello Colin

如果我们将

address    = "dns:///myserver.local:50051"

改为

address    = "myserver.local:50051"

同样在测试一遍,结果是一样的。

[root@debian client]# go run client.go
2025/05/17 16:44:11 Greeting: Hello Colin

同样的改为IP

	address    = "127.0.0.1:50051"

结果也一样

因此,gRPC如果使用DNS作为名称解析服务,支持两种地址传入,分别是dns:///myserver.local:50051myserver.local:50051。也可以使用IP+端口作为地址传入。

自定义名称解析器

gRPC是可以自定义名称解析服务的,而不是用默认的DNS,自定义名称解析服务需要自己实现解析器(Resolver)、构建器(Builder)、自定义schema。这提升了工作量,但也增加了灵活性。

下面我们实现一个自定义名称解析器的demo,代码结构如下:

grpc-custom-resolver-demo/
├── go.mod
├── proto/
│   ├── greet.pb.go
│   ├── greet.proto
│   └── greet_grpc.pb.go
├── server/
│   └── server.go
├── client/
│   └── client.go
└── resolver/
    └── resolver.go 

proto复用前面的就行。

Server实现

Server部分的代码只需要小小改动一下,我们定义两个端口,50051和50052。通过这个两个端口提供两个gRPC服务端实例。

package main

import (
	"context"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"

	pb "grpc-dns-demo/proto"

	"google.golang.org/grpc"
)

const (
	port1 = ":50051"
	port2 = ":50052"
)

type server struct {
	pb.UnimplementedGreeterServer
}

// SayHello implements proto.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

	go startServer(port1)
	go startServer(port2)

	<-stop
	log.Println("Shutting down servers...")

}

func startServer(port string) {
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	log.Printf("Server listening at %v", lis.Addr())

	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})

	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

Resolver

要实现自定义名称解析器,需要通过google.golang.org/grpc/resolver的Register方法注册进去。

func Register(b Builder) {
	m[b.Scheme()] = b
}

Register方法需要传入一个Builder的实例,Buider接口有两个方法,实现这两个方法才能实现Builder实例。

type Builder interface {

	Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)

	Scheme() string
}

所以需要在我们自定义解析器里实现一个Builder接口,并且返回值是Resolver,所以在Build里面需要实现Resolver接口,完整代码如下

grpc-dns-demo/resolver/resolver.go

package custom_resolver

import (
	"google.golang.org/grpc/resolver"
)

const (
	myScheme      = "example"
	myServiceName = "my-custom-service:1234"
	backendAddr   = "127.0.0.1:50051"
	backendAddr2  = "127.0.0.1:50052"
)

type myResolver struct {
	target     resolver.Target
	cc         resolver.ClientConn
	addrsStore map[string][]string
}

type myResolverBuilder struct{}

func (*myResolverBuilder) Build(target resolver.Target, cc resolver.ClientConn, _ resolver.BuildOptions) (resolver.Resolver, error) {
	r := &myResolver{
		target: target,
		cc:     cc,
		addrsStore: map[string][]string{
			myServiceName: {backendAddr, backendAddr2},
		},
	}
	r.start()
	return r, nil
}

func (*myResolverBuilder) Scheme() string { return myScheme }

func (r *myResolver) start() {
	addrStrs := r.addrsStore[r.target.Endpoint()]
	addrs := make([]resolver.Address, len(addrStrs))
	for i, s := range addrStrs {
		addrs[i] = resolver.Address{Addr: s}
	}
	r.cc.UpdateState(resolver.State{Addresses: addrs})
}

func (r *myResolver) ResolveNow(resolver.ResolveNowOptions) {}
func (r *myResolver) Close()                                {}

func init() {
	resolver.Register(&myResolverBuilder{})
}

client实现

客户端里我们通过 _ "grpc-dns-demo/resolver" 这一行代码来引入resolver,这会自动执行init方法,这会将example:///my-custom-service:1234注册进gRPC全局注册表。

客户端里我们写入上述地址,作为address传入即可。

address    = "example:///my-custom-service:1234"

完整代码如下

package main

import (
	"context"
	"log"
	"time"

	pb "grpc-dns-demo/proto"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"

	// 重要:导入自定义解析器包,这会执行其 init() 函数来注册解析器
	_ "grpc-dns-demo/resolver"
)

const (
	// 使用自定义 scheme "example" 和我们解析器中定义的服务名
	// 解析器会将 my-custom-service:1234 解析为 ["127.0.0.1:50051", "127.0.0.1:50052"]
	address    = "example:///my-custom-service:1234"
	clientName = "Colin"
)

func main() {
	log.Println("Client: Dialing with custom resolver address:", address)

	// Set up a connection to the server.
	// gRPC 会自动选择注册到 "example" scheme 的解析器
	// 默认情况下,gRPC 会在解析出的多个地址之间进行轮询 (round_robin) 负载均衡
	conn, err := grpc.NewClient(
		address,
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		// 如果你的解析器返回了 service config, 你可能不需要在这里显式设置
		// grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
	)
	if err != nil {
		log.Fatalf("Client: Did not connect: %v", err)
	}
	defer conn.Close()
	log.Println("Client: Connected successfully!")

	c := pb.NewGreeterClient(conn)

	for range 5 { // 发送几次请求,观察负载均衡(如果服务端监听在不同端口)
		ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
		defer cancel()

		r, err := c.SayHello(ctx, &pb.HelloRequest{Name: clientName})
		if err != nil {
			log.Fatalf("Client: Could not greet: %v", err)
		}
		log.Printf("Client: Greeting from server: %s", r.GetMessage())
		time.Sleep(500 * time.Millisecond)
	}
}

运行测试

通过运行客户端我们发现 example:///my-custom-service:1234 被成功解析并访问到了server。

[root@debian client]# go run client.go
2025/05/18 09:35:59 Client: Dialing with custom resolver address: example:///my-custom-service:1234
2025/05/18 09:35:59 Client: Connected successfully!
2025/05/18 09:35:59 Client: Greeting from server: Hello Colin
2025/05/18 09:35:59 Client: Greeting from server: Hello Colin
2025/05/18 09:36:00 Client: Greeting from server: Hello Colin
2025/05/18 09:36:00 Client: Greeting from server: Hello Colin
2025/05/18 09:36:01 Client: Greeting from server: Hello Colin

参考

Code Golang gRPC GO NS Name Resolver DNS