Teaser Image

mindwind

十日画一水,五日画一石




曾经有读者在文章后留言问过一个关于编程的问题:

对于我们刚入职的来说,是想到哪写到哪,不对了再改再验证的好?还是花时间找出大体思路,有步骤有计划的具体问题具体分析的好?

关于这个问题我当时回答当然是后者好,这个问题的答案看上去很明显,但为什么初学者会产生这样的疑问呢?我陷入了对这个问题的思索,回溯追忆着自己从学习编程到从业至今的经历,感觉编程其实是这样一件事,它大概会经历三个阶段。

Debugging

翻译是把一种语言的文字转变为另一种语言的文字,而编程就是把用自然语言描述的现实问题转变为用程序语言来描述并解决的过程,所以编程和翻译不无想通之处。

三年多以前我偶然读到一篇英文的关于性能的好文章(对,就是那篇 《认清性能问题》 ),就忍不住想翻译过来。那是我第一次尝试翻译长文,老实说翻得很痛苦,断断续续花了好几周的业余时间。当时初次翻译的那种感觉就和刚学编程时感到得差不多,对于翻译那时的我就如给我留言的读者面临编程一样都是初学者(现在对于翻译还依然是初学者,虽然过去了几年,但并没有进行足够时间的理论和实践训练)。

初次翻译时,免不了不少单词或组合不熟悉,一路磕磕碰碰的查词典或 Google。一些似乎能理解含义的句子,却感觉无法很好的用中文来表达,如果直白的译出感觉完全不像正常的中文句子表达方式。如似种种的磕碰之处,难道不像刚学编程时候的情形么?

刚开始写代码,甚至对语法都掌握不熟,对各种库和 API 不知道、不了解、不熟悉。一路写代码,翻翻书、查查 Google,搜搜 API 文档,好不容易写完一段代码,却没有信心能否执行、执行能否正确。

小心翼翼的点击 Debug 按钮开始了单步调试之旅,一步步验证所有的变量或执行结果是否符合预期。如果出错了,是在哪一步开始或哪个变量出错的。一段不到一屏的代码,足足单步走了半小时,反复改了好几次,终于顺利执行完毕,按预期输出了执行结果。

如果不是自己写全新的代码,而是一来就接手了别人的代码,没有文档,前辈稍微给你介绍几句,你就很快又开始了 Debug 单步调试之旅,一步步搞清代码运行的所有步骤和内部逻辑。根据你接手代码的规模,这个阶段可能持续数天到数周不等。

这就是我感觉可以划为编程第一阶段的 「Debugging」时期。这个时期或长或短,我们曾经为各种编程工具或 IDE 提供的高级 Debugging 功能能激动不已,甚或感激涕零,但如果你不逐渐将使用 Debugging 功能从高频降为低频,那么你可能很难走入第二阶段。

Coding

翻译讲究「信、达、雅」,那么何谓「信、达、雅」?它是由我国清末新兴启蒙思想家严复提出的,他在《天演论》中的 “译例言” 讲到:

译事三难:信、达、雅。求其信已大难矣,顾信矣,不达,虽译犹不译也,则达尚焉。

信,指不违背原文,不偏离原文,不篡改,不增不减,要求准确可信地表达原文描述的事实。

这条应用在编程上就是需要程序员深刻的理解用户的原始需求,虽然需求很多时候来自于需求文档,但需求文档上写的并不一定真正体现了用户的原始需求。就好像一百多年前福特问用户需要什么,用户回答说需要一匹更快的马,但真实的原始需求是能更快的到达目的地。

只有真正理解了用户的原始需求,最后通过编程实现的程序系统才是符合「信」的标准。但在这一条的修行上几乎没有止境,因为要做到「信」的标准,编写行业软件程序的程序员需要在一个行业长期沉淀,才能慢慢搞明白用户的真实需求。要不然用户说需要一匹更快的马,我们就跑去“养”只更壮更快的马,后来用户需求又变了,说要让马能在天上飞,程序员就傻眼了,拒绝用户说:你这需求不合理,技术上实现不了。

达,指不拘泥于原文的形式,表达通顺明白,让读者对所述内容明达。

按严复的标准,只满足「信」一条的翻译,还不如不译,至少还需要满足「达」这条才算尚可。这条应用在编程上就是在说程序的可读性、可理解性和可维护性。程序是写给机器去执行的,但却是给人看的。只满足「信」这一条的程序能准确的满足用户的需要,但没有「达」则很难维护下去。

所有关于代码规范和风格的编程约束都是在约定「达」的标准。曾经读到过一篇文章《如何防止代码腐烂》,里面有一些特形象的示例图来体现不同程序员关于代码的「达」的标准,这里部分引用如下(两条分割线之间):


新手的代码

进阶者的代码
小规模时

大规模时

进阶者已经知道如何设计代码,懂得代码规范,但一般局限于一个模块。规模一大,模块间的调用就会比较混乱,难以维护。

更有经验的代码

更有经验者的代码,模块内部代码整洁,模块之间层次清晰,有设计模式,有成熟的体系。可以保持长期的代码整洁。

那么一个团队里面会出现什么情况呢?似乎,我们只要让一堆有经验的人来开发,那么代码必然不会出什么问题。可惜,事实不是这样。

一个团队的程序员的代码风格有多样性,有这样的 也有这样的 放眼一看,会发现不同的代码风格,不同的设计思想,不同的设计理念,每个程序员都有自己的代码个性。

一个团队内部有新人,有熟手,有牛人,一个设计好的架构可能会变坏。

当程序员 A 和程序员 B 在一起的时候,会有如下变化:

原本整洁的代码变得不整洁了。


如上,个人可以通过编程实践用时间来堆积经验,逐渐达到「达」的标准。但一个团队中的程序员代码风格差异如何解决?这就像如果一本书由一群人来翻译,你会发现每章的文字风格都有差异,所以我是超级不喜欢读一群人一起翻译的书。

原文《如何防止代码腐烂》中给出的解决方案是:多沟通,深入理解别人的代码思路和风格,不要轻易盲目的修改。但实际上这些年下来,这个方法在实践中走的并不顺畅。

如今的微服务架构风行,倒是提供了另一种解决方案,每个服务一个唯一的 Owner,长期由一个人来维护的代码,就不会那么容易腐烂,因为一个人不存在沟通的问题。而一个人所能「达」到的层次,完全由个人的经验水平和追求来决定。

雅,指选用的词语要得体,追求文章本身的古雅,简明优雅。

雅的标准,应用在编程上其实就是我曾在之前的文章 《编程的艺术门槛》 所阐述的境界,当然那是很高的要求了,只有先满足于「信」和「达」的要求,我们有余力再来追求「雅」。

下面是一个例子,同一个方法,实现了完全一样的功能,都符合「信」的要求,而方法很短小,命名也完全符合规范,可理解性和维护性都没问题,符合「达」的要求,唯一的差别就在追求「雅」上的不同。

方法生成一个字符串 key 值,传入两个参数:服务全类名和方法名,然后返回 key 值,key 的长度受外部条件约束不能超过 50 个字符。

private String generateKey(String service, String method) {  
    String head = "DBO$";  
    String key = "";  

    int len = head.length() + service.length() + method.length();  
    if (len <= 50) {  
        key = head + service + "." + method;  
    } else {  
        service = service.substring(service.lastIndexOf(".") + 1);  
        len = head.length() + service.length() + method.length();  
        key = head + service + "." + method;  
        if (len > 50) {  
            key = head + method;  
            if (key.length() > 50) {  
                key = key.substring(0, 48) + ".~";  
            }  
        }  
    }  

    return key;  
}

方法实现不复杂,很短,看起来也不错,分析下逻辑:

  1. 首先 key 由固定的头(head)+ service(全类名)+ method(方法)组成,若小于 50 字符,直接返回。
  2. 若超过 50 字符限制,则去掉包名,保留类名,再判断一次,若此时小于 50 字符则返回。
  3. 若还是超过 50 字符限制(可能有个变态的家伙起了个很长的类名或方法名),则连类名一起去掉,保留头和方法再判断一次若小于 50 字符则返回。
  4. 最后如果有个变态长的方法(46+个字符),没办法,只好暴力截断到 50 字符返回。

这个实现最大限度的在生成的 key 中保留全部的有用信息,对超过限制的情况依次按信息重要程度的不同进行丢弃。这里只有一个问题,这个业务规则只有 4 个判断,实现进行了三次 if 语句嵌套,还好这个方法比较短,可读性还不成问题。

而现实中很多业务规则比这复杂的多,以前看过一些实现的 if 嵌套多达 10 层的,方法也长的要命。当然一开始没有嵌套那么多层,只是后来随着时间的演变,业务规则发生了变化,慢慢增加了。后来的程序员就按照这种方式继续嵌套下去,慢慢演变至此,到我看到的时候就有 10 层了。

程序员有一种编程的惯性,特别是进行维护性编程时,一开始接手一个别人做的系统,不可能一下能了解和掌控全局。当要增加新功能时,在原有代码上添加逻辑,很容易保持原来程序的写法惯性(考虑这样写更安全)。所以一个 10 层嵌套 if 的业务逻辑方法实现,第一个程序员也许只写了 3 次嵌套,感觉还不错不失简洁。后来写 4、5、6 层的程序员就是懒惰不愿再改,到了写第 8、9、10 层的程序员时基本很可能就是不敢再乱动了。

那么如何让这个小程序在未来的生命周期内,更优雅的演变下去?下面是另一个版本实现:

private String generateKey(String service, String method) {  
    String head = "DBO$";  
    String key = head + service + "." + method;  

    // head + service(with package) + method  
    if (key.length() <= 50) {  
        return key;  
    }  

    LOG.info("key = " + key);  

    // head + service(without package) + method  
    service = service.substring(service.lastIndexOf(".") + 1);  
    key = head + service + "." + method;  
    if (key.length() <= 50) {  
        return key;  
    }  

    // head + method  
    key = head + method;  
    if (key.length() <= 50) {  
        return key;  
    }  

    // last, we cut the string to 50 characters limit.  
    key = key.substring(0, 48) + ".~";  
    return key;  
}

从嵌套变成了顺序逻辑,为未来的程序员留下更优雅的编程惯性方向。

Running

编程与翻译类比,相对翻译超越信达雅的部分在于:文字写出来能让人读懂,读爽就够了,代码写出来还需要运行才能产生最终的价值。

曾经,我在《程序员的成长阶梯和级别定义》一文中定义高级程序员不仅开发的程序又快又好,而且还能清晰的定义出多快多好。开发程序「又快又好」说明开发效率高,并且写出的程序符合「信、达、雅」的标准,但清晰定义「多快多好」则是指运行时的最终效果和效率。

需要准确评估代码的运行效果和效率,每个程序员可能都需要深刻的记住并理解下面这张图:

只有深刻记住并理解了程序运行各环节的效率数据,你才有可能接近准确地评估程序运行的最终效果。当然,上面这张图只是最基础的程序运行效率数据,实际的生产运行环节会需要更多的基准效率数据你才可能做出更准确的预估。

说一个例子,最近我们团队一个高级程序员和我讨论要在所有的微服务中引入一个限流开源工具。这对于他和我们团队都是一个新东西,如何去做引入后线上运行效果的评估?首先,他去阅读资料和代码搞懂该工具的实现原理和机制并能清晰的描述出来,这是第一步。再接着去对该工具进行效果测试,又称功能可用性验证,这是第二步。最后,再进行基准性能测试,或者又叫基准效率测试(Benchmark),以确定符合预期的标准。

做完上述三步,他拿出一个该工具的原理性描述演示说明文档,一份样例使用代码和一份基准效率测试结果,如下:

第一次测试,我们的样例代码编写逻辑是:通过模拟限流发生时抛出异常。这种情况导致限流发生时,单实例的服务性能下降非常大(降幅 98%,如图)。开始以为是这个新工具的问题,后来仔细分析才发现是异常抛出太频繁的问题,属于使用不当问题。再改变逻辑为限流发生时不抛异常,返回错误代码,运行效率一切平稳。

另外,上图中有个红色字体部分,当阀值设置为 100 万而请求数超过 100 万时,发生了很大偏差。这是一个很奇怪的测试结果,但如果心里对各种基准效率数据有谱的话,会知道这实际绝不会影响线上服务的运行。

因为一个服务主要由两部分组成:RPC 和业务逻辑。而 RPC 又由网络通信加上编解码序列化组成。我们的服务都是 Java 实现的,而 Java 最高效且吞吐最大的网络通信方式是基于 NIO 的方式,而我们服务使用的 RPC 框架正是基于 Netty 的(一个基于 Java NIO 网络通信框架)。

我曾经单独在一组 4 核的物理主机上测试过 Java 原生 NIO 和 Netty V3 和 V4 两个版本的基准性能对比,经过 Netty 封装后,大约有 10% 的性能损耗。而原生的 Java NIO 在当时的测试环境所能达到 TPS 极限(就是继续加压,但 TPS 不再上升,CPU 也消耗不上去,延时却在增加)在 1K 报文下大约在 5 万出头,而 Netty 在 4.5 万附近。增加了 RPC 的编解码后,TPS 极限下降至 1.3 万左右。

所以,实际一个服务在类似基准测试的环境下(事实上,我们的生产环境目前多数也是虚拟的 4 核 Docker)单实例所能承载的 TPS 极限不可能超过 RPC 的上限,因为 RPC 是没有包含业务逻辑的部分。算上不算简单的业务逻辑,我能预期的单实例 TPS 也许只有 1~2K。

所以上面 100 万的阀值偏差是绝对影响不到单实例的服务的。当然最后我们也搞明白了,100 万的阀值偏差来自于时间精度的大小,那个限流工具采用了微秒作为最小时间精度,所以只能在百万级的范围内保证准确。

讲完上述例子,就是想说明一个程序员要想精确的评估程序的运行效果和效率,就得自己动手做大量的基准测试。基准测试和测试人员做的性能测试不同,测试人员做的性能测试都是针对真实业务综合场景的模拟,测试的是整体系统的运行。而基准测试是开发人员自己做来帮准理解程序运行效果和效率的方式,当测试人员的性能测试发现了系统的性能问题时,开发人员才可能一步步拆解根据基准测试的标尺效果找到真正的瓶颈点,否则大部分的性能优化都是靠猜测。

. . .

最后,关于文初的那个问题,有答案了么?不是想到哪写到哪,而是写到哪,一段鲜活的代码就成了你想的那样。写出来就是对的,没有再验证再修改,中间的思路分析、计划和步骤越来越少,说明你写代码的功力自然越来越深。


写点文字,画点画儿。 微信公众号「瞬息之间」,遇见了不妨关注看看。