23 de março de 2019

Criando uma aplicação distribuída com ASP.NET Core e o Microsoft Orleans

Este é mais um artigo de uma série sobre o Microsoft Orleans. Caso não tenha visto o primeiro, recomendo que o leia: Conhecendo o Microsoft Orleans.

Como dito no artigo passado, o Orleans está na segunda versão e uma das novidades dela é o suporte ao .NET Standard 2.0, desta forma, como o título indica, a aplicação demonstrada aqui utilizará este framework.

Estrutura da solução

Antes de colocarmos a mão na massa, é importante entender como funciona a solução do Orleans. Esta biblioteca recomenda que um projeto que a implemente seja estruturado com no mínimo quatro projetos:

  • OrleansHost: Este projeto irá criar um executável que iniciará os silos, os hosts do Orleans que conterá as instâncias dos grãos. É possível iniciar vários silos, que irão trabalhar em conjunto e compartilhar a carga.

  • GrainsInterfaces: Este projeto contém as interfaces que definem todos os grãos contido nos silos.

  • Grains: Este projeto contém a implementação de todos os grãos definidos em GrainsInterfaces. Por isso, ele é implementado em conjunto com o OrleansHost.

  • Inteface: Este projeto pode ser qualquer projeto de interface, mas geralmente trata-se de API. Ele irá se conectar ao OrleansHost, por TCP, para ter acesso aos grãos.

Durante o desenvolvimento, o projeto interface e o OrleansHost podem ser executados em conjunto na mesma máquina. Mas em produção, geralmente eles são executados separadamente. O OrleansHost é implementado em um cluster e a interface em um servidor web.

Neste artigo, a solução será feita em uma máquina Mac OSX, utilizando a versão 2.1.4, em conjunto com o Visual Studio Code, pois amo este ambiente. Mas o Orleans fornece um plugin de templates para o Visual Studio, que facilita a criação de projetos com esta biblioteca. Desta forma, caso esteja utilizando esta IDE recomendo que instale este plugin.

Criando o projeto GrainsInterfaces

Como o projeto Grains necessita do GrainsInterfaces e o OrleansHost fará uso do Grains, o primeiro projeto que precisa ser criado é o GrainsInterfaces.

Caso esteja utilizando o Visual Studio com o plugin do Orleans instalado, crie um projeto com base no template “Orleans Grain Interface Collection. No meu ambiente, incialmente irei criar uma pasta chamada OrleansDotNet, e dentro dela uma solução:

dotnet new sln -n OrleansDotNet

Em seguida, um projeto Class Library:

dotnet new classlib -n GraosInterfaces

Neste projeto adicione a biblioteca Microsoft.Orleans.OrleansCodeGenerator.Build:

dotnet add package Microsoft.Orleans.OrleansCodeGenerator.Build

E adicione nele a interface abaixo:

using System;
using Orleans;
using System.Threading.Tasks;

namespace OrleansDotNet.GraosInterfaces
{
    public interface IGraoContador: IGrainWithStringKey
    {
        Task Incremento(int incremento);
        Task<int> GetContador();
    }
}

As interfaces grãos devem definir a implementação de uma das interfaces “GrainWith*” do Orleans e definir métodos que retornem uma Task, para métodos void, ou Task, caso o método retorne algum valor.

A interface implementada acima, IGrainWithStringKey, é utilizada para indicar que o código de identificação do grão será definido com uma string. Infelizmente a documentação do Orleans ainda não documenta bem todas as interfaces disponíveis, mas em um artigo futuro abordo todas.

Criando o projeto Grains

Com a interface definida podemos implementá-la um um projeto Grains. Caso esteja utilizando o Visual Studio, crie um projeto com o template Orleans Grain Class Collection. No meu ambiente, irei criar um projeto Class Library chamado Graos:

dotnet new classlib -n Graos

E este projeto também precisa da referência abaixo:

dotnet add package Microsoft.Orleans.OrleansCodeGenerator.Build

Em seguida, referencie o projeto de interface:

dotnet add Graos.csproj reference ../GraosInterfaces/GraosInterfaces.csproj

Agora podemos implementar o nosso grão:

using System;
using Orleans;
using System.Threading.Tasks;
using OrleansDotNet.GraosInterfaces;

namespace OrleansDotNet.Graos
{
    public class GraoContador: Grain, IGraoContador
    {
        private int _contador;

        public Task Incremento(int incremento)
        {
            _contador += incremento;
            return Task.CompletedTask;
        }

        public Task<int> GetContador()
        {
            return Task.FromResult(_contador);
        }
    }
}

Esta classe não tem segredo, o importante é implementar a nossa interface grão e herdar a classe Grain. Dentro dela é definido um contador simples.

Criando o projeto OrleansHost

Com o grão definido, podemos definir o nosso silo (host). Caso esteja utilizando o Visual Studio, crie um projeto com base no template “Orleans Dev/Test Host“.

No meu ambiente irei criar uma aplicação console chamada Silo:

dotnet new console -n Silo

Nele adicione adicione uma referência para a biblioteca Microsoft.Orleans.Server:

dotnet add package Microsoft.Orleans.Server

Como iremos registrar o log do silo do console, adicione a referencia abaixo:

dotnet add package Microsoft.Extensions.Logging.Console

Por fim, adicione a referencia do projeto Graos:

dotnet add Silo.csproj reference ../Graos/Graos.csproj

E na classe Program adicione o código abaixo:

using System;
using Orleans;
using Orleans.Runtime.Configuration;
using Orleans.Hosting;
using Orleans.Configuration;
using System.Threading.Tasks;
using OrleansDotNet.Graos;
using Microsoft.Extensions.Logging;
using System.Runtime.Loader;
using System.Threading;
using System.Net;

namespace OrleansDotNet.Silo
{
    class Program
    {
        private static ISiloHost silo;
        private static readonly ManualResetEvent siloStopped = new ManualResetEvent(false);

        static void Main(string[] args)
        {

            silo = new SiloHostBuilder()
                .UseLocalhostClustering()
                .Configure<ClusterOptions>(options =>
                {
                    options.ClusterId = "OrleansDotNet-cluster";
                    options.ServiceId = "OrleansDotNet";
                })
                .Configure<EndpointOptions>(options => options.AdvertisedIPAddress = IPAddress.Loopback)
                .ConfigureApplicationParts(parts => parts.AddApplicationPart(typeof(GraoContador).Assembly).WithReferences())
                .ConfigureLogging(logging => logging.AddConsole())
                .Build();

            Task.Run(StartSilo);

            AssemblyLoadContext.Default.Unloading += context =>
            {
                Task.Run(StopSilo);
                siloStopped.WaitOne();
            };

            siloStopped.WaitOne();

        }

        private static async Task StartSilo()
        {
            await silo.StartAsync();
            Console.WriteLine("Silo iniciado");
        }

        private static async Task StopSilo()
        {
            await silo.StopAsync();
            Console.WriteLine("Silo parado");
            siloStopped.Set();
        }
    }
}

Nesta classe, estamos definindo que o Silo irá utilizar as configurações de um cluster local (UseLocalhostClustering()), também é definido as identificações do cluster, bem com o seu IP (Configure()). Por fim, se define o grão que será adicionado ao silo e onde o seu log deve ser exibido.

Com o silo definido, ele é iniciado. Ele só irá parar em caso de algum erro ou quando o usuário forçar a sua parada.

Criando o projeto Interface

Com o Silo definido podemos criar a nossa aplicação interface. Neste projeto vou criar uma aplicação WebAPI chamada InterfaceAPI:

dotnet new webapi -n InterfaceApi

Aproveite e já vincule os projetos a solução:

dotnet sln OrleansDotNet.sln add **/*.csproj

Na api é necessário adicionar a referência Microsoft.Orleans.Client:

dotnet add package Microsoft.Orleans.Client

E referenciar o projeto GraosInterfaces:

dotnet add InterfaceApi.csproj reference ../GraosInterfaces/GraosInterfaces.csproj

Para trabalhar com “dependency injection“, vamos configurar o cliente do Orleans na classe Startup conforme o código abaixo:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Orleans;
using Orleans.Runtime.Configuration;
using Orleans.Hosting;
using System.Net;
using Orleans.Configuration;
using OrleansDotNet.GraosInterfaces;

namespace InterfaceApi
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();

            services.AddSingleton<IClusterClient>(provider =>
            {
                var client = new ClientBuilder()
                            .UseLocalhostClustering()
                            .Configure<ClusterOptions>(options =>
                            {
                                options.ClusterId = "OrleansDotNet-cluster";
                                options.ServiceId = "OrleansDotNet";
                            })
                            .ConfigureApplicationParts(parts => parts.AddApplicationPart(typeof(IGraoContador).Assembly).WithReferences())
                            .ConfigureLogging(logging => logging.AddConsole())
                            .Build();

                StartClientWithRetries(client).Wait();

                return client;
            });
        }

        private static async Task StartClientWithRetries(IClusterClient client)
        {
            for (var i=0; i<5; i++)
            {
                try
                {
                    await client.Connect();
                    return;
                }
                catch(Exception)
                { }
                await Task.Delay(TimeSpan.FromSeconds(5));
            }
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseMvc();
            app.UseStaticFiles();
        }
    }
}

Note que a configuração do cliente é parecida com o do host:

var client = new ClientBuilder()
            .UseLocalhostClustering()
            .Configure<ClusterOptions>(options =>
            {
                options.ClusterId = "OrleansDotNet-cluster";
                options.ServiceId = "OrleansDotNet";
            })
            .ConfigureApplicationParts(parts => parts.AddApplicationPart(typeof(IGraoContador).Assembly).WithReferences())
            .ConfigureLogging(logging => logging.AddConsole())
            .Build();

As diferenças é que agora estamos utilizando a classe ClientBuilder e se adiciona a interface IGraoContador (em detrimento a GraoContador definida no host.

Nesta classe também foi definido o método StartClientWithRetries que tenta se conectar ao host 5 vezes antes de desistir. Quando a conexão for obtida, o objeto client é retornado.

Este cliente será obtido no controller, conforme o código abaixo:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Orleans;
using OrleansDotNet.GraosInterfaces;

namespace InterfaceApi.Controllers
{
    [Route("api/[controller]")]
    public class ContadorController : Controller
    {
        private IClusterClient client;

        public ContadorController(IClusterClient client){
            this.client = client;
        }

        [HttpGet]
        public async Task<int> Get()
        {
            var contador = client.GetGrain<IGraoContador>("TW-1");

            return await contador.GetContador();
        }


        [HttpPost]
        public async Task Post()
        {
            var contador = client.GetGrain<IGraoContador>("TW-1");
            await contador.Incremento(1);
        }
    }
}

Para obter o grão, utilizamos o método GetGrain. A este método deve ser passado a chave do grão:

var contador = client.GetGrain<IGraoContador>("TW-1");

Como definimos que a chave dele será uma string, acima estou passando uma string arbitrária. A partir da instância obtida os métodos do grão são chamados, como o GetContador():

return await contador.GetContador();

Com isso, nós estamos obtendo informações do nosso grão contido no silo.

Para finalizar, criei dentro da pasta wwwroot um arquivo HTML, onde os métodos da api são chamados:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">


    <title>Exemplo de Orleans, no ASP.NET Core *.*</title>
</head>
<body>
    <div class="container">
        <div class="card">
            <div class="card-body">
                Contador: <spam id="countValor">0</spam>

                <button id="btnIncrement" class="btn-primary">Incrementar</button>
            </div>
        </div>


    </div>


    <!-- JavaScript -->
    <script src="https://code.jquery.com/jquery-3.1.0.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>

    <script type="text/javascript">
        $(document).ready(function () {
            $.get("api/contador/", function (value) {
                console.log('GET contador=' + value);

                $('#countValor').html(value);
            });

            $('#btnIncrement').click(function () {
                $.post("api/contador/", function () {
                    $.get("api/contador/", function (value) {
                        console.log('GET contador=' + value);

                        $('#countValor').html(value);
                    });
                });
                return false;
            });
        });
    </script>
</body>
</html>

Pronto, a nossa aplicação está finalizada. Para testá-la, primeiro é necessário iniciar o Silo:

Em seguida a aplicação web:

No navegador teremos o resultado abaixo:

Ao clicar em “Incrementar“, veremos o valor do contador ser incrementado:

Conclusão

Este é um exemplo simples, porém funcional, que nos dá uma noção do poder do Orleans. Em artigos futuros mostrarei mais detalhes dele.

Caso queria executar a aplicação demostrada aqui no curso, você pode vê-la aqui no GitHub.

Instrutor, nerd, cinéfilo e desenvolvedor nas horas vagas. Graduado em Ciências da Computação pela Universidade Metodista de São Paulo.