【问题标题】:Implementing the Repository Pattern Correctly with EF Core使用 EF Core 正确实施存储库模式
【发布时间】:2020-11-22 17:06:17
【问题描述】:

注意

我不是问是否我应该使用存储库模式,我关心的是如何。将与持久性相关的对象注入域类对我来说不是一个选择:它使单元测试成为不可能(不,使用内存数据库的测试不是单元测试,因为它们涵盖了许多不同的类而没有隔离),它结合了域逻辑使用 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() 方法。以下 sn-p 不起作用:

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

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

...
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's implementation(有点过时了,但思路都是一样的)在查询时忽略了本地的变化。
  2. 有没有比每次手动查询 DB 和 Local 集合更好的解决方案来包含 EF Core 中的本地更改?

更新 - 我的解决方案

我最终实施了@Ronald 的回答建议的第二个解决方案。我使存储库自动保​​存对数据库的更改,并将每个请求包装在数据库事务中。我从提议的解决方案中改变的一件事是我在每次读取时调用SaveChangesAsync,而不是写入。这类似于 Hibernate 已经在做的事情(在 Java 中)。这是一个简化的实现:

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 方法所必需的。

【问题讨论】:

  • EF 本身存储库和工作单元模式的实现。在此之上分层附加抽象通常会增加复杂性、降低可维护性、降低可重用性并降低运行时效率。
  • 您的应用程序的真实来源是什么 - 是数据库还是应用程序内存?如果它是数据库,则您的存储库将按预期运行,并且仅返回已保存的对象。我认为这些考虑比尝试实现“类似集合”的存储库更重要。
  • 数据库是一个实现细节,领域模型不应该知道它的存在。如果有什么是“真相的来源”,那就是内存中的聚合,但即使我们确实关心细节,我说的是在同一事务中进行的本地更改,因此数据库将始终保持一致。跨度>
  • 我明白你的观点@GurGaller。我认为database 是实现细节,但persistence 不是,但我在这里可能错了。我认为这个问题很有趣,会寻找一个体面的答案:)
  • 是的,让我们看看是否有人有更好的想法。我同意,似乎“作为集合的存储库”正在泄露它持久存在的事实。

标签: c# entity-framework-core domain-driven-design repository-pattern ddd-repositories


【解决方案1】:

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

如果您只有本地插入并且只在查询中使用 where 和 select,这将非常简单,因为合并操作只是追加。
当您尝试支持更多运算符(例如 order by、skip & take、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)。
  • 另外,SaveChanges() 可能会抛出异常,例如,如果在添加的实体之一中违反了某些 UNIQUE 约束,并且这不是方法调用者的工作像FindById() 来处理此类异常。
  • @GurGaller 我更新了答案,并提出了如何使用 ef 进行操作的想法。为了避免 SaveChanges 中的异常,您可以在每次写入后保存(也许提供 AddRange 以提高效率)或使用可延迟约束(如果您的数据库有它们)。
【解决方案2】:

这里似乎对存储库和实体存在误解。 首先,DDD 的 Entity 和 EntityFramework 的 Entity 是略有不同的概念。 在 DDD 中,Entity 基本上是一种跟踪业务概念实例超时演变的方式,而在 EntityFramwork 中,Entity 只是一个持久性问题。

从 DDD 的角度来看,存储库模式不会直接操作实体,而是聚合。是的,很酷的故事兄弟,但它改变了什么?长话短说,聚合可以看作是保护严格域不变量的事务边界,不变量必须符合跨一致性,而不是最终一致性。 从 DDD 的角度来看,存储库将提供一个聚合的实例,该实例是一个由 DDD 的称为聚合根的实体根植的对象,其中包含可选的实体和值对象。
使用 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
    }
}

这里发生了什么? 控制器是“用例”层:它负责感染聚合(来自 repo 的聚合根,使聚合完成其工作,然后命令 repo 保存其更改。 使用控制器中的工作单元可能会更加透明,这将保存注入的 DbContext(因为具体的 repo 将必须访问不同的 DbSet:订单和项目)
但你明白了。 您可能还希望为每个表保留 1 个数据访问权限,但它将由聚合专用存储库使用。

希望它足够清楚

【讨论】:

  • 感谢您的回答。你说得对,存储库应该只在聚合根上工作,我在问题中给出的例子被简化了,因为我们保存什么并不重要。我对您的解决方案的问题是存储库中的 Save 方法。 Repository 的接口是领域模型的一部分,如果我们在其中包含 Save 方法,我们将打破 Persistence Ignorance Principal
  • 在这个简化的示例中,是的,保存在存储库中。为了简单起见。但是您可以使用存储库使用的相同 DBContext 轻松构建工作单元,并在要提交事务的“用例”中调用其 SaveChanges() 方法。您甚至可以通过在 UseCase(此处为控制器)中同时注入 DbContext 和 Repository 来避免工作单元模式,因此用例对 SaveChanges() 方法进行显式调用。
  • 这里的关键部分:SaveChanges() 在 DbContext 上调用一次,而不是为每个 DbSet 调用一次。 Repository 所做的工作不仅仅是实体和表之间的映射。请记住,您的域模型与 Persitence 模型的用途不同。
  • 我明白这一切,但它仍然不能解决我的问题。如果我在 Repository 接口之外使用 Save 方法,但使用 Repository 而不保存是不可能的,Repository 是一个泄漏的抽象,它根本不像集合。使用 Repository 的域服务不应调用 Save,因为它们不应该关心持久性或事务范围,因此如果它们仅使用 Repository,则未保存的数据将不会包含在查询中。请参阅问题中的示例
  • 域服务不应该知道任何持久性细节,我同意。但是,在这里,它更像是一个应用程序服务,它负责在应用程序中执行用例,而不是一个域服务,它的职责是托管一些不属于特定聚合的域逻辑。我同意,持久层的实现细节不应该在领域模型中泄露。但是,AFAIK,没有什么可以禁止应用层或用例层(您在其上构建聚合并调用方法的地方)了解一些关于持久性细节的信息。
【解决方案3】:

您可以从 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);
}

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

具体的实现存储库实现(此处为 EFCore)驻留在基础架构层中。

有一个generic EFCore repository implementation 用于覆盖常见的存储库方法:

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 中的规则。

将事务控制权留给客户端。虽然 REPOSITORY 会插入和删除 从数据库中,它通常不会提交任何内容。之后很想承诺 例如,保存,但客户端可能具有正确启动和 提交工作单元。如果 REPOSITORY 保留其事务管理将更简单 放手。

参见第六章,存储库。

【讨论】:

  • 感谢您的回答。我在这里注意到的一件事是,在每次写入操作之后,实现都会调用SaveChangesAsync。是不是有点低效?有没有办法一次保存所有更改,并且仍然正确实现接口(在结果中包括未保存的聚合)?
  • 我知道你的意思,但是如果你坚持聚合模式,SaveChangesAsync() 将覆盖整个聚合,包括子实体和值对象,并且已经最大限度地减少了数据库往返。 DDD 也更适合于聚合边界内的集中更改,这不应暗示使用此存储库方法进行写入的性能问题。另外,恐怕你不能同时拥有这两者,在 EFCore 中执行和 Add() 方法时不要在内部调用 save 并通过随后的 find() 调用立即检索添加的聚合。
【解决方案4】:

您需要使用 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);

已编辑

工作单元.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 集合的原因。我正在寻找一个更好的实现,它不需要每个查询都写两次。
  • 不需要保存在存储库中。您只需在提交之前将所有 Select + Insert + Update + Delete 和所有内容(您需要使用的所有插入/更新/删除)放入工作单元中,然后在提交之后,您只能使用 Select。例如在我上面的编辑中。这就是你想要的。只提交一次。
  • 它仍然没有正确实现该模式。存储库的用户不负责管理事务范围,其他人必须为每个请求提交一次工作单元。同样,我不希望“让它工作”,我知道 EF Core 是如何工作的,但我正在尝试正确实现存储库模式,而不需要保存更改以供它们应用(在同一个事务中)。
猜你喜欢
  • 2023-03-25
  • 1970-01-01
  • 1970-01-01
  • 2010-09-11
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-07-27
  • 2021-10-05
相关资源
最近更新 更多