Browse Source

Merge pull request #25088 from abpframework/copilot/add-example-for-entity-cache

Add `FindManyAsync`/`GetManyAsync` to `IEntityCache` and extract `MapToValue` virtual method
pull/25090/head
Ma Liming 2 weeks ago
committed by GitHub
parent
commit
fdcd6fe60c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 90
      docs/en/framework/infrastructure/entity-cache.md
  2. 74
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs
  3. 26
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheServiceCollectionExtensions.cs
  4. 9
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs
  5. 21
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs
  6. 7
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/TestAppModule.cs
  7. 183
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityCache_Tests.cs

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

@ -26,7 +26,7 @@ public class Product : AggregateRoot<Guid>
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<Guid>
{
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<Product, ProductDto, Guid>(
* 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<ProductDto, Guid> _productCache;
public ProductAppService(IEntityCache<ProductDto, Guid> productCache)
{
_productCache = productCache;
}
public async Task<List<ProductDto>> GetManyAsync(List<Guid> ids)
{
return await _productCache.GetManyAsync(ids);
}
public async Task<List<ProductDto?>> FindManyAsync(List<Guid> 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<Product, ProductCacheDto, Guid>
{
public ProductEntityCache(
IReadOnlyRepository<Product, Guid> repository,
IDistributedCache<EntityCacheItemWrapper<ProductCacheDto>, 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<ProductEntityCache, Product, ProductCacheDto, Guid>(
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)

74
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<TEntity, TEntityCacheItem, TKey> :
}))?.Value;
}
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
return idArray
.Select(id => cacheItemDict.TryGetValue(id!, out var wrapper) ? wrapper?.Value : null)
.ToList();
}
public virtual async Task<TEntityCacheItem> GetAsync(TKey id)
{
return (await Cache.GetOrAddAsync(
@ -59,6 +75,62 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
}))!.Value!;
}
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
return idArray
.Select(id =>
{
var cacheItem = cacheItemDict.TryGetValue(id!, out var wrapper) ? wrapper?.Value : null;
if (cacheItem == null)
{
throw new EntityNotFoundException<TEntity>(id);
}
return cacheItem;
})
.ToList();
}
protected virtual async Task<KeyValuePair<TKey, EntityCacheItemWrapper<TEntityCacheItem>?>[]> 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<TKey, EntityCacheItemWrapper<TEntityCacheItem>>(
key,
MapToCacheItem(entity)!
);
})
.ToList();
});
}
protected virtual bool HasObjectExtensionInfo()
{
return typeof(IHasExtraProperties).IsAssignableFrom(typeof(TEntity)) &&

26
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<TEntityCache, TEntity, TEntityCacheItem, TKey>(
this IServiceCollection services,
DistributedCacheEntryOptions? cacheOptions = null)
where TEntityCache : EntityCacheBase<TEntity, TEntityCacheItem, TKey>
where TEntity : Entity<TKey>
where TEntityCacheItem : class
{
services.Replace(ServiceDescriptor.Transient<IEntityCache<TEntityCacheItem, TKey>, TEntityCache>());
services.TryAddTransient<TEntityCache>();
services.Configure<AbpDistributedCacheOptions>(options =>
{
options.ConfigureCache<EntityCacheItemWrapper<TEntityCacheItem>>(cacheOptions ?? GetDefaultCacheOptions());
});
if (typeof(TEntity) == typeof(TEntityCacheItem))
{
services.Configure<AbpSystemTextJsonSerializerModifiersOptions>(options =>
{
options.Modifiers.Add(new AbpIncludeNonPublicPropertiesModifiers<TEntity, TKey>().CreateModifyAction(x => x.Id));
});
}
return services;
}
private static DistributedCacheEntryOptions GetDefaultCacheOptions()
{
return new DistributedCacheEntryOptions {

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

@ -30,11 +30,16 @@ public class EntityCacheWithObjectMapper<TEntity, TEntityCacheItem, TKey> :
return null;
}
return new EntityCacheItemWrapper<TEntityCacheItem>(MapToValue(entity));
}
protected virtual TEntityCacheItem MapToValue(TEntity entity)
{
if (typeof(TEntity) == typeof(TEntityCacheItem))
{
return new EntityCacheItemWrapper<TEntityCacheItem>(entity.As<TEntityCacheItem>());
return entity.As<TEntityCacheItem>();
}
return new EntityCacheItemWrapper<TEntityCacheItem>(ObjectMapper.Map<TEntity, TEntityCacheItem>(entity));
return ObjectMapper.Map<TEntity, TEntityCacheItem>(entity);
}
}

21
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<TEntityCacheItem, in TKey>
/// or returns null if the entity was not found.
/// </summary>
Task<TEntityCacheItem?> FindAsync(TKey id);
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/>.
/// 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.
/// </summary>
Task<List<TEntityCacheItem?>> FindManyAsync(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]
Task<TEntityCacheItem> GetAsync(TKey id);
}
/// <summary>
/// Gets multiple entities with the given <paramref name="ids"/>.
/// Returns a list where each entry corresponds to the given id in the same order.
/// Throws <see cref="EntityNotFoundException"/> if any entity was not found.
/// </summary>
Task<List<TEntityCacheItem>> GetManyAsync(IEnumerable<TKey> ids);
}

7
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<Product, ProductCacheItem2, Guid>();
// Test ReplaceEntityCache: first add default, then replace with custom implementation
context.Services.AddEntityCache<Product, CustomProductCacheItem, Guid>();
context.Services.ReplaceEntityCache<CustomProductEntityCache, Product, CustomProductCacheItem, Guid>();
// Test ReplaceEntityCache without prior registration
context.Services.ReplaceEntityCache<CustomProductEntityCacheWithoutPriorRegistration, Product, CustomProductCacheItemWithoutPriorRegistration, Guid>();
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)

183
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<TStartupModule> : TestAppTestBase<TStart
protected readonly IRepository<Product, Guid> ProductRepository;
protected readonly IEntityCache<Product, Guid> ProductEntityCache;
protected readonly IEntityCache<ProductCacheItem, Guid> ProductCacheItem;
protected readonly IEntityCache<CustomProductCacheItem, Guid> CustomProductCacheItem;
protected readonly IEntityCache<CustomProductCacheItemWithoutPriorRegistration, Guid> CustomProductCacheItemWithoutPriorRegistration;
protected EntityCache_Tests()
{
ProductRepository = GetRequiredService<IRepository<Product, Guid>>();
ProductEntityCache = GetRequiredService<IEntityCache<Product, Guid>>();
ProductCacheItem = GetRequiredService<IEntityCache<ProductCacheItem, Guid>>();
CustomProductCacheItem = GetRequiredService<IEntityCache<CustomProductCacheItem, Guid>>();
CustomProductCacheItemWithoutPriorRegistration = GetRequiredService<IEntityCache<CustomProductCacheItemWithoutPriorRegistration, Guid>>();
}
[Fact]
@ -35,6 +41,19 @@ public abstract class EntityCache_Tests<TStartupModule> : TestAppTestBase<TStart
(await ProductCacheItem.FindAsync(notExistId)).ShouldBeNull();
}
[Fact]
public async Task FindMany_Should_Return_Null_For_Not_Existing_Entities()
{
var notExistId = Guid.NewGuid();
var result = await ProductEntityCache.FindManyAsync(new[] { notExistId });
result.Count.ShouldBe(1);
result[0].ShouldBeNull();
var cacheItemResult = await ProductCacheItem.FindManyAsync(new[] { notExistId });
cacheItemResult.Count.ShouldBe(1);
cacheItemResult[0].ShouldBeNull();
}
[Fact]
public async Task Should_Throw_EntityNotFoundException_IF_Entity_Not_Exist()
{
@ -43,6 +62,14 @@ public abstract class EntityCache_Tests<TStartupModule> : TestAppTestBase<TStart
await Assert.ThrowsAsync<EntityNotFoundException<Product>>(() => ProductCacheItem.GetAsync(notExistId));
}
[Fact]
public async Task GetMany_Should_Throw_EntityNotFoundException_For_Not_Existing_Entities()
{
var notExistId = Guid.NewGuid();
await Assert.ThrowsAsync<EntityNotFoundException<Product>>(() => ProductEntityCache.GetManyAsync(new[] { notExistId }));
await Assert.ThrowsAsync<EntityNotFoundException<Product>>(() => ProductCacheItem.GetManyAsync(new[] { notExistId }));
}
[Fact]
public async Task Should_Return_EntityCache()
{
@ -63,6 +90,56 @@ public abstract class EntityCache_Tests<TStartupModule> : TestAppTestBase<TStart
productCacheItem.Price.ShouldBe(decimal.One);
}
[Fact]
public async Task FindMany_Should_Return_EntityCache()
{
var notExistId = Guid.NewGuid();
var ids = new[] { TestDataBuilder.ProductId, notExistId };
var products = await ProductEntityCache.FindManyAsync(ids);
products.Count.ShouldBe(2);
products[0].ShouldNotBeNull();
products[0]!.Id.ShouldBe(TestDataBuilder.ProductId);
products[0]!.Name.ShouldBe("Product1");
products[0]!.Price.ShouldBe(decimal.One);
products[1].ShouldBeNull();
// Call again to test caching
products = await ProductEntityCache.FindManyAsync(ids);
products.Count.ShouldBe(2);
products[0].ShouldNotBeNull();
products[0]!.Id.ShouldBe(TestDataBuilder.ProductId);
var productCacheItems = await ProductCacheItem.FindManyAsync(ids);
productCacheItems.Count.ShouldBe(2);
productCacheItems[0].ShouldNotBeNull();
productCacheItems[0]!.Id.ShouldBe(TestDataBuilder.ProductId);
productCacheItems[0]!.Name.ShouldBe("Product1");
productCacheItems[0]!.Price.ShouldBe(decimal.One);
productCacheItems[1].ShouldBeNull();
}
[Fact]
public async Task GetMany_Should_Return_EntityCache()
{
var products = await ProductEntityCache.GetManyAsync(new[] { TestDataBuilder.ProductId });
products.Count.ShouldBe(1);
products[0].Id.ShouldBe(TestDataBuilder.ProductId);
products[0].Name.ShouldBe("Product1");
products[0].Price.ShouldBe(decimal.One);
// Call again to test caching
products = await ProductEntityCache.GetManyAsync(new[] { TestDataBuilder.ProductId });
products.Count.ShouldBe(1);
products[0].Id.ShouldBe(TestDataBuilder.ProductId);
var productCacheItems = await ProductCacheItem.GetManyAsync(new[] { TestDataBuilder.ProductId });
productCacheItems.Count.ShouldBe(1);
productCacheItems[0].Id.ShouldBe(TestDataBuilder.ProductId);
productCacheItems[0].Name.ShouldBe("Product1");
productCacheItems[0].Price.ShouldBe(decimal.One);
}
[Fact]
public async Task Should_Return_Null_IF_Deleted()
{
@ -119,6 +196,46 @@ public abstract class EntityCache_Tests<TStartupModule> : TestAppTestBase<TStart
productCacheItem.Price.ShouldBe(decimal.Zero);
}
[Fact]
public async Task FindMany_Should_Handle_Duplicate_Ids()
{
var ids = new[] { TestDataBuilder.ProductId, TestDataBuilder.ProductId };
var products = await ProductEntityCache.FindManyAsync(ids);
products.Count.ShouldBe(2);
products[0].ShouldNotBeNull();
products[0]!.Id.ShouldBe(TestDataBuilder.ProductId);
products[1].ShouldNotBeNull();
products[1]!.Id.ShouldBe(TestDataBuilder.ProductId);
}
[Fact]
public async Task GetMany_Should_Handle_Duplicate_Ids()
{
var ids = new[] { TestDataBuilder.ProductId, TestDataBuilder.ProductId };
var products = await ProductEntityCache.GetManyAsync(ids);
products.Count.ShouldBe(2);
products[0].Id.ShouldBe(TestDataBuilder.ProductId);
products[1].Id.ShouldBe(TestDataBuilder.ProductId);
}
[Fact]
public async Task ReplaceEntityCache_Should_Use_Custom_Mapping()
{
var product = await CustomProductCacheItem.FindAsync(TestDataBuilder.ProductId);
product.ShouldNotBeNull();
product.Name.ShouldBe("PRODUCT1");
}
[Fact]
public async Task ReplaceEntityCache_Without_Prior_Registration_Should_Work()
{
var product = await CustomProductCacheItemWithoutPriorRegistration.FindAsync(TestDataBuilder.ProductId);
product.ShouldNotBeNull();
product.Name.ShouldBe("PRODUCT1");
}
[Fact]
public void EntityCache_Default_Options_Should_Be_2_Minutes()
{
@ -204,3 +321,69 @@ public class ProductCacheItem2
public decimal Price { get; set; }
}
[Serializable]
[CacheName("CustomProductCacheItem")]
public class CustomProductCacheItem
{
public Guid Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
public class CustomProductEntityCache : EntityCacheWithObjectMapper<Product, CustomProductCacheItem, Guid>
{
public CustomProductEntityCache(
IReadOnlyRepository<Product, Guid> repository,
IDistributedCache<EntityCacheItemWrapper<CustomProductCacheItem>, 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<Product, CustomProductCacheItemWithoutPriorRegistration, Guid>
{
public CustomProductEntityCacheWithoutPriorRegistration(
IReadOnlyRepository<Product, Guid> repository,
IDistributedCache<EntityCacheItemWrapper<CustomProductCacheItemWithoutPriorRegistration>, 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
};
}
}

Loading…
Cancel
Save