通过实例学习 Go:第 6 部分 - 使用 Go 创建 gRPC 应用
在之前的文章中,我们创建了一个HTTP REST API 服务器、一个CLI、一个Discord 机器人,甚至还有一个Nintendo Game Boy Advance 游戏。今天,让我们创建另一种类型的应用程序:一个用 Go 语言编写的 gRPC 应用程序!
gRPC
首先,什么是gRPC?
gRPC是一个现代化的开源远程过程调用 (RPC) 框架,最初由 Google 开发。
gRPC 的核心思想是定义一个服务,并指定可以远程调用的方法及其参数和返回类型。在服务器端,服务器实现此接口并运行 gRPC 服务器来处理客户端调用。在客户端,客户端有一个存根(在某些语言中简称为客户端),它提供与服务器相同的方法。
它使用Protocol Buffers,这是谷歌的开源技术,用于序列化和反序列化结构化数据。
gRPC 使用HTTP/2作为传输层(延迟更低、响应复用、服务器端流式传输、客户端流式传输甚至双向流式传输……)
每个 RPC 服务都在一个文件中声明protobuf。
通过此.proto文件,您可以生成多种语言的客户端。
因此,gRPC 的强大之处在于它与语言无关:你可以用 Go 编写一个服务器,然后用 Java、Python、Rust、Go 等多种语言编写多个客户端……
如果您有需要相互通信的微服务,gRPC 可以替代 REST API 接口作为解决方案。
初始化
我们在上一篇文章中创建了Git 仓库,现在只需要将其检索到本地即可:
$ git clone https://github.com/scraly/learning-go-by-examples.git
$ cd learning-go-by-examples
我们将为go-gopher-grpcCLI 应用程序创建一个文件夹,然后进入该文件夹:
$ mkdir go-gopher-grpc
$ cd go-gopher-grpc
现在,我们需要初始化 Go 模块(依赖管理):
$ go mod init github.com/scraly/learning-go-by-examples/go-gopher-grpc
go: creating new go.mod: module github.com/scraly/learning-go-by-examples/go-gopher-grpc
这将创建一个go.mod类似这样的文件:
module github.com/scraly/learning-go-by-examples/go-gopher-grpc
go 1.16
在开始我们的超级 gRPC 应用程序之前,按照良好实践,我们将创建一个简单的代码组织结构。
创建以下文件夹结构:
.
├── README.md
├── bin
├── go.mod
└── test-results
就这些?是的,我们代码组织结构的其余部分很快就会创建完成 ;-)。
创建我们的 CLI 应用程序
与第二篇文章类似,我们将创建一个命令行界面 (CLI) 应用程序。
如果您不了解Cobra,我建议您先阅读CLI 相关文章,然后再继续学习。
安装 Cobra:
$ go get -u github.com/spf13/cobra@latest
生成我们的 CLI 应用程序结构和导入语句:
$ cobra init --pkg-name github.com/scraly/learning-go-by-examples/go-gopher-grpc
Your Cobra application is ready at
/Users/aurelievache/git/github.com/scraly/learning-go-by-examples/go-gopher-grpc
我们的应用程序已初始化,已创建main.go文件和cmd/文件夹,代码结构如下:
.
├── LICENSE
├── bin
├── cmd
│ └── root.go
├── go.mod
├── go.sum
├── main.go
└── test-results
与 CLI 文章中所述类似,我们需要使用Viperroot.go ,因此需要安装它:
$ go get github.com/spf13/viper@v1.8.1
让我们创建 gRPC 客户端和服务端
我们需要一个 gRPC 应用程序,所以我们首先需要做的是创建一个应用程序server和一个client命令:
$ cobra add client
client created at /Users/aurelievache/git/github.com/scraly/learning-go-by-examples/go-gopher-grpc
$ cobra add server
server created at /Users/aurelievache/git/github.com/scraly/learning-go-by-examples/go-gopher-grpc
现在,cmd/文件夹代码组织结构中应该包含以下文件:
cmd
├── client.go
├── root.go
└── server.go
此时,该go.mod文件应包含以下导入语句:
module github.com/scraly/learning-go-by-examples/go-gopher-grpc
go 1.16
require (
github.com/spf13/cast v1.4.0 // indirect
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.8.1
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
golang.org/x/text v0.3.6 // indirect
)
为了向用户解释我们应用程序的目标和用途,我们需要编辑该root.go文件:
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "go-gopher-grpc",
Short: "gRPC app in Go",
Long: `gRPC application written in Go.`,
}
是时候执行我们的应用程序了:
$ go run main.go
gRPC application written in Go.
Usage:
go-gopher-grpc [command]
Available Commands:
client A brief description of your command
completion generate the autocompletion script for the specified shell
help Help about any command
server A brief description of your command
Flags:
--config string config file (default is $HOME/.go-gopher-grpc.yaml)
-h, --help help for go-gopher-grpc
-t, --toggle Help message for toggle
Use "go-gopher-grpc [command] --help" for more information about a command.
默认情况下会显示使用说明,完美!
让我们测试一下我们的client命令server:
$ go run main.go client
client called
$ go run main.go server
server called
好的,clientandserver命令也得到了响应。
让我们创建我们的原型
正如我们所说,默认情况下,gRPC 使用Protocol Buffers。
使用 Protocol Buffers 的第一步是定义要序列化到.proto文件中的数据结构。
让我们gopher.proto在新文件夹下创建一个文件pkg/gopher/:
syntax = "proto3";
package gopher;
option go_package = "github.com/scraly/learning-by-examples/go-gopher-grpc";
// The gopher service definition.
service Gopher {
// Get Gopher URL
rpc GetGopher (GopherRequest) returns (GopherReply) {}
}
// The request message containing the user's name.
message GopherRequest {
string name = 1;
}
// The response message containing the greetings
message GopherReply {
string message = 1;
}
让我们来解释一下。
这个.proto文件公开了我们的Gopher服务,该服务有一个GetGopher函数,任何用任何语言编写的 gRPC 客户端都可以调用该函数。
gRPC 受多种编程语言支持,因此需要与 gRPC 服务器交互的微服务可以使用.proto输出文件中的代码生成自己的代码。
option go_package要生成 Go 代码,必须提供 Go 包的导入路径,每个.proto文件都必须提供该路径。
从 proto 生成 Go 代码
现在,我们需要安装Protocol Buffers v3。
适用于 macOS:
$ brew install protoc
检查 protoc 是否已正确安装:
$ protoc --version
libprotoc 3.17.3
现在我们需要借助protoc工具生成 Go gRPC 代码:
$ protoc --go_out=plugins=grpc:. --go_opt=paths=source_relative pkg/gopher/gopher.proto
文件夹中应该会多出一个新文件pkg/gopher:
pkg/gopher
├── gopher.pb.go
└── gopher.proto
gopher.go该文件包含生成的代码,我们将把该代码导入到我们的server.go文件中,以便将我们的 gRPC 服务器注册到Gopher服务。
让我们创建 gRPC 服务器
现在是时候创建我们的 gRPC 服务器了,为此我们需要编辑我们的server.go文件。
首先,我们初始化名为 cmd 的包,以及我们需要导入的所有依赖项/库:
package cmd
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net"
"net/http"
"strings"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
pb "github.com/scraly/learning-go-by-examples/go-gopher-grpc/pkg/gopher"
"google.golang.org/grpc"
)
然后,我们初始化常量:
const (
port = ":9000"
KuteGoAPIURL = "https://kutego-api-xxxxx-ew.a.run.app"
)
我们定义了两个结构体,一个用于服务器,一个用于 Gopher 数据。
// server is used to implement gopher.GopherServer.
type Server struct {
pb.UnimplementedGopherServer
}
type Gopher struct {
URL string `json: "url"`
}
我们改进了 serverCmd run 函数,该函数用于初始化 gRPC 服务器、注册 RPC 服务并启动服务器:
// serverCmd represents the server command
var serverCmd = &cobra.Command{
Use: "server",
Short: "Starts the Schema gRPC server",
Run: func(cmd *cobra.Command, args []string) {
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
// Register services
pb.RegisterGopherServer(grpcServer, &Server{})
log.Printf("GRPC server listening on %v", lis.Addr())
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
},
}
最后,我们实现了GetGopher该方法。
等等,我们想要什么?
哎呀,不好意思,我忘了解释一下我们的服务器会提供什么服务了^^。
我们的 gRPC 应该实现一个 GetGopher 方法,该方法将:
- 检查请求是否不为空,并且包含一个不为空的 Gopher 名称。
- 向KuteGo API请求有关 Gopher 的信息
- 返回 Gopher 的 URL
// GetGopher implements gopher.GopherServer
func (s *Server) GetGopher(ctx context.Context, req *pb.GopherRequest) (*pb.GopherReply, error) {
res := &pb.GopherReply{}
// Check request
if req == nil {
fmt.Println("request must not be nil")
return res, xerrors.Errorf("request must not be nil")
}
if req.Name == "" {
fmt.Println("name must not be empty in the request")
return res, xerrors.Errorf("name must not be empty in the request")
}
log.Printf("Received: %v", req.GetName())
//Call KuteGo API in order to get Gopher's URL
response, err := http.Get(KuteGoAPIURL + "/gophers?name=" + req.GetName())
if err != nil {
log.Fatalf("failed to call KuteGoAPI: %v", err)
}
defer response.Body.Close()
if response.StatusCode == 200 {
// Transform our response to a []byte
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatalf("failed to read response body: %v", err)
}
// Put only needed informations of the JSON document in our array of Gopher
var data []Gopher
err = json.Unmarshal(body, &data)
if err != nil {
log.Fatalf("failed to unmarshal JSON: %v", err)
}
// Create a string with all of the Gopher's name and a blank line as separator
var gophers strings.Builder
for _, gopher := range data {
gophers.WriteString(gopher.URL + "\n")
}
res.Message = gophers.String()
} else {
log.Fatal("Can't get the Gopher :-(")
}
return res, nil
}
不要忘记原有的原始init功能:
func init() {
rootCmd.AddCommand(serverCmd)
}
安装我们的依赖项
和往常一样,如果您使用外部依赖项,则需要安装它们:
$ go get google.golang.org/grpc
$ go get golang.org/x/xerrors
让我们创建 gRPC 客户端
现在,我们可以创建 gRPC 客户端了,为此我们需要编辑我们的client.go文件。
我们初始化名为 cmd 的包,以及所有需要导入的依赖项/库:
package cmd
import (
"context"
"log"
"os"
"time"
"google.golang.org/grpc"
pb "github.com/scraly/learning-go-by-examples/go-gopher-grpc/pkg/gopher"
"github.com/spf13/cobra"
)
定义我们的常量:
const (
address = "localhost:9000"
defaultName = "dr-who"
)
我们改进了 clientCmd 运行函数,使其:
- 初始化 gRPC 客户端
- 连接到 gRPC 服务器
- 调用 GetGopher 函数并传入 Gopher 的名称。
- 返回“URL:” + gRPC 调用返回的消息
// clientCmd represents the client command
var clientCmd = &cobra.Command{
Use: "client",
Short: "Query the gRPC server",
Run: func(cmd *cobra.Command, args []string) {
var conn *grpc.ClientConn
conn, err := grpc.Dial(address, grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %s", err)
}
defer conn.Close()
client := pb.NewGopherClient(conn)
var name string
// Contact the server and print out its response.
// name := defaultName
if len(os.Args) > 2 {
name = os.Args[2]
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r, err := client.GetGopher(ctx, &pb.GopherRequest{Name: name})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("URL: %s", r.GetMessage())
},
}
也别忘了还有现有的init方法:
func init() {
rootCmd.AddCommand(clientCmd)
}
试试看!
让我们启动 gRPC 服务器:
$ go run main.go server
2021/08/07 14:57:27 GRPC server listening on [::]:9000
然后,在终端的另一个标签页中,启动调用我们GetGopher方法并传入参数“gandalf”的gRPC客户端:
$ go run main.go client gandalf
2021/08/07 14:57:35 URL: https://raw.githubusercontent.com/scraly/gophers/main/gandalf.png
我们的应用程序运行正常,它会返回“URL:”+所需Gopher的URL。
建好了!
您的应用程序已准备就绪,现在只需构建即可。
为此,我们将像之前的文章一样,使用Taskfile来自动化常见任务。
所以,对于这个应用,我也创建了一个Taskfile.yml包含以下内容的文件:
version: "3"
tasks:
build:
desc: Build the app
cmds:
- GOFLAGS=-mod=mod go build -o bin/gopher-grpc main.go
run:
desc: Run the app
cmds:
- GOFLAGS=-mod=mod go run main.go
generate:
desc: Generate Go code from protobuf
cmds:
- protoc --go_out=plugins=grpc:. --go_opt=paths=source_relative pkg/gopher/gopher.proto
test:
desc: Execute Unit Tests
cmds:
- gotestsum --junitfile test-results/unit-tests.xml -- -short -race -cover -coverprofile test-results/cover.out ./...
正因如此,我们才能轻松构建我们的应用程序:
$ task build
task: [build] GOFLAGS=-mod=mod go build -o bin/gopher-grpc main.go
让我们用新生成的可执行二进制文件再测试一次:
$ ./bin/gopher-grpc server
2021/08/07 15:07:20 GRPC server listening on [::]:9000
在终端的另一个标签页中:
$ ./bin/gopher-grpc client yoda-gopher
2021/08/07 15:07:34 URL: https://raw.githubusercontent.com/scraly/gophers/main/yoda-gopher.png
酷!这是我们可爱的尤达地鼠的网址!:-)
单元测试?
现在,我可以将我的 gRPC 服务器/微服务部署到生产环境中了,太棒了,谢谢,再见!
呃……等等,在那之前,正如你所知,测试我们的应用程序非常重要,这样才能确保应用在部署前能够按预期运行。
单元测试是一种强大的实践,在 Go 语言中,你甚至可以为 gRPC 应用创建单元测试。
使用 Golang,你不需要像 Java 中的 JUnit 那样导入外部包。它已经集成到核心包中,只需使用命令即可go test。
让我们执行单元测试:
$ go test
? github.com/scraly/learning-go-by-examples/go-gopher-grpc [no test files]
如您所见,0 个单元测试成功运行,正常^^
我们将在下一节中处理它们,但在此之前,我们将发现一个有用的工具gotestsum。
戈特苏姆
Gotestsum,这是什么新工具?Go test 还不够吗?
让我们来回答这个问题。Go语言的优势之一在于它拥有丰富的工具生态系统,可以让我们的生活更加轻松便捷。
正如我们所见,该测试工具已与 Go 集成。这很方便,但例如,它对用户不太友好,也难以集成到所有 CI/CD 解决方案中。
这就是为什么gotestsum这个小巧的 Go 工具能够go test改进测试结果的显示,生成更易于阅读、更实用的报告,并可以直接输出 JUnit 格式的结果。这也是本文推荐的优秀实践之一 ;-)。
安装它:
$ go get gotest.tools/gotestsum
让我们执行task test使用该工具的命令gotestsum:
$ task test
task: [test] gotestsum --junitfile test-results/unit-tests.xml -- -short -race -cover -coverprofile test-results/cover.out ./...
∅ . (3ms)
∅ cmd
∅ pkg/gopher
DONE 0 tests in 1.409s
上面的代码表明我们使用 gotestsum 工具运行单元测试,并将测试结果以JUnit格式导出到名为test-results/unit-tests.xml.
以下是一个 JUnit 格式的测试结果文件示例:
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite tests="0" failures="0" time="0.000000" name="github.com/scraly/learning-go-by-examples/go-gopher-grpc" timestamp="2021-08-11T14:23:36+02:00">
<properties>
<property name="go.version" value="go1.16.5 darwin/amd64"></property>
</properties>
</testsuite>
<testsuite tests="0" failures="0" time="0.000000" name="github.com/scraly/learning-go-by-examples/go-gopher-grpc/cmd" timestamp="2021-08-11T14:23:36+02:00">
<properties>
<property name="go.version" value="go1.16.5 darwin/amd64"></property>
</properties>
</testsuite>
<testsuite tests="0" failures="0" time="0.000000" name="github.com/scraly/learning-go-by-examples/go-gopher-grpc/pkg/gopher" timestamp="2021-08-11T14:23:36+02:00">
<properties>
<property name="go.version" value="go1.16.5 darwin/amd64"></property>
</properties>
</testsuite>
</testsuites>
如何测试gRPC?
我们的应用是一个 gRPC 客户端/服务器架构,这意味着当我们调用该getGopher方法时,会触发客户端/服务器通信,但我们的单元测试中无需测试 gRPC 调用本身。我们只会测试应用的智能性。
正如我们所看到的,我们的 gRPC 服务器基于一个名为pkg/gopher/gopher.proto.
标准 Go 库提供了一个包,允许我们测试 Go 程序。Go 测试文件必须与要测试的文件放在同一文件夹中,并且以 .test 为_test.go扩展名。必须遵循此规则,以便 Go 可执行文件能够识别我们的测试文件。
第一步是创建一个server_test.go文件,并将其放置在旁边server.go。
我们将为这个测试文件命名包cmd_test,首先导入测试包,然后创建我们要测试的函数,如下所示:
package cmd_test
import "testing"
func TestGetGopher(t *testing.T) {
}
/!\警告:每个测试函数必须写成funcTest***(t *testing.T),其中***表示我们要测试的函数的名称。
让我们用表格驱动测试来编写测试
在我们的应用程序中,我们不会测试所有内容,而是从测试业务逻辑和应用程序的智能部分开始。在我们的应用程序中,我们关注的是其内部结构server.go,特别是以下GetGopher功能:
func (s *server) GetGopher(ctx context.Context, req *pb.GopherRequest) (*pb.GopherReply, error) {
res := &pb.GopherReply{}
...
如您所见,为了尽可能覆盖我们的代码,我们至少需要测试三种情况:
- 请求为空。
- 请求为空(名称字段为空)。
- 请求中已填写姓名栏。
表格驱动测试
我们将采用表格驱动测试的方法,而不是创建测试用例方法并复制粘贴,这将使工作变得容易得多。
编写好的测试并非易事,但在很多情况下,表格驱动测试可以覆盖很多内容:表格中的每个条目都是一个完整的测试用例,包含输入和预期结果。有时还会提供一些额外信息。测试输出易于阅读。如果您在编写测试时经常使用复制粘贴,不妨问问自己,重构为表格驱动测试是否是更好的选择。
给定一个测试用例表,实际测试只需扫描表中的所有条目,并对每个条目执行必要的测试。测试代码只需编写一次,之后便可对所有表条目重复执行。因此,编写包含清晰错误信息的全面测试变得更加容易。
首先,安装所需的外部依赖项:
$ go get github.com/onsi/gomega
让我们来定义一下我们的软件包及其依赖项:
package cmd_test
import (
"context"
"testing"
cmd "github.com/scraly/learning-go-by-examples/go-gopher-grpc/cmd"
pb "github.com/scraly/learning-go-by-examples/go-gopher-grpc/pkg/gopher"
. "github.com/onsi/gomega"
)
然后,我们在函数中定义测试用例TestGetGopher:
func TestGetGopher(t *testing.T) {
s := cmd.Server{}
testCases := []struct {
name string
req *pb.GopherRequest
message string
expectedErr bool
}{
{
name: "req ok",
req: &pb.GopherRequest{Name: "yoda-gopher"},
message: "https://raw.githubusercontent.com/scraly/gophers/main/yoda-gopher.png\n",
expectedErr: false,
},
{
name: "req with empty name",
req: &pb.GopherRequest{},
expectedErr: true,
},
{
name: "nil request",
req: nil,
expectedErr: true,
},
}
良好的做法是为测试用例命名,这样如果在执行过程中发生错误,就会显示测试用例的名称,方便我们查看错误所在。
然后,我遍历所有测试用例。我调用我的服务,并根据是否等待错误发生来测试它是否存在,否则测试结果是否符合预期:
for _, tc := range testCases {
testCase := tc
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
g := NewGomegaWithT(t)
ctx := context.Background()
// call
response, err := s.GetGopher(ctx, testCase.req)
t.Log("Got : ", response)
// assert results expectations
if testCase.expectedErr {
g.Expect(response).ToNot(BeNil(), "Result should be nil")
g.Expect(err).ToNot(BeNil(), "Result should be nil")
} else {
g.Expect(response.Message).To(Equal(testCase.message))
}
})
}
}
Aurélie,你的代码很棒!但是,为什么不直接使用 `i` 呢testCase,而要创建一个新的变量 `a` 并让它接收一个值 `b` 呢?tctc
简而言之,如果没有这一行代码,就会出现t.Parallel()Go 语言中一个众所周知的 bug——我们使用了一个位于 go 例程中的闭包。因此,原本应该执行三个测试用例:“req ok”、“req with empty name” 和 “nil request”,现在却会运行三个测试用例,但每次运行的值都与第一个测试用例相同 :-(。
那么,Gomega是什么?
Gomega是一个 Go 语言库,它允许你进行断言。在我们的示例中,我们检查获取到的值是 null、非 null 还是等于某个特定值,但 Gomega 库的功能远不止这些。
让我们运行单元测试吧!
要运行新创建的单元测试,如果您使用Visual Studio Code,可以直接在 IDE 中运行它们;非常方便:
首先,打开server_test.go文件。
绿色高亮部分是测试覆盖的代码——太棒了!红色线条部分是单元测试未覆盖的代码 ;-)。
否则,我们可以借助我们出色的 Taskfile,在命令行中运行项目的所有单元测试:
$ task test
task: [test] gotestsum --junitfile test-results/unit-tests.xml -- -short -race -cover -coverprofile test-results/cover.out ./...
∅ . (1ms)
✓ cmd (1.388s) (coverage: 41.5% of statements)
∅ pkg/gopher
DONE 4 tests in 7.787s
太好了,这是单元测试之旅的开始 :-)。
如果你习惯于在编写测试用例时复制粘贴,我建议你认真了解一下表格驱动测试 :-)。这确实是一种非常好的单元测试实践,正如我们所见,编写覆盖代码的单元测试变得轻而易举。
结论
正如你在本文和之前的文章中看到的,使用 Go 可以创建多个不同的应用程序……并且可以编写单元测试,而无需从 StackOverflow 复制粘贴代码 ;-)。
我们用 Go 语言编写的 gRPC 应用的所有代码都可以在这里找到:https://github.com/scraly/learning-go-by-examples/tree/main/go-gopher-grpc
在接下来的文章中,我们将用 Go 创建其他类型的应用程序。
希望你会喜欢。
文章来源:https://dev.to/aurelievache/learning-go-by-examples-part-6-create-a-grpc-app-in-go-2ja3








