Привет, это вторая статья из цикла про наш путь создания Low-Code платформы-конструктора для разработки сложных Back Office систем. В прошлой статье я сформулировал, что такое «сложные системы», задачу, которую необходимо решить, а также привел набор «наблюдений» о принципах построения IT-производства на базе Low-Code инструмента. В этот раз я опишу подход, который мы выбрали для построения Back-End и работы с базой данных. В следующих статьях про принципы организации тонкого клиента.

В институте, как у всех IT-специальностей, у нас был курс про базы данных. Могу честно признаться, что я его ненавидел. Он был не только скучный, но и с какими-то унылыми и пустыми примерами про клиентов и заказы, очевидными связями и формами для заполнения данных. Тогда, в моем кругу, базы данных всегда были вторичны, а вот алгоритмы и языки программирования считались интересными и сложными, и, как следствие, крутыми. Даже если посмотреть на олимпиады по программированию, то там, в целом, одна алгоритмика. После того, как я лет 15 поварился в финтех, могу с уверенностью сказать, что про базы данных не с того начинают и вообще не то читают. Лекторы старательно прячут от вас истинный архитектурный кайф от проектирования, и, вместо этого, вытаскивают наружу неприглядное внутреннее устройство и всякую специфику. После этого кажется, что «таблицы» делать ничего не стоит, а все остальное — это скучнейшие отчеты, мерзенький тюнинг sql запросов и разные проблемы администрирования – крест для админов, которые сами виноваты в выборе своей судьбы. В тоже время, дзен проектирования находится рядом, если осознать, что именно база определяет основные бизнес-объекты продукта и главный функционал через взаимоотношения между ними, а также задает направление UI по ролям пользователей, кто что видит и как ищет. Здесь нужно применять абстрактное мышление, уметь обобщать и выделять главное, предсказывать в какую сторону продукт будет развиваться и проводить экстрасенсорный анализ требований, поскольку они часто «не очень». При этом ты не пишешь ни строчки кода, а просто крупными мазками рисуешь каркас продукта и продумываешь функционал. Ну ведь здорово же!

Базы данных не так часто меняются, как JavaScript-движки, но все равно животный страх привязаться к одному производителю многим не дает спать спокойно. Когда я участвовал в разработке ядра большой финтех системы под Oracle, у нас был негласный указ не только избегать различных подсказок, но и не использовать многотабличные запросы при описании бизнес-логики на PL/SQL. Вместо join-ов были вложенные курсоры (для уверенности, как пойдет запрос), а еще все боролись за возможность автоматической конвертации пакетов Oracle во что-нибудь другое. На текущий момент, в связи с развитием нереляционных баз данных (ну запустятся же они когда-нибудь с полноценным транзакционным режимом), точно хочется сохранить похожий подход при взаимодействии с базой и быть максимально гибким в выборе системы для хранения. Всю внутреннюю бизнес-логику (что может быть написано на PL/pgSQL, PL/SQL и прочих), удобно выносить наружу, чтобы иметь возможность в будущем заниматься линейным масштабированием сервера приложений, а для базы оставить только хранение и поиск данных.

Структура базы данных

Обновление большой базы данных на новую версию – всегда больное местечко. Во-первых, все стоит и ждет. Во-вторых, оно не всегда проходит гладко. Для финтеха это критично, и мы приняли решение организовать хранение данных в «запакованном» виде - почти Schema-less структура, только на честной базе данных PostgreSQL, которую мы поддержали одну из первых. Для индексных полей создаются дополнительные таблицы, но все основные таблицы практически состоят из одного запакованного поля. Для шевеления структуры и upgrade-ов, так на порядок легче, а еще это на шаг ближе к NoSQL, что тоже немаловажно.

Структура базы данных у нас описывается в json файлах, каждая таблица в своем файле. Таблицы делятся на

  • «обычные» - автоматически создаются и upgrade-тся в нашей базе

  • «абстрактные» - структура таблицы описывается как для «обычной» таблицы, но при этом на Java / Kotlin создаются кастомные процедуры для заполнения ее данными из любых внешних (и внутренних) источников. Также можно определить кастомные процедуры создания, обновления и удаления записей, и абстрактная таблица ничем уже не будет отличаться от обычных. Любые view, это тоже «абстрактные» таблицы, для которых написан Java-код

Важно, что при описании логики взаимодействия для разработчика уже нет разницы с какими именно таблицами он работает. Более того, конструктору веб-экранов тоже все равно из таблиц какого типа создавать экраны. Данный подход позволяет конструировать практически любые дашборды на любых источниках данных и использовать Low-Code платформу как инструмент для создания «единых точек входа» или сложных рабочих мест.

Рассмотрим пример из двух таблиц Client и Loan (выданные клиенту ссуды).

Выданные клиенту ссуды
Выданные клиенту ссуды

При этом сделаем таблицу Client абстрактной и будем набирать в нее данные, к примеру, из текстового файла. Таблица Loan будет обычной и, соответственно, автоматически создастся в базе данных.

Описание таблиц Client (client.,json) и Loan (loan.json)
Описание таблиц Client (client.,json) и Loan (loan.json)

Замечу, что поле client.parent ссылается на туже самую таблицу для организации иерархии. Поле loan.status - это «короткий справочник», по которому генерируются константы в Java / Kotlin для удобного описания логики поведения. Элементы «@» - технические, создаются автоматически и служат для автоматического обновления структуры базы данных, чтобы не было необходимости вручную создавать alter скрипты и можно было бы легко добавлять, удалять и модифицировать все элементы. В данной структуре кроме типов полей описывается информация о доменах полей, к примеру, на что именно ссылается поле («ref» на какую таблицу), как поступать с текущей строчкой при удалении записи на которую ссылается текущая (элемент «refDelete»), какое поле «отвечает» за название текущей строчки при отображении в справочнике, построенном по данной таблице и прочее. Все это используется, в том числе, во время генерации java-объектов, которые нужны для API при работе с базой данных.

Описание индексов происходит в том же файле, где описывается таблица.

Индекс в таблице Loan
Индекс в таблице Loan

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

По всем описаниям таблиц в json автоматически генерируются классы, атрибуты которых соответствуют полям в таблице. По сути «виртуальным полям», потому что в базе данных строчка хранится в запакованном виде, как объект. Вся работа с базой происходит через объекты сгенерированных классов.

Процедуры и описание бизнес-логики

Прямого доступа из кода к базе нет, и все select/update/delete операции закрываются специальным API. Это, во-первых, позволяет максимально изолировать бизнес-логику от способа хранения данных, во-вторых, позволяет создавать единообразную работу как с обычными, так и абстрактными таблицами и в-третьих, сильно упрощает «читабельность» кода.

Для примера напишем функцию, которая закрывает все ссуды у всех клиентов, ссылающихся на текущего клиента и так вниз по «дереву» клиентов.

Функция closeAllClientLoans
Функция closeAllClientLoans

Первый метод iterateсоздает курсор и проходит последовательно по всем строчкам указанной таблицы, применяя фильтр parent = ${clientID}, где вместо ${clientID} впечатывается значение входной переменной функции.  Поскольку таблица Client абстрактная, то для нее внутри будет вызван соответствующий класс, где описано, как заполнять ее данными. Для более строгой записи фильтра parent = ${clientID}, чтобы не впечатывать названия полей в строчку, можно заменить на QueryHelper.c().and(Client.fParent, clientID).toString()

Поскольку перед функцией стоит @ActionName("closeAllClientLoans"), то по данной функции будет автоматически сформировано REST API, и функцию можно будет привязать к любой кнопке в экране. Так же через REST можно вызывать самостоятельно все стандартные функции (в том числе все select/update/delete) и строить альтернативных тонких клиентов или экраны «первого» типа (из моей предыдущей статьи), для «людей». Конечно, сверху еще накладывается проверка прав пользователей и различный аудит при необходимости.

Создание новых объектов (строчек в базе данных) происходит похожим образом, к примеру функция createClientLoan создает новую ссуду для клиента.

Создание ссуды у клиента
Создание ссуды у клиента

Важно отметить, что именно таким API к базе данных и его «одинаковостью» к внешним источникам (абстрактным таблицам) мы боролись за «души» так называемых бизнес-разработчиков: за простоту и прозрачность описания логики приложения, отделение логики от технического уровня взаимодействия с базой и тонким клиентом, снятие головных болей со сборками и обновлениями структуры данных. Более того, если функция, вызываемая снаружи (через API) выбрасывает exception, то автоматически вызывается rollback (если нужно), для того чтобы важные финтех данные остались в консистентном виде. При успешном завершении commit тоже происходит автоматически. Конечно, внутри кода можно дополнительно управлять commit/rollback, но тут задача максимально очистить код и оставить только важную бизнес-логику, описывающую поведение продукта.

Часто бывает так, что перед обновлением записи в таблице нужно всегда дополнительно заполнить еще какие-то поля (некоторый аналог триггеров), к примеру, для денормализации или просто по логике продукта. Для этого у нас есть ряд методов, к примеру, beforeUpdate которые можно переопределить и добавить внутрь дополнительные правила.

Заполнение данных перед update в базе
Заполнение данных перед update в базе

В данном случае мы автоматически заполняем у ссуды due_date, если это поле по каким-то причинам не было задано в момент обновления записи в базе данных.

Модули и компоненты

Модуль – это минимальная единица поставки, которая независимо версионируется и состоит (опционально) из

  • Описания таблиц (в json)

  • Описания бизнес-логики (java/kotlin)

  • Набора экранов (в json) – об экранах попозже

  • Ну и еще немножко всякого разного в виде текста, типа локализации (text), документации (markdown) и прочего

Модуль может быть независимо установлен на развернутой платформе, представляет из себя один zip-файл и создается автоматически на этапе build-а проекта в IDE. Модуль — это независимая часть функционала со своими таблицами, логикой и экранами, но может быть, к примеру, модуль только с одними экранами или одними таблицами, зависит от вашего подхода к лицензированию, поставкам и архитектуры самого продукта. Экраны могут быть построены на таблицах из разных модулей, и бизнес-логика тоже может обращаться к таблицам из других модулей (если это не запрещено явно). Сама Low-Code платформа тоже представляет из себя набор системных модулей, некоторые из которых входят в дистрибутив по умолчанию, а некоторые доставляются по необходимости.

Аппликационный сервер мы построили на базе Apache Tomcat, он Stateless, ни хранит никаких данных и может быть линейно масштабирован при необходимости. Именно там «крутится» ядро Low-Code платформы, туда же «подкладываются» модули, а дальше ядро само занимается развертыванием схемы в базе данных, предоставлением API, раздачей статики для тонкого клиента (экраны строятся динамически по их описаниям в json), проверкой прав и различными диагностиками.

В качестве базовых системных модулей в ядро в том числе входят: модуль аутентификации, авторизации, аудита, работа с пользователями, конструктор экранов, базовых уведомлений, диагностики базы данных. При необходимости мы еще подключаем модуль для работы с документами (компоненты для OnlyOffice и Р7-Офис), уведомления через Telegram (с обратной связью), полнотекстовый поиск, платежи, и прочее. Количество модулей постоянно увеличивается, появляются еще разные кастомные (к примеру, интеграции с SAP, Teamcenter и прочие). В общем, тут важно сделать максимально простое подключение новых модулей, API, систему установки и поставки, чтобы потом не сойти с ума от клубка зависимостей.

Права

Для удобства раздачи прав для пользователей удобно оперировать сразу целыми экранами - выдавать права на экран, а также иметь возможность тонко настроить, что именно в конкретном экране можно и нельзя делать. Поскольку экран состоит из серверных акций (выборка, обновление таблиц, вызов дополнительных акций), то, естественно, права должны проверяться на сервере.

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

Диагностика

Мы столкнулись с необходимостью поддержки более тонкой диагностики работы. Нужно быстро определять состояние процессов сервера, состояние соединений сервера с базой данных, разбираться в статистике запросов к базе данных, оценивать размеры объектов и понимать статистику выполнения акций. Более того, нужно уметь по запросу сразу понимать в каком месте «кода» он вызывался. Это интересно, с учетом того, что в стандартной статистике базы этого, конечно, нет. Мы научились «прокидывать» кое-что в запросе, и удобно показывать, где и что происходит. В первую очередь, это нужно для быстрого решения проблем у клиента, чтобы сразу понять в чем дело.

Housekeeping

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

  • Временные или промежуточные данные

  • Старые записи, созданные для логирования

  • Старые данные, уже выгруженные в системы DWH (Data Warehouse), и прочие

Необходимо уметь задавать правила выборки данных. У нас они задаются прямо в модуле, разработчиком, ведущим его разработку. Правила могут выглядеть, как фильтры строк (по дате или полям состояния) или как программная выборка для сложных случаев. После установки модуля на prod, администратор системы может контролировать (изменять) правила. Можно изменять условия применения правил или условия удаления, к примеру, как рассчитывается дата, ранее которой данные будут удаляться.

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

IDE для разработки

В качестве основного IDE для разработки мы взяли IntelliJ IDEA (Community Edition), для которого написали свои собственные плагины и добавили несколько кнопок на toolbar.

Первая кнопка – по модели данных в json генерирует соответствующие классы доступа. Чтобы можно было сразу разрабатывать бизнес логику. Замечу, что если бизнес-логики еще нет (так бывает), и нужно просто сделать много экранов для заполнения данных, то не нужно ничего программировать вообще.

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

Третья кнопка – перезапускает локальный Tomcat с новым модулем.

В проекте хранится все необходимое для сборки модуля – описание таблиц и экранов в json, логика поведения, локализация и прочее.

Небольшой итог

В соответствии с целями, поставленными в предыдущей статье, мне нужен Low-Code инструмент для организации производства продуктов, простой и прозрачный, но без потери возможностей в сложных кастомизациях. Вся структура любого модуля «видна как на ладони»: структура базы данных в json, экраны в json, код бизнес-логики «очищен» от технической рутины и состоит преимущественно из простых функций, элементарных языковых конструкций и единообразного API к базе. При этом, никто не мешает на Java / Kotlin написать там все, что угодно. Система модульная, делится на модули в любых комбинациях и поставляется как угодно. Сервер Stateless и может быть линейно масштабируем. К базе данных привязки нет. Можно поддержать и NoSQL базы, при этом весь код будет работать, как и раньше. В коробке продумана система прав, различная аутентификация, Housekeeping и прочее, чтобы предоставить возможность разработчикам сосредоточится на разработке бизнес-решения и «забыть» про технический слой. Все обновления происходят автоматически, через UI.   

В следующей статье я расскажу, как у нас устроен тонкий клиент и редактор экранов.

Предыдущую статью можно найти тут Как мы делали Low-Code конструктор для Back Office. Часть 1

Директор по развитию ООО "Дольмен", telegram: @fintechbrother 

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


  1. itGuevara
    04.09.2024 18:05

    Low-Code? Вроде бы описание как "много кода".

    Low-Code преподносится обычно как "программирование без программирования" (в пределе No-code), типа рисуешь квадратики BPMN и оно "работает" (исполняется) в Workflow Engine. Тут другой Low-Code? Тогда VBA - это super Low-Code?


    1. OlegAslamov Автор
      04.09.2024 18:05

      Мы делаем не No-Code, а Low-Code, что допускает программирование, но избавляет от большей части технической рутины. Просто на Workflow Engine-ах, к сожалению, не создать сложных систем для финтех, и обычно встречаются No-Code и Low-Code платформы с уже готовым функционалом (в какой-то области), где ты можешь поменять некоторые бизнес процессы workflow в одной конкретной инсталляции, но не можешь сделать свой новый продукт для продажи и лицензирования. Иначе говоря, это просто возможности для кастомизации готовых продуктов. Наша задача была сделать полноценный инструмент для создания новых продуктов, и сделать его максимально простым и освобожденным от технической рутины поставок, обновлений базы данных, API, верстки экранов и прочего. Да, конечно программировать на минимальном уровне "школьной программы" нужно уметь, но все остальное берет на себя платформа и если сравнить с полноценной разработкой с нуля (из разных библиотек), то уж это точно всем лоу-кодам лоу код).

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


  1. CrushBy
    04.09.2024 18:05

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

    Вот тут не понял. То есть все данные в виде bytea, которые считываются и "расшифровываются" на application server ? А как, например, посчитать сумму всех заказов по какому-то условию ? Это сначала должно считаться на томкат, где будет идти расчет ? То есть будет главная проблема ORM ?


    1. OlegAslamov Автор
      04.09.2024 18:05

      У нас не ORM, мы не классы описываем, а "честные" таблицы, поля, связи, индексы. Запросы к базе через идут через наш API (where условия, как в sql, но одно-табличные), дальше система ищет сначала по индексам через стандартный sql к БД, потом данные поступают на application server (tomcat) и там происходит "дофильтрация" из начального where условия. При "дофильтрации" происходит распаковка на системном уровне (все верно), время распаковки просто мизерное, это очень быстро работает. Если правильно сделаны индексы, то вообще ничего не стоит, а если fullscan то и так и так плохо. Для больших отчетов правильно все выгружать в Data Mart какой-нибудь. Для нас безумно важно сохранить минимальное время простоя при upgrade системы и быть готовым к поддержке нереляционных баз данных.


      1. CrushBy
        04.09.2024 18:05

        дальше система ищет сначала по индексам через стандартный sql к БД, потом данные поступают на application server (tomcat) и там происходит "дофильтрация" из начального where условия. При "дофильтрации" происходит распаковка на системном уровне (все верно), время распаковки просто мизерное, это очень быстро работает. 

        Ну смотрите, давайте на конкретном примере. Например, при вводе заказа на закупку товаров вам нужно показать продажи определенных товаров, которые вы заказываете. Вот пример интерфейса из реального приложения (выделена колонка с продажами, а вводится количество к закупке на основании разной информации) :

        Чтобы ее посчитать нужно условно сделать SELECT SUM(<продажа>) FROM <таблица с продажами> GROUP BY <товар> WHERE <фильтр по товарам>. В базовом случае это посчитается на самом сервере БД очень быстро, и на application server улетят уже конкретное количество продаж по каждому товару. Если я правильно понял про то, что Вы пишете, то у вас полетят целиком строки из таблицы с продажами, там они "разархивируются" и дальше идет непосредственно расчет на сервере приложений, группируя уже там по каждому товару.

        Да, может это будут несколько миллисекунд, но это будет дополнительная нагрузка и на сеть и на CPU, которая будет в РАЗЫ больше, чем если считать это не выходя за пределы БД.

        Понятно, что это не критичная нагрузка, если там десятки пользователей. Но, если несколько тысяч работает одновременно, то это просто кратный waste ресурсов. И так получается везде.

        Собственно это и есть одна из основных проблем падения производительности в ORM (что приходится данные таскать на application server) из-за чего многим приходится делать винегрет из ORM и plain-SQL.


        1. OlegAslamov Автор
          04.09.2024 18:05

          Прежде всего, спасибо за подробный комментарий.

          На базе данных "SELECT SUM(<продажа>) FROM ... " рассчитается точно быстрее, чем на сервере приложений после распаковки. Вы абсолютно правы. Дополню только, что если поля входят в индекс (который Вы сами проектируете), то sum переложится на базу, но, все равно, все в индекс не положить и можно легко придумать запрос, который на базе будет выполнен быстрее.

          Однако, чтобы затормозить сервер приложений, нужно реально очень много одновременных операций (что люди-клиенты или ежемесячные отчеты обычно не делают). В любом случае, у нас может быть много серверов приложений на одну БД, они stateless и легко параллелятся.

          Жертва с хранением "запакованных данных" была принесена, чтобы максимально избежать простоя на апгрейде при изменении структуры данных, снять ограничения на изменение структуры таблиц и быть по-настоящему независимым от типа БД.

          Куски системы, в которых нужна высокопроизводительная параллельная работа (онлайн транзакции, например) обычно проектируются индивидуально, и, скорее всего потребуют еще специальной системы хранения + что-нибудь типа Kafka впереди и так далее. Создать универсальный конструктор для таких задач невозможно, и это точно будет не low-code, а много-код, как писали выше. Для нашей платформы любые такие внешние системы вписываются органично, мы закрываем через API из абстрактными таблицами и дальше получаем единообразное описание бизнес логики + редактор экранов, что тоже экономит много ресурсов.

          Надеюсь, я смог ответить на Ваш вопрос.


          1. OlegAslamov Автор
            04.09.2024 18:05

            Выше я показывал пример createClientLoan, где создавалась новая ссуда у клиента, если Loan - абстрактная таблица и хранится где-то в "высоко-производительном" месте, то createClientLoan никак не поменяется. Для разработчика "бизнес-логики" все будет абсолютно прозрачно.


          1. CrushBy
            04.09.2024 18:05

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

            Не очень понимаю, как он переложится на базу. Например, у вас есть таблица с 2мя колонками - товар и количество. Нужно посчитать SELECT SUM(количество) GROUP BY товар. По количеству понятно, что нет никакого смысла строить индекс. То есть она будет заархивирована, так ? Но, если колонка будет "заархивирована", то вся таблица будет целиком на сервер приложений перегоняться, так ?

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

            Не очень понял. Например, вдруг понадобился какой-то индекс по какому-то полю. Чтобы мигрировать данные, то надо будет сначала достать из "заархивированных" данных колонку (причем это придется делать на сервере приложений ?). Потом записать ее в таблицу (а возможно и в старую, а это UPDATE с полной копией таблицы в PostgreSQL). И после этого еще достроить индекс. То есть, если ошибиться сначала со структурой базы, то потом будет очень больно ее изменять. И downtime будет еще выше.

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

            Да, я понимаю, зачем нужен low-code. Просто в вашем подходе понятны минусы, а плюсы не очень очевидны. Если речь идет о масштабируемости, то она важна ну на очень больших объемах и загрузке. И там overhead за счет ORM и распаковки будет важен. А на относительно небольших объемах и масштабируемость не нужна.

            У нас на открытой и бесплатной платформе lsFusion тоже low-code и тоже единообразное описание логики, редакторы экранов и прочее. Но там классическая база данных, где все выполняется на сервере БД. Да, есть вопросы с масштабированием, но мы делаем решения для розничной торговли с некоторыми таблицами более миллиарда записей, несколькими тысячами одновременно работающих пользователей и даже там пока не требуется масштабирования и все работает на одном сервере.

            Уверен, что в 95% задач в том же финтехе также не требует масштабирования (достаточно асинхронной реплики для надежности). Плюс масштабирование не бесплатно (CAP-теорему никто не отменял). Соответственно, не очень понятно зачем жертвовать производительностью и мощью SQL-запросов, все перекладывая на сервер приложений и java-код.