微信号:infoqchina

介绍:有内容的技术社区媒体

偷师饿了么:怎样用HTTP/2优化iOS APP网络层次架构?

2016-03-07 08:04 王朝成

HTTP/2,是HTTP协议发布后的首个更新,于2015年2月17日被批准。它采用了一系列优化技术来整体提升HTTP协议的传输性能,如异步连接复用、头压缩等等,可谓是当前互联网应用开发中,网络层次架构优化的必选方案之一。

Apple对于HTTP/2的态度也非常积极,5月HTTP/2正式发表后不久,便在紧接着6月召开的WWDC 2015大会中,向全球开发者宣布,iOS 9 开始支持HTTP/2。尽管Apple早早地宣布支持HTTP/2,但是现在整个技术圈内提及的iOS网络层架构设计还大多数停留在HTTP 1.1时代,并没有一个与时俱进的、包含HTTP/2优化的网络层架构设计策略。

对于架构设计,我曾说过,脱离业务谈架构就是纯粹的耍流氓。因此,架构的设计一定要结合当前的业务需求来进行设计和规划,并且做好一定的可扩展性,以应对未来的变化。

本文会结合当前饿了么的业务谈谈以下几个方面内容:

  1. 如何在iOS下使用HTTP/2?

  2. 如何设计一个iOS的网络层架构?

  3. 与时俱进下,我们的解决方案?

1
与时俱进:HTTP/2下的iOS网络库

移动端的APP,网络层是一个几乎完全不可或缺的角色。而也正是在网络层,由于不同家的业务模型、业务结构不一样,使得网络层的架构呈现一种百家争鸣的局面。另一方面,Apple对网络层的API也是有比较好的封装;即使你不是太熟悉Apple的网络API,使用业界流行的AFNetworking或者ASIHttpRequest也是可以简化不少的操作。不过后者的作者已经多年不维护了,因此AFNetworking基本上已经成为了iOS APP的标配。 

Apple在CocoaTouch层基于CFNetworking库提供的网络API有两个大类:NSURLConnection和NSURLSession。后者从iOS7开始出现,并宣称是NSURLConnection的替代者。随着时间的推移,WWDC 2015的召开也正式宣布了iOS9中NSURLConnection的deprecated标注,完成了其历史使命,也兑现了之前的承诺。

不过在实际的开发过程中,我们可以发现,尽管标注了deprecated,并不意味着NSURLConnection的库不可以继续使用;而且,由于习惯性问题,还是有很多的工程师仍旧执着于NSURLConnection所带来的熟悉味道;特别是使用AFNetworking库的工程师们,由于AFNetworking对于NSURLConnection的封装非常精美,并且可以根据业务需要自定义添加相应的依赖关系,使得其在实际应用中让人感到无比的“舒服”。

然而,鱼与熊掌总是不可兼得。WWDC 2015 Session711 告诉我们,从 iOS 9 才开始支持的 HTTP / 2 协议只能在 NSURLSession 中使用。这也就意味着,要想进化到 HTTP / 2 就不得不舍弃陪伴我们多年的 NSURLConnection ,而且还要将设计网络层架构的思维方式调整到 NSURLSession 上来。

不过庆幸的是,AFNetworking从2.0开始也提供了NSURLSession的实现版本,并且相关的 API 并没有太大的变动,对于一般性的迁移还是能够轻松应对;并且,从AFNetworking 3.0开始,正式抛弃NSURLConnection,全面投入NSURLSession的怀抱。 

如果在您的网络层设计中,采用了AFNetworking来降低设计的复杂性,那么正如前面提到的,由于两者在 API 方面并没有太大的差异,因此在一般的网络层迁移过程中可以平滑地过渡。而如果你在网络层设计中直接采用了原生的 API,也不需要担心,因为 NSURLSession 的 API 被设计的更加美妙,也更加易用。不过,尽管NSURLSession有非常多值得称赞的地方,但是它毕竟是一种新的设计思想和理念。因此在实际的使用过程中我们会发现很多与之前设计思维相抵触的地方,尤其是在网络依赖性处理上,就连AFNetworking也做得不是太好,这点我们在后面的章节中还会再次提到。 

综合以上而言,技术的脚步永远向前,仅凭 HTTP / 2 这一点,我们相信 Apple 也一定会把重心向 NSURLSession 偏移,继续优化她,而我们也要跟上历史的车轮,是时候让迟暮的 NSURLConnection 休息了。

2
发展进击:iOS网络层的架构设计

架构的设计总是和业务的发展相结合和适应的。在饿了么移动多款App的发展过程中,由于不同业务的差异性导致接口、协议等都有不同的需求,给多款App设计出一个拥有干净API和高度内耦合的网络层成为了一项挑战,而这个设计也将直接影响我们APP业务工程师们的开发效率。这节主要阐述理论,抛出一些问题。在下一节会给出结合饿了么多款APP的业务下所设计的网络层的解决方案。 

本节我们主要讨论两点:

  1. 与业务相结合的网络层设计

  2. 与安全相关的网络层设计 

与业务相结合的网络层设计

先来看与业务相结合的网络层设计。 

与业务相连接最紧密的地方必然是输入与输出,而网络层的功能无疑是接受输入的数据,挑选一个相应的通道组装数据发送给服务器,然后将服务器返回的数据返回给上一层,即输出数据。接下来我们从数据的角度来看看在网络层设计中需要考虑些什么样的问题。这里我们会从以下三个方面来进行阐述: 

  1. 数据输入

  2. 数据回调

  3. 数据转换

    • 数据输入

    首先是输入过程。业务数据调用网络层接口时可以称之为输入,这里一般会有两种形式的设计。 

    第一种比较常见,很多时候会被称为集中式的API处理,即将一些经常使用的网络层调用的代码封装成一到两个函数供上层调用。上层输入相关的参数便能取得相关的回调。如以下函数: 

    + (void)networkTransferWithURLString:(NSString *)urlString

                           andParameters:(NSDictionary *)parameters

                                  isPOST:(BOOL)isPost

                            transferType:(NETWORK_TRANSFER_TYPE)transferType

                       andSuccessHandler:(void (^)(id responseObject))successHandler

                       andFailureHandler:(void (^)(NSError *error))failureHandler {

                       // 封装AFN

                       }

    另一种形式的设计,则采用一种继承形式设计每一个API,而每一个API都对应一个类,这个类中将该API的所有参数都设定好,并提供“开始”接口和“返回”的Block,很多时候我们称这种为分布式的API处理。一个比较通用的BaseAPI可以有下列可配置项:

    typedef NS_ENUM(NSUInteger, DRDRequestMethodType) {

        DRDRequestMethodTypeGET     = 0,

        DRDRequestMethodTypePOST    = 1,

        DRDRequestMethodTypeHEAD    = 2,

        DRDRequestMethodTypePUT     = 3,

        DRDRequestMethodTypePATCH   = 4,

        DRDRequestMethodTypeDELETE  = 5

    };

    @interface DRDBaseAPI : NSObject

    @property (nonatomic, copy, nullable) NSString *baseUrl;

    @property (nonatomic, copy, nullable) void (^apiCompletionHandler)(_Nonnull id responseObject,  NSError * _Nullable error);

    - (DRDRequestMethodType)apiRequestMethodType;

    - (DRDRequestSerializerType)apiRequestSerializerType;

    - (DRDResponseSerializerType)apiResponseSerializerType;

    - (void)start;

    - (void)cancel;

    ...

    @end

    每一个具体的API都可以继承自这个BaseAPI,当上层业务需要进行网络调用时,实例化一个需要调用的API接口,对返回的Block进行编码,同时开启接口。如以下代码: 

    DRDAPIPostCall *apiPost = [[DRDAPIPostCall alloc] init];

    [apiPost setApiCompletionHandler:^(id responseObject, NSError * error) {

    }];

    [apiPost start];

    这两种网络层接口的设计其实都是对应不同的业务所产生出来的思维,因此必然都有其优缺点。例如,第一种形式的接口其优点在于简单粗暴,适用于业务逻辑相对简单并且统一的RESTFUL API网络接口。但是缺点也非常明显,一旦遇上稍微复杂一些的网络接口情况,便需要在ViewController里写入大量的逻辑来达到目的。这也同时会使得原本就臃肿不堪的ViewController变得更加的庞大。

    第二种的设计则优雅得多。将大量的配置逻辑都放在了另一个类文件中进行设计,ViewController中的代码会变得更轻盈一些。而将配置放在类中还有另一个好处,那便是可以增加许多平时不使用的可配置项,来增加整个网络层的可扩展性;与此同时,每一个API对应了一个不同的类的设计,又可以让不同的API可以有不同的表象,如分别遵循不同的JSON-RPC版本。

    不过这种形式的设计也存在触目惊心的问题,那便是类爆炸。如果是小型的APP,则问题并不是那么明显;而如果是中大型APP的话,动则上百个API,会使得后期的维护变得有些吃力。 

    • 数据回调

    说完了输入问题,接下来输出的设计。在输出部分的设计中,可以说是八仙过海各显神通。 

    网络层的传输大多以异步加载为主,即服务器响应后由网络层来负责将数据推给上层业务线程。在iOS的体系中,也提供了很多种方式用于这种场景的处理,例如直接广播的Notification、函数回调的delegate以及最具特色的Block,都能够完成这种任务。那么采用哪种方式呢?在回答这个问题前我们先来看看这几种方式其各自的优缺点。 

    • Notification

    Notification,顾名思义的广播,其特点在于一对多地发送相关数据的通知。优点非常明显,易于实现;但缺点也很明显,会破坏整个APP架构设计中的层次结构,造成跨层的调用和处理。 

    • Delegate

    Delegate,最常用的的回调方式。优点是后期易于维护且不会造成跨层的调用;缺点则是回调代码与输入的逻辑代码大部分时候不会放在一起,增加了一些后期阅读上的成本。 

    • Block

    Block是OC语言中的特性,其优点恰好是Delegate的缺点,即它让回调的代码能够和调用的代码保持在相同位置,利于静态代码追踪和逻辑思维的延续。缺点则在于容易造成循环引用(Retain Cycle);并且对于大型APP来说,埋点这种AOP行为通常在Block中难以为继,且会造成Debug上的一些困难。 

    在Block的使用过程中,一定要注意使用weakSelf和strongSelf来打破循环引用。否则造成的内存泄漏会造成后期排查的困难。

    • 小结

    也许读者看到这会更困惑了,究竟什么样的方案更佳?个人认为还是要从业务需求出发来进行设计,从我自身而言我更喜欢Block+Notification的形式,然后在适当的时候辅以Delegate完成。 

    • 数据转换

    数据回调的问题已经基本解决,但是新的问题也摆在了我们的面前:上层该看到怎样的数据? 

    在这里我们会发现非常多的应用场景,比如大多数情况下,业务层都希望返回的是与其自身相关的数据结构(Model实例),在这样的前提下能够非常地方便地对本地的数据进行相关的操作;而又比如说查询一个操作结果的是与非,那么本身数据就只有一个yes或者no,这时候采用一个数据结构来囊括便会显得复杂和臃肿;又或是网络层采取了JSON-RPC这样的协议,返回回来的信息存在大量的冗余数据,但上层业务却是若水万千只取一瓢饮。 

    从以上各种场景中我们可以看到,业务所需的数据形式非常多变,因此最好的方式还是交给上层自己去处理。一种常见的方法就是设定一个Delegate或者Block进行返回数据的转换,将JSON或者XML等格式转成所需要的数据格式以方便上层业务继续处理。不过我个人更倾向于在API本身就实现好这个Delegate或者Block所描述的转换函数,这样会让API的层次更加清晰。下一节我会谈到我们的处理方式。

    与安全相关的网络层设计

    接下来我们来看看与安全性相关的设计。其实总体来说,使用了HTTPS基本上就已经足够保证你的网络安全性了,这里我也就不一一列举其好处。事实上国外多数的大公司以及国内的BAT几乎都已经是全站HTTPS了,免费的SSL证书的申请难度也在不断降低,门槛上已经可以说是没有门槛。因此为了站点的安全性,上HTTPS吧。 

    不过,HTTPS如果使用不当仍然会存在一些的小缺陷,MITMA(Man-in-the-middle attack)攻击便是其中的一种。尝试这样一种情况,使用Charles这样的抓包工具来抓取HTTPS的包,Charles会让我们去安装它自己颁发的根证书。一旦我们选择和信任了这个根证书,我们会发现Charles能够顺利地显示整个HTTPS通信的情况了。

    对于这种中间人攻击,目前一般的解决方案即采取SSL Pinning,即将服务器的公钥证书与整个APP打包在一起发出,然后在网络请求时候将服务器发送过来的证书与本地证书进行比较,从而避免中间人攻击的可能性。关于这部分的设计,AFNetworking已经有相关的实现了,我在《正确使用AFNetworking的SSL保证网络安全》有过详细阐述,这里就不再赘述了。 

    3
    来点实际的:解决方案

    说完了理论,现在结合实际来谈谈我们的解决方案。 

    我们要使用HTTP/2,那么在网络库的选择上必然需要使用NSURLSession来达到目的,并且我们也不希望自己去实现序列化以及RESTFUL的复杂性,因此AFNetworking3.0成了一个比较不错的选择。但是似乎仅仅有这些还不够。接下来会分为以下几个部分来谈谈我们的解决方案: 

    • 业务协议

    • 输入与配置

    • 数据转换与输出

    • 安全

    业务协议

    从业务协议上来说,饿了么众多APP中,每款APP都有其自身的特点,例如有些采取RESTFUL的设计,也有采用JSON-RPC的设计来达到业务目的。这时候如果采取集中式的API设计,相对应JSON-RPC会产生大量的RPC协议封装代码。并且于不同版本、类型的RPC协议,需要有不同的集中函数或者增加大量的参数来处理其中的差异性。

    如果采取分布式的API设计,则可以将这部分协议代码放进API自身类中来进行处理。在这里,我设计了一个RPCProtocol,由业务方自己来定义所需要遵循的业务RPC标准。而每个API都保存一个rpcDelegate字段来自定义自己的上层协议,而如果为空时,即代表着不进行RPC封装而是直接发送,从而达到JSON-RPC和RESTFUL在一个APP共存的目的;并且由于每个API都可以指定不同的rpcDelegate,因此可以适用于服务器端不同的RPC版本兼容性。这里,RPCProtocol会有一些这样的阐述: 

    NS_ASSUME_NONNULL_BEGIN

    @protocol DRDRPCProtocol <NSObject>

    - (nullable NSString *)rpcRequestUrlWithAPI:(DRDBaseAPI *)api;

    - (nullable id)rpcRequestParamsWithAPI:(DRDBaseAPI *)api;

    - (nullable id)rpcResponseObjReformer:(id)responseObject withAPI:(DRDBaseAPI *)api;

    - (nullable id)rpcResultWithFormattedResponse:(id)formattedResponseObj withAPI:(DRDBaseAPI *)api;

    - (NSError *)rpcErrorWithFormattedResponse:(id)formattedResponseObj withAPI:(DRDBaseAPI *)api;

    @end

    NS_ASSUME_NONNULL_END

    Protocol中会对RequestURL,RequestParams进行RPC装箱设计,并且对于回包,也有rpcResponseObjReformer进行拆箱,将可用值和错误值交给rpcResultWithFormattedResponse以及rpcErrorWithFormattedResponse处理后,再返回给业务上层。 

    通过使用RPCProtocol,我们保持了整个网络层上层协议的一种可扩展性。

    输入与配置

    解决完协议的问题,我们再来看看输入和配置的问题。 

    简单一看,似乎输入和配置的关系并不大;的确如此,在集中式的API设计时,更多的时候是传参,那么现在采取分散式API设计时,由于每个API先继承BaseAPI,然后再在子类中去覆盖每个需要配置的函数,因此看起来每个API都更像是一个配置的过程。 

    配置完成后的每个API,过去的方式可能是每个API都对应一个APIManager,反馈到AFNetworking上呢,可能就是每个API都使用一个AFHTTPRequestOperationManager,然后在这个Manager去发起请求。 

    不过,这种形式在HTTP/2上会显得愚笨。我们都知道HTTP/2是复用TCP管道连接的,这点体现在NSURLSession底层对于每个session是对多个task进行连接的复用。

    如果继续采取过去的方式多个AFHTTPSession来请求,会导致多个TCP连接,并且连接数不可控。而复用Session的话,可以充分利用NSURLSession的并发控制以及HTTP/2的高复用来提高性能。这点我在我的另一片文章《别说你会AFNetworking3.0/NSURLSession》有过详细阐述,这里也不再赘述了。 

    因此,我这里将每个配置好的API都扔到一个共享的APIManager中,即分散式API回归到集中式调用的怀抱。由APIManager来负责提供SessionManager的策略。并且通过Global的配置,来决定每个Session的最大并发数。同时将AFNetworking封装进整个APIManager,保持对外透明。

    这样未来如果升级AFNetworking版本,或者打算切换到直接使用NSURLSession来处理网络连接,上层业务API也不需要有任何的改动,进一步增强了未来的可配置性。 

    上节我们提到了分散式API,它具有一个最大的缺点,那便是导致类的爆炸,产生出成白上千的API的文件。在这一点上,我设计了另一个BaseAPI,我称之为GeneralAPI。这个API与之前的有什么不同呢?先来看一个典型的GET请求API的类文件内容: 

    - (NSString *)requestMethod {

        return @"get";

    }

    - (id)requestParameters {

        return nil;

    }

    - (DRDRequestMethodType)apiRequestMethodType {

        return DRDRequestMethodTypeGET;

    }

    - (DRDRequestSerializerType)apiRequestSerializerType {

        return DRDRequestSerializerTypeHTTP;

    }

    - (DRDResponseSerializerType)apiResponseSerializerType {

        return DRDResponseSerializerTypeHTTP;

    }

    在这个API里,覆盖好几个函数即可以完成相应的内容。而在ViewController中进行调用会有这样的代码。 

        DRDAPIGetCall *apiGet = [[DRDAPIGetCall alloc] init];

        [apiGet setApiCompletionHandler:^(id responseObject, NSError * error) {

            NSLog(@"responseObject is %@", responseObject);

            if (error) {

                NSLog(@"Error is %@", error.localizedDescription);

            }

        }];

        [apiGet start];

    而如果使用GeneralAPI,则在ViewController中,会是这样的代码。 

    DRDGeneralAPI *apiGeGet            = [[DRDGeneralAPI alloc] initWithRequestMethod:@"get"];

    apiGeGet.apiRequestMethodType      = DRDRequestMethodTypeGET;

    apiGeGet.apiRequestSerializerType  = DRDRequestSerializerTypeHTTP;

    apiGeGet.apiResponseSerializerType = DRDResponseSerializerTypeHTTP;

    [apiGeGet setApiCompletionHandler:^(id responseObject, NSError * error) {

        NSLog(@"responseObject is %@", responseObject);

        if (error) {

            NSLog(@"Error is %@", error.localizedDescription);

        }

    }];

    [apiGeGet start];

    没错,使用GeneralAPI,将一些简单的,不复杂的API的配置,直接使用property来直接在ViewController中赋值,这样就降低了一些简单的API生成类文件导致的爆炸。增强了整个网络层的易用性和简便性。

    数据转换与输出

    再来看看数据转换与输出。 

    这里我们将数据转换和输出放在一起来讨论。上节提到的交付什么样的数据给业务层、数据转换的操作,但并没有给出答案。现在我们来结合业务来看看怎样来操作数据的转换。 

    前面提到过,饿了么各个APP产品线都会有自己的性格和特点,数据转换上也会呈现各自喜好的局面。有人喜欢用Mantle,有人用过MJExtension,也有采取YYModel,也有大牛自己实现JSON<-->Model的转换。因此,网络层最好并不过问数据的转换方式以及过程,而是提供一个机会给上层业务来让其自己采用自己的方式进行转换。因此,我在API的设计中,提供了这样一个函数: 

    - (nullable id)apiResponseObjReformer:(id)responseObject andError:(NSError * _Nullable)error;

    GeneralAPI中对应为: 

    @property (nonatomic, copy, nullable) id _Nullable (^apiResponseObjReformerBlock)(id responseObject, NSError * _Nullable error);

    这个函数默认为空,参数中的responseObject为rpcDelegate拆包后产生的resposneObject。在这个函数中,上层业务可以将responseObject进行Model的转换工作,将Model作为返回值交给apiCompletionHandler函数进行操作。这样既保持了ViewController中的简洁性,也在保证了各个上层业务对于JSON<-->Model转换的多样性的同时,保证了未来转换方式的可扩展性。

    安全

    最后来谈谈安全。 

    其实安全到这一块可谈的已经不多了,该谈的都在上节中谈完了。由于APIManager中采用了AFNetworking简化SSL Pinning的复杂度,因此在网络层中只需要三步便可以完成SSL Pinning。 

    1. 实例化一个DRDSecurityPolicy, 将SecurityPolicy中的DRDSSLPinningMode设置成为DRDSSLPinningModePublicKey或者DRDSSLPinningModeCertificate。

    2. 将API的apiSecurityPolicy设定为以上实例。

    3. 将服务器的公钥证书放到APP Bundle中。

    结束!

    • 彩蛋:One More Thing!

    **多网络请求的并发执行。 **

    设想这样一个场景,我们希望有若干个网络请求,当这些请求都结束后才通知上层应用工作的完成。 

    早期采取AFNetworking的AFHTTPRequestOperationManager方案时,AFN对整个系统都采用了NSOperation以及NSOperationQueue来控制网络请求的依赖性。但是HTTP/2后的NSURLSession由于无法使用这种设计,因此造成这种并发依赖难以为继。 

    值得庆幸的是,AFNetworking在SessionManager的实现中依旧保留有dispatch_group_t的接口。因此我们使用dispatch_group创建了DRDAPIBatchAPIRequests类,来达到并发网络请求的目的。 

    @interface DRDAPIBatchAPIRequests : NSObject

    @property (nonatomic, strong, readonly, nullable) NSMutableSet *apiRequestsSet;

    @property (nonatomic, weak, nullable) id<DRDAPIBatchAPIRequestsProtocol> delegate;

    - (void)addAPIRequest:(nonnull DRDBaseAPI *)api;

    - (void)addBatchAPIRequests:(nonnull NSSet *)apis;

    - (void)start;

    @end

    一如既往,保持API层面简洁。这里我们使用了Delegate来处理多个网络请求完成后的回调操作: 

    @protocol DRDAPIBatchAPIRequestsProtocol <NSObject>

    - (void)batchAPIRequestsDidFinished:(nonnull DRDAPIBatchAPIRequests *)batchApis;

    @end

    在这个设计中,每个API完成后,都可以有自己的回调。所有的并发网络请求完成后仍旧可以有一个公用的回调,让整体设计保持一个离散+集中都能得到很好处理的情形,保证上层的业务可扩展性。

    总之,

    结合业务,才能谈及架构;持续调优,才能不断地与时俱进。 

    最后我们也开源了我们的DRDNetworking库,欢迎大家多提宝贵意见。 

    老司机介绍

    王朝成,饿了么移动技术架构/框架组负责人、资深iOS工程师,负责饿了么移动技术的远景规划、技术架构选型、外部技术方案评估等工作。目前关注领域包括移动端架构、移动端安全及自动化测试等。王朝成之前曾供职于中兴通讯欧洲事业部,并曾自主创业且担任主要技术负责人。业余爱好行摄天下,华盖创意签约摄影师。



    横炮一:

    InfoQ「给我一个理由,送你一本好书」活动第四天开奖结果已公布!想知道自己在送书名单中吗?快戳阅读原文吧!


    横炮二:

    明天就是三八女人节了,你身边有高颜值的女性程序员同事吗?欢迎微信后台「留言+照片」,我们会整理后在节日当天放出,成功入选者可获得赠书一本哦~

     
    InfoQ 更多文章 年前挖的坑都填了吗?技术债务偿还计划 程序员VS武林高手:技术为外功,思维乃内力 腾讯游戏大数据服务场景与应用(附PPT) Google和微软牵手的产物:我为什么选择Angular 2? 前豆瓣工程副总裁段念谈豆瓣的研发管理
    猜您喜欢 从贝叶斯的角度看正则项 iOS学习路线 直播时代,你是不是一个已经听天由命的程序员? 长跑人生 测试knowledge大闯关,等你来挑战!