codecamp

快车道:流,重构,TDD与设计模式

从春运往返到项目交付

去年年底,也就是2016年年底,我从广州回老家茂名过年,基本上花了接近12个小时。而我另一个同学,从上海回到广州才花了2个多小时。省内行程竟是国内行程时间的近6倍!当然这与交通工具也有关。但现在中国的春运,给人第一印象就是:塞、塞、塞。

在回家的路上,包括我,很多人都想早点到家,都想快人一步,但事实往往是一塞再塞,一拖一再拖,最终到达的时间比预计到达时间一晚再晚。司机无奈,乘客厌烦,家人着急。行走在高速公路上的体现,想必是很差很差的。幸好的是,一般只有在节假日的时候,这会这么拥堵,其他时间路况还是比较顺畅的,到达家里的时间还是比较可控的。

但对于项目交付呢?项目延迟的情况是有时在重大特殊时期才会容易出现延期交付,还是一般情况下也是风险重重,很不顺畅?在项目开发过程中,有没有也一种高速公路,可以不用等待经灯一路畅通无阻?有没有一条快车道,可以让开发更大的提升效率、降低风险?

这篇文章希望能找到一些答案。

为什么你的效率那么高?

在近五年工作中,我曾不止多次被同事问及:为什么你的效率那么高?

其实我的开发效率并不算高,在我看来,只是普通正常的开发速度。但有一点可以肯定的是,我开发的项目通常质量都比较有保障。我也一直在思考,在总结,在提炼,这其中有没技巧?毕竟相互影响的因素众多而繁杂,若总结过多则不能概要体现本质,总结得过少则容易产生偏差而引起误导。

虽然有那么一刻我做到了高效开发,但未能持续稳定地做到。能稳定地做到未必能很好总结到,总结出来又未必能很好准确表白出来。但在这再一次返乡的途中,我尝试回想过去曾高效工作的一些要点,以帮助希望提高胜任力的开发同学。

曾经在大学时,看《人件》未能看懂,把握不了它的深意。毕业工作后再看,虽不敢说完全把握了它的精髓,但颇有体会和感触。特别对于流这一说法,通常情况下开发人员要进入流状态需要15分钟左右,而一旦进入,则像是进入了一个忘我、非常投入其中的工作状态,此时的效率如此之高,足以让人感到畅通无比。这个问题解决了,那个要点也妥善处理了,一切都得到了完美的层次,除了心情愉悦,开发人员想必更多是成就感。

这是因为在这段时间内,开发人员可以完全专注于手中正在做的事情,可以很好地在大脑中构建起与业务需求对应的设计模型。软件开发,毕竟还是一个高度智力劳动与沟通协助的过程。在需要高度智力进行编程的这段时间内,程序员可以排除外界的干扰专心致志编写代码,也就自然而然能更高效产生出质量更高的代码。

这种流状态,除了外部的工作环境能够协助营造外,也正是我们在这里希望可以通过恰当的方式所到达的。并且只有当我们掌握了恰当的方式技巧,我们才能更好地进入流状态,从而有更好的产能。

重构、TDD和设计模式

重构、TDD和设计模式,都是我们耳闻能详的术语。这些年,我也一直在使用他们。但一个悖论是,这些在行业内所推崇的手段、实践和理论,在我们身边的实际项目开发中却很少得到应用,甚至乎知之甚少。也话这就像我们的生活一样,越强调的,往往是越缺少的。

重构、TDD和设计模式,各自都已有丰富和专业的讲解、书籍和学习资料。这里,我就不再赘述。我这里只是再稍微分享一下我个人的见解,以及这三者之间微妙的关系。

重构:对过去代码的优化、对将来代码的雕琢

在Martin Fowler先生那本《重构》的书中,我们可以学到很多整理整洁代码有用的手法。我觉得不必要刻意去记住这些细致有点呆板的重构手法,而是在平时时而用之,慢慢就会得心应手了。这里面有一个很值得借鉴的童子军原则:让代码比你来时更干净!

这让我想到了破窗理论,我曾经在一次分享中也提到这点。对于已有的项目,如果先前的代码是整洁的,那么后面的开发同学也会自然而然继续保持整洁,如果原来的代码是混乱不堪的,那么后面开发的同学往往也会继续放而任之。虽然也许同样面临项目交付压力、同样是这位开发人员,但不同的代码风格真的会在潜意识上影响后面的代码风格。在我实际的工作中,我不止一次发现了这个规律。即对于我精心开发的代码,后面参与进来的同学也会尽量精心开发维护,因为他们说怕把我已有的代码搞脏了。 那我是怎么做到精心开发的呢?

软件开发有时是艺术,有时是科学,而这里正是科学的重构指导了我编写精心的代码。

对于过去已有的代码,我会使用重构进行小步优化,从而慢慢得到更灵活、更具可读性、更容易维护的代码。当然,这个过程是漫长的。但方向对了,只要我们努力,总会得到一个好的结果的。但要坚持、敢于持续小步重构。如果哪天我们得到了优雅的代码,不是因为我们今天做了什么,而在于我们过去一直在做了什么。

而对于新的项目,对于未来投入到生产环境、成为产品的代码,我则会在开发过程中粗糙完成功能后,再用重构慢慢雕琢,像工匠对待他的艺术作品一样慢慢雕琢。根据短而美、单一职责原则、开放-封闭原则、KISS等,我会用心慢慢把我觉得还不够完美的代码进行再调整,直到我认为这是一段好的代码,是一段别人容易理解的代码时,我才结束重构。

TDD:有助保持流状态的意图导向编程

TDD即测试驱动开发,是一种最佳实践,它有很多好处。

但我觉得它最重要的好处在于可以帮助我们keep住上下文,以帮助我们在纷繁的工作中保持难得的流状态。实际工作中,会有很多打断我们开发思路的事情,一如开会、线上问题排查、需求讨论、新邮件、上洗手间等。频繁地切换思路,势必会影响我们对开发的状态投入,尤其长时间的打断会严重让我们忘却之前在大脑临时内存区域的一些重要待办事项。而“红-绿-重构”下的测试驱动开发,能够帮助我们保持对最终达成目标的关注。即,失败的测试帮助了我们持久化记住了之前那一历史时刻大部分的信息和场景概况。

此外,在具备自我验证能力下的单元测试套件下,更是为我们搭建了一个360度的安全网,一个可以任意大胆进行各种尝试的沙箱环境,并能最大程度上让核心业务逻辑得以保证,从而保证了最终的交付质量。其中,快速反馈大大缩短了等待的周期,以便我可以快速发现问题、定位问题、修复问题、再回归测试验证。而通过层层验证的核心业务,更是让我们增大了自身对代码质量的信心。曾经多次,代码上线后,测试同学怀疑我的代码有问题,但经过了严格单元没说的我跟他们说,不会的,这块我已经用等价类进行了各种测试,你说的这种情况我已验收过,没问题。果然确实没问题。我想,也许这就是专业。

不得不说,在我身边,我发现很多同学为了运行自己所编写的代码,得到相应的反馈,其过程是非常漫长的。最常见的是在Web应用项目开发过程中,程序员要把代码编写好后,上传到某一测试服务器,再切换到浏览器进行访问、手动操作和人工验证。可想而知,当开发人员同时要面对好几个功能开发时,局面会是多么混乱。而且当要排查某些问题或者修复线上故障而突然临时运行代码时,这种成本更为巨大,因为开发人员要手动重现当时的开发场景。开发速度可想而知,相比拥有测试套件的方式,会慢很多,而且还很影响开发的心情。心情得不到平静,因此也就难以很好处理各种问题。乱而不通。

设计模式:形式服从于功能

关于设计模式的书有很多,从最初提及永恒之道的《建筑永恒之道》,到经典的GOF《设计模式》,再到后续的《企业应用架构模式》、《大话设计模式》、《设计模式分析》、《反模式》等,都是非常优秀的学习资料。其中亚力山大所说的,掌握了一种大家所共有的语言模式后,在设计一些建筑时,是这些建筑本身通过语言模式告诉我,“它就应该是这样的!”而不是我个人刻意去这样设计以满足某些其他与建筑本身无关的目的,如个人的报酬、抑或外界的压力等。但如果不掌握这些语言模式,即便感知到这些建筑本质是怎样的,我们也不知如何表达出来,无从下手,自然也就不知如何确切地构建出来。

设计模式可以说是一种更好组织代码的语言模式,即便不使用它,也能完成功能的开发。但使用它,能够更好得到浮现式的设计,从微架构慢慢演变到一个更大、更完善、更永远的架构。

对于设计模式,我理解是:形式应该服从于功能。即最终目标是完成满足用户的特定功能,使用设计模式是为了更好组织代码外在形式,以便更好表达概念的完整性。

开发“三把斧”及其三者之间的微妙关系

虽然我不想刻意制造更多的术语,由于这里重复提到重构、TDD和设计模式,我们暂且在这里把这三者称为:开发“三把斧”。他们之间的微妙关系,我一直都在构思总结。最后总结出如下:

除上图可以看出,最下面的是TDD,也就是说测试驱动开发是一项应该落地的实践,也是我们开发的基础。从头到尾,由始至终,我们都应该遵循测试驱动开发。说白一点,还没开始编写产品代码时,我们就应该编写测试代码,哪怕最后完成了产品代码,我们依然还要运行测试套件。

在中间,在三个层级的代码,从左到右,我们暂且命名为:粗糙的代码、合格的代码、精心的代码,分别代表坏代码、中等代码和好的代码。一般这些都是循序渐进的,即最初我们编写的是粗糙的代码,继而调整为合格的代码,最后雕琢成精心的代码。当然,也有一步到位的情况,但我们这里所讲的适用普通大众的情况。

而在这三等代码之间,要实现往上一级的转换,则需要使用到各种重构的手法。重构的手法多种多样,故而有多个箭头指向,这也就说明了不同的开发人员可以采用不同的重构方式,毕竟条条大路通罗马,代码没有绝对的表现形式。

再往上,即最顶上,则是我们的设计模式。注意,这里使用了虚线,即与实线的重构、实线的TDD不同,它是一种虚的东西。虽然设计模式也有其名称、实现步骤、成例和注意事项等具体的内容,但我觉得设计模式更像是高层的思想,正是它指导了我们往更好的方向前进。所以,在这里我把设计模式作为了我们最高的指导思想。

很明显,综合起来就是一个金字塔的结构,概括来说就是:TDD是基础实践,重构是通往更好的代码的途径,设计模式则是最高层的指导思想。如下:

这三者应该是相互影响、相互促进的。缺少了TDD,我们就缺少了有力的保障,就没有了安全可靠的测试基础。缺少了重构,我们就会迷失于如何把代码变得更好的细节中。而缺少了设计模式,我们就会漫无目的地进行重构而不知所终,因为我们不知道最终该如何确切组织我们的代码。

开发“三板斧”是很重要的,如果说我为什么开发效率那么高,是因为我和其他开发同学有一些不同之处,那就是掌握了这“三板斧”。如果你尚未对重构、TDD或设计模式有一个良好的理解,我建议你先补充相关的必要基础知识,再继续往下。因为我们将会探讨如何使用这“三板斧”进行高效开发。

准备进入快车道!

前面讲了少理论,下面让我们来开始实践一下。但在进入快车道前,我们还有一些准备工作要做。

环境与工具: 除了快,还是快!拒绝等待!

已经有很多研究表明,良好的办工环境,能提升开发人员的办公效率。当然,我不是行政人员,所以改善不了你现在的办公环境。但我们完全可以定制自己的虚拟办公环境,即开发环境与工具。

有一点是不容置疑的,那就是,快,尽可能地快!拒绝一切等待的时间!

比如,IDE卡顿,慢?换!改用文本编辑器,比如在Linux上使用vim。本地环境运行响应慢?换!改用云服务器,或用内部远程服务器。git的界面化操作慢?换!改用命令行直接进行操作,减少华丽的界面响应时间。类似的代码需要人工重复编写?换!改成代码自动生成或者其他辅助工具。

如果所用的工具跟不上你的操作速度,甚至跟不上你的思维速度,那么你就想尽一切办法进行优化,改用其他更好的方式。重复一遍,拒绝一切等待的时间!

打个草稿,或画个草图

虽然我们从事的是软件开发,但不能因为这样就觉得全部的虚拟电子化产品都比原始的工具要好。就比如,对于打草稿或画草图,我就非常建议使用笔和本进行乱图乱画。要知道,很多名人的杰作,都是从草稿来的。

记录下我们灵光一闪的想法,把各种问题标识下来,以及在开发前把大体的思路和关键点画在草稿上,这对我们后续的开发大有裨益。

产品上线后出现的问题,往往是因为我们之前考虑不够周全,如果再进一步,我们就能提前预防。而草稿则有助于帮我们发现这些问题。

精益求精的管道工

在我小的时候,我比较喜欢“制造”一些东西,如设计并“制造”一个灌溉农田的管道设施,当然那只是玩玩泥沙而已。但现在,我发现软件开发也是需要先设计再慢慢创造的,我也就从一名管理工变成了一名开发人员。而在开发过程中,我仍然保持着当年小时候那么对精心制造的精神。

要进入开发快车道,主要是这几个步骤:

  • 1、明确最高层的功能
  • 2、选定主模式
  • 3、编写失败的测试用例
  • 4、实现具体功能,通过测试
  • 5、细化模式与小步重构
  • 6、回归测试

    下面,我将重点结合一个案例来阐述这些步骤是如何开展的,以便更好让大家理解如何进行快车道进行高效开发。这些步骤对旧代码的维护和编写全新的代码,都是适用的。

    1、明确最高层的功能

    明确最高层的功能,就是我们所要开发的最终功能,可以是特定的产品需求,也可以是某种技术功能。

在开源框架PhalApi中,我们需要实现一个可以支持分表操作的功能。这里,我们就以这个开发过程作为讲解案例。在这个案例中,明显最高层的功能就是实现能支持数据库分表的操作,并且分表的策略是可以配置的。开发人员只需要简单配置,即可方便进行分表操作。

以下是一个分表的示例。

上图引自PhalApi官方文档海量数据:可配置的分库分表

2、选定主模式

明确了最高层功能后,下一步就是选定主模式。当然这一步,不是必选的。

对于简单的项目,或者本来就不适用于设计模式的业务场景,可以省略这一步。但由于我们这里主要讨论的是复杂业务场景的快速开发,所以需要这一步(对于简单的功能开发,开发速度和质量往往差异不大)。

在选定主模式前,需要对业务背景进行一些分析。由于PhalApi的数据库操作是基于NotORM开源类库的,所以所实现的分表操作也是基于NotORM之上。显而易见,这里适合采用代理模式。

关于主模式,《设计模式分析》中有更详细的说明,这里不再赘述。

3、编写失败的测试用例

前面做了不少铺垫,到这里我们终于接触到熟悉的代码了。正如前面如说,首先应该编写的是测试代码。基于上面的理解,可以快速编写以下测试用例:

<?php


class PhpUnderControl_PhalApiDBNotORM_Test extends PHPUnit_Framework_TestCase
{


    public $notorm;


    protected function setUp()
    {
        $this->notorm = new PhalApi_DB_NotORM(DI()->config->get('dbs')/** , true **/);
    }


    /**
     * @dataProvider provideTable
     */
    public function testHere($table)
    {
        $demo = $this->notorm->$table;
        $this->assertNotNull($demo);


        $rs = $demo->fetchAll();


        $this->assertNotEmpty($rs);
    }


    public function provideTable()
    {
        return array(
            array('demo'),
            array('demo_0'),
            array('demo_1'),
            array('demo_3'),
        );
    }
}

为了突出重点,上面省略了一些次要的代码,完整的测试代码请见PhalApi_DB_NotORM_Test.php。 关于如何编写测试用例,之前也有讨论,更多请参考最佳开发实践:自动化单元测试(PHP)

稍微解释一下上面测试代码的意图,在testHere()用例中,分别使用了由provideTable()方法提供的四组测试数据,分别表示着四个表,即一个默认表demo和三个分表demo_0, demo_1, demo_3,然后取出表中的全部纪录,并验证结果数据集是否不为空,即保证能正常取到数据。

上面的测试场景,还缺少两部分重要信息,一是分表的配置,另一则是分表结构和纪录数据。

分表配置

分表的关键配置如下,demo表配置了两个路由,一组是不使用分表的情况;另一组是使用了分表的情况,涵盖了后缀是0、1、2的分表。

'demo' => array( 'map' => array( array('db' => 'db_demo'), array('start' => 0, 'end' => 2, 'db' => 'db_demo'), ), ),

分表结构和纪录数据

分表结构如下,其他分表结构一样,为节省流量,这里不再一一列出。

CREATE TABLE demo ( id int(11) unsigned NOT NULL AUTO_INCREMENT, name varchar(11) DEFAULT NULL, ext_data text COMMENT 'json data here', PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;


CREATE TABLE `demo_0` ( ... )


CREATE TABLE `demo_1` ( ... )


CREATE TABLE `demo_2` ( ... )

表的测试数据,可自由填充。

至此,通过一开始编写单元测试的代码,可以引导我们决定数据结构、最终使用方式以及高层功能如何具体落地。

当然,一开始这个测试用例是失败的。但没关系,因为我们正是需要它失败(有时编写了本应失败的测试用例却成功了,反而能发现一些微妙的问题)。

接下来,我们就要让它通过测试,从失败的红色变成通过的绿色。

4、实现具体功能,通过测试

虽然过去了几年,但到现在我依然清晰记得我当时是如何编程开发的。那时,我进入了一个美妙的Ubuntu世界,使用了双屏幕,开启了四个命令窗口,全屏均匀平铺,从左到右的窗口分别是:单元测试(除了总是先写测试代码外,我也总是把测试的命令窗口放在第一位)、主开发具体实现功能窗口、副开发具体实现功能窗口、参考辅助窗口。

主开发窗口所编写的代码,就是当前正在测试的类。而副开发窗口则是实现正在测试的类的更底层的依赖代码。每完成一个小阶段性的编程,我就运行一下测试,以便得到快速反馈,验证我刚刚写的这一小段代码有没什么问题,如果有,则改正,没有,则继续向前。

同时对于在开发过程中所想到、发现、总结的问题,不管大小,我都会快速做下备忘,列个待办清单,以便稍候逐一处理。

具体的实现主要就是在代理模式下,按TDD的步骤向前推进。这里扼要讲一下具体的实现过程。

由于待实现的类PhalApi_DB_NotORM是一个代理,并且是一个要维护多个NotORM实例的容器,而这些NotORM实例又会共享PDO链接资源。明白了这些数据结构,就不难界定有哪些类成员属性了,如下所示:

class PhalApi_DB_NotORM {


    /**
     * @var NotORM $_notorms NotORM的实例池
     */
    protected $_notorms = array();


    /**
     * @var PDO $_pdos PDO连接池,统一管理,避免重复连接
     */
    protected $_pdos = array();


}

数据结构确定好,就不难确定需要对外提供的公共接口操作了。由于是代理类,所以我们需要使用PHP的魔法方法把setter和getter类进行包装转发。其中getter类操作是重点,因为我们的分表支持正是由这里提供的,而setter则是辅助类方法,保持原来的获取即可。所以,追加__set()和```__get()````两个魔法后,如下:

public function __get($name) { // TODO ... ... }

public function __set($name, $value) { foreach ($this->_notorms as $key => $notorm) { $notorm->$name = $value; } }

由于getter的实现是核心业务,其实现相对setter来说更为复杂。但是,在单元测试不断的意图编程驱动下,在保持始终对高层功能的关注,我们应该按“最短路径”以简单的方式实现所需的功能,不走入设计瘫痪的圈套,也不增加目前不必要的功能。

具体的实现这里就不再深入展下说明。显然,这一步应该是通过测试为结束点,暂时先不管代码长得多么难看,先快速简单实现。

5、细化模式与小步重构

对于复杂的业务场景,也许需要用到的设计模式不止一个,那么就要组合使用多个设计模式以便让业务逻辑、规则、结构更好地呈现和表达出来。

小步重构,就是对已经实现具体功能的代码,进行精心雕琢,局部调整,并且每次调整好后都要时刻频繁执行单元测试,以便验证刚刚的改动没有造成其他副作用,没有引入新的BUG。

这里举一个小小的重构,即提取函数。PhalApi_DB_NotORM实现后,由于严格的测试,得到了质量的保证,深入各开发同学的喜欢。但为了更好地支持开发同学定制除了默认的MySQL以外其他数据库的PDO链接,我们对原来的创建PDO实现进行了提取,提取成一个单独的函数成员,以便具体项目可以对其进行定制重载。

一开始的代码是:

protected function getPdo($dbKey) { // ... ...

try { $dsn = sprintf('mysql:dbname=%s;host=%s;port=%d', $dbCfg['name'], isset($dbCfg['host']) ? $dbCfg['host'] : 'localhost', isset($dbCfg['port']) ? $dbCfg['port'] : 3306 ); $charset = isset($dbCfg['charset']) ? $dbCfg['charset'] : 'UTF8';

$this->_pdos[$dbKey] = new PDO( $dsn, $dbCfg['user'], $dbCfg['password'] ); $this->_pdos[$dbKey]->exec("SET NAMES '{$charset}'"); } catch (PDOException $ex) { // ... ... } // ... ... }

将具体的PDO创建过程进行提取,重构后的代码是:

protected function getPdo($dbKey) { // ... ...

try { $this->_pdos[$dbKey] = $this->createPDOBy($dbCfg); } catch (PDOException $ex) { // ... ... } // ... ... }

protected function createPDOBy($dbCfg) { $dsn = sprintf('mysql:dbname=%s;host=%s;port=%d', $dbCfg['name'], isset($dbCfg['host']) ? $dbCfg['host'] : 'localhost', isset($dbCfg['port']) ? $dbCfg['port'] : 3306 ); $charset = isset($dbCfg['charset']) ? $dbCfg['charset'] : 'UTF8';

$pdo = new PDO( $dsn, $dbCfg['user'], $dbCfg['password'] ); $pdo->exec("SET NAMES '{$charset}'");

return $pdo; }

最终完整的实现代码,请见:PhalApi_DB_NotORM

6、回归测试

当积累到一定的单元测试后,我们可以在完成具体功能后,同时也运行全部测试以便进行回归测试,以确保在实现新功能的同时,没有对已有的功能做出非期望的修改和影响。

这是很重要的一步,因为往往会在这一步会一些意外的收获,或者提前尽量发现一些隐藏的BUG。

小结:开发这条快车道

在YouTube上的What is DevOps - Brandon,有这样一张slice:

从上面的图中可以看出,仅仅在DevOps就有8个主要环节,分别是:前期计划的plan、编码开发的code、持续构建的build、自动化测试的test、发布release、部署deploy、线上操作operate、monitor监控。更何况我们整个项目开发流程周期所涉及的环节,肯定远比DevOp棕8个环节还要多。

也就是说,我们上述所说的快车道只是适用于“编码开发的code”这一阶段,只是针对开发人员在编程开发的这一过程中,或者更准确来说是开发人员如何独自高效完成功能开发这一过程。

实现高效开发的途径肯定也不止一种,并且因人而异,但如果没有找到其他更行之有效的途径之前,我觉得上述这条快车道是值得尝试的。很多同学一开始就对行业内流行和推崇的实践和理论持抵触和怀疑的态度,但如果你未曾全面了解它、也没在工作中实践过它,至少应该给自己一个机会去尝试一下。

我想,当一个人把重构、TDD和设计模式能很好地结合使用时,他就能更容易进入专注而高产的流状态,走进开发这条快车道,一路畅通无阻。

温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }