深入理解 gRPC 客户端服务发现与负载均衡
1. 服务发现基础与准备
非分布式日志(如
Log
类型)对服务器信息并不了解。为了解决这个问题,我们创建了一个新的接口,其唯一方法
GetServers()
与
DistributedLog.GetServers
相匹配。在更新
agent
包中的端到端测试时,我们会将
DistributedLog
同时设置为
CommitLog
和
GetServerer
,新的服务器端点会对其进行错误处理。
在
agent.go
中,更新
setupServer()
方法,让服务器从
DistributedLog
获取集群的服务器信息:
ClientSideServiceDiscovery/internal/agent/agent.go
serverConfig := &server.Config{
CommitLog: a.log,
Authorizer: authorizer,
GetServerer: a.log,
}
这样,我们就有了一个客户端可以调用的服务器端点,用于获取集群的服务器信息。接下来,我们将构建解析器。
2. 构建解析器
我们将编写的 gRPC 解析器会调用
GetServers()
端点,并将获取的信息传递给 gRPC,以便选择器知道可以将请求路由到哪些服务器。
操作步骤如下:
1. 创建一个新的包用于解析器和选择器代码:
$ mkdir internal/loadbalance
-
在
internal/loadbalance中创建resolver.go文件,代码如下:
ClientSideServiceDiscovery/internal/loadbalance/resolver.go
package loadbalance
import (
"context"
"fmt"
"sync"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/attributes"
"google.golang.org/grpc/resolver"
"google.golang.org/grpc/serviceconfig"
api "github.com/travisjeffery/proglog/api/v1"
)
type Resolver struct {
mu sync.Mutex
clientConn resolver.ClientConn
resolverConn *grpc.ClientConn
serviceConfig *serviceconfig.ParseResult
logger *zap.Logger
}
Resolver
类型将实现 gRPC 的
resolver.Builder
和
resolver.Resolver
接口。
clientConn
是用户的客户端连接,gRPC 会将其传递给解析器,以便解析器用发现的服务器信息更新它。
resolverConn
是解析器自己到服务器的客户端连接,用于调用
GetServers()
并获取服务器信息。
-
实现
resolver.Builder接口:
ClientSideServiceDiscovery/internal/loadbalance/resolver.go
var _ resolver.Builder = (*Resolver)(nil)
func (r *Resolver) Build(
target resolver.Target,
cc resolver.ClientConn,
opts resolver.BuildOptions,
) (resolver.Resolver, error) {
r.logger = zap.L().Named("resolver")
r.clientConn = cc
var dialOpts []grpc.DialOption
if opts.DialCreds != nil {
dialOpts = append(
dialOpts,
grpc.WithTransportCredentials(opts.DialCreds),
)
}
r.serviceConfig = r.clientConn.ParseServiceConfig(
fmt.Sprintf(`{"loadBalancingConfig":[{"%s":{}}]}`, Name),
)
var err error
r.resolverConn, err = grpc.Dial(target.Endpoint, dialOpts...)
if err != nil {
return nil, err
}
r.ResolveNow(resolver.ResolveNowOptions{})
return r, nil
}
const Name = "proglog"
func (r *Resolver) Scheme() string {
return Name
}
func init() {
resolver.Register(&Resolver{})
}
resolver.Builder
包含两个方法:
-
Build()
:接收构建解析器所需的数据(如目标地址)和解析器将用发现的服务器信息更新的客户端连接。它会设置一个到服务器的客户端连接,以便解析器可以调用
GetServers()
API。
-
Scheme()
:返回解析器的方案标识符。当调用
grpc.Dial
时,gRPC 会从你提供的目标地址中解析出方案,并尝试找到匹配的解析器,默认使用其 DNS 解析器。对于我们的解析器,目标地址的格式应为:
proglog://your-service-address
。
我们在
init()
中向 gRPC 注册这个解析器,以便 gRPC 在查找与目标方案匹配的解析器时知道它。
-
实现
resolver.Resolver接口:
ClientSideServiceDiscovery/internal/loadbalance/resolver.go
var _ resolver.Resolver = (*Resolver)(nil)
func (r *Resolver) ResolveNow(resolver.ResolveNowOptions) {
r.mu.Lock()
defer r.mu.Unlock()
client := api.NewLogClient(r.resolverConn)
ctx := context.Background()
res, err := client.GetServers(ctx, &api.GetServersRequest{})
if err != nil {
r.logger.Error(
"failed to resolve server",
zap.Error(err),
)
return
}
var addrs []resolver.Address
for _, server := range res.Servers {
addrs = append(addrs, resolver.Address{
Addr: server.RpcAddr,
Attributes: attributes.New(
"is_leader",
server.IsLeader,
),
})
}
r.clientConn.UpdateState(resolver.State{
Addresses: addrs,
ServiceConfig: r.serviceConfig,
})
}
func (r *Resolver) Close() {
if err := r.resolverConn.Close(); err != nil {
r.logger.Error(
"failed to close conn",
zap.Error(err),
)
}
}
resolver.Resolver
包含两个方法:
-
ResolveNow()
:gRPC 调用此方法来解析目标、发现服务器,并使用服务器信息更新客户端连接。解析器发现服务器的方式取决于解析器和所使用的服务。我们创建一个 gRPC 客户端来调用
GetServers()
API 获取集群的服务器信息。
-
Close()
:关闭解析器。在我们的解析器中,关闭在
Build()
中创建的到服务器的连接。
3. 测试解析器
在
internal/loadbalance
中创建
resolver_test.go
文件,代码如下:
ClientSideServiceDiscovery/internal/loadbalance/resolver_test.go
package loadbalance_test
import (
"net"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/attributes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/resolver"
"google.golang.org/grpc/serviceconfig"
api "github.com/travisjeffery/proglog/api/v1"
"github.com/travisjeffery/proglog/internal/loadbalance"
"github.com/travisjeffery/proglog/internal/config"
"github.com/travisjeffery/proglog/internal/server"
)
func TestResolver(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
tlsConfig, err := config.SetupTLSConfig(config.TLSConfig{
CertFile: config.ServerCertFile,
KeyFile: config.ServerKeyFile,
CAFile: config.CAFile,
Server: true,
ServerAddress: "127.0.0.1",
})
require.NoError(t, err)
serverCreds := credentials.NewTLS(tlsConfig)
srv, err := server.NewGRPCServer(&server.Config{
GetServerer: &getServers{},
}, grpc.Creds(serverCreds))
require.NoError(t, err)
go srv.Serve(l)
conn := &clientConn{}
tlsConfig, err = config.SetupTLSConfig(config.TLSConfig{
CertFile: config.RootClientCertFile,
KeyFile: config.RootClientKeyFile,
CAFile: config.CAFile,
Server: false,
ServerAddress: "127.0.0.1",
})
require.NoError(t, err)
clientCreds := credentials.NewTLS(tlsConfig)
opts := resolver.BuildOptions{
DialCreds: clientCreds,
}
r := &loadbalance.Resolver{}
_, err = r.Build(
resolver.Target{
Endpoint: l.Addr().String(),
},
conn,
opts,
)
require.NoError(t, err)
wantState := resolver.State{
Addresses: []resolver.Address{{
Addr: "localhost:9001",
Attributes: attributes.New("is_leader", true),
}, {
Addr: "localhost:9002",
Attributes: attributes.New("is_leader", false),
}},
}
require.Equal(t, wantState, conn.state)
conn.state.Addresses = nil
r.ResolveNow(resolver.ResolveNowOptions{})
require.Equal(t, wantState, conn.state)
}
type getServers struct{}
func (s *getServers) GetServers() ([]*api.Server, error) {
return []*api.Server{{
Id: "leader",
RpcAddr: "localhost:9001",
IsLeader: true,
}, {
Id: "follower",
RpcAddr: "localhost:9002",
}}, nil
}
type clientConn struct {
resolver.ClientConn
state resolver.State
}
func (c *clientConn) UpdateState(state resolver.State) {
c.state = state
}
func (c *clientConn) ReportError(err error) {}
func (c *clientConn) NewAddress(addrs []resolver.Address) {}
func (c *clientConn) NewServiceConfig(config string) {}
func (c *clientConn) ParseServiceConfig(
config string,
) *serviceconfig.ParseResult {
return nil
}
这个测试代码设置了一个服务器,让测试解析器尝试从中发现一些服务器。我们传入一个模拟的
GetServerers
,以便设置解析器应该找到的服务器。
4. 解析器工作流程
graph TD;
A[开始] --> B[创建解析器实例];
B --> C[调用Build方法];
C --> D[设置客户端连接和日志记录器];
D --> E[处理传输凭证];
E --> F[解析服务配置];
F --> G[建立到服务器的连接];
G --> H[调用ResolveNow方法];
H --> I[调用GetServers API获取服务器信息];
I --> J[更新客户端连接状态];
J --> K[结束];
5. 解析器相关信息总结
| 接口 | 方法 | 功能 |
|---|---|---|
| resolver.Builder | Build() | 接收构建解析器所需的数据,设置到服务器的客户端连接 |
| resolver.Builder | Scheme() | 返回解析器的方案标识符 |
| resolver.Resolver | ResolveNow() | 解析目标、发现服务器并更新客户端连接 |
| resolver.Resolver | Close() | 关闭解析器连接 |
6. 实现选择器
在 gRPC 架构中,选择器负责处理 RPC 负载均衡逻辑。它们从解析器发现的服务器中选择一个服务器来处理每个 RPC。选择器可以根据 RPC、客户端和服务器的信息进行请求路由,其功能不仅仅局限于负载均衡。
操作步骤如下:
1. 在
internal/loadbalance
中创建
picker.go
文件,代码如下:
ClientSideServiceDiscovery/internal/loadbalance/picker.go
package loadbalance
import (
"strings"
"sync"
"sync/atomic"
"google.golang.org/grpc/balancer"
"google.golang.org/grpc/balancer/base"
)
var _ base.PickerBuilder = (*Picker)(nil)
type Picker struct {
mu sync.RWMutex
leader balancer.SubConn
followers []balancer.SubConn
current uint64
}
func (p *Picker) Build(buildInfo base.PickerBuildInfo) balancer.Picker {
p.mu.Lock()
defer p.mu.Unlock()
var followers []balancer.SubConn
for sc, scInfo := range buildInfo.ReadySCs {
isLeader := scInfo.Address.Attributes.Value("is_leader").(bool)
if isLeader {
p.leader = sc
continue
}
followers = append(followers, sc)
}
p.followers = followers
return p
}
选择器和解析器一样使用构建器模式。gRPC 将子连接及其信息的映射传递给
Build()
方法来设置选择器。我们的选择器将把消费 RPC 路由到跟随者服务器,将生产 RPC 路由到领导者服务器。
-
实现
balancer.Picker接口:
ClientSideServiceDiscovery/internal/loadbalance/picker.go
var _ balancer.Picker = (*Picker)(nil)
func (p *Picker) Pick(info balancer.PickInfo) (
balancer.PickResult, error) {
p.mu.RLock()
defer p.mu.RUnlock()
var result balancer.PickResult
if strings.Contains(info.FullMethodName, "Produce") ||
len(p.followers) == 0 {
result.SubConn = p.leader
} else if strings.Contains(info.FullMethodName, "Consume") {
result.SubConn = p.nextFollower()
}
if result.SubConn == nil {
return result, balancer.ErrNoSubConnAvailable
}
return result, nil
}
func (p *Picker) nextFollower() balancer.SubConn {
cur := atomic.AddUint64(&p.current, uint64(1))
len := uint64(len(p.followers))
idx := int(cur % len)
return p.followers[idx]
}
选择器有一个方法
Pick(balancer.PickInfo)
。gRPC 会给
Pick()
方法一个包含 RPC 名称和上下文的
balancer.PickInfo
,帮助选择器选择子连接。我们根据 RPC 的方法名来决定选择领导者还是跟随者子连接,并使用轮询算法在跟随者之间平衡消费调用。
- 注册选择器:
ClientSideServiceDiscovery/internal/loadbalance/picker.go
func init() {
balancer.Register(
base.NewBalancerBuilder(Name, &Picker{}, base.Config{}),
)
}
7. 测试选择器
在
internal/loadbalance
中创建
picker_test.go
文件,代码如下:
ClientSideServiceDiscovery/internal/loadbalance/picker_test.go
package loadbalance_test
import (
"testing"
"google.golang.org/grpc/attributes"
"google.golang.org/grpc/balancer"
"google.golang.org/grpc/balancer/base"
"google.golang.org/grpc/resolver"
"github.com/stretchr/testify/require"
"github.com/travisjeffery/proglog/internal/loadbalance"
)
func TestPickerNoSubConnAvailable(t *testing.T) {
picker := &loadbalance.Picker{}
for _, method := range []string{
"/log.vX.Log/Produce",
"/log.vX.Log/Consume",
} {
info := balancer.PickInfo{
FullMethodName: method,
}
result, err := picker.Pick(info)
require.Equal(t, balancer.ErrNoSubConnAvailable, err)
require.Nil(t, result.SubConn)
}
}
func TestPickerProducesToLeader(t *testing.T) {
picker, subConns := setupTest()
info := balancer.PickInfo{
FullMethodName: "/log.vX.Log/Produce",
}
for i := 0; i < 5; i++ {
gotPick, err := picker.Pick(info)
require.NoError(t, err)
require.Equal(t, subConns[0], gotPick.SubConn)
}
}
func TestPickerConsumesFromFollowers(t *testing.T) {
picker, subConns := setupTest()
info := balancer.PickInfo{
FullMethodName: "/log.vX.Log/Consume",
}
for i := 0; i < 5; i++ {
pick, err := picker.Pick(info)
require.NoError(t, err)
require.Equal(t, subConns[i%2+1], pick.SubConn)
}
}
func setupTest() (*loadbalance.Picker, []*subConn) {
var subConns []*subConn
buildInfo := base.PickerBuildInfo{
ReadySCs: make(map[balancer.SubConn]base.SubConnInfo),
}
for i := 0; i < 3; i++ {
sc := &subConn{}
addr := resolver.Address{
Attributes: attributes.New("is_leader", i == 0),
}
sc.UpdateAddresses([]resolver.Address{addr})
buildInfo.ReadySCs[sc] = base.SubConnInfo{Address: addr}
subConns = append(subConns, sc)
}
picker := &loadbalance.Picker{}
picker.Build(buildInfo)
return picker, subConns
}
type subConn struct {
addrs []resolver.Address
}
func (s *subConn) UpdateAddresses(addrs []resolver.Address) {
s.addrs = addrs
}
func (s *subConn) Connect() {}
TestPickerNoSubConnAvailable()
测试在解析器发现服务器并更新选择器状态之前,选择器是否返回
balancer.ErrNoSubConnAvailable
。
TestPickerProducesToLeader()
测试选择器是否为生产调用选择领导者子连接。
TestPickerConsumesFromFollowers()
测试选择器是否使用轮询算法为消费调用选择跟随者子连接。
8. 选择器工作流程
graph TD;
A[开始] --> B[创建选择器实例];
B --> C[调用Build方法];
C --> D[区分领导者和跟随者子连接];
D --> E[等待Pick请求];
E --> F[根据RPC方法名选择子连接];
F --> G[若为Produce或无跟随者选领导者];
F --> H[若为Consume选跟随者];
G --> I[返回所选子连接];
H --> J[使用轮询算法选跟随者];
J --> I;
I --> K[结束];
9. 选择器相关信息总结
| 接口 | 方法 | 功能 |
|---|---|---|
| base.PickerBuilder | Build() | 接收子连接信息,区分领导者和跟随者子连接 |
| balancer.Picker | Pick() | 根据 RPC 方法名选择子连接处理请求 |
10. 端到端测试
现在我们准备更新代理测试,进行端到端测试,包括客户端配置解析器和选择器、解析器发现服务器以及选择器为每个 RPC 选择子连接。
操作步骤如下:
1. 打开
internal/agent/agent_test.go
文件,添加以下导入:
ClientSideServiceDiscovery/internal/agent/agent_test.go
"github.com/travisjeffery/proglog/internal/loadbalance"
-
更新
client()函数以使用解析器和选择器:
ClientSideServiceDiscovery/internal/agent/agent_test.go
func client(
t *testing.T,
agent *agent.Agent,
tlsConfig *tls.Config,
) api.LogClient {
tlsCreds := credentials.NewTLS(tlsConfig)
opts := []grpc.DialOption{
grpc.WithTransportCredentials(tlsCreds),
}
rpcAddr, err := agent.Config.RPCAddr()
require.NoError(t, err)
conn, err := grpc.Dial(fmt.Sprintf(
"%s:///%s",
loadbalance.Name,
rpcAddr,
), opts...)
require.NoError(t, err)
client := api.NewLogClient(conn)
return client
}
- 调整测试代码,等待服务器复制记录后再进行消费:
ClientSideServiceDiscovery/internal/agent/agent_test.go
// wait until replication has finished
time.Sleep(3 * time.Second)
consumeResponse, err := leaderClient.Consume(
context.Background(),
&api.ConsumeRequest{
Offset: produceResponse.Offset,
},
)
require.NoError(t, err)
require.Equal(t, consumeResponse.Record.Value, []byte("foo"))
followerClient := client(t, agents[1], peerTLSConfig)
consumeResponse, err = followerClient.Consume(
context.Background(),
&api.ConsumeRequest{
Offset: produceResponse.Offset,
},
)
require.NoError(t, err)
require.Equal(t, consumeResponse.Record.Value, []byte("foo"))
运行测试:
$ make test
,确保测试通过。
通过以上步骤,我们深入了解了 gRPC 如何解析服务并在它们之间平衡调用,以及如何构建自己的解析器和选择器。解析器可以让客户端动态发现服务器,选择器不仅可用于负载均衡,还能实现自定义的路由逻辑。
超级会员免费看
953

被折叠的 条评论
为什么被折叠?



