当前位置:

首页 > 编程开发 > 加班之余从零写了个 spdlog 风格的日志库

加班之余从零写了个 spdlog 风格的日志库

从零实现spdlog风格C++日志库,采用分层架构:common.h定义日志消息结构,formatter.h将模式串编译为flag_formatter链表,sink.h通过模板参数实现锁策略,async_logger.h使用阻塞队列异步写日志,registry.h作为全局单例管理。通过亲手实现理解设计模式与工程细节。

从零手写 C++ 日志库 —— 一个 spdlog 风格 logger 的完整实现


spdlog 是 C++ 日志库的事实标准,header-only、性能炸裂、功能齐全。但说实话,每次翻开它的源码都有点想放弃——模板嵌套、工厂模式、fmt 集成,太多高级特性堆在一起,根本看不清架构脉络。

加班之余从零写了个 spdlog 风格的日志库

有一天突然冒出一个想法:与其硬看源码,不如自己从零写一个。目标不是替代 spdlog,而是理解它为什么长成那个样子。

从零写一个日志库,真实的好处是:一个设计点都逃不掉。用 spdlog 的时候 logger->info("hello") 一行就结束了。但自己实现这一行,背后每个决策都得想清楚——消息怎么存、格式串怎么编、sink 要不要锁、异步队列怎么设计。等你全部亲手做过一遍,再看 spdlog 源码就会发现没什么神秘的了——那些设计模式你在自己实现的过程中已经摸过,剩下的只是工程细节。

这篇文章按代码的分层结构走,每一层都会解释"它要解决什么问题、为什么这样设计、最关键的代码是什么"。


项目结构

第一版写了两天——一个 580 行的单头文件,所有类堆在一起。大家可能不信,那个版本能跑,也能看懂,但回看真的累。上周才拆成了模块:

 复制代码my_logger/
├── logger.h                  — 聚合头文件,用户只 include 这一个
└── core/
    ├── common.h              — 日志级别、消息结构体、跨平台适配
    ├── blocking_queue.h      — 线程安全阻塞队列
    ├── formatter.h           — 策略模式:%flag → formatter 链表
    ├── sink.h                — 多态输出:控制台/文件/滚动文件
    ├── logger.h              — 日志记录器
    ├── async_logger.h        — 异步日志(后台线程写 sink)
    ├── registry.h            — 全局单例注册表
    └── macros.h              — LOG_INFO / LOG_ERROR 等宏

跟 muduo 的分层思路一样——每层只做一件事,替换任何一层都不影响其他层。


第一层:common.h — 消息结构

一条日志长什么样?其实就是这几个字段:

 复制代码enum class level {
    trace = 0, debug = 1, info = 2, warn = 3, err = 4, crit = 5, off = 6
};struct log_msg {
    std::string logger_name;                            // 哪个 logger 产生的
    level lvl;                                          // 日志级别
    std::string payload;                                // 用户传进来的消息正文
    std::chrono::system_clock::time_point time;         // 产生时刻
    size_t thread_id;                                   // 哪个线程产生的
};

就是一个结构体——把一次日志调用的所有信息打包,下一步交给 formatter 处理。thread_id 是用 std::hash 算出来的简短数字,方便输出。

这里有个坑:localtime 是 C 标准库函数,Windows 和 Linux 的线程安全版本不一样。Windows 用 localtime_s,Linux 用 localtime_r。写了一个小包装:

 复制代码inline void localtime_platform(const std::time_t* t, std::tm* tm) {
#ifdef _WIN32
    localtime_s(tm, t);
#else
    localtime_r(t, tm);
#endif
}

第二层:formatter.h — 格式串怎么编译

这是整个库最有意思的设计。用户传一个模式串:

 复制代码"[%Y-%m-%d %H:%M:%S.%e] [%n] [%l] %v"

最终输出:

 复制代码[2026-07-13 23:15:30.123] [myapp] [info] hello world

问题来了:这段模式串怎么处理?最简单的做法是 format() 里用 if/else 逐个字符解析——遇到 % 就看看后面是什么、拼一段再继续走。能跑,但性能差。每条日志都要重新分析模式串,O(n) 字符串扫描。

spdlog 的思路是把模式串编译成一个对象数组。编译只做一次(构造时),运行时直接遍历对象数组执行虚函数——零解析开销。

flag_formatter 继承体系

每个 %flag 对应一个子类:

 复制代码class flag_formatter {
public:
    virtual ~flag_formatter() = default;
    virtual void format(const log_msg& msg, const std::tm& tm_time,
                        std::string& dest) = 0;
};

然后 %Y → year_flag,%m → month_flag,%d → day_flag,依此类推。每个子类就干一件小事:

 复制代码class year_flag : public flag_formatter {
public:
    void format(const log_msg&, const std::tm& tm, std::string& dest) override {
        dest += std::to_string(tm.tm_year + 1900);
    }
};class message_flag : public flag_formatter {
public:
    void format(const log_msg& msg, const std::tm&, std::string& dest) override {
        dest += msg.payload;
    }
};

编译 → 执行

 复制代码class pattern_formatter {
    std::vector> formatters_;    void compile_pattern_(const std::string& pattern) {
        std::string literal;    // 攒着 %flag 之间的普通文本块
        for (auto it = pattern.begin(); it != pattern.end(); ++it) {
            if (*it == '%' && std::next(it) != pattern.end()) {
                // 先把之前攒的文本块输出
                if (!literal.empty()) {
                    formatters_.push_back(make_unique(move(literal)));
                    literal.clear();
                }
                // 读取 flag 字符,创建对应的 formatter
                char flag = *++it;
                formatters_.push_back(create_flag_(flag));
            } else {
                literal += *it;
            }
        }
        // 剩的文本块(如果有)
        if (!literal.empty())
            formatters_.push_back(make_unique(move(literal)));
    }    // 实际执行格式化就是简单遍历
    void format(const log_msg& msg, std::string& dest) {
        for (auto& f : formatters_) {
            f->format(msg, cached_tm_, dest);
        }
        dest += 'n';
    }
};

逻辑很直接:遍历模式串,% 后面跟的字符是一个 flag 指令,其他字符是普通的文字块。literal_flag 直接追加文字,name_flag 追加 logger 名称,level_flag 追加级别名称,等等。

时间缓存

还有一个重要的优化。一条消息可能发给多个 sink(比如同时输出到屏幕和文件),如果每个 sink 都调一次 localtime,浪费不小。实际上同一条消息的时间不可能变——秒级之内都一样。所以缓冲了一个 cached_tm_,秒变了才重新调 localtime:

 复制代码void format(const log_msg& msg, std::string& dest) {
    auto secs = duration_cast(msg.time.time_since_epoch());
    if (secs != last_log_secs_) {        // 秒变了才更新
        time_t t = system_clock::to_time_t(msg.time);
        localtime_platform(&t, &cached_tm_);
        last_log_secs_ = secs;
    }
    for (auto& f : formatters_)
        f->format(msg, cached_tm_, dest);
    dest += 'n';
}

第三层:sink.h — 多态输出 + 模板注入锁

日志的输出目标可以是控制台、文件、甚至网络。每种目标行为不同——控制台直接 cout,文件需要 ofstream,滚动文件还需要检测大小、重命名。但它们有统一的接口:

 复制代码class sink {
public:
    virtual ~sink() = default;
    virtual void log(level lvl, const std::string& msg) = 0;
    virtual void flush() = 0;    void set_level(level lvl) { level_ = lvl; }
    level get_level() const { return level_; }    bool should_log(level msg_level) {
        return static_cast<int>(msg_level) >= static_cast<int>(level_);
    }protected:
    level level_{ level::trace };
};

sink 只定义接口,具体的输出行为留给子类。同时每个 sink 有自己的 level 阈值——比如 stdout_sink 设 info,file_sink 设 trace,就能做到"屏幕只看重要信息,文件记录全部"。

锁策略——模板注入

多线程下写文件需要加锁,但控制台输出版本如果是单线程就不需要锁——白费 CPU。怎么让一份代码同时支持两种锁策略?答案是模板参数:

 复制代码// 空锁——编译后直接消失,零开销
struct null_mutex {
    void lock() {}
    void unlock() {}
};// 模板基类:通过 Mutex 参数决定加不加锁
template <typename Mutex = null_mutex>
class base_sink : public sink {
public:
    void log(level lvl, const std::string& msg) final {
        std::lock_guard lock(mtx_);    // 如果是 null_mutex,lock() 是空函数
        sink_it_(lvl, msg);                    // → 编译器内联,直接消失
    }
    void flush() final {
        std::lock_guard lock(mtx_);
        flush_();
    }
protected:
    virtual void sink_it_(level lvl, const std::string& msg) = 0;
    virtual void flush_() = 0;
    Mutex mtx_;
};

子类继承的时候选模板参数:

 复制代码// 控制台——默认单线程,template 用的是 null_mutex
class stdout_sink : public base_sink<> {
    void sink_it_(level, const std::string& msg) override { std::cout << msg; }
    void flush_()   override { std::cout << std::flush; }
};// 文件——多线程安全,template 用的是 std::mutex
class file_sink : public base_sink {
    std::ofstream file_;
public:
    file_sink(const std::string& filename) { file_.open(filename); }
    void sink_it_(level, const std::string& msg) override { file_ << msg; }
    void flush_()   override { file_ << std::flush; }
};

这就是"编译期多态"——template 不是运行时选的,是编译时就确定了。null_mutex 版本在生成的汇编里根本没有锁操作。

滚动文件 sink

生产环境最实用的 sink:文件大到一定尺寸(比如 1MB),它会关掉旧文件、给它改个名(app_20260713_231530.log),然后开一个新 app.log 继续写。核心是 std::rename 这个系统调用,原子操作,跨平台。

 复制代码void rotate_() {
    auto now = system_clock::now();
    auto t = system_clock::to_time_t(now);
    tm tm_buf;
    localtime_platform(&t, &tm_buf);
    char ts[32];
    strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tm_buf);    auto old = base_name_ + ".log";
    auto rotated = base_name_ + "_" + ts + ".log";
    rename(old.c_str(), rotated.c_str());
}

第四层:logger.h — 组合 formatter + sink

有了 formatter 和 sink,logger 的工作就是把两者串起来:

 复制代码class logger {
    std::string name_;
    level level_{ level::info };
    std::unique_ptr formatter_;
    std::vector> sinks_;   // 可以有多个 sinkpublic:
    void log(level lvl, const std::string& payload) {
        if (!should_log(lvl)) return;               // ① 级别过滤        log_msg msg;                                // ② 打包消息
        msg.logger_name = name_;
        msg.lvl = lvl;
        msg.payload = payload;
        msg.time = system_clock::now();
        msg.thread_id = hash{}(this_thread::get_id());        std::string formatted;
        formatter_->format(msg, formatted);         // ③ 格式化        for (auto& s : sinks_) {                    // ④ 分发给每个 sink
            if (s->should_log(lvl))
                s->log(lvl, formatted);
        }
    }    // 加一个 sink
    void add_sink(std::shared_ptr s) { sinks_.push_back(move(s)); }
};

用户实际用的便捷接口:

 复制代码void trace(const std::string& msg) { log(level::trace, msg); }
void debug(const std::string& msg) { log(level::debug, msg); }
void info(const std::string& msg)  { log(level::info,  msg); }
void warn(const std::string& msg)  { log(level::warn,  msg); }
void error(const std::string& msg) { log(level::err,   msg); }
void crit(const std::string& msg)  { log(level::crit,  msg); }

第五层:async_logger.h — 异步日志

同步 logger 有一个性能坑:写文件是 I/O 操作,可能慢,会阻塞业务线程。解决方案是把"写 sink"这个慢操作移到后台线程去,业务线程只负责格式化消息和丢队列。

blocking_queue — 生产者消费者

 复制代码template <typename T>
class blocking_queue {
    std::queue q_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stopped_ = false;    void enqueue(T item) {
        { lock_guard lock(mtx_); q_.push(move(item)); }
        cv_.notify_one();                      // 唤醒一个等待的消费者
    }    optional dequeue() {
        unique_lock lock(mtx_);
        cv_.wait(lock, [this] { return !q_.empty() || stopped_; });
        if (stopped_ && q_.empty()) return nullopt;  // 退出了
        T item = move(q_.front());
        q_.pop();
        return item;
    }    void stop() {
        { lock_guard lock(mtx_); stopped_ = true; }
        cv_.notify_all();                      // 唤醒所有消费者
    }
};

async_logger 包装

 复制代码class async_logger {
    shared_ptr backend_;        // 同步 logger(做格式化)
    blocking_queue q_;       // 消息队列
    vector workers_;            // 后台线程池    void log(level lvl, const string& payload) {
        // 第一步:格式化(前端线程做,快)
        log_msg msg = ...;
        string formatted;
        backend_->format(msg, formatted);        // 第二步:丢队列(前端线程的活干完了)
        async_msg amsg{lvl, move(formatted)};
        q_.enqueue(move(amsg));
    }    // 后台线程:出队 → 写 sink(慢,不在业务线程上)
    void run_() {
        while (true) {
            auto msg = q_.dequeue();
            if (!msg) break;
            backend_->log_formatted(msg->lvl, msg->formatted);
        }
    }    ~async_logger() {
        q_.stop();          // 停止队列
        for (auto& t : workers_) t.join();  // 等所有 worker 刷盘
    }
};

析构时 q_.stop() → 所有 worker 被唤醒 → 把队列里的还没处理完的消息刷盘 → join() 等所有线程退出。优雅关闭,不会丢日志。


第六层:registry.h — 全局管理

照着 spdlog 的 registry 写了一个单例注册表:

 复制代码class registry {
public:
    static registry& instance() {
        static registry r;            // C++11 保证线程安全
        return r;
    }    void register_logger(shared_ptr log) {
        lock_guard lock(mtx_);
        loggers_[log->name()] = move(log);
    }    shared_ptr get(const string& name) {
        lock_guard lock(mtx_);
        auto it = loggers_.find(name);
        return it != loggers_.end() ? it->second : nullptr;
    }
};

macros.h — 级别过滤宏

 复制代码#define LOG_TRACE(logger, msg) if ((logger)->should_log(level::trace)) (logger)->trace(msg)
#define LOG_DEBUG(logger, msg) if ((logger)->should_log(level::debug)) (logger)->debug(msg)
#define LOG_INFO(logger, msg)  if ((logger)->should_log(level::info))  (logger)->info(msg)
#define LOG_WARN(logger, msg)  if ((logger)->should_log(level::warn))  (logger)->warn(msg)
#define LOG_ERROR(logger, msg) if ((logger)->should_log(level::err))   (logger)->error(msg)
#define LOG_CRIT(logger, msg)  if ((logger)->should_log(level::crit))  (logger)->crit(msg)

if 在前面——如果级别没达标,连 payload 的字符串拼接都不执行。


Benchmark

 复制代码// 同步单线程
auto log = make_shared("bench", make_shared("bench.log"));
log->set_level(level::info);
for (int i = 0; i < 100000; i++) LOG_INFO(log, "bench message");
 复制代码同步单线程 100K 条: 57ms → 1,754,385 msg/s
同步 4 线程  100K 条:201ms →   497,512 msg/s
异步单线程 100K 条:250ms →   400,000 msg/s
异步 4 线程  100K 条:1470ms →  68,027 msg/s

同步单线程 175 万/秒——基本是 std::string 拼接 + 内存拷贝的上限。异步 4 线程下的锁竞争是下一步优化点(ring buffer / 无锁队列)。


跟 muduo 合体

把这个 logger 跟另一个从零写的 Reactor 网络库串了起来,写了一个多人聊天室:

 复制代码auto console = make_shared();
auto file    = make_shared("chat.log");
auto log     = make_shared("chat", console);
log->add_sink(file);EventLoop loop;
TcpServer server(&loop, 8080);
server.setThreadNum(4);
vector clients;server.setConnectionCallback([&](TcpConnection* conn) {
    clients.push_back(conn);
    LOG_INFO(log, "新客户端连接,在线: " + to_string(clients.size()));
});server.setMessageCallback([&](TcpConnection* conn, const char* data, size_t len) {
    string msg(data, len);
    LOG_INFO(log, "收到: " + msg);
    for (auto* c : clients)
        if (c != conn) c->send(msg + "rn");     // 广播
});server.start();
loop.loop();

多个终端 telnet 到 8080,打字互发消息。chat.log 同步记录每一条:

 复制代码[22:56:52] 聊天服务器启动,端口 8080...
[22:57:03] 新客户端连接,在线:1
[22:57:31] 收到:hello from client 1
[22:57:36] 收到:hello from client 2

两个自己造的轮子第一次协同工作,那种感觉很难描述。以前写出来的东西都是用别人的库——这是第一次自己的两个库在同一个进程里配合,"我写的库能给我的另一个库当基础设施"。


踩过的坑

  1. localtime 不是线程安全的。 C 标准库返回内部缓冲区指针,多线程同时调会互相覆盖。必须用 localtime_r(Linux) 或 localtime_s(Windows),或者自己加锁。
  2. pattern 编译和格式化要分离。 最初在 format() 里边解析 %flag 边拼字符串——每条日志都重新扫描模式串。改成"构造函数编译、运行时遍历对象数组"之后,格式化从 O(n) 字符串扫描变成了 O(n) 数组遍历——常数差距很大。
  3. lock_guard vs unique_lock。 lock_guard 轻量但不可解锁,只用于简单的保护块。unique_lock 可以手动 unlock + relock,配合 condition_variable::wait 必须用它。
  4. 异步析构的顺序不能错。 async_logger 的析构里必须先 q_.stop() 唤醒所有 worker,再 join()。如果反过来——join 之前没 stop——worker 永远阻塞在 dequeue() 上,整个程序 hang 住。

计划

接下来还会更新:

  • color_stdout_sink(按级别着色:error 红、warn 黄)
  • daily_rolling_sink(按天切文件,不是按大小)
  • flush_on_crash(SIGSEGV 时自动刷盘)
  • rate limiter(相同日志 N 秒内只输出一次)
  • benchmark vs spdlog(性能对比表格)
本站声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系bd@zhengruan.com
作者最新文章
编程开发
相关文章 更多
codex安装windows教程桌面版完整安装步骤指南
codex安装windows教程桌面版完整安装步骤指南

详解如何在 Windows 电脑上通过 Microsoft Store 安装 Codex 桌面应用,包括启动登录、切换 Codex 模式、关联本地代码项目及终端环境配置的界面操作指引。

github配置ssh key windows
github配置ssh key windows

本文详解如何在Windows环境中使用PowerShell生成SSH密钥对,配置ssh-agent自动加载私钥,并将公钥安全添加至GitHub账户,最后通过git remote set-url切换为SSH地址,解决每次操作需输入账号密码的问题。

backdrop和background区别
backdrop和background区别

深入解析backdrop与background在网页开发及视觉设计中的区别。通过CSS代码示例,展示如何正确使用background设置元素背景,以及利用backdrop-filter创建弹窗遮罩模糊效果,避免概念混用。

windsurf ide download Windows版安装教程
windsurf ide download Windows版安装教程

详解 Windsurf IDE 在 Windows 系统的下载来源、安装步骤及首次启动界面。指导用户如何导入编辑器设置、打开项目文件夹及使用终端与 AI 功能,适合初次接触该工具的开发者阅读。

ServBay安装配置详细教程与操作指南
ServBay安装配置详细教程与操作指南

新手入门 ServBay 本地开发环境,详解安装包下载、Dashboard 状态监控、Packages 组件安装、Services 服务控制及 Websites 项目配置。掌握 .servbay.config 版本管理与日志排查技巧,快速搭建稳定的 PHP、Node.js 等多语言开发环境。

codekit环境配置指南从安装到环境搭建完整教程
codekit环境配置指南从安装到环境搭建完整教程

详解 CodeKit 在 macOS 下的安装步骤、项目导入方法、Sass与JavaScript编译设置及浏览器自动刷新功能,助您快速搭建高效的前端开发环境。

codex安装windows 命令行完整操作教程
codex安装windows 命令行完整操作教程

详解Windows环境下安装OpenAI Codex CLI的步骤,包括WSL环境检查、Node.js/npm配置、npm全局安装命令及首次启动验证,适合开发者快速上手。

NativeRest环境配置要求与完整操作教程
NativeRest环境配置要求与完整操作教程

学习如何配置 NativeRest REST API 客户端。涵盖 Windows/macOS/Linux 安装后的工作区创建、环境变量管理、请求编辑及响应查看步骤,帮助开发者快速完成基础环境搭建与连通性测试。

CSS设置透明度的注意事项有哪些?opacity属性详解
CSS设置透明度的注意事项有哪些?opacity属性详解

深入解析CSS中设置透明度的核心属性opacity,剖析子元素继承、事件穿透、层叠上下文等关键注意事项,并提供与rgba、hsla的实用选型对比。

flutter页面传值到后台的方法及示例代码
flutter页面传值到后台的方法及示例代码

flutter页面传值到后台的完整实现方法及示例代码,帮助读者快速掌握相关技术要点。

查看更多
精品专题 更多
装机必备
装机必备

正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

Windows
Windows

正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

PDF教程
PDF教程

正软商城PDF教程频道提供PDF编辑、转换、合并、拆分、压缩及格式处理方法,同时介绍常用PDF软件和工具的使用技巧。

Mac软件 更多
WPS Office macOS版
WPS Office macOS版
macOS

WPS Office是一款面向macOS用户的综合办公套件,将文字处理、电子表格、演示文稿、PDF工具、模板与云协作整合在同一应用中。它兼容常见Microsoft Office格式,支持Intel及Apple芯片Mac,基础功能可免费使用,

Shapr3D macOS版
Shapr3D macOS版
Mac

Shapr3D是一款面向工业设计、机械工程、建筑概念和三维打印工作流的CAD软件。Mac版采用Parasolid建模内核,支持草图约束、实体建模、工程图、可视化渲染及常见CAD格式交换,并可通过账户在多台设备之间同步项目。

REAPER macOS版
REAPER macOS版
Mac

REAPER是Cockos开发的数字音频工作站,提供多轨音频与MIDI录制、剪辑、处理、混音和母带制作工具。Mac版兼容Intel与Apple芯片,支持AU、VST、VST3、CLAP等插件格式,并提供高度可定制的工作流程。

WINDOWS 更多
Adobe InDesign Windows版
Adobe InDesign Windows版
Windows

Adobe InDesign 是面向印刷与数字出版的专业页面排版工具,可制作书籍、杂志、宣传册、海报、交互式 PDF 和电子书。它提供精细的文字样式、多页面管理、印前检查及多格式输出能力,并能与 Photoshop、Illustrator

Adobe Illustrator Windows版
Adobe Illustrator Windows版
Windows

Adobe Illustrator 是用于绘制矢量图形、插画、图标、徽标和版式的专业设计工具。它以路径和锚点为核心,作品放大后仍能保持清晰,并提供画板、文字、渐变、图像描摹及精确变换等功能,适合品牌视觉、印刷物料、数字界面和商业插画创作。

Adobe Bridge Windows版
Adobe Bridge Windows版
Windows

Adobe Bridge 是 Adobe 推出的创意资产管理工具,可集中预览、分类和查找照片、视频、PDF及多种设计文件,并支持元数据、关键词、评级、批量重命名和格式导出。它可与 Photoshop、Illustrator 等应用衔接,