15 Formatting实战:如何构建一个数据流处理实例?
你好,我是卢誉声。
C++20为我们带来了重要的文本格式化标准库支持。通过Formatting库和formatter类型,我们可以实现高度灵活的文本格式化方案。那么,我们该如何在实际工程项目中使用它呢?
日志输出在实际工程项目中是一个常见需求,无论是运行过程记录,还是错误记录与异常跟踪,都需要用到日志。
在这一讲中,我们会基于新标准实现一个日志库。你可以重点关注特化formatter类型的方法,实现高度灵活的标准化定制。
好,话不多说,我们就从架构设计开始,一步步实现这个日志库(课程配套代码可以从这里获取)。
日志库架构设计
事实上,实现一个足够灵活的日志库并不容易。在实际工程项目中,日志输出不仅需要支持自定义日志的输出格式,还需要支持不同的输出目标。比如,输出到控制台、文件,甚至是网络流或者数据库等。
Python和Java这类现代语言都有成熟的日志库与标准接口。C++ Formatting的正式提出,让我们能使用简洁的方式实现日志库。
同时,Python的logging模块设计比较优雅。因此,我们参照它的架构,设计了基于C++20的日志架构。
项目的模块图是后面这样。
对照图片可以看到,logging模块是工程的核心,包含核心框架、handlers和formatters三个子模块。
其中,核心框架包括Level、Record、Formatter、Handler与Logger的定义。由于我们使用了模版,因此核心框架的声明实现都在头文件中。具体含义你可以参考后面这张表。
由于我们关注的重点在于如何使用Formatting库和如何特化formatter类型。因此,对于核心框架的定义和实现,你可以参考完整的工程代码。
日志格式化器模块
从模块图中可以看出,我们在formatters模块中实现了三组不同的日志格式化器。我们来比较一下。
首先是CFormatter,它是C⻛格格式化的日志输出。实现较为简单,但是如果阅读了代码,你就发现这种实现方式难以避免类型和缓冲区安全问题。
StreamFormatter则是C++流⻛格的日志输出。基于C++流的实现相对于C的实现更加注重类型安全,并能完全避免缓冲区溢出。但是,这么做编码复杂,也会影响整体代码的可读性。
最后就是ModernFormatter,即C++20 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内置对C++11的时间点对象的直接格式化。在C++20中,由于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);
// LoggerLevel:Logger的日志等级
static constexpr Level LoggerLevel = loggerLevel;
// 构造函数:name为日志记录器名称,attachedHandlers是需要注册到Logger对象中的日志处理器
// 由于日志处理器也不允许拷贝,只允许移动,所以这里采用的是元组的移动构造函数
Logger(const std::string& name, std::tuple<HandlerTypes...>&& attachedHandlers) :
// 调用std::forward转发右值引用
_name(name), _attachedHandlers(std::forward<std::tuple<HandlerTypes...>>(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实际处理日志输出
handleLog<level, 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将消息同时提交给前一个日志处理器
handleLog<messageLevel, handlerIndex - 1>(record);
// 获取当前日志处理器并提交消息
auto& handler = std::get<handlerIndex>(_attachedHandlers);
handler.emit<messageLevel>(record);
}
template <Level messageLevel, int32_t handlerIndex>
requires (handlerIndex == 0)
void handleLog(const Record& record) {
// 获取当前日志处理器并提交消息
auto& handler = std::get<handlerIndex>(_attachedHandlers);
handler.emit<messageLevel>(record);
}
// 提交严重错误信息(log的包装)
Logger& critical(const std::string& message) {
return log<Level::Critical>(message);
}
// 提交一般错误信息(log的包装)
Logger& error(const std::string& message) {
return log<Level::Error>(message);
}
// 提交警告信息(log的包装)
Logger& warning(const std::string& message) {
return log<Level::Warning>(message);
}
// 提交普通信息(log的包装)
Logger& info(const std::string& message) {
return log<Level::Info>(message);
}
// 提交调试信息(log的包装)
Logger& debug(const std::string& message) {
return log<Level::Debug>(message);
}
// 提交程序跟踪信息(log的包装)
Logger& trace(const std::string& message) {
return log<Level::Trace>(message);
}
private:
// 日志记录器名称
std::string _name;
// 注册的日志处理器,由于日志处理器的类型与数量不定,因此这里使用元组而非数组
std::tuple<HandlerTypes...> _attachedHandlers;
};
// 日志记录器生成工厂
template <Level level = Level::Warning>
class LoggerFactory {
public:
// 创建日志记录器,指定名称与处理器
template <Handler... HandlerTypes>
static Logger<level, HandlerTypes...> createLogger(const std::string& name, std::tuple<HandlerTypes...>&& attachedHandlers) {
return Logger<level, HandlerTypes...>(name, std::forward<std::tuple<HandlerTypes...>>(attachedHandlers));
}
// 创建日志记录器,指定名称,处理器采用默认处理器(DefaultHandler)
template <Handler... HandlerTypes>
static Logger<level, handlers::DefaultHandler<level>> createLogger(const std::string& name) {
return Logger<level, handlers::DefaultHandler<level>>(name, std::make_tuple(handlers::DefaultHandler<level>()));
}
};
}
日志记录器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_as<std::string>;
// 要求有移动构造函数,无拷贝构造函数
}&& std::move_constructible<HandlerType> && !std::copy_constructible<HandlerType>;
// 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 BaseHandler<HandlerLevel> {
public:
// 构造函数,需要指定格式化器,默认格式化器为defaultFormatter
DefaultHandler(Formatter formatter = defaultFormatter) : BaseHandler<HandlerLevel>(formatter) {}
// 禁止拷贝构造函数
DefaultHandler(const DefaultHandler&) = delete;
// 定义移动构造函数
DefaultHandler(const DefaultHandler&& rhs) noexcept : BaseHandler<HandlerLevel>(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++编程体系中,尽可能让计算发生在编译时,而非运行时。
课后思考
我们在第11讲中,编写了基于Ranges的工程,其中包含了一些控制台输出日志。请你尝试编译今天这一讲的代码,替换Ranges工程中的所有输出,包括控制台输出和日志。
欢迎分享你的问题以及日志库的改进意见。我们一同交流。下一讲见!
- 李云龙 👍(1) 💬(3)
不仅学到了format,还在老师的项目代码中学到了不同风格的时间处理
2024-01-17 - 三笑三引伏兵 👍(0) 💬(2)
我想问下为什么BaseHandler不声明一个emit的纯虚函数呢 因为开销吗
2024-11-13 - peter 👍(0) 💬(1)
StreamHandler的“流”是否包含File?甚至包含标准输出流? 我目前的理解是:标准输出流就是控制台;“流”一般包括文件输出流、网络输出流,好像没有别的了。
2023-02-25