Why RPC?

在 HTTP 和 RPC 的选择上,可能有些人是迷惑的,主要是因为有些 RPC 框架配置复杂,如果走 HTTP 也能完成同样的功能,那么为什么要选择 RPC?

RPC,即 Remote Procedure Call,远程过程调用,主要是基于 TCP/IP 协议。而 HTTP 服务主要是基于 HTTP 协议的。我们都知道 HTTP 协议是在传输层协议 TCP 之上的,所以就效率来看的话,RPC 当然是要更胜一筹。另外, HTTP 的最大优势在于双端异构下的无障碍传输,但由于公司内部服务基本不存在异构的情况,所以这个优点微乎其微。所以,RPC 主要用于公司内部的服务调用,性能消耗低,传输效率高,服务治理方便。HTTP 主要用于对外的异构环境,浏览器接口调用,APP 接口调用,第三方接口调用等。

与 HTTP 的异同

传输协议

RPC:可基于 TCP 也可基于 HTTP

HTTP:HTTP协议

传输效率

RPC:使用自定义的 TCP 协议,可以让请求报文体积更小,或者使用 HTTP2 协议,也可以很好的减少报文的体积,提高传输效率

HTTP:如果是基于 HTTP1.1 的协议,请求中会包含很多无用的内容,如果是基于 HTTP2.0,那么简单的封装以下是可以作为一个 RPC 来使用的

性能消耗(主要在于序列化和反序列化的耗时)

RPC:可以基于 thrift 实现高效的二进制传输

HTTP:超文本传输协议,大部分是通过 json 来实现的,字节大小和序列化耗时都比 thrift 要更消耗性能

负载均衡

RPC:基本都自带了负载均衡策略

HTTP:需要配置 Nginx,HAProxy 来实现

其他

rpc 框架一般还包含以下 HTTP 没有的高级功能:

  • 服务治理(下游服务新增,重启,下线时如何不影响上游调用者)
  • 服务熔断/降级
  • 流量监控等等

RPC 框架 ——基于 Thrift 服务的 Kite

一个 rpc 调用,一般分为以下几步

  1. 发送方将请求序列化
  2. 发送方通过网络发送
  3. 接收方通过网络接受
  4. 接收方反序列化得到请求

当然在实际使用中还有很多额外的工作要做,服务端要监听端口,客户端要进行链接,服务端还要选择如何处理请求,多线程,线程通信等等一系列工作,需要处理有很多。

Thrift

Thrift 通过分层的方法,把整个过程分为四层,每一层解决一个问题,上下层之间提供服务。

  1. server:完成端口的监听,当有链接到来时为其创建 transport,protocol,并调用相应的 processor 处理
  2. processor:对外提供一个统一的 process(in, out protocol) 接口
  3. protocol:完成序列化
  4. transport: 完成具体的传输功能(通过网络发送,写入磁盘等)

一个大概的处理过程例子如下:(我觉得图里的序列化和反序列化写反了)

image-20190903153604699

因为 thrift 采用了分层,使得各层之间可以互相独立,所以如图中标注,虚线以下的代码是静态生成的,这部分代码,主要是通过传入的 TProtocol 完成反序列化,然后将得到的请求传递给用户注册进的 Handler 去处理,处理完以后,再通过 TProtocol 序列化应答发送回去。下面这部分,就是工具通过 idl 生成的,一般称为 stub。stub 通过传入的 TProtocol 完成读取数据,反序列化,调用 handler 处理,序列化应答,发送功能。

虚线以上,完成了链接的建立,在用链接创建 TTrannport,用 TTransport 创建 TProtocol。这部分决定了链接如何建立,序列化格式是什么。最后将封装了序列化/反序列化操作的 TProtocol 传递给 stub。

Thrift 这种分层的设计很好的将具体的序列化/反序列化操作与普通的服务端链接建立,数据读取,协议格式等进行了解藕,服务端可以专心在虚线以上部分的建设,其余的交给 stub。

Kite

kite 框架,其实完成的就是上一节说的那幅图中的虚线以上部分。对照图,可以分为三部分:

  • 为新链接建立 TProtocol 对象

  • 把用户的 Handler 注册到 TProcessor 中

  • 把 TProcessor 注册到 kite 框架中
构造 TProtocol 对象
  1. kite 直接使用了 golang 标准库 net。net.Listen 监听,然后直接开启一个 for loop 开始 Accept。
  2. Accept 完成链接建立以后得到 net.Conn 对象。
  3. 开启协程处理接下来的步骤。
  4. 用 net.Conn 构造 TTransport 对象,再构造 TProtocol 对象。

    这几步和上图中描述的是差不多的。接下来是把 Handler 注册到 stub 中,并且把 stub 注册到 kite 框架中。stub 代码是工具已经生成的,对外提供了注册接口。Handler 代码是 kitool 工具生成的,注册操作也是 kitool 生成的代码中完成的。

kitool 工具的角色其实有两个功能:

  • 为 thrift 生成的 stub 代码,生成 Handler
  • 将 stub 注册到 kite 框架
Handler 注册

kitool 默认生成了一个 Handler,然后调用了 stub 的注册完成注册,这一部分比较简单

1
Hello.NewHelloServiceProcessor(&HelloServiceHandler{})
stub 注册

stub 是通过工具从 idl 中生成的,独立于 kite 框架。框架中提供了接口将外部的 stub 注册进来。在 kite 框架中有一个 export 的全局变量 Processor thrift.TProcessor,给这个变量赋值就完成了 stub 的注册。

实际上这个操作是由 kitool 生成的代码完成的。通过 kitool 生成的项目在项目根目录下有一个 kite.go 文件,这个文件里就完成了 stub 的注册:

1
2
3
func init() {
kite.Processor = Hello.NewHelloServiceProcessor(&HelloServiceHandler{})
}

最后,把 TProtocol 交给 Processor 处理即可。

Middleware

可以发现,到目前位置,kite 框架看起来是比较“简单”的,但是似乎有很多 routine work 多没有提到,比如限流,日志记录,监控上报等等。这就要提到 middleware(中间件)了。kite 通过使用中间件将框架主体与这些 routine work 进行了解藕。kite框架主体,只关注底层请求的接入,routine work 全都集中在了 middleware 当中。这一部分是在 kitool 生成的 Handler 中完成的。

这里先引入两个类型:

  • EndPoint
  • Middleware
1
2
3
4
// EndPoint represent one method for calling from remote.
type EndPoint func(ctx context.Context, req interface{}) (resp interface{}, err error)
// Middleware deal with input EndPoint and output EndPoint
type Middleware func(EndPoint) EndPoint

可以发现,EndPoint 是一个函数类型,middleware 实际上是一个高级函数,入参是 EndPoint,返回值也是 EndPoint。有这两个类型就可以写出类似这样的代码:

1
2
3
4
5
6
7
8
func MyMW(next EndPoint) EndPoint {
return func(ctx contex.Context, req interface{}) (resp interface{}, err error) {
//do something before next
rsp, err := next(ctx, req)
//do something after next
return rsp, err
}
}

这里的 MyMW 返回了一个新的 EndPoint,实际上是一个闭包,wrap 了 next。当执行下面的代码时,实际上执行的是 MyMW 返回的新 EndPoint,这个 EndPoint 可以在执行 next 之前/之后,执行一些 pre/after 操作。

1
newNext := MyMW(next)

并且,由于 middleware 的入参和返回值都是同一个类型,因此 middleware 还可以串联起来:

1
2
3
n1 := MyMW1(next)
n2 := MyMW2(n1)
n3 := MyM3(n2)

可以发现,使用中间件我们可以在原始的函数之外包上很多层的逻辑。回到框架,kite 就是使用这样的方法在最原始的业务处理函数之外包裹整个过程不需要侵入框架或则侵入业务处理函数中做任何的修改,以一种方便,可扩展,可维护的方式拓展了框架的功能。