接上一篇《超详细!spdlog源码解析(上)》,我们提到spdlog主要由logger(也包括async_logger)、sink、formatter、registry这四个部分组成。其中logger已经在上一篇中介绍了,没看过的朋友点击链接先把那篇看了。

本文将重点介绍sink这部分的代码。sink —— 接收log_msg对象,并通过formatter将对象中所含有的信息转换成字符串,最后将字符串输出到指定的地方,例如控制台、文件等,甚至通过tcp/udp将字符串发送到指定的地方。sink译为“下沉”,扩展一下可以理解为“落笔”,做的是把日志真正记录下来的事情。

sink

sink相关的代码都在sinks文件夹中,有不同种类的sink实现,以满足用户对不同输出目的地的需求,比如有控制台、文件、网络、数据库等。我们不会一一介绍,只介绍下面几个,同时展示了它们之间的继承关系。其他的sink大家有兴趣的话自行阅读源码吧。

1
2
3
sink 
| ---> base_sink ---> basic_file_sink
| ---> stdout_sink_base

sink是所有不同类型sink的基类,它提供了统一的接口,实际上并它的实现并没有多少代码量。我们看看它的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using level_t = std::atomic<int>;

class sink
{
public:
virtual ~sink() = default;
virtual void log(const details::log_msg &msg) = 0;
virtual void flush() = 0;
virtual void set_pattern(const std::string &pattern) = 0;
virtual void set_formatter(std::unique_ptr<spdlog::formatter> sink_formatter) = 0;

void set_level(level::level_enum log_level);
level::level_enum level() const;
bool should_log(level::level_enum msg_level) const;

protected:
// sink log level - default is all
level_t level_{level::trace};
};

sink类只有一个成员变量,level_类型是原子变量。同时之后跟level_相关的成员函数在这里实现了,其他的都是纯虚函数,需要子类去实现。这是因为sink及其子类都要是线程安全的,因为此处level_已经是原子变量了,可以做到线程安全了,所以跟level_相关的成员函数就直接在此处得到实现。其他成员函数log()和flush()的功能是将从logger传过来的msg转成字符串然后写到缓冲区和从缓冲区写到目的地(控制台、文件等)。set_pattern()和set_formatter()是用来设置日志格式的,例如显示时间的样式等,这两个函数一定程度上是等价的。具体是怎么格式化的,我们留到将formatter部分的时候再讲,本文不再展开。

说到线程安全的实现,不知道有没有小伙伴会想,既然sink及其子类都要求线程安全,那么就应该在sink这个基类这里把线程安全相关的代码都写好,这样子类继承时候再写的代码只管逻辑就行,不用再考虑线程安全问题了。这么想是对的,确实应该在父类中把线程安全相关的代码都写好,spdlog也是这么做的。但是是在base_sink类里实现的,而不是sink类。为什么是在base_sink类里,而不是在sink类里?以及为什么stdout_sink_base直接继承自sink而不是base_sink?这两个问题我们等下会讲。

base_sink

base_sink继承自sink,而且是个类模板,代码也很少,就是对该加锁地方加上了锁,以此来实现线程安全。以下是base_sink部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename Mutex>
class base_sink : public sink {
public:
void log(const details::log_msg &msg) final {
std::lock_guard<Mutex> lock(mutex_);
sink_it_(msg);
}

void flush() final {
std::lock_guard<Mutex> lock(mutex_);
flush_();
}

protected:
Mutex mutex_;
virtual void sink_it_(const details::log_msg &msg) = 0;
virtual void flush_() = 0;
};

log和flush函数在sink是纯虚函数,需要在子类中实现。而base_sink的实现仅仅只是加锁之后再进一步调用sink_it_和flush_,只是做了线程安全方面的处理,没有具体业务逻辑。这里需要强调一下的是,锁mutex_的类型是Mutex是通过模板参数传进来的,也就是说base_sink是支持多种锁类型的。用模板来支持多种锁类型,这还不是这个模板用法的最值得说道的点。最值得说道的点是,这样的实现能够同时让base_sink十分优雅的支持无锁版本。

需要说明,对于只在线程中使用spdlog,我们肯定不希望每次写日志还要加锁,毕竟这带来的白白的性能损耗,所以也必须给所有类型的sink至少提供有锁和无锁两种版本。那怎么办?实现方式肯定很多,大家可以先暂停想象自己来写会怎么实现。接下来我们看看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 {}
};

以上是basic_file_sink中的两行代码,basic_file_sink继承自Mutex。basic_file_sink_mt后面的mt就是multi-thread的意思,表示多线程版本,所以模板参数用的是std::mutex,这很合理,不用再解释。basic_file_sink_st后面的st就是single-thread的意思,表示单线程版本,模板参数用的是details::null_mutex,这个null_mutex是spdlog自己实现的空锁。

这样的实现真是妙极了!!!不光整体代码看起来简洁优雅,而且如果想自定义一个spdlog里面没有的custom_sink,我们只需要关注这个custom_sink怎么把日志信息写到目的地的逻辑就行,然后就能自然得到有锁(线程安全)版本和无锁版本。

basic_file_sink

接着上面,我们继续来看basic_file_sink的代码。就是把base_sink的两个纯虚函数sink_it_和flush_实现了,本身逻辑也是十分简单。

1
2
3
4
5
6
7
8
9
10
11
template <typename Mutex>
void basic_file_sink<Mutex>::sink_it_(const details::log_msg &msg) {
memory_buf_t formatted;
formatter_->format(msg, formatted);
file_helper_.write(formatted);
}

template <typename Mutex>
void basic_file_sink<Mutex>::flush_() {
file_helper_.flush();
}

sink_it_中的formatter_->format(msg, formatted)就是将msg中的内容格式化成字符串,然后写入到formatted中,这部分的实现我们后面会讲到。file_helper_.write和file_helper_.flush中的核心代码(ubuntu环境下)无非也就是std::fwrite和std::fflush两个标注库函数了。

日志目的地是文件相关的sink还有好几个,例如daily_file_sink、hourly_file_sink、rotating_file_sink。看名字就知道是当文件或者时间达到一定条件的时候,就会自动切换到新的文件。这部分的代码有一些,大多数代码主要就是关于判断文件切换触发条件和以及具体切换过程的,都是些具体业务相关的。比如像线程安全这种方面,已经在其父类考虑了。所以这部分代码我们就不重点讲解了,大家感兴趣的自己去看看。

stdout_sinks

前面我们提到,stdout_sink_base直接继承自sink而不是base_sink。stdout_sink_base也是要考虑线程安全的,我们来看看它与base_sink有何不同。作为对比我们把base_sink的部分代码也贴过出来:

1
2
3
4
5
6
7
8
9
10
11
template <typename Mutex>
class base_sink : public sink {
protected:
Mutex mutex_;
};

template <typename ConsoleMutex>
class stdout_sink_base : public sink {
protected:
ConsoleMutex::mutex_t &mutex_;
};

stdout_sink_base和base_sink最大的区别就在于成员变量mutex_的类型不同。stdout_sink_base的锁类型是带引用的。看到这聪明的你肯定意识到是怎么回事。因为stdout_sink_base就是输出到控制台,那一个程序只能有一个控制台啊,因此stdout_sink_base中的mutex_应该是全局唯一的,是个单例,那么很理所当然的这里用引用类型。这个ConsoleMutex模板参数的实参就是一下两个结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct console_mutex {
using mutex_t = std::mutex;
static mutex_t &mutex() {
static mutex_t s_mutex;
return s_mutex;
}
};

struct console_nullmutex {
using mutex_t = null_mutex;
static mutex_t &mutex() {
static mutex_t s_mutex;
return s_mutex;
}
};

stdout_sink_base的其他成员函数的实现就是怎么把字符串输出到控制台的业务逻辑了,不是本文重点。

formatter

pattern_formatter

formatter的代码主要是在formatter.h、pattern_formatter.h、pattern_formatter-inl.h这三个文件中。在上面讲的sink中,是通过下面两句语句来使用formatter的。

1
2
memory_buf_t formatted;
formatter_->format(msg, formatted);

回顾一下,上面的msg是details::log_msg结构的对象,formatter_->format就是把msg中存着的信息以预定义好的格式转成字符串存到formatted中。其中的formatter_的实际类型是pattern_formatter(的指针)。pattern_formatter类继承自formatter类,但是这里不用多纠结,直接看pattern_formatter中的format方法。

1
2
3
4
5
6
7
// 部分不影响主体逻辑的代码未展示
void pattern_formatter::format(const details::log_msg &msg, memory_buf_t &dest)
{
for (auto &f : formatters_) {
f->format(msg, cached_tm_, dest); // cached_tm_是从msg取出日志时间,并转换到了对应市区的时间
}
}

核心代码就上面那几句,遍历formatters_,然后通过f->format不断往dest里追加数据,注意是追加!看起来pattern_formatter中又持有了很多个用来格式化msg内容的对象,即formatters_。这个formatters_变量名起得容易让人误解。这里强调下,pattern_formatter类继承自formatter类,pattern_formatter中的成员变量formatters_的类型是std::vector<std::unique_ptrdetails::flag_formatter>,它是flag_formatter的容器,跟formatter类没有任何继承关系。下面要介绍flag_formatter,为了方便我也叫做formatter,请大家注意区分。

flag_formatter

要完全理解上面代码的含义,还要从怎么设置日志格式讲起。我们可以不设置日志格式,这样用的就是默认格式,也可以自定义格式,而且自由度很高。

1
2
3
4
5
6
spdlog::info("Welcome to spdlog!");
spdlog::set_pattern("[%H点%M分] [%L] %v");
spdlog::info("Welcome to spdlog!");
// -----------------------输出-----------------------
// [2023-12-28 22:56:18.996] [info] Welcome to spdlog!
// [22点56分] [I] Welcome to spdlog!

可以看到,set_pattern之后,日志输出的格式变了。set_pattern里面那段字符串”[%H点%M分] [%L] %v”,不用多说我们也看得出来有一些特殊的符号来表示日志中的不同信息。set_pattern就是告诉pattern_formatter要按照这些符号组合起来的样式来组织日志信息。set_pattern函数也是将格式信息层层传递logger–>sink–>formatter,最后交由pattern_formatter来处理:

1
2
3
4
5
6
7
8
9
// 实际源代码的逻辑跟以下不一致
// 此处这么展示,仅是为了方便说明compile_pattern_做了什么。但不代表源代码是按照这样逻辑实现的
void pattern_formatter::compile_pattern_(const std::string &pattern) {

std::vector<std::string> flag_vec = split_pattern(pattern);
for (const string &flag : flag_vec) {
formatters_.push_back(flag_to_formatter(flag));
}
}

我们假设传入compile_pattern_的pattern = “[%H点%M分] [%L] %v”。该函数首先对pattern进行解析,分隔出多个flag,最终flag_vec = {“[“, “%H”, “点”, “%M”, “分] [“, “%L”, “]”, “ “, “%v”};首先每个特殊符号是一个flag,其次最长非特殊字符串也是flag。接着通过flag_to_formatter构造flag对应的flag_formatter追加到formatters_中。这些flag对应的flag_formatter如下:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
// aggregate_formatter对应的flag是所有非特殊符号的flag
// 例如上述的{"[", "点", "分] [", "]", " "}
void aggregate_formatter::format(const details::log_msg &,
const std::tm &,
memory_buf_t &dest) override
{
fmt_helper::append_string_view(str_, dest); // dest中追加内容,str_的值就是对应的flag字符串
}

// H_formatter对应的flag是%H
// 类似的也有%M对应的M_formatter,实现类似,不再展示
void H_formatter<ScopedPadder>::format(const details::log_msg &,
const std::tm &tm_time,
memory_buf_t &dest) override
{
const size_t field_size = 2;
ScopedPadder p(field_size, padinfo_, dest); // 内容对齐用的
fmt_helper::pad2(tm_time.tm_hour, dest); // 向dest中追加内容
}

// short_level_formatter对应的flag是%L
void short_level_formatter<ScopedPadder>::format(const details::log_msg &msg,
const std::tm &,
memory_buf_t &dest) override
{
string_view_t level_name{level::to_short_c_str(msg.level)};
ScopedPadder p(level_name.size(), padinfo_, dest);
fmt_helper::append_string_view(level_name, dest);
}

// v_formatter对应的flag是%v
void v_formatter<ScopedPadder>::format(const details::log_msg &msg,
const std::tm &,
memory_buf_t &dest) override
{
ScopedPadder p(msg.payload.size(), padinfo_, dest);
fmt_helper::append_string_view(msg.payload, dest); // msg.payload就是用户想要记录的日志内容
}

从上面的代码可以看出,不同的flag对应不同的flag_formatter,每个flag_formatter都实现了format方法,大部分代码应该都不用再细细解释了,看一下函数名、变量名和注释就能知道实现了什么功能。

上面代码值得一说的就是ScopedPadder的实现,它是类模板的模板参数。在spdlog中,它可对应scoped_padder和null_scoped_padder两个类型,都是用来处理字符串对齐的。null_scoped_padder看名字中有个null就知道它的功能是什么都不做,毕竟不对齐也是一种对齐方式。所以重点看scoped_padder,以上述H_formatter::format中的使用为例,这里仅仅是ScopedPadder p(field_size, padinfo_, dest);这样初始化了一个scoped_padder对象,之后并没有调用它的任何成员方法,看起来是不是很奇怪?看到这可能有些朋友已经猜到了,这里用的是RAII技术,它通过构造和析构来完成对齐工作。

对齐无外乎居中、左对齐和右对齐,对齐的时候有效内容长度和期望长度不一致,需要填充空格,填充左边、右边或两边都要。scoped_padder的构造方法里做的事就是在左边填充,然后H_formatter::format里通过fmt_helper::pad2继续向dest里追加内容。最后scoped_padder析构,其析构函数里再往dest填充。

看到这里回到上面去看pattern_formatter::compile_pattern_,就明白当时说的“通过f->format不断往dest里追加数据”是什么意思了。

相关文章:

参考链接:https://zhuanlan.zhihu.com/p/674689537