{ "index": 355, "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.