日志系统扩展:插件化输出

日志系统做到一定程度,你会发现一个尴尬的事:输出方式不够用了

刚开始,控制台打印就挺好。后来项目大了,需要写文件。再后来,团队要统一收集日志,得往数据库里写。甚至要对接 ELK(Elasticsearch + Logstash + Kibana)做实时分析。

你总不能每次都改日志核心代码吧?

所以,插件化输出就来了。说白了,就是让日志系统支持「动态加载输出后端」。你想往哪写,就加载哪个插件。核心代码不动,扩展性拉满。

核心思想:日志系统只负责格式化日志、分发日志。具体「写到哪里」,由插件决定。

为什么需要插件化?

我遇到过这样一个项目:嵌入式设备跑着跑着,客户突然说「日志要同时存到本地文件和远程服务器」。当时日志系统是写死的,只支持串口输出。改起来那叫一个痛苦——每个日志调用点都得加判断。

后来我重构了日志系统,采用插件化架构。再遇到类似需求,只需要写一个新插件,注册进去就行。代码零侵入。

插件化的好处很明显:

  • 解耦:日志核心与输出方式分离
  • 可扩展:新增输出后端,不用改已有代码
  • 动态切换:运行时可以加载/卸载插件
  • 复用:不同项目共用同一套日志核心,只换插件

插件接口设计

插件化第一步,定义接口。C语言没有接口关键字,但可以用函数指针结构体模拟。

// log_plugin.h
typedef struct {
    const char *name;           // 插件名称
    int   (*init)(void);        // 初始化
    void  (*write)(int level, const char *msg);  // 写日志
    void  (*flush)(void);       // 刷新缓冲区
    void  (*deinit)(void);      // 反初始化
} log_plugin_t;

每个插件只需要实现这四个函数。核心日志系统通过指针调用,完全不知道背后是控制台、文件还是数据库。

小技巧:插件名用字符串,方便调试时打印。我习惯在 init 函数里打印一句「plugin xxx loaded」,排查问题一目了然。

插件注册与动态加载

插件怎么注册?两种方式:

  1. 静态注册:编译时把插件列表写死在数组里
  2. 动态加载:运行时通过 dlopen(Linux)或 LoadLibrary(Windows)加载 .so/.dll

嵌入式环境通常用静态注册,因为动态加载需要操作系统支持。但架构上要预留动态加载的接口。

// log_core.c
static log_plugin_t *plugins[MAX_PLUGINS];
static int plugin_count = 0;

int log_register_plugin(log_plugin_t *plugin) {
    if (plugin_count >= MAX_PLUGINS) return -1;
    if (plugin->init && plugin->init() != 0) {
        printf("plugin %s init failed\n", plugin->name);
        return -1;
    }
    plugins[plugin_count++] = plugin;
    return 0;
}

void log_write(int level, const char *fmt, ...) {
    char buf[LOG_BUF_SIZE];
    va_list args;
    va_start(args, fmt);
    vsnprintf(buf, sizeof(buf), fmt, args);
    va_end(args);

    for (int i = 0; i < plugin_count; i++) {
        if (plugins[i]->write) {
            plugins[i]->write(level, buf);
        }
    }
}

你看,核心代码就这么简单。遍历插件列表,挨个调用 write。新增插件?写个结构体,调用 log_register_plugin 就行。

四种输出后端实现

我分别实现了控制台、文件、数据库、ELK 四种插件。每个插件都遵循同一套接口。

1. 控制台插件

static int console_init(void) {
    // 设置颜色等
    return 0;
}

static void console_write(int level, const char *msg) {
    const char *color = level >= LOG_WARN ? "\033[31m" : "\033[0m";
    printf("%s%s\033[0m\n", color, msg);
}

log_plugin_t console_plugin = {
    .name = "console",
    .init = console_init,
    .write = console_write,
    .flush = NULL,
    .deinit = NULL
};

2. 文件插件

static FILE *log_file = NULL;

static int file_init(void) {
    log_file = fopen("/var/log/app.log", "a");
    return log_file ? 0 : -1;
}

static void file_write(int level, const char *msg) {
    if (log_file) {
        fprintf(log_file, "%s\n", msg);
        fflush(log_file);  // 确保不丢日志
    }
}

log_plugin_t file_plugin = {
    .name = "file",
    .init = file_init,
    .write = file_write,
    .flush = NULL,
    .deinit = NULL
};

注意:文件插件一定要考虑日志轮转。我曾经在生产环境上忘记加轮转,结果日志文件撑爆了磁盘。建议用 logrotate 或自己实现按大小/时间切分。

3. 数据库插件

static MYSQL *conn = NULL;

static int db_init(void) {
    conn = mysql_init(NULL);
    return mysql_real_connect(conn, "localhost", "user", "pass", "logs", 0, NULL, 0) ? 0 : -1;
}

static void db_write(int level, const char *msg) {
    char query[1024];
    snprintf(query, sizeof(query),
             "INSERT INTO logs(level, message) VALUES(%d, '%s')",
             level, msg);
    mysql_query(conn, query);
}

log_plugin_t db_plugin = {
    .name = "database",
    .init = db_init,
    .write = db_write,
    .flush = NULL,
    .deinit = NULL
};

数据库插件要注意连接池和重连机制。网络一抖,连接断了,日志就丢了。我一般会在 write 里加个重试逻辑。

4. ELK 插件

static int elk_init(void) {
    // 初始化 HTTP 客户端,连接 Logstash
    return 0;
}

static void elk_write(int level, const char *msg) {
    // 构造 JSON 格式
    char json[2048];
    snprintf(json, sizeof(json),
             "{\"level\":%d,\"message\":\"%s\",\"timestamp\":%ld}",
             level, msg, time(NULL));
    // 通过 HTTP POST 发送到 Logstash
    http_post("http://logstash:5044", json);
}

log_plugin_t elk_plugin = {
    .name = "ELK",
    .init = elk_init,
    .write = elk_write,
    .flush = NULL,
    .deinit = NULL
};

ELK 插件的关键是 JSON 格式化和网络发送。我建议用 cJSON 库来构建 JSON,避免手拼字符串出 bug。

动态加载实现

如果系统支持动态库,可以做到运行时加载插件:

#ifdef USE_DLOPEN
#include <dlfcn.h>

int log_load_plugin(const char *so_path) {
    void *handle = dlopen(so_path, RTLD_LAZY);
    if (!handle) return -1;

    log_plugin_t *(*get_plugin)(void) = dlsym(handle, "get_plugin");
    if (!get_plugin) {
        dlclose(handle);
        return -1;
    }

    log_plugin_t *plugin = get_plugin();
    return log_register_plugin(plugin);
}
#endif

每个 .so 文件里实现一个 get_plugin 函数,返回插件结构体指针。主程序通过 dlopen 加载,然后注册。

经验之谈:动态加载虽然灵活,但嵌入式环境慎用。一是文件系统可能不支持 .so,二是动态链接增加内存开销。我一般只在 Linux 服务器上用动态加载,嵌入式用静态注册。

架构总览

下面这张图展示了插件化日志系统的整体架构:

插件化日志系统架构 应用程序 日志核心(格式化 + 分发) log_write() → 遍历插件列表 → 调用 write 插件管理器(注册/加载/卸载) 插件列表(数组/链表) [console] [file] [database] [ELK] ... 控制台插件 文件插件 数据库插件 ELK插件

从图里能看出来,应用程序只跟日志核心打交道。核心负责格式化日志,然后分发给所有已注册的插件。每个插件各司其职,互不干扰。

避坑指南

插件化看着简单,实际落地有不少坑。我踩过几个,分享给你:

  • 线程安全:多个线程同时写日志,插件内部必须加锁。我习惯在核心层加一把大锁,插件层不再考虑并发。
  • 插件初始化失败:比如数据库连不上,不能导致整个系统崩溃。注册时检查返回值,失败就跳过。
  • 内存泄漏:插件卸载时要释放所有资源。我见过有人只注册不反初始化,内存越跑越高。
  • 日志风暴:如果插件本身出问题(比如网络不通),反复重试会刷爆日志。建议加熔断机制,连续失败 N 次后暂停该插件。

总结一下:插件化输出的核心是「接口统一、实现分离」。控制台、文件、数据库、ELK 只是四种常见的输出后端。你完全可以自己写插件,输出到 MQTT、Kafka、甚至蜂鸣器——只要你想。

嗯,插件化就聊到这儿。代码量不大,但架构上的收益是长期的。下次项目要加新输出方式,你只需要写一个 .c 文件,注册一下,完事。


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