65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Entity Framework 实现和测试存储库模式

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.88/5 (9投票s)

2018 年 4 月 16 日

CPOL

8分钟阅读

viewsIcon

20301

研究存储库模式为何仍然有用以及使用它的好处。

让我们从存储库模式的定义开始。马丁·福勒(Martin Flower)的著作《企业应用架构模式》中,我们可以找到关于该模式的最佳定义之一。

存储库在领域层和数据映射层之间充当中介,就像一个内存中的领域对象集合。

有人可能会问,为什么这个定义如此出色?我个人喜欢它,因为它强调了存储库模式的两个非常重要的属性。第一个属性是该模式是一种旨在降低复杂性的抽象。第二个重要属性是该模式实际上是一个镜像数据库表的内存集合。

存储库模式的优点和误解

即使我们使用 Entity Framework,我们也可能最终会遇到大量重复的查询代码。例如,如果我们正在开发一个博客应用程序,并且想在几个地方获取查看次数最多的文章,我们可能会遇到重复的查询逻辑,看起来像这样:

var twentyMostViewedPosts = context.Articles
  .Where(a => a.IsPublished)
  .OrderBy(a => a.Views).Take(20);

我们可能最终会遇到更复杂的查询,这些查询可能会在代码中重复出现。更改和维护此类代码并不容易。因此,我们仍然需要存储库来帮助我们。我们可以将此行为封装在存储库中,然后像这样调用它:

var twentyMostViewedPosts = repository.GetTopTwentyArticles(context);

这样更清晰,不是吗?此外,如果我们想更改此逻辑中的任何内容,只需在一个地方进行更改,这是一个巨大的优势。所以,使用存储库模式的第一个好处就是避免重复的查询逻辑。

第二个明显的好处是它将应用程序代码与持久化框架以及我们正在使用的数据库分离开来。基本上,我们可以在存储库中使用不同的 OR/M,或者使用完全不同的技术,例如MongoDBPostgreSQL。纵观过去十年数据库趋势的变化,我们显然希望拥有这样的灵活性。

最后一个,但可能是最重要的好处之一是它简化了单元测试。然而,人们常常误以为该模式可以更轻松地测试数据访问层。这是不正确的,但它在测试业务逻辑方面变得越来越有价值。很容易在业务逻辑中模拟存储库实现。

存储库模式概述

如前所述,存储库是对象的内存集合,该集合需要一个接口,通过该接口我们可以访问该集合中的元素。因此,存储库应公开经典的 CRUD 操作。有些人选择跳过 `Update` 操作,因为从内存集合中更新对象本质上就是获取它并更改其值。我是这些人之一 🙂 当然,如果你喜欢,也可以实现此功能。

总而言之,这就是存储库接口和实现的样子:

您可以看到,我们正在努力实现一个通用的解决方案。我们将定义一个公开这些功能的 `IRepository` 接口:

  • `Add` – 将类型为 `T` 的对象添加到存储库的方法。
  • `Remove` – 从存储库中删除类型为 `T` 的对象的方法。
  • `Get` – 从存储库中获取类型为 `T` 的对象的方法。
  • `GetAll` – 从存储库中获取所有对象的方法。
  • `Find` – 根据特定条件查找并检索对象的方法。

请注意,我们的存储库中也没有 `Save` 方法。这是另一个称为工作单元(Unit of Work)的模式发挥作用的地方。它是一个单独的组件,保存有关不同存储库的信息并实现保存功能;类似这样:

Entity Framework 简介

此时,我们知道我们将使用 Entity Framework,如果您不熟悉它,可以查看MSDN 文章。本质上,Entity Framework 是一个对象关系映射器(O/RM),它使 .NET 开发人员能够使用 .NET 对象与数据库进行交互。它消除了开发人员通常需要编写的大部分数据访问代码。简而言之,它将代码对象映射到数据库表,反之亦然。

在使用 EntityFramework 时有两种方法。第一种称为数据库优先(database-first)方法。在这种方法中,我们创建数据库,然后使用 Entity Framework 创建领域对象并在此基础上构建代码。第二种称为代码优先(code-first)方法。我们将采用这种方法来构建我们的存储库。

有两个重要的类我们需要了解——`DbContext` 和 `DbSet`。`DbContext` 是 Entity Framework 的一个重要类。在使用 Entity Framework 时,我们需要有一个类继承此类。这样,Entity Framework 就会知道需要创建什么。继承 `DbContext` 的类会公开 `DbSet` 属性,用于我们希望成为模型一部分的类型。

测试、模拟和实现

太棒了,现在我们知道存储库应该是什么样子了,我们可以开始为 `Repository` 类编写测试了。继续之前,一个小提示:在此示例中,我使用的是 xUnit 单元测试框架和 Moq 模拟框架。此外,遵循测试驱动开发(TDD)的精神,我们将先编写测试,然后再编写实现。除此之外,此示例中将使用的测试类型是一个简单的 `TestClass`,它看起来像这样:

public class TestClass
{
    public int Id { get; set; }
}

很简单,对吧?它只有一个属性——`Id`。让我们定义存储库的接口应该是什么样子,以便稍后更好地理解我们的实现:

public interface IReporitory<TEntity> where TEntity : class 
{
    void Add(TEntity entity);
    void Remove(TEntity entity);
    TEntity Get(int id);
    IEnumerable<TEntity> GetAll();
    IEnumerable<TEntity> Find(Expression<Func<TEntity, bool>> predicate);
}

Add 方法

好的,现在让我们看看 `Add` 方法的测试是什么样的:

[Fact]
public void Add_TestClassObjectPassed_ProperMethodCalled()
{
    // Arrange
    var testObject = new TestClass();

    var context = new Mock<DbContext>();
    var dbSetMock = new Mock<DbSet<TestClass>>();
    context.Setup(x => x.Set<TestClass>()).Returns(dbSetMock.Object);
    dbSetMock.Setup(x => x.Add(It.IsAny<TestClass>())).Returns(testObject);

    // Act
    var repository = new Repository<TestClass>(context.Object);
    repository.Add(testObject);

    //Assert
    context.Verify(x => x.Set<TestClass>());
    dbSetMock.Verify(x => x.Add(It.Is<TestClass>(y => y == testObject)));
}

这里有几件事需要解释。首先,假设存储库模式将依赖于 `DbContext` 类,并且它将通过构造函数接收。这就是为什么在测试的 `Arrange` 部分,我们模拟了这个类。我们也创建了一个 `DbSet` 类的模拟对象。然后,我们对其进行设置,使得 `DbSet` 的 `Add` 方法返回 `testObject`,它只是 `TestClass` 的一个对象,并且 `DbContext` 的 `Set` 方法返回 `DbSet` 模拟对象。

这样做的目的是为了之后测试这些方法是否被调用,这可以在测试的 Assert 部分看到。总而言之,我们模拟了 `DbContext` 和 `DbSet`,然后调用存储库的 `Add` 方法,最后,我们验证了从模拟对象中调用了这些方法。

此方法的实现非常直接。如下所示:

public class Repository<TEntity> : IReporitory<TEntity> where TEntity : class
{
      protected readonly DbContext Context;
      protected readonly DbSet<TEntity> Entities;

      public Repository(DbContext context)
      {
          Context = context;
          Entities = Context.Set<TEntity>();
      }

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

现在您可以看到为什么我们需要模拟 `DbContext` 的 `Set` 方法了。此方法返回一个非泛型的 `DbSet` 实例,通过它可以访问上下文中给定类型的实体。然后,我们在方法中使用该实例添加另一个对象。

删除方法

此方法的实现与 `Add` 方法的实现类似。让我们看看测试是什么样的:

[Fact]
public void Remove_TestClassObjectPassed_ProperMethodCalled()
{
    // Arrange
    var testObject = new TestClass();

    var context = new Mock<DbContext>();
    var dbSetMock = new Mock<DbSet<TestClass>>();
    context.Setup(x => x.Set<TestClass>()).Returns(dbSetMock.Object);
    dbSetMock.Setup(x => x.Remove(It.IsAny<TestClass>())).Returns(testObject);

    // Act
    var repository = new Repository<TestClass>(context.Object);
    repository.Remove(testObject);

    //Assert
    context.Verify(x => x.Set<TestClass>());
    dbSetMock.Verify(x => x.Remove(It.Is<TestClass>(y => y == testObject)));
}

与 `Add` 方法的测试几乎相同,我们只需要设置 `DbSet` 的 `Remove` 方法。这是添加 `Remove` 方法后 `Repository` 类的实现样子:

public class Repository<TEntity> : IReporitory<TEntity> where TEntity : class
{
    protected readonly DbContext Context;
    protected readonly DbSet<TEntity> Entities;

    public Repository(DbContext context)
    {
        Context = context;
        Entities = Context.Set<TEntity>();
    }

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

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

现在,由于在实现 `Add` 方法时,我们正确地初始化了 `DbContext` 和 `DbSe<wbr />t`,所以我们很容易添加其他方法。

Get 方法

在实现 `Get` 方法时,我们遵循相同的原则。此方法的测试如下所示:

[Fact]
public void Get_TestClassObjectPassed_ProperMethodCalled()
{
    // Arrange
    var testObject = new TestClass();

    var context = new Mock<DbContext>();
    var dbSetMock = new Mock<DbSet<TestClass>>();

    context.Setup(x => x.Set<TestClass>()).Returns(dbSetMock.Object);
    dbSetMock.Setup(x => x.Find(It.IsAny<int>())).Returns(testObject);

    // Act
    var repository = new Repository<TestClass>(context.Object);
    repository.Get(1);

    // Assert
    context.Verify(x => x.Set<TestClass>());
    dbSetMock.Verify(x => x.Find(It.IsAny<int>()));
}

添加 `Get` 方法后,`Repository` 类如下所示:

public class Repository<TEntity> : IReporitory<TEntity> where TEntity : class
{
    protected readonly DbContext Context;
    protected readonly DbSet<TEntity> Entities;

    public Repository(DbContext context)
    {
        Context = context;
        Entities = Context.Set<TEntity>();
    }

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

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

    public TEntity Get(int id)
    {
        return Entities.Find(id);
    }

GetAll 方法

对于 `GetAll` 方法,我们需要做一些小小的调整。此方法需要返回对象列表。这意味着我们需要创建一个 `TestClass` 对象列表,并通过 `DbSet` 返回它。实际上,这意味着我们在测试中需要模拟实现 `IQueryableinterface` 的 `DbSet` 的一部分。这里是如何做的:

[Fact]
public void GetAll_TestClassObjectPassed_ProperMethodCalled()
{
    // Arrange
    var testObject = new TestClass() { Id = 1 };
    var testList = new List<TestClass>() { testObject };

    var dbSetMock = new Mock<DbSet<TestClass>>();
    dbSetMock.As<IQueryable<TestClass>>().Setup(x => x.Provider).Returns
                                         (testList.AsQueryable().Provider);
    dbSetMock.As<IQueryable<TestClass>>().Setup(x => x.Expression).
                                         Returns(testList.AsQueryable().Expression);
    dbSetMock.As<IQueryable<TestClass>>().Setup(x => x.ElementType).Returns
                                         (testList.AsQueryable().ElementType);
    dbSetMock.As<IQueryable<TestClass>>().Setup(x => x.GetEnumerator()).Returns
                                         (testList.AsQueryable().GetEnumerator());

    var context = new Mock<DbContext>();
    context.Setup(x => x.Set<TestClass>()).Returns(dbSetMock.Object);

    // Act
    var repository = new Repository<TestClass>(context.Object);
    var result = repository.GetAll();

    // Assert
    Assert.Equal(testList, result.ToList());
}

我们需要通过创建的测试列表中的属性来模拟 `Provider`、`Expression`、`ElementType` 和 `GetEnumerator(<wbr />)`。事实证明,为这个方法编写测试比编写实现本身更具挑战性。这是我们扩展了 `GetAll` 方法的 `Repository` 类:

public class Repository<TEntity> : IReporitory<TEntity> where TEntity : class
{
  protected readonly DbContext Context;
  protected readonly DbSet<TEntity> Entities;

  public Repository(DbContext context)
  {
      Context = context;
      Entities = Context.Set<TEntity>();
  }

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

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

  public TEntity Get(int id)
  {
      return Entities.Find(id);
  }

  public IEnumerable<TEntity> GetAll()
  {
      return Entities.ToList();
  }
}

Find 方法

在上一示例中学习了如何模拟 `IQueryable` 后,为 `Find` 方法编写测试会容易得多。我们遵循与 `GetAll` 方法相同的原则。测试如下所示:

[Fact]
public void Find_TestClassObjectPassed_ProperMethodCalled()
{
    var testObject = new TestClass(){Id = 1};
    var testList = new List<TestClass>() {testObject};

    var dbSetMock = new Mock<DbSet<TestClass>>();
    dbSetMock.As<IQueryable<TestClass>>().Setup(x => x.Provider).Returns
                                               (testList.AsQueryable().Provider);
    dbSetMock.As<IQueryable<TestClass>>().Setup(x => x.Expression).Returns
                                               (testList.AsQueryable().Expression);
    dbSetMock.As<IQueryable<TestClass>>().Setup(x => x.ElementType).Returns
                                               (testList.AsQueryable().ElementType);
    dbSetMock.As<IQueryable<TestClass>>().Setup(x => x.GetEnumerator()).Returns
                                               (testList.AsQueryable().GetEnumerator());

    var context = new Mock<DbContext>();
    context.Setup(x => x.Set<TestClass>()).Returns(dbSetMock.Object);

    var repository = new Repository<TestClass>(context.Object);

    var result = repository.Find(x => x.Id == 1);

    Assert.Equal(testList, result.ToList());
}

最后,`Repository` 类的完整实现如下所示:

public class Repository<TEntity> : IReporitory<TEntity> where TEntity : class
{
    protected readonly DbContext Context;
    protected readonly DbSet<TEntity> Entities;

    public Repository(DbContext context)
    {
        Context = context;
        Entities = Context.Set<TEntity>();
    }

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

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

    public TEntity Get(int id)
    {
        return Entities.Find(id);
    }

    public IEnumerable<TEntity> GetAll()
    {
        return Entities.ToList();
    }

    public IEnumerable<TEntity> Find(Expression<Func<TEntity, bool>> predicate)
    {
        return Entities.Where(predicate);
    }

结论

很多人认为,如果我们使用 `DbContext` 和 `DbSet`,就不需要实现存储库模式和工作单元。如果你问我是否是这种情况,我会告诉你这取决于问题的类型。在本文中,您有机会了解如何构建和测试使用 Entity Framework 的通用存储库。如果您选择使用 `DbContext` 和 `DbSet`,仍然可以从本文中获得一些有用的技巧,例如如何模拟这些类。

感谢阅读!

历史

  • 2018 年 4 月 16 日:初始版本

 

© . All rights reserved.