Ejemplo del modelo actor con Dapr

Otro de los bloques que Dapr contempla dentro de su framework es el de los actores, un modelo que ha ganado popularidad en los últimos años, si bien data de 1973 (propuesto por Carl Hewitt), gracias a los sistemas distribuidos y las CPUs de última generación con más de un núcleo. Aunque hay un artículo en la documentación oficial que explica algunos puntos de su funcionamiento en .NET, hoy quiero compartir contigo un ejemplo que he montado, con el que he estado trasteando estos días.

Aplicación de ejemplo

Como vengo acostumbrando, voy a seguir usando mi ejemplo de los héroes pero esta vez en formato actor 🤓. Cada héroe será mi unidad mínima de cómputo y estado.

La interfaz del actor

Lo primero que hago es, al igual que el ejemplo de la documentación oficial, crear un directorio llamado tour-of-heroes-dapr-actors y dentro de él un proyecto de tipo librería llamado Interfaces al que le he añadido el paquete de nuget Dapr.Actors:

# Create Actor Interfaces
dotnet new classlib -o Interfaces
cd Interfaces
# Add Dapr.Actors nuget package.
dotnet add package Dapr.Actors
cd ..

En el he creado una interfaz como la que sigue:

namespace Interfaces;
public interface IHeroActor : IActor
{
    Task<string> SetDataAsync(HeroData data);
    Task<HeroData> GetDataAsync();
    Task RegisterReminder();
    Task UnregisterReminder();
    Task RegisterTimer();
    Task UnregisterTimer();
}

Los métodos SetDataAsync y GetDataAsync hacen uso de la clase HeroData para el estado del héroe, la cual tiene el siguiente aspecto:

namespace Interfaces
{
    public class HeroData
    {
        public string Name { get; set; }
        public string AlterEgo { get; set; }
        public string Description { get; set; }
        public override string ToString()
        {
            return $"Name: {this.Name}, Alter ego: {this.AlterEgo}";
        }
    }
}

El resto de métodos a implementar veremos después para qué se usan.

Hospedar los actores

Ahora que ya sabemos qué pinta van a tener nuestros héroes necesitamos una implementación de los mismos y un servidor que los hospede. En este caso, al ser el ejemplo en .NET, vamos a usar un proyecto del tipo ASP.NET Web Core:

# Create ASP.Net Web service to host Dapr actor
dotnet new web -o HeroActorService
cd MyActorService
# Add Dapr.Actors.AspNetCore nuget package.
dotnet add package Dapr.Actors.AspNetCore
# Add Actor Interface reference
dotnet add reference ../Interfaces/Interfaces.csproj
cd ..

Creamos un nuevo archivo, llamado HeroActor.cs, e implementamos la interfaz que se definió en el punto anterior:

using Dapr.Actors.Runtime;
using Interfaces;
internal class HeroActor : Actor, IHeroActor, IRemindable
{
    public HeroActor(ActorHost host) : base(host) { }
    protected override Task OnActivateAsync()
    {
        //Inside an actor class, you have access to an ILogger instance through a property on the base Actor class.
        this.Logger.LogInformation("Activating hero with id: {Id}", this.Id);
        // When logging, avoid using format strings like: $"Getting state at {DateTime.UtcNow}"
        // Logging should use the named placeholder syntax which offers better performance and integration with logging systems.

        return Task.CompletedTask;
    }
    protected override Task OnDeactivateAsync()
    {
        var heroInfo = this.GetDataAsync().GetAwaiter().GetResult();
        this.Logger.LogInformation(
            "[OnDeactivateAsync] {Name} is going to sleep...",
            heroInfo.Name
        );
        return Task.CompletedTask;
    }
    public async Task<string> SetDataAsync(HeroData data)
    {
        this.Logger.LogInformation(
            "Setting state at {CurrentTime} for hero with id {Id}",
            DateTime.Now,
            this.Id
        );
        await this.StateManager.SetStateAsync<HeroData>("hero_data", data);
        return "Success";
    }
    public Task<HeroData> GetDataAsync()
    {
        this.Logger.LogInformation(
            "Getting state at {CurrentTime} for hero with id {Id}",
            DateTime.Now,
            this.Id
        );
        return this.StateManager.GetStateAsync<HeroData>("hero_data");
    }
    public async Task RegisterReminder()
    {
        this.Logger.LogInformation("Register reminder");
        await this.RegisterReminderAsync(
            "MyReminder",
            null,
            TimeSpan.FromSeconds(0),
            TimeSpan.FromSeconds(15)
        );
    }
    public Task UnregisterReminder()
    {
        this.Logger.LogInformation("Unregistering MyReminder...");
        return this.UnregisterReminderAsync("MyReminder");
    }
   
    public Task ReceiveReminderAsync(
        string reminderName,
        byte[] state,
        TimeSpan dueTime,
        TimeSpan period
    )
    {
        var heroInfo = this.GetDataAsync().GetAwaiter().GetResult();
        this.Logger.LogInformation("[ReceiveReminderAsync] I'm {Name}", heroInfo.Name);
        return Task.CompletedTask;
    }
  
    public Task RegisterTimer()
    {
        return this.RegisterTimerAsync(
            timerName: "MyTimer",
            callback: nameof(this.OnTimerCallBack),
            callbackParams: null,
            dueTime: TimeSpan.FromSeconds(0), // Time to delay before the async callback is first invoked
            period: TimeSpan.FromSeconds(30) // Time interval between invocations of the async callback
        );
    }
   
    public Task UnregisterTimer()
    {
        this.Logger.LogInformation("Unregistering MyTimer...");
        return this.UnregisterTimerAsync("MyTimer");
    }
   
    private Task OnTimerCallBack(byte[] data)
    {
        this.Logger.LogInformation(
            "OnTimerCallBack is called for actor hero with id: {Id}",
            this.Id
        );
        return Task.CompletedTask;
    }
}

Esta va a implementar la clase Actor, nuestro IHeroActor y la interfaz IRemindable. La primera de ellas es necesaria para que Dapr tome esta clase como una implementación de sus actores, con la funcionalidad común a estos. IHeroActor es la interfaz mostrada anteriormente y IRemindable nos permite consumir reminders que explicaré ahora.

Empezando de arriba a abajo, estos son los métodos implementados:

  • OnActivateAsync: se ejecuta cada vez que un actor se activa, ya sea porque se crea la primera vez o porque se invoca alguno de los métodos del actor, después de un tiempo de inactividad, y el actor vuelve a activarse. En este método he aprovechado para dejar algunos comentarios respecto al logging, ya que se recomienda el formato que se ve en el código y no interpolación para evitar que el rendimiento se degrade.
  • OnDeactivateAsync: cada vez que un actor pasa por un tiempo de inactividad, es decir que nadie le consulta o no tienen ningún reminder configurado, este se descarga de memoria y, en el caso de los héroes, se van a dormir. Este método es muy útil si queremos hacer alguna tarea adicional antes de que esto pase.
  • SetDataAsync: como comenté al inicio de este artículo, los actores tienen estado y este se guardará en el componente de tipo state store configurado, o el por defecto que genera se genera, de tipo Redis con dapr init. En este método se ve cómo persistir la información que corresponde al actor en cuestión.
  • GetDataAsync: si en el método anterior persistíamos el estado del actor en este otro somos capaces de recuperarlo. Además, en cualquier caso, podemos usar nuestras clases, como HeroData, que será serializada a JSON para guardarse dicha información y poder tratarla de una forma más cómoda para nosotros.
  • RegisterReminder: como comentaba más arriba, los actores se desactivan tras de un tiempo de actividad, pero es posible que en ciertas ocasiones necesitemos que estos realicen alguna operación sin importar si están desactivados o no. Con los remainders somos capaces de que los actores ejecuten tareas cada cierto tiempo estén activos o no. En este caso cada 15 segundos se ejecutará el método ReceiveReminderAsync.
  • UnregisterReminder: nos permite eliminar un reminder que previamente hemos dado de alta.
  • RegisterTimer: este caso es similar a los reminder a excepción de que si el actor que lo tiene configurado en un momento dado se desactiva ya no se ejecutarán más temporizadores para ese actor.
  • UnregisterTimer: nos permite eliminar el temporizador.

Lo único que nos queda es ver todo esto en funcionamiento 😃 pero antes necesitamos algo más: hay que registrar en nuestro proyecto de ASP.NET Web Core este tipo de actor y habilitar el handler que sepa que tiene que interactuar con estos. Para ello podemos hacerlo en el archivo Program.cs del proyecto:

var builder = WebApplication.CreateBuilder(args);
// The AddActors(...) method will register actor services with ASP.NET Core dependency injection.
builder.Services.AddActors(options =>
{
    options.Actors.RegisterActor<HeroActor>();
    options.ActorIdleTimeout = TimeSpan.FromSeconds(25);
    options.ActorScanInterval = TimeSpan.FromSeconds(10);
    options.DrainOngoingCallTimeout = TimeSpan.FromSeconds(5);
    options.DrainRebalancedActors = true;
    
});
var app = builder.Build();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
    endpoints.MapActorsHandlers();
});
app.MapGet("/", () => "Hello World!");
app.Run();

En este puedes ver que he añadido a nivel de builder.Services el método AddActors y dentro de este he registrado el que acabamos de implementar. Además de esto podemos establecer otros valores, como el tiempo que tarda un actor en tiempo de inactividad, para que se desactive, cada cuánto tiempo se escanea, así como el timeout antes de que se drene un actor.
Por otro lado, dentro de app.UseEndpoints añadimos el handler para los actores con MapActorHandlers.

Cliente para probar los actores

Ahora que ya tenemos todo implementado, podemos crear un último proyecto que será el que utilicemos de cliente de todo lo anterior:

# Create Actor's Client
dotnet new console -o HeroActorClient
cd HeroActorClient
# Add Dapr.Actors nuget package.
dotnet add package Dapr.Actors
# Add Actor Interface reference
dotnet add reference ../Interfaces/Interfaces.csproj
cd ..

En Program.cs tengo una implementación como la siguiente:

using Dapr.Actors;
using Dapr.Actors.Client;
using Interfaces;
// Two styles of actor client
/**********************************************************************************************************************************************************************
**** Strongly-typed: Strongly-typed clients are based on .NET interfaces and provide the typical benefits of strong-typing. They don’t work with non-.NET actors. ***
**********************************************************************************************************************************************************************/
var actorType = "HeroActor";
// var actorId = new ActorId("1");
// If you don’t have an actor id and want to communicate with a new instance, create a random id with ActorId.CreateRandom()
var actorId = new ActorId(ActorId.CreateRandom().ToString());
// All of the APIs on IActorProxyFactory will require an actor type and actor id to communicate with an actor.
var proxy = ActorProxy.Create<IHeroActor>(actorId, actorType);
// Now you can use the actor interface to call the actor's methods.
Console.WriteLine($"Calling SetDataAsync on {actorType}: {actorId}...");
var response = await proxy.SetDataAsync(
    new HeroData() { Name = "Batman", AlterEgo = "Bruce Wayne", }
);
Console.WriteLine($"Got response: {response}");
Console.WriteLine($"Calling GetDataAsync on {actorType}: {actorId}...");
var savedData = await proxy.GetDataAsync();
Console.WriteLine($"Got response: {savedData}");
Console.WriteLine("Set a remainder in order to make the hero alive...");
await proxy.RegisterReminder();
/**********************************************************************************************************************************************************************
**** Weakly-typed: Weakly-typed clients use the ActorProxy class. It is recommended to use these only when required for interop or other advanced reasons.        ****
**********************************************************************************************************************************************************************/
var weaklyActorId = new ActorId(ActorId.CreateRandom().ToString());
var weaklyProxy = ActorProxy.Create(weaklyActorId, actorType);
await weaklyProxy.InvokeMethodAsync(
    "SetDataAsync",
    new HeroData() { Name = "Spiderman", AlterEgo = "Peter Parker" }
);
Console.WriteLine($"Got response using weakly client: {response}");
var weaklyData = await weaklyProxy.InvokeMethodAsync<HeroData>("GetDataAsync");
Console.WriteLine($"Got response calling GetDataAsync weakly: {weaklyData}");
await weaklyProxy.InvokeMethodAsync("RegisterTimer");
Console.WriteLine("Press enter to exit...");
Console.ReadLine();
Console.WriteLine("Remove reminders and timers");
await proxy.UnregisterReminder();
await weaklyProxy.InvokeMethodAsync("UnregisterTimer");

En este cliente he querido juntar las dos posibilidades que tenemos a la hora de invocar a los actores, usando SDK, ya que todo se puede hacer a través de la API REST de Dapr. En este caso tenemos un primer bloque donde el proxy se crea utilizando la interfaz IHeroActor, lo cual nos permite que las llamadas a los métodos sean fuertemente tipadas y así evitar errores tipo «magic strings» por ejemplo. En el segundo apartado lo que tenemos es un cliente débilmente tipado. Este suele utilizarse cuando tenemos un entorno multilenguage donde no tenemos necesariamente una interfaz, en este caso en .NET, a la que podamos referirnos cuando creamos el proxy.

Para poder ejecutar todo eso necesitamos primero ejecutar en un terminal el proyecto HeroActorService:

cd HeroActorService
dapr run --app-id myapp --app-port 5001 --dapr-http-port 3500 -- dotnet run

y en otro el cliente:

cd HeroActorClient
dotnet run

La salida de ambos debería de ser como la siguiente:

HeroActorService y HeroServiceClient

El código del ejemplo lo tienes en mi GitHub

¡Saludos!