Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11468 глав6256 карточек справочника
Дерево руководства

Глава 49 · После MySQLi/PDO, callback и знакомства с C-структурами

Mysqlnd: собрать C-модуль, увидеть запросы и сохранить поведение приложения

Собрать свой C-модуль mysqlnd, получить реальные товары через MySQLi/PDO и увидеть выполнение, ошибки, отдельные подключения и ограниченный PHP-callback.

Получить товары и увидеть работу общего драйвера

При оптимизации каталога полезно знать, сколько реальных выполнений делает обработчик, даже если часть кода использует MySQLi, а часть PDO. В этой главе соберём небольшой собственный модуль C: каталог по-прежнему вернёт BOX и LED, а рядом появятся счётчики прямых и подготовленных выполнений. Ошибка неизвестного столбца сохранит код 1054; необязательный PHP-наблюдатель сможет получить событие без SQL и пароля.

Где находится наш модуль
  1. PHP: MySQLi/PDO получает товары
  2. mysqlnd: выбранный query или stmt.execute
  3. phpman_mysqlnd считает → вызывает сохранённого родителя
  4. MySQL возвращает данные → PHP читает строки; snapshot показывает наблюдения

Здесь есть два уровня языка. PHP выполняет сценарии каталога. C создаёт расширение, загружаемое в PHP-процесс и работающее внутри mysqlnd. Функции phpman_mysqlnd_* написаны для этой главы: они не появляются после одной лишь установки стандартного mysqli/mysqlnd. Слово «плагин» здесь означает нативный модуль драйвера, а не WordPress-плагин сайта.

Перед началом нужны различия PHP API, mysqlnd и MySQL Proxy, базовые подключение и чтение таблиц, параметры и возврат функции, callback и замыкание и обработка исключений. Новое C-устройство объясняется ниже по частям; готовые PHP-программы можно сначала запустить и сопоставить с результатом, затем изучить внутренности.

ШагЧто получить
Прямой запросТолько доступные товары и одна попытка query
Подготовленный фильтрЦены не выше 1300 копеек и отдельный stmt.execute
Два PHP-клиентаОдинаковые цены; фактический путь PDO
Ошибки1054/1062 сохранены, исправленный каталог прочитан
Время жизниЗакрытие одного подключения не ломает другое
C → PHPОграниченный callback, полезный служебный SELECT, исходный результат цел

Проверенный нативный профиль — Ubuntu 24.04/WSL2, PHP 8.3.6 CLI NTS, mysqlnd 8.3.6 и MySQL 8.0.46 через локальный Unix-сокет. Модуль phpman_mysqlnd 0.1.0 действительно скомпилирован и загружен; публичные архивы содержат исходники для сборки, не готовый бинарник чужого PHP. Этот профиль не заявляет поддержку production FPM, ZTS, постоянных соединений, Windows DLL или всех методов mysqlnd.

Как устроены hooks, таблицы методов и время жизни

Чтобы добавить наблюдение, нужно понять место перехвата. mysqlnd разбит на части: статистика, соединение, результат, метаданные столбцов, подготовленная команда, сетевой ввод-вывод и протокол. В официальной архитектурной схеме перечислены mysqlnd_statistics.c, mysqlnd.c, mysqlnd_result.c, mysqlnd_result_meta.c, mysqlnd_ps.c, mysqlnd_net.c и mysqlnd_wireprotocol.c. Это историческая карта: в PHP 8.3 сетевые обязанности разделены между PFC/VIO, а соединение — между MYSQLND и MYSQLND_CONN_DATA. Сверять нужно заголовки именно компилируемого PHP.

struct в C объединяет поля одного объекта; указатель хранит адрес объекта или функции. Поле с указателем на функцию играет роль метода. Это приём организации C-кода, а не наследование PHP-класса: автоматических правил parent и владения памятью за автора не выполняет язык.

/* Учебная модель, не замена внутреннего MYSQLND. */
struct counter { unsigned long attempts; };
struct counter state = {0};
state.attempts++;
/* attempts теперь 1: это число наблюдавшихся попыток. */

Фигурные скобки описывают структуру; state — один её экземпляр. Точка выбирает поле attempts; ++ увеличивает его на единицу. В настоящем hook соединение передано указателем: conn->m означает поле через указатель, а сохранённая parent-функция вызывается обычными круглыми скобками. Физическая аналогия — сменная насадка на инструменте: она считает включения и пропускает действие к инструменту. Она не получает права подменять результат работой «для отчёта».

Циклы жизни и место состояния
  1. MINIT: регистрация ID, копия таблиц и установка hooks
  2. RINIT: новое окно и массивы конкретного PHP-запроса
  3. query/execute: состояние своего соединения + parent
  4. dtor: освободить свой slot → parent dtor
  5. RSHUTDOWN: убрать callback/массивы; MSHUTDOWN: завершить модуль
ГраницаЧто делаем и почему
MINITОдин раз при старте модуля сохраняем текущие таблицы. Это общий ресурс процесса; изменение во время запроса может конфликтовать с другими потоками/плагинами.
RINITНачало PHP-запроса: только его счётчики, массив и callback. В CLI данный запуск — один такой запрос.
RSHUTDOWNОтключаем наблюдение прежде, чем уничтожать сохранённые PHP-значения. Чужие запросы не наследуют callback.
MSHUTDOWNЗавершение модуля. Возвращаем parent-таблицу только если текущая таблица всё ещё наша, не затирая более позднюю установку чужого плагина.

Сохранить родителя, а не вызвать себя

/* Фрагмент MINIT из полного модуля. */
parent_conn_methods = *mysqlnd_conn_data_get_methods();
monitor_conn_methods = parent_conn_methods;
monitor_conn_methods.query = phpman_query;
mysqlnd_conn_data_set_methods(&monitor_conn_methods);

/* Внутри phpman_query после учёта попытки: */
enum_func_status status = parent_conn_methods.query(conn, query, query_len);
return status;

Звёздочка разыменовывает указатель на текущую таблицу; присваивание копирует всю структуру её текущего типа. & берёт адрес нашей таблицы. Сохранённый parent может быть не голым mysqlnd, а ранее загруженным плагином. Вызов через уже заменённую текущую таблицу вернулся бы в нас и создал рекурсию. Число байтов таблицы нельзя жёстко записать вручную: состав меняется между версиями; используйте типы заголовков и пересобирайте модуль при смене ABI.

Кооперативная цепочка может выглядеть так: monitor → cache → mysqlnd. Если каждый вызывает сохранённого предшественника, оба расширения работают в одной цепочке; пропуск parent уничтожает смысл последующих звеньев. В официальном примере порядок регистрации при MINIT объясняется загрузкой независимых расширений. В рабочем модуле зависимость mysqlnd объявлена явно через PHP_ADD_EXTENSION_DEP и ZEND_MOD_REQUIRED; произвольный порядок двух других plugins не объявляется гарантией.

Свойства и память конкретного соединения

Добавить поле в готовый C struct во время работы нельзя. При выделении объекта mysqlnd предусматривает ячейки void* по ID плагина. Модель адреса из документации: конец основной структуры + номер плагина × sizeof(void*). Это поясняет устройство, но прикладной код использует getter, а не ручную арифметику и чтение чужих ячеек.

/* В query-hook conn имеет тип MYSQLND_CONN_DATA*. */
PHPMAN_CONNECTION **slot = (PHPMAN_CONNECTION **)
    mysqlnd_plugin_get_plugin_connection_data_data(conn, phpman_plugin_id);
if (slot && !*slot) {
    *slot = mnd_pecalloc(1, sizeof(PHPMAN_CONNECTION), conn->persistent);
}
/* При уничтожении: mnd_pefree(*slot, conn->persistent); *slot = NULL; */

Двойной указатель нужен потому, что getter возвращает адрес ячейки, а ячейка хранит адрес нашего состояния. Выделение и освобождение — обязанность автора плагина. mnd_* allocator учитывает persistent-флаг и поддерживает отладку выделений. Наш dtor освобождает только свой объект, обнуляет slot, затем вызывает parent и больше не трогает уничтоженное conn. Поколение окна отделяет прежние счётчики от нового reset; ограничение 64 записей ограничивает публичную таблицу наблюдения, а не число разрешённых соединений.

Историческая таблица архитектурыВремя замены и жизненный цикл
Connection / Statement / Resultset MetaОбщая таблица методов: меняется при MINIT. Для stmt_init и метаданных parent-конструктор должен отработать до добавления своих свойств.
Resultset / Network / Wire protocolДокументация описывает экземпляры с собственными копиями таблиц: исходные defaults меняются при MINIT; локальная копия может уточняться после создания. Это не разрешение менять общую таблицу во время запроса.
Resultsetresult_init выделяет результат; use_result/store_result могут сбрасывать и инициализировать его. prepare/get_result/resultMetaData также затрагивают результат/метаданные.
КонструкторыИсторически mysqlnd_init, mysqlnd_net_init, mysqlnd_protocol_init не предлагались как заменяемые методы. Для разрешённых result_init/result_meta_init/stmt_init нужно вызвать parent: он выделяет обязательную память и slots.
ОсвобождениеДокументация перечисляет connection free_contents/end_psession, result free_result, metadata free, statement dtor/free_stmt_content, network/protocol free. Если есть несколько путей, своё состояние освобождается один раз в первом сработавшем, а parent сохраняется. В текущем PHP 8.3 наш модуль использует фактический connection_data.dtor; старые имена не подставлены механически.

Plugin API не изолирует нативный код: указатели позволяют повредить чужие данные или весь процесс. Регистрация не является песочницей. Поэтому требуется доверенный исходник, совпадающий ABI и отдельный стенд; этот учебный модуль не выдаётся за готовый production-agent. Его полезная гарантия в примерах конкретна: обе SQL-ветви вызывают предшественника с исходными аргументами, а реальные строки/ошибки остаются доступными приложению.

Все имена официального C Plugin API и текущие заголовки

Ниже сохранены все 13 имён из официального краткого списка C Plugin API. Это не функции, которые можно написать в PHP-файле. Таблица разделяет назначение, точный маршрут в заголовках PHP 8.3.6 и исторические написания. При переносе на другую версию нужно снова сверять типы аргументов и владельцев таблиц.

Имя официального спискаДля какой работы
mysqlnd_plugin_register()Получить числовой ID плагина при MINIT; ID выбирает его собственную ячейку данных.
mysqlnd_plugin_count()Узнать текущее число регистраций, например при диагностике состава mysqlnd.
mysqlnd_plugin_get_plugin_connection_data()Получить адрес собственного указателя состояния объекта соединения.
mysqlnd_plugin_get_plugin_result_data()Прикрепить свои данные к MYSQLND_RES, например состояние обработки конкретной выдачи.
mysqlnd_plugin_get_plugin_stmt_data()Хранить отдельные свойства подготовленной команды.
mysqlnd_plugin_get_plugin_net_data()Исторический slot сетевого объекта MYSQLND_NET.
mysqlnd_plugin_get_plugin_protocol_data()Прикрепить данные к объекту разбора протокола.
mysqlnd_conn_get_methods()Получить таблицу методов оболочки соединения и сохранить цепочку вызовов.
mysqlnd_result_get_methods()Получить исходные операции результата, например перед расширением чтения выдачи.
mysqlnd_result_meta_get_methods()Историческое написание получения методов метаданных результата из списка API.
mysqlnd_stmt_get_methods()Сохранить и расширить операции подготовленной команды, включая execute.
mysqlnd_net_get_methods()Историческая таблица сетевого объекта MYSQLND_NET.
mysqlnd_protocol_get_methods()Получить таблицу фабрики декодирования протокола.

mysqlnd_plugin_register()

Получить числовой ID плагина при MINIT; ID выбирает его собственную ячейку данных.

unsigned int mysqlnd_plugin_register(void);

mysqlnd.h; mysqlnd_plugin.c. Функция сохранена и вызывает register_ex(NULL). Наш модуль использует mysqlnd_plugin_register_ex(&header), чтобы передать имя, версию и MYSQLND_PLUGIN_API_VERSION=2. ID не равен номеру соединения.

mysqlnd_plugin_count()

Узнать текущее число регистраций, например при диагностике состава mysqlnd.

unsigned int mysqlnd_plugin_count(void);

mysqlnd.h; mysqlnd_plugin.c. Возвращает общий счётчик регистраций, не число запросов/соединений и не разрешение вручную вычислять чужой slot.

mysqlnd_plugin_get_plugin_connection_data()

Получить адрес собственного указателя состояния объекта соединения.

mysqlnd_plugin_get_plugin_connection_data(conn, plugin_id)

mysqlnd_ext_plugin.h: макрос для MYSQLND*. В PHP 8.3 данные выполняющего соединения вынесены в MYSQLND_CONN_DATA; наш query-hook получает именно этот тип и использует другой точный макрос mysqlnd_plugin_get_plugin_connection_data_data(conn, plugin_id). Эти два типа нельзя подменять cast ради компиляции.

mysqlnd_plugin_get_plugin_result_data()

Прикрепить свои данные к MYSQLND_RES, например состояние обработки конкретной выдачи.

mysqlnd_plugin_get_plugin_result_data(result, plugin_id)

mysqlnd_ext_plugin.h: адрес void* в result slot. Для buffered/unbuffered частей есть отдельные getters. Монитор главы результаты не расширяет: строки остаются владением mysqlnd и PHP-клиента.

mysqlnd_plugin_get_plugin_stmt_data()

Хранить отдельные свойства подготовленной команды.

mysqlnd_plugin_get_plugin_stmt_data(stmt, plugin_id)

mysqlnd_ext_plugin.h: getter принимает MYSQLND_STMT*. При расширении такого состояния нужно освободить его при уничтожении statement. Наш hook execute относит событие к stmt->data->conn и не выделяет собственное stmt-состояние.

mysqlnd_plugin_get_plugin_net_data()

Исторический slot сетевого объекта MYSQLND_NET.

mysqlnd_plugin_get_plugin_net_data(net, plugin_id) /* историческое имя */

В mysqlnd_ext_plugin.h PHP 8.3.6 такого макроса нет. Современные части — MYSQLND_PFC и MYSQLND_VIO, getters get_pfc_area/get_vio_area в mysqlnd_plugin_area_getters. В этом заголовке макрос mysqlnd_plugin_get_plugin_vio_data фактически обращается к get_pfc_area: перенос требует чтения реализации и отдельной проверки, а не слепой замены имени. Учебный модуль сетевые slots не использует.

mysqlnd_plugin_get_plugin_protocol_data()

Прикрепить данные к объекту разбора протокола.

mysqlnd_plugin_get_plugin_protocol_data(factory, plugin_id)

mysqlnd_ext_plugin.h: имя сохранено, текущий аргумент — MYSQLND_PROTOCOL_PAYLOAD_DECODER_FACTORY*. Это не пакет запроса и не PHP-массив. Не меняйте байты протокола, если нужен только счётчик query/execute.

mysqlnd_conn_get_methods()

Получить таблицу методов оболочки соединения и сохранить цепочку вызовов.

mysqlnd_conn_get_methods()

mysqlnd_ext_plugin.h: getter MYSQLND-таблицы сохранён. Для query/dtor в текущем модуле используются mysqlnd_conn_data_get_methods() и mysqlnd_conn_data_set_methods(), поскольку query находится в connection_data, а не в прежней единой таблице.

mysqlnd_result_get_methods()

Получить исходные операции результата, например перед расширением чтения выдачи.

mysqlnd_result_get_methods()

mysqlnd_ext_plugin.h: getter/setter общей исходной таблицы MYSQLND_RES. Перехват result требует собственного контракта чтения/освобождения; query-счётчик не заменяет его и не заявляет измерение fetch.

mysqlnd_result_meta_get_methods()

Историческое написание получения методов метаданных результата из списка API.

mysqlnd_result_metadata_get_methods()

Точное современное имя объявлено в mysqlnd_result_meta.h: возвращает struct st_mysqlnd_res_meta_methods*. В официальной архитектурной таблице тоже используется metadata. В кратком списке API указано meta — различие сохранено явно, без выдуманного PHP-метода.

mysqlnd_stmt_get_methods()

Сохранить и расширить операции подготовленной команды, включая execute.

mysqlnd_stmt_get_methods()

mysqlnd_ext_plugin.h: возвращает указатель на таблицу MYSQLND_STMT. Копия текущей таблицы сохраняет parent execute; mysqlnd_stmt_set_methods(&monitor_stmt_methods) ставит нашу таблицу при MINIT. Именно этот маршрут наблюдает подготовленный фильтр цен.

mysqlnd_net_get_methods()

Историческая таблица сетевого объекта MYSQLND_NET.

mysqlnd_net_get_methods() /* историческое имя */

В PHP 8.3.6 вместо этого имени доступны mysqlnd_pfc_get_methods()/set_methods и mysqlnd_vio_get_methods()/set_methods в mysqlnd_ext_plugin.h. PFC отвечает за framing/codec пакетов, VIO — ввод-вывод. Это разные структуры; ни одна не является универсальной подстановкой старой MYSQLND_NET.

mysqlnd_protocol_get_methods()

Получить таблицу фабрики декодирования протокола.

mysqlnd_protocol_get_methods()

mysqlnd_ext_plugin.h: имя сохранено, тип — mysqlnd_protocol_payload_decoder_factory. Изменение протокольных методов не требуется для нашего наблюдения. Общую исходную таблицу меняют согласованно при MINIT, а не посреди пользовательского запроса.

Набор API состоит из менеджера регистраций, интерфейсов модулей mysqlnd и доступа к их таблицам/состоянию. Возврат getter — адрес нативной структуры/ячейки, а не PHP-объект с автоматическим временем жизни. ID, тип и живой объект должны соответствовать друг другу. Наш модуль демонстрирует только нужные query/statement/connection_data маршруты; остальные имена объяснены как возможности C API, без ложного заявления об их runtime-перехвате.

Техническое основание для текущих имён: mysqlnd.h, mysqlnd_ext_plugin.h, mysqlnd_result_meta.h. Для переноса полезнее эти точные типы, чем механическое переименование старого net в vio.

Собрать модуль и пройти путь C → PHP

Результат сборки — modules/phpman_mysqlnd.so. PHP загружает его только в отдельный CLI-процесс, затем доступны три авторские функции. Это полноценное PHP-расширение: config.m4 описывает сборку и зависимость, C-файл — регистрацию, hooks, состояние и PHP-интерфейс. Одного PHP-класса или объявления функции с тем же именем для C-перехвата недостаточно.

Файл архиваНазначение
phpman_mysqlnd.cПолный исходник C модуля, включая hook query/execute/dtor, callback и освобождение памяти.
config.m4Опция --enable-phpman-mysqlnd и зависимость mysqlnd.
build-extension.shphpize → configure → make → отдельная проверка загрузки.
README-extension.txtТочный запуск, ABI, определения счётчиков и границы callback.
example.php / lab.php / README.txtПолный PHP-сценарий, подключение к отдельной учебной базе, результат и очистка только собственных таблиц.

Сборка и первый наблюдаемый результат

Нужны PHP CLI и development headers одной версии, phpize/php-config, C-компилятор, make и autoconf. Для проверенного Ubuntu-профиля использованы PHP 8.3.6 NTS x64 и каталог API 20230831. Не собирайте под одни заголовки для загрузки в другой PHP. Windows DLL этим скриптом не создаётся. Команды выполняются внутри отдельной папки распакованных исходников:

#!/usr/bin/env bash
set -euo pipefail
# Use matching PHP, phpize, php-config and headers. Run in this source folder.
phpize8.3
./configure --enable-phpman-mysqlnd --with-php-config=/usr/bin/php-config8.3
make -j2
php8.3 -n -d extension=mysqlnd -d extension="$PWD/modules/phpman_mysqlnd.so" \
  -r 'echo json_encode(phpman_mysqlnd_snapshot(), JSON_PRETTY_PRINT), PHP_EOL;'
# No make install, php.ini edit, SQL command or server installation.

В этой главе эти стадии действительно выполнены: первый make завершился кодом 0, модуль загрузился в PHP 8.3.6 и сообщил version=0.1.0; начальный snapshot содержит нули и пустой connections. Затем ограниченный SQL-прототип подтвердил query, native prepared, исходные ошибки, второй клиент и callback. make install не выполнялся, системный ini не менялся. Ноль после загрузки ожидаем: запросы к базе ещё не выполнялись.

php8.3 -n -d extension=mysqlnd -d extension=mysqli -d extension=pdo -d extension=pdo_mysql -d extension=/ABSOLUTE/PATH/modules/phpman_mysqlnd.so example.php

Флаг -n отключает чтение ini; каждый -d extension явно загружает нужный модуль в этом процессе. Подставьте путь своего собранного .so. Порядок здесь mysqlnd → mysqli/PDO/pdo_mysql → наш монитор. Локальная база, Unix-сокет и ограниченные реквизиты задаются переменными окружения по README; исходники не содержат пароля. После каждого примера остаются result.html/result.json/metrics.json, а созданные им таблицы удаляются по собственному списку. Системные таблицы и соседние данные не очищаются.

Три функции нашего модуля

Авторская PHP-функцияВход, результат и ошибка
phpman_mysqlnd_reset(): voidНачинает новое окно в текущем запросе; обнуляет счётчики и локальные ID. Не выполняет SQL и не закрывает соединения. Внутри observer запрещена, чтобы не разрушить измеряемое окно.
phpman_mysqlnd_snapshot(): arrayВозвращает копию счётчиков, версии и ограниченного списка соединений. query_attempts/query_failed относятся к query; stmt_attempts/stmt_failed — к execute. Это не таймер и не число строк.
phpman_mysqlnd_observe(?callable $callback): voidСохраняет один callback или отключает его null. Получает событие kind/ok/connection_id; return callback не управляет SQL. Недопустимый callable отклоняется. Замена callback во время его выполнения запрещена.

От маленького hook к полному исходнику

/* Смысл прозрачного перехвата: фрагмент внутри функции. */
++attempts;
enum_func_status status = parent_conn_methods.query(conn, query, query_len);
if (status == FAIL) { ++failed; }
return status;

Считаем попытку до исходного вызова, затем FAIL по его фактическому статусу. SQL, его длина и указатель соединения передаются без изменений; return возвращает тот же статус. Полный исходник добавляет состояние конкретного соединения и optional observer. Не копируйте фрагмент как самостоятельный C-файл: объявления и регистрация находятся ниже.

Полный config.m4
PHP_ARG_ENABLE([phpman_mysqlnd], [whether to enable the teaching mysqlnd monitor],
  [AS_HELP_STRING([--enable-phpman-mysqlnd], [Build the PHP Manual teaching monitor])], [no])
if test "$PHP_PHPMAN_MYSQLND" != "no"; then
  PHP_NEW_EXTENSION([phpman_mysqlnd], [phpman_mysqlnd.c], [$ext_shared])
  PHP_ADD_EXTENSION_DEP([phpman_mysqlnd], [mysqlnd])
fi
Полный компилируемый phpman_mysqlnd.c
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "php.h"
#include "ext/standard/info.h"
#include "Zend/zend_exceptions.h"
#include "ext/mysqlnd/mysqlnd.h"
#include "ext/mysqlnd/mysqlnd_ext_plugin.h"
#include "ext/mysqlnd/mysqlnd_alloc.h"
#define PHPMAN_VERSION "0.1.0"
#define PHPMAN_MAX_CONNECTIONS 64
typedef struct {
    zend_ulong generation;
    zend_long id, query_attempts, query_failed, stmt_attempts, stmt_failed;
} PHPMAN_CONNECTION;
ZEND_BEGIN_MODULE_GLOBALS(phpman_mysqlnd)
    bool active, notifying;
    zend_ulong generation;
    zend_long next_id, query_attempts, query_failed, stmt_attempts, stmt_failed;
    zend_long tracked_connections, closed_connections, dropped_connections;
    zend_long observer_calls, observer_errors, observer_reentrant_skips;
    zval observer, records;
ZEND_END_MODULE_GLOBALS(phpman_mysqlnd)
ZEND_DECLARE_MODULE_GLOBALS(phpman_mysqlnd)
#define PMG(v) ZEND_MODULE_GLOBALS_ACCESSOR(phpman_mysqlnd, v)
static unsigned int phpman_plugin_id;
static MYSQLND_CLASS_METHODS_TYPE(mysqlnd_conn_data) parent_conn_methods, monitor_conn_methods;
static MYSQLND_CLASS_METHODS_TYPE(mysqlnd_stmt) parent_stmt_methods, monitor_stmt_methods;
static struct st_mysqlnd_plugin_header plugin_header = {
    MYSQLND_PLUGIN_API_VERSION, "phpman_mysqlnd", 100, PHPMAN_VERSION,
    "PHP-3.01", "PHP Manual teaching example", {NULL, NULL}, {NULL}
};
static void phpman_record(PHPMAN_CONNECTION *state, bool closed)
{
    zval row;
    if (!PMG(active) || !state || state->generation != PMG(generation) || state->id == 0) {return;}
    array_init(&row);
    add_assoc_long(&row, "id", state->id);
    add_assoc_long(&row, "query_attempts", state->query_attempts);
    add_assoc_long(&row, "query_failed", state->query_failed);
    add_assoc_long(&row, "stmt_attempts", state->stmt_attempts);
    add_assoc_long(&row, "stmt_failed", state->stmt_failed);
    add_assoc_bool(&row, "closed", closed);
    zend_hash_index_update(Z_ARRVAL(PMG(records)), (zend_ulong)state->id, &row);
}
static PHPMAN_CONNECTION *phpman_connection(MYSQLND_CONN_DATA *conn)
{
    PHPMAN_CONNECTION **slot;
    if (!PMG(active) || !conn) {return NULL;}
    slot = (PHPMAN_CONNECTION **)mysqlnd_plugin_get_plugin_connection_data_data(conn, phpman_plugin_id);
    if (!slot) {return NULL;}
    if (!*slot) {*slot = mnd_pecalloc(1, sizeof(PHPMAN_CONNECTION), conn->persistent);}
    if ((*slot)->generation != PMG(generation)) {
        memset(*slot, 0, sizeof(PHPMAN_CONNECTION));
        (*slot)->generation = PMG(generation);
        if (PMG(tracked_connections) < PHPMAN_MAX_CONNECTIONS) {
            (*slot)->id = ++PMG(next_id); ++PMG(tracked_connections);
        } else {++PMG(dropped_connections);}
    }
    return *slot;
}
static void phpman_notify(const char *kind, enum_func_status status, zend_long connection_id)
{
    zval event, retval;
    if (!PMG(active) || Z_ISUNDEF(PMG(observer)) || Z_TYPE(PMG(observer)) == IS_NULL) {return;}
    if (PMG(notifying)) {++PMG(observer_reentrant_skips); return;}
    /* Never replace an exception which existed before our observer. */
    if (EG(exception)) {return;}
    array_init(&event);
    add_assoc_string(&event, "kind", (char *)kind);
    add_assoc_bool(&event, "ok", status == PASS);
    add_assoc_long(&event, "connection_id", connection_id);
    ZVAL_UNDEF(&retval);
    PMG(notifying) = true; ++PMG(observer_calls);
    zend_result called = call_user_function(EG(function_table), NULL, &PMG(observer), &retval, 1, &event);
    if (called == FAILURE || EG(exception)) {
        ++PMG(observer_errors);
        /* Only an exception raised by this optional observer is isolated. */
        if (EG(exception)) {zend_clear_exception();}
    }
    if (!Z_ISUNDEF(retval)) {zval_ptr_dtor(&retval);}
    zval_ptr_dtor(&event); PMG(notifying) = false;
}
static enum_func_status phpman_query(MYSQLND_CONN_DATA *conn, const char * const query, const size_t query_len)
{
    PHPMAN_CONNECTION *state = phpman_connection(conn);
    zend_long connection_id = state ? state->id : 0;
    if (PMG(active)) {++PMG(query_attempts); if (state) {++state->query_attempts;}}
    /* Original pointers, length, connection and return status stay unchanged. */
    enum_func_status status = parent_conn_methods.query(conn, query, query_len);
    if (PMG(active)) {
        if (status == FAIL) {++PMG(query_failed); if (state) {++state->query_failed;}}
        phpman_record(state, false); phpman_notify("query", status, connection_id);
    }
    return status;
}
static enum_func_status phpman_stmt_execute(MYSQLND_STMT * const stmt)
{
    PHPMAN_CONNECTION *state = phpman_connection(stmt->data->conn);
    zend_long connection_id = state ? state->id : 0;
    if (PMG(active)) {++PMG(stmt_attempts); if (state) {++state->stmt_attempts;}}
    enum_func_status status = parent_stmt_methods.execute(stmt);
    if (PMG(active)) {
        if (status == FAIL) {++PMG(stmt_failed); if (state) {++state->stmt_failed;}}
        phpman_record(state, false); phpman_notify("stmt_execute", status, connection_id);
    }
    return status;
}
static void phpman_conn_dtor(MYSQLND_CONN_DATA *conn)
{
    PHPMAN_CONNECTION **slot =
        (PHPMAN_CONNECTION **)mysqlnd_plugin_get_plugin_connection_data_data(conn, phpman_plugin_id);
    if (slot && *slot) {
        if (PMG(active) && (*slot)->generation == PMG(generation) && (*slot)->id != 0) {
            ++PMG(closed_connections); phpman_record(*slot, true);
        }
        mnd_pefree(*slot, conn->persistent); *slot = NULL;
    }
    /* Parent frees the connection. Do not access conn afterwards. */
    parent_conn_methods.dtor(conn);
}
static void phpman_clear_window(void)
{
    ++PMG(generation);
    PMG(next_id) = PMG(query_attempts) = PMG(query_failed) = 0;
    PMG(stmt_attempts) = PMG(stmt_failed) = 0;
    PMG(tracked_connections) = PMG(closed_connections) = PMG(dropped_connections) = 0;
    PMG(observer_calls) = PMG(observer_errors) = PMG(observer_reentrant_skips) = 0;
    if (!Z_ISUNDEF(PMG(records))) {zval_ptr_dtor(&PMG(records));}
    array_init(&PMG(records));
}
PHP_FUNCTION(phpman_mysqlnd_reset)
{
    ZEND_PARSE_PARAMETERS_NONE();
    if (PMG(notifying)) {
        zend_throw_error(NULL, "Cannot reset the measurement window inside its observer");
        RETURN_THROWS();
    }
    phpman_clear_window();
}
PHP_FUNCTION(phpman_mysqlnd_snapshot)
{
    zval connections, *record;
    ZEND_PARSE_PARAMETERS_NONE();
    array_init(return_value);
    add_assoc_string(return_value, "extension_version", PHPMAN_VERSION);
    add_assoc_long(return_value, "query_attempts", PMG(query_attempts));
    add_assoc_long(return_value, "query_failed", PMG(query_failed));
    add_assoc_long(return_value, "stmt_attempts", PMG(stmt_attempts));
    add_assoc_long(return_value, "stmt_failed", PMG(stmt_failed));
    add_assoc_long(return_value, "tracked_connections", PMG(tracked_connections));
    add_assoc_long(return_value, "closed_connections", PMG(closed_connections));
    add_assoc_long(return_value, "dropped_connections", PMG(dropped_connections));
    add_assoc_long(return_value, "observer_calls", PMG(observer_calls));
    add_assoc_long(return_value, "observer_errors", PMG(observer_errors));
    add_assoc_long(return_value, "observer_reentrant_skips", PMG(observer_reentrant_skips));
    array_init(&connections);
    ZEND_HASH_FOREACH_VAL(Z_ARRVAL(PMG(records)), record) {
        Z_TRY_ADDREF_P(record); add_next_index_zval(&connections, record);
    } ZEND_HASH_FOREACH_END();
    add_assoc_zval(return_value, "connections", &connections);
}
PHP_FUNCTION(phpman_mysqlnd_observe)
{
    zval *callback;
    ZEND_PARSE_PARAMETERS_START(1, 1)
        Z_PARAM_ZVAL(callback)
    ZEND_PARSE_PARAMETERS_END();
    if (PMG(notifying)) {
        zend_throw_error(NULL, "Cannot replace the observer from inside itself");
        RETURN_THROWS();
    }
    if (Z_TYPE_P(callback) != IS_NULL && !zend_is_callable(callback, 0, NULL)) {
        zend_argument_type_error(1, "must be a valid callback or null"); RETURN_THROWS();
    }
    if (!Z_ISUNDEF(PMG(observer))) {zval_ptr_dtor(&PMG(observer));}
    ZVAL_COPY(&PMG(observer), callback);
}
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_snapshot, 0, 0, IS_ARRAY, 0)
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_reset, 0, 0, IS_VOID, 0)
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_observe, 0, 1, IS_VOID, 0)
    ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 1)
ZEND_END_ARG_INFO()
static const zend_function_entry phpman_functions[] = {
    PHP_FE(phpman_mysqlnd_snapshot, arginfo_snapshot)
    PHP_FE(phpman_mysqlnd_reset, arginfo_reset)
    PHP_FE(phpman_mysqlnd_observe, arginfo_observe)
    PHP_FE_END
};
static PHP_GINIT_FUNCTION(phpman_mysqlnd)
{
    memset(phpman_mysqlnd_globals, 0, sizeof(*phpman_mysqlnd_globals));
    ZVAL_UNDEF(&phpman_mysqlnd_globals->observer);
    ZVAL_UNDEF(&phpman_mysqlnd_globals->records);
}
static PHP_MINIT_FUNCTION(phpman_mysqlnd)
{
    phpman_plugin_id = mysqlnd_plugin_register_ex(&plugin_header);
    if (phpman_plugin_id == 0xCAFE) {return FAILURE;}
    parent_conn_methods = *mysqlnd_conn_data_get_methods();
    monitor_conn_methods = parent_conn_methods;
    monitor_conn_methods.query = phpman_query;
    monitor_conn_methods.dtor = phpman_conn_dtor;
    parent_stmt_methods = *mysqlnd_stmt_get_methods();
    monitor_stmt_methods = parent_stmt_methods;
    monitor_stmt_methods.execute = phpman_stmt_execute;
    mysqlnd_conn_data_set_methods(&monitor_conn_methods);
    mysqlnd_stmt_set_methods(&monitor_stmt_methods);
    return SUCCESS;
}
static PHP_MSHUTDOWN_FUNCTION(phpman_mysqlnd)
{
    /* Do not overwrite a later plugin's replacement table. */
    if (mysqlnd_conn_data_get_methods() == &monitor_conn_methods) {mysqlnd_conn_data_set_methods(&parent_conn_methods);}
    if (mysqlnd_stmt_get_methods() == &monitor_stmt_methods) {mysqlnd_stmt_set_methods(&parent_stmt_methods);}
    return SUCCESS;
}
static PHP_RINIT_FUNCTION(phpman_mysqlnd)
{
#if defined(ZTS) && defined(COMPILE_DL_PHPMAN_MYSQLND)
    ZEND_TSRMLS_CACHE_UPDATE();
#endif
    PMG(active) = true; PMG(notifying) = false;
    ZVAL_NULL(&PMG(observer)); ZVAL_UNDEF(&PMG(records)); phpman_clear_window();
    return SUCCESS;
}
static PHP_RSHUTDOWN_FUNCTION(phpman_mysqlnd)
{
    PMG(active) = false;
    if (!Z_ISUNDEF(PMG(observer))) {zval_ptr_dtor(&PMG(observer)); ZVAL_UNDEF(&PMG(observer));}
    if (!Z_ISUNDEF(PMG(records))) {zval_ptr_dtor(&PMG(records)); ZVAL_UNDEF(&PMG(records));}
    return SUCCESS;
}
static PHP_MINFO_FUNCTION(phpman_mysqlnd)
{
    php_info_print_table_start();
    php_info_print_table_row(2, "phpman_mysqlnd teaching monitor", PHPMAN_VERSION);
    php_info_print_table_row(2, "Captured values", "counts and local IDs only; no SQL or parameters");
    php_info_print_table_end();
}
static const zend_module_dep phpman_deps[] = {ZEND_MOD_REQUIRED("mysqlnd") ZEND_MOD_END};
zend_module_entry phpman_mysqlnd_module_entry = {
    STANDARD_MODULE_HEADER_EX, NULL, phpman_deps, "phpman_mysqlnd", phpman_functions,
    PHP_MINIT(phpman_mysqlnd), PHP_MSHUTDOWN(phpman_mysqlnd),
    PHP_RINIT(phpman_mysqlnd), PHP_RSHUTDOWN(phpman_mysqlnd),
    PHP_MINFO(phpman_mysqlnd), PHPMAN_VERSION,
    PHP_MODULE_GLOBALS(phpman_mysqlnd), PHP_GINIT(phpman_mysqlnd), NULL, NULL,
    STANDARD_MODULE_PROPERTIES_EX
};
#ifdef COMPILE_DL_PHPMAN_MYSQLND
#ifdef ZTS
ZEND_TSRMLS_CACHE_DEFINE()
#endif
ZEND_GET_MODULE(phpman_mysqlnd)
#endif

Мост из C обратно в PHP: официальный замысел и текущая реализация

Официальный стартовый материал предлагает написать собственный PHP-класс mysqlnd_plugin_connection и функцию регистрации mysqlnd_plugin_set_conn_proxy. Это проектируемый мост, не встроенные функции расширения mysqlnd. Пользовательский proxy мог бы иметь pre-hook до подключения или post-hook после query и вызывать parent, а C-часть должна была бы зарегистрировать класс/объект, преобразовать аргументы и предоставить безопасный путь к сохранённому родителю.

Путь bridge из документации
  1. PHP регистрирует объект/функцию наблюдения
  2. PHP API вызывает mysqlnd
  3. C hook находит сохранённый PHP callable
  4. Аргументы C → PHP zval → вызов
  5. Результат обработан; владение освобождено; исходное действие сохранено

В историческом примере proxy::connect меняет адрес подключения и вызывает parent::connect; результат возвращается через C к первоначальному PHP API. Это объясняет общий механизм и возможность изменения поведения. Наш модуль не меняет адрес, SQL или параметры и вообще не перехватывает connect: его публичный bridge узко ограничен наблюдением после query/execute, что достаточно для показанного каталога.

На старой странице встречаются TSRMLS_*, MAKE_STD_ZVAL, ZEND_REGISTER_RESOURCE и старые сигнатуры с zval**. Это эпоха PHP 5. В ней отдельно разобраны простые строковые аргументы, передача MYSQLND* через resource, обратное извлечение resource в PHP_METHOD и вызов сохранённого C parent с превращением PASS/FAIL в PHP bool. Эти обязанности никуда не исчезают, но код нельзя переносить дословно в PHP 8.3: современный объект/ресурс требует точного типа, владельца, валидности указателя и правил уничтожения.

Документация предлагает call_user_function либо более низкоуровневый zend_call_method; у приведённой формы последнего только два argument-slot, поэтому для длинного connect обсуждается свой wrapper. В нашем API вместо копирования такого wrapper передаётся один осмысленный массив события. PHP 8.3 call_user_function принимает адреса zval для callable, return value и массива аргументов. Мы инициализируем retval, сохраняем callable с учётом ссылок и уничтожаем свои значения после вызова. Нативный MYSQLND* наружу не отдаём: PHP видит только локальный числовой ID.

/* Фрагмент нашего C → PHP bridge; event уже создан как массив. */
zval retval;
ZVAL_UNDEF(&retval);
zend_result called = call_user_function(EG(function_table), NULL,
    &PMG(observer), &retval, 1, &event);
/* Полный код отдельно обрабатывает новое исключение observer,
   затем освобождает event и инициализированный retval. */

Полезный callback не должен стать бесконечным источником новых callback. Флаг notifying подавляет повторный вызов наблюдателя от его служебного SELECT, но не скрывает сам SELECT из статистики. Ошибки необязательного callback учитываются отдельно, и только возникшее у него исключение изолируется; ранее существовавшее исключение драйвера не очищается. Observer отключается до finally-очистки и в конце PHP-запроса. Полный сценарий ниже читает реальные товары после каждого такого случая.

Для собственного расширения сначала определите точку наблюдения и действие при отказе, затем владение памятью и цепочку parent, затем публичный PHP API. Нельзя выводить обещание «наблюдаем всё» из того, что успешно скомпилирован один query-hook. Если нужна обязательная проверка целостности или доступа, оставьте её в явном обработчике и транзакции, а не в необязательном observer.

Получить доступные товары и увидеть один прямой запрос

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

Путь действия и результата
  1. Таблица: BOX 5, MUG 0, LED 12
  2. WHERE stock>0 → BOX/LED
  3. query hook → сохранённый mysqlnd.query → сервер
  4. snapshot: query_attempts=1; HTML: реальные остатки

Самая короткая рабочая идея

phpman_mysqlnd_reset();
$result = $db->query("SELECT sku, stock FROM $q WHERE stock > 0 ORDER BY id");
$items = $result->fetch_all(MYSQLI_ASSOC);
$counts = phpman_mysqlnd_snapshot();

reset() открывает окно измерения после создания учебной таблицы. $q — полное имя только нашей таблицы, уже проверенное и заключённое в обратные кавычки helper-функцией. SELECT выбирает два нужных столбца, WHERE отбрасывает MUG на сервере. fetch_all получает строки; snapshot возвращает отдельный массив счётчиков, не заменяет товары.

Это фрагмент после подключения и подготовки данных. Полный файл ниже создаёт нужную таблицу с BOX/MUG/LED, выполняет действие, сохраняет читаемый результат и удаляет только свои данные.

Что получится в проверяемом профиле

НаблюдениеРезультат
Доступные товарыBOX: 5; LED: 12
Прямые запросы1
Ошибки прямых запросов0
Подготовленные выполнения0
Самостоятельный PHP-файл, исходники C и архив
Получить доступные товары и увидеть один прямой запросСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null; $owned=$removed=$rows=$checks=$observations=[];
$runtime=n148Runtime();
try {

    $config=n148Config(); $db=n148Connect($config); $runtime=n148Runtime($db,$config);

    $name=n148OwnTable($db,$owned,'catalog','(id INT PRIMARY KEY, sku VARCHAR(20) UNIQUE, price_kopecks INT NOT NULL, stock INT NOT NULL) ENGINE=InnoDB');
    $q=n148Qualified($name);
    $db->query("INSERT INTO $q VALUES (1,'BOX',1200,5),(2,'MUG',2400,0),(3,'LED',700,12)");

    phpman_mysqlnd_reset();
    $catalog=$db->query("SELECT sku,stock FROM $q WHERE stock>0 ORDER BY id")->fetch_all(MYSQLI_ASSOC);
    $snapshot=phpman_mysqlnd_snapshot();
    $display=array_map(static fn(array $r):string=>$r['sku'].': '.(int)$r['stock'],$catalog);
    $rows=[['Доступные товары',implode('; ',$display)],['Прямые запросы',$snapshot['query_attempts']],
        ['Ошибки прямых запросов',$snapshot['query_failed']],['Подготовленные выполнения',$snapshot['stmt_attempts']]];
    $checks=['catalog_is_filtered'=>$display===['BOX: 5','LED: 12'],
        'one_direct_query'=>$snapshot['query_attempts']===1 && $snapshot['query_failed']===0,
        'one_connection'=>$snapshot['tracked_connections']===1,'no_prepared_execution'=>$snapshot['stmt_attempts']===0];
    $observations=['catalog'=>$catalog,'snapshot'=>$snapshot];

} finally {
    if (function_exists('phpman_mysqlnd_observe')) {phpman_mysqlnd_observe(null);}
    if ($db instanceof mysqli) {
        try {$removed=n148Cleanup($db,$owned);} finally {$db->close();}
    }
}

n148Report('mysql148-nd-native-query', 'Получить доступные товары и увидеть один прямой запрос', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Доступные товары — BOX: 5; LED: 12
Прямые запросы — 1
Ошибки прямых запросов — 0
Подготовленные выполнения — 0

Самостоятельный CLI-сценарий с собранным учебным phpman_mysqlnd. Реальные строки/ошибки и счётчики сохраняются в HTML/JSON; README содержит подготовку отдельной MySQL-базы и команду запуска.

Открыть таблицу фактического результата · Данные, снимки и условия JSON.

Границы и перенос в свой проект

Счётчик означает вход в перехваченный query, а не число строк, запросов всего сайта или сетевых пакетов. Полное чтение проверено отдельно по BOX/LED. У обычного mysqli query числовые поля здесь приходят строками; для подписи остаток явно приведён к int. Вне этого малого целочисленного поля такое приведение не является универсальным преобразованием денежных DECIMAL.

Попробуйте изменить требование

Добавьте в измеряемое окно отдельный COUNT(*) того же фильтра. Какие два числа нужно сравнить?

Разбор

Количество доступных строк станет 2, а query_attempts — 2. Это разные величины. COUNT считайте SQL-запросом, не загружайте весь каталог ради count в PHP.

Отобрать товары по цене и увидеть подготовленное выполнение

Покупатель задаёт предел цены. Значение передаётся отдельно от SQL, а монитор должен видеть выполнение даже тогда, когда прямой query не вызывается. Получим BOX за 1200 и LED за 700 копеек.

Путь действия и результата
  1. SQL с ? + threshold=1300
  2. prepare и bind_param создают подготовленную команду
  3. stmt.execute hook → сервер сравнивает цену
  4. Результат BOX/LED; stmt_attempts=1, query_attempts=0

Самая короткая рабочая идея

$statement = $db->prepare("SELECT sku, price_kopecks FROM $q WHERE price_kopecks <= ? ORDER BY id");
$threshold = 1300;
$statement->bind_param("i", $threshold);
$statement->execute();
$items = $statement->get_result()->fetch_all(MYSQLI_ASSOC);

Знак ? — место для значения, а не кавычки вокруг текста. bind_param с i связывает целую переменную; execute выполняет подготовленную команду. Наш второй C-hook находится именно в таблице методов statement. prepare и bind не увеличивают stmt_attempts; это намеренная граница выбранного измерения.

Это фрагмент после подключения и подготовки данных. Полный файл ниже создаёт нужную таблицу с BOX/MUG/LED, выполняет действие, сохраняет читаемый результат и удаляет только свои данные.

Что получится в проверяемом профиле

НаблюдениеРезультат
Цена не выше 1300 копеекBOX: 1200; LED: 700
Прямые запросы0
Подготовленные выполнения1
Ошибки выполнения0
Самостоятельный PHP-файл, исходники C и архив
Отобрать товары по цене и увидеть подготовленное выполнениеСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null; $owned=$removed=$rows=$checks=$observations=[];
$runtime=n148Runtime();
try {

    $config=n148Config(); $db=n148Connect($config); $runtime=n148Runtime($db,$config);

    $name=n148OwnTable($db,$owned,'catalog','(id INT PRIMARY KEY, sku VARCHAR(20) UNIQUE, price_kopecks INT NOT NULL, stock INT NOT NULL) ENGINE=InnoDB');
    $q=n148Qualified($name);
    $db->query("INSERT INTO $q VALUES (1,'BOX',1200,5),(2,'MUG',2400,0),(3,'LED',700,12)");

    phpman_mysqlnd_reset();
    $statement=$db->prepare("SELECT sku,price_kopecks FROM $q WHERE price_kopecks<=? ORDER BY id");
    $threshold=1300; $statement->bind_param('i',$threshold);
    $statement->execute();
    $catalog=$statement->get_result()->fetch_all(MYSQLI_ASSOC); $statement->close();
    $snapshot=phpman_mysqlnd_snapshot();
    $display=array_map(static fn(array $r):string=>$r['sku'].': '.(int)$r['price_kopecks'],$catalog);
    $rows=[['Цена не выше 1300 копеек',implode('; ',$display)],['Прямые запросы',$snapshot['query_attempts']],
        ['Подготовленные выполнения',$snapshot['stmt_attempts']],['Ошибки выполнения',$snapshot['stmt_failed']]];
    $checks=['threshold_applied'=>$catalog===[['sku'=>'BOX','price_kopecks'=>1200],['sku'=>'LED','price_kopecks'=>700]],
        'prepared_hook_seen'=>$snapshot['stmt_attempts']===1 && $snapshot['stmt_failed']===0,
        'query_hook_not_overclaimed'=>$snapshot['query_attempts']===0];
    $observations=['threshold_kopecks'=>$threshold,'catalog'=>$catalog,'snapshot'=>$snapshot];

} finally {
    if (function_exists('phpman_mysqlnd_observe')) {phpman_mysqlnd_observe(null);}
    if ($db instanceof mysqli) {
        try {$removed=n148Cleanup($db,$owned);} finally {$db->close();}
    }
}

n148Report('mysql148-nd-prepared-paths', 'Отобрать товары по цене и увидеть подготовленное выполнение', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Цена не выше 1300 копеек — BOX: 1200; LED: 700
Прямые запросы — 0
Подготовленные выполнения — 1
Ошибки выполнения — 0

Самостоятельный CLI-сценарий с собранным учебным phpman_mysqlnd. Реальные строки/ошибки и счётчики сохраняются в HTML/JSON; README содержит подготовку отдельной MySQL-базы и команду запуска.

Открыть таблицу фактического результата · Данные, снимки и условия JSON.

Границы и перенос в свой проект

Нельзя измерять все обращения к MySQL одним query-hook и затем объявлять подготовленный SELECT «невидимым запросом, которого не было». Протокол подготовки и выполнения отличается от текстового query. Монитор не собирает подставленный SQL и не публикует параметры; ошибку команды и строки результата сохраняет приложение.

Попробуйте изменить требование

Измените threshold на 800 и выполните эту же statement второй раз в том же окне. Что останется и сколько будет stmt_attempts?

Разбор

Останется LED: 700; stmt_attempts станет 2 при query_attempts=0. После каждого execute нужно прочитать или освободить соответствующий результат, прежде чем использовать следующее выполнение.

Сопоставить цены из MySQLi и PDO на общем mysqlnd

В проекте старый каталог читает MySQLi, новый сервис — PDO. Нужно убедиться, что они получают одинаковые цены, и увидеть обе ветви в одной точке драйвера. Получим три пары SKU/цена и отдельно BOX/LED с остатком больше 3.

Путь действия и результата
  1. Одна таблица каталога
  2. MySQLi::query → query hook
  3. PDO native query/prepare → stmt.execute hook
  4. Сравнение фактических цен; два независимых соединения

Самая короткая рабочая идея

$mysqliRows = $db->query("SELECT sku, price_kopecks FROM $q ORDER BY id")->fetch_all(MYSQLI_ASSOC);
$pdoRows = $pdo->query("SELECT sku, price_kopecks FROM $q ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);

Оба SQL обращаются к одной учебной таблице, но $db и $pdo — разные объекты с разными соединениями. PDO создаётся с ATTR_EMULATE_PREPARES=false. В проверенном PHP 8.3.6 его query тоже проходит через нативный statement execute. Поэтому три пользовательских вызова дают query=1 и stmt=2, а не query=2 и stmt=1.

Это фрагмент после подключения и подготовки данных. Полный файл ниже создаёт нужную таблицу с BOX/MUG/LED, выполняет действие, сохраняет читаемый результат и удаляет только свои данные.

Что получится в проверяемом профиле

НаблюдениеРезультат
Цены MySQLi и PDO совпалида
Цены в копейкахBOX: 1200; MUG: 2400; LED: 700
Остаток больше 3BOX, LED
Прямые запросы1
Подготовленные выполнения2
Соединения в окне2
Самостоятельный PHP-файл, исходники C и архив
Сопоставить цены из MySQLi и PDO на общем mysqlndСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null; $owned=$removed=$rows=$checks=$observations=[];
$runtime=n148Runtime();
try {

    $config=n148Config(); $db=n148Connect($config); $runtime=n148Runtime($db,$config);

    $name=n148OwnTable($db,$owned,'catalog','(id INT PRIMARY KEY, sku VARCHAR(20) UNIQUE, price_kopecks INT NOT NULL, stock INT NOT NULL) ENGINE=InnoDB');
    $q=n148Qualified($name);
    $db->query("INSERT INTO $q VALUES (1,'BOX',1200,5),(2,'MUG',2400,0),(3,'LED',700,12)");

    $pdo=n148Pdo($config);
    try {
        phpman_mysqlnd_reset();
        $mysqliRows=$db->query("SELECT sku,price_kopecks FROM $q ORDER BY id")->fetch_all(MYSQLI_ASSOC);
        $pdoRows=$pdo->query("SELECT sku,price_kopecks FROM $q ORDER BY id")->fetchAll();
        $prepared=$pdo->prepare("SELECT sku FROM $q WHERE stock>? ORDER BY id"); $prepared->execute([3]);
        $stockRows=$prepared->fetchAll(); $prepared=null;
        $snapshot=phpman_mysqlnd_snapshot();
        $normalize=static fn(array $list):array=>array_map(static fn(array $r):array=>[$r['sku'],(int)$r['price_kopecks']],$list);
        $prices=$normalize($mysqliRows);
        $rows=[['Цены MySQLi и PDO совпали',$prices===$normalize($pdoRows)],['Цены в копейках','BOX: '.$prices[0][1].'; MUG: '.$prices[1][1].'; LED: '.$prices[2][1]],
            ['Остаток больше 3',implode(', ',array_column($stockRows,'sku'))],['Прямые запросы',$snapshot['query_attempts']],
            ['Подготовленные выполнения',$snapshot['stmt_attempts']],['Соединения в окне',$snapshot['tracked_connections']]];
        $checks=['same_catalog_prices'=>$prices===[['BOX',1200],['MUG',2400],['LED',700]] && $prices===$normalize($pdoRows),
            'stock_filtered'=>$stockRows===[['sku'=>'BOX'],['sku'=>'LED']],
            'actual_driver_paths'=>$snapshot['query_attempts']===1 && $snapshot['stmt_attempts']===2,
            'two_connections'=>$snapshot['tracked_connections']===2];
        $observations=['mysqli_rows'=>$mysqliRows,'pdo_rows'=>$pdoRows,'stock_rows'=>$stockRows,'snapshot'=>$snapshot,
            'pdo_emulate_prepares'=>$pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES)];
    } finally {$pdo=null;}

} finally {
    if (function_exists('phpman_mysqlnd_observe')) {phpman_mysqlnd_observe(null);}
    if ($db instanceof mysqli) {
        try {$removed=n148Cleanup($db,$owned);} finally {$db->close();}
    }
}

n148Report('mysql148-nd-mixed-clients', 'Сопоставить цены из MySQLi и PDO на общем mysqlnd', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Цены MySQLi и PDO совпали — да
Цены в копейках — BOX: 1200; MUG: 2400; LED: 700
Остаток больше 3 — BOX, LED
Прямые запросы — 1
Подготовленные выполнения — 2
Соединения в окне — 2

Самостоятельный CLI-сценарий с собранным учебным phpman_mysqlnd. Реальные строки/ошибки и счётчики сохраняются в HTML/JSON; README содержит подготовку отдельной MySQL-базы и команду запуска.

Открыть таблицу фактического результата · Данные, снимки и условия JSON.

Границы и перенос в свой проект

Имя PHP-метода не доказывает путь внутри драйвера. Значения цены в обычном mysqli query здесь строки, в PDO — int; перед предметным сравнением малые целые копейки явно нормализованы. Один клиент не наследует транзакцию другого. Модуль наблюдает оба только при использовании ими mysqlnd в том же PHP-процессе.

Попробуйте изменить требование

Можно ли суммировать query_attempts+stmt_attempts и назвать это числом всех сетевых команд?

Разбор

Нет: здесь получится 3 наблюдаемых выполнения. Подготовка, fetch, управление транзакцией и другие команды не включены отдельными hook. Для полноты сетевой статистики нужны другие инструменты и отдельные определения метрик.

Увидеть SQL-ошибку и получить правильный каталог после исправления

Опечатка имени столбца или повторный ключ не должны превращаться в успешный запрос из-за установленного монитора. Получим два исходных кода ошибок, затем рабочий список BOX/LED; неудачная вставка не заменит товар BOX.

Путь действия и результата
  1. SELECT nonexistent_stock → серверная 1054
  2. INSERT id=1 повторно → серверная 1062
  3. C-hook возвращает FAIL родителя без подмены
  4. PHP catch сохраняет коды → исправленный SELECT читает BOX/LED

Самая короткая рабочая идея

try {
    $db->query("SELECT nonexistent_stock FROM $q");
} catch (mysqli_sql_exception $error) {
    $code = $error->getCode(); // 1054
}
$items = $db->query("SELECT sku, stock FROM $q WHERE stock > 0 ORDER BY id")->fetch_all(MYSQLI_ASSOC);

try выполняет действие; catch получает именно mysqli_sql_exception, созданное расширением. getCode и getSqlState сохраняют машинно проверяемую причину. Наш C-hook считает FAIL родительского метода и возвращает тот же статус. Он не заменяет его true и не гасит исходное исключение.

Это фрагмент после подключения и подготовки данных. Полный файл ниже создаёт нужную таблицу с BOX/MUG/LED, выполняет действие, сохраняет читаемый результат и удаляет только свои данные.

Что получится в проверяемом профиле

НаблюдениеРезультат
Неизвестный столбец1054
Повтор первичного ключа1062
Исправленный каталогBOX: 5; LED: 12
Прямых попыток / ошибок2 / 1
Подготовленных попыток / ошибок1 / 1
Самостоятельный PHP-файл, исходники C и архив
Увидеть SQL-ошибку и получить правильный каталог после исправленияСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null; $owned=$removed=$rows=$checks=$observations=[];
$runtime=n148Runtime();
try {

    $config=n148Config(); $db=n148Connect($config); $runtime=n148Runtime($db,$config);

    $name=n148OwnTable($db,$owned,'catalog','(id INT PRIMARY KEY, sku VARCHAR(20) UNIQUE, price_kopecks INT NOT NULL, stock INT NOT NULL) ENGINE=InnoDB');
    $q=n148Qualified($name);
    $db->query("INSERT INTO $q VALUES (1,'BOX',1200,5),(2,'MUG',2400,0),(3,'LED',700,12)");

    phpman_mysqlnd_reset(); $queryError=$statementError=null;
    try {$db->query("SELECT nonexistent_stock FROM $q");}
    catch (mysqli_sql_exception $error) {$queryError=['code'=>$error->getCode(),'state'=>$error->getSqlState(),'type'=>get_class($error)];}
    $statement=$db->prepare("INSERT INTO $q VALUES (?,?,?,?)");
    $id=1;$sku='OTHER';$price=500;$stock=1; $statement->bind_param('isii',$id,$sku,$price,$stock);
    try {$statement->execute();}
    catch (mysqli_sql_exception $error) {$statementError=['code'=>$error->getCode(),'state'=>$error->getSqlState(),'type'=>get_class($error)];}
    finally {$statement->close();}
    $catalog=$db->query("SELECT sku,stock FROM $q WHERE stock>0 ORDER BY id")->fetch_all(MYSQLI_ASSOC);
    $snapshot=phpman_mysqlnd_snapshot();
    $rows=[['Неизвестный столбец',$queryError['code']??0],['Повтор первичного ключа',$statementError['code']??0],
        ['Исправленный каталог',implode('; ',array_map(static fn(array $r):string=>$r['sku'].': '.(int)$r['stock'],$catalog))],
        ['Прямых попыток / ошибок',$snapshot['query_attempts'].' / '.$snapshot['query_failed']],
        ['Подготовленных попыток / ошибок',$snapshot['stmt_attempts'].' / '.$snapshot['stmt_failed']]];
    $checks=['query_error_preserved'=>$queryError!==null && $queryError['code']===1054 && $queryError['state']==='42S22',
        'statement_error_preserved'=>$statementError!==null && $statementError['code']===1062 && $statementError['state']==='23000',
        'original_data_preserved'=>array_column($catalog,'sku')===['BOX','LED'] && (int)$catalog[0]['stock']===5,
        'failed_counts_seen'=>$snapshot['query_attempts']===2 && $snapshot['query_failed']===1 && $snapshot['stmt_attempts']===1 && $snapshot['stmt_failed']===1];
    $observations=['query_error'=>$queryError,'statement_error'=>$statementError,'catalog'=>$catalog,'snapshot'=>$snapshot];

} finally {
    if (function_exists('phpman_mysqlnd_observe')) {phpman_mysqlnd_observe(null);}
    if ($db instanceof mysqli) {
        try {$removed=n148Cleanup($db,$owned);} finally {$db->close();}
    }
}

n148Report('mysql148-nd-error-preservation', 'Увидеть SQL-ошибку и получить правильный каталог после исправления', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Неизвестный столбец — 1054
Повтор первичного ключа — 1062
Исправленный каталог — BOX: 5; LED: 12
Прямых попыток / ошибок — 2 / 1
Подготовленных попыток / ошибок — 1 / 1

Самостоятельный CLI-сценарий с собранным учебным phpman_mysqlnd. Реальные строки/ошибки и счётчики сохраняются в HTML/JSON; README содержит подготовку отдельной MySQL-базы и команду запуска.

Открыть таблицу фактического результата · Данные, снимки и условия JSON.

Границы и перенос в свой проект

Ноль изменённых строк не равен ошибке SQL: корректный UPDATE без совпадений может выполниться успешно. query_failed не измеряет неудовлетворённые бизнес-условия. Их проверяют по affected_rows и реальным данным. Не показывайте пользователю внутренний SQL или секреты; здесь публикуются только класс, код и SQLSTATE.

Попробуйте изменить требование

Что произойдёт с failed, если SELECT корректен, но WHERE не находит товаров?

Разбор

Запрос успешен: строки пусты, query_failed не увеличивается. Нужно показать человеку отсутствие подходящих товаров; исключение для этого не требуется.

Закрыть один источник и продолжить чтение другого

Обработчик объединяет данные из двух источников. Закрытие первого соединения должно освободить его собственное состояние монитора и оставить второй источник работоспособным. Здесь источники обозначены явными SELECT-константами, без выдуманного обращения к чужим складам.

Путь действия и результата
  1. Первый connection → warehouse-a 5, локальный id1
  2. Второй connection → warehouse-b 12, локальный id2
  3. close первого → его plugin slot освобождён
  4. Второй SELECT возвращает17; snapshot хранит итог id1/id2

Самая короткая рабочая идея

$left = $first->query("SELECT 5 AS stock")->fetch_assoc();
$first->close();
$right = $second->query("SELECT 12+5 AS total_stock")->fetch_assoc();
// Второе соединение остаётся пригодным для своей работы.

first и second — два отдельных объекта mysqli. close завершает первое соединение, но не уничтожает уже скопированный PHP-массив left. В C собственный счётчик привязан к plugin slot конкретного connection_data. Поэтому его освобождение не затрагивает соседний указатель. Локальные ID 1/2 относятся только к этому окну измерения, это не серверные CONNECTION_ID().

Это фрагмент с двумя уже созданными соединениями. Полный файл ниже открывает их, выполняет три SELECT, закрывает свои подключения и сохраняет отчёт; таблицы ему не нужны.

Что получится в проверяемом профиле

НаблюдениеРезультат
Первый источникwarehouse-a: 5
Второй источникwarehouse-b: 12
Расчёт после закрытия первого17
Закрытые / наблюдавшиеся соединения1 / 2
Попытки по соединениям1 / 2
Самостоятельный PHP-файл, исходники C и архив
Закрыть один источник и продолжить чтение другогоСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null; $owned=$removed=$rows=$checks=$observations=[];
$runtime=n148Runtime();
try {

    $config=n148Config(); $db=n148Connect($config); $runtime=n148Runtime($db,$config);

    $first=n148Connect($config); $second=n148Connect($config);
    try {
        phpman_mysqlnd_reset();
        $left=$first->query("SELECT 'warehouse-a' AS source, 5 AS stock")->fetch_assoc();
        $right=$second->query("SELECT 'warehouse-b' AS source, 12 AS stock")->fetch_assoc();
        $first->close(); $first=null;
        $total=$second->query('SELECT 12+5 AS total_stock')->fetch_assoc();
        $snapshot=phpman_mysqlnd_snapshot(); $connections=$snapshot['connections'];
        $rows=[['Первый источник',$left['source'].': '.(int)$left['stock']],['Второй источник',$right['source'].': '.(int)$right['stock']],
            ['Расчёт после закрытия первого',(int)$total['total_stock']],['Закрытые / наблюдавшиеся соединения',$snapshot['closed_connections'].' / '.$snapshot['tracked_connections']],
            ['Попытки по соединениям',$connections[0]['query_attempts'].' / '.$connections[1]['query_attempts']]];
        $checks=['both_results_read'=>$left['source']==='warehouse-a' && $right['source']==='warehouse-b',
            'second_connection_survived'=>(int)$total['total_stock']===17,
            'separate_local_state'=>$snapshot['tracked_connections']===2 && $snapshot['closed_connections']===1 && count($connections)===2
                && $connections[0]['query_attempts']===1 && $connections[0]['closed']===true && $connections[1]['query_attempts']===2 && $connections[1]['closed']===false];
        $observations=['first_result'=>$left,'second_result'=>$right,'after_close'=>$total,'snapshot'=>$snapshot];
    } finally {if ($first instanceof mysqli) {$first->close();} $second->close();}

} finally {
    if (function_exists('phpman_mysqlnd_observe')) {phpman_mysqlnd_observe(null);}
    if ($db instanceof mysqli) {
        try {$removed=n148Cleanup($db,$owned);} finally {$db->close();}
    }
}

n148Report('mysql148-nd-connection-lifecycle', 'Закрыть один источник и продолжить чтение другого', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Первый источник — warehouse-a: 5
Второй источник — warehouse-b: 12
Расчёт после закрытия первого — 17
Закрытые / наблюдавшиеся соединения — 1 / 2
Попытки по соединениям — 1 / 2

Самостоятельный CLI-сценарий с собранным учебным phpman_mysqlnd. Реальные строки/ошибки и счётчики сохраняются в HTML/JSON; README содержит подготовку отдельной MySQL-базы и команду запуска.

Открыть таблицу фактического результата · Данные, снимки и условия JSON.

Границы и перенос в свой проект

Сценарий не обещает поведения постоянных соединений: они могут пережить запрос PHP, поэтому владение памятью и границы запроса необходимо проверять отдельно. Для наблюдаемых соединений snapshot ограничен 64 строками; dropped_connections сообщает о превышении, общие числа продолжаются. reset обнуляет окно и ID, а не закрывает MySQL-соединения.

Попробуйте изменить требование

Удалятся ли значения left из PHP после закрытия first? Что нельзя делать после close?

Разбор

Полученный массив остаётся доступным. Повторный запрос через закрытый first недопустим; используйте живой second или создайте новое соединение. Закрытие одного объекта не является глобальной очисткой всех подключений.

Вызвать PHP-наблюдателя из C и не испортить каталог

После запроса каталога хочется уведомить вспомогательную диагностику, не переделывая каждое место вызова. Наблюдатель получает короткое событие, отдельным соединением обнаруживает один товар без остатка, а основной обработчик по-прежнему получает BOX/LED. Затем намеренно отказавший наблюдатель не уничтожает следующий результат BOX.

Путь действия и результата
  1. Основной query → исходный mysqlnd возвращает статус
  2. C формирует kind/ok/connection_id → PHP callback
  3. Другой connection считает MUG без остатка
  4. Вложенный hook считается, повтор callback запрещён guard
  5. Основной fetch получает BOX/LED

Самая короткая рабочая идея

$events = [];
phpman_mysqlnd_observe(static function (array $event) use (&$events): void {
    $events[] = $event;
});
$items = $db->query("SELECT sku, stock FROM $q WHERE stock > 0 ORDER BY id")->fetch_all(MYSQLI_ASSOC);
phpman_mysqlnd_observe(null);

observe — наша функция расширения, не стандартный API mysqlnd. callable сохраняет PHP-функцию до отключения или конца запроса; массив event создаёт C. kind указывает перехваченный метод, ok — статус родителя, connection_id — локальный номер. use (&$events) позволяет обработчику дописать событие во внешний массив. null выключает callback. В полном примере отдельный watcher заранее открыт для дополнительного SELECT.

Это фрагмент после подключения и подготовки данных. Полный файл ниже создаёт нужную таблицу с BOX/MUG/LED, выполняет действие, сохраняет читаемый результат и удаляет только свои данные.

Что получится в проверяемом профиле

НаблюдениеРезультат
Доступные товарыBOX, LED
Товаров без остатка1
Вызовов первого наблюдателя1
Повторных callback пропущено1
Ошибок необязательного наблюдателя1
Каталог после его ошибкиBOX
Самостоятельный PHP-файл, исходники C и архив
Вызвать PHP-наблюдателя из C и не испортить каталогСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null; $owned=$removed=$rows=$checks=$observations=[];
$runtime=n148Runtime();
try {

    $config=n148Config(); $db=n148Connect($config); $runtime=n148Runtime($db,$config);

    $name=n148OwnTable($db,$owned,'catalog','(id INT PRIMARY KEY, sku VARCHAR(20) UNIQUE, price_kopecks INT NOT NULL, stock INT NOT NULL) ENGINE=InnoDB');
    $q=n148Qualified($name);
    $db->query("INSERT INTO $q VALUES (1,'BOX',1200,5),(2,'MUG',2400,0),(3,'LED',700,12)");

    $watcher=n148Connect($config); $events=$alerts=[];
    try {
        phpman_mysqlnd_reset();
        phpman_mysqlnd_observe(static function(array $event) use (&$events,&$alerts,$watcher,$q):void {
            $events[]=$event;
            $alerts[]=$watcher->query("SELECT COUNT(*) AS sold_out FROM $q WHERE stock=0")->fetch_assoc();
        });
        $catalog=$db->query("SELECT sku,stock FROM $q WHERE stock>0 ORDER BY id")->fetch_all(MYSQLI_ASSOC);
        phpman_mysqlnd_observe(null);
        $afterWatch=phpman_mysqlnd_snapshot();
        phpman_mysqlnd_observe(static function(array $event):void {throw new RuntimeException('Необязательный наблюдатель отказал');});
        $still=$db->query("SELECT sku FROM $q WHERE id=1")->fetch_assoc();
        phpman_mysqlnd_observe(null);
        $snapshot=phpman_mysqlnd_snapshot();
        $rows=[['Доступные товары',implode(', ',array_column($catalog,'sku'))],['Товаров без остатка',(int)$alerts[0]['sold_out']],
            ['Вызовов первого наблюдателя',$afterWatch['observer_calls']],['Повторных callback пропущено',$afterWatch['observer_reentrant_skips']],
            ['Ошибок необязательного наблюдателя',$snapshot['observer_errors']],['Каталог после его ошибки',$still['sku']]];
        $checks=['catalog_preserved'=>array_column($catalog,'sku')===['BOX','LED'],
            'watch_result_read'=>count($alerts)===1 && (int)$alerts[0]['sold_out']===1,
            'reentrancy_bounded'=>count($events)===1 && $events[0]===['kind'=>'query','ok'=>true,'connection_id'=>1]
                && $afterWatch['query_attempts']===2 && $afterWatch['observer_calls']===1 && $afterWatch['observer_reentrant_skips']===1,
            'observer_error_isolated'=>$still['sku']==='BOX' && $snapshot['query_failed']===0 && $snapshot['observer_errors']===1 && $snapshot['observer_calls']===2];
        $observations=['catalog'=>$catalog,'events'=>$events,'alerts'=>$alerts,'after_watch'=>$afterWatch,'after_observer_error'=>$still,'snapshot'=>$snapshot];
    } finally {phpman_mysqlnd_observe(null); $watcher->close();}

} finally {
    if (function_exists('phpman_mysqlnd_observe')) {phpman_mysqlnd_observe(null);}
    if ($db instanceof mysqli) {
        try {$removed=n148Cleanup($db,$owned);} finally {$db->close();}
    }
}

n148Report('mysql148-nd-userland-observer', 'Вызвать PHP-наблюдателя из C и не испортить каталог', $rows, $checks, $observations, $runtime, $owned, $removed);
Ожидаемый результат
Доступные товары — BOX, LED
Товаров без остатка — 1
Вызовов первого наблюдателя — 1
Повторных callback пропущено — 1
Ошибок необязательного наблюдателя — 1
Каталог после его ошибки — BOX

Самостоятельный CLI-сценарий с собранным учебным phpman_mysqlnd. Реальные строки/ошибки и счётчики сохраняются в HTML/JSON; README содержит подготовку отдельной MySQL-базы и команду запуска.

Открыть таблицу фактического результата · Данные, снимки и условия JSON.

Границы и перенос в свой проект

Callback запускается до полного пользовательского чтения исходного результата: нельзя читать или закрывать исходное занятое соединение внутри него. Guard предотвращает повторный callback от вложенного запроса, но сам запрос учитывается. Ошибка необязательного callback изолируется и считается; исходное ранее возникшее SQL-исключение не очищается. Это намеренная политика наблюдения, неподходящая для обязательной проверки оплаты/доступа. Fatal/OOM/завершение процесса не восстанавливаются. Callback добавляет работу, это не бесплатное логирование.

Попробуйте изменить требование

Почему нельзя убрать guard и позволить watcher заново вызывать тот же наблюдатель? Можно ли вместо observer решать, разрешена ли продажа?

Разбор

Без guard служебный SELECT вызывает callback, который снова делает SELECT, и так до отказа. Разрешение продажи должно быть обязательной бизнес-проверкой в обычном обработчике/транзакции; необязательный observer не управляет успешностью исходного SQL.

Какие реальные ошибки разработки здесь разобраны

Разработчик хочет увидеть, какую команду выполняет подготовленный запрос. В нашем решении подготовленное выполнение наблюдается отдельно; монитор не строит потенциально неверную SQL-строку из параметров и не хранит их. Prepared execute и попытка увидеть подставленный SQL → самостоятельный пример.

В обсуждении успешный возврат SQL принимают за успешное бизнес-действие, хотя условие не нашло строку. Здесь FAIL, пустой результат и предметный результат разделены: код ошибки сохраняется, а полезные данные читаются отдельно. UPDATE выполнился, но нужная строка не изменилась → самостоятельный пример.

Скомпилированный мост к PHP ещё может ошибаться в zval, указателях и возврате. Старые PHP 5 рецепты не перенесены механически: наш модуль использует API PHP 8.3, явное владение callback/retval и ограничение повторного входа. Вызов PHP из расширения C: значение результата и время жизни → самостоятельный пример.

Применить наблюдения без ложных обещаний

Для своего обработчика задайте измеряемое окно: подготовка подключения до reset, нужные действия внутри окна, snapshot после полного чтения, очистка после снимка. Сначала убедитесь в правильных строках и отказах, затем интерпретируйте счётчики. Раздел границ mysqlnd и измерения страницы связывает эту механику с прикладным выбором инструмента.

Этот материал раскрывает архитектуру, все имена официального списка C API и полный путь сборки одного работающего плагина. Он не заявляет, что перехвачены все нативные методы или проверены все SAPI. Для расширения до fetch/async/постоянных соединений требуется отдельный явный контракт жизненного цикла и новые реальные сценарии, а не переименование query_attempts в «все запросы».

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.