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 без обязательного поля. Цена такой ошибки — неверное решение на границе интеграции. Можно повторить операцию, хотя она уже прошла, или скрыть реальный сбой под пустым экраном.
Частая причина — условие if (!$data) сразу после json_decode(). В PHP это условие смешивает несколько разных значений. Пустой массив ложен. false и 0 тоже ложны. Корректный JSON null превращается в null. Невалидная строка часто даёт тот же null. Одного значения переменной недостаточно для диагноза.
Надёжный порядок состоит из трёх границ. Сначала проверяется транспорт и HTTP-статус. Затем читается ошибка декодирования сразу после json_decode(). Только после успешного разбора проверяется форма ответа и обязательные поля контракта. Этот порядок не угадывает причину по пустому значению. Он сохраняет причинность.
json_decode() переводит JSON-строку в значение PHP. При флаге true объект становится ассоциативным массивом. Литералы true, false и null сохраняют свои типы. Если строка не разбирается, функция также может вернуть null. Поэтому результат декодера нужно читать вместе с состоянием JSON-расширения.
В PHP 7.1 нет флага JSON_THROW_ON_ERROR. Для этой версии проверка json_last_error() — основной способ отличить ошибку разбора от корректного значения. В новых версиях можно включить исключение декодера, но проверка контракта ответа всё равно остаётся отдельной задачей.
Например, строка null — допустимый JSON. Но если endpoint создания заказа обязан вернуть объект с order_id, такой ответ не подходит операции. Это не синтаксическая ошибка. Это корректный формат с неверной формой данных. То же относится к []: для поиска это может означать «ничего не найдено», а для создания заказа — отсутствие результата.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
HTTP 200, результат null | Корректный JSON null или ошибка разбора | Сразу прочитать json_last_error() | Разделить ветки формата и контракта |
| HTTP 200, пустой массив | Валидный список без элементов | Проверить JSON_ERROR_NONE и смысл endpoint | Принять только там, где пустой список разрешён |
| HTTP 503, тело HTML | Удалённый сервер или прокси вернул страницу ошибки | Сохранить статус, размер и безопасный отпечаток тела | Обработать транспортную ошибку; JSON не разбирать как успешный ответ |
HTTP 200, объект без order_id | JSON синтаксически корректен, но договор изменился или нарушен | Проверить тип, наличие и непустое значение поля | Отклонить ответ как ошибку контракта |
| Разные ответы дают один лог | Ошибки сведены к bad response | Добавить категорию и request id | Логировать отдельные события с безопасным контекстом |
Ниже приведён учебный пример для PHP 7.1. Он не делает HTTP-запрос и не обещает поведение конкретного API. Предполагается, что код выше уже проверил статус ответа. Для операции создания заказа договор намеренно мал: успешное тело должно быть JSON-объектом с непустым строковым order_id.
<?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 от прокси, поэтому его нельзя бездумно отправлять в общий журнал.
Проверка is_array() в этом примере не означает, что любой массив годится. При json_decode($body, true) и объект, и JSON-массив становятся PHP-массивом. Для данного договора после неё нужны проверка поля и его типа. Если endpoint должен возвращать список, проверяется список. Если он разрешает null, это правило фиксируется в контракте операции, а не выводится из поведения PHP.
Положительный пример редко показывает проблему. Нужны ответы, которые похожи по результату, но требуют разных действий. HTML от reverse proxy нельзя считать JSON. null нельзя считать ошибкой только потому, что оно ложно в PHP. Пустой массив нельзя объявлять сбоем без знания смысла запроса. Объект с чужим набором полей нельзя принимать только потому, что декодер не сообщил об ошибке.
{\"order_id\":\"A-17\"}. Ожидается JSON_ERROR_NONE и массив с непустым идентификатором.<html>maintenance</html>. Ожидается JSON_ERROR_SYNTAX и событие json_decode_error.null. Разбор должен завершиться без ошибки, после чего проверка формы должна отклонить ответ для операции создания заказа.[]. Разбор должен завершиться без ошибки, но отсутствие order_id должно дать contract_error.[] является успешным результатом. Нельзя переносить правило создания заказа на другой endpoint.Эта схема не проверяет, что удалённый сервис действительно вернул ожидаемый бизнес-результат. Синтаксически правильный JSON может содержать устаревший идентификатор, неверное состояние заказа или лишнее поле. Для этого нужны правила предметного контракта и проверка HTTP-семантики.
\nСхема не защищает от повторной отправки операции. Если клиент не знает, был ли заказ создан до обрыва ответа, решение о повторе требует идемпотентности или отдельного запроса состояния. Нельзя превращать любую ошибку декодирования в автоматический retry. HTML от прокси и повреждённый ответ партнёра могут повторяться.
\nВ PHP 7.1 доступен код ошибки, но не современное исключение JSON_THROW_ON_ERROR. При обновлении PHP можно сократить ветку декодирования. Нельзя сокращать проверку обязательных полей. Флаг true у json_decode() также влияет на то, как код различает объект и список; это решение нужно закрепить в локальном контракте.
Изменение готово, если для пяти входов из порядка действий система выдаёт разные проверяемые результаты: транспортная ошибка, ошибка JSON, корректный null не того типа, пустой допустимый список и успешный объект с идентификатором. В журнале есть request id и категория, но нет полного чувствительного тела. В коде отсутствует проверка if (!$data) как единственный диагноз. Каждый endpoint явно описывает, допустимы ли null, пустой список и дополнительные поля.
JSON_THROW_ON_ERROR.Клиент вызывает API, получает HTTP 200 и показывает пустой результат. В журнале остаётся одна запись: bad response. Пользователь не понимает, создался ли заказ, а разработчик не знает, что вернул партнёр: пустой список, null, HTML страницы ошибки или JSON без обязательного поля. Из-за этого можно повторить уже выполненную операцию или скрыть настоящий сбой под пустым экраном.
Обычно проблема начинается с условия if (!$data) сразу после json_decode(). В PHP оно смешивает пустой массив, false, 0, корректный null и результат неудачного разбора. Само ложное значение не сообщает, на каком шаге исчезла полезная информация.
Разберём один сценарий по порядку. Сначала проверим HTTP-статус и наличие тела, затем сразу снимем код ошибки декодера, а после успешного разбора сверим форму ответа с договором операции. Такой маршрут оставляет наблюдаемую причину вместо догадки по содержимому переменной.
\nПредставим запрос создания заказа. Партнёр возвращает статус 200, но тело оказывается строкой null. В другом запуске прокси отдаёт страницу обслуживания с тем же успешным для клиентского кода присваиванием $data = null. В третьем запуске приходит []. Три тела требуют разных объяснений, хотя наивная проверка отправляет их в одну ветку.
HTTP-статус отвечает только за транспортный результат запроса. Он не доказывает, что тело имеет нужный синтаксис JSON и тем более содержит обязательные поля. Статус 503 следует обработать как транспортную ошибку до разбора тела. Для статуса 200 всё равно остаются две проверки: формат и договор полезной нагрузки.
\nnull не доказывает ошибкуJSON-текстом может быть не только объект или массив. Стандарт также допускает строку, число и литералы true, false и null. Поэтому строка null — корректный JSON. После json_decode('null') PHP возвращает значение null, но при невалидном входе функция тоже может вернуть null.
Различить эти случаи помогает json_last_error(). Если сразу после декодирования получен JSON_ERROR_NONE, синтаксис принят. Это ещё не означает, что ответ годится данному endpoint: корректный null может нарушать договор создания заказа.
То же относится к ложным значениям. После json_decode('[]', true) получится пустой PHP-массив, а json_decode('false', true) вернёт false. Для поиска отсутствие элементов может быть успешным результатом. Для создания заказа нужен объект с идентификатором, поэтому те же значения должны быть отклонены уже на проверке формы, а не названы «битым JSON».
| Сырое тело | Результат json_decode(..., true) | json_last_error() | Решение для создания заказа |
|---|---|---|---|
{"order_id":"A-17"} | ассоциативный массив | JSON_ERROR_NONE | Проверить строковое поле и принять |
null | null | JSON_ERROR_NONE | JSON корректен, но форма не подходит |
[] | пустой массив | JSON_ERROR_NONE | Отклонить: нет order_id |
false или 0 | false или 0 | JSON_ERROR_NONE | Отклонить: это не объект с идентификатором |
<html>maintenance</html> | обычно null | JSON_ERROR_SYNTAX | Зафиксировать ошибку разбора, не считать успехом |
| пустая строка | не декодировать в этом обработчике | не применяется | Отклонить пустое тело до разбора |
Таблица показывает, почему сравнение только с null не работает. Сначала нужно сохранить статус и тело настолько, насколько это разрешает политика журнала. Затем результат декодера читается вместе с кодом ошибки. Только третьим шагом имеет смысл спрашивать, что именно разрешено бизнес-операции.
Функция ниже принимает уже полученное тело. Проверка HTTP-статуса находится снаружи: она относится к транспортному клиенту, а не к декодированию. Контракт этого примера намеренно мал — успешным считается JSON-объект с непустым строковым order_id. В другом API поле может называться иначе, но порядок проверок остаётся тем же.
<?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-страницу; его нельзя бездумно писать в общий лог.
Вызов с телом {"order_id":"A-17"} возвращает массив. Вызов с null проходит проверку синтаксиса, затем попадает в valid JSON is not an object. Вызов с [] проходит синтаксис и доходит до отсутствующего order_id. Вызов с HTML не проходит JSON_ERROR_NONE. Каждая ветка описывает наблюдаемый результат, а не предположение о том, что произошло у партнёра.
При втором аргументе true объекты JSON становятся ассоциативными массивами PHP. JSON-массивы тоже становятся PHP-массивами, поэтому одного is_array($data) недостаточно, чтобы доказать, что пришёл объект. В примере это ограничение закрывает проверка ключа order_id: список и пустой массив такого ключа не имеют.
Проверка поля должна соответствовать конкретной операции. Для создания заказа нужно зафиксировать имя поля, его тип и правило пустого значения. Для поиска можно разрешить []. Для ответа с null нужно отдельно решить, означает ли он «ничего не найдено» или нарушение договора. Нельзя переносить правило одного endpoint на другой только потому, что PHP одинаково приводит их результаты.
Полезно также заранее решить, разрешены ли дополнительные поля. В этом примере они не запрещены: клиент использует только order_id. Если договор требует точного набора полей, добавьте явную проверку ключей и зафиксируйте её в тесте. Молчаливое принятие лишних данных и строгое отклонение — разные политики, обе должны быть намеренными.
Тест не обязан ходить в сеть. Для этой ошибки достаточно передать функции фиксированные строки и проверить исключение, категорию события или возвращённый массив. Важно проверять не только положительный ответ: похожие на вид входы должны расходиться по наблюдаемым веткам.
\n{"order_id":"A-17"}. Ожидаются JSON_ERROR_NONE, строковый идентификатор и возвращённый массив.<html>maintenance</html>. Ожидаются JSON_ERROR_SYNTAX и событие json_decode_error.null. Ожидается JSON_ERROR_NONE, после чего форма должна отклонить ответ для создания заказа.[]. Ожидается JSON_ERROR_NONE, но отсутствие order_id должно дать contract_error.false и 0. Ожидается успешный разбор, но отказ на проверке типа и формы.[] разрешён договором. Этот тест подтверждает, что правила ответа принадлежат операции, а не общей функции декодирования.Ошибка декодирования не сообщает, выполнил ли партнёр операцию. Клиент мог получить HTML после создания заказа или потерять соединение сразу после записи. Поэтому автоматический повтор создания опасен без идемпотентного ключа или отдельного запроса состояния.
\nЭта статья не проектирует механизм повторов. Её граница уже: разделить транспорт, синтаксис и форму ответа, сохранить безопасный диагностический контекст и передать решение о повторе владельцу контракта. Если такой механизм нужен, его следует проверять отдельными тестами на повторный запрос и поздний ответ, а не прятать в decodeCreatedOrder().
В PHP 7.1 нет флага JSON_THROW_ON_ERROR; в документации PHP он появился в версии 7.3. Для выбранной версии проверка json_last_error() после json_decode() — прямой способ различить ошибку разбора и корректное значение. При обновлении проекта исключение может сделать ветку компактнее, но проверка обязательных полей никуда не исчезает.
Декодер работает со строками в UTF-8. Код JSON_ERROR_UTF8 показывает проблему кодировки, но не доказывает, какая система её внесла. Не стоит лечить такой ответ без проверки источника: сначала сохраните безопасные признаки тела и выясните, где нарушилась кодировка.
RFC 8259 рекомендует уникальные имена полей в JSON-объекте. Если партнёр прислал повторяющийся order_id, разные реализации могут обработать его по-разному. Критическое поле лучше закрепить в договоре и, если это важно для интеграции, добавить отрицательный тест на повтор имени.
Проверка готова, если все входы из порядка действий дают разные проверяемые результаты: транспортная ошибка, ошибка JSON, корректный null не того типа, пустой допустимый список, ложные значения и успешный объект с идентификатором. В журнале есть категория и request id, но нет полного чувствительного тела. Код не использует if (!$data) как единственный диагноз. Для каждого endpoint отдельно записано, разрешены ли null, пустой список и дополнительные поля.
JSON_THROW_ON_ERROR в PHP 7.3.JSON_ERROR_NONE.