vlambda博客
学习文章列表

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

作者 | 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。

我做了一个 Go 语言的微服务工具包编译 order.proto

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

我做了一个 Go 语言的微服务工具包Order 的结构体(生成的代码)

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

我做了一个 Go 语言的微服务工具包OrderServiceServer 接口(生成的代码)

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

我做了一个 Go 语言的微服务工具包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官网,内容更多更精彩!


本周好文推荐






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

点个在看少个 bug 👇