转载于:http://www.csdn.net/article/2012-09-27/2810441-Programming-Like-Kent-Beck
我、Stig、Krzysztof、Jakub和Kent Beck在2012年的Iterate Code Camp花了一个星期时间一起做了个项目,进行最佳实践性的编程学习。我们想和大家分享一下我们在这个过程中学习到的宝贵经验及教训,以使我们能成为更好的程序员(或者至少我们是这样认为的)。
编程风格背后的价值观
在整个实践过程中,我们渐渐学会了遵守编程最基本的三个价值观:沟通、简单和灵活(按照重要性进行排序),这三个价值观应该被所有开发人员所铭记。在下面我们将会对它们进行简单介绍,另外你也可以在Kent的《实现模式》(Implementation Patterns)一书中找到更详细的说明以及一些基本的实践。
沟通
程序的可读性往往高于程序如何去写,任何一个程序员都能写出机器读得懂的代码,而一个优秀的程序员应该写出别人可以理解的代码。代码作为传达设计理念的工具,在编写的时候应该清楚地表达出设计思想。(就拿典型的企业系统来说,在5——15年内代码都会被大量修改,而每次去执行修改的人肯定不会是同一个人,因此代码要更加通俗易懂,这样修改起来才轻松。
简单
消除不必要的复杂性,各级应用都应该以简单为原则。简单可以使程序更容易理解和更利于沟通;通过沟通更容易实现简单。
灵活
现在做出一个可行的决定,并保留可以将来修改它的灵活度,这是好的软件开发的关键。——Kent Beck的《Implementation Patterns》
程序应该灵活地修改,使一些常见的修改变得简单,至少要比现在简单。复杂性通常来自于过度的灵活性,但是如果没有灵活性,那么它就如同垃圾。最好的一种灵活性就是来自简单广泛的测试。试图通过设计phases之类行为来增加灵活度,最终得到的通常是“复杂的灵活”。大多数时候,对于程序哪些地方需要被修改,在没有足够信息的前提下很难做出正确的决定。所以适当地推迟决定,直到最后判断正确后再做出一个有效和有用的决策。
概括
编写简单、通俗易懂的代码,会让你的工作伙伴或者未来的系统维护人员花很少的精力就可以轻易代码。(当然,这也需要一定的技能等级和专业知识。)你无法预见需求变化,所以保持代码简单、灵活,使其演变可以跟上需求的变化和时间的推移。
学习要点1 你并不需要它
今天将演示什么?测试开始时做什么?
在开始之前,我们设立了一个非常有趣和富有挑战的主题。我们决定尽最大的努力尝试一个高伸缩性的、分布式数据库。我们花了几个小时讨论如何实现,毕竟这里有许多事情需要考虑:同步策略、哈希一致、集群成员自动发现、冲突解决方案等等。如果只有5天时间,你需要计划如何用最简单的方式实现。需要做什么?如何跳过?对吗?错,Kent只是问我们在最后一天将演示什么和如何测试。
这最终会被证明是个非常聪明的做法,因为我们实际上可实现的功能只有一个小小的子集,对迄今遇到的问题进行总结,然后再进一步做出更加理智的决策。我们发现,任何超过10分钟以上的讨论90%的时间都被浪费啦!但这并不意味那个计划就是失败的(虽然产生的计划一般都是无用的),它只是意味着我们要做的会比实际需要多。
因此,我们宁愿选择不断接受反馈和经验来完善前期计划。问问自己:在下次我将要展示什么?写什么样的反馈能够反映自己,指导我今后的开发工作。
学习要点2 编写高级测试来指导开发
我们第二天的目标是通过编写一个实例进行同步和演示,忘掉第一个实例然后直接从第二个实例中解读(复制)数据。我们仅仅跟随着这些步骤编写相应的测试。
(当然,这个API之后会变得普通)
现在有意思的是,这并不是个单元测试,它是个基本的集成测试,少用点技术术语就是一个故事驱动的高层次功能测试,是客户比较喜欢的功能。如果是一个单元测试,那么它会告诉你,这个类和预期打算的一样,而如果是一个故事测试,那么结果会是:“此功能和预期的一样”。
我一直认为TDD只适合单元/类级别的测试,这样的观点被证明是错误的,TDD还适合更高级别的测试。它包含了一些非常有意思的属性:
- 衡量项目进度,给“客户”的练习都是非常有意义的,因此每次得到的反馈信息也更真实可靠。
- 这可以帮助你专注于提供业务功能
- 它很有可能保持不变并且比单元测试或大多数代码存在的更长久,因为它是一个概念层次的东西。
现在,根据测试金字塔来看,故事测试用例明显要比单元测试要少,而且故事测试用例并不会测试所有可能性。难道这意味着需要在测完所有的故事测试用例后,再在小规模的单元测试中重复一遍?答案是否定的,那是完全没有意义的。回到灵活性原则和改变方式上,只有当你需要的时候才会构建额外的单元测试,例如在这几种情况下:第一个故事测试没有完全捕捉正确、发现一个非常重要的特殊案例、你想专注于整个解决方案里面的一部分。猜测故障点跟猜测设计一样,完全是在浪费时间。
学习要点3 单元测试的最佳实践
通常,会为了验证某个想法而开始一段测试,但我们并没有十足的把握能确保成功。因此,提供一个最佳实践来指导或帮助你实现最终结果。首先,提出一个要求,然后在集中精力去考虑如何实现。这就是我们在Graft项目中进行同步策略测试的最佳实践方法。
在测试里面写实现方法
确定功能后再开始编写测试用例,而不是提前思考如何组织(创建哪些类?在哪里进行整合?是否使用一个工厂类或工厂方法),为什么不直接在测试方法里面写代码?上面提到的那些因素可以以后分析。这样你就可以一心一意地撰写功能测试报告。此外,通过推迟内部组织实施,你将会有更多的时间去思考和决定,最后你会得到一个非常好的解决方案。
关键原则:重点、避免过早决策
自底向下的设计
避免:
- 过早过多假设
- 思维固定在某个特定的、不成熟的设计中
- 限制思维(通常会以第一个计划来结束设计)
从小到大,先从一小部分的功能开始实现,然后再一步步整合形成一个复杂的模块。不要因为各个模块间的依赖关系而感到心烦意乱,在真正实现整合和替代之前先给它们进行简单地备份。使用这种技术就无需在最初的时候与设计方案相绑定。在这个过程中,不仅需要经验还需根据点直觉,连同TDD会有更好的设计和实现方案展现出来。
在没有形成最终解决方案的时候,我们发现这个方法非常有用。在开发Graft时,我们并没有预先设计好整个应用程序。在第一天我们只是挑选了一个用户案例去实现,然后在接下的日子里,我们也是在挑选案例然后进行实现。
行动与要求
我们的Graft数据库有一个接受用户命令的telnet-like接口。参照下面测试addComment的两个简单地变化:
- //Test1
- Graftdb=...;this.subject=newCommandProcessor(db);
- subject.process("addCommenteventIdmy_comment");
- assertthat(subject.process("getCommentseventId")).isEqualTo("my_comment");
- //Test2(samesetUp)
- subject.process("addCommenteventIdmy_comment");
- assertthat(db.getComments("eventId")).containsOnly("my_comment");
在Test1里面,当对addComment命令进行测试时,直接使用getComments这个命令来检查结果状态。在整个测试过程中,只使用了一个单独的API入口点——subject。Test2是直接访问底层数据库实例并且使用其API来获取数据。与此同时,subject这个API也访问底层数据库。
因此Test1并不是真正意义上的“单元”测试,因为它还依赖另一个测试类。而Test2则更加专注并且写法更加简单,它直接访问目标数据结构,对源代码进行正确检查。
我们将继续讨论像Test1那样的测试,在同等级上,它会完成所有相同的操作,即基于同一级别的公共API对象测试会更好。这里的“更好”是指容易理解、更加重要和稳定可维护,因为它们不是耦合到内部实现的功能测试。
像Test2的这种测试则更常见,无论是直接访问底层(对象、属性、数据库……)还是通过模拟来直接对产生的副作用进行验证。这些技术往往会导致耦合且对测试难于维护,这种技术应该限制在“私有单元测试”中,切勿混合公共和私有单元测试”。
- 把后面的任务用列表弹出来,而不是立即去做
- 专注于修复测试——无论多么差劲和简单(或者重构)
- 专注于当前需求——切勿过早提取
还有一件值得我们的注意的事情,Kent任何时候都会关注自己在做什么。专注意味着你会一心一意的做一件事情,不会受其他事情所影响,无论那件事情有多么重要或者只是简单修复。(备注:永远不要说永远)如果你在修复一个Bug的过程中还发现其他的事情,比如给一个类起更好的名字、删除已死的代码、修复一个未提交的Bug等。这些你都可以用列表记下来,等到手头的Bug修复成功后再去落实。
并行(Parallel)设计
并行设计意味着当改变一个设计的时候,你要尽可能的保持在原来的需求上渐渐添加新元素,直至转向新设计。在这个过程要不断地确保正确性,这需要花费更大的精力,当然这样做也会让其更加安全和容易实现可恢复重构。
一个典型的并行设计案例是使用Nosql数据库替换RDBMS。开始时,你会把实现好的代码写入新的数据库中,然后再把它同时写进新旧这两个系统里面,并且从新的里面读取数据(也许这样就可以与旧的相比较进行验证),同时还在使用旧的数据库数据。接下来你将开始实际使用Nosql数据库的数据,同时从旧DB读/写数据(这样你可以方便地切换回来)。只有当新的DB被证明正确无误时,才可以逐步删除旧的DB。
一个小型的并行设计案例是用对象替换方法参数,例如notifuComment方法:
- -publicvoidnotifyComment(Stringmessage,StringeventName,Stringuser){
- -notifications.add(user+":commentedon"+eventName+""+message);
- ---
- +publicvoidnotifyComment(Edgetarget){
- +notifications.add(target.getTo().getId()+":commentedon"+target.getFrom().getId()+""+target.get("comment"));
步骤:
这样做的好处是,代码的正确性会得到保证并且可以随时工作,你可以在任何时候提交或停止。
可恢复的重构
对大规模的代码进行重构时,需要使用一些最佳实践来保证代码随时可被恢复。上面介绍的并行设计是被实践证明的,一种非常安全的重构步骤,在执行过程中不会破坏任何东西,代码的正确性也会得到保证。
在重构过程中,不同的人会有不同的测试策略。但是我想说的,程序员在开发代码时,应该有这样的理念:我因编写代码而得到报酬,而不是为了测试,所以我希望编写的代码能够达到一定水平以至于更少的被测试。(我认为,这种信心等级与工业标准相比还是比较高的,但这仅仅有点狂妄自大)如果我平时很少犯这种错误(比如在构造函数里面设置了错误的变量),就不用对它进行测试。我比较倾向做一些有意义测试错误,所以我会额外关心那种复杂条件下的逻辑结构。当在一个团队中编码时,我会改变测试策略,非常小心的对代码进行测试,最后对错误进行总结。
最后,我通过引用Kent的话:“how much testing to do”来结束这一主题。
对称守则
对称是一个非常抽象的概念,与沟通、简单和灵活相比更加具体,但仍然是普通的。在Kent的《Implementation Patterns》一书中指出,对称应该作为编程原则。
与不对称相比而言,代码的对称性还是比较容易把握的。它更容易去读和理解,所以对称代码会更加具体,再次引用Kent的话:
想象一下代码所要表达的想法,比如“从数据库中获取最后的更新文件”代码需要执行几次。如果方法名称和执行顺序都不同且彼此有很大的差别,那么这样的代码就是不对称的。问问自己:“这个方法是用来干嘛的”。一个对称代码的例子是保持代码在抽象层次上的一致性,比如方法。细心的读者可能已经注意到,一致性是对称的抽象层次,例如一致性的方法命名。但是,对称是抽象的,它涉及更多的想法,而不是规则(如“驼峰式”里的类名和方法命名规则)。
- 如何管理你的时间,保持精力——注意休息和保持旺盛的体力,在劳累之前必须停下来。一个精力充沛的开发人员比熬夜劳累过度的程序员效率要高很多。(JB Rainsberger在《经济学中的软件设计》中分享过于劳累的工作会使其完全徒劳无功)
- 结对编程是一种技能,必须自觉地学习(它可能是更具挑战性的一些人格类型,应得到尊重)
- 与手动更改相比,更喜欢使用IDE重构——f.ex.我们之前从未使用过的“内联”重构,而Kent一直在使用。一旦你掌握了如何重构,你会发现,它明显比手动重构更有效率。更重要的是,避免打破东西(记住, Murphy所说的——what can break will break)
你可以在GitHub下载我们此次在俱乐部所开发的源码。
总结
我非常希望你们(亲爱的读者)能在平时的工作实践中运用这些价值观和设计原则,希望它们对你有用!