打造一個通用、可配置、多句柄的數據上報 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 上報* isrealtime; <是否實時上報* isuploadpayload; <是否需要上報 payload* @end @interface hctconfigurationmodel : nsobject <nscoding>

@property (nonatomic, copy) NSString *url;                        /<當前 namespace 對應的上報地址 * @property (nonatomic, assign) bool isupload; <全局上報開關* isgather; <全局採集開關* isupdateclear; <升級後是否清除數據* nsinteger maxbodymbyte; <最大包體積單位 m (範圍 < 3m)* periodictimersecond; <定時上報時間單位秒 (範圍1 ~ 30秒)* maxitem; <最大條數 100)* maxflowmbyte; <每天最大非 wi-fi 上傳流量單位 100m)* expirationday; <數據過期時間單位 天 30)* copy) nsarray<hctitemmodel> *monitorList; /<配置項目* @end ``` 因爲數據需要持久化保存,所以需要實現 `nscoding` 協議。 一個小竅門,每個屬性寫 `encode`、`decode` 會很麻煩,可以藉助於宏來實現快速編寫。 ```objective-c #define hct_decode(decoder, datatype, keyname) \ { _##keyname="[decoder" decode##datatype##forkey:nsstringfromselector(@selector(keyname))]; }; hct_encode(acoder, key) [acoder encode##datatype:_##key forkey:nsstringfromselector(@selector(key))]; - (instancetype)initwithcoder:(nscoder *)adecoder if (self="[super" init]) hct_decode(adecoder, object, type) bool, onlywifi) isrealtime) isuploadpayload) } return self; (void)encodewithcoder:(nscoder *)acoder 拋出一個問題:既然監控很重要,那別要配置了,直接全部上傳。 我們想一想這個問題,監控數據都是不直接上傳的,監控 sdk 的責任就是收集監控數據,而且監控後的數據非常多,app 運行期間的網絡請求可能都有 n 次,app 啓動時間、卡頓、奔潰、內存等可能不多,但是這些數據直接上傳後期拓展性非常差,比如根據 apm 監控大盤分析出某個監控能力暫時先關閉掉。這時候就無力迴天了,必須等下次 發佈新版本。監控數據必須先存儲,假如 crash 了,則必須保存了數據等下次啓動再去組裝數據、上傳。而且數據在消費、新數據在不斷生產,假如上傳失敗了還需要對失敗數據的處理,所以這些邏輯還是挺多的,對於監控 來做這個事情,不是很合適。答案就顯而易見了,必須要配置(監控開關的配置、數據上報的行爲配置)。 ### 2. 默認配置 因爲監控真的很特殊,app 一啓動就需要去收集 app 的性能、質量相關數據,所以需要一份默認的配置信息。 初始化一份默認配置 (void)setdefaultconfigurationmodel hctconfigurationmodel *configurationmodel="[[HCTConfigurationModel" alloc] init]; configurationmodel.url="@&quot;https://***DomainName.com&quot;;" 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" appcrashitem.type="@&quot;appCrash&quot;;" appcrashitem.onlywifi="NO;" appcrashitem.isrealtime="YES;" appcrashitem.isuploadpayload="YES;" *applagitem="[[HCTItemModel" applagitem.type="@&quot;appLag&quot;;" applagitem.onlywifi="NO;" applagitem.isrealtime="NO;" applagitem.isuploadpayload="NO;" *appbootitem="[[HCTItemModel" appbootitem.type="@&quot;appBoot&quot;;" appbootitem.onlywifi="NO;" appbootitem.isrealtime="NO;" appbootitem.isuploadpayload="NO;" *netitem="[[HCTItemModel" netitem.type="@&quot;net&quot;;" netitem.onlywifi="NO;" netitem.isrealtime="NO;" netitem.isuploadpayload="NO;" *neterroritem="[[HCTItemModel" neterroritem.type="@&quot;netError&quot;;" neterroritem.onlywifi="NO;" neterroritem.isrealtime="NO;" neterroritem.isuploadpayload="NO;" configurationmodel.monitorlist="@[appCrashItem," applagitem, appbootitem, netitem, neterroritem]; self.configurationmodel="configurationModel;" 上面的例子是一份默認配置信息 3. 拉取策略 網絡拉取使用了基礎 (非網絡 sdk)的能力 mget,根據 key 註冊網絡服務。這些 一般是 內部的定義好的,比如統跳路由表等。 這類 的共性是 在打包階段會內置一份默認配置,app 啓動後會去拉取最新數據,然後完成數據的緩存,緩存會在 `nsdocumentdirectory` 目錄下按照 名稱、 版本號、打包平臺上分配的打包任務 id、 建立緩存文件夾。 此外它的特點是等 啓動完成後纔去請求網絡,獲取數據,不會影響 的啓動。 流程圖如下 ![數據上報配置信息獲取流程](https: raw.githubusercontent.com fantasticlbp knowledge-kit master assets 2020-06-29-datauploadconfigurationstructure.png) 下面是一個截取代碼,對比上面圖看看。 @synthesize configurationdictionary="_configurationDictionary;" #pragma mark initial methods + (instancetype)sharedinstance static hctconfigurationservice *_sharedinstance="nil;" dispatch_once_t oncetoken; dispatch_once(&oncetoken, ^{ _sharedinstance="[[self" }); _sharedinstance; (instancetype)init [self setup]; public method (void)registerandfetchconfigurationinfo __weak typeof(self) weakself="self;" nsdictionary *params="@{@&quot;deviceId&quot;:" [[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 error) }]; (hctconfigurationmodel *)getconfigurationwithnamespace:(nsstring *)namespace (!hct_is_class(namespace, nsstring)) nsassert(hct_is_class(namespace, nsstring), @"需要根據 namespace 參數獲取對應的配置信息,所以必須是 nsstring 類型"); nil; (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 &amp;error)
    {
        m_rwlock.lockRead();
        std::shared_ptr<handlewrap> handleWrap = m_handles.popBack();
        if (handleWrap == nullptr) {
            if (m_aliveHandleCount &lt; s_maxConcurrency) {
                handleWrap = generate(error);
                if (handleWrap) {
                    ++m_aliveHandleCount;
                    if (m_aliveHandleCount &gt; 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",
                    &amp;error);
            }
        }
        if (handleWrap) {
            handleWrap-&gt;handle-&gt;setTag(tag.load());
            if (invoke(handleWrap, error)) {
                return RecyclableHandle(
                    handleWrap, [this](std::shared_ptr<handlewrap> &amp;handleWrap) {
                        flowBack(handleWrap);
                    });
            }
        }
    
        handleWrap = nullptr;
        m_rwlock.unlockRead();
        return RecyclableHandle(nullptr, nullptr);
    }
    
    void HandlePool::flowBack(const std::shared_ptr<handlewrap> &amp;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> &amp;handle, Error &amp;error) -&gt; bool {
        handle-&gt;registerCommittedHook(
          [](Handle *handle, int pages, void *) {
            static TimedQueue<std::string> s_timedQueue(2);
            if (pages &gt; 1000) {
              s_timedQueue.reQueue(handle-&gt;path);
            }
            static std::thread s_checkpointThread([]() {
              pthread_setname_np(
                ("WCDB-" + Database::defaultCheckpointConfigName)
                .c_str());
              while (true) {
                s_timedQueue.waitUntilExpired(
                  [](const std::string &amp;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 &amp;&amp; "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) &amp;&amp; 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], &amp;shouldRollback);
        
        if (shouldRollback) {
            [[self database] rollback];
        }
        else {
            [[self database] commit];
        }
    });
    
    FMDBRelease(self);
}

上面的 _queue 其實是一個串行隊列,通過 _queue = dispatch_queue_create([[NSString stringWithFormat:@"fmdb.%@", self] UTF8String], NULL); 創建。所以,FMDB 的核心就是以同步的形式向串行隊列提交任務,來保證多線程操作下的讀寫問題(比每個操作加鎖效率高很多)。只有一個任務執行完畢,纔可以執行下一個任務。

上一個版本的數據上報 SDK 功能比較簡單,就是上報 APM 監控後的數據,所以數據量不會很大,之前的人封裝超級簡單,僅以事務的形式封裝了一層 FMDB 的增刪改查操作。那麼就會有一個問題。假如 SDK 被業務線接入,業務線開發者不知道數據上報 SDK 的內部實現,直接調用接口去寫入大量數據,結果 App 發生了卡頓,那不得反饋你這個 SDK 超級難用啊。

2. 針對 FMDB 的改進

改法也比較簡單,我們先弄清楚 FMDB 這樣設計的原因。數據庫操作的環境可能是主線程、子線程等不同環境去修改數據,主線程、子線程去讀取數據,所以創建了一個串行隊列去執行真正的數據增刪改查。

目的就是讓不同線程去使用 FMDB 的時候不會阻塞當前線程。既然 FMDB 內部維護了一個串行隊列去處理多線程情況下的數據操作,那麼改法也比較簡單,那就是創建一個併發隊列,然後以異步的方式提交任務到 FMDB 中去,FMDB 內部的串行隊列去執行真正的任務。

代碼如下

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

// 以刪除數據爲例,以異步任務的方式向併發隊列提交任務,任務內部調用 FMDatabaseQueue 去串行執行每個任務
- (void)removeAllLogsInTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself removeAllLogsInTable:tableName];
    });
}

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

小實驗模擬下流程

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

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

MainThread Dispatch Async Task To ConcurrentQueue

3. 數據表設計

通用的數據上報 SDK 的功能是數據的保存和上報。從數據的角度來劃分,數據可以分爲 APM 監控數據和業務線的業務數據。

數據各有什麼特點呢?APM 監控數據一般可以劃分爲:基本信息、異常信息、線程信息,也就是最大程度的還原案發線程的數據。業務線數據基本上不會有所謂的大量數據,最多就是數據條數非常多。鑑於此現狀,可以將數據表設計爲 meta 表payload 表。meta 表用來存放 APM 的基礎數據和業務線的數據,payload 表用來存放 APM 的線程堆棧數據。

數據表的設計是基於業務情況的。那有這樣幾個背景

  • APM 監控數據需要報警(具體可以查看 APM 文章,地址在開頭 ),所以數據上報 SDK 上報後的數據需要實時解析
  • 產品側比如監控大盤可以慢,所以符號化系統是異步的
  • 監控數據實在太大了,如果同步解析會因爲壓力較大造成性能瓶頸

所以把監控數據拆分爲2塊,即 meta 表、payload 表。meta 表相當於記錄索引信息,服務端只需要關心這個。而 payload 數據在服務端是不會處理的,會有一個異步服務單獨處理。

meta 表、payload 表結構如下:

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

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

4. 數據庫表的封裝

#import "HCTDatabase.h"
#import <fmdb fmdb.h>

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

@interface HCTDatabase ()

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

@end

@implementation HCTDatabase

#pragma mark - life cycle
+ (instancetype)sharedInstance {
    static HCTDatabase *_sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&amp;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 &lt; 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'))  &gt;= created_time;
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where strftime('%%s', date('now', '-%zd day')) &gt;= 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(@"上報系統數據庫文件位置 -&gt; %@", 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表是否存在 -&gt; %@", 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表是否存在 -&gt; %@", 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 上報* isrealtime; **<是否實時上報* isuploadpayload; **<是否需要上報 payload* @end @interface hctconfigurationmodel : nsobject <nscoding>

@property (nonatomic, copy) NSString *url;                        /**<當前 namespace 對應的上報地址 * @property (nonatomic, assign) bool isupload; **<全局上報開關* isgather; **<全局採集開關* isupdateclear; **<升級後是否清除數據* nsinteger maxbodymbyte; **<最大包體積單位 m (範圍 < 3m)* periodictimersecond; **<定時上報時間單位秒 (範圍1 ~ 30秒)* maxitem; **<最大條數 100)* maxflowmbyte; **<每天最大非 wi-fi 上傳流量單位 100m)* expirationday; **<數據過期時間單位 天 30)* copy) nsarray<hctitemmodel> *monitorList; /**<配置項目* @end ``` 監控數據存儲流程: 1. 每個數據(監控數據、業務線數據)過來先判斷該數據所在的 namespace 是否開啓了收集開關 2. 判斷數據是否可以落庫,根據數據接口中 type 能否命中上報配置數據中的 monitorlist 中的任何一項的 3. 監控數據先寫入 meta 表,然後判斷是否寫入 payload 表。判斷標準是計算監控數據的 大小是否超過了上報配置數據的 `maxbodymbyte`。超過大小的數據就不能入庫,因爲這是服務端消耗 的一個上限 4. 走監控接口過來的數據,在方法內部會爲監控數據增加基礎信息(比如 app 名稱、app 版本號、打包任務 id、設備類型等等) ```objective-c @property (nonatomic, copy) nsstring *xxx_app_name; **<app 名稱(wax)* *xxx_app_version; 版本(wax)* *xxx_candle_task_id; **<打包平臺分配的打包任務id* *sys_system_model; **<系統類型(android ios)* *sys_device_id; **<設備 id* *sys_brand; **<系統品牌* *sys_phone_model; **<設備型號* *sys_system_version; **<系統版本* *app_platform; **<平臺號* *app_version; 版本(業務版本)* *app_session_id; **<session *app_package_name; **<包名* *app_mode; **<debug release* *app_uid; **<user *app_mc; **<渠道號* *app_monitor_version; **<監控版本號。和服務端維持同一個版本,服務端升級的話,sdk也跟着升級* *report_id; **<唯一id* *create_time; **<時間* assign) bool is_biz; **<是否是監控數據* 5. 因爲本次交給數據上報 sdk 的 crash 類型的數據是上次奔潰時的數據,所以在第4點說的規則不太適用,apm 類型是特例。 6. 計算每條數據的大小。metasize + payloadsize 7. 再寫入 表 8. 判斷是否觸發實時上報,觸發後走後續流程。 - (void)sendwithtype:(nsstring *)type meta:(nsdictionary *)meta payload:(nsdata *__nullable)payload { 檢查參數合法性 *warning="[NSString" stringwithformat:@"%@不能是空字符串", type]; if (!hct_is_class(type, nsstring)) nsassert1(hct_is_class(type, nsstring), warning, type); return; } (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) &amp;&amp; !payload) {
        return;
    }

    // 5. 添加限制(超過大小的數據就不能入庫,因爲這是服務端消耗 payload 的一個上限)
    CGFloat payloadSize = [self calculateDataSize:payload];
    if (payloadSize &gt; self.configureModel.maxBodyMByte) {
        NSString *assertString = [NSString stringWithFormat:@"payload 數據的大小超過臨界值 %zdKB", self.configureModel.maxBodyMByte];
        NSAssert(payloadSize &lt;= 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:&amp;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 中的文件引用計數的意思,雖然不一樣,但是提出來做一下參考。實驗是這樣的

    1. 先看一下當前各個掛載目錄的空間大小:df -h

    2. 首先我們產生一個50M大小的文件

    3. 寫一段代碼讀取文件

      #include<stdio.h>
      #include<unistd.h>
      int&nbsp;main(void)
      {&nbsp;&nbsp;&nbsp;&nbsp;FILE&nbsp;*fp&nbsp;=&nbsp;NULL;&nbsp;&nbsp;&nbsp;
        fp&nbsp;=&nbsp;fopen("/boot/test.txt",&nbsp;"rw+");&nbsp;&nbsp;&nbsp;
        if(NULL&nbsp;==&nbsp;fp){&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      	  perror("open&nbsp;file&nbsp;failed");&nbsp;&nbsp;&nbsp;
        	return&nbsp;-1;&nbsp;&nbsp;&nbsp;
        }&nbsp;&nbsp;&nbsp;&nbsp;
        while(1){&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
      	  //do&nbsp;nothing&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;sleep(1);&nbsp;&nbsp;&nbsp;
        }&nbsp;&nbsp;&nbsp;
        fclose(fp);&nbsp;&nbsp;
        return&nbsp;0;
      }
      
    4. 命令行模式下使用 rm 刪除文件

    5. 查看文件大小: df -h,發現文件被刪除了,但是該目錄下的可用空間並未變多

    解釋:實際上,只有當一個文件的引用計數爲0(包括硬鏈接數)的時候,纔可能調用 unlink 刪除,只要它不是0,那麼就不會被刪除。所謂的刪除,也不過是文件名到 inode 的鏈接刪除,只要不被重新寫入新的數據,磁盤上的 block 數據塊不會被刪除,因此,你會看到,即便刪庫跑路了,某些數據還是可以恢復的。換句話說,當一個程序打開一個文件的時候(獲取到文件描述符),它的引用計數會被+1,rm雖然看似刪除了文件,實際上只是會將引用計數減1,但由於引用計數不爲0,因此文件不會被刪除。

  • 在數據聚合的時候優先獲取 crash 數據,總數據條數需要小於上報配置數據的條數限制、總數據大小需要小於上報配置數據的大小限制。這裏的處理使用了遞歸,改變了函數參數

    - (void)assembleDataInTable:(HCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray<hctlogmodel *> *records))completion {
        // 1. 獲取到合適的 Crash 類型的數據
        [self fetchCrashDataByCount:self.configureModel.maxFlowMByte
                            inTable:tableType
                         upperBound:self.configureModel.maxBodyMByte
                         completion:^(NSArray<hctlogmodel *> *records) {
                             NSArray<hctlogmodel *> *crashData = records;
                             // 2. 計算剩餘需要的數據條數和剩餘需要的數據大小
                             NSInteger remainingCount = self.configureModel.maxItem - crashData.count;
                             float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData];
                             // 3. 獲取除 Crash 類型之外的其他數據,且需要符合相應規則
                             BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi);
                             [self fetchDataExceptCrash:remainingCount
                                                inTable:tableType
                                             upperBound:remainingSize
                                                 isWiFI:isWifi
                                             completion:^(NSArray<hctlogmodel *> *records) {
                                                 NSArray<hctlogmodel *> *dataExceptCrash = records;
    
                                                 NSMutableArray *dataSource = [NSMutableArray array];
                                                 [dataSource addObjectsFromArray:crashData];
                                                 [dataSource addObjectsFromArray:dataExceptCrash];
                                                 if (completion) {
                                                     completion([dataSource copy]);
                                                 }
                                             }];
                         }];
    }
    
    - (void)fetchDataExceptCrash:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray<hctlogmodel *> *records))completion {
        // 1. 根據剩餘需要數據條數去查詢表中非 Crash 類型的數據集合
        __block NSMutableArray *conditions = [NSMutableArray array];
        [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            if (isWifi) {
                if (![obj.type isEqualToString:@"appCrash"]) {
                    [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]];
                }
            } else {
                if (!obj.onlyWifi &amp;&amp; ![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 &gt; size) {
                                     NSInteger currentCount = count - 1;
                                     return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion];
                                 } else {
                                     if (completion) {
                                         completion(records);
                                     }
                                 }
                             }];
    }
    
  • 整個 SDK 的 Unit Test 通過率 100%,代碼分支覆蓋率爲 93%。測試基於 TDD 和 BDD。測試框架:系統自帶的 XCTest,第三方的 OCMockKiwiExpectaSpecta。測試使用了基礎類,後續每個文件都設計繼承自測試基類的類。

    Xcode 可以看到整個 SDK 的測試覆蓋率和單個文件的測試覆蓋率

    Xcode 測試覆蓋率

    也可以使用 slather。在項目終端環境下新建 .slather.yml 配置文件,然後執行語句 slather coverage -s --scheme hermes-client-Example --workspace hermes-client.xcworkspace hermes-client.xcodeproj

    關於質量保證的最基礎、可靠的方案之一軟件測試,在各個端都有一些需要注意的地方,還需要結合工程化,我會寫專門的文章談談經驗心得。

五、 接口設計及核心實現

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 下的配置信息* nsstring *metaurl; **<meta 接口地址* *payloadurl; **<payload strong) hctrequestfactory *requester; **<網絡請求中心* nsnumber *currenttimestamp; **<保存的時間戳* *currentflow; **<當前已使用的流量* tmlooptaskexecutor *taskexecutor; **<上報數據定時任務* assign) bool isapplaunched; **<通過 kvc 的形式獲取到 hermesclient 裏面存儲 app 是否啓動完成的標識,這種 case 是處理: mget 首次獲取到 3個 namespace, 但 運行期間服務端新增某種 此時業務線如果插入數據依舊可以正常落庫、上報* @end @implementation hctservice @synthesize currenttimestamp="_currentTimestamp;" currentflow="_currentFlow;" #pragma mark - life cycle (instancetype)initwithnamespace:(nsstring * _nonnull )namespace { if (self="[super" init]) _namespace="namespace;" [self setupconfig]; (self.isapplaunched) executehandlerwhenapplaunched]; } else [[nsnotificationcenter defaultcenter] addobserverforname:uiapplicationdidfinishlaunchingnotification object:nil queue:[nsoperationqueue mainqueue] usingblock:^(nsnotification note) [[hermesclient sharedinstance] setvalue:@(yes) forkey:@"isapplaunched"]; }]; return self; public method (void)sendwithtype:(nsstring *)type meta:(nsdictionary *)meta payload:(nsdata *__nullable)payload 1. 檢查參數合法性 *warning="[NSString" stringwithformat:@"%@不能是空字符串", type]; (!hct_is_class(type, nsstring)) nsassert1(hct_is_class(type, nsstring), warning, type); return; (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) &amp;&amp; !payload) {
        return;
    }

    // 5. 添加限制(超過大小的數據就不能入庫,因爲這是服務端消耗 payload 的一個上限)
    CGFloat payloadSize = [self calculateDataSize:payload];
    if (payloadSize &gt; self.configureModel.maxBodyMByte) {
        NSString *assertString = [NSString stringWithFormat:@"payload 數據的大小超過臨界值 %zdKB", self.configureModel.maxBodyMByte];
        NSAssert(payloadSize &lt;= 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:&amp;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 &gt; 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 &gt; 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] &amp;&amp; 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 &lt; %zd and namespace = '%@') or size &gt; %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 &gt; 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:&amp;error];
    if (error) {
        HCTLOG(@"%@", error);
        return;
    }

    // 5. 添加限制(超過 10K 的數據就不能入庫,因爲這是服務端消耗 meta 的一個上限)
    CGFloat metaSize = [self calculateDataSize:metaData];
    if (metaSize &gt; 10 / 1024.0) {
        NSAssert(metaSize &lt;= 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] &amp;&amp; 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 &amp;&amp; self.configureModel.isUpload) {
            [self handleUploadTask:networkType];
        }
    }];
}

/**
 上報前的校驗
 - 判斷網絡情況,分爲 wifi 和 非 Wi-Fi 、網絡不通的情況。
 - 從配置下發的 monitorList 找出 onlyWifi 字段爲 true 的 type,組成數組 [appCrash、appLag...]
 - 網絡不通,則不能上報
 - 網絡通,則判斷上報校驗
 1. 當前GMT時間戳-保存的時間戳超過24h。則認爲是一個新的自然天
 - 清除 currentFlow
 - 觸發上報流程
 2. 當前GMT時間戳-保存的時間戳不超過24h
 - 當前的流量是否超過配置信息裏面的最大流量,未超過(&lt;):觸發上報流程
 - 當前的流量是否超過配置信息裏面的最大流量,超過:結束流程
 */
- (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 &gt; 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 &lt; self.configureModel.maxFlowMByte) {
                        if (completionBlock) {
                            completionBlock(YES, NetworkingManagerStatusReachableViaWWAN);
                        }
                    } else {
                        if (completionBlock) {
                            completionBlock(NO, NetworkingManagerStatusReachableViaWWAN);
                        }
                    }
                }
                break;
            }
        }
    }];
}

- (void)handleUploadTask:(NetworkingManagerStatusType)networkType {
    // 數據聚合(2張表分別掃描) -&gt; 壓縮 -&gt; 上報
    [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 &lt; (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 &lt; (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:&amp;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:&amp;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 &lt; maxItem &amp;&amp; size &lt; 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 &gt; 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 &amp;&amp; ![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 &gt; 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 的同事具有連帶責任。

參考資料

</hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></nsdata></hctlogmodel></hctitemmodel></需要配置的baseurl*></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></unistd.h></stdio.h></配置項目*></當前></上報數據類型*></nscoding></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></hctlogmodel></fmdb></std::string></handle></handlewrap></handlewrap></handlewrap></配置項目*></當前></上報數據類型*></nscoding>

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章