[关闭]
@phper 2021-01-12T09:32:59.000000Z 字数 27503 阅读 2919

go和php中的gRpc实战

Golang


实战演示一下gRpc的几种调用通讯模式(普通、客户端流、服务端流、双向流)。以及和PHP客户端的联通调用。

1. 需求分析

我们这次只搞个很简单的需求,搞个用户server系统,提供2个接口给外部,1个是保存用户信息,1个是根据用户UID查询用户信息,就这2个,不搞复杂了。

简单的用代码描述下就是:

  1. function1 saveUser(name, age) return (UID)
  2. function1 getUserInfo(UID) return (UID, name, age)

ok,需求分析结束。

2. 编写ptorobuf文件

需求分析结束之后,我们明确了我们需要干什么了,我们需要对外提供2个grp接口,那么我们就开始编写protobuf吧:

先定义2个rpc服务函数

  1. service UserServer {
  2. rpc SaveUser (UserParams) returns (Id) {}
  3. rpc GetUserInfo (Id) returns (UserInfo) {}
  4. }

定义好了rpc之后,我们知道,函数里面的参数和返回值得都得是message信息,OK,我们就开始创建参数和返回值对应的3个message吧。

  1. //用户ID
  2. message Id {
  3. int32 id = 1;
  4. }
  5. //名字
  6. message Name {
  7. string name = 1;
  8. }
  9. //年龄
  10. message Age {
  11. int32 age = 1;
  12. }
  13. // 用户信息
  14. message UserInfo {
  15. int32 id = 1;
  16. string name = 2;
  17. int32 age = 3;
  18. }
  19. // 用户参数
  20. message UserParams {
  21. Name name = 1;
  22. Age age = 2;
  23. }

上面的写法是,将age和name单独出来搞成2个message,然后在UserParams里面搞成嵌套message,目的是想看下嵌套message在不同语言中的用法。但是总体也是比较简单的。

完整的ptoro文件为:

  1. //userServer.proto
  2. syntax = "proto3";
  3. package proto;
  4. option go_package = ".;proto";
  5. message ...
  6. service ...

3. 编译成go版本的服务端和php版本的客户端文件

编写完成protobuf文件了,接下来就是编译成不同的语言了,我们将使用protoc命令,来编译生成不同语言的版本库。我们用go语言作为服务端语言,用php和go分别作为客户端语言,完成本次的调用。

如果你机器上还未安装protobuf工具,安装很简单,如果是Mac的话,一条命令就搞定了:

  1. brew install protobuf

如果是其他系统,请参考官方文档,也很简单。

安装好的服务,命令执行的关键字是 protoc --go_out= xx xx

3.1 编译成go版本

先编译生成go版本的服务端和客户端:

  1. protoc --go_out=plugins=grpc:. userServer.proto

需要注意的是我们需要加上grpc的支持,生成grpc服务的代码。如果你执行报错,可能是protoc-gen-go扩展没安装,安装很简单:

  1. go get -u github.com/golang/protobuf/protoc-gen-go

它会在$GOPATH/bin目录下生成1个可执行的protoc-gen-go文件。所以,这个文件不要删了。不然你使用--go_out 时,会找不到protoc-gen-go,提示报错。

OK,执行完毕之后,就会在proto目录下生成userServer.pg.go的文件,里面将proto里的message都转换成了go语言的struct,并且也把rpc也转换生成了2个可调用的客户端函数。

我们看下生成后目录结构,生成的ph.go文件在proto目录下。

  1. .
  2. ├── client
  3.    └── simple_client
  4.    └── client.go
  5. ├── go.mod
  6. ├── go.sum
  7. ├── proto
  8.    ├── userServer.pb.go
  9.    └── userServer.proto
  10. └── server
  11. └── simple_server
  12. └── server.go

3.2 编译成php客户端

我们在PHP里面去调用go提供的grpc服务,那么PHP就是一个客户端,同理,在PHP里面使用,其实也需要编译这个protobug文件,需要用到--php_out这个参数。

利用prcl快速安装protobuf和grpc这2个php扩展

  1. sudo pecl install protobuf
  2. sudo pecl install grpc

下载grpc源码,这一步是为了生成php的proto生成器,可以给我们生成client服务代码,当然你也可以自己去写代码,不用这个生成器。但是对于新手,我建议你下载安装。

  1. git clone -b v1.34.0 https://github.com/grpc/grpc #可能需要一段时间
  2. git submodule update --init #可能需要一段时间
  3. make grpc_php_plugin

php生成器位置生成在:/Users/Jack/www/grpc/bins/opt/grpc_php_plugin

我们把同一份userServer.proto文件,拷贝到我们的php环境目录下。然后执行命令,生成php和grpc服务类php文件:

  1. protoc -I=. userServer.proto --php_out=. --grpc_out=. --plugin=protoc-gen-grpc=/Users/Jack/www/grpc/bins/opt/grpc_php_plugin

在项目更目录下新建 composer.json 文件

  1. {
  2. "name": "grpc-go-php",
  3. "require": {
  4. "grpc/grpc": "^v1.34.0",
  5. "google/protobuf": "^v3.14.0"
  6. },
  7. "autoload": {
  8. "psr-4": {
  9. "GPBMetadata\\": "GPBMetadata/", //自动生成的php类文件夹
  10. "Proto\\": "Proto/" //proto的文件夹
  11. }
  12. }
  13. }

执行下载2个依赖的库。这2个库类似于sdk,它们封装了很多方法方便我们应用层去使用,它们底层会去调用上面用prcl生成的2个php扩展的方法。

  1. composer install

这一通,完成后,我们看下目录结构:

  1. ├── GPBMetadata
  2.    └── UserServer.php
  3. ├── Proto
  4.    ├── Age.php
  5.    ├── Id.php
  6.    ├── Name.php
  7.    ├── UserInfo.php
  8.    ├── UserParams.php
  9.    └── UserServerClient.php
  10. ├── composer.json
  11. ├── composer.lock
  12. ├── main.php
  13. ├── userServer.proto
  14. └── vendor
  15. ├── autoload.php
  16. ├── composer
  17. ├── google
  18.    └── protobuf
  19.   
  20. └── grpc
  21. └─

其中GPBMetadataProto文件夹是自动生成的。vendor里的2个扩展也是composer自动下载生成的。

OK,一切准备好了,go版本的服务端和客户端准备就绪。php版本的客户端也准备就绪。

4. 普通模式调用

普通模式,也叫一元模式,它是最常见,也是使用做多的方式,和普通的http请求模式是一样的。

客户端像服务端发送1次请求,然后服务端给出响应结果。很简单的模式。

  1. client --1---> server
  2. client <--2--- server

OK,那我们来看下,我们如何实现这种普通模式的调用。

4.1 go语言的调用实现

我们先用go来实现,上面go的服务端和客户端代码都生成好了。我们先在serverclient目录下,分别新建自己的代码:

  1. .
  2. ├── client
  3.    └── simple_client
  4.    └── client.go
  5. └── server
  6. └── simple_server
  7. └── server.go

我们先来完成server端的代码逻辑,server端上面的逻辑,主要分3方面:

  1. 1. 新建tcp连接。
  2. 2. 注册grpc服务,并把它挂到tcp上。
  3. 3. 完成对外提供的几个rpc方法的逻辑。

我们就按照这个逻辑来开始写:

  1. package main
  2. import (
  3. "context"
  4. "fmt"
  5. "go-grpc-example/proto"
  6. "google.golang.org/grpc"
  7. "log"
  8. "math/rand"
  9. "net"
  10. )
  11. //新建1个结构体,下面绑定了2个方法,实现了UserServerServer接口。
  12. type UserServer struct{}
  13. func main() {
  14. //监听tcp
  15. listen, err := net.Listen("tcp", "127.0.0.1:9527")
  16. if err != nil {
  17. log.Fatalf("tcp listen failed:%v", err)
  18. }
  19. //新建grpc
  20. server := grpc.NewServer()
  21. fmt.Println("userServer grpc services start success")
  22. //rcp方法注册到grpc
  23. proto.RegisterUserServerServer(server, &UserServer{})
  24. //监听tcp
  25. _ = server.Serve(listen)
  26. }
  27. //保存用户
  28. //第一个参数是固定的context
  29. func (Service *UserServer) SaveUser(ctx context.Context, params *proto.UserParams) (*proto.Id, error) {
  30. id := rand.Int31n(100) //随机生成id 模式保存成功
  31. res := &proto.Id{Id: id}
  32. fmt.Printf("%+v", params.GetAge())
  33. fmt.Printf("%+v\n", params.GetName())
  34. return res, nil
  35. }
  36. //获取用户信息
  37. //第一个参数是固定的context
  38. func (Service *UserServer) GetUserInfo(ctx context.Context, id *proto.Id) (*proto.UserInfo, error) {
  39. res := &proto.UserInfo{Id: id.GetId(), Name: "test", Age: 31}
  40. return res, nil
  41. }

2个rpc方法很简单,只是mock数据,并没有真实实现。后期有时间,再来实现吧。值得注意是,rpc的函数,第一个参数是固定的ctx context.Context,这是用于控制信号和超时的,是固定写法。有空我专门来搞一起context包的学习

我们运行一下, grpc服务启动成功:

  1. $ go run server/simple_server/server.go
  2. userServer grpc services start success

在来看看client端,如何利用生成的pb.go文件,来实现逻辑呢?也是一样

  1. 1. 监听server启动的tcpip:端口。
  2. 2. 新建连接client服务,并绑定tcp
  3. 3. 去调用这2rpc的函数。

开始写逻辑

  1. package main
  2. import (
  3. "context"
  4. "fmt"
  5. "go-grpc-example/proto"
  6. "google.golang.org/grpc"
  7. "log"
  8. )
  9. var client proto.UserServerClient
  10. func main() {
  11. //链接tcp端口
  12. connect, err := grpc.Dial("127.0.0.1:9527", grpc.WithInsecure())
  13. if err != nil {
  14. log.Fatalln(err)
  15. }
  16. //新建client
  17. client = proto.NewUserServerClient(connect)
  18. //调用
  19. SaveUser()
  20. GetUserInfo()
  21. }
  22. func SaveUser() {
  23. params := proto.UserParams{}
  24. params.Age = &proto.Age{Age: 31}
  25. params.Name = &proto.Name{Name: "test"}
  26. res, err := client.SaveUser(context.Background(), &params)
  27. if err != nil {
  28. log.Fatalf("client.SaveUser err: %v", err)
  29. }
  30. fmt.Printf("%+v\n", res.Id)
  31. }
  32. func GetUserInfo() {
  33. res, err := client.GetUserInfo(context.Background(), &proto.Id{Id: 1})
  34. if err != nil {
  35. log.Fatalf("client.userInfo err: %v", err)
  36. }
  37. fmt.Printf("%+v\n", res)
  38. }

调用的rpc的方法,是pb.go文件里已经帮我们生成了,我们直接调用即可。这2个函数的参数和返回值,和刚在server定义的得保持一致。第一个参数得是context.Context

我们测试一下client的代码,是否可以调通:

  1. $ go run client/simple_client/client.go
  2. 23
  3. id:1 name:"test" age:31

我们可以看到有返回值了,在server那边也有了打印:

  1. $ go run server/simple_server/server.go
  2. userServer grpc services start success
  3. age:31name:"test"
  4. age:31name:"test"
  5. age:31name:"test"
  6. age:31name:"test"

ok,到此为止,go版本的客户端和服务端的grpc通讯成功了。

4.2 php语言的客户端调用

我们再用php来调用go的grpc服务,看下具体是怎么操作。首先,我们在上面已经自动帮我们生成了grpc的client的server类,那我们就直接调用。

操作逻辑,和go client的步骤是一样的,分成2步:

  1. 1. 监听server启动的tcpip:端口,并直接新建连接client服务
  2. 2. 去调用这2rpc的函数。

我们具体看下,代码应该怎么写:

  1. <?php
  2. //引入 composer 的自动载加
  3. require __DIR__ . '/vendor/autoload.php';
  4. //连接Grpc服务端
  5. //并连接客户端
  6. $client = new \Proto\UserServerClient('127.0.0.1:9527', [
  7. 'credentials' => Grpc\ChannelCredentials::createInsecure()
  8. ]);
  9. //实例化 $UserParams 请求类
  10. $UserParams = new \Proto\UserParams();
  11. $age = new \Proto\Age();
  12. $age->setAge("18");
  13. $UserParams->setAge($age);
  14. $name = new \Proto\Name();
  15. $name->setName("jack");
  16. $UserParams->setName($name);
  17. //调用远程服务
  18. /**
  19. * @var $Id \Proto\Id
  20. */
  21. list($Id, $status) = $client->SaveUser($UserParams)->wait();
  22. var_dump($status, $Id->getId());
  23. //实例化Id类
  24. $Id = new \Proto\Id();
  25. //赋值
  26. $Id->setId("1");
  27. //调用
  28. /**
  29. * @var $User \Proto\UserInfo
  30. */
  31. list($UserInfo, $status) = $client->GetUserInfo($Id)->wait();
  32. var_dump($status, $UserInfo->getId(), $UserInfo->getName(), $UserInfo->getAge());

调用方式,看上去写法有点难受,没有go简洁,这是因为,php的调用方式都是用的方式呈现的,所以调用传值以及返回的都是对应的类。所以只能用setXXX()这种模式来赋值的,以及用getXXX()方式来获取值。

我们执行一下:

  1. php client.php
  2. 0, 58
  3. 1, test, 31

我们再去go server端看下输出:

  1. age:18name:"jack"
  2. age:18name:"jack"
  3. age:18name:"jack"
  4. age:18name:"jack"

ok,php作为客户端调用go的grpc server成功!

5. Client-side streaming RPC 客户端流模式调用

什么是客户端流呢?也就是客户端在一次请求中,不断的将内容像流水一样,传给服务端。而服务端,则是需要不段的循环获取数据。

  1. client -1-> -2-> -3-> -4-> server
  2. client <--------1---------- server

为什么会有这种场景,因为存在一个痛点,就是客户端大包发送,

  1. 如果一次性发送大包,极有可能超时或者丢包,而通过流水的方式不断的发给服务端,则能保证实时性和安全性。
  2. 接收端还一边接收数据一边处理数据。也能保证数据的及时性。

所以,流的方式传输还是有很大的使用场景的。那我们先来看看,流式调用有啥不同的地方。

最大的不同就是在protobuf文件中,定义一个rpc 函数时候,得加一个stream关键字,就表示这是一个流媒体的传输调用。

  1. service UserServer {
  2. rpc SaveUser (stream UserParams) returns (Id) {} //客户端流
  3. rpc GetUserInfo (Id) returns (stream UserInfo) {} //服务端流
  4. rpc DeleteUser(stream Id) returns (stream Status){} //双向流
  5. }

上面我们定义了3个rpc方法,如果是在函数的参数前,加stream,表示是客户端发送流式的请求,反之,如果是返回的参数前面,加stream,表示是客户端发送流式的请求。同理,2个都加,则表示是双向的,2边都在流式的发送,这种情况就复杂一些,我们分别来试一试。

5.1 go语言的客户端流调用

我们先完善一下protobuf文件,我们在proto目录下新建1个stream流式的文件,名字叫:streamArticleServer.proto:

  1. syntax = "proto3";
  2. package proto;
  3. option go_package = ".;proto";
  4. //ID
  5. message Aid {
  6. int32 id = 1;
  7. }
  8. //作者
  9. message Author {
  10. string author = 1;
  11. }
  12. //标题
  13. message Title {
  14. string title = 1;
  15. }
  16. //内容
  17. message Content {
  18. string content = 1;
  19. }
  20. // 文章信息
  21. message ArticleInfo {
  22. int32 id = 1;
  23. string author = 2;
  24. string title = 3;
  25. string content = 4;
  26. }
  27. // 保存文章信息
  28. message ArticleParam {
  29. Author author = 2;
  30. Title title = 3;
  31. Content content = 4;
  32. }
  33. //删除状态
  34. message Status{
  35. bool code = 1;
  36. }
  37. // 声明那些方法可以使用rpc
  38. service ArticleServer {
  39. rpc SaveArticle (stream ArticleParam) returns (Aid) {}
  40. rpc GetArticleInfo (Aid) returns (stream ArticleInfo) {}
  41. rpc DeleteArticle(stream Aid) returns (stream Status){}
  42. }
  43. //执行 : protoc --go_out=plugins=grpc:. streamArticleServer.proto

然后,我们执行一下,生成对应的go服务端和客户端文件:

  1. protoc --go_out=plugins=grpc:. streamArticleServer.proto

这样,就在proto目录下,生成了一个新的streamArticleServer.pb.go文件,grpc需要的client和server相关的调用都在这里面生成好了。

由于,我们本次只是看客户端流调用,那么我们只看SaveArticle这个方法。

接下来,我们开始写client和server的调用代码。

我们先来完成server端的代码逻辑,server端上面的逻辑,主要分3方面:

  1. 1. 新建tcp连接。
  2. 2. 注册grpc服务,并把它挂到tcp上。
  3. 3. 完成对外提供的几个rpc方法的逻辑。

这和普通的server是一样的,只是在处理具体的请求的时候,会用for 循环

  1. package main
  2. //流式服务
  3. import (
  4. "fmt"
  5. "go-grpc-example/proto"
  6. "google.golang.org/grpc"
  7. "log"
  8. "net"
  9. )
  10. type StreamArticleServer struct {
  11. }
  12. func (server *StreamArticleServer) SaveArticle(stream proto.ArticleServer_SaveArticleServer) error {
  13. return nil
  14. }
  15. func (server *StreamArticleServer) GetArticleInfo(aid *proto.Aid, stream proto.ArticleServer_GetArticleInfoServer) error {
  16. return nil
  17. }
  18. func (server *StreamArticleServer) DeleteArticle(stream proto.ArticleServer_DeleteArticleServer) error {
  19. return nil
  20. }
  21. func main() {
  22. listen, err := net.Listen("tcp", "127.0.0.1:9527")
  23. if err != nil {
  24. log.Fatalf("tcp listen failed:%v", err)
  25. }
  26. server := grpc.NewServer()
  27. proto.RegisterArticleServerServer(server, &StreamArticleServer{})
  28. fmt.Println("article stream Server grpc services start success")
  29. _ = server.Serve(listen)
  30. }

我们先把架子搭起来,值得注意的是:绑定3个方法的时候,他们的参数,和普通的模式不一样了

我们做的时候,可以看下 RegisterArticleServerServer(s *grpc.Server, srv ArticleServerServer)的第二个参数 ArticleServerServer,可以看下,这个接口是怎么写的,因为我们需要实现这个接口。

接口定义如下:

  1. type ArticleServerServer interface {
  2. SaveArticle(ArticleServer_SaveArticleServer) error
  3. GetArticleInfo(*Aid, ArticleServer_GetArticleInfoServer) error
  4. DeleteArticle(ArticleServer_DeleteArticleServer) error
  5. }

这样,我们自己去实现这3个接口的时候,也就知道自己的参数和返回值怎么写了。这也算是一种学习方法吧。

好了。架子搭完,我们就来实现以下client流的情况下,sever的实现方式,也就是 SaveArticle这个函数的内容实现:

  1. func (server *StreamArticleServer) SaveArticle(stream proto.ArticleServer_SaveArticleServer) error {
  2. for {
  3. id := rand.Int31n(100)
  4. r, err := stream.Recv()
  5. if err == io.EOF {
  6. fmt.Println("读取数据结束")
  7. res := &proto.Aid{Id: id}
  8. return stream.SendAndClose(res)
  9. }
  10. if err != nil {
  11. return err
  12. }
  13. fmt.Printf("stream.rev author: %s, title: %s, context: %s", r.Author.GetAuthor(), r.Title.GetTitle(), r.Content.GetContent())
  14. }
  15. }

首先是有个for 循环,源源不断的接受client的流数据,然后通过判断err == io.EOF来判断客户端额流水结束。然后整体返回1个随机的ID mock假数据。这样,server端就实现完毕了。

那接着我们来实现client流怎么写。

老规矩,先定义出client的架子:

  1. package main
  2. import (
  3. "context"
  4. "fmt"
  5. "go-grpc-example/proto"
  6. "google.golang.org/grpc"
  7. "log"
  8. )
  9. var client proto.ArticleServerClient
  10. func main() {
  11. connect, err := grpc.Dial("127.0.0.1:9527", grpc.WithInsecure())
  12. if err != nil {
  13. log.Fatal("connect grpc fail")
  14. }
  15. defer connect.Close()
  16. client = proto.NewArticleServerClient(connect)
  17. //SaveArticle()
  18. //GetArticleInfo()
  19. //DeleteArticle()
  20. }
  21. func SaveArticle() {
  22. }
  23. func GetArticleInfo() {
  24. }
  25. func DeleteArticle() {
  26. }

前面的链接grpc的部分和普通的模式是一样的。重点是本次的client流式该怎么写呢?也就是SaveArticle方法的写法:

  1. func SaveArticle() {
  2. //定义一组数据
  3. SaveList := map[string]proto.ArticleParam{
  4. "1": {Author: &proto.Author{Author: "tony"}, Title: &proto.Title{Title: "title1"}, Content: &proto.Content{Content: "content1"}},
  5. "2": {Author: &proto.Author{Author: "jack"}, Title: &proto.Title{Title: "title2"}, Content: &proto.Content{Content: "content2"}},
  6. "3": {Author: &proto.Author{Author: "tom"}, Title: &proto.Title{Title: "title3"}, Content: &proto.Content{Content: "content3"}},
  7. "4": {Author: &proto.Author{Author: "boby"}, Title: &proto.Title{Title: "title4"}, Content: &proto.Content{Content: "content4"}},
  8. }
  9. //先调用函数
  10. stream, err := client.SaveArticle(context.Background())
  11. if err != nil {
  12. log.Fatal("SaveArticle grpc fail", err.Error())
  13. }
  14. //再循环发送
  15. for _, info := range SaveList {
  16. err = stream.Send(&info)
  17. if err != nil {
  18. log.Fatal("SaveArticle Send info fail", err.Error())
  19. }
  20. }
  21. //发送关闭新号,并且获取返回值
  22. resp, err := stream.CloseAndRecv()
  23. if err != nil {
  24. log.Fatal("SaveArticle CloseAndRecv fail", err.Error())
  25. }
  26. fmt.Printf("resp: id = %d", resp.GetId())
  27. }

这里需要注意的是,我们搞了个map,来循环给server发送数据,然后再就是关闭新号发送,再接受数据。可以看出,和server的方法是反着来的。也比较好记。

我们测试一下。首先是启动server,启动成功:

  1. $ go run server/stream_server/server.go
  2. article stream Server grpc services start success

然后,我们编辑client.go里面main函数里的SaveArticle的注释,客户端执行调用一下:

  1. $ go run client/stream_client/client.go
  2. resp: id = 81

调用成功,返回ID = 81。

再看下server那边的输出:

  1. $ go run server/stream_server/server.go
  2. article stream Server grpc services start success
  3. stream.rev author: jack, title: title2, context: content2
  4. stream.rev author: tom, title: title3, context: content3
  5. stream.rev author: boby, title: title4, context: content4
  6. stream.rev author: tony, title: title1, context: content1
  7. 读取数据结束

OK,server 也输出正常。完全联通!

当然,你说,客户端是流模式,就一定得搞个for循环去发送数据么?当然不是!

  1. func SaveArticle2() {
  2. //定义一组数据
  3. SaveInfo := proto.ArticleParam {
  4. Author: &proto.Author{Author: "mark"}, Title: &proto.Title{Title: "title5"}, Content: &proto.Content{Content: "content5"},
  5. }
  6. //先调用函数
  7. stream, err := client.SaveArticle(context.Background())
  8. if err != nil {
  9. log.Fatal("SaveArticle grpc fail", err.Error())
  10. }
  11. //发送
  12. err = stream.Send(&SaveInfo)
  13. if err != nil {
  14. log.Fatal("SaveArticle Send info fail", err.Error())
  15. }
  16. ////发送关闭新号,并且获取返回值
  17. resp, err := stream.CloseAndRecv()
  18. if err != nil {
  19. log.Fatal("SaveArticle CloseAndRecv fail", err.Error())
  20. }
  21. fmt.Printf("resp: id = %d", resp.GetId())
  22. }

这样也是可以的。相当于数组是1。循环了1次而已。

5.2 php语言的客户端流调用

我们看下php版本的client流模式如何写呢?直接上代码吧:

  1. <?php
  2. //引入 composer 的自动载加
  3. require __DIR__ . '/vendor/autoload.php';
  4. SaveArticle();
  5. function SaveArticle()
  6. {
  7. //连接 gRpc服务端
  8. $client = new \Proto\ArticleServerClient('127.0.0.1:9527', [
  9. 'credentials' => Grpc\ChannelCredentials::createInsecure()
  10. ]);
  11. //请求 SaveArticle 方法
  12. $stream = $client->SaveArticle();
  13. $ArticleParam = new \Proto\ArticleParam();
  14. //循环流式写入数据
  15. for ($i = 0; $i < 10; $i++) {
  16. $ArticleParam->setAuthor((new \Proto\Author())->setAuthor("kevin1"));
  17. $ArticleParam->setTitle((new \Proto\Title())->setTitle("title_php_" . $i));
  18. $ArticleParam->setContent((new \Proto\Content())->setContent("content_php_" . $i));
  19. $stream->write($ArticleParam);
  20. }
  21. //关闭并返回结果
  22. /**
  23. * @var $aid \proto\Aid
  24. */
  25. list($aid, $status) = $stream->wait();
  26. //打印AID
  27. var_dump($aid->getId());
  28. }

我们执行一下,打印:98,同时服务端server也输出了响应的流水数据:

  1. stream.rev author: kevin1, title: title_php_0, context: content_php_0
  2. stream.rev author: kevin1, title: title_php_1, context: content_php_1
  3. stream.rev author: kevin1, title: title_php_2, context: content_php_2
  4. stream.rev author: kevin1, title: title_php_3, context: content_php_3
  5. stream.rev author: kevin1, title: title_php_4, context: content_php_4
  6. stream.rev author: kevin1, title: title_php_5, context: content_php_5
  7. stream.rev author: kevin1, title: title_php_6, context: content_php_6
  8. stream.rev author: kevin1, title: title_php_7, context: content_php_7
  9. stream.rev author: kevin1, title: title_php_8, context: content_php_8
  10. stream.rev author: kevin1, title: title_php_9, context: content_php_9
  11. 读取数据结束

PHP的client的很多地方处理方式,和go client 很类似,比如,先调用client->SaveArticle(),参数是空,啥也不传,然后再循环写入(write/Send)。然后,再发送关闭,等待结果返回。

6. 服务端流模式调用

有了前面客户端流模式的铺垫,服务端流模式就简单了很多。无非是将之前的操作反过来操作下。server不断的循环发送给client,然后client循环接受。套路是一样的。整个通讯过程就变成了这样:

  1. client ----1------------------> server
  2. client <-5- <-4- <-3- <-2- <-1- server

6.1 go语言的服务端流调用

我们先来看下go服务端代码的编写,也就是实现server.go中GetArticleInfo函数里面的内容。

  1. func (server *StreamArticleServer) GetArticleInfo(aid *proto.Aid, stream proto.ArticleServer_GetArticleInfoServer) error {
  2. for i := 0; i < 6; i++ {
  3. id := strconv.Itoa(int(aid.GetId()))
  4. err := stream.Send(&proto.ArticleInfo{
  5. Id: aid.GetId(),
  6. Author: "jack",
  7. Title: "title_go_" + id,
  8. Content: "content_go_" + id,
  9. })
  10. if err != nil {
  11. return err
  12. }
  13. }
  14. fmt.Println("发送完毕")
  15. return nil
  16. }

单纯的服务端流,就比较简单,1个for循环6次,每次send数据即可,也不需要关闭。

我们再看下go client怎么实现呢?也就是client.go中现实GetArticleInfo函数里面的内容。

  1. func GetArticleInfo() {
  2. Aid := proto.Aid{
  3. Id: 2,
  4. }
  5. //请求
  6. stream, err := client.GetArticleInfo(context.Background(), &Aid)
  7. if err != nil {
  8. log.Fatal("GetArticleInfo grpc fail", err.Error())
  9. }
  10. //循环接受server流发来数据
  11. for {
  12. r, err := stream.Recv()
  13. if err == io.EOF {
  14. fmt.Println("读取数据结束")
  15. break
  16. }
  17. if err != nil {
  18. log.Fatal("GetArticleInfo Recv fail", err.Error())
  19. }
  20. fmt.Printf("stream.rev aid: %d, author: %s, title: %s, context: %s\n", r.GetId(), r.GetAuthor(), r.GetTitle(), r.GetContent())
  21. }
  22. }

client代码也同样比较简单,搞个for死循环去Recv就可以了。判断是否是EOF了,则表示sever发送结束了,就可以跳出循环,结束。

我们运行下,看下client的输出:

  1. $ go run client/stream_client/client.go
  2. stream.rev aid: 2, author: jack, title: title_go_2, context: content_go_2
  3. stream.rev aid: 2, author: jack, title: title_go_2, context: content_go_2
  4. stream.rev aid: 2, author: jack, title: title_go_2, context: content_go_2
  5. stream.rev aid: 2, author: jack, title: title_go_2, context: content_go_2
  6. stream.rev aid: 2, author: jack, title: title_go_2, context: content_go_2
  7. stream.rev aid: 2, author: jack, title: title_go_2, context: content_go_2
  8. 读取数据结束

server端的输出:

  1. $ go run server/stream_server/server.go
  2. article stream Server grpc services start success
  3. 发送完毕

OK ,go语言版本的client和server通宵成功,结束!

6.2 php语言的服务端流调用

由于,我们本次只要PHP作为client调用,所以,我们只看下PHP如何接受go的server流的数据,其实和前面的client server 类似,反过来即可。我们直接看下GetArticleInfo函数代码怎么实现:

  1. function GetArticleInfo()
  2. {
  3. //连接 gRpc服务端
  4. $client = new \Proto\ArticleServerClient('127.0.0.1:9527', [
  5. 'credentials' => Grpc\ChannelCredentials::createInsecure()
  6. ]);
  7. //请求 SaveArticle 方法
  8. $stream = $client->GetArticleInfo((new \Proto\Aid())->setId("668"));
  9. //获取服务流的数据
  10. $features = $stream->responses();
  11. //循环遍历打印出来
  12. /**
  13. * @var $feature \proto\ArticleInfo
  14. */
  15. foreach ($features as $feature) {
  16. echo $feature->getId() . "--" . $feature->getAuthor() . "--" . $feature->getTitle() . "--" . $feature->getContent() . PHP_EOL;
  17. }
  18. }

需要注意的地方就是语法的改变,普通模式是使用wait方法就可以直接获取结果了,在服务流模式下,client写法就不一样了,得先response,再foreach循环这个值。

我们运行下:

  1. $ php stream.php
  2. 668--jack--title_go_668--content_go_668
  3. 668--jack--title_go_668--content_go_668
  4. 668--jack--title_go_668--content_go_668
  5. 668--jack--title_go_668--content_go_668
  6. 668--jack--title_go_668--content_go_668
  7. 668--jack--title_go_668--content_go_668

OK,客户端获取数据成功,这些数据都是server通过流模式传输过来的。再看下server端的输出:

  1. $ go run server/stream_server/server.go
  2. article stream Server grpc services start success
  3. 668 发送完毕

好了,整个通讯就完成了。

7. 双向流模式调用

双向模式顾名思义,就是client和server都是流水模式,2边一起流水。

  1. client -1-> -2-> -3-> --4> -5-> server
  2. client <-5- <-4- <-3- <-2- <-1- server

通过前面的单独的流水模式,我们应该可以猜到代码该怎么写了,无非就是把之前的send啊,recv啊一起上呗,for循环也一起都用。下面开始写。

7.1 go语言的双向流流调用

首先是go语言的服务端的写法,我们直接写吧,也就是实现函数DeleteArticle内的方法实现:

  1. //双端
  2. func (server *StreamArticleServer) DeleteArticle(stream proto.ArticleServer_DeleteArticleServer) error {
  3. for {
  4. //循环接收client发送的流数据
  5. r, err := stream.Recv()
  6. if err == io.EOF {
  7. fmt.Println("read done!")
  8. return nil
  9. }
  10. if err != nil {
  11. return err
  12. }
  13. fmt.Printf("stream.rev aid: %d\n", r.GetId())
  14. //循环发流数据给client
  15. err = stream.Send(&proto.Status{Code: true})
  16. if err != nil {
  17. return err
  18. }
  19. //fmt.Println("send done!")
  20. }
  21. }

代码也比较好理解,先1个for循环,里面先去Rev,再去Send。当然,反着来也是可以的。

我们再看下client怎么实现:

  1. //双向流
  2. func DeleteArticle() {
  3. //链接rpc
  4. stream, err := client.DeleteArticle(context.Background())
  5. if err != nil {
  6. log.Fatal("DeleteArticle grpc fail", err.Error())
  7. }
  8. for i := 0; i < 6; i++ {
  9. //先发
  10. err = stream.Send(&proto.Aid{Id: int32(i)})
  11. if err != nil {
  12. log.Fatal("DeleteArticle Send fail", err.Error())
  13. }
  14. //再收
  15. r, err := stream.Recv()
  16. if err == io.EOF {
  17. break
  18. }
  19. if err != nil {
  20. log.Fatal("GetArticleInfo Recv fail", err.Error())
  21. }
  22. fmt.Printf("stream.rev status: %v\n", r.GetCode())
  23. }
  24. //发送结束
  25. _ = stream.CloseSend()
  26. }

客户端也差不多,先来1个6for循环,然后先Send,再Recv。这次不能反着来,不能就阻塞了。for 循环结束后,可以主动发送一个CloseSend,这样server就可以手动手动EOF的信息了。

我们先运行server,再运行client,看下打印输出:

  1. #client
  2. $ go run client/stream_client/client.go
  3. stream.rev status: true
  4. stream.rev status: true
  5. stream.rev status: true
  6. stream.rev status: true
  7. stream.rev status: true
  8. stream.rev status: true

再看下server端的输出:

  1. $ go run server/stream_server/server.go
  2. article stream Server grpc services start success
  3. stream.rev aid: 0
  4. stream.rev aid: 1
  5. stream.rev aid: 2
  6. stream.rev aid: 3
  7. stream.rev aid: 4
  8. stream.rev aid: 5
  9. read done!

OK,通讯成功!

7.2 php语言的双向流流调用

go的client已经OK了,我们继续看下PHP作为client的情况。直接上代码:

  1. function DeleteArticle()
  2. {
  3. //连接 gRpc服务端
  4. $client = new \Proto\ArticleServerClient('127.0.0.1:9527', [
  5. 'credentials' => Grpc\ChannelCredentials::createInsecure()
  6. ]);
  7. //请求 SaveArticle 方法
  8. $stream = $client->DeleteArticle();
  9. $AidParam = new \Proto\Aid();
  10. //循环流式写入数据
  11. for ($i = 0; $i < 6; $i++) {
  12. $AidParam->setId($i);
  13. $stream->write($AidParam);
  14. }
  15. //写入结束
  16. $stream->writesDone();
  17. /**
  18. * @var $reply \proto\Status
  19. */
  20. while ($reply = $stream->read()) {
  21. var_dump($reply->getCode());
  22. }
  23. }

写法和go的client稍有不同,先自己for 6次写,再调用writesDone写入结束,再while循环read,打印出code信息。

我们运行下:

  1. $ php stream.php
  2. bool(true)
  3. bool(true)
  4. bool(true)
  5. bool(true)
  6. bool(true)
  7. bool(true)

服务端:

  1. stream.rev aid: 0
  2. stream.rev aid: 1
  3. stream.rev aid: 2
  4. stream.rev aid: 3
  5. stream.rev aid: 4
  6. stream.rev aid: 5
  7. read done!

OK,整个grpc的通讯和夸语言的调用就结束了,还是收获满满的。接下来,我们要看下grpc tls加密通讯,以及设置超时的context,还有就是如何同时提供http的Restful的接口方式,以及如何部署,服务发现以及负载均衡的实现。

8. TLS加密通讯

上面的这些例子都是讲的明文通讯,在某些情况下很容易被截获的,还是有点危险的。因为grpc是基于http2的,所以我们看下,如何配置tls,使其支持https的特性呢?

那么回顾下,https的核心逻辑:

  1. 1. server 采用非对称加密,生成一个公钥 public1 和私钥 private1
  2. 2. server 把公钥 public1 传给 client
  3. 3. client 采用对称加密生成1个秘钥A (或者2个秘钥A,内容都是一样)
  4. 4. client server给自己的公钥 public1 加密自己生成的对称秘钥A。生成了一个秘钥B.
  5. 5. client 把秘钥 B 传给server
  6. 6. client 秘钥A 加密需要传输的数据Data,并传给server
  7. 7. server 收到 秘钥B后,用自己的私钥 private1 解开了,得到了秘钥A
  8. 8. server 收到加密后的data 后,用秘钥A解开了,获得了元素数据。

简而言之,就是采用非对称加密+对称加密的方式。其中,对称加密产生的秘钥,是既可以加密,又可以解密的,加密解密速度很快。而采用非对称加密,则不可以,必须公钥解密私钥,或者私钥加密公钥,加解密速度慢。这样一个组合,就可以保障数据得到加密,又不会影响速度。

OK,知道了原理之后,我们看下具体在代码里如何实现。

首先,我们要生成server的 公钥public1私钥 private1。那就得用到openssl命令了。

需要注意的是Go在1.15版本,X509 无法使用了,需要用Sans算法代替, 具体的操作可参考这篇文章:Go1.15下Sans 秘钥生成过程

这样,我们就得到了2个key,1个是test.pem,它就是公钥。 1个是test.key,它是私钥。其中,我们设置openssl.cnf中alt_names为:

  1. [ alt_names ]
  2. DNS.1 = www.zchd.ltd
  3. DNS.2 = www.test.zchd.ltd

顾明思意,设置的通用名称是这个。这个在client调用中会用到,不清楚先别急。

8.1 golang中使用tls加密

我们先在golang中的server 和clent 中使用tls,看看怎么做,首先是server

  1. package main
  2. //采用https的token加密
  3. import (
  4. "context"
  5. "fmt"
  6. "go-grpc-example/proto"
  7. "google.golang.org/grpc"
  8. "google.golang.org/grpc/credentials"
  9. "log"
  10. "math/rand"
  11. "net"
  12. )
  13. type UserServer struct{}
  14. func main() {
  15. //读2个证书
  16. c, err := credentials.NewServerTLSFromFile("/Users/Jack/www/gowww/go-grpc-example/conf/test.pem", "/Users/Jack/www/gowww/go-grpc-example/conf/test.key")
  17. if err != nil {
  18. log.Fatalf("new tls server err:", err.Error())
  19. }
  20. //监听端口
  21. listen, err := net.Listen("tcp", "127.0.0.1:9528")
  22. if err != nil {
  23. log.Fatalf("tcp listen failed:%v", err)
  24. }
  25. //新建grpc服务,并且传入证书handle
  26. server := grpc.NewServer(grpc.Creds(c))
  27. fmt.Println("userServer grpc services start success")
  28. //注册本次的UserServer 服务
  29. proto.RegisterUserServerServer(server, &UserServer{})
  30. _ = server.Serve(listen)
  31. }
  32. //保存用户
  33. func (Service *UserServer) SaveUser(ctx context.Context, params *proto.UserParams) (*proto.Id, error) {
  34. id := rand.Int31n(100) //随机生成id 模式保存成功
  35. res := &proto.Id{Id: id}
  36. fmt.Printf("%+v ", params.GetAge())
  37. fmt.Printf("%+v\n", params.GetName())
  38. return res, nil
  39. }
  40. func (Service *UserServer) GetUserInfo(ctx context.Context, id *proto.Id) (*proto.UserInfo, error) {
  41. res := &proto.UserInfo{Id: id.GetId(), Name: "test", Age: 31}
  42. return res, nil
  43. }

我们可以发现,除了注册Grpc使用证书不同之外,其他的rpc函数和非tls上是一致的。我们看下client怎么写:

  1. package main
  2. import (
  3. "context"
  4. "fmt"
  5. "go-grpc-example/proto"
  6. "google.golang.org/grpc"
  7. "google.golang.org/grpc/credentials"
  8. "log"
  9. )
  10. var client proto.UserServerClient
  11. func main() {
  12. //读取证书和服务名
  13. crt, err := credentials.NewClientTLSFromFile("/Users/Jack/www/gowww/go-grpc-example/conf/test.pem", "www.zchd.ltd")
  14. if err != nil {
  15. panic(err.Error())
  16. }
  17. //监听端口,并传入证书handle
  18. connect, err := grpc.Dial("127.0.0.1:9528", grpc.WithTransportCredentials(crt))
  19. if err != nil {
  20. log.Fatalln(err)
  21. }
  22. defer connect.Close()
  23. //新建服务客户端
  24. client = proto.NewUserServerClient(connect)
  25. SaveUser()
  26. //GetUserInfo()
  27. }
  28. func SaveUser() {
  29. params := proto.UserParams{}
  30. params.Age = &proto.Age{Age: 31}
  31. params.Name = &proto.Name{Name: "test"}
  32. res, err := client.SaveUser(context.Background(), &params)
  33. if err != nil {
  34. log.Fatalf("client.SaveUser err: %v", err)
  35. }
  36. fmt.Println(res.Id)
  37. }
  38. func GetUserInfo() {
  39. res, err := client.GetUserInfo(context.Background(), &proto.Id{Id: 1})
  40. if err != nil {
  41. log.Fatalf("client.userInfo err: %v", err)
  42. }
  43. fmt.Printf("%+v\n", res)
  44. }

代码总体也很简单,需要注意的是NewClientTLSFromFile()这个函数,第一个参数需要传pem公钥文件,第一个参数传serverNameOverride,也就是我们在OpenSSL.cnf里面设置DNS的名字。

我们运行一下:

  1. $ go run client/simple_token_client/client.go
  2. 47

服务端也有输出:

  1. $ go run server/simple_tls_server/server.go
  2. age:31 name:"test"

成功连接。需要注意的是2个证书的生成,涉及很多openssl命令,要注意别搞错了,这个搞错就很同意连接不成功,出现各种问题。

8.2 php client 使用tls加密 连接

老规矩,我们在PHP中的client,也可以用这种方式来加密连接一下服务端,直接上代码:

  1. <?php
  2. //引入 composer 的自动载加
  3. require __DIR__ . '/vendor/autoload.php';
  4. //公钥内容
  5. $pem = file_get_contents("/Users/Jack/www/gowww/go-grpc-example/conf/test.pem");
  6. //导入公钥证书和DNS name名
  7. $client = new \Proto\UserServerClient('127.0.0.1:9528', [
  8. 'credentials' => \Grpc\ChannelCredentials::createSsl($pem),
  9. 'grpc.ssl_target_name_override' => 'www.zchd.ltd',
  10. ]);
  11. //实例化 $UserParams 请求类
  12. $UserParams = new \Proto\UserParams();
  13. $age = new \Proto\Age();
  14. $age->setAge(18);
  15. $UserParams->setAge($age);
  16. $name = new \Proto\Name();
  17. $name->setName("jack");
  18. $UserParams->setName($name);
  19. //调用远程服务
  20. /**
  21. * @var $Id \Proto\Id
  22. */
  23. list($Id, $status) = $client->SaveUser($UserParams)->wait();
  24. var_dump($status->code, $Id->getId());
  25. //实例化Id类
  26. $Id = new \Proto\Id();
  27. //赋值
  28. $Id->setId("1");
  29. //调用
  30. /**
  31. * @var $User \Proto\UserInfo
  32. */
  33. list($UserInfo, $status) = $client->GetUserInfo($Id)->wait();
  34. var_dump($status->code, $UserInfo->getId(), $UserInfo->getName(), $UserInfo->getAge());

需要注意的是证书的导入和写法,有点却别。运行一下:

  1. $ php main_tls.php
  2. int(0)
  3. int(59)
  4. int(0)
  5. int(1)
  6. string(4) "test"
  7. int(31)

成功了。

9. 超时控制

我们平时在代码中通过curl调用1个http请求的时候,都会设置timeout超时,这个是非常重要的,之前笔者就经历过1个接口没设置超时时间,由于1个接口读取时间很长,导致请求长时间等待,由于http请求堆积太多导致,服务线程池飙升,就导致节点死机。所以这是很严重的一个事情。

那么,我们再client中去调用grpc的服务请求的时候,也应该要设置超时时间,这个超时可以通过context来实现。

所以,核心是用到context这个包,来设置,有2种方式,都可以:

  1. //设置超时时间为1秒
  2. ctx, cancel = context.WithDeadline(context.Background(), time.Now().Add(1*time.Second))
  3. //更好的写法
  4. ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)

直接上client代码吧:

  1. package main
  2. //超时控制
  3. import (
  4. "context"
  5. "fmt"
  6. "go-grpc-example/proto"
  7. "google.golang.org/grpc"
  8. "google.golang.org/grpc/codes"
  9. "google.golang.org/grpc/status"
  10. "log"
  11. "time"
  12. )
  13. var client proto.UserServerClient
  14. var ctx context.Context
  15. var cancel context.CancelFunc
  16. func main() {
  17. connect, err := grpc.Dial("127.0.0.1:9527", grpc.WithInsecure())
  18. if err != nil {
  19. log.Fatalln(err)
  20. }
  21. defer connect.Close()
  22. //ctx, cancel = context.WithDeadline(context.Background(), time.Now().Add(1*time.Second))
  23. //另一种写法,1秒超时
  24. ctx, cancel = context.WithTimeout(context.Background(), 1*time.Second)
  25. defer cancel()
  26. client = proto.NewUserServerClient(connect)
  27. SaveUser()
  28. }
  29. func SaveUser() {
  30. params := proto.UserParams{}
  31. params.Age = &proto.Age{Age: 31}
  32. params.Name = &proto.Name{Name: "test"}
  33. //打印当前时间
  34. fmt.Println(time.Now().Format("2006-01-02 15:04:05"))
  35. //开始请求
  36. res, err := client.SaveUser(ctx, &params)
  37. fmt.Println(err)
  38. if err != nil {
  39. got := status.Code(err)
  40. //客户端自己超时控制
  41. if got == codes.DeadlineExceeded {
  42. log.Println("client.SaveUser err: deadline")
  43. }
  44. log.Printf("client.SaveUser err: %+v", err)
  45. } else {
  46. fmt.Println(res.Id)
  47. }
  48. }

client先是设置了context的WithTimeout时间为1秒,然后判断调用grpc函数SaveUser的错误返回值,如果限制超时,就终止请求。

server端其实也需要对这个超时时间做及时的判断,因为,server端可能请求了很多协程服务,client已经停止了,那么server端也应该要及时的停止了,而不是还在后端运行和计算,这样也可以节省服务器很多资源:

  1. package main
  2. import (
  3. "context"
  4. "fmt"
  5. "go-grpc-example/proto"
  6. "google.golang.org/grpc"
  7. "google.golang.org/grpc/codes"
  8. "google.golang.org/grpc/status"
  9. "log"
  10. "math/rand"
  11. "net"
  12. "time"
  13. )
  14. type UserServer struct{}
  15. func main() {
  16. listen, err := net.Listen("tcp", "127.0.0.1:9527")
  17. if err != nil {
  18. log.Fatalf("tcp listen failed:%v", err)
  19. }
  20. server := grpc.NewServer()
  21. fmt.Println("userServer grpc services start success")
  22. proto.RegisterUserServerServer(server, &UserServer{})
  23. _ = server.Serve(listen)
  24. }
  25. //保存用户
  26. func (Service *UserServer) SaveUser(ctx context.Context, params *proto.UserParams) (*proto.Id, error) {
  27. time.Sleep(3*time.Second)
  28. //检测是否超时
  29. timeD, ok := ctx.Deadline()
  30. if ok {
  31. fmt.Println(timeD.Format("2006-01-02 15:04:05"), ctx.Err())
  32. return nil, status.Errorf(codes.Canceled, "UserServer.SaveUser Deadline")
  33. }
  34. id := rand.Int31n(100) //随机生成id 模式保存成功
  35. res := &proto.Id{Id: id}
  36. fmt.Printf("%+v, ", params.GetAge())
  37. fmt.Printf("%+v\n", params.GetName())
  38. return res, nil
  39. }

我们再server端,模拟了3秒超时。

ok,我们运行一下:

  1. $ go run client/simple_timeout_client/client.go
  2. 2021-01-11 18:20:22
  3. 2021/01/11 18:20:23 client.SaveUser err: deadline
  4. 2021/01/11 18:20:23 client.SaveUser err: rpc error: code = DeadlineExceeded desc = context deadline exceeded
  1. $ go run server/simple_timeout_server/server.go
  2. userServer grpc services start success
  3. 2021-01-11 18:20:23 context deadline exceeded

可以看到,在1秒后,deadline了。成功!

当然,也可以用select + ctx.Done()的模式,来监听client的取消事件的:

关键代码如下:

  1. select {
  2. case <-ctx.Done():
  3. fmt.Println("ctx.Done", ctx.Err())
  4. return nil, status.Errorf(codes.Canceled, "UserServer.SaveUser Deadline")
  5. }

参考文章:

  1. https://eddycjy.com/posts/go/grpc/2018-09-22-install/
  2. https://www.cnblogs.com/xiangxiaolin/p/12791281.html
  3. https://github.com/eddycjy/go-grpc-example
  4. php grpc 官方demo https://github.com/grpc/grpc/tree/v1.34.0/examples/php
  5. Go 1.15 解决GRPC X509 https://blog.csdn.net/cuichenghd/article/details/109230584
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注