多年以来,我一直认为自己是一名语言无关的软件开发人员,因为在编程语言方面,我总是把掌握基础知识和学习新概念放在首位,而不是“玩最爱”。在我 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;
}
// 支付信息的 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.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 结合使用的服务接口。
并发: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 对支付方式进行预授权并返回错误。
// 如果预先授权成功,则返回 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()
}
复制代码
大多数仓库(和履约中心)都有订单管理系统,以实现高效、经济的订单履行。类似地,管理并发对于维持应用程序的质量至关重要。下面的示例使用waitgroup
和channel
来限制仓库一次可以处理的订单数量。
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)上定义的函数。为了测试一个方法,必须初始化它的父类型。参见下文。
// 要避免这种情况
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) 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()
}
复制代码
下面是将上述所有联系在一起的测试用例。
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
评论