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

2021年2月27日 405点热度 0人点赞 0条评论
图片
作者 | George Francis Jr
译者 | 刘雅梦
策划 | 田晓旭

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

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

1REST + 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.go和order_grpc.pb.go。order.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 结合使用的服务接口。

2并发: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 函数的能力,但是应该尽量避免使用init。init函数有一些缺点,其中包括返回值为空。具体来说,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()}
大多数仓库(和履约中心)都有订单管理系统,以实现高效、经济的订单履行。类似地,管理并发对于维持应用程序的质量至关重要。下面的示例使用waitgroup和channel来限制仓库一次可以处理的订单数量。
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) // 仅为了测试}
3有效的单元测试

在我早期的职业生涯(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 函数(在上面的代码片段中),你可能会注意到它嵌入了外部依赖 preAuthorizePayment 和 verifyInventory。这个函数很难测试。因为 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

点击文末【阅读原文】移步InfoQ官网,内容更多更精彩!

本周好文推荐

赔偿9200万美元!TikTok与美国用户和解隐私诉讼

周鸿祎称微信靠摇妹子起家;华为东莞实验室爆炸损失3945万元;人社部回应延迟退休 | Q资讯

程序员的“黄金时代”,死去又重来?

放弃大厂高薪的程序员,涌进体制内




每周精要上线移动端,立刻订阅,你将获得
InfoQ 用户每周必看的精华内容集合:
资深技术编辑撰写或编译的全球 IT 要闻
一线技术专家撰写的实操技术案例
InfoQ 出品的课程技术活动报名通道;
“码”上关注,订阅每周新鲜资讯

图片

点个在看少个 bug?

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

root

这个人很懒,什么都没留下

文章评论