6 软件架构
6.14 测试边界
和程序代码一样,测试代码也是系统的一部分。甚至,测试代码有时在系统架构中的地位还要比其他部分更独特一些。
测试也是一种系统组件。
从架构的角度来讲,所有的测试都是一样的。不论它们是小型的TDD测试,还是大型的FitNess、Cucumber、SpecFlow或JBehave测试,对架构来说都是一样的。
究其本质而言,测试组件也是要遵守依赖关系原则的。因为其中总是充满了各种细节信息,非常具体,所以它始终都是向内依赖于被测试部分的代码的。事实上,我们可以将测试组件视为系统架构中最外圈的程序,它们始终是向内依赖的,而且系统中没有其他组件依赖于它们。
另外,测试组件是可以独立部署的,事实上,大部分测试组件都是被部署在测试环境中,而不是生产环境中的,所以,即使是在那些本身不需要独立部署的系统中,其测试代码也总是独立部署的。
测试组件通常是一个系统中最独立的组件,系统的正常运行并不需要用到测试组件,用户也不依赖于测试组件。测试组件的存在是为了支持开发过程,而不是运行过程。然而,测试组件仍然是系统中不可或缺的一个组件。事实上,测试组件在许多方面都反映了系统中其他组件所应遵循的设计模型。
由于测试代码的独立性,以及往往不会被部署到生产环境的特点,开发者常常会在系统设计中忽视测试的重要性,这种做法是极为错误的。测试如果没有被集成到系统设计中,往往是非常脆弱的,这种脆弱性会使得系统变得死板,非常难以更改。
当然,这里的关键之处就是耦合。如果测试代码与系统是强耦合的,它就得随着系统变更而变更,哪怕只是系统中组件的一点小变化,都可能会导致许多与之相耦合的测试出现问题,需要做出相应的变更。这种修改一个通用的系统组件可能会导致成百上千个测试出现问题的情况,称为脆弱的测试问题(fragile tests problem)。
脆弱的测试问题往往会让系统变得非常死板,当开发者意识到一些简单的修改就会导致大量的测试出错时,他们自然就会抵制修改。要想解决这个问题,就必须在设计中考虑到系统的可测试性,软件设计的第一原则,就是不要依赖于多变的东西。
设计这样一个系统的方法之一就是专门为验证业务逻辑的测试创建一个API。这个API应该被授予超级用户权限,允许测试代码可以忽视安全限制,绕过那些成本高昂的资源(例如数据库),强制将系统设置到某种可测试的状态中,总而言之,该API应该成为用户界面所用到的交互器与接口适配器的一个超集。
设置测试API是为了将测试部分从应用程序中分离出来。换句话说,这种解耦动作不只是为了分隔测试部分与UI部分,而是要将测试代码的结构与应用程序其他部分的代码结构分开。
结构性耦合是测试代码所具有的耦合关系中最强大、最阴险的一种形式,测试专用API的作用就是将应用程序与测试代码解耦,这样,我们的产品代码就可以在不影响测试的情况下进行重构和演进。同样的,这种设计也允许测试代码在不影响生产代码的情况下进行重构和演进。
这种对演进过程的隔离是很重要的,因为随着时间的推移,测试代码趋向于越来越具体和详细,产品代码则会趋向于越来越抽象和通用。结构性的强耦合可能会让这种必需的演进无法进行------------至少会形成强烈的干扰。
当然,这种具有超级权限的测试专用API如果被部署到我们的产品系统中,可能会是非常危险的。如果要避免这种情况发生,应该将测试专用API及其对应的具体实现放置在一个单独的、可独立部署的组件中。
测试并不是独立于整个系统之外的,恰恰相反,它们是系统的一个重要组成部分。我们需要精心设计这些测试,才能让它们发挥验证系统稳定性和预防问题复发的作用。没有按系统组成部分来设计的测试代码,往往是非常脆弱且难以维护的,这种测试最后常常会被抛弃,因为它们终究会出问题。
6.15 整洁的嵌入式架构
虽然软件质量本身并不会时间推移而损耗,但是未妥善管理的硬件依赖和固件依赖却是软件的头号杀手。
也就是说,本可以长期使用的嵌入式软件可能会由于其中隐含的硬件依赖关系而无法继续使用,这种情况是很常见的。
这里,软件(software)应该是一种使用周期很长的东西,而固件(firmware)则会随着硬件演进而淘汰过时,来看看固件的一些定义:
(1) 固件通常被存储在非可变内存设备,例如ROM、EPROM或者闪存中;
(2) 固件是直接编程在一个硬件设备上的一组指令或者一段程序;
(3) 固件是嵌入在一个硬件中的软件程序;
(4) 固件是被写入到只读内存设备中的(ROM)程序或数据;
但实际上,大家普遍所认知的固件定义是错误的,或者至少是过时的,固件并不一定是指存储在ROM中的代码,也并不是依据其存储的位置来定义的,而是由其代码的依赖关系,及其随着硬件的演进在变更难度上的变化来定义的。硬件的演进是显而易见的,我们在架构嵌入式代码时要时刻记住这一点。
那么,如果一个产品从头到尾都与具体技术、具体硬件息息相关、无法分割时,整个产品就已经成为事实上的固件了。
为什么很多嵌入式软件最后都成为了固件呢?看起来,很可能是因为我们在做嵌入式设计时只关注代码能否顺利运行,并不太关心其结构能否撑起一个较长的有效生命周期,Kent Beck描述了软件构建过程中的三个阶段:
(1) "先让代码工作起来"------------如果代码不能工作,就不能产生价值;
(2) "然后再试图将它变好"------------通过对代码进行重构,让我们自己和其他人更好地理解代码,并能按照需求不断地修改代码;
(3) "最后再试着让它运行得更好"------------按照性能提升的"需求"来重构代码;
而大部分"野生"的嵌入式代码,都只关注"先让它工作起来"这个目标------------也许还有些团队会同时痴迷于"让它更快"这个目标,不放过任何一个机会加入各种微优化。在《人月神话》这本书中,Fred Brooks建议我们应该随时准备"抛弃一个设计",即"在实践中学习正确的工作方法,然后再重写一个更好的版本"。
这个建议对非嵌入式软件系统开发同样有用,毕竟目前大部分非嵌入式应用也仅仅停留在"可用"这个目标上,很少考虑为了长久使用而进行正确的设计。对于程序员来说,让他的程序工作这件事只能被称为"程序适用性(app-titude test)",一个程序员,不论他写的是否是嵌入式程序,如果目标仅仅是让程序可以工作,恐怕对他的老板和这个程序本身而言都是一件坏事。
嵌入式系统的程序员通常需要处理很多在写非嵌入式系统时不需要关心的事情------------例如,有限的地址空间、实时性限制、运行截止时间、有限的I/O能力、非常规的用户接口、感应器,以及其他与物理世界的实际链接。大部分时候,这些系统的硬件是和它的软件、固件并行开发的,工程师在为这种系统编写代码的时候,往往没有任何地方可以运行。这就是目标硬件瓶颈(target-hardware bottleneck)是嵌入式开发所特有的一个问题,如果我们没有采用某种清晰的架构来设计嵌入式系统的代码结构,就经常会面临只能在目标系统平台上测试代码的难题。如果只能在特定的平台上测试代码,那么这一定会拖慢项目的开发进度。
整洁的嵌入式架构就是可测试的嵌入式架构。
我们来看一下具体应如何将架构设计的原则应用在嵌入式软件和固件上,以避免陷入目标硬件瓶颈。
首先是分层,分层可以有很多种方式,先来看三层结构:
首先,底层是硬件层,由于科技的进步与摩尔定律,硬件是一定会改变的。旧的硬件部件将会被淘汰,新的硬件部件可能耗电量更少,或者性能更好,或者价格更便宜,不管硬件更新的原因是什么,作为嵌入式工程师,我们都不会希望这些不可避免的硬件变动带来更多的工作量。
硬件与系统其他部分的分隔是既定的------------至少在硬件设计完成之后如此。这也是我们试图通过程序适用测试之时往往会发生问题的地方。因为没有什么东西可以真正阻碍硬件实现细节污染到应用代码。如果我们在构建代码的时候不够小心,没有小心安排哪些模块之间可以互相依赖,代码很快就非常难以更改了。请注意,这里所说的变更不仅仅是指来自硬件的变更,还包括用户的功能性变更、修复代码中的Bug。
另外,软件与固件集成在一起也属于设计上的反模式(anti-pattern),符合这种反模式的代码修改起来都会很困难,同时,这种代码也很危险,容易造成意外事故,这导致它经历任何微小的改动都需要进行完整的回归测试,如果没有完善的测试流程,那么就会有无穷无尽的手工测试,同时还有纷沓而来的Bug报告。
软件与固件之间的分割线往往没有代码与硬件之间的分割线那么清晰,所以,我们的工作之一就是将这个边界定义得更清晰一些,软件与固件之间的边界被称为硬件抽象层(HAL),这不是一个概念,它在PC上的存在甚至可以追溯到Windows诞生之前:
HAL的存在是为了给它上层的软件提供服务,HAL的API应该按照这些软件的需要来量身定做。例如,固件可以直接将字节和字节组存入闪存中。相比之下,软件需要的是从某种持久化平台保存和读取name/value对信息,它不应该关心自己信息到底是被存储到闪存中、磁盘中、云端存储中,还是在内存中读取/存储这些信息。总之,HAL的作用是为软件部分提供一种服务,以便隐藏具体的实现细节。毕竟专门针对闪存的实现代码是一种细节信息,它应该与软件部分隔离。
不要向HAL的用户暴露硬件细节 ,依照整洁的嵌入式架构所构建的软件应该是可以脱离目标硬件平台来进行测试的。因为设计合理的HAL可以为我们脱离硬件平台的测试提供相应的支撑。
当我们的嵌入式应用依赖于某种特殊的工具链时,该工具链通常会。为我们提供一些"帮助"性质的头文件,这些编译器往往会自带一些基于C语言的扩展库,并添加一些用于访问特殊功能的关键词,这会导致这些程序的代码看起来仍然用的是C语言,但实际上它们已经不是C语言了。有时候,这些嵌入式应用的提供商所指定的C编译器还会提供类似于全局变量的功能,以便我们直接访问寄存器、I/O端口、时钟信息、I/O位、中断控制器以及其他处理器函数,这些函数会极大地方便我们对相关硬件的访问。但请注意,一旦你在代码中使用了这些函数,你写的就不再是C语言程序,它就不能用其他编译器来编译了,甚至可能连同一个处理器的不同编译器也不行。为了避免我们的代码在未来出现问题,我们就必须限制这些C扩展的使用范围。
在整洁的嵌入式架构中,固件将这类底层函数隔离成处理器抽象层(PAL),这样一来,使用PAL的固件代码就可以在目标平台之外被测试了。
除HAL和PAL之外,由于嵌入式系统可能使用某种实时操作系统(RTOS),或者某种嵌入式的Linux或Windows,因此我们必须将操作系统也定义为实现细节,让代码避免与操作系统层产生依赖。
整洁的嵌入式架构会引入操作系统抽象层(OSAL),将软件与操作系统分隔开:
除了在嵌入式系统的主要分层(指软件、操作系统、固件、硬件这四层)之中增加HAL和OSAL之外,我们还可以应用其他的设计原则,这些设计原则可以帮助我们按功能模块、接口编程以及可替代性来划分系统。
分层架构的理念是基于接口编程的理念来设计的,当模块之间能以接口形式交互时,我们就可以将一个服务替换成另外一个服务。
由整洁的嵌入式架构所构建的系统应该在每一个分层中都是可测试的,因为它的模块之间采用接口通信,每一个接口都为平台之外的测试提供了替换点。
7 实现细节
数据库只是实现细节。
从系统架构的角度来看,数据库并不重要------------它只是一个实现细节,在系统架构中并不占据重要角色。如果就数据库与整个系统架构的关系打个比方,它们之间就好比是门把手和整个房屋架构的关系。请注意,这里讲的是"数据库"而非"数据模型",为应用程序中的数据设计结构,对于系统架构来说当然是很重要的,但是数据库并不是数据模型。数据库只是一款软件,是用来存取数据的工具。从系统架构的角度来看,工具通常是无关紧要的------------因为这只是一个底层的实现细节,一种达成目标的手段,一个优秀的架构师是不会让实现细节污染整个系统架构的。
数据的组织结构,数据的模型,都是系统架构中的重要部分,但是从磁盘上存储/读取数据的机制和手段对于架构来说则不是那么重要,即使类似于读取/存储性能这样的指标,也应被封装在具体的数据库内部,而不是作为系统架构的一部分。
Web是实现细节。
这也是在前文不断被提及的概念。GUI只是一个实现细节,而Web则是GUI的一种,所以也是一个实现细节,作为一名软件架构师,我们需要将这类细节与核心业务逻辑隔离开来------------即使不是Web,换一种GUI,也对业务核心逻辑无影响。
应用程序框架是实现细节。
应用程序框架现在非常流行,这在通常情况下是一件好事,这么多框架都非常有效,非常有用,而且是免费的,但框架并不等同于系统架构------------尽管有些框架确实以此为目标。
框架的作者通常会希望我们与其框架紧密结合,这意味着我们将与框架签订终身契约,而他们则不需要为我们遵守任何承诺,把风险全部交由我们自己承担,例如框架自身架构设计可能不正确而要求我们将代码引入到业务对象或业务实体中,或者框架可能会想要我们将框架耦合在最内圈代码中等,比如框架自身为了演进新增了很多我们不需要的功能等。
因此我们应该将框架作为架构最外圈的一个实现细节来使用,而不是让它们进入内圈,不要让框架污染我们的核心代码,应该依据依赖关系原则,将它们当作核心代码的插件来进行管理。尽可能长时间地将框架留在架构边界之外,我们的业务逻辑才不会被框架影响,在我们需要替换框架时可以轻松替代。