【ProgrammingMicrosoftAzureServiceFabric】第四章: Actor模式

第4章: Actor模式

过去几十年里我们从面向对象编程(OOP)中学习到许多问题可以被建模为一些拥有行为和状态的交互对象。Actor模式更进了一步,把问题建模为独立的并通过消息交互的Agent。

Actor模式(也称为Actor 架构或Actor 模型)提供了一个强大的工具来抽象复杂的分布式系统。在分布式系统中大量的Agent在一起工作,并不需要任何中心治理。Agent是自给自足的,根据它们收到的消息和自身的状态在本地做出决策,这使它们适应各种异构环境。因为Agent可以演变、迁移,更换和在不破坏其他Agent的情况下销毁,这样的系统往往表现出令人印象深刻的鲁棒性、适应性、和并发性。

人类社会是一个典型的Actor模型。每个人(Agent)是一个独立的实体,我们都拥有自己的属性,我们根据我们的知识(状态和行为)做出决策,并对周围的信息做出反应。我们互相通信(发信息),我们共同组成一个充满活力的社会。

同样,如果一个系统可以由多个独立交互Actor来描述,则可以使用Actor模式进行建模。例如,在多人游戏中,每个玩家都可以被视为一个Actor;在电子商务系统中,每个购物车都可以被看作是一个Actor;在物联网场景中,一个传感器可以被视为一个Actor。游戏玩家 、购物车和传感器似乎没有任何共同点,但是因为它们都有状态都表现出一些行为,它们都代表独立的实体,因此它们可以抽象为Actor。

复杂系统建模
在人类社会中,没有支配我们行动的中心实体。虽然有政府和法律,但是没有人告诉我们每天应该具体行动的过程。相反,我们基于我们的经验和观察采取行为。不可思议的是,虽然我们每个个体单独的作出决定,没有任何集中的协调,但还是有社会现象的出现。复杂系统建模将是第18章的主题,“复杂系统建模” 。对于复杂系统的建模,像Azure Service Fabric这样云级别的Actor模式框架是一个具有革命性的工具。

Service Fabric Reliable Actors

Service Fabric Reliable Actors API使用actors提供异步,单线程的编程模型。 这个编程模型使我们能够像写单件,单线程实现的方式来实现actor。

Actors

Actor是一个封装了特定状态和行为的独立的单线程组件。 我们可以把actor类型理解成为OOP中的类。 根据actor的类型我们可以创建任意数目的actor实例, 这就像在OOP中我们实例化一个类。 每一个actor实例具有一个唯一个actor ID, 客户端或者是其他actor可以根据这个ID来识别和定位某个actor。

Actor可以拥有状态。根据是否有用状态,我们把actor分为:有状态actor和无状态actor。Actor的状态是由actor状态提供者来管理,状态可以被保存到内存中,磁盘,或者是外部存储上。

在Service Fabric中,我们要实现的actor需要继承自StateLessActor基类(无状态actor)或StateFulActor基类(有状态actor), 并实现actor自身的行为接口,这个接口继承自一个空的IActor接口。在本章的稍后部分我们会看看具体的例子。

Actor的生命周期

Actor是虚拟的,这意味中在逻辑上他们总是存在的。 我们不需要创建或销毁actor。 当我们需要和actor通信的时候,我们只需要使用actor代理向actor发送一条消息。如果actor没有被激活或者是actor已经被注销,Service Fabric会激活actor。 如果actor有一段时间没有被使用过,Service Fabric会自动注销这个actor以减少资源消耗。

特定的事件发生时,Service Fabric会调用不同的回调函数。 当actor被激活时(相当于.NET 对象被载入内存),OnActiveAsync()方法会调用;当actor被销毁时(这意味着actor正在被垃圾回收),OnDeactiveAsync()方法会被调用。

Actor 状态

有状态actor继承自StatefulActor这个泛型类,T是状态类型。 状态类型是可序列化的数据契约(Service Fabric Actor模式使用System.Runtime.Serialization.DataContractSerializer 来做状态数据序列化)。有状态actor应该在OnActiveAsync() 方法中初始化它的状态。例如:

```
public override Task OnActivateAsync()
{
    if (this.State == null)
    {
        this.State = new Actor1State() { Count = 0 };
    }
}
```

注意: 在更新状态属性的时我们不需要加锁, Service Fabric 会保证在任何时候只有一个actor的方法被调用。在后面的“并发”章节会有更多的细节。我们只需要一个简单的赋值语句来更新状态,例如:

```
this.State.Count = count;
```

Actor通信

Actor直接通过发送消息的方式和其他actor通信。为了简化编程模型,Service Fabric提供了actor代理,actor代理让我们可以直接向actor发送消息,就像我们调用actor定义的方法一样。我们可以使用代理来完成客户端到actor,actor到actor的通信。 例如:

```
var proxy = ActorProxy.Create<IActor1>(ActorId.NewId(), "fabric:/Application29");
proxy.SetCountAsync(10).Wait();
```

Actor可以被迁移,例如,当一个节点失效,在这个节点上的actor就会被迁移到一个健康的节点上。 Actor代理隐藏了actor的物理位置,使客户端可以直接通过actor ID和actor通信。代理也有一套内建的重试机制来处理短暂的错误,所以我们的客户端代码不需要复杂的处理逻辑。但是,这也意味着消息可能被多次发送到actor上,如果actor没有对前面的接受进行确认。

并发

Actor提供了基于回合制的并发模型,actor级别的锁被用来保证在任何时候只有一个actor的方法被调用。这种并发模型允许开发人员可以像写单线程组件一样的来写actor的代码。这种并发应用于所有的从客户端和其它actor对actor方法的调用,定时器回调函数,提醒回调函数,这些我们会在后面继续讨论。当一个方法在执行时,新的请求将异步的等待这个锁,直到获取到这个actor级别的锁。

只有当运行的方法返回和返回的任务完成,actor级别的锁才会被释放。 然而,我们可以在我们的代码里继续使用其他的线程和异步方法。在这种情况下,回合制的并发不会被应用。

如果一个方法不用修改状态, 我们可以把这个方法标示为只读的。 当一个方法被标记为只读,状态更新的逻辑就会被跳过,可以提供更好的性能。但是,只读方法仍然受回合制的并发机制约束。

默认的,Actor允许重入的。所以,如果Actor A调用Actor B的一个方法,Actor B再调用Actor A的另一个方法, 这种调用是被允许的,应为它是单个逻辑调用链上下文。 定时器和提醒的回调函数总是开始一个新的调用上下文。 所以,在前面的情况下,Actor A调用Actor B, 如果一个定时器回调时,它需要等待Actor级别的锁,因为它是在不同的调用上下文中。

我们可以通过注释掉重入属性来禁用actor的重入。

```
[Reentrant(ReentrancyMode.Disallowed)]
public class Actor1 : Actor<Actor1State>, IActor1
```

Actor是为并发而设计的,基于回合的并发仅仅应用于单个actor实例。 多个actor实例可以并发的在相同的宿主上运行。

基于Actor的tic-tac-toe游戏

现在让我们用actor模式开发一个简单的两个玩家的tic-tac-toe游戏。

Actor模型

在这个系统中actor是什么? 即使是对于这样一个简单的应用, 又存在多种方式来定义actor模型。一种直接的方式是定义玩家actor类型和游戏actor类型。玩家actor 代表一个游戏玩家, 游戏actor代表一个游戏回合,而游戏棋盘这是游戏actor的状态。

对于任何在线游戏, 如何避免作弊是设计时需要考虑的。对于我们的tic-tac-toe游戏, 我们需要确保两个游戏玩家是轮流下棋的。 为了保证这一点,游戏actor需要保存下一步轮到谁下棋的属性。 即使是玩家试图多次移动棋子,它的请求都不会被处理直到轮到该玩家为止。

为了简单,我们使用一个控制台应用作为客户端。 在客户端程序里,我们使用两个线程模拟两个玩家同时走棋——两个玩家都在试图欺诈。系统需要确保游戏按序推进。

创建应用

首先我们创建所有类的骨架, 然后实现系统的actors。

  1. 创建ActorTicTacToeApplication的Service Fabric应用, 包含一个叫Player的无状态Actor服务;
  2. 右键点击ActorTicTacToeApplication的应用,选择新建Fabric Service菜单;
  3. 添加一个名叫Game的有状态Actor服务;
  4. 添加一个新的TestClient控制台应用。 修改目标框架为.NET Framework 4.5.1何目标平台为x64;
  5. 在TestClient项目中,添加对Game.Interfaces和Player.Interfaces的引用;
  6. 添加对Microsoft.ServiceFabric.Actors NuGet包的引用;

定义actor接口

Player actor是一个无状态actor,它只有两个方法的简单接口: 加入游戏和移动一步棋。 Game actor有更复杂的接口, 它允许游戏玩家加入游戏,移动棋子,返回棋盘状态和赢家。

  1. 修改Player.Interfaces 项目中的IPlayer接口

    ```
    public interface IPlayer : IActor
    {
        Task<bool> JoinGameAsync(ActorId gameId, string playerName);
        Task<bool> MakeMoveAsync(ActorId gameId, int x, int y);
    }
    ```
    
  2. 修改Game.Interfaces中IGame接口:

    public interface IGame : IActor
    {
    Task<bool> JoinGameAsync(long playerId, string playerName);
    Task<int[]> GetGameBoardAsync();
    Task<string> GetWinnerAsync();
    Task<bool> MakeMoveAsync(long playerId, int x, int y);
    }

实现Game actor

在这一部分,我们将着重讨论Game actor。我们定义游戏状态,然后实现IGame 接口。

  1. 在Game项目中修改ActorState类。

    ```
    [DataContract]
    public class ActorState
    {
        [DataMember]
        public int[] Board;
        [DataMember]
        public string Winner;
        [DataMember]
        public List<Tuple<long,string>> Players;
        [DataMember]
        public int NextPlayerIndex;
        [DataMember]
        public int NumberOfMoves;
    }
    ```
    

    游戏棋盘(Board属性)被定义为有九个byte的数组。 数组中每一项可以被赋值为0(没有棋子),1(玩家1的棋子),或1(玩家2的棋子)。属性Winner保存游戏赢家的名字。 当游戏进行时,它的值为空。 当一个玩家获胜,这个属性会被设置为获胜玩家的名字。 如果平局,它被设置为TIE。Players属性保存了玩家的列表。 NextPlayerIndex表现轮到哪个玩家下棋。 最后,NumberOfMoves跟踪棋盘上已经有多少个棋子了。我们用来判断是否棋盘已经填满。

  2. 修改OnActivateAsync()方法,在actor激活时初始化状态。

    ```
    public override Task OnActivateAsync()
    {
        if (this.State == null)
        {
            this.State = new ActorState()
            {
                Board = new int[9],
                Winner = "",
                Players = new List<Tuple<long,string>>(),
                NextPlayerIndex = 0,
                NumberOfMoves = 0
            };
        }
        return Task.FromResult(true);
    }
    ```
    
  3. 实现JoinGameAsync()方法。 这个方法允许两个不同名字的玩家加入游戏。 尽管可能会有多个客户端同时尝试加入游戏,我们不需要对玩家队列进行加锁保护。回合制的并发机制将会确保任何时候这个方法只有一个调用者。

    ```
    public Task<bool> JoinGameAsync(long playerId, string playerName)
    {
        if (this.State.Players.Count >= 2
            || this.State.Players.FirstOrDefault(p => p.Item2 == playerName) != null)
                return Task.FromResult<bool>(false);
        this.State.Players.Add(new Tuple<long, string>(playerId, playerName));
        return Task.FromResult<bool>(true);
    }
    ```
    
  4. 状态获取的方法很容易实现,如下。 这两个方法都被标记为只读。

    ```
    [Readonly]
    public Task<int[]> GetGameBoardAsync()
    {
        return Task.FromResult<int[]>(this.State.Board);
    }
    [Readonly]
    public Task<string> GetWinnerAsync()
    {
        return Task.FromResult<string>(this.State.Winner);
    }
    ```
    
  5. MakeMoveAsync()方法是游戏引擎的核心。 首先,它检测棋子移动是否有效:是否轮到该玩家,是否棋子是放在没有棋子的位置, 是否游戏已经结束。其次, 它更新棋盘和判断谁是赢家。 如果游戏没有结束,需要标示轮到哪个玩家下棋。 由于回合制的并发控制,我们代码逻辑不需要担心并发的问题。

    ```
    public Task<bool> MakeMoveAsync(long playerId, int x, int y)
    {
        if (x < 0 || x > 2 || y < 0 || y > 2
            || this.State.Players.Count != 2
            || this.State.NumberOfMoves >= 9
            || this.State.Winner != "")
                return Task.FromResult<bool>(false);
    
        int index = this.State.Players.FindIndex(p => p.Item1 == playerId);
        if (index == this.State.NextPlayerIndex)
        {
            if (this.State.Board[y * 3 + x] == 0)
            {
                int piece = index * 2 - 1;
                this.State.Board[y * 3 + x] = piece;
                this.State.NumberOfMoves++;
    
                if (HasWon(piece * 3))
                    this.State.Winner = this.State.Players[index].Item2 + " (" +
                               (piece == -1 ? "X" : "O") + ")";
                else if (this.State.Winner == "" && this.State.NumberOfMoves >= 9)
                    this.State.Winner = "TIE";
    
                this.State.NextPlayerIndex = (this.State.NextPlayerIndex + 1) % 2;
                return Task.FromResult<bool>(true);
             }
            else
                return Task.FromResult<bool>(false);
        }
        else
            return Task.FromResult<bool>(false);
    }
    ```
    
  6. HasWon()方法是一个O(1)复杂度的检测是否水平,垂直,和对角方向上连成一条线简单实现。

    ```
    private bool HasWon(int sum)
    {
        return this.State.Board[0] + this.State.Board[1] + this.State.Board[2] == sum
            || this.State.Board[3] + this.State.Board[4] + this.State.Board[5] == sum
            || this.State.Board[6] + this.State.Board[7] + this.State.Board[8] == sum
            || this.State.Board[0] + this.State.Board[3] + this.State.Board[6] == sum
            || this.State.Board[1] + this.State.Board[4] + this.State.Board[7] == sum
            || this.State.Board[2] + this.State.Board[5] + this.State.Board[8] == sum
            || this.State.Board[0] + this.State.Board[4] + this.State.Board[8] == sum
            || this.State.Board[2] + this.State.Board[4] + this.State.Board[6] == sum;
    }
    ```
    

实现游戏玩家actor

Player的实现非常简单,方法调用对应的游戏actor的方法。实现也显示了actor使用相同的actor代理和其他actor通信。 这里我们需要添加对Game.Interfaces的引用。

    ```
    public Task<bool> JoinGameAsync(ActorId gameId, string playerName)
    {
        var game = ActorProxy.Create<IGame>(gameId, "fabric:/ActorTicTacToeApplication");
        return game.JoinGameAsync(this.Id.GetLongId(), playerName);
    }

    public Task<bool> MakeMoveAsync(ActorId gameId, int x, int y)
    {
        var game = ActorProxy.Create<IGame>(gameId, "fabric:/ActorTicTacToeApplication");
        return game.MakeMoveAsync(this.Id.GetLongId(), x, y);
    }
    ```

测试客户端的实现

现在,我们来看看TestClient项目。 为了演示基于回合制的并发, 我们将运行两个并发的游戏玩家actor,两个玩家都随机的移动棋子,不会等待每一操作的返回。 基于回合制的并发和玩家的交替逻辑保证了游戏按照规则进行。

  1. 实现Main()方法。 这个方法启动三个并行的任务: 两个是下棋的游戏玩家和一个刷新和显示游戏棋盘的任务。

    ```
    public static void Main(string[] args)
    {
        var player1 = ActorProxy.Create<IPlayer>(ActorId.NewId(),
              "fabric:/ActorTicTacToeApplication");
        var player2 = ActorProxy.Create<IPlayer>(ActorId.NewId(),
              "fabric:/ActorTicTacToeApplication");
        var gameId = ActorId.NewId();
        var game = ActorProxy.Create<IGame>(gameId, "fabric:/ActorTicTacToeApplication");
        var rand = new Random();
    
        var result1 = player1.JoinGameAsync(gameId, "Player 1");
        var result2 = player2.JoinGameAsync(gameId, "Player 2");
    
        if (!result1.Result || !result2.Result)
        {
            Console.WriteLine("Failed to join game.");
            return;
        }
        var player1Task = Task.Run(() =>{ MakeMove(player1, game, gameId);});
        var player2Task = Task.Run(() => { MakeMove(player2, game, gameId); });
        var gameTask = Task.Run(() =>
        {
            string winner = "";
            while (winner == "")
            {
                var board = game.GetGameBoardAsync().Result;
                PrintBoard(board);
                winner = game.GetWinnerAsync().Result;
                Task.Delay(1000).Wait();
            }
    
            Console.WriteLine("Winner is: " + winner);
        });
    
        gameTask.Wait();
        Console.Read();
    }
    ```
    
  2. 游戏玩家使用MakeMove()来随机的移动棋子,每一次移动棋子以前随机的休眠几秒钟。

    ```
    private static async void MakeMove(IPlayer player,IGame game, ActorId gameId)
    {
        Random rand = new Random();
        while (true)
        {
            await player.MakeMoveAsync(gameId, rand.Next(0, 3), rand.Next(0, 3));
            await Task.Delay(rand.Next(500, 2000));
        }
    }
    ```
    
  3. 最后,PrintBoard()打印出棋盘。

    ```
    private static void PrintBoard(int[] board)
    {
        Console.Clear();
    
        for (int i = 0; i < board.Length;i++)
        {
            if (board[i] == -1)
                Console.Write(" X ");
            else if (board[i] == 1)
                Console.Write(" O ");
            else
                Console.Write(" . ");
            if ((i+1) % 3 == 0)
                Console.WriteLine();
        }
    }
    ```
    

测试

不是我们的应用并启动测试客户端。 正常情况下,我们可以看到如图4-1的模拟游戏。

一些更多思考

还有很多方法可以改进我们的游戏,有兴趣的读者可以试一试下面这些练习:

  • 用户的交互。 前面的实现并不是一个真正的游戏,我们无法亲自玩这个游戏。 但我们很容易修改成为一个真正的游戏:接受用户的输入而不是自动随机的移动棋子。

  • 聪明的玩家。 当前的实现仅仅是随机的移动棋子。 但是,但是我们可以写一个聪明的玩家。 tic-tac-toe是一个可以枚举所以可能移动棋并找到一个永远也不会输的下法的棋盘游戏 。当然,这可能会惹恼我们的客户,因为他们永远也不会赢。因此,你可以在某些时候让我们聪明的游戏玩家随机的移动棋子,给对手一个赢的机会。

  • 避免无效的走棋。 游戏玩家都是没有检查棋盘就随机的移动棋子。随之游戏的进行,棋盘逐渐填满,无效移动棋子的概率也不断的增加。这也就是为什么我们看到游戏推进的速度越来越慢。我们可以在移动棋子之前检查棋盘,减少无效移动棋子。

定时器,提醒,和事件

Actor封装了行为和状态。 有一些是响应式的行为,而另外一些则是自我驱动的。 例如, 表示一辆汽车的actor可能有几个响应式行为接受输入,如转向,加速,刹车灯。但它需要保持当前的速度和方向直到收到新的命令。在这种情况下,actor需要能够以一定速度更新它的状态,这个可以通过定时器和提醒来完成。

Actor定时器

Actor定时器是对.NET定时器的封装,它也受回合制并发的约束。为了使用定时器,首先声明一个IActorTimer 的局部变量。

接着, 我们使用基类的RegisterTimer()方法注册一个行的定时器。 通常,在OnActiveAsync()方法中注册定时器。

```
public override Task OnActivateAsync()
{
    ...
    mTimer = RegisterTimer(Move, //callback function
        someObject, //callback state
        TimeSpan.FromSeconds(5), //delay before first callback
        TimeSpan.FromSeconds(1)); //callback interval
}
```

回调函数是一个返回Task的简单的方法。

```
private Task Move(Object state)
{
    ...
    return Task.FromResult(1);
}
```

我们可以使用UnRegisterTimer()来注销定时器,例如:

```
public override Task OnDeactivateAsync()
{
    if (mTimer != null)
        UnregisterTimer(mTimer);
    return base.OnDeactivateAsync();
}
```

由于Actor定时器受回合制并发的约束,在任何时候只会有一个回调在执行。 这也意味着当回调执行的时候定时器会被停止,当回调执行完成再重新启动定时器。

如果是一个有状态的actor,当定时器回调执行完成,Service Fabric运行时会制动保存状态。如果保存操作失败,actor实例会被注销,一个新的实例会被创建。如果回调不会修改状态,我们可以把回到标记为只读的,从而跳过状态保存。

Actor提醒

Actor的提醒提供了另外一种定时触发回调的机制。Actor提醒和定时器的主要不同在于:actor提醒回调在任何环境下总是被触发直到它被显示的注销。 即使是actor已经被注销,提醒回调将重新激活actor。

只有有状态actor支持提醒。

我们可以使用基类的RegisterReminder()方法来注册一个提醒。 例如,下面代码段注册一个提醒来触发每隔15天支付1700美元的账单。

```
string task = "Pay bill";
int amountInDollars = 1700;
Task<IActorReminder> reminder = RegisterReminder(
    task, //reminder name
    BitConverter.GetBytes(amountInDollars), //callback state
    TimeSpan.FromDays(3), //delay before the first callback
    TimeSpan.FromDays(15), //callback interval
    ActorReminderAttributes.None); //callback flag – if the method is ReadOnly.

```

使用提醒的actor需要实现IRemindable接口,它定义了ReceiveReminderAsync()方法。 例如,为了处理上上面的提醒,我们需要实现一些ReceiveReminderAsync()方法的实现。

```
public Task ReceiveReminderAsync(string reminderName, byte[] context, TimeSpan dueTime, TimeSpan period)
{
    if (reminderName.Equals("Pay bill"))
    {
        int amountToPay = BitConverter.ToInt32(context, 0);
        System.Console.WriteLine("Please pay your bill of ${0}!", amountToPay);
    }
    return Task.FromResult(true);
}
```

如果actor注册了多个提醒,无论哪个提到到期,处理方法都会被调用。 我们的代码需要检查提醒的名字来决定受到的是哪个提醒消息。

如果回调不会修改actor状态,当注册提醒时,可以用ActorReminderAttributes.ReadOnly属性来标记回调为只读的。这个不同于定时器的标记回调自身为只读。

如果要注销提醒,首先我们使用基类的GetReminder()方法来获取到注册的提醒的引用,然后使用UnregisterReminder()方法来注销提醒。

```
IActorReminder reminder = GetReminder("Pay bill");
Task reminderUnregistration = UnregisterReminder(reminder);
```

Actor事件

Service Fabric也允许actor使用事件发送通知给客户端。 这种机制设计来actor和客户端通信,actor和actor之间不支持这种通信方式。
为了使用actor事件, 首先,我们需要定一个继承者IActorEvents接口的事件接口。 所有的在这个接口里的方法必须返回void, 所有参数必须是可以序列化的数据类型。 例如, 下面的actor事件接口定义了一个新的挑战者加入了游戏的事件。

```
public interface IGameEvents : IActorEvents
{
    void NewChallengerHasArrived(string playerName);
}
```

发布事件的actor需要实现IActorEventPublisher接口,例如:

```
public interface IGameActor : IActor, IActorEventPublisher<IGameEvents>
{
    ...
}
```

在客户端,我们需要实现一个事件处理者来处理actor发出的事件。

```
class GameEventsHandler : IGameEvents
{
    public void NewChallengerHasArrived(string playerName)
    {
        Console.WriteLine(@"A New Challenger Has Arrived: {1}", playerName);
    }
}
```

接下来,我们可以用actor代理来注册这个处理对象。

```
var proxy = ActorProxy.Create<IGameActor>(actorId, serviceUri);
proxy.SubscribeAsync(new GameEventsHandler()).Wait();
```

当actor在一个节点上失效,actor代理会自动重新订阅这个事件。 SubscribeAsync()可以接受第二个参数,第二个参数指定重试 了重新订阅的时间间隔。
actor代理的UnsubscribeAsync()方法可以用来取消订阅。
最后,为了触发事件, actor需要使用GetEvent()方法来获取到事件,并调用事件接口上的方法来触发事件。

```
var evt = GetEvent<IGameEvents>();
evt.NewChallengerHasArrived(State.PlayerName);
```

Actor内部实现

Service Fabric Actor隐藏了大量的细节,为开发人员提供了一个简单的编程模型。 深入理解这些场景背后的细节是很有价值的,这可以帮助我们理解我们有什么可供选择,有什么样的限制,和有哪些陷阱需要避免。

猜你喜欢

转载自blog.csdn.net/nj_kevin_peng/article/details/77979263