使用依赖关系注入在ASP.NET Core中编写干净代码
作者:网络转载 发布时间:[ 2016/5/23 13:24:10 ] 推荐标签:测试开发技术 .NET
请注意,没有新关键字的单个实例,控制器所需要的依赖关系全部通过其构造函数传入,并且 ASP.NET DI 容器会代我负责处理此进程。在专注于编写应用程序时,我无需担心通过其构造函数完成我的类请求的依赖关系所涉及的探测。
当然,如果我愿意,我可以自定义此行为,甚至可以用其他实现完全替换默认容器。因为我的控制器类现在遵循显式依赖关系原则,我知道要想使控制器类正常工作,我需要为其提供一个 GeekDinnerDbContext 实例。通过对 DbContext 进行一些设置,我可以单独实例化控制器,如此控制台应用程序所示:
var optionsBuilder = new DbContextOptionsBuilder();
optionsBuilder.UseSqlServer(Startup.ConnectionString);
var dbContext = new GeekDinnerDbContext(optionsBuilder.Options);
var controller = new DinnersController(dbContext);
var result = (ViewResult) controller.Index();
构造 EF Core DbContext 所涉及的操作要比构造 EF6 DbContext 稍微多一些,后者只需一个连接字符串。这是因为,像 ASP.NET Core 一样,EF Core 已设计得更加模块化。通常情况下,你无需直接处理 DbContextOptionsBuilder,因为当你通过扩展方法(如 AddEntityFramework 和 AddSqlServer)配置 EF 时会在后台使用它。
但能否对它进行测试?
手动测试你的应用程序非常重要—你希望能够运行应用程序,查看它是否真正运行并产生预期的输出。但是,每次进行更改都必须进行测试很浪费时间。相比紧密耦合应用,松散耦合应用程序的大好处之一是它们更适合进行单元测试。更妙的是,相比其前身,ASP.NET Core 和 EF Core 都更易于进行测试。
首先,我将通过传入已配置为使用内存存储的 DbContext 来直接针对控制器编写一个简单测试。我将使用 DbContextOptions 参数来配置 GeekDinnerDbContext,它通过其构造函数公开为我的测试的设置代码的一部分:
var optionsBuilder = new DbContextOptionsBuilder<GeekDinnerDbContext>();
optionsBuilder.UseInMemoryDatabase();
_dbContext = new GeekDinnerDbContext(optionsBuilder.Options);
// Add sample data
_dbContext.Dinners.Add(new Dinner() { Title = "Title 1" });
_dbContext.Dinners.Add(new Dinner() { Title = "Title 2" });
_dbContext.Dinners.Add(new Dinner() { Title = "Title 3" });
_dbContext.SaveChanges();
在我的测试类中进行上述配置后,即可轻松编写一个测试,显示正确的数据已返回到 ViewResult 的模型中:
[Fact]
public void ReturnsDinnersInViewModel()
{
var controller = new OriginalDinnersController(_dbContext);
var result = controller.Index();
var viewResult = Assert.IsType<ViewResult>(result);
var viewModel = Assert.IsType<IEnumerable<Dinner>>(
viewResult.ViewData.Model).ToList();
Assert.Equal(1, viewModel.Count(d => d.Title == "Title 1"));
Assert.Equal(3, viewModel.Count);
}
当然,这里还没有大量的逻辑以供测试,因此,本测试不会真的进行那么多测试。批评家们会辩驳这不是非常有价值的测试,我同意他们的观点。但是,这是具备更多逻辑时进行操作的起点,因为很快会有更多逻辑。但首先,尽管 EF Core 可以通过其内存选项支持单元测试,我仍会避免直接耦合到我的控制器中的 EF。没有理由通过数据访问基础结构问题来耦合 UI 问题—实际上,这违反了另一条原则,即关注点分离原则。
不要依赖于你不使用的内容
接口分隔原则 (bit.ly/LS-Principle) 指出类应仅依赖于它们实际使用的功能。对于启用 DI 的新 DinnersController 而言,它仍依赖于整个 DbContext。可以使用仅提供必需功能的抽象,而不将控制器实现整合到 EF 中。
此操作方法真正需要什么才能正常工作? 当然不是整个 DbContext。它甚至无需访问上下文的完整 Dinners 属性。它需要的只是能够显示合适页面的 Dinner 实例。表示此内容的简单 .NET 抽象为 IEnumerable<Dinner>。因此,我将定义一个接口,该接口仅返回 IEnumerable<Dinner>,并满足 Index 方法的(大多数)要求。
public interface IDinnerRepository
{
IEnumerable<Dinner> List();
}
我将此称之为存储库,因为它符合该模式: 它抽象出类似集合的接口后的数据访问。如果出于某些原因,你不喜欢存储库模式或名称,你可以将其称之为 IGetDinners 或 IDinnerService 或者任何你喜欢的名称(我的技术审阅者建议将其称为 ICanHasDinner)。无论你如何为此类型命名,它都能起到相同的作用。
一切绪后,我现在可以调整 DinnersController 以接受将 IDinnerRepository 作为构造函数参数,而不是 GeekDinnerDbContext,并调用 List 方法,而不直接访问
Dinners DbSet:
private readonly IDinnerRepository _dinnerRepository;
public DinnersController(IDinnerRepository dinnerRepository)
{
_dinnerRepository = dinnerRepository;
}
public IActionResult Index()
{
return View(_dinnerRepository.List());
}
此时,你可以生成并运行你的 Web 应用程序,但如果你导航到 /Dinners 则会遇到异常: Invalid-OperationException: 在尝试激活 GeekDinner.Controllers.DinnersController 时,无法解析类型“Geek-Dinner.Core.Interfaces.IdinnerRepository”的服务。
我尚未实现此接口,并且在我进行实现时,我还需要配置在 DI 满足 IDinnerRepository 请求时要使用的实现。实现接口并不复杂:
public class DinnerRepository : IDinnerRepository
{
private readonly GeekDinnerDbContext _dbContext;
public DinnerRepository(GeekDinnerDbContext dbContext)
{
_dbContext = dbContext;
}
public IEnumerable<Dinner> List()
{
return _dbContext.Dinners;
}
}
请注意,这非常适用于直接将存储库实现耦合到 EF。如果我需要换出 EF,则只需创建此接口的新实现。此实现类是我的应用程序的基础结构的一部分,这是应用程序中我的类依赖于特定实现的一个地方。
若要在类请求 IDinnerRepository 时将 ASP.NET Core 配置为注入正确实现,我需要将以下代码行添加到之前所示的 ConfigureServices 方法的末尾:
services.AddScoped<IDinnerRepository, DinnerRepository>();
此语句要求 ASP.NET Core DI 容器在容器解析依赖于 IDinnerRepository 实例的类型时使用 DinnerRepository 实例。作用域意味着一个实例将用于 ASP.NET 处理的每个 Web 请求。还可以使用暂时或单一生存期添加服务。在这种情况下,作用域适用,因为我的 DinnerRepository 依赖于同样使用作用域生存期的 DbContext。下面是可用对象生存期的摘要:
暂时: 新类型实例在每次请求类型时使用。
作用域: 新类型实例在给定 HTTP 请求中进行第一次请求时创建,然后重用于在该 HTTP 请求期间解析的所有后续类型。
单一: 单一类型实例会创建一次,并由该类型的所有后续请求使用。
内置容器支持多种方法,来构造它将提供的类型。典型的情况是只提供容器和类型,容器将尝试实例化该类型,提供类型运行时需要的任何依赖关系。你还可以提供 lambda 表达式用来构造类型或单一生存期,你可以在注册时在 ConfigureServices 中提供完全构造的实例。
随着依赖关系注入关联,应用程序可以像以前一样运行。现在,如图 1 所示,我可以通过准备绪的新抽象,使用 IDinner-Repository 接口的虚设或模拟实现对其进行测试,而不在我的测试代码中直接依赖于 EF。
图 1 使用 Mock 对象测试 DinnersController
public class DinnersControllerIndex
{
private List<Dinner> GetTestDinnerCollection()
{
return new List<Dinner>()
{
new Dinner() {Title = "Test Dinner 1" },
new Dinner() {Title = "Test Dinner 2" },
};
}
[Fact]
public void ReturnsDinnersInViewModel()
{
var mockRepository = new Mock<IDinnerRepository>();
mockRepository.Setup(r =>
r.List()).Returns(GetTestDinnerCollection());
var controller = new DinnersController(mockRepository.Object, null);
var result = controller.Index();
var viewResult = Assert.IsType<ViewResult>(result);
var viewModel = Assert.IsType<IEnumerable<Dinner>>(
viewResult.ViewData.Model).ToList();
Assert.Equal("Test Dinner 1", viewModel.First().Title);
Assert.Equal(2, viewModel.Count);
}
}
无论 Dinner 实例的列表来自何处,此测试都能正常运行。你可以重写数据访问代码以使用其他数据库、Azure 表存储或 XML 文件,并且控制器也会同样正常运行。当然,在此情况中,并没有执行很多操作,那么,你可能想知道…
实际的逻辑会怎么样?
到目前为止,我没有真正实现任何实际的业务逻辑—这只是返回简单数据集合的简单方法。测试的真正价值在于,在遇到逻辑和特殊情况时,你需要对其会按照预期运行满怀信心。为了说明这一点,我打算将一些需求添加到我的 GeekDinner 站点。此站点将公开一个 API,允许任何人访问 dinner 的 RSVP。
但是,dinner 将拥有可选的大容量,并且 RSVP 不应超过这一容量。请求超过大容量的 RSVP 的用户不应被添加到候补名单中。后,dinner 可以指定相对于其开始时间必须接收 RSVP 的后期限,在此期限后它们将停止接收 RSVP。
我可以将此逻辑全部编码到一个操作中,但我认为这让一个方法承担了太多责任,尤其是 UI 方法应专注于 UI 问题,而不是业务逻辑。控制器应确认它接收的输入有效,并且应确保它返回的响应适合于客户端。在此之外的决策,尤其是业务逻辑,不属于控制器。
放置业务逻辑的佳位置位于应用程序的域模型中,这不应依赖于基础结构方面的问题(如数据库或 UI)。Dinner 类在管理需求中所述的 RSVP 问题时具价值,因为它会为 dinner 存储大容量,并知道目前已完成了多少 RSVP。但是,部分逻辑还依赖于 RSVP 发生的时间以及是否超过后期限,因此方法也需要访问当前时间。
我可以只使用 DateTime.Now,但这会造成逻辑难以测试,并将我的域模型耦合到系统时钟。另一种方法是使用 IDateTime 抽象并将其注入到 Dinner 实体。但是,根据我的经验,好使 Dinner 等实体没有依赖关系,尤其是如果你计划使用类似 EF 的 O/RM 工具将这些实体从持久性层中提取出来。我不希望将实体的依赖关系填充为该进程的一部分,EF 肯定不可能在我没有执行其他代码的情况下做到这一点。
此时一个常用的方法是将逻辑从 Dinner 实体中提取出来,并将其放在可轻松注入依赖关系的某类服务(如 DinnerService 或 RsvpService)中。这往往会导致贫乏域模型反模式 (bit.ly/anemic-model),不过,其中实体具有很少行为或没有行为,只是状态包。不,在这种情况下,解决方案相当简单—方法可以将当前时间作为参数,并让调用代码将其传入。
通过此方法,添加 RSVP 的逻辑非常简单(请参阅图 2)。有多个测试可说明此方法按预期运行,这些方法在与本文关联的示例项目中提供。
本文内容不用于商业目的,如涉及知识产权问题,请权利人联系SPASVO小编(021-61079698-8054),我们将立即处理,马上删除。
相关推荐
编程常用的几种时间戳转换(java .net 数据库).Net中关于相等的问题Asp.net MVC如何对所有用户输入的字符串字段做Trim处理Asp.Net WebForm生命周期的详解.Net开发的两个小技巧asp.net 六大内置对象.Net基础体系和跨框架开发普及Linux使用Jexus托管Asp.Net Core应用程序asp.net登录验证码实现方法ASP.NET自带对象JSON字符串与实体类的转换从 .NET 和 Java 之争谈 IT 行业.Net高效开发之不可错过的实用工具ASP.NET MVC必须知道的那些事!.NET中使用无闪刷新控件时提示框不显示.net开发中要注意的事项Asp.net Core MVC中使用Session
更新发布
功能测试和接口测试的区别
2023/3/23 14:23:39如何写好测试用例文档
2023/3/22 16:17:39常用的选择回归测试的方式有哪些?
2022/6/14 16:14:27测试流程中需要重点把关几个过程?
2021/10/18 15:37:44性能测试的七种方法
2021/9/17 15:19:29全链路压测优化思路
2021/9/14 15:42:25性能测试流程浅谈
2021/5/28 17:25:47常见的APP性能测试指标
2021/5/8 17:01:11热门文章
常见的移动App Bug??崩溃的测试用例设计如何用Jmeter做压力测试QC使用说明APP压力测试入门教程移动app测试中的主要问题jenkins+testng+ant+webdriver持续集成测试使用JMeter进行HTTP负载测试Selenium 2.0 WebDriver 使用指南