核心数据模型
ClientConn是最核心的数据模型,该结构里面维护了一个连接的所有属性,理解了该数据结构的设计,grpc-go的源码也就不难了
// ClientConn represents a virtual connection to a conceptual endpoint, to
// perform RPCs.
//
// A ClientConn is free to have zero or more actual connections to the endpoint
// based on configuration, load, etc. It is also free to determine which actual
// endpoints to use and may change it every RPC, permitting client-side load
// balancing.
//
// A ClientConn encapsulates a range of functionality including name
// resolution, TCP connection establishment (with retries and backoff) and TLS
// handshakes. It also handles errors on established connections by
// re-resolving the name and reconnecting.
type ClientConn struct {
ctx context.Context // Initialized using the background context at dial time.
cancel context.CancelFunc // Cancelled on close.
// The following are initialized at dial time, and are read-only after that.
target string // User's dial target.
parsedTarget resolver.Target // See parseTargetAndFindResolver().
authority string // See determineAuthority().
dopts dialOptions // Default and user specified dial options.
channelzID *channelz.Identifier // Channelz identifier for the channel.
balancerWrapper *ccBalancerWrapper // Uses gracefulswitch.balancer underneath.
// The following provide their own synchronization, and therefore don't
// require cc.mu to be held to access them.
csMgr *connectivityStateManager
blockingpicker *pickerWrapper
safeConfigSelector iresolver.SafeConfigSelector
czData *channelzData
retryThrottler atomic.Value // Updated from service config.
// firstResolveEvent is used to track whether the name resolver sent us at
// least one update. RPCs block on this event.
firstResolveEvent *grpcsync.Event
// mu protects the following fields.
// TODO: split mu so the same mutex isn't used for everything.
mu sync.RWMutex
resolverWrapper *ccResolverWrapper // Initialized in Dial; cleared in Close.
sc *ServiceConfig // Latest service config received from the resolver.
conns map[*addrConn]struct{} // Set to nil on close.
mkp keepalive.ClientParameters // May be updated upon receipt of a GoAway.
lceMu sync.Mutex // protects lastConnectionError
lastConnectionError error
}
ClientConn和Resolver,Balancer之间的关联
Resolver是用来做地址解析的,Balancer是从解析好的地址里面选择一个,进行底层的连接,那它们和ClientConn是怎么交互的呢?
ClientConn 持有一个 ccBalancerWrapper,ccBalancerWrapper反过来持有ClientConn,ccBalancerWrapper还持有一个gracefulswitch.Balancer,而gracefulswitch.Balancer又反过来持有持有它本身的gracefulswitch.Balancer。
ClientConn 持有一个 ccResolverWrapper,ccResolverWrapper反过来持有ClientConn,ccResolverWrapper还持有一个resolver.Resolver,而resolver.Resolver又反过来持有持有它本身的ccResolverWrapper。
理解:其实就是,互相有关联的两个结构体,互相引用。这样虽然提升了阅读难度,但是确实实现了高内聚,低耦合,每个结构做自己的事情。 ClientConn 《-》 ccBalancerWrapper 《-》 gracefulswitch.Balancer ClientConn 《-》 ccResolverWrapper 《-》 resolver.Resolver 每个层级都维护自己的状态,在自己状态变更的时候,通知需要知道该变更的上下层级,从而实现了整个框架的状态实时联动。ClientConn和ccResolverWrapper是一个包里里面的,而Resolver是另一个包的。ccResolverWrapper是resolver包中ClientConn接口的实现,ccBalancerWrapper是balancer包中ClientConn接口的实现
这种不同层级的结构体互相持有的写法,在go的runtime和操作系统的源码里面也很常见。
我们在刚开始写代码的时候,被教育说controller依赖logic,logic依赖dao,不要反向依赖。所以造成对这种互相依赖的代码的阅读障碍。当然,在写业务代码的时候,还是不建议互相依赖的,互相依赖也有互相依赖的痛点。
比如,ClientConn依赖了resolver包,resolver包也依赖了clientconn包,golang在编译的时候,就会报错循环依赖。grpc-go的解决办法是在resolver包内定义了一个ClientConn的接口,resolver只依赖自己包内的ClientConn接口,而这个接口的实际实现是在其他包。通过接口解决了循环依赖的问题,同时,也提升了源码阅读的难度。在没有goland的时候,很难找到真正的实现。
面向对象的写法,会在某个方法里面改变某个对象的某个值,然后在后续的处理流程中去使用这个被更改的值。所以,看一个方法的时候,需要注意的是这个方法操作了哪个对象的哪个值。而面向过程的写法中,大部分情况都是上一个函数的返回值作为下一个函数的参数,逻辑直观,阅读难度低。
面向对象的写法,在数据结构和逻辑复杂的场景,依然能实现代码的高内聚低耦合。面向过程的写法,在逻辑不复杂的情况下看起来更直观。
连接简历过程整个方法的调用链:
grpc.DialContext -> newCCResolverWrapper -> rb.Build -> r.start -> cc.updateResolverState -> balancewrapper.updateClientConnState -> balancer.UpdateClientConnState -> b.cc.NewSubConn -> b.sc.Connect -> addrConn.connect -> addrConn.resetTransport->addrConn.tryAllAddrs -> addrConn.createTransport -> transport.NewClientTransport -> newHTTP2Client -> 启动读goroutine
其中 b.cc.NewSubConn只传了一个地址,默认的baseBalancer会让每个resolver解析出来的地址都建立一个连接,如果想实现单地址多连接,其中一种办法就是resolver解析的时候,同样的地址多写几个进去。
实际使用
自定义balancer
在实际操作的时候,需要自己注册balance,但是,一般都会new一个basebalancer,然后自定义balancer中的picker,实现自定义的连接选择逻辑。
func init() {
//向grpc注册一个basebalancer类型的balancer,但是自定义了pick的选择逻辑
b := base.NewBalancerBuilder(lbName, &weightedBalancer{}, base.Config{HealthCheck: true})
balancer.Register(b)
}
自定义resolver
自己注册一个resolver,实现服务发现逻辑。可以直接在build的时候实现服务发现,然后调用cc.updateResolverState,也可以在ResolveNow的时候再实现
RPC框架中的实现思路
实际场景中的RPC框架一般都是支持跨集群的,可以用一个map[string][ClientConn]来保存client,每个client在初始化的时候,就配置好这个client属于哪个集群,底层的连接池数量是多少。
集群相关的信息,可以放到 resolver.Address.Attributes里面