一、什么是RPC
1.1 概念
RPC ( Remote Procedure Call ),顾名思义,是一种远程(网络)过程调用。先来看维基百科的解释,
In distributed computing, a remote procedure call (RPC) is when a computer program causes a procedure (subroutine) to execute in a different address space (commonly on another computer on a shared network), which is coded as if it were a normal (local) procedure call, without the programmer explicitly coding the details for the remote interaction.
在分布式系统中,远程过程调用(RPC)是指计算机程序使一个过程(子例程)在不同的地址空间(通常在共享网络上的另一台计算机上)中执行,这个调用过程就好像它是一个普通的(本地)过程调用一样,而不需要编程人员去考虑“远程”交互的细节。简单来说,RPC是一种跨服务器的「进程间通信」机制。
RPC是一种请求-响应协议。RPC由客户端启动,客户端向已知的远程服务器发送请求消息,使用提供的参数执行指定的过程,接着远程服务器向客户端发送响应。当服务器处理调用时,客户端线程将被阻塞(除非客户端向服务器发送的是一个异步请求,例如XMLHttpRequest,否则它将等待服务器完成处理后再恢复执行)。
1.2 现有的实现
RPC在不同的实现中有许多变化和微妙之处,导致了各种不同(不兼容)的RPC协议。
以字节跳动的这篇推送所言,与RPC相关的框架或实现有如下几个,笔者做了一个简单的整理,方便大家快速了解它们各自的含义,
- Dubbo:Apache Dubbo是一个高性能、基于Java的开源RPC框架(对Golang也有很好的支持)。
- SOAP:Simple Object Access Protocol,以XML作为数据传输的格式,是比较早期的跨应用或服务器资源共享方式。
- Thrift:Apache Thrift是由Facebook开源的一个跨平台、跨语言的RPC通信框架,也是目前最流行的RPC框架之一,更多信息可以参考这篇论文。
- gRPC:一个开源的高性能RPC框架,基于http/2双向传输。
- RESTful (REST):RESTful不是一种框架,而是一套准则和约束,它的要求(特性)之一是无状态的(Stateless)。
一个RPC的核心是序列化协议和网络(传输协议) ,一个好的,定制化高的RPC实现应该做到如下几点,
- 支持多种序列化协议,如 Thrift、Protobuf、JSON。
- 支持多种网络协议,如 TCP、HTTP、HTTP/2。
SOFAStack(一个金融机分布式架构)认为,在RPC的两类核心协议中,序列化协议负责规定业务数据和二进制串的转换规则,而通讯层协议一般是和业务无关的,它的职责是将业务数据打包后,安全、完整的传输给接受方。这两种协议的关注点是不太一样的,对于一个 RPC 框架来说,通讯层协议一旦确定就很少变化,这要求它具备足够好的通用性和扩展性;而应用层协议理论上可以由业务自由选择,它更多的是关注编码的效率和跨语言等特性。
1.3 调用流程
根据阿里的这篇文章,一个基于TCP协议的RPC调用流程包含下面几个阶段,
- 调用方(Client)通过本地的 RPC 代理(Proxy)调用相应的接口
- 本地代理将 RPC 的服务名,方法名和参数等等信息转换成一个标准的 RPC Request 对象交给 RPC 框架
- RPC 框架采用 RPC 协议(RPC Protocol)将 RPC Request 对象序列化成二进制形式,然后通过 TCP 通道传递给服务提供方 (Server)
- 服务端(Server)收到二进制数据后,将它反序列化成 RPC Request 对象
- 服务端(Server)根据 RPC Request 中的信息找到本地对应的方法,传入参数执行,得到结果,并将结果封装成 RPC Response 交给 RPC 框架
- RPC 框架通过 RPC 协议(RPC Protocol)将 RPC Response 对象序列化成二进制形式,然后通过 TCP 通道传递给服务调用方(Client)
- 调用方(Client)收到二进制数据后,将它反序列化成 RPC Response 对象,并且将结果通过本地代理(Proxy)返回给业务代码
之所以把对象序列化为二进制结构,是因为TCP 通道里传输的数据只能是二进制的。另外,和TCP协议一样,RPC的数据也必须要分包(Packet)传输,之所以要有包,是因为二进制只完成 字节流(Stream)的传输,并不知道一次数据请求和响应的起始和结束,我们需要预先定义好包结构才能做解析。同时,我们也把包分为主体(body)和头部(header),分别包括请求的元信息和消息主体。
1.4 实现
在实现的过程中,需要注意以下细节,
- 大端序、小端序
TCP/IP 协议 RFC1700 里规定使用「大端」字节序作为网络字节序,所以,我们在开发网络通讯协议的时候操作 Buffer 都应该用大端序的 API,NodeJS对应的API是以 BE 结尾的。
- 避免Buffer碎片化
解决方案是预先分配一块较大的内存,为每一个Buffer分配不同的偏移量(offset),当内存写满时再把所有Buffer拼接成一个完整的Packet。
- 不同语言的底层数据类型不一致
JavaScript 的Number能够表达的整数范围是 -(2^53 - 1) ~ (2^53 - 1),而 Java 里面的 Long 类型的范围是 -(2^64 - 1) ~ (2^64 - 1),在遇到类似问题的时候要先在RPC协议的逻辑里处理好。
- 协议包的切分
网络传输过程中并不会按照我们定义的包(Packet)一个个传输,而可能一次收到多个包,或者一个包分多次收到。在实现时必须要做好包的拆分,在NodeJS里可以通过net启用socket的读写来完成这块逻辑。
以上就是这篇文章里讨论RPC的全部内容了。
二、什么是CGI
2.1 概念
先来看维基百科,
In computing, Common Gateway Interface (CGI) is an interface specification that enables web servers to execute an external program, typically to process user requests.
CGI是一套能够让web服务器去执行外部程序的接口规范,通常是用来处理用户的请求。当我们讨论CGI时,通常指的是CGI脚本或CGI程序,即CGI的本质就是一个可执行程序。
根据某百度工程师的这篇文章,我们可以把CGI,Common Gateway Interface三个单词各做拆分,分别理解每个词代表的含义,
- Common,通用,CGI程序应该可以由任何语言来编写,或者说,所有支持标准输出,支持获取环境变量的编程语言都能用来编写CGI程序。
- Gateway,网关,更形象的叫法是“协议翻译机”。通常,网关的输入输出两端使用的是不同的协议。即一方是HTTP协议,另一方可能是其他协议,比如企业内部的自定义协议,CGI程序既是如此。
- Interface,接口,用户可以直接通过浏览器的http请求作为接口的输入,比如某个URL(
www.example.com/cgi-bin/helloworld.cgi),Web服务器调用helloworld.cgi之前,会把各类HTTP请求中的信息以环境变量的方式写入OS。除环境变量外,另外一个CGI程序获取数据的方式就是标准输入(stdin) ,假设以post方法请求一个CGI的URL,那么POST的数据,CGI是通过标准输入来获取到的。
那么输出呢?CGI的输出其实就是编程语言提供的标准输出,因为编程语言可以动态地获得一些信息,再把这些信息转换成字符串,所以CGI可以直接(动态地)返回一个html页面内容。根据这篇文章所言,CGI技术是上个世纪90年代最通用的动态渲染服务端内容( dynamically rendered server-side content) 的方式。
举个例子,下面就是一个用Bash写的简答的CGI脚本,
#!/usr/bin/bash
echo "Content-type: text/html"
echo ""
echo '<html>'
echo ' <body>'
echo ' <p>The time is: '
# Prints current date/time to STDOUT:
date
echo ' </p>'
echo ' <p>Your HTTP user agent is:'
# A CGI-enabled server will set this ENV var at runtime:
echo $HTTP_USER_AGENT
echo ' </p>'
echo ' </body>'
echo '</html>'
exit 0
可以看到,CGI的输出包括了Html页面的响应头和消息主体,直接在字符串里写html标签就好了。
2.2 缺陷
CGI脚本也有一些硬伤,下面这段话直接摘自上文引用的文章,
每次HTTP请求CGI,Web服务器都有启动一个新的进程去执行这个CGI程序,即颇具Unix特色的fork-and-execute。当用户请求量大的时候,这个fork-and-execute的操作会严重拖慢Web服务器的性能。
时势造英雄,FastCGI(简称FCGI)技术应运而生。简单来说,其本质就是一个常驻内存的进程池技术,由调度器负责将传递过来的CGI请求发送给处理CGI的handler进程来处理。在一个请求处理完成之后,该处理进程不销毁,继续等待下一个请求的到来。
另外,CGI程序还有一不大不小的缺陷,缺乏URL路由的功能,基本上一个CGI都是独立提供给外界访问,一个CGI就是独立的可执行程序。(然而实际上也能通过一些方法来达到路由的效果)
2.3 现状
CGI其实是一个比较古老的概念,在Restful风格API的出现,让CGI获得了续命。CGI解析前端请求,再转发给对应后端;然后从后端取回数据,给前端返回XML或JSON。
在字节镜像计划的《复杂生产环境的前后端数据高效交互方案-CGI》这篇直播分享中,讲了下面几点,
- CGI提供了一整套面向RPC微服务架构的、实现GraphQL规范的、开发友好的轻量级网关中台。
- 后端在CGI注册服务,前端申请介入后,网关提供地址,供前端应用调用后端接口。
- 前端直接调用RPC的问题是,必须经过一层Node Server,这就造成了一些资源浪费,每一个前端的服务都启一个Node Server的话会造成大量浪费。
可以看出,CGI可以作为前后端的中间层,作为一个公共的网关层,这个网关层是所有Node层的集合,降低了前端开发的复杂度(不用关心后端),同时充分利用了服务器资源(在网关层实现调度)。
三、什么是GraphQL
GraphQL是一种API查询语言,提出的目的之一是替代RESTful的设计方案。
GraphQL 是一种针对 Graph(图状数据)进行查询特别有优势的 Query Language(查询语言),适合大规模复杂数据的查询(换句话说,简单的查询依然使用RESTful的风格就可以了)。
根据这个知乎高赞回答,一个人要在 Facebook (或其他社交平台)上打开我的页面查看我的信息,你需要请求如下信息:
我的名字
我的头像
我的好友(按他们跟你的亲疏程度排序取前 6):
- 好友 1 的名字、头像及链接
- 好友 2 的名字、头像及链接
- ……
我的照片(按时间倒序排序取前 6):
- 照片 1 及其链接
- 照片 2 及其链接
- ……
我的帖子(按时间倒序排序):
- 帖子 1:
- - 帖子 1 内容
- 帖子 1 评论:
- - 帖子 1 评论 1:
- - 帖子 1 评论 1 内容
- 帖子 1 评论 1 作者名字
- 帖子 1 评论 1 作者头像
- 帖子 1 评论 2:
- - ……
- ……
- 帖子 2:
- - 帖子 2 内容
- 帖子 2 评论:
- - ……
- ……
如果我们用常见的 RESTful API 涉及,每个 API 负责请求一种类型的对象,例如用户是一个类型,帖子是另一个类型,那就需要非常多个请求才能把这个页面所需的所有数据拿回来。如果我们想查询一些没有直接定义好接口的数据,在不涉及后端改动的前提下,就需要前端自己通过现有的查询方式,再做一些适配来解决,这在复杂多变的场景下对前端工程师的心智是一个极大的考验。
所以,我们需要通过GraphQL来解决复杂查询的问题。
举个例子,如果前端希望返回一个 ID 为 233 的用户的名称和性别,并查找这个用户的前十个好友的名字和 Email,再找到这个人父亲的电话,和这个父亲的狗的名字,那么我们可以通过 GraphQL 的一次 query 拿到全部信息,无需从好几个异步 API 里面来回找:
query {
user (id : "233") {
name
gender
friend (first: 10) {
name
email
}
father {
telephone
dog {
name
}
}
}
}
由于GraphQL本身是一种查询语言规范,所以这个查询是完全自适配的,前端可以按照自己的需求自定义查询的内容。
那么,这个GraphQL与我们前面所说的RPC、CGI有什么关系呢?有下面几点,同样出自上面的直播分享,
- GraphQL是文档化的,便于在IDL(比如RPC thrift文件)和gql query字符串之间做转换
- GraphQL可以让接口的实现逻辑与前端解耦(通过CGI),前端可以按需获取返回值
- GraphQL还支持原生接口的聚合,可以一次请求访问多个接口
以上就是这篇文章关于GraphQL的全部内容了。
读完这些,你对“复杂生产环境的前后端数据高效交互方案”是不是有了更多的理解呢~