![](https://habrastorage.org/getpro/habr/upload_files/359/31a/520/35931a5206f6943d39e982375895864f.png)
Добрый день, дорогие читатели. В этой статье я постараюсь рассказать о принципе построения архитектуры для фронтенда, в частности для React, так как хорошая архитектура должна быть самостоятельным элементом системы.
В рамках статьи я постараюсь просто рассмотреть и дать ответы на следующие темы:
что такое архитектура и почему она должна быть чистая;
как написать архитектуру, которая основана на сервисах;
пример построения архитектуры для приложения заметок;
интеграция архитектуры с реактом.
Вступление
Главная цель архитектуры заключается в построении системы, которая должна манипулировать сущностями с использованием определенных политик и не зависеть от деталей. Она должна быть простая и писаться с использованием только языка программирования и его стандартной библиотеки. То есть она должна быть чистая и не иметь внешних зависимостей, таких как фреймворки, браузеры, и в некоторых случаях от библиотеки.
Политики - это истинная ценность системы, которая воплощает все бизнес-правила и процедуры.
Детали - это все остальное, что позволяет людям, другим системам и программистам взаимодействовать с политикой, никак не влияя на ее поведение. К ним можно отнести устройства ввода/вывода, базы данных, веб-системы, серверы, фреймворки, протоколы обмена данными и т.д.
Архитектура должна писаться так, чтобы она не устаревала по мере развития приложения в следствии развития используемых фреймворков, библиотек и браузеров. Это подход поможет использовать архитектуру с разными фреймворками, библиотеками и браузерами. А также даст возможность развивать и тестировать только ее.
Почему архитектуре нельзя зависеть от браузера
Браузер для кода - это как Операционная Система для программ, которые запускаются в этой ОС. Если архитектура начинает зависеть от браузера, то становится микропрограммой, и теперь полностью зависит от него, поэтому будет тяжело переноситься в другие окружения.
Для приложений придумали OSAL (Operating System Abstraction Layer) - это слой абстракции операционной системы, по аналогии с HAL (Hardware Abstraction Layer), предоставляет услугу доступа к операционной системе и не раскрывает программному обеспечению, как она работает. В общих случаях все системные вызовы скрыты за интерфейсами.
Нам же для браузера нужно создать слой BSAL (Browser System Abstraction Layer). Но это не значит что нужно все скрывать за интерфейсами, скрывать необходимо только те компоненты, которые требуются для архитектуры.
Благодаря избавлению зависимости от браузера, архитектура может легко переноситься на другие браузеры и окружения, к примеру на NodeJS.
Почему архитектуре нельзя зависеть от фреймворков
Фреймворки очень сильно проникают в код. В следствии этого архитектура начинает развиваться под его воздействием и дальнейшая замена фреймворка очень затруднительна. Неконтролируемое развитие фреймворка вынуждает изменять архитектуру каждый раз, как фреймворк меняется, к примеру некоторые методы устаревают или появляются другие.
Обычно фреймворки диктуют как следует создавать свои сущности, объекты и предлагают наследоваться от их сущностей, поэтому фреймворк должен быть скрыт за прокси-объектами для доступа к нему.
Библиотеки
Для того, чтобы библиотека проникла в архитектуру, она должна быть скрыта за определенным фасадом - общим интерфейсом для библиотеки и подключаться только в одном месте. И тогда весь код будет зависеть от фасада, а не от библиотеки и в случае ее замены на какую-то другую (по причинам производительности или устаревания), нужно будет отредактировать только один файл с фасадом.
К примеру:
import { createBrowserHistory } from 'history';
class HistoryAPI {
protected history = createBrowserHistory({});
push(pathname: string): void {
this.history.push(pathname);
}
}
export default HistoryAPI;
Мысль о том почему все так сложилось
Причина, почему на фронтенде сложилась такая плачевная ситуация с построением архитектуры, кроется в том, что изначально фронтенд был деталью для сервера - отображением его данных. И поэтому внутри него смешалась бизнес-логика и ее отображение. Но со временем фронт вырос в отдельную сущность и теперь его нужно писать так как и сервер, считая сервер деталью для архитектуры.
Базовые принципы
Основные правила построения архитектуры - это то что она должна быть простой, отражать в себе сам смысл системы, когда она простая - в ней легче разобраться и внести изменения. Архитектура не копируется, а создается под нужды текущего приложения.
Сложность - враг стабильности. Чем сложнее становится система, тем менее она стабильна. Чем менее она стабильна, тем она рискованнее и тем ниже оказывается уровень ее доступности.
Каждый компонент в архитектуре должен иметь свои зависимости, которые передаются ему в конструкторе. Это значит что компоненты не должны внутри создаваться с помощью new
. Они могут создаваться только если они чистые, то есть не имеют никаких побочных эффектов. Если это необходимо, то можно передавать в компонент, зависимость в виде фабрики по конструированию объектов.
К примеру:
const netAPI = new NetAPI();
const newNoteRepository = new NetNoteRepository(netAPI);
const noteService = new NoteService(newNoteRepository);
У нас в архитектуре будут определенные "уровни", с учетом того, что у нас все приложение связано с обработкой данных, которые могут зависеть только от слоев выше по уровню:
Структуры - это простые структуры данных состоящие из массивов, объектов или примитивных типов. Для простоты можно сказать, что они должны удовлетворять условию JSON, благодаря этому их свободно можно передавать между уровнями, передавать по сети или где-то хранить;
Обработчики структур - это объекты, которые должны служить тому, как правильно заполнять структуры и проверять их валидность, то есть иметь методы для всего этого;
Репозитории (Провайдеры данных) - это объекты, которые используются для получения данных из внешних источников, такие как сеть, БД, специальные сущности браузера и т.п. Они должны отвечать за конструирование запросов на получения и реализовывать простые методы для доступа к получению данных;
Сервисы - это объекты, в которых будет содержаться вся основная логика для работы архитектуры. Они должны быть не зависимые друг от друга и иметь зависимости только от того что передано в них в конструкторе. К примеру сервис отвечающий за модальные окна, сервис отвечающий за страницы, сервис отвечающий за заметки и т.п.;
Компонент приложения - это компонент, в котором происходит связывание всех сервисов, назначение для них обработчиков и другая логика характерная приложению;
Компонент входа - это самый "грязный" компонент, в котором происходит создание всех объектов и передача их как параметров в другие объекты.
Не стоит создавать божественные объекты. Объекты должны отвечать за свою зону ответственности и делать только то, что подразумевает их название.
Разделение логики отображения и бизнес логики
Благодаря тому, что наша архитектура будет изолирована от отображения и внешнего взаимодействия, то отображение может быть у нее любым. К примеру с использованием реакта, консольного вывода, нативных компонентов и других источников вывода.
Проектирование
Далее будет идти много картинок, на которых будет показано взаимодействие между уровнями и компонентами. Для примера я буду строить приложение для заметок.
Заметка о графиках
Для построения графиков, я буду использовать псевдоUML и раскрашивать компоненты с общим смыслом в определенный цвет.
Структуры
В основе всего лежат наши структуры данных, они должны представлять собой простые JSON. И должны с легкостью передаваться из компонента в компонент. В общем случае они представляют из себя интерфейс.
К примеру у нас есть:
interface Note {
id: string;
name: string;
description: string;
created: number;
tags: string[];
}
interface Filter {
including: string;
tags: string[];
}
interface User {
name: string;
role: 'user' | 'admin';
}
По хорошему - эти структуры должны создаваться только в рамках самой архитектуры, через определенные фабричные функции.
К примеру:
interface Factory {
makeNote(): Note;
makeFilter(): Filter;
}
Обработчики структур
Обработчики структур нужны для того, чтобы была возможность правильно управлять структурами, то есть: задавать значения полям, проверять валидность структуры и выполнять в зависимости от каких-то флагов определенные действия с ней.
К примеру:
interface NoteHandler {
note: Note;
setName(name: string): void;
setDescription(description: string): void;
addTag(tag: string): void;
removeTag(tag: string): void;
validate(): Errors<Note>;
}
Эти обработчики должны быть чистыми и тогда они могут использоваться по всему проекту.
![](https://habrastorage.org/getpro/habr/upload_files/3ec/cfa/a9b/3eccfaa9b9a0d10d805b92349e3a0829.png)
Получение данных
Прежде чем рассуждать об объектах которые предоставляют приложению данные, нам нужно определить для них определенные вспомогательные объекты для работы с внешним API.
Вспомогательное API
Под Вспомогательным API подразумеваются определенные объекты, которые будут делать общую логику, к примеру использовать сеть, БД, хранилище в браузере и т.п.
К примеру:
interface NetAPI {
get<T>(url: string): Promise<T>;
post<T, D>(url: string, data: D): Promise<T>;
}
Благодаря этому мы можем реализовать внутри использование любых библиотек или API браузера.
![](https://habrastorage.org/getpro/habr/upload_files/4c2/971/33e/4c297133e39bea2f08c65e8e71a76082.png)
В этом примере видно, что можно использовать либо библиотеку axios, либо браузерный fetch и наш код не заметит разницы, так как он будет зависеть только от интерфейса NetAPI
.
Репозитории
Репозитории - это провайдеры данных, которые позволяют нам получать данные из внешних источников. Именно они отвечают за то, как правильно конструировать запросы к внешнему источнику.
К примеру:
interface NoteRepository {
loadNotes(filter: Filter): Promise<Note[]>;
save(note: Note): Promise<boolean>;
}
![](https://habrastorage.org/getpro/habr/upload_files/abd/987/ecd/abd987ecd2f2992900bba7363aa1d55b.png)
Благодаря такому подходу, позволяется использовать данные из разных источников. К примеру, можно использовать MockedNoteRepository
для локальной разработки без участия сервера.
Лучше сразу создавать асинхронные методы для того, чтобы их можно было применить для асинхронного кода, без переписывания кода, который использует этот репозиторий.
Сервисы, сервисы и еще раз сервисы
И вот тут вступает в наше дело сервисы, из которых будет состоять вся наша архитектура. Сервисы являются конкретными компонентами системы, которые реализуют основную логику и предоставляют через свой компонент методы и свойства для того, чтобы можно было получать какие-либо данные для отображения и методы для изменения данных в сервисе.
Для того, чтобы сервисы могли общаться между собой и сообщать другим компонентам, они должны реализовывать интерфейс, который используется для генерации событий и иметь подписчиков на эти события.
К примеру:
interface Emmitable<E> {
on<K extends keyof E>(event: K, cb: (event: E[K]) => void): void;
off<K extends keyof E>(event: K, cb: (event: E[K]) => void): void;
emit<K extends keyof E>(event: K, data: E[K]): void;
}
interface NoteEvents {
change: undefined;
notesChange: Note[];
filterChange: Filter;
}
class NoteService implements Emmitable<NoteEvents> {
noteRepository: NoteRepository;
notes: Note[] = [];
filter: Filter = {
including: '',
tags: [],
}
private callbacks: {
[K in keyof NoteEvents]?: ((event: NoteEvents[K]) => void)[];
} = {};
on<K extends keyof NoteEvents>(event: K, cb: (event: NoteEvents[K]) => void): void {
if (!this.callbacks[event]) {
this.callbacks[event] = [];
}
const callbacks = this.callbacks[event];
if (!Array.isArray(callbacks)) {
return;
}
callbacks.push(cb);
}
off<K extends keyof NoteEvents>(event: K, cb: (event: NoteEvents[K]) => void): void {
if (!this.callbacks[event]) {
return;
}
const callbacks = this.callbacks[event];
if (!Array.isArray(callbacks)) {
return;
}
const index = callbacks.findIndex((aCallback) => aCallback === cb);
if (index !== -1) {
callbacks.splice(index, 1);
}
}
emit<K extends keyof NoteEvents>(event: K, data: NoteEvents[K]): void {
setTimeout(() => {
if (!this.callbacks[event]) {
return;
}
const callbacks = this.callbacks[event];
if (!Array.isArray(callbacks)) {
return;
}
callbacks.forEach((callback) => {
callback(data);
});
}, 0);
}
constructor(noteRepository: NoteRepository) {
this.noteRepository = noteRepository;
}
loadNotes(): Promise<boolean> {
return this.noteRepository
.loadNotes(this.filter)
.then((notes) => {
this.notes = notes;
this.emit('notesChange', this.notes);
this.emit('change', undefined);
return true;
});
}
saveNote(note: Note): Promise<boolean> {
return this.noteRepository
.save(note)
.then(() => this.loadNotes());
}
setFilter(filter: Filter): void {
this.filter = filter;
this.emit('filterChange', this.filter);
this.loadNotes();
}
}
Реализацию интерфейса Emmitable<E>
можно вынести в отдельный класс и от него наследовать сервис, но тут показана базовая концепция того, как сервис устроен.
![](https://habrastorage.org/getpro/habr/upload_files/309/bba/45c/309bba45c48413419cbde1fa55598fa4.png)
Теперь для того, чтобы понять, что у сервиса что-то меняется, нам необходимо будет на него подписаться. Базовое событие на все случаи изменения будет change
. На конкретные изменения можно заводить свои события, к примеру filterChange
будет происходить по изменению фильтра.
Маленькое замечание - сервис должен писаться, вне зависимости от фреймворка отображения - это касается модификации массивов и изменения свойств объектов.
В построении сервиса можно заметить, что он похож на подход MobX и можно его заменить им, но так не нужно делать, так как внешний код проникает внутрь самой архитектуры.
Внутри сервиса должны храниться и экспортироваться все необходимые для него типы и интерфейсы. Это не значит, что все должно содержаться в одном файле.
![](https://habrastorage.org/getpro/habr/upload_files/f00/1e9/a88/f001e9a8818950bc93337c7e1865dda7.png)
Компонент приложения
Компонент приложения в нашем случае будет состоять из двух частей:
Часть, которая объединяет все сервисы вместе - сервисы;
Часть - само приложение, которое принимает в качестве параметра сервисы и связывает их.
![](https://habrastorage.org/getpro/habr/upload_files/e20/c4a/8de/e20c4a8de0c9c26c414cc7fa43dabf87.png)
Полная архитектура:
![](https://habrastorage.org/getpro/habr/upload_files/e06/8e1/b13/e068e1b13ed6a2d8b4a0eb48cfdeedaa.png)
Слияние все в приложение - Компонент входа
Компонент входа - это самый грязный компонент, обычно будет входной файл в наше приложение - index.ts
. В нем будут подключаться все файлы и создаваться экземпляры репозиториев, внешних API, сервисов и самого приложения. Только в нем можно использовать new
. В этом файле можно переопределить все, что необходимо для разных ситуаций. Этот компонент как конструктор Lego, в котором из деталек собирается все приложение.
К примеру:
const netAPI = new NetAPI();
const tokenGetter = new TokenGetter(netAPI);
const authNetAPI = new AuthNetAPI(tokenGetter);
const historyAPI = new HistoryAPI();
const services: Services = {
note: new NoteService(new NoteRepository(authNetAPI)),
modal: new ModalService(),
page: new PageService(historyAPI),
auth: new AuthService(new AuthRepository(netAPI)),
user: new UserService(new UserRepository(authNetAPI)),
};
const application = new Application(services, tokenGetter, authNetRequest);
На основании этого можно написать функцию, которая будет создавать тестовое окружение для тестирования или специальное окружение для разработки.
Сценарии
Так как все сервисы независимые друг от друга, то все сценарии использования будут выглядеть как отдельные функции, которые принимают сервисы в качестве параметра и вызывают определенную последовательность действий сервисов.
К примеру:
function saveNote(services: Services, note: Note): void {
services.note.saveNote(note)
.then(() => {
services.modal.setModal({
type: 'success',
title: 'Заметка успешно сохранена',
description: '',
onClose: () => {
services.modal.setModal(undefined);
},
});
services.page.setPage({
type: 'notes',
});
})
.catch(() => {
services.modal.setModal({
type: 'error',
title: 'Не удалось сохранить заметку',
description: '',
onClose: () => {
services.modal.setModal(undefined);
},
});
})
}
В этом подходе есть минус в том, что в каждой функции будет огромная зависимость в виде всех сервисов. И тогда для каждого вызова нужно будет передавать их все. Для избавления от этого можно создать объект с сценариями:
class Scenarios {
private services: Services;
constructor(services: Services) {
this.services = services;
}
saveNote(note: Note): void {
// ...
}
}
И использовать методы этого объекта.
Интеграция с реактом
Для того, чтобы реакт знал о сервисах - они передаются ему в качестве параметра в главный компонент, в файле index.ts
:
const root = document.getElementById('root');
ReactDOM.render(<App services={services} />, root);
Чтобы каждый компонент мог воспользоваться сервисом, их нужно обернуть в контекст:
export default React.createContext<Services>({} as Services);
Тогда главный компонент будет выглядеть так:
interface AppProps {
services: Services;
}
const App: FC<AppProps> = ({ services }) => {
return (
<ServiceContext.Provider value={services}>
{<AppContainer />}
</ServiceContext.Provider>
);
};
Контекст затем можно использовать в виде хука:
export default function useService<K extends keyof Services>(service: K): Services[K] {
const services = useContext(ServiceContext);
return services[service];
}
И он в компоненте:
const NotesPage: FC = () => {
const noteService = useService('note');
const [notes, setNotes] = useState<Note[]>(noteService.notes);
useEffect(() => {
const onChange = () => {
setNotes(noteService.notes.concat());
};
noteService.on('change', onChange);
return () => {
noteService.off('change', onChange);
};
}, [noteService]);
// ...
}
В этой реализации, компоненты очень сильно связаны с сервисами и их можно разбить на:
сервисные компоненты - компоненты, которые связана с получением данных из сервиса, назначением обработчиков и вызовом методов;
простые компоненты - компоненты, которые не участвуют в связях с сервисах, а получают от них данные в виде параметров.
Такой подход позволит изолировано тестировать простые компоненты, без участия сервисов и контекста.
Если используется объект со сценариями, то его можно передать в реакт через контекст, по аналогии с сервисами.
Плюсы и минусы
Плюсы данного подхода заключаются в том, что отображение отделено от архитектуры и это позволяет им развиваться не зависимо друг от друга. И в случае чего, у нас есть возможность перейти на другую библиотеку отображения без вреда нашей архитектуре.
Минусы данного подхода в том, что скорость разработки на начальном этапе медленная, по сравнению с использованием фреймворков или включения логики в слой отображения. На дальнейшем этапе проседание в скорости будет из-за того что необходимо будет создавать интерфейсы и их поддерживать.
Заключение
В рамках статьи была разработана архитектура, которая независима, а все зависимости для нее скрыты за интерфейсами: браузер, фреймворки и библиотеки. Такая архитектура будет хорошо развиваться по мере роста приложения, так как она состоит из самостоятельных единиц, которые должны зависеть только от интерфейсов.
Рассмотренный пример построения приложения показывает лишь рекомендации и подход к построению своей архитектуры. Поэтому ваша архитектура должна быть именно ваша и зависеть от самого смысла приложения.