一直需要一個 Log 系統,可以將程序運行過程中打的 log 發送到自己服務器,方便之後數據分析或者除錯。之前也嘗試過找一些第三方服務,但看來看去,國內貌似沒看到專門做這一塊的,而國外看了下有 Loggly,似乎滿足需求,但它要收費且日志保存時間太短。後來無意間看了下 Loggly 提供的 SDK 源代碼,發現了 CocoaLumberjack 這個好東西,而 Loggly 其也不過就是在 CocoaLumberjack 上自定義了 Logger 和 Formatter 而已。自己做的話,也很簡單。
先說下需求,我理想中的 Log 系統需要:
可以設定 Log 等級
可以積攢到一定量的 log 後,一次性發送給服務器,絕對不能打一個 Log 就發一次
可以一定時間後,將未發送的 log 發送到服務器
可以在 App 切入後台時將未發送的 log 發送到服務器
其他一些需求,比如可以遠程設定發送 log 的等級閥值,還有閥值的有效期等,和本文無關就不寫了。
開始動手前,先了解下 CocoaLumberjack 是什麼:
CocoaLumberjack 最早是由 Robbie Hanson 開發的日志庫,可以在 iOS 和 MacOSX 開發上使用。其簡單,快讀,強大又不失靈活。它自帶了幾種log方式,分別是:
DDASLLogger 將 log 發送給蘋果服務器,之後在 Console.app 中可以查看
DDTTYLogger 將 log 發送給 Xcode 的控制台
DDFileLogger 講 log 寫入本地文件
CocoaLumberjack 打一個 log 的流程大概就是這樣的:
所有的 log 都會發給 DDLog 對象,其運行在自己的一個GCD隊列(GlobalLoggingQueue),之後,DDLog 會將 log 分發給其下注冊的一個或多個 Logger,這步在多核下是並發的,效率很高。每個 Logger 處理收到的 log 也是在它們自己的 GCD隊列下(loggingQueue)做的,它們詢問其下的 Formatter,獲取 Log 消息格式,然後最終根據 Logger 的邏輯,將 log 消息分發到不同的地方。
因為一個 DDLog 可以把 log 分發到所有其下注冊的 Logger 下,也就是說一個 log 可以同時打到控制台,打到遠程服務器,打到本地文件,相當靈活。
CocoaLumberjack 支持 Log 等級:
typedef NS_OPTIONS(NSUInteger, DDLogFlag) { DDLogFlagError = (1 << 0), // 0...00001 DDLogFlagWarning = (1 << 1), // 0...00010 DDLogFlagInfo = (1 << 2), // 0...00100 DDLogFlagDebug = (1 << 3), // 0...01000 DDLogFlagVerbose = (1 << 4) // 0...10000 }; typedef NS_ENUM(NSUInteger, DDLogLevel) { DDLogLevelOff = 0, DDLogLevelError = (DDLogFlagError), // 0...00001 DDLogLevelWarning = (DDLogLevelError | DDLogFlagWarning), // 0...00011 DDLogLevelInfo = (DDLogLevelWarning | DDLogFlagInfo), // 0...00111 DDLogLevelDebug = (DDLogLevelInfo | DDLogFlagDebug), // 0...01111 DDLogLevelVerbose = (DDLogLevelDebug | DDLogFlagVerbose), // 0...11111 DDLogLevelAll = NSUIntegerMax // 1111....11111 (DDLogLevelVerbose plus any other flags) };
DDLogLevel 定義了全局的 log 等級,DDLogFlag 是我們打 log 時設定的 log 等級,CocoaLumberjack 會比較兩者,如果 flag 低於 level,則不會打 log:
#define LOG_MAYBE(async, lvl, flg, ctx, tag, fnct, frmt, ...) \ do { if(lvl & flg) LOG_MACRO(async, lvl, flg, ctx, tag, fnct, frmt, ##__VA_ARGS__); } while(0)
DDLogger 協議定義了 logger 對象需要遵從的方法和變量,為了方便使用,其提供了 DDAbstractLogger 對象,我們只需要繼承該對象就可以自定義自己的 logger。對於第二點和第三點需求,我們可以利用 DDAbstractDatabaseLogger,其也是繼承自 DDAbstractLogger,並在其上定義了 saveThreshold, saveInterval 等控制參數。這個 logger 本身是針對寫入數據庫的 log 設計的,我們也可以利用它這幾個參數,實現我們上面所提的需求的第二和第三點。
對於第二點,設定 _saveThreshold 值即可,比如如果希望積攢1000條 log 再一次性發送,就賦值 1000.
對於第三點,設定 _saveInterval,比如如果希望每分鐘發送一次,就設定 60.
由此,CocoaLumberjack 已經實現了需求中的 1、2、3 點,我們要做的無非是自定義 Logger 和 Formatter,將 log 的最終去處改為發送到我們自己的服務器中。
而第四點,我們可以監聽 UIApplicationWillResignActiveNotification 事件,當觸發時,手動調用 logger 的 db_save 方法,發送數據給服務器。
廢話了半天,現在看下實現。
首先我們設定 log 的消息結構。自定義一個 LogFormatter, 遵從 DDLogFormatter 協議,我們需要重寫 formatLogMessage 這個方法,這個方法返回值是 NSString,就是最終 log 的消息體字符串。而輸入參數 logMessage 是由 logger 發的一個 DDLogMessage 對象,包含了一些必要的信息:
@interface DDLogMessage : NSObject { // Direct accessors to be used only for performance @public NSString *_message; DDLogLevel _level; DDLogFlag _flag; NSUInteger _context; NSString *_file; NSString *_fileName; NSString *_function; NSUInteger _line; id _tag; DDLogMessageOptions _options; NSDate *_timestamp; NSString *_threadID; NSString *_threadName; NSString *_queueLabel; }
可以利用這些信息構建自己的 log 消息體。比如我們這裡只需要 log 所在文件名,行數還有所在函數名,則可以這樣寫:
- (NSString *)formatLogMessage:(DDLogMessage *)logMessage{ NSMutableDictionary *logDict = [NSMutableDictionary dictionary]; //取得文件名 NSString *locationString; NSArray *parts = [logMessage->_file componentsSeparatedByString:@"/"]; if ([parts count] > 0) locationString = [parts lastObject]; if ([locationString length] == 0) locationString = @"No file"; //這裡的格式: {"location":"myfile.m:120(void a::sub(int)"}, 文件名,行數和函數名是用的編譯器宏 __FILE__, __LINE__, __PRETTY_FUNCTION__ logDict[@"location"] = [NSString stringWithFormat:@"%@:%lu(%@)", locationString, (unsigned long)logMessage->_line, logMessage->_function] //嘗試將logDict內容轉為字符串,其實這裡可以直接構造字符串,但真實項目中,肯定需要很多其他的信息,不可能僅僅文件名、行數和函數名就夠了的。 NSError *error; NSData *outputJson = [NSJSONSerialization dataWithJSONObject:logfields options:0 error:&error]; if (error) return @"{\"location\":\"error\"}" NSString *jsonString = [[NSString alloc] initWithData:outputJson encoding:NSUTF8StringEncoding]; if (jsonString) return jsonString; return @"{\"location\":\"error\"}" }
接下來自定義 logger,其繼承自 DDAbstractDatabaseLogger。在初始化方法中,先設定好一些參數,以及添加一個UIApplicationWillResignActiveNotification的觀察者,用以實現第四個需求。
- (instancetype)init { self = [super init]; if (self) { self.deleteInterval = 0; self.maxAge = 0; self.deleteOnEverySave = NO; self.saveInterval = 60; self.saveThreshold = 500; //別忘了在 dealloc 裡 removeObserver [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(saveOnSuspend) name:@"UIApplicationWillResignActiveNotification" object:nil]; } return self; } - (void)saveOnSuspend { dispatch_async(_loggerQueue, ^{ [self db_save]; }); }
每次打 log 時,db_log: 會被調用,我們在這個函數裡,將 log 發給 formatter,將返回的 log 消息體字符串保存在緩沖中。 db_log 的返回值告訴 DDLog 該條 log 是否成功保存進緩存。
- (BOOL)db_log:(DDLogMessage *)logMessage { if (!_logFormatter) { //沒有指定 formatter return NO; } if (!_logMessagesArray) _logMessagesArray = [NSMutableArray arrayWithCapacity:500]; // 我們的saveThreshold只有500,所以一般情況下夠了 if ([_logMessagesArray count] > 2000) { // 如果段時間內進入大量log,並且遲遲發不到服務器上,我們可以判斷哪裡出了問題,在這之後的 log 暫時不處理了。 // 但我們依然要告訴 DDLog 這個存進去了。 return YES; } //利用 formatter 得到消息字符串,添加到緩存 [_logMessagesArray addObject:[_logFormatter formatLogMessage:logMessage]]; return YES; }
當1分鐘或者未寫入 log 數達到 500 時, db_save 就會被調用,我們在這裡,將緩存的數據上傳到自己的服務器。
- (void)db_save{ //判斷是否在 logger 自己的GCD隊列中 if (![self isOnInternalLoggerQueue]) NSAssert(NO, @"db_saveAndDelete should only be executed on the internalLoggerQueue thread, if you're seeing this, your doing it wrong."); //如果緩存內沒數據,啥也不做 if ([_logMessagesArray count] == 0) return; 獲取緩存中所有數據,之後將緩存清空 NSArray *oldLogMessagesArray = [_logMessagesArray copy]; _logMessagesArray = [NSMutableArray arrayWithCapacity:0]; //用換行符,把所有的數據拼成一個大字符串 NSString *logMessagesString = [oldLogMessagesArray componentsJoinedByString:@"\n"]; //發送給咱自己服務器(自己實現了) [self post:logMessagesString]; }
最後,我們需要在程序某處定義全局 log 等級(我這裡使用 Info),並在 AppDelegate 的 didFinishLaunchingWithOptions 裡初始化所有 Log 相關的東西:
static NSUInteger LOG_LEVEL_DEF = DDLogLevelInfo; - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { MyLogger *logger = [MyLogger new]; [logger setLogFormatter:[MyLogFormatter new]]; [DDLog addLogger:logger]; //.... }
然後就可以利用 DDLogError, DDLogWarning 等宏在程序中打 log 了。使用方法與 NSLog 一樣。這幾個宏的定義:
//注意,DDLogError 是肯定同步的 #define DDLogError(frmt, ...) LOG_MAYBE(NO, LOG_LEVEL_DEF, DDLogFlagError, 0, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__) #define DDLogWarn(frmt, ...) LOG_MAYBE(LOG_ASYNC_ENABLED, LOG_LEVEL_DEF, DDLogFlagWarning, 0, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__) #define DDLogInfo(frmt, ...) LOG_MAYBE(LOG_ASYNC_ENABLED, LOG_LEVEL_DEF, DDLogFlagInfo, 0, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__) #define DDLogDebug(frmt, ...) LOG_MAYBE(LOG_ASYNC_ENABLED, LOG_LEVEL_DEF, DDLogFlagDebug, 0, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__) #define DDLogVerbose(frmt, ...) LOG_MAYBE(LOG_ASYNC_ENABLED, LOG_LEVEL_DEF, DDLogFlagVerbose, 0, nil, __PRETTY_FUNCTION__, frmt, ##__VA_ARGS__)
最後感謝 CocoaLumberjack 的作者 Robbie Hanson ,如果你喜歡他開發的庫,比如 XMPPFramework,別忘了幫他買杯啤酒哦~