IBM文章:如何编写出好的用户需求文档
许多软件开发团队没有需求工程师;开发人员捕获、编写和管理所有的需求。这在资源效率方面是有意义的:开发人员可以在正式编码之前,在系统停机时间收集和编写需求。然而这一做法的缺点是,通常程序员没有在编写需求方面受过技术和工具的培训,结果他们总是费力和低效地工作,而且有时做出的需求规约不符合规范。
为了写出好的代码,开发人员必须知道很多事情:诸如控制结构和调用约定之类的基本概念;至少一门程序设计语言,包括它的语法和结构;操作系统基础;以及如何使用诸如编译器、调试器、集成环境这类的技术。好在他们能以所有这些知识为跳板写出好的需求来。通过应用许多与他们编写代码时相同的原则和概念,开发人员可以有效地担当起需求工程师的职责。
让我们看一些开发人员可以利用的编程概念。
遵循结构
所有的程序设计语言都有一种结构。这种结构指出程序的不同部分如何定义,彼此之间形成何种关系。Java程序由类来形成结构,COBOL程序有不同的“区段”,C程序有一个主程序以及多个子程序。
程序有一个特定的结构,需求也是如此。设想你把一个C程序的所有代码都塞到主程序里——它会变得不可读而且无法维护。与此相似,如果你的需求规约只是一张毫无规则的大列表,你也没办法使用它。不管你意识到没有,一组需求总有一个结构。获得需求中结构的最佳方法是把它们按不同类型来组织,而这些类型常常对应着不同的级别。
为理解不同类型之间的差别,我们来看看用于保险索赔处理的四条需求样例:
我们必须有能力处理积压下来的索赔单据。
系统必须能自动检查索赔表单以获得适用条款。
对索赔者,系统要根据其社会保险号码确定其是否是注册用户。
系统要支持处理多至100个并发的索赔请求。
你的直觉或许会告诉你其中每条需求都有一些不同的东西。第一条需求是级别很高的;它在表达业务需要的时候甚至没有提到系统本身。第二条需求表达了系统应该做什么,但仍在较高的级别上;它仍然太宽泛,不能直接翻译成代码。第三条是低级别的需求;它确实为软件必须完成的功能提供了足够的细节,使你能写成代码。第四条需求虽然非常详细,但并没有告诉你系统必须做什么;它只是规定了系统必须有多快。当你跟用户和其他涉众打交道时,这些需求是非常典型的。也许你已经看到为什么把它们放在一个大而无组织的表上会导致混乱。
为使需求更为可用,你可以把它们按范畴或类型分开,比如:
业务需要
特性
功能性软件需求
非功能性软件需求
这是IBM Rational Unified Process(RUP)中建议的类型。它们绝非唯一可能的类型,但它们表达了一种有用的方法。在你的项目的早期,就要决定用什么类型。这样,在你从涉众那里收集信息时,要确定他们描述的是何种需求类型,然后写成需求。
注意你可以用两种格式之一指定功能性软件需求:声明形式和用例形式。上述第三条需求是声明形式的;它是粗线条的,用了一个“要……”的句式。另一个形式是用例的,它也指定了系统应该做什么,级别足够低,能直接写成代码,不过它提供了更多的上下文,告诉用户和系统应该如何交互以执行一些有价值的东西。(关于用例的更多细节见下文。)在你着手收集项目需求之前,你应该确定哪一类功能型需求是你要用的,以后就不要改变。
用习惯保证质量
你知道写出好的代码和坏的代码都是可能的。有很多途径导致坏的代码,其中之一就是使用非常抽象的函数名和变量名,比如routineX48,PerformDataFunction,DoIt,HandleStuff,do_args_method。这样取名没有给这些方法和过程的功能提供任何有用的信息,迫使读者钻进代码里去搞清楚。另一个糟糕的做法是用单字母的变量名如i,j,k。你用一个简单的文本编辑器无法搜索到这些变量,其功能也不清晰。
当然,你也有很多途径写出坏的需求。可能最糟的错误是二义性。如果一条需求让两个人按两种方式解释,这条需求就有二义性。比如,这是一个从实际需求规约里抽出来的需求:
应用程序在多个并发用户访问时必须极其稳定,且不能牺牲速度。
多个和极其这样的词有多种解释,所以这一需求是有二义性的。事实上,为了获得清晰性,你必须把它表达成这样三条非常具体的需求:
系统故障的平均间隔时间不能大于每星期一次。
系统应支持1000个并发用户同时查询数据库,而不会发生拥塞或数据丢失。
系统的平均响应时间在多至1000个并发用户时应小于一秒。
质量需求还有更多的属性,详见IEEE的指南。 1
详细编写注释
风格良好的程序包含注释,这些注释为代码提供了额外的信息,解释这段代码在做什么或者它为什么用这种方法写。好的注释不解释代码怎样做某件事情——这一点代码本身显然已经说清楚了——而只提供必要的信息帮助用户、维护人员和复审人员理解代码做了什么,以此保证质量。类似地,需求也有属性——这是使需求更为可读和可用的信息。当你捕获需求时你也应该寻求属性信息。例如,一个重要的属性是来源:这条需求是从哪里来的?记下你的信息的来路将在你需要回溯以获得更多信息时节约大量时间。另一个属性是用户优先级。如果一个用户给你五十条需求,他也应该让你知道其中每一条跟其他相关的比起来重要度如何。这样在项目生存周期的后期,时间越来越紧迫,你意识到已经不可能满足所有的需求时,至少你还知道哪些是最重要的。
正如没有哪条规则告诉你代码里的注释一定要怎么写才正确,也不存在“正确”属性的一个普适的列表。来源和优先级几乎总是有用的,但你必须定义适合你的项目的其他属性。当你搜集需求时,试着预计一下当你着手设计系统和编码时整个团队可能需要什么信息。
熟悉语言
显然,开发人员必须熟悉他们用来编码的语言,不管是Java,COBOL,C++,C,Visual Basic,Fortran还是其他什么语言。为了写出好的代码,你必须了解语言之间的细微差别。虽然所有语言里基本的程序设计概念都是一样的,但在具体某个操作时它们会使用不同的方式。比如,Java的循环结构用“for”;Fortran则用“DO”。C语言里你以子程序名带上参数来调用一个子程序;Fortran里你用一个CALL语句。
为了写好需求你也得熟悉语言。多数需求是用自然语言写成的(法语、英语等等)。自然语言非常强大,但也非常复杂;未受过写作训练的开发人员在写作中表达复杂想法的时候有时会碰上困难。我们这里没有足够的篇幅留给一个完整的写作课程,但有些指导原则是有用的。
首先,对声明形式的需求使用完整的句子。(例如,以“应”或者类似的结构表达的语句。)在每个句子里检查主语和动词。
第二,使用简单句。一个语句只包含一个独立子句,只传达一个想法时,更易于理解、检验和测试。如果你的需求太复杂,难以用简单句表述,试着把它分解成几条更小、更易于定义的需求。并列句和复合句会引入依赖关系(分支);换言之,它们可能描述那些依赖某些动作的变量,结果常常产生一条不必要的复杂需求,增加测试的困难。
简单句:系统应能显示车沿跑道绕行一圈花费的时间。
并列句:系统应能显示车沿跑道绕行一圈花费的时间,且时间的格式应是hh:mm:ss。(这是两条需求,一条是功能性需求,指定系统要做什么,另一条是用户界面需求,指定时间格式。)
复合句:系统应能在车沿跑道绕行一圈后5秒之内显示这一圈花费的时间。(这也是两条需求,一条功能性需求和一条性能需求。)
为了给并列句和复合句写出合适的测试,你只能把其中的两条需求分开。既然如此为什么不干脆从一开始就这样做呢?以下是把上述复合句拆分成简单句的一种方案:
系统应能显示车沿跑道绕行一圈花费的时间。
绕行时间的显示格式应为hh:mm:ss。
绕行时间应在一圈结束后5秒内显示出来。
注意,为使需求更易测试,它们写得更易阅读。
还有一个写好需求的技巧:使用一致的文档格式。你已经有了一个编码的格式或模板。编写需求的时候也可以利用它。一致性是关键;每个规约文档应该用相同的标题、字体、缩进等等。模板有助于做到这点。实际上,它们起到表单的作用;编写需求的开发人员编写好的规约就无需从草稿开始,做重新发明车轮的工作。如果你需要模板的样例,RUP上面有很多。
需从草稿开始,做重新发明车轮的工作。如果你需要模板的样例,RUP上面有很多。
遵循指南
许多开发团队使用类似这样的编码指南:
将模块的定义和实现放在不同的文件里(C++)。
在一个代码块的范围内作缩进(Java)。
将频繁调用的数据元素放在每组工作存储区变量的开头(COBOL)。
在编写需求时你也应该遵循某种指南。例如,你如果决定用用例来规约软件需求,你的指南就应该告诉你如何写出事件流程。用例事件流程解释了系统和用户(参与者)如何通过交互完成工作。你的指南应当描述在主流程里发生什么(成功场景),在备用流程里发生什么(意外场景),也应该描述如何组织这些流程的结构。你的指南也应该提示两个流程的长度以及其中的独立步骤。如果你决定使用传统的声明式需求,则指南应当解释如何编写需求。好在许多这样的指南已经在RUP和其他相关的资源里有了, 所以你不必自己撰写。 2
理解操作环境
为开发好的代码,你必须熟悉那台运行你的系统的机器,也必须熟悉怎样使用其操作系统。如果是Windows,你必须熟悉MFC和.Net。如果是Linux,你必须熟悉UNIX系统调用。
为写出好的需求,你要理解的是操作人员而不是操作系统。你也必须理解用户而不是用户界面。Java开发人员考虑类路径;需求编写人员考虑通向类(或工作组)的正确途径。
需求捕获是一项以人为中心的工作。你不能虚构需求,只能从其他人那里获得需求。这对内向的开发人员来说或许是个挑战,但如果能正确地应用已有的技能,他们是能成功的。
用户常常不知道他们要的是什么,或者知道是什么却不知道怎么描述它。开发人员却拥有这样的能力来改善这一点:他们常常不得不破解编译器给出的费解和难懂的错误信息。比如,Java开发人员在写一个小应用程序时可能碰上这样的信息:
load: com.mindprod.mypackage.MyApplet.class can't be instantiated.
java.lang.InstantiationException: com/mindprod/mypackage/MyApplet
这是什么意思呢?如果开发人员不确定,他会去检查代码,查询编译器的文档,甚至利用Google这样的搜索引擎,来弄清问题出在哪儿。最后他将发现,他写的小应用程序代码缺少缺省的构造函数。
如果你正在为一个天气预报系统收集需求,涉众之一告诉你系统应能“用标准的带小尾巴的箭头显示200平方英里的一块区域之上大气层不同高度的风速和风向”,你就需要深度挖掘一下。你可以要一个类似的系统给出的报告,求助于气象学的书籍,也可以请另一名涉众把要求描述得更精确一些。你应当继续研究,直到掌握足够多的细节来描述期望的功能。这样你就可以重述需求,使之清晰和无二义性,提供足够的细节以支持设计。
另一个捕获需求的技巧是避免问诱导性的问题。虽然你对用户的需要可能已经有了想法,但如果你把这些和盘托出,你恐怕无法获知整体上他们到底需要什么。反之,问一些开放性的问题如“你要让分开的数据怎样显示?”而不要问“你要不要把气压和温度合起来显示在一张图表里?”
遵循既定原则
设计和编写优秀程序的基本原则中,有信息隐藏、耦合和内聚。在编写需求中也有与之相应的原则。
信息隐藏
这个原则是说一段代码的使用者/调用者不能访问甚至不能获知数据的内部细节。所有对数据的访问与修改必须通过函数调用来完成。这样,你在改变内部数据结构时不会影响到调用它的外部程序。
对于需求这也是一个好的原则,特别是在用例表达的情形。如我们所说过的,用例具有事件流程。写得不好的用例常有塞满了数据定义的事件流程。考虑这个管理购买请求用例的基本事件流程:
基本事件流程:
系统显示所有未决的购买请求。
每个未决请求包含该请求的如下信息(限制为char型):
授权ID (仅内部使用)
PO #
引用ID
发布者帐户缩写名
经销商帐户名(前10个)
经销商帐号
购买原因码
请求购买量
请求日期
分配给内部
注释指针
经授权的管理员可以做以下几件事之一:1)批准 2)拒绝 3)取消 或 4)分配请求。他选择1)批准。
……如此等等直到所有步骤完成。
以上十五行里,十一行用来说明哪些数据与一个未决请求在一起处理。这些信息很重要,但却使用例中发生的事情变得不清晰。更好的解决方案是将数据隐至别处。这些步骤就变成这样:
基本事件流程:
系统显示所有未决的请求。
经授权的管理员可以做以下几件事之一:1)批准 2)拒绝 3)取消 或 4)分配请求。他选择1)批准。
……如此等等直到所有步骤完成。
未决的购买请求用了斜体,指出数据在别处定义(通常在用例的特殊需求段或在词汇表中定义)。这使表达真实功能性需求的事件流程易于阅读和理解。
耦合与内聚
对编码人员,耦合原则是指程序中的单独模块应当尽可能地互不相关。 一个模块内部的处理应与其他模块的内部机制无关。内聚原则是指一个模块内部的所有代码应该只完成一个目标。这些原则使程序易于理解和维护。
这些原则对需求同样适用,尤其是用例。用例应当独立(即极少或没有耦合)。 3 每个用例应指定一个有意义的功能块,说明系统如何为参与者提供有价值的东西。参与者焦点很重要,你可以指定系统为参与者做什么,而不必担心用例按序排列的问题。
一个用例中所有的功能性成分应当只用于完成参与者的一个目标(高度内聚)。在一个典型的自动取款机(ATM)系统中,一个用例是“取款”,另一个是“转帐”。每个用例集中于单一的目标。如果你把这些功能合并到一个用例里,就成为低内聚的(和不合适的)依赖关系。
然而要小心,许多用例的初学者走过了头,建立了太多的低层用例。我看到过一个用于银行债务收集系统的模型,它拥有150个用例,用例的起名诸如“修改数据”。这个项目有一个十人团队,计划持续了差不多一年。然而,由于这些用例太琐碎,整个团队在推进的时候碰上了无数的麻烦。他们大量描述了对用户毫无价值的底层功能,这些功能既难以理解也难以使用。每个用例极为内聚,但也因此造成用例间的高度耦合。把层次提升到更明确的活动如“收集债务信息”,就能在耦合和内聚之间保持合适的平衡。