• 《软件框架设计的艺术》试读:2.2 模块化应用程序


    模块化的应用程序是由分布式团队开发出来的独立组件组成的。这些独立的组件通常都会提供一个自己的API,当然在具体执行的时候,也需要第三方组件的API或者其他功能才能保证正确运行。例如,Tomcat服务器需要Java运行时实现。同样,标准的C++模板库也需要libc,这样才能调用printf方法。如果使用了大量的组件,那么面临的最大问题就是能否看清整个应用的全貌。只有理解了整个系统以后,才能理清楚模块间的交互关系。在上一节中,我们可以看到一个组件的API只会把其最重要的功能给暴露出来,大部分情况下,用户无需关注其内部的实现,只需要集中精力了解API即可。但如果系统包含成千上万个组件的话,光是组件API的信息就会非常多,很难无绪地处理。所以我们来看看能否找到一些可行的方式,在不需要深入了解所有组件的情况下,也能把组件整合在一起来构建可用的系统。

    在设计API时,第一课也是最重要的是:给自己的组件起个漂亮的名字。这些名称必须是独一无二的,能够根据该名称在整个系统中查找到相应的组件,而且这个名字应该尽量做到让用户闻其名知其意。对Linux Kernel来说,其中kernel这个词就是一个很好的名字;libc是C语言的基础库,这个名字也算不错;org.netbeans.api.projects是NetBeans平台上的一个支持项目结构的组件,这个名字堪称完美。一般来说,现代的所有组件都有一个名称,因此要开发新组件的话,也很自然地应该先给它起个名字。但深入地考虑一下就会发现,所谓的名称,其实只对人有意义,对于机器来说,无论阿猫阿狗哪个名字都无所谓。对于机器来说,任意一个16进制的名字都可以,哪怕是0xFE970A3C429B7D930E。事实上,有些组件的名称还带有人名,这也说明了组件的名称其实主要是针对人而非机器。组件的名字对客户和终端客户很有用,他们可以利用组件的名字了解组件对于供应商和集成商来说,名字也同样重要,有助于他们使用这些组件来构建应用程序。

    在知道如何为自己所开发的组件命名以后,还要看一下每一个组件运行的环境。没有哪个组件是在真空中运行的。它需要从周围的环境中取得相应的服务。所以,使用一个组件可能还需要完全了解该组件的环境需求①,搞不好还要深入了解其内部实现才能明确其环境需求,如果运气好一点,通过运行该组件也可以了解其环境需求。但这都不是我们想要做到的“针对性无绪”,因为这样提高了对集成人员的要求,他们在构建一个应用程序之前,还需要了解每一个库的很多细节才行。如果一个库对其用户提出了如此高的要求,那么会严重地阻碍其用户群的发展。事实上,一个库的大部分用户不需要了解该功能库的内部实现。也应该如此,用户可以在不深入了解一个库内部情况的前提下,就能使用库完成自己的工作。通过正确设计和描述每一个独立组件就可以做到这一点。如果一个组件能够自动处理自己所需要的运行环境,那么相应的集成人员在使用该组件时,就能做到尽量无绪,因为相应的环境不需要人为干涉,不需要使用编译器、链接器和集成工具。

    在模块化系统中,每一个组件都会提供一些其他组件所需要的信息。组件的设计者需要将这些信息设置进去,或者由打包工具自动处理。例如,RPM安装文件的构建就是如此,Fedora、Mandriva和SUSE这些Linux的不同版本,都使用该格式来创建它们版本的安装包,这些安装包能够自动检查本地动态链接库以查找该软件运行时需要的动态链接库,并自动调整安装包内容以保证能够使用这些动态链接库。不管这些工作是工具自动完成,还是由人工完成,都只需要处理一次就可以了。完成该工作的开发人员应该就是该独立组件的作者,他们知道组件的内部机制,了解组件运行时所需环境,从而正确地列出其依赖的内容。这也是“无绪”的又一个例子:一个工程师多花点时间和精力来列出组件所依赖的内容,而组件的用户,如应用的集成人员或者其他开发人员,则只需要这个组件提供的信息来让工具自动完成相应的工作即可。

    配置类路径时的噩梦

    纯粹只依赖Java平台来编写Java应用程序的好日子已经一去不复返了。可用于开发Java应用程序的开源软件类库已经很多了,而且还在每天增加。结果,几乎现在每个Java程序都会使用那些已经打包的JAR类库,如Apache Commons、HttpClient、JUnit、Swing部件等。如果要运行这样一个程序,第一件事就是正确设置程序运行时的类路径。直接把这些库都包含在类路径中是一件比较简单的事情,但是每一个库都还有自己额外的依赖库,也需要将这些额外的依赖库也加入到类路径中,如此反复,直到所有的依赖库都加入到类路径中,这样做使得类路径的配置变成了一个噩梦。

    我最近有机会使用了FreeMarker,这是一个漂亮的模板引擎,我要把它作为一个类库用在NetBeans的开发中。引入一个freemaker.jar类库文件很容易,但在我尝试检查它的所有类是否成功连接的时候,却觉得非常痛苦。这个JAR包引用了很多别的项目,如Apache ANT、Jython、JDOM和log4j,还有Apache Commons Logging。对于FreeMarker来说,真需要这么多的项目吗?如果没有这些库,那么FreeMarker还能运行吗?如果后者答案是肯定的话,那么又是哪些功能用到了这些库,而且这些功能是否会因为库的缺失而引起系统的崩溃?我不知道,其实我也不想知道,我只希望在使用这个库的时候,能尽量做到无绪,但现在我无法达到这个目标。我现在必须深入研究源代码,找到那些我们在使用FreeMarker时不会调用到的第三方类。我曾经祈祷FreeMarker能够使用某种模块化系统来标识它依赖于哪些内容,如NetBeans Runtime Container就提供了类似的功能。

    如果从技术角度来对分布式开发给出一个好的解决方案,那么就是将应用程序模块化。那些非模块化的程序由大量代码组成,然后相互之间紧密地耦合在一起,而模块化程序则可以由很多小的独立代码段②组成。这些小的代码段是独立存在的,且被唯一地标识,通过公开接口的方式供他人使用,每一个代码段还可以正确地描述该代码段所需要的运行环境(比如说第三方组件或部件使用该代码段时,如何准备其环境)。对Linux版本的发行商来说,这种开发方式是可行的,而且也证明了其有效性,可以交由分散的团队按照自己的计划来分别开发相应的模块,然后再由一个管控者将所有的模块集成在一起,这样可以有效地规避时间安排和团队分散的风险。而且这样做可以更好地支持无绪的开发模式:如果开发人员能够将自己所负责部分的依赖正确地描述出来,对系统的集成人员来说,在集成该部分时就不需要了解组件的内部信息了,并且仍然能够成功地构建最终的应用程序。

    但现在系统开发面临着新的挑战,要知道,软件开发中的组件并不是静态的,而是会随着时间的推移在不停地向前发展,时刻在变化着。不管这种改变是因为要修正现有的bug,或者增加新的功能,但一个API确实是在不停地变化着,因此仅仅通过一个名字是不能够唯一标识一个API的。为了让很多组件能够整合在一起运行,就必须能正确地标识该组件提供了何种API。

    例如,如果一个用Java编写的类引用了String.contains(String)方法,那么这个类可以在Java 5中运行,因为这个方法是从Java 5这个版本开始在String类中提供的。但这个类在Java 6中也可以使用,因为这个版本的Java也提供了这个方法。考虑到Java团队的兼容性处理方式,相信对于最新的Java 7,这个类也是可以使用的。但老版本的Java没有提供这个方法。所以如果使用老版本的Java运行使用了该方法的类,就会出现类无法正确连接的问题③。

    事实上,如果开发人员想明确某个类或者某个应用对运行环境的需求,就必须列举该类调用的所有方法,以及它引用的所有类和字段。当然关于依赖的描述信息会非常长、不具有可读性,有时甚至比实际的源代码本身更大。可以简单设想一下,有一个类声明自己需要某一个版本的Java,要求这个版本的Java所提供的java.lang.String,必须有一个构造函数及length和index of这两个方法,而且这个类还实现了java.io.Serializable序列化接口。这样细节的描述已经使得组件超出了无绪的范畴,而本书却一直在强调无绪。尽管一台计算机可以自动检查这些约束关系,但对于人来说,想做到这一点就比较困难了。人们更合适处理一些简单的场景,如使用自然数来标识组件的版本。如果按照这种方式来描述前面的需求,就变成“当前类库要使用Java 5”。

     
    图2-1 应用程序需要一个组件的两个版本同时运行
    图2-1 应用程序需要一个组件的两个版本同时运行

     

    但仅仅使用数字还可能导致别的问题。设想一下,如果你想将组件A(它要求使用Java 5版本)和组件B(它要求使用Java 6版本)集成到自己的程序中,如图2-1所示。在这种情况下,需要有一个环境能同时支持组件A和组件B,但问题在于到底使用哪一个Java版本。为了处理这种问题,大部分API开发模型都会用到兼容性概念。即如果在某个版本N中加入了某个API,在接下来的版本N+1、N+2、N+3…都会兼容这个API。所以处理前面问题的方式就是使用Java 6版本:因为组件B要求使用这个版本,而组件A则需要Java 5,但这也说明组件A可以在Java 6上运行,因为Java 6会对Java 5兼容。事实上,引入这种API开发模型大大简化了开发人员集成程序时的工作。他们在集成程序时,可以做到非常无绪。每一个类库都有一个“小小”的用户需求:向后兼容。事实上,这个需求绝对不小。它非常复杂,并不是一个可以轻易达到的目标,这也是本书的一个主题所在。本书会强调,如果要把分布式开发团队开发的组件集成到大型程序中,就需要让集成人员做到最大化的无绪,也就意味着这些开发团队不仅要细心开发各自组件和相应API,还要保证兼容性。

    软件版本的非线性

     
    图2-2 版本树
    图2-2 版本树

     

    给软件的版本进行编号的时候,最常用的编号方式不是基于自然数。相反,更习惯使用一个圆点分隔的十进制。这么做自有其必要性,因为在软件开发中,发布的版本其实是非线性的,因为开发时并不一定是沿着预先定义好的主干进行开发,而是会有很多分支,每个分支都有其特殊含义,比如说一个用来修复bug的分支等。如图2-2所示,假设有一个1.1.1版本,这个版本的编号要比2版本小,其包含的功能也会比2版本要少一些,但事实上该版本的发布可能还落在2版本的后面。

    模块化应用程序的每一个模块都会有一个版本号,像1.34.8这样。如果有新版本发布了,则会有一个新版本号,比原有版本号大(按字典顺序排列),如1.34.10、1.35.1或2.0。

    在一个模块化系统中,如果要表示一个组件对其他组件的依赖关系,可以用名称加上需要的最小版本号。不管是XML解析器,还是数据库驱动,或者是文本编辑器,乃至Web浏览器,都可以通过它们的依赖声明来获取其版本号。比如说,从依赖声明中,可以知道该模块需要版本高于3.0的XML解析器和版本高于1.5的Web浏览器等。这些假设都是建立在完全兼容的情况下:也就是说,即使某个模块的版本号比较高,集成的系统也仍然可以正常工作。

    想只通过一个版本号就能提示一个功能库从创建至今的所有变化,显然是不可能的。但版本号比较实用,而且更重要的是它可以让相关人员在使用某个功能库的时候,尽量地保持无绪,这样也允许那些功能库的开发人员来通过一个容易识别的版本号来识别其功能上的改变。

    只有在开发和设计时遵守一定的规则,前面那种处理依赖关系的方案才能生效。第一个规则是,如果发布了一个新的版本,那么前一个版本提供的功能和约定,对于新版本应该继续有效。当然,这事说着容易,做起来可麻烦多了,否则还要一个质量控制部门做什么。第二个规则是,如果本组件的外部依赖有所改变,那么就需要与其进行同步调整。因此,一个模块化系统需要依赖某个新功能,比如说要使用一个HTML编辑器,就要加入一个“htmleditor>=1.0”这样的依赖关系的声明。如果还要使用Web浏览器在1.7新版本中引入的新特性,那么还要加入“webbrowser>=1.7”这样一个依赖关系的声明。

    组件的某些版本可能存在特定的bug,所以为了处理这种情况,还会使用第二个版本号(实现版本号)。与前面标准的版本号不同,这个版本号通常是一个字符串,如Build20050611,对于这种版本,测试方面就没有什么区别了。如果某个bug出现在3.1版本中,那么在3.2版本中却不一定有。所以出于bug修复的考虑,为功能库加入特定的第二个版本号还是非常有用的。

    一个系统会有相应的版本和依赖关系,应该由一个管控者来统一管理,以保证系统中每个模块的需求都会被满足。这样一个管控者可以对系统进行检查,保证所有已经安装的模块能够保持一致性。Linux的分发版本中可以分别使用dpkg和rpm两种命令来对Debian和RPM两种安装包进行检查。而且还可以使用这种依赖关系处理程序运行时的一些特殊要求。

    例如,基于NetBeans平台开发的应用程序,都是由多个模块组成,它们在运行时被加载。NetBeans模块系统使用声明的依赖关系来查找需要加载哪些相应的模块,不仅如此,在加载每个模块的时候,还会根据依赖关系为其设定相应的父ClassLoader,这样可以有效地保证各个模块对应的类路径不会产生冲突。这样也可以加强各个模块声明的依赖关系:如果一个模块没有声明自己依赖于某一个指定的外部模块,那么就无法调用该外部模块的代码;而且除非所有的依赖都已经满足,否则当前模块是不会被加载的。

    ① 作者的意思应该是指在执行某组件时,如果不符合前提环境,可能会抛出某些异常通知开发人员进行修改,如找不到某个类或者某个配置文件。——译者注

    ② 这里的代码段,在本书中是组件或者模块的另一个说法。——译者注

    ③ 如果要出现作者说的这种情况,在使用Java 5或以上版本编译代码时,要将兼容性级别调整为以前的版本,否则执行时,就会先抛出版本号不支持的异常,可以通过javac -?了解更多这方面的信息。——译者注
  • 相关阅读:
    57. 插入区间
    1117. H2O 生成
    1114. 按序打印
    185. 部门工资前三高的所有员工
    453. 最小移动次数使数组元素相等
    简单中缀算数表达式求值
    悬停显示title-获取日期时间
    v-pre&v-cloak&v-once添加属性-禁止值改变 v-once
    v-html & v-text普通命令
    过滤器串联执行-加参数
  • 原文地址:https://www.cnblogs.com/shihao/p/2146378.html
Copyright © 2020-2023  润新知