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

来自:博客园
时间:2024-02-07
阅读:

实现见 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++ 模板参数推导
返回顶部
顶部