Больше PDF богу PDF
Рано или поздно перед практически любой системой, которая автоматизирует обслуживание клиентов, встает задача генерации документов. Зачастую это договоры, заявки и другие документы, призванные юридически оформить взаимоотношения клиента с компанией. А где юридическая значимость, там и порой маниакальное стремление к четкому совпадению итогового документа с некой формой, установленной контролирующим органом.
Формировать итоговый документ так, чтобы ни один контроллер не придрался можно несколькими способами:
- разработать собственный генератор документов в нужном виде, с использованием данных из СУБД проекта;
- использовать некие шаблоны, которые сможет генерировать администратор системы без навыков программиста.
У первого способа есть как очевидные минусы в виде привлечения программиста на каждый чих, так и менее очевидные в виде необходимости обеспечения поддержки нормального отображения документа во всех программах просмотра и редактирования.
Шаблоны же в свою очередь зачастую создаются в актуальном ПО и по умолчанию способны обеспечить нужный уровень совместимости. Плюс если вдруг все таки что-то идет не так, то time to market у исправлений будет минимальный, так как их может внести любой обученный человек.
Для шаблонизации нами был выбран формат DOCX, так как умение редактировать такие файлы входит в базовую компьютерную грамотность и в 99% случаев, компании держат свои печатные формы, которые потом клиенты заполняют от руки, именно в нем, а значит им будет проще переносить документооборот в наше ПО.
Также с ним не сложно работать на программном уровне, так как он, по сути, представляет собой набор заZIPованных XML с текстом, а значит нам достаточно:
- распаковать DOCX-шаблон;
- найти внутри структуры XML’ек метки, обозначающие необходимость вставки тех или иных данных;
- подставить данные по меткам;
- запаковать набор XML обратно в DOCX.
Но, тем не менее, у DOCX есть небольшой минус - хоть его и могут открывать десятки офисных пакетов, каждый их них делает это чуть по-своему. Да даже в рамках разных версий одного и того же пакета могут быть отклонения в отображении.
А так как требование к четкому соответствию и неизменности никуда не девалось, мы приняли решение конвертировать корректно заполненный DOCX в PDF, который не так склонен к видоизменению отображения между разными программами просмотра.
Итого процесс работы выглядит следующим образом:
- администратор системы в своем офисном пакете создает шаблон и размечает его названиями переменных, из предзаданного списка;
- шаблон загружается в систему;
- в рамках какого-либо бизнес-процесса система находит все привязанные к нему шаблоны и заполняет их;
- результат заполнения отдается на конвертацию в PDF.
На выходе клиент или менеджер получает документ, заполненный всеми необходимыми данными и почти гарантированно выглядящий так, чтобы у контролирующего органа не было претензий.
И вдруг пришло больше 1 клиента
Ну точней в нашем случае клиентов было под 40 тысяч в день и средние потребности в генерации составляли по 15-20 документов в секунду.
Как думаю понятно из предшествующей простыни текста, процесс генерации даже 1 документа нетривиален: тут тебе и распакуй\запакуй иногда вполне не маленькие DOCX, распарси XML, а потом это все ещё и в PDF сконвертируй. И так надо было делать десятки раз в секунду, причем неплохо было бы и в других местах не заставлять клиентов ждать.
Бенчмаркинг показал, что в целом с подготовкой заполненного DOCX мы справлялись вполне быстро. 100 файлов подготавливались из шаблона, в среднем, за 4 секунды. Причем в качестве шаблона использовалась заведомо превышающая все, даже самые безумные, границы бюрократических требований моя магистерская диссертация.
А вот с конвертацией DOCX в PDF дело обстояло гораздо хуже…
Тут стоит уточнить, что способов конвертации DOCX в PDF не так чтобы уж много, а из дающих стабильный результат так и вообще нами найден был только один - консольный вызов офисного пакета LibreOffice.
Команда выглядит вот так и включает в себя все доступные параметры оптимизации своего исполнения, какие нам удалось найти:
lowriter --headless --quickstart --minimized --nolockcheck --convert-to pdf --outdir /path_for_pdf in_file.docx
И с такими параметрами 1 магистерская диссертация конвертируется из DOCX в PDF за 2.8 секунды! Но даже если “понизить планочку” и скармливать более-менее реальные по размеру шаблоны, то время падает до 1.2 секунды, что все равно преступно много. А значит, что до нашей цели в 20-30 генераций в секунду ещё очень далеко.
Без друзей меня — чуть-чуть, а с друзьями — много!
Если с вертикальной масштабируемостью не задалось - время переходить к горизонтальной. Но тут LibreOffice подкидывает определенную подлянку - два вызова lowriter
с конвертацией в одной системе одновременно работать отказывались.
То есть один docker-контейнер с веб-приложением в один момент времени мог генерировать только 1 документ, несмотря на то, что умел обрабатывать много пользовательских запросов одновременно. На любую попытку в параллель запустить два процесса генерации, LibreOffice кидал ошибку для второго документа.
Мы столкнулись с этим ещё до появления значимой нагрузки и решили проблему “в лоб”, просто обернув консольный вызов сначала в эксклюзивную блокировку СУБД(не делайте так никогда!), а когда с ней начались проблемы, то сменили на блокировку через Redis.
И все было хорошо - наш многопоточный сервер приложений, получал множество запросов, среди которых были и запросы на генерацию документов, которые выполнялись по одному за раз благодаря блокировке.
И собственно эта самая блокировка нас и подвела, ведь добавляя инстансы приложению мы не увеличивали производительность - мешала блокировка. Но и избавиться от блокировки мы не могли, так как не можем контролировать, на какой из многопоточных инстансов прилетит следующий запрос на генерацию.То есть надо было обеспечить блокировку ресурса, но только в рамках контейнера.
И вместо сложных схем с введением в Redis-блокировку идентификаторов стенда, мы снова пошли самым прямым путем - реализовали файловую блокировку.
Пример простой реализации разделения доступа к ресурсу на основе блокировки файла:
class LockManager def self.file_lock(lock_name) Rails.logger.info "try lock #{lock_name}" File.open(File.join(Dir.tmpdir, "#{lock_name}.lock"), 'w+') do |f| f.flock(File::LOCK_EX) Rails.logger.info "lock #{lock_name}" yield end.tap{ Rails.logger.info "release lock #{lock_name}" } end end
Такой вид блокировки позволил нам поднять отдельно 30 инстансов, роутингом направить на них только запросы связанные с генерацией файлов и тем самым при сохранившейся скорости генерации 1,5-2 секунды на 1 файл, по итогу получить общую производительность в 15-20 генераций в секунду.
Я не жадный, я экономный
Если вы читали другие статьи нашего блога, то наверное знаете, что разработку мы зачастую ведем на фреймворке Ruby On Rails, который, как и большинство фреймворков интерпретируемых языков, не славиться экономностью ресурсов.
Каждый инстанс нашего приложения в спокойном состоянии занимает около 500 мб оперативки, а на дальней дистанции так и вообще способен выжирать до 2 Гб. Умножаем на 30. Огорчаемся.
Помог разговор с братом по разуму - таким же длинным техлидом, как и я сам. С высоты своей колокольни, он задал только один вопрос:
А ты уверен, что LibreOffice не может генерировать файлы параллельно?
И тут меня обуяли сомнения, а точно ли все параметры для оптимизации запуска LibreOffice мы применили? Ведь в тот момент проблемы распараллеливания перед нами не стояло, а когда встретились с ней, то и копать сильно глубоко не стали, просто убедившись, что текущий вариант не работает.
Поисковик корпорации зла привел меня в тред на форуме самого LibreOffice, где некто под ником mikekaganski(дай бог ему здоровья), рассказывает о том, что LibreOffice прекрасно умеет параллелиться в рамках разных пользовательских профилей.
И, как ни странно, параллельная генерация с указанием двух разных профилей работала корректно! А значит и прожорливые инстансы больше не нужны. 1 docker-контейнер с сервером-приложений на 30 потоков, делал ровно тоже самое, что и 30 этих самых инстансов прежде!
Но нас ждал ещё один подвох - просто тупо создавать динамические папки под профили на каждую генерацию оказалось слишком накладно. LibreOffice тратил на инициализацию каждой из них порядка 250мс. Как говориться мелочь, а не приятно. На 20-30 целевых файлов, это лишние 5-7 секунд. Да и чистить весь этот мусор надо было как-то.
Поэтому пришлось прикручивать какие-то механизмы ротации предварительно созданных профилей. Для этих целей использовали атомарную операцию редиса INCR, старое доброе деление по модулю, ну, а если все таки запросов станет больше, чем нужно, то файловая блокировка поставит повторяющиеся номера в очередь.
В итоге получился вот такой код:
def self.execute in_file, dir profile_num = MyRedis.incr(LOFFICE_PROFILES_COUNTER_KEY) % LOFFICE_PROFILES_COUNT cmd = SECRETS['ooffice_exe'].presence || 'lowriter' Terrapin::CommandLine.path = SECRETS['ooffice_path'] if SECRETS['ooffice_path'].present? LockManager.file_lock("docx_to_pdf_convert_#{profile_num}") do params = [ '--headless', '--quickstart', '--minimized', '--nolockcheck', "-env:UserInstallation=file:///tmp/profile#{profile_num}", '--convert-to pdf', "--outdir #{dir}", "#{in_file}" ].join(' ') line = Terrapin::CommandLine.new(cmd, params) line.run() if line.exit_status.zero? puts line.command_output else txt = "Command #{cmd} #{params} with '#{in_file.inspect}' and '#{dir.inspect}' due with error #{line.exit_status}. \n" txt += "STDOUT:\n" + line.command_output.force_encoding('UTF-8') txt += "\n\nSTDERR:\n" + line.command_error_output.force_encoding('UTF-8') raise Error, txt end end rescue Terrapin::ExitStatusError => e raise Error, "Command for #{[in_file, dir].inspect} was ended with error #{e}. LO is #{cmd}, Terrapin::CommandLine.path is #{Terrapin::CommandLine.path || 'not setted in SECRETS'}" rescue Terrapin::CommandNotFoundError => e raise Error, "Command #{[in_file, dir].inspect} was not found. LO is #{cmd}, Terrapin::CommandLine.path is #{Terrapin::CommandLine.path || 'not setted in SECRETS'}" end def self.init_loffice LOFFICE_PROFILES_COUNT.times do |i| FileUtils.mkdir_p "/tmp/profile#{i}" File.chmod(0777, "/tmp/profile#{i}") end MyRedis.set LOFFICE_PROFILES_COUNTER_KEY, 0 end
А можно быстрее?
На заданную цифру мы вышли, но, что называется “впритык”. Наращивание объемов генерации требовало добавления потоков и профилей, а может даже и инстансов. И мы решили постучаться в потолок вертикального масштабирования.
Вскоре обнаружилась новая зона роста - LibreOffice очень много времени тратит на собственную инициализацию, несмотря на всю пачку параметров, заставляю его быть максимально быстрым. Но при этом он умеет в пакетную обработку! То есть вместо конкретного DOCX в параметре in_file
можно передать /path/*.docx
и улететь в космос в абсолютных цифрах.
Шутка ли, 10 файлов в поштучном режиме конвертируются 12 секунд, а в пакетном - за 1.8 секунды!
Но пакетирование, в свою очередь, заставляет гадать на кофейной гуще пытаясь соблюсти баланс между оптимальным размером пакета и временем ожидания сбора этого пакета. Так как нагрузка у нас даже на этом стенде, по большому счету, неравномерная, а другие стенды нашего облачного решения работают вообще по своим собственным законам, в эти дебри мы лезть не решились.
Тем более что бизнес-процессы решили все за нас: в каждом из них генерируется от 1 до N документов. Есть как мелкие заявки на 1 файл, так и какое-нибудь открытие счета с 10-15 документами. Но в среднем по больнице значение 3-5 документов на бизнес-процесс.
Собственно по этому принципу мы и стали собирать пакеты - получая запрос на генерацию файлов по определенному бизнес-процессу приложение находит все привязанные шаблоны DOCX, заполняет их в конкретные DOCX и пакетно через LibreOffice превращает их всех в PDF.
Все эти меры позволили нам сократить среднее время генерации полного пакета документов для отдельных бизнес-процессов с 30 до 2 секунд при том, что была обеспечена уверенная генерация 30 файлов в секунду и даже заложен запас по производительности.
Плюс в любой момент мы можем стать транжирами, развернув снова 30 инстансов и за час обернуть Землю в 3 слоя PDF файлов. Я уверен, что Бог PDF остался нами доволен!