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

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

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

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

Глава 54 · После файлов, параметризованного SQLSRV и чтения полей результата

SQLSRV: вложения потоком, проверка целостности и повторная отправка

Акт и журнал сохраняются как точные байты: автоматическая и пошаговая отправка, повторное использование потока, SHA-256 и различие NULL/пустого файла.

Прикрепить файл и понять, что именно передаётся

Нужно приложить к заявке акт, сохранить журнал устройства или выдать сохранённый документ. Важно получить тот же файл целиком, различать отсутствие и пустой файл, а при повторной отправке понимать позицию источника. Поток позволяет читать следующие байты порциями; это средство работы с данными, а не особый формат файла.

Файл → SQL Server → проверяемое вложение
  1. Файл на диске → fopen(rb) → ресурс с текущей позицией
  2. Параметр SQLSRV STREAM(BINARY) → столбец VARBINARY(MAX)
  3. SELECT конкретного id → get_field(STREAM(BINARY))
  4. Чтение порциями → счётчик байтов и SHA-256 → карточка документа

Предварительно пройдите подключение SQLSRV, параметризованный запрос и работу с файлами. В коде db — соединение, stmt — ресурс команды, input — файловый поток; это три разных ресурса. Таблица q создаётся только для учебной программы. Примеры работают с уже существующими локальными файлами из архива, а не принимают пользовательский путь из запроса.

Увидеть поток до знакомства с базой

$input = fopen(__DIR__ . "/attachment.txt", "rb");
if ($input === false) { throw new RuntimeException("Файл не открыт"); }
try {
    $firstBytes = fread($input, 4);
    // Это первые четыре байта, а не обязательно четыре буквы UTF-8.
    rewind($input); // Вернуть позицию в начало перед отправкой целиком.
} finally {
    fclose($input);
}

fopen открывает доступ, fread читает порцию и двигает позицию, rewind возвращает её в начало, fclose закрывает доступ. Поток напоминает рулон с отметкой места чтения, но аналогия не обещает возможность перемотать любой источник: для сети она может отсутствовать. Байт — единица хранимых данных; русская буква UTF-8 обычно занимает несколько байтов. Бинарный режим означает сохранение байтов; он подходит и текстовому файлу, если нужно сохранить его как вложение без перекодировки.

Что требуетсяПодход
Обычная запись локального файлаПередать открытый поток параметром; автоматическая отправка по умолчанию.
Управлять шагами отправкиSendStreamParamsAtExec=false и цикл sqlsrv_send_stream_data с проверкой окончания.
Повторить или заменить вложениеЗадать позицию источника, сохранить ссылки параметров, открыть новый поток при замене.
Показать «файл не приложен» отдельно от пустогоХранить и читать NULL как отдельное состояние, не подменять пустой строкой.

VARBINARY(MAX) хранит байты; это не обещание неограниченного размера и не автоматический выбор лучшего хранилища для любого сервиса. Маленькие учебные файлы позволяют увидеть весь путь; большой файл читают порциями без полного накопления в строку PHP. На сервере выбирается только нужное вложение по id, а списку карточек обычно нужны имя и размер без payload.

Сохранить акт осмотра и прочитать те же байты из базы

К карточке осмотра нужно прикрепить акт и позже получить его без потери символов или обрезки. Файл уже лежит на диске: откроем его, передадим поток в VARBINARY(MAX), прочитаем обратно и покажем текст, длину и контрольную сумму. В этой задаче база хранит точные байты файла; она не разбирает документ на строки SQL.

Путь данных в этом примере
  1. attachment.txt → fopen(rb): открытый поток, не текст целиком
  2. query с параметром STREAM(BINARY) → VARBINARY(MAX)
  3. SELECT только filename,признак NULL,размер,payload WHERE id=1
  4. get_field: метаданные0–2, затем поток3 → fread по4096байт → SHA-256
  5. Квитанция: акт виден, размер и SHA-256 совпадают; ресурсы закрыты

Сначала небольшой вызов

$input = fopen(__DIR__ . "/attachment.txt", "rb");
// Проверить input !== false.
$params = [[$input, SQLSRV_PARAM_IN,
    SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY), SQLSRV_SQLTYPE_VARBINARY("max")]];
$stmt = sqlsrv_query($db, "UPDATE $q SET payload = ? WHERE id = 1", $params);
// По умолчанию поток отправляет сам query. Проверить stmt, затем закрыть ресурсы.

Поток — открытый источник, из которого читают следующие байты порциями. Переменная input содержит ресурс доступа, а не весь файл. rb означает чтение в бинарном режиме: на Windows это важно для сохранения байтов без текстового преобразования переводов строк. В параметре указаны направление IN, PHP-поток с бинарной кодировкой и SQL-тип VARBINARY(MAX). Вопросительный знак принимает значение параметра, не имя таблицы. Автоматическая отправка не означает, что PHP сначала сделал file_get_contents всего файла. Для readback функция readAttachment151 читает поля по порядку, считает длину и SHA-256 и накапливает текст только для маленького учебного акта.

Что получится

НаблюдениеРезультат
Файл в карточкеattachment.txt
Байт сохранено и прочитано87
Содержимое актаАкт осмотра №151 | Объект: склад | Проверено: 3 линии
SHA-256 прочитанного файла8f604ecbd03a06be01e1a8b38edfb5ee4316a6758d87ec9a8eccadaebe72377e
Размер и SHA-256 совпали с исходникомда
Сохранить акт осмотра и прочитать те же байты из базыСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null;$owned=$removed=$rows=$checks=$observations=[];
$runtime=s151Runtime();
try {

    $config=s151Config();$db=s151Connect($config);$runtime=s151Runtime($db,$config);

    function readAttachment151($db,string $q,int $id,bool $showKnownUtf8Text=false): array {
        $stmt=s151Query($db,"SELECT filename,CASE WHEN payload IS NULL THEN 1 ELSE 0 END AS missing,CAST(DATALENGTH(payload) AS int) AS byte_count,payload FROM $q WHERE id = ?",[$id]);
        $blob=null;
        try {
            $state=sqlsrv_fetch($stmt);
            if ($state===false) {s151Failure('Чтение вложения');}
            if ($state===null) {throw new RuntimeException('Вложение с заданным id отсутствует.');}
            $filename=sqlsrv_get_field($stmt,0);
            if ($filename===false) {s151Failure('Имя вложения');}
            $missing=sqlsrv_get_field($stmt,1);
            if ($missing===false) {s151Failure('Признак NULL');}
            $sqlBytes=sqlsrv_get_field($stmt,2);
            if ($sqlBytes===false) {s151Failure('Размер вложения');}
            // A missing attachment is represented explicitly, not turned into an empty stream.
            if ($missing===1) {
                if ($sqlBytes!==null) {throw new RuntimeException('У SQL NULL ожидался NULL-размер.');}
                return ['filename'=>$filename,'is_null'=>true,'bytes'=>null,'sha256'=>null,'preview'=>null];
            }
            $blob=sqlsrv_get_field($stmt,3,SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY));
            if ($blob===false) {s151Failure('Получение бинарного потока');}
            if (!is_resource($blob)) {throw new RuntimeException('Для существующего SQL-значения ожидался поток.');}
            $hash=hash_init('sha256');$bytes=0;$preview='';
            while (!feof($blob)) {
                $chunk=fread($blob,4096);
                if ($chunk===false) {throw new RuntimeException('Не удалось прочитать поток вложения.');}
                if ($chunk==='') {
                    if (feof($blob)) {break;}
                    throw new RuntimeException('Чтение потока не продвинулось.');
                }
                $bytes+=strlen($chunk);hash_update($hash,$chunk);
                // Enabled only for known UTF-8 text fixtures, never guessed from binary size.
                if ($showKnownUtf8Text && $sqlBytes<=256) {$preview.=$chunk;}
            }
            $readErrors=sqlsrv_errors(SQLSRV_ERR_ERRORS);
            if ($readErrors!==null) {throw new RuntimeException('SQLSRV сообщил об ошибке чтения потока.');}
            if ($bytes!==$sqlBytes) {throw new RuntimeException('Прочитанный размер отличается от DATALENGTH.');}
            return ['filename'=>$filename,'is_null'=>false,'bytes'=>$bytes,'sha256'=>hash_final($hash),'preview'=>$preview];
        } finally {
            if (is_resource($blob)) {fclose($blob);}
            sqlsrv_free_stmt($stmt);
        }
    }

    $name=s151OwnTable($db,$owned,'auto','(id int NOT NULL PRIMARY KEY, filename nvarchar(80) NOT NULL, payload varbinary(max) NULL)');
    $q=s151Qualified($name);
    $path=__DIR__.'/attachment.txt';
    $expectedBytes=filesize($path);$expectedHash=hash_file('sha256',$path);
    if ($expectedBytes===false || $expectedHash===false) {throw new RuntimeException('Не удалось прочитать учебный файл.');}
    $input=fopen($path,'rb');
    if ($input===false) {throw new RuntimeException('Не удалось открыть attachment.txt.');}
    try {
        $params=[1,'attachment.txt',[$input,SQLSRV_PARAM_IN,SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY),SQLSRV_SQLTYPE_VARBINARY('max')]];
        // Default SendStreamParamsAtExec=true: query sends the stream itself.
        $insert=s151Query($db,"INSERT INTO $q (id,filename,payload) VALUES (?,?,?)",$params);
        sqlsrv_free_stmt($insert);
    } finally {fclose($input);}
    $saved=readAttachment151($db,$q,1,true);
    $rows=[['Файл в карточке',$saved['filename']],['Байт сохранено и прочитано',$saved['bytes']],
        ['Содержимое акта',str_replace("\n",' | ',trim($saved['preview']))],['SHA-256 прочитанного файла',$saved['sha256']],
        ['Размер и SHA-256 совпали с исходником',$saved['bytes']===$expectedBytes&&$saved['sha256']===$expectedHash]];
    $checks=['stored_filename'=>$saved['filename']==='attachment.txt','not_null'=>$saved['is_null']===false,
        'byte_length'=>$saved['bytes']===$expectedBytes,'hash_equal'=>$saved['sha256']===$expectedHash,
        'visible_text'=>$saved['preview']==="Акт осмотра №151\nОбъект: склад\nПроверено: 3 линии\n",'input_closed'=>!is_resource($input)];
    $observations=['attachment'=>$saved,'input_bytes'=>$expectedBytes,'input_sha256'=>$expectedHash,'send_mode'=>'automatic'];

} finally {
    if (is_resource($db)) {try {$removed=s151Cleanup($db,$owned);} finally {sqlsrv_close($db);}}
}

s151Report('sqlsrv151-stream-automatic','Сохранить акт осмотра и прочитать те же байты из базы',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Файл в карточке — attachment.txt
Байт сохранено и прочитано — 87
Содержимое акта — Акт осмотра №151 | Объект: склад | Проверено: 3 линии
SHA-256 прочитанного файла — 8f604ecbd03a06be01e1a8b38edfb5ee4316a6758d87ec9a8eccadaebe72377e
Размер и SHA-256 совпали с исходником — да

Локальная программа включает исходные текстовые файлы, lab.php и README.txt. Она создаёт одну собственную таблицу SQL Server, сохраняет result.html/result.json/metrics.json и удаляет свою таблицу. Исходные файлы остаются рядом для сравнения.

Фактический результат запуска · Данные результата JSON.

Ограничения и ошибки

Не передавайте путь к файлу вместо открытого ресурса и не закрывайте input до окончания отправки. На чтении не возвращайтесь к столбцу имени после получения бинарного потока; сначала метаданные, затем поток. SHA-256 здесь подтверждает одинаковые байты, а не доверие к отправителю. Для больших вложений сравните хранение в базе и объектном/файловом хранилище: важны резервное копирование, размер базы, права и выдача. Это локальный сценарий передачи SQLSRV; загрузка файла из браузера, проверка MIME и авторизация рассматриваются отдельно.

Поток документа перестал быть пригодным для чтения после попытки вернуться к более раннему столбцу результата. Исходная ситуация на Stack Overflow. Сначала читать имя и метаданные по возрастанию индексов, затем бинарный столбец. Полностью прочитать и закрыть его поток до следующей строки или освобождения statement.

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

Замените в attachment.txt «3 линии» на «4 линии». Сначала предположите, что изменится в отчёте.

Как проверить решение

Текст и SHA-256 изменятся, длина останется прежней для замены одной цифры другой. Код сравнивает с новым исходником; статическое ожидание учебного текста тоже следует обновить. Совпадение размера без совпадения SHA-256 недостаточно для проверки содержимого.

Отправить журнал порциями и подтвердить полный файл

У устройства накопился журнал из 600 событий. При явной отправке приложение само вызывает очередной шаг, а после завершения проверяет, что в базе весь журнал. В учебном файле больше 8 КБ: одного шага недостаточно. Отчёт показывает фактическую длину и SHA-256; число вызовов сохранено отдельно как наблюдение, а не как обещанный процент загрузки.

Путь данных в этом примере
  1. dispatch-log.txt:600записей,24600байт
  2. query с SendStreamParamsAtExec=false → statement ожидает отправки параметра
  3. send_stream_data → передать до8КБ; true → есть ещё данные
  4. SQLSRV5.13.3: null → закончено; false → ошибка
  5. SELECT payload потоком → размер и SHA-256 совпали с файлом

Сначала небольшой вызов

while (true) {
    $more = sqlsrv_send_stream_data($stmt);
    if ($more === false) {
        $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
        throw new RuntimeException("Ошибка передачи");
    }
    if ($more === null) { break; }
}

SendStreamParamsAtExec=false выключает автоматическую отправку параметров-потоков при выполнении. Ресурс stmt уже существует, но файл ещё нужно передать. В проверенном SQLSRV 5.13.3 один вызов отправляет до 8 КБ; true означает продолжение, null — нормальное завершение, false — ошибку. Короткое описание PHP.net/Microsoft говорит о bool и не отражает этот null; полный контракт подтверждён исходниками версии и запуском. Поэтому цикл явно разделяет три состояния. Перед сообщением «готово» проверяются диагностика и фактически сохранённое содержимое; значение more не является числом байтов.

Что получится

НаблюдениеРезультат
Файл журналаdispatch-log.txt
Байт в журнале24600
Байт прочитано из базы24600
Для отправки потребовалось несколько вызововда
Завершающий null без ошибкида
SHA-256 прочитанного журнала2c500d864816bedf0130a090f3e2611886cbcb8be7f127e60bb5258bde06fa8b
Журнал сохранился полностьюда
Отправить журнал порциями и подтвердить полный файлСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null;$owned=$removed=$rows=$checks=$observations=[];
$runtime=s151Runtime();
try {

    $config=s151Config();$db=s151Connect($config);$runtime=s151Runtime($db,$config);

    function readAttachment151($db,string $q,int $id,bool $showKnownUtf8Text=false): array {
        $stmt=s151Query($db,"SELECT filename,CASE WHEN payload IS NULL THEN 1 ELSE 0 END AS missing,CAST(DATALENGTH(payload) AS int) AS byte_count,payload FROM $q WHERE id = ?",[$id]);
        $blob=null;
        try {
            $state=sqlsrv_fetch($stmt);
            if ($state===false) {s151Failure('Чтение вложения');}
            if ($state===null) {throw new RuntimeException('Вложение с заданным id отсутствует.');}
            $filename=sqlsrv_get_field($stmt,0);
            if ($filename===false) {s151Failure('Имя вложения');}
            $missing=sqlsrv_get_field($stmt,1);
            if ($missing===false) {s151Failure('Признак NULL');}
            $sqlBytes=sqlsrv_get_field($stmt,2);
            if ($sqlBytes===false) {s151Failure('Размер вложения');}
            // A missing attachment is represented explicitly, not turned into an empty stream.
            if ($missing===1) {
                if ($sqlBytes!==null) {throw new RuntimeException('У SQL NULL ожидался NULL-размер.');}
                return ['filename'=>$filename,'is_null'=>true,'bytes'=>null,'sha256'=>null,'preview'=>null];
            }
            $blob=sqlsrv_get_field($stmt,3,SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY));
            if ($blob===false) {s151Failure('Получение бинарного потока');}
            if (!is_resource($blob)) {throw new RuntimeException('Для существующего SQL-значения ожидался поток.');}
            $hash=hash_init('sha256');$bytes=0;$preview='';
            while (!feof($blob)) {
                $chunk=fread($blob,4096);
                if ($chunk===false) {throw new RuntimeException('Не удалось прочитать поток вложения.');}
                if ($chunk==='') {
                    if (feof($blob)) {break;}
                    throw new RuntimeException('Чтение потока не продвинулось.');
                }
                $bytes+=strlen($chunk);hash_update($hash,$chunk);
                // Enabled only for known UTF-8 text fixtures, never guessed from binary size.
                if ($showKnownUtf8Text && $sqlBytes<=256) {$preview.=$chunk;}
            }
            $readErrors=sqlsrv_errors(SQLSRV_ERR_ERRORS);
            if ($readErrors!==null) {throw new RuntimeException('SQLSRV сообщил об ошибке чтения потока.');}
            if ($bytes!==$sqlBytes) {throw new RuntimeException('Прочитанный размер отличается от DATALENGTH.');}
            return ['filename'=>$filename,'is_null'=>false,'bytes'=>$bytes,'sha256'=>hash_final($hash),'preview'=>$preview];
        } finally {
            if (is_resource($blob)) {fclose($blob);}
            sqlsrv_free_stmt($stmt);
        }
    }

    $name=s151OwnTable($db,$owned,'chunks','(id int NOT NULL PRIMARY KEY, filename nvarchar(80) NOT NULL, payload varbinary(max) NULL)');
    $q=s151Qualified($name);$path=__DIR__.'/dispatch-log.txt';
    $expectedBytes=filesize($path);$expectedHash=hash_file('sha256',$path);
    if ($expectedBytes===false || $expectedHash===false) {throw new RuntimeException('Не удалось прочитать журнал.');}
    $input=fopen($path,'rb');
    if ($input===false) {throw new RuntimeException('Не удалось открыть журнал.');}
    $stmt=null;$calls=0;$moreCalls=0;$finalErrors=null;
    try {
        $params=[1,'dispatch-log.txt',[$input,SQLSRV_PARAM_IN,SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY),SQLSRV_SQLTYPE_VARBINARY('max')]];
        $stmt=s151Query($db,"INSERT INTO $q (id,filename,payload) VALUES (?,?,?)",$params,['SendStreamParamsAtExec'=>false]);
        while (true) {
            $more=sqlsrv_send_stream_data($stmt);$calls++;
            if ($more===false) {
                // SQLSRV 5.13.3: false is failure, null is normal completion.
                $finalErrors=sqlsrv_errors(SQLSRV_ERR_ERRORS);
                throw new RuntimeException('Поток не отправлен: '.json_encode(s151Diagnostics($finalErrors)));
            }
            if ($more===null) {$finalErrors=sqlsrv_errors(SQLSRV_ERR_ERRORS);break;}
            $moreCalls++;
        }
    } finally {
        if (is_resource($stmt)) {sqlsrv_free_stmt($stmt);}
        fclose($input);
    }
    $saved=readAttachment151($db,$q,1);
    $rows=[['Файл журнала',$saved['filename']],['Байт в журнале',$expectedBytes],['Байт прочитано из базы',$saved['bytes']],
        ['Для отправки потребовалось несколько вызовов',$calls>1&&$moreCalls>0],['Завершающий null без ошибки',$more===null&&$finalErrors===null],
        ['SHA-256 прочитанного журнала',$saved['sha256']],['Журнал сохранился полностью',$saved['bytes']===$expectedBytes&&$saved['sha256']===$expectedHash]];
    $checks=['fixture_larger_than_chunk'=>$expectedBytes>8192,'multiple_send_calls'=>$calls>1&&$moreCalls>0,
        'terminal_null_clean'=>$more===null&&$finalErrors===null,'byte_length'=>$saved['bytes']===$expectedBytes,
        'hash_equal'=>$saved['sha256']===$expectedHash,'large_body_not_accumulated'=>$saved['preview']==='',
        'input_closed'=>!is_resource($input)];
    $observations=['attachment'=>$saved,'send_calls'=>$calls,'true_returns'=>$moreCalls,'final_errors'=>s151Diagnostics($finalErrors),
        'input_bytes'=>$expectedBytes,'input_sha256'=>$expectedHash,'send_mode'=>'explicit_chunks'];

} finally {
    if (is_resource($db)) {try {$removed=s151Cleanup($db,$owned);} finally {sqlsrv_close($db);}}
}

s151Report('sqlsrv151-stream-chunks','Отправить журнал порциями и подтвердить полный файл',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Файл журнала — dispatch-log.txt
Байт в журнале — 24600
Байт прочитано из базы — 24600
Для отправки потребовалось несколько вызовов — да
Завершающий null без ошибки — да
SHA-256 прочитанного журнала — 2c500d864816bedf0130a090f3e2611886cbcb8be7f127e60bb5258bde06fa8b
Журнал сохранился полностью — да

Локальная программа включает исходные текстовые файлы, lab.php и README.txt. Она создаёт одну собственную таблицу SQL Server, сохраняет result.html/result.json/metrics.json и удаляет свою таблицу. Исходные файлы остаются рядом для сравнения.

Фактический результат запуска · Данные результата JSON.

Ограничения и ошибки

Не вычисляйте прогресс как calls×8192: порции могут быть меньше, а завершающий вызов тоже участвует в управлении протоколом. При нескольких потоковых параметрах функция обслуживает их через statement; это не отдельный запрос INSERT на каждую порцию. false — отказ, даже если первое выполнение команды было успешным. Не объявляйте переданным весь файл по одному query с отключённой автоматической отправкой. Читать большой payload через stream_get_contents целиком означало бы снова выделить память под весь файл; здесь хеш считается постепенно.

Разработчик опасается, что драйвер обрезает бинарное значение, и пробует уменьшить тип поля, теряя часть данных. Исходная ситуация на Stack Overflow. Не обрезать файл ради исчезновения ошибки. Здесь выбран VARBINARY(MAX), а сохранность подтверждается серверным DATALENGTH и SHA-256 прочитанного потока. Историческая ошибка старого драйвера не объявляется текущим ограничением в 4000 или 8000 байт.

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

Увеличьте журнал до 1200 строк, повторив генерацию строк с новыми номерами. Какие показатели должны измениться, а какие правила останутся теми же?

Как проверить решение

Увеличатся длина и обычно число шагов отправки, изменится SHA-256. Строгой формулы количества вызовов этот пример не обещает. Проверка должна подтвердить фактический новый размер и хеш; до окончания передачи нельзя закрывать поток или читать квитанцию.

Повторно отправить файл и заменить поток в подготовленной команде

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

Путь данных в этом примере
  1. original.txt → hash_update_stream → позиция в конце
  2. rewind(input) → позиция0 → execute id1
  3. rewind(input) → execute id2: тот же полный документ
  4. input=новый fopen(replacement.txt); id3 → execute использует связанную переменную
  5. Прочитать три отдельных вложения: два одинаковых SHA-256, третий другой

Сначала небольшой вызов

if (rewind($input) === false) {
    throw new RuntimeException("Нельзя вернуть поток в начало");
}
$id = 2;
// stmt был подготовлен с &$id и &$input.
if (sqlsrv_execute($stmt) === false) {
    $errors = sqlsrv_errors();
}

У файла есть содержимое, а у открытого потока ещё и текущая позиция. rewind ставит её в начало; ftell показывает смещение в байтах. hash_update_stream читает источник с текущего места, поэтому после хеширования нужен осознанный возврат к началу для повторного использования. Знак & в массиве параметров связывает statement с переменными id, filename и input. Присваивание нового fopen в input меняет ресурс для следующего execute. Подготовка выполняется один раз, но каждое execute создаёт отдельную строку в таблице.

Что получится

НаблюдениеРезультат
Позиция после подсчёта хеша46
Позиция после rewind0
Первая копияАкт №151-A: принято 3 линии.
Повторная копияАкт №151-A: принято 3 линии.
Заменённое вложениеАкт №151-B: принято 4 линии.
Обе отправки исходного файла совпалида
Связанная переменная приняла новый потокда
Повторно отправить файл и заменить поток в подготовленной командеСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null;$owned=$removed=$rows=$checks=$observations=[];
$runtime=s151Runtime();
try {

    $config=s151Config();$db=s151Connect($config);$runtime=s151Runtime($db,$config);

    function readAttachment151($db,string $q,int $id,bool $showKnownUtf8Text=false): array {
        $stmt=s151Query($db,"SELECT filename,CASE WHEN payload IS NULL THEN 1 ELSE 0 END AS missing,CAST(DATALENGTH(payload) AS int) AS byte_count,payload FROM $q WHERE id = ?",[$id]);
        $blob=null;
        try {
            $state=sqlsrv_fetch($stmt);
            if ($state===false) {s151Failure('Чтение вложения');}
            if ($state===null) {throw new RuntimeException('Вложение с заданным id отсутствует.');}
            $filename=sqlsrv_get_field($stmt,0);
            if ($filename===false) {s151Failure('Имя вложения');}
            $missing=sqlsrv_get_field($stmt,1);
            if ($missing===false) {s151Failure('Признак NULL');}
            $sqlBytes=sqlsrv_get_field($stmt,2);
            if ($sqlBytes===false) {s151Failure('Размер вложения');}
            // A missing attachment is represented explicitly, not turned into an empty stream.
            if ($missing===1) {
                if ($sqlBytes!==null) {throw new RuntimeException('У SQL NULL ожидался NULL-размер.');}
                return ['filename'=>$filename,'is_null'=>true,'bytes'=>null,'sha256'=>null,'preview'=>null];
            }
            $blob=sqlsrv_get_field($stmt,3,SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY));
            if ($blob===false) {s151Failure('Получение бинарного потока');}
            if (!is_resource($blob)) {throw new RuntimeException('Для существующего SQL-значения ожидался поток.');}
            $hash=hash_init('sha256');$bytes=0;$preview='';
            while (!feof($blob)) {
                $chunk=fread($blob,4096);
                if ($chunk===false) {throw new RuntimeException('Не удалось прочитать поток вложения.');}
                if ($chunk==='') {
                    if (feof($blob)) {break;}
                    throw new RuntimeException('Чтение потока не продвинулось.');
                }
                $bytes+=strlen($chunk);hash_update($hash,$chunk);
                // Enabled only for known UTF-8 text fixtures, never guessed from binary size.
                if ($showKnownUtf8Text && $sqlBytes<=256) {$preview.=$chunk;}
            }
            $readErrors=sqlsrv_errors(SQLSRV_ERR_ERRORS);
            if ($readErrors!==null) {throw new RuntimeException('SQLSRV сообщил об ошибке чтения потока.');}
            if ($bytes!==$sqlBytes) {throw new RuntimeException('Прочитанный размер отличается от DATALENGTH.');}
            return ['filename'=>$filename,'is_null'=>false,'bytes'=>$bytes,'sha256'=>hash_final($hash),'preview'=>$preview];
        } finally {
            if (is_resource($blob)) {fclose($blob);}
            sqlsrv_free_stmt($stmt);
        }
    }

    $name=s151OwnTable($db,$owned,'rewind','(id int NOT NULL PRIMARY KEY, filename nvarchar(80) NOT NULL, payload varbinary(max) NULL)');
    $q=s151Qualified($name);$input=fopen(__DIR__.'/original.txt','rb');
    if ($input===false) {throw new RuntimeException('Не удалось открыть исходный акт.');}
    $stmt=null;
    try {
        // Hashing consumes this same handle. Rewind before binding/sending its full contents.
        $hash=hash_init('sha256');$hashedBytes=hash_update_stream($hash,$input);$originalHash=hash_final($hash);
        $afterHash=ftell($input);
        if (rewind($input)===false) {throw new RuntimeException('Исходный поток нельзя перемотать.');}
        $beforeFirst=ftell($input);$id=1;$filename='original.txt';
        $params=[&$id,&$filename,[&$input,SQLSRV_PARAM_IN,SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY),SQLSRV_SQLTYPE_VARBINARY('max')]];
        $stmt=sqlsrv_prepare($db,"INSERT INTO $q (id,filename,payload) VALUES (?,?,?)",$params,['QueryTimeout'=>10]);
        if ($stmt===false) {s151Failure('Подготовка вложения');}
        if (sqlsrv_execute($stmt)===false) {s151Failure('Первая отправка');}
        if (rewind($input)===false) {throw new RuntimeException('Не удалось повторно перемотать акт.');}
        $id=2;$filename='copy.txt';
        if (sqlsrv_execute($stmt)===false) {s151Failure('Повторная отправка');}
        fclose($input);
        $input=fopen(__DIR__.'/replacement.txt','rb');
        if ($input===false) {throw new RuntimeException('Не удалось открыть замену.');}
        $id=3;$filename='replacement.txt';
        if (sqlsrv_execute($stmt)===false) {s151Failure('Отправка замены');}
    } finally {
        if (is_resource($stmt)) {sqlsrv_free_stmt($stmt);}
        if (is_resource($input)) {fclose($input);}
    }
    $first=readAttachment151($db,$q,1,true);$copy=readAttachment151($db,$q,2,true);$replacement=readAttachment151($db,$q,3,true);
    $replacementHash=hash_file('sha256',__DIR__.'/replacement.txt');
    $rows=[['Позиция после подсчёта хеша',$afterHash],['Позиция после rewind',$beforeFirst],
        ['Первая копия',trim($first['preview'])],['Повторная копия',trim($copy['preview'])],['Заменённое вложение',trim($replacement['preview'])],
        ['Обе отправки исходного файла совпали',$first['sha256']===$originalHash&&$copy['sha256']===$originalHash],
        ['Связанная переменная приняла новый поток',$replacement['sha256']===$replacementHash&&$replacement['sha256']!==$originalHash]];
    $checks=['hash_consumed_handle'=>$afterHash===$hashedBytes&&$hashedBytes>0,'rewind_position_zero'=>$beforeFirst===0,
        'first_complete'=>$first['bytes']===$hashedBytes&&$first['sha256']===$originalHash,
        'repeat_complete'=>$copy['bytes']===$hashedBytes&&$copy['sha256']===$originalHash,
        'replacement_complete'=>$replacement['sha256']===$replacementHash,'replacement_distinct'=>$replacement['sha256']!==$originalHash,
        'input_closed'=>!is_resource($input)];
    $observations=['first'=>$first,'copy'=>$copy,'replacement'=>$replacement,'after_hash_position'=>$afterHash,'rewound_position'=>$beforeFirst];

} finally {
    if (is_resource($db)) {try {$removed=s151Cleanup($db,$owned);} finally {sqlsrv_close($db);}}
}

s151Report('sqlsrv151-stream-rewind','Повторно отправить файл и заменить поток в подготовленной команде',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Позиция после подсчёта хеша — 46
Позиция после rewind — 0
Первая копия — Акт №151-A: принято 3 линии.
Повторная копия — Акт №151-A: принято 3 линии.
Заменённое вложение — Акт №151-B: принято 4 линии.
Обе отправки исходного файла совпали — да
Связанная переменная приняла новый поток — да

Локальная программа включает исходные текстовые файлы, lab.php и README.txt. Она создаёт одну собственную таблицу SQL Server, сохраняет result.html/result.json/metrics.json и удаляет свою таблицу. Исходные файлы остаются рядом для сравнения.

Фактический результат запуска · Данные результата JSON.

Ограничения и ошибки

Проверяйте результат rewind: сетевой поток или другое нестандартное хранилище может не поддерживать возврат. Тогда заново откройте воспроизводимый источник либо сохраните его во временный файл с ограничением размера. Не используйте закрытый ресурс, не меняйте связанный поток посреди незавершённой отправки. Повтор операции после сетевого сбоя — отдельная задача идемпотентности: копия файла в примере создаётся намеренно с новым id, а не маскируется как безопасный автоматический retry. Механизм SQLSRV может самостоятельно возвращать seekable-потоки в начало; этот пример задаёт позицию явно и проверяет конечные байты вместо зависимости от скрытой перемотки драйвера.

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

Перед третьим execute откройте original.txt вместо replacement.txt. Предскажите содержимое и сравнение хешей.

Как проверить решение

Все три вложения будут иметь одинаковые байты, хотя имя третьей записи пока останется replacement.txt. Имя — отдельное поле, оно не доказывает содержимое. Чтобы сценарий соответствовал новой задаче, измените также имя и ожидание distinct.

Различить отсутствующее вложение, пустой файл и документ

В карточке заявки три разных состояния: файл ещё не приложили; пользователь приложил существующий файл длиной 0; файл содержит документ. От них зависит сообщение человеку. Сохраним эти состояния раздельно и прочитаем их, не превращая NULL в пустую строку. Для NULL не существует контрольной суммы файла, у пустого файла есть SHA-256 нулевого содержимого.

Путь данных в этом примере
  1. id1: payload=NULL → missing1, DATALENGTH=NULL → «не прикреплено»
  2. id2: fopen(empty.txt) → 0байт в VARBINARY → missing0, DATALENGTH0
  3. id3: small-note.txt → реальные байты → «Вложение принято.»
  4. Сначала читать SQL-признак отсутствия, затем поток только для существующего файла
  5. Отчёт сохраняет null и0 как разные значения

Сначала небольшой вызов

$sql = "SELECT CASE WHEN payload IS NULL THEN 1 ELSE 0 END AS missing,
    CAST(DATALENGTH(payload) AS int) AS byte_count FROM $q WHERE id = ?";
$stmt = sqlsrv_query($db, $sql, [1]);
// Для отсутствующего вложения: missing=1, byte_count=null.
// Для пустого существующего файла: missing=0, byte_count=0.

SQL NULL означает отсутствие значения, а пустое VARBINARY содержит значение длиной 0 байт. CASE WHEN создаёт явный числовой признак для интерфейса. DATALENGTH считает байты, возвращая NULL для NULL. Для VARBINARY(MAX) это SQL bigint, который SQLSRV обычно возвращает строкой: в полном примере ограниченный размер приведён в SQL к int. readAttachment151 сначала читает признак и размер, затем получает бинарный поток только у существующего файла. Это существенно: в проверенном SQLSRV 5.13.3 явный запрос PHPTYPE_STREAM для SQL NULL возвращает пустой ресурс потока, поэтому по нулю прочитанных байтов нельзя восстановить отсутствие значения. SQL-признак сохраняет различие заранее.

Что получится

НаблюдениеРезультат
id1: состояниене прикреплено
id1: размер SQLnull
id2: состояниеесть пустой файл
id2: байт0
id2: SHA-256 пустого файлаe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
id3: содержимоеВложение принято.
id3: байт33
Различить отсутствующее вложение, пустой файл и документСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=$config=null;$owned=$removed=$rows=$checks=$observations=[];
$runtime=s151Runtime();
try {

    $config=s151Config();$db=s151Connect($config);$runtime=s151Runtime($db,$config);

    function readAttachment151($db,string $q,int $id,bool $showKnownUtf8Text=false): array {
        $stmt=s151Query($db,"SELECT filename,CASE WHEN payload IS NULL THEN 1 ELSE 0 END AS missing,CAST(DATALENGTH(payload) AS int) AS byte_count,payload FROM $q WHERE id = ?",[$id]);
        $blob=null;
        try {
            $state=sqlsrv_fetch($stmt);
            if ($state===false) {s151Failure('Чтение вложения');}
            if ($state===null) {throw new RuntimeException('Вложение с заданным id отсутствует.');}
            $filename=sqlsrv_get_field($stmt,0);
            if ($filename===false) {s151Failure('Имя вложения');}
            $missing=sqlsrv_get_field($stmt,1);
            if ($missing===false) {s151Failure('Признак NULL');}
            $sqlBytes=sqlsrv_get_field($stmt,2);
            if ($sqlBytes===false) {s151Failure('Размер вложения');}
            // A missing attachment is represented explicitly, not turned into an empty stream.
            if ($missing===1) {
                if ($sqlBytes!==null) {throw new RuntimeException('У SQL NULL ожидался NULL-размер.');}
                return ['filename'=>$filename,'is_null'=>true,'bytes'=>null,'sha256'=>null,'preview'=>null];
            }
            $blob=sqlsrv_get_field($stmt,3,SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY));
            if ($blob===false) {s151Failure('Получение бинарного потока');}
            if (!is_resource($blob)) {throw new RuntimeException('Для существующего SQL-значения ожидался поток.');}
            $hash=hash_init('sha256');$bytes=0;$preview='';
            while (!feof($blob)) {
                $chunk=fread($blob,4096);
                if ($chunk===false) {throw new RuntimeException('Не удалось прочитать поток вложения.');}
                if ($chunk==='') {
                    if (feof($blob)) {break;}
                    throw new RuntimeException('Чтение потока не продвинулось.');
                }
                $bytes+=strlen($chunk);hash_update($hash,$chunk);
                // Enabled only for known UTF-8 text fixtures, never guessed from binary size.
                if ($showKnownUtf8Text && $sqlBytes<=256) {$preview.=$chunk;}
            }
            $readErrors=sqlsrv_errors(SQLSRV_ERR_ERRORS);
            if ($readErrors!==null) {throw new RuntimeException('SQLSRV сообщил об ошибке чтения потока.');}
            if ($bytes!==$sqlBytes) {throw new RuntimeException('Прочитанный размер отличается от DATALENGTH.');}
            return ['filename'=>$filename,'is_null'=>false,'bytes'=>$bytes,'sha256'=>hash_final($hash),'preview'=>$preview];
        } finally {
            if (is_resource($blob)) {fclose($blob);}
            sqlsrv_free_stmt($stmt);
        }
    }

    $name=s151OwnTable($db,$owned,'empty','(id int NOT NULL PRIMARY KEY, filename nvarchar(80) NOT NULL, payload varbinary(max) NULL)');
    $q=s151Qualified($name);
    // Missing file is a business state. It is deliberately SQL NULL, not a fake stream.
    s151Command($db,"INSERT INTO $q (id,filename,payload) VALUES (1,N'not-attached',NULL)");
    foreach ([[2,'empty.txt'],[3,'small-note.txt']] as [$id,$filename]) {
        $input=fopen(__DIR__.'/'.$filename,'rb');
        if ($input===false) {throw new RuntimeException('Не удалось открыть учебный файл '.$filename);}
        try {
            $params=[$id,$filename,[$input,SQLSRV_PARAM_IN,SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY),SQLSRV_SQLTYPE_VARBINARY('max')]];
            $insert=s151Query($db,"INSERT INTO $q (id,filename,payload) VALUES (?,?,?)",$params);
            sqlsrv_free_stmt($insert);
        } finally {fclose($input);}
    }
    $absent=readAttachment151($db,$q,1);$empty=readAttachment151($db,$q,2);$note=readAttachment151($db,$q,3,true);
    $rows=[['id1: состояние',$absent['is_null']?'не прикреплено':'есть файл'],['id1: размер SQL',$absent['bytes']],
        ['id2: состояние',$empty['is_null']?'не прикреплено':'есть пустой файл'],['id2: байт',$empty['bytes']],
        ['id2: SHA-256 пустого файла',$empty['sha256']],['id3: содержимое',trim($note['preview'])],['id3: байт',$note['bytes']]];
    $checks=['sql_null_preserved'=>$absent['is_null']===true&&$absent['bytes']===null&&$absent['sha256']===null,
        'empty_is_not_null'=>$empty['is_null']===false,'empty_zero_bytes'=>$empty['bytes']===0,
        'empty_has_hash'=>$empty['sha256']===hash('sha256',''),'note_not_null'=>$note['is_null']===false,
        'note_bytes'=>$note['bytes']===filesize(__DIR__.'/small-note.txt'),'note_hash'=>$note['sha256']===hash_file('sha256',__DIR__.'/small-note.txt'),
        'note_visible'=>$note['preview']==="Вложение принято.\n"];
    $observations=['missing'=>$absent,'empty'=>$empty,'document'=>$note];

} finally {
    if (is_resource($db)) {try {$removed=s151Cleanup($db,$owned);} finally {sqlsrv_close($db);}}
}

s151Report('sqlsrv151-stream-empty-null','Различить отсутствующее вложение, пустой файл и документ',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
id1: состояние — не прикреплено
id1: размер SQL — null
id2: состояние — есть пустой файл
id2: байт — 0
id2: SHA-256 пустого файла — e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
id3: содержимое — Вложение принято.
id3: байт — 33

Локальная программа включает исходные текстовые файлы, lab.php и README.txt. Она создаёт одну собственную таблицу SQL Server, сохраняет result.html/result.json/metrics.json и удаляет свою таблицу. Исходные файлы остаются рядом для сравнения.

Фактический результат запуска · Данные результата JSON.

Ограничения и ошибки

Пустой файл не обязательно допустим по правилам продукта: можно отклонить его до записи понятным сообщением, но это бизнес-правило, не ошибка SQLSRV. Нельзя автоматически заменить NULL на пустую строку или считать любой ложный результат «не найдено». Этот пример проверяет SQL-значение, а не HTTP upload error: ошибки формы, размер загрузки и отсутствующий временный файл разбираются в главе загрузок.

В приложении после смены драйвера и слоя доступа SQL NULL стал восприниматься как пустая строка. Исходная ситуация на Stack Overflow. Проверять отдельный контракт отсутствия данных, не превращать любое значение в строку через поток. В примере SQL-признак IS NULL и DATALENGTH различают отсутствующее вложение, существующий пустой файл и содержимое.

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

Установите payload=NULL для id2 и выполните чтение. Что станет с размером и хешем?

Как проверить решение

Второй результат должен перейти в состояние «не прикреплено», размер и хеш станут null. SHA-256 пустого файла не подходит отсутствующему значению. Если интерфейс продолжает показывать «пустой файл», он потерял бизнес-состояние.

Функция отправки и соседние операции

ДействиеКуда перейти
Отправлять очередную порциюsqlsrv_send_stream_data
Открыть и читать файлФайлы и потоки PHP
Получить поток столбцаsqlsrv_get_field
Отменить незавершённую командуsqlsrv_cancel

От сохранённого файла к устойчивому сервису

Сохранность вложения определяется прочитанными байтами, а не именем файла или bool от первого вызова. Потоки должны оставаться открытыми до конца своей операции; порядок чтения столбцов и позиция файла — часть контракта. Для списка документов выбирайте метаданные без больших payload; содержимое получайте для конкретной карточки или скачивания.

Следующий шаг — отмена, тайм-аут и повторное использование statement. Если вложение и карточка должны сохраниться вместе, применяйте транзакцию. Веб-приём файла с формой, ограничениями и проверками начинается в главе загрузки файлов.

Функции и методы этой темы

Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.

sqlsrv_send_stream_dataПередавать поток параметра порциями и отдельно проверять завершение.

Полный контракт true/null/false проверен на SQLSRV 5.13.3; первая версия не указана в PHP Manual · sqlsrv

sqlsrv_send_stream_data(resource $stmt): bool|null

Явно отправить очередную порцию параметра-потока, когда автоматическая отправка выключена. Это нужно, например, при записи большого журнала устройства, когда приложение управляет шагами передачи и отдельно подтверждает сохранность файла.

Одна команда и несколько шагов отправки
  1. query/execute с SendStreamParamsAtExec=false
  2. statement остаётся ресурсом подготовленной/выполненной команды
  3. send_stream_data(stmt) отправляет до 8 КБ; true означает продолжение
  4. SQLSRV 5.13.3: null → готово; false → ошибка и немедленная диагностика
ЭлементСмысл
$stmtРесурс statement от sqlsrv_prepare или sqlsrv_query после запуска команды. Это не файловый поток и не bool результата sqlsrv_execute.
ПодготовкаПоток включён в параметры, а SendStreamParamsAtExec=false задан в options query/prepare. По умолчанию true, и query/execute отправляет данные сам.
trueУ драйвера остаются данные для отправки; нужно продолжить. Это не количество байтов и не утверждение, что отправлено ровно 8192 байта.
nullПроверенный SQLSRV 5.13.3 закончил отправку. Затем нужно подтвердить результат команды и содержимое.
falseОшибка. Сразу сохраните sqlsrv_errors, не выдавайте сообщение об успешной загрузке.

Функция принимает один параметр — ресурс команды. В проверенной версии 5.13.3 результат имеет три состояния true/null/false. Краткие страницы PHP.net и Microsoft описывают bool; отличие подтверждают исходники Microsoft 5.13.3, sqlsrv_send_stream_data и реальный запуск. Сигнатура карточки показывает этот полный контракт. Кроме того, PHP Manual упоминает ресурс от execute, но sqlsrv_execute возвращает bool: ресурс остаётся переменной, полученной от prepare. Не присваивайте результат execute обратно в stmt.

Журнал устройства больше одной порции

$stmt = sqlsrv_query($db, $sql, $params, ["SendStreamParamsAtExec" => false]);
if ($stmt === false) { throw new RuntimeException("Команда не началась"); }
while (true) {
    $more = sqlsrv_send_stream_data($stmt);
    if ($more === false) {
        $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
        throw new RuntimeException("Ошибка отправки");
    }
    if ($more === null) { break; }
}

Здесь SQL, поток и параметры готовятся заранее. Полный пример отправляет 600 событий и сверяет размер/SHA-256 прочитанного журнала. Само окончание цикла не подменяет эту квитанцию.

Повторная отправка через подготовленную команду

// stmt создан sqlsrv_prepare с &$input и SendStreamParamsAtExec=false.
if (rewind($input) === false) { throw new RuntimeException("Источник нельзя перемотать"); }
if (sqlsrv_execute($stmt) === false) { throw new RuntimeException("Выполнение не началось"); }
do {
    $more = sqlsrv_send_stream_data($stmt);
    if ($more === false) {
        $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
        throw new RuntimeException("Ошибка повторной отправки");
    }
} while ($more === true);
// Здесь null: отправка закончена. Проверить сохранённый файл.

Это составной фрагмент: он добавляет явные шаги к повторному выполнению. Самостоятельная программа показывает позицию потока, ссылки параметров и замену файла использует автоматическую отправку, чтобы отдельно объяснить повторное использование ресурса; цикл явной отправки полностью выполнен в соседнем сценарии журнала.

Границы и ошибки

Данные передаются через существующий statement, а не отдельными INSERT для каждого блока. Держите input открытым до завершения. Не выполняйте команду повторно и не освобождайте statement посреди передачи. Вызов с закрытым/неверным PHP-ресурсом может быть ошибкой PHP, а не обычным false с SQL-диагностикой. Число true не является процентом, максимум8КБ относится к одному вызову. Не нужен ручной цикл, если оставлен SendStreamParamsAtExec=true. Успешная передача параметра не заменяет commit транзакции, обработку последующих результатов и правила повторов при неоднозначном сетевом отказе.

Продолжение: prepare/execute и параметры, чтение бинарного поля, отмена и завершение команды, фиксация и откат.

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

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

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

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

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