源碼淺析 - CocoaLumberjack 3.6 之 FileLogger

DDFileLogger

繼續(xù)上一篇:CocoaLumberjack 之 DDLog,重點(diǎn)介紹了 lumberjack 的核心管理類 DDLog 以及兩個(gè)核心協(xié)議 DDLoggerDDLogFormatter。還涉及了基于 DDLogger 協(xié)議的抽象類 DDAbstractLogger,以及基于 DDAbstractLogger 派生的分別針對(duì)系統(tǒng)日志 API ASLos_log 的封裝類 DDASLLoggerDDOSLogger。

本文將會(huì)繼續(xù)介紹基于 DDLogger 的應(yīng)用類 DDFileLogger

Log File

關(guān)于日志文件,這里貼一下 wiki 描述:

In computing, a log file is a file that records either events that occur in an operating system or other software runs,[1] or messages between different users of a communication software.

可能部分新手同學(xué)對(duì)日志文件的重要性沒有很強(qiáng)的認(rèn)識(shí),尤其是移動(dòng)端。畢竟,我們大部分的時(shí)間 force 在 crash log、console log 和 event log 中,而這些 log 基本上是以日志文件來存儲(chǔ)。除此之外,我們可能也會(huì)主動(dòng)添加一些關(guān)鍵節(jié)點(diǎn)的日志,以方便定位和解決問題。因此,如何保證日志文件的的完整性和準(zhǔn)確性就非常重要了。

對(duì)于 logging file 簡單能聯(lián)想到的有兩點(diǎn):

  1. log message 的文件寫入,以及何時(shí)進(jìn)行滾動(dòng)地記錄文件;
  2. 日志文件管理,當(dāng)日志寫入結(jié)束后需要考慮文件壓縮以節(jié)約磁盤,以及日志上傳。

剛好分別對(duì)應(yīng)了 DDFileLogger 主要涉及文件寫入,DDLogFileManager 負(fù)責(zé)文件管理。

Init

先看初始化方式:

- (instancetype)initWithLogFileManager:(id <DDLogFileManager>)logFileManager
                       completionQueue:(nullable dispatch_queue_t)dispatchQueue;

作為 NS_DESIGNATED_INITIALIZER,logFileManager 是必須要提供的,如果直接通過 - (**instancetype**)init 初始化會(huì)主動(dòng) new 出 DDLogFileManagerDefault 當(dāng)作默認(rèn)值。completionQueue 默認(rèn)為 DEFAULT 優(yōu)先級(jí),完整實(shí)現(xiàn)如下:

_completionQueue = dispatchQueue ?: dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

_maximumFileSize = kDDDefaultLogMaxFileSize;
_rollingFrequency = kDDDefaultLogRollingFrequency;
_automaticallyAppendNewlineForCustomFormatters = YES;

_logFileManager = aLogFileManager;
_logFormatter = [DDLogFileFormatterDefault new];

File Rolling

也稱為 log rotation,wiki 解釋:

In information technology, log rotation is an automated process used in system administration in which log files are compressed, moved (archived), renamed or deleted once they are too old or too big (there can be other metrics that can apply here). New incoming log data is directed into a new fresh file (at the same location)[1].

日志輪替算是系統(tǒng)級(jí)別的常規(guī)操作策略,在 Linux 中是有專門的命令 logrotate 來實(shí)現(xiàn),macOS 上對(duì)應(yīng)的則是 newsyslog。根據(jù) mac manual 文檔說明,log 文件的輪替歸檔需要滿足三個(gè)條件:

  1. It is larger than the configured size (in kilobytes).
  2. A configured number of hours have elapsed since the log was last archived.
  3. This is the specific configured hour for rotation of the log.

對(duì)應(yīng) DDFileLogger 中剛好兩個(gè)屬性:

@property (readwrite, assign) unsigned long long maximumFileSize;

@property (readwrite, assign) NSTimeInterval rollingFrequency;

lumberjack 中輪替相關(guān)的默認(rèn)值如下:

/// 默認(rèn)日志文件 size 上限
unsigned long long const kDDDefaultLogMaxFileSize      = 1024 * 1024; // 1 MB
/// 默認(rèn)日志文件分割間隔(執(zhí)行輪替的間隔)
NSTimeInterval     const kDDDefaultLogRollingFrequency = 60 * 60 * 24; // 24 Hours
/// 默認(rèn)最大日志文件分割數(shù)量
NSUInteger         const kDDDefaultLogMaxNumLogFiles   = 5; // 5 Files
/// 默認(rèn)日志文件整體磁盤配額
unsigned long long const kDDDefaultLogFilesDiskQuota   = 20 * 1024 * 1024; // 20 MB
/// 日志文件滾動(dòng)計(jì)時(shí)器更新頻率
NSTimeInterval     const kDDRollingLeeway              = 1.0; // 1s

對(duì)于 maximumFileSizerollingFrequency ,它們兩個(gè)條件只要滿足一者就會(huì)觸發(fā) log rolling。需要注意的是,一旦觸發(fā) rolling 后,會(huì)重置這兩個(gè)狀態(tài)。例如:rollingFrequency 默認(rèn)為 24 h,但是 log file 僅在 20 h 的時(shí)候就超過了 maximumFileSize 限制,那么就會(huì)觸發(fā) rolling,并重啟一個(gè) 24 h 的 timer。

如果希望僅按照 rollingFrequency 作為控制條件,可以設(shè)置 maximumFileSize 為 zero。同理,可以設(shè)置 rollingFrequency 為 zero 來達(dá)到 disable 的作用。

另外,rolling 中還提供了 doNotReuseLogFiles 來控制,是否允許復(fù)用上一次運(yùn)行時(shí)寫入的 log file。默認(rèn)為

NO,如果設(shè)置為 YES,則每啟動(dòng)都會(huì)新生成一次 log file。

lt_maybeRollLogFileDueToSize

先來看看 rollLogFile by size 的情況。當(dāng)修改 maximumFileSize 時(shí)會(huì)觸發(fā) lt_maybeRollLogFileDueToSize,setMaximumFileSize: 實(shí)現(xiàn)如下:

dispatch_block_t block = ^{
    @autoreleasepool {
        self->_maximumFileSize = newMaximumFileSize;
        [self lt_maybeRollLogFileDueToSize];
    }
};
NSAssert(![self isOnGlobalLoggingQueue], @"Core architecture requirement failure");
NSAssert(![self isOnInternalLoggerQueue], @"MUST access ivar directly, NOT via self.* syntax.");

dispatch_queue_t globalLoggingQueue = [DDLog loggingQueue];

dispatch_async(globalLoggingQueue, ^{
    dispatch_async(self.loggerQueue, block);
});

最終會(huì)在 loggerQueue 中調(diào)用 block 以觸發(fā) lt_maybeRollLogFileDueToSize。上述代碼為何要通過兩層的 queue 的嵌套以及 loggingQueue 和 loggerQueue 的說明都在上一篇又詳細(xì)的解釋。來看 lt_maybeRollLogFileDueToSize 實(shí)現(xiàn):

NSAssert([self isOnInternalLoggerQueue], @"lt_ methods should be on logger queue.");
if (_maximumFileSize > 0) {
    unsigned long long fileSize = [_currentLogFileHandle offsetInFile];

    if (fileSize >= _maximumFileSize) {
        NSLogVerbose(@"DDFileLogger: Rolling log file due to size (%qu)...", fileSize);

        [self lt_rollLogFileNow];
    }
}
  1. 首頁是斷言對(duì) loggerQueue 的環(huán)境檢查;
  2. 作者通過對(duì) _maximumFileSize > 0 來控制是否開啟 log 大小檢查。_currentLogFileHandle 是當(dāng)前所寫入 log file 的文件操作符(之后簡稱為 fd :file descriptor)為 NSFileHandle 類;
  3. 當(dāng)文件超限時(shí),執(zhí)行 lt_rollLogFileNow

lt_maybeRollLogFileDueToAge

setMaximumFileSize: 類似,修改 rollingFrequency 會(huì)在 block 中觸發(fā) lt_maybeRollLogFileDueToAge

NSAssert([self isOnInternalLoggerQueue], @"lt_ methods should be on logger queue.");

if (_rollingFrequency > 0.0 && (_currentLogFileInfo.age + kDDRollingLeeway) >= _rollingFrequency) {
    NSLogVerbose(@"DDFileLogger: Rolling log file due to age...");
    [self lt_rollLogFileNow];
} else {
    [self lt_scheduleTimerToRollLogFileDueToAge];
}

同樣是檢查環(huán)境,檢查 _rollingFrequency > 0.0 以及 log file 的創(chuàng)建時(shí)間是否超限。如果輪替時(shí)間超限則開始輪替。否則會(huì)重置 rollingTimer 下一次輪替的 delay 時(shí)間。

lt_scheduleTimerToRollLogFileDueToAge

這里的定時(shí)器使用的是 dispatch_source_t 。首先將當(dāng)前 timer invalid 然后檢查 _currentLogFileInfo 和 _rollingFrequency:

if (_rollingTimer) {
    dispatch_source_cancel(_rollingTimer);
    _rollingTimer = NULL;
}
if (_currentLogFileInfo == nil || _rollingFrequency <= 0.0) {
    return;
}

然后是重新生成 timer 并設(shè)置 event handler:

  1. 獲取文件創(chuàng)建時(shí)間,計(jì)算下一次輪替的觸發(fā)時(shí)間 logFileRollingDate;

    NSDate *logFileCreationDate = [_currentLogFileInfo creationDate];
    NSTimeInterval frequency = MIN(_rollingFrequency, DBL_MAX - [logFileCreationDate timeIntervalSinceReferenceDate]);
    NSDate *logFileRollingDate = [logFileCreationDate dateByAddingTimeInterval:frequency];
    
  2. 依據(jù) logFileRollingDate 和當(dāng)前時(shí)間計(jì)算dely,初始化 _rollingTimer 并設(shè)置 evenhandler;

    _rollingTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, _loggerQueue);
    __weak __auto_type weakSelf = self;
    dispatch_source_set_event_handler(_rollingTimer, ^{ @autoreleasepool {
        [weakSelf lt_maybeRollLogFileDueToAge];
    } });
    //... 兼容 MRC,設(shè)置 dispatch_source_t release 回調(diào)
    
  3. 設(shè)置 kDDRollingLeeway 作為定時(shí)器刷新間隔,delay 為觸發(fā)時(shí)間,開始計(jì)時(shí);

    static NSTimeInterval const kDDMaxTimerDelay = LLONG_MAX / NSEC_PER_SEC;
    int64_t delay = (int64_t)(MIN([logFileRollingDate timeIntervalSinceNow], kDDMaxTimerDelay) * (NSTimeInterval) NSEC_PER_SEC);
    dispatch_time_t fireTime = dispatch_time(DISPATCH_TIME_NOW, delay);
    
    dispatch_source_set_timer(_rollingTimer, fireTime, DISPATCH_TIME_FOREVER, (uint64_t)kDDRollingLeeway * NSEC_PER_SEC);
    
    if (@available(macOS 10.12, iOS 10.0, tvOS 10.0, watchOS 3.0, *))
        dispatch_activate(_rollingTimer);
    else
        dispatch_resume(_rollingTimer);
    

lt_rollLogFileNow

整個(gè) fileLogger 的文件寫入操作均基于 fd,而 fd 的獲取是通過 lazy 的方式。如果 fd 為空, rollLogFile will do nothing。而日志輪替做的事情也比較清晰:

  1. 同步并關(guān)閉 fd,同時(shí)將文件標(biāo)記為 Archived;
  2. 向 fileManger 發(fā)送日志輪替通知;
  3. 清理 log file 文件變更狀態(tài)監(jiān)聽,invalid rollingTime;
if (_currentLogFileHandle == nil) { return; }

[_currentLogFileHandle synchronizeFile];
[_currentLogFileHandle closeFile];
_currentLogFileHandle = nil;

_currentLogFileInfo.isArchived = YES;
BOOL logFileManagerRespondsToSelector = [_logFileManager respondsToSelector:@selector(didRollAndArchiveLogFile:)];
NSString *archivedFilePath = (logFileManagerRespondsToSelector) ? [_currentLogFileInfo.filePath copy] : nil;
_currentLogFileInfo = nil;

if (logFileManagerRespondsToSelector) {
    dispatch_async(_completionQueue, ^{
        [self->_logFileManager didRollAndArchiveLogFile:archivedFilePath];
    });
}

if (_currentLogFileVnode) {
    dispatch_source_cancel(_currentLogFileVnode);
    _currentLogFileVnode = nil;
}

if (_rollingTimer) {
    dispatch_source_cancel(_rollingTimer);
    _rollingTimer = nil;
}

isArchived 在 fileInfo 中是如何保存這個(gè)狀態(tài)的呢?

這里利用系統(tǒng) API <sys/xattr.h>setxattr方法將該 flag 直接保存在文件描述中,getxattr 用來獲取 flag,removexattr 用于刪除 flag。這個(gè)在年初的文章 《源碼淺析 SDWebImage 5.6》 中也提到過,SD 也是用它來存儲(chǔ)額外信息的。

DDLogFileInfo

前面的代碼中已經(jīng)接觸過部分 fileInfo 的 property 了,正式介紹一下:

A simple class that provides access to various file attributes. It provides good performance as it only fetches the information if requested, and it caches the information to prevent duplicate fetches.

可以說 fileInfo 是保存了 log file 的首次訪問時(shí)的快照,它追求的是性能而非時(shí)時(shí)性。最關(guān)鍵的屬性是 fileAttributes

@property (strong, nonatomic, readonly) NSDictionary<NSFileAttributeKey, id> *fileAttributes;

creationDatemodificationDate、fileSizeage 均通過 NSFileAttributeKey 從它這獲取的。既然是 lazy 又不更新,fileLogger 又是通過什么方式來準(zhǔn)確獲取真正的 fileSize 和增量更新 log file 呢?答案是 file descriptor

method desc
offsetInFile 獲取文件大小
synchronizeFile 內(nèi)存數(shù)據(jù)寫入磁盤
closeFile 關(guān)閉文件
seekToEndOfFile 將文件指針移動(dòng)的末尾

可見 fileLogger 始終通過唯一的 fd 來操作文件,從而提高讀寫效率。當(dāng)然,還有更快的就是使用 mmap,像美團(tuán)的 logan 和微信的 xlog。

最后,對(duì)于 setxattr、getxattr、removexattr 操作 fileInfo 提高了 convene method:

- (BOOL)hasExtendedAttributeWithName:(NSString *)attrName;
- (void)addExtendedAttributeWithName:(NSString *)attrName;
- (void)removeExtendedAttributeWithName:(NSString *)attrName;

DDLogger Protocol

logMessage:

將 log message 寫入文件,經(jīng)過 lt_dataForMessage 將 log mesage 轉(zhuǎn)化為 NSData,最終調(diào)用 lt_logData:。

flush

先經(jīng)過 loggingQueue 和 loggerQueue 最終調(diào)用 block 內(nèi)部的 lt_flush,而 lt_flush 就一行代碼:

[_currentLogFileHandle synchronizeFile];

lt_logData

將 log message 轉(zhuǎn)化過的 NSData 寫入 file,代碼如下:

@try {
    NSFileHandle *handle = [self lt_currentLogFileHandle];
    [handle seekToEndOfFile];
    [handle writeData:data];
} @catch (NSException *exception) {
    exception_count++;
    if (exception_count <= 10) {
        NSLogError(@"DDFileLogger.logMessage: %@", exception);
        if (exception_count == 10) {
            NSLogError(@"DDFileLogger.logMessage: Too many exceptions -- will not log any more of them.");
        }
    }
}

核心代碼就三行,但是可以看到 lumberjack 的容錯(cuò)做的真心好,當(dāng)異常數(shù)過多,就不停止輸出了。

剩下的代碼是對(duì) deprecated 的 API 的兼容,算是目前看過對(duì) deprecated API 十分友好的 lib 了。

首先對(duì)舊的 willLogMessagedidLogMessage 而言,新提供的 API 是增加了 fileInfo 作為返回值。然后用 dispatch_once_t 來避免多次響應(yīng)者查詢,以優(yōu)化代碼,畢竟 logMessage 可是一個(gè)高頻調(diào)用的 API。

static BOOL implementsDeprecatedWillLog = NO;

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
implementsDeprecatedWillLog = [self respondsToSelector:@selector(willLogMessage)];
});

if (implementsDeprecatedWillLog) {
    [self willLogMessage];
} else {
    [self willLogMessage:_currentLogFileInfo];
}

同時(shí)還利用消息轉(zhuǎn)發(fā),將過期方法轉(zhuǎn)移至 dummyMethod 避免 unrecognized selector sent to instance crash

- (void)dummyMethod {}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    if (aSelector == @selector(willLogMessage) || aSelector == @selector(didLogMessage)) {
        // Ignore calls to deprecated methods.
        return [self methodSignatureForSelector:@selector(dummyMethod)];
    }
    return [super methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    if (anInvocation.selector != @selector(dummyMethod)) {
        [super forwardInvocation:anInvocation];
    }
}

File Logging

整個(gè) file logging 相關(guān)方法是對(duì) fileHandle 和 fileInfo 的各種狀態(tài)判斷以及更新。

lt_currentLogFileHandle

_currentLogFileHandle 通過 layze 方式獲取。當(dāng) lt_rollLogFileNow 成功后會(huì)將 _currentLogFileHandle 置 nil。創(chuàng)建邏輯如下:

NSString *logFilePath = [[self lt_currentLogFileInfo] filePath];
_currentLogFileHandle = [NSFileHandle fileHandleForWritingAtPath:logFilePath];
[_currentLogFileHandle seekToEndOfFile];

if (_currentLogFileHandle) {
    [self lt_scheduleTimerToRollLogFileDueToAge];
    [self lt_monitorCurrentLogFileForExternalChanges];
}
  1. 通過 lt_currentLogFileInfo 獲取 filePath 生成 _currentLogFileHandle 并將文件指針置文章末尾;
  2. 創(chuàng)建成功后重置 rolling 定時(shí)器,并開啟 GCD 監(jiān)聽 _currentLogFileHandle;

lt_monitorCurrentLogFileForExternalChanges

先是 _currentLogFileHandle 是否為空的斷言,接著是設(shè)置 dispatch_source_vnode_flags_t 添加 event handler 回調(diào):

dispatch_source_vnode_flags_t flags = DISPATCH_VNODE_DELETE | DISPATCH_VNODE_RENAME | DISPATCH_VNODE_REVOKE;
_currentLogFileVnode = dispatch_source_create(DISPATCH_SOURCE_TYPE_VNODE, (uintptr_t)[_currentLogFileHandle fileDescriptor], flags, _loggerQueue);

__weak __auto_type weakSelf = self;
dispatch_source_set_event_handler(_currentLogFileVnode, ^{ @autoreleasepool {
    NSLogInfo(@"DDFileLogger: Current logfile was moved. Rolling it and creating a new one");
    [weakSelf lt_rollLogFileNow];
} });
//... 兼容 MRC,設(shè)置 dispatch_source_t release 回調(diào)

if (@available(macOS 10.12, iOS 10.0, tvOS 10.0, watchOS 3.0, *))
    dispatch_activate(_currentLogFileVnode);
else
    dispatch_resume(_currentLogFileVnode);

lt_currentLogFileInfo

獲取當(dāng)前是否存在可用的 fileInfo。檢查條件如下:

  1. 取出 logsDirectory 中的 log 文件列表,并轉(zhuǎn)化為 DDLogFileInfo。文件需要滿足以 .log 結(jié)尾;
  2. 對(duì)轉(zhuǎn)化后的 fileInfo 進(jìn)行排序,取出最近時(shí)間的做為 newCurrentLogFile;
  3. 如果 newCurrentLogFile 存在,檢查其可用性。如不可用則會(huì)通過 fileManger 創(chuàng)建新的 fileInfo;

實(shí)現(xiàn)這里就不貼出了,接著來看檢查條件。

lt_shouldUseLogFile: isResuming

isResuming 是從對(duì)上一步 newCurrentLogFile 可用性的判斷中傳入的參數(shù):

// Check if we're resuming and if so, get the first of the sorted log file infos.
BOOL isResuming = newCurrentLogFile == nil;
  1. 先判定文件是否為已歸檔文件,已歸檔則不可用;

  2. 如果 isResuming 為 YES,即 fileInfo 是從磁盤文件中復(fù)用的,需要檢查兩個(gè)狀態(tài):

    1. _doNotReuseLogFiles 是否允許復(fù)用上次運(yùn)行的 log file;
    2. 通過 lt_shouldLogFileBeArchived 檢查檢查文件歸檔狀態(tài);

    一旦,_doNotReuseLogFiles 為 YES 或 文件已滿足歸檔條件,則設(shè)置 logFileInfo.isArchived = YES,并通知 fileManager。

  3. 滿足條件返回 YES;

lt_shouldLogFileBeArchived

if (mostRecentLogFileInfo.isArchived) {
    return NO;
} else if ([self shouldArchiveRecentLogFileInfo:mostRecentLogFileInfo]) {
    return YES;
} else if (_maximumFileSize > 0 && mostRecentLogFileInfo.fileSize >= _maximumFileSize) {
    return YES;
} else if (_rollingFrequency > 0.0 && mostRecentLogFileInfo.age >= _rollingFrequency) {
    return YES;
}
#if TARGET_OS_IPHONE
    if (doesAppRunInBackground()) {
        NSFileProtectionType key = mostRecentLogFileInfo.fileAttributes[NSFileProtectionKey];
        BOOL isUntilFirstAuth = [key isEqualToString:NSFileProtectionCompleteUntilFirstUserAuthentication];
        BOOL isNone = [key isEqualToString:NSFileProtectionNone];

        if (key != nil && !isUntilFirstAuth && !isNone) {
            return YES;
        }
    }
#endif
return NO;

當(dāng) mostRecentLogFileInfo 不滿足前四步檢查,需要判斷 App 是否在后臺(tái)運(yùn)行,并根據(jù) NSFileProtectionKey 確認(rèn) log file 的讀寫權(quán)限。doesAppRunInBackground 通過 mainBundle 的 UIBackgroundModes 來獲取。

為何要加這個(gè)判定條件呢?這就需要關(guān)注 log file 生成時(shí)說起。創(chuàng)建 log file 時(shí)會(huì)設(shè)置 logFileProtection。

- (NSFileProtectionType)logFileProtection {
    if (_defaultFileProtectionLevel.length > 0) {
        return _defaultFileProtectionLevel;
    } else if (doesAppRunInBackground()) {
        return NSFileProtectionCompleteUntilFirstUserAuthentication;
    } else {
        return NSFileProtectionCompleteUnlessOpen;
    }
}

NSFileProtectionCompleteUnlessOpen

當(dāng)設(shè)備被鎖定時(shí),各文件仍然能夠進(jìn)行創(chuàng)建,而已經(jīng)打開的文件則可繼續(xù)接受訪問。利用這一機(jī)制,我們可以在后臺(tái)完成各類相關(guān)任務(wù)——例如保存新數(shù)據(jù)或者更新數(shù)據(jù)庫。

NSFileProtectionCompleteUntilFirstUserAuthentication

當(dāng)設(shè)備引導(dǎo)完成后,對(duì)應(yīng)文件可在用戶輸入密碼后隨時(shí)接受訪問——即使是在設(shè)備被鎖定的情況下。利用這種方式,您可以隨時(shí)讀取運(yùn)行在后臺(tái)的文件。

也就是說,App 運(yùn)行在前臺(tái)時(shí),通過 NSFileProtectionCompleteUnlessOpen 來獲取更多權(quán)限,而在后臺(tái)則需要使用 NSFileProtectionCompleteUntilFirstUserAuthentication 來修飾。因此,當(dāng)我們?cè)诤笈_(tái)的情況下從磁盤中恢復(fù)的 log file 卻是 App 在前臺(tái)的時(shí)候所生成的話,由于權(quán)限不同,我們只能將其 Archive 來重新生成新的 log file。

DDLogFileManager

fileManger 有對(duì)應(yīng)一個(gè)的 protocol

@protocol DDLogFileManager <NSObject>
@required

@property (readwrite, assign, atomic) NSUInteger maximumNumberOfLogFiles;
@property (readwrite, assign, atomic) unsigned long long logFilesDiskQuota;
@property (nonatomic, readonly, copy) NSString *logsDirectory;
@property (nonatomic, readonly, strong) NSArray<NSString *> *unsortedLogFilePaths;
@property (nonatomic, readonly, strong) NSArray<NSString *> *unsortedLogFileNames;
@property (nonatomic, readonly, strong) NSArray<DDLogFileInfo *> *unsortedLogFileInfos;
@property (nonatomic, readonly, strong) NSArray<NSString *> *sortedLogFilePaths;
@property (nonatomic, readonly, strong) NSArray<NSString *> *sortedLogFileNames;
@property (nonatomic, readonly, strong) NSArray<DDLogFileInfo *> *sortedLogFileInfos;

- (nullable NSString *)createNewLogFileWithError:(NSError **)error;

@optional
- (void)didArchiveLogFile:(NSString *)logFilePath NS_SWIFT_NAME(didArchiveLogFile(atPath:));
- (void)didRollAndArchiveLogFile:(NSString *)logFilePath NS_SWIFT_NAME(didRollAndArchiveLogFile(atPath:));

@end

其默認(rèn)實(shí)現(xiàn)類為 DDLogFileManagerDefault。

DDLogFileManagerDefault

所有創(chuàng)建的 logFile 都存儲(chǔ)在 logsDirectory 目錄下,文件名稱格式為 <bundle identifier> <date> <time>.log ,例如: com.organization.myapp 2020-05-09 17-14.log ,目錄在 Mac 上為 ~/Library/Logs/<Application Name>。iPhone 上為 ~/Library/Caches/Logs。

managerDefault 基本圍繞著 _logsDirectory 目錄來管理文件。對(duì)其目錄下的文件的基本操作等,這里不展開了。稍微提一點(diǎn) maximumNumberOfLogFileslogFilesDiskQuota 的控制是通過 KVO 來實(shí)現(xiàn)監(jiān)聽的。它們最終會(huì)觸發(fā) deleteOldLogFiles

deleteOldLogFiles

由于是 I/O 操作,整個(gè)代碼是放在 GCD 中以 PRIORITY_DEFAULT 執(zhí)行的?;具壿嬋缦拢?/p>

  1. 取出 log file 文件名中的 date 字符轉(zhuǎn)為 date (轉(zhuǎn)換失敗則嘗試從 fileAttributes 中獲取),然后進(jìn)行排序。

  2. 計(jì)算 logsDirectory 目錄下的所有 log 文件 size,并標(biāo)記首個(gè)超出限制的文件 index;

    unsigned long long used = 0;
    for (NSUInteger i = 0; i < sortedLogFileInfos.count; i++) {
       DDLogFileInfo *info = sortedLogFileInfos[i];
       used += info.fileSize;
    
       if (used > diskQuota) {
           firstIndexToDelete = i;
           break;
       }
    }
    
  3. 對(duì)比 maxNumLogFilesfirstIndexToDelete 最終確定要?jiǎng)h除的文件范圍:

    if (maxNumLogFiles) {
         if (firstIndexToDelete == NSNotFound) {
             firstIndexToDelete = maxNumLogFiles;
         } else {
             firstIndexToDelete = MIN(firstIndexToDelete, maxNumLogFiles);
         }
     }
    
  4. 如果 firstIndexToDelete 為第一個(gè)文件,僅僅刪除第一個(gè)且未標(biāo)記為 isArchived 的文件。

  5. 最后遍歷 sortedLogFileInfosfirstIndexToDelete 開始刪除。

DDLogFileFormatterDefault

fileLogger 中的 fileFormatter 僅僅是在每條 log message 前添加了 _timestamp 的前綴。

Buffering

lumberjack 為 DDFileLogger 提供了 buffer 的分類,通過 NSProxy 來實(shí)現(xiàn)的,用法也一如既往的簡單:

[DDLog addLogger:[_logger wrapWithBuffer]];

通過 wrapWithBuffer 返回的是 DDBufferedProxy 類:

(DDFileLogger *)[[DDBufferedProxy alloc] initWithFileLogger:self];

接著利用 Message Forwarding 將 DDBufferedProxy 未代理的方法通通轉(zhuǎn)發(fā)回 fileLogger 處理:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.fileLogger methodSignatureForSelector:sel];
}

- (BOOL)respondsToSelector:(SEL)aSelector {
    return [self.fileLogger respondsToSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.fileLogger];
}

作為 buffer 聲明了哪些屬性呢?

@interface DDBufferedProxy : NSProxy

@property (nonatomic) DDFileLogger *fileLogger;
@property (nonatomic) NSOutputStream *buffer;
@property (nonatomic) NSUInteger maxBufferSizeBytes;
@property (nonatomic) NSUInteger currentBufferSizeBytes;

@end

是的,BufferProxy 正是通過 NSOutputStream 將 log message 優(yōu)先寫入 memory 的方式作為過渡。那這 buffer 的預(yù)設(shè)值是多少呢 ?

static const NSUInteger kDDDefaultBufferSize = 4096; // 4 kB, block f_bsize on iphone7
static const NSUInteger kDDMaxBufferSize = 1048576; // ~1 mB, f_iosize on iphone7

這兩個(gè)默認(rèn)值是基于 iPhone 7 上的 buffer size 來決定的。真實(shí)值取自 <sys/mount.h> API:

static inline NSUInteger p_DDGetDefaultBufferSizeBytesMax(const BOOL max) {
    struct statfs *mountedFileSystems = NULL;
    int count = getmntinfo(&mountedFileSystems, 0);

    for (int i = 0; i < count; i++) {
        struct statfs mounted = mountedFileSystems[i];
        const char *name = mounted.f_mntonname;

        // We can use 2 as max here, since any length > 1 will fail the if-statement.
        if (strnlen(name, 2) == 1 && *name == '/') {
            return max ? (NSUInteger)mounted.f_iosize : (NSUInteger)mounted.f_bsize;
        }
    }

    return max ? kDDMaxBufferSize : kDDDefaultBufferSize;
}

核心是根據(jù)當(dāng)前已掛載的文件系統(tǒng)的 f_iosizef_bsize

  • f_iosize: 最佳傳輸 block 大??;
  • f_bsize: 基礎(chǔ)文件系統(tǒng) block 大?。?/li>

讀取不到的話就是基于 iPhone 7 提供的默認(rèn)值作為 defaultBufferSize 和 maxBufferSize。

static NSUInteger DDGetMaxBufferSizeBytes() {
    static NSUInteger maxBufferSize = 0;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        maxBufferSize = p_DDGetDefaultBufferSizeBytesMax(YES);
    });
    return maxBufferSize;
}

static NSUInteger DDGetDefaultBufferSizeBytes() {
    static NSUInteger defaultBufferSize = 0;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        defaultBufferSize = p_DDGetDefaultBufferSizeBytesMax(NO);
    });
    return defaultBufferSize;
}

Life Cycle

初始化

_fileLogger = fileLogger;
_maxBufferSizeBytes = DDGetDefaultBufferSizeBytes();
[self flushBuffer];

初始化時(shí),保存 fileLogger 引用,獲取默認(rèn) bufferSize 以及刷新 outputStream。

flushBuffer

[_buffer close];
_buffer = [NSOutputStream outputStreamToMemory];
[_buffer open];
_currentBufferSizeBytes = 0;

通過 outputStreamToMemory 創(chuàng)建一個(gè)直接將所寫入的數(shù)據(jù)寫到內(nèi)存中。用 _currentBufferSizeBytes 記錄當(dāng)前所占用內(nèi)存的大小。

dealloc

為了保證數(shù)據(jù)完整性,在生命周期結(jié)束后會(huì)主動(dòng)將數(shù)據(jù)寫回 fileLogger 中,以期最終能寫入 log file。

dispatch_block_t block = ^{
     [self lt_sendBufferedDataToFileLogger];
     self.fileLogger = nil;
 };

 if ([self->_fileLogger isOnInternalLoggerQueue]) {
     block();
 } else {
     dispatch_sync(self->_fileLogger.loggerQueue, block);
 }

lt_sendBufferedDataToFileLogger

寫回 fileLogger 則是將 buffer 中的 data 取出,然后調(diào)用 lt_logData 寫入 fileLogger,最后 flushBuffer。

NSData *data = [_buffer propertyForKey:NSStreamDataWrittenToMemoryStreamKey];
[_fileLogger lt_logData:data];
[self flushBuffer];

Logging

bufferProxy 攔截了這兩個(gè)方法 logMessageflush,其余的都通過 message forwarding 轉(zhuǎn)回 filgLogger了。

logMessage

例行檢查和 fileLogger 中的一樣,而 logMessage 方法主要作用是將 log message 轉(zhuǎn)化為 NSData 再調(diào)用 lt_logData。對(duì) buffer 而言則是將 NSData 以 byteStream 的方式寫入 outputStream。

[data enumerateByteRangesUsingBlock:^(const void * __nonnull bytes, NSRange byteRange, BOOL * __nonnull __unused stop) {
    NSUInteger bytesLength = byteRange.length;
#ifdef NS_BLOCK_ASSERTIONS
    __unused
#endif
    NSInteger written = [_buffer write:bytes maxLength:bytesLength];
    NSAssert(written > 0 && (NSUInteger)written == bytesLength, @"Failed to write to memory buffer.");

    _currentBufferSizeBytes += bytesLength;

    if (_currentBufferSizeBytes >= _maxBufferSizeBytes) {
        [self lt_sendBufferedDataToFileLogger];
    }
}];

會(huì)不斷地將 data 寫入 buffer,如果滿了就寫入 log file 并 flushBuffer。通過這種方式,有效的減小了文件寫入的 I/O 操作。

flush

flush 為了及時(shí)將 buffer 等緩存數(shù)據(jù)及時(shí)寫入,防止應(yīng)用被主動(dòng)退出或 Crash 時(shí)數(shù)據(jù)丟失。作為 Public method 還是再強(qiáng)調(diào)一下,它在執(zhí)行前依舊需要進(jìn)行 loggingQueue 和 loggerQueue 的檢查,之后才是核心代碼。

dispatch_block_t block = ^{
adispatch_block_t block = ^{
    @autoreleasepool {
        [self lt_sendBufferedDataToFileLogger];
        [self.fileLogger flush];
    }
};

總結(jié)

通過對(duì) DDFileLogger 源碼的淺嘗,能夠強(qiáng)烈感受到作者扎實(shí)的基礎(chǔ)能力。例如使用 NSFileHandle 來操作 file 的增量寫入,使用 dispatch_source_t 來實(shí)現(xiàn) timer 的 delay 功能,以及利用 dispatch_source_t 來監(jiān)聽 NSFileHandle 的 change 等都體現(xiàn)了作者對(duì) GCD 的熟悉,包括上一篇中的 queue 的使用。除了這些實(shí)現(xiàn)細(xì)節(jié)之外,作者對(duì) file rolling 等日志監(jiān)控的相關(guān)概念都理解的挺到位的。最后則是對(duì) deprecated API 有著較好的兼容,也是十分為人稱道的。

總之,收獲頗多。

未完待續(xù)

DDLogger 中至少還會(huì)有一篇的 blog 是關(guān)于 DDAbstractDatabaseLogger 分析。之后可能會(huì)有相關(guān)日志組件的橫向?qū)Ρ?,但?lumberjack 是真的超乎我想象的優(yōu)先開源實(shí)現(xiàn)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容