如何将高内聚模型分解成不同的 类

How to break a high cohesive model into different classes

我有一个容器,里面有 8 个继电器。现在我想让客户转 on/off 不同的继电器。到目前为止一切顺利。

一个解决方案可能是:

container.tryTurnOn(1, true);
container.turnRandomOn(true); //turn relais random to on or not.

1表示继电器1,true表示尝试开启; false 表示尝试将其关闭。 如果我在其中添加更多与继电器本身相关而不是与容器相关的方法(改变颜色、自毁或其他一些创造性的东西^^),容器接口将会长大。

所以我认为每个继电器都有一个自己的class是个好主意,这样你就可以做这样的事情

container.getRelais(1).tryTurnOn(true);
container.turnRandomOn(true); //this stay the same

现在我也有不同的规则: 只允许 8 个继电器中的 6 个可以设置为开。 (容器的任务保证了这一点)。

所以在那种情况下我有几个问题:

我从 getRelais 得到的 Relais 是贫血的,它只​​保存 relais 状态,但功能在容器中,因为只有容器知道是否允许 tryTurnOn(true)。 (或者我应该将行为放入中继,通过容器访问其他中继?) 我遇到的第二个问题是循环依赖:Relais 将打开的愿望发送到容器,因此它必须知道容器。容器还必须知道它的子节点(继电器),以检查是否没有打开超过 6 个的继电器。 我也没有再在这里 Tell-Dont-Ask,因为容器向 Relais 询问他们的状态,并根据结果直接从 relais 访问状态并更改它.

问题的一个扩展:

也许我想在那里添加更多规则:

您将如何以面向对象的方式对这些进行建模? 问题域看起来非常有凝聚力,因为所有人都必须了解一切。 你会把行为放在哪里,你会把行为分成不同的组件吗? 我现在不知道如何以面向对象的方式执行此操作,在我看来,无论如何都会在某些过程代码中产生结果。但这就是为什么我问^^

出于这个答案的目的,我将讨论 lamps,因为我发现它更容易形象化。


开与关,tell-don't-ask,界面越来越大

首先,lamp在这件事上没有发言权。 更准确地说,我们可以让lamp关闭,但是lamp不知道它是否可以打开。

尝试在 lamp 中打开和关闭模型会导致 lamp 询问其他 lamp 的状态。那不是一个好的设计。 即使你不相信tell-don't-ask,这样的设计在没有全局锁的情况下保证线程安全也是噩梦。

相反,让我们将 lamp 是否开启或关闭视为 lamp 的外部因素。我们将有一组正在运行的 lamp。打开 lamp 就是将其添加到集合中。关闭它,就是将其从集合中移除。该集合可以有一个设置的最大容量,你有 tell-don't-ask。你告诉集合添加 lamp,它知道它是否可以以及如何做。您不必问它有多少项目并据此决定。


您可以决定其他属性是否需要是外部的。例如,您可以让颜色成为 lamp 的属性。或者你可以有一个颜色服务,它拥有从 lamps 到颜色的 dictionary/map。

我只说开和关,你自己想办法其他属性可能需要类似的处理。


至关重要的是,容器的接口不必随着您添加更多属性而增长。相反,您只需将它们添加到 lamp class,或为它们添加一个新结构。


管理员

集合不负责知道用户是否是管理员。

相反,必须有一个请求对象,它将关联到一个用户会话。边界必须将这个请求映射到一个控制器(这是路由器的责任),我们将有不同的控制器用于管理员和 non-admin 案例。

但是,打开的lamp集合是一样的。因此,我们将把这个 class 一分为二:第一个是没有约束的存储 class,第二个是负责检查的视图 class,这是唯一的方法访问存储 class。现在,控制器可以根据我们是否正在处理来自管理员的请求来请求配置有不同最大容量的视图。

谁提出这些要求?当然是景色。必须有一些用户界面,用户——可能是管理员也可能不是——将使用。此外必须有一些会话管理系统,因为用户必须进行身份验证,否则......你怎么会有角色?

请注意,我正在为不同的用例争论不同的控制器。没有一个大胖子控制器可以处理所有事情。


太阳

原来太阳是个演员。太阳将启动操作。当太阳的亮度发生变化时,我们可能不得不关闭 lamps。

无论您如何模拟太阳,我们都需要一个能够处理太阳亮度变化的太阳适配器。当亮度大于阈值 (95%) 时,适配器将调用控制器关闭所有 lamps。

同样,我们应该能够向适配器询问最后的亮度值。 我不确定控制器应该这样做,还是路由器应该这样做。无论如何...... 这应该会导致在创建 lamp 集合的视图时请求不同的容量。

我希望你能看到如何将这个想法扩展到与太阳相关的更复杂的规则。


合成根,以及创造与毁灭

我们不想要单例,我们也不想要 hard-coded lamps.

会有一个合成根。默认情况下,组合根是 class 保存应用程序的入口点。 虽然,这可以委托给不同的 class,当限制阻止您编写入口点时(例如,您正在为其他东西制作插件)。 你可以想到组合根作为你的粘合剂。

组合根将创建应用程序初始状态所需的不同 classes 的实例。 这可以通过调用工厂来完成。并将link这些实例放在一起。

因此,组合根将创建第一个 lamps 并将它们添加到包含所有 lamps 的容器中。 并且该容器不是单例,lamp 不是 hard-coded。

组合根还将创建 lamp 的集合。


现在,在销毁 lamp 时。如果 lamp 打开了,我们应该将它从 co 中删除lamp 的选择。除了,假设我们正在谈论 Java,默认情况下集合保持 lamp 存活。

在我们开始讨论弱引用和临时引用或类似内容之前……我提醒您,容器和集合不是单例。

这意味着可能有多个。 出于测试目的,我们可能有兴趣在一次执行中创建多个。 这也意味着我们可以向其中多个添加 lamp。这意味着,我们不应该考虑对象的字面破坏。但是从容器中移除。

因此,容器将允许您设置一个侦听器,当 lamp 被删除时将调用该侦听器。组合根将设置该侦听器,以从打开的 lamp 集合中删除 lamp。

此处容器和集合完全解耦,但 link 在 运行 时由组合根编辑。这就是我将合成根视为胶水的意思。


自毁 lamps,当然,可以通过从容器中移除 lamp 来实现。 也许在计时器上?我不知道。

销毁的 lamp 是仍然存在的 lamp,但它不再位于创建它的上下文的容器中。


易于使用

这是可选的,但是,您可能希望在 lamps 上获得所需的界面。

如此处所述,打开和关闭不是 lamp 的属性。我们可能想假装它是。这意味着引用 lamp 中的 lamp 集合。这不仅是一种形式的循环依赖,更重要的是,这意味着 lamp 可以关闭其他 lamp。那不行。

谢天谢地,我们有一个组合根来将事物粘合在一起。我们可以让 lamp 公开打开和关闭,并允许在我们尝试打开或关闭 lamp.

时调用监听器

组合根然后将在创建时添加监听器……好吧,我们可能应该让组合根实例化一个工厂来创建 lamps 并在创建时设置监听器。然后,这些听众将与打开和关闭的 lamp 集合交谈。

这样,lamp 就不会依赖于集合。他们只是不知道该集合的存在。听众依赖于两者,但他们不依赖于听众。同样,工厂将依赖于侦听器和 lamp class,但它们不依赖于工厂。最后,组合根依赖于一切,没有任何东西依赖于组合根。那里没有循环依赖。

顺便说一句,考虑到任何东西都不应该依赖于复合根,传递复合根是 no-no。


我想说明一下,让组合根做依赖注入(或者创建做依赖注入的工厂),是它的初衷。

如果您需要使用外部库(您不信任它,可能想要替换它,或者访问外部系统),您可能希望使用您控制的接口为该外部库创建一个适配器,并从组合根中将其注入到任何需要的地方,而不是让其余代码直接依赖于外部库。

如果你反过来处理依赖注入,你总是会问谁注入了依赖?这个问题最终会引导您进入应用程序的入口点。


在多个上下文中

您可以考虑以下选项,具体取决于您的要求。

正如我上面所说的,可以有多个上下文,每个上下文都有一个容器和一个 lamp 的集合,这也意味着如果我向多个上下文添加一个 lamp , lamp 可能在一个上下文中出现而在另一个上下文中关闭。

可悲的是,我们正在谈论 Java。因此,我建议的解决方案是禁止将 lamp 添加到多个容器和集合中。

为此,我们将定义一个上下文,将上下文注入不同的对象,然后容器和集合可以检查我们是否尝试添加 lamp 正确的上下文。

等一下……那个上下文会有一个容器和一个集合……上下文是组合根!啊,但这意味着上下文有一个包含上下文的容器。并且容器有 lamps 具有包含容器的上下文。循环依赖反击!

传递合成根是 no-no。我们只需要识别它。为此,一个简单的 id 值就可以了。我们传递 id,然后比较 id,然后就这样了。


输出附录

假设您要渲染 lamp。为简单起见,您将拥有一个 UI,其中每个 lamp 都有一个小部件,根据 lamp 是打开还是关闭,它应该是亮的还是暗的。 它也可能会改变颜色和其他东西。

我给你三种解法

  1. 此输出不会自动更新。用户可以请求查看 lamp 的状态。也许可以轮询它以更新它。在这种情况下,将有一个控制器来处理来自用户的请求,并且该控制器将具有读取 lamps 和 lamps 集合的代码,并创建一个响应对象(缺乏行为),并将该响应对象提供给 UI。然后 UI 负责呈现响应对象。 另请参阅“视图模型”和“演示者”。

  2. 我们希望输出自动更新。但是变化很少,所以我们不希望 UI 经常工作。您可以允许侦听器更改 lamp 上的集合。然后组合根将注入监听器,这些监听器将(最好是异步地)调用 UI 来更新其视图模型。

  3. 我们希望输出自动更新。变化无时无刻不在发生。我们关心的是快速做到这一点。让 UI 线程直接读取容器和集合,每帧一次,并据此进行渲染。

不知道该用哪个?使用适配器。


关于测试和单例的附录

如果我们想要自动化测试,我们通常希望 运行 对单次执行进行长列表测试。为此,我们不希望测试相互影响。他们应该是独立的。

这通常被理解为测试不应该有副作用(显然,如果它们没有副作用,它们就不会相互影响)。 这会导致嘲笑,et.al。但让我们不要去那里。

但是,如果我们的解决方案创建了一个单例容器和单例 lamps,那么它们的状态将从一个测试转移到另一个测试。导致每次测试后要清理 up/tear 的下一个额外代码。

再加上单例的所有问题,例如使潜入 hard-coded 依赖项变得非常容易(而不是使用依赖项注入),这会使代码更难推理并保持。

相反,我们不希望他们是单身人士。我们希望每个测试都创建一个新容器,lamps,等等...我们希望每个单例都发生在具有不同对象的单独上下文中。


关于适配器与控制器的附录

适配器和控制器都是与外部系统打交道的方式。如果外部系统是 agent/user(它可以在您的系统上启动操作),您希望控制器处理来自它的请求。

另一方面,适配器旨在控制外部系统,这样您就可以替换该外部系统。

如果您有一个需要两者的外部系统怎么办?你做一个adapter,这个adapter可以调用controller

例如。我们制作了一个纯文本用户界面,我们可能希望在未来用一些 GUI 替换它,或者制作一个网络版本,或类似的东西。重构代码,使所有 UI I/O 都在一个地方。将其提取到 class 中,并在您的控制下使用一个界面。那是你的 UI 适配器。然后您可以以不同的方式实现相同的接口(例如使用 GUI),这样您就可以轻松地替换 UI。 这是多态的一种形式。

当然 UI 应该允许用户与系统交互。因此,这个 UI 适配器应该能够调用控制器来执行不同的操作。 打开和关闭 lamp 等等。 控制器应该担心如何做到这一点。

有时您需要在中间插入一些东西,根据系统状态(无论用户是否经过身份验证,角色是否为管理员,等等)为适配器提供不同的控制器 - 那是路由器。


这是程序化的吗?

可以说,是的。我们可能可以这样说任何现实世界的 OO 解决方案。如果你仔细观察,它最终是程序性的。然而,重要的是,我们要 tell-don't-ask。然而,在任何 getter-setter 对(询问和 not-really-encapsulation 对)中都有程序代码堡垒,因此也许还有改进的余地。

从大局来看,这是面向对象的。我们需要意识到的是,并非每个对象都必须是一个实体。有些对象是服务,只有行为,里面会很程序化。可悲的是,其他一些对象是值,并且具有o 行为。对于那些如果我们在不同的平台(Java 除外),我们可能不会使用对象。

请注意,值是必需的。这就是为什么我们有 int 和 bool 等等。

将它们想象成传递信息的小型计算机。有时消息对于本地类型来说太复杂了。然后我们为这些消息创建一个类型。 这意味着是一个值……你知道,就像 int,但更复杂……但是 Java 是这样,在其他一些语言中它会是一个结构。 也,并非网络中的每台计算机都需要一个状态,它们可以是简单地接收一些输入、处理它并在线发送消息的服务器。

我们没有破坏封装。事实上,通过将域模型隐藏在控制器后面,这些控制器可以是模块化的,并且更改不必在控制器之间传播(如果我们想更改我们存储 lamps 的方式,则不必更改调用控制器的UI。同样,如果我们想改变UI,我们不需要改变控制器)。控制器提供了一个抽象层。

我们制作了一个系统,它本身的行为或多或少像一个大物体。控制器是方法,域模型是状态。您可以在一台计算机中拥有多个。事实上,如果您使用通过网络与设计有类似架构的其他系统通信的控制器,您可以让多台计算机像一台大型计算机一样工作。其中一些可以专用于国家,我们称它们为数据库服务器。其他可以专用于处理信息,它们可以是应用程序服务器(或类似的)。就是里面有小电脑的电脑,里面有小电脑……

但是,一旦你达到了较低的水平,在某些时候,你需要一些程序代码。

让我们看看支柱:抽象,检查,控制器就是这样做的。封装,检查一下,虽然我们把它的头颠倒了开和关,但它不能导致无效状态(我们要防止lamp在多个容器中正确地做到这一点)。继承,你不需要它(好吧,我们有接口继承)。多态,检查,所有的依赖注入。

让我们看看S.O.L.I.D的原则:单一职责,查一下,其实你好像在抱怨我break太多了,但是,single fat controller的问题是它有太多的理由去改变. Open-closed,检查...或多或少,除了添加新的控制器或适配器之外,我没有提出任何扩展点。 Liskov 替换,检查,我们没有违反它,虽然不是教科书示例,但我建议您可以替换适配器。接口隔离原则,查?嗯……我没有涉及到这一点,但是好吧,在实施时遵循它是有意义的。依赖倒置,检查,一路走来,我告诉你不要 hard-code 依赖,而是用组合根注入它们,也没有单例。


复杂玩具示例的附录

关于属性的种类,或许有话要说。举个小例子,假设我们有汽车对象。

首先,假设汽车有型号和颜色。

我们是否应该为每个模型制作一个class?也许,如果一个模型和另一个模型之间的功能差异与系统相关。否则,我们可以为模型制作一个属性。

我们可以问同样的颜色,我们是否应该为每种颜色制作一个class?你可以看到考虑这一点会如何导致多重继承及其问题。如果将来我们想要有多种颜色的汽车,甚至可能是图纸,我们最好使用一个着色组件,一个属性。

如果我们想谈谈车主呢?那应该是汽车的属性吗?也许。但是,车class并没有在上面运行。您想要具有行为的数据。所有权与汽车无关。对于数据库,我们可能会放置一个所有者字段,非常data-oriented。然而,面向对象的设计会建议人们有一份他们拥有的汽车的清单……等等,你可以把一辆车归为多人所有。这在系统中有意义吗?如果多个所有者没有意义,那就是无效状态。封装失败。

我们可以通过使用将汽车映射到所有者的字典来处理这个问题,因为汽车是关键,每辆车只有一个所有者。为了方便获取一个人的汽车列表,我们制作了一个 class ,其中包含从汽车到车主的字典,以及从车主到汽车列表的字典,这是这个新的 class 以保持车主在其列表中拥有该车的不变性,反之亦然。

实际上,将属性移动到字典通常是可行的。 具有处理破坏的缺点。鉴于字典持有对对象的引用,因此可以防止垃圾回收。 Over dohis,并使其通用,然后您就拥有了 Entity-Component-System 的核心,这是一个 data-driven 设计。 不是违反封装,而是倒退。它违背了所有面向对象的设计方法……但是,如果您有很多相互依赖的规则需要绕过传统封装,它可能是一个更好的解决方案。

车开不开怎么办?那是内在的,对吧?行为在车上。直到,你给我一个车外的不变量,比如要求只能有六辆车。 class 中的一个不变量表明我们需要一个新的 class。什么 class 可以自然地表示受数量限制的一组对象的不变量?我知道,一个集合(一个集合,或者一个列表,或者一个向量)。

现在需要小车配合采集,循环依赖。这个答案是我试图解决的问题。


*顺便说一下,在之前尝试回答这个问题时,我已经满了 Model-View-Controller。

All problems in computer science can be solved by another level of abstraction. … But that usually will create another problem.

-- 引用 David Wheeler 的话。