不同的存储库模式实现





5.00/5 (5投票s)
重新思考存储库模式
引言
经典的存储库模式是实现应用程序中数据库访问的一种简单方法。它满足了小型应用程序的许多常规设计目标。另一方面,CQS和CQRS为更大、更复杂的应用程序提供了更复杂但结构良好的设计模式。
在本文中,我将开发基本的存储库模式,应用CQS中一些基本的好实践,并实现一个完全通用的提供程序。
这不是在DotNetCore中带有几个附加功能的、千篇一律的IRepository
实现。
- 没有为每个实体类单独实现。你不会看到这个
public class WeatherForecastRepository : GenericRepository<WeatherForecast>, IWeatherForcastRepository { public WeatherForecastRepository(DbContextClass dbContext) : base(dbContext) {} } public interface IProductRepository : IGenericRepository<WeatherForecast> { }
- 没有单独的
UnitOfWork
类:它已内置。 - 所有标准数据I/O都使用单个数据代理。
- 设计中使用了CQS请求、结果和处理程序模式。
命名、术语和实践
- DI:依赖注入
- CQS:命令/查询分离
代码是:
- Net7.0
- C# 10
- 启用了可空引用类型
Repo
Repo和本文最新版本在此处:Blazr.IRepository。
数据存储
该解决方案需要一个真实的数据存储进行测试:它实现了一个Entity Framework内存数据库。
我是一名Blazor开发者,所以我测试数据类是WeatherForecast
。数据提供程序的代码在附录中。
这是DBContext
工厂使用的DbContext
。
public sealed class InMemoryWeatherDbContext : DbContext
{
public DbSet<WeatherForecast> WeatherForecast { get; set; } = default!;
public InMemoryWeatherDbContext
(DbContextOptions<InMemoryWeatherDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<WeatherForecast>().ToTable("WeatherForecast");
}
测试工厂和上下文
以下XUnit
测试演示了DI中的基本数据存储设置。它
- 设置DI容器。
- 从测试提供程序加载数据。
- 测试记录数是否正确。
- 测试任意一条记录是否正确。
[Fact]
public async Task DBContextTest()
{
// Gets the control test data
var testProvider = WeatherTestDataProvider.Instance();
// Build our services container
var services = new ServiceCollection();
// Define the DbSet and Server Type for the DbContext Factory
services.AddDbContextFactory<InMemoryWeatherDbContext>(options
=> options.UseInMemoryDatabase($"WeatherDatabase-{Guid.NewGuid().ToString()}"));
var rootProvider = services.BuildServiceProvider();
//define a scoped container
var providerScope = rootProvider.CreateScope();
var provider = providerScope.ServiceProvider;
// get the DbContext factory and add the test data
var factory = provider.GetService<IDbContextFactory<InMemoryWeatherDbContext>>();
if (factory is not null)
WeatherTestDataProvider.Instance().LoadDbContext
<InMemoryWeatherDbContext>(factory);
// Check the data has been loaded
var dbContext = factory!.CreateDbContext();
Assert.NotNull(dbContext);
var count = dbContext.Set<WeatherForecast>().Count();
Assert.Equal(testProvider.WeatherForecasts.Count(), count);
// Test an arbitrary record
var testRecord = testProvider.GetRandomRecord()!;
var record = await dbContext.Set<WeatherForecast>().SingleOrDefaultAsync
(item => item.Uid.Equals(testRecord.Uid));
Assert.Equal(testRecord, record);
// Dispose of the resources correctly
providerScope.Dispose();
rootProvider.Dispose();
}
经典存储库模式实现
这是我在网上找到的一个简洁的实现。
public abstract class Repository<T> : IRepository<T> where T : class
{
protected readonly DbContextClass _dbContext;
protected GenericRepository(DbContextClass context)
=> _dbContext = context;
public async Task<T> GetById(int id)
=> await _dbContext.Set<T>().FindAsync(id);
public async Task<IEnumerable<T>> GetAll()
=> await _dbContext.Set<T>().ToListAsync();
public async Task Add(T entity)
=> await _dbContext.Set<T>().AddAsync(entity);
public void Delete(T entity)
=> _dbContext.Set<T>().Remove(entity);
public void Update(T entity)
=> _dbContext.Set<T>().Update(entity);
}
}
剖析一下
- 返回
null
时会发生什么,它意味着什么? - 那个
add
/update
/delete
真的成功了吗?我怎么知道? - 如何处理取消令牌?现在大多数
async
方法都接受一个取消令牌。 - 当你的
DBSet
包含一百万条记录时会发生什么(也许DBA昨晚做错了什么)? - 我的应用程序中有一个数据存储实体对应一个这样的实现。
实现
请求和结果
请求对象封装了我们要请求的内容,结果对象封装了我们期望返回的数据和状态信息。它们是records
:定义一次然后消费。
Commands
命令是对数据存储进行更改的请求:Create
/Update
/Delete
操作。我们可以这样定义一个
public record CommandRequest<TRecord>
{
public required TRecord Item { get; init; }
public CancellationToken Cancellation { get; set; } = new ();
}
命令只返回状态信息:不返回数据。我们可以这样定义一个结果
public record CommandResult
{
public bool Successful { get; init; }
public string Message { get; init; } = string.Empty;
private CommandResult() { }
public static CommandResult Success(string? message = null)
=> new CommandResult { Successful = true, Message= message ?? string.Empty };
public static CommandResult Failure(string message)
=> new CommandResult { Message = message};
}
此时,值得注意的是返回规则的一个小例外:插入记录的Id
。如果你不使用Guid
为记录提供唯一标识符,那么数据库生成的Id
就是状态信息。
项请求
查询是从数据存储获取数据的请求:不进行变异。我们可以这样定义一个项查询
public sealed record ItemQueryRequest
{
public required Guid Uid { get; init; }
public CancellationToken Cancellation { get; set; } = new();
}
以及返回的结果:请求的数据和状态。
public sealed record ItemQueryResult<TRecord>
{
public TRecord? Item { get; init;}
public bool Successful { get; init; }
public string Message { get; init; } = string.Empty;
private ItemQueryResult() { }
public static ItemQueryResult<TRecord>
Success(TRecord Item, string? message = null)
=> new ItemQueryResult<TRecord>
{ Successful=true, Item= Item, Message= message ?? string.Empty };
public static ItemQueryResult<TRecord> Failure(string message)
=> new ItemQueryResult<TRecord> { Message = message};
}
列表查询
列表查询带来了一些额外的挑战
- 它们永远不应请求所有内容。在极端情况下,一个表可能有1,000,000+行。每个请求都应受到限制。请求定义了
StartIndex
和PageSize
来限制数据并提供分页。如果你将页面大小设置为1,000,000,你的数据管道和前端能优雅地处理它吗? - 它们需要处理排序和过滤。请求将这些定义为Linq表达式。
public sealed record ListQueryRequest<TRecord>
{
public int StartIndex { get; init; } = 0;
public int PageSize { get; init; } = 1000;
public CancellationToken Cancellation { get; set; } = new ();
public bool SortDescending { get; } = false;
public Expression<Func<TRecord, bool>>? FilterExpression { get; init; }
public Expression<Func<TRecord, object>>? SortExpression { get; init; }
}
结果返回项、总项数(用于分页)和状态信息。Items
始终作为IEnumerable
返回。
public sealed record ListQueryResult<TRecord>
{
public IEnumerable<TRecord> Items { get; init;} = Enumerable.Empty<TRecord>();
public bool Successful { get; init; }
public string Message { get; init; } = string.Empty;
public long TotalCount { get; init; }
private ListQueryResult() { }
public static ListQueryResult<TRecord> Success
(IEnumerable<TRecord> Items, long totalCount, string? message = null)
=> new ListQueryResult<TRecord> {Successful=true, Items= Items,
TotalCount = totalCount, Message= message ?? string.Empty };
public static ListQueryResult<TRecord> Failure(string message)
=> new ListQueryResult<TRecord> { Message = message};
}
处理程序
处理程序是小型、单一目的的类,用于处理请求并返回结果。它们将底层细节从更高级别的数据代理中抽象出来。
命令处理器
该接口提供了抽象。
public interface ICreateRequestHandler
{
public ValueTask<CommandResult> ExecuteAsync<TRecord>
(CommandRequest<TRecord> request)
where TRecord : class, new();
}
实现执行实际工作。
- 注入
DBContext
工厂。 - 通过
DbContext
工厂实现单元工作DbContext。 - 使用上下文的
Add
方法将记录添加到EF。 - 调用
SaveChangesAsync
,传入取消令牌,并期望报告一次更改。 - 如果出现问题,提供状态信息。
public sealed class CreateRequestHandler<TDbContext>
: ICreateRequestHandler
where TDbContext : DbContext
{
private readonly IDbContextFactory<TDbContext> _factory;
public CreateRequestHandler(IDbContextFactory<TDbContext> factory)
=> _factory = factory;
public async ValueTask<CommandResult> ExecuteAsync<TRecord>
(CommandRequest<TRecord> request)
where TRecord : class, new()
{
if (request == null)
throw new DataPipelineException
($"No CommandRequest defined in {this.GetType().FullName}");
using var dbContext = _factory.CreateDbContext();
dbContext.Add<TRecord>(request.Item);
return await dbContext.SaveChangesAsync(request.Cancellation) == 1
? CommandResult.Success("Record Updated")
: CommandResult.Failure("Error updating Record");
}
}
Update
和Delete
处理程序相同,但使用不同的dbContext
方法:Update
和Remove
。
项请求处理程序
接口。
public interface IItemRequestHandler
{
public ValueTask<ItemQueryResult<TRecord>> ExecuteAsync<TRecord>
(ItemQueryRequest request)
where TRecord : class, new();
}
以及服务器实现。请注意
- 注入
DBContext
工厂。 - 通过
DbContext
工厂实现单元工作DbContext。 - 关闭跟踪。此事务不涉及变异。
- 检查是否可以使用
Id
获取项 - 记录实现了IGuidIdentity
。 - 如果不行,尝试
FindAsync
,它使用内置的Key
方法来获取记录。 - 如果出现问题,提供状态信息。
public sealed class ItemRequestHandler<TDbContext>
: IItemRequestHandler
where TDbContext : DbContext
{
private readonly IDbContextFactory<TDbContext> _factory;
public ItemRequestHandler(IDbContextFactory<TDbContext> factory)
=> _factory = factory;
public async ValueTask<ItemQueryResult<TRecord>>
ExecuteAsync<TRecord>(ItemQueryRequest request)
where TRecord : class, new()
{
if (request == null)
throw new DataPipelineException
($"No ListQueryRequest defined in {this.GetType().FullName}");
using var dbContext = _factory.CreateDbContext();
dbContext.ChangeTracker.QueryTrackingBehavior =
QueryTrackingBehavior.NoTracking;
TRecord? record = null;
// first check if the record implements IGuidIdentity.
// If so, we can do a cast and then do the query via the Uid property directly.
if ((new TRecord()) is IGuidIdentity)
record = await dbContext.Set<TRecord>().SingleOrDefaultAsync
(item => ((IGuidIdentity)item).Uid == request.Uid, request.Cancellation);
// Try and use the EF FindAsync implementation
if (record is null)
record = await dbContext.FindAsync<TRecord>(request.Uid);
if (record is null)
return ItemQueryResult<TRecord>.Failure
("No record retrieved");
return ItemQueryResult<TRecord>.Success(record);
}
}
列表请求处理程序
接口。
public interface IListRequestHandler
{
public ValueTask<ListQueryResult<TRecord>> ExecuteAsync<TRecord>
(ListQueryRequest<TRecord> request)
where TRecord : class, new();
}
以及实现。
请注意有两个内部方法
_getItemsAsync
获取项。这会构建一个IQueryable
对象并返回一个已物化的IEnumerable
。在工厂处理DbContext
之前,您必须执行查询。_getCountAsync
根据过滤器获取所有记录的计数。
private async ValueTask<IEnumerable<TRecord>> _getItemsAsync<TRecord>
(ListQueryRequest<TRecord> request)
where TRecord : class, new()
{
if (request == null)
throw new DataPipelineException
($"No ListQueryRequest defined in {this.GetType().FullName}");
using var dbContext = _factory.CreateDbContext();
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
IQueryable<TRecord> query = dbContext.Set<TRecord>();
if (request.FilterExpression is not null)
query = query
.Where(request.FilterExpression)
.AsQueryable();
if (request.SortExpression is not null)
query = request.SortDescending
? query.OrderByDescending(request.SortExpression)
: query.OrderBy(request.SortExpression);
if (request.PageSize > 0)
query = query
.Skip(request.StartIndex)
.Take(request.PageSize);
return query is IAsyncEnumerable<TRecord>
? await query.ToListAsync()
: query.ToList();
}
存储库类替换
首先是接口。
非常重要的一点是每个方法上的泛型TRecord
定义,而不是接口上的。这消除了对特定实体实现的需要。
public interface IDataBroker
{
public ValueTask<ListQueryResult<TRecord>> GetItemsAsync<TRecord>
(ListQueryRequest<TRecord> request) where TRecord : class, new();
public ValueTask<ItemQueryResult<TRecord>> GetItemAsync<TRecord>
(ItemQueryRequest request) where TRecord : class, new();
public ValueTask<CommandResult> UpdateItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new();
public ValueTask<CommandResult> CreateItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new();
public ValueTask<CommandResult> DeleteItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new();
}
以及实现。每个处理程序都在DI中注册,并注入到代理中。
public sealed class RepositoryDataBroker : IDataBroker
{
private readonly IListRequestHandler _listRequestHandler;
private readonly IItemRequestHandler _itemRequestHandler;
private readonly IUpdateRequestHandler _updateRequestHandler;
private readonly ICreateRequestHandler _createRequestHandler;
private readonly IDeleteRequestHandler _deleteRequestHandler;
public RepositoryDataBroker(
IListRequestHandler listRequestHandler,
IItemRequestHandler itemRequestHandler,
ICreateRequestHandler createRequestHandler,
IUpdateRequestHandler updateRequestHandler,
IDeleteRequestHandler deleteRequestHandler)
{
_listRequestHandler = listRequestHandler;
_itemRequestHandler = itemRequestHandler;
_createRequestHandler = createRequestHandler;
_updateRequestHandler = updateRequestHandler;
_deleteRequestHandler = deleteRequestHandler;
}
public ValueTask<ItemQueryResult<TRecord>> GetItemAsync<TRecord>
(ItemQueryRequest request) where TRecord : class, new()
=> _itemRequestHandler.ExecuteAsync<TRecord>(request);
public ValueTask<ListQueryResult<TRecord>> GetItemsAsync<TRecord>
(ListQueryRequest<TRecord> request) where TRecord : class, new()
=> _listRequestHandler.ExecuteAsync<TRecord>(request);
public ValueTask<CommandResult> CreateItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new()
=> _createRequestHandler.ExecuteAsync<TRecord>(request);
public ValueTask<CommandResult> UpdateItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new()
=> _updateRequestHandler.ExecuteAsync<TRecord>(request);
public ValueTask<CommandResult> DeleteItemAsync<TRecord>
(CommandRequest<TRecord> request) where TRecord : class, new()
=> _deleteRequestHandler.ExecuteAsync<TRecord>(request);
}
测试数据代理
我们现在可以为数据代理定义一组测试。这里包含了两个。其余的在Repo中。
前两个方法用于创建根DI容器并填充数据库。
private ServiceProvider BuildRootContainer()
{
var services = new ServiceCollection();
// Define the DbSet and Server Type for the DbContext Factory
services.AddDbContextFactory<InMemoryWeatherDbContext>(options
=> options.UseInMemoryDatabase($"WeatherDatabase-{Guid.NewGuid().ToString()}"));
// Define the Broker and Handlers
services.AddScoped<IDataBroker, RepositoryDataBroker>();
services.AddScoped<IListRequestHandler,
ListRequestHandler<InMemoryWeatherDbContext>>();
services.AddScoped<IItemRequestHandler,
ItemRequestHandler<InMemoryWeatherDbContext>>();
services.AddScoped<IUpdateRequestHandler,
UpdateRequestHandler<InMemoryWeatherDbContext>>();
services.AddScoped<ICreateRequestHandler,
CreateRequestHandler<InMemoryWeatherDbContext>>();
services.AddScoped<IDeleteRequestHandler,
DeleteRequestHandler<InMemoryWeatherDbContext>>();
// Create the container
return services.BuildServiceProvider();
}
private IDbContextFactory<InMemoryWeatherDbContext>
GetPopulatedFactory(IServiceProvider provider)
{
// get the DbContext factory and add the test data
var factory = provider.GetService<IDbContextFactory<InMemoryWeatherDbContext>>();
if (factory is not null)
WeatherTestDataProvider.Instance().LoadDbContext
<InMemoryWeatherDbContext>(factory);
return factory!;
}
GetItems
测试
[Fact]
public async Task GetItemsTest()
{
// Get our test provider to use as our control
var testProvider = WeatherTestDataProvider.Instance();
// Build the root DI Container
var rootProvider = this.BuildRootContainer();
//define a scoped container
var providerScope = rootProvider.CreateScope();
var provider = providerScope.ServiceProvider;
// get the DbContext factory and add the test data
var factory = this.GetPopulatedFactory(provider);
// Check we can retrieve the first 1000 records
var dbContext = factory!.CreateDbContext();
Assert.NotNull(dbContext);
var databroker = provider.GetRequiredService<IDataBroker>();
var request = new ListQueryRequest<WeatherForecast>();
var result = await databroker.GetItemsAsync<WeatherForecast>(request);
Assert.NotNull(result);
Assert.Equal(testProvider.WeatherForecasts.Count(), result.TotalCount);
providerScope.Dispose();
rootProvider.Dispose();
}
AddItem
测试
[Fact]
public async Task AddItemTest()
{
// Get our test provider to use as our control
var testProvider = WeatherTestDataProvider.Instance();
// Build the root DI Container
var rootProvider = this.BuildRootContainer();
//define a scoped container
var providerScope = rootProvider.CreateScope();
var provider = providerScope.ServiceProvider;
// get the DbContext factory and add the test data
var factory = this.GetPopulatedFactory(provider);
// Check we can retrieve the first 1000 records
var dbContext = factory!.CreateDbContext();
Assert.NotNull(dbContext);
var databroker = provider.GetRequiredService<IDataBroker>();
// Create a Test record
var newRecord = new WeatherForecast { Uid = Guid.NewGuid(),
Date = DateOnly.FromDateTime(DateTime.Now),
TemperatureC = 50, Summary = "Add Testing" };
// Add the Record
{
var request = new CommandRequest<WeatherForecast>() { Item = newRecord };
var result = await databroker.CreateItemAsync<WeatherForecast>(request);
Assert.NotNull(result);
Assert.True(result.Successful);
}
// Get the new record
{
var request = new ItemQueryRequest() { Uid = newRecord.Uid };
var result = await databroker.GetItemAsync<WeatherForecast>(request);
Assert.Equal(newRecord, result.Item);
}
// Check the record count has incremented
{
var request = new ListQueryRequest<WeatherForecast>();
var result = await databroker.GetItemsAsync<WeatherForecast>(request);
Assert.NotNull(result);
Assert.Equal(testProvider.WeatherForecasts.Count() + 1, result.TotalCount);
}
providerScope.Dispose();
rootProvider.Dispose();
}
总结
我在这里呈现的是一个混合的存储库模式。它保持了存储库模式的简洁性,并增加了一些CQS模式的最佳特性。
将底层的EF和Linq代码抽象到单独的处理程序中,可以使类保持小巧、简洁和单一目的。
单一的数据代理简化了核心和表示域的数据管道配置。
对于那些认为在EF之上实现任何数据库管道是一种反模式的人,我的回答是:我将EF用作另一个对象关系映射器[ORB]。你可以将此管道插入Dapper、LinqToDb等。我从不在我的数据/基础设施域中构建核心业务逻辑代码(数据关系):[个人观点]这是疯狂的想法。
附录
数据存储
测试系统实现了一个Entity Framework内存数据库。
我是一名Blazor开发者,因此我演示数据类自然是WeatherForecast
。这是我的数据类。请注意,它是一个记录类型,用于不变性,并且我为测试目的设置了一些任意的默认值。
public sealed record WeatherForecast : IGuidIdentity
{
[Key] public Guid Uid { get; init; } = Guid.Empty;
public DateOnly Date { get; init; } = DateOnly.FromDateTime(DateTime.Now);
public int TemperatureC { get; init; } = 60;
public string? Summary { get; init; } = <span class="pl-pds">"Testing";
}
首先是一个生成数据集的类。这是一个单例模式类(不是DI单例)。诸如GetRandomRecord
之类的方法用于测试。
public sealed class WeatherTestDataProvider
{
private int RecordsToGenerate;
public IEnumerable<WeatherForecast> WeatherForecasts { get; private set; } =
Enumerable.Empty<WeatherForecast>();
private WeatherTestDataProvider()
=> this.Load();
public void LoadDbContext<TDbContext>(IDbContextFactory<TDbContext> factory)
where TDbContext : DbContext
{
using var dbContext = factory.CreateDbContext();
var weatherForcasts = dbContext.Set<WeatherForecast>();
// Check if we already have a full data set
// If not clear down any existing data and start again
if (weatherForcasts.Count() == 0)
{
dbContext.AddRange(this.WeatherForecasts);
dbContext.SaveChanges();
}
}
public void Load(int records = 100)
{
RecordsToGenerate = records;
if (WeatherForecasts.Count() == 0)
this.LoadForecasts();
}
private void LoadForecasts()
{
var forecasts = new List<WeatherForecast>();
for (var index = 0; index < RecordsToGenerate; index++)
{
var rec = new WeatherForecast
{
Uid = Guid.NewGuid(),
Summary = Summaries[Random.Shared.Next(Summaries.Length)],
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
};
forecasts.Add(rec);
}
this.WeatherForecasts = forecasts;
}
public WeatherForecast GetForecast()
{
return new WeatherForecast
{
Uid = Guid.NewGuid(),
Summary = Summaries[Random.Shared.Next(Summaries.Length)],
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(-1)),
TemperatureC = Random.Shared.Next(-20, 55),
};
}
public WeatherForecast? GetRandomRecord()
{
var record = new WeatherForecast();
if (this.WeatherForecasts.Count() > 0)
{
var ran = new Random().Next(0, WeatherForecasts.Count());
return this.WeatherForecasts.Skip(ran).FirstOrDefault();
}
return null;
}
private static WeatherTestDataProvider? _weatherTestData;
public static WeatherTestDataProvider Instance()
{
if (_weatherTestData is null)
_weatherTestData = new WeatherTestDataProvider();
return _weatherTestData;
}
public static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy",
"Hot", "Sweltering", "Scorching"
};
}
DbContext
public sealed class InMemoryWeatherDbContext
: DbContext
{
public DbSet<WeatherForecast> WeatherForecast { get; set; } = default!;
public InMemoryWeatherDbContext
(DbContextOptions<InMemoryWeatherDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<WeatherForecast>().ToTable("WeatherForecast");
}
历史
- 2022年12月19日:初始版本