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

知识小集

共 96225字,需浏览 193分钟

 ·

2021-07-02 03:29



一个 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 在线程安全的实现主要是基于HandleHandlePool 和 Database 三个类完成的。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 状态

  • 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:x86、DEC

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

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

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

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

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

    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,发现文件被删除了,但是该目录下的可用空间并未变多

  • 在数据聚合的时候优先获取 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 的测试覆盖率和单个文件的测试覆盖率


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

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



五、 接口设计及核心实现


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



六、 总结与思考


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 的同事具有连带责任。


参考资料


  • WAL

  • WCDB 的 WAL 模式和异步 Checkpoint

  • sqlite vacuum

  • 彻底弄懂函数防抖和函数节流





浏览 16
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报