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

Большинство разработчиков, которые изучают UDF, уже имеют опыт использования ООП. Однако многие подходы в UDF могут сильно отличаться от принятых в ООП. Это может усложнить изучение новой архитектуры. В этой статье я попытался систематизировать способы взаимодействия модулей между собой и показать, как они могут быть реализованы в ООП и UDF.
Для начала определимся с терминами. В рамках статьи буду оперировать понятием «модуль». Важно понимать, что термин не привязан к конкретному языку, архитектуре или парадигме. Модуль — элемент домена, который хорошо сформирован вокруг конкретной задачи (подробнее в разделе High Cohesion из предыдущей статьи). В ООП модуль реализуется с помощью объектов классов, в UDF — тройкой State, Reducer, Actions. Перейду к рассмотрению связей между модулями.
Типы взаимодействия
По типу взаимодействия между модулями можем разделить их на 2 группы:
-
Они никак не взаимодействуют друг с другом.
-
Они каким-то образом взаимодействуют. Например, один модуль что-то сообщает или запрашивает у другого.
Рассмотрим эти группы детальнее:
1. Не взаимодействуют
Это самый простой случай. У нас есть 2 модуля и они ничего не знают друг о друге.

Посмотрим, как 2 таких модуля можно было бы реализовать в ООП и в UDF:
ООП
Создадим 2 экземпляра двух различных классов и будем оперировать ими независимо друг от друга.
let fly = Fly() fly.buzz() let cutlet = Cutlet() cutlet.fry()
UDF
В рамках AppState живут 2 отдельных стейта, а их редюсеры один за другим вызываются в главном редюсере.
struct AppState { var fly: Fly var cutlet: Cutlet } func reduce(state: inout AppState, action: Action) { reduce(state: &state.fly, action: action) reduce(state: &state.cutlet, action: action) }
2. Взаимодействуют
2 модуля каким-либо образом взаимодействуют друг с другом.

Можно выделить такие виды взаимодействия:
-
Domain1 нужно что-то сообщить в Domain2.
-
Domain1 нужно что-то синхронно получить из Domain2.
-
Domain1 нужно что-то асинхронно получить из Domain2.
ООП
Для взаимодействия между объектами одному объекту обычно предоставляется ссылка на другой объект:
class Driver { func doSomething(with car: Car) { // что-то делаем с объектом car } }
-
Если нужно что-то сообщить в объект, мы вызываем метод этого класса:
car.startEngine()
-
Если нужно что-то синхронно получить из класса, мы вызываем метод, который возвращает искомое значение:
let temperature = thermometer.getCurrentTemperature()
-
Если нужно что-то асинхронно получить, то в зависимости от языка и фреймворка могут использоваться коллбеки, делегаты, промисы и так далее:
service.getRemoteData { data in print(data) }
В ООП также существуют способ организовать взаимодействие между объектами без явных ссылок друг на друга. Например, этого можно добиться с помощью шаблона «Посредник».
UDF
В случае UDF модули чаще всего ничего не знают друг о друге, а их взаимодействие реализуется с помощью посредника. В качестве посредника между двумя модулями выступает их общий родительский модуль:

Вот что здесь происходит:
-
Редюсер всего приложения получает Action из модуля Driver.
-
Модуль приложения знает о модуле Car, поэтому в рамках своего редюсера он может обновить данные в стейте модуля Car.
Тоже самое в коде:
struct AppState { var driver: Driver var car: Car } func reduce(state: inout AppState, action: Action) { reduce(state: &state.driver, action: action) reduce(state: &state.car, action: action) if case DriverActions.PowerDidTap = action { state.car.isEngineRunning = true } }
Так как соседние модули взаимодействуют через общего родителя, нет смысла разбирать типы взаимодействия между ними. Лучше сосредоточиться на взаимодействия между модулями «родитель-ребенок».
Взаимодействие «родитель-ребенок»
По взаимодействию «родитель-ребенок» выделю 2 группы:
-
У модуля один дочерний модуль и только он им владеет.
-
Несколько модулей используют один и тот же дочерний модуль.
1. У модуля один родитель
Такую ситуацию можно представить как один модуль, вложенный в другой.

Разберем основные типы взаимодействия «родитель-ребенок»:
a. Родителю нужно что-то изменить в ребенке.
b. Ребенку что-то нужно изменить в родителе.
c. Родителю нужно что-то получить от ребенка.
d. Ребенку что-то нужно получить от родителя.
ООП
Тут мы можем использовать композицию:
class Car { private let engine = Engine() }
Таким образом, экземпляр Car единолично владеет экземпляром Engine.
а. Родителю нужно что-то изменить в ребенке.
func startEngine() { engine.start() }
b. Ребенку что-то нужно изменить в родителе.
protocol EngineDelegate: AnyObject { func engineDidStop() } class Engine { weak var delegate: EngineDelegate? //... func run() { //... if somethingIsBroken { delegate?.engineDidStop() } } }
c. Родителю нужно что-то получить от ребенка.
class Car { let engine = Engine() var speed: Int = 0 //... func pushGasPedal() { if engine.isRunning { speed += 10 } } }
d. Ребенку что-то нужно получить от родителя.
protocol EngineDelegate: AnyObject { func isOutOfGas() -> Bool } class Engine { weak var delegate: EngineDelegate? var status: EngineStatus = .off //... func run() { //... guard let delegate = delegate else { return } if somethingIsBroken, delegate.isOutOfGas() { status = .outOfGas } } }
UDF
Реализуем Engine как дочерний модуль по отношению к Car:
//App struct AppState { var car: Car } func reduce(state: inout AppState, action: Action) { reduce(state: &state.car, action: action) } //Car struct Car { var engine: Engine } func reduce(state: inout Car, action: Action) { reduce(state: &state.engine, action: action) //Car reducer logic }
a. Родителю нужно что-то изменить в ребенке.
func reduce(state: inout Car, action: Action) { reduce(state: &state.engine, action: action) if case CarActions.DidTurnKey = action { state.engine.isRunning = true } }
b. Ребенку что-то нужно изменить в родителе.
func reduce(state: inout Car, action: Action) { reduce(state: &state.engine, action: action) if case EngineActions.engineDidStop = action { state.errorAlert = “Unexpected Engine Stopping“ } }
c. Родителю нужно что-то получить от ребенка.
func reduce(state: inout Car, action: Action) { reduce(state: &state.engine, action: action) if case CarActions.DidPushGasPedal = action, state.engine.isRunning { state.speed += 10 } }
d. Ребенку что-то нужно получить от родителя.
В такой ситуации данные, нужные ребенку, выносятся в отдельный дочерний стейт.
struct Engine { var gasTank: GasTank var status: EngineStatus } func reduce(state: inout Engine, action: Action) { if case EngineActions.engineDidStop = action, state.gasTank.isOutOfGas { state.status = .outOfGas } }
Однако проблема может возникнуть, когда мы попытаемся использовать 2 отдельных дочерних стейта в двух разных местах приложения.
Предположим, у нас есть 2 машины:

Когда AppReducer получает Action для Car, неизвестно, какому из двух модулей он предназначается. В результате сработают редюсеры обоих модулей, и мы обновим State в обоих модулях. Экшену нужно добавить контекст, к какому конкретно модулю он имеет отношение. Рассмотрим 2 решения: Namespace и Иерархия экшенов.
Namespace
Введем протокол Namespacable, который будет требовать от Action наличие неймспейса:
protocol Namespaceable { associatedType Namespace var namespace: Namespace { get } }
Чтобы у нас была возможность указать редюсеру, в рамках какого неймспейса он должен работать и не прокидывать редюсеру еще один параметр, реализуем такую функцию высшего порядка:
func namespacableReducer<State>( namespace: Namespace, reducer: @escaping Reducer<State> ) -> Reducer<State> { return { state, action in guard let namespaceable = action as? Namespaceable, namespaceable.namespace == namespace else { return } return reducer(&state, action) } }
Теперь мы можем создать Action для нашего модуля и реализовать протокол Namespaceable:
enum CarActions: Action, Namespaceable { case breakDidPress(namespace: String) var namespace: Namespace { switch self { case let .buttonDidTap(namespace): return namespace } } }
А затем отправить их, используя соответствующий неймспейс:
store.dispatch(CarActions.breakDidPress("primary")) store.dispatch(CarActions.breakDidPress("secondary"))
Теперь остается только создать соответствующее редюсеры и вызвать в appReducer:
let primaryCarReducer = namespacableReducer(namespace: "primary", reducer: carReducer) let secondaryCarReducer = namespacableReducer(namespace: "secondary", reducer: carReducer) func appReduce(state: inout AppState, action: Action) { primaryCarReducer(state: &state.primaryCar, action: action) secondaryCarReducer(state: &state.secondaryCar, action: action) }
В результате получим такую картину:

Иерархия экшенов
Рассмотрим иерархическую композицию экшенов, аналогичную композиции стейтов:
enum AppActions: Action { case primary(CarActions) case secondary(CarActions) // other actions }
Тогда мы можем отправить их вот так:
store.dispatch(AppActions.primary(.breakDidPress)) store.dispatch(AppActions.secondary(.breakDidPress))
Внутри appReducer, в зависимости от ветки, вызываем редюсер на соответствующем стейте:
func appReduce(state: inout AppState, action: AppActions) { switch action { case let .primary(carAction): carReducer(state: &state.primaryCar, action: carAction) case let .secondary(carAction): carReducer(state: &state.secondaryCar, action: carAction) } }
Для удобства реализации appReduce хотелось бы иметь аналог namespacableReducer, чтобы мы могли просто указать, в какой из веток экшенов мы заинтересованы в данном редюсере. Для этого нам нужно типизировать редюсеры по экшену, а затем добавить функцию contraReducer:
func contraReducer<State, GlobalAction, LocalAction>( reducer: Reducer<State, LocalAction>, action toLocalAction: (GlobalAction) -> LocalAction? ) -> Reducer<State, GlobalAction> { return { state, action in guard let localAction = toLocalAction(action) else { return } return reducer(&state, localAction) } }
Теперь мы можем в виде замыкания указать, какой из экшенов нужно достать. Так как замыкания получаются достаточно массивными, зафиксируем их в расширении для AppActions:
extension AppActions { static func toPrimaryCarActions(action: AppActions) -> CarActions? { if case let .primary(carAction) = action { return carAction } else { return nil } } static func toSecondaryActions(action: AppActions) -> CarActions? { if case let .primary(carAction) = action { return carAction } else { return nil } } }
Теперь мы можем сделать тоже самое, что и для Namespacable:
let primaryCarReducer = contraReducer( reducer: carReducer, action: AppActions.toPrimaryCarActions) let secondaryCarReducer = contraReducer( reducer: carReducer, action: AppActions.toSecondaryActions) func appReduce(state: inout AppState, action: AppActions) { primaryCarReducer(&state.primaryCar, action) secondaryCarReducer(&state.secondaryCar, action) }
По extension кажется, что мы не избавились от логики раскрытия энама экшенов, а просто перенесли его в extension. Мы бы полностью избавились от этого кода, если бы в свифте были KeyPath для энамов. Тогда создание редюсеров выглядело бы как-то так:
let primaryCarReducer = contraReducer(reducer: carReducer, action: </span>AppActions.primary) let secondaryCarReducer = contraReducer(reducer: carReducer, action: </span>AppActions.secondary)
Разработчики The Composable Architecture (TCA) озаботились этой проблемой и сделали фреймворк CasePaths. С его помощью наши 2 редюсера в TCA выглядели бы примерно так:
let appReducer = Reducer<AppState, AppActions, AppEnvironment>.combine( carReducer.pullback( state: .primary, action: /AppAction.primary, environment: .carEnvironment), carReducer.pullback( state: .secondary, action: /AppAction.secondary, environment: .carEnvironment) )
2. У модуля несколько родителей
Это ситуация, когда один и тот же экземпляр модуля используют 2 родителя:

ООП
Используем агрегацию:
let car = Car() let firstDriver = Driver(car: car) let secondDriver = Driver(car: car)
Таким образом, каждый из родителей получает ссылку на один и тот же экземпляр дочернего класса.
UDF
Данный случай подробно разобран в статье «UDF в супераппе». Такой случай тоже имеет 2 решения: Computed Module State и State Protocol.
Computed Module State
Стейт каждого модуля, который использует дочерний, сделаем вычисляемым. Физически в стейте будем хранить только дочерний стейт. Это позволит нам гарантировать, что дочерний модуль всегда будет только один:
struct FirstDriver { var car: Car } struct SecondDriver { var car: Car } struct AppState { var car: Car } extension AppState { var firstDriver: FirstDriver { get { .init(car: car) } set { car = newValue.car } } var secondDriver: SecondDriver { get { .init(car: car) } set { car = newValue.car } } } func appReduce(state: inout AppState, action: Action) { reduce(state: &state.firstDriver, action: action) reduce(state: &state.secondDriver, action: action) }
State Protocol
Вместо вычислимых свойств для описания стейтов будем использовать протоколы. Физически в стейте также хранится только один дочерний стейт, а AppState просто реализует данные протоколы:
protocol FirstDriver { var car: Car } protocol SecondDriver { var car: Car } struct AppState: FirstDriver, SecondDriver { var car: Car }
Заключение
В качестве заключения я собрал все вышеизложенные подходы в одну таблицу:
|
ООП |
UDF |
|
|
Модули не взаимодействуют |
Два отдельных класса |
Два отдельных набора стейтов, редюсеров и экшенов |
|
Модули взаимодействуют |
Вызов метода, Callback, Promise и так далее |
Модули используют общий родительский модуль как посредника |
|
Родитель-ребенок. Ребенок только одного родителя |
Композиция |
Namespace или Иерархия экшенов |
|
Родитель-ребенок. Ребенок нескольких родителей |
Агрегация |
Computed Module State или State Protocol |
ссылка на оригинал статьи https://habr.com/ru/company/indriver/blog/650823/
Добавить комментарий