cover_image

一文学懂微服务神器-GoKit

一点技术团队 赛博大象技术团队
2022年03月16日 00:30
一、微服务简介
谈GoKit不得不提到微服务理念,GoKit被设计出来就是为了解决微服务遇到的一些问题。
互联网公司在业务初期,一般业务功能都较简单,此时系统是清晰的。随着系统不断的迭代,功能越来越复杂,系统越来越耦合,给开发、维护和部署带来了困难。尤其是在发布时,本来只是升级了部分功能模块,但是不得不将整个应用重新部署。
2014年,ThoughtWorks的Martin FowlerJames Lewis提出了一种新的架构风格——微服务架构,随后各种微服务架构和设计准则如雨后春笋般不断涌现。
完整的微服务定义来自Martin Fowler的文章《Microservices》:
In short, the microservice architectural style is an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API. These services are built around business capabilities and independently deployable by fully automated deployment machinery. There is a bare minimum of centralized management of these services, which may be written in different programming languages and use different data storage technologies.
总结就是以下几点:
  • 在自己的进程中运行并用轻量的通信机制

  • 已业务为中心进行构建

  • 能够独立的自动化部署

  • 使用最少的集中化管理


二、GoKit简介

GoKit其实是一个Go语言组件库,通过它可以构建稳定、可靠、易维护的微服务。GoKit的原作者为Peter Bourgon,其灵感主要还是来自Twitter公司基于Scala开发的RPC框架Finagle。Peter Bourgon认为Scala语言还是太复杂了,通常也是难以理解的,对比之下Go语言就比较易于理解,且拥有接近C语言的运行效率,但是当时Go语言还没有一个成熟的、详尽的分布式服务工具包。在这个背景下,GoKit诞生了。


图片


Gokit的特点就是其标志的洋葱模型,如上图所示。可以看到模型分成了很多层,而这些其实可以归为三层:最里边这层可以称为Service层,这层是服务的基础,实现了所有的业务逻辑;中间的这层称之为Endpoint层,可以理解为安全层,有点类似controller层里的handler,在这层中每个方法都被抽象成endpoint.Endpoint方法;最外层的是Transport层,主要负责各种协议的解析和封装,例如HTTP、gRPC。

网络请求进入服务时先经过Transport层,一直流入到Service层,然后进行响应时路径相反。


三、为什么如此设计

很多开发者在学习GoKit的过程中,可能理解起来会比较困难,尤其是其中的抽象的Endpoint层。

为了方便理解,我们可以按照微服务的理论,尝试设计一套微服务的架构,通过这个过程由浅入深的去理解GoKit的设计理念。


3.1 简易服务搭建

例如,实现一个计算加法的计算服务,那么从业务出发,这个服务应该被定义成这样:即接受两个int型的变量,然后相加后返回。

type CalculateService interface {  sum(intint) (int, error)}

接着编写具体的结构体,去实现这个接口。

type Calculator struct {  A int  B int}
func (c *Calculator) sum(a, b int) (int, error) { return a + b, nil}

定义Calculator的ServeHTTP方法,让这个结构体实现net/http包的Handler接口。这样http.ListenAndServe函数就可以接收相应的对象,并且能够启动这个服务了。

func (c *Calculator) ServeHTTP(w http.ResponseWriter, r *http.Request) {  r.ParseForm()  method := r.Form.Get("method")  var calculator Calculator  switch method {    case "sum":      reqA := r.FormValue("a")      reqB := r.FormValue("b")      if reqA == "" || reqB == "" {        return      }      paramA, err := strconv.Atoi(reqA)      if err != nil {        return      }      paramB, err := strconv.Atoi(reqB)      if err != nil {        return      }      res, err := calculator.sub(paramA, paramB)      if err != nil {        return      }      json.NewEncoder(w).Encode(res)  }}
func main() { mux := http.NewServeMux() ps := new(Calculator) mux.Handle("/calculate", ps) http.ListenAndServe(":8081", mux)}


3.2 完善服务

现在要完善这个服务,首先加入日志功能,那么ServeHTTP方法现在变成这样。

func (c *Calculator) ServeHTTP(w http.ResponseWriter, r *http.Request) {  r.ParseForm()  method := r.Form.Get("method")  var calculator Calculator  switch method {    case "sum":      reqA := r.FormValue("a")      reqB := r.FormValue("b")      if reqA == "" || reqB == "" {        log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusBadRequest)               return      }      paramA, err := strconv.Atoi(reqA)      if err != nil {        log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusBadRequest)        return      }      paramB, err := strconv.Atoi(reqB)      if err != nil {        log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusBadRequest)        return      }      res, err := calculator.sub(paramA, paramB)      if err != nil {        log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusInternalServerError)        return      }      log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusOK)      json.NewEncoder(w).Encode(res)  }}

如果要加入其他的管理功能呢,比如监控,下面以Prometheus举例。可以看到代码已经变得十分臃肿。

func (c *Calculator) ServeHTTP(w http.ResponseWriter, r *http.Request) {  r.ParseForm()  method := r.Form.Get("method")  var calculator Calculator  var begin = time.Now()  switch method {    case "sum":      reqA := r.FormValue("a")      reqB := r.FormValue("b")      if reqA == "" || reqB == "" {        log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusBadRequest)        dur.WithLabelValues(r.Method, fmt.Sprint(http.StatusBadRequest)).Observe(time.Since(begin).Seconds())        return      }      paramA, err := strconv.Atoi(reqA)      if err != nil {        log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusBadRequest)        dur.WithLabelValues(r.Method, fmt.Sprint(http.StatusBadRequest)).Observe(time.Since(begin).Seconds())        return      }      paramB, err := strconv.Atoi(reqB)      if err != nil {        log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusBadRequest)        dur.WithLabelValues(r.Method, fmt.Sprint(http.StatusBadRequest)).Observe(time.Since(begin).Seconds())        return      }      res, err := calculator.sub(paramA, paramB)      if err != nil {        log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusInternalServerError)        dur.WithLabelValues(r.Method, fmt.Sprint(http.StatusInternalServerError)).Observe(time.Since(begin).Seconds())        return      }      log.Printf("%s: %s: %s: %d", r.RemoteAddr, r.Method, r.URL, http.StatusOK)      dur.WithLabelValues(r.Method, fmt.Sprint(http.StatusOK)).Observe(time.Since(begin).Seconds())      json.NewEncoder(w).Encode(res)  }}

3.3 增加中间层

假如继续加入其他功能,业务逻辑那层依然是简洁明了的,但是协议解析的这层代码将会越来越臃肿。

设想,能不能引入一种功能单一、可以互相组合使用的中间件,例如下面这样:

func logging() {  // log...}
func rateLimiting() { // limit rate...}
func metrics() { // metrics...}

可以自然的想到使用装饰者模式,装饰者模式的好处是可以动态的扩展功能,需要哪些功能装饰一下就可以了。

为了实现装饰者模式,可以先抽象出一个函数类型,不管对这个类型装饰多少层,最后还是最初的类型。这个函数应该有输入和输出,还有一个报错信息,所以大概是下面所示:

type Endpoint func(request interface{}) (response interface{}, err error)

除此之外,在一些场景下,例如rpc,需要在洋葱模型之间传递错误信息。这里引入context来实现并发场景下对goroutine的控制。

type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)

然后这里重新定义一个函数,用于返回一个Endpoint类型,这样就可以对返回的Endpoint类型进行各种中间件的包装了。

func makeSumEndpoint() Endpoint {  return func(ctx context.Context, request interface{}) (interface {}, error) {    calculator := request.(Calculator)    return calculator.sum(calculator.A, calculator.B)    }}
现在就可以对Endpoint进行装饰了,拿日志功能举例,这里定义一个日志中间件,来给Endpoint加上日志功能:
func loggingMiddleware(e Endpoint) Endpoint {  return func(ctx context.Context, request interface{}) (response interface{}, err error) {    // logging    begin := time.Now()    defer func() {      log.Printf("time cost: %d", time.Since(begin))    }()    return e(ctx, request)  }}
如果想要对中间件定制化怎么办?很容易想到可以对其再装饰一层。这里还是拿日志中间件举例,这里增加了一个method参数,传入不同的参数就可以设置不同的日志行头。
func makeLoggingMiddleware(method string) Middleware {  return func(e Endpoint) Endpoint {    return func(ctx context.Context, request interface{}) (response interface{}, err error) {      // logging      begin := time.Now()      defer func() {        log.Printf("%s :time cost: %d", method, time.Since(begin))      }()      return e(ctx, request)    }  }}
最终将其适配到serveHTTP方法中。原来为Calculator编写的serveHTTP已不再适用,因为目的是调用endpoint,不再和业务逻辑之间有联系。所以这里定义一个新的结构体Handler,重新编写对应的ServeHTTP方法:
type Handler struct {  endpoint Endpoint}
func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.ParseForm() method := r.Form.Get("method") switch method { case "sum": reqA := r.FormValue("a") reqB := r.FormValue("b") if reqA == "" || reqB == "" { return } paramA, err := strconv.Atoi(reqA) if err != nil { return } paramB, err := strconv.Atoi(reqB) if err != nil { return } var calculator = Calculator{ A: paramA, B: paramB, } res, err := handler.endpoint(context.Background(), calculator) if err != nil { return } json.NewEncoder(w).Encode(res) }}

可以看到协议层还在直接引用业务层的Calculator结构体,这里可以沿用之前的处理方法,Handler结构体增加两个函数类型:decodeRequest和encodeResponse,分别负责请求内容解析和响应内容封装。当调用Serve HTTP方法时,首先调用decodeRequest解析请求内容,然后调用endpoint方法,最后调用encodeResponse将响应内容进行封装。这其实就是GoKit的解决办法。

type Handler struct {   endpoint Endpoint   dec func(r *http.Request) (interface{}, error)   enc func(v interface{}, w http.ResponseWriter) error}
func decodeRequest(r *http.Request) (interface{}, error) { r.ParseForm() reqA := r.FormValue("a") reqB := r.FormValue("b") if reqA == "" || reqB == "" { return nil, errors.New("param err") } paramA, err := strconv.Atoi(reqA) if err != nil { return nil, err } paramB, err := strconv.Atoi(reqB) if err != nil { return nil, err } return Calculator{ A: paramA, B: paramB, },nil}

func encodeResponse(v interface{}, w http.ResponseWriter) error { return json.NewEncoder(w).Encode(v)}

func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.ParseForm() method := r.Form.Get("method") switch method { case "sum": calculator,err := handler.dec(r) if err != nil { return } res, err := handler.endpoint(context.Background(), calculator) if err != nil { return } err = handler.enc(res, w) }}

经过这样处理后,三层结构已经非常清晰了,这也是恰好对应着GoKit中的Transport、Endpoint和Service三层架构。

图片

GoKit提供给了开发者现成的组件,开发者只需要编写对应的service、endpoint和encode/decode函数就可以了。下面是GoKit提供的限流器、熔断器、链路追踪等中间件。开发者还可以自己编写中间件。

func NewErroringLimiter(limit Allower) endpoint.Middleware {  return func(next endpoint.Endpoint) endpoint.Endpoint {    return func(ctx context.Context, request interface{}) (interface{}, error) {      if !limit.Allow() {        return nil, ErrLimited      }      return next(ctx, request)    }  }}
func Gobreaker(cb *gobreaker.CircuitBreaker) endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, request interface{}) (interface{}, error) { return cb.Execute(func() (interface{}, error) { return next(ctx, request) }) } }}
func TraceServer(tracer opentracing.Tracer, operationName string) endpoint.Middleware { return func(next endpoint.Endpoint) endpoint.Endpoint { return func(ctx context.Context, request interface{}) (interface{}, error) { serverSpan := opentracing.SpanFromContext(ctx) if serverSpan == nil { // All we can do is create a new root span. serverSpan = tracer.StartSpan(operationName) } else { serverSpan.SetOperationName(operationName) } defer serverSpan.Finish() otext.SpanKindRPCServer.Set(serverSpan) ctx = opentracing.ContextWithSpan(ctx, serverSpan) return next(ctx, request) } }}

除了服务端,GoKit将客户端也抽象出了同样的Endpoint层,例如go-kit/kit/transport/http中NewClient函数,返回的Client结构体会含有一个Endpoint方法,这个方法就是获取对应的endpoint,内部逻辑实际就是发出具体的http请求。这样的好处就是一个同一个中间件可以同时对服务端和客户端进行装饰。


四、总结

GoKit套件这种架构达到了超低的耦合度。

Service层就只负责业务逻辑,哪怕哪天不想用GoKit这套东西了,可以直接把Serivce层的方法直接嵌到其他框架中;Transport层就只负责协议的解析与封装,GoKit目前支持多种通信方式,包括HTTP、gRPC、Thrift、RabbitMQ等等;而抽象出来的Enpoint中间层,可以利用GoKit提供的各种Middleware进行封装,实现服务发现、接口限流、负载均衡以及链路追踪等功能。

最后再细看GoKit在设计过程中体现的哲学思想:

  • S.O.L.I.D design面向对象编程和面向对象设计五大原则

  • DDD领域驱动设计

  • The Clean Architecture整洁架构理论

截止到2022年1月1日,GoKit 在github中的stars已经达到22.1k,也是从侧面反映了GoKit不失为一个优秀的微服务组件库。

微服务解决了大型单体应用带来的问题,但是却引入了一些新的问题:例如链路较长导致的测试困难、分布式事务等等。假如GoKit仅仅也是用来开发微服务应用的话,那这些问题也是不可避免的。好消息是尽管GoKit被设计成一个打造微服务的组件库,开发者依然可以参考GoKit的这种三层架构的思想,来建造一个稳定、易维护的系统。



文章来自一点资讯信息流业务部
Go · 目录
下一篇Golang在商业化广告的优化实践
继续滑动看下一个
赛博大象技术团队
向上滑动看下一个