Zapis, który umiera w połowie — czy to przez wymuszony restart, zabity proces, czy dysk zapełniający się w trakcie zapisu — tradycyjnie oznaczał jedno dla formatu zbudowanego wokół zapisów w miejscu: cokolwiek bajtów dotarło na dysk przed przerwaniem, to właśnie dostajesz z powrotem, a obcięty skoroszyt już się nie otwiera. HotXLS zamyka ten tryb awarii bezpieczną ścieżką zapisu używaną dla każdego pliku XLSX, ODS i klasycznego XLS, jaki zapisuje. Każde wywołanie SaveAs zapisuje kompletny nowy plik do pliku tymczasowego utworzonego obok miejsca docelowego, a następnie zatwierdza go pojedynczą atomową zmianą nazwy MoveFileExW z Windows API, więc przerwany zapis może co najwyżej nie wyprodukować nowego pliku — nigdy nie uszkadza tego, który już miałeś. Ta sama dyscyplina bufor-potem-podmiana działa jednolicie w obu silnikach zapisu HotXLS, writerze BIFF8 stojącym za klasycznym XLS i writerze OOXML stojącym za XLSX i ODS, i jest to wzorzec wart pożyczenia dla każdego pliku, który twój własny kod w Delphi nadpisuje bezpośrednio, arkusze kalkulacyjne czy nie
Co się dzieje, jeśli zapis skoroszytu zostanie przerwany w połowie?
Bezpośrednia odpowiedź jest taka, że zależy to całkowicie od tego, jak writer dotyka pliku docelowego, a typowa implementacja — otwarcie pliku docelowego i strumieniowanie nowej treści bezpośrednio do niego — jest w porządku, dopóki nic nigdy nie idzie źle. W chwili gdy coś idzie źle — awaria, wymuszone zabicie procesu, udział sieciowy, który odpada w trakcie zapisu — plik na dysku zostaje w jakimkolwiek pośrednim stanie, do jakiego writer zdążył dojść: centralny katalog ZIP, który nigdy nie został dopisany dla XLSX czy ODS, albo strumień BIFF brakujący rekordów, których oczekuje czytnik dla klasycznego XLS. Excel nie naprawia tego łagodnie, i żaden inny odbiorca oczekujący kompletnego pliku też tego nie robi, więc praktycznym wynikiem jest skoroszyt, który wczoraj otwierał się w porządku, a dziś odmawia otwarcia
Jak HotXLS buforuje każdy zapis za jedną atomową podmianą
HotXLS nigdy nie otwiera pliku docelowego do zapisu bezpośrednio, dla żadnego z trzech formatów, jakie zapisuje. Sekwencja ma ten sam kształt za każdym razem: zbuduj kompletne wyjście gdzieś, co nie jest plikiem, który użytkownik już ma na dysku, i przenieś je na miejsce dopiero, gdy ta budowa w pełni się powiedzie. Konkretnie, SaveAs tworzy pusty plik tymczasowy w tym samym folderze co ścieżka docelowa, zapisuje cały nowy skoroszyt do tego pliku tymczasowego, i dopiero po tym, jak ten zapis wróci bez błędu, zatwierdza plik tymczasowy nad miejscem docelowym pojedynczą zmianą nazwy. Nic z tego nie wymaga właściwości do włączenia; to po prostu to, co SaveAs robi dla zwykłej ścieżki pliku, przy każdym wywołaniu
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;
Ta sama dyscyplina dotyczy klasycznego writera XLS, nie tylko writera OOXML, a oba pliki tymczasowe dzielą nawet konwencję nazewnictwa: oba wywołują API Windows GetTempFileNameW z prefiksem hxl, więc zapis przerwany przed czyszczeniem może zostawić zabłąkany plik o nazwie w stylu hxl4C2A.tmp siedzący obok skoroszytu. Ten plik to nie uszkodzenie, to dowód, że mechanizm zadziałał dokładnie tak, jak zaprojektowano: niekompletny zapis zatrzymał się tam, a twój faktyczny skoroszyt nigdy w ogóle nie został otwarty do zapisu. Widok takiego pliku po awarii jest bezpieczny do usunięcia i nie ma czego badać
Dlaczego buforować plik tymczasowy obok skoroszytu zamiast w %TEMP%?
Krótka odpowiedź jest taka, że zmiana nazwy przez MoveFileExW jest atomowa tylko wtedy, gdy źródło i cel siedzą na tym samym woluminie, a najpewniejszym sposobem zagwarantowania tego bez proszenia wywołującego o cokolwiek konfigurować jest wyprowadzenie lokalizacji pliku tymczasowego z samej ścieżki docelowej. HotXLS oblicza własny folder celu i przekazuje ten katalog wprost do GetTempFileNameW, więc plik tymczasowy jest zawsze tworzony na tym samym dysku, tym samym woluminie, co plik, który za chwilę zastąpi, automatycznie, przy każdym zapisie. Gdyby biblioteka zamiast tego buforowała zapisy w systemowym folderze temp, ścieżka docelowa na innym dysku albo zmapowanym woluminie sieciowym zamieniłaby ostatni krok w operację międzywoluminową, którą API Windows albo wprost odmawia, albo, jeśli wywołujący jawnie się na to zgodzi dodatkową flagą, której HotXLS tu nie ustawia, po cichu degraduje do nieatomowego kopiowania po którym następuje usunięcie, ponownie otwierając dokładnie to okno przerwania, dla zamknięcia którego cały ten mechanizm istnieje
Krok zatwierdzenia: MoveFileExW, zapis bezpośredni i co się dzieje przy niepowodzeniu
Ostatnim krokiem każdego zapisu jest dokładnie jedno wywołanie API Windows, MoveFileExW, niosące dwie flagi, z których każda wykonuje odrębną pracę. MOVEFILE_REPLACE_EXISTING to to, co pozwala zmianie nazwy wylądować na pliku, który już istnieje; bez niej zmiana nazwy celująca w istniejącą ścieżkę po prostu zawodzi, co pokonałoby cały sens zapisu mającego zastąpić skoroszyt, który już posiadasz. MOVEFILE_WRITE_THROUGH pokrywa trwałość: mówi funkcji, żeby nie zwracała sterowania, dopóki przenoszenie faktycznie nie zakończy się na dysku, zamiast wracać, gdy tylko zmiana nazwy zostanie po prostu zakolejkowana, zamykając węższy, ale prawdziwy wyścig, w którym awaria natychmiast po powrocie z SaveAs wciąż mogłaby złapać podmianę w locie. Jeśli plik tymczasowy nie może zostać utworzony, albo końcowa zmiana nazwy zawodzi z jakiegokolwiek powodu (problem z uprawnieniami, zablokowany cel, niezgodność woluminów), HotXLS sam usuwa plik tymczasowy zamiast zostawiać śmieci, a plik docelowy zostaje dokładnie taki, jaki był przed wywołaniem
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;
Samo SaveAs zachowuje konwencję zwracania współdzieloną w całym HotXLS, jedynkę przy sukcesie, liczbę ujemną przy niepowodzeniu, ale goła liczba całkowita nie mówi, dlaczego zapis zawiódł, a traktowanie każdego ujemnego wyniku tak samo wyrzuca informację, którą polityka ponawiania mogłaby faktycznie wykorzystać. Właściwość LastDiagnostic, i pełniejsza kolekcja Diagnostics za nią, niesie komunikat wygenerowany wewnętrznie przez HotXLS, odróżniając plik tymczasowy, którego nie dało się utworzyć, od zmiany nazwy, jaką Windows odmówił. Zadanie wsadowe, które loguje Code i Message przy każdym nieudanym SaveAs, buduje dokładnie ten dowód, jaki chcesz mieć w tym jednym przypadku, gdy klient zgłosi zapis, który po cichu nic nie zrobił
Klasyczny XLS płaci pamięcią, XLSX i ODS płacą dyskiem
Dwa silniki zapisu osiągają ten sam bezpieczny wobec awarii wynik różnymi drogami, a różnica ma znaczenie, jeśli już strojysz którykolwiek z nich pod duże zadanie wsadowe. Klasyczny writer XLS buduje cały złożony dokument OLE w pamięci najpierw, używając strukturalnego magazynu wspieranego przez uchwyt pamięci, i dopiero kopiuje ten gotowy bufor do sąsiedniego pliku tymczasowego jednym zapisem; uzasadnienie we własnym źródle HotXLS jest wprost: zbudowanie całego pliku w pamięci najpierw jest tym, co powstrzymuje nieudany albo anulowany zapis przed obcięciem celu. Writer XLSX i ODS zamiast tego strumieniuje swoje wpisy ZIP do pliku tymczasowego w miarę ich produkowania, to samo buforowanie na poziomie pliku z innym profilem pamięci. Jeśli już opierasz się na StreamingWrite, żeby utrzymać duże eksporty XLSX w granicach pamięci kontenera, wiedz, że równoważna dźwignia dla eksportu klasycznego XLS nie istnieje w tej samej formie: gwarancja bezpieczeństwa wobec awarii jest bezwarunkowa w obu przypadkach, ale bardzo duży starszy eksport .xls trzyma swoje kompletne wyjście w RAM niezależnie od tego, kompromis omówiony głębiej w naszym artykule o zapisach strumieniowych dla zadań wsadowych na serwerze
Stosowanie tego samego wzorca poza HotXLS i gdzie kończy się gwarancja
Pożyczenie tego wzorca to głównie kwestia podłączenia tych samych dwóch wywołań API Windows, na których HotXLS opiera się wewnętrznie. GetTempFileNameW daje ci unikalnie nazwany, pusty plik w wybranym przez ciebie folderze, a MoveFileExW zatwierdza twój gotowy zapis nad prawdziwym celem w jednym kroku; minimalna wersja tej samej procedury, jaką HotXLS uruchamia przed każdym SaveAs, wygląda tak
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;
Gwarancja ma prawdziwe krawędzie warte poznania, zanim zaufasz jej ślepo. Buforowanie pełnej kopii przed zastąpieniem oryginału oznacza, że zapis przez chwilę potrzebuje miejsca na dysku zarówno na stary plik, jak i nowy, z grubsza podwójny rozmiar skoroszytu na czas zapisu, co jest w porządku dla raportu i warte sprawdzenia dla eksportu wielogigabajtowego działającego na niemal pełnym woluminie. Plik tymczasowy musi też wylądować w tym samym folderze co cel, więc jakiekolwiek konto, pod którym działa HotXLS, potrzebuje uprawnienia tworzenia plików w tym konkretnym folderze, nie tylko uprawnienia nadpisania jednego pliku, o którym już wie; wdrożenie, które blokuje folder docelowy wyłącznie do edycji w miejscu konkretnych istniejących nazw plików, zamiast dostępu do zapisu na poziomie folderu, zobaczy niepowodzenie SaveAs na kroku pliku tymczasowego, mimo że równoważny zapis bezpośredni by się powiódł
Warto wprost zaznaczyć jeszcze dwie granice. Cel na udziale sieciowym albo wewnątrz folderu synchronizowanego przez OneDrive albo podobnego klienta może zachowywać się inaczej niż lokalny NTFS, mimo że Windows nadal zgłasza go jako pojedynczy wolumin, ponieważ sterownik systemu plików stojący przed nim może nie implementować zmiany nazwy w ten sam sposób; jeśli twój cel wdrożenia zapisuje przez ścieżkę sieciową, warto przetestować wymuszone przerwanie właśnie tam, zamiast zakładać, że zachowanie lokalnego dysku się przenosi. A cały mechanizm jest ograniczony do zapisu do nazwanego pliku. Wywołaj SaveAs względem TStream zamiast tego, a HotXLS zapisuje bezpośrednio do jakiegokolwiek strumienia, jaki podałeś, bez pliku docelowego do buforowania czy ochrony, ponieważ trwałość tego strumienia (bufor pamięci, przesyłanie sieciowe, blob bazy danych) od tego momentu całkowicie leży w gestii twojego kodu
Przebieg weryfikacji może potem polegać dokładnie na tej gwarancji, w tym ten wbudowany w stanowisko audytu i konwersji skoroszytów: ponownie otwarty plik, który wraca skrócony albo brakujący, to prawdziwy problem konwersji do zbadania, nigdy zapis, który został przerwany w połowie i zostawił na dysku coś niejednoznacznego. Bezpieczne wobec awarii buforowane zapisy są wbudowane w SaveAs dla każdego skoroszytu XLSX, ODS i klasycznego XLS produkowanego przez komponent HotXLS dla Delphi i C++Buildera, bez wymaganej konfiguracji, żeby je włączyć