打造一個通用、可配置、多控制程式碼的資料上報 SDK

fantasticbaby發表於2020-11-20
一個 App 一般會存在很多場景去上傳 App 中產生的資料,比如 APM、埋點統計、開發者自定義的資料等等。所以本篇文章就講講如何設計一個通用的、可配置的、多控制程式碼的資料上報 SDK。

前置說明

因為這篇文章和 APM 是屬於姊妹篇,所以看這篇文章的時候有些東西不知道活著好奇的時候可以看帶你打造一套 APM 監控系統

另外看到我在下面的程式碼段,有些命名風格、簡寫、分類、方法的命名等,我簡單做個說明。

  • 資料上報 SDK 叫 HermesClient,我們規定類的命名一般用 SDK 的名字縮寫,當前情況下縮寫為 HCT
  • 給 Category 命名,規則為 類名 + SDK 字首縮寫的小寫格式 + 下劃線 + 駝峰命名格式的功能描述。比如給 NSDate 增加一個獲取毫秒時間戳的分類,那麼類名為 NSDate+HCT_TimeStamp
  • 給 Category 的方法命名,規則為 SDK 字首縮寫的小寫格式 + 下劃線 + 駝峰命名格式的功能描述。比如給 NSDate 增加一個根據當前時間獲取毫秒時間戳的方法,那麼方法名為 + (long long)HCT_currentTimestamp;

一、 首先定義需要做什麼

我們要做的是「一個通用可配置、多控制程式碼的資料上報 SDK」,也就是說這個 SDK 具有這麼幾個功能:

  • 具有從服務端拉取配置資訊的能力,這些配置用來控制 SDK 的上報行為(需不需要預設行為?)
  • SDK 具有多控制程式碼特性,也就是擁有多個物件,每個物件具有自己的控制行為,彼此之間的執行、操作互相隔離
  • APM 監控作為非常特殊的能力存在,它也使用資料上報 SDK。它的能力是 App 質量監控的保障,所以針對 APM 的資料上報通道是需要特殊處理的。
  • 資料先根據配置決定要不要存,存下來之後再根據配置決定如何上報

明白我們需要做什麼,接下來的步驟就是分析設計怎麼做。

二、 拉取配置資訊

1. 需要哪些配置資訊

首先明確幾個原則:

  • 因為監控資料上報作為資料上報的一個特殊 case,那麼監控的配置資訊也應該特殊處理。
  • 監控能力包含很多,比如卡頓、網路、奔潰、記憶體、電量、啟動時間、CPU 使用率。每個監控能力都需要一份配置資訊,比如監控型別、是否僅 WI-FI 環境下上報、是否實時上報、是否需要攜帶 Payload 資料。(注:Payload 其實就是經過 gZip 壓縮、AES-CBC 加密後的資料)
  • 多控制程式碼,所以需要一個欄位標識每份配置資訊,也就是一個 namespace 的概念
  • 每個 namespace 下都有自己的配置,比如資料上傳後的伺服器地址、上報開關、App 升級後是否需要清除掉之前版本儲存的資料、單次上傳資料包的最大體積限制、資料記錄的最大條數、在非 WI-FI 環境下每天上報的最大流量、資料過期天數、上報開關等
  • 針對 APM 的資料配置,還需要一個是否需要採集的開關。

所以資料欄位基本如下

@interface HCTItemModel : NSObject <NSCoding>

@property (nonatomic, copy) NSString *type;         /<上報資料型別*/
@property (nonatomic, assign) BOOL onlyWifi;        /<是否僅 Wi-Fi 上報*/
@property (nonatomic, assign) BOOL isRealtime;      /<是否實時上報*/
@property (nonatomic, assign) BOOL isUploadPayload; /<是否需要上報 Payload*/

@end

@interface HCTConfigurationModel : NSObject <NSCoding>

@property (nonatomic, copy) NSString *url;                        /<當前 namespace 對應的上報地址 */
@property (nonatomic, assign) BOOL isUpload;                      /<全域性上報開關*/
@property (nonatomic, assign) BOOL isGather;                      /<全域性採集開關*/
@property (nonatomic, assign) BOOL isUpdateClear;                 /<升級後是否清除資料*/
@property (nonatomic, assign) NSInteger maxBodyMByte;             /<最大包體積單位 M (範圍 < 3M)*/
@property (nonatomic, assign) NSInteger periodicTimerSecond;      /<定時上報時間單位秒 (範圍1 ~ 30秒)*/
@property (nonatomic, assign) NSInteger maxItem;                  /<最大條數 (範圍 < 100)*/
@property (nonatomic, assign) NSInteger maxFlowMByte;             /<每天最大非 Wi-Fi 上傳流量單位 M (範圍 < 100M)*/
@property (nonatomic, assign) NSInteger expirationDay;            /<資料過期時間單位 天 (範圍 < 30)*/
@property (nonatomic, copy) NSArray<HCTItemModel *> *monitorList; /<配置專案*/

@end

因為資料需要持久化儲存,所以需要實現 NSCoding 協議。

一個小竅門,每個屬性寫 encodedecode 會很麻煩,可以藉助於巨集來實現快速編寫。

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

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

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

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

丟擲一個問題:既然監控很重要,那別要配置了,直接全部上傳。

我們想一想這個問題,監控資料都是不直接上傳的,監控 SDK 的責任就是收集監控資料,而且監控後的資料非常多,App 執行期間的網路請求可能都有 n 次,App 啟動時間、卡頓、奔潰、記憶體等可能不多,但是這些資料直接上傳後期擴充性非常差,比如根據 APM 監控大盤分析出某個監控能力暫時先關閉掉。這時候就無力迴天了,必須等下次 SDK 釋出新版本。監控資料必須先儲存,假如 crash 了,則必須儲存了資料等下次啟動再去組裝資料、上傳。而且資料在消費、新資料在不斷生產,假如上傳失敗了還需要對失敗資料的處理,所以這些邏輯還是挺多的,對於監控 SDK 來做這個事情,不是很合適。答案就顯而易見了,必須要配置(監控開關的配置、資料上報的行為配置)。

2. 預設配置

因為監控真的很特殊,App 一啟動就需要去收集 App 的效能、質量相關資料,所以需要一份預設的配置資訊。

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

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

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

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

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

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

上面的例子是一份預設配置資訊

3. 拉取策略

網路拉取使用了基礎 SDK (非網路 SDK)的能力 mGet,根據 key 註冊網路服務。這些 key 一般是 SDK 內部的定義好的,比如統跳路由表等。

這類 key 的共性是 App 在打包階段會內建一份預設配置,App 啟動後會去拉取最新資料,然後完成資料的快取,快取會在 NSDocumentDirectory 目錄下按照 SDK 名稱、 App 版本號、打包平臺上分配的打包任務 id、 key 建立快取資料夾。

此外它的特點是等 App 啟動完成後才去請求網路,獲取資料,不會影響 App 的啟動。

流程圖如下

資料上報配置資訊獲取流程

下面是一個擷取程式碼,對比上面圖看看。

@synthesize configurationDictionary = _configurationDictionary;

#pragma mark - Initial Methods

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

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

#pragma mark - public Method

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

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

- (HCTConfigurationModel *)getConfigurationWithNamespace:(NSString *)namespace {
    if (!HCT_IS_CLASS(namespace, NSString)) {
        NSAssert(HCT_IS_CLASS(namespace, NSString), @"需要根據 namespace 引數獲取對應的配置資訊,所以必須是 NSString 型別");
        return nil;
    }
    if (namespace.length == 0) {
        NSAssert(namespace.length > 0, @"需要根據 namespace 引數獲取對應的配置資訊,所以必須是非空的 NSString");
        return nil;
    }
    id configurationData = [self.configurationDictionary objectForKey:namespace];
    if (!configurationData) {
        return nil;
    }
    if (!HCT_IS_CLASS(configurationData, NSDictionary)) {
        return nil;
    }
    NSDictionary *configurationDictionary = (NSDictionary *)configurationData;
    return [HCTConfigurationModel modelWithDictionary:configurationDictionary];
}


#pragma mark - private method

- (void)setUp {
    // 建立資料儲存的資料夾
    [[NSFileManager defaultManager] createDirectoryAtPath:[self configurationDataFilePath] withIntermediateDirectories:YES attributes:nil error:nil];
    [self setDefaultConfigurationModel];
    [self getConfigurationModelFromLocal];
}

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

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

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

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

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

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

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

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


#pragma mark - getters and setters

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

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

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

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

@end

三、資料儲存

1. 資料儲存技術選型

記得在做資料上報技術的評審會議上,Android 同事說用 WCDB,特色是 ORM、多執行緒安全、高效能。然後就被質疑了。因為上個版本使用的技術是基於系統自帶的 sqlite2,單純為了 ORM、多執行緒問題就額外引入一個三方庫,是不太能說服人的。有這樣幾個疑問

  • ORM 並不是核心訴求,利用 Runtime 可以在基礎上進行修改,也可支援 ORM 功能
  • 執行緒安全。WCDB 線上程安全的實現主要是基於HandleHandlePoolDatabase 三個類完成的。Handle 是 sqlite3 指標,HandlePool 用來處理連線。

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

    所以 WCDB 連線池通過讀寫鎖保證執行緒安全。所以之前版本的地方要實現執行緒安全修改下缺陷就可以。增加了 sqlite3,雖然看起來就是幾兆大小,但是這對於公共團隊是致命的。業務線開發者每次接入 SDK 會注意App 包體積的變化,為了資料上報增加好幾兆,這是不可以接受的。

  • 高效能的背後是 WCDB 自帶的 sqlite3 開啟了 WAL模式 (Write-Ahead Logging)。當 WAL 檔案超過 1000 個頁大小時,SQLite3 會將 WAL 檔案寫會資料庫檔案。也就是 checkpointing。當大批量的資料寫入場景時,如果不停提交檔案到資料庫事務,效率肯定低下,WCDB 的策略就是在觸發 checkpoint 時,通過延時佇列去處理,避免不停的觸發 WalCheckpoint 呼叫。通過 TimedQueue 將同個資料庫的 WalCheckpoint 合併延遲到2秒後執行

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

一般來說公共組做事情,SDK 命名、介面名稱、介面個數、引數個數、引數名稱、引數資料型別是嚴格一致的,差異是語言而已。實在萬不得已,能力不能堆砌的情況下是可以不一致的,但是需要在技術評審會議上說明原因,需要在釋出文件、接入文件都有所體現。

所以最後的結論是在之前的版本基礎上進行修改,之前的版本是 FMDB。

2. 資料庫維護佇列

1. FMDB 佇列

FMDB 使用主要是通過 FMDatabaseQueue- (void)inTransaction:(__attribute__((noescape)) void (^)(FMDatabase *db, BOOL *rollback))block- (void)inDatabase:(__attribute__((noescape)) void (^)(FMDatabase *db))block。這2個方法的實現如下

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

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

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

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

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

2. 針對 FMDB 的改進

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

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

程式碼如下

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

// 以刪除資料為例,以非同步任務的方式向併發佇列提交任務,任務內部呼叫 FMDatabaseQueue 去序列執行每個任務
- (void)removeAllLogsInTableType:(HCTLogTableType)tableType {
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself removeAllLogsInTable:tableName];
    });
}

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

小實驗模擬下流程

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

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

MainThread Dispatch Async Task To ConcurrentQueue

3. 資料表設計

通用的資料上報 SDK 的功能是資料的儲存和上報。從資料的角度來劃分,資料可以分為 APM 監控資料和業務線的業務資料。

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

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

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

所以把監控資料拆分為2塊,即 meta 表、payload 表。meta 表相當於記錄索引資訊,服務端只需要關心這個。而 payload 資料在服務端是不會處理的,會有一個非同步服務單獨處理。

meta 表、payload 表結構如下:

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

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

4. 資料庫表的封裝

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

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

@interface HCTDatabase ()

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

@end

@implementation HCTDatabase

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

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

#pragma mark - public Method

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

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

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

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

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

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

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

- (void)updateData:(NSString *)state useCondition:(NSString *)condition inTableType:(HCTLogTableType)tableType {
    if (!HCT_IS_CLASS(state, NSString)) {
        NSAssert(HCT_IS_CLASS(state, NSString), @"資料表欄位更改命令必須是合法字串");
        return;
    }
    if (state.length == 0) {
        NSAssert(!(state.length == 0), @"資料表欄位更改命令必須是合法字串");
        return;
    }
    
    if (!HCT_IS_CLASS(condition, NSString)) {
        NSAssert(HCT_IS_CLASS(condition, NSString), @"資料表欄位更改條件必須是字串型別");
        return;
    }
    if (condition.length == 0) {
        NSAssert(!(condition.length == 0), @"資料表欄位更改條件不能為空");
        return;
    }
    [self isExistInTable:tableType];
    __weak typeof(self) weakself = self;
    dispatch_async(self.dbOperationQueue, ^{
        NSString *tableName = HCTGetTableNameFromType(tableType);
        [weakself updateData:state useCondition:condition inTable:tableName];
    });
}

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

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

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

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

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

#pragma mark - CMDatabaseDelegate

- (void)add:(NSArray<HCTLogModel *> *)logs inTable:(NSString *)tableName {
    if (logs.count == 0) {
        return;
    }
    __weak typeof(self) weakself = self;
    [self.dbQueue inTransaction:^(FMDatabase *_Nonnull db, BOOL *_Nonnull rollback) {
        [db setDateFormat:weakself.dateFormatter];
        for (NSInteger index = 0; index < logs.count; index++) {
            id obj = logs[index];
            // meta 型別資料的處理邏輯
            if (HCT_IS_CLASS(obj, HCTLogMetaModel)) {
                HCTLogMetaModel *model = (HCTLogMetaModel *)obj;
                if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) {
                    HCTLOG(@"引數錯誤 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace);
                    return;
                }

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

            // payload 型別資料的處理邏輯
            if (HCT_IS_CLASS(obj, HCTLogPayloadModel)) {
                HCTLogPayloadModel *model = (HCTLogPayloadModel *)obj;
                if (model.monitor_type == nil || model.meta == nil || model.created_time == nil || model.namespace == nil) {
                    HCTLOG(@"引數錯誤 { monitor_type: %@, meta: %@, created_time: %@, namespace: %@}", model.monitor_type, model.meta, model.created_time, model.namespace);
                    return;
                }

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

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

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

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

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

- (void)removeRecordsBeforeDays:(NSInteger)day inTable:(NSString *)tableName {
    // 找出從create到現在已經超過最大 day 天的資料,然後刪除 :delete from ***_hermes_meta where strftime('%s', date('now', '-2 day'))  >= created_time;
    NSString *sqlString = [NSString stringWithFormat:@"delete from %@ where strftime('%%s', date('now', '-%zd day')) >= created_time", tableName, day];
    [self.dbQueue inDatabase:^(FMDatabase *_Nonnull db) {
        [db executeUpdate:sqlString];
    }];
}

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

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

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

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

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

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

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

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

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

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

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

        FMResultSet *resultSet = [db executeQuery:sqlString];

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

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

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

#pragma mark - private method

+ (NSString *)databaseFilePath {
    NSString *docsPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
    NSString *dbPath = [docsPath stringByAppendingPathComponent:HCT_LOG_DATABASE_NAME];
    HCTLOG(@"上報系統資料庫檔案位置 -> %@", dbPath);
    return dbPath;
}

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

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

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

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

@end

上面有個地方需要注意下,因為經常需要根據型別來判讀操作那個資料表,使用頻次很高,所以寫成行內函數的形式

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

5. 資料儲存流程

APM 監控資料會比較特殊點,比如 iOS 當發生 crash 後是沒辦法上報的,只有將 crash 資訊儲存到檔案中,下次 App 啟動後讀取 crash 日誌資料夾再去交給資料上報 SDK。Android 在發生 crash 後由於機制不一樣,可以馬上將 crash 資訊交給資料上報 SDK。

由於 payload 資料,也就是堆疊資料非常大,所以上報的介面也有限制,一次上傳介面中報文最大包體積的限制等等。

可以看一下 Model 資訊,

@interface HCTItemModel : NSObject <NSCoding>

@property (nonatomic, copy) NSString *type;         /**<上報資料型別*/
@property (nonatomic, assign) BOOL onlyWifi;        /**<是否僅 Wi-Fi 上報*/
@property (nonatomic, assign) BOOL isRealtime;      /**<是否實時上報*/
@property (nonatomic, assign) BOOL isUploadPayload; /**<是否需要上報 Payload*/

@end

@interface HCTConfigurationModel : NSObject <NSCoding>

@property (nonatomic, copy) NSString *url;                        /**<當前 namespace 對應的上報地址 */
@property (nonatomic, assign) BOOL isUpload;                      /**<全域性上報開關*/
@property (nonatomic, assign) BOOL isGather;                      /**<全域性採集開關*/
@property (nonatomic, assign) BOOL isUpdateClear;                 /**<升級後是否清除資料*/
@property (nonatomic, assign) NSInteger maxBodyMByte;             /**<最大包體積單位 M (範圍 < 3M)*/
@property (nonatomic, assign) NSInteger periodicTimerSecond;      /**<定時上報時間單位秒 (範圍1 ~ 30秒)*/
@property (nonatomic, assign) NSInteger maxItem;                  /**<最大條數 (範圍 < 100)*/
@property (nonatomic, assign) NSInteger maxFlowMByte;             /**<每天最大非 Wi-Fi 上傳流量單位 M (範圍 < 100M)*/
@property (nonatomic, assign) NSInteger expirationDay;            /**<資料過期時間單位 天 (範圍 < 30)*/
@property (nonatomic, copy) NSArray<HCTItemModel *> *monitorList; /**<配置專案*/

@end

監控資料儲存流程:

  1. 每個資料(監控資料、業務線資料)過來先判斷該資料所在的 namespace 是否開啟了收集開關
  2. 判斷資料是否可以落庫,根據資料介面中 type 能否命中上報配置資料中的 monitorList 中的任何一項的 type
  3. 監控資料先寫入 meta 表,然後判斷是否寫入 payload 表。判斷標準是計算監控資料的 payload 大小是否超過了上報配置資料的 maxBodyMByte。超過大小的資料就不能入庫,因為這是服務端消耗 payload 的一個上限
  4. 走監控介面過來的資料,在方法內部會為監控資料增加基礎資訊(比如 App 名稱、App 版本號、打包任務 id、裝置型別等等)

    @property (nonatomic, copy) NSString *xxx_APP_NAME;       /**<App 名稱(wax)*/
    @property (nonatomic, copy) NSString *xxx_APP_VERSION;    /**<App 版本(wax)*/
    @property (nonatomic, copy) NSString *xxx_CANDLE_TASK_ID; /**<打包平臺分配的打包任務id*/
    @property (nonatomic, copy) NSString *SYS_SYSTEM_MODEL;   /**<系統型別(android / iOS)*/
    @property (nonatomic, copy) NSString *SYS_DEVICE_ID;      /**<裝置 id*/
    
    @property (nonatomic, copy) NSString *SYS_BRAND;          /**<系統品牌*/
    @property (nonatomic, copy) NSString *SYS_PHONE_MODEL;    /**<裝置型號*/
    @property (nonatomic, copy) NSString *SYS_SYSTEM_VERSION; /**<系統版本*/
    @property (nonatomic, copy) NSString *APP_PLATFORM;       /**<平臺號*/
    @property (nonatomic, copy) NSString *APP_VERSION;        /**<App 版本(業務版本)*/
    
    @property (nonatomic, copy) NSString *APP_SESSION_ID;   /**<session id*/
    @property (nonatomic, copy) NSString *APP_PACKAGE_NAME; /**<包名*/
    @property (nonatomic, copy) NSString *APP_MODE;         /**<Debug/Release*/
    @property (nonatomic, copy) NSString *APP_UID;          /**<user id*/
    @property (nonatomic, copy) NSString *APP_MC;           /**<渠道號*/
    
    @property (nonatomic, copy) NSString *APP_MONITOR_VERSION; /**<監控版本號。和服務端維持同一個版本,服務端升級的話,SDK也跟著升級*/
    @property (nonatomic, copy) NSString *REPORT_ID;           /**<唯一ID*/
    @property (nonatomic, copy) NSString *CREATE_TIME;         /**<時間*/
    @property (nonatomic, assign) BOOL IS_BIZ;                 /**<是否是監控資料*/
  5. 因為本次交給資料上報 SDK 的 crash 型別的資料是上次奔潰時的資料,所以在第4點說的規則不太適用,APM crash 型別是特例。
  6. 計算每條資料的大小。metaSize + payloadSize
  7. 再寫入 payload 表
  8. 判斷是否觸發實時上報,觸發後走後續流程。
- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload {
    // 1. 檢查引數合法性
    NSString *warning = [NSString stringWithFormat:@"%@不能是空字串", type];
    if (!HCT_IS_CLASS(type, NSString)) {
        NSAssert1(HCT_IS_CLASS(type, NSString), warning, type);
        return;
    }
    if (type.length == 0) {
        NSAssert1(type.length > 0, warning, type);
        return;
    }

    if (!HCT_IS_CLASS(meta, NSDictionary)) {
        return;
    }
    if (meta.allKeys.count == 0) {
        return;
    }
    
    // 2. 判斷當前 namespace 是否開啟了收集
    if (!self.configureModel.isGather) {
        HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下資料收集開關為關閉狀態", self.namespace]);
        return ;
    }
    
    // 3. 判斷是否是有效的資料。可以落庫(type 和監控引數的介面中 monitorList 中的任一條目的type 相等)
    BOOL isValidate = [self validateLogData:type];
    if (!isValidate) {
        return;
    }

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

    // 4. 如果 payload 不存在則退出當前執行
    if (!HCT_IS_CLASS(payload, NSData) && !payload) {
        return;
    }

    // 5. 新增限制(超過大小的資料就不能入庫,因為這是服務端消耗 payload 的一個上限)
    CGFloat payloadSize = [self calculateDataSize:payload];
    if (payloadSize > self.configureModel.maxBodyMByte) {
        NSString *assertString = [NSString stringWithFormat:@"payload 資料的大小超過臨界值 %zdKB", self.configureModel.maxBodyMByte];
        NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString);
        return;
    }

    // 6. 合併 meta 與 Common 基礎資料,用來儲存 payload 上報所需要的 meta 資訊
    NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary];
    NSDictionary *commonDictionary = [commonModel getDictionary];
    // Crash 型別為特例,外部傳入的 Crash 案發現場資訊不能被覆蓋
    if ([type isEqualToString:@"appCrash"]) {
        [metaDictionary addEntriesFromDictionary:commonDictionary];
        [metaDictionary addEntriesFromDictionary:meta];
    } else {
        [metaDictionary addEntriesFromDictionary:meta];
        [metaDictionary addEntriesFromDictionary:commonDictionary];
    }

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

    // 7. 計算上報時 payload 這條資料的大小(meta+payload)
    NSMutableData *totalData = [NSMutableData data];
    [totalData appendData:metaData];
    [totalData appendData:payload];

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

    // 9. 判斷是否觸發實時上報
    [self handleUploadDataWithtype:type];
}

業務線資料儲存流程基本和監控資料的儲存差不多,有差別的是某些欄位的標示,用來區分業務線資料。

四、資料上報機制

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 main(void)
      {    FILE *fp = NULL;   
        fp = fopen("/boot/test.txt", "rw+");   
        if(NULL == fp){      
            perror("open file failed");   
            return -1;   
        }    
        while(1){      
            //do nothing       sleep(1);   
        }   
        fclose(fp);  
        return 0;
      }
    4. 命令列模式下使用 rm 刪除檔案
    5. 檢視檔案大小: df -h,發現檔案被刪除了,但是該目錄下的可用空間並未變多

    解釋:實際上,只有當一個檔案的引用計數為0(包括硬連結數)的時候,才可能呼叫 unlink 刪除,只要它不是0,那麼就不會被刪除。所謂的刪除,也不過是檔名到 inode 的連結刪除,只要不被重新寫入新的資料,磁碟上的 block 資料塊不會被刪除,因此,你會看到,即便刪庫跑路了,某些資料還是可以恢復的。換句話說,當一個程式開啟一個檔案的時候(獲取到檔案描述符),它的引用計數會被+1,rm雖然看似刪除了檔案,實際上只是會將引用計數減1,但由於引用計數不為0,因此檔案不會被刪除。

  • 在資料聚合的時候優先獲取 crash 資料,總資料條數需要小於上報配置資料的條數限制、總資料大小需要小於上報配置資料的大小限制。這裡的處理使用了遞迴,改變了函式引數

    - (void)assembleDataInTable:(HCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
        // 1. 獲取到合適的 Crash 型別的資料
        [self fetchCrashDataByCount:self.configureModel.maxFlowMByte
                            inTable:tableType
                         upperBound:self.configureModel.maxBodyMByte
                         completion:^(NSArray<HCTLogModel *> *records) {
                             NSArray<HCTLogModel *> *crashData = records;
                             // 2. 計算剩餘需要的資料條數和剩餘需要的資料大小
                             NSInteger remainingCount = self.configureModel.maxItem - crashData.count;
                             float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData];
                             // 3. 獲取除 Crash 型別之外的其他資料,且需要符合相應規則
                             BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi);
                             [self fetchDataExceptCrash:remainingCount
                                                inTable:tableType
                                             upperBound:remainingSize
                                                 isWiFI:isWifi
                                             completion:^(NSArray<HCTLogModel *> *records) {
                                                 NSArray<HCTLogModel *> *dataExceptCrash = records;
    
                                                 NSMutableArray *dataSource = [NSMutableArray array];
                                                 [dataSource addObjectsFromArray:crashData];
                                                 [dataSource addObjectsFromArray:dataExceptCrash];
                                                 if (completion) {
                                                     completion([dataSource copy]);
                                                 }
                                             }];
                         }];
    }
    
    - (void)fetchDataExceptCrash:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(float)size isWiFI:(BOOL)isWifi completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
        // 1. 根據剩餘需要資料條數去查詢表中非 Crash 型別的資料集合
        __block NSMutableArray *conditions = [NSMutableArray array];
        [self.configureModel.monitorList enumerateObjectsUsingBlock:^(HCTItemModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            if (isWifi) {
                if (![obj.type isEqualToString:@"appCrash"]) {
                    [conditions addObject:[NSString stringWithFormat:@"'%@'", obj.type]];
                }
            } else {
                if (!obj.onlyWifi && ![obj.type isEqualToString:@"appCrash"]) {
                    [conditions addObject:[NSString stringWithFormat:@"'%@'", HCT_SAFE_STRING(obj.type)]];
                }
            }
        }];
        NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type in (%@) and is_used = 0 and namespace = '%@'", [conditions componentsJoinedByString:@","], self.namespace];
    
        // 2. 根據是否有 Wifi 查詢對應的資料
        [HCT_DATABASE getRecordsByCount:count
                               condtion:queryCrashDataCondition
                            inTableType:tableType
                             completion:^(NSArray<HCTLogModel *> *_Nonnull records) {
                                 // 3. 非 Crash 型別的資料集合大小是否超過剩餘需要的資料大小
                                 float dataSize = [self calculateDataSize:records];
    
                                 // 4. 大於最大包體積則遞迴獲取 maxItem-1 條非 Crash 資料集合並判斷資料大小
                                 if (size == 0) {
                                     if (completion) {
                                         completion(records);
                                     }
                                 } else if (dataSize > size) {
                                     NSInteger currentCount = count - 1;
                                     return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion];
                                 } else {
                                     if (completion) {
                                         completion(records);
                                     }
                                 }
                             }];
    }
  • 整個 SDK 的 Unit Test 通過率 100%,程式碼分支覆蓋率為 93%。測試基於 TDD 和 BDD。測試框架:系統自帶的 XCTest,第三方的 OCMockKiwiExpectaSpecta。測試使用了基礎類,後續每個檔案都設計繼承自測試基類的類。

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

    Xcode 測試覆蓋率

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

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

五、 介面設計及核心實現

1. 介面設計

@interface HermesClient : NSObject

- (instancetype)init NS_UNAVAILABLE;

+ (instancetype)new NS_UNAVAILABLE;

/**
 單例方式初始化全域性唯一物件。單例之後必須馬上 setUp

 @return 單例物件
 */
+ (instancetype)sharedInstance;

/**
    當前 SDK 初始化。當前功能:註冊配置下發服務。
 */
- (void)setup;

/**
 上報 payload 型別的資料

 @param type 監控型別
 @param meta 後設資料
 @param payload payload型別的資料
 */
- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload;

/**
 上報 meta 型別的資料,需要傳遞三個引數。type 表明是什麼型別的資料;prefix 代表字首,上報到後臺會拼接 prefix+type;meta 是字典型別的後設資料

 @param type 資料型別
 @param prefix 資料型別的字首。一般是業務線名稱首字母簡寫。比如記賬:JZ
 @param meta description後設資料
 */
- (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta;

/**
 獲取上報相關的通用資訊

 @return 上報基礎資訊
 */
- (HCTCommonModel *)getCommon;

/**
 是否需要採集上報

 @return 上報開關
 */
- (BOOL)isGather:(NSString *)namespace;

@end

HermesClient 類是整個 SDK 的入口,也是介面的提供者。其中 - (void)sendWithType:(NSString *)type prefix:(NSString *)prefix meta:(NSDictionary *)meta; 介面給業務方使用。

- (void)sendWithType:(NSString *)type meta:(NSDictionary *)meta payload:(NSData *__nullable)payload; 給監控資料使用。

setup 方法內部開啟多個 namespace 下的處理 handler。

- (void)setup {
    // 註冊 mget 獲取監控和各業務線的配置資訊,會產生多個 namespace,彼此平行、隔離
    [[HCTConfigurationService sharedInstance] registerAndFetchConfigurationInfo];
   
    [self.configutations enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        HCTService *service = [[HCTService alloc] initWithNamespace:obj];
        [self.services setObject:service forKey:obj];
    }];
    HCTService *hermesService = [self.services objectForKey:HermesNAMESPACE];
    if (!hermesService) {
        hermesService = [[HCTService alloc] initWithNamespace:HermesNAMESPACE];
        [self.services setObject:hermesService forKey:HermesNAMESPACE];
    }
}

2. 核心實現

真正處理邏輯的是 HCTService 類。

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

@interface HCTService ()

@property (nonatomic, copy) NSString *requestBaseUrl;           /**<需要配置的baseUrl*/
@property (nonatomic, copy) HCTConfigurationModel *configureModel;  /**<當前 namespace 下的配置資訊*/
@property (nonatomic, copy) NSString *metaURL;                  /**<meta 介面地址*/
@property (nonatomic, copy) NSString *payloadURL;               /**<payload 介面地址*/
@property (nonatomic, strong) HCTRequestFactory *requester;     /**<網路請求中心*/
@property (nonatomic, strong) NSNumber *currentTimestamp;       /**<儲存的時間戳*/
@property (nonatomic, strong) NSNumber *currentFlow;            /**<當前已使用的流量*/
@property (nonatomic, strong) TMLoopTaskExecutor *taskExecutor; /**<上報資料定時任務*/
@property (nonatomic, assign) BOOL isAppLaunched;               /**<通過 KVC 的形式獲取到 HermesClient 裡面儲存 App 是否啟動完成的標識,這種 case 是處理: mget 首次獲取到 3個 namespace, 但 App 執行期間服務端新增某種 namespace, 此時業務線如果插入資料依舊可以正常落庫、上報*/
@end

@implementation HCTService

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

#pragma mark - life cycle

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


#pragma mark - public Method

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

    if (!HCT_IS_CLASS(meta, NSDictionary)) {
        return;
    }
    if (meta.allKeys.count == 0) {
        return;
    }
    
    // 2. 判斷當前 namespace 是否開啟了收集
    if (!self.configureModel.isGather) {
        HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下資料收集開關為關閉狀態", self.namespace]);
        return ;
    }
    
    // 3. 判斷是否是有效的資料。可以落庫(type 和監控引數的介面中 monitorList 中的任一條目的type 相等)
    BOOL isValidate = [self validateLogData:type];
    if (!isValidate) {
        return;
    }

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

    // 4. 如果 payload 不存在則退出當前執行
    if (!HCT_IS_CLASS(payload, NSData) && !payload) {
        return;
    }

    // 5. 新增限制(超過大小的資料就不能入庫,因為這是服務端消耗 payload 的一個上限)
    CGFloat payloadSize = [self calculateDataSize:payload];
    if (payloadSize > self.configureModel.maxBodyMByte) {
        NSString *assertString = [NSString stringWithFormat:@"payload 資料的大小超過臨界值 %zdKB", self.configureModel.maxBodyMByte];
        NSAssert(payloadSize <= self.configureModel.maxBodyMByte, assertString);
        return;
    }

    // 6. 合併 meta 與 Common 基礎資料,用來儲存 payload 上報所需要的 meta 資訊
    NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary];
    NSDictionary *commonDictionary = [commonModel getDictionary];
    // Crash 型別為特例,外部傳入的 Crash 案發現場資訊不能被覆蓋
    if ([type isEqualToString:@"appCrash"]) {
        [metaDictionary addEntriesFromDictionary:commonDictionary];
        [metaDictionary addEntriesFromDictionary:meta];
    } else {
        [metaDictionary addEntriesFromDictionary:meta];
        [metaDictionary addEntriesFromDictionary:commonDictionary];
    }

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

    // 7. 計算上報時 payload 這條資料的大小(meta+payload)
    NSMutableData *totalData = [NSMutableData data];
    [totalData appendData:metaData];
    [totalData appendData:payload];

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

    // 9. 判斷是否觸發實時上報
    [self handleUploadDataWithtype:type];
}

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

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

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

    // 2. 私有介面處理 is_biz 邏輯
    HCTCommonModel *commonModel = [[HCTCommonModel alloc] init];
    [self sendWithType:type namespace:prefix meta:meta isBiz:YES commonModel:commonModel];
}


#pragma mark - private method

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

- (void)executeHandlerWhenAppLaunched
{
    // 1. 刪除非法資料
    [self handleInvalidateData];
    // 2. 回收資料庫磁碟碎片空間
    [self rebuildDatabase];
    // 3. 開啟定時器去定時上報資料
    [self executeTimedTask];
}

/*
 1. 當 App 版本變化的時候刪除資料
 2. 刪除過期資料
 3. 刪除 Payload 表裡面超過限制的資料
 4. 刪除上傳介面網路成功,但是突發 crash 造成沒有刪除這批資料的情況,所以啟動完成後刪除 is_used = YES 的資料
 */
- (void)handleInvalidateData
{
    NSString *currentVersion = [[HermesClient sharedInstance] getCommon].APP_VERSION;
    NSString *savedVersion = [[NSUserDefaults standardUserDefaults] stringForKey:HCT_SAVED_APP_VERSION] ?: [currentVersion copy];
    
    NSInteger threshold = [NSDate HCT_currentTimestamp];
    if (![currentVersion isEqualToString:savedVersion] && self.configureModel.isUpdateClear) {
        [[NSUserDefaults standardUserDefaults] setObject:currentVersion forKey:HCT_SAVED_APP_VERSION];
    } else {
        threshold = [NSDate HCT_currentTimestamp] - self.configureModel.expirationDay * 24 * 60 * 60 *1000;
    }
    NSInteger sizeUpperLimit = self.configureModel.maxBodyMByte * 1024 * 1024;
    NSString *sqlString = [NSString stringWithFormat:@"(created_time < %zd and namespace = '%@') or size > %zd or is_used = 1", threshold, self.namespace, sizeUpperLimit];
    [HCT_DATABASE removeDataUseCondition:sqlString inTableType:HCTLogTableTypeMeta];
    [HCT_DATABASE removeDataUseCondition:sqlString inTableType:HCTLogTableTypePayload];
}

// 啟動時刻清理資料表空間碎片,回收磁碟大小
- (void)rebuildDatabase {
    [HCT_DATABASE rebuildDatabaseFileInTableType:HCTLogTableTypeMeta];
    [HCT_DATABASE rebuildDatabaseFileInTableType:HCTLogTableTypePayload];
}

// 判斷資料是否可以落庫
- (BOOL)validateLogData:(NSString *)dataType {
    NSArray<HCTItemModel *> *monitors = self.configureModel.monitorList;
    __block BOOL isValidate = NO;
    [monitors enumerateObjectsUsingBlock:^(HCTItemModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        if ([obj.type isEqualToString:dataType]) {
            isValidate = YES;
            *stop = YES;
        }
    }];
    return isValidate;
}

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

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

// 對內和對外的儲存都走這個流程。通過這個介面設定 is_biz 資訊
- (void)sendWithType:(NSString *)type namespace:(NSString *)namespace meta:(NSDictionary *)meta isBiz:(BOOL)is_biz commonModel:(HCTCommonModel *)commonModel {
    // 0. 判斷當前 namespace 是否開啟了收集
    if (!self.configureModel.isGather) {
        HCTLOG(@"%@", [NSString stringWithFormat:@"Namespace: %@ 下資料收集開關為關閉狀態", self.namespace]);
        return ;
    }
    
    // 1. 檢查引數合法性
    NSString *warning = [NSString stringWithFormat:@"%@不能是空字串", type];
    if (!HCT_IS_CLASS(type, NSString)) {
        NSAssert1(HCT_IS_CLASS(type, NSString), warning, type);
        return;
    }
    if (type.length == 0) {
        NSAssert1(type.length > 0, warning, type);
        return;
    }

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

    // 2. 判斷是否是有效的資料。可以落庫(type 和監控引數的介面中 monitorList 中的任一條目的type 相等)
    BOOL isValidate = [self validateLogData:type];
    if (!isValidate) {
        return;
    }

    // 3. 合併 meta 與 Common 基礎資料
    NSMutableDictionary *mutableMeta = [NSMutableDictionary dictionaryWithDictionary:meta];
    mutableMeta[@"MONITOR_TYPE"] = is_biz ? [NSString stringWithFormat:@"%@-%@", namespace, type] : type;
    meta = [mutableMeta copy];
    
    commonModel.IS_BIZ = is_biz;
    NSMutableDictionary *metaDictionary = [NSMutableDictionary dictionary];
    NSDictionary *commonDictionary = [commonModel getDictionary];

    // Crash 型別為特例,外部傳入的 Crash 案發現場資訊不能被覆蓋
    if ([type isEqualToString:@"appCrash"]) {
        [metaDictionary addEntriesFromDictionary:commonDictionary];
        [metaDictionary addEntriesFromDictionary:meta];
    } else {
        [metaDictionary addEntriesFromDictionary:meta];
        [metaDictionary addEntriesFromDictionary:commonDictionary];
    }

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

    // 5. 新增限制(超過 10K 的資料就不能入庫,因為這是服務端消耗 meta 的一個上限)
    CGFloat metaSize = [self calculateDataSize:metaData];
    if (metaSize > 10 / 1024.0) {
        NSAssert(metaSize <= 10 / 1024.0, @"meta 資料的大小超過臨界值 10KB");
        return;
    }

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

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

    // 7. 寫入資料庫
    [HCT_DATABASE add:@[metaModel] inTableType:HCTLogTableTypeMeta];

    // 8. 判斷是否觸發實時上報(對內的介面則在函式內部判斷,如果是對外的則在這裡判斷)
    if (is_biz) {
        [self handleUploadDataWithtype:type];
    }
}

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

/*
 計算 資料包大小,分為2種情況。
 1. 上傳前使用資料表中的 size 欄位去判斷大小
 2. 上報完成後則根據真實網路通訊中組裝的 payload 進行大小計算
 */
- (float)calculateDataSize:(id)data {
    if (HCT_IS_CLASS(data, NSArray)) {
        __block NSInteger dataLength = 0;
        NSArray *uploadDatasource = (NSArray *)data;
        [uploadDatasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
            if (HCT_IS_CLASS(obj, HCTLogModel)) {
                HCTLogModel *uploadModel = (HCTLogModel *)obj;
                dataLength += uploadModel.size;
            }
        }];
        return dataLength / (1024 * 1024.0);
    } else if (HCT_IS_CLASS(data, NSData)) {
        NSData *rawData = (NSData *)data;
        return rawData.length / (1024 * 1024.0);
    } else {
        return 0;
    }
}

// 上報流程的主函式
- (void)upload {
    /*
     1. 判斷能否上報
     2. 資料聚合
     3. 加密壓縮
     4. 1分鐘內的網路請求合併為1次
     5. 上報(全域性上報開關是開著的情況)
     - 成功:刪除本地資料、呼叫更新策略的介面
     - 失敗:不刪除本地資料
     */
    [self judgeCanUploadCompletionBlock:^(BOOL canUpload, NetworkingManagerStatusType networkType) {
        if (canUpload && self.configureModel.isUpload) {
            [self handleUploadTask:networkType];
        }
    }];
}

/**
 上報前的校驗
 - 判斷網路情況,分為 wifi 和 非 Wi-Fi 、網路不通的情況。
 - 從配置下發的 monitorList 找出 onlyWifi 欄位為 true 的 type,組成陣列 [appCrash、appLag...]
 - 網路不通,則不能上報
 - 網路通,則判斷上報校驗
 1. 當前GMT時間戳-儲存的時間戳超過24h。則認為是一個新的自然天
 - 清除 currentFlow
 - 觸發上報流程
 2. 當前GMT時間戳-儲存的時間戳不超過24h
 - 當前的流量是否超過配置資訊裡面的最大流量,未超過(<):觸發上報流程
 - 當前的流量是否超過配置資訊裡面的最大流量,超過:結束流程
 */
- (void)judgeCanUploadCompletionBlock:(void (^)(BOOL canUpload, NetworkingManagerStatusType networkType))completionBlock {
    // WIFI 的情況下不判斷直接上傳;不是 WIFI 的情況需要判斷「當日最大限制流量」
    [self.requester networkStatusWithBlock:^(NetworkingManagerStatusType status) {
        switch (status) {
            case NetworkingManagerStatusUnknown: {
                HCTLOG(@"沒有網路許可權哦");
                if (completionBlock) {
                    completionBlock(NO, NetworkingManagerStatusUnknown);
                }
                break;
            }
            case NetworkingManagerStatusNotReachable: {
                if (completionBlock) {
                    completionBlock(NO, NetworkingManagerStatusNotReachable);
                }
                break;
            }
            case NetworkingManagerStatusReachableViaWiFi: {
                if (completionBlock) {
                    completionBlock(YES, NetworkingManagerStatusReachableViaWiFi);
                }
                break;
            }
            case NetworkingManagerStatusReachableViaWWAN: {
                if ([self currentGMTStyleTimeStamp] - self.currentTimestamp.integerValue > 24 * 60 * 60 * 1000) {
                    self.currentFlow = [NSNumber numberWithFloat:0];
                    self.currentTimestamp = [NSNumber numberWithInteger:[self currentGMTStyleTimeStamp]];
                    if (completionBlock) {
                        completionBlock(YES, NetworkingManagerStatusReachableViaWWAN);
                    }
                } else {
                    if (self.currentFlow.floatValue < self.configureModel.maxFlowMByte) {
                        if (completionBlock) {
                            completionBlock(YES, NetworkingManagerStatusReachableViaWWAN);
                        }
                    } else {
                        if (completionBlock) {
                            completionBlock(NO, NetworkingManagerStatusReachableViaWWAN);
                        }
                    }
                }
                break;
            }
        }
    }];
}

- (void)handleUploadTask:(NetworkingManagerStatusType)networkType {
    // 資料聚合(2張表分別掃描) -> 壓縮 -> 上報
    [self handleUploadTaskInMetaTable:networkType];
    [self handleUploadTaskInPayloadTable:networkType];
}

- (void)handleUploadTaskInMetaTable:(NetworkingManagerStatusType)networkType {
    __weak typeof(self) weakself = self;
    // 1. 資料聚合
    [self assembleDataInTable:HCTLogTableTypeMeta
                  networkType:networkType
                   completion:^(NSArray<HCTLogModel *> *records) {
                       if (records.count == 0) {
                           return;
                       }
                       // 2. 加密壓縮處理:(meta 整體先加密再壓縮,payload一條條先加密再壓縮)
                       __block NSMutableString *metaStrings = [NSMutableString string];
                       __block NSMutableArray *usedReportIds = [NSMutableArray array];
               
                       // 2.1. 遍歷拼接model,取出 meta,用 \n 拼接
                       [records enumerateObjectsUsingBlock:^(HCTLogModel *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
                           if (HCT_IS_CLASS(obj, HCTLogMetaModel)) {
                               HCTLogMetaModel *metaModel = (HCTLogMetaModel *)obj;
                               BOOL shouldAppendLineBreakSymbol = idx < (records.count - 1);
                               [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", metaModel.report_id]];
                               [metaStrings appendString:[NSString stringWithFormat:@"%@%@", metaModel.meta, shouldAppendLineBreakSymbol ? @"\n" : @""]];
                           }
                       }];
                       if (metaStrings.length == 0) {
                           return;
                       }
                       // 2.2 拼接後的內容先壓縮再加密
                       NSData *data = [HCTDataSerializer compressAndEncryptWithString:metaStrings];
        
                      // 3. 將取出來用於介面請求的資料標記為 dirty
                      NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]];
                     [[HCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:HCTLogTableTypeMeta];

                       // 4. 請求網路
                       NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.metaURL];

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

- (NSData *)handlePayloadData:(NSArray *)rawArray {
    // 1. 資料校驗
    if (rawArray.count == 0) {
        return nil;
    }
    // 2. 加密壓縮處理:(meta 整體先加密再壓縮,payload一條條先加密再壓縮)
    __block NSMutableString *metaStrings = [NSMutableString string];
    __block NSMutableArray<NSData *> *payloads = [NSMutableArray array];

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

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

            // 2.2 判斷是否需要上傳 payload 資訊。如果需要則將 payload 取出。
            if ([self needUploadPayload:payloadModel]) {
                if (payloadModel.payload) {
                    NSData *payloadData = [HCTDataSerializer compressAndEncryptWithData:payloadModel.payload];
                    if (payloadData) {
                        [payloads addObject:payloadData];
                    }
                }
            }
        }
    }];

    NSData *metaData = [HCTDataSerializer compressAndEncryptWithString:metaStrings];

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

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

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

    __block NSMutableData *uploadData = [NSMutableData data];
    // 先新增 header 基礎資訊,不需要加密壓縮
    [uploadData appendData:[headerData copy]];
    // 再新增 meta 資訊,meta 資訊需要先壓縮再加密
    [uploadData appendData:metaData];
    // 再新增 payload 資訊
    [payloads enumerateObjectsUsingBlock:^(NSData *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        [uploadData appendData:obj];
    }];
    return [uploadData copy];
}

- (void)handleUploadTaskInPayloadTable:(NetworkingManagerStatusType)networkType {
    __weak typeof(self) weakself = self;
    // 1. 資料聚合
    [self assembleDataInTable:HCTLogTableTypePayload
                  networkType:networkType
                   completion:^(NSArray<HCTLogModel *> *records) {
                       if (records.count == 0) {
                           return;
                       }
                       // 2. 取出可以上傳的 payload 資料
                       NSArray *canUploadPayloadData = [self fetchDataCanUploadPayload:records];
                       
                       if (canUploadPayloadData.count == 0) {
                           return;
                       }
        
                    // 3. 將取出來用於介面請求的資料標記為 dirty
                    __block NSMutableArray *usedReportIds = [NSMutableArray array];
                    [canUploadPayloadData enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                        if (HCT_IS_CLASS(obj, HCTLogModel)) {
                            HCTLogModel *model = (HCTLogModel *)obj;
                            [usedReportIds addObject:[NSString stringWithFormat:@"'%@'", model.report_id]];
                        }
                    }];
                    NSString *updateCondtion = [NSString stringWithFormat:@"report_id in (%@)", [usedReportIds componentsJoinedByString:@","]];
        
                    [[HCTDatabase sharedInstance] updateData:@"is_used = 1" useCondition:updateCondtion inTableType:HCTLogTableTypePayload];
        
                        // 4. 將取出的資料聚合,組成報文
                       NSData *uploadData = [self handlePayloadData:canUploadPayloadData];

                       // 5. 請求網路
                       NSString *requestURL = [NSString stringWithFormat:@"%@/%@", weakself.requestBaseUrl, weakself.payloadURL];

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

// 清除過期資料
- (void)deleteInvalidateData:(NSArray<HCTLogModel *> *)data inTableType:(HCTLogTableType)tableType {
    [HCT_DATABASE remove:data inTableType:tableType];
}

// 以秒為單位的時間戳
- (NSInteger)currentGMTStyleTimeStamp {
    return [NSDate HCT_currentTimestamp]/1000;
}

#pragma mark-- 資料庫操作

/**
 根據介面配置資訊中的條件獲取表中的上報資料
 - Wi-Fi 的時候都上報
 - 不為 Wi-Fi 的時候:onlyWifi 為 false 的型別進行上報
 */
- (void)assembleDataInTable:(HCTLogTableType)tableType networkType:(NetworkingManagerStatusType)networkType completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
    // 1. 獲取到合適的 Crash 型別的資料
    [self fetchCrashDataByCount:self.configureModel.maxFlowMByte
                        inTable:tableType
                     upperBound:self.configureModel.maxBodyMByte
                     completion:^(NSArray<HCTLogModel *> *records) {
                         NSArray<HCTLogModel *> *crashData = records;
                         // 2. 計算剩餘需要的資料條數和剩餘需要的資料大小
                         NSInteger remainingCount = self.configureModel.maxItem - crashData.count;
                         float remainingSize = self.configureModel.maxBodyMByte - [self calculateDataSize:crashData];
                         // 3. 獲取除 Crash 型別之外的其他資料,且需要符合相應規則
                         BOOL isWifi = (networkType == NetworkingManagerStatusReachableViaWiFi);
                         [self fetchDataExceptCrash:remainingCount
                                            inTable:tableType
                                         upperBound:remainingSize
                                             isWiFI:isWifi
                                         completion:^(NSArray<HCTLogModel *> *records) {
                                             NSArray<HCTLogModel *> *dataExceptCrash = records;

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


- (NSArray *)fetchDataCanUploadPayload:(NSArray *)datasource {
    __weak typeof(self) weakself = self;
    __block NSMutableArray *array = [NSMutableArray array];
    if (!HCT_IS_CLASS(datasource, NSArray)) {
        NSAssert(HCT_IS_CLASS(datasource, NSArray), @"引數必須是陣列");
        return nil;
    }
    [datasource enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
        if (HCT_IS_CLASS(obj, HCTLogPayloadModel)) {
            HCTLogPayloadModel *payloadModel = (HCTLogPayloadModel *)obj;
            // 判斷是否需要上傳 payload 資訊
            if ([weakself needUploadPayload:payloadModel]) {
                [array addObject:payloadModel];
            }
        }
    }];
    return [array copy];
}

// 遞迴獲取符合條件的 Crash 資料集合(count < maxItem && size < maxBodySize)
- (void)fetchCrashDataByCount:(NSInteger)count inTable:(HCTLogTableType)tableType upperBound:(NSInteger)size completion:(void (^)(NSArray<HCTLogModel *> *records))completion {
    // 1. 先通過介面拿到的 maxItem 數去查詢表中的 Crash 資料集合
    NSString *queryCrashDataCondition = [NSString stringWithFormat:@"monitor_type = 'appCrash' and is_used = 0 and namespace = '%@'", self.namespace];
    [HCT_DATABASE getRecordsByCount:count
                           condtion:queryCrashDataCondition
                        inTableType:tableType
                         completion:^(NSArray<HCTLogModel *> *_Nonnull records) {
                             // 2. Crash 資料集合大小是否超過配置介面拿到的最大包體積(單位M) maxBodySize
                             float dataSize = [self calculateDataSize:records];

                             // 3. 大於最大包體積則遞迴獲取 maxItem-- 條 Crash 資料集合並判斷資料大小
                             if (size == 0) {
                                 if (completion) {
                                     completion(records);
                                 }
                             } else if (dataSize > size) {
                                 NSInteger currentCount = count - 1;
                                 [self fetchCrashDataByCount:currentCount inTable:tableType upperBound:size completion:completion];
                             } else {
                                 if (completion) {
                                     completion(records);
                                 }
                             }
                         }];
}

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

    // 2. 根據是否有 Wifi 查詢對應的資料
    [HCT_DATABASE getRecordsByCount:count
                           condtion:queryCrashDataCondition
                        inTableType:tableType
                         completion:^(NSArray<HCTLogModel *> *_Nonnull records) {
                             // 3. 非 Crash 型別的資料集合大小是否超過剩餘需要的資料大小
                             float dataSize = [self calculateDataSize:records];

                             // 4. 大於最大包體積則遞迴獲取 maxItem-1 條非 Crash 資料集合並判斷資料大小
                             if (size == 0) {
                                 if (completion) {
                                     completion(records);
                                 }
                             } else if (dataSize > size) {
                                 NSInteger currentCount = count - 1;
                                 return [self fetchDataExceptCrash:currentCount inTable:tableType upperBound:size isWiFI:isWifi completion:completion];
                             } else {
                                 if (completion) {
                                     completion(records);
                                 }
                             }
                         }];
}


#pragma mark - getters and setters

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

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

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

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

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

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

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

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

@end

六、 總結與思考

1. 技術方面

多執行緒技術很強大,但是很容易出問題。普通做業務的時候用一些簡單的 GCD、NSOperation 等就可以滿足基本需求了,但是做 SDK 就不一樣,你需要考慮各種場景。比如 FMDB 在多執行緒讀寫的時候,設計了 FMDatabaseQueue 以序列佇列的方式同步執行任務。但是這樣一來假如使用者在主執行緒插入 n 次資料到資料庫,這樣會發生 ANR,所以我們還得維護一個任務派發佇列,用來維護業務方提交的任務,是一個併發佇列,以非同步任務的方式提交給 FMDB 以同步任務的方式在序列佇列上執行。

AFNetworking 2.0 使用了 NSURLConnection,同時維護了一個常駐執行緒,去處理網路成功後的回撥。AF 存在一個常駐執行緒,假如其他 n 個 SDK 的其中 m 個 SDK 也開啟了常駐執行緒,那你的 App 整合後就有 1+m 個常駐執行緒。

AFNetworking 3.0 使用 NSURLSession 替換 NSURLConnection,取消了常駐執行緒。為什麼換了? ? 逼不得已呀,Apple 官方出了 NSURLSession,那就不需要 NSURLConnection,併為之建立常駐執行緒了。至於為什麼 NSURLSession 不需要常駐執行緒?它比 NSURLConnecction 多做了什麼,以後再聊

建立執行緒的過程,需要用到實體記憶體,CPU 也會消耗時間。新建一個執行緒,系統會在該程式空間分配一定的記憶體作為執行緒堆疊。堆疊大小是 4KB 的倍數。在 iOS 主執行緒堆疊大小是 1MB,新建立的子執行緒堆疊大小是 512KB。此外執行緒建立得多了,CPU 在切換執行緒上下文時,還會更新暫存器,更新暫存器的時候需要定址,而定址的過程有 CPU 消耗。執行緒過多時記憶體、CPU 都會有大量的消耗,出現 ANR 甚至被強殺。

舉了 ? 是 FMDB 和 AFNetworking 的作者那麼厲害,設計的 FMDB 不包裝會 ANR,AFNetworking 必須使用常駐執行緒,為什麼?正是由於多執行緒太強大、靈活了,開發者騷操作太多,所以 FMDB 設計最簡單保證資料庫操作執行緒安全,具體使用可以自己維護佇列去包一層。AFNetworking 內的多執行緒也嚴格基於系統特點來設計。

所以有必要再研究下多執行緒,建議讀 GCD 原始碼,也就是 libdispatch

2. 規範方面

很多開發都不做測試,我們公司都嚴格約定測試。寫基礎 SDK 更是如此,一個 App 基礎功能必須質量穩定,所以測試是保證手段之一。一定要寫好 Unit Test。這樣子不斷版本迭代,對於 UT,輸入恆定,輸出恆定,這樣內部實現如何變動不需要關心,只需要判斷恆定輸入,恆定輸出就足夠了。(針對每個函式單一原則的基礎上也是滿足 UT)。還有一個好處就是當和別人討論的的時候,你畫個技術流程圖、技術架構圖、測試的 case、測試輸入、輸出表述清楚,聽的人再看看邊界情況是否都考慮全,基本上很快溝通完畢,效率考高。

在做 SDK 的介面設計的時候,方法名、引數個數、引數型別、引數名稱、返回值名稱、型別、資料結構,儘量要做到 iOS 和 Android 端一致,除非某些特殊情況,無法保證一致的輸出。別問為什麼?好處太多了,成熟 SDK 都這麼做。

比如一個資料上報 SDK。需要考慮資料來源是什麼,我設計的介面需要暴露什麼資訊,資料如何高效儲存、資料如何校驗、資料如何高效及時上報。 假如我做的資料上報 SDK 可以上報 APM 監控資料、同時也開放能力給業務線使用,業務線自己將感興趣的資料並寫入儲存,保證不丟失的情況下如何高效上報。因為資料實時上報,所以需要考慮上傳的網路環境、Wi-Fi 環境和 4G 環境下的邏輯不一樣的、資料聚合組裝成自定義報文並上報、一個自然天內資料上傳需要做流量限制等等、App 版本升級一些資料可能會失去意義、當然儲存的資料也存在時效性。種種這些東西就是在開發前需要考慮清楚的。所以基礎平臺做事情基本是 設計思考時間:編碼時間 = 7:3

為什麼?假設你一個需求,預期10天時間;前期架構設計、類的設計、Uint Test 設計估計7天,到時候編碼開發2天完成。 這麼做的好處很多,比如:

  1. 除非是非常優秀,不然腦子想的再前面到真正開發的時候發現有出入,coding 完發現和前期方案設計不一樣。所以建議用流程圖、UML圖、技術架構圖、UT 也一樣,設計個表格,這樣等到時候編碼也就是 coding 的工作了,將圖翻譯成程式碼
  2. 後期和別人討論或者溝通或者 CTO 進行 code review 的時候不需要一行行看程式碼。你將相關的架構圖、流程圖、UML 圖給他看看。他再看看一些關鍵邏輯的 UT,保證輸入輸出正確,一般來說這樣就夠了

3. 質量保證

UT 是質量保證的一個方面,另一個就是 MR 機制。我們團隊 MR 採用 +1 機制。每個 merge request 必須有團隊內至少3個人 +1,且其中一人必須為同技術棧且比你資深一些的同事 +1,一人為和你參加同一個專案的同事。

當有人評論或者有疑問時,你必須解答清楚,別人提出的修改點要麼修改好,要麼解釋清楚,才可以 +1。當 +1 數大於3,則合併分支程式碼。

連帶責任制。當你的線上程式碼存在 bug 時,為你該次 MR +1 的同事具有連帶責任。

參考資料

相關文章