如何基于 spdlog 在编译期提供类 logrus 的日志接口
如何基于 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 的接口
- key/value 不分离,代码清晰能够看到对应关系
- 编译期搞定,不分配内存
- 日志的 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");
额外提供一些宏
- 减少日志代码长度
- 提升日志代码的区分度
- 获取
__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),一行日志包括
- 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
,实现一个最重要的构造函数,包含了两个字面量字符串及下标列表参数。
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,可以增加一个推导指引来实现构造函数
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,在编译期确定类型。
- 提供对外调用的 with_field(s) 和 info 接口
- 在 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
- 单个 Field 直接为
- 收集 args,使用 std::tuple_cat 追加即可
- 构造 fmt
- 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 来完成现有的 Formatter 和 Entry 的功能,在类中需要写非常多的辅助函数来完成,还很容易推导失败,甚至经常进入死循环,直接把 clangd 干到 oom。所以做了一个转变
- 核心为 key/value,只要在编译期确定类型即可,这里用结构体封装,只实现构造函数,这样可以灵活调整模版类型
- Entry 和 Field 同理,只完成收集存储的功能
- 最后参数构造全部放在函数中进行,既可以修改 fmt 的值,还能够直接指定模版类型
TODO
- 提升 Formatter 的抽象程度,增加自定义 Formatter
- 增加 spdlog::logger 可选项
- 完善
const T &
和T &&
的函数定义
参考
如何基于 spdlog 在编译期提供类 logrus 的日志接口的更多相关文章
- 手淘架构组最新实践 | iOS基于静态库插桩的⼆进制重排启动优化 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 编译期插桩
抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 原创 Leo 字节跳动技术团队 2019-08-09 https://mp.weixin.qq.com/s/Drmmx5JtjG ...
- ClassLoader Java中类加载出现在哪个阶段,编译期和运行期? 类加载和类装载是一样的吗
1.ClassLoader Java中类加载出现在哪个阶段,编译期和运行期? 类加载和类装载是一样的吗? :当然是运行期间啊,我自己有个理解误区,改正后如下:编译期间编译器是不去加载类的,只负责编译而 ...
- C++编译期多态与运行期多态
前言 今日的C++不再是个单纯的"带类的C"语言,它已经发展成为一个多种次语言所组成的语言集合,其中泛型编程与基于它的STL是C++发展中最为出彩的那部分.在面向对象C++编程中, ...
- Javac早期(编译期)
从Sun Javac的代码来看,编译过程大致可以分为3个过程: 解析与填充符号表过程. 插入式注解处理器的注解处理过程. 分析与字节码生成过程. Javac编译动作的入口是com.sun.tools. ...
- 读书笔记 effective c++ Item 41 理解隐式接口和编译期多态
1. 显示接口和运行时多态 面向对象编程的世界围绕着显式接口和运行时多态.举个例子,考虑下面的类(无意义的类), class Widget { public: Widget(); virtual ~W ...
- JVM总结(六):早期(编译期)优化
这节我们来总结一下JVM编译器优化问题. JVM编译器优化 Javac编译器 Javac的源码和调试 解析与填充符号表 注解处理器 语法分析与字节码生成 Java语法糖 泛型和类型擦除 自动装箱.拆箱 ...
- Spring AOP 之编译期织入、装载期织入、运行时织入(转)
https://blog.csdn.net/wenbingoon/article/details/22888619 一 前言 AOP 实现的关键就在于 AOP 框架自动创建的 AOP 代理,AOP ...
- 深入理解JVM - 早期(编译期)优化
Java“编译期”是一段“不确定”的操作过程:可能是指一个前端编译器(编译器的前端)把*.java文件转变为*.class文件的过程:可能是指虚拟机的后端运行期编译器(JIT编译器,Just In T ...
- 深入分析Java的编译期与运行期
不知大家有没有思考过,当我们使用IDE写了一个Demo类,并执行main函数打印 hello world时都经历了哪些流程么? 想通过这篇文章来分析分析Java的执行流程,或者换句话说想聊聊Java的 ...
- 《深入理解Java虚拟机》-----第10章 程序编译与代码优化-早期(编译期)优化
概述 Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运 ...
随机推荐
- 【SHELL】百分比进度指示,原地踏步
百分比进度指示,原地踏步效果实现主要利用退格'\b',同行'\c' #!/bin/bash function percentage_progress() { progress=$(($1*100/$2 ...
- 百度网盘(百度云)SVIP超级会员共享账号每日更新(2024.01.23)
一.百度网盘SVIP超级会员共享账号 可能很多人不懂这个共享账号是什么意思,小编在这里给大家做一下解答. 我们多知道百度网盘很大的用处就是类似U盘,不同的人把文件上传到百度网盘,别人可以直接下载,避免 ...
- Nginx的日志处理
Nginx的日志处理 背景 之前一直被各种咨询nginx的使用问题. 大部分都是性能, 加模块, 以及一些tcp端口等的问题. 其实这些都还好, 还有一个比较麻烦的问题时日志相关的. nginx的日志 ...
- [转帖]RHEL/CentOS 7的systemd target及其中的multi-user.target
在RHEL/CentOS 6中,使用SysV init和Upstart,通过预定义一组Runlevels(从0到6)表示不同的执行模式. [root@myhost app]# ll /etc/rc.d ...
- [转帖]MinIO Client(mc)完全指南
https://www.cnblogs.com/lvzhenjiang/p/14944821.html 目录 一.获取MinIO Client(mc) 1.1 docker版 1.2 Homebrew ...
- [转帖]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 ...
- [转帖]Intel固态硬盘总结
https://www.cnblogs.com/hongdada/p/17326247.html 2012年推出的S3700,采用的是25nm闪存颗粒. 2015年推出s3710,采用的是20nm闪存 ...
- [转帖]iptables开放指定端口
https://www.jianshu.com/p/5b44dd20484c 由于业务的需要, MySQL,Redis,mongodb等应用的端口需要我们手动操作开启 下面以 MySQL 为例,开启 ...
- Springboot数据库连接池的学习与了解
背景 昨天学习总结了tomcat的http连接池和线程池相关的知识,总结的不是很完整, 自己知道的也比较少,总结的时候就在想tomcat针对client 端有连接池,并且通过NIO的机制, 以较少的t ...
- ElasticSearch集群灾难:别放弃,也许能再抢救一下 | 京东云技术团队
1 前言 Elasticsearch作为一个分布式搜索引擎,自身是高可用的:但也架不住一些特殊情况的发生,如: 集群超过半数的master节点丢失,ES的节点无法形成一个集群,进而导致集群不可用: ...