1. 我的第一個 PHP 擴充套件

CismonX發表於2018-02-25

1.1 作者的話

1.1.1 為什麼要開這個專欄

這個專欄的主要目的是帶領大家理解PHP的底層機制,並掌握PHP擴充套件開發的基本要領。你會發現,在漫山遍野的PHP相關書籍、教程中,這樣的內容寥寥無幾。業界元老Sara Golemon女士曾寫過一本書,Extending and Embedding PHP,於2006年出版。Zend API在每一個版本都有變化,以至於如今這本書已經沒有太大參考價值了。目前,基本上只有PHP Internals BookPHP Internals兩個非官方站點提供稍微全面些的PHP7擴充套件開發的指導。然而,它們只起到了官方沒有提供的API文件的作用(是的,除了幾行少得可憐的註釋,官方並沒有提供任何API文件),在實際開發中,很多都需要自己摸索,在不斷地除錯、閱讀PHP原始碼後,問題才能迎刃而解。所以我寫這個專欄,除了講一些基礎之外,我還會把我在做PHP擴充套件開發的過程中踩過的坑分享給大家,讓大家少走彎路,能更快上手。畢竟優雅高效開發才是PHPer們所追求的目標。

1.1.2 什麼時候我們需要寫PHP擴充套件

在PHP7的年代,userland PHP的效能是足夠的。很多時候我們遇到的效能瓶頸都是出在I/O或者業務邏輯上,而不是PHP本身的執行速度不夠。而像計算密集的程式,比如一些演算法,我們不會拿PHP去做。

那什麼時候我們需要寫PHP擴充套件呢?

  • 當PHP的語法特性無法滿足我們的要求時。比如在PHP5.5之前,沒有Generator,所以如果我們想要在PHP中使用協程,就必須在底層實現一個上下文切換的庫
  • 當我們需要使用一個C/C++的庫時。只有當你充分閱讀並理解它的原始碼以後你才有可能用PHP重寫這個庫,而直接封裝成PHP擴充套件你只往往需要理解它暴露的介面就可以了。簡單高效。
  • 當PHP的執行速度真的成為我們專案的效能瓶頸時。yaf和swoole等擴充套件的存在證明了這一點。

最重要的一點,掌握PHP擴充套件開發的技術,可以給PHP帶來無限的可能,而不是侷限於Web開發的小領域中。

1.1.3 學習PHP擴充套件開發需要掌握什麼基礎

閱讀本專欄文章需要掌握以下基礎。

  • 瞭解PHP的基本語法
  • 掌握C/C++基礎
  • 掌握使用GDB除錯C/C++程式的基本方法

之後我寫文章時預設大家有這樣的基礎。不然事無鉅細,連malloc是什麼都要展開講,對於那些有基礎的讀者來說可謂是一種折磨。

1.2 PHP擴充套件的檔案結構

1.2.1 生成一個PHP擴充套件的骨架

PHP原始碼倉庫的ext目錄下有一個shell指令碼ext_skel(從PHP7.3起換成了一個PHP指令碼ext_skel.php)。這個指令碼可以用來生成一個最小結構的可用的PHP擴充套件,方便開發者在其基礎上進行開發。

我們先用它生成一個名為foo的擴充套件。

./ext_skel --extname=foo
ls -R foo

可以看到生成的目錄下有以下檔案:

foo/:
config.m4 config.w32 CREDITS EXPERIMENTAL foo.c foo.php php_foo.h tests

foo/tests:
001.phpt

1.2.2 config.m4指令碼

我們現在可以看到生成的config.m4指令碼。這個指令碼在PHP擴充套件中至關重要,它告訴PHP構建系統應該如何構建這個擴充套件。我們可以呼叫acinclude.m4中定義的M4巨集來方便我們編寫配置指令碼。acinclude.m4有詳細的註釋,所以不難理解。大家也可以閱讀官方擴充套件以及PECL擴充套件的config.m4指令碼來熟悉一下寫法。下面我會講解幾個最常用的巨集的使用方法。

1.2.2.1 ./configure引數

我們知道,當我們執行phpize後,PHP編譯系統將使用autoconf,根據config.m4生成configure指令碼。我們往往希望在執行configure指令碼時指定某些特定引數,比如--enable-pcntl--with-curl=/usr/local。我們可以在config.m4中呼叫PHP_ARG_ENABLEPHP_ARG_WITH這兩個巨集來實現。

dnl
dnl PHP_ARG_ENABLE(arg-name, check message, help text[, default-val[, extension-or-not]])
dnl Sets PHP_ARG_NAME either to the user value or to the default value.
dnl default-val defaults to no.  This will also set the variable ext_shared,
dnl and will overwrite any previous variable of that name.
dnl If extension-or-not is yes (default), then do the ENABLE_ALL check and run
dnl the PHP_ARG_ANALYZE_EX.
dnl

其中,如果一個引數的extension-or-notyes,則該參數列示這個擴充套件本身是否會被編譯。一般來說,一個擴充套件有且僅有一個這樣的引數,且它的arg-name為這個擴充套件的名稱。

check message為configure指令碼執行時會輸出的資訊:

checking for foo support... yes

help text為執行./configure --help時對應的提示資訊:

Optional Features and Packages:

--enable-foo-debug Compile with debugging symbols

default-val為當你不指定這個引數的情況下生成的對應變數的值。如果指定了,--enable-foo會置$PHP_FOOyes,而--disable-foo會置no。如果是--enable-foo=bar,那它就和with等價,置$PHP_FOO為等號後的字串。注意,不論arg-name為如何,這個對應的變數永遠是大寫,而且"-"會被轉換為"_"。

1.2.2.2 新增擴充套件

使用PHP_NEW_EXTENSION將擴充套件新增到構建中。

dnl
dnl PHP_NEW_EXTENSION(extname, sources [, shared [, sapi_class [, extra-cflags [, cxx [, zend_ext]]]]])
dnl
dnl Includes an extension in the build.
dnl
dnl "extname" is the name of the extension.
dnl "sources" is a list of files relative to the subdir which are used
dnl to build the extension.
dnl "shared" can be set to "shared" or "yes" to build the extension as
dnl a dynamically loadable library. Optional parameter "sapi_class" can
dnl be set to "cli" to mark extension build only with CLI or CGI sapi's.
dnl "extra-cflags" are passed to the compiler, with 
dnl @ext_srcdir@ and @ext_builddir@ being substituted.
dnl "cxx" can be used to indicate that a C++ shared module is desired.
dnl "zend_ext" indicates a zend extension.

extname為擴充套件的名稱。

sources為原始檔的列表,多個檔案之間用空格分隔。

shared為該擴充套件是否要編譯為shared object,從而可以在php.ini中通過指定extension=foo選擇性載入。這個引數應該設定為$ext_shared,由configure指令碼進行判斷。

sapi-class如果設為cli,則限制該擴充套件只能應用於PHP-CLI和PHP-CGI。否則,該擴充套件可以在任何sapi上使用。

extra-cflags等同於CFLAGS+=" ..."或者CXXFLAGS+=" ...",比如我們的擴充套件使用了C++11的語法,就可以在這裡指定-std=c++11

cxxyes表明在link時libtool會選擇g++而不是cc,如果你的擴充套件是用C++編寫的,建議設定為yes,否則你需要-lstdc++(除非你的依賴包含了其他C++庫,已經連線了libstdc++)。

zend-ext若為yes,則表明這是一個Zend擴充套件而不是PHP擴充套件。在一般的應用場合,PHP擴充套件就足以滿足我們的要求。而且編寫Zend擴充套件要求開發者對Zend引擎有著深刻的理解。我們短時間內不做討論。

1.2.2.3 配置依賴

我們的PHP擴充套件往往需要依賴其他的庫。

巨集PHP_ADD_INCLUDE可以用來新增額外的包含標頭檔案的目錄。額外的目錄將會被新增到引數$INCLUDES中。

dnl
dnl PHP_ADD_INCLUDE(path [,before])
dnl
dnl add an include path. 
dnl if before is 1, add in the beginning of INCLUDES.
dnl

巨集PHP_CHECK_LIBRARY可以用來判斷一個庫是否有效。

dnl
dnl PHP_CHECK_LIBRARY(library, function [, action-found [, action-not-found [, extra-libs]]])
dnl
dnl Wrapper for AC_CHECK_LIB
dnl

function為用來測試的函式。configure指令碼會通過該函式的符號是否存在來判斷這個庫是否有效。

action-found為測試成功後將要執行的指令碼。action-not-found為測試失敗後執行的指令碼。

extra-libs為測試時額外的$LDFLAGS

以下例子來自cURL擴充套件。

PHP_CHECK_LIBRARY(curl, curl_easy_perform, 
[
  AC_DEFINE(HAVE_CURL, 1, [ ])
], [
  AC_MSG_ERROR(There is something wrong. Please check config.log for more information.)
], [
  $CURL_LIBS
])

巨集PHP_ADD_LIBRARY可以用來新增要連線的庫。

dnl
dnl PHP_ADD_LIBRARY(library[, append[, shared-libadd]])
dnl
dnl add a library to the link line
dnl

library為庫的名稱。

如果append為1,則該庫會被新增到shared-libadd變數的尾部。否則新增到變數的首部。

注意,shared-libadd變數必須為大寫庫名加“_SHARED_LIBADD”。比如FOO_SHARED_LIBADD

Bash命令pkg-config可以為我們提供庫的資訊。

引數--cflags為使用該庫所需要額外指定的$CFLAGS,主要是標頭檔案包含目錄。常配合巨集PHP_EVAL_INCLINE使用。

dnl
dnl PHP_EVAL_INCLINE(headerline)
dnl
dnl Use this macro, if you need to add header search paths to the PHP
dnl build system which are only given in compiler notation.
dnl

引數--libs為額外的$LDFLAGS,常配合巨集PHP_EVAL_LIBLINE使用。

dnl
dnl PHP_EVAL_LIBLINE(libline, SHARED-LIBADD)
dnl
dnl Use this macro, if you need to add libraries and or library search
dnl paths to the PHP build system which are only given in compiler
dnl notation.
dnl

下面是簡單的例子。

LIBFOO_INCLINE=`pkg-config libfoo --cflags`
LIBFOO_LIBLINE=`pkg-config libfoo --libs`
PHP_EVAL_INCLINE($LIBFOO_INCLINE)
PHP_EVAL_LIBLINE($LIBFOO_LIBLINE, FOO_SHARED_LIBADD)
PHP_SUBST(FOO_SHARED_LIBADD)

注意,最後一定要呼叫PHP_SUBST,否則shared-libadd中的庫將不會被連線。

1.2.2.4 其他

  • 如果你的擴充套件是使用C++編寫的,必須呼叫巨集PHP_REQUIRE_CXX,否則無法編譯。
  • 常使用AC_DEFINE(ENABLE_BAR, 1, [ ])替代$CFLAGS中的-DENABLE_BAR=1
  • 如果在配置過程中檢測到錯誤,編譯不應該繼續進行下去,可以呼叫巨集AC_MSG_ERROR輸出錯誤資訊並中止構建。
  • config.w32指令碼包含該擴充套件在Windows下構建時的配置資訊,這裡我們不做討論。

1.2.3 原始檔

我們在1.2.1中生成的骨架中包含了php_foo.h和foo.c兩個檔案。通過閱讀原始碼,可以發現它包含了擴充套件的入口變數,以及一個簡單的函式confirm_foo_compiled()。在實際開發中,我們可以根據專案需求編寫任意數量的原始檔和標頭檔案,並在config.m4中正確配置。

1.2.3.1 PHP擴充套件入口

每個PHP擴充套件都有且僅有一個入口,即一個型別為zend_module_entry的結構體全域性變數。它是必不可少的。

在zend_modules.h中,它的定義如下。我在程式碼中加上了註釋,

struct _zend_module_entry {
    // ...
    // 以上結構的含義不重要,略去。
    // 初始化入口變數時使用巨集STANDARD_MODULE_HEADER即可
    const char *name;                                 // 擴充套件的名稱
    const struct _zend_function_entry *functions;     // 擴充套件包含的函式列表入口
    int (*module_startup_func)(INIT_FUNC_ARGS);       // 擴充套件啟動時執行的函式
    int (*module_shutdown_func)(SHUTDOWN_FUNC_ARGS);  // 擴充套件結束執行時執行的函式
    int (*request_startup_func)(INIT_FUNC_ARGS);      // 每次收到請求時執行的函式
    int (*request_shutdown_func)(SHUTDOWN_FUNC_ARGS); // 每次請求結束時執行的函式
    void (*info_func)(ZEND_MODULE_INFO_FUNC_ARGS);    // `php -i`時執行的函式,輸出資訊
    const char *version;                              // 擴充套件的版本號
    // ...
    // 以下結構的含義不重要,略去
    // 初始化入口變數時使用巨集STANDARD_MODULE_PROPERTIES即可
};

有關以上“請求”的概念,如果不瞭解,可以閱讀這篇講PHP生命週期的文章

1.2.3.2 使用巨集進行PHP擴充套件開發

通過閱讀原始碼,大家可以發現,絕大多數供PHP擴充套件開發者使用的Zend API都是巨集。大家應該逐漸適應這一點。很多初學者在入門一個新的框架/庫的時候,總是偏愛IDE的自動補全功能,在候選函式列表裡找到自己想要呼叫的函式,看到它接受的引數型別及含義,閱讀它的API文件。然而,多數IDE對巨集的支援並不好,再加上巨集引數不具備型別、Zend API沒有文件,對於這些初學者來說,確實增加了他們上手的難度。

1.2.4 測試指令碼

tests目錄下的phpt測試指令碼可以用來測試你的PHP擴充套件是否能夠按照預期執行。

在編譯完成後,make test以執行所有phpt指令碼。

PHP官網的這篇文章詳細地介紹瞭如何編寫phpt指令碼,這裡就不再贅述了。

1.3 下期預告

在這篇文章中大家主要了解了一個PHP擴充套件的基本結構,以及如何為自己的PHP擴充套件編寫配置指令碼。下次,我將會為大家帶來第二章:

  1. 淺析ZVAL

敬請期待。此外,如果我的文章有紕漏或者有需要補充的地方,歡迎評論指出,或者給我發郵件。

Living on the bleeding edge

相關文章