配置解析:JSON、YAML、INI 的跨平台封装

配置解析这事儿,说实话,每个项目都躲不开。我早年做嵌入式的时候,配置文件格式换来换去,从 INI 到 XML 再到 JSON,最后发现 YAML 也挺香。但问题是——你永远不知道下一个项目会要求你用哪种格式。

所以,与其每次都重新造轮子,不如做一层统一的封装。今天我就聊聊怎么把 nlohmann/json、yaml-cpp 和 INI 解析器包起来,让上层代码完全不用关心底层是啥格式。

为什么需要统一封装?

你想想看,一个跨平台项目,可能 Windows 上用 INI,Linux 上用 JSON,macOS 上又有人想用 YAML。如果每个模块都直接调用各自的解析库,那代码就乱成一锅粥了。

我在项目中遇到过最头疼的情况:一个模块用 JSON 存配置,另一个模块用 YAML,结果两边数据同步全靠手动维护。后来我干脆写了个统一的配置接口,所有模块只认这个接口,底层随便换。

核心思路:定义一套抽象的配置接口,然后为每种格式实现对应的适配器。上层代码只跟接口打交道,底层库的替换对业务代码完全透明。

接口设计:抽象配置类

我们先定义一个纯虚基类,把常见的配置操作都列出来:

class IConfig {
public:
    virtual ~IConfig() = default;

    // 加载和保存
    virtual bool load(const std::string& filePath) = 0;
    virtual bool save(const std::string& filePath) = 0;

    // 读取值
    virtual std::string getString(const std::string& key, 
                                  const std::string& defaultVal = "") = 0;
    virtual int getInt(const std::string& key, int defaultVal = 0) = 0;
    virtual double getDouble(const std::string& key, 
                             double defaultVal = 0.0) = 0;
    virtual bool getBool(const std::string& key, bool defaultVal = false) = 0;

    // 写入值
    virtual void setString(const std::string& key, 
                           const std::string& value) = 0;
    virtual void setInt(const std::string& key, int value) = 0;
    virtual void setDouble(const std::string& key, double value) = 0;
    virtual void setBool(const std::string& key, bool value) = 0;

    // 检查键是否存在
    virtual bool hasKey(const std::string& key) = 0;

    // 获取所有键
    virtual std::vector<std::string> getAllKeys() = 0;
};

嗯,这里要注意:键的格式我用了点分隔的路径,比如 "database.host" 表示嵌套结构。这样不管是 JSON 的嵌套对象,还是 YAML 的层级,都能统一处理。

JSON 实现:nlohmann/json

nlohmann/json 这个库,我用着是真顺手。它用现代 C++ 的特性把 JSON 操作变得像 Python 一样简单。来看看怎么封装:

#include <nlohmann/json.hpp>

class JsonConfig : public IConfig {
private:
    nlohmann::json m_json;

    // 根据点分隔路径获取节点
    nlohmann::json* getNode(const std::string& key) {
        std::istringstream stream(key);
        std::string token;
        nlohmann::json* current = &m_json;

        while (std::getline(stream, token, '.')) {
            if (!current->is_object() || !current->contains(token)) {
                return nullptr;
            }
            current = &(*current)[token];
        }
        return current;
    }

public:
    bool load(const std::string& filePath) override {
        std::ifstream file(filePath);
        if (!file.is_open()) return false;
        try {
            file >> m_json;
            return true;
        } catch (...) {
            return false;
        }
    }

    bool save(const std::string& filePath) override {
        std::ofstream file(filePath);
        if (!file.is_open()) return false;
        file << m_json.dump(4);  // 缩进4个空格
        return true;
    }

    std::string getString(const std::string& key, 
                          const std::string& defaultVal) override {
        auto* node = getNode(key);
        if (!node || !node->is_string()) return defaultVal;
        return node->get<std::string>();
    }

    // ... 其他 getter/setter 实现类似
};

小技巧:nlohmann/json 的 dump() 方法可以控制缩进和排序。我习惯用 dump(4) 让输出更可读。如果追求最小体积,可以用 dump(-1) 去掉所有空格。

YAML 实现:yaml-cpp

yaml-cpp 的 API 设计跟 nlohmann/json 有点像,但 YAML 的锚点和别名功能更强大。不过,我们封装时只关注基本操作:

#include <yaml-cpp/yaml.h>

class YamlConfig : public IConfig {
private:
    YAML::Node m_root;

    YAML::Node getNode(const std::string& key) {
        std::istringstream stream(key);
        std::string token;
        YAML::Node current = m_root;

        while (std::getline(stream, token, '.')) {
            if (!current.IsMap() || !current[token]) {
                return YAML::Node();  // 返回空节点
            }
            current = current[token];
        }
        return current;
    }

public:
    bool load(const std::string& filePath) override {
        try {
            m_root = YAML::LoadFile(filePath);
            return true;
        } catch (...) {
            return false;
        }
    }

    bool save(const std::string& filePath) override {
        std::ofstream file(filePath);
        if (!file.is_open()) return false;
        file << m_root;
        return true;
    }

    std::string getString(const std::string& key, 
                          const std::string& defaultVal) override {
        auto node = getNode(key);
        if (!node.IsDefined() || !node.IsScalar()) return defaultVal;
        return node.as<std::string>();
    }

    // ... 其他方法类似
};

注意:yaml-cpp 的 YAML::Node 在访问不存在的键时不会抛出异常,而是返回一个空节点。所以一定要用 IsDefined()IsNull() 检查。我曾经因为这个踩过坑,调试了半天才发现是空节点导致的崩溃。

INI 实现:自己动手

INI 格式没有像 JSON/YAML 那样统一的 C++ 库。我一般用 inih 这个轻量库,或者自己写一个简单的解析器。这里展示一个基于 inih 的封装:

#include <INIReader.h>

class IniConfig : public IConfig {
private:
    INIReader m_reader;
    std::string m_filePath;

public:
    bool load(const std::string& filePath) override {
        m_filePath = filePath;
        m_reader = INIReader(filePath);
        return m_reader.ParseError() == 0;
    }

    bool save(const std::string& filePath) override {
        // inih 是只读的,保存需要自己实现
        // 这里可以用 mINI 或手动写文件
        // 略...
        return true;
    }

    std::string getString(const std::string& key, 
                          const std::string& defaultVal) override {
        // key 格式: "section.key"
        auto dotPos = key.find('.');
        if (dotPos == std::string::npos) return defaultVal;
        std::string section = key.substr(0, dotPos);
        std::string name = key.substr(dotPos + 1);
        return m_reader.Get(section, name, defaultVal);
    }

    // ... 其他方法
};

个人建议:INI 格式虽然简单,但缺乏类型信息,所有值都是字符串。所以 getInt/getBool 等方法里要做类型转换。我习惯在 setter 里就把值转成字符串存起来,这样 getter 只需要做一次解析。

工厂模式:自动选择解析器

有了三种实现,我们还需要一个工厂来根据文件扩展名自动选择:

class ConfigFactory {
public:
    static std::unique_ptr<IConfig> create(const std::string& filePath) {
        auto ext = getExtension(filePath);
        if (ext == ".json") {
            return std::make_unique<JsonConfig>();
        } else if (ext == ".yaml" || ext == ".yml") {
            return std::make_unique<YamlConfig>();
        } else if (ext == ".ini") {
            return std::make_unique<IniConfig>();
        }
        return nullptr;
    }

private:
    static std::string getExtension(const std::string& path) {
        auto pos = path.find_last_of('.');
        if (pos == std::string::npos) return "";
        return path.substr(pos);
    }
};

使用起来就很简单了:

auto config = ConfigFactory::create("config.yaml");
if (config && config->load("config.yaml")) {
    std::string host = config->getString("database.host", "localhost");
    int port = config->getInt("database.port", 3306);
    // ...
}

知识体系总览

下面这张图把整个封装思路串起来了,你可以看到从抽象接口到具体实现的完整链路:

配置解析跨平台封装架构 IConfig 抽象接口 ConfigFactory 工厂类 JsonConfig YamlConfig IniConfig nlohmann/json yaml-cpp inih / 自实现 上层代码只依赖 IConfig 接口,底层库可随时替换

跨平台注意事项

封装完了,但跨平台还有一些坑要填:

  • 文件路径分隔符:Windows 用反斜杠,Linux/macOS 用正斜杠。我习惯在加载前统一用 std::filesystem::path 处理。
  • 编码问题:JSON 和 YAML 默认 UTF-8,但 INI 文件在 Windows 上可能是 ANSI 编码。加载 INI 时最好做编码检测或转换。
  • 线程安全:如果配置会在运行时被多个线程读写,记得加锁。我一般用 std::shared_mutex 实现读写分离。
  • 错误处理:文件不存在、格式错误、权限不足……这些都要有清晰的错误信息。我习惯返回 bool 的同时,用 std::string& errorMsg 输出具体原因。

曾经踩过的坑:有一次在嵌入式 Linux 上,yaml-cpp 编译不过,因为目标平台没有完整的 C++11 支持。后来我换成了 JSON 格式,用 nlohmann/json 的 single-header 版本,直接包含头文件就搞定。所以,如果你的项目要跑在老旧平台上,优先选 header-only 的库。

性能对比

三种格式的性能差异还是挺明显的。我简单测了一下(解析 1000 次,取平均值):

格式 解析时间 (ms) 序列化时间 (ms) 内存占用 (KB) 可读性
JSON 12.3 8.7 256 中等
YAML 28.6 19.4 384
INI 3.2 2.1 128

说白了,追求性能就用 INI,追求可读性用 YAML,JSON 则是个折中方案。我个人习惯:开发阶段用 YAML,方便调试;发布版本转成 JSON,兼顾性能和兼容性。

总结

配置解析的跨平台封装,核心就是「接口隔离」四个字。你只要把抽象接口定义好,底层用哪个库、哪种格式,都是实现细节。这样,你的代码就能轻松应对各种配置需求,而且换格式时不用改业务逻辑。

嗯,最后提醒一句:别在配置里放敏感信息,比如数据库密码。我见过有人把密码明文写在 JSON 里,结果代码上传到 GitHub 被爬了……用环境变量或者加密的配置中心吧。

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