如何理解松耦合应用程序中的整体架构?

43

我们一直在使用松耦合和依赖注入来开发代码。

许多“服务”样式的类都有一个构造函数和一个实现接口的方法。每个单独的类在孤立状态下非常容易理解。

然而,由于耦合度较低,只看一个类并不能告诉你它周围的类或者它在更大的框架中的位置。

在Eclipse中跳转到协作者也不容易,因为你必须通过接口来进行。如果接口是Runnable,这对于找出实际插入的类没有任何帮助。实际上,需要返回DI容器定义并从那里尝试搞清楚事情。

这里是一行来自依赖注入服务类的代码:

  // myExpiryCutoffDateService was injected, 
  Date cutoff = myExpiryCutoffDateService.get();

这里的耦合性非常松散。到期日期可以用任何方式实现。

以下是在更紧密耦合的应用程序中可能看起来像什么。

  ExpiryDateService = new ExpiryDateService();
  Date cutoff = getCutoffDate( databaseConnection, paymentInstrument );

从紧密耦合的版本中,我可以推断出截止日期是通过使用数据库连接从支付工具中确定的。

相比第二种风格的代码,我发现第一种风格的代码更难理解。

你可能会说,在阅读这个类时,我不需要知道如何计算截止日期。这是正确的,但如果我正在调试错误或确定增强功能的位置,那么了解这些信息是有用的。

还有其他人遇到这个问题吗?你们都采取了什么解决方案?这只是需要适应的问题吗?是否有任何工具可以可视化类之间的连接方式?我应该使类更大还是更紧密耦合?

(我刻意让这个问题与容器无关,因为我对任何答案感兴趣。)

10个回答

35

虽然我不知道如何在一段话中回答这个问题,但我尝试用一篇博客文章来回答它:http://blog.ploeh.dk/2012/02/02/LooseCouplingAndTheBigPicture.aspx

总结一下,我认为最重要的几点是:

  • 理解松散耦合的代码库需要有不同的思维方式。虽然很难“跳到协作者”,但这也应该基本上与相关性无关。
  • 松散耦合主要是关于理解部分而不是整体。你很少需要同时理解全部。
  • 在定位错误时,你应该依靠堆栈跟踪而不是代码的静态结构来了解协作者。
  • 确保代码易于理解的责任在于开发人员编写代码,而不是阅读代码的开发人员。

11
你难以理解整个系统的部分原因可能在于你的代码库没有组织得很好。你的合作者应该放在同一个文件夹或附近文件夹中。如果你的顶级架构看起来像/models /views /controllers,那么你很容易会感到困惑,因为你可能有大量的文件在每个文件夹中。按照它们与问题域的关系将模块聚集在一起,而不是按“文件类型”进行分类。了解更多信息,请参见:http://blog.8thlight.com/uncle-bob/2011/09/30/Screaming-Architecture.html - timoxley
也许我们的抽象过于抽象了。我们主要使用4个接口,每个接口都包含一个方法:run()、<T> get()、appendXML(XMLElement e)、modify(<T> thing)。 - WW.
这些听起来完全适合RAP,但它们本身并没有很好地传达它们所扮演的角色。也许你可以使用参数/变量命名来解决这个问题。 - Mark Seemann
1
依赖于堆栈跟踪的一个问题是,当您没有堆栈跟踪时,并且无法轻松获取堆栈跟踪,并且处于尝试形成假设以解释故障之前的阶段时,这会成为一个问题。如果您正在诊断具有异步代码的仅在生产环境中出现的错误,则通常没有可用的有意义的堆栈跟踪。这些时刻使得松散耦合系统的诊断特别痛苦。 - Ian Griffiths

12

一些工具能识别DI框架并知道如何解决依赖关系,使您能够以自然的方式浏览代码。但当这不可用时,您只需尽可能地使用IDE提供的功能。

我使用Visual Studio和一个定制的框架,所以你描述的问题就是我的生活。在Visual Studio中,SHIFT+F12是我的好朋友。它会显示光标下符号的所有引用。过一段时间后,您将习惯于对代码进行必要的非线性导航,并很自然地思考“哪个类实现了这个接口”和“注入/配置站点在哪里,以便我可以看到哪个类被用来满足这个接口依赖关系”。

还有可用于VS的扩展,提供UI增强功能,帮助处理此类问题,例如Productivity Power Tools。例如,您可以将鼠标悬停在接口上,信息框将弹出,您可以单击“Implemented By”以查看解决方案中实现该接口的所有类。您可以双击跳转到其中任何一个类的定义。(但我仍然通常只使用SHIFT+F12)。


4
我们使用 ReSharper 进行开发,它对工作效率有很大提升。 - TrueWill
3
没有 ReSharper 我无法生存。我已经说服了我加入的最近三个团队投资它,并且这也改变了他们的生活。 - scottm

8
我刚刚参与了一次关于这个问题的内部讨论,并写下了这篇文章,我认为它太好了,不能不分享。我将它(几乎)未经编辑地复制在这里,尽管它是更大的内部讨论的一部分,但我认为大部分内容可以独立存在。
这次讨论是关于引入名为IPurchaseReceiptService的自定义接口,以及是否应该用IObserver<T>来替换它。

我不能说我对这方面有很强的数据支持-这只是一些我正在追求的理论...... 不过,我目前关于认知负荷的理论大致如下:考虑你的特殊 IPurchaseReceiptService

public interface IPurchaseReceiptService
{
    void SendReceipt(string transactionId, string userGuid);
}

如果我们将其保留为当前的Header Interface,它只有一个名为SendReceipt的方法。这很酷。
不太酷的是你必须为接口和方法分别想出一个名称。两者之间存在一些重叠:单词Receipt出现了两次。在我看来,有时这种重叠可能更加明显。
此外,接口的名称为IPurchaseReceiptService,这也不是特别有帮助。 Service后缀本质上是新的Manager,并且在我看来是一种设计问题。
此外,你不仅要命名接口和方法,还需要在使用变量时对其进行命名:
public EvoNotifyController(
    ICreditCardService creditCardService,
    IPurchaseReceiptService purchaseReceiptService,
    EvoCipher cipher
)

此时,您已经重复说了三遍同样的事情。根据我的理论,这是认知负荷和设计应该更简单的气味。
现在,将其与使用众所周知的界面 IObserver<T> 进行对比:
public EvoNotifyController(
    ICreditCardService creditCardService,
    IObserver<TransactionInfo> purchaseReceiptService,
    EvoCipher cipher
)

这使您可以摆脱繁文缛节,将设计简化为核心问题。您仍然具有意图揭示的命名方式 - 您只需将设计从 类型名称角色提示 转换为 参数名称角色提示


当涉及到“断开性(disconnectedness)”的讨论时,我并不抱有使用IObserver<T>就能神奇地解决这个问题的幻想,但我对此有另一种看法。

我的理论是,许多程序员发现编程接口很困难的原因恰恰在于他们习惯了Visual Studio的“转到定义(Go to definition)”功能(顺便说一下,这又是工具堆积腐烂心智的又一个例子)。这些程序员总是处于一种需要知道接口“另一侧”的状态。为什么会这样?这是因为抽象化不好吗?

这与RAP(RAP)有关,因为如果你确认程序员的信念,即每个接口背后都有一个特定的实现,那么他们认为接口只会妨碍事情的发展也就不足为奇了。

然而,如果您应用了RAP,我希望程序员们会逐渐认识到,在特定接口的背后可能有任何实现,并且他们的客户端代码必须能够处理该接口的任何实现,而不会改变系统的正确性。如果这个理论成立,我们刚刚在代码库中引入了Liskov替换原则,而不会吓到任何人,因为这是一种高深概念他们不理解 :)

1
我并不是在说接口好的更好,但OnNext(TransactionInfo value)传达了“我们正在观察已发生的交易”,而SendReceipt则传达了“这将导致发送收据”。'purchaseReceiptService'在任一情况下都没有告诉我这一点。这是我的看法。 - Yves Reynhout
“These programmers are perpetually in a state of mind where they need to know what's 'on the other side of an interface'. Why?" 的政治正确答案可能是“因为单元测试不足”。我非常支持必要时的文档和易读的命名方式,以减少文档实例,因此对于“难道是因为抽象不好吗?”的回答是否定的。我认为更有可能的是具体名称不太好,遮盖了抽象语义。 - Andy Dent
基于这个基础,你只需要根据签名创建接口。再加上泛型,你只需要很少的接口。Run、Process<T>、Provider<T>、Calculator<X,Y>。这些接口名称已经不再传达任何意义。但是管理复杂性就是要找到有用的抽象。这些抽象太过抽象,以至于开始变得无用。 - WW.
@WW。抽象仍然被命名,只是用它们的变量名而不是类型来命名。在许多编程语言中编写的代码(即所有动态类型语言)都是这样工作的。 - Mark Seemann

7
然而,由于耦合度较低,查看一个类并不能告诉你周围的类是什么或它在更大的图景中的位置。对每个类来说,您都确切地知道类依赖于哪些对象才能在运行时提供其功能。您知道它们,因为您知道预期注入哪些对象。您不知道的是实际的具体类将在运行时注入,该类将实现您所知道的类(们)依赖于接口或基类。因此,如果您想查看注入的实际类是什么,只需查看该类的配置文件,以查看注入的具体类。您还可以使用IDE提供的工具。因为您提到了Eclipse,Spring已经为其提供了插件,并且还有一个显示您配置的bean的可视选项卡。您有检查过吗?这不就是您要寻找的吗?请参阅Spring论坛中的相同讨论。更新:重新阅读您的问题后,我认为这不是一个真正的问题。我的意思是如下。像所有的“松耦合”一样,它不是万灵药,本身也有缺点。大多数人倾向于关注优点,但和任何解决方案一样,它也有其缺点。您在问题中所描述的是其中一个主要的缺点,即确实不容易看到全局视图,因为您可以通过“任何东西”进行配置和插入。还有其他缺点,例如比紧密耦合的应用程序慢等等。无论如何,再次强调,您在问题中描述的不是您遇到并可以找到标准解决方法(或任何方式解决)的问题。这是松耦合的缺点之一,而且您必须决定此成本是否比实际获得的收益更高,就像在任何设计决策权衡中一样。这就像问:嘿,我正在使用名为“Singleton”的模式。它很好用,但我不能创建新对象!怎么办呢?好吧,你不能;但如果您需要,则可能适合单例模式...

也许接口只需要更好的文档,这样你就知道可以从注入的类中期望什么? - Ocelot20
在我的问题中,我可能应该说“关于它周围的类的实现没有任何信息”。理智上我知道这不应该有影响,但当寻找正确的方法来添加新功能或修复错误时,这仍然会使代码阅读和理解变慢。 - WW.
你的更新很有趣。你似乎在说这是显而易见的。我读过的关于松耦合的内容中没有提到这一点作为缺点,因此我们在编写代码时才发现了它。我认为值得理解如何改进问题区域或者也许我们使耦合过于松散了。 - WW.
@WW.: 如上所述,所有的方法都有优点和缺点。即使是面向对象编程在某些情况下也不适用,比如微程序设计。反射、DI、MVC、设计模式等等。对于所有这些,你知道其中的好处,但也存在缺点。例如,设计模式提供了灵活性,但也会导致类数量过多。反射会降低速度等等。所有这些确实存在一定程度上的问题。当你进行设计时,你会选择权衡。在你的情况下,你只看到了优点。如果你开始搜索缺点,你就会发现它们被提到了。你只是没有看完整个故事。 - Cratylus
@WW.:开始搜索完整的评论,阅读缺点和不足之处。你刚刚碰到了其中之一。希望好处比问题多。否则,请阅读我的Singleton类比。 - Cratylus

5
一件帮助我的事情是将多个紧密相关的类放在同一个文件中。我知道这与通常的建议(每个文件只有1个类)相反,我一般也赞同这种建议,但在我的应用程序架构中,这样做非常有效。下面我将尝试解释在哪种情况下可以这样做。
我的业务层架构是围绕业务命令的概念设计的。定义了命令类(简单的DTO,仅包含数据而没有行为),并且对于每个命令,都有一个“命令处理程序”,其中包含执行此命令的业务逻辑。每个命令处理程序实现通用的ICommandHandler接口,其中TCommand是实际的业务命令。
消费者依赖于ICommandHandler并创建新的命令实例,并使用注入的处理程序来执行这些命令。如下所示:
public class Consumer
{
    private ICommandHandler<CustomerMovedCommand> handler;

    public Consumer(ICommandHandler<CustomerMovedCommand> h)
    {
        this.handler = h;
    }

    public void MoveCustomer(int customerId, Address address)
    {
        var command = new CustomerMovedCommand();

        command.CustomerId = customerId;
        command.NewAddress = address;

        this.handler.Handle(command);
    }
}

现在,消费者只依赖于特定的 ICommandHandler<TCommand> ,并且对实际实现没有概念(正如应该的那样)。然而,尽管 Consumer 不应知道实现细节,在开发过程中,作为开发人员的我非常关注实际执行的业务逻辑,因为开发是以垂直片段进行的;这意味着我经常同时处理简单功能的 UI 和业务逻辑。这意味着我经常在业务逻辑和 UI 逻辑之间切换。
所以我做的是将命令(在此示例中为 CustomerMovedCommand)和 ICommandHandler<CustomerMovedCommand> 的实现放在同一个文件中,命令在前。因为命令本身是具体的(因为它是 DTO,所以没有抽象的理由),跳转到该类很容易(在 Visual Studio 中按 F12)。通过将处理程序放在命令旁边,跳转到命令也意味着跳转到业务逻辑。
当然,这仅适用于命令和处理程序可以存在于同一个程序集中的情况。当您的命令需要单独部署时(例如在客户端/服务器场景中重用它们时),这将无法工作。
当然,这只是我的业务层的45%。另一个很大的部分(约45%)是查询,并且它们的设计方式相似,使用查询类和查询处理程序。这两个类也放置在同一个文件中,这使我能够快速导航到业务逻辑。
因为命令和查询占据了我的业务层的90%左右,所以在大多数情况下,我可以非常快速地从表示层移动到业务层,甚至在业务层内轻松导航。
我必须说,这是我将多个类放置在同一文件中的唯一两种情况,但这使得导航变得更加容易。
如果您想了解更多关于我如何设计这些内容的信息,我已经写了两篇文章:

4
在我看来,松耦合的代码可以帮助您很多,但我同意您对其可读性的看法。真正的问题是方法名称也应传达有价值的信息。
这就是“领域驱动设计”所述的“意图明确的接口”原则(http://domaindrivendesign.org/node/113)。
您可以重命名“get”方法:
// intention revealing name
Date cutoff = myExpiryCutoffDateService.calculateFromPayment();

我建议您彻底阅读DDD原则,这样您的代码就可以变得更易读,从而更易于管理。


2
我发现The Brain作为节点映射工具在开发中非常有用。如果你编写一些脚本将你的源代码解析成The Brain可以接受的XML格式,你就可以轻松地浏览你的系统了。
秘密的关键是在你的代码注释中放置guids,每个你想要跟踪的元素上都要标记,然后在The Brain中点击节点即可转到你的IDE中对应的guid。

2

文档!

没错,你指出了松耦合代码的主要缺点。如果你已经意识到最终会回报,那么它确实会更长时间地找到需要修改的“位置”,你可能需要打开几个文件才能找到“正确的位置”……

但是这时有一件非常重要的事情:文档。奇怪的是没有一个答案明确提到过这一点,这是所有大型开发项目中的一个重要需求。

API文档
具有良好搜索功能的APIDoc。每个文件和几乎每个方法都有清晰的描述。

“大局”文档
我认为拥有解释整体情况的Wiki很好。Bob制作了代理系统?它是如何工作的?它是否处理身份验证?哪种组件将使用它?不是整个教程,只是一个可以阅读5分钟,了解涉及哪些组件以及它们如何链接在一起的地方。

我同意Mark Seemann答案的所有观点,但是当你第一次进入一个项目时,即使你很好地理解了解耦合的原理,你仍然需要猜测很多,或者需要某种帮助来找出需要开发特定功能的实现位置。

...再次强调:APIDoc和小型开发者Wiki。


2
根据项目中有多少开发人员以及是否想在不同项目中重用某些部分,松散耦合可以帮助您很多。如果您的团队很大,项目需要跨越数年,具有松散耦合可以帮助工作更轻松地分配给不同组的开发人员。我使用Spring / Java进行大量DI,并且Eclipse提供了一些图形来显示依赖项。使用F3打开光标下的类非常有帮助。如前所述,熟悉工具的快捷方式将有所帮助。
另一件要考虑的事情是创建自定义类或包装器,因为它们比已有的通用类(如日期)更容易跟踪。
如果您使用多个应用程序模块或层,则可能很难理解项目流程的确切情况,因此您可能需要创建/使用一些自定义工具来查看所有内容之间的关系。我为自己创建了这个,它帮助我更轻松地理解项目结构。

0

我很惊讶居然没有人谈论过松耦合代码的可测试性(当然是指单元测试),以及紧耦合设计的不可测试性(同样是指单元测试)!选择哪种设计显而易见。如今,有了所有的模拟和覆盖框架,这是显而易见的,至少对我来说是这样。

除非你不进行代码的单元测试,或者你认为你在进行单元测试,但实际上并没有......通过紧耦合几乎无法实现隔离测试。

你认为你必须从IDE中导航到所有依赖项吗?忘了吧!这与编译和运行时的情况相同。几乎找不到任何编译错误,除非你测试它,也就是执行它。想知道接口背后的内容?设置断点并运行该应用程序。

阿门。

...在评论后更新...

不确定这是否适用于您,但在Eclipse中有一个称为层次结构视图的东西。它会显示您项目中(不确定工作区是否也包括在内)接口的所有实现。您只需导航到接口并按F4即可。然后它将向您显示实现接口的所有具体和抽象类。

The hierarchy view in Eclipse after pressing F4


我们编写的代码的可测试性基本上是完美的。但是,我正在努力解决问题。你的答案是使用调试器,这意味着一切都变得具体了。 - WW.
1
@WW:我不知道你使用哪个IDE,但为了理解整体情况,Eclipse层次结构视图可能会很有用。 - Jagger

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接