Browse Source

Add GetManyAsync/FindManyAsync to IEntityCache and extract MapToValue virtual method

Co-authored-by: hikalkan <1210527+hikalkan@users.noreply.github.com>
pull/25088/head
copilot-swe-agent[bot] 2 weeks ago
parent
commit
8a0b5c2d4c
  1. 77
      docs/en/framework/infrastructure/entity-cache.md
  2. 62
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheBase.cs
  3. 9
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/EntityCacheWithObjectMapper.cs
  4. 19
      framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Caching/IEntityCache.cs
  5. 71
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityCache_Tests.cs

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

@ -147,6 +147,83 @@ 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:
```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.TryAddTransient<IEntityCache<ProductCacheDto, Guid>, ProductEntityCache>();
context.Services.TryAddTransient<ProductEntityCache>();
context.Services.Configure<AbpDistributedCacheOptions>(options =>
{
options.ConfigureCache<EntityCacheItemWrapper<ProductCacheDto>>(
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
});
```
## See Also
* [Distributed caching](../fundamentals/caching.md)

62
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,16 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
}))?.Value;
}
public virtual async Task<List<TEntityCacheItem?>> FindManyAsync(IEnumerable<TKey> ids)
{
var idArray = ids.ToArray();
var cacheItems = await GetOrAddManyCacheItemsAsync(idArray);
return idArray
.Select(id => cacheItems.FirstOrDefault(x => EqualityComparer<TKey>.Default.Equals(x.Key, id)).Value?.Value)
.ToList();
}
public virtual async Task<TEntityCacheItem> GetAsync(TKey id)
{
return (await Cache.GetOrAddAsync(
@ -59,6 +71,54 @@ public abstract class EntityCacheBase<TEntity, TEntityCacheItem, TKey> :
}))!.Value!;
}
public virtual async Task<List<TEntityCacheItem>> GetManyAsync(IEnumerable<TKey> ids)
{
var idArray = ids.ToArray();
var cacheItems = await GetOrAddManyCacheItemsAsync(idArray);
return idArray
.Select(id =>
{
var cacheItem = cacheItems.FirstOrDefault(x => EqualityComparer<TKey>.Default.Equals(x.Key, id)).Value?.Value;
if (cacheItem == null)
{
throw new EntityNotFoundException(typeof(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)
);
return missingKeyArray
.Select(key =>
{
var entity = entities.FirstOrDefault(e => EqualityComparer<TKey>.Default.Equals(e.Id, key));
return new KeyValuePair<TKey, EntityCacheItemWrapper<TEntityCacheItem>>(
key,
MapToCacheItem(entity)!
);
})
.ToList();
});
}
protected virtual bool HasObjectExtensionInfo()
{
return typeof(IHasExtraProperties).IsAssignableFrom(typeof(TEntity)) &&

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);
}
}

19
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);
}

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

@ -35,6 +35,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 +56,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>(() => ProductEntityCache.GetManyAsync(new[] { notExistId }));
await Assert.ThrowsAsync<EntityNotFoundException>(() => ProductCacheItem.GetManyAsync(new[] { notExistId }));
}
[Fact]
public async Task Should_Return_EntityCache()
{
@ -63,6 +84,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()
{

Loading…
Cancel
Save