一个软件生命周期示例
第一年,新兴的业务规划,用户体验和软件的设计都非常漂亮,我们新增feature与bug fix的速度都非常快,业务发展的也非常好。
第二年,由于feature越来越多,业务变化比较快,代码复杂度增加导致了交付的速度与bug fix的速度都有所下降。因此,团队通过增加人手来加快交付速度,但这也增加了团队沟通成本和管理成本。新人由于对code base的理解程度及业务压力,短期内只会做一些临时方案满足需求,导致代码的复杂度进一步增加。
第四年,交付速度与质量严重影响了业务的发展,开发人员与业务人员向管理层提出重写该应用。重写启动后,团队分为两个组。一个组继续维持现有系统,另一个组负责重写原系统。
第五年,团队同时背负两套系统的工作压力。重写的新系统在实施过程中会发现很多在重写前没有评估到业务及技术细节,严重低估了重写的工作量。同时,新业务在不断发展,重写的系统同时要保证做增量开发。重写系统没有上线前是不产生价值的。导致负责重写的团队压力很大。为了应对上述种种情况,团队往往会做一些折中方案,能够给使系统尽快上线。最终结果是,更加加快了代码的腐化程度,重复到上一步。
不能说所有的软件生命周期都一定是这个样子,但上述的小例子真实的发生在我们这个行业,而且在不同的公司,不同的业务,不同的时间在不断地重演。
软件不变的真理
在讨论如何打破僵局前,我们先讨论下是如何造成上述局面的。交付与bug fix的速度与用户体验的下降,很大程度上的原因是因为软件的内部质量,代码质量的下降导致的(烂代码)。
没有开发人员主观意愿上想写烂代码的。变化是造成我们代码腐化的主要原因之一。
变化的维度
- 业务变化: 业务是具有不确定性的,也正是由于业务的变化,才使我们的业务在不断的向前发展
- 组织的变化:人员更替,组织变革,都一定程度上影响代码的变化。老人员带走了业务知识和技能,新人员带来了新的思想和方法。
- 需求在沟通过程中的误解:业务人员与开发人员的沟通,管理层与开发人员的沟通,开发人员与开发人员的沟通,很多时候我们理解的并不一定是对方理解的
- 对业务理解,技术理解的深度的变化:我们对一个业务,对某项技术,随时间的变化,理解也是不同的。
软件的本质是变化,即软件唯一不变的真理是变化。
应对变化的方式
应对的变化的方式一般有以下几种:
- 为什么需求总是再变,难道不能想清楚再提需求吗?
反感,抗拒需求变化。实现需求通过if else完成,代码僵化。每次变化都需要更改新增if等。 - 过度设计,抽象很多万能类,如BaseAction,Extention之类,总是想做一种万能抽象,覆盖所有业务变化。但过度抽象等于没有抽象,要针对Special Case做特殊编码,如
instance of someType
或
if(type) {
}else{
}
重设计模式:设计模式是好的,但是有些场景用设计模式太重了。设计模式有一定的开发和新人融入成本。如为未来的变化,做了很多模式设计,但是业务可能几年都没有变化。又如,某些功能只上线一个阶段的活动,很快就下线了(这里往往存在埋伏,就是有些时候业务上线一个月就下线,可是后续就再也没有下线,还不断提新需求)
更好的方式:渐进式
我们已经了解到,变化是软件的本质。无论一个系统设计的有多好,扩展性有多强,但随着变化,系统无法避免会变得糟糕。那我们就要拥抱变化,不仅仅在态度上拥抱变化,我们的在编码过程中同样要做到拥抱变化。
这需要我们有沟通的能力,识别代码坏味道的能力,具有重构代码的能力和意识。
否则只是在原来代码上加逻辑,打补丁,终究有一天会使代码变的复杂度极高,维护和扩展成本倍增。增加人员不是一个最优解决方案,人越多,沟通成本与管理成本都会增倍。会导致更大混乱。
渐进式方法只满足当前需求,但要有重构的意识和能力,所谓演进不是在原来代码简单的新增代码,而是改变原代码结构,使其更符合新业务的变化。听起来是不是很耳熟,没错,就是重构的定义一致。重构是不改变原有代码行为的基础上,改变代码的设计。而我们要做的是,改变代码行为(新增feature,bugFix)的同时,改变代码的设计。使我们能够重新获得快速交付高质量软件的能力。
重构
相信大家对重构都非常了解和熟悉,来自于马丁.福勒的经典书籍。主要包含两大部分内容。
- 代码的坏味道
- 重构的手法
但这里想和大家讨论的是以下方面的思考
- 重构的时机:重构不是,也不应该是一个固定给一大段时间重构,而是每时每刻都要进行,而且时机应该尽可能早,越早重构,需要的时间和技能成本越低。以下是几个典型的非常好的重构时机,Code Review,FixBug,Add Feature.
- 重构的前提:
- 重构不是一个人的战斗,需要团队一起参与,或者必须团队成员知道我们在实时重构着。如果一个人进行重构,面对庞大的代码基,影响不大。另外,如果一个人进行大面积重构,会影响团队其他成员的情绪和工作。例如,别人的新开发的功能还没有合并,已经被重构掉了大部分代码,导致无法合并了。
- 小步进行,快速得到重构的反馈。自动化测试最好,如果没有,也要减少测试成本。当得到反馈的速度越快,我们的代码质量越高,不仅仅指编程。任何学习性工作都普遍适应这条原则。
- 测试成本的重视:上面提到了快速反馈对我们编程质量的重要影响,所以我们务必要重视测试成本,软件质量与测试成本成反比。
1. 务必保障本地可部署,可测试
2. 少使用static加载耗时成本高的服务。如: 导致服务启动需要5-10分钟
3. 尽量写UT
- 量化:有些东西确实很主观,如一个小方法,循环做了某事,从SRP的角度,循环是一个职责,做某事是一个职责,这是否违反了SRP呢? 但是还是可以量化一些东西的,如
1. 类的行数大小
2. 参数的个数
3. 全局变量的数量
4. 依赖服务的数量
5. 配置数量(配置量大不是好事,约定大约配置更佳)
6. 文件提交(git commit)次数 - 切入点:有限资源发挥最大效能。遗留代码是有价值的,它经过了线上的洗礼,经过了大量的测试,我们既无可能,也无必要将全部的代码都重构一遍。重构也是有巨大成本和风险的,所以我们要谨慎对待。遵循以下三个原则。
- 事不过三原则:当我们对同一块或类似代码修改超过两次,在第三次改动时,需要先重构再添加功能。业务唯快不破,我们要快速支持业务发展,可以暂时不考虑代码的可维护性,但是反复出现三次的时候,说明如果不重视这个问题,事后必将形成技术债务,拖慢我们的节奏。
- 童子军军规: Check in的时候比Check out时,代码整洁一点,至少不让它变的更糟糕。
- 文件提交次数过多的文件: 当一个文件被反复提交和修改,说明其设计严重耦合(测试代码与配置文件、配置类等除外),违反SRP与OCP,是代码腐化最快的地方。所以我们要将重构的重点放在这些地方。可以下载安装git extra,执行git effort来查看代码基中需要重点关注的对象。
如统计历史中,提交次数超过20次以上的所有文件:git effort --above 20
path commits active days
pom.xml....................................................................................................................... 185 88
src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnection.java........................................ 121 96
src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java............................... 120 97
src/main/java/org/springframework/data/redis/connection/jedis/JedisConnection.java............................................ 119 99
src/main/resources/changelog.txt.............................................................................................. 104 66
src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java................................. 96 82
src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java..................................... 94 79
src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java............................................ 65 53
src/main/java/org/springframework/data/redis/core/RedisTemplate.java.......................................................... 62 59
src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionIntegrationTests.java............................ 55 51
注意,按如上方法统计在在某些情况下会有遗漏,如一个很糟糕的类从A工程迁移到了B工程,这个类在B工程中的提交次数就会很少,我们就遗漏掉了这个需要重点关注的对象。
为了保险起见,可以通过如下命令做二次筛选。统计最近30天提交次数超过5次的文件:git effort --above 5 -- --since="30 days ago"
PS:该方法通过本人实际项目经验,非常有效。曾经只将一个项目的前10几个对象重构完成后,整个团队的交付速度提升了数倍。
康威定律:一个组织的产出物,与这个组织的协作方式一致。
敏捷组织中,通过康威定律总结出全栈小组,避免沟通壁垒,减少沟通中的误解带来的软件实现的复杂度。(关于康威定律的理解,强烈推荐肥侠同学的<微服务架构的理论基础 - 康威定律>)。该定律同样适用于非敏捷团队。如果团队内部任务分配是以项目为单位进行划分,那么就会带来如下几种结果。
- 代码实现耦合项目名称。
项目名称通常不能表明我们在做什么。如沙漠行动,雷霆计划等。如果以这种项目为任务的划分单元。代码中难免会有这样的体现
if(沙漠行动){
doSomething();
}
if(雷霆计划){
doSomething();
}
if(***二期){
doSomething();
}
如果负责该项目的同学还在团队,那么后面的同学势必会反复的询问这样项目的含义和背景。直到所有团队的人都问一遍甚至几遍为止。如果该同学已经不再团队了,那么这就变成了谁都不敢碰的迷之地带。
各种对象的mapping,各种概念不一致的命名。
A项目用了一个概念,B项目可能同时也用,因为两个同学之间没有交叉,各自独立完成自己的任务,导致大家分别对同一概念进行建模。而后续集成时,因为DeadLine临近,已经来不及做大的改动,索性mapping一下,后续有时间再改吧。这里的后续基本等于永远不。项目成员单兵作战,无法得到团队的全面支持。
每个同学自己负责一个项目,当项目出现技术难点或项目滞后时,其他同学很难给予及时且有用的帮助。因为其他同学也有自己的项目压力,也没有相关的项目背景知识,无法提供有效的帮助。项目上线后无可靠的back up。如果该同学休假或工作变动,该项目是无法短时间内被其他同学接手的。
解依赖与可阅读
依赖过重与阅读性差,都是造成我们代码继续腐化,难以维护的重要原因。我们要尽量加避免。
依赖:可按照如下方式对依赖进行分类。
- 应用级别:
现在微服务架构大行其道。可以通过针对某一特性变更,所涉及的服务数量进行统计。 - 类级别:
在代码库中,存在BaseAction,AbstractOperation类似这样的抽象时,我们要用心留意下是否存在依赖过多的情况。抽象类的依赖与具体实现类的依赖均为该实现类的直接依赖。通常这样的抽象往往意味着违反了LSP(里氏替换原则),即无法使用多态来简化代码的复杂度。 - 方法参数:
参数依赖也属于类的依赖,我们应该运用最少知原则,尽可能减少对外部对象的依赖。
可阅读性
有意义的命名
代码到处都在命名,据说大师眼中编程最难的部分是起一个好名字。好名字需要有一定的上下文,但是统一的标准是Don't make me think。不要让读者去想。名字要表达做什么,而非怎么做。举几个例子
- 反例:Config-1,Config-2
正例:RedisConfig,RabbitMqConfig - 反例:NormalFlow (正常流程,正常流程代表可以退款,无需仲裁)
正例:RefundWithoutArbitration
过度抽象:通常难以阅读和理解
如果没有重构能力,为了应对变化,很容易产生过度抽象,当什么都能做的时候,其实什么都没有做好。按层次划分,各层次经常出现的可能存在过度抽象的命名。
服务层Service:
XXXAction, XXXBO, XXXOperation,领域模型层Domain:
Ext,Ability, Extension,Flow,Process, Case, Rule, Feature方法层面Method:
doBusiness, execute, doAction, invokeBusiness, call, doProcess高层抽象
CommonXXX, BaseXXX, AbstractXXX通用的Timer
select from timer
if(timer_type1){
doSomeSpecial for this type
}
if(timer_type2){
doSomeSpecial for this type
}
为什么如上例子均属于过度抽象呢?因为上述命名均跨领域,可以适用于所有软件。丢失了大量业务模型本身自解释的概念。
*** 强调上述阐释的内容并非所有这么做都一定有问题,只是这样实现的代码出现的问题的几率比较大。当涉及到类似情况时,请多用心思考是否存在上述问题。 ***
规范,约束和团队协作
好吧,上面说了好多正确的废话,接下来我们该谈谈实际可量化,可执行的一些方法和工作方式。
约束规范
无规矩不成方圆。绝对的自由带来的是混乱。只有在一定的规则约束下,方能有序的发展。
约束和规范,每个公司都会制定,Google,阿里,甚至一些小的公司都会制定一些非常详细且非常合理的代码规范。有一个问题,各个规范都是开源的,大家都可以学习,为什么取得的成效确不容乐观呢?对这个问题我的思考是,规范太多了。少说几十条,多则几百条。作为开发很难事无巨细的都记住。即使有各种插件提示warning,依然无法遏制住不规范的现象(因为程序员喜欢忽略warning,只关注error)。人类的大脑每次只能记住七种概念,事无巨细,无人遵守的规范,不如少儿精大家都遵守的规范。所以有必要对代码规范进行简化。如下是我总结的几个规范,供大家参考,分为基础版本与进阶版本(所涉及的数值仅供参考,具体数值由团队决定,并持续跌进)
基础版本
- 删除无效代码
无效代码包括不执行的代码(死代码)、注释掉的代码。无效代码唯一的作用就是增加维护工作量。我们需要去阅读它,维护它,如果代码比较混乱,我们完全无法理解的代码,新增功能的时候,为了保险起见,可能还要使无效代码新增功能。这些都是巨大的浪费。针对于这样的代码,我们要毫不留情的删除。代码的回退是版本控制的职责,不是保留这些无效代码的理由。而且删除无效的代码成本非常低,可以说是投入产出比非常高的一个行为。
- 命名有业务含义,不过度抽象
- 单个文件Commit次数超过20,即需要重构分解 ,配置文件、测试代码、配置类除外(个人认为,最为重要的一条)
- 减少魔法值,硬编码
- 类行数限制,不超过500。方法行数限制,不超过80。方法参数限制,不超过5个
- 类的直接依赖的数量不要超过8个
- 方法要么Do something,要么Query something,不可两者都做(命令查询分离)
- 全局变量限制,无法解释为什么必须用,就不能用。
- 表达式限制: 不准有过长的表达式
- 每周一pull并merge,每周末下班前merge并push
进阶版本:(在基础版本上)
- 类大小限制200,方法行数限制20,方法参数限制3,0为最佳
- 类的直接依赖与参数依赖总和限制8个
- 单个文件Commit次数超过15,即需要重构分解
- 领域服务,与领域模型均被UT覆盖
- 每日早上pull并merge,每日下班前merge并push
Code Review
Code Review的频率和覆盖面与代码质量,交付速度成正比。Code Review越前置,需要重构的成本越低。当然Code Review需要建立一种Open的文化,团队共享代码,把别人对代码的问题反馈当做是学习和探讨的机会。可以参考Google如何做Code Review
同时强烈建议团队一起学习clean code,非常简单,但是非常有用。
附上一篇:王垠版本的Clean code 编程的智慧
沟通
构建高质量的软件很重要的一个前提是向正确的人问正确的问题。我们要多问问题,问正确的问题。问清楚了,总比事后去修改软件成本要低。最直接有效的沟通是面对面的沟通。少使用电话或者社交软件。
分享交流
多做技术交流,形成规范和共识。将最佳实践分享到团队,受益最多的是我们自己。
可测性
测试成本与交付速度成反比,尽可能降低测试成本。有UT更好,但不强制,一味强调测试覆盖率,即不可能,也带来不了最大收益
- 保证本地启动应用
- 避免应用配置复杂化
- 减少启动耗时严重的应用:
如静态资源的加载成本过高的对象 - 避免依赖过多的应用
回顾迭进
定期回顾,频次团队来定,回顾这一阶段,我们那些地方做的好,继续推广发扬
- 好的,可以持续推广发扬
- 可以更好的,持续试错并实践。
- 不好的,可以改进的
- 不好的,不可以改进的,有没有其他方案
这里强调下,多关注后两条,多关注失败,少关注成功。只有从失败里学习,才能继续成长。
迭代
短期迭代交付,持续交付,PD,测试与研发团队紧密合作(全栈团队更佳)
总结
软件开发是一项复杂的团队协作的工作。细节决定成败,没有银弹,只有我们不懈努力,才能得到更好的结果。