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

Создание файлов Excel в Delphi без автоматизации Office

Если единственная задача сервера — выдавать файлы Excel, ему незачем запускать Excel. Установка Office на агент сборки или сервис отчётности ради управления им через COM-автоматизацию — неверное решение, и оно остаётся неверным ровно столько, сколько существует сама практика. Microsoft говорит об этом прямо, и за двадцать лет формулировка не смягчилась: Office не спроектирован и не лицензирован для автоматизации из фонового серверного процесса. Правильный ответ — писать байты BIFF и OOXML напрямую, вообще без участия Excel. На этом и построен HotXLS — нативная библиотека Object Pascal, которая сама читает и пишет форматы электронных таблиц, так что нет настольного приложения, которое зависнет, потечёт или потребует оплаты за рабочее место

Почему управление EXCEL.EXE из сервиса даёт сбой

COM-автоматизация дистанционно управляет настольной программой, а настольная программа молча рассчитывает на три вещи, которых служба Windows дать не может: загруженный профиль пользователя, интерактивную оконную станцию и человека, смотрящего на экран. Уберите их — и отказы приходят в форме, которую не воспроизводит ни одна машина разработчика. Запрос на восстановление файла, ошибка надстройки или диалог активации лицензии открываются на рабочем столе, которого никто не видит, и вызов автоматизации, породивший их, никогда не возвращает управление. Вызывающая сторона в итоге отваливается по таймауту; экземпляр Excel часто нет — он выживает сиротой, держит блокировки файлов и отравляет следующий запуск. Каждый, кто наблюдал, как под сервисной учётной записью накапливаются одиннадцать бесхозных процессов EXCEL.EXE, знает продолжение этой истории

Схема, сопоставляющая сервис Delphi, который управляет EXCEL.EXE через COM-автоматизацию, где скрытые диалоги и осиротевшие процессы блокируют вызовы, с HotXLS, пишущим байты книг BIFF8 и OOXML напрямую внутри процесса
COM-автоматизация наследует несбывшиеся предпосылки настольной программы, тогда как HotXLS пишет байты BIFF8 и OOXML напрямую и ничего не требует устанавливать на сервере

С масштабированием история не лучше даже тогда, когда ничего не падает. Экземпляр Excel — это конвейер на одну книгу, каждое обращение к свойству оплачивает межпроцессный маршалинг COM, а на машине с этим кодом лежит лицензия Office, условия которой исключают ровно такой сценарий. Большинство команд упирается в эти пределы по одной аварии за раз — примерно так «убрать слой COM» и попадает в дорожную карту

Прежде чем начинать эту переработку, закройте один вопрос об охвате, потому что от него зависит, сколько работы предстоит на самом деле. Код на COM почти никогда не ограничивается заполнением ячеек. Он вызывает Workbook.SaveAs с константами формата, принудительно пересчитывает, выставляет параметры печати, иногда лезет в буфер обмена. Пройдите по старому коду и выпишите, какие из этих действий действительно доходят до результата, потому что каждое ложится в свой угол нативной библиотеки, а пара из них (буфер обмена — самый очевидный пример) не имеет серверного смысла и должна быть выброшена, а не перенесена

Два нативных движка, две модели владения

HotXLS меняет процесс Excel на две прямые реализации форматов. Движок потока записей BIFF8 (TXLSWorkbook, модуль lxHandle) отвечает за .xls. Писатель пакетов OOXML (TXLSXWorkbook, модуль lxHandleX) выдаёт .xlsx, соответствующий ECMA-376 / ISO/IEC 29500. На сервере нечего регистрировать и нечего устанавливать, а открытых одновременно книг может быть столько, сколько позволяет память

Схема сравнения двух фасадов HotXLS в Delphi: TXLSWorkbook, освобождаемый автоматически подсчётом ссылок интерфейса IXLSWorkbook, и TXLSXWorkbook как обычный объект, которому нужен явный Free в блоке try..finally
Фасад XLS освобождается подсчётом ссылок интерфейса, а фасаду XLSX нужен явный Free, и коллекции листов различаются: Entries считает с единицы, Items — с нуля

Что подводит новичков раньше всего — два фасада владеют памятью по-разному, и эта разница молчит до самого падения:

var
  Book: IXLSWorkbook;          // ссылка на интерфейс: освобождается автоматически
  Sheet: IXLSWorksheet;
  BookX: TXLSXWorkbook;        // обычный объект: освобождаете сами
  SheetX: TXLSXWorksheet;
begin
  // вывод BIFF8 .xls - без Free; владеет счётчик ссылок интерфейса
  Book := TXLSWorkbook.Create;
  Sheet := Book.Sheets.Add;
  Sheet.Name := 'Report';
  Sheet.Cells.Item[1, 1].Value := 'Generated without Excel';
  Book.SaveAs('report.xls');

  // вывод OOXML .xlsx - явное время жизни
  BookX := TXLSXWorkbook.Create;
  try
    SheetX := BookX.Sheets.Add('Report');
    SheetX.Cells[1, 1].Value := 'Generated without Excel';
    BookX.SaveAs('report.xlsx');
  finally
    BookX.Free;
  end;
end;

Фасад XLS работает через подсчёт ссылок интерфейса IXLSWorkbook. Объявляйте переменную типом интерфейса и никогда не вызывайте у неё Free; удержите тот же объект в обычной объектной переменной и освободите его сами — счётчик ссылок освободит его второй раз. Фасад XLSX — рядовой объект, которому нужен рядовой try..finally. Адресация ячеек с единицы у обеих сторон, и это единственное, в чём они совпадают. Коллекции листов не совпадают: Entries на стороне XLS считает с единицы, индексатор Items у XLSX — с нуля, и эта ошибка на единицу компилируется без замечаний, как бы вы её ни допустили, а проявляется только во время выполнения

Запись книги прямо в HTTP-ответ

У серверного экспорта обычно нет причин трогать диск. Временные файлы требуют политики очистки, сталкиваются при одновременных запросах и оставляют данные клиентов на томах, которые никто не догадался проверять. Оба фасада принимают TStream через перегрузки SaveAs, поэтому книга может уходить прямо в ответ:

Mem := TMemoryStream.Create;
Book := TXLSXWorkbook.Create;
try
  Sheet := Book.Sheets.Add('Data');
  Sheet.Cells[1, 1].Value := 'Generated ' + DateTimeToStr(Now);
  Book.SaveAs(Mem);          // пишет с ТЕКУЩЕЙ позиции потока
  Mem.Position := 0;         // перемотайте перед передачей потока
  Response.ContentType :=
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  Response.ContentStream := Mem;   // теперь Mem принадлежит фреймворку
finally
  Book.Free;
end;

Перемотка — та самая строка, которая оправдывает свой комментарий. SaveAs(Stream) пишет с текущей позиции потока и после этого никогда не возвращается к нулю. Забудьте Mem.Position := 0 — и клиент получит нулевой по размеру файл, а Excel назовёт его повреждённым. Это самая частая ошибка в коде, отдающем книги по сети, и самая коварная, потому что она проходит мимо любого юнит-теста, который проверяет лишь ненулевую длину потока

Одна процедура сборки книги дотягивается до любого другого формата доставки без перестройки. SaveAsCSV отвечает на просьбу «просто дайте сырые данные», SaveAsHTML закрывает «вставьте это на страницу портала», SaveAsRTF кормит конвейеры документов, а SaveAsODS покрывает требование OpenDocument — и всё это с перегрузками и для файла, и для потока. Одна процедура экспорта плюс параметр формата заменяют то, что раньше было четырьмя отдельными макросами COM. У экспортера HTML структура TXLSXHtmlExportOptions несёт заголовок, класс CSS и переключатель «фрагмент или полный документ», что избавляет портальный сценарий от правки экспортированной разметки регулярными выражениями

Схема обработчика запроса на Delphi, который сохраняет книгу HotXLS в TMemoryStream, перематывает Mem.Position на ноль и передаёт поток в HTTP-ответ, рядом показаны экспортеры CSV, HTML, RTF и ODS
Сохранение в TMemoryStream и перемотка перед передачей отправляют байты книги прямо клиенту, а одна процедура экспорта покрывает писатели CSV, HTML, RTF и ODS

Значения формул без процесса Excel, который их посчитает

При COM-автоматизации Excel пересчитывал всё бесплатно, и отказ от COM молча отменяет эту услугу. SaveAs сохраняет формулы как текст, не вычисляя их; числа появляются, только когда Excel откроет файл и пересчитает, — это поведение фасад XLS позволяет настроить через RecalcOnSave и CalculationMode. Для файла, который уходит человеку, это ровно то, что нужно. Это неверно для сервиса, обязанного подтвердить итог до отправки, и неверно для экспорта в CSV, который пишет текст формулы, а не её результат. В обоих случаях вычислять приходится на сервере встроенным движком:

SheetX.Cells[1, 1].Value := 1200;
SheetX.Cells[2, 1].Value := 950;
SheetX.Cells[3, 1].Formula := 'SUM(A1:A2)';   // фасад XLSX: без префикса '='
Total := BookX.Calculate('SUM(A1:A2)');       // вычислить на сервере, прямо сейчас
if Total <> 2150 then
  raise Exception.Create('reconciliation failed before delivery');

Соглашение фасадов кусается и здесь. Сторона XLSX присваивает выражения через Cell.Formula без знака равенства; сторона XLS пишет их через Cell.Value с ведущим '='. Перенесите код с одной стороны на другую без правок — и неверное соглашение сохранит текстовую строку, которая лишь похожа на формулу, и ни одна ошибка об этом не сообщит. Когда формулам книги нужно дотянуться до вашей бизнес-логики, колбэк OnUserFunction позволяет движку передавать неизвестные имена функций в код Delphi во время вычисления. Это нативная замена надстройкам с UDF, которые обычно прячутся внутри тех самых таблиц, вокруг которых вырастала система на COM-автоматизации

Края развёртывания, которые видны только на сервере

Несколько деталей решают, будет ли внедрение чистым или загадочным, и первая из них — граф модулей. Экспортер набора данных с перетаскиванием TDataToXLS тянет за собой модули VCL Forms, Controls и Dialogs. В настольном инструменте это безвредно; в консольном сервисе он тащит следом весь VCL. Ядровые модули lxHandle и lxHandleX обращаются только к Windows, Classes, SysUtils и Variants, поэтому чистому сервису выгоднее написать собственный цикл по набору данных поверх ядрового API, чем импортировать компонент ради удобства

Дальше идёт многопоточность. Экземпляры книг не потокобезопасны, но и общего глобального состояния у них нет, поэтому масштабируется самый простой шаблон: по объекту книги на задание или на рабочий поток. Это даёт параллельную генерацию отчётов, на которую один общий экземпляр Excel не способен никогда. Обработчику запроса, который создаёт, наполняет, сохраняет и освобождает собственную книгу, блокировки не нужны вовсе, а радиус поражения при сбое сжимается с «общий экземпляр Excel заклинило для всех» до «этот один запрос выбросил исключение», с чем ваша существующая обработка ошибок уже умеет справляться

Последний пункт — выбор целевого формата. TXLSWorkbook.SaveAs по умолчанию пишет BIFF (xlExcel97), а проталкивание содержимого XLS в .xlsx идёт через мост SaveXLSWorkbookAsXLSX с пониженной точностью. Выбирайте фасад по формату, который собираетесь отдавать, ещё на этапе проектирования, а не стройте на одном и конвертируйте в конце конвейера

Для той половины типового проекта замены, что отвечает за загрузку данных, шаблоны экспорта из базы данных в книгу покрывают и компонент, и написанный вручную цикл, а когда число строк доходит до шестизначного, приёмы производительности на больших книгах становятся разницей между минутами и секундами. Отчёты, построенные по макетам из дизайнера, разобраны в руководстве по генерации отчётов из шаблонов

HotXLS поставляется исходным кодом Object Pascal для Delphi и C++Builder; редакции, лицензирование и полный справочник API — на странице продукта HotXLS Delphi Component