整理一下关于C++项目中日志库的学习,在之前曾经陆续实现过几次较为简单的日志工具, 但是在编程逐步正式化之后,自己造这种基础的轮子还是没必要的,采用成熟的spdlog日志库比较合适。

概述

spdlog是一个快速的、跨平台的C++日志库,它提供了简单易用的API,支持多种日志级别、多线程安全、异步日志写入等功能。 它具有灵活的配置选项,允许开发者根据需要自定义日志记录行为,支持日志文件的轮换和分割等常见功能。 因为spdlog的使用简单直观,文档齐全,社区活跃,它已经成为当前最常用的C++日志记录工具之一。

spdlog对使用者非常友好:

  • 可以作为纯头文件库使用,直接复制到项目中
  • 可以在编译安装后通过CMake导入
  • 可以将源码直接放在项目文件夹下,作为CMake子项目添加并使用(作为头文件库或二进制库均可)

spdlog更推荐通过编译使用,因为可以节约整体的编译时间,否则每次编译时spdlog也要占用相当的时间,可能是用了大量模板的原因。在导入spdlog的头文件时,采用按需取用的策略,而不是某些库采用的只需要include一个万能头文件即可。

spdlog依赖fmtlib进行格式化输出,fmtlib已经被c++20收编了,变成了std::format),为了避免版本冲突,spdlog可以选择使用内置或外部提供的fmtlib。

下面是最简单的Helloworld

1
2
3
4
5
6
#include "spdlog/spdlog.h"
int main()
{
// Use the default logger (stdout, multi-threaded, colored)
spdlog::info("Hello, {}!", "World");
}

编译与测试

将spdlog源码直接下载到本地进行编译,但是查看spdlog查看的ci.yml,发现它只支持如下几个环境:

  • linux:gcc,clang
  • macOS:clang

在Windows上官方并不支持,但测试发现msvc其实可以成功编译。而某些MinGW-W64发行版无法通过编译,例如 LLVM-MinGW。 编译产物是spdlog.lib,将其和整个spdlog头文件夹手动复制到项目中就可以使用了。

创建如下的main.cpp文件,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "spdlog/spdlog.h"

int main()
{
spdlog::info("Welcome to spdlog!");
spdlog::error("Some error message with arg: {}", 1);

spdlog::warn("Easy padding in numbers like {:08d}", 12);
spdlog::critical("Support for int: {0:d}; hex: {0:x}; oct: {0:o}; bin: {0:b}", 42);
spdlog::info("Support for floats {:03.2f}", 1.23456);
spdlog::info("Positional args are {1} {0}..", "too", "supported");
spdlog::info("{:<30}", "left aligned");

spdlog::set_level(spdlog::level::debug); // Set global log level to debug
spdlog::debug("This message should be displayed..");

// change log pattern
spdlog::set_pattern("[%H:%M:%S %z] [%n] [%^---%L---%$] [thread %t] %v");

// Compile time log levels
// Note that this does not change the current log level, it will only
// remove (depending on SPDLOG_ACTIVE_LEVEL) the call on the release code.
SPDLOG_TRACE("Some trace message with param {}", 42);
SPDLOG_DEBUG("Some debug message");
}

使用如下命令手动编译即可

1
clang++ main.cpp -Iinclude -Llib  -lspdlog -o test

缺少库文件也是可以的,作为纯头文件库使用

1
clang++ main.cpp -Iinclude -o test

可以正常运行即可,程序输出内容如下

1
2
3
4
5
6
7
8
[2024-04-15 19:41:38.245] [info] Welcome to spdlog!
[2024-04-15 19:41:38.245] [error] Some error message with arg: 1
[2024-04-15 19:41:38.246] [warning] Easy padding in numbers like 00000012
[2024-04-15 19:41:38.246] [critical] Support for int: 42; hex: 2a; oct: 52; bin: 101010
[2024-04-15 19:41:38.246] [info] Support for floats 1.23
[2024-04-15 19:41:38.247] [info] Positional args are supported too..
[2024-04-15 19:41:38.247] [info] left aligned
[2024-04-15 19:41:38.247] [debug] This message should be displayed..

CMake导入spdlog

基于URL添加源码

我们可以使用FetchContent模块:基于FetchContent模块的FetchContent_Declare命令,它会在生成项目时,进行源码的拉取。(需要保证网络的畅通)

在项目根目录的CMakeLists.txt中添加如下片段

1
2
3
4
5
6
7
include(FetchContent)
FetchContent_Declare(
spdlog
URL https://github.com/gabime/spdlog/archive/refs/tags/v1.13.0.zip
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
FetchContent_MakeAvailable(spdlog)

本地下载添加源码

我们同样可以手动下载spdlog的完整源码,并复制为当前项目的根目录下的子文件夹spdlog/,将其作为当前CMake项目的子项目, 这免去了每次从网络下载的过程。

在根目录下的CMakeLists.txt直接添加子目录作为子项目即可(注意导入的顺序要在其他使用spdlog项目之前)

1
add_subdirectory(spdlog)

导入已安装的spdlog

如果spdlog已经被安装了,CMake可以成功找到,那么直接导入也是可以的

1
find_package(spdlog REQUIRED)

注意此时使用导入的目标似乎都要带上spdlog::名称前缀。

项目源码分析

支持头文件和编译使用

spdlog源码的项目结构大致如下

1
2
3
4
5
6
7
8
|-cmake/
|-example/
|-include/
|-spdlog/
|-src/
|-tests/

...

有几个比较重要的文件:

  • spdlog/spdlog.h 为日志库接口,提供日志宏的属性控制函数。
  • spdlog/logger.h 为日志管理器,为前后端连接的枢纽。
  • spdlog/async.h 为异步模式接口。
  • spdlog/sinks/base_sink.h 为日志文件格式父类,后面所有的日志文件格式都是继承该类来实现不同功能。
  • spdlog/sinks/registry.h 用于登记所有的logger,及一些默认的属性,如日志格式、日志写入等级。

首先探究一下spdlog是如何实现同时支持编译使用和纯头文件使用的。

spdlog的实现文件全部以头文件或-inl.h文件的形式存放在include/spdlog/目录中,有的头文件和-inl.h文件是成对出现的,有的则只含有头文件。

对于成对出现的xxx.hxxx-inl.h,其中的内容为

xxx.h
1
2
3
4
5
6
7
#pragma once

// 类的定义,其中的部分方法在内部直接实现,剩下的方法只有声明

#ifdef SPDLOG_HEADER_ONLY
#include "pattern_formatter-inl.h"
#endif
xxx-inl.h
1
2
3
4
5
6
7
#pragma once

#ifndef SPDLOG_HEADER_ONLY
#include <spdlog/pattern_formatter.h>
#endif

// 既有一些类的定义,以及一些类方法的外部实现

src/目录下只有几个非必要的cpp文件,只是为了成功编译而加上的,这几个文件中的主要内容为

1
2
3
4
5
6
7
8
9
10
#ifndef SPDLOG_COMPILED_LIB
#error Please define SPDLOG_COMPILED_LIB to compile this file.
#endif

#include "spdlog/xx/xx.h"
#include "spdlog/xx/xx-inl.h"

// 一些模板的实例化,例如
template class SPDLOG_API spdlog::sinks::rotating_file_sink<std::mutex>;
template class SPDLOG_API spdlog::sinks::rotating_file_sink<spdlog::details::null_mutex>;

很明显,我们要关注下面的宏:

  • SPDLOG_HEADER_ONLY,代表作为纯头文件库使用
  • SPDLOG_COMPILED_LIB,代表正在编译,如果没有定义SPDLOG_COMPILED_LIB,在尝试编译那些*.cpp文件时直接报错。

项目结构

spdlog项目的逻辑结构大致如图

主要由logger(也包括async_logger)、sinkformatterregistry这四个部分组成,它们之间的基本逻辑结构如下图所示

  • spdlog log API:建立在logger之上的用户友好型接口,只是对logger使用的封装,目的只是为了能够像官网给的示例代码spdlog::info("Welcome to spdlog!");那样,让用户能够以最简单的方式使用spdlog打印出log。
  • logger:spdlog开始处理日志的入口。对于同步的sync-logger,主要负责日志信息的整理,将格式化(通过fmt)后的日志内容、日志等级、日志时间等信息打包整理为一个名为log_msg结构体的对象,然后再交给下游的sink进行处理。对于异步的async-logger,则是在将整理后的log_msg对象交给线程池,由线程池接管并处理后续的工作。
  • sink:接收log_msg对象,将对象中所含有的信息通过formatter转换成字符串,最后将字符串输出到指定的地方,例如控制台、文件等,甚至支持通过tcp/udp将字符串进行网络传输。sink可以理解为“下沉”或“落笔”,即负责把日志真正记录下来。
  • formatter:负责将log_msg对象中的信息转换成字符串,例如等级、时间、实际内容等。时间的格式和精度、等级输出显示的颜色等都是由formatter决定的。支持用户自定义格式。
  • registry:负责管理所有的logger,包括创建、销毁、获取等。用户还可以通过registry对所有的logger进行一些全局设置,例如设置日志等级。

使用方法与配置

日志等级

首先需要建立的概念是日志等级,spdlog支持如下日志等级

  • trace,最低等级,显示代码的执行过程信息
  • debug,调试信息
  • info,默认等级,输出一些提示信息
  • warn,警告
  • error,错误
  • critical,致命错误
  • off,最高等级,用于关闭所有日志记录。

等级从低到高,默认等级是info,可以设置全局的日志等级(在设置之后生效)

1
spdlog::set_level(spdlog::level::debug);

低于全局日志等级的输出语句不会输出信息,例如下面的输出语句中只有第二个才会输出

1
2
3
4
// default level is info
spdlog::debug("debug no show!");
spdlog::set_level(spdlog::level::debug);
spdlog::debug("debug show!");

如果设置为最高等级off,就会关闭所有的日志输出。

与此同时还有一组宏也可以控制日志等级

1
2
3
4
5
6
7
#define SPDLOG_LEVEL_TRACE 0
#define SPDLOG_LEVEL_DEBUG 1
#define SPDLOG_LEVEL_INFO 2
#define SPDLOG_LEVEL_WARN 3
#define SPDLOG_LEVEL_ERROR 4
#define SPDLOG_LEVEL_CRITICAL 5
#define SPDLOG_LEVEL_OFF 6

logger与sinker

spdlog的核心概念是logger,

默认logger

为了使用方便,spdlog提供了一个默认的全局logger(输出到stdout,彩色输出,线程安全), 可以直接使用spdlog::info(...)等语句调用。

1
spdlog::info("Hello,world!");

默认logger也可以被替换为其它logger(以shared_ptr形式)

1
2
spdlog::set_default_logger(some_other_logger);
spdlog::info("Use the new default logger");

补充

默认情况下的日志输出是同步模式的,可通过以下方法开启异步模式:

1
2
size_t q_size = 4096; // queue size must be power of 2
spdlog::set_async_mode(q_size);

在异步模式下,日志先存入队列(队列占用的内存 = 设置的队列大小 * slot的大小, 64位系统下slot大小为104字节),再由工作者线程从队列中取出并输出。当队列满时,会根据设定策略处理:

  • 阻塞新来的日志,直到队列中有剩余空间(默认处理方式);
  • 丢弃新来的日志,需要如下设定策略:
    1
    spdlog::set_async_mode(q_size, spdlog::async_overflow_policy::discard_log_msg);

spdlog为了保证线程安全和效率,将很多对象都分别提供了多线程与单线程版本:

  • *_st:单线程版本,不用加锁,效率更高。
  • *_mt:多线程版本,加锁保证程序是线程安全的。

在具体实现时,spdlog使用了下面的技巧来同时提供有锁和无锁版本,即用一个空锁类型代表不加锁的那一类处理

1
2
3
4
5
6
7
using basic_file_sink_mt = basic_file_sink<std::mutex>;
using basic_file_sink_st = basic_file_sink<details::null_mutex>;

struct null_mutex {
void lock() const {}
void unlock() const {}
};