- Live软件开发面面谈
- 潘俊编著
- 4320字
- 2021-03-31 00:00:55
1.5 真正实现
要真正消除依赖,还需更进一步。调用方的代码不能引用具体的编解码器类型,不知道除接口之外这些类型的任何信息,但总归要以某种形式知道这些类型。这看上去有些矛盾的任务如何完成呢?答案依然是通过第三方。调用方不能直接了解被调用方,但是某个第三方可以了解,调用方再去找第三方,而第三方本身是无关任何具体的被调用方的,是某种通用的习惯或标准的机制。最常用的第三方就是配置文件。
1.5.1 配置文件
假设在某个配置文件里记录了每种媒体文件对应的编解码器所在的程序集和类型名称,播放器读取该文件来创建所需的编解码器对象。这个配置文件可以有一个专用的名称,可以是某个播放器统一的配置文件的一部分,也可以每个编解码器自带一个名称统一的配置文件。配置文件的格式有很多种选择,INI、XML、YML……只要能满足需求就行。下面就用服务定位器模式加配置文件来演示如何真正消除依赖。
这个类型用于解析和返回配置文件包含的信息。为了方便,这里提供一个专门的静态方法返回所有的编解码器信息,每条信息由媒体文件格式、对应的编解码器类型名称和所在的程序集名称组成。
在播放器从服务定位器获取编解码器对象前,上述代码先利用AppConfig读取的配置文件信息为每一种媒体文件格式创建编解码器实例。如果编解码器较多,且创建成本高,也可以配合采用某种延迟创建(Lazy creation)机制等到播放器获取编解码器时才创建。
在现实世界中,利用配置文件来实现针对接口编程的例子也是很多的。Java的数据库编程接口JDBC就是一个很好的范例。所有和数据库的交互都是通过Connection、Statement、ResultSet之类的接口完成的,接口的具体实现则交给各个数据库开发者提供的驱动器。这样既使得数据库使用者读写数据的代码有通用性,又给了数据库开发者最大的灵活性。所有读写数据库的活动都是从Driver接口获取一个Connection开始的,每个特定的数据库驱动程序都要有一个类实现Driver接口。应用程序使用的具体数据库的该驱动类的名称就记录在配置文件中,然后由DriverManager读取并创建实例。过去配置文件是Java的系统属性文件,后来可以是META-INF/services/java.sql.Driver,不过根本的机制都没变。
1.5.2 配置代码
比起使用配置文件,用代码来提供同样的信息简单很多,这就是所谓的配置代码。例如在AppConfig的GetCodecInfo方法里直接用硬编码写入编解码器的信息。这种方式不是重蹈了1.4.2节中界定的覆辙吗?确实如此,所以只有在一种特殊的情况下,这种方式才有正当性。
我们已经看出,包含被调用者信息的配置代码,如果和调用者在一起,就仍然构成调用方的依赖。那么唯一可行的就是配置代码既不属于被调用方,也不属于调用方。到目前为止,我们所处的开发环境都是调用者和被调用者可能由无关的两方组织或个人完成,这也是需要消除两者间依赖的现实原因。配置代码不属于任何一方,这就意味着又多出了一个新的开发方的场景。在此场景中,原有的调用者和被调用者代码都作为可重复独立使用的模块公布,程序员利用这些模块开发特定的程序。这些程序通常是非正式的、代码较少的并且可随需求和环境变动随时方便地修改代码的,它们对原有的调用者和被调用者模块的依赖都无关紧要,配置代码在这里就像方便的黏合剂一样,免去更复杂和正式的配置文件。理论上对这些第三方程序,直接应用上文所述的三种模式也可以,使用配置代码的好处,只是将配置信息和对象初始化等代码分离开来,方便维护和修改。
下面用播放器例子来说明,这种情况就是播放器和编解码器都是现成的组件,一个程序员利用它们开发一个能够满足业余爱好的个人播放器。
1.5.3 惯例先于配置
配置文件在整个软件中发挥着很大作用。对用户来说,它保存他们的个性化和偏好设置。对开发人员来说,它是用于存放程序运行所需各种信息的地方。这些信息既包括在程序开发时无从预知,只有在部署的环境才知道的;也包括那些通过编辑配置文件而无须修改代码就能改变程序行为的。前者是不得不这么做,后者则是为了获得灵活性的好处。两种目的也不是泾渭分明,本节所分析的为了消除依赖而采用的配置文件就可以说兼而有之。
再好的东西太多也会成为麻烦。配置文件的方便使得有一段时期程序员大量依赖它,于是随着组件、框架的增长,配置文件也爆炸式增长。配置文件大多采用XML格式,一个项目用到的类库、框架越多,这些XML文件就越多。修改一个长长的、层次复杂的XML文件不是一件惬意的事,至少不像在IDE里编写代码那样有那么多提示和错误检查。修改配置文件,既需要专门的知识,又容易遗漏和出错。为应对这种情况,有新的理念被提出。
一位餐馆的熟客在点餐时可以说老样子,而不用每次重复:一份回锅肉,辣椒十成熟,肉八成熟;一碗西红柿鸡蛋汤,少放点西红柿,少放点鸡蛋,多放点水;一碗米饭,别加芝麻和香菜。在编写图形用户界面时,控件的某项属性如果和默认值一样,就不用写代码设置。我们参加别人婚礼时,如果不是亲朋好友的特殊关系,礼金就按惯例。
所有这些背后的理念都是相同的,那就是遵循某种惯例时,可以省去对该惯例包含的信息的描述,而活动参与各方仍然能够顺利沟通和合作。这个思想用到配置文件过多的问题上,就成了惯例先于配置(Convention over configuration)【注:这个原则的译名有很多,约定优于配置、约定胜于配置、惯例优先等等,不一而足。然而都不够准确。与约定相比,惯例更贴近Convention在这里的含义;Over表达的也不是优于胜于暗示的那种一方比一方品质更好、效果更佳,或者两方发生冲突时惯例的效力更高(实际上正相反,当惯例不能满足需求,必须使用配置时,配置的效力更高),而是作为手段的优先使用。惯例优先比较贴切,但又省略了配置,译者可能也是考虑到惯例优先配置不符合中文的习惯。总而言之,我以为惯例先于配置最符合原文的含义。】的开发范式。实际上,惯例在编程中早已大量存在和使用。每种语言的变量、函数命名规则,编码时的格式规范,都是代码的作者与读者之间的惯例。但这些还只是为了人的方便,惯例的更大用途是让程序的各方能相互沟通和合作,一个最不起眼的例子就是C和Java的本地运行程序都会有一个静态的main函数作为启动的入口,更复杂的例子包括Java文件所属包和文件路径的对应、Web项目内部的文件夹结构遵循一定的标准以方便开发时建构工具和运行时容器读取所需的文件。这些隐藏的信息如果不是采用惯例的形式,就要引入配置文件,而程序要读取这些配置文件,就需要它们的名称和位置信息,这些信息不可能又保存在另一级配置文件里,所以归根结底程序总是需要或多或少的惯例。
在消除依赖的上下文里,惯例发挥作用的形式很简单。被调用者的具体类型的名称只要遵循某种惯例,调用者就可以无须其他帮助便可找到它们。比如说每种媒体文件的编解码器的类型名称都遵循文件格式+Codec+版本信息的惯例,播放器就可以在某个第三方的编解码器模块里找到诸如MP4CodecV2的类型。
1.5.4 元数据
惯例的本质是一种合作各方知道的隐秘的知识。利用它可以节省明示的成本。不过惯例也有局限性。一是它的隐秘令外人不易了解,比起配置文件这样的明示方式显得不够清楚。二是惯例的本质决定它只能适应单调的情况,无法满足复杂和特殊的需求。例如在各种ORM(Object-Relational Mapping,对象关系映射)方案中,要建立对象属性和关系型数据库表字段之间的映射,我们很容易提出两者之间名称一致的惯例,但是因为种种原因,这个简单的惯例不能满足所有的场合的需要。遇到这些局限时,我们是不是只有采用惯例先于但不是取代的配置呢?Hibernate之类的ORM开始时就是这样做的,长长的XML配置文件维护起来令人头痛。幸好我们还有一件新武器——元数据。
顾名思义,元数据的意思就是关于其他数据的数据。比方说,一本书记录了大量的信息(数据),那关于这本书的信息,诸如标题、作者、出版社,就是该书的元数据。代码里的类、字段和方法等等同样可以看作是数据,我们以某种形式来描述这些数据就是它们的元数据。最简单的就是代码的注释。例如,我们都知道可以用某种约定格式的注释记录一个方法的用途、参数和返回值等信息,这些元数据既可以被IDE提取作为参考,也可以用专门的工具抽取出来制成完整的文档(JavaDoc就是著名的样例)。
元数据有时可以代替惯例给我们一种更清晰地描述信息的途径。譬如单元测试的类型和方法名称过去通常约定缀以Test,以区别于普通对象,并便于测试工具识别和运行。有了元数据,就可以给这些方法加上特殊的标记(如C#的Metadata元数据和Java的Annotation标注)。如下面这个采用JUnit标注的测试对象。
另一方面,元数据和代码在一起,相较于独立的配置文件,更简洁直观和易于维护。所以在Java中有了Annotation之后,Hibernate的对象关系映射就换成了这种方式。下面(来自Hibernate官方网站教程)分别采用XML配置文件和标注来建立映射的样例就清晰地体现了两者的差别。
针对消除依赖的主题,应用元数据的方式也很简单。上一节末尾提到采用惯例时,媒体文件的编解码器类型的名称遵循特定的格式。如果采用元数据,就可以为编解码器接口定义一个带参数的标记,参数用于设定编解码器所针对的媒体格式和版本号。每个编解码器的开发者只要给其具体编解码器类型加上该标记,播放器在加载包含这些编解码器的类库时,就可以利用标记找到所需的编解码器。
在3.5节,还会给出用元数据消除依赖在现实世界中的应用。
1.5.5 实现消除依赖的方法的本质
在列举了真正实现消除依赖的各种途径之后,再来看看它们的共同点和本质。消除依赖要求调用者和被调用者仅通过接口沟通,而接口是不包含实现代码,调用者无法创建实例的,所以调用者还是要在某个入口处创建一个具体实现接口的被调用者实例。创建实例时不能在代码中用到该实例的具体类型(否则就产生了对它的依赖),也不能将这种方式的创建委托给其他对象(依赖有传递性),所以唯一可行的创建实例的方式是反射。反射时不能直接在代码里写明实例类型的名称(否则就仅仅是另一种形式的依赖),必须通过某种约定的途径获得被调用者类型的信息,这些途径主要包括配置文件、惯例和元数据。除了配置文件是显式地说明被调用者的信息,采用后两种途径时,调用者依然要借助反射。
调用者利用反射来创建被调用者的实例。调用者通过配置文件、惯例和元数据来获取被调用者类型的信息。这两点便是实现消除依赖的诸方法的本质。
那平常被宣传和介绍的工厂模式、服务器定位模式和依赖注入的价值何在呢?答案很简单。它们的价值就是它们本身实现的功能。工厂模式能将某一系列的对象创建集中于一处,服务定位器模式方便调用者从单个地方获取所需服务,依赖注入使调用者通过方法参数被动地获得被调用者。总之,作为有普适性的设计模式,它们可以用在除消除依赖之外的各种场合,所以单纯应用它们也就不能保证消除依赖。直接在调用者的代码里运用上面所说的消除依赖方法的两条原则,就能够实现针对接口编程。不过为了使代码功能清晰,通常我们会采用某种设计模式,将获取被调用者实例的逻辑封装在单独的对象中。也就是说,工厂模式、服务器定位模式和依赖注入是实现消除依赖时两条原则的封装方式。