0x00 引言

随着柯洁对战AlphaGo的败北,人工智能一词进入了越来越多人的视界。一时间似乎人人都在谈论人工智能,风口似乎正在向这一块领域进行倾斜。不过今天我们来谈的不是人工智能,而是人工智能背后的大数据

构建人工智能基础的机器学习,需要大量的数据进行训练;即要有足够的数据量来喂饱我们的机器学习算法,以让它更加的智能

那么问题来了,如何获取到我们想要的大数据?有读者可能第一时间就会想到现在非常火的爬虫技术。没错,爬虫可以帮我们获取到大量的公开数据。然而对于我们自身的业务、性能等信息却无用武之地。因此我们自然会想到,自己构建一个监控或者说埋点的SDK,来获取用户行为或者性能分析的数据。这样便能够最大限度地发挥数据的作用。

本文会结合一些经验来谈谈以下几个方面的内容:

  1. 如何架构一个iOS的监控SDK?
  2. 如何实现自动采集?

0x01 iOS下的监控设计架构

俗话说得好,脱离业务的实践来谈架构就是一种耍流氓。因此我们先来看看一种监控业务的场景。

其实采集用户数据的场景无外乎两种:APM和UBT。APM(Application Performace Management)倾向于数据性能的采集,而UBT(User Behavior Track)则更倾向于业务数据的分析。这二者之间有一定的共同性,都是从业务层获取到数据,然后持久化在手机本地,再寻找相应的时机发送给服务器端,完成一次数据同步的工作。

基于以上的数据流转过程,我们主要会从以下几点来展开讨论:

  1. 数据持久化相关的设计
  2. 数据发送传输相关的设计
  3. 采集相关的设计

数据持久化相关的设计

先来看看数据持久化方面的内容。在这一小节里,我们主要会面临以下几个问题:

  • 为什么需要数据持久化
  • 数据持久化方案的选择
  • 如何设计数据持久化层

为什么需要数据持久化?

对于移动端设备,特别是iOS设备而言,用户在操作APP时,随时都有可能按下Home键回到桌面,或者手动杀死应用。在这一种场景下,倘若采集到的数据一直存在于设备内存中,iOS系统则可能随时清空该应用内存,最终导致我们采集的数据丢失。因此为了减少数据损耗,在数据采集完成后的第一时间进行数据持久化,似乎显得额外的重要。

当然也有读者会疑惑,如果我们在采集完成后第一时间就同步到服务器,是否就可以减少这一个步骤?值得肯定的是,这种场景的确存在,但却仅存在于100%网络正常且服务100%正常情况下的理想国。当然,如果说所有的数据仅仅用于采样分析,并可以忍受部分数据的丢失,那么这种去数据持久化的方案也并非不能接受。

数据持久化方案的选择

接下来再来看看数据持久化的方案。在iOS平台上,目前比较流行的数据持久化方案有以下几种:

  • Core Data
  • Sqlite
  • Realm

选择哪种方案更好?其实是个永远都在争执的话题。Core Data是Apple原生推荐和支持的方式,但学习曲线陡峭,很多开发者并不大喜欢。Sqlite也支持,但使用较为复杂;开源社区上FMDB是一个不错的封装好的选择。而Realm则是最近一个比较火的跨平台的数据持久化中间件,拥有不错的社区支持,也不失为另一种选择。当然也有直接使用文件系统这种方式去处理数据,也是一种尝试。

那么,究竟如何选择?其实更取决于使用它的人。如果团队成员对Core Data都比较推崇,那完全可以全部采取这样一种方式;如果团队成员比较喜欢尝鲜,也有不错的探索精神,则Realm也会是个不错的选择;而如果中规中矩,又不想花那么多的时间去熟悉Core Data的框架,那选择Sqlite也并没有太多的过错。

如何设计数据持久化层

对于采集到的监控数据而言,需要注意到的地方是,这部分数据都是要最终同步到远端服务器上。因此,本地的持久化数据相对于服务器端而言,是某种意义上的缓存。所以,我们可以根据这样一个点,来思考如何设计这类数据的输入、输出和存储的问题。

很快会有读者疑惑,不就是根据数据库API来提供接口给上层调用么?哪里来的这么多讲究?

这里其实存在两种截然不同的数据存储方式:

  1. 格式化存储
  2. 二进制存储

这是什么概念?

格式化存储方式类似服务器端的数据库表结构设计。把整个数据模型在数据库里进行映射存储。这种存储方式的好处在于Debug相对简单,比如选择了Sqlite作为数据库解决方案,那么就可以直接在相应的文件夹下打开这个创建的sqlite数据库文件,查看当前的数据情况,从而发现问题。但是这种方式也存在其先天缺陷,那就是如果一旦数据结构做了变更,那么整个数据库的表结构也要有相应的变更。其二,当表结构变更发生在数据量较多的情况下时候,进行数据升级也会占据较多的时间。其三,其可扩展性也相对较差;如果一旦未来需要新增某种监控数据类型,那表结构也就需要做相应的变更。

二进制存储则是直接将Blob的二进制数据存储在本地数据库中。如即将准备发送的JSON数据,或者ProtoBuff数据等。这种设计的初衷点就在于之前提到的缓存二字。既然我们本地的数据仅仅只是远端数据的一次缓存,那么本地就不应该去关注或者消费这些数据。因此,并不需要太过关注其数据本身的数据结构,而是直接保存要发送的二进制即可。这种设计方案好处在于可扩展性强,不论与服务器端通信的协议如何变,数据库都不需要发生变更,都是存储二进制数据;同时由于字段少,所以吞吐率也相对较高。但这种设计也存在一定的风险,那就是Debug时的耗时需要增加,需有相应的工具来反序列化数据库里的数据才能知道数据存储正确与否;同时这种设计方案也不能更新数据库内不正确的数据值,就算发现错误也难以修正。

因此建议在一开始时还是采用格式化存储方式来做存储。当方案成熟后,可以考虑切换到二进制存储来提高扩展性和性能。

以上讨论了一下数据的存储方式,那么接下来我们再来讨论一下数据的写和读的问题。

我们都知道,数据库经常会遇到读和写同时进行的问题,这时候就会导致数据的不一致。解决这个问题的方案有许多种,也是我们老生常谈的那些,例如读写锁、互斥锁、信号量、队列等等。个人比较倾向和喜欢用一个串行队列的方式来解决这个问题。一来实现起来较为简便,二来上层调用到了数据库层就将接下来的任务交由子线程队列处理,可以从某种意义上防止主线程的采集卡顿问题。

综合以上的讨论,我们似乎可以看出些端倪。在设计数据持久化层时,可以做两层结构处理;底层是提供数据库数据的CRUD功能的层次,上层则提供数据库序列化(如果有)和读写队列的功能。

数据发送传输相关的设计

数据的发送传输层主要目的则是用于完成取数据,装数据,和发数据三个过程。主要需要考虑的是以下几个方面:

  • 数据的传输协议
  • 数据的序列化方式
  • 发送间隔策略

数据的传输协议

在数据的传输协议上,主要考量的是,我们需要长连接还是短连接?

先来说说长连接。我们这里的长连接主要还是指的TCP层的Socket的长连接。由于每个TCP的连接都需要三次握手,这就会有相应的时间消耗。如果每次的发送都是先连接后发送,那么处理的速度会降低很多;所以每次的操作后都不断开,多次发送数据时候直接发送数据包会节省很多TCP建立的开销。

但这种方式同时也会带来巨大的服务器资源——维护这些连接所消耗的大量内存。试想一下成千上万甚至上亿的客户端连接,需要多少的服务器资源。当然,如果公司已经存在一条统一的长连接通道,那么一致地向该通道发送数据也不失为一种好的方式。

再说说短连接,狭义上的短连接我们通常指的HTTP协议,发送完数据后就将其连接关闭。这种好处在于代码上客户端和服务器端都易于实现,能达到快速开发的效果。不足之处可能就在于每次开启一次请求时,都需要重新建立连接,从而导致额外的开销。

至于如何选择,还是需要视整体情况。不过如果在公司没有统一的长连接网关的情况下,建议暂时可以采取HTTP/1.1 Keep Alive来进行过渡,当然最新HTTP/2也是个很不错的选择。

数据的序列化方式

谈完了协议,再谈谈数据如何传输的问题。这主要包含了两个方面:数据的序列化方式和数据的压缩方式。

对于数据的序列化算法,当前比较流行的几种方式有JSON, ProtoBuff, Thrift等;而对于压缩方式而言,则存在Gzip, 7z 等多种方案。

由于监控的数据,存在发送间隔短、发送次数多的特点,因此如何减少用户的额外消耗流量是数据的序列化主要考虑的问题。在这些协议的测试过程中,ProtoBuff+7z的组合获得了最大的数据压缩率。不过同时也带来了不小的二进制包开销,如7z的算法库本身就有好几M的大小;因此需要在各自范围内的时间复杂度和空间复杂度中根据需求做一个权衡。毕竟适合自己的才是最好的。

发送间隔策略

提到发送间隔,一部分开发者可能会拍脑袋构思出以下几种设计:

  1. 若干秒一次发送或者若干条数据满了之后一直尝试发送直到发送成功
  2. 若干秒一次发送,如果不成功则发送间隔乘以2以此类推。直到发送成功发送间隔恢复初始

实时上,这两种发送间隔策略在实践的过程中都存在一些问题。首先我们来看看第一条。

第一种发送策略会在平时正常的服务器状态下,风平浪静。然而一旦网络发生一丝抖动,所有活跃客户端都会不断地尝试建立连接和发送数据给我们的服务器。这种情况下相当与发生了一次雪崩,即让我们自己的客户端发起了一次针对我们自己服务器的DDOS攻击。

再来看第二种发送策略,相对第一种而言已经有一定的进步,因为发送失败后会有一定的时间后退来进行重试。然而在实践过程中发现,一旦发送网络抖动,客户端还是会在以上约定的偶数时间上来对自己的服务器进行小规模的DDOS攻击,问题仍然没有得到有效地解决。

难道无解了么?其实不然,多年前的CSMA/CD,即冲突检测的载波监听多路访问的方法,给了我们一些启发。它的工作原理如下(摘自Wiki):

发送数据前,先侦听信道是否空闲,若空闲,则立即发送数据。若信道忙碌,则等待一段时间至信道中的信息传输结束后再发送数据;若在上一段信息发送结束后,同时有两个或两个以上的节点都提出发送请求,则判定为冲突。若侦听到冲突,则立即停止发送数据,等待一段随机时间,再重新尝试。

这里我们注意到一段文字等待一段随机时间。其实CSMA/CD有自己的算法,那么我们是否也能根据这种思想来更深层地做一次优化,将重试的时间分散在一段时间区间内?答案是肯定的,事实证明这也能有效降低服务器在网络抖动时的负载压力。

采集相关的设计

以上基本完成了一款监控SDK的消费者部分的设计,现在来看看生产者部分。

一般来说,采集分为手动和自动两种。这里我们先来谈一下手动这个方面。手动采集一般更多的还是对业务场景的抽象,如一个事件、一次请求、又或者一个页面的记录。因此手动采集的设计更像是一个API接口的设计。不同的业务提供一个API接口,比如以下提供的页面和事件的API接口。

- (void)trackPageViewWithName:(NSString * _Nonnull)name
                       params:(NSDictionary * _Nullable)params;
                       
- (void)trackEventWithID:(NSString * _Nonnull)eventID
                    page:(NSString * _Nullable)page
                  params:(NSDictionary * _Nullable)params;

通过提供诸如此类的接口,再将业务层传输下来的数据进行存储,基本就能够满足一款普通的监控采集SDK的需求了。

小结

以上一个简单的监控SDK的基本架构也就出来了:
SDK Arch

不同的API模块将其自身对应的数据发送给数据库读写队列进行存储,而发送模块则定时来数据库查询是否有需要发送的数据并将其同步至服务器。

0x02 自动采集

以上设计好了手动采集数据的SDK,那么,能否实现自动采集呢?或者说,如何在iOS平台上实现自动的采集呢?

在这里,我们首先要从技术层面来找是否存在可行性。既然名为自动采集,那么肯定是在不惊动原代码的前提下来进行的。既然不能动原代码,那么肯定是要用到一些AOP的技术了。因此,让我们先来看看iOS平台上的一些AOP技术。

周所周知,iOS下有两大语言Objective-C和Swift。同时我们也都清楚Objective-C是一门动态语言,并且Swift在ABI稳定之前都是通过动态库的形式链接进整个iOS系统的。因此要做到AOP主要还是从Objective-C上着手会比较方便。而Objective-CMethod Swizzling已经被我们用的滚瓜烂熟了,自然成为了我们的AOP首选。但对于再底层的一些C语言库,这种方式就不适用了,这时候Facebook大神们出的Fishhook可以从MachO的角度来进行一些AOP,可以作为部分的补充。

一个普通的切面代码可以如下:

+ (void)swizzingClassMethodWithOriginClass:(Class)oriCls
                            originSelector:(SEL)originSelector
                               targetClass:(Class)targetCls
                            targetSelector:(SEL)targetSelector {
    
    NSParameterAssert(oriCls);
    NSParameterAssert(targetCls);
    NSParameterAssert(originSelector);
    NSParameterAssert(targetSelector);
    if ([oriCls instancesRespondToSelector:targetSelector])
        return;
    
    Method swizzledMethod = class_getClassMethod(targetCls, targetSelector);
    
    if (sk_addMethod(object_getClass(oriCls), targetSelector, swizzledMethod)) {
        sk_swizzleClassSelector(object_getClass(oriCls), originSelector, targetSelector);
    }
}

+ (void)swizzingInstanceMethodWithOriginClass:(Class)oriCls
                               originSelector:(SEL)originSelector
                                  targetClass:(Class)targetCls
                               targetSelector:(SEL)targetSelector {
    NSParameterAssert(oriCls);
    NSParameterAssert(targetCls);
    NSParameterAssert(originSelector);
    NSParameterAssert(targetSelector);
    if ([oriCls instancesRespondToSelector:targetSelector])
        return;
    
    Method swizzledMethod = class_getInstanceMethod(targetCls, targetSelector);
    
    if (sk_addMethod(oriCls, targetSelector, swizzledMethod)) {
        sk_swizzleInstanceSelector(oriCls, originSelector, targetSelector);
    }
}

如果技术上可行性没有问题,那么业务层是否可行呢?这里我们从两块不同的数据类型来分别探讨一下这个问题:

  • 性能监控数据
  • 业务埋点数据

性能监控数据

由于性能监控数据种类繁多,这里以网络请求为例。

对于网络请求的性能,我们主要关心的是其在TCP/IP整个协议栈上每个阶段的时间,以及HTTP请求的包大小。了解到需求后,我们便可以着手来看看从哪儿入手。

我们都知道,iOS平台的HTTP网络请求主要都集中于于urlconnectionurlsession两个大库(基于CFNetworking的实现由于篇幅问题暂不在此展开讨论),因此可以从这个源头开始入手,AOP掉主要的一些函数,下面代码以urlsession为例:

+ (void)_swizzleNSURLSession {
    // Swizzle NSURLSession method
    NSArray<NSString *> *selectors = @[
                                       // asnyc selector
                                       NSStringFromSelector(@selector(dataTaskWithRequest:completionHandler:)),
                                       NSStringFromSelector(@selector(downloadTaskWithRequest:completionHandler:)),
                                       // sync selector
                                       NSStringFromSelector(@selector(downloadTaskWithRequest:))
                                       
                                       ];
    for (NSString *selector in selectors) {
    
		...
        
        while (class_getInstanceMethod(currentClass, originSelector)) {
            if (realRespondsToSelector(originSelector, currentClass)) {
                [FGRSwizzlingUtils swizzingInstanceMethodWithOriginClass:currentClass
                                                              originSelector:originSelector
                                                                 targetClass:[FGRURLSessionBlender class]
                                                              targetSelector:targetSelector];
            }
            ...
        }
        
        [FGRSwizzlingUtils swizzingInstanceMethodWithOriginClass:[session class]
                                                      originSelector:originSelector
                                                         targetClass:[FGRURLSessionBlender class]
                                                      targetSelector:targetSelector];
        ...
    }
	
	...
	
    for (NSString *classSelector in classSelectors) {
        
        ...
        
        SEL originSelector = NSSelectorFromString(classSelector);
        SEL targetSelector = NSSelectorFromString(targetSelectorString);
        
        [FGRSwizzlingUtils swizzingClassMethodWithOriginClass:[NSURLSession class]
                                                   originSelector:originSelector
                                                      targetClass:[FGRURLSessionBlender class]
                                                   targetSelector:targetSelector];
    }
}

当然这里其实我们需要注意一个点,就是iOS10及其以上,平台提供给我们一套API回调方便我们获取相关的数据:

/*
 * Sent when complete statistics information has been collected for the task.
 */
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

但对于iOS7, 8, 9来说,想获取具体的DNS时间, TCP时间, SSL时间这些详细Metrics并不那么容易,需要通过Fishhook底层的C库来达到目的。但可惜的是,在iOS9下,我们似乎通过这招也没法切到SSL相关的数据。如果读者有什么好方法希望能够不吝赐教。

uint32_t c = _dyld_image_count();
    for (uint32_t i = 0; i < c; i++) {
        const char *name = _dyld_get_image_name(i);
        NSString *imageName = [NSString stringWithUTF8String:name];
        if ( [imageName rangeOfString:@"CFNetwork"].length != 0
            || [imageName rangeOfString:@"libsystem_network.dylib"].length != 0
            || [imageName rangeOfString:@"Security"].length != 0) {
            
            rebind_symbols_image((void *)_dyld_get_image_header(i),  _dyld_get_image_vmaddr_slide(i), (struct rebinding[7]) {
                {"getpeername", (void *)Fragarach_getpeername, (void **)&FragarachOriginal_getpeername},
                {"dup", (void *)Fragarach_dup, (void **)&FragarachOriginal_dup },
                {"write", (void *)Fragarach_write, (void **)&FragarachOriginal_write },
                {"read", (void *)Fragarach_read, (void **)&FragarachOriginal_read },
                {"SSLWrite", (void *)Fragarach_SSLWrite, (void **)&FragarachOriginal_SSLWrite },
                {"SSLHandshake", (void *)Fragarach_SSLHandshake, (void **)&FragarachOriginal_SSLHandshake },
                {"close", (void *)Fragarach_close, (void **)&FragarachOriginal_close }
            }, 7);
        }
        
    }

当通过AOP方式hook住相关的函数后,我们便能够在每个函数节点通过存储时间戳的方式达到记录协议栈时间的目的。而对于请求的大小,则一般通过计算HTTP请求的头和Body的长度来达到总体长度的目的。

事实上性能数据的自动化采集是个非常大的范畴,由于篇幅原因只列举了基本的网络请求的自动化采集的方式,也希望能起到抛砖引玉的作用。

业务埋点数据

对于业务埋点来说,我们过去经常使用的一般还是手动地埋,这样比较精准而且可控,想要什么就有什么。但对于程序员工程师来说,总是会去想如果能自动化就自动化去处理掉,因此才会去琢磨如何去进行业务的自动埋点。

对于自动化业务埋点,一般需要解决以下几个问题:

  1. 位置
  2. 操作
  3. 参数

对于第一个问题,基本大家都通过xPath这种方式来进行定位。对于同一个页面里很多种相同的元素则通过UIButton[1], UIButton[2]这种方式来进行区分。由于有很多的文章都对这种方式进行了介绍,这里就不再浪费篇幅进行介绍。

第二个问题,操作。其实对于iOS平台的用户行为而言,所有的行为都能归为以下几个大类:

  • ViewAppear&ViewDisappear
  • Action-Target
  • ReuseCells
  • Gestures
  • ApplicationStatus(如AppDidLaunch 等)

所以,通过对这几种主要行为进行Hook,基本能达到捕获绝大多数行为操作的目的。

第三个问题,参数。对于大多数操作,我们其实都能捕获函数触发时带的参数。然而对于一些埋点,大数据团队更加希望能够获取触发该操作时的一些用户状态信息。这时候,对于自动埋点而言可能就并不那么适用了。有一种做法是,SDK提供若干个全局的参数列表,每次的操作记录都带上这些参数的瞬时值,从而解决一部分这样的问题。

然而不幸的是,总是有一部分数据采集的需求是比较难实现或者说自动采集实现起来比较麻烦的,比如曝光率、非全局的瞬时参数值。这时候可能还是需要结合一些手动的埋点才能最终达到目的。

其实对于业务数据的自动采集而言,更多的时候需要在后端大数据的产品进行支持,从多个维度进行分析才能真正获取到更多有价值的信息!

0x03 结语

历史车轮总是在不断地前进,技术、架构也同样在不断地前行。我们总是想着能否再智能一点,再自动化一点。于是有了以上的文字。

希望本文能对读者如何设计一个监控SDK有所启发!

小说明

《iOS 成长之路》特供稿件