C++ 类型安全日志记录器






3.71/5 (9投票s)
C++ 的类型安全目标透明日志记录器
问题
每个应用程序都会记录大量的诊断消息,主要用于(生产)调试,记录到控制台、标准错误设备或文件。日志可以写入到许多其他目的地。无论每个应用程序必须能够配置哪个目的地,诊断日志消息以及生成消息的方式是我们现在感兴趣的。因此,我们需要一个能够对日志记录目的地透明的 Logger
类。这应该不是问题,设计它会很有趣。
现在问题的关键是日志消息的生成。通常,日志消息是在您的代码中动态生成的。例如,如果用户调用 API int GetFileSize(LPCTSTR lpszFilePath)
,日志可能看起来像这样:
GetFileSize - File: C:\Temp\Sample.txt. Size: 1492 bytes.
在上面这行日志中,文件路径和大小值在运行时根据文件而知。日志还可能包含当前日期和时间、请求文件大小的当前登录用户以及其他各种可能对用户来说信息丰富的内容。所以,毫无疑问,我们的日志记录器应该有一个带可变参数的方法。让我们来草拟第一个 Logger
类。
class Logger
{
public: Logger() ;
public: ~Logger() ;
public: void LogMessage(const std::string& category, const std::string& fmtSpec, ...);
};
可变参数的功能已经足够。C/C++ 风格的格式规范 string
使用 %
符号,晦涩难懂,并且难以匹配每种类型的相应格式规范;当格式规范 string
很长时尤其困难。例如,为了输出一个 int
,格式说明符必须是 %d
;如果程序员错误地指定了 %s
,整个程序就会崩溃。应用程序会悲惨地崩溃。这是我们要解决的主要问题——类型安全的日志记录。
所以:
- 我们的日志记录器必须提供类型安全的日志记录,这意味着由于格式规范不匹配或参数数量不正确而导致崩溃的可能性几乎为零。
- C++ 编程语言和 Windows 操作系统都没有提供方便通用的日志记录工具。
Logger
必须与日志目的地松散耦合。- 可变参数设施非常原始,C 程序员可能会很高兴,而 C++ 将所有内容抽象和封装为对象。
解决方案 - (类型安全) 日志记录器
首要的障碍是指定可变数量参数的功能,尤其是以类型安全的方式。幸运的是,标准模板库是我们的救星。我们可以依靠 std::ostringstream
可爱的小工具来即时生成消息。快速浏览一下:
std::ostringstream ostr;
ostr << "GetFileSize - File: " << filePath.c_str() <<
"\tSize: " << fileSize << "bytes.";
这样构建的消息可以导向任何目的地——文件、标准错误、用户界面等。这是我们解决方案的核心。
然而,消息的构建必须基于格式规范。格式规范的一个优点是它提供了一个将要构建/记录的消息的全局视图;另一方面,从上面 std::ostringstream
构造中读取和了解消息是晦涩难懂的。这意味着我们正在考虑融合和创新一种 Logging
构造,它涉及 printf
类的格式规范与 std::ostringstream
。尽管我们似乎已经解决了可变参数问题,但我们在格式规范上又回到了原点。我们的目标是摆脱 %ld
必须匹配数字或 %s
匹配字符串的世界。就此而言,即使 std::ostringstream
的 << operator
也不能很好地处理 std::string
类型的参数。
如果这是我们的痛点,那么让我们设计自己的格式规范,它能提供类型安全。通过类型安全,我们旨在永不发生运行时崩溃并检测规范异常。所以让我们使用 .NET 风格的格式规范,它使用参数索引占位符,但格式说明符仍然是 %
。因此,对于我们的 GetFileSize
示例,格式规范 string
可能如下所示:
"GetFileSize - File: %0. Size: %1 bytes. User: %2. Is '%0' read-only: %3"
现在,你肯定会说“哇!”。上述格式规范 string
的第一个优点是重复的索引占位符 (%0
),它避免了指定重复参数。其他优点将在稍后讨论。
我们已经准备好我们的格式规范设计——格式规范和可变参数。现在我们需要将这些合并以即时构建消息。我们需要一种方法(通过方法等)来传入格式规范,然后传递参数。除此之外,我们必须能够进行基于日志级别的日志记录。这意味着我必须能够传入格式规范和参数,并使用 LogMessage
方法。也许我们的 Logger
用法可能是:
Logger x("GetFileSize - File: %0. Size: %1 bytes. User: %2. Is '%0' read-only: %3");
x << "C:\\Temp\\Sample.txt" << 2945 << Visitor << "True";
x.LogMessage();
可怕,不是吗?通过这种丑陋的方式,我们意识到有一种优雅的方式。
Logger().LogMessage("GetFileSize - File: %0. Size: %1 bytes.
User: %2. Is '%0' read-only: %3")
<< "C:\\Temp\\Sample.txt" << 2945 << Visitor << "True";
或者
Logger().LogMessage("Failed to get file size!");
这就是我们的日志结构将如何实现。我们在一行代码中创建并记录日志。我们的 Logger
类将重载 <<
运算符以接收传入的参数,并将利用析构函数在日志记录完成的行之后立即将日志记录到所需目的地。为什么是析构函数?因为我们依赖 C++ 的承诺,即它会在创建临时对象的当前语句执行完成后销毁它们。嗯,那时正是我们真正需要日志记录发生的时候,不是吗?
我们记录到哪里?
正如我们之前讨论的,我们的 Logger
对日志记录目的地是透明的。因此,我们打算将实际日志记录的部分从 Logger
类中分离出来。Logger
类使用用户定义的类型 TLogWriter
(一个模板参数),它实际将 string
记录到所需目的地。Logger
类的主要职责是根据格式规范格式化日志消息,然后使用 TLogWriter
类型记录消息。
template <typename TLogWriter > class Logger
{
private: std::string _fmtSpec;
private: TLogWriter& _logWriter;
typedef typename TLogWriter::TLogMetaData TLogMetaData;
private: TLogMetaData _logState;
public: Logger(TLogWriter& gWriter, const std::string& fmtSpec,
TLogMetaData logState) : _formatSpec(fmtSpec),
_logState(logState),
_logWriter(gWriter)
{
}
public: virtual ~Logger()
{
// Use PrepareStream private method that constructs the message from
// the _fmtSpec and arguments passed using overloaded << operator.
std::string streamText = PrepareStream();
_logWriter(streamText, _logState);
}
protected: std::string PrepareStream();
private: template<typename T> Logger& operator <<(T t);
};
用户需要定义 TLogWriter
类型,该类型应实际以所需格式执行物理日志记录。为了便于使用,下载中提供了记录到标准错误设备和文件的日志写入器。
TLogWriter
类型有一些先决条件
- 一个
typedef
,描述在日志记录期间需要传递给日志写入器的元数据(例如Category
、ThreadID
等) - 定义一个具有以下原型的
() 运算符
:std::string operator()(const std::string& msgText, TLogMetaData lmData)
拥有独立的 TLogWriter
类型的原因之一是为了将日志记录期间所需的元数据信息从 Logger
类中抽象出来。通过这样做,我们还获得了无需从 Logger
类派生即可直接使用 Logger
类的好处。Logger
类现在可以轻松地适应任何符合上述先决条件的用户定义的 TLogWriter
类型。
“如果我对日志元数据不感兴趣怎么办?我只想将格式化字符串记录到目的地。” 您可以修改 Logger
类以摆脱 TLogMetaData
。我们将此选项留给用户,因为根据我们的经验,大多数日志记录都需要元数据。
示例与使用
让我们尝试编写一个记录到标准错误设备的 TLogWriter
。
struct LogMetadata
{
LogLevel eLevel; // Trace, Info, Warning, Error
LogCategories eCategory; // General, Init, Shutdown etc
std::string ThreadInfo; // Thread ID and Name
public: LogMetadata(LogLevel eLevel, LogCategories eCategory,
const std::string& threadInfo)
: eLevel(eLevel), eCategory(eCategory), ThreadInfo(threadInfo)
{
}
};
class StdErrorWriter
{
public: typedef LogMetadata TLogMetaData;
public: StdErrorWriter()
{
}
private: static std::string CurrentDateTimeToString();
private: static std::string ToString(LogCategory);
private: static std::string ToString(LogLevel);
public: std::string operator()(const std::string& msgText, TLogMetadata lmData)
{
std::ostringstream ostr;
ostr << "[" << CurrentDateTimeToString() << "] [" <<
ToString(lmData.Category) << "] [" << ToString(lmData.LogLevel) <<
"] " << msgText << std::endl;
std::string logText = ostr.str();
::OutputDebugString(logText.c_str());
}
};
以下是在代码中使用上述写入器的方式:
int _tmain()
{
// Imagine a method GetFiles(const std::string& dirPath) that returns a
// vector of file names from the specified directory. If the directory
// path is empty\zero-length, current directory may be assumed.
std::vector files = GetFiles();
StdErrorWriter seWriter;
for (size_t i = 0; i < files.size(); ++t)
{
// Imagine a method int GetFileSize(const std::string& filePath)
int size = GetFileSize(files[i]);
Logger<StdErrorWriter>(seWriter,
"GetFileSize - File: %1.%0Size: %2.%0",
LogMetadata(LOGLEVEL_INFO, LOGCGTRY_APPDB, CurrentThreadInfoText()))
<< "...." << files[i] << size;
}
}
在上述写入器的使用中,您甚至可以将其创建为临时对象,如下所示,因为它没有任何状态信息。
Logger<StdErrorWriter>(StdErrorWriter(),
"GetFileSize - File: %1.%0Size: %2.%0",
LogMetadata(LOGLEVEL_INFO, LOGCGTRY_APPDB, CurrentThreadInfoText()))
<< "...." << files[i] << size;
但如果写入器写入文件,那么将其创建为临时对象是不明智的,因为它可能涉及为每行日志打开和关闭文件。有关 FileLogWriter
的实现,请参阅随附的源代码。
亮点
格式规范(%n
)只将 %
后跟 n
视为索引占位符,其中 n
是 0-256 范围内的任意数字。%
之后的任何其他字符都不会受到特殊处理,而是直接导向日志记录目的地;除了一个 %
(在 %
之后)用于记录一个 %
,就像 C 风格日志记录中的 \\
一样。简而言之,%%
是 %
的转义序列。
与 C 风格不同,当需要多次显示一个参数时,格式规范可以通过参数索引(%n
)多次引用,并且只需指定一次参数,从而避免指定重复参数。
由于 Logger 重载了“<<
”运算符并内部依赖于 std::ostringstream
,因此任何参数本质上都应该是可转换为 string
的类型。所有简单类型都将被识别并自动转换为 string
进行日志记录。对于复杂类型和特殊日志格式,用户应提供格式化的 string
。例如,如果我想记录我的类,我可能(必须)在该类上提供一个 ToString
方法,该方法给我该类的 string
表示,这不是不公平的事情。
由于我们使用了带 %
的自定义格式规范,所以不可能出现参数类型不匹配,也不会因此而崩溃。此外,任何传入的不可转换为 string
的参数都会导致编译错误,这是最大的好处之一。
参数数量不匹配已安全处理,避免了运行时崩溃。如果传入的参数数量(通过 <<
)少于参数占位符的数量(%n
),则会为每个未找到相应参数的参数占位符发出断言,并且直接记录 %n
。例如,在下面的日志行中,%2
因参数不匹配而被断言,并记录字符串“%2
”。
Logger(StdErrorWriter(), "GetFileSize - File: %1.%0Size: %2.%0",
LogMetadata(LOGLEVEL_INFO, LOGCGTRY_APPDB, CurrentThreadInfoText()))
<< "...." << files[i];
所以参数不匹配可以在编译时毫无疑问地识别和解决。
所有超出所需参数数量的参数都将附加到生成的日志消息中。
限制
Logger
的设计意图是作为临时对象创建和使用。它不是设计为作为命名对象创建的。其基本原理是,日志记录的触发是基于析构函数的,我们希望日志消息立即出现,而不是当(命名)对象超出范围时。尽管有方法可以实现这一点,但大多数日志记录情况都通过当前设计得到解决。
尽管规范是半 .NET 风格的,但我们的 Logger
不提供所有格式化功能——十六进制、间距等。所有这些都被保留在 Logger
之外。这不是故意的,但我们认为从简单的 Logger
开始。因此,如果您想以十六进制输出数字,可以使用 Logger
类的 ToHexString static
方法。
Logger("Hex number: %0", Logger::ToHexString(1000));
可以指定(在格式规范中)的最大参数数量是 256。在我为我的应用程序编写这个日志记录器时,不可能有一个格式规范包含超过 256 个参数。此外,我认为一个允许构建包含 256 个参数的格式规范的 Logger
可能足够花哨,但从实际角度来看,阅读和了解消息的全局视图并非那么容易,并且其目的也因此而失效。但是,对于持不同意见的人,通过对代码进行少量(次要)修改,可以轻松消除此限制。
日志愉快!
历史
- 2009 年 4 月 7 日 - 初始版本
- 2009 年 4 月 20 日 - 修改设计,将写入器逻辑抽象为
TLogWriter
类型,并包含由TLogWriter
指定的日志元数据 - 2009 年 5 月 11 日 - 更新源代码