目录深入理解重要的编程模型
来源于《Software Architecture Patterns》
事件驱动架构(Event-Driven Architecture)是一种用于设计应用的软件架构和模型,程序的执行流由外部事件来决定,它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。主要包括 4 个基本组件:
事件驱动模型也就是我们常说的观察者模式,或者发布-订阅模型;
理解它的几个关键点:
许多现代应用设计都是由事件驱动的,优势如下:
事件驱动架构可以最大程度减少耦合度,因此是现代化分布式应用架构的理想之选。
事件驱动系统使单个事件易于隔离测试。然而,这种与整个应用系统的分离也抑制了这些单元报告错误、重试调用程序甚至只是向用户确认进程已完成的能力。换句话说:当事件驱动系统中发生错误时,很难追踪到底是哪里出了问题。可观察性工具正在应对调试复杂事件链的挑战。但是,添加到业务交易交叉点的每个工具都会为负责管理这些工作流的程序员带来另一层复杂性。
事件队列,事件驱动的程序必定会直接或者间接拥有一个事件队列,用于存储未能及时处理的事件,这个事件队列,可以采用消息队列。
事件串联,事件驱动的程序的行为,完全受外部输入的事件控制,所以事件驱动框架中,存在大量处理程序逻辑,可以通过事件把各个处理流程关联起来。
顺序性和原子化,事件驱动的程序可以按照一定的顺序处理队列中的事件,而这个顺序则是由事件的触发顺序决定的,这一特性往往被用于保证某些过程的顺序性和原子化。
要理解事件驱动和程序,就需要与非事件驱动的程序进行比较。实际上,现代的程序大多是事件驱动的,比如多线程的程序,肯定是事件驱动的。早期则存在许多非事件驱动的程序,这样的程序,在需要等待某个条件触发时,会不断地检查这个条件,直到条件满足,这是很浪费cpu时间的。而事件驱动的程序,则有机会释放cpu从而进入睡眠态(注意是有机会,当然程序也可自行决定不释放cpu),当事件触发时被操作系统唤醒,这样就能更加有效地使用cpu。
事件驱动框架一般是采用Reactor模式或者Proactor模式的IO模型。
Reactor模式其中非常重要的一环就是调用函数来完成数据拷贝,这部分是应用程序自己完成的,内核只负责通知监控的事件到来了,所以本质上Reactor模式属于非阻塞同步IO。
Proactor模式,借助于系统本身的异步IO特性,由操作系统进行数据拷贝,在完成之后来通知应用程序来取就可以,效率更高一些,但是底层需要借助于内核的异步IO机制来实现,可能借助于DMA和Zero-Copy技术来实现,理论上性能更高。
当前Windows系统通过IOCP实现了真正的异步I/O,而在 Linux 系统的异步I/O还不完善,比如Linux中的boost.asio模块就是异步IO的支持,但是目前Linux系统还是以基于Reactor模式的非阻塞同步IO为主。
消息驱动和事件驱动很类似,都是先有一个事件,然后产生一个相应的消息,再把消息放入消息队列,由需要的项目获取。他们只是一些细微区别,一般都采用相同框架,细微的区别:
消息驱动:生产者A发送一个消息到消息队列,消费者B收到该消息。生产者A很明确这个消息是发给消费者B的。通常是P2P模式。
事件驱动:生产者A发出一个事件,消费者B或者消费者C收到这个事件,或者没人收到这个事件,生产者A只会产生一个事件,不关心谁会处理这个事件 ,通常是发布-订阅模型。
现代软件系统是跨多个端点运行并通过大型网络连接的分布式系统。例如,考虑一位航空公司客户通过 Web 浏览器购买机票。该订单可能会通过API,然后通过一系列返回结果的过程。这些来回通信的一个术语是消息传递。在消息驱动架构中,这些 API 调用看起来非常像一个函数调用:API 知道它在调用什么,期待某个结果并等待该结果。
常用的消息驱动框架:
数据驱动核心出发点是相对于程序逻辑,人类更擅长于处理数据。数据比程序逻辑更容易驾驭,所以我们应该尽可能的将设计的复杂度从程序代码转移至数据。
假设有一个程序,需要处理其他程序发送的消息,消息类型是字符串,每个消息都需要一个函数进行处理。第一印象,我们可能会这样处理:
上面的消息类型取自sip协议(不完全相同,sip协议借鉴了http协议),消息类型可能还会增加。看着常常的流程可能有点累,检测一下中间某个消息有没有处理也比较费劲,而且,每增加一个消息,就要增加一个流程分支。
按照数据驱动编程的思路,可能会这样设计:
下面这种思路的优势:
可读性更强,消息处理流程一目了然。
更容易修改,要增加新的消息,只要修改数据即可,不需要修改流程。
重用,第一种方案的很多的else if其实只是消息类型和处理函数不同,但是逻辑是一样的。下面的这种方案就是将这种相同的逻辑提取出来,而把容易发生变化的部分提到外面。
很多设计思路背后的原理其实都是相通的,隐含在数据驱动编程背后的实现思想包括:
1、控制复杂度。通过把程序逻辑的复杂度转移到人类更容易处理的数据中来,从而达到控制复杂度的目标。
2、隔离变化。像上面的例子,每个消息处理的逻辑是不变的,但是消息可能是变化的,那就把容易变化的消息和不容易变化的逻辑分离。
3、机制和策略的分离。和第二点很像,本书中很多地方提到了机制和策略。上例中,我的理解,机制就是消息的处理逻辑,策略就是不同的消息处理.
消除重复代码,考虑一个消息(事件)驱动的系统,系统的某一模块需要和其他的几个模块进行通信。它收到消息后,需要根据消息的发送方,消息的类型,自身的状态,进行不同的处理。比较常见的一个做法是用三个级联的switch分支实现通过硬编码来实现:
switch(sendMode){ case: } switch(msgEvent){ case: } switch(myStatus){ case: }
这种方法的缺点:
可读性不高:找一个消息的处理部分代码需要跳转多层代码。
过多的switch分支,这其实也是一种重复代码。他们都有共同的特性,还 可以再进一步进行提炼。
可扩展性差:如果为程序增加一种新的模块的状态,这可能要改变所有的 消息处理的函数,非常的不方便,而且过程容易出错。
程序缺少核心主干:缺少一个能够提纲挈领的主干,程序的主干被淹没在 大量的代码逻辑之中。
根据定义的三个枚举:模块类型,消息类型,自身模块状态,定义一个函数跳转表:
typedef struct __EVENT_DRIVE { MODE_TYPE mod;//消息的发送模块 EVENT_TYPE event;//消息类型 STATUS_TYPE status;//自身状态 EVENT_FUN eventfun;//此状态下的处理函数指针 }EVENT_DRIVE; EVENT_DRIVE eventdriver[] = //这就是一张表的定义,不一定是数据库中的表。也可以使自己定义的一个结构体数组。 { {MODE_A, EVENT_a, STATUS_1, fun1} {MODE_A, EVENT_a, STATUS_2, fun2} {MODE_A, EVENT_a, STATUS_3, fun3} {MODE_A, EVENT_b, STATUS_1, fun4} {MODE_A, EVENT_b, STATUS_2, fun5} {MODE_B, EVENT_a, STATUS_1, fun6} {MODE_B, EVENT_a, STATUS_2, fun7} {MODE_B, EVENT_a, STATUS_3, fun8} {MODE_B, EVENT_b, STATUS_1, fun9} {MODE_B, EVENT_b, STATUS_2, fun10} }; int driversize = sizeof(eventdriver) / sizeof(EVENT_DRIVE)//驱动表的大小 EVENT_FUN GetFunFromDriver(MODE_TYPE mod, EVENT_TYPE event, STATUS_TYPE status)//驱动表查找函数 { int i = 0; for (i = 0; i < driversize; i++){ if ((eventdriver[i].mod == mod) && (eventdriver[i].event == event) \ && (eventdriver[i].status == status)){ return eventdriver[i].eventfun; } } return NULL; }
这种方法的好处:
提高了程序的可读性。一个消息如何处理,只要看一下驱动表就知道,非常明显。
减少了重复代码。这种方法的代码量肯定比第一种少。为什么?因为它把一些重复的东西:switch分支处理进行了抽象,把其中公共的东西——根据三个元素查找处理方法抽象成了一个函数GetFunFromDriver外加一个驱动表。
可扩展性。注意这个函数指针,他的定义其实就是一种契约,类似于java中的接口,c++中的纯虚函数,只有满足这个条件(入参,返回值),才可以作为一个事件的处理函数。这个有一点插件结构的味道,你可以对这些插件进行方便替换,新增,删除,从而改变程序的行为。而这种改变,对事件处理函数的查找又是隔离的(也可以叫做隔离了变化)。、
程序有一个明显的清晰主干。
降低了复杂度。通过把程序逻辑的复杂度转移到人类更容易处理的数据中来,从而达到控制复杂度的目标。