Выходя за рамки ООП. Разработка расширений для PHP на PHP
Какие есть границы в PHP? Синтаксические они или это рантайм, или же просто ограничения языка, которые нельзя обойти. Почему они существуют? Давайте посмотрим как преодолеть границы живого языка и как взломать его изнутри. Это же всегда интересно?
В этом нам поможет Александр Лисаченко. Он автор аспектно-ориентированного фреймворка Go! AOP и вообще гуру в Enterprise архитектуре. Основой для статьи стало его выступление на PHP Russia 2021.
Границы ООП
Наверное, одна из самых известных границ — это принцип открытости и закрытости. Если применить его к классам, он гласит: класс может быть открыт для расширения, но при этом закрыт для изменения.
C помощью ключевого слова final можно сделать финальный класс закрытым от наследования:
final class Secret
{
public function test ()
{
echo ‘Yes’;
}
}
Но тогда может возникнуть другой вопрос: «А можно ли создать mock такого класса (class Child extends Secret {})»?
Если попытаться сделать это напрямую, то появится сообщение о фатальной ошибке. Потому что существующий финальный класс никаким образом не может быть расширен. Так устроен язык.
Но что делать, если финальный класс необходимо замокать? Есть несколько вариантов.
Наверное, самый простой и известный — это возможность языка PHP создавать пользовательские stream wrapper. Когда PHP подключает какой-то файл, он использует специальный обработчик для встроенного типа file. Если этот обработчик выгрузить с помощью stream_wrapper_unregister («file»), то при подключении файла, PHP скажет, что не может никак его подключить. Но если после этого зарегистрировать свой новый класс обработчика, то можно будет получить контроль над всем исходным кодом файла, который будет загружаться. Используя данный подход можно сделать токенизацию и преобразовать исходный код PHP так, чтобы он не содержал больше токен final, после чего вернуть код обратно компилятору для выполнения без токена final — и тогда компилятор будет рад его выполнить, потому что класс перестанет быть финальным.
Достичь этого можно также использованием другого варианта на основе stream filters — специальной возможности PHP подключать пользовательские фильтры поверх потоков данных.
Рассмотрим простой файл:
Он не содержит ничего, кроме надписи «Hello, world!».
Если добавить к include специальную конструкцию с php://filter/read, то к файлу magic.php применится преобразование и будет выполняться уже то, что получилось в его результате. В данном случае встроенный в PHP фильтр string.toupper приводит всё к верхнему регистру. Фактически, исходный код никак не меняется в файловой системе, а только трансформируется через фильтр, после чего возвращается обратно в PHP для компиляции и выполнения, и в итоге текст уже отображается в верхнем регистре: HELLO, WORLD!
У этого подхода с фильтрами довольно широкое применение. Например, если посмотреть поглубже фреймворк Go! AOP, то это одна из недокументированных функциональностей языка, которая используется, чтобы сделать инструментацию кода и провести какие-то трансформации на лету.
Также этот способ удобен для всевозможного анализа и защиты кода в режиме выполнения. Например, можно зашифровать файл и поставлять его уже не в виде исходного кода, а как зашифрованный, и проверять, допустим, подпись лицензии. Также можно реализовать нестандартные возможности, в том числе, отключение ключевого слово final как было отмечено выше.
Immutable структуры PHP
Одна из самых фундаментальных границ — это неизменяемая или иммутабельная структура самого PHP. Здесь тоже часто возникает вопрос — чем является PHP: интерпретатором или компилятором?
Интересный факт в том, что PHP — это одновременно и интерпретатор, и компилятор. Нельзя просто взять и выполнить код на PHP. В отличие от того же GoLang, Rust или C++, PHP не может подготовить скомпилированный бинарный файл. Поэтому чтобы выполнить скрипт, PHP надо выполнить следующие шаги:
разобрать код по токенам
скомпилировать его в бинарный OpCode
выполнить опкод с помощью ZendEngine
получить результат выполнения
Однако, такое происходит только при первом выполнении скрипта. При повторном выполнении этого же скрипта и включенном кешировании опкодов, операции лексического разбора кода уже может не быть, так как будет повторно использован сгенерированный ранее OpCache, содержащий готовый бинарный OpCode. Правда, если что-то меняется в исходном скрипте, то нужно повторно провести лексический разбор кода и обновить бинарный OpCode в OpCache, однако если отключить проверку даты последнего обновления файла с исходным кодом (filemtime) с помощью opcache.validate_timestamps=0 в боевом режиме, то ZendEngine совсем не будет проверять, что хранится в исходном файле.
OpCache — это фиксированный блок памяти, представляющий собой бинарные структуры. Не получится подключиться туда напрямую из PHP. Хотя эти структуры по своей природе вроде как неизменяемые, но изменить их можно, если как-то получить доступ напрямую к этой памяти. По-умолчанию, OpCache работает только в режиме добавления новых структур и как только мы заполняем OpCache — то ничего из него уже не удаляется, только постоянно дописывается новое. Благодаря тому, что ничего постоянно не изменяется и не удаляется, достигается высокая эффективность.
В OpCache хранится:
File functions (HashTable) — аналог PHP-массива внутри ZendEngine со списком функций в данном файле;
File classes (HashTable) — классы, определенные в этом файле;
File main OpArray — Скомпилированный OpCode;
Interned strings — дополнительные данные, например, объявленные строки;
Immutable arrays — это особенное представление массивов. Не все массивы в PHP являются неизменяемыми, только часть из них удовлетворяет этим условиям.
Meta-information (system_id, etc) — дополнительная метаинформация.
System_id используется для проверки того, что и OpCode, и OpCache принадлежат к одной и той же версии PHP. Иначе, сформированный бинарный OpCache в виде файлов, мог бы случайно загрузиться неправильно в ZendEngine после обновление PHP на боевом сервере.
Благодаря тому, что OpCache неизменяемый — можно получить огромный выигрыш в производительности. Поэтому, если производятся какие-то низкоуровневые изменения с исходным кодом файлов PHP, (тот же самый Go! AOP, когда он работает с файлами) — то надо пытаться делать так, чтобы эти файлы уже лежали в файловой системе. Это максимально увеличит производительность.
Посмотрим, что нельзя делать с OpCache, и какие ограничения он накладывает на язык:
Нельзя удалять или добавлять методы класса во время выполнения скрипта, что иногда хотелось бы.
Класс, его родители, интерфейсы, трейты и все другие возможные составные части тоже неизменяемые. Какого-то API по изменению этих структур не существует. Более того, низкоуровневый доступ к этим структурам очень сильно ограничен для того, чтобы предотвращать все возможные ошибки.
Но есть нюансы и исключения, такие как runkit, uopz, которые позволяют получить доступ к этим структурам. Эти же расширения дают возможность найти способ, как это сделать с PHP.
Перегрузка операторов (Operator overloading)
Третья граница — это языковые ограничения.
Наверное, многие бы хотели иметь перегрузку операторов, например для классов денег или матриц.
В Википедии есть такое определение: Перегрузка операторов — это возможность осуществить полиморфизм, когда с помощью операторов можно принимать разные типы данных и в зависимости от этого реализовывать какой-то функционал.
Но можно ли умножать или складывать матрицы в PHP?
Начнем с простого примера:
$first = new Matrix ([[10, 20, 30]]);
$second = new Matrix ([[2, 4, 6]]);
$value = $first * 2 + $second;
var_dump($value);
Здесь есть две матрицы, первую умножаем на 2 и прибавляем к ней вторую. Казалось бы, все выглядит красиво, но перегрузка операторов в PHP недоступна.
PHP выдаст сообщение, причем до версии 8.0 вообще с типом notice, что объект класса Matrix не может быть сконвертирован к number. Более того, он выдаст странный результат int (3), то есть каждая матрица будет преобразована к единице.
Начиная с 8 версии, PHP уже ведет себя адекватнее. Есть прямое сообщение о том, что матрица не может быть умножена на число, что уже в принципе близко к нашему исконному требованию о типизации.
Тем не менее, в текущем варианте PHP не поддерживает перегрузку операторов. Есть только открытый, но очень давний rfc, позволяющий сделать перегрузку операторов на PHP. Тем не менее, стоит отметить, что это не поддерживается до сих пор со стороны обычных разработчиков (userland).
Hacking the PHP engine
Перейдем ко взлому PHP-движка. Для этого потребуется FFI, доступный в версии языка PHP 7.4 и старше.
С помощью FFI появляется возможность вызывать всевозможные «сишные» функции, использовать «сишные» структуры, подключать биндинги к внешним библиотекам (librdkafka и т.п.), которые теперь можно подключить в PHP напрямую. Это дает возможность написать свое расширение на PHP для «сишных» библиотек на чистом PHP.
С точки зрения производительности, PHP имеет возможности оптимизации загрузки таких определений. Например, preload этих структур во время загрузки кода.
Если FFI дает возможность получить доступ к «сишным» структурам — почему бы не попробовать использовать FFI в PHP для того, чтобы получить доступ к самому PHP изнутри?
Эта идея стала началом появления библиотеки lisachenko/z-engine.
lisachenko/z-engine
Библиотека построена на базе Reflection (ReflectionClass, ReflectionMethod), но содержит дополнительный функционал. Она предоставляет самый низкоуровневый доступ к структурам PHP прямо во время выполнения этого же кода. Можно посмотреть, что находится в стеке PHP, какие там лежат аргументы, изменить эти аргументы во время вызова функции и вызвать доступные системные хуки. Получается, что из PHP можно низкоуровнево управлять всей работой PHP.
Это на самом деле страшно, потому что приводит зачастую к ужасным последствиям. Все идет плохо, рассыпается, рушится, потому что есть законы, и для PHP программиста лучше не лезть в сторону C, а C-программисту лучше не лезть в сторону PHP!
Поэтому начинать можно только в том случае, если вы знаете, что делаете!
Вернемся к первому вопросу: как замокать финальный класс? Простые варианты уже были рассмотрены, теперь перейдем на более хардкорные вещи, как это сделать на уровне Z-engine с помощью расширений языка.
Давайте посмотрим на структуру zend_class_entry:
struct _zend_class_entry {
char type;
zend_string *name;
/* class_entry or string depending on ZEND_ACC_LINKED */
union {
zend_class_entry *parent;
zend_string *parent_name;
};
int refcount;
uint32_t ce_flags;
…
};
Структура довольно объемная, поэтому приведен только ее заголовок. Интересно поле с названием ce_flags — здесь содержатся различные модификаторы класса: финальный, абстрактный, плюс тип класса — интерфейс или класс.
Попробуем изменить эту структуру с помощью небольшого кусочка кода, написанного поверх фреймворка Z-engine:
Этот код инициализирует ядро Z-engine, затем определяет финальный класс, создает экземпляр ReflectionClass этого класса (расширенный класс, который предоставляет сам фреймворк) и делает простой вызов setFinal (false). С этого момента внутренняя структура класса в PHP уже не будет содержать флажка final. После этого можно наследоваться от этого класса без ограничений, а если надо — то можно возвращать final-флажок обратно.
Реализация метода в самом фреймворке, выглядит довольно просто:
Здесь есть метод setFinal, который принимает логический флажок, и в зависимости от его значения меняет поле ce_flags, устанавливая флаг ZEND_ACC_FINAL, либо снимая его, если хочется сделать его не финальным.
Можно ли написать PHP-extension на PHP?
Давайте реализуем свой первый PHP extension с перегрузкой операторов.
В качестве примера, возьмем библиотеку lisachenko/native-php-matrix, которая работает поверх библиотеки Z-engine и представляет возможность использовать обычные операции сложения и умножения вместе с объектами матриц.
Расширение для сложения и умножения матриц
Библиотека устанавливает внутренние системные хуки в самом движке на замыкания в языке PHP. Например, можно поставить хук на zend_object_do_operation_t. Это специальный хук, который есть у пользовательских объектов. Если его перегрузить, то будет полный контроль того, как PHP выполняет специальные операции с объектом.
А если перезагрузить специальный хук zend_object_compare_zvals_t, то можно будет сравнивать матрицы через двойное «равно».
Получается первое расширение для PHP, написанное на самом PHP. Давайте глянем как это все устроено внутри.
Нам потребуется очередная структура из PHP — zend_object, представляющая собой отдельный объект в самом PHP.
Кроме полей gc, handle, properties_table, есть одно константное поле handlers с типом zend_object_handlers. Это довольно объемная структура, в ней порядка 20 различных хуков, которые существуют для каждого отдельного объекта, а не для класса.
Из этой структуры можно использовать практически все. Например, сделать пользовательские обработчики приведения объекта к скалярному значению или же собственный обработчик, который будет обрабатывать countable интерфейс или возвращать какое-то свое особое представление для отладки. Здесь есть большое количество возможностей, но остановимся на do_operation.
Он принимает opcode, некоторый указатель, куда нужно вернуть результат, левое и правое значения.
Чтобы избавить PHP-разработчиков от знания всех низкоуровневых деталей, есть высокоуровневый интерфейс в фреймворке, написанный на PHP — ObjectDoOperationInterface.
У него только один метод doOperation.
Посмотрим на реализацию этого интерфейса для перегрузки операторов для класса матрицы:
Как можно заметить, здесь уже передается понятная для PHP-разработчиков структура, содержащая первый аргумент, второй аргумент, а также сам opcode. Для примера показана реализация сложение двух матриц путем перегрузки обработчика опкода ADD.
Теперь PHP сможет использовать перегруженный оператор суммы и можно писать кастомные операции сложения и умножения:
$first = new Matrix ([[10, 20, 30]]);
$second = new Matrix ([[2, 4, 6]]);
$value = $first * 2 + $second;
var_dump($value);
Расширение для immutable объектов
Это второе расширение, которое ожидают многие разработчики PHP в самом PHP.
Возьмём теперь lisachenko/immutable-object, также использующий Z-engine, но будем работать с другими хуками, чтобы на самом низком уровне перегрузить все доступы к операциям записи свойств в объекте.
Напишем код таким образом, чтобы любой класс имплементирующий интерфейс ImmutableInterface, нельзя было изменить из PHP ни через reflection, ни прямой записью полей, а только из конструктора, либо из определенного разрешенного сеттера.
Для этого понадобится опять zend_object_handlers, но только с хуком write_property — этот хук используется PHP для записи значения свойства в объекте.
Еще потребуется хук get_property_ptr_ptr, который используется, когда PHP получает или изменяет свойство объекта неявно. Например, по ссылке, или когда мы используем $this→field++, или когда мы пытаемся сделать array_push ($this→field, something). Если get_property_ptr_ptr не перегружать, тогда можно через всякие хитрости ограничение из PHP-кода обойти. Если перегрузить, то доступ будет прикрыт.
Ещё потребуется специальный хук unset_property, который отвечает за unset свойства, то есть за очистку свойства объекта из памяти. Есть аналогия со стандартными магическими методами __unset и __set, но в случае PHP это немного по-другому.
Посмотрим, как реализована __fieldWrite, и как устроена вся защита.
Берем debug_backtrace и проверяем с какого кода вызывается попытка записи свойства. Так как хук __fieldWrite автоматически вызывается ядром Z-engine при любом обращении при записи свойства, то всегда можно будет понять, откуда был вызвано это изменения — из нашего кода или не нашего.
Дальше можно разобраться, был ли это вызов из конструктора вашего класса или это статический метод-конструктор. Если это не конструктор, и статический метод где может происходить инициализация свойств объекта, то вы можете остановить выполнение с помощью LogicException.
Для реализации __fieldPointer и __fieldUnset можно сделать так:
Здесь не нужна никакая логика по-умолчанию. При обращении к свойству через амперсанд, попытке получить на него ссылку или сделать неявный array_push в свойство, будем сразу кидать исключение, так как это точно не нужно для неизменяемого объекта. Для __fieldUnset все то же самое — просто кидаем исключение при попытке очистить неизменяемое свойство.
Создадим экземпляр простого класса immutable, который будет имплементировать ImmutableInterface:
Есть свойство $value. В примере оно специально сделано публичным, чтобы его можно было изменить снаружи, и есть конструктор для записи значения.
Дальше создается неизменяемый экземпляр класса $instance, для которого делается var_dump и производится попытка обновить значение этого публичного свойства.
Если вы посмотрите на результат, то увидите, что в первом случае свойство записано и инициализировано, а во втором получилась фатальная ошибка. Потому что неизменяемый объект может быть изменен или создан только в конструкторе либо в статическом методе.
Теперь у нас появились настоящие immutable-объекты, которые можно использовать в PHP. Делать это в продакшн не рекомендуется, но во время dev-разработки это более чем удачное решение в сочетании с PhpStan или Psalm, которые могут валидировать аннотации и проверять доступы лексически.
Выполнение машинного кода
Посмотрите на пирамиду! На самом верху находятся высокоуровневые языки: C, C++, Java, там же PHP. Ниже идут языки высокого уровня, но ближе к уровню машинного кода. А на самом нижнем уровне код физически выполняется на процессоре.
Глядя на это, возникает вопрос: «А нельзя ли достучаться до машинного кода из PHP, и что при этом получится»?
Давайте поэкспериментируем.
Возьмем Z-engine и создадим чистый блок памяти с уровня PHP:
$pageSize = Core::call('getpagesize');
$rawCode = Core::call('mmap',null, $pageSize, 0x7,0x22,-1,0);
FFI::memcpy($rawCode, $code, strlen($code));
Важно! Для этого потребуется аллоцировать память, которая должна иметь специальный executable бит и имеет размер, кратный странице памяти.
Дальше нужно подготовить определенным образом структуру zend_internal_function
Структура zend_internal_function используется в PHP для хранения информации о внутренних функциях. Чтобы её инициализировать, надо указать что это внутренняя функция, передать общее количество аргументов и требуемое количество аргументов (в данном случае они 0) и передать определения самих аргументов. Информация о них содержится в arg_info. Там можно передать тип и указать дополнительные атрибуты, либо указать null если у нас нет никаких аргументов.
Помечаем флагом ZEND_ACC_PUBLIC что этот метод будет публичным и его можно вызвать из класса, и передаем в handler указатель на участок памяти, в котором будет содержаться код.
Теперь нужно указатель на этот метод добавить в таблицу с методами для какого-то реального класса:
$valueEntry = ReflectionValue: newEntry (ReflectionValue: IS_PTR, $rawFunction);
$this→methodTable→add (strtolower ($methodName), $valueEntry);
Это будет выглядеть так:
Это весь пример целиком. Вверху настоящий машинный код, скомпилированный под данную платформу, который вообще не имеет ничего общего с PHP.
Он сделан на компиляторе ASM с указанием relative размещения кода. Если не указать код relative, то при попытке выполнения такого кода на PHP, он скорее всего сегфолтнется так как PHP загружает участки данных в произвольный участок памяти, а линковщики обычно имеют понятие base-адреса, куда должен быть размещен код.
Дальше подготавливается экземпляр расширенного ReflectionClass для TestClass-а и добавляем туда internal метод, который уже содержит бинарное представление.
Если его вызвать, то вы увидите первый «Hello, World!», который выполняется на ассемблере напрямую из PHP.
Внимание! Код добавления internal-методов находится только в ветке фреймворка, но не в мастере из-за соображений безопасности.
Новые области для PHP
Этот подход открывает следующие возможности:
Доступ к более быстрому оборудованию: CUDA, GPU;
Доступ к нативному машинному коду: SSE, AVX;
Библиотеки для эффективных матричных операций, машинного обучения (привет Python);
Возможность писать расширения PHP и привязки к библиотекам C на чистом PHP;
Максимальную скорость, сравнимую с кодом C.
Но это еще далеко не всё, и можно раскопать многое другое. Например, стабильный API для пользовательских расширений PHP. Можно получить доступ к написанию расширений на PHP. В качестве примера на сайте Z-engine на GitHub есть информация о том, как создать счетчик, который будет переживать отдельные запросы. В классическом PHP это ограничено режимом, когда на каждый запрос память очищается. С помощью Z-engine это ограничение можно обойти и выделить участок памяти, который будет доступен постоянно до рестарта самого PHP.
Можно рассмотреть кучу вариантов манипуляций OpCode. Например, есть специальное расширение IonCube, которое поставляется платно и позволяет выполнять код защищенно и можно дистрибьютить уже не исходный код PHP, а поставлять и выполнять уже готовый OpCode. С помощью Z-engine можно также попробовать дистрибьютить PHP не в виде исходного кода (файликов, строчек), а в виде бинарного пакета, выполняемого максимально эффективно, и, возможно, без компиляции.
Можно попробовать сделать shared-объекты. Реализовать настоящий PHP-объект, например, контейнер Symfony, который может жить между запросами. В теории это сделать крайне сложно, но если удастся туда добраться и сделать расширение, то, наверное, будет здорово.
Еще можно попытаться реализовывать всевозможные операции поддержки высокоэффективного железа. Если у вас, допустим, движок для матрицы или для Machine Learning, это тоже можно сделать на самом PHP. Что открывает целый спектр возможных направлений. Например, поконкурировать с Python в Machine Learning. Сделать библиотеку, которая будет использовать CUDA или CUBLAS.
Возможностей много, но над ними еще надо работать.
Конференция PHP Russia в этом году пройдет в рамках HighLoad. С 24 и 25 ноября 2022 в Крокус-Экспо Москва. Все выкупленные билеты автоматически перенесутся на флагманскую конференцию. Если вы еще не успели их приобрести, купить билеты и посмотреть программу можно на официальном сайте.