零基础C++(35) 结课项目一(异步日志系统)

项目描述

异步日志打印系统的设计与实现

本项目旨在设计并实现一个高效的异步日志打印系统,利用C++的现代特性如可变参数模板(Variadic Templates)、多线程编程、以及模板折叠(Template Folding)。该系统允许开发者以友好的格式字符串方式记录日志消息,同时保证日志记录过程不会阻塞主线程的执行。项目适用于希望深入理解并应用C++高级编程技术的学生,尤其是对并发编程和模板编程感兴趣的学习者。

项目目标

  1. 掌握可变参数模板:理解并应用C++的可变参数模板来实现灵活的日志记录接口。
  2. 理解多线程与并发:学习如何在C++中创建和管理多个线程,确保线程安全的日志队列实现。
  3. 应用模板折叠:利用模板折叠技术高效地处理参数包,实现占位符格式化日志消息。
  4. 实现异步日志机制:设计一个后台线程持续处理日志队列,异步写入日志文件,避免主线程阻塞。
  5. 错误处理与资源管理:确保日志系统在异常情况下的稳健性,以及在程序结束时进行优雅的资源释放。

项目需求

功能需求

  1. 日志记录接口
    • 支持带有占位符 {} 的格式化日志记录,例如 log("Hello {}", name);
    • 支持无占位符的日志记录方式,按顺序拼接参数。
    • 支持不同类型的日志参数(如整数、字符串、浮点数等)。
  2. 线程安全的日志队列
    • 实现一个高效的线程安全队列,用于存储待写入的日志消息。
    • 支持多生产者(主线程)和单消费者(后台写入线程)的模式。
  3. 后台写入线程
    • 启动一个独立的后台线程,持续从日志队列中取出日志消息并写入日志文件。
    • 确保在程序退出前,所有日志消息都被正确写入。
  4. 格式化功能
    • 实现占位符 {} 的替换逻辑,将参数按顺序填充到日志消息中。
    • 处理占位符数量与参数数量不匹配的情况:多余的参数按顺序拼接,缺少参数时保留 {}
  5. 错误处理
    • 捕获并处理可能的异常,如文件打开失败、格式化错误等。
    • 确保程序在异常情况下不会崩溃,并提供有意义的错误信息。

非功能需求

  1. 性能
    • 日志记录过程应尽可能高效,避免对主线程造成显著的性能影响。
    • 后台写入线程应能够快速处理日志队列,防止队列过长。
  2. 可扩展性
    • 设计系统时考虑到未来可能的功能扩展,如不同的日志级别(INFO、DEBUG、ERROR)、多目标输出(文件、控制台、网络等)。
  3. 可读性与维护性
    • 代码应遵循清晰的结构和命名规范,便于理解和维护。
    • 提供详细的注释和文档,帮助学生理解每个模块的功能与实现细节。

技术与工具

  • 编程语言:C++17或更高版本
  • 编译器:支持C++17的编译器,如 GCC、Clang 或 MSVC
  • 开发环境:任意集成开发环境(IDE),如 Visual Studio、CLion、或 VS Code
  • 版本控制:Git(推荐,用于代码管理和协作)
  • 其他库(可选):推荐使用 fmtfmt(https://github.com/fmtlib/fmt) 库进行格式化,提升代码的简洁性和性能

项目实施步骤

  1. 项目初始化
    • 创建项目结构,设置开发环境。
    • 初始化Git仓库,记录项目进展。
  2. 设计日志队列
    • 实现线程安全的队列类,支持多线程访问。
    • 使用 std::mutexstd::condition_variable 实现同步机制。
  3. 实现日志接口
    • 设计 Logger 类,提供日志记录的公共接口。
    • 实现可变参数模板的 log 方法,支持格式化日志消息。
  4. 开发后台写入线程
    • Logger 类中启动一个独立的线程,处理日志队列中的消息。
    • 实现消息的文件写入逻辑,确保文件操作的安全与高效。
  5. 实现格式化功能
    • 编写 formatMessage 函数,解析格式字符串并替换 {} 占位符。
    • 处理参数数量与占位符数量不匹配的情况。
  6. 集成与测试
    • 编写测试代码,验证日志系统的各项功能。
    • 测试多线程环境下的日志记录,确保线程安全性。
  7. 优化与扩展
    • 根据测试结果优化性能,减少锁的粒度,提高并发能力。
    • 考虑添加日志级别、时间戳和多目标输出等扩展功能。
  8. 文档撰写
    • 编写详细的项目文档,包含项目背景、设计思路、实现细节和使用指南。
    • 为代码添加注释,提升代码的可读性和可维护性。

完整示例代码

以下是结合上述需求与设计思路的完整代码示例,供大家参考与学习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
#include <queue>
#include <string>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <fstream>
#include <atomic>
#include <sstream>
#include <vector>
#include <stdexcept>

// 辅助函数,将单个参数转换为字符串
template<typename T>
std::string to_string_helper(T&& arg) {
std::ostringstream oss;
oss << std::forward<T>(arg);
return oss.str();
}

// 线程安全的日志队列
class LogQueue {
public:
void push(const std::string& msg) {
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(msg);
cond_var_.notify_one();
}

bool pop(std::string& msg) {
std::unique_lock<std::mutex> lock(mutex_);
while (queue_.empty() && !is_shutdown_) {
cond_var_.wait(lock);
}
if (is_shutdown_ && queue_.empty()) {
return false;
}
msg = queue_.front();
queue_.pop();
return true;
}

void shutdown() {
std::lock_guard<std::mutex> lock(mutex_);
is_shutdown_ = true;
cond_var_.notify_all();
}

private:
std::queue<std::string> queue_;
std::mutex mutex_;
std::condition_variable cond_var_;
bool is_shutdown_ = false;
};

// Logger 类
class Logger {
public:
Logger(const std::string& filename) : log_file_(filename, std::ios::out | std::ios::app), exit_flag_(false) {
if (!log_file_.is_open()) {
throw std::runtime_error("无法打开日志文件");
}
worker_thread_ = std::thread(&Logger::processQueue, this);
}

~Logger() {
log_queue_.shutdown();
if (worker_thread_.joinable()) {
worker_thread_.join();
}
if (log_file_.is_open()) {
log_file_.close();
}
}

// 日志接口:支持带格式字符串的日志
template<typename... Args>
void log(const std::string& format, Args&&... args) {
log_queue_.push(formatMessage(format, std::forward<Args>(args)...));
}

private:
LogQueue log_queue_;
std::thread worker_thread_;
std::ofstream log_file_;
std::atomic<bool> exit_flag_;

void processQueue() {
std::string msg;
while (log_queue_.pop(msg)) {
log_file_ << msg << std::endl;
}
}

// 使用模板折叠格式化日志消息,支持 "{}" 占位符
template<typename... Args>
std::string formatMessage(const std::string& format, Args&&... args) {
std::vector<std::string> arg_strings = { to_string_helper(std::forward<Args>(args))... };
std::ostringstream oss;
size_t arg_index = 0;
size_t pos = 0;
size_t placeholder = format.find("{}", pos);

while (placeholder != std::string::npos) {
oss << format.substr(pos, placeholder - pos);
if (arg_index < arg_strings.size()) {
oss << arg_strings[arg_index++];
} else {
// 没有足够的参数,保留 "{}"
oss << "{}";
}
pos = placeholder + 2; // 跳过 "{}"
placeholder = format.find("{}", pos);
}

// 添加剩余的字符串
oss << format.substr(pos);

// 如果还有剩余的参数,按原方式拼接
while (arg_index < arg_strings.size()) {
oss << arg_strings[arg_index++];
}

return oss.str();
}
};

// 使用示例
int main() {
try {
Logger logger("log.txt");

logger.log("Starting application.");

int user_id = 42;
std::string action = "login";
double duration = 3.5;
std::string world = "World";

logger.log("User {} performed {} in {} seconds.", user_id, action, duration);
logger.log("Hello {}", world);
logger.log("This is a message without placeholders.");
logger.log("Multiple placeholders: {}, {}, {}.", 1, 2, 3);

// 模拟一些延迟以确保后台线程处理完日志
std::this_thread::sleep_for(std::chrono::seconds(1));
}
catch (const std::exception& ex) {
std::cerr << "日志系统初始化失败: " << ex.what() << std::endl;
}

return 0;
}

代码解释

  1. **辅助函数 to_string_helper**:
    • 将任意类型的参数转换为字符串,利用 std::ostringstream 实现。
  2. **线程安全的日志队列 LogQueue**:
    • 使用 std::mutex 保护 std::queue,确保多线程访问的安全性。
    • 使用 std::condition_variable 实现生产者-消费者模式,避免不必要的忙等待。
  3. Logger
    • 构造函数初始化日志文件,并启动后台写入线程。
    • 析构函数确保在程序结束前,所有日志消息被正确写入,并关闭文件和线程资源。
    • log 方法通过可变参数模板接受任意数量和类型的参数,调用 formatMessage 进行格式化后,将日志消息推入队列。
    • processQueue 方法在后台线程中运行,持续从日志队列中取出消息并写入文件。
  4. **格式化函数 formatMessage**:
    • 接受一个格式字符串和参数包。
    • 遍历格式字符串,查找 {} 占位符,并依次替换为参数字符串。
    • 如果占位符多于参数,保留未替换的 {}
    • 如果参数多于占位符,按顺序拼接剩余参数。
  5. main 函数
    • 创建 Logger 实例,指定日志文件名。
    • 演示不同形式的日志记录,包括有占位符和无占位符的调用。
    • 使用 std::this_thread::sleep_for 模拟延迟,确保后台线程有时间处理日志队列。

扩展与优化建议

  1. 集成 fmt

    • 使用成熟的格式化库如 fmtfmt(https://github.com/fmtlib/fmt) 来简化格式化逻辑,提高性能和功能。

    • 示例修改:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      #include <fmt/format.h>

      // 修改 formatMessage 使用 fmt::format
      template<typename... Args>
      std::string formatMessage(const std::string& format, Args&&... args) {
      try {
      return fmt::format(format, std::forward<Args>(args)...);
      }
      catch (const fmt::format_error& e) {
      // 如果格式化失败,回退到简单拼接
      std::vector<std::string> arg_strings = { to_string_helper(std::forward<Args>(args))... };
      std::ostringstream oss;
      size_t arg_index = 0;
      size_t pos = 0;
      size_t placeholder = format.find("{}", pos);

      while (placeholder != std::string::npos) {
      oss << format.substr(pos, placeholder - pos);
      if (arg_index < arg_strings.size()) {
      oss << arg_strings[arg_index++];
      } else {
      oss << "{}";
      }
      pos = placeholder + 2;
      placeholder = format.find("{}", pos);
      }

      oss << format.substr(pos);

      while (arg_index < arg_strings.size()) {
      oss << arg_strings[arg_index++];
      }

      return oss.str();
      }
      }
  2. 增加日志级别

    • 为日志消息添加不同的级别(如 INFODEBUGERROR),并根据配置选择性地记录或过滤日志。

    • 修改日志接口,增加级别参数:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      enum class LogLevel { INFO, DEBUG, ERROR };

      template<typename... Args>
      void log(LogLevel level, const std::string& format, Args&&... args) {
      std::string level_str;
      switch(level) {
      case LogLevel::INFO: level_str = "[INFO] "; break;
      case LogLevel::DEBUG: level_str = "[DEBUG] "; break;
      case LogLevel::ERROR: level_str = "[ERROR] "; break;
      }
      log_queue_.push(level_str + formatMessage(format, std::forward<Args>(args)...));
      }
  3. 添加时间戳

    • 为每条日志消息添加时间戳,记录日志的生成时间。

    • 1
      formatMessage

      中添加时间信息:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      std::string getCurrentTime() {
      auto now = std::chrono::system_clock::now();
      std::time_t now_time = std::chrono::system_clock::to_time_t(now);
      char buffer[100];
      std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", std::localtime(&now_time));
      return std::string(buffer);
      }

      template<typename... Args>
      std::string formatMessage(const std::string& format, Args&&... args) {
      return "[" + getCurrentTime() + "] " + (formatMessage_logic);
      }
  4. 支持多目标输出

    • 除了文件输出外,支持控制台输出、网络日志服务器等多种输出目标。
    • 使用策略模式或观察者模式设计灵活的输出组件。
  5. 日志文件分割

    • 根据日志文件大小或日期进行日志文件的轮转,防止单个日志文件过大。
    • processQueue 中监控文件大小,达到阈值时切换至新文件。