博客主页:花果山~程序猿-CSDN博客
文章分栏:项目日记_花果山~程序猿的博客-CSDN博客
关注我一起学习,一起进步,一起探索编程的无限可能吧!让我们一起努力,一起成长!
目录
一,项目介绍
日志插件技术实现
同步日志
异步日志
相关技术补充
C不定参宏
C不定参函数
C++不定参函数
设计模式
工厂模式
建造者模式
代理模式
项目模块设计
日志框架设计
日志格式模块(format)
日志落地模块(sink)
日志管理器模块(logger)
扩展
双缓冲区异步任务处理器(Asynlogger)
异步日志器模块
日志宏
性能测试
模块关系图
改bug心得
结语
嗨!收到一张超美的图,愿你每天都能顺心!
一,项目介绍
- 企业开发中对于运行中的程序不适合使用调试器调试。⽣产环境的产品为了保证其稳定性及安全性是不允许开发⼈员附加调试器去排查问题, 可以借助⽇志系统来打印⼀些⽇志帮助开发⼈员解决问题。
- 问题无法复现。上线客⼾端的产品出现bug⽆法复现并解决, 可以借助⽇志系统打印⽇志并上传到服务端帮助开发⼈员进⾏分析。
- 对于⼀些⾼频操作(如定时器、⼼跳包)在少量调试次数下可能⽆法触发我们想要的⾏为,通过断点的暂停⽅式,我们不得不重复操作⼏⼗次、上百次甚⾄更多,导致排查问题效率是⾮常低下, 可 以借助打印⽇志的⽅式查问题 。
- 在分布式、多线程/多进程代码中, 出现bug⽐较难以定位, 可以借助⽇志系统打印log帮助定位bug。
本项⽬主要实现⼀个⽇志系统, 其主要⽀持以下功能:
- 支持日志等级选择
- 支持用户(开发人员)日志格式自定义
- 支持日志可靠落地,方向如:控制台,文件,滚动文件等
- 支持同步,异步模式选择
- 支持多线程程序并发写日志
核心技术:
- 类层次设计(继承 & 多态)
- C++(如:多线程,智能指针等)
- 双缓冲区
- 生产消费者模型
- 设计模式(单例,工厂,建造者,代理模式)
日志插件技术实现
现在我们日志输出方式主要是3种方式:
- printf,cout,等输出函数到控制台
- 对于⼤型商业化项⽬, 为了⽅便排查问题,我们⼀般会将⽇志输出到⽂件或者是数据库系统⽅便查询和分析⽇志, 主要分为同步⽇志和异步⽇志⽅式
同步日志
同步⽇志是指当输出⽇志时,必须等待⽇志输出语句执⾏完毕后,才能执⾏后⾯的业务逻辑语句,⽇志输出语句与程序的业务逻辑语句将在同⼀个线程运⾏。每次调⽤⼀次打印⽇志API就对应⼀次系统调⽤write写⽇志⽂件异步日志。
缺点:
- 在高并发情况下,由于写日志IO的系统操作,效率过低;
- 同时write操作有可能让线程阻塞,无法执行业务逻辑。
异步日志
异步日志就是将日志输出操作分离出,让一个线程负责日志输出操作,而业务线程只负责在写。业务线程只需要在内存中向日志缓冲区写入日志(日志的生产者),而日志线程就只负责从日志缓冲区中获取日志,完成日志的输出操作(日志的消费者)。这之间的关系就是一个典型的生产-消费者模型。
相关技术补充
C不定参宏
#include <stdio.h>// 定义一个不定参宏,用于打印消息
#define LOG(format, ...) printf(format, __VA_ARGS__)// 项目中通过它来简化接口调用
#define DEBUG(fmt, ...) \RoolLogger()->Debug(__FILE__, __LINE__, fmt, ##__VA_ARGS__)int main() {// 使用不定参宏打印不同数量的参数LOG("Hello, world!\n");LOG("The value of x is %d\n", 42);LOG("x = %d, y = %d, z = %d\n", 10, 20, 30);return 0;
}
C不定参函数
void Debug(const std::string &fmt, ...)
{// 1.从不定参中获取字符串char custom_log[1024] = {0};va_list v_li; // 本质是 char*va_start(v_li, fmt); // 将记录全部不定参数,到v_li中vsnprintf(custom_log, sizeof custom_log, fmt.c_str(), v_li); //通过接口转到char[]中va_end(v_li);print("%s%n", custom_log);}
C++不定参函数
// 模板函数 func 的定义
template <class T>
void func(const T& t)
{cout << t << endl;cout << endl;
}//非模板函数 func 的定义
void func()
{cout << 0 << endl;
}//1. 模板变参函数 func 的定义
template<class T , class ...Ags>
//void func(const T& t, Ags... args) // 一个一个地解包参数void func(const T& t, Ags&&... args) //
{cout << t << " 剩余包数:" << sizeof...(args) << endl ;//func(args...); // 剩余参数包func(forward<Ags>(args)...); //...的位置有讲究,
}//2. 使用变参为类传参
class MyClass {
public:MyClass(int a, int b, char _c) : x(a), y(b), c(_c) {}// 其他成员...
private:int x, y;char c;
};templete <class T, class ...Args>
void setclass(Args &&... args) {return std::make_shared<T>(std::forword<Args>(args)...);
// 假设 Args 是 int, int,char。不定参展开传参,展开示例如下:
//auto ptr = std::make_shared<MyClass>(std::forward<int>(10), std::forward<int>(20), std::forword<char>('k'));int main()
{func();func(1, 'A');func(1, 'A', "hello word");setclass<MyClass>("10", "20", 'k');
}
设计模式
设计模式是前辈们对代码开发经验的总结,是解决特定问题的⼀系列套路。它不是语法规定,⽽是⼀套⽤来提⾼代码可复⽤性、可维护性、可读性、稳健性以及安全性的解决⽅案。
设计原则:
从整体上来理解六⼤设计原则,可以简要的概括为⼀句话,⽤抽象构建框架,⽤实现扩展细节,具体到每⼀条设计原则,则对应⼀条注意事项:
- 单⼀职责原则告诉我们实现类要职责单⼀;
- ⾥⽒替换原则告诉我们不要破坏继承体系;
- 依赖倒置原则告诉我们要⾯向接⼝编程;
- 接⼝隔离原则告诉我们在设计接⼝的时候要精简单⼀;
- 迪⽶特法则告诉我们要降低耦合;
- 开闭原则是总纲,告诉我们要对扩展开放,对修改关闭。
这里只简单提及所用到的模式,详细了解设计模式,请自行寻找各模式的例子。
工厂模式
⼯⼚模式是⼀种创建型设计模式, 它提供了⼀种创建对象的最佳⽅式。在⼯⼚模式中,我们创建对象时不会对上层暴露创建逻辑,⽽是通过使⽤⼀个共同结构来指向新创建的对象,以此实现创建-使⽤的分离。()
⼯⼚模式可以分为:
简单⼯⼚模式: 简单⼯⼚模式实现由⼀个⼯⼚对象通过类型决定创建出来指定产品类的实例。假设有个⼯⼚能⽣产出⽔果,当客⼾需要产品的时候明确告知⼯⼚⽣产哪类⽔果,⼯⼚需要接收⽤⼾提供的类别信息,当新增产品的时候,⼯⼚内部去添加新产品的⽣产⽅式。
缺点:这个模式的结构和管理产品对象的⽅式⼗分简单, 但是它的扩展性⾮常差,当我们需要新增产品的时候,就需要去修改⼯⼚类新增⼀个类型的产品创建逻辑,违背了开闭原则。
⼯⼚⽅法模式: 在简单⼯⼚模式下新增多个⼯⼚,多个产品,每个产品对应⼀个⼯⼚。假设现在有A、B 两种产品,则开两个⼯⼚,⼯⼚ A 负责⽣产产品 A,⼯⼚ B 负责⽣产产品 B,⽤⼾只知道产品的⼯⼚名,⽽不知道具体的产品信息,⼯⼚不需要再接收客⼾的产品类别,⽽只负责⽣产产品。
缺点:⼯⼚⽅法模式每次增加⼀个产品时,都需要增加⼀个具体产品类和⼯⼚类,这会使得系统中类的个数成倍增加,在⼀定程度上增加了系统的耦合度。
抽象工厂模式:⼯⼚⽅法模式通过引⼊⼯⼚等级结构,解决了简单⼯⼚模式中⼯⼚类职责太重的问题,但由于⼯⼚⽅法模式中的每个⼯⼚只⽣产⼀类产品,可能会导致系统中存在⼤量的⼯⼚类,势必会增加系统的开销。
建造者模式
建造者模式是⼀种创建型设计模式, 使⽤多个简单的对象⼀步⼀步构建成⼀个复杂的对象,能够将⼀个复杂的对象的构建与它的表⽰分离,提供⼀种创建对象的最佳⽅式。主要⽤于解决对象的构建过于复杂的问题。
代理模式
代理模式指代理控制对其他对象的访问, 也就是代理对象控制对原对象的引⽤。在某些情况下,⼀个对象不适合或者不能直接被引⽤访问,⽽代理对象可以在客⼾端和⽬标对象之间起到中介的作⽤。
项目模块设计
日志框架设计
⽇志等级模块:对输出⽇志的等级进⾏划分,以便于控制⽇志的输出,并提供等级枚举转字符串功能。
- OFF:关闭
- DEBUG:调试,调试时的关键信息输出。
- INFO:提⽰,普通的提⽰型⽇志信息。
- WARN:警告,不影响运⾏,但是需要注意⼀下的⽇志。
- ERROR:错误,程序运⾏出现错误的⽇志
- FATAL:致命,⼀般是代码异常导致程序⽆法继续推进运⾏的⽇志
日志消息模块:存储日志必要的信息,如:时间,所在文件,行,日志等级,日志用户自定义部分,所在线程ID。
好了,我们有了组件日志的”材料“后开始制作日志,那我们该怎么将日志数据排列??
日志格式模块(format)
功能:用户给予日志格式化规则,如:%d%T[%t]%T[%p]%T[%c]%T%f:%l%T%m%n,日志格式器模块会生成的日志格式,然后日志依据格式构建日志信息,最后返回日志字符串。
设计模式:简单工厂模式
设计思想:
- 抽象出一个格式化子类的基类,同时声明虚函数fomat为子类形成日志的共同方法,由子类重写。
- 基于基类,派生出 时间,所在文件,行,日志等级等必要信息的子类;并重写format接口。
- 创建formatter 工厂类,根据用户规定的日志格式化规则,形成日志信息构建流水线(vector<format>)(实现方法:通过基类指针就能调用各个子类对象的fomat函数); 向外部提供获取日志字符串接口(GetResult)。
在外部构造日志时,直接调用formatter工厂类,由工厂类对外交互。
关系图:
format模块代码:
Log-Project/logs/format.hpp · 逆光/标准日志插件项目 - 码云 - 开源中国 (gitee.com)
日志落地模块(sink)
功能:用户给予一段日志信息,日志落地模块根据日志输出等级,将日志信息落地到输出到控制台,文件,滚动文件甚至是数据库。
设计模式:简单工厂模式
设计思路:
- 抽象出一个log落地基类,并同时声明log虚函数,让子类重写。
- 基于log基类,派生出控制台,文件,滚动文件(文件超过一定大小,自动创建新文件)等其他方向的子类,并根据落地方向不同重写log函数。
- 支持落地扩展。用户可以自己添加相应的落地方向模块,而工厂类创建不用修改。
- 创建logSink工厂类,向外部提供一个创建落地对象的接口createlogsink,用户即可通过传入落地方向的类,进行创建具体对象,让创建与表示分离。
代码:
Log-Project/logs/sink.hpp · 逆光/标准日志插件项目 - 码云 - 开源中国 (gitee.com)
日志管理器模块(logger)
功能:整合日志格式模块 & 日志管理器模块,向外提供根据日志等级输出的接口。
设计模式:工厂模式
管理成员:
- 日志器名称(日志器唯一标识符,在全局日志器中会对日志器进行管理)
- 日志格式化模块对象
- 日志落地方向对象数组(日志落地方向可能存在多个)
- 日志落地操作锁(在多线程情况下,保证日志写操作线程安全,避免日志交叉)
- 日志最小输出等级(小于该等级日志则不输出)
管理函数:
- debug,info,warn,fatal等级的日志输出操作(根据传入日志message使用日志格式化对象形成日志,然后调用日志落地接口log,完成日志落地)
- 抽象日志落地log
实现方式:
- 抽象logger基类(派生出 同步日志器 & 异步日志器 子类)
- 由于同步日志器异步日志器之间的区别是落地操作(log)的不同,因此我们将落地操作(log)抽象出来,在子类完成不同落地操作。最后通过logger基类指针,来调用不同日志器派生类重写的log操作。
代码:
Log-Project/logs/logger.hpp · 逆光/标准日志插件项目 - 码云 - 开源中国 (gitee.com)
扩展
功能:设计一个建造者类,简化用户设置日志参数,和用户使用日志器的复杂度。
设计模式:建造者模式
管理成员:由于是建造者类,其将管理成员为日志管理器的管理成员
管理函数:
- 对外提供设置日志器名称,日志器格式化规则,日志等级,创建日志落地方向等接口,一步步构造日志器部件。
实现方式:
- 抽象builderlogger类,提供日志器初始化部件接口(派生出 局部日志器建造者 & 全局日志器建造者 子类)
- 局部日志器与全局(单例)日志器建造者,区别在于前者无法突破作用域,而全局可以在程序的任一位置访问,并给予查看,管理,建造日志器。因此我们需要将builderlogger类中build抽象出来,让派生类来完成各自创建操作。
- 全局日志器建造者类需要使用单例模式,同时需要维护一个以名称查找的日志器unorder_map容器,以便于管理全局的日志器。
代码:
Log-Project/logs/builder.hpp · 逆光/标准日志插件项目 - 码云 - 开源中国 (gitee.com)
双缓冲区异步任务处理器(Asynlogger)
异步日志器相比与同步日志器,线程在调用日志器输出日志时,异步将日志信息存放在内存中然后继续业务逻辑,并不直接调用系统接口,进行IO操作;存在在内存中的日志信息会由异步日志落地线程专门来将日志信息落地。
设计思想:异步处理线程 + 数据池
使⽤者将需要完成的任务添加到任务池中,由异步线程来完成任务的实际执⾏操作。
任务池的设计思想:双缓冲区阻塞数据池
优势:避免了空间的频繁申请释放,且尽可能的减少了⽣产者与消费者之间锁冲突的概率,提⾼了任务处理效率。
问题:为什么不采用任务队列的方式处理日志?
答:
1. 任务队列锁冲突频繁,逻辑复杂。假设使用同一个任务队列,生产者(写日志线程)与生产者(写日志线程)存在锁冲突;生产者(写日志线程)与消费者(落地线程)存在锁冲突,锁冲突概率高。
2. 任务队列一般采用链表的方式,这样存在频繁的控制申请释放,空间无法复用。
⽽双缓冲区不同,双缓冲区是处理器将⼀个缓冲区中的任务全部处理完毕后,然后交换两个缓冲区,重新对新的缓冲区中的任务进⾏处理,虽然同时多线程写⼊也会冲突,但
是冲突并不会像每次只处理⼀条的时候频繁(⽣产者与消费者之间的锁冲突也只有交换缓冲区时,会有锁冲突),且不涉及到空间的频繁申请释放所带来的消耗。
缓冲区类设计
功能:提供缓冲区交换接口; 支持多日志一次落地; 支持缓冲区动态扩容
代码:
Log-Project/logs/buffer.hpp · 逆光/标准日志插件项目 - 码云 - 开源中国 (gitee.com)
异步日志器模块
异步⽇志器类继承⾃⽇志器类, 并在同步⽇志器类上拓展了异步消息处理器。当我们需要异步输出⽇志的时候, 需要创建异步⽇志器和消息处理器, 调⽤异步⽇志器的log、error、info、fatal等函数输出不同级别⽇志。
相对与同步日志器,异步日志器需要实现:
- 重写log函数,让日志信息写入到缓冲区中。
- 提供默认异步日志线程的缓冲区交换,读取缓冲区,最终完成日志落地功能逻辑的回调函数
代码:
Log-Project/logs/logger.hpp · 逆光/标准日志插件项目 - 码云 - 开源中国 (gitee.com)
日志宏
提供全局的⽇志器获取接⼝。 使⽤代理模式通过全局函数或宏函数来代理Logger类的log、debug、info、warn、error、fatal等接
⼝,以便于控制源码⽂件名称和⾏号的输出控制,简化⽤⼾操作。 当仅需标准输出⽇志的时候可以通过主⽇志器来打印⽇志。 且操作时只需要通过宏函数直接进⾏输出即可。(简化用户使用复杂度)
// 定义宏,用于自动获取文件名和行号,并且可以接受任意的日志记录器对象名,方便用户不用操作全局日志器
#define LOG_DEBUG(logger_name, fmt, ...) \GetLogger(logger_name)->Debug(__FILE__, __LINE__, fmt, ##__VA_ARGS__)#define LOG_INFO(logger_name, fmt, ...) \GetLogger(logger_name)->Info(__FILE__, __LINE__, fmt, ##__VA_ARGS__)#define LOG_FAIL(logger_name, fmt, ...) \GetLogger(logger_name)->Fail(__FILE__, __LINE__, fmt, ##__VA_ARGS__)#define LOG_WARN(logger_name, fmt, ...) \GetLogger(logger_name)->Warn(__FILE__, __LINE__, fmt, ##__VA_ARGS__)
详尽代码:
Log-Project/logs/log.h · 逆光/标准日志插件项目 - 码云 - 开源中国 (gitee.com)
性能测试
下⾯对⽇志系统做⼀个性能测试,测试⼀下平均每秒能打印多少条⽇志消息到⽂件。
主要的测试⽅法是:每秒能打印⽇志数 = 打印⽇志条数 / 总的打印⽇志消耗时间
主要测试要素:同步/异步 & 单线程/多线程。
测试项目:
- 1000w+条指定⻓度的⽇志输出所耗时间
- 每秒可以输出多少条⽇志
- 每秒可以输出多少MB⽇志
测试环境:
CPU: Intel(R) Xeon(R) Gold 6278C CPU @ 2.60GHz(2 核)
RAM: 4G
OS : CentOS Linux release 7.6.1810 (Core)
测试代码:
Log-Project/bench/bench.cxx · 逆光/标准日志插件项目 - 码云 - 开源中国 (gitee.com)
测试结果:打印相同日志数量,异步多线程最快;其次是同步多线程;
模块关系图
、
改bug心得
出错原因:对formatter功能理解有问题。
formatter功能:是根据用户给出的格式化规则,通过解析函数(pase_pattern),形成一个构造日志"队列"。
就好像:formatter是工厂,通过pase_pattern,自定义化形成一个构造日志流水线。
问题出在:我是如何理解这个流水线是:临时的(错误),还是持久化的(正确)。
首先从此次出错的问题:在多线程场景中,我的日志构造线的_items出现段错误。
出现问题思考解决思路:
刚开始时,我通过cout,定位到了出错点,同时也发现新线程来使用_items时出错。我刚开始意识到,这个_items出现线程安全,我后面甚至还想要不将_items从成员变量,做成临时变量,好让每次不用考虑加锁解锁。但这样做每发一个日志,就创建一个_items流水线工具,然后就丢弃,这样会有很大的性能浪费,在这里想就有问题,就没往这边想了。(差点在错误的路上一路狂奔)
最后,发现我应该将构造日志流水线做成持久化。从上到下讲,作为一个日志插件,用户在初始化日志器后,日志输出格式也应该确定了,日志构造流水线,也应处于被只读的情况,没有线程安全一说。
因此解析日志操作不能出现在,日志器构造日志中,应在初始化操作中。
结语
本小节就到这里了,感谢小伙伴的浏览,如果有什么建议,欢迎在评论区评论,如果给小伙伴带来一些收获,请动动你发财的小手点个免费的赞,你的点赞和关注永远是博主创作的动力源泉。