日志文件系統
對文件系統進行修改時,需要進行很多操作。這些操作可能中途被打斷,也就是說,這些操作不是“不可中斷”(atomic)的。如果操作被打斷,就可能造成文件系統出現不一致的狀態。
例如:刪除文件時,先要從目錄樹中移除文件的標示,然后收回文件占用的空間。如果在這兩步之間操作被打斷,文件占用的空間就無法收回。文件系統認為它是被占用的,但實際上目錄樹中已經找不到使用它的文件了。
在非日志文件系統中,要檢查並修復類似的錯誤必須對整個文件系統的數據結構進行檢查。這個操作可能會花費很長的時間。
為了避免這樣的錯誤,日志文件系統分配了一個稱為日志的區域來提前記錄要對文件系統做的更改。在崩潰后,只要讀取日志重新執行未完成的操作,文件系統就可以恢復一致。這種恢復是原子的,因為只存在幾種情況:
- 不需要重新執行:這個事務被標記為已經完成
- 成功重新執行:根據日志,這個事務被重新執行
- 無法重新執行:這個事務會被撤銷,就如同這個事務沒有發生過一樣
- 日志本身不完整:事務還沒有被完全寫入日志,他會被簡單忽略
日志系統的設計
為什么需要日志
對於一些高頻操作(如心跳包、定時器、界面繪制下的某些高頻重復的行為),可能在少量次數下無法觸發我們想要的行為,而通過斷點的暫停方式,我們不得不重復操作幾十次、上百次甚至更多,這樣排查問題效率是非常低下的。對於這類操作,我們可以通過打印日志,將當時的程序行為上下文現場記錄下來,然后從日志系統中找出某次不正常行為的上下文信息。
日志系統的技術上的實現
同步寫日志
所謂同步寫日志,指的是在輸出日志的地方,將日志即時寫入到文件中去。采用這種方式,勢必造成CPU等待,進而導致主線程“卡”在寫文件處,進而造成界面卡幀。
但是,很多時候我們不用擔心這種問題,主要有兩個原因:其一,是用戶感覺不到這種同步寫文件造成的延遲;其二,是客戶端除了UI線程外,還有其他與界面無關的工作線程,在這些線程中寫文件一般不會對用戶的體驗產生什么影響。
多線程同步寫日志出現的問題一——不同線程的日志事件時間序列錯亂
產生這種問題的主要原因是由於多個線程同時寫日志到同一個文件時,產生日志的時間和實際寫入磁盤的時間不是一個原子操作。我們可以這樣來理解,一個線程T1在t1時刻產生了日志,另一個線程T2在t2時刻也產生了一個日志(t2 > t1)。但是由於一些原因導致線程T1發生阻塞,並沒有把日志即刻寫入到文件中,而線程T2並沒有發生阻塞,即刻就把日志信息寫入到了文件中。這種情況的存在就會導致不同線程的日志事件時間序列錯亂。
多線程同步寫日志出現的問題二——不同線程的日志輸出錯亂拼接
假設線程A的日志信息為AAAAAA,線程B的日志信息為BBBBBB,線程C的日志信息為CCCCCC。那么會不會產生一種情況使得日志文件中的輸出結果為AABBCCAABBCCAABBCC。
實際上,在類Unix系統上(包括Linux),同一個進程內針對同一個FIEL*的操作是線程安全的,也就是說在Linux系統上不會產生上述的情況發生。
在Windows系統上,由於對FILE*的操作並不是線程安全的,可能會發生上述情況。
這種同步日志的實現方式,一般用於低頻寫日志的軟件系統中,所以可以認為這種多線程同時寫日志到同一個文件中是可行的。
異步寫日志
所謂異步寫日志,就是通過一些線程同步技術將日志先暫存下來,然后再通過一個或多個專門的日志寫入線程將這些緩存的日志寫入到磁盤中。
實現的時候可以使用一個隊列來存儲其他線程產生的日志,日志線程從該隊列中取出日志,然后將日志內容寫入文件。
日志系統的實現
設計一個日志系統需要考慮哪些問題?
既然是日志系統肯定需要記錄日志(LogEvent),那么我們就需要一個類來表達日志的概念,這個類至少應該包含兩個屬性,一個是時間戳,另一個是消息本身。
其次是日志輸出(LogAppender),可以輸出到不同的地方,控制台、文件。
然后是將日志信息進行格式化輸出(LogFormatter),LogAppender 可以引用 LogFormatter 這樣就可以將 LogEvent 事件中的日志消息經過 LogFormatter 進行格式化,然后再由 LogAppender 輸出。
如果想要獲取日志,必須得先獲取一個什么東西,這個東西可以成為 Logger,此外,還可以使用 LoggerManager 對這些不同的 Logger 進行管理。
LogLevel 類定義了日志的級別。
LogEventWarp 對 LogEvent 進行了封裝,當 LogEventWarp 析構的時候,能夠觸發 LogEvent 將日志信息寫入到指定位置。
類圖
日志寫入流程圖
LogEvent
LogEvent 是日志事件,所有的日志信息都是由 LogEvent 來管理,同時 LogEvent 也提供了格式化寫入的功能。
// 日志事件 class LogEvent { public: // 指向日志事件的指針 typedef std::shared_ptr<LogEvent> ptr; /* * 構造函數 * 傳入 Logger 指針,可以將該日志事件寫入到對應的 Logger 中 */ LogEvent(std::shared_ptr<Logger> logger, LogLevel::Level level, const char* file, ~LogEvent() {} // 獲取該日志事件所對應的 Logger 類 std::shared_ptr<Logger> getLogger() const { return m_logger; } // 獲取日志級別 LogLevel::Level getLevel() const { return m_level; } // 獲取文件名 const char* getFileName() const { return m_file; } // 獲取行號 int32_t getLine() const { return m_line; } // 獲取運行的時間 uint32_t getElapse() const { return m_elapse; } // 獲取線程 id uint32_t getThreadId() const { return m_threadId; } // 獲取協程 id uint32_t getFiberId() const { return m_fiberId; } // 獲取當前時間 uint32_t getTime() const { return m_time; } // 獲取日志內容 std::string getContent() const { return m_ss.str(); } // 以 stringstream 的形式獲取日志內容 std::stringstream& getSS() { return m_ss; } // 將日志內容進行格式化 void format(const char* fmt, ...); void format(const char* fmt, va_list al); private: const char* m_file = nullptr; // 文件名 int32_t m_line = 0; // 行號 uint32_t m_elapse = 0; // 程序啟動開始到現在的毫秒數 uint32_t m_threadId = 0; // 線程id uint32_t m_fiberId = 0; // 協程id uint64_t m_time; // 時間戳 std::stringstream m_ss; // 日志流 std::shared_ptr<Logger> m_logger; // 指向 Logger 類的指針 LogLevel::Level m_level; // 該日志事件的級別 };
下面的這兩個函數提供了一種獲取變長參數的方法,具體的原理可以查看這篇博客,大致的意思就是通過傳入的第一個參數 fmt 來確定每一個變長參數在內存中的位置,進而獲取參數的取值。
(gdb) p fmt
$3 = 0x414228 "test macro fmt error %s"
從上面調試的結果可以看出該函數獲取變長參數的具體做法是在給定字符串的最后加上變長參數的格式化輸出。
vasprintf 函數將變長參數的內容輸出到 buf 中,若成功則返回輸出內容的長度,若失敗則返回 -1.
/** * 獲取省略號指定的參數 */ void LogEvent::format(const char* fmt, ...) { va_list al; va_start(al, fmt); format(fmt, al); va_end(al); } /** * 將參數輸出到m_ss中(格式化寫入)
*/
void LogEvent::format(const char* fmt, va_list al) { char* buf = nullptr; int len = vasprintf(&buf, fmt, al); if (len != -1) { m_ss << std::string(buf, len); free(buf); } }
上面的這種格式化輸出主要用在了下面的這個宏定義里面
#define RAINBOW_LOG_FMT_LEVEL(logger, level, fmt, ...) \ if (logger->getLevel() <= level) \ rainbow::LogEventWrap(rainbow::LogEvent::ptr(new rainbow::LogEvent(logger, level, \ __FILE__, __LINE__, 0, rainbow::GetThreadId(), \ rainbow::GetFiberId(), time(0)))).getEvent()->format(fmt, __VA_ARGS__)
LogAppender
通過該類派生出的不同子類可以將日志信息輸出到不同的位置。一個 Logger 可以有多個 Appender,LogAppender 有單獨的日志級別,因此可以通過設置不同級別的 Appender 從而將日志輸出到不同的位置。此外每一個 LogAppender 也都會有自己單獨的日志格式,從而方便進行管理。
還可以通過 scoket 套接字,將日志輸出到服務器上。
// 日志輸出到目的地(控制台、文件) class LogAppender { public: typedef std::shared_ptr<LogAppender> ptr; LogAppender(); virtual ~LogAppender() {} // 純虛函數,子類必須實現該方法 virtual void log(std::shared_ptr<Logger> logger, LogLevel::Level level, LogEvent::ptr event) = 0; // 按照給定的格式序列化輸出 void setFormatter(LogFormatter::ptr val) { m_formatter = val; } // 獲取日志格式 LogFormatter::ptr getFormatter() const { return m_formatter; } LogLevel::Level getLevel() { return m_level; } void setLevel(const LogLevel::Level& level); protected: LogLevel::Level m_level; LogFormatter::ptr m_formatter; };
// 輸出到控制台的Appender class StdoutLogAppender : public LogAppender { public: typedef std::shared_ptr<StdoutLogAppender> ptr; virtual void log(Logger::ptr logger, LogLevel::Level level, LogEvent::ptr event) override; }; // 定義輸出到文件的Appender class FileLogAppender : public LogAppender { public: typedef std::shared_ptr<FileLogAppender> ptr; virtual void log(Logger::ptr logger, LogLevel::Level level, LogEvent::ptr event) override; FileLogAppender(const std::string& filename); // 重新打開文件,如果文件打開成功則返回true bool reopen(); private: std::string m_filename; std::ofstream m_filestream; };
LogFormatter
日志格式器(LogFormatter)可以將傳入的日志格式進行解析,並可以和 LogEvent 指針結合將特定格式的日志信息輸出到 stringstream 中,等待 LogEventWrap 析構的時候將日志信息寫入到指定的 Appender 中。
// 日志格式器 class LogFormatter { public: // 指向該類的指針 typedef std::shared_ptr<LogFormatter> ptr; // 日志格式 LogFormatter(const std::string& pattern);
// 對日志進行解析,並返回格式化之后的string std::string format(std::shared_ptr<Logger> ptr, LogLevel::Level level, LogEvent::ptr event); std::ostream& format(std::ostream& ofs, std::shared_ptr<Logger> ptr, LogLevel::Level level, LogEvent::ptr event); public: class FormatItem { public: FormatItem(const std::string& fmt = ""){}; virtual ~FormatItem() {} virtual void format(std::ostream& os, std::shared_ptr<Logger> logger, LogLevel::Level level, LogEvent::ptr event) = 0; // 注意這里的指針類型是FormatItem類型的指針 typedef std::shared_ptr<FormatItem> ptr; }; void init(); private:
// 日志格式 std::string m_pattern;
// 根據日志格式解析出的日志格式單元類的指針存放至數組中 std::vector<FormatItem::ptr> m_items; };
Logger
日志可以用 Logger 類來進行表示,每一個 Logger 類含有多個 LoggerAppender,可以通過指針把 Logger 類傳遞給 LogEvent 從而使日志事件能夠獲取 Logger 類的一些信息。
std::enable_shared_from_this 能讓一個對象(假設其名為 t ,且已被一個 std::shared_ptr 對象 pt 管理)安全地生成其他額外的 std::shared_ptr 實例(假設名為 pt1, pt2, ... ) ,它們與 pt 共享對象 t 的所有權。
// 日志器 class Logger : public std::enable_shared_from_this<Logger> { public: typedef std::shared_ptr<Logger> ptr; Logger(const std::string& name = "root"); // 只有滿足日直級別的日志才會被輸出 void log(LogLevel::Level level, LogEvent::ptr event); void debug(LogEvent::ptr event); void info(LogEvent::ptr event); void warn(LogEvent::ptr event); void error(LogEvent::ptr event); void fatal(LogEvent::ptr event); void addAppender(LogAppender::ptr appender); void delAppender(LogAppender::ptr appender); LogLevel::Level getLevel() const { return m_level; } void setAppender(LogLevel::Level val) { m_level = val; } const std::string getName() const { return this->m_name; } private: std::string m_name = "root"; // 日志名稱 LogLevel::Level m_level; // 日志器的級別 std::list<LogAppender::ptr> m_appenders; // Appender集合 LogFormatter::ptr m_formatter; // 日志格式 };
LoggerManager
管理所有的日志器,並且支持通過日志器的名字獲取日志器。
// 管理所有的日志器 class LoggerManager { public: LoggerManager(); Logger::ptr getLogger(const std::string& name); void init(); Logger::ptr getRoot() const { return m_root; } private: // 通過日志器的名字獲取日志器 std::map<std::string, Logger::ptr> m_loggers; Logger::ptr m_root; }; /** * 日志器管理類,單例模式 */ typedef rainbow::Singleton<LoggerManager> LoggerMgr; } // namespace rainbow