代号/parquet

基于Thrift的PHP实现,用于使用Apache Parquet格式

v0.7.1 2024-01-07 15:40 UTC

This package is auto-updated.

Last update: 2024-09-07 16:57:02 UTC


README

Build Status (Github Actions)

GitHub Workflow Status (with event) GitHub Workflow Status (with event)

Packagist Version Packagist PHP Version Support Packagist Downloads

这是PHP中第一个Parquet文件格式读取/写入实现,基于Apache基金会提供的Thrift源代码。代码和概念的大部分已从parquet-dotnet(见https://github.com/elastacloud/parquet-dotnethttps://github.com/aloneguid/parquet-dotnet)移植而来。因此,感谢Ivan Gavryliuk(https://github.com/aloneguid)。

此软件包允许您在不使用特殊外部扩展的情况下(除非您想使用特殊的压缩方法)读取和写入Parquet文件/流。它在核心功能方面与parquet-dotnet(几乎)100%兼容,通过PHPUnit完成。

重要

此存储库(以及Packagist上的相关软件包)是jocoon/parquet官方项目的延续。由于各种改进和关键错误修复,在此codename/parquet中,强烈建议不要使用旧软件包。

索引

前言

对于本软件包的一些部分,我不得不发明一些新的模式,因为我没有找到任何满足要求的实现。在大多数情况下,根本没有任何可用的实现。

一些亮点

  • GZIP流包装器(也写入头和校验和),用于与fopen()和类似函数一起使用
  • Snappy流包装器(Snappy压缩算法),用于与fopen()和类似函数一起使用
  • 指定/打开/包装一个资源ID的流包装器,而不是(或除文件路径或URI外)
  • TStreamTransport作为纯流Thrift数据的TTransport实现

背景

我开始开发这个库是因为没有PHP的实现。

在我公司,我们需要一个快速解决方案来将大量数据从数据库存档到一个仍然是可查询、可扩展(从模式角度来看)和容错性的格式。我们开始通过AWS DMS将实时“迁移”测试到S3,由于内存限制,在特定数量的数据上崩溃。它太侧重于数据库,而且很容易在以前的加载中意外删除数据。由于我们的体系结构高度依赖于SDS和平台无关,因此我不倾向于以数据库转储的1:1克隆方式存储数据。相反,我希望能够以我想要的方式动态地存储数据,就像DMS导出到S3一样。最后,由于上述原因,项目失败了。

但我无法将Parquet格式从我的脑海中摆脱...

排名第一的搜索结果(https://stackoverflow.com/questions/44780419/how-to-create-orc-or-parquet-files-from-php-code)看起来很有希望,似乎不需要太多努力就能实现PHP版本——但实际上,它确实需要一些(大约两周的非连续工作)。对我来说,作为一个PHP和C#开发者,parquet-dotnet是一个完美的起点——不仅因为它的基准测试非常令人信服。但我预计PHP实现不会达到这些性能水平,因为这只是一个初始实现,展示了原理。而且,之前没有人做过。

存在的理由

由于PHP在Web相关项目中占有很大份额,在日益增长的大数据应用和场景中,这是一个必须拥有的。就我个人动机而言,这是展示PHP(在物理上、虚拟上?)超越“脚本语言”声誉的一种方式。我认为——至少我希望——有些人会从这个包及其传达的信息中受益。不仅仅是Thrift对象。这是一个双关语。

要求

您需要几个扩展才能充分利用这个库。

  • bcmath(今天,这应该是一个必备条件)
  • gmp(用于处理任意大的整数——以及间接的大小数!)
  • zlib(用于GZIP(解)压缩)
  • snappyhttps://github.com/kjdev/php-ext-snappy ——遗憾的是,尚未发布到PECL——您需要自己编译它——请参阅安装说明)

这个库最初是为PHP 7.3开发的,但它应该可以在PHP > 7上运行,并在发布时进行测试。目前,由于一些DateTime问题,PHP 7.1和7.2的测试将失败。我会看看这个问题。PHP 7.3和7.4的测试完全通过。在写作的时候,8.0.0 RC2的表现也很好。

这个库高度依赖于

  • packaged/thrift 用于处理与Thrift相关对象和数据(apache/thrift的简化版)
  • pear/Math_BigInteger 用于处理二进制存储的任意精度小数(悖论,我知道)

截至v0.2,我已经转向使用无实现依赖的方法来使用读取器和写入器。现在,我们正在处理BinaryReader(接口)和BinaryWriter(接口)实现,它们抽象了底层机制。我注意到 mdurrant/php-binary-reader 确实太慢了。我只是不想为了尝试Nelexa的读取能力而重构一切。相反,我创建了上述两个接口来抽象各种提供二进制读取/写入的包。这最终导致了一种测试/基准测试不同实现的最优方式——也可以混合使用,例如,使用wapmorgan的包进行读取,而使用Nelexa的包进行写入。

截至v0.2.1,我自行实现了二进制读取器/写入器,因为没有实现满足性能要求。特别是对于写入,这个超轻量级的实现提供了Nelexa缓冲区的三倍性能。
* 故意为之,我喜欢这个词

范围内的替代第三方二进制读取/写入包

  • nelexa/buffer
  • mdurrant/php-binary-reader(仅读取)
  • wapmorgan/binary-stream

安装

通过composer安装此包,例如:

composer require codename/parquet

包含的 Dockerfile 可以让您了解所需的系统要求。最重要的事情是克隆并安装 php-ext-snappy。在写作的时候,它 尚未发布到PECL

...
# NOTE: this is a dockerfile snippet. Bare metal machines will be a little bit different

RUN git clone --recursive --depth=1 https://github.com/kjdev/php-ext-snappy.git \
  && cd php-ext-snappy \
  && phpize \
  && ./configure \
  && make \
  && make install \
  && docker-php-ext-enable snappy \
  && ls -lna

...

请注意:php-ext-snappy在Windows上编译和安装有些棘手,所以这只是一个关于在基于Linux的系统上安装和使用的简要信息。只要您不需要snappy压缩来读取或写入,就可以使用php-parquet,无需自己编译。

帮助工具使生活更轻松

我发现Mukunku的ParquetViewer(https://github.com/mukunku/ParquetViewer)是一个查看要读取的数据或验证Windows桌面机器上的一些内容的绝佳方式。至少,这有助于理解某些机制,因为它通过简单地将数据作为表格显示来提供视觉上的辅助。

API

使用方法几乎与parquet-dotnet相同。请注意,我们没有像C#中的using ( ... ) { }这样的代码块。因此,您必须确保自己关闭/释放未使用的资源,或者让PHP的GC通过其refcounting算法自动处理。这就是为什么我不使用像parquet-dotnet那样的析构器的原因。

一般性评论

由于PHP的类型系统与C#完全不同,我们必须在处理某些数据类型方面做一些补充。例如,PHP中的整数是可空的,而C#中的int不是。这是我仍不确定如何处理的问题。目前,我已经将int(PHP integer)设置为可空的——parquet-dotnet是作为不可空的来做的。您始终可以通过在您的DataField上手动设置->hasNulls = true;来调整此行为。此外,php-parquet使用双重方式来确定类型。在PHP中,原始数据有其自己的类型(整数、布尔值、浮点数/双精度浮点数等)。对于类实例(尤其是DateTime/DateTimeImmutable),get_type()返回的类型总是object。这就是为什么存在第二个属性DataTypeHandlers的原因,以匹配、确定和处理它:phpClass。

在撰写本文时,并不是所有由parquet-dotnet支持的DataType在这里都得到了支持。例如,我跳过了Int16、SignedByte等,但扩展到完全的二进制兼容性不应该太复杂。

目前,这个库提供了读取和写入parquet文件/流所需的核心功能。它不包括来自C#命名空间Parquet.Data.Rows的parquet-dotnet的Table、Row、Enumerators/辅助函数。

读取文件

use codename\parquet\ParquetReader;

// open file stream (in this example for reading only)
$fileStream = fopen(__DIR__.'/test.parquet', 'r');

// open parquet file reader
$parquetReader = new ParquetReader($fileStream);

 // Print custom metadata or do other stuff with it
 print_r($parquetReader->getCustomMetadata());  

// get file schema (available straight after opening parquet reader)
// however, get only data fields as only they contain data values
$dataFields = $parquetReader->schema->GetDataFields();

// enumerate through row groups in this file
for($i = 0; $i < $parquetReader->getRowGroupCount(); $i++)
{
  // create row group reader
  $groupReader = $parquetReader->OpenRowGroupReader($i);
  // read all columns inside each row group (you have an option to read only
  // required columns if you need to.
  $columns = [];
  foreach($dataFields as $field) {
    $columns[] = $groupReader->ReadColumn($field);
  }

  // get first column, for instance
  $firstColumn = $columns[0];

  // $data member, accessible through ->getData() contains an array of column data
  $data = $firstColumn->getData();

  // Print data or do other stuff with it
  print_r($data);
}

写入文件

use codename\parquet\ParquetWriter;

use codename\parquet\data\Schema;
use codename\parquet\data\DataField;
use codename\parquet\data\DataColumn;

//create data columns with schema metadata and the data you need
$idColumn = new DataColumn(
  DataField::createFromType('id', 'integer'), // NOTE: this is a little bit different to C# due to the type system of PHP
  [ 1, 2 ]
);

$cityColumn = new DataColumn(
  DataField::createFromType('city', 'string'),
  [ "London", "Derby" ]
);


// create file schema
$schema = new Schema([$idColumn->getField(), $cityColumn->getField()]);

// create file handle with w+ flag, to create a new file - if it doesn't exist yet - or truncate, if it exists
$fileStream = fopen(__DIR__.'/test.parquet', 'w+');

$parquetWriter = new ParquetWriter($schema, $fileStream);

// optional, write custom metadata
$metadata = ['author'=>'santa', 'date'=>'2020-01-01'];
$parquetWriter->setCustomMetadata($metadata);

// create a new row group in the file
$groupWriter = $parquetWriter->CreateRowGroup();

$groupWriter->WriteColumn($idColumn);
$groupWriter->WriteColumn($cityColumn);

// As we have no 'using' in PHP, I implemented finish() methods
// for ParquetWriter and ParquetRowGroupWriter

$groupWriter->finish();   // finish inner writer(s)
$parquetWriter->finish(); // finish the parquet writer last

简化用法

您还可以使用ParquetDataIteratorParquetDataWriter来处理高度复杂模式(例如嵌套数据)。尽管在撰写本文时处于实验阶段,但单元测试和集成测试表明,我们与Spark有100%的兼容性,因为其他大多数Parquet实现缺少某些功能或超级复杂嵌套的情况。

ParquetDataIteratorParquetDataWriter利用PHP类型系统的“动态性”和(关联)数组——这只有在完全使用无符号64位整数时才会停止——由于PHP的性质,这些只能部分支持。

读取

ParquetDataIterator以最内存高效的方式自动遍历所有行组和数据页,遍历parquet文件中的所有列。这意味着,它不会将所有数据集加载到内存中,而是在每个数据页/行组的基础上进行。

在底层,它利用了DataColumnsToArrayConverter的功能,最终执行有关定义和重复级别的所有“重活”。

use codename\parquet\helper\ParquetDataIterator;

$iterateMe = ParquetDataIterator::fromFile('your-parquet-file.parquet');

foreach($iterateMe as $dataset) {
  // $dataset is an associative array
  // and already combines data of all columns
  // back to a row-like structure
}

写入

相反,ParquetDataWriter允许您通过传递PHP关联数组数据(一次或分批)来写入Parquet文件(内存或磁盘)。内部,它使用ArrayToDataColumnsConverter来生成数据、字典、定义和重复级别。

use codename\parquet\helper\ParquetDataWriter;

$schema = new Schema([
  DataField::createFromType('id', 'integer'),
  DataField::createFromType('name', 'string'),
]);

$handle = fopen('sample.parquet', 'r+');
$dataWriter = new ParquetDataWriter($handle, $schema);

// add two records at once
$dataToWrite = [
  [ 'id' => 1, 'name' => 'abc' ],
  [ 'id' => 2, 'name' => 'def' ],
];
$dataWriter->putBatch($dataToWrite);

// we add a third, single one
$dataWriter->put([ 'id' => 3, 'name' => 'ghi' ]);

$dataWriter->finish(); // Don't forget to finish at some point.
fclose($handle); // You may close the handle, if you have to.

复杂数据

php-parquet 支持Parquet格式的完整嵌套功能。您可能会注意到,根据您嵌套的字段类型,您可能会“丢失”一些键名。这是有意为之。

  • 列表元素没有键 - 它们是数组元素。
  • Map的值字段在关联数组中没有键 - 键由Map的关键列提供。
  • 重复字段被隐式转换为类似数组的结构。

一般来说,以下是Parquet格式逻辑类型的PHP等效项。

此格式与Spark配置为 spark.conf.set("spark.sql.jsonGenerator.ignoreNullFields", False) 生成的JSON导出数据兼容。默认情况下,Spark在导出到JSON时将完全删除null值。

请注意:所有这些字段类型可以在每个嵌套级别上设置为可空的或不可空的/必填的(影响定义级别)。一些可空性被用来表示空列表,并将它们与列表的null值区分开来。

use codename\parquet\helper\ParquetDataIterator;
use codename\parquet\helper\ParquetDataWriter;

$schema = new Schema([
  DataField::createFromType('id', 'integer'),
  new MapField(
    'aMapField',
    DataField::createFromType('someKey', 'string'),
    StructField::createWithFieldArray(
      'aStructField'
      [
        DataField::createFromType('anInteger', 'integer'),
        DataField::createFromType('aString', 'string'),
      ]
    )
  ),
  StructField::createWithFieldArray(
    'rootLevelStructField'
    [
      DataField::createFromType('anotherInteger', 'integer'),
      DataField::createFromType('anotherString', 'string'),
    ]
  ),
  new ListField(
    'aListField',
    DataField::createFromType('someInteger', 'integer'),
  )
]);

$handle = fopen('complex.parquet', 'r+');
$dataWriter = new ParquetDataWriter($handle, $schema);

$dataToWrite = [
  // This is a single dataset:
  [
    'id' => 1,
    'aMapField' => [
      'key1' => [ 'anInteger' => 123, 'aString' => 'abc' ],
      'key2' => [ 'anInteger' => 456, 'aString' => 'def' ],
    ],
    'rootLevelStructField' => [
      'anotherInteger' => 7,
      'anotherString' => 'in paradise'
    ],
    'aListField' => [ 1, 2, 3 ]
  ],
  // ... add more datasets as you wish.
];
$dataWriter->putBatch($dataToWrite);
$dataWriter->finish();

$iterateMe = ParquetDataIterator::fromFile('complex.parquet');

// f.e. write back into a full-blown php array:
$readData = [];
foreach($iterateMe as $dataset) {
  $readData[] = $dataset;
}

// and now compare this to the original data supplied.
// manually, by print_r, var_dump, assertions, comparisons or whatever you like.

性能

此包还提供了与parquet-dotnet相同的基准测试。以下是在我的机器上的结果。

* 在Windows 10机器上Docker化,并使用绑定挂载,这会减慢大多数高IOPS过程。
** 它似乎fastparquet或Python进行了一些内部缓存 - 第一次打开文件时的原始结果要好得多(约2'700ms)

总的来说,这些测试是在gzip压缩级别6下对php-parquet进行的。如果使用1(最小压缩)将大约减半,如果使用9(最大压缩)将几乎加倍。注意,后者可能不会产生最小的文件大小,但总是压缩时间最长。

编码风格

由于这是一个从完全不同的编程语言中部分移植的包,编程风格几乎是混乱的。我决定保留大部分的 casing(例如,$writer->CreateRowGroup() 而不是 ->createRowGroup()),以保持与parquet-dotnet的某种“视觉兼容性”。至少,这是从我的角度来看的理想状态,因为它使得在初始开发阶段比较和扩展变得容易。

致谢

一些代码部分和概念已从C#/.NET移植,请参阅

许可证

php-parquet遵照MIT许可证。请参阅文件LICENSE。

贡献

如果您愿意,请随意提交PR。作为一个业余的开源项目,贡献将有助于所有该包的用户,包括您自己。在创建PR和/或问题时,请应用一些常识,没有模板。