分享一个C++下使用简单的反射实现的程序模块化的思路

分享一个C++下使用简单的反射实现的程序模块化的思路

首先说一个基本问题,项目大了以后,一定要做模块化处理,每个模块处理各自的事情,各个模块之间尽量不要有太多的耦合,就是说模块A尽量不要依赖模块B,模块B也不要依赖模块A。千万不要把所有代码写在一起搞成一锅粥,人脑是有限的,拆分成各个小块,一是逻辑简单不容易出问题,二是即使出了问题排查起来也更容易。

例子

由于我没有想到一个比较好的,对新手也特别容易理解的例子,所以这里就直接按照我能想到的举例吧。

假如我们有个需求是这样的:

有一个tcp server,要接收客户端发过来的请求,然后根据不同的请求,做出不同的处理后再回复客户端。

当收到客户端发过来的数据 进程信息 时,tcp server这个程序就列出所有的进程信息,然后发送给客户端;

当收到客户端发过来的数据 文件信息 时,tcp server这个程序就列出所有的文件信息,然后发送给客户端;

那这个需求怎么划分,分成哪些模块呢?

这里就把它分成三个模块(我们这里暂不考虑客户端的问题):

  1. tcp server 数据收发模块
  2. 列出进程信息的模块
  3. 列出文件信息的模块

初步的代码实现

先添加三个代码文件,每个文件里有一个类,这三个文件就表示上面所说的三个模块,如下:

// 文件 tcp_server.hpp
struct tcp_server
{
    void on_recv(std::string data)
    {
        // 假定这里表示收到了客户端的数据,具体的处理过程这里暂时不写,
        // 只要知道这个暂时就只是用来表示模块划分这个概念,且这个模块
        // 就只处理和本模块相关的逻辑就行了。
    }
}
// 文件 process.hpp
struct process
{
    void get_process_info()
    {
        // 假定这里是表示查询进程信息的
    }
}
// 文件 files.hpp
struct files
{
    void get_files_info()
    {
        // 假定这里是表示查询文件信息的
    }
}

然后是main函数

// 文件 main.cpp
// 把三个模块的实现文件include进来
#include "tcp_server.hpp"
#include "process.hpp"
#include "file.hpp"
int main()
{
    // 加载三个模块,这里就直接通过构造三个模块的类的对象来表示了
    tcp_server m_server;
    process m_process;
    files m_files;
}

先忽略掉这些代码中,那些感觉怪怪的,不太对的问题,先从简单开始往下走。

第一个问题

假如过了一段时间,又出现了一个新需求:

当收到客户端发过来的数据 硬件参数 时,tcp server这个程序就列出当前设备的硬件参数信息,然后发送给客户端;

怎么做呢?再写一个文件,里面有个类,如下:

// 文件 device.hpp
struct device
{
    void get_device_info()
    {
        // 假定这里是表示查询硬件参数信息的
    }
}

然后修改main.cpp文件和main函数,如下:

// 文件 main.cpp
// 把三个模块的实现文件include进来
#include "tcp_server.hpp"
#include "process.hpp"
#include "file.hpp"
#include "device.hpp" // 这次要包含硬件参数这个新的头文件
int main()
{
    // 加载三个模块,这里就直接通过构造三个模块的类的对象来表示了
    tcp_server m_server;
    process m_process;
    files m_files;
    // 再构造硬件参数信息这个模块
    device m_device;
}

假如又过了一段时间,又再次出现了一个新需求,… 如次往复…

每次出现新需求时,我们都需要添加一个文件,然后修改main.cpp文件和main函数。

其中添加新文件这个操作是避免不了的,但是每次都要修改main.cpp文件和main函数这个操作就有些烦琐了,而且上面的演示代码中只是构造了每个模块的对象,却并没有调用该对象的成员函数等,实际上每个模块都要有接口函数的,而且接口函数通常会有多个,每个都要去调用的,这样每次需求变更时main函数中要写的代码就很多了。

解决第一个问题

如果每创建一个类时,这个类能自动将自己注册到某个地方,而且我们还能到那个地方去取出来,总共有多少个注册过的类,就好了。

怎么实现这个功能呢?我用的是从这篇文章中学到的办法:C++反射机制:可变参数模板实现C++反射(二)https://zhuanlan.zhihu.com/p/320061875

我修改了他的一些代码用来实现这个功能,这是基础代码用来实现反射的:https://github.com/zhllxt/asio3/blob/main/include/asio3/core/pfr.hpp

有了上面这个东西,我们修改一下之前的代码,如下:

首先,我们造一个虚基类(这次我把我常用的几个接口函数全写上了):

struct imodule
{
    virtual ~imodule(){}

    virtual bool init() = 0;
    virtual bool start() = 0;
    virtual void stop() = 0;
    virtual void uninit() = 0;
}

然后修改模块的实现类:

// 文件 tcp_server.h
struct tcp_server : imodule, pfr::base_dynamic_creator<imodule, tcp_server>
{
    // 实现虚基类的几个接口函数
    virtual bool init() override {}
    virtual bool start() override {}
    virtual void stop() override {}
    virtual void uninit() override {}

    void on_recv(std::string data);
}

// 文件 tcp_server.cpp
// 注意:现在必须要有一个cpp文件了,不能只是hpp文件,因为只有cpp文件才会参与编译过程。
// 而只有参与了编译过程,pfr::base_dynamic_creator<imodule, tcp_server>这个反射的
// 代码才会起作用,也就是这个类才能够被注册到类工厂里面去。后面的类相同,不再解释了。
void tcp_server::on_recv(std::string data)
{
    // ...
}
// 文件 process.h
struct process : imodule, pfr::base_dynamic_creator<imodule, process>
{
    // 实现虚基类的几个接口函数
    virtual bool init() override {}
    virtual bool start() override {}
    virtual void stop() override {}
    virtual void uninit() override {}

    void get_process_info();
}
// 文件 files.h
struct files : imodule, pfr::base_dynamic_creator<imodule, files>
{
    // 实现虚基类的几个接口函数
    virtual bool init() override {}
    virtual bool start() override {}
    virtual void stop() override {}
    virtual void uninit() override {}

    void get_files_info();
}

解释一下上面的代码:

主要就是这个派生理解起来困难一点: struct tcp_server : imodule, pfr::base_dynamic_creator<imodule, tcp_server>

首先 tcp_server : imodule 这里要从 imodule 派生的目的是为了我们能将所有的模块类对象都保存到一个 vector 中,这个功能先放一放后面会说到。

然后从这个类 pfr::base_dynamic_creator<imodule, tcp_server> 派生的意思是:

我们有一个类工厂,名字叫 class_factory, 只要你的类从pfr::base_dynamic_creator<imodule, tcp_server> 派生了,那么你的这个类就会自动被注册到类工厂中,然后你就可以调用类工厂的创建函数 class_factory::create(...) 来创建一个该类的实例对象了,而该创建函数的返回值是 imodule* (创建函数实际上就是new了一个对象出来),这样我们就可以保存这个类对象了。

pfr::base_dynamic_creator<imodule, tcp_server> 就表示会创建tcp_server这个类对象的实例,而返回值是imodule*类型的。

OK,有了上面的解释之后,可以知道,上面三个类都是从 pfr::base_dynamic_creator 派生的,那么类工厂就会自动注册进了这三个类了。

接下来我们再写一个专门用来管理模块的类

// 文件 module_mgr.hpp
struct module_mgr
{
	bool init()
	{
        // class_factory<imodule> 的意思是这样:
        // 还记得前面各种这样的派生代码: base_dynamic_creator<imodule, ...> 这种派生
        // 代码就会导致class_factory<imodule>这个模板类被实例化,实例化听起来难以理解,
        // 你就当成是new了一个class_factory<imodule>这种类型的对象出来就好理解了,而
        // class_factory<imodule>是个单例类,class_factory<imodule>::instance()就是
        // 取出那个单例对象,然后拿来用,就这样子。
		pfr::class_factory<imodule>& factory = pfr::class_factory<imodule>::instance();

        // 调用工厂类对象的for_each函数,这个函数会遍历,所有已经注册进工厂里面的类了。
        // 这里遍历完实际上有三个类,即前面的tcp_server, process, files这三个类
		factory.for_each([this](std::string class_name, const auto& create_func)
		{
            // class_name是类名称,实际上就是"tcp_server", "process", "files"这三个字符串
            // create_func是创建对象的函数,实际上就是用来创建tcp_server, process, files
            // 这三个类的对象的那个创建函数
            // 这里我们调用create_func()创建一个类的对象,然后将类名称和对象本身保存到一个
            // map中,注意这里create_func()的返回值是imodule*即imodule的指针,我们将其
            // 转换为shared_ptr再保存,避免后面要手动delete的问题
            // 还记得前面 tcp_server : imodule 为什么要从imodule派生了吗?原因就在这里,就
            // 是为了让tcp_server, process, files这三个不同类型的对象能保存到同一个map里。
            // 这里保存的就是基类指针,然后通过virtual多态,又能调用到真正的派生类的函数。
			module_map.emplace(class_name, std::shared_ptr<imodule>(create_func()));
		});

        // 然后遍历我们的map,得到的就是每个模块的,对象的,指针,这样我们就可以调用
        // 每个模块的init函数了,注意module_ptr的类型是std::shared_ptr<imodule>,但是
        // 因为init函数是个虚函数,所以真正的模块的对象即tcp_server, process, files这三
        // 个类的init函数就会被调用了。
		for (auto& [class_name, module_ptr] : module_map)
		{
			module_ptr->init();
		}

		return true;
	}

	bool start()
	{
        // 同上
		for (auto& [class_name, module_ptr] : module_map)
		{
			module_ptr->start();
		}
	}

	void stop()
	{
        // 同上
		for (auto& [class_name, module_ptr] : module_map)
		{
			module_ptr->stop();
		}
	}

	void uninit()
	{
        // 同上
		for (auto& [class_name, module_ptr] : module_map)
		{
			module_ptr->uninit();
		}
	}

	std::map<std::string, std::shared_ptr<imodule>> module_map;
};

再修改一下main.cpp文件和main函数,如下:

// 文件 main.cpp
// 这次只需要include模块管理类就行了
#include "module_mgr.hpp"
int main()
{
    // 构造模块管理类的对象
    module_mgr m_module_mgr;

    // 然后依次调用初始化,启动等函数:
    m_module_mgr.init();

    m_module_mgr.start();

    // 如果按下回车键,程序退出
    while (std::getchar() != '\n');

    // 程序准备退出了,开始停止和卸载所有的模块
    m_module_mgr.stop();

    m_module_mgr.uninit();
}

有了这个功能之后,再新增硬件参数这个模块时,就只需要添加相应的文件就行了,如下:

// 文件 device.h
struct device : imodule, pfr::base_dynamic_creator<imodule, device>
{
    // 实现虚基类的几个接口函数
    virtual bool init() override {}
    virtual bool start() override {}
    virtual void stop() override {}
    virtual void uninit() override {}

    void get_device_info();
}

// 文件 device.cpp
void device::get_device_info()
{
    // ...
}

此时main函数以及其它的代码就完全不需要再改动了。以后要增加新的模块,也是同样再只添加该模块对应的文件即可,其它代码不用动。但有个前提是,新增完文件后,要重新编译工程代码才会生效。重新编译之后,新添加的模块的类就会被自动注册到类工厂中,然后在module_mgr中被创建出来,然后运行程序时该类的init start等接口函数就会被调用到了。

第二个问题

前面说到,有这个需求:

当收到客户端发过来的数据 进程信息 时,tcp server这个程序就列出所有的进程信息,然后发送给客户端;

谁收到客户端发过来的数据?肯定是 tcp_server 模块

谁来列出所有的进程信息?肯定是 process 模块

所以问题就来了:tcp_server模块收到请求后,要告诉process模块,而process 模块处理完之后,要把处理结果再通知回去给tcp_server模块

在前面的的示例代码中,tcp_server模块和process模块之间并没有什么联系,就是他们之间没什么耦合性,那怎么做这个模块之间互相通知的功能呢?

这里用到了一个开源库:https://github.com/wqking/eventpp

同时我又修改了一下这个开源库,更符合我自己用的:https://github.com/zhllxt/asio3/blob/main/include/asio3/core/event_dispatcher.hpp

实际上这个开源库的实现就是设计模式中的 监听者模式 ,如果新手对 监听者模式 不太了解的话,建议先去搜一下,先看一下最简单的 监听者模式 的实现代码,就明白是怎么回事了。 监听者模式的用途很普遍,不知道的话建议先了解一下。这个开源库的实现代码相当复杂,大量晦涩难懂的模板,但是他的扩展性适用性很强。

接下来说具体的实现代码。

这里要用到的类就是上面说的这个开源库里的 event_dispatcher 这个类,那首先肯定要定义一个这个类的变量了,比如event_dispatcher m_event_dispatcher;

这个变量放在哪儿呢?你可以放在 main 函数的前面,作为一个全局变量这样子:

event_dispatcher m_event_dispatcher;

int main()
{

}

但我的习惯是这样的:

做一个单例类,然后把所有的要共用的变量,全部放在这个单例类中,通常整个程序就只有这一个单例类,而且这个单例类会共享给所有模块。下面是具体的代码:

// 文件 app.hpp
class application
{
public:
    static application& instance() { static application g; return g; }

    // 然后把event_dispatcher变量放在这里
    event_dispatcher event_dispatcher;

    // 把模块管理类的基类指针放在这里,前面的代码中我没有做imodule_mgr这个
    // 基类类型,实际上我习惯,把所有业务系统中要用到的公用的类,都做个基类,
    // 然后全部放在application这个单例类里,也就是说这个单例类里面只会保存
    // 基类指针,而不会保存具体的实现类的指针。
    // 具体可参考这里:https://github.com/zhllxt/naslite
    std::shared_ptr<imodule_mgr> module_mgr;

    // 比如配置模块的指针
    std::shared_ptr<iconfig>          config{};

    // 比如日志对象的指针
    std::shared_ptr<spdlog::logger>   logger{};

    // 等等.....
}

// 这里再声明一个全局变量,方便以后使用
application& app = application::instance();

下一个问题:这个event_dispatcher实际上可以理解为是一个数据转发器,它可以把数据从模块A转发到模块B,既然是数据转发器,那它转发的数据到底是什么呢?这个数据实际上你是可以随意定义的。

我的习惯是这样的:

首先还是定义一个基类,就叫event事件基类:

struct ievent
{
    virtual ~ievent(){}

    // 这个函数是个非常关键的函数,用于获取事件的类型的,获取到事件类型之后,
    // 就可以从基类转换为真正的那个事件类型。
    virtual std::type_index get_type() { return typeid(*this); } 
}

tcp_server 收到 进程信息 这个请求之后,要转发这个请求给 process 模块,所以这里要造一个新的事件类型:

struct process_info_event : ievent
{
    // 要重写这个虚函数 返回值return typeid(*this);这次返回值的类型信息就是
    // process_info_event了。
    virtual std::type_index get_type() { return typeid(*this); } 

    // 在这里保存这个事件要用到的所有数据和变量等
    // 由于我会用到协程,所以保存一个channel变量用于在协程A中等待协程B
    // 处理完毕,通过这个channel来得到处理完毕的通知
    asio::experimental::channel<void(asio::error_code)> ch;

    // 这里保存查询到的进程信息
    std::vector<std::string> process_info;

    // 保存错误信息,表示处理模块,处理之后有没有错误
    std::string message;
}

参照上面代码再写两个事件类型,分别为 files_info_eventdevice_info_event 这里就不再写具体的代码了。

接下来要改造一下 event_dispatcher event_dispatcher; 这段定义变量的代码,如下:

先定义一个策略类:

struct event_policy
{
	static std::type_index get_event(const std::shared_ptr<ievent>& e)
	{
		return e->get_type();
	}
};

再定制一个event_dispatcher类:

using event_dispatcher_type = event_dispatcher<
	std::type_index, void(std::shared_ptr<ievent>), event_policy>;

开始解释上面这两段代码

event_dispatcher<std::type_index 这第一个模板参数是这个意思:你要转发的数据,有数据A,有数据B,那你怎么区分出数据是A还是B呢?这第一个参数就表示用于区分数据是A还是B的那个ID了,这个ID你可以用 enumint字符串 都可以,这里选用 type_index 也就是直接用类本身的类型信息来当作ID,非常合适且好用。

void(std::shared_ptr<ievent>) 这第二个模板参数表示,在你转发数据之前,接收方要提前准备一个回调函数,当你要转发数据时,就会调用那个回调函数,这就是那个回调函数的签名,也就是这个回调函数没有返回值,有一个参数是std::shared_ptr<ievent>类型的。

event_policy> 这第三个模板参数是这个意思:当你转发数据时,event_dispatcher 实际传给回调函数的参数是 std::shared_ptr<ievent> event_ptrevent_dispatcher怎么知道这个event_ptr到底是数据A还是数据B呢?此时它会调用struct event_policy中的get_event 函数来获取这个ID到底是A还是B,由于前面我们用了多态即virtual std::type_index get_type() { return typeid(*this); },所以就能获取到真正的那个类型的ID了。

接下来完善一下代码。

完整代码实现

// 文件 app.hpp
class application
{
public:
    struct event_policy
    {
        static std::type_index get_event(const std::shared_ptr<ievent>& e)
        {
            return e->get_type();
        }
    };

    // 定制后的event_dispatcher
    using event_dispatcher_type = event_dispatcher<
        std::type_index, void(std::shared_ptr<ievent>), event_policy>;

    static application& instance() { static application g; return g; }

    // 然后把event_dispatcher变量放在这里
    event_dispatcher_type event_dispatcher;

    // 把模块管理类的基类指针放在这里,前面的代码中我没有做imodule_mgr这个
    // 基类类型,实际上我习惯,把所有业务系统中要用到的公用的类,都做个基类,
    // 然后全部放在application这个单例类里,也就是说这个单例类里面只会保存
    // 基类指针,而不会保存具体的实现类的指针。
    // 具体可参考这里:https://github.com/zhllxt/naslite
    std::shared_ptr<imodule_mgr> module_mgr;

    // 比如配置模块的指针
    std::shared_ptr<iconfig>          config{};

    // 比如日志对象的指针
    std::shared_ptr<spdlog::logger>   logger{};

    // 等等.....
}

// 这里再声明一个全局变量,方便以后使用
application& app = application::instance();
// 具体的实现文件 tcp_server.cpp
// 把三个事件类include进来
#include "process_info_event.hpp"
#include "files_info_event.hpp"
#include "device_info_event.hpp"
// 把那个application单例类也直接include进来,由于app.hpp这个单例类中只会包含
// 基类指针接口,不会包含任何的具体实现类,所以在这里直接包含app.hpp不会有问题,
// 而且可以非常方便的访问到app中的所有的公用变量,早前我没有这样做,而是选择
// 像这样:init(std::shared_ptr<iconfig> config, std::shared_ptr<spdlog::logger> logger)
// 也就是将app中的公用变量作为参数传递到init函数中,然后在当前这个模块中造一些
// 成员变量将这些传入的参数保存起来,这样做的好处是tcp_server.cpp这个实现
// 文件不需要包含app.hpp这个单例总文件,因为包含这个app.hpp感觉有些怪怪的,
// 但是我在后面的使用中慢慢发现,要向init(...)中传递的参数太多了,反而不好,
// 后面干脆选择现在这种直接包含和使用app的方式了。
#include "app.hpp"

// 注意,这里我把on_recv函数改为协程函数了,因为用协程处理起来非常方便,尤其
// 是将事件从当前模块投递给其它模块,然后协程在当前模块等待,直到其它模块处理
// 完成,这里再接着处理,这个过程,用协程处理起来非常轻松,如果用异步的方式很
// 痛苦。
asio::awaitable<void> tcp_server::on_recv(std::string data)
{
    if (data == "进程信息")
    {
        // new一个事件
        std::shared_ptr<ievent> e = std::make_shared<process_info_event>();
        // 分发事件,分发之后这个事件会被process模块接收到并处理
        app.event_dispatcher.dispatch(e);
        // 分发之后,用协程等待事件,直接事件被处理完毕(请参考后续代码)
        co_await e->ch.async_receive(asio::use_nothrow_awaitable);
        // 到这里说明事件处理完了,有了处理结果了,也就是进程信息保存在了e->process_info中
        // 将处理结果发回给客户端(先别纠结代码中的错误,明白意思就行)
        co_await asio::async_send(client, e->process_info, asio::use_nothrow_awaitable);
    }
    else if(data == "文件信息")
    {
        // .... 同上,不再具体说明了
    }
}
// 头文件 process.h
struct process : imodule, pfr::base_dynamic_creator<imodule, process>
{
    // 实现虚基类的几个接口函数
    virtual bool init() override ;
    virtual bool start() override ;
    virtual void stop() override ;
    virtual void uninit() override ;

    void get_process_info();

    // 注意,这里添加了一个成员函数,是用来接收事件的
    void handle_event(std::shared_ptr<struct ievent> e);
}
// 实现文件 process.cpp
#include "process_info_event.hpp" // 包含事件类
#include "app.hpp" // 包含app类
bool process::init()
{
    // 在这里,注册事件监听器,第一个参数就是,你要监听哪个类型的数据,所以
    // 这里typeid(process_info_event)就是要监听process_info_event这个类型的
    // 数据,第二个参数是,当别的模块分发process_info_event这个类型的数据时,
    // 这第二个参数即回调函数就会被调用到(如果分发的是别的事件,比如
    // files_info_event,那这个回调函数是不会触发的)
    app.event_dispatcher.append_listener(typeid(process_info_event),
	[this](std::shared_ptr<ievent> e) mutable
	{
        // 生成一个协程,来处理这个事件
        // 注意,这里有个this->ctx,这个意思是:每个模块都在不同的线程中运行的
        // 所以当前模块就是在this->ctx这个线程中运行的,这里用co_spawn(this->ctx
        // 生成一个协程之后,handle_event这个函数就会在当前的this->ctx这个线程中
        // 被执行,这对那些有线程安全访问的需求是非常必要的。
        asio::co_spawn(this->ctx.get_executor(), handle_event(
            std::static_pointer_cast<process_info_event>(std::move(e))), asio::detached);
	});
}
bool process::start()
{
    // ...
}
void process::stop()
{
    // ...
}
void process::uninit()
{
    // 要退出了,卸载监听器
    app.event_dispatcher.remove_listener(typeid(process_info_event));
}

void process::get_process_info()
{

}

void process::handle_event(std::shared_ptr<process_info_event> e)
{
    // 开始处理事件
    // 假定调用get_process_info可以获取进程信息
    get_process_info();
    e->process_info = ...; // 给结果赋值
    // 处理完毕,开始通知tcp_server,处理完了。
    // dispatch的目的是从当前this->ctx线程切换到ch线程,也即调用者的线程
    co_await asio::dispatch(e->ch.get_executor(), asio::use_nothrow_awaitable);
    // 调用channel的async_send函数后,前面的那个co_await e->ch.async_receive就会结束等待,返回了
    co_await e->ch.async_send(asio::error_code{}, asio::use_nothrow_awaitable);
}

上面是大致的过程介绍和实现,完整的具体代码工程可以参考一下这个项目:https://github.com/zhllxt/naslite

最后更新于 2024-01-23

  • 23
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值