如何基於 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 的接口

  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++ 模板參數推導
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章