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()
}
测试客户端的逻辑却不想要连接真正的服务器端所带来的开销,可以使用 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)在任意目录下创建 server 和 client 项目文件,在服务端和客户端的项目目录下分别创建 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
文件分别在 server 和 client 项目下生成 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)启动服务端程序,重新打开一个终端。
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) {}
}
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
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为例》([斯里兰卡] 卡山 • 因德拉西里 丹尼什 • 库鲁普 著)