Как стать автором
Обновить

The Clean Architecture на TypeScript и React. Часть 1: Основы

Время на прочтение 21 мин
Количество просмотров 42K


Добрый день, уважаемые читатели. В этой статье мы поговорим об архитектуре программного обеспечения в веб-разработке. Довольно долгое время я и мои коллеги используем вариацию The Clean Architecture для построения архитектуры в своих проектах Frontend проектах. Изначально я взял ее на вооружение с переходом на TypeScript, так как не нашел других подходящих общепринятых архитектурных подходов в мире разработки на React (а пришел я из Android-разработки, где давным-давно, еще до Kotlin, наделала шумихи статья от Fernando Cejas, на которую я до сих пор иногда ссылаюсь).

В данной статье я хочу рассказать вам о нашем опыте применения The Clean Architecture в React-приложениях с использованием TypeScript. Зачем я это рассказываю? — Иногда мне приходится разъяснять и обосновывать ее использование разработчикам, которые еще не знакомы с таким подходом. Поэтому здесь я сделаю детальный разбор с наглядными пояснениями на которое я смогу ссылаться в будущем.

Содержание

  1. Введение
  2. Теоретическая часть
    • Для чего вообще нужна архитектура?
    • Оригинальное определение The Clean Architecture
    • The Clean Architecture для Frontend
  3. Практическая часть
    • Описание веб-приложения авторизации
    • Структура исходного кода
    • UML диаграмма проекта
    • Разбор кода
  4. Заключение
  5. Ресурсы и источники

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. Приложение выглядит так:



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

  1. Поля не должны быть пустыми (валидация).
  2. Введенная почта должна иметь корректный формат (валидация).
  3. Данные доступа должны пройти валидацию на сервере (заглушка API) и получить ключ валидации.
  4. Для авторизации методу API нужно предоставить данные валидации и ключ валидации.
  5. После авторизации ключ доступа должен быть сохранен внутри приложения (слой сущностей).
  6. При выходе ключ авторизации должен стираться из памяти.

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 диаграмма
Теги:
Хабы:
+8
Комментарии 42
Комментарии Комментарии 42

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн