From 6d1ca5d79f569e0cfddf9df136f110ddc16864ed Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 01:16:57 +0300 Subject: [PATCH] editorial: refine PHP JSON diagnostics article 355 --- editorial/agent-rewrites/355.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/355.json b/editorial/agent-rewrites/355.json index afc8f75..e9fe425 100644 --- a/editorial/agent-rewrites/355.json +++ b/editorial/agent-rewrites/355.json @@ -3,5 +3,5 @@ "slug": "editorial-2018-02-field-php-diagnostics", "title": "PHP: почему null не доказывает ошибку JSON", "excerpt": "Пустой массив, null и false могут быть корректным JSON, а ошибка декодирования тоже часто возвращает null. Разбираем ответ API по слоям: транспорт, синтаксис и контракт данных.", - "contentHtml": "

Клиент вызывает API, получает HTTP 200 и показывает пустой результат. В журнале остаётся одна запись: bad response. Пользователь не понимает, создался ли заказ. Разработчик не знает, что вернул партнёр: пустой список, null, HTML страницы ошибки или JSON без обязательного поля. Цена такой ошибки — неверное решение на границе интеграции. Можно повторить операцию, хотя она уже прошла, или скрыть реальный сбой под пустым экраном.

\n

Частая причина — условие if (!$data) сразу после json_decode(). В PHP это условие смешивает несколько разных значений. Пустой массив ложен. false и 0 тоже ложны. Корректный JSON null превращается в null. Невалидная строка часто даёт тот же null. Одного значения переменной недостаточно для диагноза.

\n

Надёжный порядок состоит из трёх границ. Сначала проверяется транспорт и HTTP-статус. Затем читается ошибка декодирования сразу после json_decode(). Только после успешного разбора проверяется форма ответа и обязательные поля контракта. Этот порядок не угадывает причину по пустому значению. Он сохраняет причинность.

\n

Что именно различает PHP

\n

json_decode() переводит JSON-строку в значение PHP. При флаге true объект становится ассоциативным массивом. Литералы true, false и null сохраняют свои типы. Если строка не разбирается, функция также может вернуть null. Поэтому результат декодера нужно читать вместе с состоянием JSON-расширения.

\n

В PHP 7.1 нет флага JSON_THROW_ON_ERROR. Для этой версии проверка json_last_error() — основной способ отличить ошибку разбора от корректного значения. В новых версиях можно включить исключение декодера, но проверка контракта ответа всё равно остаётся отдельной задачей.

\n

Например, строка null — допустимый JSON. Но если endpoint создания заказа обязан вернуть объект с order_id, такой ответ не подходит операции. Это не синтаксическая ошибка. Это корректный формат с неверной формой данных. То же относится к []: для поиска это может означать «ничего не найдено», а для создания заказа — отсутствие результата.

\n
\"Поток
Один ответ проходит последовательные проверки. Ошибка транспорта, ошибка JSON и нарушение контракта не должны получать одно сообщение.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
HTTP 200, результат nullКорректный JSON null или ошибка разбораСразу прочитать json_last_error()Разделить ветки формата и контракта
HTTP 200, пустой массивВалидный список без элементовПроверить JSON_ERROR_NONE и смысл endpointПринять только там, где пустой список разрешён
HTTP 503, тело HTMLУдалённый сервер или прокси вернул страницу ошибкиСохранить статус, размер и безопасный отпечаток телаОбработать транспортную ошибку; JSON не разбирать как успешный ответ
HTTP 200, объект без order_idJSON синтаксически корректен, но договор изменился или нарушенПроверить тип, наличие и непустое значение поляОтклонить ответ как ошибку контракта
Разные ответы дают один логОшибки сведены к bad responseДобавить категорию и request idЛогировать отдельные события с безопасным контекстом
\n

Учебный обработчик

\n

Ниже приведён учебный пример для PHP 7.1. Он не делает HTTP-запрос и не обещает поведение конкретного API. Предполагается, что код выше уже проверил статус ответа. Для операции создания заказа договор намеренно мал: успешное тело должно быть JSON-объектом с непустым строковым order_id.

\n
<?php\n\nfunction logPayloadEvent($kind, array $context)\n{\n    $context['kind'] = $kind;\n    error_log(json_encode($context, JSON_UNESCAPED_UNICODE));\n}\n\nfunction rejectPayload($reason, $requestId, $body)\n{\n    logPayloadEvent('contract_error', array(\n        'reason' => $reason,\n        'request_id' => $requestId,\n        'body_bytes' => strlen($body),\n        'body_sha256' => hash('sha256', $body),\n    ));\n\n    throw new UnexpectedValueException($reason);\n}\n\nfunction decodeCreatedOrder($body, $requestId)\n{\n    if ($body === '') {\n        rejectPayload('empty response body', $requestId, $body);\n    }\n\n    $data = json_decode($body, true);\n    $jsonError = json_last_error();\n\n    if ($jsonError !== JSON_ERROR_NONE) {\n        logPayloadEvent('json_decode_error', array(\n            'request_id' => $requestId,\n            'json_error' => $jsonError,\n            'body_bytes' => strlen($body),\n            'body_sha256' => hash('sha256', $body),\n        ));\n\n        throw new UnexpectedValueException('response is not valid JSON');\n    }\n\n    if (!is_array($data)) {\n        rejectPayload('valid JSON is not an object', $requestId, $body);\n    }\n\n    if (!array_key_exists('order_id', $data)\n        || !is_string($data['order_id'])\n        || $data['order_id'] === ''\n    ) {\n        rejectPayload('order_id is missing or has the wrong type', $requestId, $body);\n    }\n\n    return $data;\n}
\n

Снимок $jsonError появляется сразу после декодирования. Это важно: следующая операция JSON может изменить состояние последней ошибки. В журнал попадают категория, код ошибки, идентификатор запроса, размер тела и SHA-256. Хеш помогает сопоставить одинаковые ответы без записи всего тела. Само тело может содержать токены, персональные данные или HTML от прокси, поэтому его нельзя бездумно отправлять в общий журнал.

\n

Проверка is_array() в этом примере не означает, что любой массив годится. При json_decode($body, true) и объект, и JSON-массив становятся PHP-массивом. Для данного договора после неё нужны проверка поля и его типа. Если endpoint должен возвращать список, проверяется список. Если он разрешает null, это правило фиксируется в контракте операции, а не выводится из поведения PHP.

\n

Отрицательный путь нельзя пропускать

\n

Положительный пример редко показывает проблему. Нужны ответы, которые похожи по результату, но требуют разных действий. HTML от reverse proxy нельзя считать JSON. null нельзя считать ошибкой только потому, что оно ложно в PHP. Пустой массив нельзя объявлять сбоем без знания смысла запроса. Объект с чужим набором полей нельзя принимать только потому, что декодер не сообщил об ошибке.

\n
  1. Проверить HTTP-статус до разбора тела. Для статуса 503 сохранить транспортную категорию и не выдавать ответ за успешный JSON.
  2. Передать в декодер {\"order_id\":\"A-17\"}. Ожидается JSON_ERROR_NONE и массив с непустым идентификатором.
  3. Передать <html>maintenance</html>. Ожидается JSON_ERROR_SYNTAX и событие json_decode_error.
  4. Передать строку null. Разбор должен завершиться без ошибки, после чего проверка формы должна отклонить ответ для операции создания заказа.
  5. Передать []. Разбор должен завершиться без ошибки, но отсутствие order_id должно дать contract_error.
  6. Отдельно описать операцию поиска, где [] является успешным результатом. Нельзя переносить правило создания заказа на другой endpoint.
\n

Ограничения

\n

Эта схема не проверяет, что удалённый сервис действительно вернул ожидаемый бизнес-результат. Синтаксически правильный JSON может содержать устаревший идентификатор, неверное состояние заказа или лишнее поле. Для этого нужны правила предметного контракта и проверка HTTP-семантики.

\n

Схема не защищает от повторной отправки операции. Если клиент не знает, был ли заказ создан до обрыва ответа, решение о повторе требует идемпотентности или отдельного запроса состояния. Нельзя превращать любую ошибку декодирования в автоматический retry. HTML от прокси и повреждённый ответ партнёра могут повторяться.

\n

В PHP 7.1 доступен код ошибки, но не современное исключение JSON_THROW_ON_ERROR. При обновлении PHP можно сократить ветку декодирования. Нельзя сокращать проверку обязательных полей. Флаг true у json_decode() также влияет на то, как код различает объект и список; это решение нужно закрепить в локальном контракте.

\n

Критерий готовности

\n

Изменение готово, если для пяти входов из порядка действий система выдаёт разные проверяемые результаты: транспортная ошибка, ошибка JSON, корректный null не того типа, пустой допустимый список и успешный объект с идентификатором. В журнале есть request id и категория, но нет полного чувствительного тела. В коде отсутствует проверка if (!$data) как единственный диагноз. Каждый endpoint явно описывает, допустимы ли null, пустой список и дополнительные поля.

\n

Проверяемые источники

\n" + "contentHtml": "

Клиент вызывает API, получает HTTP 200 и показывает пустой результат. В журнале остаётся одна запись: bad response. Пользователь не понимает, создался ли заказ, а разработчик не знает, что вернул партнёр: пустой список, null, HTML страницы ошибки или JSON без обязательного поля. Из-за этого можно повторить уже выполненную операцию или скрыть настоящий сбой под пустым экраном.

\n

Обычно проблема начинается с условия if (!$data) сразу после json_decode(). В PHP оно смешивает пустой массив, false, 0, корректный null и результат неудачного разбора. Само ложное значение не сообщает, на каком шаге исчезла полезная информация.

\n

Разберём один сценарий по порядку. Сначала проверим HTTP-статус и наличие тела, затем сразу снимем код ошибки декодера, а после успешного разбора сверим форму ответа с договором операции. Такой маршрут оставляет наблюдаемую причину вместо догадки по содержимому переменной.

\n

Сценарий: ответ есть, результата нет

\n

Представим запрос создания заказа. Партнёр возвращает статус 200, но тело оказывается строкой null. В другом запуске прокси отдаёт страницу обслуживания с тем же успешным для клиентского кода присваиванием $data = null. В третьем запуске приходит []. Три тела требуют разных объяснений, хотя наивная проверка отправляет их в одну ветку.

\n

HTTP-статус отвечает только за транспортный результат запроса. Он не доказывает, что тело имеет нужный синтаксис JSON и тем более содержит обязательные поля. Статус 503 следует обработать как транспортную ошибку до разбора тела. Для статуса 200 всё равно остаются две проверки: формат и договор полезной нагрузки.

\n

Почему null не доказывает ошибку

\n

JSON-текстом может быть не только объект или массив. Стандарт также допускает строку, число и литералы true, false и null. Поэтому строка null — корректный JSON. После json_decode('null') PHP возвращает значение null, но при невалидном входе функция тоже может вернуть null.

\n

Различить эти случаи помогает json_last_error(). Если сразу после декодирования получен JSON_ERROR_NONE, синтаксис принят. Это ещё не означает, что ответ годится данному endpoint: корректный null может нарушать договор создания заказа.

\n

То же относится к ложным значениям. После json_decode('[]', true) получится пустой PHP-массив, а json_decode('false', true) вернёт false. Для поиска отсутствие элементов может быть успешным результатом. Для создания заказа нужен объект с идентификатором, поэтому те же значения должны быть отклонены уже на проверке формы, а не названы «битым JSON».

\n
\"Схема
Один ответ проходит последовательные границы. Транспортная ошибка, ошибка синтаксиса и нарушение договора получают разные причины и действия.
\n

Разбор шести ответов

\n
Сырое телоРезультат json_decode(..., true)json_last_error()Решение для создания заказа
{"order_id":"A-17"}ассоциативный массивJSON_ERROR_NONEПроверить строковое поле и принять
nullnullJSON_ERROR_NONEJSON корректен, но форма не подходит
[]пустой массивJSON_ERROR_NONEОтклонить: нет order_id
false или 0false или 0JSON_ERROR_NONEОтклонить: это не объект с идентификатором
<html>maintenance</html>обычно nullJSON_ERROR_SYNTAXЗафиксировать ошибку разбора, не считать успехом
пустая строкане декодировать в этом обработчикене применяетсяОтклонить пустое тело до разбора
\n

Таблица показывает, почему сравнение только с null не работает. Сначала нужно сохранить статус и тело настолько, насколько это разрешает политика журнала. Затем результат декодера читается вместе с кодом ошибки. Только третьим шагом имеет смысл спрашивать, что именно разрешено бизнес-операции.

\n

Учебный обработчик для PHP 7.1

\n

Функция ниже принимает уже полученное тело. Проверка HTTP-статуса находится снаружи: она относится к транспортному клиенту, а не к декодированию. Контракт этого примера намеренно мал — успешным считается JSON-объект с непустым строковым order_id. В другом API поле может называться иначе, но порядок проверок остаётся тем же.

\n
<?php\n\nfunction logPayloadEvent($kind, array $context)\n{\n    $context['kind'] = $kind;\n    error_log(json_encode($context, JSON_UNESCAPED_UNICODE));\n}\n\nfunction rejectPayload($reason, $requestId, $body)\n{\n    logPayloadEvent('contract_error', array(\n        'reason' => $reason,\n        'request_id' => $requestId,\n        'body_bytes' => strlen($body),\n        'body_sha256' => hash('sha256', $body),\n    ));\n\n    throw new UnexpectedValueException($reason);\n}\n\nfunction decodeCreatedOrder($body, $requestId)\n{\n    if ($body === '') {\n        rejectPayload('empty response body', $requestId, $body);\n    }\n\n    $data = json_decode($body, true);\n    $jsonError = json_last_error();\n\n    if ($jsonError !== JSON_ERROR_NONE) {\n        logPayloadEvent('json_decode_error', array(\n            'request_id' => $requestId,\n            'json_error' => $jsonError,\n            'body_bytes' => strlen($body),\n            'body_sha256' => hash('sha256', $body),\n        ));\n\n        throw new UnexpectedValueException('response is not valid JSON');\n    }\n\n    if (!is_array($data)) {\n        rejectPayload('valid JSON is not an object', $requestId, $body);\n    }\n\n    if (!array_key_exists('order_id', $data)\n        || !is_string($data['order_id'])\n        || $data['order_id'] === ''\n    ) {\n        rejectPayload('order_id is missing or has the wrong type', $requestId, $body);\n    }\n\n    return $data;\n}
\n

Снимок $jsonError делается сразу после вызова декодера. Состояние относится к последней операции JSON, поэтому повторный разбор или последующий json_encode() может затруднить диагностику. В журнале достаточно оставить категорию, код, идентификатор запроса, размер и SHA-256 тела. Полный ответ может содержать токены, персональные данные или внутреннюю HTML-страницу; его нельзя бездумно писать в общий лог.

\n

Вызов с телом {"order_id":"A-17"} возвращает массив. Вызов с null проходит проверку синтаксиса, затем попадает в valid JSON is not an object. Вызов с [] проходит синтаксис и доходит до отсутствующего order_id. Вызов с HTML не проходит JSON_ERROR_NONE. Каждая ветка описывает наблюдаемый результат, а не предположение о том, что произошло у партнёра.

\n

Форма ответа — отдельный договор

\n

При втором аргументе true объекты JSON становятся ассоциативными массивами PHP. JSON-массивы тоже становятся PHP-массивами, поэтому одного is_array($data) недостаточно, чтобы доказать, что пришёл объект. В примере это ограничение закрывает проверка ключа order_id: список и пустой массив такого ключа не имеют.

\n

Проверка поля должна соответствовать конкретной операции. Для создания заказа нужно зафиксировать имя поля, его тип и правило пустого значения. Для поиска можно разрешить []. Для ответа с null нужно отдельно решить, означает ли он «ничего не найдено» или нарушение договора. Нельзя переносить правило одного endpoint на другой только потому, что PHP одинаково приводит их результаты.

\n

Полезно также заранее решить, разрешены ли дополнительные поля. В этом примере они не запрещены: клиент использует только order_id. Если договор требует точного набора полей, добавьте явную проверку ключей и зафиксируйте её в тесте. Молчаливое принятие лишних данных и строгое отклонение — разные политики, обе должны быть намеренными.

\n

Проверка сценария по шагам

\n

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

\n
  1. До декодирования передать ответ со статусом 503 и телом HTML. Ожидается транспортная ошибка; тело не выдаётся за успешный JSON.
  2. Передать {"order_id":"A-17"}. Ожидаются JSON_ERROR_NONE, строковый идентификатор и возвращённый массив.
  3. Передать <html>maintenance</html>. Ожидаются JSON_ERROR_SYNTAX и событие json_decode_error.
  4. Передать строку null. Ожидается JSON_ERROR_NONE, после чего форма должна отклонить ответ для создания заказа.
  5. Передать []. Ожидается JSON_ERROR_NONE, но отсутствие order_id должно дать contract_error.
  6. Передать false и 0. Ожидается успешный разбор, но отказ на проверке типа и формы.
  7. Отдельно проверить поиск, где [] разрешён договором. Этот тест подтверждает, что правила ответа принадлежат операции, а не общей функции декодирования.
\n

Что делать с повтором запроса

\n

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

\n

Эта статья не проектирует механизм повторов. Её граница уже: разделить транспорт, синтаксис и форму ответа, сохранить безопасный диагностический контекст и передать решение о повторе владельцу контракта. Если такой механизм нужен, его следует проверять отдельными тестами на повторный запрос и поздний ответ, а не прятать в decodeCreatedOrder().

\n

Ограничения и версия PHP

\n

В PHP 7.1 нет флага JSON_THROW_ON_ERROR; в документации PHP он появился в версии 7.3. Для выбранной версии проверка json_last_error() после json_decode() — прямой способ различить ошибку разбора и корректное значение. При обновлении проекта исключение может сделать ветку компактнее, но проверка обязательных полей никуда не исчезает.

\n

Декодер работает со строками в UTF-8. Код JSON_ERROR_UTF8 показывает проблему кодировки, но не доказывает, какая система её внесла. Не стоит лечить такой ответ без проверки источника: сначала сохраните безопасные признаки тела и выясните, где нарушилась кодировка.

\n

RFC 8259 рекомендует уникальные имена полей в JSON-объекте. Если партнёр прислал повторяющийся order_id, разные реализации могут обработать его по-разному. Критическое поле лучше закрепить в договоре и, если это важно для интеграции, добавить отрицательный тест на повтор имени.

\n

Критерий готовности

\n

Проверка готова, если все входы из порядка действий дают разные проверяемые результаты: транспортная ошибка, ошибка JSON, корректный null не того типа, пустой допустимый список, ложные значения и успешный объект с идентификатором. В журнале есть категория и request id, но нет полного чувствительного тела. Код не использует if (!$data) как единственный диагноз. Для каждого endpoint отдельно записано, разрешены ли null, пустой список и дополнительные поля.

\n

Проверяемые источники

\n" }