设计原则与思想:规范与重构 理论一 - 三 什么情况下要重构?到底重构什么?又该如何重构?有哪些非常能落地的技术手段?如何写出可测试性好的代码?

理论一:什么情况下要重构?到底重构什么?又该如何重构?

  1. 重构的目的:为什么要重构(why)?

    • 对于项目来言,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。

    • 对于个人而言,重构非常锻炼一个人的代码能力,并且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式、编程规范等理论知识的练兵场。

  2. 重构的对象:到底重构什么(what)?

    • 按照重构的规模,我们可以将重构大致分为大规模高层次的重构和小规模低层次的重构。

    • 大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等
      等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。

    • 小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等等编程细节问题,主要是针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论知识。

  3. 重构的时机:什么时候重构(when)?

    • 特别提倡的重构策略是持续重构。
      我反复强调,我们一定要建立持续重构意识,把重构作为开发必不可少的部分,融入到日常开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。

    • 平时没有事情的时候,你可以看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。或者,在修改、添加某个功能代码的时候,你也可以顺手把不符合编码规范、不好的设计重
      构一下。总之,就像把单元测试、Code Review 作为开发的一部分,我们如果能把持续重构也作为开发的一部分,成为一种开发习惯,对项目、对自己都会很有好处。

  4. 重构的方法:如何重构(how)?

    • 大规模高层次的重构一定是有组织、有计划,并且非常谨慎的,需要有经验、熟悉业务的资深同事来主导。

    • 小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时都可以去做。
      我们还可以借助很多成熟的静态代码分析工具(比如 CheckStyle、FindBugs、PMD),来自动发现代码中的问题,然后针对性地进行重构优化。

理论二:为了保证重构不出错,有哪些非常能落地的技术手段?

  • 最可落地执行、最有效的保证重构不出错的手段应该就是单元测试(Unit Testing)了。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有逻辑的正确性未被破坏,原有的外部可见行为未变,符合上一节课中我们对重构的定义。
  1. 什么是单元测试?

    • 集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。

    • 单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。这个“单元”一般是类或函数,而不是模块或者系统。

  2. 为什么要写单元测试?

    • 除此之外,单元测试还是对集成测试的有力补充,还能帮助我们快速熟悉代码,是 TDD 可落地执行的改进方案。
    1. 单元测试能有效地帮你发现代码中的 bug。

      • 通过单元测试也常常会发现代码中的很多考虑不全面的地方。 提高写出 bug free 的代码
    2. 写单元测试能帮你发现代码设计上的问题

      • 如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函
        数、全局变量、代码高度耦合等。
    3. 单元测试是对集成测试的有力补充

      • 如果我们能保证每个类、每个函数都能按照我们的预期来执行,底层 bug 少了,那组装起来的整个系统,出问题的概率也就相应减少了。
    4. 写单元测试的过程本身就是代码重构的过程

      • 编写单元测试就相当于对代码的一次自我 Code Review,在这个过程中,我们可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构。
    5. 阅读单元测试能帮助你快速熟悉代码

      • 阅读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。但大部分程序员不写文档和注释,变量名起的也不好,这时候就可以借助单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。
    6. 单元测试是 TDD 可落地执行的改进方案

      • 曲线救国。我个人觉得,单元测试正好是对 TDD 的一种改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码。这个开发流程更加容易被接受,更加容易落地执行,而且又兼顾了 TDD 的优点。
  3. 如何编写单元测试?

    1. 写单元测试真的是件很耗时的事情吗?

      • 虽然代码可能比我们自己写的代码多1-2倍,但不耗时,因为编写测试简单,且不同测试用例之间的代码差别可能并不是很大,简单 copy-paste 改改就行。
    2. 对单元测试的代码质量有什么要求吗?

      • 单元测试毕竟不会在产线上运行,而且每个类的测试代码也比较独立,基本不互相依赖。所以,相对于被测代码,我们对单元测试代码的质量可以放低一些要求。命名稍微有些不规范,代码稍微有些重复,也都是没有问题的。
    3. 单元测试只要覆盖率高就够了吗?

      • 实际上,过度关注单元测试的覆盖率会导致开发人员为了提高覆盖率,写很多没有必要的测试代码,比如 get、set 方法非常简单,没有必要测试。从过往的经验上来讲,一个项目的单元测试覆盖率在 60~70% 即可上线。如果项目对代码质量要求比较高,可以适当提高单元测试覆盖率的要求。
    4. 写单元测试需要了解代码的实现逻辑吗?

      • 单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。
      • 我们切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试。否则,一旦对代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改,那原本的单元测试都会运行失败,也就起不到为重构保驾护航的作用了,也违背了我们写单元测试的初衷。
    5. 如何选择单元测试框架?

      • 使用公司统一用的测试框架即可
  4. 单元测试为何难落地执行?

    • 一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写

    • 另一方面,国内研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。最后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行得很好。

理论三:什么是代码的可测试性?如何写出可测试性好的代码?

单元测试定义(解答了为什么我们都假设外部依赖返回都是我们预期结果?):单元测试主要是测试程序员自己编写的代码逻辑的正确性,并非是端到端的集成测试,它不需要测试所依赖的外部系统(分布式锁、WalletRPC 服务)的逻辑正确性。所以,如果代码中依赖了外部系统或者不可控组件,比如,需要依赖数据库、网络通信、文件系统等,那我们就需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作“mock”。所谓的 mock 就是用一个“假”的服务替换真正的服务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据。

  1. 什么是代码的可测试性?

    • 代码的可测试性,就是针对代码编写单元测试的难易程度。
      编写单元测试的难易程度 ≈ 代码设计得合不合理
  2. 编写可测试性代码的最有效手段

    • 依赖注入是编写可测试性代码的最有效手段。通过依赖注入,我们在编写单元测试的时候,可以通过 mock 的方法解依赖外部服务,这也是我们在编写单元测试的过程中最有技术挑战的地方。
  3. 其他常见的 Anti-Patterns

    1. 代码中包含未决行为(不确定)逻辑

      • 定义:所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。

      • 解决:我们一般的处理方式是将这种未决行为逻辑重新封装。针对 Transaction 类,我们只需要将交易是否过期的逻辑,封装到 isExpired() 函数中即可

      • 针对封装的函数是否需要测试?看情况,对于
        isExpired() 函数,逻辑非常简单,肉眼就能判定是否有 bug,是可以不用写单元测试的。

    2. 滥用可变全局变量

      • 问题:全局变量生命周期和类相关,可能造成测试不符合预期

      • 解决:新增reset全局变量的函数。不过,每个单元测试框架执行单元测试用例的方式可能是不同的。有的是顺序执行,有的是并发执行。对于并发执行的情况,即便我们每次都把 position 重设为 0,也并不奏效。

    3. 滥用静态方法

      • 这个要分情况来看。只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,我们才需要在单元测试中 mock 这个静态方法。除此之外,如果只是类似Math.abs() 这样的简单静态方法,并不会影响代码的可测试性,因为本身并不需要mock。
    4. 使用复杂的继承关系

      • 问题:如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。继承结构越深,我们mock依赖对象越多,还要去查看父类mock就容易漏

      • 解决:利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可。

    5. 高度耦合的代码

      • 如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那我们在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的。

      • 问题:我们今天讲到,依赖注入是提高代码可测试性的最有效的手段。所以,依赖注入,就是不要在类内部通过 new 的方式创建对象,而是要通过外部创建好之后传递给类使用。那是不是所有的对象都不能在类内部创建呢?哪种类型的对象可以在类内部创建并且不影响代码的可测试性?你能举几个例子吗?

new是JDK自己的对象就OK,否则是外部导入的依赖就不行了,未来我们可能会替换为别的实现方式,所以一般需要注入
ai回答(这里感觉补充“测试”这一个标准挺好的):总结来说,对于外部依赖、可能变化的依赖、需要复杂初始化的依赖以及需要在测试中模拟的依赖,使用依赖注入是一个很好的实践。而对于 JDK 内置对象、创建成本低的对象以及对性能要求极高的对象,可以考虑直接在类内部创建。
外部依赖:如果一个类需要使用到其他类的实例,而这些实例是由其他模块提供的,则应该通过依赖注入来提供这些实例。这样可以确保类之间的解耦,并使代码更加灵活。
可能变化的依赖:如果一个类依赖的对象在未来可能发生变化(例如,更换不同的实现),则应该使用依赖注入。这样可以更容易地进行替换,而不会影响到使用该依赖的类。
配置和初始化复杂的依赖:如果一个依赖需要复杂的配置或者初始化过程,那么使用依赖注入可以更好地管理和控制这些过程。
测试:如果一个类需要依赖的对象在测试时需要被模拟(mocks)或存根(stubs),那么应该使用依赖注入。这样可以更容易地控制和模拟依赖的行为,便于编写单元测试。
JDK 内置对象:一般来说,对于 JDK 自带的对象(如 String, Integer, List, Map 等),它们通常不需要依赖注入。这是因为这些对象通常是不可变的,且通常不需要特别的配置或初始化。

依赖注入可以通过mock的方法将不可控的依赖变得可控
除了mock方式,还可以利用二次封装来解决某些代码行为不可控的情况
  • 23
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值