19

tl;博士

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

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


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

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

我有一个带有可爱蓬松小猫的数据库表。每只小猫都有小猫图片链接、小猫蓬松度指数、小猫名字和小猫id。这些映射到 EF 生成的 POCO,称为Kitten. 我可能会在其他项目中使用这个类,而不仅仅是 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<Kitten> 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 的类。基本上它应该是数据库实体之上的一层,因此控制器不必知道实际的数据库、映射器或实体框架。

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

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

澄清1:这在实践中并不是一个简单的应用。这是一个包含数十个控制器和数千行代码的应用程序,实体不仅在这里使用,还用于其他数十个 C# 项目。这里的例子是一个简化的测试用例

4

9 回答 9

28

第二种方法更胜一筹。让我们尝试一个蹩脚的类比:

你进入一家比萨店,走到柜台前。“欢迎来到 McPizza Maestro Double Deluxe,我可以接受您的订单吗?” 脸上长满疙瘩的收银员问你,他眼中的空洞威胁着要引诱你。“是的,我要一份大的橄榄披萨”。“好的”,收银员应了一声,他的声音在“o”的中间发出了嘶哑的声音。他冲着厨房大喊“一个吉米卡特!”

然后,稍等片刻后,您会得到一个带有橄榄的大比萨饼。你有没有发现什么特别的地方?收银员没有说“拿一些面团,像圣诞节一样旋转它,倒一些奶酪和番茄酱,撒上橄榄,然后放入烤箱约8分钟!” 仔细想想,这并不奇怪。收银员只是两个世界之间的门户:想要披萨的顾客和制作披萨的厨师。收银员都知道,厨师从外星人那里得到比萨饼,或者从吉米·卡特那里切片(他的资源正在减少,伙计们)。

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

现在,假设店主认为比萨只是一种时尚(亵渎神灵!),而您切换到更现代的东西,即高档汉堡店。让我们回顾一下会发生什么:

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

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

于 2013-07-15T19:07:22.903 回答
13

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

首先,选项 2 将引发运行时异常,因为 Entity Framework 不支持投影到实体中(Select(e => new Kitten(...))并且它不允许在投影中使用带参数的构造函数。现在,在这种情况下,这个注释似乎有点迂腐,但是通过投影到实体中并返回一个Kitten(或Kittens 的枚举),您隐藏了该方法的真正问题。

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

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

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

除了这个特定的视图之外,很可能没有一个会在任何其他地方被重用。你为什么要第二次在任何地方只用Take10 只小猫?你有第二个小猫列表视图吗?如果是这样,它将有一个原因,并且查询只是偶然相同,现在如果一个更改另一个不一定,否则列表视图没有正确“重用”并且不应该存在两次。或者可能是 Excel 导出使用的相同列表?但也许 Excel 用户希望明天有 1000 只小猫,而视图应该仍然只显示 10 只。或者视图应该显示小猫的nameimageUrlAge明天,但 Excel 用户不想拥有它,因为他们的 Excel 宏将不再正确运行。仅仅因为两段代码是相同的,如果它们处于不同的上下文或具有不同的语义,则不必将它们分解为一个通用的可重用组件。你最好留下一个GetLatestKittensForListViewand GetLatestKittensForExcelExport。或者你最好在你的服务层中根本没有这样的方法。


鉴于这些考虑,去比萨店游览可以类比为什么第一种方法更好:)

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

收银员走进厨房。“柜台上有一个精神病患者,他想吃披萨……这是一块花岗岩……等等……我们首先得有个名字”,他告诉厨师。

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

“我的披萨已经在烤箱里了吗?”突然它从厨房门里喊道。“让我们停止讨论,告诉我如何制作这个披萨,我们不会再有这样的披萨了”,厨师决定。“好吧,这是一个橄榄披萨,上面是番茄酱,底部是奶酪,然后在烤箱里烤 90 分钟,直到它变黑变硬,就像一块平坦的花岗岩岩石。”


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

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

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

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

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<object> ToKittenListViewModel(
    this IQueryable<Kitten> 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<object>();
}

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

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

这样的架构是如此不同,以至于 - 在我看来 - 你根本不能说“也许”或“不是现在,但它可能是未来的要求,或者可能不会”。这是项目的利益相关者必须在您继续进行架构决策之前定义的东西,因为它会显着增加开发成本,如果“可能”永远不会成为现实,我们将在开发和维护上浪费金钱。

我只是在谈论 Web 应用程序中的查询或 GET 请求,它们很少有我称之为“业务逻辑”的东西。POST 请求和修改数据是完全不同的故事。例如,如果禁止在开具发票后更改订单,则这是一般的“业务规则”,无论是哪个视图、Web 服务或后台进程或任何试图更改订单的东西,它通常都适用。我肯定会将这种订单状态检查放入业务服务或任何公共组件中,而不是放入控制器中。

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

于 2013-07-18T16:47:45.800 回答
10

这是那里的关键短语:

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

控制器以 HTTP 为中心。它仅用于处理 HTTP 请求。如果你想在任何其他项目中使用你的模型,即你的业务逻辑,你不能在控制器中有任何逻辑。你必须能够取下你的模型,把它放在别的地方,你所有的业务逻辑仍然有效。

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

当您可以拥有可重用的简单方法时,您真的想重写所有项目中的所有 db/linq 请求吗?

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

于 2013-07-15T18:54:42.550 回答
4

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

在 MVC 中,您将应用程序分为两个主要层:表示层(包含控制器和视图)和模型层(包含...模型)。

重点是将应用程序中的 3 个主要职责分开:

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

如您所见,数据库处理是在模型上找到的,它有几个优点:

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

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

于 2013-07-09T20:02:26.723 回答
3

@Win has the idea I'd more or less follow.

Have the Presentation just presents.

The Controller simply acts as a bridge, it does nothing really, it is the middle man. Should be easy to test.

The DAL is the hardest part. Some like to separate it out on a web service, I have done so for a project once. That way you can also have the DAL act as an API for others (internally or externally) to consume - so WCF or WebAPI comes to mind.

That way your DAL is completely independent of your web server. If someone hacks your server, the DAL is probably still secure.

It's up to you I guess.

于 2013-07-15T18:56:03.720 回答
3

我更喜欢第二种方法。它至少将控制器和业务逻辑分开。单元测试仍然有点困难(可能是我不擅长嘲笑)。

我个人更喜欢下面的方法。主要原因是很容易对每一层进行单元测试——表示、业务逻辑、数据访问。此外,您可以在很多开源项目中看到这种方法。

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<Kitten> GetLatestKittens(int fluffinessIndex=10);
   }
}

namespace MyProject.Services.KittenService
{
   public class KittenService : IKittenService
   {
      public IEnumerable<Kitten> 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); 
         }
      }
   }
}
于 2013-07-09T22:24:37.743 回答
3

单一责任原则。你的每个班级都应该有一个且只有一个改变的理由。@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<Kitten> 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); 
        }
    }
}

使用更多虚构的控制器类,您可以看到这将如何更容易重用。那太棒了!我们有代码重用,但还有更多好处。比方说,我们的小猫网站正在疯狂起飞,每个人都想看毛茸茸的小猫,所以我们需要对我们的数据库(分片)进行分区。我们所有数据库调用的构造函数都需要注入到正确数据库的连接。使用我们基于控制器的 EF 代码,由于 DATABASE 问题,我们将不得不更改控制器。

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

使用服务,我们可以执行以下操作,同时保护控制器免受该更改的影响。

public class KittenService : IDisposable
{
    public IEnumerable<Kitten> 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);
    }
}

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

分离类还允许您集中以前很难或重复的行为。考虑在您的数据访问中记录错误。在第一种方法中,您需要到处进行日志记录。中间有一层,您可以轻松插入一些日志记录逻辑。

public class KittenService : IDisposable
{
    public IEnumerable<Kitten> GetFluffyKittens()
    {
        Func<IEnumerable<Kitten>> 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<T> func){
        try
        {
            return func();
        }
        catch(Exception ex){
            Logging.Log(ex);
            throw ex;
        }
    }
}
于 2013-07-18T16:47:19.287 回答
1

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

编辑:关于测试的更多信息

如果您可以测试,您可以在发布之前查看您的应用程序是否符合规范。
如果您不能轻松测试,您将不会编写测试。

从那个聊天室:

好的,所以在一个普通的应用程序上你编写它并且它并没有太大的变化,但是在一个非普通的应用程序上你会得到这些被称为依赖项的讨厌的东西,当你改变一个时会破坏很多狗屎,所以你使用依赖注入来注入一个你可以伪造的 repo,然后你可以编写单元测试以确保你的代码不会

于 2013-07-09T20:03:40.203 回答
1

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

如果您想从控制器中抽象出数据访问(这是一件好事),您可能需要创建一个包含类似GetLatestKittens(int fluffinessIndex).

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

于 2013-07-09T20:05:07.493 回答