前言
正式工作已经一年了,说来惭愧,阅读的基本只有项目代码,一开始读着还挺有意思,但每天看实在有些反胃,再加上最近写代码全力依仗agent,代码能力更是呈指数级下降。 xxx啊xxx,你怎么能如此堕落? 事到如今,不做点什么找回手感真是不行了,开搞! 代码嘛,无非读和写,写还好说,刷刷leetcode就能解决,读的话只能依赖一些开源项目了。
源码面前,了无秘密,所以,《Code Analysis》系列应运而生,学习优秀的代码实现,偶尔给大脑也来上两组动作,开练!
本系列旨在通过解析开源项目,学习优雅、简洁的实现,讨论其使用场景,覆盖的范围一般不会超过项目的实现,配合代码阅读更佳
项目简介
log.c是一个简单的C日志库实现,代码也才200行出头,用于本系列的开头实在再好不过了!
项目链接: GitHub Repository rxi/log.c 在 GitHub 上查看该项目。
回想下,要实现一个极简的日志库需要什么? 其实很简单,基础的文件操作就可以了,只要能把一定格式的信息输出到文件,任何人都可以在文件操作api的基础上封装一套日志库。
只是一个良好工程实践的日志库会稍微复杂一点点:
- 提供了哪些日志等级
- 是否支持分级、滚动写入
- 是否是线程安全的
- 提供了什么格式的基础信息
日志输出
log.c提供了以下几种日志等级:
1enum { LOG_TRACE, LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR, LOG_FATAL };
2
3// 必要的字符串文本描述,配合日志等级枚举使用
4static const char *level_strings[] = {
5 "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"
6};
上述几个基本覆盖了常用的日志等级。 不同项目对于日志等级的使用也不尽相同,但一般而言,在调试阶段所有日志是常开的,而项目上线后,则只能记录重要且有效的信息等级,否则会把存储拉爆。 (比如最近codex最近有个bug就是频繁写日志,导致磁盘的寿命大大减少,不知道是不是用codex自举导致的。) 因此,对一个基础的日志库而言,调整日志输出等级的API是必要的。 在log.c中,所有的日志配置都依赖如下的结构:
1static struct {
2 void *udata;
3 log_LockFn lock;
4 int level; // 日志等级
5 bool quiet;
6 Callback callbacks[MAX_CALLBACKS];
7} L;
这个全局变量名字叫L显然叫有些抽象了,应当取有意义的名称。
显然,这里的level就是用于控制输出的日志等级。
常见C库的日志输出往往借助宏来实现,比如,对于不同的日志等级,封装了多个宏,但不同的宏底层使用相同的函数/宏来套娃实现。 在log.c中也使用了上述的操作:
1#define log_trace(...) log_log(LOG_TRACE, __FILE__, __LINE__, __VA_ARGS__)
2#define log_debug(...) log_log(LOG_DEBUG, __FILE__, __LINE__, __VA_ARGS__)
3#define log_info(...) log_log(LOG_INFO, __FILE__, __LINE__, __VA_ARGS__)
4#define log_warn(...) log_log(LOG_WARN, __FILE__, __LINE__, __VA_ARGS__)
5#define log_error(...) log_log(LOG_ERROR, __FILE__, __LINE__, __VA_ARGS__)
6#define log_fatal(...) log_log(LOG_FATAL, __FILE__, __LINE__, __VA_ARGS__)
这里有几点比较有趣:
__FILE__和__LINE__是编译器提供的预定义宏,给出文件名、行数等信息,似乎是标准明确定义的- 底层都是通过
log_log函数来实现输出的 - 使用了可变参数宏
不难发现,最为关键的点就在log_log的实现:
1void log_log(int level, const char *file, int line, const char *fmt, ...) {
2 // 事件参数
3 log_Event ev = {
4 .fmt = fmt,
5 .file = file,
6 .line = line,
7 .level = level,
8 };
9
10 lock();
11
12 // L相当于一个全局配置
13 // level是配置的等级,小于该等级都会被过滤掉
14 if (!L.quiet && level >= L.level) {
15 // 默认输出到错误窗口,毕竟stderr本质是文件描述符
16 init_event(&ev, stderr);
17 // 这里就是重点
18 va_start(ev.ap, fmt);
19 stdout_callback(&ev);
20 va_end(ev.ap);
21 }
22
23 // 正常文件输出是走这里
24 for (int i = 0; i < MAX_CALLBACKS && L.callbacks[i].fn; i++) {
25 Callback *cb = &L.callbacks[i];
26 if (level >= cb->level) {
27 init_event(&ev, cb->udata);
28 va_start(ev.ap, fmt);
29 cb->fn(&ev);
30 va_end(ev.ap);
31 }
32 }
33
34 unlock();
35}
忽略其他内容,让我们关注的一个if内部的重点,!L.quiet && level >= L.level表明只有没有被静默并且日志等级比设置日志等级高才会输出。
那显然,也会有以下两个控制接口来控制这两个参数:
1void log_set_level(int level);
2void log_set_quiet(bool enable);
我想不看实现也都知道这俩该怎么写。
继续观察第一个if,只看重点部分,va_start和va_end是一对用于处理可变参数的宏,如何使用在这里就不过多展开了,配合va_list和va_arg可以达到读取可变参数的目的。显然,对于一次log_info("hello, %s", "log.c");调用而言,宏展开之后,参数按照位置对应:
1void log_log(
2 int level, // LOG_INFO
3 const char *file, // __FILE__
4 int line, // __LINE__
5 const char *fmt, // "hello, %s"
6 ... // "log.c"
7);
那么,stdout_callback显然就是拼凑字符串了:
1static void stdout_callback(log_Event *ev) {
2 char buf[16];
3 // 转换时间
4 buf[strftime(buf, sizeof(buf), "%H:%M:%S", ev->time)] = '\0';
5 // 通过宏定义控制色彩开关
6 // 第一个fprintf是打印常规格式
7#ifdef LOG_USE_COLOR
8 fprintf(
9 ev->udata, "%s %s%-5s\x1b[0m \x1b[90m%s:%d:\x1b[0m ",
10 buf, level_colors[ev->level], level_strings[ev->level],
11 ev->file, ev->line);
12#else
13 fprintf(
14 ev->udata, "%s %-5s %s:%d: ",
15 buf, level_strings[ev->level], ev->file, ev->line);
16#endif
17 // 后一个vfprintf是打印用户格式
18 vfprintf(ev->udata, ev->fmt, ev->ap);
19 fprintf(ev->udata, "\n");
20 fflush(ev->udata);
21}
可以看到,该函数:
- 首先转换时间,将标准时间表示做到buf里
- 判断是否打开了
LOG_USE_COLOR宏,如果打开了,则打印时带颜色控制(这里就不介绍了,也意义不大),将基础的日志信息先设置到udata指向的文件,在控制台输出的示例中,是stderr - vfprintf将用户传入的可变参数列表按照fmt的格式输出到目标文件描述符
- 打印换行符,从缓冲区刷到文件里
至此,完成了一条日志的输出
文件输出
如果想要控制输出的文件,则需要以下三个函数:
1int log_add_fp(FILE *fp, int level) {
2 return log_add_callback(file_callback, fp, level);
3}
4
5int log_add_callback(log_LogFn fn, void *udata, int level) {
6 for (int i = 0; i < MAX_CALLBACKS; i++) {
7 if (!L.callbacks[i].fn) {
8 L.callbacks[i] = (Callback) { fn, udata, level };
9 return 0;
10 }
11 }
12 return -1;
13}
14
15// 输出到文件
16static void file_callback(log_Event *ev) {
17 char buf[64];
18 buf[strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", ev->time)] = '\0';
19 fprintf(
20 ev->udata, "%s %-5s %s:%d: ",
21 buf, level_strings[ev->level], ev->file, ev->line);
22 vfprintf(ev->udata, ev->fmt, ev->ap);
23 fprintf(ev->udata, "\n");
24 fflush(ev->udata);
25}
显然,是通过file_callback回调实现的,将指定的文件描述符、file_callback函数以及需要的日志等级存入到Callback这个结构里,再回看log_log函数,不难发现第一个for就是正常文件的输出,对每一个预设回调进行调用。
通过这个功能,可以实现对于不同等级的日志分文件存储。另外,通过callback机制,也可以做到一些关键处日志的回调,例如分析、上报之类的功能。
锁控制
如果有两个线程同时向一处输出,有可能会造成乱码,所以log.c也提供机制以调节输出顺序:
1static void lock(void) {
2 if (L.lock) { L.lock(true, L.udata); }
3}
4
5
6static void unlock(void) {
7 if (L.lock) { L.lock(false, L.udata); }
8}
9
10void log_set_lock(log_LockFn fn, void *udata) {
11 L.lock = fn;
12 L.udata = udata;
13}
可以通过log_set_lock这种类似回调的机制,来引入外部的锁实现,以控制日志输出顺序,比如提供一个这样的函数:
1static void log_lock(bool should_lock, void *udata) {
2 // 恢复 udata 原本的类型
3 auto *mutex = static_cast<std::mutex *>(udata);
4
5 if (should_lock) {
6 mutex->lock();
7 } else {
8 mutex->unlock();
9 }
10}
显然,通过传入该函数,即可调节多个线程的打印顺序,不至于混乱。
总结
一遍看下来,该库也存在一些问题:
- 不支持滚动输出,比如按照设定时间来滚动输出,全都堆到一个文件里实在太大了
- 性能上未必足够好,对于一些高性能的项目,一条日志其实有多次写入似乎有些慢,而且挤占了主线程的工作时间
- 可以带上函数的预定义宏
- 一些校验问题等等
当然上述分析+结论也并非完美,不难看到log.c库的简洁,并且提供了及格的日志支持,如果项目不是很复杂,引入该库也是不错的选择。
综上,log.c更像是初学者的玩具,日志这一块显然能做更多事情,比如一个更强力的日志库叫spdlog,这也将是下一篇内容的新坑。
下次继续!