重构:勿以善小而不为
2022-03-10 10:24:02来源:逸言
重构最大的敌人不是技巧与能力,而是懒惰,或者说是态度。
许多细小的重构看似无足轻重,例如方法重命名,提取方法,即使重构了,似乎对代码的结构也没有太大的影响,于是就决定淡然处之,心里想“事情还未到不可挽回的地步,实现功能要紧,至于重构,还是以后再做吧!”这样一想,于是就会滋生得过且过的想法,等到代码开始变得一团糟时,重构已经变得无比困难了。此时需要的重构技巧,将百倍难于发现坏味道之初。
1在我参加的前一个项目中,我们定义了一个处理OrderSet的Controller。刚刚开始开发时,对于OrderSet的操作并不多,主要是Search与Count操作。OrderSet分为WithDetails与WithoutDetail两种类型。
为了实现的简单,我们将这两种类型的操作都放在一个Controller中。随着操作的逐渐增多,这个Controller就变得越来越庞大,逐渐变得臃肿起来。
每当我需要调用或者修改Controller时,我都在想:“嗯,这个代码太糟糕了,什么时候给它重构一下。”想是这么想,却一直扮演着说话的巨人,行动的矮子。即使兴起这样的念头,又因为其他的工作将此念头浇灭。直到有一天,这个Controller的代码已经到了忍无可忍的地步,我和我的Pair终于达成一致意见,决定对此代码进行手术。
我们花费了近一天的时间对Controller以及相关的Repository进行了彻底的重构。重构前后的代码天差地别,我终于可以不用像吃了苍蝇那般看着代码恶心了。这种重构后体验到的愉悦简直无与伦比,最关键是结果令人满意,重构后的代码变得更可读,更简单,也更容易增加新的功能。
2如今工作的项目,需要对遗留系统进行迁移。首要的任务是为此系统编写BDD测试,以期搭建迁移的测试保护网,并能够形成可读与可工作的测试用例文档。
在开始接触这个任务时,客户方的开发人员已经基本搭建好了初步的框架。当我们在面对不良代码时,第一个浮现在脑海中的念头是“重构”,然而考虑到时间因素,随之又强迫自己灭了这个念头,因为我们需要考虑项目的进度。我们总是在这样的取舍之中艰难前进,终于,在系统需要增加一个新消息的测试时,我强烈地感受到重构的迫在眉睫。即使代码有诸多破窗,修补了一扇,总强过听之任之。经过接近一天多的重构(当然还包括run tests以及build花费的时间),结果令人满意。
回顾这个过程,我发现在发现坏味道时,如果能及时地对代码进行重构,并保证重构的小步前进,并不会阻碍开发进度,相反还能够在一定程度改善代码质量,提高代码的可读性、可重用性以及可扩展性。
所谓“勿以善小而不为”,千万不要因为小重构对代码质量的影响微乎其微而轻视她,或者忽略她,又或者推迟到忍无可忍再想到重构。重构并非最后的救命稻草,而是随时保持我们正确前进的一把尺子。
3说完了重构的重要性,让我再来粗略地介绍这个重构过程。
我们的测试程序主要针对Message的发送、接收与验证。业务的处理则由部署在JBoss上的应用来处理。我们需要设定期望的Message,在发送请求到远程系统后,接收返回的消息,并验证消息以及数据库是否符合我们的期望。重构的起因在于我们需要编写新的测试覆盖之前从未测试过的消息,其类型为SO08。
如果沿用之前的实现,我们就需要在测试步骤中增加MessageType的分支,根据消息类型对返回的消息进行检查。
检查的逻辑事实上已经被抽象为MessageChecker接口,并为各种类型的消息建立了不同的MessageChecker子类。MessageCheckFactory则是这些子类的工厂,负责根据类型创建对应的子类对象。这样的设计是完全合理的。
然而,问题出现在MessageReceiver,它提供了接收消息的方法,通过传入的消息类型、队列名称等参数,返回消息。这个返回值被定义为MessageReader。MessageReader正是问题的症结。
我一直强调的面向对象设计中一个重要概念就是所谓”对象的自治“,即对象的职责是自我完备的,它能够对自己拥有的数据负责,具备了“智能”处理的行为特征。
MessageReader违背了这一原则,它是愚笨的对象,仿佛“坐拥宝山而不知”的笨伯,虽然拥有消息的值,却不知道该如何处理这些消息。简而言之,它提供的方法只能对XML格式的消息进行读取,却不具有真正的业务行为。于是在测试步骤中,就产生了这样的代码(省略了部分实现代码):
private void checkPropagationQueueByName(String name, Queue queue, MessageType messageType) { MessageReader reader = messageReceiver.getMessageFor(messageType, identifier, queue); String messageText = reader.toString(); if (messageType == SO05) { messageCheckFactory.checkerFor(messageType, getExpectedSO05ResponseFor(name), messageText); } if (messageType == SO07) { checkSO07Response(name, messageType, messageText) } if (messageType == SO08) { messageCheckFactory.checkerFor(messageType, getExpectedSO08ResponseFor(name), messageText) .checkResponse(); }}
不幸的是,这样的逻辑处理在其他测试步骤中同样存在。
我注意到,之所以要处理分支,是因为系统需要判断返回的消息是否符合期望,以实现测试目标。这个检查的逻辑根据不同的消息类型会有不同的处理逻辑(其中,主要逻辑则委派给由MessageCheckFactory创建的MessageChecker对象)。
从接口看,它们都需要接收返回的消息与期望的消息。以SO05为例,它需要返回的消息messageText,以及由getExpectedSO05ResponseFor(name)方法返回的期望的消息。对于SO07而言,实现方法稍显复杂,所以提取了一个私有方法checkSO07Response()来处理。
毫无疑问,我清楚地嗅到了代码的坏味道。重构势在必行。一方面,这个分支的处理是不合理的,随着消息类型的增多,这条分支语句会越来越长。关键是这种处理接收消息的逻辑不止存在这一处,这种没有封装的实现方式可能导致出现重复代码,违背了DRY原则。另一方面,则是对ExpectedMessage的处理。它分散在多个测试步骤中,有的放在AddUpdateCustomerSteps,有的则被提取到AbstractSteps类。从职责分配的角度看,测试步骤本身并不应该承担创建或获取ExpectedMessage的职责。
重构的目标就是MessageReceiver接口。我首先查看了MessageReceiver的实现类与调用者,发现其实现类只有一个,即DefaultMessageReceiver。调用者则非常多,调用的方法为getMessageFor()。
这个方法正是我要操刀手术的目标方法。我希望它能够返回ResponseMessage自治对象,而非MessageReader。这意味着我们既需要修改方法的签名,同时还需要修改实现。修改方法签名会影响到调用的依赖点。在依赖点较多的情况下,这种重构需要谨慎处理。
4我认为,在重构时首先需要明确重构的目的是什么,然后还需要理清楚整个重构的步骤,最后有条不紊地实施重构。
显然,我们的目的是希望消除分支语句,并以统一的方式对各种类型的返回消息进行处理。根据对自治对象的分析,这意味着需要赋予ResponseMessage以行为,使得它自身就能够处理对ExpectedMessage的验证。
由于创建ExpectedMessage的逻辑是分散的,因此,我们需要首先对这部分功能进行重构。以getExpectedSO05ResponseFor(name)方法的重构为例。该方法的实现如下所示:
private MessageReader getExpectedSO05ResponseFor(String name) { MessageWriter writer; if (scenarioContext.hasExpectedMessage() && scenarioContext.getExpectedMessage().getMessageType() == SO05) { writer = scenarioContext.getExpectedMessage(); } else { writer = transformerFactory.transformerFor(scenarioContext.getRequestMessage(), SO05) .forCustomer(name) .transform(); } writer.selectBlock(SO05_MESSAGE_HEADER); writer.setFieldValue(MESSAGE_HEADER.USER_ID, null); writer.selectBlock(SO05_PROFILE); String identifier = storyContext.getCustomerIdentifier(name); writer.setFieldValue(PROFILE.PROFILE_ID, identifier); String customerVersion = scenarioContext.getCustomerVersion(); writer.setFieldValue(PROFILE.USER_COUNT, customerVersion); if (writer.selectBlockIfExists(SO05_INDIVIDUAL)) { writer.setFieldValue(INDIVIDUAL_CUSTOMER_TYPE, null); } return messageFactory.readFor(SO05, writer.toString());}
我们需要定义一个专门的对象来承担这一职责,因此,我引入了一个新对象ExpectedMessageFactory。通过采用Move Method手法(快捷键为F6,指IntelliJ下的快捷键,下同)可以完成这一重构。
若要通过IDE自动帮助我们完成这一重构,就首先需要将该方法定义为static方法。然而,观察该方法的实现,它调用了许多字段值,例如scenarioContext,transformFactory等。由于这些字段并非static的,一旦将方法设置为static,使用这些字段就会提示错误。因此,在进行Move Method重构之前,需要首先将该方法调用的字段提取为参数,即运用Extract Parameter重构手法(快捷键为Ctrl+Alt+P)。如果该方法还调用了其他方法,则需要分析了解这些方法存在多少依赖,从职责上看是否也需要转移?如果只有重构的目标方法调用了它,则可以将方法内联(快捷键位Ctrl+ALT+N)。
做好这些准备工作后,就可以移动方法了。所有的这些手法,IDE都提供了自动重构的工具,所以并不须要担心这样的重构会引入新的问题。转移了方法后,原来的依赖点就自动改为对静态方法的调用。由于我们还需要再将其修改为非静态方法,此时,我们需要手动地修改所有原来对静态方法的调用。同时,对于当初为了移动方便而提取出来的参数,在移动到新类后,还需要恢复其原有地位,即将这些参数再提取为字段(快捷键为Ctrl+ALT+F)。之所以要这样做,一方面可以减少方法的参数,使得方法变得更为简洁,另一方面也可以提高类的内聚性。在转移了方法后,我还对原方法进行了Extract Method重构(快捷键为Ctrl+ALT+M):
private MessageReader getExpectedSO05ResponseFor(String name) { MessageWriter writer = initializeExpectedMessage(name, SO05); setSO05MessageHeader(writer); setSO05Profile(name, writer); setSO05Individual(writer); return messageFactory.readFor(SO05, writer.toString());}private MessageWriter initializeExpectedMessage(String name, MessageType messageType) { MessageWriter messageWriter; if (scenarioContext.hasExpectedMessage() && scenarioContext.getExpectedMessage().getMessageType() == messageType) { writer = scenarioContext.getExpectedMessage(); } else { writer = transformerFactory.transformerFor(scenarioContext.getRequestMessage(), messageType) .forCustomer(name) .transform(); } return messageWriter;}private void setSO05MessageHeader(MessageWriter writer) { writer.selectBlock(SO05_MESSAGE_HEADER); writer.setFieldValue(MESSAGE_HEADER.USER_ID, null);}private void setSO05Profile(String name, MessageWriter writer) { writer.selectBlock(SO05_PROFILE); String identifier = storyContext.getCustomerIdentifier(name); writer.setFieldValue(PROFILE.PROFILE_ID, identifier); String customerVersion = scenarioContext.getCustomerVersion(); writer.setFieldValue(PROFILE.USER_COUNT, customerVersion);}private void setSO05Individual(MessageWriter writer) { if (writer.selectBlockIfExists(SO05_INDIVIDUAL)) { writer.setFieldValue(INDIVIDUAL_CUSTOMER_TYPE, null); }}
通过对方法的提取,一方面以能表达功能意图的方法名提高代码的可读性,另一方面还能通过这种重构发现可能重用的方法,例如上面代码片段中的initializeExpectedMessage(),就是在经过提取方法的重构后,才发现其实对于SO07消息而言,同样存在相同的初始化逻辑。
private MessageWriter getExpectedSO07WriterFor(String name) { MessageWriter writer = initializeExpectedMessage(name, SO07); setSO07Details(name, writer); setSO07Blocks(name, writer); return writer;}5
在完成对ExpectedMessage创建功能的重构后,接下来就可以考虑处理MessageReceiver了。
看起来,我们必须修改getMessageFor()方法的签名。一种稳妥的做法是暂时保留该方法,然后引入一个新方法,并在新方法中调用getMessageFor()方法。不过,这种方式需要我们手动地去修改所有依赖点;另一种做法则是先通过提取方法的方式,将原有getMessageFor()的所有实现提取到一个私有方法中,然后再直接利用修改方法签名的重构手法(快捷键为Ctrl+F6),直接修改getMessageFor()。这样做的好处是IDE工具可以直接帮助你修改所有的依赖点,同时还能够保留原有的实现。
为了更好地表达方法的意图,我还对该方法进行了更名重构(快捷键为Shift+F6),将其重命名为getResponseMessage()。由于方法的返回值发生了变化,所以依赖该方法的地方都会出现返回值类型不吻合的提示。在IntelliJ中,我们可以很容易地找到这些提示位置,并直接通过Alt+Enter根据工具给出的提示,来改变返回值类型。
改变了返回值类型并不意味着完事大吉,因为后面对该返回类型的调用,即前面提到的那段分支语句,仍然是不一致的。原来使用的是MessageReader对象,现在变成ResponseMessage对象了。这就需要我们手动地修改这些调用。当然,也有一种取巧的办法,就是将这些代码结合Extract Method与Move Method重构手法,再转移到我们引入的ResponseMessage中,因为在我们之前的分析中,已经明确这些分支判断逻辑应该封装到ResponseMessage对象。最终的重构结果为:
public abstract class ResponseMessage { public ResponseMessage(MessageReader messageReader) { this.messageReader = messageReader; } public void check(MessageReader expectedMessage) { messageCheckFactory.checkerFor(getMessageType(), expectedMessage, getMessageText()).checkResponse(); } protected abstract MessageType getMessageType();}public class SO05ResponseMessage extends ResponseMessage { public SO05ResponseMessage(MessageReader messageReader) { super(messageReader); } @Override protected MessageType getMessageType() { return MessageType.SO05; }}public class DefaultMessageReceiver { public ResponseMessage getResponseMessage(MessageType type, String identifier, GCISQueue queue) { MessageReader messageReader = getMessageFor(type, identifier, queue); return createResponseMessage(type, messageReader, identifer); } private MessageReader getMessageFor(MessageType type, String identifier, GCISQueue queue) { MessageReader reader = getCachedMessageFor(type, identifier, queue); while (reader == null) { reader = getMessageFromQueueFor(type, identifer, queue); } return reader; } private ResponseMessage createResponseMessage(MessageType messageType, MessageReader messageReader, String identifer) { ResponseMessage message = null; switch (messageType) { case SO05: message = new SO05ResponseMessage(messageReader); break; case SO07: message = new SO07ResponseMessage(messageReader); break; case SO08: message = new SO08ResponseMessage(messageReader); break; } message.setMessageCheckFactory(messageCheckFactory); return message; }}//invokerpublic class AddUpdateProductSystemCustomerSteps extends AbstractCustomerExpectationSteps { private void checkPropagationQueueByName(String name, Queue queue, MessageType messageType) { ResponseMessage responseMessage = messageReceiver.getMessageFor(messageType, identifier, queue); }}6
掌握重构的技巧并不难,关于在于你必须要有好的嗅觉,能够及时发现代码的坏味道。
然而,即使你拥有高超的重构技艺,如果未能养成随时重构的好习惯,又能如何?换言之,重构能力体现的是你的专业技能,而重构习惯体现的则是你的职业素养。你是否愿意为追求高质量的卓越代码而为之付出时间和精力呢?你能否以好的结果来说服客户尊重你的重构成果呢?我觉得,对卓越软件的追求,不仅限于自己,同时也需要将此理念灌输给客户,并使得客户愿意为之付费。从软件成本来看,这种对高质量软件的追求或许违背了短期利益,但绝对符合软件开发的长期利益。
所以,在下决心打磨代码质量之前,还是先找好重构这块磨刀石,并放到自己随时伸手可及的工具箱中吧。