日誌系統幾乎是每一個實際的軟件項目從開發、測試到交付,再到後期的維護過程中極爲重要的查看軟件代碼運行流程、還原錯誤現場、記錄運行錯誤位置及上下文等的重要依據。一個高性能的日誌系統,能夠準確記錄重要的變量信息,同時又沒有冗餘的打印導致日誌文件記錄無效的數據。本文Jungle將用C++設計實現一個日誌系統。
1.爲什麼需要日誌
爲什麼需要日誌?其實在引言中已經提到了,實際的軟件項目的幾乎每個過程,都離不開日誌。初學代碼時,Jungle的第一行代碼是實現打印“hello world”,打印到控制檯。在後來的學習中,Jungle又學會了設斷點調試代碼,在適當的地方通過斷點來觀察變量的值。但在實際的軟件項目中,試想一下,通過輸出到控制檯或者通過設斷點來調試代碼,可能嗎?
- 客戶現場,會讓你現場打印到控制檯上調試嗎?
- 報了error的軟件項目,你能夠明確知道軟件crash的位置嗎?
- 你能保證設斷點可以還原error時候的現場嗎?
- 概率性的error事件,設斷點還奏效嗎?
- 如果是時效性的代碼(比如USB連接) ,設斷點調試還合理嗎?
- ……
日誌,可以記錄每一時刻軟件的運行情況,記錄error或者crash時的信息(時間、關鍵變量的值、出錯位置、線程等);另一方面,對於概率性error事件,可以在重複測試時通過日誌來查詢錯誤復現時候的情況。簡言之,日誌是跟蹤和回憶某個時刻或者時間段內的程序行爲進而定位問題的一種重要手段。
2.日誌系統設計
軟件運行過程中,需要記錄的有什麼呢?前述已經提到,關鍵變量的值、運行的位置(哪個文件、哪個函數、哪一行)、時間、線程號、進程號。本文Jungle採用C++設計了LOG類,介紹LOG類的設計之前,需要提及的是log的級別和log位置。
2.1.1.log級別
Log級別是什麼意思呢?在開發階段,Jungle可能想盡可能詳細地跟蹤代碼運行過程,所以可以打印儘可能多的信息到日誌文件中;測試過程中,測試部可能不需要這麼詳細的信息,所以這時候有的信息可能不必輸出到Log文件;產品交付客戶使用時,爲了軟件運行更快、客戶體驗更好,這時候就只需打印關鍵信息到日誌文件了,因爲過多的寫文件會耗費大量時間,影響軟件運行速度。所以Jungle爲LOG類定義瞭如下級別:
enum LOGLEVEL
{
LOG_LEVEL_NONE,
LOG_LEVEL_ERROR, // error
LOG_LEVEL_WARNING, // warning
LOG_LEVEL_DEBUG, // debug
LOG_LEVEL_INFO, // info
};
在軟件設計中,可以通過某些方法或者預留一些開關來設置Log級別,方便在開發、調試、測試和客戶現場靈活地調整日誌級別,以獲取到有用的日誌信息。
2.1.2.log輸出位置
Log文件可以輸出到控制檯(其實也是不錯的方法),也可以輸出到指定路徑下的某個文件裏,也可能有別的需求。比如,開發或調試時,簡單的信息直接就打印到軟件某個界面上;測試或者交付客戶時,最好將日誌保存到文件裏,這樣可以保存儘可能多的信息。因此,Jungle進行了如下設計:
enum LOGTARGET
{
LOG_TARGET_NONE = 0x00,
LOG_TARGET_CONSOLE = 0x01,
LOG_TARGET_FILE = 0x10
};
2.1.3.log的作用域
一個軟件系統,要在哪兒輸出日誌呢?Everywhere!只要是你想打印日誌的地方,任何一個函數、任何一個文件,都應該而且必須可以打印。也就是說這個log類的對象(不妨叫做日誌記錄器),日誌記錄器必須是全局的!
光是全局的就夠了嗎?你這個文件裏有一個全局的日誌記錄器,輸出日誌到file.log文件裏;另一個文件裏也有一個日誌記錄器,也輸出到file.log文件裏……多個日誌記錄器同時往一個文件裏寫日誌,這顯然不合理。所以還必須保證日誌記錄器全局且唯一!
怎麼保證日誌記錄器唯一呢?即Log類在具體的軟件系統中有且僅有一個實例化對象。答案是採用單例模式!(設計模式(九)——單例模式)
2.2.日誌類的設計
綜上所述,Jungle設計的日誌類LOG如下:
class LOG
{
public:
// 初始化
void init(LOGLEVEL loglevel, LOGTARGET logtarget);
//
void uninit();
// file
int createFile();
static LOG* getInstance();
// Log級別
LOGLEVEL getLogLevel();
void setLogLevel(LOGLEVEL loglevel);
// Log輸出位置
LOGTARGET getLogTarget();
void setLogTarget(LOGTARGET logtarget);
// 打log
static int writeLog(
LOGLEVEL loglevel, // Log級別
unsigned char* fileName, // 函數所在文件名
unsigned char* function, // 函數名
int lineNumber, // 行號
char* format, // 格式化
...); // 變量
// 輸出log
static void outputToTarget();
private:
LOG();
~LOG();
static LOG* Log;
// 互斥鎖
static mutex log_mutex;
// 存儲log的buffer
static string logBuffer;
// Log級別
LOGLEVEL logLevel;
// Log輸出位置
LOGTARGET logTarget;
// Handle
static HANDLE mFileHandle;
};
其中,互斥鎖log_mutex是用於在多線程環境下保證只創建一個LOG類的實例 (設計模式(九)——單例模式);mFileHandle是log文件的句柄。
2.3.日誌類的實現
2.3.1.初始化
LOG* LOG::Log = NULL;
string LOG::logBuffer = "";
HANDLE LOG::mFileHandle = INVALID_HANDLE_VALUE;
mutex LOG::log_mutex;
LOG::LOG()
{
// 初始化
init(LOG_LEVEL_NONE, LOG_TARGET_FILE);
}
void LOG::init(LOGLEVEL loglevel, LOGTARGET logtarget)
{
setLogLevel(loglevel);
setLogTarget(logtarget);
createFile();
}
void LOG::uninit()
{
if (INVALID_HANDLE_VALUE != mFileHandle)
{
CloseHandle(mFileHandle);
}
}
LOG* LOG::getInstance()
{
if (NULL == Log)
{
log_mutex.lock();
if (NULL == Log)
{
Log = new LOG();
}
log_mutex.unlock();
}
return Log;
}
LOGLEVEL LOG::getLogLevel()
{
return this->logLevel;
}
void LOG::setLogLevel(LOGLEVEL iLogLevel)
{
this->logLevel = iLogLevel;
}
LOGTARGET LOG::getLogTarget()
{
return this->logTarget;
}
void LOG::setLogTarget(LOGTARGET iLogTarget)
{
this->logTarget = iLogTarget;
}
初始化工作設置了日誌的級別和輸出位置(代碼中提供了日誌級別和輸出位置的setter、getter方法)。函數createFile()是創建日誌文件位置,並獲取日誌文件的句柄mFileHandle。代碼如下:
int LOG::createFile()
{
TCHAR fileDirectory[256];
GetCurrentDirectory(256, fileDirectory);
// 創建log文件的路徑
TCHAR logFileDirectory[256];
_stprintf_s(logFileDirectory, _T("%s\\Test\\"), fileDirectory);// 使用_stprintf_s需要包含頭文件<TCHAR.H>
// 文件夾不存在則創建文件夾
if (_taccess(logFileDirectory, 0) == -1)
{
_tmkdir(logFileDirectory);
}
TCHAR cTmpPath[MAX_PATH] = { 0 };
TCHAR* lpPos = NULL;
TCHAR cTmp = _T('\0');
WCHAR pszLogFileName[256];
// wcscat:連接字符串
wcscat(logFileDirectory, _T("test.log"));
_stprintf_s(pszLogFileName, _T("%s"), logFileDirectory);
mFileHandle = CreateFile(
pszLogFileName,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ,
NULL,
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (INVALID_HANDLE_VALUE == mFileHandle)
{
return -1;
}
return 0;
}
其中,需要介紹的是下述函數:
- GetCurrentDirectory:在一個緩衝區中裝載當前目錄
- _stprintf_s:將若干個參數按照format格式存到buffer中
- _taccess:判斷文件是否存在,返回值0表示該文件存在,返回-1表示文件不存在或者該模式下沒有訪問權限
- _tmkdir:創建一個目錄
2.3.2.寫日誌
以下是writeLog()方法的實現:
int LOG::writeLog(
LOGLEVEL loglevel, // Log級別
unsigned char* fileName, // 函數所在文件名
unsigned char* function, // 函數名
int lineNumber, // 行號
char* format, // 格式化
...)
{
int ret = 0;
// 獲取日期和時間
char timeBuffer[100];
ret = getSystemTime(timeBuffer);
logBuffer += string(timeBuffer);
// LOG級別
char* logLevel;
if (loglevel == LOG_LEVEL_DEBUG){
logLevel = "DEBUG";
}
else if (loglevel == LOG_LEVEL_INFO){
logLevel = "INFO";
}
else if (loglevel == LOG_LEVEL_WARNING){
logLevel = "WARNING";
}
else if (loglevel == LOG_LEVEL_ERROR){
logLevel = "ERROR";
}
// [進程號][線程號][Log級別][文件名][函數名:行號]
char locInfo[100];
char* format2 = "[PID:%4d][TID:%4d][%s][%-s][%s:%4d]";
ret = printfToBuffer(locInfo, 100, format2,
GetCurrentProcessId(),
GetCurrentThreadId(),
logLevel,
fileName,
function,
lineNumber);
logBuffer += string(locInfo);
// 日誌正文
char logInfo2[256];
va_list ap;
va_start(ap, format);
ret = vsnprintf(logInfo2, 256, format, ap);
va_end(ap);
logBuffer += string(logInfo2);
logBuffer += string("\n");
outputToTarget();
return 0;
}
2.3.3.輸出日誌
void LOG::outputToTarget()
{
if (LOG::getInstance()->getLogTarget() & LOG_TARGET_FILE)
{
SetFilePointer(mFileHandle, 0, NULL, FILE_END);
DWORD dwBytesWritten = 0;
WriteFile(mFileHandle, logBuffer.c_str(), logBuffer.length(), &dwBytesWritten, NULL);
FlushFileBuffers(mFileHandle);
}
if (LOG::getInstance()->getLogTarget() & LOG_TARGET_CONSOLE)
{
printf("%s", logBuffer.c_str());
}
// 清除buffer
logBuffer.clear();
}
- SetFilePointer:將文件指針移動到文件指定的位置
- FlushFileBuffers:把寫文件緩衝區的數據強制寫入磁盤
爲了使用方便,可以定義一些宏來簡化函數的使用,本文不再贅述。
3.測試
Jungle將上述設計實現的日誌系統應用到了之前寫的一些小程序裏,比如在之前的“欲戴王冠,必承其重”——深度解析職責鏈模式的代碼。如何添加呢?就是將兩個文件(頭文件和源文件)加入工程,包含頭文件,再在需要打log的地方加上Jungle在日誌類裏定義的宏即可。下列是示例log:
因爲程序比較簡單,代碼量很小,所以只有一個線程(log中TID都是一樣的)。但上述測試結果驗證了Jungle設計的日誌系統是可行的。
4.多線程環境
4.1.多線程環境測試
接下來Jungle設計一個簡單的多線程環境,測試一下上述日誌系統,測試代碼如下:
#define THREAD_NUM 5
// 全局資源變量
int g_num = 0;
unsigned int __stdcall func(void *pPM)
{
LOG_INFO("enter");
Sleep(50);
g_num++;
LOG_INFO("g_num = %d", g_num);
LOG_INFO("exit");
return 0;
}
int main()
{
LOG *logger = LOG::getInstance();
HANDLE handle[THREAD_NUM];
//線程編號
int threadNum = 0;
while (threadNum < THREAD_NUM)
{
handle[threadNum] = (HANDLE)_beginthreadex(NULL, 0, func, NULL, 0, NULL);
//等子線程接收到參數時主線程可能改變了這個i的值
threadNum++;
}
//保證子線程已全部運行結束
WaitForMultipleObjects(THREAD_NUM, handle, TRUE, INFINITE);
return 0;
}
上述代碼中,Jungle一共開啓了5個線程,理論上打印的日誌文件裏,TID應該出現5個不同的數值。每個線程裏打印全局變量(即全局共享資源)的值。下面是輸出的日誌,一共運行了兩次(第5、6行隔開):
問題來啦!
首先,在第一次運行輸出的日誌裏,出現了亂碼!(第1行和第4行),而且看起來該輸出log的地方沒有完全輸出(真的嗎?)
其次,在第二次運行輸出的日誌裏,一行log裏好像打印了兩次日誌(第8行)!
問題出在哪裏呢?
爲什麼會出現亂碼?仔細看第8行log,其實打印的都是同一個時刻、同一個位置,都是在調用writeLog函數(宏LOG_INFO即是調用writeLog函數)時出現的問題,也就是說在這個時刻,兩個線程都跑到函數writeLog裏寫log,導致logBuffer緩衝區裏存放了兩次信息。只不過第8行運氣較好,每次的編碼都保存完整。而第1行和第4行就沒這麼走運了!(logBuffer裏已經完全亂了!)所以根本問題是,多個線程在同一個時刻訪問了同一個資源!所以針對多線程環境,我們需要做到共享資源的互斥!
4.2.線程安全的日誌系統
在單例模式的設計實現裏已經提到了線程安全,Jungle用互斥鎖達到了互斥的目的。本文也可以使用互斥鎖(並且在日誌對象實例的單例模式中已經使用),但在這裏Jungle想用另一種方法:臨界區。
在Log類成員裏聲明一個CRITICAL_SECTION對象criticalSection,初始化時:
InitializeCriticalSection(&criticalSection);
當然,最好在釋放資源時加上下述代碼:
DeleteCriticalSection(&criticalSection);
而在進入writeLog時和離開writeLog時加上下述代碼:
int LOG::writeLog(...)
{
int ret = 0;
EnterCriticalSection(&criticalSection);
// do something
LeaveCriticalSection(&criticalSection);
return 0;
}
需要提及的是,最好是在LeaveCriticalSection之後再DeleteCriticalSection。
接下來再在多線程環境裏測試,Jungle測試了幾次,但爲了縮短篇幅,只展示一次的結果:
可以看到,日誌完整記錄了每個線程的運行過程(線程號TID不同)。
5.注意事項
儘管上述已經基本實現了日誌系統,但仍有很大的改進空間,在調試代碼和查閱資料的過程中,Jungle發現需要注意以下幾個問題:
- 字符編碼問題:寬字符、ANSI編碼等多種不同編碼的兼容;
- Visio Studio版本的差異:Jungle本想將日誌系統應用到之前設計的一個機器人仿真控制器裏,但遺憾的是編譯不通過,因爲那個代碼是用Visio Studio 2008寫的,而Mutex是C++2011標準的內容,需要用支持該新標準的編譯器,比如VS2012及以上版本。(當然了,可以用臨界區等其他方法實現互斥,這裏Jungle只是提出這個需要注意的問題);
- 關於宏_CRT_SECURE_NO_WARNINGS:是的,需要在預處理器里加上這個宏或者代碼裏顯示聲明這個宏,否則編譯不通過,如下圖。原因是代碼中使用的wcscat等函數不安全,可能會造成內存泄露等。解決方法除了前述提到的聲明宏以外,還可以使用更安全的函數。
最後,推薦兩篇不錯的關於日誌系統的文章:
歡迎評論區與Jungle交流,歡迎關注Jungle的公衆號!
歡迎關注知乎專欄:Jungle是一個用Qt的工業Robot
歡迎關注Jungle的微信公衆號:Jungle筆記