介绍
这是很久之前的一个项目了,最近刚好有些时间,就来总结一下吧!
推荐初步熟悉项目后阅读本文: https://gitee.com/smalldyy/easy-msg-cpp
从何而来
这要从我从事Qt开发的那些日子说起了,项目说大不大,说小也不小,人倒是一茬又一茬,需求也换了又换,后来的事情大家都懂了,项目变成了一坨浓Shit,且不说其中的设计、构架、以及需求问题,单说说我对这个项目的直观感受,在我看来,整个程序仿佛一颗大树,从某点作为根然后一直向上延伸,在没有足够时间重构的情况下,它的层级越来越深,这时候问题来了,如果想让树木的两个不同分支的叶子节点发生关系,事情就马上会变得十分痛苦!
这两个想要联系的对象根本不再一个地方,我可能要将其中一个对象的指针在这颗大树的节点上倒退3层然后再前进2层才能让他们见面,然后暗戳戳的写下一个connect。
这时候我就想,如果有一个专门的通信组件负责传递各种消息,让两个对象中间产生一个媒介作为他们通信的桥梁,获取这件事情就会变得更加轻松了,我不用再费尽心思的将两个对象引用到同一个作用域,甚至还要考虑哪个作用域更加合理。
诚然,如果在前期就对项目的各个组件进行全盘规划,我想这种困境可能不会或者很少出现,但是并非所有事情都会按照美好的方向前进,就如曾经堆在我面前的那坨浓Shit,尽管我也为它的存在出过不少力…………
设计目标
- 提供C++对象进程内通信功能 可进行消息传递;
- 将已经存在的结构体定义为消息时,不能破坏已经存在的结构体本身的结构;
- 处理消息的类无需继承任何基类;
- 足够简单的订阅方法;
- RAII形式的取消订阅,但也支持手动取消订阅。
你可能注意到了,我特意强调了不破坏原有结构。目的很简单,就是为了保证项目不会因为引入这个组件而发生太大的变化。众所周知,大部分程序员都是懒癌晚期,如果引入一个组件会导致工作量激增,程序员就会开始衡量shit的臭味和工作量之间的关系了。
总之,核心特征只有两个:易用,改动小。
原理分析
我首先将这个组件设计为一个基于订阅分发方式的通信组件,它有三个主要角色,订阅者,发布者,和消息。
首先考虑最简单的发布者,发布者的功能非常直观——发送消息,也就是说用户只要在需要的位置调用一个sendMsg之类的函数即可,这个函数的功能就是将用户给定的消息发送出去。
然后便是订阅者,我们要求订阅的宿主类型不可以继承任何基类,这个要求决定了我们订阅的方式,我们需要提供一个函数,它接受一个对象的指针(我称之为宿主)和它的成员函数,将两者包装成一个std::function
,将这个包装好的回调函数与一个定义好的消息关联并记录下来,这就形成了订阅关系。
当发布者发送消息时,我们的组件需要查询订阅关系,找到消息对应的回调函数,将消息作为参数调用它!此时,对象间就完成了一次通信。我们的组件就是信使,这样就无需发信人四处奔波了。
我们还要求不破坏原本的结构体的结构,这也就意味着我们不能改动已经存在的结构体,比如果让它继承一个消息基类然后就能作为消息传递之类的操作——虽然很好,但是我们得对这个设计说拜拜了。但是,上树订阅分发的流程必然要求消息拥有一个统一的基类类型,否则我们无法统一回调函数的函数签名,存储订阅关系也就无从谈起了!因为参数类型不同的函数,是很难存储到一个容器中以供查询的!
为了解决这个闹人的问题,我们不妨反向思考一下,既然我们不能让一个已经存在的消息结构继承我们的基类,那么就创建一个新的类型同时继承两者吧!
class NewExistMsg : public ExistMsg, public em::EasyMsg
用户可以使用 NewExistMsg
来创建消息体,就像使用 ExistMsg
一样,回调函数可以使用EasyMsg*
作为参数,来达到类型的统一,并可以安全的进行多态设计。
至此,消息的问题也解决了。
你可能会感兴趣的技术细节
以下是EasyMsg的头文件:
class EASYMSG_API EasyMsg {
public:
EasyMsg();
virtual ~EasyMsg() = default;
virtual std::string id() const = 0;
template <typename T> struct is_easymsg {
template <typename U> static char test(typename U::MsgType *x);
template <typename U> static long test(U *x);
static const bool value = sizeof(test<T>(0)) == 1;
};
// c++17 support constexpr if
#if ((defined(_MSVC_LANG) && _MSVC_LANG >= 201703L) || __cplusplus >= 201703L)
template <typename EASY_MSG_ID> bool match() {
is_easymsg<EASY_MSG_ID> test_easymsg;
if constexpr (test_easymsg.value) { // c++17
return id() == EASY_MSG_ID::value;
} else {
std::cerr << "匹配消息ID时发生错误,检查是否使用了未定义的消息? 检查:"
<< typeid(EASY_MSG_ID).name() << std::endl;
return false;
}
}
#else
template <class MSGID>
typename std::enable_if<!is_easymsg<MSGID>::value, bool>::type match() {
std::cerr
<< "匹配消息ID时发生错误,检查是否使用了未定义的消息? typeinfo : "
<< typeid(MSGID).name() << std::endl;
return false;
}
template <class MSGID>
typename std::enable_if<is_easymsg<MSGID>::value, bool>::type match() {
return id() == MSGID::value;
}
#endif
};
这里边有一些有意思的东西可以学习一下,首先映入眼帘的就像是经典的虚析构函数,这是作为多态基类的必要手续。接下来就是SFINAE
的经典用法,我是用这个技巧实现了match函数,这个函数的主要作用就是判断给定的EASY_MSG_ID是否和传入的消息指针是同一种消息类型。
match根据c++标准分成了两个实现,C++17版本借助了 constexpr if特性。以前的版本则用了经典的std::enable_if。
对SFINAE
不甚了解的人应该很难理解这些代码,SFINAE
中文含义为“匹配失败不是错误”,这对模板变成来说非常重要,不过这已经超出了本文范围,我仅仅是抛砖引玉,之后我可能会更新文章对此段代码进行详解,从而让大家了解这些惯用法。
其他的便没有什么技术细节了,都是些常规的东西,无非是用map记录下订阅关系,然后send时执行回调之类的东西,不值细说。
结论
本文向大家介绍了一个侵入性较低的C++对象间通信组件,或许可以帮助你解决一些头疼的通信问题,并展示了一些你可能感兴趣的技术细节,如果能引发更多的思考那就更好不过了!