1.3 如何实现
关于针对接口编程,前面谈了是什么(What)和为什么(Why),下面介绍怎么做(How)。下面给出一个媒体播放器的实例,实现利用各种编解码器来播放媒体文件的功能。第一个版本的代码如下。
由于每种编解码器的解码方法的名称均不一样,所以播放器不仅引用不同编解码器的实例,还要了解其API的差异,调用正确的方法,这样开发人员当然太痛苦了。首先想到的改进方法就是为编解码器制定统一的接口,之后播放器使用编解码器时就方便得多,于是有了第二个版本的代码。
第二个版本的播放器MediaPlayerV2现在的问题就是对具体编解码器类型的依赖了。因为依赖来源于对编解码器实例的初始化,所以可将负责初始化的代码从播放器移出,转交另一个对象来负责。面向对象开发中的工厂模式(Factory Pattern)常用来创建对象实例,我们就先采用它来改进播放器。
1.3.1 工厂模式
工厂模式包括抽象工厂(Abstract Factory)和工厂方法(Factory Method)两种类型。前者可以看作是后者的扩展,用于创建一系列相关的对象实例。这里只看工厂方法模式,它又有两种变体。第一种是创建者基类的工厂方法创建产品基类的实例,创建者继承类的工厂方法创建产品继承类的实例,一个创建者继承类只对应一个产品继承类。在这里使用这种模式只会将对特定编解码器类的依赖转移到它对应的特定创建者类上,对我们的目标没有帮助。另一种变体是创建者类的工厂方法接收参数,以返回不同类型的产品实例,下面给出这种模式的代码。
1.3.2 服务定位器模式
应用工厂模式时,返回的对象都是每次新创建的实例。如果调用方并不需要如此,那么该类型对象的创建成本就太高,每个实例占用的资源过大,这种方式很不经济。又或者调用方需要每次都获取到同一个实例时,也必须采用其他方式。
可以从另一种角度来描述依赖的问题。一个对象需要访问和调用其他对象的属性和方法,被调用者可被视为向调用者提供服务,或者换种说法被调用者就是服务(Service)。消除依赖就意味着调用者能够以接口的形式获取服务。如何获取服务呢?花点钱雇一班管家和佣人,或者打电话去家政公司请钟点工或者有事找相关机构都可以。翻译成计算机的语言,最自然的思路就是有一个集中的地方可以根据所需的接口返回服务——实现该接口的对象。这个地方按照习惯被称为服务定位器(Service locator),实现和使用它的编程套路就是服务定位器模式。
服务定位器的核心是返回服务的方法,至于这些服务对象本身是怎样来的,可以根据实际情况采用各种方法:预先创建所有服务,然后添加到定位器内部某个映射数据结构内保存;调用者请求服务时再创建,并保存以备下次请求;甚至每次请求时创建。定位器可以是一个静态(Static)类,调用者直接访问它的静态方法,它容纳的服务也是所有调用者共享的;也可以是一个实例类,调用者需要访问它的实例,服务只在该实例的调用者间共享。前者相当于提供公共服务的家政公司和相关机构,后者类比于私人管家和仆佣。为了简便,下列代码使用了一个静态服务定位器。
许多情况下,获取服务的Resolve <T>方法只需要根据调用者提供的接口T返回对应的服务就可以了,服务对象的具体类型是什么由定位器决定,或者更准确地说由程序的需求和所处的环境决定。也就是说,调用者请求的一种接口对应一个服务。但是在我们的例子里,媒体播放器需要获取实现编解码器接口的多种具体类型的实例,所以Resolve <T>方法还补充了一个可选的字符串参数,用来传递编解码器类型的信息。关于服务的来源,这里采用的途径是通过Register <T>方法添加,同样有一个可选的字符串参数。因为添加服务是播放器工作之前的准备,所以将这部分代码放在一个单独的类ControllerUsingServiceLocatorV1中,再由它来调用播放器。
1.3.3 依赖注入
上两种模式有一个共同点,就是无论被调用者的来源如何,调用者都要从某个地方主动获取。我们把这种方式称为拉。与之相对的是由外界将调用者需要的对象推给它,依赖注入(Dependency injection)就是这样一种模式。这个名称听上去很深奥,其实本质很简单。所谓将对象推给调用者,在程序中就是指将它作为参数传递给调用者。根据一个对象接受参数传递的方法的类型和特点,专家们又将依赖注入分为好几种,并给它们都起了很酷的名字。将被调用者通过调用者的构造函数传递称为构造函数注入(Constructor injection);通过调用者的设值方法或属性传递称为设值注入(Setter injection);如果用于传递的方法是在实现一个专为依赖注入而建的接口,就称为接口注入(Interface injection);如果被调用者是作为一个普通方法的参数传入,就称为方法调用注入(Method call injection)。下列代码演示了这几种形式。
构造函数注入、设值注入和接口注入都将传入的对象保存在调用者的字段里,可供调用者的所有方法使用。这类情况下,执行注入的对象也就负责创建调用者的实例,这个对象传统上的称谓包括容器、应用上下文(Application context),它是整个应用程序控制流程的起点。容器提供方法返回注入好了的调用者实例,特定于应用程序的代码就从调用这些方法获取实例开始。方法调用注入传入的对象仅仅在该方法内被使用,其他方法无法访问,该方法要使用也只能在每次调用时注入。在实现上接口注入最为复杂。不仅要为负责注入的方法建一个接口,还要为每种调用者配套一个注入器类,这些注入器类又实现一个公共的注入器接口,然后由一个容器创建注入器,注入器再调用它配套的那个对象实现的特定注入接口里的方法。是不是感觉要被绕晕了?那就直接忽略它,因为这样折腾的结果是和其他形式相比没有优势,现实中也很少有人使用。
最后要比较的是构造函数注入和设值注入。两者的选择实际上是一个更一般的问题:应该通过构造函数还是设值函数向对象传递信息?构造函数是直观的选择,它毕竟就是被设计出来干这个的。对象的用户最容易使用,也能够最清晰地透过它的参数类型了解该对象需要的信息。构造函数在对象初始化时必然会运行,设值函数则要依靠用户在恰当的时候调用。构造函数还有一个特别的好处,即能够用来设计不可变的(Immutable)对象。不可变的对象有很多好处,线程安全、易于测试等等。将构造函数传入的信息保存在只读字段里,对象一旦创建,无论被调用了什么方法,状态都和最初保持一样。而使用设值函数时,保存传入信息的字段就不能是只读的,使得以后可能通过再次调用设值函数或者其他方法修改该字段。尽管如此,构造函数也有难以应对需求的时候。遇上参数太多、类型相同不好区分多种版本的构造函数等情况就要考虑使用设值函数。
总的来说,最常见和有用的形式是构造函数注入和设值注入。因为上面所说的实现机制,容器返回的调用者对象内部保有实现了被调用者接口的某个具体类型的对象。而在我们的例子里,播放器需要调用多种编解码器的对象,所以只能采用方法调用注入的形式。与应用服务定位器模式类似,可把负责注入的代码放在播放器之外的一个控制器类中。