Привет, Хабр. Меня зовут Серафим Недошивин, я 18-летний разработчик на Go, PHP и TS. Эта статья не посвящена тонкостям устройства gc нашего любимого языка программирования Go и уж тем более не является строго технической. Более того, эта статья является своего рода одой управленческому учёту малого бизнеса в России, а также всем людям, отдавшим свою жизнь в попытках создать наиболее подходящий инструмент для этой задачи.
Автор прекрасно понимает, что тема ERP/CRM систем обсасана со всех сторон ещё десятилетие назад. Огромное количество разработчиков и по сей день зарабатывают на внедрении систем наподобие 1C:ERP в предприятия. Однако поспешу обрадовать читателя: сегодня я попытаюсь описать процесс создания своего рода аналога такой системы на довольно необычном для этой сферы стеке и углубиться в тонкости её устройства.
Проще говоря, я опишу процесс создания системы управления малым бизнесом на Go, опишу ключевые архитектурные проблемы, возникшие передо мной, и затрону ряд зависимостей, которые я успешно приобрел и преумножил в процессе реализации такого инструмента (включая трамадоловую и алкогольную). Кроме того, в этой статье затрагиваются аспекты реализации SAAS-подобных систем с физической изоляцией данных, что, в свою очередь, применимо к огромному количеству сфер разработки, кроме ERP систем.
Также стоит заметить, что эта статья служит отличным пособием, как НЕ надо реализовывать свои проекты, для всех молодых студентов и школьников.
Предисловие
Моё знакомство с системами управления предприятиями произошло в районе 15 лет в сфере управления станциями технического обслуживания, проще говоря — автосервисами. Всё началось по моей собственной инициативе упорядочить учёт агрегатов и, конечно же, клиентов в рамках одного небольшого автосервиса. На тот момент меня по неведомой мне причине терзала мысль создать систему, в которой каждый товар (он же агрегат), услуга, клиент и доход/расход были бы взаимосвязаны и на основании истории ведения предприятия можно было бы рассчитывать текущий баланс компании.
Неправда ли, задача довольно распространённая и примитивная?
Нет, далеко не примитивная. На первый взгляд предстояло реализовать всего лишь небольшую систему, рассчитанную максимум на 5-6 сотрудников, в которой клиенты, товары и финансы складывались в один пазл. Но заглядывая вглубь каждой такой задачи, вы натыкаетесь на огромное количество проблем, в том числе относительной самостоятельности и в то же время связанности разных модулей системы, производительности JOIN операций базы данных и, конечно же, проблем “безопасности”.
Собственно говоря, спустя 3-4 месяца была готова первая версия такой системы, сделанная во многом наобум и, конечно же, имеющая тонну уязвимостей и багов, в первую очередь на уровне базы данных. Кроме того, сделана эта система была на голом PHP (я не смеюсь, без PSR-4), что, в свою очередь, означало время ответа каждого эндпоинта API от 100 до ± 300 мс, что очевидно неблагоприятно сказывалось на пользовательском опыте. Понятное дело, что помимо проблем с серверной реализацией, визуализация всего этого ассорти хромала и состояла по большей части из такого же голого HTML+CSS, вперемешку с сотнями AJAX-запросов и рендерингом на сервере.
Приблизительно в то же время я занялся просвещением в сфере Google языка и параллельно пытался реализовать некое подобие мультитенантного фреймворка с физической изоляцией данных на всё том же старом добром PHP. Подробнее тут (включите VPN).
И в конечном итоге дошёл до следующей версии, с нуля переписанной на Go + pgx | TS + Next.js 15 + SCSS. На реализацию которой у меня ушло 12 месяцев, вся психика, пара десятков блистеров и пара сотен баклашек старого мельника.
Архитектура & Стек
Для начала определим концепцию: мы реализуем систему управления операционными показателями, а не бухгалтерией. По моему собственному опыту могу утверждать, что попытки реализовать бухгалтерию в одиночку или с командой без пары десятков аналитиков не приведут ни к чему хорошему. 1С хороша не за счёт своего языка или технологий, а за счёт соответствия всем законодательствам и шаблонам сдачи налоговой отчётности во всех своих редакциях. Все попытки воспроизвести аналог увенчаются не более чем провалом…
…в отличие от операционки. Финансовые показатели, показатели наличия на складах, трафика клиентов и заказов вполне реально рассчитывать в реальном времени через собственную систему. Да, вы не будете соответствовать УСН и быть совместимым с 1С, но кто сказал, что все предприятия РФ вообще платят налоги (привет, Федеральная налоговая служба).
Забыв про институт, бритву и отдых, я взялся реализовывать именно такую систему, направленную в первую очередь на операционные показатели малых предприятий. В этой статье я уделю особенное внимание именно серверной реализации Kroncl (название системы), однако и клиентская часть заслуживает отдельного внимания; будь на то время, я посвящу ей отдельную статью (Next.js, TS, SCSS в связке с React Query без Redux — это ещё та песня).
Будущая система должна была отвечать нескольким основным требованиям:
-
изоляция каждой организации на физическом уровне;
-
гранулярная система прав (RBAC с определёнными доработками);
-
логирование каждого действия пользователей в рамках конкретной компании с возможностью удаления по первому требованию владельцев организации (это, кстати, требование многих регламентов, в том числе GDPR);
-
5 основных модулей: управление персоналом (HRM), каталогом & складом (WM), клиентами (CRM), финансами (FM) и сделками (DM) [вынесено в отдельный модуль].
Серверный стек Kroncl, включая наиболее важные библиотеки, выглядит довольно скромно:
-
pgx (в связке с собственным мигратором, updater утилитой, без SQLx);
-
PostgreSQL 16+ (очевидно);
-
chi (предсказуемо);
-
MinIO (логично);
-
httprate (часть chi);
-
Prometheus + Grafana;
-
и всё это крутится на минималистичном Caddy сервере.
А теперь, дорогой читатель, перейдём к кругам ада реализации системы управления малыми предприятиями — Kroncl.
Все репозитории проекта доступны на официальном Github, наслаждайтесь моей болью.
Круг 1. Модульность
Мы пишем ERP-подобную систему. Это та самая область, в которой JOIN операции играют огромное значение. Системы данного рода не являются хайповыми высоконагруженными проектами, где выгодно распилить реализацию на несколько предметных областей и упаковать в зоопарк микросервисов. Для читателя это может показаться странным. Ведь ERP — это как раз таки несколько предметных областей, для которых архитектурно “правильнее” было бы изолировать хранилища данных разных модулей по разным базам данных.
Нет, по моему мнению, это заблуждение. Помимо того, что поддерживать 5 отдельных микросервисов (не включая единую базу авторизации) само по себе трудно без большой команды, это ещё и не оптимально именно в контексте ERP систем.
Если вы работаете над аналогичной системой, задайте себе один простой вопрос: как вы будете внедрять данные ответственных сотрудников в выборку заказов? Если ваш ответ — не errgroup эндпоинт в микросервисе HRM, используемый в микросервисе DM (управление сделками), — забудьте про идею микросервисов.
Собственно говоря, Kroncl сервер — это монолит. Монолит, который просто поделён на модули, предоставляющие ряд публичных методов для других модулей. И поверьте, я ни разу не пожалел о сделанном выборе.
Круг 2. Изоляция данных
Распространённым подходом для большого количества SAAS систем является изоляция на уровне схем базы данных. Этот подход не является чем-то новым, но тем не менее требует оооочень грамотной реализации, от которой зависит будущая стабильность системы.
Да, термин “физической изоляции” в данном случае является лукавством. Однако и здесь существует огромное количество интерпретаций конкретной реализации. Ведь если вы с самого начала реализуете систему, предусматривающую изоляцию данных компаний по схемам, чего вам стоит заменить схемы на отдельные инстансы PostgreSQL, крутящиеся в условном Yandex Cloud? При грамотной реализации — пары сотен строк кода.
Не сомневаюсь, что сообщество Go разработчиков кишит огромным количеством реализаций мультитенантности на уровне схем, но в случае с Kroncl я посчитал нужным воссоздать собственную систему на базе таблицы “companies_storages” в публичной схеме + тип хранилища в каждой записи. Таким образом вы получаете максимальную гибкость. При инициализации новой компании вам ничего не стоит заменить тип хранилища на “db” вместо “scheme” и реализовать метод подключения к выделенному хранилищу организации.
Однако в случае с отдельными инстансами БД вам также придётся хранить ключи для подключений к конкретным хранилищам организаций, что, в свою очередь, ведёт к внедрению Volt/аналогов в ваш стек. Именно поэтому изоляция по схемам на ранних этапах является лучшим решением.
-
К слову говоря, смежные системы, наподобие Битрикс24, работают иначе. Изучив их открытое API и немного раскинув мозгами, можно прийти к выводу, что их облако работает по принципу “общей свалки”. Это также не является новостью и, в свою очередь, обусловлено просто напросто наследием их коробочных версий. Данные организаций живут, образно выражаясь, в общем бакете данных, которые на уровне приложения фильтруются с помощью аналога TENANT_ID в каждом запросе к базе. И надо сказать, это выгодно. Выгодно, но опасно. Если вы работаете в Битрикс24 — соболезную, если вы хотите делать так же — не советую.
Однако аспект мультитенантности системы переходит на следующие круги ада, над которыми вам придётся страдать ещё больше.
Круг 3. Пулы соединений
Этот круг ада не долгий, но больной. Образно говоря, при каждом запросе к API определённой компании нам нужно доставать ссылку подключения к схеме/базе организации и на лету конфигурировать сервисы модулей, передавая соединение к конкретной организации.
Это несложная задача, учитывая пулы соединений, предусматриваемые pgx. Кэшируя пулы в памяти приложения и организовав мидлвар с определением ID конкретной организации, можно добиться отличных результатов инъекции соединения к схеме организации в сервисе/репозитории модулей.
Круг 4. Миграции
Вы реализовали изоляцию данных разных компаний на уровне схем PostgreSQL. Вы молодцы! Теперь при создании компании регистрируется запись в таблице “companies_storages” и параллельно запускается фоновый воркер создания схемы. При последующих запросах к методам модулей происходит определение соединения для схемы конкретной организации и его инъекция во все тенантские сервисы. Но есть один момент…
Как организовать миграцию всех схем всех компаний? Как отслеживать версии миграции для всех организаций и не ломать обратную совместимость таблиц модулей?
Для решения этой задачи вам наиболее вероятно понадобится писать костыль на базе golang-migrate. В Kroncl это реализовано в cmd/migrate/main.go, организовывающем миграции публичной схемы и схемы тенантов. Разделение миграций на директории public/tenant + мигратор, понимающий флаги -type public/tenant/all-tenants, позволит организовать миграцию схем.
Однако этот аспект системы до сих пор вызывает у меня некий ступор, ибо миграция всех тенантов до сих пор работает в Kroncl лишь в одном направлении — up. Откат миграций во всех схемах организаций — задачка посложнее, которую ещё предстоит решить.
Определение версии схемы конкретной организации довольно просто реализуется за счёт schema_migrations таблиц в каждой схеме (предусматривается golang-migrate).
Кроме того, в результате долгих бессонных ночей я пришёл к выводу, что при инициализации компании необходимо воссоздавать таблицы всех модулей системы в схеме организации вне зависимости от того, включён тот или иной модуль в тарифный план или нет. Такой подход существенно облегчает реализацию миграции компаний между тарифными планами. Разделение же доступа к тем или иным модулям в зависимости от настройки тарификации является отдельным кругом ада, о котором ещё пойдёт речь.
Круг 5. Доступы
Тема разграничения доступов в российских ERP-подобных системах является давней болью. Подавляющее число аналогов реализуют данный аспект своих платформ на базе классического RBAC с разделением на базовые роли по типу: админ/гость/владелец…
Kroncl лучше. Здесь также воссоздана RBAC модель, но вот только все разрешения разбиты с такой точностью, что позавидуют ребята из Shopify. Кроме того, уровень критичности разрешения, его включение в тарифный план и доступность в случае просрочки оплаты тарифа организации заложены в конфигурацию всех разрешений. Помимо того, понятие ролей подменено должностями, регистрирующимися в модуле HRM, доступно переопределение разрешений конкретных аккаунтов, подключенных к организации, и всё это чудо завязано на единую систему логирования на базе кодов разрешений.
-
На своём опыте выяснил, что если ваша система является коммерческой (как в случае с Kroncl), предпочтительно заводить отдельные мапы конфига с привязкой конкретных разрешений к тарифным уровням (например MAX_PRICING_LVL) и разрешениям, доступным организациям даже в случае просрочки тарифа.
Таким образом мы получаем довольно объёмный конфиг всех разрешений платформы с распределением по критичности (по шкале от 1 до 10) и привязкой к требуемому тарифному плану.
В роутере chi Kroncl каждый маршрут API организации требует определённого разрешения, а доступность действия определяется с помощью отдельного мидлвара, составляющего карту доступных для данной компании разрешений, вычисляющего привязку аккаунта к определённой должности (через связку аккаунт->сотрудник в организации->должность), разрешения конкретного аккаунта и блокирующего или разрешающего доступ инициатора метода к данному действию.
В совокупности вся эта система прав является каким-то сплошным сумасшествием, и именно поэтому, если вы здравомыслящий человек, — воспользуйтесь готовыми решениями для Go. Я же просто дурак, который написал монстроподобную систему прав и чуть не умер в процессе.
-
К слову говоря, определение доступности действия на бэке лишь полдела. Всё это нужно синхронизировать с клиентом, определяя доступность каждой кнопки (по типу “Создать клиента”) в зависимости от разрешений конкретного пользователя в конкретной организации. [Кэш React Query + кастомный хук usePermission + контексты организаций Next.js вас спасут].
Круг 6. Логирование
Логирование действий в рамках конкретной организации является той ещё задачей. С одной стороны, необходимо обеспечить мониторинг активности аккаунтов в конкретной организации, с другой — не убить производительность всей системы.
И я говорю не про системное логирование всего приложения, которым пронизан каждый второй проект на базе Loki + Go. Я говорю про логирование действий именно в контексте каждой конкретной организации.
Рассматривая аналогичные системы, как например Битрикс24 (мне почему-то нравится данный пример) — у ребят существует “Журнал событий”, который наиболее вероятно организован поверх их системы разрешений.
Здесь Kroncl не уступает аналогам, а может даже и превосходит (судить читателю). Логирование действия всегда тесно связано с требуемыми для выполнения конкретных действий разрешениями, что делает просто очевидным использование именно конфига разрешений для мониторинга активности.
Отдельная таблица по типу logs, логирование user-agent, параметров запроса, критичности требуемого для его выполнения разрешения — и вы получаете готовую базу для мониторинга активности каждой организации. На базе такой системы пользователи вашей системы получают возможность просматривать, кто, что и когда сделал в их организации, в виде GitHub-подобной грядки активности по дням. Круто? — Не то слово.
Лирическое отступление
Надо сказать, что первые круги ада проходили в моём случае, так сказать, на “энтузиазме”. Каждая новая задача казалась прекрасной, и каждый баг воспринимался всего лишь как “досадно, но поправимо”. Бэк писался параллельно с клиентом, и в целом создавалось впечатление, что всё идёт как надо. База была заложена, оставалось лишь наполнить систему логикой модулей, и система заработает.
Прошло ± 3 месяца с момента начала работы над проектом, но, признаться честно, я уже прилично устал (заебался). Институт сменился ночами за стареньким (разъёбанным) ноутбуком, холодный билд контейнера на котором занимал около 10 минут. + WSL, который как нарочно падал, как только я перезагружался. Не хочу досаждать читателю чрезмерными подробностями, но едва ли ни каждый второй день я ездил на текстильщики в автосервис брата по ночам, ибо там хотя бы был компьютер менеджера (и то без видюхи). Тем не менее, дальше ещё интереснее.
На этом этапе я плавно перейду к разбору бизнес-составляющей системы, не затрагивающей архитектуру (ну практически).
Круг 7. HRM / Загадка учёта персонала
Учёт персонала. Наивному читателю покажется, что здесь всё дело просто в аккаунтах/пользователях, подключенных к организации.
И собственно да, на этом принципе строят свои платформы большинство аналогов. Аккаунт = сотрудник, что здесь такого.
Не то здесь абсолютно всё. Представьте себе организацию, в которой 20 механиков и 2 менеджера. 2 менеджера зарегистрированы в вашей системе и подключены к организации, но вот 20 механиков даже о ней и не слышали. Задача каждой уважающей себя ERP — организовать корректный учёт двух типов этих сотрудников, не ломая логирование, доступы и всё остальное.
Эта задача вполне себе решается с помощью разделения аккаунта и сотрудника на два независимых типа. Действия в конкретной организации происходят от лица конкретных сотрудников, но вот доступ (иначе говоря, разрешения) определяется на основании статуса аккаунта в данной организации.
Собственно говоря, на этом и основан HRM модуль Kroncl. Сотрудник может быть зарегистрирован, может не быть — нам на это всё равно. Хотите указать выполнение финансовой операции с привязкой незарегистрированного сотрудника — пожалуйста, просто выберите запись из базы сотрудников модуля HRM. Аккаунт же, в свою очередь, может быть привязан к сотруднику, а сотрудник — к должности, разрешения которой наследуют соответствующие аккаунты.
Просто? (Ахуительно просто) Очень просто, почему я вообще этим занимаюсь?
Стоит заметить, что модуль управления персоналом является системообразующим в любой системе управления предприятием и, по моему скромному мнению, должен быть частью любой серьёзной системы. Ибо как вы собираетесь организовывать учёт финансовых операций без связки с сотрудниками? Как назначать ответственных за заказы без этого модуля?
-
Если вдруг, по какой-то неведомой мне случайности, эту статью читают разработчики Битрикс24 — добавьте вы уже это, господи. Сделайте понятным для владельцев организаций учёт НЕзарегистрированных сотрудников. Я в курсе, что текущий подход Битрикса очевидно является наследием их $USER_ID ещё со времён Битрикс: Управление сайтом. Но всё же. 26 год уже, если что. Базовый минимум.
Круг 8. FM / Сторнирование операций
Модуль управления финансами является если не самой сложной частью Kroncl, то уж точно самой полезной. Вы не увидите здесь десятков интеграций с банками (пока), здесь не будет выписок прямиком в налоговую, но что гораздо важнее — на транзакционной модели построены все оставшиеся модули системы.
Честно говоря, я никогда не понимал, каким образом (а самое главное, зачем вообще) вести учёт внутри организаций, не полагаясь на единую базу финансовых операций, доступ к которой есть у модуля CRM, при завершении заказов, при выплате зарплат сотрудникам и т.д. и т.п.
С этими мыслями я и принялся реализовывать FM (модуль управления финансами). Надо заметить, что здесь очень легко заиграться и наделать такого сюра, что смеяться будут даже разработчики 1С.
На основе истории финансовых операций высчитывается текущий баланс организации, сводится аналитика по категориям (в том числе системным) и сотрудникам.
Думаю, ни для кого не является секретом, что прямое удаление операции является непростительной ошибкой в сфере финансового аудита. И это вполне себе оправдано, ибо противоположная логика означала бы превращение этого самого аудита в насмешку над самой его идеей. Аналогично и в Kroncl: отмена каждой операции влечёт за собой не прямое удаление, а создание дубля с противоположным знаком.
Кроме того, финансовый модуль Kroncl включает в себя примитивный учёт долговых обязательств, в том числе и выплат по ним.
На отладку конкретно этого модуля я посидел, как не седеют к 50.
Круг 9. WM / Типы складского учёта
Управление складом & каталогом в Kroncl помещено в отдельный модуль. Большинство серьёзных систем (как условный МойСклад) построены на ордерной системе, но вот в случае с нашей системой всё немного не так. Стоит уточнить, что складской учёт в масштабах производств среднего и большого масштаба — это 100% ад, воспроизвести который одному практически невозможно. Однако речь идёт про малый бизнес, и здесь ордеры, склады и множественные цены в большинстве случаев не нужны.
Проще говоря, Kroncl полагается на систему с динамическим расчётом остатков на складе на основе истории поставок/погрузок, в состав которых входят товары из каталога. Каталог же, в свою очередь, предусматривает учёт товарных позиций: как услуг, так и товаров (материальных или цифровых) с возможностью выбора стратегии учёта остатков: партионный / поштучный учёт.
И вот на этом моменте начинаются главные проблемы. Начнём с того, что само по себе разделение стратегии учёта остатков на партии/поштучные экземпляры довольно сложно. Каждая поставка/отгрузка позволяет выбирать товарные позиции из каталога, но вот стратегии у разных товарных позиций могут быть разные. В конечном итоге крайне легко превратить складской учёт в головоломку, которую вы будете разгадывать через 2 месяца.
…и это не говоря про связь с сделками. Ведь при оформлении сделки состав заказа может включать как конкретный товар/партию из поставок/отгрузок, так и абстрактную товарную позицию из каталога (например, услугу). И всё это должно рассчитываться по базовым ценам, указанным в каталоге, НО с возможностью переопределения в момент составления заказа.
Кроме того, автор всё ещё бьётся над идеей temp услуг/товаров, возникающих только в момент составления конкретного заказа. Как, например, специфичная только для данного клиента и заказа услуга, которая нужна лишь один раз.
Круг 10. DM / Парадокс взаимосвязанности
Модуль DM (управление сделками) был заведомо вынесен за пределы классической CRM (отдельный модуль управления клиентской базой). По мнению автора, предметные области двух этих модулей уж слишком разные, чтобы пытаться уместить их в одном модуле.
Главная сложность сделок в том, что они, в свою очередь, тащат за собой гору смежной информации: о составе заказа (интеграция с WM), клиенте (CRM), финансовых операциях по заказу (FM), статусе в воронке, типе заказа и т.д. На первый взгляд грамотный JOIN решает все эти проблемы, но должны ли быть модули системы настолько связаны, чтобы изменение структуры таблицы в одном ломало все взаимосвязанные? Нет, по-хорошему не должны.
Поймите меня правильно: JOIN отлично применим в рамках одного конкретного модуля и его сущностей, но вот связь DM->WM через обычный JOIN как минимум является смертным грехом.
На помощь нам приходит errgroup + публичные методы получения коллекций сущностей в разных модулях. Таким образом CRM предоставляет метод для получения коллекции клиентов по мапе их ID, HRM — получение сотрудников по EmployeeIDS и так далее. Таким образом мы избегаем N+1, сохраняем относительную самостоятельность каждого модуля и обеспечиваем НЕ идеальную, но терпимую производительность в рамках допустимого времени ответа.
Лирическое завершение
На момент реализации последнего модуля (DM) прошло около 8 месяцев с момента начала проекта. И честно говоря — это отвратительно. За год я появился в вузе раза 3 максимум, в результате был отчислен, живу теперь, видимо, только по ночам, ещё и казарма меня ждёт не дождётся.
Опять же, не хочу показаться мучеником, но под 8-й месяц я уже откровенно (ёбнулся). В конце концов дошло до регулярного нахлюпывания в сплюни каждые 3-4 дня (просто потому что) и поездок куда-то за чем-то (читатель поймёт, за чем).
Можно было бы рассказать ещё про пару сотен кругов, которые я наворачивал последние годы, но стоит ли.
Короче, никому не советую, но мне очень понравилось ]](((. Зато как же выглядит.
Для удобства читателя:
Многое пока только обкатывается, но тем не менее. Благодарю за внимание.
ссылка на оригинал статьи https://habr.com/ru/articles/1027786/