
Я довольно долго пишу бэкенд на PHP: в основном это привычные API, админки, интеграции и очереди. Для большинства задач этого стека более чем достаточно. Однако со временем мне стало интересно разобраться, как устроен PHP «под капотом», и попробовать написать код, который будет работать не поверх интерпретатора, а внутри него. Заодно появился хороший повод познакомиться с Rust — языком, который всё чаще используют для создания производительных и безопасных системных компонентов.
В этой статье мы с нуля напишем собственное PHP-расширение на Rust. Разберёмся, как устроен процесс разработки, как собрать расширение и подключить его к PHP, как экспортировать функции и работать с типами данных. После прочтения у вас будет готовый шаблон проекта и понимание того, как создавать собственные экстеншены без необходимости писать код на C и вручную работать с Zend API.
Для реализации я выбрал ext-php-rs — библиотеку, которая предоставляет удобную обёртку над Zend API и позволяет собирать PHP-расширения обычной командой cargo build, без phpize и написания glue-кода на C. На практике это выглядит так: вы описываете Rust-функции, помечаете их макросами и получаете файл .so, который PHP загружает как обычное расширение.
Мы не будем ограничиваться только сборкой проекта. В конце статьи сравним производительность расширения с реализацией на чистом PHP на нескольких практических примерах: от классического Hello World до генерации UUID, обработки строк регулярными выражениями и парсинга логов. Все примеры, бенчмарки и исходный код доступны в репозитории alexdontsov/php_rust_ext.
Что такое ext-php-rs
ext-php-rs — это библиотека, которая предоставляет безопасную и удобную обёртку над внутренним API PHP (Zend API). Она позволяет разрабатывать PHP-расширения на Rust, не погружаясь в детали взаимодействия с движком и не работая напрямую с API, написанным на C.
При разработке классического расширения приходится самостоятельно регистрировать функции и классы, работать с zval, разбирать аргументы, управлять памятью через emalloc/efree, а также использовать phpize и систему сборки Autotools. ext-php-rs берёт большую часть этой работы на себя. В результате разработчик получает обычный Rust-проект с привычной экосистемой Cargo и минимальным количеством специфичного для PHP кода.
Основные макросы библиотеки:
#[php_function]— экспортирует функцию в PHP;#[php_module]— описывает модуль расширения;#[php_class] и #[php_impl]— экспортируют классы и их методы (в этой статье мы их рассматривать не будем).
Библиотека автоматически преобразует большинство распространённых типов данных между Rust и PHP. Например, String, bool, Vec<T>, HashMap<String, T> и Result<T, E> можно использовать прямо в сигнатурах функций, не работая вручную с zval. Если функция возвращает Result, ошибка автоматически преобразуется в PHP-исключение.
Документация проекта:
Для работы с библиотекой потребуется PHP 8.1 или новее, установленные заголовочные файлы (php-dev) и Clang. Также важно помнить, что расширение собирается под конкретную версию PHP, установленную в системе, поэтому при переносе на другой сервер его, как правило, необходимо пересобирать. Чтобы избежать подобных проблем, при этом удобно использовать Docker: собирать расширение в контейнере с той же версией PHP, которая используется в целевом окружении. Это делает сборку воспроизводимой и исключает расхождения между локальной машиной, CI и продакшеном.
Предварительные требования
Перед началом работы нужно убедиться, что в системе установлены следующие компоненты:
Rust (
rustup,cargo);PHP CLI и заголовочные файлы для разработки (в Ubuntu — пакет
php-dev);php-config(обычно устанавливается вместе сphp-devи используетсяext-php-rsво время сборки);инструменты для сборки C-проектов (
build-essential).
Проверить, что всё установлено корректно, можно следующими командами:
php -v php-config --version cargo --version
Если каждая из команд успешно выводит информацию о версии, значит окружение готово к созданию PHP-расширения.
Создаём проект расширения
Создадим новый проект:
cargo new --lib php_rust_ext cd php_rust_ext
Теперь настроим файл Cargo.toml:
[package] name = "php_rust_ext" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] ext-php-rs = "*"
Параметр crate-type = ["cdylib"] указывает Cargo, что результатом сборки должна стать динамическая библиотека (.so в Linux, .dll в Windows или .dylib в macOS). Именно такую библиотеку PHP может загрузить как расширение.
В качестве зависимости подключаем ext-php-rs, которая возьмёт на себя взаимодействие с Zend API.
Пишем минимальный код
Создадим файл src/lib.rs и добавим в него следующее содержимое:
use ext_php_rs::prelude::*; #[php_function] pub fn hello_rust(name: String) -> String { format!("Привет, {name}! Это Rust-экстеншен.") } #[php_module] pub fn module(module: ModuleBuilder) -> ModuleBuilder { module }
Несмотря на небольшой объём, этот код уже представляет собой полноценное PHP-расширение.
Разберём его по частям:
use ext_php_rs::prelude::*;— подключает основные типы, трейты и макросы библиотекиext-php-rs.#[php_function]сообщает библиотеке, что функциюhello_rust()необходимо экспортировать в PHP. После сборки её можно будет вызвать из PHP-кода как обычную функцию.Сигнатура функции выглядит как обычная Rust-функция.
ext-php-rsавтоматически преобразует строку из PHP вString, а возвращаемое значениеString— обратно в строку PHP.#[php_module]объявляет точку входа расширения. Во время загрузки PHP вызывает эту функцию, аModuleBuilderиспользуется для регистрации функций, классов и других сущностей, которые будут доступны из PHP.
Сборка
После того как код готов, можно собрать расширение. В большинстве случаев достаточно выполнить одну команду:
cargo build --release
Cargo скомпилирует проект и создаст динамическую библиотеку, которую PHP сможет загрузить как расширение. Готовый файл будет находиться в каталоге target/release/. В Linux это библиотека с расширением .so, например:
target/release/libphp_rust_ext.so
Обратите внимание, что Cargo по умолчанию добавляет к имени библиотеки префикс lib.
Подключаем расширение в PHP
Для начала проще всего подключить расширение только на время выполнения одной команды, не меняя настройки PHP.
php -d extension=/полный/путь/to/libphp_rust_ext.so \ -r 'echo hello_rust("МИР"), PHP_EOL;'
Если расширение загрузилось успешно, вы увидите результат работы функции:
Привет, МИР! Это Rust-экстеншен.
Такой способ удобен во время разработки: достаточно пересобрать библиотеку и снова выполнить команду, не редактируя php.ini.
Если же расширение должно быть доступно постоянно, добавьте его в конфигурационный файл php.ini:
extension=/полный/путь/to/libphp_rust_ext.so
После этого перезапустите PHP (или PHP-FPM, если используете его).
Проверить, что расширение действительно загружено, можно командой:
php -m | rg php_rust_ext
Если в списке модулей появится php_rust_ext, значит расширение успешно подключено и готово к использованию.
Пример чуть полезнее: быстрая обработка массива
В предыдущем примере мы экспортировали простую функцию, принимающую строку. Теперь рассмотрим более практичный случай — передачу массива из PHP в Rust.
use ext_php_rs::prelude::*; #[php_function] pub fn sum_ints(values: Vec<i64>) -> i64 { values.iter().sum() } #[php_module] pub fn module(module: ModuleBuilder) -> ModuleBuilder { module }
PHP:
<?php $result = sum_ints([1, 2, 3, 4, 5]); echo $result; // 15 $result = sum_ints([1, 2, 3, 4, 5]); echo $result; // 15
С точки зрения Rust здесь нет ничего необычного: функция принимает Vec<i64> и возвращает сумму всех элементов.
Вызов из PHP выглядит так:
<?php $result = sum_ints([1, 2, 3, 4, 5]); echo $result; // 15
Несмотря на то что из PHP передается обычный массив, в Rust он автоматически преобразуется в Vec<i64>. Аналогично, возвращаемое значение i64 без дополнительного кода конвертируется обратно в целое число PHP.
Именно в этом одно из главных преимуществ ext-php-rs: разработчик работает с привычными типами Rust, а библиотека самостоятельно выполняет преобразование данных между двумя языками.
Такой подход особенно удобен для задач, где основное время тратится на вычисления: обработку больших массивов, сериализацию, валидацию данных, парсинг, работу с бинарными форматами или другие CPU-bound операции. В этих случаях перенос «горячих» участков кода в Rust может дать заметный прирост производительности без необходимости переписывать всё приложение.
Боевой пример: генерация UUID
Предыдущие примеры были демонстрационными, но на практике PHP-расширения обычно пишут ради конкретной задачи: ускорить вычисления, использовать существующую библиотеку или вынести наиболее нагруженный участок приложения в нативный код.
В качестве примера реализуем небольшой набор функций для работы с UUID. В PHP для этого часто используют пакет ramsey/uuid, а в Rust есть производительный крейт uuid, который поддерживает все современные версии идентификаторов и отлично подходит для подобных задач.
Для начала добавим новую зависимость в Cargo.toml:
Добавим новую зависимость в Cargo.toml:
[dependencies] ext-php-rs = "0.12" uuid = { version = "1", features = ["v4", "v7"] }
Теперь расширим файл src/lib.rs:
use ext_php_rs::prelude::*; use uuid::Uuid; const MAX_BATCH_SIZE: u64 = 10_000; #[php_function] pub fn uuid_generate() -> String { Uuid::new_v4().to_string() } #[php_function] pub fn uuid_generate_v7() -> String { Uuid::now_v7().to_string() } #[php_function] pub fn uuid_is_valid(value: String) -> bool { Uuid::parse_str(&value).is_ok() } #[php_function] pub fn uuid_generate_batch(count: u64) -> Result<Vec<String>, String> { if count == 0 { return Err("count must be greater than 0".into()); } if count > MAX_BATCH_SIZE { return Err(format!("count must not exceed {MAX_BATCH_SIZE}")); } Ok((0..count).map(|_| Uuid::new_v4().to_string()).collect()) } #[php_module] pub fn module(module: ModuleBuilder) -> ModuleBuilder { module }
В этом примере уже можно увидеть несколько полезных возможностей ext-php-rs:
uuid_generate()— генерирует случайный UUID версии 4.uuid_generate_v7()— создаёт UUID версии 7. Благодаря временной составляющей такие идентификаторы упорядочиваются по времени создания и лучше подходят для использования в качестве первичных ключей в базе данных.uuid_is_valid()— проверяет корректность UUID и возвращаетtrueилиfalse, не используя исключения для обычного сценария проверки.uuid_generate_batch()— генерирует сразу несколько UUID в одном вызове. Вся работа выполняется внутри Rust, что позволяет избежать лишних переходов между PHP и нативным кодом при массовой генерации.
Обратите внимание на сигнатуру функции uuid_generate_batch():
pub fn uuid_generate_batch(count: u64) -> Result<Vec<String>, String>
Возвращаемый тип Result<Vec<String>, String> не требует дополнительной обработки. Если функция возвращает Ok, ext-php-rs автоматически преобразует Vec<String> в массив PHP. Если же возвращается Err, библиотека сама создаст и выбросит PHP-исключение с текстом ошибки.
Соберём библиотеку и перейдём в PHP-код:
<?php $id = uuid_generate(); echo "uuid_generate(): {$id}\n"; echo 'uuid_is_valid(): ' . (uuid_is_valid($id) ? 'yes' : 'no') . "\n"; $v7 = uuid_generate_v7(); echo "uuid_generate_v7(): {$v7}\n"; $batch = uuid_generate_batch(5); foreach ($batch as $index => $uuid) { echo ($index + 1) . ". {$uuid}\n"; }
Пример вывода:
uuid_generate(): 86d32c74-adc5-409e-96c3-dd274587a557 uuid_is_valid(): yes uuid_generate_v7(): 019ea638-f268-7713-94ae-15b6a4967423 1. 4e5fc411-5940-4670-9aca-4a379dee12bf 2. 75a4b1a9-1f0f-4a1b-876b-f466e8a4626c
Такой пример наглядно показывает, зачем выносить логику в Rust: переиспользуем готовый крейт, получаем типобезопасность и быструю пакетную обработку, а в PHP остаётся простой API из нескольких функций.
Пример 2: обработка текста с помощью регулярных выражений
Ещё один распространённый сценарий — обработка больших текстовых файлов: логов веб-сервера, дампов, CSV или других данных, где необходимо найти все совпадения по регулярному выражению.
В PHP для этого обычно используют preg_match_all(). Однако если объём входных данных измеряется десятками или сотнями мегабайт, имеет смысл выполнить всю обработку внутри Rust и вернуть в PHP уже готовый результат.
Добавим в проект крейт regex:
regex = "1"
Реализуем две функции в src/lib.rs:
#[php_function] pub fn extract_matches(text: String, pattern: String) -> Result<Vec<String>, String> { let re = Regex::new(&pattern) .map_err(|e| e.to_string())?; Ok(re.find_iter(&text) .map(|m| m.as_str().to_string()) .collect()) } #[php_function] pub fn extract_match_count(text: String, pattern: String) -> Result<i64, String> { let re = cached_regex(&pattern)?; // для многострочного текста считаем совпадения построчно // ... Ok(count as i64) } fn cached_regex(pattern: &str) -> Result<Regex, String> { static CACHE: OnceLock<Mutex<HashMap<String, Regex>>> = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut guard = cache .lock() .map_err(|_| "regex cache lock poisoned".to_string())?; if let Some(regex) = guard.get(pattern) { return Ok(regex.clone()); } let regex = Regex::new(pattern).map_err(|error| format!("invalid regex: {error}"))?; guard.insert(pattern.to_string(), regex.clone()); Ok(regex) }
PHP (паттерн в синтаксисе Rust, без /.../):
$log = file_get_contents('access.log'); $ips = extract_matches($log, '\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b'); echo count($ips), " IP-адресов\n";
Пример 3: парсинг и валидация access log
Следующий шаг — не просто найти совпадения, а посчитать метрики: сколько строк валидно, сколько 4xx/5xx, суммарный трафик.
use std::collections::HashMap; #[php_function] pub fn parse_access_log(raw: String) -> HashMap<String, i64> { let mut lines = 0_i64; let mut valid_lines = 0_i64; let mut invalid_lines = 0_i64; let mut status_2xx = 0_i64; let mut status_4xx = 0_i64; let mut status_5xx = 0_i64; let mut total_bytes = 0_i64; for line in raw.lines() { lines += 1; let Some((status, bytes)) = parse_log_line(line) else { invalid_lines += 1; continue; }; valid_lines += 1; total_bytes += bytes; if status >= 200 && status < 300 { status_2xx += 1; } else if status >= 400 && status < 500 { status_4xx += 1; } else if status >= 500 && status < 600 { status_5xx += 1; } } HashMap::from([ ("lines".into(), lines), ("valid_lines".into(), valid_lines), ("invalid_lines".into(), invalid_lines), ("status_2xx".into(), status_2xx), ("status_4xx".into(), status_4xx), ("status_5xx".into(), status_5xx), ("total_bytes".into(), total_bytes), ]) }
Внутри parse_log_line — ручной разбор полей (быстрее, чем preg_match на каждой строке в PHP). Regex остаётся для гибкого поиска, а структурированный парсинг — через детерминированный код.
PHP:
$stats = parse_access_log(file_get_contents('access.log')); echo "valid: {$stats['valid_lines']}\n"; echo "4xx: {$stats['status_4xx']}\n"; echo "bytes: {$stats['total_response_bytes']}\n"; Один вызов из PHP — вся обработка 100 000 строк в Rust.
Один вызов из PHP — вся обработка 100 000 строк в Rust.
Сравнение производительности
Любые заявления о производительности имеют смысл только после измерений. В репозитории проекта есть скрипт examples/benchmark.php, который сравнивает реализацию на чистом PHP с аналогичными функциями, реализованными в Rust-расширении.
Запустить тест можно так:
cargo build --release php -d extension=target/release/libphp_rust_ext.so \ examples/benchmark.php
или воспользоваться готовым скриптом:
./examples/benchmark.sh
Для повышения точности измерений бенчмарк предварительно прогревает код, использует hrtime (true) и сравнивает эквивалентные реализации одной и той же задачи. В тестах рассматриваются несколько сценариев:
генерация UUID (одиночная и пакетная);
обработка access log с помощью регулярных выражений;
парсинг access log;
array_sum()как пример функции, где накладные расходы на переход между PHP и Rust оказываются выше потенциальной выгоды.
Фикстура журнала содержит около 100 000 строк (≈8,6 МБ).
Результаты (PHP 8.1, release-сборка)
Задача | PHP | Rust | Результат |
UUID v4 | 0.70 μs/op | 0.42 μs/op | ≈1.7× быстрее |
10 000 UUID | 7031 μs/op | 4203 μs/op | ≈1.7× быстрее |
Валидация 100 000 строк | 16050 μs/op | 15512 μs/op | практически одинаково |
Парсинг 100 000 строк access log | 35041 μs/op | 9672 μs/op | ≈3.6× быстрее |
| 25 μs/op | 172 μs/op | PHP быстрее |
Разумеется, абсолютные значения будут зависеть от процессора, версии PHP и условий запуска. Однако сами тенденции достаточно показательны.
Что можно вынести из результатов
Сам факт использования расширения ещё не гарантирует прирост производительности. Намного важнее характер выполняемой задачи.
Наиболее заметный выигрыш получается тогда, когда в одном вызове расширение выполняет большой объём вычислений: парсит лог, обрабатывает бинарные данные, сериализует информацию или агрегирует результаты. В этом случае накладные расходы на переход между PHP и Rust практически незаметны, а более производительный код начинает окупаться.
Если же задача сводится к нескольким простым операциям или обработке небольшого объёма данных, выигрыш оказывается значительно меньше. Например, генерация UUID выполняется быстрее, но разница уже не выглядит столь впечатляющей.
Есть и сценарии, где перенос логики в расширение вовсе не имеет смысла. Функция array_sum() — хороший пример: она уже хорошо оптимизирована внутри PHP, а дополнительные преобразования данных между PHP и Rust только увеличивают время выполнения.
Из этого следует простой практический вывод: имеет смысл переносить в Rust не отдельные операции, а целиком вычислительный участок программы. Чем больше работы выполняется внутри расширения и чем меньше данных приходится передавать обратно в PHP, тем выше вероятность получить реальный прирост производительности.
На что обратить внимание в продакшене
Перед использованием расширений в рабочем проекте стоит учитывать несколько особенностей:
расширение собирается под конкретную версию PHP, поэтому необходимо следить за совместимостью API;
ошибки лучше возвращать в виде
Result, позволяяext-php-rsпреобразовывать их в PHP-исключения, а не допускать выходpanic!за пределы Rust-кода;перед переносом логики в расширение стоит провести профилирование — далеко не каждое узкое место связано с вычислениями;
при деплое необходимо доставлять
.soвместе с приложением или собирать его в том же Docker-образе, который используется в продакшене.
Заключение
Для меня ext-php-rs стал относительно простым способом познакомиться с разработкой PHP-расширений без глубокого погружения в C и Zend API. Благодаря экосистеме Rust порог входа оказался заметно ниже: достаточно создать проект, написать несколько функций и собрать динамическую библиотеку.
Главный вывод из экспериментов прост: Rust не делает код автоматически быстрее. Если работа небольшая, выигрыш может нивелироваться накладными расходами на переход между PHP и расширением. Но когда за один вызов выполняется большой объём вычислений — парсинг, обработка данных или другие CPU-bound задачи — такой подход действительно способен дать ощутимый прирост производительности.
Надеюсь, эта статья поможет вам сделать первые шаги в разработке PHP-расширений на Rust и вдохновит на собственные эксперименты.
Автор текста: Александр Донцов
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.

