Компьютерный форум OSzone.net  

Компьютерный форум OSzone.net (http://forum.oszone.net/index.php)
-   Скриптовые языки администрирования Windows (http://forum.oszone.net/forumdisplay.php?f=102)
-   -   [решено] Вывод части бинарного файла (http://forum.oszone.net/showthread.php?t=308494)

Anonymоus 27-11-2015 10:10 2579305

Вывод части бинарного файла
 
Приветствую уважаемых форумчан.
В ходе реализации RFC 7233 (HTTP Range Requests) для батника, запущенного в качестве CGI-скрипта, столкнулся с новой для себя задачей — вывести определённый диапазон байтов бинарного файла в stdout. Первым кандидатом на роль подходящего инструмента стал dd, и путём чтения мануалов и поиска на stackoverflow пришёл к вот этой строчке:
Код:

dd if="%TargetFile%" ibs=1 skip=%~1 count=%~2
где соответственно, %1 и %2 - смещение от начала файла и размер фрагмента в байтах. Проблема в том, что при этом dd работает с размером блока, равным одному байту (а мне именно такая точность и требуется), что совершенно неприемлемо по скорости работы и нагрузке на процессор.
Прошу подсказать более подходящий инструмент для этой задачи, или объяснить что я делаю не так, в случае если неправильно использую dd.

Foreigner 27-11-2015 10:27 2579310

Обязательно батник? В powershell решается без dd:
Код:

$skip = 100
$count = 150
$file = 'C:\path\to\file'

(get-content $file -encoding byte)[$skip..($skip+$count)]  # Медленный способ
[io.file]::readallbytes($file)[$skip..($skip+$count)]              # Оптимальный способ


Anonymоus 27-11-2015 10:56 2579317

Если возможно, хотелось бы всё же избежать использования powershell, не в последнюю очередь оттого, что я с ним почти не знаком и не смогу полноценно интегрировать предложенное решение в свой скрипт. Ваш пример я так и не смог запустить с бинарным файлом (не выводит ничего), а с текстовым показывает ряд чисел вместо фрагмента содержимого. Интересует решение именно для батника, со сторонними утилитами.

Foreigner 27-11-2015 14:20 2579375

Не совсем понятно зачем выводить по одному байту.
Код:

dd if=inputfile.bin skip=1000 count=1 bs=10240

:: 10240 bytes (10 kB) copied, 1,436 seconds


greg zakharov 27-11-2015 15:38 2579395

Не знаю сколь много считывать, но как вариант:
Код:

@echo off
  setlocal enabledelayedexpansion
    for /f "tokens=2" %%i in (
      'fc /b "%binfile%" "%~f0" ^| findstr /brc:"[0-9].*:"'
    ) do set "hex=!hex!%%i"
    echo !hex:~%skip%,%read%!
  endlocal
exit /b

:: ниже, под этими строками, поместить достаточно
:: "мусора", чтобы fc считал как можно больше байт

Foreigner, в случае с PS - не лучше ли через FileStream?

Anonymоus 27-11-2015 20:13 2579444

Цитата:

Цитата Foreigner
Не совсем понятно зачем выводить по одному байту »

Согласно спецификации. От клиента к серверу в заголовках запроса может придти любой диапазон байтов, и не обязательно, что он будет кратным использованному размеру блока для dd. Соответственно, обеспечить точный вывод запрошенного клиентом я могу только при блоке в 1 байт, но при этом очень проседает производительность. Текущая версия реализации выглядит так:
Код скрипта
Код:

@Echo Off

:: Обрабатываем аргументы
Set Data=%*
:Next
For /F "tokens=1,* delims=;" %%A In ("%Data%") Do (
        For /F "tokens=1,2 delims==" %%C In ("%%A") Do (Set %%C=%%D)
        If Not "%%B"=="" Set Data=%%B&GoTo :Next
)
:: Обрабатываем get\post параметры
rem Выпилено на время теста, работаем только с заголовками запроса
:: Обрабатываем куки
If Not Defined H-Cookie Set "H-Cookie=dummy"]
::
Set H-Cookie|Findstr /C:"<" /C:">" /C:"&" /C:"|" /C:^"\^"^" /C:"(" /C:")" /C:"\^">nul&&(Call HTTP302Header err_badcookie&Exit)
For %%A In ("%H-Cookie:;=" "%") Do For /F "tokens=1,* delims==" %%A In ("%%~A") Do Set "%%A=%%B"
:: Проверяем авторизацию и доступ
If Not Defined Code Exit /B 3 &:: err_nosession
For %%A In (User Access LastSeen) Do Set "%%A="
For /F "tokens=1-3 delims=|" %%A In ('Call GetUserInfo %H-REMOTE_ADDR% %Code%') Do (
        Set "UserName=%%A"
        Set "Access=%%B"
        Set "LastLogin=%%C"
)
If Not Defined Access Exit /B 3 &:: err_nosession
If %Access% LSS 5 Exit /B 5 &:: err_noaccess


:: ДЛЯ ОТЛАДКИ
:: В рабочей копии скрипта все эти данные будут взяты из БД
Set File=D:\Projects\cmdserv\wwwroot\test.mp3
For %%A In ("%File%") Do (
        Set name=%%~nxA
        Set size=%%~zA
)
Set timestamp=1442271745

:: Приводим имя файла к рабочей кодировке, для отладки, в продакшене имя сразу будет в юникоде
For /F "eol= delims=" %%A In ('Echo %name%^|recode cp866..utf8') Do Set "nameUTF8=%%A"
:: Конвертируем время для Last-Modified
For /F "delims=" %%A In ('UnixTime2HTTPDate %timestamp%') Do (Set timestamp=%%A)
:: Получаем текущее время
For /F "delims=" %%A In ('GetHTTPDate') Do (Set datetime=%%A)
:: Получаем MIME-тип, таблица для сопоставления по умолчанию указана в переменных окружения враппера
For /F "delims=" %%A In ('GetMIMEType "%name%"') Do Set "mimetype=%%A"


:: Если указан Range переходим к обработчику запроса
If Defined H-Range GoTo :Range

:: Отдаём заголовки и файл, если не поступало запросов на частичную отдачу
        Echo HTTP/1.1 200 OK
        Echo Server: OWS/%__OWS_version%
        Echo Date: %datetime%
        Echo Accept-Ranges: bytes
        Echo Content-Type: %mimetype%
        Echo Content-Disposition: attachment; filename="%nameUTF8%"; size=%size%; creation-date=%timestamp%; modification-date=%timestamp%; read-date=%datetime%
        Echo Last-Modified: %timestamp%
        Echo Content-Length: %size%
        Echo Connection: close
        Echo.
Type "%File%"
Exit


:Range
SetLocal EnableDelayedExpansion
:: Случайная строка для использования в качестве разделителя
For /F "delims=" %%A In ('GetRandomName charset=3 length=16') Do (Set boundary=%%A)
:: Максимальное значение диапазона = размер-1байт
For /F "delims=" %%- In ('cc %size%-1') Do Set MaxRange=%%-
:: Имя временного файла, используемого если в одном запросе несколько диапазонов
Set TmpFile="%Temp%\~part%boundary%.tmp"
:: Выделяем диапазоны из заголовка, если тип не bytes - отдаём HTTP 416
For /F "tokens=1,* delims==" %%A In ("%H-Range%") Do If /I "%%A"=="bytes" (Set "Ranges=%%B") Else (Call :Err416)
Set Parts=0
For %%A In (%Ranges%) Do (
        Set rejected=false
        Set Range="%%A"
        For /F "tokens=1,2 delims=-" %%B In ("!Range:-="-"!") Do (
                Set "rMin=%%~B"
                Set "rMax=%%~C"
                :: Разворачиваем диапазоны с пустыми значениями
                If "!rMax!"=="" Set rMax=%MaxRange%
                cc !rMax! ^>= %size%|ExitCode&&Set rMax=%MaxRange%
                If "!rMin!"=="" For /F "delims=" %%- In ('cc %MaxRange%-!rMax!+1') Do (
                        Set rMax=%MaxRange%
                        Set rMin=%%-
                )
        )
        :: Проверки
        If Not Defined rMin Set rejected=true
        If Not Defined rMax Set rejected=true
        cc !rMin! ^<= !rMax!|ExitCode||Set rejected=true
        :: Если диапазон проходит по всем условиям, записываем аргументы для функции, выдающей часть файла
        If "!rejected!"=="false" (
                Set /A Parts+=1
                Set $Range=!rMin!-!rMax!/%size%
                Set Skip=!rMin!
                For /F "delims=" %%- In ('cc !rMax!+1-!rMin!') Do Set Length=%%-
                Set Part[!Parts!]="!Skip!" "!Length!" "!$Range!" "%boundary%" "!Parts!"
        )

)
:: Есть заголовок Range, но ни один из диапазонов невалиден? Отдаём HTTP 416
If "%Parts%"=="0" Call :Err416
Echo HTTP/1.1 206 Partial Content
Echo Server: OWS/%__OWS_version%
Echo Date: %datetime%
Echo Last-Modified: %timestamp%
Echo Accept-Ranges: bytes
If "%Parts%"=="1" (
        Echo Content-Type: %mimetype%
) Else (
        Echo Content-Type: multipart/byteranges; boundary=%boundary%
)
If "%Parts%"=="1" (
        Echo Content-Length: !Length!
        Echo Content-Range: bytes !rMin!-!rMax!/%size%
        Echo.
        :: Нет смысла играться со временным файлом, если диапазон один - выводим напрямую
        Call :PartialContent !Part[1]! 1
) Else (
        For /L %%A In (1,1,!Parts!) Do (
                Call :PartialContent !Part[%%A]! !Parts!>>%TmpFile%
        )
        :: Подсчитываем размер получившихся частей ВМЕСТЕ с заголовками и разделителями
        Call :PartsSize %TmpFile%
        Echo.
rem        Выводим контент и удаляем временный файл
        Type "%TmpFile%"
        Del "%TmpFile%" 2>nul
)
EndLocal
Exit

:: Выдаём часть файла согласно аргументам
:PartialContent (skip, length, range, boundary, part, parts)
rem Echo Skip: "%~1", Length: "%~2", Range: "%~3" --%~4-- [%~5 of %~6]
If "%~6"=="1" (
        dd if="%File%" ibs=1 skip=%~1 count=%~2 2>nul
        Exit /B
)
Echo --%~4
Echo Content-Type: %mimetype%
Echo Content-Range: bytes %~3
Echo.
dd if="%File%" ibs=1 skip=%~1 count=%~2 2>nul
:: Если это последний блок, пишем финальный разделитель
If "%~5"=="%~6" <nul Set /P "Echo=--%~4--"
Exit /B

:PartsSize (file)
For %%A In ("%~1") Do Echo Content-Length: %%~zA
Exit /B

:Err416
        Echo HTTP/1.1 416 Range Not Satisfiable
        Echo Server: OWS/%__OWS_version%
        Echo Date: %datetime%
        Echo Accept-Ranges: bytes
        Echo Content-Range: bytes */%size%
        Echo.
Exit

Если не найду другого решения, буду писать алгоритм выбора размера блока для dd, отдавать часть максимально большими блоками, а остаток точно доводить побайтово.
UPD: Прошу прощения, я не совсем верно вас понял при первом прочтении сообщения. Вы предложили использовать один блок сразу нужного размера, и это до меня только сейчас дошло. Да, это может сработать, сейчас протестирую.

greg zakharov, к сожалению, те же проблемы с производительностью, файлы обычно бывают от десятка мегабайт до гигабайта, а запрашиваемая часть файла варьируется в зависимости от браузера либо другого софта, делающего запрос, но редко превышает треть или четверть общего размера.

Foreigner 27-11-2015 20:33 2579452

Цитата:

Цитата greg zakharov
в случае с PS - не лучше ли через FileStream? »

Лучше, особенно если файл большого размера.

Цитата:

Цитата Anonymоus
использовать один блок сразу нужного размера »

Да, если известен офсет и размер блока. Только вот гнутый dd через раз вылетает.

Anonymоus 27-11-2015 20:55 2579454

Протестировал с одним блоком нужного размера, работает действительно быстрее, но точно задать офсет не выйдет — ibs влияет и на это тоже, опять всплывает проблема с кратностью размера блока. А я уже успел обрадоваться, что решение найдено. Про вылеты вы верно заметили, бывало пару раз, я списал это как раз на работу с блоком в один байт — всё же совершенно нетипичные условия работы для этой утилиты.

Anonymоus 28-11-2015 05:34 2579524

Решил проблему с помощью двух вызовов dd с передачей результата через пайп, первый обеспечивает смещение, второй выдаёт данные нужной длины. Производительность приемлемая даже на больших файлах. Заодно заменил саму утилиту на эту, она не упала ни разу за всё время тестирования.
Код:

dd if="%File%" bs=%Offset% skip=1 2>nul|dd bs=%Length% count=1 of="out.tmp" 2>nul


Время: 16:58.

Время: 16:58.
© OSzone.net 2001-