Техническа статия

Записване с защита от срив чрез временен файл в HotXLS

Запис, който прекъсне по средата заради принудително рестартиране, прекратен процес или диск, запълнен по време на записването, традиционно означава едно и също за формат, изграден около записване на място: байтовете, достигнали до диска преди прекъсването, са всичко, което ще получите обратно, а съкратена работна книга не се отваря отново. HotXLS затваря този сценарий с път за безопасно при срив записване, използван за всеки XLSX, ODS и класически XLS файл, който записва. Всяко извикване на SaveAs записва целия нов файл във временен файл, създаден до целевия, след което го въвежда в употреба с едно атомарно преименуване MoveFileExW от Windows API, така че прекъснатият запис може само да не създаде новия файл, но никога да не повреди вече съществуващия. Същата дисциплина за подготовка и подмяна се прилага еднакво от двата механизма за запис на HotXLS, писателя на BIFF8 зад класическия XLS и писателя на OOXML зад XLSX и ODS, и е модел, който си струва да използвате за всеки файл, който вашият Delphi код презаписва директно, независимо дали е електронна таблица

Какво се случва, ако записването на работна книга прекъсне по средата

Прякият отговор зависи изцяло от начина, по който писателят докосва целевия файл, а обичайната реализация, която отваря целевия файл и предава новото съдържание директно в него, е добра, докато нищо не се обърка. В момента, в който възникне проблем, срив, принудително прекратяване на процеса или отпадане на мрежов дял по време на запис, файлът на диска остава в междинното състояние, до което е стигнал писателят: централна ZIP директория, която не е била добавена за XLSX или ODS, или BIFF поток без записи, които четецът очаква за класически XLS. Excel не поправя това надеждно, както и всеки друг потребител, който очаква пълен файл, затова практическият резултат е работна книга, която вчера се е отваряла, а днес отказва да се отвори

Как HotXLS подготвя всеки запис зад една атомарна подмяна

HotXLS никога не отваря директно целевия файл за запис, независимо кой от трите формата записва. Последователността винаги има една и съща форма: изградете цялото изходно съдържание на място, което не е файлът, който потребителят вече има на диска, и го преместете на място едва след като изграждането завърши успешно. Конкретно SaveAs създава празен временен файл в същата папка като целевия път, записва цялата нова работна книга във временния файл и едва след като записването завърши без грешка, поставя временния файл върху целевия с едно преименуване. Няма нужда да включвате това чрез свойство: така просто работи SaveAs за обикновен файлов път при всяко извикване

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Report');
    Sheet.Cells[1, 1].Value := 'Nothing special to enable here';
    // If this call is interrupted, monthly-report.xlsx on disk stays
    // either the old version, complete, or the new version, complete
    if Book.SaveAs('monthly-report.xlsx', xlsxOpenXMLWorkbook) <> 1 then
      raise Exception.Create('Save failed, see Book.LastDiagnostic');
  finally
    Book.Free;
  end;
end;

Същата дисциплина се прилага и за писателя на класически XLS, не само за OOXML, а двата временни файла дори споделят схема за именуване: и двата извикват Windows API GetTempFileNameW с префикс hxl, така че запис, прекъснат преди почистването, може да остави остатъчен файл с име като hxl4C2A.tmp до вашата работна книга. Този файл не е повреда, а доказателство, че механизмът е работил точно по замисъл: непълният запис е спрял там, а истинската ви работна книга никога не е била отваряна за запис. Ако видите такъв файл след срив, безопасно е да го изтриете и няма какво да разследвате

Защо временният файл се подготвя до работната книга, а не в %TEMP%

Краткият отговор е, че преименуването чрез MoveFileExW е атомарно само когато източникът и целта са на един и същ том, а най-сигурният начин да гарантирате това, без да искате допълнителна настройка от извикващия код, е да изведете местоположението на временния файл от самия целеви път. HotXLS изчислява собствената папка на целта и подава тази директория директно на GetTempFileNameW, затова временният файл винаги се създава на същото устройство и същия том като файла, който предстои да замести, автоматично при всяко записване. Ако библиотеката вместо това подготвяше записите в системната временна папка, целеви път на друго устройство или картографиран мрежов том би превърнал последната стъпка в операция между томове, която Windows API или отказва директно, или при изрично включване от извикващия с допълнителен флаг, който HotXLS не задава тук, тихо понижава до неатомарно копиране и изтриване, като така отваря отново точно прозореца за прекъсване, който този механизъм трябва да затвори

Стъпката за въвеждане: MoveFileExW, запис с незабавно записване и грешки

Последната стъпка на всяко записване е точно едно извикване на Windows API, MoveFileExW, с два флага, които вършат различна работа. MOVEFILE_REPLACE_EXISTING позволява преименуването да попадне върху вече съществуващ файл; без него преименуването към съществуващ път просто се проваля, което би обезсмислило записа, предназначен да замени вече налична работна книга. MOVEFILE_WRITE_THROUGH се грижи за устойчивостта: казва на функцията да не се връща, докато преместването действително не завърши на диска, вместо да се върне веднага след само поставянето на преименуването в опашка, като затваря по-тесен, но реален прозорец, в който срив непосредствено след връщането от SaveAs все още може да прекъсне подмяната. Ако временният файл не може да бъде създаден или последното преименуване се провали по каквато и да е причина, проблем с разрешенията, заключена цел или несъвпадение на томовете, HotXLS сам изтрива временния файл, вместо да оставя отпадъци, а целевият файл остава точно такъв, какъвто е бил преди извикването

Result := Book.SaveAs(TargetPath, xlsxOpenXMLWorkbook);
if Result <> 1 then
begin
  // TargetPath on disk is unchanged; safe to retry, alert, or
  // fall back to a different path without touching prior output
  LogWriter.Write(Format('SaveAs failed (%d): %s',
    [Book.LastDiagnostic.Code, Book.LastDiagnostic.Message]));
  Exit(False);
end;

Самият SaveAs използва споделеното в HotXLS правило за резултата, единица при успех и отрицателно число при неуспех, но голо цяло число не казва защо е неуспешен записът, а третирането на всеки отрицателен резултат по един и същ начин изхвърля информация, която действително може да е полезна за политика за повторен опит. Свойството LastDiagnostic и по-пълната колекция Diagnostics зад него носят съобщението, генерирано вътрешно от HotXLS, и разграничават временен файл, който не е могъл да бъде създаден, от преименуване, отказано от Windows. Пакетна задача, която записва Code и Message при всеки неуспешен SaveAs, натрупва точно доказателствата, които ще искате в момента, когато клиент съобщи, че записването тихо не е направило нищо

Класическият XLS плаща с памет, а XLSX и ODS плащат с дисково пространство

Двата механизма за запис достигат до един и същ резултат, безопасен при срив, по различни пътища, а разликата има значение, ако вече настройвате някой от тях за голяма пакетна задача. Писателят на класически XLS първо изгражда целия OLE съставен документ в паметта чрез структурирано хранилище, поддържано от манипулатор на паметта, и едва след това копира готовия буфер в съседния временен файл с една операция за запис; разсъждението в собствения изходен код на HotXLS е пряко: изграждането на целия файл първо в паметта предотвратява неуспешен или отменен запис да съкрати целевия файл. Писателят за XLSX и ODS вместо това предава ZIP записите във временния файл, докато ги създава, тоест използва същото подготвяне на ниво файл с различен профил на паметта. Ако вече разчитате на StreamingWrite, за да държите големите XLSX експорти в рамките на паметта на контейнера, имайте предвид, че еквивалентният механизъм за класически XLS експорт не съществува в същата форма: гаранцията за безопасност при срив е безусловна и в двата случая, но много голям legacy .xls експорт винаги държи целия си резултат в RAM, компромис, разгледан по-подробно в статията ни за поточен запис в сървърни пакетни задачи

Прилагане на същия модел извън HotXLS и границите на гаранцията

Използването на модела обикновено се свежда до свързването на същите два Windows API извиквания, на които HotXLS разчита вътрешно. GetTempFileNameW ви дава уникално именуван празен файл в избрана от вас папка, а MoveFileExW въвежда готовия запис върху истинската цел в една стъпка; минимална версия на същата процедура, която HotXLS изпълнява преди всеки SaveAs, изглежда така

function SaveFileAtomically(const Path: WideString; const Contents: TBytes): Boolean;
var
  Dir, TempName: WideString;
  Buffer: array[0..MAX_PATH] of WideChar;
  FS: TFileStream;
begin
  Result := False;
  Dir := ExtractFilePath(ExpandFileName(Path));
  FillChar(Buffer, SizeOf(Buffer), 0);
  if GetTempFileNameW(PWideChar(Dir), 'app', 0, @Buffer[0]) = 0 then
    Exit;
  TempName := PWideChar(@Buffer[0]);
  try
    FS := TFileStream.Create(TempName, fmCreate or fmShareExclusive);
    try
      FS.WriteBuffer(Contents[0], Length(Contents));
    finally
      FS.Free;
    end;
    Result := MoveFileExW(PWideChar(TempName), PWideChar(ExpandFileName(Path)),
      MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
  finally
    if not Result then
      DeleteFileW(PWideChar(TempName));
  end;
end;

Гаранцията има реални граници, които е добре да знаете, преди да разчитате на нея без проверка. Подготвянето на пълно копие преди замяната на оригинала означава, че записът временно се нуждае от дисково пространство и за стария, и за новия файл, приблизително два пъти размера на работната книга за времето на записването, което е приемливо за отчет, но трябва да се провери при многогигабайтов експорт върху почти запълнен том. Временният файл също трябва да попадне в същата папка като целта, затова акаунтът, под който работи HotXLS, трябва да има разрешение за създаване на файл точно в тази папка, а не само разрешение да презаписва единствения файл, който вече познава; внедряване, което заключва целевата папка до промени на място в конкретни съществуващи имена, вместо да предостави достъп за запис на ниво папка, ще види как SaveAs се проваля при стъпката за временния файл, въпреки че еквивалентният директен запис би успял

Още две граници заслужават ясно отбелязване. Цел, намираща се в мрежов дял или в папка, синхронизирана от OneDrive или подобен клиент, може да се държи различно от локална NTFS, въпреки че Windows все още я отчита като един том, защото файловата система пред нея може да не реализира преименуването по същия начин; ако внедряването ви записва по мрежов път, струва си да тествате отделно принудително прекъсване точно там, вместо да приемате, че поведението на локалния диск се пренася автоматично. Целият механизъм е ограничен и до записване във файл с име. Ако вместо това извикате SaveAs с TStream, HotXLS записва директно в подадения поток, без целеви файл, който да бъде подготвен или защитен, защото устойчивостта на този поток, независимо дали е буфер в паметта, мрежово качване или двоичен обект в база данни, от този момент нататък е отговорност изцяло на вашия код

След това проверката може да разчита точно на тази гаранция, включително на вградената в работна среда за одит и конвертиране на работни книги: повторно отворен файл, който се връща съкратен или непълен, е истински проблем при конвертирането, който трябва да бъде проследен, а не запис, прекъснат по средата и оставил нещо двусмислено на диска. Безопасното при срив подготвено записване е вградено в SaveAs за всяка XLSX, ODS и класическа XLS работна книга, създадена от компонента HotXLS за Delphi и C++Builder, без необходимост от конфигурация за включването му