在过去的许多年里,关于系统的架构产生了很多想法。包括:
- Hexagonal Architecture (又名Ports and Adapters),由Alistair Cockburn提出,并且被Steve Freeman和Nat Pryce在他们著作Growing Object Oriented Software中采用。
- Onion Architecture ,由Jeffrey Palermo提出。
- Screaming Architecture ,由我去年的一篇博文提出。
- DCI,由James Coplien和Trygve Reenskaug提出。
- BCE,由Ivar Jacobson在他的著作Object Oriented Software Engineering: A Use-Case Driven Approach中提出。
这些架构细看不同,然则相似。它们有着一个相同的目标,那就是通过给软件分层将不同的关注点分隔开来。每种架构都至少划分了业务逻辑(business rules)层和界面(interfaces)层。
赖于这些架构产生的系统具有以下特性:
- 框架无关。不依赖于任何库中特定的功能,仅仅将其作为工具而不是受之所限。
- 可测试性。业务逻辑可脱离于UI、数据库、Web服务器或者任何其他的外部元素进行测试。
- UI无关。系统可以任意更换UI。例如Web UI可以被替换成console UI而不用更改业务逻辑。
- 数据库无关。系统可以随意将Oracle或者SQL Server替换成Mongo、BigTable、CouchDB或者其他数据库。业务逻辑不应该绑定到特定数据库。
- 外部代理无关。总而言之,业务逻辑不应该知道外部世界的任何事情。
本文开头的图片中将所有这些架构整合成了一个单独可操作的模式。
依赖规则
同心圆代表软件中不同的部分,外层为机制(mechanisms)里层为策略(policies)。
使得架构有效的最关键规则是依赖规则。依赖规则规定依赖的方向只能由内向外。内部圆圈不能知道外部圆圈,特别的,外部圆圈中定义的函数、类、变量或者任何其他软件实体不能被内部引用。
同理,外部圆圈使用的数据格式不应该被内部圆圈使用,特别是由外部框架产生的格式。总之内部圆圈不应该受到外部圆圈任何影响。
实体(Entities)
实体封装了企业范围的业务逻辑。一个实体可以是包含方法的对象也可以是数据结构和函数的集合。实体可以被企业内任何不同的应用程序使用。
如果你不是身在企业,仅仅只是写一个单独的程序,那么实体就是该程序的业务对象。它们封装了最通用和高层次的规则。即便外部如何变化,这部分是不大可能变动的。例如,这部分不会受页面导航或者安全的影响。对应用程序进行的任何可操作的改变都不应该影响到实体层。
用例(Use Cases)
软件的该层包含了应用程序特定的业务逻辑。它封装和实现了系统的所有用例。这些用例协调进出实体的数据流并且指导实体使用企业内业务逻辑以完成目标。
该层的更改不应该影响实体层。该层也同样不应该被外部数据库、UI或者任何框架所影响。
用例所对应的该软件层会受到应用程序的操作更改的影响。如果用例的细节改变了,那么该层的一些代码自然要更改。
接口适配器(Interface Adapters)
软件的该层由适配器的集合构成,这些适配器将用例和实体使用的数据格式转化为外部代理如数据库或Web使用的数据格式。该层包含了构成GUI的MVC 架构。Presenters、Views和Controllers都属于这层。models基本上就是一些数据结构,这些数据结构从controllers传递到use cases,然后从use cases传递到presenters和Views。
类似地,来自entities和use cases的数据被转化为持久化框架使用的格式,例如数据库。该层以内的圆圈不应知道关于数据库的任何细节。如果数据库是基于SQL的,那么SQL相关部分都应该被限制在这一层关于数据库的部分。
同样在这一层,还应该存在一些必要的适配器将外部形式的数据,例如外部服务,转化为use cases和entities使用的形式。
框架和驱动
最外层一般由一些框架和工具组成,如数据库,Web框架等等。在这层一般只需要写一些胶水代码将之和里层连接起来。
这一层基本就是所有的细节所在。Web是细节,数据库也如此。这一层应该放到最外面尽量将其影响降到最低。
只需要四层吗?
当然不是,这些圆圈只是示意。你可能需要不止这四层,事实上没有规定你只能划分四层。然而,依赖原则确实必须的。源代码的依赖总是由内向外。越向内,抽象程度越高。最外层是最低级的具体细节。越向内,软件封装了抽象程度越高的策略。
跨越边界
在图中右下部分是关于跨越圆圈边界的示例。展示了Controllers和Presenters与Use Cases进行通信的过程。注意控制流,开始于controller通过use case流向presenter。同样留意源代码依赖。每个都向内指向use cases。
我们通常使用依赖反转原则来决绝明显的矛盾。例如,在Java语言中,我会组织接口和继承关系来使得源代码依赖反向与右向控制流。
例如,use case需要调用presenter。然而不能直接调用,否则违背了依赖原则:内部圆圈不应该引用外部圆圈中的代码。因此use case只能调用内层圆圈中的接口(就是上图中的Use Case Output Port),并且presenter应该实现这些接口。
该技术被用于跨越所有的边界。我们利用动态多态来创建符合依赖原则的反向控制流源码依赖,而不管控制流的方向如何。
什么数据在跨越边界
通常情况下,跨越边界的是简单的数据结构。你可以使用基本结构或者简单的数据传输对象。或者可以作为函数参数的数据。或者可以打包到hashmap中的数据,或者将之构造成对象。重要的是只有单一简单的数据可以通过边界,而不能自欺欺人的传递实体或者数据库行。我们不希望这些数据结构所带来的依赖违反了依赖原则。
例如,许多数据库框架在执行查询后返回RowStructure的数据格式。我们不希望直接让这种行数据结构跨边界传递。由于这样做使得内部获悉了外部情况从而违背了依赖原则。
所以在跨边界传递数据时,应该使用内部圆圈的格式。
结论
这些简单的原则很容易遵守,同时在后来也会免去许多让你头疼的事。通过将软件分层,并且遵守依赖原则,这样能使得系统具有可测试性。当任意一个外部系统被废弃后,例如数据库或者Web框架,都可以很轻松的替换掉。