模块化开发
前言
本文我们将讨论一下模块化开发。这里提到了两个关键词:模块化和开发。模块化是本文将要讨论的核心。而对于模块化的对象,在这里我们主要针对的是开发,而不单单只是针对编程、设计或者管理等来阐述的,这样描述的主要目的是希望可以通过本文让处于不同岗位的开发人员在工作中的各个方面都能去进行模块化思考。由于笔者一直从业于游戏软件开发领域,所以本文中对模块化所举的例子、说明等,大多都是针对游戏软件开发的。
何为模块化开发
不知道您之前有没有在网上看过这个视频,视频的内容是一栋15层的宾馆大楼如何在7天的时间里拔地而起的。这段视频引起了国内外网友们的热议。在这段视频中,我们完整的看到这栋大楼如何一层一层的将架构搭起来,又一步一步的把内外填充好,最终完成整栋建筑。我对建筑行业完全外行,但如果跟我以前看到过的盖楼的方式上比较,从表面上我也看到了一点区别:过去我看到的盖楼过程是一点、一点建起来的,比如一块砖、一块砖的砌,一小块楼板、一小块楼板的灌注。在这个视频中我看到这栋大楼则是一块、一块的建起来的,一大块墙、一大块墙的拼,一大块楼板、一大块楼板的拼。也许这个视频就可以作为一个让人们对模块化产生直接认知的很好的说明示例。
(图1. 7天建起了15层的大楼)
总的来说,模块化并不只是软件开发领域独有的概念,这个概念在很多传统行业中有着更深远的历史,比如建筑业、汽车业、计算机硬件业等等。并且,无论我们有没有仔细想过模块化这个概念,人类自古以来做事的方式一直都是按照这种思路来进行的,只是在不同阶段、不同条件下执行的方式、程度等有所不同。
这里是对模块化这个名词的一个基本解释:构建标准化的灵活多用的单元。我想给出这样的解释来说明何为模块化:1. 将大的个体分解成多个小的个体;2. 独立的、闭合的去分析处理这个个体和其与外界的关系;3. 将这些个体组织、集成为一个新的大的个体;4. 持续的、迭代的进行这个过程直至解决问题。
什么可以被视为模块
模块化的对象和处理单元都是模块,那都有什么可以被视为模块呢?也许这是一个既简单又复杂的问题,说它简单是因为它的概念很好理解,但应该去做到什么程度却又是一件有挑战的事情,如果处理的不是恰当好处就会让事情变得十分复杂而低效。
但为了能有个直观的感受,我还是想举几个例子。在软件开发中,一个C++函数、一个C++类或者一个功能库都可以被视作一个模块;一款产品、应用或者工具也可以被视作一个模块;再放大并抽象一点说,一个游戏功能、项目管理中的一个用例也可以被视作一个模块;甚至项目中的一个小组、公司中一个部门也可以被视作一个模块。
总之,理论上来说任何事情、事物都可以模块化的来看待,但我的忠告是不要做的太过、太抽象。
软件开发中的几个示例
下面给出几个更具体的示例来帮助我们更好的来体会模块化。从下面这几个示例中,我们可以感受到一个模块化的系统可能具有的一些特性。
基于组件的游戏对象系统
我们先来看看“基于组建的游戏对象系统” ——一种组织、管理游戏中各种类型对象的技术或者设计。这个设计的大致结构如图2所示。它有什么样的特性呢?
- 每个对象内部都具有比较完整的、通用的功能和属性用于它对自身的表述。
- 对象大部分的行为都是在其内部进行的。
- 对象之间的关系主要是通过消息这个简单的接口建立起来的。
- 即便是直接的相互调用,它所提供的也是标准的、通用的接口,也就是说一个对象可以很容易的跟另一个对象解开关联、再和其它对象关联起来。
(图2. 基于组件的游戏对象)
并行计算
再让我们看看并行计算。为了提高处理能力,目前的处理器(CPU或GPU)都将多核作为其主要的解决方案之一。相应的,在(游戏)软件开发中,程序开发人员也都在努力的应用着这个特性来提高系统的处理速度。但同时多核也意味着要系统尽量去做并行的处理,这将大大提高程序逻辑处理和调试的复杂度。因此,对于并行计算来说,它天然的就对模块化有着很高的需求。例如:
- 将一大批运算拆解为一个个能独立处理的单位,这样它们就可以被交给不同的核心去处理。
- 尽量的减小各个单位之间的依赖或者对资源的共享,使得并行处理的单位之间的执行顺序不影响结果的正确性。
- 尽量的减少或简化各个单位之间通信和交互,来降低逻辑上的复杂度或相互等待。
下面两个图示简要的展示了游戏系统在单核处理和多核处理上的不同。
(图 3. 在单核上,资源是被顺序的使用的,逻辑也是顺序的执行的。)
(图 4. 多核时,同一个资源则有可能被不同核上的处理任务同时请求,不同处理任务之间也可能有逻辑上的依赖。)
Visual Studio工程
最后我们看看Visual Studio工程。绝大多数项目中的程序开发人员都会通过IDE去创建工程来管理项目的代码、属性和构建等,或者至少也会去创建make文件来描述项目的构建方式,比如C++游戏开发人员几乎都会使用Visual Studio C++工程来管理游戏的C++代码和库。对于一个大型项目,我们通常会将不同的系统用不同的工程来管理——当然,你也可以通过用不同的文件目录来标识和组织不同的模块,但这样做的话那些IDE所提供的优秀的辅助管理功能就不能为你服务了。现在我们看看这些由Visual Studio工程构成的一个个模块又有哪些特性:
- 每个模块对自己有清楚地描述与定义。
- 各个模块间的关系被明确的建立起来:谁依赖于谁、相关联的配置、包含路径是什么等等。
- 操作(如编译、发布)一个模块的时候,会根据依赖关系先去处理那些被依赖的模块。
- 可以独立的处理每个模块或者所有模块。
(图 5. Visual Studio Solution中的项目关系:包含和依赖)
模块化的原则
现在我们对模块化应该已经有了更清晰地感受了,后面我们就尝试着把这个思想融入到我们的开发工作当中去。在此之前,我们先归纳一下模块化的几个原则。因为模块化是人类固有的一个非常基本的思维方式,所以它的内容描述起来比较简要。
先从过程上来说——分解与综合。如果一件事情太大、太复杂,那我们就:
- 把它分解成几个小的部分,如果还是太复杂,那就继续分解。
- 一个一个的去解决掉这些相对小的部分。
- 再把这些已经解决的小的部分组织并集成为大的整体。
再从特性上来说——低耦合、高内聚。把问题拆开后,对于每个部分:
- 清楚地给出或者限定模块的功能与职责。
- 跟别的模块之间只去建立那些必需的关联。
- 只把那些必要的接口暴露出来。
(图6. 一个模块化的系统)
模块化开发的初步实践
前面,我们主要是从概念上在讨论模块化开发,下面我们来把这个概念具体化,看看它可以怎样跟我们的实际工作结合起来。在此之前我想再强调一下,模块化绝不仅仅只是针对编程工作的,它也可以在开发流程、项目管理、团队组织等方面进行有效地实践。但因为大多数的读者对程序开发都比较熟悉,所以这里我们就只借用程序开发工作来初步的讨论一下模块化的实践。下面我们分别从内聚与耦合两个方面来说一下软件开发、设计工作中的模块化。
内聚
一方面,每个模块都应该是尽量的内聚的。那么,一个内聚的模块要有哪些特性?我们如何能生产出这样的一个模块?
代码设计
首先从代码本身来说,一个内聚的C++模块的代码设计要尽量考虑到这些方面:接口、内存、性能、线程、网络、IO等,尽可能在模块的内部处理好它们。下面我们概要的来说一下每个方面。
- 接口——在设计和实现过程中,尽量做到只开放或者说只暴露出来那些真正需要开放的接口,如C++头文件、类或函数等等,不要将所有内部实现和接口都暴露出来让用户随便使用。并且,尽量的保证接口的稳定。
- 内存——在设计时,或者至少在完成系统后,清楚这个系统大概需要使用多少基本内存或者说最小内存,并最好能够在系统的介绍说明中提出来;对外提供一个接口,让这个系统的用户可以通过这个接口指定这个系统最多要用多少内存,最好也可以让用户能把一整块分配好的内存空间提供给这个系统来使用;尽量避免动态的分配、释放内存,这里的动态是指在程序(比如游戏程序)的主要状态的循环中,每帧或者高频率的发生的;考虑一下这个系统需要什么样的数据结构来管理其内存使用;弄清楚这个系统的内存分布情况是什么样的。
- 性能——在设计这个模块的时候,尽量给出在性能上的分析与预算。例如,在每一次循环(帧)中 ,这个模块大概会用掉多少CPU或GPU时间(毫秒)?这个模块在不同情况下是如何影响性能的?
- 线程——这个模块本身是否可以或者需要执行多线程的任务?如果需要就要把这个模块的线程使用规划出来,比如是简单的使用几个就创建出来几个,还是用线程池来管理它们。这个模块是否会在多个线程中被使用?如果会,这个模块的内存、文件等资源就要被设计和实现成线程安全的。
- 网络——如果这个模块有网络访问,估计好它自身可能需要使用多少网络负载。如果可能接受多个网络连接,考虑一下是否需要使用连接池来管理连接。
- IO——如果这个模块有对文件的读写,设计时就要考虑一下这些方面:如果有数据读取或者载入,还需要其有较高的读取性能,那么这个模块就应该支持序列化的数据载入。如果这个模块要读取流数据,那它最好能对其自身能同时处理的流数据的最大数量有个限定。
测试
现在我们已经让一个模块在尽量独立的处理自己能处理的事情了,之后我们就需要让它能有效地对内、对外保障其自身的正确性与稳定性。如何保障——测试。对于程序开发人员,其中一个不错的实践方法就是使用单元测试。当各个模块有了单元测试后,任意一个开发人员修改了某一个模块,他可以马上知道这个模块和与其相关模块是不是仍在很好的工作。当然也可以选择人工的进行这个过程,但如果能将单元测试集成到一个模块的内部作为其一部分,将会给这个模块带来更高、更稳定、更有效的保障。
打包
现在假设你已经拥有了一个内聚性很好的模块,它也能很好的进行自我保障,然后呢?然后你的这个系统就应该被模块化的提供出去,我们也可以说将它打个包交给使用者。打成包的产品也可以被看作一个独立模块。提供打包的形式可能有:你的这个包是一个底层库,把源代码,文档和编译出来的静态、动态库文件打成一个压缩包,发布出来,让使用者可以从某个地方下载或者拷贝;这个包也可能是一个C++工程,这样你所提供的就是源代码和工程文件,发布方式就是提交到版本控制系统上,使用你的包的用户会直接把这些代码和工程集成到他的项目中去。
至于这个包具体要提供哪些内容、以什么样的形式发布或交付给其他用户,就取决于你的组织、团队、项目、产品、构建方式等各方面因素了。
耦合
另一方面,各个系统之间的耦合应被尽量的降低。那么,这些耦合是怎么形成的?我们可以怎样降低模块间的耦合呢?
模块间关系
说到耦合首先要说的就是模块间的关系,正是模块间的各种各样的关系造成了模块之间的耦合。那么模块之间都有可能存在哪些关系呢?
- 依赖关系——一个模块需要使用另外的一些模块,没有它们这个模块就没办法工作,这就是依赖。对于依赖,在设计代码时就应该确定好你的系统到底要依赖于哪些库,并搞清楚你的这个系统是不是真的需要或者适合依赖于某一个库,这个分析过程会有助于降低你的模块对其它模块的耦合程度。
- 继承(父子)关系——继承关系主要是面向复用的,子模块具备部分或者全部父模块的特性,在此基础上再进行扩展,这就是继承。例如,一个新的项目开始了,我们从一个老的项目里拿出了整套生产线,在这套老的生产线上进行改进,来生产新产品,这就是一种继承关系。
- 包含关系——包含关系主要是面向组织和行为的,当一个模块做了什么事情,对应的就需要有些子模块的操作被触发或执行,或者要让一些信息从当前模块传递到包含它的模块,这就构成了包含关系。例如,在一个项目中除了主产品外,还要为数据生产线提供五个独立的工具,这些工具都是要交给内容制作人员使用的,这样就需要有一系列的类似的操作来管理这五个工具子项目,如构建、打包、测试、代码静态分析等等,特别是对于持续集成环境,最好能有一个简单的方法来同时针对这五个工具的工程进行操作。可能的一种实践就是把这五个工具子工程添加到同一个Visual Studio解决方案中(即使这些工具之间没有任何关系),当需要编译或者测试所有工具代码的时候,执行这个解决方案的对应的命令就可以了。
模块的相互定位
现在假设我们已经明确了一个大的系统中各个模块之间到底是什么样的关系了,但为了将各个模块之间的关系真正的建立起来,我们还需要考虑一件事情,就是定位。如何才能定位到那些有关联的模块并与其建立起关系呢?
首先,需要各个模块对外提供一些基本信息,这个信息可能包括其位置、版本、平台、配置等等。例如,在你的游戏项目中,武器系统是依赖于Bullet库的0.7.1版本的,你们只需要Win32版,并且没有打算调试Bullet,所以只使用了编译出来的Release版本的静态库,所有这些信息共同构成了你们的游戏项目中的武器系统对Bullet库的依赖关系。
然后,再通过人工或工具将这些关系在开发中体现出来。例如,Visual Studio中的项目和其属性、C++代码中的include、甚至口头或者邮件中的一个版本号的通知,都是在建立各个模块之间的关系。
模块间的相互影响
在各个模块之间建立起了某种关系之后,它们还要依靠某种方式来实现相互沟通与影响,才能真正构成一个协同工作的更大的系统。模块间通信的形式是多种多样的,但不同的通信方式也会使模块间的耦合程度不同,思考一下,哪一种更适合你的系统。例如,在一个软件系统的设计、开发中可能会有这些模块间的协作方式:
- 在同一个进程中的各个模块要如何相互影响?靠消息、回调、还是直接调用?直接调用实现起来快捷,但它也让两个系统建立起了强耦合关系;靠消息、回调要多做点事情,但却降低了系统间的耦合。
- 一个应用程序也可以理解为一个模块,那么在多个应用程序之间如何相互影响?靠文件、消息管道、还是靠数据库?这里使用不同的方式会分别让这个应用程序依赖于其它不同的系统。
- 一个系统还有可能是跨网络、跨操作系统的,它的各个子系统也分别可以看作一个个模块,它们之间又是如何相互影响的?靠web服务、网络消息、甚至一些人工操作?它们也同样决定了系统间的耦合程度。
模块化开发的好处
前面我们讨论了这么多模块化的问题,那么它倒地是不是值得被如此费心思的考虑?现在我们就来说说:恰当地进行模块化的开发都有可能会带来哪些收益?
更容易把问题定位或限定到一个较小的范围。当系统出现什么问题时,我们会去调试系统,那调试的过程可能会是什么样的?如果系统内部的逻辑、结构错综复杂,我们的头脑中就要始终存储着好几方面的、相关系统的信息和关系,还会让调试的步骤从各个系统之间跳过来、转过去,很是艰苦。但如果一个系统的各个模块的设计比较内聚,在发生问题的时候,因为各模块之间主要是靠接口进行互相影响的,所以解决问题时我们只需关注接口,看看接口的输入输出是否正确,如果哪个接口的输入输出出现了问题,我们就可以快速的定位到问题是发生在哪个模块中,剩下就可以集中精力去分析、解决这个相对较小的系统内部的问题了。
系统功能、各个系统之间的关系和开发人员的责任更清晰。在一个庞大而复杂的系统开发中,估计很多开发或者管理人员都遇到过这样的问题:发现了一个Bug或者技术问题,却不确定找谁来解决这个Bug最合适,或者谁可能最清楚这个问题。一个模块化良好的系统会清晰地定义自身的功能、特性、接口还有它与其它系统之间的关系,这就能让你更容易的说出一个问题发生在了哪个模块,并且模块本身又是内聚的,这就很容易知道应该去寻求谁的帮助来解决这个模块的相关问题。
功能更容易被复用或替换。在开发中也许你还遇到过这样的问题:现在要开发的某个功能在之前的某款产品中已经有过类似的实现,于是你们打算把那个功能移植过来,但移植的时候发现那个功能有着非常多而复杂的依赖关系,这使得你们不得不花费大量的时间去提取和集成,让这个老功能跟你们的新系统结合起来,然后又要花大把的时间去解决集成后所产生的新的问题,而这个过程有可能已经在你们团队的发展中被重复了多次。但当你的系统的模块化做的更好之后,因为一个模块与其它模块的关系清晰,耦合更小,解开这个模块与其它模块间的关联也会相对容易并省时得多。就像当今的计算机硬件,哪里坏掉了或者需要升级了你会怎么做?拔掉它,换个新的就好了,也许软件开发的复杂性让它还没法做到这个程度,但更好的模块化一定会让工作比之前更容易、更灵活、更可靠、更稳定。
并行的进行项目开发更容易、更高效。当你的组织打算并行的开发两款以上的不同产品,而它们之间有大概50%的相同性,你们可能会怎么做?当有了一个优良的模块化的结构后,因为复杂的系统已经被分解成了一个个功能独立的单元。这样的单元更容易同时被多个项目共同使用,也更容易被多个项目共同维护与改进,这就尽量避免了一部分人力在不同项目中分别做着重复的劳动。
更容易通过增加人力来提高生产速度。如果一个大的系统有太多的耦合,在项目进行的某个时候我们要增加人力资源来促进项目的前进,这将会是一个很有风险的做法,因为这个新人(即使是有经验的)要花不少的时间先去研究并且理清一个系统以及相关联的系统,而这些关联关系都只能靠在成千上万行代码中跳转来跳转去的去阅读、试验来理解。即使他觉得能在这个代码上做事情了,真的开发出来的功能也有可能由于某些模块间复杂的关系和依赖而埋下了Bug的种子,将来再回头解决这些Bug,将会花费整个开发团队更多的时间。 如果各个系统的模块化做的很好,增援的人就可以集中注意力关注于某个模块自身和与其相关模块的接口,并只需保证这个模块自身的接口输入输出不出错。这样就大大降低了增加人力所带来的成本和风险。
可持续的、迭代的去解决问题。对于一个大的系统,把它拆分开后我们就可以集中精力去解决每个相对小些的问题,如果觉得问题还是大,那就继续分解。而对于这些小的系统,由于它们都比较内聚,把它们拼装成大的系统也会相对容易些。这样开发人员就可以很容易的将工作分成一个一个的周期去处理,并能够按阶段的去完成一个个看得到结果、评估得了的目标。
总结
本文的主旨并不是要介绍什么问题的解决方法或方案,而只是在与您讨论一个概念,所以在文中没有什么固定的、明确的规则和标准是一定要被遵守的,但我希望能够通过本文来激发读者在软件开发时去对各方面工作都能够进行模块化的思考。至于最佳的方案与实践,还是需要根据实际项目环境和条件来把握。最后,提出一个狡猾的忠告与建议:模块化,不要做得太过,但也不要什么都不做。