日志系统扩展:插件化输出
日志系统做到一定程度,你会发现一个尴尬的事:输出方式不够用了。
刚开始,控制台打印就挺好。后来项目大了,需要写文件。再后来,团队要统一收集日志,得往数据库里写。甚至要对接 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」,排查问题一目了然。
插件注册与动态加载
插件怎么注册?两种方式:
- 静态注册:编译时把插件列表写死在数组里
- 动态加载:运行时通过 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 服务器上用动态加载,嵌入式用静态注册。
架构总览
下面这张图展示了插件化日志系统的整体架构:
从图里能看出来,应用程序只跟日志核心打交道。核心负责格式化日志,然后分发给所有已注册的插件。每个插件各司其职,互不干扰。
避坑指南
插件化看着简单,实际落地有不少坑。我踩过几个,分享给你:
- 线程安全:多个线程同时写日志,插件内部必须加锁。我习惯在核心层加一把大锁,插件层不再考虑并发。
- 插件初始化失败:比如数据库连不上,不能导致整个系统崩溃。注册时检查返回值,失败就跳过。
- 内存泄漏:插件卸载时要释放所有资源。我见过有人只注册不反初始化,内存越跑越高。
- 日志风暴:如果插件本身出问题(比如网络不通),反复重试会刷爆日志。建议加熔断机制,连续失败 N 次后暂停该插件。
总结一下:插件化输出的核心是「接口统一、实现分离」。控制台、文件、数据库、ELK 只是四种常见的输出后端。你完全可以自己写插件,输出到 MQTT、Kafka、甚至蜂鸣器——只要你想。
嗯,插件化就聊到这儿。代码量不大,但架构上的收益是长期的。下次项目要加新输出方式,你只需要写一个 .c 文件,注册一下,完事。