В предыдущей части мы рассмотрели, что такое домен и какими принципами можно руководствоваться при его модуляризации. В этой части сконцентрируемся на типах связей между модулями и различиях в проектировании ООП и 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 |
Комментарии (4)
kotovsky_art
16.02.2022 11:16ReduxJS плохой пример потому, что он используется в окружении где нет мощи типов или даже чего-то подобного на 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: То что вы не заставляете его использовать других - плохой аргумент, вы ж тут туториалы пишите. Человек который не понимаем причин и следствий потащит эту дичь в свой проект.
MasterWatcher Автор
16.02.2022 12:43+2В приведенном вами примере из Point Free говорится о коллекциях, я говорил о случае двух отдельных модулей. Но как случай обобщения на n модулей и хранение их в коллекции пример хороший, спасибо.
По поводу namespace во вью, конечно namespace всегда можно вынести из вью, в случае Redux например в Action Creator. Но тема статьи же не про вью.
Касательно строгости функциональной архитектуры и правильных и неправильных подходов я, пожалуй, не буду комментировать :)
kotovsky_art
У вас проблема с инкапсуляцией поведения для состояний отдельных Car. Отсюда и костыль в виде протокола Namespacable. Раз уж вы знакомы с чуваками из Point Free и их библиотеками посмотрите блок видео о Composable Architecture по теме Derived Behavior. И уберите этот костыль.
MasterWatcher Автор
Спасибо за комментарий! Derived Behavior конечно же смотрели :) Если вы конкретно про этот пример, то тут скорее про переиспользование favorites: Set<Int> в двух разных модулях. Этому случаю у нас посвящен раздел "У модуля несколько родителей", а такой подход описан в блоке Computed Module State.
Конкретно использование Namespacable не считаем костылем, а просто одним из вариантов решения проблемы, никого не заставляем так писать :) Вот и вот примеры использования такого подхода в редаксе. В The Composable Architecture другой подход и у нас он упоминается в разделе "Иерархия экшенов".
Про инкапсуляцию в смысле механизма скрытия для UDF писали в одной из прошлых статей.