21、JSON解析:cJSON库集成、解析与构建、配置文件JSON化

说到JSON,我估计大家都不陌生。前后端交互用它,配置文件用它,就连一些嵌入式设备也在用。但说实话,在C语言里处理JSON,早期真挺痛苦的——自己写解析器?太费劲。用别人的库?又怕太重量级。

后来我遇到了cJSON,嗯,就是那个只有两个文件、轻量到不行的JSON解析库。今天咱们就聊聊怎么把它集成到项目里,怎么解析和构建JSON,以及怎么把配置文件做成JSON格式。

为什么选cJSON?

市面上C语言的JSON库不少,比如Jansson、json-c、parson等等。我个人习惯用cJSON,原因有三:

  • 够轻量——就两个文件,cJSON.c和cJSON.h,不依赖别的库
  • API简洁——核心函数不超过20个,上手快
  • MIT协议——商用随便用,没坑

我在项目中遇到过好几次,团队里有人想用json-c,结果编译时发现要链接一堆依赖库,最后又换回cJSON。说白了,对于命令行工具这种场景,轻量才是王道。

集成cJSON到项目

集成方式很简单,就三步:

  1. 把cJSON.c和cJSON.h拷到你的项目目录
  2. 在代码里#include "cJSON.h"
  3. 编译时把cJSON.c一起编译

举个例子,你的项目结构可以这样:

my_tool/
├── src/
│   ├── main.c
│   ├── config.c
│   └── cJSON.c
├── include/
│   ├── config.h
│   └── cJSON.h
└── Makefile

编译命令也很直接:

gcc -Iinclude src/main.c src/config.c src/cJSON.c -o my_tool
小提示:如果你用CMake,可以直接把cJSON.c加到add_executable里,不用额外配置。

解析JSON:从字符串到结构体

解析JSON,说白了就是把一段JSON字符串变成cJSON对象树,然后从树里取数据。

来看一个实际例子。假设我们有这样一个配置文件:

{
  "server": {
    "host": "127.0.0.1",
    "port": 8080,
    "workers": 4
  },
  "logging": {
    "level": "info",
    "file": "/var/log/app.log"
  }
}

解析它的代码长这样:

#include <stdio.h>
#include <stdlib.h>
#include "cJSON.h"

int parse_config(const char *json_str) {
    cJSON *root = cJSON_Parse(json_str);
    if (root == NULL) {
        const char *error_ptr = cJSON_GetErrorPtr();
        if (error_ptr != NULL) {
            fprintf(stderr, "解析错误: %s\n", error_ptr);
        }
        return -1;
    }

    // 取server对象
    cJSON *server = cJSON_GetObjectItem(root, "server");
    if (cJSON_IsObject(server)) {
        cJSON *host = cJSON_GetObjectItem(server, "host");
        cJSON *port = cJSON_GetObjectItem(server, "port");
        cJSON *workers = cJSON_GetObjectItem(server, "workers");

        if (cJSON_IsString(host) && cJSON_IsNumber(port) && cJSON_IsNumber(workers)) {
            printf("Host: %s\n", host->valuestring);
            printf("Port: %d\n", port->valueint);
            printf("Workers: %d\n", workers->valueint);
        }
    }

    // 取logging对象
    cJSON *logging = cJSON_GetObjectItem(root, "logging");
    if (cJSON_IsObject(logging)) {
        cJSON *level = cJSON_GetObjectItem(logging, "level");
        cJSON *file = cJSON_GetObjectItem(logging, "file");

        if (cJSON_IsString(level) && cJSON_IsString(file)) {
            printf("Log Level: %s\n", level->valuestring);
            printf("Log File: %s\n", file->valuestring);
        }
    }

    cJSON_Delete(root);
    return 0;
}
注意:解析完一定要调用cJSON_Delete释放内存。我曾经在项目里忘了这步,结果跑了一周后内存爆了,排查了半天才发现是cJSON对象没释放。

构建JSON:从结构体到字符串

构建JSON比解析更直观。你想想看,就是创建对象、添加键值对、最后转成字符串。

下面这个例子,构建一个网络配置的JSON:

char *build_network_config(void) {
    cJSON *root = cJSON_CreateObject();
    if (root == NULL) return NULL;

    // 添加字符串
    cJSON_AddStringToObject(root, "interface", "eth0");

    // 添加数字
    cJSON_AddNumberToObject(root, "mtu", 1500);

    // 添加布尔值
    cJSON_AddBoolToObject(root, "dhcp", 1);

    // 添加数组
    cJSON *dns = cJSON_CreateArray();
    cJSON_AddItemToArray(dns, cJSON_CreateString("8.8.8.8"));
    cJSON_AddItemToArray(dns, cJSON_CreateString("8.8.4.4"));
    cJSON_AddItemToObject(root, "dns_servers", dns);

    // 添加嵌套对象
    cJSON *ipv4 = cJSON_CreateObject();
    cJSON_AddStringToObject(ipv4, "address", "192.168.1.100");
    cJSON_AddStringToObject(ipv4, "netmask", "255.255.255.0");
    cJSON_AddItemToObject(root, "ipv4", ipv4);

    // 转成字符串
    char *json_str = cJSON_Print(root);
    if (json_str == NULL) {
        fprintf(stderr, "JSON序列化失败\n");
        cJSON_Delete(root);
        return NULL;
    }

    cJSON_Delete(root);
    return json_str;
}

生成的JSON长这样:

{
    "interface": "eth0",
    "mtu": 1500,
    "dhcp": true,
    "dns_servers": ["8.8.8.8", "8.8.4.4"],
    "ipv4": {
        "address": "192.168.1.100",
        "netmask": "255.255.255.0"
    }
}
小技巧:cJSON_Print生成的是带格式的字符串,适合给人看。如果你要传输或存储,可以用cJSON_PrintUnformatted,它生成的是紧凑格式,体积更小。

配置文件JSON化:实战经验

把配置文件做成JSON格式,好处很明显:可读性强、层次清晰、扩展方便。但也有一些坑要注意。

我曾经在做一个日志分析工具时,把配置写成了JSON。一开始觉得挺爽,后来发现用户经常把配置写错——多一个逗号、少一个引号,解析就挂了。嗯,这里要注意,给用户用的工具,一定要做好错误提示。

下面是我总结的配置文件JSON化最佳实践:

实践要点 说明
提供默认值 解析时如果某个字段缺失,用默认值代替,不要直接报错
校验字段类型 用cJSON_IsString、cJSON_IsNumber等函数检查类型
友好的错误提示 告诉用户哪一行、哪个字段出错了
支持注释 cJSON原生不支持注释,但可以在解析前用预处理去掉//和/* */
版本兼容 在配置里加一个"version"字段,方便以后升级

来看一个带默认值和校验的完整示例:

typedef struct {
    char host[256];
    int port;
    int workers;
    char log_level[16];
    char log_file[512];
} AppConfig;

int load_config(const char *filepath, AppConfig *config) {
    // 设置默认值
    snprintf(config->host, sizeof(config->host), "localhost");
    config->port = 8080;
    config->workers = 4;
    snprintf(config->log_level, sizeof(config->log_level), "info");
    snprintf(config->log_file, sizeof(config->log_file), "/var/log/app.log");

    // 读取文件
    FILE *fp = fopen(filepath, "r");
    if (fp == NULL) {
        fprintf(stderr, "无法打开配置文件: %s\n", filepath);
        return -1;
    }

    fseek(fp, 0, SEEK_END);
    long fsize = ftell(fp);
    fseek(fp, 0, SEEK_SET);

    char *json_str = malloc(fsize + 1);
    fread(json_str, 1, fsize, fp);
    json_str[fsize] = '\0';
    fclose(fp);

    // 解析JSON
    cJSON *root = cJSON_Parse(json_str);
    free(json_str);

    if (root == NULL) {
        const char *err = cJSON_GetErrorPtr();
        fprintf(stderr, "JSON解析失败: %s\n", err ? err : "未知错误");
        return -1;
    }

    // 读取server配置,缺失则用默认值
    cJSON *server = cJSON_GetObjectItem(root, "server");
    if (cJSON_IsObject(server)) {
        cJSON *host = cJSON_GetObjectItem(server, "host");
        if (cJSON_IsString(host)) {
            snprintf(config->host, sizeof(config->host), "%s", host->valuestring);
        }

        cJSON *port = cJSON_GetObjectItem(server, "port");
        if (cJSON_IsNumber(port)) {
            config->port = port->valueint;
        }

        cJSON *workers = cJSON_GetObjectItem(server, "workers");
        if (cJSON_IsNumber(workers)) {
            config->workers = workers->valueint;
        }
    }

    // 读取logging配置
    cJSON *logging = cJSON_GetObjectItem(root, "logging");
    if (cJSON_IsObject(logging)) {
        cJSON *level = cJSON_GetObjectItem(logging, "level");
        if (cJSON_IsString(level)) {
            snprintf(config->log_level, sizeof(config->log_level), "%s", level->valuestring);
        }

        cJSON *file = cJSON_GetObjectItem(logging, "file");
        if (cJSON_IsString(file)) {
            snprintf(config->log_file, sizeof(config->log_file), "%s", file->valuestring);
        }
    }

    cJSON_Delete(root);
    return 0;
}

核心知识体系

下面这张图,把cJSON的核心操作和配置文件JSON化的流程串起来了:

cJSON 核心操作与配置文件JSON化流程 JSON字符串 / 文件 cJSON_Parse() 解析 核心操作 cJSON_GetObjectItem() cJSON_IsString/Number/Bool() cJSON_Add*ToObject() cJSON_CreateArray/Object() cJSON_Print() / cJSON_PrintUnformatted() 配置结构体 / JSON字符串 cJSON_Delete() 释放内存

这张图把整个流程分成了五层:从输入JSON字符串开始,经过解析、核心操作、输出,最后一定要释放内存。每一步都有对应的cJSON函数,你照着这个流程写代码,基本不会出错。

避坑指南

最后分享几个我踩过的坑:

  • 内存泄漏——每次cJSON_Parse和cJSON_Print后,记得配对cJSON_Delete和free。我曾经在循环里解析JSON,忘了释放,跑了一晚上内存涨到2GB。
  • 空指针检查——cJSON_GetObjectItem可能返回NULL,一定要判断。别问我怎么知道的,线上崩过一次就知道了。
  • 字符串拷贝——cJSON对象释放后,它的valuestring指针就失效了。如果你要长期保存字符串,用snprintf拷出来。
  • 大文件处理——如果配置文件超过10MB,建议用流式解析,不要一次性读到内存里。cJSON虽然方便,但不适合超大JSON。

好了,关于cJSON的集成、解析与构建,以及配置文件JSON化,就聊这么多。这些技巧我在好几个项目里都用过,效果不错。你写命令行工具时,不妨试试把配置做成JSON格式,维护起来会轻松很多。


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