Приветствую!
Прошло некоторое время с момента запуска, и я думаю пришла пора поделиться опытом создания собственного сервиса для подготовки документов. Возможно он будет полезен начинающим программистам о том, как не стоит делать и на чем можно сэкономить время.
Постановка проблемы
Однажды ко мне обратился друг со следующим вопросом: есть накопленная экспертиза в виде базы юридических документов, хотелось бы эту экспертизу монетезировать в виде сервиса. На что было предложено встретиться через некоторое время и обсудить техническое задание.
Анализ спроса
На сервисе подбора слов от Яндекса были найдены обнадеживающие цифры. Люди постояно ищут образцы документов.
Анализ конкурентов
В качестве конкурентов виделся только 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 человеку далекому от программирования не представлялась возможным.
Ненужные улучшения
Монолит был преобразован в микросервисы. От 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)
Medd
05.08.2021 21:51вывод четкий и понятный. Автор спасибо за статью. Я так же занимаюсь сервисом и пусть пользуются не так много, хотя есть потенциал, я доволен так как я погружаюсь во что то новое чего не хватает на работе.
Возможно, если тебе это действительно нравится, это принесёт плоды и аудиторию.
ktzv
05.08.2021 21:52+5Вставлю свои пять копеек: работа проделана колоссальная, автору респект. Это раз.
Самая главная проблема – вы вообще не доносите до потенциального клиента выгоду предложения. Человеку с улицы видно какой-то сайт, который вообще непонятно что делает и какую боль облегчает.
Видео положения не спасает, потому что
без звука
записаны под линуксом, что тоже скорее всего вызывает ненужные триггеры у людей, привыкших к интерфейсу Windows
Забавно. На сколько мне известно, люди обычно ищут шаблоны, а не программы для их составления.
Даже по этому комментарию видно, что пользователь не понял, что он может запихать туда готовый документ и по нему уже сделать шаблон.
Плюс вы зря зациклились на юристах – тем же продажникам или самозанятым, каждый день делающим договоры, отличающиеся названиями и реквизитами контрагентов, это бы зашло на ура, как пример. Ниша гораздо шире.
Резюмируя, я бы посоветовал:
чётко сформулировать все фичи проекта и какие проблемы он решает
исходя из этого переделать лендинг
переписать видео со звуком и под виндой
расширить предложение на более широкий круг потенциальных потребителей
aborouhin
05.08.2021 22:37Любопытно. Я вообще за российским рынком LegalTech-решений более или менее слежу, но про Ваш продукт узнал только из этого поста. Вы на конкурентов вообще смотрели? Freshdoc, ТурбоКонтракт, Докзилла, Doc.one? Чем от них выделяться планируете?
P.S. За описание использованных технологий и наступленных граблей спасибо, ценно.aborouhin
06.08.2021 03:06P.S. Невнимательно прочитал текст, про Freshdoc не заметил. Но остальные заслуживают как минимум не меньшего внимания. Хотя только Freshdoc из всех пытается «продавать шаблоны, а не программы», но ценность такого подхода сильно зависит от целевой аудитории проекта. А вот в части функционала другие конкуренты могут предложить более интересные решения.
Lelant0s
05.08.2021 22:38"Самое ценное, что было в ответе, следующее:
Забавно. На сколько мне известно, люди обычно ищут шаблоны, а не программы для их составления."
Пусть эту фразу вспоминают все те, кто маркетологов называет "маркетолухами" и считает себя умнее.
А автора - с почином (без сарказма).
zoldaten
06.08.2021 17:30+1Как практикующий юрист скажу, что все эти шаблонизаторы хороши только если ты сидишь на какой-нибудь канцелярщине и штампуешь одно и то же изо дня в день. Но в этом случае тебя вообще робот может заменить. Второй случай — шаблон нужен срочно и вчера, но все равно это быстро гуглится бесплатно. Главное «рамку» получить, а наполнить можно уже из головы. В Консультанте+ это все есть.
Чего реально не хватает — это форм или их переводов по иностранным юрисдикциям, законодательства сопряженных государств, хотя бы СНГ. Как-то торговые дома закрывал в Грузии, Абхазии. Найти что-то из их законов, а уж составить что-то по ним просто ад. Понятно, узкая специфика, но она на вес золота.
pavelpromin
07.08.2021 16:36Реквестирую шаблонизатор как API.
Когда на POST запрос с JSON данными, на основе своего или публичного шаблона генерируется редактируемый документ (docx/odf).И оплата происходит по факту запроса, а не по подписке (считай процессорное время). Фронтендом может быть боты, SPA на локалхосте или приложения под ОС
В моем случае, не найдя вариантов, пришлось сделать на питоне как бэкенд телеграм бота.
402d
А с вижуал бейсиком не знакомы ?
Просто идею можно было обкатать на минималках.
Формы . превьюшки и т.д . А вот сам документ человеку дается в виде архива.
Внутри вордовский файл формы для мерга по данным из dbf файла.
Т.е. слияние делается на машине самого клиента .