前面我们实现过一个简单地日志,但是对于实际工程项目而言,那样的日志过于简略。接下来我们就来实现一个更加先进、更符合工程需要的日志系统,在多线程业务下日志也不影响业务性能。
相关的代码提交至gitee:同步_异步日志: 本项⽬主要实现⼀个⽇志系统, 其主要⽀持以下功能: 1、⽀持多级别⽇志消息 2、⽀持同步⽇志和异步⽇志 3、⽀持可靠写⼊⽇志到控制台、⽂件以及滚动⽂件中 4、⽀持多线程程序并发写⽇志 5、⽀持扩展不同的⽇志落地⽬标地
目录
[核心概念与 4 个宏](#核心概念与 4 个宏)
方式一:std::initializer_list(C++11+,同类型不定参首选)
日志系统项目介绍
功能实现
本日志系统项目主要支持以下功能:
• 支持多级别日志消息
• 支持同步日志和异步日志
• 支持可靠写入日志到控制台、文件以及滚动文件中
• 支持多线程程序并发写日志
• 支持扩展不同的日志落地目标地
日志的意义
在实际项目工程中,日志系统扮演着至关重要的角色。它不仅是系统运行状态的"黑匣子",记录下程序执行的轨迹、关键数据变更和异常信息,更是开发与运维人员进行故障排查、性能分析、安全审计和业务监控的核心依据。完善的日志系统能够显著提升系统的可观测性与可维护性,帮助团队快速定位线上问题、分析用户行为、追踪链路调用,甚至触发自动告警,从而保障系统的稳定运行与持续迭代
同步日志
同步日志是指当输出日志时,必须等待日志输出语句执行完毕后,才能执行后面的业务逻辑语句,日志输出语句与程序的业务逻辑语句将在同一个线程运行。每次调用一次打印日志API就对应一次系统调用write写日志文件。

在高并发场景下,随着日志数量不断增加,同步日志系统容易产生系统瓶颈:
• 一方面,大量的日志打印陷入等量的write系统调用,有一定系统开销.
• 另一方面,使得打印日志的进程附带了大量同步的磁盘IO,影响程序性能
异步日志
异步日志是指在进行日志输出时,日志输出语句与业务逻辑语句并不是在同一个线程中运行,而是有专门的线程用于进行日志输出操作。业务线程只需要将日志放到一个内存缓冲区中不用等待即可继续执行后续业务逻辑(作为日志的生产者),而日志的落地操作交给单独的日志线程去完成(作为日志的消费者), 这是一个典型的生产-消费模型。

这样做的好处是即使日志没有真的地完成输出也不会影响程序的主业务,可以提高程序的性能:
• 主线程调用日志打印接口成为非阻塞操作
• 同步的磁盘IO从主线程中剥离出来交给单独的线程完成
日志模块划分
日志等级模块
对输出日志的等级进行划分,以便于控制日志的输出,并提供等级枚举转字符串功能。
◦ OFF :关闭
◦DEBUG :调试,调试时的关键信息输出。
◦ INFO :提示,普通的提示型日志信息。
◦WARN :警告,不影响运行,但是需要注意一下的日志。
◦ ERROR :错误,程序运行出现错误的日志
◦ FATAL:致命,一般是代码异常导致程序无法继续推进运行的日志
日志消息模块
中间存储日志输出所需的各项要素信息
时间 :描述本条日志的输出时间。
线程ID :描述本条日志是哪个线程输出的。
日志等级 :描述本条日志的等级。
日志数据 :本条日志的有效载荷数据。
日志文件名 :描述本条日志在哪个源码文件中输出的。
日志行号:描述本条日志在源码文件的哪一行输出的。
日志消息格式化模块
设置日志输出格式,并提供对日志消息进行格式化功能。
系统的默认日志输出格式 :%d{%H:%M:%S}%T[%t]%T[%p]%T[%c]%T%f:%l%T%m%n
例如:
bash
13:26:32 [2343223321] [FATAL] [root] main.c:76 套接字创建失败\n ◦
%d{%H:%M:%S} :表示日期时间,花括号中的内容表示日期时间的格式。
%T :表示制表符缩进。
%t :表示线程ID
%p :表示日志级别
%c :表示日志器名称,不同的开发组可以创建自己的日志器进行日志输出,小组之间互不影响。
%f :表示日志输出时的源代码文件名。
%l :表示日志输出时的源代码行号。
%m :表示给与的日志有效载荷数据
%n:表示换行
设计思想:设计不同的子类,不同的子类从日志消息中取出不同的数据进行处理。
日志消息落地模块
决定了日志的落地方向,可以是标准输出,也可以是日志文件,也可以滚动文件输出....
标准输出 :表示将日志进行标准输出的打印。
日志文件输出 :表示将日志写入指定的文件末尾。
滚动文件输出 :当前以文件大小进行控制,当一个日志文件大小达到指定大小,则切换下一个文件进行输出。后期,也可以扩展远程日志输出,创建客户端,将日志消息发送给远程的日志分析服务器。
设计思想:设计不同的子类,不同的子类控制不同的日志落地方向。
日志器模块
此模块是对以上几个模块的整合模块,用户通过日志器进行日志的输出,有效降低用户的使用难度。
日志器管理模块
为了降低项目开发的日志耦合,不同的项目组可以有自己的日志器来控制输出格式以及落地方向,因此本项目是一个多日志器的日志系统。
管理模块
就是对创建的所有日志器进行统一管理。并提供一个默认日志器提供标准输出的日志输出。
异步线程模块
实现对日志的异步输出功能,用户只需要将输出日志任务放入任务池,异步线程负责日志的落地输出功能,以此提供更加高效的非阻塞日志输出。
设计图

前置知识:不定参数
C语言风格不定参函数
C 语言从诞生之初就支持不定参,核心依赖 <stdarg.h> 头文件里的 4 个宏,这是最底层、最灵活但也最「危险」的方式 ------完全没有类型安全,全靠开发者手动约定参数规则。
核心概念与 4 个宏
不定参函数的结构是:至少 1 个固定参数 + 省略号 ...,固定参数用来「告诉」函数后面有多少个不定参、分别是什么类型。
4 个核心宏的作用:
| 宏 | 作用 |
|---|---|
va_list |
定义一个「参数列表指针」类型,用来遍历不定参 |
va_start(ap, last_fixed_arg) |
用最后一个固定参数初始化指针 ap |
va_arg(ap, type) |
从 ap 中取出下一个参数,必须手动指定正确的类型 |
va_end(ap) |
清理指针 ap,结束不定参遍历 |
示例:简化版printf函数
cpp
#include <stdarg.h>
#include <stdio.h>
void simple_printf(const char* format, ...) {
va_list args;
va_start(args, format);
while (*format != '\0')
{
if (*format == '%')
{
format++; // 跳过%,看后面的格式化字符
switch (*format)
{
case 'd':
{
// 取出int类型参数
int val = va_arg(args, int);
printf("%d", val);
break;
}
case 's':
{
// 取出const char*类型参数
const char* val = va_arg(args, const char*);
printf("%s", val);
break;
}
default:
putchar(*format); // 不是格式化字符,直接输出
}
}
else
{
putchar(*format);
}
format++;
}
va_end(args);
}
int main()
{
simple_printf("你好,%s!你今年%d岁了。\n", "张三", 25);
// 输出:你好,张三!你今年25岁了。
return 0;
}
但是C风格不定参函数会有以下风险:
- 没有类型安全 :
va_arg完全信任你指定的类型,如果类型错了(比如把int当char*读),直接是未定义行为(崩溃、乱码都是轻的); - 必须手动约定参数数量 / 类型 :要么像
sum那样用固定参数传数量,要么像printf那样用格式化字符串约定,否则函数根本不知道怎么读参数; - 类型自动提升 :不定参里的
char/short会被提升为int,float会被提升为double,所以va_arg绝对不能写char/short/float,必须写int/double; va_end必须调用:虽然有些平台不调用也没事,但跨平台时可能会内存泄漏,必须养成习惯
C++风格不定参函数
C++ 完全兼容 C 的 stdarg.h 方式,但因为 C++ 强调类型安全,所以从 C++11 开始引入了更安全、更易用的不定参机制,优先推荐用这些。
方式一:std::initializer_list(C++11+,同类型不定参首选)
如果你的不定参全是同一种类型 ,用 std::initializer_list 最合适 ------ 它本质是一个轻量级的「数组视图」,类型安全,用起来也简单。
核心特点
-
必须用
{}包围参数(比如func({1,2,3})); -
只能存储同一种类型;
-
支持范围
for遍历。
代码示例:计算多个整数的和 + 打印多个字符串
cpp
#include <initializer_list>
#include <iostream>
#include <string>
// 计算initializer_list中int的和
int sum(std::initializer_list<int> il)
{
int total = 0;
for (int val : il) // 范围for遍历,简单直观
{
total += val;
}
return total;
}
// 打印多个字符串
void print_strings(std::initializer_list<std::string> il)
{
for (const auto& s : il)
{
std::cout << s << " ";
}
std::cout << "\n";
}
int main() {
std::cout << "Sum: " << sum({10, 20, 30, 40}) << "\n"; // 输出100
print_strings({"Hello", "C++", "World", "!"}); // 输出Hello C++ World !
return 0;
}
方式二:可变参数模板(C++11+,任意类型、类型安全)
如果你的不定参是不同类型,用「可变参数模板」最合适 ------ 这是现代 C++ 最强大的特性之一,完全类型安全,支持任意数量、任意类型的参数。
核心概念
-
模板参数包 :
typename... Args,表示 0 个或多个模板类型; -
函数参数包 :
Args... args,表示 0 个或多个函数参数; -
参数包展开:通过递归或 C++17 的「折叠表达式」把参数包展开处理。
代码示例 1:递归展开(C++11-17 通用)
通过递归逐个处理参数,最后用一个「终止函数」结束递归:
cpp
#include <iostream>
#include <string>
// 递归终止函数:当参数包为空时调用
void print_args()
{
std::cout << "\n";
}
// 递归展开函数:处理第一个参数,然后递归处理剩下的
template <typename T, typename... Args> // T是第一个参数的类型,Args是剩下的类型包
void print_args(T first, Args... rest) // first是第一个参数,rest是剩下的参数包
{
std::cout << first << " "; // 处理第一个参数
print_args(rest...); // 递归展开剩下的参数包
}
int main()
{
print_args(10, 3.14, "Hello", std::string("World"));
// 输出:10 3.14 Hello World
return 0;
}
代码示例 2:C++17 折叠表达式(更简洁,零递归)
C++17 引入了「折叠表达式」,一行代码就能展开参数包,不需要递归终止函数,编译期展开,运行时零开销:
cpp
#include <iostream>
#include <string>
template <typename... Args>
void print_args(Args... args)
{
// 左折叠表达式:((std::cout << args << " "), ...)
// 意思是:依次对每个args执行std::cout << args << " "
((std::cout << args << " "), ...);
std::cout << "\n";
}
int main()
{
print_args(20, 6.28, "Hi", std::string("C++17"));
// 输出:20 6.28 Hi C++17
return 0;
}
封面图自取:
