达达集团技术

达达快送iOS版网络AOP方案探索与实践

引言

由于业务需求,达达快送需要对app的网络请求进行加密传输和流量监控。如果网络框架是统一的我们可以在框架内部处理,但达达快送业务线有多个app且每个app存在复数网络框架,无法实现统一修改。所以我们在探索有没有一个方案可以尽可能少的修改来达到我们的需求,实现一个AOP的网络切面则可以较好地解决问题。

一、AOP简介

AOP(Aspect Oriented Programming),意为面向切面编程,它是面向对象编程范式的一种补充。AOP在iOS下的应用比较广泛,较为常见的是无痕埋点,安全保护等。而网络AOP主要是在网络层切面拦截,便于对请求和响应做二次处理。

二、网络AOP的方案对比

1.通过网络框架来实现AOP

  • AFNetWorking

AFN中提供了一套基于task各状态的通知,可以满足一些监控、日志的需求。

  • Moya

Moya是Alamorfire基础上的封装,利用Swift的协议能力,实现了一套POP的网络库。它还提供了可选化插件的接口,通过遵守PluginType,可以对请求的Request和返回的Response拦截和修改,这也是AOP的思想体现。

  • 自定义网络框架

大部分项目的网络框架都有统一的出入口,在此基础上构建一个切面就很简单高效了,虽然基于网络库的切面拦截会遗漏第三方库内置的一些请求,但是已经可以满足绝大部分需求了。

2.使用runtime方案hook

知名调试库FLEX就是通过hook相关系统代理函数的方式实现抓包的,FLEX兼容了NSURLConnection和NSURLSession,其中NSURLConnection基本已经被弃用,所以我们自己实现的时候只需要考虑NSURLSession的hook。

3.NSURLProtocol方案

NSURLProtocol是URL Loading System的重要组成部分,它能拦截所有基于URL Loading System的网络请求。NSURLProtocol本身可以视为苹果提供的AOP工具,我们只需要实现并注册URLProtocol的一个子类即可。当一个请求发出时,系统就会创建一个实例去处理该请求。
我们对比下上述几种方案的优缺点

方案

核心思路

优点

缺点

基于AFN的实现

监听AFN提供的各时机通知,获取相应的数据

1.代码量很小。

2.结构依靠AFN提供的通知串联,逻辑简单

1.只可监控AFN发起的请求。

2.不能修改请求/响应数据。

基于Moya的实现

实现PluginType协议,操作相应数据

1.moya是gitHub上的高星项目,开发者持续维护,稳定性强。

2.基于协议实现,对swift有很好的支持。

3.支持对请求/响应数据的读写。

1.Moya只能在Swift项目中使用。

2.只限于moya框架。

基于自定义网络框架的实现

在统一出入口处制作一个切面,通过通知/代理等方式实现转发功能

1.实现方式自由,出现问题易于定位。

2.自定义框架可以针对app做更多优化。

3.支持对请求/响应数据的读写。

1.对于小公司来说成本相对较高。

2.由于是在自定义框架之上做拦截处理,对于其他网络库及自带请求的一些sdk将没有拦截能力。

基于runtime的实现

对NSURLSession等系统请求类进行方法交换

1.方案思路明确,易于理解。

2.基于底层实现,对上层代码无影响

3.支持对请求/响应数据的读写。

1.需要交换的方法数量很多,代码量很大。

2.实现复杂,风险较大。

基于NSURLProtocol

实现NSURLProtocol子类并注册,系统会在请求发出时自动创建实例来处理请求

1.苹果推荐

2.可移植

3.支持对请求/响应数据的读写。

对于同一个请求,只有一个NSURLProtocol可以响应并处理。

三、网络AOP方案在达达app的使用情况

基于达达app多个网络框架并用的现状,通过网络框架来实现AOP的三个方案显然都不适用。那只能从runtime和NSURLProtocol两种方案里面进行选择,考虑到runtime方案较为复杂且风险较大,而NSURLProtocol又是业内最为通用的方案,我们决定使用NSURLProtocol方案。

1.整体结构

当我们实现并注册了一个NSURLProtocol子类时,所有基于URL Loading System的网络请求都将被该类拦截,如果不搭建一个转发体系,当我们有多个功能需要借助NSURLProtocol实现的话,势必会使这个类膨胀,代码难以维护。因此更好的方案是将NSURLProtocol视作一个入口,在此基础上封装一套转发机制,新增的功能以插件的形式插入。

架构如下图所示:

当NSURLProtocol拦截到请求时,会将请求转发到拦截器中,各功能插件实现拦截器协议并注册到拦截器中,拦截器协议定义了两个关键节点函数,给插件提供访问request和response的时机。

多插件注册时,插件的优先级会在很大程度上影响请求。举个例子,当流量插件优先级高于加密插件时,流量的统计就是不准确的(加密会增大请求体体积)。但过于复杂的优先级体系又将增加插件的设计和使用难度,最终决定由插件的注册顺序决定它的调用顺序。这对于调用方来说也是最直观明了的。

如图所示,目前客户端根据业务需要设计了3个插件,各插件间相互独立,避免单个插件影响到全局。

2.设计思路

拦截插件内部设计思路见下图:
在每一个HTTP请求开始时,URL Load System会创建合适的NSURLProtocol对象处理URL请求,当NSURLProtocol实例收到请求,会将请求发送到转发器单例。 转发器遍历插件数组,当插件开启并且实现当前节点代理时,转发器会转发request到插件。 当所有插件处理完成后,转发器会处理后的请求返回给拦截器,由拦截器发起最终请求。 对响应的处理也相同。

3.开发中遇到的问题

问题1:拦截到的HTTPBody为空

post请求的request.HTTPBody是空的,我们需要从request的HTTPBodyStream中取出并赋值给HTTPBody

if ([method isEqualToString:@"post"]) {    newRequest = request.mutableCopy;    NSMutableData *httpBody = [NSMutableData data];    NSInputStream *stream = request.HTTPBodyStream;    [stream open];    NSInteger maxLength = 1024;    uint8_t buffer[maxLength];    BOOL isReadEnd = NO;    while (!isReadEnd) {        NSInteger readLength =  [stream read:buffer maxLength:maxLength];        if (stream.streamError == nil && readLength > 0) {            [httpBody appendBytes:buffer length:readLength];        } else {            isReadEnd = YES;        }    }}

问题2:   本地的网络拥塞,导致后续请求超时

表单格式(multipart/form-data)拦截并对bodyStream读取后,如果没有写入body,会造成本地的网络拥塞,使后续请求超时。 只要是拦截了请求,哪怕不对数据做任何处理也需要重新写入body。
NSMutableData *httpBody = [NSMutableData data];NSInputStream *stream = request.HTTPBodyStream;[stream open];NSInteger maxLength = 1024;uint8_t buffer[maxLength];BOOL isReadEnd = NO;while (!isReadEnd) {    NSInteger readLength =  [stream read:buffer maxLength:maxLength];    if (stream.streamError == nil && readLength > 0) {         [httpBody appendBytes:buffer length:readLength];       } else {         isReadEnd = YES;       }    }[stream close];newRequest.HTTPBody = httpBody;

结语

通过几种AOP方案,我们很容易构建出一个易拓展,低风险的针对原生的网络切面,可以无侵入的对请求做二次处理。然而由于WKWebview的特殊性,上述方案无法很好地完成网页请求的拦截,下一次,我们可以一起探讨WKWebview的拦截,实现真正“无死角”网络切面。