你好,我是郑建勋。

这节课,让我们将Worker节点变为一个支持GRPC与HTTP协议访问的服务,让它最终可以被Master服务和外部服务直接访问。在Worker节点上线之后,我们还要将Worker节点注册到服务注册中心。

GRPC与Protocol buffers

一般要在微服务中进行远程通信,会选择 GRPC 或RESTful风格的协议。我们之前就提到过,GRPC的好处包括:

  • 使用了HTTP/2传输协议来传输序列化后的二进制信息,让传输速度更快;
  • 可以为不同的语言生成对应的Client库,让外部访问非常便利;
  • 使用 Protocol Buffers 定义API的行为,提供了强大的序列化与反序列化能力;
  • 支持双向的流式传输(Bi-directional streaming)。

GRPC默认使用 Protocol buffers 协议来定义接口,它有如下特点:

  • 它提供了与语言、框架无关的序列化与反序列化的能力;
  • 它序列化生成的字节数组比JSON更小,同时序列化与反序列化的速度也比JSON更快;
  • 有良好的向后和向前兼容性。

Protocol buffers 将接口语言定义在以 .proto为后缀的文件中,之后 proto 编译器结合特定语言的运行库生成特定的SDK。这个SDK文件有助于我们在Client端访问,也有助于我们生成GRPC Server。

现在让我们来实战一下Protocol buffers 协议。

**第一步,**书写一个简单的文件hello.proto:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
syntax = "proto3";
option go_package = "proto/greeter";

service Greeter {
rpc Hello(Request) returns (Response) {}
}

message Request {
string name = 1;
}

message Response {
string greeting = 2;
}

proto协议很容易理解:

  • syntax = "proto3"; 标识我们协议的版本,每个版本的语言可能会有所不同,目前最新的使用最多的版本是proto3,它的语法你可以查看官方文档
  • option go_package 定义生成的 Go 的 package 名;
  • service Greeter 定义了一个服务Greeter,它的远程方法为Hello,Hello参数为结构体Request,返回值为结构体Response。

要根据这个proto文件生成Go对应的协议文件,我们需要做一下前置的工作:下载 proto 的编译器protoc,安装 protoc 指定版本的方式可以查看官方的安装文档

同时,我们还需要安装 protoc 的Go语言的插件。

1
2
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

**第二步,**输入命令protoc进行编译,编译完成后生成了hello.pb.go与hello_grpc.pb.go两个协议文件。

1
protoc -I $GOPATH/src  -I . --go_out=.  --go-grpc_out=.  hello.proto

在hello_grpc.pb.go中,我们会看到生成的文件为我们自动生成了GreeterServer接口,接口中有Hello方法。

1
2
3
type GreeterServer interface {
Hello(context.Context, *Request) (*Response, error)
}

**第三步,**在我们的main函数中生成结构体Greeter,实现GreeterServer接口,然后调用生成协议文件中的pb.RegisterGreeterServer,将Greeter注册到GRPC server中。代码如下所示。要注意的是,xxx/proto/greeter需要替换为你自己的项目中协议文件的位置。

至此,我们就生成了一个GRPC服务了,该服务提供了Hello方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
pb "xxx/proto/greeter"
"log"
"net"

"google.golang.org/grpc"
)
type Greeter struct {
pb.UnimplementedGreeterServer
}

func (g *Greeter) Hello(ctx context.Context, req *pb.Request) (rsp *pb.Response, err error) {
rsp.Greeting = "Hello " + req.Name
return rsp, nil
}

func main() {
println("gRPC server tutorial in Go")

listener, err := net.Listen("tcp", ":9000")
if err != nil {
panic(err)
}

s := grpc.NewServer()
pb.RegisterGreeterServer(s, &Greeter{})
if err := s.Serve(listener); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

go-micro 与GRPC-gateway

刚才我们看到了原生的生成GRPC服务器的方法。不过在我们的项目中,我打算用另一个目前微服务领域比较流行的框架go-micro来实现我们的GRPC服务器。

相比原生的方式,go-micro拥有更丰富的生态和功能,更方便的工具和API。例如,在go-micro中,服务注册可以方便地切换到etcd、ZooKeeper、Gossip、NATS等注册中心,方便我们实现服务注册功能。Server端也同时支持GRPC、HTTP等多种协议。

要在go-micro中实现GRPC服务器,我们同样需要利用前面的 proto文件生成的协议文件。不过,go-micro在此基础上进行了扩展,我们需要下载protoc-gen-micro插件来生成micro适用的协议文件。这个插件的版本需要和我们使用的go-micro版本相同。目前,最新的 go-micro版本为v4,我们这个项目就用最新的版本来开发。所以,我们需要先下载protoc-gen-micro v4版本:

1
go install github.com/asim/go-micro/cmd/protoc-gen-micro/v4@latest

接着输入如下命名,生成一个新的文件hello.pb.micro.go:

1
protoc -I $GOPATH/src  -I .  --micro_out=. --go_out=.  --go-grpc_out=.  hello.proto

在hello.pb.micro.go中,micro生成了一个接口GreeterHandler,所以我们需要在代码中实现这个新的接口:

1
2
3
type GreeterHandler interface {
Hello(context.Context, *Request, *Response) error
}

用 go-micro 生成GRPC服务器的代码如下,Greeter结构体实现了GreeterHandler接口。代码调用pb.RegisterGreeterHandler将Greeter注册到micro生成的GRPC server中。另外如果要查看使用go-micro的样例,可以查看example库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
pb "xxx/proto/greeter"
"log"
"context"
"go-micro.dev/v4"
"google.golang.org/grpc"
)

type Greeter struct{}

func (g *Greeter) Hello(ctx context.Context, req *pb.Request, rsp *pb.Response) error {
rsp.Greeting = "Hello " + req.Name
return nil
}

func main() {
service := micro.NewService(
micro.Name("helloworld"),
)

service.Init()

pb.RegisterGreeterHandler(service.Server(), new(Greeter))

if err := service.Run(); err != nil {
log.Fatal(err)
}
}

但到这里我们还不满足。GRPC在调试的时候比HTTP协议要繁琐,而且有些外部服务可能不支持使用GRPC协议,为了解决这些问题,我们可以让服务同时具备 GRPC 与 HTTP 的能力。

要实现这一目的,我们需要借助一个第三方库:grpc-gateway。grpc-gateway的功能就是生成一个HTTP的代理服务,然后这个HTTP代理服务会将HTTP请求转换为GRPC的协议,并转发到GRPC服务器中。从而实现了服务同时暴露HTTP接口与GRPC接口的目的。

要实现grpc-gateway的能力,我们需要对proto文件进行改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
syntax = "proto3";
option go_package = "proto/greeter";
import "google/api/annotations.proto";

service Greeter {
rpc Hello(Request) returns (Response) {
option (google.api.http) = {
post: "/greeter/hello"
body: "*"
};
}
}

message Request {
string name = 1;
}

message Response {
string greeting = 2;
}

这里我们引入了一个依赖google/api/annotations.proto,并且加入了自定义的option选项,grpc-gateway的插件会识别到这个自定义选项,并为我们生成HTTP代理服务。

要生成指定的协议文件,我们需要先安装grpc-gateway的插件:

1
2
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest

同时,提前下载依赖文件:google/api/annotations.proto。在这里,我手动下载了依赖文件并放入到了GOPATH中:

1
2
git clone git@github.com:googleapis/googleapis.git
mv googleapis/google $(go env GOPATH)/src/google

最后,利用下面的指令将proto文件生成协议文件。要注意的是,这里我们同时加入了go-micro的插件和grpc-gateway的插件,两个插件之间可能存在命名冲突。所以我指定了grpc-gateway的选项 register_func_suffix 为 Gw,它能够让生成的函数名包含该Gw前缀,这就解决了命名冲突问题。

1
protoc -I $GOPATH/src  -I .  --micro_out=. --go_out=.  --go-grpc_out=.  --grpc-gateway_out=logtostderr=true,register_func_suffix=Gw:. hello.proto

这样我们就生成了4个文件,分别是hello.pb.go、hello.pb.gw.go、hello.pb.micro.go和hello_grpc.pb.go。 其中,hello.pb.gw.go就是 grpc-gateway 插件生成的文件。

接下来我们借助 go-micro 与 grpc-gateway 为项目生成具备GRPC与HTTP能力的服务器,代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import (
"context"
"fmt"
pb "xxx/proto/greeter"
gs "github.com/go-micro/plugins/v4/server/grpc"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"go-micro.dev/v4"
"go-micro.dev/v4/registry"
"go-micro.dev/v4/server"
"google.golang.org/grpc"
"log"
"net/http"
)

type Greeter struct{}

func (g *Greeter) Hello(ctx context.Context, req *pb.Request, rsp *pb.Response) error {
rsp.Greeting = "Hello " + req.Name
return nil
}

func main() {
// http proxy
go HandleHTTP()

// grpc server
service := micro.NewService(
micro.Server(gs.NewServer()),
micro.Address(":9090"),
micro.Name("go.micro.server.worker"),
)

service.Init()

pb.RegisterGreeterHandler(service.Server(), new(Greeter))

if err := service.Run(); err != nil {
log.Fatal(err)
}
}

func HandleHTTP() {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()

mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithInsecure()}

err := pb.RegisterGreeterGwFromEndpoint(ctx, mux, "localhost:9090", opts)
if err != nil {
fmt.Println(err)
}

http.ListenAndServe(":8080", mux)
}

其中,HandleHTTP 函数生成 HTTP 服务器,监听8080端口。同时,我们利用了 grpc-gateway 生成的 RegisterGreeterGwFromEndpoint 方法,指定了要转发到哪一个GRPC服务器。当访问该HTTP接口后,该代理服务器会将请求转发到GRPC服务器。

现在让我们来验证一下功能,我们使用HTTP协议去访问服务:

1
curl -H "content-type: application/json" -d '{"name": "john"}' http://localhost:8080/greeter/hello

返回结果如下:

1
2
3
{
"greeting": "Hello "
}

这就表明我们已经成功地使用HTTP请求访问到了GRPC服务器。

注册中心与etcd

刚才,我们将Worker变成了GRPC服务器,也看到了go-micro的使用方式,接下来让我们看看如何用go-micro完成服务的注册。

在go-micro中使用micro.NewService生成一个service。其中,service可以用option的模式注入参数。而 micro.NewService 有许多默认的option,默认情况下生成的服务器并不是GRPC类型的。为了生成GRPC服务器,我们需要导入go-micro的 GRPC插件库,生成一个GRPC server注入到 micro.NewService 中。同时,micro.Address指定了服务器监听的地址,而micro.Name表示服务器的名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import (
"go-micro.dev/v4"
"github.com/go-micro/plugins/v4/server/grpc"
)

func main() {
...
// grpc server
service := micro.NewService(
micro.Server(gs.NewServer()),
micro.Address(":9090"),
micro.Name("go.micro.server.worker"),
)
}

在micro.NewService中还可以注入register模块,用于指定使用哪一个注册中心。我们的项目中将使用etcd作为注册中心。为了在go-micro v4中使用etcd作为注册中心,我们需要导入etcd插件库,如下所示。这里的etcd注册模块仍然使用了option模式,registry.Addrs指定了当前etcd的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import (
etcdReg "github.com/go-micro/plugins/v4/registry/etcd"
)
func main() {
...
reg := etcdReg.NewRegistry(
registry.Addrs(":2379"),
)

service := micro.NewService(
micro.Server(gs.NewServer()),
micro.Address(":9090"),
micro.Registry(reg),
micro.Name("go.micro.server"),
)
}

接下来,让我们首先启动etcd服务器。启动服务器的方式有很多种,你可以参考官方文档。这里我利用Docker来启动一个etcd的服务器(关于Docker,我们在之后的章节会详细介绍):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
rm -rf /tmp/etcd-data.tmp && mkdir -p /tmp/etcd-data.tmp && \\
docker rmi gcr.io/etcd-development/etcd:v3.5.6 || true && \\
docker run \\
-p 2379:2379 \\
-p 2380:2380 \\
--mount type=bind,source=/tmp/etcd-data.tmp,destination=/etcd-data \\
--name etcd-gcr-v3.5.6 \\
gcr.io/etcd-development/etcd:v3.5.6 \\
/usr/local/bin/etcd \\
--name s1 \\
--data-dir /etcd-data \\
--listen-client-urls <http://0.0.0.0:2379> \\
--advertise-client-urls <http://0.0.0.0:2379> \\
--listen-peer-urls <http://0.0.0.0:2380> \\
--initial-advertise-peer-urls <http://0.0.0.0:2380> \\
--initial-cluster s1=http://0.0.0.0:2380 \\
--initial-cluster-token tkn \\
--initial-cluster-state new \\
--log-level info \\
--logger zap \\
--log-outputs stderr

要验证用Dokcer启动etcd服务器是否成功,功能是否正常,我们可以使用下面几条命令。这些命令会打印etcd的版本,并用一个简单的Key-Value值验证出put与get功能是正常的。

1
2
3
4
5
docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcd --version"
docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl version"
docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl endpoint health"
docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl put foo bar"
docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl get foo"

接下来,让我们启动go-micro构建的的GRPC服务器,服务的信息会注册到etcd中,并且会定时发送自己的健康状况用于保活。

下面让我们验证一下:

1
2
3
» docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl get --prefix /"                                                            jackson@jacksondeMacBook-Pro
/micro/registry/go.micro.server/go.micro.server-707c1d61-2c20-42b4-95a0-6d3e8473727e
{"name":"go.micro.server","version":"latest","metadata":null,"endpoints":[{"name":"Say.Hello","request":{"name":"Request","type":"Request","values":[{"name":"name","type":"string","values":null}]},"response":{"name":"Response","type":"Response","values":[{"name":"msg","type":"string","values":null}]},"metadata":{}}],"nodes":[{"id":"go.micro.server-707c1d61-2c20-42b4-95a0-6d3e8473727e","address":"192.168.0.107:9090","metadata":{"broker":"http","protocol":"grpc","registry":"etcd","server":"grpc","transport":"grpc"}}]}

这里,命令 get --prefix / 表示获取前缀为/的Key。 我们会发现,go-micro注册到etcd中的Key为 /micro/registry/c/go.micro.server-707c1d61-2c20-42b4-95a0-6d3e8473727e,其中go.micro.server是服务的名字,最后的一串ID是随机字符。

我们可以通过在生成server时指定特殊的ID来替换掉随机的ID,如下所示:

1
2
3
4
5
6
7
8
9
10
11
func main(){
...
service := micro.NewService(
micro.Server(gs.NewServer(
server.Id("1"),
)),
micro.Address(":9090"),
micro.Registry(reg),
micro.Name("go.micro.server.worker"),
)
}

这时会发现注册到etcd服务中的Key已经发生了变化:

1
2
3
» docker exec etcd-gcr-v3.5.6 /bin/sh -c "/usr/local/bin/etcdctl get --prefix /"                                                            jackson@jacksondeMacBook-Pro
/micro/registry/go.micro.server.worker/go.micro.server.worker-1
{"name":"go.micro.server.worker","version":"latest","metadata":null,"endpoints":[{"name":"Say.Hello","request":{"name":"Request","type":"Request","values":[{"name":"name","type":"string","values":null}]},"response":{"name":"Response","type":"Response","values":[{"name":"msg","type":"string","values":null}]},"metadata":{}}],"nodes":[{"id":"go.micro.server.worker-1","address":"192.168.0.107:9090","metadata":{"broker":"http","protocol":"grpc","registry":"etcd","server":"grpc","transport":"grpc"}}]}

上述完整的代码位于v0.3.0中。
最后,我们也可以用一个GRPC的客户端去访问我们的服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import (
grpccli "github.com/go-micro/plugins/v4/client/grpc"
"go-micro.dev/v4"
"go-micro.dev/v4/registry"
pb "xxx/proto/greeter"
}

func main() {
reg := etcdReg.NewRegistry(
registry.Addrs(":2379"),
)
// create a new service
service := micro.NewService(
micro.Registry(reg),
micro.Client(grpccli.NewClient()),
)

// parse command line flags
service.Init()

// Use the generated client stub
cl := pb.NewGreeterService("go.micro.server.worker", service.Client())

// Make request
rsp, err := cl.Hello(context.Background(), &pb.Request{
Name: "John",
})
if err != nil {
fmt.Println(err)
return
}

fmt.Println(rsp.Greeting)
}

这里 pb.NewGreeterService 的第一个参数代表服务器的注册名。如果运行后能够正常地返回结果,代表GRPC客户端访问GRPC服务器成功了。GRPC返回的结果如下所示:

1
2
» go run main.go     
Hello John

总结

好了,总结一下。这节课,我们为Worker服务构建了GRPC服务器和HTTP服务器。其中,HTTP服务器是用grpc-gateway生成的一个代理,它最终也会访问GRPC服务器。构建GRPC服务器需要安装一些必要的依赖,还要书写定义接口行为的proto文件。

在这节课的例子中,我们使用了go-micro微服务框架实现了GRPC服务器,它为微服务提供了比较丰富的能力,然后我们使用go-micro的插件将服务注册到了etcd注册中心当中。客户端可以通过服务器注册的服务名找到该服务并完成调用。如果同一个服务名找到了多个服务器,go-micro会默认使用负载均衡机制保障公平性。

课后题

这节课,我们用HTTP POST请求访问了HTTP代理服务服务器:

1
curl -H "content-type: application/json" -d '{"name": "john"}' http://localhost:8080/greeter/hello

返回结果如下:

1
2
3
{
"greeting": "Hello "
}

但是不知道你注意到没有,我们预期返回的信息应该是:

1
2
3
{
"greeting": "Hello john"
}

你知道是哪个地方出现了问题吗?

欢迎你跟我交流讨论,我们也会在后面修复这一问题。下节课见!