09 Observable сервисы
Это инстансы класса, являющегося наследником базового класса BaseService
, который вы получаете из обвязки (из модуля "bi-internal/core"
если точнее).
Он сам по себе реализует паттерн Observable
, при котором вы формируете наблюдаемую модель (некий объект сродни state
в React
), изменения которого происходят публичными методами этого сервиса (или из-за того, что сервис подписан на изменения другого сервиса, который драматически влияет на поведение текущего) и который реализует (наследует от BaseService
) механизм уведомления компонентов-подписчиков данного сервиса.
Основная цель Observable
сервиса - предоставить вам механизмы взаимосвязи между как React
компонентами в разных контекстах (в разных атласах, дешбордах, дешлетах), так и экономного запрашивания данных, которые “дорого” запрашивать часто, не кешируя.
Так же он позволит вам выступать в качестве портала, через который можно передавать данные, реализовывать собственную событийную систему. Или в качестве некой смеси Model-Controller
из MVC
архитектуры для фронтенда.
Чаще всего обязательными ключами такой модели являются loading
и error
(по ним вы можете отслеживать, готова ли модель для работы или еще в процессе загрузки, а также не содержит ли она ошибок). Это наследие интерфейса IBaseModel
export interface IBaseModel {
error?: string | null;
loading?: boolean | number;
}
loading: true,
error: null
То есть мы изначально ожидаем, что ошибок нет, а модель грузится. Когда целевое действие в инициализации сделано (например вы загрузили с сервера важные структуры данных и как-то организовали их хранение), то вы выставляете loading: false
и, если имеет место - указываете текст ошибки. Работа с сервисом начнется именно с момента loading: false
.
Чтобы подписаться на изменения модели такого сервиса нужно заинициализировать его и указать callback
-функцию, которая вызывается на события изменения модели сервиса, полной или частичной. По умолчанию такой callback
автоматически принимает на вход текущий объект модели сервиса после каждого события изменения. Подписка по-разному выглядит для классовых и функциональных компонентов реакта, но об этом позднее.
Сами по себе сервисы могут быть:
Singleton
конструктор такого класса пустой, а сам инстанс получаем статическим методомMyService.getInstance()
.Factory
конструктор зависит от неких входных параметров (например идентификатора куба, имени схемы атласа и чего угодно, что для вас имеет смысл) и сохраняет каждый инстанс по соответствующему идентификатору
MyService.createInstance(koobId)
. Это уместно, когда для каждого из кубов (например), которые вы встречаете на странице вы делаете ресурсоемкую задачу и не хотите дубляжа - подобный сервис вам в помощь
Инстансы сервисов, какие бы они ни были, принято хранить в глобальных переменных внутри window
и предварять его название символом __
. Например, если в консоли браузера, в котором открыта страница инстанса Luxms BI набрать __
, то мы увидим ряд сервисов, которые были сохранены системой после своей инициализации. Там всегда хранится актуальная его версия. Очень полезно
так можно посмотреть на текущие настройки:
Если вы хотите создавать инстансы для каждого из переданных идентификаторов, то вот пример метода createInstance
:
export class MyService extends BaseService<IMyServiceModel> {
private readonly id: string | number;
private constructor(koobId: string) {
super({
loading: false,
error: null,
data: [],
});
this.id = koobId;
}
// Тут какие-то публичные методы, которые вам понадобятся
public setSomething = (smth: string[]) => {
this._updateWithData({
data: smth
})
}
public static createInstance (id: string | number) : MyService {
if (!(window.__myService)) {
window.__myService = {};
}
if (!window.__myService.hasOwnProperty(String(id))) {
window.__myService[String(id)] = new MyService(String(id));
}
return window.__myService[String(id)];
};
}
Аналогичный пример для getInstance
:
export class MyService extends BaseService<IMyServiceModel> {
private constructor() {
super({
loading: false,
error: null,
data: [],
});
}
// Тут какие-то публичные методы, которые вам понадобятся
public setSomething = (smth: string[]) => {
this._updateWithData({
data: smth
})
}
public static getInstance = () => {
if (!(window.__myService)) {
window.__myService = new MyService();
}
return window.__myService;
};
}
MyService.getInstance();
Итого:
Вы можете использовать пример с сервисами выше как шаблон написания собственных сервисов. К чему все сводится:
- Создаете папку под сервисы, например
src/services
- Создать в нем файл
MyService.ts
, название файла одноименно с названием класса. - Класс должен быть наследником
BaseService
- В конструкторе прописываете логику инициализации (или вызываете функцию инита, которую в классе и пропишете)
- Пишете метод (
getInstance
,createInstance
), который создает инстанс и прихранивает его в переменной где-то вwindow
- Создаете публичные методы, которые будете дергать из компонентов-подписчиков, и которые меняют модель сервиса одним из методов типа
_updateWithData
или_updateModel
(о них ниже)
На этапе работы с сервисами вам может пригодиться обращение к сохраненному инстансу сервиса, чтобы проверить верность его модели или работы методов.
- Открыть консоль браузера
- Набрать
__appConfig
- Увидим инстанс класс
AppConfig
с настройками фронтенд-приложения изsettings.js
__appConfig.getModel()
или__appConfig._model
отдаст текущий объект модели данного сервиса.- Для сервиса типа
Factory
вы увидите не просто объект класса сервиса, а объект с ключами, являющимися идентификаторами из конструктора или методаcreateInstance
. И чтобы получить модель, вам нужно будет дополнительно выбрать идентификатор того инстанса, чью модель вы хотите получить.
Методы изменения модели
От предка сервисы наследуют стандартные гетеры и сетеры в виде _setModel(model: M)
и getModel()
методы. Которые устанавливают полную модель или возвращают ее соответственно.
Так же есть методы:
// Изменить несколько полей в модели и уведомить слушателей
protected _updateModel(partialModel: Partial<M>): void {
this._setModel({
...(this._model as any),
...(partialModel as any),
});
}
// Уведомить слушателей что модель загружается (принудительно)
protected _updateWithLoading(): void {
this._updateModel({ loading: true, error: null } as any);
}
// Уведомить слушателей что модель содержит ошибку (принудительно)
protected _updateWithError(error: string): void {
this._updateModel({ loading: false, error } as any);
}
// Уведомить слушателей что модель загружена с новыми значениями полей (принудительно)
protected _updateWithData(partialModel: Partial<M>): void {
this._updateModel({ loading: false, error: null, ...(partialModel as any) });
}
Пример:
const publicFilters = {sex: ['=','Мужской'] };
const privateFilters = {age: ['>=',50'] };
/* Выставит в модели UrlState видимые (publicFilters) в урле фильтры и скрытые (privateFilters), которые пропадут после перезагрузки страницы */
UrlState.getInstance().updateModel({f: publicFilters, _koobFilters: privateFilters});
Методы уведомления подписчиков
От предка сервисы наследуют методы:
/* Можно вызвать событие и вызвать колбеки слушателей, передав им на вход список аргументов */
protected _notify(eventDescription: IEventDescription, ...args: any[]) {
const listeners = this._listeners.filter(listener => shouldNotifyListener(eventDescription, listener));
listeners.forEach(listener => {
if (!this._listeners) { // suddenly we were disposed
debugger;
return;
}
if (this._listeners.includes(listener)) { // check if listener was removed during update
try {
listener.callback(...args);
} catch (err) {
console.log(`[Observer] Error notifying listener of event "${eventDescription}"`);
console.log(err);
}
}
});
}
Методы реализации подписки на изменение модели
От предка сервисы наследуют методы:
/* Принимает идентификатор события или поле модели и колбек, вызываемый при наступлении события без первичного уведомления подписчиков */
subscribe(event: string | string[], (model: M) => void)
/* Принимает колбек, вызываемый при при наступлении события и с первичным уведомлением подписчиков */
subscribeAndNotify(event: string | string[], (model: M) => void)
/* Принимает колбек, вызываемый при любом изменении полей модели без первичного уведомления подписчиков */
subscribeUpdates((model: M) => void)
/* Принимает колбек, вызываемый при любом изменении полей модели и с первичным уведомлением подписчиков */
subscribeUpdatesAndNotify((model: M) => void)
Эти методы вы будете использовать только в классовых компонентах, ибо в функциональных методы работы с ней иные.
Колбек есть функция написанная в React
компоненте и которая совершает какие-то действия с его state
(вызывает setState
) (или другом сервисе и тогда речь о его модели). При указании нее вы автоматически передаете this
как ссылку на компонент сервису. Благодаря ей он и вызывает нужный колбек у нужного компонента, дирижируя состояниями компонентов на страницах.
Методы реализации отписки от изменений модели
unsubscribe
- метод, который очищает память и удаляет инстанс. На тот случай. если хотите очистить память или если вы не хотите доверять тому, что есть в кеше и хотите инициировать его заново при определенных условиях. Чаще всего вызывается на unmount реакт-компонента, если это необходимо
unsubscribe((model: M) => void);
Метод whenReady()
Метод для случаев, когда явного метода для инициализации инстанса нет. Проверяет ,что сервис загружен, ошибок нет и автоматически реализует подписку на обновление полей модели, без первичного уведомления подписчиков по умолчанию.
/**
* Wait until service in state 'ready' (which means, no error and no loading)
* return Promise<MODEL>
*
* if model signal error, it rejects resulting promise
*
* @returns {Promise<MODEL>}
*/
public whenReady(): Promise<M> {
return this.lock<M>(() => {
if (this._model.error) {
return Promise.reject(this._model.error);
}
if (this.isReady()) {
return Promise.resolve(this._model);
}
return new Promise<M>((resolve, reject) => {
if (this._model.error) {
reject(this._model.error);
return;
}
if (this.isReady()) {
resolve(this._model);
return;
}
const subscription = this.subscribe('update', model => {
if (model.error) {
subscription.dispose();
reject(model.error);
} else if (this.isReady()) {
subscription.dispose();
resolve(model);
}
});
// return this.happens('ready');
});
});
}
Сервис AppConfig для работы с настройками из settings.js
Пакет "bi-internal/core"
, Singleton
Инстанс получаем через AppConfig.getInstance()
Сервис, который хранит своей модели информацию о настройках из файла settings.js
.
Из него вы можете подчерпнуть информацию о текущей языковой локализации, темах (подразумевается, что тут вы переопределяете базовые темы для текущего инстанса), название проекта и используемые опции в блоке features
.
Чтобы получить информацию о настройках, вам нужен следующий код:
import React from 'react';
import {AppConfig} from 'bi-internal/core';
class MyClassComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
appConfigModel: AppConfig.getInstance().getModel()
}
}
public render() {
const {appConfigModel} = this.state;
return (
{/* Отобразит Luxms BI по умолчанию*/}
<div>{appConfigModel.projectTitle}</div>
);
}
}
const MyComponent = (props) => {
const appConfigModel = useService(AppConfig);
return (
{/* Отобразит Luxms BI по умолчанию*/}
<div>{appConfigModel.projectTitle}</div>
);
};
export default MyComponent;
У данного сервиса есть полезный публичный метод, который вам понадобится при запросах к API приложения или файлам: AppConfig.fixRequestUrl(url: string): string
. Формирует корректный урл к указанному в аргументе пути на основе настроек приложения.
const url = AppConfig.fixRequestUrl('/srv/datagate/source/upload');
Перечень статичных методов сервиса, позволяющих быстро получить значение соответствующего поля модели:
public static fixRequestUrl(url: string): string {
return this.getInstance().fixRequestUrl(url);
}
public static hasFeature(featureName: string): boolean {
return this.getInstance().hasFeature(featureName);
}
public static getProjectTitle(): string {
return this.getModel().projectTitle;
}
public static getLocale(): string {
return this.getModel().locale;
}
public static getLanguage(): 'en' | 'ru' {
return this.getModel().language;
}
public static getPlugins(): string[] {
return this.getModel().plugins;
}
AuthenticationService и информация о пользователе
Пакет "bi-internal/core"
, Singleton
Инстанс получаем через AuthenticationService.getInstance()
Сервис реализует фронтенд часть механизма аутентификации и хранит конфиг пользователя.
Подразумевается, что вы хотите переопределить окно логина и написать свой способ аутентификации пользователя. Для этого вы создали компонент DlgAuth.js
в ресурсах атласа ds_res
и в нем реализуете интерфейс для новой формы входа пользователя в BI.
Используемые интерфейсы:
// Интерфейс модели сервиса
export interface IAuthentication extends IBaseModel {
error: string | null; // есть ли ошибка в процессе формирования модели
loading: boolean; // загружено ли все необходимое для модели
authenticating: boolean; // в процессе ли аутентификации
authenticated: boolean; // аутентифицирован (есть кука)
userId?: number; // id пользователя
access_level?: string; // старый режим прав доступа (с выключенным rbac9). admin или usual
name?: string;// имя пользователя
site_role: string, // (автоматически подставляется БД) сайтовая роль
license_role: string, // (автоматически подставляется БД) лицензионная роль
userConfig?: { [key: string]: string }; /* объект с доп.полями пользователя. Что угодно, например идентификатор выбранной ранее в профиле темы themeId */
isNeed2FACode?: boolean; /* ждем ли ввод смс при двухфакторке (нужно указать в таблице adm.configs authType = '2fa') */
isBlocked?: boolean; // заблокирован ли
errorKey?: string; // ключ ошибки из БД
errorMessage?: string; // описание ошибки из БД
violationMessages?: string[]; // список нарушений парольной политики (при смене пароля)
redirectUri?: string; // адрес редиректа при статусе запроса 303
}
// Интерфейс пользователя системы
export interface User {
id: number; // ID
name: string; // имя пользователя (ФИО например)
email: string; // почта
username: string; // логин
phone: string; // телефон
access_level: 'admin' | 'usual'; // старый режим прав доступа (с выключенным rbac9). admin или usual
config: any; // объект с доп. информацией о пользователе
is_blocked: 0 | 1; // заблокирован?
is_local: boolean; // локальный ли пользователь или доменный
license_role_ident: string; // идентфикатор лицензионной роли
site_role_ident: string; // идентфикатор сайтовой роли
password_policy: number; // парольная политика
}
interface IAuthCheckData2 {
sessionId: string;
user: User;
authType?: string;
}
interface IAuthCheckData3 {
access_level: string; // 'admin' или 'usual'
id: string; // user id
name: string; // имя пользователя
authType?: string; // тип авторизации (двухфакторная (2fa) или иная)
config: any; /* конфиг пользователя, например ключи
avatarId: "g1x3y3" (идентификатор иконки пользователя)
favoriteAtlases: [112,1560, 1553,70] (Атласы в избранном)
themeId: "light" (сохраненная ранее тема)
*/
}
Пример модели сервиса и ответа методов check
, login
:
{
"id": "1",
"username": "adm",
"email": "blackhole@localhost.localdomain",
"access_level": "admin",
"name": "Default Admin",
"config": {
"themeId": "light"
},
"sys_config": {
"ext_groups": []
},
"site_role": "Super",
"license_role": "Admin",
"error": null,
"loading": false,
"authenticating": false,
"authenticated": true,
"userId": 1,
"userConfig": {
"themeId": "light"
},
"redirectUri": null
}
import {AuthenticationService, IAuthentication} from 'bi-internal/core';
const MyComponent = (props) => {
const authService = AuthenticationService.getInstance();
const authModel: IAuthentication = authService.getModel();
{/* "Default Admin" */}
return (<div>{authModel.name}</div>)
}
export default MyComponent;
Базовый метод init сервиса AuthenticationService:
Запускается в конструкторе
private async _init(): Promise<any> {
/*
Запускает процесс аутентификации, делает метод `check`, ловит ошибки, проверяет количество попыток входа и статусы ответов
*/
}
API для нужд аутентификации/авторизации
Запрос check:
GET /api/auth/check
По умолчанию сам факт обращения по данному урлу запускает механизм аутентификации через обращение к таблице adm.users
(с проверкой прав доступа при rbac9
). Так же возможно включить SSO Kerberos / Simple and Protected GSSAPI Negotiation Mechanism (SPNEGO)
(Подробнее о настройке сервера в Руководстве системного администратора. Приложение D
). Возвращает объект информации о пользователе интерфейса User
с IAuthCheckData2
или IAuthCheckData3
.
Соответствующий метод сервиса:
public async check(): Promise<IAuthCheckData2 | IAuthCheckData3> {
/*
считывает настройки сервиса AppConfig, UrlState
проверяет режимы loginme, no-sso и OIDC
Если есть функция getAuthToken, использует ее для получения токена OIDC
*/
}
Запрос check-no-sso:
GET /api/auth/check-no-sso
Используется автоматически, если в урле ?no-sso
. Возвращает то же, что и обычный check
, но пользователя берем из adm.users
, а не внешнего источника. Необходимо для случаев, когда в системе включен режим OpenID Connect
, но нам по какой-либо причине нужно принудительно попасть в систему и что-то там настроить.
Соответствующий метод сервиса: check()
, разница только в итоговом урле GET
запроса.
Запрос login:
POST /api/auth/login
Осуществляет процесс передачи логина и пароля на сервер для аутентификации пользователя. При успешном входе вернется authenticated: true
и возвращается объект информации о пользователе, аналогичный запросу check
.
body:
{
`username=${encodeURIComponent('admin')}&password=${encodeURIComponent('12345')}`
}
axios({
method: 'post',
url: AppConfig.fixRequestUrl(`/api/auth/login`),
data: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
}
Соответствующий метод сервиса:
public async signIn(username: string, password: string, new_password?: string): Promise<IAuthentication> {
/*
выполняет запрос `login` и `2fa` (при необходимости)
*/
}
Запросы 2fa:
В запросе login
есть ситуация, когда включен режим authType: '2fa'
. Тогда после отправки логина и пароля через login
вернется частичная модель со статусом 449
, требующая ввести код подтверждения и isNeed2FACode: true
.
Сервис, отсылающий смс с кодом настраивается на сервере, однако, допустим, что вам уже завели учетную запись, указав телефон, вы ввели логин/пароль и вот код уже у вас на руках. Получив частичную модель вы увидели (или отрисовали в своем компоненте DlgAuth.js
) форму ввода кода подтверждения.
Запрос 2fa/login2:
POST /api/auth/2fa/login2
Отправляет код подтверждения на сервер.
body:
{
factor2: parseInt(code, 10)
}
Соответствующий метод сервиса:
public async signInWithCode(code: string): Promise<any> {
/*
выполняет запрос `2fa/login2`
*/
}
Запрос 2fa/resend-factor2:
GET /api/auth/2fa/resend-factor2
Приводит к попытке повторной отправки кода подтверждения на телефон (сервис приема кодов).
Соответствующий метод сервиса:
public async resendCode(): Promise<any> {
/*
выполняет запрос `2fa/resend-factor2`
*/
}
Запрос logout:
GET /api/auth/logout
Осуществляет выход из учетной записи пользователя.
Соответствующий метод сервиса:
public async signOut(): Promise<any> {
/*
выполняет выход из BI и при `OIDC` перенаправляет на урл провайдера `OIDC` для выхода
*/
}
Процесс аутентификации:
В таблице adm.configs
есть поле authorization.mode
, отвечающее за режим авторизации. Если указан rbac
, то используются спец. алгоритмы по проверке домена и групп LDAP и правила из таблиц в схеме rbac
. Если указан rbac9
, то используется новая гранулярная система прав доступа, появившаяся в версии v9
Есть authentication.mode
, отвечающий за режим аутентификации. Он или пустой или OIDC
- аутентификация через протокол OpenID Connect
через Keycloak
,Blitz
и иные провайдеры данного протокола. Если включен OIDC
, для подключения используются настройки с префиксом authentication.OIDC
Есть поле luxmsbi.authentication
. Оно или пустое или 2fa
(2-факторная авторизация с кодом через смс или бот).
Как AuthenticationService
проводит аутентификацию на уровне фронтенда:
Проверяем, нет ли
?loginme
в урле.Если есть - не делаем
check
, принудительно показываем окно входа в систему, далее после ввода логин-пароля шлем запросlogin
(опционально запросы типа ‘2fa’), проверяем пользователя по таблицеadm.users
, получаем кукуLuxmsBI-User-Session
и мы в системе.
Необходимо для тестировщиковЕсли нет - проверяем, нет ли
?no-sso
в урле.Если есть - Делается запрос
check-no-sso
. идем вLDAP
и проверяем пользователя вadm.users
.Если нет - Делается запрос
check
, что запускает процесс аутентификации черезSPNEGO
(v9) илиLDAP
(default)Если в конфиге
authentication.mode = null
- идем вLDAP
и проверяем пользователя вadm.users
Если в конфиге
authentication.mode = 'OIDC'
(OpenID Connect
), то
- используя настройки в конфиге с префиксом
authentication.OIDC
, выполняемcheck
- получаем в ответе статус 303 и
redirect_uri
, на который мы редиректимся на страницу провайдераOpenID Connect
(Blitz
,Keycloak
) - Авторизуемся в системе провайдера
- Возвращаемся обратно в BI.
- Выход из учетки осуществляется аналогично. В каждый запрос после такой авторизации мы подмешиваем заголовок
'Authorization': `Bearer ${token}`
Если нам не нужно, чтобы нас редиректило со статусом 303 при включенном
'OIDC'
, то мы можем указать вopt/luxmsbi/web/settings/settings.js
в блокеfeatures
опцию!OIDCRedirect
, тогда, получив 303 мы принудительно остаемся на странице с окном авторизации и в кастомном компонентеDlgAuth.js
реализовать логику обработки 303, храняredirect_uri
до востребования. Таким образом возможно сделать страницу с выбором: авторизоваться черезLDAP
или черезOpenID Client
протокол, ибо урл с редиректом уже у нас, его можно просто навесить как обработчик клика по какой-либо кастомной кнопке.Допустим есть система, которая ходит в провайдер
OIDC
, напримерKeycloak
. В ней есть блок, где вставлен BI вiframe
и в нем мы хотим авторизоваться по токену из родительского окна.
Мы можем прописать в конфиге функциюAppConfig.getModel().getAuthToken
, которая будет реализовывать логику кастомного получения токена внутри фрейма. Возвращает соответствующий токен .
Родительский фрейм может реализвовать API через механизм window.postMessage
:
когда родительскому фрейму приходит сообщение от iframe
с BI:
{
"type": "jwtToken",
"forceUpdate": true | false
}
родительский фрейм должен отправить ответное сообщение:
{
"type" :"jwtToken",
"token": "сам токен",
"expires": "timestamp когда надо будет его перезапросить"
}
Пример функции getAuthToken
:
getAuthToken: function() {
if (window.parent === window) return;
return new Promise(function (resolve) {
var onMessage = function (event) {
if (event.data.type === 'jwtToken') {
window.removeEventListener('message', onMessage)
resolve(event.data);
}
};
window.addEventListener('message', onMessage);
window.parent.postMessage({"type":"getJwtToken","forceUpdate":false}, 'https://graph-demo-ui.datacloud.t1-cloud.ru/');
});
},
UrlState и работа с url приложения
Пакет "bi-internal/core"
, Singleton
Инстанс получаем через UrlState.getInstance()
Сервис следит за состоянием url
SPA-приложения luxmsbi-web-client
(Обвязки).
import {UrlState} from 'bi-internal/core';
Его модель выглядит так:
Ряд ключей уже устарели и являются необходимыми только для MLP
кубов (нашей более старой версии хранения данных в атласе (тогда это звалось датасет)), потому я позволил себе их убрать из списка:
dash: null // строка, хранящая идентификатор текущего выделенного дешлета (раскрытого на весь экран)
dboard: "4" // идентификатор текущего дешборда
path: ( ['ds', 'ds_5142', 'dashboards'] // полный путь к текущему разделу
route: "#dashboards" // идентификатор роута текущего раздела. В данном случае мы на странице с дешбордами
segment: "ds" // идентификатор плагина (сегмента) в общем случае или одного из нескольких разделов ,
// которые вы видите на стандартной разводящей странице после авторизации
segmentId: "ds_5142" // идентификатор элемента раздела (сегмента)
slide: null // идентификатор слайда (актуально при предпросмотре презентации)
f: {} // опциональный ключ, который по синтаксису идентичен тому, что вы пишете в блоке filters конфига дешлета
// c той разницей, что туда нельзя писать значение true, только идентификатор дименшна и массив
// с оператором и операндами. Если там появится что-то вроде sex: ["=", "Мужской"] то вы таким образом
// наложите сохраняющийся при перезагрузке страницы фильтр на дименшн sex. Потому этот способ
// используется, чтобы передать кому-то ссылку на страницу с предустановленными фильтрами (будет get-параметр &f.sex=Мужской)
_koobFilters: {} // хранит фильтры из сервиса фильтров, которые вы не хотите показывать в урле (например потому, что урл не резиновый, а строка фильтра может быть огромной)
У данной модели есть особенности: все ключи здесь являются частью стандартного интерфейса IUrlState
. И не все из них вы видите в итоговом урле приложения, потому что не все из них на данный момент времени могут иметь значение или оправданы разделом. Не являются частью стандартного интерфейса "f"
и "koobFilters"
они добавляются программно сервисом по фильтрации данных например.
Так вот постулируется следующее: все ключи в модели UrlState
, которые содержат "_"
в начале являются скрытыми и явно в урле не видны. Например вы хотите сохранить в модели UrlState
объект, который хранит какую-то нужную вам информацию на другом дешборде или на другом атласе.
Вы можете сохранить его как раз в ключе с "_"
. А если хотите сделать новый GET
-параметр - укажите обычный ключ, без префикса.
Этот сервис предоставляет ряд методов, которые можно использовать:
UrlState.navigate({segment: 'ds', segmentId: 'ds_230', dboard: "3"})
метод принимает объект который частично или полностью содержит ключи, которые вы хотите переопределить в модели урла и как следствие совершить переход на другой раздел, дешборд, атлас или слайд. В данном примере совершится переход на дешборд с идентификатором 3 атласа c schema_name = 'ds_230'
.
UrlState.updateModel({_myData: {key1: 4546, data: [124, 5757, 575468]}})
И последующее получение
UrlState.getModel()._myData
Вы сохраните в модели данного сервиса данные, которые могут использовать ваши компоненты React по всему инстансу Luxms BI. Не злоупотребляйте! придерживайтесь принципов грамотного разбиения сервисов и компонентов по выполняемым ими функциям, а не использовать один как швейцарский нож.
Подобная возможность хранения в данной модели произвольных данных связана с функционалом презентаций, когда вы путешествуете по разделам BI и добавляете интересующую вас страницу (например ту, где вы выбрали какой-то фильтр или сделали действие ,которое достигается вашим кастомным сервисом ). Добавление страницы в конкретный шаблон презентации под капотом есть ни что иное как добавление в список ее полной модели UrlState
.
Ибо на сервере при генерации презентаций работает headless chrome
, который получает объект модели UrlState
, восстанавливает страницу, исходя из модели и результат рендерит и сохраняет картинку в pdf
или pptx
(и так для каждого слайда).
Это должно говорить вам о том, что если ваш кастомный сервис качественно влияет на внешний вид и функционал страницы - озаботтесь тем ,чтобы придумать и сохранить ключевую информацию в модели UrlState
. Это означает, что вы не храните там без нужды результат тяжелого запроса или огромный массив, а храните только то, без чего например запрос за этим массивом невозможен. Тогда, при попадани такой модели в презентацию - сервер сможет восстановить именно ту ситуацию, какую вы ожидаете увидеть в презентации.
Примеры сервисов
Давайте рассмотрим пример observable
сервиса, который вы можете использовать в своих компонентах. Предлагаю хранить все ваши сервисы в папке src/services
проекта BMR
и иметь в имени класса слово "Service"
. Данный сервис есть ни что иное как локальная версия коробочного сервиса по управлению фильтрами KoobFiltersService
(назовем файл KoobFiltersService.ts
). И допустим мы хотим его как-то поменять и в дальнейшем в компонентах использовать эту версию сервиса, а не ту, что приходит нам из обвязки
import { BaseService, UrlState } from 'bi-internal/core';
import { throttle } from 'lodash';
const throttleTimeout = 300; // можно ставить достаточно большим
// повторные фильтры будут срабатывать в течение этого времени
export interface IKoobFiltersModel {
loading?: boolean;
error?: string;
query?: string;
result: any;
filters: any;
pendingFilters: any;
}
export class KoobFiltersService extends BaseService<IKoobFiltersModel> {
private constructor() {
super({
loading: false,
error: null,
query: undefined,
result: {},
filters: {},
pendingFilters: {},
});
/*
Хотим, чтобы каждый раз ,когда в модели меняются объекты `_koobFilters` или `f` вызывался колбек `this._onUrlStateUpdated`
*/
UrlState.subscribeAndNotify('_koobFilters f', this._onUrlStateUpdated);
}
protected _dispose() {
UrlState.unsubscribe(this._onUrlStateUpdated);
super._dispose();
}
private _onUrlStateUpdated = (url) => {
this._updateWithData({filters: {...url._koobFilters, ...url.f}});
}
public setFilter(koob: string, dimension: string, filter?: any[]) {
let filters = this._model?.pendingFilters;
if (filter) {
let arr: string[] | undefined = filter?.slice(0);
filters = {...filters, [dimension]: arr};
} else {
filters = {...filters, [dimension]: undefined};
}
this._updateModel({pendingFilters: filters});
this._applyAllFilters();
}
public setFilters(koob: string, newFilters: any) {
let filters = this._model.pendingFilters;
for (let dimension in newFilters) {
let filter = newFilters[dimension];
if (filter) {
let arr: string[] | undefined = filter?.slice(0);
filters = {...filters, [dimension]: arr};
} else {
filters = {...filters, [dimension]: undefined};
}
}
this._updateModel({pendingFilters: filters});
this._applyAllFilters();
}
public applyPeriodsFilter(dimension: string, lodate: string | number, hidate: string | number) {
const filters = this._model.pendingFilters;
const _filters = {...filters, [dimension]: ['between', lodate, hidate]};
this._updateModel({pendingFilters: _filters});
this._applyAllFilters();
}
private _applyAllFilters = throttle(() => {
const filters = {...this._model.filters, ...this._model.pendingFilters};
this._updateModel({pendingFilters: {}});
const url = UrlState.getInstance().getModel();
let publicKeys = Object.keys(url.f || {}); // Раскидываем ключи фильтров на две части - публичную и скрытую
const publicFilters = {}, privateFilters = {}; // в публичную попадают ключи, которые уже есть в url
for (let key in filters) { // Может быть стоит добавить какое-то более остроумное условие
if (publicKeys.includes(key)) {
publicFilters[key] = filters[key];
} else {
privateFilters[key] = filters[key];
}
}
UrlState.getInstance().updateModel({f: publicFilters, _koobFilters: privateFilters});
}, throttleTimeout);
public static getInstance = () => {
if (!(window.__koobFiltersService)) {
window.__koobFiltersService = new KoobFiltersService();
}
return window.__koobFiltersService;
};
}
KoobFiltersService.getInstance();
KoobFiltersService
- singleton
, который аггрегирует в себе информацию обо всех фильтрах, которые были сделаны пользователем на текущий момент. По умолчанию, на фильтр, добавляемый в модель такого сервиса реагируют не все деши, а лишь те, для которых в блоке filters
прописано true
на него
filters: {
category: ["=", "Clothes", "Shoes", "Scarfs", "Bags"],
example: true
}
то есть если я каким-то образом (через упр.деш или программно) выставлю фильтр на exampleDimension
- дешлет обновится и перезапросит данные.
В затроттленном методе applyAllFilters
мы видим как раз ту самую ситуацию, что мы хотим выставить важные для нас параметры в урл (явно и неявно)
UrlState.getInstance().updateModel({f: publicFilters, _koobFilters: privateFilters});
Рассмотрим пример работы с секцией публичных фильтров f. Это объект фильтров, аналогично тому, как мы задаем фильтры в конфиге дэшлета, который затем преобразуется к строчку, которая может быть прочитана в урле в качестве фильтра.
Есть ограничения, что не все операторы попадут в урл. Сейчас туда удачно встраиваются =, between.
Таким образом, если наш объект фильтров будет:
filters: {
category: ["=", "Clothes", "Shoes", "Scarfs", "Bags"],
example: ["between", "2020-01-01", "2024-01-01"],
}
Мы вызвали
UrlState.getInstance().updateModel({f: filters});
То в урле мы увидим примерно следующее:
/#/ds/ds_19738/dashboards?f.category=Clothes,Shoes,Scarfs,Bags&f.example=2020-01-01..2024-01-01
И эти фильтры не сбросятся при перезагрузке, но могут быть изменены через УД и сам урл при этом будет меняться, попадая в history.
Пока нет в интерфейсе кнопки, которая будет автоматически делать фильтры публичными.
Подписка на сервисы через React хуки
Мы уже видели в примеров функционального компонента MyComponent
подписку на сервис KoobFiltersService
Она достигалась методами useService
, useServiceItself
.
Так вот, такая запись
const koobFiltersService = useServiceItself<KoobFiltersService>(KoobFiltersService);
или такая
const koobFiltersServiceModel = useService<KoobFiltersService>(KoobFiltersService);
В обоих случаях приведет к тому, что модель сервиса, хранящаяся в переменной koobFiltersService
( через .getModel()
) или koobFiltersServiceModel
будет всегда актуальной. и когда бы вы не обратились к этим переменной - они, при условии, что loading: false
дадут вам свою актуальную модель
Достаточно в ключевых местах (но только ниже всех useEffect
, иначе ошибка минифицированного React будет) вызвать блок
if (koobFiltersServiceModel.loading || koobFiltersServiceModel.error) return;
Или иным способом проверять что сервис загружен.
И далее работать в обычных рамках функционального реакт-компонента.
Примеры для сиглтон и не-синглтон сервисов
const koobFiltersService = useServiceItself<KoobFiltersService>(KoobFiltersService)
const datePickerService = useServiceItself<DatePickerService >(DatePickerService, "luxmsbi.myKoob")
Классовые же компоненты требуют от вас немного большей организованности:
Рассмотрим пример
import React from "react";
import './DatePickers.scss';
// Подключили кастомный сервис
import {DatePickerService} from "../services/ds/DatePickerService";
export default class DatePickers extends React.Component<any> {
private _datePickerService: DatePickerService = null; // подготовили переменную для хранения инстанса сервиса внутри текущего компонента.
public state: {
koob: string;
data: any;
error: string,
};
public constructor(props) {
super(props);
this.state = {
koob: "",
data: [],
error: ""
};
}
public componentDidMount(): void {
const { cfg } = this.props;
const koob = cfg.getRaw().koob;
// Пусть сервис зависит от идентификатора куба
this._datePickerService = DatePickerService.createInstance(koob);
this._datePickerService.subscribeUpdatesAndNotify(this._onSvcUpdated); // подписка на все изменения модели
}
private _onSvcUpdated = (model) => {
if (model.loading || model.error) return; // проверяем, готов ли к работе сервис и устанавливаем стейт из данных модели
this.setState({
data: model.data,
});
}
public onSubmitClick = () => {
// прим какого-то целевого действия, вызывающего метод, меняющий модель сервиса
this._datePickerService?.setFilter(here_some_arguments);
}
public render() {
const {data} = this.state;
return (
<div className="DatePickers">
{/* что-то делаем с data */}
<div className="DatePickers__SelectButtons">
<div className="DatePickers__SelectButton active" onClick={this.onSubmitClick}>Применить</div>
</div>
</div>
</div>
);
}
}
Таким образом компоненты на сервисах можно легко свести к банальному View
, который только отображает данные, но почти ничего сам не считает и зависит только от настроек конфига дешлета.
Всю работу и бизнес-логику на себя возьмет сервис.
CanIService и проверка прав
Пакет "bi-internal/services"
, Singleton
Инстанс получаем через CanIService.getInstance()
Сервис позволяющий накапливать, сохранять и получать claim’ы с сервера.
Модель реализует интерфейс:
import {
IBaseModel
} from 'bi-internal/core';
type CanIModel = IBaseModel & Record<string, boolean>;
Используемый API:
POST /api/can/i
{
url: AppConfig.fixRequestUrl(`/api/can/i`),
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
data: claims, // массив строк claim типа ['L koob.cubes', 'C adm.topics']
}
Основные методы (у всех есть static
версии):
/**
* @method
* @param {string} claim - единичный запрос типа "R adm.datasets/ds_xx".
* @return {Promise<boolean\>} - возвращает з-ние клейма.
*/
public one(claim: string): Promise<boolean\> {
return this.ensure([claim])
.then(model => model[claim])
.catch(err => false);
}
/**
* @method
* @param {string[]} claims - массив запрос типа ["R adm.datasets/ds_x1", "R adm.datasets/ds_x1"...]
* @return {Promise<CanIModel>} - возвращает млодель типа {[claim:string]:boolean}.
*/
public ensure(claims: string[]): Promise<CanIModel> {
const queueds: string[] = claims.filter(claim => this._queued[claim]);
const pendings: string[] = claims.filter(claim => this._pending[claim]);
claims = claims.filter(claim => this._model[claim] === undefined && !this._pending[claim] && !this._queued[claim]);
if (!claims.length) { // все клеймы уже были у нас
if (queueds.length) { // но среди них есть стоящие в очереди
return this._queuedPromise;
} else if (pendings.length) { // есть те, которые сейчас загружаются
return this._pendingPromise;
} else { // все готовы
return Promise.resolve(this._model);
}
}
// Надо какие-то загрузить
claims.forEach(claim => this._queued[claim] = true); // ставим их в очередь
if (!this._queuedPromise) { // если еще не было очереди, то создаем ее
this._queuedPromise = new Promise<CanIModel>(resolve => { this._queueResolve = resolve; }); // и сохраняем функцию-резолвер
}
if (!this._currentRequest) { // Ничего не запущено
this._currentRequest = new Promise(resolve => resolve()).then(() => this._run()); // запускаем в следующем тике
}
return this._queuedPromise;
}
/**
* @method
* @param {string} claim - проверяет зн-ие в моделе (<CanIModel>) этот клейм типа "R adm.datasets/ds_x1"
* @return {boolean}
*/
public can = (claim: string): boolean => {
return this.getModel()[claim];
}
KoobDataService и работа с данными из кубов
Пакет "bi-internal/services"
, Factory
Инстанс получаем через (new KoobDataService(koob, [], measures, filters)).whenReady()
, специального метода под него нет.
Сервис позволяющий запрашивать данные из куба.
Модель реализует интерфейс IKoobDataModel
:
interface IKoobDimension {
id: string;
axisId?: string;
formula?: string;
source_ident?: string;
cube_name?: string;
name?: string;
title: string;
type: string | 'STRING';
values?: any[];
members?: any;
config?: any;
min?: number;
max?: number;
sql: string;
key?: string;
subtotal?: boolean; // table
upSubtotal?: boolean; // table delete
count?: number;
}
export interface IKoobMeasure {
id: string; // id для measure, например, x
formula: string; // формула, например, sum(x) или count(z):x
axisId?: string;
source_ident?: string;
cube_name?: string;
name?: string;
title: string;
format?: string;
members?: any;
type: string | 'SUM'; // SUM, COUNT, AVG, FN
min?: number;
max?: number;
sql: string;
key?: string;
config?: any;
unit?: any;
}
export interface IKoobDataModel {
loading?: boolean;
error?: string;
dimensions: IKoobDimension[];
measures: IKoobMeasure[];
values: any[];
sort?: string[];
subtotals?: string[];
}
Конструктор содержит следующие аргументы:
{
koobId: string, // полный идентификатор куба 'источник_данных.название_таблицы'
dimensions: IKoobDimension[],
measures: IKoobMeasure[],
filters: any,
loadBy?: number,
sort?: any,
subtotals?: string[] // подытоги по дименшнам
}
Может вам пригодиться, когда нужно получить все значения, объекты меж, дименшнов конкретного куба.
Чаще всего будут использоваться методы данного сервиса и описанные там вспомогательные функции для запросов за данными:
Методы класса:
public setSort(sort: string | string[] | null) {
/* установит сортировку по одному или нескольким полям ['+sex','-age'] - сортировка ASC по sex, DESC - по age */
}
public setFilter(filters: any) {
/* установит фильтры на дименшны куба */
}
Вспомогательные функции:
export interface CancelToken {
promise: Promise<Cancel>;
reason?: Cancel;
throwIfRequested(): void;
}
export interface IKoobDataRequest3 {
options?: string[];
offset?: number;
limit?: number;
sort?: string[];
subtotals?: string[];
cancelToken?: CancelToken;
schema_name?: string;
}
/* С помощью данного метода можно получить данные так же, как их обычно получают дешлеты (только фильтры вам придется прокидывать самим через KoobFiltersService) */
export async function koobDataRequest3(koobId: string,
dimensions: string[],
measures: string[],
allFilters: any,
request: IKoobDataRequest3 = {},
comment?: string) {
const schema_name = request.schema_name || 'koob'; // запрос за данными локальными куба или глобального соответственно
const url: string = AppConfig.fixRequestUrl(`/api/v3/${schema_name}/data` + (comment ? '?' + comment : ''));
const columns = dimensions.concat(measures);
let filters: any;
if (allFilters && typeof allFilters === 'object') {
filters = {};
Object.keys(allFilters).forEach(key => {
let value = allFilters[key];
if (value === '∀' || value === '*') { // фильтр подразумевающий 'ВСЕ'
return; // просто не выносим в фильтры
} else if (Array.isArray(value) && value[0] === 'IN') { // много где сконфигурировано ['IN', 'a', 'b']
value = ['='].concat(value.slice(1));
}
filters[key] = value;
});
}
const body: any = {
with: koobId,
columns,
filters,
};
if (request.offset) body.offset = request.offset;
if (request.limit) body.limit = request.limit;
if (request.sort) body.sort = request.sort;
if (request.options) body.options = request.options;
if (request.subtotals?.length) body.subtotals = request.subtotals;
if (!measures.length) { // если нет measures, то лучше применить distinct
body.distinct = [];
}
try {
const response = await axios({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/stream+json',
},
data: body,
cancelToken: request.cancelToken,
});
let data = response.data;
if (String(response.headers['content-type']).startsWith('application/stream+json')) {
if (typeof data === 'string') {
data = data.split('\n').filter((line: string) => !!line).map((line: string) => JSON.parse(line));
} else if (data && (typeof data === 'object') && !Array.isArray(data)) {
data = [data];
}
}
return data;
} catch (e) {
AlertsVC.getInstance().pushDangerAlert(extractErrorMessage(e));
return '';
}
}
/* С помощью данного метода можно получить данные так же, как их получает запрос /data, но у результирующей выборки берется COUNT и возвращается количество строк, например {count: 12} */
export async function koobCountRequest3(koobId: string,
dimensions: string[],
measures: string[],
allFilters: any,
request: IKoobDataRequest3 = {},
comment?: string) {
const schema_name = request.schema_name || 'koob';
const url: string = AppConfig.fixRequestUrl(`/api/v3/${schema_name}/count` + (comment ? '?' + comment : ''));
const columns = dimensions.concat(measures);
let filters: any;
if (allFilters && typeof allFilters === 'object') {
filters = {};
Object.keys(allFilters).forEach(key => {
let value = allFilters[key];
if (value === '∀' || value === '*') { // фильтр подразумевающий 'ВСЕ'
return; // просто не выносим в фильтры
} else if (Array.isArray(value) && value[0] === 'IN') { // много где сконфигурировано ['IN', 'a', 'b']
value = ['='].concat(value.slice(1));
}
filters[key] = value;
});
}
const body: any = {
with: koobId,
columns,
filters,
};
if (request.offset) body.offset = request.offset;
if (request.limit) body.limit = request.limit;
if (request.sort) body.sort = request.sort;
if (request.options) body.options = request.options;
if (request.subtotals?.length) body.subtotals = request.subtotals;
if (!measures.length) { // если нет measures, то лучше применить distinct
body.distinct = [];
}
try {
const response = await axios({
url,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/stream+json',
},
data: body,
cancelToken: request.cancelToken,
});
let data = response.data;
if (String(response.headers['content-type']).startsWith('application/stream+json')) {
if (typeof data === 'string') {
data = data.split('\n').filter((line: string) => !!line).map((line: string) => JSON.parse(line));
} else if (data && (typeof data === 'object') && !Array.isArray(data)) {
data = [data];
}
}
return data;
} catch (error) {
console.error(error);
AlertsVC.getInstance().pushDangerAlert(extractErrorMessage(error));
return '';
}
}