Browse Source

Add FindManyAsDictionaryAsync/GetManyAsDictionaryAsync to IEntityCache and add notnull constraint to TKey

pull/25090/head
maliming 2 weeks ago
parent
commit
8240e01060
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 27
      docs/en/framework/infrastructure/entity-cache.md
  2. 53
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs
  3. 4
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs
  4. 1
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs
  5. 1
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs
  6. 1
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs
  7. 17
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs
  8. 94
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityCache_Tests.cs

27
docs/en/framework/infrastructure/entity-cache.md

@ -149,7 +149,11 @@ context.Services.AddEntityCache<Product, ProductDto, Guid>(
## Getting Multiple Entities
In addition to the single-entity methods `FindAsync` and `GetAsync`, the `IEntityCache` service also provides `FindManyAsync` and `GetManyAsync` methods for retrieving multiple entities at once:
In addition to the single-entity methods `FindAsync` and `GetAsync`, the `IEntityCache` service provides batch retrieval methods for retrieving multiple entities at once.
### List-Based Batch Retrieval
`FindManyAsync` and `GetManyAsync` return results as a list, preserving the order of the given IDs (including duplicates):
```csharp
public class ProductAppService : ApplicationService, IProductAppService
@ -176,7 +180,26 @@ public class ProductAppService : ApplicationService, IProductAppService
* `GetManyAsync` throws `EntityNotFoundException` if any entity is not found for the given IDs.
* `FindManyAsync` returns a list where each entry corresponds to the given ID in the same order; an entry will be `null` if the entity was not found.
Both methods internally use `IDistributedCache.GetOrAddManyAsync` to batch-fetch only the cache-missed entities from the database, making them more efficient than calling `FindAsync` or `GetAsync` in a loop.
### Dictionary-Based Batch Retrieval
`FindManyAsDictionaryAsync` and `GetManyAsDictionaryAsync` return a `Dictionary<TKey, TEntityCacheItem>` keyed by entity ID, which is convenient when you need fast lookup by ID:
```csharp
public async Task<Dictionary<Guid, ProductDto?>> FindManyAsDictionaryAsync(List<Guid> ids)
{
return await _productCache.FindManyAsDictionaryAsync(ids);
}
public async Task<Dictionary<Guid, ProductDto>> GetManyAsDictionaryAsync(List<Guid> ids)
{
return await _productCache.GetManyAsDictionaryAsync(ids);
}
```
* `GetManyAsDictionaryAsync` throws `EntityNotFoundException` if any entity is not found for the given IDs.
* `FindManyAsDictionaryAsync` returns a dictionary where the value is `null` if the entity was not found for the corresponding key.
All batch methods internally use `IDistributedCache.GetOrAddManyAsync` to batch-fetch only the cache-missed entities from the database, making them more efficient than calling `FindAsync` or `GetAsync` in a loop.
## Custom Object Mapping

53
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs

@ -16,6 +16,7 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
ILocalEventHandler<EntityChangedEventData<TEntity>>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
protected IReadOnlyRepository<TEntity, TKey> Repository { get; }
protected IDistributedCache<EntityCacheItemWrapper<TEntityCacheItem>, TKey> Cache { get; }
@ -49,17 +50,17 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
public virtual async Task<List<TEntityCacheItem?>> FindManyAsync(IEnumerable<TKey> ids)
{
var idArray = ids.ToArray();
var distinctIds = idArray.Distinct().ToArray();
var cacheItems = await GetOrAddManyCacheItemsAsync(distinctIds);
#pragma warning disable CS8714
var cacheItemDict = cacheItems.ToDictionary(x => x.Key, x => x.Value);
#pragma warning restore CS8714
var cacheItemDict = await GetCacheItemDictionaryAsync(idArray.Distinct().ToArray());
return idArray
.Select(id => cacheItemDict.TryGetValue(id!, out var wrapper) ? wrapper?.Value : null)
.Select(id => cacheItemDict.TryGetValue(id, out var item) ? item : null)
.ToList();
}
public virtual async Task<Dictionary<TKey, TEntityCacheItem?>> FindManyAsDictionaryAsync(IEnumerable<TKey> ids)
{
return await GetCacheItemDictionaryAsync(ids.Distinct().ToArray());
}
public virtual async Task<TEntityCacheItem> GetAsync(TKey id)
{
return (await Cache.GetOrAddAsync(
@ -78,26 +79,40 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
public virtual async Task<List<TEntityCacheItem>> GetManyAsync(IEnumerable<TKey> ids)
{
var idArray = ids.ToArray();
var distinctIds = idArray.Distinct().ToArray();
var cacheItems = await GetOrAddManyCacheItemsAsync(distinctIds);
#pragma warning disable CS8714
var cacheItemDict = cacheItems.ToDictionary(x => x.Key, x => x.Value);
#pragma warning restore CS8714
var cacheItemDict = await GetCacheItemDictionaryAsync(idArray.Distinct().ToArray());
return idArray
.Select(id =>
{
var cacheItem = cacheItemDict.TryGetValue(id!, out var wrapper) ? wrapper?.Value : null;
if (cacheItem == null)
if (!cacheItemDict.TryGetValue(id, out var item) || item == null)
{
throw new EntityNotFoundException<TEntity>(id);
}
return cacheItem;
return item;
})
.ToList();
}
public virtual async Task<Dictionary<TKey, TEntityCacheItem>> GetManyAsDictionaryAsync(IEnumerable<TKey> ids)
{
var cacheItemDict = await GetCacheItemDictionaryAsync(ids.Distinct().ToArray());
var result = new Dictionary<TKey, TEntityCacheItem>();
foreach (var pair in cacheItemDict)
{
if (pair.Value == null)
{
throw new EntityNotFoundException<TEntity>(pair.Key);
}
result[pair.Key] = pair.Value;
}
return result;
}
protected virtual async Task<Dictionary<TKey, TEntityCacheItem?>> GetCacheItemDictionaryAsync(TKey[] distinctIds)
{
var cacheItems = await GetOrAddManyCacheItemsAsync(distinctIds);
return cacheItems.ToDictionary(x => x.Key, x => x.Value?.Value);
}
protected virtual async Task<KeyValuePair<TKey, EntityCacheItemWrapper<TEntityCacheItem>?>[]> GetOrAddManyCacheItemsAsync(TKey[] ids)
{
return await Cache.GetOrAddManyAsync(
@ -114,14 +129,12 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
x => missingKeyArray.Contains(x.Id),
includeDetails: true
);
#pragma warning disable CS8714
var entityDict = entities.ToDictionary(e => e.Id);
#pragma warning restore CS8714
return missingKeyArray
.Select(key =>
{
entityDict.TryGetValue(key!, out var entity);
entityDict.TryGetValue(key, out var entity);
return new KeyValuePair<TKey, EntityCacheItemWrapper<TEntityCacheItem>>(
key,
MapToCacheItem(entity)!

4
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs

@ -14,6 +14,7 @@ public static class EntityCacheServiceCollectionExtensions
this IServiceCollection services,
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntity, TKey>, EntityCacheWithoutCacheItem<TEntity, TKey>>();
services.TryAddTransient<EntityCacheWithoutCacheItem<TEntity, TKey>>();
@ -36,6 +37,7 @@ public static class EntityCacheServiceCollectionExtensions
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntityCacheItem, TKey>, EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>>();
services.TryAddTransient<EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>>();
@ -53,6 +55,7 @@ public static class EntityCacheServiceCollectionExtensions
DistributedCacheEntryOptions? cacheOptions = null)
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.TryAddTransient<IEntityCache<TEntityCacheItem, TKey>, EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, TEntityCacheItem, TKey>>();
services.TryAddTransient<EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, TEntityCacheItem, TKey>>();
@ -71,6 +74,7 @@ public static class EntityCacheServiceCollectionExtensions
where TEntityCache : EntityCacheBase<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
services.Replace(ServiceDescriptor.Transient<IEntityCache<TEntityCacheItem, TKey>, TEntityCache>());
services.TryAddTransient<TEntityCache>();

1
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs

@ -10,6 +10,7 @@ public class EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey> :
EntityCacheBase<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
protected IObjectMapper ObjectMapper { get; }

1
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs

@ -9,6 +9,7 @@ public class EntityCacheWithObjectMapperContext<TObjectMapperContext, TEntity, T
EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
where TKey : notnull
{
public EntityCacheWithObjectMapperContext(
IReadOnlyRepository<TEntity, TKey> repository,

1
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs

@ -7,6 +7,7 @@ namespace Volo.Abp.Domain.Entities.Caching;
public class EntityCacheWithoutCacheItem<TEntity, TKey> :
EntityCacheBase<TEntity, TEntity, TKey>
where TEntity : Entity<TKey>
where TKey : notnull
{
public EntityCacheWithoutCacheItem(
IReadOnlyRepository<TEntity, TKey> repository,

17
framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs

@ -4,8 +4,9 @@ using JetBrains.Annotations;
namespace Volo.Abp.Domain.Entities.Caching;
public interface IEntityCache<TEntityCacheItem, in TKey>
public interface IEntityCache<TEntityCacheItem, TKey>
where TEntityCacheItem : class
where TKey : notnull
{
/// <summary>
/// Gets the entity with given <paramref name="id"/>,
@ -20,11 +21,17 @@ public interface IEntityCache<TEntityCacheItem, in TKey>
/// </summary>
Task<List<TEntityCacheItem?>> FindManyAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/> as a dictionary keyed by id.
/// An entry will be null if the entity was not found for the corresponding id.
/// </summary>
Task<Dictionary<TKey, TEntityCacheItem?>> FindManyAsDictionaryAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets the entity with given <paramref name="id"/>,
/// or throws <see cref="EntityNotFoundException"/> if the entity was not found.
/// </summary>
[ItemNotNull]
[ItemNotNull]
Task<TEntityCacheItem> GetAsync(TKey id);
/// <summary>
@ -33,4 +40,10 @@ public interface IEntityCache<TEntityCacheItem, in TKey>
/// Throws <see cref="EntityNotFoundException"/> if any entity was not found.
/// </summary>
Task<List<TEntityCacheItem>> GetManyAsync(IEnumerable<TKey> ids);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/> as a dictionary keyed by id.
/// Throws <see cref="EntityNotFoundException"/> if any entity was not found.
/// </summary>
Task<Dictionary<TKey, TEntityCacheItem>> GetManyAsDictionaryAsync(IEnumerable<TKey> ids);
}

94
framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityCache_Tests.cs

@ -236,6 +236,100 @@ public abstract class EntityCache_Tests<TStartupModule> : TestAppTestBase<TStart
product.Name.ShouldBe("PRODUCT1");
}
[Fact]
public async Task FindManyAsDictionary_Should_Return_Null_For_Not_Existing_Entities()
{
var notExistId = Guid.NewGuid();
var result = await ProductEntityCache.FindManyAsDictionaryAsync(new[] { notExistId });
result.Count.ShouldBe(1);
result.ContainsKey(notExistId).ShouldBeTrue();
result[notExistId].ShouldBeNull();
var cacheItemResult = await ProductCacheItem.FindManyAsDictionaryAsync(new[] { notExistId });
cacheItemResult.Count.ShouldBe(1);
cacheItemResult.ContainsKey(notExistId).ShouldBeTrue();
cacheItemResult[notExistId].ShouldBeNull();
}
[Fact]
public async Task GetManyAsDictionary_Should_Throw_EntityNotFoundException_For_Not_Existing_Entities()
{
var notExistId = Guid.NewGuid();
await Assert.ThrowsAsync<EntityNotFoundException<Product>>(() => ProductEntityCache.GetManyAsDictionaryAsync(new[] { notExistId }));
await Assert.ThrowsAsync<EntityNotFoundException<Product>>(() => ProductCacheItem.GetManyAsDictionaryAsync(new[] { notExistId }));
}
[Fact]
public async Task FindManyAsDictionary_Should_Return_EntityCache()
{
var notExistId = Guid.NewGuid();
var ids = new[] { TestDataBuilder.ProductId, notExistId };
var products = await ProductEntityCache.FindManyAsDictionaryAsync(ids);
products.Count.ShouldBe(2);
products[TestDataBuilder.ProductId].ShouldNotBeNull();
products[TestDataBuilder.ProductId]!.Id.ShouldBe(TestDataBuilder.ProductId);
products[TestDataBuilder.ProductId]!.Name.ShouldBe("Product1");
products[TestDataBuilder.ProductId]!.Price.ShouldBe(decimal.One);
products[notExistId].ShouldBeNull();
// Call again to test caching
products = await ProductEntityCache.FindManyAsDictionaryAsync(ids);
products.Count.ShouldBe(2);
products[TestDataBuilder.ProductId].ShouldNotBeNull();
var productCacheItems = await ProductCacheItem.FindManyAsDictionaryAsync(ids);
productCacheItems.Count.ShouldBe(2);
productCacheItems[TestDataBuilder.ProductId].ShouldNotBeNull();
productCacheItems[TestDataBuilder.ProductId]!.Id.ShouldBe(TestDataBuilder.ProductId);
productCacheItems[TestDataBuilder.ProductId]!.Name.ShouldBe("Product1");
productCacheItems[TestDataBuilder.ProductId]!.Price.ShouldBe(decimal.One);
productCacheItems[notExistId].ShouldBeNull();
}
[Fact]
public async Task GetManyAsDictionary_Should_Return_EntityCache()
{
var products = await ProductEntityCache.GetManyAsDictionaryAsync(new[] { TestDataBuilder.ProductId });
products.Count.ShouldBe(1);
products[TestDataBuilder.ProductId].Id.ShouldBe(TestDataBuilder.ProductId);
products[TestDataBuilder.ProductId].Name.ShouldBe("Product1");
products[TestDataBuilder.ProductId].Price.ShouldBe(decimal.One);
// Call again to test caching
products = await ProductEntityCache.GetManyAsDictionaryAsync(new[] { TestDataBuilder.ProductId });
products.Count.ShouldBe(1);
products[TestDataBuilder.ProductId].Id.ShouldBe(TestDataBuilder.ProductId);
var productCacheItems = await ProductCacheItem.GetManyAsDictionaryAsync(new[] { TestDataBuilder.ProductId });
productCacheItems.Count.ShouldBe(1);
productCacheItems[TestDataBuilder.ProductId].Id.ShouldBe(TestDataBuilder.ProductId);
productCacheItems[TestDataBuilder.ProductId].Name.ShouldBe("Product1");
productCacheItems[TestDataBuilder.ProductId].Price.ShouldBe(decimal.One);
}
[Fact]
public async Task FindManyAsDictionary_Should_Handle_Duplicate_Ids()
{
var ids = new[] { TestDataBuilder.ProductId, TestDataBuilder.ProductId };
var products = await ProductEntityCache.FindManyAsDictionaryAsync(ids);
products.Count.ShouldBe(1);
products[TestDataBuilder.ProductId].ShouldNotBeNull();
products[TestDataBuilder.ProductId]!.Id.ShouldBe(TestDataBuilder.ProductId);
}
[Fact]
public async Task GetManyAsDictionary_Should_Handle_Duplicate_Ids()
{
var ids = new[] { TestDataBuilder.ProductId, TestDataBuilder.ProductId };
var products = await ProductEntityCache.GetManyAsDictionaryAsync(ids);
products.Count.ShouldBe(1);
products[TestDataBuilder.ProductId].Id.ShouldBe(TestDataBuilder.ProductId);
}
[Fact]
public void EntityCache_Default_Options_Should_Be_2_Minutes()
{

Loading…
Cancel
Save