factory pattern in golang
主要是介绍工厂模式的基本思想,之后通过几个实际项目的源码分析,达到熟练掌握工厂模式的目的。
工厂模式的适用场景
在实际的项目中,常常会遇到这种情况:一个backend的接口,之后多个实际的backend的实现,一个driver的接口,多个实际类型的driver的实现,那么这种场景下的代码应该如何处理?因为不同的driver多少会有一些类似的地方,比如对应于数据库的CRUD操作(create, retrieve, update, delete)。想然好的代码绝对不仅仅是一侧的,100种不同的backend的具体的实现,在代码中的某个层次上也就是剩下CRUD几种操作了。工场方法应该利用的就是这么一种思想,如何将整个代码分开成不同的层次,每一层着重处理每一层的问题。再说白一点,factory模式就是提取出一些共性的东西,在factory method的层面上来看,代码逻辑是相同的,具体再往下一层的话,代码就各有区别了。
对于数据结构或者说数据实体之间的关系的角度,对应的关系无非就是一对一,一对多,多对多几种,工厂模式就是一对多这种关系的一种具体实现。
从技术实现的角度上如何实现工厂模式?如果能做到根据指定的字段加载对应的包或者生成对应的对象,实现起来也没有很复杂了(比如python)。但是在Golang中并没有动态加载的这种模式,那么传递一个具体的driver名称过来的时候,接下来怎么根据这个指定的driver名称来加载上对应的实际的struct呢?比如传递的string字段是”mysql”就create出一个mysql的struct实例,如果传入的是”redis”,create出一个redis的struct实例。
一种解决方案是使用golang中反射的功能来实现动态加载的功能,说白了就是先把所有需要用到的struct都存下来,并且通过反射类型对其进行封装,之后根据传入的key值需要使用哪个就取到哪个的类型信息,最后通过reflect.New方法生成这个类型的一个新的实例对象。
具体取得的方式可以参考golang reflection(之前文章)相关内容。示例代码以及这个问题的讨论可以参考这个。
还有另外一种方式就是使用所谓的“工厂模式”,也就是通过所谓的better way to struct the proram, 来实现以上功能, 这里是一个作为练习的一个具体实现,这种方式的好处就是避免了反射的使用,代码的可读性会好一些,这里是参考docker/distribution中的工厂模式的一个简单实现,具体实现的效果就是,通过register方法将标记参数(key值)以及接口(interface)注册到map中,之后向create方法中传入不同的标记参数,create方法可以根据不同的参数类型调用对应每个具体类型的create方法,得到一个包含了后端通用方法的定义好的接口。
工厂模式与接口抽象
比较以下以上提到的两种实现方式,第一种比较灵活,第二种比较结构化,我个人觉得应该是第二种更有助于在层析性比较强的更大的程序中使用。不管哪种方法都需要使用到map,在第一种方法中,map的value值是reflect.Value的这种反射类型,第二种方法中,map的value值是一个storageDriver接口,在接口中定义了一系列下层struct实现的方法。
最近看到微信文章《左耳朵耗子:我对GitLab误删除数据库事件的几点思考》中的一句话:
真正良性的运维能力是——人管代码,代码管机器,而不是人管机器。 |
把这个逻辑迁移到程序编写上,就是说每个结构做的事情应该是明确的,所操作的方法是用接口类型规定清楚了的,操作的是接口而非是具体的每个对象。动不动就要回归到最底层的对象上去做操作,多少有点“人肉编程”的感觉,就是说没有把要做的事情固定明确下来,每次都是重新来做一遍。
尝试取对一类对象抽象出来的接口,这个过程就是寻找这一类对象的相同点的过程,这样做是尝试使用层次化的方式来组织程序的一个关键。
docker/distribution中的例子
最近正好有看一点registry的源码,下面具体分析下在registry中这个工厂模式是怎么使用的。
这里采用的源码tag是v1.11.0
比如registry backend的设计,在最外层是storagedriver的接口,这个storageDriver接口的功能就是充当各个create方法的返回值,其中抽象了所有的StorageDriver所需要的方法:
type StorageDriver interface { |
之后在第二层的目录中,每个目录都是具体的driver实现,实现了上面的StorageDriver接口。比如azure,inmemory,s3等等。当然还有好多别的内容,等下介绍。
下面关键是看如何将这两部分连接起来。
在一个factory的package中,有一个StorageDriverFactory的interface。还有一个register和create的function,下面来详细看看。
type StorageDriverFactory interface { |
可以看到,StorageDriverFactory
接口中只有一个Create方法,传入参数是一个map,返回的是一个实现了StorageDriver接口类型的实例。注意这里区别StorageDriverFactory接口与StorageDriver接口,第一个接口中封装的方法是实现Factory模式中所必需要的,第二个是定义了最后不同StorageDriver实例所需要实现的方法。它们的功能是不同的,因此需要把两个接口分开。从实现的角度来说,最底层的结构体应该都实现了这两个接口中的方法。
继续往下看,在这个package内部还存放着一个map
var driverFactories = make(map[string]StorageDriverFactory) |
其中key值是storagedriver的名称,value值是对应的实现了StorageDriverFactory接口的实例(通过这个接口的定义可以找到不同的子类型实现的create方法)。
可以看到factory中的register方法就是把实例注册到上面提到的driverFactories的map中,传入的参数是一个name以及一个StorageDriverFactory实例。
func Register(name string, factory StorageDriverFactory) { |
再看一下Create方法,这里最后一步利用interface实现多态,可以找到不同的底层类型具体实现的create方法:
func Create(name string, parameters map[string]interface{}) (storagedriver.StorageDriver, error) { |
首先是根据传入进来的name从directories中的具体的driverFactory实例,之后在调用对应实例的Create方法,传入的值是一个map,这个应该是传入的参数,从这里可以看到,只有每个driver首先被注册到directory中,之后才能被create。
还有一个问题,它们是如何被注册进去的呢?
下面来看看,每个对应的driver中,Create方法是如何实现的,当每个backend都有自己不同的逻辑,比如以最简单的s3为例,在对应的包中有init方法,包被应用的时候,init方法首先会被调用:
type s3DriverFactory struct{} |
核心就是调用factory的register方法,把s3DriverFactory类型的指针注册到map中,之后最关键的部分就是s3DriverFactory实现的这个Create方法,返回的就是最外层的那个实现了StorageDriver接口的实例。
也就是说,在具体使用的时候,只要通过factory的Create方法,并且传入对应的driver name和相关的parameters,就可以得到一个对应的driver了,这个过程对外呈现的就是一个统一的factory Create方法,就是所谓的工厂模式的实现,是指上是将不同struct的Create方法注册在了一个map中,这个Create方法被一个driverFactory的接口包装了起来。
google/cadvisor项目中的 container package
这里采用的源码tag是v0.20.0
开始的时候这一块看的似懂非懂,比较凌乱,一旦熟悉了工厂模式的思路,这一块就变得有章可寻,变得清晰明了好多。
cadvisor这块的工厂模式,在实现上比上面介绍的registry中的方式更复杂一点,由于具体的container部分可是不同类型的driver,比如docker的,直接使用libcontainer的,或者以后支持更多,比如说使用rkt等等。这里的原理也是多种driver做相同事情的不同实现,所以用工厂模式是很适用的。
首先是在最外层cadvisor/container/factory.go
中有一个factories []ContainerHandlerFactory
的数组,具体的factory都是往这个数组中注册的。其中有一个注册的函数,就是把新的factory append到这个数组的后面,这个套路应该很熟了。
在第二层目录中,有docker,raw 等多种方式的实现,也就是说,通过factory模式抽象的,是这三种具体的driver。
在上面提到的registry的实现中,每个子模块的工厂实例只有一个Create方法,我们看下cadvisor这部分中,对应的工厂方式的使用上都定义了哪些方法。
type ContainerHandlerFactory interface { |
可以看到这里的信息更详细了些,但是本质还是不变的,第一个方法是创建一个新的实例,返回的对象是实现了ContainerHandler接口的实例,这个是通过接口模式所要交付出来的内容。根据之前distribution中的实现套路,可以推测出ContainerHandler接口中定义的就是每种driver的主要功能,比如获取容器的状态,返回容易中运行的进程,返回容器中运行的线程,启动对应的driver等等函数,这部分定义的函数是与这个组件的具体功能相关的,这个组建的主要功能是搜集容器信息,因此主要的函数就是和搜集容器信息相关的。
ContainerHandlerFactory接口中后面的方法起到一些辅助的功能,比如返回这个factory的名字,这个factory是否能够处理指定的container,以及Debug信息,从工厂模式的实现的角度上看可以看到google/cadvisor的代码确实比docker/distribution考虑的完善一些。
继续往下看,每个driver中对于Factory接口都有不同的实现,这里主要是create这部分。比如docker的package,最后返回的是dockerContainerHandler。
之后factory模式怎么使用的呢?按照前一部分的介绍,一定是在某个地方,先把一些factory的具体实例注册到数组中,然后在具体使用的时候,再根据一些实际传入的key字段取到对应的factory,之后再进行create操作。
这里的factory模式的使用有点变化,关键在于NewContainerHandler的操作,这个函数的主要功能就是通过检索之前注册进来的factory实例,然后调用其中对应的Create方法,生成新的Handler,与上一部分registry的工厂模式比较,可以发现,这里存储工厂实例的结构是一个Array(一共就有两种底层driver,也没必要用map),而通常意义上的应该是一个map才对,通过key值来检索到对应的工厂实例。
根据上面的distribution的流程,这里还应该有一个Create方法,就是在其中调用具体的driver的Create方法:
// Create a new ContainerHandler for the specified container. |
这里的方式又有变化,相比于distribution中的通过map的key值来定位,这里直接把判断是否为对应的factory的功能放在了函数中,由不同的子类型来实现。在具体每种driver的实现上由于逻辑比较复杂,对应接口实现对应的实例,没有混合在一起。比如raw driver,rawfactory实现了ContainerHandlerFactory接口,rawcontainerHandler实例实现了ContainerHandler接口。
继续分析,对factories数组进行遍历,调用factory.CanHandleAndAccept来判断这个factory是否可以处理传入的container name 如果可以,则调用对应的factory实例中的create方法(NewContainerHandler),得到对应的handler,其中创建时候具体的判断逻辑就不在这里赘述了。
按照这里的设计,在向factories数组中注册对应的实例的时候也有逻辑层次再里面(应该是特殊的在前,一般的在后),因为如果两种factory同时满足的话,第一次被检索到的factory总是先被使用。可以看到在manager的启动函数中,对应部分也是按照我们推测的顺序调用对应的各个factory的Register函数注册进来的:
// Start the container manager. |
这里factory注册的时候,不像是上面介绍的registry中的实现那样直接再init函数中注册一个&FactoryInstance{}过去这么简单,因为在创建对应的FactoryInstance的时候,需要有一些额外的操作要做,因此,每个driver的注册部分被单独提炼出来了一个函数 docker.Register 以及 raw.Register。它们的主要工作就是准备对应FactoryInstance所需要的字段,创建FactoryInstance,注册到上面提到的factories数组中。
kubernetes kube-scheduler 中的例子
在具体深入到scheduler代码之前可以参考下这个帖子,防止以后源码不在了,暂时把源码也粘贴过来:
package main |
这种方式本质上来讲也是工厂模式,与之前的区别就是,省略了一个Factory的interface,在map中直接用一个定义好的函数来代替。虽然看起来是简洁了一些,不过个人感觉在可读性上,没有那种把接口定义完整的方式读起来顺畅,代码执行效率可能会高一些吧,但是把一个函数当成参数传进来,看着就比较别扭,才用这种方式编写代码可能还有其他的一些因素吧。
下面是使用同样的例子,然后使用之前介绍的工厂模式进行书写,主要的区别就是心定义了一个Factory的接口,里面是Create方法,合起来相当于上面版本中map的value值。
//traditional version |
如果Factory中的方法比较简单,比如这种就有一个的,直接把Factory的value的map值写成函数比较好,如果方法比较多,比如像cadvisor中介绍的那种情况,还是把方法单独抽象成接口比较好。
以上内容了解清楚,再看scheduler中涉及工厂模式的代码就比较容易了,其中方法的注册以及工厂模式实现的思路基本上是于以上内容一致的,具体scheduler的代码再另一篇中进行细致分析。
总结
通过上面的实例介绍,相信对golang中工厂模式的使用场景,使用方式,以及具体实现步骤,都有了一个了解,也可以把factory模式应用到自己的实际项目中了,多个driver对上统一暴露相同的接口,让项目结构更清晰。