打造一个通用、可配置、多句柄的数据上报 SDK

一个 App 通常会存在不少场景去上传 App 中产生的数据,好比 APM、埋点统计、开发者自定义的数据等等。因此本篇文章就讲讲如何设计一个通用的、可配置的、多句柄的数据上报 SDK。

前置说明

由于这篇文章和 APM 是属于姊妹篇,因此看这篇文章的时候有些东西不知道活着好奇的时候能够看带你打造一套 APM 监控系统html

另外看到我在下面的代码段,有些命名风格、简写、分类、方法的命名等,我简单作个说明。node

  • 数据上报 SDK 叫 HermesClient,咱们规定类的命名通常用 SDK 的名字缩写,当前状况下缩写为 HCT
  • 给 Category 命名,规则为 类名 + SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述。好比给 NSDate 增长一个获取毫秒时间戳的分类,那么类名为 NSDate+HCT_TimeStamp
  • 给 Category 的方法命名,规则为 SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述。好比给 NSDate 增长一个根据当前时间获取毫秒时间戳的方法,那么方法名为 + (long long)HCT_currentTimestamp;

1、 首先定义须要作什么

咱们要作的是「一个通用可配置、多句柄的数据上报 SDK」,也就是说这个 SDK 具备这么几个功能:android

  • 具备从服务端拉取配置信息的能力,这些配置用来控制 SDK 的上报行为(需不须要默认行为?)
  • SDK 具备多句柄特性,也就是拥有多个对象,每一个对象具备本身的控制行为,彼此之间的运行、操做互相隔离
  • APM 监控做为很是特殊的能力存在,它也使用数据上报 SDK。它的能力是 App 质量监控的保障,因此针对 APM 的数据上报通道是须要特殊处理的。
  • 数据先根据配置决定要不要存,存下来以后再根据配置决定如何上报

明白咱们须要作什么,接下来的步骤就是分析设计怎么作。c++

2、 拉取配置信息

1. 须要哪些配置信息

首先明确几个原则:git

  • 由于监控数据上报做为数据上报的一个特殊 case,那么监控的配置信息也应该特殊处理。
  • 监控能力包含不少,好比卡顿、网络、奔溃、内存、电量、启动时间、CPU 使用率。每一个监控能力都须要一份配置信息,好比监控类型、是否仅 WI-FI 环境下上报、是否实时上报、是否须要携带 Payload 数据。(注:Payload 其实就是通过 gZip 压缩、AES-CBC 加密后的数据)
  • 多句柄,因此须要一个字段标识每份配置信息,也就是一个 namespace 的概念
  • 每一个 namespace 下都有本身的配置,好比数据上传后的服务器地址、上报开关、App 升级后是否须要清除掉以前版本保存的数据、单次上传数据包的最大致积限制、数据记录的最大条数、在非 WI-FI 环境下天天上报的最大流量、数据过时天数、上报开关等
  • 针对 APM 的数据配置,还须要一个是否须要采集的开关。

因此数据字段基本以下github

@interface HCTItemModel : NSObject <NSCoding>

@property (nonatomic, copy) NSString *type;         /<上报数据类型*/
@property (nonatomic, assign) BOOL onlyWifi;        /<是否仅 Wi-Fi 上报*/
@property (nonatomic, assign) BOOL isRealtime;      /<是否实时上报*/
@property (nonatomic, assign) BOOL isUploadPayload; /<是否须要上报 Payload*/

@end

@interface HCTConfigurationModel : NSObject <NSCoding>

@property (nonatomic, copy) NSString *url;                        /<当前 namespace 对应的上报地址 */
@property (nonatomic, assign) BOOL isUpload;                      /<全局上报开关*/
@property (nonatomic, assign) BOOL isGather;                      /<全局采集开关*/
@property (nonatomic, assign) BOOL isUpdateClear;                 /<升级后是否清除数据*/
@property (nonatomic, assign) NSInteger maxBodyMByte;             /<最大包体积单位 M (范围 < 3M)*/
@property (nonatomic, assign) NSInteger periodicTimerSecond;      /<定时上报时间单位秒 (范围1 ~ 30秒)*/
@property (nonatomic, assign) NSInteger maxItem;                  /<最大条数 (范围 < 100)*/
@property (nonatomic, assign) NSInteger maxFlowMByte;             /<天天最大非 Wi-Fi 上传流量单位 M (范围 < 100M)*/
@property (nonatomic, assign) NSInteger expirationDay;            /<数据过时时间单位 天 (范围 < 30)*/
@property (nonatomic, copy) NSArray<HCTItemModel *> *monitorList; /<配置项目*/

@end

由于数据须要持久化保存,因此须要实现 NSCoding 协议。objective-c

一个小窍门,每一个属性写 encodedecode 会很麻烦,能够借助于宏来实现快速编写。sql

#define HCT_DECODE(decoder, dataType, keyName)                                                    \
{                                                                                             \
_##keyName = [decoder decode##dataType##ForKey:NSStringFromSelector(@selector(keyName))]; \
};

#define HCT_ENCODE(aCoder, dataType, key)                                             \
{                                                                                 \
[aCoder encode##dataType:_##key forKey:NSStringFromSelector(@selector(key))]; \
};

- (instancetype)initWithCoder:(NSCoder *)aDecoder {
    if (self = [super init]) {
        HCT_DECODE(aDecoder, Object, type)
        HCT_DECODE(aDecoder, Bool, onlyWifi)
        HCT_DECODE(aDecoder, Bool, isRealtime)
        HCT_DECODE(aDecoder, Bool, isUploadPayload)
    }
    return self;
}

- (void)encodeWithCoder:(NSCoder *)aCoder {
    HCT_ENCODE(aCoder, Object, type)
    HCT_ENCODE(aCoder, Bool, onlyWifi)
    HCT_ENCODE(aCoder, Bool, isRealtime)
    HCT_ENCODE(aCoder, Bool, isUploadPayload)
}

抛出一个问题:既然监控很重要,那别要配置了,直接所有上传。shell

咱们想想这个问题,监控数据都是不直接上传的,监控 SDK 的责任就是收集监控数据,并且监控后的数据很是多,App 运行期间的网络请求可能都有 n 次,App 启动时间、卡顿、奔溃、内存等可能很少,可是这些数据直接上传后期拓展性很是差,好比根据 APM 监控大盘分析出某个监控能力暂时先关闭掉。这时候就无力回天了,必须等下次 SDK 发布新版本。监控数据必须先存储,假如 crash 了,则必须保存了数据等下次启动再去组装数据、上传。并且数据在消费、新数据在不断生产,假如上传失败了还须要对失败数据的处理,因此这些逻辑仍是挺多的,对于监控 SDK 来作这个事情,不是很合适。答案就显而易见了,必需要配置(监控开关的配置、数据上报的行为配置)。数据库

2. 默认配置

由于监控真的很特殊,App 一启动就须要去收集 App 的性能、质量相关数据,因此须要一份默认的配置信息。

// 初始化一份默认配置
- (void)setDefaultConfigurationModel {
    HCTConfigurationModel *configurationModel = [[HCTConfigurationModel alloc] init];
    configurationModel.url = @"https://***DomainName.com";
    configurationModel.isUpload = YES;
    configurationModel.isGather = YES;
    configurationModel.isUpdateClear = YES;
    configurationModel.periodicTimerSecond = 5;
    configurationModel.maxBodyMByte = 1;
    configurationModel.maxItem = 100;
    configurationModel.maxFlowMByte = 20;
    configurationModel.expirationDay = 15;

    HCTItemModel *appCrashItem = [[HCTItemModel alloc] init];
    appCrashItem.type = @"appCrash";
    appCrashItem.onlyWifi = NO;
    appCrashItem.isRealtime = YES;
    appCrashItem.isUploadPayload = YES;

    HCTItemModel *appLagItem = [[HCTItemModel alloc] init];
    appLagItem.type = @"appLag";
    appLagItem.onlyWifi = NO;
    appLagItem.isRealtime = NO;
    appLagItem.isUploadPayload = NO;

    HCTItemModel *appBootItem = [[HCTItemModel alloc] init];
    appBootItem.type = @"appBoot";
    appBootItem.onlyWifi = NO;
    appBootItem.isRealtime = NO;
    appBootItem.isUploadPayload = NO;

    HCTItemModel *netItem = [[HCTItemModel alloc] init];
    netItem.type = @"net";
    netItem.onlyWifi = NO;
    netItem.isRealtime = NO;
    netItem.isUploadPayload = NO;

    HCTItemModel *netErrorItem = [[HCTItemModel alloc] init];
    netErrorItem.type = @"netError";
    netErrorItem.onlyWifi = NO;
    netErrorItem.isRealtime = NO;
    netErrorItem.isUploadPayload = NO;
    configurationModel.monitorList = @[appCrashItem, appLagItem, appBootItem, netItem, netErrorItem];
    self.configurationModel = configurationModel;
}

上面的例子是一份默认配置信息

3. 拉取策略

网络拉取使用了基础 SDK (非网络 SDK)的能力 mGet,根据 key 注册网络服务。这些 key 通常是 SDK 内部的定义好的,好比统跳路由表等。

这类 key 的共性是 App 在打包阶段会内置一份默认配置,App 启动后会去拉取最新数据,而后完成数据的缓存,缓存会在 NSDocumentDirectory 目录下按照 SDK 名称、 App 版本号、打包平台上分配的打包任务 id、 key 创建缓存文件夹。

此外它的特色是等 App 启动完成后才去请求网络,获取数据,不会影响 App 的启动。

流程图以下

数据上报配置信息获取流程

下面是一个截取代码,对比上面图看看。

@synthesize configurationDictionary = _configurationDictionary;

#pragma mark - Initial Methods

+ (instancetype)sharedInstance {
    static HCTConfigurationService *_sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _sharedInstance = [[self alloc] init];
    });
    return _sharedInstance;
}

- (instancetype)init {
    if (self = [super init]) {
        [self setUp];
    }
    return self;
}

#pragma mark - public Method

- (void)registerAndFetchConfigurationInfo {
    __weak typeof(self) weakself = self;
    NSDictionary *params = @{@"deviceId": [[HermesClient sharedInstance] getCommon].SYS_DEVICE_ID};

    [self.requester fetchUploadConfigurationWithParams:params success:^(NSDictionary * _Nonnull configurationDictionary) {
        weakself.configurationDictionary = configurationDictionary;
        [NSKeyedArchiver archiveRootObject:configurationDictionary toFile:[self savedFilePath]];
    } failure:^(NSError * _Nonnull error) {
        
    }];
}

- (HCTConfigurationModel *)getConfigurationWithNamespace:(NSString *)namespace {
    if (!HCT_IS_CLASS(namespace, NSString)) {
        NSAssert(HCT_IS_CLASS(namespace, NSString), @"须要根据 namespace 参数获取对应的配置信息,因此必须是 NSString 类型");
        return nil;
    }
    if (namespace.length == 0) {
        NSAssert(namespace.length > 0, @"须要根据 namespace 参数获取对应的配置信息,因此必须是非空的 NSString");
        return nil;
    }
    id configurationData = [self.configurationDictionary objectForKey:namespace];
    if (!configurationData) {
        return nil;
    }
    if (!HCT_IS_CLASS(configurationData, NSDictionary)) {
        return nil;
    }
    NSDictionary *configurationDictionary = (NSDictionary *)configurationData;
    return [HCTConfigurationModel modelWithDictionary:configurationDictionary];
}


#pragma mark - private method

- (void)setUp {
    // 建立数据保存的文件夹
    [[NSFileManager defaultManager] createDirectoryAtPath:[self configurationDataFilePath] withIntermediateDirectories:YES attributes:nil error:nil];
    [self setDefaultConfigurationModel];
    [self getConfigurationModelFromLocal];
}

- (NSString *)savedFilePath {
    return [NSString stringWithFormat:@"%@/%@", [self configurationDataFilePath], HCT_CONFIGURATION_FILEPATH];
}

// 初始化一份默认配置
- (void)setDefaultConfigurationModel {
    HCTConfigurationModel *configurationModel = [[HCTConfigurationModel alloc] init];
    configurationModel.url = @"https://.com";
    configurationModel.isUpload = YES;
    configurationModel.isGather = YES;
    configurationModel.isUpdateClear = YES;
    configurationModel.periodicTimerSecond = 5;
    configurationModel.maxBodyMByte = 1;
    configurationModel.maxItem = 100;
    configurationModel.maxFlowMByte = 20;
    configurationModel.expirationDay = 15;

    HCTItemModel *appCrashItem = [[HCTItemModel alloc] init];
    appCrashItem.type = @"appCrash";
    appCrashItem.onlyWifi = NO;
    appCrashItem.isRealtime = YES;
    appCrashItem.isUploadPayload = YES;

    HCTItemModel *appLagItem = [[HCTItemModel alloc] init];
    appLagItem.type = @"appLag";
    appLagItem.onlyWifi = NO;
    appLagItem.isRealtime = NO;
    appLagItem.isUploadPayload = NO;

    HCTItemModel *appBootItem = [[HCTItemModel alloc] init];
    appBootItem.type = @"appBoot";
    appBootItem.onlyWifi = NO;
    appBootItem.isRealtime = NO;
    appBootItem.isUploadPayload = NO;

    HCTItemModel *netItem = [[HCTItemModel alloc] init];
    netItem.type = @"net";
    netItem.onlyWifi = NO;
    netItem.isRealtime = NO;
    netItem.isUploadPayload = NO;

    HCTItemModel *netErrorItem = [[HCTItemModel alloc] init];
    netErrorItem.type = @"netError";
    netErrorItem.onlyWifi = NO;
    netErrorItem.isRealtime = NO;
    netErrorItem.isUploadPayload = NO;
    configurationModel.monitorList = @[appCrashItem, appLagItem, appBootItem, netItem, netErrorItem];
    self.configurationModel = configurationModel;
}

- (void)getConfigurationModelFromLocal {
    id unarchiveObject = [NSKeyedUnarchiver unarchiveObjectWithFile:[self savedFilePath]];
    if (unarchiveObject) {
        if (HCT_IS_CLASS(unarchiveObject, NSDictionary)) {
            self.configurationDictionary = (NSDictionary *)unarchiveObject;
            [self.configurationDictionary enumerateKeysAndObjectsUsingBlock:^(id  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
                if ([key isEqualToString:HermesNAMESPACE]) {
                    if (HCT_IS_CLASS(obj, NSDictionary)) {
                        NSDictionary *configurationDictionary = (NSDictionary *)obj;
                        self.configurationModel = [HCTConfigurationModel modelWithDictionary:configurationDictionary];
                    }
                }
            }];
        }
    }
}


#pragma mark - getters and setters

- (NSString *)configurationDataFilePath {
    NSString *filePath = [NSString stringWithFormat:@"%@/%@/%@/%@", NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject, @"hermes", [CMAppProfile sharedInstance].mAppVersion, [[HermesClient sharedInstance] getCommon].WAX_CANDLE_TASK_ID];
    return filePath;
}

- (HCTRequestFactory *)requester {
    if (!_requester) {
        _requester = [[HCTRequestFactory alloc] init];
    }
    return _requester;
}

- (void)setConfigurationDictionary:(NSDictionary *)configurationDictionary
{
    @synchronized (self) {
        _configurationDictionary = configurationDictionary;
    }
}

- (NSDictionary *)configurationDictionary
{
    @synchronized (self) {
        if (_configurationDictionary == nil) {
            NSDictionary *hermesDictionary = [self.configurationModel getDictionary];
            _configurationDictionary = @{HermesNAMESPACE: hermesDictionary};
        }
        return _configurationDictionary;
    }
}

@end

3、数据存储

1. 数据存储技术选型

记得在作数据上报技术的评审会议上,Android 同事说用 WCDB,特点是 ORM、多线程安全、高性能。而后就被质疑了。由于上个版本使用的技术是基于系统自带的 sqlite2,单纯为了 ORM、多线程问题就额外引入一个三方库,是不太能说服人的。有这样几个疑问

  • ORM 并非核心诉求,利用 Runtime 能够在基础上进行修改,也可支持 ORM 功能
  • 线程安全。WCDB 在线程安全的实现主要是基于HandleHandlePoolDatabase 三个类完成的。Handle 是 sqlite3 指针,HandlePool 用来处理链接。

    RecyclableHandle HandlePool::flowOut(Error &error)
    {
        m_rwlock.lockRead();
        std::shared_ptr<HandleWrap> handleWrap = m_handles.popBack();
        if (handleWrap == nullptr) {
            if (m_aliveHandleCount < s_maxConcurrency) {
                handleWrap = generate(error);
                if (handleWrap) {
                    ++m_aliveHandleCount;
                    if (m_aliveHandleCount > s_hardwareConcurrency) {
                        WCDB::Error::Warning(
                            ("The concurrency of database:" +
                             std::to_string(tag.load()) + " with " +
                             std::to_string(m_aliveHandleCount) +
                             " exceeds the concurrency of hardware:" +
                             std::to_string(s_hardwareConcurrency))
                                .c_str());
                    }
                }
            } else {
                Error::ReportCore(
                    tag.load(), path, Error::CoreOperation::FlowOut,
                    Error::CoreCode::Exceed,
                    "The concurrency of database exceeds the max concurrency",
                    &error);
            }
        }
        if (handleWrap) {
            handleWrap->handle->setTag(tag.load());
            if (invoke(handleWrap, error)) {
                return RecyclableHandle(
                    handleWrap, [this](std::shared_ptr<HandleWrap> &handleWrap) {
                        flowBack(handleWrap);
                    });
            }
        }
    
        handleWrap = nullptr;
        m_rwlock.unlockRead();
        return RecyclableHandle(nullptr, nullptr);
    }
    
    void HandlePool::flowBack(const std::shared_ptr<HandleWrap> &handleWrap)
    {
        if (handleWrap) {
            bool inserted = m_handles.pushBack(handleWrap);
            m_rwlock.unlockRead();
            if (!inserted) {
                --m_aliveHandleCount;
            }
        }
    }

    因此 WCDB 链接池经过读写锁保证线程安全。因此以前版本的地方要实现线程安全修改下缺陷就能够。增长了 sqlite3,虽然看起来就是几兆大小,可是这对于公共团队是致命的。业务线开发者每次接入 SDK 会注意App 包体积的变化,为了数据上报增长好几兆,这是不能够接受的。

  • 高性能的背后是 WCDB 自带的 sqlite3 开启了 WAL模式 (Write-Ahead Logging)。当 WAL 文件超过 1000 个页大小时,SQLite3 会将 WAL 文件写会数据库文件。也就是 checkpointing。当大批量的数据写入场景时,若是不停提交文件到数据库事务,效率确定低下,WCDB 的策略就是在触发 checkpoint 时,经过延时队列去处理,避免不停的触发 WalCheckpoint 调用。经过 TimedQueue 将同个数据库的 WalCheckpoint 合并延迟到2秒后执行

    {
      Database::defaultCheckpointConfigName,
      [](std::shared_ptr<Handle> &handle, Error &error) -> bool {
        handle->registerCommittedHook(
          [](Handle *handle, int pages, void *) {
            static TimedQueue<std::string> s_timedQueue(2);
            if (pages > 1000) {
              s_timedQueue.reQueue(handle->path);
            }
            static std::thread s_checkpointThread([]() {
              pthread_setname_np(
                ("WCDB-" + Database::defaultCheckpointConfigName)
                .c_str());
              while (true) {
                s_timedQueue.waitUntilExpired(
                  [](const std::string &path) {
                    Database database(path);
                    WCDB::Error innerError;
                    database.exec(StatementPragma().pragma(
                      Pragma::WalCheckpoint),
                                  innerError);
                  });
              }
            });
            static std::once_flag s_flag;
            std::call_once(s_flag,
                           []() { s_checkpointThread.detach(); });
          },
          nullptr);
        return true;
      },
      (Configs::Order) Database::ConfigOrder::Checkpoint,
    },

通常来讲公共组作事情,SDK 命名、接口名称、接口个数、参数个数、参数名称、参数数据类型是严格一致的,差别是语言而已。实在万不得已,能力不能堆砌的状况下是能够不一致的,可是须要在技术评审会议上说明缘由,须要在发布文档、接入文档都有所体现。

因此最后的结论是在以前的版本基础上进行修改,以前的版本是 FMDB。

2. 数据库维护队列

1. FMDB 队列

FMDB 使用主要是经过 FMDatabaseQueue- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block。这2个方法的实现以下

- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block {
#ifndef NDEBUG
    /* Get the currently executing queue (which should probably be nil, but in theory could be another DB queue
     * and then check it against self to make sure we're not about to deadlock. */
    FMDatabaseQueue *currentSyncQueue = (__bridge id)dispatch_get_specific(kDispatchQueueSpecificKey);
    assert(currentSyncQueue != self && "inDatabase: was called reentrantly on the same queue, which would lead to a deadlock");
#endif
    
    FMDBRetain(self);
    
    dispatch_sync(_queue, ^() {
        
        FMDatabase *db = [self database];
        
        block(db);
        
        if ([db hasOpenResultSets]) {
            NSLog(@"Warning: there is at least one open result set around after performing [FMDatabaseQueue inDatabase:]");
            
#if defined(DEBUG) && DEBUG
            NSSet *openSetCopy = FMDBReturnAutoreleased([[db valueForKey:@"_openResultSets"] copy]);
            for (NSValue *rsInWrappedInATastyValueMeal in openSetCopy) {
                FMResultSet *rs = (FMResultSet *)[rsInWrappedInATastyValueMeal pointerValue];
                NSLog(@"query: '%@'", [rs query]);
            }
#endif
        }
    });
    
    FMDBRelease(self);
}
- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block {
    [self beginTransaction:FMDBTransactionExclusive withBlock:block];
}

- (void)beginTransaction:(FMDBTransaction)transaction withBlock:(void (^)(FMDatabase *db, BOOL *rollback))block {
    FMDBRetain(self);
    dispatch_sync(_queue, ^() { 
        
        BOOL shouldRollback = NO;

        switch (transaction) {
            case FMDBTransactionExclusive:
                [[self database] beginTransaction];
                break;
            case FMDBTransactionDeferred:
                [[self database] beginDeferredTransaction];
                break;
            case FMDBTransactionImmediate:
                [[self database] beginImmediateTransaction];
                break;
        }
        
        block([self database], &shouldRollback);
        
        if (shouldRollback) {
            [[self database] rollback];
        }
        else {
            [[self database] commit];
        }
    });
    
    FMDBRelease(self);
}

上面的 _queue 实际上是一个串行队列,经过 _queue = dispatch_queue_create([[NSString stringWithFormat:@"fmdb.%@", self] UTF8String], NULL); 建立。因此,FMDB 的核心就是以同步的形式向串行队列提交任务,来保证多线程操做下的读写问题(比每一个操做加锁效率高不少)。只有一个任务执行完毕,才能够执行下一个任务。

上一个版本的数据上报 SDK 功能比较简单,就是上报 APM 监控后的数据,因此数据量不会很大,以前的人封装超级简单,仅以事务的形式封装了一层 FMDB 的增删改查操做。那么就会有一个问题。假如 SDK 被业务线接入,业务线开发者不知道数据上报 SDK 的内部实现,直接调用接口去写入大量数据,结果 App 发生了卡顿,那不得反馈你这个 SDK 超级难用啊。

2. 针对 FMDB 的改进

改法也比较简单,咱们先弄清楚 FMDB 这样设计的缘由。数据库操做的环境多是主线程、子线程等不一样环境去修改数据,主线程、子线程去读取数据,因此建立了一个串行队列去执行真正的数据增删改查。

目的就是让不一样线程去使用 FMDB 的时候不会阻塞当前线程。既然 FMDB 内部维护了一个串行队列去处理多线程状况下的数据操做,那么改法也比较简单,那就是建立一个并发队列,而后以异步的方式提交任务到 FMDB 中去,FMDB 内部的串行队列去执行真正的任务。

代码以下

// 建立队列
self.dbOperationQueue = dispatch_queue_create(HCT_DATABASE_OPERATION_QUEUE, DISPATCH_QUEUE_CONCURRENT);
self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[HCTDatabase databaseFilePath]];

// 以删除数据为例,以异步任务的方式向并发队列提交任务,任务内部调用 FMDatabaseQueue 去串行执行每一个任务
- (void)removeAllLogsInTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself removeAllLogsInTable:tableName];
    });
}

- (void)removeAllLogsInTable:(NSString *)tableName {
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@", tableName];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db executeUpdate:sqlString];
    }];
}

小实验模拟下流程

sleep(1);
NSLog(@"1");
dispatch_queue_t concurrentQueue = dispatch_queue_create("HCT_DATABASE_OPERATION_QUEUE", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(concurrentQueue, ^{
  sleep(2);
  NSLog(@"2");
});
sleep(1);
NSLog(@"3");
dispatch_async(concurrentQueue, ^{
  sleep(3);
  NSLog(@"4");
});
sleep(1);
NSLog(@"5");

2020-07-01 13:28:13.610575+0800 Test[54460:1557233] 1
2020-07-01 13:28:14.611937+0800 Test[54460:1557233] 3
2020-07-01 13:28:15.613347+0800 Test[54460:1557233] 5
2020-07-01 13:28:15.613372+0800 Test[54460:1557280] 2
2020-07-01 13:28:17.616837+0800 Test[54460:1557277] 4

MainThread Dispatch Async Task To ConcurrentQueue

3. 数据表设计

通用的数据上报 SDK 的功能是数据的保存和上报。从数据的角度来划分,数据能够分为 APM 监控数据和业务线的业务数据。

数据各有什么特色呢?APM 监控数据通常能够划分为:基本信息、异常信息、线程信息,也就是最大程度的还原案发线程的数据。业务线数据基本上不会有所谓的大量数据,最多就是数据条数很是多。鉴于此现状,能够将数据表设计为 meta 表payload 表。meta 表用来存放 APM 的基础数据和业务线的数据,payload 表用来存放 APM 的线程堆栈数据。

数据表的设计是基于业务状况的。那有这样几个背景

  • APM 监控数据须要报警(具体能够查看 APM 文章,地址在开头 ),因此数据上报 SDK 上报后的数据须要实时解析
  • 产品侧好比监控大盘能够慢,因此符号化系统是异步的
  • 监控数据实在太大了,若是同步解析会由于压力较大形成性能瓶颈

因此把监控数据拆分为2块,即 meta 表、payload 表。meta 表至关于记录索引信息,服务端只须要关心这个。而 payload 数据在服务端是不会处理的,会有一个异步服务单独处理。

meta 表、payload 表结构以下:

create table if not exists ***_meta (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, namespace text, is_used integer NOT NULL, size integer NOT NULL);

create table if not exists ***_payload (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, payload blob, namespace text, is_used integer NOT NULL, size integer NOT NULL);

4. 数据库表的封装

#import "HCTDatabase.h"
#import <FMDB/FMDB.h>

static NSString *const HCT_LOG_DATABASE_NAME = @"***.db";
static NSString *const HCT_LOG_TABLE_META = @"***_hermes_meta";
static NSString *const HCT_LOG_TABLE_PAYLOAD = @"***_hermes_payload";
const char *HCT_DATABASE_OPERATION_QUEUE = "com.***.HCT_database_operation_QUEUE";

@interface HCTDatabase ()

@property (nonatomic, strong) dispatch_queue_t dbOperationQueue;
@property (nonatomic, strong) FMDatabaseQueue *dbQueue;
@property (nonatomic, strong) NSDateFormatter *dateFormatter;

@end

@implementation HCTDatabase

#pragma mark - life cycle
+ (instancetype)sharedInstance {
    static HCTDatabase *_sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _sharedInstance = [[self alloc] init];
    });
    return _sharedInstance;
}

- (instancetype)init {
    self = [super init];
    self.dateFormatter = [[NSDateFormatter alloc] init];
    [self.dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss A Z"];
    self.dbOperationQueue = dispatch_queue_create(HCT_DATABASE_OPERATION_QUEUE, DISPATCH_QUEUE_CONCURRENT);
    self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[HCTDatabase databaseFilePath]];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [self createLogMetaTableIfNotExist:db];
        [self createLogPayloadTableIfNotExist:db];
    }];
    return self;
}

#pragma mark - public Method

- (void)add:(NSArray<HCTLogModel *> *)logs inTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself add:logs inTable:tableName];
    });
}

- (void)remove:(NSArray<HCTLogModel *> *)logs inTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself remove:logs inTable:tableName];
    });
}

- (void)removeAllLogsInTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself removeAllLogsInTable:tableName];
    });
}

- (void)removeOldestRecordsByCount:(NSInteger)count inTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself removeOldestRecordsByCount:count inTable:tableName];
    });
}

- (void)removeLatestRecordsByCount:(NSInteger)count inTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself removeLatestRecordsByCount:count inTable:tableName];
    });
}

- (void)removeRecordsBeforeDays:(NSInteger)day inTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself removeRecordsBeforeDays:day inTable:tableName];
    });
    [self rebuildDatabaseFileInTableType:tableType];
}

- (void)removeDataUseCondition:(NSString *)condition inTableType:(HCTLogTableType)tableType {
    if (!HCT_IS_CLASS(condition, NSString)) {
        NSAssert(HCT_IS_CLASS(condition, NSString), @"自定义删除条件必须是字符串类型");
        return;
    }
    if (condition.length == 0) {
        NSAssert(!(condition.length == 0), @"自定义删除条件不能为空");
        return;
    }
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself removeDataUseCondition:condition inTable:tableName];
    });
}

- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTableType:(HCTLogTableType)tableType {
    if (!HCT_IS_CLASS(state, NSString)) {
        NSAssert(HCT_IS_CLASS(state, NSString), @"数据表字段更改命令必须是合法字符串");
        return;
    }
    if (state.length == 0) {
        NSAssert(!(state.length == 0), @"数据表字段更改命令必须是合法字符串");
        return;
    }
    
    if (!HCT_IS_CLASS(condition, NSString)) {
        NSAssert(HCT_IS_CLASS(condition, NSString), @"数据表字段更改条件必须是字符串类型");
        return;
    }
    if (condition.length == 0) {
        NSAssert(!(condition.length == 0), @"数据表字段更改条件不能为空");
        return;
    }
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself updateData:state useCondition:condition inTable:tableName];
    });
}

- (void)recordsCountInTableType:(HCTLogTableType)tableType completion:(void (^)(NSInteger count))completion {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        NSInteger recordsCount = [weakself recordsCountInTable:tableName];
        if (completion) {
            completion(recordsCount);
        }
    });
}

- (void)getLatestRecoreds:(NSInteger)count inTableType:(HCTLogTableType)tableType completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        NSArray<HCTLogModel *> *records = [weakself getLatestRecoreds:count inTable:tableName];
        if (completion) {
            completion(records);
        }
    });
}

- (void)getOldestRecoreds:(NSInteger)count inTableType:(HCTLogTableType)tableType completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        NSArray<HCTLogModel *> *records = [weakself getOldestRecoreds:count inTable:tableName];
        if (completion) {
            completion(records);
        }
    });
}

- (void)getRecordsByCount:(NSInteger)count condtion:(NSString *)condition inTableType:(HCTLogTableType)tableType completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
    if (!HCT_IS_CLASS(condition, NSString)) {
        NSAssert(HCT_IS_CLASS(condition, NSString), @"自定义查询条件必须是字符串类型");
        if (completion) {
            completion(nil);
        }
    }
    if (condition.length == 0) {
        NSAssert(!(condition.length == 0), @"自定义查询条件不能为空");
        if (completion) {
            completion(nil);
        }
    }
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        NSArray<HCTLogModel *> *records = [weakself getRecordsByCount:count condtion:condition inTable:tableName];
        if (completion) {
            completion(records);
        }
    });
}

- (void)rebuildDatabaseFileInTableType:(HCTLogTableType)tableType {
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself rebuildDatabaseFileInTable:tableName];
    });
}

#pragma mark - CMDatabaseDelegate

- (void)add:(NSArray<HCTLogModel *> *)logs inTable:(NSString *)tableName {
    if (logs.count == 0) {
        return;
    }
    __weak typeof(self) weakself = self;
    [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) {
        [db setDateFormat:weakself.dateFormatter];
        for (NSInteger index = 0; index < logs.count; index++) {
            id obj = logs[index];
            // meta 类型数据的处理逻辑
            if (HCT_IS_CLASS(obj, HCTLogMetaModel)) {
                HCTLogMetaModel *model = (HCTLogMetaModel *)obj;
                if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) {
                    HCTLOG(@"参数错误 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace);
                    return;
                }

                NSString *sqlString = [NSString stringWithFormat:@"insert into %@ (report_id, monitor_type, is_biz, created_time, meta, namespace, is_used, size) values (?, ?, ?, ?, ?, ?, ?, ?)", tableName];
                [db executeUpdate:sqlString withArgumentsInArray:@[model.report_id, model.monitor_type, @(model.is_biz), model.created_time, model.meta, model.namespace, @(model.is_used), @(model.size)]];
            }

            // payload 类型数据的处理逻辑
            if (HCT_IS_CLASS(obj, HCTLogPayloadModel)) {
                HCTLogPayloadModel *model = (HCTLogPayloadModel *)obj;
                if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) {
                    HCTLOG(@"参数错误 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace);
                    return;
                }

                NSString *sqlString = [NSString stringWithFormat:@"insert into %@ (report_id, monitor_type, is_biz, created_time, meta, payload, namespace, is_used, size) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", tableName];
                [db executeUpdate:sqlString withArgumentsInArray:@[model.report_id, model.monitor_type, @(model.is_biz), model.created_time, model.meta, model.payload ?: [NSData data], model.namespace, @(model.is_used), @(model.size)]];
            }
        }
    }];
}

- (NSInteger)remove:(NSArray<HCTLogModel *> *)logs inTable:(NSString *)tableName {
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where report_id = ?", tableName];
    [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) {
        [logs enumerateObjectsUsingBlock:^(HCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
            [db executeUpdate:sqlString withArgumentsInArray:@[obj.report_id]];
        }];
    }];
    return 0;
}

- (void)removeAllLogsInTable:(NSString *)tableName {
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@", tableName];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db executeUpdate:sqlString];
    }];
}

- (void)removeOldestRecordsByCount:(NSInteger)count inTable:(NSString *)tableName {
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where id in (select id from %@ order by created_time asc limit ? )", tableName, tableName];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db executeUpdate:sqlString withArgumentsInArray:@[@(count)]];
    }];
}

- (void)removeLatestRecordsByCount:(NSInteger)count inTable:(NSString *)tableName {
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where id in (select id from %@ order by created_time desc limit ?)", tableName, tableName];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db executeUpdate:sqlString withArgumentsInArray:@[@(count)]];
    }];
}

- (void)removeRecordsBeforeDays:(NSInteger)day inTable:(NSString *)tableName {
    // 找出从create到如今已经超过最大 day 天的数据,而后删除 :delete from ***_hermes_meta where strftime('%s', date('now', '-2 day'))  >= created_time;
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where strftime('%%s', date('now', '-%zd day')) >= created_time", tableName, day];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db executeUpdate:sqlString];
    }];
}

- (void)removeDataUseCondition:(NSString *)condition inTable:(NSString *)tableName {
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where %@", tableName, condition];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db executeUpdate:sqlString];
    }];
}

- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTable:(NSString *)tableName
{
    NSString *sqlString = [NSString stringWithFormat:@"update %@ set %@ where %@", tableName, state, condition];
    [self.dbQueue inDatabase:^(FMDatabase * _Nonnull db) {
        BOOL res =  [db executeUpdate:sqlString];
        HCTLOG(res ? @"更新成功" : @"更新失败");
    }];
}

- (NSInteger)recordsCountInTable:(NSString *)tableName {
    NSString *sqlString = [NSString stringWithFormat:@"select count(*) as count from %@", tableName];
    __block NSInteger recordsCount = 0;
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        FMResultSet *resultSet = [db executeQuery:sqlString];
        [resultSet next];
        recordsCount = [resultSet intForColumn:@"count"];
        [resultSet close];
    }];
    return recordsCount;
}

- (NSArray<HCTLogModel *> *)getLatestRecoreds:(NSInteger)count inTable:(NSString *)tableName {
    __block NSMutableArray<HCTLogModel *> *records = [NSMutableArray new];
    NSString *sql = [NSString stringWithFormat:@"select * from %@ order by created_time desc limit ?", tableName];

    __weak typeof(self) weakself = self;
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db setDateFormat:weakself.dateFormatter];
        FMResultSet *resultSet = [db executeQuery:sql withArgumentsInArray:@[@(count)]];
        while ([resultSet next]) {
            if ([tableName isEqualToString:HCT_LOG_TABLE_META]) {
                HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init];
                model.log_id = [resultSet intForColumn:@"id"];
                model.report_id = [resultSet stringForColumn:@"report_id"];
                model.monitor_type = [resultSet stringForColumn:@"monitor_type"];
                model.created_time = [resultSet stringForColumn:@"created_time"];
                model.meta = [resultSet stringForColumn:@"meta"];
                model.namespace = [resultSet stringForColumn:@"namespace"];
                model.size = [resultSet intForColumn:@"size"];
                model.is_biz = [resultSet boolForColumn:@"is_biz"];
                model.is_used = [resultSet boolForColumn:@"is_used"];
                [records addObject:model];

            } else if ([tableName isEqualToString:HCT_LOG_TABLE_PAYLOAD]) {
                HCTLogPayloadModel *model = [[HCTLogPayloadModel alloc] init];
                model.log_id = [resultSet intForColumn:@"id"];
                model.report_id = [resultSet stringForColumn:@"report_id"];
                model.monitor_type = [resultSet stringForColumn:@"monitor_type"];
                model.created_time = [resultSet stringForColumn:@"created_time"];
                model.meta = [resultSet stringForColumn:@"meta"];
                model.payload = [resultSet dataForColumn:@"payload"];
                model.namespace = [resultSet stringForColumn:@"namespace"];
                model.size = [resultSet intForColumn:@"size"];
                model.is_biz = [resultSet boolForColumn:@"is_biz"];
                model.is_used = [resultSet boolForColumn:@"is_used"];
                [records addObject:model];
            }
        }
        [resultSet close];
    }];
    return records;
}

- (NSArray<HCTLogModel *> *)getOldestRecoreds:(NSInteger)count inTable:(NSString *)tableName {
    __block NSMutableArray<HCTLogModel *> *records = [NSMutableArray array];
    NSString *sqlString = [NSString stringWithFormat:@"select * from %@ order by created_time asc limit ?", tableName];

    __weak typeof(self) weakself = self;
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db setDateFormat:weakself.dateFormatter];

        FMResultSet *resultSet = [db executeQuery:sqlString withArgumentsInArray:@[@(count)]];
        while ([resultSet next]) {
            if ([tableName isEqualToString:HCT_LOG_TABLE_META]) {
                HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init];
                model.log_id = [resultSet intForColumn:@"id"];
                model.report_id = [resultSet stringForColumn:@"report_id"];
                model.monitor_type = [resultSet stringForColumn:@"monitor_type"];
                model.created_time = [resultSet stringForColumn:@"created_time"];
                model.meta = [resultSet stringForColumn:@"meta"];
                model.namespace = [resultSet stringForColumn:@"namespace"];
                model.size = [resultSet intForColumn:@"size"];
                model.is_biz = [resultSet boolForColumn:@"is_biz"];
                model.is_used = [resultSet boolForColumn:@"is_used"];
                [records addObject:model];

            } else if ([tableName isEqualToString:HCT_LOG_TABLE_PAYLOAD]) {
                HCTLogPayloadModel *model = [[HCTLogPayloadModel alloc] init];
                model.log_id = [resultSet intForColumn:@"id"];
                model.report_id = [resultSet stringForColumn:@"report_id"];
                model.monitor_type = [resultSet stringForColumn:@"monitor_type"];
                model.created_time = [resultSet stringForColumn:@"created_time"];
                model.meta = [resultSet stringForColumn:@"meta"];
                model.payload = [resultSet dataForColumn:@"payload"];
                model.namespace = [resultSet stringForColumn:@"namespace"];
                model.size = [resultSet intForColumn:@"size"];
                model.is_used = [resultSet boolForColumn:@"is_used"];
                model.is_biz = [resultSet boolForColumn:@"is_biz"];
                [records addObject:model];
            }
        }
        [resultSet close];
    }];
    return records;
}

- (NSArray<HCTLogModel *> *)getRecordsByCount:(NSInteger)count condtion:(NSString *)condition inTable:(NSString *)tableName {
    __block NSMutableArray<HCTLogModel *> *records = [NSMutableArray array];
    __weak typeof(self) weakself = self;
    NSString *sqlString = [NSString stringWithFormat:@"select * from %@ where %@ order by created_time desc limit %zd", tableName, condition, count];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db setDateFormat:weakself.dateFormatter];

        FMResultSet *resultSet = [db executeQuery:sqlString];

        while ([resultSet next]) {
            if ([tableName isEqualToString:HCT_LOG_TABLE_META]) {
                HCTLogMetaModel *model = [[HCTLogMetaModel alloc] init];
                model.log_id = [resultSet intForColumn:@"id"];
                model.report_id = [resultSet stringForColumn:@"report_id"];
                model.monitor_type = [resultSet stringForColumn:@"monitor_type"];
                model.created_time = [resultSet stringForColumn:@"created_time"];
                model.meta = [resultSet stringForColumn:@"meta"];
                model.namespace = [resultSet stringForColumn:@"namespace"];
                model.size = [resultSet intForColumn:@"size"];
                model.is_biz = [resultSet boolForColumn:@"is_biz"];
                model.is_used = [resultSet boolForColumn:@"is_used"];
                [records addObject:model];

            } else if ([tableName isEqualToString:HCT_LOG_TABLE_PAYLOAD]) {
                HCTLogPayloadModel *model = [[HCTLogPayloadModel alloc] init];
                model.log_id = [resultSet intForColumn:@"id"];
                model.report_id = [resultSet stringForColumn:@"report_id"];
                model.monitor_type = [resultSet stringForColumn:@"monitor_type"];
                model.created_time = [resultSet stringForColumn:@"created_time"];
                model.meta = [resultSet stringForColumn:@"meta"];
                model.payload = [resultSet dataForColumn:@"payload"];
                model.namespace = [resultSet stringForColumn:@"namespace"];
                model.size = [resultSet intForColumn:@"size"];
                model.is_biz = [resultSet boolForColumn:@"is_biz"];
                model.is_used = [resultSet boolForColumn:@"is_used"];
                [records addObject:model];
            }
        }
        [resultSet close];
    }];
    return records;
}

- (void)rebuildDatabaseFileInTable:(NSString *)tableName {
    NSString *sqlString = [NSString stringWithFormat:@"vacuum %@", tableName];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db executeUpdate:sqlString];
    }];
}

#pragma mark - private method

+ (NSString *)databaseFilePath {
    NSString *docsPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
    NSString *dbPath = [docsPath stringByAppendingPathComponent:HCT_LOG_DATABASE_NAME];
    HCTLOG(@"上报系统数据库文件位置 -> %@", dbPath);
    return dbPath;
}

- (void)createLogMetaTableIfNotExist:(FMDatabase *)db {
    NSString *createMetaTableSQL = [NSString stringWithFormat:@"create table if not exists %@ (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, namespace text, is_used integer NOT NULL, size integer NOT NULL)", HCT_LOG_TABLE_META];
    BOOL result = [db executeStatements:createMetaTableSQL];
    HCTLOG(@"确认日志Meta表是否存在 -> %@", result ? @"成功" : @"失败");
}

- (void)createLogPayloadTableIfNotExist:(FMDatabase *)db {
    NSString *createMetaTableSQL = [NSString stringWithFormat:@"create table if not exists %@ (id integer NOT NULL primary key autoincrement, report_id text, monitor_type text, is_biz integer NOT NULL, created_time datetime, meta text, payload blob, namespace text, is_used integer NOT NULL, size integer NOT NULL)", HCT_LOG_TABLE_PAYLOAD];
    BOOL result = [db executeStatements:createMetaTableSQL];
    HCTLOG(@"确认日志Payload表是否存在 -> %@", result ? @"成功" : @"失败");
}

NS_INLINE NSString *HCTGetTableNameFromType(HCTLogTableType type) {
    if (type == HCTLogTableTypeMeta) {
        return HCT_LOG_TABLE_META;
    }
    if (type == HCTLogTableTypePayload) {
        return HCT_LOG_TABLE_PAYLOAD;
    }
    return @"";
}

// 每次操做前检查数据库以及数据表是否存在,不存在则建立数据库和数据表
- (void)isExistInTable:(HCTLogTableType)tableType {
    NSString *databaseFilePath = [HCTDatabase databaseFilePath];
    BOOL isExist = [[NSFileManager defaultManager] fileExistsAtPath:databaseFilePath];
    if (!isExist) {
        self.dbQueue = [FMDatabaseQueue databaseQueueWithPath:[HCTDatabase databaseFilePath]];
    }
    [self.dbQueue inDatabase:^(FMDatabase *db) {
        NSString *tableName = HCTGetTableNameFromType(tableType);
        BOOL res = [db tableExists:tableName];
        if (!res) {
            if (tableType == HCTLogTableTypeMeta) {
                [self createLogMetaTableIfNotExist:db];
            }
            if (tableType == HCTLogTableTypeMeta) {
                [self createLogPayloadTableIfNotExist:db];
            }
        }
    }];
}

@end

上面有个地方须要注意下,由于常常须要根据类型来判读操做那个数据表,使用频次很高,因此写成内联函数的形式

NS_INLINE NSString *HCTGetTableNameFromType(HCTLogTableType type) {
    if (type == HCTLogTableTypeMeta) {
        return HCT_LOG_TABLE_META;
    }
    if (type == HCTLogTableTypePayload) {
        return HCT_LOG_TABLE_PAYLOAD;
    }
    return @"";
}

5. 数据存储流程

APM 监控数据会比较特殊点,好比 iOS 当发生 crash 后是没办法上报的,只有将 crash 信息保存到文件中,下次 App 启动后读取 crash 日志文件夹再去交给数据上报 SDK。Android 在发生 crash 后因为机制不同,能够立刻将 crash 信息交给数据上报 SDK。

因为 payload 数据,也就是堆栈数据很是大,因此上报的接口也有限制,一次上传接口中报文最大包体积的限制等等。

能够看一下 Model 信息,

@interface HCTItemModel : NSObject <NSCoding>

@property (nonatomic, copy) NSString *type;         /**<上报数据类型*/
@property (nonatomic, assign) BOOL onlyWifi;        /**<是否仅 Wi-Fi 上报*/
@property (nonatomic, assign) BOOL isRealtime;      /**<是否实时上报*/
@property (nonatomic, assign) BOOL isUploadPayload; /**<是否须要上报 Payload*/

@end

@interface HCTConfigurationModel : NSObject <NSCoding>

@property (nonatomic, copy) NSString *url;                        /**<当前 namespace 对应的上报地址 */
@property (nonatomic, assign) BOOL isUpload;                      /**<全局上报开关*/
@property (nonatomic, assign) BOOL isGather;                      /**<全局采集开关*/
@property (nonatomic, assign) BOOL isUpdateClear;                 /**<升级后是否清除数据*/
@property (nonatomic, assign) NSInteger maxBodyMByte;             /**<最大包体积单位 M (范围 < 3M)*/
@property (nonatomic, assign) NSInteger periodicTimerSecond;      /**<定时上报时间单位秒 (范围1 ~ 30秒)*/
@property (nonatomic, assign) NSInteger maxItem;                  /**<最大条数 (范围 < 100)*/
@property (nonatomic, assign) NSInteger maxFlowMByte;             /**<天天最大非 Wi-Fi 上传流量单位 M (范围 < 100M)*/
@property (nonatomic, assign) NSInteger expirationDay;            /**<数据过时时间单位 天 (范围 < 30)*/
@property (nonatomic, copy) NSArray<HCTItemModel *> *monitorList; /**<配置项目*/

@end

监控数据存储流程:

  1. 每一个数据(监控数据、业务线数据)过来先判断该数据所在的 namespace 是否开启了收集开关
  2. 判断数据是否能够落库,根据数据接口中 type 可否命中上报配置数据中的 monitorList 中的任何一项的 type
  3. 监控数据先写入 meta 表,而后判断是否写入 payload 表。判断标准是计算监控数据的 payload 大小是否超过了上报配置数据的 maxBodyMByte。超过大小的数据就不能入库,由于这是服务端消耗 payload 的一个上限
  4. 走监控接口过来的数据,在方法内部会为监控数据增长基础信息(好比 App 名称、App 版本号、打包任务 id、设备类型等等)

    @property (nonatomic, copy) NSString *xxx_APP_NAME;       /**<App 名称(wax)*/
    @property (nonatomic, copy) NSString *xxx_APP_VERSION;    /**<App 版本(wax)*/
    @property (nonatomic, copy) NSString *xxx_CANDLE_TASK_ID; /**<打包平台分配的打包任务id*/
    @property (nonatomic, copy) NSString *SYS_SYSTEM_MODEL;   /**<系统类型(android / iOS)*/
    @property (nonatomic, copy) NSString *SYS_DEVICE_ID;      /**<设备 id*/
    
    @property (nonatomic, copy) NSString *SYS_BRAND;          /**<系统品牌*/
    @property (nonatomic, copy) NSString *SYS_PHONE_MODEL;    /**<设备型号*/
    @property (nonatomic, copy) NSString *SYS_SYSTEM_VERSION; /**<系统版本*/
    @property (nonatomic, copy) NSString *APP_PLATFORM;       /**<平台号*/
    @property (nonatomic, copy) NSString *APP_VERSION;        /**<App 版本(业务版本)*/
    
    @property (nonatomic, copy) NSString *APP_SESSION_ID;   /**<session id*/
    @property (nonatomic, copy) NSString *APP_PACKAGE_NAME; /**<包名*/
    @property (nonatomic, copy) NSString *APP_MODE;         /**<Debug/Release*/
    @property (nonatomic, copy) NSString *APP_UID;          /**<user id*/
    @property (nonatomic, copy) NSString *APP_MC;           /**<渠道号*/
    
    @property (nonatomic, copy) NSString *APP_MONITOR_VERSION; /**<监控版本号。和服务端维持同一个版本,服务端升级的话,SDK也跟着升级*/
    @property (nonatomic, copy) NSString *REPORT_ID;           /**<惟一ID*/
    @property (nonatomic, copy) NSString *CREATE_TIME;         /**<时间*/
    @property (nonatomic, assign) BOOL IS_BIZ;                 /**<是不是监控数据*/
  5. 由于本次交给数据上报 SDK 的 crash 类型的数据是上次奔溃时的数据,因此在第4点说的规则不太适用,APM crash 类型是特例。
  6. 计算每条数据的大小。metaSize + payloadSize
  7. 再写入 payload 表
  8. 判断是否触发实时上报,触发后走后续流程。
- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload {
    // 1. 检查参数合法性
    NSString *warning = [NSString stringWithFormat:@"%@不能是空字符串", type];
    if (!HCT_IS_CLASS(type, NSString)) {
        NSAssert1(HCT_IS_CLASS(type, NSString), warning, type);
        return;
    }
    if (type.length == 0) {
        NSAssert1(type.length > 0, warning, type);
        return;
    }

    if (!HCT_IS_CLASS(meta, NSDictionary)) {
        return;
    }
    if (meta.allKeys.count == 0) {
        return;
    }
    
    // 2. 判断当前 namespace 是否开启了收集
    if (!self.configureModel.isGather) {
        HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]);
        return ;
    }
    
    // 3. 判断是不是有效的数据。能够落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等)
    BOOL isValidate = [self validateLogData:type];
    if (!isValidate) {
        return;
    }

    // 3. 先写入 meta 表
    HCTCommonModel *commonModel = [[HCTCommonModel alloc] init];
    [self sendWithType:type namespace:HermesNAMESPACE meta:meta isBiz:NO commonModel:commonModel];

    // 4. 若是 payload 不存在则退出当前执行
    if (!HCT_IS_CLASS(payload, NSData) && !payload) {
        return;
    }

    // 5. 添加限制(超过大小的数据就不能入库,由于这是服务端消耗 payload 的一个上限)
    CGFloat payloadSize = [self calculateDataSize:payload];
    if (payloadSize > self.configureModel.maxBodyMByte) {
        NSString *assertString = [NSString stringWithFormat:@"payload 数据的大小超过临界值 %zdKB", self.configureModel.maxBodyMByte];
        NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString);
        return;
    }

    // 6. 合并 meta 与 Common 基础数据,用来存储 payload 上报所须要的 meta 信息
    NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary];
    NSDictionary *commonDictionary = [commonModel getDictionary];
    // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖
    if ([type isEqualToString:@"appCrash"]) {
        [metaDictionary addEntriesFromDictionary:commonDictionary];
        [metaDictionary addEntriesFromDictionary:meta];
    } else {
        [metaDictionary addEntriesFromDictionary:meta];
        [metaDictionary addEntriesFromDictionary:commonDictionary];
    }

    NSError *error;
    NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error];
    if (error) {
        HCTLOG(@"%@", error);
        return;
    }
    NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding];

    // 7. 计算上报时 payload 这条数据的大小(meta+payload)
    NSMutableData *totalData = [NSMutableData data];
    [totalData appendData:metaData];
    [totalData appendData:payload];

    // 8. 再写入 payload 表
    HCTLogPayloadModel *payloadModel = [[HCTLogPayloadModel alloc] init];
    payloadModel.is_used = NO;
    payloadModel.namespace = HermesNAMESPACE;
    payloadModel.report_id = HCT_SAFE_STRING(commonModel.REPORT_ID);
    payloadModel.monitor_type = HCT_SAFE_STRING(type);
    payloadModel.is_biz = NO;
    payloadModel.created_time = HCT_SAFE_STRING(commonModel.CREATE_TIME);
    payloadModel.meta = HCT_SAFE_STRING(metaContentString);
    payloadModel.payload = payload;
    payloadModel.size = totalData.length;
    [HCT_DATABASE add:@[payloadModel] inTableType:HCTLogTableTypePayload];

    // 9. 判断是否触发实时上报
    [self handleUploadDataWithtype:type];
}

业务线数据存储流程基本和监控数据的存储差很少,有差异的是某些字段的标示,用来区分业务线数据。

4、数据上报机制

1. 数据上报流程和机制设计

数据上报机制须要结合数据特色进行设计,数据分为 APM 监控数据和业务线上传数据。先分析下2部分数据的特色。

  • 业务线数据可能会要求实时上报,须要有根据上报配置数据控制的能力
  • 整个数据聚合上报过程须要有根据上报配置数据控制的能力定时器周期的能力,隔一段时间去触发上报
  • 整个数据(业务数据、APM 监控数据)的上报与否须要有经过配置数据控制的能力
  • 由于 App 在某个版本下收集的数据可能会对下个版本的时候无效,因此上报 SDK 启动后须要有删除以前版本数据的能力(上报配置数据中删除开关打开的状况下)
  • 一样,须要删除过时数据的能力(删除距今多少个天然天前的数据,一样走下发而来的上报配置项)
  • 由于 APM 监控数据很是大,且数据上报 SDK 确定数据比较大,因此一个网络通讯方式的设计好坏会影响 SDK 的质量,为了网络性能不采用传统的 key/value 传输。采用自定义报文结构
  • 数据的上报流程触发方式有3种:App 启动后触发(APM 监控到 crash 的时候写入本地,启动后处理上次 crash 的数据,是一个特殊 case );定时器触发;数据调用数据上报 SDK 接口后命中实时上报逻辑
  • 数据落库后会触发一次完整的上报流程
  • 上报流程的第一步会先判断该数据的 type 可否名字上报配置的 type,命中后若是实时上报配置项为 true,则立刻执行后续真正的数据聚合过程;不然中断(只落库,不触发上报)
  • 因为频率会比较高,因此须要作节流的逻辑

    不少人会搞不清楚防抖和节流的区别。一言以蔽之:“函数防抖关注必定时间连续触发的事件只在最后执行一次,而函数节流侧重于一段时间内只执行一次”。此处不是本文重点,感兴趣的的能够查看这篇文章

  • 上报流程会首先判断(为了节约用户流量)

    • 判断当前网络环境为 WI-FI 则实时上报
    • 判断当前网络环境不可用,则实时中断后续
    • 判断当前网络环境为蜂窝网络, 则作是否超过1个天然天内使用流量是否超标的判断

      • T(当前时间戳) - T(上次保存时间戳) > 24h,则清零已使用的流量,记录当前时间戳到上次上报时间的变量中
      • T(当前时间戳) - T(上次保存时间戳) <= 24h,则判断一个天然天内已使用流量大小是否超过下发的数据上报配置中的流量上限字段,超过则 exit;不然执行后续流程
  • 数据聚合分表进行,且会有必定的规则

    • 优先获取 crash 数据
    • 单次网络上报中,总体数据条数不能数据上报配置中的条数限制;数据大小不能超过数据配置中的数据大小
  • 数据取出后将这批数据标记为 dirty 状态
  • meta 表数据须要先 gZip 压缩,再使用 AES 128 加密
  • payload 表数据需组装自定义格式的报文。格式以下

    Header 部分:

    2字节大小、数据类型 unsigned short 表示 meta 数据大小 + n 条 payload 数据结构(2字节大小、数据类型为 unsigned int 表示单条 payload 数据大小)
    header + meta 数据 + payload 数据
  • 发起数据上报网络请求

    • 成功回调:删除标记为dirty 的数据。判断为流量环境,则将该批数据大小叠加到1个天然天内已使用流量大小的变量中。
    • 失败回调:更新标记为dirty 的数据为正常状态。判断为流量环境,则将该批数据大小叠加到1个天然天内已使用流量大小的变量中。

整个上报流程图以下:

数据上报流程

2. 踩过的坑 && 作得好的地方

  • 以前作针对网络接口基本上都是使用现有协议的 key/value 协议上开发的,它的优势是使用简单,缺点是协议体太大。在设计方案的时候分析道数据上报 SDK 网络上报确定是很是高频的因此咱们须要设计自定义的报文协议,这部分的设计上能够参考 TCP 报文头结构
  • 当时和后端对接接口的时候发现数据上报过去,服务端解析不了。断点调试发现数据聚合后的大小、条数、压缩、加密都是正常的,在本地 Mock 后彻底能够反向解析出来。但为何到服务端就解析不了,联调后发现是字节端序(Big-Endian)的问题。简单介绍以下,关于大小端序的详细介绍请查看个人这篇文章

    主机字节顺序HBO(Host Byte Order):与 CPU 类型有关。Big-Endian: PowerPC、IBM、Sun。Little-Endian:x8六、DEC

    网络字节顺序 NBO(Network Byte Order):网络默认为大端序。

  • 上面的逻辑有一步是当网络上报成功后须要删除标记为 dirty 的数据。可是测试了一下发现,大量数据删除后数据库文件的大小不变,理论上须要腾出内存数据大小的空间。

    sqlite 采用的是变长记录存储,当数据被删除后,未使用的磁盘空间被添加到一个内在的“空闲列表”中,用于下次插入数据,这属于优化机制之一,sqlite 提供 vacuum 命令来释放。

    这个问题相似于 Linux 中的文件引用计数的意思,虽然不同,可是提出来作一下参考。实验是这样的

    1. 先看一下当前各个挂载目录的空间大小:df -h
    2. 首先咱们产生一个50M大小的文件
    3. 写一段代码读取文件

      #include<stdio.h>
      #include<unistd.h>
      int main(void)
      {    FILE *fp = NULL;   
        fp = fopen("/boot/test.txt", "rw+");   
        if(NULL == fp){      
            perror("open file failed");   
            return -1;   
        }    
        while(1){      
            //do nothing       sleep(1);   
        }   
        fclose(fp);  
        return 0;
      }
    4. 命令行模式下使用 rm 删除文件
    5. 查看文件大小: df -h,发现文件被删除了,可是该目录下的可用空间并未变多

    解释:实际上,只有当一个文件的引用计数为0(包括硬连接数)的时候,才可能调用 unlink 删除,只要它不是0,那么就不会被删除。所谓的删除,也不过是文件名到 inode 的连接删除,只要不被从新写入新的数据,磁盘上的 block 数据块不会被删除,所以,你会看到,即使删库跑路了,某些数据仍是能够恢复的。换句话说,当一个程序打开一个文件的时候(获取到文件描述符),它的引用计数会被+1,rm虽然看似删除了文件,实际上只是会将引用计数减1,但因为引用计数不为0,所以文件不会被删除。

  • 在数据聚合的时候优先获取 crash 数据,总数据条数须要小于上报配置数据的条数限制、总数据大小须要小于上报配置数据的大小限制。这里的处理使用了递归,改变了函数参数

    - (void)assembleDataInTable:(HCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
        // 1. 获取到合适的 Crash 类型的数据
        [self fetchCrashDataByCount:self.configureModel.maxFlowMByte
                            inTable:tableType
                         upperBound:self.configureModel.maxBodyMByte
                         completion:^(NSArray<HCTLogModel *> *records) {
                             NSArray<HCTLogModel *> *crashData = records;
                             // 2. 计算剩余须要的数据条数和剩余须要的数据大小
                             NSInteger remainingCount = self.configureModel.maxItem - crashData.count;
                             float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData];
                             // 3. 获取除 Crash 类型以外的其余数据,且须要符合相应规则
                             BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi);
                             [self fetchDataExceptCrash:remainingCount
                                                inTable:tableType
                                             upperBound:remainingSize
                                                 isWiFI:isWifi
                                             completion:^(NSArray<HCTLogModel *> *records) {
                                                 NSArray<HCTLogModel *> *dataExceptCrash = records;
    
                                                 NSMutableArray *dataSource = [NSMutableArray array];
                                                 [dataSource addObjectsFromArray:crashData];
                                                 [dataSource addObjectsFromArray:dataExceptCrash];
                                                 if (completion) {
                                                     completion([dataSource copy]);
                                                 }
                                             }];
                         }];
    }
    
    - (void)fetchDataExceptCrash:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
        // 1. 根据剩余须要数据条数去查询表中非 Crash 类型的数据集合
        __block NSMutableArray *conditions = [NSMutableArray array];
        [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            if (isWifi) {
                if (![obj.type isEqualToString:@"appCrash"]) {
                    [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]];
                }
            } else {
                if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) {
                    [conditions addObject:[NSString stringWithFormat:@"'%@'", HCT_SAFE_STRING(obj.type)]];
                }
            }
        }];
        NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace];
    
        // 2. 根据是否有 Wifi 查找对应的数据
        [HCT_DATABASE getRecordsByCount:count
                               condtion:queryCrashDataCondition
                            inTableType:tableType
                             completion:^(NSArray<HCTLogModel *> *_Nonnull records) {
                                 // 3. 非 Crash 类型的数据集合大小是否超过剩余须要的数据大小
                                 float dataSize = [self calculateDataSize:records];
    
                                 // 4. 大于最大包体积则递归获取 maxItem-1 条非 Crash 数据集合并判断数据大小
                                 if (size == 0) {
                                     if (completion) {
                                         completion(records);
                                     }
                                 } else if (dataSize > size) {
                                     NSInteger currentCount = count - 1;
                                     return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion];
                                 } else {
                                     if (completion) {
                                         completion(records);
                                     }
                                 }
                             }];
    }
  • 整个 SDK 的 Unit Test 经过率 100%,代码分支覆盖率为 93%。测试基于 TDD 和 BDD。测试框架:系统自带的 XCTest,第三方的 OCMockKiwiExpectaSpecta。测试使用了基础类,后续每一个文件都设计继承自测试基类的类。

    Xcode 能够看到整个 SDK 的测试覆盖率和单个文件的测试覆盖率

    Xcode 测试覆盖率

    也可使用 slather。在项目终端环境下新建 .slather.yml 配置文件,而后执行语句 slather coverage -s --scheme hermes-client-Example --workspace hermes-client.xcworkspace hermes-client.xcodeproj

    关于质量保证的最基础、可靠的方案之一软件测试,在各个端都有一些须要注意的地方,还须要结合工程化,我会写专门的文章谈谈经验心得。

5、 接口设计及核心实现

1. 接口设计

@interface HermesClient : NSObject

- (instancetype)init NS_UNAVAILABLE;

+ (instancetype)new NS_UNAVAILABLE;

/**
 单例方式初始化全局惟一对象。单例以后必须立刻 setUp

 @return 单例对象
 */
+ (instancetype)sharedInstance;

/**
    当前 SDK 初始化。当前功能:注册配置下发服务。
 */
- (void)setup;

/**
 上报 payload 类型的数据

 @param type 监控类型
 @param meta 元数据
 @param payload payload类型的数据
 */
- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload;

/**
 上报 meta 类型的数据,须要传递三个参数。type 代表是什么类型的数据;prefix 表明前缀,上报到后台会拼接 prefix+type;meta 是字典类型的元数据

 @param type 数据类型
 @param prefix 数据类型的前缀。通常是业务线名称首字母简写。好比记帐:JZ
 @param meta description元数据
 */
- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta;

/**
 获取上报相关的通用信息

 @return 上报基础信息
 */
- (HCTCommonModel *)getCommon;

/**
 是否须要采集上报

 @return 上报开关
 */
- (BOOL)isGather:(NSString *)namespace;

@end

HermesClient 类是整个 SDK 的入口,也是接口的提供者。其中 - (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta; 接口给业务方使用。

- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload; 给监控数据使用。

setup 方法内部开启多个 namespace 下的处理 handler。

- (void)setup {
    // 注册 mget 获取监控和各业务线的配置信息,会产生多个 namespace,彼此平行、隔离
    [[HCTConfigurationService sharedInstance] registerAndFetchConfigurationInfo];
   
    [self.configutations enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        HCTService *service = [[HCTService alloc] initWithNamespace:obj];
        [self.services setObject:service forKey:obj];
    }];
    HCTService *hermesService = [self.services objectForKey:HermesNAMESPACE];
    if (!hermesService) {
        hermesService = [[HCTService alloc] initWithNamespace:HermesNAMESPACE];
        [self.services setObject:hermesService forKey:HermesNAMESPACE];
    }
}

2. 核心实现

真正处理逻辑的是 HCTService 类。

#define HCT_SAVED_FLOW @"HCT_SAVED_FLOW"
#define HCT_SAVED_TIMESTAMP @"HCT_SAVED_TIMESTAMP"

@interface HCTService ()

@property (nonatomic, copy) NSString *requestBaseUrl;           /**<须要配置的baseUrl*/
@property (nonatomic, copy) HCTConfigurationModel *configureModel;  /**<当前 namespace 下的配置信息*/
@property (nonatomic, copy) NSString *metaURL;                  /**<meta 接口地址*/
@property (nonatomic, copy) NSString *payloadURL;               /**<payload 接口地址*/
@property (nonatomic, strong) HCTRequestFactory *requester;     /**<网络请求中心*/
@property (nonatomic, strong) NSNumber *currentTimestamp;       /**<保存的时间戳*/
@property (nonatomic, strong) NSNumber *currentFlow;            /**<当前已使用的流量*/
@property (nonatomic, strong) TMLoopTaskExecutor *taskExecutor; /**<上报数据定时任务*/
@property (nonatomic, assign) BOOL isAppLaunched;               /**<经过 KVC 的形式获取到 HermesClient 里面存储 App 是否启动完成的标识,这种 case 是处理: mget 首次获取到 3个 namespace, 但 App 运行期间服务端新增某种 namespace, 此时业务线若是插入数据依旧能够正常落库、上报*/
@end

@implementation HCTService

@synthesize currentTimestamp = _currentTimestamp;
@synthesize currentFlow = _currentFlow;

#pragma mark - life cycle

- (instancetype)initWithNamespace:(NSString  * _Nonnull )namespace {
    if (self = [super init]) {
        _namespace = namespace;
        [self setupConfig];
        if (self.isAppLaunched) {
            [self executeHandlerWhenAppLaunched];
        } else {
            [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification
                                                              object:nil
                                                               queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
                [self executeHandlerWhenAppLaunched];
                [[HermesClient sharedInstance] setValue:@(YES) forKey:@"isAppLaunched"];
            }];
        }
    }
    return self;
}


#pragma mark - public Method

- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload {
    // 1. 检查参数合法性
    NSString *warning = [NSString stringWithFormat:@"%@不能是空字符串", type];
    if (!HCT_IS_CLASS(type, NSString)) {
        NSAssert1(HCT_IS_CLASS(type, NSString), warning, type);
        return;
    }
    if (type.length == 0) {
        NSAssert1(type.length > 0, warning, type);
        return;
    }

    if (!HCT_IS_CLASS(meta, NSDictionary)) {
        return;
    }
    if (meta.allKeys.count == 0) {
        return;
    }
    
    // 2. 判断当前 namespace 是否开启了收集
    if (!self.configureModel.isGather) {
        HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]);
        return ;
    }
    
    // 3. 判断是不是有效的数据。能够落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等)
    BOOL isValidate = [self validateLogData:type];
    if (!isValidate) {
        return;
    }

    // 3. 先写入 meta 表
    HCTCommonModel *commonModel = [[HCTCommonModel alloc] init];
    [self sendWithType:type namespace:HermesNAMESPACE meta:meta isBiz:NO commonModel:commonModel];

    // 4. 若是 payload 不存在则退出当前执行
    if (!HCT_IS_CLASS(payload, NSData) && !payload) {
        return;
    }

    // 5. 添加限制(超过大小的数据就不能入库,由于这是服务端消耗 payload 的一个上限)
    CGFloat payloadSize = [self calculateDataSize:payload];
    if (payloadSize > self.configureModel.maxBodyMByte) {
        NSString *assertString = [NSString stringWithFormat:@"payload 数据的大小超过临界值 %zdKB", self.configureModel.maxBodyMByte];
        NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString);
        return;
    }

    // 6. 合并 meta 与 Common 基础数据,用来存储 payload 上报所须要的 meta 信息
    NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary];
    NSDictionary *commonDictionary = [commonModel getDictionary];
    // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖
    if ([type isEqualToString:@"appCrash"]) {
        [metaDictionary addEntriesFromDictionary:commonDictionary];
        [metaDictionary addEntriesFromDictionary:meta];
    } else {
        [metaDictionary addEntriesFromDictionary:meta];
        [metaDictionary addEntriesFromDictionary:commonDictionary];
    }

    NSError *error;
    NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error];
    if (error) {
        HCTLOG(@"%@", error);
        return;
    }
    NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding];

    // 7. 计算上报时 payload 这条数据的大小(meta+payload)
    NSMutableData *totalData = [NSMutableData data];
    [totalData appendData:metaData];
    [totalData appendData:payload];

    // 8. 再写入 payload 表
    HCTLogPayloadModel *payloadModel = [[HCTLogPayloadModel alloc] init];
    payloadModel.is_used = NO;
    payloadModel.namespace = HermesNAMESPACE;
    payloadModel.report_id = HCT_SAFE_STRING(commonModel.REPORT_ID);
    payloadModel.monitor_type = HCT_SAFE_STRING(type);
    payloadModel.is_biz = NO;
    payloadModel.created_time = HCT_SAFE_STRING(commonModel.CREATE_TIME);
    payloadModel.meta = HCT_SAFE_STRING(metaContentString);
    payloadModel.payload = payload;
    payloadModel.size = totalData.length;
    [HCT_DATABASE add:@[payloadModel] inTableType:HCTLogTableTypePayload];

    // 9. 判断是否触发实时上报
    [self handleUploadDataWithtype:type];
}

- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta {
    // 1. 校验参数合法性
    NSString *prefixWarning = [NSString stringWithFormat:@"%@不能是空字符串", prefix];
    if (!HCT_IS_CLASS(prefix, NSString)) {
        NSAssert1(HCT_IS_CLASS(prefix, NSString), prefixWarning, prefix);
        return;
    }
    if (prefix.length == 0) {
        NSAssert1(prefix.length > 0, prefixWarning, prefix);
        return;
    }

    NSString *typeWarning = [NSString stringWithFormat:@"%@不能是空字符串", type];
    if (!HCT_IS_CLASS(type, NSString)) {
        NSAssert1(HCT_IS_CLASS(type, NSString), typeWarning, type);
        return;
    }
    if (type.length == 0) {
        NSAssert1(type.length > 0, typeWarning, type);
        return;
    }

    if (!HCT_IS_CLASS(meta, NSDictionary)) {
        return;
    }
    if (meta.allKeys.count == 0) {
        return;
    }

    // 2. 私有接口处理 is_biz 逻辑
    HCTCommonModel *commonModel = [[HCTCommonModel alloc] init];
    [self sendWithType:type namespace:prefix meta:meta isBiz:YES commonModel:commonModel];
}


#pragma mark - private method

// 基础配置
- (void)setupConfig {
    _requestBaseUrl = @"https://***DomainName.com";
    _metaURL = @"hermes/***";
    _payloadURL = @"hermes/***";
}

- (void)executeHandlerWhenAppLaunched
{
    // 1. 删除非法数据
    [self handleInvalidateData];
    // 2. 回收数据库磁盘碎片空间
    [self rebuildDatabase];
    // 3. 开启定时器去定时上报数据
    [self executeTimedTask];
}

/*
 1. 当 App 版本变化的时候删除数据
 2. 删除过时数据
 3. 删除 Payload 表里面超过限制的数据
 4. 删除上传接口网络成功,可是突发 crash 形成没有删除这批数据的状况,因此启动完成后删除 is_used = YES 的数据
 */
- (void)handleInvalidateData
{
    NSString *currentVersion = [[HermesClient sharedInstance] getCommon].APP_VERSION;
    NSString *savedVersion = [[NSUserDefaults standardUserDefaults] stringForKey:HCT_SAVED_APP_VERSION] ?: [currentVersion copy];
    
    NSInteger threshold = [NSDate HCT_currentTimestamp];
    if (![currentVersion isEqualToString:savedVersion] && self.configureModel.isUpdateClear) {
        [[NSUserDefaults standardUserDefaults] setObject:currentVersion forKey:HCT_SAVED_APP_VERSION];
    } else {
        threshold = [NSDate HCT_currentTimestamp] - self.configureModel.expirationDay * 24 * 60 * 60 *1000;
    }
    NSInteger sizeUpperLimit = self.configureModel.maxBodyMByte * 1024 * 1024;
    NSString *sqlString = [NSString stringWithFormat:@"(created_time < %zd and namespace = '%@') or size > %zd or is_used = 1", threshold, self.namespace, sizeUpperLimit];
    [HCT_DATABASE removeDataUseCondition:sqlString inTableType:HCTLogTableTypeMeta];
    [HCT_DATABASE removeDataUseCondition:sqlString inTableType:HCTLogTableTypePayload];
}

// 启动时刻清理数据表空间碎片,回收磁盘大小
- (void)rebuildDatabase {
    [HCT_DATABASE rebuildDatabaseFileInTableType:HCTLogTableTypeMeta];
    [HCT_DATABASE rebuildDatabaseFileInTableType:HCTLogTableTypePayload];
}

// 判断数据是否能够落库
- (BOOL)validateLogData:(NSString *)dataType {
    NSArray<HCTItemModel *> *monitors = self.configureModel.monitorList;
    __block BOOL isValidate = NO;
    [monitors enumerateObjectsUsingBlock:^(HCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        if ([obj.type isEqualToString:dataType]) {
            isValidate = YES;
            *stop = YES;
        }
    }];
    return isValidate;
}

- (void)executeTimedTask {
    __weak typeof(self) weakself = self;
    self.taskExecutor = [[TMLoopTaskExecutor alloc] init];
    TMTaskOption *dataUploadOption = [[TMTaskOption alloc] init];
    dataUploadOption.option = TMTaskRunOptionRuntime;
    dataUploadOption.interval = self.configureModel.periodicTimerSecond;
    TMTask *dataUploadTask = [[TMTask alloc] init];
    dataUploadTask.runBlock = ^{
        [weakself upload];
    };
    [self.taskExecutor addTask:dataUploadTask option:dataUploadOption];
}

- (void)handleUploadDataWithtype:(NSString *)type {
    __block BOOL canUploadInTime = NO;
    [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        if ([type isEqualToString:obj.type]) {
            if (obj.isRealtime) {
                canUploadInTime = YES;
                *stop = YES;
            }
        }
    }];
    if (canUploadInTime) {
        // 节流
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [self upload];
        });
    }
}

// 对内和对外的存储都走这个流程。经过这个接口设置 is_biz 信息
- (void)sendWithType:(NSString *)type namespace:(NSString *)namespace meta:(NSDictionary *)meta isBiz:(BOOL)is_biz commonModel:(HCTCommonModel *)commonModel {
    // 0. 判断当前 namespace 是否开启了收集
    if (!self.configureModel.isGather) {
        HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下数据收集开关为关闭状态", self.namespace]);
        return ;
    }
    
    // 1. 检查参数合法性
    NSString *warning = [NSString stringWithFormat:@"%@不能是空字符串", type];
    if (!HCT_IS_CLASS(type, NSString)) {
        NSAssert1(HCT_IS_CLASS(type, NSString), warning, type);
        return;
    }
    if (type.length == 0) {
        NSAssert1(type.length > 0, warning, type);
        return;
    }

    if (!HCT_IS_CLASS(meta, NSDictionary)) {
        return;
    }
    if (meta.allKeys.count == 0) {
        return;
    }

    // 2. 判断是不是有效的数据。能够落库(type 和监控参数的接口中 monitorList 中的任一条目的type 相等)
    BOOL isValidate = [self validateLogData:type];
    if (!isValidate) {
        return;
    }

    // 3. 合并 meta 与 Common 基础数据
    NSMutableDictionary *mutableMeta = [NSMutableDictionary dictionaryWithDictionary:meta];
    mutableMeta[@"MONITOR_TYPE"] = is_biz ? [NSString stringWithFormat:@"%@-%@", namespace, type] : type;
    meta = [mutableMeta copy];
    
    commonModel.IS_BIZ = is_biz;
    NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary];
    NSDictionary *commonDictionary = [commonModel getDictionary];

    // Crash 类型为特例,外部传入的 Crash 案发现场信息不能被覆盖
    if ([type isEqualToString:@"appCrash"]) {
        [metaDictionary addEntriesFromDictionary:commonDictionary];
        [metaDictionary addEntriesFromDictionary:meta];
    } else {
        [metaDictionary addEntriesFromDictionary:meta];
        [metaDictionary addEntriesFromDictionary:commonDictionary];
    }

    // 4. 转换为 NSData
    NSError *error;
    NSData *metaData = [NSJSONSerialization dataWithJSONObject:metaDictionary options:0 error:&error];
    if (error) {
        HCTLOG(@"%@", error);
        return;
    }

    // 5. 添加限制(超过 10K 的数据就不能入库,由于这是服务端消耗 meta 的一个上限)
    CGFloat metaSize = [self calculateDataSize:metaData];
    if (metaSize > 10 / 1024.0) {
        NSAssert(metaSize <= 10 / 1024.0, @"meta 数据的大小超过临界值 10KB");
        return;
    }

    NSString *metaContentString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding];

    // 6. 构造 MetaModel 模型
    HCTLogMetaModel *metaModel = [[HCTLogMetaModel alloc] init];
    metaModel.namespace = namespace;
    metaModel.report_id = HCT_SAFE_STRING(commonModel.REPORT_ID);
    metaModel.monitor_type = HCT_SAFE_STRING(type);
    metaModel.created_time = HCT_SAFE_STRING(commonModel.CREATE_TIME);
    metaModel.meta = HCT_SAFE_STRING(metaContentString);
    metaModel.size = metaData.length;
    metaModel.is_biz = is_biz;

    // 7. 写入数据库
    [HCT_DATABASE add:@[metaModel] inTableType:HCTLogTableTypeMeta];

    // 8. 判断是否触发实时上报(对内的接口则在函数内部判断,若是是对外的则在这里判断)
    if (is_biz) {
        [self handleUploadDataWithtype:type];
    }
}

- (BOOL)needUploadPayload:(HCTLogPayloadModel *)model {
    __block BOOL needed = NO;
    [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        if ([obj.type isEqualToString:model.monitor_type] && obj.isUploadPayload) {
            needed = YES;
            *stop = YES;
        }
    }];
    return needed;
}

/*
 计算 数据包大小,分为2种状况。
 1. 上传前使用数据表中的 size 字段去判断大小
 2. 上报完成后则根据真实网络通讯中组装的 payload 进行大小计算
 */
- (float)calculateDataSize:(id)data {
    if (HCT_IS_CLASS(data, NSArray)) {
        __block NSInteger dataLength = 0;
        NSArray *uploadDatasource = (NSArray *)data;
        [uploadDatasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
            if (HCT_IS_CLASS(obj, HCTLogModel)) {
                HCTLogModel *uploadModel = (HCTLogModel *)obj;
                dataLength += uploadModel.size;
            }
        }];
        return dataLength / (1024 * 1024.0);
    } else if (HCT_IS_CLASS(data, NSData)) {
        NSData *rawData = (NSData *)data;
        return rawData.length / (1024 * 1024.0);
    } else {
        return 0;
    }
}

// 上报流程的主函数
- (void)upload {
    /*
     1. 判断可否上报
     2. 数据聚合
     3. 加密压缩
     4. 1分钟内的网络请求合并为1次
     5. 上报(全局上报开关是开着的状况)
     - 成功:删除本地数据、调用更新策略的接口
     - 失败:不删除本地数据
     */
    [self judgeCanUploadCompletionBlock:^(BOOL canUpload, NetworkingManagerStatusType networkType) {
        if (canUpload && self.configureModel.isUpload) {
            [self handleUploadTask:networkType];
        }
    }];
}

/**
 上报前的校验
 - 判断网络状况,分为 wifi 和 非 Wi-Fi 、网络不通的状况。
 - 从配置下发的 monitorList 找出 onlyWifi 字段为 true 的 type,组成数组 [appCrash、appLag...]
 - 网络不通,则不能上报
 - 网络通,则判断上报校验
 1. 当前GMT时间戳-保存的时间戳超过24h。则认为是一个新的天然天
 - 清除 currentFlow
 - 触发上报流程
 2. 当前GMT时间戳-保存的时间戳不超过24h
 - 当前的流量是否超过配置信息里面的最大流量,未超过(<):触发上报流程
 - 当前的流量是否超过配置信息里面的最大流量,超过:结束流程
 */
- (void)judgeCanUploadCompletionBlock:(void (^)(BOOL canUpload, NetworkingManagerStatusType networkType))completionBlock {
    // WIFI 的状况下不判断直接上传;不是 WIFI 的状况须要判断「当日最大限制流量」
    [self.requester networkStatusWithBlock:^(NetworkingManagerStatusType status) {
        switch (status) {
            case NetworkingManagerStatusUnknown: {
                HCTLOG(@"没有网络权限哦");
                if (completionBlock) {
                    completionBlock(NO, NetworkingManagerStatusUnknown);
                }
                break;
            }
            case NetworkingManagerStatusNotReachable: {
                if (completionBlock) {
                    completionBlock(NO, NetworkingManagerStatusNotReachable);
                }
                break;
            }
            case NetworkingManagerStatusReachableViaWiFi: {
                if (completionBlock) {
                    completionBlock(YES, NetworkingManagerStatusReachableViaWiFi);
                }
                break;
            }
            case NetworkingManagerStatusReachableViaWWAN: {
                if ([self currentGMTStyleTimeStamp] - self.currentTimestamp.integerValue > 24 * 60 * 60 * 1000) {
                    self.currentFlow = [NSNumber numberWithFloat:0];
                    self.currentTimestamp = [NSNumber numberWithInteger:[self currentGMTStyleTimeStamp]];
                    if (completionBlock) {
                        completionBlock(YES, NetworkingManagerStatusReachableViaWWAN);
                    }
                } else {
                    if (self.currentFlow.floatValue < self.configureModel.maxFlowMByte) {
                        if (completionBlock) {
                            completionBlock(YES, NetworkingManagerStatusReachableViaWWAN);
                        }
                    } else {
                        if (completionBlock) {
                            completionBlock(NO, NetworkingManagerStatusReachableViaWWAN);
                        }
                    }
                }
                break;
            }
        }
    }];
}

- (void)handleUploadTask:(NetworkingManagerStatusType)networkType {
    // 数据聚合(2张表分别扫描) -> 压缩 -> 上报
    [self handleUploadTaskInMetaTable:networkType];
    [self handleUploadTaskInPayloadTable:networkType];
}

- (void)handleUploadTaskInMetaTable:(NetworkingManagerStatusType)networkType {
    __weak typeof(self) weakself = self;
    // 1. 数据聚合
    [self assembleDataInTable:HCTLogTableTypeMeta
                  networkType:networkType
                   completion:^(NSArray<HCTLogModel *> *records) {
                       if (records.count == 0) {
                           return;
                       }
                       // 2. 加密压缩处理:(meta 总体先加密再压缩,payload一条条先加密再压缩)
                       __block NSMutableString *metaStrings = [NSMutableString string];
                       __block NSMutableArray *usedReportIds = [NSMutableArray array];
               
                       // 2.1. 遍历拼接model,取出 meta,用 \n 拼接
                       [records enumerateObjectsUsingBlock:^(HCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
                           if (HCT_IS_CLASS(obj, HCTLogMetaModel)) {
                               HCTLogMetaModel *metaModel = (HCTLogMetaModel *)obj;
                               BOOL shouldAppendLineBreakSymbol = idx < (records.count - 1);
                               [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", metaModel.report_id]];
                               [metaStrings appendString:[NSString stringWithFormat:@"%@%@", metaModel.meta, shouldAppendLineBreakSymbol ? @"\n" : @""]];
                           }
                       }];
                       if (metaStrings.length == 0) {
                           return;
                       }
                       // 2.2 拼接后的内容先压缩再加密
                       NSData *data = [HCTDataSerializer compressAndEncryptWithString:metaStrings];
        
                      // 3. 将取出来用于接口请求的数据标记为 dirty
                      NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]];
                     [[HCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:HCTLogTableTypeMeta];

                       // 4. 请求网络
                       NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.metaURL];

                       [weakself.requester postDataWithRequestURL:requestURL
                           bodyData:data
                           success:^{
                               [weakself deleteInvalidateData:records inTableType:HCTLogTableTypeMeta];
                               if (networkType == NetworkingManagerStatusReachableViaWWAN) {
                                   float currentFlow = [weakself.currentFlow floatValue];
                                   currentFlow += [weakself calculateDataSize:data];
                                   weakself.currentFlow = [NSNumber numberWithFloat:currentFlow];
                               }
                           }
                           failure:^(NSError *_Nonnull error) {
                               [[HCTDatabase sharedInstance] updateData:@"is_used = 0" useCondition:updateCondtion inTableType:HCTLogTableTypeMeta];
                               if (networkType == NetworkingManagerStatusReachableViaWWAN) {
                                   float currentFlow = [weakself.currentFlow floatValue];
                                   currentFlow += [weakself calculateDataSize:data];
                                   weakself.currentFlow = [NSNumber numberWithFloat:currentFlow];
                               }
                           }];
                   }];
}

- (NSData *)handlePayloadData:(NSArray *)rawArray {
    // 1. 数据校验
    if (rawArray.count == 0) {
        return nil;
    }
    // 2. 加密压缩处理:(meta 总体先加密再压缩,payload一条条先加密再压缩)
    __block NSMutableString *metaStrings = [NSMutableString string];
    __block NSMutableArray<NSData *> *payloads = [NSMutableArray array];

    
    // 2.1. 遍历拼接model,取出 meta,用 \n 拼接
    [rawArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        if (HCT_IS_CLASS(obj, HCTLogPayloadModel)) {
            HCTLogPayloadModel *payloadModel = (HCTLogPayloadModel *)obj;
            BOOL shouldAppendLineBreakSymbol = idx < (rawArray.count - 1);

            [metaStrings appendString:[NSString stringWithFormat:@"%@%@", HCT_SAFE_STRING(payloadModel.meta), shouldAppendLineBreakSymbol ? @"\n" : @""]];

            // 2.2 判断是否须要上传 payload 信息。若是须要则将 payload 取出。
            if ([self needUploadPayload:payloadModel]) {
                if (payloadModel.payload) {
                    NSData *payloadData = [HCTDataSerializer compressAndEncryptWithData:payloadModel.payload];
                    if (payloadData) {
                        [payloads addObject:payloadData];
                    }
                }
            }
        }
    }];

    NSData *metaData = [HCTDataSerializer compressAndEncryptWithString:metaStrings];

    __block NSMutableData *headerData = [NSMutableData data];
    unsigned short metaLength = (unsigned short)metaData.length;
    HTONS(metaLength);  // 处理2字节的大端序
    [headerData appendData:[NSData dataWithBytes:&metaLength length:sizeof(metaLength)]];

    Byte payloadCountbytes[] = {payloads.count};
    NSData *payloadCountData = [[NSData alloc] initWithBytes:payloadCountbytes length:sizeof(payloadCountbytes)];
    [headerData appendData:payloadCountData];

    [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        unsigned int payloadLength = (unsigned int)obj.length;
        HTONL(payloadLength);  // 处理4字节的大端序
        [headerData appendData:[NSData dataWithBytes:&payloadLength length:sizeof(payloadLength)]];
    }];

    __block NSMutableData *uploadData = [NSMutableData data];
    // 先添加 header 基础信息,不须要加密压缩
    [uploadData appendData:[headerData copy]];
    // 再添加 meta 信息,meta 信息须要先压缩再加密
    [uploadData appendData:metaData];
    // 再添加 payload 信息
    [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        [uploadData appendData:obj];
    }];
    return [uploadData copy];
}

- (void)handleUploadTaskInPayloadTable:(NetworkingManagerStatusType)networkType {
    __weak typeof(self) weakself = self;
    // 1. 数据聚合
    [self assembleDataInTable:HCTLogTableTypePayload
                  networkType:networkType
                   completion:^(NSArray<HCTLogModel *> *records) {
                       if (records.count == 0) {
                           return;
                       }
                       // 2. 取出能够上传的 payload 数据
                       NSArray *canUploadPayloadData = [self fetchDataCanUploadPayload:records];
                       
                       if (canUploadPayloadData.count == 0) {
                           return;
                       }
        
                    // 3. 将取出来用于接口请求的数据标记为 dirty
                    __block NSMutableArray *usedReportIds = [NSMutableArray array];
                    [canUploadPayloadData enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                        if (HCT_IS_CLASS(obj, HCTLogModel)) {
                            HCTLogModel *model = (HCTLogModel *)obj;
                            [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", model.report_id]];
                        }
                    }];
                    NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]];
        
                    [[HCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:HCTLogTableTypePayload];
        
                        // 4. 将取出的数据聚合,组成报文
                       NSData *uploadData = [self handlePayloadData:canUploadPayloadData];

                       // 5. 请求网络
                       NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.payloadURL];

                       [weakself.requester postDataWithRequestURL:requestURL
                           bodyData:uploadData
                           success:^{
                               [weakself deleteInvalidateData:canUploadPayloadData inTableType:HCTLogTableTypePayload];
                               if (networkType == NetworkingManagerStatusReachableViaWWAN) {
                                   float currentFlow = [weakself.currentFlow floatValue];
                                   currentFlow += [weakself calculateDataSize:uploadData];
                                   weakself.currentFlow = [NSNumber numberWithFloat:currentFlow];
                               }
                           }
                           failure:^(NSError *_Nonnull error) {
                               [[HCTDatabase sharedInstance] updateData:@"is_used = 0" useCondition:updateCondtion inTableType:HCTLogTableTypePayload];
                               if (networkType == NetworkingManagerStatusReachableViaWWAN) {
                                   float currentFlow = [weakself.currentFlow floatValue];
                                   currentFlow += [weakself calculateDataSize:uploadData];
                                   weakself.currentFlow = [NSNumber numberWithFloat:currentFlow];
                               }
                           }];
                   }];
}

// 清除过时数据
- (void)deleteInvalidateData:(NSArray<HCTLogModel *> *)data inTableType:(HCTLogTableType)tableType {
    [HCT_DATABASE remove:data inTableType:tableType];
}

// 以秒为单位的时间戳
- (NSInteger)currentGMTStyleTimeStamp {
    return [NSDate HCT_currentTimestamp]/1000;
}

#pragma mark-- 数据库操做

/**
 根据接口配置信息中的条件获取表中的上报数据
 - Wi-Fi 的时候都上报
 - 不为 Wi-Fi 的时候:onlyWifi 为 false 的类型进行上报
 */
- (void)assembleDataInTable:(HCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
    // 1. 获取到合适的 Crash 类型的数据
    [self fetchCrashDataByCount:self.configureModel.maxFlowMByte
                        inTable:tableType
                     upperBound:self.configureModel.maxBodyMByte
                     completion:^(NSArray<HCTLogModel *> *records) {
                         NSArray<HCTLogModel *> *crashData = records;
                         // 2. 计算剩余须要的数据条数和剩余须要的数据大小
                         NSInteger remainingCount = self.configureModel.maxItem - crashData.count;
                         float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData];
                         // 3. 获取除 Crash 类型以外的其余数据,且须要符合相应规则
                         BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi);
                         [self fetchDataExceptCrash:remainingCount
                                            inTable:tableType
                                         upperBound:remainingSize
                                             isWiFI:isWifi
                                         completion:^(NSArray<HCTLogModel *> *records) {
                                             NSArray<HCTLogModel *> *dataExceptCrash = records;

                                             NSMutableArray *dataSource = [NSMutableArray array];
                                             [dataSource addObjectsFromArray:crashData];
                                             [dataSource addObjectsFromArray:dataExceptCrash];
                                             if (completion) {
                                                 completion([dataSource copy]);
                                             }
                                         }];
                     }];
}


- (NSArray *)fetchDataCanUploadPayload:(NSArray *)datasource {
    __weak typeof(self) weakself = self;
    __block NSMutableArray *array = [NSMutableArray array];
    if (!HCT_IS_CLASS(datasource, NSArray)) {
        NSAssert(HCT_IS_CLASS(datasource, NSArray), @"参数必须是数组");
        return nil;
    }
    [datasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        if (HCT_IS_CLASS(obj, HCTLogPayloadModel)) {
            HCTLogPayloadModel *payloadModel = (HCTLogPayloadModel *)obj;
            // 判断是否须要上传 payload 信息
            if ([weakself needUploadPayload:payloadModel]) {
                [array addObject:payloadModel];
            }
        }
    }];
    return [array copy];
}

// 递归获取符合条件的 Crash 数据集合(count < maxItem && size < maxBodySize)
- (void)fetchCrashDataByCount:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(NSInteger)size completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
    // 1. 先经过接口拿到的 maxItem 数去查询表中的 Crash 数据集合
    NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type = 'appCrash' and is_used = 0 and namespace = '%@'", self.namespace];
    [HCT_DATABASE getRecordsByCount:count
                           condtion:queryCrashDataCondition
                        inTableType:tableType
                         completion:^(NSArray<HCTLogModel *> *_Nonnull records) {
                             // 2. Crash 数据集合大小是否超过配置接口拿到的最大包体积(单位M) maxBodySize
                             float dataSize = [self calculateDataSize:records];

                             // 3. 大于最大包体积则递归获取 maxItem-- 条 Crash 数据集合并判断数据大小
                             if (size == 0) {
                                 if (completion) {
                                     completion(records);
                                 }
                             } else if (dataSize > size) {
                                 NSInteger currentCount = count - 1;
                                 [self fetchCrashDataByCount:currentCount inTable:tableType upperBound:size completion:completion];
                             } else {
                                 if (completion) {
                                     completion(records);
                                 }
                             }
                         }];
}

- (void)fetchDataExceptCrash:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
    // 1. 根据剩余须要数据条数去查询表中非 Crash 类型的数据集合
    __block NSMutableArray *conditions = [NSMutableArray array];
    [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if (isWifi) {
            if (![obj.type isEqualToString:@"appCrash"]) {
                [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]];
            }
        } else {
            if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) {
                [conditions addObject:[NSString stringWithFormat:@"'%@'", HCT_SAFE_STRING(obj.type)]];
            }
        }
    }];
    NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace];

    // 2. 根据是否有 Wifi 查找对应的数据
    [HCT_DATABASE getRecordsByCount:count
                           condtion:queryCrashDataCondition
                        inTableType:tableType
                         completion:^(NSArray<HCTLogModel *> *_Nonnull records) {
                             // 3. 非 Crash 类型的数据集合大小是否超过剩余须要的数据大小
                             float dataSize = [self calculateDataSize:records];

                             // 4. 大于最大包体积则递归获取 maxItem-1 条非 Crash 数据集合并判断数据大小
                             if (size == 0) {
                                 if (completion) {
                                     completion(records);
                                 }
                             } else if (dataSize > size) {
                                 NSInteger currentCount = count - 1;
                                 return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion];
                             } else {
                                 if (completion) {
                                     completion(records);
                                 }
                             }
                         }];
}


#pragma mark - getters and setters

- (HCTRequestFactory *)requester {
    if (!_requester) {
        _requester = [[HCTRequestFactory alloc] init];
    }
    return _requester;
}

- (NSNumber *)currentTimestamp {
    if (!_currentTimestamp) {
        NSInteger currentTimestampValue = [[NSUserDefaults standardUserDefaults] integerForKey:HCT_SAVED_TIMESTAMP];
        _currentTimestamp = [NSNumber numberWithInteger:currentTimestampValue];
    }
    return _currentTimestamp;
}

- (void)setCurrentTimestamp:(NSNumber *)currentTimestamp {
    [[NSUserDefaults standardUserDefaults] setInteger:[currentTimestamp integerValue] forKey:HCT_SAVED_TIMESTAMP];
    _currentTimestamp = currentTimestamp;
}

- (NSNumber *)currentFlow {
    if (!_currentFlow) {
        float currentFlowValue = [[NSUserDefaults standardUserDefaults] floatForKey:HCT_SAVED_FLOW];
        _currentFlow = [NSNumber numberWithFloat:currentFlowValue];
    }
    return _currentFlow;
}

- (void)setCurrentFlow:(NSNumber *)currentFlow {
    [[NSUserDefaults standardUserDefaults] setFloat:[currentFlow floatValue] forKey:HCT_SAVED_FLOW];
    _currentFlow = currentFlow;
}

- (HCTConfigurationModel *)configureModel
{
    return [[HCTConfigurationService sharedInstance] getConfigurationWithNamespace:self.namespace];
}

- (NSString *)requestBaseUrl
{
    return self.configureModel.url ? self.configureModel.url : @"https://common.***.com";
}

- (BOOL)isAppLaunched
{
    id isAppLaunched = [[HermesClient sharedInstance] valueForKey:@"isAppLaunched"];
    return [isAppLaunched boolValue];
}

@end

6、 总结与思考

1. 技术方面

多线程技术很强大,可是很容易出问题。普通作业务的时候用一些简单的 GCD、NSOperation 等就能够知足基本需求了,可是作 SDK 就不同,你须要考虑各类场景。好比 FMDB 在多线程读写的时候,设计了 FMDatabaseQueue 以串行队列的方式同步执行任务。可是这样一来假如使用者在主线程插入 n 次数据到数据库,这样会发生 ANR,因此咱们还得维护一个任务派发队列,用来维护业务方提交的任务,是一个并发队列,以异步任务的方式提交给 FMDB 以同步任务的方式在串行队列上执行。

AFNetworking 2.0 使用了 NSURLConnection,同时维护了一个常驻线程,去处理网络成功后的回调。AF 存在一个常驻线程,假如其余 n 个 SDK 的其中 m 个 SDK 也开启了常驻线程,那你的 App 集成后就有 1+m 个常驻线程。

AFNetworking 3.0 使用 NSURLSession 替换 NSURLConnection,取消了常驻线程。为何换了? 😂 逼不得已呀,Apple 官方出了 NSURLSession,那就不须要 NSURLConnection,并为之建立常驻线程了。至于为何 NSURLSession 不须要常驻线程?它比 NSURLConnecction 多作了什么,之后再聊

建立线程的过程,须要用到物理内存,CPU 也会消耗时间。新建一个线程,系统会在该进程空间分配必定的内存做为线程堆栈。堆栈大小是 4KB 的倍数。在 iOS 主线程堆栈大小是 1MB,新建立的子线程堆栈大小是 512KB。此外线程建立得多了,CPU 在切换线程上下文时,还会更新寄存器,更新寄存器的时候须要寻址,而寻址的过程有 CPU 消耗。线程过多时内存、CPU 都会有大量的消耗,出现 ANR 甚至被强杀。

举了 🌰 是 FMDB 和 AFNetworking 的做者那么厉害,设计的 FMDB 不包装会 ANR,AFNetworking 必须使用常驻线程,为何?正是因为多线程太强大、灵活了,开发者骚操做太多,因此 FMDB 设计最简单保证数据库操做线程安全,具体使用能够本身维护队列去包一层。AFNetworking 内的多线程也严格基于系统特色来设计。

因此有必要再研究下多线程,建议读 GCD 源码,也就是 libdispatch

2. 规范方面

不少开发都不作测试,咱们公司都严格约定测试。写基础 SDK 更是如此,一个 App 基础功能必须质量稳定,因此测试是保证手段之一。必定要写好 Unit Test。这样子不断版本迭代,对于 UT,输入恒定,输出恒定,这样内部实现如何变更不须要关心,只须要判断恒定输入,恒定输出就足够了。(针对每一个函数单一原则的基础上也是知足 UT)。还有一个好处就是当和别人讨论的的时候,你画个技术流程图、技术架构图、测试的 case、测试输入、输出表述清楚,听的人再看看边界状况是否都考虑全,基本上很快沟通完毕,效率考高。

在作 SDK 的接口设计的时候,方法名、参数个数、参数类型、参数名称、返回值名称、类型、数据结构,尽可能要作到 iOS 和 Android 端一致,除非某些特殊状况,没法保证一致的输出。别问为何?好处太多了,成熟 SDK 都这么作。

好比一个数据上报 SDK。须要考虑数据来源是什么,我设计的接口须要暴露什么信息,数据如何高效存储、数据如何校验、数据如何高效及时上报。 假如我作的数据上报 SDK 能够上报 APM 监控数据、同时也开放能力给业务线使用,业务线本身将感兴趣的数据并写入保存,保证不丢失的状况下如何高效上报。由于数据实时上报,因此须要考虑上传的网络环境、Wi-Fi 环境和 4G 环境下的逻辑不同的、数据聚合组装成自定义报文并上报、一个天然天内数据上传须要作流量限制等等、App 版本升级一些数据可能会失去意义、固然存储的数据也存在时效性。种种这些东西就是在开发前须要考虑清楚的。因此基础平台作事情基本是 设计思考时间:编码时间 = 7:3

为何?假设你一个需求,预期10天时间;前期架构设计、类的设计、Uint Test 设计估计7天,到时候编码开发2天完成。 这么作的好处不少,好比:

  1. 除非是很是优秀,否则脑子想的再前面到真正开发的时候发现有出入,coding 完发现和前期方案设计不同。因此建议用流程图、UML图、技术架构图、UT 也同样,设计个表格,这样等到时候编码也就是 coding 的工做了,将图翻译成代码
  2. 后期和别人讨论或者沟通或者 CTO 进行 code review 的时候不须要一行行看代码。你将相关的架构图、流程图、UML 图给他看看。他再看看一些关键逻辑的 UT,保证输入输出正确,通常来讲这样就够了

3. 质量保证

UT 是质量保证的一个方面,另外一个就是 MR 机制。咱们团队 MR 采用 +1 机制。每一个 merge request 必须有团队内至少3我的 +1,且其中一人必须为同技术栈且比你资深一些的同事 +1,一人为和你参加同一个项目的同事。

当有人评论或者有疑问时,你必须解答清楚,别人提出的修改点要么修改好,要么解释清楚,才能够 +1。当 +1 数大于3,则合并分支代码。

连带责任制。当你的线上代码存在 bug 时,为你该次 MR +1 的同事具备连带责任。

参考资料