原文出处:深入理解重要的编程模型

背景

模型是对事物共性的抽象,编程模型就是对编程的共性的抽象。

什么是编程的共性呢?

最重要的共性就是:程序设计时,代码的抽象方式、组织方式或复用方式。编程模型主要是方法与思想。编程模型处于方法或思想性的层面,在很多情况下,也可称为编程方法、编程方式、编程模式或编程技术、编程范式。在这里就当做同一种说法。

当面对一个新问题时,通常的想法是通过分析,不断的转化和转换,得到本质相同的熟悉的、或抽象的、简单的一个问题,这就是化归思想。把初始的问题或对象称为原型,把化归后的相对定型的模拟化或理想化的对象称为模型

编程模型,简单地可以理解它就是模板,遇到相似问题就可以方便依模板解决,这样就简化了编程问题。不同的编程环境和不同的应用对象有不同的编程模型。

事件驱动

来源于《Software Architecture Patterns》

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

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

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

理解它的几个关键点:

许多现代应用设计都是由事件驱动的,事件驱动应用可以用任何一种编程语言来创建,因为事件驱动本身是一种编程方法,而不是一种编程语言。

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

深入理解事件驱动

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

  2. IO模型,事件驱动框架一般是采用Reactor模式或者Proactor模式的IO模型。

Reactor模式其中非常重要的一环就是调用函数来完成数据拷贝,这部分是应用程序自己完成的,内核只负责通知监控的事件到来了,所以本质上Reactor模式属于非阻塞同步IO。

来自:深入理解Linux高性能网络架构的那些事

Proactor模式,借助于系统本身的异步IO特性,由操作系统进行数据拷贝,在完成之后来通知应用程序来取就可以,效率更高一些,但是底层需要借助于内核的异步IO机制来实现,可能借助于DMA和Zero-Copy技术来实现,理论上性能更高。

当前Windows系统通过IOCP实现了真正的异步I/O,而在Linux 系统的异步I/O还不完善,比如Linux中的boost.asio模块就是异步IO的 支持,但是目前Linux系统还是以基于Reactor模式的非阻塞同步IO为主。

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

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

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

事件驱动的缺点

常用的事件驱动框架

消息驱动

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

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

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

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

消息驱动的优点

常用的消息驱动框架

事件驱动vs消息驱动

消息驱动的方法与事件驱动的方法一样有很多优点和缺点,但每种方法都有自己最适合的情况。

消息感觉很像经典的编程模型:调用一个函数,等待一个结果,对结果做一些事情。除了为大多数程序员所熟悉之外,这种结构还可以使调试更加直接。另一个优点是消息“阻塞”,这意味着呼叫和响应的各个单元坐下来等待轮到接收者进行处理。

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

如果通信通常以一对一的方式进行,并且优先接收定期状态更新或确认,那么您将倾向于使用基于消息的方法。但是,如果系统之间的交互特别复杂,并且确认和状态更新导致的延迟使得等待它们变得不切实际,那么事件驱动的设计可能更合适。但是请记住,大多数大型组织最终会采用混合策略,一些面向客户/API调用使用消息驱动,而企业本身使用事件驱动。因此,尽可能多地熟悉两者并没有什么坏处。

数据驱动

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

例子

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

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

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

下面这种思路的优势:

1、可读性更强,消息处理流程一目了然。 2、更容易修改,要增加新的消息,只要修改数据即可,不需要修改流程。 3、重用,第一种方案的很多的else if其实只是消息类型和处理函数不同,但是逻辑是一样的。下面的这种方案就是将这种相同的逻辑提取出来,而把容易发生变化的部分提到外面。

隐含在背后的思想

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

  1. 控制复杂度。通过把程序逻辑的复杂度转移到人类更容易处理的数据中来,从而达到控制复杂度的目标。
  2. 隔离变化。像上面的例子,每个消息处理的逻辑是不变的,但是消息可能是变化的,那就把容易变化的消息和不容易变化的逻辑分离。
  3. 机制和策略的分离。和第二点很像,本书中很多地方提到了机制和策略。上例中,我的理解,机制就是消息的处理逻辑,策略就是不同的消息处理:

深入理解编程艺术之策略与机制相分离

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

1. 表驱动法(Table-Driven)

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

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

这种方法的缺点:

用表驱动法来实现

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

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;
}

这种方法的好处:

2. 基于数据模型编程

数据驱动思考

总结

设计模式(古典)主要针对OOP领域编程设计方法的抽象。这里的编程模型,主要是针对业务编程框架的抽象。

消息驱动事件驱动,本身有很多相似地方,消息驱动主要代表是经典跨进程通信架构,让消息处理和函数调用一样,逻辑依然可以保持清晰简单。而事件驱动采取异步处理方式,最大化解耦,让程序耦合更低,框架更易扩展,两种编程模型都有各自优缺点,只有根据具体的场景找到一种合适使用方法。

数据驱动是一种新的编程思考,坚持"data as program"准则,把处理逻辑数据化,这样可以通过不同数据配置来实现不同的逻辑,让核心代码更精炼简单,框架更易扩展。

参考和扩展阅读


原文出处:深入理解编程艺术之策略与机制相分离

在现代操作系统的结构设计中,经常利用“机制与策略分离”的原理来构造OS结构。所谓机制,是指实现某一功能的具体执行机构。而策略,则是在机制基础上,借助于某些参数和算法来实现该功能的优化,或达到不同的功能目标。通常,机制处于一个系统的基层,而策略则处于系统的高层。

在程序设计中,机制与策略分离的思想可以提高程序的可复用性,可维护性和可调试性使程序更具有高内聚低耦合性。如果说机制是砖,那么策略就是房子,同样的砖可以建不同的房子,我们不能把建砖和建房子混在一起实现。

策略的变化要远远大于机制的变化。将两者分离,可以使机制相对保持稳定,而同时支持策略的变化。

在代码大全中提到“隔离变化”的概念,以及设计模式中提到的将易变化的部分和不易变化的部分分离也是这个思路。

在《Unix编程艺术》第一章就深刻讨论这个编程哲学:

“在我们对 Unix 错误的讨论中,我们观察到 X window的设计者做出了一个基本决定来实现“机制,而不是策略” —— 使 X 成为一个通用的图形引擎,并将有关用户界面风格的决定留给工具包和其他级别的系统。我们通过指出政策和机制倾向于在不同的时间尺度上发生变异来证明这一点,政策的变化比机制快得多,GUI工具包的外观和感觉上的时尚可能来来去去,但光栅操作和合成是永恒的。

因此,将策略和机制硬连接在一起会产生两个负面影响:它使策略变得僵化并且更难以响应用户需求而改变,这意味着试图改变策略有很强的破坏机制稳定的倾向。

另一方面,通过将两者分开,我们可以在不破坏机制的情况下试验新策略。我们还使为机制编写好的测试变得更加容易。

实现这种分离的一种方法是,例如,将应用程序编写为由嵌入式脚本语言驱动的 C服务例程库,应用程序控制流是用脚本语言而不是 C 编写的。这种模式是Emacs编辑器,它使用嵌入式 Lisp解释器来控制用 C 编写的编辑原语。

另一种方法是将您的应用程序分成协作的前端和后端进程,这些进程通过套接字上的专用应用程序协议进行通信;前端执行策略,后端实现机制。这样的全局复杂性通常远低于实现相同功能的单进程单体的复杂性,从而减少您对错误的脆弱性并降低生命周期成本(提高健壮性)。”

一些例子

GUI框架

MVC(Model-View-Controller)作为最经典的GUI架构,MVC模式的核心思想是数据层(Domain)与表现层(Presentation)的隔离。

View,Model属于策略,在系统中属于可变部分,Controller属于机制,不会随着view的变化而变化,属于系统中不变的部分,构建一个系统要尽肯能分离可变部分和不可变部分。

netfilter框架

netfilter框架是一个典型将机制和策略分离好例子:

Netfilter是一个设计良好的框架,之所以说它是一个框架是因为它提供了最基本的底层支撑,而对于实现的关注度却没有那么高,这种底层支撑实际上是5个HOOK点:

Netfilter拥有几乎无限的可扩展性,Liuux中使用的仅仅是它的一个很小的部分,大部分的内容作为可插拔的module处于待命状态Netfilter的机制集成在Linux内核中, 然而它的策略扩展却处于一个独立的空间,我们说这种所谓的机制也仅仅是5个HOOK点。我们浏览netfilter.org就会知道,它里面融合了大量的策略,我们最熟悉的就是ip tables了,上图的ebtables,arptables,nft也是Netfilter的扩展之一,足以看出,Netfilter有多强大,内核仅仅给出钩子点而已,如果你嫌某些不好,你可以自己实现一个更好的,事实上,Netfilter中有很多的东西并没有集成在Linux内核。

TCP拥塞控制框架

Linux系统中的TCP拥塞控制采用面向对象的设计思想,提供拥塞控制接口用于实现不同的拥塞控制策略,成功把拥塞控制解耦了:

eBPF框架

游戏引擎

游戏引擎架构

游戏引擎便是专门为游戏而设计的工具及技术集成,之所以称为引擎,如同交通工具中的引擎,提供了最核心的技术部分--游戏机制,然后可以通过脚本语言或者关卡设计来插入策略逻辑,重用性是游戏引擎的一个重要设计目标,这样很多游戏开发都可以通过"换皮策略"来快速开发新游戏。

最后一些问题

1、透过现象看本质,机制与策略到底是什么?为什么要将机制与策略分离?

机制可以认为是业务通用的核心模型(框架),不易变化;策略可以认为是某个功能的具体实现方案,可以被框架使用;机制与策略分离,是一种可扩展性设计的重要方法,提供一个继承接口,用于提供不同的实现,这也就是策略模式和接口隔离原则。机制关联一个抽象的策略(也就是接口),用不同的具体策略初始化抽象策略,就能调用具体策略的处理流程。

2、假如不分离,会出现什么问题?

把策略同机制揉成一团有两个负面影响:一来会使策略变得死板,难以适应用户需求的改变,二来也意味着任何策略的改变都极有可能动摇机制,对原来稳定的框架造成污染,引入风险。

所以我们在设计系统的时候,可以参考这种机制和策略模式,让系统具有更好的扩展性和更好的稳定性。

参考和扩展阅读