编程模型

深入理解重要的编程模型


1. 事件驱动

来源于《Software Architecture Patterns》

事件驱动架构(Event-Driven Architecture)是一种用于设计应用的软件架构和模型,程序的执行流由外部事件来决定,它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。主要包括 4 个基本组件:

  • 事件队列(event queue):接收事件的入口,存储待处理事件
  • 分发器(event mediator):将不同的事件分发到不同的业务逻辑单元
  • 事件通道(event channel):分发器与处理器之间的联系渠道
  • 事件处理器(event processor):实现业务逻辑,处理完成后会发出事件,触发下一步操作

1.1. 为什么采用事件驱动模型?

事件驱动模型也就是我们常说的观察者模式,或者发布-订阅模型;

理解它的几个关键点:

  • 首先是一种对象间的一对多的关系;最简单的如交通信号灯,信号灯是目标(一方),行人注视着信号灯(多方);
  • 当目标发送改变(发布),观察者(订阅者)就可以接收到改变;
  • 观察者如何处理(如行人如何走,是快走/慢走/不走,目标不会管的),目标无需干涉;所以就松散耦合了它们之间的关系。

许多现代应用设计都是由事件驱动的,优势如下:

  • 松耦合:服务不需要(也不应该)知道或依赖于其他服务。在使用事件时,服务独立运行,不了解其他服务,包括其实现细节和传输协议。事件模型下的服务可以独立地、更容易地更新、测试和部署。
  • 易扩展:通过高度独立和解耦的事件处理器自然地实现了可扩展性。每个事件处理器都可以单独扩展,从而实现细粒度的可扩展性。
  • 恢复支持:带有队列的事件驱动架构可以通过“重播”过去的事件来恢复丢失的工作。当用户需要恢复时,这对于防止数据丢失非常有用。

事件驱动架构可以最大程度减少耦合度,因此是现代化分布式应用架构的理想之选。

事件驱动系统使单个事件易于隔离测试。然而,这种与整个应用系统的分离也抑制了这些单元报告错误、重试调用程序甚至只是向用户确认进程已完成的能力。换句话说:当事件驱动系统中发生错误时,很难追踪到底是哪里出了问题。可观察性工具正在应对调试复杂事件链的挑战。但是,添加到业务交易交叉点的每个工具都会为负责管理这些工作流的程序员带来另一层复杂性。

1.2. 深入理解事件驱动

  1. 事件队列,事件驱动的程序必定会直接或者间接拥有一个事件队列,用于存储未能及时处理的事件,这个事件队列,可以采用消息队列。

  2. 事件串联,事件驱动的程序的行为,完全受外部输入的事件控制,所以事件驱动框架中,存在大量处理程序逻辑,可以通过事件把各个处理流程关联起来。

  3. 顺序性和原子化,事件驱动的程序可以按照一定的顺序处理队列中的事件,而这个顺序则是由事件的触发顺序决定的,这一特性往往被用于保证某些过程的顺序性和原子化。

1.2.1. 异步处理和主动轮训

要理解事件驱动和程序,就需要与非事件驱动的程序进行比较。实际上,现代的程序大多是事件驱动的,比如多线程的程序,肯定是事件驱动的。早期则存在许多非事件驱动的程序,这样的程序,在需要等待某个条件触发时,会不断地检查这个条件,直到条件满足,这是很浪费cpu时间的。而事件驱动的程序,则有机会释放cpu从而进入睡眠态(注意是有机会,当然程序也可自行决定不释放cpu),当事件触发时被操作系统唤醒,这样就能更加有效地使用cpu。

1.2.2. IO模型

事件驱动框架一般是采用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为主。

1.2.3. 常用的事件驱动框架

  • select
  • poll
  • epoll
  • libev
  • 中断系统

2. 消息驱动

消息驱动事件驱动很类似,都是先有一个事件,然后产生一个相应的消息,再把消息放入消息队列,由需要的项目获取。他们只是一些细微区别,一般都采用相同框架,细微的区别:

  • 消息驱动:生产者A发送一个消息到消息队列,消费者B收到该消息。生产者A很明确这个消息是发给消费者B的。通常是P2P模式。

  • 事件驱动:生产者A发出一个事件,消费者B或者消费者C收到这个事件,或者没人收到这个事件,生产者A只会产生一个事件,不关心谁会处理这个事件 ,通常是发布-订阅模型。

现代软件系统是跨多个端点运行并通过大型网络连接的分布式系统。例如,考虑一位航空公司客户通过 Web 浏览器购买机票。该订单可能会通过API,然后通过一系列返回结果的过程。这些来回通信的一个术语是消息传递。在消息驱动架构中,这些 API 调用看起来非常像一个函数调用:API 知道它在调用什么,期待某个结果并等待该结果。

常用的消息驱动框架:

  • API网关
  • gRPC
  • 微服务架构

3. 数据驱动

数据驱动核心出发点是相对于程序逻辑,人类更擅长于处理数据。数据比程序逻辑更容易驾驭,所以我们应该尽可能的将设计的复杂度从程序代码转移至数据。

假设有一个程序,需要处理其他程序发送的消息,消息类型是字符串,每个消息都需要一个函数进行处理。第一印象,我们可能会这样处理:

上面的消息类型取自sip协议(不完全相同,sip协议借鉴了http协议),消息类型可能还会增加。看着常常的流程可能有点累,检测一下中间某个消息有没有处理也比较费劲,而且,每增加一个消息,就要增加一个流程分支。

按照数据驱动编程的思路,可能会这样设计:

下面这种思路的优势:

  1. 可读性更强,消息处理流程一目了然。

  2. 更容易修改,要增加新的消息,只要修改数据即可,不需要修改流程。

  3. 重用,第一种方案的很多的else if其实只是消息类型和处理函数不同,但是逻辑是一样的。下面的这种方案就是将这种相同的逻辑提取出来,而把容易发生变化的部分提到外面。

3.1. 隐含在背后的思想

很多设计思路背后的原理其实都是相通的,隐含在数据驱动编程背后的实现思想包括:

1、控制复杂度。通过把程序逻辑的复杂度转移到人类更容易处理的数据中来,从而达到控制复杂度的目标。

2、隔离变化。像上面的例子,每个消息处理的逻辑是不变的,但是消息可能是变化的,那就把容易变化的消息和不容易变化的逻辑分离。

3、机制和策略的分离。和第二点很像,本书中很多地方提到了机制和策略。上例中,我的理解,机制就是消息的处理逻辑,策略就是不同的消息处理.

3.2. 数据驱动编程可以用来做什么

3.2.1. 表驱动法(Table-Driven)

消除重复代码,考虑一个消息(事件)驱动的系统,系统的某一模块需要和其他的几个模块进行通信。它收到消息后,需要根据消息的发送方,消息的类型,自身的状态,进行不同的处理。比较常见的一个做法是用三个级联的switch分支实现通过硬编码来实现:

switch(sendMode){
case:
}
switch(msgEvent){
case:
}
switch(myStatus){
case:
}

这种方法的缺点:

  • 可读性不高:找一个消息的处理部分代码需要跳转多层代码。

  • 过多的switch分支,这其实也是一种重复代码。他们都有共同的特性,还 可以再进一步进行提炼。

  • 可扩展性差:如果为程序增加一种新的模块的状态,这可能要改变所有的 消息处理的函数,非常的不方便,而且过程容易出错。

  • 程序缺少核心主干:缺少一个能够提纲挈领的主干,程序的主干被淹没在 大量的代码逻辑之中。

3.2.2. 用表驱动法来实现

根据定义的三个枚举:模块类型,消息类型,自身模块状态,定义一个函数跳转表:

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++中的纯虚函数,只有满足这个条件(入参,返回值),才可以作为一个事件的处理函数。这个有一点插件结构的味道,你可以对这些插件进行方便替换,新增,删除,从而改变程序的行为。而这种改变,对事件处理函数的查找又是隔离的(也可以叫做隔离了变化)。、

  • 程序有一个明显的清晰主干

  • 降低了复杂度。通过把程序逻辑的复杂度转移到人类更容易处理的数据中来,从而达到控制复杂度的目标。

posted @ 2021-10-29 07:15  brt2  阅读(153)  评论(0编辑  收藏  举报