如何基於 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 &&
的函數定義