在当今计算机科学中已知的许多编程模型中,行为体模型(Actor Model)是一个突出的模型。正如你在本章中所了解的,Dapr 为这种编程模型提供了一个高效、轻量级和灵活的实现,支持任何现代编程语言。
行为体模型
在计算机科学中,行为体模型被描述为一种用于当前计算的数学模型。行为体可以被认为是这些计算的基元:它接收一个消息,然后可以在此基础上做出局部决定。行为体封装了自己的状态,并通过使用唯一的 ID 进行分区来实现可扩展性。一旦收到消息,行为体就可以对消息做出反应,连接到外部系统,或者创建更多不同类型的行为体。一个行为体可以是有状态的,也可以是无状态的:当有状态时,它可以修改自己的内部状态。这导致了面向对象的编程体验,对象的状态被封装在其中,由强类型的方法修改。行为体总是在基于回合的并发模型中被调用,并允许对行为体的任何特定实例进行单线程访问。
例如,图 5-1 说明了对同一 Cat 类型的行为体实例的两个并发调用,调用 Eat 方法,将以串行方式执行。
正如你所看到的,每次有请求进入行为体,就会获得一个锁;只有这样,方法才能被执行,行为体的状态才能改变。每个行为体实例都保持自己的状态,并且可以通过封装状态改变逻辑的知名方法与其他行为体互动来改变它们的状态。
图 5-1. 基于回合制的行为体执行
行为体模型并不新鲜:它是在 1973 年 1 发明的,灵感来自物理学和量子力学。各种编程语言对行为体模型都有影响,包括 Lisp 和 Smalltalk。
行为体模型本身并没有规定任何关于行为体运行地点、网络地址或任何其他基础设施方面的特殊约束或要求。因此,当调用一个行为体时,客户端有责任知道这个行为体被放在哪里(例如,在哪个节点上,以及该节点的物理网络地址是什么)。同一行为体 ID 的两个实例不能同时存在;这将违反行为体模型,并使状态可能被破坏。
在过去的几十年里,已经创建了几个行为体框架来支持编程模型。下一节将对其中的一些框架进行简要介绍。
现代行为体框架
近年来,已经为特定的语言运行时开发了一些行为体框架,帮助开发者在分布式环境中跨节点大规模地使用行为体。这方面的例子包括 Akka,这是一个著名的基于 Scala 的行为体模型,有一个 .NET 的移植;Orleans,这是一个 C# 虚拟行为体,以及 Reliable Actors,这是一个运行在 Microsoft Service Fabric 上的虚拟行为体框架。
Akka 和 Orleans 以及 Service Fabric Reliable Actor 之间的主要区别是,后两个框架都采用了虚拟行为体。这意味着,当用户调用一个行为体时,如果该行为体不存在,这些框架将创建一个该行为体的实例,将其放置在集群的任何地方,并代表用户解析其网络地址。开发者不需要知道或关心行为体被放置在哪里,只需要用 ID 和所需的方法来调用行为体。下面是一个调用 Service Fabric Reliable Actor 的例子:
using System;using System.Threading.Tasks;using Microsoft.ServiceFabric.Actors;using Microsoft.ServiceFabric.Actors.Client;using HelloWorld.Interfaces;namespace ActorClient{class Program{static void Main(string[] args){IHelloWorld actor =ActorProxy.Create<IHelloWorld>("MyActor",new Uri("fabric:/MyApplication/HelloWorldActorService"));Task<string> retval = actor.GetHelloWorldAsync();Console.Write(retval.Result);Console.ReadLine();}}}
正如你所看到的,一个行为体引用被创建,其代理是一个 ID 为 MyActor 的演员。下一行调用了一个行为体方法并打印出结果。这个概念在许多行为体框架中都是类似的:它通常涉及到创建一个给定类型的具有 ID 的行为体的引用,然后调用该行为体的一个方法。
Reliable Actor 支持 C# 和 Java,并与底层托管平台 Service Fabric 联系在一起。Orleans 使用筒仓(Silo)的概念来托管谷物(Grain,相当于一个行为体),筒仓可以在任何 .NET 支持的环境中运行。Akka 作为一个 JVM 进程运行,可以被 Java/Scala 原生访问,也可以通过一个 .NET 端口被 .NET 应用访问。Akka 可以在任何环境下运行,包括在容器内。
虽然这些现代框架都提供了行为体的编程体验,但它们因此被限制在有限的编程语言和/或托管平台上(在 Service Fabric Reliable Actor 的情况下)。
行为体编程模型是一个非常直观的模型,将状态和行为作为一个孤立的单元进行封装的概念,与熟悉面向对象编程(OOP)范式的人产生了良好的共鸣。然而,如果不注意编程模型的微妙含义,它可能会被误用,正如下一节所总结的。
行为体模型的误用
在许多情况下,行为体的使用是不正确的,会带来不想要的结果。例如,考虑以下场景,行为体被用来模拟道路上的汽车。每辆汽车都保持自己的状态:在这种情况下,它的行驶速度。
起初,行为体模型似乎非常合适:当汽车开始行驶时,就会创建一个行为体,当速度发生变化时,就会调用行为体上的 ChangeSpeed(speed) 方法来更新当前状态。但是,一个新的需求悄悄地出现在积压中 —— 开发人员需要能够在任何给定的时刻创建一个所有当前行驶的汽车的状态快照。他们的第一个冲动可能是查询每个行为体(针对每辆车),并汇总所有行为体的数据。
然而,这产生了以下问题:
- 试图同时调用数以百计或数以千计的行为体是一个缓慢的、无性能的操作。
- 查询行为体的状态会阻碍其他调用的行为体更新车辆的速度,本质上是暂停了所有在那一刻活跃的行为体实例。
行为体模型的另一个常见误用是用每个行为体保存大量的数据。数据越大,调用行为体的 I/O 调用(无论是获取状态还是让行为体保存状态)完成的时间就越长,从而锁定行为体的时间就越长,产生瓶颈。因此,很明显,确保行为体适合手头的问题是很重要的,而且问题可以被建模以正确地划分行为体,这样就不会因为从许多客户端调用一个行为体 ID 而产生瓶颈,或者保存大量的数据而减慢网络操作。
如果需要对多个行为体的数据进行汇总,最好是查询底层状态存储。如果这不可行,建议让每个行为体把它的数据保存到外部数据库,可以查询并提供聚合。图 5-2 说明了如何使用这种查询来聚合行为体的状态。第 7 章 将介绍一些关于使行为和聚合成为本地行为体功能的想法。
现在你已经对行为体编程模型和行为体编程中一些常见的反模式有了一个简单的了解,让我们来探索 Dapr 是如何实现这个编程模型的。
图 5-2. 集合状态的直接状态查询
Dapr 和行为模式
Dapr 提供了一个云原生的、有弹性的、与平台无关的虚拟行为体模型,作为其核心能力的一部分。行为体运行时间在 Dapr 运行时间内运行,使得在 Dapr 之上编写特定语言的行为体 SDK 变得容易,同时使行为体可以从任何语言通过 HTTP 或 gRPC 调用。
在撰写本文时,Dapr 还支持用 Dapr SDK 编写 .NET 和 Java 的行为体。Python SDK 正在积极开发中。
当我们决定支持行为体模型时,我们本可以选择一个现有的框架。然而,我们选择了编写一个全新的行为体框架,因为我们看到了两个现有框架无法满足的需求:
- 需要一个与语言无关的行为体模型
- 需要一个能在 Kubernetes 上原生运行的行为体模型,但又是独立的,可以在本地开发者的机器上运行,也是如此
考虑到这一点,我们决定解决虚拟行为体的一些最困难的问题:在一个集群中大规模地激活行为体,同时确保任何给定的行为体 ID 的单一激活,单线程访问,快速的性能,以及先进的功能,如计时器和持久的提醒器。
Dapr 中的行为体功能存在于两个地方:Dapr 运行时内部和一个叫做安置服务(Placement Service)的系统服务中。安置服务负责在集群中为行为体发现新的主机。一旦一个主机通过流式 gRPC 连接到放置服务,该主机就会使用一个一致的哈希算法来构建该主机上的行为体类型地图。
Dapr 使用的具体算法被称为有约束负载的一致散列;它是由谷歌在 2016 年的 白皮书 中首次发布。这种算法在节点(在我们的例子中是行为主体)快速和动态地加入和离开的环境中表现得特别好。
由于 Dapr 是作为一个边车运行的,在 Kubernetes 中,主机是一个 Pod,而不是一个节点。Pod 可以在节点之间移动、销毁或升级,当一个部署被扩展时,成千上万的 Pod 可以加入,而它们早期的行为体被删除。在这种动态环境中,散列算法必须是快速和高性能的,而且当环(主机的组合)改变时,它必须在主机之间尽可能少地转移行为体。图 5-3 显示了安置服务在 Dapr 中的工作方式。首先,主机与安置服务建立连接,报告它们的地址、健康状况和托管行为体类型。
图 5-3. Dapr 安置服务
然后,安置服务构建一个哈希表,该表由一个给定行为体类型的主机数组组成。每次更新哈希表时,安置服务都会更新所有与之相连的 Dapr 边车,如 图 5-4 所示。
图 5-4. 对 Dapr 边车的安置更新
当来自客户端的调用进入 Dapr 边车以调用一个行为体时,本地边车实例会在哈希表中查找该行为体的地址。哈希表接收行为体类型和 ID 作为输入,并返回该行为体的地址。下面是 Dapr 运行时的代码,显示了这一点:
func (a *actorsRuntime) lookupActorAddress(actorType, actorID string) string {// read lock for table mapa.placementTableLock.RLock()defer a.placementTableLock.RUnlock()t := a.placementTables.Entries[actorType]if t == nil {return ""}host, err := t.GetHost(actorID)if err != nil || host == nil {return ""}return fmt.Sprintf("%s:%v", host.Name, host.Port)}
正如这里所看到的,一个读锁被放置在放置表上。一个条目列表被检索出来,关键是行为体类型,然后一个主机被轮询为特定的行为体 ID。一致的散列表将返回该行为体 ID 所属范围的主机。最后,返回该主机的网络地址。
安置服务有一个困难的工作:它需要保持集群中的所有 Dapr 边车与表的最新快照同步,无论何时新的主机加入或离开。同步是很重要的,因为否则会出现同一行为体实例的多次激活。为了做到这一点,Dapr 使用一个独特的三阶段提交来更新边栏。当一个新的主机加入和哈希表发生转变时,Dapr 会向所有的边栏发出一个 “锁” 的命令。在这个命令发出后,正在进行的对行为体的请求被允许完成,而传入的请求则被临时保留。在收到所有参与的边车确认它们已经被锁定后,开始用新的哈希表进行更新。Dapr 边车各自收到新的哈希表并更新其本地副本。
第三个也是最后一个阶段是解锁边车。这个过程确保所有 Dapr 实例中的所有新请求都是针对哈希表的同一副本工作的。以下来自安置服务的代码显示了这一点:
func (p *Service) PerformTablesUpdate(hosts []daprinternal_pb.PlacementService_ReportDaprStatusServer, options placementOptions) {p.updateLock.Lock()defer p.updateLock.Unlock()if options.incrementGeneration {p.generation++}o := daprinternal_pb.PlacementOrder{Operation: "lock",}for _, host := range hosts {err := host.Send(&o)if err != nil {log.Errorf("error updating host on lock operation: %s", err)continue}}v := fmt.Sprintf("%v", p.generation)o.Operation = "update"o.Tables = &daprinternal_pb.PlacementTables{Version: v,Entries: map[string]*daprinternal_pb.PlacementTable{},}for k, v := range p.entries {hosts, sortedSet, loadMap, totalLoad := v.GetInternals()table := daprinternal_pb.PlacementTable{Hosts: hosts,SortedSet: sortedSet,TotalLoad: totalLoad,LoadMap: make(map[string]*daprinternal_pb.Host),}for lk, lv := range loadMap {h := daprinternal_pb.Host{Name: lv.Name,Load: lv.Load,Port: lv.Port,}table.LoadMap[lk] = &h}o.Tables.Entries[k] = &table}for _, host := range hosts {err := host.Send(&o)if err != nil {log.Errorf("error updating host on update operation: %s", err)continue}}o.Tables = nilo.Operation = "unlock"for _, host := range hosts {err := host.Send(&o)if err != nil {log.Errorf("error updating host on unlock operation: %s", err)continue}}}
如果一个节点在锁定阶段崩溃了,它就会被带出环,在重新加入时,它将在接受任何新的行为体客户端请求之前收到最新的快照。
现在让我们切换到用户的角度,学习如何使用 Dapr 行为体。
调用一个 Dapr 行为体
为了调用一个 Dapr 行为体,你可以使用 HTTP 或 gRPC 来与 Dapr API 对话。下面的例子展示了如何通过 HTTP 调用一个 Dapr 行为体:
$ curl -X POST http://localhost:3500/v1.0/actors/stormtrooper/50/method/shoot
这个例子使用 curl 调用运行在 3500 端口的 Dapr。它调用了一个 ID 为 50 的 stormtrooper 类型的行为体和一个名为 shoot 的方法。同样的事情也可以在任何一个 Dapr 原生客户端上完成。这种能力对 Dapr 来说是独一无二的:它允许开发者使用任何能理解 HTTP 的编程语言来调用用任何语言编写的 Dapr 行为体。接下来,我们将研究 Dapr 行为体如何管理状态。
Dapr 行为体状态管理
Dapr 使用外部状态存储来保存行为体的状态。这带来了以下好处:
- 对行为体数据的可视性
- 能够查询底层存储的汇总数据
- 灵活使用各种内部/云端的数据库
第三个好处与 Dapr 的云原生性方面发挥得很好,允许开发者在资源受限的环境中使用 Redis 这样的状态存储来运行行为体,然后在云中运行时,利用云数据库的优势,提供更高的服务水平协议(SLA)和更容易维护。这使得开发人员可以在不同环境之间移植他们的应用程序,并接受各种编程语言。
Dapr 使用以下模式为行为者的状态构建密钥:<DAPR-ID>||<ACTOR-TYPE>||<ACTOR-ID>||<KEY>。
<DAPR-ID>代表给 Dapr 应用程序的唯一 ID<ACTOR-TYPE>代表行为体的类型<ACTOR-ID>代表一个行为体类型的行为体实例的唯一 ID<KEY>是特定状态值的一个键,一个行为体 ID 可以持有多个状态键
知道了这一点,如果底层存储支持 SQL 接口,就很容易查询一个给定行为体的状态或对多个行为体的状态进行汇总查询。状态是以交易的方式保存的,Dapr 允许行为体使用特定的键来保存细化的状态。例如,一个猫行动者可以保存一个关于吃饭的状态值,一个关于颜色的不同状态值,以及另一个关于睡觉的状态值。然后,当一个行为体需要恢复它的状态时,它可以被延迟加载,而不是一次性地恢复。同样,在保存状态时,这也减少了延迟,并增加了吞吐量,因为只保存了细化的状态,而不是包含所有数值的单一数据结构。
像其他一些行为体框架一样,Dapr 行为体可以由外部调用或由定时器和提醒器触发的内部信号来触发。我们接下来会看一下这一点是如何实现的。
定时器
定时器是一种在给定的时间表上为行为体安排特定工作的方式。当一个行为体被一个定时器触发时,它可以改变它的状态。定时器是不持久的,当一个行为体重启到不同的主机上或在垃圾回收后被重新激活时,定时器不会被调用。每当一个行为体被激活时,它就有责任重新登记定时器。
下面的例子展示了如何通过 HTTP 创建一个行为体的定时器:
$ curl http://localhost:3500/v1.0/actors/stormtrooper/50/timers/checkRebels \-H "Content-Type: application/json" -d '{ "data": "someData", "dueTime": "1m", "period": "20s", "callback": "myEventHandler" }'
这个例子调用了 ID 为 50 的 stormtrooper 类型的行为体,并创建了一个名为 checkRebels 的新定时器。在发送给 Dapr 运行时的 HTTP POST 正文中,我们可以看到数据被附加到调用中。当定时器启动时,这些数据将被传递给行为体实例。dueTime 属性是第一次启动定时器前要等待的初始超时。period 属性表示该定时器的循环时间间隔,而 callback 指定了行为体上应该被 Dapr 调用的方法。你也可以使用 HTTP 删除一个行为体上的定时器:
$ curl http://localhost:3500/v1.0/actors/stormtrooper/50/timers/checkRebels \ -X "Content-Type: application/json"
下面的例子显示了如何用 Dapr C# Actor SDK 注册一个定时器:
{using System;using System.Threading.Tasks;using Dapr.Actors;using Dapr.Actors.Runtime;using IDemoActorInterface;public class DemoActor : Actor, IDemoActor, IRemindable{private const string StateName = "my_data";private IActorReminder reminder;/// <summary>/// Initializes a new instance of the <see cref="DemoActor"/> class./// </summary>/// <param name="service">Actor service hosting the actor.</param>/// <param name="actorId">Actor ID.</param>public DemoActor(ActorService service, ActorId actorId) : base(service, actorId){}/// <inheritdoc/>public Task RegisterTimer(){return this.RegisterTimerAsync("Test", this.TimerCallBack,null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));}/// <inheritdoc/>public Task UnregisterTimer(){return this.UnregisterTimerAsync("Test");}private Task TimerCallBack(object data){// Code for timer callback can be added here.return Task.CompletedTask;}}}
这个例子使用 RegisterTimer 方法来注册一个名为 Test 的新定时器,让它在第一次发射前等待 5 秒,然后每隔 5 秒触发一次。回调函数是 TimerCallback。当一个行为体死亡时,除非该行为体再次被实例化,否则定时器将不会再次调用它。
提醒器的工作方式与定时器类似,但它们是持久的。它们不会随着行为体实例的消失而消失。相反,它们总是存在的,除非明确地取消注册,正如下一节所解释的。
提醒器
与定时器不同,提醒器是持久的。它们被用来在一个指定的时间间隔内安排一个重复的工作。如果一个行为体死亡或迁移到不同的主机上,提醒器是持久化的,并将在它启动时调用(从而重新创建该行为体)。下面的例子展示了如何使用 HTTP 创建一个行为体提醒:
$ curl http://localhost:3500/v1.0/actors/stormtrooper/50/reminders/checkRebels \-H "Content-Type: application/json" -d '{ "data": "someData", "dueTime": "1m", "period": "20s" }'
这个例子在一个 ID 为 50 的 stormtrooper 类型的行为体上创建了一个 checkRebels 提醒。传递给 Dapr 运行时的数据包括 dueTime 和 period,前者是指在开始提醒前要等待的第一次超时,后者则是设置循环的间隔时间。
你可以使用 HTTP 请求明确地删除一个提醒器。下面的例子显示了如何删除 checkRebels 的提醒:
$ curl http://localhost:3500/v1.0/actors/stormtrooper/50/reminders/checkRebels \ -X "Content-Type: application/json"
下面的例子显示了如何使用 .Net SDK 创建一个提醒程序:
public class DemoActor : Actor, IDemoActor, IRemindable{private const string StateName = "my_data";private IActorReminder reminder;public DemoActor(ActorService service, ActorId actorId) : base(service, actorId){}/// <inheritdoc/>public async Task RegisterReminder(){this.reminder = await this.RegisterReminderAsync("Test", null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));}/// <inheritdoc/>public Task UnregisterReminder(){return this.UnregisterReminderAsync("Test");}/// <inheritdoc/>public Task ReceiveReminderAsync(string reminderName, byte[] state, TimeSpan dueTime, TimeSpan period){// This method is invoked when an actor reminder is fired.return Task.CompletedTask;}}
现在你已经学会了 Dapr 行为体的基本组件和功能,让我们用一个使用 Dapr .NET SDK 的完整例子来结束本章。
开始编写 C# 的 Dapr 行为体
要开始使用 C# 的 Dapr 行为体,首先在你的机器上下载并安装 Dapr。你还需要 .NET Core SDK。完整的教程可以在 GitHub 上找到。
定义行为体接口
要使用 C# SDK 在行为体编程模型中编程,你需要做的第一件事就是定义你的行为体将实现的接口。我们将在这里定义一个接口:
namespace IDemoActorInterface{using System.Threading.Tasks;using Dapr.Actors;/// <summary>/// Interface for Actor method./// </summary>public interface IDemoActor : IActor{/// <summary>/// Method to save data./// </summary>/// <param name="data">Data to save.</param>/// <returns>A task that represents the asynchronous save operation.</returns>Task SaveData(MyData data);}/// <summary>/// Data used by the sample actor./// </summary> public class MyData{/// <summary>/// Gets or sets the value for PropertyA./// </summary>public string PropertyA { get; set; }}}
你可以看到,我们的行为体有一个方法,SaveData,它接受一个 MyData 类型的类。这个类封装了每个用 ID 激活的行为体的状态。
实现行为体接口
下一步开始实现接口:
namespace DaprDemoActor{using System;using System.Threading.Tasks;using Dapr.Actors;using Dapr.Actors.Runtime;using IDemoActorInterface;/// </summary>public class DemoActor : Actor, IDemoActor, IRemindable{private const string StateName = "my_data";private IActorReminder reminder;/// <summary>/// Initializes a new instance of the <see cref="DemoActor"/> class./// </summary>/// <param name="service">Actor service hosting the actor.</param>/// <param name="actorId">Actor ID.</param>public DemoActor(ActorService service, ActorId actorId) : base(service, actorId){}/// <inheritdoc/>public async Task SaveData(MyData data){Console.WriteLine($"This is Actor id {this.Id} with data {data.ToString()}");/// Set state using StateManager. State is saved after the method/// execution.await this.StateManager.SetStateAsync<MyData>(StateName, data);}}}
DemoActor 类实现了 IDemoActor 接口并继承了来自 Dapr.Actors 命名空间的 Actor 基类。
在幕后,Dapr .Net SDK 启动,注册行为体类型,然后联系 Dapr 运行时,将自己注册为 DemoActor 行为体类型的主机。然后,Dapr 运行时更新放置服务,这反过来又更新行为体哈希表,并将其发送到集群中的所有 Dapr 边车。每当 SaveData 方法被调用时,StateManager 会被 SetStateAsync 调用。这个方法根据一个特定的键来保存数据,在这个例子中,它是 "my_data"。
总结
Dapr 提供了一个新的行为体框架,它支持所有现代编程语言和典型的虚拟行为体行为,如基于回合的并发、状态管理、计时器和提醒。如果你以前有使用现有虚拟行为体框架的经验,你会发现使用 Dapr 行为体是一个非常熟悉的过程。
Dapr 的行为体设计的一个关键区别是,Dapr 不把行为体实例当作独立的进程,而是当作同一网络服务的路由规则。这使得 Dapr 可以高密度地托管行为体实例 —— 我们实验过在一个节点上启动一百万个行为体实例,没有什么问题。
在下一章中,你将看到几个例子,说明如何在一些应用场景中使用行为体。在最后一章中,你将看到我们如何设想 Dapr 行为体的一些新的高级功能。
