当前位置: 首页 > news >正文

【读书笔记】架构整洁之道 P5-2 软件架构

第5部分 软件架构

第21章 尖叫的软件架构

假设我们现在正在查看某个建筑的设计架构图,那么在这个反映建筑设计师精心设计成果的文件中,究竟应该包括怎样的架构图呢?

如果这是一幅单户住宅的建筑架构图,那么我们很可能会先看到一个大门,然后是一条连接到起居室的通道,同时可能还会看到一个餐厅。接着,距离餐厅不远处应该会有一个厨房,可能厨房附近还会有一个非正式用餐区,或一个亲子房。当我们阅读这个架构图时,应该不会怀疑这是一个单户住宅。几乎整个建筑设计都在尖叫着告诉你:这是一个“家”。

我们的应用程序的架构设计又会“喊”些什么呢?当我们查看它的顶层结构目录,以及顶层软件包中的源代码时,它们究竟是在喊“健康管理系统”“账务系统”“库存管理系统”,还是在喊:“Rails”“Spring/Hibernate”“ASP”这样的技术名词呢?

架构设计的主题

在这里,再次推荐读者仔细阅读Ivar Jacobson关于软件架构设计的那本书:Object Oriented Software Engineering,请读者注意这本书的副标题:A Use Case Driven Approach(业务用例驱动的设计方式)。在这本书中,Jacobson提出了一个观点:软件的系统架构应该为该系统的用例提供支持。这就像住宅和图书馆的建筑计划满篇都在非常明显地凸显这些建筑的用例一样,软件系统的架构设计图也应该非常明确地凸显该应用程序会有哪些用例

架构设计不是(或者说不应该是)与框架相关的,这件事不应该是基于框架来完成的。对于我们来说,框架只是一个可用的工具和手段,而不是一个架构所规范的内容如果我们的架构是基于框架来设计的,它就不能基于我们的用例来设计了

架构设计的核心目标

一个良好的架构设计应该围绕着用例来展开,这样的架构设计可以在脱离框架、工具以及使用环境的情况下完整地描述用例。

而且,良好的架构设计应该尽可能地允许用户推迟和延后决定采用什么框架、数据库、Web服务以及其他与环境相关的工具。

那Web呢

Web究竟是不是一种架构?如果我们的系统需要以Web形式来交付,这是否意味着我们只能采用某种系统架构?当然不是!Web只是一种交付手段——一种IO设备——这就是它在应用程序的架构设计中的角色。换句话说,应用程序采用Web方式来交付只是一个实现细节,这不应该主导整个项目的结构设计。事实上,关于一个应用程序是否应该以Web形式来交付这件事,它本身就应该是一个被推迟和延后的决策。

框架是工具而不是生活信条

采用框架可能会很有帮助,但采用它们的成本呢?我们一定要懂得权衡如何使用一个框架,如何保护自己。无论如何,我们需要仔细考虑如何能保持对系统用例的关注,避免让框架主导我们的架构设计。

可测试的架构设计

如果系统架构的所有设计都是围绕着用例来展开的,并且在使用框架的问题上保持谨慎的态度,那么我们就应该可以在不依赖任何框架的情况下针对这些用例进行单元测试。另外,我们在运行测试的时候不应该运行Web服务,也不应该需要连接数据库。我们测试的应该只是一个简单的业务实体对象,没有任何与框架、数据库相关的依赖关系。总而言之,我们应该通过用例对象来调度业务实体对象,确保所有的测试都不需要依赖框架。

第22章 整洁架构

在过去的几十年中,我们曾见证过一系列关于系统架构的想法被提出,虽然这些架构在细节上各有不同,但总体来说是非常相似的。它们都具有同一个设计目标:按照不同关注点对软件进行切割。也就是说,这些架构都会将软件切割成不同的层,至少有一层是只包含该软件的业务逻辑的,而用户接口、系统接口则属于其他层。

按照这些架构设计出来的系统,通常都具有以下特点。

  • 独立于框架:这些系统的架构并不依赖某个功能丰富的框架之中的某个函数。框架可以被当成工具来使用,但不需要让系统来适应框架。
  • 可被测试:这些系统的业务逻辑可以脱离UI、数据库、Web服务以及其他的外部元素来进行测试。
  • 独立于UI:这些系统的UI变更起来很容易,不需要修改其他的系统部分。例如,我们可以在不修改业务逻辑的前提下将一个系统的UI由Web界面替换成命令行界面。
  • 独立于数据库:我们可以轻易将这些系统使用的Oracle、SQL Server替换成Mongo、BigTable、CouchDB之类的数据库。因为业务逻辑与数据库之间已经完成了解耦。
  • 独立于任何外部机构:这些系统的业务逻辑并不需要知道任何其他外部接口的存在。

图22.1:整洁架构

图22.1:整洁架构

依赖关系规则

图22.1中的同心圆分别代表了软件系统中的不同层次,通常越靠近中心,其所在的软件层次就越高。基本上,外层圆代表的是机制,内层圆代表的是策略。

当然这其中有一条贯穿整个架构设计的规则,即它的依赖关系规则:源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略

换句话说,就是任何属于内层圆中的代码都不应该牵涉外层圆中的代码,尤其是内层圆中的代码不应该引用外层圆中代码所声明的名字,包括函数、类、变量以及一切其他有命名的软件实体。

同样的道理,外层圆中使用的数据格式也不应该被内层圆中的代码所使用,尤其是当数据格式是由外层圆的框架所生成时。

  • 业务实体:业务实体这一层中封装的是整个系统的关键业务逻辑,一个业务实体既可以是一个带有方法的对象,也可以是一组数据结构和函数的集合。无论如何,只要它能被系统中的其他不同应用复用就可以。
  • 用例:软件的用例层中通常包含的是特定应用场景下的业务逻辑,这里面封装并实现了整个系统的所有用例。这些用例引导了数据在业务实体之间的流入/流出,并指挥着业务实体利用其中的关键业务逻辑来实现用例的设计目标。我们既不希望在这一层所发生的变更影响业务实体,同时也不希望这一层受外部因素(譬如数据库、UI、常见框架)的影响。用例层应该与它们都保持隔离。
  • 接口适配器:软件的接口适配器层中通常是一组数据转换器,它们负责将数据从对用例和业务实体而言最方便操作的格式,转化成外部系统(譬如数据库以及Web)最方便操作的格式。例如,这一层中应该包含整个GUI MVC框架。展示器、视图、控制器都应该属于接口适配器层。而模型部分则应该由控制器传递给用例,再由用例传回展示器和视图。同样的,这一层的代码也会负责将数据从对业务实体与用例而言最方便操作的格式,转化为对所采用的持久性框架(譬如数据库)最方便的格式。
  • 框架与驱动程序:图22.1中最外层的模型层一般是由工具、数据库、Web框架等组成的。在这一层中,我们通常只需要编写一些与内层沟通的黏合性代码。框架与驱动程序层中包含了所有的实现细节。Web是一个实现细节,数据库也是一个实现细节。我们将这些细节放在最外层,这样它们就很难影响到其他层了。

图22.1中所显示的同心圆只是为了说明架构的结构,真正的架构很可能会超过四层。并没有某个规则约定一个系统的架构有且只能有四层。然而,这其中的依赖关系原则是不变的。

跨越边界

在图22.1的右下侧,我们示范的是在架构中跨边界的情况。具体来说就是控制器、展示器与下一层的用例之间的通信过程。请注意这里控制流的方向:它从控制器开始,穿过用例,最后执行展示器的代码。但同时我们也该注意到,源码中的依赖方向却都是向内指向用例的。

我们可以采用这种方式跨越系统中所有的架构边界。利用动态多态技术,我们将源码中的依赖关系与控制流的方向进行反转。不管控制流原本的方向如何,我们都可以让它遵守架构的依赖关系规则。

哪些数据会跨越边界

一般来说,会跨越边界的数据在数据结构上都是很简单的。如果可以的话,我们会尽量采用一些基本的结构体或简单的可传输数据对象。或者直接通过函数调用的参数来传递数据。另外,我们也可以将数据放入哈希表,或整合成某种对象。这里最重要的是这个跨边界传输的对象应该有一个独立、简单的数据结构。总之,不要投机取巧地直接传递业务实体或数据库记录对象。同时,这些传递的数据结构中也不应该存在违反依赖规则的依赖关系。

例如,很多数据库框架会返回一个便于查询的结果对象,我们称之为“行结构体”。这个结构体不应该跨边界向架构的内层传递。因为这等于让内层的代码引用外层代码,违反依赖规则

因此,当我们进行跨边界传输时,一定要采用内层最方便使用的形式

一个常见的应用场景

图22.2:一个基于Web的、使用数据库的常见Java程序

ViewModel中基本上只包含字符串和一些View都会用到的开关数据。同时,OutputData中可能会包含一些Date对象,Presenter会将其格式化成可对用户展示的字符串,并将其填充到ViewModel中。同理,Currency对象和其他业务相关的数据也会经历类似的操作。如你所见,Button和MenuItems的命名定义位于ViewModel中,并且其中还包括了用于告知View层Button和MenuItems是否可用的开关数据。

我们可以看出,View除了将ViewModel中的数据转换成HTML格式之外,并没有其他功能。

第23章 展示器和谦卑对象

在第22章中,我们引入了展示器(presenter)的概念,展示器实际上是采用谦卑对象(humble object)模式的一种形式,这种设计模式可以很好地帮助识别和保护系统架构的边界。事实上,第22章所介绍的整洁架构中就充满了大量谦卑对象的实现体。

谦卑对象模式

谦卑对象模式[11]最初的设计目的是帮助单元测试的编写者区分容易测试的行为与难以测试的行为,并将它们隔离。其设计思路非常简单,就是将这两类行为拆分成两组模块或类。其中一组模块被称为谦卑(Humble)组,包含了系统中所有难以测试的行为,而这些行为已经被简化到不能再简化了。另一组模块则包含了所有不属于谦卑对象的行为。

例如,GUI通常是很难进行单元测试的,因为让计算机自行检视屏幕内容,并检查指定元素是否出现是非常难的事情。然而,GUI中的大部分行为实际上是很容易被测试的。这时候,我们就可以利用谦卑对象模式将GUI的这两种行为拆分成展示器与视图两部分

展示器与视图

视图部分属于难以测试的谦卑对象。这种对象的代码通常应该越简单越好,它只应负责将数据填充到GUI上,而不应该对数据进行任何处理。

展示器则是可测试的对象。展示器的工作是负责从应用程序中接收数据,然后按视图的需要将这些数据格式化,以便视图将其呈现在屏幕上。

总而言之,应用程序所能控制的、要在屏幕上显示的一切东西,都应该在视图模型中以字符串、布尔值或枚举值的形式存在。然后,视图部分除了加载视图模型所需要的值,不应该再做任何其他事情。因此,我们才能说视图是谦卑对象。[12]

测试与架构

众所周知,强大的可测试性是一个架构的设计是否优秀的显著衡量标准之一。谦卑对象模式就是这方面的一个非常好的例子。我们将系统行为分割成可测试和不可测试两部分的过程常常就也定义了系统的架构边界。展示器与视图之间的边界只是多种架构边界中的一种,另外还有许多其他边界。

数据库网关

对于用例交互器(interactor)与数据库中间的组件,我们通常称之为数据库网关[13]。这些数据库网关本身是一个多态接口,包含了应用程序在数据库上所要执行的创建、读取、更新、删除等所有操作。

另外,我们之前说过,SQL不应该出现在用例层的代码中,所以这部分的功能就需要通过网关接口来提供,而这些接口的实现则要由数据库层的类来负责。显然,这些实现也应该都属于谦卑对象,它们应该只利用SQL或其他数据库提供的接口来访问所需要的数据。与之相反,交互器则不属于谦卑对象,因为它们封装的是特定应用场景下的业务逻辑。不过,交互器尽管不属于谦卑对象,却是可测试的,因为数据库网关通常可以被替换成对应的测试桩和测试替身类。

DeepSeek:对于Mybatis来说

  • ✅ 数据库网关 = Mapper接口(定义业务数据操作)
  • ✅ 数据映射器 = Mapper.xml + MyBatis框架(实现数据映射)
  • ✅ 谦卑对象 = MyBatis的具体实现层(只做简单数据转换)

数据映射器

Hibernate这类的ORM框架应该属于系统架构中的哪一层呢?

首先,我们要弄清楚一件事:对象关系映射器(ORM)事实上是压根就不存在的。道理很简单,对象不是数据结构。至少从用户的角度来说,对象内部的数据应该都是私有的,不可见的,用户在通常情况下只能看到对象的公有函数。因此从用户角度来说,对象是一些操作的集合,而不是简单的数据结构体。

与之相反,数据结构体则是一组公开的数据变量,其中不包含任何行为信息。所以ORM更应该被称为“数据映射器”,因为它们只是将数据从关系型数据库加载到了对应的数据结构中。

那么,这样的ORM系统应该属于系统架构中的哪一层呢?当然是数据库层。ORM其实就是在数据库和数据库网关接口之间构建了另一种谦卑对象的边界。

服务监听器

如果我们的应用程序需要与其他服务进行某种交互,或者该应用本身要提供某一套服务,我们在相关服务的边界处也会看到谦卑对象模式吗?

答案是肯定的。我们的应用程序会将数据加载到简单的数据结构中,并将这些数据结构跨边界传输给那些能够将其格式化并传递其他外部服务的模块。在输入端,服务监听器会负责从服务接口中接收数据,并将其格式化成该应用程序易用的格式。总而言之,上述数据结构可以进行跨服务边界的传输。

在每个系统架构的边界处,都有可能发现谦卑对象模式的存在。因为跨边界的通信肯定需要用到某种简单的数据结构,而边界会自然而然地将系统分割成难以测试的部分与容易测试的部分,所以通过在系统的边界处运用谦卑对象模式,我们可以大幅地提高整个系统的可测试性

第24章 不完全边界

构建完整的架构边界是一件很耗费成本的事。在这个过程中,需要为系统设计双向的多态边界接口,用于输入和输出的数据结构,以及所有相关的依赖关系管理,以便将系统分割成可独立编译与部署的组件。这里会涉及大量的前期工作,以及大量的后期维护工作。

在很多情况下,一位优秀的架构师都会认为设计架构边界的成本太高了——但为了应对将来可能的需要,通常还是希望预留一个边界。

但这种预防性设计在敏捷社区里是饱受诟病的,因为它显然违背了YAGNI原则(“You Aren’t Going to Need It”,意即“不要预测未来的需要”)。然而,架构师的工作本身就是要做这样的预见性设计,这时候,我们就需要引入不完全边界(partial boundary)的概念了。

省掉最后一步

构建不完全边界的一种方式就是在将系统分割成一系列可以独立编译、独立部署的组件之后,再把它们构建成一个组件。换句话说,在将系统中所有的接口、用于输入/输出的数据格式等每一件事都设置好之后,仍选择将它们统一编译和部署为一个组件。

显然,这种不完全边界所需要的代码量以及设计的工作量,和设计完整边界时是完全一样的。但它省去了多组件管理这部分的工作,这就等于省去了版本号管理和发布管理方面的工作

单向边界

在设计一套完整的系统架构边界时,往往需要用反向接口来维护边界两侧组件的隔离性。而且,维护这种双向的隔离性,通常不会是一次性的工作,它需要我们持续地长期投入资源维护下去。

在图24.1中,你会看到一个临时占位的,将来可被替换成完整架构边界的更简单的结构。这个结构采用了传统的策略模式(strategy pattern)。如你所见,其Client使用的是一个由ServiceImpl类实现的ServiceBoundary接口。

图24.1:策略模式

很明显,上述设计为未来构建完整的系统架构边界打下了坚实基础。为了未来将Client与ServiceImpl隔离,必要的依赖反转已经做完了。同时,我们也能清楚地看到,图中的虚线箭头代表了未来有可能很快就会出现的隔离问题。由于没有采用双向反向接口,这部分就只能依赖开发者和架构师的自律性来保证组件持久隔离了

门户模式

下面,我们再来看一个更简单的架构边界设计:采用门户模式(facade pattern),其架构如图24.2所示。在这种模式下,我们连依赖反转的工作都可以省了。这里的边界将只能由Facade类来定义,这个类的背后是一份包含了所有服务函数的列表,它会负责将Client的调用传递给对Client不可见的服务函数。

图24.2:门户模式

但需要注意的是,在该设计中,Client会传递性地依赖于所有的Service类。在静态类型语言中,这就意味着对Service类的源码所做的任何修改都会导致Client的重新编译。另外,我们应该也能想象得到为这种结构建立反向通道是多容易的事。

第25章 层次与边界

人们通常习惯于将系统分成三个组件:UI、业务逻辑和数据库。对于一些简单系统来说,的确可以这样,但稍复杂一些系统的组件就远不止三个了。

基于文本的冒险游戏:Hunt The Wumpus

如果我们能管理好源码中的依赖关系,就应该像图25.1所展示的那样,多个UI组件复用同一套游戏业务逻辑。而游戏的业务逻辑组件不知道,也不必知道UI正在使用哪一种自然语言。

持久化存储介质同理

图25.2:遵循依赖关系规则的设计

可否采用整洁架构

很显然,这里具备了采用整洁架构方法所需要的一切,包括用例、业务实体以及对应的数据结构都有了[15],但我们是否已经找到了所有相应的架构边界呢?

例如,语言并不是UI变更的唯一方向。我们可能还会需要变更文字输入/输出的方式。例如,我们的输入/输出可以采用命令行窗口,或者用短信息,或者采用某种聊天程序。这里的可能性有很多。

这就意味着这类变更应该有一个对应的架构边界。也许我们需要构造一个API,以便将语言部分与通信部分隔开,这样一来,该设计的结构应如图25.3所示。

图25.3:修正后的设计图

我们也可以看到GameRules与Language这两个组件之间的交互是通过一个由GameRules定义,并由Language实现的API来完成的。同样的,Language与 TextDelievery 之间的交互也是通过由Language定义,并由TextDelievery实现的API来完成。这些API的定义和维护都是由使用方来负责的,而非实现方。

如果我们进一步查看GameRules内部,就会发现GameRules组件的代码中使用的 Boundary 多态接口是由 Language 组件来实现的;同时还会发现Language组件使用的Boundary多态接口由GameRules代码实现。

在所有这些场景中,由Boundary接口所定义的API都是由其使用者的上一层组件负责维护的。

我们可以去掉所有的具体实现类,只保留API组件来进一步简化上面这张设计图,其简化的结果如图25.4所示。

图25.4:简化版设计图

这种设计方式将数据流分成两路[16]。左侧的数据流关注如何与用户通信,而右侧的数据流关注的是数据持久化。两条数据流在顶部的 GameRules 汇聚[17]。GameRules组件是所有数据的最终处理者。

交汇数据流

那么,这个例子中是否永远只有这两条数据流呢?当然不是。假设我们现在要在网络上与多个其他玩家一起玩这个游戏,就会需要一个网络组件,如图25.5所示。这样一来,我们有了三条数据流,它们都由GameRules组件所控制。

图25.5:增加一个网络组件

数据流的分割

我们可以再来看一下Hunt The Wumpu这个游戏的GameRules组件。游戏的部分业务逻辑处理的是玩家在地图中的行走。这一部分需要知道游戏中的洞穴如何相连,每个洞穴中有什么物体存在,还要知道如何将玩家从一个洞穴移到另一个洞穴,以及如何触发各种需要玩家处理的事件。

但是,游戏中还有一组更高层次的策略——这些策略负责了解玩家的血量,以及每个事件的后果和影响。这些策略既可以让玩家逐渐损失血量,也可能由于发现食物而增加血量。总而言之,游戏的低层策略会负责向高层策略传递事件,例如FoundFood和FellInPit。而高层组件则要管理玩家状态(如图25.6所示),最终该策略将会决定玩家在游戏中的输赢。

图25.6:管理玩家的高层策略

这些究竟是否属于架构边界呢?是否需要设计一个API来分割MoveManagement和PlayerManagement呢?在回答这些问题之前,让我们把问题弄得更有意思一点,再往里面加上微服务吧!

假设我们现在面对的是一个可以面向海量玩家的新版Hunt The Wumpus游戏。它的MoveManagmenet 组合是由玩家的本地计算机来处理的。而PlayerManagement组件则由服务端来处理。但PlayerMangament组件会为所有连接上它的MoveManagement组件提供一个微服务的API。

在图中,可以看到MoveMangament与PlayerManagment之间存在一个完整的系统架构边界。

图25.7:添加一个微服务的API

第26章 Main组件

在所有的系统中,都至少要有一个组件来负责创建、协调、监督其他组件的运转。我们将其称为Main组件。

最细节化的部分

Main组件是系统中最细节化的部分——也就是底层的策略,它是整个系统的初始点。在整个系统中,除了操作系统不会再有其他组件依赖于它了Main组件的任务是创建所有的工厂类、策略类以及其他的全局设施,并最终将系统的控制权转交给最高抽象层的代码来处理

Main组件中的依赖关系通常应该由依赖注入框架来注入。在该框架将依赖关系注入到Main组件之后,Main组件就应该可以在不依赖于该框架的情况下自行分配这些依赖关系了。

请记住,Main组件是整个系统中细节信息最多的组件。

本章小结

Main组件也可以被视为应用程序的一个插件——这个插件负责设置起始状态、配置信息、加载外部资源,最后将控制权转交给应用程序的其他高层组件。另外,由于Main组件能以插件形式存在于系统中,因此我们可以为一个系统设计多个Main组件,让它们各自对应于不同的配置。

例如,我们既可以设计专门针对开发环境的Main组件,也可以设计专门针对测试的或者生产环境的Main组件。除此之外,我们还可以针对要部署的国家、地区甚至客户设计不同的Main组件。

当我们将Main组件视为一种插件时,用架构边界将它与系统其他部分隔离开这件事,在系统的配置上是不是就变得更容易了呢?

第27章 服务:宏观与微观

面向服务的“架构”以及微服务“架构”近年来非常流行,其中的原因如下:

  • 服务之间似乎是强隔离的,但是下文我们会讲到,并不完全是这样
  • 服务被认为是支持独立开发和部署的,同样,下文我们也会讲到,并不完全是这样。

面向服务的架构

首先,我们来批判“只要使用了服务,就等于有了一套架构”这种思想。这显然是完全错误的。如前文所述,架构设计的任务就是找到高层策略与低层细节之间的架构边界,同时保证这些边界遵守依赖关系规则。所谓的服务本身只是一种比函数调用方式成本稍高的,分割应用程序行为的一种形式,与系统架构无关。

我们用函数的组织形式来做个类比。不管是单体程序,还是多组件程序,系统架构都是由那些跨越架构边界的关键函数调用来定义的,并且整个架构必须遵守依赖关系规则。系统中许多其他的函数虽然也起到了隔离行为的效果,但它们显然并不具有架构意义。

服务的情况也一样,服务这种形式说到底不过是一种跨进程/平台边界的函数调用而已。有些服务会具有架构上的意义,有些则没有。我们这里重点要讨论的,当然是前者。

服务所带来的好处

  • 解耦合的谬论:确实,服务之间的确在变量层面做到了彼此隔离。然而,它们之间还是可能会因为处理器内的共享资源,或者通过网络共享资源而彼此耦合的。另外,任何形式的共享数据行为都会导致强耦合。
    • 例如,如果给服务之间传递的数据记录中增加了一个新字段,那么每个需要操作这个字段的服务都必须要做出相应的变更,服务之间必须对这条数据的解读达成一致。因此其实这些服务全部是强耦合于这条数据结构的,因此它们是间接彼此耦合的。
    • 再来说说服务能很好地定义接口——它确实能很好地定义接口——但函数也能做到这一点。事实上,服务的接口与普通的函数接口相比,并没有比后者更正式、更严谨,也没有更好,这一点根本算不上什么好处。
  • 独立开发部署的谬论:无数历史事实证明,大型系统一样可以采用单体模式,或者组件模式来构建,不一定非得服务化。因此服务化并不是构建大型系统的唯一选择。其次,上文说到的解耦合谬论已经说明拆分服务并不意味着这些服务可以彼此独立开发、部署和运维。如果这些服务之间以数据形式或者行为形式相耦合,那么它们的开发、部署和运维也必须彼此协调来进行。

运送猫咪的难题

图27.1:出租车调度系统的服务架构图

为了增加这个运送猫咪的功能,该系统所有的服务都需要做变更,而且这些服务之间还要彼此做好协调。

换句话说,这些服务事实上全都是强耦合的,并不能真正做到独立开发、部署和维护。

这就是所谓的横跨型变更(cross-cutting concern)问题,它是所有的软件系统都要面对的问题,无论服务化还是非服务化的。其中,图27.1所示的这种按功能切分服务的架构方式,在跨系统的功能变更时是最脆弱的。

对象化是救星

如果采用组件化的系统架构,如何解决这个难题呢?通过对SOLID设计原则的仔细考虑,我们应该一开始就设计出一系列多态化的类,以应对将来新功能的扩展需要。

这种策略下的系统架构如图27.2所示,我们可以看到该图中的类与图27.1中的服务大致是相互对应的。然而,请读者注意这里设置了架构边界,并且遵守了依赖关系原则。

现在,原先服务化设计中的大部分逻辑都被包含在对象模型的基类中。然而,针对每次特定行程的逻辑被抽离到一个单独的Rides组件中。运送猫咪的新功能被放入到Kittens组件中。这两个组件覆盖了原始组件中的抽象基类,这种设计模式被称作模板方法模式或策略模式。

同时,我们也会注意到Rides和Kittens这两个新组件都遵守了依赖关系原则。另外,实现功能的类也都是由UI控制下的工厂类创建出来的。

显然,如果我们在这种架构下引入运送猫咪的功能,TaxiUI组件就必须随之变更,但其他的组件就无须变更了。这里只需要引入一个新的jar文件或者Gem、DLL。系统在运行时就会自动动态地加载它们。

图27.2:采用面向对象的方法来处理横跨型变更

这样一来,运送猫咪的功能就与系统的其他部分实现了解耦,可以实现独立开发和部署了。

基于组件的服务

那么,问题来了:服务化也可以做到这一点吗?答案是肯定的。服务并不一定必须是小型的单体程序。服务也可以按照SOLID原则来设计,按照组件结构来部署,这样就可以做到在添加/删除组件时不影响服务中的其他组件。

我们可以将Java中的服务看作是一个或多个jar文件中的一组抽象类,而每个新功能或功能扩展都是另一个jar文件中的类,它们都扩展了之前jar文件中的抽象类。这样一来,部署新功能就不再是部署服务了,而只是简单地在服务的加载路径下增加一个jar文件。换句话说,这种增加新功能的过程符合开闭原则(OCP)。

这种服务的架构如图27.3所示。我们可以看到,在该架构中服务仍然和之前一样,但是每个服务中都增加了内部组件结构,以便使用衍生类来添加新功能,而这些衍生类都有各自所生存的组件。

图27.3:该架构中的每个服务有自己内部的组件结构,允许以衍生类的方式为其添加新功能

横跨型变更

现在我们应该已经明白了,系统的架构边界事实上并不落在服务之间,而是穿透所有服务,在服务内部以组件的形式存在

为了处理这个所有大型系统都会遇到的横跨型变更问题,我们必须在服务内部采用遵守依赖关系原则的组件设计方式,如图27.4所示。总而言之,服务边界并不能代表系统的架构边界,服务内部的组件边界才是。

图27.4:服务内部的组件的设计必须符合依赖指向规则

本章小结

虽然服务化可能有助于提升系统的可扩展性和可研发性,但服务本身却并不能代表整个系统的架构设计。系统的架构是由系统内部的架构边界,以及边界之间的依赖关系所定义的,与系统中各组件之间的调用和通信方式无关。

一个服务可能是一个独立组件,以系统架构边界的形式隔开。一个服务也可能由几个组件组成,其中的组件以架构边界的形式互相隔离。在极端情况下[19],客户端和服务端甚至可能会由于耦合得过于紧密而不具备系统架构意义上的隔离性。

第28章 测试边界

和程序代码一样,测试代码也是系统的一部分。甚至,测试代码有时在系统架构中的地位还要比其他部分更独特一些。

测试也是一种系统组件

究其本质而言,测试组件也是要遵守依赖关系原则的。因为其中总是充满了各种细节信息,非常具体,所以它始终都是向内依赖于被测试部分的代码的。事实上,我们可以将测试组件视为系统架构中最外圈的程序。它们始终是向内依赖的,而且系统中没有其他组件依赖于它们。

另外,测试组件是可以独立部署的。事实上,大部分测试组件都是被部署在测试环境中,而不是生产环境中的。所以,即使是在那些本身不需要独立部署的系统中,其测试代码也总是独立部署的。

测试组件通常是一个系统中最独立的组件。系统的正常运行并不需要用到测试组件,用户也不依赖于测试组件。测试组件的存在是为了支持开发过程,而不是运行过程。然而,测试组件仍然是系统中不可或缺的一个组件。事实上,测试组件在许多方面都反映了系统中其他组件所应遵循的设计模型。

可测试性设计

测试如果没有被集成到系统设计中,往往是非常脆弱的,这种脆弱性会使得系统变得死板,非常难以更改。

当然,这里的关键之处就是耦合。如果测试代码与系统是强耦合的,它就得随着系统变更而变更。哪怕只是系统中组件的一点小变化,都可能会导致许多与之相耦合的测试出现问题,需要做出相应的变更。

这个问题可能会很严重。修改一个通用的系统组件可能会导致成百上千个测试出现问题,我们通常将这类问题称为脆弱的测试问题(fragiletestsproblem)。

要想解决这个问题,就必须在设计中考虑到系统的可测试性。软件设计的第一条原则——不管是为了可测试性还是其他什么东西——是不变的,就是不要依赖于多变的东西。譬如,GUI往往是多变的,因此通过GUI来验证系统的测试一定是脆弱的。因此,我们在系统设计与测试设计时,应该让业务逻辑不通过GUI也可以被测试。

测试专用API

设计这样一个系统的方法之一就是专门为验证业务逻辑的测试创建一个API。这个API应该被授予超级用户权限,允许测试代码可以忽视安全限制,绕过那些成本高昂的资源(例如数据库),强制将系统设置到某种可测试的状态中。总而言之,该API应该成为用户界面所用到的交互器与接口适配器的一个超集。

设置测试API是为了将测试部分从应用程序中分离出来。换句话说,这种解耦动作不只是为了分隔测试部分与UI部分,而是要将测试代码的结构与应用程序其他部分的代码结构分开。

结构性耦合

结构性耦合是测试代码所具有的耦合关系中最强大、最阴险的一种形式。假设我们现在有一组测试套件,它针对每个产品类都有一个对应的测试类,每个产品函数都有一个对应的测试函数。显然,该测试套件与应用程序在结构上是紧耦合的。

每当应用程序中的一个函数或类发生变更时,该测试套件就必须进行大量相应的修改。因此,这些测试是非常脆弱的,它们也会让产品代码变得非常死板。

测试专用API的作用就是将应用程序与测试代码解耦。这样,我们的产品代码就可以在不影响测试的情况下进行重构和演进。同样的,这种设计也允许测试代码在不影响生产代码的情况下进行重构和演进。

这种对演进过程的隔离是很重要的,因为随着时间的推移,测试代码趋向于越来越具体和详细。相比之下,我们的产品代码则会趋向于越来越抽象和通用。

第29章 整洁的嵌入式架构

“虽然软件质量本身并不会随时间推移而损耗,但是未妥善管理的硬件依赖和固件依赖却是软件的头号杀手。”

本可以长期使用的嵌入式软件可能会由于其中隐含的硬件依赖关系而无法继续使用,这种情况是很常见的。

固件并不一定是指存储在ROM中的代码。固件也并不是依据其存储的位置来定义的,而是由其代码的依赖关系,及其随着硬件的演进在变更难度上的变化来定义的。

我们真的应该少写点固件,而多写点软件。

还有,非嵌入式工程师竟然也要写固件程序!虽然你可能并不是嵌入式系统的开发者,但如果你在代码中嵌入了SQL或者是代码中引入了对某个平台的依赖的话,其实就是在写固件代码。譬如,Android工程师在没有将业务逻辑与Android API分离之前,实际上也是在写固件代码。

再来看另外一个例子:我们都知道命令消息是通过串行端口传递给系统的。这自然就要有一个消息的处理器/分发器系统。其中,消息处理器得了解消息格式,可以解析消息,然后将消息分发给具体的处理代码。这些都很正常,但消息处理器/分发器的代码和操作UART硬件[21]的代码往往会被放在同一个文件中,消息处理器的代码中常常充斥着与UART相关的实现细节。这样一来,本可以长时间使用的消息处理器代码变成了一段固件代码,这太不应该了!

下面就来看一下应该如何通过好的架构设计让嵌入式代码拥有更长的有效生命周期。

“程序适用测试”测试

对于程序员来说,让他的程序工作这件事只能被称为“程序适用测试(app-titude test)”。

目标硬件瓶颈

目标硬件瓶颈(target-hardware bottleneck)是嵌入式开发所特有的一个问题,如果我们没有采用某种清晰的架构来设计嵌入式系统的代码结构,就经常会面临只能在目标系统平台上测试代码的难题。如果只能在特定的平台上测试代码,那么这一定会拖慢项目的开发进度。

整洁的嵌入式架构就是可测试的嵌入式架构

分层

分层可以有很多种方式,这里先按图29.1所示的设计将系统分成三层。首先,底层是硬件层。正如Doug警告我们的那样,由于科技的进步与摩尔定律,硬件是一定会改变的。

图29.1:三层结构设计图29.2:硬件必须与系统其他部分分隔开

硬件与系统其他部分的分隔是既定的——至少在硬件设计完成之后如此(如图29.2所示)。

另外,软件与固件集成在一起也属于设计上的反模式(anti-pattern)。符合这种反模式的代码修改起来都会很困难。

硬件是实现细节

软件与固件之间的分割线往往没有代码与硬件之间的分割线那么清晰,如图29.3所示。

图29.3:软件与固件之间的边界往往没有代码与硬件之间的边界那么清晰图29.4:硬件抽象层

所以,我们的工作之一就是将这个边界定义得更清晰一些。软件与固件之间的边界被称为硬件抽象层(HAL),如图29.4所示。这不是一个新概念,它在PC上的存在甚至可以追溯到Windows诞生之前。

HAL的存在是为了给它上层的软件提供服务,HAL的API应该按照这些软件的需要来量身定做。HAL的作用是为软件部分提供一种服务,以便隐藏具体的实现细节。

不要向HAL的用户暴露硬件细节

依照整洁的嵌入式架构所建构的软件应该是可以脱离目标硬件平台来进行测试的。因为设计合理的HAL可以为我们脱离硬件平台的测试提供相应的支撑。

处理器是实现细节

当我们的嵌入式应用依赖于某种特殊的工具链时,该工具链通常会为我们提供一些“<i>帮助</i>”[23]性质的头文件。这些编译器往往会自带一些基于C语言的扩展库,并添加一些用于访问特殊功能的关键词。这会导致这些程序的代码看起来仍然用的是C语言,但实际上它们已经不是C语言了。

有时候,这些嵌入式应用的提供商所指定的C编译器还会提供类似于全局变量的功能,以便我们直接访问寄存器、I/O端口、时钟信息、I/O位、中断控制器以及其他处理器函数,这些函数会极大地方便我们对相关硬件的访问。但请注意,一旦你在代码中使用了这些函数,你写的就不再是C语言程序,它就不能用其他编译器来编译了,甚至可能连同一个处理器的不同编译器也不行。

为了避免自己的代码在未来出现问题,我们就必须限制这些C扩展的使用范围。

下面来看一下针对ACME DSP(数字信号处理器)系统设计的头文件——Wile E Coyote采用的就是这个系统:

#ifndef _ACME_STD_TYPES
#define _ACME_STD_TYPES#if defined(_ACME_X42)typedef unsigned int Uint_32;typedef unsigned short Uint_16;typedef unsigned char Uint_8;typedef int Int_32;typedef short Int_16;typedef char Int_8;
#elif defined(_ACME_A42)typedef unsigned long Uint_32;typedef unsigned int Uint_16;typedef unsigned char Uint_8;typedef long Int_32;typedef int Int_16;typedef char Int_8;
#else#error <acmetypes.h> is not supported for this environment
#endif
#endif

该acmetypes.h头文件通常不应该直接使用。因为如果这样做的话,代码就和某个ACME DSP绑定在一起了。

这时候你可能会问,我们在这里写代码不就是为了使用ACME DSP吗?不引用这个头文件如何编译代码呢?但如果引用了这个头文件,就等于同时定义了__ACME_X42和__ACME_A42,那么我们的代码在平台之外进行测试的时候整数类型的大小就会是错误的。

因此在这里,我们应该用更标准的stdint.h来替代acmetypes.h。如果目标编译器没有提供stdint.h的话,我们可以自己写一个。例如,下面就是一个针对目标编译器的,可以用acmetypes.h来构建目标的自定义stdint.h:

#ifndef _STDINT_H_
#define _STDINT_H_
#include <acmetypes.h>
typedef Uint_32 uint32_t;
typedef Uint_16 uint16_t;
typedef Uint_8 uint8_t;
typedef Int_32 int32_t;
typedef Int_16 int16_t;
typedef Int_8 int8_t;
#endif

使用stdint.h来编写嵌入式的软件和固件,你的代码会是整洁且可移植的。当然,我们应该让所有的软件都独立于处理器,但这并不是所有固件都可以做到的。

在整洁的嵌入式架构中,我们会将这些用于设备访问的寄存器访问集中在一起,并将其限制在固件层中。这样一来,任何需要知道这些寄存器值的代码都必须成为固件代码,与硬件实现绑定。一旦这些代码与处理器实现强绑定,那么在处理器稳定工作之前它们是无法工作的,并且在需要将其迁移到一个新处理器上时也会遇到麻烦。

如果我们真的需要使用这种微处理器,固件就必须将这类底层函数隔离成处理器抽象层(PAL),这样一来,使用PAL的固件代码就可以在目标平台之外被测试了。

操作系统是实现细节

为了延长代码的生命周期,我们必须将操作系统也定义为实现细节,让代码避免与操作系统层产生依赖。

整洁的嵌入式架构会引入操作系统抽象层(OSAL,如图29.6所示),将软件与操作系统分隔开。

图29.6:操作系统抽象层

当然,我们可能会担心代码膨胀的问题。但是,其实上面这种分层已经将因为使用操作系统所带来的重复性代码隔离开了,因此这种重复不一定会带来很大的额外负担。而且,如果我们定义了OSAL,还可以让自己的应用共享一种公用结构。比如采用一套标准的消息传递机制,这样每个线程就不用自己定义一个并行模型了。

另外,OSAL还可以帮助高价值的应用程序实现在目标平台、目标操作系统之外进行测试。一个由整洁的嵌入式架构所构建出来的软件是可以在目标操作系统之外被测试的。设计良好的OSAL会为这种目标环境外的测试提供支撑点。

面向接口编程与可替代性

分层架构的理念是基于接口编程的理念来设计的。当模块之间能以接口形式交互时,我们就可以将一个服务替换成另外一个服务。例如,很多读者应该都写过能在某个目标机器上运行的、小型的自定义的printf函数。只要我们的printf与标准的printf函数接口一致,它们就可以互相替换。

目前的普适规则之一就是用头文件来充当接口的定义。然而,如果真的要这样做的话,就需要小心控制头文件中的内容,尽量确保头文件中只包括函数声明,以及函数所需要的结构体名字和常量。

另外,不要在定义接口的头文件中包含只有具体实现代码才需要的数据结构、常量以及类型定义(typedef)。这不仅仅是架构是否整洁的问题,而是这样做可能会导致意外的依赖关系。总之,我们必须控制好实现细节的可见性,因为这些实现细节是肯定会变化的。关注实现细节的代码越少,它们所需的变更就越少。

由整洁的嵌入式架构所构建的系统应该在每一个分层中都是可测试的,因为它的模块之间采用接口通信,每一个接口都为平台之外的测试提供了替换点。

DRY条件性编译命令

另一个经常被忽视的可替代换性规则的实际案例是嵌入式C/C++程序对不同平台和操作系统的处理方式。这些程序经常会用条件性编译命令来根据不同的平台启用和禁用某一段代码。例如,我曾经遇到过 #ifdef BOARD_V2这条语句在一个电信应用程序中出现了几千次的情况。

很显然,这种代码的重复违背了“不要重复自己(DRY)”原则[24]。

使用硬件抽象层如何?这样的话,硬件类型就只是HAL中的一个实现细节了。而且,如果系统中使用的是HAL所提供的一系列接口,而不是条件性编译语句,那么我们就可以用链接器,或者某种运行时加载器来将软件与硬件相结合了。

http://www.dtcms.com/a/412163.html

相关文章:

  • 网站搭建十大品牌公司1688一键铺货到拼多多
  • ai做网站ppt万能模板免费下载
  • 不同地区(语言)windows系统的字符串乱码问题
  • 英文外贸商城网站设计wordpress网站怎么加速
  • 软考-系统架构设计师 关系数据库详细讲解
  • 国外网站购物西安到北京火车票查询
  • 视频广告网站h5 技术做健康类网站
  • Java基础-面向对象复习知识3
  • [Windows] VarCalc v0.0.1
  • 网站开发培训北京有用建站宝盒做网站的吗
  • 亚马逊网站青岛鑫隆建设集团网站
  • 南宁最高端网站建设局域网内部网站建设app下载
  • 电机东莞网站建设建立网站时首先考虑的问题
  • 有没有什么网站做泰国的东西免费国内linux服务器
  • 销售网站制作电话甘肃交通建设监理公司网站
  • sqlite是什么
  • React学习第二天——表单控件
  • wordpress多站点详细设置(图解)杭州网络科技公司排名
  • 网站建设公司ejiew网站建设的公司哪家好
  • 免费前端模板网站公司加盟
  • ResNeXt-50--分组卷积--J6
  • 【开题答辩全过程】以 “勤工有道”微信小程序为例,包含答辩的问题和答案
  • 淄博网站建设费用摄影网站开发综述
  • 个人模板网站wordpress主题免费吗
  • 基于英飞凌PSOC Control C3的高速吹风机变频控制方案
  • 服装企业网站建设的目的网站宣传搭建
  • 有没有做维修的网站一个服务器可以做两个网站
  • display version 概念、故障排错及题目
  • 沈阳网站开发培训多少钱网站主页布局
  • 快速排名网站系统如何做不同域名跳转同一个网站