Работа с cab-архивами через IStream

в 15:07, , рубрики: api, Cabinet, FCI, FDI, IStream, разработка под windows, сжатие, метки: , , ,

Некоторое время назад мне потребовалось сжимать данные прямо в памяти, причём не использовать для этого ничего стороннего — т.е. пользоваться встроенными в систему возможностями. Выбор пал на Cabinet.dll в качестве средства для сжатия данных и на интерфейс IStream для работы с данными в памяти. Ничего подобного в интернете я не нашёл, поэтому решил поделиться наработками.

Вступление

Использовать сторонние решения не хотелось, ибо пришлось бы таскать с собой библиотеки или включать исходники в проект. Windows предоставляет не такой уж и большой набор средств для сжатия/распаковки данных: это Cabinet.dll, ZipFldr.dll (сжатые Zip-папки) и RtlCompressBuffer/RtlDecompressBuffer. Внятной документации по сжатым Zip-папкам я не нашёл, RtlCompressBuffer/RtlDecompressBuffer в версиях по Windows 7 включительно поддерживает только сжатие LZ, а вот Cabinet.dll присутствует в системе аж с Windows 95 и до наших дней.

В качестве функций для работы с файлами и памятью документация предлагает использовать функции стандартной библиотеки C или функции Windows API, такие как CreateFile/CloseHandle/ReadFile/WriteFile. Так как все операции над файлами выполнялись в памяти, то для этих целей было решено использовать IStream.

Немного о Cabinet.dll

Библиотека функционально делится на 2 части: FCI (file compression interface) и FDI (file decompression interface). Почитать об этом можно тут. Оба интерфейса используют, по сути, одни и те же функции для работы с файлами и памятью, но Microsoft почему-то решила сделать разные прототипы для FCI и FDI. Впрочем, ничто не мешает описать одни через другие. Как это сделать, смотрите ниже.

Для использования библиотеки надо подключить файлы FCI.h и/или FDI.h соответственно и указать линкеру на Cabinet.lib. Все эти файлы входят в состав Windows SDK.

Реализация интерфейса сжатия

Простейший код, реализующий сжатие, выглядит так:

/*
Входные данные:
	IStream* pIStreamFile — поток с данными файла, который надо добавить в архив
	char* szFileName — имя файла в архиве. Если файлов несколько, то и имена их должны быть разными
*/
ERF erf;
CCAB ccab = {MAXINT, MAXINT};
*(IStream**)ccab.szCabPath = SHCreateMemStream(0, 0);	//Поток для выходного файла
HFCI hFCI = FCICreate(&erf, fPlaced, fAlloc, fFree, fOpen, fRead, fWrite, fClose, fSeek, fDelete, fTemp, &ccab, 0);
if(hFCI){
	FCIAddFile(hFCI, (PSZ)pIStreamFile, szFileName, 0, fGetNext, fStatus, fInfo, tcompTYPE_MSZIP);
	FCIFlushFolder(hFCI, fGetNext, fStatus);
	FCIFlushCabinet(hFCI, 0, fGetNext, fStatus);
	FCIDestroy(hFCI);
}
/*
Выходные данные:
	(IStream*)ccab.szCabPath — поток, содержащий cab-архив. Не забудьте сделать ему Release() по окончании использования!
*/

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

Тут следует добавить, что файловые дескрипторы у нас будут нестандартными в этом плане — это указатели на IStream. В силу этой особенности нужно быть аккуратным с передачей этого «дескриптора». Например, в структуре CCAB есть 2 поля: szCabPath и szCab, и казалось бы логичным передать адрес во 2-й параметр, но нет. FCI выполняет конкатенацию строк (вернее, он-то думает, что конкатенирует строки, но мы-то знаем…), поэтому в результате «именем» файла будет являться szCabPath, и он же будет являться дескриптором.

fPlaced

Вызывается каждый раз при добавлении нового файла в архив.

FNFCIFILEPLACED(fPlaced){
	return 0;
}

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

fGetNext

Вызывается перед созданием нового тома архива.

FNFCIGETNEXTCABINET(fGetNext){
	return 1;
}

В случае успеха возвращает TRUE, в противном случае — FALSE. Ничего примечательного.

fStatus

Вызывается на нескольких этапах обработки файла: сжатие блока, добавление сжатого блока и запись архива.

FNFCISTATUS(fStatus){
	return typeStatus == statusCabinet ? cb2 : 0;
}

В случае ошибки надо вернуть -1, в противном случае — любое значение (за исключением typeStatus == statusCabinet — тогда надо вернуть размер архива, который передаётся через параметр cb2).

fInfo

Устанавливает атрибуты файла.


FNFCIGETOPENINFO(fInfo){
	*pattribs = 0;
	return (INT_PTR)pszName;
}

IStream не поддерживает атрибуты даты, да и вообще файловые атрибуты, поэтому значение по адресу pattribs надо установить в 0, иначе вы рискуете получить файлы в архиве со странными атрибутами (а то и не получить архив вовсе).

Возврат -1 означает ошибку, в противном случае надо вернуть дескриптор открытого файла.

fTemp

Создание временного файла.

FNFCIGETTEMPFILE(fTemp){
	*(IStream**)pszTempName = SHCreateMemStream(0, 0);
	return 1;
}

В случае успеха возвращает TRUE, иначе — FALSE. Имя файла (указатель на IStream в данном случае) передаётся через параметр pszTempName.

fDelete

Удаление файла.

FNFCIDELETE(fDelete){
	(*(IStream**)pszFile)->Release();
	return 0;
}

При успехе возвращает 0, при неудаче — -1. Удаление файла в данном случае — это освобождение занимаемых потоком ресурсов, поэтому просто делаем Release().

fAlloc, fFree

Выделение/освобождение памяти.

FNFCIALLOC(fAlloc){
	return new char[cb];
}
FNFCIFREE(fFree){
	delete memory;
}

Тут всё очень просто, поэтому я даже объединил эти функции в одном разделе.

fOpen

Открытие файла (потока).

FNFCIOPEN(fOpen){
	return *(INT_PTR*)pszFile;
}

Т.к. имя файла в нашем случае эквивалентно дескриптору этого файла, поэтому мы и возвращаем имя в качестве дескриптора (ну или -1, если вдруг произошла какая-то ошибка).

fClose

Закрытие дескриптора файла.

FNFCICLOSE(fClose){
	LARGE_INTEGER li = {};
	((IStream*)hf)->Seek(li, 0, 0);
	return 0;
}

При успехе возвращает 0, при неудаче — -1. Почему не Release()? Потому что он «удаляет файл», т.е. уничтожает поток, в то время как нужно лишь его закрытие. Поэтому просто сбрасываем указатель на начало.

fRead, fWrite

Чтение/запись данных из файла/в файл.

FNFCIREAD(fRead){
	ULONG ul;
	HRESULT hr = ((IStream*)hf)->Read(memory, cb, &ul);
	return (hr && hr != S_FALSE) ? -1 : ul;
}
FNFCIWRITE(fWrite){
	ULONG ul;
	HRESULT hr = ((IStream*)hf)->Write(memory, cb, &ul);
	return (hr && hr != S_FALSE) ? -1 : ul;
}

Возвращает количество прочтённых/записанных байт или -1 в случае ошибки (0 — достигнут конец файла).

fSeek

Позиционирование указателя в файле.

FNFCISEEK(fSeek){
	LARGE_INTEGER liDist = {dist};
	HRESULT h r =((IStream*)hf)->Seek(liDist, seektype, (ULARGE_INTEGER*)&liDist);
	return hr ? -1 : liDist.LowPart;
}

Возвращает -1 при ошибке, иначе — новую позицию указателя.

Реализация интерфейса распаковки

Код распаковки выглядит следующим образом:

/*
Входные данные:
	IStream* pIStrCab — поток с архивом
*/
ERF erf;
HFDI hFDI = FDICreate(fAlloc, fFree, fnOpen, fnRead, fnWrite, fnClose, fnSeek, cpuUNKNOWN, &erf);
if(hFDI){
	IStream *pIStrSrc = SHCreateMemStream(0, 0);
	if(FDICopy(hFDI, (PSZ)&pIStrCab, (PSZ)&pIStrCab, 0, fnNotify, 0, &pIStrSrc)){
		//Использование данных из потока pIStrSrc
	}
	pIStrSrc->Release();
	FDIDestroy(hFDI);
}
pIStrCab->Release();
/*
Выходные данные:
	IStream* pIStrSrc — поток с распакованными данными
*/

Здесь уже не всё так просто. Дело в том, что извлечение всех файлов из архива инициируется единственной функцией FDICopy, которая в процессе своей работы вызывает fnNotify, где и происходит вся магия. Но об этом — чуть позже.

В целом процесс аналогичен: создаём контекст FDI, поток для выходных данных, извлекаем файл из архива в этот поток (в моём примере надо было извлечь единственный файл) и уничтожаем контекст. (PSZ)&pIStrCab надо указать дважды, потому что в процессе своей работы функция конкатенирует оба параметра, и если опустить один из них, то будет ошибка (да, и на такие грабли я тоже натыкался).

Теперь немного о функциях. В целом они аналогичны функциям FCI, кроме того, что у них нет 2-х параметров; функции выделения/освобождения памяти вообще идентичны, поэтому повторно их описывать не имеет смысла. Для уменьшения количества кода можно переписать функции FCI через функции FDI, чтобы не указывать лишние нулевые параметры.

fnOpen, fnClose

Открытие/закрытие файла (потока).

FNOPEN(fnOpen){
	return *(INT_PTR*)pszFile;
}
FNCLOSE(fnClose){
	return fClose(hf, 0, 0);
}

fnOpen проще продублировать, чем вызывать fOpen, а в fnClose вызывается функция FCI fClose с 2-мя нулевыми последними параметрами, ибо они не используются в этой реализации.

fnRead, fnWrite, fnSeek

Чтение/запись данных и позиционирование указателя.

FNREAD(fnRead){
	return fRead(hf, pv, cb, 0, 0);
}
FNWRITE(fnWrite){
	return fWrite(hf, pv, cb, 0, 0);
}
FNSEEK(fnSeek){
	return fSeek(hf, dist, seektype, 0, 0);
}

Возвращаемые значения аналогичны значениям для FCI.

fnNotify

Самая главная функция.

FNFDINOTIFY(fnNotify){
	if(fdint == fdintCOPY_FILE)
		if(!lstrcmp(pfdin->psz1, "Data"))	//Если это тот файл, который надо извлечь
			return (INT_PTR)*(int*)pfdin->pv;
	return fdint == fdintCLOSE_FILE_INFO;
}

Всю информацию по функции можно прочитать тут. Здесь же нужно несколько пояснений.
В большинстве случаев функция возвращает 0 как показатель успеха (кроме fdintCLOSE_FILE_INFO, тогда надо вернуть TRUE). При fdint == fdintCOPY_FILE поведение следующее: 0 означает пропуск файла, -1 — ошибка (завершение FDICopy), другое значение — дескриптор потока, в который надо извлечь данные.

Теперь начинается самое интересное, потому что если мы будем создавать потоки в этой функции, снаружи мы не получим к ним доступ. Поэтому есть минимум 2 пути решения, и оба они затрагивают доселе незадействованный и потому неприметный последний параметр pvUser функции FDICopy. Через него можно передавать пользовательские данные, и именно он возвращается в pfdin->pv. Первый путь — если у вас есть фиксированный список имён файлов, которые надо извлечь из архива, то его можно передать в виде массива структур, содержащих требуемое имя файла и указатель на IStream для извлечения в него. Второй путь — когда число файлов неизвестно, и вам надо извлечь их все; в таком случае через pvUser можно передать адрес контейнера (например, std::vector), в котором будут сохраняться имена и дескрипторы извлечённых файлов).

Послесловие

Этот способ подходит для случаев, когда результирующий размер данных у вас не особо большой — порядка сотни мегабайт. Разумеется, при наличии 8+ Гб памяти это не такие уж и большие затраты, но помните, что операция перевыделения памяти — не самая быстрая операция, которая к тому же ведёт к фрагментации памяти, вследствие чего может внезапно случиться такая оказия, что достаточно длинного непрерывного блока памяти у вас не будет.

В качестве некоторой альтернативы можно использовать structured storage (там тот же самый IStream) или файловые потоки, созданные с помощью SHCreateStreamOnFile/SHCreateStreamOnFileEx. Таким образом, можно совместить операции ввода/вывода в памяти с аналогичными операциями в файлах, т.к. интерфейс IStream может использоваться в обоих случаях без каких-либо дополнительных манипуляций.

Если у вас есть какие-то вопросы по реализации, готов ответить на них в комментариях.

Автор: apixosoft

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js