13、Dapr 中的 Actor 编程模型详解

Dapr 中的 Actor 编程模型详解

1. Actor 编程模型概述

Actor 编程模型是一种直观的模型,它将状态和操作封装为一个独立单元,这与熟悉面向对象编程(OOP)范式的人产生了很好的共鸣。然而,如果不注意该编程模型的微妙含义,可能会导致误用。

1.1 Actor 模型的误用

  • 场景示例 :以用 Actor 模拟道路上的汽车为例,每辆车维护自己的状态(如行驶速度)。起初,Actor 模型似乎很合适,汽车启动时创建 Actor,速度变化时调用 ChangeSpeed(speed) 方法更新状态。但当需要创建所有行驶中汽车状态的快照时,若直接查询每个 Actor 并聚合数据,会产生以下问题:
    • 同时调用成百上千个 Actor 是缓慢且性能不佳的操作。
    • 查询 Actor 状态会阻塞其他更新车辆速度的调用,暂停所有活跃的 Actor 实例。
  • 另一种常见误用 :每个 Actor 保存大量数据。数据量越大,调用 Actor 的 I/O 操作完成所需时间越长,会导致 Actor 长时间锁定,造成瓶颈。

为避免这些问题,如果需要多个 Actor 数据的聚合视图,最好查询底层状态存储。若不可行,建议每个 Actor 将数据保存到外部数据库,以便查询和聚合。

2. Dapr 与 Actor

2.1 Dapr 的 Actor 模型特点

Dapr 提供了云原生、弹性、平台无关的虚拟 Actor 模型,作为其核心功能的一部分。Actor 运行时在 Dapr 运行时内运行,便于在 Dapr 之上编写特定语言的 Actor SDK,并且可以通过 HTTP 或 gRPC 从任何语言调用 Actor。目前,Dapr 支持使用 .NET 和 Java 的 Dapr SDK 编写 Actor,Python SDK 正在积极开发中。

选择实现新的 Actor 框架,是因为现有框架无法满足以下两个需求:
- 语言无关的 Actor 模型。
- 能在 Kubernetes 上原生运行,但又独立且可在本地开发机器上运行的 Actor 模型。

2.2 Dapr 中 Actor 功能的位置

Dapr 中的 Actor 功能位于两个地方:Dapr 运行时内部和名为 Placement 服务的系统服务。Placement 服务负责在集群中发现 Actor 的新主机。主机通过流式 gRPC 连接到 Placement 服务后,使用一致性哈希算法构建该主机上托管的 Actor 类型的映射。

Dapr 使用的具体算法是带有限制负载的一致性哈希算法,该算法在节点快速动态加入和离开的环境中表现出色。在 Kubernetes 中,由于 Dapr 作为边车运行,主机是 Pod 而非节点。Pod 可以在节点之间移动、销毁或升级,在这种动态环境中,哈希算法必须快速高效,并且在主机组合变化时,尽量减少 Actor 在主机之间的迁移。

2.3 Placement 服务的工作流程

graph LR
    classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px

    A([主机]):::startend -->|建立连接,报告信息| B(Placement 服务):::process
    B -->|构建哈希表| C(哈希表):::process
    C -->|更新哈希表| D(Dapr 边车):::process
    E([客户端]):::startend -->|调用 Actor| D
    D -->|查找 Actor 地址| C
    C -->|返回地址| D
    D -->|调用 Actor| F(Actor):::process
  1. 主机与 Placement 服务建立连接,报告其地址、健康状况和托管的 Actor 类型。
  2. Placement 服务构建包含给定 Actor 类型主机数组的哈希表。
  3. 每次哈希表更新时,Placement 服务更新所有连接的 Dapr 边车。
  4. 客户端调用 Dapr 边车以调用 Actor 时,本地边车实例在哈希表中查找 Actor 的地址。

以下是 Dapr 运行时中查找 Actor 地址的代码:

func (a *actorsRuntime) lookupActorAddress(actorType, actorID string) string {
    // read lock for table map
    a.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)
}

2.4 Placement 服务的同步机制

Placement 服务需要确保集群中所有 Dapr 边车与最新的哈希表快照同步,否则可能会出现同一 Actor 实例多次激活的情况。Dapr 使用独特的三阶段提交来更新边车:
1. 锁定阶段 :新主机加入导致哈希表变化时,Dapr 向所有边车发出“锁定”命令。此时,正在进行的 Actor 请求允许完成,传入请求暂停。
2. 更新阶段 :收到所有参与边车已锁定的确认后,开始使用新的哈希表进行更新。Dapr 边车接收新的哈希表并更新本地副本。
3. 解锁阶段 :最后解锁边车,确保所有 Dapr 实例中的新请求都基于相同的哈希表副本工作。

以下是 Placement 服务执行表更新的代码:

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 = nil
    o.Operation = "unlock"
    for _, host := range hosts {
        err := host.Send(&o)
        if err != nil {
            log.Errorf("error updating host on unlock operation: %s", err)
            continue
        }
    }
}

如果节点在锁定阶段崩溃,它将被从环中移除,重新加入时将在接收任何新的 Actor 客户端请求之前获取最新的快照。

3. 调用 Dapr Actor

可以使用 HTTP 或 gRPC 与 Dapr API 通信来调用 Dapr Actor。以下是通过 HTTP 调用 Dapr Actor 的示例:

curl -X POST http://localhost:3500/v1.0/actors/stormtrooper/50/method/shoot

此示例使用 curl 调用运行在端口 3500 上的 Dapr,调用类型为 stormtrooper 、ID 为 50 的 Actor 的 shoot 方法。同样的操作也可以使用任何 Dapr 协议缓冲区客户端完成。这是 Dapr 的独特功能,允许开发人员使用任何理解 HTTP 的编程语言调用用任何语言编写的 Dapr Actor。

4. 状态管理

4.1 使用外部状态存储的好处

Dapr 使用外部状态存储来保存 Actor 的状态,带来以下好处:
- 可见性:可以查看 Actor 数据。
- 聚合查询:能够查询底层存储以获取聚合数据。
- 灵活性:可以使用各种本地或基于云的数据库。

4.2 状态键的构造模式

Dapr 使用以下模式构造 Actor 状态的键: <DAPR-ID>||<ACTOR-TYPE>||<ACTOR-ID>||<KEY> ,其中:
- <DAPR-ID> :Dapr 应用程序的唯一 ID。
- <ACTOR-TYPE> :Actor 的类型。
- <ACTOR-ID> :Actor 实例的唯一 ID。
- <KEY> :特定状态值的键,一个 Actor ID 可以持有多个状态键。

了解这些后,如果底层存储支持 SQL 接口,就可以轻松查询给定 Actor 的状态或执行多个 Actor 状态的聚合查询。状态以事务方式保存,Dapr 允许 Actor 使用特定键保存细粒度状态,减少恢复和保存状态时的延迟,提高吞吐量。

5. 定时器

定时器是为 Actor 按给定时间表安排特定工作的一种方式。当 Actor 被定时器触发时,它可以改变自己的状态。定时器不持久化,当 Actor 故障转移到不同主机或在垃圾回收后重新激活时,定时器不会被调用。每次 Actor 激活时,都有责任重新注册定时器。

5.1 通过 HTTP 创建 Actor 定时器

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"
}'

此示例调用类型为 stormtrooper 、ID 为 50 的 Actor,创建一个名为 checkRebels 的新定时器。在发送给 Dapr 运行时的 HTTP POST 主体中,附加的数据将在定时器触发时传递给 Actor 实例。 dueTime 是首次触发定时器前的初始超时时间, period 是定时器的重复间隔, callback 指定 Dapr 应调用的 Actor 方法。

5.2 使用 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;

        public DemoActor(ActorService service, ActorId actorId)
              : base(service, actorId)
        {
        }

        public Task RegisterTimer()
        {
            return this.RegisterTimerAsync("Test", this.TimerCallBack, 
              null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
        }

        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 。当 Actor 死亡时,除非 Actor 重新实例化,否则定时器不会再次调用。

6. 提醒器

与定时器不同,提醒器是持久化的,用于按指定间隔安排重复工作。如果 Actor 死亡或迁移到不同主机,提醒器会持久化,触发时会调用(并重新创建)Actor。

6.1 通过 HTTP 创建 Actor 提醒器

curl http://localhost:3500/v1.0/actors/stormtrooper/50/reminders/checkRebels \
    -H "Content-Type: application/json"
-d '{
    "data": "someData",
    "dueTime": "1m",
    "period": "20s"
}'

此示例在类型为 stormtrooper 、ID 为 50 的 Actor 上创建一个 checkRebels 提醒器。传递给 Dapr 运行时的数据包括 dueTime (开始提醒器前的首次超时时间)和 period (重复间隔)。

6.2 使用 Dotnet 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)
    {
    }
    public async Task RegisterReminder()
    {
        this.reminder = await this.RegisterReminderAsync("Test", 
          null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
    }

    public Task UnregisterReminder()
    {
        return this.UnregisterReminderAsync("Test");
    }

    public Task ReceiveReminderAsync(string reminderName, byte[] 
          state, TimeSpan dueTime, TimeSpan period)
    {
        // This method is invoked when an actor reminder is fired.
        return Task.CompletedTask;
    }
}

7. 开始使用 C# 编写 Dapr Actor

7.1 准备工作

首先,在机器上下载并安装 Dapr(https://oreil.ly/VISmr),还需要 .NET Core SDK。完整教程可在 GitHub(https://oreil.ly/dekIm)上找到。

7.2 定义 Actor 接口

namespace IDemoActorInterface
{
    using System.Threading.Tasks;
    using Dapr.Actors;

    public interface IDemoActor : IActor
    {
        Task SaveData(MyData data);
    }

    public class MyData
    {
        public string PropertyA { get; set; }
    }
}

此 Actor 有一个 SaveData 方法,接受 MyData 类型的类,该类封装了每个激活的 Actor 的状态。

7.3 实现 Actor 接口

namespace DaprDemoActor
{
    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;

        public DemoActor(ActorService service, ActorId actorId)
              : base(service, actorId)
        {
        }

        public async Task SaveData(MyData data)
        {
            Console.WriteLine($"This is Actor id {this.Id} with data 
              {data.ToString()}");
            await this.StateManager.SetStateAsync<MyData>(StateName, data);
        }
    }
}

DemoActor 类实现了 IDemoActor 接口,并继承自 Actor 基类。Dapr Dotnet SDK 会在幕后启动、注册 Actor 类型,然后联系 Dapr 运行时,将自己注册为 DemoActor 类型的主机。每当调用 SaveData 方法时,会调用 StateManager SetStateAsync 方法,根据特定键(此例中为 "my_data" )保存数据。

8. 总结

Dapr 提供了一个新的 Actor 框架,支持所有现代编程语言和典型的虚拟 Actor 行为,如基于回合的并发、状态管理、定时器和提醒器。与现有虚拟 Actor 框架相比,Dapr 将 Actor 实例视为同一 Web 服务上的路由规则,而非独立进程,这允许 Dapr 以高密度托管 Actor 实例。

8.1 Dapr Actor 框架的优势

  • 语言兼容性 :支持所有现代编程语言,开发人员可以使用自己熟悉的语言进行 Actor 开发,无需担心语言限制。
  • 高托管密度 :将 Actor 实例视为同一 Web 服务上的路由规则,能够在单个节点上轻松启动大量 Actor 实例,如文中提到的在单节点上启动百万个 Actor 实例也不会有太大问题,提高了资源利用率。
  • 典型行为支持 :具备基于回合的并发、状态管理、定时器和提醒器等典型的虚拟 Actor 行为,满足各种应用场景的需求。

8.2 应用场景展望

虽然没有详细介绍具体的应用场景示例,但可以推测 Dapr Actor 在以下场景中可能会有出色的表现:
- 游戏开发 :在多人在线游戏中,每个玩家角色可以作为一个 Actor,管理自己的状态(如位置、血量等),通过定时器和提醒器实现角色的定时动作(如自动回血、定时任务等)。
- 物联网 :每个物联网设备可以抽象为一个 Actor,保存设备的状态信息(如温度、湿度等),并根据定时器进行数据采集和上报。
- 金融交易 :处理交易请求的 Actor 可以管理交易的状态(如未处理、处理中、已完成等),并通过提醒器确保交易的按时执行和结算。

8.3 未来发展方向

虽然没有提及具体的新和高级功能,但可以预见 Dapr Actor 未来可能会在以下方面进行改进和拓展:
- 性能优化 :进一步优化 Actor 的并发处理能力和响应速度,以应对更高的负载和更复杂的业务逻辑。
- 功能增强 :增加更多的内置功能,如更复杂的状态管理机制、分布式事务支持等。
- 集成扩展 :与更多的云服务和第三方工具进行集成,提供更丰富的生态系统。

8.4 总结表格

特性 描述
语言兼容性 支持所有现代编程语言
高托管密度 可在单节点上启动大量 Actor 实例
典型行为支持 具备基于回合的并发、状态管理、定时器和提醒器等功能
应用场景 游戏开发、物联网、金融交易等
未来发展方向 性能优化、功能增强、集成扩展等

8.5 操作步骤回顾

为了方便读者回顾,以下是使用 Dapr Actor 的主要操作步骤总结:
1. 调用 Dapr Actor
- 使用 HTTP 调用: curl -X POST http://localhost:3500/v1.0/actors/stormtrooper/50/method/shoot
- 也可使用 gRPC 或 Dapr 协议缓冲区客户端完成相同操作。
2. 状态管理
- 状态键构造: <DAPR-ID>||<ACTOR-TYPE>||<ACTOR-ID>||<KEY>
- 保存状态:调用 StateManager SetStateAsync 方法。
3. 定时器使用
- 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"
}'
- C# SDK 注册:
public Task RegisterTimer()
{
    return this.RegisterTimerAsync("Test", this.TimerCallBack, 
      null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
  1. 提醒器使用
    • HTTP 创建:
curl http://localhost:3500/v1.0/actors/stormtrooper/50/reminders/checkRebels \
    -H "Content-Type: application/json"
-d '{
    "data": "someData",
    "dueTime": "1m",
    "period": "20s"
}'
- Dotnet SDK 创建:
public async Task RegisterReminder()
{
    this.reminder = await this.RegisterReminderAsync("Test", 
      null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
  1. C# 编写 Dapr Actor
    • 定义接口:
namespace IDemoActorInterface
{
    using System.Threading.Tasks;
    using Dapr.Actors;

    public interface IDemoActor : IActor
    {
        Task SaveData(MyData data);
    }

    public class MyData
    {
        public string PropertyA { get; set; }
    }
}
- 实现接口:
namespace DaprDemoActor
{
    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;

        public DemoActor(ActorService service, ActorId actorId)
              : base(service, actorId)
        {
        }

        public async Task SaveData(MyData data)
        {
            Console.WriteLine($"This is Actor id {this.Id} with data 
              {data.ToString()}");
            await this.StateManager.SetStateAsync<MyData>(StateName, data);
        }
    }
}

8.6 整体流程 mermaid 图

graph LR
    classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px

    A([准备工作]):::startend --> B(定义 Actor 接口):::process
    B --> C(实现 Actor 接口):::process
    C --> D(状态管理):::process
    C --> E(定时器使用):::process
    C --> F(提醒器使用):::process
    D --> G(调用 Dapr Actor):::process
    E --> G
    F --> G
    G --> H([应用部署与运行]):::startend

通过以上内容,我们对 Dapr 中的 Actor 编程模型有了全面的了解,包括其概念、使用方法、优势以及未来的发展方向。希望这些信息能帮助开发人员更好地应用 Dapr Actor 进行项目开发。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值