mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 22:53:42 +08:00
mod
This commit is contained in:
334
极客时间专栏/设计模式之美/设计原则与思想:总结课/38 | 总结回顾面向对象、设计原则、编程规范、重构技巧等知识点.md
Normal file
334
极客时间专栏/设计模式之美/设计原则与思想:总结课/38 | 总结回顾面向对象、设计原则、编程规范、重构技巧等知识点.md
Normal file
@@ -0,0 +1,334 @@
|
||||
<audio id="audio" title="38 | 总结回顾面向对象、设计原则、编程规范、重构技巧等知识点" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/38/b1c99a318a16c63227d67aa39a68c838.mp3"></audio>
|
||||
|
||||
到今天为止,设计原则和思想已经全部讲完了,其中包括:面向对象、设计原则、规范与重构三个模块的内容。除此之外,我们还学习了贯穿整个专栏的代码质量评判标准。专栏的进度已经接近一半,马上就要进入设计模式内容的学习了。在此之前,我先带你一块来总结回顾一下,我们已经学过的所有知识点。
|
||||
|
||||
今天的内容比较多,有一万多字,但都是之前学过的,算是一个总结回顾,主要是想带你复习一下,温故而知新。如果你看了之后,感觉都有印象,那就说明学得还不错;如果还能在脑子里形成自己的知识架构,闭上眼睛都能回忆上来,那说明你学得很好;如果能有自己的理解,并且在项目开发中,开始思考代码质量问题,开始用已经学过的设计原则和思想来审视代码,那说明你已经掌握这些内容的精髓。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/d3/f3262ef8152517d3b11bfc3f2d2b12d3.png" alt="">
|
||||
|
||||
## 一、代码质量评判标准
|
||||
|
||||
**如何评价代码质量的高低?**
|
||||
|
||||
代码质量的评价有很强的主观性,描述代码质量的词汇也有很多,比如可读性、可维护性、灵活、优雅、简洁。这些词汇是从不同的维度去评价代码质量的。它们之间有互相作用,并不是独立的,比如,代码的可读性好、可扩展性好就意味着代码的可维护性好。代码质量高低是一个综合各种因素得到的结论。我们并不能通过单一维度去评价一段代码的好坏。
|
||||
|
||||
**最常用的评价标准有哪几个?**
|
||||
|
||||
最常用到几个评判代码质量的标准有:可维护性、可读性、可扩展性、灵活性、简洁性、可复用性、可测试性。其中,可维护性、可读性、可扩展性又是提到最多的、最重要的三个评价标准。
|
||||
|
||||
**如何才能写出高质量的代码?**
|
||||
|
||||
要写出高质量代码,我们就需要掌握一些更加细化、更加能落地的编程方法论,这就包含面向对象设计思想、设计原则、设计模式、编码规范、重构技巧等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/34/2b/34c51d1eb44ffc099d448ad10bcda82b.jpg" alt="">
|
||||
|
||||
## 二、面向对象
|
||||
|
||||
### 1.面向对象概述
|
||||
|
||||
现在,主流的编程范式或者编程风格有三种,它们分别是面向过程、面向对象和函数式编程。面向对象这种编程风格又是这其中最主流的。现在比较流行的编程语言大部分都是面向对象编程语言。大部分项目也都是基于面向对象编程风格开发的。面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式编码实现的基础。
|
||||
|
||||
### 2.面向对象四大特性
|
||||
|
||||
封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方法来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持,例如Java中的private、protected、public关键字。封装特性存在的意义,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。
|
||||
|
||||
如果说封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现。抽象存在的意义,一方面是修改实现不需要改变定义;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。
|
||||
|
||||
继承用来表示类之间的is-a关系,分为两种模式:单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。
|
||||
|
||||
多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现,比如继承、接口类、duck-typing。多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。
|
||||
|
||||
### 3.面向对象VS面向过程
|
||||
|
||||
面向对象编程相比面向过程编程的优势主要有三个。
|
||||
|
||||
- 对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。
|
||||
- 面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。
|
||||
- 从编程语言跟机器打交道方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。
|
||||
|
||||
面向对象编程一般使用面向对象编程语言来进行,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的。
|
||||
|
||||
面向对象和面向过程两种编程风格并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。
|
||||
|
||||
不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。
|
||||
|
||||
### 4.面向对象分析、设计与编程
|
||||
|
||||
面向对象分析(OOA)、面向对象设计(OOD)、面向对象编程(OOP),是面向对象开发的三个主要环节。简单点讲,面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做,面向对象编程就是将分析和设计的的结果翻译成代码的过程。
|
||||
|
||||
需求分析的过程实际上是一个不断迭代优化的过程。我们不要试图一下就给出一个完美的解决方案,而是先给出一个粗糙的、基础的方案,有一个迭代的基础,然后再慢慢优化。这样一个思考过程能让我们摆脱无从下手的窘境。
|
||||
|
||||
面向对象设计和实现要做的事情就是把合适的代码放到合适的类中。至于到底选择哪种划分方法,判定的标准是让代码尽量地满足“松耦合、高内聚”、单一职责、对扩展开放对修改关闭等我们之前讲到的各种设计原则和思想,尽量地做到代码可复用、易读、易扩展、易维护。
|
||||
|
||||
面向对象分析的产出是详细的需求描述。面向对象设计的产出是类。在面向对象设计这一环节中,我们将需求描述转化为具体的类的设计。这个环节的工作可以拆分为下面四个部分。
|
||||
|
||||
- 划分职责进而识别出有哪些类
|
||||
|
||||
根据需求描述,我们把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否归为同一个类。
|
||||
|
||||
- 定义类及其属性和方法
|
||||
|
||||
我们识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选出真正的方法,把功能点中涉及的名词,作为候选属性,然后同样再进行过滤筛选。
|
||||
|
||||
- 定义类与类之间的交互关系
|
||||
|
||||
UML统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。我们从更加贴近编程的角度,对类与类之间的关系做了调整,保留了四个关系:泛化、实现、组合、依赖。
|
||||
|
||||
- 将类组装起来并提供执行入口
|
||||
|
||||
我们要将所有的类组装在一起,提供一个执行入口。这个入口可能是一个main()函数,也可能是一组给外部用的API接口。通过这个入口,我们能触发整个代码跑起来。
|
||||
|
||||
### 5.接口VS抽象类
|
||||
|
||||
抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性(Java可以定义静态常量),只能声明方法,方法不能包含代码实现(Java8以后可以有默认实现)。类实现接口的时候,必须实现接口中声明的所有方法。
|
||||
|
||||
抽象类是对成员变量和方法的抽象,是一种is-a关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种has-a关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。
|
||||
|
||||
什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果要表示一种is-a的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种has-a关系,并且是为了解决抽象而非代码复用问题,那我们就用接口。
|
||||
|
||||
### 6.基于接口而非实现编程
|
||||
|
||||
应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。
|
||||
|
||||
实际上,“基于接口而非实现编程”这条原则的另一个表述方式是,“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。
|
||||
|
||||
越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。
|
||||
|
||||
### 7.多用组合少用继承
|
||||
|
||||
**为什么不推荐使用继承?**
|
||||
|
||||
继承是面向对象的四大特性之一,用来表示类之间的is-a关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少用,甚至不用继承。
|
||||
|
||||
**组合相比继承有哪些优势?**
|
||||
|
||||
继承主要有三个作用:表示is-a关系、支持多态特性、代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。
|
||||
|
||||
**如何判断该用组合还是继承?**
|
||||
|
||||
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的项目开发中,我们还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。
|
||||
|
||||
### 8.贫血模型VS充血模型
|
||||
|
||||
我们平时做Web项目的业务开发,大部分都是基于贫血模型的MVC三层架构,在专栏中我把它称为传统的开发模式。之所以称之为“传统”,是相对于新兴的基于充血模型的DDD开发模式来说的。基于贫血模型的传统开发模式,是典型的面向过程的编程风格。相反,基于充血模型的DDD开发模式,是典型的面向对象的编程风格。
|
||||
|
||||
不过,DDD也并非银弹。对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的DDD开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的DDD开发模式,因为前期需要在设计上投入更多时间和精力,来提高代码的复用性和可维护性,所以相比基于贫血模型的开发模式,更加有优势。
|
||||
|
||||
基于充血模型的DDD开发模式跟基于贫血模型的传统开发模式相比,主要区别在Service层。在基于充血模型的开发模式下,我们将部分原来在Service类中的业务逻辑移动到了一个充血的Domain领域模型中,让Service类的实现依赖这个Domain类。不过,Service类并不会完全移除,而是负责一些不适合放在Domain类中的功能。比如,负责与Repository层打交道、跨领域模型的业务聚合功能、幂等事务等非功能性的工作。
|
||||
|
||||
基于充血模型的DDD开发模式跟基于贫血模型的传统开发模式相比,Controller层和Repository层的代码基本上相同。这是因为,Repository层的Entity生命周期有限,Controller层的VO只是单纯作为一种DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在Service层。所以,Repository层和Controller层继续沿用贫血模型的设计思路是没有问题的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/e7/f4ce06502a9782d200e8e10a90bf2ce7.jpg" alt="">
|
||||
|
||||
## 三、设计原则
|
||||
|
||||
### 1.SOLID原则:SRP单一职责原则
|
||||
|
||||
一个类只负责完成一个职责或者功能。单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、松耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
|
||||
|
||||
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
|
||||
|
||||
- 类中的代码行数、函数或者属性过多;
|
||||
- 类依赖的其他类过多或者依赖类的其他类过多;
|
||||
- 私有方法过多;
|
||||
- 比较难给类起一个合适的名字;
|
||||
- 类中大量的方法都是集中操作类中的某几个属性。
|
||||
|
||||
### 2.SOLID原则:OCP开闭原则
|
||||
|
||||
**如何理解“对扩展开放、修改关闭”?**
|
||||
|
||||
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
|
||||
|
||||
**如何做到“对扩展开放、修改关闭”?**
|
||||
|
||||
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
|
||||
|
||||
很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
|
||||
|
||||
### 3.SOLID原则:LSP里式替换原则
|
||||
|
||||
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
|
||||
|
||||
里式替换原则是用来指导继承关系中子类该如何设计的一个原则。理解里式替换原则,最核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数的原有“约定”。这里的“约定”包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。
|
||||
|
||||
理解这个原则,我们还要弄明白,里式替换原则跟多态的区别。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。
|
||||
|
||||
### 4.SOLID原则:ISP接口隔离原则
|
||||
|
||||
接口隔离原则的描述是:客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。
|
||||
|
||||
如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
|
||||
|
||||
如果把“接口”理解为单个API接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。
|
||||
|
||||
如果把“接口”理解为OOP中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
|
||||
|
||||
单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考的角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
|
||||
|
||||
### 5.SOLID原则:DIP依赖倒置原则
|
||||
|
||||
**控制反转:**实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。
|
||||
|
||||
**依赖注入:**依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。我们不通过new的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或“注入”)给类来使用。
|
||||
|
||||
**依赖注入框架:**我们通过依赖注入框架提供的扩展点,简单配置一下所有需要的类及其类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
|
||||
|
||||
**依赖反转原则:**依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不需要依赖具体实现细节,具体实现细节依赖抽象。
|
||||
|
||||
### 6.KISS、YAGNI原则
|
||||
|
||||
KISS原则的中文描述是:尽量保持简单。KISS原则是保持代码可读和可维护的重要手段。KISS原则中的“简单“”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,我们还要考虑逻辑复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用复杂的方法解决,也并不违背KISS原则。除此之外,同样的代码,在某个业务场景下满足KISS原则,换一个应用场景可能就不满足了。
|
||||
|
||||
对于如何写出满足KISS原则的代码,我总结了下面几条指导原则:
|
||||
|
||||
- 不要使用同事可能不懂的技术来实现代码;
|
||||
- 不要重复造轮子,善于使用已经有的工具类库;
|
||||
- 不要过度优化。
|
||||
|
||||
YAGNI原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。
|
||||
|
||||
YAGNI原则跟KISS原则并非一回事儿。KISS原则讲的是“如何做”的问题(尽量保持简单),而YAGNI原则说的是“要不要做”的问题(当前不需要的就不要做)。
|
||||
|
||||
### 7.DRY原则
|
||||
|
||||
DRY原则中文描述是:不要重复自己,将它应用在编程中,可以理解为:不要写重复的代码。
|
||||
|
||||
专栏中讲到了三种代码重复的情况:实现逻辑重复、功能语义重复、代码执行重复。实现逻辑重复,但功能语义不重复的代码,并不违反DRY原则。实现逻辑不重复,但功能语义重复的代码,也算是违反DRY原则。而代码执行重复也算是违反DRY原则。
|
||||
|
||||
除此之外,我们还讲到了提高代码复用性的一些手段,包括:减少代码耦合、满足单一职责原则、模块化、业务与非业务逻辑分离、通用代码下沉、继承、多态、抽象、封装、应用模板等设计模式。复用意识也非常重要。在设计每个模块、类、函数的时候,要像设计一个外部API一样去思考它的复用性。
|
||||
|
||||
我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。
|
||||
|
||||
相比于代码的可复用性,DRY原则适用性更强些。我们可以不写可复用的代码,但一定不能写重复的代码。
|
||||
|
||||
### 8.LOD原则
|
||||
|
||||
**如何理解“高内聚、松耦合”?**
|
||||
|
||||
“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中。所谓“松耦合”指的是,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动也不会或者很少导致依赖类的代码改动。
|
||||
|
||||
**如何理解“迪米特法则”?**
|
||||
|
||||
迪米特法则的描述为:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/9f/fbf1ae0ce08d4ea890b80944c2b8309f.jpg" alt="">
|
||||
|
||||
## 四、规范与重构
|
||||
|
||||
### 1.重构概述
|
||||
|
||||
**重构的目的:为什么重构(why)?**
|
||||
|
||||
对于项目来言,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。对于个人而言,重构非常锻炼一个人的代码能力,并且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式、编程规范等理论知识的练兵场。
|
||||
|
||||
**重构的对象:重构什么(what)?**
|
||||
|
||||
按照重构的规模,我们可以将重构大致分为大规模高层次的重构和小规模低层次的重构。大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等编程细节问题,主要是针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论知识。
|
||||
|
||||
**重构的时机:什么时候重构(when)?**
|
||||
|
||||
我反复强调,我们一定要建立持续重构意识,把重构作为开发必不可少的部分融入到开发中,而不是等到代码出现很大问题的时候,再大刀阔斧地重构。
|
||||
|
||||
**重构的方法:如何重构(how)?**
|
||||
|
||||
大规模高层次的重构难度比较大,需要有组织、有计划地进行,分阶段地小步快跑,时刻保持代码处于一个可运行的状态。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做。
|
||||
|
||||
### 2.单元测试
|
||||
|
||||
**什么是单元测试?**
|
||||
|
||||
单元测试是代码层面的测试,用于测试“自己”编写的代码的逻辑正确性。单元测试顾名思义是测试一个“单元”,这个“单元”一般是类或函数,而不是模块或者系统。
|
||||
|
||||
**为什么要写单元测试?**
|
||||
|
||||
单元测试能有效地发现代码中的Bug、代码设计上的问题。写单元测试的过程本身就是代码重构的过程。单元测试是对集成测试的有力补充,能帮助我们快速熟悉代码,是TDD可落地执行的折中方案。
|
||||
|
||||
**如何编写单元测试?**
|
||||
|
||||
写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将其翻译成代码的过程。我们可以利用一些测试框架来简化测试代码的编写。对于单元测试,我们需要建立以下正确的认知:
|
||||
|
||||
- 编写单元测试尽管繁琐,但并不是太耗时;
|
||||
- 我们可以稍微放低单元测试的质量要求;
|
||||
- 覆盖率作为衡量单元测试好坏的唯一标准是不合理的;
|
||||
- 写单元测试一般不需要了解代码的实现逻辑;
|
||||
- 单元测试框架无法测试多半是代码的可测试性不好。
|
||||
|
||||
**单元测试为何难落地执行?**
|
||||
|
||||
一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写。另一方面,国内研发比较偏向“快糙猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾,最后,没有建立对单元测试的正确认识,觉得可有可无,单靠督促很难执行得很好。
|
||||
|
||||
### 3.代码的可测试性
|
||||
|
||||
**什么是代码的可测试性?**
|
||||
|
||||
粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。
|
||||
|
||||
**编写可测试性代码的最有效手段**
|
||||
|
||||
依赖注入是编写可测试性代码的最有效手段。通过依赖注入,我们在编写单元测试代码的时候,可以通过mock的方法将不可控的依赖变得可控,这也是我们在编写单元测试的过程中最有技术挑战的地方。除了mock方式,我们还可以利用二次封装来解决某些代码行为不可控的情况。
|
||||
|
||||
**常见的Anti-Patterns**
|
||||
|
||||
典型的、常见的测试不友好的代码有下面这5种:
|
||||
|
||||
- 代码中包含未决行为逻辑;
|
||||
- 滥用可变全局变量;
|
||||
- 滥用静态方法;
|
||||
- 使用复杂的继承关系;
|
||||
- 高度耦合的代码。
|
||||
|
||||
### 4.大型重构:解耦
|
||||
|
||||
**“解耦”为何如此重要?**
|
||||
|
||||
过于复杂的代码往往在可读性、可维护性上都不友好。解耦,保证代码松耦合、高内聚,是控制代码复杂度的有效手段。如果代码高内聚、松耦合,也就是意味着,代码结构清晰、分层、模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。
|
||||
|
||||
**代码是否需要“解耦”?**
|
||||
|
||||
间接的衡量标准有很多,比如:改动一个模块或类的代码受影响的模块或类是否有很多、改动一个模块或者类的代码依赖的模块或者类是否需要改动、代码的可测试性是否好等等。直接的衡量标准是把模块与模块之间及其类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
|
||||
|
||||
**如何给代码“解耦”?**
|
||||
|
||||
给代码解耦的方法有:封装与抽象、中间层、模块化,以及一些其他的设计思想与原则,比如:单一职责原则、基于接口而非实现编程、依赖注入、多用组合少用继承、迪米特法则。当然,还有一些设计模式,比如观察者模式。
|
||||
|
||||
### 5.小型重构:编码规范
|
||||
|
||||
前面我们讲了很多设计原则,后面还会讲到很多设计模式,利用好它们都可以有效地改善代码的质量。但是,这些知识的合理应用非常依赖个人经验,有时候用不好会适得其反。但是编码规范正好相反,大部分都简单明了,在代码的细节方面,能立竿见影地改善质量。除此之外,我们前面也讲到,持续低层次小规模重构依赖的基本上都是这些编码规范,也是改善代码可读性的有效手段。
|
||||
|
||||
根据我自己的开发经验,我总结罗列了20条我认为最应该关注、最好用的编码规范,分为三个大的方面:命名与注释(Naming and Comments)、代码风格(Code Style)、编程技巧(Coding Tips)。
|
||||
|
||||
**命名与注释**
|
||||
|
||||
- 命名的关键是能准确的达意。对于不同作用域的命名,我们可以适当的选择不同的长度,作用域小的命名,比如临时变量等,可以适当的选择短一些的命名方式。除此之外,命名中个也可以使用一些耳熟能详的缩写。
|
||||
- 我们借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名。
|
||||
- 命名要可读、可搜索。不要使用生僻的、不好读的英文单词来命名。除此之外,命名要符合项目的统一规范,也不要用些反直觉的命名。
|
||||
- 接口有两种命名方式。一种是在接口中带前缀"I",另一种是在接口的实现类中带后缀“Impl”。两种命名方式都可以,关键是要在项目中统一。对于抽象类的命名,我们更倾向于带有前缀“Abstract”。
|
||||
- 注释的目的就是让代码更容易看懂,只要符合这个要求,你就可以写。总结一下的话,注释主要包含这样三个方面的内容:做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”。
|
||||
- 注释本身有一定的维护成本,所以并非越多越好。类和函数一定要写注释,而且要写的尽可能全面详细些,而函数内部的注释会相对少一些,一般都是靠好的命名和提炼函数、解释性变量、总结性注释来做到代码易读。
|
||||
|
||||
**代码风格**
|
||||
|
||||
代码风格都没有对错和优劣之分,不同的编程语言风格都不太一样,只要能在团队、项目中统一即可,不过,最好能跟业内推荐的风格、开源项目的代码风格相一致。所以,这里就不展开罗列了,你可以对照着自己熟悉的编程语言的代码风格,自己复习一下。
|
||||
|
||||
**编程技巧**
|
||||
|
||||
- 将复杂的逻辑提炼拆分成函数和类;
|
||||
- 通过拆分成多个函数的方式来处理参数过多的情况;
|
||||
- 通过将参数封装为对象来处理参数过多的情况;
|
||||
- 函数中不要使用参数来做代码执行逻辑的控制;
|
||||
- 移除过深的嵌套层次,方法包括:去掉多余的if或else语句,使用continue、break、return关键字提前退出嵌套,调整执行顺序来减少嵌套,将部分嵌套逻辑抽象成函数;
|
||||
- 用字面常量取代魔法数;
|
||||
- 利用解释性变量来解释复杂表达式。
|
||||
|
||||
**统一编码规范**
|
||||
|
||||
除了细节的知识点之外,最后,还有一条非常重要的,那就是,项目、团队,甚至公司,一定要制定统一的编码规范,并且通过Code Review督促执行,这对提高代码质量有立竿见影的效果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/8a/fc56f7c2b348d324c93a09dd0dee538a.jpg" alt="">
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
不知不觉我们已经学了这么多内容,在复习完这部分内容之后,你可以在留言区说一说你的掌握程度,看自己符合我开篇中讲到的哪个层次。
|
||||
|
||||
如果有帮助,欢迎你收藏这篇文章,并且把它分享给你的朋友。
|
||||
441
极客时间专栏/设计模式之美/设计原则与思想:总结课/39 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(上).md
Normal file
441
极客时间专栏/设计模式之美/设计原则与思想:总结课/39 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(上).md
Normal file
@@ -0,0 +1,441 @@
|
||||
<audio id="audio" title="39 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/5f/c5f5274e55ade88213e6c2b81815c75f.mp3"></audio>
|
||||
|
||||
在[第25节](https://time.geekbang.org/column/article/179644)、[第26节](https://time.geekbang.org/column/article/179673)中,我们讲了如何对一个性能计数器框架进行分析、设计与实现,并且实践了之前学过的一些设计原则和设计思想。当时我们提到,小步快跑、逐步迭代是一种非常实用的开发模式。所以,针对这个框架的开发,我们分多个版本来逐步完善。
|
||||
|
||||
在第25、26节课中,我们实现了框架的第一个版本,它只包含最基本的一些功能,在设计与实现上还有很多不足。所以,接下来,我会针对这些不足,继续迭代开发两个版本:版本2和版本3,分别对应第39节和第40节的内容。
|
||||
|
||||
在版本2中,我们会利用之前学过的重构方法,对版本1的设计与实现进行重构,解决版本1存在的设计问题,让它满足之前学过的设计原则、思想、编程规范。在版本3中,我们再对版本2进行迭代,并且完善框架的功能和非功能需求,让其满足第25节课中罗列的所有需求。
|
||||
|
||||
话不多说,让我们正式开始版本2的设计与实现吧!
|
||||
|
||||
## 回顾版本1的设计与实现
|
||||
|
||||
首先,让我们一块回顾一下版本1的设计与实现。当然,如果时间充足,你最好能再重新看一下第25、26节的内容。在版本1中,整个框架的代码被划分为下面这几个类。
|
||||
|
||||
- MetricsCollector:负责打点采集原始数据,包括记录每次接口请求的响应时间和请求时间戳,并调用MetricsStorage提供的接口来存储这些原始数据。
|
||||
- MetricsStorage和RedisMetricsStorage:负责原始数据的存储和读取。
|
||||
- Aggregator:是一个工具类,负责各种统计数据的计算,比如响应时间的最大值、最小值、平均值、百分位值、接口访问次数、tps。
|
||||
- ConsoleReporter和EmailReporter:相当于一个上帝类(God Class),定时根据给定的时间区间,从数据库中取出数据,借助Aggregator类完成统计工作,并将统计结果输出到相应的终端,比如命令行、邮件。
|
||||
|
||||
MetricCollector、MetricsStorage、RedisMetricsStorage的设计与实现比较简单,不是版本2重构的重点。今天,我们重点来看一下Aggregator和ConsoleReporter、EmailReporter这几个类。
|
||||
|
||||
**我们先来看一下Aggregator类存在的问题。**
|
||||
|
||||
Aggregator类里面只有一个静态函数,有50行左右的代码量,负责各种统计数据的计算。当要添加新的统计功能的时候,我们需要修改aggregate()函数代码。一旦越来越多的统计功能添加进来之后,这个函数的代码量会持续增加,可读性、可维护性就变差了。因此,我们需要在版本2中对其进行重构。
|
||||
|
||||
```
|
||||
public class Aggregator {
|
||||
public static RequestStat aggregate(List<RequestInfo> requestInfos, long durationInMillis) {
|
||||
double maxRespTime = Double.MIN_VALUE;
|
||||
double minRespTime = Double.MAX_VALUE;
|
||||
double avgRespTime = -1;
|
||||
double p999RespTime = -1;
|
||||
double p99RespTime = -1;
|
||||
double sumRespTime = 0;
|
||||
long count = 0;
|
||||
for (RequestInfo requestInfo : requestInfos) {
|
||||
++count;
|
||||
double respTime = requestInfo.getResponseTime();
|
||||
if (maxRespTime < respTime) {
|
||||
maxRespTime = respTime;
|
||||
}
|
||||
if (minRespTime > respTime) {
|
||||
minRespTime = respTime;
|
||||
}
|
||||
sumRespTime += respTime;
|
||||
}
|
||||
if (count != 0) {
|
||||
avgRespTime = sumRespTime / count;
|
||||
}
|
||||
long tps = (long)(count / durationInMillis * 1000);
|
||||
Collections.sort(requestInfos, new Comparator<RequestInfo>() {
|
||||
@Override
|
||||
public int compare(RequestInfo o1, RequestInfo o2) {
|
||||
double diff = o1.getResponseTime() - o2.getResponseTime();
|
||||
if (diff < 0.0) {
|
||||
return -1;
|
||||
} else if (diff > 0.0) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (count != 0) {
|
||||
int idx999 = (int)(count * 0.999);
|
||||
int idx99 = (int)(count * 0.99);
|
||||
p999RespTime = requestInfos.get(idx999).getResponseTime();
|
||||
p99RespTime = requestInfos.get(idx99).getResponseTime();
|
||||
}
|
||||
RequestStat requestStat = new RequestStat();
|
||||
requestStat.setMaxResponseTime(maxRespTime);
|
||||
requestStat.setMinResponseTime(minRespTime);
|
||||
requestStat.setAvgResponseTime(avgRespTime);
|
||||
requestStat.setP999ResponseTime(p999RespTime);
|
||||
requestStat.setP99ResponseTime(p99RespTime);
|
||||
requestStat.setCount(count);
|
||||
requestStat.setTps(tps);
|
||||
return requestStat;
|
||||
}
|
||||
}
|
||||
|
||||
public class RequestStat {
|
||||
private double maxResponseTime;
|
||||
private double minResponseTime;
|
||||
private double avgResponseTime;
|
||||
private double p999ResponseTime;
|
||||
private double p99ResponseTime;
|
||||
private long count;
|
||||
private long tps;
|
||||
//...省略getter/setter方法...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**我们再来看一下ConsoleReporter和EmailReporter这两个类存在的问题。**
|
||||
|
||||
ConsoleReporter和EmailReporter两个类中存在代码重复问题。在这两个类中,从数据库中取数据、做统计的逻辑都是相同的,可以抽取出来复用,否则就违反了DRY原则。
|
||||
|
||||
整个类负责的事情比较多,不相干的逻辑糅合在里面,职责不够单一。特别是显示部分的代码可能会比较复杂(比如Email的显示方式),最好能将这部分显示逻辑剥离出来,设计成一个独立的类。
|
||||
|
||||
除此之外,因为代码中涉及线程操作,并且调用了Aggregator的静态函数,所以代码的可测试性也有待提高。
|
||||
|
||||
```
|
||||
public class ConsoleReporter {
|
||||
private MetricsStorage metricsStorage;
|
||||
private ScheduledExecutorService executor;
|
||||
|
||||
public ConsoleReporter(MetricsStorage metricsStorage) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
this.executor = Executors.newSingleThreadScheduledExecutor();
|
||||
}
|
||||
|
||||
public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
|
||||
executor.scheduleAtFixedRate(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
long durationInMillis = durationInSeconds * 1000;
|
||||
long endTimeInMillis = System.currentTimeMillis();
|
||||
long startTimeInMillis = endTimeInMillis - durationInMillis;
|
||||
Map<String, List<RequestInfo>> requestInfos =
|
||||
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
|
||||
Map<String, RequestStat> stats = new HashMap<>();
|
||||
for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
|
||||
String apiName = entry.getKey();
|
||||
List<RequestInfo> requestInfosPerApi = entry.getValue();
|
||||
RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
|
||||
stats.put(apiName, requestStat);
|
||||
}
|
||||
System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMillis + "]");
|
||||
Gson gson = new Gson();
|
||||
System.out.println(gson.toJson(stats));
|
||||
}
|
||||
}, 0, periodInSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class EmailReporter {
|
||||
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
|
||||
|
||||
private MetricsStorage metricsStorage;
|
||||
private EmailSender emailSender;
|
||||
private List<String> toAddresses = new ArrayList<>();
|
||||
|
||||
public EmailReporter(MetricsStorage metricsStorage) {
|
||||
this(metricsStorage, new EmailSender(/*省略参数*/));
|
||||
}
|
||||
|
||||
public EmailReporter(MetricsStorage metricsStorage, EmailSender emailSender) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
this.emailSender = emailSender;
|
||||
}
|
||||
|
||||
public void addToAddress(String address) {
|
||||
toAddresses.add(address);
|
||||
}
|
||||
|
||||
public void startDailyReport() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(Calendar.DATE, 1);
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0);
|
||||
calendar.set(Calendar.MINUTE, 0);
|
||||
calendar.set(Calendar.SECOND, 0);
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
Date firstTime = calendar.getTime();
|
||||
Timer timer = new Timer();
|
||||
timer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
|
||||
long endTimeInMillis = System.currentTimeMillis();
|
||||
long startTimeInMillis = endTimeInMillis - durationInMillis;
|
||||
Map<String, List<RequestInfo>> requestInfos =
|
||||
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
|
||||
Map<String, RequestStat> stats = new HashMap<>();
|
||||
for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
|
||||
String apiName = entry.getKey();
|
||||
List<RequestInfo> requestInfosPerApi = entry.getValue();
|
||||
RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
|
||||
stats.put(apiName, requestStat);
|
||||
}
|
||||
// TODO: 格式化为html格式,并且发送邮件
|
||||
}
|
||||
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 针对版本1的问题进行重构
|
||||
|
||||
Aggregator类和ConsoleReporter、EmailReporter类主要负责统计显示的工作。在第26节中,我们提到,如果我们把统计显示所要完成的功能逻辑细分一下,主要包含下面4点:
|
||||
|
||||
1. 根据给定的时间区间,从数据库中拉取数据;
|
||||
1. 根据原始数据,计算得到统计数据;
|
||||
1. 将统计数据显示到终端(命令行或邮件);
|
||||
1. 定时触发以上三个过程的执行。
|
||||
|
||||
之前的划分方法是将所有的逻辑都放到ConsoleReporter和EmailReporter这两个上帝类中,而Aggregator只是一个包含静态方法的工具类。这样的划分方法存在前面提到的一些问题,我们需要对其进行重新划分。
|
||||
|
||||
面向对象设计中的最后一步是组装类并提供执行入口,所以,组装前三部分逻辑的上帝类是必须要有的。我们可以将上帝类做的很轻量级,把核心逻辑都剥离出去,形成独立的类,上帝类只负责组装类和串联执行流程。这样做的好处是,代码结构更加清晰,底层核心逻辑更容易被复用。按照这个设计思路,具体的重构工作包含以下4个方面。
|
||||
|
||||
- 第1个逻辑:根据给定时间区间,从数据库中拉取数据。这部分逻辑已经被封装在MetricsStorage类中了,所以这部分不需要处理。
|
||||
- 第2个逻辑:根据原始数据,计算得到统计数据。我们可以将这部分逻辑移动到Aggregator类中。这样Aggregator类就不仅仅是只包含统计方法的工具类了。按照这个思路,重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
public class Aggregator {
|
||||
public Map<String, RequestStat> aggregate(
|
||||
Map<String, List<RequestInfo>> requestInfos, long durationInMillis) {
|
||||
Map<String, RequestStat> requestStats = new HashMap<>();
|
||||
for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet()) {
|
||||
String apiName = entry.getKey();
|
||||
List<RequestInfo> requestInfosPerApi = entry.getValue();
|
||||
RequestStat requestStat = doAggregate(requestInfosPerApi, durationInMillis);
|
||||
requestStats.put(apiName, requestStat);
|
||||
}
|
||||
return requestStats;
|
||||
}
|
||||
|
||||
private RequestStat doAggregate(List<RequestInfo> requestInfos, long durationInMillis) {
|
||||
List<Double> respTimes = new ArrayList<>();
|
||||
for (RequestInfo requestInfo : requestInfos) {
|
||||
double respTime = requestInfo.getResponseTime();
|
||||
respTimes.add(respTime);
|
||||
}
|
||||
|
||||
RequestStat requestStat = new RequestStat();
|
||||
requestStat.setMaxResponseTime(max(respTimes));
|
||||
requestStat.setMinResponseTime(min(respTimes));
|
||||
requestStat.setAvgResponseTime(avg(respTimes));
|
||||
requestStat.setP999ResponseTime(percentile999(respTimes));
|
||||
requestStat.setP99ResponseTime(percentile99(respTimes));
|
||||
requestStat.setCount(respTimes.size());
|
||||
requestStat.setTps((long) tps(respTimes.size(), durationInMillis/1000));
|
||||
return requestStat;
|
||||
}
|
||||
|
||||
// 以下的函数的代码实现均省略...
|
||||
private double max(List<Double> dataset) {}
|
||||
private double min(List<Double> dataset) {}
|
||||
private double avg(List<Double> dataset) {}
|
||||
private double tps(int count, double duration) {}
|
||||
private double percentile999(List<Double> dataset) {}
|
||||
private double percentile99(List<Double> dataset) {}
|
||||
private double percentile(List<Double> dataset, double ratio) {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 第3个逻辑:将统计数据显示到终端。我们将这部分逻辑剥离出来,设计成两个类:ConsoleViewer类和EmailViewer类,分别负责将统计结果显示到命令行和邮件中。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
public interface StatViewer {
|
||||
void output(Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills);
|
||||
}
|
||||
|
||||
public class ConsoleViewer implements StatViewer {
|
||||
public void output(
|
||||
Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) {
|
||||
System.out.println("Time Span: [" + startTimeInMillis + ", " + endTimeInMills + "]");
|
||||
Gson gson = new Gson();
|
||||
System.out.println(gson.toJson(requestStats));
|
||||
}
|
||||
}
|
||||
|
||||
public class EmailViewer implements StatViewer {
|
||||
private EmailSender emailSender;
|
||||
private List<String> toAddresses = new ArrayList<>();
|
||||
|
||||
public EmailViewer() {
|
||||
this.emailSender = new EmailSender(/*省略参数*/);
|
||||
}
|
||||
|
||||
public EmailViewer(EmailSender emailSender) {
|
||||
this.emailSender = emailSender;
|
||||
}
|
||||
|
||||
public void addToAddress(String address) {
|
||||
toAddresses.add(address);
|
||||
}
|
||||
|
||||
public void output(
|
||||
Map<String, RequestStat> requestStats, long startTimeInMillis, long endTimeInMills) {
|
||||
// format the requestStats to HTML style.
|
||||
// send it to email toAddresses.
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
- 第4个逻辑:组装类并定时触发执行统计显示。在将核心逻辑剥离出来之后,这个类的代码变得更加简洁、清晰,只负责组装各个类(MetricsStorage、Aggegrator、StatViewer)来完成整个工作流程。重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
public class ConsoleReporter {
|
||||
private MetricsStorage metricsStorage;
|
||||
private Aggregator aggregator;
|
||||
private StatViewer viewer;
|
||||
private ScheduledExecutorService executor;
|
||||
|
||||
public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
this.aggregator = aggregator;
|
||||
this.viewer = viewer;
|
||||
this.executor = Executors.newSingleThreadScheduledExecutor();
|
||||
}
|
||||
|
||||
public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
|
||||
executor.scheduleAtFixedRate(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
long durationInMillis = durationInSeconds * 1000;
|
||||
long endTimeInMillis = System.currentTimeMillis();
|
||||
long startTimeInMillis = endTimeInMillis - durationInMillis;
|
||||
Map<String, List<RequestInfo>> requestInfos =
|
||||
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
|
||||
Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos, durationInMillis);
|
||||
viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
|
||||
}
|
||||
}, 0L, periodInSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class EmailReporter {
|
||||
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
|
||||
|
||||
private MetricsStorage metricsStorage;
|
||||
private Aggregator aggregator;
|
||||
private StatViewer viewer;
|
||||
|
||||
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
this.aggregator = aggregator;
|
||||
this.viewer = viewer;
|
||||
}
|
||||
|
||||
public void startDailyReport() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(Calendar.DATE, 1);
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0);
|
||||
calendar.set(Calendar.MINUTE, 0);
|
||||
calendar.set(Calendar.SECOND, 0);
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
Date firstTime = calendar.getTime();
|
||||
Timer timer = new Timer();
|
||||
timer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
|
||||
long endTimeInMillis = System.currentTimeMillis();
|
||||
long startTimeInMillis = endTimeInMillis - durationInMillis;
|
||||
Map<String, List<RequestInfo>> requestInfos =
|
||||
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
|
||||
Map<String, RequestStat> stats = aggregator.aggregate(requestInfos, durationInMillis);
|
||||
viewer.output(stats, startTimeInMillis, endTimeInMillis);
|
||||
}
|
||||
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过上面的重构之后,我们现在再来看一下,现在框架该如何来使用。
|
||||
|
||||
我们需要在应用启动的时候,创建好ConsoleReporter对象,并且调用它的startRepeatedReport()函数,来启动定时统计并输出数据到终端。同理,我们还需要创建好EmailReporter对象,并且调用它的startDailyReport()函数,来启动每日统计并输出数据到制定邮件地址。我们通过MetricsCollector类来收集接口的访问情况,这部分收集代码会跟业务逻辑代码耦合在一起,或者统一放到类似Spring AOP的切面中完成。具体的使用代码示例如下:
|
||||
|
||||
```
|
||||
public class PerfCounterTest {
|
||||
public static void main(String[] args) {
|
||||
MetricsStorage storage = new RedisMetricsStorage();
|
||||
Aggregator aggregator = new Aggregator();
|
||||
|
||||
// 定时触发统计并将结果显示到终端
|
||||
ConsoleViewer consoleViewer = new ConsoleViewer();
|
||||
ConsoleReporter consoleReporter = new ConsoleReporter(storage, aggregator, consoleViewer);
|
||||
consoleReporter.startRepeatedReport(60, 60);
|
||||
|
||||
// 定时触发统计并将结果输出到邮件
|
||||
EmailViewer emailViewer = new EmailViewer();
|
||||
emailViewer.addToAddress("wangzheng@xzg.com");
|
||||
EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
|
||||
emailReporter.startDailyReport();
|
||||
|
||||
// 收集接口访问数据
|
||||
MetricsCollector collector = new MetricsCollector(storage);
|
||||
collector.recordRequest(new RequestInfo("register", 123, 10234));
|
||||
collector.recordRequest(new RequestInfo("register", 223, 11234));
|
||||
collector.recordRequest(new RequestInfo("register", 323, 12334));
|
||||
collector.recordRequest(new RequestInfo("login", 23, 12434));
|
||||
collector.recordRequest(new RequestInfo("login", 1223, 14234));
|
||||
|
||||
try {
|
||||
Thread.sleep(100000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Review版本2的设计与实现
|
||||
|
||||
现在,我们Review一下,针对版本1重构之后,版本2的设计与实现。
|
||||
|
||||
重构之后,MetricsStorage负责存储,Aggregator负责统计,StatViewer(ConsoleViewer、EmailViewer)负责显示,三个类各司其职。ConsoleReporter和EmailReporter负责组装这三个类,将获取原始数据、聚合统计、显示统计结果到终端这三个阶段的工作串联起来,定时触发执行。
|
||||
|
||||
除此之外,MetricsStorage、Aggregator、StatViewer三个类的设计也符合迪米特法则。它们只与跟自己有直接相关的数据进行交互。MetricsStorage输出的是RequestInfo相关数据。Aggregator类输入的是RequestInfo数据,输出的是RequestStat数据。StatViewer输入的是RequestStat数据。
|
||||
|
||||
针对版本1和版本2,我画了一张它们的类之间依赖关系的对比图,如下所示。从图中,我们可以看出,重构之后的代码结构更加清晰、有条理。这也印证了之前提到的:面向对象设计和实现要做的事情,就是把合适的代码放到合适的类中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/34/1303d16f75c7266cef9105f540c54834.jpg" alt="">
|
||||
|
||||
刚刚我们分析了代码的整体结构和依赖关系,我们现在再来具体看每个类的设计。
|
||||
|
||||
Aggregator类从一个只包含一个静态函数的工具类,变成了一个普通的聚合统计类。现在,我们可以通过依赖注入的方式,将其组装进ConsoleReporter和EmailReporter类中,这样就更加容易编写单元测试。
|
||||
|
||||
Aggregator类在重构前,所有的逻辑都集中在aggregate()函数内,代码行数较多,代码的可读性和可维护性较差。在重构之后,我们将每个统计逻辑拆分成独立的函数,aggregate()函数变得比较单薄,可读性提高了。尽管我们要添加新的统计功能,还是要修改aggregate()函数,但现在的aggregate()函数代码行数很少,结构非常清晰,修改起来更加容易,可维护性提高。
|
||||
|
||||
目前来看,Aggregator的设计还算合理。但是,如果随着更多的统计功能的加入,Aggregator类的代码会越来越多。这个时候,我们可以将统计函数剥离出来,设计成独立的类,以解决Aggregator类的无限膨胀问题。不过,暂时来说没有必要这么做,毕竟将每个统计函数独立成类,会增加类的个数,也会影响到代码的可读性和可维护性。
|
||||
|
||||
ConsoleReporter和EmailReporter经过重构之后,代码的重复问题变小了,但仍然没有完全解决。尽管这两个类不再调用Aggregator的静态方法,但因为涉及多线程和时间相关的计算,代码的测试性仍然不够好。这两个问题我们留在下一节课中解决,你也可以留言说说的你解决方案。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的重点内容。
|
||||
|
||||
面向对象设计中的最后一步是组装类并提供执行入口,也就是上帝类要做的事情。这个上帝类是没办法去掉的,但我们可以将上帝类做得很轻量级,把核心逻辑都剥离出去,下沉形成独立的类。上帝类只负责组装类和串联执行流程。这样做的好处是,代码结构更加清晰,底层核心逻辑更容易被复用。
|
||||
|
||||
面向对象设计和实现要做的事情,就是把合适的代码放到合适的类中。当我们要实现某个功能的时候,不管如何设计,所需要编写的代码量基本上是一样的,唯一的区别就是如何将这些代码划分到不同的类中。不同的人有不同的划分方法,对应得到的代码结构(比如类与类之间交互等)也不尽相同。
|
||||
|
||||
好的设计一定是结构清晰、有条理、逻辑性强,看起来一目了然,读完之后常常有一种原来如此的感觉。差的设计往往逻辑、代码乱塞一通,没有什么设计思路可言,看起来莫名其妙,读完之后一头雾水。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1. 今天我们提到,重构之后的ConsoleReporter和EmailReporter仍然存在代码重复和可测试性差的问题,你可以思考一下,应该如何解决呢?
|
||||
1. 从上面的使用示例中,我们可以看出,框架易用性有待提高:ConsoleReporter和EmailReporter的创建过程比较复杂,使用者需要正确地组装各种类才行。对于框架的易用性,你有没有什么办法改善一下呢?
|
||||
|
||||
欢迎在留言区写下你的思考和想法,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
485
极客时间专栏/设计模式之美/设计原则与思想:总结课/40 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(下).md
Normal file
485
极客时间专栏/设计模式之美/设计原则与思想:总结课/40 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(下).md
Normal file
@@ -0,0 +1,485 @@
|
||||
<audio id="audio" title="40 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bb/26/bbc5a1f54141775a37bd22d87caaf426.mp3"></audio>
|
||||
|
||||
上一节课中,我们针对版本1存在的问题(特别是Aggregator类、ConsoleReporter和EmailReporter类)进行了重构优化。经过重构之后,代码结构更加清晰、合理、有逻辑性。不过,在细节方面还是存在一些问题,比如ConsoleReporter、EmailReporter类仍然存在代码重复、可测试性差的问题。今天,我们就在版本3中持续重构这部分代码。
|
||||
|
||||
除此之外,在版本3中,我们还会继续完善框架的功能和非功能需求。比如,让原始数据的采集和存储异步执行,解决聚合统计在数据量大的情况下会导致内存吃紧问题,以及提高框架的易用性等,让它成为一个能用且好用的框架。
|
||||
|
||||
话不多说,让我们正式开始版本3的设计与实现吧!
|
||||
|
||||
## 代码重构优化
|
||||
|
||||
我们知道,继承能解决代码重复的问题。我们可以将ConsoleReporter和EmailReporter中的相同代码逻辑,提取到父类ScheduledReporter中,以解决代码重复问题。按照这个思路,重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
public abstract class ScheduledReporter {
|
||||
protected MetricsStorage metricsStorage;
|
||||
protected Aggregator aggregator;
|
||||
protected StatViewer viewer;
|
||||
|
||||
public ScheduledReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
this.aggregator = aggregator;
|
||||
this.viewer = viewer;
|
||||
}
|
||||
|
||||
protected void doStatAndReport(long startTimeInMillis, long endTimeInMillis) {
|
||||
long durationInMillis = endTimeInMillis - startTimeInMillis;
|
||||
Map<String, List<RequestInfo>> requestInfos =
|
||||
metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
|
||||
Map<String, RequestStat> requestStats = aggregator.aggregate(requestInfos, durationInMillis);
|
||||
viewer.output(requestStats, startTimeInMillis, endTimeInMillis);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
ConsoleReporter和EmailReporter代码重复的问题解决了,那我们再来看一下代码的可测试性问题。因为ConsoleReporter和EmailReporter的代码比较相似,且EmailReporter的代码更复杂些,所以,关于如何重构来提高其可测试性,我们拿EmailReporter来举例说明。将重复代码提取到父类ScheduledReporter之后,EmailReporter代码如下所示:
|
||||
|
||||
```
|
||||
public class EmailReporter extends ScheduledReporter {
|
||||
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
|
||||
|
||||
private MetricsStorage metricsStorage;
|
||||
private Aggregator aggregator;
|
||||
private StatViewer viewer;
|
||||
|
||||
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
this.aggregator = aggregator;
|
||||
this.viewer = viewer;
|
||||
}
|
||||
|
||||
public void startDailyReport() {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(Calendar.DATE, 1);
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0);
|
||||
calendar.set(Calendar.MINUTE, 0);
|
||||
calendar.set(Calendar.SECOND, 0);
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
Date firstTime = calendar.getTime();
|
||||
|
||||
Timer timer = new Timer();
|
||||
timer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
|
||||
long endTimeInMillis = System.currentTimeMillis();
|
||||
long startTimeInMillis = endTimeInMillis - durationInMillis;
|
||||
doStatAndReport(startTimeInMillis, endTimeInMillis);
|
||||
}
|
||||
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
前面提到,之所以EmailReporter可测试性不好,一方面是因为用到了线程(定时器也相当于多线程),另一方面是因为涉及时间的计算逻辑。
|
||||
|
||||
实际上,在经过上一步的重构之后,EmailReporter中的startDailyReport()函数的核心逻辑已经被抽离出去了,较复杂的、容易出bug的就只剩下计算firstTime的那部分代码了。我们可以将这部分代码继续抽离出来,封装成一个函数,然后,单独针对这个函数写单元测试。重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
public class EmailReporter extends ScheduledReporter {
|
||||
// 省略其他代码...
|
||||
public void startDailyReport() {
|
||||
Date firstTime = trimTimeFieldsToZeroOfNextDay();
|
||||
Timer timer = new Timer();
|
||||
timer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
// 省略其他代码...
|
||||
}
|
||||
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
|
||||
}
|
||||
|
||||
// 设置成protected而非private是为了方便写单元测试
|
||||
@VisibleForTesting
|
||||
protected Date trimTimeFieldsToZeroOfNextDay() {
|
||||
Calendar calendar = Calendar.getInstance(); // 这里可以获取当前时间
|
||||
calendar.add(Calendar.DATE, 1);
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0);
|
||||
calendar.set(Calendar.MINUTE, 0);
|
||||
calendar.set(Calendar.SECOND, 0);
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
return calendar.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
简单的代码抽离成trimTimeFieldsToZeroOfNextDay()函数之后,虽然代码更加清晰了,一眼就能从名字上知道这段代码的意图(获取当前时间的下一天的0点时间),但我们发现这个函数的可测试性仍然不好,因为它强依赖当前的系统时间。实际上,这个问题挺普遍的。一般的解决方法是,将强依赖的部分通过参数传递进来,这有点类似我们之前讲的依赖注入。按照这个思路,我们再对trimTimeFieldsToZeroOfNextDay()函数进行重构。重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
public class EmailReporter extends ScheduledReporter {
|
||||
// 省略其他代码...
|
||||
public void startDailyReport() {
|
||||
// new Date()可以获取当前时间
|
||||
Date firstTime = trimTimeFieldsToZeroOfNextDay(new Date());
|
||||
Timer timer = new Timer();
|
||||
timer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
// 省略其他代码...
|
||||
}
|
||||
}, firstTime, DAY_HOURS_IN_SECONDS * 1000);
|
||||
}
|
||||
|
||||
protected Date trimTimeFieldsToZeroOfNextDay(Date date) {
|
||||
Calendar calendar = Calendar.getInstance(); // 这里可以获取当前时间
|
||||
calendar.setTime(date); // 重新设置时间
|
||||
calendar.add(Calendar.DATE, 1);
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0);
|
||||
calendar.set(Calendar.MINUTE, 0);
|
||||
calendar.set(Calendar.SECOND, 0);
|
||||
calendar.set(Calendar.MILLISECOND, 0);
|
||||
return calendar.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
经过这次重构之后,trimTimeFieldsToZeroOfNextDay()函数不再强依赖当前的系统时间,所以非常容易对其编写单元测试。你可以把它作为练习,写一下这个函数的单元测试。
|
||||
|
||||
不过,EmailReporter类中startDailyReport()还是涉及多线程,针对这个函数该如何写单元测试呢?我的看法是,这个函数不需要写单元测试。为什么这么说呢?我们可以回到写单元测试的初衷来分析这个问题。单元测试是为了提高代码质量,减少bug。如果代码足够简单,简单到bug无处隐藏,那我们就没必要为了写单元测试而写单元测试,或者为了追求单元测试覆盖率而写单元测试。经过多次代码重构之后,startDailyReport()函数里面已经没有多少代码逻辑了,所以,完全没必要对它写单元测试了。
|
||||
|
||||
## 功能需求完善
|
||||
|
||||
经过了多个版本的迭代、重构,我们现在来重新Review一下,目前的设计与实现是否已经完全满足第25讲中最初的功能需求了。
|
||||
|
||||
最初的功能需求描述是下面这个样子的,我们来重新看一下。
|
||||
|
||||
>
|
||||
我们希望设计开发一个小的框架,能够获取接口调用的各种统计信息,比如响应时间的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile),接口调用次数(count)、频率(tps) 等,并且支持将统计结果以各种显示格式(比如:JSON格式、网页格式、自定义显示格式等)输出到各种终端(Console命令行、HTTP网页、Email、日志文件、自定义输出终端等),以方便查看。
|
||||
|
||||
|
||||
经过整理拆解之后的需求列表如下所示:
|
||||
|
||||
>
|
||||
<p>接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等。<br>
|
||||
统计信息的类型:max、min、avg、percentile、count、tps等。<br>
|
||||
统计信息显示格式:JSON、HTML、自定义显示格式。<br>
|
||||
统计信息显示终端:Console、Email、HTTP网页、日志、自定义显示终端。</p>
|
||||
|
||||
|
||||
经过挖掘,我们还得到一些隐藏的需求,如下所示:
|
||||
|
||||
>
|
||||
统计触发方式:包括主动和被动两种。主动表示以一定的频率定时统计数据,并主动推送到显示终端,比如邮件推送。被动表示用户触发统计,比如用户在网页中选择要统计的时间区间,触发统计,并将结果显示给用户。
|
||||
|
||||
|
||||
>
|
||||
统计时间区间:框架需要支持自定义统计时间区间,比如统计最近10分钟的某接口的tps、访问次数,或者统计12月11日00点到12月12日00点之间某接口响应时间的最大值、最小值、平均值等。
|
||||
|
||||
|
||||
>
|
||||
统计时间间隔:对于主动触发统计,我们还要支持指定统计时间间隔,也就是多久触发一次统计显示。比如,每间隔10s统计一次接口信息并显示到命令行中,每间隔24小时发送一封统计信息邮件。
|
||||
|
||||
|
||||
版本3已经实现了大部分的功能,还有以下几个小的功能点没有实现。你可以将这些还没有实现的功能,自己实现一下,继续迭代出框架的第4个版本。
|
||||
|
||||
- 被动触发统计的方式,也就是需求中提到的通过网页展示统计信息。实际上,这部分代码的实现也并不难。我们可以复用框架现在的代码,编写一些展示页面和提供获取统计数据的接口即可。
|
||||
- 对于自定义显示终端,比如显示数据到自己开发的监控平台,这就有点类似通过网页来显示数据,不过更加简单些,只需要提供一些获取统计数据的接口,监控平台通过这些接口拉取数据来显示即可。
|
||||
- 自定义显示格式。在框架现在的代码实现中,显示格式和显示终端(比如Console、Email)是紧密耦合在一起的,比如,Console只能通过JSON格式来显示统计数据,Email只能通过某种固定的HTML格式显示数据,这样的设计还不够灵活。我们可以将显示格式设计成独立的类,将显示终端和显示格式的代码分离,让显示终端支持配置不同的显示格式。具体的代码实现留给你自己思考,我这里就不多说了。
|
||||
|
||||
## 非功能需求完善
|
||||
|
||||
Review完了功能需求的完善程度,现在,我们再来看,版本3的非功能性需求的完善程度。在第25讲中,我们提到,针对这个框架的开发,我们需要考虑的非功能性需求包括:易用性、性能、扩展性、容错性、通用性。我们现在就依次来看一下这几个方面。
|
||||
|
||||
### 1.易用性
|
||||
|
||||
所谓的易用性,顾名思义,就是框架是否好用。框架的使用者将框架集成到自己的系统中时,主要用到MetricsCollector和EmailReporter、ConsoleReporter这几个类。通过MetricsCollector类来采集数据,通过EmailReporter、ConsoleReporter类来触发主动统计数据、显示统计结果。示例代码如下所示:
|
||||
|
||||
```
|
||||
public class PerfCounterTest {
|
||||
public static void main(String[] args) {
|
||||
MetricsStorage storage = new RedisMetricsStorage();
|
||||
Aggregator aggregator = new Aggregator();
|
||||
|
||||
// 定时触发统计并将结果显示到终端
|
||||
ConsoleViewer consoleViewer = new ConsoleViewer();
|
||||
ConsoleReporter consoleReporter = new ConsoleReporter(storage, aggregator, consoleViewer);
|
||||
consoleReporter.startRepeatedReport(60, 60);
|
||||
|
||||
// 定时触发统计并将结果输出到邮件
|
||||
EmailViewer emailViewer = new EmailViewer();
|
||||
emailViewer.addToAddress("wangzheng@xzg.com");
|
||||
EmailReporter emailReporter = new EmailReporter(storage, aggregator, emailViewer);
|
||||
emailReporter.startDailyReport();
|
||||
|
||||
// 收集接口访问数据
|
||||
MetricsCollector collector = new MetricsCollector(storage);
|
||||
collector.recordRequest(new RequestInfo("register", 123, 10234));
|
||||
collector.recordRequest(new RequestInfo("register", 223, 11234));
|
||||
collector.recordRequest(new RequestInfo("register", 323, 12334));
|
||||
collector.recordRequest(new RequestInfo("login", 23, 12434));
|
||||
collector.recordRequest(new RequestInfo("login", 1223, 14234));
|
||||
|
||||
try {
|
||||
Thread.sleep(100000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面的使用示例中,我们可以看出,框架用起来还是稍微有些复杂的,需要组装各种类,比如需要创建MetricsStorage对象、Aggregator对象、ConsoleViewer对象,然后注入到ConsoleReporter中,才能使用ConsoleReporter。除此之外,还有可能存在误用的情况,比如把EmailViewer传递进了ConsoleReporter中。总体上来讲,框架的使用方式暴露了太多细节给用户,过于灵活也带来了易用性的降低。
|
||||
|
||||
为了让框架用起来更加简单(能将组装的细节封装在框架中,不暴露给框架使用者),又不失灵活性(可以自由组装不同的MetricsStorage实现类、StatViewer实现类到ConsoleReporter或EmailReporter),也不降低代码的可测试性(通过依赖注入来组装类,方便在单元测试中mock),我们可以额外地提供一些封装了默认依赖的构造函数,让使用者自主选择使用哪种构造函数来构造对象。这段话理解起来有点复杂,我把按照这个思路重构之后的代码放到了下面,你可以结合着一块看一下。
|
||||
|
||||
```
|
||||
public class MetricsCollector {
|
||||
private MetricsStorage metricsStorage;
|
||||
|
||||
// 兼顾代码的易用性,新增一个封装了默认依赖的构造函数
|
||||
public MetricsCollectorB() {
|
||||
this(new RedisMetricsStorage());
|
||||
}
|
||||
|
||||
// 兼顾灵活性和代码的可测试性,这个构造函数继续保留
|
||||
public MetricsCollectorB(MetricsStorage metricsStorage) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
}
|
||||
// 省略其他代码...
|
||||
}
|
||||
|
||||
public class ConsoleReporter extends ScheduledReporter {
|
||||
private ScheduledExecutorService executor;
|
||||
|
||||
// 兼顾代码的易用性,新增一个封装了默认依赖的构造函数
|
||||
public ConsoleReporter() {
|
||||
this(new RedisMetricsStorage(), new Aggregator(), new ConsoleViewer());
|
||||
}
|
||||
|
||||
// 兼顾灵活性和代码的可测试性,这个构造函数继续保留
|
||||
public ConsoleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
|
||||
super(metricsStorage, aggregator, viewer);
|
||||
this.executor = Executors.newSingleThreadScheduledExecutor();
|
||||
}
|
||||
// 省略其他代码...
|
||||
}
|
||||
|
||||
public class EmailReporter extends ScheduledReporter {
|
||||
private static final Long DAY_HOURS_IN_SECONDS = 86400L;
|
||||
|
||||
// 兼顾代码的易用性,新增一个封装了默认依赖的构造函数
|
||||
public EmailReporter(List<String> emailToAddresses) {
|
||||
this(new RedisMetricsStorage(), new Aggregator(), new EmailViewer(emailToAddresses));
|
||||
}
|
||||
|
||||
// 兼顾灵活性和代码的可测试性,这个构造函数继续保留
|
||||
public EmailReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
|
||||
super(metricsStorage, aggregator, viewer);
|
||||
}
|
||||
// 省略其他代码...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在,我们再来看下框架如何来使用。具体使用示例如下所示。看起来是不是简单多了呢?
|
||||
|
||||
```
|
||||
public class PerfCounterTest {
|
||||
public static void main(String[] args) {
|
||||
ConsoleReporter consoleReporter = new ConsoleReporter();
|
||||
consoleReporter.startRepeatedReport(60, 60);
|
||||
|
||||
List<String> emailToAddresses = new ArrayList<>();
|
||||
emailToAddresses.add("wangzheng@xzg.com");
|
||||
EmailReporter emailReporter = new EmailReporter(emailToAddresses);
|
||||
emailReporter.startDailyReport();
|
||||
|
||||
MetricsCollector collector = new MetricsCollector();
|
||||
collector.recordRequest(new RequestInfo("register", 123, 10234));
|
||||
collector.recordRequest(new RequestInfo("register", 223, 11234));
|
||||
collector.recordRequest(new RequestInfo("register", 323, 12334));
|
||||
collector.recordRequest(new RequestInfo("login", 23, 12434));
|
||||
collector.recordRequest(new RequestInfo("login", 1223, 14234));
|
||||
|
||||
try {
|
||||
Thread.sleep(100000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你足够细心,可能已经发现,RedisMeticsStorage和EmailViewer还需要另外一些配置信息才能构建成功,比如Redis的地址,Email邮箱的POP3服务器地址、发送地址。这些配置并没有在刚刚代码中体现到,那我们该如何获取呢?
|
||||
|
||||
我们可以将这些配置信息放到配置文件中,在框架启动的时候,读取配置文件中的配置信息到一个Configuration单例类。RedisMetricsStorage类和EmailViewer类都可以从这个Configuration类中获取需要的配置信息来构建自己。
|
||||
|
||||
### 2.性能
|
||||
|
||||
对于需要集成到业务系统的框架来说,我们不希望框架本身代码的执行效率,对业务系统有太多性能上的影响。对于性能计数器这个框架来说,一方面,我们希望它是低延迟的,也就是说,统计代码不影响或很少影响接口本身的响应时间;另一方面,我们希望框架本身对内存的消耗不能太大。
|
||||
|
||||
对于性能这一点,落实到具体的代码层面,需要解决两个问题,也是我们之前提到过的,一个是采集和存储要异步来执行,因为存储基于外部存储(比如Redis),会比较慢,异步存储可以降低对接口响应时间的影响。另一个是当需要聚合统计的数据量比较大的时候,一次性加载太多的数据到内存,有可能会导致内存吃紧,甚至内存溢出,这样整个系统都会瘫痪掉。
|
||||
|
||||
针对第一个问题,我们通过在MetricsCollector中引入Google Guava EventBus来解决。实际上,我们可以把EventBus看作一个“生产者-消费者”模型或者“发布-订阅”模型,采集的数据先放入内存共享队列中,另一个线程读取共享队列中的数据,写入到外部存储(比如Redis)中。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
public class MetricsCollector {
|
||||
private static final int DEFAULT_STORAGE_THREAD_POOL_SIZE = 20;
|
||||
|
||||
private MetricsStorage metricsStorage;
|
||||
private EventBus eventBus;
|
||||
|
||||
public MetricsCollector(MetricsStorage metricsStorage) {
|
||||
this(metricsStorage, DEFAULT_STORAGE_THREAD_POOL_SIZE);
|
||||
}
|
||||
|
||||
public MetricsCollector(MetricsStorage metricsStorage, int threadNumToSaveData) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
this.eventBus = new AsyncEventBus(Executors.newFixedThreadPool(threadNumToSaveData));
|
||||
this.eventBus.register(new EventListener());
|
||||
}
|
||||
|
||||
public void recordRequest(RequestInfo requestInfo) {
|
||||
if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) {
|
||||
return;
|
||||
}
|
||||
eventBus.post(requestInfo);
|
||||
}
|
||||
|
||||
public class EventListener {
|
||||
@Subscribe
|
||||
public void saveRequestInfo(RequestInfo requestInfo) {
|
||||
metricsStorage.saveRequestInfo(requestInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
针对第二个问题,解决的思路比较简单,但代码实现稍微有点复杂。当统计的时间间隔较大的时候,需要统计的数据量就会比较大。我们可以将其划分为一些小的时间区间(比如10分钟作为一个统计单元),针对每个小的时间区间分别进行统计,然后将统计得到的结果再进行聚合,得到最终整个时间区间的统计结果。不过,这个思路只适合响应时间的max、min、avg,及其接口请求count、tps的统计,对于响应时间的percentile的统计并不适用。
|
||||
|
||||
对于percentile的统计要稍微复杂一些,具体的解决思路是这样子的:我们分批从Redis中读取数据,然后存储到文件中,再根据响应时间从小到大利用外部排序算法来进行排序(具体的实现方式可以看一下《数据结构与算法之美》专栏)。排序完成之后,再从文件中读取第count*percentile(count表示总的数据个数,percentile就是百分比,99百分位就是0.99)个数据,就是对应的percentile响应时间。
|
||||
|
||||
这里我只给出了除了percentile之外的统计信息的计算代码,如下所示。对于percentile的计算,因为代码量比较大,留给你自己实现。
|
||||
|
||||
```
|
||||
public class ScheduleReporter {
|
||||
private static final long MAX_STAT_DURATION_IN_MILLIS = 10 * 60 * 1000; // 10minutes
|
||||
|
||||
protected MetricsStorage metricsStorage;
|
||||
protected Aggregator aggregator;
|
||||
protected StatViewer viewer;
|
||||
|
||||
public ScheduleReporter(MetricsStorage metricsStorage, Aggregator aggregator, StatViewer viewer) {
|
||||
this.metricsStorage = metricsStorage;
|
||||
this.aggregator = aggregator;
|
||||
this.viewer = viewer;
|
||||
}
|
||||
|
||||
protected void doStatAndReport(long startTimeInMillis, long endTimeInMillis) {
|
||||
Map<String, RequestStat> stats = doStat(startTimeInMillis, endTimeInMillis);
|
||||
viewer.output(stats, startTimeInMillis, endTimeInMillis);
|
||||
}
|
||||
|
||||
private Map<String, RequestStat> doStat(long startTimeInMillis, long endTimeInMillis) {
|
||||
Map<String, List<RequestStat>> segmentStats = new HashMap<>();
|
||||
long segmentStartTimeMillis = startTimeInMillis;
|
||||
while (segmentStartTimeMillis < endTimeInMillis) {
|
||||
long segmentEndTimeMillis = segmentStartTimeMillis + MAX_STAT_DURATION_IN_MILLIS;
|
||||
if (segmentEndTimeMillis > endTimeInMillis) {
|
||||
segmentEndTimeMillis = endTimeInMillis;
|
||||
}
|
||||
Map<String, List<RequestInfo>> requestInfos =
|
||||
metricsStorage.getRequestInfos(segmentStartTimeMillis, segmentEndTimeMillis);
|
||||
if (requestInfos == null || requestInfos.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Map<String, RequestStat> segmentStat = aggregator.aggregate(
|
||||
requestInfos, segmentEndTimeMillis - segmentStartTimeMillis);
|
||||
addStat(segmentStats, segmentStat);
|
||||
segmentStartTimeMillis += MAX_STAT_DURATION_IN_MILLIS;
|
||||
}
|
||||
|
||||
long durationInMillis = endTimeInMillis - startTimeInMillis;
|
||||
Map<String, RequestStat> aggregatedStats = aggregateStats(segmentStats, durationInMillis);
|
||||
return aggregatedStats;
|
||||
}
|
||||
|
||||
private void addStat(Map<String, List<RequestStat>> segmentStats,
|
||||
Map<String, RequestStat> segmentStat) {
|
||||
for (Map.Entry<String, RequestStat> entry : segmentStat.entrySet()) {
|
||||
String apiName = entry.getKey();
|
||||
RequestStat stat = entry.getValue();
|
||||
List<RequestStat> statList = segmentStats.putIfAbsent(apiName, new ArrayList<>());
|
||||
statList.add(stat);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, RequestStat> aggregateStats(Map<String, List<RequestStat>> segmentStats,
|
||||
long durationInMillis) {
|
||||
Map<String, RequestStat> aggregatedStats = new HashMap<>();
|
||||
for (Map.Entry<String, List<RequestStat>> entry : segmentStats.entrySet()) {
|
||||
String apiName = entry.getKey();
|
||||
List<RequestStat> apiStats = entry.getValue();
|
||||
double maxRespTime = Double.MIN_VALUE;
|
||||
double minRespTime = Double.MAX_VALUE;
|
||||
long count = 0;
|
||||
double sumRespTime = 0;
|
||||
for (RequestStat stat : apiStats) {
|
||||
if (stat.getMaxResponseTime() > maxRespTime) maxRespTime = stat.getMaxResponseTime();
|
||||
if (stat.getMinResponseTime() < minRespTime) minRespTime = stat.getMinResponseTime();
|
||||
count += stat.getCount();
|
||||
sumRespTime += (stat.getCount() * stat.getAvgResponseTime());
|
||||
}
|
||||
RequestStat aggregatedStat = new RequestStat();
|
||||
aggregatedStat.setMaxResponseTime(maxRespTime);
|
||||
aggregatedStat.setMinResponseTime(minRespTime);
|
||||
aggregatedStat.setAvgResponseTime(sumRespTime / count);
|
||||
aggregatedStat.setCount(count);
|
||||
aggregatedStat.setTps(count / durationInMillis * 1000);
|
||||
aggregatedStats.put(apiName, aggregatedStat);
|
||||
}
|
||||
return aggregatedStats;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 3.扩展性
|
||||
|
||||
前面我们提到,框架的扩展性有别于代码的扩展性,是从使用者的角度来讲的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。
|
||||
|
||||
在刚刚讲到框架的易用性的时候,我们给出了框架如何使用的代码示例。从示例中,我们可以发现,框架在兼顾易用性的同时,也可以灵活地替换各种类对象,比如MetricsStorage、StatViewer。举个例子来说,如果我们要让框架基于HBase来存储原始数据而非Redis,那我们只需要设计一个实现MetricsStorage接口的HBaseMetricsStorage类,传递给MetricsCollector和ConsoleReporter、EmailReporter类即可。
|
||||
|
||||
### 4.容错性
|
||||
|
||||
容错性这一点也非常重要。对于这个框架来说,不能因为框架本身的异常导致接口请求出错。所以,对框架可能存在的各种异常情况,我们都要考虑全面。
|
||||
|
||||
在现在的框架设计与实现中,采集和存储是异步执行,即便Redis挂掉或者写入超时,也不会影响到接口的正常响应。除此之外,Redis异常,可能会影响到数据统计显示(也就是ConsoleReporter、EmailReporter负责的工作),但并不会影响到接口的正常响应。
|
||||
|
||||
### 5.通用性
|
||||
|
||||
为了提高框架的复用性,能够灵活应用到各种场景中,框架在设计的时候,要尽可能通用。我们要多去思考一下,除了接口统计这样一个需求,这个框架还可以适用到其他哪些场景中。比如是否还可以处理其他事件的统计信息,比如SQL请求时间的统计、业务统计(比如支付成功率)等。关于这一点,我们在现在的版本3中暂时没有考虑到,你可以自己思考一下。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的重点内容。
|
||||
|
||||
还记得吗?在第25、26讲中,我们提到,针对性能计数器这个框架的开发,要想一下子实现我们罗列的所有功能,对任何人来说都是比较有挑战的。而经过这几个版本的迭代之后,我们不知不觉地就完成了几乎所有的需求,包括功能性和非功能性的需求。
|
||||
|
||||
在第25讲中,我们实现了一个最小原型,虽然非常简陋,所有的代码都塞在一个类中,但它帮我们梳理清楚了需求。在第26讲中,我们实现了框架的第1个版本,这个版本只包含最基本的功能,并且初步利用面向对象的设计方法,把不同功能的代码划分到了不同的类中。
|
||||
|
||||
在第39讲中,我们实现了框架的第2个版本,这个版本对第1个版本的代码结构进行了比较大的调整,让整体代码结构更加合理、清晰、有逻辑性。
|
||||
|
||||
在第40讲中,我们实现了框架的第3个版本,对第2个版本遗留的细节问题进行了重构,并且重点解决了框架的易用性和性能问题。
|
||||
|
||||
从上面的迭代过程,我们可以发现,大部分情况下,我们都是针对问题解决问题,每个版本都聚焦一小部分问题,所以整个过程也没有感觉到有太大难度。尽管我们迭代了3个版本,但目前的设计和实现还有很多值得进一步优化和完善的地方,但限于专栏的篇幅,继续优化的工作留给你自己来完成。
|
||||
|
||||
最后,我希望你不仅仅关注这个框架本身的设计和实现,更重要的是学会这个逐步优化的方法,以及其中涉及的一些编程技巧、设计思路,能够举一反三地用在其他项目中。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
最后,还是给你留一道课堂讨论题。
|
||||
|
||||
正常情况下,ConsoleReporter的startRepeatedReport()函数只会被调用一次。但是,如果被多次调用,那就会存在问题。具体会有什么问题呢?又该如何解决呢?
|
||||
|
||||
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/8b/43/8b7e755ddb44e490848a052c5dc11043.jpg" alt="">](https://jinshuju.net/f/cZuRmd)
|
||||
Reference in New Issue
Block a user