Saltar a contenido

Template Arquitectura Orleans Client

Quickstart

  1. En GitHub seleccionar la opción para crear un nuevo repository
  2. Seleccionar el template architecture-it/Template-Platform-Orleans
  3. Seleccionar un nombre y crear al repositorio
  4. Configurar politicas de seguridad de branch
  5. Asignar usuarios/teams al repositorio
  6. Cambiar nombre de los proyectos de OrleansClient y *.Grains template repo)

Link de repositorio template

Arquitectura de Software

El template contiene la siguiente arquitectura.

Solution

Los proyectos OrleansClient, Application, Domain, Infrastructure corresponde a la arquitectura que posee actualmente platform con el estandar de Clean Architecture

OrleansServer: Este proyecto se utiliza para configurar y ejecutar un servidor de Orleans durante la fase de desarrollo. No se utiliza en entornos de producción o pruebas. Cualquier modificación que hagamos aquí, como añadir inyecciones de dependencias o cambiar configuraciones, no se reflejará en los servidores de Orleans en entornos de producción o pruebas. Por lo tanto, es crucial llevar un registro de estos cambios y aplicarlos manualmente en los servidores correspondientes si es necesario.

Para realizar la configuración utilizamos la librería Andreani.Arq.Orleans.Server

var builder = WebApplication.CreateBuilder(args);

builder.Host.ConfigureAndreaniWebHost(args)
    .AddServerOrleansLocal();

builder.Services
    .ConfigureAndreaniWorkerServices()
    .AddObservability();

var app = builder.Build();

app.ConfigureAndreaniWorker()
    .ConfigureObservability();

app.UseDashboardOrleans();

app.Run();

OrleansClient.Grain.Interface y OrleansClient.Grain

Estos proyectos se adhieren a las mejores prácticas recomendadas por Microsoft. En el proyecto OrleansClient.Grain.Interface, encontrarás las abstracciones o interfaces que son implementadas exclusivamente por los Clientes. Por otro lado, el proyecto OrleansClient.Grain contiene la implementación real de estos granos, es decir, la lógica de negocio que los granos ejecutan en el servidor.

Nota

Es crucial cambiar los nombres de los proyectos para que reflejen de manera clara y única la identidad del proyecto en el que se están utilizando.

Aplication.Test y OrleansClient.Grain.Test Estos proyectos contienen test de los ejemplos del template y estan preparados para realizar Unit test sobre los Grain de Orleans. Ver más información de Unit Test en Orleans

Ejemplos Funcionales

Workflow de aplicación Workflow de aplicación

Person CRUD

Create Person

PersonController.cs
    [HttpPost]
    [ProducesResponseType(typeof(CreatePersonResponse), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(List<Notify>), StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(CreatePersonCommand body) => 
    Result(await Mediator.Send(body));
CreatePersonCommandHandler.cs
    private readonly IClusterClient _client;
    private readonly ILogger<CreatePersonCommandHandler> _logger;

    public CreatePersonCommandHandler(IClusterClient client, ILogger<CreatePersonCommandHandler> logger)
    {
        _client = client;
        _logger = logger;
    }

    public async Task<Response<CreatePersonResponse>> Handle(CreatePersonCommand request, CancellationToken cancellationToken)
    {
        var info = new PersonInfo()
        {
            Nombre = request.Nombre,
            Apellido = request.Apellido
        };
        var person = await _client.GetGrain<IPersonGrains>(Guid.NewGuid())
                            .CreateOrUpdate(info);

        _logger.LogDebug("the person was add correctly");

        return new Response<CreatePersonResponse>
        {
            Content = new CreatePersonResponse
            {
                Message = "Success",
                PersonId = person.Id,
            },
            StatusCode = System.Net.HttpStatusCode.Created
        };
    }
Creamos un Id para la el nuevo recurso y guardamos la información en el estado del Grain
PersonGrains.cs
    public class PersonGrains : IGrainBase, IPersonGrains
    {
        // .... OmitCode
        public async Task<PersonDto> CreateOrUpdate(PersonInfo info)
        {
            _state.State = new PersonStateGrain()
            {
                Apellido = info.Apellido,
                Nombre = info.Nombre,
            };

            await _state.WriteStateAsync();

            await _grainFactory.GetGrain<IBigDirectoryGuidGrain>(nameof(IPersonGrains)).AddAsync(_id);

            return MapState();
        }
    }

  1. Mapeamos la información nueva del estado y lo persistimos en la base de datos.
  2. Agregamos el grain al directorio de IPersonGrains Para ver como funcionan los directorios ver

Update Person

Se omite el controller

UpdatePersonHandler.cs
public async Task<Response<PersonDto>> Handle(UpdatePersonCommand request, CancellationToken cancellationToken)
{
    var response = new Response<PersonDto>();

    Guid _id = Guid.Parse(request.PersonId);
    var exist = await _client.GetGrain<IBigDirectoryGuidGrain>(nameof(IPersonGrains))
                .Exist(_id);

    if (!exist)
    {
        response.AddNotification("#3123", nameof(request.PersonId), string.Format(ErrorMessage.NOT_FOUND_RECORD, "Person", request.PersonId));
        response.StatusCode = System.Net.HttpStatusCode.NotFound;
        return response;
    }
    var info = new PersonInfo()
    {
        Nombre = request.Nombre,
        Apellido = request.Apellido
    };
    var result = await _client.GetGrain<IPersonGrains>(_id)
                .CreateOrUpdate(info);

    response.Content = result;
    return response;
}

  • Validamos que exista el Id ingresado. Recordemos que orleans al realizar una llamada de GetGrain con un Id, en caso de que no exista lo crea automaticamente, en este caso no queremos crear uno nuevo, solo actualizar uno existente. Para eso utilizamos el directorio de grain para IPersonGrains.
  • Hidratamos el grain y actualizamos el estado

Get Persons

ListPersonHandler.cs
public async Task<Response<List<PersonDto>>> Handle(ListPerson request, CancellationToken cancellationToken)
{
    var directory = _client.GetGrain<IBigDirectoryGuidGrain>(nameof(IPersonGrains));
    var grains = await directory.GetAll<IPersonGrains, PersonDto>(Guid.Empty.ToString());

    return new Response<List<PersonDto>>
    {

        Content = grains.Content,
        StatusCode = System.Net.HttpStatusCode.OK
    };
}
Para obtener la información de todos los grains almacenados utilizamos el directorio de los IPersonGrains

User CRUD

Register

UserController.cs
[HttpPost("register")]
[ProducesResponseType(typeof(UserRegisterDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(List<Notify>), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Register(UserRegisterDto body) => 
            Result(await Mediator.Send(new UserRegisterRequest(body)));
UserRegisterHandler.cs
public async Task<Response<UserRegisterDto>> Handle(UserRegisterRequest request, CancellationToken cancellationToken)
{
    var result = new Response<UserRegisterDto>();
    try
    {
        if (await _client.GetGrain<IBigDirectoryStringGrain>(nameof(IUserGrains))
            .Exist(request.userInfo.Email))
        {
            result.AddNotification("#3123", nameof(request.userInfo), "the email has already been used");
            result.StatusCode = System.Net.HttpStatusCode.Conflict;
            return result;
        }
        await _client.GetGrain<IUserGrains>(request.userInfo.Email)
            .Register(request.userInfo.Password, new(request.userInfo.Name, request.userInfo.Lastname));

        result.Content = request.userInfo;
        result.StatusCode = System.Net.HttpStatusCode.Created;
    }
    catch (InvalidLoginException ex)
    {
        result.AddNotification(new Notify
        {
            Code = "1",
            Message = ex.Message,
            Property = nameof(request.userInfo)
        });
    }
    return result;
}

  1. Validamos que el email ya no haya sido utilizado.
  2. Cremos el Grain con el email del usuario.
  3. Almacenamos la información del usuario.
UserGrain.cs
public async Task Register(string Password, PersonInfo info)
{
    if (!string.IsNullOrEmpty(_userState.State.Password))
        throw new InvalidLoginException("El usuario ya existe");

    var person = await _grainFactory.GetGrain<IPersonGrains>(Guid.NewGuid())
        .CreateOrUpdate(info);

    await _grainFactory.GetGrain<IBigDirectoryStringGrain>(nameof(IUserGrains))
        .AddAsync(_username);

    _userState.State = new UserStateGrain
    {
        PersonId = person.Id,
        LastLogin = null,
        Password = Password,
    };

    await _userState.WriteStateAsync();
}
  1. Creamos un grain de IPersonGrains
  2. Registramos el Grain en el direcorio de IUserGrains
  3. Persistimos la información

Get Users

UserController.cs
[HttpGet]
[ProducesResponseType(typeof(List<UserDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(List<Notify>), StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetAll(string? offset = null, int limit = 10) => 
    Result(await Mediator.Send(new GetListUserRequest(offset, limit)));
El controller posee una estrategia de paginado

UserController.cs
public async Task<Response<UsersResponse>> Handle(GetListUserRequest request, CancellationToken cancellationToken)
{
    var grains = await _client.GetGrain<IBigDirectoryStringGrain>(nameof(IUserGrains))
                                .GetAll<IUserGrains, UserDto>(request.Offset, limit: request.Limit);

    return new Response<UsersResponse>
    {
        Content = new UsersResponse
        {
            Data = grains.Content,
            limit = grains.Limit,
            Next = grains.Next == null || Guid.Parse(grains.Next) == Guid.Empty ? null : grains.Next,
        },
        StatusCode = System.Net.HttpStatusCode.OK
    };
}
Utilizamos la estrategía de páginado que tiene incorporado los directorios.

Manejo de Timers y Reminders

Timer

Los temporizadores se utilizan en Orleans para crear comportamientos periódicos que no requieren múltiples activaciones (instancias de grano). Un temporizador en Orleans es similar a la clase estándar .NET System.Threading.Timer.

A continuación, se describen los pasos para implementar un temporizador en Platform-Orleans-Template, junto con ejemplos de código:

  1. Crear la interfaz ITimerGraincon los métodos Task Start(), Task Reset(), y Task Stop(). Aquí tienes un ejemplo:
ITimerGrain.cs
public interface ITimerGrain : IGrainWithIntegerKey
{
    Task Start();
    Task Reset();
    Task Stop();
}
  1. Crear la clase que implementa la interfaz ITimerGrain. Aquí tienes un ejemplo de implementación:
TimerGrain.cs
   public class TimerGrain : ITimerGrain, IGrainBase
   {
       private readonly ITimerRegistry _timerRegistry;
       private IDisposable? _timer;
       private int _tick;
       private readonly ILogger<TimerGrain> _logger;
       public IGrainContext GrainContext { get; }

       public TimerGrain(ITimerRegistry timerRegistry,ILogger<TimerGrain> logger, IGrainContext context)
       {
           _timerRegistry = timerRegistry;
           _tick = 0;
           _logger = logger;
           GrainContext = context;
       }

       public Task Start()
       {
           _timer = _timerRegistry.RegisterTimer(GrainContext, x =>
           {
               _tick++;
               _logger.LogInformation("Hello timmer {estado}", _tick);

               return Task.CompletedTask;
           }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2));
           return Task.CompletedTask;
       }

       public Task Stop()
       {
           if( _tick!=0 ) 
           { 
               _timer?.Dispose();
               _tick=0;
           }
           return Task.CompletedTask;
       }

       public async Task Reset()
       {
           await Stop();
           await Start();
       }
   }
  1. Utilizar TimerGrain desde controller o handler, Aquí tienes un ejemplo de cómo utilizarlo desde un controlador:
TimerController.cs
    [ApiController]
    [ApiVersion("1.0")]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class TimerController : ApiControllerBase
    {
        private readonly IClusterClient _client;

        public TimerController(IClusterClient client)
        {
            _client = client;
        }

        [HttpGet("start")]
        public IEnumerable<string> Get()
        {
            var timer = _client.GetGrain<ITimerGrain>(0);
            timer.Start();
            return new string[] { "value1", "value2" };
        }

        [HttpGet("stop")]
        public IEnumerable<string> Stop()
        {
            var timer = _client.GetGrain<ITimerGrain>(0);
            timer.Stop();
            return new string[] { "value1", "value2" };
        }

        [HttpGet("reset")]
        public IEnumerable<string> Reset()
        {
            var timer = _client.GetGrain<ITimerGrain>(0);
            timer.Reset();
            return new string[] { "value1", "value2" };
        }
    }

Reminder

Los recordatorios en Orleans son similares a los temporizadores, pero con algunas diferencias clave:

  • Los recordatorios son persistentes y continúan activándose en casi todas las situaciones, a menos que se cancelen explícitamente.

  • Los recordatorios están asociados a un grano en lugar de una activación específica.

  • Si un grano no tiene ninguna activación asociada cuando se activa un recordatorio, se crea una activación para ese grano.

  • La entrega de un recordatorio se realiza mediante mensajes y sigue la misma semántica de intercalación que otros métodos de grano.

  • Los recordatorios no deben utilizarse para intervalos de tiempo muy cortos; su período debería medirse en minutos, horas o días.

A continuación mostramos los pasos a seguir para implementar un recordatorio en Platform-Orleans-Template y ejemplos en código:

  1. Crear la interfaz IReminderGraincon los métodos Task Start() y Task Stop(). Aquí tienes un ejemplo:
IReminderGrain.cs
public interface IReminderGrain : IGrainWithIntegerKey
{
    Task Start();
    Task Stop();
}
  1. Crear la clase que implementa la interfaz IReminderGrainy también implementa la interfaz IRemindable. Aquí tienes un ejemplo de implementación:
ReminderGrain.cs
public class ReminderGrain : IReminderGrain, IGrainBase, IRemindable
{
    private IGrainReminder _reminder=null!;
    private readonly ILogger<ReminderGrain> _logger;
    public IGrainContext GrainContext { get; }

    public ReminderGrain( ILogger<ReminderGrain> logger, IGrainContext context)
    {
        _logger = logger;
        GrainContext = context;
    }

    public async Task Start()
    {
        _reminder ??=await this.RegisterOrUpdateReminder(
                "ReminderTest",
                TimeSpan.Zero,
                TimeSpan.FromMinutes(1)); 
    }

    public async Task Stop()
    {
        if(_reminder != null) 
        {
            await this.UnregisterReminder(_reminder);
        }
        else
        {
            _reminder = await this.GetReminder("ReminderTest");

            await this.UnregisterReminder(_reminder);
        }
    }

    public Task ReceiveReminder(string reminderName, TickStatus status)
    {
        if (reminderName.Equals("ReminderTest"))
        {
            _logger.LogInformation("Reminder test is ok");
        }
        return Task.CompletedTask;
    }
}
  1. Utilizar ReminderGrain desde controller o handler. Aquí tienes un ejemplo de cómo utilizarlo desde un controlador:
ReminderController.cs
    [ApiController]
    [ApiVersion("1.0")]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class ReminderController : ApiControllerBase
    {
        private readonly IClusterClient _client;

        public ReminderController(IClusterClient client)
        {
            _client = client;
        }

        [HttpGet("start")]
        public IEnumerable<string> Get()
        {
            var reminder = _client.GetGrain<IReminderGrain>(0);
            reminder.Start();
            return new string[] { "value1", "value2" };
        }

        [HttpGet("stop")]
        public IEnumerable<string> Stop()
        {
            var reminder = _client.GetGrain<IReminderGrain>(0);
            reminder.Stop();
            return new string[] { "value1", "value2" };
        }
    }
  1. Agregar la configuración en el archivo appsettings. Aquí tienes un ejemplo de cómo hacerlo en formato YAML o JSON:
Orleans:
    ReminerServices:
        Invariant: System.Data.SqlClient
        ConnectionString: Server=localhost;Database=Orleans;User Id=sa;Password=Password1234;
{
    "Orleans": {
        "ReminerServices": {
            "Invariant": "System.Data.SqlClient",
            "ConnectionString": "Server=localhost;Database=Orleans;User Id=sa;Password=Password1234;"
        }
    }
}

Notas

Si estás utilizando el template Platform-Orleans-Template, no es necesario agregar la configuración en appsettings ya que viene configurada por defecto y se puede cambiar si necesitas utilizar una conexión diferente a la configuración predeterminada.

Para obtener más información sobre los Timers y Reminders puede dirigirse a la documentación oficial de Orleans.

Manejo de Streams

Orleans streaming es una característica poderosa del framework Orleans que permite a los desarrolladores escribir aplicaciones reactivas que operan sobre secuencias de eventos de forma estructurada. Orleans streaming proporciona un conjunto de abstracciones y APIs que simplifican y robustecen la manipulación de streams. Un flujo en Orleans es una entidad lógica que siempre existe y nunca puede fallar, identificado por su StreamId. Los flujos permiten desacoplar la generación de datos de su procesamiento, en el tiempo y el espacio. Estas secuencias funcionan uniformemente en todos los granos y clientes de Orleans y son compatibles con una amplia gama de tecnologías de colas, como Event Hubs, ServiceBus, Azure Queues y Apache Kafka. Además, la transmisión de Orleans admite enlaces de transmisión dinámicos, gestión transparente del ciclo de vida de consumo de transmisión y proveedores de transmisión extensibles.

Configuración

Para configurar el servicio de streaming en el template Platform-Orleans-Template, sigue estos pasos:

  1. Configuración del servicio de streaming en el archivo Program.cs del servidor:
Program.cs
    var builder = WebApplication.CreateBuilder(args);

    builder.Host.ConfigureAndreaniWebHost(args)
    .AddServerOrleansLocal(silo =>
    {
        silo.AddMemoryStreams("StreamProvider").AddMemoryGrainStorage("PubSubStore");
    });

    builder.Services.ConfigureAndreaniWorkerServices()
        .AddGrainsDependency(builder.Configuration)
        .AddObservability();

    var app = builder.Build();

    app.ConfigureAndreaniWorker().ConfigureObservability();

    app.UseDashboardOrleans();

    app.Run();
  1. Configuración del servicio de streaming en el archivo Program.cs del cliente:
Program.cs
    var builder = WebApplication.CreateBuilder(args);

    builder.Host.ConfigureAndreaniWebHost(args).
    ConfigureOrleansClientLocal(client=>
    {
        client.AddMemoryStreams("StreamProvider");
    });

    builder.Services.ConfigureAndreaniServices()
        .AddApplication()
        .AddInfrastructure(builder.Configuration)
        .AddCors(p => p.AddDefaultPolicy(x => x.AllowAnyOrigin().AllowAnyHeader()))
        .AddObservability();

    var app = builder.Build();

    app.UseCors();
    app.ConfigureAndreani().ConfigureObservability();

    await Task.Delay(8000);

    app.Run();

Notas

En los ejemplos anteriores se utiliza el proveedor de streaming MemoryStreamProvider, ya que utilizar un proveedor de streaming externo requiere de una configuración adicional.

Publicar un evento

Para publicar un evento en un stream, sigue estos pasos:

  1. Crear la interfaz IPublisherGraincon el método Task Publish(string message):
IPublisherGrain.cs
    public interface IPublisherGrain : IGrainWithStringKey
    {
        Task Publish(string message);
    }
  1. Crear la clase que implementa la interfaz IPublisherGrain:
PublisherGrain.cs
        public class PublisherGrain: IGrainBase, IPublisherGrain
    {

        public IGrainContext GrainContext { get; }
        private readonly IAsyncStream<string> _stream;

        public PublisherGrain(IGrainContext grainContext)
        {
            GrainContext = grainContext;
            // Creates a GUID for a chat room bean and a chat room stream
            var guid = Guid.NewGuid() ;
            // Gets one of the providers which we defined in our config
            var streamProvider = this.GetStreamProvider("StreamProvider");
            // Gets the reference to a stream
            var streamId = StreamId.Create("RANDOMDATA", guid);
            _stream = streamProvider.GetStream<string>(streamId);
        }

        public async Task Publish(string message)
        {
            await _stream.OnNextAsync(message);
        }
    }
  1. Utiliza PublisherGrain desde controller o handler. Aquí tienes un ejemplo de cómo utilizarlo desde un controlador:
StreamController.cs
    [ApiController]
    [ApiVersion("1.0")]
    [Route("api/v{version:apiVersion}/[controller]")]
    public class StreamController : ApiControllerBase
    {
        private readonly IClusterClient _client;

        public StreamController(IClusterClient client)
        {
            _client = client;
        }

        [HttpGet("publish")]
        public async Task<IEnumerable<string>> Get()
        {
            var publisher = _client.GetGrain<IPublisherGrain>("0");
            await publisher.Publish("Hello World!!!!!");
            return new string[] { "value1", "value2" };
        }
    }

Suscribirse a un evento

Para suscribirse a un evento en un stream, debes implementar IGrainBase y agregar el atributo ImplicitStreamSubscription al grano, especificando el topico al que deseas suscribirte. Aquí tienes un ejemplo:

SubscriberGrain.cs
    [ImplicitStreamSubscription("RANDOMDATA")]
    public class ConsumerGrain : IGrainBase, IGrainWithStringKey
    {

        public IGrainContext GrainContext { get; }
        private readonly IAsyncStream<string> _stream;
        private readonly ILogger<ConsumerGrain> _logger;

        public ConsumerGrain(IGrainContext grainContext, ILogger<ConsumerGrain> logger)
        {
            GrainContext = grainContext;
            _logger = logger;
            // Creates a GUID based on our GUID as a grain
            var guid = this.GetPrimaryKey();

            // Gets one of the providers which we defined in config
            var streamProvider = this.GetStreamProvider("StreamProvider");

            // Gets the reference to a stream
            var streamId = StreamId.Create("RANDOMDATA", guid);
            _stream = streamProvider.GetStream<string>(streamId);
        }

        public async Task OnActivateAsync(CancellationToken _)
        {
            await _stream.SubscribeAsync(OnNextAsync);
        }

        public Task OnNextAsync(string item, StreamSequenceToken? token = null)
        {
            _logger.LogInformation($"Received: {item}");
            return Task.CompletedTask;
        }
    }

Para mas información sobre orleans streaming puede dirigirse a la documentación oficial de Orleans.

Integración Continua

El template cuenta con la configuración por defecto para el proceso de CI estandar de la compañía, es necesario realizar las configuración de politicas de seguridad en las branch.

  1. No permitir push a las protegidas. Ej: main y cicd.
  2. Analísis de SonarQube bloqueante en Pull Request.

Despliegue Continuo

Nuget Grains

Para publicar los Grains como paquetes NuGet en GitHub Packages, el template incluye un pipeline que se activa automáticamente al detectar cambios en la carpeta Grain. Sin embargo, es esencial gestionar de forma consciente la versión del paquete que se desea publicar. La configuración para estos paquetes se halla en el archivo packages.props, que está situado en el directorio raíz (root).

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <Import Project=".\Build\Common.props" />
    <PropertyGroup>
        <Authors>Andreani Architecture it</Authors>
        <Company>Andreani</Company>
        <Version>1.0.0</Version>
        <IsPackable>true</IsPackable>
        <!--<PackageProjectUrl>{repository url}</PackageProjectUrl>
        <RepositoryUrl>{repository url}</RepositoryUrl>-->
    </PropertyGroup>
</Project>
La etiqueta Authors debe ser reeplazada por el equipo dueño de esta aplicación La etiqueta Version determina la versión que se asignará al paquete. Es crucial llenar los campos PackageProjectUrl y RepositoryUrl con la URL del repositorio de tu aplicación.

Dockerfile

El cliente se va a desplegar con estrategía Dockerfile, este template posee una imagenes exclusivas para NET 7 y Orleans

FROM ghcr.io/architecture-it/net-sdk:7.0 AS publish
WORKDIR /app
COPY . .
WORKDIR "/app/src/Api"
RUN dotnet publish "{appname}.csproj" -c Release -o /app/publish

FROM ghcr.io/architecture-it/net:7.0
COPY --from=publish /app/publish .

ENTRYPOINT ["dotnet", "{appname}.dll"]

Warning

Reemplazar appname con el nombre final del proyecto que reemplaza a OrleansClient