【问题标题】:How to design a versioned C API如何设计版本化的 C API
【发布时间】:2015-12-03 16:47:09
【问题描述】:

我想设计一个在单个库中提供多个版本的 C API。

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

我完全意识到支持旧 API 版本是一个很大的麻烦,我会尝试在第一时间获得正确的 API,但由于该 API 分布在地理上分布的系统上,几乎没有维护的可能性,我仍然希望能够只更新我的图书馆而不破坏其他软件。

对于面向对象的语言,任务更容易/微不足道(取决于语言),但对于 C?

【问题讨论】:

    标签: c api versioning


    【解决方案1】:

    我强烈推荐的一件事是为您的整个库使用一个版本号。我在看到以前没有这样做的代码库中遭受了如此多的痛苦之后才这么说。

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

    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);
         ...
    }
    

    虽然考虑到您可以在每个接口级别上混合、匹配和使用您想要的任何可用接口版本,这看起来很酷,但您可能会开始想象这是一场维护噩梦。

    首先,因为要处理的版本号太多(每个可用的接口都有一个),所以开发人员有很多方法会出错。考虑到在一个大团队中出错的方式有很多,事情偶尔会出错。我们让开发人员在同一个周期内两次或多次修改版本号,或者更糟糕的是,忘记为他们彻底修改的界面增加版本号,这种情况并不少见。

    如果 SDK 附带此错误,则后一个错误将是灾难性的,因为它会使曾经为 DrawingInterface3 编写的所有插件损坏,因为它们不再是二进制兼容的。同时,所有现在针对DrawingInterface3(实际上应该是DrawingInterface4)编写插件的人(但是更新界面的人忘记将其版本化)现在需要针对固定的SDK重新编译他们的所有插件,而不是他们所有人都会这样做(有些人会发布一个插件并停止维护它)。因此,即使我们解决了实际上针对 DrawingInterface3 编写的插件的问题,它也会永久地使所有插件都针对应该是 DrawingInterface4 foobar 的内容构建。

    但这还不是最严重的问题。最糟糕的是,现在引擎盖后面的代码必须处理接口的爆炸性组合。有人可能想使用绘图接口版本 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);
         ...
    }
    

    使用面向对象的语言,任务更容易/微不足道(取决于 关于语言)但对于 C?

    对我来说,让事情变得微不足道的并不是真正的 OOP。实际上,使用本机代码的 OOP 可以使事情变得更加困难。例如,对 dylib 进行版本控制甚至确保使用 C++ 的广泛兼容性可能是一场噩梦,需要前面提到 C++ 的许多特性(异常处理、虚函数、标准库、一般对象,如果您不仅要针对其他 C++ 编译器,而且还有其他语言的 FFI 等)。容易与否的事情与代码的动态链接方式有关。对于使用 JIT 或解释器的语言,这要简单得多。

    对于本机代码,这往往要复杂得多,因为它引入了各种 ABI 问题,例如调用约定,因为您的库必须以二进制形式直接使用,而不是动态编译和链接对于用户的特定机器、标准库等。使用非本机代码,这有点像你在开源你的库(实际上没有这样做,而是运送一些不完全是本机的中间代码,比如 IR 字节码是即时编译的)。当您实际上不向用户发送本机二进制文件时,“开源”自然会使事情变得简单得多。

    我之所以使用 C API 而不是 C++,主要是因为 C API 比 C++ 更简单、更普遍兼容。我使用 C++ 来实现所有的 C API,但是在将 C 严格用于 API 接口本身(与整个 SDK 的单个版本号结合使用)之后,我的生活变得简单了很多。

    方便和安全

    我在 cmets 中发现了这一点,但我有一些建议:不要试图让您的 API(实际上是动态链接的)非常方便和安全地使用。否则,除非您的库相当琐碎(而且相当琐碎的库通常不会过多关注与版本控制相关的架构问题),否则您可以成倍增加版本控制维护工作。

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

    我建议这样做的原因是,如果您过于努力地使导出的原始 API 真正方便使用,您可能会遇到现在维护 10 倍于遗留代码的情况。这就像重构减去所有好处,因为现在您必须维护“不太方便”的功能实现的旧版本、适度“方便/安全”功能的新版本、最新版本等。你必须维护整个遗留代码,因为您不断引入所有新功能和更改,同时弃用一些东西只是为了让您导出的 API 越来越方便和安全地使用。

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

    除此之外,试图使您的 C API 方便/安全通常是徒劳的,因为例如,您在 C API 之上施加的任何安全性都不会使实际上需要 RAII 一致性的 C++ 开发人员无需在您的库之上编写自己的包装器,异常处理就永远快乐。 C# 开发人员永远不会想要以原始形式使用该库——他们会比 C++ 开发人员更加极端。

    不管怎样,人们经常会在你的库之上编写安全的包装器。如果您想要使用一个安全且漂亮的库,如果它的规模不小(例如跨越数百个标头),那么对我来说最有效的途径就是只专注于导出必需 功能,没有便利/帮助的东西,并在单独的静态链接库中构建便利/帮助的东西,您直接将其源代码交给用户来构建。

    printf

    我喜欢 printf 的例子,因为如果您查看 printf,它是一个可变参数函数,在 C 中使用这些函数非常不安全,并且通常是开发人员的绊脚石。但另一方面,它是一个古老的函数,已经存在了几十年,并且在今天仍然适用,不需要printf_ver2printf_ver3 等等,这是因为可变参数该功能的性质允许它在不引入新功能的情况下进行扩展。

    所以我经常看到那里的甜蜜点有 printf 这样的东西,这将允许您在未来的版本中扩展它,而无需引入大量功能和遗留代码来维护,但同时在顶部提供包装器,它们是使用安全(因此类比的printf 只能在实现此类包装器的一个地方使用,而不是由用户直接使用)。然后,该组合应该为您提供一个小目标来维护向后兼容性,同时提供更安全、更方便使用的东西。对我来说,通过版本控制来优先考虑可维护性和可扩展性,然后分别为用户解决便利性和安全性等问题,这对维护长期存在的库有很大帮助,因为长期存在的库的维护工作可能会在成本上成为天文数字从长远来看,如果您不小心保持二进制导出的目标尽可能小且尽可能简约。维护一大堆 20 年前的代码肯定不好玩,因为有些人仍在使用针对它编写的东西。

    简易扩展

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

    我建议尽可能多地利用它,因为这是我在以前的代码库中看到的另一件事。一些开发人员针对根本不影响 ABI 并且不影响旧版本库用户的功能的更改而更改 SDK 版本,并且不必要地创建了全新的代码分支来维护。再次,所需的维护工作使您添加要维护的版本数倍增,因此它有助于利用并找到尽可能多的方法来避免版本化问题,并使您必须为每个版本维护的代码量尽可能地保持在最低限度。

    ABI 也有点棘手,因此对每个旧版本的接口进行单元测试非常有帮助,以确保它们在引入新版本时仍然可以正常工作。您甚至不必一遍又一遍地为旧版本构建单元测试,因为这样做的目的是确保二进制兼容性。因此,您可以只归档它们的可执行文件并在 CI 中运行它们,例如,不必一遍又一遍地构建源代码(实际上有参数反对一遍又一遍地构建它们,因为重点是确保针对旧版本构建的旧二进制文件仍然适用于库的最新二进制文件)。当您浏览 ABI 的地雷和向后兼容性时,这些单元测试也将消除任何疑虑,即您所做的更改是否会影响以前的二进制文件,以及您是否需要对全新的接口和实现进行版本化或可以只需修改现有的。

    【讨论】:

      【解决方案2】:

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

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

      struct base_api
      {
          int version;
      };
      

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

      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
      };
      

      然后有一个导出函数,它接受一个版本号,并返回一个指向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
      }
      

      上例中的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;
      }
      

      【讨论】:

      • 这也是我推荐的。从第 2 版函数内部,您还可以决定是否要在运行第 2 版函数所需的功能之前/之后运行第 1 版函数(执行“基类”方法),或者完全忽略第 1 版函数并推出一个全新的功能(虚拟功能)。 C中的多态性。
      • 我不清楚这与简单地向界面添加功能有何不同。版本 1 的函数指针不会改变,并且仍然可以不变地使用(除了我可以更改版本 2 库的版本 1 的实现)。我也希望可以选择更改函数参数。
      • @astifter 这不能以类型安全的方式完成。而且它绝对不能以向后兼容的方式完成。如果您在版本 2 中更改了版本 1 的功能,那么所有使用版本 1 的应用程序在更新后将突然停止正常工作。您可以通过在基本结构中添加 min_version_supported 来稍微抵消这一点,这样您就可以淘汰旧版本的 API。
      • @astifter 更改参数/返回类型的最安全、稳定和可移植的方法是创建一个具有所有额外功能的全新函数,并保留旧函数原样。以 Windows API CreateWindowCreateWindowEx 为例,其中“ex”版本包含附加参数。使用某些语言机制可能可以更改现有函数的参数和返回类型,但这样做不是一个好主意,因为它破坏了所有向后兼容性。
      • @Lundin 我想的越多,我得出的结论就越多。我正在考虑使用定义来选择标头中的 API 并在库中提供几个 API 实现,但使用 C 几乎是不可能/实际上是不可能的。您介意为此问题提供答案,以便我将其标记为已回答吗?
      【解决方案3】:

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

      int foo( int version, char *buffer, int length, ... )
      {
      }
      

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

      int foo( int version, ... )
      

      但即使是第一个版本的函数也不是自记录的。


      另一个选项是传递一个指向结构的指针,例如

      struct FooParams
      {
          int version;
          char *buffer;
          int length;
      };
      
      int foo( struct FooParams *params )
      {
      }
      

      结构定义应包含size 和/或version,以便您知道调用者使用的是哪个结构。

      【讨论】:

      • 我不建议出于任何目的使用可变参数函数,它们会导致各种类型安全问题和运行时错误。看看 printf 和 scanf,我想在任何编程语言中都很难找到比这两个对人类造成的危害更大的标准库函数。
      • 感谢 user3386109,但我同意 Lundin 的观点,即可变参数函数使用起来很危险(而且很麻烦)。此外,我不想提供正确类型检查的界面。此外,我不希望用户在 API 版本之间混搭,我不想只提供向后兼容性并“强迫”用户在选择 API 版本时做出有意识的决定。
      • 对我来说问题是可变参数函数的使用,在我看来不一定是它们的提供。有时在最低级别,它们可以简化很多事情,并且不必被广泛使用。您不一定必须在 dylib 中实现最安全的 API。这样做可能会开始变得非常适得其反。相反,您可以专注于在 dylib 中以最小形式导出所需的功能。然后最重要的是,您可以为用户提供一个 静态链接 库,然后该库采用“原始”API 并提供类型安全且易于使用的东西...
      • ...该策略可以从根本上简化您的维护,同时最终为您的用户提供更多类型安全的东西。您可以构建最丰富和最安全的接口以在静态库中使用(如果需要,您甚至可以将 C++ 与标准库一起使用——没有 ABI 问题,因为它是内部链接并针对用户的机器、编译器构建的——您可以也可以安全地抛出异常,因为这些异常会从用户的二进制文件中抛出到他们自己的二进制文件中,等等)。
      • 所以在我看来并不是printf的存在给人类带来了悲痛。这是此类功能的广泛使用。但是它们的存在本可以用来在顶部构建一些非常类型安全的东西,有时它们会以这种方式使用(例如:safe_printf 使用 C++ 中的可变参数模板,但构建在函数中提供的原始功能的函数之上比如printfsprintf,它们通常被实现为编译器内部函数,直接生成汇编代码)。
      【解决方案4】:

      函数指针。

      为库中的每个函数声明一个函数指针变量:

      return_type ( function_name_impl* )(parameters);
      

      您多次实现该功能,所需的版本数不限。所以你有function_name_VERSION_1function_name_VERSION_2等。

      选择函数的版本将正确的指针分配给每个函数指针变量。

      最后,使用了一个宏function_name,这样您的代码就可以调用所需的函数,而无需每次都为选择API版本而烦恼,也不必为函数指针使用sintax。

      这种策略有一个重要的优势。如果您已经实现了 API 的第一个版本并且已经在源代码形式中使用,您可以使用此策略将其转换为多版本 API,并且您不需要使用您的 API 对源代码进行任何更改,除了调用 @ 987654325@;

      图书馆.h:

      #ifndef LIBRARY_H
      #define LIBRARY_H
      
      #include <errno.h>
      
      #define VERSION_1 1
      #define VERSION_2 2
      
      /**
       * Example of library function
       */
      #define compute(a,b) ((*compute_impl)((a),(b)))
      
      extern int (*compute_impl)( int a, int b);
      
      
      /**
       * Set version of library to be used.
       * Sets errno to 0 on success. To non-zero if requested version
       *is not available
       */
      
      extern void setVersion( int version );
      
      #endif // LIBRARY_H
      

      图书馆.c:

      #include "library.h"
      
      int (*compute_impl)( int a, int b);
      
      int compute_VERSION_1( int a, int b)
      {
        return a+b;
      }
      
      int compute_VERSION_2( int a, int b)
      {
        return a+b+1;
      }
      
      /**
       * Set version of library to be used.
       * Sets errno to 0 on success. To non-zero if requested version
       *is not available
       */
      void setVersion( int version )
      {
        switch( version )
        {
          case VERSION_1 :
            compute_impl = &compute_VERSION_1;
            break;
          case VERSION_2 :
            compute_impl = &compute_VERSION_2;
            break;
          default :
            errno = 1;
            return;
         }
         errno = 0;
         return;
      }
      

      main.c:

      #include <stdio.h>
      #include "library.h"
      
      int main(void)
      {
        int j;
      
        setVersion( VERSION_2 );
        if ( errno )  {
          printf("API version requested not available\n");
          return 1;
        }
        j = compute( 3, 7 );
        printf("%d\n", j );
        return 0;
      }
      

      【讨论】:

      • 如果返回类型和参数在版本之间没有改变,这是最简单的解决方案。但如果是的话,你会想要做一些更抽象的东西,因为函数指针在指向不同类型的函数指针之间的转换不是 C 标准明确定义的。
      • 是的,但如果返回或参数类型发生变化,那么提供替代版本而不需要使用 API 更改源代码的目标是不可行的。与往常一样,策略的适用性取决于要满足的要求。
      • 这是继承的主要问题之一:在需求阶段,您无法轻易预见程序的所有未来需求。这就是为什么我认为应该谨慎使用继承,它可能会产生比它解决的问题更多的问题。
      • 感谢答案,@JoseAntonioDuraOlmos 正如 Lundin 指出的那样,这只允许更改函数的实现,并不能解决更改函数签名本身的问题。
      • @astifter。您有兴趣更改函数签名的哪一部分?返回类型;姓名;添加/删除/交换参数;改变参数的类型。另外,您的目标包括以下哪些功能:能够针对最初为版本 1 编写的版本 2 源代码进行编译;在运行时选择任何可用版本;无需使用它重新编译软件即可部署更新的二进制库,同时保留之前选择的功能。多版本 API 中还需要任何其他功能吗?
      猜你喜欢
      • 2023-03-24
      • 1970-01-01
      • 2017-01-24
      • 1970-01-01
      • 2010-10-04
      • 2020-01-08
      • 1970-01-01
      • 2020-06-17
      • 1970-01-01
      相关资源
      最近更新 更多