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

news/2025/9/30 15:42:50/文章来源:https://www.cnblogs.com/yxysuanfa/p/19121278

第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章 整洁架构

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

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

图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.mzph.cn/news/923011.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

嘉兴 企业网站 哪家厦门网站建设模板

效率工具 推荐一个程序员的常用工具网站&#xff0c;效率加倍嘎嘎好用&#xff1a;程序员常用工具 云服务器 云服务器限时免费领&#xff1a;轻量服务器2核4G腾讯云&#xff1a;2核2G4M云服务器新老同享99元/年&#xff0c;续费同价阿里云&#xff1a;2核2G3M的ECS服务器只需99…

网站建设后台 手工上传网页图片显示不出来打叉

介绍&#xff1a; JavaScript是一种基于对象和事件驱动的编程语言&#xff0c;在Web开发中占据着重要的地位。随着前端技术的不断发展&#xff0c;出现了一系列的框架和库&#xff0c;Vue和React是其中较为知名的两个。 Vue是一个轻量级的JavaScript框架&#xff0c;由尤雨溪…

金华市金东区建设局网站上海软件外包公司有哪些

1.概述 QwtPlotMarker类是Qwt绘图库中用于在图表上绘制标记的类。标记可以是垂直或水平线、直线、文本或箭头等。它可用于标记某个特定的位置、绘制参考线或注释信息。 以下是类继承关系图&#xff1a; 2.常用方法 设置标记的坐标。传入x和y坐标值&#xff0c;标记将被放置在…

怎么做网站教程html文本文档wordpress 首页 缩略图

目录 一、网络文件 1.1.存储类型 1.2.FTP 文件传输协议 1.3.传输模式 二、内网搭建yum仓库 一、网络文件 1.1.存储类型 直连式存储&#xff1a;Direct-Attached Storage&#xff0c;简称DAS 存储区域网络&#xff1a;Storage Area Network&#xff0c;简称SAN&#xff0…

回忆中学的函数

这篇文章,带你一次性回顾中学时代里的那些函数。如果对初中、高中的函数还记忆模糊,建议往下翻一翻。 目录一、函数的意义要素特征二、初阶函数1. 一次函数函数特征应用示例2. 反比例函数函数特征应用示例3. 二次函数…

Java 一行一行的读取文本,小Demo 大学问

String str="A\n" +"B\n" +"C";在Java中,有多种方式可以一行一行地读取文本。以下是几种常用的方法: 1. 使用 BufferedReader + FileReader String str = "A\n" + "B\…

免费网站系统沧州讯呗网络科技有限公司

动态标签foreach&#xff0c;做过批量操作&#xff0c;但是foreach只能处理记录数不多的批量操作&#xff0c;数据量大了后&#xff0c;先不说效率&#xff0c;能不能成功操作都是问题&#xff0c;所以这里讲一讲Mybatis正确的批量操作方法&#xff1a; 在获取opensession对象…

数字化转型业务流程总览图

数字化转型业务流程总览图flowchart TDA[客户询价/委托] --> B[智能报价系统<br/>AI-Powered Quotation]B --> C{报价确认?}C -->|是| D[订单管理<br/>Order Management]C -->|否| E[报价调整…

MYSQL数据库取消表的约束

要修改MySQL中的chk_quantity约束以允许负数,可以通过以下步骤实现: 1. 删除原有约束 首先需要删除现有的chk_quantity约束: sqlCopy Code ALTER TABLE 表名 DROP CONSTRAINT chk_quantity; 2. 重新添加允许负数的…

家里wifi电信出口ip如何控制不变,解决访问云服务器上面的资源

家里wifi电信出口ip如何控制不变,解决访问云服务器上面的资源家里wifi电信出口ip如何控制不变,解决访问云服务器上面的资源 解决方案:通过在公司部署一台公共机器,通过远程的方式来连接,而公司的公共机器是可以将公…

2025 年京东 e 卡回收平台最新推荐排行榜:权威测评实时结算平台,助力用户安全高效转让京东 e 卡

随着数字消费的普及,京东 e 卡作为常用电商消费凭证,其闲置回收需求持续攀升。但当前回收市场乱象丛生,部分平台结算周期长达数天,严重影响用户资金周转;还有平台暗藏手续费,导致用户实际收益大幅缩水,更有非正…

【qml-12】Quick3D达成机器人鼠标拖拽转换视角(无限角度)与滚轮缩放

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

2025 年挤压造粒机源头厂家最新推荐榜单:前五企业技术实力、服务能力及口碑测评指南对辊挤压/化肥挤压/干粉挤压造粒机厂家推荐

随着有机肥产业朝着规模化、精细化方向快速发展,挤压造粒机作为生产核心设备,其质量与性能直接决定企业生产效率、产品品质及综合成本。但当前市场环境中,设备乱象频发:部分设备无法适配湿度 20%-40% 的发酵有机物…

三生团队网站找谁做的中山市建设工程网上办事系统

因为在OJ上做编程&#xff0c;要求标准输入&#xff0c;特别是多行输入。特意查了资料&#xff0c;自己验证了可行性。if __name__ "__main__":strList []for line in sys.stdin: #当没有接受到输入结束信号就一直遍历每一行tempStr line.split()#对字符串利用空…

2025 年支付宝消费券回收平台最新推荐榜单:优质平台权威测评,助您高效安全处理闲置消费券支付宝消费券回收/闲置支付宝消费券回收/支付宝消费券快速回收平台推荐

随着支付宝消费券在日常生活中的广泛应用,越来越多用户面临消费券闲置难题 —— 指定消费场景限制、有效期短等问题,让大量消费券白白浪费。而当前支付宝消费券回收行业乱象丛生,部分平台结算周期长达数天、安全防护…

ICP备案查询网站 域名备案查询

ICP备案查询网站 域名备案查询ICP备案查询网站 官方查询渠道‌工信部ICP/IP地址/域名信息备案管理系统‌网址:https://beian.miit.gov.cn/https://beian.miit.gov.cn/#/Integrated/index

模板网站哪个好近期十大热点新闻

L1正则化和L2正则化是机器学习中常用的两种正则化方法&#xff0c;用于防止模型过拟合。它们的区别主要体现在数学形式、作用机制和应用效果上。以下是详细对比&#xff1a; 1. 数学定义 L1正则化&#xff08;也叫Lasso正则化&#xff09;&#xff1a; 在损失函数中加入权重参…

网站提交百度了经常修改网站搬瓦工做网站

hello宝子们...我们是艾斯视觉擅长ui设计和前端开发10年经验&#xff01;希望我的分享能帮助到您&#xff01;如需帮助可以评论关注私信我们一起探讨&#xff01;致敬感谢感恩&#xff01; 随着区块链技术和大数据技术的不断发展&#xff0c;两者的结合为企业带来了新的商业模式…

网络与系统攻防技术实验一——逆向破解与Bof

1.实验内容1.1手工修改可执行文件,改变程序执行流程,直接跳转到getShell函数。1.2利用foo函数的Bof漏洞,构造一个攻击输入字符串,覆盖返回地址,触发getShell函数。1.3注入一个自己制作的shellcode并运行这段shell…

对外宣传网站建设方案工商营业执照咨询电话24小时

文章目录 第一章 Range &#xff08;单元格&#xff09;对象1. 单元格的引用方法1.1 使用Range 属性1.2 使用Cells 属性1.3 使用快捷记号1.4 使用Offset 属性1.5 使用Resizae 属性1.6 使用Union 方法1.7 使用UsedRange 属性1.8 使用CurrentRegion 属性 2. 选定单元格区域的方法…