Observable сервисы
Это инстансы класса, являющегося наследником базового класса BaseService, который вы получаете из обвязки (из модуля “bi-internal/core” если точнее).
Он сам по себе реализует паттерн, при котором вы формируете наблюдаемую модель (некий объект сродни реактовскому state), изменения которого происходят публичными методами этого сервиса и который наследует механизм уведомления компонентов-подписчиков данного сервиса. Чаще всего обязательными ключами такой модели являются булев loading и строковый error (по ним вы можете отслеживать, готова ли модель для работы или еще в процессе загрузки, а также не содержит ли она ошибок). Чаще всего дефолтным значением для них является
loading: true,
error: ""
То есть мы изначально ожидаем, что ошибок нет, а модель грузится. Когда целевое действие в инициализации сделано (например вы загрузили с сервера важные структуры данных и как-то организовали их хранение), то вы выставляете
loading: false
и если имеет место - указываете текст ошибки. Работа с сервисом начнется именно с момента loading: false
Чтобы подписаться на изменения модели такого сервиса нужно заинициализировать его и указать колбек-функцию, которая вызывается на каждое событие изменения модели сервиса. По умолчанию такой колбек автоматически принимает на вход текущее значение объекта модели сервиса после каждого события изменения. Подписка по-разному выглядит для классовых и функциональных компонентов реакта, но об этом позднее.
Сами по себе сервисы могут реализовывать как Singleton подход (т.е. конструктор такого класса пустой), так и быть Factory и зависеть от неких входных параметров (например идентификатора куба, имени схемы атласа и чего угодно, что для вас имеет смысл)
Основная цель observable сервиса - предоставить вам механизмы взаимосвязи между как компонентами в разных контекстах, так и экономного запрашивания данных, которые “дорого” запрашивать часто, не кешируя.
Так же он позволит вам выступать в качестве портала, через который можно передавать данные, реализовывать собственную событийную систему. Или в качестве некой смеси Model-Controller из MVC архитектуры для фронтенда.
UrlState и работа с урлом приложения
Самым первым примером такого сервиса выступает UrlState из пакета “bi-internal/core”. Это Singleton, который следит за состояние 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({dboard: "3"})
метод принимает объект который частично или полностью содержит ключи, которые вы хотите переопределить в модели урла и как следствие совершить переход на другой раздел, дешборд, атлас или слайд. В данном примере совершится переход на дешборд с идентификатором 3.
UrlState.updateModel({_myData: {key1: 4546, data: [124, 5757, 575468]}})
И последующее получение
UrlState.getModel()._myData
Вы сохраните в модели данного сервиса данные, которые могут использовать ваши компоненты реакт по всему инстансу Luxms BI. Не злоупотребляйте! придерживайтесь принципов грамотного разбиения сервисов и компонентов по выполняемым ими функциям, а не использовать один как швейцарский нож.
Подобная возможность хранения в данной модели произвольных данных связана с функционалом презентаций, когда вы путешествуете по разделам BI и добавляете интересующую вас страницу (например ту, где вы выбрали какой-то фильтр или сделали действие ,которое достигается вашим кастомным сервисом ). Добавление страницы в конкретный шаблон презентации под капотом есть ни что иное как добавление в список ее полной модели UrlState.
Ибо на сервере при генерации презентаций работает headless chrome, который получает объект модели urlState, восстанавливает страницу, исходя из модели и результат рендерит и сохраняет картинку в pdf или pptx (и так для каждого слайда).
Это должно говорить вам о том, что если ваш кастомный сервис качественно влияет на внешний вид и функционал страницы - озаботтесь тем ,чтобы придумать и сохранить ключевую информацию в модели UrlState. Это означает, что вы не храните там без нужды результат тяжелого запроса или огромный массив, а храните только то, без чего например запрос за этим массивом невозможен. Тогда, при попадани такой модели в презентацию - сервер сможет восстановить именно ту ситуацию, какую вы ожидаете увидеть в презентации.
Рассмотрим примеры сервисов
Давайте рассмотрим пример observable сервиса, который вы можете использовать в своих компонентах. Предлагаю хранить в папке src/services и иметь в имени класса слово “Service”. Данный сервис есть ни что иное как локальная версия коробочного сервиса по управлению фильтрами KoobFiltersService (назовем файл KoobFiltersService.ts). И допустим мы хотим его как-то поменять и в дальнейшем в компонентах использовать эту версию сервиса, а не ту, что приходит нам из обвязки
import { BaseService, UrlState } from 'bi-internal/core';
import { throttle } from 'lodash';
const throttleTimeout = 0; // можно ставить достаточно большим
// повторные фильтры будут срабатывать в течение этого времени
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: {},
});
// Хотим, чтобы каждый раз ,когда в модели
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
}
то есть если я каким-то образом (через упр.деш или программно) выставлю фильтр на example - дешлет обновится и перезапросит данные.
Обратите внимания на метод getInstance - он проверяет наличие кешированной версии инстанса данного сервиса и если нет сохраняет его в переменной с нижним подчеркиванием. В данном случае нижнее подчеркивание ничего не значит, это просто наш подход к обозначению того, что эта переменная есть инстанс сервиса.
Чаще всего у это метода есть собрат createInstance, который используется в тех сервисах, которые зависят от контекста в виде идентификатора чего бы то ни было. Мой опыт показывает что самое частое, что используется в качестве такого идентификатора - схеманейм атласа или идентификатор куба. Это уместно, когда для каждого из кубов, которые вы встречаете на странице вы делаете ресурсоемкую задачу и не хотите дубляжа - сервис вам в помощь.
Рассмотрим сервис выше далее.
От предка сервис наследует два самых часто используемых метода:
subscribeAndNotify("строка с интересующими ключами из модели через пробел", "колбек, который надо вызвать")
subscribeUpdatesAndNotify("колбек, который надо вызвать")
Эти методы вы будете использовать только в классовых компонентах, ибо в функциональных методы работы с ней немного проще.
Первый метод будет вызвать колбек только тогда, когда в сервисе поменялись один или несколько ключей ,которые были указаны через пробел (в нашем примере это “_koobFilters” и “f”).
Колбек есть функция написанная в компоненте и которая совершает какие-то действия с его state (вызывает setState). При указании нее вы автоматически передаете this как ссылку на компонент сервису. Благодаря ей он и вызывает нужный колбек у нужного компонента, дирижируя состояниями компонентов на странице(-цах).
Второй метод (subscribeUpdatesAndNotify) - менее избирателен и будет вызывать колбек, если любое поле модели было изменено.
В затроттленном методе applyAllFilters мы видим как раз ту самую ситуацию, что мы хотим выставить важные для нас параметры в урл (явно и неявно)
UrlState.getInstance().updateModel({f: publicFilters, _koobFilters: privateFilters});
Это позволит подхватить выставленные фильтры в тех же презентациях или где бы то ни было.
Еще один метод внутри сервиса: выставление новых значений модели:
this._updateWithData({filters: {...url._koobFilters, ...url.f}});
Мы указали, что из всех ключей хотим поменять только filters и сохраняем в этот ключ новое значение. Этот метод приведет к тому, что по окончанию работы пнет всех подписчиков, вызывая их колбеки одновременно.
Метод выше поменяет только те ключи, что мы указали, но у метода есть собратья в виде
_updateWithLoading() // выставит error: null и loading: true
_updateWithError(error: string) // выставит loading: false, error: error
Просто для удобства.
и последнее
UrlState.unsubscribe(this._onUrlStateUpdated);
unsubscribe - метод, который очищает память и удаляет инстанс. На тот случай. если вы не хотите доверять тому, что есть в кеше и хотите инициировать его заново при определенных условиях. Чаще всего вызывается на unmount реакт-компонента, если это необходимо (в большинстве случаев нет)
Если вы хотите создавать инстансы для каждого из переданных идентификаторов, то вот пример метода createInstance
// Тут пример конструктора
export class DatePickerService extends BaseService<IDatePickerModel> {
private readonly id: string | number;
private constructor(koobId: string) {
super({
loading: false,
error: null,
data: [],
});
this.id = koobId;
}
// Тут какие-то методы, которые вам понадобятся
// ляляля
// Пример createInstance
public static createInstance (id: string | number) : DatePickerService {
if (!(window.__datePickerService)) {
window.__datePickerService = {};
}
if (!window.__datePickerService.hasOwnProperty(String(id))) {
window.__datePickerService[String(id)] = new DatePickerService(String(id));
}
return window.__datePickerService[String(id)];
};
Итого:
Вы можете использовать пример с сервисами выше как шаблон. К чему все сводится:
- Пишете метод, который создает инстанс и прихранивает его в переменной где-то в window
- В конструкторе прописываете логику инициализации (или вызываете функцию инита, которую в классе и пропишете)
- Создаете публичные методы, которые будете дергать из компонентов-подписчиков, и которые меняют модель сервиса одним из методов типа _updateWith
Подписка на сервисы
Мы уже видели в примеров функционального компонента MyComponent подписку на сервис KoobFiltersService
Она достигалась методами useService, useServiceItself.
Так вот, такая запись
const koobFiltersService = useServiceItself<KoobFiltersService>(KoobFiltersService);
или такая
const koobFiltersServiceModel = useService<KoobFiltersService>(KoobFiltersService);
В обоих случаях приведет к тому, что модель сервиса, хранящаяся в переменной koobFiltersService ( через .getModel()) или koobFiltersServiceModel будет всегда актуальной. и когда бы вы не обратились к этим переменной - они, при условии, что loading: false дадут вам свою актуальную модель
Достаточно в ключевых местах (но только ниже всех useEffect, иначе ошибка минифицированного реакта будет) вызвать блок
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, который тупо отображает данные, но почти ничего сам не считает и зависит только от настроек конфига дешлета.
Всю работу и бизнес-логику на себя возьмет сервис.