美文网首页设计模式
【设计模式】规范与重构

【设计模式】规范与重构

作者: allen218 | 来源:发表于2020-09-12 11:29 被阅读0次

    1. 重构的目的?

    重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。

    1.1 重构不改变软件的可见行为

    也就是在保证功能不变的前提下,利用设计思想、原则、模式编程规范等理论来优化代码,修改设计上的不足,提高代码质量。

    2. 为什么要重构

    1. 重构是保证代码质量的有效手段
    2. 重构是避免前期过度设计的有效手段
    3. 重构可以提供工程师的代码能力

    2.1 重构对于工程师能力提升的重要性

    初级工程师在维护代码,高级工程师在写代码,资深工程师在重构代码。

    意思是初级工程师在原有的代码上修改 bug,增加或修改功能。高级工程师从零开始设计代码结构、搭建代码框架;而资深工程师为代码质量负责,需要发觉代码存在的问题。

    3. 重构的对象

    根据重构的规模,分为大规模高层次重构和小规划低层次重构。

    大型重构指的是:对顶层代码的重构,包括系统、模块、代码结构及类与类之间的关系等。常用的手段有:分层、模块化、解耦、抽象可复用组件等。重构的工具常用的有:设计思想、原则和设计模式。

    小型重构指的是:对代码细节的重构,主要针对类、函数、变量等代码级别的重构。常见的有:规范命名、规范注释、消除超大类或函数、提取重复代码等。常用的工具有编码规范。

    4. 重构的时机:什么时候重构

    一般的重构策略是持续重构。平时事情不多的时候,就看看代码有哪些不好的地方,优化一下。或者在修改,添加某个功能的时候,顺便把存在问题的代码重构一下。

    5. 重构的方法

    5.1 大型重构的方法

    对于大型重构而言,需要分阶段进行。每个阶段完成一小部分的代码重构,然后,提交、测试和运行。如果没有问题后,再进行下一阶段的重构。

    5.2 小型重构的方法

    由于小型重构往往影响较小,改动耗时较短,所以,只要你愿意,什么时候都可以进行重构。

    5.3 重构的负责人

    常常需要资深的工程师,项目 Leader 来负责。

    6. 单元测试

    6.1 什么单元测试

    集成测试

    集成测试的测试对象是整个系统或者某个功能模块。比如:用户注册、登录模块。

    单元测试

    单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期逻辑执行。

    6.2 单元测试的作用

    1. 单元测试能有效地帮你发现代码中的 bug,写出 bug free 的代码
    2. 写单元测试能帮你发现代码设计上的问题,代码的可测试性是评判代码质量的一个重要标准
    3. 单元测试的对集成测试的补充,集成测试往往无法覆盖代码实现的方方面面
    4. 写单元测试的过程本身就是代码重构的过程,在写单元测试的过程中,就相当于是对代码的一次 Code View
    5. 阅读单元测试能帮助你快速熟悉代码
    6. 单元测试是 TDD 可落地执行的改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回头对代码进行重构

    6.3 单元测试覆盖率存在问题

    单元测试覆盖率常常基于所有方法覆盖测试的百分比来计算的。

    而在代码编写过程中,并不是所有方法都需要被覆盖的,比如:get/set,而实现应该关注的是:需要添加单元测试的类或函数的测试是否足够全面,是否覆盖了各种输入、异常、边界条件等测试用例

    6.4 单元测试不需要了解代码的实现逻辑

    单元测试不需要依赖被测试函数的具体实现逻辑,它只关心被测试函数实现了什么功能。

    6.5 Google 内部对待单元测试的态度

    很多项目几乎没有测试团队参与,代码的正确性完全靠开发团队来保障。

    7. 代码的可测试性

    什么是代码的可测试性?

    所谓代码的可测试性,就是针对代码编写单元测试的难易程度。

    7.1 单元测试改造前代码

    public class Transaction {
      private String id;
      private Long buyerId;
      private Long sellerId;
      private Long productId;
      private String orderId;
      private Long createTimestamp;
      private Double amount;
      private STATUS status;
      private String walletTransactionId;
      
      // ...get() methods...
      
      public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
          this.id = preAssignedId;
        } else {
          this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
          this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.status = STATUS.TO_BE_EXECUTD;
        this.createTimestamp = System.currentTimestamp();
      }
      
      public boolean execute() throws InvalidTransactionException {
        if ((buyerId == null || (sellerId == null || amount < 0.0) {
          throw new InvalidTransactionException(...);
        }
        if (status == STATUS.EXECUTED) return true;
        boolean isLocked = false;
        try {
          isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
          if (!isLocked) {
            return false; // 锁定未成功,返回false,job兜底执行
          }
          if (status == STATUS.EXECUTED) return true; // double check
          long executionInvokedTimestamp = System.currentTimestamp();
          if (executionInvokedTimestamp - createdTimestap > 14days) {
            this.status = STATUS.EXPIRED;
            return false;
          }
          WalletRpcService walletRpcService = new WalletRpcService();
          String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
          if (walletTransactionId != null) {
            this.walletTransactionId = walletTransactionId;
            this.status = STATUS.EXECUTED;
            return true;
          } else {
            this.status = STATUS.FAILED;
            return false;
          }
        } finally {
          if (isLocked) {
           RedisDistributedLock.getSingletonIntance().unlockTransction(id);
          }
        }
      }
    }
    

    该代码需要包含以下测试用例:

    1. 正常情况下,交易执行成功,交易状态设置为 EXECUTED,函数返回成功
    2. buyId、sellId 为 null,amount 小于 0,返回 InvalidTransactionException
    3. 交易已过期,交易状态为 EXPIRED,返回 false
    4. 交易已经执行了,不再重复执行,返回 true
    5. 钱包转钱失败,交易状态为 FAILED,函数返回 false
    6. 交易正在执行,不会被重复执行,返回 false

    7.2 测试用例 1

    单元测试主要是测试程序员自己写的代码逻辑是否存在问题,并不需要测试所依赖系统或服务逻辑的正确性。所以,如果代码中依赖了外部系统或者不可控组件,如:数据库,网络服务和文件系统等,就需要将被测试代码与外部系统解依赖,解依赖的方法就是 mock

    1. 使用 mock 替换 WalletRpcService 服务

    对于上述代码中的 WalletRpcService 服务,需要将其 mock,具体做法为:

    1. 自定义 mock 类继承 WalletRpcService 类
    public class MockWalletRpcServiceOne extends WalletRpcService {
      public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
        return "123bac";
      } 
    }
    
    public class MockWalletRpcServiceTwo extends WalletRpcService {
      public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
        return null;
      } 
    }
    
    1. excute() 方法进行重构,通过依赖注入的方式引入 WalletRpcService 服务
    public class Transaction {
      //...
      // 添加一个成员变量及其set方法
      private WalletRpcService walletRpcService;
      
      public void setWalletRpcService(WalletRpcService walletRpcService) {
        this.walletRpcService = walletRpcService;
      }
      // ...
      public boolean execute() {
        // ...
        // 删除下面这一行代码
        // WalletRpcService walletRpcService = new WalletRpcService();
        // ...
      }
    }
    
    1. 使用 mock 替换 WalletRpcService 服务
    public void testExecute() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
      // 使用mock对象来替代真正的RPC服务
      transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
      boolean executedResult = transaction.execute();
      assertTrue(executedResult);
      assertEquals(STATUS.EXECUTED, transaction.getStatus());
    }
    

    2. 使用 mock 替换 RedisDistributedLock

    mock 单例类存在的问题:单例类相当于一个全局变量,我们无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。

    1. 将单例类重新使用普通类封装
    public class TransactionLock {
      public boolean lock(String id) {
        return RedisDistributedLock.getSingletonIntance().lockTransction(id);
      }
      
      public void unlock() {
        RedisDistributedLock.getSingletonIntance().unlockTransction(id);
      }
    }
    
    1. 将 RedisDistributedLock 重构为通过依赖注入引入
    public class Transaction {
      //...
      private TransactionLock lock;
      
      public void setTransactionLock(TransactionLock lock) {
        this.lock = lock;
      }
     
      public boolean execute() {
        //...
        try {
          isLocked = lock.lock();
          //...
        } finally {
          if (isLocked) {
            lock.unlock();
          }
        }
        //...
      }
    }
    
    1. 创建 TransactionLock 的 mock 对象,并复写其真实方法,返回我们想要的任何结果
    public void testExecute() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      
      TransactionLock mockLock = new TransactionLock() {
        public boolean lock(String id) {
          return true;
        }
      
        public void unlock() {}
      };
      
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
      transaction.setWalletRpcService(new MockWalletRpcServiceOne());
      transaction.setTransactionLock(mockLock);
      boolean executedResult = transaction.execute();
      assertTrue(executedResult);
      assertEquals(STATUS.EXECUTED, transaction.getStatus());
    }
    

    mock 是什么

    所谓 mock 就是用一个“假”的服务替换掉真的服务,由于 mock 的服务完全在我们的控制之下,所以,完全可以模拟输出我们想要的数据。

    7.3 测试用例 3

    public void testExecute_with_TransactionIsExpired() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
      transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
      boolean actualResult = transaction.execute();
      assertFalse(actualResult);
      assertEquals(STATUS.EXPIRED, transaction.getStatus());
    }
    

    上面的代码来写单元测试没有问题,但是如果 setCreatedTimestamp() 没有提供,而是在构造函数中自动生成的,该如何完成单元测试呢?这同样的是在写单元测试过程中一类常见的问题,就是代码中包含时间有关的“未决行为”逻辑。

    解决方法:将这种未决行为的行为逻辑重新封装。上面的代码中,我们需要把原有代码的实现进行重构,把将交易上否过期的逻辑,封装到 isExpired() 函数即可。

    1. 封装时间过期的逻辑
    public class Transaction {
    
      protected boolean isExpired() {
        long executionInvokedTimestamp = System.currentTimestamp();
        return executionInvokedTimestamp - createdTimestamp > 14days;
      }
      
      public boolean execute() throws InvalidTransactionException {
        //...
          if (isExpired()) {
            this.status = STATUS.EXPIRED;
            return false;
          }
        //...
      }
    }
    
    1. 创建 Transaction 对象,并复写 isExpired() 方法
    public void testExecute_with_TransactionIsExpired() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
        protected boolean isExpired() {
          return true;
        }
      };
      boolean actualResult = transaction.execute();
      assertFalse(actualResult);
      assertEquals(STATUS.EXPIRED, transaction.getStatus());
    }
    

    7.4 测试方法总结

    1. 对普通对象进行 mock 的方法

    1. 继承原对象,并复写对应的方法,得到我们想要的任何结果
    2. 将原对象的依赖关系改为依赖注入的方式进行依赖
    3. 在单元测试代码中,将 mock 对象通过依赖注入的方式注入到类中

    2. 对单例类进行 mock 的方法

    1. 将单例类提供的功能,通过新的普通类进行封装
    2. 创建 1 中的普通类的实例对象,并复写对应的方法,得到我们想要的任何结果
    3. 对原有代码进行重构,将直接依赖单例的地方,改为依赖封装了单例类的普通对象;并通过依赖注入的方式依赖此普通对象
    4. 在单元测试代码中,创建普通对象的实现,并将其通过依赖注入的方式注入到类中

    3. 针对时间等未决行为的处理方法

    1. 将未决进行在原代码中进行重构,将其通过单独的方法封装
    2. 在单元测试代码中,创建待测试对象时,复写未决行为方法,返回我们想要的任何结果

    7.5 常见的测试性不好的代码

    1. 未决行为

    所谓未决行为就是代码输出是随机或者说不确定的。比如和时间、随机数有关的代码。

    2. 全局变量

    public class RangeLimiter {
      private static AtomicInteger position = new AtomicInteger(0);
      public static final int MAX_LIMIT = 5;
      public static final int MIN_LIMIT = -5;
    
      public boolean move(int delta) {
        int currentPos = position.addAndGet(delta);
        boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT);
        return betweenRange;
      }
    }
    
    public class RangeLimiterTest {
      public void testMove_betweenRange() {
        RangeLimiter rangeLimiter = new RangeLimiter();
        assertTrue(rangeLimiter.move(1));
        assertTrue(rangeLimiter.move(3));
        assertTrue(rangeLimiter.move(-5));
      }
    
      public void testMove_exceedRange() {
        RangeLimiter rangeLimiter = new RangeLimiter();
        assertFalse(rangeLimiter.move(6));
      }
    }
    

    如果上面的 testMove_betweenRangetestMove_exceedRange 顺序执行,由于全局变量的存在,testMove_betweenRange 方法执行后,posion 的值一直存在,而导致 testMove_exceedRange 方法执行断言失败。

    3. 静态方法

    主要原因是静态方法很难 mock。当然,需要分情况来看,只有静态方法耗时太长、依赖外部资源、逻辑复杂、存在未决行为等的情况下,我们才需要在单元测试中对其进行 mock 操作。如果只是简单的静态方法,如:math.abs(),并不需要对其进行 mock。

    4. 复杂继承

    如果在父类中使用了外部对象,需要对其进行 mock 后才能运行单元测试,那么所有子类在编写单元测试的时候都需要 mock 这个依赖对象。

    如果继承关系过于复杂,越是底层的子类,需要 mock 的依赖类就越多。

    如果继承关系比较复杂的情况下,需要通过组合、接口和委托的方式对其进行重构。

    5. 高耦合代码

    如果一个类的职责很重,需要依赖十几个外部对象才能工作,在编写单元测试的时候,可能就需要编写十几个 mock 对象,这显然会大大增加编写单元测试的成本。

    8. 如何给代码解耦

    解耦的作用:是控制代码复杂度的有效手段,利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。

    8.1 封装和抽象

    通过封装和抽象,可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。

    8.2 引入中间层

    让两两之间存在依赖关系中的多个类,利用中介者设计模式,让其共同依赖同一个中介类,来降低类之间依赖的复杂性。

    同时,在重构的过程中,引入中间层可以起到过渡的作用,能够让开发和重构同步执行,不互相干扰。当某个接口开发设计得有问题,我们需要修改它的定义,同时,所有调用它的地方都要有相应的改动。如果新开发的代码也用到了这个接口,那开发和重构就冲突了。为了让重构能够小步快跑,可以分四个阶段来完成上述接口的修改:

    1. 引入一个中间层,包裹老的接口,同时提供新的接口定义
    2. 新开发的代码依赖中间层提供的新接口
    3. 将依赖老接口的代码改成依赖新的接口
    4. 确保所有的代码都依赖新接口后,删除老接口

    8.3 模块化

    1. 对于一个复杂的系统来说,将系统划分成各个独立的模块,让不同的人复杂不同的模块,即使在不了解全部实现细节的情况下,管理者也可能协调各个模块,让系统稳定运转
    2. 对于软件开发来说,不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小团队只需要聚焦于一个独立的高内聚模块来开发
    3. 对于代码层面来说,合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。

    8.4 单一职责原则

    模块或类的职责设计单一,而不是大而全,依赖它的类或者它依赖的类就会比较少,代码耦合也就相应的降低了。

    8.5 基于接口而非实现编程

    通过接口这个中间层,隔离变化和具体的实现。在有依赖关系的模块或类之间,一方的改变,不会影响到另一方。

    8.6 依赖注入

    依赖注入是将代码之间的强耦合变为弱耦合,尽管依赖注入无法将本来有依赖关系的两个类解耦为没有依赖关系,但可以让依赖关系变得没有那么紧密,容易做到插拔。

    8.7 多用组合少用继承

    和依赖注入类似,或者说组合就是依赖注入的一种具体实现方式。通过组合让原本强耦合关系并成一种弱耦合关系,同样的,也容易做到插拔。

    8.8 迪米特原则

    不该有依赖关系的类之间不要有依赖;有依赖关系的两个类之间,尽量只依赖其必要的接口。明显看出,这也是一种降低类之间耦合度的一种方式。

    9. 编程规范

    9.1 命名

    1. 名字太长,由于代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码可读性。在能达意的情况下,尽量用较短的命名。比如:大家较熟悉的词,就建议用缩写;对于作用域比较小的变量(如函数内临时变量),可以用相对短的命名
    2. 利用上下文简化命名。如:类名为 User,那么里面的属性就可以直接使用 name,而没有必要再使用 username
    3. 命名要可读可搜索。可读指的是不要用大家都看不懂的单词来命名;可搜索指的是通过在写代码的时候,方便地联想出对应的函数,如:通过 get 就能找到获取当前对象中的所有函数,而不能说有的地方用的 acquire,这就要求大家在命名时,最好能符合项目的命名习惯
    4. 对于不同作用域的命名,我们可以适当选择不同的长度,作用域小的变量,可以适当选择一些短的命名方式

    9.2 注释

    类和函数一定要写注释,而且需要尽量详细一些,而函数内部的注释相对少一些,一般要通过好的命名、提炼函数、解释性变量、总结性注释来提高代码的可读性。

    9.3 代码风格

    1. 对于函数的代码行数,一般不要超过一屏幕垂直高度,也就是让一个函数的代码完整地显示在屏幕上。对于类的代码行数有一个间接的评估标准,那就是,实现功能不知道要用某个函数了,想用哪个函数半天也没有找到,只用一个小功能,而需要引入整个类的时候,说明类的行数过多了
    2. 一行代码的最长不要超过一屏幕的宽度,如果需要通过鼠标才能查看一行的全部代码,显然不利用代码的阅读
    3. 善用代码行分割单元块。如果逻辑上可以将函数内部的实现分为相对独立的代码块,而代码块又不太需要抽成单独方法的时候,可以用空行来将其分割。以外,还可以在类的成员变量和函数之间,静态成员变量和成员变量之间、各函数之间、各成员变量之间通过空行来进行分割
    4. 类中函数和变量的排列顺序。静态成员变量 -> 成员变量 -> 静态方法 -> 普通方法,同时,成员变量之间或者方法之间,按照作用域,先写作用域大的,如:public 变量或方法

    9.4 编程技巧

    1. 对于较复杂的逻辑,利用模块化和抽象思维,把代码分割成更小单元块
    2. 避免函数参数过多。函数参数大于 5 个左右的时候,就需要考虑是否需要将函数拆分成多个函数;或者使用对象来替代普通的参数传递
    3. 勿用函数参数来控制逻辑。不要使用 boolean 参数来控制函数的执行逻辑,而是通过分拆为两个函数来实现
    4. 函数设计尽可能职责单一
    5. 移除过深的代码嵌套层次。一般建议嵌套层次不超过 2 层
    6. 善于使用解释型变量来提高代码的可读性。如:常量代码魔法数;

    说明

    此文是根据王争设计模式之美相关专栏内容整理而来,非原创。

    相关文章

      网友评论

        本文标题:【设计模式】规范与重构

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