EagleBear2002 的博客

这里必须根绝一切犹豫,这里任何怯懦都无济于事

软件工程与计算II-10-软件体系结构设计与构建

本文主要内容来自 SpriCoder的博客,更换了更清晰的图片并对原文的疏漏做了补充和修正。

体系结构设计过程(简略步骤) 重要

  1. 分析关键需求和项目约束:分析用例文档和需求规格说明书(包含需求规格和项目约束)。注意既要考虑功能性需求,又要考虑非功能性需求,甚至很大意义上体系结构设计是为了满足非功能性需求。
  2. 通过选择体系结构风格:选择分层风格(信息系统、并行开发、非 web 应用),进行评审。
  3. 进行软件体系结构逻辑(抽象)设计:产生逻辑包图
  4. 依赖逻辑设计进行软件体系结构(实现)设计:产生物理类图
  5. 完善体系结构设计:关键类图,持久化数据格式的定义等
  6. 添加构件接口:包、重要文件的创建,定义接口
  7. 迭代过程 3-6
  1. 步骤 1-3 是逻辑设计,步骤 4-7 是物理设计
  2. 本文接下来的部分将要按照这个思路进行下去

第一步:分析关键需求和项目约束

  1. 一般来说,体系结构设计的输入要素主要由两个来源:
    1. 软件需求规格说明
    2. 项目约束
  2. 体系结构设计必须落实所有的功能性需求和非功能性需求

系统结构需求

实践案例

  1. 需求
    • a 概要功能需求:10 个功能
    • b 非功能性需求
      • 安全需求:Security1~3
      • 约束:IC2
  2. 项目约束
    • a 开发技术:Java
    • b 时间较为紧张
    • c 开发人员:不熟悉 Web 技术

第二步:通过选择体系结构风格

实践案例

  1. 分层风格
    1. 协议不变情况下易于修改
    2. 能够促进并行开发:只需要界定好层与层之间的接口

第三步:进行软件体系结构逻辑(抽象)设计

依据概要功能需求与体系结构风格建立初始设计

  1. 将需求分配到子系统和模块
    1. 考虑功能的相同性:不同任务,但是相同功能
    2. 考虑可复用性:结构、数据、行为的可复用性
  2. 实践案例
    1. 销售与退货
    2. 产品调整、入库、出库与库存分析
    3. 会员发展与礼品赠送
    4. 销售策略
  3. 基本功能
    1. 销售
    2. 库存
    3. 会员
    4. 销售策略
    5. 用户

  1. 销售是否只用到 Sale 的数据:下图中的显示部分用 UI 后缀,持久层用 Data 后缀(指包名)

  1. 连锁超市管理系统的改进概要功能设计

  1. 初步设计方案一:

  1. 初步设计方案二:
    1. 层与层之间就是一一对应了。
    2. 逻辑层对象进行一一交互,只要互相对应的进行通知即可。
    3. 进一步将业务层的功能进行切割划分

使用非功能性需求与项目约束评价和改进初始设计

初步设计的分析

  1. 能够满足项目约束:
    1. 分层风格能促进并行开发,从而缩短开发时间;
    2. 分层风格可以使用 Java 技术,而不使用 Web 技术。
  2. 无法满足安全需求(Security1~3)网络分布约束(IC2),所以需要改进:
    1. 为使其满足安全需求,可以增加用户登录与验证功能,可以建立专门的三个模块 (Presentation、Logic、 Data),也可以将该功能并入用户管理功能,即为 userui, user, userdata 三个模块增加新的职责。
    2. 为满足网络分布约束,需要将模块分布到客户端和服务器组成的网络上。
    3. 可以将 Prensentation 层模块部署在客户端,将 Logic 层和 Data 层的模块部署在服务器端。
    4. 也可将 Prensentation 层和 Logic 层模块部署在客户端,将 Data 层的模块部署在服务器端。
    5. 一旦相邻两层被部署到网络两端,那么它们之间的交互就无法通过程序调用来完成,可以考虑将简单的程序调用转化为远程方法调用 RMI(Remote Methods Invocation)者 HTTP。
  3. 连锁超市管理系统最终的软件体系结构逻辑设计方案

Package Design Principles 包设计原则

  1. 重用发布等价原则(REP):重用的粒度就是发布的粒度
  2. 共同封闭原则(CCP):包中所有类对于同一类性质的变化应该是共同封闭的,一个变化若对一个包产生影响,则对该包中的所有类产生影响,而对于其他包不造成任何影响。
  3. 共同重用原理(CRP):一个包中的所有类应该是能够共同重用的。
  4. 无环依赖原则(ADP):在包的依赖关系图中不能存在环。
  5. 稳定依赖原则(SDP):朝着稳定的方向进行依赖
  6. 稳定抽象原则(SAP):包的抽象程度应该和其稳定程度一致
  7. 前三条描述的是依赖性,后三条描述的是耦合性

Common Closure Principle (CCP) 共同封闭原则

  1. 一起修改的类应该放在一起
  2. 最小化修改对程序员的影响
  3. 当需要更改时,对程序员有利
  4. 如果更改由于编译和链接时间以及重新验证而影响了尽可能少的软件包
  5. 示例

  1. 总结
    1. 将具有相似闭包的类分组
    2. 面向可以预期的变更封闭包
    3. 将更改限制为几个软件包
    4. 降低包装释放频率
    5. 减少程序员的工作量
    6. 只对可预测的变更有作用,不可预测的变更会为系统带来极大的破坏能力,并且无法进行预测。

Common Reuse Principle (CRP) 共同重用原则

  1. 一起被重用的应该在一起
  2. 包应重点突出,用户应使用包中的所有类
  3. 总结
    1. 根据常见重用对类进行分组:避免给用户不必要的依赖
    2. 遵循 CRP 通常会导致软件包拆分:获得更多,更小,更专注的包
    3. 减少重新使用者的工作

共同封闭原则和共同重用的原则的折衷

  1. CCP 和 CRP 原则是互斥的,即不能同时满足它们。
  2. CRP 使重用者的生活更加轻松,而 CCP 使维护者的生活更加轻松。
  3. CCP 致力于使包装尽可能大,而 CRP 则尝试使包装很小。
  4. 在项目的早期,架构师可以设置包结构,使 CCP 占主导地位,并协助开发和维护。后来,随着体系结构的稳定,架构师可以重构程序包结构,以使外部重用程序的 CRP 最大化
  5. 也就是说在软件开发的不同阶段 CCP 和 CRP 的重视重用程度时不同的,要根据项目进展来进行不同程度的学习。

Reuse-Release Equivalency Principle (REP) 重用发布等价原则

  1. 重用的单位是发布单位
  2. 关于重用软件
  3. 可重用软件是外部软件,您可以使用它,但其他人可以维护它。
  4. 商业和非商业外部软件之间的重用没有区别。
  5. 例子

  1. 总结
    1. 为重用器分组组件(类)
    2. 单个类通常是不可重用的:几个协作类组成一个包
    3. 包中的类应构成可重用和可释放的模块:模块提供一致的功能
    4. 减少重新使用者的工作
    5. 和相关联的类一起发布,而不是单独进行发布

The Acyclic Dependencies Principle (ADP) 无环依赖原则

  1. 程序包的依赖关系结构必须是有向非循环图(DAG)
  2. 稳定并分批发布项目
  3. 以自上而下的层次结构组织程序包依赖关系
  4. 一旦形成环就可能会形成无限的修改(环中的包的修改会导致大量的修改的发生)

Dependencies are a DAG

  1. 左侧这个树就形成了环状结构

依赖循环打破循环:第一种方式:

  1. 这种情况是单向依赖产生的闭环
  2. 一种解决方案,将 Common error 依赖的一个 GUI 中的一个包,就可以避免循环依赖的出现了。

依赖循环打破循环:第二种方式:

  1. 另一情况,这种情况是互相依赖产生的闭环,A 依赖于 X,Y 依赖于 B
  2. 解决方案:从 Y 提供一个接口(这个接口被 B 实现,被 Y 持有)
    1. B 实现 BY:B 依赖于 BY

无环依赖原则的应用场景

  1. 层次式风格和主程序/子程序风格通常不会产生
  2. 面向对象式风格尤其要注意
  3. C/S 的 MVC 风格可能会发生
  4. 基于数据流、事件/消息、数据共享进行交互的体系结构风格通常不会发生

Stable Dependencies Principle (SDP) 稳定依赖原则

  1. 依赖应当指向一个稳定的方向(更改他所需要付出的努力)
  2. 稳定性:对应于更换包装所需的时间
  3. 稳定的软件包:项目内难以更改
  4. 稳定性可以量化

稳定依赖原则

  1. 为什么 X 是稳定的,而 Y 是不稳定的(别人变化不会影响到我)

包的稳定性度量

  1. 一种方法是计算进、出该包的依赖关系的数目。可以使用这些数值来计算该包的位置稳定性(positional stability)。
  2. (Ca)入耦合度(Afferent Coupling):指处于该包的外部并依赖于该包内的类的类的数目。
  3. (Ce)出耦合度(Efferent Coupling):指处于该包的内部并依赖于该包外的类的类的数目。
  4. (不稳定性 I)I = Ce / (Ca + Ce)
  5. 该度量的取值范围是[0,1]。I=0 表示该包具有大的稳定性。I=1 表示该包具有大的不稳定性。通过计算和一个包内的类有依赖关系的包外的类的数目,就可以计算出实体 Ca 和 Ce 。
  6. 当一个包的 I 度量值为 1 时,就意味着没有任何其他的包依赖于该包(Ca = 0);而该包却依赖于其他的包(Ce>0)。这是一个包不稳定的状态:它是不承担责任且有依赖性的。因为没有包依赖于它,所以它就没有不改变理由,而它所依赖的包会给它提供丰富的更改理由。
  7. 另一方面,当一个包的 I 度量值为 0 时,就意味着其他包会依赖于该包(Ca > 0),但是该包却不依赖于任何其他的包(Ce = 0)。它是负有责任且无依赖性的。这种包达到了大程度的稳定性。它的依赖者使其难以更改,而且没有任何依赖关系会迫使它去改变

Stable Abstractions Principle (SAP) 稳定抽象原则

  1. 稳定的包应该是抽象的包(接口)
  2. 不稳定的包应该是具体的包
  3. 稳定的包装包含高层的设计。
  4. 使它们成为抽象可以打开它们进行扩展,但可以关闭它们进行修改(OCP)。
  5. 稳定的难以更改的包装中保留了一些灵活性。

抽象性度量

  1. 包的抽象性用抽象类的数目和包中所有类的数目进行计算。
  2. 假如说包中类的总数是 Nc,  抽象类的数目是 Na,那么抽象度 A = Na / Nc

  1. String 类(Instability、Abstraction 原点)
  2. String 类就是尴尬的处于原点的一个包,导致任何对 String 的修改都有可能导致之前开发的 java 程序无法运行。

例子

包设计的过程

  1. 迭代的过程:先用 CCP  原则对把可能一同变化的类组织成包进行发布
  2. 随着系统的不断增长,我们开始关注创建可重用的元素,于是开始使用  CRP  和  REP  来指导包的组合。
  3. 后使用  ADP、SDP、SAP  对包图进行度量,去掉不好的依赖。(修改设计)

第四步:依赖逻辑设计进行软件体系结构(实现)设计;

  1. 开发包(构件)设计
  2. 运行时的进程
  3. 物理部署

初始物理包

细节考虑

  1. Presentation 层与 Logic 层被置于客户端,Data 层被置于服务器端,那么 Logic 层的开发包依赖于 Data 层的开发包是不可能的。
    1. 可以考虑使用 RMI 技术,RMI 技术会将 Data 层开发包分解为置于客户端的 dataservice 接口包和置于服务器的 data 开发包。这样一来,Logic 层开发包依赖于 dataservice 包,dataservice 和 data 层的开发包都依赖于 RMI 类库包。
  2. 所有的 Data 层开发包都需要进行数据持久化(例如读写数据库、读写文件等),所以它们会有一些重复代码,可以将重复代码独立为新的开发包然后所有的 Data 层开发包都依赖于 databaseutility,databaseutility 会依赖于 JDBC 类库包或者 IO 类库包。
  3. 所有的 Presentation 层开发包都需要使用图形类型建立界面,都要依赖于图形界面类库包。
  4. 此外,Presentation 层实现时,由 mainui 包负责整个页面之间的跳转逻辑。其它各包负责各自页面自身的功能。
  5. 在分层风格的典型设计中,不希望高层直接依赖于低层,而是为低层建立接口包, 实现依赖倒置原则(参见 15.2.3), 所以应该调整为:各 Prensentatin 层开发包(调用)依赖于 Logic 层接口包 businesslogicaservice 包,Logic 层开发包(实现)依赖于 Logic 层接口包 businesslogicaservice 包。
  6. 在分层风格的典型设计中,Presentation 层与 Logic 层之间、Logic 层与 Data 层之间可能会传递复杂数据对象,那么相邻两层都需要使用数据对象声明,所以需要将数据对象声明单独独立为开发包(VO 包与 PO 包),或者将数据对象声明放入接口包(VO 包放入 Logic 接口包,PO 包独立)。
  7. 开发包的循环依赖现象需要消除,对此可以使用依赖倒置原则(参见 15.2.3)循环依赖变为单向依赖:
    1. Sales 与 Commodity:将部分 Commodity 类抽象接口 commodityInfoService 置入 Sales 包, 这样 Commodity 单向依赖于 Sales(实现接口+调用)。
    2. Sales 与 Member:将部分 Member 类抽象接口置入 Sales 包,这样 Member 单向依赖于 Sales(实现接口+调用)。
  8. 在 Logic 层中,一些关于初始化和业务逻辑层上下文的工作被分配到 utilitybl 包中去。

连锁超市管理系统开发包图

  1. 客户端

  1. 服务器端

进程图

  1. 进程图主要是表明运行时的进程,以及个进程间如何进行通信的。

部署图

  1. UML 部署图描述了一个运行时的物理节点,以及在这个节点上运行的软件构建的静态视图。

第五步:完善体系结构设计

  1. 完善软件体系结构设计
  2. 细化软件体系结构设计

完善启动和网络链接

  1. 失败业务的现场保护问题
  2. 网络连接断开的恢复问题
  3. 除了细化职责建立关键类图之后,模块传递的数据对象也需要被明确定义,因为他们是模块建接口的重要部分,必须严格、准确。

细化 salesbl 模块

数据定义

  1. 接口的数据对象
  2. 关键类的重要数据结构
  3. Value Object (VO)
  4. Persistent Object(PO)
    1. 用 POJO 实现

Java 实体

  1. 实体是一个可以代表一个临时的业务实体的对象,比如一个账单或者用户
  2. 没有逻辑业务方法(行为)
  3. 实体必须在使用它们的会话或事务之间持久存在。
  4. 实体是存储在文件或者数据库中的
  5. 实体是一个 Beans
    1. Simple or EJB.

POJO(Plain Old Java Objects)= Simple Java Entities

1
2
3
4
5
6
7
8
9
public class Person extends Entity {
private String fisrst;
private String last;
private Address address;
private Phone phone;
public Phone getPhone() { return phone; }
public void setPhone(Phone p) { phone = p; }
// etc
}

Value Objects(逻辑层向展示层传递数据)

  1. 价值对象(VO)含一个或多个公共领域中实体的属性。
  2. 在层之间传递值对象,而不是实体。
  3. 应该考虑实现 Serializable
  4. 值对象可以更新和创建实体。
  5. 实体可以创建价值对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PersonVO extends ValueObject {
public String first;
public String last;
public int addressOid;
public String street;
public String apartment;
public String city;
public String state;
public String zip;
public int phoneOid;
public String phone;
public void update(Person per) { ... }
public Person makePerson() { ... }
}

图书地址业务实体

  1. 可以是多个实体的集合

项目实践

  1. VO: View 层与 Logic 之间的数据传递
    1. Customer(VIP)VO、CommodityVO、SaleLineItemVO、PaymentVO、 GiftVO…
  2. PO:Logic 与 Data 层之间的数据传递
    1. SalesPO、SaleLineItemPO、CustomerPO、CommodityPO、 PaymentPO、GiftPO…

持久化对象 UserPO 的细化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class UserPO implements Serializable {
int id;
String name;
String password;
UserRole role;
public UserPO(int i, String n, String p, UserRole r){
id = i;
name = n;
password = p;
role = r;
}
public String getName(){
return name;
}
public int getID(){
return id;
}
public String getPassword(){
return password;
}
public UserRole getRole(){
return role;
}
}

数据持久化的细化

第六步:添加构件接口

构件初步设计

  1. 根据分配的需求确定模块对外接口
  2. 初步设计关键类
  3. 编写接口规范

确定模块对外接口

  1. 有的接口是面向业务的

编写接口规范

  1. 规范的书写

体系结构的原型构建

包的创建

  1. 包用于将系统组织成有层次结构的机制,可以根据构件的设计来创建项目的包。

重要文件的创建

  1. 可选
  2. 数据文件
  3. 系统文件
  4. 配置文件

文件列表

  1. SalesBLService 的定义
1
2
3
4
5
6
7
8
9
10
11
12
13
public interface SalesBLService {
//销售界面得到商品和商品促销的信息
public CommodityVO getCommodityByID(int id);
public ArrayList<CommodityPromotionVO> getCommodityPromotionListByID(intcommodityID);
//销售界面得到会员的信息
public MemberVO getMember ();
//销售的步骤
public ResultMessage addMember(int id);
public ResultMessage addCommodity(int id, int quantity);
public double getTotal(int mode);
public double getChange(double payment);
public void endSales();
}

定义构件之间的接口

  1. 在包和文件定义之后,我们可以着力开始定义构件之间的接口

关键需求的实现

  1. 实现一些关键功能需求
    1. 比如连锁超市系统中销售用例就是一个关键的用例。它所覆盖的面广,如果销售用例可以实现的话,其它用例的实现就比较有信心了。而且这个需求需要做到端到端的实现。
    2. 比如,连锁超市系统中存在客户端和服务器端。那么我们就需要在客户端通过 GUI 界面发出指令,通过客户端的业务逻辑处理,访问服务器端的数据,对数据进行修改。只有这样才能表明当前的体系结构可以胜任功能性需求。
  2. 对原型的非功能性指标进行估算和验证。如果出现不符合非功能性需求和项目约束的情况,我们还需要重新对体系结构设计进行调整。
    1. 比如,连锁超市系统中我们用问难来保存数据,而当数据越来越多,特别是销售记录要保存 1 年。这时候,客户端跨网络对服务器上文件的查找就比较慢,可能会使得我们不满足实时性的要求。
    2. 这时候,可能我们就要调整体系结构中数据存储的方案,比如换成数据库。或者在客户端的逻辑层和数据层之间,加设数据映射层。在客户端运行初始化时,将数据从服务器端载入到客户端的内存中去。

体系结构集成与测试

集成的策略

  1. 当体系结构中原型各个模块的代码都编写完成并经过单元测试之后,需要将所有模块组合起来形成整个软件原型系统, 这就是集成。
  2. 集成的目的是为了逐步让各个模块合成为一个系统来工作,从而验证整个系统的功能、性能、可靠性等需求。对于被集成起来的系统一般主要是通过其暴露出来的接口,伪装一定的参数和输入,进行黑盒测试。
  3. 根据从模块之间集成的先后顺序,一般有下列几种常见的集成策略:
    1. 大爆炸式(所有放在一起,看能不能过)
    2. 增量式
      1. 自顶向下式
      2. 自底向上式
      3. 三明治式
      4. 持续集成
  4. 模块开发完成后我们进行开发集成测试用例

大爆炸集成

  1. 大爆炸集成:将所有模块一次性组合在一起。
  2. 优点:可以在短时间内迅速完成集成
  3. 缺点:一次运行成功可能性不高,不容易发现 bug

自顶向下集成

  1. 自顶向下集成是对分层次的架构,先集成和测试上层的测模块,下层的模块用伪装的具体接口的桩程序。
  2. 自顶向下集成的优点:
    1. 按深度优先可以首先实现和验证一个完整的功能需求;
    2. 只需顶端一个驱动(Driver);
    3. 利于故障定位。
  3. 自顶向下集成的缺点:
    1. 桩的开发量大;
    2. 底层验证被推迟,且底层组件测试不充分。
    3. 但是一般会带来比较好的顶层设计的优势。
  4. 自顶向下基层适用于控制结构比较清晰和稳定、高层接口变化较小、底层接口未定义或经常被修改、控制组件有具有较大的技术风险的软件系统。

  1. 测试 M1 的时候,我们使用假的 M2、M3、M4 来进行代替(简单的代替这个模块)(被叫做 Stub)

自底向上集成

  1. 高层设计的错误不会被很快的发现
  2. 而这往往被我们认为会带来比较致命的问题
  3. 优点:
    1. 对底层组件较早进行验证
    2. 底层组件开发可以并行
    3. 桩的工作量少
  4. 缺点:
    1. 驱动的开发作用量大
    2. 对高层的设计被推迟,设计上的高层错误并不能被及时发现

持续集成

  1. 一种增量集成方法,但它提倡尽早集成和频繁集成
  2. 尽早集成是指不需要总是等待一个模块开发完成才把它集成起来,而是在开发之初就利用 Stub 集成起来。
  3. 频繁集成是指开发者每次完成一些开发任务之后,就可以用开发结果替换 Stub 中的相应组件,进行集成与测试。一般来说,每人每天至少集成一次,也可以多次。
  4. 结合尽早集成和频繁集成的办法,持续集成可以做到:
    1. 防止软件开发中出现无法集成与发布的情况。因为软件项目在任何时刻都是可以集成和发布的。
    2. 有利于检查和发现集成缺陷。因为早的版本主要集成了简单的 Stub,比较容易做到没有错误。后续代码逐渐开发完成后,频繁集成又使得即使出现集成问题也能够尽快发现、尽快解决。
  5. 持续集成的频率很高,所以手动改的集成对软件工程师来说是无法接受的,必须利用版本控制工具和持续集成工具。如果程序员可以仅仅使用一条命令就完成一次完整的集成,开发团队才有动力并且能够坚持进行频繁的集成。
  6. 如果你们的团队选择,白天进行代码开发,晚上进行代码测试,如果晚上你的代码出现比较严重的问题的时候,有可能会叫醒你来修改代码,以避免第二天浪费更多的时间。

依据模块接口建立桩程序 Stub

  1. Stub 桩程序
    1. 为了完成程序的编译和连接而使用的暂时代码
    2. 对外模拟和代替承担模块接口的关键类
    3. 比真实程序简单的多,使用为简单的逻辑:比如简单的替代 DAO 层。

Stub 定义

Stub 使用

Driver 定义

集成与构建

  1. 集成
    1. 使用桩程序辅助集成
    2. 编译与链接
  2. 持续集成
    1. 逐步编写各个模块内部程序,替换相应的桩程序
    2. 真实程序不仅实现业务逻辑,而且会使用其他模块的接口程序(真实程序或者桩程序)
    3. 每次替换之后都进行集成与测试

项目实践

  1. 客户端
    1. View 层:HCI 特殊测试,需要模拟 logic 的 stub
    2. Logical 层:
      1. 驱动模拟来自 View 层的请求
      2. 桩程序模拟远程 Data
      3. 需要模拟同层调用的 driver 和 stub
  2. 服务器端(Data 层)
    1. 驱动模拟来自客户端的请求
    2. 桩程序模拟未完成的 Data 模块接口

体系结构文档化

体系结构设计文档模版 IEEE1471-2000

体系结构评审

评审的角度

  1. 设计方案正确性、先进性、可行性;
  2. 系统组成、系统要求及接口协调的合理性;
  3. 对于功能模块的输入参数、输出参数的定义是否明确;
  4. 系统性能、可靠性、安全性要求是否合理;
  5. 文档的描述是否清晰、明确。

体系结构评审的方法

  1. 对结果的评审:Checklist(动态更新的)
  2. 对设计决策的评审

软件体系结构设计文档的 Checklist

  1. 体系结构设计是否为后续开发提供了一个足够的视角?
  2. 是否所有的功能都被分配给了具体模块?
  3. 是否所有的非功能属性都得到了满足?
  4. 是否所有的项目约束都得到了满足?
  5. 体系结构设计是否为后继设计提供了简洁的概述、背景信息、限制条件和清晰的组织结构?
  6. 体系结构设计是否能应对可能发生的变更?
  7. 体系结构设计是否关注点在详细设计和用户接口的层次之上?
  8. 不同的体系结构设计视角的依赖是否一致?
  9. 系统环境是否定义,包括硬件、软件、和外部系统?