Как стать автором
Обновить
inDrive.Tech
Global Unicorn with Developers Driving the Growth

Модуляризация доменного слоя в UDF. Часть II

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

В предыдущей части мы рассмотрели, что такое домен и какими принципами можно руководствоваться при его модуляризации. В этой части сконцентрируемся на типах связей между модулями и различиях в проектировании ООП и 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

Теги:
Хабы:
+8
Комментарии4

Публикации

Информация

Сайт
indrive.com
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
США
Представитель
g_volgin

Истории