前言
在学习sylar框架的配置篇时,我花了不少时间。其实单说配置模块的学习难度,感觉是没有其他模块难度大的,但是最后花费的时间真的比后面的模块甚至还要多,归根到底是之前没有了解过配置模块的开发,很多内容都是陌生的;
我觉得在学习配置模块的时候要关注这么几个问题:
一是配置模块到底是干嘛的?
二是配置到底是如何加载的,从哪里读配置、怎么解析、怎么存储到具体的对象?
三是配置模块sylar是如何设计的?(代码解析)
其实只要把这三个问题弄明白,配置模块的核心逻辑就差不多懂了。至于剩下的,大多是细节上的设计,顺着核心思路往下捋,就不难理解了。
注:源码中涉及了一些后面的知识,像是Visit方法 和 LoadFromConfDir等方法 以及 EnvMgr类,这些大家先不用去管,这些都是在启动服务器之前做的一些事,只要是把我提到的这些类和方法搞明白本模块就算是可以结束了;
配置模块是干嘛的?
“为什么非得有这个配置模块”,我直接在代码中把相关的数据定义好不就好了吗?为什么还需要单独写这么个配置模块,不是多此一举吗?大家看一下日志的配置信息;
logs:
– name: smer
isAsyc: false
level: debug
formatter: "%d{%Y-%m-%d %H:%M:%S}%T%t%T%F%T[%p]%T[%c]%T%f:%l%T%m%n"
appenders:
– type: FileLogAppender
level: debug
formatter: "%d{%Y-%m-%d %H:%M:%S}%T%t%T%F%T[%p]%T[%c]%T%f:%l%T%m%n"
file: log.txt
– name: root
isAsyc: false
level: debug
formatter: "%d{%Y-%m-%d %H:%M:%S}%T%t%T%F%T[%p]%T[%c]%T%f:%l%T%m%n"
appenders:
– type: FileLogAppender
file: log.txt
level: debug
– type: StdoutAppender
level: debug
…
如果直接写成配置的格式,很直观对吧;但是大家考虑一下如果这些内容都通过编码的方式来完成会怎样?是不是每增加一个类型log,我们都得写一大串代码,设置名称、设置级别阈值、设置日志格式、设置输出地,然后还要针对每一个下属的类LogAppender(日志输出地)再做编码,这也太复杂了吧,我们开发的准则是什么?简洁高效优雅,如果真是按照编码的方式去写这些配置,那还优雅吗?写出来的项目是在太冗余了吧。
// 编码方式配置日志:对应原 YAML 中的 logs 节点(smer 和 root 两个 logger)
void configLoggerByCode() {
/*********************************************************************
1. 配置第一个 Logger:对应 YAML 中的 "name: smer" 节点
********************************************************************/
// 1.1 创建名为 "smer" 的 Logger 对象(默认是同步日志,对应 isAsyc: false)
auto smer_logger = sylar::Logger::CreateLogger("smer");
// 设置日志级别:对应 YAML 中的 "level: debug"
smer_logger->setLevel(sylar::LogLevel::DEBUG);
// 1.2 配置日志格式器:对应 YAML 中的 "formatter: …"
std::string smer_formatter_pattern = "%d{%Y-%m-%d %H:%M:%S}%T%t%T%F%T[%p]%T[%c]%T%f:%l%T%m%n";
auto smer_formatter = std::make_shared<sylar::LogFormatter>(smer_formatter_pattern);
smer_logger->setFormatter(smer_formatter); // 给 smer logger 绑定格式器
// 1.3 配置 Appender:对应 YAML 中的 "appenders: – type: FileLogAppender"
// 创建 FileLogAppender(输出到 log.txt 文件)
auto smer_file_appender = std::make_shared<sylar::FileLogAppender>("./log.txt");
// 给 Appender 设置级别和格式(和 logger 保持一致,也可单独设置)
smer_file_appender->setLevel(sylar::LogLevel::DEBUG);
smer_file_appender->setFormatter(smer_formatter);
// 将 Appender 绑定到 smer logger
smer_logger->addAppender(smer_file_appender);
// 1.4 注册 logger 到全局日志管理器(方便后续通过名字获取:LoggerMgr::GetInstance()->getLogger("smer"))
sylar::LoggerMgr::GetInstance()->addLogger(smer_logger);
/*********************************************************************
2. 配置第二个 Logger:对应 YAML 中的 "name: root" 节点(root 是框架默认根日志器)
********************************************************************/
// 2.1 获取全局 root logger(sylar 通常默认存在 root logger,也可手动创建)
auto root_logger = sylar::LoggerMgr::GetInstance()->getRootLogger();
// 设置 root 日志级别:对应 YAML 中的 "level: debug"
root_logger->setLevel(sylar::LogLevel::DEBUG);
// 2.2 配置 root 日志格式器:和 smer 用相同的格式
auto root_formatter = std::make_shared<sylar::LogFormatter>(smer_formatter_pattern);
root_logger->setFormatter(root_formatter);
// 2.3 配置第一个 Appender:FileLogAppender(输出到 log.txt)
auto root_file_appender = std::make_shared<sylar::FileLogAppender>("./log.txt");
root_file_appender->setLevel(sylar::LogLevel::DEBUG);
root_file_appender->setFormatter(root_formatter);
root_logger->addAppender(root_file_appender);
// 2.4 配置第二个 Appender:StdoutAppender(输出到控制台)
auto root_stdout_appender = std::make_shared<sylar::StdoutLogAppender>(); // 控制台输出器
root_stdout_appender->setLevel(sylar::LogLevel::DEBUG);
root_stdout_appender->setFormatter(root_formatter);
root_logger->addAppender(root_stdout_appender);
}
这么写我们根本分不清每种日志到底是怎么样的一个属性?甚至哪些是root的属性,那些是smer的属性都要花很长时间找一找?所以我们肯定不能通过这种方式去设置logger的属性(配置),而且在实际的开发中,我们肯定不是一个人进行开发吧,就比如说你是开发其他模块的,***你的队友开发完日志模块以后说:你想用日志模块,还需去自己去看上面一大串代码去区分不同的日志 *** 那我们本经过一天的摧残之后,还要和这样的队友交互,那心态不直接崩了?而且我们也肯定不想做写这种不优雅的模块的作者吧;所以说配置系统是必要的;
根据ai给出的定义,配置系统的功效大概就是:
配置系统是无需修改软件代码,即可通过调整参数 / 规则,实现软件适配不同环境、满足动态需求,并支持配置
管理(如回滚、批量同步)的工具 / 机制,核心是让软件灵活可控、降低变更成本。
考虑如何加载配置?
配置模块的加载配置流程就是:读配置文件 —> 实现一个通用的转化方式(FromStr)—> 转化到具体的对象存储(类)
既然已经知道了配置模块的配置流程,我们就可以考虑配置系统是如何设计的了。
(一)首先是考虑使用什么配置语言存储配置信息(json yaml xml ini…)
sylar使用的是yaml文件存储配置信息,使用yamlcpp库进行读配置,yamlcpp是需要安装和配置的,通过ai配就行;(或是等博主全部更新完,看博主的源代码的guide.md文件)
(yamlcpp到底干嘛的?==》我想访问一个配置项的key和value(port:8080),Yaml::Node node加载完配置以后直接node["port"]就可以访问,简单高效;)
(二)知道了使用什么什么配置语言以后,需要考虑的就是如何实现通用的转化方法(把配置内容string转成正确的数据类型然后读到具体的成员变量中?)
yamlcpp结合Boost的LexicalCast确实是可以实现简单的数据类型的转换(int long string…),但是vector、list、set、map以及自定义的类等数据类型的转换呢?我们的实现方式是使用模板元编程实现的,原模版实现简单数据类型的转化,本质还是用的boost库的lexical_cast,只是针对复杂类型做了偏特化(具体什么是偏特化大家往下看)
(三)知道怎么从配置文件读配置了,也知道转化的方法了,然后需要处理的就是转化的时机;
sylar是如何设计配置模块的?
配置模块输出起来感觉怪怪的,从哪里开始着手都感觉是半路杀进来的,那我就先从配置的三个最基本的类开始输出吧;
如何存储配置信息?
配置模块设计了三个类来(从类中)设置、存储和读取配置信息; ===>(key value description就是配置信息)
ConfigVarBase是配置的基类,成员变量只有key(配置的名称)和description(配置的描述);
ConfigVar是配置的子类,成员变量有value(配置的值)和map存储配置变更事件(这个后面再讲,知道是个存储回调函数的容器就行)
Config是配置的一个方法类,里面提供了一些静态方法,供用户往存储配置信息的容器datas_中添加配置信息(Lookup),查询配置信息(lookup)、将加载好的配置信息(YAML::Node)经过转化存储到容器中
还有一个配合使用的方法ListAllMember,该方法负责把配置信息树结构打平成list结构
logs:
– name: smer
formatter: "%d{%Y-%m-%d %H:%M:%S}%T%t%T%F%T[%p]%T[%c]%T%f:%l%T%m%n"
appenders:
– type: FileLogAppender
level: debug
formatter: "%d{%Y-%m-%d %H:%M:%S}%T%t%T%F%T[%p]%T[%c]%T%f:%l%T%m%n"
file: log.txt
经过打平以后:
name就是 logs.name
type就是 logs.appenders.type
配置信息转化
转化是通过对LexicalCast偏特化实现的;
这是原模板类:F是From的意思,T是To的意思,即从F数据类型转成T数据类型,像是int 到 string / string 到 int / int 到 long这种简单类型的转化是直接可以通过boost库的lexical_cast实现的;
template<class F,class T>
class LexicalCast
{
public:
T operator()(const F& v){
return boost::lexical_cast<T>(v);
}
};
但是std::string怎么转成vector set list map这种类型呢?就必须实现一组偏特化的类;(比如vector)
template<class T>
class LexicalCast<std::string,std::vector<T> >{
public:
std::vector<T> operator()(const std::string& val){
YAML::Node node = YAML::Load(val);
typename std::vector<T> vec;
std::stringstream ss;
for(size_t i = 0;i<node.size();i++){
ss.str("");
ss<<node[i];
vec.push_back(LexicalCast<std::string,T>()(ss.str()));
}
return vec;
}
};
//vector -> string
template<class T>
class LexicalCast<std::vector<T>, std::string>{
public:
std::string operator()(const std::vector<T>& val){
YAML::Node node;
for(auto& i : val){
node.push_back(YAML::Load(LexicalCast<T,std::string>()(i)));
}
std::stringstream ss;
ss<<node;
return ss.str();
}
};
自定义类型怎么和std::string完成互相转化?
class Person{
public:
Person(std::string name = "smer",int age=11,bool sex = true)
:name_(name),age_(age),sex_(sex){
}
std::string name_;
int age_;
bool sex_;
std::string toString()const{
std::stringstream ss;
ss << "[Person name=" << name_
<< " age=" << age_
<< " sex=" << sex_
<<"]";
return ss.str();
}
bool operator==(Person pe)const {
return true;
}
};
namespace sylar{
template<>
class LexicalCast<Person,std::string>{
public:
std::string operator()(const Person& val){
YAML::Node node;
node["name"] = YAML::Load(val.name_);
node["age"] = YAML::Load(std::to_string(val.age_));
node["sex"] = YAML::Load(std::to_string(val.sex_));
std::stringstream ss;
ss<<node;
// SYLAR_LOG_INFO(SYLAR_LOG_ROOT()) << "ss.str() to string";
// SYLAR_LOG_INFO(SYLAR_LOG_ROOT()) << ss.str();
return ss.str();
}
};
template<>
class LexicalCast<std::string,Person>{
public:
Person operator()(const std::string& val){
YAML::Node node = YAML::Load(val);
Person p;
p.name_ = node["name"].as<std::string>();
p.age_ = node["age"].as<int>();
p.sex_ = node["sex"].as<bool>();
// SYLAR_LOG_INFO(SYLAR_LOG_ROOT()) << "node";
// SYLAR_LOG_INFO(SYLAR_LOG_ROOT()) << node;
// SYLAR_LOG_INFO(SYLAR_LOG_ROOT()) << "to person";
// SYLAR_LOG_INFO(SYLAR_LOG_ROOT()) << p.toString();
return p;
}
};
因为我们使用的yaml这种标准的配置格式,完全没必要有这样的担心(我从文件读到的数据是一整个文件的数据,我怎么知道怎么获取name和他的值啊),这些在yamlcpp中早就已经做好了,比如Yaml::Node node已经读取了配置文件,node["logs"]就是logs的值,使用相当方便,毕竟我们是开发服务器,不是开发解析库,这种库可以直接拿来使用;
比如配置信息是list:[10,20,30,40],遍历node[list]的结果就是10 20 30 40,这和数组差不多,那么
我们如何将配置信息list转成vector<T>呢?就是在for遍历list的时候,将单个简单元素都转成T对吧,当然前面
提到T都是简单类型,如果T又是list<T>呢?也就是说list是这样的
list:
[10,20,30,40]
[40,50,60,70]
…
那么当转化函数首先会进入LexicalCast<std::string,vector<T>>,然后在这个函数遍历第一个元素的时候发
现,T并不简单数据类型而是list<int>,那么会递归的调用LexicalCast<std::string,int>,当把第一行元素
遍历完之后,退出LexicalCast<std::string,int>,继续遍历list的第二行元素,再次进入
LexicalCast<std::string,int>,一次类推;
当然如果list的配置是三层数据结构的也同理,就再递归一个LexicalCast<std::string,T>呗;
存储时机
现在也知道怎么转化了,下一步就是什么时候转化呗;那让我们看一下Config::LoadFromYaml这个函数就知道了;
(一)LoadFromYaml首先是创建一个all_nodes,这个变量将用来存放从Node解析的所有的key和value;
(二)ListAllMember将配置打平后,放入all_nodes,如下:
a:
b:
c:d
e:f
打平后
key value
a b:
c:d
e:f
a.b c:d
a.b.c d
a.e f
(三)然后遍历all_nodes 如果datas_该配置已经存在则只改变值 若不存在就跳过
其实大致的内容就这么多:
首先是弄明白用什么存储配置:配置信息存放在配置文件中,服务器可以直接使用的配置是通过三个类进行存储的,基类只有key和description,子类多了value,工具类存储所有的配置信息,并提供一些方法(创建 查询 将配置文件的配置转到datas_存储)
然后就是转化方法,是使用到了模板元编程,其实就是将复杂的数据类型通过递归偏特化的方法转成简单的数据类型转化,底层还是用了boost的lexical_cast方法;
最后就是提到了Config的方法LoadFromYaml,这个方法就是将文件的配置的key先打平(a.b.c.)存入all_nodes,然后遍历all_nodes,去看配置信息是否存在于存放配置的容器中,若存在就只变更数据,不存在就跳过;
总的调用链是什么?
用户调用Yaml的Load方法加载"/pwd/config.yml"(可以是从根路径开始也可以从项目路径开始pwd/config.yml),将数据加载到Yaml::Node node中,然后调用Config::LoadFromYaml,在Config::LoadFromYaml内先将所有的配置项打平存放到list键值对类型的all_nodes容器中存放,然后遍历all_nodes将yaml的复杂数据类型转成c++的复杂数据类型后,将正确类型的数据存放到了datas_容器中就结束了;其实调用流程比日志篇要简单太多了,就是本节涉及的内容比较杂(多),还有很多没有接触到的内容,所以学习起来比较痛苦;
其他
接下来我输出一下项目中出现的一些其他的小问题?
硬编码
硬编码指直接在代码中固定写入具体值(而非通过配置动态获取)。
例如:定义全局变量 int port = 8888; 时,8888 就是硬编码 —— 若要修改端口,必须改动代码并重新编译,无法通过外部配置灵活调整。
约定优于配置
“约定优于配置”指:系统默认遵循“**仅读取代码中已定义的配置项**”这一约定——配置文件中若存在未在代码里定义的配置项,会被自动忽略;只有代码中明确声明了的配置项,才会从配置文件中读取对应值并生效。 例如:若代码里定义了 `datas_` 中包含 `ip` 这个键,则会读取配置文件中 `ip:127.0.0.1` 的值;若 `datas_` 中没有 `ip` 这个键,即便配置文件里有 `ip` 项,也会被跳过不读。
配置变更事件
配置变更事件是指:当配置项的值发生改变时,系统通过日志打印等方式,对这一变更及变更内容进行实时通知的机制。
(1)每一个配置信息都单独放在ConfigVar中,其类内部还维护了一个map<uint32_t,cb>类型的事件回调表;(cb类型是std::function<void(const T& old_value,const T& new_value)>);
(2)如果想触发配置变更事件就必须提前注册回调事件到ConfigVar内部的回调事件列表,最好是在进入main函数之前注册也可以其他时候注册,只要你能保证正常通知就好;
(3)如何做的?配置在设置值的时候都必须通过ConfigVar的setval方法,在setval方法内部设置值之前会遍历对象内部的回调函数,若发现值不同,就会执行通知,若相同直接从函数返回;
怎么在进入main之前做一些事情?
首先明白main是在什么阶段执行的:
(1)加载程序二进制:操作系统将可执行文件加载到内存,准备执行。
(2)静态初始化阶段:初始化所有全局变量、静态变量(包括全局对象的构造、静态成员的初始化)。
(3)调用 main 函数:静态初始化完成后,才会进入 main 函数执行用户代码。
(4)静态销毁阶段:main 执行结束后,销毁全局 / 静态变量(调用其析构函数)
要想在main之前执行一些内容就必须(2)解决去做这些事情;
方法 1:全局对象的构造函数(最常用)
定义一个全局类对象,在类的构造函数中编写需要提前执行的逻辑。全局对象的构造会在 main 前自动触发。
方法 2:静态成员变量的初始化
类的静态成员变量属于 “全局生命周期”,其初始化会在 main 前完成。可通过静态成员的初始化逻辑实现 main 前操作。
方法 3:局部静态变量的初始化(C++11+)
局部静态变量(函数内定义的 static 变量)的初始化时机是 “第一次调用该函数前”—— 若函数在 main 中被首次调用,其初始化仍会在 main 执行前完成(编译器优化)。
方法 4:attribute((constructor)) 编译器扩展(非标准,慎用)
GCC、Clang 等编译器支持 __attribute__((constructor)) 修饰函数,被修饰的函数会在 main 前自动执行(属于编译器扩展,不兼容 MSVC 等其他编译器)。
静态方法初始化顺序?(effective c++)条款3
初始化顺序不确定
多个全局 / 静态变量的初始化顺序是 “未定义行为”—— 编译器不保证哪个先执行。例如:
// 全局变量 A
int a = [](){ std::cout << "A"; return 0; }();
// 全局变量 B
int b = [](){ std::cout << "B"; return 0; }();
输出可能是 AB 或 BA,若 a 依赖 b 的初始化结果,会导致逻辑错误。
解决办法:尽量避免多个 main 前操作的依赖;若必须依赖,可通过 “单例模式 + 局部静态变量” 保证顺序
(C++11 后局部静态变量初始化是线程安全的)。
避免复杂逻辑
main 前执行的代码若抛异常、死锁或调用未初始化的资源(如 std::cout 虽可正常使用,但部分库可能未就绪),会导致程序直接崩溃,且调试难度极高。
建议:仅用于 “轻量级初始化”(如加载配置、注册模块、打印启动信息),复杂逻辑(如网络连接、数据库初始化)放到 main 中执行。
线程安全问题(C++11 前)
C++11 标准明确 “局部静态变量的初始化是线程安全的”,但 C++11 前或部分旧编译器中,多线程环境下的静态初始化可能存在竞争问题。
解决办法:跨线程项目优先用 C++11+ 标准,或避免在 main 前执行多线程相关逻辑。
编译器扩展的兼容性
方法 4 中的 __attribute__((constructor)) 是 GCC/Clang 扩展,MSVC(Visual Studio)不支持(MSVC 用 #pragma init_seg,逻辑更复杂)。跨平台项目优先用 “全局对象” 或 “局部静态变量” 的标准方法。
模板元编程(effective c++)条款48
详细请看后一章节
结语
其实本模块的内容不多,难度也不算太大,但是输出起来感觉还是蛮费劲的,输出的内容也是比较混乱,可能还是因为内力不够吧,在剩下的模块输出的时会慢慢改善,争取每一个模块的输出都能进步一点,至少能解析清楚;
sylar的下一个模块就是线程模块了,其实并发编程的内容还是蛮多的,我会结合一些经典书籍进行输出,而不是简单的介绍接口使用,前面模块的不算是服务器框架的核心内容,基本上就是当天就能把博客输出完,后面的博客会做的更加深入和专业一点,可能时间上可能会拖到每个章节用几天的时间,但是sylar服务器框架的复盘章节的博客肯定会在9月份内完整的输出完,10月份就是输出基于sylar服务器框架的IM即时通讯项目的内容;
再就是我会单独开一节对这两个模块的内容做一个更完整的总结,特别是对于偏特化、多态等等这些内容;
大家如果想一起学习的可以加下qq群:1055531113;







