如何基于 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. 【SHELL】百分比进度指示,原地踏步

    百分比进度指示,原地踏步效果实现主要利用退格'\b',同行'\c' #!/bin/bash function percentage_progress() { progress=$(($1*100/$2 ...

  2. 百度网盘(百度云)SVIP超级会员共享账号每日更新(2024.01.23)

    一.百度网盘SVIP超级会员共享账号 可能很多人不懂这个共享账号是什么意思,小编在这里给大家做一下解答. 我们多知道百度网盘很大的用处就是类似U盘,不同的人把文件上传到百度网盘,别人可以直接下载,避免 ...

  3. Nginx的日志处理

    Nginx的日志处理 背景 之前一直被各种咨询nginx的使用问题. 大部分都是性能, 加模块, 以及一些tcp端口等的问题. 其实这些都还好, 还有一个比较麻烦的问题时日志相关的. nginx的日志 ...

  4. [转帖]RHEL/CentOS 7的systemd target及其中的multi-user.target

    在RHEL/CentOS 6中,使用SysV init和Upstart,通过预定义一组Runlevels(从0到6)表示不同的执行模式. [root@myhost app]# ll /etc/rc.d ...

  5. [转帖]MinIO Client(mc)完全指南

    https://www.cnblogs.com/lvzhenjiang/p/14944821.html 目录 一.获取MinIO Client(mc) 1.1 docker版 1.2 Homebrew ...

  6. [转帖]nginx配置默认首页(index.html index.htm)全流程(包含遇到问题的解决)

    https://www.cnblogs.com/tujietg/p/10753041.html#:~:text=%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%EF%BC%9 ...

  7. [转帖]Intel固态硬盘总结

    https://www.cnblogs.com/hongdada/p/17326247.html 2012年推出的S3700,采用的是25nm闪存颗粒. 2015年推出s3710,采用的是20nm闪存 ...

  8. [转帖]iptables开放指定端口

    https://www.jianshu.com/p/5b44dd20484c 由于业务的需要, MySQL,Redis,mongodb等应用的端口需要我们手动操作开启 下面以 MySQL 为例,开启 ...

  9. Springboot数据库连接池的学习与了解

    背景 昨天学习总结了tomcat的http连接池和线程池相关的知识,总结的不是很完整, 自己知道的也比较少,总结的时候就在想tomcat针对client 端有连接池,并且通过NIO的机制, 以较少的t ...

  10. ElasticSearch集群灾难:别放弃,也许能再抢救一下 | 京东云技术团队

    ​ 1 前言 Elasticsearch作为一个分布式搜索引擎,自身是高可用的:但也架不住一些特殊情况的发生,如: 集群超过半数的master节点丢失,ES的节点无法形成一个集群,进而导致集群不可用: ...