gRPC 碎碎念

前言

新开的一个项目,后台童鞋提议使用 gRPC,于是默默上了 gRPC 这条贼船。目前行使平稳,有些小颠簸,但可以克服。

gRPC 概述

gRPC 是由 Google2015 年开源的一套主要面向移动应用开发的 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 框架的典型开发流程如下

  1. 后端定义服务,生成 .proto 文件
  2. 后端通过插件生成服务器接口代码,填充实现
  3. 客户端通过插件生成对应客户端代码,并调用

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 引入 gRPCObjC 实现。一方面 swiftgRPCrelease 不久,并不一定很稳定,另一方面,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 时,使用 protocgrpc_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 MessagesServices 都是没有任何前缀,如 GreeterHelloReqeust。很明显前期的 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 层代码

我们会发现,在使用 protocplugin 的时候有两个参数 --objc_out,--grpc_out 分别制定生成的 MessagesServices。那么我们就可以只是使用 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);
            }
            
        }
    }
}