架构师训练营 1 期第 3 周:代码重构 - 总结
人们发明一些设计模式,用来指导我们进行程序开发,我们可以基于这些设计模式开发软件编程框架,而我们的应用程序又是基于软件编程框架开发的。本文主要介绍如何在框架或程序中更好的使用设计模式,这些设计模式遵循了面向对象设计的原则(开闭原则OCP、依赖倒置原则DIP、里氏替换原则LSP、单一职责原则SRP、接口隔离原则ISP),以保证我们的设计实现面向对象设计的目标,即设计出强内聚、低耦合的程序,从而是程序具备易扩展、更强壮、可移植、更简单的特性。
一、使用设计模式优化排序工具包
设计模式的定义和分类
什么是设计模式?
- 设计模式是一种可重复使用的解决方案。 
- 每一种模式都描述了一种问题的通用解决方案,而这种问题在我们的环境中,不断重复的出现,而解决方案就可以重复的解决这些问题。 
我们要学好设计模式,重点关注这个模式是解决什么问题的,它是如何去解决这个问题的,它在解决这个问题的过程中体现的优势是什么,有没有更好的解决方案。一个设计模式主要包含如下四部分:
- 模式的名字:由少量的字组成的名称,有助于我们表达我们的设计。 
- 待解问题:描述了何时需要运用这种模式,以及运用模式的环境(上下文)。 
- 解决方案:描述了组成设计的元素(类和对象)、它们的关系、职责以及合作。但这种解决方案是抽象的,它不代表具体的实现。 
- 结论:运用这种方案所带来的利和弊。主要是指它对系统的弹性、扩展性、和可移植性的影响。 
设计模式的分类
- 从功能分 
- 创建模式:对类的实例化过程的抽象。 
- 结构模式:将类或者对象结合在一起形成更大的结构。 
- 行为模式:对在不同的对象之间划分责任和算法的抽象化。 
- 从方式分 
- 类模式:以继承的方式实现模式,静态的。 
- 对象模式:以组合的方式实现模式,动态的。 
设计模式举例:开发排序工具包
开发者可以利用工具包,使用各种排序算法,对各种数据结构,对各种数据内容进行排序
面向接口编程
这里涉及三部分内容:
- 排序器(Sorter):比如冒泡排序,插入排序等不同的排序器 
- 数据容器(Sortable):数据是放在什么样的容器里,比如数组,列表等 
- 比较器(Comparator):里面放的数据内容是什么样的,排序时两个数据哪个大哪个小是怎么决定的 
通过这三个接口,我们就可以去构建出一个排序的工具,应用程序就可以针对这些接口使用我们的排序工具,应用程序针对接口进行编程,接口的实现类可以根据需要进行替换。

简单工厂模式
实现类是怎么在程序中去得到的?简单的new一个实现类不符合开闭原则,如果要保证不依赖于这些实现类,我们可以利用简单的工厂模式
- 客户端通过接口进行编程,接口的实现类是由工厂返回的,而工厂指向具体的类,当通过工厂返回一个接口的时候,实际上这个接口指向的是一个具体的类 

简单工厂模式的优缺点
优点
- 应用程序客户端Client满足开闭原则,但我们需求变更时,需要换一种排序器,客户端不需要修改任何代码; 
缺点
- Factory工厂类是不符合开闭原则的,当需要Factory返回一种新的排序算法的时候,必须要修改getSorter方法,返回一个新的Sorter 
简单工厂的改进一
- getSorter参数传递Sorter的类路径名String,Factory根据类名通过反射的方式构造出对应的Sorter,然后返回 
改进一存在的问题
- 解决了Factory的开闭原则问题,但是需要修改客户端程序了,需要知道Sorter的实现是什么(如类路径名),使Client不符合开闭原则 
- 丧失了编译时的类型安全,传递的类名如果拼写错误,只有在运行时才能发现问题 
- 限制了Sorter的实现,只能通过“默认构造函数”来创建,不能根据需要带入参数,因为Factory的getSorter实现里没有传递参数 
简单工厂的改进二
- 创建 sort.properties 文件: 
- sorter=demo.sort.impl.BubbleSorter 
- 通过配置文件读取要加载的Sorter类 
相对”改进一"的改进点
- Client调用getSorter时不需要传参了,Client实现了开闭原则 
- Factory也实现了开闭原则,当增加或修改需求时,需要返回不同的Sorter实现类时,只需要修改配置文件就可以了,Factory本身不需要做任何修改 
仍存在的缺点
- 丧失了编译时的类型安全,配置文件中的类名如果拼写错误,只有在运行时才能发现问题 
- 限制了Sorter的实现,只能通过“默认构造函数”来创建,无法对它传递参数 
简单工厂模式要点
- 简单工厂模式是非常重要的一种对象构建方式,是许多其他模式的基础 
- 通过配置文件去获取类名并以动态编程方式构造实现类的这种机制,解决了简单工厂最重要的问题,即工厂本身的开闭原则问题 
满足开闭原则OCP的方法要点
- Client实现了针对抽象接口进行编程(Client针对Sorter、Sortable、Comparator接口进行编程,不需要关心具体的实现类) 
- 通过Factory类获取抽象接口实现实例,Factory采用动态编程的方式实现,即将编译时类型检查转变成通过配置问题件加载类的运行时检查 
二、Singleton单例模式
Singleton 模式保证产生单一实例,就是说一个类只产生一个实例。
使用 singletong 有两个原因:
- 基于性能需求考虑:只有一个实例,可以减少实例频繁创建和销毁带来的资源消耗; 
- 基于功能需求考虑:当多个用户使用这个实例的时候,便于进行统一控制(比如打印机对象),属于实际业务管理的要求。 
Singleton实现方法一
- 使用私有的构造函数,保证外部无法通过new创建对象,保证实例的单一性 
- 提供公有静态方法getInstance对外部返回实例对象,并且通过这个方法保证实例的单一性 
- 通过私有的静态的成员变量new出实例对象,因为是私有的,外部无法直接访问私有变量,只能通过公有getInstance方法获得;因为是静态的,因此这个变量只会有一个,以保证实例的单一性 
- 采用饿汉模式,提前创建对象,类的私有成员变量会在构造函数执行前进行初始化 
Singleton实现方法二
- 采用懒汉模式,对象并不是已开始就创建出来,而是在外部第一次调用getInstance方法时进行创建 
- 给公有静态方法getInstance添加synchronized同步锁,以保证多线程调用getInstance方法的时候,必须要同步等待进入这个方法,不能多线程并行的进入方法执行,从而保证只有一个对象会被创建出来 
- 方法二中每次调用getInstance方法时都要获取synchronized同步锁,性能会受到一定的影响,实践中推荐第一种单例方式,如果业务场景需要采用懒汉模式进行单例实现,可以参考方法三的方式 
Singleton实现方法三
- 采用懒汉模式,通过使用类的静态内部类的方式实现,不需要使用synchronized同步锁,降低性能消耗。 
三、适配器模式(Adapter)
基于面向接口编程的思路,如果有已经实现类似功能的实现类,但是如果存在要求的接口和实现类接口不匹配的情况,这时可以采用适配器模式,把我们要求的接口适配到已经实现的类上,这样就可以用已实现的类来满足我们要求的接口了。
排序工具类的例子中,Sortable接口为抽象的数据容器接口,ArrayList是数据容器实现类,通过ListSortable类来进行适配

基于设计中优先使用组合替代继承的原则,继承会带来一些资源的消耗,以及继承方面的一些不便,所以我们尽量使用组合的方式,也就是对象适配器的方式实现适配器模式
适配器应用
- JDBC Driver:是对具体数据库的适配器,如,将Oracle或MySQL不同数据库的实现适配到JDBC接口中 
- JDBC-ODBC Bridge:是将Windows ODBC实现适配到JDBC接口中 
三、JUnit中的设计模式
JUnit是设计模式四人帮GOF中的几个作者写出来的一个单元测试框架,JUint中对设计模式的应用是非常灵活的,值得我们参考。
使用模板方法模式控制单元测试的执行
如何用JUnit写单元测试
实现一个单元测试的步骤
- 创建测试类,继承于TestCase 
- 测试前初始化数据:覆盖TestCase里的setUp方法 
- 编写测试方法:命名规则为testXXX方法 
- 测试完清理环境:覆盖TestCase里tearDown方法,回复原来状态 
JUnit单元测试是如何执行的?
- TestCase通过runBare方法,保证setUp、runTest、tearDown这三个方法顺序调用(先初始化,再执行,最后清理),其中runTest方法通过反射的动态机制去查找所有test开头的方法,顺序的去调用testXXX 

这种模式叫做模板方法模式(Template Method),是扩展功能的最基本模式之一,是一种“类的行为模式”,它是通过继承的方式来实现扩展的
- 基类中定义好过程,定义好算法的轮廓和骨架,定义好模板方法,决定模板方法的调用顺序 
- 子类中实现已经定义好的抽象方法,负责算法的实现 
模板方法的形式
- 抽象方法:父类中定义抽象方法,子类中强制实现该方法 
- 具体方法:父类中定义方法并实现执行代码,子类可以覆盖,也可以不覆盖,如果明确告知子类“不要覆盖它”,最好表明final 
- 钩子方法:父类中定义空方法,子类可以根据实现场景需要选择性覆盖 
Java Servlet中也有类似的模板方法应用场景

使用模板方法模式简化同类测试用例的编写
测试Sortable类
测试排序程序

策略模式和模板方法模式结合使用
策略模式(Strategy)
- 应用程序在开发期针对接口进行编程,具体的实现类实现接口,在运行期注入不同的实现类,这里的接口叫做策略接口,实现策略接口的实现类叫做策略实现 
- 策略模式是扩展功能的另一种最基本的模式,它是一种“对象的行为模式”,它是通过组合的方法来实现的 

基类TestCase实现Test接口,Test接口就是JUnit单元测试框架的策略接口,TestCase是一种策略实现
什么时候使用策略模式
- 当系统在多种算法中选择一种的时候,可以使用策略模式 
- 重构系统时,将条件语句转换成对于策略的多态调用 
更多的时候,策略模式和模板方法模式结合在一起使用
- JUnit框架调用的是策略接口Test接口,而模板方法实际上是TestCase中定义的,我们的具体实现类再继承TestCase模板 
- GenericServlet类的service方法中定义了方法的流程,定义了模板方法,HttpServlet类继承GenericServlet类,使用了模板方法模式;GenericServlet类实现Servlet接口,Servlet接口是一个策略接口,容器基于Servlet接口进行编程,使用了策略模式 
- 策略接口供应用者调用,而在实现接口的模板基类里面去定义公共执行的流程、公共的方法,而把具体的类留给抽象方法等待具体的业务类去实现它,从而使程序更灵活,更易于扩展,即节约了代码,又保证了流程 

使用组合模式构建复杂测试包
参数化的单元测试
通过组合模式的设计模式,我们可以把一些TestCase组合在一起,统一调用执行,构造出更复杂的测试包
组合模式(Composite)是一种“对象的结构模式”,很多时候是用在基于树的结构的处理的情况

组合模式的应用
- 文件系统 
- AWT控件 

使用装饰器模式构建性能测试
测试排序程序的性能
冒泡排序和插入排序,谁更快?
- 这种测试必须重复多次(如10,000次)才能比较准确地计算出性能。 
- 如何让 BubbleSorterTests 和 InsertionSorterTests 重复运行多次,而不需要修改它们的代码? 
- 如何计算时间? 
运用 JUnit 扩展包中的辅助类:
- junit.extensions.TestSetup 
- junit.extensions.RepeatedTest 

性能测试类的实现
性能测试的装饰链结构

装饰器模式(Decoator)
- 我们在执行自己的方法之前,我们先执行一下其他的和我们实现同样接口其他的类的方法,或者说和我们实现同样接口的其他类的方法把我们接口类实现的方法包装起来再执行 
- 装饰器最重要的是,你要装饰的那个类跟你实现的是同一个接口,这样的话装饰事实上是可以互相装饰的,可以不停的装饰下去的,装饰器会形成一条“链” 
- 装饰器模式是一种“对象的结构模式”,它可以在不改变对接口端接口调用的前提下,或者对客户端透明的情况下,扩展现有对象的功能,可以对它进行装饰,客户端看到的还是同样一个接口,但是这个接口被装饰过了 
- PerformanceTests的客户端指的是执行单元测试的测试框架,可以是Eclipse,也可以是JUnit自己的run,通过装饰器模式的方式,虽然还是执行Test,但是已经被装饰过了(repeat执行),实现了性能测试重复执行的扩展功能 
- 装饰器有时候也被称为包装器(Wrapper),把一个类进行包装,但是包装后,接口不变,所以说他和适配器是不同的,适配器是转换接口,而装饰器保持接口不变 
装饰器和模板方法、策略模式的比较
- 装饰器保持对象的功能不变,扩展其外围的功能 
- 模板方法和策略模式则保持算法的框架不变,而扩展其内部的实现 
装饰器和继承的比较
- 都可以用来扩展对象的功能 
- 但装饰器是动态的,继承是静态的 
- 装饰器可以任意组合 
装饰器的应用
- Java Servlet 中的应用 
- HttpServletRequest/HttpServletRequestWrapper 
- HttpServletResponse/HttpServletResponseWrapper 
- 可以对Request对象的方法进行包装 
- 包装getSession方法(不希望通过Servlet容器管理Session,希望通过远程去获得Session,使应用服务器集群共享一个全局Session),从远程统一中心获取Session构造Session对象 
- 其他的Request数据可以对数据进行加解密,在请求传过来的时候,得到加密数据的时候,在Wrapper里对它进行解密,再向下传递获得的就是解密数据了 
- 同步化装饰器 
- Collections. synchronizedList(list) ,使线程不安全的List变成线程安全的List,对每个方法都加了synchronize关键字,对它进行装饰,对外面看起来,它还是一个List接口 
- 取代原先的 Vector、Hashtable 等同步类 
- Java I/O 类库简介 
- 核心 - 流,即数据的有序排列,将数据从源送达目的地 
- 流的种类 
- InputStream、OutputStream - 代表byte流(八位字节流) 
- Reader、Writer - 代表char流(Unicode 字符流) 
- 流的对称性 
- 输入-输出对称 
- Byte-Char 对称 
- 因此我们只要学习任意一种流,就可以基本了解其它所有的流 
四、Spring中的设计模式
依赖注入DI与控制反转IoC
- A对象不需要自己去创建B对象,B对象由外部去创建,并且注入到A对象里来,这叫做依赖注入DI 
- A对象去创建B对象,并且依赖B对象,这时正常的一种控制关系,依赖注入的方式反过来了,所以,有时这种依赖注入也被称为控制反转IoC,提供这种依赖注入功能的框架或容器,有时也被称为IoC容器 

Spring的做法:
Bean解析代码

Spring中的单例模式
- 在Spring中缺省情况下提供的也是一个单例对象,但是这些单例对象却不需要我们去写private的私有构造函数,它就能够控制单例 
- Spring实现了单例,是因为容器去管理了对象的创建,容器只要不重复常见对象,它就实现了单例了 
Spring MVC模式
- MVC模式其实和早期的Servlet原始的开发方式相对应,就是要把处理业务逻辑的model模型跟最后要展现的HTML视图分离开来,通过控制器进行分发和分离 
五、设计模式案例:Intel大数据SQL引擎&Panthera设计模式
问题背景
- 在大数据领域,主要的数据仓库SQL引擎是Hive,Hive可以将SQL转化成MapReduce,然后在大数据引擎上面去执行,可以针对大规模的数据使用SQL进行分析 
- 它有个问题是,Hive支持的是自己定义的HiveQL这样一种类SQL语法,它跟标准的SQL不太一样,一方面它里面的语法特点,整个的写法,和标准SQL不太一样,另外一方面,它的语法支持也没有标准SQL那样全面,标准SQL里常用的一些,比如子查询(就是在where里面还有个select叫嵌套子查询),Hive并不支持这样的语法,还有其他的一些常用的,比如exist、in。 
- 当传统的企业(银行,传统商超等)想要把他们的数据仓库从Oracle转换到Hive上面来的时候,他们就遇到了两个困难,一个困难就是,他们要把以前在Oracle上跑的SQL迁移到Hive上面来,这个迁移的工作,因为HiveQL跟SQL不一样,需要进行语法改变,另一方面,这些语法功能本身也不支持,不光是语法不一样,有些根本就不支持这样的功能。 
- 于是有一个想法是使Hive支持标准的SQL,支持Oracle、MySQL这样的标准的SQL,一方面语法让它们一致,另一方面Hive不支持的关键字、语法都给支持了 
问题分析
- Hive架构 
- Hive是将传统的可以通过JDBC或ODBC提交的SQL,传到Hive里面来,Hive会将SQL转换成一个Hadoop的MapReduce的一组job,构成一个DAG有向无环图,然后在Hadoop上面进行执行 
- 它转换的关键就是它里面的一个驱动器Driver,在Driver里面核心要做三件事情,这也是所有的数据库引擎要做的事情,第一件事情是Complier编译,它要把SQL或HiveQL编译成一颗AST抽象语法树,然后对这颗抽象语法树进行Optimizer,进行语义优化,优化的性能更高,更加的易于执行,然后把优化后的语法进行转换,构建成一个执行计划,交给执行器,执行器会把它变成一个MapReduce的程序,然后提交给Hadoop去执行 

- 查询处理引擎流程分析 
- Hive的三个关键过程:语法解析 - 语义分析 - 执行 
- Hive的执行计划是MapReduce 
- Hive的语义分析或者优化器要做的就是要把这个抽象语法树进行语义分析,进行优化,转化成执行计划 
- 在这个场景下,要把标准SQL转化成Hive的Optimizer优化器能够识别的一个抽象语法树就可以了,以前的Hive生成的AST是它自己的AST,我们能否把标注SQL转化成Hive能够识别的AST,把这个AST交给Hive的Optimizer和Executor去执行呢,这是整个设计的一个关键 
- 我们要做的事情就是把SQL通过自己的SQL解析器生成一个标准SQL的AST,然后再把这个标准SQL的AST抽象语法树转换成Hive能够识别的Hive-AST,然后把Hive-AST交给Hive的Optimizer执行就可以了 
- 传进来的Query,经过Hive的Driver进行识别,判断它是一个标注的SQL还是HiveQL,如果是HiveQL然后用Hive的Parser,直接获得的是Hive的AST,如果是标准SQL,对它进行标准SQL的语法解析,获得标准的SQL-AST,然后再把标准的SQL-AST进行转化,把它转换成一个Hive-AST 
- 在标准SQL-AST转换Hive-AST的时候,也有两件事情要做,一件事情就是,要把Hive-AST里不支持的这些语法进行语法转换,比如说它不支持嵌套子查询,这时候已经不仅是语法转换了,进行的是语义转换,一个嵌套子查询它等价于一个join操作,实际上这个时候要把标准SQL-AST里面的嵌套子查询转换成一个join语法,转换完了以后,它得到的还是一个标准SQL-AST,再对这个标准SQL-AST进行等价转换,转化成Hive-AST,就可以了。所以这里有两步,一步是语义变换,另一部才是语法的转换 
- 经语义转化的SQL语句比原始的SQL语句更复杂,但它们在逻辑关系或者运算关系上,这两条SQL是等价的 
- 我们先不管转换的代数关系是什么样子的,假设我们已经知道了转换前和转换后语法变化的特点和特性,我们如何去做这件事,先考虑怎么做,然后再考虑做的有没有问题,我们怎样通过设计模式和设计原则对它进行优化 


初始代码结构问题
- HiveASTGenerator类,遍历标准SQL产生的SQL-AST抽象语法树,对每一个语法点进行对应的转换,不断的check、gen、attach, 
- 这个类一共2000多行代码,大概实现了不到10%的语法功能,只是验证了技术方案是成功的,这种代码设计违背的单一职责原则,语义变化,语法转化,各种语法点的处理全在一个类里完成,这个类会变得非常庞大,它的职责变得非常复杂 
- 我们能否通过单一职责原则把这些职责分开呢,分开以后,能不能用一些设计模式,更优雅的把这些分开的类去组合起来,让它们共同构成一个整体对外提供服务 
重构后的代码结构(Panthera代码)
- 代码拆分职责单一: 
- SqlASTTranslator负责整个过程执行,分成transformer和generator,在transformer里进行语义的转换,在generator里面重新生成Hive需要的语法树Hive-AST 
- 在具体的类里,再把每个语法点拆分出来 
- 装饰器模式:TransformerBuilder构造器 
- 模板方法模式:BaseSqlASTTransformer实现了SqlASTTransformer接口,定义的模板方法 
- 代码更加复用,逻辑更加简单,代码职责更加单一,符合开闭原则,更加易于扩展,总代码量变小了,更关键的是可以支持团队协作开发 
版权声明: 本文为 InfoQ 作者【piercebn】的原创文章。
原文链接:【http://xie.infoq.cn/article/5f754b7111977b67e52c4f749】。文章转载请联系作者。
 
  
  
  
  
  
  
  
  
    
评论