我的github:codetoys,所有代码都将会位于ctfc库中。已经放入库中我会指出在库中的位置。
这些代码大部分以Linux为目标但部分代码是纯C++的,可以在任何平台上使用。
1 概述
程序日志输出是一个基本功能,主要用来调试程序,功能可大可小,大的像Log4j这样的中间件,小的像cout输出到控制台。
选择中间件当然不是我们每个程序员都能决定的,但是我们可以给自己编写一个日志接口,适用于所有的日志系统。
日志接口当然可以用函数,但是C++的流操作看起来更舒适,所以我们写一个仿照cout的接口,支持<<操作。
在这个示例中,你将会练习到类设计的各种方面。你也很容易改写这个代码用于自己的项目。
示例调用和输出:
代码:
myLog << "明文长度 " << c2 << ENDI;
输出
[0808 09:41:17 949][信息 ]ConsoleApplication1.cpp[0047]明文长度 20
输出格式:
[月日 时:分:秒 毫秒][类别]文件名[行号]信息。。。。。。
注意本例中控制台输出并没有格式化,格式化后的信息只输出到“程序名.log”文件中。
2 设计目标
- 全局对象。日志对象应该是一个全局对象,在任何地方都可以直接使用。很多人会立即想到用单件模式,这是对的,不过单件模式是用来约束别人的,如果你有自觉性,可以直接用全局变量。
- 支持流操作<<。这通常通过重载operator<<实现,需要对所有基本类型实现,工作量看起来很大,不过这是必须的,而且这只是一次性工作,完成后再也不用修改。
- 支持日志分类或级别。一般来说,日志可以分为调试、信息、警告、出错等,通过一个开关就可以直接控制调试信息是否输出。
- 日志格式。一般会以日期时间开始,然后是日志级别,最后是信息。有时候我们希望加入一些特定信息,比如线程号、CPU时间,这些信息尽量在日志类里面实现,不需要修改调用方代码。如果需要输出文件名和行号,可以用宏来实现。
- 多种输出。日志可能同时输出到控制台、文件或日志中间件,这些工作在日志接口内部完成,不需要调用方代码干预。
3 完整源码
整个代码包括两个文件,基于VS2022控制台项目,多字节字符集,用了一些windows API。重点是接口而不是实现,因为在不同平台下工作,我有好几个不同的实现。
头文件(挺长,后面分别解释):
#define myLog _myLog.LogPos(__FILE__,__LINE__)
class CEndI
{};
extern CEndI ENDI;
class CMyLog
{
private:
HANDLE hFile = 0;
char* m_buf = nullptr;//内部用的临时缓存
int m_buflen = 0;
std::string filename;
int fileline = -1;
std::string message;//缓存的信息,直到ENDI才会实际输出
void SetBuf(int len)
{
if (m_buflen < len)
{
delete[] m_buf;
m_buf = new char[len];
if (!m_buf)m_buflen = 0;
else m_buflen = len;
}
}
bool Output(char const* type)
{
std::string tmpstr;
SYSTEMTIME t;
GetLocalTime(&t);
char buf[256];
sprintf_s(buf, "[%02d%02d %02d:%02d:%02d %03d][%-8s]", t.wMonth, t.wDay, t.wHour, t.wMinute, t.wSecond, t.wMilliseconds, type);
tmpstr += buf;
if (filename.size() > 0)tmpstr += filename.substr(filename.find_last_of('\\') + 1);
sprintf_s(buf, "[%04d]", fileline);
if (fileline > 0)tmpstr += buf;
std::cout << message << std::endl;
tmpstr += message + "\r\n";
SetFilePointer(hFile, 0, NULL, FILE_END);
DWORD dwCount;
WriteFile(hFile, tmpstr.c_str(), (DWORD)tmpstr.size(), &dwCount, 0);
filename = "";
fileline = -1;
message = "";
return true;
}
public:
CMyLog()
{
char pszPathName[2048];
GetModuleFileName(NULL, pszPathName, 2048);
StrCat(pszPathName, ".log");
hFile = CreateFile(pszPathName, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL
, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0);
if (INVALID_HANDLE_VALUE == hFile)
{
std::cout << "创建或打开日志文件失败" << std::endl;
}
}
~CMyLog()
{
if (hFile)CloseHandle(hFile);
if (m_buf)delete[] m_buf;
}
CMyLog& LogPos(char const* file, int line)
{
filename = file;
fileline = line;
return *this;
}
CMyLog& operator<<(wchar_t const* str)
{
size_t len;
if (S_OK == StringCchLengthW(str, STRSAFE_MAX_CCH, &len))
{
SetBuf((int)(len * sizeof(wchar_t) + 1));
int count = WideCharToMultiByte(CP_ACP, 0, str, -1, m_buf, (int)m_buflen, NULL, NULL);
if (0 != count)
{
return operator<<(m_buf);
}
}
return *this;
}
CMyLog& operator<<(char const* str)
{
message += str;
return *this;
}
CMyLog& operator<<(std::string const& str)
{
message += str;
return *this;
}
CMyLog& operator<<(unsigned long long nn)
{
char buf[256];
sprintf_s(buf, "%lld", nn);
return operator<<(buf);
}
CMyLog& operator<<(CEndI const&)
{
Output("信息");
return *this;
}
CMyLog& ShowBuf(unsigned char const* buffer, long long len)
{
operator<<("\r\n");
char buf[256];
for (long long i = 0; i < len; ++i)
{
sprintf_s(buf, "%02X", buffer[i]);
operator<<(buf);
if (i + 1 % 32 == 0)operator<<("\r\n");
}
operator<<(ENDI);
return *this;
}
};
extern CMyLog _myLog;
源文件:
#include 头文件
CEndI ENDI;
CMyLog _myLog;
这个文件里面只有两个全局变量的定义。
4 细节解释
4.1 宏 类 全局对象
#define myLog _myLog.LogPos(__FILE__,__LINE__)
class CEndI
{};
extern CEndI ENDI;
class CMyLog
{
public:
CMyLog();
~CMyLog();
CMyLog& LogPos(char const* file, int line);
CMyLog& operator<<(wchar_t const* str);
CMyLog& operator<<(char const* str);
CMyLog& operator<<(std::string const& str);
CMyLog& operator<<(unsigned long long nn);
CMyLog& operator<<(CEndI const&);
CMyLog& ShowBuf(unsigned char const* buffer, long long len);
};
extern CMyLog _myLog;
删除非接口部分就很简单明了了:
日志类CMyLog的全局对象是_myLog,有默认构造函数和析构函数,方法LogPos用来记录文件名和行号,有一组operator<<输出各种类型的信息(种类比较少是因为别的类型没用到),ShowBuf是个辅助函数,与日志类功能无关。
宏myLog设置了文件名和行号。
CEndI是个空类,仅存在一个全局对象,妙处是可以写这样的代码:
myLog << "信息啊啊啊啊啊啊啊啊" << a << " " << b << c << ENDI;
CEndI代表日志级别为“信息”,类似的,可以再定义CEndD代表“调试”、CEenE代表“出错”。相应的也需要增加重载的operator<<。
myLog是个宏,设置了文件名和行号,操作符<<输出信息,最后是ENDI,触发重载的CMyLog& operator<<(CEndI const&),这个重载的功能是将整条信息格式化后输出。
讨论一下这个接口的设计问题。
全局变量用下划线开头(_myLog),这是违反规则的,一个或两个下划线开头的变量应该留给编译器使用。
类里包含了一个与类无关的方法(ShowBuf),这是不对的。“设计”的第一要义是“概念完整性”,不要多、不要少、不要歪。
单件模式是不是更好?单件模式挺酷的,但意思真的不大。而且,涉及到动态库的时候,单件模式通常采用的在头文件里的方法里的静态变量的方式在某些环境下会失效,这比链接了两次同一个cpp要难发现得多(早年在IBM-AIX上发生了这个问题)。
4.2 构造和析构
类构造完成后应该处于合法可用状态,析构之后不应该有残留(类本身空间不算)。本例中在构造时打开文件,析构时关闭文件。
关闭文件或删除内存时应该先检查有效性,不检查而直接关闭文件或删除内存是常见的BUG来源(程序退出时异常)。
对于全局变量、静态变量的构造,要注意不要依赖其它对象,因为全局变量和静态变量的构造顺序是无法预知的(写在前面也不一定先构造)。
4.3 操作符重载
操作符重载可以干任何事,比如我们这里输出ENDI对象其实是输出之前的信息,ENDI本身没有包含任何输出。原则上操作符重载应该符合操作符的公认的语义。
operator<<的返回值可以是任意的,但是一般都返回对象自身的引用,好处就是可以写出“a<<b<<c<<d”这样的代码,如果返回别的东西,这样的代码就无法编译了。
4.4 多种输出
本例中Output方法负责产生输出,输出到文件的信息是格式化过的,输出到控制台的是原始信息。很容易根据自己的需要修改,也可以加上输出到日志中间件的代码。
4.5 日志级别控制
一般人们主张输出调试信息的代码根本不要出现发布版本中,但是实际上经常需要对发布的程序进行调试,这意味着用参数和变量控制更合适。而且,代码内存布局的变化会引发BUG(实际是暴露BUG),经常见到有人抱怨“debug版本好好的,release版就挂了”,所以保持单一版本是有好处的。
(这里是文档结束)