目录
一、什么是重构
如书的序言所说,重构是这样一个过程:再不改变外部代码的前提下,对代码做出修改,改进程序的内部结构,重构是一种经过千锤百炼形成的有条不紊的程序整理方法,可以最大程度减少整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。
- 重构(名词): 对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
- 重构(动词): 使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。
二、重构的目的和时机
2.1 目的
重构不是包治百病的灵丹妙药,也绝对不是所谓的“银弹”。重构只是一种工具,能够帮助你始终良好的控制代码而已。使用它,可能基于下面的几个目的。
这里有一个有意思的科普(引用自百度百科: 没有银弹
):在民俗传说里,所有能让我们充满梦靥的怪物之中,没有比狼人更可怕的了,因为它们会突然地从一般人变身为恐怖的怪兽,因此人们尝试着查找能够奇迹似地将狼人一枪毙命的银弹。我们熟悉的软件项目也有类似的特质(以一个不懂技术的管理者角度来看),平常看似单纯而率直,但很可能一转眼就变成一只时程延误、预算超支、产品充满瑕疵的怪兽,所以,我们听到了绝望的呼唤,渴望有一种银弹,能够有效降低软件开发的成本,就跟电脑硬件成本能快速下降一样。
2.1.1 改进软件的设计
当人们只为短期目的而修改代码时,他们经常没有完全理解架构的整体设计。于是代码逐渐失去了自己的结构。程序员越来越难以通过阅读代码来理解原来的设计。代码结构的流失有累积效应。越难看出代码所代表的设计企图,就越难以保护其设计,于是设计就腐败得越快。
完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事情,因此改进设计的一个重要方向就是消除重复代码。消除重复代码,我就可以确定所有事物和行为在代码中只表述一次,这正是优秀设计的根本。
2.2.2 使软件更容易理解
所谓程序设计,很大程度上就是与计算机对话:我编写代码告诉计算机做什么,而它的响应是按照我的指示精确行动。一言以蔽之,我所做的就是填补“我想要它做什么”和“我告诉它做什么”之间的缝隙。编程的核心就在于“准确说出我想要的”。
然而别忘了,除计算机之外,源码还有其他读者,并且很大概率还是几个月后的自己,如何更清晰地表达我想要做的,这可能就需要一些重构的手法。
这里我联想到了软件设计的 KISS 原则:KISS 原则, Keep It Simple and Stupid ,简单的理解这句话就是,要把一个系统做的连白痴都会用。
2.2.3 帮助找到 BUG
对代码的理解,可以帮助找到系统中存在的一些 BUG。搞清楚程序结构的同时,也可以对自己的假设做一些验证,这样一来 BUG 想不发现都难。
Kent Beck 经常形容自己的一句话是:“我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的的程序员。”重构能够帮助我们更有效地写出健壮的代码。
2.2.4 提高编程速度
听起来可能有些反直觉,因为重构可能会花大量的时间改善设计、提高阅读性、修改 BUG,难道不是在降低开发速度嘛?
软件开发者交谈时的故事:一开始他们进展很快,但如今想要添加一个新功能需要的时间就要长得多。他们需要花越来越多的时间去考虑如何把新功能塞进现有的代码库,不断蹦出来的bug修复起来也越来越慢。代码库看起来就像补丁摞补丁,需要细致的考古工作才能弄明白整个系统是如何工作的。这份负担不断拖慢新增功能的速度,到最后程序员恨不得从头开始重写整个系统。
下面这幅图可以描绘他们经历的困境。
但有些团队的境遇则截然不同。他们添加新功能的速度越来越快,因为他们能利用已有的功能,基于已有的功能快速构建新功能。
两种团队的区别就在于软件的内部质量。需要添加新功能时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。良好的模块划分使我只需要理解代码库的一小部分,就可以做出修改。如果代码很清晰,我引入 BUG 的可能性就会变小,即使引入了 BUG,调试也会容易得多。理想情况下,代码库会逐步演化成一个平台,在其上可以很容易地构造与其领域相关的新功能。
这种现象被作者称为“设计耐久性假说”:通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。目前还无法科学地证明这个理论,所以说它是一个“假说”。
20年前,行业的陈规认为:良好的设计必须在开始编程之前完成,因为一旦开始编写代码,设计就只会逐渐腐败。重构改变了这个图景。现在我们可以改善已有代码的设计,因此我们可以先做一个设计,然后不断改善它,哪怕程序本身的功能也在不断发生着变化。由于预先做出良好的设计非常困难,想要既体面又快速地开发功能,重构必不可少。
2.2 重构的时机
- 添加功能时重构
- 修补错误时重构
- 复审代码时重构
2.3 重构的难题
2.3.1 数据库
原因一:程序和数据库关联过于紧密耦合
原因二:数据迁移
解决:再对象模型和数据库模型之间加入一个分隔层,可以隔离两个模型各自的变化。
2.3.2 修改接口
当你要修改某个函数名时,留下旧函数,让他调用新函数,而不是赋值函数实现。
2.3.3 难以通过重构首发完成的设计改动
2.3.4 何时不该重构
重构并不是必要,当然也有一些不那么需要重构的情况:
- 不需要修改,那些丑陋的代码能隐藏在一个 API 之下。 只有当我需要理解其工作原理时,对其进行重构才会有价值;
- 重写比重构容易。 这可能就需要良好的判断力和丰富的经验才能够进行抉择了。
三、“坏”代码
可以好好看下《阿里巴巴开发手册》
- 神秘命名
- 重复代码
- 过长函数
- 过长参数列表
- 全局数据: 全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。一次又一次,全局数据造成了一些诡异的 BUG,而问题的根源却在遥远的别处。
- 可变数据: 对数据的修改经常导致出乎意料的结果和难以发现的 BUG。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据。
- 发散式变化: 模块经常因为不同的原因在不同的方向上发生变化。
- 散弹式修改: 每遇到某种变化,你都必须在许多不同的类内做出许多小修改。
- 依恋情结: 所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流。
- 数据泥团: 你经常在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。
- 基本类型偏执: 很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。
- 重复的 switch: 在不同的地方反复使用相同的 switch 逻辑。问题在于:每当你想增加一个选择分支时,必须找到所有的 switch,并逐一更新。考虑利用多态替换他。
- 循环语句: 我们发现,管道操作(如 filter 和 map)可以帮助我们更快地看清被处理的元素一级处理它们的动作。
- 冗余的元素
- 夸夸其谈通用性: 函数或类的唯一用户是测试用例。
- 临时字段: 有时你会看到这样的类:其内部某个字段仅为某种特定情况而定。这样的代码让人不理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。
- 过长的消息链
- 中间人: 过度运用委托。
- 内幕交易: 软件开发者喜欢在模块之间筑起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。
- 过大的类
- 异曲同工的类
- 纯数据类: 所谓纯数据类是指:他们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。
- 被拒绝的遗赠: 拒绝继承超类的实现,我们不介意:但如果拒绝支持超类的接口,这就难以接受了。
- 注释: 当你感觉需要纂写注释时,请先尝试重构,试着让所有注释都变得多余。
四、重构列表
- 重构的记录格式
- 寻找应用点
- 重构手法:小步前进、频繁测试
五、重新组织函数
- 提炼函数
- 内联函数
- 内联临时变量
- 以查询取代临时变量
- 引入解释性变量
- 分解临时变量
- 移除对参数的赋值
- 以函数对象取代函数
- 替换算法
六、对象之间搬移特性
- 搬移函数:再函数最常应用的类建立一个有着类似行为的新函数,将旧函数变成一个单纯的委托函数或是将旧函数完全移除。
- 搬移字段:再目标类新建一个字段,修改字段的所有用户,令他们改用新字段
- 提炼类:建立一个新类,将相关的字段和函数从旧类搬移到新类。
- 类内联化:将这个类的所有特性搬移到另一个类中,然后移除原类。
- 隐藏“委托关系”:再服务类上建立客户所需的所有函数,用以隐藏委托关系。
- 移除中间人:让客户直接调用受托类。
- 引入外加函数:在客户类建立一个函数,并以第一参数形式传入一个服务类实例。
- 引入本地扩展:简历一个新类,使它包含这些额外函数。让这个扩展屏成为源类的子类或包装类。
七、重新组织数据源
- 自封装字段:为这个字段简历取值/设置函数,并且只以这些函数来访问字段。
- 以对象取代数据值:将数据变成对象。
- 将值对象改为引用对象。
- 将应用对象改为值对象。
- 以对象取代数组:以对象取代数组,对于数组中的每个元素,以一个字段来表示。
- 复制“被监视数据”:将数据复制到一个领域对象中,简历一个Observer模式,用以同步领域对象和GUI对象内的重复数据。
- 单向关联改为双向关联:添加一个反向指针,并使修改函数能够同时更新两条连接。
- 字面常量取代魔法数:创造一个常量,根据其意义为他命名,并将上述的字面数值替换为这个常量。
- 封装字段:私有化,private,并且提供相应的访问函数。
- 封装集合:让这个函数返回该集合的一个只读副本,并在这个类中提供添加/移除集合元素的函数。
- 以数据类型取代记录:为该记录创建一个数据对象
- 以类取代类型码。
- 以子类取代类型码。
八、简化条件表达式
- 分解条件表达式:从if、then。else三个段落中分别提炼出独立函数
- 合并条件表达式:将这些测试合并为一个条件表达式,并将这个条件表达式提炼成为一个独立函数。
- 合并重复的条件片段:将这些重复代码搬移到条件表达式之外。
- 移除控制标记:以break或return取代控制标记。
- 以卫语句取代嵌套条件表达式。
- 以多态取代条件表达式:将这些条件表达式的每个分支放进一个子类内的腹泻函数中,将原始函数沈明伟抽象函数。
- 引入断言:
九、简化函数调用
- 函数改名:修改函数名称
- 添加参数:为函数添加一个对象参数,让该对象带进函数所需信息。
- 移除参数:移除函数参数。
- 将查询函数和修改函数分离:建立两个不同函数,一个负责查询,一个负责修改。
- 函数携带参数:建立单一函数,以参数表达不同的值、
- 明确函数取代参数:针对参数每一个可能值,简历一个独立函数。
- 保持对象完整:传递整个对象
- 引入参数对象:以对象取代参数。
- 工厂函数取代构造函数。
- 以异常取代错误码。
- 以测试取代异常:修改调用者,使它在调用函数之前先做检查。
十、处理概括关系
- 字段上移
- 函数上移
- 构造函数本体上移:在超类中新建一个构造函数,并在子类构造函数中调用它。
- 函数下移
- 字段下移
- 提炼子类:新建一个子类,将上面所说的一部分特性移到子类中。
- 提炼超类
- 提炼接口:相同的子集提炼到一个独立的接口中
- 折叠继承体系
- 塑造模板函数:将这些操作分别放进独立函数中,并且保持他们都有相同的签名,于是原函数也就变得相同了,然后将原函数上移至超类。
- 委托取代继承:
- 继承取代委托:
十一、大型重构
- 梳理并分解继承体系:建立两个继承体系,并通过委托关系让其中一个可以调用另一个。
- 讲过程化设计转换为对象设计:将数据变成对象,将大块的行为分成小块,并将行为移入相关对象之中。
- 将领域和表述/显示分离:将领域逻辑分离出来,为他们简历独立的领域类。
- 提炼继承体系:简历继承体系,易一阁子类标识一个特殊情况
阿里开发手册
- 各层命名规约
- 获取单个对象的方法用 get 做前缀。
- 获取多个对象的方法用 list 做前缀,复数结尾,如:listObjects。
- 获取统计值的方法用 count 做前缀。
- 插入的方法用 save/insert 做前缀。
- 删除的方法用 remove/delete 做前缀。
- 修改的方法用 update 做前缀
-
领域模型命名规约
-
数据对象:xxxDO,xxx 即为数据表名。
-
数据传输对象:xxxDTO,xxx 为业务领域相关的名称。
-
展示对象:xxxVO,xxx 一般为网页名称。
-
POJO 是 DO/DTO/BO/VO 的统称,禁止命名成 xxxPOJO。
-
- 方法参数在定义和传入时,多个参数逗号后面必须加空格。正例:下例中实参的 args1,后边必须要有一个空格。
method(args1, args2, args3); - 所有整型包装类对象之间值的比较,全部使用 equals 方法比较。
说明:对于 Integer var = ? 在-128 至 127 之间的赋值,Integer 对象是在 IntegerCache.cache 产生, 会复用已有对象,这个区间内的 Integer 值可以直接使用==进行判断,但是这个区间之外的所有数据,都会在堆上产生,并不会复用已有对象,这是一个大坑,推荐使用 equals 方法进行判断。 -
任何货币金额,均以最小货币单位且整型类型来进行存储。
-
如上所示 BigDecimal 的等值比较应使用 compareTo()方法,而不是 equals()方法。 说明: equals()方法会比较值和精度 (1.0 与 1.00 返回结果为 false) ,而 compareTo()则会忽略精度。
-
定义 DO/DTO/VO 等 POJO 类时,不要设定任何属性 默认值 。
-
序列化类新增属性时,请不要修改 serialVersionUID 字段,避免反序列失败;如果完全不兼容升级,避免反序列化混乱,那么请修改 serialVersionUID 值。
-
关于 hashCode 和 equals 的处理,遵循如下规则:
1) 只要覆写 equals,就必须覆写 hashCode。
2) 因为 Set 存储的是不重复的对象,依据 hashCode 和 equals 进行判断,所以 Set 存储的对象必须覆写这两种方法。
3) 如果自定义对象作为 Map 的键,那么必须覆写 hashCode 和 equals。
说明:String 因为覆写了 hashCode 和 equals 方法,所以可以愉快地将 String 对象作为 key 来使用。 - 判断所有集合内部的元素是否为空,使用 isEmpty()方法,而不是 size()==0 的方式。
说明:在某些集合中,前者的时间复杂度为 O(1),而且可读性更好。 - 使用 entrySet 遍历 Map 类集合 KV,而不是 keySet 方式进行遍历。
说明:keySet 其实是遍历了 2 次,一次是转为 Iterator 对象,另一次是从 hashMap 中取出 key 所对应的value。而 entrySet 只是遍历了一次就把 key 和 value 都放到了 entry 中,效率更高。如果是 JDK8,使用Map.forEach 方法 - 错误码设计规范:
错误码为字符串类型,共 5 位,分成两个部分:错误产生来源+四位数字编号。
说明:错误产生来源分为 A/B/C,A 表示错误来源于用户,比如参数错误,用户安装版本过低,用户支付超时等问题;B 表示错误来源于当前系统,往往是业务逻辑出错,或程序健壮性差等问题;C 表示错误来源于第三方服务,比如 CDN 服务出错,消息投递超时等问题;四位数字编号从 0001 到 9999,大类之间的步长间距预留 100 - 日志打印时禁止直接用 JSON 工具将对象转换成 String。用toString()
- 数据库表设计规范:
数据库名、表名、字段名,都不允许出现任何大写字母。
数据库名成:tps***(业务名),例:tps_tiku。
数据库表名:业务名_类型名_实体名,例:tk_td_paper
数据库表类型
tb:基础信息表,数据基本不增长
td:业务产生数据表,数据会随着用户操作增长很快
tr :业务关系表,实体建的关联关系表
vb:基础表之间的视图
vd:数据表之间的视图
vr:关系表之间的视图
表和字段一定要有注释
提供表间逻辑关系。
提供表结构设计文档。
普通索引命名 idx_字段名_字段名,唯一索引 uniq_字段名_字段名 - 利用延迟关联或者子查询优化超多分页场景。
说明:MySQL 并不是跳过 offset 行,而是取 offset+N 行,然后返回放弃前 offset 行,返回 N 行,那当
offset 特别大的时候,效率就非常的低下,要么控制返回的总页数,要么对超过特定阈值的页数进行 SQL
改写。 - 分层领域模型规约:
DO(Data Object):此对象与数据库表结构一一对应,通过 DAO 层向上传输数据源对象。
DTO(Data Transfer Object):数据传输对象,Service 或 Manager 向外传输的对象。
BO(Business Object):业务对象,可以由 Service 层输出的封装业务逻辑的对象。
Query:数据查询对象,各层接收上层的查询请求。注意超过 2 个参数的查询封装,禁止使用 Map 类来传输。
VO(View Object):显示层对象,通常是 Web 向模板渲染引擎层传输的对象。 - 高并发服务器建议调小 TCP 协议的 time_wait 超时时间
操作系统默认 240 秒后,才会关闭处于 time_wait 状态的连接,在高并发访问下,服务器端会因为处于 time_wait 的连接数太多,可能无法建立新的连接,所以需要在服务器上调小此等待值。 - 调大服务器所支持的最大文件句柄数(File Descriptor,简写为 fd)
主流操作系统的设计是将 TCP/UDP 连接采用与文件一样的方式去管理,即一个连接对应于一个 fd。主流的linux服务器默认所支持最大fd数量为1024,当并发连接数很大时很容易因为fd不足而出现“open too many files”错误,导致新的连接无法建立。建议将linux 服务器所支持的最大句柄数调高数倍(与服务器的内存数量相关)。 - 在线上生产环境,JVM 的 Xms 和 Xmx 设置一样大小的内存容量,避免在 GC 后调整堆大小带来的压力