Fuhui

gRPC入门


时间飞逝 如一名携带信息的邮差 但那只不过是我们的比喻 人物是杜撰的 匆忙是假装的 携带的也不是人的讯息

为什么使用grpc

主要包括以下两点原因:

  1. protocl buffer一种高效的序列化结构。
  2. 支持http 2.0标准化协议。

很对人经常拿thriftgrpc比较,现在先不发表任何看法,后续会深入thrift进行介绍。

http/2

HTTP/2 enables a more efficient use of network resources and a reduced perception of latency by introducing header field compression and allowing multiple concurrent exchanges on the same connection… Specifically, it allows interleaving of request and response messages on the same connection and uses an efficient coding for HTTP header fields. It also allows prioritization of requests, letting more important requests complete more quickly, further improving performance.

The resulting protocol is more friendly to the network, because fewer TCP connections can be used in comparison to HTTP/1.x. This means less competition with other flows, and longer-lived connections, which in turn leads to better utilization of available network capacity. Finally, HTTP/2 also enables more efficient processing of messages through use of binary message framing.

http/2带来了网络性能的巨大提升,下面列举一些个人觉得比较重要的细节:

  1. http/2对每个源只需创建一个持久连接,在这一个连接内,可以并行的处理多个请求和响应,而且做到不相互影响。
  2. 允许客户端和服务端实现自己的数据流和连接流控制,这对我们传输大数据非常有帮助。

更多细节,请参考文章末尾的链接,当然,后续也会专门介绍。

准备工作

大家可以参考protobuf的介绍,具体包括:

  1. 安装Go的开发环境,因为后续是基于Go语言的开发项目
  2. 安装protocol-buffers
  3. 安装protoc-gen-go,用于自动生成源码

生成源码的命令如下,其中,--go_out用于指定生成源码的保存路径;而-I-IPATH的简写,用于指定查找import文件的路径,可以指定多个;最后的order是编译的grpc文件的存储路径。

protoc -I proto/ proto/order.proto --go_out=plugins=grpc:order

protocol buffer

google开发的高效、跨平台的数据传输格式。当然,本质还是数据传输结构。但google赋予了它丰富的功能,比如importpackage、消息嵌套等等。import用于引入别的.proto文件;package用于定义命名空间,转换到go源码中就是包名;repeated用于定义重复的数据;enum用于定义枚举类型等。

.proto内字段的基本定义:

type name = tag;

Protocol buffer本身不包含类型的描述信息,因此获取了没有.proto描述文件的二进制信息是毫无用处的,我们很难提取出非常有用的信息。Go语言complier生成的文件后缀是.pb.go,它自动生成了setget以及readwrite方法,我们可以很方便的序列化数据。

下面我们定义一个创建订单的.proto文件,概括的描述:buyerIDdevice上支付amountsku商品。

  1. 声明版本为proto3packageorder
  2. 设备类型定义为枚举类型,包括ANDROIDIOS两种,而且类型被嵌套声明在OrderParams内。
  3. sku声明为repeated,因为用户可能购买多个商品。
  4. OrderResult为响应的消息体结构,包括生成的订单号和处理的响应码。
  5. service声明了order要提供的服务。当前仅仅实现一个simple RPC:客户端使用OrderParams参数请求RPC服务器,收到OrderResult作为响应。
syntax = "proto3";
package order;

service Order {

    //a simple RPC
    //create new order
    rpc Add (OrderParams) returns (OrderResult) {
    }
}

message OrderParams {
    string amount = 1; //订单金额
    int64 buyerID = 2; //购买用户ID

    enum Device {
        IOS = 0;
        ANDROID = 1;
    }
    Device device = 3;
    repeated Sku sku = 4;
}

message Sku {
    int32 num = 1;
    string skuId = 2;
    int32 unitPrice = 3;
}

message OrderResult {
    int32 statusCode = 1;
    string orderID = 2;
}

grpc接口

通过定义的.proto文件生成grpc clientserver端实现的接口类型。生成的内容主要包括:

  1. protocol buffer各种消息类型的序列化操作
  2. grpc client实现的接口类型,以及client实现的grpc方法
  3. grpc server待实现的接口类型

service处理流程

第一步. 服务端为每个接收的连接创建单独的goroutine进行处理。

第二步. 自动生成的代码中,声明了服务的具体描述,也是该服务的“路由”。包括服务名称ServiceNameMethodsStreams。当rpc接收到新的数据时,会根据路由执行对应的方法。因为我们的设定没有处理流的场景,所以Streams为空的结构体。

代码中的服务名称被指定为:order.Order,对应创建订单的方法是:Add

   var _Order_serviceDesc = grpc.ServiceDesc{
   	ServiceName: "order.Order",
   	HandlerType: (*OrderServer)(nil),
   	Methods: []grpc.MethodDesc{
   		{
   			MethodName: "Add",
   			Handler:    _Order_Add_Handler,
   		},
   	},
   	Streams:  []grpc.StreamDesc{},
   	Metadata: "order.proto",
   }

第三步. 将路由注册到rpc服务中。如下所示,就是将上述的路由转换为map对应关系的过程。类比restful风格的接口定义,等价于/order/这种请求都由这个service来进行处理。

最终将service注册到gRPC server上。同时,我们可以逆向猜出服务的处理过程:通过请求的路径获取service,然后通过MethodName调用相应的处理方法。

   srv := &service{
   	server: ss,
   	md:     make(map[string]*MethodDesc),
   	sd:     make(map[string]*StreamDesc),
   	mdata:  sd.Metadata,
   }
   for i := range sd.Methods {
   	d := &sd.Methods[i]
   	srv.md[d.MethodName] = d
   }
   for i := range sd.Streams {
   	d := &sd.Streams[i]
   	srv.sd[d.StreamName] = d
   }
   s.m[sd.ServiceName] = srv

第四步. gRPC服务处理请求。通过请求的:path,获取对应的serviceMethodName进行处理。

   service := sm[:pos]
   method := sm[pos+1:]
   
   if srv, ok := s.m[service]; ok {
   	if md, ok := srv.md[method]; ok {
   		s.processUnaryRPC(t, stream, srv, md, trInfo)
   		return
   	}
   	if sd, ok := srv.sd[method]; ok {
   		s.processStreamingRPC(t, stream, srv, sd, trInfo)
   		return
   	}
   }

通过结合protoc自动生成的client端代码,无需抓包,我们就可以推断出path的格式,以及系统是如何处理路由的。代码中定义的:/order.Order/Add就是依据。

   func (c *orderClient) Add(ctx context.Context, in *OrderParams, opts ...grpc.CallOption) (*OrderResult, error) {
   	out := new(OrderResult)
   	err := c.cc.Invoke(ctx, "/order.Order/Add", in, out, opts...)
   	if err != nil {
   		return nil, err
   	}
   	return out, nil
   }
   

创建订单

为了简单起见,我们只保证订单的唯一性。这里我们实现一个简易版本,而且也不做过多介绍。感兴趣的同学可以移步到另一篇文章:探讨分布式ID生成系统去了解,毕竟不应该是本节的重心。

//上次创建订单使用的毫秒时间
var lastTimestamp = time.Now().UnixNano() / 1000000
var sequence int64

const MaxSequence = 4096

// 42bit分配给毫秒时间戳
// 12bit分配给序列号,每4096就重新开始循环
// 10bit分配给机器ID
func CreateOrder(nodeId int64) string {
	currentTimestamp := getCurrentTimestamp()
	if currentTimestamp == lastTimestamp {
		sequence = (sequence + 1) % MaxSequence
		if sequence == 0 {
			currentTimestamp = waitNextMillis(currentTimestamp)
		}
	} else {
		sequence = 0
	}

	orderId := currentTimestamp << 22
	orderId |= nodeId << 10
	orderId |= sequence

	return strings.ToUpper(fmt.Sprintf("%x", orderId))
}

func getCurrentTimestamp() int64 {
	return time.Now().UnixNano() / 1000000
}

func waitNextMillis(currentTimestamp int64) int64 {
	for currentTimestamp == lastTimestamp {
		currentTimestamp = getCurrentTimestamp()
	}
	return currentTimestamp
}

运行系统

创建服务端代码。注意:使用grpc提供的默认选项,其实是很危险的行为。在生产开发中,被不熟悉的默认选项坑到的情况比比皆是。这里的代码不要作为后续生产环境开发的参考。服务端的代码相比客户端要复杂一点,需要我们去实现处理请求的接口。

type Order struct {
}

func (o *Order) Add(ctx context.Context, in *order.OrderParams) (*order.OrderResult, error) {
	return &order.OrderResult{
		OrderID: util.CreateOrder(1),
	}, nil
}

func main() {
	lis, err := net.Listen("tcp", "127.0.0.1:10000")
	if err != nil {
		log.Fatalf("Failed to listen: %v", err)
	}

	grpcServer := grpc.NewServer()
	order.RegisterOrderServer(grpcServer, &Order{})
	grpcServer.Serve(lis)
}

客户端的代码非常简单,构造参数,处理返回就Ok了。

func createOrder(client order.OrderClient, params *order.OrderParams) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	orderResult, err := client.Add(ctx, params)
	if err != nil {
		log.Fatalf("%v.GetFeatures(_) = _, %v: ", client, err)
	}

	log.Println(orderResult)
}

func main() {
    conn, err := grpc.Dial("127.0.0.1:10000")
	if err != nil {
		log.Fatalf("fail to dial: %v", err)
	}
	defer conn.Close()

	client := order.NewOrderClient(conn)
	orderParams := &order.OrderParams{
		BuyerID: 10318003,
	}
	createOrder(client, orderParams)
}

总结

文章介绍了gRPC的入门知识,包括protocol buffer以及http/2gRPC封装了很多东西,对于一般场合,我们只需要指定配置,实现接口就可以了,非常简单。

在入门的介绍里,大家会觉得gRPC不就跟RESTFUL请求一样吗?确实是,我也这样觉得。但存在一个最直观的优点:通过使用gRPC,可以将复杂的接口调用关系封装在SDK中,直接提供给第三方使用,而且还能有效避免错误调用接口的情况。

如果gRPC只能这样的话,它就太失败了,他用HTTP/2简直就是用来打蚊子的,让我们后续继续深入了解吧。


参考文章:

  1. gRPC:Google 开源的基于 HTTP/2 和 ProtoBuf 的通用 RPC 框架
  2. GRPC
  3. HTTP/2 简介
  4. http2