首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

我做了一个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,以支持列出 / 过滤现有的订单。

代码语言:javascript
复制
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;
}
// 支付信息的 message
message PaymentMethod {
    enum Type {
    NOT_DEFINED = 0;
    VISA = 1;
    MASTERCARD = 2;
    PAYPAL = 3;
    APPLEPAY = 4;
  }
   Type payment_type = 1;
   string pre_authorization_token = 2; 
}
// 包含在订单中的商品的详细信息的 message
message 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()方法之前调用它。请参见下面的示例。

代码语言:javascript
复制
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 服务,我们可以正式实现这种“联姻”。

代码语言:javascript
复制
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 结合起来。

代码语言:javascript
复制
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 服务以进行后台处理和基于通道的错误传播。

代码语言:javascript
复制
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

代码语言:javascript
复制
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 服务的错误,并处理应用程序的启动 / 关闭(包括操作系统的终止信号)。

代码语言:javascript
复制
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参数,并且在上下文取消(或请求超时)时能够提前返回。

代码语言:javascript
复制
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 对支付方式进行预授权并返回错误。
// 如果预先授权成功,则返回 nil
func 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来限制仓库一次可以处理的订单数量。

代码语言:javascript
复制
import (
    "fmt"
    "sync"
    "time"
)
// OrderDispatcher 是一个守护进程,它使用 sync 创建一个工作池。waitGroup 并发地
// 处理和分发订单
type OrderDispatcher struct {
    ordersCh   chan *Order
    orderLimit int // 并发处理的最大订单数
}
// NewOrderDispatcher 创建一个新的 OrderDispatcher
func 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)上定义的函数。为了测试一个方法,必须初始化它的父类型。参见下文。

代码语言:javascript
复制
// 要避免这种情况
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转换为 高阶函数 来解决这个问题。

代码语言:javascript
复制
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) error
type 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()
}

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

代码语言:javascript
复制
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 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/Rq1RKVsMv58bxzExQLiJ
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券