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

注意

本文档重点介绍实现或使用 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 等标志进行构建。