Разработка расширения для PHP на C++. Хуки встроенных функций и методов.

PHP - это мощный и широко используемый язык программирования для веб-разработки. Однако иногда требуется расширение функциональности или модификация стандартного поведения PHP с использованием более низкоуровневых языков, таких как C++. В этой статье мы рассмотрим, как создать расширение для PHP на C++ на примере разработки функциональности, позволяющей перехватывать вызовы встроенных функций и методов классов в php.

Требования к реализации

Расширение должно предоставлять следующий функционал:

  • главная функция, доступная из php, которая позволяет установить хук на встроенную функцию или метод, назовем ее hook_set_hook

  • функция hook_set_hook в качестве аргументов принимает имя функции и класса, callback вызываемый до вызова целевой функции, callback вызываемый после вызова целевой функции

  • первый callback — при его вызове в качестве аргумента принимает массив, содержащий название вызываемой функции, вложенный массив аргументов вызываемой функции

  • второй callback — при его вызове в качестве аргумента принимает массив, содержащий название, вызываемой функции, вложенный массив аргументов вызываемой функции, результат выполнения функции и исключение, если оно возникло в процессе работы целевой функции

Шаг 1. Установка php из исходников

Для начала работы нам нужно установить последнюю версию php, работать будет начиная с версии php7+. Установку будем производить путем сборки из исходных кодов, это довольно просто:

git clone https://github.com/php/php-src.git
cd php-src
./buildconf --force
./configure --enable-debug
make
sudo make install

Обратите внимание, мы используем флаг --enable‑debug это необходимо для того, чтобы иметь возможность отлаживать наше расширение используя стандартный отладчик gdb

Шаг 2. Создание шаблона расширения

Далее нам необходимо создать минимальный шаблон нашего проекта, для этого существует специальная утилита ext_skel.php, поставляющаяся вместе с исходниками. Запустим ее указав название нашего расширения hooks:

cd /path/to/your/extension
/path/to/php-src/ext/ext_skel.php --ext=hooks --dir .
cd hooks

Если все прошло успешно в папке hooks вы увидите следующий листинг файлов:

Hidden text

config.m4
config.w32
hooks.c
hooks.stub.php
hooks_arginfo.h
php_hooks.h
tests

Шаг 3. Конфигурация и первая сборка

После создания шаблона с помощью ext_skel.php у нас есть конфигурационный файл config.m4 (config.w32 для windows), необходимо его отредактировать с учетом того, что мы будем писать на с++, а заодно переименуем файл hooks.c в hooks.cpp

В файле config.m4 все строки, начинающиеся с dnl, являются комментариями, поэтому просто удалим их и получим следующий конфиг:

PHP_ARG_ENABLE([hooks],
  [whether to enable hooks support],
  [AS_HELP_STRING([--enable-hooks],
    [Enable hooks support])],
  [no])

if test "$PHP_HOOKS" != "no"; then
  AC_DEFINE(HAVE_HOOKS, 1, [ Have hooks support ])
  PHP_SUBST(HOOKS_SHARED_LIBADD)

  dnl Установим нужные флаги компилятора
  HOOKS_COMMON_FLAGS="-Wno-write-strings -D__STDC_LIMIT_MACROS -D__STDC_CONSTANT_MACROS -D__STDC_FORMAT_MACROS -DZEND_ENABLE_STATIC_TSRMLS_CACHE=1"

  PHP_NEW_EXTENSION(hooks, , $ext_shared,,HOOKS_COMMON_FLAGS,cxx)

  dnl Собственно активация поддержки c++ c использованием стандарта с++17
  PHP_REQUIRE_CXX()
  PHP_CXX_COMPILE_STDCXX(17, mandatory, PHP_HOOKS_STDCXX)

  PHP_HOOKS_CXX_FLAGS="$HOOKS_COMMON_FLAGS $PHP_HOOKS_STDCXX"
  PHP_ADD_SOURCES_X(PHP_EXT_DIR(hooks),  hooks.cpp, $PHP_HOOKS_CXX_FLAGS, shared_objects_hooks, yes)
fi

На этом конфигурация закончена, далее воспользуемся утилитой phpize для того, чтобы сгенерировать все необходимые конфиги и подготовить проект к сборке, сконфигурируем его с помощью configure, скомпилируем используя make и, наконец, запустим тесты, которые были созданы в шаблоне:

 phpize && ./configure && make && make test

В случае если все сделано правильно, среди прочего вывода в терминале вы увидите следующее:

Hidden text
=====================================================================
TIME START 2023-12-06 12:47:11
=====================================================================
PASS Check if hooks is loaded [tests/001.phpt]
PASS test1() Basic test [tests/002.phpt]
PASS test2() Basic test [tests/003.phpt]
=====================================================================
TIME END 2023-12-06 12:47:11

=====================================================================
TEST RESULT SUMMARY
---------------------------------------------------------------------
Exts skipped    :    0
Exts tested     :   26
---------------------------------------------------------------------

Number of tests :    3                 3
Tests skipped   :    0 (  0.0%) --------
Tests warned    :    0 (  0.0%) (  0.0%)
Tests failed    :    0 (  0.0%) (  0.0%)
Tests passed    :    3 (100.0%) (100.0%)
---------------------------------------------------------------------
Time taken      :    0 seconds
=====================================================================

На этом подготовительные этапы закончены и можем приступать непосредственно к разработке ключевого функционала расширения.

Шаг 4. Настройка работы в IDE

В своей работе в качестве домашней ОС я давно уже использую Windows, в качестве IDE для C++ у меня CLion, а сборку под Linux я осуществляю с помощью WSL. Поэтому данный шаг будет актуален именно для этого набора инструментов.

Для начала работы откройте папку с расширением в IDE, поскольку CLion использует CMake, создадим файл CMakeLists.txt в корне проекта, он нам понадобится только для того, чтобы IDE определила расположение заголовочных файлов php и мы могли использовать автодополнение кода, раскрывать макросы и переходить к определениям сущностей.

cmake_minimum_required(VERSION 3.16)
project(hooks CXX)

SET(CMAKE_CXX_STANDARD 17)
SET(PHPCPP_PHP_PATH "/usr/include/php/20210902") #замените на свой путь

INCLUDE_DIRECTORIES(
        "${PHPCPP_PHP_PATH}/"
        "${PHPCPP_PHP_PATH}/main"
        "${PHPCPP_PHP_PATH}/Zend"
        "${PHPCPP_PHP_PATH}/TSRM"
        "${PHPCPP_PHP_PATH}/ext"
        "${PHPCPP_PHP_PATH}/build/main"
        "${PHPCPP_PHP_PATH}/build/Zend"
)

add_library(hooks SHARED
        config.h
        hooks.cpp
        php_hooks.h)

Не забудьте нажать "Load CMake Project"

Настроим сборку проекта, для начала необходимо добавить возможность работы с wsl, для этого в меню IDE откроем File -> Settings -> Build, Execution, Deployment -> Toolchains, и добавим WSL.

WSL добавлен, все инструменты автоматически найдены
WSL добавлен, все инструменты автоматически найдены

Далее сама сборка проекта, добавим новую конфигурацию, нас вполне устроит стандартный CMake Application, только сборку мы будем производить не через него, а через make, для этого в разделе Before launch заменим стандартный Build на External tool 'External Tools/make'.
В качестве Executable мы укажем путь до php, т.к. запускать мы будем именно php скрипты.
Для того чтобы php точно стартовал с нужными настройками и подключенным расширением укажем аргументы, главное здесь то, что запускаться будет скрипт test.php лежащий в корне проекта. Данную часть вы можете переделывать на свой вкус, как вам удобно.

 -n -c '/path/to/hooks/tmp-php.ini'  -d "output_handler=" -d "open_basedir=" -d "disable_functions=" -d "output_buffering=Off" -d "error_reporting=32767" -d "display_errors=1" -d "display_startup_errors=1" -d "log_errors=0" -d "html_errors=0" -d "track_errors=0" -d "report_memleaks=1" -d "report_zend_debug=0" -d "docref_root=" -d "docref_ext=.html" -d "error_prepend_string=" -d "error_append_string=" -d "auto_prepend_file=" -d "auto_append_file=" -d "ignore_repeated_errors=0" -d "precision=14" -d "memory_limit=128M" -d "log_errors_max_len=0" -d "opcache.fast_shutdown=0" -d "opcache.file_update_protection=0" -d "opcache.revalidate_freq=0" -d "zend.assertions=1" -d "zend.exception_ignore_args=0" -d "extension_dir=/path/to/hooks/modules/" -d "extension=hooks.so" -d "session.auto_start=0" -d "zlib.output_compression=Off" -f "/path/to/hooks/test.php"  2>&1 
Конфигурация запуска проекта
Конфигурация запуска проекта

На этом настройка проекта закончена, создадим файл test.php в корне со следующим содержимым:

<?
echo extension_loaded('hooks') ? 'Extension loaded' : 'Extension NOT loaded';

Теперь запустим проект (Shift+F10), результатом вывода в терминале, должно быть:

Extension loaded
Process finished with exit code 0

Если вы получили иное, проверьте все предыдущие шаги

Шаг 5. Разбираем структуру кода шаблона и знакомимся с Zend Engine

Рассмотрим содержимое файла hooks.cpp, самое важное находится внизу файла, структура zend_module_entry является главной и содержит всю информацию о модуле

zend_module_entry hooks_module_entry = {
	STANDARD_MODULE_HEADER,
  	"hooks",           // название расширения
	ext_functions,     // функции расшения
	nullptr,           // callback инициализации работы модуля
	nullptr,           // callback завершения работы модуля
	PHP_RINIT(hooks),  // callback инициализации запроса
	nullptr,           // callback завершения запроса
	PHP_MINFO(hooks),  // информация о модуле
	PHP_HOOKS_VERSION, // версия модуля
	STANDARD_MODULE_PROPERTIES
};

Чуть выше через макрос PHP_MINFO_FUNCTION(hooks) объявляется функция zm_info_hooks, отсюда и далее мы будем встречать большое количество макросов Zend Engineу:

PHP_MINFO_FUNCTION(hooks){
	php_info_print_table_start();
	php_info_print_table_header(2, "hooks support", "enabled");
	php_info_print_table_end();
}

Данная функция добавит в вывод phpinfo информацию о нашем раcширении.
Еще выше располагается callback-функция инициализации запроса:

PHP_RINIT_FUNCTION(hooks){
#if defined(ZTS) && defined(COMPILE_DL_HOOKS)
	ZEND_TSRMLS_CACHE_UPDATE();
#endif
	return SUCCESS;
}

Здесь проходит проверка, собран ли php с поддержкой ZTS (zend thread safety) и выполняется макрос ZEND_TSRMLS_CACHE_UPDATE() этот вызов необходим для корректного управления ресурсами когда php собран с поддержкой потоков, поскольку это не наш случай останавливаться на этом подробно не будем.

Осталось рассмотреть только две учебные функции test1 и test2:

PHP_FUNCTION(test1){
	ZEND_PARSE_PARAMETERS_NONE();

	php_printf("The extension %s is loaded and working!\r\n", "hooks");
}

PHP_FUNCTION(test2){
	char *var = "World";
	size_t var_len = sizeof("World") - 1;
	zend_string *retval;

	ZEND_PARSE_PARAMETERS_START(0, 1)
		Z_PARAM_OPTIONAL
		Z_PARAM_STRING(var, var_len)
	ZEND_PARSE_PARAMETERS_END();

	retval = strpprintf(0, "Hello %s", var);

	RETURN_STR(retval);
}

Функция test1 не принимает никаких параметров и просто печатает текст, функция test2 несколько сложнее, она принимает необязательный аргумент строкового типа, если никакой аргумент не передан, то результатом будет строка "Hello World", если передать параметр "С++", то функция вернет строку "Hello C++".
Объявление же этих функций находится в заголовочном файле hooks_arginfo.h

ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test1, 0, 0, IS_VOID, 0)
ZEND_END_ARG_INFO()

ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test2, 0, 0, IS_STRING, 0)
	ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, str, IS_STRING, 0, "\"\"")
ZEND_END_ARG_INFO()


ZEND_FUNCTION(test1);
ZEND_FUNCTION(test2);


static const zend_function_entry ext_functions[] = {
	ZEND_FE(test1, arginfo_test1)
	ZEND_FE(test2, arginfo_test2)
	ZEND_FE_END
};

Этот файл автоматически генерируется на основе hooks.stub.php, править руками hooks_arginfo.h нельзя, иначе ваши изменения могут быть затерты в дальнейшем.
Рассмотрим содержимое и работу c hooks.stub.php

<?php

/** @generate-class-entries */

function test1(): void {}

function test2(string $str = ""): string {}

Уберем из него функции test1 и test2 и добавим свою:

<?php

/** @generate-class-entries */

// Функция установки хуков
function hooks_set_hook(mixed ...$args): bool {}

Не удаляйте строку ` /** @generate-class-entries */`

Функция hooks_set_hook будет иметь переменное количество аргументов, но об этом позже.

При следующем выполнении команды make, файл hooks_arginfo.h будет сгенерирован заново. Однако файл hooks.cpp нам нужно отредактировать руками, убрать из него упоминания удаленных функций test1 и test2, и добавить минимальную реализацию hooks_set_hook:

PHP_FUNCTION(hooks_set_hook){
    php_printf("It works!");
    RETURN_TRUE;
}

В файле test.php оставим только вызов этой функции с пустыми параметрами:

<?
hooks_set_hook();

Запустим проект и увидим в терминале:

It works!

Шаг 6. Подменяем встроенную целевую функцию на собственную.

Для того чтобы реализовать функционал хуков мы будем просто подменять целевую функцию на собственную, напишем для начала такую функцию, мы не будем регистрировать ее в файле hooks.stub.php и она не будет доступна в php коде.

/**
 * Функция на которую будет производиться подмена встроенных функций.
 * @param execute_data
 * @param return_value
 */
ZEND_NAMED_FUNCTION(my_hook) {
   php_printf("my_hook");
}

С помощью макроса ZEND_NAMED_FUNCTION мы объявили функцию my_hook, посмотрим, во что разворачивается макрос:

Hidden text
void my_hook(zend_execute_data *execute_data, zval *return_value){
    php_printf("my_hook");
}

Теперь вернемся к реализации функции hooks_set_hook, ранее я писал, что она будет принимать переменное количество аргументов, а именно ее вызов может выглядеть двумя следующими образами:

<?
/**
* Установка хука на функцию
* @param string function
* @param callable before
* @param callable after
**/
function hook_set_hook(string $function, callable $before, callable $after) : bool;

/**
* Установка хука на метод класса
* @param string class
* @param string method
* @param callable before
* @param callable after
**/
function hook_set_hook(string $class, string $method, callable $before, callable $after) : bool;

Реализовать это просто, добавим в тело функции код который извлекает переданные параметры и сохраняет их в локальные переменные для дальнейшей работы:

...
// Инициализируем необходимые переменные для получения аргементов
zend_string *function = nullptr;
zend_class_entry *clazz = nullptr;
zend_fcall_info before_fci = zend_fcall_info();
zend_fcall_info after_fci = zend_fcall_info();
zend_fcall_info_cache before_fcc = zend_fcall_info_cache();
zend_fcall_info_cache after_fcc = zend_fcall_info_cache();

// Проверяем соответствует ли текущий вызов одному из двух вариантов
if (zend_parse_parameters_ex(ZEND_PARSE_PARAMS_QUIET, ZEND_NUM_ARGS(), "CSff",
                             &clazz, &function, &before_fci, &before_fcc, &after_fci, &after_fcc) != SUCCESS and
    zend_parse_parameters_ex(ZEND_PARSE_PARAMS_QUIET, ZEND_NUM_ARGS(), "Sff",
                             &function, &before_fci, &before_fcc, &after_fci, &after_fcc) != SUCCESS
        ) {
    // Если мы получили не те аргементы которые ожидали выбросим исключение и вернем false
    zend_throw_exception_ex(spl_ce_InvalidArgumentException, 0, "Invalid arguments");
    RETURN_BOOL(false);
}
...

Я не стану подробно расписывать используемые здесь функции, документацию можно найти тут: https://github.com/php/php-src/blob/master/docs/parameter-parsing-api.md

Для работы с исключениями потребуется подключить два заголовочных файла:

#include "ext/spl/spl_exceptions.h"
#include "Zend/zend_exceptions.h"

Теперь если мы запустим проект мы получим ошибку Fatal error: Uncaught InvalidArgumentException.

Исправим это передав функции hooks_set_hook верные аргументы, давайте попробуем поставить хук на функцию date, содержимое файла test.php должно принять такой вид:

<?
$before = function ($args) {
    echo "before function called\n";
    print_r($args);
};

$after = function ($args) {
    echo "after function called\n";
    print_r($args);
};

hooks_set_hook('date', $before, $after);

echo date('Y-m-d');

При запуске проекта, ожидаемо увидим текущую дату. Перейдем непосредственно к реализации логики подмены вызова функции date и вызова наших callback-функций.
В рантайме встроенные функции хранятся в хеш-таблице к которой мы можем легко получить доступ с помощью макроса CG (compiler globals), в случае классов, их методы содержатся в структуре zend_class_entry. Получим указатель на оригинальную функцию или метод класса c помощью функции работы с хеш-таблицами zend_hash_str_find_ptr:

...
auto *original_function = static_cast<zend_function *>(zend_hash_str_find_ptr(
        clazz != nullptr ? &clazz->function_table : CG(function_table), function->val,
        function->len));
...

Далее убедимся, что мы что-то нашли и это точно встроенная, а не пользовательская функция, поскольку с пользовательскими функциями такой подход не сработает.

...
if (original_function != nullptr) {
    if (original_function->internal_function.type == ZEND_INTERNAL_FUNCTION) {
    //...
  }
}
...

original_function является указателем на union _zend_function, который в свою очередь содержит поле internal_function типа zend_internal_function, а в нем есть нужный нам zif_handler handler, его то мы и будем подменять на функцию ZEND_NAMED_FUNCTION(my_hook) которую мы объявили выше:

...
original_function->internal_function.handler = my_hook;
...

Запустим проект и теперь увидим, что вместо даты у нас выводится текст "my_hook" и ошибка Fatal error: date(): Return value must be of type string, null returned in Unknown on line 0 поскольку мы изменили обработчик функции но не ее сигнатуру.

Теперь нам нужно как-то сохранить callback-функции и обработчик оригинальной функции date для их последующего вызова внутри ZEND_NAMED_FUNCTION(my_hook)

Для хранения оригинальных обработчиков и callback-функций напишем собственную структуру, разместим ее в файле php_hooks.h

...
struct hook_t {
    std::string name;

    zend_fcall_info before_fci{};
    zend_fcall_info after_fci{};

    zend_fcall_info_cache before_fcc{};
    zend_fcall_info_cache after_fcc{};

    zif_handler original_handler;
};


ZEND_BEGIN_MODULE_GLOBALS(hooks)
    std::map<std::string, std::shared_ptr<hook_t>> *originals;
ZEND_END_MODULE_GLOBALS(hooks)
...

В макросе ZEND_MODULE_GLOBALS мы можем расположить глобальные переменные необходимые для работы нашего модуля, макрос разворачивается в обычную структуру. Это особенно важно если php работает в режиме php-fpm, подмена функции будет действовать на протяжении жизни всего процесса, если такое поведение не требуется, то необходимо либо добавить функцию снятия хука и возвращать оригинальный handler, либо делать это автоматически в PHP_RSHUTDOWN

Для использования переменных объявленных в макросе ZEND_MODULE_GLOBALS в файле hooks.cpp, после подключения заголовочных файлов необходимо добавить вызов макроса

ZEND_DECLARE_MODULE_GLOBALS(hooks)

Кроме этого их необходимо проинициализировать в callback PHP_MINIT_FUNCTION, который инициализируется при загрузке модуля, добавим следующий код в hooks.cpp

...
static void php_init_globals(zend_hooks_globals *ng) {
    ng->originals = new std::map<std::string, std::shared_ptr<hook_t>>();
}

PHP_MINIT_FUNCTION (hooks) {
    ZEND_INIT_MODULE_GLOBALS(hooks, php_init_globals, null);
    return SUCCESS;
}
...

Также необходимо не забыть добавить данную функцию инициализации модуля в структуру zend_module_entry, сделать это можно с помощью макроса PHP_MINIT(hooks).

Теперь глобальные переменные будут доступны через hooks_globals, дополним логику функции hooks_set_hook сохранением всех необходимых данных и она примет следующий вид:

PHP_FUNCTION (hooks_set_hook) {
    // Инициализируем необходимые переменные для получения аргементов
    zend_string *function = nullptr;
    zend_class_entry *clazz = nullptr;
    zend_fcall_info before_fci = zend_fcall_info();
    zend_fcall_info after_fci = zend_fcall_info();
    zend_fcall_info_cache before_fcc = zend_fcall_info_cache();
    zend_fcall_info_cache after_fcc = zend_fcall_info_cache();

    // Проверяем соответствует ли текущий вызов одному из двух вариантов
    if (zend_parse_parameters_ex(ZEND_PARSE_PARAMS_QUIET, ZEND_NUM_ARGS(), "CSff",
                                 &clazz, &function, &before_fci, &before_fcc, &after_fci, &after_fcc) != SUCCESS and
        zend_parse_parameters_ex(ZEND_PARSE_PARAMS_QUIET, ZEND_NUM_ARGS(), "Sff",
                                 &function, &before_fci, &before_fcc, &after_fci, &after_fcc) != SUCCESS
            ) {
        // Если мы получили не те аргементы которые ожидали выбросим исключение и вернем false
        zend_throw_exception_ex(spl_ce_InvalidArgumentException, 0, "Invalid arguments");
        RETURN_FALSE;
    }

    // Получим указатель на оригинальную функцию или метод класса
    auto *original_function = static_cast<zend_function *>(zend_hash_str_find_ptr(
            clazz != nullptr ? &clazz->function_table : CG(function_table), function->val,
            function->len));
    // Проверим, что мы нашли встроенную функцию
    if (original_function != nullptr) {
        if (original_function->internal_function.type == ZEND_INTERNAL_FUNCTION) {
            // Сформируем ключ для std::map
            std::string key;
            if (clazz) {
                key.append(clazz->name->val);
                key.append("::");
            }
            key.append(function->val);

            //Проверим не установили ли мы хук ранее
            auto original_it = hooks_globals.originals->find(key);
            if (original_it == hooks_globals.originals->end()) {
                //сохраним все, что необходимо в глобальную переменную модуля
                auto hook = std::make_shared<hook_t>();
                hook->name = key;
                hook->before_fci = before_fci;
                hook->after_fci = after_fci;

                hook->before_fcc = before_fcc;
                hook->after_fcc = after_fcc;
                hook->original_handler = original_function->internal_function.handler;
                hooks_globals.originals->insert(std::pair<std::string, std::shared_ptr<hook_t>>(key, std::move(hook)));
                original_function->internal_function.handler = my_hook;
                RETURN_TRUE;
            }
        }
    }
    RETURN_FALSE;
}

На этом написание функции установки хуков завершено.

Шаг 7. Реализация выполнения callback функций, перехват аргументов, исключений и результата целевой функции.

Вернемся к функции ZEND_NAMED_FUNCTION(my_hook) и напишем ее реализацию, в эту функцию передается два аргумента по ссылке - zend_execute_data *execute_data и zval *return_value. Первый хранит всю информацию о вызове функции, во второй нужен для возвращаемого значения.
Для начала получим название той функции которая была вызвана и попробуем найти ее среди сохраненных хуков:

...
// название функции или метода
auto function_name = execute_data->func->internal_function.function_name;
std::string key;
// если есть this то имеем дело с методом класса
if (getThis() != nullptr) {
    key.append(execute_data->func->internal_function.scope->name->val);
    key.append("::");
}
key.append(function_name->val);
// проверяем есть ли ключ в таблице, если нет завершаем работу.
auto hook_info = hooks_globals.originals->find(key);
if (hook_info == hooks_globals.originals->end()) {
    return;
}
...

Следующим шагом просто вызовем оригинальную функцию:

...
hook_info->second->original_handler(INTERNAL_FUNCTION_PARAM_PASSTHRU);
...

Теперь можно снова запустить проект и если все сделано верно в терминале мы увидим дату полученную функцией date. Осталось добавить вызовы сохраненных callback-функций до и после оригинальной. Начнем с подготовки аргументов для первой callback-функции, она в качестве аргумента должна принимать имя оригинальной функции и все ее параметры.

...
auto hook_info = hook_info_it->second;

// Массив аргументов для callback-функции, будет содержать ключи name и args
HashTable *handler_args_array;
ALLOC_HASHTABLE(handler_args_array);
zend_hash_init(handler_args_array, 0, NULL, ZVAL_PTR_DTOR, 0);

//Аргументы для callback-функции
zval handler_args_zval[2];
ZVAL_ARR(&handler_args_zval[0], handler_args_array);

// Массив аргументов переданных при вызове оригинальной функции
HashTable *hooked_args_array;
ALLOC_HASHTABLE(hooked_args_array);
zend_hash_init(hooked_args_array, 0, NULL, ZVAL_PTR_DTOR, 0);

zval hooked_args_zval;
ZVAL_ARR(&hooked_args_zval, hooked_args_array);

// копируем переданные аргументы, мы получим именно копию
int arg_count = ZEND_CALL_NUM_ARGS(execute_data);
for (int i = 1; i <= arg_count; i++) {
    zend_hash_next_index_insert(hooked_args_array, ZEND_CALL_ARG(execute_data, i));
}
// подготавливаем массив handler_args_array
zval hook_name;
ZVAL_STRING(&hook_name, key.c_str());
zend_hash_str_add(handler_args_array, "name", 4, &hook_name);
zend_hash_str_add(handler_args_array, "args", 4, &hooked_args_zval);

// Производим вызов первой callback-функции, передавая в качестве аргумента массив
hook_info->before_fci.params = handler_args_zval;
hook_info->before_fci.param_count = 2;
// переменная для сохранения результата работы callback-функции
zval before_ret;
hook_info->before_fci.retval = &before_ret;

zend_call_function(&hook_info->before_fci, &hook_info->before_fcc);
...

Теперь если мы запустим проект, то перед вызовом date будет вызван наш callback. Осталось дело за малым, подготовить аргументы вызова второй callback-функции и вызвать ее. Сохраним результат работы оригинальной функции, проверим есть ли исключение и сохраним его.

...
// Добавим результат работы оригинальной функции в массив аргументов
if (Z_TYPE_P(return_value) != IS_NULL) {
    zend_hash_str_add(handler_args_array, "result", 6, return_value);
}
// проверим есть ли необработанное исключение и если есть сохраним ссылку на него
auto exception = EG(exception);
if (exception != nullptr) {
    zval exception_zval;
    ZVAL_OBJ(&exception_zval, exception);
    zend_hash_str_add(handler_args_array, "exception", 9, &exception_zval);
    zend_exception_save();
}

// Производим вызов второй callback-функции, передавая в качестве аргумента массив
hook_info->after_fci.params = handler_args_zval;
hook_info->after_fci.param_count = 2;
// переменная для сохранения результата работы callback-функции
zval after_ret;
hook_info->after_fci.retval = &after_ret;

zend_call_function(&hook_info->after_fci, &hook_info->after_fcc);

// Восстановим исключение
if (exception != nullptr) {
    zend_exception_restore();
}

//Не забываем освобождать ресурсы
zend_hash_destroy(handler_args_array);
FREE_HASHTABLE(handler_args_array);
...

Вызов zend_exception_save необходим для того, чтобы подавить исключение и продолжить выполнение функции и вызова второго callback, после чего мы восстанавливаем исключение с помощью zend_exception_restore.

Запустим проект еще раз и если все было сделано без ошибок, увидим выполнение оригинальной функции и двух callback-функций.

На этом реализация расширения для php на с++ закончена. Ознакомиться с проектом целиком можно в репозитории на github, ссылка будет в конце статьи.

Шаг 8. Проверка корректной работы модуля

Мы протестировали работу с одной функцией date, но наше расширение должно работать и с методами классов, а также уметь перехватывать исключения, давайте проверим это на примере подключения к не существующей БД SQLite:

<?
$before = function ($args) {
    echo "before function called\n";
    print_r($args);
};

$after = function ($args, $_this = null) {
    echo "after function called\n";
    print_r($args);
};

hooks_set_hook('PDO', '__construct', $before, $after);


try {
    // Подключение к базе данных SQLite
    $pdo = new PDO('sqlite:/database.db');
} catch (Exception $e) {
    echo 'Connection failed: ' . $e->getMessage();
};

После запуска получим следующее:

Hidden text
before function called
Array
(
    [name] => PDO::__construct
    [args] => Array
        (
            [0] => sqlite:/database.db
        )

)
after function called
Array
(
    [name] => PDO::__construct
    [args] => Array
        (
            [0] => sqlite:/database.db
        )

    [exception] => PDOException Object
        (
            [message:protected] => SQLSTATE[HY000] [14] unable to open database file
            [string:Exception:private] =>
            [code:protected] => 14
            [file:protected] => /mnt/c/Users/artuk/Desktop/hooks2/test.php
            [line:protected] => 17
            [trace:Exception:private] => Array
                (
                    [0] => Array
                        (
                            [file] => /mnt/c/Users/artuk/Desktop/hooks2/test.php
                            [line] => 17
                            [function] => __construct
                            [class] => PDO
                            [type] => ->
                            [args] => Array
                                (
                                    [0] => sqlite:/database.db
                                )

                        )

                )

            [previous:Exception:private] =>
            [errorInfo] => Array
                (
                    [0] => HY000
                    [1] => 14
                    [2] => unable to open database file
                )

        )

)
Connection failed: SQLSTATE[HY000] [14] unable to open database file
Process finished with exit code 0

Все работает как и предполагалось. Данное расширение можно использовать например для логирования, отладки или сбора телеметрии.

Заключение

На этом разработка данного расширения закончена, конечно в текущей реализации существуют некоторые проблемы и я постараюсь их исправить в дальнейшем. Также было бы хорошо научиться работать с пользовательскими функциями, но текущий подход не позволяет делать это так просто, если кто-то знает хорошее решение пожалуйста свяжитесь со мной.

В этой статье мы подробно рассмотрели процесс создания расширения для PHP на C++. Начиная с настройки среды разработки и создания проекта, мы последовательно прошли через шаги по реализации хуков и выполнения callback-функций. Мы покрыли ключевые моменты, от встраивания хуков во встроенные PHP-функции до подмены целевых функций и обработки аргументов, исключений и результатов.

Надеюсь эта информация будет вам полезна.

Репозиторий проекта:

https://github.com/ArtUkrainskiy/hooks

Полезные материалы:

https://github.com/php/php-src/ - официальный репозиторий php

https://www.phpinternalsbook.com/ - хороший справочный материал, хоть местами и сильно устарел

Комментарии (2)


  1. MaxPro33
    08.12.2023 15:04

    Вы упомянули, что ваше расширение позволяет перехватывать вызовы встроенных функций и методов классов в PHP. Можете ли вы предоставить пример использования вашего расширения для перехвата вызова какой-либо конкретной встроенной функции или метода класса?


  1. WowaBBS
    08.12.2023 15:04

    Ещё было бы интересно (и немного проще в использовании) написание данного "расширения" на PHP с использованием FFI.