从Controller访问数据库实体

TL;博士

在一个很好的设计。 应该在单独的业务逻辑层(在asp.net MVC模型中)处理访问数据库,还是可以将IQueryable s或DbContext对象传递给控制器​​?

为什么? 各自的优点和缺点是什么?


我正在用C#构建一个ASP.NET MVC应用程序。 它使用EntityFramework作为ORM。

让我们稍微简化一下这个场景。

我有一个可爱的蓬松小猫数据库表。 每只小猫都有小猫图像链接,小猫蓬松指数,小猫名称和小猫ID。 这些映射到EF生成的名为Kitten POCO。 我可能会在其他项目中使用此类,而不仅仅是asp.net MVC项目。

我有一个KittenController应该在/Kittens取得最新的蓬松小猫。 它可能包含选择小猫的一些逻辑,但没有太多的逻辑。 我一直在和朋友争论如何实现这一点,我不会透露双方:)

选项1:控制器中的db:

 public ActionResult Kittens() // some parameters might be here { using(var db = new KittenEntities()){ // db can also be injected, var result = db.Kittens // this explicit query is here .Where(kitten=>kitten.fluffiness > 10) .Select(kitten=>new { Name=kitten.name, Url=kitten.imageUrl }).Take(10); return Json(result,JsonRequestBehavior.AllowGet); } } 

选项2:单独的模型

 public class Kitten{ public string Name {get; set; } public string Url {get; set; } private Kitten(){ _fluffiness = fluffinessIndex; } public static IEnumerable GetLatestKittens(int fluffinessIndex=10){ using(var db = new KittenEntities()){ //connection can also be injected return db.Kittens.Where(kitten=>kitten.fluffiness > 10) .Select(entity=>new Kitten(entity.name,entity.imageUrl)) .Take(10).ToList(); } } // it's static for simplicity here, in fact it's probably also an object method // Also, in practice it might be a service in a services directory creating the // Objects and fetching them from the DB, and just the kitten MVC _type_ here } 

 //----Then the controller: public ActionResult Kittens() // some parameters might be here { return Json(Kittens.GetLatestKittens(10),JsonRequestBehavior.AllowGet); } 

注意: GetLatestKittens不太可能在代码的其他地方使用,但可能会。 可以使用Kitten的构造函数而不是静态构建方法并更改Kittens的类。 基本上它应该是数据库实体之上的一层因此控制器不必知道实际的数据库,映射器或entity framework。

  • 每种设计有哪些优缺点?
  • 有明显的赢家吗? 为什么?

注意:当然,替代方法也非常有价值。

澄清1:这在实践中不是一个微不足道的应用。 这是一个具有数十个控制器和数千行代码的应用程序,这些实体不仅在这里使用,而且还在其他数十个C#项目中使用。 这里的例子是一个简化的测试用例

选项1和2有点极端,就像魔鬼和深蓝色海洋之间的选择,但如果我必须在两者之间做出选择,我宁愿选择1。

首先,选项2将抛出运行时exception,因为entity framework不支持投影到实体( Select(e => new Kitten(...))并且它不允许在投影中使用带参数的构造函数现在,这个注释在这种情况下看起来有点迂腐,但是通过投射到实体并返回Kitten (或Kitten的枚举),你隐藏了这种方法的真正问题。

显然,您的方法返回您要在视图中使用的实体的两个属性 – 小猫的nameimageUrl 。 因为这些只是所有Kitten属性的选择,返回(半填充) Kitten实体是不合适的。 那么,从这个方法实际返回什么类型?

  • 您可以返回object (或IEnumerable )(这就是我理解您对“ 对象方法 ”的评论),如果您将结果传递给Json(...)以便稍后在Javascript中处理,那么这很好。 但是你会丢失所有编译时类型信息,我怀疑object结果类型对其他任何东西都有用。
  • 您可以返回一些只包含两个属性的命名类型 – 可能称为“KittensListDto”。

现在,这只是一个视图的一种方法 – 列出小猫的视图。 然后你有一个详细信息视图来显示单个小猫,然后是编辑视图,然后是删除确认视图。 现有Kitten实体的四个视图,每个视图都需要不同的属性,每个属性都需要单独的方法和投影以及不同的DTO类型。 Dog实体和项目中的100个实体相同,您可能获得400种方法和400种返回类型。

并且很可能不会在任何其他地方重复使用这个特定视图。 你为什么要在第二次带nameimageUrl 10只小猫? 你有第二只小猫列表视图吗? 如果是这样,它将有一个原因,并且查询只是偶然和现在相同,如果一个更改另一个不一定,否则列表视图没有被正确“重用”并且不应该存在两次。 或者是Excel导出使用的列表可能相同? 但也许Excel用户希望明天有1000只小猫,而视图应该仍然只显示10.或者视图应该明天显示小猫的Age ,但Excel用户不希望这样,因为他们的Excel宏无法正确运行随着那种变化。 仅仅因为两段代码是相同的,如果它们位于不同的上下文中或具有不同的语义,则不必将它们分解为公共可重用组件。 你最好把它留给GetLatestKittensForListViewGetLatestKittensForExcelExport 。 或者你最好在服务层中没有这样的方法。


根据这些考虑因素,比萨店的游览作为类比,为什么第一种方法是优越的:)

“欢迎来到BigPizza,定制比萨店,我可以接受您的订单吗?” “好吧,我想要一个披萨加橄榄,但是上面是番茄酱,底部是奶酪,然后在烤箱里烘烤90分钟,直到它变成黑色,像硬花岗岩一样坚硬。” “好吧,先生,定制的比萨饼是我们的专业,我们会做到的。”

收银员去了厨房。 他告诉厨师说:“柜台里有一个心理学家,他想要一个比萨饼……这是一块花岗岩的岩石……等等……我们需要先找一个名字”。

“不!”,厨师尖叫道,“不会再说了!你知道我们已经尝过了。” 他拿了一叠400页的纸,“这里我们从2005年开始有花岗岩的岩石 ,但是…它没有橄榄,而是青椒……或者这里是顶级番茄 ……但客户想要它烤了半分钟。“ “也许我们应该把它称为TopTomatoGraniteRockSpecial ?” “但它并没有把底部的奶酪考虑在内……”收银员说:“这就是特别应该表达的东西。” “但披萨形成的金字塔形状也很特别”,厨师回答道。 “嗯……这很难……”,绝望的收银员说。

“我的比萨饼已经在烤箱里了吗?”突然它从厨房的门口喊道。 “让我们停止讨论,告诉我如何制作这种比萨饼,我们不会第二次吃这种比萨饼”,厨师决定。 “好吧,这是一个披着橄榄的比萨饼,但是顶部是番茄酱,底部是奶酪,然后在烤箱里烘烤90分钟,直到它变成黑色,像硬花岗岩一样坚硬。”


如果选项1通过在视图层中使用数据库上下文违反了关注点分离原则,则选项2通过在服务或业务层中使用以表示为中心的查询逻辑来违反相同的原则。 从技术角度来看,它不会,但它最终会得到一个服务层,除了表示层之外的“可重用”。 它具有更高的开发和维护成本,因为对于控制器操作中的每个必需数据,您必须创建服务,方法和返回类型。

现在,实际上可能存在经常重复使用的查询或查询部分,这就是为什么我认为选项1几乎与选项2一样极端 – 例如按键的Where子句(可能会在详细信息中使用,编辑和删除确认)视图),过滤掉“软删除”实体,由多租户架构中的租户过滤或禁用更改跟踪等。对于这种真正的重复查询逻辑,我可以想象将其提取到服务或存储库层(但可能只是可重用的扩展方法)可能有意义,比如

 public IQueryable GetKittens() { return context.Kittens.AsNoTracking().Where(k => !k.IsDeleted); } 

之后的任何其他内容(如投影属性)都是特定于视图的,我不希望在此层中拥有它。 为了使这种方法成为可能,必须从服务/存储库中公开IQueryable 。 这并不意味着select必须直接在控制器动作中。 特别是胖和复杂的投影(可能通过导航属性,执行分组等加入其他实体)可以移动到IQueryable扩展方法中,这些方法收集在其他文件,目录甚至是另一个项目中,但仍然是一个项目是表示层的附录,它比服务层更接近它。 然后一个动作可能如下所示:

 public ActionResult Kittens() { var result = kittenService.GetKittens() .Where(kitten => kitten.fluffiness > 10) .OrderBy(kitten => kitten.name) .Select(kitten => new { Name=kitten.name, Url=kitten.imageUrl }) .Take(10); return Json(result,JsonRequestBehavior.AllowGet); } 

或者像这样:

 public ActionResult Kittens() { var result = kittenService.GetKittens() .ToKittenListViewModel(10, 10); return Json(result,JsonRequestBehavior.AllowGet); } 

ToKittenListViewModel()是:

 public static IEnumerable ToKittenListViewModel( this IQueryable kittens, int minFluffiness, int pageItems) { return kittens .Where(kitten => kitten.fluffiness > minFluffiness) .OrderBy(kitten => kitten.name) .Select(kitten => new { Name = kitten.name, Url = kitten.imageUrl }) .Take(pageItems) .AsEnumerable() .Cast(); } 

这只是一个基本思想和草图,另一个解决方案可能在选项1和2之间。

嗯,这一切都取决于整体架构和要求,我上面写的所有内容都可能是无用的和错误的。 您是否必须考虑将来可以更改ORM或数据访问技术? 控制器和数据库之间是否存在物理边界,控制器是否与上下文断开连接,是否需要通过Web服务获取数据,例如将来? 这将需要一种非常不同的方法,这种方法更倾向于选项2。

这样的架构是如此不同 – 在我看来 – 你根本不能说“可能”或“不是现在,但可能在未来可能是一个要求,或者可能不会”。 这是项目利益相关者在进行架构决策之前必须定义的内容,因为它会大大增加开发成本,如果“可能”变成现实,我们就会浪费在开发和维护上的资金。

我只讨论在Web应用程序中的查询或GET请求,这些应用程序很少有我称之为“业务逻辑”的东西。 POST请求和修改数据是一个完全不同的故事。 如果禁止订单在开票后可以更改,例如这是一般的“业务规则”,通常适用于任何视图或Web服务或后台进程或任何尝试更改订单的内容。 我肯定会将订单状态检查到业务服务或任何常见组件中,而不会进入控制器。

在控制器操作中可能存在反对使用IQueryable的争论,因为它与LINQ-to-Entities耦合,这将使unit testing变得困难。 但是什么是unit testing将在不包含任何业务逻辑的控制器操作中进行测试,该控制器操作传递的参数通常来自视图,通过模型绑定或路由 – 未被unit testing覆盖 – 使用模拟存储库/服务返回IEnumerable – 未测试数据库查询和访问 – 并且返回View – 未测试视图的正确呈现?

第二种方法是优越的。 让我们尝试一个蹩脚的比喻:

你进入一家披萨店,走到柜台。 “欢迎来到McPizza Maestro Double Deluxe,我可以接受您的订单吗?” 那个疙瘩的收银员问你,他眼中的虚空有可能引诱你。“是的,我会买一个带橄榄的大披萨”。 “好的”,收银员回复,他的声音在“o”声中间嘶叫。 他向厨房大喊“One Jimmy Carter!”

然后,等了一会儿,你会得到一个带橄榄的大披萨。 你有没有注意到什么特别的东西? 收银员没有说“取一些面团,像圣诞节那样旋转,倒入一些奶酪和番茄酱,撒上橄榄,放入烤箱约8分钟!” 想一想,这根本不是特别的。 收银员只是两个世界之间的门户:想要披萨的顾客和制作披萨的厨师。 对于所有收银员都知道,厨师从外星人那里得到他的披萨,或者把他们从吉米卡特那里切下来(他是一个人口减少的资源)。

那是你的情况。 你的收银员不是傻瓜。 他知道如何制作披萨。 这并不意味着他应该制作披萨,或者告诉别人如何制作披萨。 这是厨师的工作。 正如其他答案(特别是Florian Margaine和Madara Uchiha)所说明的那样,责任分离。 该模型可能没有做太多,它可能只是一个函数调用,它甚至可能是一行 – 但这并不重要,因为控制器不关心

现在,让我们说主人认为比萨饼只是一种时尚(亵渎神灵!)而你又转向更现代的东西,一种奇特的汉堡包。 让我们回顾一下会发生什么:

你进入一个花式汉堡关节,走到柜台。 “欢迎来到Le Burger Maestro Double Deluxe,我可以接受您的订单吗?” “是的,我会有一个带橄榄的大汉堡”。 “好的”,他转向厨房,“一个吉米卡特!”

然后,你会得到一个带橄榄的大汉堡包(ew)。

这是关键词:

我可能会在其他项目中使用此类,而不仅仅是asp.net MVC项目。

控制器以HTTP为中心。 它只在那里处理HTTP请求。 如果要在任何其他项目(即业务逻辑)中使用模型,则控制器中不能包含任何逻辑。 您必须能够脱掉模型,将其放在其他地方,并且所有业务逻辑仍然有效。

所以,不,不要从控制器访问您的数据库。 它会杀死你可能获得的任何可能的重用。

当您可以使用可重用的简单方法时,是否真的想要在所有项目中重写所有db / linq请求?

另一件事:选项1中的函数有两个职责:它从mapper对象中获取结果显示它。 这是太多的责任。 责任清单中有一个“和”。 您的选项2只有一个责任:作为模型和视图之间的链接。

我不确定ASP.NET或C#是如何做的。 但我确实知道MVC。

在MVC中,您将应用程序分为两个主要层: 表示层(包含Controller和View)和Model层(包含……模型)。

关键是要分离申请中的3个主要职责:

  1. 应用程序逻辑 ,处理请求,用户输入等。这是控制器。
  2. 表示逻辑 ,处理模板,显示,格式。 那是视图。
  3. 业务逻辑或“重逻辑”,基本上处理其他所有事情。 这基本上就是你的实际应用程序 ,你的应用程序应该做的所有事情都完成了。 此部分处理表示应用程序信息结构的域对象,它处理这些对象到永久存储(无论是会话,数据库还是文件)的映射。

如您所见,在Model上可以找到数据库处理,它有几个优点:

  • 控制器与模型的联系较少。 因为“工作”在模型中完成,如果您想要更改控制器,如果您的数据库处理在模型中,您将能够更轻松地完成。
  • 您获得更多灵活性。 在您想要更改映射方案的情况下(我想从MySQL切换到Postgres),我只需要更改一次(在基本Mapper定义中)。

有关更多信息,请参阅此处的优秀答案: 如何在MVC中构建模型?

我更喜欢第二种方法。 它至少将控制器和业务逻辑分开。 unit testing仍然有点困难(可能我不善于嘲笑)。

我个人更喜欢以下方法。 主要原因是每层的unit testing很容易 – 表示,业务逻辑,数据访问。 此外,您可以在许多开源项目中看到这种方法。

 namespace MyProject.Web.Controllers { public class MyController : Controller { private readonly IKittenService _kittenService ; public MyController(IKittenService kittenService) { _kittenService = kittenService; } public ActionResult Kittens() { // var result = _kittenService.GetLatestKittens(10); // Return something. } } } namespace MyProject.Domain.Kittens { public class Kitten { public string Name {get; set; } public string Url {get; set; } } } namespace MyProject.Services.KittenService { public interface IKittenService { IEnumerable GetLatestKittens(int fluffinessIndex=10); } } namespace MyProject.Services.KittenService { public class KittenService : IKittenService { public IEnumerable GetLatestKittens(int fluffinessIndex=10) { using(var db = new KittenEntities()) { return db.Kittens // this explicit query is here .Where(kitten=>kitten.fluffiness > 10) .Select(kitten=>new { Name=kitten.name, Url=kitten.imageUrl }).Take(10); } } } } 

@Win有一个想法,我或多或少会跟随。

Presentation 呈现

控制器只是作为一座桥梁 ,它什么也没做,它是中间人。 应该很容易测试。

DAL是最难的部分。 有些人喜欢在Web服务上将其分开,我曾经为一个项目做过一次。 这样你就可以让DAL充当其他人(内部或外部)消费的API – 所以我想到了WCF或WebAPI。

这样,您的DAL完全独立于您的Web服务器。 如果某人攻击您的服务器,DAL可能仍然是安全的。

我猜这取决于你。

单一责任原则 。 您的每个课程都应该只有一个改变的理由。 @Zirak给出了一个很好的例子,说明每个人在事件链中如何具有单一的责任感。

让我们看一下您提供的假设测试用例。

 public ActionResult Kittens() // some parameters might be here { using(var db = new KittenEntities()){ // db can also be injected, var result = db.Kittens // this explicit query is here .Where(kitten=>kitten.fluffiness > 10) .Select(kitten=>new { Name=kitten.name, Url=kitten.imageUrl }).Take(10); return Json(result,JsonRequestBehavior.AllowGet); } } 

在它们之间有一个服务层 ,它可能看起来像这样。

 public ActionResult Kittens() // some parameters might be here { using(var service = new KittenService()) { var result = service.GetFluffyKittens(); return Json(result,JsonRequestBehavior.AllowGet); } } public class KittenService : IDisposable { public IEnumerable GetFluffyKittens() { using(var db = new KittenEntities()){ // db can also be injected, return db.Kittens // this explicit query is here .Where(kitten=>kitten.fluffiness > 10) .Select(kitten=>new { Name=kitten.name, Url=kitten.imageUrl }).Take(10); } } } 

通过一些虚构的控制器类,您可以看到这将如何更容易重用。 那很棒! 我们有代码重用,但还有更多的好处。 让我们举个例子说,我们的小猫网站正在疯狂起飞,每个人都想看看蓬松的小猫,所以我们需要对我们的数据库进行分区(分片)。 我们所有db调用的构造函数都需要注入与正确数据库的连接。 使用基于控制器的EF代码,由于DATABASE问题,我们必须更改控制器。

显然,这意味着我们的控制器现在依赖于数据库问题。 他们现在有太多理由要改变,这可能会导致代码中的意外错误,并且需要重新测试与该更改无关的代码。

通过服务,我们可以执行以下操作,同时保护控制器免受该更改。

 public class KittenService : IDisposable { public IEnumerable GetFluffyKittens() { using(var db = GetDbContextForFuffyKittens()){ // db can also be injected, return db.Kittens // this explicit query is here .Where(kitten=>kitten.fluffiness > 10) .Select(kitten=>new { Name=kitten.name, Url=kitten.imageUrl }).Take(10); } } protected KittenEntities GetDbContextForFuffyKittens(){ // ... code to determine the least used shard and get connection string ... var connectionString = GetShardThatIsntBusy(); return new KittensEntities(connectionString); } } 

这里的关键是将更改与代码的其他部分隔离开来。 您应该测试任何受代码更改影响的内容,因此您希望将更改彼此隔离。 这会产生保持代码干燥的副作用,因此您最终会获得更灵活,可重用的类和服务。

分离类还允许您集中以前难以或重复的行为。 考虑记录数据访问中的错误。 在第一种方法中,您需要在任何地方进行记录。 通过介于两者之间的层,您可以轻松插入一些日志逻辑。

 public class KittenService : IDisposable { public IEnumerable GetFluffyKittens() { Func> func = () => { using(var db = GetDbContextForFuffyKittens()){ // db can also be injected, return db.Kittens // this explicit query is here .Where(kitten=>kitten.fluffiness > 10) .Select(kitten=>new { Name=kitten.name, Url=kitten.imageUrl }).Take(10); } }; return this.Execute(func); } protected KittenEntities GetDbContextForFuffyKittens(){ // ... code to determine the least used shard and get connection string ... var connectionString = GetShardThatIsntBusy(); return new KittensEntities(connectionString); } protected T Execute(Func func){ try { return func(); } catch(Exception ex){ Logging.Log(ex); throw ex; } } } 

无论哪种方式都不适合测试。 使用dependency injection来获取DI容器以创建db上下文并将其注入控制器构造函数。

编辑:更多关于测试

如果您可以测试,您可以在发布之前查看应用程序是否按照规范运行。
如果您无法轻松测试,则不会编写测试。

从那个聊天室:

好的,所以在一个简单的应用程序上你编写它并没有太大的变化,但是在一个非常简单的应用程序中你会得到这些令人讨厌的东西,称为依赖项,当你改变它时会打破很多东西,所以你使用dependency injection来注入一个你可以伪造的仓库,然后你可以编写unit testing,以确保你的代码没有

如果我有(注意:真的有)在两个给定选项之间进行选择,为了简单起见,我会说1,但我不建议使用它,因为它很难维护并导致大量重复代码。 控制器应包含尽可能少的业务逻辑。 它应该只委托数据访问,将其映射到ViewModel并将其传递给View。

如果要从控制器中抽象数据访问(这是一件好事),您可能需要创建一个包含GetLatestKittens(int fluffinessIndex)等方法的服务层。

我不建议在POCO中放置数据访问逻辑,这不允许您切换到另一个ORM(例如NHibernate)并重用相同的POCO。