为什么“较低”的应用程序层不了解“较高”的层是一个好主意?


66

在典型的(设计良好的)MVC Web应用程序中,数据库不知道模型代码,模型代码不知道控制器代码,并且控制器代码不知道视图代码。(我想您甚至可以从硬件开始甚至更远的地方开始,而且模式可能相同。)

换个方向,您只能向下移动一层。视图可以知道控制器,但不能知道模型。控制器可以知道模型,但不能知道数据库;该模型可以识别数据库,但不能识别操作系统。(更深层次的内容可能无关紧要。)

我可以直观地理解为什么这是一个好主意,但我无法明确表达。为什么这种单向分层样式是个好主意?


10
也许是因为数据从数据库“上传”到视图。它在数据库中“启动”,在视图中“到达”。层感知的方向与数据“旅行”相反。我喜欢使用“引号”。
杰森·斯威特

1
您在最后一句话中标记了它:单向。为什么链表比双链表更典型?使用单链表,关系的维护变得无限简单。我们以这种方式创建依赖关系图,因为递归调用的可能性大大降低,并且一般的图特征变得更易于推理。合理的结构从本质上讲更易于维护,并且在微观级别(实现)影响图的相同事物在宏观级别(架构)也是如此。
吉米·霍法

2
实际上,在大多数情况下,View都不知道Controller是一个好习惯。由于管制员几乎总是知道该视图,因此让管制员知道该视图会创建一个循环引用
Amy Blankenship

8
不好的类比时间:出于同样的原因,在您驾车时用汽车向后撞你的家伙通常是对事故负责和负责的人。他可以看到您在做什么,应该控制什么,如果他不能回避您,那就意味着他不遵守安全规则。并非相反。而且,通过链接,这使他不必担心身后的事情。
haylem

1
显然,视图知道强类型视图模型。
DazManCat

Answers:


121

层,模块,实际上就是体系结构本身,都是使计算机程序更容易被人理解的手段。解决问题的数字优化方法几乎总是使非模块化,自引用甚至自我修改的代码变得混乱不堪-无论是在内存系统受到严重破坏的嵌入式系统中经过大量优化的汇编代码,还是数百万年后的DNA序列选择压力。这样的系统没有层次,没有明显的信息流方向,实际上根本没有我们可以辨别的结构。对于除了作者以外的所有人,他们似乎都是靠纯粹的魔法工作的。

在软件工程中,我们要避免这种情况。好的体系结构是为了使普通人可以理解的系统而牺牲一些效率的故意决定。一次了解一件事比了解只有在一起使用才有意义的两件事要容易。这就是为什么模块和层是一个好主意的原因。

但是模块不可避免地必须彼此调用函数,并且必须在彼此之间创建层。因此,在实践中,始终必须构建系统,以便某些部分需要其他部分。首选的折中方法是以这样的方式构建它们:一个零件需要另一个零件,但是该零件不需要第一个零件。这正是单向分层为我们提供的:可以在不了解业务规则的情况下了解数据库模式,而在不了解用户界面的情况下了解业务规则。在两个方向上都具有独立性会很好-允许某人在不知道任何内容的情况下编写新的UI关于业务规则的一切-但是实际上这几乎是不可能的。经验法则,例如“没有周期性的依赖关系”或“依赖关系只能下降到一个水平”,只是抓住了一个基本思想的实际可达到的限制,即一次一件事情比两件事情容易理解。


1
“使普通人可以理解系统”是什么意思?我认为这样的表述会鼓励新程序员拒绝您的优点,因为像大多数人一样,他们认为自己比大多数人更聪明,这对他们来说不是问题。我会说“使系统可以被人类理解”
Thomas Bonini

12
对于那些认为完全去耦是要努力实现的理想选择,但无法理解为什么它不起作用的人来说,这是必读的。
罗伯特·哈维

6
好吧,@ Andreas,总有梅尔
TRiG

6
我认为“更容易理解”是不够的。这也是为了使修改,扩展和维护代码变得更加容易。
Mike Weller

1
@Peri:确实存在这样的法律,请参见en.wikipedia.org/wiki/Law_of_Demeter。您是否同意这是另一回事。
Mike Chamberlain 2014年

61

基本动机是:您希望能够撕裂整个图层并替换完全不同(重写)的图层,并且NOBODY应该(能够)注意到差异。

最明显的例子是剥掉底层并替换另一层。当您针对硬件仿真开发上层,然后替代实际硬件时,便会执行此操作。

下一个示例是当您撕裂中间层并替换为另一个中间层时。考虑一个使用在RS-232上运行的协议的应用程序。一天,您必须完全更改协议的编码,因为“其他内容已更改”。(示例:因为您正在通过从洛杉矶市中心到Marina Del Rey的无线电链路工作,并且现在正在通过从洛杉矶市中心到欧罗巴探测器的无线电链路工作,所以从ASCII编码的纯ASCII编码转换为Reed-Solomon编码, ,木星的卫星之一,并且该链接需要更好的前向纠错。)

进行此工作的唯一方法是,如果每个层都将一个已知的,定义好的接口导出到上一层,并期望将一个已知的,定义好的接口导出到下一层。

现在,下层并不完全了解上层并不是什么情况。更确切地说,下层知道的是,紧接其上一层的层将根据其定义的界面精确运行。它一无所知,因为根据定义,不在定义的接口中的任何内容都可以更改而无需通知。

RS-232层不知道它运行的是ASCII,Reed-Solomon,Unicode(阿拉伯语代码页,日语代码页,Rigellian Beta代码页)还是什么。它只知道它正在获取字节序列,并将这些字节写入端口。下周,他可能会从完全不同的东西中获得完全不同的字节序列。他不在乎。他只是移动字节。

分层设计的第一个(也是最好的)解释是Dijkstra的经典论文“ Multiprogramming System的结构”。在此业务中需要阅读。


这很有用,感谢您的链接。我希望我可以选择两个答案作为最佳答案。我基本上把硬币扔了过来,又挑了另一个,但我仍然投票赞成你的。
杰森·斯威特

+1是出色的例子。我喜欢JRS
ViSu 2013年

@JasonSwett:如果我掷硬币,我会把它掷硬币直到指定答案!^^ +1给约翰。
Olivier Dulac

我在某种程度上不同意这一点,因为您很少希望能够剥离业务规则层并将其与另一个交换。与UI或数据访问技术相比,业务规则的变化要慢得多。
安迪

叮叮叮!!!我认为您要寻找的词是“解耦”。这就是好的API的用途。定义模块的公共接口,以便可以通用使用。
Evan Plaice

8

因为更高的水平可能会改变。

当发生这种情况时,无论是由于需求变化,新用户,不同技术,模块化(即单向分层)应用程序,都需要较少的维护,并且更容易适应新的需求。


4

我认为主要原因是它使事情之间的联系更加紧密。耦合越紧密,以后发生问题的可能性就越大。请参阅本文更多信息:联轴器

这是节选:

缺点

紧密耦合的系统往往表现出以下发展特征,这通常被视为不利条件:一个模块的更改通常会导致其他模块的更改产生连锁反应。由于模块间依赖性增加,模块的组装可能需要更多的精力和/或时间。特定模块可能更难重用和/或测试,因为必须包含从属模块。

出于这样的理由,说有一个tigher耦合系统是出于性能方面的考虑。我提到的文章也对此有一些信息。



4

图层不应具有双向依赖性

分层体系结构的优点在于,这些层应该可以独立使用:

  • 您应该能够在第一个表示层之外构建一个新的表示层,而无需更改下一层(例如,在现有Web界面之外构建一个API层)
  • 您应该能够重构或最终替换下层而无需更改顶层

这些条件基本上是对称的。他们解释了为什么通常只有一个依赖方向更好,而没有哪个更好。

依赖方向应遵循命令方向

我们之所以选择自上而下的依赖结构,是因为顶级对象创建并使用了底层对象。依赖关系从本质上讲是一种关系,意思是“如果没有 B,A 就不能依靠B”。因此,如果A中的对象使用B中的对象,那么依赖关系就应该这样。

这在某种程度上是任意的。在其他模式(例如MVVM)中,控制很容易从底层流出。例如,您可以设置一个标签,该标签的可见标题绑定到变量并随其更改。但是,通常最好还是具有自上而下的依赖关系,因为主要对象始终是用户与之交互的对象,而这些对象完成了大部分工作。

虽然从上至下使用方法调用,但从下至上(通常)使用事件。即使控件以其他方式流动,事件也可以使依赖关系自上而下。顶层对象订阅底层的事件。底层对作为插件的顶层一无所知。

还有其他保持单一方向的方法,例如:

  • 延续(将lambda或要调用的方法传递给异步方法,并将事件传递给异步方法)
  • 子类化(在B的父类的A中创建一个子类,然后将其注入底层,有点像插件)

3

我想在Matt Fenwick和Kilian Foth已经解释过的内容上加上我的两分钱。

软件体系结构的一个原则是,应通过组成较小的自包含模块(黑盒)来构建复杂的程序:这可以最大程度地减少依赖性,从而降低复杂性。因此,这种单向依赖关系是一个好主意,因为它使您更容易理解软件,并且管理复杂性是软件开发中最重要的问题之一。

因此,在分层体系结构中,下层是黑盒,它们实现了抽象层,在这些抽象层之上构建了上层。如果较低层(例如,B层)可以看到较高层A的详细信息,则B不再是黑匣子:其实现细节取决于其自身用户的某些细节,但是黑匣子的思想是内容(其实现)与其用户无关!


3

纯娱乐。

想想拉拉队的金字塔。底行支撑着它们上方的行。

如果那排上的啦啦队长往下看,他们将保持稳定并保持平衡,以使上方的那些不摔倒。

如果她抬起头来看看上面每个人的状况,她将失去平衡,导致整个筹码掉落。

这不是真正的技术,但我认为这可能会有所帮助。


3

从软件维护的角度来看,虽然易于理解并且在一定程度上可替换的组件无疑是一个很好的理由,但一个同样重要的理由(可能是首先发明了层的理由)也是如此。最重要的是,依赖关系可能会破坏事物。

例如,假设A依赖于B。由于没有任何事物依赖于A,因此开发人员可以自由地将A更改为他们的内心内容,而不必担心他们会破坏除A以外的任何事物。但是,如果开发人员想要更改B,则可以进行任何更改用B编写的文件可能会破坏A。这在计算机早期(结构化开发)很常见,开发人员将在程序的一部分中修复错误,并在其他地方的程序中完全无关的部分中引发错误。都是因为依赖。

为了继续该示例,现在假设A依赖于B并且B依赖于A. IOW,循环依赖。现在,无论何时何地进行更改,都有可能破坏其他模块。B的更改仍然可以破坏A,但是现在A的更改也可以破坏B。

因此,在您最初的问题中,如果您是一个小组中的一个小项目,那么这一切都是过大的,因为您可以随意更改模块。但是,如果您在一个规模较大的项目中,如果所有模块都依赖于其他模块,那么每次需要更改时,都有可能破坏其他模块。在大型项目中,很难确定所有影响,因此您可能会错过一些影响。

在有许多开发人员的大型项目中(例如,一些仅工作在A层,B层和C层的开发人员),情况会变得更糟。很有可能必须与其他层的成员一起审查/讨论每个更改,以确保您的更改不会中断或强迫他们进行正在处理的工作。如果您的更改确实迫使其他人进行更改,那么您必须说服他们他们应该进行更改,因为他们不会仅仅因为您在模块中拥有这种出色的新工作方式而愿意承担更多的工作。IOW,一个官僚的噩梦。

但是,如果您将依赖关系限制为A依赖于B,B依赖于C,那么只有C层人员需要协调他们对两个团队的变更。B层只需要与A层团队协调更改,A层团队可以自由地做他们想做的事,因为他们的代码不会影响B层或C层。因此,理想情况下,您将设计层,以便C层非常变化B几乎没有变化,而A则做了大部分变化。


+1在我的雇主那里,我们实际上有一个内部图,它描述了您的最后一段的本质,因为它适用于我正在研究的产品,即,越往下走,变更率就越低(应该是)。
RobV

1

较低层不知道较高层的最基本原因是,存在更多种类的较高层。例如,您的Linux系统上有成千上万个不同的程序,但是它们调用相同的C库malloc函数。因此,依赖关系就是从这些程序到该库。

请注意,“下层”实际上是中间层。

考虑一个通过某些设备驱动程序与外界进行通信的应用程序。操作系统在中间

操作系统不依赖于应用程序内或设备驱动程序内的详细信息。相同类型的设备驱动程序有很多种,它们共享相同的设备驱动程序框架。有时,内核黑客为了特定的硬件或设备而不得不在框架中放入一些特殊情况处理(我遇到的最近示例:Linux的USB串行框架中的PL2303特定代码)。发生这种情况时,他们通常会在评论中指出应该吸走多少东西,应该将其删除。即使OS调用了驱动程序中的功能,调用也会通过使驱动程序看起来相同的钩子进行,而当驱动程序调用OS时,它们通常直接按名称使用特定的功能。

因此,在某些方面,操作系统是真正从应用的角度出发的下层,并从应用的角度来看:一种通信枢纽里的东西连接和数据切换到去适当的途径。它有助于通信集线器的设计,以导出可被任何人使用的灵活服务,而不是将任何设备或应用程序特定的hacks移动到集线器中。


我很高兴,只要我不必担心在特定的CPU引脚上设置特定的电压即可:)
CVn

1

关注点分离和分而治之的方法可能是对此问题的另一种解释。关注点分离提供了可移植性,在某些更复杂的体系结构中,它提供了平台独立的扩展和性能优势。

在这种情况下,如果您考虑一个5层架构(客户端,表示,业务,集成和资源层),则较低级别的体系结构不应了解较高级别的逻辑和业务,反之亦然。我指的是较低级别的集成和资源级别。集成中提供的数据库集成接口以及真实的数据库和Web服务(第三方数据提供程序)属于资源层。这样一来,就可伸缩性或其他方面而言,假设您将MySQL数据库更改为像MangoDB这样的NoSQL文档数据库。

在这种方法中,业务层不关心集成层如何提供资源的连接/传输。它仅查找集成层提供的数据访问对象。可以将其扩展到更多方案,但基本上,关注点分离可能是造成此问题的第一原因。


1

扩展基里安·福斯(Kilian Foth)的答案,这种分层的方向对应于人类探索系统的方向。

想象一下,您是一位新开发人员,负责修复分层系统中的错误。

错误通常是客户需求和获得的东西之间的不匹配。当客户通过UI与系统进行通信并通过UI获取结果(UI字面意思是“用户界面”)时,也会按照UI报告错误。因此,作为开发人员,您别无选择,只能开始查看UI,以了解发生了什么。

这就是为什么需要自上而下的连接。现在,为什么我们没有双向连接?

好了,您有三种情况可能会发生该错误。

它可能发生在UI代码本身中,因此被本地化。这很容易,您只需要找到一个位置并修复它。

由于UI的调用,它可能发生在系统的其他部分。这很困难,您可以跟踪呼叫树,找到发生错误的位置并进行修复。

它可能是由于调用INTO UI代码而导致的。这很困难,您必须抓住呼叫,找到其来源,然后找出错误发生的位置。考虑到您的起点位于调用树的单个分支的深处,并且您首先需要找到正确的调用树,UI代码中可能有多个调用,调试已为您切开。

为了尽可能消除最困难的情况,强烈建议不要使用循环依赖项,各层大多以自顶向下的方式连接。即使需要其他方式的连接,也通常会受到限制并明确定义。例如,即使使用回调(一种反向连接),在回调中调用的代码通常也首先提供此回调,为反向连接实现某种“选择加入”,并限制它们对理解反向连接的影响。系统。

分层是一种工具,主要针对支持现有系统的开发人员。好吧,层之间的连接也反映了这一点。


-1

我想在这里明确提及的另一个原因是代码可重用性。我们已经有了被替换的RS232介质的示例,因此让我们再往前走一步...

想象一下您正在开发驱动程序。它是您的工作,并且您写很多东西。协议可能会在某些时候开始重复,物理介质也会如此。

因此,您将要开始做的事情-除非您非常喜欢一遍又一遍地做同一件事-是为这些事情编写可重用的层。

假设您必须为Modbus设备编写5个驱动程序。其中一个使用Modbus TCP,两个使用RS485上的Modbus,其余通过RS232传输。您不会重复实现Modbus 5次,因为您要编写5个驱动程序。另外,您不会重复实现Modbus 3次,因为您下面有3个不同的物理层。

您要做的是编写一个TCP媒体访问,一个RS485媒体访问以及一个RS232媒体访问。知道此时将在上面有一个Modbus层是否明智?可能不是。您要实现的下一个驱动程序也可能使用以太网,但使用HTTP-REST。如果您必须重新实现以太网媒体访问权限以通过HTTP进行通信,那将是一种耻辱。

在上面的一层中,您将只实现一次Modbus。那个Modbus层再一次将不知道那一层的驱动程序。这些驱动程序当然必须知道他们应该使用Modbus,并且应该知道他们正在使用以太网。但是,无论如何实施我刚刚描述的方式,您都不仅可以撕掉一层并替换它。您当然可以-而这对我来说是最大的好处,请继续使用现有的以太网层,以完全与最初导致其创建的项目无关。

这一点,我们可能每天都将其视为开发人员,这可以节省大量时间。有无数种用于各种协议和其他事物的库。这些存在的原因是诸如遵循命令方向的依赖关系之类的原理,这使我们能够构建可重用的软件层。


By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.