diff --git a/README.md b/README.md index 1be3603..a19a111 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -[![NuGet Version](https://img.shields.io/nuget/v/CodeCargo.NatsDistributedCache?cacheSeconds=3600&color=516bf1)](https://www.nuget.org/packages/CodeCargo.NatsDistributedCache/) +[![CodeCargo.NatsDistributedCache](https://img.shields.io/nuget/v/CodeCargo.NatsDistributedCache?color=516bf1&label=CodeCargo.NatsDistributedCache)](https://www.nuget.org/packages/CodeCargo.NatsDistributedCache/) [![CodeCargo.NatsHybridCache](https://img.shields.io/nuget/v/CodeCargo.NatsHybridCache?color=516bf1&label=CodeCargo.NatsHybridCache)](https://www.nuget.org/packages/CodeCargo.NatsHybridCache/) # CodeCargo.NatsDistributedCache ## Overview -A .NET 8+ library for integrating NATS as a distributed cache in ASP.NET Core applications. Supports the new HybridCache system for fast, scalable caching. +A .NET 8+ library for using NATS with `HybridCache` or as an `IDistributedCache` directly. ## Requirements @@ -21,6 +21,7 @@ A .NET 8+ library for integrating NATS as a distributed cache in ASP.NET Core ap ```bash # add NATS Distributed Cache dotnet add package CodeCargo.NatsDistributedCache +dotnet add package CodeCargo.NatsHybridCache # optional - add full NATS.Net (NATS Distributed Cache uses a subset of NATS.Net dependencies) dotnet add package NATS.Net @@ -29,13 +30,26 @@ dotnet add package NATS.Net dotnet add package Microsoft.Extensions.Caching.Hybrid ``` -## Usage +## Use with `HybridCache` -See the [Full Example here](https://github.com/code-cargo/NatsDistributedCache/tree/main/util/ReadmeExample/Program.cs). -This is the portion for registering services: +The `CodeCargo.NatsHybridCache` package provides an extension method that: + +1. Adds the NATS `IDistributedCache` +2. Adds `HybridCache` +3. Configures `HybridCache` to use the NATs Connection's serializer registry + +### Install + +```bash +dotnet add package CodeCargo.NatsDistributedCache +dotnet add package CodeCargo.NatsHybridCache +dotnet add package NATS.Net +``` + +### Example ```csharp -using CodeCargo.NatsDistributedCache; +using CodeCargo.NatsHybridCache; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using NATS.Client.Core; @@ -43,39 +57,62 @@ using NATS.Client.Hosting; using NATS.Client.KeyValueStore; using NATS.Net; -// Set the NATS URL, this normally comes from configuration const string natsUrl = "nats://localhost:4222"; - -// Create a host builder for a Console application -// For a Web Application you can use WebApplication.CreateBuilder(args) var builder = Host.CreateDefaultBuilder(args); - -// Add services to the container builder.ConfigureServices(services => { - // Add NATS client services.AddNats(configureOpts: options => options with { Url = natsUrl }); - // Add a NATS distributed cache - services.AddNatsDistributedCache(options => + services.AddNatsHybridCache(options => { options.BucketName = "cache"; }); +}); + +var host = builder.Build(); +var natsConnection = host.Services.GetRequiredService(); +var kvContext = natsConnection.CreateKeyValueStoreContext(); +await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") +{ + LimitMarkerTTL = TimeSpan.FromSeconds(1) +}); + +await host.RunAsync(); +``` + +## Use `IDistributedCache` Directly - // (Optional) Add HybridCache - var hybridCacheServices = services.AddHybridCache(); +### Install - // (Optional) Use NATS Serializer for HybridCache - hybridCacheServices.AddSerializerFactory( - NatsOpts.Default.SerializerRegistry.ToHybridCacheSerializerFactory()); +```bash +dotnet add package CodeCargo.NatsDistributedCache +dotnet add package NATS.Net +``` - // Add other services as needed +### Example + +```csharp +using CodeCargo.NatsDistributedCache; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NATS.Client.Core; +using NATS.Client.Hosting; +using NATS.Client.KeyValueStore; +using NATS.Net; + +const string natsUrl = "nats://localhost:4222"; +var builder = Host.CreateDefaultBuilder(args); +builder.ConfigureServices(services => +{ + services.AddNats(configureOpts: options => options with { Url = natsUrl }); + + services.AddNatsDistributedCache(options => + { + options.BucketName = "cache"; + }); }); -// Build the host var host = builder.Build(); - -// Ensure that the KV Store is created var natsConnection = host.Services.GetRequiredService(); var kvContext = natsConnection.CreateKeyValueStoreContext(); await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") @@ -83,7 +120,6 @@ await kvContext.CreateOrUpdateStoreAsync(new NatsKVConfig("cache") LimitMarkerTTL = TimeSpan.FromSeconds(1) }); -// Start the host await host.RunAsync(); ``` diff --git a/src/NatsHybridCache/NatsHybridCacheExtensions.cs b/src/NatsHybridCache/NatsHybridCacheExtensions.cs index d5e8c49..dc2a4a4 100644 --- a/src/NatsHybridCache/NatsHybridCacheExtensions.cs +++ b/src/NatsHybridCache/NatsHybridCacheExtensions.cs @@ -16,16 +16,23 @@ public static class NatsHybridCacheExtensions /// use the serializer registry from the configured . /// /// The to add services to. - /// An action to configure . + /// An action to configure . + /// An optional action to configure . /// If set, resolves a keyed instance. /// The configured . public static IHybridCacheBuilder AddNatsHybridCache( this IServiceCollection services, - Action configureOptions, + Action configureNatsOptions, + Action? configureHybridCacheOptions = null, object? connectionServiceKey = null) { - services.AddNatsDistributedCache(configureOptions, connectionServiceKey); + services.AddNatsDistributedCache(configureNatsOptions, connectionServiceKey); var builder = services.AddHybridCache(); + if (configureHybridCacheOptions != null) + { + builder.Services.Configure(configureHybridCacheOptions); + } + builder.Services.AddSingleton(sp => { var natsConnection = connectionServiceKey == null diff --git a/util/ReadmeExample/DistributedCache.cs b/util/ReadmeExample/DistributedCache.cs new file mode 100644 index 0000000..82f7ecf --- /dev/null +++ b/util/ReadmeExample/DistributedCache.cs @@ -0,0 +1,150 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using CodeCargo.NatsDistributedCache; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NATS.Client.Core; +using NATS.Client.Hosting; +using NATS.Client.KeyValueStore; +using NATS.Net; + +namespace CodeCargo.ReadmeExample; + +public static class DistributedCacheStartup +{ + public static async Task RunAsync(string[] args) + { + var aspireStartupTimeout = TimeSpan.FromSeconds(30); + var appStartupTimeout = TimeSpan.FromSeconds(30); + var appShutdownTimeout = TimeSpan.FromSeconds(10); + + Console.WriteLine("Starting Aspire..."); + var aspireAppHost = await DistributedApplicationTestingBuilder.CreateAsync(); + var aspireApp = await aspireAppHost.BuildAsync(); + await aspireApp.StartAsync(); + + var resourceNotificationService = aspireApp.Services.GetRequiredService(); + using var startupCts = new CancellationTokenSource(aspireStartupTimeout); + await resourceNotificationService.WaitForResourceHealthyAsync("Nats", startupCts.Token); + Console.WriteLine("Aspire started"); + + var natsConnectionString = await aspireApp.GetConnectionStringAsync("Nats", cancellationToken: startupCts.Token); + if (string.IsNullOrEmpty(natsConnectionString)) + { + throw new InvalidOperationException("Cannot find connection string for NATS"); + } + + var builder = Host.CreateDefaultBuilder(args); + builder.ConfigureServices(services => + { + services.AddNats(configureOpts: options => options with { Url = natsConnectionString }); + services.AddNatsDistributedCache(options => + { + options.BucketName = "cache"; + }); + + services.AddScoped(); + }); + + var host = builder.Build(); + var lifetime = host.Services.GetRequiredService(); + + Console.WriteLine("Creating KV store..."); + var natsConnection = host.Services.GetRequiredService(); + var kvContext = natsConnection.CreateKeyValueStoreContext(); + await kvContext.CreateOrUpdateStoreAsync( + new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1) }, startupCts.Token); + Console.WriteLine("KV store created"); + + Console.WriteLine("Starting app..."); + using var appCts = new CancellationTokenSource(); + var appTask = Task.Run(async () => + { + try + { + await host.RunAsync(appCts.Token); + } + catch (OperationCanceledException) when (appCts.IsCancellationRequested) + { + } + }); + + try + { + await WaitForApplicationStartAsync(lifetime, appStartupTimeout); + Console.WriteLine("App started"); + + using var scope = host.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.Run(); + + await appCts.CancelAsync(); + await appTask; + } + finally + { + using var stopCts = new CancellationTokenSource(appShutdownTimeout); + try + { + Console.WriteLine("Stopping app..."); + await aspireApp.StopAsync(stopCts.Token); + Console.WriteLine("App stopped"); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Error stopping app: {ex.Message}"); + } + + await aspireApp.DisposeAsync(); + } + } + + private static async Task WaitForApplicationStartAsync(IHostApplicationLifetime lifetime, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, + lifetime.ApplicationStarted); + try + { + await Task.Delay(timeout, linkedCts.Token); + } + catch (OperationCanceledException) when (lifetime.ApplicationStarted.IsCancellationRequested) + { + } + } +} + +public class DistributedCacheService +{ + private readonly IDistributedCache _cache; + private readonly ILogger _logger; + + public DistributedCacheService(IDistributedCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task Run() + { + _logger.LogInformation("------------------------------------------"); + _logger.LogInformation("DistributedCache example"); + + const string cacheKey = "distributed-cache-greeting"; + const string value = "Hello from NATS Distributed Cache!"; + await _cache.SetStringAsync( + cacheKey, + value, + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) }); + _logger.LogInformation("Set value in cache: {Value}", value); + + var retrievedValue = await _cache.GetStringAsync(cacheKey); + _logger.LogInformation("Retrieved value from cache: {Value}", retrievedValue); + + await _cache.RemoveAsync(cacheKey); + _logger.LogInformation("Removed value from cache"); + } +} diff --git a/util/ReadmeExample/HybridCache.cs b/util/ReadmeExample/HybridCache.cs new file mode 100644 index 0000000..ef22e7c --- /dev/null +++ b/util/ReadmeExample/HybridCache.cs @@ -0,0 +1,147 @@ +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using CodeCargo.NatsHybridCache; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NATS.Client.Core; +using NATS.Client.Hosting; +using NATS.Client.KeyValueStore; +using NATS.Net; + +namespace CodeCargo.ReadmeExample; + +public static class HybridCacheStartup +{ + public static async Task RunAsync(string[] args) + { + var aspireStartupTimeout = TimeSpan.FromSeconds(30); + var appStartupTimeout = TimeSpan.FromSeconds(30); + var appShutdownTimeout = TimeSpan.FromSeconds(10); + + Console.WriteLine("Starting Aspire..."); + var aspireAppHost = await DistributedApplicationTestingBuilder.CreateAsync(); + var aspireApp = await aspireAppHost.BuildAsync(); + await aspireApp.StartAsync(); + + var resourceNotificationService = aspireApp.Services.GetRequiredService(); + using var startupCts = new CancellationTokenSource(aspireStartupTimeout); + await resourceNotificationService.WaitForResourceHealthyAsync("Nats", startupCts.Token); + Console.WriteLine("Aspire started"); + + var natsConnectionString = await aspireApp.GetConnectionStringAsync("Nats", cancellationToken: startupCts.Token); + if (string.IsNullOrEmpty(natsConnectionString)) + { + throw new InvalidOperationException("Cannot find connection string for NATS"); + } + + var builder = Host.CreateDefaultBuilder(args); + builder.ConfigureServices(services => + { + services.AddNats(configureOpts: options => options with { Url = natsConnectionString }); + services.AddNatsHybridCache(options => + { + options.BucketName = "cache"; + }); + + services.AddScoped(); + }); + + var host = builder.Build(); + var lifetime = host.Services.GetRequiredService(); + + Console.WriteLine("Creating KV store..."); + var natsConnection = host.Services.GetRequiredService(); + var kvContext = natsConnection.CreateKeyValueStoreContext(); + await kvContext.CreateOrUpdateStoreAsync( + new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1) }, startupCts.Token); + Console.WriteLine("KV store created"); + + Console.WriteLine("Starting app..."); + using var appCts = new CancellationTokenSource(); + var appTask = Task.Run(async () => + { + try + { + await host.RunAsync(appCts.Token); + } + catch (OperationCanceledException) when (appCts.IsCancellationRequested) + { + } + }); + + try + { + await WaitForApplicationStartAsync(lifetime, appStartupTimeout); + Console.WriteLine("App started"); + + using var scope = host.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.Run(); + + await appCts.CancelAsync(); + await appTask; + } + finally + { + using var stopCts = new CancellationTokenSource(appShutdownTimeout); + try + { + Console.WriteLine("Stopping app..."); + await aspireApp.StopAsync(stopCts.Token); + Console.WriteLine("App stopped"); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Error stopping app: {ex.Message}"); + } + + await aspireApp.DisposeAsync(); + } + } + + private static async Task WaitForApplicationStartAsync(IHostApplicationLifetime lifetime, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cts.Token, + lifetime.ApplicationStarted); + try + { + await Task.Delay(timeout, linkedCts.Token); + } + catch (OperationCanceledException) when (lifetime.ApplicationStarted.IsCancellationRequested) + { + } + } +} + +public class HybridCacheService +{ + private readonly HybridCache _cache; + private readonly ILogger _logger; + + public HybridCacheService(HybridCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task Run() + { + _logger.LogInformation("------------------------------------------"); + _logger.LogInformation("HybridCache example"); + + const string key = "hybrid-cache-greeting"; + + var result = await _cache.GetOrCreateAsync( + key, + _ => ValueTask.FromResult("Hello from NATS Hybrid Cache!"), + new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(1) }); + _logger.LogInformation("Got/created value from cache: {Result}", result); + + await _cache.RemoveAsync(key); + _logger.LogInformation("Removed value from cache"); + } +} diff --git a/util/ReadmeExample/Program.cs b/util/ReadmeExample/Program.cs index 535efbc..5f36385 100644 --- a/util/ReadmeExample/Program.cs +++ b/util/ReadmeExample/Program.cs @@ -1,182 +1,4 @@ -using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Testing; -using CodeCargo.NatsDistributedCache; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Hybrid; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using NATS.Client.Core; -using NATS.Client.Hosting; -using NATS.Client.KeyValueStore; -using NATS.Net; +using CodeCargo.ReadmeExample; -// Timeouts -var aspireStartupTimeout = TimeSpan.FromSeconds(30); -var appStartupTimeout = TimeSpan.FromSeconds(30); -var appShutdownTimeout = TimeSpan.FromSeconds(10); - -// Start the NatsAppHost application -Console.WriteLine("Starting Aspire..."); -var aspireAppHost = await DistributedApplicationTestingBuilder.CreateAsync(); -var aspireApp = await aspireAppHost.BuildAsync(); -await aspireApp.StartAsync(); - -// Wait for the NATS resource to be healthy before proceeding -var resourceNotificationService = aspireApp.Services.GetRequiredService(); -using var startupCts = new CancellationTokenSource(aspireStartupTimeout); -await resourceNotificationService.WaitForResourceHealthyAsync("Nats", startupCts.Token); -Console.WriteLine("Aspire started"); - -// Get NATS connection string from Aspire -var natsConnectionString = await aspireApp.GetConnectionStringAsync("Nats", cancellationToken: startupCts.Token); -if (string.IsNullOrEmpty(natsConnectionString)) -{ - throw new InvalidOperationException("Cannot find connection string for NATS"); -} - -// Create a host builder for a console application -var builder = Host.CreateDefaultBuilder(args); - -// Add services to the container -builder.ConfigureServices(services => -{ - // Add NATS client - services.AddNats(configureOpts: options => options with { Url = natsConnectionString }); - - // Add a NATS distributed cache - services.AddNatsDistributedCache(options => - { - options.BucketName = "cache"; - }); - - // (Optional) Add HybridCache - var hybridCacheServices = services.AddHybridCache(); - - // (Optional) Use NATS Serializer for HybridCache - hybridCacheServices.AddSerializerFactory( - NatsOpts.Default.SerializerRegistry.ToHybridCacheSerializerFactory()); -}); - -// Build the host -var host = builder.Build(); -var lifetime = host.Services.GetRequiredService(); - -// Create KV store -Console.WriteLine("Creating KV store..."); -var natsConnection = host.Services.GetRequiredService(); -var kvContext = natsConnection.CreateKeyValueStoreContext(); -await kvContext.CreateOrUpdateStoreAsync( - new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1) }, startupCts.Token); -Console.WriteLine("KV store created"); - -// Start the host -Console.WriteLine("Starting app..."); -using var appCts = new CancellationTokenSource(); -var appTask = Task.Run(async () => -{ - try - { - await host.RunAsync(appCts.Token); - } - catch (OperationCanceledException) when (appCts.IsCancellationRequested) - { - // Ignore expected cancellation - } -}); - -try -{ - // Wait for the host to start - await WaitForApplicationStartAsync(lifetime, appStartupTimeout); - Console.WriteLine("App started"); - - // Run the examples - await DistributedCacheExample(host.Services); - await HybridCacheExample(host.Services); - - // Shut down gracefully - await appCts.CancelAsync(); - await appTask; -} -finally -{ - // Clean up resources - using var stopCts = new CancellationTokenSource(appShutdownTimeout); - try - { - Console.WriteLine("Stopping app..."); - await aspireApp.StopAsync(stopCts.Token); - Console.WriteLine("App stopped"); - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync($"Error stopping app: {ex.Message}"); - } - - await aspireApp.DisposeAsync(); -} - -return; - -static async Task WaitForApplicationStartAsync(IHostApplicationLifetime lifetime, TimeSpan timeout) -{ - using var cts = new CancellationTokenSource(timeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - cts.Token, - lifetime.ApplicationStarted); - try - { - await Task.Delay(timeout, linkedCts.Token); - } - catch (OperationCanceledException) when (lifetime.ApplicationStarted.IsCancellationRequested) - { - // Application started successfully - } -} - -static async Task DistributedCacheExample(IServiceProvider serviceProvider) -{ - Console.WriteLine("------------------------------------------"); - Console.WriteLine("DistributedCache example"); - using var scope = serviceProvider.CreateScope(); - var cache = scope.ServiceProvider.GetRequiredService(); - - // Set a value - const string cacheKey = "distributed-cache-greeting"; - const string value = "Hello from NATS Distributed Cache!"; - await cache.SetStringAsync( - cacheKey, - value, - new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1) }); - Console.WriteLine($"Set value in cache: {value}"); - - // Retrieve the value - var retrievedValue = await cache.GetStringAsync(cacheKey); - Console.WriteLine($"Retrieved value from cache: {retrievedValue}"); - - // Remove the value - await cache.RemoveAsync(cacheKey); - Console.WriteLine("Removed value from cache"); -} - -static async Task HybridCacheExample(IServiceProvider serviceProvider) -{ - Console.WriteLine("------------------------------------------"); - Console.WriteLine("HybridCache example"); - using var scope = serviceProvider.CreateScope(); - var cache = scope.ServiceProvider.GetRequiredService(); - - // Define key to use - const string key = "hybrid-cache-greeting"; - - // Use GetOrCreateAsync to either get the value from cache or create it if not present - var result = await cache.GetOrCreateAsync( - key, - _ => ValueTask.FromResult("Hello from NATS Hybrid Cache!"), - new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(1) }); - Console.WriteLine($"Got/created value from cache: {result}"); - - // Remove the value from cache - await cache.RemoveAsync(key); - Console.WriteLine("Removed value from cache"); -} +await DistributedCacheStartup.RunAsync(args); +await HybridCacheStartup.RunAsync(args);