写点什么

我做了一个 Go 语言的微服务工具包

  • 2021 年 2 月 22 日
  • 本文字数:12850 字

    阅读完需:约 42 分钟

我做了一个Go语言的微服务工具包

多年以来,我一直认为自己是一名语言无关的软件开发人员,因为在编程语言方面,我总是把掌握基础知识和学习新概念放在首位,而不是“玩最爱”。在我 15 年的职业生涯中,我已经用多种语言(例如 Java、Scala、Go 等)编写了数千行代码。直到我精通 Go 之后,我才意识到:选择正确的语言很重要。我成为了一名真正的忠实主义者;今天,它无疑是我最喜欢的语言。它的简单、优雅以及强大的并发范式使其非常适用于下一代的分布式服务。


为了表达我对这种语言的热爱,我开发了一个工具包,以帮助希望使用 Go 来增强微服务的其他开发人员。

REST + gRPC: 打造完美的婚姻


微服务通常由 HTTP 或 RPC 框架(如 REST 和 gRPC)支持。


REST 来自于人们熟悉的面向实体(entry) 设计——设计方法是 HTTP 协议的一 构建块。CRUD(Create、Read、Update、Delete)操作定义了实体的一组行为。REST API 使用 HTTP 方法的子集在通常表示 / 序列化为 JSON 的实体上执行 CRUD 操作。


gRPC 是一个高性能的 RPC 框架(备注:RPC API 允许开发人员访问分布式的过程或方法,这些过程或方法在语法上与集中式的过程或方法没有区别,从而隐藏了通过网络进行数据序列化 / 传输的复杂性)。它提供了客户端、服务端和双向流。


在底层,gRPC 使用 HTTP/2(用于传输)和 Protocol Buffers(用于高效的序列化)来实现比 REST+JSON 更高的性能。它为代码自动生成提供了一流的支持。protobuf 编译器生成客户端和服务端的代码,从而促进了应用程序的快速开发,并减少了发布新服务所需的工作量。


通过将 REST+gRPC 相结合,我们可以创建高性能的分布式服务,为客户提供双向访问模式,同时还能保留面向实体设计方法的优点。


下面是上述介绍的一个示例,在这个例子中,我们首先定义了一个 gRPC 服务,使用 protobuf 规范以面向实体的方式操作orders。使用order作为实体,我们需要定义该实体能够支持的服务,即与 CRUD 操作相对应的 RPC 方法。我们将添加一个额外的 RPC 方法List,以支持列出 / 过滤现有的订单。


syntax = "proto3";package orders;import "google/protobuf/timestamp.proto";// 使用 CRUD + List rpc 方法定义 Order 服务 service OrderService {
// 创建订单 rpc Create (CreateOrderRequest) returns (CreateOrderResponse);
// 检索现有的订单 rpc Retrieve (RetrieveOrderRequest) returns (RetrieveOrderResponse);
// 修改现有订单 rpc Update (UpdateOrderRequest) returns (UpdateOrderResponse);
// 删除现有订单 rpc Delete (DeleteOrderRequest) returns (DeleteOrderResponse);
// 现有订单的 List 列表 rpc List (ListOrderRequest) returns (ListOrderResponse);}// 订单详细信息的 message(这是我们的实体)message Order { // 订单可能存在的状态 enum Status { PENDING = 0; PAID = 1; SHIPPED = 2; DELIVERED = 3; CANCELLED = 4; } int64 order_id = 1; repeated Item items = 2; float total = 3; google.protobuf.Timestamp order_date = 5; Status status = 6;}// 支付信息的 messagemessage PaymentMethod { enum Type { NOT_DEFINED = 0; VISA = 1; MASTERCARD = 2; PAYPAL = 3; APPLEPAY = 4; } Type payment_type = 1; string pre_authorization_token = 2; }// 包含在订单中的商品的详细信息的 messagemessage Item { string description = 1; float price = 2;}// 创建订单的请求message CreateOrderRequest { repeated Item items = 1; PaymentMethod payment_method = 2;}// 订单创建的响应message CreateOrderResponse { Order order = 1;}// 检索订单的请求message RetrieveOrderRequest { int64 order_id = 1;}// 检索订单的响应message RetrieveOrderResponse { Order order = 1;}// 更新现有订单的请求message UpdateOrderRequest { int64 order_id = 1; repeated Item items = 2; PaymentMethod payment_method = 3;}// 更新现有订单的响应message UpdateOrderResponse { Order order = 1;}// 删除现有订单的请求message DeleteOrderRequest { int64 order_id = 1; repeated Item items = 2;}// 删除现有订单的响应message DeleteOrderResponse { Order order = 1;}// 获取现有订单列表的请求message ListOrderRequest { repeated int64 ids = 1; Order.Status statuses = 2;}// 获取现有订单列表的响应message ListOrderResponse { repeated Order order = 1;}
复制代码


order.proto 接下来,我们使用带有必要 Go 选项的protoc来编译order.proto



编译 order.proto


运行上面的命令将生成两个文件:order.pb.goorder_grpc.pb.goorder.pb.go包含了针对order.proto中定义的每种 protobuf 的message类型的结构体。



Order 的结构体(生成的代码)


order_grpc.pb.go提供了用于与订单服务交互的客户端 / 服务端代码。这个文件中包括了OrderServiceServer——OrderService的接口转换(为了与“婚姻”进行类比,可以将它看作是司仪)。



OrderServiceServer 接口(生成的代码)


为了启动并运行 gRPC 服务,我们需要实现OrderServiceServer接口。在本练习中,我们可以使用UnimplementedOrderServiceServer(生成的代码中提供的基本的实现)。



UnimplementedOrderServiceServer(生成的代码)


RegisterOrderServiceServer方法接受grpc.Server以及OrderServiceServer接口;此方法基于我们订单服务接口实现封装了一个grpc.Server,并且必须要在调用服务的Serve()方法之前调用它。请参见下面的示例。


import(  "log"  "net"  "google.golang.org/grpc")const (  grpcPort = "50051")func main() {  grpcServer := grpc.NewServer()  orderService := UnimplementedOrderServiceServer{}  RegisterOrderServiceServer(grpcServer, &orderService)  lis, err := net.Listen("tcp", ":" + grpcPort)  if err != nil {    log.Fatalf("failed to listen: %v", err)  }  if err := grpcServer.Serve(lis); err != nil {    log.Fatalf("failed to start gRPC server: %v", err)  }}
复制代码


初始化 gRPC 服务


通过这个步骤,gRPC 订单服务只需要几行代码就可以完成了。最后一步是开发一个 REST 服务。通过将OrderServiceServer接口注入到 REST 服务,我们可以正式实现这种“联姻”。


import (    "net/http"    "github.com/gin-gonic/gin"    "github.com/golang/protobuf/jsonpb"    "google.golang.org/grpc")// RestServer 为订单服务实现了一个 REST 服务type RestServer struct {    server       *http.Server    orderService OrderServiceServer // 与我们注入到 gRPC 服务的订单服务相同}// NewRestServer 是一个创建 RestServer 的便捷函数func NewRestServer(orderService OrderServiceServer, port string) RestServer {    rs := RestServer{        server: &http.Server{            Addr:    ":" + port,            Handler: router,        },        orderService: orderService,    }    // 注册 routes    router.POST("/order", rs.create)    router.GET("/order/:id", rs.retrieve)    router.PUT("/order", rs.update)    router.DELETE("/order", rs.delete)    router.GET("/order", rs.list)    return rs}// Start 启动服务器func (r RestServer) Start() error {    return r.server.ListenAndServe()}// create 是一个处理函数,它根据订单请求创建订单 (JSON 主体)func (r RestServer) create(c *gin.Context) {    var req CreateOrderRequest    // unmarshal 订单请求    err := jsonpb.Unmarshal(c.Request.Body, &req)    if err != nil {        c.String(http.StatusInternalServerError, "error creating order request")    }    // 根据请求,使用订单服务创建订单    resp, err := r.orderService.Create(c.Request.Context(), &req)    if err != nil {        c.String(http.StatusInternalServerError, "error creating order")    }    m := &jsonpb.Marshaler{}    if err := m.Marshal(c.Writer, resp); err != nil {        c.String(http.StatusInternalServerError, "error sending order response")    }}func (r RestServer) retrieve(c *gin.Context) {    c.String(http.StatusNotImplemented, "not implemented yet")}func (r RestServer) update(c *gin.Context) {    c.String(http.StatusNotImplemented, "not implemented yet")}func (r RestServer) delete(c *gin.Context) {    c.String(http.StatusNotImplemented, "not implemented yet")}func (r RestServer) list(c *gin.Context) {    c.String(http.StatusNotImplemented, "not implemented yet")}
复制代码


嵌入订单服务接口的 REST 服务示例


最后,更新main方法,将 REST + gRPC 结合起来。


import(  "log"  "net"  "google.golang.org/grpc")const (  grpcPort = "50051"  restPort = "8080")func main() {  grpcServer := grpc.NewServer()  orderService := UnimplementedOrderServiceServer{}  RegisterOrderServiceServer(grpCServer, &orderService)  lis, err := net.Listen("tcp", ":" + grpcPort)  if err != nil {    log.Fatalf("failed to listen: %v", err)  }  go func() {
// Serve() 是一个阻塞调用,因此需要将这个调用加入到 goroutine 中 grpcServer.Serve(lis) }()
restServer := NewRestServer(orderService, restPort) // Start() 也在阻塞,但这是可以的,因为我们需要一个阻塞调用来防止 main() 突然退 // 出。我们很快就会重构这个逻辑! restServer.Start()
}
复制代码


使用服务接口统一 REST + gRPC 服务


现在,都使用相同的订单服务实现来启动并运行 gRPC 和 REST 服务了。请注意,我们可以对上面的代码片段进行一些优化,因为它涉及到了错误处理、并发、可读性等。稍后我们将解决这些问题。


如上所述,gRPC 框架提供了丰富的 protobuf 工具,可促进应用程序的快速开发,使开发人员能够生成客户端 / 服务端代码,包括可用于将 gRPC 与 REST 或其他 HTTP API 结合使用的服务接口。

并发:Goroutines & Channels


Goroutine是与其他函数并发执行的函数。可以将它们视为不会阻塞当前执行线程的后台进程。在后台,这些轻量级的线程被多路复用到一个或多个(n:1)操作系统线程(OS threads)。这样一来,Go 程序可以处理数百万个goroutine,而 Javafuture可以处理的线程数量将会受到可用 OS 线程数的限制(因为 Java 线程与 OS 线程的比例是 1:1)。这种性能优势的注意事项是,Go 线程共享内存空间,并且必须同步访问该内存空间(这对于 Java 开发人员来说应该很熟悉)。这里channel可以从自由竞争状态和死锁的地狱中拯救我们。


Channel是基本类型的管道(你可以把它们视为邮箱),它允许goroutine在没有互斥锁的情况下安全地来回共享数据。通道读 / 写 阻塞) 当前执行线程,直到发送方或接收方准备就绪为止。


下面是可能会使用goroutine的一些常见任务。


  • 应用程序任务: 运行 Web 服务端、DB 连接池、守护程序、API 轮询、数据处理队列

  • 请求 / 事件任务: 处理传入的 HTTP 请求,执行昂贵的子任务(例如多个网络调用)来完成请求,向 Kafka 发布新消息

  • 即发即弃(Fire & Forget)任务: 日志记录、报警、度量指标


阻塞当前执行线程,直到服务端完成服务请求为止。如果你想了解 Go 的 HTTP 服务端是如何处理请求的,请签出源码(TL;DR,为每个传入的 HTTP 请求生成一个goroutine)。


由于grpcServer.Serve()restServer.Start()都是阻塞调用,因此在main执行线程中只能执行其中的一个调用。另一个必须在后台执行。REST 和 gRPC 服务的start/serve方法也会返回错误,我们需要优雅地处理这些错误。(关于此技巧的快速提示:将每个服务包装在一个暴露错误通道的结构体中。调用goroutine中的 start/serve 方法,将错误写入错误通道。这允许我们使用select来等待多个通道操作的执行完成)。


以下代码演示了如何优化 REST 和 gRPC 服务以进行后台处理和基于通道的错误传播。


import (    "net/http"    "github.com/gin-gonic/gin"    "github.com/golang/protobuf/jsonpb"    "google.golang.org/grpc")// RestServer 为订单服务实现了一个 REST 服务。type RestServer struct {    server       *http.Server    orderService OrderServiceServer // 与我们注入 gRPC 服务端的订单服务相同    errCh        chan error}// NewRestServer 是一个创建 RestServer 的便捷函数func NewRestServer(orderService OrderServiceServer, port string) RestServer {    router := gin.Default()    rs := RestServer{        server: &http.Server{            Addr:    ":" + port,            Handler: router,        },        orderService: orderService,        errCh:        make(chan error),    }    // 注册路由    router.POST("/order", rs.create)    router.GET("/order/:id", rs.retrieve)    router.PUT("/order", rs.update)    router.DELETE("/order", rs.delete)    router.GET("/order", rs.list)    return rs}// Start 在后台启动 REST 服务,将错误推入错误通道func (r RestServer) Start() {    go func() {        r.errCh <- r.server.ListenAndServe()    }()}// Stop 停止服务func (r RestServer) Stop() error {    return r.server.Close()}// Error 返回服务端的错误通道func (r RestServer) Error() chan error {    return r.errCh}
复制代码


重构 RestServer


import (    "net"    "google.golang.org/grpc")// GrpcServer 为订单服务实现 gRPC 服务type GrpcServer struct {    server   *grpc.Server    errCh    chan error    listener net.Listener}//NewGrpcServer 是一个创建 GrpcServer 的便捷函数func NewGrpcServer(service OrderServiceServer, port string) (GrpcServer, error) {    lis, err := net.Listen("tcp", ":"+port)    if err != nil {        return GrpcServer{}, err    }    server := grpc.NewServer()    RegisterOrderServiceServer(server, service)    return GrpcServer{        server:   server,        listener: lis,        errCh:    make(chan error),    }, nil}// Start 在后台启动服务,将任何错误传入错误通道func (g GrpcServer) Start() {    go func() {        g.errCh <- g.server.Serve(g.listener)    }()}// Stop 停止 gRPC 服务func (g GrpcServer) Stop() {    g.server.GracefulStop()}//Error 返回服务的错误通道func (g GrpcServer) Error() chan error {    return g.errCh}
复制代码


GrpcServer


切记将 Go 应用视为实体。开发人员通常可以编写出可靠的服务级代码,然后使用大量条件log.Fatal()语句和其他难以理解的逻辑来填充其main方法。


考虑为应用程序创建一个包含配置、服务端和其他应用程序级依赖的结构体。尽管 Go 提供了创建多个 init 函数的能力,但是应该尽量避免使用initinit函数有一些缺点,其中包括返回值为空。具体来说,Go 运行时(runtime) 将查找具有以下签名的包级函数



这意味着你不能从init函数中返回值。如果你试图初始化一个变量并且发生了错误,你可能会被迫 panic、退出应用程序或写入recover逻辑。初始化函数会使代码更难理解。相反,可以尝试创建自己的自定义构造函数,比如创建一个新应用程序、执行所有必要的应用程序初始化并返回应用程序的函数。如果在应用程序初始化过程中可能发生错误,只需更改函数的返回签名即可返回应用程序的实例和错误。


下面是main的优化版本,它为应用程序创建一个结构体,使用select来监听 REST 和 gRPC 服务的错误,并处理应用程序的启动 / 关闭(包括操作系统的终止信号)。


import (    "log"    "os"    "os/signal"    "syscall")const (    grpcPort = "50051"    restPort = "8080")//app 是一个便捷的封装,用于启动和关闭订单微服务所需的所有东西type app struct {    restServer RestServer    grpcServer GrpcServer    /* Listens for an application termination signal       Ex. (Ctrl X, Docker container shutdown, etc) */    shutdownCh chan os.Signal}// start 在后台启动 REST 和 gRPC 服务func (a app) start() {    a.restServer.Start() // non blocking now    a.grpcServer.Start() // also non blocking :-)}// stop 关闭服务func (a app) shutdown() error {    a.grpcServer.Stop()    return a.restServer.Stop()}// newApp 使用 REST 和 gRPC 服务创建一个新的应用程序// 这个函数执行所有与应用程序相关的初始化func newApp() (app, error) {    orderService := UnimplementedOrderServiceServer{}    gs, err := NewGrpcServer(orderService, grpcPort)        if err != nil {        return app{}, err    }    quit := make(chan os.Signal, 1)    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
return app{ restServer: NewRestServer(orderService, restPort), grpcServer: gs, shutdownCh: quit, }, nil}// 运行启动应用程序,处理任何 REST 或 gRPC 服务的错误以及任何关机的信号func run() error { app, err := newApp() if err != nil { return err } app.start() defer app.shutdown() select { case restErr := <-app.restServer.Error(): return restErr case grpcErr := <-app.grpcServer.Error(): return grpcErr case <-app.shutdownCh: return nil }}func main() { if err := run(); err != nil { log.Fatal(err) }}
复制代码


重构 main


在创建或更新order之前,我们需要获取付款方式的预授权,并且我们应该确认要购买的商品是否有库存。假设这些子任务可能会出错(失败或超时),并且可以独立执行。处理请求级并发有几个选项。我们可以使用标准的 goroutine 和 channel,但也许还有更好的选择。


Waitgroups 允许我们启动一组 goroutine 并等待它们完成。waitGroup也可以工作,但它的职责是管理 waitGroup 计数器。ErrGroups 非常适合执行子任务集合。errGroup由一组执行子任务和处理错误传播的 goroutine 组成。errGroup等待(阻塞)直到所有子任务完成为止。


对传入和传出的服务请求使用 上下文(Context)。上下文允许跨客户端和服务端传播请求范围内的值、截止日期和取消信号。Context有一个Done()通道,当Context被取消时,它可以通知 goroutine,允许它们提前退出并释放系统资源。当使用errgroup.WithContext()时,如果第一次遇到子任务错误或第一次返回wait(),则取消派生上下文。


在下面的示例中,validateOrder创建了一个errGroup,它派生出两个并发子任务,一个任务时preAuthorizePayment,另一个任务是checkInventory用于确认所有商品是否都有库存。在两个子任务中调用的函数都接受Context参数,并且在上下文取消(或请求超时)时能够提前返回。


import (    "context"    "errors"    "time"    "golang.org/x/sync/errgroup")var (    ErrPreAuthorizationTimeout = errors.New("pre-authorization request timeout")    ErrInventoryRequestTimeout = errors.New("check inventory request timeout")    ErrItemOutOfStock = errors.New("sorry one or more items in your order is out of stock"))// preAuthorizePayment 对支付方式进行预授权并返回错误。// 如果预先授权成功,则返回 nilfunc preAuthorizePayment(ctx context.Context, payment *PaymentMethod, orderAmount float32) error {    // 在这里执行昂贵的授权逻辑——在这个例子中我们使用 sleep    // 并返回 nil 来表示成功的授权    timer := time.NewTimer(3 * time.Second)    select {    case <-timer.C:        return nil    case <-ctx.Done():        return ErrPreAuthorizationTimeout    }}// checkInventory 返回一个布尔值和一个错误,表示是否所有商品是否都有库存//(true, nil) 表示所有商品都有库存并且没有遇到错误func checkInventory(ctx context.Context, items []*Item) (bool, error) {    // 在这里执行昂贵的库存检查逻辑 - 在这个例子中我们使用 sleep    timer := time.NewTimer(2 * time.Second)    select {    case <-timer.C:        return true, nil    case <-ctx.Done():        return false, ErrInventoryRequestTimeout    }}// getOrderTotal 计算订单总数func getOrderTotal(items []*Item) float32 {    var total float32    for _, item := range items {        total += item.Price    }    return total}func validateOrder(ctx context.Context, items []*Item, payment *PaymentMethod) error {    g, errCtx := errgroup.WithContext(ctx)    g.Go(func() error {        return preAuthorizePayment(errCtx, payment, getOrderTotal(items))    })    g.Go(func() error {        itemsInStock, err := checkInventory(errCtx, items)        if err != nil {            return err        }        if !itemsInStock {            return ErrItemOutOfStock        }        return nil    })    return g.Wait()}
复制代码


大多数仓库(和履约中心)都有订单管理系统,以实现高效、经济的订单履行。类似地,管理并发对于维持应用程序的质量至关重要。下面的示例使用waitgroupchannel来限制仓库一次可以处理的订单数量。


import (    "fmt"    "sync"    "time")// OrderDispatcher 是一个守护进程,它使用 sync 创建一个工作池。waitGroup 并发地// 处理和分发订单type OrderDispatcher struct {    ordersCh   chan *Order    orderLimit int // 并发处理的最大订单数}// NewOrderDispatcher 创建一个新的 OrderDispatcherfunc NewOrderDispatcher(orderLimit int, bufferSize int) OrderDispatcher {    return OrderDispatcher{        ordersCh:   make(chan *Order, bufferSize), // initiliaze as a buffered channel        orderLimit: orderLimit,    }}// SubmitOrder 提交订单进行处理func (d OrderDispatcher) SubmitOrder(order *Order) {    go func() {        d.ordersCh <- order    }()}// Start 在后台启动调度程序func (d OrderDispatcher) Start() {    go d.processOrders()}// Shutdown 通过关闭订单来关闭 OrderDispatcher// 注意:这个函数应该只在最后一个订单到达订单通道之后才执行。// 向一个封闭的通道提交命令会引起 panic。func (d OrderDispatcher) Shutdown() {    close(d.ordersCh)}// processOrders 使用“for range”和一个 sync.waitGroup 在后台处理所有传入的订单 func (d OrderDispatcher) processOrders() {    limiter := make(chan struct{}, d.orderLimit)    var wg sync.WaitGroup    // 连续地处理从订单通道接收到的订单    // 当通道关闭时,此循环将终止    for order := range d.ordersCh {        limiter <- struct{}{}        wg.Add(1)        go func(order *Order) {            // TODO: 触发执行流程,将订单组装成一个包裹并发货,            // 这里我们 sleep 并打印            time.Sleep(50 * time.Millisecond)            fmt.Printf("Order (%v) has shipped \n", order)            <-limiter            wg.Done()        }(order)    }    wg.Wait()}func main() {    dispatcher := NewOrderDispatcher(3, 100)    dispatcher.Start()    defer dispatcher.Shutdown()    dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "iPhone Screen Protector", Price: 9.99}}})    dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "iPhone Case", Price: 19.99}}})    dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "Pixel Case", Price: 14.99}}})    dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "Bluetooth Speaker", Price: 29.99}}})    dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "4K Monitor", Price: 159.99}}})    dispatcher.SubmitOrder(&Order{Items: []*Item{{Description: "Inkjet Printer", Price: 79.99}}})
time.Sleep(5 * time.Second) // 仅为了测试}
复制代码


有效的单元测试


在我早期的职业生涯(Java 时代),单元测试(unit testing) 让我想起了妈妈经常放在我餐盘里的蔬菜。小时候,我总是先吃好东西,然后偷偷地把蔬菜铲进垃圾桶里。换句话说,单元测试给我留下了不好的印象。这主要是因为它需要团队跟上新的 mock 框架的速度,这些框架通常很难理解,学习曲线很陡峭。更不用说,这些依赖于反射的嘲弄性框架了——正如 Rob Pike 曾经说过的那样,反射从来都不是清晰的。


然而,幸运的是,Go 改变了我对单元测试的看法。以下是我在测试过程中学到的一些技巧。


使用纯函数代替方法。纯函数是最容易测试的代码单元之一。纯函数是确定性的,不需要初始化就可以进行测试。方法是在类型(例如 struct)上定义的函数。为了测试一个方法,必须初始化它的父类型。参见下文。


// 要避免这种情况type OrderTotaler struct {           items []*Item}// 这是一个方法。将它绑定到一个结构体上不会产生任何好处,// 因为在测试这个方法之前需要对结构体进行初始化func (t OrderTotaler) getOrderTotal() float32 {    var total float32    for _, item := range t.items {        total += item.Price    }    return total}// 这样做。这是一个纯函数func getOrderTotal(items []*Item) float32 {    var total float32    for _, item := range items {        total += item.Price    }    return total}
复制代码


方法 vs 纯函数(示例)


创建函数依赖。函数执行任务所需的任何外部依赖(DB、Web 服务调用、事件生成器等)都可以作为参数注入到函数中。具有嵌入式依赖的函数很难测试。开发人员通常通过使用能够在运行时(通过反射)更改(mock)外部依赖值的测试框架来绕过这种 代码味道。如果再看一下validateOrder函数(在上面的代码片段中),你可能会注意到它嵌入了外部依赖preAuthorizePaymentverifyInventory。这个函数很难测试。因为 Go 支持一级函数——我们可以通过将validateOrder转换为 高阶函数 来解决这个问题。


var (      ErrPreAuthorizationTimeout = errors.New("pre-authorization request timeout")    ErrInventoryRequestTimeout = errors.New("check inventory request timeout")    ErrItemOutOfStock = errors.New("sorry one or more items in your order is out of stock"))// 为我们的外部依赖项创建别名type preAuthorizePaymentFunc func(context.Context, *PaymentMethod, float32) errortype checkInventoryFunc func (context.Context, []*Item) (bool, error)// 将依赖项作为参数传入到 validateOrder 中func validateOrder(ctx context.Context, items []*Item, payment *PaymentMethod,     preAuthorizePayment preAuthorizePaymentFunc, checkInventory checkInventoryFunc) error {    g, errCtx := errgroup.WithContext(ctx)    g.Go(func() error {        return preAuthorizePayment(errCtx, payment, getOrderTotal(items))    })    g.Go(func() error {        itemsInStock, err := checkInventory(errCtx, items)        if err != nil {            return err        }        if !itemsInStock {            return ErrItemOutOfStock        }        return nil    })    return g.Wait()}
复制代码


下面是将上述所有联系在一起的测试用例。


import (    "context"    "errors"    "testing")func TestVerifyOrder(t *testing.T) {    ctx := context.Background()    iphoneScreenProtector := Item{Description: "iPhone Screen Protector", Price: 9.99}    iphoneCase := Item{Description: "iPhone Case", Price: 19.99}    // function mock of external dependency #1    preAuth := func(ctx context.Context, payment *PaymentMethod, amount float32) error {        if amount <= 0 || payment.PaymentType == PaymentMethod_UNDEFINED {            return errors.New("invalid pre authorization request")        }        return nil    }    // function mock of external dependency #2    checkInv := func(ctx context.Context, items []*Item) (bool, error) {        if len(items) == 0 {            return false, errors.New("no items to check")        }        if len(items) == 1 && items[0] == &iphoneScreenProtector {            return true, nil        }        return false, nil    }    t.Run("payment pre-authorization and inventory checks are successful", func(t *testing.T) {        visaPayment := PaymentMethod{            PaymentType:           PaymentMethod_VISA,            PreAuthorizationToken: "fooBarToken"}        // No mocking frameworks needed        if err := validateOrder(ctx, []*Item{&iphoneScreenProtector}, &visaPayment, preAuth, checkInv); err != nil {            t.Error("Expected nil, got ", err)        }    })    t.Run("error during payment pre-authorization", func(t *testing.T) {        invalidPayment := PaymentMethod{            PaymentType:           PaymentMethod_UNDEFINED,            PreAuthorizationToken: "fooBarToken"}        if err := validateOrder(ctx, []*Item{&iphoneScreenProtector}, &invalidPayment, preAuth, checkInv); err == nil {            t.Error("Expected error, got nil")        }    })    t.Run("item is out of stock", func(t *testing.T) {        visaPayment := PaymentMethod{            PaymentType:           PaymentMethod_VISA,            PreAuthorizationToken: "fooBarToken"}        if err := validateOrder(ctx, []*Item{&iphoneCase}, &visaPayment, preAuth, checkInv); err == nil {            t.Error("Expected error, got nil")        }    })    // TODO determine what the other test cases are and write them :-)}
复制代码


Mock 框架在用作工具而不是拐杖时非常有用。即使我们可以在没有第三方的情况下 mock 外部依赖,这些框架仍然能为单元测试繁琐地方(如执行测试断言)提供了价值。


对队友是友好的。正如 Rob Pike 所说的“清晰胜于聪明”,我总是鼓励开发人员在编写代码时要考虑到受众。清晰的代码易于编写,易于测试,并且应该易于开发人员(和非开发人员)理解。


原文链接:


https://levelup.gitconnected.com/the-golang-microservice-toolkit-7521516ee4b


2021 年 2 月 22 日 11:002650

评论

发布
暂无评论
发现更多内容

架构训练实战营第一周作业

赵岩

如何实现CNN特征层可视化?终于懂了....

Jackpop

Python小白福音!Github开源了一款神器....

Jackpop

Vue进阶(幺伍叁):Vue-highlight 实现代码高亮

No Silver Bullet

Vue 10 月日更 highlight.js

敏捷QA需要编写测试用例吗?

BY林子

测试用例 敏捷测试

架构实战营模块 1 作业

VegetableBird

架构实战营 #架构实战营 「架构实战营」

左手 CloudWeGo,右手 Kratos ,如何选?

baiyutang

golang 10月日更

架构训练营 - 第一周作业

二手攻城师

「架构实战营」

数仓无损压缩算法:gzip算法

华为云开发者社区

算法 deflate 无损 gzip 压缩数据

微信业务架构图

宥君

架构实战营

架构实战营 模块一作业 微信业务架构图 & 学生管理系统

dog_brother

「架构实战营」

代码简洁之道:一行Python代码解决问题是时尚还是玄学

博文视点Broadview

引导行业发展!旺链科技加入“可信区块链推进计划”

旺链科技

区块链 数字经济 产业区块链

第一周学习

乐知

「架构实战营」

Prometheus HTTP API 查询(一) 接口格式

耳东@Erdong

Prometheus PromQL 10 月日更 HTTP API

Java 有关 Integer 一个好玩的包装类

HoneyMoose

游戏数字资产复用——有哪些是你需要知道的?

龙智—DevOps解决方案

游戏开发 游戏引擎 perforce

只需2步,教你在Vue中设置登录验证拦截

华为云开发者社区

Vue 浏览器 Token pringboot 登录验证

第一周作业

赵先生

架构实战营

学生管理系统架构

宥君

架构实战营

学习总结(第一周)

Geek_1d37ea

架构实战营

模块一作业

Geek_1d37ea

Flux架构思想在度咔App中的实践

百度Geek说

百度 架构 后端 短视频 Flux

架构实战营 - 第三期 - 模块一作业

白小黑

架构实战营

B格被拉满了....

Jackpop

模块一作业

美好心情

「架构实战营」

模块一

网易云信被纳入 Gartner 2021年《CPaaS 市场指南》研究报告

网易云信

云通信 Gartner 音视频开发

模块一作业及总结

Thomas

架构实战营

Java 包装类和基本类型

HoneyMoose

OKR与影响地图,别再傻傻分不清

华为云开发者社区

OKR 敏捷 影响地图 规划 目标

如何行之有效地参与开源?

如何行之有效地参与开源?

我做了一个Go语言的微服务工具包-InfoQ