接口设计的总结


模块化与接口设计

在软件变得越来越庞大的时候,人们开始探索模块化编程,允许软件的某一部分功能封装成一个模块,在软件运行时由操作系统在磁盘上找寻对应的模块并加载使用,从此软件可以划分为相互独立的模块,允许不修改软件整体结构,仅单独更新某个模块而增强某个功能。

动态链接库 (DLL) 是一种很好的模块化技术,在软件运行时,动态链接库会被操作系统装载,并与主体程序链接,主体程序通过调用动态链接库中的函数实现某一个功能。在升级模块时主体程序不更新,因此动态链接库中的函数在更新时必须保持与主体程序的接口不变,否则主体程序调用动态链接库时将因为接口不匹配而出错。

教科书和开发经验都告诉我们,接口的设计要保持一定的稳定性,尽量减少接口变化带来的维护工作量。

 

C语言的做法

Windows 系统 API 显然有很强烈的接口不变性需求,因为 MS 也希望在他的操作系统升级后,旧版系统下的软件无须太大的改动就可以应用在新操作系统中。

WINDOWS API 是 C 语言接口, C 语言的一种常见做法是对参数结构体定义一个 length 字段,程序中通过判断 length 字段来区分不同版本的数据结构,这样就可以做到高版本的接口兼容低版本的接口

以 WINDOWS API 中启动进程的函数为例

BOOL WINAPI CreateProcess(
__in_opt LPCTSTR lpApplicationName,
__inout_opt LPTSTR lpCommandLine,
__in_opt LPSECURITY_ATTRIBUTES lpProcessAttributes,
__in_opt LPSECURITY_ATTRIBUTES lpThreadAttributes,
__in BOOL bInheritHandles,
__in DWORD dwCreationFlags,
__in_opt LPVOID lpEnvironment,
__in_opt LPCTSTR lpCurrentDirectory,
__in LPSTARTUPINFO lpStartupInfo,
__out LPPROCESS_INFORMATION lpProcessInformation
);

其中的参数 lpProcessAttributes 是 SECURITY_ATTRIBUTES 接口,定义如下

typedef struct _SECURITY_ATTRIBUTES {
DWORD nLength;
LPVOID lpSecurityDescriptor;
BOOL bInheritHandle;
} SECURITY_ATTRIBUTES, *PSECURITY_ATTRIBUTES, *LPSECURITY_ATTRIBUTES;

可以看到 SECURITY_ATTRIBUTES 中的成员 nLength ,他描述的是 SECURITY_ATTRIBUTES 结构的大小,文档要求调用者指定传入的 SECURITY_ATTRIBUTES 参数的大小,一般的做法是取 sizeof(SECURITY_ATTRIBUTES) ,可以推断 API 的实现中将这样区分 lpSecurityDescriptor 参数的版本

if(sizeof(*lpSecurityDescriptor) == sizeof(SECURITY_ATTRIBUTES_NEWVERSION)){

//new implementation of this API...

}else if(sizeof(*lpSecurityDescriptor) == sizeof(SECURITY_ATTRIBUTES_OLDVERSION)){

//support old version of this API...

}

这样升级 API 时,接口定义不需要改动,当旧软件放到新版本的操作系统中运行时,新版本的 API 可以继续支持旧版本的调用程序,而在新操作系统下开发的新软件又可以使用新版本API 的特性,实现了接口不变的功能增强。

只是有一个小负担,就是多出了一个无业务意义,仅仅用来保持接口不变而设置的 nLength 成员,而且要求调用者编程时必须传入 sizeof(SECURITY_ATTRIBUTES) 的值,但它确实达到了目的。

这种接口在 WINDOWS API 中不为少数,在 UNIX 的库中似乎相对少一点,但也存在,比如 socket API


COM的做法

在 C++ 世界, COM 选择了一种更为花哨的做法,通过接口包容来实现

定义一个接口

virtual class IMyInterface1{

virtual void doSomething() = 0;

};

编写对象实现这个接口

class MyObject1 : public IMyInterface1{

virtual void doSomething();

};

通过 createInstance 创建 MyObject1 的对象并返回 IMyInterface1 ,调用端通过 IMyInterface1 来使用功能。当接口升级时,定义一个扩展接口

virtual class IMyInterface2 : public IMyInterface1{

virtual void doOtherthing() = 0;

};

新接口支持 2 个功能 doSomething 和 doOtherthing ,所以必须编写一个新类去实现新功能 doOtherthing ,并且希望旧功能 doSomething 的代码可以继续重用,新对象可以这么写

class MyObject2 : public ImyInterface2{

virtual void doSomething() { obj1.doSomething();}

virtual void doOtherthing();

 

MyObject1 m_obj1;

};

当请求新对象的旧接口时转到旧对象中去实现,当请求新对象的新接口时使用新实现,新版本的 createInstance 根据新旧对象的 GUID 创建新对象或者旧对象,返回新接口指针或者旧接口指针,旧的调用者同样不需要作任何改变即可使用新的组件,而在新组件环境中编写的新调用端又可以使用新接口,它用样达到了接口不变的目的。

相比 C 语言的做法,少了用于支持接口设计的冗余字段 nLength ,但程序接口比 C 语言复杂了不少。

实际上 COM 的包容方案比上述稍微复杂一点点。另外 COM 还有另一个更复杂的方案-聚合,但聚合要求被聚合对象有附加的特殊设计,基本上被限定在 COM 的框架内,无法在一般 C++ 程序设计中通用,这里不讨论。

 

SOA/SCA的做法

SOA 是近年提出的概念,其中 SCA 是 SOA 概念中的面向组件编程,既然是组件,接口不变性同样很重要, SCA 选择了更强硬的做法- SDO

SDO 是 Service Data Object 的缩写,说到底就是一个 XML ,利用了 XML 的高扩展性, SDO 只是一个对 XML 解释的封装类而已,所有接口通过形如

class MyObject1{

void MyService(SDO myParam);

};

的导出类来实现(导出类还是 createInstance 不是关键, SDO 类型的参数才是关键), SDO 说到底是一个 XML 操作类,他与一个 XML 字符串对应,与形如

class MyObject1{

void MyService(char *myParamXml);

};

是一个道理,说的更彻底一点这个接口就是一个哑接口,接口的意义完全由模块内部运行时解释,业务强相关,与形如

class MyObject1{

virtual void MyService(void *myParam);

};

的接口的道理是一样的,这种接口一劳永逸,无论模块怎么升级、内部实现怎么改,只要 myParam 的设计适当, MyService 接口的定义是永远不需要变动的,他永远是

virtual void MyService(void *myParam);

但无论是调用者还是被调用者,实现起来都比前面讨论的方案更复杂、更多约定,也更容易出错,并且所有错误都需要在运行时才能发现,没法做编译时检查。我的同时很喜欢 SDO 这种做法。

顺便说一下 Tuscany 项目是 apache 组织的一个 SCA 参考实现,实现了 SDO , C++ 版本目前还在 Incubator 状态,一年多最近没更新了,貌似此路不通。。。

 

一种字典的方案

工作过程中发现有另一种民间方案,有点土,但也有点实用,就是使用类似字典的表格来传递参数,只要字典类结构不变,接口也不需要改变,他形如

void MyFunc1(Map param);

参数 Map 类是一个稳定的结构,所有参数放到 Map 里面去传递,使用时

Map param;

param[“name”]=”lingch”

param[“gender”]=”male”

MyFunc1(param);

当模块升级时,可以通过支持更多的 KEY 来增强功能,如

param[“name”]=”lingch”

param[“gender”]=”male”

param[“extended1”]=”handsome”;

MyFunc1(param);

新模块可以支持 extended 参数提供更丰富的功能,旧模块无法之此后 extended 参数可以直接忽略。他也得到了接口稳定的目的。

这种做法可以在 C++ 中使用, Map 类型要足够稳定,可以使用 STL 中的 std::map ,还好 std::map 还是足够稳定的没怎么变过,如果考虑 STL 有多个供应商则你可能要自己写一个类

我发现 JAVA 一类的上层程序员更喜欢这种做法,他们用 Properties 来传递字典

 

另一种假想的方案

在思考接口设计的问题时,我曾经考虑过这么一种方案,通过 C++ 的 RTTI 机制来实现 ( 这种方案没有验证过 ) ,接口 API 设计成

void MyAPI(MyClass *param);

在实现 MyAPI 时判断参数类型

if(typeid(b_object).name() == “xxx”){

//new implementation of this API...

}else if(typeid(b_object).name() == “yyy”){

//support old version of this API...

}

这种方式与 C 语言的做法一样简单,并且参数结构里少了 nLength 的冗余字段,但需要 C++ RTTI 特性的支持,而 RTTI 似乎不是所有 C++ 编译器都支持的,即使在 VC 里面 RTTI 默认也是关闭的。

 

总结

以上讨论了各种接口设计,我不打算评价各种接口的优劣,他们各有各的适用场景。接口设计是一个矛盾的问题,一方面我们希望接口稳定不变,以降低接口变动带来的维护成本,另一方面,设计灵活的不易变的接口本身就给接口的实现和使用都带来成本。对于这种矛盾的问题常常又变成一个‘度’的问题,要权衡。

可以从以下几点来考虑模块之间使用哪种接口

  • 模块的使用范围:小范围使用的模块变动成本低,无须灵活设计

  • 模块的生命周期:没有长远发展计划的模块无须投入精力设计灵活接口

  • 程序员的技能水平:考虑程序员的水平是否足够维护或使用该接口

  • 学习成本:这套接口是否会引入大量语法知识使得学习成本增加

 

技术的选择还是要以实际收益为依据

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值