Базовые концепции
Чтобы понимать как работать с ядром, необходимо принять несколько базовых концепций:
Понятие “Класс”
Понятие “Класс” в ядре это javscript класс, унаследованный от базового класса обеспечивающего загрузку профайлов соответствующей сущности в бд, связь с базой данных на основе профайлов, обеспечение прочих полезных методов. В дальнейшем может встречаться определение Сущность, которое подразумевает класс, его профайлы, методы, данные в бд, формы, таблицы на стороне фронтенда.
Профайл класса и клиентского объекта
Профайл содержит настройки самого класса и каждого его поля. Одной из важнейших, но далеко не единственной, задачей профайла является предоставление ядру информацию о том, как строить sql запросы к бд, включая связи между таблицами (см. пункт ниже), различные параметры по умолчанию, такие как сортировки, лимиты, наложения ограничивающих условий.
В отличии от классических ORM, система описания полей сущности класса предусматривает описание полей не только основной таблицы, но и полей из других сущностей, которые будут подтянуты системой через JOIN(ы). Таким образом, профайл создает что-то схожее с понятием VIEW в классических реляционных СУБД, но конечно, описание полей в профайле не ограничивается только этим, а служит еще и для многих других целей.
Это базовая концепция. Когда разработчик проектирует сущности для какого-то решения, поля из связанных таблиц. закладываются сразу в класс. Например в классе user есть не только поле country_id, но и country_name, и country_code, которые являются виртуальными, то есть подтягиваются из другой таблицы (ds_user_country), по ключу country_id. То есть эти связи заложены в профайле, а не строятся разработчиком во время запроса. Разумеется при запросе, пользователь может ограничить набор полей, который ему необходим и лишние JOIN(ы) собираться не будут.
Первичное создание профайла и полей осуществляется в файле tables.json, после чего может модифицироваться через интерфейс системы. В tables.json описывается только самое необходимое, а большинство настроек имеют оптимальные значения по умолчанию.
Автоматически создаваемые поля
Автоматически создаваемые поля. Все таблицы автоматически имеют системные поля, такие как, created(datetime), updated(datetime), deleted(datetime) (ядро работает по принципу soft deletion), created_by_user_id, last_edited_by_user_id, deleted_by_user_id и их виртуальные поля для подтягивания фио, также еще некоторые системные, для различных механизмов. Кроме этого для таблиц с иерархической связью, автоматически создаются поля node_deep, parents_count, children_count, корректность содержимого которого поддерживается ядром автоматически. Кол-во родителей и детей в данных полях указано именно на всю глубину, а не только на один уровень (прямой потомок/родитель).
Кроме функций построения запросов, и других взаимодействий с бд, таких как например, можно ли создавать записи в этой таблице, или при создании (или при редактировании) указывать значение определенного поля и прочих, профайл содержит различные настройки, большая часть из который используется на стороне фронтенда для определения внешнего вида и функциональности компонентов. Тут также есть настройки всего класса и настройки каждого поля. Пример настроек класса - количество записей на страницу (для построения пагинации в таблицах), имя формы, которую надо открыть из контекстного меню таблицы, заголовок. Примеры настроек для полей: Наименование, текст подсказки, тип редактора, выводить ли фильтр по нему и какой тип этого фильтра, минимальное и максимальное значение для полей числового типа и минимальный шаг для изменения стрелочками, и прочее. См. Профайл класса/клиентского объекта и их полей.
Клиентские объекты.
Это копии класса, но со своим профайлом его самого и его полей. При этом набор полей тот же. Это необходимо в основном для фронтенда, чтобы в таблице например запретить создание, а в форме разрешить, или в разных таблицах выводить разный набор полей и, например, одну таблицу сделать для одной роли, а другую для другой.
На стороне фронтеда клиентские объекты обычно связаны с таблицей, формой или фреймом, которые опираясь на профайл и верстку (актуально для форм и фреймов), создают компоненты на странице. При этом у них может быть свой кастомный js код, позволяющий по разному взаимодействовать с данными и вызывать различные методы.
Расположение логики приложения
Большая часть логики разрабатываемого приложения, должна располагаться в методах вышеупомянутых классов. Точкой входа в любой процесс является какой либо метод определенного класса (через внутреннее api см. ниже).
Могут быть какие-то внешние механизмы, например интеграции с платежными системами, где реализация интеграции with конкретной системой вынесена в отдельное место: /modules/payment_system/instances/<payment_system_name>, но при этом конкретный инстанс переопределяет или дополняет метод универсального класса платежной системы в /modules/payment_system/instances/index.ts методы которого вызываются из методов сущности Ps_transaction (класс транзакций с внешними платежными системами). На примере интеграций с платежной системой, есть еще вторая точка входа, когда платежная система обращается на url системы для оповещения об успехе или неудаче проведения оплаты, и тут у нас имеется /routes/index.ts где обрабатывается ответ, перенаправляется также в Ps_transaction.result, который вызывает завершение в нужном инстансе платежной системы, в ее метод result, который в свою очередь (после парсинга, проверок подписи) вызывает завершающий метод super.result родительского класса интеграций платежных систем (/modules/payment_system/instances/index.ts), а он сохраняет результат средствами того же внутреннего api.
Этот пример показывает, что различные внешние модули, занимаются лишь специфичной для них работой, а далее управление снова передается в основные методы сущностей.
Вызов методов через внутреннее API
Все методы, как других, так и того же классов должны вызываться через внутреннее API.
Иногда, в рамках метода, если нужно вызвать метод того же класса можно вызвать его через this, но надо отдавать себе отчет в том, что в таком случае, вызываемый метод не будет помещен в цепочку вызова и не будет отдельно залогирован, блокировки и снятия блокировок будет работать, но снятие произойдет только при завершении именно исходного метода, который прошел через api (если блокировки имеются). Лучше избегать таких вызовов, если только вы не хотите избежать излишнего логирования и точно понимаете что делаете.
Для обращения же к методам других классов всегда следует использовать внутреннее api, так как для работоспособности недостаточно просто создать новый инстанс и вызвать его метод.
Подробнее, что происходит во внутреннем api смотри в ”Внутреннее API. Подробно”.
Использование справочников вместо enum
Не используем enum, используем справочники вида ds_ (отдельно смотри про договоренности наименований).
Запросы можно строить по sysname, ядро самостоятельно возьмет id из кэша или сходит в базу и подставит его в оригинальный запрос.
Создание/изменение записи тоже может быть с sysname, а не по id, ядро также сделает всю работу за вас.
Подытоживая, вы свободно можете работать по sysname не думая о том, что в базу надо подставить сопоставленный номер.
Все sysname в справочниках пишем UPPERCASE. Так как они сразу выделяются в коде. В дальнейшем, возможно стоит доработать ядро так, чтобы содержимое справочника автоматически синхронизировалось в ts типы в соответствующем файле структур класса, тогда можно будет использовать не строку, а что-то вроде "ds_order_status.CREATED”. Это повысит контроль за кодом.
Входные и выходные параметры каждого метода стандартизированы
Входным параметром любого метода любой сущности является объект запроса “r” соответствующий интерфейсу IAPIRequest, который содержит информацию о цепочке, клиенте, конкретном подключении, системных параметрах запроса, классе и методе запроса и параметрах метода.
Любой метод должен возвращать объект соответствующей интерфейсу IAPIResponse (иногда вы можете увидеть что выходной параметр должен соответствовать IError, это устаревший вид, но IAPIResponse расширяет IError и по факту, все ответы соответствуют что первому, что второму).
Для соответствия этому интерфейсу используется один из трех классов ошибок/успеха: UserOk (используется для успешных ответов), UserError (для пользовательских ошибок, таких как, например, “Товар закончился” или “Пароль неверный”), MyError (используются для внутренних ошибок, типа неверно переданные параметры (не пользовательские, а те, что передаются кодом, например, “Не передан id” (пользователь сам никогда не вписывает id, поэтому это ошибка относится к неправильному использованию метода)).
Все эти классы имеют поле code, которое только для UserOk будет равно 0, а для остальных отличное от 0 значение. Для пользовательских методов, по умолчанию, для ошибок вида MyError и UserError код будет одинаковый (-1000), а особые коды используются лишь для некоторых особых условий, которые преимущественно используются для механизмов ядра. Например, если запрос неавторизован или сессия просрочена код будет -4, а для недостатка доступа используется код 11. Но повторюсь, кодами пользуются лишь некоторые механизмы ядра, и при разработке логики они не используются (кроме проверки на 0). Вы можете посмотреть существующие коды в /error/error.js (многие могут быть не актуальны).
Ответ может выглядеть так:
return new UserOk('Все хорошо, пейлоад лежит в data', {calculatedAmount: 10, hint:'Посчитано для 5 строк'})
Тогда эти данные можно будет получить в res.data. Например console.log(res.data.calculatedAmount) // выведет 10.
Ответ с ошибкой также может содержать payload (поле data), в который имеет смысл добавить и выше возникшую ошибку, и параметры, с которыми был вызван вышестоящий метод, вызвавший ее.
Имеется два пути обработки такой ошибки.
Или передать ее как есть, возможно дополнив и/или изменив текст, но сохранив инстанс и соответственно тип ошибки:
if (res.code) return res
Есть методы для дополнения объекта ошибки. setMsg и setData. setMsg принимает новый текст и опционально, вторым параметром объект, для дополнение data. Второй метод setData принимает только объект для дополнений data.
// get row
p = {id: params.id}
res = await r.api(Deal, 'getById', prepareP(p))
if (res.code) {
// return res
// return res.setMsg('Новый текст ошибки', {p})
return res.setData({p})
}
Или создать новую, с новым текстом и, возможно, новым типом, и в data указать ошибку и параметры.
p = {id: params.id}
res = await r.api(Deal, 'getById', prepareP(p))
if (res.code) {
return new UserError('Возможно кто-то удалил документ, обновите таблицу и попробуйте еще раз.',
{res, p})
}
Никаких throw
Концепция ядра такова, что методы никогда не кидают ошибку, а вместо этого возвращают объект UserError или MyError. Этого принципа и следует придерживаться при написании собственных методов.
Если метод использует какую-либо встроенную или стороннюю функцию, которая может выбросить ошибку, то ее следует обернуть в try/catch и в catch вернуть инстанс IAPIResponse.
Естественно, на верхнем уровне внутреннего api, а также еще выше, на уровне express имеется ловушка для непредвиденных ошибок, но это аварийный механизм.
Отсюда вывод, всегда проверять res.code.
Может показаться, что данный подход усложняет написание кода, за счет необходимости делать эту обработку после вызова каждого метода через внутреннее api, однако взамен мы приобретаем следующие преимущества:
Классический Exception предоставляет стек, но с учетом всех оптимизаций/минификаций, вызовов через анонимные функции и в целом очень большого количества промежуточных функций, читать этот стек практически невозможно, а его логирование обычно срезает большую часть данных. Механизм используемый в ядре предоставляет четкую цепочку вызова методов и ответов на каждом из уровней если они были уточнены, то с доп данными. То есть мы знаем, что метод один был вызван с такими то параметрами и вернул такой то ответ, внутри он вызвал следующий метод уже с другими параметрами и его ответ также имеется, и так далее.
Второе, преимущество (по мнению создателей ядра), состоит в том, что итак обрабатывать ошибки нужно на каждом уровне вызова (то о чем говорится, в предыдущем пункте), но с выбросом Exception это превращается в громоздкие try/catch вместо короткого
if (res.code) return res.setData({p})
или более полного:
if (res.code) return res.setMsg('Новый текст', {p, anotherAdditionalInfo:{abc:123}})
Принцип написания любого метода
Перед методом необходимо оставлять документирующий комментарий, достаточный для понимания, что именно делает метод и какие имеет особенности. Параметры при этом в нем описывать не требуется, так как стиль JSDoc создаваемый автоматически укажет только параметр r, а разработчика интересует только содержимое r.data.params. Они будут описаны с помощью generic, см. пункт ниже.
Входные и выходные параметры, а именно их определяющая для конкретного метода часть документируется в generics для IAPIRequest и IAPIResponse соответственно. Также там можно указать комментарий для параметров, в однострочном или многострочном стиле. В коде ниже можно будет увидеть пример.
Код метода, представляет собой условно 3 блока.
Проверка входных параметров, и определение переменных уровня метода, которые пригодятся далее.
Параметры метода расположены в r.data.params. Параметры запроса (нужны реже, так как это скорее системные параметры и используются ядром) расположены в r.params. Подробнее см. описание IAPIRequest.
Как правило в начале определяются 2 переменные p и res, которые будут многократно переиспользованы для вызовов методов через внутреннее api.
Основная логика состоящая из последовательного вызова необходимых методов, проверок и вычислений.
Как правило это получение данных, какие нибудь проверки, вызов других методов, сравнения, синхронизации данных, генерация чего-либо, сохранение изменений в базу.
Многие разработчики любят создавать отдельный метод под каждую микрозадачу, однако мы придерживаемся концепции, что выносить логику следует в первую очередь, в том случае, если она используется несколькими методами или если метод или файл сильно разрастаются.
Возврат успеха.
Этот блок весьма условный, так как метод может завершиться и раньше при определенных условиях, как успехом, так и не удачей. Но в конце в любом случае должен быть конечный return.
Пример метода:
/**
* Method will update the description of the deal by setting the current time,
* after which it will prepare the pdf
*/
async doDoc(r: IAPIRequest<{
id: number
}>): Promise<IAPIResponse<{
timeOfOperation?:string // Time that will be added to the description
/*
* Url to the generated pdf
*/
pdfLink:string
}>> {
const rParams: IAPIParams = r.params
const params: IAPIQueryParams = r.data.params
const {id} = params
if (isNaN(+id)) return new MyError('id not passed')
let p
let res: IAPIResponse
let pdfLink = ''
const timeOfOperation = toUserFriendlyDateTime()
// get row
p = {
id,
columns:['id', 'status_sysname'],
}
res = await r.api(Deal, 'getById', prepareP(p))
if (res.code) {
return new UserError('Maybe someone deleted the deal, update the table and try again.', {res, p})
}
const row = res.data.rows[0]
if (row.status_sysname !== 'READY') {
return new UserError('The deal is not ready yet', {row})
}
// Update the deal description
p = {
id:row.id,
description: `Test_description_${timeOfOperation}`,
}
res = await r.api(Deal, 'modify', prepareP(p))
if (res.code) res.setMsg('Failed to update the deal description', {p})
// Creating pdf...
return new UserOk('Pdf created', {timeOfOperation, pdfLink})
}
Выделение методов в отдельный файл. Ключевые сущности проекта, как правило имеют большое количество методов и их стоит выносить в отдельные файлы, разбивая по логике. Файлы сущностей располагаются в /classes/. Например /classes/Deal.ts. Часть методов можно вынести в отдельные файлы и расположить их следует в одноименной директории (ее нужно создать) /classes/Deal/someMethods.ts
Так как выделяемые в отдельный файл методы не должны терять доступа к контексту, то его следует передавать. Один из способов:
Метод в отдельном файле может выглядеть так:
export async function prepareReceipt(this: Payment, r: IAPIRequest<{
ids: number[] // ID платежей. Можно указать несколько - объединит в один чек
}>): Promise<IAPIResponse<{
receiptItems:{
name:string
price: number
quantity: number
amount: number
}[]
}>> {
…
А в самом классе делаем только определение метода и вызов вынесенного через call
/**
* Подготовит элементы платежа для чека. Метод учитывает, что для позиции заказа, могут быть указаны
* специальные подэлементы для чека. Метод приведет стоимость таких подэлементов к цене в платеже.
*/
async prepareReceipt(r: IAPIRequest<{
ids: number[] // ID платежей. Можно указать несколько - объединит в один чек
}>): Promise<IAPIResponse<{
receiptItems:{
name:string
price: number
quantity: number
amount: number
}[]
}>> {
return await prepareReceipt.call(this, r)
}
CRUD. В ядре это AddGetModifyRemove
Любой класс имеет эти четыре метода (и не только их) для работы с базой данных.
Подробнее описано в “Методы базового класса”.
Не залезаем в БД. Не пишем запросы руками
В БД через сторонние программы разработчику может потребоваться, в большинстве случаев, залезть только для того чтобы сделать или загрузить дамп. Изредка может потребоваться залезть, чтобы изучить данные при какой-то отладки. И, совсем редко, может потребоваться манипуляции со структурой или данными, если в процессе разработки были допущены крупные ошибки, и хочется переделать некоторую часть, а механизмы ядра не справляются в связи с каким-нибудь подвисшими данными или чем-то еще.
Договоренности по наименованиям, синтаксису, прочему
Наименование справочников d_ и ds_
Ядро предусматривает справочники двух типов.
Это системные справочники “ds_”, от слов dictionary system. Используются для таких справочников, которые могут меняться только в процессе разработки, но не в процессе эксплуатации. Например ds_order_status или ds_payment_type
Справочники вида “d_”, от слова dictionary. Могут меняться в процессе эксплуатации системы. Например d_product_category.
Иногда граница может быть размыта, например language, может быть как d_ так и ds_, так как справочник может быть сразу полностью (достаточно) залит или пополняться по мере надобности.
Такое наименование помогает при миграции данных, где справочники ds_ всегда переносятся, а d_ могут быть перенесены разово или вообще не переносится. Кроме этого это позволяет визуально лучше ориентироваться в таблицах, если требуется глубокая отладка или опять таки мердж данных. Еще такие сущности, автоматически попадают в меню Справочники или Системные справочники.
Типы полей. Используется в tables.json при описании
id - bigint(20)
Часто можно увидеть что bigint используется для id даже маленьких справочников, например ds_gender. Мы ранее не уделяли этому внимания, однако лучше использовать наиболее подходящий тип. См.в разделе, как заполнять tables.json
Вы можете указывать и другие размерности, но в этом случае не забывайте, когда вы в другой сущности используете поле как вторичный ключ к первой сущности, оно должно быть того же типа.
![][image2]
Чекбокс - tinyint(1)
Для булевых полей всегда использовать этот тип и длину. Именно так предусмотрено в MariaDB и именно на этот показатель ориентируется ядро для различных действий.
Остальные типы соответствуют типам MariaDB.
Бэкофис и фронтенд. Как организовать
Варианты реализации
Во многих проектах основной фронтенд ядра является как бэкофисом, административной панелью и интерфейсом для конечного пользователя. Во фронтенде ядра сразу имеются все интерфейсы, как для разработки (управление профайлами, меню, доступами, системными справочниками…), так и для администратора системы (опять таки управление пользователями и их доступом, настройками относящимися к проекту), а также для конечных пользователей (в рамках конкретного проекта строятся интерфейсы для конечного пользователя).
В этом случае, это, в большей степени, одностраничное приложение.
Именно такая реализация имеется из коробки.
Второй вариант, писать фронтенд проекта отдельно, будь то сайт, ERP-система или что-либо еще. Тогда бэкофис остается для разработчика и администраторов системы(опционально, так как и для них можно написать отдельный фронтенд), а фронтенд конечного пользователя пишется отдельно (на любом фреймвроке или нативно) и общается с бэкэндом по API (про типы подключения читай ниже).
Еще вариант писать мультистраничное приложение (сайт) с рендерингом на стороне сервера (SSR), используя роутинг предоставляемый фреймворком express, который входит в часть ядра, и использовать движок шаблонизации, например jade (pug).
Также можно скомбинировать первый и третий подходы и написать фронтенд на отдельном инстансе ядра используя SSR, это позволит инстансу “сайта” заниматься только сайтом, обеспечивая соединение к бэкэнду (отдельный инстанс, где реализована основная логика проекта).
Для мультистраничных решений, а также других запросов извне используется стандартный роутинг фреймворка express, откуда можно использовать внутренний API. Подробнее см. роутинг.
Подключение к бэкэнду
Встроенный фронтенд использует нашу библиотеку для подключения доступную в npm: go_core_query (https://www.npmjs.com/package/go_core_query), которая обеспечивает непрерывное соединение с сервером по сокету, умеет обрабатывать ответ об не авторизированном запросе (действие сессии истекло) и перенаправлять на страницу авторизации (можно переопределить это действие). Предоставляет два метода общения с сервером: через асинхронную функцию api (максимально схожую с серверным внутренним api); и конечно общение через emit/on - то есть все то, что предоставляет socket.io. Большая часть логики обычно реализуется через api, так как архитектура приложения значительно прозрачнее, когда есть запрос и есть ответ, но для некоторых задач можно использовать сокет как есть (для обеспечения двусторонней связи).
Функция api, кстати, также работает через socket, а не через fetch, но разработчику об этом думать не надо.
Для того, чтобы в консоле видеть запросы и ответы, необходимо включить режим отладки: вызовите в консоле (браузера) debug(); после чего вы должны увидеть информацию об изменении режима отладки; обновите страницу.
В своих приложениях вы также можете использовать эту библиотеку, как на фронте так и на бэкэнде, настроив ее под свои нужды. Подробнее см. документацию на библиотеку.
Кроме библиотеки вы можете использовать простой http(s) API, через fetch или другие механизмы. Вам потребуется выполнить запрос авторизации, в ответ вы получите JWT, который требуется добавлять в Authorization header: Authorization: Bearer
Мобильное приложение
Мобильное приложение может быть написано на чем угодно, в частности на ReactNative. Для RN приложений также как и для обычного фронтенда доступна библиотека для подключения. В остальных случаях вам подойдет http(s) API. Подробнее