gRPC 的测试

文章目录

  • 简介
    • 测试 gRPC 服务器端
    • 测试 gRPC 客户端
    • 负载测试
  • 负载测试程序示例


简介


gRPC 应用程序始终会与网络交互,测试应该涵盖服务器端和客户端 gRPC 应用程序的网络方面。

测试 gRPC 服务器端

gRPC 服务的测试通常使用 gRPC 客户端应用程序来完成,该客户端应用程序是测试用例的一部分。服务器端的测试包括使用所需的服务启动 gRPC 服务器,并使用实现测试用例的客户端应用程序连接到服务器。

在 Go 语言中,gRPC 测试用例应该是使用 testing 包的 Go 通用测试用例来实现的,例如以下的关键的程序使用 Go 语言编写的测试用例,它对 ProductInfo 服务进行了测试。

// 常规测试,启动 gRPC 客户端和服务端
func TestServer_AddProduct(t *testing.T) { 
		// 在 HTTP/2 之上启动常规的 gRPC 服务器
		grpcServer := initGRPCServerHTTP2() 
		conn, err := grpc.Dial(address, grpc.WithInsecure()) 
		if err != nil {
				grpcServer.Stop()
				t.Fatalf("did not connect: %v", err)
		}
		defer conn.Close()
		c := pb.NewProductInfoClient(conn)
		name := "Sumsung S10"
		description := "Samsung Galaxy S10 is the latest smart phone, launched in February 2019"
		price := float32(700.0)
		ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel()
		r, err := c.AddProduct(ctx, &pb.Product{Name: name, Description: description, Price: price}) 
		if err != nil { 
		t.Fatalf("Could not add product: %v", err)
		}
		if r.Value == "" {
				t.Errorf("Invalid Product ID %s", r.Value)
		}
		log.Printf("Res %s", r.Value)
		grpcServer.Stop()
}

测试 gRPC 客户端

测试客户端的逻辑却不想要连接真正的服务器端所带来的开销,可以使用 mock 框架。对 gRPC 服务器端进行 mock,能够让开发人员在客户端编写轻量级单元测试,来对功能进行检查,避免对服务器进行 RPC。

若使用 Go 语言开发 gRPC 客户端应用程序,则可以(借助生成的代码)使用 Gomock 来模拟客户端接口,并通过编码的方式设置方法以接收和返回预先确定的值。在使用 Gomock 时,可以通过如下命令为 gRPC 客户端应用程序生成 mock 接口:

mockgen grpc-docker/go/proto \ ProductInfoClient > mock_prodinfo/prodinfo_mock.go 

这里指定 ProductInfoClient 是要模拟的接口,然后,所编写的测试代码可以导入 mockgen 生成的包以及 gomock 包,从而为客户端逻辑编写单元测试例如以下的的程序创建了一个 mock 对象,预期对它的方法进行调用并返回一个响应。

func TestAddProduct(t *testing.T) {
		ctrl := gomock.NewController(t)
		defer ctrl.Finish()
		// 创建 mock 对象,预期对远程方法进行调用
		mocklProdInfoClient := NewMockProductInfoClient(ctrl) 

		...

		req := &pb.Product{Name: name, Description: description, Price: price}
		// 对 mock 对象进行编码
		mocklProdInfoClient.EXPECT().AddProduct(gomock.Any(), &rpcMsg{msg: req},). Return(&wrapper.StringValue{Value: "ABC123" + name}, nil) 
		// 调用实际的测试方法,它会调用客户端存根的远程方法
		testAddProduct(t, mocklProdInfoClient) 	
}

func testAddProduct(t *testing.T, client pb.ProductInfoClient) {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel()

...

		r, err := client.AddProduct(ctx, &pb.Product{Name: name, Description: description, Price: price})
		// 测试并校验响应
}

mock gRPC 服务器不会带来与真实的 gRPC 服务器端完全相同的行为。因此,特定的功能可能无法通过测试来校验,除非重新实现 gRPC 服务器端可能出现的所有错误逻辑。在实践中,可以通过 mock 校验一组选定的功能,而其他的功能则需要通过真正的 gRPC 服务器实现来验证。


负载测试

使用常规的工具很难对 gRPC 应用程序进行负载测试和基准测量,这是因为这些应用程序都或多或少是与特定协议(如 HTTP)绑定的。对于 gRPC 来说,需要定制的负载测试工具,这些工具能够生成对服务器端的虚拟 RPC 负载,从而实现对 gRPC 服务器端的负载测试。

ghz 就是这样的负载测试工具,它是使用 Go 语言实现的命令行工具。它能够在本地对服务进行测试和调试,也能用在自动化持续集成环境中,实现性能回归测试。

ghz 的安装与配置参考以下两种方式:

  • 离线包方式

可以直接在 Release 页面下载二进制文件并依次执行以下命令:

# 下载
wget https://github.com/bojand/ghz/releases/download/v0.114.0/ghz-linux-x86_64.tar.gz

# 解压
tar -zxvf ghz-linux-x86_64.tar.gz 

# 解压后的文件
ghz  ghz-linux-x86_64.tar.gz  ghz-web  LICENSE

# 添加到环境变量(export PATH=$PATH:/user/local)
sudo vim /etc/profile

# 使环境变量生效
source /etc/profile
  • 源码编译方式

参考 ghz 官网的教程,依次执行如下的命令:

# 克隆仓库
git clone https://github.com/bojand/ghz

# 进入 ghz 目录
cd ghz

# Build using make
make build

# Build using go
cd cmd/ghz
go build .

# Install using go 
go install github.com/bojand/ghz/cmd/ghz@latest

ghz 工具有如下两种使用方法:

  • 二进制文件方式,通过命令行参数或者配置文件指定配置信息;

  • ghz/runner 编程方式,通过代码指定配置信息。

ghz 命令参数说明:

参数可以查看 官方文档 或者查阅帮助命令 ghz -h ,大致可以分为基本参数,负载参数(主要控制 ghz 每秒发起的请求数(RPS)),并发参数。具体的参数参考如下表:

基本参数 说明
–config 指定配置文件位置
–proto 指定 proto 文件位置,会从 proto 文件中获取相关信息
–call 指定调用的方法,具体格式:包名.服务名.方法名
-c 并发请求数
-n 最大请求数,达到后则结束测试
-d 请求参数,JSON格式,如 -d ‘{“name”:“cqupthao”}’
-D 以文件方式指定请求参数,JSON文件位置,如 -D ./file.json
-o 输出路径,默认输出到 stdout
-O/–format 输出格式,有多种格式可选(csv、json、pretty、html、influx-summary、influx-details、满足 InfluxDB line-protocol 格式的输出)
负载参数 说明
-r/–rps 指定RPS,ghz 以恒定的 RPS 进行测试
–load-schedule 负载调度算法,取值有(const:恒定RPS,也是默认调用算法;step:步进增长RPS,需要配合 load-start、load-step、load-end、load-step-duration 和 load-max-duration 等参数;line:线性增长RPS,需要配合 load-start、load-step、load-end 和 load-max-duration 等参数,其实 line 就是 step 算法将 load-step-duration 时间固定为一秒了)
–load-start step、line 的起始 RPS
–load-step step、line 的步进值或斜率值
–load-end step、line 的负载结束值
–load-max-suration 最大持续时间,到达则结束

使用方法如下所示:

# 从 50 RPS 开始,每 5 秒钟增加 10 RPS ,一直到完成 10000 请求为止
-n 10000 -c 10 --load-schedule=step --load-start=50 --load-step=10 --load-step-duration=5s

# 从 50 RPS 开始,每 5 秒钟增加 10 RPS ,最多增加到 150 RPS ,一直到完成 10000 请求为止
-n 10000 -c 10 --load-schedule=step --load-start=50 --load-end=150 --load-step=10 --load-step-duration=5s

# 从 200 RPS 开始,每 1 秒钟降低 2RPS ,一直降低到 50 RPS ,一直到完成 10000 请求为止
# line 其实就是 step ,只不过是把 –load-step-duration 固定为 1 秒了
-n 10000 -c 10 --load-schedule=line --load-start=200 --load-step=-2 --load-end=50
并发参数 说明
-c 并发 woker 数,非并发请求数
–concurrency-schedule 并发调度算法,和–load-schedule类似(const:恒定并发数,默认值;step:步进增加并发数;line:线性增加并发数)
–concurrency-start 起始并发数
–concurrency-end 结束并发数
–concurrency-step 并发数步进值
–concurrency-step-duration 在每个梯段需要持续的时间
–concurrency-max-duration 最大持续时间

使用方法如下所示:

# 固定 RPS 200 ,worker 数从 5 开始,每 5 秒增加 5 ,最大增加到 50
# 5 个 worker 时也要完成 200 RPS (即每个 worker 需要完成 40 RPS ,到 50 个 worker 时只需要每个 worker 完成 4 RPS 即可达到 200 RPS)
-n 100000 --rps 200 --concurrency-schedule=step --concurrency-start=5 --concurrency-step=5 --concurrency-end=50 --concurrency-step-duration=5s

所有参数都可以通过配置文件来指定,例如如下内容的文件:

{
    	"proto": "/path/to/greeter.proto",
   	 	"call": "helloworld.Greeter.SayHello",
    	"total": 2000,
    	"concurrency": 50,
    	"data": {
        		"name": "Joe"
    	},
    	"metadata": {
        		"foo": "bar",
        		"trace_id": "{{.RequestNumber}}",
        		"timestamp": "{{.TimestampUnix}}"
    	},
    	"import-paths": [
        		"/path/to/protos"
    	],
   	 	"max-duration": "10s",
    	"host": "0.0.0.0:50051"
}

负载测试程序示例


(1)在任意目录下创建 serverclient 项目文件,在服务端和客户端的项目目录下分别创建 proto 目录,用于存放 helloworld.proto 文件,具体的文件结构和 helloworld.proto 文件内容如下所示:

Tesing
├── client
│   └── proto
│   	└── helloworld.proto
└── server
	└── proto
    	└── helloworld.proto
syntax = "proto3";

option go_package = "../proto";

package helloworld;

// The greeting service definition.
service Greeter {
  		// Sends a greeting
  		rpc SayHello (HelloRequest) returns (HelloReply) {}
  		// Sends another greeting
  		rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}}

// The request message containing the user's name.
message HelloRequest {
  		string name = 1;
}

// The response message containing the greetings
message HelloReply {
  		string message = 1;
}

根据 helloworld.proto 文件分别在 serverclient 项目下生成 gRPC 代码文件,进入 proto 目录执行以下的命令:

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative *.proto

成功生成后的源码文件会保存在 proto 文件夹下,具体目录结构如下所示:

Testing
├── client
│   └── proto
│       ├── helloworld_grpc.pb.go
│       ├── helloworld.pb.go
│       └── helloworld.proto
└── server
    └── proto
        ├── helloworld_grpc.pb.go
        ├── helloworld.pb.go
        └── helloworld.proto

(2)在服务端需要实现该服务定义并运行 gRPC 服务器来处理客户端的调用,编写服务端程序重载服务基类(实现所生成的服务器骨架的逻辑),创建 gRPC 服务器,注册服务并指定端口监听传入的消息,该程序的具体代码如下:

package main

import (
        "context"
        "flag"
        "fmt"
        "log"
        "net"
        "google.golang.org/grpc"
        pb "server/proto"
)

var (
        port = flag.Int("port", 50051, "The server port")
)

// server is used to implement helloworld.GreeterServer.
type server struct {
        pb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
        log.Printf("Received: %v", in.GetName())
        return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func (s *server) SayHelloAgain(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
        return &pb.HelloReply{Message: "Hello again " + in.Name}, nil
}
func main() {
        flag.Parse()
        lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
        if err != nil {
                log.Fatalf("failed to listen: %v", err)
        }
        s := grpc.NewServer()
        pb.RegisterGreeterServer(s, &server{})
        log.Printf("server listening at %v", lis.Addr())
        if err := s.Serve(lis); err != nil {
                log.Fatalf("failed to serve: %v", err)
        }
}

(3)在 client 项目下编写一个调用 server 提供的 RPC 服务的程序,该程序的具体代码如下:

package main

import (
        "context"
        "flag"
        "log"
        "time"

        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
        pb "client/proto"
)

const (
        defaultName = "world"
)

var (
        addr = flag.String("addr", "localhost:50051", "the address to connect to")
        name = flag.String("name", defaultName, "Name to greet")
)

func main() {
        flag.Parse()
        // Set up a connection to the server.
        conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil {
                log.Fatalf("did not connect: %v", err)
        }
        defer conn.Close()
        c := pb.NewGreeterClient(conn)

        // Contact the server and print out its response.
        ctx, cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        r, err := c.SayHello(ctx, &pb.HelloRequest{Name: *name})
        if err != nil {
                log.Fatalf("could not greet: %v", err)
        }
        log.Printf("Greeting: %s", r.GetMessage())

        r, err = c.SayHelloAgain(ctx, &pb.HelloRequest{Name: *name})
        if err != nil {
                log.Fatalf("could not greet: %v", err)
        }
        log.Printf("Greeting: %s", r.Message)
}

(4)分别在 server 和 client 项目下编译并执行程序,输出如下的结果:

2023/02/28 22:09:16 Greeting: Hello world
2023/02/28 22:09:16 Greeting: Hello again world

(5)启动服务端程序,重新打开一个终端。

  • 进行基本参数测试,进入 client 项目目录下执行如下的命令:
ghz -c 10 -n 1000 --insecure --proto proto/helloworld.proto --call helloworld.Greeter.SayHelloAgain localhost:50051

成功执行后输出如下的结果:

Summary:
  Count:        1000
  Total:        235.00 ms
  Slowest:      6.81 ms
  Fastest:      0.22 ms
  Average:      1.18 ms
  Requests/sec: 4255.23

Response time histogram:
  0.219 [1]   |
  0.879 [475] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  1.538 [283] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  2.197 [137] |∎∎∎∎∎∎∎∎∎∎∎∎
  2.857 [63]  |∎∎∎∎∎
  3.516 [21]  |∎∎
  4.176 [10]  |4.835 [6]   |5.495 [3]   |
  6.154 [0]   |
  6.813 [1]   |

Latency distribution:
  10 % in 0.45 ms 
  25 % in 0.62 ms 
  50 % in 0.91 ms 
  75 % in 1.51 ms 
  90 % in 2.21 ms 
  95 % in 2.68 ms 
  99 % in 4.10 ms 

Status code distribution:
  [OK]   1000 responses   

--call helloworld.Greeter.SayHelloAgain 参数说明,包名为 helloworld、 service 名为 Greeter ,方法名为 SayHelloAgain ,对应的 proto 文件关键内容如下:

// 省略其它代码...
package helloworld;
service Greeter {
  	rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}
}
  • 进行负载参数测试,进入 server 项目目录下执行如下的命令:
ghz -c 10 -n 1000 --insecure --proto proto/helloworld.proto --call helloworld.Greeter.SayHelloAgain --load-schedule=step --load-start=50 --load-step=10 --load-step-duration=5s localhost:50051

成功执行后输出如下的结果:

Summary:
  Count:        1000
  Total:        16.25 s
  Slowest:      2.89 ms
  Fastest:      0.39 ms
  Average:      0.62 ms
  Requests/sec: 61.53

Response time histogram:
  0.392 [1]   |
  0.642 [775] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  0.892 [182] |∎∎∎∎∎∎∎∎∎
  1.142 [13]  |1.392 [8]   |
  1.642 [2]   |
  1.892 [3]   |
  2.142 [0]   |
  2.392 [3]   |
  2.642 [8]   |
  2.893 [5]   |

Latency distribution:
  10 % in 0.47 ms 
  25 % in 0.50 ms 
  50 % in 0.55 ms 
  75 % in 0.63 ms 
  90 % in 0.76 ms 
  95 % in 0.85 ms 
  99 % in 2.46 ms 

Status code distribution:
  [OK]   1000 responses   

若要指定使用 HTML 格式输出结果,执行以下的命令后可以在当前目录看到输出的 HTML 文件:

ghz -c 10 -n 1000 --insecure --proto proto/helloworld.proto --call helloworld.Greeter.SayHelloAgain --load-schedule=step --load-start=50 --load-step=10 --load-step-duration=5s -o report.html -O html localhost:50051
  • 进行并发参数测试,进入 server 项目目录下执行如下的命令:
ghz -c 10 -n 1000 --insecure --proto proto/helloworld.proto --call helloworld.Greeter.SayHelloAgain --rps 200 --concurrency-schedule=step --concurrency-start=5 --concurrency-step=5 --concurrency-end=50 --concurrency-step-duration=5s localhost:50051

成功执行后输出如下的结果:

Summary:
  Count:        1000
  Total:        5.00 s
  Slowest:      3.69 ms
  Fastest:      0.32 ms
  Average:      0.49 ms
  Requests/sec: 199.96

Response time histogram:
  0.324 [1]   |
  0.661 [922] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  0.998 [50]  |∎∎
  1.335 [6]   |
  1.673 [3]   |
  2.010 [3]   |
  2.347 [5]   |
  2.684 [5]   |
  3.021 [1]   |
  3.358 [2]   |
  3.695 [2]   |

Latency distribution:
  10 % in 0.37 ms 
  25 % in 0.39 ms 
  50 % in 0.42 ms 
  75 % in 0.47 ms 
  90 % in 0.62 ms 
  95 % in 0.74 ms 
  99 % in 2.25 ms 

Status code distribution:
  [OK]   1000 responses   

若要指定使用 JSON 格式输出结果,执行以下的命令后可以在当前目录看到输出的 JSON 文件:

ghz -c 10 -n 1000 --insecure --proto proto/helloworld.proto --call helloworld.Greeter.SayHelloAgain --rps 200 --concurrency-schedule=step --concurrency-start=5 --concurrency-step=5 --concurrency-end=50 --concurrency-step-duration=5s -o report.json -O json localhost:50051

(6) ghz/runner 编程实现参数,在 server 项目目录下编写一个程序实现测试,该程序的具体代码如下:

package main

import (
        "log"
        "os"

        "github.com/bojand/ghz/printer"
        "github.com/bojand/ghz/runner"
        "github.com/golang/protobuf/proto"
        pb "server/proto"
)

// 官方文档 https://ghz.sh/docs/intro.html
func main() {
        // 组装 BinaryData
        item := pb.HelloRequest{Name: "lixd"}
        buf := proto.Buffer{}
        err := buf.EncodeMessage(&item)
        if err != nil {
                log.Fatal(err)
                return
        }
        report, err := runner.Run(
                // 基本配置 call host proto 文件 data
                "helloworld.Greeter.SayHello", //  'package.Service/method' or 'package.Service.Method'
                "localhost:50051",
                runner.WithProtoFile("proto/helloworld.proto", []string{}),
                runner.WithBinaryData(buf.Bytes()),
                runner.WithInsecure(true),
                runner.WithTotalRequests(10000),
                // 并发参数
                runner.WithConcurrencySchedule(runner.ScheduleLine),
                runner.WithConcurrencyStep(10),
                runner.WithConcurrencyStart(5),
                runner.WithConcurrencyEnd(100),
        )
        if err != nil {
                log.Fatal(err)
                return
        }
        // 指定输出路径
        file, err := os.Create("report.html")
        if err != nil {
                log.Fatal(err)
                return
        }
        rp := printer.ReportPrinter{
                Out:    file,
                Report: report,
        }
        // 指定输出格式
        _ = rp.Print("html")
}

成功执行程序后会在当前目录下生成指定格式的文件,推荐使用 ghz/runner 编程方式 + HTML 格式输出结果。

  • ghz/runner 编程方式相比二进制方式更加灵活;

  • HTML 格式输出结果更加直观。


  • 参考链接:gRPC 官网

  • 参考书籍:《gRPC与云原生应用开发:以Go和Java为例》([斯里兰卡] 卡山 • 因德拉西里 丹尼什 • 库鲁普 著)

你可能感兴趣的:(微服务系列,单元测试,golang,rpc,服务器,后端)