rails
July 8

Больше PDF богу PDF

Рано или поздно перед практически любой системой, которая автоматизирует обслуживание клиентов, встает задача генерации документов. Зачастую это договоры, заявки и другие документы, призванные юридически оформить взаимоотношения клиента с компанией. А где юридическая значимость, там и порой маниакальное стремление к четкому совпадению итогового документа с некой формой, установленной контролирующим органом.

Формировать итоговый документ так, чтобы ни один контроллер не придрался можно несколькими способами:

  1. разработать собственный генератор документов в нужном виде, с использованием данных из СУБД проекта;
  2. использовать некие шаблоны, которые сможет генерировать администратор системы без навыков программиста.

У первого способа есть как очевидные минусы в виде привлечения программиста на каждый чих, так и менее очевидные в виде необходимости обеспечения поддержки нормального отображения документа во всех программах просмотра и редактирования.

Шаблоны же в свою очередь зачастую создаются в актуальном ПО и по умолчанию способны обеспечить нужный уровень совместимости. Плюс если вдруг все таки что-то идет не так, то time to market у исправлений будет минимальный, так как их может внести любой обученный человек.

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

Также с ним не сложно работать на программном уровне, так как он, по сути, представляет собой набор заZIPованных XML с текстом, а значит нам достаточно:

  1. распаковать DOCX-шаблон;
  2. найти внутри структуры XML’ек метки, обозначающие необходимость вставки тех или иных данных;
  3. подставить данные по меткам;
  4. запаковать набор XML обратно в DOCX.

Но, тем не менее, у DOCX есть небольшой минус - хоть его и могут открывать десятки офисных пакетов, каждый их них делает это чуть по-своему. Да даже в рамках разных версий одного и того же пакета могут быть отклонения в отображении.

А так как требование к четкому соответствию и неизменности никуда не девалось, мы приняли решение конвертировать корректно заполненный DOCX в PDF, который не так склонен к видоизменению отображения между разными программами просмотра.

Итого процесс работы выглядит следующим образом:

  1. администратор системы в своем офисном пакете создает шаблон и размечает его названиями переменных, из предзаданного списка;
  2. шаблон загружается в систему;
  3. в рамках какого-либо бизнес-процесса система находит все привязанные к нему шаблоны и заполняет их;
  4. результат заполнения отдается на конвертацию в 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 остался нами доволен!