3.1.2 有状态函数编程模型的实现
本节具体介绍有状态编程模型的实现方式,以及基于有状态函数实现的各种编排模型。
3.1.2.1 状态和函数的关系描述
如图3-1描述了有状态函数编程模型中函数与状态之间的关系。函数用来处理状态,是处理状态的接口实现,例如,函数a、b、c都是状态A的操作接口实现。不处理状态的函数可以将其状态视为空。函数之间支持直接调用。
图3-1 有状态函数编程模型中函数与状态之间的关系
3.1.2.2 状态的定义和操作
明确了状态和函数的关系之后,我们首先来看如何进行状态的定义和操作。
1. 状态的定义
状态用来表示在业务处理中跨函数调用的过程数据,跨函数调用是指通过invoke API或restful trigger调用函数。开发者需要定义状态,状态以系统唯一的stateid为标识。
定义状态即定义状态的数据结构,然后用于初始化context.state即可。
2. 状态的操作
开发者可以在函数中通过context参数(context.state)访问状态。执行函数前,系统会自动将状态实例加载到context.state中。当函数执行结束后,系统会自动保存状态。系统会为每一个状态设置一个默认的老化超时时间,如果老化时间间隔内没有操作该状态,则会删除该状态。开发者可以通过settimeout接口来设置这个超时时间。当然,开发者也可以主动终止并删除状态。这两种机制可以类比为单机编程中对数据资源的回收和主动释放。系统对状态进行如下保证。
• 原子性:系统保证函数对状态的所有操作要么全部完成,要么全部不完成。如果在执行过程中发生错误,则退回到函数开始前的状态。
• 一致性:不存在函数执行过程的中间状态,系统保证状态的一致性要求。
• 隔离性:对同一个状态的并发操作不会互相干扰,不会出现不一致的现象。对不同状态的操作可以并发执行。系统为每个状态维护一个队列,对该状态的操作请求进行排队并按顺序执行。
• 持久性:如果函数执行结束,那么对状态操作的结果就是持久性的。接下来的其他操作或故障不应该对其执行结果产生任何影响。
表3-2是有状态函数操作的主要API。
表3-2 有状态函数操作的主要API
invoke操作是有状态函数直接调用的接口。当用户不指定stateid的值时,系统会新创建一个状态,并返回状态的ID。开发者可以通过YR.getYRFuture(future)获取状态的ID和返回值value。伪代码逻辑如下。
有状态函数帮助开发者屏蔽了状态管理的复杂过程,为开发者提供了原生的单机编程体验。
• 对状态的操作如同操作变量一样简单。
• 对同一个状态的并发操作保证一致性,不同状态的操作可以并行执行。
• 系统对开发者屏蔽了容器崩溃异常。容器崩溃后,系统会自动重新执行,并保证对有状态函数的调用满足At-least-once execution要求。相比之下,如果实现Exactly-once execution,则系统实现复杂且性能开销较大;如果没有确保At-least-once execution,则编写操作的正确性就很难保证。最终选择At-least-once execution,这需要在编写操作正确性和系统开销之间做出折中选择,相应的代价是如果出现重复的请求执行,则需要开发者保证操作的幂等性。
3.1.2.3 函数的定义和操作
函数的定义方式与现有的Serverless平台基本保持一致,只是增加了对状态的操作,如表3-2所示的状态操作API。以下是有状态函数及其调用的示例代码。
• 有状态函数jshandler定义了一个简单的计数函数counter。
• 有状态函数调用函数caller,通过invoke函数调用了上面的jshandler函数。
3.1.2.4 通过有状态函数支持函数编排
对于无状态函数,函数编排可以解决应用的有状态问题。函数编排实际上是对函数的执行进行状态机管理。例如,AWS通过step function进行函数编排,开发者需要通过类似DSL语言对函数的执行过程进行编排。微软则定义了一种特殊的Durable函数进行编排工作,支持不同的编排模型。相比之下,华为元戎的有状态函数由于原生支持状态操作,因此可以不用重新定义函数类型进行编排操作。本节将介绍如何使用有状态函数来实现多种模式的函数编排。
函数编排应当遵循以下原则。
• 函数应当被视为“黑盒”。
• 替换原则,即编排也是一个函数。
• 编排应该避免双花问题,即重复计费。
微软官方文档中提到的Durable函数支持的函数编排有6种通用应用模式中,华为元戎的有状态函数可以直接支持其中的3种模式(函数链、聚合器、异步HTTP API),而其他3种模式(扇出/扇入、监视、人机交互)由于“双花问题”,即重复消费(使用)问题,需要对函数进行简单拆分后方可支持这3种模式,下面我们将详细讲解如何使用有状态函数实现6种通用应用模式。
1. 编排函数相关接口
在介绍6种通用模式的实现之前,首先介绍与编排函数相关的接口。
开发者需要实现的handler函数如下所示。
当其返回Future时,系统会等待Future完成,再将结果返回给被调用者。利用这个特性,可以将等待Future完成这一操作从Function函数转移到系统中,从而避免双花问题。
开发者可以调用invoke、onComplete、getYRFuture和wait函数,如下所示。
2. 通用模式
下面介绍几种通用模式的实现。
(1)函数链。对多个函数进行顺序调用,即流水线任务顺序执行可形成函数链,如图3-2所示。
图3-2 函数链
这个过程中可能有分支,但整体上是顺序进行的。根据函数链中的函数调用是否存在数据依赖关系可以细化出多种模式,举例如下。
模式一:F2依赖F1的执行结果,F3依赖F2的执行结果,F4依赖F3的执行结果,实现代码如下。
模式二:基于模式一进行拓展,函数调用关系不变,F3依赖F1和F2执行完成后再触发,其余与模式一保持一致,实现代码如下。
模式三:基于模式一继续拓展,函数调用关系不变,某些调用不存在数据依赖关系,例如,F3不依赖F1和F2的执行结果,只需要等待F1和F2完成,此模式的实现与模式一、模式二相比,需要引入一个新的函数接口wait,实现该模式的代码如下。
(2)扇出/扇入。扇出/扇入是指多个函数同时执行,然后等待所有函数返回执行结果后再执行下一步,如图3-3所示。例如,用户完成支付需要通过短信、微信、邮件等多种方式通知用户。此外,fork/join也符合这种模式。
图3-3 扇入/扇出
直观的方式是,使用一个有状态函数实现扇出/扇入模式,示例代码如下。
以上这种做法会导致双花问题。由于使用了getFuture接口,导致在F2执行的时候,即使F1没有工作也要等待并占用资源,同样在F3执行的时候,F2也要等待并占用资源。为了解决这个双花问题,可以考虑对函数编排进行拆分,需要注意的是,对函数编排进行拆分时需要遵守“黑盒原则”,即不能修改F1、F2、F3的函数实现,例如,不能向F1的函数实现中添加invoke(F2),示例代码如下。
handler返回Future前会等待其完成,因此ENTRY会等待F2_WRAPPER完成才返回给调用者,F2_WRAPPER会等待F3_WRAPPER完成才返回给调用者,F3_WRAPPER会等待F3完成才返回给调用者,这些等待过程都是在系统层面完成的,不占用Function的执行时间,从而避免了双花问题。
(3)聚合器。聚合器用于对数据进行聚合处理,聚合是一个典型的有状态处理过程,例如,在监控数据聚合时,函数需要等待数据上报后再进行聚合,然后持久化到时序数据库中,如图3-4所示。
图3-4 聚合
由于聚合是典型的有状态处理过程,所以使用有状态函数实现该模式的示例代码非常直观。
(4)异步HTTP API。异步HTTP API模式解决的问题是如何协同long-running任务的状态。实现此模式的常见方法是使HTTP端点触发长期运行的操作,然后将客户端重定向到状态端点,客户端轮询该端点以了解操作完成时的情况,如图3-5所示。
图3-5 异步HTTP API
在这种模式下,可通过图3-5中的GetStatus函数查看Start和DoWork的执行状态。
例如,通过HTTP请求异步触发函数。
通过函数的id可以查询函数的执行状态。
在这种模式下执行的函数需要保存执行状态,并提供状态访问的接口函数,GetStatus函数访问相应接口即可。
(5)监视。监视模式是工作流中一种灵活的循环过程,如轮询直到满足特定条件。我们可以通过函数监视任务来实现监视模式,如图3-6所示。这种模式常用来监控内部任务的执行情况并进行处理,例如,数据从TP数据库迁移到AP数据库,如果迁移无法在8点前完成,需要终止迁移,可采用该模式来实现。
图3-6 监视
可以使用一个有状态函数实现该模式,示例代码如下。
和前面造成双花问题的模式一样,这里由于调用了3次getYRFuture函数,所以也存在双花问题。因此,可将上述函数拆分为多个函数,以避免双花问题,拆分后监视模式的状态机描述如图3-7所示。
图3-7 监视模式的状态机描述
根据图3-7的拆分方案,entry将调用获取和判断任务状态的wrapper函数及执行后续操作的wapper函数,wrapper函数获取并等待future执行,示例代码如下。
(6)人机交互。工作流中的某些步骤需要人工干预才能继续,例如,在审批流程中,主管审批后才能继续进行后续流程。人机交互模式如图3-8所示。
图3-8 人机交互模式
使用一个有状态函数实现该模式,示例代码如下。
由于在此模式下需要调用wait函数,和getYRFuture函数一样,这也会造成双花问题。同样,还是将上述函数进行拆分,以避免双花问题。我们把上述函数拆分为entry和wait两个函数,示例代码如下。