Almacenamiento personalizado de granos

En el tutorial sobre el almacenamiento de actor declarativo, usted aprendió a permitir que los grains almacenen su estado en una tabla de Azure mediante el uso de uno de los proveedores de almacenamiento integrados. Aunque Azure es un excelente lugar para almacenar los datos, existen muchas alternativas. Hay tantos que apoyarlos no es factible. En su lugar, Orleans está diseñado para permitirle agregar fácilmente compatibilidad con su almacenamiento preferido escribiendo un proveedor de almacenamiento personalizado.

En este tutorial, aprenderá a escribir un simple proveedor de almacenamiento basado en archivos. Un sistema de archivos no es el mejor lugar para almacenar estados de grano porque es local, puede tener problemas con bloqueos de archivos y la última fecha de actualización no es suficiente para evitar la incoherencia. Sin embargo, es un ejemplo sencillo para ilustrar la implementación de un proveedor de almacenamiento de granos.

Comienza

Un Orleans proveedor de almacenamiento de granos es una clase que implementa IGrainStorage, incluida en el paquete Microsoft.Orleans.Core NuGet. También hereda de ILifecycleParticipant<ISiloLifecycle>, lo que le permite suscribirse a eventos específicos en el ciclo de vida del silo. Empiece por crear una clase denominada FileGrainStorage.

using Microsoft.Extensions.Options;
using Orleans.Configuration;
using Orleans.Runtime;
using Orleans.Storage;

namespace GrainStorage;

public sealed class FileGrainStorage : IGrainStorage, ILifecycleParticipant<ISiloLifecycle>
{
    private readonly string _storageName;
    private readonly FileGrainStorageOptions _options;
    private readonly ClusterOptions _clusterOptions;

    public FileGrainStorage(
        string storageName,
        FileGrainStorageOptions options,
        IOptions<ClusterOptions> clusterOptions)
    {
        _storageName = storageName;
        _options = options;
        _clusterOptions = clusterOptions.Value;
    }

    public Task ClearStateAsync<T>(
        string stateName,
        GrainId grainId,
        IGrainState<T> grainState)
    {
        throw new NotImplementedException();
    }

    public Task ReadStateAsync<T>(
        string stateName,
        GrainId grainId,
        IGrainState<T> grainState)
    {
        throw new NotImplementedException();
    }

    public Task WriteStateAsync<T>(
        string stateName,
        GrainId grainId,
        IGrainState<T> grainState)
    {
        throw new NotImplementedException();
    }

    public void Participate(ISiloLifecycle lifecycle) =>
        throw new NotImplementedException();
}

Cada método implementa el método correspondiente en la IGrainStorage interfaz, aceptando un parámetro de tipo genérico para el tipo de estado subyacente. Los métodos son los siguientes:

El método ILifecycleParticipant<TLifecycleObservable>.Participate se suscribe al ciclo de vida del silo.

Antes de iniciar la implementación, cree una clase de opciones que contenga el directorio raíz donde se conservan los archivos de estado detallados. Cree un archivo de opciones denominado FileGrainStorageOptions que contenga lo siguiente:

using Orleans.Storage;

namespace GrainStorage;

public sealed class FileGrainStorageOptions : IStorageProviderSerializerOptions
{
    public required string RootDirectory { get; set; }

    public required IGrainStorageSerializer GrainStorageSerializer { get; set; }
}

Con la clase options creada, explore los parámetros de constructor de la FileGrainStorage clase :

  • storageName: especifica qué granos deben usar este proveedor de almacenamiento, por ejemplo, [StorageProvider(ProviderName = "File")].
  • options: La clase "options" acaba de ser creada.
  • clusterOptions: las opciones de clúster utilizadas para recuperar el ServiceId.

Inicialización del almacenamiento

Para inicializar el almacenamiento, suscríbase a la ServiceLifecycleStage.ApplicationServices etapa con una onStart función. Considere la siguiente implementación de ILifecycleParticipant<TLifecycleObservable>.Participate:

public void Participate(ISiloLifecycle lifecycle) =>
    lifecycle.Subscribe(
        observerName: OptionFormattingUtilities.Name<FileGrainStorage>(_storageName),
        stage: ServiceLifecycleStage.ApplicationServices,
        onStart: (ct) =>
        {
            Directory.CreateDirectory(_options.RootDirectory);
            return Task.CompletedTask;
        });

La onStart función crea condicionalmente el directorio raíz para almacenar estados de grano si aún no existe.

Además, proporcione una función común para construir el nombre de archivo, lo que garantiza la unicidad por servicio, el identificador de grano y el tipo de grano:

private string GetKeyString(string grainType, GrainId grainId) =>
    $"{_clusterOptions.ServiceId}.{grainId.Key}.{grainType}";

Estado de lectura

Para leer un estado de grano, obtenga el nombre de archivo mediante la función GetKeyString y combínelo con el directorio raíz de la instancia _options.

public async Task ReadStateAsync<T>(
    string stateName,
    GrainId grainId,
    IGrainState<T> grainState)
{
    var fName = GetKeyString(stateName, grainId);
    var path = Path.Combine(_options.RootDirectory, fName!);
    var fileInfo = new FileInfo(path);
    if (fileInfo is { Exists: false })
    {
        grainState.State = (T)Activator.CreateInstance(typeof(T))!;
        return;
    }

    using var stream = fileInfo.OpenText();
    var storedData = await stream.ReadToEndAsync();
    
    grainState.State = _options.GrainStorageSerializer.Deserialize<T>(new BinaryData(storedData));
    grainState.ETag = fileInfo.LastWriteTimeUtc.ToString();
}

Usa fileInfo.LastWriteTimeUtc como ETag, que otras funciones utilizan para las comprobaciones de inconsistencias y así evitar la pérdida de datos.

Para la deserialización, use el IStorageProviderSerializerOptions.GrainStorageSerializer. Esto es importante para serializar y deserializar correctamente el estado.

Estado de escritura

Escribir el estado es similar a leer el estado.

public async Task WriteStateAsync<T>(
    string stateName,
    GrainId grainId,
    IGrainState<T> grainState)
{
    var storedData = _options.GrainStorageSerializer.Serialize(grainState.State);
    var fName = GetKeyString(stateName, grainId);
    var path = Path.Combine(_options.RootDirectory, fName!);
    var fileInfo = new FileInfo(path);
    if (fileInfo.Exists && fileInfo.LastWriteTimeUtc.ToString() != grainState.ETag)
    {
        throw new InconsistentStateException($"""
            Version conflict (WriteState): ServiceId={_clusterOptions.ServiceId}
            ProviderName={_storageName} GrainType={typeof(T)}
            GrainReference={grainId}.
            """);
    }

    await File.WriteAllBytesAsync(path, storedData.ToArray());

    fileInfo.Refresh();
    grainState.ETag = fileInfo.LastWriteTimeUtc.ToString();
}

De forma similar al estado de lectura, use el IStorageProviderSerializerOptions.GrainStorageSerializer para escribir el estado. La verificación actual ETag se realiza contra la hora UTC de la última actualización del archivo. Si la fecha es diferente, significa que otra activación del mismo grano cambió el estado simultáneamente. En esta situación, lance un InconsistentStateException. Esto provoca que la activación actual se mate para evitar sobrescribir el estado guardado previamente por el otro grano activado.

Eliminar estado

Borrar el estado implica eliminar el archivo si existe.

public Task ClearStateAsync<T>(
    string stateName,
    GrainId grainId,
    IGrainState<T> grainState)
{
    var fName = GetKeyString(stateName, grainId);
    var path = Path.Combine(_options.RootDirectory, fName!);
    var fileInfo = new FileInfo(path);
    if (fileInfo.Exists)
    {
        if (fileInfo.LastWriteTimeUtc.ToString() != grainState.ETag)
        {
            throw new InconsistentStateException($"""
                Version conflict (ClearState): ServiceId={_clusterOptions.ServiceId}
                ProviderName={_storageName} GrainType={typeof(T)}
                GrainReference={grainId}.
                """);
        }

        grainState.ETag = null;
        grainState.State = (T)Activator.CreateInstance(typeof(T))!;

        fileInfo.Delete();
    }

    return Task.CompletedTask;
}

Por el mismo motivo que WriteStateAsync, compruebe si hay incoherencia. Antes de eliminar el archivo y restablecer el ETag, compruebe si el ETag actual coincide con la hora UTC de última escritura.

Ponlo todo junto

A continuación, cree una fábrica que permita asociar las opciones al nombre del proveedor al crear una instancia de FileGrainStorage para facilitar el registro en la colección de servicios.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Orleans.Configuration.Overrides;
using Orleans.Storage;

namespace GrainStorage;

internal static class FileGrainStorageFactory
{
    internal static IGrainStorage Create(
        IServiceProvider services, string name)
    {
        var optionsMonitor =
            services.GetRequiredService<IOptionsMonitor<FileGrainStorageOptions>>();

        return ActivatorUtilities.CreateInstance<FileGrainStorage>(
            services,
            name,
            optionsMonitor.Get(name),
            services.GetProviderClusterOptions(name));
    }
}

Finalmente, para registrar el almacenamiento de granos, cree una extensión en ISiloBuilder. Esta extensión registra el almacenamiento de grano como un singleton con clave mediante ServiceCollectionServiceExtensions.AddKeyedSingleton, la API estándar de inyección de dependencias con claves de .NET 8+.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Orleans.Runtime;
using Orleans.Storage;

namespace GrainStorage;

public static class FileSiloBuilderExtensions
{
    public static ISiloBuilder AddFileGrainStorage(
        this ISiloBuilder builder,
        string providerName,
        Action<FileGrainStorageOptions> options)
    {
        builder.Services.AddFileGrainStorage(providerName, options);
        return builder;
    }

    public static IServiceCollection AddFileGrainStorage(
        this IServiceCollection services,
        string providerName,
        Action<FileGrainStorageOptions> options)
    {
        services.AddOptions<FileGrainStorageOptions>(providerName)
            .Configure(options);

        services.AddTransient<
            IPostConfigureOptions<FileGrainStorageOptions>,
            DefaultStorageProviderSerializerOptionsConfigurator<FileGrainStorageOptions>>();

        // <KeyedRegistrations>
        services.AddKeyedSingleton<IGrainStorage>(
            providerName,
            (sp, key) => FileGrainStorageFactory.Create(sp, key?.ToString() ?? providerName));

        services.AddKeyedSingleton<ILifecycleParticipant<ISiloLifecycle>>(
            providerName,
            (sp, key) => (ILifecycleParticipant<ISiloLifecycle>)sp.GetRequiredKeyedService<IGrainStorage>(key));
        // </KeyedRegistrations>

        return services;
    }
}

FileGrainStorage implementa dos interfaces, IGrainStorage y ILifecycleParticipant<ISiloLifecycle>. Por lo tanto, registre dos servicios singleton con clave, uno para cada interfaz.

services.AddKeyedSingleton<IGrainStorage>(
    providerName,
    (sp, key) => FileGrainStorageFactory.Create(sp, key?.ToString() ?? providerName));

services.AddKeyedSingleton<ILifecycleParticipant<ISiloLifecycle>>(
    providerName,
    (sp, key) => (ILifecycleParticipant<ISiloLifecycle>)sp.GetRequiredKeyedService<IGrainStorage>(key));

Esto permite agregar el almacenamiento de archivos mediante la extensión en ISiloBuilder:

using GrainStorage;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);
builder.UseOrleans(siloBuilder =>
{
    siloBuilder.UseLocalhostClustering()
        .AddFileGrainStorage("File", options =>
        {
            string path = Environment.GetFolderPath(
                Environment.SpecialFolder.ApplicationData);

            options.RootDirectory = Path.Combine(path, "Orleans/GrainState/v1");
        });
});

using var host = builder.Build();
await host.RunAsync();

Ahora puede decorar los cereales con el proveedor [StorageProvider(ProviderName = "File")], y este almacena el estado del cereal en el directorio raíz configurado en las opciones disponibles. Considere la implementación completa de FileGrainStorage:

using Microsoft.Extensions.Options;
using Orleans.Configuration;
using Orleans.Runtime;
using Orleans.Storage;

namespace GrainStorage;

public sealed class FileGrainStorage : IGrainStorage, ILifecycleParticipant<ISiloLifecycle>
{
    private readonly string _storageName;
    private readonly FileGrainStorageOptions _options;
    private readonly ClusterOptions _clusterOptions;

    public FileGrainStorage(
        string storageName,
        FileGrainStorageOptions options,
        IOptions<ClusterOptions> clusterOptions)
    {
        _storageName = storageName;
        _options = options;
        _clusterOptions = clusterOptions.Value;
    }

    // <clearstateasync>
    public Task ClearStateAsync<T>(
        string stateName,
        GrainId grainId,
        IGrainState<T> grainState)
    {
        var fName = GetKeyString(stateName, grainId);
        var path = Path.Combine(_options.RootDirectory, fName!);
        var fileInfo = new FileInfo(path);
        if (fileInfo.Exists)
        {
            if (fileInfo.LastWriteTimeUtc.ToString() != grainState.ETag)
            {
                throw new InconsistentStateException($"""
                    Version conflict (ClearState): ServiceId={_clusterOptions.ServiceId}
                    ProviderName={_storageName} GrainType={typeof(T)}
                    GrainReference={grainId}.
                    """);
            }

            grainState.ETag = null;
            grainState.State = (T)Activator.CreateInstance(typeof(T))!;

            fileInfo.Delete();
        }

        return Task.CompletedTask;
    }
    // </clearstateasync>
    // <readstateasync>
    public async Task ReadStateAsync<T>(
        string stateName,
        GrainId grainId,
        IGrainState<T> grainState)
    {
        var fName = GetKeyString(stateName, grainId);
        var path = Path.Combine(_options.RootDirectory, fName!);
        var fileInfo = new FileInfo(path);
        if (fileInfo is { Exists: false })
        {
            grainState.State = (T)Activator.CreateInstance(typeof(T))!;
            return;
        }

        using var stream = fileInfo.OpenText();
        var storedData = await stream.ReadToEndAsync();
        
        grainState.State = _options.GrainStorageSerializer.Deserialize<T>(new BinaryData(storedData));
        grainState.ETag = fileInfo.LastWriteTimeUtc.ToString();
    }
    // </readstateasync>
    // <writestateasync>
    public async Task WriteStateAsync<T>(
        string stateName,
        GrainId grainId,
        IGrainState<T> grainState)
    {
        var storedData = _options.GrainStorageSerializer.Serialize(grainState.State);
        var fName = GetKeyString(stateName, grainId);
        var path = Path.Combine(_options.RootDirectory, fName!);
        var fileInfo = new FileInfo(path);
        if (fileInfo.Exists && fileInfo.LastWriteTimeUtc.ToString() != grainState.ETag)
        {
            throw new InconsistentStateException($"""
                Version conflict (WriteState): ServiceId={_clusterOptions.ServiceId}
                ProviderName={_storageName} GrainType={typeof(T)}
                GrainReference={grainId}.
                """);
        }

        await File.WriteAllBytesAsync(path, storedData.ToArray());

        fileInfo.Refresh();
        grainState.ETag = fileInfo.LastWriteTimeUtc.ToString();
    }
    // </writestateasync>
    // <participate>
    public void Participate(ISiloLifecycle lifecycle) =>
        lifecycle.Subscribe(
            observerName: OptionFormattingUtilities.Name<FileGrainStorage>(_storageName),
            stage: ServiceLifecycleStage.ApplicationServices,
            onStart: (ct) =>
            {
                Directory.CreateDirectory(_options.RootDirectory);
                return Task.CompletedTask;
            });
    // </participate>
    // <getkeystring>
    private string GetKeyString(string grainType, GrainId grainId) =>
        $"{_clusterOptions.ServiceId}.{grainId.Key}.{grainType}";
    // </getkeystring>
}
public class FileGrainStorage
    : IGrainStorage, ILifecycleParticipant<ISiloLifecycle>
{
    private readonly string _storageName;
    private readonly FileGrainStorageOptions _options;
    private readonly ClusterOptions _clusterOptions;
    private readonly IGrainFactory _grainFactory;
    private readonly ITypeResolver _typeResolver;
    private JsonSerializerSettings _jsonSettings;

    public FileGrainStorage(
        string storageName,
        FileGrainStorageOptions options,
        IOptions<ClusterOptions> clusterOptions,
        IGrainFactory grainFactory,
        ITypeResolver typeResolver)
    {
        _storageName = storageName;
        _options = options;
        _clusterOptions = clusterOptions.Value;
        _grainFactory = grainFactory;
        _typeResolver = typeResolver;
    }

    public Task ClearStateAsync(
        string grainType,
        GrainReference grainReference,
        IGrainState grainState)
    {
        throw new NotImplementedException();
    }

    public Task ReadStateAsync(
        string grainType,
        GrainReference grainReference,
        IGrainState grainState)
    {
        throw new NotImplementedException();
    }

    public Task WriteStateAsync(
        string grainType,
        GrainReference grainReference,
        IGrainState grainState)
    {
        throw new NotImplementedException();
    }

    public void Participate(
        ISiloLifecycle lifecycle)
    {
        throw new NotImplementedException();
    }
}

Antes de iniciar la implementación, cree una clase de opciones que contenga el directorio raíz donde se almacenan los archivos de estado de grano. Cree un archivo de opciones denominado FileGrainStorageOptions:

public class FileGrainStorageOptions
{
    public string RootDirectory { get; set; }
}

Cree un constructor que contenga dos campos: storageName para especificar qué granos deben usar este almacenamiento ([StorageProvider(ProviderName = "File")]) y directory, el directorio donde se guardan los estados de grano.

IGrainFactory y ITypeResolver se usan en la sección siguiente para inicializar el almacenamiento.

Además, toma dos opciones como argumentos: tus propios FileGrainStorageOptions y el ClusterOptions. Estos son necesarios para implementar las funcionalidades de almacenamiento.

También necesitas JsonSerializerSettings cuando serializas y deserializas en formato JSON.

Importante

JSON es un detalle de implementación. Es necesario decidir qué protocolo de serialización o deserialización se ajusta a la aplicación. Otro formato común es binario.

Inicialización del almacenamiento

Para inicializar el almacenamiento, registre una función Init en el ciclo de vida de ApplicationServices.

public void Participate(ISiloLifecycle lifecycle)
{
    lifecycle.Subscribe(
        OptionFormattingUtilities.Name<FileGrainStorageImpl>(_storageName),
        ServiceLifecycleStage.ApplicationServices,
        Init);
}

La Init función establece el _jsonSettings utilizado para configurar el serializador JSON. A la vez, cree la carpeta para almacenar los estados del grano si aún no existe.

private Task Init(CancellationToken ct)
{
    // Settings could be made configurable from Options.
    _jsonSettings =
        OrleansJsonSerializer.UpdateSerializerSettings(
            OrleansJsonSerializer.GetDefaultSerializerSettings(
                _typeResolver,
                _grainFactory),
            false,
            false,
            null);

    var directory = new DirectoryInfo(_options.RootDirectory);
    if (!directory.Exists)
        directory.Create();

    return Task.CompletedTask;
}

Además, proporcione una función común para construir el nombre de archivo, lo que garantiza la unicidad por servicio, el identificador de grano y el tipo de grano.

private string GetKeyString(string grainType, GrainReference grainReference)
{
    return $"{_clusterOptions.ServiceId}.{grainReference.ToKeyString()}.{grainType}";
}

Estado de lectura

Para leer un estado de grano, obtenga el nombre de archivo mediante la función definida anteriormente y combínelo con el directorio raíz de las opciones.

public async Task ReadStateAsync(
    string grainType,
    GrainReference grainReference,
    IGrainState grainState)
{
    var fName = GetKeyString(grainType, grainReference);
    var path = Path.Combine(_options.RootDirectory, fName);

    var fileInfo = new FileInfo(path);
    if (!fileInfo.Exists)
    {
        grainState.State = Activator.CreateInstance(grainState.State.GetType());
        return;
    }

    using (var stream = fileInfo.OpenText())
    {
        var storedData = await stream.ReadToEndAsync();
        grainState.State = JsonConvert.DeserializeObject(storedData, _jsonSettings);
    }

    grainState.ETag = fileInfo.LastWriteTimeUtc.ToString();
}

Use fileInfo.LastWriteTimeUtc como ETag, que otras funciones usan para comprobaciones de incoherencia para evitar la pérdida de datos.

Tenga en cuenta que para la deserialización, use el _jsonSettings establecido en la función Init. Esto es importante para serializar o deserializar correctamente el estado.

Estado de escritura

Escribir el estado es similar a leer el estado.

public async Task WriteStateAsync(
    string grainType,
    GrainReference grainReference,
    IGrainState grainState)
{
    var storedData = JsonConvert.SerializeObject(grainState.State, _jsonSettings);

    var fName = GetKeyString(grainType, grainReference);
    var path = Path.Combine(_options.RootDirectory, fName);

    var fileInfo = new FileInfo(path);

    if (fileInfo.Exists && fileInfo.LastWriteTimeUtc.ToString() != grainState.ETag)
    {
        throw new InconsistentStateException(
            $"Version conflict (WriteState): ServiceId={_clusterOptions.ServiceId} " +
            $"ProviderName={_storageName} GrainType={grainType} " +
            $"GrainReference={grainReference.ToKeyString()}.");
    }

    using (var stream = new StreamWriter(fileInfo.Open(FileMode.Create, FileAccess.Write)))
    {
        await stream.WriteAsync(storedData);
    }

    fileInfo.Refresh();
    grainState.ETag = fileInfo.LastWriteTimeUtc.ToString();
}

De forma similar al estado de lectura, use _jsonSettings para escribir el estado. La ETag actual verifica la última hora de actualización UTC del archivo. Si la fecha es diferente, significa que otra activación del mismo grano cambió el estado simultáneamente. En esta situación, inicie un InconsistentStateException, que da como resultado que se elimina la activación actual para evitar sobrescribir el estado guardado previamente por el otro grano activado.

Borrar el estado

Borrar el estado implica eliminar el archivo si existe.

public Task ClearStateAsync(
    string grainType,
    GrainReference grainReference,
    IGrainState grainState)
{
    var fName = GetKeyString(grainType, grainReference);
    var path = Path.Combine(_options.RootDirectory, fName);

    var fileInfo = new FileInfo(path);
    if (fileInfo.Exists)
    {
        if (fileInfo.LastWriteTimeUtc.ToString() != grainState.ETag)
        {
            throw new InconsistentStateException(
                $"Version conflict (ClearState): ServiceId={_clusterOptions.ServiceId} " +
                $"ProviderName={_storageName} GrainType={grainType} " +
                $"GrainReference={grainReference.ToKeyString()}.");
        }

        grainState.ETag = null;
        grainState.State = Activator.CreateInstance(grainState.State.GetType());
        fileInfo.Delete();
    }

    return Task.CompletedTask;
}

Por el mismo motivo que WriteStateAsync, compruebe si hay incoherencia. Antes de eliminar el archivo y restablecer la ETag, compruebe si la ETag actual coincide con la hora utc de última escritura.

Ponlo todo junto

A continuación, cree una fábrica que permita asociar las opciones al nombre del proveedor al crear una instancia de FileGrainStorage para facilitar el registro en la colección de servicios.

public static class FileGrainStorageFactory
{
    internal static IGrainStorage Create(
        IServiceProvider services, string name)
    {
        IOptionsSnapshot<FileGrainStorageOptions> optionsSnapshot =
            services.GetRequiredService<IOptionsSnapshot<FileGrainStorageOptions>>();

        return ActivatorUtilities.CreateInstance<FileGrainStorageImpl>(
            services,
            name,
            optionsSnapshot.Get(name),
            services.GetProviderClusterOptions(name));
    }
}

Finalmente, para registrar el almacenamiento de granos, cree una extensión en ISiloHostBuilder. Esta extensión registra internamente el almacenamiento de granos como un servicio con nombre mediante .AddSingletonNamedService(...), una extensión proporcionada por Orleans.Core.

public static class FileSiloBuilderExtensions
{
    public static ISiloHostBuilder AddFileGrainStorage(
        this ISiloHostBuilder builder,
        string providerName,
        Action<FileGrainStorageOptions> options)
    {
        return builder.ConfigureServices(
            services => services.AddFileGrainStorage(providerName, options));
    }

    public static IServiceCollection AddFileGrainStorage(
        this IServiceCollection services,
        string providerName,
        Action<FileGrainStorageOptions> options)
    {
        services.AddOptions<FileGrainStorageOptions>(providerName).Configure(options);

        return services.AddSingletonNamedService(providerName, FileGrainStorageFactory.Create)
            .AddSingletonNamedService(
                providerName,
                (s, n) => (ILifecycleParticipant<ISiloLifecycle>)s.GetRequiredServiceByName<IGrainStorage>(n));
    }
}

FileGrainStorage implementa dos interfaces, IGrainStorage y ILifecycleParticipant<ISiloLifecycle>. Por lo tanto, registre dos servicios con nombre, uno para cada interfaz. Esto permite agregar el almacenamiento de archivos mediante la extensión en ISiloHostBuilder:

public static void ConfigureSilo()
{
    var silo = new SiloHostBuilder()
        .UseLocalhostClustering()
        .AddFileGrainStorage("File", opts =>
        {
            opts.RootDirectory = "C:/TestFiles";
        })
        .Build();
}

Ahora puede decorar los cereales con el proveedor [StorageProvider(ProviderName = "File")], y este almacena el estado del cereal en el directorio raíz configurado en las opciones disponibles.