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

от автора

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


ссылка на оригинал статьи https://habr.com/ru/company/indriver/blog/650823/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *