3.3 用例
在前面几节之中,笔者对敏捷开发做了批评。除了敏捷开发,目前还有一种比较流行的做法,那就是通过编写用例来捕获需求。这一节要讨论用例与情境设计之间的区别。
用例,本质上是用一系列带有序号的步骤,来对场景进行简单的文字描述,就此而言,我们可以在各种详略不同的层面上撰写用例。此外,还有一些用例图可以展示每位参与者所使用的用例,以及用例之间的扩展关系,这里所说的参与者(actor),与情境设计之中的用户组有些相似。笔者稍后会讨论什么叫做“扩展”。用例图与笔者所说的情境图看起来很像,只是它没有绘制数据表。在本书的第6章里面,笔者要提出一种对任务进行文字描述的办法,这种描述看上去与用例所做的描述很像。用例与任务描述的主要区别有两点:第一,任务描述会专门指出该任务所要访问的数据;第二,笔者会从陈述(narrative)与规则(rule)这两个方面来描述某项任务需要完成的事情,而用例则只有陈述这一个方面。总之,用例与任务描述之间,必然有着相当多的共性,大家仔细想想,假如它们之间没有这么多的共性,那才是一件应该感到奇怪的事情。
笔者对用例有一些严重的担忧:
- 用例缺乏正规的原子概念。
- 用例的设计层次不够清晰。
- 用例比较模糊,甚至对于使用它们的人来说,也是如此。
- 以用例来描述的大型应用程序,是很难理解的。
- 用例不能为分析提供很好的支持,因而也无法很好地支撑工程化的设计。
接下来就分别讨论这些缺点。
3.3.1 原子性
用例可以用来描述任务,也就是说,可以用来描述某人在某时所做的某件事,但这并不意味着用例只能描述一项任务。我们可以把相关的两项任务合起来描述到同一个用例里面。例如,我们经常会碰到那种需要填写较长表单(如报税表)的应用程序,这种应用程序会提供一个选项,使得用户可以保存当前的更新并于稍后继续填表,填好表单之后,用户可以提交并等待后续的处理。如果以用例来描述,那么填写表单这一操作,就会视为一个用例,但是笔者却认为,这里面其实有两种任务:
1.开始填表,并且有可能填写完毕。
2.继续填表,并且有可能填写完毕。
这两项任务的绝大部分逻辑,自然是相同的,因此,笔者会把这些逻辑提取到一个共用的任务片段之中,这样就无需重复撰写这部分内容了。至于是否应该把二者合并到同一个用例里面,则是个很难说明白的问题,因为据笔者所知,用例方面的书籍并没有提到原子性这一概念,因而我们没办法说清楚这个问题。
那么,笔者为什么一定要把这两项任务分开看待,而不赞成将其写到同一个用例里面呢?这是因为我们必须对两者之间的缺口进行设计。即使面对这个非常简单的例子,我们也依然要确保以下两点:
- 用户必须能够看到当前还有哪些表单尚未填完。
- 有些表单可能永远都不会填完。因此,设计者必须决定是把这些没填完的表单一直放在那里,还是将其删除,如果要删除,那么应该隔多久再删除。
此外,我们还必须回答下面几个问题:
- 如果一张表单是由某人开始填写的,那么是不是必须由这个人来填完这张表单?如果是,那怎么保证这一点?如果不是,那又怎样使其他用户能够看到没有填完的表单?
- 如果应用程序出现故障,或用户在填表的过程中离开,那么,还没有完全填好的表单是否应该自动保存?
- 用户在开始填写新的表单之前,是不是必须把原来还没填完的那张表单填好?
如果决定把这两个任务合并到同一个用例里面,那么你就可以(注意,我只是说可以,不是说必须)把刚才提出的那些问题暂时掩盖起来,但是那些问题最终还是会出现,而且有可能是在已经编写了一些实现代码之后,才突然冒出来的。
3.3.2 设计层次不明确
我们再举一个例子,这个例子会引发更加复杂的问题。假设要撰写这样的用例:它的参与者是送货司机,用例本身用来描述拣选包裹、投递包裹并且使客户签收包裹这一过程。那么,这其中还是会有两项任务,一项是把包裹分配给送货司机,另一项是客户签收该包裹。用例的撰写者可能会把这两项任务写到同一个用例里面。现在假设送货的车辆出现了故障。此时,该包裹应该会指派给另外一位送货司机去派送,更倒霉的情况是,该包裹会取消派送。然而问题在于,车辆出现故障这一状况,应该由哪个任务来记录呢?可能应该有一个专门用来记录车辆停运的任务。而且还应该再安排一项任务,用来获取包裹,并决定如何处置该包裹。这样就越来越复杂了。而且还有可能会有一项业务流程用来显示包裹的派送情况,另外一套流程用来对这些派送车辆进行管理。当某辆车发生故障时,这两项流程就要进行交互。理论上我们可以把这些事项全都写到一个用例里面,但笔者不建议这样做,因为这会令用例变得特别复杂(然而有人告诉我,一个用例通常要用好几页纸才能写完,所以我怀疑实际工作中可能真的有人把这些全都写到一个用例里面)。由于本例还涉及两条业务流程,因此,我们可能需要用两个用例来记录这个场景,每个用例都对应于其中的一条流程,这两个用例之间可能会以微妙的方式沟通。要想在冗长而复杂的用例之中对沟通情况进行分析,是相当困难的。实际上,为了进行分析,我们首先必须把用例拆分成多个单元,使得每个单元都用来表示某人于某时所做的某件事。换句话说,要想理解复杂的场景,我们首先还是得确定其中的各项任务。
通过这个例子,大家可以看出,用例的层次感是比较模糊的,我们不清楚它究竟是在流程层面进行描述,还是在任务层面进行描述。在刚才那两个例子里面,我们都必须对流程进行一些描述,才能将两项或多项任务联系起来。
实际上,用例不仅会把流程层面与任务层面混为一谈,而且还会令任务层面与逻辑用户界面的设计之间,无法形成清晰的界限。
公允地说,某些用例专家明确表示,他们并不会在用例之中指定用户界面。笔者对这种说法的理解是,他们不仅不会在用例中指定实际的屏幕布局,而且也不会在其中包含逻辑用户界面。我强烈怀疑有没有人会遵守这条规则,因为业界总是鼓励设计者以场景的角度来思考问题,于是他们自然就会想象有一位用户正坐在计算机前面。而且,就连这些专家自己所举的例子,通常也和他们提倡的原则有所出入。例如,笔者曾经好几次看到有人把登录也包含在用例的各个步骤之中。如果改用情境驱动开发,那么登录根本就不应该包含在用户界面的设计里面,也更不会包含在情境设计之中,它只是技术设计里面的一个部分。
之所以要在任务设计与用户界面设计之间画出清晰的界限,是因为我们在设计的过程中,总是应该由此来厘清自己所要达成的目标与达成该目标所用的方法。如果忽视“自己所要达成的目标”而单单去谈论方法,那就容易错过某些重点,反之,若忽视“达成该目标所用的方法”而单单去谈论目标,则容易错过一些更好的设计方案。我们尤其不应该跳过对用户界面所做的设计,而直接根据用例去编写程序,因为这样做会错过很多的机会,使我们无法发现更好的设计:
把用户界面设计视为一个整体,可以帮助应用程序变得更加易于使用。用户界面设计可以对任务设计做出重大的转变,本书第8章将会讨论这个问题。
对用户界面设计进行审阅,就相当于同时对情境设计进行检查。
用户界面设计可以提升程序员的编程速度。如果没有用户界面设计,那么程序员就需要在缺乏相关信息的情况下,花很多时间在脑中构想用户界面,而且在发现自己做错了之后,又要花很多时间去重做,有了用户界面设计之后,这些时间就可以缩短,而且项目经理也更容易找到合适的程序员来解决那些更为困难的编程问题。
用户界面设计之中还包含一种信息,那就是详细的数据字段,它们没有出现在情境设计之中,因为情境设计更加简洁。假如以用例来表达这些字段,那就必须将其写入用例文档,这会令文档变得冗长而啰嗦。
我们一定要清楚自己当前正在哪个层面工作,而且一定要防止这些层面彼此混同。每一层都有它自身的完整度约束条件,并且都有一套各自的恢复技术。在数据库事务层面,其恢复工作是由数据库系统来处理的,而在任务层面,我们则会以各种手段进行恢复,使其看起来与数据库事务一样,具备原子性。
3.3.3 用例本身比较模糊
对用例的解释,已经成了一项专门的学问。很多书和很多专家都在谈这个问题,而且用例也有很多种不同的风格。笔者怀疑,在业界刚开始撰写用例的那段时间里,专家们或许就已经发现,这些用例写得相当糟糕,于是,他们觉得应该制定一些规则与指针。其实用例本质上是很简单的,它就是一份包含各种动作的有序列表而已,然而如此简单的用例却需要用这么多的专业技巧才能写好,这是相当奇怪的。
有一些情况令人感到困惑。最为突出的一种情形,是把用例运用在战略层面,这种用例称为战略用例。某网站有一条评论[16]说道:“我都写这么多年用例,但还是不太能理解系统范围(System Scope)与战略范围(Strategic Scope)之间有什么微妙的区别!”笔者自己也很难想象,如何才能把战略用一系列步骤定义出来。用例的表达形式,重在描述“怎样做”(how),而战略思想则重在描述“做什么”(what)。因此,笔者并不推荐以用例来确定战略方面的内容。
用例的另外一项特性也容易令人误解,那就是它的“扩展”或“包含”能力。这两种能力都用来捕获共用的逻辑,使得这些逻辑只需要写一次就好。“包含”(include)指的是某用例纳入了另外一个用例之中的某个片段,如果两个或多个用例有某些步骤是相同的,那么这样做就比较合理。“扩展”(extend)说的是某用例与另外一个用例相似,然而在某种程度上还有着微妙的区别。“包含”是一种局限性较大的用法,因为你必须把那个片段全部包括进来,而扩展则允许我们在很多地方进行修改。另外,要对用例进行扩展,就必须针对整个用例,而不能仅仅针对其中的片段。实际工作中,我们必须谨慎地考虑自己到底是应该“包含”,还是应该“扩展”。我们经常会急着扩展某个用例,而没有考虑这样做所带来的各种后果。
(很多参考资料里面所举的例子,也不能够较好地消除这种困惑。例如,笔者于2015年1月在维基百科上面搜索“use case”词条,然后看到一张图(当时是如此,现在可能有所变化),它说“吃食物”(eat food)与“喝酒”(drink wine)这两个用例之间是扩展的关系,笔者觉得这样举例并没有太大帮助,而且还显得有些奇怪。)
用例之所以令人困惑,其主要原因还在于3.3.2节所说的那个因素,也就是它会把各种层面的设计混同起来。
笔者现在要说一句会惹很多人讨厌的话:我觉得用例有一个根本问题,那就是缺乏一套良好的理论。这意味着我们很难精确地定义出自己必须要做或者绝对不能做的事情,而且它也缺乏一套使我们能够判断出正误的概念。
3.3.4 大型的用例文档难以理解
以用例来描述大型的应用程序,会产生出相当枯燥的文档。撰写用例的人,在制定并记录这些用例(如有100~1000个用例)的过程中,自然会对应用程序有一个很好的理解,然而需求文档还应该有一项功能,那就是要把即将制作的这款应用程序解释给利益相关者听,使其能够验证这套设计方案是否正确,并给我们提供良好的反馈。但是,庞大而枯燥的文档,并不能够较好地实现这项功能。
3.3.5 用例对工程化的设计起不到帮助作用
之所以说用例无法给工程化的设计提供支持,其根本原因就在于它们很难加以分析。
我们先停下来想想,一份易于分析的设计方案,应该具备哪些特征:
- 它必须能够划分成一些不太复杂的块。这不仅可以把设计方案分解到多个功能领域之中,而且还有助于我们据此构建设计体系。用例,是不具备这个特征的,所有的用例,都位于同一层面。
- 它必须把所有的依赖关系都记录下来。把设计方案划分成组件的时候,我们必须完全确保组件之间的所有关系都得到了记录。任务之间的很多依赖关系,都位于他们所共享的数据之中,而用例并没有把这一点明确记录下来。我们必须阅读相关的文字,才能将其找出来。
- 它一定要有原子性这一概念。我们可以把组件说成黑盒,因为我们只知道每个组件所做的事情,而不知道它是如何完成这件事的。对于任何一种设计来说,我们都必须假设其中的组件所输出的结果,其数量是固定且可以预知的。我们只能对自己所见的内容进行分析。如果组件有着某种隐藏的输出结果,那么任何一种分析都没有办法揭示它。
- 它必须能够在解决方案与需求之间轻松地建立关联,换句话说,也就是能够回答“需求X是怎样实现的?”这一问题。在设计方案的最顶层,我们必须要能够把它与基本的业务需求联系起来。如果为业务需求提供支撑的用例有100~1000个,那么笔者觉得这一点是很难保证的,即便我们假设基本的业务需求确实写在了其中的某个地方,也依然不容易做到这一点。
- 它应该要有一套易于分析的规则,而不是一些步骤或松散的描述。我们可以利用数学技巧来分析规则。就算不做数学分析,通常也能够看出相互之间明显矛盾的那些规则。然而步骤分析起来就没有规则那么容易了,因为我们总是要面对一个问题,那就是:“这些步骤之间的顺序到底是至关重要的,还是随意安排的?”
简单地说,用例是用来帮助我们收集需求的,而不是用来帮助我们分析需求的。
3.3.6 小结
总之,用例会使业务设计变得困难。
在写这本书的时候,有些人告诉我说他们很享受撰写用例的过程,并且认为用例挺好的。笔者对此的看法是:这些撰写用例的人之所以认为用例不错,是因为他们为了撰写用例,必须要思考应用程序所处的场景,而这种思考,无疑会帮助他们更好地理解应用程序所应完成的事情。然而对于那些必须把用例从头到尾读一遍的人来说,这种用例恐怕就不会讨人喜欢了吧。
此外,用例还有一个相当微妙的问题,那就是:它并不能为项目的估算成本工作打下坚实的基础。这个问题3.4节再谈。