1. 程式人生 > 程式設計 >【譯】從 Rust 到不只是 Rust:PHP 語言領域

【譯】從 Rust 到不只是 Rust:PHP 語言領域

From Rust to beyond: The PHP galaxy 譯文

這篇部落格文章是“如何將 Rust 傳播到其他語言領域”系列文章之一。Rust 完成進度:

我們今天探索的領域是 PHP 領域。這個文章解釋了什麼是 PHP,如何將 Rust 程式編譯成 C 再轉換成 PHP 原生擴充套件。

PHP 是什麼?為什麼是它?

PHP 是:

受歡迎的通用指令碼語言,尤其是在 web 開發領域。從個人部落格到世界上最流行的網站,PHP 提供了快速、靈活並且實用的功能。

令人遺憾的是,PHP 近年來名聲不佳,但是最近的發行版(從 PHP 7.0 開始)引入了許多簡潔的語言特性,這些特性令人喜愛。PHP 也是一種快速指令碼語言,並且非常靈活。PHP 現在已經具備了型別、性徵、可變引數、閉包(帶有顯式範圍)、生成器和強大的向後相容特性。PHP 的開發由

RFCs 領導,整個過程是開放和民主的。Gutenberg 專案是 WordPress 的新編輯器。WordPress 是用 PHP 編寫的。很自然的,我們需要一個 PHP 的本地擴充套件來解析 Gutenberg 文章格式。PHP 是一種具有規範的語言。其最流行的虛擬機器器是 Zend Engine,還有一些其他虛擬機器器,比如 HHVM(但 HHVM 最近已經放棄對 PHP 的支援,轉而支援他們團隊自己的 PHP 分支,也稱為 Hack),PeachpieTagua VM(正在開發中)。在本文中,我們將為 Zend Engine 建立一個擴充套件。這個虛擬機器器是 C 語言編寫的。恰好跟之前的一篇文章
C 系列
相契合。

Rust ? C ? PHP

要將 Rust 解析器移植到 PHP 中,我們首先需要將它移植到 C。這在上一篇文章中已經實現了。從這一端到 C 有兩個檔案:libgutenberg_post_parser.agutenberg_post_parser.h,分別是靜態庫和標頭檔案。

使用腳手架引導

PHP 原始碼中自帶了一個建立擴充套件的腳手架/模板,是 ext_skel.php。這個指令碼可以從 Zend Engine 虛擬機器器的原始碼中找到。可以這樣使用它:

$ 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 以外的目錄。所以我們使用 phpizephpizephpphp-cgiphpdbgphp-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 "php.h"
#include "ext/standard/info.h"
#include "php_gutenberg_post_parser.h"
#include "gutenberg_post_parser.h"
複製程式碼

最後一行引入的 gutenberg_post_parser.h 檔案由 Rust 生成(準確的說是 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>)
}
複製程式碼

AST 的 C 變體與上方的版本是類似的(具有很多結構,但思路幾乎相同)。所以在 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_post_parseGutenberg_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 代表一個特定的型別。並會有對應的處理程式與 class entry 相關聯。邏輯有些複雜。如果你想了解更多內容,我建議你閱讀 PHP Internals Book。接著,我們建立一個函式來例項化這些物件:

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;

    // 宣告 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);

    // 宣告 create handler.
    gutenberg_parser_block_class_entry->create_object = create_parser_node_object;

    // 類是 final 的(不能被繼承)
    gutenberg_parser_block_class_entry->ce_flags |= ZEND_ACC_FINAL;

    // 使用空字串作為預設值宣告 `namespace` 公共屬性,
    zend_declare_property_string(gutenberg_parser_block_class_entry,"namespace",sizeof("namespace") - 1,"",ZEND_ACC_PUBLIC);

    // 使用空字串作為預設值宣告 `name` 公共屬性
    zend_declare_property_string(gutenberg_parser_block_class_entry,"name",sizeof("name") - 1,ZEND_ACC_PUBLIC);

    // 使用 `NULL` 作為預設值宣告 `attributes` 公共屬性
    zend_declare_property_null(gutenberg_parser_block_class_entry,"attributes",sizeof("attributes") - 1,ZEND_ACC_PUBLIC);

    // 使用 `NULL` 作為預設值,宣告 `children` 公共屬性
    zend_declare_property_null(gutenberg_parser_block_class_entry,"children",sizeof("children") - 1,ZEND_ACC_PUBLIC);

    // 宣告 Gutenberg_Parser_Block.

    … 略 …

    // 宣告 Gutenberg 解析器節點物件 handler

    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_FUNCTIONPHP_MINFO_FUNCTION 函式,它們是由 ext_skel.php 指令碼生成的。模組條目資訊和模組配置也是這樣生成的。

gutenberg_post_parse 函式

現在我們將重點介紹 gutenberg_post_parse 函式。該函式接收一個字串作為引數,如果解析失敗,則返回 false,否則返回型別為 Gutenberg_Parser_BlockGutenberg_Parser_Phrase 的物件陣列。我們開始編寫它!注意它是由 PHP_FUNCTION 巨集宣告的.

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

    // 將 input 作為字串讀入
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC,"s",&input,&input_len) == FAILURE) {
        return;
    }
複製程式碼

在這個步驟中,引數已經作為字串("s")被宣告和引入了。字串值在 input 中,字串長度儲存在 input_len。下一步就是解析 input。(實際上不需要字串長度)。這就是我們要呼叫 Rust 程式碼的地方!我們可以這樣做:

    // 解析 input
    Result parser_result = parse(input);

    // 如果解析失敗,則返回 false.
    if (parser_result.tag == Err) {
        RETURN_FALSE;
    }

    // 否則將 Rust 的 AST 對映到 PHP 的陣列中
    const Vector_Node nodes = parse_result.ok._0;
複製程式碼

Result 型別和 parse 函式來自 Rust 中。如果你不記得這些型別,可以閱讀前一篇關於 C 領域的文章。Zend Engine 有一個 RETURN_FALSE 巨集,用於返回 false!很方便是嗎?最後,如果順利,我們將得到 Vector_Node 型別的節點集合。下一步是將它們對映到 PHP 型別中,如 Gutenberg 型別的陣列。我們開始幹吧:

    // 注意:return_value 是一個"魔術"變數,它用於存放返回值
    //
    // 分配一個陣列空間
    array_init_size(return_value,nodes.length);

    // 對映 Rust AST
    into_php_objects(return_value,&nodes);
}
複製程式碼

完事了 ?!噢,等等 …… 還要實現 into_php_objects函式!

into_php_objects 函式

這個函式並不複雜:只是它是通過 Zend Engine 的 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;
    }

    // 遍歷所有節點
    for (uintptr_t nth = 0; nth < number_of_nodes; ++nth) {
        const Node node = nodes->buffer[nth];

        if (node.tag == Block) {
            // 將 Block 對映為 Gutenberg_Parser_Block
        } else if (node.tag == Phrase) {
            // 將 Phrase 對映為 Gutenberg_Parser_Phrase
        }
    }
}
複製程式碼

現在,我們開始實現對映一個記憶體區塊(以下簡稱塊)。主要過程如下:

  1. 為塊名稱空間和塊名稱分配 PHP 字串,
  2. 分配物件,
  3. 將塊名稱空間和塊名稱設定為各自的獨享屬性
  4. 為塊屬性分配一個 PHP 字串
  5. 把塊屬性設定為對應的物件屬性
  6. 如果有子節點,初始化一個陣列,並使用子節點和新陣列呼叫 into_php_objects
  7. 把子節點設定為對應的物件屬性
  8. 最後,在返回的陣列中新增塊物件
const Block_Body block = node.block;
zval php_block,php_block_namespace,php_block_name;

// 1. 準備 PHP 字串
ZVAL_STRINGL(&php_block_namespace,block.namespace.pointer,block.namespace.length);
ZVAL_STRINGL(&php_block_name,block.name.pointer,block.name.length);
複製程式碼

你還記得名稱空間、名稱和其他類似資料的型別是 Slice_c_char 嗎?它就是一個帶有指標和長度的結構體。指標指向原始的輸入字串,因此沒有副本(這其實是 slice 的定義)。好了,Zend Engine 中有名為 ZVAL_STRINGL 的巨集,它的功能是通過“指標”和“長度”建立字串,很棒!可不幸的是,Zend Engine 在底層做了拷貝…… 沒有辦法只保留指標和長度,但是它保證拷貝的數量很小。我想應該為了獲取資料的全部所有權,這是垃圾回收所必需的。

// 2. 建立 Gutenberg_Parser_Block 物件
object_init_ex(&php_block,gutenberg_parser_block_class_entry);
複製程式碼

使用 gutenberg_parser_block_class_entry 所代表的類例項化物件。

// 3. 設定名稱空間和名稱
add_property_zval(&php_block,&php_block_namespace);
add_property_zval(&php_block,&php_block_name);

zval_ptr_dtor(&php_block_namespace);
zval_ptr_dtor(&php_block_name);
複製程式碼

zval_ptr_dtor 的作用是給引用計數加 1。便於垃圾回收。

// 4. 處理一些記憶體塊屬性
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. 設定屬性
    add_property_zval(&php_block,&php_block_attributes);

    zval_ptr_dtor(&php_block_attributes);
}
複製程式碼

它類似於 namespacename 所做的。現在我們繼續討論 children。

// 6. 處理子節點
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);

    // 遞迴
    into_php_objects(&php_children_array,children);

    // 7. 設定 children
    add_property_zval(&php_block,&php_children_array);

    Z_DELREF(php_children_array);
}

free((void*) children);
複製程式碼

最後,將塊例項增加到返回的陣列中:

// 8. 在集合中加入物件
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.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 字串
  • 在 中 Zend Engine 為 Gutenberg 擴充套件分配記憶體,
  • 通過 FFI(靜態庫 + header)傳遞到 Rust,
  • 通過 Gutenberg 擴充套件返回資料到 Zend Engine
  • 生成 PHP 物件,
  • PHP 讀取該物件。

Rust 適用於很多地方!我們已經看到在實際程式設計中已經有人實現如何用 Rust 實現解析器,如何將其繫結到 C 語言並生成除了 C 標頭檔案之外的靜態庫,如何建立一個 PHP 擴充套件並暴露一個函式介面和兩個物件,如何把“C 繫結”整合到 PHP,以及如何在 PHP 中使用該擴充套件。提醒一下,“C 繫結”大概有 150 行程式碼。PHP 擴充套件大概有 300 行程式碼,但是減去自動生成的“程式碼修飾”(一些宣告和管理擴充套件的模板檔案),PHP 擴充套件將減少到大約 200 行程式碼。同樣,考慮到解析器仍然是用 Rust 編寫的,修改解析器不會影響繫結(除非 AST 發生了較大更新),我發現整個實現過程只是一小部分程式碼。PHP 是一個有垃圾回收的語言。這就解釋了為何需要拷貝所有的字串,這樣資料都能被 PHP 擁有。然而,Rust 中不拷貝任何資料的事實表明可以減少記憶體分配和釋放,這些開銷恰好在大多數情況下是最大的時間成本。Rust 還提供了安全性。考慮到我們要進行繫結的數量,這個特性可能受到質疑:Rust 到 C 到 PHP,這種安全性還存在嗎?從 Rust 的角度看,答案是確定的,但在 C 或 PHP 中發生的所有操作都被認為是不安全的。在 C 繫結中必須特別謹慎處理所有情況。這樣還快嗎?好吧,讓我們進行基準測試。我想提醒你,這個實驗的首要目標是解決原始的 PEG.js 解析器效能問題。在 JavaScript 的基礎上,WASM 和 ASM.js 方案已經被證明要快的多(參見 WebAssembly 領域ASM.js 領域)。對於 PHP,使用 phpegjs:它讀取為 PEG.js 編寫的語法並將其編譯到 PHP。我們來比較一下:

檔名 PEG PHP parser (ms) Rust parser as a PHP extension (ms) 提升倍數
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 擴充套件,記憶體消耗基本保持不變,並且接近解析檔案的大小。我認為我們可以通過迭代器而非陣列的方式來進一步優化該擴充套件。這是我想探索的東西以及分析對效能的影響。PHP 核心書籍有個迭代器章節。我們將在本系列的下一節看到 Rust 可以助力於很多領域,而且傳播的越多,就越有趣味。感謝你的閱讀!