Добрый день, уважаемые читатели. В этой статье мы поговорим об архитектуре программного обеспечения в веб-разработке. Довольно долгое время я и мои коллеги используем вариацию The Clean Architecture для построения архитектуры в своих проектах Frontend проектах. Изначально я взял ее на вооружение с переходом на TypeScript, так как не нашел других подходящих общепринятых архитектурных подходов в мире разработки на React (а пришел я из Android-разработки, где давным-давно, еще до Kotlin, наделала шумихи статья от Fernando Cejas, на которую я до сих пор иногда ссылаюсь).
В данной статье я хочу рассказать вам о нашем опыте применения The Clean Architecture в React-приложениях с использованием TypeScript. Зачем я это рассказываю? — Иногда мне приходится разъяснять и обосновывать ее использование разработчикам, которые еще не знакомы с таким подходом. Поэтому здесь я сделаю детальный разбор с наглядными пояснениями на которое я смогу ссылаться в будущем.
Содержание
- Введение
- Теоретическая часть
- Для чего вообще нужна архитектура?
- Оригинальное определение The Clean Architecture
- The Clean Architecture для Frontend
- Практическая часть
- Описание веб-приложения авторизации
- Структура исходного кода
- UML диаграмма проекта
- Разбор кода
- Заключение
- Ресурсы и источники
1. Введение
Архитектура — это, прежде всего, глобальная вещь. Ее понимание необходимо не в разрезе конкретного языка программирования. Вам необходимо понимание ключевых идей в целом, чтобы значит за счет чего достигаются преимущества от использования той или иной архитектуры. Принцип тот же, что и с паттернами проектирования или SOLID — они придуманы не для конкретного языка, а для целых методологий программирования (как, например, ООП).
Разобраться в архитектуре проще всего, когда видишь всю картину целиком. Поэтому в данной статье я расскажу не только о том, как должно быть “в теории” — а и приведу конкретный пример проекта. Сначала разберемся с теоретической частью применения The Clean Architecture во frontend’e, а потом рассмотрим веб-приложение с UML диаграммой и описанием каждого класса.
Важное уточнение: The Clean Architecture не устанавливает строгих правил организации приложений, она дает только рекомендации. У каждой платформы и языка будут свои нюансы. В данной статье преподносится подход, который я использовал со своими коллегами и использую сейчас — он не является панацеей.
Также хотелось бы отметить, что использование подобных архитектурных подходов может быть избыточно для маленьких проектов. Основная задача любой архитектуры — сделать код понятным, поддерживаемым и тестируемым. Но если ваше приложение быстрее написать на JS без архитектур, тестирования и прочего — это вполне нормально. Не занимайтесь overengineering'ом там, где это не нужно. Помните, что основную силу архитектуры\тестирование приобретают в больших проектах с несколькими разработчиками, где нужно понимать и изменять чужой код.
При излишнем улучшении того, что работает, я вспоминаю следующую цитату:
Преждевременная оптимизация — корень всех (или большинства) проблем в программировании.
— Дональд Кнут, «Computer Programming as an Art» (1974)
Как говорят, «лучшее — врал хорошего». Поэтому не пытайтесь создать что-то идеальное, когда Вам просто нужно решить проблему (в нашем случае, решить проблему сопровождения за счет архитектуры).
2. Теоретическая часть
2.1. Для чего вообще нужна архитектура?
Ответ: архитектура необходима для экономии времени в процессе разработки, поддержания тестируемости и расширяемости системы на протяжении долгого периода разработки.
Более детально о том, что бывает, если не закладывать архитектуру для больших приложений — Вы можете прочитать, например, в книге The Clean Architecture Боба Мартина. Для краткого объяснения, я приведу следующий график из этой книги:
На данном графике мы видим, что с каждой новой версией (допустим, они выпускаются с равными промежутками времени) в систему добавляется все меньшее количество строк, а также рост стоимости одной строки. Это происходит ввиду усложнения системы, а внесение изменений начинает требовать неоправданно большого количества усилий.
В книге The Clean Architecture этот график приводиться в качестве примера плохой архитектуры. Такой подход рано или поздно приведет к тому, что стоимость расширения системы будет стоить дороже, чем выгода от самой системы.
«На первые 90 процентов кода уходит 10 процентов времени, потраченного на разработку. На оставшиеся 10 процентов кода уходит оставшиеся 90 процентов»
— Том Каргилл, Bell Labs
Вывод: систему дороже изменять, поэтому нужно заранее думать о том, как вы будете изменять ее в будущем.
А теперь мой “идеальный” вариант, какой мы (разработчики, PM'ы, заказчики) хотели бы видеть в наших проектах:
На графике наглядно показано, что скорость роста количества строк не меняется в зависимости от версии. Стоимость строки кода (в зависимости от версии) увеличивается, но незначительно с учетом того, что речь идет о миллионах строк. К сожалению, такой вариант маловероятен, если мы говорим о большой Enterprise системе, так как продукт расширяется, сложность системы увеличивается, разработчики меняются, поэтому затраты на разработку неизбежно будут расти.
Однако я могу Вас и обрадовать — мы говорим о Frontend приложениях! Давайте смотреть правде в глаза — как правило, подобные приложения не вырастают до миллионов строк, иначе браузеры бы банально долго загружали такие приложения. В крайнем случае, они разбиваются на разные продукты, а основная логика лежит на backend стороне. Поэтому мы в какой-то мере можем стремиться к приведенной выше тенденции роста стоимости кода (с разной успешностью, в зависимости от размера приложения). Если наш проект даже на 50% дешевле сопровождается, чем мог бы без хорошей архитектуры — это уже экономия времени разработчиков и средств заказчика.
Изначально выстроив хорошую и понятную архитектуру, в результате получаем следующие преимущества:
- дешевле сопровождения кода (следовательно, меньше временных и финансовых затрат);
- упрощение тестируемости кода (следовательно, потребуется меньше тестировщиков и ниже потери из-за пропущенных “багов на проде”);
- ускорение внедрения новых разработчиков в проект.
Думаю, на вопрос “а зачем это нужно?!”, я ответил. Далее переходим к технической части вопроса.
2.2. Оригинальное определение
Я не буду углубляться в детальное описание The Clean Architecture, так как эта тема раскрыта во многих статья, а только коротко сформулирую суть вопроса.
В оригинальной статье Боба Мартина 2012-го года показана следующая диаграмма:
Ключевая идея данной диаграммы заключается в том, что приложение делиться на слои (слоев может быть любое количество). Внутренние слои не знают о внешних, зависимости обращены в центр. Чем дальше слой от центра, тем больше он знает о “небизесовых” деталях приложения (например, что за фреймворк используется и сколько кнопок на экране).
- Entities. В центре у нас находятся Entities (сущности). В них заключена бизнес-логика приложения и здесь нет зависимостей от платформы. Entities описывают только бизнес-логику приложения. Например, возьмем класс Cart (корзина) — мы можем добавить товар в корзину, удалить его и т.д. Ничего о таких вещах, как React, базы данных, кнопках — данный класс не знает.
Говоря о независимости от платформы имеется ввиду, что здесь не применяются специфические библиотеки как React\Angular\Express\Nest.js\DI и т.д. Если, например, возникнет необходимость, мы сможем взять цельную сущность из Web-приложения на React’e — и вставить в код для NodeJS без изменений. - Use cases. Во втором слое диаграммы расположены Use Cases (они же — сценарии использования, они же — Interactors). Сценарии использования описывают, как взаимодействовать с сущностями в контексте нашего приложение. Например, если сущность знает только о том, что в нее можно добавить заказ — сценарий использования знает, что из сущности можно взять этот заказ и отправить в репозиторий (см. далее).
- Gateways, Presenters, etc. В данном контексте (Gateways = Repositories, Presenters = View Models) — слои системы, которые отвечают за связь между бизнес-правилами приложения и платформенно зависимыми частями системы. Например, репозитории предоставляют интерфейсы, которые будут реализовывать классы для доступа к API или хранилищам, а View Model интерфейс будет служить для связи React-компонентов с вызовами бизнес-логики.
Уточнение: в нашем случае Use Cases и Repositories, как правило, будут находиться в ином порядке, так как большая часть работы frontend приложений заключается в получении и отправке данных через API. - External interfaces. Платформенно зависимый слой. Здесь находятся прямые обращения к API, компоненты React'а и т.д. Именно этот слой труднее всего поддается тестированию и абстрагированию (кнопочка в React’e — есть кнопочка React’e ).
2.3. Определение в контексте frontend’a
А теперь перейдем к нашей frontend области. В контексте Frontend’a, диаграмму выше можно представить вот так:
- Entities. Бизнес сущности такие же, как и в оригинальном варианте архитектуры. Обратите внимание, что сущности умеют хранить состояние и часто используются для этой цели. Например, сущность “корзина” может хранить в себе заказы текущей сессии, чтобы предоставлять методы работы с ними (получение общей цены, суммарного количества товаров и т.д.).
- Repository interfaces. Интерфейсы для доступа к API, БД, хранилищам и т. д. Может показаться странным, что интерфейсы для доступа к данным находятся “выше” сценариев использования. Однако, как показывает практика, сценарии использования знают о репозиториях и активно используют их. А вот репозитории ничего не знают о сценариях использования, но знают о сущностях. Это пример инверсии зависимостей из SOLID’a (возможность определения интерфейса во внутреннем слое, сделав реализацию во внешнем). Использование интерфейсов добавляет абстракцию (например, никто не знает, делает ли репозиторий запросы к API или берет данные из кеша).
- Use Cases. Аналогично оригинальной диаграмме. Объекты, которые реализуют бизнес-логику в контексте нашего приложения (т. е. понимают, что делать с сущностями — отправлять, загружать, фильтровать, объединять).
- View Models и View Interfaces.
ViewModel — это замена Presenters из оригинальной диаграммы. В своих проектах я применяю архитектуру MVVP вместо MVP\MVC\MV*. Если описывать кратко, разница с MVP лишь в одном: Presenter знает о View и вызывает ее методы, а ViewModel не знает о View, имея только один метод уведомления об изменениях. View просто “мониторит” состояние View Model. MVVP имеет однонаправленную зависимость (View > ViewModel), а MVP — двунаправленную (View ? Presenter). Меньше зависимостей — проще тестировать.
View Interfaces — в нашем случае, один базовый класс для всех View, через который View Model уведомляет конкретные реализации View об изменениях. Содержит метод по типу onViewModelChanged(): void. Еще один пример инверсии зависимостей. - 5. External interfaces. Аналогично оригинальной диаграмме, в этом слое находятся платформенно зависимые реализации. В случае приложения ниже — это компоненты React’a и реализация интерфейсов для доступа к API. Однако также здесь может быть любой другой фреймворк (AngularJS, React Native) и любое другое хранилище (IndexDB, local storage и т.д.). The Clean Architecutre позволяет изолировать применение конкретных фреймворков, библиотек и технологий, тем самым давая возможность в какой-то мере заменять их.
Если представить диаграмму выше в виде трехслойного приложения, она приобретает следующий вид:
Красные стрелки — поток течения данных (но не зависимостей, диаграмма зависимостей отображена на круговой диаграмме выше). Изображение в виде прямоугольной диаграммы позволяет лучше понять, как движется поток данных внутри приложения. Идею описания в виде такой диаграммы я увидел в ЭТОЙ статье.
Имейте ввиду, что в более сложных приложениях структура слоев может меняться. Например, распространенная практика, когда каждый из слоев выше, чем domain — может иметь свои мапперы для преобразования данных.
3. Пример приложения
3.1. Описание веб-приложения авторизации
Чтобы применение архитектуры было более наглядным и понятным, я создал веб-приложение, построенное ее основе. Исходный код приложения Вы можете посмотреть в репозитории GitHub. Приложение выглядит так:
Приложение представляет собой простое окно авторизации. Для усложнения самого приложения (чтобы архитектура была уместна), делаем следующие вводные:
- Поля не должны быть пустыми (валидация).
- Введенная почта должна иметь корректный формат (валидация).
- Данные доступа должны пройти валидацию на сервере (заглушка API) и получить ключ валидации.
- Для авторизации методу API нужно предоставить данные валидации и ключ валидации.
- После авторизации ключ доступа должен быть сохранен внутри приложения (слой сущностей).
- При выходе ключ авторизации должен стираться из памяти.
3.2. Структура исходного кода
В нашем примере структура папки src выглядит следующим образом:
- data — содержит классы для работы с данными. Эта директория является крайним кругом на круговой диаграмме, так как содержит классы для реализации интерфейсов репозиториев. Следовательно, эти классы знают об API и платформенно зависимых вещах (local storage, cookie и т.д.).
- domain — классы бизнес логики. Здесь находятся Entities, Use Cases и Repository Interfaces. В подкаталоге entities есть разделение на две директории: models и structures. Разница между этими директориями в том, что models — это сущности с логикой, а structures — простые структуры данных (по типу POJO в Java). Это разделение сделано для удобства, так как в models мы кладем классы, с которыми мы (разработчики) непосредственно и часто работаем, а в structures — объекты, которые возвращает сервер в виде JSON-объекта (json2ts, «привет») и мы их используем для передачи между слоями.
- presentation — содержит View Models, View Interfaces и View (фреймворковские компоненты), а также util — для различных валидаций, утилит и т.п.
Разумеется, структура может меняться от проекта к проекту. Например, в корне presentation в одном из моих проектов находятся классы для контроля состояния сайдбара и класс для навигации между страницами.
3.3. URM диаграмма проекта
Исходники для увеличения — GitHub.
Разделение классов по слоям наглядно показано прямоугольниками. Обратите внимание, что зависимости направлены в сторону слоя Domain (в соответствии с диаграммой).
3.4. Разбор кода
Entities layer
В данном разделе мы пройдемся по всем классам с описанием их логики работы. Начнем с самого дальнего круга — Entities, так как на его основе базируются остальные классы.
AuthListener.tsx
// Используем для обновления слушателей
// в классе AuthHolder
export default interface AuthListener {
onAuthChanged(): void;
}
AuthHolder.tsx
import AuthListener from './AuthListener';
// Данный класс хранит состояние авторизации (п. 3.1.5). Для того, чтобы
// обновлять presentation слой, мы используем паттерн Observer
// со слушателями AuthListener
export default class AuthHolder {
private authListeners: AuthListener[];
private isAuthorized: boolean;
private authToken: string;
public constructor() {
this.isAuthorized = false;
this.authListeners = [];
this.authToken = '';
}
public onSignedIn(authToken: string): void {
this.isAuthorized = true;
this.authToken = authToken;
this.notifyListeners();
}
public onSignedOut(): void {
this.isAuthorized = false;
this.authToken = '';
this.notifyListeners();
}
public isUserAuthorized(): boolean {
return this.isAuthorized;
}
/**
* @throws {Error} if user is not authorized
*/
public getAuthToken(): string {
if (!this.isAuthorized) {
throw new Error('User is not authorized');
}
return this.authToken;
}
public addAuthListener(authListener: AuthListener): void {
this.authListeners.push(authListener);
}
public removeAuthListener(authListener: AuthListener): void {
this.authListeners.splice(this.authListeners.indexOf(authListener), 1);
}
private notifyListeners(): void {
this.authListeners.forEach((listener) => listener.onAuthChanged());
}
}
AuthorizationResult.tsx
// Простая структура данных для передачи между слоями
export default interface AuthorizationResult {
authorizationToken: string;
}
ValidationResult.tsx
// Еще одна структура данных для передачи между слоями
export default interface ValidationResult {
validationKey: string;
}
На этом слой сущностей заканчивается. Обратите внимание, данный слой занимается исключительно бизнес логикой (хранение состояния) и используется для передачи данных во всем остальном приложении.
Часто состояние не нужно хранить в классах бизнес-логики. Для этой цели хорошо подходит связка репозитория со сценарием использования (для преобразования данных).
Repository interfaces
AuthRepository.tsx
import ValidationResult from '../../entity/auth/stuctures/ValidationResult';
import AuthorizationResult from '../../entity/auth/stuctures/AuthorizationResult';
// Здесь мы объявляем интерфейс, который потом реализует класс для доступа к API
export default interface AuthRepository {
/**
* @throws {Error} if validation has not passed
*/
validateCredentials(email: string, password: string): Promise<ValidationResult>;
/**
* @throws {Error} if credentials have not passed
*/
login(email: string, password: string, validationKey: string): Promise<AuthorizationResult>;
}
Use Cases
LoginUseCase.tsx
import AuthRepository from '../../repository/auth/AuthRepository';
import AuthHolder from '../../entity/auth/models/AuthHolder';
export default class LoginUseCase {
private authRepository: AuthRepository;
private authHolder: AuthHolder;
public constructor(authRepository: AuthRepository, authHolder: AuthHolder) {
this.authRepository = authRepository;
this.authHolder = authHolder;
}
/**
* @throws {Error} if credentials are not valid or have not passed
*/
public async loginUser(email: string, password: string): Promise<void> {
const validationResult = await this.authRepository.validateCredentials(email, password);
const authResult = await this.authRepository.login(
email,
password,
validationResult.validationKey,
);
this.authHolder.onSignedIn(authResult.authorizationToken);
}
}
В данном случае Use Case имеет только один метод. Обычно сценарии использования имеют только один публичный метод, в котором реализована сложная логика для одного действия. В данном случае – необходимо сначала провести валидацию, а потом отправить данные валидации в API метод авторизации.
Однако также часто используется подход, когда несколько сценариев объединяются в один, если имеют общую логику.
Внимательно следите, чтобы сценарии использования не содержали логику, которая должна находится в сущностях. Слишком большое количество методов или хранение состояния в Use Case часто служит индикатором того, что код должен находиться в другом слое.
Repository implemetation
AuthFakeApi.tsx
import AuthRepository from '../../domain/repository/auth/AuthRepository';
import ValidationResult from '../../domain/entity/auth/stuctures/ValidationResult';
import AuthorizationResult from '../../domain/entity/auth/stuctures/AuthorizationResult';
// Класс, имитирующий доступ к API
export default class AuthFakeApi implements AuthRepository {
/**
* @throws {Error} if validation has not passed
*/
validateCredentials(email: string, password: string): Promise<ValidationResult> {
return new Promise((resolve, reject) => {
// Создаем правило, которое должен был бы поддерживать сервер
if (password.length < 5) {
reject(new Error('Password length should be more than 5 characters'));
return;
}
resolve({
validationKey: 'A34dZ7',
});
});
}
/**
* @throws {Error} if credentials have not passed
*/
login(email: string, password: string, validationKey: string): Promise<AuthorizationResult> {
return new Promise((resolve, reject) => {
// Имитируем проверку ключа валидации
if (validationKey === 'A34dZ7') {
// Создаем пример подходящего аккаунта с логином user@email.com и паролем password
if (email === 'user@email.com' && password === 'password') {
resolve({
authorizationToken: 'Bearer ASKJdsfjdijosd93wiesf93isef',
});
}
} else {
reject(new Error('Validation key is not correct. Please try later'));
return;
}
reject(new Error('Email or password is not correct'));
});
}
}
В данном классе мы сделали имитацию доступа к API. Мы возвращаем Promise, который вернул бы настоящий fetch-запрос. Если мы захотим заменить реализацию на реальный API — просто изменим класс AuthFakeApi на AuthApi в файле App.tsx или инструменте внедрения зависимостей, если такой используется.
Обратите внимание, что мы аннотируем методы описанием ошибок, чтобы другие программисты понимали потребность обработки ошибок. К сожалению, TypeScript в данный момент не имеет инструкций по типу throws в Java, поэтому мы используем простую аннотацию.
util (presentation слой)
В данную директорию мы кладем классы, которые осуществляют логику “превентивной” валидации данных, а также другие классы для работы с UI слоем.
FormValidator.tsx
export default class FormValidator {
static isValidEmail(email: string): boolean {
const emailRegex = /^\S+@\S+\.\S+$/;
return emailRegex.test(email);
}
}
View interfaces
BaseView.tsx
Класс, которые позволяет View Model уведомлять View об изменениях. Реализуется всеми View компонентами.
export default interface BaseView {
onViewModelChanged(): void;
}
View Models
BaseViewModel.tsx
Класс, который предоставляет базовые методы для связи View Model и View. Реализуется всеми View Models.
import BaseView from '../view/BaseView';
export default interface BaseViewModel {
attachView(baseView: BaseView): void;
detachView(): void;
}
AuthViewModel.tsx
import BaseViewModel from '../BaseViewModel';
// Интерфейс ViewModel, который будет доступен View. Здесь
// объявлены все публичные поля, которые будет использовать View
export default interface AuthViewModel extends BaseViewModel {
emailQuery: string;
passwordQuery: string;
isSignInButtonVisible: boolean;
isSignOutButtonVisible: boolean;
isShowError: boolean;
errorMessage: string;
authStatus: string;
isAuthStatusPositive: boolean;
onEmailQueryChanged(loginQuery: string): void;
onPasswordQueryChanged(passwordQuery: string): void;
onClickSignIn(): void;
onClickSignOut(): void;
}
AuthViewModelImpl.tsx
import AuthViewModel from './AuthViewModel';
import BaseView from '../../view/BaseView';
import LoginUseCase from '../../../domain/interactors/auth/LoginUseCase';
import AuthHolder from '../../../domain/entity/auth/models/AuthHolder';
import AuthListener from '../../../domain/entity/auth/models/AuthListener';
import FormValidator from '../../util/FormValidator';
export default class AuthViewModelImpl implements AuthViewModel, AuthListener {
public emailQuery: string;
public passwordQuery: string;
public isSignInButtonVisible: boolean;
public isSignOutButtonVisible: boolean;
public isShowError: boolean;
public errorMessage: string;
public authStatus: string;
public isAuthStatusPositive: boolean;
private baseView?: BaseView;
private loginUseCase: LoginUseCase;
private authHolder: AuthHolder;
public constructor(loginUseCase: LoginUseCase, authHolder: AuthHolder) {
this.emailQuery = '';
this.passwordQuery = '';
this.isSignInButtonVisible = true;
this.isSignOutButtonVisible = false;
this.isShowError = false;
this.errorMessage = '';
this.authStatus = 'is not authorized';
this.isAuthStatusPositive = false;
this.loginUseCase = loginUseCase;
this.authHolder = authHolder;
// Делаем наш класс слушателем событий авторизации
this.authHolder.addAuthListener(this);
}
public attachView = (baseView: BaseView): void => {
this.baseView = baseView;
};
public detachView = (): void => {
this.baseView = undefined;
};
// Данный метод является методом интерфейса AuthListener
public onAuthChanged = (): void => {
// Изменяем данные модели, чтобы View
// отобразила изменения при входе и выходе
if (this.authHolder.isUserAuthorized()) {
this.isSignInButtonVisible = false;
this.isSignOutButtonVisible = true;
this.authStatus = 'authorized';
this.isAuthStatusPositive = true;
} else {
this.isSignInButtonVisible = true;
this.isSignOutButtonVisible = false;
this.authStatus = 'is not autorized';
this.isAuthStatusPositive = false;
}
this.notifyViewAboutChanges();
};
public onEmailQueryChanged = (loginQuery: string): void => {
this.emailQuery = loginQuery;
this.notifyViewAboutChanges();
};
public onPasswordQueryChanged = (passwordQuery: string): void => {
this.passwordQuery = passwordQuery;
this.notifyViewAboutChanges();
};
public onClickSignIn = async (): Promise<void> => {
if (!this.validateLoginForm()) {
this.notifyViewAboutChanges();
return;
}
try {
await this.loginUseCase.loginUser(this.emailQuery, this.passwordQuery);
this.isShowError = false;
this.errorMessage = '';
} catch (e) {
this.errorMessage = e.message;
this.isShowError = true;
}
this.notifyViewAboutChanges();
};
public onClickSignOut = (): void => {
// Удаляем данные авторизации без посредника в виде сценария использования
this.authHolder.onSignedOut();
};
private validateLoginForm = (): boolean => {
if (!this.emailQuery) {
this.isShowError = true;
this.errorMessage = 'Email cannot be empty';
return false;
}
// Убираем ошибку, если раньше ставили для этого условия
if (this.errorMessage === 'Email cannot be empty') {
this.isShowError = false;
this.errorMessage = '';
}
if (!FormValidator.isValidEmail(this.emailQuery)) {
this.isShowError = true;
this.errorMessage = 'Email format is not valid';
return false;
}
if (this.errorMessage === 'Email format is not valid') {
this.isShowError = false;
this.errorMessage = '';
}
if (!this.passwordQuery) {
this.isShowError = true;
this.errorMessage = 'Password cannot be empty';
return false;
}
if (this.errorMessage === 'Password cannot be empty') {
this.isShowError = false;
this.errorMessage = '';
}
return true;
}
private notifyViewAboutChanges = (): void => {
if (this.baseView) {
this.baseView.onViewModelChanged();
}
};
}
Обратите внимание на метод
onClickSignOut
— в нем мы напрямую обращаемся к классу AuthHolder. Это один из тех случаев, когда посредник в виде сценария использования был бы лишним, потому что логика метода довольно тривиальна. Аналогично можно обращаться напрямую к интерфейсу репозиториев. Однако при усложнении кода, для выполнения выхода — необходимо вынести его в отдельный сценарий использования.
UI (views)
AuthComponent.tsx
import React from 'react';
import './auth-component.css';
import BaseView from '../BaseView';
import AuthViewModel from '../../view-model/auth/AuthViewModel';
export interface AuthComponentProps {
authViewModel: AuthViewModel;
}
export interface AuthComponentState {
emailQuery: string;
passwordQuery: string;
isSignInButtonVisible: boolean;
isSignOutButtonVisible: boolean;
isShowError: boolean;
errorMessage: string;
authStatus: string;
isAuthStatusPositive: boolean;
}
export default class AuthComponent
extends React.Component<AuthComponentProps, AuthComponentState>
implements BaseView {
private authViewModel: AuthViewModel;
public constructor(props: AuthComponentProps) {
super(props);
const { authViewModel } = this.props;
this.authViewModel = authViewModel;
this.state = {
emailQuery: authViewModel.emailQuery,
passwordQuery: authViewModel.passwordQuery,
isSignInButtonVisible: authViewModel.isSignInButtonVisible,
isSignOutButtonVisible: authViewModel.isSignOutButtonVisible,
isShowError: authViewModel.isShowError,
errorMessage: authViewModel.errorMessage,
authStatus: authViewModel.authStatus,
isAuthStatusPositive: authViewModel.isAuthStatusPositive,
};
}
public componentDidMount(): void {
this.authViewModel.attachView(this);
}
public componentWillUnmount(): void {
this.authViewModel.detachView();
}
// При каждом обновлении ViewModel, мы обновляем
// state нашего компонента
public onViewModelChanged(): void {
this.setState({
emailQuery: this.authViewModel.emailQuery,
passwordQuery: this.authViewModel.passwordQuery,
isSignInButtonVisible: this.authViewModel.isSignInButtonVisible,
isSignOutButtonVisible: this.authViewModel.isSignOutButtonVisible,
isShowError: this.authViewModel.isShowError,
errorMessage: this.authViewModel.errorMessage,
authStatus: this.authViewModel.authStatus,
isAuthStatusPositive: this.authViewModel.isAuthStatusPositive,
});
}
public render(): JSX.Element {
const {
emailQuery,
passwordQuery,
isSignInButtonVisible,
isSignOutButtonVisible,
isShowError,
errorMessage,
authStatus,
isAuthStatusPositive,
} = this.state;
return (
<div className="row flex-grow-1 d-flex justify-content-center align-items-center">
<div className="auth-container col bg-white border rounded-lg py-4 px-5">
<div className="row mt-2 mb-4">
Status:
<span className={`${isAuthStatusPositive ? 'text-success' : 'text-danger'}`}>
{authStatus}
</span>
</div>
<div className="row mt-2">
<input
type="text"
placeholder="user@email.com"
onChange={(e: React.FormEvent<HTMLInputElement>): void => {
this.authViewModel.onEmailQueryChanged(e.currentTarget.value);
}}
value={emailQuery}
className="form-control"
/>
</div>
<div className="row mt-2">
<input
type="password"
placeholder="password"
onChange={(e: React.FormEvent<HTMLInputElement>): void => {
this.authViewModel.onPasswordQueryChanged(e.currentTarget.value);
}}
value={passwordQuery}
className="form-control"
/>
</div>
{isShowError && (
<div className="row my-3 text-danger justify-content-center">{errorMessage}</div>
)}
{isSignInButtonVisible && (
<div className="row mt-4">
<button
type="button"
className="col btn btn-primary"
onClick={(): void => this.authViewModel.onClickSignIn()}
>
Sign in
</button>
</div>
)}
{isSignOutButtonVisible && (
<div className="row mt-4">
<button
type="button"
className="col btn btn-primary"
onClick={(): void => this.authViewModel.onClickSignOut()}
>
Sign out
</button>
</div>
)}
</div>
</div>
);
}
}
Данный компонент является зависимым от фреймворка и, следовательно, находиться в самом крайнем слое диаграммы.
AuthComponent при монтировании (
componentDidMount
) прикрепляется к AuthViewModel и открепляется при исчезновении (componentWillUnmount
). При каждом изменении ViewModel, AuthComponent обновляет свое состояние для дальнейшего обновления разметки.Обратите внимание на условный рендеринг в зависимости от состояния:
{isSignOutButtonVisible && (
<div className="row mt-4">
<button
type="button"
className="col btn btn-primary"
onClick={(): void => this.authViewModel.onClickSignOut()}
>
Sign out
</button>
</div>
)}
А также на обращение к методам ViewModel для передачи значений:
onClick={(): void => this.authViewModel.onClickSignOut()}
Entry point
Для входа в приложение, мы используем файлы index.tsx и App.tsx.
index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import 'bootstrap/dist/css/bootstrap.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root'),
);
serviceWorker.unregister();
App.tsx
import React from 'react';
import './app.css';
import AuthComponent from './presentation/view/auth/AuthComponent';
import AuthViewModelImpl from './presentation/view-model/auth/AuthViewModelImpl';
import AuthFakeApi from './data/auth/AuthFakeApi';
import LoginUseCase from './domain/interactors/auth/LoginUseCase';
import AuthHolder from './domain/entity/auth/models/AuthHolder';
function App(): JSX.Element {
// data layer
const authRepository = new AuthFakeApi();
// domain layer
const authHolder = new AuthHolder();
const loginUseCase = new LoginUseCase(authRepository, authHolder);
// view layer
const authViewModel = new AuthViewModelImpl(loginUseCase, authHolder);
return (
<div className="app-container d-flex container-fluid">
<AuthComponent authViewModel={authViewModel} />
</div>
);
}
export default App;
Именно в файле App.tsx происходит инициализация всех зависимостей. В данном приложении мы не используем инструменты внедрения зависимостей, чтобы излишне не усложнять код.
Если нам потребуется изменить какую-то зависимость, мы будем заменять ее в этом файле. Например, вместо строки:
const authRepository = new AuthFakeApi();
Напишем:
const authRepository = new AuthApi();
Также обратите внимание, что мы используем только интерфейсы, а не конкретные реализации (все основывается на абстракции). При объявлении переменных, мы подразумеваем следующее:
const authRepository: AuthRepository = new AuthFakeApi();
Это позволяет скрывать детали реализации (чтобы потом заменять ее без изменения интерфейса).
4. Заключение
Надеюсь, в ходе чтения статьи у вас сложилось понимание, как можно применять The Clean Architecture в React (и не только проектах), и наш опыт поможет сделать ваши приложения более качественными.
В данной статье были описаны теоретические и практические основы использования The Clean Architecture в frontend проектах. Как говорилось ранее, The Clean Architecture дает только рекомендации о том, как строить Вашу архитектуру.
Выше был приведен пример простого приложения, которое использует данную архитектуру. Учтите, что по мере роста приложения, архитектура может меняться, поэтому приведенный выше код — не является панацеей (как говорилось вначале), в этой статье лишь передача части нашего опыта.
5. Ресурсы
Исходный код
UML диаграмма
LabEG
Классно! Я уже много лет пишу на чистой архитектуре на реакте. Всегда идеально отрабатывает какой бы специфичный или огромный проект ни был.
А вот со статьёй вы меня опередили, моя только наполовину написана, и она очень пересекается с вашей.
Интересно какой процент разработчиков используют чистую архитектуру в веб-приложениях. У меня есть проблема с подбором разработчиков, большая часть кроме редакса ничего не знают и знать не хотят. Как вы решаете эту проблему?
RostislavDugin Автор
В любом случае, интересно увидеть Вашу статью. Подчерпнуть что-то новое всегда можно (почему-то так выходит, что чистая архитектура у всех немного своя :)).
«У меня есть проблема с подбором разработчиков, большая часть кроме редакса ничего не знают и знать не хотят.» — честно говоря, я не сильный фанат Redux'a. Если приложение пишет больше, чем 1-2 человека — может начать путаница из-за сваливания состояния. Кто-то где-то что-то отправляет (не дай Бог из UI'я) — и искать это проблематично, все состояние находится в куче. К тому же, Redux особо-то и не отобразишь на UML диаграмме, например. Какой-нибудь Repository\Entity с шаблоном Observer лучше поддается понимаю и все состояние не сваливается в кучу. Это лично мое мнение, но я его избегаю. Аналогичный подход с Redux Saga — UseCase\Repository + View Model на async\await более красиво решают эту проблему и цепочку вызовов видно напрямую.
«Как вы решаете эту проблему?» — только учить. Специалисты стоят довольно дорого и проекты не всегда имеют для них бюджет. Поэтому объяснение + код ревью. Чистая архитектура не сложная вещь (вообще, сложные вещи в разработке с трудом приживаются и тяжело поддерживаются) — поэтому разработчики довольно быстро ее понимают и со временем начинают использовать без проблем.