vlambda博客
学习文章列表

「gRPC系列」gRPC 远程服务调用框架介绍与使用

大家好,我是阿星,今天开个新坑,开启「gRPC系列」的分享,将全面剖析gRPC 的设计原则与工作原理。这是第一篇:「gRPC 远程服务调用框架介绍与使用」。


随着现在云原生与微服务的普及,RPC 服务使用场景也越来越多了。并且,大多数微服务框架也都是基于 RPC 服务的管理为核心来拓展的,在 go 语言系列中,有 go micro,go zero ,k8s等优秀的开源软件,底层也都采用了谷歌开源的 gRPC 来作为其 RPC 框架。

序0:进程间通信

对我们现在的开发者来说,RPC 已经是一个使用频率非常频繁的技术。那么RPC 技术的诞生到底是为了解决什么问题呢?下面来一步步探究


在现有操作系统架构中,不同进程都拥有自己的内存空间和寄存器,不同进程之间数据无法共享。那么如果一项任务需要不同进程协作完成,他们是如何有条不紊地进行协作呢?


为了解决这个问题,引入了一个新的概念:进程间通信,简称「IPC」。


进程因为其内存空间是私有的,无法和其他进程直接共享数据,但是不同进程最终都是可以由内核来调度的,那么就可以在内核空间中开辟一个缓存区,将数据缓存到内核中,然后供其他进程读取,这样就能间接实现进程之间的通信了。



基于上述方案,具体的进程间通信的实现方式主要有如下几种:

  • 管道、匿名管道

  • 有名管道

  • 信号

  • 息队列

  • 共享内存

  • 信号量

  • socket


其中,前六种方案只能实现单机系统的进程间调用。随着现在分布式系统发展,往往都需要进行分布式的进程间调用。于是只能使用 socket 这种基于网络模块的通信方式来实现了。


那么再来看看, socket 具体是怎样实现进程间通信的呢?


socket 简单来说就是网络模型中的应用层和传输层之间的一个桥梁,为了更好地理解,我在网上找了一张图:


「gRPC系列」gRPC 远程服务调用框架介绍与使用图片来源:www.topgoer.com


如上图所示,socket 将复杂的 TCP / IP 协议簇隐藏在接口后面,应用层只需要借助 socket 就可以很轻松地和传输层进行交互。在 socket 的基础上,不同机器的进程就可以很方便地建立连接进行通信。也可以说 socket 其实就是基于网络通信来实现不同机器的进程间通信。

 


序1:RPC 远程过程调用

基于网络模型进行通信,可以直接使用应用层的通信协议,比如 http 协议,也可以使用 tcp , udp 协议等。


但是使用这样的网络协议进行通信时,传递的信息都是二进制数据流,类似这样的:


「gRPC系列」gRPC 远程服务调用框架介绍与使用


要想完成一次信息通信,需要接收方和发送方约定好对应的数据格式,并需要双方都以对应的数据格式去序列化和反序列化,才能完成一次完整的通信。


例如,使用http协议实现两个进程通信时,主要流程如下图所示:


「gRPC系列」gRPC 远程服务调用框架介绍与使用


这种通信方式,对于一些内部服务而言,多一个接口,就需要在请求方和响应方分别实现序列化和反序列化。对于开发者来说过程有些繁琐了,要写两套同样的序列化代码,如果语言不一致,还得保证异构语言之间的序列化数据结构是否一致,比如有些语言支持的数据类型,在其他语言就不一定支持。并且没有数据格式的强约束性,也很容易在开发过程中埋下一些坑。


如果能够在一个地方约束统一掉数据格式,双方都由这个格式的数据去序列化和反序列化,也不用关注底层通信协议的话,上述代码就简单多了。


在请求方(下面称为客户端)和处理方(下面称为服务端)之间约定好调用的接口函数签名、入参和出参,使用一个公共库定义好序列化方式,这样就可以省去开发频繁序列化和反序列化的开发负担。函数定义类似这样:


type Hello interface { SayHello(request) response}


服务端实现上述接口,客户端就可以使用下面这样简单的代码就可以轻松获取服务端的处理结果:


var response = HelloServer.SayHello(request)


像这种在机器A 上的进程调用另外一台机器 B 上的进程,其中 A 上的调用进程被挂起,而 B 上的被调用进程开始执行,当值返回给 A 时,A 进程继续执行。调用方可以通过使用参数将信息传送给被调用方,而后可以通过传回的结果得到信息的进程间通信的方法叫做「 RPC 远程过程调用 」。


简单来说 RPC就是屏蔽了底层通信协议,并且对通信协议传输的二进制数据流自动进行了序列化和反序列化操作。可以简单理解为 RPC=数据序列化+网络通信。整体实现流程如下图所示:


「gRPC系列」gRPC 远程服务调用框架介绍与使用


一个完整的RPC远程过程主要由下面几个步骤构成:

1. 调用客户端句柄,执行传递参数。
2. 调用本地系统内核发送网络消息。
3. 消息传递到远程主机,就是被调用的服务端。
4. 服务端句柄得到消息并解析消息。
5. 服务端执行被调用方法,并将执行完毕的结果返回给服务器句柄。
6. 服务器句柄返回结果,并调用远程系统内核。
7. 消息经过网络传递给客户端。

8. 客户端接受数据。


序2:gRPC

RPC 只是一个技术概念,实现 RPC 的方式也有很多,但是 RPC 的使用场景往往伴随着高性能,那么今天就以目前使用比较多,对异构系统的兼容性和性能都做得比较好的一个RPC框架:「 gRPC 」。


gRPC 定义

gRPC 是一个高性能、开源、通用的RPC框架,由Google推出,基于HTTP2协议标准设计开发,默认采用Protocol Buffers数据序列化协议,支持多种开发语言。gRPC提供了一种简单的方法来精确的定义服务,并且为客户端和服务端自动生成可靠的功能库。
https://grpc.io/

在gRPC客户端可以直接调用不同服务器上的远程程序,使用姿势看起来就像调用本地程序一样,很容易去构建分布式应用和服务。和很多RPC系统一样,服务端负责实现定义好的接口并处理客户端的请求,客户端根据接口描述直接调用需要的服务。客户端和服务端可以分别使用gRPC支持的不同语言实现。

https://grpc.io/


「gRPC系列」gRPC 远程服务调用框架介绍与使用


gRPC 的设计原则


  • 语言中立,支持多种语言

  • 基于 IDL ( 接口定义语言(Interface Define Language))文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端接口以及客户端 Stub

  • 通信协议基于标准的 HTTP/2 设计,支持·双向流、消息头压缩、单 TCP 的多路复用、服务端推送等特性,这些特性使得 gRPC 在移动端设备上更加省电和节省网络流量

  • 序列化支持 PB(Protocol Buffer)和 JSON,PB 是一种语言无关的高性能序列化框架,基于 HTTP/2 + PB, 保障了 RPC 调用的高性能。


如上图,通过 gRPC 进行远程过程调用时,远程服务的调用对使用者更加简单和透明,底层的传输方式,序列化方式,通信细节等统统不需要关心。


简单上手


下面就来介绍一下怎么使用 gRPC 。首先需要先基于proto定义出一套接口来,这个接口就是客户端和服务端都遵守的一套共同的约定即前面所说的确定的函数签名,入参和出参。


syntax = "proto3";package hello;
// The greeting service definition.service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) {}}
// The request message containing the user's name.message HelloRequest { string name = 1;}
// The response message containing the greetingsmessage HelloReply { string message = 1;}


然后使用 protoc --go_out=. *.proto 就可以在当前目录自动生成应的go接口文件了,下面就针对该接口文件进行实现和调用。


服务端实现:


package main
import ( "context" hello "demo/micro_grpc/proto" "fmt"  "google.golang.org/grpc" "net")
type HelloServer struct {
}
func (s *HelloServer) SayHello(ctx context.Context, in *hello.HelloRequest) (*hello.HelloReply, error) { return &hello.HelloReply{Message: "Hello again " + in.GetName()}, nil}
func main() { lis, err := net.Listen("tcp", ":10010") if err != nil { fmt.Printf("failed to listen: %v", err) return } s := grpc.NewServer() // 创建gRPC服务器  hello.RegisterGreeterServer(s, &HelloServer{}) // 在gRPC服务端注册服务 err = s.Serve(lis) if err != nil { fmt.Printf("failed to serve: %v", err) return }}

客户端调用:


 
package main
import ( "context" hello "demo/micro_grpc/proto" "fmt"
"google.golang.org/grpc")
func main() { // 连接服务器 conn, err := grpc.Dial(":10010", grpc.WithInsecure()) if err != nil { fmt.Printf("faild to connect: %v", err) } defer conn.Close()  c := hello.NewGreeterClient(conn) // 调用服务端的SayHello r, err := c.SayHello(context.Background(), &hello.HelloRequest{Name: "astar"}) if err != nil { fmt.Printf("could not greet: %v", err) } fmt.Printf("Greeting: %s !\\n", r.Message) //再调用服务端程序 r, err = c.SayHello(context.Background(), &hello.HelloRequest{Name: "astar1"}) if err != nil { fmt.Printf("could not greet: %v", err) } fmt.Printf("Greeting: %s !\\n", r.Message)}


如上,就实现了简单的gRPC远程过程调用的代码了,也和预期一致,使用


 r, err := c.SayHello(context.Background(), &hello.HelloRequest{Name: "astar"})


这样简单的函数调用就可以像调用本地函数一样调用远程进程的代码了。



序3:总结



总体来说,gRPC 在数据序列化上使用了高效的数据序列化协议 protobuf ,在增加序列化性能的同时也减少了消息本身的体积,另外,基于 http2 高效的网络消息传输协议,使得 gRPC 在性能上能够比传统的 http 请求要优越很多,以至于其在微服务/k8s等领域使用特别广泛。


另外,gRPC 虽然在性能上比传统的 http 请求要高出很多,但是,但是也增加了系统之间的耦合性,例如当客户端需要远程调用 B 系统提供的 RPC 服务时,就必须和 B 系统建立相关 RPC 系统之间的强耦合关系。 


RPC 框架,为的是解决分布式调用的问题,但是分布式调用也会衍生出一些新的需求,比如服务治理,负载均衡等功能。


在 gRPC 中,也有上述相关功能的组件,但是 gRPC 作为一款轻量级的 RPC 框架,对其的默认实现是非常简单的,远不能满足我们大型系统的服务治理的要求,但是他也提供了可插拔式的选项,可以允许我们自定义或者引入第三方优秀的相关组件来完善该功能。我也会在后续的「 gRPC 系列 」的分享中,陆续分享 gRPC 服务治理的相关组件以及其远程调用的底层实现原理的源码分析等。