驱动程序和驱动程序管理器如何协同工作

注意

本文档重点介绍实现或使用 adbc.h 中的 C API 定义的驱动程序/应用程序。这包括 C/C++、Python 和 Ruby;以及可能的 C#、Go 和 Rust(通过 FFI 实现或使用驱动程序时)。

当应用程序调用诸如 AdbcStatementExecuteQuery() 之类的函数时,它如何“知道”实际调用哪个驱动程序中的哪个函数?

这可以通过几种方式发生。在最简单的情况下,应用程序链接到单个驱动程序,并直接调用驱动程序显式定义的 ADBC 函数。

../_images/DriverDirectLink.mmd.svg

在最简单的情况下,应用程序直接链接到驱动程序并调用 ADBC 函数。

这不适用于多个驱动程序,或不/不能直接链接到驱动程序的应用程序(想想动态加载,可能在像 Python 这样的语言中)。对于这种情况,ADBC 提供了一个函数指针表(AdbcDriver),以及一种从驱动程序请求此表的方法。然后,应用程序分两步进行。首先,它动态加载一个驱动程序并调用一个入口点函数来获取函数表。

../_images/DriverTableLoad.mmd.svg

现在,应用程序向驱动程序请求一个函数表以供调用。

然后,应用程序通过调用表中的函数来使用驱动程序。

../_images/DriverTableUse.mmd.svg

应用程序使用该表来调用驱动程序函数。这种方法可以扩展到多个驱动程序。

然而,处理该表是很麻烦的。因此,总体推荐的方法是使用 ADBC 驱动程序管理器。这是一个库,它伪装成一个可以链接和使用的“像正常”的单个驱动程序。在内部,它加载函数指针表,并跟踪哪些数据库/连接/语句对象需要哪个“实际”驱动程序,从而可以轻松地在运行时动态加载驱动程序,并从同一应用程序中使用多个驱动程序。

../_images/DriverManagerUse.mmd.svg

应用程序使用驱动程序管理器来“感觉像”它只是使用单个驱动程序。驱动程序管理器在幕后处理细节。

更多细节

adbc.h 标头将所有内容联系在一起。它是抽象 API 定义,类似于其他语言中的接口/特征/协议定义。然而,C 是 C,它只包含一堆函数原型和结构定义,没有任何实现。

驱动程序的核心只是一个库,它在 adbc.h 中实现这些函数原型。这些函数可以用 C 实现,也可以用不同的语言实现,并通过特定于语言的 FFI 机制导出。例如,ADBC 的 Go 和 C# 实现都可以将驱动程序导出给期望 C API 定义的使用者。只要以某种方式实现了 adbc.h 中的定义,应用程序通常就不会知道底层实际是什么。

但是,应用程序如何调用这些函数呢?在这里,有几个选择。

同样,最简单的情况如下:如果 (1) 应用程序直接链接到驱动程序,并且 (2) 驱动程序以与 adbc.h 中相同的名称公开 ADBC 函数,那么应用程序可以直接 #include <arrow-adbc/adbc.h> 并直接调用 AdbcStatementExecuteQuery(...)。在这里,应用程序和驱动程序之间的关系与任何其他 C 库没有区别。

../_images/DriverDirectLink.mmd.svg

在最简单的情况下,应用程序直接链接到驱动程序并调用 ADBC 函数。当应用程序调用 StatementExecuteQuery 时,它直接由与其链接的驱动程序提供。

不幸的是,这在其他场景中效果不佳。例如,如果应用程序希望使用多个 ADBC 驱动程序,则不再有效:两个驱动程序都定义了相同的函数(adbc.h 中的函数),并且当应用程序链接它们时,链接器无法分辨应用程序调用 ADBC 函数时所指的是哪个驱动程序的函数。最重要的是,这违反了 单一定义规则

在这种情况下,驱动程序可以提供特定于驱动程序的别名,应用程序可以使用,例如 PostgresqlStatementExecuteQueryFlightSqlStatementExecuteQuery。然后,应用程序可以链接两个驱动程序,忽略 Adbc… 函数(并忽略那里的单一定义规则的技术违规),并使用别名代替。

../_images/DriverAlias.mmd.svg

为了绕过单一定义规则,我们可以提供 ADBC API 的别名。

但是,这对应用程序来说相当不方便。此外,这在某种程度上破坏了使用 ADBC 的意义,因为现在应用程序为每个驱动程序都有一个单独的 API,即使它们在技术上都是相同 API 的克隆。这也没有解决想要动态加载驱动程序的应用程序的问题。例如,Python 脚本会希望在运行时加载驱动程序。在这种情况下,它需要知道驱动程序中的哪些函数对应于 ADBC API 定义中的哪些函数,而无需硬编码这些知识。

ADBC 预料到了这一点,并定义了 AdbcDriver。这只是一个函数指针表,每个 ADBC 函数有一个条目。这样,应用程序可以动态加载驱动程序并调用返回此函数指针表的入口点函数。(它确实必须硬编码或猜测入口点的名称;ADBC 规范列出了一组它可以尝试的名称,基于驱动程序库本身的名称。)

../_images/DriverTableLoad.mmd.svg

应用程序首先从驱动程序加载一个函数指针表。

然后,它可以通过调用该表中的函数来使用驱动程序。

../_images/DriverTableUse.mmd.svg

应用程序使用该表来调用驱动程序函数。这种方法可以扩展到多个驱动程序。

当然,通过一个巨大的函数指针表来调用所有函数是不方便的。因此,ADBC 提供了“驱动程序管理器”,这是一个_伪装_成简单驱动程序并实现所有 ADBC 函数的库。在内部,它动态加载驱动程序,请求函数指针表,并跟踪哪些连接正在使用哪些驱动程序。应用程序只需要调用标准 ADBC 函数,就像我们开始时的最简单情况一样。

../_images/DriverManagerUse.mmd.svg

应用程序使用驱动程序管理器来“感觉像”它只是使用单个驱动程序。驱动程序管理器在幕后处理细节。

因此,概括来说,驱动程序应该实现以下三件事:

  1. 每个 ADBC 函数的实现,

  2. 每个实现函数周围的薄包装器,用于导出每个函数的 ADBC 名称,以及

  3. 一个返回 AdbcDriver 表的入口点函数,其中包含 (1) 中的函数。

然后,应用程序可以选择以下使用驱动程序的方式:

  • 直接链接驱动程序并使用上面 (2) 中的 Adbc… 函数(仅在最简单的情况下),

  • 直接/动态链接驱动程序,通过上面 (3) 加载 AdbcDriver,并通过函数指针调用 ADBC 函数(通常不推荐),

  • 链接 ADBC 驱动程序管理器,调用 Adbc… 函数,并让驱动程序管理器处理上面的 (3)(大多数应用程序都希望这样做)。

换句话说,通常最容易直接始终使用驱动程序管理器。但它所发挥的魔力并非必需或过于复杂。

注意

您可能会问:当我们有 AdbcDriver 时,为什么我们要费心同时定义 AdbcStatementExecuteQuerySqliteStatementExecuteQuery(即,为什么同时存在上面的 (1) 和 (2))?我们不能只定义 Adbc… 版本,并在请求时将其放入函数表中吗?

在这里,出现了实现约束。在运行时,当驱动程序查找(例如)AdbcStatementExecuteQuery 的地址以将其放入表中时,动态链接器将发挥作用以确定此函数的位置。不幸的是,它可能会在驱动程序管理器中找到它。这是一个问题,因为然后驱动程序管理器在调用函数的“驱动程序”版本时最终会进入无限循环!

通过拥有一个看似多余的函数副本,我们可以然后从动态链接器隐藏“真正的实现”并避免这种行为。

驱动程序管理器可以尝试通过使用 RTLD_DEEPBIND 加载驱动程序来解决这个问题。然而,这不具有可移植性,并且如果我们在开发期间也想使用诸如 AddressSanitizer 之类的工具,则会产生问题。驱动程序也可以使用诸如 -Bsymbolic-functions 之类的标志进行构建。