Live软件开发面面谈(txt+pdf+epub+mobi电子书下载)


发布时间:2020-10-08 16:24:40

点击下载

作者:潘俊

出版社:清华大学出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

Live软件开发面面谈

Live软件开发面面谈试读:

内容简介

现实的软件开发会遇到许多具体的问题,例如,如何消除依赖?怎样进行事件驱动编程?如何在迥异的环境中实现MVC架构?怎样在不同的Web开发框架之间选择?文档型数据库与关系型数据库相比有哪些优缺点?如何构建合适的存取控制?对这些问题,简单的答案、现成的选择、枯燥的代码很多,但是从问题的源头和本质出发,深入全面的分析却很少。本书就软件开发中带有普遍性的重要方面,内容由浅入深地逐渐展开,力图使读者对软件开发实践产生由点及面、融会贯通的理解。自序PREFACE

开发软件离不开编写代码,但仅仅具备编程的技能也还不足以胜任开发软件的工作。这就好比一个人会烧砖、砌墙,但要造一间可供人居住的屋子,他还得了解屋子的结构、不同房间的功能、水电管线的敷设、墙面地面的装修等方面的知识。对软件开发人员来说,编程领域的知识往往是最受关注的,它们确实也可以分为多个层次:编程语言本身的知识(如C、Java),编程范式和思想,面向对象编程和函数式编程,开发框架的知识(如Spring、AngularJS),等等。一个新人若想以软件开发为职业,大概需要阅读的范围就会集中在以上方面。然而,当他开始项目开发时,就会发现还有许多实际的问题需要考虑和解决,软件开发并不像编程教材上的代码样例和习题那样专注于某个算法或思想。

不妨考虑一个典型的业务系统,它是一个图形用户界面的程序,因而需要采用某种GUI框架开发界面;用户在界面上的操作通过事件机制调用相应的处理程序;用户界面、事件处理程序和体现需求的业务逻辑必须组成某种合理的结构,否则系统会随着功能的增加迅速变得难以理解和维护;系统越大,组件越多,越需要适当地保持它们之间的依赖关系,合理地应用接口是关键;这个业务系统显然比所有数据都来自即时输入的计算器复杂,许多信息要往返于数据库;最后,这是一个多用户使用的系统,必须适应不同用户的权限需求。编程语言和范式的理论知识没有触及这些实际的问题,开发框架虽然涉及实践,却又局限在具体的方案中,不易让人获得对知识的一般理解。

软件开发实践中遇到的各个方面的问题往往缺乏系统的理论,程序员凭着各自的理解动手,或者知其然而不知其所以然,或者每个人的所以然有出入甚至矛盾。例如,针对接口编程就是尽量多用接口吗?事件驱动编程的本质是什么?怎么样算是应用了MVC架构?极简主义就是越简单越好吗?文档型数据库和关系型数据库的优劣各体现在什么地方?基于角色的存取控制系统是如何理解权限的?在主流的软件开发理念之外能否另辟蹊径?客户端和浏览器之间的竞争究竟意味着什么?对这类实践中涉及的概念和遇到的问题,如果追根溯源,多思考一些是什么、为什么和怎么做,达到融会贯通的理解,既对实际开发有帮助,又有益于在纷繁多变的技术浪潮中看清技术的本质、把握解决问题的方向。

本书从以上思路出发,逐个讨论软件开发实践中的重要主题。第1章辨析对象间的依赖和针对接口编程。第2章讨论事件驱动编程的方方面面。第3章分析MVC架构的思想和实现。第4章比较图形用户界面的一些相关或对立的思想和技术,并介绍极简主义潮流。第5章分析热门的文档型数据库,并和关系型数据库做对比。第6章讨论存取控制的各个环节,分析基于角色的和基于属性的存取控制的优缺点。第7章介绍快速的Lotus Notes程序开发。第8章探讨软件的兴衰和客户端的潮流。顺序上靠前的章节内容具有一般性,不会依赖其后的部分,靠后的章节有可能应用前文的知识。编写风格上每章力图从主题的源头和本质入手,遵循逻辑层层展开,尽量全面地遍历主题涉及的方方面面。书中代码为正在讨论的理念和问题服务,只是示意性地勾勒出核心的部分,无关和繁冗的部分被省略。

野人献曝,未免贻笑大方;愚者千虑,或有一得可鉴。

是为序。作者2018年5月  第1章 接口

在面向对象编程中,我们将问题拆分成一个个对象来实现,每个对象有其负责的功能,多个对象合作才能形成一个有用的系统。合作在代码中就表现为对象之间的引用和方法调用。调用者与被调用者的关系称为依赖。依赖关系意味着被调用者的变化可能影响和破坏调用者原本正常的运行。当系统变得越来越大,对象越来越多,牵涉方越来越广,持续的时间越长时,设计者就希望这样牵一发而动全身的影响尽可能地小。换句话说,就是希望能消除对象之间的依赖。调用者既要调用被调用者的方法,又不能产生对它的依赖,解决方法便是运用接口。

接口的理念在编程中由来已久,在Java、C#等主流语言中更是引入了原生的Interface结构,类库中也有大量现成的接口。然而单纯地使用、甚至定义接口,并不能达到消除依赖的目的。广为提倡的尽量使用接口编程,有什么好处?真正能消除依赖的针对接口编程又如何实现?它与常用的工厂模式、服务定位器模式和依赖注入有什么关系?最后,什么时候才有必要针对接口编程?在本章讨论这些问题的过程中,接口、依赖、若干设计模式、配置文件、惯例、元数据等概念的含义将得到深入的挖掘和思考。1.1 使用接口编程

先来看看在用Java、C#这样的面向对象语言编程时,经常被提倡的尽量使用接口的理念。在用继承基类和实现接口构建的类型层次体系中,越往上的类型越一般和抽象,越往下的类型越具体和多功能。在定义变量时,无论是类字段、方法变量,还是方法的参数和返回值,都尽可能使用抽象的类型。例如Java语言只支持单个基类,类型的大量抽象继承均以接口的方式体现,导致在一个类的层次体系的高层,接口往往比类多,所以尽可能使用的抽象类型就以接口居多,这也就是所谓的使用接口编程。例如下面的C#代码。

在以ConcreteClass结尾的两个方法中,使用的是具体的类型Hashtable;而在以Interface结尾的两个方法中,使用的是抽象的接口IDictionary。使用接口的好处是,对于DeclareAndReturnInterface方法,将来如果基于业务逻辑或性能的考虑,觉得应该采用另一个更合适的实现IDictionary的类,如SortedList,只需要把dict变量初始化成一个SortedList,后面的代码和返回的类型丝毫不受影响,因而对调用方是透明的;对于PassInterface方法,能够接受任何实现了IDictionary接口的参数,调用方传入的具体类型发生变动不会影响该方法的运作。简言之,就是使用的类型越一般,代码的应用范围越广,适应性越好。当然,应该是尽可能一般,而不是无条件的最一般。所谓可能,就是指该类型的接口能够满足使用者的需求。例如,在上面的例子中不能使用比IDictionary更一般的接口ICollection,因为它没有Add方法。1.2 依赖反转原则

在模块化或组件化软件设计中,不同模块间保持松耦合。每个模块都定义有清晰的接口,模块间的调用都通过和限于接口,只要接口不变,模块内对接口的实现可以自由修改和演化。这个原则就是著名的“针对接口编程,而不是针对实现”(Program to an interface, not an implementation)。针对接口编程比使用接口编程更具雄心、野心和企图心(More ambitious),它要更彻底地消除依赖关系。1.1节的DeclareAndReturnInterface方法,虽然dict变量声明为IDictionary接口,但因为初始化为Hashtable类型,该方法所在的类UseInterfaceDemo仍然依赖Hashtable类。要消除此依赖,必须做到模块之间完全以接口交流。

为了分析依赖和接口的关系,下面介绍最简单的两个模块的情况。应用模块需要使用工具模块的功能。按照传统的设计,应用模块直接引用工具模块。两者的依赖关系如图1.1所示。图1.1 传统设计中应用模块和工具模块的关系

这种紧密的耦合限制了应用模块的可用性,它只能和特定的工具模块一同工作,当有更好的或实现其他功能的工具模块时,它也不能替换以利用。为了打破这个约束,可以将应用模块需要的工具模块的功能抽象成一个工具接口,应用模块通过这个接口来使用工具模块,工具模块只要实现这个接口,就能被自由替换。此时假如将工具接口置于应用模块内,因为工具模块要引用该接口,两模块间的依赖关系发生了奇妙的倒转,如图1.2所示。图1.2 工具接口置于应用模块之内时两模块之间倒转的关系

每个工具模块在开发时都要引用应用模块,当然是不理想的,尤其作为工具模块根本无法知道要使用它们的应用模块可能是什么样的,所以这样反过来的依赖也必须消除。办法就是令工具接口脱离应用模块,成为一个新的独立模块。这样应用模块和工具模块都仅仅引用这个抽象的接口模块。三者的关系如图1.3所示。图1.3 工具接口独立于应用模块和工具模块时三者的关系

这个最终方案可以用一句话来概括:调用模块不应该依赖被调用模块,两者应该依赖抽象出的接口。这个原则被称为依赖反转(dependency inversion),虽然它不是顾名思义地指上面第二种情况。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返回对应的服务就可以了,服务对象的具体类型是什么由定位器决定,或者更准确地说由程序的需求和所处的环境决定。也就是说,调用者请求的一种接口对应一个服务。但是在我们的例子里,媒体播放器需要获取实现编解码器接口的多种具体类型的实例,所以Resolve 方法还补充了一个可选的字符串参数,用来传递编解码器类型的信息。关于服务的来源,这里采用的途径是通过Register 方法添加,同样有一个可选的字符串参数。因为添加服务是播放器工作之前的准备,所以将这部分代码放在一个单独的类ControllerUsingServiceLocatorV1中,再由它来调用播放器。1.3.3 依赖注入

上两种模式有一个共同点,就是无论被调用者的来源如何,调用者都要从某个地方主动获取。我们把这种方式称为拉。与之相对的是由外界将调用者需要的对象推给它,依赖注入(Dependency injection)就是这样一种模式。这个名称听上去很深奥,其实本质很简单。所谓将对象推给调用者,在程序中就是指将它作为参数传递给调用者。根据一个对象接受参数传递的方法的类型和特点,专家们又将依赖注入分为好几种,并给它们都起了很酷的名字。将被调用者通过调用者的构造函数传递称为构造函数注入(Constructor injection);通过调用者的设值方法或属性传递称为设值注入(Setter injection);如果用于传递的方法是在实现一个专为依赖注入而建的接口,就称为接口注入(Interface injection);如果被调用者是作为一个普通方法的参数传入,就称为方法调用注入(Method call injection)。下列代码演示了这几种形式。

构造函数注入、设值注入和接口注入都将传入的对象保存在调用者的字段里,可供调用者的所有方法使用。这类情况下,执行注入的对象也就负责创建调用者的实例,这个对象传统上的称谓包括容器、应用上下文(Application context),它是整个应用程序控制流程的起点。容器提供方法返回注入好了的调用者实例,特定于应用程序的代码就从调用这些方法获取实例开始。方法调用注入传入的对象仅仅在该方法内被使用,其他方法无法访问,该方法要使用也只能在每次调用时注入。在实现上接口注入最为复杂。不仅要为负责注入的方法建一个接口,还要为每种调用者配套一个注入器类,这些注入器类又实现一个公共的注入器接口,然后由一个容器创建注入器,注入器再调用它配套的那个对象实现的特定注入接口里的方法。是不是感觉要被绕晕了?那就直接忽略它,因为这样折腾的结果是和其他形式相比没有优势,现实中也很少有人使用。

最后要比较的是构造函数注入和设值注入。两者的选择实际上是一个更一般的问题:应该通过构造函数还是设值函数向对象传递信息?构造函数是直观的选择,它毕竟就是被设计出来干这个的。对象的用户最容易使用,也能够最清晰地透过它的参数类型了解该对象需要的信息。构造函数在对象初始化时必然会运行,设值函数则要依靠用户在恰当的时候调用。构造函数还有一个特别的好处,即能够用来设计不可变的(Immutable)对象。不可变的对象有很多好处,线程安全、易于测试等等。将构造函数传入的信息保存在只读字段里,对象一旦创建,无论被调用了什么方法,状态都和最初保持一样。而使用设值函数时,保存传入信息的字段就不能是只读的,使得以后可能通过再次调用设值函数或者其他方法修改该字段。尽管如此,构造函数也有难以应对需求的时候。遇上参数太多、类型相同不好区分多种版本的构造函数等情况就要考虑使用设值函数。

总的来说,最常见和有用的形式是构造函数注入和设值注入。因为上面所说的实现机制,容器返回的调用者对象内部保有实现了被调用者接口的某个具体类型的对象。而在我们的例子里,播放器需要调用多种编解码器的对象,所以只能采用方法调用注入的形式。与应用服务定位器模式类似,可把负责注入的代码放在播放器之外的一个控制器类中。1.4 真的实现了吗

工厂、服务定位器和依赖注入是通常人们用于消除模块间依赖的三大法宝。在检视这些模式的代码时,不知道聪明的读者有没有产生一个疑问:播放器对编解码器的依赖真的消除了吗?1.4.1 依赖的传递性

∀a, b, c∈X: (aRb∧bRc)⇒aRc

相信很多朋友对上面表达式中符号的含义还有印象,它是传递关系(Transitive relation)的数学定义。一个集合X上的二元关系R被称为是传递的,如果集合中任意元素a对b有此关系,而b对c又有此关系时,a对c就有此关系。我们都知道很多这样的关系,例如有序关系:如果a>b,b>c,就有a>c。程序中对象间的依赖也是一种传递关系。如果对象a依赖对象b,b又依赖c,那么a对c也有依赖。

再来看上一节的三种模式。播放器的代码中都没有对编解码器具体类型的依赖。但是,在工厂模式中,工厂创建了具体编解码器的实例;在服务定位器模式中,负责注册服务的控制器创建了具体编解码器的实例;在依赖注入模式中,负责注入的控制器创建了具体编解码器的实例。也就是说,三种模式下仍然有某个对象依赖具体编解码器。在工厂模式中,播放器依赖工厂;在服务定位器模式中,控制器依赖播放器;在依赖注入模式中,控制器依赖播放器。因为依赖有传递性,所以在工厂模式下,播放器依然依赖具体编解码器。在后两种模式下,播放器虽然不依赖播放器了,但是控制器和播放器一起都属于对编解码器模块的调用模块,将播放器的依赖转移到控制器,并没有解决问题。所以结论就是工厂、服务定位器和依赖注入模式并没有消除模块间的依赖。有人或许会质疑,我上面的代码写得不够好,但是它们的确体现了三种模式的内涵,这是毫无疑问的。1.4.2 依赖的形式

既然没有消除依赖,就要继续想办法。调用方的代码不能引用具体的编解码器类型,但总归要以某种形式知道这些类型。用这些类型的名称似乎是个不错的方案,字符串记录的名称无须引用对应的类型,使用反射创建这些类型的实例,将调用者对被调用者的类型依赖从编译时移除,而仅出现在运行时。编解码器的类型名称可以和之前的媒体文件类型名称一样,由播放器传递给工厂或服务定位器。不过依据这些名称利用反射创建编解码器实例的代码太简单,不如直接写在播放器内。

与编解码器的类型名称一起出现的还有它所在的程序集的名称,如果是Java,此名称就会以Java包的信息形式被包装在某个ClassLoader里。

然后再认真想想,这下真的消除了依赖吗?消除依赖的本质是调用者和被调用者在都不知道对方具体实现的状态下通过接口来合作。换言之,除了接口,双方对对方一无所知。调用者不能引用被调用者的具体类型,就能知道这些类型的名称吗?

依赖的形式不仅包括直接使用被调用者的具体类型,还包括使用被调用者接口之外的任何信息。

所以,上述播放器依然依赖具体的编解码器。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 实现消除依赖的方法的本质

在列举了真正实现消除依赖的各种途径之后,再来看看它们的共同点和本质。消除依赖要求调用者和被调用者仅通过接口沟通,而接口是不包含实现代码,调用者无法创建实例的,所以调用者还是要在某个入口处创建一个具体实现接口的被调用者实例。创建实例时不能在代码中用到该实例的具体类型(否则就产生了对它的依赖),也不能将这种方式的创建委托给其他对象(依赖有传递性),所以唯一可行的创建实例的方式是反射。反射时不能直接在代码里写明实例类型的名称(否则就仅仅是另一种形式的依赖),必须通过某种约定的途径获得被调用者类型的信息,这些途径主要包括配置文件、惯例和元数据。除了配置文件是显式地说明被调用者的信息,采用后两种途径时,调用者依然要借助反射。

调用者利用反射来创建被调用者的实例。调用者通过配置文件、惯例和元数据来获取被调用者类型的信息。这两点便是实现消除依赖的诸方法的本质。

那平常被宣传和介绍的工厂模式、服务器定位模式和依赖注入的价值何在呢?答案很简单。它们的价值就是它们本身实现的功能。工厂模式能将某一系列的对象创建集中于一处,服务定位器模式方便调用者从单个地方获取所需服务,依赖注入使调用者通过方法参数被动地获得被调用者。总之,作为有普适性的设计模式,它们可以用在除消除依赖之外的各种场合,所以单纯应用它们也就不能保证消除依赖。直接在调用者的代码里运用上面所说的消除依赖方法的两条原则,就能够实现针对接口编程。不过为了使代码功能清晰,通常我们会采用某种设计模式,将获取被调用者实例的逻辑封装在单独的对象中。也就是说,工厂模式、服务器定位模式和依赖注入是实现消除依赖时两条原则的封装方式。1.6 有必要针对接口编程吗

到现在为止,我们讲的都是针对接口编程的意义、消除依赖的实现方式,仿佛针对接口编程是一个先验的、放之四海而皆准的真理。市面上介绍编程的教程和文章、网络上分享经验的博客和帖子,无论是不是关于针对接口编程的主题,有许多在代码样例中每创建一个类前都先定义一个接口(在后面的讨论中不妨简称为接口先行),而且在文字中透露出这样做不言而喻的正确性。这时候,人们天生的怀疑精神又有用武之地了。有必要在一切场合都针对接口编程吗?或者更准确地说,什么情况下应该针对接口编程(什么情况下不需要、不应该)?1.6.1 针对接口编程的成本

本章先前所论述的针对接口编程的好处都是真实的,与此同时还有一点也是真实的,就是针对接口编程的成本。不这么做时,调用者直接创建被调用者的实例,一行代码足矣。这么做时,先要抽象出被调用者的接口,让具体类型实现该接口,然后采用工厂、服务定位器或依赖注入的模式,还要设定配置文件、惯例或元数据,多写无数行代码。我们日常买东西的时候会讲究性价比,写程序时自然也要考虑这样做值不值得。如果为每个类都定义一个接口,一个项目里的代码文件数量就几乎要翻一倍。而且上述接口先行的样例往往是定义完接口就了事,针对接口编程所需的配套工作都略而不谈,让这些代码表面上既用到了接口,又不甚烦琐。实际上假若真将这种接口先行的方式贯彻到项目开发中,任何人也坚持不了——为被调用者创建了接口,那调用者要不要也有接口,工厂、服务定位器和依赖注入的容器要不要创建接口,解析配置文件和元数据的对象要不要接口,这些对象用到的任何一个哪怕是辅助的提供方便的对象要不要接口……项目代码的主体将变成实现针对接口编程的脚手架(Scaffold),业务逻辑反将退居其次。所以无论多么主张接口先行,最后要面对的问题都是什么情况下需要为类创建接口并针对接口编程?1.6.2 接口的意义

针对接口编程是手段,目的是消除依赖。本来调用者使用被调用者的类型,针对接口编程后,调用者使用被调用者遵循的接口。为什么使用类型是依赖,使用接口就消除了依赖呢?如果说在前一种情况中调用者依赖了被调用者的类型,那么后一种情况为什么不说调用者依赖了被调用者的接口?可以从两个角度回答这个疑问。

首先,可以说“被调用者的类型”,即该类型是属于被调用者的;但严格地讲,我们不能说“被调用者的接口”,因为该接口不属于被调用者,同样也不属于调用者。对于需要合作的调用者和被调用者双方,接口是第三方的中介。虽然有时把名称上体现被调用者共同点的接口看成是它们的代表(如ICodec代表MP3Codec),又或者反过来把接口看作调用者对被调用者需要的功能的抽象,但实际上就像1.2节中最后的图例所示,接口是独立于调用者和被调用者的。所以将调用者使用的被调用者的具体类型换成接口后,调用者就不再依赖被调用者了,那么对第三方接口的引用算不算依赖呢?

其次,接口本质上也是类型,和普通类型的差别就在于它不包含具体的实现。一个概念的内涵越大,外延就越小;反之,内涵越小,外延就越大。用另一种方式来表述就是,一个对象越具体,应用范围就越小,有效时间越短,越容易发生变化;反之,一个对象越抽象,应用范围就越广,越稳定。【注:与之相应的是,越具体的对象在它的小范围内发挥的作用越大,越直接;越抽象的对象在它的大范围内发挥的作用越小,越间接。这个哲学的陈述有各种场景的应用(因为它本身就很抽象)。比如说对他人的关心,我常常发现一个人关心的人越少,感情就越浓烈,像一些溺爱子孙的父母和老人,大部分心思都用在孩子身上,吃穿用住具体到无以复加;而那些关心社会大众胸怀天下的大人物,对身边的人关心的强度却不怎么高。卢梭关心人类社会不公正的来源,写《社会契约论》和《爱弥儿》,对人类的爱很普遍、很抽象,自己的孩子却生一个抛弃一个。很多哲学家视婚姻为累赘。西方社会的人际关系和中国相比,正是亲人不亲,外人不外。】接口因为不包含具体的实现,是最抽象的,因而与实现它的类型相比,应用范围最广,最稳定。所以当调用者使用在广大范围内长久有效的接口时,依赖的问题就失去意义了,因为依赖一个对象本身不是问题,问题是发生在对象失效或者需要替换时。

由上述讨论可见,接口的第一个意义是它的高度抽象和由此带来的广泛适用性和稳定性。

提倡针对接口编程的人往往会这样推销:接口是对一个对象行为和功能的描述,是对象暴露给外界的信息的总和,是对象之间交流的契约。如何实现接口则应该是对象的隐私,是彼此间不知道也不应该知道的内部细节。一旦调用者获取了被调用者接口之外的信息,例如通过被调用者的具体类型创建实例,或者调用了接口以外的方法,被调用者就失去了替换和修改的自由。这些堂皇的陈述看上去都很正确,但问题是怎样理解“接口”。我们在学习面向对象编程时,都了解到相较于过程式编程,对象有三大好处或者说特点:封装、继承和多态。封装的意思就是一个对象只暴露它想暴露的方法和属性,外界无须知道的则隐藏起来。为此语言设计者发明了一堆存取限定符:public、private、protected、package,以精确地区分对象的信息对外界的可见性。那些标记为public的公开方法不就是一个对象的“接口”吗?调用者只要通过这些方法来使用,被调用者的私有方法不还是可以自由修改和替换吗?甚至更进一步说,即使在过程式编程中,一个函数暴露给外界的也仅仅是它的签名(名称、参数和返回值),实现的代码同样是隐藏的和可以修改的,函数的签名不就是它的“接口”吗?所以说,我们在Java这样的静态强类型语言中所说的接口,也就是语言中的Interface的要义不在于它包含的是一个对象公开的信息,而是这些信息是多个类型的对象共同具备的,也就是说,它描述的是多种对象具有的公开的共同点,因此另一个对象如果是通过这些共同点来使用这些对象中的一个,就可以随时替换成其他任何一个。这是接口的第二个意义。1.6.3 何时针对接口编程

理解了接口的意义,也就有了前述有关问题的判别准则。

任何注定不会有多个实现的接口都是不必要的。

这里的注定不是中国男子国家足球队注定战胜不了巴西队的注定,几十年后,沧海桑田,一切皆有可能。总之,这里的注定指的是开发者能预知的必定。在为一个类创建接口前,开发者可以略微思考一下在可预见的将来该类是否会一直是这个接口的唯一实现。很多时候,这样的判断并不是太难。比如写一个在Word文档中插入代码的小工具,又或者为一家美容公司开发的项目中针对该公司特定规则的业务逻辑。用户需求的特殊性决定了代码的针对性(特殊性、选择性),从而不会产生多个对象遵循同一接口而实现细节有差异的需要。需求的易变性(缺乏长期信息化的积累、最佳实践和行业标准)导致代码的频繁改动,会使定义接口失去基础。项目的规模、时限等因素引致的资源和进度紧张让开发人员没有时间去细致地设计接口,为不同的实现预留空间。

上面的判别准则是否定的陈述,我们还需要从肯定的角度来看什么情况下接口是必要的。最简单的是完全相反的情况,凡是注定有多个实现的接口都是必要的。很多场合也是很容易做出这样的判断的。比如我们一直使用的编解码器的例子,具体编解码器类型不仅肯定有多个,而且会随着新的媒体格式的出现和现有编解码器的改进和尝试而不断增长。又比如Java和C#的Collections类库中的各种接口,在设计的时候就预知会有多个实现类,并且将来还会不断有类型因需要而实现。这些场合也有共同的规律可循。开发类库时,常常会有一些对象的行为是基础的、许多类型共有的,这样的行为就适合被抽象为接口。设计框架、架构和标准时,任务的目标往往就是抽象的接口,具体如何实现在设计时既有可能不清楚,更有可能是有意留给标准的参与者和遵循者未来去完成。软件公司和开源组织推出可扩展的产品时,为了第三方能够开发插件,必须提供接口。

然而许多程序员日常开发的项目不属于这类情况——定制化的用户界面和业务逻辑,系统完全在公司或组织内部完成,没有留给第三方开发的可能和空间。当处于上述正反两个凡是准则的中间地带时,就需要具体情况具体分析。特殊的、易变的、周边的对象倾向于不需要接口;普遍用到因而可能产生变体的、稳定的、核心的对象倾向于需要接口。项目和团队的大小也很有关系。项目越大,需求越多,建模形成的系统越复杂,就越容易演化出多个类型遵循一组共同行为的结构。另一方面,系统越庞大,对清晰的结构、稳定性和可扩展性的要求也就越高。项目规模大的一个衍生品是开发团队人数多,随之而来的是任务分解、分组负责,每个小组需要能够独立开发负责的模块,模块又能方便地合作以完成最终的产品,这就要求在各个模块间定义清晰的接口,实际上这种情况相当于前面所说的软件公司开发可扩展的产品,第三方提供插件,本质都是一个系统不是在一个开发人员群体内部完成,该群体的人在设计时必须想到系统有部分功能是不在自己控制范围之内的,必须通过接口与他人合作。如此一来,就有一个项目开始时不大,后来因某种原因越变越大的情况,该如何处理?也就是说,系统成长后,有些对象需要接口了,而最初设计时根据前述的标准和考量是不需要的。我们应该未雨绸缪,一开始就多定义接口吗?那样就回到前面否定的接口先行的老路上去了。还有一个不那样做的可行性上的理由是,在系统诞生时就预先设计好将来会用到的接口几乎是不可能的,随着需求和目标的扩展和变化,依据用户的反馈,包括引入接口这样结构上的变化是不可避免的,这也是重构在项目开发中的重要性所在。而且幸运的是,借助现代开发环境的重构工具,从一个类提取接口的工作可以轻松完成。  第2章 事件

在计算机科学里,事件指的是系统内发生的某件事或变化,可以被某个程序接收并处理。它可以是用户输入导致的,例如按键、单击鼠标;可以是网络通信导致的,例如Web服务器接收到一个请求,邮件服务器收到一封邮件;也可以仅仅作为不同对象之间控制流程转移的一种手段,例如为程序自定义的事件。所有这些情况都被抽象出一套共同的机制,用于有效地处理事件参与者之间的互动。这个机制包含以下几个组成部分:事件的源/发布者、事件的收听者/订阅者/处理器以及收听者与发布者之间如何处理事件的协议,包括收听者用于处理事件的方法的签名、发布者传递给收听者的事件信息。事件机制在图形界面软件开发、网络编程等领域都有广泛的应用,围绕它进行的编程范式被称为事件驱动编程。

事件与编程中的许多其他概念既有联系也有区别,如控制反转(Inversion of control)、回调函数(Callback function)和观察者模式(Observer pattern)。把事件和它们放在一起讨论比较有助于更清楚地理解各自的内涵和用途。之后本章将重点分析Java、C#和JavaScript三种语言中事件编程的不同实现方式和特点,以更充分地揭示事件的本质,并且例示一个理念在不同语言中相映成趣的表现形式——这既能体现理念的一般性,又极好地展示了编程语言由于设计之差异在解决问题的方式和表现力上的多样性。2.1 控制反转

所谓控制反转,是针对程序正常的控制流程而言的。一般情况下,正在运行的函数或对象的方法调用另一个函数或对象的方法,控制也就从调用方转移到被调用方,直到被调用方运行完毕,才返回给调用方。但是某些情况下,需要被调用方中途将控制传递回调用方,这种控制转移的方向与正常方向相反的现象就称为控制反转。最常见的有以下几种情况。(1)被调用方需要一直运行,无法返回,而在不确定的时间又要运行调用方的逻辑。图形用户界面程序的开发就是很好的例子。程序员使用图形用户界面的通用类库里的控件创建视图,视图一直运行,收听用户操作触发的事件。用户什么时候输入文本框、单击按钮是不确定的。当这些事件发生时,视图则要通过事件的处理程序,执行项目特定的业务逻辑。(2)被调用方运行时间较长,调用方不愿或者不能等待被调用方执行完成。在正常的控制流程下,在被调用方执行完毕返回前,调用方一直等待,即处于所谓阻塞状态。假如采用控制反转的模式,将调用方等待被调用方返回后要运行的逻辑以某种方式传递给被调用方,然后新开一个线程,让被调用方在其中运行,调用方就可以保有控制,去做其他事情。函数的异步调用就是这种情况。(3)被调用方是提取多个特定程序中重用的公共的逻辑,被调用时还需要补充原来程序中特定的逻辑。例如,JavaScript中Array的forEach()、map()等方法,将对一个列表数据结构的遍历逻辑提取出来,被调用时需要传入一个函数,以实现循环中特定的逻辑。

控制反转发生的共同前提是:调用方是项目特定的代码,被调用方是具有某种功能的通用程序,在开发中无法也不应该被修改。否则若被调用方也是一般的项目(ad hoc)代码,当它需要访问调用方的功能时,就可以直接在代码中加入,控制的转移也就是正常的。

比如对于以上第一种情况,假如图形用户界面的类库不是通用的,而是程序员每开发一个从头写出的项目,每个控件都是独一无二的,那就可以直接在一个按钮的实现代码内部添加它要处理的事件的响应程序。应用程序运行时,控件执行事件处理程序时也仅仅是调用自己的一个方法。这么极端的情况当然不会发生,一种缓和的变体却是可能的,并且实实在在地存在。在这种情况下,控件仍然来自现成的类库,向视图上添加的却不是它们的实例,而是实例化自它们的继承类,在继承类中添加了事件处理程序。这样控件执行事件处理程序时,也没有将控制返回给它的调用方。理论上,开发图形用户界面程序时,确实可以采用这种方式,实际上Android的用户界面框架还特意提供了这种途径,作为控件基类的View有若干公开的方法,例如onTouchEvent(),当一个按钮被单击时,这个方法就会被系统调用。所以要为按钮添加响应该事件的逻辑,可以在按钮的继承类中实现这个方法。然而现实中没有多少程序员会采用这种方法,因为采用事件发布者和订阅者的模式,只需使用现成的控件,添加事件处理程序和调用一个方法一样简单,而为每个控件实例都创建一个继承类就烦琐得多。由这些讨论也可以从反面看出,事件实现的控制反转对图形用户界面程序开发来说,是一种多么有效和重要的模式。

对于第二种情况,假如被调用方是普通的项目代码,调用方不愿等待它运行完毕后返回,仍然要创建新的线程,但是不必将被调用方返回后要运行的逻辑再传递给它,因为此时被调用方和调用方一样,也在程序员的控制之下,直接将这些逻辑写在被调用方中就可以了。2.2 观察者模式

在面向对象的语言中,为了在上述的第一种情况中(不确定何时要从被调用方运行调用方的逻辑)实现控制反转,常常会应用观察者模式。该模式的含义是:一个对象的内部状态发生变化时,通知另一些感兴趣的对象。前者称为主体,后者称为观察者。具体到代码上,主体内部保持一个观察者的列表,程序通过调用主体的addObserver或deleteObserver方法向其增加或删除观察者,当主体的内部逻辑引发状态变化时,调用自身的notifyObservers方法,该方法遍历观察者列表,分别调用它们的notify方法,将主体作为参数传递给观察者,观察者就可以依据主体的状态变化作出相应的动作。两者的关系如图2.1所示。

与事件编程做对比,主体可以被看作事件发布者,观察者是收听者,notify方法是具体的事件处理程序,主体作为参数被传递给notify方法所以又是事件信息。将观察者模式以事件编程的语言来改写,就会得到类似下面的代码。图2.1 观察者模式

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载