etcd 启动分析_grpc-go基于etcd实现服务发现机制

构建高可用、高性能的通信服务,通常采用服务注册与发现、负载均衡和容错处理等机制实现。根据负载均衡实现所在的位置不同,通常可分为以下三种解决方案

负载均衡选择

代理还是客户端?

注意:在某些文献中,代理负载平衡也称为服务器端负载平衡。

在代理与客户端负载平衡之间进行选择是主要的架构选择。 在代理负载平衡中,客户端向负载均衡器(LB)代理发出RPC。 LB将RPC调用分配给可用的后端服务器之一,后端服务器实现服务调用的实际逻辑。 LB跟踪每个后端的负载,并实现公平分配负载的算法。 客户端自己不了解后端服务器。 客户可能不受信任。 此体系结构通常用于面向用户的服务,其中来自开放式Internet的客户端可以连接到数据中心中的服务器,如下图所示。 在这种情况下,客户端向LB发出请求(#1)。 LB将请求传递给其中一个后端(#2),后端报告加载到LB(#3)。

在客户端负载平衡中,客户端知道多个后端服务器,并选择一个用于每个RPC。 客户端从后端服务器获取负载报告,客户端实现负载平衡算法。 在更简单的配置中,不考虑服务器负载,客户端只能在可用服务器之间进行循环。 如下图所示。 如您所见,客户端向特定后端发出请求(#1)。 后端响应负载信息(#2),通常在执行客户端RPC的同一连接上。 然后客户端更新其内部状态。

比较

代理负载均衡选项

代理负载平衡可以是L3 / L4(传输级别)或L7(应用级别)。在传输级负载平衡中,服务器终止TCP连接并打开与所选后端的另一个连接。应用程序数据(HTTP / 2和gRPC帧)只是在客户端连接到后端连接之间复制。 L3 / L4 LB设计的处理非常少,与L7 LB相比延迟更少,并且因为它消耗更少的资源而更便宜。

在L7(应用程序级别)负载平衡中,LB终止并解析HTTP / 2协议。 LB可以检查每个请求并根据请求内容分配后端。例如,作为HTTP标头的一部分发送的会话cookie可用于与特定后端关联,因此该会话的所有请求都由同一后端提供。一旦LB选择了适当的后端,它就会为该后端创建一个新的HTTP / 2连接。然后,它将从客户端接收的HTTP / 2流转发到所选择的后端。使用HTTP / 2,LB可以在多个后端之间分配来自一个客户端的流。

L3 / L4(传输层)与L7(应用)

客户端负载均衡选项

重客户端

胖客户端方法意味着在客户端中实现负载平衡智能。 客户端负责跟踪可用服务器,其工作负载以及用于选择服务器的算法。 客户端通常集成与其他基础结构通信的库,例如服务发现,名称解析,配额管理等。

grpc客户端负载均衡

gRPC开源组件官方并未直接提供服务注册与发现的功能实现,但其设计文档已提供实现的思路,并在不同语言的gRPC代码API中已提供了命名解析和负载均衡接口供扩展。

其基本实现原理:

服务启动后gRPC客户端向命名服务器发出名称解析请求,名称将解析为一个或多个IP地址,每个IP地址标示它是服务器地址还是负载均衡器地址,以及标示要使用那个客户端负载均衡策略或服务配置。

客户端实例化负载均衡策略,如果解析返回的地址是负载均衡器地址,则客户端将使用grpclb策略,否则客户端使用服务配置请求的负载均衡策略。

负载均衡策略为每个服务器地址创建一个子通道(channel)。

当有rpc请求时,负载均衡策略决定那个子通道即grpc服务器将接收请求,当可用服务器为空时客户端的请求将被阻塞。

根据gRPC官方提供的设计思路,基于进程内LB方案(即第2个案,阿里开源的服务框架 Dubbo 也是采用类似机制),结合分布式一致的组件(如Zookeeper、Consul、Etcd),可找到gRPC服务发现和负载均衡的可行解决方案。简单介绍下基于Etcd3的关键代码实现:

代码实现

命名解析实现:resolver.go

packagebalancerimport("context""log""strings""time""github.com/coreos/etcd/clientv3""github.com/coreos/etcd/mvcc/mvccpb""google.golang.org/grpc/resolver")constschema="wonamingv3"varcli*clientv3.ClienttypeetcdResolverstruct{rawAddrstringccresolver.ClientConn}// NewResolver initialize an etcd clientfuncNewResolver(etcdAddrstring)resolver.Builder{return&etcdResolver{rawAddr:etcdAddr}}func(r*etcdResolver)Build(targetresolver.Target,ccresolver.ClientConn,optsresolver.BuildOption)(resolver.Resolver,error){varerrerrorifcli==nil{cli,err=clientv3.New(clientv3.Config{Endpoints:strings.Split(r.rawAddr,";"),DialTimeout:15*time.Second,})iferr!=nil{returnnil,err}}r.cc=ccgor.watch("/"+target.Scheme+"/"+target.Endpoint+"/")returnr,nil}func(retcdResolver)Scheme()string{returnschema}func(retcdResolver)ResolveNow(rnresolver.ResolveNowOption){log.Println("ResolveNow")// TODO check}// Close closes the resolver.func(retcdResolver)Close(){log.Println("Close")}func(r*etcdResolver)watch(keyPrefixstring){varaddrList[]resolver.AddressgetResp,err:=cli.Get(context.Background(),keyPrefix,clientv3.WithPrefix())iferr!=nil{log.Println(err)}else{fori:=rangegetResp.Kvs{addrList=append(addrList,resolver.Address{Addr:strings.TrimPrefix(string(getResp.Kvs[i].Key),keyPrefix)})}}r.cc.NewAddress(addrList)rch:=cli.Watch(context.Background(),keyPrefix,clientv3.WithPrefix())forn:=rangerch{for_,ev:=rangen.Events{addr:=strings.TrimPrefix(string(ev.Kv.Key),keyPrefix)switchev.Type{casemvccpb.PUT:if!exist(addrList,addr){addrList=append(addrList,resolver.Address{Addr:addr})r.cc.NewAddress(addrList)}casemvccpb.DELETE:ifs,ok:=remove(addrList,addr);ok{addrList=sr.cc.NewAddress(addrList)}}//log.Printf("%s %q : %q\n", ev.Type, ev.Kv.Key, ev.Kv.Value)}}}funcexist(l[]resolver.Address,addrstring)bool{fori:=rangel{ifl[i].Addr==addr{returntrue}}returnfalse}funcremove(s[]resolver.Address,addrstring)([]resolver.Address,bool){fori:=ranges{ifs[i].Addr==addr{s[i]=s[len(s)-1]returns[:len(s)-1],true}}returnnil,false}

服务注册实现:naming.go

packagebalancerimport("context""log""strings""time""github.com/coreos/etcd/clientv3""fmt")// Register register service with name as prefix to etcd, multi etcd addr should use ; to splitfuncRegister(etcdAddr,namestring,addrstring,ttlint64)error{varerrerrorifcli==nil{cli,err=clientv3.New(clientv3.Config{Endpoints:strings.Split(etcdAddr,";"),DialTimeout:15*time.Second,})iferr!=nil{returnerr}}ticker:=time.NewTicker(time.Second*time.Duration(ttl))gofunc(){for{getResp,err:=cli.Get(context.Background(),"/"+schema+"/"+name+"/"+addr)iferr!=nil{log.Println(err)}elseifgetResp.Count==0{err=withAlive(name,addr,ttl)iferr!=nil{log.Println(err)}}else{// do nothing}

服务端:main.go

packagemainimport("flag""log""net""os""os/signal""syscall""grpc/balancer""../pb""golang.org/x/net/context""google.golang.org/grpc")constsvcName="project/test"varaddr="127.0.0.1:50051"funcmain(){flag.StringVar(&addr,"addr",addr,"addr to lis")flag.Parse()lis,err:=net.Listen("tcp",addr)iferr!=nil{log.Fatalf("failed to listen: %s",err)}deferlis.Close()s:=grpc.NewServer()defers.GracefulStop()pb.RegisterHelloServiceServer(s,&hello{})gobalancer.Register("127.0.0.1:2379",svcName,addr,5)ch:=make(chanos.Signal,1)signal.Notify(ch,syscall.SIGTERM,syscall.SIGINT,syscall.SIGKILL,syscall.SIGHUP,syscall.SIGQUIT)gofunc(){s:=

客户端:main.go

packagemainimport("fmt""time""grpc/balancer""grpc/balancer/pb""golang.org/x/net/context""google.golang.org/grpc""google.golang.org/grpc/resolver")funcmain(){r:=balancer.NewResolver("localhost:2378")resolver.Register(r)conn,err:=grpc.Dial(r.Scheme()+"://author/project/test",grpc.WithBalancerName("round_robin"),grpc.WithInsecure())iferr!=nil{panic(err)}client:=pb.NewHelloServiceClient(conn)for{resp,err:=client.Echo(context.Background(),&pb.Payload{Data:"hello"},grpc.FailFast(true))iferr!=nil{fmt.Println(err)}else{fmt.Println(resp)}

源码解析

使用的grpc-go的版本为1.14.0

etcd版本为3.2.0

首先实现了一个命名解析器:etcdResolver。实现了Builder和Resolver接口

// Builder creates a resolver that will be used to watch name resolution updates.typeBuilderinterface{// Build creates a new resolver for the given target. gRPC dial calls Build synchronously, and fails if the returned error is// not nil.Build(targetTarget,ccClientConn,optsBuildOption)(Resolver,error)// Scheme returns the scheme supported by this resolver.// Scheme is defined at https://github.com/grpc/grpc/blob/master/doc/naming.md.Scheme()string}

// Resolver watches for the updates on the specified target.// Updates include address updates and service config updates.typeResolverinterface{// ResolveNow will be called by gRPC to try to resolve the target name// again. It's just a hint, resolver can ignore this if it's not necessary. It could be called multiple times concurrently.ResolveNow(ResolveNowOption)// Close closes the resolver.Close()}

Builder接口在发起rpc请求的时候会调用Build方法。etcdResolver的Build方法首先创建一条到etcd服务端的连接。然后启动一个goroutine watch相应的key上是否有变更,如果有,根据不同的event进行不同的处理

func(r*etcdResolver)watch(keyPrefixstring){varaddrList[]resolver.AddressgetResp,err:=cli.Get(context.Background(),keyPrefix,clientv3.WithPrefix())iferr!=nil{log.Println(err)}else{fori:=rangegetResp.Kvs{addrList=append(addrList,resolver.Address{Addr:strings.TrimPrefix(string(getResp.Kvs[i].Key),keyPrefix)})}}// 更新地址列表r.cc.NewAddress(addrList)rch:=cli.Watch(context.Background(),keyPrefix,clientv3.WithPrefix())forn:=rangerch{for_,ev:=rangen.Events{addr:=strings.TrimPrefix(string(ev.Kv.Key),keyPrefix)switchev.Type{casemvccpb.PUT:if!exist(addrList,addr){addrList=append(addrList,resolver.Address{Addr:addr})// 更新地址列表r.cc.NewAddress(addrList)}casemvccpb.DELETE:ifs,ok:=remove(addrList,addr);ok{addrList=s// 更新地址列表r.cc.NewAddress(addrList)}}//log.Printf("%s %q : %q\n", ev.Type, ev.Kv.Key, ev.Kv.Value)}}}

etcdResolver watch的key同时也是注册方操作的key。当有服务器启动时,调用etcdv3 api的put函数,更新相关信息。当服务器关机时,调用delete函数

funcwithAlive(namestring,addrstring,ttlint64)error{leaseResp,err:=cli.Grant(context.Background(),ttl)iferr!=nil{returnerr}fmt.Printf("key:%v\n","/"+schema+"/"+name+"/"+addr)_,err=cli.Put(context.Background(),"/"+schema+"/"+name+"/"+addr,addr,clientv3.WithLease(leaseResp.ID))iferr!=nil{returnerr}_,err=cli.KeepAlive(context.Background(),leaseResp.ID)iferr!=nil{returnerr}returnnil}// UnRegister remove service from etcdfuncUnRegister(namestring,addrstring){ifcli!=nil{cli.Delete(context.Background(),"/"+schema+"/"+name+"/"+addr)}}

服务端的原理很简单,启动的时候调用注册的方法,停止的时候注销

gobalancer.Register("127.0.0.1:2379",svcName,addr,5)ch:=make(chanos.Signal,1)signal.Notify(ch,syscall.SIGTERM,syscall.SIGINT,syscall.SIGKILL,syscall.SIGHUP,syscall.SIGQUIT)gofunc(){s:=

整个的服务发现和负载均衡都交给了客户端来做,这里的底层代码很复杂,尤其是涉及到grpc部分。如果是带着疑问去看的话,可能效果更好些。

问题:

当有服务器上线时,请求能顺利的转发到新上线的服务器吗?

当有服务器关机时,请求还会向这台服务器转发吗?

grpc底层是怎么维护多个连接的?

调用方如果只dial一次,维护一个client。如果服务器产生了变化,请求能否自动转发?

客户端代码:

funcmain(){r:=balancer.NewResolver("localhost:2379")resolver.Register(r)conn,err:=grpc.Dial(r.Scheme()+"://author/project/test",grpc.WithBalancerName("round_robin"),grpc.WithInsecure())iferr!=nil{panic(err)}client:=pb.NewHelloServiceClient(conn)for{resp,err:=client.Echo(context.Background(),&pb.Payload{Data:"hello"},grpc.FailFast(true))iferr!=nil{fmt.Println(err)}else{fmt.Println(resp)}

客户端使用了自实现的命名解析器etcdResolver。使用的负载均衡器为grpc自带的round robin。客户端代码很简洁,grpc的封装带来了很大的便捷性,也方便推广。但是想真正了解原理,还需要深入到grpc源码。首先看看Dial函数:

// Dial creates a client connection to the given target.funcDial(targetstring,opts...DialOption)(*ClientConn,error){returnDialContext(context.Background(),target,opts...)}funcDialContext(ctxcontext.Context,targetstring,opts...DialOption)(conn*ClientConn,errerror){// 初始化客户端连接cc:=&ClientConn{target:target,csMgr:&connectivityStateManager{},conns:make(map[*addrConn]struct{}),dopts:defaultDialOptions(),blockingpicker:newPickerWrapper(),}...// 初始化opts...// 设置resolver// 客户端调用了resolver.Register(r)函数,注册了实例ifcc.dopts.resolverBuilder==nil{// Only try to parse target when resolver builder is not already set.cc.parsedTarget=parseTarget(cc.target)grpclog.Infof("parsed scheme: %q",cc.parsedTarget.Scheme)// 此处调用get方法获取resolvercc.dopts.resolverBuilder=resolver.Get(cc.parsedTarget.Scheme)ifcc.dopts.resolverBuilder==nil{// If resolver builder is still nil, the parse target's scheme is// not registered. Fallback to default resolver and set Endpoint to// the original unparsed target.grpclog.Infof("scheme %q not registered, fallback to default scheme",cc.parsedTarget.Scheme)cc.parsedTarget=resolver.Target{Scheme:resolver.GetDefaultScheme(),Endpoint:target,}cc.dopts.resolverBuilder=resolver.Get(cc.parsedTarget.Scheme)}}else{cc.parsedTarget=resolver.Target{Endpoint:target}}// 构建resolvercc.resolverWrapper,err=newCCResolverWrapper(cc)// 启动resolvercc.resolverWrapper.start()}

// newCCResolverWrapper parses cc.target for scheme and gets the resolver// builder for this scheme and builds the resolver. The monitoring goroutine// for it is not started yet and can be created by calling start(). If withResolverBuilder dial option is set, the specified resolver will be// used instead.funcnewCCResolverWrapper(cc*ClientConn)(*ccResolverWrapper,error){// 获取resolver实例,此处为客户端实现的etcdResolverrb:=cc.dopts.resolverBuilderifrb==nil{returnnil,fmt.Errorf("could not get resolver for scheme: %q",cc.parsedTarget.Scheme)}ccr:=&ccResolverWrapper{cc:cc,addrCh:make(chan[]resolver.Address,1),scCh:make(chanstring,1),done:make(chanstruct{}),}varerrerror// 此处为Build接口的调用处,构建成功后返回解析器ccr.resolver,err=rb.Build(cc.parsedTarget,ccr,resolver.BuildOption{DisableServiceConfig:cc.dopts.disableServiceConfig})iferr!=nil{returnnil,err}returnccr,nil}

// 启动一个watcher goroutine

func(ccr*ccResolverWrapper)start(){fmt.Printf("go wrapper watcher\n")goccr.watcher()}

// watcher processes address updates and service config updates sequentially.// Otherwise, we need to resolve possible races between address and service// config (e.g. they specify different balancer types).func(ccr*ccResolverWrapper)watcher(){for{select{case

ccResolverWrapper包裹了clientConn,实现了resolver.ClientConnection。包括NewAddress

// ccResolverWrapper is a wrapper on top of cc for resolvers.// It implements resolver.ClientConnection interface.typeccResolverWrapperstruct{cc*ClientConnresolverresolver.ResolveraddrChchan[]resolver.AddressscChchanstringdonechanstruct{}}

// NewAddress is called by the resolver implemenetion to send addresses to gRPC.func(ccr*ccResolverWrapper)NewAddress(addrs[]resolver.Address){select{case

NewAddress方法将地址发送给addrCh channel。当有服务器状态更新的时候,此方法在etcdResolver中会调用。

resolverWrapper的watcher方法读取addrCh,当有数据的时候调用handleResolvedAddrs方法

func(cc*ClientConn)handleResolvedAddrs(addrs[]resolver.Address,errerror){cc.mu.Lock()defercc.mu.Unlock()ifcc.conns==nil{// cc was closed.return}// 比较当前存储的地址是否和resolver中获得的地址一样,// 如果是一样的,直接返回。说明目前没有更新,不需要重新build balancerifreflect.DeepEqual(cc.curAddresses,addrs){return}cc.curAddresses=addrsifcc.dopts.balancerBuilder==nil{// Only look at balancer types and switch balancer if balancer dial// option is not set.varisGRPCLBboolfor_,a:=rangeaddrs{ifa.Type==resolver.GRPCLB{isGRPCLB=truebreak}}varnewBalancerNamestringifisGRPCLB{newBalancerName=grpclbName}else{// Address list doesn't contain grpclb address. Try to pick a// non-grpclb balancer.newBalancerName=cc.curBalancerName// If current balancer is grpclb, switch to the previous one.ifnewBalancerName==grpclbName{newBalancerName=cc.preBalancerName}// The following could be true in two cases:// - the first time handling resolved addresses// (curBalancerName="")// - the first time handling non-grpclb addresses// (curBalancerName="grpclb", preBalancerName="")ifnewBalancerName==""{newBalancerName=PickFirstBalancerName}}cc.switchBalancer(newBalancerName)}elseifcc.balancerWrapper==nil{// Balancer dial option was set, and this is the first time handling// resolved addresses. Build a balancer with dopts.balancerBuilder.// 生成实例化的balancercc.balancerWrapper=newCCBalancerWrapper(cc,cc.dopts.balancerBuilder,cc.balancerBuildOpts)}cc.balancerWrapper.handleResolvedAddrs(addrs,nil)}

newCCBalancerWrapper根据传入的参数实例化balancer。

funcnewCCBalancerWrapper(cc*ClientConn,bbalancer.Builder,boptsbalancer.BuildOptions)*ccBalancerWrapper{ccb:=&ccBalancerWrapper{cc:cc,stateChangeQueue:newSCStateUpdateBuffer(),resolverUpdateCh:make(chan*resolverUpdate,1),done:make(chanstruct{}),subConns:make(map[*acBalancerWrapper]struct{}),}goccb.watcher()// 调用build接口实例化balancerccb.balancer=b.Build(ccb,bopts)returnccb}

这里使用的balancer是grpc自带的round-robin负载均衡组件,实现了balancer.Builder接口

// Name is the name of round_robin balancer.constName="round_robin"// newBuilder creates a new roundrobin balancer builder.funcnewBuilder()balancer.Builder{returnbase.NewBalancerBuilder(Name,&rrPickerBuilder{})}funcinit(){balancer.Register(newBuilder())}func(bb*baseBuilder)Build(ccbalancer.ClientConn,optbalancer.BuildOptions)balancer.Balancer{return&baseBalancer{cc:cc,pickerBuilder:bb.pickerBuilder,subConns:make(map[resolver.Address]balancer.SubConn),scStates:make(map[balancer.SubConn]connectivity.State),csEvltr:&connectivityStateEvaluator{},// Initialize picker to a picker that always return// ErrNoSubConnAvailable, because when state of a SubConn changes, we// may call UpdateBalancerState with this picker.picker:NewErrPicker(balancer.ErrNoSubConnAvailable),}}

其实到这里可以发现resolver和balancer的实现和调用模式都是非常像的。客户端负责实现接口和register,调用者负责实例化。

grpc在使用的时候通过Get函数获取当前register的实例,然后调用相应的接口实例化

同样的,balancerWrapper也有一个watch方法。resolver解析到的地址会塞入到resolverUpdateCh channel。

caset:=

当从channel中读取到数据时,调用HandleResolveAddrs方法

typebaseBalancerstruct{ccbalancer.ClientConnpickerBuilderPickerBuildercsEvltr*connectivityStateEvaluatorstateconnectivity.StatesubConnsmap[resolver.Address]balancer.SubConn// 子连接池scStatesmap[balancer.SubConn]connectivity.Statepickerbalancer.Picker//选择器}func(b*baseBalancer)HandleResolvedAddrs(addrs[]resolver.Address,errerror){iferr!=nil{grpclog.Infof("base.baseBalancer: HandleResolvedAddrs called with error %v",err)return}grpclog.Infoln("base.baseBalancer: got new resolved addresses: ",addrs)// addrsSet is the set converted from addrs, it's used for quick lookup of an address.addrsSet:=make(map[resolver.Address]struct{})for_,a:=rangeaddrs{addrsSet[a]=struct{}{}if_,ok:=b.subConns[a];!ok{// a is a new address (not existing in b.subConns).sc,err:=b.cc.NewSubConn([]resolver.Address{a},balancer.NewSubConnOptions{})iferr!=nil{grpclog.Warningf("base.baseBalancer: failed to create new SubConn: %v",err)continue}// 放入子链接池b.subConns[a]=scb.scStates[sc]=connectivity.Idlesc.Connect()}}}

bashBalancer结构中有一个选择器balancer.Picker接口。round_robin实现了rrPicker的Pick接口

typerrPickerstruct{// subConns is the snapshot of the roundrobin balancer when this picker was// created. The slice is immutable. Each Get() will do a round robin// selection from it and return the selected SubConn.subConns[]balancer.SubConnmusync.Mutexnextint}// 轮询算法func(p*rrPicker)Pick(ctxcontext.Context,optsbalancer.PickOptions)(balancer.SubConn,func(balancer.DoneInfo),error){iflen(p.subConns)<=0{returnnil,nil,balancer.ErrNoSubConnAvailable}p.mu.Lock()sc:=p.subConns[p.next]p.next=(p.next+1)%len(p.subConns)p.mu.Unlock()returnsc,nil,nil}

rrPicker实现了Pick接口。算法是很简单的轮询操作。

还有最后一个问题:Pick方法是什么时候调用的呢? 方法嵌套的层次比较多。简单来说是:

invoke –> newClientStream –> newAttemptLocked –> getTransport –> pick

每一次invoke调用会进行一次pick选择。所以客户端只维护一个client也是可以的

总结

初步分析了grpc resolver和balancer部分的源码,感触比较大。学到了不少编程思想:

面向接口编程

代理模式(wrapper)

工厂模式

还有很多重要的细节没有研读。有时间还是要继续

Go微服务开发是利用Go语言进行微服务架构的开发方式。在这个问题中,使用了gin、grpcetcd进行重构grpc-todolist项目。 Gin是一个轻量级的Web框架,使用它可以快速构建高性能的Web应用程序。它具有简单易用、性能出色和灵活的特点。在微服务开发中,Gin可以作为HTTP服务器框架,处理和响应客户端的HTTP请求。 gRPC是一种高性能、开源的远程过程调用(RPC)框架。它支持多种编程语言,并使用带有协议缓冲区的Google Protocol Buffers进行数据交换。在微服务架构中,gRPC可以用于服务之间的通信,通过定义接口和消息格式,实现服务间的数据传输和调用。 Etcd是一个高可靠、分布式的键值存储系统。它使用Raft一致性算法来保证数据的可靠性和一致性。在微服务开发中,Etcd可以作为服务发现和配置管理的工具,用于注册和发现各个微服务的信息。 对于重构grpc-todolist项目来说,使用gin可以将原有的HTTP接口改写为更加高性能的接口,提高整个系统的性能。通过使用gRPC,可以将原有的接口定义为gRPC接口,实现服务间的高效通信,并且易于扩展和维护。同时,借助Etcd实现服务注册和发现,提高系统的可用性和灵活性。 总而言之,通过使用gin、grpcetcdgrpc-todolist项目进行重构,可以提高系统性能、扩展性和可维护性。这种微服务开发方式能够更好地适应大规模分布式系统的需求,使得系统更加稳定和可靠。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值