2.12 软件模型与方法
概述与动机
软件模型是对一个系统有意为之的简化,用于回答某个具体问题。方法则是一种有纪律的软件生产方式,包括在此过程中所用到的各种模型。二者共同构成了软件工程知识体系(SWEBOK,Software Engineering Body of Knowledge)中的一个知识领域,因为它们是你在构建系统之前、期间和之后用来推理系统的思维工具。一张 UML(统一建模语言)类图、一张实体关系图(ERD)、一个状态机、一份形式化规约,以及一个一次性原型,都是模型。瀑布模型、原型法、形式化开发和敏捷,都是方法。
为什么要费心构建模型?因为人的工作记忆容量很小,而软件系统却很庞大。没有人能把一个十万行的系统装进脑子里,所以我们绘制图形、编写抽象,一次只展示一个侧面:数据、控制流、状态、交互。模型从来不需要忠实于代码本身;它只需要适合支撑某一个决策。一个好的模型恰好展示出你做某个决定所需要的东西,并隐藏其余一切。
在大型团队中,真正攸关的是协调与沟通。当数百名工程师、架构师、分析师和审计人员共同在一个系统上工作时,共享的模型就是他们协商设计、需求与风险的共同立足点。因此,应把建模看作一件有具体任务要完成的工具。当一个模型的成本低于它所避免的错误时,它就是值得的;而当你只是为建模而建模、让一个模型在过时已久之后依然被保留、或者把它精雕细琢到超出了它本应服务的那个决策所需的程度时,它就成了浪费。建模与软件需求(第 2.8 章)、软件设计原则(第 2.2 章)、架构及其表示法(如 C4 与 arc42,第 3.1 章),以及敏捷工作方式(第 10.7 章)紧密相连。
关键原则
- 每一个模型都有其目的;如果你说不出这个模型支撑的是哪个决策,就不要画它。
- 抽象是建模的核心行为:纳入与目的相关的内容,省略其余的一切。
- 一致性在单个模型内部和多个模型之间都很重要;相互矛盾的模型比没有模型更糟。
- 模型首先是沟通工件;其受众决定了它的表示法和细节程度。
- 优先选择能回答问题的最轻量模型;精细化是有维护成本的。
- 一个模型的好坏取决于对它进行的分析;一个未经检验的模型只是一个未经测试的假设。
- 选择方法要匹配问题的不确定性、风险,以及失败的后果。
建议
以抽象、目的与一致性为原则来建模
在开始每一个模型之前,先明确其目的和受众。然后毫不留情地朝着这个目的进行抽象:一张用于解决竞态条件的时序图应当展示时序和消息,而不是每一个字段。让你的模型彼此保持一致,使 ERD 中的实体、类图中的类,以及需求中的名词都相互吻合,也要与现实保持一致,这意味着当系统发生变化时,你需要更新或删除相应的模型。一个被人信任的过时模型是一种隐患;一个被所有人无视的过时模型,即便是浪费,也依然会消耗人们的注意力。
根据问题选择结构性模型或行为性模型
使用结构性模型来展示一个系统由什么构成、各部分之间如何关联:用类图、组件图来展示这些,用实体关系图来展示数据结构。使用行为性模型来展示一个系统随时间发生了什么:用状态机描述具有有意义生命周期的对象,用时序图描述跨组件的交互,用活动图描述工作流和业务流程。选择那种恰好能揭示你眼前这个决策的表示法。大多数系统只需要少数几种精心挑选、有选择地绘制的图表类型,而不需要把整套 UML 目录套用到一切事物上。
分析模型,而不只是绘制模型
一个模型要靠分析、而不仅仅是绘制出来,才能证明自己的价值。检查一个状态机是否存在不可达状态、缺失的转换,以及死锁。检查一个 ERD 是否存在范式化问题和孤立的关系。对照需求走查一张时序图,找出缺失的错误处理路径。与能够发现问题所在的领域专家一起评审你的模型。而在失败代价高昂的地方,应当借助工具支持的分析手段(模型检测器、一致性检查器、仿真),而不是靠肉眼审查。
默认采用启发式方法
大多数软件都是用启发式方法构建的:这是一种基于经验、迭代式的方法,非正式地使用模型,并依据预期而非证明来评判结果。对于大多数商业和政府系统而言,这恰恰是正确的做法:需求会不断演变,而一个缺陷通常是可以补救的。启发式方法与敏捷(第 10.7 章)天然契合:只建立足够让团队达成一致的模型,然后开始构建并从中学习。
把形式化方法留给后果严重的核心部分
形式化方法用数学语言来表达规约,并通过验证(无论是证明还是穷尽式的模型检测)来确立某些属性。它们需要真正的技能和时间投入,而其回报恰恰体现在失败会是灾难性或不可逆的场景:安全攸关的控制系统、加密协议、金融结算核心系统等等。把它们应用于系统中那一小部分关键核心,而不是整个系统。还要注意,即便没有完整的证明,仅仅是形式化规约本身,往往也会因为强迫你变得精确而带来价值。
用原型法来消除不确定性
当需求或可行性尚不明朗时,构建一个原型来学习,然后有意识地决定是让它继续演化,还是将其舍弃。一次性原型以低成本探索某个问题,随后被删除。演化型原型则会成为产品本身,因此必须按生产标准来构建。经典的失败情形是让一个一次性原型意外地滑入生产环境。所以在开始构建原型之前,就要先确定它属于哪一种类型。
让方法匹配风险,而不是匹配潮流
根据问题的不确定性和失败的后果来选择方法。高不确定性适合采用原型法和敏捷迭代。高后果则适合采用形式化分析和严格的验证。一个二者兼具的系统,需要在一个整体上敏捷的外层之内,包裹一个关键的形式化核心。无论如何,都不要仅仅因为某种方法看起来很有声望、或者因为某个供应商在推销它,就去采用它。
权衡:优点与缺点
| 模型或方法 | 应用得当时 | 失效模式 |
|---|---|---|
| 结构性模型(UML、ERD) | 对各部分与数据形成共享认知 | 图表泛滥;与代码脱节 |
| 行为性模型(状态、时序、活动) | 揭示时序、状态和边界情形 | 图表过于细碎,无人愿读 |
| 启发式方法 | 快速、灵活,适合大多数系统 | 缺乏纪律;隐藏的假设 |
| 形式化方法 | 为关键核心提供可证明的属性 | 成本高;被误用于整个系统 |
| 原型法 | 低成本的学习;及早消除风险 | 一次性代码被提升为生产代码 |
| 敏捷方法 | 能适应不断变化的需求 | 可能省略掉困难问题所需的建模 |
反复出现的张力在于严谨性与速度之间。建模太少,会把隐藏的假设直接带入生产环境。建模太多,则会把精力耗费在从未支撑任何决策、并且随代码一变就迅速过时的图表上。没有一个固定的剂量能解决这个问题,只有一条比例原则:按照一个模型或方法所化解的不确定性、以及决策出错的代价,来决定投入多少。一个支付引擎和一个营销用的微型网站,理应得到不同的对待。
与团队讨论的问题
我们是分析自己的模型,还是只是画完就了事? 一个模型要靠分析、而不是靠存在本身来证明自己的价值:一个你从未检查过是否存在不可达状态或缺失转换的状态机,只是一个被伪装成图表的、未经测试的假设。在大型团队中,真正的缺陷往往就藏在这里,因为一张看起来合理的图,恰恰是在没有人对照需求走查过它、找出缺失的错误处理路径或孤立关系的情况下,才被人信任的。把你们最重要的行为性模型带到会议上,尝试把它”打破”:哪个转换没有定义,哪个状态没有出口,哪个时序没有超时处理?在失败代价高昂的地方,答案应当推动你们借助工具支持的分析手段(模型检测器、一致性检查器、仿真),而不是靠肉眼审查,因为为一个关键核心建模的全部意义,就在于在白板上发现缺陷,而不是在生产环境中发现它。
当我们的两个模型出现矛盾时,谁说了算,又是谁会注意到这种矛盾? 一致性在单个模型内部和多个模型之间都很重要,相互矛盾的模型比没有模型更糟,因为人们会依据这两者中的任何一个采取行动。在一个大型系统里,数据模型中的实体、设计中的类,以及需求中的名词,会随着不同团队更新不同工件而悄悄地各自偏离,而第一个征兆往往是一个生产缺陷()两个组件对某个事物”是什么”产生了分歧。带一个例子来讨论:挑选一个核心概念,检查 ERD、代码和需求是否真正在其形态和生命周期上达成一致。如果没有,就决定哪个工件是权威来源、由谁负责让其余工件与之保持同步,并且要愿意删除一个模型,而不是让一个过时的模型继续对团队撒谎。
我们系统中的哪个核心,一旦出错就会真正损失金钱或伤害到人,它是否得到了与之相称的严谨程度? 本章的核心主张是让方法匹配风险:对可补救的绝大多数情形采用启发式和敏捷方法,对少数后果严重的核心部分采用形式化规约与验证,对真正不确定的情形采用低成本的原型法。两种失效模式是对称的,而且代价都很高:把形式化方法用在一个营销用的微型网站上,是在烧钱;而把一个结算引擎或资格判定规则集当作普通的敏捷工作来对待,则是在招致灾难性的、不可逆的缺陷。带一张你们系统的地图来讨论,标出哪里出错是灾难性的、哪里是可补救的,哪里的需求是确定的、哪里是未知的。答案应当让你们的建模投入集中在真金白银和不确定性所在之处,并在其他地方明确地不去投入,这样一个关键的形式化核心,就可以嵌在一个整体上敏捷的外层之内,而两种方法都不会侵入对方的领地。
在我们动手写代码之前,我们做了多少建模工作,而这个”剂量”是否会随着眼前的不确定性而改变? “大设计先行”和”完全不做设计”都是失效模式,正确的剂量介于二者之间,由一个模型实际能消除多少不确定性来决定。在大型团队中,压力来自两个方向:治理流程可能要求在任何代码写出之前就产出一整套图表,从而把用最少信息做出的决策锁定下来;而交付的压力则可能促使团队跳过那唯一一个本可以发现某个高代价边界情形的状态机。带上你们最近的两个项目,把产出的模型分成两类:真正支撑了某个决策的,和仅仅因为模板要求而画出来的。在企业和政府项目中,阶段关卡或审批委员会常常要求提前产出文档,此时要准备好主张让建模跟随风险而不是跟随一份固定的交付清单走,这样支付核心系统就能得到应有的严谨对待,而内部报表工具也不会被无人阅读的图表淹没。
我们是否已经就一套共享的表示法和模型的统一存放位置达成一致,还是每个团队都在各自发明一套? 模型首先是沟通工件,当一张用某个团队自己的工具绘制的状态机无法被继承它的团队读懂、找到或信任时,其价值就崩塌了。对于数百名工程师而言,这里存在真实的相互制衡:统一规定的表示法和仓库能带来一致性和可发现性,但也会带来学习成本,并可能把人们推向重量级工具,而实际上一张拍照留存的白板就足够了。带上一些例子来讨论模型实际存放在哪里(维基、绘图工具、幻灯片,还是某个人的笔记本电脑),并问一问六个月之后谁还能找到并理解它。在企业和受监管的环境中,审计的角度会让这个问题更加尖锐:一位找不到当前数据模型、也无法把某个决策追溯到一份已记录的状态机的审计员,会把这个系统视为缺乏文档,因此要就一套小而共享的表示法和一个持久的存放位置达成一致,并在后果较低的地方接受轻量级的记录方式,而不必讲究形式。
在构建一个原型之前,我们是否有意识地决定它是一次性的还是演化型的,并且我们是否坚持这一选择? 一个经典且代价高昂的失败情形是:一个一次性原型因为演示效果不错、又没有人事先明确它的类型,就悄悄滑入了生产环境。这里的张力是真实存在的:一次性原型能换来成本最低的学习,理应被删除;而演化型原型会成为产品本身,从第一行代码开始就必须按生产标准来构建,把二者混为一谈,要么浪费返工的精力,要么把脆弱的代码送入一个它从未被设计来承担的角色。带上一个最近的原型,问一问在它被构建之前做出了什么决定、谁有权决定将其推广还是舍弃,以及这个决定是否在交付压力下依然被坚持了下来。在政府部门及其他需要担责的场景中,面向公民的系统承担着透明度和可靠性方面的义务,应把意外的”转正”视为一种控制失效:提前确定原型的命运,并把成功舍弃一个一次性原型当作一个值得庆祝的成果,而不是一种应当避免的浪费。
行业视角
初创企业。 在白板上建模,拍照留存,然后继续前进。你最稀缺的资源是工程注意力,所以只有当一个模型的成本低于它所避免的错误时,才去使用它:在编写订阅计费的边界情形代码之前画一个订阅状态机,而不是为一个下个月可能就要转型的产品套用整套 UML 目录。保持启发式和敏捷的做法,把形式化方法完全排除在选项之外,并把每一个原型都当作一次性的,除非你有意识地决定不这样做。
小型企业。 你很可能没有专职负责形式化建模的人,因此应依赖你所购买的工具和框架中已经内置的模型,而不是自建一套建模实践。把你确实要画的少数几个模型都围绕具体的决策来构建:一张简单的数据模型草图,用于就你所持有的客户数据达成一致;一张状态图,用于描述那唯一一个一旦出错就会让你流失客户的工作流。优先选择一个数据模型已被验证过的成熟产品,而不是自己构建并维护一套;并让你所画的一切都保持足够轻量,以便一个人就能维护它。
企业。 核心问题是跨众多团队的协调,因此共享模型就成为共同立足点:一个一致的数据模型、一套统一的表示法,以及一个能够找到并信任 ERD、C4 图表和状态机的存放位置。将表示法标准化为一小套,并强制执行一致性,这样需求、设计和数据库中的实体就不会在不同团队之间逐渐偏离。把形式化规约和模型检测留给后果严重的核心部分(结算、对账、访问控制),为其所需的专业技能提供资金支持,并为每一个已记录的模型保留一条能追溯到其所支撑决策的审计轨迹。
政府。 法律所规定的规则必须能够追溯到法条,这正是形式化规约物有所值的地方:精确地规约资格认定或评估逻辑,验证关键属性,并让审计员能够把每一个结果追溯到产生它的那条规则。采购也带来了额外的分量,因为文档和模型常常是合同交付物,因此需要就哪些模型是真正承载决策的、哪些只是为了满足检查清单而产出的达成一致。用通俗易懂的语言公开描述那些后果重大的系统是如何运作的,并在承诺投入生产构建之前,用一次性原型与真实用户一起测试面向公民的信息录入流程。
示例
初创企业。 一家构建订阅计费产品的小型初创公司,在写代码之前,先在白板上把订阅的生命周期(试用、生效、逾期未付、已取消、重新激活)画成了一个状态机。在走查这张图的过程中,他们注意到自己从未定义过一个逾期未付账户最终结清付款后会发生什么()这个边界情形本可能会让真实客户被困在一种悬而未决的状态中。这个仅耗时五分钟的模型省去了一场生产环境的头疼事,而他们选择拍照留存,而不是维护一套重量级的绘图工具。在其他地方,他们保持敏捷,只建立足够达成一致所需的模型,因为在他们这个规模上,一个缺陷是可以补救的,形式化方法只会是纯粹的成本。
企业。 一家全球性银行正在构建一个新的支付平台。团队使用一张实体关系图,在账户、账本和消息传递团队之间就共享的数据模型达成一致,并使用 C4 图表(第 3.1 章)来展示各个服务是如何组合在一起的。他们把交易的生命周期(待处理、已清算、已结算、已撤销、有争议)建模为一个明确的状态机,分析发现其中缺少一个针对部分撤销情形的转换。这个缺口在白板上就被修复了,而不是在生产环境中。时序图对照需求(第 2.8 章)走查了结算流程,从而发现了缺失的超时和重试路径。日常交付采用敏捷方式,但核心对账算法()其中的一个错误就意味着真金白银的损失()获得了一份形式化规约,并在实现之前接受了模型检测。建模投入集中在真金白银和不确定性所在之处,而在其他地方则保持轻量。
政府。 一家国家税务机构正在对福利评估流程进行现代化改造。由于资格规则是由法律规定并接受审计的,团队将这些规则编写为纯变换形式的形式化规约,并验证关键属性()例如没有任何一名申请人同时被判定为符合资格和不符合资格、每一个案例都会得出一个结论()这样审计员就能把结果追溯到法条。在这个形式化核心之外,团队构建了一个面向公民的信息录入表单的一次性原型,用于与真实用户一起测试。他们了解到一个分步式向导能够减少错误,随后舍弃了这个原型,并按生产标准重新构建了信息录入流程。活动图记录了端到端的个案工作人员流程,用于培训和审计。后果严重的规则获得了形式化的严谨对待;不确定的用户体验获得了低成本的原型验证;两种方法都没有被用错地方。
商业理由:动机、投资回报率与总拥有成本
建模的回报来自于更早地发现缺陷,因为此时修复它们的成本要低得多。在白板上发现的一个矛盾,只需要几分钟就能解决。而在生产环境中发现同一个矛盾,可能要付出一次事故、一次返工计划,或者在受监管的领域中,一次法律责任的代价。模型还能通过充当持久的沟通媒介来降低总拥有成本。一个比其作者存续得更久的系统()在企业和政府场景中这是常态()当其数据模型、状态机和关键流程都被准确记录下来时,维护起来要便宜得多。
这些成本是真实存在的,你必须加以权衡。构建模型需要时间,要构建得好还需要技能,并且要持续投入精力才能保持其最新状态;形式化方法还需要额外的专业人力投入。盈亏平衡点由不确定性和后果的大小来决定。当二者都很低时,繁重的建模会破坏价值,而敏捷式的启发方法会胜出。当其中之一很高时,有针对性的建模()对于关键核心而言,加上形式化验证()就能通过预防那一类代价高昂的失败,多倍地收回投入。要向领导层证明其合理性,就把建模投入与所化解的具体风险、以及长期存续系统的可维护性挂钩。并且要追踪模型是否真的被人参考过,因为一个无人使用的模型纯粹就是成本。
反模式与陷阱
- 为建模而建模: 仅仅因为某个流程要求就产出图表,而不是因为它能支撑某个决策。
- 把过时的模型当作真相来信任: 已经不再与代码相符、却仍被依赖的图表。
- 大设计先行: 在任何代码写出之前就产出详尽无遗的模型,把用最少信息做出的决策锁定下来。
- 图表泛滥: 把每一种 UML 类型都统一套用一遍,让少数真正有用的视图淹没在噪音之中。
- 到处使用形式化方法: 把昂贵的验证手段用在那些失败后果并不足以证明其合理性的代码上。
- 原型意外”转正”: 一个一次性原型被悄悄当作产品发布出去。
- 重表示法轻实质: 争论 UML 是否”正确”,而不是这个模型是否回答了那个问题。
成熟度模型
- 第 1 级(启动): 建模是临时性的,或者干脆不存在,且纯属被动反应;没有确定任何方法;模型即便被画出来,也是不一致的、未经分析的,并且会议一结束就被抛弃。
- 第 2 级(发展): 部分团队会绘制常见的图表,并遵循某个已命名的方法,但整个组织的实践水平参差不齐:模型往往只是形式化地产出,与代码脱节,也很少被拿来分析缺陷。
- 第 3 级(标准化): 全组织范围内定义并执行一套共享的表示法、一份文档化的方法选择指南,以及若干一致性规则;模型根据目的来选用,与系统保持同步,接受缺陷评审,方法的选择也与每个问题的风险相匹配。
- 第 4 级(管理): 建模工作会依据基准来衡量和控制;团队会追踪分析在实现之前捕获了多少缺陷、模型与代码之间的偏离程度、每个模型是否真的被用来支撑过某个真实决策,以及相对于既定基准所节省的返工量和周期时间;方法的选择根据实测的不确定性和后果加以校准,关键核心部分会依据约定的覆盖率目标接受形式化验证。
- 第 5 级(协同): 建模和方法选择在整个组织范围内被持续改进,并与交付和风险规划相集成;投入会随着不确定性和后果的变化而调整,模型会依据证据被常态化地保持更新、退役或深化,形式化、启发式和原型法被组合运用,使每一种方法都恰好落在其能发挥价值的位置上。
讨论思路
- 在你们上一个项目中,哪些模型真正支撑了一个决策,哪些只是因为某个流程要求才被画出来的?
- 在你们的系统中,哪里的一份形式化规约能物有所值,哪里又会是浪费?
- 你们如何决定一个原型是一次性的还是演化型的,你们是否切实贯彻了这一决定?
- 你们如何防止模型与代码逐渐失去同步,还是接受某些模型理应被删除?
- 在你们的场景中,写代码之前合适的建模量是多少,这个量又会如何随不确定性而变化?
- 哪一种行为性模型(状态、时序,还是活动)本可以捕捉到你们最近一次的生产事故?
关键要点
- 模型是一种有目的的抽象;如果你说不出它支撑的是哪个决策,就不要画它。
- 让结构性模型和行为性模型匹配具体的问题,并保持它们的一致性与时效性。
- 分析模型;一个未经检验的模型只是一个未经测试的假设。
- 启发式和敏捷方法适合大多数系统;把形式化方法留给后果严重的核心部分。
- 用原型来消除不确定性,并预先决定它是一次性的还是演化型的。
- 按照一个模型所化解的不确定性、以及决策出错的代价,来决定建模投入的多少。
参考资料与延伸阅读
- IEEE Computer Society, SWEBOK Guide (Software Engineering Body of Knowledge), Version 4.0, Software Engineering Models and Methods knowledge area
- Martin Fowler, UML Distilled: A Brief Guide to the Standard Object Modeling Language
- Grady Booch, James Rumbaugh, Ivar Jacobson, The Unified Modeling Language User Guide
- Frederick P. Brooks, The Mythical Man-Month and No Silver Bullet: Essence and Accident in Software Engineering
- Daniel Jackson, Software Abstractions: Logic, Language, and Analysis (the Alloy modeling language)
- Leslie Lamport, Specifying Systems (TLA+)
- Simon Brown, Software Architecture for Developers (the C4 model)
- David Harel, Statecharts: A Visual Formalism for Complex Systems