Migración de Orleans 3.x a 7.0

Orleans 7.0 presenta varios cambios beneficiosos, incluidas mejoras en el hospedaje, serialización personalizada, inmutabilidad y abstracciones de granos.

Migración

Las aplicaciones existentes que usan recordatorios, flujos o persistencia de granos no se pueden migrar fácilmente a Orleans 7.0 debido a cambios en la forma en que Orleans identifica los granos y flujos. Tenemos previsto ofrecer progresivamente una ruta de migración para estas aplicaciones.

Las aplicaciones que ejecutan versiones anteriores de Orleans no se pueden actualizar fácilmente a través de una actualización gradual a Orleans 7.0. Por lo tanto, se debe usar una estrategia de actualización diferente, como implementar un nuevo clúster y retirar el clúster anterior. Orleans 7.0 cambia el protocolo de conexión de manera incompatible, lo que significa que los clústeres no pueden contener una combinación de hosts de Orleans 7.0 y hosts que ejecutan versiones anteriores de Orleans.

Hemos evitado hacer estos cambios importantes durante muchos años, incluso en las versiones principales, así que ¿por qué lo hacemos ahora? Hay dos motivos principales: las identidades y la serialización. En lo que respecta a las identidades, las identidades de grano y flujo están compuestas ahora por cadenas, lo que permite que los granos codifiquen correctamente la información de tipo genérico y permitan que los flujos se asignen más fácilmente al dominio de aplicación. Anteriormente, los tipos de grano se identificaban mediante una estructura de datos compleja que no podía representar granos genéricos, lo que daba lugar a casos de esquina. Los flujos se identificaban mediante un espacio de nombres string y una clave Guid, que para los desarrolladores era difícil de asignar en su dominio de aplicación, por muy eficiente que fuera. La serialización ahora es tolerante a versiones, lo que significa que puede modificar los tipos de maneras compatibles, seguir un conjunto de reglas y estar seguro de que puede actualizar la aplicación sin errores de serialización. Esto era especialmente problemático cuando los tipos de aplicación se conservaban en flujos o almacenamiento de granos. En las secciones siguientes se detallan los cambios principales y se describen con más detalle.

Cambios de empaquetado

Si va a actualizar un proyecto a Orleans 7.0, deberá realizar las acciones siguientes:

  • Todos los clientes deben hacer referencia a Microsoft.Orleans.Client.
  • Todos los silos (servidores) deben hacer referencia a Microsoft.Orleans.Server.
  • Todos los demás paquetes deben hacer referencia a Microsoft.Orleans.Sdk.
  • Quite todas las referencias a Microsoft.Orleans.CodeGenerator.MSBuild y Microsoft.Orleans.OrleansCodeGenerator.Build.
    • Reemplace los usos de KnownAssembly por GenerateCodeForDeclaringAssemblyAttribute.
    • El paquete Microsoft.Orleans.Sdk hace referencia al paquete generador de código fuente de C# (Microsoft.Orleans.CodeGenerator).
  • Quite todas las referencias a Microsoft.Orleans.OrleansRuntime.
  • Quite las llamadas a ConfigureApplicationParts. Se han quitado los elementos de la aplicación. El generador de código fuente de C# para Orleans se agrega a todos los paquetes (incluido el cliente y el servidor) y generará automáticamente el equivalente de elementos de aplicación.
  • Reemplace las referencias a Microsoft.Orleans.OrleansServiceBus por Microsoft.Orleans.Streaming.EventHubs
  • Si usa recordatorios, agregue una referencia a Microsoft.Orleans.Reminders
  • Si usa flujos, agregue una referencia a Microsoft.Orleans.Streaming

Sugerencia

Todos los ejemplos de Orleans se han actualizado a Orleans 7.0 y se pueden usar como referencia para los cambios realizados. Para más información, consulte el problema de Orleans n.° 8035 que detalla los cambios realizados en cada ejemplo.

Directivas global using de Orleans

Todos los proyectos de Orleans hacen referencia directa o indirecta al paquete NuGet Microsoft.Orleans.Sdk. Cuando un proyecto de Orleans se ha configurado para habilitar el uso implícito (por ejemplo, <ImplicitUsings>enable</ImplicitUsings>), los espacios de nombres Orleans y Orleans.Hosting se usan implícitamente. Esto significa que el código de la aplicación no necesita estas directivas.

Para más información, consulte ImplicitUsings y dotnet/orleans/src/Orleans.Sdk/build/Microsoft.Orleans.Sdk.targets.

Hospedaje

El tipo ClientBuilder se ha reemplazado por un método de extensión UseOrleansClient en IHostBuilder. El tipo IHostBuilder procede del paquete NuGet Microsoft.Extensions.Hosting. Esto significa que puede agregar un cliente de Orleans a un host existente sin tener que crear un contenedor de inserción de dependencias independiente. El cliente se conecta al clúster durante el inicio. Una vez se ha completado IHost.StartAsync, el cliente se conectará automáticamente. Los servicios agregados a IHostBuilder se inician en el orden de registro, por lo que llamar a UseOrleansClient antes de llamar a ConfigureWebHostDefaults garantizará que Orleans se inicie antes de que se inicie ASP.NET Core, por ejemplo, lo que le permite acceder al cliente desde la aplicación de ASP.NET Core inmediatamente.

Si desea emular el comportamiento de ClientBuilder anterior, puede crear un elemento HostBuilder independiente y configurarlo con un cliente de Orleans. IHostBuilder puede tener configurado un cliente de Orleans o un silo de Orleans. Todos los silos registran una instancia de IGrainFactory y IClusterClient que la aplicación puede utilizar, por lo que configurar un cliente por separado es innecesario y no está soportado.

Cambio de firma OnActivateAsync y OnDeactivateAsync

Orleans permite que los granos ejecuten código durante la activación y desactivación. Esto se puede usar para realizar tareas como el estado de lectura de los mensajes de ciclo de vida de almacenamiento o registro. En Orleans 7.0, la firma de estos métodos de ciclo de vida cambió:

  • OnActivateAsync() ahora acepta un parámetro CancellationToken. Cuando se cancela CancellationToken, se debe abandonar el proceso de activación.
  • OnDeactivateAsync() ahora acepta un parámetro DeactivationReason y un parámetro CancellationToken. DeactivationReason indica por qué se desactiva la activación. Se espera que los desarrolladores usen esta información con fines de registro y diagnóstico. Cuando se cancela CancellationToken, el proceso de desactivación debe completarse rápidamente. Tenga en cuenta que, dado que cualquier host puede producir un error en cualquier momento, no se recomienda confiar en OnDeactivateAsync para realizar acciones importantes, como conservar el estado crítico.

Considere el ejemplo siguiente de un grano que reemplaza estos nuevos métodos:

public sealed class PingGrain : Grain, IPingGrain
{
    private readonly ILogger<PingGrain> _logger;

    public PingGrain(ILogger<PingGrain> logger) =>
        _logger = logger;

    public override Task OnActivateAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("OnActivateAsync()");
        return Task.CompletedTask;
    }

    public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken token)
    {
        _logger.LogInformation("OnDeactivateAsync({Reason})", reason);
        return Task.CompletedTask;
    }

    public ValueTask Ping() => ValueTask.CompletedTask;
}

Granos POCO y IGrainBase

Los granos de Orleans ya no necesitan heredar de la clase base de Grain ni de ninguna otra clase. Esta funcionalidad se conoce como granos POCO. Para acceder a métodos de extensión como cualquiera de los siguientes:

El grano debe implementar IGrainBase o heredar de Grain. Este es un ejemplo de implementación de IGrainBase en una clase de grano:

public sealed class PingGrain : IGrainBase, IPingGrain
{
    public PingGrain(IGrainContext context) => GrainContext = context;

    public IGrainContext GrainContext { get; }

    public ValueTask Ping() => ValueTask.CompletedTask;
}

IGrainBase también define OnActivateAsync y OnDeactivateAsync con implementaciones predeterminadas, lo que permite que el grano participe en su ciclo de vida si lo desea:

public sealed class PingGrain : IGrainBase, IPingGrain
{
    private readonly ILogger<PingGrain> _logger;

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

    public IGrainContext GrainContext { get; }

    public Task OnActivateAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("OnActivateAsync()");
        return Task.CompletedTask;
    }

    public Task OnDeactivateAsync(DeactivationReason reason, CancellationToken token)
    {
        _logger.LogInformation("OnDeactivateAsync({Reason})", reason);
        return Task.CompletedTask;
    }

    public ValueTask Ping() => ValueTask.CompletedTask;
}

Serialización

El cambio más complicado en Orleans 7.0 es la introducción del serializador tolerante a versiones. Este cambio se realizó porque las aplicaciones tienden a evolucionar y esto provocó un problema significativo para los desarrolladores, ya que el serializador anterior no toleraba la adición de propiedades a los tipos existentes. Por otro lado, el serializador era flexible, lo que permitía a los desarrolladores representar la mayoría de los tipos de .NET sin modificaciones, incluidas características como genéricos, polimorfismo y seguimiento de referencias. Hacía tiempo que era necesaria una sustitución, pero los usuarios siguen necesitando una representación de alta fidelidad de sus tipos. Por lo tanto, en Orleans 7.0 se introdujo un serializador de reemplazo que admite la representación de alta fidelidad de los tipos de .NET, a la vez que permite que estos evolucionen. El nuevo serializador es mucho más eficaz que el serializador anterior, lo que da como resultado un rendimiento de un extremo a otro hasta un 170 % superior.

Para más información, consulte estos artículos relacionados con Orleans 7.0:

Identidades de los granos

Cada grano tiene una identidad única que consta del tipo del grano y su clave. Las versiones anteriores de Orleans usaban un tipo compuesto para GrainId para admitir claves de grano de cualquiera de estos tipos:

Esto implica cierta complejidad a la hora de tratar las claves de grano. Las identidades de grano constan de dos componentes: un tipo y una clave. El componente de tipo constaba anteriormente de un código de tipo numérico, una categoría y 3 bytes de información de tipo genérico.

Ahora, las identidades de grano tienen la forma type/key, donde type y key son cadenas. La interfaz de clave de grano más usada es IGrainWithStringKey. Esto simplifica considerablemente el funcionamiento de la identidad de los granos y mejora la compatibilidad con tipos de grano genéricos.

Las interfaces de grano también se representan ahora mediante un nombre legible, en lugar de una combinación de un código hash y una representación de cadena de cualquier parámetro de tipo genérico.

El nuevo sistema es más personalizable y estas personalizaciones se pueden controlar mediante atributos.

  • En un grano class, GrainTypeAttribute(String) especifica la parte Tipo de su identificador de grano.
  • En un grano interface, DefaultGrainTypeAttribute(String) especifica el Tipo del grano que IGrainFactory debe resolver de forma predeterminada al obtener una referencia de grano. Por ejemplo, al llamar a IGrainFactory.GetGrain<IMyGrain>("my-key"), la factoría de grano devolverá una referencia al grano "my-type/my-key" si IMyGrain tiene especificado el atributo mencionado anteriormente.
  • GrainInterfaceTypeAttribute(String) permite invalidar el nombre de la interfaz. Especificar un nombre explícitamente mediante este mecanismo permite cambiar el nombre del tipo de interfaz sin interrumpir la compatibilidad con las referencias de grano existentes. Tenga en cuenta que la interfaz también debe tener AliasAttribute en este caso, ya que su identidad se puede serializar. Para obtener más información sobre cómo especificar un alias de tipo, consulte la sección sobre serialización.

Como se mencionó anteriormente, invalidar los nombres de interfaz y clase de grano predeterminados para los tipos permite cambiar el nombre de los tipos subyacentes sin interrumpir la compatibilidad con las implementaciones existentes.

Identidades de flujo

Cuando los flujos de Orleans se lanzaron por primera vez, los flujos solo se podían identificar mediante Guid. Esto era eficaz en términos de asignación de memoria, pero era difícil para los usuarios crear identidades de flujo significativas, requiriendo a menudo cierta codificación o direccionamiento indirecto para determinar la identidad de flujo adecuada para un propósito determinado.

En Orleans 7.0, las secuencias se identifican ahora mediante cadenas. El structOrleans.Runtime.StreamId contiene tres propiedades: StreamId.Namespace, StreamId.Key y StreamId.FullKey. Estos valores de propiedad son cadenas UTF-8 codificadas. Por ejemplo, StreamId.Create(String, String).

Reemplazo de SimpleMessageStreams con BroadcastChannel

SimpleMessageStreams (también llamado SMS) se quitó en la versión 7.0. SMS tenía la misma interfaz que Orleans.Providers.Streams.PersistentStreams, pero su comportamiento era muy diferente, ya que se basaba en llamadas directas de grano a grano. Para evitar confusiones, se quitó SMS y se introdujo un nuevo reemplazo llamado Orleans.BroadcastChannel.

BroadcastChannel solo admite suscripciones implícitas y puede ser un reemplazo directo en este caso. Si necesita suscripciones explícitas o necesita usar la interfaz PersistentStream (por ejemplo, estaba usando SMS en pruebas durante el uso de EventHub en producción), MemoryStream es el mejor candidato para usted.

BroadcastChannel tendrá los mismos comportamientos que SMS, mientras que MemoryStream se comportará como otros proveedores de flujo. Tenga en cuenta el siguiente ejemplo de uso del canal de difusión:

// Configuration
builder.AddBroadcastChannel(
    "my-provider",
    options => options.FireAndForgetDelivery = false);

// Publishing
var grainKey = Guid.NewGuid().ToString("N");
var channelId = ChannelId.Create("some-namespace", grainKey);
var stream = provider.GetChannelWriter<int>(channelId);

await stream.Publish(1);
await stream.Publish(2);
await stream.Publish(3);

// Simple implicit subscriber example
[ImplicitChannelSubscription]
public sealed class SimpleSubscriberGrain : Grain, ISubscriberGrain, IOnBroadcastChannelSubscribed
{
    // Called when a subscription is added to the grain
    public Task OnSubscribed(IBroadcastChannelSubscription streamSubscription)
    {
        streamSubscription.Attach<int>(
          item => OnPublished(streamSubscription.ChannelId, item),
          ex => OnError(streamSubscription.ChannelId, ex));

        return Task.CompletedTask;

        // Called when an item is published to the channel
        static Task OnPublished(ChannelId id, int item)
        {
            // Do something
            return Task.CompletedTask;
        }

        // Called when an error occurs
        static Task OnError(ChannelId id, Exception ex)
        {
            // Do something
            return Task.CompletedTask;
        }
    }
}

La migración a MemoryStream será más fácil, ya que solo es necesario cambiar la configuración. Tenga en cuenta la siguiente configuración de MemoryStream:

builder.AddMemoryStreams<DefaultMemoryMessageBodySerializer>(
    "in-mem-provider",
    _ =>
    {
        // Number of pulling agent to start.
        // DO NOT CHANGE this value once deployed, if you do rolling deployment
        _.ConfigurePartitioning(partitionCount: 8);
    });

OpenTelemetry

El sistema de telemetría se ha actualizado en Orleans 7.0 y el sistema anterior se ha quitado en favor de las API de .NET estandarizadas, como las métricas de .NET para las métricas y ActivitySource para el seguimiento.

Como parte de esto, se han quitado los paquetes Microsoft.Orleans.TelemetryConsumers.* existentes. Estamos pensando en introducir un nuevo conjunto de paquetes para simplificar el proceso de integración de las métricas emitidas por Orleans en la solución de supervisión que prefiera. Como siempre, cualquier comentario o contribución serán bienvenidos.

La herramienta dotnet-counters cuenta con supervisión del rendimiento diseñada para la supervisión ad hoc del estado y la investigación de rendimiento de primer nivel. En los contadores de Orleans, la herramienta dotnet-counters se puede usar para supervisarlos:

dotnet counters monitor -n MyApp --counters Microsoft.Orleans

De forma similar, las métricas de OpenTelemetry pueden agregar los medidores Microsoft.Orleans, como se muestra en el código siguiente:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddPrometheusExporter()
        .AddMeter("Microsoft.Orleans"));

Para habilitar el seguimiento distribuido, configure OpenTelemetry como se muestra en el código siguiente:

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing.SetResourceBuilder(ResourceBuilder.CreateDefault()
            .AddService(serviceName: "ExampleService", serviceVersion: "1.0"));

        tracing.AddAspNetCoreInstrumentation();
        tracing.AddSource("Microsoft.Orleans.Runtime");
        tracing.AddSource("Microsoft.Orleans.Application");

        tracing.AddZipkinExporter(options =>
        {
            options.Endpoint = new Uri("http://localhost:9411/api/v2/spans");
        });
    });

En el código anterior, OpenTelemetry está configurado para supervisar:

  • Microsoft.Orleans.Runtime
  • Microsoft.Orleans.Application

Para propagar la actividad, llame a AddActivityPropagation:

builder.Host.UseOrleans((_, clientBuilder) =>
{
    clientBuilder.AddActivityPropagation();
});

Refactorización de características del paquete principal en paquetes independientes

En Orleans 7.0, hemos hecho un esfuerzo para factorizar extensiones en paquetes independientes que no dependen de Orleans.Core. Es decir, Orleans.Streaming, Orleans.Reminders y Orleans.Transactions se han separado del núcleo. Esto significa que en estos paquetes se paga exclusivamente por lo que se usa y ningún código en el núcleo de Orleans está dedicado a estas características. Esto reduce el tamaño del ensamblado y la superficie de la API principal, simplifica el núcleo y mejora el rendimiento. Con respecto al rendimiento, las transacciones en Orleans anteriormente requerían código que se ejecutó para cada método para coordinar posibles transacciones. Desde entonces se hace por método.

Se trata de un cambio importante en la compilación. Es posible que tenga código existente que interactúe con recordatorios o secuencias llamando a métodos que se definieron anteriormente en la clase base Grain, pero que ahora son métodos de extensión. Estas llamadas que no especifican this (por ejemplo GetReminders) deben actualizarse para incluir this (por ejemplo this.GetReminders()), ya que los métodos de extensión deben calificarse. Si no actualiza esas llamadas, se producirá un error de compilación y es posible que el cambio de código necesario no sea evidente si no sabe lo que ha cambiado.

Cliente de transacción

Orleans 7.0 presenta una nueva abstracción para coordinar transacciones, Orleans.ITransactionClient. Anteriormente, las transacciones solo podían coordinarse mediante granos. Con ITransactionClient, que está disponible a través de la inserción de dependencias, los clientes también pueden coordinar las transacciones sin necesidad de un grano intermedio. En el ejemplo siguiente se retiran créditos de una cuenta y se depositan en otra dentro de una sola transacción. Se puede llamar a este código desde un grano o desde un cliente externo que ha recuperado ITransactionClient del contenedor de inserción de dependencias.

await transactionClient.RunTransaction(
  TransactionOption.Create,
  () => Task.WhenAll(from.Withdraw(100), to.Deposit(100)));

Para las transacciones coordinadas por el cliente, el cliente debe agregar los servicios necesarios durante el tiempo de configuración:

clientBuilder.UseTransactions();

En el ejemplo BankAccount se muestra el uso de ITransactionClient. Para más información, consulte Transacciones de Orleans.

Reentrada de la cadena de llamadas

De forma predeterminada, los granos son de un solo subproceso y procesan solicitudes de una en una desde el comienzo hasta la finalización. En otras palabras, los granos no son reentrantes de forma predeterminada. Agregar ReentrantAttribute a una clase de grano permite procesar varias solicitudes simultáneamente de forma intercalada, sin dejar de ser subprocesos únicos. Esto puede ser útil para granos que no contienen ningún estado interno o que realizan una gran cantidad de operaciones asincrónicas, como emitir llamadas HTTP o escribir en una base de datos. Es necesario prestar especial atención cuando las solicitudes se pueden intercalar: es posible que el estado de un grano observado antes de una instrucción await haya cambiado en el momento en que se completa la operación asincrónica y el método reanuda la ejecución.

Por ejemplo, el siguiente grano representa un contador. Se ha marcado Reentrant, lo que permite que varias llamadas se intercalen. El método Increment() debe incrementar el contador interno y devolver el valor observado. Sin embargo, dado que el cuerpo del método Increment() observa el estado del grano antes de un punto await y lo actualiza después, es posible que varias ejecuciones de intercalación de Increment() puedan dar lugar a un valor _value inferior al número total de llamadas Increment() recibidas. Se trata de un error introducido por el uso incorrecto de la reentrada.

Quitar ReentrantAttribute es suficiente para corregir el problema.

[Reentrant]
public sealed class CounterGrain : Grain, ICounterGrain
{
    int _value;
    
    /// <summary>
    /// Increments the grain's value and returns the previous value.
    /// </summary>
    public Task<int> Increment()
    {
        // Do not copy this code, it contains an error.
        var currentVal = _value;
        await Task.Delay(TimeSpan.FromMilliseconds(1_000));
        _value = currentVal + 1;
        return currentValue;
    }
}

Para evitar estos errores, los granos no son reentrantes de forma predeterminada. El inconveniente de esto es la reducción del rendimiento de los granos que realizan operaciones asincrónicas en su implementación, ya que no se pueden procesar otras solicitudes mientras el grano está esperando a que se complete una operación asincrónica. Para contrarrestar esto, Orleans ofrece varias opciones para permitir la reentrada en determinados casos:

  • Para una clase completa: colocar ReentrantAttribute en el grano permite que cualquier solicitud al grano se intercale con cualquier otra solicitud.
  • Para un subconjunto de métodos: colocar AlwaysInterleaveAttribute en el método de interfaz de grano permite que las solicitudes a ese método se intercalen con cualquier otra solicitud y que las solicitudes a ese método sean intercaladas por cualquier otra solicitud.
  • Para un subconjunto de métodos: colocar ReadOnlyAttribute en el método de interfaz de grano permite que las solicitudes a ese método se intercalen con cualquier otra solicitud ReadOnly y que las solicitudes a ese método sean intercaladas por cualquier otra solicitud ReadOnly. En este sentido, es una forma más restringida de AlwaysInterleave.
  • Para cualquier solicitud dentro de una cadena de llamadas: RequestContext.AllowCallChainReentrancy() y <xref:Orleans.Runtime.RequestContext.SuppressCallChainReentrancy?displayProperty=nameWithType pueden optar por permitir o no la reentrada de las solicitudes descendentes en el grano. Las llamadas devuelven un valor que se debe eliminar al salir de la solicitud. Por lo tanto, el uso adecuado es el siguiente:
public Task<int> OuterCall(IMyGrain other)
{
    // Allow call-chain reentrancy for this grain, for the duration of the method.
    using var _ = RequestContext.AllowCallChainReentrancy();
    await other.CallMeBack(this.AsReference<IMyGrain>());
}

public Task CallMeBack(IMyGrain grain)
{
    // Because OuterCall allowed reentrancy back into that grain, this method 
    // will be able to call grain.InnerCall() without deadlocking.
    await grain.InnerCall();
}

public Task InnerCall() => Task.CompletedTask;

La reentrada de la cadena de llamadas debe seleccionarse por grano y por cadena de llamadas. Por ejemplo, considere dos granos, grano A y grano B. Si el grano A habilita la reentrada de la cadena de llamadas antes de llamar al grano B, el grano B puede volver a llamar al grano A en esa llamada. Sin embargo, el grano A no puede volver a llamar al grano B si el grano B no ha habilitado también la reentrada de la cadena de llamadas. Es por grano, por cadena de llamadas.

Los granos también pueden suprimir la información de reentrada de la cadena de llamadas para que no fluya por una cadena de llamadas mediante using var _ = RequestContext.SuppressCallChainReentrancy(). Esto evita que las llamadas posteriores vuelvan a entrar.

Scripts de migración de ADO.NET

Para garantizar la compatibilidad directa con clústeres, persistencia y recordatorios de Orleans que se basan en ADO.NET, necesitará el script de migración de SQL adecuado:

Seleccione los archivos de las bases de datos utilizadas y aplíquelos en orden.