驱动程序与驱动管理器如何协同工作¶
注意
本文档重点介绍实现或使用 adbc.h 中定义的 C API 的驱动程序/应用程序。这包括 C/C++、Python 和 Ruby;以及可能的 C#、Go 和 Rust(通过 FFI 实现或使用驱动程序时)。
当应用程序调用诸如 AdbcStatementExecuteQuery() 这样的函数时,它是如何“知道”究竟应该调用哪个驱动程序中的哪个函数的?
这可以通过几种方式实现。在最简单的情况下,应用程序链接到一个单独的驱动程序,并直接调用由该驱动程序显式定义的 ADBC 函数。
在最简单的情况下,应用程序直接链接到驱动程序并调用 ADBC 函数。¶
这对于多个驱动程序,或者无法/不允许直接链接到驱动程序的应用程序(例如,像 Python 这样的语言中的动态加载)并不适用。对于这种情况,ADBC 提供了一个函数指针表(AdbcDriver),并提供了一种从驱动程序获取该表的方法。然后,应用程序分两步进行:首先,它动态加载驱动程序并调用入口点函数以获取函数表。
现在,应用程序向驱动程序请求一个用于调用的函数表。¶
然后,应用程序通过调用表中的函数来使用驱动程序。
应用程序使用该表来调用驱动程序函数。这种方法可以扩展到多个驱动程序。¶
然而,处理这张表是很繁琐的。因此,总体上推荐的方法是使用 ADBC 驱动管理器。这是一个库,它伪装成一个可以被链接并“正常使用”的单一驱动程序。在内部,它加载函数指针表并跟踪哪些数据库/连接/语句对象需要哪个“实际”的驱动程序,从而可以轻松地在运行时动态加载驱动程序,并在同一个应用程序中使用多个驱动程序。
应用程序使用驱动管理器,从而“感觉”就像是在使用单一驱动程序一样。驱动管理器在后台处理细节。¶
详细信息¶
adbc.h 头文件将一切联系在一起。它是抽象 API 定义,类似于其他语言中的接口/特征/协议定义。然而,C 语言毕竟是 C 语言,它仅由一堆函数原型和结构定义组成,没有任何实现。
从本质上讲,驱动程序只是一个实现了 adbc.h 中这些函数原型的库。这些函数可以用 C 语言实现,也可以用其他语言实现并通过特定语言的 FFI 机制导出。例如,ADBC 的 Go 和 C# 实现都可以向期望 C API 定义的消费者导出驱动程序。只要 adbc.h 中的定义以某种方式得到实现,应用程序通常就不会在意底层究竟是什么。
那么,应用程序是如何调用这些函数的呢?这里有几种选择。
同样,最简单的情况如下:如果 (1) 应用程序直接链接到驱动程序,且 (2) 驱动程序公开的 ADBC 函数名称与 adbc.h 中的相同,那么应用程序可以直接 #include <arrow-adbc/adbc.h> 并直接调用 AdbcStatementExecuteQuery(...)。此时,应用程序和驱动程序之间的关系与其他任何 C 库没有区别。
在最简单的情况下,应用程序直接链接到驱动程序并调用 ADBC 函数。当应用程序调用 StatementExecuteQuery 时,它直接由所链接的驱动程序提供。¶
遗憾的是,这种情况在其他场景中效果不佳。例如,如果应用程序希望使用多个 ADBC 驱动程序,则此方法失效:两个驱动程序都定义了相同的函数(adbc.h 中的那些函数),当应用程序链接两者时,链接器无法分辨在应用程序调用 ADBC 函数时,应该使用哪个驱动程序的函数。最重要的是,这违反了单一定义规则 (One Definition Rule)。
在这种情况下,驱动程序可以提供应用程序可以使用的特定于驱动程序的别名,例如 PostgresqlStatementExecuteQuery 或 FlightSqlStatementExecuteQuery。这样,应用程序就可以链接两个驱动程序,忽略 Adbc… 函数(并忽略那里对单一定义规则的技术性违反),转而使用这些别名。
为了绕过单一定义规则,我们可以提供 ADBC API 的别名来代替。¶
然而,这对应用程序来说非常不方便。此外,这在一定程度上违背了使用 ADBC 的初衷,因为现在应用程序必须为每个驱动程序使用单独的 API,即使它们从技术上讲都是同一个 API 的克隆。而且,这并没有解决那些希望动态加载驱动程序的应用程序的问题。例如,Python 脚本希望在运行时加载驱动程序。在这种情况下,它需要知道驱动程序中的哪些函数对应于 ADBC API 定义中的哪些函数,而无需硬编码这些信息。
ADBC 预见到了这一点,并定义了 AdbcDriver。这只是一个函数指针表,每个 ADBC 函数都有一个条目。这样,应用程序可以动态加载驱动程序并调用返回此函数指针表的入口点函数。(它确实需要硬编码或猜测入口点的名称;ADBC 规范列出了一组可以尝试的名称,基于驱动程序库本身的名称。参见 AdbcDriverInitFunc。)
应用程序首先从驱动程序加载函数指针表。¶
然后,它可以通过调用该表中的函数来使用驱动程序。
应用程序使用该表来调用驱动程序函数。这种方法可以扩展到多个驱动程序。¶
当然,通过庞大的函数指针表来调用所有函数是不方便的。因此,ADBC 提供了“驱动管理器”,这是一个_伪装_成简单驱动程序并实现所有 ADBC 函数的库。在内部,它动态加载驱动程序,请求函数指针表,并跟踪哪些连接正在使用哪些驱动程序。应用程序只需要调用标准的 ADBC 函数,就像我们最初使用的最简单的情况一样。
应用程序使用驱动管理器,从而“感觉”就像是在使用单一驱动程序一样。驱动管理器在后台处理细节。¶
总结一下,一个驱动程序应该实现这三件事:
每个 ADBC 函数的实现,
每个实现函数的轻量包装器,用于导出每个函数的 ADBC 名称,以及
一个入口点函数,用于返回一个包含 (1) 中函数的
AdbcDriver表。
然后,应用程序在使用驱动程序时有以下选择:
直接链接驱动程序并调用
Adbc…函数(仅在最简单的情况下),使用上述 (2),直接/动态链接驱动程序,通过上述 (3) 加载
AdbcDriver,并通过函数指针调用 ADBC 函数(通常不推荐),链接 ADBC 驱动管理器,调用
Adbc…函数,让驱动管理器处理上述 (3)(这是大多数应用程序想要做的事情)。
换句话说,通常总是使用驱动管理器是最简单的。但它所展示的“魔法”并不是必需的,也不算太复杂。
注意
您可能会问:当我们有了 AdbcDriver 时,为什么还要费心同时定义 AdbcStatementExecuteQuery 和 SqliteStatementExecuteQuery(即为什么同时做上述 (1) 和 (2))?我们不能只定义 Adbc… 版本,并在请求时将其放入函数表中吗?
这里涉及实现约束。在运行时,当驱动程序查找(例如)AdbcStatementExecuteQuery 的地址并将其放入表中时,动态链接器将介入以确定该函数的位置。遗憾的是,它很可能会在驱动管理器中找到它。这是一个问题,因为当驱动管理器去调用“驱动程序”版本的函数时,它最终会陷入无限循环!
通过拥有一个看似多余的函数副本,我们可以向动态链接器隐藏“真正的实现”,从而避免这种行为。
驱动管理器可以尝试通过使用 RTLD_DEEPBIND 加载驱动程序来解决此问题。然而,这并不是可移植的,而且如果我们希望在开发过程中使用诸如 AddressSanitizer 之类的工具,它会引起问题。驱动程序也可以使用诸如 -Bsymbolic-functions 之类的标志进行构建。