本文是由 SAO Utils 的插件系统设计所引起的思考,涉及 C/C++ API 设计,二进制兼容性的考虑以及 COM 框架的设计。

设计一个优良的 API 从来不是一件容易的事,但却是我们的确需要努力去做到的事。

在 SAO Utils 的 Alpha3 版本中,提及想要使用 COM 框架设计一个插件平台,在这我觉得 COM 应该属于旧时代的遗留物了,用来学习经验可以,但是现在还使用 COM 框架就不太合适了。

COM 框架诞生于 90 年代,解决了不同 C++ 编译器之间ABI不统一的问题,使大规模的插件系统的架构成为了可能,不过对于 API 设计来说,其实是不太友好的。

COM 框架整体被微软设计的复杂无比,而且由于纯虚接口的调用时编译时确定的(指的是虚表的偏移),所以导致了设计出来的纯虚接口是无法修改的,但是软件这行业从来不是一锤子买卖,接口的维护期要比想象中的长的多,由于 bug 修复以及需求变化产生的接口的变化是必须的,纯虚接口对于这方面的变更是非常的不友好的。

这里先说一下库的版本管理,一般在 API 的设计中,版本的管理使用的是 major.minor.patch 这个约定,伴随着 major 版本号的增加是允许有少许不兼容的,而 minor 则只允许增加功能,不允许出现不兼容,至于 patch,那么主要就是 bug fix 了。

在 COM 框架中,如果你需要增加功能,那么你唯一能做的只有用一个新接口继承旧接口,而且如果需要更新大版本,接口中的函数签名发生变更甚至是废弃,那么你只能完全废弃原来的接口,新写一个接口,时间一长,接口版本的管理简直会成为恶梦,不管是对于库的使用者来说还是库的提供者来说都一样。

而且我自认为,在接口中硬编码版本号是一件很 2 的事情,(比如 IXMLDOMDocument、IXMLDOMDocument2, IXMLDOMDocument3 等等),设想一下,Linux kernel 从 0.10 的 67 个系统调用发展到 3.1.5 的 389 个,接口一直在扩充,而且保持着良好的二进制兼容性,如果当时 Linus 选择 COM 接口的链式继承风格来描述,那将会是怎样壮观?简单估计一下,也知道至少会有近百层的继承。

以上是我对 COM 的看法,事实上,C++业界对于COM框架(注意,不是COM思想)也是贬大于褒的,虽然 COM 以一种最丑陋的方式做到了二进制兼容,但以 C++ 虚函数为接口的必然会导致其脆落与僵硬,如果跳出这个局限去考虑,其实 C++ 库的接口很容易设计的更好,下面我会谈谈如何设计一个好的接口,易于维护,对API的演进友好,同时保持二进制兼容。

先从插件系统说起吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
namespace plugin
{
class Extension;
class Plugin
{
public:
virtual void retain() =0;
virtual void release() =0;
virtual int getRetainCount() =0;
// plugin name, not interface name
virtual const char* getName() =0;
virtual void getVersion(int& major, int& minor, int& patch) =0;
virtual bool isSupported(int major, int minor) =0;
virtual void* getFunction(int tag) =0;
virtual bool getFunctions(int count, const int* tags, void** functions) =0;
virtual Extension* getExtension(const char* name) =0;
protected:
virtual ~Plugin() {}
};
// register plugin that implement interface
void registerPlugin(const char* interface, Plugin* plugin);
// unregister plugin that implement interface
void unregisterPlugin(const char* interface, Plugin* plugin);
// get first plugin implement interface
Plugin* getPlugin(const char* interface);
// get first plugin implement interface that support major.minor version
Plugin* getPlugin(const char* interface, int major, int minor);
// get all plugins implement interface
Plugin** getPlugins(const char* interface, int& count);
}

因为 Plugin 的抽象比较稳定,选择抽象接口是比较合适的,而且使用了 Extension 模式来提供有限的扩展性,如果真的需要扩展,那么可以通过 Extension 来扩展而不需要修改接口本身。

插件的 API 本身是通过函数指针来提供的,通过固定的 tag 来获取,只要保持 tag 的稳定即可,而且使用函数指针也更容易提供脚本支持。

当然,使用函数指针会带来一定的不便,但是这个可以通过自己写一个 wrapper 来解决,或者可以直接由接口的定义者来提供这个 wrapper。

对于 Plugin 的管理,我是用的 free function 来进行管理的,通过 non-member non-friend 的 free function 来提供接口是很好的做法,对脚本语言也很友好,只需要导出成 C 函数即可。

在当前的设计中通过 register/unregister 函数配对来进行注册与反注册,通过 get* 系列函数通过接口名来获取插件,插件本身的生命期使用引用计数来管理。

相对于 COM 使用 GUID 作为标识的做法,我认为使用字符串作为标识更好,只要精心选好名称,也不容易发生冲突,而且在可读性和扩展性上都非常不错,例如 “sao.interface.graphics” 作为接口名, “sao.plugin.graphics” 作为插件名等等。

下面我以一个 Graphics 插件为样例,讲解一下我的推荐做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// Graphics 插件 v 1.0.0
// Graphics.h
struct Point
{
int x;
int y;
};
class Graphics
{
public:
static Graphics* getInstance();
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(const Point& p0, const Point& p1);
void drawRect(int x0, int y0, int x1, int y1);
void drawRect(const Point& p0, const Point& p1);
void drawArc(int x0, int y0, int r);
void drawArc(const Point& p, int r);
private:
Graphics();
~Graphics();
};
// Graphics_plugin.cpp
#include "Graphics.h"
#include "Plugin.h"
#include <cstddef>
namespace details
{
const int MAJOR = 1;
const int MINOR = 0;
struct Impl
{
void (*drawLine)(int x0, int y0, int x1, int y1);
void (*drawLine2)(const Point& p0, const Point& p1);
void (*drawRect)(int x0, int y0, int x1, int y1);
void (*drawRect2)(const Point& p0, const Point& p1);
void (*drawArc)(int x0, int y0, int r);
void (*drawArc2)(const Point& p, int r);
};
}
const int tags[] = {
1, // drawLine
2, // drawLine2
3, // drawRect
4, // drawRect2
5, // drawArc
6 // drawArc2
};
using namespace plugin;
using namespace details;
static Graphics* instance = nullptr;
static Impl impl;
Graphics* Graphics::getInstance()
{
if (instance == nullptr)
{
instance = new Graphics();
Plugin* p = getPlugin("sao.interface.graphics", MAJOR, MINOR);
if (p == nullptr) return nullptr;
p->retain();
p->getFunctions(sizeof(tags)/sizeof(tags[0]), tags, reinterpret_cast<void**>(&impl));
}
return instance;
}
Graphics::Graphics() {}
Graphics::~Graphics() {}
void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl.drawLine(x0, y0, x1, y1);
}
void Graphics::drawLine(const Point& p0, const Point& p1)
{
impl.drawLine2(p0, p1);
}
void Graphics::drawRect(int x0, int y0, int x1, int y1)
{
impl.drawRect(x0, y0, x1, y1);
}
void Graphics::drawRect(const Point& p0, const Point& p1)
{
impl.drawRect2(p0, p1);
}
void Graphics::drawArc(int x0, int y0, int r)
{
impl.drawArc(x0, y0, r);
}
void Graphics::drawArc(const Point& p, int r)
{
impl.drawArc2(p, r);
}

这里我是通过一个 cpp 文件来实现插件的接口的,这个实现文件是需要随着头文件一起发布的,不过里面只是 wrapper 而已,没有用到任何平台或编译器相关的内容。

如果认为多分发一个文件比较碍眼的话,那么通过一些技巧,把这个实现文件里的内容放到头文件里也是可行的。

下面假设 Graphics 因为需求的增加,演进到 1.1.0 版本,下面我只列举diff显示变更的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// Graphics v 1.1.0
// Graphics.h
@@ -20,12 +20,15 @@ public:
static Graphics* getInstance();
void drawLine(int x0, int y0, int x1, int y1);
+ void drawLine(float x0, float y0, float x1, float y1);
void drawLine(const Point& p0, const Point& p1);
void drawRect(int x0, int y0, int x1, int y1);
+ void drawRect(float x0, float y0, float x1, float y1);
void drawRect(const Point& p0, const Point& p1);
void drawArc(int x0, int y0, int r);
+ void drawArc(float x0, float y0, float r);
void drawArc(const Point& p, int r);
private:
Modified Graphics_plugin.cpp
// Graphics_plugin.cpp
@@ -18,22 +18,28 @@ const int MINOR = 0;
struct Impl
{
void (*drawLine)(int x0, int y0, int x1, int y1);
+ void (*drawLine3)(float x0, float y0, float x1, float y1);
void (*drawLine2)(const Point& p0, const Point& p1);
void (*drawRect)(int x0, int y0, int x1, int y1);
+ void (*drawRect3)(float x0, float y0, float x1, float y1);
void (*drawRect2)(const Point& p0, const Point& p1);
void (*drawArc)(int x0, int y0, int r);
+ void (*drawArc3)(float x0, float y0, float r);
void (*drawArc2)(const Point& p, int r);
};
}
const int tags[] = {
1, // drawLine
+ 7, // drawLine3
2, // drawLine2
3, // drawRect
+ 8, // drawRect3
4, // drawRect2
5, // drawArc
+ 9, // drawArc3
6 // drawArc2
};
@@ -65,6 +71,11 @@ void Graphics::drawLine(int x0, int y0, int x1, int y1)
impl.drawLine(x0, y0, x1, y1);
}
+void Graphics::drawLine(float x0, float y0, float x1, float y1)
+{
+ impl.drawLine3(x0, y0, x1, y1);
+}
+
void Graphics::drawLine(const Point& p0, const Point& p1)
{
impl.drawLine2(p0, p1);
@@ -75,6 +86,11 @@ void Graphics::drawRect(int x0, int y0, int x1, int y1)
impl.drawRect(x0, y0, x1, y1);
}
+void Graphics::drawRect(float x0, float y0, float x1, float y1)
+{
+ impl.drawRect3(x0, y0, x1, y1);
+}
+
void Graphics::drawRect(const Point& p0, const Point& p1)
{
impl.drawRect2(p0, p1);
@@ -85,6 +101,11 @@ void Graphics::drawArc(int x0, int y0, int r)
impl.drawArc(x0, y0, r);
}
+void Graphics::drawArc(float x0, float y0, float r)
+{
+ impl.drawArc3(x0, y0, r);
+}
+
void Graphics::drawArc(const Point& p, int r)
{
impl.drawArc2(p, r);

可以看到,接口以良好的方式进行着扩充,同时保持着二进制兼容性,而且也易于维护。

下面说说如何做到动态加载插件。

插件一般都是通过动态链接库的方式来提供,那么,只需要在 dll 中定义好 init_xxxx 函数用于载入时调用即可,其中 xxxx 代表 dll 的名称,以避免冲突。

init_xxxx 函数中通过 registerPlugin 注册好接口,在 Plugin 的 release 函数中通过 unregisterPlugin 函数来反注册掉自身,每个插件都可以注册多个接口,甚至提供多个实现,每个实现提供不同的接口,这些都没有限制。

另外,在 SAO Utils 中,针对插件级别的启用和关闭、动态装载和卸载的需求,可以通过扩展 Extension 来提供,这里就不细说了。

update: 2014-12-15 11:58

Joshua GPBeta 的回复:

首先十分庆幸有机会可以讨论插件平台, 我也没想到真的会有人对这个也感兴趣.

我个人觉得,COM的诟病并不在于版本控制或者二进制兼容是否实现丑陋, 而是无法”实现承继”, 这甚至有人怀疑COM根本不是面向对象的…

我产生”函数指针暴露API”的想法其实比COM架构更早, 只不过后来发现COM比这做得更好而且更规范, 事实上C版本的COM实现正是使用结构体+函数指针实现的, 详细可以参阅windows sdk各种接口的头文件. 而且这两种(C/C++)的COM实现也是二进制兼容的.

关于你的插件系统中”使用函数Tag作为函数签名”的设计, 其实COM很早也就发现这个问题也提出了自己规范的解决方案: IDispatch 接口, 但是无论是IDispatch接口还是你的函数tag都会面临同一问题: 性能, 而且把”COM接口隐含的虚函数表显式成函数表索引”我觉得这里很容易会产生调用混乱的bug–因为编译器没有办法检查是否一一对应的. 基于同样理由, 这也是我选择UUID而不是字符串作为各种ID的原因, 既然一个128位的空间就足够表示为何要用数十字节的字符串表达呢. 而可读性问题, 我倒觉得把ID直接分发给用户而不是由用户手工输入的话, 失误的可能性会更低一些, 而且, 要使用某个第三方的插件/组件, 你必须要清楚对方的插件特性, “通过输入固定唯一的可读字符串而获得某种预期功能的组件”(通过a.b.c字符串企图获得组件x)这个设计想法也是不太现实的, 因为这里没有任何协定保证(可能输入了a.b.d, 又正好存在这么一种命名的组件y, 而且编译器无法检查这个错误, 用户却以为返回的是x组件). 相反, 如果是”组件 提供 组件头文件, 头文件 提供 组件ID”, “用户 使用 头文件提供的ID”的话, 这种一一对应的关系杜绝了出错的可能性.

文章中也提到了”使用接口名作为接口版本标记是十分不智的”, 这个某个程度上我也十分认同, 但是必须注意到–微软没有使用COM作为操作系统的底层架构, 同样, 在Linux内核里面使用COM也是不智的, COM提供的是一个相对灵活的协同交互的框架, 注定是不能在性能十分繁重的情况下使用的, 而且每个版本更新很大可能是对上一版本的增量更新, 正如文章所说的, 可能会造成十分复杂的接口承继, 这就需要合理划分接口和设计接口了.

总的来说, 这个设计某些程度上保持二进制兼容的扩展了COM框架的灵活性, 但是却增加了复杂性容易造成认为索引失误, 本来由编译器实现的函数检查, 虚函数表生成等变为人为处理, 一旦更改接口必须谨记修改3个变量: 接口声明, 实现声明, 实现索引. 这三个变量任意一个出问题都会造成不可预料的错误而且极难排查…作为一种改进值得研究但是马上作为实战使用分发仍然需要一定的勇气.

Joshua GPBeta

对此回复的回应请看 谈中心化插件系统的设计(二)