PHP / API
Работа с файлами и JSON в PHP: безопасное чтение и запись
Как читать и записывать локальные файлы, обрабатывать JSON с исключениями, блокировать конкурентную запись и защищаться от обхода путей.
Содержание статьи
Файлы подходят для конфигурации, кеша, экспорта и небольших наборов данных. Но файловая система не заменяет базу автоматически: конкурентная запись, права, резервные копии и поиск быстро усложняют решение.
В этой статье разберём локальный JSON-файл вне web-root. Пользователь не управляет абсолютным путём, чтение ограничено по размеру, JSON разбирается с исключениями, а запись защищается блокировкой.
Где хранить изменяемые файлы
project/├── public/│ └── index.php├── src/└── storage/ └── settings.jsonВеб-сервер смотрит только в public/. storage/ должен быть доступен процессу PHP на чтение и, если нужно, запись, но не по HTTP.
Не храните изменяемые runtime-файлы рядом с исходным кодом и не выдавайте каталогу более широкие права, чем требуется.
Построение пути
<?phpdeclare(strict_types=1);const STORAGE_ROOT = __DIR__ . '/../storage';$settingsPath = STORAGE_ROOT . DIRECTORY_SEPARATOR . 'settings.json';Путь строится из серверной константы и фиксированного имени. Опасно:
// Не делайте так.$contents = file_get_contents(STORAGE_ROOT . '/' . $_GET['file']);Строка ../config/secret.php может выйти из разрешённого каталога. Лучше сопоставлять внешний идентификатор с фиксированным allowlist:
$allowedFiles = [ 'settings' => STORAGE_ROOT . '/settings.json', 'navigation' => STORAGE_ROOT . '/navigation.json',];$key = is_string($_GET['file'] ?? null) ? $_GET['file'] : '';$path = $allowedFiles[$key] ?? null;if ($path === null) { http_response_code(404); exit('Файл не найден.');}Если продукту действительно нужны динамические имена, проверяйте их жёстким шаблоном, нормализуйте путь и убеждайтесь, что realpath() остаётся внутри разрешённого корня. Для нового файла realpath() ещё не существует, поэтому такой сценарий требует отдельной аккуратной логики.
Чтение файла
function readFileContents(string $path, int $maximumBytes = 1_048_576): string{ if (!is_file($path) || !is_readable($path)) { throw new RuntimeException('Файл недоступен для чтения.'); } $size = filesize($path); if ($size === false || $size > $maximumBytes) { throw new RuntimeException('Недопустимый размер файла.'); } $contents = file_get_contents($path); if ($contents === false) { throw new RuntimeException('Не удалось прочитать файл.'); } return $contents;}file_get_contents() возвращает строку или false, поэтому строгое сравнение обязательно. Пустая строка является допустимым результатом и не равна ошибке.
Проверка размера защищает память только частично: между filesize() и чтением файл может измениться. Для недоверенных больших данных используйте потоковое чтение с лимитом, а не загрузку целиком.
Декодирование JSON
/** * @return array<string, mixed> */function readJsonObject(string $path): array{ $contents = readFileContents($path); try { $data = json_decode( $contents, true, 512, JSON_THROW_ON_ERROR, ); } catch (JsonException $error) { throw new RuntimeException('JSON-файл повреждён.', 0, $error); } if (!is_array($data) || array_is_list($data)) { throw new RuntimeException('Ожидался JSON-объект.'); } return $data;}Флаг JSON_THROW_ON_ERROR превращает ошибку синтаксиса в JsonException. Без него json_decode() может вернуть null, который невозможно отличить от корректного JSON null без дополнительной проверки.
Декодированный массив остаётся недоверенным. Проверьте каждое поле:
$data = readJsonObject($settingsPath);$siteName = is_string($data['site_name'] ?? null) ? trim($data['site_name']) : '';if ($siteName === '' || mb_strlen($siteName) > 100) { throw new RuntimeException('Некорректное поле site_name.');}Кодирование JSON
function encodeJson(array $data): string{ try { return json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR, ) . PHP_EOL; } catch (JsonException $error) { throw new RuntimeException('Не удалось сформировать JSON.', 0, $error); }}JSON_UNESCAPED_UNICODE делает русские строки читаемыми. JSON_THROW_ON_ERROR особенно полезен при недопустимых UTF-8 данных и неподдерживаемых значениях.
Не сериализуйте секреты в файл, который затем попадает в лог, архив или публичный экспорт.
Запись с эксклюзивной блокировкой
Простая запись:
$bytes = file_put_contents($settingsPath, encodeJson($data), LOCK_EX);if ($bytes === false) { throw new RuntimeException('Не удалось записать настройки.');}LOCK_EX просит эксклюзивную advisory-блокировку во время записи. Другие процессы тоже должны уважать flock(). Для чтения и записи согласованного файла можно сделать явные функции.
function writeLocked(string $path, string $contents): void{ $handle = fopen($path, 'c+b'); if ($handle === false) { throw new RuntimeException('Не удалось открыть файл для записи.'); } try { if (!flock($handle, LOCK_EX)) { throw new RuntimeException('Не удалось заблокировать файл.'); } if (!ftruncate($handle, 0) || !rewind($handle)) { throw new RuntimeException('Не удалось подготовить файл.'); } $remaining = $contents; while ($remaining !== '') { $written = fwrite($handle, $remaining); if ($written === false || $written === 0) { throw new RuntimeException('Не удалось полностью записать файл.'); } $remaining = substr($remaining, $written); } if (!fflush($handle)) { throw new RuntimeException('Не удалось сбросить буфер файла.'); } } finally { flock($handle, LOCK_UN); fclose($handle); }}Блокировка уменьшает риск одновременной записи, но не превращает файл в транзакционную базу. Для критичных данных нужны резервные копии, проверка восстановления и подходящее хранилище.
Чтение с общей блокировкой
function readLocked(string $path): string{ $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException('Не удалось открыть файл.'); } try { if (!flock($handle, LOCK_SH)) { throw new RuntimeException('Не удалось заблокировать файл для чтения.'); } $contents = stream_get_contents($handle); if ($contents === false) { throw new RuntimeException('Не удалось прочитать файл.'); } return $contents; } finally { flock($handle, LOCK_UN); fclose($handle); }}Если все участники используют совместимые блокировки, читатель не увидит промежуточную запись.
Удаление и переименование
Перед unlink() или rename() разрешите точный абсолютный путь и проверьте, что операция относится к ожидаемому объекту. Не передавайте пользовательскую строку и glob напрямую в удаление.
Удаление важного файла должно быть авторизовано, журналироваться и по возможности оставаться восстановимым. Ошибка is_file() между проверкой и операцией возможна: файловая система меняется конкурентно, поэтому обрабатывайте результат самой операции.
Когда выбрать базу данных
Переходите к СУБД, если нужны:
- несколько одновременных редакторов;
- выборка и сортировка по полям;
- связи и ограничения;
- атомарное изменение нескольких записей;
- история миграций;
- надёжная конкурентная запись.
JSON удобен для небольшого цельного документа, который редко меняется. Большой массив сущностей в одном файле обычно становится хрупким.
Что запомнить
Храните runtime-файлы вне web-root, не принимайте путь от пользователя и ограничивайте размер. Разбирайте JSON с JSON_THROW_ON_ERROR, затем валидируйте структуру. При записи учитывайте конкурентный доступ, права и восстановление, а при росте требований переходите к базе данных.