如何设计版本化的C API

ast*_*ter 3 c versioning api

我想设计一个C API,它在一个库中提供自己的几个版本.

我遇到了Foundation DB C API的描述,但由于FoundationDB源代码不再可用,我无法弄清楚他们是如何做到的.我知道的所有其他库在给定版本的库中提供一个API,并且必须链接到特定版本以获得所需的API.

我完全清楚支持旧的API版本是一个主要的麻烦,我将尝试第一次正确的API,但由于这个API分布在几乎没有维护可能性的地理分布式系统上,我仍然希望能够只更新我的图书馆没有打破其他软件.

使用面向对象的语言,任务更容易/更简单(取决于语言),但对于C?

Som*_*ude 5

我不知道Foundation DB C API是如何工作或设计的,但一种方法是使用结构和函数指针在C中模拟继承.

你从一个基础结构开始,比如

struct base_api
{
    int version;
};
Run Code Online (Sandbox Code Playgroud)

然后你"继承"(或扩展)这个基础结构:

struct version_1_api
{
    struct base_api base;
    // Function pointers for version 1 of the API
};

struct version_2_api
{
    struct base_api base;
    // Function pointers for version 1 of the API
    // Function pointers for version 2 of the API
};
Run Code Online (Sandbox Code Playgroud)

然后有一个带有版本号的导出函数,并返回一个指针,struct base_api然后应用程序可以将其转换为指向相应结构的指针:

struct base_api *api = library_get_api();
if (api->version >= 2)
{
    // We have at least version 2 of the API available
    struct version_2_api *api2 = (struct version_2_api *) api;
    // Use version 2 of the API
}
else if (api->version >= 1)
{
    // We have version 1 of the API available
    struct version_1_api *api1 = (struct version_1_api *) api;
    // Use version 1 of the API
}
else
{
    // Unsupported version
}
Run Code Online (Sandbox Code Playgroud)

上例中的library_get_api函数只返回指向静态结构的指针.像是这样的东西

struct base_api *library_get_api()
{
    static version_2_api api = {
        { 2 } // Version
        // Function pointers for version 1
        // Function pointers for version 2
    };

    return (struct base_api *) &api;
}
Run Code Online (Sandbox Code Playgroud)


Dra*_*rgy 5

我强烈建议的一件事是为整个库使用单个版本号.我在之前的代码库中看到如此多的痛苦之后就这么说了.

在之前的代码库中,它是一个插件架构.插件将传递一个函数指针,如下所示:

EXPORT void my_plugin_func(LookupFunction* lookup)
{
     // Retrieve version 3 of the drawing interface.
     struct DrawingInterface3* drawer = lookup(DRAWING_INTERFACE, 3);

     // Retrieve version 1 of the widget interface.
     struct WidgetInterface7* widget = lookup(WIDGET_INTERFACE, 1);

     // Retrieve the latest version of the brush interface.
     struct BrushInterface* brush = lookup(BRUSH_INTERFACE, BRUSH_INTERFACE_VER);
     ...
}
Run Code Online (Sandbox Code Playgroud)

虽然这可能看起来很酷,因为你可以在每个接口级别混合和匹配并使用你想要的任何可用接口版本,你可能会开始想象这是一个维护噩梦.

首先是因为要处理的版本号太多(每个可用的接口都有一个),开发人员出错的方式有很多种.如果在大型团队中出现如此多的错误方法,事情偶尔会出错.我们让开发人员在同一个周期中将版本号翻两次或更多次,或者更糟糕的是,忘记为他们完全修改的接口增加版本号,这种情况并不少见.

如果SDK附带了这个错误,后一个错误就是灾难性的,因为它会使所有插件都被编写,比如DrawingInterface3因为它们不再是二进制兼容而被破坏了.与此同时,所有正在编写插件的人DrawingInterface3实际上应该是DrawingInterface4(但更新界面的人忘记将其版本关闭)现在需要针对固定SDK重新编译所有插件,并不是所有人都会这样做(有些人会发布一个插件并停止维护它).因此,即使在我们修复了实际写入的插件的问题之后DrawingInterface3,它也将永久地使所有插件构建为应该已经存在的DrawingInterface4foob​​ar.

但这甚至不是最糟糕的问题.最糟糕的是,现在引擎盖后面的代码必须处理爆炸性的接口组合.有人可能想使用Drawing界面版本2绘制到Widget版本7,这可能需要一个完全独立的代码分支,从使用绘图界面5绘制到Widget版本3的代码.尽管有一种聪明的方法来设计多态解决方案引擎盖,导致破坏任何界面版本所需的最疯狂数量的爆炸性代码几乎没有任何实际好处.

因此,我首先建议将其保留为整个库/ SDK的单个版本号.你可以这样做:

// Tell the system what SDK version we're using.
EXPORT int32_t my_plugin_version(void)
{
     return SDK_VERSION_NUMBER;
}

// The system will now know what interfaces to provide
// to this plugin after calling the above function.
EXPORT void my_plugin_func(LookupFunction* lookup)
{
     // Retrieve latest version of the drawing interface.
     struct DrawingInterface* drawer = lookup(DRAWING_INTERFACE);

     // Retrieve latest version of the widget interface.
     struct WidgetInterface* widget = lookup(WIDGET_INTERFACE);

     // Retrieve latest version of the brush interface.
     struct BrushInterface* brush = lookup(BRUSH_INTERFACE);
     ...
}
Run Code Online (Sandbox Code Playgroud)

使用面向对象的语言,任务更容易/更简单(取决于语言),但对于C?

对我来说,并不是真正的OOP让事情变得微不足道.实际上使用本机代码的OOP会让事情变得更加艰难.例如,使用C++对dylib进行版本控制甚至确保广泛的兼容性可能是一个噩梦,需要上述C++的许多功能(异常处理,虚函数,标准库,一般情况下,如果你不仅要针对其他C++编译器,还有其他语言的FFI等).容易或不容易的事情与代码如何动态链接有关.使用JIT或解释器的语言,这更简单.

使用本机代码时,往往会更复杂,因为它会将各种ABI问题引入调用约定之类的事情,因为您的库必须直接以二进制形式使用,而不是为用户的动态编译和链接特殊的机器,标准库等.对于非本机代码,它有点像你是开源你的库(没有实际做它而是运送一些不完全是原生的中间代码,比如编译的IR字节代码)飞).当你没有真正向用户发送原生二进制文件时,自然而言"开源"会使事情变得简单得多.

我使用C API而非C++的主要原因尤其是因为C API比C++更简单,更普遍兼容.我使用C++来实现所有的C API,但是在使用C严格用于API接口本身(当与整个SDK的单个版本号结合使用)之后,我的生活变得更加简单.

方便和安全

我在评论中提到了这一点,但我有一些建议:不要试图使你的API(实际上是动态链接的)非常方便和安全使用.否则,你可以繁殖和增加你的版本维护工作,除非你的库是相当微不足道的(而且相当简单的通常不会那么关注与版本相关的架构问题).

相反,如果您希望使用您的库的人拥有可以安全使用的非常好且方便的界面,请在导出的API之上为他们提供一个包装器的静态库.这个静态链接的库不是你必须在向后二进制兼容性方面维护的,因为它们实际上是由库的用户构建的.这个静态的"方便/帮助"库可以随心所欲地方便.

我建议这样做的原因是,如果你努力使你的原始导出API真的很方便使用,你可以进入一个你现在维持旧代码10倍的情况.这就像重构减去所有的好处,因为现在你必须维护旧版本的"不那么方便"的功能实现,较新版本的中等"方便/安全"功能,最新版本等等.你必须维护您可以达到爆炸性数量的所有代码遗留代码,因为您不断引入所有新功能和更改,只是为了尝试使您的导出API更方便,更安全地使用.

所以我建议不要这样做,而是建议关注静态库中的那种东西.为方便/安全,请勿导出更多功能.导出新功能只是因为它们提供了之前没有的必需功能.专注于其他地方的帮助/便利.当然,你可能仍然需要在某种程度上维护便利库的"源代码兼容性",但是如果他们每隔几年必须更改一些代码以针对你的最新版本构建内容,那么编写针对你的库的代码的人可能会原谅你.图书馆.二进制兼容性是不同的,因为您可能会在10年后发现仍然无法删除该10年前的遗留代码,因为用户仍然发现使用旧版本构建的旧二进制文件仍然有用.因此,没有太多遗留代码真的很有帮助,如果你不想尽可能方便地使你的导出函数("原始代码",解包),你会更少.由于源代码与包装器兼容,只要您保持与旧版本的导出API的二进制兼容性,无论内部发生什么变化,它们都不可能中断,因此,为了保持二进制兼容性,最小化目标也是有帮助的.

除此之外,尝试使您的C API方便/安全通常是徒劳的,因为,例如,您在C API之上施加的任何安全性都不会使C++开发人员在执行异常处理时实际需要RAII一致性如果没有在你的图书馆上面写自己的包装,就会感到高兴.C#开发人员永远不会想要以原始形式使用库 - 它们甚至比C++开发人员更加极端.

因此,无论如何,人们常常会在您的图书馆顶部写下安全包装.如果你想要一个安全而漂亮的库,如果它是一个非平凡的规模(跨越,说,数百个标题),对我来说最有效的途径是专注于导出所需的功能,没有方便/助手东西,并在一个单独的静态链接库中构建方便/助手的东西,其源代码直接交给用户构建.

的printf

我喜欢这个printf例子,因为如果你看一下printf,它是一个可变函数,那些在C中使用非常不安全,而且往往是开发人员的跳闸点.但在另一面,它是一个古老的功能已经存在了那么几十年,一直是相关今天不需要printf_ver2,printf_ver3等,这是因为该函数的可变参数特性使其扩展不会引入新的功能.

所以我经常看到那里的最佳点,因为有一些类似的东西printf可以让你在未来的版本中扩展它,而不会引入大量的功能和遗留代码来维护,但同时提供顶部的包装,可以安全使用(因此类比printf只能在一个实现这种包装的地方使用,而不是由用户直接使用).然后,该组合应该为您提供一个小目标,以保持向后兼容性,同时提供更安全和方便的内置使用.对我来说,维护长期存在的库有很大帮助,其中版本控制首先要优先考虑可维护性和可扩展性,然后分别为用户解决方便和安全等问题,因为长期存储库的维护工作可能会成为天文数字的成本从长远来看,如果你不小心保持二进制,导出的目标尽可能小和极简.并且保持一大堆20年前的代码并不是很有趣,因为有些人仍在使用针对它的东西.

简单的扩展

最后要注意的是,使用C,您可以添加一些内容,而不会影响二进制兼容性和接口版本控制.例如,您可以在struct不影响二进制兼容性的情况下将字段添加到a的底部,前提是用户struct不需要知道其大小(例如:他们不是自己实例化).在这种情况下,那些使用旧版本库的人可以传递指向最新struct实例的指针,但他们根本看不到你添加的新字段,因为他们没有看到最新的定义struct(没有最新的标题,即),但他们看到的所有内容仍然可以正常工作,并且完全按照以前的方式工作(假设您在添加新字段时没有更改现有函数的实现).因此,如果它们以保留ABI的方式正确完成而不需要您使用库版本并且必须实现全新的接口,那么就可以有很多添加空间而无需维护多个版本的东西.

我建议尽可能地利用它,因为这是我在以前的代码库中看到的另一件事.一些开发人员对SDK版本提出了根本不影响ABI的更改,并且不会影响旧版本库的用户的功能,并且不必要地创建了全新的代码分支来维护.同样,维护工作需要增加您添加的更多版本以进行维护,因此有助于利用和找到尽可能多的方法来避免版本化问题,并尽可能减少每个版本必须维护的代码量.

ABI也有点棘手,因此对每个旧版本的接口进行单元测试确保它们仍能按原样引入新版本非常有用.您甚至不必反复为旧版本构建单元测试,因为它们的目的是确保二进制兼容性.所以,你可以只存档自己的可执行文件,并在CI,例如运行它们,而不必一遍遍构建源代码(实际上有争论反对一遍又一遍地建造它们,因为关键是要确保建成老版本仍然是旧的二进制反对你图书馆的最新二进制文件).当您浏览ABI的地雷并向后兼容关于您所做的更改是否会影响以前的二进制文件以及您是否需要对全新的界面和实现进行版本控制时,这些单元测试还将消除任何疑问只需修改现有的一个.


use*_*109 2

在 C 中,您可以将所有函数设为可变参数,第一个参数指示版本号,例如

int foo( int version, char *buffer, int length, ... )
{
}
Run Code Online (Sandbox Code Playgroud)

这允许您在必要时添加更多参数,但不允许您更改buffer或的类型length。你当然可以这样做

int foo( int version, ... )
Run Code Online (Sandbox Code Playgroud)

但即使该函数的第一个版本也不是自文档化的。


另一种选择是将指针传递给结构,例如

struct FooParams
{
    int version;
    char *buffer;
    int length;
};

int foo( struct FooParams *params )
{
}
Run Code Online (Sandbox Code Playgroud)

结构定义应包含 asize和/或 a version,以便您知道调用者正在使用哪个结构。