使用EF Core正确实现仓储模式

13

注意

我不是在问我是否应该使用仓储模式,我关心的是“如何”。将与持久化相关的对象注入到领域类中对我来说不是一个选项:它会使单元测试变得不可能(不,使用内存数据库的测试不是单元测试,因为它们涵盖了许多不同的类而没有隔离),它将领域逻辑与ORM耦合起来,并且打破了我实践的许多重要原则,比如持久性无感知、分离关注点等,你可以在网上搜索其好处。对我来说,正确使用 EF Core 并不像将业务逻辑与外部关注点隔离开来那么重要,这就是为什么如果这意味着仓储库不再是一个泄漏的抽象,我就会接受对 EF Core 的“hacky”使用。

原始问题

假设仓储库的接口如下:

public interface IRepository<TEntity>
    where TEntity : Entity
{
    void Add(TEntity entity);
    void Remove(TEntity entity);
    Task<TEntity?> FindByIdAsync(Guid id);
}

public abstract class Entity
{
    public Entity(Guid id)
    {
        Id = id;
    }
    public Guid Id { get; }
}

我在网上看到的大多数 EF Core 实现都做了类似以下的事情:

public class EFCoreRepository<TEntity> : IRepository<TEntity>
    where TEntity : Entity
{
    private readonly DbSet<TEntity> entities;

    public EFCoreRepository(DbContext dbContext)
    {
        entities = dbContext.Set<TEntity>();
    }

    public void Add(TEntity entity)
    {
        entities.Add(entity);
    }

    public void Remove(TEntity entity)
    {
        entities.Remove(entity);
    }

    public async Task<TEntity?> FindByIdAsync(Guid id)
    {
        return await entities.FirstOrDefaultAsync(e => e.Id == id);
    }
}
更改是在另一个类中提交的,它实现了工作单元模式。我对这种实现的问题在于它违反了仓储作为“类似集合”的对象的定义。该类的用户必须知道数据已持久化在外部存储中,并自行调用Save()方法。以下代码片段将无法正常工作:

更改是在另一个类中提交的,它实现了工作单元模式。我对这种实现的问题在于它违反了仓储作为“类似集合”的对象的定义。该类的用户必须知道数据已持久化在外部存储中,并自行调用Save()方法。以下代码片段将无法正常工作:

var entity = new ConcreteEntity(id: Guid.NewGuid());
repository.Add(entity);
var result = await repository.FindByIdAsync(entity.Id); // Will return null
更改显然不应在每次调用Add()后提交,因为这样会破坏工作单元的目的,因此我们最终得到了一个奇怪的、不太像集合的接口。 在我看来,我们应该能够像处理常规内存中的集合一样处理存储库:
var list = new List<ConcreteEntity>();
var entity = new ConcreteEntity(id: Guid.NewGuid());
list.Add(entity);
// No need to save here
var result = list.FirstOrDefault(e => e.Id == entity.Id);

当事务范围结束时,更改可以提交到数据库,但是除了处理事务的低级代码之外,我不希望领域逻辑关心事务何时被提交。为了以这种方式实现接口,我们可以使用DbSet的Local集合,以及常规的DB查询。代码如下:

...
public async Task<TEntity?> FindByIdAsync(Guid id)
{
    var entity = entities.Local.FirstOrDefault(e => e.Id == id);
    return entity ?? await entities.FirstOrDefaultAsync(e => e.Id == id);
}

这个方法可以使用,但是具体实现需要在派生出的具体存储库中实现许多其他查询数据的方法。这些查询都必须考虑到Local集合,并且我没有找到一种干净的方法来强制具体存储库不忽略本地更改。所以我的问题真正归结为:

  1. 我的仓储模式理解正确吗?为什么其他在线实现没有提到这个问题?即使Microsoft官方文档(有点过时,但是思想是一样的)也会在查询时忽略本地更改。
  2. 是否有更好的解决方案可以包含EF Core中的本地更改,而无需每次手动查询数据库和Local集合?

更新 - 我的解决方案

最终我采用了@Ronald答案建议的第二种解决方案。我让仓库自动保存更改到数据库,并将每个请求封装在数据库事务中。我从建议的解决方案中改变的一件事是,我在每次读取时调用了SaveChangesAsync。这类似于Java中的Hibernate的做法。这是一个简化的实现:

public abstract class EFCoreRepository<TEntity> : IRepository<TEntity>
    where TEntity : Entity
{
    private readonly DbSet<TEntity> dbSet;
    public EFCoreRepository(DbContext dbContext)
    {
        dbSet = dbContext.Set<TEntity>();
        Entities = new EntitySet<TEntity>(dbContext);
    }

    protected IQueryable<TEntity> Entities { get; }

    public void Add(TEntity entity)
    {
        dbSet.Add(entity);
    }

    public async Task<TEntity?> FindByIdAsync(Guid id)
    {
        return await Entities.SingleOrDefaultAsync(e => e.Id == id);
    }

    public void Remove(TEntity entity)
    {
        dbSet.Remove(entity);
    }
}

internal class EntitySet<TEntity> : IQueryable<TEntity>
    where TEntity : Entity
{
    private readonly DbSet<TEntity> dbSet;
    public EntitySet(DbContext dbContext)
    {
        dbSet = dbContext.Set<TEntity>();
        Provider = new AutoFlushingQueryProvider<TEntity>(dbContext);
    }

    public Type ElementType => dbSet.AsQueryable().ElementType;

    public Expression Expression => dbSet.AsQueryable().Expression;

    public IQueryProvider Provider { get; }

    // GetEnumerator() omitted...
}

internal class AutoFlushingQueryProvider<TEntity> : IAsyncQueryProvider
    where TEntity : Entity
{
    private readonly DbContext dbContext;
    private readonly IAsyncQueryProvider internalProvider;

    public AutoFlushingQueryProvider(DbContext dbContext)
    {
        this.dbContext = dbContext;
        var dbSet = dbContext.Set<TEntity>().AsQueryable();
        internalProvider = (IAsyncQueryProvider)dbSet.Provider;
    }
    public TResult ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken = default)
    {
        var internalResultType = typeof(TResult).GenericTypeArguments.First();

        // Calls this.ExecuteAsyncCore<internalResultType>(expression, cancellationToken)
        object? result = GetType()
            .GetMethod(nameof(ExecuteAsyncCore), BindingFlags.NonPublic | BindingFlags.Instance)
            ?.MakeGenericMethod(internalResultType)
            ?.Invoke(this, new object[] { expression, cancellationToken });

        if (result is not TResult)
            throw new Exception(); // This should never happen

        return (TResult)result;
    }

    private async Task<TResult> ExecuteAsyncCore<TResult>(Expression expression, CancellationToken cancellationToken)
    {
        await dbContext.SaveChangesAsync(cancellationToken);
        return await internalProvider.ExecuteAsync<Task<TResult>>(expression, cancellationToken);
    }

    // Other interface methods omitted...
}

注意使用了IAsyncQueryProvider,这迫使我使用了一小段反射代码。这是为了支持与 EF Core 一起使用的异步 LINQ 方法而必须的。


15
EF本身就是Repository和Unit of Work模式的实现,增加额外的抽象层通常会增加复杂性、降低可维护性、降低可重用性并降低运行效率。 - Stephen Cleary
你的应用程序的真相来源是什么 - 是数据库还是应用程序内存?如果是数据库,那么你的存储库会按预期运行并且只返回已保存的对象。我认为这些考虑因素比尝试实现“类似集合”的存储库更重要。 - Alex Buyny
我理解你的观点,@GurGaller。我认为“database”是实现细节,但“persistence”不是,但我可能在这里错了。我认为这个问题很有趣,会等待一个好的答案 :) - Alex Buyny
1
是的,让我们看看是否有更好的想法。我同意,“repository as collection”似乎泄露了它持久化东西的事实。 - Alex Buyny
对于我来说,如果你只是在谈论 EF repos,那么你的拦截是正确的。你的数据库可以是文本/CSV 文件,它对 repo 有不同的定义(通常,repo 就是 repo,无论是针对文本、平面还是关系型数据库)。EF 具有“更改跟踪”,而 Foo 没有。有些具有事务能力,有些则没有。即使使用“规范模式”,repos 的行为/定义也应该因 EF 和 Mongo 等而异。我的意思是说,正是“抽象”定义了对 repo 的期望水平。因此,我们不能说“正确的 Repository Pattern”就是“这个或那个”。 - Efe
显示剩余4条评论
5个回答

5
您可以从由Microsoft提供支持的EShopOnWeb项目中了解这个存储库实现方法:
根据领域驱动设计的规则,存储库专门用于处理聚合集合。此示例解决方案中的接口如下所示:
public interface IAsyncRepository<T> where T : BaseEntity, IAggregateRoot
{
    Task<T> GetByIdAsync(int id, CancellationToken cancellationToken = default);
    Task<IReadOnlyList<T>> ListAllAsync(CancellationToken cancellationToken = default);
    Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec, CancellationToken cancellationToken = default);
    Task<T> AddAsync(T entity, CancellationToken cancellationToken = default);
    Task UpdateAsync(T entity, CancellationToken cancellationToken = default);
    Task DeleteAsync(T entity, CancellationToken cancellationToken = default);
    Task<int> CountAsync(ISpecification<T> spec, CancellationToken cancellationToken = default);
    Task<T> FirstAsync(ISpecification<T> spec, CancellationToken cancellationToken = default);
    Task<T> FirstOrDefaultAsync(ISpecification<T> spec, CancellationToken cancellationToken = default);
}

接口本身位于域层中(在此项目中称为应用程序核心)。

具体实现存储库实现(这里是针对 EFCore 的实现)位于基础设施层。

有一个通用 EFCore 存储库实现,用于覆盖常见的存储库方法:

public class EfRepository<T> : IAsyncRepository<T> where T : BaseEntity, IAggregateRoot
{
    protected readonly CatalogContext _dbContext;

    public EfRepository(CatalogContext dbContext)
    {
        _dbContext = dbContext;
    }

    public virtual async Task<T> GetByIdAsync(int id, CancellationToken cancellationToken = default)
    {
        var keyValues = new object[] { id };
        return await _dbContext.Set<T>().FindAsync(keyValues, cancellationToken);
    }

    public async Task<T> AddAsync(T entity, CancellationToken cancellationToken = default)
    {
        await _dbContext.Set<T>().AddAsync(entity);
        await _dbContext.SaveChangesAsync(cancellationToken);

        return entity;
    }

    public async Task UpdateAsync(T entity, CancellationToken cancellationToken = default)
    {
        _dbContext.Entry(entity).State = EntityState.Modified;
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    public async Task DeleteAsync(T entity, CancellationToken cancellationToken = default)
    {
        _dbContext.Set<T>().Remove(entity);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

我只是参考了这里的一些方法。
如果需要更具体符合要求的存储库方法,您可以在域层实现更特定的存储库接口,在基础结构层中再次实现,由通用的 IAsyncRepository 和特定的接口派生。请参阅here以获取示例(尽管提供的方法不是最好的示例,但我认为您可以理解)。
采用这种方法,实际保存到数据库的操作完全由存储库实现处理,而不是存储库接口的一部分。
另一方面,事务不应该位于域层或存储库实现中。因此,如果您需要确保同一用例中的多个聚合更新是一致的,则应在应用程序层处理此事务处理。
这也符合Eric Evans在他的书《Domain-Driven Design》中的规则。
让客户端控制事务。虽然仓库会向数据库中插入和删除数据,但通常不会提交任何内容。例如,在保存后立即提交是很诱人的,但客户端应该有正确启动和提交工作单元的上下文。如果仓库不涉及事务管理,那么事务管理将更加简单。请参阅第六章,Repositories。

谢谢你的答复。我注意到这里的实现在每次写操作后都调用了 SaveChangesAsync。这不是有点低效吗?有没有一种方法能够仅保存所有更改一次,但仍正确地实现接口(将未保存的聚合包含在结果中)? - Gur Galler
我知道你的意思,但如果你坚持使用聚合模式,SaveChangesAsync() 将覆盖整个聚合,包括子实体和值对象,并且已经最小化了数据库往返。DDD 也更适合在聚合边界内进行专注的更改,这不应该意味着使用此存储库方法进行写入时存在性能问题。另外,我担心你不能同时拥有两者,在 EFCore 中执行 Add() 方法时不调用内部保存并立即使用后续的 find() 调用检索添加的聚合。 - Andreas Hütter

3

将针对不同数据集运行的相同查询的结果集合并通常不起作用。

如果你只有本地插入,并且在查询中仅使用 where 和 select,那么合并操作就是简单的追加。
随着你尝试支持更多的运算符,例如 order by、skip & take、group by 以及本地更新和删除,这个过程会变得越来越困难。

特别是,没有其他方法可以支持 local updates 和 deletions 的 group by,除了首先合并两个数据源,然后应用 group by。

在你的应用中进行此操作将是不可行的,因为这意味着检索整个表格,应用本地更改,然后执行 group by。

可能有效的方法是将本地更改传输到数据库中,然后在那里运行查询。

我能想到两种实现这个目标的方法。

转换查询

通过替换它们的 from 子句来转换你的查询,以包含本地更改

所以一个查询可能像这样

select sum(salary) from employees group by division_id

将会变成什么

select
    sum(salary) 
from 
(
    select 
        id, name, salary, division_id 
    from employees
    -- remove deleted and updated records
    where id not in (1, 2)
    -- add inserted records and new versions of updated records
    union all values (1, 'John', 200000, 1), (99, 'Jane', 300000, 1)
) _
group by division_id

这应该也适用于联接操作,如果您将相同的转换应用于联接的表。
但是,如果要使用ef进行此操作,则需要进行一些非常深入的自定义。
以下是如何在ef中至少部分实现它的想法,它不支持联接操作,并且不幸涉及一些手动sql生成。
static IQueryable<T> WithLocal<T>(this DbContext db)
    where T : Entity
{
    var set = db.Set<T>();
    var changes = db.ChangeTracker.Entries<T>();
    var model = db.Model.FindEntityType(typeof(T));

    var deletions = changes
        .Where(change => change.State == EntityState.Deleted)
        .Select(change => change.Entity.Id);
        
    return set
        // Hard part left as an exercise for the reader :)
        // Generate this from 'changes' and 'model', you can use parameters for the values
        .FromSqlRaw("select 1 as id, 'John' as name, 200000 as salary, 1 as division_id union all select 99 as id, 'Jane' as name, 300000 as salary, 1 as division_id")
        .Union(set.Where(entity => !deletions.Contains(entity.Id)));
}

你可以像这样使用它

var query = db.WithLocal<Employee>()
    .GroupBy(employee => employee.DivisionId)
    .Select(group => group.Sum(employee => employee.Salary));

保持事务的开放状态

一个更简单的方法是将写操作提交到数据库,但不要提交事务,这样使用同一事务运行的所有查询都可以看到更改,但其他人则看不到。 在请求结束时,您可以从存储库之外进行提交或回滚。

使用此方法,您的查询还将看到数据库生成的值,例如计算列、自动递增ID和触发器生成的值。


我从未尝试过这种方法,也不能对这些方法的性能影响发表评论,但如果您需要此功能,我认为没有太多其他方法可选。


谢谢您的回答。关于第一个解决方案,我尝试使用Union将内存中的数据与查询一起发送(就像您演示的那样),但显然EF不支持在Union语句中使用内存集合。因此,如果有人有想法,请分享一下。至于第二个解决方案,它可以工作,但每个读取方法都需要另一次访问数据库,这并不理想(我们必须在查询之前调用SaveChanges)。 - Gur Galler
此外,如果添加的实体中有违反某些“UNIQUE”约束条件的情况,则SaveChanges()可能会抛出异常,而调用类似于FindById()的方法的调用者不需要处理这些异常。 - Gur Galler
@GurGaller 我更新了答案,并提供了使用 ef 的实现思路。 为了避免在 SaveChanges 中出现异常,您可以在每次写入后保存(也许提供 AddRange 以提高效率),或者如果您的数据库支持,则使用可延迟约束。 - Roald

3
似乎在这里存在着关于Repository和Entities的误解。首先,在DDD中,Entity和EntityFramework中的Entity是略有不同的概念。在DDD中,Entity基本上是一种跟踪业务概念实例演变的方式,而在EntityFramwork中,Entity仅仅是一个持久化问题。
从DDD的角度来看,存储库模式不会直接操作Entities,而是聚合(Aggregate)。简单地说,一个聚合可以被视为保护严格域不变量的事务边界,这些不变量必须符合事务一致性,而不是最终一致性。在DDD的视角中,存储库将获取一个聚合的实例,该实例是由DDD的Entity称为聚合根根的对象,其中包含可选的Entities和Value Objects。
使用EF时,存储库将执行繁重的工作,从一个或多个SQL表中提取数据,依靠工厂提供完全实例化并准备好使用的聚合。它还将进行事务处理,以便以结构化,关系型方式将聚合(以及其内部组件)保存到数据库中。但是,聚合不应该知道存储库的存在。核心模型不关心任何持久性详细信息。聚合使用属于"应用程序层"或"用例"层,而不是域层。
最后总结一下。假设你想在asp.net薄应用程序中实现DDD存储库:
class OrderController
{
    private IOrderRepository _orderRepository;

    public OrderController(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task PlaceOrder(Guid orderId)
    {
        var aggregate = await _orderRepository.FindByIdAsync(orderId);
        aggregate.PlaceOrder();
        await _orderRepository.Save();
    }
}

internal interface IOrderRepository
{
    void Add(Order order);
    void Remove(Order order);
    Task<Order> FindByIdAsync(Guid id);
    Task Save();
}

internal class Order
{
    public Guid Id { get; }

    private IList<Item> items;
    public static Order CreateOrder(IList<Item> items)
    {
        return new Order(items);
    }

    private Order(IList<Item> items)
    {
        this.Id = Guid.NewGuid();
        this.items = items;
    }

    public void PlaceOrder()
    {
        // do stuff with aggregate sttus and items list
    }
}

这里发生了什么?
控制器是“用例”层:它负责从存储库中获取聚合(聚合根),使聚合完成其工作,然后命令存储库保存其更改。在控制器中使用工作单元可以使其更透明,这样可以保存注入的DbContext(因为具体的存储库将需要访问不同的DbSet:订单和项目)
但你懂的。 您可能还希望每个表保留1个数据访问,但它将由专门用于聚合的存储库使用。
希望这足够清晰。

谢谢你的回答。你说得对,仓储应该只在聚合根上工作,我在问题中给出的示例是简化的,因为我们保存什么并不重要。我对你的解决方案有问题的地方是仓储中的“Save”方法。仓储的接口是领域模型的一部分,如果我们在其中包含一个“Save”方法,就会违反持久性无知原则。 - Gur Galler
在这个简化的例子中,是的,Save操作在Repository上。为了简单起见。 但是你可以轻松地使用与仓储相同的DBContext构建一个工作单元,并在“用例”中调用其SaveChanges()方法,以便在想要提交事务的地方进行提交。你甚至可以通过在UseCase(这里是控制器)中注入DbContext和Repository来避免使用工作单元模式,因此用例可以显式调用SaveChanges()方法。 - Oinant
这里的关键部分是:SaveChanges() 仅在 DbContext 上调用一次,而不是每个 DbSets 都调用一次。而且 Repository 做的工作不仅仅是实体和表之间的映射。请记住,您的领域模型与持久性模型的目的不同。 - Oinant
我理解了所有这些,但它仍然没有解决我的问题。如果我将“保存”方法从存储库接口中移出,但是在不保存的情况下使用存储库是不可能的,那么存储库就是一个泄漏的抽象,而且它根本不像集合。使用存储库的域服务不应该调用“保存”,因为它们不应该关心持久性或事务范围,所以如果它们仅使用存储库,则未保存的数据将不包括在查询中。请参见问题中的示例。 - Gur Galler
1
域服务不应该了解任何持久化细节,我同意。然而,在这里,更多的是一个应用程序服务,负责在应用程序内执行用例,而不是域服务,其职责是托管一些不属于特定聚合体的域逻辑。持久化层的实现细节不应泄漏到域模型中,我同意。但是据我所知,没有什么禁止应用程序层或用例层(您在其中获取聚合并调用其方法的地方)了解有关持久性详细信息的事情。 - Oinant

0

我认为DbContext是仓储库,它具有您所需的所有方法。虽然一些应用程序架构可以没有需要实体框架并拥有自己的存储库模式、工作单元(EF 使用更改跟踪器)和查询规范语言(EF 使用表达式),但这些框架体系似乎仍然使用 EF 来获得直接的实现,那么为什么要在这样的架构中投资时间呢?

唯一可能有用的是查询重用(我认为这相当被高估了),但 EF 有预编译查询,在这方面可能会有帮助。


重用并不是一个非常强有力的论点,因为您可以使用EF并仍然以各种方式重用查询(例如扩展方法)。我在提出这个问题时对EF的主要问题是我不想与它耦合。我希望能够在明天早上切换到基于MongoDB的实现,而不必更改代表纯业务逻辑的代码。 - Gur Galler
也许在那个时候它还没有提供,但只需要为EF选择不同的数据库提供程序,您就可以拥有我不想在自己的存储库层中编写的所有功能。顺便说一句,更改任何大型企业数据库(独立于数据库)通常都太昂贵了,因为大多数情况下会有报告或一些4GL存储过程,这些都是太昂贵以至于无法替换的。 - Wouter

-2

你需要使用SaveChanges()才能获取新的id。

UnitOfWork.cs

private readonly DbContext dbContext;
public UnitOfWork(DbContext dbContext)
{
    this.dbContext = dbContext;
}

public void Commit()
{
    dbContext.SaveChanges();
}

.

var entity = new ConcreteEntity(id: Guid.NewGuid());
repository.Add(entity);
Commit();
var result = await repository.FindByIdAsync(entity.Id);

已编辑

UnitOfWork.cs

var users = userRepository.GetAll(); // select
var roles = roleRepository.GetAll(); // select 
var entity = new ConcreteEntity(id: Guid.NewGuid());
repository.Add(entity);

var order = new Order()
{
    InvoiceNo = "00002",
    CustomerID = 1,
    Amount = 500.00, 
    OrderDetails = new OrderDetail()
                   {
                        ItemID = 1,
                        Quantity = 5,
                        Amount = 500.00
                   }
};

orderRepository.Add(order);

// can add more insert or update or delete here before commit

Commit();

var result = await repository.FindByIdAsync(entity.Id);
var orderresult = await orderRepository.FindByIdAsync(order.Id);

我了解EF的行为,但正如我在问题中所述,每次更改后保存都会破坏工作单元的目的。为了正确实现模式(并且像“集合”一样),存储库必须在查询中包括未保存的实体,这就是我使用“Local”集合的原因。我正在寻找更好的实现方式,不需要将每个查询都写两遍。 - Gur Galler
不需要将其保存在存储库中。您只需在提交之前将所有选择+插入+更新+删除和所有内容(您需要使用的所有插入/更新/删除)放入工作单元中,然后在提交后,您只能使用选择。例如,在我的上面编辑的示例中。这就是您想要的。仅提交一次。 - Asherguru
它仍然没有正确实现该模式。Repository的用户不负责管理事务范围,其他人必须在每个请求中提交工作单元。再次强调,我不是要“让它工作”,我知道EF Core的工作原理,但我正在尝试正确地实现Repository模式,而无需保存更改以使其应用(在同一事务中)。 - Gur Galler

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接