之前已经有一个RPC代码,但是,实在是太简陋了,这里在原来的基础上做了一点扩充,加了一些反射在里面。
一、Protobuf的RPC结构
首先需要分析一下RPC的整体结构,主要参考全图文分析:如何利用Google的protobuf,来思考、设计、实现自己的RPC框架
以下面为例结合上面图片分析(建议看原链接,内容也来自原链接)。
// proto文件
// echo_service.proto
syntax = "proto3";
package test;
option cc_generic_services = true;
message EchoRequest {
string message = 1;
}
message EchoResponse {
string message = 1;
}
message AddRequest {
int32 a = 1;
int32 b = 2;
}
message AddResponse {
int32 result = 1;
}
service EchoService {
rpc Echo(EchoRequest) returns(EchoResponse);
rpc Add(AddRequest) returns(AddResponse);
}
经过protobuf的编译器就会产生echo_service.pb.h, echo_service.pb.c两个文件,包括其中包括两个class,EchoService和EchoService_stub,EchoService用作服务端,EchoService_stub用作客户端。关系如下
具体::google::protobuf::Service包括什么方法不清楚,三个类别EchoService、Service_Stub和EchoServiceImpl大概的作用如下:
- EchoService:相当于一个接口,提供了callmethod方法,getrequest和getresponse实例的方法,定义了service提供的方法。
- EchoService_Stub:调用Echo方法和add方法,这两个方法在客户端就是调用callmethod方法,这个callmethod方法就是将请求的函数名称和参数发送给服务端。所以客户端初始化stub主要是初始化channel,这个channel是一个tcp连接,需要初始化服务端的地址。
- EchoServiceImpl:实现Echo和Add的具体方法,至于对端有了发送数据,服务端会自动接收数据,解析请求,根据请求的service名称,方法,参数,执行相应的函数,然后将结果发送给对端。
所以,核心问题是:(1)message如何序列化?(2)如何根据函数名字找到函数?
序列化问题,一个是参数如何编码,这个就是数据如何存储的问题,向leveldb中变长存储等等,而protobuf提供了数据类型的关键字,这也就是说protobuf只支持那些类型数据化(并不是说其他的类型数据不能序列,而是可以通过这些类型组合),而后面我们实现自己的数据序列化时,数据只能是basic类型,和string类型,并且不像protobuf对数据进行了压缩。其时最简单的序列化方法,class内存中的字节copy下来以二进制流方式发送给对方,对方开辟相应大小的内存,copy过去就行了。
第二个问题,如何通过名字找到函数,通过名字找到成员变量,获得成员变量的名字,通过名字构建一个class,这个对C++来说,确实有点困难,毕竟,C++是静态语言,先是编码成二进制可执行文件,文件中都是以地址形式出现,哪有什么运行时的类型信息,除非主动在代码中写入类型信息,编译时编译到文件中,然后才能找到,至于数据类型,这个只能通过模板元编程,在编译器期间实现类型的自动推断。这个大概就是所谓的反射机制了吧。反正C++想简简单单的实现反射是不可能,要么有编译器的支持,要么花费麻烦的方式,毕竟不像JAVA哪些语言天然支持反射。不过,后面在实现反射上偷偷取个巧,比如,前面,根据函数名称找函数,通过map将两者映射起来,后面也需要手动的记录函数名称,变量啥的,但是不会向前面那样,会更加灵活一点。
二、反射实现
这里反射是参考了深入探讨C++的高级反射机制(2):写个能用的反射库,不过没有加入动态反射,好像没必要根据类名构建class,就没有实现。
#define REFLECTABLE_PROPERTIES(TypeName, ...) using CURRENT_TYPE_NAME = TypeName; \
static constexpr auto properties() { return std::make_tuple(__VA_ARGS__); }
#define REFLECTABLE_MENBER_FUNCS(TypeName, ...) using CURRENT_FUNC_NAME = TypeName; \
static constexpr auto member_funcs() { return std::make_tuple(__VA_ARGS__); }
// 这个宏用于创建属性信息,并自动将字段名转换为字符串
#define REFLEC_PROPERTY(Name) VarMetaInfo<decltype(&CURRENT_TYPE_NAME::Name), decltype(CURRENT_TYPE_NAME::Name), &CURRENT_TYPE_NAME::Name>(#Name)
#define REFLEC_FUNCTION(Func, Bind) FuncMetaInfo<decltype(&CURRENT_FUNC_NAME::Func), Bind, &CURRENT_FUNC_NAME::Func>(#Func)
宏这一块和博客是一样的,不过这里关于class成员变量和成员函数,我们新增了一些记录,
// 成员函数信息
template <typename T1, typename T2, T1 Value>
struct VarMetaInfo {
const char* name; // 成员变量的名字
using type = T2; // 成员变量类型
constexpr VarMetaInfo(const char* name) : name(name) {}
constexpr T1 get_value() const { return Value; } // 这是获得成员变量的偏移地址
};
// 成员函数信息
template <typename T, typename F, T Value>
struct FuncMetaInfo {
template<typename TT>
struct function_traits;
template<typename R, typename ...Args>
struct function_traits<std::function<R(Args...)>>{
static const size_t nargs = sizeof...(Args);
using result_type = R;
using fargs = typename std::tuple<Args...>;
};
const char* name; // 成员函数的名字
const int nargs = function_traits<F>::nargs; // 成员函数的变量个数
constexpr FuncMetaInfo(const char* name) : name(name) { } // 成员函数的名字
constexpr T get_func() const { return Value; } // 成员函数的地址
typedef typename function_traits<F>::result_type ret; // 成员函数的返回值类型
typedef typename function_traits<F>::fargs fargs; // 成员函数的参数类型tuple
template <size_t i>
struct arg{
typedef typename std::tuple_element<i, fargs>::type type;
}; // 获取第N个成员类型
};
用法如下
struct VoteMsg : public Message{
int m_iTerm;
int m_iCandidateId;
int m_iLastLogIndex;
int m_iLastLogTerm;
// 构造函数
VoteMsg() = default;
VoteMsg(int term, int cand, int lslogidx, int lslogterm)
: m_iTerm(term), m_iCandidateId(cand), m_iLastLogIndex(lslogidx), m_iLastLogTerm(lslogterm){}
// 序列化与反序列化
void SerilizeToString(mIOSream &o){
serilize(o, *this);
}
void DeserilizeToString(mIOSream &o){
deserilize(o, *this);
}
// 加入成员变量
REFLECTABLE_PROPERTIES(VoteMsg,
REFLEC_PROPERTY(m_iTerm),
REFLEC_PROPERTY(m_iCandidateId),
REFLEC_PROPERTY(m_iLastLogIndex),
REFLEC_PROPERTY(m_iLastLogTerm)
);
};
class raftService : public Service{
public:
raftService() = default;
virtual void method1(VoteMsg* votereq, VoteResponse* voteresp) = 0;
virtual void method2(VoteMsg* votereq, VoteResponse* voteresp) = 0;
virtual ~raftService() {}
template<size_t N = 0, typename Tuple>
void CallMethodImpl(const char* name, mIOSream& mio, const Tuple& funcs);
void CallMethod(const char* name, mIOSream& mio);
// 加入成员函数
REFLECTABLE_MENBER_FUNCS(raftService,
REFLEC_FUNCTION(method1, std::function<void(VoteMsg*, VoteResponse*)>),
REFLEC_FUNCTION(method2, std::function<void(VoteMsg*, VoteResponse*)>)
);
};
实际上,C++如果没有编译器的帮助,要么通过typeid.name拆分名称,要么手动通过宏记录变量/函数及其变量名称,编译期间通过变量的符号名称,如果去解析符号名称,这个着实有点复杂,毕竟C++编译器的符号表命名规则不是很懂。所以这里还是需要手动输入名字(这里其是怎么说,如果再多费点功夫,干脆手动建个map好像也不是不行,至少,多点手动,少点代码复杂度.....)
至于博客中的relf,看懂之后,基本就知道如何提取meta信息了(不过看懂那玩意确实有点费劲),这里就总结一下反射中的注意项:
- 这个代码需要至少C++17编译。
- 反射保存class的成员信息是通过C++11特性tuple可以保存不同类型的数据,比如第一个数是int,第二个是float数据。但是,如果想从中获得第N个成员,这个N必须是在编译期间确定的。怎么理解编译期间确定呢,最简单的方法就是,假如想获得第i个元素,如果这个i是个变量(可以理解成如果这个i是通过cin输入的,比如输入1,获得第一个元素,输入2或得第2个元素)这是不可能通过编译的,也就是说变量的数据可以是不确定的,但是类型必须是确定的,比如int a= b + 1;给出不同的b,a的数值是可以随意改变的,但是,a是int型,无论b是什么,这条语句a都是int型,而tuple,auto a = std::get<N>(tuple);如果N是随意改变的,那么编译的时候a的类型就是不确定的,这对C++来说是不会被允许的(而python是在运行期间确定的,所以python只会在运行的时候报错)。这是这个问题,在写代码的时候遇到一个困扰三天的问题。
template<typename T> void serilize_impl(mIOSream& o, T& elem){ constexpr bool is_str = std::is_same_v<T, std::string>; constexpr bool is_cls = std::is_class_v<T>; if constexpr(is_cls && !is_str){ elem.SerilizeToString(o); } else{ o.serilize(elem); } }
这个函数功能是这样的,如果输入的类型是basic类型,就值及调用函数,如果是class类型,就调用成员函数(先忽略class有没有这个方法的问题)想根据输入的类型不同,执行不同的分支,如果没有if constexpr(...)会编译不通过,会说,int类型不是class类型,没有SerilizeToString方法。是不是很愚蠢的问题,我在调用SerilizeToString之前判断了类型,不是class根本不执行这个语句,为什么编译器还会报这种错误,实际上,当时一开始我就是想通过constexpr,按理说应该不会出错了,但是当时是这么写的
template<typename T> void serilize_impl(mIOSream& o, T& elem){ constexpr bool is_str = std::is_same_v<T, std::string>; constexpr bool is_cls = std::is_class_v<T>; if (is_cls && !is_str){ elem.SerilizeToString(o); } else{ o.serilize(elem); } }
就是if那没有constexpr,我觉得,如果两个布尔值用constexpr修饰,if自然会优化成constexpr,但是这样编译器回报错,于是我想到了第二个方案,就是把is_same的true_type和false_type当作参数输入一个模板函数,编译器会根据两个type匹配不同的模板实现执行不同的函数,其是这也是偏特化,但是C++的函数没有偏特化(确实函数,模板的偏特化没什么意义,函数有重载,不需要偏特化),这个问题思考了三天,一直找不到一个合理的解决方案,因为,我们肯定需要给函数送入两个类型,一个是truetype或falsetype,一个是elem的类型,然后通过truetype或falsetype匹配函数不同的模板,这个对函数来说似乎有点复杂,要么写一个class,重载()符号,相当于一个函数对象,然后写class的偏特化,但是这个方法看着有点愚蠢,主要,这个问题不复杂,然后纠结了好几天,试了许多方法都不行,绝望之际,我真的不相信C++编译器不能在编译阶段推断出类型优化那个不需要的分支,于是就在三个地方都加了constexpr,就最后是前一个代码,结果,就编译过去了!!我觉得是编译的时候-g debug使得那个代码编译禁止所有优化,不然两个constexpr bool肯定在编译期间就可以优化不需要的分支(我猜的没有-g应该会编译成功,没有尝试)。
- 然后是第三个问题,是当时回去路上突然意识到这么一个问题:模板实际上是不存在的函数,而是在编译调用并给出模板的参数,然后特化出的函数,而我在写代码测试的时候,比如输入一个int,然后编译链接,编译器和知道给的数据的类型,一位定义在main函数,所以编译器知道特化一个类型的函数,比如mian中是int,会特化int,是float,会特化float。但是,服务端的是先运行程序,然后根据对方给的数据找到类型,根据类型匹配函数执行,而模板没有使用某个类型,就不会特化出某种类型的函数,执行的时候就不会有某个类型的函数,服务端代码先编译,在执行,编译的时候怎么会知道对方给什么数据,不知道数据类型,就不会特化出对应的函数,那怎么执行??当时,突然觉得,自己不会在自娱自乐吧,这么多天的代码根本没有实现反射什么的,因为test数据是写在代码里,编译器不过是根据特定的类型特例化对应的函数执行,如果数据是从cin输入的,根本就会报错,因为没有特例化函数。当时特别绝望,觉得浪费了这么久,结果是在自娱自乐,根本没有什么反射,只不过是被编译器骗了!!于是在这种绝望的想法下,自闭了一天,但是!!突然,我想明白了,还是之前那个if else的错误让我突然明白模板是什么,为什么模板对C++来说有用了。我们还是先从一个例子说起
void func(const char* name){ switch(name){ case str1: cfunc1(..); case str2: cfunc2(..); ... } }
我们想这么一个伪代码,func接受一个函数名字,然后switch字符串和哪个匹配,执行哪个函数(switch是不能给字符串的,上面是代码逻辑而以),我在test的时候,直接调用func("method1");这句话name是字符常量,编译器编译的时候,可以直接推断出执行哪个分支,然后特例化对应case的cfunc,而不会特化其他的case,这样服务端编译时怎么特化函数,编译,就不会编译任何特化函数,那服务端岂不是个虚名。这种想法是错的!!!而且错的很基本,正是因为编译器不知道name是什么,会和哪个str匹配,所以编译器会特化每一个case的函数,就如同上面的if else一样,编译期间不知道执行哪个分支,所以两个分支都存在,而对于int类型来说没有SerilizeToString,所以编译期间会检查出错误。换句话说,模板是在利用编译器帮你写代码,根据需要特例化一些函数,所以对C++来说,只有哪些在class中定义的宏class可能会被特例化对应类型的函数方法,而没有用宏反射的,不会特例化对他们的函数,简单来说,C++是静态语言,我们的宏帮助在编译期间记录一些信息,但是如果class没用用宏记录信息,运行时候类型等信息是丢失的,但是java不一样,java的jvm在编译的时候已经记录所有的class信息,函数信息,jvm本是就是一个巨大的class工厂,因此可以通过注入的方式构造函数,而C++没有函数信息,所以需要人为记录。这就是为什么protobuf有自己的编译器,对C++来说,编译器不能帮你保存各种符号信息,要么手动在每个class中记录信息,写在代码程序里(或者说写在二进制可执行文件的代码段或者只读段等等就是编译出的二进制文件),要么,有个东西能把你写完的cpp文件转换成另一个cpp文件,转换的cpp文件里添加了保存class信息的二进制代码,而这个东西就是protobuf的编译器作用,将proto文件内的数据结构,转换成cpp文件,在类里加入了序列化方法,get,set方法等等。
总结来说,变量内容是变化的,输入不同,执行到同一行代码,变量的内容可以是不一样的,但是无论输入如何变化,执行到同一行代码,变量的类型是确定的。变量的内容是在运行时cpu做运算,而模板编程是面向C++编译器的编程,像using,typedef这种类型的重命名是对编译器来说的,包括什么is_same,is_class等等,这些都是对编译器来说的,用来控制编译器怎样生成代码,生成哪些代码,而函数的运算逻辑,加减乘除,函数调用,才是cpu要执行的逻辑流。