diff --git a/docs/en/framework/infrastructure/entity-cache.md b/docs/en/framework/infrastructure/entity-cache.md index 16adef5986..e7540eec19 100644 --- a/docs/en/framework/infrastructure/entity-cache.md +++ b/docs/en/framework/infrastructure/entity-cache.md @@ -149,7 +149,11 @@ context.Services.AddEntityCache( ## 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` keyed by entity ID, which is convenient when you need fast lookup by ID: + +```csharp +public async Task> FindManyAsDictionaryAsync(List ids) +{ + return await _productCache.FindManyAsDictionaryAsync(ids); +} + +public async Task> GetManyAsDictionaryAsync(List 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 diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs index 6fff3cf67c..5ba2c047c0 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs +++ b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs @@ -16,6 +16,7 @@ public abstract class EntityCacheBase : ILocalEventHandler> where TEntity : Entity where TEntityCacheItem : class + where TKey : notnull { protected IReadOnlyRepository Repository { get; } protected IDistributedCache, TKey> Cache { get; } @@ -49,17 +50,17 @@ public abstract class EntityCacheBase : public virtual async Task> FindManyAsync(IEnumerable 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> FindManyAsDictionaryAsync(IEnumerable ids) + { + return await GetCacheItemDictionaryAsync(ids.Distinct().ToArray()); + } + public virtual async Task GetAsync(TKey id) { return (await Cache.GetOrAddAsync( @@ -78,26 +79,40 @@ public abstract class EntityCacheBase : public virtual async Task> GetManyAsync(IEnumerable 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(id); } - - return cacheItem; + return item; }) .ToList(); } + public virtual async Task> GetManyAsDictionaryAsync(IEnumerable ids) + { + var cacheItemDict = await GetCacheItemDictionaryAsync(ids.Distinct().ToArray()); + var result = new Dictionary(); + foreach (var pair in cacheItemDict) + { + if (pair.Value == null) + { + throw new EntityNotFoundException(pair.Key); + } + result[pair.Key] = pair.Value; + } + return result; + } + + protected virtual async Task> GetCacheItemDictionaryAsync(TKey[] distinctIds) + { + var cacheItems = await GetOrAddManyCacheItemsAsync(distinctIds); + return cacheItems.ToDictionary(x => x.Key, x => x.Value?.Value); + } + protected virtual async Task?>[]> GetOrAddManyCacheItemsAsync(TKey[] ids) { return await Cache.GetOrAddManyAsync( @@ -114,14 +129,12 @@ public abstract class EntityCacheBase : 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>( key, MapToCacheItem(entity)! diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs index 2919b6b0d5..febdbd07f9 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs +++ b/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 + where TKey : notnull { services.TryAddTransient, EntityCacheWithoutCacheItem>(); services.TryAddTransient>(); @@ -36,6 +37,7 @@ public static class EntityCacheServiceCollectionExtensions DistributedCacheEntryOptions? cacheOptions = null) where TEntity : Entity where TEntityCacheItem : class + where TKey : notnull { services.TryAddTransient, EntityCacheWithObjectMapper>(); services.TryAddTransient>(); @@ -53,6 +55,7 @@ public static class EntityCacheServiceCollectionExtensions DistributedCacheEntryOptions? cacheOptions = null) where TEntity : Entity where TEntityCacheItem : class + where TKey : notnull { services.TryAddTransient, EntityCacheWithObjectMapperContext>(); services.TryAddTransient>(); @@ -71,6 +74,7 @@ public static class EntityCacheServiceCollectionExtensions where TEntityCache : EntityCacheBase where TEntity : Entity where TEntityCacheItem : class + where TKey : notnull { services.Replace(ServiceDescriptor.Transient, TEntityCache>()); services.TryAddTransient(); diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs index 472443e307..359b9ad716 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs +++ b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs @@ -10,6 +10,7 @@ public class EntityCacheWithObjectMapper : EntityCacheBase where TEntity : Entity where TEntityCacheItem : class + where TKey : notnull { protected IObjectMapper ObjectMapper { get; } diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs index 2471e1057c..4121c2aeae 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs +++ b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapperContext.cs @@ -9,6 +9,7 @@ public class EntityCacheWithObjectMapperContext where TEntity : Entity where TEntityCacheItem : class + where TKey : notnull { public EntityCacheWithObjectMapperContext( IReadOnlyRepository repository, diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs index 4738534831..ecd3973685 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithoutCacheItem.cs +++ b/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 : EntityCacheBase where TEntity : Entity + where TKey : notnull { public EntityCacheWithoutCacheItem( IReadOnlyRepository repository, diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs index 3441d7bdcc..202d7ca51b 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs +++ b/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 +public interface IEntityCache where TEntityCacheItem : class + where TKey : notnull { /// /// Gets the entity with given , @@ -20,11 +21,17 @@ public interface IEntityCache /// Task> FindManyAsync(IEnumerable ids); + /// + /// Gets multiple entities with the given as a dictionary keyed by id. + /// An entry will be null if the entity was not found for the corresponding id. + /// + Task> FindManyAsDictionaryAsync(IEnumerable ids); + /// /// Gets the entity with given , /// or throws if the entity was not found. /// - [ItemNotNull] + [ItemNotNull] Task GetAsync(TKey id); /// @@ -33,4 +40,10 @@ public interface IEntityCache /// Throws if any entity was not found. /// Task> GetManyAsync(IEnumerable ids); + + /// + /// Gets multiple entities with the given as a dictionary keyed by id. + /// Throws if any entity was not found. + /// + Task> GetManyAsDictionaryAsync(IEnumerable ids); } diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityCache_Tests.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityCache_Tests.cs index d4f278d468..de5340b8d8 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityCache_Tests.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityCache_Tests.cs @@ -236,6 +236,100 @@ public abstract class EntityCache_Tests : TestAppTestBase>(() => ProductEntityCache.GetManyAsDictionaryAsync(new[] { notExistId })); + await Assert.ThrowsAsync>(() => 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() {