gRPC 在 iOS 项目(Objective-C)上的应用初探

3,034 阅读13分钟

什么是 RPC

RPC 英文简全称 Remote Procedure Call(远程过程调用)一种实现进程间通信的协议,主要功能目标是让构建分布式应用时更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。RPC 框架需提供一种透明调用机制让使用者不必显式的区分本地调用和远程调用,可以让使用者可以像调用本地服务一样调用远程服务。

一个基础的 RPC 框架 通常包含两部分,1、传输协议 2、序列化协议,成熟的 RPC 的库会封装如“服务发现”,"负载均衡",“熔断降级”一类面向服务的高级特性。

常用的传输协议包含:HTTP/HTTP2、TCP/UDP。

常用的序列化协议:基于文本编码的xml、json,基于二进制的protobuf、hessian等。

HTTP协议支持连接池复用也可以用 protobuf 二进制协议对数据进行序列化,但是HTTP协议佣有较多冗余的请求、响应头部报文。而自定义 TCP 协议则可以有效控制报文大小提高通信效率。

所以大多数 RPC 框架会选择选自定义 TCP 协议 + 基于文本的序列化协议来做为进程通信的通道。

gRPC 又是什么

gRPC 就是 google 版 RPC 的简称, 其传输协议使用 HTTP2, 序列化协议使用 protobuf。除此之外 gRPC 还包含成熟的 IDL 方案,接口及数据结构可编写 IDL 文件(.proto)再通过工具生成不同的平台的代码供调用。

gRPC 将 HTTP2 做为通信息协议,HTTP2相对HTTP1x有以下优化:

  • 二进制分帧

HTTP1.x以换行符作为纯文本的分隔符。HTTP/2将所有传输的信息分割为更小的消息和帧,并对它们采用二进制格式的编码。帧是HTTP2通信的最小单位,每个帧包含帧首部,首部会标识当前帧所局的消息。消息由一个或多个帧组成,例如请求消息或响应消息。

  • 多路复用

在HTTP1.x中,并发的请求会同时使用多个TCP连接并且会有数量限制。HTTP2 利用二进制分帧,同一个域名下的请求可以在单个连接上完成,数据以消息的形式发送而消息又可以由多个帧组成,多个帧之间可以乱序发送,接收方根据帧首部标识重新组装成消息。

  • 流优(请求)优先级

在 HTTP/2 中,每个请求都可以带一个31bit的优先值,0表示最高优先级, 数值越大优先级越低。有了这个优先值,客户端和服务器就可以在处理不同的流时采取不同的策略,以最优的方式发送流、消息和帧。

  • 服务器推送

相对于 HTTP1.x 的单请求单次响应不同,HTTP/2 可以实现单次请求多次响应的服务端连续发送消息。

  • 头部压缩

在连接存续期间,客户端和服务端会各自维护一份头部表,对于相同的头部数据头部信息不再发送,头部的键值对要么被更新,要么被追加。

HTTP2 解决了 HTTP1.x 存在的问题,在效率上接近 TCP 但又比 TCP 自定义协议更方便。TCP 自定义协议还需要自己解决并发连接数的控制、断连和重连机制、网络闪断、宕机保护、消息缓存和重发机制等等问题。

HTTP2 参考链接

Protocol Buffers(简称Protobuf) ,是Google出品的序列化协议,开发语言无关,和平台无关,具有良好的可扩展性。Protobuf和所有的序列化框架一样,都可以用于数据存储、通讯协议。Protobuf的序列化的结果体积要比XML、JSON小很多,XML和JSON的描述信息太多了,导致消息要大;此外Protobuf还使用了Varint 编码,减少数据对空间的占用。Protobuf序列化和反序列化速度比XML、JSON快很多,是直接把二进制流做位运算转换为完整对象,而XML和JSON还需要构建成 XML 或者 JSON 对象结构再做字段匹配。

gRPC 使用 HTTP2 传输协议传输 protobuf 序列化的二进制数据,因此有极高的效率、极低的资源占用率。

protocol buffer 官方文档

proto 文件怎么写

.proto 文件用于描述服务及接口名称以及请求/响应所用的数据结构。它充当了不同语言平台、服务之间的「说明文档」,通过特定的编绎工具 proto 文件可被翻译为 Go、Java、C++、Python、Objective-C等语言的接口实现代码。 proto 语法非常简单,先看一眼完整的 proto 文件:

// 指定 Protocol Buffer 版本
syntax = "proto3";

// 用于指定 Objective-C 类前缀
option objc_class_prefix = "RTG";

// 命名空间
package routeguide;

// 服务名称
service RouteGuide {

  // 接口名称,接口上方的注释编译后会保留成 API 注释
  rpc GetFeature(Point) returns (Feature) {}

  rpc ListFeatures(Rectangle) returns (stream Feature) {}

  rpc RecordRoute(stream Point) returns (RouteSummary) {}

  rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
}

// 数据结构描述
message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

message Rectangle {
  Point lo = 1;
  Point hi = 2;
}

message Feature {
  string name = 1;
  Point location = 2;
}

message RouteNote {
  Point location = 1;
  string message = 2;
}

message RouteSummary {
  int32 point_count = 1;
  int32 feature_count = 2;
  int32 distance = 3;
  int32 elapsed_time = 4;
}

定义一个服务的基本语法以 service 为关键词,后面跟着服务名称。一个服务在编绎后会成为一个单独的对象,服务内的接口会被编绎为对象内的方法(Objective-C)。

service RouteGuide {
   ...
}

定义好服务后就可以在服务内添加接口了,接口以 rpc 关键词做为开头来修饰,同时包含三个要素,接口名、请求数据结构、响应数据结构

// GetFeature是接口名,Point、Feature 是相应请求、响应数据结构
rpc GetFeature(Point) returns (Feature) {}

接口的请求和响应都可以定义为连续消息流,也就是说客户端发送一个请求数据而服务端可以返回多个响应数据。同样的,客户端也可以连续发送多个请求数据,服务端接收完所有请求数据后返回一个响应数据。只需要在对应的数据结构前添加 stream 关键词即可标识当前请求或者响应是否是连续的。

// 连续响应
rpc ListFeatures(Rectangle) returns (stream Feature) {}

// 连续请求
rpc RecordRoute(stream Point) returns (RouteSummary) {}

除了上面的请求或者响应为之一可以被定义为连续消息流外,请求响应也可以同时为连续消息流,只需要同时在请求响应数据结构前添加 stream 标识即可。连续的请求和连续响应是相互独立的,客户端和服务端都可以以任意组合去处理接收到的消息。例如:服务端可以接收完所有客户端的消息后再返回数据给客户端,或者服务端可以接收到一条消息立即返回一条消息,或者其它任何的组合。并且两端收到的消息顺序是和发送的顺序一致的。

rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}

数据结构名称前要以 message 关键字修饰,需要在字段前指定每个字段的类型。

每个字段都有一个编号,此编号通常是从1开始的自增数,原则上自增没有上限,但是单个数据结构字段编号最好不要超过16个,超过16个字段在进行二进制编码时会占用额外的空间。

Message 会编绎成与服务接口独立的文件,接口服务依赖 Message 编绎后的源代码。

message Point {
  int32 latitude = 1; // 1 是字段编号
  int32 longitude = 2; // 同上
}

message 内的字段同 json 转换为 model 类似支持嵌套。例如一个 Rectangle 需要用两个座标表示,而之前定义过 Point 那么在定义 Rectangle 内的字段时可以把 Point 做为坐标字段的类型。

message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

message Rectangle {
  Point lo = 1;
  Point hi = 2;
}

在编译为Objective-C接口库时可以指定一个类前缀,这个前缀将作用于所有服务、数据结构类名。

option objc_class_prefix = "RTG";

编绎 proto 文件

生成 Objective-C 客户端代码可以自己下载 protoc 及 grpc_objective_c_plugin 插件工具 通过命令行来编绎,但更为简单的方法是通过 cocoapods 进行编绎并集成到 iOS 项目里。官方提供了示例 podspec 文件,替换 proto 文件路径部分即可。

通过分析 podspec 得知,podspec文件主要做了两件事:

一、通过两个空的 Pod 依赖下载了 protoc 编绎器工具及 Objective-C 编绎插件。这两个依赖不包含任何源码仅仅只是利用 cocoapods a 工具的依赖管理下载编绎工具链,因此不会打包进项目里对项目产生影响。

二、在生成 pod 工程之前通过第一步下载的编绎器执行了一段编绎命令生成了源码文件到指定目录并将它作为项目的依赖集成到了项目中。这一过程与我们用 pod 管理第三方库是一样的,生成的源码和所需要的依赖可以在 Pod 工程中直接查看。

一个完整的 gRPC 服务接口会生成两种 Objective-C 文件,pbobjc 与 pbrpc。pbobjc 类型是 proto文件内的 Message, 也就是我们通常所说的 Model, 而 pbrpc 则是接口 API。

  • *.pbobjc.h
  • *.pbobjc.m
  • *.pbrpc.h
  • *.pbrpc.m

调用方式

API 的调用就非常简单了,与常用的网络库接口调用类似,gRPC 提供了 delegate 和 block 两种回调方式。 实际上 gRPC 在编绎出的 .pbrpc.h 文件内提供了两个版本的接口,只有版本1提供了 block 的回调方式,但版本1的接口官方在注释里说明不再推荐使用,所以暂时推荐使用的回调方式也只有 delegate。

delegate 调用方式

- (void)execRequest {
  RTGRectangle *rectangle = [RTGRectangle message];
  rectangle.lo.latitude = 405E6;
  rectangle.lo.longitude = -750E6;
  rectangle.hi.latitude = 410E6;
  rectangle.hi.longitude = -745E6;

  GRPCUnaryProtoCall *call = [_service listFeaturesWithMessage:rectangle
                                               responseHandler:self //< 指定代理
                                                   callOptions:nil];
  [call start];
}

// delegate method
// 指定代理回调执行的线程
- (dispatch_queue_t)dispatchQueue {
  return dispatch_get_main_queue();
}

- (void)didReceiveProtoMessage:(GPBMessage *)message {
  RTGFeature *response = (RTGFeature *)message;
  if (response) {
    ....
  }
}

- (void)didCloseWithTrailingMetadata:(NSDictionary *)trailingMetadata error:(NSError *)error {
  if (error) {
    .....
  }
}

block 方试调用接口(不推荐)

RTGPoint *point = [RTGPoint message];
point.latitude = 40E7;
point.longitude = -74E7;

[service getFeatureWithRequest:point handler:^(RTGFeature *response, NSError *error) {
  if (response) {
    // Successful response received
  } else {
    // RPC error
  }
}];

连续发送、接收

连续接收和单次请求响应的调用方式是一样的,唯一不同的是连续请求的 didReceiveProtoMessage 回调方法会被调用多次。

连续发送是调用服务对象的接口方法后返回一个对象,调用这个返回对象的 start 方法。之后再连续写入(writeMessage)请求对象。

GRPCStreamingProtoCall *call = [_service recordRouteWithResponseHandler:self
                                                              callOptions:nil];
[call start];
for (id feature in features) {
    RTGPoint *location = [RTGPoint message];
    ...
    [call writeMessage:location];
 }
[call finish];

最后再调用 finish 告诉服务端请求数据发送完毕。

发送和接收同时连续时的调用方式就是连续接收和连续发送的结合。

- (void)execRequest {
  NSArray *notes = @[[RTGRouteNote noteWithMessage:@"First message" latitude:0 longitude:0],
                     [RTGRouteNote noteWithMessage:@"Second message" latitude:0]
                     ...];

  GRPCStreamingProtoCall *call = [_service routeChatWithResponseHandler:self // 代理 
                                                            callOptions:nil];
  [call start];
  for (RTGRouteNote *note in notes) {
    [call writeMessage:note]; // 连续发送
  }
  [call finish]; // 通知接收端发送完毕,无限发送时可不调用
}

// 接收方法会被调用多次
- (void)didReceiveProtoMessage:(GPBMessage *)message {
  RTGRouteNote *note = (RTGRouteNote *)message;
  if (note) {
    ...
  }
}

// 发生错误或者接收完毕会被调用
- (void)didCloseWithTrailingMetadata:(NSDictionary *)trailingMetadata error:(NSError *)error {
  if (!error) {
    ...
  } else {
    ...
  }
}

接口兼容

无论接口建立在哪种网络通信方式都不可避免的存在接口兼容的问题, gRPC 既然作为通用的服务调用框架自然也需要解决接口兼容问题。接口兼容可以分为「前向兼容」、「后向兼容」。举个栗子,如果接收方升级了自己的接口字段,发送方还是使用的老版本接口字段,接收方还能正常解析老版本的数据则称为后向兼容。如果发送方升级了自己的接口字段,接收方还是使用的老版本字段,接收方还能解析出来新版本的数据则称为向前兼容。

gRPC 使用 Protobuf 作为消息编码方式,接口兼容又主要是消息字段的兼容处理,所以接口的兼容实际上就是 Protobuf 解析字段时的兼容处理。处理向后兼容也就是接收方使用新版本时,解析接收到的老版本数据流可能出现的情况会是新版本新增了字段或者新版本更改了字段,由于基数据流中不存在新增或修改的字段所以解析时会生成默认值。前向兼容是接收到的数据流包含接收方不可识别的字段,这种情况会直接丢弃该字段数据。

前向兼容和后向兼容的前提是字段名称和字段编号在基生命周期内不可重用,也就是说当你需要更改字段名和编号时需要标记老字段为保留(reserved)字段,以防止以后再有人使用这些用过的字段名和编号。因为对于已经使用过的字段可能会存在于某些老版本的客户端中,如果再有其他开发使用被用过了的字段名和编号会使老版本的客户端收到的数据含义发生改变。

enum Foo {
  reserved 2, 15, 9 to 11, 40 to max;
  reserved "FOO", "BAR";
}

另外还可对类型进行更改,只不过这种更改限定于同一个系列。例如: (int32, uint32, int64, uint64, and bool)、(sint32, sint64)括号内的类型可以相互转换。

总节

gRPC 在客户端侧的功能表现几乎就是一个网络框架、API接口与 Model 数据的集合,gRPC 帮我们实现了网络请求管理、接口定义与 Model 解析,它完全屏蔽了网络数据的转换,让使用者无需关心 Model 转换连接管理等细节。调用 gRPC 接口就像调用一个本地异步服务一样,使用 gRPC 可以做到完全替代客户端的网络层。

优点

  • 前后端共用的接口描述,大大减少多端沟通成本
  • 出色的传输及数据压缩性能,有效减少通信带宽要求
  • 较好的易用性,接入方便
  • 压缩二进制数据,安全性好
  • 社区活跃,稳定性高

缺点

  • 有一定的学习成本
  • 第三方库依赖,增大包体积
  • 与现有通信不兼容,需求不高时显得过重
  • 二进制压缩数据通信带来额外的抓包分析成本