架构设计对于软件开发工程师来说有很多值得研究的地方,这里记录学习笔记。
软件架构设计的目标与过程
软件架构的定义
软件架构也称软件体系结构,它是一组有关如下要素的重要决策:
软件系统的组织,构成系统的结构化元素,接口和它们相互协作的行为的选择,结构化元素和行为元素组合成粒度更大的子系统的方式的选择,以及指导这一组织(元素及其接口、协作和组合方式)的架构风格的选择。
两个基本概念
- 组成:“计算机及组件之间的交互”,交互描述了它们之间的关系,例如“表示层”和“业务层”如何读取数据、功能如何调用等。
- 决策:架构决策不但表现了系统组织、元素、子系统的组织风格决策,还包括了非功能性需求的决策,例如对于可扩展性的决策,对于表示逻辑和业务逻辑变化的隔离,第三方工具包变化的隔离等,这就使架构有了弹性。
软件架构设计的方法学
设计过程要关注生产率
质量为前提,软件工程想尽办法把复杂的事情变得简单,变得可操作。
模块化可以提供生产率
模块化就是组件,组件的功能是单一的,组件搭建更大系统时只需要调用接口。
设计要关注便于分工合作
软件开发概念的现代化,主要是把商业的组织原则加于软件开发之上,也就是把一个大的系统通过分工合作,经过各有所长的精细化管理,它所增加的效率,可以使整个软件开发的效率大大提高。
设计要支持用数目字来管理
小问题关注细节,大问题关注系统。一个模块划分,应该划分多少,多少人员参与。一个功能完成了多少,什么进度,这些都需要用数目字来衡量。
软件开发是工程还是创造?
如果我们不能从根本上理解软件,那么所有的方法论都会成为无木之本。软件开发从工程角度,它是使开发过程的各个要素,以一种统一协调的方式运转,保证时间、资金和质量这样的三角约束得以平衡,需要制定相应的工程标准、建立实施过程、制定项目计划以及提供可预见可重复性的基础设施,因此是一种工程过程。而另一方面,从创造角度,软件开发里面的每个模块开发需要像写文章一样不断修改和涂抹,软件开发人员视需求为朋友不断发挥个人的创造价值去适应客户,从这个角度也是一种创造过程。
现代软件系统架构
软件开发技术演进
1936年图灵把人的计算定义为两个过程:a)在纸上写上或擦除某个符号;b)把注意力从纸上的一个位置移动到另一个位置。
在每个阶段,人要决定下一个动作,依赖于:a)此人当前所关注的纸上某个位置的符号;b)此人当前思维的状态。
和图灵机计算能力一致的还有lambda验算:
$\lambda$演算(lambda calculus)是一套从数学逻辑中发展,以变量绑定和替换的规则,来研究函数如何抽象化定义、函数如何被应用以及递归的形式系统。lambda演算作为一种广泛用途的计算模型,可以清晰地定义什么是一个可计算函数,而任何可计算函数都能以这种形式表达和求值,它能模拟单一磁带图灵机的计算过程;尽管如此,lambda演算强调的是变化规则的运用,而非实现它们的具体机器。
Lambda演算可比拟是最根本的编程语言,它包括了一条变换规则(变量替换)和一条将函数抽象化定义的方式。因此普遍公认是一直更接近软件而非硬件的方式。对函数式编程语言造成很大影响,比如Lisp、ML语言和Haskell语言。在1936年邱奇利用$\lambda$演算给出了对于判定性问题的否定:关于两个lambda表达式是否等价的命题,无法由一个“通用的算法”判断,这是不可判定性能够证明的头一个问题,甚至还在停机问题 之先。
Fortran语言是为了满足数值计算的需求而发展出来的。1953年12月,IBM公司工程师约翰·巴科斯(J. Backus)因深深体会编写程序很困难,而写了一份备忘录给董事长斯伯特·赫德(Cuthbert Hurd),建议为IBM704系统设计全新的电脑语言以提升开发效率。当时IBM公司的顾问冯·诺伊曼强烈反对,因为他认为不切实际而且根本不必要。但赫德批准了这项计划。1957年,IBM公司开发出第一套FORTRAN语言,在IBM704电脑上运作。
1958年,约翰·麦卡锡在麻省理工学院发明了Lisp编程语言,采用了信息处理语言的特征。1960年,他在《ACM通讯》发表论文,名为《递归函数的符号表达式以及由机器运算的方式,第一部》(Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I)。在这篇论文中阐述了只要透过一些简单的运算符,以及用于函数的记号,就可以创建一个具图灵完备性语言,可用于算法中。 Lisp最初创建时受到阿隆佐·邱奇的lambda演算的影响,用来作为计算机程序实用的数学表达。因为是早期的高端编程语言之一,它很快成为人工智能研究中最受欢迎的编程语言。
C语言作为结构化编程语言,它的优点是贴近图灵机模型,相对可以直接映射和充分调动硬件,静态可控性强;但是缺点是数据的全局可访问性带来较高耦合复杂度,局部可复用性及响应变化能力较差。建模方式为分层次、划模块、定接口,设计数据结构(数据建模)、规划算法流程。能够支撑了大规模并行开发,偏静态规划式开发交付,可裁剪性和可复用粒度过粗,响应变化能力相对较弱。主要关注系统级软件,追求稳定可靠少变。
C++和Java作为面向对象编程语言,它的优点是对象自封装数据和行为,利于理解和复用; 对象作为“稳定的设计质料”,适合泛领域使用; 多态提高了响应变化的能力,进一步提升了软件规模;对设计的理解和演进优先是对结构的理解和调整;它的缺点是业务逻辑碎片化,散落在离散的对象内;行为和数据的失匹配协调(贫血与充血)-> DCI架构;面向对象建模依赖工程经验,缺乏严格的理论支撑。主要关注企业级应用,业务多变,软件追求泛领域、高复用、易扩展、分布式。
随着网络信息速度的提升,对软件的需求量增大,开发需求周期缩短,逐渐发展出敏捷开发、演进式设计和领域驱动设计,为满足多元化业务服务,主要关注增量设计、持续交付、快速响应。
而到了云计算时代(2015-至今),编程语言(Scala、C++1x、Go、Rust、Java8)也变成多范式融合,提供了异步、并发、安全等支持特性,基础设施分离并共享、架构自适应,主要关注云化、并发、弹性、安全,比如典型的说法叫云原生。
那么这么多编程范式的创新、架构设计的演进、建模方法的发展,我们需要回答类似这样的一些问题:1.“模块化”、“组件化”、“服务化”、“包管理”说的是什么?2. “设计原则”如何应用在软件设计中?3. “面向过程”、“面向数据”、“面向对象”、“面向函数”、“面向模型”等等的区别和联系是什么?
什么是优雅的架构
架构设计应该是跟艺术设计一般。遵循如下几个普遍原则:
- 一处一个事实
也就是尽量减少每个组件、每个类的颗粒度,遵循正交设计原则。
- 概念的完整性
概念完整性是同样也是为了减少问题的复杂性,从架构设计之初就应该保存单一的设计思想和哲学思考。
- 最少量机制
如何用最少量去解决单一的问题,永远与复杂的对抗。
- 抵制熵值
熵增定律告诉我们系统随着时间的推移会变得凌乱,也就是“破窗效应”,要在问题出现时及时注释出来或者是及时Debug。
设计的关键是拒绝烟囱系统
在设计早期需要从水平角度和垂直角度去设计元要素:
- 水平要素:多个应用和特定实现之间是共同的;
- 垂直要素:依赖于单个应用和特定软件实现。
遵循这两个要素可以设计出更加抽象的系统,从而避免需要时时清理的烟囱系统。水平领域捕捉系统的共性,垂直领域增加扩展接口。
如何进行架构规划
视图。视图是一个轻量级说明,包括图、表与规范说明,连在一起共同保证概念一致性。步骤如下:
- 定义架构目标;
- 定义问题;
- 选择视图;
- 集成蓝图
- 追踪视图到需求;
- 对蓝图进行迭代;
- 推广架构;
- 验证实现。
整个系统的顶层架构应该有这几块:利用业务架构概念保证系统业务上的正确性。应用结构化设计方法保证基础架构的稳定性。通过稳定接口保证了系统的一致性。通过信息隐蔽使架构在早期可验证并且缓解了风险。通过子系统和模块的高内聚低耦合优化,保证了架构在后期的可更正与可修改。通过云计算与面向服务的架构,实现了产品构建的低成本、高效率以及可伸缩性,这样的一个基础架构才算得上是一个比较成功的架构。至于子系统以上的顶层架构是需要日积月累的对业务的不断熟悉才能很好设计,而对于普通的软件开发工程师来说,一般是在一个小型的软件团队,需要更关注小粒度的结构设计,需要对架构进行重构和优化,所以后文将会讲解结构设计的重构与优化。
软件重构与优化
从方法论角度我们做顶层架构设计倾向于结构化方法,在子系统考虑问题的时候,一般采用面向对象方法来面对需求的变数。
重构的定义
重构(Refactoring):应用一系列不改变软件行为的重构操作对软件进行重新组织的过程,效率和可维护性是进行重构最重要的理由。
重构的原则
1)一个时刻只戴一顶帽子
重构软件开发分为两种不同的活动:增加功能和重构。在增加新功能的时候不应该改原有的代码;在重构的时候不应该新增新的功能。
2)小步前进
小步前进的步骤为:确定重构的位置(发现坏的味道),编写并运行单元测试,找到合适重构并进行实施,运行单元测试,修改单元测试,运行所有的单元测试和功能测试等。
面向对象设计的基本原则
首先需要了解一下UML的基础符号如下:
为了保证我们在变化驱动的过程环境下的结构合理性,我们需要一些设计原则来保证,于是就延伸出诸如单一职责原则、开放封闭原则、依赖倒置原则、接口隔离原则、包的内聚性原则以及包的依赖性原则等等。
单一职责原则(SRP)
也称为内聚性原则。SRP原则的描述为:就一个类而言,应该仅有一个引起它变化的原因。
开放封闭原则(OCP)
OCP原则为我们提供了一个保证就是我们后续软件版本是可以基于第一版逐步迭代下去又保持相对稳定。
OCP原则的基本概念
OCP原则的目的,是要求我们设计的软件实体(类、模块、函数等等)应该是可以扩展的,但是不可修改。
两个主要特征:
- 对于扩展是开放的:这意味着模块的行为是可以扩展的,当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。
- 对于更改是封闭的:对模块行为进行扩展时,不必改动模块的源代码。
实现OCP的关键是抽象
在C++、Java等OOP语言中,可以创建出固定却能够描述一组任意个可能行为的抽象体。这个抽象体就是抽象基类(接口)。而一组任意个可能的行为则表现为可能的派生类。模块可以操作一个抽象体。由于模块依赖于一个固定的抽象体,所以它对于更改可以是关闭的。同时,通过从这个抽象体派生,也可以扩展为这个模块的行为。
-
1)依赖于抽象,将隔离被依赖者的变化
-
2)抽象类也可以隔离这种变化——模板方法模式(Template Method)
预测变化和“贴切的”结构
通过以上讨论可以归纳出:如果我们预测到了变化,那么就可以设计一个抽象来隔离它。行为型模式大多数涉及两种对象,即封装可变化特征的新对象,和使用这些新对象的已经有的对象。二者之间通过对象组合在一起工作。这就避免了变化的功能成为这些已有对象的难以分割的一部分。
依赖倒置原则(DIP)
原则如下:
- 高层模块不应该依赖于底层模块。二者都应该依赖于抽象;
- 抽象不应该依赖于细节。细节应该依赖于抽象。
传统模型的缺陷就在于底层的一个改动对于高层来说都是连串的影响。
更合理的模型是上层设置抽象
每个较高层次都为它所需要的服务声明一个抽象接口,较低的层次实现了这些抽象接口,每个高层类都通过该抽象接口使用下一层,这样高层就不依赖于底层。底层反而依赖于在高层中声明的抽象服务接口。
倒置的接口所有权
不仅仅是依赖关系的倒置,也是接口所有权的倒置。我们通常会认为工具库应该拥有它们自己的接口。但是当应用了DIP时,我们发现往往是客户拥有抽象接口,而它们的服务者则从这些抽象接口派生。
依赖终止于抽象类和接口
根据这个启发式规则,可知:
- 任何变量都不应该持有一个指向具体类的指针或者引用;
- 任何类都不应该从具体类派生;
- 任何方法都不应该覆盖它的任何基类中的已经实行了的方法。
依赖倒置的最高实现是框架(Framework),将细节置于最顶层。应用程序通过调用框架接口,接口回调应用层逻辑来完成业务流程。
接口隔离原则(ISP)
ISP原则是应对“胖(fat)”接口而言的。类的“胖“(不内聚)接口可以分解为多组方法。每一组方法都服务于一组不同的客户程序。
分类客户就是分类接口
-
1)被不同客户使用的接口需要保持分类;
-
2)迫使接口改变的往往正是使用者;
包的设计与重构原则
如何进行包的设计
在UML的概念中,包可以用作包容、组织类的容器。通过把类组织成包,我们可以在更高层的抽象上来理解设计。我们也可以通过包来管理软件的开发和发布。目的就是根据一些原则对应用程序中的类进行划分,然后把那些划分后的类分配到包中。
五个基本问题:
- 在向包中分配类时应该依据什么原则?
- 应该使用什么设计原则来管理包之间的关系?
- 包的设计应该应于类呢(自顶向下)?还是类的设计应该先于包(自底向上)?
- 如何实际表现出“包”?
- 包创建好后,我们应当将它们用于何种目的?
包的内聚性原则
重用发布等价原则(REP)
这里的原则就是:重用的粒度就是发布的粒度。
共同重用原则(CRP)
这个原则描述为:一个包中的所有类应该是共同重用的。
共同封闭原则(CCP)
即包中的所有类对于同一类性质的变化应该是共同封闭的。
接下来的三个原则用来处理包之间的关系。
消除依赖环
- 把包的发布和私有修改分开
- 开发团队独立决定何时采用包的新版本
- 配置管理对软件开发的支持
- 这种决定过程需要考虑包的依赖关系
解除依赖环
任何情况下,都可以解除包之间的依赖环并把依赖关系图恢复为一个DAG。两个主要的方法
- 使用依赖倒置原则(DIP)
- 新创建一个新包
包的稳定依赖原则(SDO)
- 一个大量被依赖的包应该是稳定的
- 不被依赖的包可以不稳定
- 可改变的包位于顶部并依赖于底部稳定的包
- 易于更改的包不应该被严重依赖
包的稳定抽象原则(SAP)
- 在抽象类中根据OCP原则进行顶层设计
- 包的抽象程度应该和其稳定程序一致
SAP和SDP结合形成了针对包的DIP原则。SDP规定依赖应该朝着稳定的方向前进,而SAP则规定稳定性意味着抽象性。
封装类或者接口的变化
设计模式的原则和策略
- 开发-封闭原则
- 从场景进行设计的原则
- 封装变化的原则
利用外观模式封装类的变化
外观模式定义了一个把子系统的一组接口集成在一起的高层接口,以提供一个一致的处理方式,其他系统可以方便的调用子系统中的功能,而忽略子系统内部发生的变化。
- 使用场合
- 为一个比较复杂的子系统,提供一个简单的接口;
- 把客户程序和子系统的实现部分分离,提供子系统的独立性和可移植性;
- 简化子系统的依赖关系。
利用适配器模式封装接口变化
在系统之间集成的时候,最常见的问题是接口不一致,很多能满足功能的软件模块,由于接口不同,而导致无法使用。在这种情况下可以使用适配器模式。
适配器模式的含义在于,把一个类的接口转换为另一个接口,使原本不兼容而不能在一起工作的类能够一起工作。
封装业务单元的变化
如果我们遇到的是业务流程不变,但是业务单元可能改变,这种分离可以从纵向、横向和外网三个方面考虑。
利用模板方法封装业务单元变化
软件复用的关键是寻找相似性,很多时候是业务流程相似。这种情况下,可以定义一个业务流程骨架,将业务实现延伸到子类。
模板方法模式(Template Method)是准备一个抽象类,将部分逻辑以具体方法以及具体构造子类的形式实现,然后声明一些抽象方法来迫使子类实现剩余的逻辑。不同的子类可以以不同的方式实现这些抽象方法,从而对剩余的逻辑有不同的实现。
利用工厂模式封装对象变化
简单工厂的作用是实例化对象,而不需要客户了解这个对象属于哪个具体的子类。我们可以这样来理解,简单工厂是参数化的工厂方法,由于它可以处理粒度比较大的问题,所以还是单独列出来比较有利。
简单工厂实例化的类具有相同的接口,类的个数有限而且基本上不需要扩展的时候,可以使用简单工厂。使用简单工厂缺点是实例化的类型在编译的时候已经确定,如果增加新的类,需要修改工厂。
通常简单工厂需要采用静态方法来实现。
利用桥接模式封装业务单元变化
模板方法是利用继承来完成切割,当对耦合性要求比较高,无法使用继承的时候,可以横向切割,也就是使用桥接模式。
利用装饰器模式封装核心业务变化
在不改变对象的前提下,动态增加它的功能。也就是说,我们不希望改变原有的类,或者采用创建子类的方式增加功能,此时可以采用装饰器模式。
装饰器结构的一个重要的特点是,它继承于一个抽象类,但它又使用这个抽象类的聚合(即装饰类对象可以包含抽象类对象),恰当的设计,可以达到我们提出来的目的。
利用观察者模式处理业务单元的变化
当需要上层对底层的操作的时候,可以使用观察者模式实现向上协作。也就是上层相应底层的事件,但这个事件的执行代码由上层提供。
定义对象一对多的依赖关系,当一个对象发生变化的时候,所有依赖它的对象都得到通知并且被自动更新。
代理模式在架构设计中的应用
代理模式的意图,是为其它对象提供一个代理,以控制对这个对象的访问。
首先作为代理对象必须与被代理对象有相同的接口,换句话说,用户不能因为使不使用代理而做改变。其次,需要通过代理控制对对象的访问,这时,对于不需要代理的客户,被代理对象应该是不透明的,否则谈不上代理。
架构师的知识结构
- 首先是一个好的程序员,技术上强;
- 知识结构:对象的观点,UML、RUP、设计模式
- 系统的概念:分析能力、把握抽象的能力
- 沟通能力:与客户沟通能力、与项目其他成员的沟通能力
- 知识面要广、把握行业流行趋势,但不要赶时髦
- 灵活机动,不能教条
- 聚焦于人,而不是工艺技术
- 保存简单
- 迭代和递增的工作
- 亲自动手
- 开口讨论前先实践
- 让架构吸引你的客户