跟我一起学Go系列:gRPC 入门必备

RPC 的定义这里就不再说,看文章的同学都是成熟的开发。gRPC 是 Google 开源的高性能跨语言的 RPC 方案,该框架的作者 Louis Ryan 阐述了设计这款框架的动机,有兴趣的同学可以看看: gRPC的动机和设计原则

另一个值得一提的问题是,众所周知 RPC 框架基本都是直接基于 TCP 协议自研数据结构和编解码方式,但是 gRPC 却完全不是这样,它使用 HTTP/2 协议来传输数据。基于这一点来说, yRPC 肯定就不是性能最佳的那一款 RPC 框架。但是在不追求顶格 QPS 的前提下,通用性和兼容性也是不可忽略的要素。

如果要探究为什么 gRPC 要选择使用 HTTP/2 作为底层协议,这个其实也很好解释。HTTP 协议作为 Web 端标准协议在 Google 内被大规模广泛使用。为了解决 1.x 的问题,Google 将自研的 SPDY 协议公开并推动基于 SPDY 的 HTTP/2 协议。所以 gRPC 从兼容性和推广 HTTP/2 两个角度来说有充足理由选择 HTTP/2,何况基于 HTTP/2 的一些新特性也会让实现方案上少写很多代码。

这里衍生出另一个基础问题:既然底层使用 HTTP/2,那为啥还要用 RPC,不直接用 Restful 的方式更直接吗。RPC 通常使用二进制编码来压缩消息的内容,Restful 更多的使用 JSON 格式,消息体中的冗余数据比较多,性能不如 RPC。

说了这么多题外话下面我们还是看一下作为业内使用率比较高的一款 RPC 框架是如何跑起来的。

开始前的准备

因为 gRPC 使用 Protocol Buffers 做为协议传输编码格式,我们先安装 Protocol Buffers 。具体安装大家可以自行搜索教程,我这里使用 mac 的 brew 来安装。

因为原生的 Protobuf 并不支持将 .proto 文件编译为 Go 源码,后面 Go 官方单独开发了一款编译插件:

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

无论你是通过 go get 的方式安装还是通过别的方式,确保它在 $GOPATH/bin 中以便编译器protoc能够找到它。通过这个插件你可以将 .proto 文件编译为 Go 文件。并且在 protoc-gen-go 插件中还提供了专门的 gRPC 编译相关的支持,可以将 pb 文件中定义的 rpc service 方法渲染成 Go 对象。

这里对于安装过程简单介绍过去,因为它并不是本文介绍的要点,但是对于大家来说肯定不是那么好绕过去的🐶,安装过程出现问题搜索一下即可,已经有人替你们踩过坑!

声明:以下代码都可以在 Github 仓库中找到。


Hello World 入门

我们先定义一个用于本次测试的 pb 格式:

syntax = "proto3"; // 版本声明,使用Protocol Buffers v3版本
option go_package = "/";

package models.pb; // 包名


service Calculate {
    rpc Sum (stream SumRequest) returns (SumResponse) {}
}

message SumRequest {
    int64 num = 1;
}

message SumResponse {
    int64 result = 1;
}

执行如下命令生成对应的 pb 文件:

 protoc --go_out=plugins=grpc:. *.proto

执行完成之后就会在当前目录下生成 HelloWorld.pb.go 文件。

接下来开始编写 服务端和客户端相关的代码。首先引入 gRPC 的包:

go get -u google.golang.org/grpc

服务端代码如下:

package grpcTest

import (
	"fmt"
	"net"
	"testing"

	pb "gorm-demo/models/pb"
	"golang.org/x/net/context"
	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"
)

type server struct{}

func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	return &pb.HelloReply{Message: "Hello " + in.Name}, nil
}

func TestGrpcServer(t *testing.T) {
	// 监听本地的8972端口
	lis, err := net.Listen("tcp", ":8972")
	if err != nil {
		fmt.Printf("failed to listen: %v", err)
		return
	}
	s := grpc.NewServer() // 创建gRPC服务器
	pb.RegisterGreeterServer(s, &server{}) // 在gRPC服务端注册服务

	reflection.Register(s) //在给定的gRPC服务器上注册服务器反射服务
	// Serve方法在lis上接受传入连接,为每个连接创建一个ServerTransport和server的goroutine。
	// 该goroutine读取gRPC请求,然后调用已注册的处理程序来响应它们。
	err = s.Serve(lis)
	if err != nil {
		fmt.Printf("failed to serve: %v", err)
		return
	}
}

接下来是客户端:

package grpcTest

import (
	"fmt"
	"testing"

	pb "gorm-demo/models/pb"
	"golang.org/x/net/context"
	"google.golang.org/grpc"
)



func TestGrpcClient(t *testing.T) {
	// 连接服务器
	conn, err := grpc.Dial(":8972", grpc.WithInsecure())
	if err != nil {
		fmt.Printf("faild to connect: %v", err)
	}
	defer conn.Close()

	c := pb.NewGreeterClient(conn)
	// 调用服务端的SayHello
	r, err := c.SayHello(context.Background(), &pb.HelloRequest{Name: "CN"})
	if err != nil {
		fmt.Printf("could not greet: %v", err)
	}
	fmt.Printf("Greeting: %s !\n", r.Message)
}

分别启动服务端和客户端程序可以看到能够正常的收发消息。

更高端的技能

gRPC 主要有 4 种请求和响应模式,分别是简单模式(Simple RPC)服务端流式(Server-side streaming RPC)客户端流式(Client-side streaming RPC)、和双向流式(Bidirectional streaming RPC)

  • 简单模式(Simple RPC):客户端发起请求并等待服务端响应,就是普通的 Ping-Pong 模式。
  • 服务端流式(Server-side streaming RPC):服务端发送数据,客户端接收数据。客户端发送请求到服务器,拿到一个流去读取返回的消息序列。 客户端读取返回的流,直到里面没有任何消息。
  • 客户端流式(Client-side streaming RPC):与服务端数据流模式相反,这次是客户端源源不断的向服务端发送数据流,而在发送结束后,由服务端返回一个响应。
  • 双向流式(Bidirectional streaming RPC):双方使用读写流去发送一个消息序列,两个流独立操作,双方可以同时发送和同时接收。

上面演示的代码就是简单模式,客户端发起请求等待服务器回应。下面的三种数据流模式第一眼看上去的时候感觉很难理解,HTTP 协议在我们的认识中就是 Ping-Pong 模式,请求和应答。流式处理是怎么发生的。

建立在 HTTP 基本原理的基础上, gRPC 对请求处理做了一些包装。当一次请求的数据量过大会有两个问题,第一是超时,第二可能超过网络请求最大包长度限制。对于这种情况下的问题要么业务侧做分解将数据拆分成多次请求返回,要么就是通过关键字的方式返回关键字由业务侧根据关键字二次查询详细数据。

流式处理这个概念相当于逆天改命,跳过上面两种基本的上层处理方案,从底层提供一次请求,多次返回的功能。客户端发起一次流式处理请求,服务端分多次将数据分包返回给客户端。

流式处理不是空中花园还是需要有底层支持,因为 gRPC 是基于 HTTP/2 来传输,HTTP/2 本身就有二进制分帧的概念。通常一个请求或响应会被分为一个或多个帧传输,流则表示已建立连接的虚拟通道,可以传输多次请求或响应。每个帧中包含 Stream Identifier,标志所属流。HTTP/2 通过流与帧实现多路复用,对于相同域名的请求,通过 Stream Identifier 标识可在同一个流中进行,从而减少连接开销。

了解了这些理论之后我们先来实践一下,先看接口定义:

syntax = "proto3"; // 版本声明,使用Protocol Buffers v3版本
option go_package = "/";

package models.pb; // 包名


service BaseService {
    
    //计算求和的方式来测试服务端流
    rpc Sum (stream SumRequest) returns (SumResponse) {}
    // 服务端流式响应
    rpc ServerStream (StreamRequest) returns (stream StreamResponse){}
    // 客户端流式请求
    rpc ClientStream (stream StreamRequest) returns (StreamResponse){}
    // 双向流式
    rpc Streaming (stream StreamRequest) returns (stream StreamResponse){}
}

message StreamRequest{
    string input = 1;
}

message StreamResponse{
    string output = 1;
}

message SumRequest {
    int64 num = 1;
}

message SumResponse {
    int64 result = 1;
}

上面这个 pb 里面定义了 4 个接口,其中第一个求和的接口就是让你更好理解流式请求的概念,只有读完整个流数据之后才会结束计算。我们来看一下实现。

客户端流式响应测试 - Sum 求和:

客户端代码:

package normal

import (
	"fmt"
	"testing"

	"golang.org/x/net/context"
	"google.golang.org/grpc"
	pb "gorm-demo/models/pb"
)

func TestGrpcClient(t *testing.T) {
	// 连接服务器
	conn, err := grpc.Dial(":8972", grpc.WithInsecure())
	if err != nil {
		fmt.Printf("faild to connect: %v", err)
	}
	defer conn.Close()

	c := pb.NewBaseServiceClient(conn)
	// 调用Sum
	sumCli, err := c.Sum(context.Background())
	if err != nil {
		panic("sum cli err")
	}
	sumCli.Send(&pb.SumRequest{Num: int64(1)})
	sumCli.Send(&pb.SumRequest{Num: int64(2)})
	sumCli.Send(&pb.SumRequest{Num: int64(3)})
	sumCli.Send(&pb.SumRequest{Num: int64(4)})

	recv, err := sumCli.CloseAndRecv()
	if err != nil {
		fmt.Printf("send sum request err: %v", err)
	}

	fmt.Printf("sum = : %v !\n", recv.Result)
}

这里是客户端调用 Sum 方法后分批多次发送请求数据给服务端,等发送完成服务端会返回一个最终的结果。

服务端代码:

package normal

import (
	"fmt"
	"io"
	"net"
	"strconv"
	"testing"

	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"
	pb "gorm-demo/models/pb"
)

type server struct{}


func TestGrpcServer(t *testing.T) {
	// 监听本地的8972端口
	lis, err := net.Listen("tcp", ":8972")
	if err != nil {
		fmt.Printf("failed to listen: %v", err)
		return
	}
	s := grpc.NewServer()                  // 创建gRPC服务器
	pb.RegisterBaseServiceServer(s, &server{}) // 在gRPC服务端注册服务

	reflection.Register(s) //在给定的gRPC服务器上注册服务器反射服务
	// Serve方法在lis上接受传入连接,为每个连接创建一个ServerTransport和server的goroutine。
	// 该goroutine读取gRPC请求,然后调用已注册的处理程序来响应它们。
	err = s.Serve(lis)
	if err != nil {
		fmt.Printf("failed to serve: %v", err)
		return
	}
}



//sum案例--客户端流式处理
func (*server) Sum(req pb.BaseService_SumServer) (err error) {
	var sum int64 = 0
	for {
		reqObj, err := req.Recv()
		if err == io.EOF {
			fmt.Printf("Recv Sum err: %v", err)
			req.SendAndClose(&pb.SumResponse{Result: sum})
			return nil
		} else if err == nil {
			fmt.Printf("get client request param = %v", reqObj.Num)
			sum += reqObj.Num
		} else {
			return err
		}
	}
}

// 服务端流式处理
func (s *server) ServerStream(in *pb.StreamRequest, stream pb.BaseService_ServerStreamServer) error {
	input := in.Input
	for _, s := range input {
		stream.Send(&pb.StreamResponse{Output: string(s)})
	}
	return nil
}

// 客户端流式响应 - 服务端逻辑
func (s *server) ClientStream(stream pb.BaseService_ClientStreamServer) error {
	output := ""
	for {
		r, err := stream.Recv()
		if err == io.EOF {
			return stream.SendAndClose(&pb.StreamResponse{Output: output})
		}
		if err != nil {
			fmt.Println(err)
		}
		output += r.Input
	}
}

// 双向流式处理
func (s *server) Streaming(stream pb.BaseService_StreamingServer) error {
	for n := 0; ; {
		res, err := stream.Recv()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		v, _ := strconv.Atoi(res.Input)
		n += v
		stream.Send(&pb.StreamResponse{Output: strconv.Itoa(n)})
	}
}

Sum 方法中服务端等待获取客户端的请求数据,直到遇到最后一个 EOF 之后将计算的结果返回给客户端,本次请求结束。

服务端流式响应测试

与客户端流式响应相反,服务端流式响应就是服务端持续发送数据流,客户端接收并最终结束流。

客户端逻辑:

package normal

import (
	"fmt"
	"testing"

	"golang.org/x/net/context"
	"google.golang.org/grpc"
	pb "gorm-demo/models/pb"
)

func TestGrpcClient(t *testing.T) {
	// 连接服务器
	conn, err := grpc.Dial(":8972", grpc.WithInsecure())
	if err != nil {
		fmt.Printf("faild to connect: %v", err)
	}
	defer conn.Close()

	c := pb.NewBaseServiceClient(conn)
	clientStream(c, "我收到了服务端的请求数据拉")
}

// 客户端
func clientStream(client pb.BaseServiceClient, input string) error {
	stream, _ := client.ClientStream(context.Background())
	for _, s := range input {
		fmt.Println("Client Stream Send:", string(s))
		err := stream.Send(&pb.StreamRequest{Input: string(s)})
		if err != nil {
			return err
		}
	}
	res, err := stream.CloseAndRecv()
	if err != nil {
		fmt.Println(err)
	}
	fmt.Println("Client Stream Recv:", res.Output)
	return nil
}

客户端现在变为接收数据方。

服务端现在从客户端收到请求之后只管去处理,处理的结果分多次发给客户端即可:

//服务端流式处理
serverStream(c, &pb.StreamRequest{Input: "我是一只小老虎"})

// 服务端流式处理
func serverStream(client pb.BaseServiceClient, r *pb.StreamRequest) error {
  fmt.Println("Server Stream Send:", r.Input)
  stream, _ := client.ServerStream(context.Background(), r)
  for {
    res, err := stream.Recv()
    if err == io.EOF {
      break
    }
    if err != nil {
      return err
    }
    fmt.Println("Server Stream Recv:", res.Output)
  }
  return nil
}

大家可以运行一下看看效果。

双向流处理

顾名思义就是服务端和客户端都可以发送和接收消息。

服务端代码:


// 双向流式处理
func (s *server) Streaming(stream pb.BaseService_StreamingServer) error {
	for n := 0; ; {
		res, err := stream.Recv()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		v, _ := strconv.Atoi(res.Input)
		n += v
		stream.Send(&pb.StreamResponse{Output: strconv.Itoa(n)})
	}
}

客户端代码:


// 双向流式处理
func streaming(client pb.BaseServiceClient) error {
	stream, _ := client.Streaming(context.Background())
	for n := 0; n < 10; n++ {
		fmt.Println("Streaming Send:", n)
		err := stream.Send(&pb.StreamRequest{Input: strconv.Itoa(n)})
		if err != nil {
			return err
		}
		res, err := stream.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			return err
		}
		fmt.Println("Streaming Recv:", res.Output)
	}
	stream.CloseSend()
	return nil
}

可以看到双方都能接收和发送消息。

入门篇就先说这么多,都是实操案例。大家先上手看看如何玩起来再去慢慢了解更深层次的东西吧。

posted @ 2021-05-12 09:47  rickiyang  阅读(762)  评论(1编辑  收藏  举报