如何基于 spdlog 在编译期提供类 logrus 的日志接口

实现见 Github,代码简单,只有一个头文件。

前提

几年前看到戈君在知乎上的一篇文章,关于打印日志的一些经验总结;

实践下来很受用,在 golang 里结构化日志和 logrus 非常契合,最常见的使用方式如下。

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)

spdlog::info("Closing TCP id={} listener={} addr={} ns={}", id, fmt::ptr(listener), addr.format(), netns);

字段多了容易造成 key-value 距离较远,修改起来容易张冠李戴。

期望

对 spdlog 进行简单的封装,提供类似 logrus 的接口

  1. key/value 不分离,代码清晰能够看到对应关系
  2. 编译期搞定,不分配内存
  3. 日志的 msg 及 key 只支持字面量字符串(这两个信息在打日志的时候就应该清晰)
// 纯消息的日志
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");

额外提供一些宏

  1. 减少日志代码长度
  2. 提升日志代码的区分度
  3. 获取 __FILE__, __FUNCTION__, __LINE__(优先级低)
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),一行日志包括

  1. fields,包括零或者多个 key/valuewith_field 产生一个 key/value
  2. msg,特化的 field,在所有的 fields 第一个位置,具体为 "msg"=msg

分解一下参数实现

  • fmt 由所有的 key 组合而成,可能出现多个如 key1={} key2={},这里为了增加区分度实现为 key1='{}' key2='{}'
  • args 由所有的 value 组合而成,按顺序展开即可

实现所需

  1. 构造 fmt,需要在编译期对字符串常量进行拼接
  2. key/value 抽象为 Field 进行管理,并把所有的 Field 存在 std::tuple
  3. 在所有的 Field 都进入 std::tuple 后,构造出 spdlog 需要的参数

实现字面量字符串相加

所有的 key 都是字面量的字符串,期望是实现任意个字面量字符串进行相加。

key 的类型为 const char[N],要实现编译期相加,根据 N 来实现一个结构体/类,因为类型一定会在编译期确定。

结合 N 和 C++14 的特性 std::index_sequence,实现一个最重要的构造函数,包含了两个字面量字符串及下标列表参数。

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 结尾)分别为 N1N2,那么相加的长度为 N1+N2-1,可以增加一个推导指引来实现构造函数

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 只提供基本的构造函数,相加的过程放在外部的函数中进行;

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 来达到使用各种参数相同调用的效果

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,功能实现简单只提供构造函数,作为字段的最小单位提供给其它模块使用。

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 *

auto x = logrus::Field("hello", "world");
  • 没有推导指引函数的情况下 x 被推导为 logrus::Field<6, char[6]>
  • 有推导指引函数的情况下 x 被推导为 logrus::Field<6UL, const char *>

定义日志行对象 logrus::Entry

作为一个日志行的对象,内部包含了所有的 logrus::Field,在编译期确定类型。

  1. 提供对外调用的 with_field(s)info 接口
  2. info 被调用的时候调用日志格式化函数进行参数构造,并且最终调用 spdlog::log

with_field(s) 返回类型为 Entry<Fields...>,为了足够简单,只接受 Field 类型的参数。

同样的,为 Entry(k, v) 增加一个构造函数的推导指引,否则类型就推导为 std::tuple<N, T> 了。

make_formatter 为格式化函数的一个辅助函数。

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
    • 收集 args,使用 std::tuple_cat 追加即可
  • Formatter::log, 展开 std::tuple<Args...> args,为了减少工作量直接使用 C++17 中的 std::apply,在lambda内部进行调用真正的 spdlog::log
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 构造函数来初始化一条日志

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,通过可变参数进行实现。

template <size_t N, typename... Fields>
void trace(const char (&msg)[N], const Fields &...fields) {
Entry(std::forward_as_tuple(fields...)).trace(msg);
}

至此,用宏进行封装一下也变得顺理成章了

#define LOG_TRACE(...) logrus::trace(__VA_ARGS__)

遇到的坑

实例化 logrus::Field("key", "value") 的时候,模版第二个参数推导为 char[N] 而不是 char *,后面发现 std::pair 推导的类型没有问题,把 std::pair 的代码单独扒了看一遍才看到有推导指引这种东西

刚开始实现的时候,准备定一个 Fields 来完成现有的 FormatterEntry 的功能,在类中需要写非常多的辅助函数来完成,还很容易推导失败,甚至经常进入死循环,直接把 clangd 干到 oom。所以做了一个转变

  1. 核心为 key/value,只要在编译期确定类型即可,这里用结构体封装,只实现构造函数,这样可以灵活调整模版类型
  2. Entry 和 Field 同理,只完成收集存储的功能
  3. 最后参数构造全部放在函数中进行,既可以修改 fmt 的值,还能够直接指定模版类型

TODO

  1. 提升 Formatter 的抽象程度,增加自定义 Formatter
  2. 增加 spdlog::logger 可选项
  3. 完善 const T &T && 的函数定义

参考

  1. 如何打印日志
  2. Structured, pluggable logging for Go.
  3. C++ 模板参数推导

如何基于 spdlog 在编译期提供类 logrus 的日志接口的更多相关文章

  1. 手淘架构组最新实践 | iOS基于静态库插桩的⼆进制重排启动优化 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 编译期插桩

    抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 原创 Leo 字节跳动技术团队 2019-08-09 https://mp.weixin.qq.com/s/Drmmx5JtjG ...

  2. ClassLoader Java中类加载出现在哪个阶段,编译期和运行期? 类加载和类装载是一样的吗

    1.ClassLoader Java中类加载出现在哪个阶段,编译期和运行期? 类加载和类装载是一样的吗? :当然是运行期间啊,我自己有个理解误区,改正后如下:编译期间编译器是不去加载类的,只负责编译而 ...

  3. C++编译期多态与运行期多态

    前言 今日的C++不再是个单纯的"带类的C"语言,它已经发展成为一个多种次语言所组成的语言集合,其中泛型编程与基于它的STL是C++发展中最为出彩的那部分.在面向对象C++编程中, ...

  4. Javac早期(编译期)

    从Sun Javac的代码来看,编译过程大致可以分为3个过程: 解析与填充符号表过程. 插入式注解处理器的注解处理过程. 分析与字节码生成过程. Javac编译动作的入口是com.sun.tools. ...

  5. 读书笔记 effective c++ Item 41 理解隐式接口和编译期多态

    1. 显示接口和运行时多态 面向对象编程的世界围绕着显式接口和运行时多态.举个例子,考虑下面的类(无意义的类), class Widget { public: Widget(); virtual ~W ...

  6. JVM总结(六):早期(编译期)优化

    这节我们来总结一下JVM编译器优化问题. JVM编译器优化 Javac编译器 Javac的源码和调试 解析与填充符号表 注解处理器 语法分析与字节码生成 Java语法糖 泛型和类型擦除 自动装箱.拆箱 ...

  7. Spring AOP 之编译期织入、装载期织入、运行时织入(转)

    https://blog.csdn.net/wenbingoon/article/details/22888619 一   前言 AOP 实现的关键就在于 AOP 框架自动创建的 AOP 代理,AOP ...

  8. 深入理解JVM - 早期(编译期)优化

    Java“编译期”是一段“不确定”的操作过程:可能是指一个前端编译器(编译器的前端)把*.java文件转变为*.class文件的过程:可能是指虚拟机的后端运行期编译器(JIT编译器,Just In T ...

  9. 深入分析Java的编译期与运行期

    不知大家有没有思考过,当我们使用IDE写了一个Demo类,并执行main函数打印 hello world时都经历了哪些流程么? 想通过这篇文章来分析分析Java的执行流程,或者换句话说想聊聊Java的 ...

  10. 《深入理解Java虚拟机》-----第10章 程序编译与代码优化-早期(编译期)优化

    概述 Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运 ...

随机推荐

  1. P1541-DP【绿】

    刚开始理解错题意了,题中说"玩家每次需要从所有的爬行卡片中选择一张之前没有使用过的爬行卡片"指的是不能用同一张卡片,我给理解成不能连续用同一种卡片了.后来想想其实题目中的说法歧义不 ...

  2. vmware超融合基础安装与配置

    目录 vmware超融合 安装配置ESXI 安装VMware vCenter Server 安装vCenter插件 安装vCenter 使用VMware Vsphere Client登录Vcenter ...

  3. php开发中常见的漏洞点(一) 基础sql注入

    前言 本系列为小迪2022的学习笔记,仅用于自我记录. 正文 在一般情况下,一个网站的首页大致如下 在上方存在着各种各样的导航标签.链接.而一般情况下网站的导航会用参数进行索引的编写,比如id.pag ...

  4. python常见面试题讲解(九)字符个数统计

    题目描述 编写一个函数,计算字符串中含有的不同字符的个数.字符在ACSII码范围内(0~127),换行表示结束符,不算在字符里.不在范围内的不作统计.注意是不同的字符 输入描述: 输入N个字符,字符在 ...

  5. Feign源码解析6:如何集成discoveryClient获取服务列表

    背景 我们上一篇介绍了feign调用的整体流程,在@FeignClient没有写死url的情况下,就会生成一个支持客户端负载均衡的LoadBalancerClient.这个LoadBalancerCl ...

  6. 你不知道的JavaScript APIs

    前言 在本文中,将介绍一些鲜为人知但却非常有用的API,如: Page Visibility API Web Share API Broadcast Channel API International ...

  7. 如何学习 Photoshop

    你有没有想过"图像处理或图形设计看起来很酷,我要学习 Photoshop!" 然后你第一次打开 Photoshop,并被你所看到的东西所震撼. Photoshop 是一款功能强大的 ...

  8. AHB 局限性

    AHB's problem SoC bus 架构 AXI is used more and more 频率200M使用AHB,频率再升高就使用AXI AHB的问题 AHB协议本身限制要求较高,比如co ...

  9. 【SHELL】获取脚本输入参数

    参数获取 EXEC_PARAMS=(${@:index}) 示例 ./do.sh test a b c d e f EXEC_PARAMS=(${@:0}) ./do.sh test a b c d ...

  10. [转帖]TIKV扩容之刨坑填坑​

    01 背景 某tidb集群收到告警,TIKV 节点磁盘使用率85%以上,联系业务无法快速删除数据,于是想到扩容TIKV 节点,原先TIKV 节点机器都是6TB的硬盘,目前只有3TB的机器可扩,也担心r ...