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

Деякий час тому мені потрібно стискати дані прямо в пам'яті, причому не використовувати для цього нічого стороннього, тобто користуватися вбудованими в систему можливостями. Вибір припав на Cabinet.dll в якості засобу для стиснення даних і інтерфейс IStream для роботи з даними в пам'яті. Нічого подібного в інтернеті я не знайшов, тому вирішив поділитися напрацюваннями.

Вступ
Використовувати сторонні рішення не хотілося, бо довелося б тягати з собою бібліотеки або включати исходники в проект. Windows надає не такий вже і великий набір засобів для стиснення/розпакування даних: Cabinet.dll, ZipFldr.dll (стислі Zip-папки) і RtlCompressBuffer/RtlDecompressBuffer. Виразної документації із стисненим Zip-папок я не знайшов, RtlCompressBuffer/RtlDecompressBuffer у версіях Windows 7 включно підтримує тільки стиснення LZ, а от Cabinet.dll є в системі аж <a href=«msdn.microsoft.com/en-us/library/ms974336>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 може використовуватися в обох випадках без якихось додаткових маніпуляцій.

Якщо у вас є якісь питання з реалізації, готовий відповісти на них у коментарях.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.