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

注意

本文档重点介绍实现或使用 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 语言的特点是,它只包含一堆函数原型和结构定义,没有任何实现。

一个驱动程序,其核心只是一个实现 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 规范列出了一组它可以尝试的名称,基于驱动程序库本身的名称。请参见 AdbcDriverInitFunc。)

../_images/DriverTableLoad.mmd.svg

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

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

../_images/DriverTableUse.mmd.svg

应用程序使用该表调用驱动程序函数。这种方法适用于多个驱动程序。

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

../_images/DriverManagerUse.mmd.svg

应用程序使用驱动程序管理器,使其“感觉”自己只是在使用单个驱动程序。驱动程序管理器在幕后处理细节。

总而言之,驱动程序应该实现这三件事:

  1. 每个 ADBC 函数的实现,

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

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

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

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

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

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

换句话说,通常最简单的方法是始终使用驱动程序管理器。但它所实现的“魔力”并非必需,也并非那么复杂。

注意

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

这里涉及到实现限制。在运行时,当驱动程序查找(例如)AdbcStatementExecuteQuery 的地址并将其放入表中时,动态链接器将介入以确定该函数的位置。不幸的是,它可能会在驱动程序管理器中找到它。这是一个问题,因为当驱动程序管理器调用该函数的“驱动程序”版本时,它将陷入无限循环!

通过拥有一个看似冗余的函数副本,我们可以将“真实实现”隐藏起来,避免动态链接器的这种行为。

驱动程序管理器可以尝试通过使用 RTLD_DEEPBIND 加载驱动程序来解决此问题。然而,这不可移植,并且如果我们还想在开发过程中使用 AddressSanitizer 等工具,会引发问题。驱动程序也可以使用 -Bsymbolic-functions 等标志进行构建。