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

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

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

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

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

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

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

В качестве конкурентов виделся только 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<PrerendererViewComponent> _logger;

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

        public async Task<IViewComponentResult> InvokeAsync(
            Dictionary<string, string> 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 вызывается следующий код

<environment names="Production">

    @section Styles
    {
        <link rel="stylesheet" href="~/dist/css/main.css"/>
    }
    
    @section Preload {
        <link rel="preload" href="~/dist/css/main.css" as="style" />
        <link rel="preload" href="~/dist/js/main.js" as="script" />
    }

</environment>

<div id="app">  

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

</div>

@section Scripts
{
    <script src="/dist/js/main.js"></script>
}

Также ушло время на прикручивание и настройку 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 к потреблению памяти на открытый документ

убивает идею хостинга сервиса на одном сервере. Нужно было переходить на более отказоустойчивую архитектуру. Как стандарт выбор пал на 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.

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

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

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

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

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

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

Выводы

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

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

Комментарии (8)


  1. 402d
    05.08.2021 20:10

    А с вижуал бейсиком не знакомы ?

    Просто идею можно было обкатать на минималках.

    Формы . превьюшки и т.д . А вот сам документ человеку дается в виде архива.

    Внутри вордовский файл формы для мерга по данным из dbf файла.

    Т.е. слияние делается на машине самого клиента .


  1. Medd
    05.08.2021 21:51

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

    Возможно, если тебе это действительно нравится, это принесёт плоды и аудиторию.


  1. ktzv
    05.08.2021 21:52
    +5

    Вставлю свои пять копеек: работа проделана колоссальная, автору респект. Это раз.

    Самая главная проблема – вы вообще не доносите до потенциального клиента выгоду предложения. Человеку с улицы видно какой-то сайт, который вообще непонятно что делает и какую боль облегчает.

    Видео положения не спасает, потому что

    • без звука

    • записаны под линуксом, что тоже скорее всего вызывает ненужные триггеры у людей, привыкших к интерфейсу Windows

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

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

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

    Резюмируя, я бы посоветовал:

    • чётко сформулировать все фичи проекта и какие проблемы он решает

    • исходя из этого переделать лендинг

    • переписать видео со звуком и под виндой

    • расширить предложение на более широкий круг потенциальных потребителей


  1. aborouhin
    05.08.2021 22:37

    Любопытно. Я вообще за российским рынком LegalTech-решений более или менее слежу, но про Ваш продукт узнал только из этого поста. Вы на конкурентов вообще смотрели? Freshdoc, ТурбоКонтракт, Докзилла, Doc.one? Чем от них выделяться планируете?

    P.S. За описание использованных технологий и наступленных граблей спасибо, ценно.


    1. aborouhin
      06.08.2021 03:06

      P.S. Невнимательно прочитал текст, про Freshdoc не заметил. Но остальные заслуживают как минимум не меньшего внимания. Хотя только Freshdoc из всех пытается «продавать шаблоны, а не программы», но ценность такого подхода сильно зависит от целевой аудитории проекта. А вот в части функционала другие конкуренты могут предложить более интересные решения.


  1. Lelant0s
    05.08.2021 22:38

    "Самое ценное, что было в ответе, следующее:

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

    Пусть эту фразу вспоминают все те, кто маркетологов называет "маркетолухами" и считает себя умнее.

    А автора - с почином (без сарказма).


  1. zoldaten
    06.08.2021 17:30
    +1

    Как практикующий юрист скажу, что все эти шаблонизаторы хороши только если ты сидишь на какой-нибудь канцелярщине и штампуешь одно и то же изо дня в день. Но в этом случае тебя вообще робот может заменить. Второй случай — шаблон нужен срочно и вчера, но все равно это быстро гуглится бесплатно. Главное «рамку» получить, а наполнить можно уже из головы. В Консультанте+ это все есть.
    Чего реально не хватает — это форм или их переводов по иностранным юрисдикциям, законодательства сопряженных государств, хотя бы СНГ. Как-то торговые дома закрывал в Грузии, Абхазии. Найти что-то из их законов, а уж составить что-то по ним просто ад. Понятно, узкая специфика, но она на вес золота.


  1. pavelpromin
    07.08.2021 16:36

    Реквестирую шаблонизатор как API.

    Когда на POST запрос с JSON данными, на основе своего или публичного шаблона генерируется редактируемый документ (docx/odf).И оплата происходит по факту запроса, а не по подписке (считай процессорное время). Фронтендом может быть боты, SPA на локалхосте или приложения под ОС

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