深度解析 Kratos 客户端服务发现与负载均衡:从 Dial 入口到 gRPC 全链路落地(上篇)

前言

继此前对 kratos 服务发现核心逻辑的深度解析后,本文将聚焦客户端侧服务发现的具体执行流程,结合 Grpc底层机制拆解 kratos 如何实现服务发现与 Grpc 的适配。

在深入拆解 kratos 客户端服务发现的代码逻辑前,首先我们要明确的是:kratos 采用服务端注册 + 客户端发现 + 客户端负载均衡的架构模式,服务注册由服务端主动上报至注册中心,而客户端需通过服务发现获取可用节点列表,并自行完成负载均衡选路。

clientOptions 核心字段解析

我们先从clientOptions核心字段开始解析,是因为后续客户端发起连接的核心函数 dial,会直接复用这些配置项------无论是服务发现的注册中心对接、Grpc 的拦截器配置,还是负载均衡器选择,都依赖clientOptions中定义的核心字段。理解这些字段的作用,能帮我们更清晰地看懂后续dial函数的执行逻辑,避免陷入代码细节中难以梳理主线。

依旧是熟悉的 option 选项模式,对于 clientOptions 我们这里主要先讲解核心

go 复制代码
type clientOptions struct {
	endpoint               string            // 核心 服务端地址(如 "127.0.0.1:8080" 或服务名 "user-service")
	subsetSize             int                            // 服务发现时的子集大小
	tlsConf                *tls.Config                    // TLS 配置 
	timeout                time.Duration                  // 连接超时 请求超时时间
	discovery              registry.Discovery             // 核心 服务发现接口 对接注册中心,如 etcd/nacos/consul
	middleware             []middleware.Middleware        // 一元 RPC 的中间件
	streamMiddleware       []middleware.Middleware        // 流式 RPC 的中间件
	ints                   []grpc.UnaryClientInterceptor  // 核心 原生 gRPC 一元拦截器
	streamInts             []grpc.StreamClientInterceptor // 核心 原生 gRPC 流式拦截器
	grpcOpts               []grpc.DialOption              // 核心 原生 gRPC 的 DialOption 留给用户自定义
	balancerName           string                         // 核心 负载均衡器名称 如 "round_robin" "wrr"
	filters                []selector.NodeFilter          // 节点过滤器,过滤不健康的节点
	healthCheckConfig      string                         // 健康检查配置
	printDiscoveryDebugLog bool                           // 是否打印服务发现的调试日志
}

type ClientOption func(o *clientOptions)

// WithEndpoint with client endpoint.
func WithEndpoint(endpoint string) ClientOption {
	return func(o *clientOptions) {
		o.endpoint = endpoint
	}
}

// WithTimeout with client timeout.
func WithTimeout(timeout time.Duration) ClientOption {
	return func(o *clientOptions) {
		o.timeout = timeout
	}
}

// 等等

客户端 发送 Dial,这两个函数是给外部调用的,一个是安全调用一个是非安全调用,本质调用一个函数,就是后面我要介绍的

go 复制代码
func DialInsecure(ctx context.Context, opts ...ClientOption) (*grpc.ClientConn, error) {
    return dial(ctx, true, opts...)
}

func Dial(ctx context.Context, opts ...ClientOption) (*grpc.ClientConn, error) {
    return dial(ctx, false, opts...)
}

Dial() 函数

大家的注意点要先放到重点的代码,一些扩展性高,对于主流程影响不大的地方先跳过,等到真正熟悉之后,在回头看就会很清晰,这里给大家列一下该函数的核心代码,首先是函数选项模式,赋默认值,并使用用户传来的值进行覆盖,重点函数 grpc.WithResolvers() ,应用于 Grpc 内部的函数选项模式,大家应该清楚的是 kratos 在服务发现和负载均衡上底层依赖的是 Grpc, 还有核心就是负载均衡相关 grpc.WithDefaultServiceConfig(),也是调用Grpc 中的 with...() 函数,会在下一个文章中讲解

go 复制代码
func dial(ctx context.Context, insecure bool, opts ...ClientOption) (*grpc.ClientConn, error) {
	// 初始化 clientOptions,设置默认值
	options := clientOptions{
		timeout:                2000 * time.Millisecond, // 默认超时 2 秒
		balancerName:           balancerName,            // 默认负载均衡器名称 常量 selector 后续会讲
		subsetSize:             25,                      // 暂时不重要
		printDiscoveryDebugLog: true,                    // 暂时不重要
		healthCheckConfig:      `,"healthCheckConfig":{"serviceName":""}`, // 默认健康检查配置
	}
	// 遍历用户传入的 ClientOption,覆盖默认配置,经典选项模式核心
	for _, o := range opts {
		o(&options) 
	}
    
    // 构建 Kratos 业务中间件对应的原生一元拦截器
	ints := []grpc.UnaryClientInterceptor{
		unaryClientInterceptor(options.middleware, options.timeout, options.filters),
	}
	// 构建 Kratos 业务中间件对应的原生流式拦截器
	sints := []grpc.StreamClientInterceptor{
		streamClientInterceptor(options.streamMiddleware, options.filters),
	}

	// 追加用户自定义的原生一元拦截器
	if len(options.ints) > 0 {
		ints = append(ints, options.ints...)
	}
	// 追加用户自定义的原生流式拦截器
	if len(options.streamInts) > 0 {
		sints = append(sints, options.streamInts...)
	}
    
	grpcOpts := []grpc.DialOption{
		// 设置 gRPC 负载均衡 + 健康检查  核心 负载均衡
		grpc.WithDefaultServiceConfig(fmt.Sprintf(`{"loadBalancingConfig": [{"%s":{}}]%s}`,
			options.balancerName, options.healthCheckConfig)),
		// 整合所有一元拦截器
		grpc.WithChainUnaryInterceptor(ints...),
		// 整合所有流式拦截器
		grpc.WithChainStreamInterceptor(sints...),
	}
    // 这是本篇文章的重点
	if options.discovery != nil {
		grpcOpts = append(grpcOpts,
			grpc.WithResolvers(
                // 这个是 kratos 通用的 服务发现的 Builder 由于 grpc要求服务发现传一个Builder 后续会讲为什么
				discovery.NewBuilder(
                    /*
                    内部接收的是接口 所以这里传入是我们自己的 就是 consul的 Registry
                    在服务注册文章中重点介绍过,大概解释一下,kratos支持很多类型比如 consul etcd等等
                    每一个都是一个文件夹,其中都有一个 New 函数返回属于自身的Registry实例
                    */ 
					options.discovery,   
					discovery.WithInsecure(insecure),
					discovery.WithTimeout(options.timeout),
					discovery.WithSubset(options.subsetSize),
					discovery.PrintDebugLog(options.printDiscoveryDebugLog),
				)))
	}
    // 是否 安全拨号
	if insecure {
		grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(grpcinsecure.NewCredentials()))
	}
    // TLS 配置 
	if options.tlsConf != nil {
		grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(credentials.NewTLS(options.tlsConf)))
	}
    
    // 用户自己传的
	if len(options.grpcOpts) > 0 {
		grpcOpts = append(grpcOpts, options.grpcOpts...)
	}
    // 核心 创建 gRPC 连接  下面进入该函数
	return grpc.DialContext(ctx, options.endpoint, grpcOpts...)
}

dial() 函数中核心依赖------discovery.Builder

由上面函数我们知道的是要服务发现需要传入一个 discovery 它是一个接口 registry.Discovery 我们这里用的 consul 举例子,kratos 支持很多,每支持一个,就会有一个 Registry,所以这里传入的就是 consulRegistry , 也是和服务注册场景中用到的是同一个结构体,这并不是巧合,而是 kratos 框架的设计逻辑:registry.Registry接口同时抽象了服务注册和服务发现两类核心能力,而consul.Registry作为该接口的具体实现,天然同时具备注册和发现的能力。

下面是 kratos 的服务注册和服务发现的核心接口

大家要清楚的是,kratos 支持很多能够满足接口的结构体,每支持一个,就会有一个 Registry,核心是这些 Registry 是既实现了 Registrar 接口,也实现了 Discovery 接口,所以我们才说天然同时具备注册和发现的能力。

go 复制代码
// 服务注册 必须要实现
type Registrar interface {
	Register(ctx context.Context, service *ServiceInstance) error
	Deregister(ctx context.Context, service *ServiceInstance) error
}

// 服务发现
type Discovery interface {
	GetService(ctx context.Context, serviceName string) ([]*ServiceInstance, error)
	Watch(ctx context.Context, serviceName string) (Watcher, error)
}

// 后续会讲
type Watcher interface {
	Next() ([]*ServiceInstance, error)
	Stop() error
}

// 这里就是服务注册和服务发现需要用到的kratos自己定义的注册信息,注册时需要构建这个实例,上个文章已经讲过
type ServiceInstance struct {
	ID string `json:"id"`
	Name string `json:"name"`
	Version string `json:"version"`
	Metadata map[string]string `json:"metadata"`
	//   http://127.0.0.1:8000?isSecure=false
	//   grpc://127.0.0.1:9000?isSecure=false
	Endpoints []string `json:"endpoints"`
}

想要实例化传入参数 discovery 参考一下下面代码

go 复制代码
func NewDiscovery() registry.Discovery {
    // api这个是官方consul包 因为 kratos 的 consul 注册中心实现底层正是基于该官方库封装而来
    c := api.DefaultConfig()	// 默认ip 为 127.0.0.1:8500
    c.Address = ""              // 这个就是你下载consul 的服务器ip:port 比如用虚拟机docker安装的就填写虚拟机ip和port
	c.Scheme = registry.Scheme  // consul 只支持 http 和 https 这个是你去连接consul的 不是去服务发现
	cli, err := api.NewClient(c)  // 同样是官方consul的 返回 kratos内部定义的 Registry 它需要的官方实例
	if err != nil {
		panic(err)
	}
    // 核心  这里就是调用的kratos内部包consul 传入的是底层官方consul的实例
	r := consul.New(cli, consul.WithHealthCheck(true))
	return r
}

以防大家有点晕,这里贴出 kratos 对于 consulRegistry,上面就是调用下面代码中的函数,本质上包装了官方实例,补充上 kratos 自己的字段,增加扩展性

go 复制代码
type Registry struct {
	cli               *Client    
	enableHealthCheck bool
	registry          map[string]*serviceSet
	lock              sync.RWMutex
	timeout           time.Duration
}

// New creates consul registry
func New(apiClient *api.Client, opts ...Option) *Registry {
	r := &Registry{
		registry:          make(map[string]*serviceSet),
		enableHealthCheck: true,
		timeout:           10 * time.Second,
        // 这个实例本质上就是对官方 consul 的实例的扩展,内部嵌套了官方实例,还有自己定义的字段
		cli: &Client{
			dc:                             SingleDatacenter,
			cli:                            apiClient,   // 我们传入的 官方 consul 的实例
			resolver:                       defaultResolver,
			healthcheckInterval:            10,
			heartbeat:                      true,
			deregisterCriticalServiceAfter: 600,
			cancelers:                      make(map[string]*canceler),
		},
	}
	for _, o := range opts {
		o(r)
	}
	return r
}
// 实现了  Registrar 和 Discovery 接口 这里不写了 后面会用到 用到我会再贴出

这里主要讲解一下 discovery.NewBuilder() 函数

如果第一次看这段代码可能很懵,这么写的原因主要是 Grpc 要求的,必须放入一个实现了 Builder 接口的,也就是代码中 resolver.Builder ,也就是必须有 Build(...)Scheme() 函数,Build(...) 主要作用就是返回一个 resolver.Resolver,同样是接口,Grpc 这么做的原因是Builder 负责造 监听器,Resolver 负责让这个监听器一直跑着,还能随时关掉,做到职责分离。

go 复制代码
// 常量 无论你使用什么前缀名都是 discovery 例如 discovery:///test-srv 这种就是consul形式 前提你注册了test-srv
const name = "discovery" 

type builder struct {
	discoverer registry.Discovery
	timeout    time.Duration
	insecure   bool
	subsetSize int
	debugLog   bool
}

// Grpc 要求传入的 实现 resolver.Builder 的 builder  后续需要用它的 build(...) 函数
func NewBuilder(d registry.Discovery, opts ...Option) resolver.Builder {
	b := &builder{
		discoverer: d,
		timeout:    time.Second * 10,
		insecure:   false,
		debugLog:   true,
		subsetSize: 25,
	}
	for _, o := range opts {
		o(b)
	}
	return b
}

// 后续会重点讲到
func (b *builder) Build(target resolver.Target, cc resolver.ClientConn, _ resolver.BuildOptions) (resolver.Resolver, error) {
	watchRes := &struct {
		err error
		w   registry.Watcher
	}{}

	done := make(chan struct{}, 1)
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		w, err := b.discoverer.Watch(ctx, strings.TrimPrefix(target.URL.Path, "/"))
		watchRes.w = w
		watchRes.err = err
		close(done)
	}()

	var err error
	if b.timeout > 0 {
		select {
		case <-done:
			err = watchRes.err
		case <-time.After(b.timeout):
			err = ErrWatcherCreateTimeout
		}
	} else {
		<-done
		err = watchRes.err
	}
	if err != nil {
		cancel()
		return nil, err
	}

	r := &discoveryResolver{
		w:           watchRes.w,
		cc:          cc,
		ctx:         ctx,
		cancel:      cancel,
		insecure:    b.insecure,
		debugLog:    b.debugLog,
		subsetSize:  b.subsetSize,
		selectorKey: uuid.New().String(),
	}
	go r.watch()
	return r, nil
}

// 这个函数就是表明自己用什么去服务发现 我们这里就是自定义的 kratos 把所有的自定义的服务发现 都为一个 name
// 只需要记住因为我们告诉 grpc 用自己的服务发现,那么 grpc 会调用这个函数,从而知道用我们自己的服务发现 后续会有
func (*builder) Scheme() string {
	return name
}

DialContext() 函数

进入Grpc 官方DialContext() 函数,我建议大家先看到 for 循环那里,然后去后续我介绍的 NewClient() 函数,那是主流程,后续返回后,在看 for 循环,大家就会知道在做什么了

go 复制代码
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
    
    /* 
    先添加默认 再用传来的opts覆盖
   	withDefaultScheme("passthrough"):设置默认的命名解析方案为 passthrough 用于新手的
   	我们这个是kratos框架 Scheme 统一是 discovery 也就是上面说过的 Scheme() 函数返回值
     */
	opts = append([]DialOption{withDefaultScheme("passthrough"), WithLocalDNSResolution()}, opts...)
    
    // 解析所有 DialOption 并填充到 ClientConn 的配置中;函数选项模式,把我们传来的放进去
	cc, err := NewClient(target, opts...)  // 返回 ClientConn 实例对象
	if err != nil {
		return nil, err
	}

	// 由于上面已经创建实例,失败会导致资源泄漏,这个时候要拦住关闭
	defer func() {
		if err != nil {
			cc.Close()
		}
	}()

    // 脱离空闲状态的 重点函数 是一条主线程 可以说是主逻辑 
    // 讲完 上面的 NewClient(...) 函数 就开启这个主逻辑
	if err := cc.idlenessMgr.ExitIdleMode(); err != nil {
		return nil, err
	}

    // 如果配置了非阻塞模式(block=false),则直接返回 ClientConn 实例(不管连接是否成功建立),连接会在后台异步重试。
	if !cc.dopts.block {
		return cc, nil
	}
    
	//  拨号超时控制
	if cc.dopts.timeout > 0 {
        // 说明配置开启了 超时时间限制
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, cc.dopts.timeout)
		defer cancel()
	}
    
    // 注意是 defer函数 也是拦截错误
	defer func() {
		select {
		case <-ctx.Done():  // 监听上下文是否结束 进入 switch
			switch {
			case ctx.Err() == err:  // 说明当前错误就是上下文错误,将返回的 conn 置为 nil;
				conn = nil
			case err == nil || !cc.dopts.returnLastError: // 纯粹 应对超时
				conn, err = nil, ctx.Err()
			default:  // 其他情况 返回组合错误
				conn, err = nil, fmt.Errorf("%v: %v", ctx.Err(), err)
			}
		default:  //  表示上下文未结束,不处理
		}
	}()

    // 阻塞拨号的核心循环
	for {
		s := cc.GetState()  // 核心 下篇会讲解 这篇未用到
		if s == connectivity.Idle {
            // 触发连接建立
			cc.Connect()
		}
		if s == connectivity.Ready {
            // 连接就绪 直接返回
			return cc, nil
            // 先跳过 非重点
            // 配置项,是否遇到非临时错误时直接失败 TransientFailure  临时失败
		} else if cc.dopts.copts.FailOnNonTempDialError && s == connectivity.TransientFailure {
			if err = cc.connectionError(); err != nil {
                // 判断错误是否实现 Temporary()
				terr, ok := err.(interface {
					Temporary() bool
				})
                //  错误是非临时的 则直接返回错误(不再重试) 
				if ok && !terr.Temporary() {
					return nil, err
				}
			}
		}
        // 返回值:true 表示状态变化(继续循环检查),false 表示 ctx 超时 / 取消;
		if !cc.WaitForStateChange(ctx, s) {
			// ctx got timeout or canceled.
			if err = cc.connectionError(); err != nil && cc.dopts.returnLastError {
				return nil, err
			}
			return nil, ctx.Err()
		}
	}
}

NewClient() 函数

NewClient()Grpc 客户端连接创建的核心初始化函数,本质上是核心组件的初始化,为后续的连接建立打下基础。

go 复制代码
func NewClient(target string, opts ...DialOption) (conn *ClientConn, err error) {
    // 构建实例   
	cc := &ClientConn{
         /* 
         这个在我们这个例子中是 discovery:///test-srv 解释如下:
        discovery 是kratos固定写法,无论用etcd还是consul,都是这个 主要是告诉Grpc要用自定义的服务发现和负载均衡
		test-srv 是我们服务注册的时候注册上去的name,必须要注册上去才行      
         */
		target: target,  
		conns:  make(map[*addrConn]struct{}),
        // 启动一些默认的配置
		dopts:  defaultDialOptions(),   
	}
	// 重试限流管理器 先跳过
	cc.retryThrottler.Store((*retryThrottler)(nil))
    
    // 安全的服务配置选择器 先跳过
	cc.safeConfigSelector.UpdateConfigSelector(&defaultConfigSelector{nil})
    
    // 调用 cancel() 可关闭整个 ClientConn  维护主 context
	cc.ctx, cc.cancel = context.WithCancel(context.Background())

    // 判断是否传入了禁用全局拨号选项的配置  先跳过
	disableGlobalOpts := false
	for _, opt := range opts {
		if _, ok := opt.(*disableGlobalDialOptions); ok {
			disableGlobalOpts = true
			break
		}
	}
	// 也就是 grpc有一些默认的配置 用户需要么
	if !disableGlobalOpts {
		for _, opt := range globalDialOptions {
			opt.apply(&cc.dopts)
		}
	}
    
	// 用户传入的进行修改   cc.dopts 是所有的配置信息了
	for _, opt := range opts {
		opt.apply(&cc.dopts)
	}

    // 这个函数是核心 这个是找到我们自己的注册的服务发现的builder 没注册用grpc默认的 下面会深入该函数
	if err := cc.initParsedTargetAndResolverBuilder(); err != nil {
		return nil, err
	}

    // 对不同目标地址应用不同的全局配置  跳过
	for _, opt := range globalPerTargetDialOptions {
		opt.DialOptionForTarget(cc.parsedTarget.URL).apply(&cc.dopts)
	}
    
	//  初始化拦截器
	chainUnaryClientInterceptors(cc)
	chainStreamClientInterceptors(cc)
	
    
    // 校验配置的传输层安全凭据 先跳过
	if err := cc.validateTransportCredentials(); err != nil {
		return nil, err
	}
	// 核心 这个是关于负载均衡的 会在下一个文章中介绍
    // defaultServiceConfigRawJSON:用户传入的 JSON 字符串(比如 {"loadBalancingPolicy":"round_robin"});
	if cc.dopts.defaultServiceConfigRawJSON != nil {
		scpr := parseServiceConfig(*cc.dopts.defaultServiceConfigRawJSON, cc.dopts.maxCallAttempts)
		if scpr.Err != nil {
			return nil, fmt.Errorf("%s: %v", invalidDefaultServiceConfigErrPrefix, scpr.Err)
		}
		cc.dopts.defaultServiceConfig, _ = scpr.Config.(*ServiceConfig)
	}
    
    // 复制 TCP 保活参数到 ClientConn,用于维护长连接的存活
	cc.keepaliveParams = cc.dopts.copts.KeepaliveParams
	
    // 用于服务端识别客户端请求的目标
	if err = cc.initAuthority(); err != nil {
		return nil, err
	}

    // 注册到监控工具 先跳过
	cc.channelzRegistration(target)
	channelz.Infof(logger, cc.channelz, "parsed dial target is: %#v", cc.parsedTarget)
	channelz.Infof(logger, cc.channelz, "Channel authority set to %q", cc.authority)
	
    // 创建连接状态管理器,负责维护 ClientConn 的整体状态(Idle/Connecting/Ready 等)
	cc.csMgr = newConnectivityStateManager(cc.ctx, cc.channelz)
    
    //  初始化负载均衡选择器包装器
	cc.pickerWrapper = newPickerWrapper()
    
	// 初始化 RPC 指标统计组件 先跳过 非核心
	cc.metricsRecorderList = istats.NewMetricsRecorderList(cc.dopts.copts.StatsHandlers)
	cc.statsHandler = istats.NewCombinedHandler(cc.dopts.copts.StatsHandlers...)
    
    /* initIdleStateLocked() 里面主要是给这两个赋值  这两个函数我们在负载均衡文章中讲到 这里用不到
    // 内容如下
    cc.resolverWrapper = newCCResolverWrapper(cc)  解析器包装器 
	cc.balancerWrapper = newCCBalancerWrapper(cc)  负载均衡包装器 
    */
    cc.initIdleStateLocked() 


    // 创建空闲管理器  重点 因为 DialContext() 中的主逻辑就是该结构体的方法 必须实例化好
	cc.idlenessMgr = idle.NewManager((*idler)(cc), cc.dopts.idleTimeout)

	return cc, nil
}

initParsedTargetAndResolverBuilder() 函数

主要是为 ClientConn.resolverBuilder 赋值,使用 Kratos 框架进行拨号都是 discovery:///xxx 这样,那是由于我们使用 grpc.WithResolvers() 传入了自己的处理方法,如果正常新手,没用框架就会走 Grpc 默认的 passthrough:///127.0.0.1:8080

go 复制代码
func (cc *ClientConn) initParsedTargetAndResolverBuilder() error {
	logger.Infof("original dial target is: %q", cc.target)

	var rb resolver.Builder
    // parseTarget 重点 将 cc.target = discovery:///test-srv  解析成 
    // Scheme: discovery  Host: ``  Path: /test-srv  填充到 url.URL 结构体中 
	parsedTarget, err := parseTarget(cc.target)
	if err == nil {
        // 解析成功 没错的话
        // 核心函数 下个函数就会介绍 这时候就去取出我们当时用 grpc.WithResolvers() 传入的 builder
		rb = cc.getResolver(parsedTarget.URL.Scheme)
		if rb != nil {
     		// 也就是说 能找到就给 resolverBuilder 赋值  这个是重点后续会重点用
			cc.parsedTarget = parsedTarget  // 这个就是把上面解析的给赋上值 格式是 url.URL 
			cc.resolverBuilder = rb
			return nil
		}
	}
    
    // 到了这里说明出错了或者压根没传过  就使用默认的 也就是新手用的,kratos 一般不会走这里 除非你改源码
    //  默认 scheme 是 passthrough
    defScheme := cc.dopts.defaultScheme
	if internal.UserSetDefaultScheme {  //看看用户设置了么 设置了就用用户的
		defScheme = resolver.GetDefaultScheme()
	}
	//canonicalTarget =  passthrough:///127.0.0.1:8080
	canonicalTarget := defScheme + ":///" + cc.target
	
    // 再来一遍 解析
	parsedTarget, err = parseTarget(canonicalTarget)
	if err != nil {
		return err
	}
    // 找到 gRPC 内置的 passthrough 解析器 默认的
	rb = cc.getResolver(parsedTarget.URL.Scheme)
	if rb == nil {
		return fmt.Errorf("could not get resolver for default scheme: %q", parsedTarget.URL.Scheme)
	}
	cc.parsedTarget = parsedTarget
	cc.resolverBuilder = rb
	return nil
}

getResolver() 函数

这里有两种获取方法,第一种就是我们通过 grpc.WithResolvers() 该函数传入,这算局部,还有一个全局,就是通过resolver.Register() 该函数注册上去,本质上就是一个map,键为 schemekratos 就是 discovery,这里找的原则就是先找局部传入的,局部没有找到的话,就掉全局,也就是return 的函数,都没有就返回空,上面函数就会使用默认的 passthrough

go 复制代码
func (cc *ClientConn) getResolver(scheme string) resolver.Builder {

    // 这个for 循环是我们自己的Dial中调用 grgrpc.WithResolvers(),可能传多个
	for _, rb := range cc.dopts.resolvers {
		if scheme == rb.Scheme() {
			return rb
		}
	}
    // 没有传  去调用下面的 看看全局注册了么
	return resolver.Get(scheme)
}

Register() 函数

当想注册全局服务发现的时候,可以在项目启动的时候进行注册。

go 复制代码
func Register(b Builder) {
	m[b.Scheme()] = b
}

// 本质上 我们实现的结构体 会注册到这上面 也就是上面的 Register 函数 我门会调用 然后它会存到 一个全局map中  这样服务发现可以找到
func Get(scheme string) Builder {
	if b, ok := m[scheme]; ok {
		return b
	}
	return nil
}

newCCResolverWrapper() 函数

它是在 NewClient() 函数里面 cc.initIdleStateLocked() 初始化两个重要的结构体之一,该结构体后续绑定的方法是服务发现最重要的一条主线,因为 Go 没有继承,我们就使用组合加封装的方法,包装成包装器(Wrapper),在这里只需要记住它里面有上游对象的引用就可以(ClientConn)。

go 复制代码
func newCCResolverWrapper(cc *ClientConn) *ccResolverWrapper {
	ctx, cancel := context.WithCancel(cc.ctx)
	return &ccResolverWrapper{
		cc:                  cc,   // 这是 Go 替代传统面向对象继承的核心方式
		ignoreServiceConfig: cc.dopts.disableServiceConfig,
		serializer:          grpcsync.NewCallbackSerializer(ctx),
		serializerCancel:    cancel,
	}
}

idle.NewManager() 函数

本质也是包装器,把 cc 被包装为 Enforcer 接口实例,传入到 NewManager 实现空闲管理能力扩展;这里的 cc 也就是 ClientConn 已经实现了 Enforcer 这个接口,后续这个就可以用于空闲管理了。

go 复制代码
func NewManager(enforcer Enforcer, timeout time.Duration) *Manager {
	return &Manager{
		enforcer:         enforcer, // 这里就是 ClientConn 上游对象
		timeout:          timeout,
		actuallyIdle:     true,
		activeCallsCount: -math.MaxInt32,
	}

到这里,NewClient() 函数里面服务发现的东西大部分讲解完成,后续我们再回到调用它的 DialContext() 函数。

深入了解 cc.idlenessMgr.ExitIdleMode() 函数

这个函数是跳出空闲状态,因为来订阅者了,这里着重讲一下空闲状态调用的 m.enforcer.ExitIdleMode(),就是上面函数我们把 ClientConn 包装成了空闲管理器这里就用了,只不过换了一下名字而已

go 复制代码
func (m *Manager) ExitIdleMode() error {  
	
	m.idleMu.Lock()
	defer m.idleMu.Unlock()
	// 这里面 先判断如果连接已经关了,或者本来就不是真的空闲(比如已经在连接中),就啥也不用干,直接返回,
	if m.isClosed() || !m.actuallyIdle {
		return nil
	}
    // 如果是空闲 那就调用m.enforcer.ExitIdleMode() 而这个是接口
    // 这里就是 ClientConn.ExitIdleMode()
	if err := m.enforcer.ExitIdleMode(); err != nil {
		return fmt.Errorf("failed to exit idle mode: %w", err)
	}

	// 原子操作修改活跃调用数 先了解一下等全部学完就明白了
	atomic.AddInt32(&m.activeCallsCount, math.MaxInt32)
	m.actuallyIdle = false

	// 重置空闲计时器 
	m.resetIdleTimerLocked(m.timeout)
	return nil
}

ExitIdleMode() 函数

这里虽然本质上就是直接调用 ClientConn.ExitIdleMode(), 但这种层层代理调用的设计模式,核心是分层解耦、接口抽象、职责隔离,这里不多讲。

go 复制代码
func (i *idler) ExitIdleMode() error {
	return (*ClientConn)(i).exitIdleMode()
}

cc.exitIdleMode() 函数

cc.resolverWrapper 实例已经在 NewClient() 初始化了

go 复制代码
func (cc *ClientConn) exitIdleMode() (err error) {
	cc.mu.Lock()
    // 查连接集合是否为空  这是我们初始化的时候是 空map 并不是 nil 大家可以回去看一眼
	if cc.conns == nil {
		cc.mu.Unlock()
		return errConnClosing
	}
	cc.mu.Unlock()
	// 核心函数
	if err := cc.resolverWrapper.start(); err != nil {
		return err
	}

	cc.addTraceEvent("exiting idle mode")
	return nil
}

start() 函数

重点是 让 ccr.resolver 有了值 也就是我们自己写Build 函数返回值 类型为 resolver.Resolver

这里的 TrySchedule 是将一个回调函数安全地提交到序列化器的执行队列中,保证回调函数的串行执行,且能处理提交失败的场景,简单理解就是数据结构里的队列,放进队列里的函数必须串行执行。

go 复制代码
func (ccr *ccResolverWrapper) start() error {
	errCh := make(chan error)  // 因为下面是异步 所以要用 chan 阻塞一下
    
	ccr.serializer.TrySchedule(func(ctx context.Context) {
		if ctx.Err() != nil {  // clientconn 挂了 那没必要继续了
			return
		}
		opts := resolver.BuildOptions{  // 封装一下
			DisableServiceConfig: ccr.cc.dopts.disableServiceConfig,
			DialCreds:            ccr.cc.dopts.copts.TransportCredentials,
			CredsBundle:          ccr.cc.dopts.copts.CredsBundle,
			Dialer:               ccr.cc.dopts.copts.Dialer,
			Authority:            ccr.cc.authority,
			MetricsRecorder:      ccr.cc.metricsRecorderList,
		}
		var err error
		//第一个分支 注册的 resolver 直接生效 gRPC 不插手 不想要任何 gRPC 默认行为 没有保底
        // 使用的话 grpc.WithNoProxy() grpc.WithContextDialer(...) 走这个
		if ccr.cc.dopts.copts.Dialer != nil || !ccr.cc.dopts.useProxy {
			ccr.resolver, err = ccr.cc.resolverBuilder.Build(ccr.cc.parsedTarget, ccr, opts)
		} else {
            // 第二个分支 grpc 会帮你  Proxy 判断 本地 DNS 优化 scheme fallback 等等 我们走这个
			ccr.resolver, err = delegatingresolver.New(ccr.cc.parsedTarget, ccr, opts, ccr.cc.resolverBuilder, ccr.cc.dopts.enableLocalDNSResolution)
		}
		errCh <- err
	})
	return <-errCh
}

delegatingresolver.New() 核心函数

重点是传入了 ccr.cc.resolverBuilder,这个就是我们一直强调的通过 grpc.WithResolvers() 传进来的 builder,然后在上面 initParsedTargetAndResolverBuilder() 函数中赋给 ccr.cc.resolverBuilder

go 复制代码
func New(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions, targetResolverBuilder resolver.Builder, targetResolutionEnabled bool) (resolver.Resolver, error) {
	// 初始化委托解析器核心结构体
	r := &delegatingResolver{
		target:         target,          // 待解析的目标地址 我们的 discovery:///test-srv
		cc:             cc,              // 上层的 ClientConn 一样的父引用
		proxyResolver:  nopResolver{},   // 代理解析器默认初始化为空实现
		targetResolver: nopResolver{},   // 目标解析器默认初始化为空实现
	}

	// 提取目标地址 在咱们的例子中 addr = test-srv
	addr := target.Endpoint()
	var err error
    
	// 特殊处理:如果目标协议是 dns、未启用客户端目标解析、且启用了代理目标默认端口,则先解析目标地址,补充默认端口 跳过
	if target.URL.Scheme == "dns" && !targetResolutionEnabled && envconfig.EnableDefaultPortForProxyTarget {
		addr, err = parseTarget(addr)
		if err != nil {
			return nil, fmt.Errorf("delegating_resolver: invalid target address %q: %v", target.Endpoint(), err)
		}
	}
    
	// 中间有些非核心代码就不看了

	// 构建目标解析器  同样是一个包装器,核心作用是把目标解析器的状态更新转发给委托解析器
	wcc := &wrappingClientConn{
		stateListener: r.updateTargetResolverState,  // 状态更新回调:解析结果变化时触发 后续会讲
		parent:        r,                            // 指向委托解析器本身
	}
    // 用传入的解析器构建器,构建目标解析器 核心中的核心 这个时候调用我们传入的builder.build 方法
	if r.targetResolver, err = targetResolverBuilder.Build(target, wcc, opts); err != nil {
		return nil, fmt.Errorf("delegating_resolver: unable to build the resolver for target %s: %v", target, err)
	}
	return r, nil
}

kratos 内部定义的 builder.Build() 函数

至此,我们通过 grpc.WithResolvers() 注入的自定义 resolver.Builder 实例,终于在这一步被执行,调用其 Build() 方法,这是整个解析器委托链路中,自定义服务发现逻辑落地的关键触发点。

这里说明一下,我们进行服务发现是作为一个 watcher 也就是订阅者来取服务实例,考虑到一个问题,如果实例更新了,或者有的挂了,订阅者必须知道,所以至少需要两个协程来监听,第一,第一次对某个服务进行服务发现,比如用户服务,那必须启动一个协程来监听它服务注册的多个实例,如果增加或者减少,必须得监听到,并发送给使用用户服务的 watcher;第二,既然说了更改了要通知订阅者,那订阅者也得开一个协程去监听有没有给自己发信息通知去更新实例,我们下个函数就会讲到第一个需要监听的,我们这个函数最后执行的 go r.watch(),就是去解决第二个。

go 复制代码
func (b *builder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
    
	var (
		err error
		w   registry.Watcher    //作为一个订阅方
	)
	done := make(chan struct{}, 1)
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		// b.discoverer 这个就是实现 discovery 接口的 consul 也可以是别的  它内部的watch
		w, err = b.discoverer.Watch(ctx, strings.TrimPrefix(target.URL.Path, "/"))
		close(done)
	}()
    
    // 等上面处理完成 close(done) 之后 才能到这 或者超时了直接报错 建议看完后回来再看后面
	select {
	case <-done:
	case <-time.After(b.timeout):
        // kratos 预制的错误
		err = ErrWatcherCreateTimeout
	}
	if err != nil {
		cancel()
		return nil, err
	}
    // 返回 grpc 需要的实现了Resolver接口的结构体
	r := &discoveryResolver{
		w:        w,
		cc:       cc,
		ctx:      ctx,
		cancel:   cancel,
		insecure: b.insecure,
	}
	go r.watch()
	return r, nil
}

consul对应 kratos 包下的 Watch 方法

我们一直使用的 discovery:///test-srv 这个例子,name 就是 test-srv,补充一下这个函数的位置,我们之前就提到过, kratos 服务注册和服务发现是集成在一个结构体中,这里就是属于 consulRegistry。这里函数名字叫 watch 主要是想表达如果是第一次找 test-srv 这个服务,那么我要开一个协程去监听 consul 服务器,随时更新新的实例。

go 复制代码
func (r *Registry) Watch(ctx context.Context, name string) (registry.Watcher, error) {
	r.lock.Lock()
	defer r.lock.Unlock()
	set, ok := r.registry[name]
	// 刚开始肯定没有 没有就创建放进去,如果找到说明服务名一样 复用 set 
	if !ok {
		set = &serviceSet{
			// 管理该服务名下的所有活跃监听者,空结构体不占用内存,仅用于标记监听者的 存在性
            // 后续服务实例变化时,遍历该 map 通知所有监听者
			watcher:     make(map[*watcher]struct{}),
			services:    &atomic.Value{}, // 原子值类型 用于读多写少  服务实例列表
			serviceName: name,
		}
		r.registry[name] = set
	}

	// 初始化 watcher 现在是服务发现,相当于我们现在是订阅者
	w := &watcher{ // 代表 一个订阅者
		event: make(chan struct{}, 1),  // 这里代表如果服务更新了,地址换了或者加了,通知一声
	} 
	w.ctx, w.cancel = context.WithCancel(context.Background())
    
    // 重点 这里是如果服务发生变化,订阅者收到消息,他就从set的services字段中更新服务
	w.set = set
	set.lock.Lock()
    
	// 把这一个订阅者 放进map中 后续改地址就遍历这个set的 watcher 挨个发消息通知
	set.watcher[w] = struct{}{}
	set.lock.Unlock()
    
	// 原子读取存储的服务实例列表 如果不是第一次的话,直接去拿服务就好
	ss, _ := set.services.Load().([]*registry.ServiceInstance)
	if len(ss) > 0 { 
        // 非首次监听  里面有  我直接让你去取 那边阻塞了 next方法 后续有
		w.event <- struct{}{}
	}
    
    // 这里就是首次也是我要介绍的
	if !ok {
        // 后续会进入这个函数
		err := r.resolve(set)
		if err != nil {
			return nil, err
		}
	}
	return w, nil
}

resolve() 函数

这里说一下函数具体做了什么,因为是第一次对 name 的注册,我们一定要开启一个监控器,监控服务注册里面的实例,比如增加了删除了,我们的 watcher 必须得知道,不然前端来请求,用到服务了,你这服务挂了就不合理了,所以第一次注册 name 一定要开一个监听器,这里注意,是每一个不同的 name 都要开一个监听,比如你用户服务注册了,那就要执行这个函数来监听用户服务实例,服务又注册了订单服务,那也要执行这个函数,再开一个协程去监听订单服务实例,这个函数还有就是因为我们第一次进来,我们的 watcher 并没有获得服务实例,所以我们要先获取一下,然后重点就是要告诉我们的 watcher,这里就通过 broadcast() 函数通知我们所有的 watcher

go 复制代码
func (r *Registry) resolve(ss *serviceSet) error {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
	//idx 是 Consul 服务端数据版本的标识 ------ 每次服务实例变化,该索引会递增
    // r.cli.Service()函数是真正去 consul 的客户端拿地址后面就介绍该函数
	services, idx, err := r.cli.Service(ctx, ss.serviceName, 0, true)
	defer cancel()
	if err != nil {
		return err
	} else if len(services) > 0 {
		// 更新 s.services 这个服务实例列表并执行广播
		ss.broadcast(services)
	}
	go func() {
		// 创建一个定时器 1秒的
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()
		for {
			<-ticker.C
            // 我们去服务器拿实例 要有超时时间 不然一直拿不到卡住也不行
			ctx, cancel := context.WithTimeout(context.Background(), time.Second*120)
            // 这里主要根据 tmpIdx 是否和之前的 idx 一样来判断有没有更新 更新了 tmpIdx 就变了
			tmpService, tmpIdx, err := r.cli.Service(ctx, ss.serviceName, idx, true)
			cancel()
            // 错了 大概率网络有问题 稍后重试
			if err != nil {
				time.Sleep(time.Second)
				continue
			}
            // 这种情况就是有变化了 我要更新一下服务 ss.broadcast 通知属于他们的 watcher
			if len(tmpService) != 0 && tmpIdx != idx {
				services = tmpService
				ss.broadcast(services)
			}
            // 改变 idx 用于下一次判断
			idx = tmpIdx
		}
	}()

	return nil
}

Service() 函数(服务发现的底层方法)

这个函数就是与服务端的consul进行交互,知道是通过这个去获得服务实例就好。

go 复制代码
func (c *Client) Service(ctx context.Context, service string, index uint64, passingOnly bool) ([]*registry.ServiceInstance, uint64, error) {
	// 构造Consul查询选项:长轮询配置
	opts := &api.QueryOptions{
		WaitIndex: index,   // 增量查询索引(
		WaitTime:  time.Second * 55, // 长轮询超时时间
	}
	opts = opts.WithContext(ctx) // 绑定上下文
	
	// 调用Consul API
	entries, meta, err := c.cli.Health().Service(service, "", passingOnly, opts)
	if err != nil {
		return nil, 0, err
	}
	
	// 返回实例 和 LastIndex
	return c.resolver(ctx, entries), meta.LastIndex, nil
}

ss.broadcast() 核心函数

首先更新服务实例,不管是有的挂了还是增加了,直接覆盖,更新完成后,通知自己的 watcher 让他们取更新后的实例。

这里直接发信息,信息容量为1,如果满了说明它上次更新的数据时,通知它但它没取,这次就不用通知了,因为我发消息的本质就是让你来取,它闲下来的时候就会来取最新的了。

go 复制代码
func (s *serviceSet) broadcast(ss []*registry.ServiceInstance) {
	s.services.Store(ss)
	s.lock.RLock()
	defer s.lock.RUnlock()
    // 遍历每个 watcher 发信息
	for k := range s.watcher {
		select {
		case k.event <- struct{}{}:
		default:
            // 说明信息上次就发了 没取 无所谓直接跳过
		}
	}
}

discoveryResolver.watch() 函数

这里就是上面说过的每一个 watcher 都必须监听是否有信息发过来,用于更新服务实例,这里除了主 context 被取消,就会进入到 r.w.Next() 函数,它才是一直卡住,一直到收到消息,这层的话就是类似中间层,永不停息的执行 r.w.Next() 函数,捕获错误,更新后,调用 r.update(ins) 重点函数进行更新实例,职责分离。

go 复制代码
func (r *discoveryResolver) watch() {
	for {
		select {
		case <-r.ctx.Done():
			return
		default:
		}
        // 在这阻塞住 
		ins, err := r.w.Next()
		if err != nil {
			if errors.Is(err, context.Canceled) {
				return
			}
			log.Errorf("[resolver] Failed to watch discovery endpoint: %v", err)
			time.Sleep(time.Second)
			continue
		}
		r.update(ins)  // 返回之后更新
	}
}

w.Next() 函数

这里就是一直阻塞,直到 w.event 有信息过来,然后更新set.services 字段值,然后返回服务实例列表。

go 复制代码
func (w *watcher) Next() (services []*registry.ServiceInstance, err error) {
   // 这里就是除非错了或者来信息才会走
   select {
   case <-w.ctx.Done():
   	err = w.ctx.Err()
   case <-w.event:
   }

   ss, ok := w.set.services.Load().([]*registry.ServiceInstance)

   if ok {
   	services = append(services, ss...)
   }
   return
}

discoveryResolver.update() 函数

这个函数主要的作用就是我们服务注册使用的是 kratos 内部自定义的结构体 registry.ServiceInstance,但 Grpc 不接受这样的结构体,我们必须变成它能接收的参数,也就是下方函数中 addrs 变量的意义,当我们需要用到实例的时候,我们并不需要在解析回 registry.ServiceInstance,我们把它存起来用的时候拿出来就行。

GO 复制代码
func (r *discoveryResolver) update(ins []*registry.ServiceInstance) {
	addrs := make([]resolver.Address, 0)
    // 防止重复的
	endpoints := make(map[string]struct{})
     // 遍历 Next() 返回的每个实例
    for _, in := range ins {
        // 解析实例的endpoint(把自己的Endpoints转成gRPC能识别的地址)
        endpoint, err := ParseEndpoint(in.Endpoints, "grpc", !r.insecure)
        if err != nil { // 解析失败  比如实例的Endpoints格式不对
            log.Errorf("[resolver] Failed to parse discovery endpoint: %v", err)
            continue // 跳过这个实例,处理下一个
        }
        
        if endpoint == "" { // 解析出空地址,跳过
            continue
        }
        
        // 过滤重复的 endpoint
        if _, ok := endpoints[endpoint]; ok {
            continue // 已经存在,跳过
        }
        
        endpoints[endpoint] = struct{}{} // 标记为已存在,防止重复

        // 构造gRPC需要的resolver.Address结构体
        addr := resolver.Address{
            ServerName: in.Name,       // 服务名(比如user-service)
            Attributes: parseAttributes(in.Metadata), // 解析实例的元数据(比如版本、权重)
            Addr:       endpoint,      // 核心:gRPC要连接的IP:Port(比如192.168.1.100:8080)
        }
        
        // 把原始的 serviceInstance 存到Attributes里,后续使用的时候不需要再换回来,直接取出这个
        addr.Attributes = addr.Attributes.WithValue("rawServiceInstance", in)
        // 把构造好的地址加入最终列表
        addrs = append(addrs, addr)
    }
	if len(addrs) == 0 {
		log.Warnf("[resolver] Zero endpoint found,refused to write, instances: %v", ins)
		return
	}
    // 把构造好的地址列表更新给gRPC客户端连接池 
    err := r.cc.UpdateState(resolver.State{Addresses: addrs})
    if err != nil { // 更新失败(比如客户端已关闭)
        log.Errorf("[resolver] failed to update state: %s", err)
    }
    // 日志输出(方便排查问题)
	b, _ := json.Marshal(ins)
	log.Infof("[resolver] update instances: %s", b)
}

下面就回到 Grpc 内部 cc.UpdateState(resolver.State{Addresses: addrs}) 函数中,也就是服务发现大部分完成了,因为我们已经从中获得了这些实例,下篇文章讲解负载均衡,这两者相结合,大家就明白源码级别的整个流程。

总结

本篇上篇,我们从 kratos 客户端Dial入口出发,走完了服务发现全链路:把注册中心能力封装成 Grpc 兼容的解析器,顺着 Grpc 底层流程监听服务节点变化,实时把注册中心的实例转成 Grpc 能用的地址并同步到客户端。下篇就接着讲,拿到这些节点后,怎么结合 Grpc 做客户端负载均衡。

相关推荐
kevinzeng19 小时前
Spring 核心知识点:EnvironmentAware 接口详解
后端
xyy12319 小时前
C# / ASP.NET Core 依赖注入 (DI) 核心知识点
后端
yuhaiqiang20 小时前
为什么我建议你不要只问一个AI?🤫偷偷学会“群发”,答案准到离谱!
人工智能·后端·ai编程
双向3321 小时前
AR 眼镜拯救社恐:我用 Kotlin 写了个拜年提词器
后端
吾日三省Java21 小时前
Spring Cloud架构下的日志追踪:传统MDC vs 王炸SkyWalking
java·后端·架构
想打游戏的程序猿21 小时前
服务端用AI写前端:隐患、困境与思考
后端
前端拿破轮21 小时前
从0到1搭建个人网站(三):用 Cloudflare R2 + PicGo 搭建高速图床
前端·后端·面试
树獭叔叔21 小时前
深度拆解 DiT:扩散模型与 Transformer 的巅峰结合
后端·aigc·openai
ZhengEnCi21 小时前
08c. 检索算法与策略-混合检索
后端·python·算法