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

Posted fantasticbaby

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了打造一个通用可配置多句柄的数据上报 SDK相关的知识,希望对你有一定的参考价值。

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

前置说明

因为这篇文章和 APM 是属于姊妹篇,所以看这篇文章的时候有些东西不知道活着好奇的时候可以看带你打造一套 APM 监控系统。

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

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

一、 首先定义需要做什么

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

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

明白我们需要做什么,接下来的步骤就是分析设计怎么做。

二、 拉取配置信息

1. 需要哪些配置信息

首先明确几个原则:

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

所以数据字段基本如下

@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 协议。

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

#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)
}

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

我们想一想这个问题,监控数据都是不直接上传的,监控 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

三、数据存储

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

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];
}

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

四、数据上报机制

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 状态以上是关于打造一个通用可配置多句柄的数据上报 SDK的主要内容,如果未能解决你的问题,请参考以下文章

    Android端日志收集上报SDK相关内容测试的方案梳理总结

    几个非常实用的JQuery代码片段

    Eclipse 中的通用代码片段或模板

    Android 逆向Android 逆向通用工具开发 ( Android 平台运行的 cmd 程序类型 | Android 平台运行的 cmd 程序编译选项 | 编译 cmd 可执行程序 )(代码片段

    如何使用 Swift 使用此代码片段为 iOS 应用程序初始化 SDK?

    如何配置海康联网网关上级域,通过国标GB28181级联到EasyCVR?