gRPC 碎碎念
09 Jan 2017前言
新开的一个项目,后台童鞋提议使用 gRPC,于是默默上了 gRPC 这条贼船。目前行使平稳,有些小颠簸,但可以克服。
gRPC 概述
gRPC 是由 Google 与 2015 年开源的一套主要面向移动应用开发的 RPC 框架。
相对于其他 RPC 框架而言,它有两个显著的特点:
- 基于
HTTP/2协议标准设计
移动网络高延迟,低带宽,高丢包率的状态,使得我们需要进行大量的网络调优。而 HTTP 1.1 的一些特性使得它并不能很好的适应移动网络,一方面使用文本协议和无法复用 HTTP 头使得 HTTP 1.1 的流量消耗较大,另一方面 HTTP 1.1 的请求是有序堵塞的,使得 head-of-line blocking 问题十分严重,即使采用多连接和 pipelining 效果仍有限。但 HTTP/2 则可以用比较有效解决这些问题:采用二进制协议,完全多路复用,报头压缩,更能主动推送消息到客户端。
- 强大的
IDL特性
默认情况下,gRPC 使用 protobuf 作为 IDL(Interface Definition Language) 来定义服务(当然也可以使用 json 等):给定相应服务的 .proto 文件,gRPC 可以通过插件生成相应的客户端和服务器调用过程代码,使得客户端和服务器不再需要关心具体的请求装配,收发,解析过程,而更专注于相应的业务逻辑。
使用 gRPC 作为 RPC 框架的典型开发流程如下
- 后端定义服务,生成
.proto文件 - 后端通过插件生成服务器接口代码,填充实现
- 客户端通过插件生成对应客户端代码,并调用
iOS 中的 gRPC
拍完一波 gRPC 的马屁,让我们进入 iOS 端的流程。
一个最简单的服务定义如下:gRPC Helloworld example
// 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 greetings
message HelloReply {
string message = 1;
}
这里定义了一个 Greeter 服务,通过 HelloRequest 参数,填充相应的参数 (name),调用后服务器返回相应的文本信息,基本是一个最简单的 echo service 的实现。那么问题是,在 iOS 端我们怎么来使用这个服务定义呢?
当然我们需要引入 gRPC 框架,这里推荐使用 cocoapods 引入 gRPC 的 ObjC 实现。一方面 swift 版 gRPC 刚 release 不久,并不一定很稳定,另一方面,gRPC 框架有各种依赖,手动导入基本不太可能,只能通过 cocoapods 进入导入。
但不像使用其他第三方库一样我们可以直接 pod grpc,使用 gRPC 需要另辟蹊径。原因在于 gRPC 为了方便各个平台能够方便使用,除提供一个基于 C 语言的核心实现外,还需集成各种胶水库,工具链。一个完整的 gRPC 框架依赖如下组件
| 组件 | 作用 |
|---|---|
| Protobuf | Protobuf,序列化反序列化框架 |
| gRPC-Core | C 语言 gRPC 实现 |
| gRPC | ObjC gRPC wrapper |
| gRPC-ProtoRPC | ObjC gRPC Serivce 定义 |
| gRPC-RxLibrary | Reactive 拓展 (大雾,好贴心) |
| ProtoCompiler | Protobuf 编译器 |
| ProtoCompiler-gRPCPlugin | gRPC protobuf 编译器插件 |
官方推荐的做法是自定义一个本地私有 podspec,客户端通过 pod install 这个 podsepc 导入所有依赖库并串联所有流程。一个最简单的 podspec 可以参考 gRPC Helloworld example
除去前面一些常见的说明外,这个 podspec 最重要的点在于
# Base directory where the .proto files are.
src = "../../protos"
# Run protoc with the Objective-C and gRPC plugins to generate protocol messages and gRPC clients.
s.dependency "!ProtoCompiler-gRPCPlugin", "~> 1.0"
# Pods directory corresponding to this app's Podfile, relative to the location of this podspec.
pods_root = 'Pods'
# Path where Cocoapods downloads protoc and the gRPC plugin.
protoc_dir = "#{pods_root}/!ProtoCompiler"
protoc = "#{protoc_dir}/protoc"
plugin = "#{pods_root}/!ProtoCompiler-gRPCPlugin/grpc_objective_c_plugin"
# Directory where the generated files will be placed.
dir = "#{pods_root}/#{s.name}"
s.prepare_command = <<-CMD
mkdir -p #{dir}
#{protoc} \
--plugin=protoc-gen-grpc=#{plugin} \
--objc_out=#{dir} \
--grpc_out=#{dir} \
-I #{src} \
-I #{protoc_dir} \
#{src}/helloworld.proto
CMD
在 pod install 时,使用 protoc 和 grpc_objective_c_plugin 编译 src 中的 proto 文件,生成相应的 RPC 代码并最终导入工程。最后通过 #import 响应服务头文件调用方法就完成了一个 gRPC 远程请求的过程。
Greeter *client = [[HLWGreeter alloc] initWithHost:kHostAddress];
HelloRequest *request = [HLWHelloRequest message];
request.name = @"Objective-C";
[client sayHelloWithRequest:request handler:^(HLWHelloReply *response, NSError *error) {
NSLog(@"%@", response.message);
}];
一些小问题
从客户端的角度而言,gRPC 的确很简单,将复杂的网络请求变成了一个简单的 RPC 调用过程,但是在使用 gRPC 的时候还是碰到了一些小问题。
自动生成的代码类名没前缀
ObjC 只能通过前缀避免命名冲突。但默认生成的 gRPC Messages 和 Services 都是没有任何前缀,如 Greeter 和 HelloReqeust。很明显前期的 gRPC 开发对 ObjC 并不了解,甚至于他们自己的 gRPC-ProtoRPC 库中类都是没有任何前缀,如 ProtoRPC,直到后期才开始添加 GRPC 作为前缀:GRPCProtoCall,并将前者标记为废弃。
目前的处理方法是在 proto 文件开始处通过 objc_class_prefix 选项为生成的类制定前缀。
option objc_class_prefix = "NTES";
不过需要吐槽的一点是,难道不应该在 podspec 实现这个功能才更为简单么?
无法为所有的 RPC 提供全局拦截器
出于两方面的考虑,我们需要为所有的 gRPC 请求添加全局拦截器
- 日志输出,记录所有请求信息,方便后续排查
- 全局错误处理,如
session过期这种业务逻辑
然而通过 gRPC complier plugin 生成的 RPC 都是以 Service 为单位,提供一个集中式的 API 对应 RPC 的管理方式,即一个 RPC 调用就是对应一个网络请求,所有网络请求都被分开操作,没有任何关联关系。从 RPC 这个概念出发,这种做法是可取的,但是出于具体业务的需求,我更推荐使用基于基类/协议的网络请求封装:所有请求都继承自某个基类/实现某套协议接口,一个网络请求对应一个类,但他们都通过统一的流程进出,自定义需求通过重写基类/协议方法来实现。不过这只是个人设计网络协议时的一种倾向,而回到 gRPC 这边,问题就变成了:既然它已经设计成这种,我们应该怎么插入自己的全局拦截器呢?
- 方法一:抛弃
gRPC Service层代码
我们会发现,在使用 protoc 和 plugin 的时候有两个参数 --objc_out,--grpc_out 分别制定生成的 Messages 和 Services。那么我们就可以只是使用 Messages 中提供的请求和响应类,直接废弃 gRPC 自动生成的 Services 层,通过自主构造 GRPCCall 的方法进行调用。但是这种改动的问题是容易引起调用的不一致,尤其是当后端修改相应服务方法名后。
- 方法二:重写
gRPC ProtoCompiler Plugin代码,重新生成RPC层代码
同样是修改生成 RPC 代码的流程,这种方法会安全许多:通过修改 plugin 的代码,按照自己的意愿生成相应的 RPC 层代码,同时由于只是修改而非废弃原 Service 层代码,仍旧能够保证各个 RPC 方法名,请求,响应类和服务器接口的一致性。唯一的问题是需要维护一个私有的 ProtoCompiler Plugin pod 仓库。有兴趣的同学可以参考 complier 里 objc 相关的实现代码,不过值得吐槽的是,这个 plugin 工程需要使用 Visual Studio 打开编译。(大雾)
- 方法三:通过
AOP进行拦截
这种是我们现在正在使用,也是 ObjC 里喜闻乐见的方法,通过 method swizzle 替换掉所有 RPC 方法,并将所有的回调进行统一包装,就可以实现全局拦截的作用。
- (void)hookAllGRPCCall:(Class)cls
{
unsigned int count = 0;
Method *methods = class_copyMethodList(cls, &count);
for (unsigned int i = 0; i < count; i++)
{
SEL sel = method_getName(methods[i]);
NSString *selName = NSStringFromSelector(sel);
if ([selName hasPrefix:@"RPCTo"]) //所有生成 RPCCall 的方法都有这个前缀
{
FCGRPCHookBlock block = ^(id<AspectInfo> info,id request,GRXSingleHandler handler)
{
NSInteger requestId = [self requestId];
DDLogInfo(@"begin grpc id %zd for %@ %@\nrequest %@",requestId,cls,selName,request);
GRXSingleHandler hookHandler = ^(id value, NSError *errorOrNil){
DDLogInfo(@"end grpc id %zd for %@ %@\nresponse %@ error %@",requestId,cls,selName,value,errorOrNil);
#warning todo 添加 session失效后重新请求的逻辑
if (handler) {
handler(value,errorOrNil);
}
};
NSInvocation *invocation = [info originalInvocation];
[invocation setArgument:&hookHandler atIndex:3];
[invocation invoke];
};
NSError *error = nil;
[cls aspect_hookSelector:sel
withOptions:AspectPositionInstead
usingBlock:block
error:&error];
if (error)
{
DDLogError(@"swizzle %@ selector %@ failed",cls,selName);
}
}
}
}