Как я запускал шаблонизатор офисных документов как сервис

Приветствую!

Прошло некоторое время с момента запуска, и я думаю пришла пора поделиться опытом создания собственного сервиса для подготовки документов. Возможно он будет полезен начинающим программистам о том, как не стоит делать и на чем можно сэкономить время.

Постановка проблемы

Однажды ко мне обратился друг со следующим вопросом: есть накопленная экспертиза в виде базы юридических документов, хотелось бы эту экспертизу монетезировать в виде сервиса. На что было предложено встретиться через некоторое время и обсудить техническое задание.

Анализ спроса

На сервисе подбора слов от Яндекса были найдены обнадеживающие цифры. Люди постояно ищут образцы документов.

image-loader.svg

Анализ конкурентов

В качестве конкурентов виделся только Freshdoc. Он позволяет после заполнения анкеты за небольшую сумму скачивать документы. Как преимущество можно было предоставить возможность редактировать текст, т.к основными пользователями сервиса должны были стать опытные юристы. Они отвечали бы за дальнейшее наполнение и актуализацию базы документов. А также убрать необходимость вводить однотипные параметры несколько раз: фио, адреса. Второй категорией пользователей должны были стать обычные пользователи и менее опытные юристы, которые бы скачивали готовые документы, созданные по готовым шаблонам.

Техническое задание

Необходимо разработать сервис, в котором пользователь без предварительного обучения сможет подготовить под себя шаблоны юридических документов (.doc/.docx файлы), а также вести свои дела. В деле необходимо предусмотреть возможность заводить параметры. Примерами параметров могут служить: телефоны, адрес и наименования судов, ФИО, реквизиты и т.д.

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

Минимально значимый продукт

На коленке была быстро написана первая версия приложения. Технически это был монолит на .net core версии 2.1. На фронтенде выбор пал на VueJs с Vuetify как более простого, если сравнивать с React, и популярного на тот момент решения. Для импорта и конвертации документов использовался сервис docsbox от dveselov. Docsbox написан на питоне и имеет удобное HTTP API и при необходимости мог масштабироваться, т.к в нем есть очередь на Redis и воркер процессы для вызова API LibreOffice (pyuno). На фронтенде использовался rich text editor: tinymce с плагином templates. В качестве хостинга выбор пал на Digital Ocean с dokku. Был поднят CI на Gitlab и Nexus репозиторий для хранения пакетов и библиотек.

внешний вид первой версиивнешний вид первой версии

Впечатления и недостатки первой версии

Заказчику прототип понравился, было решено продолжить работу. Но возникли технические трудности на более чем тривиальных документах: в редакторе HTML после импорта .doc и генерации PDF постоянно съезжала верстка документов. Были попытки заменить SDK LibreOffice на библиотеки, которые могли дать прирост в качестве документа HTML для редактирования, максимальная ставка делалась на Open XMl SDK, но даже она давала артефакты в HTML редакторе.

Была даже выпущена отдельная версия с редактором .tex на библиотеке pandoc по аналогии с сервисом Overleaf. Но использовать .tex человеку далекому от программирования не представлялась возможным.

пример генерации превью документа .texпример генерации превью документа .tex

Ненужные улучшения

Монолит был преобразован в микросервисы. От MVC части отделился API проект, добавлен отдельный процесс Worker для обработки событий из API. Здеь очень помогла библиотека Marten и ее поддержка стримов событий.

Очень много времени было потрачено на настройку SSR и webpack. Изначально использовалилсь штатные node services для пререндерера от Microsoft, но их вскоре задепрекейтили. Для чего был написан свой велосипед на ViewComponent и KoaJs

public class PrerendererViewComponent : ViewComponent
    {
        private readonly IConfiguration _configuration;
        private readonly IHttpClientFactory _factory;
        private readonly ILogger _logger;

        public PrerendererViewComponent(IConfiguration configuration, IHttpClientFactory factory,
            ILogger logger)
        {
            _configuration = configuration;
            _factory = factory;
            _logger = logger;
        }

        public async Task InvokeAsync(
            Dictionary claims)
        {
            var upstreamHost = new Uri(_configuration["NODE:PRERENDERER:URL"]);

            var uri = new Uri(UriHelper.BuildAbsolute(
                upstreamHost.Scheme,
                HostString.FromUriComponent(upstreamHost),
                PathString.FromUriComponent(upstreamHost),
                HttpContext.Request.Path,
                HttpContext.Request.QueryString));

            var accessToken = await HttpContext.GetTokenAsync("access_token");
            var prerenderedOptions = JsonSerializer.SerializeToUtf8Bytes(new
            {
                claims,
                accessToken
            });

            _logger.LogDebug("prerendering: {0}", Encoding.UTF8.GetString(prerenderedOptions));

            var streamContent = new StreamContent(new MemoryStream(prerenderedOptions));
            streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
            var request =
                HttpContext.Request.CreatePrerendererHttpRequest(
                    streamContent, _logger);
            request.Headers.Host = uri.Authority;
            request.RequestUri = uri;
            
            var prerenderedResponse = await _factory.CreateClient().SendAsync(request);

            if (prerenderedResponse.StatusCode == HttpStatusCode.Unauthorized)
            {
                throw new UnauthorizedException();
            }

            if (prerenderedResponse.StatusCode == HttpStatusCode.Forbidden)
            {
                throw new ForbiddenException();
            }

            prerenderedResponse.EnsureSuccessStatusCode();

            var htmlString = await prerenderedResponse.Content.ReadAsStringAsync();

            return new HtmlContentViewComponentResult(new HtmlString(htmlString));
        }

и тогда из Index.cshtml вызывается следующий код



    @section Styles
    {
        
    }
    
    @section Preload {
        
        
    }



@await Component.InvokeAsync("Prerenderer", new { claims = Model.Claims.ToDictionary(claim => claim.Type, claim => claim.Value) })
@section Scripts { }

Также ушло время на прикручивание и настройку OAuth 2 с OpenID Connect. Был взят стандартный IdentityServer 4 с хорошим дополнением в виде админки Skoruba Identity Server.

В IdentityServer были добавлены тенанты на уровне единой базы. Далее идентификатор тенанта использовался для запросов данных в ORM Marten.

Также был применен подход backend for frontend. Это позволило уйти от CORS запросов для API с одной стороны, с другой стороны реализовать автоматическое продление токена.

С помощью велосипеда на SSR можно было бы добавлять новые frontend фреймворки, разделяя их на уровне ViewComponent. Хоть эта идея и казалась очень интересной, но такой необходимости так и не возникло.

Принципиально другое решение. Версия 2

Как-то вечером я наткнулся на статью про LibreOffice Online. В нем для рендеринга использовался canvas, что решало мою проблему с отображением документов, но весь код для подстановки параметров в tinymce пришлось выкинуть, а вместо них писать макросы на питоне

def InsertVariable(variable_name, variable_value):
    # get the doc from the scripting context which is made available to all scripts
    desktop = XSCRIPTCONTEXT.getDesktop()
    model = desktop.getCurrentComponent()


    # check whether there's already an opened document. Otherwise, create a new one
    if not hasattr(model, "Text"):
        model = desktop.loadComponentFromURL(
            "private:factory/swriter", "_blank", 0, ())

    # get the XText interface
    xModel = XSCRIPTCONTEXT.getDocument()

    field = build_variable(model, variable_name, variable_value)   

    # the writer controller impl supports the css.view.XSelectionSupplier interface
    xSelectionSupplier = xModel.getCurrentController()

    # see section 7.5.1 of developers' guide
    xIndexAccess = xSelectionSupplier.getSelection()
    count = xIndexAccess.getCount()
    if count >= 1:  # ie we have a selection
        i = 0

    while i < count:
        xTextRange = xIndexAccess.getByIndex(i)

        theString = xTextRange.getString()

        xText = xTextRange.getText()

        xWordCursor = xText.createTextCursorByRange(xTextRange)

        xWordCursor.setPropertyValue("CharStyleName", variables_style_name)

        xText.insertTextContent(xWordCursor, field, True)

        xSelectionSupplier.select(xTextRange)            

        i += 1

    return None

внешний вид второй версиивнешний вид второй версии

Недостатки версии 2

Почти сразу я столкнулся с ограничением LibreOffice Online на 10 документов и пришлось собирать его из исходников. Также была исправлена проблема с терминированием TLS. Сервис просто не запускался. После того, как были изменены конфиги и поправлен код проверки инициализации TLS сервис наконец ожил. Для WOPI сервера было решено использовать открытый проект на .net. Для хранения файлов s3-совместимый продукт — Minio. Так как сервис предполагал хранение персональных данных, то у заказчика всегда должна была быть возможность развертывания локально. Было понятно к этому времени, что любовь LibreOffice Online к потреблению памяти на открытый документ

image-loader.svg

убивает идею хостинга сервиса на одном сервере. Нужно было переходить на более отказоустойчивую архитектуру. Как стандарт выбор пал на Kubernetes, а так как денег у меня было мало, то его дистрибутив k3s от Rancher.

Kubernetes на Hetzner

Целью было создать демо-кластер для разработки стоимостью до 50 euro в месяц.

Был найден удобный terraform скрипт и написаны helm-чарты.

Для мониторинга сначала прикрутил New Relic c pixie, но из-за высокой стоимости New Relic (на конец месяца из-за одних только метрик насчиталось 400 usd) и повышенных требований на агентов pixie (2 GB RAM на агент) пришлось заменить на cilium.

Для cilium в скриптах понадобилось лишь выключить flannel.

#!/bin/bash

export DEBIAN_FRONTEND=noninteractive 
apt-get update
apt-get upgrade -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" --force-yes

# Installing and enabling fail2ban
apt-get install -y fail2ban
systemctl start fail2ban
systemctl enable fail2ban

INTERNALIP=`ip -br a | grep ens10 | awk '{ print $3 }' | awk -F\/ '{print $1}'`

# Initializing Worker
curl -sfL https://get.k3s.io | K3S_TOKEN=${secret} \
    sh -s - server --disable-network-policy --flannel-backend=none --no-flannel --cluster-init --token=${secret} --disable=traefik,local-storage,servicelb --kubelet-arg="cloud-provider=external" --disable-cloud-controller --tls-san ${lb_ip} -i $INTERNALIP --node-external-ip $INTERNALIP 

sudo mount bpffs -t bpf /sys/fs/bpf

архитектура приняла свой финальный видархитектура приняла свой финальный вид

Лендинг и уроки

Чтобы не наполнять базу пользователей ботами регистрацию в IdentityServer закрыл. Для запроса доступов в систему поднял простейший статический сайт на eleventy с шаблоном от google. До добавления аналитики сайт выдавал 100 по оценкам page speed test.

e7c083eb91841f0b2b8cd86bdb640491.jpg

После добавления сервисов аналитики сайт деградировал до приемлимых 80.

Вместо документации записал на youtube обучащие ролики с субтитрами.

Запуск и аналитика в Яндекс.Бизнесе

Перед запуском я написал в несколько популярных юридических форумов. Ответ получил только в одном с предупреждением о рекламе. Самое ценное, что было в ответе, следующее:

Забавно. На сколько мне известно, люди обычно ищут шаблоны, а не программы для их составления.

результаты работы рекламы за неделюрезультаты работы рекламы за неделю

Выводы

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

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

© Habrahabr.ru