关于重构的探讨

279 阅读18分钟

1 讨论

在全文开始之前,我们先来讨论几个小问题。

1.1 什么是重构

书中是这么定义的:

在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。 – 《重构 改善既有代码的设计》

我们按范围划分的话可以方便理解

类型修改范围示例
小型重构对单个类内部的重构优化重命名、提取变量、提取函数等
中型重构对多个类间的重构优化提取接口、超类、委托等
大型重构针对系统组件架构重构优化服务的拆分合并、组件化等

1.2 重构与性能优化是一件事吗

在我看来重构是将代码变成人喜欢的样子,而性能优化是将代码优化成计算机更喜欢的模样。这两种行为可能会有交集,但并不一样,甚至有的重构还会对性能造成一些影响。

关于重构对性能的影响,是被提及最多的问题。毕竟重构代码很多时候都带来了运行代码行数的增加(并不一定是代码总行数增加,因为重构有提炼函数的部分,优秀的重构总会带来代码总行数的下降)

又或者说将一些性能好的代码变为可读性更高的代码,牺牲掉性能优势

首先需要回顾一下,代码重构和性能优化是两个不同的概念,

重构仅仅只考虑代码的可理解性和可拓展性,对于代码的执行效率是不在乎的,在重构时切记不要同时戴着“两顶帽子”。而重构对于性能的影响,也很可能没有你想象中的那么高,在面对大部分的业务情况时,重构前和重构后代码的性能差别几乎难以体现。大部分情况下,我们不需要极致的“压榨”计算机,来减少使用的微乎其微的计算机时钟周期时间,更重要的是,减少自己在开发中使用的时间。如果对于重构后的的性能不满意,可以在完成重构后有的放矢的对部分高耗时功能进行代码优化。一件很有趣的事情是:大多数程序运行的大半时间都在一小部分代码身上,只要优化这部分代码,就能带来显著的性能提高。如果你一视同仁的优化所有代码,就会发现这是在白费劲,因为被优化的代码不会被经常执行。所以我认为重构时大可不必为性能过多担忧,可以放手去重构,如有必要再针对个别代码片段优化。短期来看,重构的确可能使软件变慢,但重构也使性能调优更容易,最终还是会得到很好的效果。

2 重构的目的、时机、难点

2.1 重构的目的

  • 优化代码结构、提高可读性。
  • 提高扩展效率。
  • 降低修改代码的风险。

2.2 何时重构

第一次做某件事时只管去做,第二次做类似的事会产生反感,但无论如何还是可以去做,第三次再做类似的事,你就应该重构。 正如老话说的:事不过三,三则重构。 – Don Roberts

2.2.1 添加新功能时对周边历史代码进行小型重构

  • 当差不多的代码复制粘贴了3~5遍的时候。
  • 比如方法提炼、变量提炼、优化方法参数、消除重复逻辑等。
  • 当然也要取舍,对于简单影响小的可以立即重构,如果比较复杂有风险的可以先做记录完成当前任务后或者另找时间重构。

2.2.2 code review时

让有经验的同学把知识传递给编写代码的同学,从而给予改进的灵感。(老同学对业务更加熟悉,也更了解业务的变化点有助于做出合理的设计)

2.2.3 有计划有目的的重构

对于中小型重构通常在需求中见缝插针进行重构就可以了。但对于大型重构难度和影响相对要大一些,所以就要做好设计,确定影响范围,这通常需要安排整块的时间。

2.2.4 出现线上问题

发生线上问题可以暴露出一些问题,这也是改进的好时机。比如上下游系统出现故障影响到你的系统,就可以思考是不是耦合性太强了能不能解耦。

2.2.5 何时不该重构

  • 6.1. 重写比重构容易

这个无需多言。

  • 6.2. 不需要理解该代码片段时

如果一个功能或者 API 一直以来“兢兢业业”,从未出现过 bug,即便其底下隐藏着十分丑陋的代码,那么我们也可以忍受它继续保持丑陋。不要忘了重构的初衷,其中之一就是为了让人更好的理解代码,当我们不需要理解其时,就让它安安静静地躺在哪儿吧,不要让不可控制的行为发生是重构的原则之一。

  • 6.3. 未与合作者商量时

如果一个功能被多个模块引用,而这些模块并非你负责时,你必须提前通知负责人,声明将要对这部分功能进行修改,哪怕重构不会带来任何使用上的变化,因为这也意味着重构行为将会带来“不可控”。

2.3 重构的难点

2.3.1 如何说服产品

对于困难的重构,可能会需要较长整块的时间甚至还会影响正常需求的进度。所以还需要业务或产品同学的理解与支持。

为此,我们需要在他人的视角上说明重构能够带来的好处,比如能够提升某类需求的开发效率缩短排期,再或者是系统存在什么隐患会对业务带来什么影响等。

2.3.2 重构阶段一些新功能可能需要实现两次

  • 需要评估新功能是否可以等待重构后完成。
  • 重构分为多个阶段小步快跑的方式,尽量不影响需求。

2.3.3 重构不彻底或烂尾导致新老逻辑使代码理解成本更高

  • 重构如果要创建新服务还是要谨慎评估。
  • 提前想好兼容新老模式的设计,线上问题应对方案。
  • 如果遇到烂尾思考是兼容并行还是将新逻辑下线。

2.3.4 控制重构的风险

1)保障重构前后行为一致

  • 使用IDEA重构功能进行安全重构
  • 单元测试
  • 功能测试
  • 回归测试后
  • 流量会回放测试

2)减少出现问题带来的影响

  • 灰度 & 开关
  • 监控报警快速发现问题

2.3.5 包含库表结构的重构

提前设计好数据迁移初始化方案,以及回滚方案。

3 常见的手段

3.1 以卫语句(Guard Clauses)取代嵌套条件表达式

function getPayAmount() {
  let result
  if (isDead) {
     // do sth and assign to result
  } else {
    if (isSeparated) {
      // do sth and assign to result
    } else {
      if (isRetired) {
        // do sth and assign to result
      } else {
        // do sth and assign to result
      }
    }
  }
  
  return result
}

在阅读该函数时,是否庆幸在 if else 之间的并非代码而是一段注释,如果是一段代码,则让人目眩眼花。那下面的代码呢?

function getPayAmount() {
  if (isDead) return deatAmount()
  if (isSeparated) return serparateAmount()
  if (isRetired) return retiredAmount()
  return normalPayAmount()
}

卫语句的精髓就是给予某条分支特别的重视,它告诉阅读者,这种情况并不是本函数的所关心的核心逻辑,

如果它真的发生了,会做一些必要的工作然后提前退出。

我相信每个程序员都会听过“每个函数只能有一个入口和一个出口”这个观念,

在重构的世界中,保证代码清晰才是最关键的。

如果“单一出口”能让代码更易读,那么就使用它吧,否则就不必这么做。

3.2 查询函数与修改函数耦合

如果某个函数只是提供一个值,没有任何副作用,这是一个很有价值的东西,我可以任意调用这个函数没有后顾之忧,也可以随意的搬迁该函数。总而言之,需要操心的事情少多了。

明确的分离“有副作用”和“无副作用”两种函数是一个很好的想法,查询函数和修改函数搭配在平常的开发中也经常出现,是时候将它们分离了!


// 给 2 鹅岁以下的五星员工发邮件鼓励
function getTotalAdnSendEmail() {
  const emailList = programmerList
    .filter(item => item.occupationalAge <= 2 && item.stars === 5)
    .map(item => item.email)
  return sendEmail(emailList)
}

// 分离查询函数,这里可以通过传递参数进一步控制查询的语句
function search() {
  return programmerList
    .filter(item => item.occupationalAge <= 2 && item.stars === 5)
    .map(item => item.email)
}

function send() {
  return sendEmail(search())
}

这样可以更好的控制查询行为以及复用函数,我们需要在一个函数内操心的事情又少了一些。

3.3 复杂的条件逻辑 && 合并条件表达式

复杂的条件逻辑是导致复杂度上升的地点之一,代码会告诉我们会发生什么事,可我们常常弄不清为什么会发生这样的事,这就证明代码的可读性大大降低了。是时候将它们封装成一个带有说明的函数了,见文知意,一目了然。


// bad
if (!date.isBefore(plan.summberStart) && !date.isAfter(plan.summberEnd)) {
  charge = quantity * plan.summerRateelse {
  charge = quantity * plan.regularRate + plan.regularServiceCharge
}


// good
if (isSummer()) {
  charge = quantity * plan.summerRateelse {
  charge = quantity * plan.regularRate + plan.regularServiceCharge
}

// perfect
isSummer() ? summerCharge() : regularCharge()

如果一串条件检查,检查条件各不相同,最终行为却一致,那么我们就应该使用逻辑或和逻辑与将他们合并成为一个条件表达式。然后再做上面代码的逻辑,封装!


if (man.age < 18return 0
if (man.hasHeartDiseasereturn 0
if (!isFull) return 0

// step 1
if (man.age < 18 && man.hasHeartDisease && !isFull) return 0

// step 2
if (isIlegalEntry(man) && !isFull) return 0

3.4 霰弹式修改

霰弹式修改与发散式变化听起来差异不大,实则它们是阴阳两面。霰弹式修改与重复代码有点像,当我们需要做出一点小修改时,却要去四处一个个的修正,你不仅很难找到它们,也很容易错过某个重要的修改,直至错误发生!


// File Reading.js
const reading = {customer"ivan"quantity10month5year2017}
function acquireReading() { return reading }
function baseRate(month, year) {
    /* */
}

// File 1
const aReading = acquireReading()
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity

// File 2
const aReading = acquireReading()
const base = (baseRate(aReading.month, aReading.year) * aReading.quantity)
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year))
function taxThreshold(year) { /* */ }

// File 3
const aReading = acquireReading()
const basicChargeAmount = calculateBaseCharge(aReading)
function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity
}

在上面的代码中,如果 reading 的逻辑发生了改变,我们需要跨越好几个文件去调整它,这很容易造成遗漏的发生。

由于每个地方都对 reading 进行了操作,那么我们可以将其封装起来,统一在一个文件中进行管理。


// File Reading.js

class Reading {
 constructor(data) {
  this.customer = data.customer
  this.quantity = data.quantity
  this.month = data.month
  this.year = data.year
 }

 get baseRate() {
  /* ... */
 }

 get baseCharge() {
  return baseRate(this.monththis.year) * this.quantity
 }

 get taxableCharge() {
  return Math.max(0, base - taxThreshold())
 }

 get taxThreshold() {
  /* ... */
 }
}

const reading = new Reading({ customer'Evan You'quantity10month8year2021 })

所有的相关逻辑在一起,不仅能提供一个共用的环境,也可以简化调用逻辑,更加清晰。

3.5 发散式变化

当某个函数会因为不同原因在不同方向上发生变化时,发散式变化就诞生了。这听起来有点迷糊,那么就用代码来解释吧。


function getPrice(order) {
  // 获取基础价格
  const basePrice = order.quantity * order.itemPrice
  // 获取折扣
  const quantityDiscount = Math.max(0, order.quantity - 500) * order.itemPrice * 0.05
  // 获取运费
  const shipping = Math.min(basePrice * 0.1100)
  // 计算价格
  return basePrice - quantityDiscount + shipping
}

const orderPrice = getPrice(order);

这个函数用于计算商品的价格,它的计算包含了基础价格 + 数量折扣 + 运费,如果基础价格的计算规则改变,我们需要修改这个函数;如果折扣规则发生改变,我们需要修改这个函数;如果运费计算规则改变了,我们还是要修改这个函数。

这种修改容易造成混乱,我们当然也希望程序一旦需要修改,我们就够跳到系统的某一点,所以是时候抽离它们了。


// 计算基础价格
function calBasePrice(order) {
    return order.quantity * order.itemPrice
}
// 计算折扣
function calDiscount(order) {
    return Math.max(0, order.quantity - 500) * order.itemPrice * 0.05
}
// 计算运费
function calShipping(basePrice) {
    return Math.min(basePrice * 0.1100)
}

// 计算商品价格
function getPrice(order) {
    return calBasePrice(order) - calDiscount(order) + calShipping(calBasePrice(order))
}

const orderPrice = getPrice(order)

虽然该函数行数不多,当其重构的过程与先前的过长函数一致,但是将各个功能抽离处理,有利于更清晰的定位问题与修改。所以过长函数拥有多重臭味道!需要及时消灭。

3.6 去掉 if...else 的七种绝佳之法

方法一:提前 return

假如有如下代码:

if (condition){
  doSomething;
} else {
  return;
}

这种代码我们一般采用提前 return 的方式,去掉不必要的 else。

if (!condition){
  return
}
doSomething;

这种方法一般只适合分支结构很简单的 if...else,我们可以提前 return ,把一些不必要的 if...else 去掉

方法二:枚举

枚举其实也是可以去掉 if...else 的,如下:

String orderStatusDes;
if ("1".equals(orderStatus)) {
    orderStatusDes = "订单未支付";
} else if ("2".equals(orderStatus)) {
    orderStatusDes = "订单已支付";
} else if ("3".equals(orderStatus)) {
    orderStatusDes = "订单已发货";
} else if ("4".equals(orderStatus)) {
    orderStatusDes = "订单已签收";
} else if ("5".equals(orderStatus)) {
    orderStatusDes = "订单已评价";
}

可能有小伙伴说,靠,谁会写这种代码?别这么绝对,大明哥工作这么久了,到现在依然看到有工作 5 、6 年的人写这样的代码。这种类型的代码非常适合枚举来解决。

先定义一个枚举类:

@Getter
@AllArgsConstructor
public enum OrderStatusEnum {
    UN_PAID("1","订单未支付"),
    PAIDED("2","订单已支付"),
    SENDED("3","订单已发货"),
    SINGED("4","订单已签收"),
    EVALUATED("5","订单已评价");

    private String status;

    private String statusDes;

    static OrderStatusEnum of(String status) {
        for (OrderStatusEnum statusEnum : OrderStatusEnum.values()) {
            if (statusEnum.getStatus().equals(status)) {
                return statusEnum;
            }
        }
        return null;
    }
}

有了这个枚举,上面代码直接可以优化为一行代码:

String orderStatusDes = OrderStatusEnum.of(orderStatus).getStatusDes();

当然一般在实际项目中,这种处理方式也不是最佳的,最佳的方式应该是在数据库里面有一个码值配置表,然后加载到系统缓存中来,在通过 code 去取值。当然枚举也是一种很好的解决方案。

方案三:Optional 判空

我相信各位小伙伴的项目里面一定存在非空判断,如果为空,则抛出异常或者 return。

Order order = getOrderById(id);
if (order == null) {
    return "-1";
} else {
    return order.getOrderStatus();
}

对于这种代码我们利用 Optional 可以非常优雅地解决。

return Optional.ofNullable(order).map(o -> o.getOrderStatus()).orElse("-1");

这种方式是不是非常优雅,有格调

方案四:Function

Function 是 Java 8 中的函数式接口,利用好它我们可以极大地简化我们的代码,例如利用它我们可以轻松去掉我们的 if...else。比如有下面一段代码:

// 抛出异常
if (...) {
  throw new RuntimeException("哎呀,有异常哦...")
}

// if...else 分支
if(...) {
  doSomething1();
} else {
  doSomething2();
}

现在我们利用 Function 来处理上面两段代码

处理抛出异常
  • 定义抛出异常的形式的函数式接口
@FunctionalInterface
public interface ThrowExceptionFunction {

    /**
     * 抛出异常
     * @param message
     */
    void throwMessage(String message);
}

这里只需要有一个这样的函数式接口就行,而且方法也没有返回值,是一个消费型接口。

  • 增加判断工具类
public class ValidateUtils {

    /**
     * 抛出异常
     * @param flag
     * @return
     */
    public static ThrowExceptionFunction isTrue(Boolean flag) {
        return (errorMessage) -> {
            if (flag) {
                throw new RuntimeException(errorMessage);
            }
        };
    }
}

ValidateUtils 类也是非常简单的,如果传入的 flag 为 true,则抛出异常。isTrue() 返回值也是刚刚我们定义的 ThrowExceptionFunction。

  • 使用
ValidateUtils.isTrue(flag).throwMessage("哎呀,有异常哦...");

使用方式是不是非常简单?

处理 if...else 分支

其实使用 Function 来去掉 if...else 分支我认为有点儿偏门,因为它非常依赖我们定义的 Function 函数,比如我们定义的方法只有两个参数,那它就只能处理处理两个分支的,对于三个分支的 if...else 则需要重新定义方法。下面以两个分支为例。

  • 定义函数式接口
@FunctionalInterface
public interface ActionHandler {
    void doActionHandler(ActionService trueActionService,ActionService falseActionService);
}

函数式接口中定义了一个方法,doActionHandler(),它有两个参数,分别为:

  1. trueActionService:为 true 时要进行的操作
  2. falseActionService:为 false 时要进行的操作
  • 定义判断方法

增加一个工具类,用来判断为 true 时执行哪个方法,为 false 时执行哪个方法。

public class ActionHandlerUtils {

    public static ActionHandler isTrue(Boolean flag) {
        return (trueActionService,falseActionService) -> {
            if (flag) {
                trueActionService.doAction();
            } else {
                falseActionService.doAction();
            }
        };
    }
}
  • 使用
ActionHandlerUtils.isTrue(true)
        .doActionHandler(() -> {
            //do true Something
        },() ->{
            //do false Something
    });

方案五:表驱动法

表驱动法,是一种让你可以在表中查找信息,而不必用过多的 if...else 来把他们找出来的方法。如下:


if ("code1".equals(action)) {
    doAction1();
} else if ("code2".equals(action)) {
    doAction2();
} else if ("code3".equals(action)) {
    doAction3();
} else if ("code4".equals(action)) {
    doAction4();
} else if ("code5".equals(action)) {
    doAction5();
}

优化方法如下:

Map<String, Function<?> action> actionMap = new HashMap<>();
action.put("code1",() -> {doAction1()});
action.put("code2",() -> {doAction2()});
action.put("code3",() -> {doAction3()});
action.put("code4",() -> {doAction4()});
action.put("code5",() -> {doAction5()});

// 使用
actionMap.get(action).apply();

其实这种方式也不是很好,因为它会显得代码非常臃肿。一种变形方案是将 doAction() 抽象成类。如下:


//1. 先定义一个 ActionService 接口
public interface ActionService {
    void doAction();
}

//2. 然后定义 5 个实现类
public class ActionService1 implements ActionService{
    public void doAction() {
        //do something
    }
}

//3. 加入表中
Map<String, ActionService> actionMap = new HashMap<>();
action.put("code1",new ActionService1());
action.put("code2",new ActionService2());
action.put("code3",new ActionService3());
action.put("code4",new ActionService4());
action.put("code5",new ActionService5());

//4. 调用
actionMap.get(action).doAction();

这种方式是不是比较优雅些!

方案六:策略模式 + 工厂方法

策略模式 + 工厂方法是解决 if...else 用得非常多的方案,它和上面的表驱动法有点儿类似。使用策略模式 + 工厂方法分为几个步骤,以上面例子为例:

  • 把条件模块抽象为一个公共的接口,策略接口
public interface ActionService {
    void doAction();
}
  • 根据每个逻辑,定义出自己具体的策略实现类,如下:
public class ActionService1 implements ActionService{
    public void doAction() {
        //do something
    }
}

public class ActionService2 implements ActionService{
    public void doAction() {
        //do something
    }
}

// 省略其他策略
  • 工厂类,统一调度,用来管理这些策略,如下:
public class ActionServiceFactory {
    private ActionServiceFactory(){

    }

    private static class SingletonHolder{
        private static ActionServiceFactory instance=new ActionServiceFactory();
    }

    public static ActionServiceFactory getInstance(){
        return SingletonHolder.instance;
    }

    private static final Map<String,ActionService> ACTION_SERVICE_MAP = new HashMap<String, ActionService>();

    static {
        ACTION_SERVICE_MAP.put("action1",new ActionService1());
        ACTION_SERVICE_MAP.put("action2",new ActionService2());
        ACTION_SERVICE_MAP.put("action3",new ActionService3());
        ACTION_SERVICE_MAP.put("action4",new ActionService4());
        ACTION_SERVICE_MAP.put("action5",new ActionService5());
    }

    public static ActionService getActionService(String actionCode) {
        ActionService actionService = ACTION_SERVICE_MAP.get(actionCode);
        if (actionService == null) {
            throw new RuntimeException("非法 actionCode");
        }
        return actionService;
    }

    public void doAction(String actionCode) {
        getActionService(actionCode).doAction();
    }
}

单例模式实现工厂类。

  • 使用
ActionServiceFactory.getInstance().doAction("action1");

这种优化方式也是很优雅的,特别适合分支较多,逻辑较为复杂的代码块,这种方式将分支逻辑与业务代码解耦了,是一种很不错的方案。

方案七:责任链模式

你想不到责任链模式也能优化 if...else 吧。责任链我们可以看做是一个单链表的数据结构,一个对象一个对象地过滤条件,符合的就执行,然后结束,不符合的就传递到下一个节点,如果每个对象都无法处理,一般都有一个最终的节点来统一处理。

我们依然以上面那个例子为例。

  • 定义责任链处理请求节点
public abstract class ActionHandler {

    // 后继节点
    protected ActionHandler successor;

    /**
     * 处理请求
     * @param actionCode
     */
    public void handler(String actionCode) {
        doHandler(actionCode);
    }

    // 设置后继节点
    protected ActionHandler setSuccessor(ActionHandler successor) {
        this.successor = successor;
        return this;
    }

    // 处理请求
    public abstract void doHandler(String actionCode);
}
  • 定义首尾节点,用于一些异常情况的处理
// 首节点,判断 actionCode 是否为空
public class HeadHandler extends ActionHandler{

    @Override
    public void doHandler(String actionCode) {
        if (StringUtils.isBlank(actionCode)) {
            throw new RuntimeException("actionCode 不能为空");
        }

        successor.doHandler(actionCode);
    }
}

// 尾节点,直接抛出异常,因为到了尾节点说明当前 code 没有处理
public class TailHandler extends ActionHandler{

    @Override
    public void doHandler(String actionCode) {
        throw new RuntimeException("当前 code[" + actionCode + "] 没有具体的 Handler 处理");
    }
}
  • 定义各个节点具体的实现节点
public class ActionHandler1 extends ActionHandler{

    @Override
    public void doHandler(String actionCode) {
        if ("action1".equals(actionCode)) {
            doAction1();
        } else {
            // 传递到下一个节点
            successor.doHandler(actionCode);
        }
    }
}

public class ActionHandler2 extends ActionHandler{

    @Override
    public void doHandler(String actionCode) {
        if ("action2".equals(actionCode)) {
            doAction2();
        } else {
            // 传递到下一个节点
            successor.doHandler(actionCode);
        }
    }
}

// 省略其他节点
  • 定义工厂,来构建一条完整的责任链,并负责调度
public class ActionHandlerFactory {
    
    private ActionHandler headHandler;
    
    private ActionHandlerFactory(){
        headHandler = new HeadHandler();
        ActionHandler actionHandler1 = new ActionHandler1();
        ActionHandler actionHandler2 = new ActionHandler2();
        ActionHandler actionHandler3 = new ActionHandler3();
        ActionHandler actionHandler4 = new ActionHandler4();
        ActionHandler actionHandler5 = new ActionHandler5();

        ActionHandler tailHandler = new TailHandler();
        
        // 构建一条完整的责任链
        headHandler.setSuccessor(actionHandler1).setSuccessor(actionHandler2).setSuccessor(actionHandler3).
                setSuccessor(actionHandler4).setSuccessor(actionHandler5).setSuccessor(tailHandler);
    }

    private static class SingletonHolder{
        private static ActionHandlerFactory instance=new ActionHandlerFactory();
    }

    public static ActionHandlerFactory getInstance(){
        return SingletonHolder.instance;
    }
        
    public void doAction(String actionCode) {
        headHandler.doHandler(actionCode);
    }
}
  • 使用
ActionHandlerFactory.getInstance().doAction("action1");

总结

大明哥在这里总结了 7 中方式用来解决 if...else 的问题,我相信里面总有一两种方案是你比较满意的,七种方案各有优劣,各自有各自的使用场景,我们需要在实践中不断领悟,在重构中不断进化,总结出适合自己最佳的重构方案。

3.7

3.8

3.9

3.10