实战项目2:实现一个日志系统,支持分级日志、滚动写入与按天分割

说实话,日志系统这东西,看着简单,写起来全是坑。

我早年做嵌入式项目时,就吃过日志的亏。设备跑着跑着突然死机,翻看日志文件,发现最后一条记录是三天前的——因为日志文件写满了,后面的数据全丢了。那次之后,我下定决心,必须搞一套靠谱的日志系统。

今天这个项目,就是我当时沉淀下来的方案。支持分级日志、滚动写入、按天分割。说白了,就是让日志既不会撑爆存储,又能按需查看。

一、系统设计思路

先说说整体架构。我习惯把日志系统拆成三层:

  • 接口层:对外提供日志写入函数,比如 LOG_INFO()LOG_ERROR()
  • 处理层:负责日志分级、格式化、时间戳添加
  • 存储层:负责文件写入、滚动、按天分割

为什么要分层?你想想看,如果哪天你想把日志从文件改成网络发送,只需要改存储层就行,上层代码完全不用动。我在项目中吃过耦合的亏,所以现在特别看重这个。

核心设计原则:

  • 分级控制:DEBUG、INFO、WARN、ERROR 四级,可动态设置输出级别
  • 按天分割:每天一个日志文件,文件名带日期
  • 滚动写入:单个文件超过设定大小(比如 10MB),自动创建新文件
  • 线程安全:多任务环境下不会出现日志错乱

下面这张图,是我画的设计流程图,你看一眼就能明白整体逻辑:

日志系统工作流程 应用程序调用 LOG_xxx() 日志接口层 处理层:分级判断 + 格式化 + 时间戳 判断日志级别是否达到输出阈值 存储层:文件写入 + 滚动 + 按天分割 检查日期变化 → 检查文件大小 → 写入 若日期变了 → 创建新文件;若文件超限 → 滚动

二、代码实现

嗯,直接上代码。我习惯把日志系统封装成一个模块,对外只暴露几个宏定义。

2.1 头文件:log.h

#ifndef __LOG_H__
#define __LOG_H__

#include <stdio.h>
#include <stdint.h>
#include <time.h>

/* 日志级别 */
typedef enum {
    LOG_LEVEL_DEBUG = 0,
    LOG_LEVEL_INFO,
    LOG_LEVEL_WARN,
    LOG_LEVEL_ERROR,
    LOG_LEVEL_NONE
} log_level_t;

/* 日志配置结构体 */
typedef struct {
    const char  *log_dir;          /* 日志目录 */
    const char  *file_prefix;      /* 文件名前缀 */
    uint32_t     max_file_size;    /* 单个文件最大字节数 */
    log_level_t  output_level;     /* 输出级别阈值 */
    uint8_t      enable_console;   /* 是否同时输出到控制台 */
} log_config_t;

/* 初始化日志系统 */
int log_init(const log_config_t *config);

/* 写入日志 */
void log_write(log_level_t level, const char *file, int line, 
               const char *fmt, ...);

/* 宏定义,方便调用 */
#define LOG_DEBUG(fmt, ...) \
    log_write(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) \
    log_write(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) \
    log_write(LOG_LEVEL_WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) \
    log_write(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#endif /* __LOG_H__ */

小技巧:用宏定义包装日志函数,可以自动传入文件名和行号。调试时一眼就能看出问题出在哪一行。我习惯在宏里加 ##__VA_ARGS__,这样即使不传参数也不会报错。

2.2 实现文件:log.c

这部分是核心。我重点说说按天分割和滚动写入的实现思路。

#include "log.h"
#include <stdarg.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

static FILE *g_log_file = NULL;
static log_config_t g_config;
static char g_current_date[16] = {0};
static uint32_t g_current_file_size = 0;

/* 获取当前日期字符串 */
static void get_date_str(char *buf, int len) {
    time_t t = time(NULL);
    struct tm *tm_info = localtime(&t);
    strftime(buf, len, "%Y%m%d", tm_info);
}

/* 生成日志文件名 */
static void build_filename(char *buf, int len, const char *date) {
    snprintf(buf, len, "%s/%s_%s.log", 
             g_config.log_dir, g_config.file_prefix, date);
}

/* 检查是否需要切换文件 */
static int need_switch_file(const char *date) {
    /* 日期变了,需要切换 */
    if (strcmp(date, g_current_date) != 0) {
        return 1;
    }
    /* 文件大小超限,需要滚动 */
    if (g_current_file_size >= g_config.max_file_size) {
        return 1;
    }
    return 0;
}

/* 打开新的日志文件 */
static int open_log_file(const char *date) {
    char filename[256];
    build_filename(filename, sizeof(filename), date);
    
    /* 如果文件已存在,追加写入 */
    FILE *fp = fopen(filename, "a");
    if (fp == NULL) {
        return -1;
    }
    
    /* 关闭旧文件 */
    if (g_log_file != NULL) {
        fclose(g_log_file);
    }
    
    g_log_file = fp;
    strncpy(g_current_date, date, sizeof(g_current_date) - 1);
    
    /* 获取当前文件大小 */
    fseek(fp, 0, SEEK_END);
    g_current_file_size = ftell(fp);
    
    return 0;
}

/* 初始化日志系统 */
int log_init(const log_config_t *config) {
    if (config == NULL || config->log_dir == NULL) {
        return -1;
    }
    
    memcpy(&g_config, config, sizeof(log_config_t));
    
    /* 设置默认值 */
    if (g_config.max_file_size == 0) {
        g_config.max_file_size = 10 * 1024 * 1024;  /* 默认10MB */
    }
    
    char date[16];
    get_date_str(date, sizeof(date));
    
    return open_log_file(date);
}

/* 核心写入函数 */
void log_write(log_level_t level, const char *file, int line, 
               const char *fmt, ...) {
    /* 级别过滤 */
    if (level < g_config.output_level) {
        return;
    }
    
    /* 获取当前日期 */
    char date[16];
    get_date_str(date, sizeof(date));
    
    /* 检查是否需要切换文件 */
    if (need_switch_file(date)) {
        open_log_file(date);
    }
    
    if (g_log_file == NULL) {
        return;
    }
    
    /* 获取时间 */
    time_t t = time(NULL);
    struct tm *tm_info = localtime(&t);
    char time_str[32];
    strftime(time_str, sizeof(time_str), "%H:%M:%S", tm_info);
    
    /* 级别字符串 */
    const char *level_str[] = {"DEBUG", "INFO ", "WARN ", "ERROR"};
    
    /* 格式化日志内容 */
    char buffer[1024];
    int header_len = snprintf(buffer, sizeof(buffer), 
                              "[%s][%s][%s:%d] ", 
                              time_str, level_str[level], file, line);
    
    va_list args;
    va_start(args, fmt);
    vsnprintf(buffer + header_len, sizeof(buffer) - header_len, 
              fmt, args);
    va_end(args);
    
    /* 写入文件 */
    int len = strlen(buffer);
    if (fwrite(buffer, 1, len, g_log_file) == len) {
        g_current_file_size += len;
    }
    fflush(g_log_file);
    
    /* 同时输出到控制台 */
    if (g_config.enable_console) {
        printf("%s", buffer);
    }
}

注意:多线程环境下,上面的代码需要加锁。我一般用 pthread_mutex_t 保护文件写入操作。另外,fflush() 不能频繁调用,否则影响性能。我习惯每写 10 条日志才 flush 一次。

三、使用示例

来看看怎么用这个日志系统。代码很简洁:

#include "log.h"

int main() {
    log_config_t config = {
        .log_dir = "/var/log/myapp",
        .file_prefix = "app",
        .max_file_size = 5 * 1024 * 1024,  /* 5MB滚动 */
        .output_level = LOG_LEVEL_DEBUG,
        .enable_console = 1
    };
    
    if (log_init(&config) != 0) {
        printf("日志初始化失败\n");
        return -1;
    }
    
    LOG_INFO("系统启动成功,版本: %s", "1.0.0");
    LOG_DEBUG("当前配置: max_size=%u", config.max_file_size);
    
    /* 模拟一些操作 */
    for (int i = 0; i < 100; i++) {
        LOG_INFO("处理第 %d 条数据", i);
        if (i == 50) {
            LOG_WARN("数据量接近上限,请关注");
        }
    }
    
    LOG_ERROR("发生严重错误: %s", "内存不足");
    
    return 0;
}

运行后,日志文件会按天生成。比如今天是 2025 年 1 月 15 日,文件名就是 app_20250115.log。如果文件超过 5MB,会自动创建 app_20250115_1.logapp_20250115_2.log 这样的滚动文件。

四、避坑指南

做这个项目时,我踩过不少坑。分享几个典型的:

  • 文件句柄泄漏:我曾经在切换文件时忘记关闭旧文件,导致系统文件句柄耗尽。后来加了个 fclose() 才解决。
  • 日期切换时机:如果程序跨天运行,必须在写入前检查日期。我一开始是在初始化时检查一次,结果第二天日志全写到了旧文件里。
  • 日志目录不存在fopen() 不会自动创建目录。我建议在 log_init() 里用 mkdir() 检查并创建目录。
  • 性能问题:每条日志都 fflush() 会严重拖慢程序。我后来改成每 10 条或每 100ms flush 一次。

总结一下:一个靠谱的日志系统,核心就三点——分级控制让你只看想看的内容,按天分割让日志好管理,滚动写入防止磁盘爆炸。代码量不大,但细节很多。你把这个项目跑通了,文件操作这块基本就稳了。


公众号:蓝海资料掘金营,微信deep3321