В одной из предыдущих статей я рассказал, как в inDriver мы пришли к использованию UDF в своем приложении. Так как приложение inDriver — суперапп с множеством модулей, главными задачами для нашей архитектуры являются масштабирование и модуляризация. Во второй статье я сконцентрировался на основных проблемах модуляризации UDF и вариантах их решения.
Однако вопрос модуляризации доменной логики заслуживает отдельной статьи. Под катом я рассмотрю, как в UDF можно разбить доменный слой на небольшие независимые части и заставить их работать в рамках одного приложения. Приятного чтения!
Содержание
Что такое домен?
Для начала необходимо определиться с терминами. Модель, доменный слой, доменная логика, бизнес-логика, логика предметной области... В зависимости от контекста эти термины могут использоваться как синонимично, так и для близких друг к другу, но разных вещей. Поэтому, прежде чем модуляризировать домен, нужно определиться, что мы имеем в виду.
На заре коммерческой разработки ПО компьютеры только начинали решать проблемы, стоящие перед частными компаниями. У бирж, банков, страховых компаний есть много процессов, которые стало возможным автоматизировать. У каждого бизнеса есть своя предметная область (domain), в которой существуют конкретные правила, по которым она работает.
Например: как составить заявку на покупку биржевого товара, как рассчитать сумму ипотечного кредита, страховую премию и т.д. Все эти правила переносились в код и стали называться доменной или бизнес-логикой.
Чтобы отделить доменную логику от логики отображения, получения и хранения данных, ее выделяют в отдельный доменный слой. Часто такой слой называют просто «модель». Такое разделение позволяет легко изменить код рендеринга или переехать на другое хранилище. Слой доменной логики при этом не меняется.
Но с развитием области коммерческого ПО стало сложнее понимать, какую именно часть кода можно назвать доменной логикой. Во-первых, технологии и бизнес начали переплетаться все сильнее. Появились отрасли, которые практически не связаны с физическим миром. Операционные системы, регистраторы доменных имен, социальные сети. Все они сильно связаны с технологиями. И понять, какой код имеет отношение непосредственно к бизнесу, а какой к технологиям становится все сложнее.
Во-вторых, с развитием интернета произошло разделение продукта на бэкенд и фронтенд. Теперь во многих приложениях большая часть доменной логики выполняется на сервере. В такой ситуации сложно сказать, какая часть клиентского кода представляет собой доменную логику, а какая просто готовит данные для отправки на сервер. Некоторые клиенты могут и вовсе не содержать доменной логики, а просто отображать данные, полученные с сервера.
Чтобы избежать всех этих путаниц, в рамках статьи будем придерживаться эвристического правила: доменной логикой называется тот код, который мы можем объяснить бизнесу. Бизнес не знает, какими мы пользуемся фреймворками, как храним и передаем данные, как верстаем экран. Но зато бизнес знает, как понять, что номер карты валидный или что корзина пользователя сохранится и будет доступна при следующем входе.
Важно понимать, что это не точное определение термина, а критерий того, что имеет смысл положить в доменный слой. Если вы не можете объяснить человеку из бизнеса, что делает конкретный кусок кода и зачем он нужен, велика вероятность, что этому коду не место в доменном слое. Приведу конкретные примеры:
Непонятно: Сервис через Alamofire делает запрос к «appname.com/api/user», а затем парсит ответ в DTO. Понятно: С сервера приходят данные пользователя. | ||
Непонятно: ListViewController по нажатию на ячейку таблицы пушит DetailViewController. Понятно: Если на экране списка товаров пользователь нажмет на товар, он перейдет к экрану с деталями этого товара. | ||
Непонятно: По нажатию на UIButton делаем запрос к «appname.com/api/purchase» и передаем доступные параметры. |
Не сомневаюсь в вашей способности объяснять сложные технические концепции бизнесу. Но важно обратить внимание, что в пунктах «Понятно» они, в большинстве своем, отсутствуют. Рассмотрим, как такой подход к домену применим в UDF.
Домен в UDF
Практики эффективного моделирования доменного слоя со временем вылились в философию Domain-Driven Design. Однако эти принципы были сформулированы в рамках объектно-ориентированной парадигмы. Unidirectional Data Flow (UDF) основан на принципах функционального программирования, поэтому нам придется посмотреть на проектирование домена с другой стороны.
В UDF вместо классов для формирования доменного слоя используются следующие сущности:
State — для описания текущего состояния приложения.
Action — для описания входящих событий.
Reducer — для обновления стейта.
Вернемся к примерам доменной логики из предыдущего раздела и посмотрим, как их можно реализовать в рамках UDF:
С сервера приходят данные пользователя
struct User {
var firstName: String
var lastName: String
//...
}
enum UserServiceActions {
case userDidLoad(User)
}
func reduce(state: inout User, action: UserServiceActions) {
switch action {
case let .userDidLoad(user):
state = user
}
}
Обратите внимание, что данный код не содержит никакой конкретики касательно того, откуда и как пришли данные пользователя. Эти детали находятся за пределами доменного слоя.
Если на экране списка товаров пользователь нажмет на товар, он перейдет к экрану с деталями этого товара
struct State {
var items: [UUID: Item]
var selectedItemID: UUID
var detailsScreenOpened = false
}
enum ItemsListActions {
case didSelectItem(UUID)
}
func reduce(state: inout State, action: ItemsListActions) {
switch action {
case let .didSelectItem(id):
state.selectedItemID = id
state.detailsScreenOpened = true
}
}
Этот код содержит условие для перехода на другой экран: detailsScreenOpened поменяется на true, когда пользователь нажмет на элемент списка. При этом код не содержит деталей реализации перехода. Это может быть present, а может push. Это даже может быть SwiftUI, а не UIKit. Такой код является декларативным, так как говорит, что должно произойти, но не как.
По нажатию на кнопку «Купить» отправляем на сервер доступные товары
struct State {
var items: [UUID: Item]
var status: Status
}
enum ItemsListActions {
case saveButtonDidTap
}
func reduce(state: inout State, action: ItemsListActions) {
switch action {
case .saveButtonDidTap:
let availableItems = state.items.values
.map { $0 }
.filter { $0.isAvailable }
state.status = .saving(availableItems)
}
}
Данный код объединяет в себе получение события из UI, фильтрацию товаров и сохранение. Чтобы встроить его в приложение достаточно реализовать UI-слой, который будет отправлять событие saveButtonDidTap и network-слой, который будет подписываться на статус и по состоянию .saving отправлять товары на сервер.
Перечислим плюсы и минусы такого подхода к проектированию доменного слоя:
+ Декларативное описание. Декларативный код не содержит деталей реализации и позволяет бизнесу быстрее понять, что происходит.
+ Просто подменить один фреймворк на другой. Мы легко можем поменять фреймворк для UI, хранения или работы с сетью, и это никак не повлияет на доменный слой.
+ Легко тестировать. Нам не нужно мокать никакие зависимости — их просто нет.
+ Не нужны протоколы. Это означает, что нам не нужны моки в доменном слое. Юнит-тесты сводятся к вызову редюсера и валидации полученного стейта.
- Непонятно, как масштабировать. При росте проекта без должного внимания к модуляризации, доменный слой постепенно превращается в нечитаемое нагромождение редюсеров. Экшены летят со всех направлений, кто и где их обрабатывает понять трудно. В следующем пункте подробнее разберем принципы модуляризации.
Основные принципы модуляризации
В разных источниках приводятся различные принципы проектирования ПО: SOLID, KISS, YAGNI, GRASP и т.д. Но в контексте модуляризации я бы хотел сосредоточиться на двух: Low Coupling и High Cohesion.
Принцип Low Coupling (слабая связанность) говорит о том, что разные модули должны иметь как можно меньше связей между собой. Это улучшает понимание логики модулей, упрощает их модификацию и тестирование, позволяет легко переиспользовать модули по отдельности.
Принцип High Cohesion (сильная сцепленность) говорит о том, что все элементы модуля должны быть хорошо сфокусированы на цели модуля. Например, если цель — хранить данные пользователя и предоставлять способы их изменения, то из такого модуля следует исключить хранение или создание заказов.
Принципы звучат достаточно абстрактно, что может усложнить их понимание. Но это лишь потому, что они не привязаны к конкретной парадигме. Принципы могут применяться как в объектно-ориентированном (ООП), так и в процедурном или функциональном программировании.
Рассмотрим, как их можно применить для UDF. Думаю, большинство из вас знакомы с парадигмой объектно-ориентированного программирования. Ниже я буду приводить аналогии из мира ООП. Начнем с Cohesion.
High Cohesion
Этот принцип говорит о сцепленности модуля. В случае ООП модулем является класс. У класса есть свойства и методы. Класс мы стараемся формировать так, чтобы он решал одну конкретную задачу. В целом, принцип схож с принципом единственной ответственности из SOLID.
В случае UDF модулем является совокупность стейтов, редюсеров и экшенов. Рассмотрим пример модуля, ответственность которого размыта:
struct State {
var user: User
var items: [UUID: Item]
var selectedItemID: UUID
}
enum Actions {
case userDidLoad(User)
case didSelectItem(UUID)
}
func reduce(state: inout State, action: Actions) {
switch action {
case let .userDidLoad(user):
state.user = user
case let .didSelectItem(id):
state.selectedItemID = id
}
}
Этот модуль одновременно отвечает и за пользователя, и за товары. У такого решения есть ряд проблем:
Модуль сложен для чтения. Конечно, пока у него всего 2 ответственности, проблема не выражена так ярко. Но чем больше у модуля будет ответственностей, тем сложнее в нем разбираться.
Если мы захотим переиспользовать логику работы с пользователем, нам придется тащить за собой и логику работы с товарами.
Любой редюсер, который располагается на уровень выше, может поменять все в нашем стейте, но внутри нашего модуля мы об этом не узнаем:
struct ParentState {
var childState: State
//...
}
func reduce(state: inout ParentState, action: Actions) {
reduce(state: &state.childState, action: action)
state.childState.user.firstName = "John"
}
Первая и вторая проблема решается обычным разбиением на 2 модуля, как это реализовано в примерах 1 и 2 в разделе «Домен в UDF».
Третья проблема напрямую связана с понятием инкапсуляции. Инкапсуляция обеспечивает сокрытие данных и логики внутри модуля. В случае ООП мы можем контролировать доступность свойств и методов класса с помощью модификаторов уровня доступа. Например, публичные свойства и методы объявляются с модификатором public, а приватные с private.
В UDF мы также можем реализовать инкапсуляцию через модификаторы доступа. Для этого мы реализуем state и reducer в рамках одного файла, а свойства стейта помечаем модификатором fileprivate:
//User.swift
struct User {
fileprivate var firstName: String
fileprivate var lastName: String
}
func reduce(state: inout User, action: Actions) {
switch action {
case let .userDidLoad(user):
//Можем легко поменять имя
state.firstName = user.firstName
}
}
//State.Swift
struct State {
var user: User
}
func reduce(state: inout State, action: Actions) {
reduce(state: &state.user, action: action)
//Ошибка: Cannot assign to property: 'firstName' setter is inaccessible
state.user.firstName = "John"
}
Если же в рамках модуля нам нужно инкапсулировать какую-то логику, можно использовать публичные функции, которые наравне с редюсером вызываются извне:
//Order.swift
struct Order {
fileprivate var status: OrderStatus
//...
}
//...Action и Reducer...//
public func updateStatus(order: inout Order, status: OrderStatus) {
//инкапсулируем логику обновления статуса
switch (order.status, status) {
case (.initial, inProgress): order.status = status
default: return
}
}
//State.swift
structState {
var order: Order
}
func reduce(state: inout FeatureState, action: Action) {
reduce(state: &state.order, action: action)
//обновляем статус
updateStatus(order: &state.order, status: .inProgress)
}
Как видим, те требования, которые в ООП накладываются на класс, справедливы и для модуля UDF. Далее рассмотрим, как организовать взаимодействие между модулями.
Low Coupling
Принцип говорит о минимизации связей между модулями. В случае ООП у нас есть протоколы, которые позволяют перенести зависимость от конкретного класса на зависимость от абстракции. Доменный слой UDF не предусматривает использование протоколов, поэтому необходимы другие способы избавления от зависимостей. Также из-за особенностей обновления состояния некоторые подходы в UDF могут показаться контринтуитивными.
Рассмотрим пример. Есть 2 модуля. Один отвечает за водителя такси, второй за заказ. На одном из экранов есть кнопка «Беру заказ». По нажатию на нее мы должны изменить статус заказа и присвоить его водителю. Модули водителя и заказа не зависят друг от друга, поэтому кажется правильным абстрагироваться от факта нажатия на кнопку, а просто иметь 2 абстрактных события, каждое из которых объявлено в своем модуле:
//Driver.swift
struct Driver {
fileprivate var selectedOrderID: UUID
//...
}
enum DriverActions {
case orderDidSelect(UUID)
}
func reduce(state: inout User, action: DriverActions) {
switch action {
case let .orderDidSelect(selectedOrderID):
state.selectedOrderID = selectedOrderID
}
}
//Order.swift
struct Order {
fileprivate var status: OrderStatus
//...
}
enum OrderActions {
case orderDidSelect
}
func reduce(state: inout Order, action: OrderActions) {
switch action {
case .orderDidSelect:
state.status = .inProgress
}
}
Тогда по нажатию на кнопку мы бы просто отправили 2 события:
//OrderViewController.swift
//...
func pickButtonTapped() {
store.dispatch(OrderActions.orderDidSelect)
store.dispatch(UserActions.orderDidSelect(id))
}
//...
У такого подхода есть 2 недостатка:
Рассинхронизация стейта. Между обработкой первого события и отправкой второго стейт приложения будет рассинхронизирован. Заказ будет в состоянии «В процессе», но он еще не назначен водителю. Бизнес-логика приложения не подразумевает такой ситуации, поэтому состояние может привести к ошибкам. Желательно не допускать невозможных состояний. Иногда для решения такой проблемы применяется batch dispatch, который позволяет обработать сразу несколько экшенов, прежде чем уведомлять подписчиков об изменении состояния. Но такой подход не решает вторую проблему.
Производительность. В приложении может быть много редюсеров. И поиск нужного может занять время. Конечно, есть способы оптимизировать этот процесс, но на каждый экшен нужно заново искать соответствующий редюсер. При большом количестве экшенов можно сильно просесть по производительности. При этом подписчики будут слишком долго ждать, пока весь стейт обновится.
Одним из решений будет оставить экшен в одном модуле. Второй модуль тоже будет его обрабатывать. Например, оставим экшен в модуле водителя. В таком случае получим такую схему зависимостей:
Это не очень хорошо, так как между модулями появилась лишняя зависимость. В качестве альтернативы можно вынести общий экшен в отдельный модуль:
Теперь модули не зависят друг от друга. Но если мы захотим переиспользовать модуль водителя или заказа, придется захватить с собой еще и модуль CommonAction. Более того, в модуле CommonAction мы нарушаем принцип High Cohesion, так как он не решает никакой конкретной бизнес-задачи сам по себе.
В целом, подход со временем может привести к огромному количеству хаотичных зависимостей, которые будет трудно распутать:
Рассмотрим второе решение. Сделаем так, чтобы ни модуль водителя, ни модуль заказа не знали о событии кнопки «Беру заказ». Переведем обработку этого экшена в родительский модуль:
//Feature.swift
struct FeatureState {
var driver: Driver
var order: Order
}
enum FeatureActions: Action {
//1
case takeOrderDidTap(UUID)
}
func reduce(state: inout FeatureState, action: Action) {
//2
reduce(state: &state.driver, action: action)
reduce(state: &state.order, action: action)
//3
if case let FeatureActions.takeOrderDidTap(selectedOrderID) = action {
state.driver.selectedOrderID = selectedOrderID
updateStatus(order: &state.order, status: .inProgress)
}
}
//Order.swift
struct Order {
fileprivate var status: OrderStatus
//...
}
//...Action и Reducer...//
//4
public func updateStatus(order: inout Order, status: OrderStatus) {
switch (order.status, status) {
case (.initial, inProgress): order.status = status
default: return
}
}
Что здесь происходит:
Объявляем Action нажатия на кнопку внутри модуля Feature.
Внутри редюсера фичи вызываем все дочерние редюсеры.
В редюсере Feature обрабатываем экшен кнопки.
Для модуля Order используем функцию updateStatus, чтобы обновить статус и не нарушить инкапсуляцию модуля.
Получаем схему:
При таком подходе модуль Driver и модуль Order ничего не знают друг о друге. Их взаимодействие обеспечивает родительский модуль Feature, в рамках которого как раз и описана логика обработки нажатия кнопки. Аналогом данного подхода в мире ООП можно назвать шаблон Mediator.
Если мы попробуем масштабировать такой подход, получим схему, в которой родительские модули знают только о дочерних и отвечают за их взаимодействие между собой:
Такая схема позволяет минимизировать количество связей между модулями, тем самым реализуя принцип Low Coupling. При этом мы не создаем лишних объектов со слабой связанностью и не влияем на консистентность данных или скорость работы редюсеров.
Заключение
Как показала практика, проектирование домена в UDF концептуально не отличается от проектирования домена в ООП. Принципы Low Coupling и High Cohesion помогают эффективно разбить код на модули и продумать, как соединить их между собой. Однако функциональная природа UDF-модулей имеет свои особенности, которые важно учитывать при проектировании доменного слоя. В следующей статье сконцентрируюсь на отличиях и постараюсь разобрать их более детально.
Ссылки
Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
Debasish Ghosh, Functional and Reactive Domain Modeling
https://en.wikipedia.org/wiki/Coupling_(computer_programming)
https://en.wikipedia.org/wiki/Cohesion_(computer_science)
https://redux.js.org/usage/structuring-reducers/refactoring-reducer-example