8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 355,
|
||
"slug": "editorial-2018-02-field-php-diagnostics",
|
||
"title": "PHP: почему null не доказывает ошибку JSON",
|
||
"excerpt": "Пустой массив, null и false могут быть корректным JSON, а ошибка декодирования тоже часто возвращает null. Разбираем ответ API по слоям: транспорт, синтаксис и контракт данных.",
|
||
"contentHtml": "<p>Клиент вызывает API, получает HTTP 200 и показывает пустой результат. В журнале остаётся одна запись: <code>bad response</code>. Пользователь не понимает, создался ли заказ, а разработчик не знает, что вернул партнёр: пустой список, <code>null</code>, HTML страницы ошибки или JSON без обязательного поля. Из-за этого можно повторить уже выполненную операцию или скрыть настоящий сбой под пустым экраном.</p>\n<p>Обычно проблема начинается с условия <code>if (!$data)</code> сразу после <code>json_decode()</code>. В PHP оно смешивает пустой массив, <code>false</code>, <code>0</code>, корректный <code>null</code> и результат неудачного разбора. Само ложное значение не сообщает, на каком шаге исчезла полезная информация.</p>\n<p>Разберём один сценарий по порядку. Сначала проверим HTTP-статус и наличие тела, затем сразу снимем код ошибки декодера, а после успешного разбора сверим форму ответа с договором операции. Такой маршрут оставляет наблюдаемую причину вместо догадки по содержимому переменной.</p>\n<h2>Сценарий: ответ есть, результата нет</h2>\n<p>Представим запрос создания заказа. Партнёр возвращает статус 200, но тело оказывается строкой <code>null</code>. В другом запуске прокси отдаёт страницу обслуживания с тем же успешным для клиентского кода присваиванием <code>$data = null</code>. В третьем запуске приходит <code>[]</code>. Три тела требуют разных объяснений, хотя наивная проверка отправляет их в одну ветку.</p>\n<p>HTTP-статус отвечает только за транспортный результат запроса. Он не доказывает, что тело имеет нужный синтаксис JSON и тем более содержит обязательные поля. Статус 503 следует обработать как транспортную ошибку до разбора тела. Для статуса 200 всё равно остаются две проверки: формат и договор полезной нагрузки.</p>\n<h2>Почему <code>null</code> не доказывает ошибку</h2>\n<p>JSON-текстом может быть не только объект или массив. Стандарт также допускает строку, число и литералы <code>true</code>, <code>false</code> и <code>null</code>. Поэтому строка <code>null</code> — корректный JSON. После <code>json_decode('null')</code> PHP возвращает значение <code>null</code>, но при невалидном входе функция тоже может вернуть <code>null</code>.</p>\n<p>Различить эти случаи помогает <code>json_last_error()</code>. Если сразу после декодирования получен <code>JSON_ERROR_NONE</code>, синтаксис принят. Это ещё не означает, что ответ годится данному endpoint: корректный <code>null</code> может нарушать договор создания заказа.</p>\n<p>То же относится к ложным значениям. После <code>json_decode('[]', true)</code> получится пустой PHP-массив, а <code>json_decode('false', true)</code> вернёт <code>false</code>. Для поиска отсутствие элементов может быть успешным результатом. Для создания заказа нужен объект с идентификатором, поэтому те же значения должны быть отклонены уже на проверке формы, а не названы «битым JSON».</p>\n<figure><img src=\"/assets/editorial/2018/json-payload-diagnostic.svg\" alt=\"Схема диагностики JSON: HTTP-статус, проверка json_last_error, форма ответа и обязательные поля контракта\" /><figcaption>Один ответ проходит последовательные границы. Транспортная ошибка, ошибка синтаксиса и нарушение договора получают разные причины и действия.</figcaption></figure>\n<h2>Разбор шести ответов</h2>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Сырое тело</th><th scope=\"col\">Результат <code>json_decode(..., true)</code></th><th scope=\"col\"><code>json_last_error()</code></th><th scope=\"col\">Решение для создания заказа</th></tr></thead><tbody><tr><td><code>{"order_id":"A-17"}</code></td><td>ассоциативный массив</td><td><code>JSON_ERROR_NONE</code></td><td>Проверить строковое поле и принять</td></tr><tr><td><code>null</code></td><td><code>null</code></td><td><code>JSON_ERROR_NONE</code></td><td>JSON корректен, но форма не подходит</td></tr><tr><td><code>[]</code></td><td>пустой массив</td><td><code>JSON_ERROR_NONE</code></td><td>Отклонить: нет <code>order_id</code></td></tr><tr><td><code>false</code> или <code>0</code></td><td><code>false</code> или <code>0</code></td><td><code>JSON_ERROR_NONE</code></td><td>Отклонить: это не объект с идентификатором</td></tr><tr><td><code><html>maintenance</html></code></td><td>обычно <code>null</code></td><td><code>JSON_ERROR_SYNTAX</code></td><td>Зафиксировать ошибку разбора, не считать успехом</td></tr><tr><td>пустая строка</td><td>не декодировать в этом обработчике</td><td>не применяется</td><td>Отклонить пустое тело до разбора</td></tr></tbody></table></div>\n<p>Таблица показывает, почему сравнение только с <code>null</code> не работает. Сначала нужно сохранить статус и тело настолько, насколько это разрешает политика журнала. Затем результат декодера читается вместе с кодом ошибки. Только третьим шагом имеет смысл спрашивать, что именно разрешено бизнес-операции.</p>\n<h2>Учебный обработчик для PHP 7.1</h2>\n<p>Функция ниже принимает уже полученное тело. Проверка HTTP-статуса находится снаружи: она относится к транспортному клиенту, а не к декодированию. Контракт этого примера намеренно мал — успешным считается JSON-объект с непустым строковым <code>order_id</code>. В другом API поле может называться иначе, но порядок проверок остаётся тем же.</p>\n<pre><code><?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}</code></pre>\n<p>Снимок <code>$jsonError</code> делается сразу после вызова декодера. Состояние относится к последней операции JSON, поэтому повторный разбор или последующий <code>json_encode()</code> может затруднить диагностику. В журнале достаточно оставить категорию, код, идентификатор запроса, размер и SHA-256 тела. Полный ответ может содержать токены, персональные данные или внутреннюю HTML-страницу; его нельзя бездумно писать в общий лог.</p>\n<p>Вызов с телом <code>{"order_id":"A-17"}</code> возвращает массив. Вызов с <code>null</code> проходит проверку синтаксиса, затем попадает в <code>valid JSON is not an object</code>. Вызов с <code>[]</code> проходит синтаксис и доходит до отсутствующего <code>order_id</code>. Вызов с HTML не проходит <code>JSON_ERROR_NONE</code>. Каждая ветка описывает наблюдаемый результат, а не предположение о том, что произошло у партнёра.</p>\n<h2>Форма ответа — отдельный договор</h2>\n<p>При втором аргументе <code>true</code> объекты JSON становятся ассоциативными массивами PHP. JSON-массивы тоже становятся PHP-массивами, поэтому одного <code>is_array($data)</code> недостаточно, чтобы доказать, что пришёл объект. В примере это ограничение закрывает проверка ключа <code>order_id</code>: список и пустой массив такого ключа не имеют.</p>\n<p>Проверка поля должна соответствовать конкретной операции. Для создания заказа нужно зафиксировать имя поля, его тип и правило пустого значения. Для поиска можно разрешить <code>[]</code>. Для ответа с <code>null</code> нужно отдельно решить, означает ли он «ничего не найдено» или нарушение договора. Нельзя переносить правило одного endpoint на другой только потому, что PHP одинаково приводит их результаты.</p>\n<p>Полезно также заранее решить, разрешены ли дополнительные поля. В этом примере они не запрещены: клиент использует только <code>order_id</code>. Если договор требует точного набора полей, добавьте явную проверку ключей и зафиксируйте её в тесте. Молчаливое принятие лишних данных и строгое отклонение — разные политики, обе должны быть намеренными.</p>\n<h2>Проверка сценария по шагам</h2>\n<p>Тест не обязан ходить в сеть. Для этой ошибки достаточно передать функции фиксированные строки и проверить исключение, категорию события или возвращённый массив. Важно проверять не только положительный ответ: похожие на вид входы должны расходиться по наблюдаемым веткам.</p>\n<ol><li>До декодирования передать ответ со статусом 503 и телом HTML. Ожидается транспортная ошибка; тело не выдаётся за успешный JSON.</li><li>Передать <code>{"order_id":"A-17"}</code>. Ожидаются <code>JSON_ERROR_NONE</code>, строковый идентификатор и возвращённый массив.</li><li>Передать <code><html>maintenance</html></code>. Ожидаются <code>JSON_ERROR_SYNTAX</code> и событие <code>json_decode_error</code>.</li><li>Передать строку <code>null</code>. Ожидается <code>JSON_ERROR_NONE</code>, после чего форма должна отклонить ответ для создания заказа.</li><li>Передать <code>[]</code>. Ожидается <code>JSON_ERROR_NONE</code>, но отсутствие <code>order_id</code> должно дать <code>contract_error</code>.</li><li>Передать <code>false</code> и <code>0</code>. Ожидается успешный разбор, но отказ на проверке типа и формы.</li><li>Отдельно проверить поиск, где <code>[]</code> разрешён договором. Этот тест подтверждает, что правила ответа принадлежат операции, а не общей функции декодирования.</li></ol>\n<h2>Что делать с повтором запроса</h2>\n<p>Ошибка декодирования не сообщает, выполнил ли партнёр операцию. Клиент мог получить HTML после создания заказа или потерять соединение сразу после записи. Поэтому автоматический повтор создания опасен без идемпотентного ключа или отдельного запроса состояния.</p>\n<p>Эта статья не проектирует механизм повторов. Её граница уже: разделить транспорт, синтаксис и форму ответа, сохранить безопасный диагностический контекст и передать решение о повторе владельцу контракта. Если такой механизм нужен, его следует проверять отдельными тестами на повторный запрос и поздний ответ, а не прятать в <code>decodeCreatedOrder()</code>.</p>\n<h2>Ограничения и версия PHP</h2>\n<p>В PHP 7.1 нет флага <code>JSON_THROW_ON_ERROR</code>; в документации PHP он появился в версии 7.3. Для выбранной версии проверка <code>json_last_error()</code> после <code>json_decode()</code> — прямой способ различить ошибку разбора и корректное значение. При обновлении проекта исключение может сделать ветку компактнее, но проверка обязательных полей никуда не исчезает.</p>\n<p>Декодер работает со строками в UTF-8. Код <code>JSON_ERROR_UTF8</code> показывает проблему кодировки, но не доказывает, какая система её внесла. Не стоит лечить такой ответ без проверки источника: сначала сохраните безопасные признаки тела и выясните, где нарушилась кодировка.</p>\n<p>RFC 8259 рекомендует уникальные имена полей в JSON-объекте. Если партнёр прислал повторяющийся <code>order_id</code>, разные реализации могут обработать его по-разному. Критическое поле лучше закрепить в договоре и, если это важно для интеграции, добавить отрицательный тест на повтор имени.</p>\n<h2>Критерий готовности</h2>\n<p>Проверка готова, если все входы из порядка действий дают разные проверяемые результаты: транспортная ошибка, ошибка JSON, корректный <code>null</code> не того типа, пустой допустимый список, ложные значения и успешный объект с идентификатором. В журнале есть категория и request id, но нет полного чувствительного тела. Код не использует <code>if (!$data)</code> как единственный диагноз. Для каждого endpoint отдельно записано, разрешены ли <code>null</code>, пустой список и дополнительные поля.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.php.net/manual/en/function.json-decode.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: json_decode</a> — типы возвращаемых значений, требование UTF-8, ассоциативный режим и добавление <code>JSON_THROW_ON_ERROR</code> в PHP 7.3.</li><li><a href=\"https://www.php.net/manual/en/function.json-last-error.php\" target=\"_blank\" rel=\"noopener\">PHP Manual: json_last_error</a> — код последней ошибки JSON и пример проверки <code>JSON_ERROR_NONE</code>.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc8259.html\" target=\"_blank\" rel=\"noopener\">RFC 8259: The JSON Data Interchange Syntax</a> — допустимые значения JSON, структуру объектов и рекомендацию об уникальности имён.</li></ul>"
|
||
}
|