Files
progcode/web/scripts/upgrade-2018-02.mjs
T
huncode 7c5b19c960
Build and deploy / deploy (push) Successful in 18s
edit full article archive to publication standard
2026-07-31 23:08:19 +03:00

502 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(text) {
return '<pre><code>' + escapeHtml(text.trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
const phpSetErrorHandler = {
title: 'PHP manual: set_error_handler',
url: 'https://www.php.net/manual/en/function.set-error-handler.php',
note: 'какие ошибки передаются пользовательскому обработчику и какие типы он не перехватывает',
};
const phpExceptionHandler = {
title: 'PHP manual: set_exception_handler',
url: 'https://www.php.net/manual/en/function.set-exception-handler.php',
note: 'обработчик непойманного Throwable, который получает Error и Exception',
};
const phpShutdown = {
title: 'PHP manual: register_shutdown_function',
url: 'https://www.php.net/manual/en/function.register-shutdown-function.php',
note: 'когда PHP вызывает зарегистрированную функцию завершения',
};
const phpLastError = {
title: 'PHP manual: error_get_last',
url: 'https://www.php.net/manual/en/function.error-get-last.php',
note: 'формат последней ошибки: type, message, file и line',
};
const phpCurlExec = {
title: 'PHP manual: curl_exec',
url: 'https://www.php.net/manual/en/function.curl-exec.php',
note: 'строгое сравнение с false и отличие ошибки cURL от HTTP-статуса',
};
const phpCurlInfo = {
title: 'PHP manual: curl_getinfo',
url: 'https://www.php.net/manual/en/function.curl-getinfo.php',
note: 'данные последней передачи, включая http_code, content_type и total_time',
};
const phpCurlErrno = {
title: 'PHP manual: curl_errno',
url: 'https://www.php.net/manual/en/function.curl-errno.php',
note: 'код последней ошибки cURL и ноль при отсутствии ошибки',
};
const httpSemantics = {
title: 'RFC 7231, раздел 6: Response Status Codes',
url: 'https://www.rfc-editor.org/rfc/rfc7231#section-6',
note: 'семантика статус-кодов HTTP на уровне протокола',
};
const phpJsonDecode = {
title: 'PHP manual: json_decode',
url: 'https://www.php.net/manual/en/function.json-decode.php',
note: 'что возвращает декодер, требование UTF-8 и изменение PHP 7.3',
};
const phpJsonLastError = {
title: 'PHP manual: json_last_error',
url: 'https://www.php.net/manual/en/function.json-last-error.php',
note: 'коды ошибок последней операции JSON',
};
const jsonRfc = {
title: 'RFC 8259: JSON',
url: 'https://www.rfc-editor.org/rfc/rfc8259.html',
note: 'JSON допускает не только объект и массив, но и null, false, true, число и строку',
};
const practiceArticle = {
slug: 'editorial-2018-02-practice-php-diagnostics',
title: 'PHP. Как записать причину 500-й ошибки в интеграции',
categories: ['PHP', 'Отладка', 'Интеграции'],
cover: '/assets/editorial/2018/php-fatal-context-flow.svg',
excerpt: 'Разбираю, как собрать один полезный диагностический факт при фатальной ошибке PHP: где работает set_error_handler, зачем нужен shutdown-обработчик и какие данные нельзя писать в лог.',
readingMinutes: 10,
contentHtml: [
paragraph('Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один <code>try/catch</code> вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса? Цена ошибки — повторный разбор интеграции без исходных фактов.'),
heading('Почему одного set_error_handler недостаточно'),
paragraph('Первое, что обычно хочется сделать, — повесить <code>set_error_handler</code> и считать задачу закрытой. У функции есть граница: пользовательский обработчик не получает <code>E_ERROR</code>, <code>E_PARSE</code>, <code>E_CORE_ERROR</code> и <code>E_COMPILE_ERROR</code>. Он также не может увидеть ошибку, случившуюся до регистрации обработчика. Это не дефект функции, а условие, от которого надо строить диагностику.'),
paragraph('Поэтому я разделяю три случая. Обычное предупреждение попадает в обработчик ошибок. Непойманное исключение или <code>Error</code> в PHP 7 попадает в обработчик исключений. Для части фатальных ошибок остаётся функция завершения: PHP вызывает её после окончания скрипта или после <code>exit()</code>, а <code>error_get_last()</code> даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.'),
figure('/assets/editorial/2018/php-fatal-context-flow.svg', 'Схема: контекст операции создаётся перед интеграцией; предупреждение идёт в set_error_handler, исключение — в set_exception_handler, фатальная ошибка проверяется при shutdown', 'Один request ID проходит через все три ветки. В журнале видно не только текст PHP, но и операцию, на которой он возник.'),
heading('Сначала определить, что именно нужно найти потом'),
paragraph('Лог полезен, если по одной записи можно ответить на четыре вопроса: какая операция шла, какой внешний идентификатор обрабатывался, где остановился код и какой класс ошибки случился. Записывать целиком <code>$_POST</code>, заголовок авторизации или ответ партнёра для этого не нужно. В них часто лежат пароли, персональные данные и токены; при расследовании такой журнал создаёт вторую проблему.'),
paragraph('Для импорта заказа я оставляю короткий контекст: случайный ID операции, имя интеграции, внешний ID заказа и этап. Этап меняется перед опасным участком: <code>request_prepared</code>, <code>partner_called</code>, <code>response_saved</code>. Если процесс оборвался, последняя метка намного полезнее догадки по номеру строки.'),
dataTable(
['Поле журнала', 'Пример', 'Зачем оно нужно'],
[
['<code>request_id</code>', '<code>sync-20180207-4f2a</code>', 'Связать запись PHP с логом веб-сервера и сообщением партнёра'],
['<code>operation</code>', '<code>order_export</code>', 'Не смешать импорт каталога, webhook и ручной запуск'],
['<code>external_id</code>', '<code>ORD-9182</code>', 'Повторить один сценарий без поиска по всему набору данных'],
['<code>stage</code>', '<code>partner_called</code>', 'Понять, успел ли код дойти до внешнего вызова'],
['<code>error_type</code>', '<code>E_ERROR</code> или <code>Throwable</code>', 'Отделить ошибку PHP от ответа HTTP'],
['<code>file</code>, <code>line</code>', 'путь и строка', 'Открыть точку падения в той версии кода, которая работала в момент сбоя'],
],
),
heading('Минимальная обвязка для PHP 7'),
paragraph('Ниже пример для одного HTTP-запроса. Он не пытается перехватить всё подряд и не меняет поведение штатного обработчика PHP: после записи предупреждения возвращается <code>false</code>. Это удобно на первом внедрении: существующие настройки <code>error_reporting</code> и журнал сервера остаются на месте, а рядом появляется структурированная запись для интеграции.'),
codeBlock(String.raw`
<?php
function writeIntegrationLog(array $record)
{
error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
}
function installIntegrationDiagnostics($requestId, $operation, $externalId)
{
$context = array(
'request_id' => $requestId,
'operation' => $operation,
'external_id' => $externalId,
'stage' => 'started',
);
$setStage = function ($stage) use (&$context) {
$context['stage'] = $stage;
};
set_error_handler(function ($severity, $message, $file, $line) use (&$context) {
if (!(error_reporting() & $severity)) {
return false;
}
writeIntegrationLog($context + array(
'kind' => 'php_error',
'error_type' => $severity,
'message' => $message,
'file' => $file,
'line' => $line,
));
return false;
});
set_exception_handler(function (Throwable $error) use (&$context) {
writeIntegrationLog($context + array(
'kind' => 'uncaught_throwable',
'class' => get_class($error),
'message' => $error->getMessage(),
'file' => $error->getFile(),
'line' => $error->getLine(),
));
});
register_shutdown_function(function () use (&$context) {
$last = error_get_last();
$fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR);
if ($last === null || !in_array($last['type'], $fatalTypes, true)) {
return;
}
writeIntegrationLog($context + array(
'kind' => 'fatal_error',
'error_type' => $last['type'],
'message' => $last['message'],
'file' => $last['file'],
'line' => $last['line'],
));
});
return $setStage;
}
$setStage = installIntegrationDiagnostics(
'sync-20180207-4f2a',
'order_export',
'ORD-9182'
);
$setStage('request_prepared');
// Здесь вызывается клиент партнёра.
$setStage('partner_called');
`),
paragraph('В настоящем коде генерация <code>request_id</code> и запись журнала обычно живут в приложении, а не в каждой интеграции. Здесь они оставлены рядом, чтобы видно было главное: контекст создаётся до внешнего вызова, а не в блоке обработки ошибки. Нельзя восстановить по фатальной ошибке то, что код не успел записать.'),
heading('Как проверить схему до аварии'),
paragraph('Проверять такую обвязку лучше не на боевом заказе. Для предупреждения достаточно отдельного скрипта с <code>trigger_error(&quot;diagnostic test&quot;, E_USER_WARNING)</code>. Для исключения — выбросить <code>RuntimeException</code> после установки этапа. Фатальный путь нужно запускать только в изолированной среде: ошибка, которую нельзя перехватить через <code>set_error_handler</code>, должна оставить запись из shutdown-функции, а сам тест не должен менять состояние сторонней системы.'),
orderedList([
'Добавить обвязку в точку входа до вызова клиента интеграции и задать <code>request_id</code>, операцию и внешний ID.',
'Запустить локальный сценарий с предупреждением и убедиться, что в журнале есть все поля таблицы, а штатное сообщение PHP не исчезло.',
'Запустить сценарий с непойманным исключением в отдельном endpoint и проверить запись с классом исключения и последним этапом.',
'В тестовой среде проверить фатальный случай после регистрации обработчиков и убедиться, что shutdown-запись не дублирует обычные предупреждения.',
'Открыть журнал с позиции человека, который не видел код: по одной строке должно быть понятно, какой внешний объект повторять и где смотреть дальше.',
]),
heading('Где эта схема заканчивается'),
paragraph('Она не ловит синтаксическую ошибку в файле, который не дал приложению стартовать: обработчики ещё не зарегистрированы. Она не гарантирует запись при принудительном завершении процесса. Она не заменяет мониторинг 500-х на уровне веб-сервера. И она не даёт права сохранять секреты в журнал. Для таких случаев остаются деплой-проверки, журналы окружения и правила маскирования данных.'),
paragraph('Ещё одна граница — дубли. Ошибка внутри <code>set_error_handler</code> и ошибка в shutdown-функции не должны сами вызвать бесконечный поток записей. Поэтому запись должна быть короткой, а логгер — максимально простым. Если для доставки лога нужен сетевой запрос, я бы не ставил его в shutdown-путь: при падении сети потеряем и исходную ошибку, и время на разбор.'),
heading('Порядок, который остаётся в проекте'),
paragraph('Сначала ставим контекст, затем меняем этапы перед побочными эффектами, потом отдельно видим предупреждение, исключение и фатальный случай. После этого ошибка 500 перестаёт быть сообщением «что-то не так». В ней есть операция, внешний объект, последняя пройденная граница и место в коде. Этого достаточно, чтобы воспроизвести проблему до следующего запроса партнёра.'),
heading('Проверяемые источники'),
sourceList([phpSetErrorHandler, phpExceptionHandler, phpShutdown, phpLastError]),
].join('\n'),
};
const mechanismArticle = {
slug: 'editorial-2018-02-mechanism-php-diagnostics',
title: 'PHP и cURL. Почему curl_exec() не означает успех интеграции',
categories: ['PHP', 'cURL', 'Интеграции'],
cover: '/assets/editorial/2018/curl-outcome-classifier.svg',
excerpt: 'curl_exec() может вернуть тело ответа, хотя партнёр ответил 404 или 500. Разбираю три уровня результата: транспорт, HTTP и контракт полезной нагрузки.',
readingMinutes: 10,
contentHtml: [
paragraph('После ночной выгрузки в логе стоит «запрос выполнен», потому что <code>curl_exec()</code> вернул строку. Утром выясняется, что строкой была HTML-страница с 403, а заказы не дошли. Ошибка в проверке не синтаксическая: код спросил cURL только о доставке ответа, а бизнес-код сделал вывод о результате всей операции. Разберём один вопрос: какой минимальный набор проверок отличает сетевой сбой, HTTP-отказ и рабочий ответ партнёра?'),
heading('У одного вызова три разных результата'),
paragraph('При включённом <code>CURLOPT_RETURNTRANSFER</code> функция <code>curl_exec()</code> возвращает тело ответа при успехе cURL и <code>false</code> при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой <code>&quot;0&quot;</code>, которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через <code>curl_getinfo()</code>. Цена ошибки — записать страницу отказа как успешный ответ и отправить дальше неверные данные.'),
paragraph('Отсюда порядок проверки. Сначала узнаём, состоялась ли передача: <code>$body === false</code>, <code>curl_errno()</code> и <code>curl_error()</code>. Затем читаем <code>http_code</code>, тип содержимого и время из <code>curl_getinfo()</code>. Только после этого разбираем тело как JSON или иной формат, который обещан договором с партнёром. Если смешать уровни, журнал начинает сообщать «ошибка API» и для DNS, и для 401, и для сломанного JSON.'),
figure('/assets/editorial/2018/curl-outcome-classifier.svg', 'Диаграмма классификации ответа cURL: false ведёт к транспортной ошибке; строка проверяется по HTTP-коду, затем по контракту тела', 'Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.'),
heading('Что сохранять для каждого уровня'),
dataTable(
['Наблюдение', 'Класс сбоя', 'Что записать в журнал', 'Следующее действие'],
[
['<code>$body === false</code>', 'Транспорт или TLS', '<code>curl_errno</code>, <code>curl_error</code>, URL без секрета, время', 'Проверить DNS, сертификат, таймаут и доступность хоста'],
['Есть тело, <code>http_code</code> 401 или 403', 'Авторизация или права', 'HTTP-код, операция, внешний ID, request ID', 'Проверить учётные данные и область доступа; не печатать токен'],
['Есть тело, <code>http_code</code> 404', 'Адрес или версия API', 'HTTP-код и маршрут без query-параметров', 'Сверить путь, метод и версию endpoint'],
['Есть тело, <code>http_code</code> 500', 'Ошибка удалённой стороны', 'HTTP-код, request ID, первые безопасные признаки ответа', 'Передать партнёру ID запроса и время, не повторять запись вслепую'],
['2xx и ожидаемое тело', 'Транспорт и HTTP прошли', 'Код, размер и время ответа', 'Проверить обязательные поля тела перед изменением локальных данных'],
],
),
heading('Клиент, который не прячет уровень ошибки'),
paragraph('В примере ниже нет общего «интеграционного клиента». Нужна маленькая функция, которую легко вызвать в изолированном скрипте и легко покрыть разными ответами. Время соединения и общий таймаут здесь проектные: их надо выбирать под договорённость с конкретным сервисом, а не переносить числа из чужого кода.'),
codeBlock(String.raw`
<?php
function requestPartner($url, $requestId)
{
$handle = curl_init($url);
curl_setopt_array($handle, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => array(
'Accept: application/json',
'X-Request-Id: ' . $requestId,
),
));
$body = curl_exec($handle);
$curlErrno = curl_errno($handle);
$curlError = curl_error($handle);
$info = curl_getinfo($handle);
curl_close($handle);
if ($body === false) {
throw new RuntimeException(json_encode(array(
'kind' => 'transport_error',
'request_id' => $requestId,
'curl_errno' => $curlErrno,
'curl_error' => $curlError,
'total_time' => $info['total_time'],
)));
}
$status = (int) $info['http_code'];
if ($status < 200 || $status >= 300) {
throw new RuntimeException(json_encode(array(
'kind' => 'http_error',
'request_id' => $requestId,
'http_code' => $status,
'content_type' => $info['content_type'],
'body_bytes' => strlen($body),
'total_time' => $info['total_time'],
)));
}
return array(
'body' => $body,
'content_type' => $info['content_type'],
'http_code' => $status,
'total_time' => $info['total_time'],
);
}
`),
paragraph('Функция намеренно не пишет URL целиком. Query-параметры часто содержат ключи, подписи или персональные идентификаторы. Если адрес нужен для расследования, лучше сохранить имя интеграции и заранее нормализованный путь. Тело ответа тоже не стоит бездумно добавлять к исключению: для первичного поиска достаточно размера, типа содержимого и ID операции; безопасный фрагмент можно сохранить отдельно в тестовой среде.'),
heading('Почему 2xx — ещё не результат операции'),
paragraph('HTTP-код описывает ответ сервера на протокольном уровне. Он не может за нас подтвердить, что партнёр принял заказ в нужном виде. Один API возвращает <code>{&quot;id&quot;:&quot;A-17&quot;}</code>, другой — <code>{&quot;accepted&quot;:true}</code>, третий ставит задачу в очередь. Поэтому после 2xx должна быть проверка конкретного поля, которое выбрано в контракте. Разбор JSON и формы ответа — отдельная граница; её нельзя заменять условием <code>if ($body)</code>.'),
paragraph('Это же объясняет, почему автоматический повтор записи нельзя включать как реакцию на любой сбой. При таймауте неизвестно, дошёл ли запрос до партнёра. Если операция создаёт заказ, второй POST может создать дубликат. Повтор становится безопасным только когда протокол даёт ключ идемпотентности, внешний ID или отдельный способ узнать результат первой попытки. Пока такого условия нет, в журнале должна появиться операция для разбора, а не второй запрос в фоне.'),
heading('Воспроизводимая матрица проверки'),
paragraph('Для проверки не нужен настоящий партнёр. Достаточно небольшого тестового endpoint, который по параметру возвращает 200 с JSON, 401, 500 и закрывает соединение. Важно сравнивать не только текст исключения, но и поля записи: у каждого сценария должен быть свой <code>kind</code>. Тогда мониторинг может отдельно считать транспортные сбои и ответы 5xx.'),
orderedList([
'Включить <code>CURLOPT_RETURNTRANSFER</code> и заменить все проверки <code>if (!$body)</code> на строгое <code>$body === false</code>.',
'Сразу после <code>curl_exec()</code> собрать <code>curl_errno</code>, <code>curl_error</code> и <code>curl_getinfo</code>, пока handle не закрыт.',
'Прогнать endpoint с недоступным адресом и проверить ветку <code>transport_error</code> с ненулевым кодом cURL.',
'Прогнать 401, 404 и 500; у них должна сработать ветка <code>http_error</code>, а не транспортная ошибка.',
'Прогнать 200 с корректным, но неожиданным телом и убедиться, что следующий слой контракта его не принимает автоматически.',
]),
heading('Границы примера'),
paragraph('Пример не задаёт универсальные таймауты и не обещает повтор запросов. Он не проверяет сертификаты вручную и не отключает TLS-проверку: если есть ошибка сертификата, её следует увидеть как транспортную причину и исправить настройку окружения. Он также не заменяет лимиты на размер ответа и контроль метода HTTP — эти правила зависят от конкретного API.'),
paragraph('Но даже такая небольшая развилка меняет качество диагностики. Вместо одного сообщения «интеграция не работает» появляются три проверяемых факта: передача не состоялась, удалённый сервер ответил не тем статусом или статус нормальный, но тело не прошло контракт. Дальше можно обсуждать решение с человеком, который отвечает именно за этот слой.'),
heading('Проверяемые источники'),
sourceList([phpCurlExec, phpCurlInfo, phpCurlErrno, httpSemantics]),
].join('\n'),
};
const fieldArticle = {
slug: 'editorial-2018-02-field-php-diagnostics',
title: 'PHP. Как отличить битый JSON от корректного null в ответе API',
categories: ['PHP', 'JSON', 'Интеграции'],
cover: '/assets/editorial/2018/json-payload-diagnostic.svg',
excerpt: 'Проверка if (!$data) смешивает пустой массив, false, null и ошибку декодирования. Собираем короткий разбор JSON для PHP 7.1 с проверкой json_last_error и контракта ответа.',
readingMinutes: 9,
contentHtml: [
paragraph('В обработчике ответа часто встречается одна строка: <code>if (!$data) { throw new Exception(&quot;bad response&quot;); }</code>. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное <code>null</code>, число <code>0</code> или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет <code>JSON_THROW_ON_ERROR</code>, поэтому после <code>json_decode()</code> нужно явно проверить состояние декодера.'),
paragraph('Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем <code>json_last_error()</code>. Только если там <code>JSON_ERROR_NONE</code>, проверяем тип и обязательные поля ответа. Цена ошибки — показать пользователю пустой результат там, где партнёр вернул повреждённый или чужой формат.'),
heading('Почему null не доказывает ошибку'),
paragraph('По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, <code>false</code>, <code>true</code> и <code>null</code>. PHP отражает это напрямую: <code>json_decode(&quot;null&quot;)</code> возвращает <code>null</code>, но <code>null</code> возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.'),
paragraph('То же происходит с пустыми коллекциями. После <code>json_decode(&quot;[]&quot;, true)</code> получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же <code>if (!$data)</code> назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.'),
figure('/assets/editorial/2018/json-payload-diagnostic.svg', 'Схема диагностики JSON: сырой ответ сначала проходит json_decode и json_last_error, затем проверку типа и обязательных полей контракта', 'Ошибка декодирования и нарушение контракта — разные события. У них разные владельцы и разные действия.'),
heading('Короткая таблица, которую стоит держать рядом с кодом'),
dataTable(
['Сырой ответ', 'Результат json_decode(..., true)', 'json_last_error', 'Что это значит для клиента'],
[
['<code>{&quot;order_id&quot;:&quot;A-17&quot;}</code>', 'ассоциативный массив', '<code>JSON_ERROR_NONE</code>', 'Проверить поле <code>order_id</code> и принять ответ'],
['<code>[]</code>', 'пустой массив', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON; допустимость зависит от операции'],
['<code>null</code>', '<code>null</code>', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON, но не тот тип, который ждёт данный endpoint'],
['<code>false</code> или <code>0</code>', '<code>false</code> или <code>0</code>', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON; проверка на «ложь» здесь ошибочна'],
['<code>&lt;html&gt;503&lt;/html&gt;</code>', 'обычно <code>null</code>', '<code>JSON_ERROR_SYNTAX</code>', 'Неверный формат ответа; сохранить безопасный диагностический контекст'],
],
),
heading('Пример для PHP 7.1'),
paragraph('Функция ниже принимает уже полученное тело только после проверки HTTP-статуса. Она не пытается угадать, где возникли данные: транспорт и HTTP должны быть разобраны раньше. Здесь контракт намеренно маленький: мы ждём объект с непустым строковым <code>order_id</code>. В другом API это может быть список, поле <code>accepted</code> или код задачи — меняется проверка контракта, но не порядок диагностики.'),
codeBlock(String.raw`
<?php
function logPayloadProblem(array $record)
{
error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
}
function rejectPayloadContract($reason, $requestId, $body)
{
logPayloadProblem(array(
'kind' => 'contract_error',
'reason' => $reason,
'request_id' => $requestId,
'body_bytes' => strlen($body),
'body_sha256' => hash('sha256', $body),
));
throw new UnexpectedValueException($reason);
}
function decodeCreatedOrder($body, $requestId)
{
if ($body === '') {
rejectPayloadContract('Partner returned an empty body', $requestId, $body);
}
$data = json_decode($body, true);
$jsonError = json_last_error();
if ($jsonError !== JSON_ERROR_NONE) {
logPayloadProblem(array(
'kind' => 'json_decode_error',
'request_id' => $requestId,
'json_error' => $jsonError,
'body_bytes' => strlen($body),
'body_sha256' => hash('sha256', $body),
));
throw new UnexpectedValueException('Partner response is not valid JSON');
}
if (!is_array($data)) {
rejectPayloadContract(
'Partner returned valid JSON, but not an object',
$requestId,
$body
);
}
if (
!array_key_exists('order_id', $data)
|| !is_string($data['order_id'])
|| $data['order_id'] === ''
) {
rejectPayloadContract(
'Partner JSON has no non-empty order_id',
$requestId,
$body
);
}
return $data;
}
`),
paragraph('Значение <code>json_last_error()</code> читается сразу после <code>json_decode()</code>. Это состояние относится к последней операции JSON, поэтому его легко затереть следующим <code>json_encode()</code> или повторным разбором. В примере в журнал попадают код ошибки, размер тела и хеш. Хеш позволяет сравнить два ответа, не печатая сам ответ в общий журнал. Если отладка требует фрагмент тела, её лучше проводить в ограниченной тестовой среде с маскированием данных.'),
heading('Не путать формат с договором'),
paragraph('Предположим, партнёр ответил <code>[]</code>. С точки зрения JSON всё в порядке. Для запроса «найди заказы за час» это может быть нормальный нулевой результат. Для запроса «создай заказ» пустой массив не годится, потому что договор ожидает идентификатор. Это уже не ошибка декодера и не повод говорить, что «API вернул битый JSON». Это нарушение контракта полезной нагрузки.'),
paragraph('Такая формулировка помогает и при разговоре с партнёром. Вместо расплывчатого «не распарсили ответ» можно передать факт: HTTP-статус был 200, JSON синтаксически корректен, но поле <code>order_id</code> отсутствует или имеет другой тип. Это сообщение можно проверить на их стороне и закрепить в документации API.'),
heading('Проверка на четырёх маленьких ответах'),
paragraph('Тест не обязан ходить в сеть. Достаточно передать функции строки и сравнить исключение или результат. Важно держать рядом успешный пустой сценарий только для той операции, где пустота допустима: иначе тест сам начнёт размывать договор.'),
orderedList([
'Передать <code>{&quot;order_id&quot;:&quot;A-17&quot;}</code> и проверить, что функция вернула массив с идентификатором.',
'Передать <code>&lt;html&gt;maintenance&lt;/html&gt;</code>; ожидается ветка <code>json_decode_error</code> с кодом <code>JSON_ERROR_SYNTAX</code>.',
'Передать <code>null</code>; <code>json_last_error()</code> должен показать успех разбора, а функция должна отклонить неподходящий тип.',
'Передать <code>[]</code>; разбор успешен, но контракт создания заказа должен отклонить отсутствие <code>order_id</code>.',
'Отдельно проверить поиск или список, где <code>[]</code> является валидным результатом, чтобы не переносить правила одной операции на другую.',
]),
heading('Версия PHP и ограничения'),
paragraph('В PHP 7.3 появился флаг <code>JSON_THROW_ON_ERROR</code>. В этом примере я намеренно остаюсь на PHP 7.1, поэтому проверка <code>json_last_error()</code> — нормальный механизм для выбранной версии, а не обходной путь. Если проект уже обновлён, исключения могут сделать код компактнее, но проверка формы ответа всё равно остаётся.'),
paragraph('Декодер ожидает строку в UTF-8. Ошибка <code>JSON_ERROR_UTF8</code> говорит о проблеме кодировки, но не объясняет, на каком именно участке она появилась. Для такого случая нужны метрики и безопасный способ сравнить исходные ответы, а не принудительное перекодирование всей строки без понимания источника. RFC 8259 рекомендует уникальные имена в объекте; при повторе разные реализации могут вести себя по-разному. Если поле критично, договор API должен фиксировать его единственность.'),
heading('Что оставить после исправления'),
paragraph('После этой доработки в клиенте остаются два разных события: <code>json_decode_error</code> для невалидного формата и <code>contract_error</code> для валидного, но неожиданного объекта. У них разные причины, разная срочность и разные адресаты. А условие <code>if (!$data)</code> исчезает: оно не способно сказать, что именно произошло.'),
heading('Проверяемые источники'),
sourceList([phpJsonDecode, phpJsonLastError, jsonRfc]),
].join('\n'),
};
const revisions = [practiceArticle, mechanismArticle, fieldArticle];
export { revisions };
function plainText(content) {
return content
.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function assertRevisionQuality(revision) {
const body = plainText(revision.contentHtml);
const issues = [];
if (body.length < 5000 || body.length > 15000) {
issues.push('основной текст: ' + body.length + ' знаков');
}
if ((revision.contentHtml.match(/<figure>/g) || []).length !== 1) {
issues.push('нужен ровно один главный рисунок');
}
if (!revision.contentHtml.includes('<table>')) issues.push('нет таблицы');
if (!revision.contentHtml.includes('<pre><code>')) issues.push('нет примера кода');
if (!revision.contentHtml.includes('<ol>')) issues.push('нет последовательности действий');
if (!revision.contentHtml.includes('<h2>Проверяемые источники</h2>')) {
issues.push('нет раздела с источниками');
}
if ((revision.contentHtml.match(/<a href="https?:\/\//g) || []).length < 2) {
issues.push('меньше двух источников');
}
if (revision.contentHtml.includes('undefined') || revision.contentHtml.includes('[object Object]')) {
issues.push('в тексте есть след генерации');
}
if (issues.length > 0) {
throw new Error(revision.slug + ': ' + issues.join('; '));
}
}
for (const revision of revisions) {
assertRevisionQuality(revision);
}
if (!process.argv.includes('--print-revisions') && process.argv[1]?.endsWith('upgrade-2018-02.mjs')) {
throw new Error('Usage: node scripts/upgrade-2018-02.mjs --print-revisions');
}
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
}