Приветствую всех читателей!
В мире информационных технологий наблюдается устойчивая тенденция перехода от традиционных десктопных приложений к веб-приложениям. Сегодня веб-приложения достигли значительной сложности и представляют собой интересную область разработки. Мне посчастливилось участвовать в создании одного из таких приложений, и я рад поделиться своим опытом и знаниями с вами.
О продукте
Хочу сказать несколько слов о продукте, в разработке которого я участвую. Это платформа для Интернета вещей, которая помогает компаниям разрабатывать и внедрять решения для подключения, мониторинга и управления различными устройствами. Она предоставляет широкий спектр функций, включая сбор данных, обработку событий, визуализацию информации и интеграцию с другими системами.
Одной из ключевых особенностей этой платформы является возможность создания интуитивно понятных дашбордов, которые позволяют пользователям визуализировать данные, получаемые с устройств, и контролировать их состояние в реальном времени. Дашборды могут быть настроены под конкретные нужды пользователя и отображать наиболее важную информацию в удобном формате.
Инструменты
На проекте используются React и MobX.
React предоставляет большую свободу в выборе инструментов и подходов к разработке. Свобода выбора является как его преимуществом, так и потенциальным источником сложностей. Мы должны тщательно изучать доступные варианты, оценивать их преимущества и недостатки, а также учитывать специфику проекта. Для работы с состоянием был выбран MobX.
MobX отлично подходит для создания интерактивных панелей управления и редакторов, где пользователи могут динамически изменять данные, и эти изменения должны мгновенно отражаться в интерфейсе. А так же он особенно хорошо сочетается с React, обеспечивая эффективное обновление компонентов при изменении состояния. Вместе они образуют мощный тандем для создания современных веб-приложений.
Про сторы в MobX
У нас сложный проект и сторов достаточно много. На случай, если есть много сторов, документация MobX рекомендует комбинировать эти сторы внутри корневого стора:
class RootStore {
constructor(someDep1, ..., someDepN) {
this.userStore = new UserStore(this, someDep1, ..., someDepN);
this.todoStore = new TodoStore(this);
...
this.someAnotherStore = new SomeAnotherStore(this);
}
}
Если стору нужны какие-то зависимости, то они задаются прямо в RootStore.
Поначалу мы приняли такой подход и следовали ему несколько лет.
Чтобы RootStore стал доступен в React-компонентах, мы передавали его через Provider, в который было обернуто все приложение.
SSR
Однажды, перед нами встала задача обеспечить быструю первую загрузку приложения. Этому способствует добавление поддержки серверного рендеринга. На тот момент приложение уже было огромное. Пришлось внести ряд изменений, чтобы получить больше контроля за жизненным циклом компонентов приложения.
То, что раньше могло быть синглтоном, в режиме SSR уже не должно им быть. Например, корневой стор вместе со всеми вложенными сторами должен создаваться заново на каждый запрос пользователя. Сторы у нас, чтобы получить данные, используют http-клиент. И раньше эту зависимость они получали неявно, через import :) А в рамках клиента сохраняются куки пользователя, значит клиент тоже должен создаваться отдельно для каждого запроса пользователя и передаваться в сторы.
Ищем X
В названии статьи предлагается решить уравнение :) Мы тоже встали перед этой задачей, потому что мы оказались в ситуации, что у нас на проекте нет как такового фреймворка, но приложение очень сложное и содержит очень много логики. Стало появляться больше сторов. Помимо сторов есть еще и другие компоненты приложения, и между ними есть те или иные зависимости.
Мы поняли, что пришло время управлять зависимостями централизованно.
Выбираем IOC-контейнер
Мы посмотрели существующие популярные библиотеки, которые предоставляют функциональность IOC-контейнера, InversifyJS одна из них. Но посчитали, что эти библиотеки избыточны для нас и достаточно много весят. В итоге родилась своя очень простая и легкая библиотека под названием vorarbeiter. Ее можно использовать как в TypeScript, так и в JavaScript проектах, в ней не используются декораторы, таким образом, при сборке проекта не генерируется дополнительный JavaScript, который утяжеляет проект. Так же ее можно использовать как в браузере, так и на сервере. А еще библиотека не тянет за собой какие-то дополнительные зависимости.
Основные концепции Vorarbeiter
-
Разрешение зависимостей:
Во время создания сервиса. Зависимости проставляются в Фабриках. Такой подход выбран, потому что он универсальный. Таким образом мы выполняем внедрение через конструктор - наиболее предпочтительный способ.
После создания сервиса. Можно указать Инжектор, который выполняется сразу после создания экземпляра сервиса. В рамках него можно внедрить зависимости через свойства или через сеттеры. Можно использовать, чтобы проставить опциональные зависимости либо чтобы обойти циклические зависимости.
-
Стратегия кэширования сервиса:
Shared - экземпляр сервиса создается один раз на время жизни всего приложения, это поведение по умолчанию.
Transient - экземпляр сервиса создается каждый раз заново в момент, когда мы запрашиваем сервис.
Scoped - экземпляр сервиса будет одним и тем же только в рамках определенного контекста, и мы можем объяснить сервису, как понимать, в каком контексте к нему обращаются.
В следующем примере показано, как с помощью фабрики передать зависимости, как создать описания сервисов и как затем ими воспользоваться:
import { createServiceSpecBuilder, ServiceFactory } from "vorarbeiter";
interface Car {
getDriverName(): string;
}
class CarImpl implements Car {
constructor(private readonly driver: Driver) {}
getDriverName() {
return this.driver.getName();
}
}
interface Driver {
getName(): string;
}
class DriverImpl implements Driver {
getName() {
return "Michael Schumacher";
}
}
class CarFactory implements ServiceFactory {
create(container: ServiceContainer): CarImpl {
const driver = container.get("driver");
return new CarImpl(driver);
}
}
const specBuilder = createServiceSpecBuilder();
specBuilder.set("car", new CarFactory());
specBuilder.set("driver", () => new DriverImpl());
const spec = specBuilder.getServiceSpec();
const serviceContainer = createServiceContainer(spec);
const car: Car = serviceContainer.get("car");
console.log(car.getDriverName()); // Michael Schumacher
А вот как применить внедрение зависимости через свойство и через сеттер с помощью Инжектора.
specBuilder.set("injectorService", () => {
return new class {
car!: Car;
driver!: Driver;
setDriver(driver: Driver) {
this.driver = driver;
}
};
}).withInjector((service, container) => {
service.car = container.get("car");
service.setDriver(container.get("driver"));
});
Вот так объявляется транзиентный сервис:
const specBuilder = createServiceSpecBuilder();
specBuilder.set("myService", () => ({ serviceName: "My service" })).transient();
const spec = specBuilder.getServiceSpec();
const serviceContainer = createServiceContainer(spec);
console.log(
serviceContainer.get("myService") === serviceContainer.get("myService")
); // false
Чтобы объявить scoped-сервис, нужно указать еще и то, как получать контекст, в котором запрашивается сервис, например:
const asyncLocalStorage = new AsyncLocalStorage<object>();
specBuilder
.set("myScopedService", () => ({ serviceName: "Awesome service" }))
.scoped(() => asyncLocalStorage.getStore());
const serviceContainer = createServiceContainer(specBuilder.getServiceSpec());
let scopedService1;
let scopedService2;
asyncLocalStorage.run({}, () => {
scopedService1 = serviceContainer.get("myScopedService");
scopedService2 = serviceContainer.get("myScopedService");
});
let scopedService3;
let scopedService4;
asyncLocalStorage.run({}, () => {
scopedService3 = serviceContainer.get("myScopedService");
scopedService4 = serviceContainer.get("myScopedService");
});
console.log(scopedService1 === scopedService2); // true
console.log(scopedService1 === scopedService3); // false
Тут мы используем AsyncLocalStorage из node.js. Он как раз подходит для того, чтобы запускать код в разных контекстах.
Интеграция с React
Чтобы легко интегрировать Vorarbeiter с React можно воспользоваться библиотекой vorarbeiter-react.
Внедрение Vorarbeiter в React происходит через Provider.
import React, { FC } from "react";
import {
createServiceContainer,
createServiceSpecBuilder,
ServiceContainer
} from "vorarbeiter";
import { ServiceContainerProvider } from "vorarbeiter-react";
import { ServiceImpl } from "./path/to/service/impl";
import { App } from "./path/to/app";
export const RootComponent: FC = () => {
const sb = createServiceSpecBuilder();
sb.set("someService", () => new ServiceImpl());
const serviceContainer = createServiceContainer(sb.getServiceSpec());
return (
<ServiceContainerProvider serviceContainer={serviceContainer}>
<App />
</ServiceContainerProvider>
);
};
Затем мы можем получить наш контейнер в функциональных компонентах с помощью хука useServiceContainer:
import React, { FC } from "react";
import { useServiceContainer } from "vorarbeiter-react";
import { Service } from "./path/to/service";
const MyComponent: FC = () => {
const serviceContainer = useServiceContainer();
const someService: Service = serviceContainer.get("someService");
return (
<div>{someService.someFieldValue}</div>
);
};
А в классовых компонентах можем использовать HOC withServiceContainer:
import React from "react";
import { withServiceContainer } from "vorarbeiter-react";
import { Service } from "./path/to/service";
const MyComponent = withServiceContainer(
class MyComponent extends React.Component {
render() {
const { serviceContainer } = this.props;
const someService: Service = serviceContainer.get("someService");
return (
<div>{someService.someFieldValue}</div>
);
}
}
);
Context API похож на ServiceLocator
IOC-контейнер, не только Vorarbeiter, управляет созданием сервисов и хранит их. Но он не может заниматься внедрением зависимостей в React-компоненты, потому что жизненным циклом этих компонентов занимается сам React. Зависимости в компонент передает родительский компонент через props, либо они берутся из контекста через Context API. Использование одного контекста на все приложение и возможность взять из него все, что угодно, напоминает подход ServiceLocator, у которого есть ряд минусов. Вообще сама идея использовать Context API не идеальна, но, я думаю, это просто меньшее из зол, потому что на другой чаше весов - передавать все через props и проблема props drilling.
IOC в React
Мы можем внедрить зависимость в React-компонент через hook или HOC. Но инверсия управления все равно произойдет в пользу родительского компонента, либо в пользу хука, но не в пользу IOC-контейнера. Так что нужно понимать, что в React применяется подход IOC, но IOC-контейнера нет. Внедрением зависимостей занимается программист сам, когда пишет компоненты. Даже если мы используем какую-то библиотеку с IOC-контейнером, все равно так или иначе мы сами будем брать из него нужные сервисы и внедрять в React-компоненты. IOC-контейнер нужен, чтобы упорядочить работу с компонентами системы за пределами React.
Что в итоге получилось
После внедрения Vorarbeiter к нам в проект мы смогли избавиться от RootStore, в рамках которого мы вручную разрешали зависимостей при создании сторов, а также который являлся контейнером для этих сторов. Теперь эти задачи выполняет IOC-контейнер Vorarbeiter. Также он управляет теперь вообще всеми зависимостями, не только сторами. Сторы у нас теперь как частный случай сервисов.
Теперь наше приложение при серверном рендеринге и при рендеринге в браузере просто по разному настраивает контейнер в самом начале, а затем все сервисы используются однообразно: например, в браузере сторы являются единственными экземплярами в рамках всего приложения, а при рендеринге на сервере сторы уникальны только в рамках каждого запроса пользователя, но при использовании по месту это знать уже не нужно.
Теперь если мы хотим добавить какой-то функционал, который по сути является каким-то сервисом, к примеру, Logger, мы знаем где его разместить и как зарегистрировать. А раньше с этим были проблемы, потому что нужно было сделать как-то, чтобы этот функционал стал доступен в компонентах React. В RootStore все не разместишь, да и руками зависимости настраивать каждый раз не удобно. Через import получать - не самая лучшая практика, так как зависимость получается неявная. Все передавать через пропсы - получаем props drilling. Оборачивать в кучу провайдеров - перебор. Значит надо один раз передать что-то такое, откуда можно получить то, что надо. Это теперь IOC-контейнер Vorarbeiter.
В итоге нам удалось решить уравнение, и ответ получился такой: x = Vorarbeiter.
Спасибо всем за интерес к данной теме! Буду рад, если наше решение тоже кому-то пригодится :)
Ссылки на библиотеки:
IOC-контейнер: vorarbeiter
Интеграция с React: vorarbeiter-react
serginho
Я для этих же целей использую tsyringe. Там еще есть возможность создавать дочерние контейнеры, и он может подниматься по дереву контейнеров для поиска нужного сервиса.
slavamuravey Автор
А у вас есть опыт использования tsyringe на сервере, чтобы экземпляры сервисов были уникальны только в рамках запроса? И чтобы без привязки к фреймворку, например, к express. Скорее всего, такое как-то можно, конечно, сделать. На мой взгляд, в моей библиотеке попроще API, и нет привязки к typescript, а в tsyringe, все же, рекомендуется typescript и декораторы, есть зависимость от reflect-metadata. Хотя, если постараться, можно и обойти. Спасибо за ваш опыт!
js2me
tsrynge нельзя подружить с accessor декораторами, отсюда и множество проблем, что с этой библиотекой разработчики будут вынуждены использовать legacy декораторы.