diff --git a/docs/en/framework/infrastructure/entity-cache.md b/docs/en/framework/infrastructure/entity-cache.md index 50f1bc1b3c..16adef5986 100644 --- a/docs/en/framework/infrastructure/entity-cache.md +++ b/docs/en/framework/infrastructure/entity-cache.md @@ -26,7 +26,7 @@ public class Product : AggregateRoot public string Name { get; set; } public string Description { get; set; } - public float Price { get; set; } + public decimal Price { get; set; } public int StockCount { get; set; } } ``` @@ -72,7 +72,7 @@ public class ProductDto : EntityDto { public string Name { get; set; } public string Description { get; set; } - public float Price { get; set; } + public decimal Price { get; set; } public int StockCount { get; set; } } ``` @@ -147,6 +147,92 @@ context.Services.AddEntityCache( * Entity classes should be serializable/deserializable to/from JSON to be cached (because it's serialized to JSON when saving in the [Distributed Cache](../fundamentals/caching.md)). If your entity class is not serializable, you can consider using a cache-item/DTO class instead, as explained before. * Entity Caching System is designed as **read-only**. You should use the standard [repository](../architecture/domain-driven-design/repositories.md) methods to manipulate the entity if you need to. If you need to manipulate (update) the entity, do not get it from the entity cache. Instead, read it from the repository, change it and update using the repository. +## 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: + +```csharp +public class ProductAppService : ApplicationService, IProductAppService +{ + private readonly IEntityCache _productCache; + + public ProductAppService(IEntityCache productCache) + { + _productCache = productCache; + } + + public async Task> GetManyAsync(List ids) + { + return await _productCache.GetManyAsync(ids); + } + + public async Task> FindManyAsync(List ids) + { + return await _productCache.FindManyAsync(ids); + } +} +``` + +* `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. + +## Custom Object Mapping + +When you need full control over how an entity is mapped to a cache item, you can derive from `EntityCacheWithObjectMapper` and override the `MapToValue` method: + +First, define the cache item class: + +```csharp +public class ProductCacheDto +{ + public Guid Id { get; set; } + public string Name { get; set; } + public decimal Price { get; set; } +} +``` + +Then, derive from `EntityCacheWithObjectMapper` and override `MapToValue`: + +```csharp +public class ProductEntityCache : + EntityCacheWithObjectMapper +{ + public ProductEntityCache( + IReadOnlyRepository repository, + IDistributedCache, Guid> cache, + IUnitOfWorkManager unitOfWorkManager, + IObjectMapper objectMapper) + : base(repository, cache, unitOfWorkManager, objectMapper) + { + } + + protected override ProductCacheDto MapToValue(Product entity) + { + // Custom mapping logic here + return new ProductCacheDto + { + Id = entity.Id, + Name = entity.Name.ToUpperInvariant(), + Price = entity.Price + }; + } +} +``` + +Register your custom cache class in the `ConfigureServices` method of your [module class](../architecture/modularity/basics.md): + +```csharp +context.Services.ReplaceEntityCache( + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) + }); +``` + +> If no prior `AddEntityCache` registration exists for the same cache item type, `ReplaceEntityCache` will simply add the service instead of throwing an error. + ## See Also * [Distributed caching](../fundamentals/caching.md) 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 7cea931c12..6fff3cf67c 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 @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Volo.Abp.Caching; using Volo.Abp.Data; using Volo.Abp.Domain.Entities.Events; @@ -44,6 +46,20 @@ public abstract class EntityCacheBase : }))?.Value; } + 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 + + return idArray + .Select(id => cacheItemDict.TryGetValue(id!, out var wrapper) ? wrapper?.Value : null) + .ToList(); + } + public virtual async Task GetAsync(TKey id) { return (await Cache.GetOrAddAsync( @@ -59,6 +75,62 @@ public abstract class EntityCacheBase : }))!.Value!; } + 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 + + return idArray + .Select(id => + { + var cacheItem = cacheItemDict.TryGetValue(id!, out var wrapper) ? wrapper?.Value : null; + if (cacheItem == null) + { + throw new EntityNotFoundException(id); + } + + return cacheItem; + }) + .ToList(); + } + + protected virtual async Task?>[]> GetOrAddManyCacheItemsAsync(TKey[] ids) + { + return await Cache.GetOrAddManyAsync( + ids, + async missingKeys => + { + if (HasObjectExtensionInfo()) + { + Repository.EnableTracking(); + } + + var missingKeyArray = missingKeys.ToArray(); + var entities = await Repository.GetListAsync( + 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); + return new KeyValuePair>( + key, + MapToCacheItem(entity)! + ); + }) + .ToList(); + }); + } + protected virtual bool HasObjectExtensionInfo() { return typeof(IHasExtraProperties).IsAssignableFrom(typeof(TEntity)) && 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 c35406b45b..2919b6b0d5 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 @@ -65,6 +65,32 @@ public static class EntityCacheServiceCollectionExtensions return services; } + public static IServiceCollection ReplaceEntityCache( + this IServiceCollection services, + DistributedCacheEntryOptions? cacheOptions = null) + where TEntityCache : EntityCacheBase + where TEntity : Entity + where TEntityCacheItem : class + { + services.Replace(ServiceDescriptor.Transient, TEntityCache>()); + services.TryAddTransient(); + + services.Configure(options => + { + options.ConfigureCache>(cacheOptions ?? GetDefaultCacheOptions()); + }); + + if (typeof(TEntity) == typeof(TEntityCacheItem)) + { + services.Configure(options => + { + options.Modifiers.Add(new AbpIncludeNonPublicPropertiesModifiers().CreateModifyAction(x => x.Id)); + }); + } + + return services; + } + private static DistributedCacheEntryOptions GetDefaultCacheOptions() { return new DistributedCacheEntryOptions { 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 62b3162cdd..472443e307 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 @@ -30,11 +30,16 @@ public class EntityCacheWithObjectMapper : return null; } + return new EntityCacheItemWrapper(MapToValue(entity)); + } + + protected virtual TEntityCacheItem MapToValue(TEntity entity) + { if (typeof(TEntity) == typeof(TEntityCacheItem)) { - return new EntityCacheItemWrapper(entity.As()); + return entity.As(); } - return new EntityCacheItemWrapper(ObjectMapper.Map(entity)); + return ObjectMapper.Map(entity); } } 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 bd8ba80a63..3441d7bdcc 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 @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; using JetBrains.Annotations; namespace Volo.Abp.Domain.Entities.Caching; @@ -11,11 +12,25 @@ public interface IEntityCache /// or returns null if the entity was not found. /// Task FindAsync(TKey id); - + + /// + /// Gets multiple entities with the given . + /// 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 for the corresponding id. + /// + Task> FindManyAsync(IEnumerable ids); + /// /// Gets the entity with given , /// or throws if the entity was not found. /// [ItemNotNull] Task GetAsync(TKey id); -} \ No newline at end of file + + /// + /// Gets multiple entities with the given . + /// Returns a list where each entry corresponds to the given id in the same order. + /// Throws if any entity was not found. + /// + Task> GetManyAsync(IEnumerable ids); +} diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/TestAppModule.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/TestAppModule.cs index 438bf4b3b9..466d3c9dd7 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/TestAppModule.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/TestAppModule.cs @@ -39,6 +39,13 @@ public class TestAppModule : AbpModule AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(9) }); context.Services.AddEntityCache(); + + // Test ReplaceEntityCache: first add default, then replace with custom implementation + context.Services.AddEntityCache(); + context.Services.ReplaceEntityCache(); + + // Test ReplaceEntityCache without prior registration + context.Services.ReplaceEntityCache(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) 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 6ef8da9b7c..d4f278d468 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 @@ -9,6 +9,8 @@ using Volo.Abp.Domain.Entities.Auditing; using Volo.Abp.Domain.Entities.Caching; using Volo.Abp.Domain.Repositories; using Volo.Abp.Modularity; +using Volo.Abp.ObjectMapping; +using Volo.Abp.Uow; using Xunit; namespace Volo.Abp.TestApp.Testing; @@ -19,12 +21,16 @@ public abstract class EntityCache_Tests : TestAppTestBase ProductRepository; protected readonly IEntityCache ProductEntityCache; protected readonly IEntityCache ProductCacheItem; + protected readonly IEntityCache CustomProductCacheItem; + protected readonly IEntityCache CustomProductCacheItemWithoutPriorRegistration; protected EntityCache_Tests() { ProductRepository = GetRequiredService>(); ProductEntityCache = GetRequiredService>(); ProductCacheItem = GetRequiredService>(); + CustomProductCacheItem = GetRequiredService>(); + CustomProductCacheItemWithoutPriorRegistration = GetRequiredService>(); } [Fact] @@ -35,6 +41,19 @@ public abstract class EntityCache_Tests : TestAppTestBase : TestAppTestBase>(() => ProductCacheItem.GetAsync(notExistId)); } + [Fact] + public async Task GetMany_Should_Throw_EntityNotFoundException_For_Not_Existing_Entities() + { + var notExistId = Guid.NewGuid(); + await Assert.ThrowsAsync>(() => ProductEntityCache.GetManyAsync(new[] { notExistId })); + await Assert.ThrowsAsync>(() => ProductCacheItem.GetManyAsync(new[] { notExistId })); + } + [Fact] public async Task Should_Return_EntityCache() { @@ -63,6 +90,56 @@ public abstract class EntityCache_Tests : TestAppTestBase : TestAppTestBase +{ + public CustomProductEntityCache( + IReadOnlyRepository repository, + IDistributedCache, Guid> cache, + IUnitOfWorkManager unitOfWorkManager, + IObjectMapper objectMapper) + : base(repository, cache, unitOfWorkManager, objectMapper) + { + } + + protected override CustomProductCacheItem MapToValue(Product entity) + { + return new CustomProductCacheItem + { + Id = entity.Id, + Name = entity.Name.ToUpperInvariant(), + Price = entity.Price + }; + } +} + +[Serializable] +[CacheName("CustomProductCacheItemWithoutPriorRegistration")] +public class CustomProductCacheItemWithoutPriorRegistration +{ + public Guid Id { get; set; } + + public string Name { get; set; } + + public decimal Price { get; set; } +} + +public class CustomProductEntityCacheWithoutPriorRegistration : EntityCacheWithObjectMapper +{ + public CustomProductEntityCacheWithoutPriorRegistration( + IReadOnlyRepository repository, + IDistributedCache, Guid> cache, + IUnitOfWorkManager unitOfWorkManager, + IObjectMapper objectMapper) + : base(repository, cache, unitOfWorkManager, objectMapper) + { + } + + protected override CustomProductCacheItemWithoutPriorRegistration MapToValue(Product entity) + { + return new CustomProductCacheItemWithoutPriorRegistration + { + Id = entity.Id, + Name = entity.Name.ToUpperInvariant(), + Price = entity.Price + }; + } +}