前言
本系列旨在通过解析开源项目,学习优雅、简洁的实现,讨论其使用场景,覆盖的范围一般不会超过项目的实现,配合代码阅读更佳
项目简介
spdlog是一个C++日志库,支持header-only以及库引入两种方式,在工程实践上广受好评。
项目链接: GitHub Repository gabime/spdlog 在 GitHub 上查看该项目。
基本使用
对于较为复杂的库,在学习实现之前,最好从使用开始,spdlog也是如此。
引入
为了简单考虑,在使用时使用header-only方式引入,CMakeLists.txt参考如下:
1cmake_minimum_required(VERSION 3.15)
2project(my_app LANGUAGES CXX)
3
4add_library(spdlog_header_only INTERFACE)
5target_include_directories(spdlog_header_only INTERFACE
6 "${CMAKE_CURRENT_SOURCE_DIR}/third_party/spdlog/include"
7)
8set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
9add_executable(my_app main.cpp)
10target_compile_features(my_app PRIVATE cxx_std_11)
11# 只影响宏
12target_compile_definitions(
13 my_app
14 PRIVATE
15 SPDLOG_ACTIVE_LEVEL=SPDLOG_LEVEL_TRACE
16)
17target_link_libraries(my_app PRIVATE spdlog_header_only)
引入方式也很有趣,spdlog提供了两种不同的引入方式,header-only的方式默认所有实现都已在头文件中。
这时候就应该问第一个问题了:既然都在头文件中,常规来讲,头文件是不编译成库的(当然也有办法),那spdlog是如何实现的呢? 这里就不得不提到spdlog精巧的设计了,观察其文件命名,很快就可以发现有两种.h文件,一种是常规.h文件,另外一种是以-inc.h结尾的头文件。 以spdlog.h和spdlog-inc.h为例,我们可以在spdlog.h以及common.h处找到如下代码:
1// spdlog.h
2#ifdef SPDLOG_HEADER_ONLY
3#include "spdlog-inl.h"
4#endif
5
6// common.h
7#ifdef SPDLOG_COMPILED_LIB
8...
9#else // !defined(SPDLOG_COMPILED_LIB)
10#define SPDLOG_API
11#define SPDLOG_HEADER_ONLY
12#define SPDLOG_INLINE inline
13#endif // #ifdef SPDLOG_COMPILED_LIB
可以看到,两种引入方式是通过宏SPDLOG_HEADER_ONLY控制的,而宏又间接影响了另外几个宏的定义,进而影响spdlog-inc.h的引入,而该文件正是存放了一些函数的定义,承担了.cpp文件的功能。
结合这一点,再看看spdlog.cpp,只需要引入必要的头文件,在预处理后,便引入了函数、方法的定义,便可以作为一个编译单元。
spdlog通过控制SPDLOG_COMPILED_LIB和SPDLOG_HEADER_ONLY两个宏,做到了支持两种导入方式。
顺带吐槽一下,C++这种上古时代的引入方式真是应该被扫进历史的垃圾堆。
使用
spdlog的使用也相当简单,以如下代码为例:
1spdlog::info("welcome to spdlog");
2// fmt的格式化,空的就是表示默认格式
3spdlog::error("Some error message with arg: {}", 1);
4// :表达格式控制,d是decimal,最小字宽为8,不足的用0填充
5spdlog::warn("Easy padding in numbers like {:08d}", 12);
6// 0表示参数编号,d是decimal,x是hex,b是oct
7spdlog::critical("Support for int: {0:d}; hex: {0:x}; oct: {0:o}; bin: {0:b}", 42);
对应到输出则是:
1[2026-08-16 19:00:04.070] [info] welcome to spdlog
2[2026-08-16 19:00:04.071] [error] Some error message with arg: 1
3[2026-08-16 19:00:04.071] [warning] Easy padding in numbers like 00000012
4[2026-08-16 19:00:04.071] [critical] Support for int: 42; hex: 2a; oct: 52; bin: 101010
对于前面的日期和时间是系统自带的,成为metadata,后面的输出信息则是用户的输出,也被称为payload。
架构
一层层剥离,我们便可以看到spdlog的构成: 以上方的spdlog::info和spdlog::error为例,实现分别如下:
1// spdlog.h
2template <typename T>
3inline void info(const T &msg) {
4 default_logger_raw()->info(msg);
5}
6
7template <typename... Args>
8inline void error(format_string_t<Args...> fmt, Args &&...args) {
9 default_logger_raw()->error(fmt, std::forward<Args>(args)...);
10}
11
12// spdlog-inc.h
13SPDLOG_INLINE logger *default_logger_raw() {
14 // 为了更快一点,取的是原生指针
15 return details::registry::instance().get_default_raw();
16}
17
18// registry-inc.h
19SPDLOG_INLINE logger *registry::get_default_raw() { return default_logger_.get(); }
显然,对于基础格式,直接调用一个参数的模板函数即可,而对于多个参数,则需要可变参数的支持。 在上述两个实现中,我们不难看到,都是调用了全局的默认logger。 在spdlog中,对象的管理层级大致如下:
- registry: 单例,通过logger的名称为key管理所有logger对象
- logger: 负责处理fmt相关的工作,处理格式化相关,创建一条消息对象
- sink: 负责将一条消息对象转发至配置的文件(stdout、stderr、普通file等等),一个logger也许会有多个sink,以将一条消息输出到不同的目标,当然也是在该层做到最终的输出
- formatter: 帮助sink管理输出前缀格式,也就是日志的metadata
- flag_formatter: 针对处理自定义的metadata格式,也就是pattern
从上面的分级可以看到spdlog的设计简洁之处,每个对象类型都有自己的职责(单一职责原则),通过一个消息对象传递消息,层层递进。 在这种架构下,如果代码新增了一个模块,我可以新增logger,来处理不同的日志源;而针对一个logger,我既希望它输出到终端,又希望它输出到文件,就增加sink。当然,如果metadata有变化,应当也增加对应的sink来处理。
此处应当有一个图。
registry
spdlog的基础管理类,也是经典单例实现,除了管理所有logger,还负责管理全局输出等级,每个logger的输出等级等等。 在registry被构造时,会顺手构造一个默认的logger,如果用不上更多功能的话,这个default_logger就可以完成几乎所有的功能。
1SPDLOG_INLINE registry::registry()
2 : formatter_(new pattern_formatter()) {
3 // 创建一个sink
4 // 以此为切入点看sink
5 auto color_sink = std::make_shared<sinks::ansicolor_stdout_sink_mt>();
6
7 const char *default_logger_name = "";
8 // 创建一个default logger,空名称,sink被logger控制
9 default_logger_ = std::make_shared<logger>(default_logger_name, std::move(color_sink));
10 loggers_[default_logger_name] = default_logger_;
11
12#endif // SPDLOG_DISABLE_DEFAULT_LOGGER
13}
删掉了无用的部分后,让我们只关注logger的创建,这里并没有太多的子类,因为logger的任务很简单,只关注格式化相关的任务。另外,这个logger的名称是匿名的,也只有一个简单的ansicolor_stdout_sink_mt,这里的mt表示是multi-thread,意味着其支持多线程。同样,这里也要提出一个新问题,为什么没有logger_mt呢?因为logger的任务用不上多线程,也没有什么线程不安全的点。
从上文可以看到,对于常规的spdlog::xxx之类的场景,默认logger就可以完成了。
logger
上文反复说过,logger负责的是格式化的部分,spdlog引入的是fmt库的格式化,格式化的细节语法并不在本文讨论范围中,以后有机会可以再分析下fmt库。
在上文中,我们说到对于是否带格式化的参数,有两套处理逻辑,分别是:
1// logger.h
2// 不带格式化信息就走这些
3template <typename T>
4void trace(const T &msg) {
5 log(level::trace, msg);
6}
7
8// 带格式化的走下面的
9template <typename... Args>
10void trace(format_string_t<Args...> fmt, Args &&...args) {
11 log(level::trace, fmt, std::forward<Args>(args)...);
12}
一级转发
根据不同参数,trace实例化的也是两套模版来重载,那么对于log,也是如此,也会分出两套出来:
1// logger.h
2// 对应不带格式化的模板,这里log也是第一层
3// 非格式化一级转发入口,非string类型
4template <typename T>
5void log(level::level_enum lvl, const T &msg) {
6 log(source_loc{}, lvl, msg);
7}
8
9// 对应带格式化的模板,即第一层log
10// 一级转发入口
11template <typename... Args>
12void log(level::level_enum lvl, format_string_t<Args...> fmt, Args &&...args) {
13 log(source_loc{}, lvl, fmt, std::forward<Args>(args)...);
14}
15
16// common.h
17struct source_loc {
18 SPDLOG_CONSTEXPR source_loc() = default;
19 SPDLOG_CONSTEXPR source_loc(const char *filename_in, int line_in, const char *funcname_in)
20 : filename{filename_in},
21 line{line_in},
22 funcname{funcname_in} {}
23
24 SPDLOG_CONSTEXPR bool empty() const SPDLOG_NOEXCEPT { return line <= 0; }
25 const char *filename{nullptr};
26 int line{0};
27 const char *funcname{nullptr};
28};
这里我们姑且将这个log称之为一级转发,可以看到,参数也是完美对应,这里有个小知识点:forward的使用,但这里就不展开了,值得注意一下。 我们可以观察到,形式上两者还是较为统一的,可以基本认为msg = fmt + args,后续也是这样处理的。 另外一个值得注意的点是,source_loc这样一个结构的首次出现,作用是记录一些location信息文件、函名、行号。
二级转发(仅非格式化)
这里对于非格式化的二级转发还有两种情况,一种是可以转成string like类型的,一种是不能转的。
对于可以转的,直接构造消息对象log_msg,到log_it_提前输出即可。
对于不能转的,才需要处理其输出方式,这里是直接套一个{},相当于使用底层的fmt库来做
1// logger.h
2// 非格式化的二级转发入口,可以转换string like类型的
3void log(source_loc loc, level::level_enum lvl, string_view_t msg) {
4 bool log_enabled = should_log(lvl);
5 bool traceback_enabled = tracer_.enabled();
6 if (!log_enabled && !traceback_enabled) {
7 return;
8 }
9
10 // 如果不需要格式化,直接输出即可
11 details::log_msg log_msg(loc, name_, lvl, msg);
12 log_it_(log_msg, log_enabled, traceback_enabled);
13}
14
15// 非格式化的二级转发入口,非string类型,比如int、bool之类的
16// T cannot be statically converted to format string (including string_view/wstring_view)
17template <class T,
18 typename std::enable_if<!is_convertible_to_any_format_string<const T &>::value,
19 int>::type = 0>
20void log(source_loc loc, level::level_enum lvl, const T &msg) {
21 // 不能转成字符串的,非格式化的转发入口
22 // 等价于给普通类型自动套上一个{},但一定要该类型是可以被格式化的
23 // 二级转发入口
24 log(loc, lvl, "{}", msg);
25}
本质上只是方便了一下,不用每次写logger->log(spdlog::level::info, "{}", 42);这种东西。
三级转发
针对非格式化(非string like类型的)和格式化,都是统一交由该层处理:
1// logger.h
2// 三级总入口,非格式化、格式化都会统一至此(做了些许简化)
3template <typename... Args>
4void log(source_loc loc, level::level_enum lvl, format_string_t<Args...> fmt, Args &&...args) {
5 log_(loc, lvl, fmt.get(), std::forward<Args>(args)...);
6}
三级的log作为统一入口,调用log_来实现格式化,删除冗余代码的结果如下:
1// 专注解决格式化问题
2// common implementation for after templated public api has been resolved
3template <typename... Args>
4void log_(source_loc loc, level::level_enum lvl, string_view_t fmt, Args &&...args) {
5 // 一条日志逻辑的起点
6 bool log_enabled = should_log(lvl);
7 bool traceback_enabled = tracer_.enabled();
8 // 如果一条日志既没有被记录下来的价值,那当然直接返回
9 if (!log_enabled && !traceback_enabled) {
10 return;
11 }
12 // 最终存放数据的buffer
13 memory_buf_t buf;
14 // make_format_args是打包参数,类型擦除,返回一个参数包对象,通常保存引用
15 // vformat_to是输出到指定缓冲区,在这里就是格式化到buf,这里也是格式化开始的阶段
16 fmt::vformat_to(fmt::appender(buf), fmt, fmt::make_format_args(args...));
17 // 现在,对于某条日志,有loc,有logger名称,有输出等级,有具体信息,最终构成了一条log_msg
18 // 但只有消息正文
19 details::log_msg log_msg(loc, name_, lvl, string_view_t(buf.data(), buf.size()));
20 // 最终通过这个函数输出
21 log_it_(log_msg, log_enabled, traceback_enabled);
22}
到fmt::vformat_to这一步,所有的位置参数都被放到了msg中,也就是说构成了一条payload,将payload放入log_msg,则正式开始了层级的传递
log_it_
在上文中,非格式化的,可以转成string like的可以直接调用log_it_,而三级转发最终也是到了log_it_,现在可以看看log_it_的实现了:
1void logger::log_it_(const details::log_msg &log_msg,
2 bool log_enabled,
3 bool traceback_enabled) {
4 if (log_enabled) {
5 sink_it_(log_msg);
6 }
7 // tracer底层是一个ring buffer
8 if (traceback_enabled) {
9 tracer_.push_back(log_msg);
10 }
11}
12
13void logger::sink_it_(const details::log_msg &msg) {
14 // 调用每个sink,来做具体的事项
15 for (auto &sink : sinks_) {
16 // sink也有自己的日志等级判断
17 if (sink->should_log(msg.level)) {
18 sink->log(msg);
19 }
20 }
21
22 if (should_flush_(msg)) {
23 flush_();
24 }
25}
log_it_调用了sink_it_,来批量调用sink->log以实现日志输出,此时可以观察log_msg这个结构,来看看其作为msg的承载,究竟记录了哪些信息:
1// log_msg.h
2// 删除了方法
3struct SPDLOG_API log_msg {
4 string_view_t logger_name; // 带logger name
5 level::level_enum level{level::off};
6 log_clock::time_point time;
7 size_t thread_id{0};
8
9 // wrapping the formatted text with color (updated by pattern_formatter).
10 mutable size_t color_range_start{0};
11 mutable size_t color_range_end{0};
12 // log_msg带着全量的信息到处传递
13 source_loc source;
14 // payload是后一半的信息
15 string_view_t payload;
16};
这些信息都是构造时实时传入的,可以看到,最终还是构成了这么一份payload,这里的string_view_t可以当作标准库中的string_view来理解,本质上还是取的log_中的memory_buf_t中的数据指针(有数据扩容机制,小对象直接放栈上,大对象会放堆上)
至此,也算看完了logger的基础工作,就是把格式参数塞到格式化字符串里,来产生payload,当然省略了相当多的细节。
info('value={}', 42)"] P["单消息日志
log(level, 'hello')
或 log(level, 42)"] B["格式化入口
log(...) → log_()"] Q["添加默认 source_loc
log(source_loc{}, level, msg)"] R{"msg 是字符串类型?"} S["直接构造 log_msg"] N["自动套上 '{}'
转入格式化入口"] F["vformat_to 格式化正文"] G["构造 log_msg"] I["log_it_(log_msg)"] K["sink_it_(log_msg)
离开 logger 层"] A --> B P --> Q --> R R -->|是| S R -->|否| N --> B B --> F --> G S --> I G --> I I --> K classDef formattedStyle fill:#E0F2FE,stroke:#0284C7,color:#0C4A6E,stroke-width:2px; classDef entryStyle fill:#F1F5F9,stroke:#64748B,color:#334155,stroke-width:2px; classDef stringStyle fill:#DCFCE7,stroke:#16A34A,color:#14532D,stroke-width:2px; classDef objectStyle fill:#FEF3C7,stroke:#D97706,color:#78350F,stroke-width:2px; classDef commonStyle fill:#EDE9FE,stroke:#7C3AED,color:#4C1D95,stroke-width:2px; classDef exitStyle fill:#FFEDD5,stroke:#EA580C,color:#7C2D12,stroke-width:2px; class A,B,F,G formattedStyle; class P,Q,R entryStyle; class S stringStyle; class N objectStyle; class I commonStyle; class K exitStyle; linkStyle default stroke:#94A3B8,stroke-width:1.5px;
sink
同样,以默认logger的sink为例:
1// registry-inl.h
2SPDLOG_INLINE registry::registry()
3 : formatter_(new pattern_formatter()) {
4
5 // 创建一个sink
6 // 以此为切入点看sink
7 auto color_sink = std::make_shared<sinks::ansicolor_stdout_sink_mt>();
8
9 const char *default_logger_name = "";
10 // 创建一个default logger,空名称,sink被logger控制
11 default_logger_ = std::make_shared<logger>(default_logger_name, std::move(color_sink));
12 loggers_[default_logger_name] = default_logger_;
13}
14
15// ansicolor_sink.h
16using ansicolor_stdout_sink_mt = ansicolor_stdout_sink<details::console_mutex>;
17
18template <typename ConsoleMutex>
19class ansicolor_stdout_sink : public ansicolor_sink<ConsoleMutex> {
20public:
21 explicit ansicolor_stdout_sink(color_mode mode = color_mode::automatic);
22};
23
24template <typename ConsoleMutex>
25class ansicolor_sink : public sink {
26 ...
27protected:
28 // 输出目标
29 FILE *target_file_;
30
31private:
32 mutex_t &mutex_;
33 bool should_do_colors_;
34 // 执行输出的对象
35 std::unique_ptr<formatter> formatter_;
36 std::array<std::string, level::n_levels> colors_;
37 void set_color_mode_(color_mode mode);
38 void print_ccode_(const string_view_t &color_code) const;
39 void print_range_(const memory_buf_t &formatted, size_t start, size_t end) const;
40 static std::string to_string_(const string_view_t &sv);
41}
省略方法之后,不难看到,这一层记录了输出目标,其他值得注意的只有以下几个:
- formatter_,负责格式化metadata
- print_ccode_,写入颜色控制指令
- print_range_,写入数据
那么,回到logger的最后一步,我们当然关注sink的log方法做了什么:
1template <typename ConsoleMutex>
2SPDLOG_INLINE void ansicolor_sink<ConsoleMutex>::log(const details::log_msg &msg) {
3 // Wrap the originally formatted message in color codes.
4 // If color is not supported in the terminal, log as is instead.
5 std::lock_guard<mutex_t> lock(mutex_);
6 msg.color_range_start = 0;
7 msg.color_range_end = 0;
8 memory_buf_t formatted;
9 // 决定前一段元信息长什么样子
10 formatter_->format(msg, formatted);
11
12 // 将格式化的内存存入
13 if (should_do_colors_ && msg.color_range_end > msg.color_range_start) {
14 // 一个pattern里只支持一块着色区域,一般而言都是level
15 // before color range
16 print_range_(formatted, 0, msg.color_range_start);
17 // in color range
18 print_ccode_(colors_.at(static_cast<size_t>(msg.level)));
19 print_range_(formatted, msg.color_range_start, msg.color_range_end);
20 print_ccode_(reset);
21 // after color range
22 print_range_(formatted, msg.color_range_end, formatted.size());
23 } else // no color
24 {
25 print_range_(formatted, 0, formatted.size());
26 }
27 fflush(target_file_);
28}
根据上述的分析,print_range_、print_ccode_之类的函数只是在做输出,那显然真正的格式化是在formatter_->format(msg, formatted);中。
对于常规的ansicolor_sink,其formatter往往是pattern_formatter,于是我们可以继续往下探讨。
formatter
自然,这里会以pattern_formatter为例,也是整个库的精华所在,pattern_formatter的定义如下:
1// pattern_formatter.h
2class SPDLOG_API pattern_formatter final : public formatter {
3 // 包含预设+自定义的formatter
4 std::vector<std::unique_ptr<details::flag_formatter>> formatters_;
5 void format(const details::log_msg &msg, memory_buf_t &dest) override;
6
7 void compile_pattern_(const std::string &pattern);
8};
最重要的就只有compile_pattern_,这一步会在构造/设置的时候发生,对传入的pattern字符串做处理,以默认情况而言,传入的往往是"%+",但并不以此为例,为了直观对比其功能,假设传入的pattern是:
1// [hour minute second zone] [logger name] [level] [thread num] message
2spdlog::set_pattern("[%H:%M:%S %z] [%n] [%^---%L---%$] [thread %t] %v");
3
4// pattern_formatter-inl.h
5SPDLOG_INLINE void pattern_formatter::compile_pattern_(const std::string &pattern) {
6 auto end = pattern.end();
7 std::unique_ptr<details::aggregate_formatter> user_chars;
8 formatters_.clear();
9 for (auto it = pattern.begin(); it != end; ++it) {
10 if (*it == '%') {
11 if (user_chars) // append user chars found so far
12 {
13 formatters_.push_back(std::move(user_chars));
14 }
15 // 解决宽度对齐格式的问题
16 auto padding = handle_padspec_(++it, end);
17
18 if (it != end) {
19 if (padding.enabled()) {
20 // pattern 中指定了宽度,例如 %8l、%-8l、%=8l
21 // 该函数进行基础的
22 handle_flag_<details::scoped_padder>(*it, padding);
23 } else {
24 // 没有指定宽度,例如 %l
25 handle_flag_<details::null_scoped_padder>(*it, padding);
26 }
27 } else {
28 break;
29 }
30 } else // chars not following the % sign should be displayed as is
31 {
32 // 对于普通的字符,就正常加到user_char里面
33 if (!user_chars) {
34 // 因为有move,所以有可能为空
35 user_chars = details::make_unique<details::aggregate_formatter>();
36 }
37 user_chars->add_ch(*it);
38 }
39 }
40 // 如果最后是纯文本,也应当处理
41 if (user_chars) // append raw chars found so far
42 {
43 formatters_.push_back(std::move(user_chars));
44 }
45}
对于compile_pattern_,其核心思路是分割不同的规格说明,以[%n]为例,在经过上述函数后,会在formatters_中添加如下几个类型的flag_formatter:
- aggregate_formatter,对应[
- name_formatter,对应%n
- aggregate_formatter,对应] 这里由于篇幅原因,并不细讲handle_flag_的处理,但也比较简单。
对于这三个flag_formatter的作用暂时省略,回过头再看看pattern_formatter的format方法的实现:
1for (auto &f : formatters_) {
2 f->format(msg, cached_tm_, dest);
3}
显然是调用各个flag_format的format方法来实现的。 核心其实只有一个,把log_msg的东西按照规格说明写入到dest里。
flag_formatter
1// pattern_formatter-inl.h
2// 以logger的formatter为例
3template <typename ScopedPadder>
4class name_formatter final : public flag_formatter {
5public:
6 explicit name_formatter(padding_info padinfo)
7 : flag_formatter(padinfo) {}
8
9 void format(const details::log_msg &msg, const std::tm &, memory_buf_t &dest) override {
10 // 在这里构造scopedpadder,专门负责处理缩进之类的事情,这里依据缩进的大小将缩进处理好
11 ScopedPadder p(msg.logger_name.size(), padinfo_, dest);
12 // 这里填充具体的内容
13 fmt_helper::append_string_view(msg.logger_name, dest);
14 // 析构ScopedPadder时,负责后半部分的填充和截断
15 }
16};
17
18// 普通文本格式化器,原样直出
19// aggregate user chars to display as is
20class aggregate_formatter final : public flag_formatter {
21public:
22 aggregate_formatter() = default;
23
24 void add_ch(char ch) { str_ += ch; }
25 // 这里也能看出来
26 void format(const details::log_msg &, const std::tm &, memory_buf_t &dest) override {
27 fmt_helper::append_string_view(str_, dest);
28 }
29
30private:
31 std::string str_;
32};
可以看到,到flag_formatter这一层基本就完成了一条日志消息的输出。 当然这里省略了相当多的细节,例如padding、自定义pattern、色彩输出等等,但结构是足够完整的,我们可以看到一条层次分明的脉络,log_msg在各层之间传递,不同对象各司其职,每个层级都只做一件事情,并且边界清晰。
小结
相较于前一篇的log.c,spdlog无疑是一套更加出色、使用了良好工程实践的日志库,使用了相当多C++特性,没办法在一篇文章全部讲完。
我想合适的篇幅应当是在三篇文章左右,下一篇应当会谈谈spdlog是如何做到线程安全、异步等,以及使用了哪些值得一提的C++技术,比如模版、RingBuffer、单例之类,最后一篇会做几个小实验,来评估一下其性能优劣。