第 11 章 系统
要将注意力放到代码组织的更高层面,才能得到整洁的代码。
11.1 如何建造一个城市
城市在没有一个人管理时,也能正常运转,是因为它能演化出恰当的抽象等级和模块。
本章将讨论如何在较高的抽象层级—系统层级—上保持整洁。
11.2 将系统的构造与使用分开
首先,构造与使用是非常不一样的过程。
软件系统应将启始过程和启始过程之后的运行时逻辑分离开,在启始过程中构建应用对象,也会存在相互缠结的依赖关系。
每个应用程序都该留意启始过程。将关注的方面分离开,是软件技艺中最古老也最重要的设计技巧。
延迟化初始化/赋值(获取对象时,如果对象为空则创建对象,反之则直接返回对象)的好处是在真正用到对象之前,无需操心这种架空构造,启始时间也会更短,而且还能保证永远不会返回 null 值。坏处是得到了对象及其构造器所需一切的硬编码,不分解这些依赖关系就无法编译,即便在运行时永不使用这种类型的对象;在测试中,由于构造逻辑与运行过程相混杂,就必须测试所有的执行路径,在全局中也无法确保初始化的值是正确的。有了这些权责,说明方法做了不止一件事,这样就略微违反了单一权责原则。
仅出现一次的延迟初始化不算是严重问题。不过,在应用程序中往往有许多种类似的情况出现。于是,全局设置策略(如果有的话)在应用程序中四散分布,缺乏模块组织性,通常也会有许多重复代码。
如果我们勤于打造有着良好格式并且强固的系统,就不该让这类就手小技巧破坏模块组织性。对象构造的启始和设置过程也不例外。应当将这个过程从正常的运行时逻辑中分离出来,确保拥有解决主要依赖问题的全局性一贯策略。
11.2.1 分解 main
将构造与使用分开的方法之一始将全部构造过程搬迁到 main 或被称之为 main 的模块中,设计系统的其他部分时,假设所有对象都已正确构造和设置。
控制流程很容易理解。 main 函数创建系统所需的对象,再传递给应用程序,应用程序只管使用。注意看横贯 main 与应用程序之间隔离的依赖箭头的方向。它们都从 main 函数向外走。这表示应用程序对 main 或者构造过程一无所知。它只是简单地指望一切已齐备。
11.2.2 工厂
有时应用程序也要负责何时创建对象。
可以使用抽象工厂模式让应用自行控制何时创建对象,但构造的细节却隔离于应用程序代码之外。
11.2.3 依赖注入
有一种强大的机制可以实现分离构造与使用,那就是依赖注入(Dependency Injection DI),控制反转(Inversion of Control,IoC)在依赖管理中的一种应用手段。控制反转将第二权责从对象中拿出来,转移到另一个专注于此的对象中,从而遵循了单一权责原则。在依赖管理情景中,对象不应负责实体化对自身的依赖。反之,它应当将这份权责移交给其他“有权利”的机制,从而实现控制的反转。因为初始设置是一种全局问题,这种授权机制通常要么是 main 例程,要么是有特定目的的容器。
调用对象并不控制真正返回对象的类别(当然前提是它实现了恰当的接口),但调用对象仍然主动分解了依赖。
真正的依赖注入还要更进一步。类并不直接分解其依赖,而是完全被动的。它提供可用于注入依赖的赋值器方法或构造器参数(或二者皆有)。在构造过程中, DI 容器(构造器容器)实体化需要的对象(通常按需创建),并使用构造器参数或赋值器方法将依赖连接到一起。至于哪个依赖对象真正得到使用,是通过配置文件或在一个有特殊目的的构造模块中编程决定。
但延后初始化的好处是什么呢?首先,多数 DI 容器在需要对象之前并不构造对象。其次,许多这类容器提供调用工厂或构造代理的机制,而这种机制可为延迟赋值或类似的优化处理所用。
11.3 扩容
与物理系统相比软件系统比较独特。它们的架构都可以递增式地增长,只要我们持续将关注面恰当地切分。
没有恰当的切分关注面,业务逻辑与容器紧密耦合,隔离单元测试很困难,也会导致冗余类型的出现。
横贯式关注面
持久化之类关注面倾向于横贯某个领域的天然对象边界。会想用同样的策略来持久化所有对象(例如,命名约定采用一致的语义)。
原则上,可以从模块、封装的角度推理持久化策略。但在实践上,却不得不将实现了持久化策略的代码铺展到许多对象中。用术语“横贯式关注面”来形容这类情况。同样,持久化框架和领域逻辑,孤立地看也可以是模块化的。问题在于横贯这些领域的情形。
实际上,EJB(Enterprise Java Bean,JavaEE 中面向服务的体系架构的解决方案)架构处理持久化、安全和事务的方法要早于面向方面编程(aspect-oriented propramming,AOP),而 AOP 是一种恢复横贯式关注面模块化的普适手段。
在 AOP 中,被称为方面(aspect)的模块构造指明了系统中哪些点的行为会以某种一致的方式被修改,从而支持某种特定的场景。这种说明是用某种简洁的声明或编程机制来实现的。
以持久化为例,可以声明哪些对象和属性(或其模式)应当被持久化,然后将持久化任务委托给持久化框架。行为的修改由 AOP 框架以无损方式在目标代码中进行。
11.4 Java 代理
Java 代理适用于简单的情况,例如在单独的对象或类中包装方法调用。然而,JDK 提供的动态代理仅能与接口协同工作。对于代理类,你得使用字节码操作库,比如 CGLIB、ASM 或 Javassist。
代码量和复杂度是代理的两大弱点,创建整洁代码变得很难!另外,代理也没有提供在系统范围内指定执行点的机制,而那正是真正的 AOP (面向方面编程)解决方案所必须的。
11.5 纯 Java AOP 框架
幸运的是,编程工具能自动处理大多数代理模版代码。在数个 Java 框架中,代理都是内嵌的,如 Spring AOP 和 JBoss AOP 等,从而能够从纯 Java 代码实现面向方面编程。在 Spring 中,你将业务逻辑编码成旧式 Java AOP 对象。POJO (Plain Ordinary Java Object,简单的 Java 对象,实际就是普通 JavaBean。)自扫门前雪,并不依赖于企业框架(或其他域)。因此,它在概念上更简单、更易于测试驱动。相对简单性也较易于保证正确地实现相应的用户故事,并为未来的用户故事维护和改进代码。
使用描述性配置文件或 API ,你把需要的应用程序构架组合起来,包括持久化、事务、安全、缓存、恢复等横贯性问题。在许多情况下,你实际上只是指定 Sprint 或 Jboss 类库,框架以对用户透明的方式处理使用 Java 代理或字节代码库的机制。这些声明驱动了依赖注入(DI)容器,DI 容器再实体化主要对象,并按需将对象连接起来。
11.6 AspectJ 的方面
通过方面来实现关注面切分的功能最全的工具是 AspectJ 语言,一种提供 “一流的” 将方面作为模块构造处理支持的 Java 扩展。在 80% ~ 90% 用到方面特性的情况下,Spring AOP 和 JBoss AOP 提供的纯 Java 实现手段足够使用。然而,AspectJ 的弱势在于,需要采用几种新工具,学习新语言构造和使用方式。
11.7 测试驱动系统架构
先做大设计可以理解为一开始就设计好一切实现的方式,先做大设计(Big Design Up Front,BDUF)在一定程序上会阻碍改进,因为心理上会抵制丢弃既成之事,也因为架构上的方案选择影响到后续的设计思路。
当然,这不是说要毫无准备地进入一个项目。对于总的覆盖范围、目标、项目进度和最终系统的总体架构,我们会有所预期。不过,我们必须有能力随机应变。
最佳的系统架构由模块化的关注面领域组成,,每个关注面均用纯 Java (或其他语言)对象实现。不同的领域之间用最不具有侵害性的方面或类方面工具整合起来,这种架构能测试驱动,就像代码一样。
11.8 优化决策
模块化和关注面切分成就了分散化管理和决策。
延迟决策至最后一刻也是好手段。它让那个我们能够基于最有可能的信息做出选择。提前决策是一种预备只是不足的决策。如果决策太早,就会绝少太多客户反馈、关于项目的思考和实施经验。
拥有模块化关注面的 POJO 系统提供的敏捷能力,允许我们基于最新的知识做出优化的、时机刚好的决策。决策的复杂性也降低了。
11.9 明智使用添加了可论证价值的标准
有了标准,就更易复用想法和组件、雇用拥有相关经验的人才、封装好点子,以及将组件连接起来。不过,创立标准的过程有时却漫长到行业等不及的程度,有些标准没能与它要服务的采用者的真实需求相结合。
11.10 系统需要领域特定语言
DSL(领域特定语言)是一种单独的小型脚本语言或以标准语言写就的 API ,领域专家可以用它编写读起来像是组织严谨的散文一般的代码。
优秀的 DSL 填平了领域概念和实现领域概念的代码之间的“壕沟”,如果你用与领域专家使用同一种语言来实现领域逻辑,就会降低不正确地将领域翻译为实现的风险。
DSL 在有效使用时能提升代码惯用法和设计模式之上的抽象层次。它允许开发者在恰当的抽象层级上直指代码的初衷。
领域特定语言允许所有抽象层级和应用程序中的所有领域,从高级策略到底层细节,使用 POJO 来表达。
11.11 总结
系统也应该时整洁的。侵害性架构会湮灭领域逻辑,冲击敏捷能力。当领域逻辑受到困扰,质量也就堪忧,因为缺陷更易隐藏,用户故事更难实现。当敏捷能力受到损害时,生产力也会降低,TDD(Test-Driven Development 测试驱动开发,是敏捷开发中的一项核心实践和技术,也是一种设计方法伦)的好处遗失殆尽。
在所有的抽象层级上,意图都应该清晰可辨。只有在编写 POJO 并使用类方面的机制来无损地组合其他关注面时,这种事情才会发生。
无论是设计系统或单独的模块,别忘了使用大概可工作的最简单方案。