如何基于 spdlog 在编译期提供类 logrus 的日志接口
实现见 Github,代码简单,只有一个头文件。
前提
几年前看到戈君在知乎上的一篇文章,关于打印日志的一些经验总结;
实践下来很受用,在 golang 里结构化日志和 logrus 非常契合,最常见的使用方式如下。
go
logrus.WithField("addr", "127.0.0.1:80").Info("New conn")
logrus.WithFields(logrus.Fields{"ip": "127.0.0.1", "port": 80}).Info("New conn")
// 复用 task_id
l := logrus.WithField("task_id", 2)
l.WithField("progress", "20%").Info("Uploading os image")
l.WithFields(logrus.Fields{"err_msg": "Success", "err_code": 0}).Info("Completed")
最近在使用 C++ 写一些东西,日志库是 spdlog,综合体验最好的日志库了。在结构化输出一些多字段的情况下,有一个体验不佳的地方(相对 logrus)
cpp
spdlog::info("Closing TCP id={} listener={} addr={} ns={}", id, fmt::ptr(listener), addr.format(), netns);
字段多了容易造成 key-value 距离较远,修改起来容易张冠李戴。
期望
对 spdlog 进行简单的封装,提供类似 logrus 的接口
- key/value 不分离,代码清晰能够看到对应关系
- 编译期搞定,不分配内存
- 日志的 msg 及 key 只支持字面量字符串(这两个信息在打日志的时候就应该清晰)
cpp
// 纯消息的日志
logrus::info("hello world!");
// 携带一个 key/value 的日志
logrus::with_field("addr", "127.0.0.1:80").info("New conn");
// 携带两个 key/value 的日志
logrus::with_field("ip", "127.0.0.1").with_field("port", 80).info("New conn2");
// 携带多个 key/value 的日志, logrus::Field 为一个 key/value 结构
logrus::with_fields(logrus::Field("ip", "127.0.0.1"), logrus::Field("port", 80)).info("New conn3");
// 复用 task_id 日志对象,在不同条件下的日志
auto l = logrus::with_field("task_id", 1);
if (true)
l.with_fields(logrus::Field("ip", "127.0.0.1"), logrus::Field("port", 80)).info("Listen on");
else
l.with_field("path", "xx.sock").info("Listen on");
额外提供一些宏
- 减少日志代码长度
- 提升日志代码的区分度
- 获取
__FILE__, __FUNCTION__, __LINE__
(优先级低)
cpp
LOG_INFO("New conn", KV("addr", "127.0.0.1:80"));
LOG_INFO("Updated version", KV("from", "1.6.1"), KV("to", "2.0.0"), KV("task_id", 2));
实现
不重复造轮子,实现的终点为调用 spdlog::log(level, fmt, args),一行日志包括
- fields,包括零或者多个 key/value ,with_field 产生一个 key/value
- msg,特化的 field,在所有的 fields 第一个位置,具体为 "msg"=msg
分解一下参数实现
- fmt 由所有的 key 组合而成,可能出现多个如 key1={} key2={} ,这里为了增加区分度实现为 key1='{}' key2='{}'
- args 由所有的 value 组合而成,按顺序展开即可
实现所需
- 构造 fmt,需要在编译期对字符串常量进行拼接
- 将 key/value 抽象为 Field 进行管理,并把所有的 Field 存在 std::tuple 中
- 在所有的 Field 都进入 std::tuple 后,构造出
spdlog
需要的参数
实现字面量字符串相加
所有的 key 都是字面量的字符串,期望是实现任意个字面量字符串进行相加。
key 的类型为 const char[N]
,要实现编译期相加,根据 N 来实现一个结构体/类,因为类型一定会在编译期确定。
结合 N 和 C++14 的特性 std::index_sequence
,实现一个最重要的构造函数,包含了两个字面量字符串及下标列表参数。
cpp
template <size_t N> struct Literal {
constexpr Literal(const char (&literal)[N])
: Literal(literal, std::make_index_sequence<N>{}) {}
constexpr Literal(const Literal<N> &literal) : Literal(literal.s) {}
template <size_t N1, size_t... I1, size_t N2, size_t... I2>
constexpr Literal(const char (&str1)[N1], std::index_sequence<I1...>,
const char (&str2)[N2], std::index_sequence<I2...>)
: s{str1[I1]..., str2[I2]..., '\0'} {}
template <size_t... I>
constexpr Literal(const char (&str)[N], std::index_sequence<I...>)
: s{str[I]...} {}
char s[N];
};
如果两个字面量字符串长度(包括 \0
结尾)分别为 N1 和 N2 ,那么相加的长度为 N1+N2-1,可以增加一个推导指引来实现构造函数
cpp
template <size_t N1, size_t N2>
Literal(const char (&)[N1], const char (&)[N2]) -> Literal<N1 + N2 - 1>;
// 有了推导指引后,可以直接实现两个相加的构造函数
template <size_t N1, size_t N2>
constexpr Literal(const char (&str1)[N1], const char (&str2)[N2])
: Literal(str1, std::make_index_sequence<N1 - 1>{}, str2,
std::make_index_sequence<N2 - 1>{}) {}
// 反之如果没有推导指引,可以通过一个函数来指定这个 N
template <size_t N1, size_t N2>
constexpr auto make_literal(const char (&str1)[N1], const char (&str2)[N2]) {
return Literal<N1 + N2 - 1>(str1, std::make_index_sequence<N1 - 1>{}, str2,
std::make_index_sequence<N2 - 1>{});
}
为了降低复杂度(可变参数的字面量字符串相加的 N
需要增加额外函数来计算),类 Literal
只提供基本的构造函数,相加的过程放在外部的函数中进行;
cpp
template <size_t N> constexpr auto make_literal(const char (&str)[N]) {
return Literal(str);
}
template <size_t N> constexpr auto make_literal(const Literal<N> &literal) {
return Literal(literal);
}
template <size_t N1, size_t N2>
constexpr auto make_literal(const char (&str1)[N1], const char (&str2)[N2]) {
return Literal<N1 + N2 - 1>(str1, std::make_index_sequence<N1 - 1>{}, str2,
std::make_index_sequence<N2 - 1>{});
}
template <size_t N1, size_t N2>
constexpr auto make_literal(const Literal<N1> &literal1,
const Literal<N2> &literal2) {
return make_literal(literal1.s, literal2.s);
}
template <size_t N1, size_t N2>
constexpr auto make_literal(const char (&str)[N1], const Literal<N2> &literal) {
return make_literal(str, literal.s);
}
template <size_t N1, size_t N2>
constexpr auto make_literal(const Literal<N1> &literal, const char (&str)[N2]) {
return make_literal(literal.s, str);
}
template <size_t N1, typename... Args>
constexpr auto make_literal(const char (&str)[N1], const Args &...args) {
return make_literal(str, make_literal(args...));
}
template <size_t N1, typename... Args>
constexpr auto make_literal(const Literal<N1> &literal, const Args &...args) {
return make_literal(literal, make_literal(args...));
}
通过重载 make_literal
来达到使用各种参数相同调用的效果
cpp
auto l1 = logrus::make_literal("123"); // logrus::Literal<4>
auto l2 = logrus::make_literal("a", "b", l1); // logrus::Literal<6>
auto l3 = logrus::make_literal(l1, " ", l2, " "); // logrus::Literal<11>
构造 spdlog 所需参数
抽象 key/value
单个 key/value 为一个 Field,功能实现简单只提供构造函数,作为字段的最小单位提供给其它模块使用。
cpp
template <size_t N, typename T> struct Field {
Literal<N> key;
T value;
constexpr Field(const char (&k)[N], T &&v)
: key(k), value(std::forward<T>(v)) {}
constexpr Field(const Literal<N> &k, T &&v)
: key(k), value(std::forward<T>(v)) {}
constexpr Field(const char (&k)[N], const T &v) : key(k), value(v) {}
constexpr Field(const Literal<N> k, const T &v) : key(k), value(v) {}
};
template <size_t N, typename T> Field(const char (&)[N], T) -> Field<N, T>;
Field 的构造推导指引函数非常重要,不可缺少,否则构造函数及后续的 tuple 会出现错误。
char[N]
在函数调用的情况下,类型会被转换为 char *
cpp
auto x = logrus::Field("hello", "world");
- 没有推导指引函数的情况下 x 被推导为 logrus::Field<6, char[6]>
- 有推导指引函数的情况下 x 被推导为 logrus::Field<6UL, const char *>
定义日志行对象 logrus::Entry
作为一个日志行的对象,内部包含了所有的 logrus::Field,在编译期确定类型。
- 提供对外调用的 with_field(s) 和 info 接口
- 在 info 被调用的时候调用日志格式化函数进行参数构造,并且最终调用 spdlog::log
with_field(s) 返回类型为 Entry<Fields...> ,为了足够简单,只接受 Field 类型的参数。
同样的,为 Entry(k, v) 增加一个构造函数的推导指引,否则类型就推导为 std::tuple<N, T> 了。
make_formatter 为格式化函数的一个辅助函数。
cpp
template <typename... Fields> struct Entry {
std::tuple<Fields...> fields;
template <size_t N, typename T>
constexpr Entry(const Field<N, T> &field) : fields(std::make_tuple(field)) {}
constexpr Entry(std::tuple<Fields...> &&fields) : fields(fields) {}
constexpr Entry(const std::tuple<Fields...> &fields) : fields(fields) {}
template <size_t N, typename T>
constexpr auto with_field(const char (&k)[N], const T &v) {
return with_fields(Field(k, v));
}
template <typename... Fields1>
constexpr auto with_fields(const Fields1 &...fields1) {
return Entry<Fields..., Fields1...>(
std::tuple_cat(fields, std::tie(fields1...)));
}
template <size_t N1>
void log(const char (&msg)[N1], spdlog::level::level_enum lvl) {
make_formatter(std::tuple_cat(std::make_tuple(Field("msg", msg)), fields),
std::make_index_sequence<sizeof...(Fields) + 1>{})
.log(lvl);
}
template <size_t N1> void info(const char (&msg)[N1]) {
log(msg, spdlog::level::info);
}
}
template <size_t N, typename T>
Entry(const Field<N, T> &field) -> Entry<Field<N, T>>;
将 key/value 转换为 spdlog 的入参
至此所有的数据都有了,现在需要对这些 key/value 进行修改及重组。还是那样,要在编译期确定类型,起手一个结构体。
在 Formatter 内就不再需要推导指引了,除构造函数和 log 之外,其它的功能全部交给外部函数进行驱动;
- make_formatter , 输入 std::tuple<Fields...> 来展开所有的 logrus::Field
- make_format_args ,写了三个重载函数进行展开调用(1个参数为终止函数,2个参数为过渡函数,多个参数为驱动函数)
- 构造 fmt
- 单个 Field 直接为
key='{}'
- 多个 Field 通过递归的从后向前进行构造,所以第一个参数为 Field ,随后的参数为 Formatter
- 单个 Field 直接为
- 收集 args,使用 std::tuple_cat 追加即可
- 构造 fmt
- Formatter::log , 展开 std::tuple<Args...> args ,为了减少工作量直接使用 C++17 中的
std::apply
,在lambda内部进行调用真正的 spdlog::log
cpp
template <size_t N, typename... Args> struct Formatter {
Literal<N> fmt;
std::tuple<Args...> args;
Formatter(const Literal<N> &fmt, const std::tuple<Args...> &args)
: fmt(fmt), args(args) {}
Formatter(const Literal<N> &fmt, std::tuple<Args...> &&args)
: fmt(fmt), args(std::forward<std::tuple<Args...>>(args)) {}
void log(spdlog::level::level_enum level) {
std::apply(
[&](Args &&...args) {
spdlog::log(level, fmt.s, std::forward<Args>(args)...);
},
std::forward<std::tuple<Args...>>(args));
}
};
template <size_t N, typename T>
constexpr auto make_format_args(const Field<N, T> &field) {
return Formatter<N + 5, T>(make_literal(field.key, "='{}'"), field.value);
}
template <size_t N1, typename T1, size_t N2, typename... Args>
constexpr auto make_format_args(const Field<N1, T1> &field,
const Formatter<N2, Args...> &formatter) {
return Formatter<N1 + N2 + 5, T1, Args...>(
make_literal(field.key, "='{}' ", formatter.fmt),
std::tuple_cat(std::tie(field.value), formatter.args));
}
template <size_t N1, typename T1, size_t N2, typename... Args>
constexpr auto make_format_args(const Field<N1, T1> &field,
Formatter<N2, Args...> &&formatter) {
return Formatter<N1 + N2 + 5, T1, Args...>(
make_literal(field.key, "='{}' ", formatter.fmt),
std::tuple_cat(std::tie(field.value), formatter.args));
}
template <size_t N1, typename T1, typename... Fields>
constexpr auto make_format_args(const Field<N1, T1> &field,
Fields &&...fileds) {
return make_format_args(field,
make_format_args(std::forward<Fields>(fileds)...));
}
template <typename Tuple, size_t... Idx>
constexpr auto make_formatter(const Tuple &tpl, std::index_sequence<Idx...>) {
return make_format_args(std::get<Idx>(tpl)...);
}
其它
类似 logrus,提供 with_field(s) 功能函数,不用调用 Entry 构造函数来初始化一条日志
cpp
template <size_t N, typename T>
constexpr auto with_field(const char (&k)[N], const T &v) {
return Entry(Field(k, v));
}
template <size_t N, typename T, typename... Fields>
constexpr auto with_fields(const Field<N, T> &field, const Fields &...fields) {
return Entry(std::make_tuple(field, fields...));
}
增强灵活性,有些日志可能有 key/value ,也有可能只有一个 msg,通过可变参数进行实现。
cpp
template <size_t N, typename... Fields>
void trace(const char (&msg)[N], const Fields &...fields) {
Entry(std::forward_as_tuple(fields...)).trace(msg);
}
至此,用宏进行封装一下也变得顺理成章了
cpp
#define LOG_TRACE(...) logrus::trace(__VA_ARGS__)
遇到的坑
实例化 logrus::Field("key", "value") 的时候,模版第二个参数推导为 char[N]
而不是 char *
,后面发现 std::pair
推导的类型没有问题,把 std::pair
的代码单独扒了看一遍才看到有推导指引这种东西
刚开始实现的时候,准备定一个 Fields 来完成现有的 Formatter 和 Entry 的功能,在类中需要写非常多的辅助函数来完成,还很容易推导失败,甚至经常进入死循环,直接把 clangd 干到 oom。所以做了一个转变
- 核心为 key/value,只要在编译期确定类型即可,这里用结构体封装,只实现构造函数,这样可以灵活调整模版类型
- Entry 和 Field 同理,只完成收集存储的功能
- 最后参数构造全部放在函数中进行,既可以修改 fmt 的值,还能够直接指定模版类型
TODO
- 提升 Formatter 的抽象程度,增加自定义 Formatter
- 增加 spdlog::logger 可选项
- 完善
const T &
和T &&
的函数定义