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

Содержание

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

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

Типы взаимодействия

По типу взаимодействия между модулями можем разделить их на 2 группы: 

  1. Они никак не взаимодействуют друг с другом.

  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 модуля каким-либо образом взаимодействуют друг с другом.

Можно выделить такие виды взаимодействия:

  1. Domain1 нужно что-то сообщить в Domain2.

  2. Domain1 нужно что-то синхронно получить из Domain2.

  3. Domain1 нужно что-то асинхронно получить из Domain2.

ООП

Для взаимодействия между объектами одному объекту обычно предоставляется ссылка на другой объект:

class Driver {
    func doSomething(with car: Car) {
      // что-то делаем с объектом car
    }
}
  1. Если нужно что-то сообщить в объект, мы вызываем метод этого класса:

car.startEngine()
  1. Если нужно что-то синхронно получить из класса, мы вызываем метод, который возвращает искомое значение:

let temperature = thermometer.getCurrentTemperature()
  1. Если нужно что-то асинхронно получить, то в зависимости от языка и фреймворка могут использоваться коллбеки, делегаты, промисы и так далее:

service.getRemoteData { data in
    print(data)
}

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

UDF

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

Вот что здесь происходит:

  1. Редюсер всего приложения получает Action из модуля Driver.

  2. Модуль приложения знает о модуле 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. У модуля один дочерний модуль и только он им владеет.

  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

Комментарии (4)


  1. kotovsky_art
    16.02.2022 09:19
    +1

    У вас проблема с инкапсуляцией поведения для состояний отдельных Car. Отсюда и костыль в виде протокола Namespacable. Раз уж вы знакомы с чуваками из Point Free и их библиотеками посмотрите блок видео о Composable Architecture по теме Derived Behavior. И уберите этот костыль.


    1. MasterWatcher Автор
      16.02.2022 10:46
      +1

      Спасибо за комментарий! Derived Behavior конечно же смотрели :) Если вы конкретно про этот пример, то тут скорее про переиспользование favorites: Set<Int> в двух разных модулях. Этому случаю у нас посвящен раздел "У модуля несколько родителей", а такой подход описан в блоке Computed Module State.

      Конкретно использование Namespacable не считаем костылем, а просто одним из вариантов решения проблемы, никого не заставляем так писать :) Вот и вот примеры использования такого подхода в редаксе. В The Composable Architecture другой подход и у нас он упоминается в разделе "Иерархия экшенов".

      Про инкапсуляцию в смысле механизма скрытия для UDF писали в одной из прошлых статей.


  1. kotovsky_art
    16.02.2022 11:16

    ReduxJS плохой пример потому, что он используется в окружении где нет мощи типов или даже чего-то подобного на enum свифта. Если что, то и имена экшенов там тоже текстовые. Вот пример Point Free где ребята правильным образом подводят к тому как извлекать поведение для списка однородных сущностей. Без странных вещей типа primary-secondary car, firstDriver - secondDriver.

    А Namespaceble костыль потому, что эта подкапотая инфраструктура начинает фигурировать в вызове из вьюшки:

    store.dispatch(CarActions.breakDidPress("primary"))

    Вы в ReduxJS где то видели в дисптаче явно переданный namespace? там такого нет и близко.

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

    enum AppActions: Action {
        case primary(CarActions)
        case secondary(CarActions)
       // other actions
    }

    А когда вы пишите экстеншн на AppActions
    static func toPrimaryCarActions(action: AppActions) -> CarActions?

    Это ж совсем дичь. В функциональной композируемой архитектуре вы либо ищите решения из этого мира, либо возвращайтесь обратно в привычный императивный мир MV* архитектур. И не говорите что потом всё переписали с CasePaths только потому, что в итоге выдернули кусок кода из TCA (который не сочетается с примерами редьюсеров, что вы писали выше) вообще не раскрыв тему pullback и извечения значений из enum. Советую пересмотреть PointFree и научиться делать сначала правильно, а потом изобретать велосипеды.

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


  1. MasterWatcher Автор
    16.02.2022 12:43
    +2

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

    По поводу namespace во вью, конечно namespace всегда можно вынести из вью, в случае Redux например в Action Creator. Но тема статьи же не про вью.

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