«Чистая архитектура» во внешнем домене

Архитектура внешний интерфейс JavaScript
«Чистая архитектура» во внешнем домене

Привет всем, яConardLi, Есть ли у фронтэнда архитектура? Это может вызывать сомнения у многих людей, потому что при реальном развитии бизнеса мы редко разрабатываем стандартную и стандартизированную структуру кода для внешнего интерфейса и можем больше сосредоточиться на разработке, иерархии каталогов и реализации бизнес-кода.

Сегодня мы рассмотрим образец архитектуры интерфейса, оригинальный автор назвал его «чистой архитектурой (Clean Architecture)", статья очень длинная и подробная, я долго ее читал, и после прочтения это было очень полезно. Я перевела ее для всех, и статья также вобрала в себя много моих собственных размышлений. Рекомендую всем прочесть.

Во-первых, мы кратко представим, что такое чистая архитектура (Clean architecture), такие как домен, вариант использования и концепции прикладного уровня. Затем следует вопрос о том, как применить чистую архитектуру к внешнему интерфейсу и стоит ли это того.

Далее мы разработаем приложение магазина, используя принципы чистой архитектуры, реализуем его с нуля и посмотрим, работает ли оно.

Это приложение будет использоватьReactВ качестве основы пользовательского интерфейса это просто для того, чтобы показать, что такой путь развития возможен иReactиспользуются вместе. Вы также можете выбрать любую другую библиотеку пользовательского интерфейса для ее реализации.

код будет использовать некоторыеTypeScript, это просто для того, чтобы показать, как использовать типы и интерфейсы для описания сущностей. На самом деле, весь код можно использовать безTypeScriptРеализовано, просто код будет выглядеть не так выразительно.

Архитектура и дизайн

Дизайн, по сути, состоит в том, чтобы разбирать вещи таким образом, чтобы их можно было снова собрать... Разбивать вещи на вещи, которые можно снова собрать, и есть дизайн. - Рич Хики, дизайн, рефакторинг и производительность

Системный дизайн на самом деле является разделением системы, и самое главное, что мы можем собрать их вместе, не тратя слишком много времени.

Я согласен с вышеизложенным, но я думаю, что еще одной важной целью системной архитектуры является масштабируемость системы. Потребности наших приложений постоянно меняются. Мы хотим, чтобы наши программы было очень легко обновлять и модифицировать в соответствии с новыми требованиями, которые постоянно меняются. Чистая архитектура может помочь нам в этом.

Что такое чистая архитектура?

Чистая архитектура — это домен приложения (domain) разделить обязанности и функции.

поле(domain) Программная модель из абстракции реального мира. Вы можете отразить отображение данных в реальном мире и процедурах. Например, если мы обновляем название продукта, заменяем старое имя новым именем — это преобразование домена.

Функции чистой архитектуры обычно делятся на три слоя, мы можем наблюдать следующую картину:

Слой домена

В центре находится доменный уровень, который описывает сущности и данные в предметной области приложения, а также код, преобразующий эти данные. Домены лежат в основе дифференциации программ.

Вы можете думать о царстве, когда мы начинаем сReactпереехал вAngularили часть, которая не меняется при изменении некоторых вариантов использования. В приложении магазина доменами являются продукты, заказы, пользователи, корзины и методы обновления этих данных.

Структуры данных и их преобразования изолированы от внешнего мира. Вызовы внешних событий инициируют переходы полей, но не определяют их поведение.

Например: функцию добавления товара в корзину не волнует, как товар был добавлен в корзину:

  • Пользователи сами добавляют, нажав кнопку "Купить"
  • Пользователь был автоматически добавлен с помощью купона.

В обоих случаях возвращается обновленный объект корзины.

прикладной уровень

Вокруг домена находится прикладной уровень, описывающий варианты использования.

Например, сценарий «добавить в корзину» является вариантом использования. Он описывает конкретное действие, которое должно быть выполнено при нажатии кнопки, и действует как своего рода «координатор»:

  • отправить запрос на сервер;
  • выполнить конвертацию домена;
  • Обновите пользовательский интерфейс данными из ответа.

Кроме того, на прикладном уровне есть порты, которые описывают, как прикладной уровень взаимодействует с внешним миром. Обычно порт представляет собой интерфейс (interface), поведенческий контракт.

Порт также можно считать «буфером» между реальным миром и приложениями. Входной порт расскажет нам, как принять внешний ввод, а тот же выходной порт объяснит, как подготовиться к внешним коммуникациям.

Слой адаптера

Самый внешний слой содержит адаптер внешней службы, мы используем адаптер для преобразования несовместимости внешней службыAPI.

Адаптеры могут уменьшить связь между нашим кодом и внешними сторонними сервисами.Адаптеры обычно делятся на:

  • Driven - отправляйте сообщения в наше приложение;
  • Пассивный - принимает сообщения, отправленные нашим приложением.

Обычный пользователь чаще всего взаимодействует с адаптером драйвера, например, адаптер драйвера используется для обработки событий кликов, отправляемых фреймворком пользовательского интерфейса. это с браузеромAPIВместе мы преобразуем события в сигналы, понятные нашему приложению.

Водители взаимодействуют с нашей инфраструктурой. На внешнем интерфейсе большая часть инфраструктуры представляет собой внутренний сервер, но иногда мы также можем напрямую взаимодействовать с некоторыми другими службами, такими как поисковые системы.

Обратите внимание, что чем дальше от центра, тем более «сервисно-ориентированная» функция кода, тем дальше от домена приложения, что очень важно позже, когда мы хотим решить, к какому уровню относится модуль.

правило зависимости

Трехуровневая архитектура имеет правило зависимости: только внешний уровень может зависеть от внутреннего уровня. это означает:

  • Поля должны быть независимыми
  • Прикладной уровень может зависеть от домена
  • Внешний слой может зависеть от чего угодно

Конечно, в некоторых особых случаях это правило может нарушаться, но лучше им не злоупотреблять. Например, в домене также могут использоваться какие-то сторонние библиотеки, хотя таких зависимостей быть не должно. Пример этого будет виден при просмотре кода ниже.

Код, который не контролирует направление зависимостей, может стать очень сложным и трудным для сопровождения. Например:

  • Круговая зависимость, модуль A зависит от B, B зависит от C, а C зависит от A.
  • Плохая тестируемость, даже тестирование небольшой части функциональности должно имитировать всю систему.
  • Связь слишком высока, поэтому взаимодействие между модулями будет хрупким.

Преимущества чистой архитектуры

независимое поле

Все основные функции приложения разделены и поддерживаются в одном месте — домене.

Функция в области автономна, что означает, что ее легче тестировать. Чем меньше зависимостей у модуля, тем меньше инфраструктуры требуется для тестирования.

Отдельные домены также легче проверить на соответствие ожиданиям бизнеса. Это помогает облегчить понимание новичкам. Кроме того, отдельные домены также упрощают устранение ошибок от требований до реализации кода.

Автономный вариант использования

Сценарии приложений и варианты использования описываются независимо. Он определяет, какие сторонние сервисы нам нужны. Мы делаем внешние сервисы более адаптируемыми к нашим потребностям, что дает нам больше возможностей для выбора подходящих сторонних сервисов. Например, сейчас, когда платежная система, которую мы вызываем, подорожала, мы можем быстро заменить ее.

Код варианта использования также плоский, легко тестируемый и легко расширяемый. Мы увидим это в последующих примерах.

Альтернативный сторонний сервис

Адаптеры упрощают замену внешних сторонних сервисов. Пока мы не меняем интерфейс, не имеет значения, какой сторонний сервис реализует интерфейс.

Таким образом, если кто-то еще изменит код, это не повлияет на нас напрямую. Адаптеры также уменьшают распространение ошибок времени выполнения приложений.

Стоимость внедрения чистой архитектуры

Архитектура – ​​это прежде всего инструмент. Как и любой другой инструмент, чистая архитектура помимо преимуществ сопряжена с дополнительными расходами.

нужно больше времени

Первый - это время. Для проектирования и реализации требуется больше времени, потому что он всегда проще, чтобы напрямую позвонить сторонним услугам, чем писать адаптеры.

Трудно с самого начала взаимодействия всех модулей и требуется очень четкое требование, мы всегда должны обращать внимание при проектировании на то, какие области могут измениться, чтобы считать более масштабируемым.

иногда кажутся излишними

В общем, чистая архитектура подходит не для всех сценариев и иногда может быть даже вредной. Если это небольшой проект, вы должны проектировать в соответствии с чистой архитектурой, что значительно повысит порог для начала работы.

Начать труднее

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

увеличение кода

Эта проблема уникальна для внешнего интерфейса, и чистая архитектура увеличит объем конечного упакованного продукта. Чем больше продукт, тем больше времени требуется браузеру для загрузки и интерпретации, поэтому объем кода должен хорошо контролироваться, а код должен быть надлежащим образом удален:

  • Опишите вариант использования проще;
  • Взаимодействуйте напрямую из адаптера и области, минуя прецедент;
  • Разделяйте код

Как уменьшить эти расходы

Вы можете сократить время реализации и размер кода, правильно срезав углы и пожертвовав архитектурной «чистотой». Если отказ от чего-то приносит большую выгоду, я без колебаний делаю это.

Таким образом, вместо того, чтобы следовать принципам проектирования чистой архитектуры во всех аспектах, придерживайтесь основных принципов.

абстрактное царство

Абстрагирование предметной области помогает нам понять общий дизайн и то, как они работают, а также облегчает другим разработчикам понимание программ, сущностей и взаимосвязей между ними.

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

соблюдать правила зависимости

Второе правило, которое не следует отбрасывать, — это правила зависимости или направление, в котором они зависят. Внешние сервисы должны подстраиваться под внутренние, а не наоборот.

Если вы пытаетесь вызвать внешний API напрямую, это проблема, и лучше написать адаптер до того, как возникнет проблема.

магазин дизайн приложения

Закончив с теорией, мы можем приступить к практике, давайте собственно спроектируем приложение для магазина.

Магазины продают различные виды файлов cookie, пользователь может выбрать свои собственные куки для покупки, а также платежные услуги через трехстороннюю оплату.

Пользователи могут видеть все файлы cookie на главной странице, но они могут купить их только после входа в систему. Нажмите кнопку входа, чтобы перейти на страницу входа.

После успешного входа в систему пользователь может добавить файлы cookie в корзину.

После добавления куки-файлов в корзину пользователь может произвести оплату. После оплаты корзина будет очищена и будет сформирован новый заказ.

Во-первых, давайте определим и разложим сущности, варианты использования и возможности.

Область дизайна

Наиболее важными аспектами программирования являются проекты предметной области, которые представляют собой преобразование сущностей в данные.

Области магазина могут включать в себя:

  • Типы данных для каждой сущности: пользователи, файлы cookie, корзины и заказы;
  • Если вы реализуете это с помощью ООП (объектно-ориентированного мышления), то также проектируйте фабрики и классы, которые генерируют сущности;
  • Функции преобразования данных.

Методы преобразования в царстве должны зависеть только от правил царства и больше ни от чего. Например, метод должен быть таким:

  • Как рассчитать общую стоимость
  • Методы определения вкусов пользователей
  • Как проверить, есть ли товар в корзине

Прикладной уровень дизайна

Прикладной уровень содержит варианты использования, использование содержит действующее лицо, действие и результат.

В приложении магазина мы можем выделить следующее:

  • Сценарий покупки продукта;
  • Оплатить, позвонить в стороннюю платежную систему;
  • Взаимодействие с товарами и заказами: обновления, запросы;
  • Доступ к разным страницам в зависимости от роли.

Обычно мы описываем варианты использования с точки зрения предметных областей, таких как «покупка», которая включает следующие шаги:

  • Запрашивайте товары из корзины и создавайте новые заказы;
  • Создать платежное поручение;
  • Уведомлять пользователя, когда платеж не проходит;
  • Оплата прошла успешно, корзина очищена, заказ отображается.

Метод варианта использования — это код, описывающий сценарий.

Кроме того, на прикладном уровне есть порты — интерфейсы, используемые для связи с внешним миром.

Проектирование слоя адаптера

На уровне адаптера мы объявляем адаптеры для внешних сервисов. Адаптеры могут быть совместимы с различными несовместимыми с нашей системой внешними сервисами.

Во внешнем интерфейсе адаптер обычно представляет собой инфраструктуру пользовательского интерфейса и модуль запроса API для внутреннего интерфейса. Например, в нашей программе магазина мы будем использовать:

  • Пользовательский интерфейс;
  • Модуль запроса API;
  • локально хранящиеся адаптеры;
  • API возвращается к адаптеру прикладного уровня.

Сравните архитектуры MVC

Иногда нам сложно судить, к какому слою относятся те или иные данные, вот небольшое сравнение с архитектурой MVC:

  • Модели обычно являются объектами предметной области.
  • Контроллер обычно связан с преобразованием или прикладным уровнем.
  • View — драйвер адаптера

Эти понятия, хотя и не тождественны в деталях, очень похожи.

Детали реализации — домен

Как только мы определили, какие объекты нам нужны, мы можем начать определять их поведение, и вот структура каталогов нашего проекта:

src/
|_domain/
  |_user.ts
  |_product.ts
  |_order.ts
  |_cart.ts
|_application/
  |_addToCart.ts
  |_authenticate.ts
  |_orderProducts.ts
  |_ports.ts
|_services/
  |_authAdapter.ts
  |_notificationAdapter.ts
  |_paymentAdapter.ts
  |_storageAdapter.ts
  |_api.ts
  |_store.tsx
|_lib/
|_ui/

поля определены вdomainкаталог, прикладной уровень определен вapplicationкаталог, адаптеры определены вserviceПод содержанием. Наконец, мы обсудим, существуют ли другие альтернативы структуре каталогов.

Создание объектов домена

У нас есть 4 объекта в домене:

  • товар
  • Пользователь
  • заказ
  • тележка

Наиболее важным из которых являетсяuser, в разговоре будем сохранять информацию о пользователе, поэтому оформляем тип пользователя в поле отдельно, тип пользователя включает в себя следующие данные:

// domain/user.ts

export type UserName = string;
export type User = {
  id: UniqueId;
  name: UserName;
  email: Email;
  preferences: Ingredient[];
  allergies: Ingredient[];
};

Пользователи могут помещать файлы cookie в корзину, и мы также добавляем типы в корзину и файлы cookie.

// domain/product.ts

export type ProductTitle = string;
export type Product = {
  id: UniqueId;
  title: ProductTitle;
  price: PriceCents;
  toppings: Ingredient[];
};


// domain/cart.ts

import { Product } from "./product";

export type Cart = {
  products: Product[];
};

После успешной оплаты будет создан новый заказ, и мы добавим еще один тип объекта заказа.

// domain/order.ts  — ConardLi

export type OrderStatus = "new" | "delivery" | "completed";

export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};

понимать отношения между сущностями

Преимущество разработки типов сущностей таким образом заключается в том, что мы можем проверить, соответствуют ли их графики отношений реальности:

Мы можем проверить следующие моменты:

  • является ли участник пользователем
  • Достаточно ли информации в заказе
  • нужно ли расширять некоторые объекты
  • Достаточно ли масштабируемости в будущем

Кроме того, на этом этапе типы могут помочь определить совместимость между сущностями и ошибки в вызовах маршрутов.

Если все так, как мы ожидали, мы можем приступить к проектированию смены домена.

Создать преобразование данных

С этими типами данных, которые мы только что разработали, может случиться что угодно. Мы можем добавлять товары в корзину, очищать корзину, обновлять товары и имена пользователей и т. д. Создадим соответствующие функции для этих преобразований данных:

Например, чтобы определить, нравится или не нравится пользователю вкус, мы можем создать две функции:

// domain/user.ts

export function hasAllergy(user: User, ingredient: Ingredient): boolean {
  return user.allergies.includes(ingredient);
}

export function hasPreference(user: User, ingredient: Ingredient): boolean {
  return user.preferences.includes(ingredient);
}

Добавьте товар в корзину и проверьте, есть ли товар в корзине:

// domain/cart.ts  — ConardLi

export function addProduct(cart: Cart, product: Product): Cart {
  return { ...cart, products: [...cart.products, product] };
}

export function contains(cart: Cart, product: Product): boolean {
  return cart.products.some(({ id }) => id === product.id);
}

Ниже приведен расчет общей стоимости (при необходимости мы также можем разработать дополнительные функции, такие как сцены со скидками, купоны и т. д.):

// domain/product.ts

export function totalPrice(products: Product[]): PriceCents {
  return products.reduce((total, { price }) => total + price, 0);
}

Создайте новый заказ и свяжите его с соответствующим пользователем и его корзиной.

// domain/order.ts

export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,
    created: new Date().toISOString(),
    status: "new",
    total: totalPrice(products),
  };
}

Детальный проект — общее ядро

Возможно, вы заметили некоторые типы, которые мы используем при описании типов доменов. НапримерEmail,UniqueIdилиDateTimeString. На самом деле это псевдонимы типов:

// shared-kernel.d.ts

type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;

я используюDateTimeStringзаменятьstringчтобы было понятнее, для чего используется строка. Чем более реалистичны эти типы, тем легче устранять неполадки.

Эти типы определены вshared-kernel.d.tsв файле. Общее ядро ​​относится к некоторому коду и данным, зависимости которых не увеличивают связь между модулями.

На практике общие ядра можно интерпретировать так: мы используемTypeScript, используя его стандартную библиотеку типов, но мы не рассматриваем их как зависимость. Это связано с тем, что модули, которые их используют, не влияют друг на друга и остаются несвязанными.

Не весь код можно считать общим ядром, главный принцип — такой код должен быть везде совместим с системой. Если часть программы используетTypeScriptнаписан на другом языке, общее ядро ​​может содержать только те части, которые работают на обоих языках.

В нашем случае все приложение написано сTypeScriptнаписан, поэтому псевдонимы для встроенных типов прекрасно подходят как часть общего ядра. Этот глобально доступный тип не увеличивает связь между модулями и может использоваться в любой части программы.

Детали реализации — прикладной уровень

Мы завершили проектирование предметной области, и ниже можно спроектировать прикладной уровень.

Этот слой будет содержать конкретный вариант использования, например, вариант использования — это полный процесс добавления товара в корзину и оплаты за него.

Сценарии использования включают взаимодействие между приложениями и внешними службами, а взаимодействия с внешними службами являются побочными эффектами. Все мы знаем, что легче вызывать или отлаживать методы без побочных эффектов, поэтому большинство функций предметной области реализуются как чистые функции.

Чтобы объединить чистые функции без побочных эффектов и взаимодействия с побочными эффектами, мы можем использовать прикладной уровень в качестве нечистого контекста с побочными эффектами.

Чистое преобразование данных с нечистым контекстом

Нечистый контекст и чистое преобразование данных с побочными эффектами — вот такая организация кода:

  • Сначала выполните побочный эффект, чтобы получить некоторые данные;
  • Затем выполните чистые функции над данными для обработки данных;
  • Наконец, выполняется побочный эффект, и результат сохраняется или передается.

Например, вариант использования «Добавить товар в корзину»:

  • Во-первых, получить статус корзины из базы данных;
  • Затем вызовите функцию обновления корзины покупок и передайте информацию о продукте, который нужно добавить;
  • Наконец, сохраните обновленную корзину в базе данных.

Процесс похож на «бутерброд»: побочные эффекты, чистые функции, побочные эффекты. Вся основная логическая обработка заключается в вызове чистых функций для преобразования данных, а вся внешняя связь изолирована в императивной оболочке.

Сценарии использования дизайна

Мы выбрали сценарий оформления заказа для дизайна варианта использования, который является более репрезентативным, поскольку он асинхронный и взаимодействует со многими сторонними службами.

Мы можем думать о том, что мы хотим выразить через весь вариант использования. У пользователя есть несколько файлов cookie в корзине, когда пользователь нажимает кнопку «Купить»:

  • Создать новый заказ;
  • Оплата в сторонней платежной системе;
  • Если платеж не прошел, уведомить пользователя;
  • Если оплата прошла успешно, сохраните заказ на сервере;
  • Сохраняйте данные о заказе в локальном хранилище и отображайте их на странице;

При проектировании функции мы передаем в качестве параметров и пользователя, и корзину, и позволяем методу завершить процесс.

type OrderProducts = (user: User, cart: Cart) => Promise<void>;

Конечно, в идеале вариант использования должен получать не два отдельных параметра, а инкапсулированный объект, что мы и сделаем в первую очередь для упрощения кода.

Написать интерфейс прикладного уровня

Давайте подробнее рассмотрим шаги варианта использования: само создание заказа является функцией предметной области, и для всех остальных операций мы должны вызывать внешние сервисы.

Мы должны помнить, что внешние методы всегда должны быть адаптированы к нашим потребностям. Поэтому на уровне приложения нам приходится не только описывать сам вариант использования, но и определять способ связи — порт для вызова внешних сервисов.

Подумайте об услугах, которые мы могли бы использовать:

  • сторонние платежные сервисы;
  • Услуги, которые уведомляют пользователям о событиях и ошибках;
  • Сохраняйте данные в локальном хранилище.

Обратите внимание, что речь идет об этих услугах.interface, а не их конкретные реализации. На данном этапе нам важно описать необходимое поведение, потому что это то поведение, на которое мы опираемся на прикладном уровне при описании сценария.

Как это реализовать, сейчас не в центре внимания, мы можем рассмотреть, какие внешние сервисы вызывать в конце, чтобы код мог максимально обеспечить низкую связанность.

Также обратите внимание, что мы разделили интерфейс по функциям. Все, что связано с платежами, находится в одном модуле, а все, что связано с хранением, — в другом. Это упрощает обеспечение того, чтобы функции из разных сторонних сервисов не смешивались.

интерфейс платежной системы

Приложение нашего магазина — это всего лишь небольшойDemo, так что система оплаты будет простой. у него будетtryPayметод, этот метод примет сумму к оплате и вернет логическое значение, указывающее результат платежа.

// application/ports.ts  — ConardLi

export interface PaymentService {
  tryPay(amount: PriceCents): Promise<boolean>;
}

Как правило, обработка платежей происходит на стороне сервера. Но мы просто демонстрируем это кратко, так что это обрабатывается непосредственно во внешнем интерфейсе. Позже мы также будем вызывать некоторые простые API вместо прямого общения с платежной системой.

Интерфейс службы уведомлений

Если что-то пойдет не так, мы должны уведомить пользователя.

Мы можем уведомлять пользователей различными способами. Такие вещи, как использование пользовательского интерфейса, отправка электронных писем или даже вибрация телефона пользователя.

В общем, службы уведомлений лучше абстрагироваться, чтобы нам пока не приходилось думать о реализации.

Отправить уведомление пользователю:

// application/ports.ts

export interface NotificationService {
  notify(message: string): void;
}

интерфейс локального хранилища

Мы сохраним новый заказ в локальном репозитории.

Этот магазин может быть любым: Redux, MobX, любым магазином. Репозиторий может быть разделен между различными объектами или данные для всего приложения могут храниться вместе. Однако сейчас это не имеет значения, так как это детали реализации.

Я привык создавать отдельный интерфейс хранилища для каждой сущности: один для пользовательских данных, один для корзин и один для заказов:

// application/ports.ts    — ConardLi

export interface OrdersStorageService {
  orders: Order[];
  updateOrders(orders: Order[]): void;
}

вариант использования

Давайте посмотрим на использование исключения с существующими методами предметной области и только что созданными интерфейсами. Скрипт будет содержать следующие шаги:

  • проверить данные;
  • Создать заказ;
  • оплатить заказ;
  • проблемы с уведомлением;
  • Сохраните результат.

Во-первых, мы объявляем модуль службы, которую хотим вызвать.TypeScriptЭто подскажет нам, что мы не дали реализацию интерфейса, так что оставьте его в покое.

// application/orderProducts.ts — ConardLi

const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};

Теперь мы можем использовать эти модули как настоящий сервис. Мы можем получить доступ к их полям и вызвать их методы. Это очень полезно при переводе вариантов использования в код.

Теперь мы создаемorderProductsспособ создания заказа:

// application/orderProducts.ts  — ConardLi
//...

async function orderProducts(user: User, cart: Cart) {
  const order = createOrder(user, cart);
}

Здесь мы рассматриваем интерфейс как контракт поведения. Другими словами, пример модуля на самом деле будет делать то, что мы ожидаем:

// application/orderProducts.ts  — ConardLi
//...

async function orderProducts(user: User, cart: Cart) {
  const order = createOrder(user, cart);

  // Try to pay for the order;
  // Notify the user if something is wrong:
  const paid = await payment.tryPay(order.total);
  if (!paid) return notifier.notify("Oops! 🤷");

  // Save the result and clear the cart:
  const { orders } = orderStorage;
  orderStorage.updateOrders([...orders, order]);
  cartStorage.emptyCart();
}

Обратите внимание, что варианты использования не вызывают напрямую сторонние службы. Он основан на поведении, описанном в интерфейсе, поэтому, пока интерфейс остается прежним, нам не нужно заботиться о том, какой модуль его реализует и как, такой модуль можно заменить.

Детали реализации — уровень адаптера

Мы «перевели» вариант использования наTypeScript, теперь давайте проверим, соответствует ли реальность нашим потребностям.

Обычно нет, поэтому нам нужно вызывать сторонние сервисы, инкапсулируя адаптер.

Добавьте пользовательский интерфейс и варианты использования

Во-первых, первый адаптер — это UI-фреймворк. Он соединяет API браузера с нашим приложением. В данном сценарии создания заказа это метод обработки кнопки «оформить заказ» и события клика, и здесь будет вызываться функция конкретного варианта использования.

// ui/components/Buy.tsx  — ConardLi

export function Buy() {
  // Get access to the use case in the component:
  const { orderProducts } = useOrderProducts();

  async function handleSubmit(e: React.FormEvent) {
    setLoading(true);
    e.preventDefault();

    // Call the use case function:
    await orderProducts(user!, cart);
    setLoading(false);
  }

  return (
    <section>
      <h2>Checkout</h2>
      <form onSubmit={handleSubmit}>{/* ... */}</form>
    </section>
  );
}

мы можем передатьHookДля инкапсуляции варианта использования рекомендуется инкапсулировать в него все сервисы, и, наконец, вернуть метод варианта использования:

// application/orderProducts.ts  — ConardLi

export function useOrderProducts() {
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();

  async function orderProducts(user: User, cookies: Cookie[]) {
    // …
  }

  return { orderProducts };
}

Мы используемhookкак инъекция зависимости. Сначала мы используемuseNotifier,usePayment,useOrdersStorageэти несколькоhookчтобы получить экземпляр службы, мы используем функциюuseOrderProductsСоздайте закрытие, чтобы они могли бытьorderProductsвызывается функция.

Следует также отметить, что функция варианта использования и другой код разделены, что более удобно для тестирования.

Реализация платежного сервиса

вариант использованияPaymentServiceинтерфейс, давайте сначала реализуем его.

Для платежных операций мы по-прежнему используем поддельный API. Также нам не обязательно писать весь сервис сейчас, мы можем реализовать его позже, самое главное сейчас реализовать заданное поведение:

// services/paymentAdapter.ts  — ConardLi

import { fakeApi } from "./api";
import { PaymentService } from "../application/ports";

export function usePayment(): PaymentService {
  return {
    tryPay(amount: PriceCents) {
      return fakeApi(true);
    },
  };
}

fakeApiЭта функция будет в450Тайм-аут, который срабатывает через миллисекунды, имитируя отложенный ответ от сервера, который возвращает параметры, которые мы передали.

// services/api.ts  — ConardLi

export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
  return new Promise((res) => setTimeout(() => res(response), 450));
}

Реализация службы уведомлений

мы просто используемalertЧтобы реализовать уведомление, поскольку код развязан, не проблема переписать этот сервис позже.

// services/notificationAdapter.ts  — ConardLi

import { NotificationService } from "../application/ports";

export function useNotifier(): NotificationService {
  return {
    notify: (message: string) => window.alert(message),
  };
}

Реализация местного хранения

мы проходимReact.Contextа такжеHooksреализовать локальное хранилище.

мы создаем новыйcontext, а затем передать егоprovider, а затем экспортировать, чтобы другие модули могли пройтиHooksиспользовать.

// store.tsx  — ConardLi

const StoreContext = React.createContext<any>({});
export const useStore = () => useContext(StoreContext);

export const Provider: React.FC = ({ children }) => {
  // ...Other entities...
  const [orders, setOrders] = useState([]);

  const value = {
    // ...
    orders,
    updateOrders: setOrders,
  };

  return (
    <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
  );
};

Мы можем реализовать по одному для каждой функциональной точкиHook. Таким образом, мы не ломаем интерфейс сервиса и хранилища, по крайней мере с точки зрения интерфейса они разделены.

// services/storageAdapter.ts

export function useOrdersStorage(): OrdersStorageService {
  return useStore();
}

Кроме того, такой подход также позволяет нам настраивать дополнительные оптимизации для каждого магазина: создание селекторов, кеширование и т.д.

Диаграмма потока данных проверки

Теперь давайте проверим, как пользователь взаимодействует с приложением.

Пользователь взаимодействует со слоем пользовательского интерфейса, но пользовательский интерфейс может получить доступ к интерфейсу службы только через порт. То есть мы можем заменить UI в любой момент.

Варианты использования обрабатываются на прикладном уровне, он может точно сказать нам, какие внешние службы требуются. Вся основная логика и данные инкапсулированы в искусство.

Все внешние сервисы скрыты в инфраструктуре и соответствуют нашим спецификациям. Если нам нужно изменить службу, которая отправляет сообщение, нам просто нужно изменить адаптер службы, которая отправляет сообщение.

Такая схема упрощает замену кода, упрощает его тестирование и делает его более масштабируемым для адаптации к изменяющимся потребностям.

что можно улучшить

Вышеизложенного достаточно, чтобы вы начали и получили представление о чистой архитектуре, но я хотел указать на некоторые моменты, которые я сделал выше, чтобы сделать пример проще.

Прочитав следующее, вы сможете понять, как выглядит чистая архитектура «без срезания углов».

Используйте объекты вместо чисел для представления цен

Вы могли заметить, что я использую число для описания цены, что не является хорошей практикой.

// shared-kernel.d.ts

type PriceCents = number;

Числа могут представлять только количество, а не валюту, а цены без валюты не имеют смысла. В идеале цена должна быть объектом с двумя полями: значение и валюта.

type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;

type Price = {
  value: AmountCents;
  currency: Currency;
};

Это решает проблему хранения денег и экономит много усилий при хранении и обращении с деньгами. Я не делал этого в примере, чтобы сделать его максимально простым. В реальной ситуации структурное определение цены будет ближе к приведенному выше.

Кроме того, стоит упомянуть, что единицей цены, такой как наименьшая единица доллара, являются центы. Отображение цен таким образом позволяет мне не беспокоиться о вычислениях с плавающей запятой.

Разделяйте код по функциям, а не по слоям

Код можно разбить на папки «по функциям», а не «по слоям», и часть функции является частью круговой диаграммы ниже.

Эта структура чище, потому что позволяет развертывать разные точки функций отдельно:

Будьте осторожны при использовании между компонентами

Если мы говорим о разделении системы на компоненты, мы должны думать об использовании кросс-компонентного кода. Давайте еще раз посмотрим на код для создания заказа:

import { Product, totalPrice } from "./product";

export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,
    created: new Date().toISOString(),
    status: "new",
    total: totalPrice(products),
  };
}

Эта функция используется из другогоProductмодуль импортированtotalPriceметод. В самом этом использовании нет ничего плохого, но если мы хотим рассмотреть вопрос о разделении кода на независимые функции, мы не можем получить прямой доступ к коду других модулей.

Используйте ts-brand , а не псевдонимы типов

При написании общего ядра я использовал псевдонимы типов. Их легко реализовать, но недостатком является то, чтоTypeScriptОтсутствует механизм контроля и обеспечения их соблюдения.

Это тоже не проблема: вы используетеstringтип для заменыDateTimeStringЭто не имеет значения, код все равно будет успешно скомпилирован. Однако это делает код хрупким и менее читабельным, потому что тогда можно использовать произвольные строки и возрастает вероятность ошибок.

Есть способ сделатьTypeScriptпонять, что нам нужен конкретный тип —ts-brand(https://github.com/kourge/ts-brand). Он точно отслеживает, как используются типы, но немного усложняет код.

Помните о возможных зависимостях в сфере

Следующий вопрос заключается в том, что мыcreateOrderДата создается в поле функции:

import { Product, totalPrice } from "./product";

export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,

    // Вот эта строка:
    created: new Date().toISOString(),

    status: "new",
    total: totalPrice(products),
  };
}

new Date().toISOString()Такую функцию можно вызывать много раз, мы можем обернуть ее вhleperв:

// lib/datetime.ts  — ConardLi

export function currentDatetime(): DateTimeString {
  return new Date().toISOString();
}

Затем вызовите его в области:

// domain/order.ts

import { currentDatetime } from "../lib/datetime";
import { Product, totalPrice } from "./product";

export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,
    created: currentDatetime(),
    status: "new",
    total: totalPrice(products),
  };
}

Но принцип домена не зависит от чего-либо еще, такcreateOrderВ функцию лучше всего передавать все данные извне, а дату можно использовать как последний параметр:

// domain/order.ts  — ConardLi

export function createOrder(
  user: User,
  cart: Cart,
  created: DateTimeString
): Order {
  return {
    user: user.id,
    products,
    created,
    status: "new",
    total: totalPrice(products),
  };
}

Таким образом, мы не нарушаем правила зависимости, даже дата создания должна зависеть от сторонней библиотеки:

function someUserCase() {
  // Use the `dateTimeSource` adapter,
  // to get the current date in the desired format:
  const createdOn = dateTimeSource.currentDatetime();

  // Pass already created date to the domain function:
  createOrder(user, cart, createdOn);
}

Это разделяет домены, а также упрощает тестирование.

В предыдущем примере я этого не делаю по двум причинам: это отвлекает нас от фокуса, а если он использует только возможности самого языка, то думаю полагаясь на свои собственныеHelperНет проблем. ТакойHelperМожно даже считать общими ядрами, так как они только уменьшают дублирование кода.

Обратите внимание на отношения между корзиной и заказом

В этом небольшом примереOrderбудет содержатьCart, потому что корзина представляет толькоProductСписок:

export type Cart = {
  products: Product[];
};

export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};

Если в корзине есть другие атрибуты, не связанные с заказом, могут возникнуть проблемы, поэтому используйтеProductListбыло бы разумнее:

type ProductList = Product[];

type Cart = {
  products: ProductList;
};

type Order = {
  user: UniqueId;
  products: ProductList;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};

Упростите тестирование вариантов использования

Сценарии использования также могут многое обсудить. Например,orderProductsфункции трудно быть независимыми отReactПриходите проверить, это не хорошо. В идеале тестирование не должно стоить слишком дорого.

Основная причина проблемы, которую мы используемHooksдля реализации варианта использования:

// application/orderProducts.ts  — ConardLi

export function useOrderProducts() {
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();
  const cartStorage = useCartStorage();

  async function orderProducts(user: User, cart: Cart) {
    const order = createOrder(user, cart);

    const paid = await payment.tryPay(order.total);
    if (!paid) return notifier.notify("Oops! 🤷");

    const { orders } = orderStorage;
    orderStorage.updateOrders([...orders, order]);
    cartStorage.emptyCart();
  }

  return { orderProducts };
}

При реализации спецификации методы вариантов использования могут быть помещены вHooksСнаружи параметр использования входящей службы или путем внедрения с использованием примера зависит:

type Dependencies = {
  notifier?: NotificationService;
  payment?: PaymentService;
  orderStorage?: OrderStorageService;
};

async function orderProducts(
  user: User,
  cart: Cart,
  dependencies: Dependencies = defaultDependencies
) {
  const { notifier, payment, orderStorage } = dependencies;

  // ...
}

потомHooksКод можно использовать как адаптер, только вариант использования останется на уровне приложения.orderProdeuctsМетоды легко тестируются.

function useOrderProducts() {
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();

  return (user: User, cart: Cart) =>
    orderProducts(user, cart, {
      notifier,
      payment,
      orderStorage,
    });
}

Настройка автоматического внедрения зависимостей

На уровне приложения мы вручную внедряем зависимости в сервисы:

export function useOrderProducts() {
  // Here we use hooks to get the instances of each service,
  // which will be used inside the orderProducts use case:
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();
  const cartStorage = useCartStorage();

  async function orderProducts(user: User, cart: Cart) {
    // ...Inside the use case we use those services.
  }

  return { orderProducts };
}

Конечно, есть лучший способ: внедрение зависимостей может выполняться автоматически. Мы уже реализовали простейший вариант внедрения через последний параметр, и можем дополнительно настроить автоматическое внедрение зависимостей.

В этом конкретном приложении я не думаю, что установка внедрения зависимостей имеет большой смысл. Это отвлекает нас и усложняет код. в использованииReactа такжеhooks, мы можем использовать их как «контейнеры», возвращающие реализации указанного интерфейса. Да, он по-прежнему реализуется вручную, но не увеличивает порог входа и быстрее читается начинающими разработчиками.

Ситуации в реальных проектах могут быть сложнее

Примеры в статье сжаты, а требования относительно просты. Очевидно, что наша реальная разработка намного сложнее, чем этот пример. Поэтому я также хочу поговорить об общих проблемах, которые могут возникнуть при использовании чистой архитектуры в реальной разработке.

Бизнес-логика филиала

Самая главная проблема заключается в том, что мы недостаточно глубоко изучаем реальные сценарии требований. Представьте себе магазин с товаром, товаром со скидкой и товаром, который списали. Как точно описать эти сущности?

Разве не должно быть расширяемой «базовой» сущности? Как именно эта сущность должна масштабироваться? Должны ли быть дополнительные поля? Должны ли эти сущности быть взаимоисключающими?

Там может быть слишком много вопросов и слишком много ответов, если только гипотеза, мы не можем учитывать все обстоятельства.

Конкретное решение зависит от конкретной ситуации, могу порекомендовать лишь некоторые из своего опыта.

Наследование не рекомендуется, даже если оно кажется «расширяемым».

Копипастированный код не обязательно плох, а иногда даже полезнее. Создайте два почти идентичных объекта и наблюдайте, как они ведут себя в реальности. В какой-то момент их поведение может сильно отличаться, а в другое время это может быть только поле или два разных. Объединить две очень похожие сущности намного проще, чем выписывать множество чеков.

Если вам нужно что-то расширить. .

Помните о ковариантности, контравариантности и инвариантности, чтобы у вас не было дополнительной работы, которой вы не ожидаете.

Для выбора между различными сущностями и расширяемостью рекомендуется использовать что-то вродеBEMконцепции блоков и модификаторов, чтобы помочь вам подумать, был ли я вBEMРассмотрим это в контексте этого, это помогает мне определить, есть ли у меня отдельная сущность или «расширение модификатора» кода.

BEM — Block Element Modfier — полезный способ создания многократно используемых внешних компонентов и внешнего кода.

взаимозависимые варианты использования

Вторая проблема зависит от варианта использования, когда события одного варианта использования запускают другой вариант использования.

Единственный способ справиться с этим, который я знаю и который помог мне, — разложить вариант использования на более мелкие атомарные варианты использования. Их будет легче собрать.

Часто эта проблема возникает в результате другой большой проблемы в программировании. Это состав сущности.

наконец

В этой статье мы представили «чистую архитектуру» для внешнего интерфейса.

Это не золотой стандарт, а набор опыта, накопленного во многих проектах, спецификациях и языках.

Я обнаружил, что это очень удобное решение, которое поможет вам разделить код. Держите уровни, модули и сервисы как можно более независимыми. Вы можете не только публиковать и развертывать независимо друг от друга, но и упрощает переход от одного проекта к другому.

Какова ваша идеальная архитектура интерфейса?

оригинал"# «Чистая архитектура» во внешнем домене"Впервые опубликовано на публичном аккаунте "Code Secret Garden", прошу обратить внимание.

Если статья была вам полезна, ставьте лайк, спасибо~