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