【项目】C++ 基于多设计模式下的同步&异步日志系统
前言
-
一般而言,业务的服务都是周而复始的运行,当程序出现某些问题时,程序员要能够进行快速的修复,而修复的前提是要能够先定位问题。
-
因此为了能够更快的定位问题,我们可以在程序运行过程中记录一些日志,通过这些日志我们便能够很容易地了解程序的运行状态,以及程序崩溃时的一些信息,有了这些信息我们便能够更好的定位问题以及分析问题了。
-
本项目代码地址:日志系统
C++ 基于多设计模式下的同步&异步日志系统
- 前言
- 日志系统的必要性
- 项目相关介绍
- 1、功能
- 2、开发环境和工具
- 3、核心技术
- 4、环境搭建
- 日志系统技术实现方式
- 1、同步写日志
- 2、异步写日志
- 相关技术补充
- 1、C风格不定参函数
- 2、C++风格不定参函数
- 3、不定参数宏函数
- 设计模式
- 1、单例模式
- 2、工厂模式
- 3、建造者模式:
- 4、代理模式
- 一、日志系统框架设计
- 模块关系图
- 二、代码设计
- 1、实用类设计
- 2、日志等级类设计
- 3、日志消息类设计
- 4、日志输出格式化类设计
- 格式化子项的实现
- 5、日志落地类设计 (简单工厂模式)
- 6、 日志器类设计(建造者模式)
- 日志器建造者类
- 7、双缓冲区异步任务处理器设计(AsyncLooper)
- 异步缓冲区类的设计
- 8、单例日志器管理类设计(单例模式)
- 9、日志宏与全局接口设计
- 三、性能测试
- 测试环境:
日志系统的必要性
- 生产环境的产品为了保证其稳定性及安全性是不允许开发人员附加调试器去排查问题, 因此需要借助日志系统来打印一些日志帮助开发人员解决问题。
- 上线客户端的产品出现bug无法复现并解决, 可以借助日志系统打印日志并上传到服务端帮助开发人员进行分析。
- 对于一些高频操作(如定时器、心跳包)在少量调试次数下可能无法触发我们想要的行为,通过断点的暂停方式,我们不得不重复操作几十次、上百次甚至更多,导致排查问题效率是非常低下, 可以借助打印日志的方式查问题
- 在分布式、多线程/多进程代码中, 出现bug比较难以定位, 可以借助日志系统打印日志帮助定位bug
- 此外,日志还可以帮助首次接触项目代码的新开发人员理解代码的运行流程
因此日志系统在实际业务项目中是必不可少的,所以本项目我们将实现一个日志系统,用于记录程序运行状态的信息,以便程序员根据日志信息掌握程序的运行状态,以及方便程序员进行问题分析和定位。
项目相关介绍
1、功能
本项目主要实现一个日志系统,其主要支持以下功能:
- 支持多级别日志消息
- 支持同步日志和异步日志
- 支持多线程程序并发写日志
- 支持多种落地方向,如:写入日志到控制台、指定文件以及滚动文件中
- 支持扩展不同的日志落地方向
2、开发环境和工具
- CentOS/Ubuntu(其他操作系统没有经过测试,有兴趣的可以自己测试~)
- vscode/vim
- g++/gdb
- Makefile
3、核心技术
- 类层次设计(继承和多态的使用)
- C++11(多线程、auto、智能指针、右值引用等)
- 双缓冲区
- 生产消费模型
- 多线程
- 设计模式(单例、工厂、代理、模板等)
4、环境搭建
本项目不依赖其他任何第三方库, 只需要安装好CentOS/Ubuntu + vscode/vim环境即可开发。
日志系统技术实现方式
日志系统的技术实现主要包括三种类型:
- 利用printf、std::cout等输出函数将日志信息打印到控制台。
- 对于大型商业化项目, 为了方便排查问题,我们一般会将日志输出到文件或者是数据库系统方便查询和分析日志, 而这时日志的输出方式就被分为了「同步写日志」和「异步写日志」方式。
1、同步写日志
同步日志是指当输出日志时,必须等待日志输出语句执行完毕后,才能执行后面的业务逻辑语句,日志输出语句与程序的业务逻辑语句将在同一个线程运行。每次调用一次打印日志API就对应一次系统调用write写日志文件。
在高并发场景下,随着日志数量不断增加,同步日志系统容易产生系统性能瓶颈:
- 一方面,大量的日志打印陷入等量的write系统调用,有一定系统开销.
- 另一方面,使得打印日志的进程附带了大量同步的磁盘IO,影响程序性能,在极为严峻的情况下可能会导致业务线程被阻塞,无法执行后续的业务程序逻辑代码。
2、异步写日志
异步日志是指在进行日志输出时,日志输出语句与业务逻辑语句并不是在同一个线程中运行,而是有专门的线程用于进行日志输出操作。业务线程只需要将日志放到一个内存缓冲区中,不用等待即可继续执行后续业务逻辑(作为日志的生产者),而日志的落地操作交给单独的日志线程去完成(作为日志的消费者), 这是一个典型的生产者——消费者模型。
这样做的好处是即使日志没有真的地完成输出也不会影响程序的主业务,可以提高程序的性能,异步写日志好处如下:
- 主线程调用日志打印接口成为非阻塞操作
- 同步的磁盘IO从主线程中剥离出来交给单独的线程完成
相关技术补充
1、C风格不定参函数
在初学C语言的时候,我们都用过printf函数进行打印。其中printf函数就是一个不定参函数,在函数内部可以根据格式化字符串中格式化字符分别获取不同的参数进行数据的格式化。
而这种不定参函数在实际的使用中也非常多见,在这里简单做一介绍,详细的介绍可以参照这篇文章可变参数详解:
不定参函数的声明格式如下:
return_type func (var, ...);
- return_type为函数返回值类型;
- func为函数名;
- var是一个任意类型的参数,但是通常为格式化字符串,用于指定参数的数量和类型;
- ... 表示不定数量的参数
不定参数最重要的就是如何拿到每一个参数,在C语言中,我们使用三个宏函数和一个类型进行操作。
#include #include // 类型 va_list // 类型初始化 void va_start(va_list ap, last); // 不定参提取 type va_arg(va_list ap, type); // 结束使用 void va_end(va_list ap);
- va_list:va_list是一个类型,它的本质其实是char*;
- va_start:用于初始化va_list对象,使其指向不定参数列表的第一个参数;
- va_arg:用于获取不定参数列表中的参数;
- va_end:用于置空va_list类型的对象;
我们来看下面的代码来掌握对C风格不定参的使用:
①使用不定参函数打印每一个参数
#include #include // num为不定参数的数目 void PrintArg(int num, ...) { // 定义一个va_list对象 va_list ap; // 1.进行初始化 va_start(ap, num); // va_list的第二个参数就是不定参数的前一个参数 // 2.获取不定参数并打印 for (int i = 0; i
②通过可变参数创建一个格式化的字符串
这里我们需要使用一个函数vasprintf,vasprintf 是一个 GNU的扩展的 C库函数(使用时需要先#define _GNU_SOURCE),它可以通过可变参数创建一个格式化的字符串,并将其存储在动态分配的内存中。它的使用方法与 printf类似,但它不会将结果打印到标准输出流中,而是将其存储在一个指向字符数组的指针中。
- 函数原型:
#include #include int vasprintf(char **strp, const char *fmt, va_list ap);
- 示例代码:
void myprint(const char* fmt, ...) { va_list ap; va_start(ap, fmt); // 字符串的起始地址 char* res = NULL; // 形成格式化字符串 if (vasprintf(&res, fmt, ap) == -1) { perror("vasprintf fail: "); return; } // 进行打印 printf(res); // 结束使用 va_end(ap); // 别忘记释放res指向的内存 free(res); } int main() { myprint("今天是[%d-%d-%d]日,祝福语:%s\n", 2024, 1, 8, "你好"); return 0; }
2、C++风格不定参函数
C++11引入了可变参数模板,可变参数模板允许你定义一个接受不定数量参数的函数,并且能够在编译时进行类型检查。这种方式更加灵活,但其使用难度也很高。
这里我们不在进行介绍,详细的介绍可以看这里:可变参数模板
3、不定参数宏函数
前面我们讲的都是在函数中使用不定参数,可是有些时候我们也要在宏函数中使用不定参数,在以前标准C是没有办法做到的。
于是在C99 中加入了__VA_ARGS__ 关键字,用于支持在宏定义中定义可变数量参数。
不定参数在宏的声明中用...代表,在宏体中不定参数被保存到__VA_ARGS__中,在宏替换时自动进行参数展开。
#include // 不定参数宏函数 // "[%s] " 和fmt 都是字符串,在编译时会被组合成为一个字符串 #define LOG(fmt, ...) printf("[%s] " fmt, __FILE__, ##__VA_ARGS__) int main() { LOG("今天是[%d-%d-%d]日,祝福语:%s\n", 2024, 1, 8, "你好"); return 0; }
设计模式
设计模式是前人对代码开发经验的总结,是解决特定问题的一系列套路。它不是语法规定,而是一套用来提高代码可复用性、可维护性、可读性、稳健性以及安全性的解决方案。
在设计模式中有六大原则需要我们去遵循(不必全部遵循,而是遵循的越多越好):
-
单一职责原则(Single Responsibility Principle):
- 类的职责应该单一,一个方法只做一件事。职责划分清晰了,每次改动到最小单位的方法或类。
- 使用建议:两个完全不一样的功能不应该放一个类中,一个类中应该是一组相关性很高的函数、数据的封装。
- 实例:在网络聊天中,「网络通信」 与「聊天」,应该分割成为「网络通信类」 与 「聊天类」。
-
开闭原则(Open Closed Principle)
- 对扩展开放,对修改封闭。
- 使用建议:对软件实体的改动,最好用扩展而非修改的方式。
- 用例:限时秒杀:商品价格不应该是修改商品的原来价格,而是新增促销价格。
-
里氏替换原则(Liskov Substitution Principle)
- 通俗点讲,就是只要父类能出现的地方,子类就可以出现,而且替换为子类也不会产生任何错误或异常。
- 在继承类时,务必重写父类中所有的方法,尤其需要注意父类的protected方法,子类尽量不要暴露自己的public方法供外界调用。
- 使用建议:子类必须完全实现父类的方法,孩子类可以有自己的个性。覆盖或实现父类的方法时,输入参数可以被放大,输出可以缩小。
- 用例:跑步运动员类——会跑步,子类长跑运动员——会跑步且擅长长跑, 子类短跑运动员——会跑步且擅长短跑。
-
依赖倒置原则(Dependence Inversion Principle)。
- 高层模块不应该依赖低层模块,两者都应该依赖其抽象,不可分割的原子逻辑就是低层模式,原子逻辑组装成的就是高层模块。
- 模块间依赖通过抽象(接口)发生,具体类之间不直接依赖
- 使用建议:每个类都尽量有抽象类,任何类都不应该从具体类派生。尽量不要重写基类的方法。结合里氏替换原则使用。
- 用例:奔驰车司机类——只能开奔驰; 司机类 —— 给什么车,就开什么车; 开车的人:司机——依赖于抽象。
-
迪米特法则(Law of Demeter),又叫"最少知道法则”:
- 尽量减少对象之间的交互,从而减小类之间的耦合。一个对象应该对其他对象有最少的了解,对类的低耦合提出了明确的要求:
- 只和直接的朋友交流, 朋友之间也是有距离的。自己的就是自己的(如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中)。
- 用例:老师让班长点名——老师给班长一个名单,班长完成点名勾选,返回结果,而不是班长点名,老师勾选。
-
接口隔离原则(Interface Segregation Principle):
- 客户端不应该依赖它不需要的接口,类间的依赖关系应该建立在最小的接口上
- 使用建议:接口设计尽量精简单一,但是不要对外暴露没有实际意义的接口。
- 用例:修改密码,不应该提供修改用户信息接口,而就是单一的最小修改密码接口,更不要暴露数据库操作。
从整体上来理解六大设计原则,可以简要的概括为一句话,用抽象构建框架,用实现扩展细节,具体到每一条设计原则,则对应一条注意事项:
- 单一职责原则告诉我们实现类要职责单一;
- 里氏替换原则告诉我们不要破坏继承体系;
- 依赖倒置原则告诉我们要面向接口编程;
- 接口隔离原则告诉我们在设计接口的时候要精简单一;
- 迪米特法则告诉我们要降低耦合;
- 开闭原则是总纲,告诉我们要对扩展开放,对修改关闭。
1、单例模式
- 一个类只能创建一个对象,即单例模式,该设计模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
- 比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。
单例模式有两种实现模式:「饿汉模式」和「懒汉模式」
饿汉模式:
- 程序启动时就会创建一个唯一的实例对象。 因为单例对象已经确定, 所以比较适用于多线程环境中, 多线程获取单例对象不需要加锁, 可以有效的避免资源竞争, 提高性能。
// 1. 单例模式之饿汉模式 class Singleton { public: static Singleton& get_instance() { return _ins; } const string& get_name() { return _name; } void set_name(const string& str) { _name = str; } private: Singleton() { cout public: static Singleton& get_instance() { static Singleton ins; return ins; } const string& get_name() { return _name; } void set_name(const string name) { _name = name; } private: Singleton() { cout public: virtual void show() = 0; }; // 产品具体类 class Apple : public Fruit { public: virtual void show() override { cout public: virtual void show() override { cout public: virtual void show() override { cout public: static shared_ptr if (category == "Apple") { return make_shared return make_shared return make_shared return shared_ptr FruitFactory ff; shared_ptr public: virtual void show() = 0; }; // 产品具体类 class Apple : public Fruit { public: virtual void show() override { cout public: virtual void show() override { cout public: virtual shared_ptr public: virtual shared_ptr return make_shared public: virtual shared_ptr return make_shared shared_ptrshow(); return 0; }
工厂方法模式:定义一个创建对象的接口,但是由子类来决定创建哪种对象,使用多个工厂分别生产指定的固定产品
-
优点
- 减轻了工厂类的负担,将某类产品的生产交给指定的工厂来进行
- 开闭原则遵循较好,添加新产品只需要新增产品的工厂即可,不需要修改原先的工厂类
-
缺点:对于某种可以形成一组产品族的情况处理较为复杂,需要创建大量的工厂类。
抽象工厂模式:工厂方法模式通过引入工厂等级结构,解决了简单工厂模式中工厂类职责太重的问题,但由于工厂方法模式中的每个工厂只生产一类产品,可能会导致系统中存在大量的工厂类,势必会增加系统的开销。此时,我们可以考虑将一些相关的产品组成一个产品族(位于不同产品等级结构中功能相关联的产品组成的家族),由同一个工厂来统一生产,这就是抽象工厂模式的基本思想。
例如:我们需要生产「猫,狗,苹果,香蕉」,显然猫狗是动物,我们需要一个动物工厂, 苹果,香蕉是水果,我们需要有一个水果工厂,然后我们让水果工厂生产水果,动物工厂生产动物,这样我们既减轻了单一工厂类职责太重的问题,也解决了导致系统中存在大量的工厂类的问题了。
// 产品抽象类 class Fruit { public: virtual void show() = 0; }; // 产品具体类 class Apple : public Fruit { public: virtual void show() override { cout public: virtual void show() override { cout public: virtual void voice() = 0; }; // 产品具体类 class Dog : public Animal { public: virtual void voice() override { cout public: virtual void voice() override { cout public: virtual shared_ptr public: virtual shared_ptr if (category == "Apple") { return make_shared return make_shared return shared_ptr return shared_ptr public: virtual shared_ptr return shared_ptr if (category == "Dog") { return make_shared return make_shared return shared_ptr shared_ptrshow(); // 生产动物 af = make_shared(); animal = af->create_animal("Dog"); animal->voice(); animal = af->create_animal("Cat"); animal->voice(); return 0; }
- 抽象工厂模式:围绕一个超级工厂创建其他工厂,每个生成的工厂按照简单工厂模式生产对象。
- 思想:将工厂抽象成两层,「抽象工厂」 和 「具体工厂子类」, 在工厂子类种生产不同类型的子产品。
抽象工厂模式适用于生产有关联的系列产品的设计模式,增加新的产品等级结构复杂,需要对原有系统进行较大的修改,甚至需要修改抽象层代码,违背了“开闭原则”。
3、建造者模式:
建造者模式是一种创建型设计模式, 使用多个简单的对象一步一步构建成一个复杂的对象,能够将一个复杂的对象的构建与它的表示分离,提供一种创建对象的最佳方式。主要用于解决对象的构建过于复杂的问题。
建造者模式主要基于四个核心类实现:
- 抽象产品类
- 具体产品类:一个具体的产品对象类
- 抽象Builder类:创建一个产品对象所需的各个部件的抽象接口
- 具体产品的Builder类:实现抽象接口,构建各个部件
- 指挥者Director类:统一组建过程,提供给调用者使用,通过指挥者来构造产品。(非必须)
以电脑为例,我们要组装一个电脑肯定是:先要组装主板,然后组装cpu,内存条,最后才能安装操作系统。
// 3. 构建者模式 // 电脑(抽象类) class Computer { public: using ptr = std::shared_ptr; void set_board(const string& mainboard) { _mainboard = mainboard; } void set_cpu(const string& cpu_info) { _cpu = cpu_info; } void set_memory(size_t memory_size) { _memory_size = memory_size; } virtual void set_os() = 0; void show() { string message = "电脑配置信息如下:\n\t"; message = message + "主板型号:" + _mainboard + "\n\tCPU信息:" + _cpu + "\n\t内存规格:" + to_string(_memory_size) + "\n\t操作系统:" + _os; cout virtual void set_os() override { _os = "Mac OS"; } }; // 构建者(抽象类) class Builder { public: using ptr = std::shared_ptr public: MacBookBuilder() { // new 一个MacBook对象 _builder = make_shared _builder-set_board(board); } virtual void build_cpu(const string& cpuinfo) { _builder-set_cpu(cpuinfo); } virtual void build_memory(size_t memory_size) { _builder->set_memory(memory_size); } virtual void build_os() { _builder->set_os(); } // 构建对象 virtual Computer::ptr build() { return _builder; } protected: Computer::ptr _builder; }; class Director { public: Director(Builder::ptr builder) :_director(builder) {} // 按顺序进行构建每一个子部分 void construct(const string& board, const string& cpuinfo, size_t memory_size) { _director->build_mainboard(board); _director->build_cpu(cpuinfo); _director->build_memory(memory_size); _director->build_os(); } private: Builder::ptr _director; }; int main() { // 创建Macbook建造者 shared_ptr builder = make_shared(); // 创建指挥者 Director director = builder; // 指挥组建 director.construct("华硕主板", "Intel", 16); // 得到对象 Computer::ptr macbook = builder->build(); // 打印信息 macbook->show(); return 0; }
4、代理模式
代理模式指代理控制对其他对象的访问, 也就是代理对象控制对原对象的引用。在某些情况下,一个对象不适合或者不能直接被引用访问,而代理对象可以在客户端和目标对象之间起到中介的作用。
代理模式的结构包括一个是真正的你要访问的对象(目标类)、一个是代理对象。目标对象与代理对象实现同一个接口,先访问代理类再通过代理类访问目标对象。代理模式分为静态代理、动态代理:
- 静态代理指的是,在编译时就已经确定好了代理类和被代理类的关系。也就是说,在编译时就已经确定了代理类要代理的是哪个被代理类。
- 动态代理指的是,在运行时才动态生成代理类,并将其与被代理类绑定。这意味着,在运行时才能确定代理类要代理的是哪个被代理类。
以租房为例,房东将房子租出去,但是要租房子出去,需要发布招租启示, 带人看房,负责维修,这些工作中有些操作并非房东能完成,因此房东为了图省事,将房子委托给中介进行租赁。
#include #include /*房东要把⼀个房⼦通过中介租出去理解代理模式*/ /* 租房类 */ class RentHouse { public: virtual void rentHouse() = 0; }; /* 房东类 */ class Landlord : public RentHouse { public: void rentHouse() { std::cout public: void rentHouse() { std::cout Intermedirary intermedirary; intermedirary.rentHouse(); return 0; } class Date { public: // 获取时间 static time_t Now() { return time(nullptr); } // 获取时间结构体 static struct tm GetTimeSet() { struct tm t; time_t time_stamp = Date::Now(); localtime_r(&time_stamp, &t); return t; } } } // ... class File { public: // 判断文件是否存在 static bool IsExist(const std::string& path) { struct stat st; if (stat(path.c_str(), &st) == 0) { return true; } else { return false; } } } } public: // 获取文件的所在目录路径 static std::string GetPath(const std::string& path) { size_t pos = path.find_last_of("/\\"); // a.txt if (pos == std::string::npos) { return "."; } else // /home/abc/test/a.txt { return path.substr(0, pos + 1); } } } // 非法路径 if (path.size() == 0) return; umask(0); // 测试样例: // /home/abc/test/ /home/abc/test // text test/ // ./test ./test/ // cur是当前位置,pos是目录分隔符位置 size_t cur = 0, pos = 0; // 父级目录 std::string parent_dir; while (cur
- 思想:将工厂抽象成两层,「抽象工厂」 和 「具体工厂子类」, 在工厂子类种生产不同类型的子产品。
- 抽象工厂模式:围绕一个超级工厂创建其他工厂,每个生成的工厂按照简单工厂模式生产对象。
-
- 程序启动时就会创建一个唯一的实例对象。 因为单例对象已经确定, 所以比较适用于多线程环境中, 多线程获取单例对象不需要加锁, 可以有效的避免资源竞争, 提高性能。
- 尽量减少对象之间的交互,从而减小类之间的耦合。一个对象应该对其他对象有最少的了解,对类的低耦合提出了明确的要求:
-
- 示例代码:
- 函数原型:
- 测试环境: