6

如何基于 spdlog 在编译期提供类 logrus 的日志接口 - 小胖西瓜

 7 months ago
source link: https://www.cnblogs.com/shuqin/p/18011032
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

如何基于 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 的值,还能够直接指定模版类型
  1. 提升 Formatter 的抽象程度,增加自定义 Formatter
  2. 增加 spdlog::logger 可选项
  3. 完善 const T &T && 的函数定义

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK