架构之路:性能与单元测试

似乎程序员都是急性子,或许是被windows冗长的开机时间折磨够了,有可能是因为提升性能的效果是最显而易见的……总之,我发现,绝大部分程序员对性能的关注和热情是无与伦比的!

C#刚刚推出的时候,就有人摇头晃脑的说,“嗯,自动垃圾回收,性能不行吧?”
  DataSet横空出世,马上有很多人写代码,在DataSet里插入几百万条数据,证明DataSet的性能问题
  Linq当然更要被骂了,尼玛用反射?反射是什么,同学们知道么?性能大老虎呀!更不用说那些自动生成的sql了,有我手写的高效么?
  ……
  所以直到今天,我仍然看到很多程序员无怨无悔的用存储过程来构建他们的系统,一个存储过程可以有几千行!然后,他们很无辜的问,“业务层有什么用?究竟能干些什么呢?”
  在带团队的时候,我最怕讲的就是性能有关的问题。你要是不谈性能呢,那代码有时候 真心看不下去;你要是强调性能呢,不知道他会给你整出什么幺蛾子出来。其实这就是一个“度”的掌握,所以非常难以用语言予以表示清楚。所以无数次挫败之 后,我只好咬牙切齿的说,“你的代码,只有一个评判标准,可维护性。性能的问题先不管!”这个答案似乎并不能服众——尤其是对有上进心的程序员而言。
  所以,我先专篇讲性能,希望能帮助大家更清楚的认识这个问题。
  性能不是不重要,而是他没有可维护性重要。要理解这一点,首先要理解可维护性的重要(请再读上一篇我花数周找bug的段子);然后要明白:解决性能问题,我们可以有很多代码以外行之有效的方法,而可维护性基本上就只能靠代码了;最后,还是要牢记:没有牺牲,就没有胜利!
  所以,在绝大多数情况下,当性能和可维护性相冲突的时候,性能让位于可维护性。我们采用其他办法来弥补代码性能不够高的问题。
  空洞的说教没有意义。我们还是举例来说明吧!
  破坏可读性
  前段时间我review代码的时候发现,这个程序员用Linq之后老是用 First()而不是Single(),我就奇怪了,按业务逻辑,返回的值就应该是一个,难道可能会是多个,多个应报异常,不应该取First()就完事 了呀?想了一会儿,问这个程序员,他的回答让我瞬间一种无力感,“First()性能更高呀!”以下为对话实录:
  “你怎么知道First()性能更高呢?”我问。
  “First()嘛,取了第一个合格的值就返回,就不会继续查下去了;Single()的话,就会一直查,查出所有数据,然后再取其中的一个。”
  “你确定?你知道有一种东西叫做索引不?”
  “啊?……”
  然后我简单的告诉他,索引是一种树状结构,可以让查询更快等等。
  “但我还是觉得应该用First()”,他想了一会儿,还是很坚定。
  “为什么?”,我不明白了。
  “就算有索引加快了查询速度,但用First()在加快了速度上更快呀!更快总是没错的吧?”
  “……”,我真不知道该怎么说了,最后突然灵光一闪,“好吧,那你说说,微软为什么要搞一个Single()方法出来呢?就为了搞出来误导你们?让用First()的产生优越感,嘲笑用Single()的?”
  他陷入了沉思。
  评论里还在纠结Single()/First()的同学,请大声的吼三遍:可读性!可读性!!可读性!!!
  发现同学们还在纠结这个细节。好吧,再解释一下:
  你怎么知道数据库用的就是MSSQL呢?你怎么知道就是用的关系数据库呢?NoSQL不行么?所以,你怎么就知道Single()/First()具体是怎么执行的呢?比如我就要写个Linq实现,把所有的数据全取出来,然后再在内存里排序,最后取First呢?
  这里我们考虑可读性,意思是:读代码时,看到Single()就能瞬间知道coder的意思是取唯一的一个;看到First()就知道coder的意思是要取第一个。和性能没关系,如果一定要纠缠性能,那好:你要确定唯一性,当然要做检查(包括不唯一时抛异常),这个性能损失是应该的呀;你要取第一个,当然要进行排序,排序也会有性能损失呀!
  我刚入行的时候,还很是收藏了几篇文章,比如《高性能编程的十大准则》之类的,里 面的内容大致就是,“总是使用StringBuilder,不要使用‘+’;总是使用……,不要使用……”。这类文章下面总是有一堆人叫好,“不错!”, “谢谢分享!”但慢慢的,我就对这些文章产生了怀疑(也应该感谢园子里的老赵,csdn里面的sp1234之类的大神);直到很后来,我才明白为什么这种说法是肤浅的;而只有通过上面的对话,我才能清晰的把我的理解说出来。
  所有这些牺牲性能的简单封装,都是有其目的的;而其中一个很重要的目的,就是为了提高可读性。你为了性能,故意不使用这些现成的封装,通常,丧失的就是可读性。
想当然
  继续上面这个例子。最开始的时候,这个程序员关于性能的考虑其实是想当然的。这种想当然的情形很多,大致有这几种:
  自己的理解完全就是错的
  自己的理解不能算错,但实际上底层已经对该问题做了优化
  自己的理解没错,底层也没优化
  第1、2种比较好理解,第3种为什么也说他“想当然”呢?因为没有和硬件环境相契合。
  最简单的例子就是“缓存”。比如面试的时候,问你一个问题,“缓存能不能提高性 能?”请注意,这是一个陷阱。答案应该是:“不一定”。几乎所有的人都认为,缓存可以迅速改善性能,是因为今天计算机的CPU和磁盘运行速度,远跟不上内 存的发展。但即使如此,无节制的缓存,一样可以拖垮整个系统。
  类似的例子还有很多。你沾沾自喜,我节约了一次磁盘读写的时候,你同时增加了CPU的负荷;你优化了算法,减少了CPU的运算,但其实增加了内存的压力……天下没有免费的午餐。同样的代码,随着数据的增加,硬件的改变,会呈现出截然不同的性能表现。
  所以,开发过程中,很多的“优化”,其实只是你的想当然。与其这样想当然的优化, 不如在拿到性能测试结果之后再有的放矢的进行优化。这时候,又回到了我们之前说的,是不是代码的可读性更重要?这样你才能迅速的找到该优化的瓶颈啊!否则,一堆乱七八糟看都看不懂的代码,你怎么去优化,你连该优化的点都找不到。
  难以维护
  另一个搞笑的例子是关于我自己的。创业家园项 目里有一个功能:显示博客正文的同时提供一个上一页下一页的链接。惯常的做法就是直接在数据库里查就是了,但我总觉得不对,这样做两次查询有必要么?能不 能优化?于是我想到了一个“绝妙”的点子:为什么不直接在博客里存储上一篇和下一篇的Id呢?这样我一次性数据往返就能取到所有数据了嘛!各位同学是不是 觉得我这个主意很棒?
  噩梦由此开始了。
  首先,我们是想在发布博客的时候,设置他的上一篇和下一篇。但是,上一篇好设置,下一篇呢?还没有啊!怎么弄,就只好在博客发布的时候,设置他的前一篇,同时设置他前一篇的后一篇。
  然后,我们新添加了一个功能,除了上一篇下一篇以外,还需要在当前博客所在分类中 的上一篇和下一篇。怎么办?再加字段呗。所以,博客里就有了Previous, PreviousInCategory, Next, NextInCategory。这时候,就感觉到有点不妥,但还可以接受。
  接着,出现了一个问题,上一篇下一篇博客被删除了,怎么办?这个过程,就相当于从一个双向链表里移出一个节点一样麻烦。头开始有点大了。
  再接着,博客除了发布删除以外,还有各种其他状态,比如被屏蔽。而且被屏蔽之后, 能否显示和当前用户又有关系。当前用户是普通用户,不能阅读;当前用户是作者自己,就能够阅读。怎么办?首先,屏蔽的时候,要设置上一篇下一篇;屏蔽取消 的时候,还是要设置上一篇下一篇。然后,上一篇下一篇得根据当前用户不同变化的这个问题,基本上就傻眼了……
  最后流着泪把辛辛苦苦折腾了好久的代码全改回来,就通过数据库查呗,多么清晰简洁的逻辑啊!性能问题?首先,这样做造成了性能问题么?然后,就算有问题,用一个缓存能解决不?
  合理浪费堆硬件
  说了这么多,不知道有没有引起同学们的反思。可能大家还是过不去心里那道坎:明明有一种性能更高的方法我们为什么不用?
  因为浪费呗!
  什么?你有没有搞错?我的代码,至少省了一块内存条!那是你还没从“穷学生”的角色里转换过来。你花一周的时间对代码进行了优化(就先不考虑你的优化带来的维护成本增加了),为老板省下了一块内存条的钱。你以为老板会拍着你的肩膀表扬你么?老板打不死你!
  兄弟,账不是你那样算的。当你是学生的时候,你的时间成本是0;但你进入工作岗位,每一天都是要发工资的。
  通过代码来调高性能,是一种无奈——对硬件性能不够的妥协(参考:80年代游戏开发者的辛苦困境。这样写性能就高,但为什么现在没有谁再这么写代码了?)。否则,绝大多数情况下,堆硬件比优化代码的效果好得多,而且便宜得多。硬件的成本按摩尔定律往下降,我们程序员的工资也能按摩尔定律减么?
  明明window 10 比window 95更耗性能,为什么今天没人用window 95?为什么VS 2013要10G的空间我们都还屁颠屁颠的赶紧装上?为什么现在大家都用C#,没人用汇编?我们站在人类文明积累的今天,就应该理所当然的享受这一切成 果。有打火机你不用,你要钻木取火。如果你是因为要学贝爷荒野求生装逼,可以理解;如果你说你是因为怕浪费天然气,我……我……我怎么说你呢?“给做打火 机的一条活路,行不?”同样的,程序员大神同学,你就当做好事,给下面写底层做硬件的一条活路吧!你的代码都是 010001000010000001010101……了,你让其他人怎么活啊?
  最后,我突然想到的一个程序员为什么对性能如此敏感疯狂,对可维护性毫不在意的一个可能原因:
  性能很好理解,卡得要死和跑得飞快;可维护性很不好理解,至少得跑个两三年才能体现,那时候,谁知道爷在哪里偷着乐呢
  性能上不来,程序员只有羞愧的低着头,都是我的错;需求有变更,开口就骂,“哪个SB又要改……”;
  大家觉得是不是这样的?所以,愿意把代码百炼成钢绕指柔的人少。想来,是一种莫名的悲哀和凄凉。
  最后最后,有一些我能想到的名言警句供大家参详:
  过早的优化是万恶之源
  优化首先需要找到性能“瓶颈”。否则,任何人都可以随手一指,“这段代码需要优化”。
  可读性更强的代码总是更好优化
  硬件永远比软件便宜
  实事求是的讲,写《【野生程序员】:优先招聘》的时候,是带着情绪的。其后也有反思,是不是我杞人忧天了?尤其是下面开始的几条评论,如“都是混口饭吃的不容易”,“何以内外之分,中华儿女非山倾河泄而不能一气前指,千年亦是如此”等,让我感觉可能是我过于敏感了。但随后一些人长篇大论,让我明白,这篇博客还是有意义的。
  想一想,招聘启示里,你们要求“计算机专业本科以上学位”,我“无计算机专业相关专业文凭”优先;然后,你们就炸了!我们没有歧视,你这才是歧视!你自卑你愤青你酸你难成大器……我无力反驳,只是想说,每个人的言行都是他心灵的镜子。谢谢你们!
  其实,我没有想挑起科班/非科班之争(虽然可能结果会超出我的预料),我的本意是想给“非科班”的同学鼓气,缓解他们身上的压力,让他们看到希望,给他们力量,让他们相信,完全可以在更艰苦的环境下自学成才,而且结果不会比“科班”的差!但你一定要委下身段踏踏实实的去学,一步一个脚印的去做,自卑自大争吵辩驳都无助于你的成长。请牢记:言语没有力量!
  另外,愿意听一句的“科班”同学,“无计算机专业相关专业文凭”优先,并非完全出于义愤。都是筑基,你是名门大派用资源用丹药堆出来的,他是一路苦修战斗领悟突破的,你觉得谁更有潜力?所以啊,放下那些虚荣骄傲,真正的去战斗吧!毕业三年以后,是没人再看你的学历的。
  另外声明一点,对老赵没有任何意见,除了景仰。他针对的是培训机构我完全明白,但仍然不能赞同。所以我说,“每一次看到这一段文字,我的心里就会有一种难以言表的复杂情绪”,至于如何复杂,不是说了吗?难以言表啊。
  ======================
  好,心平气和之后我们继续讨论技术问题。在带队的过程中,性能的问题还比较好解决,最消极的想法,“好啊,多一事不如少一事,你让我不管还不简单?”,但要求写测试代码,那就炸锅了!以我的经历,“测试驱动”是一个最具争议的话题,没有之一。吹捧者和反对者泾渭分明,而且都有大量的论据和证明。记得曾经有一篇文章,大意是:“公司付钱给你不是让你写测试代码的”,下面一片狂赞。
  在我自己的项目开始的时候,我是放弃了测试驱动的(呵呵,还找到了原文),里面总结得很准确,最大的原因是“懒”。但最后让我下定决心开始“测试驱动”实践的,是我一次花了两天一夜都没调出一个Bug,垂头丧气筋疲力尽之后,无可奈何的接受了这个现实:测试还是很有用的——即使是自己写的代码。我之前的系列博客,也已经反复的强调,架构是一种“无奈”,是现实是问题驱使你去做一些其实你本来不想做的事情。你无法理解一些看起来像“脱了裤子放屁”一样的行为,通常只是因为你没有遭遇过那些现实那些问题。(看看,大学能教你这些东西么?)
  即使你没有多少开发经验,你也应该能够想象,单元测试最大的问题,就是它需要花时间花精力去写,那么这个花费是否值得呢?这还是由你架构的目标决定的,或者你的需求决定的。如果系统是一次成型交付使用,此后几乎不会更改的,那么一次性的手工测试就够了;但如果你的系统是会被“千锤百炼”的不断折腾修改的,那么这个测试就是很有必要的。最简单的考虑:每一次更改,我都要手工测试一次;那还不会如我多花点时间,弄个“自动化”的东西出来。单元测试,其实就可以理解为一种自动化的测试工具。
  但是“自动化”的理由还远远不够。因为你马上想到的,每一次需求变更代码调整,测试代码也得相应的改呀?没有测试代码,我就只需要改开发代码;现在有了单元测试,我还得再改测试代码。本来我只维护一套代码,现在我凭空增加了一套代码也需要维护,这不是增加了维护成本,不是和你“可维护性”的架构目标背道而驰了么?是一套代码好维护呢,还是两套代码更好维护?
  这是一个非常好的问题,适用于很多情景(比如分层架构,你说分层解耦,实际上还不是一改就得从UI层改到数据库,每一层都得改?)。我能给出的回答大概有:
  无论有无单元测试,开发代码进行修改之后,是不是都要进行测试?没有单元测试,并不代表你的代码就不需要测试了,只不过是你手工的去测试了一遍而已。切记:你的工作并不只是把代码写出来而已!
  进行手工测试,和更改单元测试,两者的耗费比,会根据测试重用的次数而变化。一次手工测试可能需要5分钟跑完,更改单元测试代码可能需要20分钟,但如果这测试会跑100遍,单元测试完胜手工测试。
  你说,哪里哟?什么功能会改100遍?我没说你的功能会改100遍,我说的是测试会跑100遍。有区别么?你可能还在犯迷糊,是吧?好吧,我们讲个故事。
  有一个小伙子,他很不情愿写测试代码。老板拿他没辙啊,也没那么多精力和他磨牙,于是老板自己写单元测试。这小伙子的代码提交之前要review,老板总能一次次的找出它代码的问题。他改的是登录,老板告诉他积分系统被他改出了问题;他又去改积分,老板又告诉他消息通知系统被他改坏了;他又去改消息系统,老板告诉他登录还是有问题……于是他崩溃了,“这TM什么一个烂系统”?最终他终于回过神来了,为什么老板总能知道这里的改动会影响那里呢?老板的思维有这么严谨?老板躲在一旁偷笑,就不告诉你,“其实我就是跑了一遍单元测试而已”。
  这个老板就是我。我故意的,就不一次性的告诉他所有的问题,就要这样一次次的折磨他,让他的痛苦能刻入骨子里去。最后,我还要问他:
  你现在对你的代码是不是还那么自信?
  如果没有我的review(我也是靠单元测试),你能不能发现这些问题?
  如果我们的项目已经部署到生产环境,而且你的改动带来的破坏没有被发现就上线了,会带来什么样的后果?
  这一次,他服气了。后来他用NUnit用得麻溜麻溜的。每一次改动,如果有意想不到的未通过test case,他都会很激动的给我张截图,顺便发发牢骚。我微笑不语,那种满屏绿灯通过的踏实,和意外爆出红灯之后的惊喜,没有经历过的人,是无法体会的。
  所以其实当对象间的关系变得越来越错综复杂,像一张密密麻麻的网一样之后,一个局部的改动就很有可能会触发极其复杂的连锁反应。所以为了保险起见,所有可能相关的组件都应该进行测试(所谓的“回归测试”)。这时候如果只有纯粹的手工测试,会面临两个问题:
  难以确定测试的边界(那些部分可能会被影响),这得我们脑袋凭空硬想啊,兄弟!
  极大的测试耗费。而且这种耗费是相当的无聊繁琐伤人心的——没人愿意做这种事。据说所知,现在很多公司测试人员的工资已经比开发人员还高了。为什么?简单枯燥无聊,没人愿意做啊!
  好的,我假设你已经认识到了单元测试的重要性,并开始摩拳擦掌,跃跃欲试。接下来我得给你泼一大瓢冷水:单元测试不是那么好写的!从某种程度上讲,写单元测试比写开发代码还难。难得我工作的所有公司,没有一家有过成功的案例。
  大概是几年前,我在公司修bug,老大告诉我,“你这个功能比较核心,跑一下单元测试吧”。
  “哇塞!我们有单元测试?”一种高大上的感觉迅速弥漫全身,终于见到传说中的Unit了!
  捣鼓了一会,能跑了,试试看——我的个妈呀?怎么这么多红灯?我真被吓住了,这都是我的改动造成的?
  老大就是老大,不慌不忙,“数一下有多少个通不过?”
  “啊?”我以为我听错了,数多少个通不过有什么用?得把他们全部弄通过啊?!
  搞了一会儿,才终于弄明白了,把我改动前后的代码分别跑一遍,对照一下通过失败是不是一样的,只要是一样的,就OK了。比如,以前是8个通不过,现在还是8个通不过,这样就可以了!
  我一直不明白,为什么不把那8个通不过的单元测试给弄成通过呢?这样摆着究竟算什么?直到我自己开始写单元测试。坑爹啊!到处都是坑,跳出小坑进大坑,大坑下面还连着小坑,前面是坑后面是坑,一堆一堆的连环坑……
  单元测试写出来容易跑过难!而且跑不过的原因还不是你的开发代码逻辑错了,而是测试环境/数据出问题。要测试,一定要有数据,这个数据的构建,完全不是我们所想象的那么简单。以我们创业家园项目里的积分系统为例,假设一个简单的需求:博客被点赞,博客的作者应该获得一定积分,该积分数量是由点赞人目前所有的可用币转换而得来的(已简化,具体可参考文档:积分)。要准备的数据就有:博客一篇,要有作者,作者已有积分;点赞人一名,有一定数量可用币。如果只是这样,还可以接受,但其实下面会有一堆的问题:
  作者的积分从哪里来?我们的开发代码,出于封装的考虑,用户的积分是只读的,你单元测试怎么设这个值?
  要么写代码,模拟作者通过其他行为(发布文章回答问题等)获得积分,这将开启新一轮噩梦;
  如果用Mock或者反射强行设置,事实上省略了作者获得积分的历史,所以用户“积分历史”为null,之后对其“加积分”时,就会报异常。
  更坑的是,你以为你什么都处理好了的时候,你突然悲哀的发现,这个博客得首先“被发布”,而博客一经发布,其作者就获得了一定数量的积分,所以你以前设置的积分又变了!
  ……
  点赞人的可用币,同样可能遇到类似的问题。可用币怎么设置,设置之后会不会在跑测试时被意外更改?
  点赞的行为,被封装成一个方法,运行这个方法,会检查点赞人之前是否已经对该文章点过赞,所以还应该有一个“点赞历史记录”,哪怕是空的,都得new一个,否则就空异常
  ……
  反正当时是写得我直接摔了鼠标!写得憋屈啊!而且我还是完全隔绝了数据库的,真不知道那些要从数据库里取数据来跑单元测试的,是怎么做的?这时候我一下子就明白了,实际工作中加班赶进度,一个接一个的填坑,连重构的时间都没有,怎么可能还挤得出时间来写单元测试?就算开始雄心勃勃的写了,随着系统日益复杂,维护单元测试的成本也与日俱增,甚至复杂度更甚开发,所以放弃也就成了绝大多数项目的唯一选择。
  在公司上班么,大多数人都是这样的,能推就推。我们开发写完了代码,基本上能跑了,就该交给测试人员了呀!天经地义的嘛,是不是?而且测试的时间是不会计算到我的项目开发时间里的,我总算是按时完成了开发任务。累坏了,休息一下,让测试的忙活去吧,哈哈……
  但我是个光杆司令,我没测试人员啊!曾经有那么一两个时候,我真准备招一两个测试人员的。但好在我天生的节俭美德(也就是“抠”啦)让我冷静下来。我就想啊:测试只能告诉你出了bug,不能告诉你根源啊。没有单元测试,我单步调试,不也折腾了两天了么?这是系统本身的复杂性,或者代码组织的不合理造成的,不能归咎于单元测试。不还是有这么多开源代码都有详尽的单元测试么?他们是怎么做到的呢?在单元测试上的付出,最终一定会获得超值回报!想想没有单元测试的公司,那超级庞大的测试团队,或者四处冒烟的系统,你愿意走这么一条路么?
  所以我不断的告诫自己,不要着急,冷静细致。终于一步步抽丝剥茧,把这一团乱麻一点点的归纳整理,最终还真被我找到了一条路子,一个个的单元测试都慢慢完成通过了,开发代码里潜在的一些问题也浮出水面,被我一个个的消灭。最后再跑一遍单元测试,一路绿灯,哈哈!更奇迹的是,困扰我两天的bug不知道什么时候消失了?
  后来,我看到这样一种说法:可测试的代码不一定是好代码,但坏代码几乎是不可能被测试的。深以为然!深度耦合的代码,写他们的单元测试,难于上青天;但反过来,我们可以以可测试为标准,不断的完善重构开发代码,只要这样坚持下来,最终代码的质量怎么都不会差到哪里去。
  所以,于我而言,单元测试是否有价值的争论可以休矣!不如换个角度,想一想,怎样才能把单元测试坚持下去。
  最后,如果有心的同学就会注意到,我一直用的是“单元测试”,而不是“测试驱动”。因为测试驱动是一个更广阔的概念,是一个更崭新的天地!单元测试只是其中的一小部分,在下一篇博客,我会讲解我是如何试着将测试驱动的概念运用到项目开发管理中去的。这里,需要强调的一点:先写测试。
  一上手就写开发代码,写完了才写单元测试。这是很多开发人员的习惯,我也经常犯这样的毛病,一不留神就忘了。这样做最大的问题就是,没有真正实现“测试驱动”。你实际上还是由开发在驱动,那么很自然的,测试照着开发的if...else...写一遍,有什么意义呢?这样做下去,就会不断的强化“测试无用累赘”的印象,因为测试就是简单的把开发代码重写一遍而已。我开的药方是:
  单元测试代码和开发代码由不同的人员编写
  如果做不到上面一点,先写单元测试
  如果连上面一点也做不到,直到出了bug了再写单元测试
  第三条可能有同学无法理解,不是说单元测试很重要么?为什么要等到出了bug才写?答案是:偷懒呗!记住,我们程序员是世界上最懒的人,没意义的事从来不做!你先写开发代码再写测试真的没意义,没意义就干脆不要做了。但你可以开启“乐观模式”(或者“Lazy模式”?),先乐观的认为,我的代码没问题,或许真的就没问题呢,是吧?如果真出了问题,做一个补救,这个时候就应该用单元测试把这个问题表现出来,因为他根据墨菲定律,它这里出了问题,以后就很有可能继续出问题。这个时候,就不要再偷懒了。
posted @ 2015-10-19 14:54  耀yao  阅读(638)  评论(0编辑  收藏  举报