Browse Source

Add `AbpHybridCache`.

pull/20859/head
maliming 1 year ago
parent
commit
1b11f2de8f
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 1
      Directory.Packages.props
  2. 1
      framework/src/Volo.Abp.Caching/Volo.Abp.Caching.csproj
  3. 7
      framework/src/Volo.Abp.Caching/Volo/Abp/Caching/AbpCachingModule.cs
  4. 459
      framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/AbpHybridCache.cs
  5. 27
      framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/AbpHybridCacheJsonSerializer.cs
  6. 27
      framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/AbpHybridCacheJsonSerializerFactory.cs
  7. 51
      framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/AbpHybridCacheOptions.cs
  8. 93
      framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/IHybridCache.cs
  9. 25
      framework/test/Volo.Abp.Caching.Tests/Volo/Abp/Caching/AbpCachingTestModule.cs
  10. 519
      framework/test/Volo.Abp.Caching.Tests/Volo/Abp/Caching/HybridCache_Tests.cs

1
Directory.Packages.props

@ -80,6 +80,7 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0-rc.1.24451.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0-rc.1.24451.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0-rc.1.24451.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="9.0.0-preview.7.24406.2" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0-rc.1.24431.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.0-rc.1.24452.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0-rc.1.24431.7" />

1
framework/src/Volo.Abp.Caching/Volo.Abp.Caching.csproj

@ -17,6 +17,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
</ItemGroup>
<ItemGroup>

7
framework/src/Volo.Abp.Caching/Volo/Abp/Caching/AbpCachingModule.cs

@ -1,5 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Volo.Abp.Caching.Hybrid;
using Volo.Abp.Json;
using Volo.Abp.Modularity;
using Volo.Abp.MultiTenancy;
@ -25,6 +28,10 @@ public class AbpCachingModule : AbpModule
context.Services.AddSingleton(typeof(IDistributedCache<>), typeof(DistributedCache<>));
context.Services.AddSingleton(typeof(IDistributedCache<,>), typeof(DistributedCache<,>));
context.Services.AddHybridCache().AddSerializerFactory<AbpHybridCacheJsonSerializerFactory>();
context.Services.AddSingleton(typeof(IHybridCache<>), typeof(AbpHybridCache<>));
context.Services.AddSingleton(typeof(IHybridCache<,>), typeof(AbpHybridCache<,>));
context.Services.Configure<AbpDistributedCacheOptions>(cacheOptions =>
{
cacheOptions.GlobalCacheEntryOptions.SlidingExpiration = TimeSpan.FromMinutes(20);

459
framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/AbpHybridCache.cs

@ -0,0 +1,459 @@
using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Volo.Abp.ExceptionHandling;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Threading;
using Volo.Abp.Uow;
namespace Volo.Abp.Caching.Hybrid;
/// <summary>
/// Represents a hybrid cache of <typeparamref name="TCacheItem"/> items.
/// </summary>
/// <typeparam name="TCacheItem">The type of the cache item being cached.</typeparam>
public class AbpHybridCache<TCacheItem> : IHybridCache<TCacheItem>
where TCacheItem : class
{
public IHybridCache<TCacheItem, string> InternalCache { get; }
public AbpHybridCache(IHybridCache<TCacheItem, string> internalCache)
{
InternalCache = internalCache;
}
public virtual async Task<TCacheItem?> GetOrCreateAsync(string key, Func<Task<TCacheItem>> factory, Func<HybridCacheEntryOptions>? optionsFactory = null, bool? hideErrors = null, bool considerUow = false, CancellationToken token = default)
{
return await InternalCache.GetOrCreateAsync(key, factory, optionsFactory, hideErrors, considerUow, token);
}
public virtual async Task SetAsync(string key, TCacheItem value, HybridCacheEntryOptions? options = null, bool? hideErrors = null, bool considerUow = false, CancellationToken token = default)
{
await InternalCache.SetAsync(key, value, options, hideErrors, considerUow, token);
}
public virtual async Task RemoveAsync(string key, bool? hideErrors = null, bool considerUow = false, CancellationToken token = default)
{
await InternalCache.RemoveAsync(key, hideErrors, considerUow, token);
}
public virtual async Task RemoveManyAsync(IEnumerable<string> keys, bool? hideErrors = null, bool considerUow = false, CancellationToken token = default)
{
await InternalCache.RemoveManyAsync(keys, hideErrors, considerUow, token);
}
}
/// <summary>
/// Represents a hybrid cache of <typeparamref name="TCacheItem"/> items.
/// Uses <typeparamref name="TCacheKey"/> as the key type.
/// </summary>
/// <typeparam name="TCacheItem">The type of cache item being cached.</typeparam>
/// <typeparam name="TCacheKey">The type of cache key being used.</typeparam>
public class AbpHybridCache<TCacheItem, TCacheKey> : IHybridCache<TCacheItem, TCacheKey>
where TCacheItem : class
where TCacheKey : notnull
{
public const string UowCacheName = "AbpHybridCache";
public ILogger<AbpHybridCache<TCacheItem, TCacheKey>> Logger { get; set; }
protected string CacheName { get; set; } = default!;
protected bool IgnoreMultiTenancy { get; set; }
protected IServiceProvider ServiceProvider { get; }
protected HybridCache HybridCache { get; }
protected IDistributedCache DistributedCacheCache { get; }
protected ICancellationTokenProvider CancellationTokenProvider { get; }
protected IDistributedCacheKeyNormalizer KeyNormalizer { get; }
protected IServiceScopeFactory ServiceScopeFactory { get; }
protected IUnitOfWorkManager UnitOfWorkManager { get; }
protected SemaphoreSlim SyncSemaphore { get; }
protected HybridCacheEntryOptions DefaultCacheOptions = default!;
protected AbpHybridCacheOptions DistributedCacheOption { get; }
public AbpHybridCache(
IServiceProvider serviceProvider,
IOptions<AbpHybridCacheOptions> distributedCacheOption,
HybridCache hybridCache,
IDistributedCache distributedCache,
ICancellationTokenProvider cancellationTokenProvider,
IDistributedCacheSerializer serializer,
IDistributedCacheKeyNormalizer keyNormalizer,
IServiceScopeFactory serviceScopeFactory,
IUnitOfWorkManager unitOfWorkManager)
{
ServiceProvider = serviceProvider;
DistributedCacheOption = distributedCacheOption.Value;
HybridCache = hybridCache;
DistributedCacheCache = distributedCache;
CancellationTokenProvider = cancellationTokenProvider;
Logger = NullLogger<AbpHybridCache<TCacheItem, TCacheKey>>.Instance;
KeyNormalizer = keyNormalizer;
ServiceScopeFactory = serviceScopeFactory;
UnitOfWorkManager = unitOfWorkManager;
SyncSemaphore = new SemaphoreSlim(1, 1);
SetDefaultOptions();
}
protected virtual string NormalizeKey(TCacheKey key)
{
return KeyNormalizer.NormalizeKey(
new DistributedCacheKeyNormalizeArgs(
key.ToString()!,
CacheName,
IgnoreMultiTenancy
)
);
}
protected virtual HybridCacheEntryOptions GetDefaultCacheEntryOptions()
{
foreach (var configure in DistributedCacheOption.CacheConfigurators)
{
var options = configure.Invoke(CacheName);
if (options != null)
{
return options;
}
}
return DistributedCacheOption.GlobalHybridCacheEntryOptions;
}
protected virtual void SetDefaultOptions()
{
CacheName = CacheNameAttribute.GetCacheName(typeof(TCacheItem));
//IgnoreMultiTenancy
IgnoreMultiTenancy = typeof(TCacheItem).IsDefined(typeof(IgnoreMultiTenancyAttribute), true);
//Configure default cache entry options
DefaultCacheOptions = GetDefaultCacheEntryOptions();
}
/// <summary>
/// Gets or Creates a cache item with the given key. If no cache item is found for the given key then adds a cache item
/// provided by <paramref name="factory" /> delegate and returns the provided cache item.
/// </summary>
/// <param name="key">The key of cached item to be retrieved from the cache.</param>
/// <param name="factory">The factory delegate is used to provide the cache item when no cache item is found for the given <paramref name="key" />.</param>
/// <param name="optionsFactory">The cache options for the factory delegate.</param>
/// <param name="hideErrors">Indicates to throw or hide the exceptions for the distributed cache.</param>
/// <param name="considerUow">This will store the cache in the current unit of work until the end of the current unit of work does not really affect the cache.</param>
/// <param name="token">The <see cref="T:System.Threading.CancellationToken" /> for the task.</param>
/// <returns>The cache item.</returns>
public virtual async Task<TCacheItem?> GetOrCreateAsync(
TCacheKey key,
Func<Task<TCacheItem>> factory,
Func<HybridCacheEntryOptions>? optionsFactory = null,
bool? hideErrors = null,
bool considerUow = false,
CancellationToken token = default)
{
token = CancellationTokenProvider.FallbackToProvider(token);
hideErrors = hideErrors ?? DistributedCacheOption.HideErrors;
TCacheItem? value = null;
if (!considerUow)
{
try
{
value = await HybridCache.GetOrCreateAsync(
key: NormalizeKey(key),
factory: async cancel => await factory(),
options: optionsFactory?.Invoke(),
tags: null,
cancellationToken: token);
}
catch (Exception ex)
{
if (hideErrors == true)
{
await HandleExceptionAsync(ex);
return null;
}
throw;
}
return value;
}
try
{
using (await SyncSemaphore.LockAsync(token))
{
if (ShouldConsiderUow(considerUow))
{
value = GetUnitOfWorkCache().GetOrDefault(key)?.GetUnRemovedValueOrNull();
if (value != null)
{
return value;
}
}
var bytes = await DistributedCacheCache.GetAsync(NormalizeKey(key), token);
if (bytes != null)
{
return ResolveSerializer().Deserialize(new ReadOnlySequence<byte>(bytes, 0, bytes.Length));;
}
value = await factory();
if (ShouldConsiderUow(considerUow))
{
var uowCache = GetUnitOfWorkCache();
if (uowCache.TryGetValue(key, out var item))
{
item.SetValue(value);
}
else
{
uowCache.Add(key, new UnitOfWorkCacheItem<TCacheItem>(value));
}
}
await SetAsync(key, value, optionsFactory?.Invoke(), hideErrors, considerUow, token);
}
}
catch (Exception ex)
{
if (hideErrors == true)
{
await HandleExceptionAsync(ex);
return null;
}
throw;
}
return value;
}
/// <summary>
/// Sets the cache item value for the provided key.
/// </summary>
/// <param name="key">The key of cached item to be retrieved from the cache.</param>
/// <param name="value">The cache item value to set in the cache.</param>
/// <param name="options">The cache options for the value.</param>
/// <param name="hideErrors">Indicates to throw or hide the exceptions for the distributed cache.</param>
/// <param name="considerUow">This will store the cache in the current unit of work until the end of the current unit of work does not really affect the cache.</param>
/// <param name="token">The <see cref="T:System.Threading.CancellationToken" /> for the task.</param>
/// <returns>The <see cref="T:System.Threading.Tasks.Task" /> indicating that the operation is asynchronous.</returns>
public virtual async Task SetAsync(
TCacheKey key,
TCacheItem value,
HybridCacheEntryOptions? options = null,
bool? hideErrors = null,
bool considerUow = false,
CancellationToken token = default)
{
async Task SetRealCache()
{
token = CancellationTokenProvider.FallbackToProvider(token);
hideErrors = hideErrors ?? DistributedCacheOption.HideErrors;
try
{
await HybridCache.SetAsync(
key: NormalizeKey(key),
value: value,
options: options ?? DefaultCacheOptions,
tags: null,
cancellationToken: token
);
}
catch (Exception ex)
{
if (hideErrors == true)
{
await HandleExceptionAsync(ex);
return;
}
throw;
}
}
if (ShouldConsiderUow(considerUow))
{
var uowCache = GetUnitOfWorkCache();
if (uowCache.TryGetValue(key, out _))
{
uowCache[key].SetValue(value);
}
else
{
uowCache.Add(key, new UnitOfWorkCacheItem<TCacheItem>(value));
}
UnitOfWorkManager.Current?.OnCompleted(SetRealCache);
}
else
{
await SetRealCache();
}
}
/// <summary>
/// Removes the cache item for given key from cache.
/// </summary>
/// <param name="key">The key of cached item to be retrieved from the cache.</param>
/// <param name="hideErrors">Indicates to throw or hide the exceptions for the distributed cache.</param>
/// <param name="considerUow">This will store the cache in the current unit of work until the end of the current unit of work does not really affect the cache.</param>
/// <param name="token">The <see cref="T:System.Threading.CancellationToken" /> for the task.</param>
/// <returns>The <see cref="T:System.Threading.Tasks.Task" /> indicating that the operation is asynchronous.</returns>
public virtual async Task RemoveAsync(
TCacheKey key,
bool? hideErrors = null,
bool considerUow = false,
CancellationToken token = default)
{
await RemoveManyAsync(new[] { key }, hideErrors, considerUow, token);
}
/// <summary>
/// Removes the cache items for given keys from cache.
/// </summary>
/// <param name="keys">The keys of cached items to be retrieved from the cache.</param>
/// <param name="hideErrors">Indicates to throw or hide the exceptions for the distributed cache.</param>
/// <param name="considerUow">This will store the cache in the current unit of work until the end of the current unit of work does not really affect the cache.</param>
/// <param name="token">The <see cref="T:System.Threading.CancellationToken" /> for the task.</param>
/// <returns>The <see cref="T:System.Threading.Tasks.Task" /> indicating that the operation is asynchronous.</returns>
public async Task RemoveManyAsync(
IEnumerable<TCacheKey> keys,
bool? hideErrors = null,
bool considerUow = false,
CancellationToken token = default)
{
var keyArray = keys.ToArray();
async Task RemoveRealCache()
{
hideErrors = hideErrors ?? DistributedCacheOption.HideErrors;
try
{
await HybridCache.RemoveAsync(
keyArray.Select(NormalizeKey), token);
}
catch (Exception ex)
{
if (hideErrors == true)
{
await HandleExceptionAsync(ex);
return;
}
throw;
}
}
if (ShouldConsiderUow(considerUow))
{
var uowCache = GetUnitOfWorkCache();
foreach (var key in keyArray)
{
if (uowCache.TryGetValue(key, out _))
{
uowCache[key].RemoveValue();
}
}
UnitOfWorkManager.Current?.OnCompleted(RemoveRealCache);
}
else
{
await RemoveRealCache();
}
}
protected virtual async Task HandleExceptionAsync(Exception ex)
{
Logger.LogException(ex, LogLevel.Warning);
using (var scope = ServiceScopeFactory.CreateScope())
{
await scope.ServiceProvider
.GetRequiredService<IExceptionNotifier>()
.NotifyAsync(new ExceptionNotificationContext(ex, LogLevel.Warning));
}
}
protected virtual bool ShouldConsiderUow(bool considerUow)
{
return considerUow && UnitOfWorkManager.Current != null;
}
protected virtual string GetUnitOfWorkCacheKey()
{
return UowCacheName + CacheName;
}
protected virtual Dictionary<TCacheKey, UnitOfWorkCacheItem<TCacheItem>> GetUnitOfWorkCache()
{
if (UnitOfWorkManager.Current == null)
{
throw new AbpException($"There is no active UOW.");
}
return UnitOfWorkManager.Current.GetOrAddItem(GetUnitOfWorkCacheKey(),
key => new Dictionary<TCacheKey, UnitOfWorkCacheItem<TCacheItem>>());
}
private readonly ConcurrentDictionary<Type, object> _serializersCache = new();
protected virtual IHybridCacheSerializer<TCacheItem> ResolveSerializer()
{
if (_serializersCache.TryGetValue(typeof(TCacheItem), out var serializer))
{
return serializer.As<IHybridCacheSerializer<TCacheItem>>();
}
serializer = ServiceProvider.GetService<IHybridCacheSerializer<TCacheItem>>();
if (serializer is null)
{
var factories = ServiceProvider.GetServices<IHybridCacheSerializerFactory>().ToArray();
Array.Reverse(factories);
foreach (var factory in factories)
{
if (factory.TryCreateSerializer<TCacheItem>(out var current))
{
serializer = current;
break;
}
}
}
if (serializer is null)
{
throw new InvalidOperationException($"No {nameof(IHybridCacheSerializer<TCacheItem>)} configured for type '{typeof(TCacheItem).Name}'");
}
return serializer.As<IHybridCacheSerializer<TCacheItem>>();
}
}

27
framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/AbpHybridCacheJsonSerializer.cs

@ -0,0 +1,27 @@
using System.Buffers;
using System.Text.Json;
using Microsoft.Extensions.Caching.Hybrid;
namespace Volo.Abp.Caching.Hybrid;
public class AbpHybridCacheJsonSerializer<T> : IHybridCacheSerializer<T>
{
protected JsonSerializerOptions JsonSerializerOptions { get; }
public AbpHybridCacheJsonSerializer(JsonSerializerOptions jsonSerializerOptions)
{
JsonSerializerOptions = jsonSerializerOptions;
}
public virtual T Deserialize(ReadOnlySequence<byte> source)
{
var reader = new Utf8JsonReader(source);
return JsonSerializer.Deserialize<T>(ref reader, JsonSerializerOptions)!;
}
public virtual void Serialize(T value, IBufferWriter<byte> target)
{
using var writer = new Utf8JsonWriter(target);
JsonSerializer.Serialize<T>(writer, value, JsonSerializerOptions);
}
}

27
framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/AbpHybridCacheJsonSerializerFactory.cs

@ -0,0 +1,27 @@
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Options;
using Volo.Abp.Json.SystemTextJson;
namespace Volo.Abp.Caching.Hybrid;
public class AbpHybridCacheJsonSerializerFactory : IHybridCacheSerializerFactory
{
protected IOptions<AbpSystemTextJsonSerializerOptions> Options { get; }
public AbpHybridCacheJsonSerializerFactory(IOptions<AbpSystemTextJsonSerializerOptions> options)
{
Options = options;
}
public bool TryCreateSerializer<T>(out IHybridCacheSerializer<T>? serializer)
{
if (typeof(T) == typeof(string) || typeof(T) == typeof(byte[]))
{
serializer = null;
return false;
}
serializer = new AbpHybridCacheJsonSerializer<T>(Options.Value.JsonSerializerOptions);
return true;
}
}

51
framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/AbpHybridCacheOptions.cs

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Hybrid;
namespace Volo.Abp.Caching.Hybrid;
public class AbpHybridCacheOptions
{
/// <summary>
/// Throw or hide exceptions for the distributed cache.
/// </summary>
public bool HideErrors { get; set; } = true;
/// <summary>
/// Cache key prefix.
/// </summary>
public string KeyPrefix { get; set; }
/// <summary>
/// Global Cache entry options.
/// </summary>
public HybridCacheEntryOptions GlobalHybridCacheEntryOptions { get; set; }
/// <summary>
/// List of all cache configurators.
/// (func argument:Name of cache)
/// </summary>
public List<Func<string, HybridCacheEntryOptions?>> CacheConfigurators { get; set; } //TODO: use a configurator interface instead?
public AbpHybridCacheOptions()
{
CacheConfigurators = new List<Func<string, HybridCacheEntryOptions?>>();
GlobalHybridCacheEntryOptions = new HybridCacheEntryOptions();
KeyPrefix = "";
}
public void ConfigureCache<TCacheItem>(HybridCacheEntryOptions? options)
{
ConfigureCache(typeof(TCacheItem), options);
}
public void ConfigureCache(Type cacheItemType, HybridCacheEntryOptions? options)
{
ConfigureCache(CacheNameAttribute.GetCacheName(cacheItemType), options);
}
public void ConfigureCache(string cacheName, HybridCacheEntryOptions? options)
{
CacheConfigurators.Add(name => cacheName != name ? null : options);
}
}

93
framework/src/Volo.Abp.Caching/Volo/Abp/Caching/Hybrid/IHybridCache.cs

@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.Extensions.Caching.Hybrid;
namespace Volo.Abp.Caching.Hybrid;
/// <summary>
/// Represents a hybrid cache of <typeparamref name="TCacheItem"/> items.
/// </summary>
/// <typeparam name="TCacheItem">The type of the cache item being cached.</typeparam>
public interface IHybridCache<TCacheItem> : IHybridCache<TCacheItem, string>
where TCacheItem : class
{
IHybridCache<TCacheItem, string> InternalCache { get; }
}
/// <summary>
/// Represents a hybrid cache of <typeparamref name="TCacheItem"/> items.
/// Uses <typeparamref name="TCacheKey"/> as the key type.
/// </summary>
/// <typeparam name="TCacheItem">The type of cache item being cached.</typeparam>
/// <typeparam name="TCacheKey">The type of cache key being used.</typeparam>
public interface IHybridCache<TCacheItem, TCacheKey>
where TCacheItem : class
{
/// <summary>
/// Gets or Creates a cache item with the given key. If no cache item is found for the given key then adds a cache item
/// provided by <paramref name="factory" /> delegate and returns the provided cache item.
/// </summary>
/// <param name="key">The key of cached item to be retrieved from the cache.</param>
/// <param name="factory">The factory delegate is used to provide the cache item when no cache item is found for the given <paramref name="key" />.</param>
/// <param name="optionsFactory">The cache options for the factory delegate.</param>
/// <param name="hideErrors">Indicates to throw or hide the exceptions for the distributed cache.</param>
/// <param name="considerUow">This will store the cache in the current unit of work until the end of the current unit of work does not really affect the cache.</param>
/// <param name="token">The <see cref="T:System.Threading.CancellationToken" /> for the task.</param>
/// <returns>The cache item.</returns>
Task<TCacheItem?> GetOrCreateAsync(
[NotNull]TCacheKey key,
Func<Task<TCacheItem>> factory,
Func<HybridCacheEntryOptions>? optionsFactory = null,
bool? hideErrors = null,
bool considerUow = false,
CancellationToken token = default);
/// <summary>
/// Sets the cache item value for the provided key.
/// </summary>
/// <param name="key">The key of cached item to be retrieved from the cache.</param>
/// <param name="value">The cache item value to set in the cache.</param>
/// <param name="options">The cache options for the value.</param>
/// <param name="hideErrors">Indicates to throw or hide the exceptions for the distributed cache.</param>
/// <param name="considerUow">This will store the cache in the current unit of work until the end of the current unit of work does not really affect the cache.</param>
/// <param name="token">The <see cref="T:System.Threading.CancellationToken" /> for the task.</param>
/// <returns>The <see cref="T:System.Threading.Tasks.Task" /> indicating that the operation is asynchronous.</returns>
Task SetAsync(
[NotNull]TCacheKey key,
TCacheItem value,
HybridCacheEntryOptions? options = null,
bool? hideErrors = null,
bool considerUow = false,
CancellationToken token = default);
/// <summary>
/// Removes the cache item for given key from cache.
/// </summary>
/// <param name="key">The key of cached item to be retrieved from the cache.</param>
/// <param name="hideErrors">Indicates to throw or hide the exceptions for the distributed cache.</param>
/// <param name="considerUow">This will store the cache in the current unit of work until the end of the current unit of work does not really affect the cache.</param>
/// <param name="token">The <see cref="T:System.Threading.CancellationToken" /> for the task.</param>
/// <returns>The <see cref="T:System.Threading.Tasks.Task" /> indicating that the operation is asynchronous.</returns>
Task RemoveAsync(
[NotNull]TCacheKey key,
bool? hideErrors = null,
bool considerUow = false,
CancellationToken token = default);
/// <summary>
/// Removes the cache items for given keys from cache.
/// </summary>
/// <param name="keys">The keys of cached items to be retrieved from the cache.</param>
/// <param name="hideErrors">Indicates to throw or hide the exceptions for the distributed cache.</param>
/// <param name="considerUow">This will store the cache in the current unit of work until the end of the current unit of work does not really affect the cache.</param>
/// <param name="token">The <see cref="T:System.Threading.CancellationToken" /> for the task.</param>
/// <returns>The <see cref="T:System.Threading.Tasks.Task" /> indicating that the operation is asynchronous.</returns>
Task RemoveManyAsync(
IEnumerable<TCacheKey> keys,
bool? hideErrors = null,
bool considerUow = false,
CancellationToken token = default);
}

25
framework/test/Volo.Abp.Caching.Tests/Volo/Abp/Caching/AbpCachingTestModule.cs

@ -1,7 +1,9 @@
using Microsoft.Extensions.Caching.Distributed;
using System;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Volo.Abp.Caching.Hybrid;
using Volo.Abp.Modularity;
namespace Volo.Abp.Caching;
@ -29,6 +31,29 @@ public class AbpCachingTestModule : AbpModule
option.GlobalCacheEntryOptions.SetSlidingExpiration(TimeSpan.FromMinutes(20));
});
Configure<AbpHybridCacheOptions>(option =>
{
option.CacheConfigurators.Add(cacheName =>
{
if (cacheName == CacheNameAttribute.GetCacheName(typeof(Sail.Testing.Caching.PersonCacheItem)))
{
return new HybridCacheEntryOptions()
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(5)
};
}
return null;
});
option.GlobalHybridCacheEntryOptions = new HybridCacheEntryOptions()
{
Expiration = TimeSpan.FromMinutes(20),
LocalCacheExpiration = TimeSpan.FromMinutes(10)
};
});
context.Services.Replace(ServiceDescriptor.Singleton<IDistributedCache, TestMemoryDistributedCache>());
}
}

519
framework/test/Volo.Abp.Caching.Tests/Volo/Abp/Caching/HybridCache_Tests.cs

@ -0,0 +1,519 @@
using System;
using System.Threading.Tasks;
using Shouldly;
using Volo.Abp.Caching.Hybrid;
using Volo.Abp.Testing;
using Volo.Abp.Uow;
using Xunit;
namespace Volo.Abp.Caching;
public class HybridCache_Tests : AbpIntegratedTest<AbpCachingTestModule>
{
[Fact]
public async Task Should_GetOrCreate_Set_And_Remove_Cache_Items()
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
var cacheKey = Guid.NewGuid().ToString();
//GetOrCreateAsync
var cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem("john nash")));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe("john nash");
//SetAsync
await personCache.SetAsync(cacheKey, new PersonCacheItem("baris"));
//GetOrCreateAsync
cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem("john nash")));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe("baris");
//RemoveAsync
await personCache.RemoveAsync(cacheKey);
//GetOrCreateAsync
cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem("lucas")));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe("lucas");
}
[Fact]
public async Task GetOrCreateAsync()
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
var cacheKey = Guid.NewGuid().ToString();
const string personName = "john nash";
//Will execute the factory method to create the cache item
bool factoryExecuted = false;
var cacheItem = await personCache.GetOrCreateAsync(cacheKey,
() =>
{
factoryExecuted = true;
return Task.FromResult(new PersonCacheItem(personName));
});
factoryExecuted.ShouldBeTrue();
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName);
//This time, it will not execute the factory
factoryExecuted = false;
cacheItem = await personCache.GetOrCreateAsync(cacheKey,
() =>
{
factoryExecuted = true;
return Task.FromResult(new PersonCacheItem(personName));
});
factoryExecuted.ShouldBeFalse();
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName);
}
[Fact]
public async Task SameClassName_But_DiffNamespace_Should_Not_Use_Same_Cache()
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
var otherPersonCache = GetRequiredService<IHybridCache<Sail.Testing.Caching.PersonCacheItem>>();
var cacheKey = Guid.NewGuid().ToString();
const string personName = "john nash";
var cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName);
var cacheItem1 = await otherPersonCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new Sail.Testing.Caching.PersonCacheItem(personName)));
cacheItem1.ShouldNotBeNull();
cacheItem1.Name.ShouldBe(personName);
await personCache.RemoveAsync(cacheKey);
cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem(personName + "1")));
cacheItem1 = await otherPersonCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new Sail.Testing.Caching.PersonCacheItem(personName + "1")));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName + "1");
cacheItem1.ShouldNotBeNull();
cacheItem1.Name.ShouldBe(personName);
}
[Fact]
public async Task Should_Set_Get_And_Remove_Cache_Items_With_Integer_Type_CacheKey()
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem, int>>();
var cacheKey = 42;
const string personName = "john nash";
//GetOrCreateAsync
var cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName);
//SetAsync
await personCache.SetAsync(cacheKey, new PersonCacheItem("baris"));
//GetOrCreateAsync
cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe("baris");
//RemoveAsync
await personCache.RemoveAsync(cacheKey);
//GetOrCreateAsync
cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem("lucas")));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe("lucas");
}
[Fact]
public async Task GetOrAddAsync_With_Integer_Type_CacheKey()
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem, int>>();
var cacheKey = 42;
const string personName = "john nash";
//Will execute the factory method to create the cache item
bool factoryExecuted = false;
var cacheItem = await personCache.GetOrCreateAsync(cacheKey,
() =>
{
factoryExecuted = true;
return Task.FromResult(new PersonCacheItem(personName));
});
factoryExecuted.ShouldBeTrue();
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName);
//This time, it will not execute the factory
factoryExecuted = false;
cacheItem = await personCache.GetOrCreateAsync(cacheKey,
() =>
{
factoryExecuted = true;
return Task.FromResult(new PersonCacheItem(personName));
});
factoryExecuted.ShouldBeFalse();
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName);
}
[Fact]
public async Task SameClassName_But_DiffNamespace_Should_Not_Use_Same_Cache_With_Integer_CacheKey()
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem, int>>();
var otherPersonCache = GetRequiredService<IHybridCache<Sail.Testing.Caching.PersonCacheItem, int>>();
var cacheKey = 42;
const string personName = "john nash";
var cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName);
var cacheItem1 = await otherPersonCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new Sail.Testing.Caching.PersonCacheItem(personName)));
cacheItem1.ShouldNotBeNull();
cacheItem1.Name.ShouldBe(personName);
await personCache.RemoveAsync(cacheKey);
cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem(personName + "1")));
cacheItem1 = await otherPersonCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new Sail.Testing.Caching.PersonCacheItem(personName + "1")));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName + "1");
cacheItem1.ShouldNotBeNull();
cacheItem1.Name.ShouldBe(personName);
}
[Fact]
public async Task Should_Set_Get_And_Remove_Cache_Items_With_Object_Type_CacheKey()
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem, ComplexObjectAsCacheKey>>();
var cacheKey = new ComplexObjectAsCacheKey { Name = "DummyData", Age = 42 };
const string personName = "john nash";
//GetOrCreateAsync
var cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe(personName);
//SetAsync
await personCache.SetAsync(cacheKey, new PersonCacheItem("baris"));
//GetOrCreateAsync
cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe("baris");
//RemoveAsync
await personCache.RemoveAsync(cacheKey);
//GetOrCreateAsync
cacheItem = await personCache.GetOrCreateAsync(cacheKey, () => Task.FromResult(new PersonCacheItem("lucas")));
cacheItem.ShouldNotBeNull();
cacheItem.Name.ShouldBe("lucas");
}
[Fact]
public async Task Should_Set_Get_And_Remove_Cache_Items_For_Same_Object_Type_With_Different_CacheKeys()
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem, ComplexObjectAsCacheKey>>();
var cache1Key = new ComplexObjectAsCacheKey { Name = "John", Age = 42 };
var cache2Key = new ComplexObjectAsCacheKey { Name = "Jenny", Age = 24 };
const string personName = "john nash";
//GetOrCreateAsync
var cacheItem1 = await personCache.GetOrCreateAsync(cache1Key, () => Task.FromResult(new PersonCacheItem(personName)));
var cacheItem2 = await personCache.GetOrCreateAsync(cache2Key, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem1.ShouldNotBeNull();
cacheItem1.Name.ShouldBe(personName);
cacheItem2.ShouldNotBeNull();
cacheItem2.Name.ShouldBe(personName);
//SetAsync
cacheItem1 = new PersonCacheItem("baris");
cacheItem2 = new PersonCacheItem("jack");
await personCache.SetAsync(cache1Key, cacheItem1);
await personCache.SetAsync(cache2Key, cacheItem2);
//GetOrCreateAsync
cacheItem1 = await personCache.GetOrCreateAsync(cache1Key, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem2 = await personCache.GetOrCreateAsync(cache2Key, () => Task.FromResult(new PersonCacheItem(personName)));
cacheItem1.ShouldNotBeNull();
cacheItem1.Name.ShouldBe("baris");
cacheItem2.ShouldNotBeNull();
cacheItem2.Name.ShouldBe("jack");
//Remove
await personCache.RemoveAsync(cache1Key);
await personCache.RemoveAsync(cache2Key);
//Get (not exists since removed)
cacheItem1 = await personCache.GetOrCreateAsync(cache1Key, () => Task.FromResult(new PersonCacheItem("lucas")));
cacheItem2 = await personCache.GetOrCreateAsync(cache2Key, () => Task.FromResult(new PersonCacheItem("peter")));
cacheItem1.ShouldNotBeNull();
cacheItem1.Name.ShouldBe("lucas");
cacheItem2.ShouldNotBeNull();
cacheItem2.Name.ShouldBe("peter");
}
[Fact]
public async Task Cache_Should_Only_Available_In_Uow_For_GetOrCreateAsync()
{
const string key = "testkey";
using (var uow = GetRequiredService<IUnitOfWorkManager>().Begin())
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
var cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
await personCache.SetAsync(key, new PersonCacheItem("lucas"), considerUow: true);
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john2")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("lucas");
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john3")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john3");
uow.OnCompleted(async () =>
{
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john4")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("lucas");
});
await uow.CompleteAsync();
}
}
[Fact]
public async Task Cache_Should_Rollback_With_Uow_For_GetOrCreateAsync()
{
const string key = "testkey";
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
var cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
using (var uow = GetRequiredService<IUnitOfWorkManager>().Begin())
{
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john2")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
await personCache.SetAsync(key, new PersonCacheItem("john3"), considerUow: true);
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john4")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john3");
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john5")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
}
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john6")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
}
[Fact]
public async Task Cache_Should_Only_Available_In_Uow_For_SetAsync()
{
const string key = "testkey";
using (var uow = GetRequiredService<IUnitOfWorkManager>().Begin())
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
await personCache.SetAsync(key, new PersonCacheItem("john"), considerUow: true);
var cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john2")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john3")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john3");
uow.OnCompleted(async () =>
{
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john4")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
});
await uow.CompleteAsync();
}
}
[Fact]
public async Task Cache_Should_Rollback_With_Uow_For_SetAsync()
{
const string key = "testkey";
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
var cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
using (var uow = GetRequiredService<IUnitOfWorkManager>().Begin())
{
await personCache.SetAsync(key, new PersonCacheItem("john2"), considerUow: true);
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john3")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john2");
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john4")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
}
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john5")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
}
[Fact]
public async Task Cache_Should_Only_Available_In_Uow_For_RemoveAsync()
{
const string key = "testkey";
using (var uow = GetRequiredService<IUnitOfWorkManager>().Begin())
{
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
await personCache.SetAsync(key, new PersonCacheItem("john"), considerUow: true);
var cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john2")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
await personCache.RemoveAsync(key, considerUow: true);
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john3")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john3");
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john4")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john4");
uow.OnCompleted(async () =>
{
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john5")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john3");
});
await uow.CompleteAsync();
}
}
public async Task Cache_Should_Rollback_With_Uow_For_RemoveAsync()
{
const string key = "testkey";
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
var cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
using (var uow = GetRequiredService<IUnitOfWorkManager>().Begin())
{
await personCache.SetAsync(key, new PersonCacheItem("john2"), considerUow: true);
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john2");
await personCache.RemoveAsync(key, considerUow: true);
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john3")), considerUow: true);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john3")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
}
cacheValue = await personCache.GetOrCreateAsync(key, () => Task.FromResult(new PersonCacheItem("john")), considerUow: false);
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
}
[Fact]
public async Task Should_Remove_Multiple_Items_Async()
{
var testkey = "testkey";
var testkey2 = "testkey2";
var testkey3 = new[] { testkey, testkey2 };
var personCache = GetRequiredService<IHybridCache<PersonCacheItem>>();
var cacheValue = await personCache.GetOrCreateAsync(testkey, () => Task.FromResult(new PersonCacheItem("john")));
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john");
cacheValue = await personCache.GetOrCreateAsync(testkey2, () => Task.FromResult(new PersonCacheItem("jack")));
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("jack");
await personCache.RemoveManyAsync(testkey3);
cacheValue = await personCache.GetOrCreateAsync(testkey, () => Task.FromResult(new PersonCacheItem("john2")));
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("john2");
cacheValue = await personCache.GetOrCreateAsync(testkey2, () => Task.FromResult(new PersonCacheItem("jack2")));
cacheValue.ShouldNotBeNull();
cacheValue.Name.ShouldBe("jack2");
}
[Fact]
public async Task Should_Get_Same_Cache_Set_When_Resolve_With_Or_Without_Key()
{
var cache1 = GetRequiredService<IHybridCache<PersonCacheItem>>();
var cache2 = GetRequiredService<IHybridCache<PersonCacheItem, string>>();
cache1.InternalCache.ShouldBe(cache2);
await cache1.SetAsync("john", new PersonCacheItem("john"));
var item1 = await cache1.GetOrCreateAsync("john", () => Task.FromResult(new PersonCacheItem("john2")));
item1.ShouldNotBeNull();
item1.Name.ShouldBe("john");
var item2 = await cache1.GetOrCreateAsync("john", () => Task.FromResult(new PersonCacheItem("john3")));
item2.ShouldNotBeNull();
item2.Name.ShouldBe("john");
}
}
Loading…
Cancel
Save