Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11468 глав6256 карточек справочника
Дерево руководства

Глава 308 · После первого запроса и общей транзакции PDO

PDO_SQLITE: резерв товара, снимок читателя и план выборки

Два реальных участника резервируют товар; WAL сохраняет согласованный снимок; собственный индекс объясняет ограниченную выборку.

Один файл, два участника и три полезных результата

SQLite выполняет SQL внутри процесса PHP и сохраняет данные в файле. Отдельный сервер базы для этих проектов не нужен. Таблица задаёт поля, строка хранит одну запись, а запрос выбирает нужную часть данных. Начало SQL и PDO объясняет эти понятия на маленьких примерах.

Каждый архив создаёт собственный файл базы и учебные данные. Сначала изучите маленький вызов и его результат, затем откройте самостоятельный проект. Перед учебной уборкой программа сохраняет полезную выгрузку; в рабочем приложении постоянную базу, конечно, не удаляют после запроса. Подготовка PHP и пути разобраны в главе о локальной базе.

Локальный файл не означает единственного пользователя. Два запроса сайта или две программы могут одновременно открыть один склад. Нам нужно сохранить два резерва без отрицательного остатка, получить согласованный отчёт и понять, как SQLite выбирает нужные заказы. На выходе будут reservation.csv, snapshot.csv и query-plan.json с конкретными товарами, числами и условиями отбора.

Что требуется человекуЧто показывает проектГраница
Зарезервировать товар двум покупателямКабель: 5 → 3 → 2; первая попытка второго покупателя получила busyОдин реальный дочерний PHP, явная вторая попытка после commit
Сверить остаток в согласованном отчётеОдин читатель видит 3, писатель уже видит 7, новый снимок видит 7Два подключения в одном процессе; не тест нагрузки
Открыть первые ноябрьские заказы101/103/106 на 4300 копеек и план до/после индексаПлан объясняет стратегию, но не доказывает ускорение

Перед сложным проектом различим четыре срока жизни. Соединение $db открывает файл. Транзакция объединяет действия этого соединения до commit или rollBack. Statement $select описывает и выполняет один запрос. Результат содержит ещё не прочитанные строки этого выполнения. Переменная PHP с уже полученным qty=3 — обычное значение: новое изменение файла само её не переписывает. Основы транзакций объясняют общий смысл подтверждения и отмены, а ниже разобран именно порядок SQLite.

Те же имена, разные сроки жизни
  1. data.sqlite: товар CABLE и сохранённый qty
  2. $reader: подключение к этому файлу; BEGIN открывает общую операцию
  3. $select: SELECT qty; fetchColumn даёт PHP-значение 3
  4. closeCursor завершает результат; COMMIT завершает явную транзакцию
  5. $select=null и $reader=null снимают оставшиеся ссылки; файл приложения остаётся

Модель не означает отдельный сетевой сервер: SQLite работает внутри PHP. В проектах файл удаляется только ради воспроизводимой лаборатории, после сохранения полезной выгрузки. В приложении рабочая база остаётся. Прикладные условия, например qty>=2, по-прежнему необходимы даже при правильно выбранной транзакции.

Где начинается право записи: DEFERRED, IMMEDIATE, EXCLUSIVE

Когда действие заранее будет менять остаток, удобно получить право записи в начале операции. В PHP 8.5 соединение Pdo\Sqlite принимает ATTR_TRANSACTION_MODE; getAttribute возвращает выбранный int, setAttribute принимает одну из трёх именованных констант. Настройка относится к последующему PDO::beginTransaction, а не превращает уже начавшуюся транзакцию в другой режим.

$db->setAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE,
    Pdo\Sqlite::TRANSACTION_MODE_IMMEDIATE);
echo $db->getAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE); // 1
// Пока изменена только настройка. Начало операции:
$db->beginTransaction();
// ...короткая проверка и изменение своих строк...
$db->commit();

В строке с setAttribute первый аргумент — имя настройки, второй — её значение. :: обращается к константе класса, -> вызывает метод конкретного подключения. beginTransaction не принимает режим аргументом. Успешные beginTransaction/commit/rollBack возвращают true; отказ обрабатывают согласно ERRMODE, в наших проектах это PDOException. Начать вложенную транзакцию повторным beginTransaction нельзя: для вложенных границ SQLite имеет SAVEPOINT, но это отдельная модель, не вложенный PDO begin.

РежимЧислоКогда он нужен и что происходит
TRANSACTION_MODE_DEFERRED0По умолчанию. BEGIN откладывает выбор чтения/записи до обращения к данным. Первый SELECT создаёт читаемый снимок; дальнейший UPDATE может не получить право записи. Применение: согласованный отчёт несколькими чтениями.
TRANSACTION_MODE_IMMEDIATE1Начинает write transaction сразу. Другой писатель может вызвать busy уже на BEGIN. Применение: короткий резерв или изменение настроек по прочитанному состоянию.
TRANSACTION_MODE_EXCLUSIVE2В WAL соответствует IMMEDIATE. В остальных журнальных режимах дополнительно не допускает чтение другими подключениями на протяжении такой транзакции. Это более сильное ограничение доступа, не универсальный выбор для ускорения.

SQLite допускает несколько читателей, но одновременно пишет только один участник. Поэтому IMMEDIATE защищает право последующей записи, а не одну строку CABLE. Не держите такую транзакцию, пока человек заполняет форму или внешний сервис отвечает. Резерв товара показывает практику IMMEDIATE; сверка отчёта использует DEFERRED. Для настроек сервиса первый подход полезен, когда решение зависит от актуального значения; для отчёта из нескольких SELECT второй сохраняет общую точку чтения.

// Подключение reader читает несколько частей одного отчёта.
$reader->setAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE,
    Pdo\Sqlite::TRANSACTION_MODE_DEFERRED);
$reader->beginTransaction();
try {
    $s=$reader->query("SELECT qty FROM stock WHERE sku='CABLE'");
    $qty=$s->fetchColumn();
    $s->closeCursor();
    $reader->commit();
} catch (Throwable $e) {
    if ($reader->inTransaction()) { $reader->rollBack(); }
    throw $e;
}

Проверка inTransaction в catch нужна потому, что не всякая ошибка оставляет одинаковое состояние. В частности, отказ начала операции отличается от отказа её последующего запроса. Число режима вне допустимого набора не выбирает «автоматический» режим; проверяйте возвращённый результат настройки. До PHP 8.5 этих современных констант нет: старый код с явным BEGIN IMMEDIATE требует отдельного согласования границ с PDO. Наши проекты намеренно требуют PHP 8.5, не подменяют проверенный путь историческим вариантом.

Ограничить ожидание и отличить его от повторной покупки

Сообщение database is locked означает, что сейчас нельзя получить нужный доступ; оно не сообщает, какой покупатель должен победить. Для понятного поведения нужны предел ожидания, диагностика и явное решение о повторе. У PDO_SQLITE ATTR_TIMEOUT — целое число секунд. Драйвер PHP 8.5.11 передаёт его в sqlite3_busy_timeout, умножая на 1000. У PRAGMA busy_timeout единица уже миллисекунда: эти числа нельзя менять местами.

$db->setAttribute(PDO::ATTR_TIMEOUT, 1); // До примерно 1 секунды ожидания busy.
// Если нужна настройка SQLite в миллисекундах, это другой интерфейс:
// $db->exec('PRAGMA busy_timeout=250');
// Последняя настройка busy-handler заменяет предыдущую на этом подключении.

Ноль выключает ожидание busy. Это не ограничитель времени всего SQL, процесса, HTTP-запроса или PHP callback. При некоторых конфликтах, способных образовать взаимное ожидание, SQLite вправе сразу вернуть busy. Поэтому время в отчёте измеряется, но не сравнивается с обещанием точности таймера. У исходного драйвера значение по умолчанию 60 секунд; лаборатория явно задаёт 1 секунду, чтобы ошибка не превращалась в долгую паузу.

try {
    $db->beginTransaction();
} catch (PDOException $e) {
    $state=$e->errorInfo[0] ?? (string)$e->getCode();
    $sqliteCode=$e->errorInfo[1] ?? null;
    if ($db->inTransaction()) { $db->rollBack(); }
    // Сохранить диагностику. Новая попытка — отдельное решение приложения.
    throw $e;
}

SQLSTATE и числовой код SQLite — разные поля. Базовый SQLITE_BUSY имеет код 5; расширенные коды уточняют причину, например устаревший WAL-снимок. ATTR_EXTENDED_RESULT_CODES относится к подключению и включает расширенные коды SQLite; не сравнивайте все возможные конфликты только с 5 после включения этого режима. Список результатов SQLite помогает расшифровать их. Показанный catch не скрывает отказ и не повторяет изменение; пример ниже получает настоящий HY000/5 при другом активном писателе.

Первое применение короткого ожидания — веб-резерв с быстрым понятным отказом. Второе — фоновый импорт, которому допустимо ждать дольше, но только в рамках общего срока задания. Повтор всей операции должен заново прочитать нужные данные, проверить условие и избегать повторного внешнего платежа. Рецепт «добавить sleep» не устанавливает порядок: в нашем проекте его устанавливают сообщения между процессами.

Сохранить два резерва после явного конфликта записи

В маленьком складе два покупателя резервируют один Кабель. В файле есть 5 единиц. Первый резервирует 2, а второй не должен считать старое значение разрешением записать свой остаток. Проект показывает настоящий конфликт двух PHP-процессов и отдельную новую попытку после подтверждённого commit. Итоговая квитанция содержит оба успешных резерва и остаток 2.

Путь данных и полученный результат
  1. CABLE: 5 единиц → родитель BEGIN IMMEDIATE → резерв 2, внутри видно 3
  2. Child: команда blocked → BEGIN IMMEDIATE → busy, UPDATE ещё не запускался
  3. Родитель COMMIT → отдельная команда reserve
  4. Child: новая транзакция → резерв 1 из 3 → COMMIT → осталось 2
  5. reservation.csv: parent 2/3; child 1/2 → остановка child → уборка собственной БД

Минимальный вызов

$db->setAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE,
    Pdo\Sqlite::TRANSACTION_MODE_IMMEDIATE);
$db->beginTransaction();
try {
    $reserve=$db->prepare('UPDATE stock SET qty=qty-2 WHERE sku=? AND qty>=2');
    $reserve->execute(['CABLE']);
    $changed=$reserve->rowCount();
    $reserve->closeCursor();
    $db->commit();
    echo $changed; // 1: строка обновлена, 5 превратилось в 3.
} catch (Throwable $e) {
    if ($db->inTransaction()) { $db->rollBack(); }
    throw $e;
}

Предусловие маленького фрагмента — соединение $db и stock с CABLE/5; полный проект создаёт их сам. Стрелка -> вызывает метод соединения или statement. Именованная константа выбирает режим следующего beginTransaction; она не начинает транзакцию сама. В SQL qty=qty-2 вычисляется внутри SQLite, WHERE qty>=2 проверяет запас в той же операции, ? получает CABLE из массива. rowCount равен числу изменённых строк: 1 означает успешный резерв двух единиц, 0 — отсутствие подходящей строки. Первый begin расположен до try: его отказ не оставляет выполненного UPDATE. Полный двухпроцессный вариант отдельно перехватывает именно этот отказ.

Что получится после запуска

НаблюдениеРезультат
ТоварКабель
Начальный остаток5
Первый резерв2
Первая попытка второго покупателяHY000 / 5: запись занята
Новый резерв после commit1
Остаток после двух резервов2
Дочерний процесс завершёнда
Квитанцияreservation.csv
Самостоятельный проект, исходник и архив
Сохранить два резерва после явного конфликта записиСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$database=null;$owned=$removed=$rows=$checks=$observations=[];$ownedFiles=$removedFiles=[];
$job=null;
try {
    $database=p181Database($ownedFiles,'project');$db=p181Connect($database);$runtime=p181Runtime($db);

    require __DIR__.'/reservation-lab.php';
    $tableName=p181OwnTable($db,$owned,'stock',
        '(sku TEXT PRIMARY KEY, title TEXT NOT NULL, qty INTEGER NOT NULL CHECK(qty>=0))');
    $table=p181Qualified($tableName);
    p181Command($db,"INSERT INTO $table(sku,title,qty) VALUES(?,?,?)",['CABLE','Кабель',5]);
    $job=p181ReservationStart($database,$tableName);
    $ready=p181ReservationSetup($job,$database,$tableName);
    $db->setAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE,Pdo\Sqlite::TRANSACTION_MODE_IMMEDIATE);
    $db->beginTransaction();
    $parentAffected=p181Command($db,"UPDATE $table SET qty=qty-2 WHERE sku=? AND qty>=2",['CABLE']);
    $parentStock=p181Read($db,"SELECT sku,qty FROM $table WHERE sku=?",['CABLE']);
    $blocked=p181ReservationAsk($job,'blocked');
    if (($blocked['error']['driver_code']??null)!==5 || $blocked['transaction_active']!==false) {
        throw new RuntimeException('Не подтверждён ожидаемый конфликт начала записи.');
    }
    $db->commit(); // Только теперь второй покупатель получает разрешение на новую попытку.
    $reserved=p181ReservationAsk($job,'reserve');
    $proof=p181ReservationStop($job);
    $final=p181Read($db,"SELECT sku,title,qty FROM $table WHERE sku=?",['CABLE']);
    $csv="phase,sku,reserved,stock\n";
    $csv.='parent,CABLE,'.($parentAffected*2).','.$parentStock[0]['qty']."\n";
    $csv.='child,CABLE,'.$reserved['affected'].','.$final[0]['qty']."\n";
    file_put_contents(__DIR__.'/reservation.csv',$csv);
    $checks=[
        'parent_reserved_two'=>$parentAffected===1&&$parentStock===[['sku'=>'CABLE','qty'=>3]],
        'busy_is_explicit'=>$blocked['error']['sqlstate']==='HY000'&&$blocked['error']['driver_code']===5
            &&$blocked['error']['message_present']===true&&$blocked['transaction_active']===false,
        'new_attempt_reserved_one'=>$reserved['affected']===1&&$reserved['stock']===[['sku'=>'CABLE','qty'=>2]],
        'stock_readback'=>$final===[['sku'=>'CABLE','title'=>'Кабель','qty'=>2]],
        'child_stopped'=>$proof['started']&&$proof['stopped']&&$proof['exit_code']===0&&!$proof['termination_by_parent']&&$proof['ipc_removed'],
        'receipt_exact'=>$csv==="phase,sku,reserved,stock\nparent,CABLE,2,3\nchild,CABLE,1,2\n",
    ];
    $rows=[['Товар',$final[0]['title']],['Начальный остаток',5],['Первый резерв',2],
        ['Первая попытка второго покупателя','HY000 / 5: запись занята'],
        ['Новый резерв после commit',$reserved['affected']],['Остаток после двух резервов',$final[0]['qty']],
        ['Дочерний процесс завершён',$proof['stopped']],['Квитанция','reservation.csv']];
    $observations=['parent_stock'=>$parentStock,'blocked'=>$blocked,'second_attempt'=>$reserved,'final'=>$final,
        'child_process'=>array_intersect_key($proof,array_flip(['started','stopped','exit_code','termination_by_parent','deadline_seconds','transport','ipc_removed'])),
        'business_attempts'=>['first'=>'begin failed; no UPDATE','second'=>'one conditional UPDATE after confirmed parent commit']];

} finally {
if ($job!==null) { p181ReservationStop($job); }
    p181Close($db);
    if($database!==null){[$removed,$removedFiles]=p181Cleanup($database,$owned,$ownedFiles);}
}

p181Report('pdo181-reservation-contention','Сохранить два резерва после явного конфликта записи',$rows,$checks,$observations,$runtime,$owned,$removed,$ownedFiles,$removedFiles);
Ожидаемый результат
Товар — Кабель
Начальный остаток — 5
Первый резерв — 2
Первая попытка второго покупателя — HY000 / 5: запись занята
Новый резерв после commit — 1
Остаток после двух резервов — 2
Дочерний процесс завершён — да
Квитанция — reservation.csv

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать квитанцию двух резервов

Границы решения

Здесь не реализована очередь интернет-заказов: нет платежа, сетевого ответа и дедупликации внешних команд. Для такого приложения понадобится ключ идемпотентности. Ожидание busy ограничено одной секундой, общий протокол — 20 секундами; время в миллисекундах сохраняется как наблюдение, не как обещание точного таймера. Межпроцессные команды подтверждают порядок, а usleep внутри helper лишь опрашивает канал. При новой попытке условие запаса проверяется заново. Долгую работу вне БД выполняют до короткой транзакции, когда это допускает бизнес-правило.

Попробуйте изменить условие

Измените начальный запас с 5 на 2. Каким должен быть второй успешный по соединению, но не по резерву исход? Почему нельзя считать сам commit подтверждением покупки?

Разбор

Первый резерв оставит 0. После освобождения второй BEGIN сможет начаться, но условный UPDATE изменит 0 строк. Транзакцию можно завершить, однако квитанция должна сообщить «недостаточно товара». Измените ожидания и строки квитанции; не заменяйте qty>=1 предварительным SELECT в другом соединении.

WAL: почему согласованный снимок может быть старым

Для отчёта из нескольких запросов бывает важнее видеть согласованную старую версию, чем половину новых данных. WAL — режим журнала, в котором сохранённые изменения сначала добавляются в отдельный журнал рядом с базой. Читатель продолжает использовать свой снимок, а писатель может завершить новую версию. Разрешённых одновременно писателей всё равно не становится два.

$statement=$db->query('PRAGMA journal_mode=WAL');
$mode=$statement->fetchColumn();
$statement->closeCursor();
if (strtolower($mode)!=='wal') {
    throw new RuntimeException('WAL не включён для этого файла.');
}

PRAGMA — SQL-настройка SQLite. Вызов возвращает фактический режим, поэтому его читают и проверяют. Переключение делают до пользовательской транзакции; WAL сохраняется как свойство файла и действует на другие подключения к нему. При обычном локальном доступе -wal и -shm являются служебными спутниками базы, а не лишними выгрузками, которые можно удалить в произвольный момент.

МоментЧитательПисатель
Начало отчётаBEGIN, SELECT CABLE → 3Соединение ещё не меняет остаток
Исправление складского остаткаСнимок остаётся прежнимUPDATE qty=7; COMMIT
Повтор части отчётаSELECT снова → 3Новый SELECT → 7
Запросить свежий отчётCOMMIT старого чтения; новый SELECT → 7Его изменение уже сохранено

Это модель видимости между разными подключениями. В одном подключении собственные изменения видны последующим запросам той же транзакции. Если читатель старого снимка сам захочет записывать после чужого commit, ожидание не сделает старый снимок актуальным: возможен SQLITE_BUSY_SNAPSHOT. Завершите старую операцию и примите решение заново. Проект наблюдает только чтение снимка; отказ такого повышения не выдаётся за отдельный выполненный тест.

Первое применение WAL здесь — согласованная сверка склада. Второе — выгрузка нескольких связанных выборок для отчёта, пока короткие записи продолжаются. Размер отчёта и длительность транзакции ограничивают: старый читатель может задержать checkpoint, то есть перенос страниц журнала в основной файл. WAL требует локальной среды с общим доступом к служебной памяти и не предназначен для сетевой файловой системы.

// Все SELECT завершены; явные транзакции уже commit/rollback.
$checkpoint=$db->query('PRAGMA wal_checkpoint(TRUNCATE)');
$state=$checkpoint->fetch(PDO::FETCH_ASSOC);
$checkpoint->closeCursor();
if ((int)$state['busy']!==0) {
    throw new RuntimeException('Есть участник, мешающий завершить checkpoint.');
}

Checkpoint не заменяет commit приложения и не является разрешением удалять живую базу. TRUNCATE старается завершить перенос и сократить WAL; результат busy сообщает, завершилось ли это без занятого участника. В проектах сначала закрываются statements и дополнительные подключения, потом проверяется checkpoint, и лишь затем helper удаляет точно зарегистрированный учебный файл. Рабочую базу так не очищают; для её копии выбирают согласованный механизм backup.

Получить согласованный остаток и затем открыть свежий снимок

Страница сверки должна сравнивать несколько значений одного согласованного состояния. В режиме WAL читатель может продолжать видеть CABLE/3, пока другой участник уже сохранил CABLE/7. Это не потеря UPDATE. Файл snapshot.csv показывает четыре чтения и точный момент, после которого читатель получает новый остаток.

Путь данных и полученный результат
  1. Один файл в WAL: CABLE/3 → reader BEGIN + SELECT фиксирует снимок
  2. writer UPDATE CABLE/7 → COMMIT → его чтение даёт 7
  3. reader: новый SELECT, прежняя транзакция → всё ещё 3
  4. reader COMMIT → новая транзакция → 7
  5. Оба результата закрыты → writer закрыт → checkpoint → snapshot.csv

Минимальный вызов

$reader->beginTransaction();
$first=$reader->query("SELECT qty FROM stock WHERE sku='CABLE'");
echo $first->fetchColumn(); // 3
$first->closeCursor();
// Здесь отдельное соединение writer сохраняет qty=7 и выполняет commit.
$again=$reader->query("SELECT qty FROM stock WHERE sku='CABLE'");
echo $again->fetchColumn(); // 3 в прежней транзакции.
$again->closeCursor();
$reader->commit();
$fresh=$reader->query("SELECT qty FROM stock WHERE sku='CABLE'");
echo $fresh->fetchColumn(); // 7 в новом неявном чтении.
$fresh->closeCursor();

Фрагмент предполагает один локальный файл с WAL и два независимых PDO-подключения. beginTransaction откладывает завершение транзакции до commit; первый SELECT устанавливает читаемый снимок. fetchColumn получает только qty, closeCursor завершает statement, но явная транзакция остаётся. Второй SELECT поэтому всё ещё выводит 3. После commit следующий запрос читает 7. Между комментариями находится реальный UPDATE второго подключения в полном проекте; никакой случайный сон не нужен.

Что получится после запуска

НаблюдениеРезультат
ТоварCABLE
Читатель до изменения3
Писатель после commit7
Читатель в прежней транзакции3
Читатель в новой транзакции7
Checkpoint занятнет
Файлsnapshot.csv
Самостоятельный проект, исходник и архив
Получить согласованный остаток и затем открыть свежий снимокСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$database=null;$owned=$removed=$rows=$checks=$observations=[];$ownedFiles=$removedFiles=[];
$writer=null;
try {
    $database=p181Database($ownedFiles,'project');$db=p181Connect($database);$runtime=p181Runtime($db);

    $mode=p181Read($db,'PRAGMA journal_mode=WAL');
    $tableName=p181OwnTable($db,$owned,'stock','(sku TEXT PRIMARY KEY, qty INTEGER NOT NULL CHECK(qty>=0))');
    $table=p181Qualified($tableName);
    p181Command($db,"INSERT INTO $table(sku,qty) VALUES(?,?)",['CABLE',3]);
    $writer=p181Connect($database);
    $db->beginTransaction();
    $before=p181Read($db,"SELECT sku,qty FROM $table WHERE sku=?",['CABLE']);
    $writer->beginTransaction();
    p181Command($writer,"UPDATE $table SET qty=? WHERE sku=?",[7,'CABLE']);
    $writer->commit();
    $writerNow=p181Read($writer,"SELECT sku,qty FROM $table WHERE sku=?",['CABLE']);
    $sameSnapshot=p181Read($db,"SELECT sku,qty FROM $table WHERE sku=?",['CABLE']);
    $db->commit();
    $db->beginTransaction();
    $fresh=p181Read($db,"SELECT sku,qty FROM $table WHERE sku=?",['CABLE']);
    $db->commit();
    p181Close($writer); // Every p181Read already closed its statement.
    $checkpoint=p181Read($db,'PRAGMA wal_checkpoint(TRUNCATE)');
    $csv="observation,sku,qty\nreader_before,CABLE,".$before[0]['qty']."\nwriter_after_commit,CABLE,".$writerNow[0]['qty']
        ."\nreader_same_transaction,CABLE,".$sameSnapshot[0]['qty']."\nreader_new_transaction,CABLE,".$fresh[0]['qty']."\n";
    file_put_contents(__DIR__.'/snapshot.csv',$csv);
    $checks=['wal_enabled'=>strtolower((string)array_values($mode[0])[0])==='wal',
        'initial_snapshot'=>$before===[['sku'=>'CABLE','qty'=>3]],
        'writer_committed'=>$writerNow===[['sku'=>'CABLE','qty'=>7]],
        'same_snapshot_stable'=>$sameSnapshot===$before,
        'new_snapshot_fresh'=>$fresh===$writerNow,
        'checkpoint_not_busy'=>(int)$checkpoint[0]['busy']===0,
        'receipt_exact'=>$csv==="observation,sku,qty\nreader_before,CABLE,3\nwriter_after_commit,CABLE,7\nreader_same_transaction,CABLE,3\nreader_new_transaction,CABLE,7\n"];
    $rows=[['Товар','CABLE'],['Читатель до изменения',$before[0]['qty']],
        ['Писатель после commit',$writerNow[0]['qty']],['Читатель в прежней транзакции',$sameSnapshot[0]['qty']],
        ['Читатель в новой транзакции',$fresh[0]['qty']],['Checkpoint занят',(int)$checkpoint[0]['busy']!==0],['Файл','snapshot.csv']];
    $observations=['journal_mode'=>array_values($mode[0])[0],'reader_before'=>$before,'writer_after'=>$writerNow,
        'reader_same_transaction'=>$sameSnapshot,'reader_new_transaction'=>$fresh,'checkpoint'=>$checkpoint];

} finally {
p181Close($writer);
    p181Close($db);
    if($database!==null){[$removed,$removedFiles]=p181Cleanup($database,$owned,$ownedFiles);}
}

p181Report('pdo181-wal-snapshot','Получить согласованный остаток и затем открыть свежий снимок',$rows,$checks,$observations,$runtime,$owned,$removed,$ownedFiles,$removedFiles);
Ожидаемый результат
Товар — CABLE
Читатель до изменения — 3
Писатель после commit — 7
Читатель в прежней транзакции — 3
Читатель в новой транзакции — 7
Checkpoint занят — нет
Файл — snapshot.csv

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать четыре наблюдения снимка

Границы решения

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

Попробуйте изменить условие

Уберите явную транзакцию читателя, оставив два отдельных SELECT с закрытием курсоров. Как изменится третья строка snapshot.csv?

Разбор

Первый SELECT завершит своё неявное чтение при закрытии результата. После commit писателя новый SELECT увидит 7. Это подходит для кнопки «Обновить остаток», но не даёт общего снимка нескольким запросам отчёта. Выбор зависит от требования к согласованности, а не от желания убрать commit.

Когда statement закончен и что значит READONLY

Одна полученная строка ещё не означает завершённый результат. Это важно для короткой карточки товара, которая читает только первую строку, и для отчёта, который остановился по собственному условию. Атрибуты statement позволяют увидеть его состояние отдельно от транзакции соединения.

АтрибутПолучатель и результатЧто означает
ATTR_BUSY_STATEMENT (PHP 8.5)PDOStatement::getAttribute → bool; только чтениеtrue: выполнение началось и не достигло конца/сброса. Это состояние своего statement, не код SQLITE_BUSY от другого писателя.
ATTR_READONLY_STATEMENTPDOStatement::getAttribute → bool; только чтениеtrue: SQL не меняет файл БД непосредственно. UPDATE с условием, которое не найдёт строк, всё равно может вернуть false; это не количество изменений.
ATTR_EXPLAIN_STATEMENT (PHP 8.5)PDOStatement::getAttribute → int; setAttribute(int mode) → boolТекущий режим 0, 1 или 2. Менять его следует только после closeCursor.
$statement=$db->prepare('SELECT id,title FROM orders ORDER BY id LIMIT 3');
var_dump($statement->getAttribute(Pdo\Sqlite::ATTR_BUSY_STATEMENT)); // false
$statement->execute();
$first=$statement->fetch(PDO::FETCH_ASSOC);
var_dump($statement->getAttribute(Pdo\Sqlite::ATTR_BUSY_STATEMENT)); // true при ещё активном результате
$statement->closeCursor();
var_dump($statement->getAttribute(Pdo\Sqlite::ATTR_BUSY_STATEMENT)); // false
$statement=null;

Предусловие фрагмента — три подходящие строки. getAttribute читает состояние, fetch забирает строку, closeCursor завершает результат, присваивание null убирает ссылку PHP. Если дополнительных ссылок нет, statement может быть уничтожен. В PDO_SQLITE closeCursor вызывает сброс SQLite statement; его можно выполнить заново. Но закрытие курсора само по себе не подтверждает и не отменяет явную транзакцию — это хорошо видно в снимке читателя.

Для обычного чтения до конца цикл fetch завершает результат естественно; при раннем выходе closeCursor помещают в finally. Метод не принимает аргументов и возвращает bool; ошибки зависят от ERRMODE. Общая карточка PDOStatement::closeCursor объясняет переносимый контракт, а проект с заказами показывает наблюдение до/после сброса. Удаление только переменной $db не гарантирует закрытия файла, если живой statement ещё удерживает соединение.

$read=$db->prepare('SELECT id,title FROM orders WHERE id=?');
$write=$db->prepare('UPDATE orders SET title=? WHERE id=?');
var_dump($read->getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT));  // true
var_dump($write->getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT)); // false
// Ни execute, ни изменение данных в этом фрагменте не выполняются.
$read=null;
$write=null;

READONLY полезен в диагностике подготовленных запросов и в панели плана, где надо объяснить, что анализируется. Он не заменяет авторизацию SQL: SELECT может вызвать пользовательскую PHP-функцию с побочным эффектом. Более того, EXPLAIN сохраняет классификацию исходного запроса: план UPDATE не превращает его READONLY в true. Для ограниченного административного чтения изучите authorizer и правила доступа; для открытия готового файла без записи — read-only подключение. Это разные уровни защиты.

План выборки и инструкции SQLite вместо строк товара

План отвечает на вопрос «как SQLite собирается искать строки», а не «какие заказы выбраны». Поэтому результат плана и обычный результат нужно различать даже у одного PDOStatement. В PHP 8.5 ATTR_EXPLAIN_STATEMENT предоставляет три режима; поддержка переключения требует сборки против libsqlite не старше 3.43.0. Наличие PHP 8.5 само по себе не заменяет эту проверку зависимости.

КонстантаЧислоФорма результата и применение
EXPLAIN_MODE_PREPARED0Обычное выполнение подготовленного SQL: строки заказов или изменение данных. Возвращение из просмотра плана в реальную выборку.
EXPLAIN_MODE_EXPLAIN1Инструкции виртуальной машины SQLite: специализированный разбор реализации запроса. Это не CSV товаров.
EXPLAIN_MODE_EXPLAIN_QUERY_PLAN2Высокоуровневые узлы плана: id, parent, вспомогательное поле и detail. Для оценки отбора по индексу и сортировки.
$statement=$db->prepare('SELECT id FROM orders WHERE status=?');
$statement->setAttribute(Pdo\Sqlite::ATTR_EXPLAIN_STATEMENT,
    Pdo\Sqlite::EXPLAIN_MODE_EXPLAIN_QUERY_PLAN);
$statement->execute(['OPEN']);
$plan=$statement->fetchAll(PDO::FETCH_ASSOC);
$statement->closeCursor();
// Второе применение: при подробной диагностике нужны инструкции VM.
$statement->setAttribute(Pdo\Sqlite::ATTR_EXPLAIN_STATEMENT,
    Pdo\Sqlite::EXPLAIN_MODE_EXPLAIN);
$statement->execute(['OPEN']);
$instructions=$statement->fetchAll(PDO::FETCH_ASSOC);
$statement->closeCursor();

Здесь план и инструкции сохраняются в разные массивы. Это контекстный фрагмент для небольшой диагностической выборки; полный проект ниже выполняет обычный запрос и QUERY_PLAN, не выдаёт VM-фрагмент за отдельный проверенный MAIN. Переключение может потребовать повторной подготовки SQL внутри SQLite. Перед ним надо закончить активное выполнение, затем снова передать параметры при execute.

На уровне закреплённого PHP 8.5.11 значение режима обязано быть int: строка "2" не заменяет именованную константу и даёт TypeError. Значение вне 0…2 даёт ValueError, как и недоступная при старой libsqlite возможность. В другом отказе setAttribute может вернуть false, поэтому успешность переключения проверяют. Новые проекты не пробуют изменить режим у живого результата: сначала closeCursor, затем setAttribute.

В плане SCAN обозначает обход набора, SEARCH — поиск подходящей части по указанным условиям; индекс может также обслужить порядок. Эти слова помогают читать диагностику, но exact detail и форма дерева менялись между выпусками SQLite. Не стройте бизнес-ветвление приложения на тексте EXPLAIN. Проект сохраняет реально полученный план в раскрываемых наблюдениях, а в полезный JSON помещает условия запроса, выбранные строки и узкое сравнение наличия своего индекса.

Первое практическое применение — понять отбор OPEN-заказов по диапазону дат. Второе — объяснить, почему изменение ORDER BY требует другой стратегии сортировки. Индекс на status,created_at,id согласуется с равенством по статусу, диапазоном и стабильным порядком по дате/id. Добавлять индексы ко всем полям не нужно: они занимают место и обслуживаются при изменении данных. Для заявления о скорости нужен отдельный замер на репрезентативных данных; здесь его нет.

Выбрать три заказа и проверить роль собственного индекса

Оператору нужны первые три открытых ноябрьских заказа, а разработчику — объяснение, каким путём SQLite их находит. Мы сохраняем выбранные заказы и контракт запроса в query-plan.json, смотрим план до и после собственного индекса и отдельно наблюдаем срок активного результата. Вкладка с планом не заменяет сами полезные строки.

Путь данных и полученный результат
  1. Шесть заказов → WHERE OPEN и ноябрь → ORDER BY created_at,id → LIMIT 3
  2. EXPLAIN QUERY PLAN до индекса → наблюдение маршрута чтения
  3. Обычный режим → 101 Кабель, 103 Хомут, 106 Коробка; всего 4300 коп.
  4. Индекс(status,created_at,id) → новый план → те же три заказа
  5. Подготовлен: BUSY=false → первая строка: true → closeCursor: false → query-plan.json

Минимальный вызов

$sql="SELECT id,title FROM orders WHERE status=? ORDER BY created_at,id LIMIT 3";
$statement=$db->prepare($sql);
$statement->setAttribute(Pdo\Sqlite::ATTR_EXPLAIN_STATEMENT,
    Pdo\Sqlite::EXPLAIN_MODE_EXPLAIN_QUERY_PLAN);
$statement->execute(['OPEN']);
$plan=$statement->fetchAll(PDO::FETCH_ASSOC); // Узлы плана, ещё не заказы.
$statement->closeCursor();
$statement=null;
$statement=$db->prepare($sql); // Отдельный обычный результат и его метаданные.
$statement->execute(['OPEN']);
$orders=$statement->fetchAll(PDO::FETCH_ASSOC); // Теперь строки заказов.
$statement->closeCursor();
$statement=null;

Предусловие — orders и настроенное PDO-соединение; полный проект создаёт шесть заказов. prepare сохраняет SQL с ?, execute передаёт значение OPEN. Атрибут устанавливается на $statement, а не на $db. Режим QUERY_PLAN меняет смысл результата: вместо id/title получаются id/parent/notused/detail. closeCursor завершает выполнение, а присваивание null освобождает ссылку на объект. Затем новый prepare того же SQL создаёт обычный statement со своими метаданными; execute получает прежние параметры. В полном проекте также есть нижняя и верхняя даты: SQL ограничивает данные до передачи PHP.

Что получится после запуска

НаблюдениеРезультат
Заказ 101Кабель / 2026-11-02 / 1500
Заказ 103Хомут / 2026-11-08 / 800
Заказ 106Коробка / 2026-11-09 / 2000
Итого, копеек4300
Собственный индекс в плане до созданиянет
Собственный индекс в плане после созданияда
BUSY после первой строкида
BUSY после closeCursorнет
SELECT readonlyда
Подготовленный UPDATE readonlyнет
Файлquery-plan.json
Самостоятельный проект, исходник и архив
Выбрать три заказа и проверить роль собственного индексаСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$database=null;$owned=$removed=$rows=$checks=$observations=[];$ownedFiles=$removedFiles=[];
$statement=$update=null;
try {
    $database=p181Database($ownedFiles,'project');$db=p181Connect($database);$runtime=p181Runtime($db);

    $tableName=p181OwnTable($db,$owned,'orders',
        '(id INTEGER PRIMARY KEY, title TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, total_cents INTEGER NOT NULL)');
    $table=p181Qualified($tableName);
    $seed=[[101,'Кабель','OPEN','2026-11-02',1500],[102,'Розетка','CLOSED','2026-11-03',900],
        [103,'Хомут','OPEN','2026-11-08',800],[104,'Лента','OPEN','2026-10-31',500],
        [105,'Труба','OPEN','2026-12-01',1200],[106,'Коробка','OPEN','2026-11-09',2000]];
    foreach($seed as $row){p181Command($db,"INSERT INTO $table(id,title,status,created_at,total_cents) VALUES(?,?,?,?,?)",$row);}
    $sql="SELECT id,title,created_at,total_cents FROM $table WHERE status=? AND created_at>=? AND created_at<? ORDER BY created_at,id LIMIT 3";
    $params=['OPEN','2026-11-01','2026-12-01'];
    $statement=$db->prepare($sql);
    if(!$statement->setAttribute(Pdo\Sqlite::ATTR_EXPLAIN_STATEMENT,Pdo\Sqlite::EXPLAIN_MODE_EXPLAIN_QUERY_PLAN)){
        throw new RuntimeException('Не включён EXPLAIN QUERY PLAN.');
    }
    $statement->execute($params);$beforePlan=$statement->fetchAll(PDO::FETCH_ASSOC);
    p181Release($statement);
    $statement=$db->prepare($sql); // Новый обычный statement с собственными именами столбцов.
    $busyPrepared=$statement->getAttribute(Pdo\Sqlite::ATTR_BUSY_STATEMENT);
    $selectReadonly=$statement->getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT);
    $statement->execute($params);
    $first=$statement->fetch(PDO::FETCH_ASSOC);
    $busyAfterFirst=$statement->getAttribute(Pdo\Sqlite::ATTR_BUSY_STATEMENT);
    $orders=[$first,...$statement->fetchAll(PDO::FETCH_ASSOC)];
    $statement->closeCursor();
    $busyAfterClose=$statement->getAttribute(Pdo\Sqlite::ATTR_BUSY_STATEMENT);
    p181Release($statement);
    $indexName=p181OwnIndex($db,$owned,'order_window',$tableName,'status,created_at,id');
    $statement=$db->prepare($sql);
    $statement->setAttribute(Pdo\Sqlite::ATTR_EXPLAIN_STATEMENT,Pdo\Sqlite::EXPLAIN_MODE_EXPLAIN_QUERY_PLAN);
    $statement->execute($params);$afterPlan=$statement->fetchAll(PDO::FETCH_ASSOC);
    p181Release($statement);
    $statement=$db->prepare($sql);
    $statement->execute($params);$afterOrders=$statement->fetchAll(PDO::FETCH_ASSOC);
    p181Release($statement);
    $update=$db->prepare("UPDATE $table SET status='CLOSED' WHERE id=?");
    $updateReadonly=$update->getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT);
    p181Release($update); // UPDATE только подготовлен, не выполнялся.
    $beforeText=implode("\n",array_column($beforePlan,'detail'));
    $afterText=implode("\n",array_column($afterPlan,'detail'));
    $beforeUsesIndex=str_contains($beforeText,$indexName);
    $afterUsesIndex=str_contains($afterText,$indexName);
    $artifact=['selection'=>['status'=>'OPEN','from'=>'2026-11-01','until'=>'2026-12-01','limit'=>3],
        'orders'=>$orders,'total_cents'=>array_sum(array_column($orders,'total_cents')),
        'plan_comparison'=>['before_uses_owned_index'=>$beforeUsesIndex,'after_uses_owned_index'=>$afterUsesIndex],
        'statement'=>['busy_prepared'=>$busyPrepared,'busy_after_first_fetch'=>$busyAfterFirst,'busy_after_close'=>$busyAfterClose,
            'select_readonly'=>$selectReadonly,'update_readonly'=>$updateReadonly]];
    file_put_contents(__DIR__.'/query-plan.json',json_encode($artifact,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n");
    $checks=['selected_window'=>array_column($orders,'id')===[101,103,106]&&count($orders)===3&&$artifact['total_cents']===4300,
        'same_rows_after_index'=>$afterOrders===$orders,
        'plan_observed'=>$beforePlan!==[]&&$afterPlan!==[]&&!$beforeUsesIndex&&$afterUsesIndex,
        'statement_lifetime'=>$busyPrepared===false&&$busyAfterFirst===true&&$busyAfterClose===false,
        'readonly_scope'=>$selectReadonly===true&&$updateReadonly===false];
    $rows=[['Заказ 101',$orders[0]['title'].' / '.$orders[0]['created_at'].' / '.$orders[0]['total_cents']],
        ['Заказ 103',$orders[1]['title'].' / '.$orders[1]['created_at'].' / '.$orders[1]['total_cents']],
        ['Заказ 106',$orders[2]['title'].' / '.$orders[2]['created_at'].' / '.$orders[2]['total_cents']],
        ['Итого, копеек',$artifact['total_cents']],['Собственный индекс в плане до создания',$beforeUsesIndex],
        ['Собственный индекс в плане после создания',$afterUsesIndex],['BUSY после первой строки',$busyAfterFirst],
        ['BUSY после closeCursor',$busyAfterClose],['SELECT readonly',$selectReadonly],['Подготовленный UPDATE readonly',$updateReadonly],['Файл','query-plan.json']];
    $normalize=static function(array $plan) use($tableName,$indexName):array{
        foreach($plan as &$node){$node['detail']=str_replace([$tableName,$indexName],['orders','order_window'],$node['detail']);}
        unset($node);return $plan;
    };
    $observations=['selected_orders'=>$orders,'before_plan'=>$normalize($beforePlan),'after_plan'=>$normalize($afterPlan),
        'artifact'=>$artifact,'plan_scope'=>'Observed on the stated SQLite version; no timing or cross-version plan-text promise'];

} finally {
p181Release($statement); p181Release($update);
    p181Close($db);
    if($database!==null){[$removed,$removedFiles]=p181Cleanup($database,$owned,$ownedFiles);}
}

p181Report('pdo181-query-plan','Выбрать три заказа и проверить роль собственного индекса',$rows,$checks,$observations,$runtime,$owned,$removed,$ownedFiles,$removedFiles);
Ожидаемый результат
Заказ 101 — Кабель / 2026-11-02 / 1500
Заказ 103 — Хомут / 2026-11-08 / 800
Заказ 106 — Коробка / 2026-11-09 / 2000
Итого, копеек — 4300
Собственный индекс в плане до создания — нет
Собственный индекс в плане после создания — да
BUSY после первой строки — да
BUSY после closeCursor — нет
SELECT readonly — да
Подготовленный UPDATE readonly — нет
Файл — query-plan.json

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать контракт запроса и выбранные заказы

Границы решения

Шесть строк нужны для прозрачного воспроизводимого примера, а не измерения ускорения. В проверенной сборке PHP 8.5.11 переключение того же statement из EXPLAIN обратно в PREPARED сохранило имена id/parent/notused/detail, когда обычный SELECT тоже вернул четыре столбца. Значения уже относились к заказам, но ассоциативные ключи остались от плана. Поэтому проект освобождает statement плана и заново подготавливает обычный SELECT; успешная смена режима сама по себе не доказывает обновление PDO-метаданных. Планировщик выбирает стратегию по запросу, данным, статистике и версии SQLite; формат detail не является стабильным машинным API. В этой лаборатории проверяется наличие имени созданного индекса, не точный текст плана для будущих версий. Индекс требует места и обслуживания при записи. READONLY_STATEMENT говорит о прямой записи statement в файл БД, но пользовательский callback может иметь другие эффекты. Это не проверка прав посетителя и не разрешение запускать произвольный SQL. Только этот проект сохраняет собственный рабочий подкаталог: таблица, индекс, файл базы и sidecars удаляются с проверками, а пустой каталог остаётся после выхода PHP. Проверка запуска отдельно подтверждает его пустоту; предыдущие каталоги программа не удаляет. Это явный договор очистки, без утверждения о причине прежнего временного отказа rmdir.

Попробуйте изменить условие

Нужны три последних открытых заказа, а не первых. Какие две части изменятся, и почему нельзя просто перевернуть полученный PHP-массив?

Разбор

Поменяйте ORDER BY на created_at DESC,id DESC и ожидания выбранных id. LIMIT применяется после SQL-порядка: переворот уже ограниченных первых трёх строк не превращает их в последние три. Снова прочитайте план для нового запроса; не делайте вывод об ускорении по самому наличию индекса.

Проверить границы и выбрать следующий шаг

Три проекта отвечают на разные вопросы. Если надо сохранить остаток — важны условный UPDATE и граница транзакции. Если надо сравнить части отчёта — важен срок снимка. Если надо понять путь выборки — нужен план с теми же параметрами и фактическими строками. Само наличие CSV, индекса или WAL не доказывает корректность остальных двух задач.

СитуацияПроверка и действие
BEGIN получил busyСохранить SQLSTATE/код и проверить inTransaction. Не считать, что UPDATE уже выполнился; новая попытка начинается отдельным решением.
Прочитана одна строка, файл не освобождаетсяЗакрыть нужный statement, затем завершить явную транзакцию и убрать оставшиеся ссылки; искать владельца, а не добавлять случайный sleep.
Писатель commit, читатель видит староеПроверить, осталась ли транзакция читателя. Для свежего состояния закончить её и открыть новое чтение.
Режим плана недоступенПроверить PHP, libsqlite и compiled constants; не выдавать пустой список за успешный EXPLAIN.
Изменился текст планаПроверить версию/данные/индексы и сам выбранный результат. Не сравнивать весь detail как долговечный контракт.
Резерв надо повторить из HTTPОтдельно определить идемпотентность заявки и ответ посетителю. Локальный busy timeout не решает повтор сетевого запроса.

Следующий шаг: добавьте к резерву собственный номер заявки и сохраните его в одной транзакции с остатком. На повторную внешнюю команду возвращайте уже сохранённую квитанцию. Для отчёта измените период и предел SQL, сохранив ясное правило порядка. Для вложения перейдите к порционной работе с BLOB, где поток тоже нужно закрыть до окончания операции. Все старые карточки PDO и SQLite остаются самостоятельной справкой.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.