Относительно недавно мы начали строить качественно новую версию платформы "Юнидата", в которой изменилось очень многое, включая архитектуру, технологии, подход. Даже основная идея продукта приросла новыми деталями.

Нам кажется, что здорово делиться опытом подобных изменений, поэтому мы хотим сделать несколько статей о том, как устроена изнутри "Юнидата". В этой, первой, статье речь пойдет о UI. О том, как было раньше, что побудило нас кардинально пересмотреть стек и организацию работы с кодом, и что получилось в итоге.

Об авторе статьи. Меня зовут Илья, я занимаюсь разработкой новой версии. Мне не довелось работать с предыдущими версиями "Юнидата", и в проект я пришел на этапе прототипа. Я могу быть не до конца объективен на тему того, почему было выбрано то или иное решение, если это происходило еще до моего присоединения к продукту. В причинах перехода я написал свое видение, после общения с командой.

Итак, всем, кто любит истории переезда с ноткой технических особенностей, добро пожаловать под кат.

Краткий тех.обзор

Сейчас мы имеем модульный монолит и на бэке и на фронте. Они расположены в отельных репозиториях + есть еще один, который содержит настройки для запуска всего приложения в контейнерах.

Кроме того, продукт разделён на Community Edition (хранится в публичном гитлабе) и Enterprise Edition.

Фронтенд состоит из 20 модулей (число не конечное). Мы используем свежую версию typescript и почти свежую react (сейчас 16, но перевод на 17 - дело ближайшего времени). Применяем MVC подход в каждом модуле: реакт только view-слой, своя observable модель (обязательно про нее напишем отдельную статью), mobx сторы в качестве контроллеров.

Старые болячки

Изначально UI "Юнидатa" был написан на ExtJS. Это было одно большое приложение, которое поставлялось разным клиентам. В процессе внедрения под клиентов создавалась кастомизация:

  • либо через добавление флагов в общий конфиг и if-else в коде относительно этих параметров.

  • либо через механизм пользовательских расширений (далее UserExits или UE), который переехал в некотором виде и в новую версию, но только отчасти.

Ниже приведены самые важные, на мой взгляд, факторы, которые вынудили нас сменить стек:

1. Медленный рендер

Компоненты ExtJS имеют сложную верстку. Даже элементарные компоненты типа кнопки.

При наличии множества компонентов на странице, или при сложной иерархии компонентов layout занимал значительное время, так как построение некоторых компонентов в ExtJS итеративное. То есть для построения какой либо части компонента ExtJS ожидает завершения предыдущего построения. А наша специфика часто требовала создание динамического компонента с добавлением в иерархию.

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

2. Сложности с поддержкой версий заказчиков

Из-за обширных конфигов множилась сложность в самом коде (пресловутые if-else цепочки). Кроме того, большое количество UE, которые писал отдел внедрения, тоже создавали трудности: мы не могли переписать некоторые места с потерей обратной совместимости, без привлечения их к правкам в UE.

Поскольку проект был написан на javascript, то при изменениях на стыке платформенной части и UE приходилось проверять, что в UE передавались именно те параметры, которые ожидаются, и что возвращается то, что ожидает код приложения. И эти проверки должны проводиться для всех вариаций платформы, поставляемых клиентам, что накладно.

Возможно, были ещё какие-то моменты, но эти будем считать основополагающими, потому что текущее наше решение призвано закрывать именно эти задачи.

Для решения сложностей с поддержкой само собой был выбран typescript. Сейчас это уже де-факто стандарт для frontend разработки, но, когда принималось решение, он еще не был так популярен. Также следует упомянуть, что у нас уже была экспертиза работы с typescript, что также повлияло на выбор.

Как вы понимаете, для замены ExtJS у нас был выбор между angular, react и vue.

Еще были свежи воспоминания об angular js, второй уже успел смениться на четвертый, но некоторое недоверие к нему оставалось.

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

В нашей компании не было не было ни одного проекта в компании на Vue. Очень хотели его попробовать, но Vue не имел полноценной поддержки typescript, и брать совсем новую для себя технологию в продукт мы были не готовы (к слову, один из нас при проблемах, которые возникают из-за react'а, все еще предлагает переписать все на Vue). В итоге победил react. Сыграла роль и экспертиза в компании, и соотношение плюсов/минусов react'a.

Выбор на mobx пал в виду возможности организации знакомого подхода "умной модели", который предлагал ExtJs. Модели в ExtJs из коробки предоставляли механизм commit - фиксация состояния модели, revert - откат к последнему состоянию модели, при котором был сделан commit. Также есть Dirty - флаг, который говорит о том, что модель была изменена с последнего commit. На это поле, к примеру, удобно завязываться на UI, когда дизейблим кнопку save, если на экране не было изменений.

Все эти методы и флаги удобно держать в абстрактной сущности модели, т.к. они общие для всех моделей системы. Redux такой возможности предоставить не мог, т.к. он не приветствует объектно-ориентированный подход.

Так появилась основная связка приложения react + mobx на typescript.

Первые результаты

С новым стеком мы сделали первую итерацию и получили прототип, который включал в себя:

  • модель,

  • сервисы,

  • операции для взаимодействия с бэкендом,

  • сторы (контроллеры),

  • observer react-компоненты.

Все по классике.

По структуре всё выглядело примерно так:

— src
  |  components - общие компоненты UI
  |  model - модели приложения
    | -- core - абстрактная модель, от которой наследуют все модели. Содержит в себе логику определения dirty и deepDirty, работу с вложенными моделями и коллекциями.
    | UserModel.ts - доменные модели
    | SearchHitModel.ts
    | ...
  |  page - каждая страница - свой реакт-компонент. 
    |-- data_page
       |-— component - компоненты для конкретной страницы. Общие на приложение расположены в src/components
       | DataPage.tsx
    |-— search_page
       | SearchPage.tsx
    |-— user_page
       | UsetPage.tsx
  |  service - взаимодействие с бэкендом
    |-— operation - операции - это детальное описание запроса: метод, путь, payload, а также формирование моделей из json из ответа
      |-— data
      |-— search
      |-— user
      |-- ...
    |-- DataService.ts
    |-- SearchService.ts
    |-- UserService.ts
    |-- ...
  |  store - сторы. У каждой страницы свой основной стор. Стор это по сути список обработчиков для компонентов страницы, хранение полученных с бэка моделей
    |-- DataPageStore.ts
    |-- SearchPageStore.ts
    |-- ...

Большинство сторов наследуются от одного из базовых сторов: AbstractListStore или AbstractCrudStore.

AbstractListStore - это стор, который используется для получения списков, и выбора элемента из списка. В листинге добавлены подробные комментарии:

/**
* Abstract store, if you need list and some one selected item from it. (Many pages in admin or data management sections)
*
*/
import {AbstractModel, ReactiveProp} from '@unidata/core';
import {action, computed, observable} from 'mobx';
import {UdLogger} from '../index';
import {AbstractLoadingStore} from './AbstractLoadingStore';

export abstract class AbstractListStore<
   T extends AbstractModel, // Тип модели, которая будет элементом списка
   ItemKey = string // Тип ключа элемента 
> extends AbstractLoadingStore {
   protected abstract fetchList (): Promise<T[]>; // метод для определения сервиса для получения списка моделей

   protected abstract fetchItem (key: ItemKey): Promise<T>; // метод для определения сервиса для получения одной модели по ключу

   protected abstract findItem (key: ItemKey): (item: T, index: number) => boolean; // метод для нахождения модели по ключу в текщем списке

   @observable.ref
   protected selectedItem: T | undefined; // выбранный элемент из списка

   @observable.shallow
   protected list: T[] = []; // список элементов

   @computed
   public get getList (): T[] {return this.list;}

   @computed
   public get getSelectedItem (): T | undefined {
       return this.selectedItem;
   }

   @action
   public loadList (): Promise<void> {
       this.setLoading(true);
       
       return this.fetchList()
           .then((result) => {
               this.setList(result);
           })
           .finally(() => {
               this.setLoading(false);
           });
   }

   @action
   public loadItem (key: ItemKey): Promise<void> {
       this.setLoading(true);

       return this.fetchItem(key)
           .then((result) => {
               this.setSelectedItem(result);
           })
           .finally(() => {
               this.setLoading(false);
           });
   }

   public selectItem (name?: ItemKey): void {
       if (name === undefined) {
           this.setSelectedItem(undefined);
       } else {
           const selected = this.getList.find(this.findItem(name));

           if (selected === undefined) {
               UdLogger.warn(`You try to select item by unknown identifier: "${name}"`);
           }

           this.setSelectedItem(selected);
       }
   }

   @action
   protected setList (list: T[]) {
       this.list = list;
   }

   @action
   protected setSelectedItem (selected: T | undefined) {
       this.selectedItem = selected;

       if (this.selectedItem !== undefined) {
           // После выделения элемента надо сделать его реактивным (по-умолчанию в модели реактивно только значение value)
           this.selectedItem.setReactiveCascade([
               ReactiveProp.DIRTY,
               ReactiveProp.VALIDATION_RESULT,
               ReactiveProp.PHANTOM
           ]);
       }
   }
}

AbstractCrudStore - наследуется от AbstractListStore и добавляет методы для сохранения/удаления элементов. Полный код тут.

Вторые результаты =)

Путь оказался несколько длиннее. В целом, для MVP схема работала хорошо, только нужно было еще решить проблему поддержки различных вариантов приложения для заказчиков. И кроме того, выложить часть наработок в open source.

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

Кроме того, это будет дисциплинировать команду внедрения - им придется завести под каждый проект отдельный репозиторий и подключать в нем только те модули, которые нужны заказчику. Поскольку мы оставили поддержку UE, в этих же репозиториях они могли бы вести и их. А typesctipt обеспечит нам статическую проверку корректности типов при сборке.

Мы решили, что делать микрофронтенды небольшой командой, с учетом того, что у нас в основном коробочные решения - это неправильное расходование наших ресурсов.

А вот модульный монолит привносил для нас плюсы - мы могли собирать итоговое приложение только с нужными модулями, но при этом разрабатывать его практически как монолит, дополнительно ограничивая себя и уменьшая сильную связанность за счет настроек tsconfig и webpack.

Далее было время экспериментов со сборками. Самое важное, чего хотелось достичь - оставить всё в одном репозитории, но при этом сформировать модули максимально обособленными.

Начали с простого - определили список модулей, добавили папку module в src, добавили папку для каждого модуля. Получилась такая структура:

config - конфигурация приложения (используется npm модуль config)
 module - сами модули
    audit
    core
    core-app
    data
    dq-base
    draft
    icon
    job
    library
    meta
    override
    search
    security
    system-config
    types
    uikit
 react-resources - assets
 src - основное тело приложения (layout, код запуска приложения, загрузка локалей, UE и тд)

В каждом модуле сделали свой tsconfig.json, в котором отнаследовались от общего tsconfig, но добавили настройки path и references на модули, от которых зависит текущий модуль.

Пример конфига для модуля meta:

{
 "extends": "../../tsconfig.lib",
 "compilerOptions": {
     "baseUrl": ".",
     "paths": {
         "@unidata/uikit": ["../uikit/src"],
         "@unidata/core": ["../core/src"],
         "@unidata/core-app": ["../core-app/src"],
         "@unidata/draft": ["../draft/src"],
         "@unidata/types": ["../types/src"],
         "@unidata/icon": ["../icon/src"]
      },
      "rootDir": "src"
 },
 "references": [
     {"path": "../uikit"},
     {"path": "../core"},
     {"path": "../core-app"},
     {"path": "../draft"},
     {"path": "../types"},
     {"path": "../icon"}
 ]
}

В каждом модуле появился свой package.json. Туда прописывались зависимости, необходимые только для конкретного модуля. А общие библиотеки типа react, mobx, react-router и тд, прописывались в peedDependency.

Следующим шагом стал рефакторинг с переносом необходимых страниц/сторов/моделей в соответствующие модули. Абстрактная модель стала отдельным модулем core, практически каждый другой модуль зависит от него. Общие компоненты интерфейса отправились в модуль uikit - тоже есть практически во всех модулях как зависимость.

Для того, чтобы во всех модулях использовались одни и те же модули, мы использовали resolve.alias в настройке webpack.

{
    …
    resolve: {
        alias: {
            '@unidata/core': 'path/to/ud-core',
            '@unidata/meta': 'path/to/ud-meta',
            …
        }
    }
    …
}

Поскольку мы не можем просто собрать приложение со всеми модулями, что у нас есть (вспоминаем про то, что один и тот же бизнес-модуль может быть реализован по-разному для разных заказчиков), мы решили пойти по простому пути, и сделали в конфиге приложения три раздела для модулей:

— CORE_MODULES
— CE_MODULES
— EE_MODULES

Каждый из них содержит в себе список модулей в формате списка путей до модуля, где последняя директория является именем модуля и должна быть уникальной на все три списка. Таким образом, команда внедрения в своём репозитории может включить/отключить любой модуль, или заменить стандартный модуль самописным.

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

Вот методы, которые составляют список модулей для использования в служебных скриптах, и собственно сборка алиасов:

const path = require('path');
const config = require('config');

const getModulesMap = () => {
    const coreModules = config.has('CORE_MODULES') ? config.get('CORE_MODULES') : [];
    const ceModules = config.has('CE_MODULES') ? config.get('CE_MODULES') : [];
    const eeModules = config.has('EE_MODULES') ? config.get('EE_MODULES') : [];
    const modules = coreModules.concat(ceModules, eeModules);
 
    return modules.reduce((result, item) => {
        const modulePathItems = item.split('/');
        const moduleName = modulePathItems[modulePathItems.length - 1];

        if (result[moduleName]) {
            throw new Error(`Module with name ${moduleName} already exist`);
        }

        result[moduleName] = {
            pathToModule: modulePathItems
        };

        return result;
    }, {});
};

const resolveAliasApp = () => {
    const modules = getModulesMap();
    
    return Object.keys(modules).reduce((result, moduleName) => {
        const key = `@unidata/${moduleName}`;
        
        result[key] = path.resolve(process.cwd(), ...modules[moduleName].pathToModule, 'src');
        
        return result;
    }, {});
};

module.exports = {
    getModulesMap,
    resolveAliasApp
};

Далее мы стали думать, как нам соединить Community Edition (далее CE) и Enterprise Edition (далее EE), учитывая, что исходники CE у нас выложены в публичном gitlab, а EE - в закрытом. В итоге решили просто подключать в EE через git-submodule исходники из CE и править пути до модулей в конфиге.

Теперь стала ясна схема администрирования проектов внедрения. Под каждый проект будет создаваться репозиторий, содержащий специфичные модули или расширения. К этому репозиторию будет подключаться CE как git-submodule (ЕЕ тоже, при необходимости). В конфиге будут прописываться все модули и расширения, которые попадут в итоговую сборку.

Пример конфига для такого приложения:

# Core modules
CORE_MODULES:
 - 'unidata-platform-ui/module/override'
 - 'unidata-platform-ui/module/icon'
 - 'unidata-platform-ui/module/types'
 - 'unidata-platform-ui/module/core'
 - 'unidata-platform-ui/module/core-app'
 - 'unidata-platform-ui/module/uikit'

# CE
CE_MODULES:
 - 'unidata-platform-ui/module/search'
 - 'unidata-platform-ui/module/library'
 - 'unidata-platform-ui/module/meta'
 - 'unidata-platform-ui/module/draft'
 - 'unidata-platform-ui/module/data'
 - 'unidata-platform-ui/module/security'
 - 'unidata-platform-ui/module/audit'
 - 'unidata-platform-ui/module/system-config'
 - 'unidata-platform-ui/module/job'
 - 'unidata-platform-ui/module/dq-base'

# EE
EE_MODULES:
 - 'unidata-frontend-ee/module/dq'
 - 'unidata-frontend-ee/module/workflow'
 - 'unidata-frontend-ee/module/data-ee'
 - 'unidata-frontend-ee/module/meta-ee'
 # modules for demo
 - 'module/demo'

ENTRY_APP_DIR: 'unidata-platform-ui/src'

ENTRY_APP_DIR - параметр, который определяет точку входа в приложение для webpack. В теории, если будет нужно, внедрение сможет определить свой layout и использовать только необходимые модули.

И открылось нашему взору поле кодогенерации...

... в части исходного кода

Для того, чтобы не писать для каждого приложения свои конфиги webpack, мы решили сделать пакет unidata-ui-cli, который будем подключать как зависимость, и в наших проектах, и в проектах внедрения. В нем находится единая конфигурация webpack (для dev и prod режимов), команды для сборки, установка зависимостей, в том числе во всех модулях, а также некоторое количество утилит для кодогенерации и генерации общих типов.

Вначале для простоты добавления нового модуля, была добавлена команда для генерации нового модуля:

npm run ud-cli -- --type=create_module --moduleName=myNewModule —pathToModule=some/path/to/module —dependencies='["core-app", "uikit", "meta", "dq"]'

где:

  • pathToModule - необязательный параметр. По умолчанию новый модуль будет создан в директории module.

  • dependencies - необязательный параметр. По умолчанию все модули зависимы от core-app. Если параметр указан, то все перечисленные модули будут добавлены в tsconfig как зависимости, не придется это делать руками.

Во время активной разработки нового функционала самое популярное действие после согласований/обсуждений/прототипирования это добавление сущностей. Поэтому была добавлена команда, которая позволяет создать новую модель и сервисы с операциями для неё.

npm run ud-cli -- --type=create_model --moduleName=myNewModule —modelName=myNewModel

... в части типов

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

В обоих вариациях сборки за сам запрос отвечает модуль поиска. В нем есть соответствующий сервис и операция.

export class SearchOp extends AppModelListHttpOp {
    protected apiVersion = 'v1';

    protected config: IModelOpConfig = {
        url: '/search',
        method: 'post',
        model: SearchHit
    }; 

    constructor (payload: SearchPayload, mainResponseKey: SearchPayloadKeys) {
        super();

        this.config.data = {
            payload
        };

        this.config.rootProperty = ['payload', mainResponseKey];
    }
}

Нас интересует параметр конструктора payload и тип для него. Для первого случая, где у нас есть только модуль данных, SearchPayload будет выглядеть так:

export type SearchPayload = {
    'org.unidata.mdm.rest.v1.data’?: DataSearchQuery;
}

где DataSearchQuery - это описание поискового запроса.

export interface DataSearchQuery = {
    formFields?: IFormField[];
    formGroups?: IFormGroup[];
    countOnly: boolean;
    fetchAll: boolean;
    returnFields: string[];
    searchFields: string[];
    asOf?: string;
    searchDataType: DataType;
    facets?: FACETS[];
    text?: string;
    supplementaryRequests?: ISupplementaryRequest[];
    entity: string;
    count?: number;
    start?: number;
    page?: number;
    sortFields?: ISortField[];
}

А для второй вариации, где есть ещё и поиск по ошибкам в данных, так:

export type SearchPayload = {
    'org.unidata.mdm.rest.v1.data'?: DataSearchQuery;
    'org.unidata.mdm.rest.v1.dq.data'?: DqSearchQuery;
}

где DataSearchQuery совпадает с предыдущим интерфейсом, а DqSearchQuery описывается так:

export interface DQSearchQuery {
    setNames: string[];
    ruleNames: string[];
    functionNames: string[];
    message: string | null;
    severity: Severity | null;
    category: any;
    totalScore: 0;
    matchAll: boolean;
}

Таким образом, если у нас появляется дополнительный модуль, который может участвовать в поиске, нам необходимо добавлять новый ключ (это имя модуля) в SearchPayload со значением - интерфейсом этого запроса.

Поскольку мы заранее не знаем, какие модули будут использоваться в конкретной сборке, мы решили генерировать этот SearchPayload тип на основании поисковых интерфейсов в модулях. Мы приняли ограничение, что один модуль может выставлять только один поисковый интерфейс. Он лежит по определенному пути в модуле, а файл с ним называется как ключ этого интерфейса в SearchPayload. Сам тип экспортируется всегда как ISearchQuery для простоты обработки.

Таким образом, для модуля правил качества мы получаем файл module/dq/src/model/search_query/org.unidata.mdm.rest.v1.dq.data.ts со следующим содержимым:

import {Severity} from '../../type/Severity';

export interface ISearchQuery {
    setNames: string[];
    ruleNames: string[];
    functionNames: string[];
    message: string | null;
    severity: Severity | null;
    category: any;
    totalScore: 0;
    matchAll: boolean;
}

Для модуля Data - файл module/data/src/model/search_query/org.unidata.mdm.rest.v1.data.ts со следующим содержимым:

import {ISortField} from '@unidata/core-app';
import {IFormField, IFormGroup} from '@unidata/search';
import {FACETS} from './facet/AbstractFacet';
import {DataType, ISupplementaryRequest} from './supplementary_request/AbstractSupplementaryRequest';

export type ISearchQuery = {
    formFields?: IFormField[];
    formGroups?: IFormGroup[];
    countOnly: boolean;
    fetchAll: boolean;
    returnFields: string[];
    searchFields: string[];
    asOf?: string;
    searchDataType: DataType;
    facets?: FACETS[];
    text?: string;
    supplementaryRequests?: ISupplementaryRequest[];
    entity: string;
    count?: number;
    start?: number;
    page?: number;
    sortFields?: ISortField[];
}

В ud-cli добавили скрипт, который проходит по всем модулям, ищет в каждом модуле по пути src/model/search_query файл, в имени которого будет unidata.mdm.rest, и собирает эти интерфейсы в один, создает файл и помещает его в модуль core-app (так как это корневой модуль и от него могут зависеть все модули).

На выходе получается файл с таким содержанием:

import {ISearchQuery as DataSearchQuery} from '../../../../unidata-platform-ui/module/data/src/model/search_query/org.unidata.mdm.rest.v1.data';
import {ISearchQuery as DqSearchQuery} from '../../../../module/dq/src/model/search_query/org.unidata.mdm.rest.v1.dq.data';

export enum SearchPayloadKeys {
    DATA = 'org.unidata.mdm.rest.v1.data',
    DQ = 'org.unidata.mdm.rest.v1.dq.data'
}

export type SearchPayload = {
    [SearchPayloadKeys.DATA]?: DataSearchQuery;
    [SearchPayloadKeys.DQ]?: DqSearchQuery;
}

unidata-platform-ui встречается только в одном пути, потому что поиск по ошибкам доступен только в EE, а data- базовый модуль из CE, который подключен через git-submodule.

Этот скрипт запускается после каждого npm ci (прописан в postinstall) или его можно вызвать вручную - npm run ud-cli -- --type bind_search_payload

Таким же образом реализовали типизацию в UE.

Поскольку у каждого модуля могут быть свои точки расширения, а пользоваться ими можно во всех других модулях, мы собираем все типы UE в каждом модуле (они лежат по одному пути - module/ModuleName/src/type/ue_export/InterfaceName.d.ts. Файлов может быть много, имя каждого - это будущее имя интерфейса для UE).

После использования команды npm run bind_user_exits или после каждого npm ci мы получаем в core-app общий интерфейс для всех UE в сборке:

import {UEType as AppDataLoader} from '../../../../unidata-platform-ui/module/core-app/src/type/ue_export/appDataLoader';
import {UEType as DataProvider} from '../../../../unidata-platform-ui/module/core-app/src/type/ue_export/dataProvider';
import {UEType as GlobalHotkey} from '../../../../unidata-platform-ui/module/core-app/src/type/ue_export/globalHotkey';
// ...
import {UEType as TaskCardTab} from '../../../../module/workflow/src/type/ue_export/taskCardTab';

export enum UEList {
    AppDataLoader = 'AppDataLoader',
    DataProvider = 'DataProvider',
    GlobalHotkey = 'GlobalHotkey',
    // ...
    TaskCardTab = 'TaskCardTab'
}

export type UEModule = {
    [UEList.AppDataLoader]: AppDataLoader;
    [UEList.DataProvider]: DataProvider;
    [UEList.GlobalHotkey]: GlobalHotkey;
    // ...
    [UEList.TaskCardTab]: TaskCardTab;
}

export type UEModuleUnion = AppDataLoader |
    DataProvider |
    GlobalHotkey |
    // ...
    TaskCardTab;

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

Планы

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

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

 

p.s. Как вам иллюстрации? Мы немного поэкспериментировали в этот раз.

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


  1. vlanko
    01.09.2021 22:38
    +6

    Все хорошо, только почему в хабе Java?


    1. Unidata Автор
      02.09.2021 11:13

      Спасибо за бдительность, поправили.