C++20:使用Formatting实现数据流处理实例

📅 2026/7/3 10:27:01
C++20:使用Formatting实现数据流处理实例
引言C20 为我们带来了重要的文本格式化标准库支持。通过 Formatting 库和 formatter 类型我们可以实现高度灵活的文本格式化方案。那么我们该如何在实际工程项目中使用它呢日志输出在实际工程项目中是一个常见需求无论是运行过程记录还是错误记录与异常跟踪都需要用到日志。在这一章中我们会基于新标准实现一个日志库。你可以重点关注特化 formatter 类型的方法实现高度灵活的标准化定制。好话不多说我们就从架构设计开始一步步实现这个日志库课程配套代码https://github.com/samblg/cpp20-plus-indepth日志库架构设计事实上实现一个足够灵活的日志库并不容易。在实际工程项目中日志输出不仅需要支持自定义日志的输出格式还需要支持不同的输出目标。比如输出到控制台、文件甚至是网络流或者数据库等。Python 和 Java 这类现代语言都有成熟的日志库与标准接口。C Formatting 的正式提出让我们能使用简洁的方式实现日志库。同时Python 的 logging 模块设计比较优雅。因此我们参照它的架构设计了基于 C20 的日志架构。项目的模块图是后面这样。对照图片可以看到logging 模块是工程的核心包含核心框架、handlers 和 formatters 三个子模块。其中核心框架包括 Level、Record、Formatter、Handler 与 Logger 的定义。由于我们使用了模版因此核心框架的声明实现都在头文件中。具体含义你可以参考后面这张表。由于我们关注的重点在于如何使用 Formatting 库和如何特化 formatter 类型。因此对于核心框架的定义和实现你可以参考完整的工程代码。日志格式化器模块从模块图中可以看出我们在 formatters 模块中实现了三组不同的日志格式化器。我们来比较一下。首先是 CFormatter它是 C⻛格格式化的日志输出。实现较为简单但是如果阅读了代码你就发现这种实现方式难以避免类型和缓冲区安全问题。StreamFormatter 则是 C 流⻛格的日志输出。基于 C 流的实现相对于 C 的实现更加注重类型安全并能完全避免缓冲区溢出。但是这么做编码复杂也会影响整体代码的可读性。最后就是 ModernFormatter即 C20 format 的日志输出。基于 C Formatting 库和特化 formatter 实现。接下来我们具体来看 ModernFormatter。接口定义在 include/logging/formatters/ModernFormatter.h 中代码是后面这样。#pragma once #include string namespace logging { class Record; namespace formatters::modern { // formatRecord函数用于格式化日志记录对象 std::string formatRecord(const logging::Record record); } }具体实现在 src/logging/formatters/ModernFormatter.cpp 中。#include logging/formatters/ModernFormatter.h #include logging/Record.h namespace logging::formatters::modern { // formatRecord将Record对象格式化为字符串 std::string formatRecord(const Record record) { try { return std::format( {0:16}| [{1}] {2:%Y-%m-%d}T{2:%H:%M:%OS}Z - {3}, record.name, record.getLevelName(), record.time, record.message ); } catch (std::exception e) { std::cerr Error in format: e.what() std::endl; return ; } } }这种方案具有三个优点。第一format 内置对 C11 的时间点对象的直接格式化。在 C20 中由于 chrono 提供了针对 time_point 类型的 formatter。因此相比其他的方案这种方案对时间的格式化要简单清晰得多。第二format 不需要像 C 方案那样提前分配缓冲区因此可以避免缓冲区溢出。第三format 可以自动根据函数参数类型确定格式化的参数类型。它不需要完全根据格式化字符串判定参数类型如果格式化字符串中的类型与实际参数类型不同也能在运行时检查出来并抛出异常。我们在代码中捕获了相关异常发生错误时你可以根据具体需求来处理异常。总之采用 C Formatting 实现的文本格式化器非常简单。不过话说回来格式化文本这件事本来就该如此轻松惬意不是吗日志记录器模块现在我们来看另一个重点——日志记录器模块。日志记录器是提供给用户的接口用户可以通过日志记录器提交日志。你可以先看看代码实现再听我讲解。#pragma once #include iostream #include string #include tuple #include memory #include logging/Level.h #include logging/Handler.h #include logging/handlers/DefaultHandler.h namespace logging { // Logger类定义 // Level是日志记录器的日志等级 // HandlerTypes是所有注册的日志处理器必须满足Handler约束 // 通过requires要求每个Logger类必须注册至少一个日志处理器 template Level loggerLevel, Handler... HandlerTypes requires(sizeof...(HandlerTypes) 0) class Logger { public: // HandlerCount日志记录器数量通过sizeof...获取模板参数中不定参数的数量 static constexpr int32_t HandlerCount sizeof...(HandlerTypes); // LoggerLevelLogger的日志等级 static constexpr Level LoggerLevel loggerLevel; // 构造函数name为日志记录器名称attachedHandlers是需要注册到Logger对象中的日志处理器 // 由于日志处理器也不允许拷贝只允许移动所以这里采用的是元组的移动构造函数 Logger(const std::string name, std::tupleHandlerTypes... attachedHandlers) : // 调用std::forward转发右值引用 _name(name), _attachedHandlers(std::forwardstd::tupleHandlerTypes...(attachedHandlers)) { } // 不允许拷贝 Logger(const Logger) delete; // 不允许赋值 Logger operator(const Logger) delete; // 移动构造函数允许日志记录器对象之间移动 Logger(Logger rhs) : _name(std::move(rhs._name)), _attachedHandlers(std::move(rhs._attachedHandlers)) { } // log通用日志输出接口 // 需要通过模板参数指定输出的日志等级 // 通过requires约束丢弃比日志记录器设定等级要低的日志 // 避免运行时通过if判断 template Level level requires (level loggerLevel) Logger log(const std::string message) { return *this; } // 通过requires约束提交等级为日志记录器设定等级及以上的日志 template Level level requires (level loggerLevel) Logger log(const std::string message) { // 构造Record对象 Record record{ .name _name, .level level, .time std::chrono::system_clock::now(), .message message, }; // 调用handleLog实际处理日志输出 handleLoglevel, HandlerCount - 1(record); return *this; } // handleLog将日志记录提交给所有注册的日志处理器 // messageLevel为提交的日志等级 // handlerIndex为日志处理器的注册序号 // 通过requires约束当handlerIndex 0时会递归调用handleLog将消息同时提交给前一个日志处理器 template Level messageLevel, int32_t handlerIndex requires (handlerIndex 0) void handleLog(const Record record) { // 递归调用handleLog将消息同时提交给前一个日志处理器 handleLogmessageLevel, handlerIndex - 1(record); // 获取当前日志处理器并提交消息 auto handler std::gethandlerIndex(_attachedHandlers); handler.emitmessageLevel(record); } template Level messageLevel, int32_t handlerIndex requires (handlerIndex 0) void handleLog(const Record record) { // 获取当前日志处理器并提交消息 auto handler std::gethandlerIndex(_attachedHandlers); handler.emitmessageLevel(record); } // 提交严重错误信息log的包装 Logger critical(const std::string message) { return logLevel::Critical(message); } // 提交一般错误信息log的包装 Logger error(const std::string message) { return logLevel::Error(message); } // 提交警告信息log的包装 Logger warning(const std::string message) { return logLevel::Warning(message); } // 提交普通信息log的包装 Logger info(const std::string message) { return logLevel::Info(message); } // 提交调试信息log的包装 Logger debug(const std::string message) { return logLevel::Debug(message); } // 提交程序跟踪信息log的包装 Logger trace(const std::string message) { return logLevel::Trace(message); } private: // 日志记录器名称 std::string _name; // 注册的日志处理器由于日志处理器的类型与数量不定因此这里使用元组而非数组 std::tupleHandlerTypes... _attachedHandlers; }; // 日志记录器生成工厂 template Level level Level::Warning class LoggerFactory { public: // 创建日志记录器指定名称与处理器 template Handler... HandlerTypes static Loggerlevel, HandlerTypes... createLogger(const std::string name, std::tupleHandlerTypes... attachedHandlers) { return Loggerlevel, HandlerTypes...(name, std::forwardstd::tupleHandlerTypes...(attachedHandlers)); } // 创建日志记录器指定名称处理器采用默认处理器DefaultHandler template Handler... HandlerTypes static Loggerlevel, handlers::DefaultHandlerlevel createLogger(const std::string name) { return Loggerlevel, handlers::DefaultHandlerlevel(name, std::make_tuple(handlers::DefaultHandlerlevel())); } }; }日志记录器 Logger 是一个模板类。与其他日志记录器不同这里设计的日志框架是一个“静态”框架也就是日志输出的配置都必须在代码中编码而非读取外部配置或运行时修改。这么做的初衷在于通过 C 模板能力直接生成固化的代码避免运行时进行逻辑判断——这样效率更高。因此日志记录器的等级 Level 和需要注册到日志记录器的处理器类型都需要通过模板参数注册到 Logger 中。先来看一下构造函数。构造函数中包含两个参数。name 为日志记录器名称。attachedHandlers 是需要注册到 Logger 对象中的日志处理器。你可能已经注意到了日志处理器的类型 HandlerTypes 是一个模板不定参数唯一要求是每个参数都必须满足 Handler 约束的类型。这个 concept 表示合法的日志处理器具体实现我们会在接下来的“日志处理器模块”里讨论。由于每个日志处理器的类型都不一样。因此所有的日志处理器都按指定顺序存储在一个 tuple 中。由于日志处理器也不允许拷贝只允许移动。所以这里采用的是元组的移动构造函数也可以确保较高的运行效率。接着看一下成员函数 log该函数是通用的日志输出接口可以按照指定日志等级输出任意内容的日志。Logger 的使用者需要调用该函数输出日志该函数包含两个参数。level输出日志等级通过模板参数传递。message表示日志内容通过函数参数传递。为了在编译时就确定 Logger 是否应该接收这个日志避免运行时的额外判断我们将 level 特意定义成模板参数并利用 requires 为 log 定义了两个重载版本你可以参考这张表格。接着我们看一下成员函数 handleLog 的实现该函数可以将日志提交给 Logger 中注册的所有日志处理器包含 3 个参数。messageLevel消息日志等级需要通过模板参数传递。handlerIndex处理器在 Logger 中的注册序号需要通过模板参数传递。record提交给处理器的日志记录需要通过函数参数传递。由于 handler 的类型不一定相同。因此我们无法通过循环将日志记录提交给所有的日志处理器需要采用递归的方式。在具体实现时messageLevel 和 handlerIndex 均为模板参数handlerIndex 从最后一个日志处理器开始这解释了在成员函数 log 中调用 handleLog 时传递的是 HandlerCount - 1最终递归调用到 handlerIndex 为 0 时终止。由于 Logger 一般不会支持太多的输出目标一般来说也就是将日志输出到控制台或者输出到文件递归层数不会太深因此为了在编译时生成确定的调用链条为 C 提供递归函数内联调用优化的可能性我们将 messageLevel 和 handlerIndex 特意定义成模板参数并利用 requires 为 handleLog 定义了两个重载版本就像后面这样。好我们接着往下看代码。从 94—121 行为不同日志等级定义了包装接口便于 Logger 用户直接输出特定等级的日志减少编码。由于 Logger 必须要指定日志处理器而且多个日志处理器类型不同因此创建 Logger 对象时必须指明所有处理器的类型。为此我们定义了一个工厂类 LoggerFactory将日志等级作为类的模板参数用户调用 createLogger 函数创建 Logger 对象时编译器可以根据函数参数列表自动推导 HandlerTypes 的具体类型降低编程工作量。日志处理器模块最后我们看一下日志处理器模块以及常见的日志输出处理实现。接口设计在 logging/Handler.h 中定义了和日志处理器有关的接口。#pragma once #include logging/Formatter.h #include logging/Level.h #include logging/Record.h #include string #include memory #include type_traits #include concepts namespace logging { // Handler Concept // 不强制所有Handler都继承BaseHandler只需要满足特定的接口因此定义Concept template class HandlerType concept Handler requires (HandlerType handler, const Record record, Level level) { // 要求有emit成员函数 handler.emit; // 要求有format函数可以将Record对象格式化为string类型的字符串 { handler.format(record) } - std::same_asstd::string; // 要求有移动构造函数无拷贝构造函数 } std::move_constructibleHandlerType !std::copy_constructibleHandlerType; // BaseHandler类定义 // HandlerLevel是日志处理器的日志等级 // 自己实现Handler时可以继承BaseHandler然后实现emit template Level HandlerLevel Level::Warning class BaseHandler { public: // 构造函数formatter为日志处理器的格式化器 BaseHandler(Formatter formatter) : _formatter(formatter) {} // 不允许拷贝 BaseHandler(const BaseHandler) delete; // 不允许赋值 BaseHandler operator(const BaseHandler) delete; // 移动构造函数允许日志处理器对象之间移动 BaseHandler(BaseHandler rhs) noexcept : _formatter(std::move(rhs._formatter)) {}; // 析构函数考虑到会被继承避免析构时发生资源泄露 virtual ~BaseHandler() {} // getForamtter获取formatter Formatter getForamtter() const { return _formatter; } // setForamtter修改formatter void setForamtter(Formatter formatter) { _formatter formatter; } // format调用格式化器将record转换成文本字符串 std::string format(const Record record) { return _formatter(record); } private: // 日志处理器的格式化器 Formatter _formatter; }; }Handler 是一个 concept。出于性能考虑我们并没有强制要求所有日志处理器都继承一个标准基类然后通过标准基类调用实现。我们的做法是定义一个 concept 来约束 Handler 的接口。日志处理器的约束包括提供 emit 接口用于提交日志记录。提供 format 函数参数为日志记录对象返回类型为 std::string。提供移动构造函数。不可拷贝禁用拷贝构造函数。BaseHandler 是为其他日志处理器类提供的基类。虽然我们不强制所有的日志处理器继承一个标准基类但还是提供了一个基类实现这样可以降低具体实现的编码工作量。具体实现日志处理器具体怎么实现呢我们以 DefaultHandler 为例看一看DefaultHandler 是默认日志处理器负责将日志输出到标准输出流。DefaultHandler 实现在 logging/handlers/DefaultHandler.h 中。#pragma once #include logging/Handler.h namespace logging::handlers { // 默认日志处理器 template Level HandlerLevel Level::Warning // 继承BaseHandler class DefaultHandler : public BaseHandlerHandlerLevel { public: // 构造函数需要指定格式化器默认格式化器为defaultFormatter DefaultHandler(Formatter formatter defaultFormatter) : BaseHandlerHandlerLevel(formatter) {} // 禁止拷贝构造函数 DefaultHandler(const DefaultHandler) delete; // 定义移动构造函数 DefaultHandler(const DefaultHandler rhs) noexcept : BaseHandlerHandlerLevel(rhs.getForamtter()) {} // emit用于提交日志记录 // emitLevel HandlerLevel的日志会被丢弃 template Level emitLevel requires (emitLevel HandlerLevel) void emit(const Record record) { } // emitLevel HandlerLevel的日志会被输出到标准输出流中 template Level emitLevel requires (emitLevel HandlerLevel) void emit(const Record record) { // 调用format将日志记录对象格式化成文本字符串 std::cout this-format(record) std::endl; } }; }DefaultHandler 按照日志处理器的 concept 定义了相关接口。需要注意的是emit 成员函数通过 requires将输出日志等级较低的日志记录直接丢弃了。因此只有当满足要求的日志输出时才会输出到标准输出流中——这和 Logger 的 log 函数丢弃日志的原理一样。StreamHandler 和 FileHandler 的实现与 DefaultHandler 类似只不过是将日志输出到不同的目标它们的分工你可以参考下表。你可以通过课程配套代码了解它们的具体实现细节。总结在使用 C Formatting 库和 formatter 类型时我们往往会利用模板和 concept 来消解运行时性能损耗以实现更好的性能。对于日志处理这样一个典型的应用场景来说约束条件通常包含以下几点。提供 emit 接口用于提交日志记录。提供 format 函数参数为日志记录对象返回类型为 std::string。提供移动构造函数。不可拷贝禁用拷贝构造函数。总的来说运行时性能是我们首要考虑的问题。这是一种新的实践范式——在现代 C 编程体系中尽可能让计算发生在编译时而非运行时。