< 返回版块

2019-06-20 21:00    责任编辑:tk

标签:rust, c, cbindgen,php

来源:https://mnt.io/2018/10/29/from-rust-to-beyond-the-php-galaxy/

译注:原作者换工作到Wasmer,后续绑定相关文章没有再更新。

这篇博客文章是这一系列解释如何将Rust发射到地球以外的许多星系的文章的一部分:

今天将要探索的是PHP星系。这篇文章会解释什么是PHP,以及如何将任何的Rust程序编译为C进而制作PHP的原生扩展。

什么是PHP,为什么?

PHP 是:

流行的通用脚本语言,特别适合Web开发。从您的博客到世界上最流行的网站,PHP提供了快速、灵活和实用的功能。

令人遗憾的是,PHP多年来名声不佳,但是最近的版本(主要是从PHP 7.0开始的)引入了简洁的语言特性和许多清理优化,这些特性都被讨厌它的人过分忽略了。PHP也是一种快速脚本语言,并且非常灵活。PHP现在已经有了声明类型、特征、可变参数、闭包(带有显式范围!)、生成器等特性和强大的向后兼容能力。PHP的开发是由RFC主导的,过程开放、民主。Gutenberg项目是WordPress的一个新编辑器,因为Wordpress是用PHP编写的,很自然的我们需要一个PHP的原生扩展来解析Gutenberg博客格式。PHP是一种有规范的语言(意味着可以有不同的虚拟机实现方案)。最流行的虚拟机是Zend Engine, 其他虚拟机也存在,比如HHVM(但是PHP支持最近被放弃,转而支持它们自己的PHP fork,称为Hack)、PeachpieTagua VM(正在开发中)。在本文中,我们将为Zend Engine创建一个扩展。注意这个虚拟机是用C语言编写的,很棒的是我们已经在前面一篇文章登陆了C星系!

Rust 🚀 C 🚀 PHP

Rust to PHP

要将Rust解析器移植到PHP中,我们首先需要将它移植到C。这在上一节中已经完成。移植到C的结果就是两个文件: libgutenberg_post_parser.agutenberg_post_parser.h,分别为静态库和头文件。

从脚手架开始

PHP附带一个脚本来创建一个扩展框架模板或者说脚手架,叫做ext_skel.php。这个脚本可以从Zend引擎虚拟机的源代码找到(我们把它叫做php-src)。可以像这样调用脚本

$ cd php-src/ext/
$ ./ext_skel.php \
      --ext gutenberg_post_parser \
      --author 'Ivan Enderlin' \
      --dir /path/to/extension \
      --onlyunix
$ cd /path/to/extension
$ ls gutenberg_post_parser
tests/
.gitignore
CREDITS
config.m4
gutenberg_post_parser.c
php_gutenberg_post_parser.h

ext_skel.php建议执行以下步骤:

  • 重新编译PHP源代码的配置文件(在php-src的根目录运行./buildconf
  • 重新配置构建系统,启用扩展,这样:./configure --enable-gutenberg_post_parser
  • Make进行构建
  • 完成

但是我们的扩展很可能位于php-src树之外。所以我们将使用phpize, phpize是一个可执行文件,是随php一起安装的, 还有如php-cgi, phpdbg, php-config等。它允许根据已经编译好的php二进制文件编译扩展,这正好完美的满足了我们的需求!我们将这样使用它

$ cd /path/to/extension/gutenberg_post_parser

$ # Get the bin directory for PHP utilities.
$ PHP_PREFIX_BIN=$(php-config --prefix)/bin

$ # Clean (except if it is the first run).
$ $PHP_PREFIX_BIN/phpize --clean

$ # “phpize” the extension.
$ $PHP_PREFIX_BIN/phpize

$ # Configure the extension for a particular PHP version.
$ ./configure --with-php-config=$PHP_PREFIX_BIN/php-config

$ # Compile.
$ make install

在这篇文章中,我们将不展示我们所做的所有编辑,而是将重点放在扩展绑定上。所有的资料都可以在这里找到。下面是config.m4文件:

PHP_ARG_ENABLE(gutenberg_post_parser, whether to enable gutenberg_post_parser support,
[  --with-gutenberg_post_parser          Include gutenberg_post_parser support], no)

if  test "$PHP_GUTENBERG_POST_PARSER" != "no"; then
  PHP_SUBST(GUTENBERG_POST_PARSER_SHARED_LIBADD)

  PHP_ADD_LIBRARY_WITH_PATH(gutenberg_post_parser, ., GUTENBERG_POST_PARSER_SHARED_LIBADD)

  PHP_NEW_EXTENSION(gutenberg_post_parser, gutenberg_post_parser.c, $ext_shared)
fi

它做的事情基本上是这样的:

  • 在构建系统里面注册参数:--with-gutenberg_post_parser
  • 声明要编译的静态库以及扩展本身的源代码

我们必须添加libgutenberg_post_parser.agutenberg_post_parser.h文件在同一个目录下(符号链接是完美支持的),到这样的结构:

$ ls gutenberg_post_parser
tests/                       # from ext_skel
.gitignore                   # from ext_skel
CREDITS                      # from ext_skel
config.m4                    # from ext_skel (edited)
gutenberg_post_parser.c      # from ext_skel (will be edited)
gutenberg_post_parser.h      # from Rust
libgutenberg_post_parser.a   # from Rust
php_gutenberg_post_parser.h  # from ext_skel

扩展最核心的是gutenberg_post_parser.c文件。这个文件负责创建模块,并将我们的Rust码绑定到PHP。

模块/扩展

如前所述,我们来写gutenberg_post_parser.c文件。首先,include所有需要的东西:

#include "php.h"
#include "ext/standard/info.h"
#include "php_gutenberg_post_parser.h"
#include "gutenberg_post_parser.h"

最后一行include由Rust生成的gutenberg_post_parser.h 文件(更准确地说,是由cbindgen生成的,如果您不记得了,请查看前一集)。然后,我们必须决定要向PHP暴露什么API ?提醒一下,Rust解析器生成如下的AST定义:

pub enum Node<'a> {
    Block {
        name: (Input<'a>, Input<'a>),
        attributes: Option<Input<'a>>,
        children: Vec<Node<'a>>
    },
    Phrase(Input<'a>)
}

C版本AST和这个非常相似(具有更多的结构,但是思想几乎相同)。在PHP中,我们用以下结构

class Gutenberg_Parser_Block {
    public string $namespace;
    public string $name;
    public string $attributes;
    public array $children;
}

class Gutenberg_Parser_Phrase {
    public string $content;
}
function gutenberg_post_parse(string $gutenberg_post): array;

gutenberg_post_parse函数将输出一个对象数组,对象类型为Gutenberg_Parser_BlockGutenberg_Parser_Phrase即我们的AST。下面我们来声明这些类!

声明类

注意:后面的4个代码块不是本文的核心,它只是需要编写的代码,如果不打算编写一个PHP扩展,可以跳过它。

zend_class_entry *gutenberg_parser_block_class_entry;
zend_class_entry *gutenberg_parser_phrase_class_entry;
zend_object_handlers gutenberg_parser_node_class_entry_handlers;

typedef struct _gutenberg_parser_node {
    zend_object zobj;
} gutenberg_parser_node;

class_entry表示特定的类类型。 会有一个handlerclass_entry相关联。逻辑有点复杂。如果您需要更多详细信息,我建议您阅读PHP内部原理这本书。然后,让我们创建一个函数来即时处理这些对象

static zend_object *create_parser_node_object(zend_class_entry *class_entry)
{
    gutenberg_parser_node *gutenberg_parser_node_object;

    gutenberg_parser_node_object = ecalloc(1, sizeof(*gutenberg_parser_node_object) + zend_object_properties_size(class_entry));

    zend_object_std_init(&gutenberg_parser_node_object->zobj, class_entry);
    object_properties_init(&gutenberg_parser_node_object->zobj, class_entry);

    gutenberg_parser_node_object->zobj.handlers = &gutenberg_parser_node_class_entry_handlers;

    return &gutenberg_parser_node_object->zobj;
}

然后我们创建一个函数来释放这些对象。需要两步:通过调用析构函数来析构对象(在用户态),然后真正的释放它(在虚拟机中)

static void destroy_parser_node_object(zend_object *gutenberg_parser_node_object)
{
    zend_objects_destroy_object(gutenberg_parser_node_object);
}

static void free_parser_node_object(zend_object *gutenberg_parser_node_object)
{
    zend_object_std_dtor(gutenberg_parser_node_object);
}

然后初始化模块/扩展。初始化的过程中我们将在用户态创建类以及声明其属性等。

PHP_MINIT_FUNCTION(gutenberg_post_parser)
{
    zend_class_entry class_entry;

    // Declare Gutenberg_Parser_Block.
    INIT_CLASS_ENTRY(class_entry, "Gutenberg_Parser_Block", NULL);
    gutenberg_parser_block_class_entry = zend_register_internal_class(&class_entry TSRMLS_CC);

    // Declare the create handler.
    gutenberg_parser_block_class_entry->create_object = create_parser_node_object;

    // The class is final.
    gutenberg_parser_block_class_entry->ce_flags |= ZEND_ACC_FINAL;

    // Declare the `namespace` public attribute,
    // with an empty string for the default value.
    zend_declare_property_string(gutenberg_parser_block_class_entry, "namespace", sizeof("namespace") - 1, "", ZEND_ACC_PUBLIC);

    // Declare the `name` public attribute,
    // with an empty string for the default value.
    zend_declare_property_string(gutenberg_parser_block_class_entry, "name", sizeof("name") - 1, "", ZEND_ACC_PUBLIC);

    // Declare the `attributes` public attribute,
    // with `NULL` for the default value.
    zend_declare_property_null(gutenberg_parser_block_class_entry, "attributes", sizeof("attributes") - 1, ZEND_ACC_PUBLIC);

    // Declare the `children` public attribute,
    // with `NULL` for the default value.
    zend_declare_property_null(gutenberg_parser_block_class_entry, "children", sizeof("children") - 1, ZEND_ACC_PUBLIC);

    // Declare the Gutenberg_Parser_Block.

    … skip …

    // Declare Gutenberg parser node object handlers.

    memcpy(&gutenberg_parser_node_class_entry_handlers, zend_get_std_object_handlers(), sizeof(gutenberg_parser_node_class_entry_handlers));

    gutenberg_parser_node_class_entry_handlers.offset = XtOffsetOf(gutenberg_parser_node, zobj);
    gutenberg_parser_node_class_entry_handlers.dtor_obj = destroy_parser_node_object;
    gutenberg_parser_node_class_entry_handlers.free_obj = free_parser_node_object;

    return SUCCESS;
}

如果你还在阅读,首先:谢谢,其次:恭喜!然后,有一个PHP_RINIT_FUNCTION 函数和PHP_MINFO_FUNCTION函数,这些函数已经由ext_skel.php脚本生成。对于模块定义和其他模块配置细节也是如此。

gutenberg_post_parse函数

现在我们将关注gutenberg_post_parse这个PHP函数。该函数需要一个字符串类型的参数,如果解析失败,则返回false,否则返回Gutenberg_Parser_Block 或者 Gutenberg_Parser_Phrase的对象数组。让我们写下来!注意,它是用PHP函数宏声明的。

PHP_FUNCTION(gutenberg_post_parse)
{
    char *input;
    size_t input_len;

    // Read the input as a string.
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &input, &input_len) == FAILURE) {
        return;
    }

在此步骤中,参数已被声明类型为字符串(“s”)。字符串值在input中,字符串长度在input_len中。下一步是解析输入。(不需要字符串的长度)。这就是我们要调用Rust代码的地方!我们来写一下:

    // Parse the input.
    Result parser_result = parse(input);

    // If parsing failed, then return false.
    if (parser_result.tag == Err) {
        RETURN_FALSE;
    }

    // Else map the Rust AST into a PHP array.
    const Vector_Node nodes = parse_result.ok._0;

Result类型和parse函数来自Rust。如果你不记得这些类型,请阅读前一集关于C星系的内容。Zend Engine有一个名为RETURN_FALSE的宏来返回false!很贴心是不是?最后如果一起顺利,我们会得到一个节点集合,节点类型为Vector_Node。下一步是要映射这些Rust/C类型到PHP的类型,也就是Gutenberg类的数组。开始:

    // Note: return_value is a “magic” variable that holds the value to be returned.
    //
    // Allocate an array.
    array_init_size(return_value, nodes.length);

    // Map the Rust AST.
    into_php_objects(return_value, &nodes);
}

完成!等一下。。。into_php_objects好像还没有写!

into_php_objects函数

这个函数并不十分复杂:它只是像预期的那样充满了特定于Zend引擎的API。我们将解释如何将一个Block映射到 Gutenberg_Parser_Block,并让Phrase映射到Gutenberg_Parser_Phrase,以方便勤奋的读者。代码:

void into_php_objects(zval *php_array, const Vector_Node *nodes)
{
    const uintptr_t number_of_nodes = nodes->length;

    if (number_of_nodes == 0) {
        return;
    }

    // Iterate over all nodes.
    for (uintptr_t nth = 0; nth < number_of_nodes; ++nth) {
        const Node node = nodes->buffer[nth];

        if (node.tag == Block) {
            // Map Block into Gutenberg_Parser_Block.
        } else if (node.tag == Phrase) {
            // Map Phrase into Gutenberg_Parser_Phrase.
        }
    }
}

映射block的过程如下:

  1. blocknamespace分配一个PHP字符串,block的名字也需要这样,
  2. 分配一个对象,
  3. 设置block namespaceblock name相应的属性,
  4. 为必要block属性的PHP字符串,
  5. 设置block属性到对应的对象,
  6. 如果有子节点,初始化一个数组, 然后用child节点和新数组调用into_php_objects函数,
  7. 设置children到对应的对象,
  8. 最后,把block对象追加到将要返回的数组里面。
const Block_Body block = node.block;
zval php_block, php_block_namespace, php_block_name;

// 1. Prepare the PHP strings.
ZVAL_STRINGL(&php_block_namespace, block.namespace.pointer, block.namespace.length);
ZVAL_STRINGL(&php_block_name, block.name.pointer, block.name.length);

您还记得namespacename和其他类似的数据都属于Slice_c_char类型吗?它只是一个有指针和长度的结构。指针指向原始的输入字符串,因此没有副本(实际上这是Slice的定义)。Zend Engine有一个ZVAL_STRINGL宏,它允许从指针和长度创建字符串,太棒了!不幸的是,对于我们来说,Zend Engine在后台做了一个复制使得没有办法只保留指针和长度,但是它做到了只用很小复制的数量。我认为它之所以需要完全拥有数据所有权,是因为垃圾回收需要这个。

// 2. Create the Gutenberg_Parser_Block object.
object_init_ex(&php_block, gutenberg_parser_block_class_entry);

对象已经用gutenberg_parser_block_class_entry表示的类进行了实例化。

// 3. Set the namespace and the name.
add_property_zval(&php_block, "namespace", &php_block_namespace);
add_property_zval(&php_block, "name", &php_block_name);

zval_ptr_dtor(&php_block_namespace);
zval_ptr_dtor(&php_block_name);
The zval_ptr_dtor adds 1 to the reference counter. This is required for the garbage collector.
// 4. Deal with block attributes if some.
if (block.attributes.tag == Some) {
    Slice_c_char attributes = block.attributes.some._0;
    zval php_block_attributes;

    ZVAL_STRINGL(&php_block_attributes, attributes.pointer, attributes.length);

    // 5. Set the attributes.
    add_property_zval(&php_block, "attributes", &php_block_attributes);

    zval_ptr_dtor(&php_block_attributes);
}

namespace, name的操作一样,我们完成children

// 6. Handle children.
const Vector_Node *children = (const Vector_Node*) (block.children);

if (children->length > 0) {
    zval php_children_array;

    array_init_size(&php_children_array, children->length);

    // Recursion.
    into_php_objects(&php_children_array, children);

    // 7. Set the children.
    add_property_zval(&php_block, "children", &php_children_array);

    Z_DELREF(php_children_array);
}

free((void*) children);

最后,追加这个block示例到返回数组:

// 8. Insert the object in the collection.
add_next_index_zval(php_array, &php_block);

所有的代码可以到这里找到

PHP扩展 🚀 PHP用户态

现在扩展已经写好了,我们必须编译它。这就是我们在上面用phpize所显示的重复命令集。编译扩展之后,生成的generated gutenberg_post_parser.so库文件必须位于扩展目录中。可以使用以下命令找到此目录

$ php-config --extension-dir

比如在我的电脑上,扩展目录是/usr/local/Cellar/php/7.2.11/pecl/20170718。然后,要为给定的执行启用扩展,必须这样写:

$ php -d extension=gutenberg_post_parser -m | \
      grep gutenberg_post_parser

或者,为所有的执行都开启这个扩展,用php -ini找到PHP的配置文件php.ini,增加:

extension=gutenberg_post_parser

完成!现在,让我们使用一些反射来检查扩展是否被PHP正确加载和处理:

$ php --re gutenberg_post_parser
Extension [ <persistent> extension #64 gutenberg_post_parser version 0.1.0 ] {

  - Functions {
    Function [ <internal:gutenberg_post_parser> function gutenberg_post_parse ] {

      - Parameters [1] {
        Parameter #0 [ <required> $gutenberg_post_as_string ]
      }
    }
  }

  - Classes [2] {
    Class [ <internal:gutenberg_post_parser> final class Gutenberg_Parser_Block ] {

      - Constants [0] {
      }

      - Static properties [0] {
      }

      - Static methods [0] {
      }

      - Properties [4] {
        Property [ <default> public $namespace ]
        Property [ <default> public $name ]
        Property [ <default> public $attributes ]
        Property [ <default> public $children ]
      }

      - Methods [0] {
      }
    }

    Class [ <internal:gutenberg_post_parser> final class Gutenberg_Parser_Phrase ] {

      - Constants [0] {
      }

      - Static properties [0] {
      }

      - Static methods [0] {
      }

      - Properties [1] {
        Property [ <default> public $content ]
      }

      - Methods [0] {
      }
    }
  }
}

一切看起来都很好:有一个函数和两个类。现在,让我们在这篇博客文章中首次编写一些PHP代码

<?php

var_dump(
    gutenberg_post_parse(
        '<!-- wp:foo /-->bar<!-- wp:baz -->qux<!-- /wp:baz -->'
    )
);

/**
 * Will output:
 *     array(3) {
 *       [0]=>
 *       object(Gutenberg_Parser_Block)#1 (4) {
 *         ["namespace"]=>
 *         string(4) "core"
 *         ["name"]=>
 *         string(3) "foo"
 *         ["attributes"]=>
 *         NULL
 *         ["children"]=>
 *         NULL
 *       }
 *       [1]=>
 *       object(Gutenberg_Parser_Phrase)#2 (1) {
 *         ["content"]=>
 *         string(3) "bar"
 *       }
 *       [2]=>
 *       object(Gutenberg_Parser_Block)#3 (4) {
 *         ["namespace"]=>
 *         string(4) "core"
 *         ["name"]=>
 *         string(3) "baz"
 *         ["attributes"]=>
 *         NULL
 *         ["children"]=>
 *         array(1) {
 *           [0]=>
 *           object(Gutenberg_Parser_Phrase)#4 (1) {
 *             ["content"]=>
 *             string(3) "qux"
 *           }
 *         }
 *       }
 *     }
 */

工作得很好!

结论

这个旅程是这样的:

  • 一个PHP的string,
  • 在Gutenberg扩展中分配属于Zend Engine,
  • 通过FFI传递给Rust(静态库 + 头文件),
  • 从Gutenberg扩展回到Zend Engine,
  • 生成PHP对象,
  • PHP得到对象。

到处都适用Rust!我们已经看到在现实世界中如何用Rust编写一个解析器,如何将其绑定到C然后编译到一个静态库和C头文件,如何创建一个PHP扩展暴露一个函数和两个对象,如何将C绑定集成到PHP中,以及如何在PHP中使用这个扩展。提醒一下,C绑定大约有150行代码。PHP扩展大约有300行代码,但是减去自动生成的修饰后(声明和管理扩展的样板代码),PHP扩展减少到大约200行代码。再一次,可以看到我们需要review的代码面是很小的,因为考虑到解析器仍然是用Rust编写的,修改解析器不会影响绑定(除非AST明显更新)! PHP是一种带有垃圾收集器的语言。这解释了为什么要复制所有字符串,以便它们都属于PHP本身。然而,Rust不复制任何数据的事实节省了内存分配和释放,这在大多数情况下是最大的成本。Rust也提供了安全。考虑到我们要处理的绑定数量,可以对这个属性提出疑问: Rust到C到PHP: 这还安全么?从Rust的角度来看,答案是肯定的,但是在C或PHP中发生的所有事情都必须被认为是不安全的。在C绑定中必须特别注意处理所有情况。还快吗?我们来做个基准测试。我想提醒您,这个实验的第一个目标是解决原始PEG.js解析器的性能问题。在JavaScript方面,WASM和ASM.js已经显示出了非常快的速度(参见WebAssembly galaxy和ASM.js galaxy)。对于PHP,我们使用phpegjs:它读取为PEG.js编写的语法并将其编译到PHP。我们来比较一下

file PEG PHP parser (ms) Rust parser as a PHP extension (ms) speedup
demo-post.html 30.409 0.0012 × 25341
shortcode-shortcomings.html 76.39 0.096 × 796
redesigning-chrome-desktop.html 225.824 0.399 × 566
web-at-maximum-fps.html 173.495 0.275 × 631
early-adopting-the-future.html 280.433 0.298 × 941
pygmalian-raw-html.html 377.392 0.052 × 7258
moby-dick-parsed.html 5,437.630 5.037 × 1080

Rust解析器的PHP扩展比实际的PEG PHP实现平均快5230倍。提速的中位数是941。另一个大问题是PEG解析器由于内存限制无法处理许多个Gutenberg文档。当然,增大内存的大小是可能的,但并不理想。使用Rust解析器作为PHP扩展,内存保持大小不变,并且和被解析文档的大小接近。我认为我们可以进一步优化扩展来生成迭代器而不是数组,这是我想探索东西以及分析其对性能的影响。The PHP Internals Book中就有一章是关于迭代器的。我们将在本系列的下一集看到Rust可以到达很多星系,Rust越多的往后旅行,也会变得更加有趣。谢谢你的阅读。