重构是一个“永恒的话题”,只要开发在持续,那么重构就会一直伴随着我们。
但是,“重构”本身并不是一个很容易做到的事情。并不是读了本《重构:改善既有代码的设计》就会重构了。
这半年多来,各大技术站点都在热炒SOA、AJAX、敏捷,却鲜有文章谈论“重构”。事实上,目前国内软件行业的还是非常匮乏经验丰富的构架师和高级研发人员,而“重构”则是这两类技术人员必须修炼的基本功课。
但是,在国内搞开发,能够实践“重构”的机会并不是很多。很多人扎根进项目繁琐的需求变更中,而无暇顾及“回头整理整理思绪,整理整理代码”。特别是在“平台泛滥”的项目实施中,大量的实施人员都在考虑“如何在现有的平台和框架下实现功能”,很少考虑“代码结构是否有改善的空间”。
俺这几年大部分时间都是跟产品研发打交道,所以幸运的属于那一小部分“比较容易有机会实践重构”的人群。当然,比起在TW公司的那些哥们来说,比他们还也有些不幸,毕竟能够向TW那样“非常崇尚敏捷”的公司不多。
比上不足,比下有余,自然有一番自己的体会。前段时间又玩了一把“重构”,是重构一个命令行处理类的,把一些心得说一说。
单纯的修改代码,与重构是有很大差别的。可能你对某个Component并不很熟悉,但是通过Debug和Track,还是能够比较容易的“Edit Code”或者“Fix Bug”。但是这跟重构的差距还是很远。
举个例子:来到CDC这边快一年了,经历了两个“maintenance version”的开发和目前正在进行的“major version”的研发。之前的两个“maintenance version”只是Fix Bug,而且并没有多少参考资料,基本上只能依靠自己的Debug和Track手段来分析、跟踪、定位Bug,然后修正它。那段时间我们都在嘲弄自己“阅读代码基本靠猜,修改代码基本靠蒙”。可想而知,那时候,在旧有的代码中定位Bug和修改Bug尚且是一个非常困难的事情,根本没有“重构”的可能性。——但是我们还是很成功的修改很多Bug,这也意味着修改了很多代码。对我们来说,唯一的收获就是对旧有的代码结构有所了解了,间接的吸收了国外那些senior architecture的设计思想。
但是,在进行两个“maintenance version”的开发的过程中,我们没有任何的“重构”行为。
重构的基本前提就是你必须对“旧有的代码结构比较熟悉”。当然,如果能够了解最初的设计思路,就更容易去重构现有的代码结构了。但很多情况下,我们只能通过阅读代码来“推测”原作者的设计意图。
比如最近正在重构的一个命令行类。这个命令行类用于维护Application的Upload、Deploy等等十几个复杂的操作。
大致的类方法结构如下(真实的类比这更为复杂):
(主函数) main(String[])
(处理任务函数) task(String[])
(执行任务) :.action()
(执行某个任务) :.deploy(Application, XiNode)
(执行某个任务)········
(退出) :.exit(String, int, int)
(初始化) :.init()
(解析参数) :.parse(String[])
(打印帮助) :.usage()
(打印某个任务帮助) :.usageDeploy()
(打印某个任务帮助)········
(验证输入参数) :.validate() |
很明显,这个旧有的代码类带有这非常明显的“面向过程”的思维模式,当然,这是很多“命令行”类的通病。在最初构建命令行类的时候,任务比较少,代码行数和方法也就比较少,复杂的设计可能反而影响代码的构建效率,这样“流水式”的设计可能更容易实现和维护。
为了更容易说明问题,画了一张简易的“思维图”来诠释原始作者的设计意图(这个设计意图仅是依据阅读代码而分析出来)。
如果你能够理解旧有代码中作者的基本设计意图,那么你就基本上可以开发重构了。当然,可能会存在某些细小的地方,在你阅读代码的时候遗漏掉(毕竟没有完整的设计文档),但是这对“重构”的影响不是很大,把握注关键的一些设计意图,其他的可以在重构过程中慢慢体会和学习。
如果你是一个拥抱Agile的开发人员,“重构”肯定会紧紧围绕在你的思维中。而作为开发人员,也应该时刻有重构的欲望。
事实上,上面那个命令行类,已经很成熟了。在经过几年的补充之后,已经需要支持十几个任务了。我们姑且用ApplicationManage来代替这个命令行类的名称。很多年以后,这个类成为一个非常“臃肿”的类,一个2000多行的类。
这个类的命令模式大致这样,每个任务都有自己特定的一些参数:
ApplicationManager –upload –domain AAA –archive user ······
ApplicationManager –deploy –domain AAA –app appname ······
如果这个时候,你需要新增一个任务,比如我们需要达到这样的效果:
ApplicationManager –newtask –domain AAA –file tesfile ······
这个时候,你必须修改ApplicationManage很多地方的代码:按照原有的代码逻辑和结构在不同的地方增加代码:
我需要在解析输入参数的地方,增加对几个新的参数的解析。
我需要在校验的地方,增加对新参数的解析
我需要增加对新Usage的支持代码
我需要增加对新任务处理的支持代码
新增一个新任务的支持,代码是并没有任何难度,也不复杂,但是我却要在一个类的很多处来回修改,要知道,这是一个2000行的类啊。——这就是“代码的坏味道”。
是的,如果这个时候,当你面对这么“难以忍受”的修改代码的时候,就需要萌发了“重构”的欲望。我们无须为“重构”寻找很多冠冕堂皇的理由,只此一条就足够了:觉得有更好的实现结构可以让我们扩展起来更容易。
当你想重构的时候,你就一定能够重构吗?这是需要进行时间成本、风险估算的问题。
ApplicationManager经过几年的发展,已经相当稳定。而Feature Freeze是时间是不允许被更改的。即使旧有的代码比较“臃肿”,但是经过时间检验的稳定性、正确性是无法被推翻的。结局只有一个:只能在旧有代码上添砖加瓦,来新增一个新任务的处理。
重构的计划就这么被迫放弃吗?
当然不应该放弃。可被用来修炼重构的机会本身就不多,“猎物就在眼前,怎能轻易放弃呢?”
是的,我不能把“重构”的代码提交到svn中,但是我可以在sandbox中进行啊。“重构,不一定非要在真实项目代码中”。事实上,在真实项目代码中重构是“非常危险”的行为,除非你有足够的时间和完整的设计思路,或者你的项目经理已经批准你进行比较大范围的代码修改。否则最好是在“sandbox”中实践和预研你的想法吧。
客户并不关心代码的实现好坏,只关心“功能、效率、稳定性”。有人会站出来把“扩展性”补充进来,是的,客户也关心“扩展性”,但是客户关注的“扩展性”只是外围的扩展,而真正很多内部实现的代码结构是“对客户透明的”,客户也不是关心那部分对其毫无“意义”的内部代码实现。
记住这个吧:没有人可以阻止你在“sandbox”放入你的想法和你的实践,除了你自己。——当然,你必须协调调好你的时间、你的工作。
晚上9点钟,当坐在家中电脑前的时候,谁还能阻止我们把自己的想法在“sandbox”中实践呢?灵感有时候是来自寂静的深夜!—— 但是,切忌不要过度熬夜,华为那个俺本家兄弟(姓胡)的命运值得俺们所有开发人员“警醒”。
重构并不意味着完全摒弃旧有的代码。很多时候,旧有的代码带着很明显的“历史烙印”:它们最初设计是满足当时的需求,满足当时快速实现某些功能的需求。在那时候,设计是正确的,但是随着后续功能需求的扩张,结构逐渐变的臃肿,从而变得越来越难维护。
上面的ApplicationManager就是这样一种情形。
但是,任何的组件,“存在就意味着它的价值”,它其中肯定存在一些“经得起历史考验”的设计思路。首先,我们应该找出这些“应该被吸纳的设计思路”。
偶尔也会听到有些开发人员抱怨旧有代码:重构它,还不如重写呢。——也许真的存在“一点价值都没有的旧有代码”,但这样的几率太小了。作为开发人员,应该本着“尽量能够吸纳旧有的设计思路的精华”的思路来进行重构。
ApplicationManager命令行类中就有一些不错的设计思路的:
(1) 基本的处理过程是正确的:解析、验证、初始化、执行任务、打印Usage。
(2) 提出了“任务”这个思路。认为一个命令行类是由很多Task组成的。
在代码实现方面,也有一些不错的可借鉴的“代码”可以借鉴学习,这里就不多叙述了。
重构对旧有的代码影响就是:一部分代码需要被摒弃、被改造、被重组。上一节我们说了应该吸纳旧有代码的“精华”,而这一节,我们则要“剥离不良代码”。
ApplicationManager命令行类中有一些不错的设计思路,当然也有一些令人遗憾的结构。
(1) 虽然分离出任务这个概念,但是参数的解析却是共享的。
每个任务会有自己一些特定的参数,有些参数是共同的,而有些参数是私有的。那么旧有的命令行类是如何实现的呢?
private String mDomain;
private String mUser;
private String mPassword;
private String mFile;
private String mApp;
private String mArchive;
//省略 |
是的,旧有的命令行类,讲所有的参数都定义在成命令行类的属性。如果你需要新增一个任务,而与这个任务有关联的参数就必须新增一些属性。
(2) 每增加一个参数定义,就需要增加一段“if else”代码片段来parse参数。事实上,任务的参数解析是可以被抽象的。
让我们看看旧有的命令行在解析这些参数的代码实现片段:
for (;;){
if (stack.isEmpty()) {
break;
}
if (arg.equalsIgnoreCase("-?") || arg.equalsIgnoreCase("-help")) {
printHelp = true;
if (stack.isEmpty())
exit(null, USAGE, SUCCEEDED);
}
else if (processCommand(arg) || processBatch(arg)) {
if (printHelp)
exit(null, mAction, SUCCEEDED);
}
else if (arg.equalsIgnoreCase("-app")) {
}
else if (arg.equalsIgnoreCase("-file")) {
}
//省略
} |
很显然,当然新增了任务,或新增了参数之后,你不得不在这里再次修改代码片段。
(3) 每个任务又是由很多参数组成的。每个参数都有共性,那么就可以进行“抽象的验证”。各个参数的组合验证,则又组成了一个任务的“抽象的验证”
但是,旧有的命令行类是如何实现的呢?
private void validate() {
if (mAction == UPLOAD) {
if (mArchive == null) {
//省略
}
}else if (mAction == DEPLOY) {
if (mApp == null && mConfigUrl == null && mArchiveUrl == null) {
//省略
}
}
//省略
} |
(4) 每增加一个任务,不得不在新增一个usage方法,并写上一些“累赘”的帮助资料。而这些usage是可以依赖任务所持有的参数而固定的。
(5) ······(还有其他一些个人觉得不妥的地方,不在这里一一叙述了)
这些应该被重构的地方,就是你最初应该重构所需要达到的目标。当然,可能这时候,你会有一些新的feature希望实现,那么也可以纳入你的重构目标中。
很多人,在重构的时候,一不小心就走入了“繁杂”的误区。寻求“扩展性”必然会令支撑的代码结构变的复杂,但是不能因为一些“可能的扩展性”而盲目的设计,把重构的目标一味的放大。这样过度的重构和设计,反而会让“重构之旅夭折在路上”
比如这个ApplicationManager命令行类,已经分解成:任务+参数。但是,在最初的设计意图和实现中,任务都是“并列”的。deploy和upload这是两个任务,这两个任务是完全并列的。
但是在现实中,命令行处理的复杂性,很容易就会出现下面的情况:
ApplicationManager –flatten –export -xxx
ApplicationManager –flatten –revert –xxx
可以看到,Flatten这个任务下面又产生了“两个独立的任务”。
是不是据此我们就有必要为任务增加一个“子任务”的概念呢?—— 这个想法并没有任何错误。但是对于“开始重构”这个阶段,过度的“考虑”是“兵家大忌”的。过度的考虑和设计,只能让你自己变的更加迷惘,对重构却没有任何好处。可以看看上面那些需要重构的部分吧,已经很多了。
而且,分析一下旧有的那些任务,只有一两个任务的参数会稍微复杂一些。也就是说这种“子任务”的设计并没有多少价值。即使遇到这种情况,在任务处理类中,简单的“if else”就可以很容易处理。
难道“重构”也需要设计吗?
当然需要设计,只是不需要用什么uml tool会绘画那种class diagram。这种“正式”的设计只是用于存档,便于后人理解;有时候也用于与别人讨论的场合,标准的class diagram还是比较容易被大多数人接受。
但如果仅仅只是自己重构使用,那么在脑海中构思简单一把,或者在纸上简单画画即可了。
下面,把针对ApplicationManager的重构设计,绘制成一张思维图,姑且做个例子看看。(事实上,自己在重构的时候,仅仅只是在纸上简单画了画)。
这样,这个命令行类的最基本几个元素就被你抽象出来了:Option、TaskOptions、Task。
开始重构了:先别忙着就自己写,看看有没有现成的组件可利用?
写道这里,估计会有一帮人抱怨了:为啥不在最初的时候找开源组件呢?
如果直接找开源的组件,就谈不上什么“修炼”了,况且,现有的开源组件不一定适合自己。
但是,当你想清楚自己想干什么了,大概怎么干的时候,再去寻找“现有的组件”,收获会更多:
(1) 如果有类似的开源组件,你就可以更加全面的衡量了,而且比较容易理解开源组件的设计思路
(2) 如果没有类似的,那你就开始重构吧
有关命令行解析的开源组件,比较著名的就是Apache的CLI组件,目前release version是1.1。分析了一下CLI的设计,并画了张思路图,如下所示(注意,只涵盖了一部分主要的关系,其他辅助的没有涵盖,比如HelpFormatter等等之类):
分析完CLI,你就会明白,这个其实并不适合我们的命令行类:
(1) CLI仅仅只是用于解析命令行的参数
(2) CLI没有TaskOption这个概念,其所有的Option都是平等的。
(3) 因为Cli只关注于命令行的解析,所以对于Option的解析做很复杂。但是这样复杂的解析方式并不适合我们的命令行操作,我喜欢解析较为简单一些,比如不需要OptionGroup这个东西。
(4) 我们的命令行类需要完成:参数解析和任务执行,而不仅仅是解析。
但是,CLI还是有些地方值得我借鉴和吸纳的:
Options这个对象给了我灵感,而这正是TaskOptions所缺少的:TaskOptions可以利用Options对象去管理Option。
重构的过程是比较有乐趣的,当然,也是一种享受。
重构本身就应该是一个“迭代”的过程,一步步朝着目标结构逼近。
但是,重构过程中,会不断有些“细小”的改进。不要奢望过度的改变“你的原始想法”。如果你在重构过程中,发现你与原始的设计背离太远,那么建议你最好停下手头的coding,花点时间整理一下思绪,把整体设计再考虑考虑。
但是,细小的改进几乎是不可避免的。
举个例子:在最初实现Option对象的时候,我仅仅考虑了几个属性:名称、是否带有值,值、描述,是否必须存在。但是在后续实现打印Usage的功能的时候,却面临了一个问题:输出的option帮助信息总是无序的,当然,我试图通过option的名称来排序,但是这似乎并不是一个“业务化”的排序。最后,我不得不为Option增加了一个“order”属性来允许客户手工设置哪些参数的输出顺序。
这样改进的例子在重构过程中还很多,因为比较琐碎,具体就不再这里叙述了。事实上,这些只需要经历过几次重构过程,就会在这个“旅途”中经历不少几次的代码改进。
这是一个老话题了,重构的过程,就伴随这不断TestCase验证的过程。不断的TestCase才能保证你的实现是在“正确”的方向上。“迭代”与“TestCase”总是相互依存的。
一次重构,到底让自己修炼了什么呢?
仅仅只是让一个组件的结构更优良了吗?哦,这只是看的见的收获,还有很多你自己都看不见,却可以感受到的收获。
(1) 重构的过程,就是让自己的先前的构思设计更加趋于完善的过程。当某一次重构完成以后,你自己的“设计能力”必然会有所提高,当然,这种提高短时间并不可能带来“质的飞跃”,但是,随着时间的积累,“重构”修炼次数增加,设计的组件越来越复杂,那么总会有一天,你可以轻松把握一个复杂组件或者产品的时候。
(2) 不要因为读了本《设计模式》就认为掌握了“优良的类关系设计”。书本只是介绍了别人的经验,但是是否能够真正的吸收,则是需要在“重构”中一次次“尝试”和“积累”的。毕竟“重构”是连接旧有代码结构和新代码结构的桥梁,这个过程的演进,其实就是在一步步实践各种“模式应用”的过程。
(3) 还记得那本《分析模式》吗?它其实在给大家阐述一些“场景的实现模式”,你认为这些模式是怎么出来的呢?——其实,这就是那些大师们在不断的积累、演进、重构中的经验总结。——比如我们刚刚重构的这个命令行类,它其实就是一种场景的实现。
当然,在“重构”过程中,你也会偶尔经历一些“代码技巧”方面的尝试,让你的代码更加简洁、高效、结构优良。——这也会修炼的代码功底。
重构,究竟修炼的了什么呢?
以上仅仅只是笔者的一些心得。我想,面对重构,还是读者自己的实践,更能给你自己带来更多的收获。
查看本文来源