上下文切分
需求澄清优先
当拿到需求时,首先要做的就是需求澄清,因为在现实开发中很难遇到“正确的”需求,有任何存在疑惑,假设或者不清楚的地方都得第一时间找需求提出方沟通。
Guess Number 需求例如 Guess Number中的表格数据列Instrucment含义是什么?
- 其实这仅仅是一个附加说明,和需求中功能的输入输出没有直接关系
例如 游戏可以猜测6次,这里面用户会遇到几种情况?如何处理?
- 站在用户使用的视角,至少就得5种情况需要覆盖:比如猜第一次就对了;想想还有哪几种?文章的中后段会提到,看看和你想的是否一致!
切分小型界限上下文
需求澄清后,我们需要对整个业务域进行上下文切分,每个上下文代表着一个有较清晰边界的方案域。当不同方案域被切分出来后,这样边界之间的接口才能被放在明面上来被设计。
上下文分析
如上图,我们先从内部跳出来,站在最外层看全局,首先看到的是一个大黑盒,先假设它已经是被开发好的程序,那我们需要做的第一步是先分析它的输入输出,这也是对需求进行再一次澄清的机会;接着打开这个黑盒,分析出这个黑盒是由哪些上下文组成的,分而治之。
「回忆杀」记得以前经常有同学会说TDD是有问题的,因为没有预先设计,写代码的时候很难驱动出设计来!
- 这其实得看你要开发的业务有多复杂,如果是类似FizzBuzz这样的需求,对于有经验的程序员还真不需要预先设计(基本脑子里面逐步就想清楚了)。但如果是较复杂的业务需求,例如本文的练习,就需要做一下预先设计,先切分上下文,再将切分后上下文的输入输出分析清楚,如此逐步细化。
「回忆杀」记得以前也有同学问:那这样的预先设计是不是太重了?花了这么多时间来设计,练代码都还没写一句...
- 所以预先设计需要恰当,有一句话说得特别在理:没有被写成代码的设计都是对实现的假设。我的一般做法是切分出上下文以后,就先做核心上下文的识别,再划分优先级,然后针对最重要的一个方案域先做任务分解和需求的实例化。
下图是针对一个上下文的一个Interface做输出输入的设计和测试用例的设计:
一个上下文细化的思路
最后按照TDD流程进行开发:(每一个测试通过,进行一次git commit)
TDD 流程
上下文切分的例子sGuess Number 的上下文可以这样切分:
-
输入输出上下文:一般来说,所有带用户接口的程序,与外界相连的输入输出一般都是字符串,这个上下文的作用是把字符串映射成程序认识的带有业务含义的模型,后续的业务实现都是基于这个对内相对稳定的模型来做。
-
猜测结果判定:这个是最好识别的,因为游戏的核心计算规则由它实现,但是也是最容易变得职责不单一的,比如会混入随机数生成,游戏返回给用户的字符串的返回等职责(例子: Wrong input, Input again在这个上下文中出现)
-
游戏流程:当看到需求中存在6次猜测机会时,应该能够嗅到,猜测结果判定对于游戏流程来说是一个黑盒,本上下文主要能知道每次猜测的结果是什么就行了
-
随机生成答案:这是一个类似Adapter功能的上下文,用来隔离外部依赖;(所以得用测试来验证自己业务边界,即基于外部随机算法的基础上,测试随机结果生成的数是否符合规则)
如何选择第一个任务
选择的标准包括:
· 任务的依赖性 - 依赖其它上下文越少,优先级越高?
· 任务的重要性 - 越是属于核心业务,优先级越高
从依赖的角度看,并不一定需要优先选择不被依赖的上下文,因为我们可以使用Mock的方式驱动出当前任务需要依赖的接口,而不用考虑实现。例如,<随机生成答案>上下文与<猜测结果判定>上下文之间存在前后序的依赖关系,但实现的顺序却并不需要按照此顺序。
对于任务的重要性,主要是判断任务是否整个系统(模块)的核心功能。一个判断标准是确定上下文是功能的主要流程还是异常流程。例如上下文“检查输入”即为异常流程,可以考虑后做。
那在GuessNumber优先级选择中,我在本文中选择了<猜测结果判定>这个上下文为第一个开发任务。因为它对于游戏流程是黑盒,而答案随机生成也只是结果会给到它,并不存在强偶合。最重要的是<猜测结果判定>是这个需求中最重要的业务支撑。
还有一个思路是从<游戏流程>开始,通过Mock <猜测结果判定>,在写测试的过程中还能驱动出<猜测结果判定>被调用的interface设计。
TDD 小步增量
- 从上下文 <猜测结果判定>开始:
在开始之前,我先抛一个思考,不知道大家是否遇到类似的情况:假设我从<游戏流程>开始TDD,会遇到什么?
Game TDD发现步子比较大,第一个测试要通过涉及的概念也比较多,不仅要关注游戏的流程,还要关注承载<猜测结果判定>实现的Answer的接口,为了建立信心 可以从Answer开始开发。
说到了Game这儿,我也顺便回答一下文章开头给大家抛的问题,对于用户来说游戏状态至少会有这几种:猜第一次错了,第一次对了,第一次错第二次对,6次全错,最后一次对了。
-
先对Answer的测试用例进行设计
正交分解
上图用正交分解法对一般等价类进行了分析,得到了6种测试用例的结果。
-
实现前2个测试用例,如果要做到快速实现,小步前进,对于测试用例的选择最好符合0-1-n原则,实现顺序大致是:0A0B -> 4A0B -> 1A1B -> 0A4B -> 0A1B -> 2A2B
-
看看此时的实现代码:
image.png
别着急发表意见,往后看!
此时,我发现在写出第三个测试用例后,很难去实现1A1B的检查,那就意味着我需要进行重构了,此时重构是为了应对将要实现的几个用例,重构后:
image.png
-
测试用例3的实现,也是为了快速通过,不过这一步的步子稍微偏大
image.png -
重构一下 (在这if...else...中看到了共性:即基于A和B的结果format)
image.png -
此时,对于Answer来说,整个代码的主干框架已经基本成型,后续的用例实现基本不会出现太过陡峭的曲线了。
TDD的应用,本质上是对反馈收集的极致追求,这也是为什么要遵循一个测试一个实现这样的套路,因为每一个实现都应该是一个功能增量,用的是快速迭代的思想。与之形成鲜明对比的就是瀑布思想,总想着一次要把所有的功能代码写完,这么做也不是什么错,关键是反馈全在人的脑子里面,很难回归。就算设计者脑子中回顾,也很难在阅读者和修改者脑子中回归。所以,用TDD把脑子里面的思考都分解成一个测试+一个实现这样的过程吧,即能强制你去想清楚需求的输入输出,又能强制让程序能够被回归测试,多好!
如何驱动出对象(会持续更新,因为时间关系先写一点)
对象是行为与数据的组合,在OO的世界里,我们尽量避免:
- 设计了一堆只有getter和setter的数据类,再基于此实现了一堆专用与调用和操作这些数据类的xxxService, xxxManager类
- 把其中一个主要操作作为类名,比如NumberGuessor, Run之类的, 但是其内部做的事儿不仅仅如此,甚至还包含了与类名没法定义关系的属性。
每个上下文在实现后,都可能由1到多个类组成,但往往不太需要再次被切分的上下文都可以先为其定义一个类,开始对其进行测试用例设计。而类的名字应该是一个业务概念,其背后代表与其名字相符合的业务处理。如Answer: 代表了需要被猜测的数字,它也具备判用户输入数字与此被猜测数字结果的行为。
还有一些对象是在开发过程中被抽象出来的,比如有同学在一开始并没有抽象处Answer这个类,第一个测试是写给<游戏流程> 的Game类,那在写完第一个测试后会发现缺少了一个概念,Game需要去guess的是个啥? 由此发现少一个Answer。 那是不是一定就得先实现Answer再回来继续呢,这到不一定。毕竟还有一个目前还没谈到的内容Mock,后续我会补充上这块的内容,大家可以持续关注一下!
对于输入输出的思考(会持续更新,因为时间关系先写一点)
作为一个有用户接口的软件,架构的最简化模型或原则是: 输入输出分离;可以通过模型映射,在对外接口处,设立一个防腐层来隔离外界对内部概念的侵蚀。或者说把外部需要翻译成内部可以被理解的数据模型。
抛一个问题:输入输出层,如何做到可测?
共性的代码问题觉察:(会持续更新,因为时间关系先写一个)
<游戏流程>的Game类与Answer类的职责分配不合理,将Answer类设计为仅具有get()和set()的数据对象,而将判断数值是否正确、位置是否正确的逻辑分配给了Game。没有考虑get()和set()是否真正有必要;如果我们对guess()方法进行了方法提取,可以识别出代码的坏味道“Feature Envy”,即Game的方法用到的都是Answer的属性。这时,应该采用移动方法的重构手法对其进行重构。
有余力继续的需求
- 游戏的用户操作通过命令行实现,要求尽量可测试(单元测试)
- 实现回滚功能,每回合游戏有1次重新猜测的机会
- 显示所有历史猜测:每次用户猜测完后,都会在命令行显示所有历史猜测记录
网友评论