https://habr.com/ru/post/499078/- Разработка веб-сайтов
- JavaScript
- ООП
- ReactJS
Добрый день, уважаемые читатели. В этой статье мы поговорим об архитектуре программного обеспечения в веб-разработке. Довольно долгое время я и мои коллеги используем вариацию
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'ом там, где это не нужно. Помните, что основную силу архитектуры\тестирование приобретают в больших проектах с несколькими разработчиками, где нужно понимать и изменять чужой код.
UPD_0
UPD_0: несомненно существует много подходов и данная архитектура может быть улучшена и доработана. Суть этой статьи и основная причина, почему мы ее используем —
данная архитектура работает, вытягивая большие проекты, и она понятная другим разработчикам. Поэтому не стесняйтесь добавлять в нее новые правила и что-то изменять, но главное сохраняйте работоспособность и понятность. Не гонитесь за архитектурой ради архитектуры, ведь ваша задача только выстроить понятные правила, которые будет просто поддерживать в будущем.
При излишнем улучшении того, что работает, я вспоминаю следующую цитату:
Преждевременная оптимизация — корень всех (или большинства) проблем в программировании.
— Дональд Кнут, «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 implenetation
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 диаграмма