muduo筆記 日誌庫(二)
前半部分muduo筆記 日誌庫(一),中提到muduo非同步日誌庫分為兩部分:前端和後端。這部分描述後端。
後端
前端主要實現非同步日誌中的日誌功能,為使用者提供將日誌內容轉換為字串,封裝為一條完整的log訊息存放到RAM中;
而實現非同步,核心是通過專門的後端執行緒,與前端執行緒併發執行,將RAM中的大量日誌訊息寫到磁碟上。
後端主要包括:AsyncLogging, LogFile, AppendFile,MutexLock。
AsyncLogging 提供後端執行緒,定時將log緩衝寫到磁碟,維護緩衝及緩衝佇列。
LogFile 提供日誌檔案滾動功能,寫檔案功能。
AppendFile 封裝了OS提供的基礎的寫檔案功能。
類圖關係如下:
AsyncLogging類
AsyncLogging 主要職責:提供大緩衝Large Buffer(預設4MB)存放多條日誌訊息,緩衝佇列BufferVector用於存放多個Large Buffer,為前端執行緒提供執行緒安全的寫Large Buffer操作;提供專門的後端執行緒,用於定時或緩衝佇列非空時,將緩衝佇列中的Large Buffer通過LogFile提供的日誌檔案操作介面,逐個寫到磁碟上。
資料成員
/** * Provide async logging function. backend. * Background thread (just only one) call this module to write log to file. */ class AsyncLogging : noncopyable { ... private: typedef muduo::detail::FixedBuffer<muduo::detail::kLargeBuffer> Buffer; // Large Buffer Type typedef std::vector<std::unique_ptr<Buffer>> BufferVector; // 已滿緩衝佇列型別 typedef BufferVector::value_type BufferPtr; const int flushInterval_; // 沖刷緩衝資料到檔案的超時時間, 預設3秒 std::atomic<bool> running_; // 後端執行緒loop是否執行標誌 const string basename_; // 日誌檔案基本名稱 const off_t rollSize_; // 日誌檔案滾動大小 muduo::Thread thread_; // 後端執行緒 muduo::CountDownLatch latch_; // 門閥, 同步呼叫執行緒與新建的後端執行緒 muduo::MutexLock mutex_; // 互斥鎖, 功能相當於std::mutex muduo::Condition cond_ GUARDED_BY(mutex_); // 條件變數, 與mutex_配合使用, 等待特定條件滿足 BufferPtr currentBuffer_ GUARDED_BY(mutex_); // 當前緩衝 BufferPtr nextBuffer_ GUARDED_BY(mutex_); // 空閒緩衝 BufferVector buffers_ GUARDED_BY(mutex_); // 已滿緩衝佇列 }
AsyncLogging資料按功能主要分為3部分:1)維護存放log訊息的大緩衝Large Buffer;2)後端執行緒;3)傳遞給其他類物件的引數,如basename_,rollSize_;
LargeBuffer 存放大量log訊息
Large Buffer(FixedBuffermuduo::detail::kLargeBuffer)預設大小4MB,用於儲存多條log訊息;相對的,還有Small Buffer(FixedBuffermuduo::detail::kSmallBuffer)預設大小4KB,用於儲存一條log訊息。
當前端執行緒通過呼叫LOG_XXX << "..."時,如何將log訊息傳遞給後端呢?
可以通過呼叫AsyncLogging::append()
void AsyncLogging::append(const char *logline, int len)
{
muduo::MutexLockGuard lock(mutex_);
if (currentBuffer_->avail() > len)
{ // current buffer's free space is enough to fill C string logline[0..len-1]
currentBuffer_->append(logline, static_cast<size_t>(len));
}
else
{ // current buffer's free space is not enough
buffers_.push_back(std::move(currentBuffer_));
if (nextBuffer_)
{
currentBuffer_ = std::move(nextBuffer_);
}
else
{
currentBuffer_.reset(new Buffer); // rarely happens
}
currentBuffer_->append(logline, static_cast<size_t>(len));
cond_.notify();
}
}
append()可能會被多個前端執行緒呼叫,因此必須考慮執行緒安全,可以用mutex_加鎖。
append()基本思路:當前緩衝(currentBuffer_)剩餘空間(avail())足夠存放新log訊息大小(len)時,就直接存放到當前緩衝;當前緩衝剩餘空間不夠時,說明當前緩衝已滿(或者接近已滿),就將當前緩衝move到已滿緩衝佇列(buffers_),將空閒緩衝move到當前緩衝,再把新log訊息存放到當前緩衝中(此時當前緩衝為空,剩餘空間肯定夠用),最後喚醒等待中的後端執行緒。
注意:Large Buffer是通過std::unique_ptr指向的,move操作後,原來的 std::unique_ptr就會值為空。
問題:
1)為什麼最後要通過cond_喚醒後端執行緒?
因為沒有log訊息要記錄時,後端執行緒很可能阻塞等待log訊息,當有緩衝滿時,及時喚醒後端將已滿緩衝資料寫到磁碟上,能有效改善新能;否則,短時間內產生大量log訊息,可能造成資料堆積,甚至丟失,而後端執行緒一直休眠(直到3秒超時喚醒)。
2)為什麼呼叫notify()而不是notifyAll(),只喚醒一個執行緒,而不是喚醒所有執行緒?
因為一個應用程式通常只有一個日誌庫後端,而一個後端通常只有一個後端執行緒,也只會有一個後端執行緒在該條件變數上等待,因此喚醒一個執行緒足以。
後端執行緒 非同步寫資料到log檔案
後端執行緒的建立就是啟動,是在start()中,通過呼叫Thread::start()完成。門閥latch_目的在於讓呼叫start()執行緒等待執行緒函式啟動完成,而執行緒函式中呼叫latch_.countDown()表示啟動完成,當然,前提是latch_計數器初值為1。
// Control background thread
void start()
{
running_ = true;
thread_.start();
latch_.wait();
}
void stop() NO_THREAD_SAFETY_ANALYSIS
{
running_ = false;
cond_.notify();
thread_.join();
}
而AsyncLogging::stop()用於關閉後端執行緒,通常是在解構函式中,呼叫AsyncLogging::stop() 停止後端執行緒。
~AsyncLogging()
{
if (running_)
{
stop();
}
}
後端執行緒函式threadFunc,會構建1個LogFile物件,用於控制log檔案建立、寫日誌資料,建立2個空閒緩衝區buffer1、buffer2,和一個待寫緩衝佇列buffersToWrite,分別用於替換當前緩衝currentBuffer_、空閒緩衝nextBuffer_、已滿緩衝佇列buffers_,避免在寫檔案過程中,鎖住緩衝和佇列,導致前端無法寫資料到後端緩衝。
threadFunc中,提供了一個loop,基本流程是這樣的:
1)每次當已滿緩衝佇列中有資料時,或者即使沒有資料但3秒超時,就將當前緩衝加入到已滿緩衝佇列(即使當前緩衝沒滿),將buffer1移動給當前緩衝,buffer2移動給空閒緩衝(如果空閒緩衝已移動的話)。
2)然後,再交換已滿緩衝佇列和待寫緩衝佇列,這樣已滿緩衝佇列就為空,待寫緩衝佇列就有資料了。
3)接著,將待寫緩衝佇列的所有緩衝通過LogFile物件,寫入log檔案。
4)此時,待寫緩衝佇列中的緩衝,已經全部寫到LogFile指定的檔案中(也可能在核心緩衝中),擦除多餘緩衝,只用保留兩個,歸還給buffer1和buffer2。
5)此時,待寫緩衝佇列中的緩衝沒有任何用處,直接clear即可。
6)將核心快取記憶體中的資料flush到磁碟,防止意外情況造成資料丟失。
後端執行緒函式threadFunc,會構建1個LogFile物件,用於控制log檔案建立、寫日誌資料,建立2個空閒緩衝區buffer1、buffer2,和一個待寫緩衝佇列buffersToWrite,分別用於替換當前緩衝currentBuffer_、空閒緩衝nextBuffer_、已滿緩衝佇列buffers_,避免在寫檔案過程中,鎖住緩衝和佇列,導致前端無法寫資料到後端緩衝。
threadFunc中,提供了一個loop,基本流程是這樣的:
1)每次當已滿緩衝佇列中有資料時,或者即使沒有資料但3秒超時,就將當前緩衝加入到已滿緩衝佇列(即使當前緩衝沒滿),將buffer1移動給當前緩衝,buffer2移動給空閒緩衝(如果空閒緩衝已移動的話)。
2)然後,再交換已滿緩衝佇列和待寫緩衝佇列,這樣已滿緩衝佇列就為空,待寫緩衝佇列就有資料了。
3)接著,將待寫緩衝佇列的所有緩衝通過LogFile物件,寫入log檔案。
4)此時,待寫緩衝佇列中的緩衝,已經全部寫到LogFile指定的檔案中(也可能在核心緩衝中),擦除多餘緩衝,只用保留兩個,歸還給buffer1和buffer2。
5)此時,待寫緩衝佇列中的緩衝沒有任何用處,直接clear即可。
6)將核心快取記憶體中的資料flush到磁碟,防止意外情況造成資料丟失。
void AsyncLogging::threadFunc()
{
assert(running_ == true);
latch_.countDown();
LogFile output(basename_, rollSize_, false); // only called by this thread, so no need to use thread safe
BufferPtr newBuffer1(new Buffer);
BufferPtr newBuffer2(new Buffer);
newBuffer1->bzero();
newBuffer2->bzero();
BufferVector buffersToWrite;
static const int kBuffersToWriteMaxSize = 25;
buffersToWrite.reserve(16); // FIXME: why 16?
while (running_)
{
// ensure empty buffer
assert(newBuffer1 && newBuffer1->length() == 0);
assert(newBuffer2 && newBuffer2->length() == 0);
// ensure buffersToWrite is empty
assert(buffersToWrite.empty());
{ // push buffer to vector buffersToWrite
muduo::MutexLockGuard lock(mutex_);
if (buffers_.empty())
{ // unusual usage!
cond_.waitForSeconds(flushInterval_); // wait condition or timeout
}
// not empty or timeout
buffers_.push_back(std::move(currentBuffer_));
currentBuffer_ = std::move(newBuffer1);
buffersToWrite.swap(buffers_);
if (!nextBuffer_)
{
nextBuffer_ = std::move(newBuffer2);
}
}
// ensure buffersToWrite is not empty
assert(!buffersToWrite.empty());
if (buffersToWrite.size() > kBuffersToWriteMaxSize) // FIXME: why 25? 25x4MB = 100MB, 也就是說, 從上次loop到本次loop已經堆積超過100MB, 就丟棄多餘緩衝
{
char buf[256];
snprintf(buf, sizeof(buf), "Dropped log message at %s, %zd larger buffers\n",
Timestamp::now().toFormattedString().c_str(),
buffersToWrite.size() - 2);
fputs(buf, stderr);
output.append(buf, static_cast<int>(strlen(buf)));
buffersToWrite.erase(buffersToWrite.begin() + 2, buffersToWrite.end()); // keep 2 buffer
}
// append buffer content to logfile
for (const auto& buffer : buffersToWrite)
{
// FIXME: use unbuffered stdio FILE? or use ::writev ?
output.append(buffer->data(), buffer->length());
}
if (buffersToWrite.size() > 2)
{
// drop non-bzero-ed buffers, avoid trashing
buffersToWrite.resize(2);
}
// move vector buffersToWrite's last buffer to newBuffer1
if (!newBuffer1)
{
assert(!buffersToWrite.empty());
newBuffer1 = std::move(buffersToWrite.back());
buffersToWrite.pop_back();
newBuffer1->reset(); // reset buffer
}
// move vector buffersToWrite's last buffer to newBuffer2
if (!newBuffer2)
{
assert(!buffersToWrite.empty());
newBuffer2 = std::move(buffersToWrite.back());
buffersToWrite.pop_back();
newBuffer2->reset(); // reset buffer
}
buffersToWrite.clear();
output.flush();
}
output.flush();
}
異常處理:
當已滿緩衝佇列中的資料堆積(預設緩衝數超過25),就會丟棄多餘緩衝,只保留最開始2個。
為什麼保留2個?個人覺得2個~16個都是可以的,不過,為了有效減輕log導致的負擔,丟棄多餘的也未嘗不可。
25的含義:
25個緩衝,每個4MB,共100MB。也就是說,上次處理週期到本次,已經堆積了超過100MB資料待處理。
假設磁碟的寫速度100MB/S,要堆積100MB有2種極端情況:
1)1S內產生200MB資料;
2)25秒內,平均每秒產生104MB資料;
不論哪種情況,都是要超過磁碟的處理速度。而實際應用中,只有產生資料速度不到磁碟寫速度的1/10,應用程式效能才不會受到明顯影響。
[======]
LogFile類
LogFile 主要職責:提供對日誌檔案的操作,包括滾動日誌檔案、將log資料寫到當前log檔案、flush log資料到當前log檔案。
建構函式
LogFile::LogFile(const std::string &basename,
off_t rollSize,
bool threadSafe, // 執行緒安全控制項, 預設為true. 當只有一個後端AsnycLogging和後端執行緒時, 該項可置為false
int flushInterval,
int checkEveryN)
: basename_(basename), // 基礎檔名, 用於新log檔案命名
rollSize_(rollSize), // 滾動檔案大小
flushInterval_(flushInterval), // 沖刷時間限值, 預設3 (秒)
checkEveryN_(checkEveryN), // 寫資料次數限值, 預設1024
count_(0), // 寫資料次數計數, 超過限值checkEveryN_時清除, 然後重新計數
mutex_(threadSafe ? new MutexLock : NULL), // 互斥鎖指標, 根據是否需要執行緒安全來初始化
startOfPeriod_(0), // 本次寫log週期的起始時間(秒)
lastRoll_(0), // 上次roll日誌檔案時間(秒)
lastFlush_(0) // 上次flush日誌檔案時間(秒)
{
assert(basename.find('/') == string::npos); // basename不應該包含'/', 這是路徑分隔符
rollFile();
}
重新啟動時,可能並沒有log檔案,因此在構建LogFile物件時,直接呼叫rollFile()以建立一個全新的日誌檔案。
滾動日誌檔案
當日志文件接近指定的滾動限值(rollSize)時,需要換一個新檔案寫資料,便於後續歸檔、檢視。呼叫LogFile::rollFile()可以實現檔案滾動。
bool LogFile::rollFile()
{
time_t now = 0;
string filename = getLogFileName(basename_, &now);
time_t start = now / kRollPerSeconds_ * kRollPerSeconds_;
if (now > lastRoll_)
{ // to avoid identical roll by roll time
lastRoll_ = now;
lastFlush_ = now;
startOfPeriod_ = start;
// create new log file with new filename
file_.reset(new FileUtil::AppendFile(filename));
return true;
}
return false;
}
滾動日誌檔案操作的關鍵是:1)取得新log檔名,檔名全域性唯一;2)建立並開啟一個新log檔案,用指向LogFile物件的unique_ptr指標file_表示。
異常處理:
滾動操作會新建一個檔案,而為避免頻繁建立新檔案,rollFile會確保上次滾動時間到現在如果不到1秒,就不會滾動。
注意:是否滾動日誌檔案的條件判斷,並不在rollFile,而是在寫資料到log檔案的LogFile::append_unlocked()中,因為寫新資料的時候,是判斷當前log檔案大小是否足夠大的最合適時機。而rollFile只用專門負責如何滾動log檔案即可。
日誌檔名
getLogFileName根據呼叫者提供的基礎名,以及當前時間,得到一個全新的、唯一的log檔名。或許叫nextLogFileName更合適。
string LogFile::getLogFileName(const string &basename, time_t *now) // static
{
string filename;
filename.reserve(basename.size() + 64); // extra 64 bytes for timestamp etc.
filename = basename;
char timebuf[32];
struct tm tmbuf;
*now = time(NULL);
gmtime_r(now, &tmbuf); // FIXME: localtime_r ?
strftime(timebuf, sizeof(timebuf), ".%Y%m%d-%H%M%S.", &tmbuf);
filename += timebuf;
filename += ProcessInfo::hostname();
char pidbuf[32];
snprintf(pidbuf, sizeof(pidbuf), ".%d", ProcessInfo::pid());
filename += pidbuf;
filename += ".log";
return filename;
}
gmtime_r獲取的是gmt時區時間,localtime_r獲取的是本地時間。
新log檔名格式:
basename + now + hostname + pid + ".log"
basename 基礎名, 由使用者指定, 通常可設為應用程式名
now 當前時間, 格式: "%Y%m%d-%H%M%S"
hostname 主機名
pid 程序號, 通常由OS提供, 通過getpid獲取
".log" 固定字尾名, 表明這是一個log檔案
各部分之間, 用"."連線
如下面是一個根據basename為"test_log_mt"生成的log檔名:
test_log_mt.20220218-134000.ubuntu.12426.log
寫日誌檔案操作
LogFile提供了2個介面,用於向當前日誌檔案file_寫入資料。append本質上是通過append_unlocked完成對日誌檔案寫操作,但多了執行緒安全。使用者只需呼叫第一個介面即可,append會根據執行緒安全需求,自行判斷是否需要加上;第二個是private介面。
void append(const char *logline, int len);
void append_unlocked(const char *logline, int len);
append_unlocked 會先將log訊息寫入file_檔案,之後再判斷是否需要滾動日誌檔案;如果不滾動,就根據append_unlocked的呼叫次數和時間,確保1)一個log檔案超時(預設1天),就建立一個新的;2)flush檔案操作,不會頻繁執行(預設間隔3秒)。
void LogFile::append_unlocked(const char *logline, int len)
{
file_->append(logline, len);
if (file_->writtenBytes() > rollSize_)
{ // written bytes to file_ > roll threshold (rollSize_)
rollFile();
}
else
{
++count_;
if (count_ >= checkEveryN_)
{
count_ = 0;
time_t now = ::time(NULL);
time_t thisPeriod_ = now / kRollPerSeconds_ * kRollPerSeconds_;
if (thisPeriod_ != startOfPeriod_)
{ // new period, roll file for log
rollFile();
}
else if (now - lastFlush_ > flushInterval_)
{ // timeout ( flushInterval_ = 3 seconds)
lastFlush_ = now;
file_->flush();
}
}
}
}
append如何根據需要選擇是否執行緒安全地呼叫append_unlocked?
可以根據mutex_是否為空。因為構造時,根據使用者傳入的threadSafe實參,決定了mutex_是否為空。
void LogFile::append(const char *logline, int len)
{
if (mutex_)
{
MutexLockGuard lock(*mutex_);
append_unlocked(logline, len);
}
else
{
append_unlocked(logline, len);
}
}
flush日誌檔案
flush操作往往與write檔案操作配套。LogFile::flush實際上是通過AppendFile::flush(),完成對日誌檔案的沖刷。與LogFile::append()類似,flush也能通過mutex_指標是否為空,自動選擇執行緒安全版本,還是非執行緒安全版本。
void LogFile::flush()
{
if (mutex_)
{
MutexLockGuard lock(*mutex_);
file_->flush();
}
else
{
file_->flush();
}
}
[======]
AppendFile類
AppendFile位於FileUtil.h/.cc,封裝了OS提供的,底層的建立/開啟檔案、寫檔案、關閉檔案等操作介面,並沒有專門考慮執行緒安全問題。執行緒安全由上一層級呼叫者,如LogFile來保證。
資料結構
AppendFile的資料結構較簡單,
// not thread safe
class AppendFile : public noncopyable
{
public:
explicit AppendFile(StringArg filename);
~AppendFile();
void append(const char* logline, size_t len); // 新增log訊息到檔案末尾
void flush(); // 沖刷檔案到磁碟
off_t writtenBytes() const { return writtenBytes_; } // 返回已寫位元組數
private:
size_t write(const char* logline, size_t len); // 寫資料到檔案
FILE* fp_; // 檔案指標
char buffer_[ReadSmallFile::kBufferSize]; // 檔案操作的緩衝區
off_t writtenBytes_; // 已寫位元組數
};
RAII方式開啟、關閉檔案
AppendFile採用RAII方式管理檔案資源,構建物件即開啟檔案,銷燬物件即關閉檔案。
AppendFile::AppendFile(StringArg filename)
: fp_(::fopen(filename.c_str(), "ae")), // 'e' for O_CLOEXEC
writtenBytes_(0)
{
assert(fp_);
::setbuffer(fp_, buffer_, sizeof(buffer_)); // change stream fp_'s buffer to buffer_
#if 0
// optimization for predeclaring an access pattern for file data
struct stat statbuf;
fstat(fd_, &statbuf);
::posix_fadvise(fp_, 0, statbuf.st_size, POSIX_FADV_DONTNEED);
#endif
}
AppendFile::~AppendFile()
{
::fclose(fp_);
}
為posix_fadvise(2)指定POSIX_FADV_DONTNEED選項,告訴核心在近期不會訪問檔案的指定資料,以便核心對其進行優化。
寫資料到檔案
AppendFile有個兩個介面:append和write。其中,append()是供使用者呼叫的public介面,確保將指定資料附加到檔案末尾,實際的寫檔案操作是通過write()來完成的;write通過非執行緒安全的glibc庫函式fwrite_unlocked()來完成寫檔案操作,而沒有選擇執行緒安全的fwrite(),主要是出於效能考慮。
一個後端通常只有一個後端執行緒,一個LogFile物件,一個AppendFile物件,這樣,也就只會有一個執行緒寫同一個log檔案。
void AppendFile::append(const char *logline, size_t len)
size_t AppendFile::write(const char *logline, size_t len);
append和write實現:
void AppendFile::append(const char *logline, size_t len)
{
size_t written = 0;
/* write len byte to fp_ unless complete writing or error occurs */
while (written != len)
{
size_t remain = len - written;
size_t n = write(logline + written, remain);
if (n != remain)
{
int err = ferror(fp_);
if (err)
{
fprintf(stderr, "AppendFile::append() failed %s\n", strerror_tl(err));
clearerr(fp_); // clear error indicators for fp_
break;
}
}
written += n;
}
writtenBytes_ += written;
}
size_t AppendFile::write(const char *logline, size_t len)
{
// not thread-safe
return ::fwrite_unlocked(logline, 1, len, fp_);
}
可以看出,append是通過一個迴圈來確保所有資料都寫到磁碟檔案上,除非發生錯誤。
[======]
使用非同步日誌
自此,一個完整的非同步日誌前端、後端都已完成。但問題在於,應用程式如何使用?
為此,寫一個測試程式,對比為前端Logger設定輸出回撥函式前後不同。
#include "muduo/base/AsyncLogging.h"
#include "muduo/base/Logging.h"
#include "muduo/base/Timestamp.h"
#include <stdio.h>
#include <unistd.h>
using namespace muduo;
static const off_t kRollSize = 1*1024*1024;
AsyncLogging* g_asyncLog = NULL;
inline AsyncLogging* getAsyncLog()
{
return g_asyncLog;
}
void test_Logging()
{
LOG_TRACE << "trace";
LOG_DEBUG << "debug";
LOG_INFO << "info";
LOG_WARN << "warn";
LOG_ERROR << "error";
LOG_SYSERR << "sys error";
// 注意不能輕易使用 LOG_FATAL, LOG_SYSFATAL, 會導致程式abort
const int n = 10;
for (int i = 0; i < n; ++i) {
LOG_INFO << "Hello, " << i << " abc...xyz";
}
}
void test_AsyncLogging()
{
const int n = 3*1024*1024;
for (int i = 0; i < n; ++i) {
LOG_INFO << "Hello, " << i << " abc...xyz";
}
}
void asyncLog(const char* msg, int len)
{
AsyncLogging* logging = getAsyncLog();
if (logging)
{
logging->append(msg, len);
}
}
int main(int argc, char* argv[])
{
printf("pid = %d\n", getpid());
AsyncLogging log(::basename(argv[0]), kRollSize);
test_Logging();
sleep(1);
g_asyncLog = &log;
Logger::setOutput(asyncLog); // 為Logger設定輸出回撥, 重新配接輸出位置
log.start();
test_Logging();
test_AsyncLogging();
sleep(1);
log.stop();
return 0;
}
可以發現,在呼叫Logger::setOutput設定輸出回撥前,預設輸出位置是stdout(見defaultOutput),而設定了輸出位置為自定義asyncLog後,每當Logger要輸出log訊息時,就會通過asyncLog呼叫預先設定好的g_asyncLog的append()函式,將log訊息輸出到AsycnLogging的Large Buffer中。
從這裡也可以發現,muduo日誌庫AsycnLogging類有個bug:AsycnLogging並沒有同步設定一個flush函式,這樣Logger::flush呼叫的其實還是預設的flush到stdout,並不能跟AsycnLogging::append()同步。當然,這不是什麼難事,直接為其新增一個自定義flush()即可。
[======]
小結
1)AsyncLogging 提供多個Large Buffer快取多條log訊息,前端需要在重新配接輸出位置後,將每條log訊息輸出到Large Buffer中。後端執行緒也是由AsyncLogging 負責維護。
2)LogFile 提供日誌檔案操作,包括滾動日誌檔案、寫日誌檔案。
3)AppendFile 封裝了最底層的的寫檔案操作,供LogFile使用。
[======]
參考
https://blog.csdn.net/luotuo44/article/details/19252535
https://docs.microsoft.com/en-us/previous-versions/af6x78h6(v=vs.140)?redirectedfrom=MSDN