真刀真枪模块化(2)——图解Service模型

2020-11-09 11:41:14 浏览数 (1)

在前面一篇文章《真刀真枪模块化(1)——一本糊涂账》中,我们讨论了:

  • 在工程开发中进行模块化的本来目的——为了复用已有的代码,节省当前项目的开发时间
  • 实际操作过程中遇到的尴尬问题——模块的具体实现原本应该被视作黑盒子,程序员因为各种心理上的原因要阅读代码

以及

  • “原则上”的解决方案——严禁程序员在项目开发过程中阅读模块的具体实现代码

道理说起来简单,真要实际操作起来,一线开发人员往往会直摇头:手中已有的所谓“模块”质量参差不齐、模块的开发者鱼龙混杂、很多模块别说出了问题要找开发方负责维护了,就是原作者是谁恐怕都找不到了——在这种情况下,大谈“禁止开发人员阅读模块的实现代码”,简直就是天方夜谭,颇有几分“何不食肉糜”的傲慢

——难道模块化本身错了么?实际情况并非如此,这里傻孩子忍不住想“感慨”两句:在追求和实践新的方法(论)的时候,总难免会遇到这样那样的困难,有的困难甚至让整个方案看起来“完全行不通”——在这种时候,如果立即退出来将整个方法全盘否定,就会失去宝贵的前进机会。

在模块化的过程中,要想发挥模块化“复用已有代码”、“降低开发时间”的作用,就必须将模块视作黑盒子;一旦模块被视作黑盒子,实现的质量和后续的可靠维护就成为当前模块是否可用的基石——进一步来说,不靠谱的代码实现和差强人意的接口设计与封装是导致模块化失败的根本原因。

本文将为您介绍一种模块化封装的简单操作方式——由傻孩子根据十多年工程实践经验总结、历经无数商业项目的千锤百炼。通过这一方式构建的模块,我称之为服务(Service),因此,这里所要介绍的模型又被称之为“Service模型”。

【正文】

从具体操作层面来说,所谓Service模型并不复杂。

首先,每一个模块都有一个属于自己的专门的文件夹,文件夹的名称与模块名相同:

其次,每一个模块中都有一个专门的头文件,用于提供给模块的使用者来包含(#include);该头文件的名称必须与模块的名称相同。

需要特别强调和说明的是:

  • 该头文件用于“从模块内部向模块外部”提供使用模块所必须的“最小信息”;
  • 任何人要使用模块,必须且只能包含该头文件;
  • 我们把这类向模块的使用者提供必要信息的头文件称之为接口头文件
  • 接口头文件遵循“最小信息公开原则”,即,该头文件中只存放用户使用模块最少最少所必须知道的信息。实际操作中,类型定义、宏定义、函数和全局变量声明都应该首先放置在对应的源代码中(或是后面会提到的模块内私有的接口头文件中);当且仅当我们发现用户要使用模块的某一功能必须要用到某一信息时,才“极不情愿”地、“抠门”的、且尽可能将其它能剥离和隐藏的信息剥离开后,放置到接口头文件中。

与接口头文件相对,每一个模块内部都会有一个专门的头文件用于实现对模块的配置:

  • 该头文件用于“从模块外部向模块内部”输入配置信息;
  • 如无特殊说明或安排,该头文件应该固定命名为 app_cfg.h (没有额外的前缀和后缀);
  • 如无特殊说明或安排,该头文件应该仅包含配置信息,例如:宏定义、类型定义(在极其特殊的情况下,偶尔出现的全局变量或者函数声明);
  • 我们把这类头文件称之为“配置头文件”;

在构建和使用模块的时候,无论是模块的设计者还是模块的使用者,都应该遵循黑盒子原则,在操作上表现为——模块的使用者不应该修改任何位于模块文件夹内部的内容——模块文件夹既是黑盒子的容器,也是黑盒子的边界。

为了遵守这一原则,模块内部的配置头文件实际上是不允许用户去修改的——那么这又如何让用户更改对模块的各个配置选项呢?答案很简单,如下图所示:模块内部的app_cfg.h 在文件的一开始会首先包含上一级目录的app_cfg.h

为了实现这一点,一个模块内部 app_cfg.h 的固定内容格式为:

代码语言:javascript复制
//! 作为模块的用户,不要修改这里的任何内容
#include "../app_cfg.h"

/* app_cfg.h 的防重复包含的保护宏 */
/* 请将 XXXXXX 替换为模块的名称,并删除本注释 */
#ifndef __XXXXXX_APP_CFG_H__
#define __XXXXXX_APP_CFG_H__
...
#endif   /* app_cfg.h 文件的结尾 */

一个模块的接口头文件,其内部格式可能为:

代码语言:javascript复制
//! 作为模块的用户,不要修改这里的任何内容

/* 模块接口头文件防重复包含的保护宏 */
/* 请将 XXXXXX 替换为模块的名称,并删除本注释 */
#ifndef __XXXXXX_H__
#define __XXXXXX_H__

/* 模块的接口头文件在一开始要包含当前模块的 app_cfg.h,
 * 这里的 "./" 不可以省略 
 */
#include "./app_cfg.h" 

/* 其它include */

...

#endif   /* 接口头文件的结尾 */

可以很容易注意到,当使用某一模块时,用户可以很方便的在模块外部定义一个属于自己的 app_cfg.h 来向模块提供配置信息——而无论如何修改这一文件,都不会破坏黑盒子本身的内容

再次,一个模块往往拥有一个或多个C源文件,它只需要包含模块的接口头文件,就可以共享一些“对外公开的信息”。

这里有个朋友会问了:根据最小信息公开原则,接口头文件中只包含了一些最小信息,如果模块内的多个C源文件之间需要共享一些非公开的私有信息,该怎么处理呢?

为了解决这一问题,我们一般会引入一个以双下划线为前缀的接口头文件(比如,叫做__common.h),并视其为模块的私有财产。如下图所示,这一头文件是仅供模块内的源代码包含的——无论是模块的接口头文件还是模块的配置头文件都不应该对其进行包含——以防信息泄露:

一个典型的 __common.h 内容如下:

代码语言:javascript复制
/*! 作为模块的用户,不要修改这里的任何内容,理论上也不应该关心这
 * 里出现的任何内容。
 * 对模块的作者来说,如果模块以 lib 的形式提供,请务必将本文件删除
 */
#ifndef __XXXXXX_COMMON_H__
#define __XXXXXX_COMMON_H__

...

#endif /* 私有接口头文件的结尾 */

基于这一规则,模块内一个可能的C源文件内容如下:

代码语言:javascript复制
//! 作为模块的用户,不要修改这里的任何内容

/* 首先包含模块的接口头文件,模块的配置头文件也会间接的被引入进来 */
#include "./xxxxx.h"           
#include "./__common.h"

/* 当前C源文件私有且不想跟模块内其它C文件共享的内容: 宏、类型定义等等 */
...

/* 函数实现等等 */
...

最后,一个模块内是允许包含其它子模块的,对于这种嵌套情况,仅需要两步骤就可以完成部署:

  • 将子模块拷贝到父模块中,或者按照前述的模块构建规则,在父模块中建立一个子模块;
  • 父模块的接口头文件包含子模块的接口头文件
  • 少数情况下,如果子模块与父模块高度耦合(一般来说就是在父模块中从头开始建立一个新的子模块时会发生这种情况)——比如子模块依赖父模块的 __common.h 中提供的信息,则应该在子模块中也建立一个 __common.h,并仿照 app_cfg.h 的做法,在头文件的一开始首先向上包含父模块的 __common.h;
  • 如果父模块包含__common.h,而子模块并不需要这一信息,则子模块无需在做任何特殊修改
  • 对app_cfg.h来说,由于子模块原本就会自动包含上一级的app_cfg.h,因此,我们无须做任何特殊操作,子模块就可以透过父模块的app_cfg.h自动从外界获取配置信息——这就像是一种标准化的水管安装。

以上就是使用Service模型进行模块化的基本规则。是不是很简单?

【后记】

Service模型本身是完全本着简化用户操作的宗旨,以实用性为重中之重,同时也避免一切“反直觉”的设定。

对用户来说,这一模型是非常友好的:

  • 只需要拷贝模块目录就可以完成部署
  • 只需要在模块的外部额外添加一个app_cfg.h就可以实现对模块的配置
  • 所有关于模块的使用信息(使用说明书)都放置在一个唯一的、与模块同名的接口头文件中;且这里包含的信息对用户来说都是可用的(没有无用信息,也没有多余信息);

对模块的开发者来说:

  • 这一模型是高度遵守黑盒子原则的;
  • 用户使用模块,是不需要“用脏手染指”自己宝贵的代码的(无需修改)
  • 对制作 Library 非常友好,只需要保留接口头文件,而将其它所有文件(包括源代码和私有接口头文件)删除并保留一个固化好的app_cfg.h即可。
  • 模块是非常容易迁移和嵌套的

当然,这一Service模型也有一个小缺点(可能有些人也对此无法容忍),即,用某些工程管理工具将头文件的包含关系展开时,通常会看到海量的app_cfg.h(尽管他们内部都使用了模块特有的保护宏进行区别)——对于这一问题,在真刀真枪模块化的后续内容中,将提供一个较为完美的解决方案,这里就先卖个关子——对普通用户来说,现有的Service模型足够了。

0 人点赞