美文网首页
编程的方法——重构、测试等

编程的方法——重构、测试等

作者: 天命_风流 | 来源:发表于2020-11-08 23:02 被阅读0次

    重构

    为什么要重构?

    • 重构代码是保持代码质量的一个极其有效的手段,它可以避免你的代码腐化到无可救药的地步。
    • 重构可以应对不断增加的需求,可以适应不断迭代的系统。
    • 有了重构的意识,可以避免你在前期设计代码的时候进行过度设计。

    重构的对象

    • 重构分为大型重构和小型重构。
    • 大型重构是对顶层代码的重构,它包括对系统、模块、代码接口、类之间的关系等的重构。在大型重构中,需要用到设计原则和设计模式。
    • 小型重构是对代码细节的重构,集中在类、函数、变量等级别。对于细节的重构,主要是规范代码细节,消除大型函数,提取重复代码等。

    重构的时机

    • 时时刻刻都应当有重构的思想。持续重构提供了一条可持续、可演进的方式。

    如何重构

    • 小型重构因为影响范围小,所以可以随时进行。
    • 大型重构的规模很大,很难在某段时间内完成,所以面对大型重构时,要分阶段进行。在某一段时间内,重构一小部分代码,保证代码仓库中的代码一直处于可运行,逻辑正确的状态。
      重构的过程中,要考虑好如何兼容老代码的逻辑。在必要的时候,你还可以写一些过渡性的代码。
    • 对于如何检查自己的代码在哪些方面需要重构,可以参考下面两个情况:


      常规检查事项
    业务相关检查事项

    测试(单元测试)

    概念

    • 单元测试是对函数、类一级别的代码进行的测试。
    • 单元测试不需要高深技术,更多的是考验程序员的思维缜密的程度。
    • 单元测试可以提高你的编码能力,让你写出 bug free 的代码。
    • 单元测试可以侧面考量你的代码设计,如果一段代码难以进行单元测试,那这段代码的设计可能是有问题的。
    • 单元测试需要考虑输入、异常、边界条件

    具体

    • 依赖注入是编写可测试性代码的最有效的手段。
    • 在测试中,我们会需要依赖外部服务的调用。
      1.这种调用可能会对测试造成影响,比如性能问题或者你并不知道这个外部服务会如何返回数据。
      2.你不是这部分代码的维护者,没有权限修改这部分代码。
      3.这种时候,你需要使用 mock + 依赖注入 的方式修改代码。对于任何有外部依赖服务的地方,都设置一个 set 方法和本地变量。这样我们只需要注入这部分代码即可。
    • 代码中经常有使用当前时间判断日期的这一类未决行为,代码逻辑要求我们对这部分代码保持封装,所以修改系统时间间接完成测试不是一个好方法。我们可以使用 isInTime() 这种方式,将判断时间是否䄦的代码封装进去,测试的时候重写这部分代码即可。
    • 有一些常见的,不好测试的代码,总结如下:
      1.未决行为:这种代码的输出是随机或者不确定的。我们可以使用 isxxx 的方式封装输出并在测试中重写。
      2.全局变量:一段代码如果使用了全局变量,多次连续的测试会让变量不可控,尤其是测试框架异步测试的时候。我们可以在每次测试完成后重置变量部分解决这个问题。
      3.静态方法:和全局变量的逻辑类似,但是如果这个方法非常简单,就并不需要 mock 这个方法。
      4.复杂继承:多层继承会导致需要 mock 的类变多,使用组合替代继承可以解决这个问题。
      5.高度耦合:没啥说的,解耦吧。

    解耦

    解耦的重要性

    • 解耦可以控制代码不至于复杂到无法控制的手段
    • 降低耦合,可以让我们不必了解太多其他模块的代码,降低我们的心智负担。
    • 高内聚、低耦合意味着代码结构清晰、分层和模块化合理、依赖关系简单等。这样的代码的整体质量就不会差。
    • 是否需要解耦:
      1.代码是否牵一发动全身。
      2.画出模块与模块、类与类之间的依赖关系。

    如何解耦

    使用封装与抽象:封装和抽象可以隐藏实现的复杂性,隔离实现的易变性。给依赖的模块提供稳定且易用的接口,是简化依赖关系的有效手段。

    中间层:引入中间层可以简化模块或类之间的依赖关系,如下图:

    image.png
    在引入中间层时,如何避免开发对现有业务的影响,你需要考虑重构的步骤:
    • 引入中间层,包含老接口,提供新接口
    • 新代码依赖新接口
    • 将依赖老接口的代码修改为调用新接口
    • 修改工作完成后,删掉老接口

    模块化:将每个模块都当做一个独立的 lib 去开发,只提供接口给外部使用,而不向其他模块暴露自身的细节。模块化的本质思想是分而治之

    其他设计原则

    • 单一职责:实现高内聚的原则
    • 基于接口编程:封装稳定接口,隔离不稳定的实现
    • 依赖注入:依赖注入无法解耦,但是可以做到插拔替换
    • 多用组合:继承是一种强依赖关系,父类和子类高度耦合
    • 迪米特法则:不该有依赖的类不要有依赖,有依赖的类只依赖不要的接口

    编程规范

    • 注释:包含三个方面:做什么、为什么、怎么做
    • 如果一个函数的代码过多,将这个函数拆分成多个函数,来简化理解
    • 不要使用参数来控制逻辑:依据参数控制程序的内部逻辑,明显违背了单一职责原则和接口隔离原则:
    public void buyCourse(long userId, long courseId, boolean isVip);
    
    // 将其拆分成两个函数
    public void buyCourse(long userId, long courseId);
    public void buyCourseForVip(long userId, long courseId);
    

    如果两段代码经常同时被调用,你也可以考虑将他们放在一起“

    // 拆分成两个函数的调用方式
    boolean isVip = false;
    //...省略其他逻辑...
    if (isVip) {
      buyCourseForVip(userId, courseId);
    } else {
      buyCourse(userId, courseId);
    }
    
    // 保留标识参数的调用方式更加简洁
    boolean isVip = false;
    //...省略其他逻辑...
    buyCourse(userId, courseId, isVip);
    

    还有一种情况,根据参数是否为 null 来控制逻辑,我们也应该将这种函数拆分:

    public List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
      if (startDate != null && endDate != null) {
        // 查询两个时间区间的transactions
      }
      if (startDate != null && endDate == null) {
        // 查询startDate之后的所有transactions
      }
      if (startDate == null && endDate != null) {
        // 查询endDate之前的所有transactions
      }
      if (startDate == null && endDate == null) {
        // 查询所有的transactions
      }
    }
    
    // 拆分成多个public函数,更加清晰、易用
    public List<Transaction> selectTransactionsBetween(Long userId, Date startDate, Date endDate) {
      return selectTransactions(userId, startDate, endDate);
    }
    
    public List<Transaction> selectTransactionsStartWith(Long userId, Date startDate) {
      return selectTransactions(userId, startDate, null);
    }
    
    public List<Transaction> selectTransactionsEndWith(Long userId, Date endDate) {
      return selectTransactions(userId, null, endDate);
    }
    
    public List<Transaction> selectAllTransactions(Long userId) {
      return selectTransactions(userId, null, null);
    }
    
    private List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
      // ...
    }
    
    • 函数的职责要单一:函数的代码更少,功能能多单一就多单一,例如下面的示例:
    public boolean checkUserIfExisting(String telephone, String username, String email)  { 
      if (!StringUtils.isBlank(telephone)) {
        User user = userRepo.selectUserByTelephone(telephone);
        return user != null;
      }
      
      if (!StringUtils.isBlank(username)) {
        User user = userRepo.selectUserByUsername(username);
        return user != null;
      }
      
      if (!StringUtils.isBlank(email)) {
        User user = userRepo.selectUserByEmail(email);
        return user != null;
      }
      
      return false;
    }
    
    // 拆分成三个函数
    public boolean checkUserIfExistingByTelephone(String telephone);
    public boolean checkUserIfExistingByUsername(String username);
    public boolean checkUserIfExistingByEmail(String email);
    

    异常

    函数运行的结果可以分成两类:一类是预期的结果,一类是非预期的异常。对异常的处理,常常影响代码的健壮。

    异常处理的返回

    • 返回错误码:C语言中没有异常处理机制,它对异常的处理就是返回一个错误码。一般来说,现在大多数的语言都会有异常处理机制,所以返回错误码的应用场景比较少。
    • 返回NULL:即返回一个“不存在”的定义,但是NULL会造成空指针异常的情况,所以不建议使用。
    • 返回空对象:个人认为是返回NULL的升级版。实际上,空对象是一种设计模式,在GO中大量应用了这种设计模式,在我看来,这也是一种很好用的设计。
      对于返回空对象来说,你可以注意一下:
      1.对于 search 等单词开头,执行查找的函数来说,数据不存在可能并不是一种异常情况,返回空对象不失为一个好选择。
      2.如果上游使用迭代或者 len 判断,返回空对象是非常合适的。
    • 抛出异常:这是最常用的函数错误处理方式,因为异常可以携带更多的错误信息(例如调用栈信息)。除此之外,异常还可以将正常和异常的逻辑分开,提高代码的可读性。但是,异常也会带来一些问题,过多的异常会让代码变得冗长。

    如何处理异常

    如果是查询数据出现问题,并且上游代码会对结果使用迭代处理,使用空对象处理会比较好。
    如果程序其他方面出现问题,使用异常机制来处理问题比价好。对于异常机制,有三种处理方式:

    • 直接吞掉:如果上游代码对下游的异常并不关心,且上游可以恢复这种异常。上游可以直接吞掉。
    • 对下游代码的异常直接向上抛出:如果上游代码的上游需要理解下游的异常,(或者说业务概念上有一定的相关性)可以直接抛出。
    • 包装下游抛出的异常,再抛出:如果下游的异常过于底层,上游的上游缺乏背景去理解,上游代码就可以包装这个异常。

    总之,一个异常是否向上继续抛出,要看上层代码是否关心这个异常。关心就将它抛出,不关心就吞掉。如果上层代码需要理解这个异常,业务相关,就直接抛出;否则就包装后再抛出。

    相关文章

      网友评论

          本文标题:编程的方法——重构、测试等

          本文链接:https://www.haomeiwen.com/subject/pvcpvktx.html