在当前的数据湖和湖仓生态系统中,许多数据集现在是分区目录结构中的大型文件集合,而不是单个文件。为了简化这种工作流程,Arrow 库提供了一个 API,用于轻松处理这些类型的结构化和非结构化数据。这被称为 Datasets API,旨在通过为您查询这些类型的数据集来完成大量繁重的工作。
Datasets API 提供了一系列工具,便于与分布式的、可能是分区的数据集(这些数据集分散在多个文件中)进行交互。它还利用了计算 API,并与之前在第 5 章《Acero:流式 Arrow 执行引擎》中介绍的 Acero 无缝集成。
在本章中,我们将学习如何使用 Arrow Datasets API 高效查询多文件、表格数据集,无论它们的存储位置或格式如何。我们还将学习如何使用数据集类和方法轻松过滤或对任意大型数据集进行计算。
以下是我们将覆盖的主题:
- 查询多文件数据集
- 以编程方式过滤数据
- 在 Python 中使用 Datasets API
- 流式结果
技术要求
与之前一样,本章有很多代码示例和练习,以加深对使用这些库的理解。您需要一台联网的计算机,并安装以下内容,以便尝试示例并进行学习:
- Python 3.8+:已安装 pyarrow 模块和 dataset 子模块
- 支持 C++17 或更高版本的 C++ 编译器:已安装 Arrow 库,能够包含和链接
- 您喜欢的编码 IDE,例如 Emacs、Vim、Sublime 或 VS Code
与之前一样,您可以在随附的 GitHub 仓库中找到完整的示例代码,网址为 github.com/PacktPublis...。
我们还将利用位于公共 AWS S3 存储桶中的 NYC 出租车数据集,路径为 s3://ursa-labs-taxi-data/
。
查询多文件数据集
为了快速查询数据,现代数据集通常会被划分为多个文件,分布在多个目录中。许多引擎和工具利用这种格式读取和写入数据,例如 Apache Hive、Dremio Sonar、Presto 以及许多 AWS 服务。Arrow Datasets 库提供了一个库功能,以便处理这些类型的表格数据集,具体包括以下内容:
- 提供一个统一的接口,支持不同的数据格式和文件系统。从 Arrow 17.0.0 版本开始,这包括 Parquet、ORC、Feather(或 Arrow IPC)、JSON 和 CSV 文件,这些文件可以是本地的或存储在云中的,例如 S3 或 HDFS。
- 通过爬取分区目录来发现数据源,并在不同文件之间提供一些简单的模式规范化。
- 对行进行谓词下推以实现高效过滤,同时优化列投影和并行读取。
使用我们熟悉的 NYC 出租车数据集,我们来看一个如何对这些数据进行分区以便于查询的示例。最明显、最可能的分区方式是按日期,具体来说是按年份和月份进行分区。图 6.1 展示了这可能的样子:
以这种方式对数据进行分区可以利用目录和文件来最小化我们需要读取的文件数量,以满足请求特定时间范围的查询。例如,如果一个查询请求的是2015年整个年度的数据,我们就可以跳过读取任何不在2015年文件夹中的文件。对于只查询每年1月份数据的情况,我们只需读取每年文件夹中的01文件即可。
在 NYC 出租车数据集中,另一种潜在的数据分区方式是首先按源类型进行分区,然后再按年份和月份进行分区。是否这样做合理取决于你预计要服务的查询模式。如果大多数情况下你只查询一种类型(黄色、绿色、FHV等),那么按这种方式对数据集进行分区将会非常有利,如图6.2所示。如果查询通常会在不同的数据集类型之间混合数据,可能更好的是仅按年份和月份进行分区,以最小化 I/O 通信。
这种分区技术可以应用于任何经常被过滤的列,而不仅仅是显而易见的列,如日期和数据集类型。这是在提高过滤效率(通过减少需要读取的文件数量)和在不按该列过滤时可能需要读取更多文件之间的权衡。
但我们有点超前了。让我们从一个简单的例子开始,然后再深入探讨。
创建示例数据集
我们要做的第一件事是创建一个示例数据集,该数据集由一个包含两个 Parquet 文件的目录组成。为了简单起见,我们可以只包含三列名为 a、b 和 c 的 int64 类型列。我们的整个数据集可以像图 6.3 所示,展示了这两个 Parquet 文件。在查看代码片段之前,试着用你选择的语言编写代码来自己写这两个文件。
以下是一个代码片段,将生成我们所需的两个文件。我将使用 C++ 作为示例,但你可以使用任何你喜欢的语言。在随附的 GitHub 存储库中也有这些代码的 Python 版本供你参考:
首先,快速回顾一下我们在第一章介绍的 ABORT_ON_FAIL 宏:
ini
#include <arrow/api.h>
#include <iostream>
#define ABORT_ON_FAIL(expr) \
do { \
arrow::Status status_ = (expr); \
if (!status_.ok()) { \
std::cerr << status_.message() \
<< std::endl; \
abort(); \
} \
} while(0);
接下来,为了简化,我们将创建一个 create_table
函数:
scss
#include <memory>
std::shared_ptr<arrow::Table> create_table() {
auto schema =
arrow::schema({
arrow::field("a", arrow::int64()),
arrow::field("b", arrow::int64()),
arrow::field("c", arrow::int64())});
std::shared_ptr<arrow::Array> array_a, array_b, array_c;
arrow::NumericBuilder<arrow::Int64Type> builder;
ABORT_ON_FAIL(builder.AppendValues({
0, 1, 2, 3, 4, 5, 6, 7, 8, 9}));
ABORT_ON_FAIL(builder.Finish(&array_a));
builder.Reset();
ABORT_ON_FAIL(builder.AppendValues({
9, 8, 7, 6, 5, 4, 3, 2, 1, 0}));
ABORT_ON_FAIL(builder.Finish(&array_b));
builder.Reset();
ABORT_ON_FAIL(builder.AppendValues({
1, 2, 1, 2, 1, 2, 1, 2, 1, 2}));
ABORT_ON_FAIL(builder.Finish(&array_c));
builder.Reset();
return arrow::Table::Make(schema,
{array_a, array_b, array_c});
}
然后,我们将添加一个函数,从创建的表中写入 Parquet 文件:
c
#include <arrow/filesystem/api.h>
#include <parquet/arrow/writer.h>
namespace fs = arrow::fs;
std::string create_sample_dataset(
const std::shared_ptr<fs::FileSystem>& filesystem,
const std::string& root_path) {
auto base_path = root_path + "/parquet_dataset";
ABORT_ON_FAIL(filesystem->CreateDir(base_path));
auto table = create_table();
auto output =
filesystem->OpenOutputStream(base_path + "/data1.parquet").ValueOrDie();
ABORT_ON_FAIL(parquet::arrow::WriteTable(
*table->Slice(0, 5),
arrow::default_memory_pool(),
output, /*chunk_size*/2048));
output = filesystem->OpenOutputStream(base_path + "/data2.parquet").ValueOrDie();
ABORT_ON_FAIL(parquet::arrow::WriteTable(
*table->Slice(5),
arrow::default_memory_pool(),
output, /*chunk_size*/2048));
return base_path;
}
最后,我们添加一个主函数来初始化文件系统并设置根路径:
c
int main() {
std::shared_ptr<fs::FileSystem> filesystem =
std::make_shared<fs::LocalFileSystem>();
auto path = create_sample_dataset(filesystem, "/home/matt/sample");
std::cout << path << std::endl;
}
当然,请将要写入的根路径替换为适合你的路径。
发现数据集片段
Arrow Datasets 库能够为文件系统基础的数据集发现现有的文件,而无需你描述其包含的每一个文件路径。截至 Arrow 库的 16.0.0 版本,当前的具体数据集实现如下:
- FileSystemDataset: 由目录结构中的各种文件构成的数据集。
- InMemoryDataset: 由已存在于内存中的多个记录批次构成的数据集。
- UnionDataset: 由一个或多个其他数据集作为子集构成的数据集。
当我们谈论数据集的片段时,是指数据集中存在的并行化单位。对于 InMemoryDataset,这将是单个记录批次。通常,对于基于文件系统的数据集,"片段"将是单个文件。但在 Parquet 文件等情况下,片段也可以是单个文件中的单行组、多个行组或其他多个记录批次的配置作为单个片段。让我们使用库中存在的工厂函数来创建我们的两个示例 Parquet 文件的数据集:
首先,设置我们的包含指令和函数签名:
c
#include <arrow/dataset/api.h>
namespace ds = arrow::dataset; // 方便起见
std::shared_ptr<arrow::Table> scan_dataset(
const std::shared_ptr<fs::FileSystem>& filesystem,
const std::shared_ptr<ds::FileFormat>& format,
const std::string& base_dir) {
在函数内部,我们首先设置 FileSelector
并创建工厂:
ini
fs::FileSelector selector;
selector.base_dir = base_dir;
auto factory = ds::FileSystemDatasetFactory::Make(
filesystem, selector, format,
ds::FileSystemFactoryOptions()).ValueOrDie();
目前,我们不会对工厂做任何有趣的事情;我们只会从中获取数据集。获取到数据集后,我们可以循环遍历所有片段并打印出来:
c
auto dataset = factory->Finish().ValueOrDie();
auto fragments = dataset->GetFragments().ValueOrDie();
for (const auto& fragment : fragments) {
std::cout << "Found Fragment: "
<< (*fragment)->ToString()
<< std::endl;
}
最后,我们创建一个扫描器,通过扫描数据集获取一个表:
scss
auto scan_builder = dataset->NewScan().ValueOrDie();
auto scanner = scan_builder->Finish().ValueOrDie();
return scanner->ToTable().ValueOrDie();
} // 结束 scan_dataset 函数
要编译此函数,请确保已安装 arrow-dataset
库,因为它在大多数软件包管理器中是单独打包的。然后,你可以使用以下命令:
css
$ g++ -o sampledataset dataset.cc $(pkg-config --cflags --libs arrow-dataset)
使用 pkg-config
将为编译文件附加必要的包含和链接标志,其中包括 arrow、parquet 和 arrow-dataset 库。此外,请记住,按照目前的写法,代码片段将整个数据集读取到内存中作为一个单一的表。这对于我们的示例数据集是可以的,但如果你在非常大的数据集上尝试,则可能更希望流式传输批次,而不是将整个数据集读取到内存中。
该函数还将文件格式作为参数传入。在这种情况下,我们将使用 ParquetFileFormat
,如下所示:
ini
auto format = std::make_shared<ds::ParquetFileFormat>();
从那里,你可以根据需要设置特定选项自定义格式,然后将其传递到函数中并与 Dataset 类一起使用。其他可用的格式如下:
arrow::dataset::CsvFileFormat
arrow::dataset::IpcFileFormat
arrow::dataset::OrcFileFormat
arrow::dataset::JsonFileFormat
最后一点要记住的是,创建 arrow::dataset::Dataset
对象并不会自动开始读取数据。它只会根据提供的选项遍历目录,以查找所有所需的文件,如果事先提供了文件列表,则可以避免这一点。它还会通过查看第一个找到的文件的元数据来推断数据集的模式。你也可以提供要使用的模式,或者告诉对象检查并解析所有文件中的模式,基于传递给工厂的 arrow::dataset::InspectOptions
。
好吧。现在我们有了数据集对象,我们可以利用优化的行和列过滤与投影功能。
以编程方式过滤数据
在之前的示例中,我们创建了一个扫描器并读取了整个数据集。这次,我们将先对构建器进行一些调整,给它一个过滤器以便在开始读取数据之前使用。我们还将使用 Project 函数来控制读取哪些列。由于我们使用的是 Parquet 文件,我们可以通过仅读取所需的列而不是读取所有列来减少 I/O 和内存使用;我们只需告诉扫描器这就是我们的需求。
在前一节中,我们了解了 Arrow Compute API,这是一个用于对 Arrow 格式数据执行各种操作和计算的库。它还包括用于定义复杂表达式的对象和功能,这些表达式引用字段并调用函数。这些表达式对象可以与扫描器结合使用,以定义简单或复杂的过滤条件。在深入探讨扫描器之前,让我们快速了解一下 Expression 类。
表达自己 - 一个快速的绕道
在处理数据集时,表达式可以是以下之一:
- 一个字面值或数据,可以是标量、数组,甚至是整个记录批次或数据表。
- 对输入数据单个、可能嵌套字段的引用。
- 包含由其他表达式定义的参数的计算函数调用。
仅凭这三种构建块,消费者可以灵活地定制其逻辑和计算以适应他们的输入。这也允许对表达式进行静态分析,以在执行之前简化它们,从而优化计算。我们之前构建的示例数据集将作为我们的输入数据,这意味着我们可以引用的字段命名为 a、b 和 c。以下是几个示例:
首先,图 6.4 显示了来自输入的简单字段引用。我们引用名为 b 的列,因此我们的输出就是该列本身:
稍微复杂一点的是这个:图 6.5 显示了表达式 a < 4
。请注意,这个表达式是使用名为 less
的计算函数调用、一个字段引用和一个字面值作为参数构造的。由于该表达式是一个布尔表达式,输出是一个布尔数组,包含 true
或 false
,每个元素对应于输入列 a
中该索引的结果:
为了便于阅读和交互,一些最常用的计算函数提供了直接的 API 调用来创建表达式。这包括标准的二元比较,如 less
(小于)、greater
(大于)和 equal
(等于),以及布尔逻辑操作,如 and
(与)、or
(或)和 not
(非)。
使用表达式过滤数据
我们将基于用于扫描整个数据集的函数,通过添加过滤器来进行扩展。让我们创建一个与之前的 scan_dataset
函数具有相同签名的新函数,称之为 filter_and_select
。与之前不同的是,我们这次只读取一列,然后过滤掉一些行:
首先,将以下包含指令添加到文件中。我们需要它来使用表达式对象和计算选项:
arduino
#include <arrow/compute/api.h> // 通用计算
为了方便起见,我们之前将 arrow::dataset
命名空间简化为 ds
。现在,我们将对 arrow::compute
命名空间做类似的别名处理:
ini
namespace cp = arrow::compute;
我们的函数签名与之前的函数完全相同,只是使用了新的名称:
c
std::shared_ptr<arrow::Table> filter_and_select(
const std::shared_ptr<fs::FileSystem>& filesystem,
const std::shared_ptr<ds::FileFormat>& format,
const std::string& base_dir) {
函数的开头与构建工厂和数据集对象的过程相同,因此我在这里不再重复这些行。只需执行与 scan_dataset
函数相同的操作。(请参见"发现数据集片段"部分下的步骤 2。)
变化发生在我们创建 scan_builder
之后。我们可以告诉扫描器只对一个列进行物化,而不是获取数据集中的所有列。在这种情况下,我们只检索列 b:
ini
auto factory = ...
auto dataset = ...
auto scan_builder = dataset->NewScan().ValueOrDie();
ABORT_ON_FAIL(scan_builder->Project({"b"}));
另外,我们可以提供一个表达式和输出列名称的列表,而不是提供列列表,这类似于 SQL 的 SELECT 查询。上一行等价于执行 SELECT b FROM dataset
。下一行是添加 WHERE 子句的等效内容。你认为这将是什么样子?
less
ABORT_ON_FAIL(scan_builder->Filter(
cp::less(cp::field_ref("b"), cp::literal(4))));
你能想象如果使用 SQL 查询会是什么样子吗?在扫描构建器上添加这个 Filter
调用告诉扫描器在扫描时使用这个表达式来过滤数据。因此,它等同于在我们的数据集上执行类似于 SELECT b FROM dataset WHERE b < 4
的 SQL 查询。
然后,和之前一样,我们可以从扫描中检索并返回结果表:
scss
auto scanner = scan_builder->Finish().ValueOrDie();
return scanner->ToTable().ValueOrDie();
} // filter_and_select 函数结束
如果我们使用之前在图 6.5 中的相同示例数据集,我们的新输出应该是什么样子?如果你运行新的 filter_and_select
函数,结果是否符合你的预期?请查看以下输出,并仔细检查你的输出是否匹配:
markdown
b: int64
----
b:
[
[
3,
2,
1,
0,
]
]
尝试在调用过滤函数时使用不同的表达式,看看输出是否符合你的预期。如果过滤表达式引用了不在项目列表中的列,扫描器仍然会知道读取该列,以及为结果所需的任何其他列。接下来,我们将进行更多修改以操作结果列。
派生和重命名列(投影)
在我们的函数基础上,我们可以添加一些额外的复杂性,读取三个列,重命名一个列,并使用表达式获取一个派生列。再次保持相同的函数签名和结构;我们只修改利用 scan_builder
的行:
首先,我们将使用数据集的模式为每一列数据添加一个字段引用。通过从模式获取字段列表,我们不必将整个列表硬编码:
c
std::vector<std::string> names;
std::vector<cp::Expression> exprs;
for (const auto& field : dataset->schema()->fields()) {
names.push_back(field->name());
exprs.push_back(cp::field_ref(field->name()));
}
然后,我们添加一个新的输出列 b_as_float32
,该列只是将名为 b
的列转换为 32 位浮点数据类型。注意结构:我们创建一个由名为 cast
的函数调用构成的表达式,将字段引用作为唯一参数传递,并设置选项以告诉它应转换为哪种数据类型:
less
names.emplace_back("b_as_float32");
exprs.push_back(cp::call("cast", {cp::field_ref("b")},
cp::CastOptions::Safe(arrow::float32())));
接下来,我们创建另一个派生列 b_large
,该列将是一个布尔列,计算结果是返回 b
字段的值是否大于 1:
less
names.emplace_back("b_large");
// b > 1
exprs.push_back(cp::greater(cp::field_ref("b"),
cp::literal(1)));
最后,我们通过调用 Project
函数将这两个表达式和名称列表传递给扫描器:
scss
ABORT_ON_FAIL(scan_builder->Project(exprs, names));
在这之后,函数的其余部分与之前的示例相同,继续执行扫描并检索结果表。
你跟上了吗?你能想象与代码执行的操作等效的 SQL 风格查询是什么样的吗?我提到 SQL 是因为 Datasets API 的许多部分旨在语义上与对数据集执行 SQL 查询类似,甚至可以用来实现 SQL 引擎!
不要提前阅读,直到你尝试它!
认真地,先试一下。
好吧,以下是答案:
css
SELECT
a, b, c,
FLOAT(b) AS b_as_float32,
b > 1 AS b_large
FROM
dataset
这仅仅是 Datasets 和 Compute APIs 能做的事情的冰山一角,但应为实验提供一个有用的起点。尝试不同类型的过滤、列派生和重命名等操作。也许你有一些用例可以利用这些 API 来简化现有代码。试试看!
我们已经涵盖了 C++,现在,让我们回到 Python!
在Python中使用Datasets API
在你问之前:是的,Datasets API 在 Python 中也可用!让我们快速回顾一下我们刚才讨论的所有相同功能,但这次使用 PyArrow Python 模块而不是 C++。由于大多数数据科学家在工作中使用 Python,因此展示如何在 Python 中使用这些 API 以便于与现有工作流程和工具的集成是很有意义的。由于 Python 的语法比 C++ 更简单,代码更为简洁,因此我们可以在接下来的部分快速完成所有内容。
创建我们的示例数据集
我们可以开始创建一个类似于 C++ 示例的数据集,包含三列,但这次使用 Python:
python
>>> import pyarrow as pa
>>> import pyarrow.parquet as pq
>>> import pathlib
>>> import numpy as np
>>> import os
>>> base = pathlib.Path(os.getcwd())
>>> (base / "parquet_dataset").mkdir(exist_ok=True)
>>> table = pa.table({'a': range(10), 'b': np.random.randn(10), 'c': [1, 2] * 5})
>>> pq.write_table(table.slice(0, 5), base / "parquet_dataset/data1.parquet")
>>> pq.write_table(table.slice(5, 10), base / "parquet_dataset/data2.parquet")
在完成导入并设置写入 Parquet 文件的路径后,突出显示的行是我们实际构建示例数据集的地方。我们创建了一个包含 10 行和 3 列的表,然后将表拆分成 2 个 Parquet 文件,每个文件都有 5 行。使用 Python 的 pathlib 库使得代码非常便于移植,因为它会根据你所运行的操作系统处理文件路径的正确格式。
发现数据集
就像 C++ 库一样,Python 的数据集库可以通过传递起始搜索的基本目录来执行文件碎片的发现。或者,你可以传递单个文件的路径或文件路径的列表,而不是基本目录来进行搜索:
python
>>> import pyarrow.dataset as ds
>>> dataset = ds.dataset(base / "parquet_dataset", format="parquet")
>>> dataset.files
['<path>/parquet_dataset/data1.parquet', '<path>/parquet_dataset/data2.parquet']
<path>
应替换为你在上一个部分为示例数据集创建的目录的路径。
这也将推断数据集的模式,就像 C++ 库一样。默认情况下,它将通过读取数据集中的第一个文件来完成这一操作。你也可以手动提供一个模式作为参数传递给数据集函数:
vbnet
>>> print(dataset.schema.to_string(show_field_metadata=False))
a: int64
b: double
c: int64
最后,我们还有一个方法可以将整个数据集(或其部分)加载到 Arrow 表中,就像 C++ 中的 ToTable
方法一样:
less
>>> dataset.to_table()
pyarrow.Table
a: int64
b: double
c: int64
----
a: [[0,1,2,3,4],[5,6,7,8,9]]
b: [[[[0.4496826452924734,0.8187826910251114,0.293394262192757,-0.9355403104276471,-0.19315460805569024],[1.3384156936773497,-0.6000310181068441,0.16303489615416472,-1.4502901450565746,-0.5973093999335979]]
c: [[1,2,1,2,1],[2,1,2,1,2]]
虽然我们在前面的示例中使用了 Parquet 文件,但数据集模块确实允许在发现期间指定数据集的文件格式。
使用不同的文件格式
目前,Python 版本支持以下格式,提供一致的接口,无论底层数据文件格式如何:
- Parquet
- ORC
- Feather/Arrow IPC
- CSV
- JSON
未来还计划支持更多文件格式。通过将格式关键字参数传递给数据集函数,可以指定数据的格式。
使用 Python 进行列的过滤和投影
与 C++ 接口一样,PyArrow 库提供了一个表达式对象类型,用于配置数据列的过滤和投影。这些可以作为参数传递给数据集的 to_table
函数。为了方便起见,提供了操作符重载和辅助函数,以帮助构建过滤器和表达式。让我们尝试一下:
选择数据集的子集列将只从文件中读取请求的列:
lua
>>> dataset.to_table(columns=['a', 'c'])
pyarrow.Table
a: int64
c: int64
----
a: [[0,1,2,3,4],[5,6,7,8,9]]
c: [[1,2,1,2,1],[2,1,2,1,2]]
filter
关键字参数可以接受一个布尔表达式,该表达式定义了匹配行的谓词。任何不匹配的行都将被排除在返回的数据之外。使用字段助手函数可以指定表中的任何列,无论它是否为分区列或是否在投影列列表中:
lua
>>> dataset.to_table(columns=['a', 'b'], filter=ds.field('c') == 2)
pyarrow.Table
a: int64
b: double
----
a: [[1,3],[5,7,9]]
b: [[0.8187826910251114,-0.9355403104276471],[1.3384156936773497,0.16303489615416472,-0.5973093999335979]]
你还可以使用函数调用和布尔组合与表达式来创建复杂的过滤器并执行集合成员测试:
lua
>>> dataset.to_table(filter=(ds.field('a') > ds.field('b')) & (ds.field('a').isin([4,5])))
pyarrow.Table
a: int64
b: double
c: int64
----
a: [[4],[5]]
b: [[-0.19315460805569024],[1.3384156936773497]]
c: [[1],[2]]
列投影可以通过传递一个 Python 字典来定义,该字典将列名映射到用于派生列的表达式:
lua
>>> projection = {
... 'a_renamed': ds.field('a'),
... 'b_as_float32': ds.field('b').cast('float32'),
... 'c_1': ds.field('c') == 1,
... }
>>> dataset.to_table(columns=projection)
pyarrow.Table
a_renamed: int64
b_as_float32: float
c_1: bool
----
a_renamed: [[0,1,2,3,4],[5,6,7,8,9]]
b_as_float32: [[0.44968265,0.8187827,0.29339427,-0.9355403,-0.1931546],[1.3384157,-0.600031,0.1630349,-1.4502902,-0.5973094]]
c_1: [[true,false,true,false,true],
[false,true,false,true,false]]
到目前为止,所有示例都涉及读取整个数据集并执行投影或过滤,但我们希望能够处理可能无法完全放入内存的非常大的数据集,对吗?C++ 和 Python 数据集 API 都支持这些类型的工作流的流式和迭代读取,并允许在不一次加载整个数据集的情况下处理数据。接下来,我们将涵盖使用流式 API。
流式结果
你会记得在本章开始时,在"查询多文件数据集"部分,我提到过当你有多个文件且数据集可能太大无法一次性放入内存时,这就是解决方案。到目前为止,我们看到的示例使用了 ToTable
函数将结果完全物化为一个单一的 Arrow 表。如果结果太大无法一次性放入内存,这显然是行不通的。除了我们一直调用的 ToTable
(C++)或 to_table
(Python)函数外,扫描器还暴露了返回迭代器的函数,用于从查询中流式传输记录批。
为了演示流式处理,我们将使用 Ursa Labs 托管的公共 AWS S3 存储桶,该存储桶包含约 10 年的 NYC 出租车行程记录数据,格式为 Parquet。数据集的 URI 是 s3://ursa-labs-taxi-data/
。即使在 Parquet 格式中,那里的数据总大小也约为 37 GB,显著大于大多数人的计算机可用内存。我们将使用 C++ 和 Python 对这个数据集进行一些操作,所以让我们开始吧!
首先,让我们看看这个非常大的数据集中有多少文件和行。我们先尝试 Python。让我们使用 Jupyter 或 IPython,这样我们就可以使用 %time
来计时操作:
css
In [0]: import pyarrow.dataset as ds
In [1]: %%time
...: dataset = ds.dataset('s3://ursa-labs-taxi-data/')
...: print(len(dataset.files))
...: print(len(dataset.count_rows()))
输出如下:
css
125
1547741381
Wall Time: 5.63 s
使用 Python 数据集库,我们可以看到该数据集中有 125 个文件,并且能够在大约 5.6 秒内计算出 37 GB 的 125 个文件中超过 15 亿行的数量。对于从我的笔记本电脑访问 S3 来说,这个速度并不差。现在,使用 C++,我们首先将计时数据集发现以查找所有文件。让我们先处理包含指令:
arduino
#include <arrow/filesystem/api.h>
#include <arrow/dataset/api.h>
#include "timer.h" // 位于本书的 GitHub 存储库中
#include <memory>
#include <iostream>
然后,为了方便,我们设置命名空间别名:
ini
namespace fs = arrow::fs;
namespace ds = arrow::dataset;
在我们使用 S3 接口之前,我们需要通过调用名为 InitializeS3
的函数来初始化 AWS S3 库:
ini
fs::InitializeS3(fs::S3GlobalOptions::Defaults());
auto opts = fs::S3Options::Anonymous();
opts.region = "us-east-2";
使用 S3FileSystem
的工作方式与之前示例中的本地文件系统相同,使得工作起来非常简单:
ini
std::shared_ptr<ds::FileFormat> format =
std::make_shared<ds::ParquetFileFormat>();
std::shared_ptr<fs::FileSystem> filesystem =
fs::S3FileSystem::Make(opts).ValueOrDie();
fs::FileSelector selector;
selector.base_dir = "ursa-labs-taxi-data";
selector.recursive = true; // 检查所有子目录
通过将 recursive
标志设置为 true
,数据集 API 的发现机制将适当地遍历 S3 存储桶中的键,找到各种 Parquet 文件。让我们创建我们的数据集并计时:
c
std::shared_ptr<ds::DatasetFactory> factory;
std::shared_ptr<ds::Dataset> dataset;
{
timer t; // 见 timer.h,
// 在析构时将打印经过的时间
factory =
ds::FileSystemDatasetFactory::Make(filesystem,
selector, format,
ds::FileSystemFactoryOptions()).ValueOrDie();
dataset = factory->Finish().ValueOrDie();
}
请记住,代码片段中的便利计时器对象将在其构造和出作用域之间输出时间量,直接写入终端。编译并运行此代码后,输出表明发现数据集的 125 个文件花费了 1,518 毫秒。然后,我们可以添加调用以计算行数,如我们在 Python 中所做的:
scss
auto scan_builder = dataset->NewScan().ValueOrDie();
auto scanner = scan_builder->Finish().ValueOrDie();
{
timer t;
std::cout << scanner->CountRows().ValueOrDie()
<< std::endl;
}
我们得到了同样的 15.4 亿行计数,这次只花费了 2,970 毫秒,或 2.9 秒,而 Python 则花费了 5.6 秒。诚然,这个计算很简单。它只需要检查每个 Parquet 文件中的元数据以获取行数,然后将它们加总在一起。它需要读取的数据量实际上非常少。
让我们尝试一些比仅列出总行数更有趣的内容。如何获取整个数据集的平均乘客人数?好的,这不是一个复杂的表达式计算。但关键是展示如何轻松做到这一点以及它可以执行得多快。你认为使用我的笔记本电脑执行此计算需要多长时间?没有计算集群,仅仅是我在家庭网络上的一台笔记本电脑。记住,这不仅仅是简单的"计算列的平均值"。看看图 6.6,这是我们要做的视觉表示。它基本上是一个管道:
让我们一步一步来:
- 数据集扫描器将异步地从每个文件中只读取
passenger_count
列,并将数据分割成可配置的批次。 - 批次将以流式方式处理,这样我们就不会一次性将整个数据集加载到内存中。
- 对于每个从流中获取的批次,我们将使用计算库计算数据的总和并将其添加到一个运行总数中。我们还会跟踪看到的总行数。
- 一旦没有更多的批次,我们就可以使用计算得出的值来计算平均值。
你觉得写这个会有多复杂?你认为运行需要多长时间?准备好惊讶了吗?
首先,我们将在之前创建的数据集对象中用 Python 来实现:
ini
In [2]: import pyarrow.compute as pc
In [3]: scanner = dataset.scanner(
columns=['passenger_count'], use_async=True)
In [4]: total_passengers = 0
In [5]: total_count = 0
In [6]: %%time
...: for batch in scanner.to_batches():
...: total_passengers += pc.sum(batch.column('passenger_count')).as_py()
...: total_count += batch.num_rows
...: mean = total_passengers / total_count
输出结果如下:
less
Wall time: 2min 37s
In [7]: mean
1.669097621032076
很简单!在一台笔记本电脑上运行这个计算跨越超过 15 亿行的平均值只花费了大约 2.5 分钟。说实话,这并不差。经过一些测试,发现瓶颈在于与 S3 的连接,需要在网络上传输请求的几GB数据,而不是计算。
接下来是 C++ 版本。只需在扫描器对象上使用不同的函数即可。创建工厂和数据集对象与之前所有的 C++ 示例相同,所以让我们创建我们的扫描器:
scss
#include <atomic> // 新头文件
...
// 创建工厂和数据集的方式与之前相同
{
timer t;
auto scan_builder = dataset->NewScan().ValueOrDie();
scan_builder->BatchSize(1 << 28); // 默认是 1 << 20
scan_builder->UseThreads(true);
scan_builder->Project({"passenger_count"});
auto scanner = scan_builder->Finish().ValueOrDie();
}
设置好扫描器后,我们将向扫描器的 Scan
方法传递一个 Lambda 函数,该函数将在接收记录批时异步调用。由于这是在一个线程池中进行的,可能会同时处理,因此我们使用 std::atomic<int64_t>
来避免任何竞争条件:
less
std::atomic<int64_t> passengers(0), count(0);
ABORT_ON_FAIL(scanner->Scan(
[&](ds::TaggedRecordBatch batch) -> arrow::Status {
ARROW_ASSIGN_OR_RAISE(auto result,
cp::Sum(batch.record_batch->GetColumnByName("passenger_count")));
passengers += result.scalar_as<arrow::Int64Scalar>().value;
count += batch.record_batch->num_rows();
return arrow::Status::OK();
}));
回顾我们编写的 Python 版本,你可能会想知道为什么那里不需要任何同步。这是因为 scanner.to_batches
是一个生成器,产生记录批的流。我们不可能同时处理多个记录批。在前面的 C++ 代码片段中,我们向扫描器传递一个回调 Lambda,它可能会从多个线程同时调用。通过这种多线程方法,以及我们可能同时处理多个记录批,std::atomic
确保一切都能正确处理。
最后,一旦扫描完成,我们就可以计算结果并输出:
c
double mean =
double(passengers.load())
/ double(count.load());
std::cout << mean << std::endl;
} // 计时器块结束
在我本地的笔记本电脑上运行这个代码,平均运行时间约为 1 分 53 秒,比 Python 版本快了大约 26%。
如果你回顾图 6.6,你会看到 AWS S3 存储桶中键的结构,以及它们是如何按年份和月份分区到文件夹中的。好吧,我们可以利用这一点来加速任何基于这些字段的过滤!你只需使用 Arrow Datasets API 中包含的分区设置,当然,这就是我们接下来要做的事情。
使用分区数据集
在前面的示例中,我们将文件从扁平目录转移到了分区的子目录。如果我们在扫描器中放入过滤表达式,它将不得不打开每个文件并可能首先读取整个文件,然后过滤数据。通过将数据组织到分区的嵌套子目录中,我们可以定义一种分区布局,其中子目录的名称提供有关存储数据子集的信息。这样做使我们可以完全跳过加载那些我们知道不会匹配过滤条件的文件。
让我们再次查看 S3 上出租车数据集的分区结构:
bash
2009/
01/data.parquet
02/data.parquet
...
2010/
01/data.parquet
02/data.parquet
...
...
这可能很明显,但使用这种结构意味着我们可以通过约定假设,位于 2009/01/data.parquet
的文件只会包含 year == 2009
和 month == 1
的数据。通过使用配置,我们甚至可以为数据提供表示年份和月份的伪列,即使这些列在文件中并不存在。让我们先尝试一下 C++ 的实现;首先初始化 S3 文件系统并创建选择器对象:
c
ds::FileSystemFactoryOptions options;
options.partitioning =
ds::DirectoryPartitioning::MakeFactory({"year", "month"});
auto factory = ds::FileSystemDatasetFactory::Make(
filesystem, selector, format, options).ValueOrDie();
auto dataset = factory->Finish().ValueOrDie();
auto fragments = dataset->GetFragments().ValueOrDie();
for (const auto& fragment : fragments) {
std::cout << "Found Fragment: "
<< (*fragment)->ToString() << std::endl;
std::cout << "Partition Expression: "
<< (*fragment)->partition_expression().ToString()
<< std::endl;
}
与之前构建数据集的方式相比,突出显示的行是添加的内容。DirectoryPartitioning
工厂对象用于将分区选项添加到工厂,表示文件路径的部分顺序以及它们应该被称为什么。这甚至为创建的数据集的模式添加了名为 year
和 month
的列,从值中推断这些列的类型。如果我们打印出模式,可以确认这一点;只需添加一行打印出模式:
c
std::cout << dataset->schema()->ToString() << std::endl;
我们可以在输出中看到:
vbnet
vendor_id: string
...
year: int32
month: int32
在这种情况下,数据集根据我们 year
和 month
列的值推断出它们是 32 位整数字段。如果你愿意,也可以通过提供完整的 Arrow 模式对象,直接指定类型:
less
options.partitioning = std::make_shared<ds::DirectoryPartitioning>(
arrow::schema({arrow::field("year", arrow::uint16()),
arrow::field("month", arrow::int8())})
);
当然,我们可以在 Python 中做到这一点,如下所示:
css
In [3]: part = ds.partitioning(field_names=["year", "month"])
In [4]: part = ds.partitioning(pa.schema([("year", pa.uint16()), ("month", pa.int8())])) # 或者指定类型
In [5]: dataset = ds.dataset('s3://ursa-labs-taxi-data/', partitioning=part, format='parquet')
In [6]: for fragment in dataset.get_fragments():
...: print("Found Fragment:", fragment.path)
...: print("Partition Expression:", fragment.partition_expression)
在 C++ 和 Python 两种情况下,我们的代码片段都提供了相同的输出文件和分区表达式列表:
sql
Found Fragment: ursa-labs-taxi-data/2009/01/data.parquet
Partition Expression: ((year == 2009) and (month == 1))
Found Fragment: ursa-labs-taxi-data/2009/02/data.parquet
Partition Expression: ((year == 2009) and (month == 2))
...
一个常见的分区方案是 Apache Hive 使用的分区方案,Hive 是一个与 Hadoop 集成的 SQL 类接口。Hive 分区方案在路径中包含字段名称及其值,而不仅仅是值。例如,对于我们刚才使用的出租车数据,文件路径将是 /year=2009/month=1/data.parquet
,而不是 /2009/01/data.parquet
。由于这种方案因 Apache Hive 的普及而非常常见,Arrow 库已经提供了方便的类,用于指定 Hive 分区方案,无论你是在使用 C++ 还是 Python,如下所示:
对于 C++:
less
options.partitioning = ds::HivePartitioning::MakeFactory();
// 或者指定模式来定义类型
options.partitioning = std::make_shared<ds::HivePartitioning>(
arrow::schema({arrow::field("year", arrow::uint16()),
arrow::field("month", arrow::int8())})
);
对于 Python:
ini
In [7]: dataset = ds.dataset('s3://ursa-labs-taxi-data/', partitioning='hive', format='parquet') # 推断类型
In [8]: part = ds.partitioning( # 或者指定模式
...: pa.schema([("year", pa.uint16()), ("month", pa.int8())]),
...: flavor='hive') # 并标记为使用 Hive 风格
如果我们在定义了分区后扫描数据集,并对我们分区的字段之一或两个字段使用过滤条件,我们甚至不会读取那些分区表达式与过滤条件不匹配的文件。我们可以通过使用过滤器和检查操作耗时来确认这一点。让我们基于过滤器计数行,以确认这一点:
ini
In [9]: dataset = ds.dataset('s3://ursa-labs-taxi-data', partitioning=part, format='parquet')
In [10]: %time dataset.count_rows(filter=ds.field('year') == 2012)
Wall time: 421 ms
Out[10]: 178544324
在第一段代码中,我们计算满足 year
字段等于 2012 的过滤条件的行数。由于分区的存在,这个过程非常快速且简单,因为我们只需读取 2012 目录及其子目录下每个文件的 Parquet 元数据,并将每个文件中的行数相加,仅需 421 毫秒。
在第二段代码中,我们使用两个时间戳标量值来创建过滤表达式。然后我们计算 pickup_at
字段大于或等于 2012 年 1 月 1 日且小于 2013 年 1 月 1 日的行数。由于这不是一个分区字段,我们必须打开并读取数据集中的每个文件,以检查行以找出哪些行匹配过滤条件。需要指出的是,由于 Parquet 文件可以维护其列的统计信息,例如最小值和最大值,这并不像检查每一行那样昂贵。我们不必读取所有 15 亿行来获得答案,而是可以使用统计信息跳过大量行。不幸的是,这仍意味着我们必须至少从数据集中读取每个文件的元数据,而无法像我们在分区字段上做的那样完全跳过文件。这额外的工作导致了获取相同答案的时间达到近 21 秒,比使用分区字段慢了近 49 倍。
尝试在 C++ 库中进行相同的实验!
现在,这些在读取数据时都很好用,但在写入数据时又如何呢?Datasets API 也简化了写入数据的过程,甚至可以为你进行分区。
写入分区数据
处理数据有两个方面:查询现有数据和写入新数据。这两者都是非常重要的工作流程,因此我们希望简化这两者。在前面的部分,我们只关注了读取和查询已存在的数据集。写入数据集和写入单个表一样简单。(记得吗?我们在第二章《处理关键的 Arrow 规格》中做过这件事。)
如果你已经在内存中有一个表或记录批次,那么你可以轻松地写入数据集。你只需提供一个目录和文件的命名模板,而不是文件名。和往常一样,Python 的语法更简单,看看下面的例子:
ini
In [15]: base = pathlib.Path(...) # 使用数据集的基础路径
In [16]: root = base / "sample_dataset"
In [17]: root.mkdir(exist_ok=True)
In [18]: ds.write_dataset(table, root, format='parquet')
这将把一个名为 part-0.parquet
的文件写入指定的目录。如果你想以不同的名称命名它,可以传递 basename_template
关键字参数,字符串描述文件名的模板。该模板的语法相当简单;它只是一个包含 {i}
的字符串。{i}
字符会被一个整数替换,该整数会在文件根据其他选项(如分区配置和最大文件大小)被写入时自动递增。请记住,{i}
值对于每个分区目录都是特定的,每个分区的起始值都是 0。如果你传递与读取相同的分区选项,write_dataset
函数会自动为你进行数据分区:
ini
In [19]: part = ds.partitioning(pa.schema([('foobar', pa.int16())]),
... : flavor='hive')
In [20]: ds.write_dataset(table, root, format='parquet',
... : partitioning=part)
它将自动为分区创建目录并相应地写入数据,使用的仍然是可以在写入单个表时传递的 basename_template
选项来命名每个分区的单个文件。所有这些功能当然也可以通过 C++ 库获得:
ini
auto dataset = std::make_shared<ds::InMemoryDataset>(table);
auto scanner_builder = dataset->NewScan().ValueOrDie();
auto scanner = scanner_builder->Finish().ValueOrDie();
auto format = std::make_shared<ds::ParquetFileFormat>();
ds::FileSystemDatasetWriteOptions write_opts;
write_opts.file_write_options = format->DefaultWriteOptions();
write_opts.filesystem = filesystem;
write_opts.base_dir = base_path;
write_opts.partitioning = std::make_shared<ds::HivePartitioning>(
arrow::schema({arrow::field("year", arrow::uint16()),
arrow::field("month", arrow::int8())})
);
write_opts.basename_template = "part{i}.parquet";
ABORT_ON_FAIL(ds::FileSystemDataset::Write(write_opts, scanner));
你注意到 Write
函数接受 scanner 吗?你能想到为什么会这样吗?
我们讨论的是可能非常大的数据集,可能无法一次全部装入内存。因为 Write
函数以 scanner 作为参数,你可以非常轻松地从扫描器将数据流式传输到写入器。这个扫描器可以使用过滤器和投影进行配置,以定制你正在写入的记录批次流。数据集编写器的 Python 版本也可以接受扫描器作为第一个参数,而不是表。
我们还可以以不同的格式而非仅仅是 Parquet 来写入数据,并结合过滤数据。例如,我们可以将出租车数据集的一部分写出为 CSV 文件,只需对前一个示例做以下代码更改。
和以前一样,我们调用 fs::InitializeS3
并使用分区选项、文件选择器和格式创建 FilesystemDatasetFactory
。然而,这次我们将添加一些选项来控制它推断模式的方式。我们在发现数据集片段部分提到过 InspectOptions
,当时我们谈到如何发现模式。默认情况下,工厂将只读取一个片段以确定数据集的模式。
在本节开头的"处理分区数据集"部分,我们打印出模式以查看年份和月份字段的添加。如果你再次运行那段代码,请注意名为 rate_code_id
的字段的类型:
yaml
...
rate_code_id: null
...
这是仅通过读取第一个片段推断出的模式。现在,让我们通过添加高亮的行,增加必要的选项以检查所有片段并验证模式:
ini
auto factory = ds::FileSystemDatasetFactory::Make(
filesystem, selector,
format, options).ValueOrDie();
ds::FinishOptions finish_options;
finish_options.validate_fragments = true;
finish_options.inspect_options.fragments =
ds::InspectOptions::kInspectAllFragments;
auto dataset = factory->Finish(finish_options);
通过设置选项以检查所有片段并验证它们,工厂将编排一个共同的模式。如果我们现在打印数据集的模式,请再次注意 rate_code_id
字段:
vbnet
...
rate_code_id: string
...
数据集工厂检查了所有片段并创建了一个合并的模式。某些片段的 rate_code_id
字段类型为 null,其他片段则为 string。由于我们可以轻松地将 null 类型转换为仅包含 null 值的字符串数组,因此将其标识为字符串列是有效的合并模式。如果你之前尝试处理整个数据集,可能会遇到类似这样的错误:
vbnet
Unsupported cast from string to null using function cast_null
如果你考虑一下,这很有道理。如果我们的数据集的模式对该列有 null 类型,当我们遇到具有 string 类型的片段时,它会尝试转换这些值。虽然我们可以轻松将 null 数组转换为仅包含 null 值的字符串数组,但反过来将具有值的字符串数组转换为 null 数组则是无效的。通过花时间检查我们的片段并生成合并的模式,我们解决了这个问题!现在,要将我们的数据写出为 CSV 文件,我们所需要做的就是使用 CsvFileFormat
:
ini
auto format = std::make_shared<ds::CsvFileFormat>();
ds::FileSystemDatasetWriteOptions write_opts;
auto csv_write_options =
std::static_pointer_cast<ds::CsvFileWriteOptions>(
format->DefaultWriteOptions());
csv_write_options->write_options->delimiter = '|';
write_opts.file_write_options = csv_write_options;
...
ABORT_ON_FAIL(ds::FileSystemDataset::Write(write_opts, scanner));
请注意高亮的行。在我们使用 CsvFileFormat
类创建默认选项之后,可以设置各种选项,例如写入时使用的分隔符。在这种情况下,我们将数据写出为管道分隔的文件。对于使用 IpcFileFormat
或 OrcFileFormat
写入这些相应数据格式的数据也是如此。
练习
编写一个函数,以在不将整个数据集一次性加载到内存中的情况下重新分区一个大型数据集。尝试在 C++ 和/或 Python 中实现,以熟悉数据集的写入。玩玩批量大小和其他选项,看看如何调整以提高性能。
最后,数据集编写器还提供了可以自定义的挂钩,以允许检查所写文件并对这些信息执行你想做的任何操作。在 Python 中,你可以提供一个 file_visitor
函数,将写入的文件作为参数传递给 write_dataset
函数。在 C++ 中,FileSystemDatasetWriteOptions
对象有两个成员 writer_pre_finish
和 writer_post_finish
,它们分别是一个 Lambda 函数,在每个 FileWriter
完成文件之前和之后被调用,并接受一个指向 FileWriter
的指针。
另一个练习
尝试编写一个分区数据集,并使用通过 file_visitor
或 writer_pre_finish/writer_post_finish
函数公开的写入挂钩构建所写文件的索引及其元数据。
数据集的选项非常丰富,从不同的文件格式到使用 UnionDataset
与编译成一个接口的多个物理数据集进行交互。你还可以操作文件格式对象,以为你的读取和写入设置特定格式的选项。无论你是读取、写入,还是使用分区或非分区数据,Datasets API 都是许多用例的极其有用的构建块。它也特别适合对非常大数据集的临时探索,提供了在与计算 API 和其他 Arrow 用法结合时选择交互的巨大灵活性。在继续之前,去尝试将不同的计算和数据处理方式结合在一起,使用这些 API。真的,去试试!这很有趣!
将所有内容连接在一起
最后一件事!在上一章《Acero:一个流式 Arrow 执行引擎》中,我们的示例使用单个 Parquet 文件作为流式记录批次的源。我们刚刚介绍了使用数据集对象来提供流式数据,因此让我们将这两个概念结合起来!
假设你已经按照之前的示例设置了数据集对象,让我们像以前一样计算 passenger_count
列的均值,但这次使用 Acero 来定义执行计划。
首先,和之前一样,我们需要设置扫描器选项:
rust
auto options = std::make_shared<ds::ScanOptions>();
options->use_threads = true;
options->projection = cp::project(
{cp::field_ref("passenger_count")},
{"passenger_count"});
// 也可以更改批量大小和预读选项
ac::Declaration plan = ac::Declaration::Sequence({
{"scan", ds::ScanNodeOptions{dataset, std::move(options)}},
{"aggregate", ac::AggregateNodeOptions{
{{"mean", nullptr, "passenger_count",
"mean(passenger_count)"}}}}});
ARROW_ASSIGN_OR_RAISE(auto result,
ac::DeclarationToTable(std::move(plan)));
std::cout << "Results: " << result->ToString() << std::endl;
注意高亮的行,这里传递数据集和选项以创建执行的 "scan" 节点。这将与任何数据集对象一起工作,包括分区数据集。这比自己处理原子操作并计算均值要简单得多,对吧?
你还可以利用之前使用的 FileSystemDatasetWriteOptions
来利用 "write" 节点,并构建一个执行计划,该计划将读取数据集,转换或计算你所需的内容,然后按你的需要写出一个分区数据集。但我将把这留作练习,让你利用 Arrow 文档进行尝试:Arrow Documentation。
总结
通过将这些不同的组件(C 数据 API、计算 API、数据集 API 和 Acero)组合在一起,并在其上构建基础设施,任何人都应该能够立即创建一个基本的查询和分析引擎,并且性能相当不错。提供的功能可以抽象掉与不同文件格式交互和处理数据来源的繁琐工作,从而提供一个单一的接口,让你能够快速开始构建所需的特定逻辑。再次强调,这些组件之所以能够如此轻松地互操作,正是因为它们都是基于 Arrow 作为底层格式构建的,而 Arrow 对于这些操作特别高效。不仅如此,由于 Arrow 的标准化,这个堆栈中的各个部分可以互换并与其他现有项目(例如 DuckDB 或 Apache DataFusion)组合使用,这些项目都可以使用 Arrow C 数据 API 进行输入和结果的通信。
那么,我们接下来要去哪里呢?
下一章是第七章《探索 Apache Arrow Flight RPC》。Arrow Flight 框架是一种高效的网络数据传输方式,利用 Arrow 的 IPC 格式作为主要数据格式。在接下来的章节中,我们将构建一个 Flight 服务器和客户端,以可定制的方式传递数据。请准备好你的键盘,这将会很有趣......
好吧,至少我认为这很有趣。希望你也觉得有趣!