欢迎光临
我们一直在努力

Horse3D 游戏引擎研发笔记(七):Clydesdale——从流式日志到多输出订阅

Horse3D 游戏引擎研发笔记(七):Clydesdale——从流式日志到多输出订阅

  • Bilibili 同步视频
  • 一、为什么日志需要单独成模块
  • 二、整体架构
  • 三、入口层:为什么 Clydesdale.h 里不用宏
    • 3.1 自然的写法:宏
    • 3.2 踩坑:`#define error()` 与 Qt 虚函数冲突
    • 3.3 解法:命名空间内联函数 + using 声明
    • 3.4 命名约定
  • 四、流式写入器:HorseLogStream
    • 4.1 类结构
    • 4.2 RAII 提交
    • 4.3 格式修饰符
    • 4.4 通用模板:让 Qt 数学类型直接流式输出
    • 4.5 移动语义与拷贝禁用
  • 五、Logger:静态管理器
    • 5.1 线程安全
    • 5.2 默认输出
    • 5.3 传统 API 与流式 API 的关系
  • 六、输出层:ILogOutput 与两种实现
    • 6.1 抽象接口
    • 6.2 ConsoleLogOutput:转发 Qt
    • 6.3 FileLogOutput:带时间戳和级别
    • 6.4 自定义输出:GUI 控制台面板
  • 七、Hequ:Mongolian 下的轻量替代
  • 八、CMake 与部署
  • 九、设计取舍
  • 十、当前成果
  • 十一、下一步
  • 项目仓库

目标:拆解 Horse3D 的日志子系统 Clydesdale。从一个 info() << "…" 调用出发,梳理 RAII 流式写入器、Logger 静态管理器、ILogOutput 输出抽象、ConsoleLogOutput 与 FileLogOutput 两种实现,并交代清楚"为什么 Clydesdale.h 里故意不使用 #define 宏"这一踩坑点。最后顺带说明 Mongolian/Hequ 模块作为轻量替代的定位。

Bilibili 同步视频

Horse3D 游戏引擎研发笔记(七):Clydesdale——从流式日志到多输出订阅

一、为什么日志需要单独成模块

前六篇笔记依次建起了渲染线程、材质系统、多 Pass 管线、光照、编辑器外壳和组件接口。随着模块变多,一个很现实的问题浮现出来:每个模块出问题时,靠什么定位?

qDebug 当然能用,但它有几个明显短板:

  • 没有级别抽象——qDebug / qInfo / qWarning / qCritical 是四个独立函数,业务代码直接耦合到 Qt API。
  • 没有输出分发——想同时打到控制台和文件,要在每个调用点手动写两份逻辑。
  • 没有统一格式——时间戳、级别前缀、文件名都靠自己拼字符串。
  • 难以后期扩展——未来想加一个"把 Error 推到 GUI 控制台面板"的输出,得满项目改代码。
  • Horse3D 把日志抽到独立的 Clydesdale 模块(位于 Baggage/Clydesdale/),用一套很小的接口解决上述问题。模块名沿用项目"以马种命名"的约定——Clydesdale(克莱兹代尔马)是挽马,承担"拖运日志"的职责再合适不过。

    二、整体架构

    Clydesdale 的内部结构可以看作三层:入口层 → 管理层 → 输出层。入口层提供 debug()/info()/warning()/error() 四个便捷函数,管理层负责维护输出列表和线程安全,输出层定义抽象接口和具体实现。

    #mermaid-svg-d54Hbq8xgt9Q9Mii{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-d54Hbq8xgt9Q9Mii .error-icon{fill:#552222;}#mermaid-svg-d54Hbq8xgt9Q9Mii .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-d54Hbq8xgt9Q9Mii .marker{fill:#333333;stroke:#333333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .marker.cross{stroke:#333333;}#mermaid-svg-d54Hbq8xgt9Q9Mii svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-d54Hbq8xgt9Q9Mii p{margin:0;}#mermaid-svg-d54Hbq8xgt9Q9Mii .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster-label text{fill:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster-label span{color:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster-label span p{background-color:transparent;}#mermaid-svg-d54Hbq8xgt9Q9Mii .label text,#mermaid-svg-d54Hbq8xgt9Q9Mii span{fill:#333;color:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .node rect,#mermaid-svg-d54Hbq8xgt9Q9Mii .node circle,#mermaid-svg-d54Hbq8xgt9Q9Mii .node ellipse,#mermaid-svg-d54Hbq8xgt9Q9Mii .node polygon,#mermaid-svg-d54Hbq8xgt9Q9Mii .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .rough-node .label text,#mermaid-svg-d54Hbq8xgt9Q9Mii .node .label text,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape .label,#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape .label{text-anchor:middle;}#mermaid-svg-d54Hbq8xgt9Q9Mii .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .rough-node .label,#mermaid-svg-d54Hbq8xgt9Q9Mii .node .label,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape .label,#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape .label{text-align:center;}#mermaid-svg-d54Hbq8xgt9Q9Mii .node.clickable{cursor:pointer;}#mermaid-svg-d54Hbq8xgt9Q9Mii .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .arrowheadPath{fill:#333333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-d54Hbq8xgt9Q9Mii .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-d54Hbq8xgt9Q9Mii .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster text{fill:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster span{color:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-d54Hbq8xgt9Q9Mii .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii rect.text{fill:none;stroke-width:0;}#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape p,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape .label rect,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-d54Hbq8xgt9Q9Mii .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-d54Hbq8xgt9Q9Mii .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-d54Hbq8xgt9Q9Mii :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    输出层

    流式写入器

    管理层(Logger)

    入口层(Clydesdale.h)

    析构时提交

    debug()

    info()

    warning()

    error()

    Logger 静态单例

    logDebug() / logInfo()logWarning() / logError()返回 HorseLogStream

    debug(msg) / info(msg) …传统字符串 API

    HorseLogStreamRAII 临时对象operator<< 累积

    ILogOutput 抽象接口

    ConsoleLogOutput转发 qDebug 系列

    FileLogOutput带时间戳与级别

    自定义输出例如 GUI 控制台面板

    图 1:Clydesdale 三层架构。 入口层返回 RAII 流对象,流对象析构时把完整消息交给 Logger,Logger 遍历所有 ILogOutput 完成分发。传统字符串 API 绕过流对象,直接走 Logger::write。

    模块依赖关系非常轻:Clydesdale 只链接 Qt6::Core,被 Baggage 聚合后供所有上层模块使用。

    #mermaid-svg-ctG99vtPCxmfKW2D{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ctG99vtPCxmfKW2D .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ctG99vtPCxmfKW2D .error-icon{fill:#552222;}#mermaid-svg-ctG99vtPCxmfKW2D .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ctG99vtPCxmfKW2D .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ctG99vtPCxmfKW2D .marker.cross{stroke:#333333;}#mermaid-svg-ctG99vtPCxmfKW2D svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ctG99vtPCxmfKW2D p{margin:0;}#mermaid-svg-ctG99vtPCxmfKW2D .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster-label text{fill:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster-label span{color:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster-label span p{background-color:transparent;}#mermaid-svg-ctG99vtPCxmfKW2D .label text,#mermaid-svg-ctG99vtPCxmfKW2D span{fill:#333;color:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .node rect,#mermaid-svg-ctG99vtPCxmfKW2D .node circle,#mermaid-svg-ctG99vtPCxmfKW2D .node ellipse,#mermaid-svg-ctG99vtPCxmfKW2D .node polygon,#mermaid-svg-ctG99vtPCxmfKW2D .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ctG99vtPCxmfKW2D .rough-node .label text,#mermaid-svg-ctG99vtPCxmfKW2D .node .label text,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape .label,#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape .label{text-anchor:middle;}#mermaid-svg-ctG99vtPCxmfKW2D .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ctG99vtPCxmfKW2D .rough-node .label,#mermaid-svg-ctG99vtPCxmfKW2D .node .label,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape .label,#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape .label{text-align:center;}#mermaid-svg-ctG99vtPCxmfKW2D .node.clickable{cursor:pointer;}#mermaid-svg-ctG99vtPCxmfKW2D .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ctG99vtPCxmfKW2D .arrowheadPath{fill:#333333;}#mermaid-svg-ctG99vtPCxmfKW2D .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ctG99vtPCxmfKW2D .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ctG99vtPCxmfKW2D .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ctG99vtPCxmfKW2D .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ctG99vtPCxmfKW2D .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ctG99vtPCxmfKW2D .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ctG99vtPCxmfKW2D .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster text{fill:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster span{color:#333;}#mermaid-svg-ctG99vtPCxmfKW2D div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ctG99vtPCxmfKW2D .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ctG99vtPCxmfKW2D rect.text{fill:none;stroke-width:0;}#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape p,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape .label rect,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ctG99vtPCxmfKW2D .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ctG99vtPCxmfKW2D .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ctG99vtPCxmfKW2D :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    Mongolian(可选)

    Ferghana(编辑器)

    Sinohorse(引擎核心)

    Baggage(基础库聚合)

    仅依赖 Qt6::Core

    独立实现不依赖 Clydesdale

    Clydesdale

    Diligencier / Percheron / Mustang

    Dragon

    Balikun

    FerghanaApplication

    FerghanaEditor

    Hequ轻量日志替代

    Qt

    图 2:模块依赖关系。 Clydesdale 位于最底层,被引擎核心和编辑器同时使用。Hequ 作为可选模块独立存在,不依赖 Clydesdale,定位见第七节。

    三、入口层:为什么 Clydesdale.h 里不用宏

    入口层只有四个函数,写在一个不到 40 行的头文件 [Clydesdale.h](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/Clydesdale.h) 里:

    namespace Horse {

    inline HorseLogStream debug() { return Logger::logDebug(); }
    inline HorseLogStream info() { return Logger::logInfo(); }
    inline HorseLogStream warning() { return Logger::logWarning(); }
    inline HorseLogStream error() { return Logger::logError(); }

    } // namespace Horse

    using Horse::debug;
    using Horse::info;
    using Horse::warning;
    using Horse::error;

    看似平淡无奇,但这个文件的设计决策值得专门一节来讨论。

    3.1 自然的写法:宏

    最直觉的实现是参考 qDebug() 的宏方案:

    #define debug() HorseLogStream(LogLevel::Debug)
    #define info() HorseLogStream(LogLevel::Info)
    // …

    调用方代码完全一致:info() << "…"。但这个方案在 Horse3D 里翻过一次车。

    3.2 踩坑:#define error() 与 Qt 虚函数冲突

    Qt 基类 QIODevice / QFileDevice 拥有一个名为 error() 的虚函数:

    // QFileDevice 的真实声明
    virtual QFileDevice::FileError error() const;

    一旦某段代码先 #include "Clydesdale.h",再 #include <QFile>,预处理器会把 QFileDevice::error() 的声明替换成:

    virtual QFileDevice::FileError HorseLogStream(LogLevel::Error)() const;

    MSVC 立刻报 C3254:“类包含显式重写,但并非继承自接口”。这个错误的可怕之处在于:报错位置远离真正的元凶(Clydesdale.h),而是在任何包含 QFile/QIODevice 的 Qt 头文件处。

    3.3 解法:命名空间内联函数 + using 声明

    把宏改成 Horse 命名空间内的 inline 函数,再用 using 把它们暴露到全局:

    namespace Horse {
    inline HorseLogStream error() { return Logger::logError(); }
    }

    using Horse::error;

    这样 error() 仍然是一个真正的标识符(不是预处理符号),不会污染 Qt 头文件。调用方代码 error() << "…" 完全不变,但编译期类型检查正常工作。

    这个改动也呼应了 [Clydesdale.h](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/Clydesdale.h) 文件顶部的注释——“故意不使用 #define 宏”,并把原因写在注释里,避免后续维护者"优化"回宏方案。

    3.4 命名约定

    根据项目约束(详见 [project_memory.md](file:///c:/Users/Administrator/.trae-cn/memory/projects/-d-WorkSpace-softwarer-horse–p2-9cf0d952531cc0ea9a56/project_memory.md)):

    • 日志命名空间必须是 Horse,不是 Clydesdale。
    • 宏名必须是 debug/info/warning/error,不加 horse 前缀——horseInfo() 这种写法被否决,因为它破坏了与 Qt 原生 API 的一致性。
    • 宏定义位置必须是 Clydesdale.h,且该头文件所在 CMake target 的 target_include_directories 必须为 PUBLIC,否则消费方 include 找不到。

    四、流式写入器:HorseLogStream

    HorseLogStream 是 Clydesdale 最有特色的部分。它参考 Qt 的 QDebug,用 RAII 临时对象实现"流式累积 + 析构提交"。

    4.1 类结构

    #mermaid-svg-QWowVLSMZVZHobuU{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QWowVLSMZVZHobuU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QWowVLSMZVZHobuU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QWowVLSMZVZHobuU .error-icon{fill:#552222;}#mermaid-svg-QWowVLSMZVZHobuU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QWowVLSMZVZHobuU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QWowVLSMZVZHobuU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QWowVLSMZVZHobuU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QWowVLSMZVZHobuU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QWowVLSMZVZHobuU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QWowVLSMZVZHobuU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QWowVLSMZVZHobuU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QWowVLSMZVZHobuU .marker.cross{stroke:#333333;}#mermaid-svg-QWowVLSMZVZHobuU svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QWowVLSMZVZHobuU p{margin:0;}#mermaid-svg-QWowVLSMZVZHobuU g.classGroup text{fill:#9370DB;stroke:none;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-QWowVLSMZVZHobuU g.classGroup text .title{font-weight:bolder;}#mermaid-svg-QWowVLSMZVZHobuU .cluster-label text{fill:#333;}#mermaid-svg-QWowVLSMZVZHobuU .cluster-label span{color:#333;}#mermaid-svg-QWowVLSMZVZHobuU .cluster-label span p{background-color:transparent;}#mermaid-svg-QWowVLSMZVZHobuU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QWowVLSMZVZHobuU .cluster text{fill:#333;}#mermaid-svg-QWowVLSMZVZHobuU .cluster span{color:#333;}#mermaid-svg-QWowVLSMZVZHobuU .nodeLabel,#mermaid-svg-QWowVLSMZVZHobuU .edgeLabel{color:#131300;}#mermaid-svg-QWowVLSMZVZHobuU .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-QWowVLSMZVZHobuU .label text{fill:#131300;}#mermaid-svg-QWowVLSMZVZHobuU .labelBkg{background:#ECECFF;}#mermaid-svg-QWowVLSMZVZHobuU .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-QWowVLSMZVZHobuU .classTitle{font-weight:bolder;}#mermaid-svg-QWowVLSMZVZHobuU .node rect,#mermaid-svg-QWowVLSMZVZHobuU .node circle,#mermaid-svg-QWowVLSMZVZHobuU .node ellipse,#mermaid-svg-QWowVLSMZVZHobuU .node polygon,#mermaid-svg-QWowVLSMZVZHobuU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QWowVLSMZVZHobuU .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU g.clickable{cursor:pointer;}#mermaid-svg-QWowVLSMZVZHobuU g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-QWowVLSMZVZHobuU g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-QWowVLSMZVZHobuU .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-QWowVLSMZVZHobuU .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-QWowVLSMZVZHobuU .dashed-line{stroke-dasharray:3;}#mermaid-svg-QWowVLSMZVZHobuU .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-QWowVLSMZVZHobuU #compositionStart,#mermaid-svg-QWowVLSMZVZHobuU .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #compositionEnd,#mermaid-svg-QWowVLSMZVZHobuU .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #dependencyStart,#mermaid-svg-QWowVLSMZVZHobuU .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #dependencyStart,#mermaid-svg-QWowVLSMZVZHobuU .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #extensionStart,#mermaid-svg-QWowVLSMZVZHobuU .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #extensionEnd,#mermaid-svg-QWowVLSMZVZHobuU .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #aggregationStart,#mermaid-svg-QWowVLSMZVZHobuU .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #aggregationEnd,#mermaid-svg-QWowVLSMZVZHobuU .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #lollipopStart,#mermaid-svg-QWowVLSMZVZHobuU .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #lollipopEnd,#mermaid-svg-QWowVLSMZVZHobuU .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-QWowVLSMZVZHobuU .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QWowVLSMZVZHobuU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QWowVLSMZVZHobuU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QWowVLSMZVZHobuU :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    owns

    HorseLogStream

    -m_stream : Stream

    +HorseLogStream(level)

    +~HorseLogStream()

    +operator=(HorseLogStream&&)

    +noquote() : HorseLogStream&

    +quote() : HorseLogStream&

    +nospace() : HorseLogStream&

    +space() : HorseLogStream&

    +operator<<(QString) : HorseLogStream&

    +operator<<(int) : HorseLogStream&

    +operator<<(double) : HorseLogStream&

    +operator<<(const void*) : HorseLogStream&

    +operator<<(const T&) : HorseLogStream&

    -maybeSpace()

    Stream

    +buffer : QString

    +ts : QTextStream

    +level : LogLevel

    +space : bool

    +quote : bool

    4.2 RAII 提交

    构造时分配一个内部 Stream(持有 QString 缓冲和 QTextStream),析构时把缓冲交给 Logger::write():

    HorseLogStream::~HorseLogStream()
    {
    if (!m_stream)
    return;

    m_stream->ts.flush();

    // 末尾如果多了一个空格,砍掉,避免 "msg " 这种尾巴。
    if (m_stream->space && m_stream->buffer.endsWith(QLatin1Char(' ')))
    m_stream->buffer.chop(1);

    Logger::write(m_stream->level, m_stream->buffer);
    delete m_stream;
    }

    这带来一个非常优雅的特性:调用方完全不需要显式 flush 或 commit。整个日志调用就是一条语句:

    info() << "cnt" << cnt << "update" << elapsedMs << "ms";

    语句结束时临时对象析构,消息自动提交。

    4.3 格式修饰符

    参考 QDebug,提供四个链式修饰符:

    修饰符作用默认值
    noquote() 字符串不加引号 开(不加引号)
    quote() 字符串加引号
    nospace() 关闭自动空格插入
    space() 写入一个空格并重开自动空格 开(自动空格)

    默认配置刻意与 QDebug 默认值不同:QDebug 默认加引号、加空格,而 HorseLogStream 默认 不加引号、加空格。原因是在日志场景下,字符串内容通常是路径、变量名、错误描述,加引号反而降低可读性。

    实战例子(来自 [FerghanaApplication.cpp](file:///d:/WorkSpace/softwarer-horse/Ferghana/Ferghana/FerghanaApplication.cpp) 的国际化加载逻辑):

    Horse::info() << "i18n" << prefix
    << "loaded from fallback:" << fallbackDir << "/" << filename;

    Horse::warning() << "i18n" << prefix << "translation NOT FOUND for" << localeName
    << "(tried:" << primaryDir << "and" << fallbackDir << ")";

    Horse::error() << "i18n" << prefix << "installTranslator FAILED";

    输出形如:

    i18n app loaded from fallback: ./translations/ Ferghana_zh_CN.qm
    i18n app translation NOT FOUND for zh_CN (tried: ./i18n and ./translations)
    i18n app installTranslator FAILED

    4.4 通用模板:让 Qt 数学类型直接流式输出

    除了为常见类型(QString、int、double、const void* 等)显式重载 operator<<,HorseLogStream 还提供了一个模板重载:

    template<typename T>
    HorseLogStream &operator<<(const T &value)
    {
    if (m_stream)
    m_stream->ts << value;
    maybeSpace();
    return *this;
    }

    只要消费方链接了 QtGui,QVector2D/QVector3D/QVector4D/QMatrix4x4/QQuaternion 等 Qt 类型就能直接流式输出。这对渲染引擎调试至关重要——光位、相机位置、变换矩阵都能一行打出来。

    4.5 移动语义与拷贝禁用

    HorseLogStream 显式删除拷贝构造和拷贝赋值,只保留移动版本:

    HorseLogStream(HorseLogStream &&other) noexcept;
    HorseLogStream &operator=(HorseLogStream &&other) noexcept;

    HorseLogStream(const HorseLogStream &) = delete;
    HorseLogStream &operator=(const HorseLogStream &) = delete;

    原因是 RAII 提交依赖"唯一拥有 Stream 指针"的不变量。如果允许拷贝,两个对象析构时都会尝试 delete m_stream,触发 double-free。移动赋值甚至要在覆盖旧值前先把旧 Stream 提交并删除,避免漏日志:

    HorseLogStream &HorseLogStream::operator=(HorseLogStream &&other) noexcept
    {
    if (this != &other) {
    if (m_stream) {
    m_stream->ts.flush();
    Logger::write(m_stream->level, m_stream->buffer);
    delete m_stream;
    }
    m_stream = other.m_stream;
    other.m_stream = nullptr;
    }
    return *this;
    }

    info() 等入口函数返回的就是右值临时对象,调用链上的 << 都在临时对象上完成,最后由临时对象析构提交。整个生命周期没有拷贝。

    五、Logger:静态管理器

    Logger 是一个纯静态类,承担两个职责:维护输出列表和分发消息。

    class CLYDESDALE_EXPORT Logger final
    {
    public:
    using Output = std::shared_ptr<ILogOutput>;

    static void setOutputs(std::vector<Output> outputs);
    static void addOutput(Output output);
    static void clearOutputs();

    static void write(LogLevel level, const QString &message);

    // 传统字符串 API
    static void debug(const QString &message);
    static void info(const QString &message);
    static void warning(const QString &message);
    static void error(const QString &message);

    // 流式 API(返回 RAII 写入器)
    static HorseLogStream logDebug();
    static HorseLogStream logInfo();
    static HorseLogStream logWarning();
    static HorseLogStream logError();
    };

    5.1 线程安全

    输出列表用 std::mutex 保护。write() 在加锁期间拷贝一份 outputs,锁外遍历分发,避免某个慢速 ILogOutput 阻塞其他线程的 addOutput 调用:

    void Logger::write(LogLevel level, const QString &message)
    {
    std::vector<Output> outputs;
    {
    std::lock_guard<std::mutex> lock(loggerMutex);
    outputs = loggerOutputs; // 拷贝
    }

    for (const Output &output : outputs)
    output->write(level, message);
    }

    这里有一个工程细节值得注意:拷贝的是 shared_ptr,引用计数自增是原子的,因此即使其他线程同时销毁某个 ILogOutput,本线程持有的 shared_ptr 仍然有效。

    5.2 默认输出

    Logger 在匿名命名空间里持有一个默认输出列表,初始化时就挂了一个 ConsoleLogOutput:

    namespace {
    std::mutex loggerMutex;
    std::vector<Logger::Output> loggerOutputs{
    std::make_shared<ConsoleLogOutput>()
    };
    } // namespace

    这意味着即使什么配置都不做,Clydesdale 也会把日志打到 qDebug 系列通道,开发阶段无需任何初始化代码就能用。

    5.3 传统 API 与流式 API 的关系

    传统 API Logger::info(const QString&) 是流式 API 的语法糖。两者最终都走 Logger::write(),区别只是前者跳过了 HorseLogStream 临时对象:

    void Logger::info(const QString &message)
    {
    write(LogLevel::Info, message);
    }

    HorseLogStream Logger::logInfo()
    {
    return HorseLogStream(LogLevel::Info);
    }

    项目内部两种风格并存:

    • 流式:参数较多、需要拼接变量时,例如 info() << "FPS" << fps << "drawCalls" << count。
    • 传统:消息本身就是完整字符串(如 tr("Ferghana editor ready")),用 Logger::info(tr(…)) 更简洁。

    实战例子(来自 [FerghanaEditor.cpp](file:///d:/WorkSpace/softwarer-horse/Ferghana/Ferghana/FerghanaEditor.cpp)):

    Horse::Logger::info(tr("Ferghana editor ready"));
    Horse::Logger::info(tr("Run started"));

    六、输出层:ILogOutput 与两种实现

    6.1 抽象接口

    输出层只定义一个极简接口 [ILogOutput](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/ILogOutput.h):

    enum class LogLevel {
    Debug,
    Info,
    Warning,
    Error
    };

    class CLYDESDALE_EXPORT ILogOutput
    {
    public:
    virtual ~ILogOutput() = default;
    virtual void write(LogLevel level, const QString &message) = 0;
    };

    只有一个虚函数。任何想接收日志的目标——控制台、文件、网络、GUI 面板——都只需实现这个接口并向 Logger 注册。

    6.2 ConsoleLogOutput:转发 Qt

    [ConsoleLogOutput](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/ConsoleLogOutput.cpp) 直接转发到 Qt 的 qDebug/qInfo/qWarning/qCritical,并显式 .noquote():

    void ConsoleLogOutput::write(LogLevel level, const QString &message)
    {
    switch (level) {
    case LogLevel::Debug: qDebug().noquote() << message; break;
    case LogLevel::Info: qInfo().noquote() << message; break;
    case LogLevel::Warning: qWarning().noquote() << message; break;
    case LogLevel::Error: qCritical().noquote() << message; break;
    }
    }

    这样日志会自动走 Qt 的 qSetMessagePattern 和消息处理器,与 qt.network、qt.gui 等模块的原生日志格式一致,方便用统一工具收集。

    6.3 FileLogOutput:带时间戳和级别

    [FileLogOutput](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/FileLogOutput.cpp) 把日志写到文件,每行加 ISO 时间戳和级别前缀:

    const QString line = QStringLiteral("%1 [%2] %3\\n")
    .arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs),
    QString::fromLatin1(levelName(level)),
    message);
    file.write(line.toUtf8());

    输出形如:

    2026-08-22T14:32:05.123 [INFO] i18n app loaded from fallback: ./translations/ Ferghana_zh_CN.qm
    2026-08-22T14:32:05.124 [WARNING] i18n app translation NOT FOUND for zh_CN

    工程细节上做了三件事:

  • 自动建目录——QDir::mkpath(".") 在路径不存在时创建,避免日志初始化失败。
  • 互斥锁——mutable std::mutex m_mutex 保护文件写入,多线程同时打日志不会撕裂同一行。
  • Append 模式——QIODevice::Append 保证进程重启不覆盖历史日志。
  • 路径空检查——空路径会打 qWarning 提示,而不是默默吞掉。
  • 6.4 自定义输出:GUI 控制台面板

    ILogOutput 的扩展性在 Ferghana 编辑器里被用上了。编辑器把控制台面板的 logOutput() 挂到 Logger,让所有日志同时显示在 GUI 上:

    Horse::Logger::addOutput(m_console->logOutput());

    这种"插件式输出"让 GUI 控制台与文件日志、控制台日志完全解耦——ConsolePanel 只需要实现 ILogOutput::write(),把消息追加到 QPlainTextEdit 即可。

    七、Hequ:Mongolian 下的轻量替代

    [Mongolian/Hequ](file:///d:/WorkSpace/softwarer-horse/Mongolian/Hequ/Logger.h) 提供了一个独立的、与 Clydesdale 并存的 Logger:

    namespace Hequ {

    enum class Level { Debug, Info, Warning, Error };

    class HEQU_EXPORT Logger final
    {
    public:
    static void write(Level level, const QString &message);
    static void debug(const QString &message);
    static void info(const QString &message);
    static void warning(const QString &message);
    static void error(const QString &message);
    };

    } // namespace Hequ

    实现极其简单,直接转发到 qDebug/qInfo/qWarning/qCritical,没有流式 API,也没有 ILogOutput 抽象。

    它的存在有两个原因:

  • 教学/对照——Hequ 展示了"最小日志实现"是什么样的,方便对照理解 Clydesdale 抽象的必要性。
  • 可选模块的独立依赖——HORSE3D_BUILD_MONGOLIAN=ON 时构建。某些不想拉入整个 Clydesdale 的小工具(例如未来的命令行小工具)可以只链接 Hequ,得到一个非常薄的日志能力。
  • Hequ 与 Clydesdale 在 API 上不兼容(命名空间不同、Level 枚举不同、没有流式),因此不要在同一模块里混用。需要可扩展日志的项目应直接用 Clydesdale。

    八、CMake 与部署

    Clydesdale 是 SHARED 库,CMake 配置非常简洁:

    add_library(${PROJECT_NAME} SHARED
    clydesdale_global.h
    ILogOutput.h
    HorseLogStream.h
    HorseLogStream.cpp
    ConsoleLogOutput.h
    ConsoleLogOutput.cpp
    FileLogOutput.h
    FileLogOutput.cpp
    Logger.h
    Logger.cpp
    Clydesdale.h
    )
    add_library(Clydesdale::Core ALIAS ${PROJECT_NAME})

    target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17)

    target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

    target_link_libraries(${PROJECT_NAME} PUBLIC Qt${QT_VERSION_MAJOR}::Core)

    target_compile_definitions(${PROJECT_NAME} PRIVATE CLYDESDALE_LIBRARY)

    三个关键点:

  • target_include_directories 必须是 PUBLIC——因为 Clydesdale.h 提供 info() 等内联函数,消费方必须能 include 到该头文件,include 路径随 PUBLIC 传播。
  • target_link_libraries 是 PUBLIC——Clydesdale 内部用 QString/QTextStream,消费方也需要 Qt6::Core。
  • CLYDESDALE_LIBRARY 仅在 Clydesdale 自己编译时定义——用于切换 CLYDESDALE_EXPORT 在导出/导入之间的方向。
  • Clydesdale 被 [Baggage/CMakeLists.txt](file:///d:/WorkSpace/softwarer-horse/Baggage/CMakeLists.txt) 聚合到 Baggage INTERFACE 库,任何链接 Baggage 的模块自动获得 Clydesdale。

    九、设计取舍

    设计点当前方案替代方案选择理由
    入口实现 namespace 内联函数 + using 声明 #define 宏 避免 #define error() 与 Qt QIODevice::error() 虚函数冲突(C3254)
    命名空间 Horse,与引擎其他模块一致 Clydesdale 项目硬约束:所有日志 API 在 Horse 命名空间
    API 风格 流式 + 传统字符串并存 只保留流式 完整字符串消息(如 tr(…))用传统 API 更简洁
    流式实现 RAII 临时对象,析构提交 显式 flush() 调用方零样板代码,语句结束自动提交
    拷贝语义 删除拷贝,仅移动 允许拷贝并共享 Stream 避免 double-free,保证 RAII 提交不变量
    输出抽象 ILogOutput 单虚函数 多接口(write/flush/level) 单函数足够覆盖现有需求,扩展时再加
    输出列表 shared_ptr<ILogOutput> 裸指针 / unique_ptr 支持同一输出被多个 Logger 共享,引用计数自动管理生命周期
    线程安全 锁内拷贝列表,锁外分发 全程持锁 避免慢速 ILogOutput 阻塞 addOutput,代价是极小的拷贝开销
    默认输出 自动挂 ConsoleLogOutput 强制要求显式初始化 开箱即用,开发阶段零配置可用
    格式修饰符 默认 noquote + space 默认 quote + space(同 QDebug) 日志场景下字符串多为路径/变量名,加引号降低可读性
    Hequ 模块 独立轻量实现,不依赖 Clydesdale 复用 Clydesdale 代码 提供最小对照实现,作为可选模块独立部署

    十、当前成果

    • 入口层 Clydesdale.h 用 namespace + using 替代宏,彻底规避 Qt 虚函数冲突(C3254),同时保持 info() << "…" 的调用形态。
    • HorseLogStream 用 RAII 临时对象实现流式累积 + 析构提交,支持 noquote/quote/nospace/space 四个修饰符,模板重载让 Qt 数学类型直接可输出。
    • Logger 静态管理器维护 shared_ptr<ILogOutput> 列表,锁内拷贝、锁外分发,兼顾线程安全和性能。
    • ConsoleLogOutput 转发 Qt 原生日志通道,FileLogOutput 带时间戳和级别,二者均开箱即用。
    • ILogOutput 单虚函数接口支持 GUI 控制台面板等自定义输出,已应用于 Ferghana 编辑器的 ConsolePanel。
    • 传统字符串 API 与流式 API 共存,覆盖"完整消息"和"拼接变量"两类场景。
    • Hequ 作为 Mongolian 下的可选轻量替代,独立于 Clydesdale。

    十一、下一步

    优先级方向目标
    P0 日志过滤 支持 LogLevel 阈值(如 Release 模式只输出 Info 以上)
    P0 日志归档 FileLogOutput 按日期切分,避免单文件无限增长
    P1 结构化日志 支持 JSON 格式输出,便于日志收集系统解析
    P1 异步文件输出 文件写入放到独立线程,避免磁盘 IO 阻塞渲染线程
    P1 上下文信息 自动附加文件名、行号、函数名(参考 Q_LOGGING_CATEGORY)
    P2 日志分类 引入 LogCategory(如 Render、Editor、i18n),按模块过滤
    P2 性能统计 集成到编辑器 Profiler 面板,统计各级别日志频率

    项目仓库

    • Gitee:https://gitee.com/shendeyidi/softwarer-horse

    Horse3D 游戏引擎研发笔记(七):Clydesdale——从流式日志到多输出订阅

    本系列记录 Horse3D 游戏引擎从零开始的研发过程,欢迎交流。

    赞(0)
    未经允许不得转载:171主机测评 » Horse3D 游戏引擎研发笔记(七):Clydesdale——从流式日志到多输出订阅
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址