编写优雅代码
最后更新于:2022-04-01 03:15:23
> 原文出处:http://weibo.com/p/1001643877361430185536
> 作者:秦迪,[@蛋疼的AXB](http://weibo.com/n/%E8%9B%8B%E7%96%BC%E7%9A%84AXB)
![document/2015-09-14/55f667c23be38](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/document_2015-09-14_55f667c23be38.png)
**课程大纲**
[TOC=2,2]
## 什么是好代码
对于代码质量的定义需要于从两个维度分析:主观的,被人类理解的部分;还有客观的,在计算机里运行的状况。
我把代码质量分为五个层次,依次为:
* 完成功能的代码
* 高性能的代码
* 易读的代码
* 可测试的代码
* 可扩展的代码
## 如何编写可读的代码
在很多跟代码质量有关的书里都强调了一个观点:程序首先是给人看的,其次才是能被机器执行。
### 逐字翻译
在评价一段代码能不能让人看懂的时候,可以自己把这段代码**逐字**翻译成中文,试着组成句子,之后把中文句子读给另一个人没有看过这段代码的人听,如果另一个人能听懂,那么这段代码的可读性基本就合格了。
而实际阅读代码时,读者也会一个词一个词的阅读,推断这句话的意思,如果仅靠句子无法理解,那么就需要联系上下文理解这句代码,如果简单的联系上下文也理解不了,可能还要掌握更多其它部分的细节来帮助推断。大部分情况下,理解一句代码**在做什么**需要联系的上下文越多,意味着代码的质量越差。
逐字翻译的好处是能让作者能轻易的发现那些只有自己知道的、没有体现在代码里的假设和可读性陷阱。无法从字面意义上翻译出原本意思的代码大多都是烂代码,比如“ms代表messageService“,或者“ms.proc()是发消息“,或者“tmp代表当前的文件”。
### 遵循约定
约定包括代码和文档如何组织,注释如何编写,编码风格的约定等等,这对于代码未来的维护很重要。
大家刚开始工作时,一般需要与部门的约定保持一致,包括一些强制的规定,如代码的格式化设置文件;或者一些非强制的约定,如工程的命名等。
从更大的范围考虑,整个行业的约定和规则也在不断的更新。所以在工作中也要对行业动向和开源项目保持关注,如果发现部门中哪一项约定已经过时了,那么可以随时提出来,由平台的架构师小组review之后推进平台更新这些规则。
### 文档和注释
对于文档的标准很简单,能找到、能读懂就可以了,一般一个工程至少要包含以下几类文档:
1. 对于项目的介绍,包括项目功能、作者、目录结构等,读者应该能3分钟内大致理解这个工程是做什么的。
2. 针对新人的QuickStart,读者按照文档说明应该能在1小时内完成代码构建和简单使用。
3. 针对使用者的详细说明文档,比如接口定义、参数含义、设计等,读者能通过文档了解这些功能(或接口)的使用方法。
有一部分注释实际是文档,比如javadoc。这样能把源码和注释放在一起,对于读者更清晰,也能简化不少文档的维护的工作。
还有一类注释并不作为文档的一部分,比如函数内部的注释,这类注释的职责是说明一些代码本身无法表达的作者在编码时的思考,比如“为什么这里没有做XX”,或者“这里要注意XX问题”。
一般来说函数内部注释的数量应该不会有很多,也不会完全没有,一般滚动几屏幕看到一两处左右比较正常。过多的话可能意味着代码本身的可读性有问题,而如果一点都没有可能意味着有些隐藏的逻辑没有说明,需要考虑适当的增加一点注释了。
其次也需要考虑注释的质量:在代码可读性合格的基础上,注释应该提供比代码更多的信息。文档和注释并不是越多越好,它们可能会导致维护成本增加。
## 如何编写可发布的代码
刚开始接触高并发线上系统的新同学经常容易出现一个问题:写的代码在发布之后才发现很多考虑不到的地方,比如说测试的时候没问题,项目发布之后发现有很多意料之外的状况;或者出了问题之后不知道从哪下手排查,等等。
这里介绍一下从代码编写完成到发布前需要注意的一些地方。
### 处理异常
新手程序员普遍没有处理异常的意识,但代码的实际运行环境中充满了异常:服务器会死机,网络会超时,用户会胡乱操作,不怀好意的人会恶意攻击你的系统。
对一段代码异常处理能力的第一印象应该来自于单元测试的覆盖率。大部分异常难以在开发或者测试环境里复现,依靠测试团队也很难在集成测试环境中模拟所有的异常情况。
而单元测试可以比较简单的模拟各种异常情况,如果一个模块的单元测试覆盖率连50%都不到,很难想象这些代码考虑了异常情况下的处理,即使考虑了,这些异常处理的分支都没有被验证过,怎么指望实际运行环境中出现问题时表现良好呢?
### 处理并发
而是否高质量的实现并发编程的关键并不是是否应用了某种同步策略,而是看代码中是否保护了共享资源:
* 局部变量之外的内存访问都有并发风险(比如访问对象的属性,访问静态变量等)
* 访问共享资源也会有并发风险(比如缓存、数据库等)。
* 被调用方如果不是声明为线程安全的,那么很有可能存在并发问题(比如java的hashmap)。
* 所有依赖时序的操作,即使每一步操作都是线程安全的,还是存在并发问题(比如先删除一条记录,然后把记录数减一)。
前三种情况能够比较简单的通过代码本身分辨出来,只要简单培养一下自己对于共享资源调用的敏感度就可以了。
但是对于最后一种情况,往往很难简单的通过看代码的方式看出来,甚至出现并发问题的两处调用并不是在同一个程序里(比如两个系统同时读写一个数据库,或者并发的调用了一个程序的不同模块等)。但是,只要是代码里出现了不加锁的,访问共享资源的“先做A,再做B”之类的逻辑,可能就需要提高警惕了。
### 优化性能
性能是评价程序员能力的一个重要指标,但要评价程序的性能往往要借助于一些性能测试工具,或者在实际环境中执行才能有结果。
如果仅从代码的角度考虑,有两个评价执行效率的办法:
* 算法的时间复杂度,时间复杂度高的程序运行效率必然会低。
* 单步操作耗时,单步耗时高的操作尽量少做,比如访问数据库,访问io等,这里需要建立对各种操作的耗时的概念。
而实际工作中,也会见到一些同学过于热衷优化效率,相对的会带来程序易读性的降低、复杂度提高、或者增加工期等等。所以在优化之前最好多想想这段程序的瓶颈在哪里,为什么会有这个瓶颈,以及优化带来的收益。
再强调一下,无论是优化不足还是优化过度,判断性能指标最好的办法是用数据说话,而不是单纯看代码。
### 日志
日志代表了程序在出现问题时排查的难易程度,对于日志的评价标准有三个:
* 日志是否足够,所有异常、外部调用都需要有日志,而一条调用链路上的入口、出口和路径关键点上也需要有日志。
* 日志的表达是否清晰,包括是否能读懂,风格是否统一等。这个的评价标准跟代码的可读性一样,不重复了。
* 日志是否包含了足够的信息,这里包括了调用的上下文、外部的返回值,用于查询的关键字等,便于分析信息。
对于线上系统来说,一般可以通过调整日志级别来控制日志的数量,所以打印日志的代码只要不对阅读造成障碍,基本上都是可以接受的。
## 如何编写可维护的代码
可维护性要对应的是未来的情况,但是一般新人很难想象现在的一些做法会对未来造成什么影响。
### 避免重复
代码重复分为两种:模块内重复和模块间重复。两种重复都在一定程度上说明了编码的水平有问题。现代的IDE都提供了检查重复代码的工具,只需点几下鼠标就可以判断了。
除了代码重复之外,还会出现另一类重复:信息重复。
比方说每行代码前面都写一句注释,一段时间之后维护的同学忘了更新注释,导致注释的内容和代码本身变得不一致了。此时过多的注释反而成了鸡肋,看之无用,删之可惜。
随着项目的演进,无用的信息会越积越多,最终甚至让人无法分辨哪些信息是有效的,哪些是无效的。
如果在项目中发现好几个东西都在做同一件事情,比如通过注释描述代码在做什么,或者依靠注释替代版本管理的功能,这些都是需要避免的。
### 划分模块
模块内高内聚与模块间低耦合是大部分设计遵循的标准,通过合理的模块划分能够把复杂的功能拆分为更易于维护的更小的功能点。
一般来说可以从代码长度上初步评价一个模块划分的是否合理,一个类的长度大于2000行,或者一个函数的长度大于两屏幕都是比较危险的信号。
另一个能够体现模块划分水平的地方是依赖。如果一个模块依赖特别多,甚至出现了循环依赖,那么也可以反映出作者对模块的规划比较差,今后在维护这个工程的时候很有可能出现牵一发而动全身的情况。
有不少工具能提供依赖分析,比如IDEA中提供的Dependencies Analysis功能,学会这些工具的使用对于评价代码质量会有很大的帮助。
值得一提的是,绝大部分情况下,不恰当的模块划分也会伴随着极低的单元测试覆盖率:复杂模块的单元测试非常难写的,甚至是不可能完成的任务。
### 简洁与抽象
只要提到代码质量,必然会提到简洁、优雅之类的形容词。简洁这个词实际涵盖了很多东西,代码避免重复是简洁、设计足够抽象是简洁,一切对于提高可维护性的尝试实际都是在试图做减法。
编程经验不足的程序员往往不能意识到简洁的重要性,乐于捣鼓一些复杂的玩意并乐此不疲。但复杂是代码可维护性的天敌,也是程序员能力的一道门槛。
跨过门槛的程序员应该有能力控制逐渐增长的复杂度,总结和抽象出事物的本质,并体现到自己设计和编码中。一个程序的生命周期也是在由简入繁到化繁为简中不断迭代的过程。
简洁与抽象更像是一种思维方式,除了要理解、还需要练习。多看、多想、多交流,很多时候可以简化的东西会大大超出原先的预计。
## 如何做出优雅的设计
当程序的功能越来越多时,编程就不再只是写代码,而会涉及到模块的划分、和模块之间的交互等内容。对于新同学来说,一开始很难写出优雅的设计。
这一节会讨论一下如何能让自己编写的代码有更强的“设计感”。
### 参考设计模式
最容易快速上手的提升自己代码设计水平的方式就是参考其他人的设计,这些前人总结的面对常见场景时如何进行模块划分和交互的方式被称作设计模式。
### 设计模式的分类
* 创建型模式主要用于创建对象。
* 结构型模式主要用于处理类或对象的组合。
* 行为型模式主要用于描述对类或对象怎样交互和怎样分配职责。
由于篇幅有限,这里不再展开每一种设计模式的用途。这部分资料和书籍已经比较全了,可以课下学习。
### 编写单元测试
### 单元测试是什么
维基百科上的词条说明:
> 单元测试是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
所以,我眼中的“合格的”单元测试需要满足几个条件:
1. 测试的是一个代码单元内部的逻辑,而不是各模块之间的交互。
2. 无依赖,不需要实际运行环境就可以测试代码。
3. 运行效率高,可以随时执行。
### 单元测试的目的
了解了单元测试是什么之后,第二个问题就是:单元测试是用来做什么的?
很多人第一反应是“看看程序有没有问题”。但是,只是使用单元测试来“看看程序有没有问题”的话,效率反而不如把程序运行起来直接查看结果。原因有两个:
1. 单元测试要写额外的代码,而不写单元测试,直接运行程序也可以测试程序有没有问题。
2. 即使通过了单元测试,程序在实际运行的时候仍然有可能出问题。
在这里我总结了一下几个比较常见的单元测试的几个典型场景:
1. 开发前写单元测试,通过测试描述需求,由测试驱动开发。
2. 在开发过程中及时得到反馈,提前发现问题。
3. 应用于自动化构建或持续集成流程,对每次代码修改做回归测试。
4. 作为重构的基础,验证重构是否可靠。
还有最重要的一点:编写单元测试的难度和代码设计的好坏息息相关,单元测试测的三分是代码,七分是设计,能写出单元测试的代码基本上可以说明这段代码的设计是比较合理的。能写出和写不出单元测试之间体现了编程能力上的巨大的鸿沟,无论是什么样的程序员,坚持编写一段时间的单元测试之后,都会明显感受到代码设计能力的巨大提升。
### 如何编写单元测试
测试代码不像普通的应用程序一样有着很明确的作为“值”的输入和输出。举个例子,假如一个普通的函数要做下面这件事情:
* 接收一个user对象作为参数
* 调用dao层的update方法更新用户属性
* 返回true/false结果
那么,只需要在函数中声明一个参数、做一次调用、返回一个布尔值就可以了。但如果要对这个函数做一个“纯粹的”单元测试,那么它的输入和输出会有很多情况,比如其中一个测试是这样:
* 假设调用dao层的update方法会返回true。
* 程序去调用service层的update方法。
* 验证一下service是不是也返回了true。
而具体的测试内容可以依赖单元测试框架提供的功能来完成。
### 单元测试框架
运行框架:
* jUnit
* TestNG
* Spock
Mock框架:
* Mockito
* EasyMock
* PowerMock
* Spock
由于篇幅限制,这里不再展开具体的框架用法了,有兴趣的同学可以自行搜索相关文章。
## 如何规划合理的架构
很多新同学的规划都是未来成为架构师,要做架构师就免不了设计架构。而在微博平台工作也会经常跟架构打交道,由于后面有独立的架构课程,这里只是简单介绍一下常见的架构模式。
### 常见的架构模式
### 分层架构
分层架构是应用很普遍架构模式,它能降低模块之间的耦合,便于测试开发,它也是程序员需要掌握的基础。
典型的分层架构模式如下:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-14_55f666c340fe1.jpg)
上图中是一个4层的架构,在现实场景中,层数不是一个定值,而是需要根据业务场景的复杂度决定。使用分层模型中需要注意,一般来说不能跨层、同层或反向调用,否则会让整个层次模型由单一的树形结构变为网状结构,失去了分层的意义。
但随着程序复杂度的逐渐提升,要严格的按照分层模型逐级调用的话,会产生很多无用的空层,它们的作用只是传递请求,这也违背了软件设计尽量简洁的方向。所以实际场景中可以对各个层次规定“开放”或“关闭”属性,对于“开放”的层次,上层可以越过这层,直接访问下层。
对层次定义“开放”或“关闭”可以帮助程序员更好的理解各层次之间的交互,这类约定需要记录在文档中,并且确保团队中的每个人都了解这些约定,否则会得到一个复杂的、难以维护的工程。
### 事件驱动架构
事件驱动架构能比较好的解耦请求方和处理方,经常被用在写入请求量变化较大,或者是请求方不关心处理逻辑的场景中,它有两种主要的实现方式:
### Mediator
在mediator方式中,存在一个中介者角色,它接收写入方的请求,并把事件分配到对应的处理方(图中的channel),每个处理方只需要关心自己的channel,而不需要与写入方直接通信。
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-14_55f666c3c2c99.jpg)
在微博前些年应用比较多的应用服务-队列-消息处理服务可以认为是属于这种模式。
### Broker
在broker方式中不存在中介者的角色,取而代之的是消息流在轻量的processor中流转,形成一个消息处理的链路,如图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-14_55f666c45bae5.jpg)
前一段时间开始推广的storm流式处理属于这种模式,对于较长的处理流程,用broker方式可以避免实现Mediator的复杂性,相对的,管理整个流程变得复杂了。
### 微内核架构(Microkernel)
微内核架构相对于普通架构最主要的区别是多了“内核”的概念,在编写程序时把基础功能和扩展功能分离:内核中不再实现具体功能,而是定义“扩展点”,增加功能时不再修改主逻辑,而是通过“扩展点”挂接到内核中,如图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-14_55f666ca344f1.jpg)
之前介绍的motan RPC框架应用了这种设计,这让motan可以通过不同的插件支持更多的功能,比如增加传输协议、定义新的服务发现规则等。
### 微服务架构
近年来微服务架构的概念一直比较火,它可以解决服务逐渐增长之后造成的难以测试及部署、资源浪费等问题,但也带来了服务调度和服务发现层面的复杂度,如图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-14_55f666ca53098.jpg)
微博底层实际包含了很多业务逻辑,这些业务逻辑被抽象成一个个服务和模块,不同模块之间通过motan rpc、grpc或http rest api进行通信,通过docker和之上的调度服务进行调度和部署,最终成为一个完整的系统。
微服务隔离了各服务之间的耦合,能够有效提升开发效率;除此之外,当微博面对巨大的流量峰值时,可以进行更精细的资源调配和更有效率的部署。
### 单元化架构
传统的分层架构往往会存在一些中心节点,如数据库、缓存等,这些节点往往容易成为性能瓶颈,并且存在扩容比较复杂的问题。
在面临对扩展性和性能有极端要求的场景时,可以考虑使用单元化架构:对数据进行切分,并将每一部分数据及相关的逻辑部署在同一个节点中,如图:
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-09-14_55f666ca7cde5.jpg)
在单元化架构中,每个“单元”都可以独立部署,单元中包括独立的计算和存储模块;计算模块只与单元内的存储模块交互,不再需要分库分表等逻辑;而数据与存储更近也降低了网络消耗,进而提高了效率。
微博平台通过对群发服务的单元化改造,实现了百万级每秒的私信推送服务。
## 如何处理遗留代码
对于一个不断发展的系统,必然有一些遗留下来的历史问题。当遇到了遗留下来的烂代码时,除了理解和修改代码,更重要的是如何让代码朝着好的方向发展,而不是放任不管。
重构是处理遗留代码的比较重要的手段,这一节主要讨论一下重构的相关话题。
### 何时重构
新同学往往对重构抱有恐惧感,认为重构应该找一个比较长的时间专门去做。这种愿望很好,但是现实中一般很难找出一段相对稳定的时间。
另一方面,重构是比较考验编程水平的一项工作。代码写的不好的人,即使做了重构也只是把不好的代码变了个形式。要达到比较高的水平往往需要不断的练习,几个月做一次重构很难得到锻炼,重构效果也会打折。
所以,重构最好是能够作为一项日常工作,在开发时对刚写完的代码做重构往往单位时间的收益是最大的。
### 如何重构
一般来说,重构可以抽象成四个方面:
#### 理解现状
如果对当前程序的理解是错的,那么重构之后的正确性也就无从谈起。所以在重构之前需要理解待重构的代码做了什么,这个过程中可以伴随一些小的、基本无风险的重构,例如重命名变量、提取内部方法等,以帮助我们理解程序。
#### 理解目标
在理解了程序做了什么事情之后,第二个需要做的事情就是需要提前想好重构之后的代码是什么样的。
改变代码结构比较复杂,并且往往伴随着风险和不可控的问题。所以在实际动手之前,需要从更高的层次考虑重构之后的模块如何划分,交互是如何控制等等,在这个过程中实际与写代码要做的事情是一致的。
#### 划分范围
烂代码往往模块的划分有一些问题,在重构时牵一发而动全身,改的越多问题越多,导致重构过程不可控。所以在动手重构前需要想办法减少重构改动的范围,一般来说可以只改动相邻层次的几个类,并且只改动一个功能相关的代码,不求一次性全部改完。
为了能够划分范围,在重构时需要采用一些方法解除掉依赖链。比如增加适配器等等,这些类可能只是临时的,在完整的重构完成之后就可以删除掉,看起来是增加了工作量,但是换来的是更可控的影响范围。
#### 确保正确
为了能保证重构的正确性,需要一些测试来验证重构是否安全。最有效的是单元测试,它能提供比集成测试更高的覆盖率,也能验证重构之后的代码设计是否是合理的。
在做一次重构之前需要整理模块的单元测试。遗留代码有可能测试不全,并且难以编写单元测试,此时可以适当的牺牲待重构代码的优雅性,比如提高私有方法的可见性,满足测试的需求。在重构的过程中,这部分代码会被逐渐替换掉。
## 总结
今天跟大家讨论了一下关于编程的各个方面,关于编程的话题看似很基础,想要做好却并不容易。新同学比较容易急于求成,往往过多的关注架构或者某些新技术,却忽视了基本功的修炼,而在后续的工作过程中,基本功不扎实的人做事往往会事倍功半,难以有更一步的发展。
勿在浮沙筑高台,与各位共勉。
**新兵训练营简介**
微博平台新兵训练营活动是微博平台内部组织的针对新入职同学的团队融入培训课程,目标是团队融入,包括人的融入,氛围融入,技术融入。当前已经进行4期活动,很多学员迅速成长为平台技术骨干。
具体课程包括《环境与工具》《分布式缓存介绍》《海量数据存储基础》《平台RPC框架介绍》《平台Web框架》《编写优雅代码》《一次服务上线》《Feed架构介绍》《unread架构介绍》
微博平台是非常注重团队成员融入与成长的团队,在这里有人帮你融入,有人和你一起成长,也欢迎小伙伴们加入微博平台,欢迎私信咨询。
**讲师简介**
秦迪,[@蛋疼的AXB](http://weibo.com/n/%E8%9B%8B%E7%96%BC%E7%9A%84AXB) 微博平台及大数据部技术专家
个人介绍:http://blog.2baxb.me/about-me