前言

  • 一般而言,业务的服务都是周而复始的运行,当程序出现某些问题时,程序员要能够进行快速的修复,而修复的前提是要能够先定位问题。

  • 因此为了能够更快的定位问题,我们可以在程序运行过程中记录一些日志,通过这些日志我们便能够很容易地了解程序的运行状态,以及程序崩溃时的一些信息,有了这些信息我们便能够更好的定位问题以及分析问题了。

  • 本项目代码地址:日志系统

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环境即可开发。

日志系统技术实现方式

日志系统的技术实现主要包括三种类型:

  • 利用printfstd::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 < num; i++){// 取出参数int a = va_arg(ap, int); // 第二个参数是我们要取出的数据类型printf("%d ", a);}// 3.置空va_end(ap);}int main(){PrintArg(4, 1, 3, 4, 5);return 0;}

②通过可变参数创建一个格式化的字符串

这里我们需要使用一个函数vasprintfvasprintf 是一个 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 << "单例对象构造完毕!\n";}Singleton(const Singleton&) = delete;private:// _name 只是为了充当单例对象内部的数据string _name;static Singleton _ins;};Singleton Singleton::_ins;

懒汉模式:

  • 第一次使用要使用单例对象的时候创建实例对象。如果单例对象构造特别耗时或者耗费济源(加载插件、加载网络资源等), 可以选择懒汉模式, 在第一次使用的时候才创建对象。
  • C++11 Static local variables 特性以确保C++11起,静态变量将能够在满足 thread-safe 的前提下唯一地被构造和析构。
// 1. 单例模式之懒汉模式// C++11版本class Singleton{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 << "单例对象创建完毕!\n";}Singleton(const Singleton&);private:string _name;};

2、工厂模式

工厂模式:是一种创建型设计模式, 它提供了一种创建对象的最佳方式。在工厂模式中,我们创建对象时不会对上层暴露创建逻辑,而是通过使用一个共同结构来指向新创建的对象,以此实现创建使用的分离。

工厂模式可以分为三种:简单工厂模式,工厂方法模式,抽象工厂模式


  • 简单工厂模式: 简单工厂模式实现由一个工厂对象通过类型决定创建出来指定产品类的实例。假设有个工厂能生产出水果,当客户需要产品的时候明确告知工厂生产哪类水果,工厂需要接收用户提供的类别信息,当新增产品的时候,工厂内部去添加新产品的生产方式。
#include #include #include #include //2.工厂模式之简单工厂模式// 产品抽象类class Fruit{public:virtual void show() = 0;};// 产品具体类class Apple : public Fruit{public:virtual void show() override{cout << "I am a Apple\n";}};class Banana : public Fruit{public:virtual void show() override{cout << "I am a Banana\n";}};class Orange : public Fruit{public:virtual void show() override{cout << "I am a Orange\n";}};// 简单工厂类class FruitFactory{public:static shared_ptr<Fruit> create(const string& category){if (category == "Apple"){return make_shared<Apple>();}else if (category == "Banana"){return make_shared<Banana>();}else if (category == "Orange"){return make_shared<Orange>();}else{return shared_ptr<Fruit>();}}};int main(){FruitFactory ff;shared_ptr<Fruit> fruit;fruit = ff.create("Apple");fruit->show();fruit = ff.create("Banana");fruit->show();fruit = ff.create("Orange");fruit->show();return 0;}

简单工厂模式:通过参数控制可以生产任何产品

  • 优点:简单粗暴,直观易懂。使用一个工厂生产同一等级结构下的任意产品
  • 缺点:
    • 所有东西生产在一起,产品太多会导致代码量庞大
    • 开闭原则遵循(开放拓展,关闭修改)的不是太好,要新增产品就必须修改工厂方法。

  • 工厂方法模式: 在简单工厂模式下新增多个工厂,多个产品,每个产品对应一个工厂。假设现在有苹果、香蕉 两种产品,则开两个工厂,苹果工厂负责生产苹果,香蕉工厂负责生产香蕉,用户只知道产品的工厂名,而不知道具体的产品信息,工厂不需要再接收客户的产品类别,而只负责生产产品。
// 产品抽象类class Fruit{public:virtual void show() = 0;};// 产品具体类class Apple : public Fruit{public:virtual void show() override{cout << "I am a Apple\n";}};class Banana : public Fruit{public:virtual void show() override{cout << "I am a Banana\n";}};// 工厂方法模式class FruitFactory{public:virtual shared_ptr<Fruit> create() = 0;};class AppleFactory : public FruitFactory{public:virtual shared_ptr<Fruit> create() override{return make_shared<Apple>();}};class BananaFactory : public FruitFactory{public:virtual shared_ptr<Fruit> create() override{return make_shared<Banana>();}};int main(){shared_ptr<Fruit> fruit;shared_ptr<FruitFactory> ff;// Appleff = make_shared<AppleFactory>();fruit = ff->create();fruit->show();// Bananaff = make_shared<BananaFactory>();fruit = ff->create();fruit->show();return 0;}

工厂方法模式:定义一个创建对象的接口,但是由子类来决定创建哪种对象,使用多个工厂分别生产指定的固定产品

  • 优点

    • 减轻了工厂类的负担,将某类产品的生产交给指定的工厂来进行
    • 开闭原则遵循较好,添加新产品只需要新增产品的工厂即可,不需要修改原先的工厂类
  • 缺点:对于某种可以形成一组产品族的情况处理较为复杂,需要创建大量的工厂类。


抽象工厂模式:工厂方法模式通过引入工厂等级结构,解决了简单工厂模式中工厂类职责太重的问题,但由于工厂方法模式中的每个工厂只生产一类产品,可能会导致系统中存在大量的工厂类,势必会增加系统的开销。此时,我们可以考虑将一些相关的产品组成一个产品族(位于不同产品等级结构中功能相关联的产品组成的家族),由同一个工厂来统一生产,这就是抽象工厂模式的基本思想。

例如:我们需要生产「猫,狗,苹果,香蕉」,显然猫狗是动物,我们需要一个动物工厂, 苹果,香蕉是水果,我们需要有一个水果工厂,然后我们让水果工厂生产水果,动物工厂生产动物,这样我们既减轻了单一工厂类职责太重的问题,也解决了导致系统中存在大量的工厂类的问题了。

// 产品抽象类class Fruit{public:virtual void show() = 0;};// 产品具体类class Apple : public Fruit{public:virtual void show() override{cout << "I am a Apple\n";}};class Banana : public Fruit{public:virtual void show() override{cout << "I am a Banana\n";}};// 产品抽象类class Animal{public:virtual void voice() = 0;};// 产品具体类class Dog : public Animal{public:virtual void voice() override{cout << "汪汪汪\n";}};class Cat : public Animal{public:virtual void voice() override{cout << "喵喵喵\n";}};// 抽象工厂(超级工厂)class AbstractFactory{public:virtual shared_ptr<Fruit> create_fruit(const string& category) = 0;virtual shared_ptr<Animal> create_animal(const string& category) = 0;};// 具体工厂(水果工厂)class FruitFactory : public AbstractFactory{public:virtual shared_ptr<Fruit> create_fruit(const string& category){if (category == "Apple"){return make_shared<Apple>();}else if (category == "Banana"){return make_shared<Banana>();}else{return shared_ptr<Fruit>();}}virtual shared_ptr<Animal> create_animal(const string& category){return shared_ptr<Animal>();}};// 具体工厂(动物工厂)class AnimalFactory : public AbstractFactory{public:virtual shared_ptr<Fruit> create_fruit(const string& category){return shared_ptr<Fruit>();}virtual shared_ptr<Animal> create_animal(const string& category){if (category == "Dog"){return make_shared<Dog>();}else if (category == "Cat"){return make_shared<Cat>();}else{return shared_ptr<Animal>();}}};int main(){shared_ptr<AbstractFactory> af;shared_ptr<Fruit> fruit;shared_ptr<Animal> animal;// 生产水果af = make_shared<FruitFactory>();fruit = af->create_fruit("Apple");fruit->show();fruit = af->create_fruit("Banana");fruit->show();// 生产动物af = make_shared<AnimalFactory>();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<Computer>;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 << message << endl;}protected:string _mainboard;string _cpu;size_t _memory_size;string _os;};// 苹果电脑(具体类)class MacBook : public Computer{virtual void set_os() override{_os = "Mac OS";}};// 构建者(抽象类)class Builder{public:using ptr = std::shared_ptr<Builder>;public:virtual void build_mainboard(const string& board) = 0;virtual void build_cpu(const string& cpuinfo) = 0;virtual void build_memory(size_t memory_size) = 0;virtual void build_os() = 0;virtual Computer::ptr build() = 0;};// MacBook构建者 (具体类)class MacBookBuilder : public Builder{public:MacBookBuilder(){// new 一个MacBook对象_builder = make_shared<MacBook>();}virtual void build_mainboard(const string& board){_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> builder = make_shared<MacBookBuilder>();// 创建指挥者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 << "将房子租出去\n" << std::endl;}};/* 中介类 */class Intermedirary : public RentHouse{public:void rentHouse(){std::cout << "发布告示" << std::endl;std::cout << "带人看房" << std::endl;_landlord.rentHouse();std::cout << "负责租后维修" << std::endl;}private:Landlord _landlord;};int main(){Intermedirary intermedirary;intermedirary.rentHouse();return 0;}

一、日志系统框架设计

本项目实现的是一个多日志器日志系统,主要实现的功能是让程序员能够轻松的将程序运行日志信息落地到指定的位置,且支持同步与异步两种方式的日志落地方式。

项目的框架设计将项目分为以下几个模块来实现:


  • 日志等级模块
  • 功能:对输出日志的等级进行划分,以便于控制日志的输出,并提供将等级枚举转换为字符串功能
    • OFF:关闭(最高等级,即所有的日志都可以输出)
    • DEBUG:调试,调试时的关键信息输出。
    • INFO:提示,普通的提示型日志信息。
    • WARN:警告,不影响运行,但是需要注意一下的日志。
    • ERROR:错误,程序运行出现错误的日志
    • FATAL:致命,一般是代码异常导致程序无法继续推进运行的日志

  • 日志消息模块
  • 功能:存储日志输出所需的各项要素信息。
    • 时间:描述本条日志的输出时间。
    • 线程ID:描述本条日志是哪个线程输出的。
    • 日志等级:描述本条日志的等级。
    • 日志数据:本条日志的有效载荷数据。
    • 日志文件名:描述本条日志在哪个源码文件中输出的。
    • 日志行号:描述本条日志在源码文件的哪一行输出的。

  • 日志消息格式化模块
  • 功能:设置日志输出格式,并提供对日志消息进行格式化功能。系统的默认日志输出格式:%d{%H:%M:%S}%T[%t]%T[%p]%T[%c]%T%f:%l%T%m%n
// 效果演示13:26:32 [2343223321] [FATAL] [root] main.c:76 套接字创建失败\n
  • %d{%H:%M:%S}:表示日期时间,花括号中的内容表示日期时间的格式。
  • %T:表示制表符缩进。
  • %t:表示线程ID
  • %p:表示日志级别
  • %c:表示日志器名称,不同的开发组可以创建自己的日志器进行日志输出,小组之间互不影响。
  • %f:表示日志输出时的源代码文件名。
  • %l:表示日志输出时的源代码行号。
  • %m:表示给与的日志有效载荷数据
  • %n:表示换行

设计思想:设计不同的子类,不同的子类从日志消息中取出不同的数据进行处理。


  • 日志消息落地模块
  • 功能:决定了日志的落地方向,可以是标准输出,也可以是日志文件,也可以滚动文件输出…
    • 标准输出:表示将日志进行标准输出的打印。
    • 日志文件输出:表示将日志写入指定的文件末尾。
    • 滚动文件输出:当前以文件大小进行控制,当一个日志文件大小达到指定大小,则切换下一个文件进行输出
    • 后期也可以扩展远程日志输出,创建客户端,将日志消息发送给远程的日志分析服务器。

设计思想:设计不同的子类,不同的子类控制不同的日志落地方向。


  • 日志器模块

  • 功能:此模块是对以上几个模块的整合模块,用户通过日志器进行日志的输出,有效降低用户的使用难度。

  • 包含有:日志消息落地模块对象,日志消息格式化模块对象,日志输出等级


  • 日志器管理模块

  • 功能:为了降低项目开发的日志耦合,不同的项目组可以有自己的日志器来控制输出格式以及落地方向,因此本项目是一个多日志器的日志系统。

  • 管理模块就是对创建的所有日志器进行统一管理。并提供一个默认日志器提供标准输出的日志输出。


  • 异步线程模块
  • 功能:实现对日志的异步输出功能,用户只需要将输出日志任务放入任务池,异步线程负责日志的落地输出功能,以此提供更加高效的非阻塞日志输出。

模块关系图

二、代码设计

1、实用类设计

工欲善其事,必先利其器,整个项目会经常涉及到一些文件和时间操作,所以我们可以提前完成一些零碎的功能接口,以便于项目使用。

  • 获取系统时间戳
  • 判断文件是否存在
  • 获取文件的所在目录路径
  • 创建目录

我们可以新建一个util.hpp的头文件,在这个文件中实现我们的实用类。


获取系统时间戳:我们可以使用C库函数time得到时间戳。

#include #include namespace mylog{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;} }}

判断文件是否存在:在Linux下有一个stat的系统调用,通过这个系统调用我们能够查看文件的状态信息,但是这个系统调用在文件不存在的情况下会调用失败,返回-1,利用这个特性,我们就能够判断文件是否存在了!

#include #include namespace mylog{// ...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;}} }}

获取文件的所在目录路径

  • 当文件的路径是 /home/abc/test/a.txt,很明显目录路径是:最后一个目录分隔符之前的所有字符串,即是/home/abc/test/或者/home/abc/test
  • 所以为了拿到目录路径,我们需要对字符串进行处理,我们可以先使用find_last_of对字符串进行倒序查找/或者\,然后再进行字串截取就行了。
class File{public:// 获取文件的所在目录路径static std::string GetPath(const std::string& path){size_t pos = path.find_last_of("/\\");// a.txtif (pos == std::string::npos){return ".";}else // /home/abc/test/a.txt{return path.substr(0, pos + 1);}}}

创建目录

  • /home/abc/test/例如这样一个目录,我们想要进行创建,必须先创建/home,然后再创建/abc,最后再创建test
  • 我们可以先从前向后遍历这个字符串,依次找到路径分隔符,然后依次创建目录
  • 对于目录的创建我们可以使用系统调用mkdir来进行创建。
// 创建目录static void CreateDirectory(const std::string& path){// 非法路径if (path.size() == 0) return;umask(0);// 测试样例:// /home/abc/test//home/abc/test// texttest/// ./test./test/// cur是当前位置,pos是目录分隔符位置size_t cur = 0, pos = 0;// 父级目录std::string parent_dir;while (cur < path.size()){pos = path.find_first_of("/\\", cur);// 截取父级路径parent_dir = path.substr(0, pos);// 父级路径有效 && 目录不存在if ((parent_dir.size() != 0) && (!IsExist(parent_dir))){mkdir(parent_dir.c_str(), 0775);}// 如果pos等于结束位置,说明目录创建完毕if (pos == std::string::npos){break;}// 更新cur的位置cur = pos + 1;}}

代码整理:

//util.hpp#pragma once#ifndef __M_UTIL_H__#define __M_UTIL_H__#include #include #include #include #include namespace log{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 std::move(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;}}// 获取文件的所在目录路径static std::string GetPath(const std::string& path){size_t pos = path.find_last_of("/\\");if (pos == std::string::npos){return ".";}else{return path.substr(0, pos + 1);}}// 创建目录static void CreateDirectory(const std::string& path){if (path.size() == 0) return;umask(0);// 测试样例:// /home/abc/test//home/abc/test// texttest/// ./test./test/size_t cur = 0, pos = 0;std::string parent_dir;while (cur < path.size()){pos = path.find_first_of("/\\", cur);// 截取父级路径parent_dir = path.substr(0, pos);// 父级路径有效 && 目录不存在if ((parent_dir.size() != 0) && (!IsExist(parent_dir))){mkdir(parent_dir.c_str(), 0775);}// 如果pos等于结束位置,说明目录创建完毕if (pos == std::string::npos){break;}cur = pos + 1;}}};}#endif

2、日志等级类设计

日志等级总共分为7个等级,分别为:

  • OFF 关闭所有日志输出
  • DRBUG 进行debug时候打印日志的等级
  • INFO 打印一些用户提示信息
  • WARN 打印警告信息
  • ERROR 打印错误信息
  • FATAL 打印致命信息- 导致程序崩溃的信息

此处比较简单,我们使用枚举来表示日志等级,然后提供一个toString接口,方便我们将日志等级从枚举转换为字符串,进行日志输出。

我们可以新建一个level.hpp的头文件,在这个文件中实现我们的日志等级类。

// level.hpp#pragma once#ifndef __M_LEVEL_H__#include #include namespace log{class LogLevel{public:enum class Level{UNKONWN = 0,DEBUGE,INFO,WARNNING,ERROR,FATAL,OFF};static std::string ToString(LogLevel::Level level){switch (level){case LogLevel::Level::DEBUGE: return "DEBUGE";case LogLevel::Level::INFO: return "INFO";case LogLevel::Level::WARNNING: return "WARNNING";case LogLevel::Level::ERROR: return "ERROR";case LogLevel::Level::FATAL: return "FATAL";case LogLevel::Level::OFF: return "OFF";default:break;}return "UNKONWN";}};}#endif

3、日志消息类设计

日志消息类主要是封装一条完整的日志消息所需的内容,其中包括日志等级、对应的日志器名称、打印日志源文件的位置信息(包括文件名和行号)、线程ID、时间戳信息、具体的日志信息等内容。

这里的代码设计也很简单,我们定义一个结构体,然后将我们想要的字段全部放入就行了。

我们可以新建一个message.hpp的头文件,在这个文件中实现我们的日志消息类。

#pragma once#ifndef __M_MESSAGE_H__#define __M_MESSAGE_H__#include "level.hpp"#include "util.hpp"#include #include #include namespace log{struct LogMsg{time_t _ctime;// 时间戳LogLevel::Level _level;// 日志等级std::string _logger;// 日志器名称std::thread::id _tid;// 线程idstd::string _file;// 源码文件名size_t _line;// 源码行号std::string _payload;// 有效载荷LogMsg() {}LogMsg(LogLevel::Level level, const std::string logger, const std::string file,size_t line, const std::string payload): _ctime(Date::Now()) // 在创建logMsg时获取日志时间, _level(level), _logger(logger), _tid(std::this_thread::get_id()) // 在创建logMsg时获取线程id, _file(file), _line(line), _payload(payload){}};}#endif

4、日志输出格式化类设计

日志格式化(Formatter)类主要负责格式化日志消息。其主要包含以下内容:

  • pattern成员:保存日志输出的格式字符串,例如:%d{%H:%M:%S}%T[%t]%T[%p]%T[%c]%T%f:%l%T%m%n
日志格式符描述
%d日期
%T缩进
%t线程id
%p日志级别
%c日志器名称
%f文件名
%l行号
%m日志消息
%n换行
  • std::vector items成员:用于按序保存格式化字符串对应的格式化子项对象。

格式化子项

FormatItem类主要负责从日志消息LogMsg中获取信息以及对指定部分进行格式化,其包含以下子类:

  • MsgFormatItem :表示要从LogMsg中取出有效日志数据(即_pyload字段)
  • LevelFormatItem :表示要从LogMsg中取出日志等级(即_level字段)
  • NameFormatItem :表示要从LogMsg中取出日志器名称(即_logger字段)
  • ThreadFormatItem :表示要从LogMsg中取出线程ID(即_tid字段)
  • TimeFormatItem :表示要从LogMsg中取出时间戳并按照指定格式进行格式化(即_ctime字段)
  • CFileFormatItem :表示要从LogMsg中取出源码所在文件名(即_file字段)
  • CLineFormatItem :表示要从LogMsg中取出源码所在行号(即_line字段)
  • TabFormatItem :表示一个制表符缩进
  • NLineFormatItem :表示一个换行
  • OtherFormatItem :表示非格式化的原始字符串,例如[ , ] , : , { , }字符

看到这里你可能觉得很迷糊,我们先来梳理一下:

现在我们的日志格式化类的结构现在大概是这样:

// 格式化器class Formatter{public:// 指向格式化子项对象的智能指针using ptr = std::shared_ptr<Formatter>;// 构造函数Formatter(const std::string& pattern = "[%d{%H:%M:%S}][%p][%c][%t][%f:%l]%T%m%n") :_pattern(pattern){assert(ParsePattern());}private:// 格式化规则字符串std::string _pattern;// 按顺序存储指定的格式化子对象std::vector<FormatItem::ptr> _items;};

所以实际上我们这个类是这样工作的:

  1. 我们给类传入一个格式化字符串,然后用这个字符串初始化pattern成员,如果不进行传入格式化字符串,我们就默认为:[%d{%H:%M:%S}][%p][%c][%t][%f:%l]%T%m%n

  2. 我们解析这个pattern字符串,根据不同的日志格式符给 vector 对象items中添加对应的格式化子类。

  3. 然后提供一个缓冲区buf和一个有数据的LogMsg结构体,让items对象依次调用数组中的子对象对LogMsg的指定部分的格式化,然后将格式化的字符串依次添加到buf中,这样我们通过字符串拼接组成了一条完整的日志消息了。

所以我们必须先要实现每一个格式化子项,这样我们才能拼接形成日志,但是每一个格式化子项都是一个不同的类,(既是它们的功能是相似的),而C++中vector只能保存同一类型的对象,为了能让vector中能保存它们,我们可以使用继承和多态的性质,定义一个基类,所有的格式化子项都去继承这个基类,然后我们的vector只需要保存基类的指针就能够保存所有的格式化子项了。


格式化子项的实现

每一个格式化子项,我们基本都只需要提取指定字段,然后放入流中就行了,下面是格式化子项父类的实现:

// 格式化的父类class FormatItem{public:using ptr = std::shared_ptr<FormatItem>;virtual void Format(std::ostream& out, const LogMsg& msg) = 0;};

下面是格式化子项的实现:

// 日期格式化子项class TimeFormatItem : public FormatItem{public:// 日期格式化子项比较特殊,我们在使用时还需要指定时分秒的格式TimeFormatItem(const std::string& fmt = "%H:%M:%S") :_time_fmt(fmt.size() == 0 " />"%H:%M:%S": fmt){}// 重写父类的格式化接口void Format(std::ostream& out, const LogMsg& msg) override{struct tm t;char buf[32] = { 0 };// 使用C库函数对时间戳进行格式化localtime_r(&msg._ctime, &t);strftime(buf, sizeof(buf), _time_fmt.c_str(), &t);// 放入流中out << buf;}private:std::string _time_fmt;};
// 日志等级格式化子项class LevelFormatItem : public FormatItem{public:void Format(std::ostream& out, const LogMsg& msg) override{// 提取指定字段插入流中out << LogLevel::ToString(msg._level);}};
// 日志器名称格式化子项class LoggerFormatItem : public FormatItem{public:void Format(std::ostream& out, const LogMsg& msg) override{// 提取指定字段插入流中out << msg._logger;}};
// 线程id格式化子项class ThreadFormatItem : public FormatItem{public:void Format(std::ostream& out, const LogMsg& msg) override{// 提取指定字段插入流中out << msg._tid;}};
// 文件名称格式化子项class FileFormatItem : public FormatItem{public:void Format(std::ostream& out, const LogMsg& msg) override{// 提取指定字段插入流中out << msg._file;}};
// 文件行号格式化子项class LineFormatItem : public FormatItem{public:void Format(std::ostream& out, const LogMsg& msg) override{// 提取指定字段插入流中out << msg._line;}};
// 日志有效信息格式化子项class MsgFormatItem : public FormatItem{public: void Format(std::ostream& out, const LogMsg& msg) override{// 提取指定字段插入流中out << msg._payload;}};
// 制表符格式化子项class TabFormatItem : public FormatItem{public:void Format(std::ostream& out, const LogMsg& msg) override{// 提取指定字段插入流中out << "\t";}};
// 新行格式化子项class NLineFormatItem : public FormatItem{public:void Format(std::ostream& out, const LogMsg& msg) override{// 提取指定字段插入流中out << "\n";}};
// 原始字符格式化子项// 由于原始字符是我们想要在日志中添加的字符,在LogMsg中不存在对应的字段,所以我们需要传递参数class OtherFormatItem : public FormatItem{public:// 想要在日志中添加的字符OtherFormatItem(const std::string& str) :_str(str) {}void Format(std::ostream& out, const LogMsg& msg) override{// 将字符串添加到流中out << _str;}private:std::string _str;};

到这里格式化子项我们已经完成了,日志格式化(Formatter)类也完成了一半,我们还要在Formatter 类中添加一些成员函数。

  • 例如解析格式化字符串patternitems数组中添加不同的格式化子项的函数ParsePattern()
  • 以及函数format(),组织items中的成员形成日志,然后输出出去。

所以日志格式化(Formatter)类的完整结构应该是这样的:

// 格式化器class Formatter{public:using ptr = std::shared_ptr<Formatter>;Formatter(const std::string& pattern = "[%d{%H:%M:%S}][%p][%c][%t][%f:%l]%T%m%n") :_pattern(pattern){// 构造时解析必须成功,否则日志就不知以何种方式输出了assert(ParsePattern());}// 将日志输出到指定的流中void Format(std::ostream& out, const LogMsg& msg);// 将日志以返回值的形式进行返回std::string Format(const LogMsg& msg);private:// 解析格式化字符串并填充items数组bool ParsePattern();// 根据不同的格式化字符创建不同的格式化子类,是ParsePattern的子函数FormatItem::ptr CreateItem(const std::string& key, const std::string& val);private:// 格式化规则字符串std::string _pattern;// 按顺序存储指定的格式化对象std::vector<FormatItem::ptr> _items;};

我们先来实现一个简单的接口CreateItem(const std::string& key, const std::string& val);
这个接口要求我们根据不同的格式化字符创建不同的格式化子类。

  • 其中key是我们的格式化字符。
  • val是我们构造每一个格式化子项时要传递的参数。

关于val参数的说明:

  • 例如构造「时间格式化子项」时,我们要传递"%H:%M:%S"这样的参数,所以我们需要val这样的字段帮我们构造我们想要的格式的格式化子项。
  • 又比如当我们创建「日志器名格式化子项」时,我们不需要传递参数,即不需要val,但是为了能够兼容其他的格式换子项,我们还是给这个接口设置了val参数
// 根据不同的格式化字符创建不同的格式化子类,是ParsePattern的子函数FormatItem::ptr CreateItem(const std::string& key, const std::string& val){// 构造日期格式化子项if (key == "d") return std::make_shared<TimeFormatItem>(val);// 构造日志级别格式化子项if (key == "p") return std::make_shared<LevelFormatItem>();// 构造日志器名称格式化子项if (key == "c") return std::make_shared<LoggerFormatItem>();// 构造线程id格式化子项if (key == "t") return std::make_shared<ThreadFormatItem>();// 构造文件名格式化子项if (key == "f") return std::make_shared<FileFormatItem>();// 构造行号格式化子项if (key == "l") return std::make_shared<LineFormatItem>();// 构造日志消息格式化子项if (key == "m") return std::make_shared<MsgFormatItem>();// 构造缩进格式化子项if (key == "T") return std::make_shared<TabFormatItem>();// 构造换行格式化子项if (key == "n") return std::make_shared<NLineFormatItem>();// 没有匹配的格式化字符就构造 其他格式化子项return std::make_shared<OtherFormatItem>(val);}

这个类中最难,最核心的其实就是ParsePattern()接口的实现,这要求我们对字符串处理有很高的水平。

这里讲解一下函数运解析过程:

以这个为例abcd%%e[%d{%H:%M:%S}][%p][%c][%t]%T%m%n

  • 从前往后遍历,如果没有遇到%则说明之前的字符都是原始字符串;
  • 遇到%,则看紧随其后的是不是另一个%,如果是,则认为是在转义字符串,%%等于一个%
  • 如果%后面紧挨着的是格式化字符(c、f、l、t等),则进行处理;
  • 紧随格式化字符之后,如果有{,则认为在{之后、}之前都是子格式内容。

在处理过程中,我们需要将处理的结果保存下来,于是我们可以创建一个一个键值对(key, val)。如果是格式化字符,则key为该格式化字符,valnull或者是子格式化字符串;若为原始字符串则key为null,val为原始字符串内容

// 解析格式化字符串并填充items数组bool ParsePattern(){// 例如 abcd%%e[%d{%H:%M:%S}][%p][%c][%t]%T%m%n// 可以看到上面的示例中 `%` 前面的字符都是非格式化字符// 一、解析格式化字符串// 有效的格式化字符集合std::unordered_set<char> fmt_set = { 'd','p','c','t','f','l','m','T','n' };// 存储格式化字符的顺序// 其中pair的第一个参数是:格式化字符,第二个参数是:创建格式化子项时对应的参数std::vector<std::pair<std::string, std::string>> fmt_order;std::string key, val;// 字符串的处理位置size_t pos = 0;// 当pos越界时,表示pattern解析完毕while (pos < _pattern.size()){// 1. 先处理原始字符串,格式化字符都是以%开头的if (_pattern[pos] != '%'){// 原始字符串只有val,没有keyval.push_back(_pattern[pos++]);continue;}// 1.2 对%%进行处理转义,(走到这里当前位置一定等于%)if (pos + 1 < _pattern.size() && _pattern[pos + 1] == '%'){val.push_back('%');pos += 2;continue;}// 1.3 将组织好原始字符串放入fmt_order中if (!val.empty()){fmt_order.push_back(std::make_pair("", val));val.clear();}// 2 处理格式化字符// 2.1 走到这里说明当前位置是%,下一个字符可能是格式化字符if (pos + 1 >= _pattern.size()){std::cerr << "% 后无格式化字符!" << std::endl;return false;}else{auto it = fmt_set.find(_pattern[pos + 1]);if (it != fmt_set.end()){key.push_back(_pattern[pos + 1]);pos += 2;}else{std::cerr << "格式化字符不正确" << std::endl;return false;}}// 3.处理%d的格式化子串 如{%H:%M:%S}if (pos < _pattern.size() && _pattern[pos] == '{'){size_t end_pos = _pattern.find_first_of('}', pos + 1);if (end_pos == std::string::npos){std::cerr << "格式化子串{}匹配出现问题" << std::endl;return false;}else{val += _pattern.substr(pos + 1, end_pos - pos - 1);pos = end_pos + 1;}}fmt_order.push_back(std::make_pair(key, val));key.clear();val.clear();}// 二、使用解析完毕的数据初始化格式化子项数组for (auto& it : fmt_order){_items.push_back(CreateItem(it.first, it.second));}return true;}

两个核心功能实现完毕以后,我们就要实现Format()接口对外输出日志了。

对于Format接口我们只需要将items中的每一个对象的Format接口调用一遍输出到指定的流中就行了。

// 将日志输出到指定的流中void Format(std::ostream& out, const LogMsg& msg){// 不断循环取出每一个对象for (auto& it : _items){// 对每一个格式化子项调用Format接口,格式化指定部分然后输出到流当中// 当所有的格式化子项都被调用完毕以后就组成了一条完整的日志了it->Format(out, msg);}}// 将日志以返回值的形式进行返回std::string Format(const LogMsg& msg){// 复用 Format(std::ostream& out, const LogMsg& msg) 接口std::stringstream ssm;Format(ssm, msg);return ssm.str();}

到这里我们就已经完成了日志输出格式化类设计,我们可以使用下面的代码进行一个简单的测试:

#include "Formatter.hpp"int main(){mylog::LogMsg msg;msg._ctime = mylog::Date::Now();msg._level = mylog::LogLevel::Level::WARNNING;msg._file = "test.cpp";msg._line = 38;msg._logger = "root";msg._payload = "测试日志";mylog::Formatter formatter("%d{%H:%M:%S}[%p][%c][%f:%l]%T%m%n");std::cout << formatter.Format(msg) << std::endl;return 0;}

结果:

5、日志落地类设计 (简单工厂模式)

  • 功能
    主要负责落地日志消息到目的地。
  • 落地方向
    • 标准输出:StdoutSink
    • 固定文件:FileSink
    • 滚动文件:RollSink

滚动日志文件输出的必要性

  • 由于机器磁盘空间有限, 我们不可能一直无限地向一个文件中增加数据,如果一个日志文件体积太大,一方面是不好打开,另一方面是即时打开了由于包含数据巨大,也不利于查找我们需要的信息。
  • 所以实际开发中会对单个日志文件的大小也会做一些控制,即当大小超过某个大小时(如 1GB),我们就重新创建一个新的日志文件来滚动写日志。 对于那些过期的日志, 大部分企业内部都有专门的运维人员去定时清理过期的日志,或者设置系统定时任务,定时清理过期日志。
  • 日志文件的滚动思想
    日志文件滚动的条件有两个:「文件大小」和 「时间」。我们可以按需要进行选择,本项目基于文件大小的判断滚动生成新的文件:

    • 日志文件在大于 1GB 的时候会更换新的文件
    • 每天定点滚动一个日志文件
  • 类的成员

    • Formatter日志格式化器:主要是负责格式化日志消息,
    • mutex互斥锁:保证多线程日志落地过程中的线程安全,避免出现交叉输出的情况。

为了让这个类支持可扩展,我们将其成员函数log设置为纯虚函数,当我们需要增加一个log输出目标, 可以增加一个类继承自该类并重写log方法实现具体的落地日志逻辑。


// 日志落地基类class LogSink{public:using ptr = std::shared_ptr<LogSink>;// 日志输出接口, data为日志的其实地址,len为日志的长度virtual void log(const char* data, size_t len) = 0;virtual ~LogSink() {};};

  • 日志落地到标准输出

我们可以通过cout接口直接输出到标准输出上。

// 落地方向:标准输出class StdOutSink : public LogSink{public:void log(const char* data, size_t len) override{std::cout.write(data, len);}};

  • 日志输出到指定文件中

我们可以打开文件,然后将日志输出到指定文件中,所以,我们这个类需要外界传入一个文件名,在类内部我们也需要创建一个文件流对象。

// 落地方向:指定文件class FileSink : public LogSink{public:// 通过构造函数,创建文件并打开FileSink(const std::string& pathname) : _pathname(pathname){// 1.检查路径是否存在,不存在就创建if (!File::IsExist(File::GetPath(_pathname))){File::CreateDirectory(File::GetPath(_pathname));}// 2. 创建并打开文件_ofs.open(_pathname, std::ios::binary | std::ios::app);if (!_ofs.is_open()){std::cerr << "FileSink中文件打开失败" << std::endl;abort();}}void log(const char* data, size_t len){_ofs.write(data, len);}private:std::string _pathname;std::ofstream _ofs;};

  • 滚动文件

这个是我们实现的重点:

  1. 为了让我们的文件能够滚动起来,每次在文件中写入日志以后我们都要记录当前文件累计写入了多少字节的数据,所以我们的这个类中要有一个_cur_size字段记录,表示当前文件已经写入了多少字节。

  2. 同时我们还要有一个文件最大限制字段_max_size,表示可以写入的最大字节数,当_cur_size即将大于_max_size时,我们就要切换文件了。

  3. 同一目录下的文件是不能够重名的, 在滚动过程中我们生成的日志文件肯定也不能重名, 所以滚动文件中我们需要外界传递给我们一个文件基础名称,然后我们在这个文件基础名称后面拼接上文件的生成时间,这样生成的日志文件就不会重名了。所以我们类中需要有一个_basename字段,表示文件的基础名称。

  4. 虽然上面防止文件重名的方案已经很好了,但是如果在1秒之内需要生成多个文件(要写入的日志非常多的情况下),我们发现还是会造成文件重名,导致一个日志文件的大小超过设定大小,所以我们还需要加上一个计数器字段_name_cout,每生成一个文件该计数器就会自增一次,然后在生成文件名时,我们通过_basename + 时间信息 + _name_count这样的方式生成文件名,这样不论怎样我们都能保证文件名不会重复。

所以滚动文件的类结构是这样的:

// 落地方向:滚动文件(这里按照文件大小进行滚动)class RollBySizeSink : public LogSink{public:// 创建文件并打开RollBySizeSink(const std::string& basename, size_t max_size);// 输出日志void log(const char* data, size_t len);private:// 得到要生成的日志文件的名称// 通过基础文件名 + 时间组成 + 计数器 生成真正的文件名std::string GetFileName();private:// 基础文件名std::string _basename;// 文件大小限制size_t _max_size;// 当前文件的大小size_t _cur_size;std::ofstream _ofs;// 文件名称计数器(防止一秒之内创建多个文件时,使用同一个名称)size_t _name_cout;};
  1. 我们先来完成文件名获取接口:std::string GetFileName();
    这个接口的实现很简单:我们只需要完成字符串的拼接就行了。
// 得到要生成的日志文件的名称// 通过基础文件名 + 时间组成 + 计数器 生成真正的文件名std::string GetFileName(){// 文件名举例:test-20231123_134222-3.logstd::stringstream ssm;struct tm t = Date::GetTimeSet();ssm << _basename;ssm << '-';ssm << t.tm_year + 1900;ssm << t.tm_mon + 1;ssm << t.tm_mday;ssm << '_';ssm << t.tm_hour;ssm << t.tm_min;ssm << t.tm_sec;ssm << '-';ssm << std::to_string(_name_cout++);ssm << ".log";return ssm.str();}
  1. 接下来就是实现构造函数了,在构造函数中我们需要外界传递给我们「文件基础名称」和「文件限制大小」,然后我们在构造函数中打开这个文件。
// 创建文件并打开RollBySizeSink(const std::string& basename, size_t max_size):_basename(basename), _max_size(max_size), _cur_size(0), _name_cout(0){// 1. 获得文件名std::string filename = GetFileName();// 2. 创建并打开文件_ofs.open(filename, std::ios::binary | std::ios::app);if (!_ofs.is_open()){std::cerr << "RollBySizeSink中文件打开失败" << std::endl;abort();}}
  1. 最后就是要实现日志输出了,此时log函数的逻辑如下:

    • 判断当前日志写入后文件大小是否大于_max_size,如果不大于,就可以直接输出日志。如果大于我们就要切换文件,将日志输出到新文件中。
void log(const char* data, size_t len){// 判断文件是否超出大小if (_cur_size + len > _max_size){// 关闭旧文件_ofs.close();std::string filename = GetFileName();_ofs.open(filename, std::ios::binary | std::ios::app);if (!_ofs.is_open()){std::cerr << "RollBySizeSink中文件打开失败" << std::endl;abort();}// 由于是新文件,所以将当前文件已写入的大小置0_cur_size = 0;}_ofs.write(data, len);_cur_size += len;}

到现在我们已经将三种落地方向实现完毕了,但是现在有一个问题,如果我们想要得到一个落地方向的对象时,我们要这样写:

using namespace mylog;LogSink::ptr logSink = std::make_shared<StdOutSink>();LogSink::ptr logSink = std::make_shared<FileSink>("test");LogSink::ptr logSink = std::make_shared<RollBySizeSink>("test", 1024 * 1024 * 1024);

这里我们使用了三个接口,分别是各个类的构造函数,这样使用起来有一些复杂,特别是当我们的落地方向特别多时,所以我们还可以提供一个工厂类,将所有落地方向的构造函数封装为一个接口。

这样当我们使用时就会方便许多,但是由于不同类的构造函数有不同的参数,所以我们还要使用可变参数模板进行封装。

所以封装的工厂类:

// 落地方向类的工厂类(通过此工厂类实现对落地方向的可扩展性)class SinkFactory{public:// 模板函数template<class SinkType, class ...Args>static LogSink::ptr create(Args&&... args){return std::make_shared<SinkType>(std::forward<Args>(args)...);}};

此时如果我们想要得到一个落地方向的对象时,我们可以这样写:

using namespace mylog;LogSink::ptr logSink = SinkFactory::create<StdOutSink>();LogSink::ptr logSink = SinkFactory::create<FileSink>("./logfile/test");LogSink::ptr logSink = SinkFactory::create<RollBySizeSink>("./logfile/test", 1024 * 1024 * 1024);

此时的接口更加统一了


下面我们可以对日志落地类进行一些简单的测试了:

int main(){using namespace mylog;mylog::LogMsg msg;msg._ctime = mylog::Date::Now();msg._level = mylog::LogLevel::Level::WARNNING;msg._file = "test.cpp";msg._line = 38;msg._logger = "root";msg._payload = "测试日志";mylog::Formatter formatter("%d{%H:%M:%S}[%p][%c][%f:%l]%T%m%n");LogSink::ptr logSink = SinkFactory::create<FileSink>("./logfile/test");// 生成日志std::string s = formatter.Format(msg);LogSink::ptr log_out = SinkFactory::create<StdOutSink>();LogSink::ptr log_file = SinkFactory::create<FileSink>("./logfile/test");// 使用落地方向进行输出log_out->log(s.c_str(), s.size());log_file->log(s.c_str(), s.size());return 0;}

6、 日志器类设计(建造者模式)

  • 为什么要有日志器类
    通过上面日志落地类的测试我们可以看到,使用这个日志进行输出时,很繁琐,很凌乱。
  1. 需要我们手动创建一个LogMsg对象,然后进行填充信息。
  2. 创建一个formatter对象,然后调用Format接口得到格式化的日志。
  3. 创建一个日志落地对象,然后将日志进行落地。

为了让我们的接口更加易用方便,我们可以设置一个日志器类,将上面的模块进行整合,以后用户想要输出日志,只需要实例化一个日志器对象进行了。

  • 日志器类的结构

因为日志器模块是对前边所有模块的一个整合,所以Logger类管理的成员有:

  1. 日志器名称(日志器的唯一标识);
  2. 格式化模块对象(Formatter);
  3. 落地模块对象数组(一个日志器可能会向多个位置进行日志输出);
  4. 默认的输出限制等级(控制达到指定等级的日志才可以输出);
  5. 互斥锁(保证日志输出过程是线程安全的,不会出现交叉日志);

Logger类提供的操作有:

  • debug等级日志的输出操作;
  • info等级日志的输出操作;
  • warn等级日志的输出操作;
  • error等级日志的输出操作;
  • fatal等级日志的输出操作;

由于我们要完成的是:支持同步日志和异步日志两种方式写日志,而两个不同的日志器唯一的区别是它们在日志落地方式上有所不同:

  • 同步日志器:直接对日志消息进行输出;
  • 异步日志器:将日志消息放入缓冲区,由异步线程进行输出。

因此日志器在设计的时候先设计一个Logger基类,在Logger基类的基础上继承出SyncLogger同步日志器和AsyncLogger异步日志器。


  • Logger基类的设计

  • debuginfo等接口的功能是类似的,在设计时,需要外界传递参数有:

    • 出错的源文件文件名
    • 出错行号
    • 日志的有效信息,这个信息是和printf同格式的信息。

至于为什么要传递文件名与行号,因为要避免获取文件名和行号时是在本函数内部;

class Logger{public:using ptr = std::shared_ptr<Logger>;Logger(const std::string logger, LogLevel::Level level, Formatter::ptr formatter,std::vector<LogSink::ptr> sinks):_logger(logger), _limit_level(level), _formatter(formatter), _sinks(sinks){}// 以info等级进行输出void Info(const std::string& file, size_t line, const char* fmt, ...){// 1. 判断当前日志能否输出if (LogLevel::Level::INFO < _limit_level){return;}// 2. 形成有效载荷字符串va_list ap;va_start(ap, fmt);char* payload = NULL;if (vasprintf(&payload, fmt, ap) == -1){perror("vasprintf fail: ");return;}va_end(ap);// 3. 形成LogMsg结构体LogMsg msg(LogLevel::Level::INFO, _logger, file, line, std::string(payload));// 4. 形成日志消息字符串std::string log_message = _formatter->Format(msg);// 5. 将日志消息字符串进行落地log(log_message.c_str(), log_message.size());free(payload);}// 以Warning等级进行输出void Warning(const std::string& file, size_t line, const char* fmt, ...){// 1. 判断当前日志能否输出if (LogLevel::Level::WARNNING < _limit_level){return;}// 2. 形成有效载荷字符串va_list ap;va_start(ap, fmt);char* payload = NULL;if (vasprintf(&payload, fmt, ap) == -1){perror("vasprintf fail: ");return;}va_end(ap);// 3. 形成LogMsg结构体LogMsg msg(LogLevel::Level::WARNNING, _logger, file, line, std::string(payload));// 4. 形成日志消息字符串std::string log_message = _formatter->Format(msg);// 5. 将日志消息字符串进行落地log(log_message.c_str(), log_message.size());free(payload);}// 以Debuge等级进行输出void Debuge(const std::string& file, size_t line, const char* fmt, ...){// 1. 判断当前日志能否输出if (LogLevel::Level::DEBUGE < _limit_level){return;}// 2. 形成有效载荷字符串va_list ap;va_start(ap, fmt);char* payload = NULL;if (vasprintf(&payload, fmt, ap) == -1){perror("vasprintf fail: ");return;}va_end(ap);// 3. 形成LogMsg结构体LogMsg msg(LogLevel::Level::DEBUGE, _logger, file, line, std::string(payload));// 4. 形成日志消息字符串std::string log_message = _formatter->Format(msg);// 5. 将日志消息字符串进行落地log(log_message.c_str(), log_message.size());free(payload);}// 以Error等级进行输出void Error(const std::string& file, size_t line, const char* fmt, ...){// 1. 判断当前日志能否输出if (LogLevel::Level::ERROR < _limit_level){return;}// 2. 形成有效载荷字符串va_list ap;va_start(ap, fmt);char* payload = NULL;if (vasprintf(&payload, fmt, ap) == -1){perror("vasprintf fail: ");return;}va_end(ap);// 3. 形成LogMsg结构体LogMsg msg(LogLevel::Level::ERROR, _logger, file, line, std::string(payload));// 4. 形成日志消息字符串std::string log_message = _formatter->Format(msg);// 5. 将日志消息字符串进行落地log(log_message.c_str(), log_message.size());free(payload);}// 以Fatal等级进行输出void Fatal(const std::string& file, size_t line, const char* fmt, ...){// 1. 判断当前日志能否输出if (LogLevel::Level::FATAL < _limit_level){return;}// 2. 形成有效载荷字符串va_list ap;va_start(ap, fmt);char* payload = NULL;if (vasprintf(&payload, fmt, ap) == -1){perror("vasprintf fail: ");return;}va_end(ap);// 3. 形成LogMsg结构体LogMsg msg(LogLevel::Level::FATAL, _logger, file, line, std::string(payload));// 4. 形成日志消息字符串std::string log_message = _formatter->Format(msg);// 5. 将日志消息字符串进行落地log(log_message.c_str(), log_message.size());free(payload);}const std::string& GetLoggerName(){return _logger;}protected:// 通过log接口让不同的日志器支持同步落地或者异步落地virtual void log(const char* data, size_t len) = 0;protected:// 保护日志落地的锁std::mutex _mtx;// 日志器名称std::string _logger;// 日志限制等级LogLevel::Level _limit_level;// 格式化器Formatter::ptr _formatter;// 落地方向集合std::vector<LogSink::ptr> _sinks;};
  • 同步日志器类设计
    同步日志器设计较为简单,其核心功能就是:遍历日志落地数组,以数组中的各种落地方式进行落地操作;
// 同步日志器class SyncLogger : public Logger{public:SyncLogger(const std::string logger, LogLevel::Level level, Formatter::ptr formatter,std::vector<LogSink::ptr> sinks):Logger(logger, level, formatter, sinks){}protected:// 进行日志输出落地void log(const char* data, size_t len) override{// 对于同一个日志器来说,不能出现交叉输出日志,所以这里先加锁,保证只有一个线程在写日志。std::unique_lock<std::mutex> ulk(_mtx);for (auto& sink : _sinks){if (sink.get() != nullptr){sink->log(data, len);}}}};

到这里,同步日志器我们已经完成了,我们可以进行一个简单的日志器测试了:

int main(){using namespace mylog;// 创建格式化器mylog::Formatter::ptr formatter = std::make_shared<Formatter>();// 创建落地方向对象LogSink::ptr log_out = SinkFactory::create<StdOutSink>();LogSink::ptr log_file = SinkFactory::create<FileSink>("./logfile/test");// 创建落地方向数组std::vector<LogSink::ptr> sinks = { log_out, log_file };// 创建日志器std::string logger_name = "slogger";SyncLogger slogger(logger_name, LogLevel::Level::DEBUGE, formatter, sinks);// 输出日志slogger.Error(__FILE__, __LINE__, "测试日志");return 0;}

执行结果:


日志器建造者类

可以看到我们相比于以前,不需要自己填充LogMsg对象了,简化一些操作,但是 创建格式化器,创建落地方向对象,还是需要我们自己手动处理,而且这些部件很零散,所以我们需要使对这些部件进行整合,整合以后,整个日志器的创建过程会变得较为复杂,为了保持良好的代码风格,编写出优雅的代码,因此日志器的创建这里采用了建造者模式来进行创建。

设计思想

抽象一个日志器建造者类:

  • 设置日志器类型,根据不同类型(同步&异步)日志器的创建放到同一个日志器建造者类中完成。
  • 派生出具体的建造者类,「局部日志器建造者」(用于临时局部使用) 和 「全局日志器建造者」类(后面添加了全局单例管理器之后,将全局日志器添加全局管理)抽象日志器建造者类

建造者类中包含成员

  • logger_type 日志器类型:
  • logger_name 日志器名称:
  • limit_level 日志输出限制等级:
  • formatter 格式化对象:
  • sinks 日志落地数组
// 日志器类型,根据传入的类型不同来决定是构造同步日志器,还是异步日志器enum class LoggerType{// 同步日志器Sync_Logger,// 异步日志器Async_Logger};class LoggerBuilder{public:LoggerBuilder():_logger_type(LoggerType::Async_Logger), _limit(LogLevel::Level::DEBUGE),_async_type(ASYNCTYPE::ASYNC_SAFE){}// 开启非安全模式 (这里与后面的异步日志器有关,这里先不用理解)void BuildEnableUnSafe() { _async_type = ASYNCTYPE::ASYNC_UN_SAFE; }// 构建日志器类型void BuildType(LoggerType logger_type = LoggerType::Async_Logger) { _logger_type = logger_type; }// 构建日志器的名称void BuildName(const std::string& logger_name) { _logger_name = logger_name; }// 构建日志限制输出等级void BuildLevel(LogLevel::Level limit) { _limit = limit; }// 构建格式化器void BuildFormatter(const std::string& pattern = "[%d{%H:%M:%S}][%p][%c][%t][%f:%l]%T%m%n"){_formatter = std::make_shared<Formatter>(pattern);}// 构建落地方向数组template<class SinkType, class ...Args>void BuildLogSink(Args&&... args){LogSink::ptr sink = SinkFactory::create<SinkType>(std::forward<Args>(args)...);_sinks.push_back(sink);}// 构建日志器virtual Logger::ptr Build() = 0;protected:// 异步日志器的写入是否开启非安全模式(这里可以先不用理解) ASYNCTYPE_async_type;// 日志器的类型,同步 or 异步LoggerType _logger_type;// 日志器的名称 (每一个日志器的唯一标识)std::string _logger_name;// 日志限制输出等级LogLevel::Level _limit;// 格式化器Formatter::ptr_formatter;// 日志落地方向数组std::vector<LogSink::ptr> _sinks;};
  • 派生出局部建造者
class LocalLoggerBuilder : public LoggerBuilder{public:Logger::ptr Build() override{// 不能没有日志器名称assert(!_logger_name.empty());// 如果用户没有手动设置过格式化器,就进行构造一个默认的格式化器if (_formatter.get() == nullptr){_formatter = std::make_shared<Formatter>();}// 如果用户没有手动设置过落地方向数组,就进行默认设置一个的落地到标准输出的格式化器if (_sinks.empty()){_sinks.push_back(SinkFactory::create<StdOutSink>());}// 根据日志器的类型构造相应类型的日志器if (_logger_type == LoggerType::Async_Logger){return std::make_shared<AsyncLogger>(_logger_name, _limit, _formatter, _sinks, _async_type);}return std::make_shared<SyncLogger>(_logger_name, _limit, _formatter, _sinks);}};

可以看到,我们进行了大量的默认设置,如果用户只是想要进行简单的日志输出,完全可以直接创建一个建造者类对象,然后直接调用build接口生成日志器对象,然后使用。

  • 日志器建造者类测试

简单使用

int main(){using namespace mylog;LocalLoggerBuilder local_builder;// 切记日志器不能没有名字!local_builder.BuildName("local_logger");// 构建一个同步日志器local_builder.BuildType(LoggerType::Sync_Logger)// 直接构造日志器Logger::ptr default_logger = local_builder.Build();// 进行日志输出default_logger->Error(__FILE__, __LINE__, "测试日志器建造者类");return 0;}

根据需要进行定制:

int main(){using namespace mylog;LocalLoggerBuilder local_builder;// 切记日志器不能没有名字!local_builder.BuildName("local_logger");// 设置日志器类型local_builder.BuildType(LoggerType::Sync_Logger);// 设置落地方向local_builder.BuildLogSink<FileSink>("test.log");// 设置日志输出格式local_builder.BuildFormatter("[%d{%H:%M:%S}][%p][%f:%l]%T%m%n");// 构造日志器Logger::ptr default_logger = local_builder.Build();// 进行日志输出default_logger->Error(__FILE__, __LINE__, "测试日志器建造者类");return 0;}

7、双缓冲区异步任务处理器设计(AsyncLooper)

同步日志器完成以后我们就要设计异步日志器了,异步日志器的工作流程:

  • 设计思想:异步处理线程 + 任务池

使用者将需要完成的任务添加到任务池中,由异步线程来完成任务的实际执行操作。

  • 任务池的设计思想:双缓冲区阻塞数据池

在任务池的设计中,有很多备选方案,比如阻塞队列,环形缓冲区等等,但是不管是哪一种都会涉及到锁冲突的情况,因为在生产者与消费者模型中,任何两个角色之间都具有互斥关系,因此每一次的任务添加与取出都有可能涉及锁的冲突。

而双缓冲区不同,双缓冲区是处理器将一个缓冲区中的任务全部处理完毕后,然后交换两个缓冲区,重新对新的缓冲区中的任务进行处理。

  • 使用双缓冲区的优点
  1. 在大部分的时间中,业务线程和日志线程不会操作同一个缓冲区,(只有缓冲区交换时才会产生一次锁冲突)这也就意味着业务线程的操作,不需要等待日志线程缓慢的写文件操作,极大的减少了生产者与消费者之间的锁冲突
  2. 日志线程把缓冲区中的日志,写入到文件系统中的频率,完全由自己的写入策略来决定,避免了每条新日志信息都会唤醒后台日志线程,减少了线程唤醒的频率,降低开销。(换言之,业务线程不是将一条条日志信息分别传送给日志线程,而是将多条信息拼成一个大的 buffer 传送给后端的日志线程进行批量处理)

  • 缓冲区中存放的数据种类
    这里的缓冲区中我们直接存放格式化后的日志消息字符串,而不是LogMsg对象,这样做有两个好处:

    • 减少了LogMsg对象频繁的构造的消耗;
    • 可以针对缓冲区中的日志消息,一次性进行IO操作,减少IO次数,提高效率。

异步缓冲区类的设计

  • 类中包含的成员:

    • 一个存放字符串数据的缓冲区(使用vector进行空间管理);
    • 当前写入数据位置的指针(指向可写区域的起始位置,避免数据的写入覆盖);
    • 当前读取数据位置的指针(指向可读区域的起始位置,当读取指针与写入指针指向相同的位置表示数据读取完了);
  • 类中提供的操作:

    • 向缓冲区中写入数据;
    • 获取可读数据起始地址的接口;(这里不提供获取可读数据的内容,原因是:为了减少拷贝次数,我们让外界直接使用缓冲区中的数据)
    • 获取可读数据长度的接口;
    • 移动读写位置的接口;
    • 初始化缓冲区的操作(将读和写位置初始化为0位置,在一个缓冲区所有数据处理完毕之后,我们才进行初始化);
    • 提供交换缓冲区的操作(交换缓冲区的地址,并不交换空间数据)。
// 缓冲区默认大小const size_t default_buffer_size = 1 * 1024 * 1024;// 阈值const size_t threshold = 2 * 1024 * 1024;// 大于阈值以后每次扩容的自增值const size_t increament = 1 * 1024 * 1024;class Buffer{public:Buffer(size_t buffer_size = default_buffer_size) : _buffer(buffer_size), _read_idx(0), _write_idx(0){}// 将数据放入缓冲区中void Push(const char* data, size_t len){// 1. 判断空间是否足够if (len > WriteableSize()) Resize(len);// 2. 将数据写入缓冲区std::copy(data, data + len, &_buffer[_write_idx]);// 3. 更新数据的写入位置MoveWriteIdx(len);}// 移动可读位置void MoveReadIdx(size_t len){// 防止外界传入的参数不合法if (len > ReadableSize()){_read_idx = ReadableSize();}else{_read_idx += len;}}// 返回可写空间大小 size_t WriteableSize() { return _buffer.size() - _write_idx; }// 返回可读空间大小size_t ReadableSize() { return _write_idx - _read_idx; }// 返回可读数据的起始地址 const char* Start() { return &_buffer[_read_idx]; }// 重置缓冲区void reset(){_write_idx = 0;_read_idx = 0;}// 交换缓冲区void swap(Buffer& buf){std::swap(_write_idx, buf._write_idx);std::swap(_read_idx, buf._read_idx);_buffer.swap(buf._buffer);}// 数据判空bool Empty(){return _write_idx == _read_idx;}private:// 移动可写位置void MoveWriteIdx(size_t len){assert(_write_idx + len <= _buffer.size());_write_idx += len;}// 扩容void Resize(size_t len){size_t new_size;if (_buffer.size() < threshold){// 小于阈值之前进行2倍扩容new_size = _buffer.size() * 2 + len;}else{new_size = _buffer.size() + increament + len;}_buffer.resize(new_size);}private:// 日志缓存区std::vector<char> _buffer;// 可读位置的起始下标size_t _read_idx;// 可写位置的起始下标size_t _write_idx;};

当异步缓冲区实现完毕以后,我们就要实现异步工作器了。

  • 异步工作器的主要任务是:对消费缓冲区中的数据进行处理,若消费缓冲区中没有数据了则交换缓冲区。

  • 异步工作器类管理的成员有:

    • 双缓冲区(生产,消费);
    • 互斥锁:保证线程安全;
    • 条件变量 :生产&消费:如果生产缓冲区中没有数据,则日志线程处理完消费缓冲区数据后就休眠;
    • 回调函数:针对缓冲区中数据的处理接口——外界传入一个函数,告诉异步日志器该如何处理。
  • 异步工作器类提供的操作有

    • 停止异步工作器;
    • 添加数据到缓冲区;
  • 类内部私有操作

    • 线程入口函数:在线程入口函数中交换缓冲区,对消费缓冲区数据使用回调函数进行处理,处理完后再次交换;
  • 关于缓冲区的大小是否是可变的讨论

我们知道C++的vector是支持动态扩容的,如果我们不加限制,它会一直进行扩容,直到无法扩容为止。

缓冲区大小可变

缺点:如果我们让对vector不加以限制,任其进行扩容,可能会造成内存相关的问题(如:扩容时找不到一块足够大的连续空间,内存占用过高)。
优点:对vector不限制扩容能够让日志写入线程的写入量和写入速度最大化。

缓冲区大小不可变
缺点:当生产线程的生产速度大于消费者的消费速度时,生产线程会被阻塞。

优点:不会出现内存相关问题,内存大小可控。

为了满足不同的需求,我们将这两种方式都进行实现,设置缓冲区大小不可变为安全模式,并默认使用

enum class ASYNCTYPE{// 缓冲区安全ASYNC_SAFE,// 缓冲区非安全ASYNC_UN_SAFE};class AsynLopper{public:using cb_t = std::function<void(Buffer&)>;using ptr = std::shared_ptr<AsynLopper>;AsynLopper(cb_t call_back, ASYNCTYPE type = ASYNCTYPE::ASYNC_SAFE):_type(type), _stop(false), _call_back(call_back), _td(&AsynLopper::ThreadEntry, this){}// 向生产者缓冲区放入数据void Push(const char* data, size_t len){{// 1.先对生产者缓冲区进行加锁std::unique_lock<std::mutex> ulk(_mtx_pro_buf);// 2.进行条件判断,如果生产者缓冲区空间足够则进行写入,否则就阻塞在生产者条件变量上面if (_type == ASYNCTYPE::ASYNC_SAFE){_cond_pro.wait(ulk, [&]() {return _pro_buf.WriteableSize() >= len;});}// 3.条件满足,进行数据写入_pro_buf.Push(data, len);}// 4.写入完毕,通知消费者进行数据处理_cond_con.notify_one();}// 停止异步线程的工作void Stop(){_stop = true;// 唤醒异步线程,进行退出_cond_con.notify_all();// 回收异步线程_td.join();}~AsynLopper(){// 停止异步线程Stop();}private:// 异步线程的入口函数void ThreadEntry(){while (true){{// 1. 判断生产者缓冲区是否有数据,有则进行交换,无则在消费者者条件变量上面进行等待std::unique_lock<std::mutex> ulk(_mtx_pro_buf);// 如果_stop为真,也可以进行向下运行,为了保证数据能够写入完毕以后再进行退出_cond_con.wait(ulk, [&]() {return _stop || _pro_buf.ReadableSize() > 0;});// 退出标志被设置且生产者缓冲区没有数据,才可以退出if (_stop && _pro_buf.Empty()){break;}_pro_buf.swap(_con_buf);}// 2. 通知生产者进行数据写入if (_type == ASYNCTYPE::ASYNC_SAFE){_cond_pro.notify_all();}// 3. 消费者开始进行数据处理_call_back(_con_buf);// 4. 数据处理完毕,重新初始化消费缓冲区_con_buf.reset();}}private:// 异步工作器的安全类型ASYNCTYPE _type;// 线程的工作状态std::atomic<bool>_stop;// 保护生产者缓冲区的锁std::mutex _mtx_pro_buf;// 生产者缓冲区Buffer _pro_buf;// 消费者缓冲区Buffer _con_buf;// 生产者条件变量std::condition_variable _cond_pro;// 消费者条件变量std::condition_variable _cond_con;// 异步线程对象std::thread _td;// 线程对象的回调函数cb_t _call_back;};

异步工作器完成以后我们就可以完成我们的异步日志器了:

异步日志器对于基类中写日志操作进行函数log进行重写,不再将数据直接写入文件,而是通过异步消息处理器,放到异步线程缓冲区中,然后由异步线程进行写入落地。

  • 类内操作

    • log函数为重写Logger类的函数,主要实现将日志日志数据加入异步缓冲区;
    • RealSink函数主要由异步线程调用(是为异步工作器设置的回调函数),完成日志的实际落地操作。
  • 类内成员
    由于基类Logger中的成员已经很完善了,我们的异步日志器又是继承于基类Logger,所以我们没有必要再进行添加一些其他日志属性了,只需要添加一个异步工作器作为我们异步日志器的成员就行了。

// 异步日志器class AsyncLogger : public Logger{public:AsyncLogger(const std::string logger, LogLevel::Level level, Formatter::ptr formatter,std::vector<LogSink::ptr> sinks, ASYNCTYPE type):Logger(logger, level, formatter, sinks),_lopper(std::bind(&AsyncLogger::RealSink, this, std::placeholders::_1), type){}protected:void log(const char* data, size_t len) override{// 将数据放入异步缓冲区(这个接口是线程安全的因此不需要加锁)_lopper.Push(data, len);}// 异步线程调用此函数,用于真正地将数据落地void RealSink(Buffer& buf){// 异步线程根据落地方向进行数据落地for (auto& sink : _sinks){if (sink.get() != nullptr){sink->log(buf.Start(), buf.ReadableSize());}}}protected:// 异步工作器AsynLopper _lopper;};

8、单例日志器管理类设计(单例模式)

日志的输出,我们希望能够在任意位置都可以进行,但是当我们创建了一个日志器之后,就会受到日志器所在作用域的访问属性限制。

因此,为了突破访问区域的限制,我们创建一个日志器管理类,且这个类是一个单例类,这样的话,我们就可以在任意位置来通过管理器单例获取到指定的日志器来进行日志输出了。

基于单例日志器管理器的设计思想,我们对于日志器建造者类进行继承,继承出一个全局日志器建造者类,实现一个日志器在创建完毕后,直接将其添加到单例的日志器管理器中,以便于能够在任何位置通过日志器名称能够获取到指定的日志器进行日志输出。

  • 日志器管理器的作用
  1. 对所有创建的日志器进行管理;
  2. 可以在程序的任意位置进,获取相同的单例对象,获取其中的日志器进行日志输出;

单例日志器管理类的成员

  • 默认日志器;
  • 所管理的日志器数组(使用哈希表,日志器名称为key,日志器对象为value);
  • 互斥锁;

单例日志器管理类管理的方法

  • 添加日志器管理;
  • 判断是否管理了指定名称的日志器;
  • 获取指定名称的日志器;
  • 获取默认日志器;
class LogManager{public:// 得到实例化对象static LogManager& GetInstance(){static LogManager ins;return ins;}// 添加日志器void AddLogger(const Logger::ptr& logger){if (HasLogger(logger->GetLoggerName())){return;}std::unique_lock<std::mutex> ulk(_mtx_loggers);_loggers[logger->GetLoggerName()] = logger;}// 判断日志器集合中是否存在指定的日志器bool HasLogger(const std::string& logger_name){std::unique_lock<std::mutex> ulk(_mtx_loggers);return _loggers.find(logger_name) != _loggers.end();}// 返回默认日志器const Logger::ptr& DefaultLogger(){return _default_logger;}// 返回指定日志器const Logger::ptr& GetLogger(const std::string& logger_name){if (!HasLogger(logger_name)){return Logger::ptr();}else{return _loggers[logger_name];}}private:LogManager(){std::unique_ptr<LoggerBuilder> builder(new LocalLoggerBuilder());builder->BuildName("default");_default_logger = builder->Build();AddLogger(_default_logger);}LogManager(const LogManager&) = delete;private:// 用于保证_loggers(日志器对象集合)线程安全的锁std::mutex _mtx_loggers;// 默认logger日志器Logger::ptr _default_logger;// 日志器对象集合std::unordered_map<std::string, Logger::ptr> _loggers;};

全局建造者类设计
为了降低用户的使用复杂度,我们提供一个全局日志器建造者类。通过全局建造者建造出的对象会自动添加到LogManager对象中,降低了用户的使用复杂度。

// 全局建造者,通过全局建造者建造出的对象会自动添加到LogManager对象中,降低了用户的使用复杂度class GlobalLoggerBuilder : public LoggerBuilder{public:Logger::ptr Build() override{assert(!_logger_name.empty());if (_formatter.get() == nullptr){_formatter = std::make_shared<Formatter>();}if (_sinks.empty()){_sinks.push_back(SinkFactory::create<StdOutSink>());}Logger::ptr logger;if (_logger_type == LoggerType::Async_Logger){logger = std::make_shared<AsyncLogger>(_logger_name, _limit, _formatter, _sinks, _async_type);}else{logger = std::make_shared<SyncLogger>(_logger_name, _limit, _formatter, _sinks);}LogManager::GetInstance().AddLogger(logger);return logger;}};

此时我们的日志系统已经基本完成了,下面我们来测试一下我们的异步日志器以及单例日志器管理类:

void log_test(){using namespace mylog;Logger::ptr logger = LogManager::GetInstance().GetLogger("async_logger");logger->Debuge(__FILE__, __LINE__, "%s", "测试日志");logger->Info(__FILE__, __LINE__, "%s", "测试日志");logger->Warning(__FILE__, __LINE__, "%s", "测试日志");logger->Error(__FILE__, __LINE__, "%s", "测试日志");logger->Fatal(__FILE__, __LINE__, "%s", "测试日志");size_t count = 0;while (count < 300){logger->Fatal(__FILE__, __LINE__, "测试日志-%d", count++);}}int main(){using namespace mylog;// 创建全局日志器建造者std::unique_ptr<mylog::LoggerBuilder> builder(new mylog::GlobalLoggerBuilder());// 构造内部组件builder->BuildName("async_logger");builder->BuildLevel(LogLevel::Level::WARNNING);builder->BuildFormatter("[%c]%m%n");builder->BuildType(mylog::LoggerType::Async_Logger);builder->BuildEnableUnSafe();builder->BuildLogSink<FileSink>("./logfile/async.log");builder->BuildLogSink<StdOutSink>();// 构造出日志器对象并将其添加到日志器管理单例中builder->Build();log_test();return 0;}

标准输出中:


文件中:

9、日志宏与全局接口设计

设计思想:

  • 提供获取指定日志器的全局接口(避免用户自己操作单例对象);
  • 使用宏函数对日志器的接口进行代理(代理模式,避免用户每次日志输出时都要传递__FILE____LINE__);
  • 提供宏函数,直接通过默认日志器进行日志的标准输出打印(省去获取日志器的操作);
// 1.提供一个全局接口来得到指定的日志器对象const Logger::ptr& GetLogger(std::string name){return LogManager::GetInstance().GetLogger(name);}// 2.提供一个全局接口来得到默认日志器对象const Logger::ptr& DefaultLogger(){return LogManager::GetInstance().DefaultLogger();}// 3.使用宏函数简化用户的传参,自动填充行号和文件名#define Debuge(fmt, ...) Debuge(__FILE__, __LINE__, fmt, ##__VA_ARGS__)#define Info(fmt, ...) Info(__FILE__, __LINE__, fmt, ##__VA_ARGS__)#define Warning(fmt, ...) Warning(__FILE__, __LINE__, fmt, ##__VA_ARGS__)#define Error(fmt, ...) Error(__FILE__, __LINE__, fmt, ##__VA_ARGS__)#define Fatal(fmt, ...) Fatal(__FILE__, __LINE__, fmt, ##__VA_ARGS__)// 4.给用户使用的宏函数#define DEBUGE(fmt,...) log::DefaultLogger()->Debuge(fmt, ##__VA_ARGS__)#define INFO(fmt,...) log::DefaultLogger()->Info(fmt, ##__VA_ARGS__)#define WARNING(fmt,...) log::DefaultLogger()->Warning(fmt, ##__VA_ARGS__)#define ERROR(fmt,...) log::DefaultLogger()->Error(fmt, ##__VA_ARGS__)#define FATAL(fmt,...) log::DefaultLogger()->Fatal(fmt, ##__VA_ARGS__)

全局接口测试

int main(){using namespace mylog;DEBUG("%s", "测试日志");INFO("%s", "测试日志");WARNING("%s", "测试日志");ERROR("%s", "测试日志");FATAL("%s", "测试日志");size_t count = 0;while (count < 30){FATAL("测试日志-%d", count++);}return 0;}

测试结果:

三、性能测试

下面对日志系统做一个性能测试,测试一下平均每秒能打印多少条日志消息到文件。

  • 主要的测试是:每秒能打印日志数 = 总打印的日志条数 / 总的打印日志消耗时间
  • 主要测试要素:同步/异步 & 单线程/多线程

我们使用100w条长度为100字节的日志来测试, 每秒可以输出多少条日志,每秒可以输出多少KB日志。

测试环境:

  • CPU:阿里云 2核(vCPU) ecs.e-c1m1.large类型
  • RAM:阿里云 2G
  • ROM:阿里云 ESSD Entry云盘
  • OS:Centos-7.9
/// @brief 性能测试接口/// @param log_name 日志器名称/// @param thread_num 线程数量/// @param log_num 日志数量/// @param log_size 单条日志大小void bench(const std::string& log_name, size_t thread_num, size_t log_num, size_t log_size){// 1.得到日志器mylog::Logger::ptr logger = mylog::GetLogger(log_name);if (logger.get() == nullptr){return;}// 2.创建日志消息,分配每个线程输出的日志数量std::string msg(log_size - 1, 'A'); // 这里-1是为了在其后面添加\n,便于日志输出结果的观察size_t thr_per_num = log_num / thread_num;std::cout << "日志写入总条数:" << log_num << std::endl;std::cout << "每条日志的大小:" << log_size << std::endl;std::cout << "线程数目:" << thread_num << std::endl;std::cout << "每个线程的写入条数:" << thr_per_num << std::endl;std::cout << "---------------------------------------------------" << std::endl;// 3.创建线程std::vector<std::thread> threads;// 线程对象数组std::vector<double> cost(thread_num);// 每个线程消耗的时间for (int i = 0; i < thread_num; i++){threads.emplace_back([&, i]() {// 4.开始计时auto begin = std::chrono::high_resolution_clock::now();// 5.进行日志输出for (int j = 0; j < thr_per_num; j++){logger->Fatal("%s\n", msg.c_str());}// 6.结束计时auto end = std::chrono::high_resolution_clock::now();std::chrono::duration<double> time_span = end - begin;// 将消耗时间放入cost数组中cost[i] = time_span.count();std::cout << "线程:" << i << ", 写入消耗时间:" << time_span.count() << std::endl;});}// 回收线程for (int i = 0; i < thread_num; i++){threads[i].join();}// 7.计算:每秒输出日志数,每秒输出日志大小// 消耗的总时间是cost数组中的最大值,因为线程是并发执行的double max_time = cost[0];for (size_t i = 1; i < cost.size(); i++){max_time = cost[i] > max_time " />[i] : max_time;}// 每秒输出的日志数目size_t num_per_sec = log_num / max_time;// 每秒输出的字节大小(KB)size_t size_per_sec = (log_num * log_size) / (max_time * 1024);// 8.将计算结果输出std::cout << "---------------------------------------------------" << std::endl;std::cout << "消耗总时间:" << max_time << std::endl;std::cout << "每秒输出的日志数目" << num_per_sec << "/s" << std::endl;std::cout << "每秒输出的字节大小" << size_per_sec << "KB" << std::endl;}

同步日志测试:

/// @brief 同步日志器性能测试/// @param thread_num 线程数目void sync_bench(size_t thread_num = 1){// 1.建造日志器对象mylog::GlobalLoggerBuilder g_builder;g_builder.BuildName("Sync_logger");g_builder.BuildType(mylog::LoggerType::Sync_Logger);g_builder.BuildLevel(mylog::LogLevel::Level::DEBUGE);g_builder.BuildFormatter("%m");// 设置落地方向g_builder.BuildLogSink<mylog::FileSink>("./logfile/syncfile.mylog");g_builder.Build();bench("Sync_logger", thread_num, 1000000, 100);}

异步日志测试:

/// @brief 异步日志器性能测试/// @param thread_num 线程数目void async_bench(size_t thread_num = 1){// 1.建造日志器对象mylog::GlobalLoggerBuilder g_builder;g_builder.BuildName("Async_logger");g_builder.BuildType(mylog::LoggerType::Async_Logger);g_builder.BuildLevel(mylog::LogLevel::Level::DEBUGE);g_builder.BuildFormatter("%m");// 开启非安全模式,在测试中将实际异步落地消耗的时间排除g_builder.BuildEnableUnSafe();// 设置落地方向g_builder.BuildLogSink<mylog::FileSink>("./logfile/asyncfile.mylog");g_builder.Build();bench("Async_logger", thread_num, 1000000, 100);}

日志测试代码:

int main(){// 第一次测试sync_bench(1);// 第二次测试// sync_bench(4);// 第三次测试// async_bench(1);// 第四次测试// async_bench(4);return 0;}

同步单线程情况下:

同步四线程情况下:


异步单线程情况下:

异步四线程情况下:

能够通过上面的测试能看出来一些情况:

  • 在单线程情况下,异步效率看起来和同步差不多,这是因为现在的IO操作在用户态都会有缓冲区进行缓冲区,因此我们当前测试用例看起来的同步其实大多时候也是在操作内存,只有在缓冲区满了才会涉及到阻塞写磁盘操作,而且异步单线程日志操作存在的锁冲突,因此性能也会有一定的降低。 所以综合来说二者差距不大。

  • 此外,我们还能看到,在同步状态下单线程和四线程差别不大,这是因为限制同步日志效率的最大原因是磁盘性能,打日志的线程多少并无明显区别,线程多了反而会降低,因为增加了磁盘的读写争抢。

  • 而对于异步日志的限制,并非磁盘的性能,而是cpu和内存的处理性能,打日志并不会因为落地而阻塞,因此在异步多线程打日志的情况下性能有了显著的提高。


好了到这里,这个项目就完成了,感谢你能看到这里!╰(°▽°)╯