配置系统

配置系统有什么用?

我的理解是方便程序的运行和发布。把配置变量都抽离出来放在配置文件中,如果要修改配置变量,就直接在配置文件里修改,然后重新运行程序就可以了。如果没有配置系统的情况下要修改配置变量,一般都是直接改程序源代码,然后重新编译连接,毫无疑问这将会是费时费力的(找对应版本的各种库,对应版本的编译器等等,还要等待漫长的编译连接过程。。。),对于那些非开源软件,想改源代码就更不可能了。。。。

配置系统就能够很好地解决这些问题。

YAML

选择一种用于配置文件的语言,我选的是 YAML。它是专门用来写配置文件的语言,非常简洁和强大,远比 JSON 格式方便。

YAML 实质上是一种通用的数据串行化格式。它的基本语法规则如下:

  • 大小写敏感
  • 使用缩进表示层级关系
  • 缩进时不允许使用 Tab 键,只允许使用空格。
  • 缩进的空格数目不重要,只要相同层级的元素左侧对齐即可

# 表示注释,从这个字符一直到行尾,都会被解析器忽略。

YAML 支持的数据结构有三种:

  • 对象:键值对的集合,又称为映射(mapping)/ 哈希(hashes) / 字典(dictionary)
  • 数组:一组按次序排列的值,又称为序列(sequence) / 列表(list)
  • 纯量(scalars):单个的、不可再分的值

和 JSON 对比,它数据类型更单调简单(JSON 有 6 种类型)

YAML 下载与安装:

yaml-cpp: github repo

mkdir build && cd build && cmake .. && make install

基于 YAML 实现 配置系统

配置系统的原则:

  • 约定优于配置:约定即源代码中写死的值,而配置是指在配置文件 (.yaml) 中指定的值。
  • 不能无中生有:在源文件中未定义的配置变量,即使在配置文件 (.yaml) 中定义了也不会生效。

总体的结构是这样的:

​ 由于配置变量一般都由:变量名,变量值,变量描述构成。因此可以抽一个基类出来存放这些共通的属性,必然的,有时候会需要把配置变量输出到控制台给用户看,或根据字符串来重置变量值,因此还需要一个 fromString 和 toString 方法:

1
2
3
4
5
6
7
8
9
class ConfigVarBase {
public:
using ptr = std::shared_ptr<ConfigVarBase>;
virtual bool fromString(std::string str); // 根据 str 来设置 配置变量值
virtual void toString(); // 把配置变量值转成字符串,便于输出
private:
std::string _name;
std::string _description;
};

​ 其中变量名和变量描述由于类型固定,可以放在基类中,而变量值则不固定了,它可以是任意类型,因此就可以根据基类派生出一个模板子类来表示具体的配置变量:

1
2
3
4
5
6
7
8
template<class T>
class ConfigVar : public ConfigVarBase {
public:
bool fromString(std::string str) override;
std::string toString() override;
private:
T _val;
};

​ 现在有配置变量了,缺一个管理这些配置变量的类,我使用 map 来进行管理

1
2
3
4
5
6
7
8
class Config {
public:
...
static std::map<std::string, ConfigVarBase::ptr>& GetConfigVars() {
static std::map<std::string, ConfigVarBase::ptr> g_configVars;
return g_configVars;
}
};

这里为什么要用 static 函数来返回一个 local static 变量 map 呢?这是因为,配置系统可以被其他编译单元内的数据结构使用,如果其他编译单元想要使用 g_configVars 时,它还没有初始化完毕就会产生 runtime error,这种情况就是所谓的 non-local static 初始化顺序不一致。可以使用 local static 的方式来解决,也就是让别的编译单元通过调用函数的方式获取 g_configVars,这样使用它之前肯定被初始化好了。

这样一来 约定的变量 就实现了!

接下来就是怎么实现,从配置文件 (.yaml) 中读取配置变量。yaml-cpp 库提供了 LoadFile 函数,能从 .yaml 文件中读取 YAML::Node。

由于 .yaml 中的格式和我源代码中变量名字的格式是不一样的:

yaml 中是:

1
2
3
A:
B: 10
C: 20

源文件中的变量名则为:A.B = 10,A.C = 20

因此这里需要一个从 YAML 名称格式到 源代码中的变量名称格式的转换。可以借助 yaml-cpp 中的 IsNull, IsScalar, IsSequence, Ismap 对 node 进行递归解析,然后将变量名进行转换。只有对象类型才需要递归解析下去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void listAllNodes(const std::string& name, const YAML::Node& node,  std::vector<std::pair<std::string, YAML::Node>>& allNodes) {
allNodes.push_back(std::make_pair(name, node));
if (node.IsNull()) {
}
else if (node.IsScalar()) {
}
else if (node.IsSequence()) {
}
else if (node.IsMap()) {
for (auto it = node.begin(); it != node.end(); ++it) {
listAllNodes(name.empty() ? it->first.as<std::string>() :
name + "." + it->first.Scalar(), it->second, allNodes); // 这里是名字转换的关键所在
}
}
}
void Config::loadFromYaml(const char* filename) {
YAML::Node node = YAML::LoadFile(filename);
std::vector<std::pair<std::string, YAML::Node>> allNodes;
listAllNodes("", node, allNodes);

for (auto i : allNodes) { // 对所有的 name、node 进行遍历
std::string name = i.first;
if (name.empty())
continue;
ConfigVarBase::ptr p = Config::find(name);
if (p) { // 这里保证了不会无中生有的原则
std::stringstream ss;
ss << i.second;
p->fromString(ss.str());
}
}
}

我们都知道,YAML 文件中仅仅是一些文本,而我们需要根据 YAML 文本得到内存中的对象;或者根据内存中的对象得到 YAML 文本;这本质就是序列化和反序列化。和 JSON 的十分相似。
我做出了这样的总结:

  • YAML::Node –> std::string –> Type
    • 通过 std::stringstream 来实现 YAML::Node –> std::string. 这一部分 YAML 库已经做好了
    • 通过 自己实现的 LexicalCast\<F, T\> 来做 std::string –> Type 的转换
  • Type –> std::string –> YAML::Node
    • 通过 YAML::Load 来实现 std::string –> YAML::Node. 这一部分 YAML 库已经做好了
    • 通过 自己实现的 LexicalCast\<F, T\> 来做 Type –> std::string 的转换

fromStr 和 toStr 的实现

对于普通的内置类型可以用 boost::lexical_cast 来实现,而对于复杂的数据类型,例如:vector,list,set,map,unordered_set,unordered_map, 自定义类型 等,就要自己去实现了。

STL 类型的支持:

可以实现一个 LexicalCast 模板类,然后根据具体的 STL 容器对 LexicalCast 进行偏特化就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 对于普通内置类型
template<class F, class T>
class LexicalCast {
public:
T operator()(const F& val) {
return boost::lexical_cast<T>(val);
}
};

// cast from std::string to std::vector<T>
template<class T>
class LexicalCast<std::string, std::vector<T>> {
public:
std::vector<T> operator()(const std::string& str) {
...// 利用 yaml-cpp 的 Load 得到 node 然后遍历 node,利用 stringstream 格式化
}
};
// cast from std::vector<T> to std::string
template<class T>
class LexicalCast<std::vector<T>, std::string> {
public:
std::string operator()(const std::vector<T>& v) {
...
}
};

....

template<class T, class FromStr = LexicalCast<std::string, T>,
class ToStr = LexicalCast<T, std::string>>
class ConfigVar : public ConfigVarBase {
public:
bool fromString(std::string str) override {
_val = FromStr()(str);
return true;
}
std::string toString() override {
return ToStr()(_val);
}
private:
T _val;
};

自定义类型的支持:

自定义类型,需要实现 LexicalCast 偏特化,实现后,就可以支持 Config 解析自定义类型,自定义类型可以和常规 STL 容器一起使用。

例如,增加 Person 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Person {
public:
std::string name;
int age;
bool sex;
};

// from std::string to Person
template<>
class LexicalCast<std::string, Person> {
public:
Person operator()(const std::string& str) {
YAML::Node node = YAML::Load(str);
Person p;
p.name = node["name"].as<std::string>();
p.age = node["age"].as<int>();
p.sex = node["sex"].as<bool>();
return p;
}
};

// from Person to std::string
template<>
class LexicalCast<Person, std::string> {
public:
std::string operator()(const Person& p) {
YAML::Node node;
node["name"] = p.name;
node["age"] = p.age;
node["sex"] = p.sex;
std::stringstream ss;
ss << node;
return ss.str();
}
};

配置的事件机制

当一个配置项发生修改的时候,可以反向通知对应的代码。

这个其实挺容易实现的,在 ConfigVar 模板类中添加一个 OnChangeCallBack _cb 回调,它是

std::function<const T& oldVal, const T& newVal> 类型的,每当要改变 ConfigVar::_val 时,先判断一下,新的值是否与旧值不同,如果是的化则回调 _cb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 class ConfigVar {
...
T getValue() const { return _val; }
void setValue(const T& v) {
if (v == _val)
return;
if (_cb)
_cb(_val, v);
_val = v;
}

void setOnChangeCallBack(OnChangeCallBack cb) { _cb = cb; }
void delOnChangeCallBack() { _cb = nullptr; }
private:
T _val;
OnChangeCallBack _cb;
};

这样配置系统基本就完成了!