Akka.Net – les fondamentaux

Akka.Net – les fondamentaux

Akka.Net – les fondamentaux

 14 minutes )

Akka.Net

Nous avons vu dans l’article précédent (Les systèmes réactifs et le pattern actor model) une présentation du pattern actor model et un exemple de système d’acteurs l’utilisant.

Nous allons maintenant découvrir une des implémentations de ce pattern : Akka.Net. Il s’agit du portage .Net du framework Akka initialement créé en Scala/Java en 2009.

Akka.Net a été développé par Petabridge et permet de créer rapidement des systèmes implémentant le pattern actor model en balayant toute la complexité technique en se concentrant directement sur l’implémentation des comportements de chaque acteur.

Ce framework permet de créer des systèmes orientés message, scalables, résilients et intrinsèquement distribués.

Akka.Net (et toutes ses librairies associées) est disponible via un package nuget, et supporte .Net standard 1.6.1.

Tout acteur vit dans un ActorSystem

Comme l’a dit Carl Hewitt (concepteur du pattern actor model): “One actor is no actor, they come in systems”. En Akka.Net, ce système est appelé ActorSystem. C’est un univers dans lequel les acteurs s’exécutent et communiquent via des messages.

Un ActorSystem peut tourner sur une seule machine ou peut être distribué sur plusieurs instances.

Au sein d’un actorSystem, les acteurs sont répartis dans une hiérarchie. Chaque acteur est rattaché à un parent (y compris le root guardian qui est un cas particulier) dont il dépend et auquel il délègue certaines responsabilités (notamment la gestion des erreurs).

Pour instancier un actorySystem en C#, il doit être déclaré en lui associant un identifiant:

ActorSystem actorSystem = ActorSystem.Create("MyActorSystem");

ActorSystem

Lors de l’instanciation d’un actorSystem, plusieurs acteurs sont automatiquement créés (appelés guardians), ils sont chargés de gérer et maintenir la cohérence de l’ensemble des entités présentes dans l’actorSystem.

  • L’acteur root guardian ( / ) : c’est l’acteur au sommet de l’arborescence. Il est le premier à démarrer et le dernier à s’arrêter lors du cycle de vie de l’actorSystem.

  • L’acteur system guardian ( /system ) : tous les enfants de cet acteur sont des acteurs utiles au bon fonctionnement de l’actorSystem et gérés directement par le framework.

  • L’acteur user guardian ( /user ) : il s’agit du parent de tout acteur instancié par le code utilisant le framework. C’est réellement sous cet acteur que vivra l’ensemble des acteurs que vous allez déclarer.

Les guardians sont des acteurs avec lesquels vous n’aurez quasiment jamais à interagir directement. Tout acteur que vous instancierez possèdera donc un chemin (ou path) qui commencera par le préfixe “/user/”.

Conception d’un acteur

Pour concevoir un acteur, il est nécessaire de définir une classe héritant du type abstrait UntypedActor. La seule chose à définir est la méthode OnReceive :

public class SimpleUntypedActor : UntypedActor
{
    protected override void OnReceive(object message)
    {
        // Traitement pour tout type de message
    }
}

À chaque fois qu’une instance de cet acteur recevra un message, la méthode OnReceive sera invoquée en recevant en argument le message de type object. Nous voyons bien que le framework nous permet de nous concentrer sur l’essentiel du comportement d’un acteur : le traitement des messages qu’il reçoit.

Le fait de recevoir des messages de type object n’est pas très pratique et nous amène très souvent à tester le type réel des messages reçus en utilisant du pattern matching (depuis C#7). Cela génère des déclarations d’acteur ressemblant à :

public class SimpleReceiveActor : UntypedActor
{
        protected override void OnReceive(object message)
        {
                switch(message)
                {
                        case Type1 messageType1:
                                // Traitement pour un message de type Type1
                                break;
                        case Type2 messageType2:
                                // Traitement pour un message de type Type2
                                break;
                        default:
                                // Traitement pour tout autre type de message
                                break;
                }
        }
}

Pour simplifier la gestion des messages type par type, la classe ReceiveActor (qui hérite elle-même du type UntypedActor) permet de déterminer une action à exécuter pour chaque type de message reçu.

public class SimpleReceiveActor : ReceiveActor
{
        public SimpleReceiveActor()
        {
                Receive<Type1>(messageType1 =>        /* Traitement pour un message de type Type1   */ );
                Receive<Type2>(messageType2 =>      /* Traitement pour un message de type Type2   */ );
                ReceiveAny(message =>                         /* Traitement pour tout autre type de message */ );
        }
}

L’ordre des appels à la méthode générique Receive<T> est important, il détermine dans quel ordre l’acteur va tenter d’identifier quelle action exécuter pour le message en cours de traitement.

La méthode ReceiveAny permet de définir un comportement pour tout type de message. Lorsqu’elle est utilisée, cette méthode doit bien évidemment être placée à la suite des appels à la méthode Receive<T> car elle intercepte tous les types de messages non déclarés.

En plus de la méthode Receive<T>(T message), il existe une méthode Receive<T>(T message, Predicate<T> predicat) permettant de filtrer l’action à exécuter, non seulement par un type donné, mais également par un prédicat spécifique.

Par exemple :

public class SimpleIntReceiveActor : ReceiveActor
{
        public SimpleIntReceiveActor()
        {
                Receive<int>(intVal => Console.WriteLine($"{intVal} est pair"),
                                         intVal => (intVal % 2) == 0);
                Receive<int>(intVal => Console.WriteLine($"{intVal} est impair"));
        }
}

Il existe également des méthodes ReceiveAsync et ReceiveAnyAsync permettant des traitements asynchrones. Tant que le traitement asynchrone n’est pas terminé, l’acteur reste bloqué et commencera à traiter le prochain message à la fin de la tâche asyncrhone.

Mais que se passe-t-il lorsqu’un message est envoyé à un acteur qui ne prend pas en charge ce type de message ? Est-il perdu ? Par exemple si l’on envoie un message de type string à une instance d’un SimpleIntReceiveActor.

Pas vraiment, il est intercepté par un acteur particulier du framework (DeadLetters) qui est à l’écoute de ces messages non gérés. Il est possible de s’abonner à cet acteur afin de lui demander de nous transmettre ces messages “perdus”. Nous verrons cela dans un autre article concernant les EventStream.

Instanciation d’un acteur

Un acteur n’est jamais directement instancié. Il faut demander au framework de l’instancier pour nous. Deux cas sont possibles :

  1. Il s'agit d'un acteur principal, situé juste en-dessous de l'acteur user guardian ( /user ):
    Dans ce cas, pour instancier un acteur, il faut appeler la méthode d'extension générique ActorOf<T> sur l'ActorSystem.
    var actorRef = actorSystem.ActorOf<SimpleReceiveActor>("actorName");
    
  2. Il s'agit d'un acteur enfant d'un autre acteur
    Pour instancier un enfant d'acteur, il faut appeler la méthode d'extension générique ActorOf<T> sur la propriété Context de l'acteur parent. Cette propriété est implémentée dans la classe UntypedActor.
    var actorRef = Context.ActorOf<SimpleReceiveActor>("actorName");
    

La classe ActorSystem implémente en réalité l’interface IActorRefFactory, tout comme la propriété Context d’un acteur (IUntypedActorContext). ActorOf<T> est une méthode d’extension de l’interface IActorRefFactory. Les deux cas décrits ci-dessus déclenchent donc finalement l’appel à la même méthode.

Le résultat retourné par l’appel à la méthode ActorOf<SimpleReceiveActor> est de type IActorRef et non SimpleReceiveActor. Comme nous l’avons vu dans l’article précédent, dans le pattern Actor Model, les interactions entre acteurs s’effectuent via des références d’acteurs et non via des appels directs aux acteurs. On envoie donc un message à une référence, et pas directement à un acteur.

Un IActorRef est une référence vers un acteur (ou ensemble d’acteurs) instancié(s) par le framework et doit être utilisé pour toute interaction avec un acteur.

Les Props

Mais comment déclarer un acteur possédant un constructeur recevant des paramètres (pour lui injecter des services par exemple) ? C’est là qu’interviennent les Props.

Il existe, en effet, une autre méthode ActorOf non générique qui reçoit un objet de type Props. Un Props est un objet permettant de définir la façon dont un acteur doit être instancié par le framework. Il est, en quelques sortes, le mode d’emploi pour la création d’un acteur et permet donc de pouvoir spécifier des paramètres pour l’instanciation de ce dernier.

var actorProps = Props.Create(() => new SimpleReceiveActor(param1, param2));
actorSystem.ActorOf(actorProps, "actorName");

Communication entre acteurs

Maintenant que nous savons créer des acteurs, voyons comment les faire communiquer entre eux. Plus exactement, voyons comment envoyer des messages à des IActorRef

Il existe trois types d’envoi de message : Tell, Forward et Ask.

  1. La méthode Tell permet d'envoyer simplement un message à un IActorRef.
    // Contexte d'exécution : Acteur "a1"
    a2.Tell(message);
  2. La méthode Forward permet de transférer un message reçu à un autre IActorRef en gardant l'expéditeur d'origine en tant qu'émetteur.
    // Contexte d'exécution : Acteur "a2"
    a3.Forward(messageReceived); // On transfert le message reçu (initialement envoyé par "a1") à l'acteur "a3". "a3" le verra comme ayant été envoyé par "a1".
  3. La méthode Ask<T> est un appel qui permet d'envoyer un message et de rester bloqué en attendant une réponse. Cette méthode est à utiliser avec parcimonie car elle ralentit considérablement la consommation des messages.
    actorRef.Ask<TypeDeRetour>(message); // A utiliser avec modération car il s'agit d'appels bloquants !
    
Froward

Sender & Self

Il existe deux propriétés particulières attachées à un acteur et plus particulièrement à son contexte lors du traitement d’un message. Il s’agit des propriétés Sender et Self, elles sont toutes les deux de type IActorRef.

  • Sender est un pointeur vers l’acteur ayant émis le message en cours de traitement, il permet de pouvoir échanger avec l’acteur à l’origine du traitement en cours.

  • Self est un pointeur vers l’acteur lui-même. Il permet de planifier un traitement pour lui-même en se renvoyant un message. Ce message sera alors mis en entrée de sa file de messages à traiter. Self permet également à un acteur de transmettre sa référence à d’autres acteurs dans un message.

Les acteurs en tant que machine à états finis (Final Sate Machine - FSM)

Comme nous l’avons vu dans l’article sur le pattern actor model, lors du traitement d’un message, un acteur peut effectuer trois types d’actions :

  • Créer un ou plusieurs nouveaux acteurs
  • Envoyer un ou plusieurs messages à un nombre fini d’acteurs qu’il connait (y compris lui-même)
  • Changer la façon dont il gèrera les messages suivants

Nous avons déjà vu comment créer de nouveaux acteurs et comment envoyer des messages. Nous allons maintenant découvrir comment changer le comportement d’un acteur.

Pour implémenter ces changements d’état, Akka.Net offre une API qui permet de facilement mettre en place les définitions et les transitions entre ces différents états. Il existe deux types de changements de comportement :

  • Le remplacement de comportement
  • L’empilement des comportements

1. Le remplacement de comportement - Become

Pour définir un nouveau comportement, il est possible d’invoquer la méthode Become en lui passant en argument une Action définissant le comportement à adopter lors du traitement du prochain message.

Ce nouveau comportement sera alors maintenu jusqu’au prochain appel à la méthode Become qui le remplacera.

Voyons un exemple simple d’acteur qui utilise la méthode Become :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class BiStateActor : ReceiveActor
{
    public BiStateActor()
    {
        // L'acteur traitera le prochain message avec le comportement "HappyActor"
        Become(HappyActor);
    }

    private void HappyActor()
    {
        ReceiveAny(m => {
            Sender.Tell("I'm happy :)");

            // L'acteur traitera le prochain message avec le comportement "SadActor"
            Become(SadActor);
        });
    }

    private void SadActor()
    {
        ReceiveAny(m => {
            Sender.Tell("I'm sad :(");

            // L'acteur traitera le prochain message avec le comportement "HappyActor"
            Become(HappyActor);
        });
    }
}

Tout d’abord, cet acteur adopte le comportement HappyActor (affecté dans le constructeur - ligne 6).

Lors de la réception du premier message, cet acteur retournera donc à son expéditeur le message “I’m happy :)” puis changera de comportement pour le message suivant, où il adoptera le comportement SadActor (ligne 15).

Lors de la réception du message suivant, il retournera donc à son expéditeur le message “I’m sad :(“ puis changera de comportement pour le message suivant en adoptant le comportement HappyActor (ligne 25).

La boucle est bouclée, nous avons un acteur qui change de comportement à chaque message reçu.

2. L’empilement des comportements - BecomeStacked et UnBecomeStacked

Le fonctionnement des méthodes BecomeStacked et UnBecomeStacked est similaire au fonctionnement de Become, à la différence que le nouveau comportement est empilé au-dessus du comportement en cours (BecomeStacked). Le nouveau comportement est donc adopté, mais il est possible de revenir au comportement précédent en demandant à l’acteur de dépiler son comportement courant (BecomeStacked).

Analysons l’acteur StackedBiStateActor suivant :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class StackedBiStateActor : ReceiveActor
{
    public StackedBiStateActor()
    {
        // L'acteur démarre avec le comportement "HappyActor"
        BecomeStacked(HappyActor);
    }

    private void HappyActor()
    {
        ReceiveAny(m => {
            Sender.Tell("I'm happy :)");

            // On empile le comportement "SadActor". L'acteur aura désormais le comportement "SadActor"
            BecomeStacked(SadActor);
        });
    }

    private void SadActor()
    {
        ReceiveAny(m => {
            Sender.Tell("I'm sad :(");

            // On dépile le comportement en cours ("SadActor").
            // L'acteur aura donc désormais le comportement précédent ("HappyActor")
            UnbecomeStacked();
        });
    }
}

Son comportement est exactement le même que l’acteur BiStateActor vu juste avant, il utilise cependant les méthodes BecomeStacked et UnBecomeStacked.

L’acteur démarre en empilant le comportement HappyActor (Étape 1) (ligne 6), sa pile de comportements contient donc uniquement le comportement HappyActor et il retournera “I’m happy :)” lors du traitement de son prochain message (Étape 2).

Il empilera ensuite le comportement SadActor (Étape 3) (ligne 15) au-dessus du comportement HappyActor et retournera “I’m sad :(” à la réception du message suivant (Étape 4).

Il dépilera ensuite le comportement courant, à savoir SadActor (Étape 5) (ligne 26), et se retrouvera donc avec uniquement le comportement HappyActor dans sa pile (Étape 6).

Nous sommes revenus au comportement initial.

Pour mieux comprendre, voici les étapes :

stacked behaviour 1stacked behaviour 2stacked behaviour 3

3. Alors, Become ou BecomeStacked / UnBecomeStacked ?

Tout dépend de l’acteur que vous implémentez. Become n’est qu’une particularité de l’utilisation des méthodes BecomeStacked et UnBecomeStacked.

Lors de l’appel à la méthode Become, Akka.Net utilise en interne la fonctionnalité BecomeStacked.

Dans la plupart des cas, Become suffit, cependant il peut arriver que vous ayez besoin de garder une trace des comportements précédents de l’acteur pour pouvoir, éventuellement, les restaurer par la suite. Dans ce cas, l’utilisation de BecomeStacked/UnBecomeStacked est votre solution.

Prenons, par exemple, le cas d’un acteur chargé de gérer le parcours d’un client sur un site d’e-commerce. Le client passe par les comportements Non connecté, Connecté, En cours de shopping, En cours de saisie d’adresse, En cours de paiement et là, sa session expire. Il se retrouve alors de nouveau avec le comportement Non connecté. Lorsque l’utilisateur se reconnecte, ne serait-il pas intéressant de restaurer le comportement En cours de paiement plutôt que de lui assigner le comportement Connecté ? Si ces comportements avaient été empilés (BecomeStacked), il suffirait alors d’invoquer la méthode UnBecomeStacked pour qu’il retrouve le comportement qu’il avait avant sa déconnexion.

Mise en pratique

Le code source d’un projet illustrant tous ces principes est disponible à l’adresse suivante : Repository Github AkkaPlayground

On the next episode(s)…

Nous venons de voir les bases de l’utilisation du framework Akka.Net. Celui-ci permet d’implémenter facilement une solution basée sur le pattern actor model en se concentrant directement sur les comportements des acteurs et leur communication.

Le framework permet de définir les trois types d’actions que peut faire un acteur : – Créer de nouveaux acteurs via la méthode ActorOf et les Props – Envoyer des messages via les méthodes Tell, Forward et Ask – Changer la façon dont il gèrera les messages suivants via les méthodes Become, BecomeStacked et UnbecomeStacked

Pour continuer notre voyage dans le monde de l’actor model et le framework Akka.Net, nous étudierons dans un prochain article, les groupes et les pools d’acteurs.