日志系统¶
约 3937 个字 280 行代码 预计阅读时间 17 分钟
日志与日志系统介绍¶
计算机中的日志是记录系统和软件运行中发生事件的文件,主要作用是监控运行状态、记录异常信息,帮助快速定位问题并支持程序员进行问题修复。它是系统维护、故障排查和安全管理的重要工具
一般情况下,日志会包含以下的内容:
- 时间
- 日志等级
- 日志内容
有些日志也有可能还包含下面的内容:
- 文件名
- 当前日志在文件中所在的行号
- 进程或者线程信息
在Linux操作系统中也会有一些日志信息,可以使用下面的指令查看当前操作系统的日志文件:
Bash | |
---|---|
1 |
|
查看日志文件内容例如:
Bash | |
---|---|
1 2 3 4 |
|
日志实际上也有一些现成的解决方案,例如spdlog、glog、Boost.Log、Log4cXx等等。本次日志系统就是为了实现一个简易版的日志系统,显示出来的日志效果如下:
C++ | |
---|---|
1 2 3 4 5 6 7 |
|
设计日志系统¶
根据上面的日志效果,考虑让系统自动生成除了消息内容以外的信息,包括时间、日志等级、进程pid
、文件名和行号
本次设计日志系统考虑使用一种设计模式:策略模式。策略模式是一种行为型设计模式,其核心思想是将算法或行为封装为独立对象,使它们可以在运行时动态替换,从而避免复杂的条件判断并提升代码扩展性。
一般来说,策略模式核心组成如下:
- 抽象策略类(Strategy):用于定义算法的公共接口,声明策略的通用行为。
- 具体策略类(ConcreteStrategy):实现抽象策略接口,封装具体算法(如不同的支付方式、折扣策略)。
- 上下文类(Context):持有策略对象的引用,通过接口调用具体策略的算法,对外屏蔽实现细节
根据这个策略模式,考虑整体的设计思路:
首先是定义一个抽象策略类,类名设为LogStrategy
,既然这个类作为了抽象策略类,那么根据抽象策略类的作用考虑定义日志系统的日志处理方式的抽象虚函数printLog(const std::string &message)
,该函数的作用是让具体策略类实现具体的日志处理方式,在本次日志系统中,日志处理方式分为两种:
- 将日志打印到控制台
- 将日志写入指定目录下的指定文件中(目录和文件均可以由用户额外指定)
既然有两种日志处理方式,那么对应的具体策略类就有两种:
ConsoleLogStrategy
类:表示日志输出到控制台,对应地实现方法printLog(const std::string &message)
,函数内部主要逻辑就是将日志内容输出到控制台FileLogStrategy
类:表示日志输出到文件,对应地实现方法printLog(const std::string &message)
,函数内部主要逻辑就是将日志输出到文件
设计完这两个类后,整体的日志系统就有了处理方式,但是现在还缺少处理日志方式控制的类,所以还需要一个类用于处理日志方式控制,定义为LogHandle
类,这个类就是策略模式中的上下文类
既然是处理日志信息控制,那么肯定少不了的就是确定日志信息的输出位置,本次考虑默认输出位置为控制台,同时提供两个函数enableConsoleLog()
和enableFileLog()
分别表示启用控制台打印和启用输出到文件
有了日志输出方式的定义和控制,接下来就是考虑如何确定日志信息内容,对于日志信息内容,原则上并不属于控制输出位置类,所以考虑单独创建一个类,但是如果单独创建一个类,那么该类中就需要有LogHandle
类的对象作为成员以便可以在输出时可以直到输出位置。这里可以考虑两种方式:
- 组合
- 内部类
本次考虑使用内部类的方式,类名设定为LogMessage
,这个类主要用于合成日志信息
以上就是日志系统的基本思路和相关的类,下面针对每个类的具体设计进行详细介绍
实现日志系统¶
前置工作¶
在日志系统中需要使用日志等级,所以可以考虑使用一个枚举类,类名为LogLevel
,一共包括5种等级:
DEBUG
INFO
WARNING
ERROR
FATAL
参考代码如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
策略模式(抽象策略类)¶
根据对策略模式的认识设计抽象策略类,可以考虑下面的思路:
- 需要一个抽象虚函数
printLog()
,后面这个虚函数要被子类重写 - 将虚构函数设计为虚函数,确保先析构子类再析构父类防止有内存泄漏问题
参考代码如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 |
|
策略模式(具体策略类)¶
根据前面的描述,具体策略类需要有两个类,分别是ConsoleLogStrategy
类和FileLogStrategy
类,具体作用见上方设计日志系统部分,下面考虑设计思路:
对于ConsoleLogStrategy
类来说,首先继承父类LogStrategy
,因为该类需要将日志内容打印到控制台中,所以少不了需要重写并且实现父类的方法,在该方法中,根据前面多线程打印的经验:「如果多个线程同时打印,那么内容会出现错乱的问题」,所以在打印之前需要先使用互斥锁,每个线程需要先拿到这把锁才能开始打印。根据这个思路,在ConsoleLogStrategy
类中,需要一个互斥锁成员,在实现printLog()
函数时先抢锁,再开始打印,参考代码如下:
Note
需要注意,下面的代码使用到了前面封装的互斥锁
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
对于FileLogStrategy
来说,同样是先继承父类LogStrategy
,因为该类是为了将日志信息输出到文件中,在本日志系统中,考虑将日志文件log.txt
放在一个单独的目录log
中,其中的log.txt
和log
表示默认的文件名和路径名,所以可以考虑设置为缺省参数,对应地就需要两个变量分别记录用户指定位置和指定文件名,初始化时使用log
和log.txt
作为初始值。但是需要注意的是这一步只是完成了指定目录名和文件名,并没有实际创建一个实际的目录和文件,所以可以考虑在创建FileLogStrategy
类时就创建对应的目录和文件,此时就需要判断目录和文件是否存在
对于判断目录是否存在可以使用<filesystem>
库中的exists
接口,该接口接收一个参数,表示目录位置,如果目录不在就可以使用系统接口mkdir
创建目录,也可以考虑使用<filesystem>
库中的create_directories
接口,该接口接收一个参数,表示目录位置,考虑到这个函数可能会因为创建失败抛出异常,可以考虑使用try_catch
捕捉异常,异常类型为filesystem_error
Note
需要注意,<filesystem>
库是C++17才支持的
对于判断文件是否存在其实不需要,因为如果文件存在就在该文件中写,如果不存在就新建,所以不需要单独判断文件是否存在,只需要考虑不存在就创建,否则就追加的思路
Note
注意,日志文件中的写不建议是覆盖写,如果覆盖写就会导致之前的日志被清除
本次考虑使用C++ IO流打开文件,本次考虑使用既可以输入又可以输出的fstream
,对于写方法,可以考虑使用write
函数,但是更方便的还是使用流插入<<
,写完文件后关闭文件即可
同样,如果是多个线程,此时就只能允许一个线程写文件防止文件内容错乱,既然如此,同样需要考虑在哪加锁,为了防止一个文件被多个线程重复打开,所以考虑在打开文件直接就进行加锁,文件关闭后再解锁
参考代码如下:
C++ | |
---|---|
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 |
|
策略模式(上下文类)¶
对于上下文类LogHandler
,少不了的就是父类指针或者引用作为成员指向子类成员,因为默认考虑的是将日志输出到控制台,所以当该类创建对象时,其父类指针或者引用成员指向ConsoleLogStrategy
类对象,这里可以考虑使用智能指针,在初始化时使用ConsoleLogStrategy
类对象:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
接着,在设计日志系统部分提到,LogHandler
类需要提供两个函数,分别为enableConsoleLog()
和enableFileLog()
,表示启用控制台打印和启用输出到文件,这个切换实际上就是改变父类指针的指向,所以在enableConsoleLog
函数中让成员_log
指向ConsoleLogStrategy
对象,在enableFileLog()
函数中让成员_log
指向FileLogStrategy
对象:
C++ | |
---|---|
1 2 3 4 |
|
C++ | |
---|---|
1 2 3 4 5 |
|
Note
默认的就是启用控制台输出,提供enableConsoleLog
是便于从文件输出切换回控制台输出
上面已经解决了日志信息的控制,接下来就是处理日志信息内容,在设计日志系统部分已经提到过使用内部类的方式完成本部分,所以接下来主要考虑内部类的实现思路:
定义一个LogMessage
类,该类就是用于处理日志信息内容,在日志信息内容中,时间和进程pid
是可以直接通过函数调用获取的,但是日志等级、文件名、行号都必须通过外部行为获取,所以在LogMessage
构造函数中需要对这些进行接收。添加需要相关的成员和实现,整体代码如下:
Note
下面的代码使用到了前面封装的获取时间函数,但是对细节进行了修改:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
接下来就是根据已有的信息按照需要的格式进行拼接,本次考虑在LogMessage
对象初始化时就进行拼接,因为上面的数据涉及到各种数据类型,所以考虑使用字符串流stringstream
完成拼接,拼接的结果放到一个成员变量_message
中,用于之后与日志的自定义内容进行拼接。但是拼接之前还要考虑一个问题,在日志系统的预期效果中,日志等级是显示日志等级对应的名称而不是编号,但是日志等级枚举类直接获取为一个编号,其次枚举类型不能直接输出到流中,所以考虑额外定义一个函数level2string
处理这种情况
这个函数的主要思路就是根据不同的枚举值返回不同的字符串,所以直接穷举结果即可,因为枚举值是一个常量,所以可以考虑使用switch_case
语句:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
拼接代码如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
有了基本信息之后,接下来就是获取用户输入的基本信息,本次考虑基本的使用方法是通过流插入的方式,即类似于下面的方式:
C++ | |
---|---|
1 |
|
所以需要在LogMessage
类中重载流插入函数,但是考虑到自定义信息可能不止一种内置数据类型,所以需要使用到模板,因为只有当前函数才需要使用到模板,所以只需要在当前函数中使用模板即可。在流插入重载函数中,同样使用stringstream
流处理字符串拼接问题,最后将整个字符串放到成员_message
中:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
现在,LogMessage
就已经完成了基本的设计,但是目前LogHandler
和LogMessage
并没有具体的联系,所以接下来的目标就是让二者建立联系,而二者所谓的联系就是LogMessage
类根据当前LogHandler
的策略将日志输出到指定位置,所以在LogMessage
中需要有一个LogHandler
对象成员引用,并在构造时,需要外部传入一个LogHandler
对象初始化当前LogMessage
中的LogHandler
对象成员引用:
Note
C++ | |
---|---|
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 |
|
完成上面的步骤之后,接下来就是将日志输出到指定位置,既然要输出信息,那么肯定需要创建LogMessage
对象先构建信息,本次考虑一种思路:通过LogMessage
临时对象析构时自动调用日志输出。这个思路涉及到一个知识点:临时对象的生命周期会延长
在C++中,临时对象可以支持流插入操作。这是因为临时对象在语句结束之前都是有效的,可以安全地进行流插入操作。而此时的临时对象的声明周期就被延长到流插入操作结束后,一旦流插入重载函数返回对象的引用,就可以进行链式调用
所以根据这个知识考虑在LogMessage
的析构函数中写入日志输出的逻辑:
C++ | |
---|---|
1 2 3 4 5 6 |
|
接着考虑构建出一个临时对象,本次日志系统希望用户以如下的方式使用:
C++ | |
---|---|
1 |
|
因为LogHandler
类本身没有重载流插入<<
,所以LogHandler对象(日志等级)
就必须是重载了流插入的LogMessage
对象,所以在LogHandler
中需要重载()
,该函数返回一个LogMessage
对象,接收一个日志等级参数、一个文件名参数和一个行号参数
但是,如果LogHandler
的()
重载函数只有三个参数,那么在构造LogMessage
对象时就会出现还有loghandler
无法初始化。这就是接下来需要考虑的问题,对于loghandler
来说,因为LogMessage
类是LogHandler
类的内部类,并且()
重载函数是LogHandler
类的成员函数,所以可以使用*this
作为参数传递给loghandler
,此时的*this
代表的就是LogHandler
类的对象
所以,整个函数的设计如下:
C++ | |
---|---|
1 2 3 4 |
|
至此,基于策略模式的日志系统基本框架已经形成
宏函数设定¶
虽然已经写好了基本的日志系统,但是为了让日志系统更利于使用,在原来期望的使用方式的基础之上:
C++ | |
---|---|
1 |
|
再修改为如下的使用方式:
C++ | |
---|---|
1 |
|
先创建一个LogHandler
对象:
C++ | |
---|---|
1 2 |
|
可以考虑使用一个宏函数:
C++ | |
---|---|
1 |
|
这个宏函数的作用是通过LogHandler
类对象调用其中的operator()
函数根据传递的日志等级创建LogMessage
类对象,并且也可以利用到C语言宏中提供的一个__FILE__
和一个__LINE__
,二者分别表示的就是当前文件名和当前行号,这样可以获取到当前文件名和当前行号,并且因为是宏,所以其替换的位置就是最终结果。此时只要想在指定行打日志,那么最后日志所表示的行就是对应该日志代码所在的行
此外,还可以将enableConsoleLog
和enableFileLog
分别封装成宏函数方便使用:
C++ | |
---|---|
1 2 |
|
测试日志系统¶
使用下面的代码测试:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
一共有两个输出结果:
C++ | |
---|---|
1 2 3 4 |
|
C++ | |
---|---|
1 2 3 4 |
|