Перейти к основному содержимому

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 проводит аутентификацию на уровне фронтенда:

  1. Проверяем, нет ли ?loginme в урле.

  2. Если есть - не делаем check, принудительно показываем окно входа в систему, далее после ввода логин-пароля шлем запрос login (опционально запросы типа ‘2fa’), проверяем пользователя по таблице adm.users, получаем куку LuxmsBI-User-Session и мы в системе.
    Необходимо для тестировщиков

  3. Если нет - проверяем, нет ли ?no-sso в урле.

  4. Если есть - Делается запрос check-no-sso. идем в LDAP и проверяем пользователя в adm.users.

  5. Если нет - Делается запрос check, что запускает процесс аутентификации через SPNEGO (v9) или LDAP (default)

  6. Если в конфиге authentication.mode = null - идем в LDAP и проверяем пользователя в adm.users

  7. Если в конфиге authentication.mode = 'OIDC' (OpenID Connect), то

  • используя настройки в конфиге с префиксом authentication.OIDC, выполняем check
  • получаем в ответе статус 303 и redirect_uri, на который мы редиректимся на страницу провайдера OpenID Connect (Blitz, Keycloak)
  • Авторизуемся в системе провайдера
  • Возвращаемся обратно в BI.
  1. Выход из учетки осуществляется аналогично. В каждый запрос после такой авторизации мы подмешиваем заголовок

'Authorization': `Bearer ${token}`
  1. Если нам не нужно, чтобы нас редиректило со статусом 303 при включенном 'OIDC', то мы можем указать в opt/luxmsbi/web/settings/settings.js в блоке features опцию !OIDCRedirect, тогда, получив 303 мы принудительно остаемся на странице с окном авторизации и в кастомном компоненте DlgAuth.js реализовать логику обработки 303, храня redirect_uri до востребования. Таким образом возможно сделать страницу с выбором: авторизоваться через LDAP или через OpenID Client протокол, ибо урл с редиректом уже у нас, его можно просто навесить как обработчик клика по какой-либо кастомной кнопке.

  2. Допустим есть система, которая ходит в провайдер 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 '';
}

}