Контроль сложности и архитектура UDF +5


Сложность — главный враг разработчика. Есть такая гипотеза, хотя я бы отнес ее к аксиомам, что сложность любого программного продукта с течением времени, в процессе добавления нового функционала неизбежно растет. Растет она пока не достигнет порога при котором любое вносимое изменение гарантированно, т.е. с вероятностью близкой к 100%, внесет ошибку. Есть также дополнение к этой гипотезе — если такой проект продолжать и далее поддерживать, то рано или поздно он достигнет такого уровня сложности, что нужное изменение внести будет невозможно вообще. Невозможно будет придумать решение, не содержащее в себе какие либо костыли, заведомые антипаттерны.

Источник сложности


Сложность неизбежно привносится входными требованиями, избежать такого вида сложности невозможно. К примеру, в какой либо бухгалтерской программе у вас есть задача совершить выплаты человеку, и среди расчета этих выплат есть такое требование, что человеку проработавшему в компании больше какого то срока начисляется дополнительная сумма за стаж. Код приложения неизбежно будет содержать что то подобное:

if(qualifiedForExtraPayment(employee)) {
    calculateThatExtraPayment(...)
}

Этот if и есть элемент сложности, внесенный в приложение бизнес-требованием.
Но есть и сложность, которая требованиями не определяется, и вносится непосредственно разработчиком. Больше всего эта «искуственная» сложность исходит от некоторой особенности понимания разработчиками принципа DRY.

К примеру, в уже упомянутой гипотетической бухгалтерской программе нужно сделать две почти одинаковые страницы, на обоих нужно посчитать выплаты человеку, и показать детальную информацию по этим выплатам. Но одна страница чисто информационная, чтобы посмотреть сколько человек получит денег, а вторая страница уже исполнительная, где присутствует третие действие — совершить собственно денежный перевод.

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

Как это нужно решать.

На первой странице псевдокод будет такой

const payment = calculatePayment(employee);
displayPayment(payment);

На второй странице

const payment = calculatePayment(employee);
displayPayment(payment);
proceedWithPayment(payment);

Как это иногда решают.

Вместо того, чтобы создать второй метод, в первый вводят дополнительный параметр

const payment = calculatePayment(employee);
displayPayment(payment);
if(isOnlyInformation) {
   return;
}
proceedWithPayment(payment);

И вот мы видим еще один if, еще один элемент сложности в нашем приложении. Этот пункт сложности не исходит от требований, а исходит от способа решения задачи разработчиком.
Там где код уже достаточно декомпозирован, получается что два метода «склеились» в один из-за того, что в них совпали всего две строчки. Но изначально, на этапе когда код сначала пишется «в лоб», имплементация этих методов (calculatePayment и displayPayment) может быть еще не вынесена в отдельные методы, а написана прямо в теле этого, и картина выглядит так, что в двух методах совпадают, скажем, 100 первых строчек. В таком случае вот такое вот «избавление от дублирования кода», может даже показаться хорошим решением. Но это, очевидно, не так. В прошлой своей статье, про сухой антипаттерн я уже приводил примеры, как использование принципа DRY несет в ваше приложение сложность, которую можно избежать. Со способами, как написать тот же код, но без использования оного паттерна, без внесения в приложение сложности, которая не исходит от требований.

Таким образом мы приходим к тому, что в руках одного разработчика сложность приложения растет линейно, пропорционально сложности требований, ну или по крайней мере стремится к этому, в руках другого разработчика сложность растет экспоненциально.

Принцип единственной ответственности


«Равномерно распределенная» сложность не так уж и вредна и опасна при всей своей неизбежности, но она становится страшным сном разработчика, когда концентрируется в одном месте. Если у вас в функции есть if, то у вас добавляется вторая ветка, если их два, причем не важно вложен он или нет, то будет как минимум 4 возможных варианта прохода через метод, третий if сделает 8. И если у вас есть две отделенные, успешно декомпозированные, не влияющие друг на друга функции, в каждой из которых 4 ветви исполнения, то на каждую из них потенциально нужно написать по четыре теста, итого 8. Если же декомпозировать не удалось, и вся эта логика в одном методе, то тестов нужно написать 4*4 => 16, причем каждый такой тест будет более сложным чем тест из первых восьми. Количество и сложность тестов как раз таки и отражают итоговую сложность приложения, которая при том же количестве элементов сложности, будет отличаться более чем вдвое.

На борьбу с таким нагромождением сложности направлен широко извесный принцип проектирования кода, далее называемый SRP, первый пункт сборника принципов SOLID. И если остальные буквы этой аббревиатуры содержат правила по большей части относятся к объектно-ориентированному программированию, то SRP — универсален, и применим не только к любой парадигме программированию, но и к архитектурным решениям, да что там говорить, и к другим инженерным областям тоже. Принцип, к сожалению, содержит в себе некоторую недосказанность, не всегда понятно, а как определить вот сейчас единственная ответственность у чего либо или нет. К примеру вот god-class в миллион строк, но он имеет единственную ответственность — управляет спутником. Он же больше ничего не делает, все значит в порядке? Очевидно — нет. Принцип можно несколько перефразировать — если у некоего элемента вашего кода, а это может быть как функция, класс, так и целый слой вашего приложения, слишком много ответственности, значит нужно проводить декомпозицию, разбивать на части. А то что ее слишком много, можно определить как раз по таким признакам — если тест на это слишком сложен, содержит много шагов, либо если тестов нужно много, значит и ответственности слишком много, и нужно декомпозировать. Иногда это можно определить даже не смотря в код. По т.н. code-smell, если я захожу в файл с одним классом и вижу там 800 строк кода — мне не нужно даже читать этот код, чтобы видеть, что этот класс несет слишком много ответственности. Если в файле целая страница зависимостей (импортов, юзингов и т.п.) мне не нужно скролить вниз и начинать читать этот код, чтобы придти к такому же выводу. Есть конечно разные уловки, IDE потакают гражданам кодерам, схлопывая импорты «под плюсик», есть уловки самих г. кодеров, которые ставят широкоформатный монитор вертикально. Повторюсь, мне даже не нужно читать код такого разработчика, чтобы понять, что он мне скорее всего не понравится.

Но декомпозиция не обязана достигать абсолюта, «другой конец этой палки» — миллион методов каждый с одним операндом может быть и не лучше чем один метод с миллионом операндов. Опять же при чрезмерной декомпозиции неизбежно встанет первая из тех двух шутливых «самых сложных задач в программировании» — именования функций и инвалидации кеша. Но прежде чем дойти до такого, среднестатистическому разработчику надо сначала научиться хотя бы минимально его декомпозировать, хотя бы чуть отойти от того конца, с классом в 800 строк.

Кстати, есть еще одна такая шутливо-философская мысль — класс, как концепция, несет в себе слишком много ответственности — поведение и состояние. Ее можно разбить на две сущности — функции для поведения и «plain» объекты (POJO, POCO и т.п., либо структуры в С) для состояния. Отсюда — парадигма ООП несостоятельна. Живите с этим.

Архитектура, как борьба со сложностью


Как я уже сказал, сложность, как и умножение сложности, не специфичны для программирования, они существуют во всех аспектах жизни. И решение этой проблемы было предложено еще Цезарем — разделяй и властвуй. Две проблемы сцепленные друг с другом победить сложнее чем обе эти проблемы отдельно одну за другой, также как и сложнее победить два варварских племени, объединенных для борьбы с римлянами. Если же их как то отделить друг от друга, то по отдельности победить одно племя, потом другое значительно легче. На низком уровне, при написании уже конкретного кода этому служит декомпозиция и SRP. На высоком же уровне, на уровне проектирования приложения этому служат архитектурные решения.

Отсюда и далее я концентрируюсь на UI/frontend специфике.

Для разработки приложений, содержащих в себе UI, уже давно применяются такие архитектурные решения, как MVC, MVP, MVVM. У всех этих решений есть две общие черты, первая — М, «модель». Они даже ей дают какое то определение, я обойдусь здесь таким упрощением — вся остальная программа. Вторая — V, «представление» или view. Это слой, отделенный от всего остального приложения, единственная ответственность которого это визуальное представление данных, исходящих от модели. Различаются эти архитектуры способом связи M и V между собой. Рассматривать их я, конечно, не буду, обращу лишь внимание еще раз, что все силы, потраченные на разработку этих архитектур, были напрвлены на отделение одной проблемы от другой, и, как нетрудно догадаться, на уменьшение сложности приложения.

И, говоря, что визуальное представление — единственная ответственность этого слоя, я как раз имею ввиду основополагающий принцип этих архитектур — в слое представления не долно быть логики. Совсем. Именно ради этого эти архитектуры задуманы. И если говорить об архитектуре MVC, то там еще был такой термин «тонкий контроллер». Попользовавшись этой архитектурой, люди пришли к выводу, что и контроллер логики содержать не должен. Совсем. Ибо логика это будет уже вторая ответственность этого слоя, а стремиться нужно к тому, чтобы она была одна.

Архитектура Unidirectional Data Flow


Мотивация


Цель этой архитектуры, как и цель ранее упомянутых архитектур ровно та же — уменьшение сложности приложения, посредством разделения проблем между собой, и возведения между ними логического барьера. Преимущество этой архитектуры, почему ее нужно предпочесть другим архитектурам в следующем. Приложения не ограничиваются одним «view» и одной «моделью», в любом приложении у вас их будет несколько. И какая то связь между ними должна быть. В качестве примера возьму следующее — нужно подсчитать какую либо сумму по таблице. В один view человек вводит в таблице данные, на эти данные подписывается другая модель, подсчитывает сумму, сообщает своему view что надо обновиться. Далее пример расширяем тем, что потом на эту сумму подписана третяя модель, в которой нужно сделать кнопку «Применить» активной. Причем иногда у разработчика может возникнуть заблуждение, что вот это вот последнее действие — ответственность именно представления, и мы можем видеть в разметке что то такое: <div *ngIf="otherModel.sumOfItems === 100">.

Это уже ошибка, сравнение двух чисел это уже логика, которой должна заведовать строго модель, разметка должна оперировать строго посчитанными для нее флагами —
<div *ngIf="myModel.canProceed">.

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



Рано или поздно этот граф становится неконтролируемым и, внося изменения, разработчику непросто отследить, какие последствия это понесет в side-effect-ах, происходящих ниже по графу. В презентации от фейсбука это назвали downstream effects, это не термин. Также, другой разработчик пытаясь починить ошибку возникшую ниже по графу, будет иметь сложности в том чтобы отследить root-cause этой проблемы, лежащий на один, а то и несколько шагов выше по этому графу. Местами этот граф может и зацикливаться, и условие выхода из этого цикла может быть сломано безобидным изменением совершенно в другой точке графа, как результат — ваше приложение повисает и вы не можете понять почему. Нетрудно увидеть, что граф распространения изменений не исходит от требований заказчика. Они лишь диктуют последовательные действия, которые надо совершить, а то, что эти действия разбросаны по коду как {подставьте нужный эпитет}, и между ними вставлены подписки, броски событий и т.п. это уже на совести разработчика.

Еще немного об этом можно почитать в мотивации Redux.
Архитектура же UDF представляет из себя конвейер, на который наложено два принципиальных ограничения 1) следующий этап конвейера не имеет права влиять на предыдущий, или на результаты его исполнения. 2) «Зацикливание» конвейера может произвести только ввод от пользователя. Отсюда и название архитектуры — самостоятельно приложение двигает данные лишь в одном направлении, в сторону пользователя, и лишь внешнее влияние может пойти в направлении обратном этому основному потоку.

Основных этапов в конвейере два, первый ответственен за состояние, второй за отображение. В чем то это похоже на модель и представление в вышеупомянутых архитектурах, отличие так же как и между теми архитектурами — во взаимодействии модели с представлением. И дополнительно к этому во взаимодействии моделей между собой. Всё состояние приложения концентрируется в некотором сторе, и при возникновении некоторого внешнего события, например ввода пользователя, все изменение состояния от точки А (до внешнего раздражителя) к точке B (после раздражителя) должно произойти в одно действие. То есть в том же вышеприведенном сценарии код будет выглядеть примерно так, на примере редюсера в redux:

function userInputHappened(prev, input) {
const table = updateRow(prev.table, input);
const total = calculateTotal(prev.total, table);
const canProceed = determineCanProceed(prev.canProceed, total);
return {
   ...prev,
   table,
   total,
   canProceed
   };
}

Тут проиллюстрирован ключевой принцип, главное отличие от привычного конструирования логики. Нет никаких подписок одного на другое, никаких брошенных event-ов между двумя последовательными операциями. Все действия, которые нужно выполнить на данный внешний раздражитель, должны быть написаны последовательно. И при внесении изменений в такой код все последствия изменений обозримы. И, как результат, эта искуственно вносимая сложность минимизирована.

Redux


Изначально архитектура UDF на фронтенде была предложена компанией Facebook в рамках связки двух библиотек react и flux, чуть позже на смену flux пришел redux, предлагая пару очень важных изменений. Первое — контроль состояния теперь предполагается писать чистым кодом, по сути все наше управление состоянием теперь можно описать одной простой формулой

$State(n) = F(State(n-1), A)$


Следующее состояние равно чистой функции от предыдущего состояния и некоторого раздражителя.

Второе — слой представления теперь представляет собой еще одну чистую функцию

$HTML = F(State)$


То есть задача всех UI компонентов — получить на вход объект, выдать результатом разметку.

Таким образом достигаются следующее:

1. «нечистый», асинхронный код сдвигается от приложения к его краю, два больших, даже можно сказать определяющих блока вашего приложения становятся чистыми.

2. Очень крутые дебажные инструменты. Первый — hot reload, позволяет без потери состояния заменить чистую функцию render на лету, то есть вы можете «нагорячую» править UI слой и сразу видеть результат. не перезагружая каждый раз приложение, и также без необходимости «докликать» до точки воспроизведения бага. Второй инструмент — time travel, позволяет запомнить все ваши состояния от State(1) до State(n), и ходить по ним вперед назад, просматривая последовательно что где могло пойти не так. Дополнительно есть возможность воспроизвести баг на одном ПК, экспортировать таймлайн, и загрузить его на другом ПК, то есть передать баг от тестировщика к разработчику даже без описания шагов воспроизведения этого бага. Но этот второй инструмент накладывает еще одно требование — state и action-ы вышего приложения должны быть сериализуемы. Т.е. нельзя использовать даже такие базовые классы как Map или DateTime. Только POJO. Кроме того, в редюсерах нельзя полагаться на ссылочное равенство объектов, сравнивать нужно по значению, например по id.

3. переиспользование — задайте себе вопросы — сталкивались вы со случаем, когда какой то UI компонент сам лезет в модель, получает сам себе данные, сам их обрабатывает и отображает?
Случалось ли такое, что приходит новая задача использовать этот же UI, но с другим источником данных, либо в другом сценарии, с другим набором действий? Порой переиспользовать ту же разметку в другом сценарии просто нереально.

Совсем другое дело переиспользовать кусок UI, который принимает объект и выдает результатом разметку. Даже если у вас другая конструкция данных в нужном месте, вы ее конвертируете в то, что принимает этот компонент, вставляете его к себе и даете ему этот объект. Как результат, вы полностью уверены, что он порендерит то что вам нужно и это не понесет абсолютно никаких побочных эффектов.

Тоже самое с управлением состояния. У вас есть функция, которая обновляет строчку в таблице. Допустим она делает внутри себя что-то еще, нормализует таблицу каким либо образом, сортирует ее и т.п. Что стоит вызвать чистую функцию из другого сценария? Вы опять уверены, что никаких побочных эффектов это не понесет.

Переиспользовать же сценарий целиком становится нельзя. В силу того, что есть полный decoupling состояния представления. И точки входа для такого переиспользования придется продублировать, но основное «мясо» в таких точках входах может и должно быть переиспользовано.

К сожалению нет добра и без худа, Redux потерял одно важное ограничение. Как я уже говорил, «зацикливание», может быть инициировано только пользователем, и в FLUX было реализовано такое ограничение — в обработчике подписки на изменение состояния нельзя бросать новый Action, стор на это бросает исключение. Тоесть UI компонент, подписанный на стор, в обработчике этой подписки не имеет права сделать дополнительное изменение в состоянии. Только рендер, только хардкор. В redux это исключение убрали.

Пример некорректного использования Redux и UDF


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

1) Логика находится непосредственно в UI компонентах, UI компонент решает сам какие данные ему загрузить
2) UI компоненты наследуются друг от друга (sick!) и еще от каких то классов.
3) UI компоненты имеют название *Adapter (что дальше, *Injector, *Factory?)
4) UI компонент «знает», где в дереве состояния лежат «свои» данные и данные других компонентов
5) UI компоненты самостоятельно подписываются на часть состояния, результатом обработки бросают новые Action, для изменения «своего» состояния, зацикливая архитектуру в которой заведомо этих зацикливаний быть не должно

Мотивацией этого дизайна было желание сделать единственную точку переиспользования. Причем эта точка, естественно, в UI слое, прямо в разметке. Звучит это примерно так — я вставляю UI компонент, автоматически подцепляется (да именно такие слова я все время слышу из соседних кубиков, подцепляется, инжектится, подписывается, пробрасывается, code-smell ощущается уже дистанционно, на слух) его состояние, компонент подписывается на стейт соседних компонентов, которые для него производят входные данные, данные от которых он зависит, сам получает данные с бакенда как для первоначальной так и ленивой загрузки. И когда в этом компоненте, использованном в трех приложениях, вдруг меняется метод api, либо нужно в этот компонент дорисовать новую логику, то мы допишем это в компонент, и это соответственно применяется сразу в трех местах.

Что при этом делать, если в одном из трех мест другой источник данных, либо другая логика обработки где то в середине этого компонента — этого такое архитектурное решение не подсказывает. Как говорится — ну сделай что нибудь, тыжпрограммист. Очевидно нужно залезть вовнутрь, написать новую логику, и воткнуть if, который включит ее для 3го сценария и выключит для первых двух.

Граф распространения изменений в этом приложении выглядит вот так:



Получилось так, что люди использовали в своем приложении redux, получили его отрицательные стороны, в частности «шумный» синтаксис, который на любой чих предполагает изменение в 4х файлах, но не получили плюсов которые он несет.

Если попытаться изобразить граф распространения изменений, то нетрудно увидеть, что он очень похож на граф, который был использован как антипример в презентации Facebook.

Итого:

1) Нарушена архитектура UDF, а redux предполагается для реализации именно ее, а не как fancy pub-sub.
2) Жесткий coupling разметки с состоянием
3) Нарушен принцип SRP — логика в слое представления
4) Нарушен здравый смысл — именование/предназначение компонентов слоя представления.

Сравнение с реактивным программированием


Есть еще одна концепция, годная для создания UI приложений, которая также «лучше чем обычный порошок». И за нее, а также за фреймворки ее реализующие, есть немало агитаторов.
Она предполагает такое взаимодествие между моделями, при которых когда вы меняете какую то сущность, вам не надо заботиться о зависимых сущностях, они сами обнаружат, что она изменилась и пересчитают себя.

Псеводокод буду писать на примере фреймворка knockoutJS.

У нас есть tableVm, в ней есть поле table, и функция, которая эту таблицу позволяет обновлять

tableVm.table = ko.observable(someInitialData);
tableVm.updateTable = function(input) {
  const current = tableVm.table();
  const updated = ... // some code here
  tableVm.table(updated);
}

Далее есть код в другой(иногда в той же) View-Model-и

tableTotalVm.total = ko.computed(() => tableVm.table().map(...).sum());

В третьей VM следующий код

submitVm.canProceed = ko.computed(() => tableTotalVm.total() === 100);

Нетрудно увидеть что мы, что называется — «back to square one», пришли к тому, от чего UDF сознательно уходила — один кусочек кода подписан на другой, второй подписан на третий и т.д. Рано или поздно приходим к большому и неконтролируемому графу распространения изменений.

Кроме того предполагается и обратное распространение изменений.

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

lowerVm.index = ko.observable(indexLoadedFromApi);

upperVm.index = ko.computed(
() => lowerVm.index() + 1), 
(newVal) => lowerVm.index(newVal - 1)
);

И когда UI меняет верхнее значение в такой системе, computed продвигает это изменение в нижнюю модель. Т.к. изменилась нижняя модель, то нужно опять пересчитать верхнюю модель, и соответсвенно обновить UI. Тут еще может возникнуть такая ситуация — когда сверху вы положили одно значение, а из этой круговерти обновлений пересчиталось другое, из-за ошибки в коде, либо из-за нечеткой логики. В таком случае во фреймворке circular dependency detection механизм может некорректно отработать и вы либо «зависнете», либо получите исключение в совершенно непонятном месте, с ничего не говорящим сообщением об ошибке, а то и вовсе stack overflow.

Да и сам факт наличия подобного механизма как бы сообщает, что есть опасность создать зацикленные зависимости. А еще иногда циклические зависимости диктуются бизнес требованиями. Представьте себе игру вроде Civilization где три ползунка, наука, культура и производство, сумма их всегда должна быть 100%, вы крутите один вверх, другие два автоматически пропорционально едут вниз. Попробуйте реализовать что-то такое с помощью трех computed. Ну или, например симулятор жизни. Окажется, что это будет не тысяча computed зависящих друг от друга, а один observable, в котором будет вся модель всего приложения. И возникает вопрос, а нужен ли нам этот один observable в приложении.

Вот здесь есть мнение автора, который выступает за реактивное программирование, не совсем такое, как в knockout, но есть подозрение, что симптоматика там ровно те же. И еще автор радостно выставляет UDF «в плохом свете», со смайликами. Что очень наглядно показывает что иногда даже люди считающие себя экспертами в написании UI приложений, вероятно даже заслуженно считающие, эту архитектуру совершенно не понимают. Она и правда непроста для понимания, ибо ломает все к чему за долгие годы привыкли.

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



Тут я нарисовал как при каком либо действии пользователя во view1, изменения распространяются последовательно в разные computed, затем в некоторые observable, и видно, что computed5 будет пересчитан дважды, и view3 также будет обновлена дважды. Мы в нашем knockout приложении также однажды напоролись на такой эффект, где один computed пересчитывался N раз, по количеству элементов массива. Заявленое громкое преимущество, что knockout обновляет лишь то, что нужно, и его реактивная модель этим несет в мир производительность, в этот момент с треском лопнуло.

Приложение с UDF


Когда я изучал примеры приложения на react/redux, меня не покидало ощущение, что проработаны оно не до конца. В частности cамим автором redux, Деном Абрамовым предлагается непосредственно в UI компонентах, в тех же файлах писать т.н. dispatchToProps функции, которые навешивают на props ui компонентов следующее

(dispatch) => {
    return {
        someMethod: (someData) => {
            dispatch({type: Actions.someAction, someData });
        }
    }
};

Иногда эти методы становятся более сложными, их наполнение расширяется, в том числе появляются асинхронные операции

(dispatch) => {
    return {
        async someMethod: (someId) => {
            dispatch({type: Actions.startedLoadingAction });
            const someData = await api.loadSomeData(someId);
            dispatch({type: Actions.someAction, someData });
        }
    }
};

Нетрудно увидеть, что это бизнес логика. А я долго распинался на тему того, что в UI слое бизнес-логике не место. Кроме того эта логика, располагающаяся во втором шаге конвейера, влияет на первый, что нарушает уже саму архитектуру UDF. То что так делать нельзя, стало очевидно сразу, на как же делать можно и нужно? Через какое-то время, после изысканий, проб и ошибок, ответ пришел. Причем очевидность этого ответа поражает своей простотой — код который влияет на N-ный шаг конвейера должен находиться на шаге конвейера N-1. Кроме того, заявление о том, что асинхронный код должен быть сдвинут к краю приложения, обретает также и уточнение к какому именно краю. Внезапно это тот край, где backend, связь с которым и имеет асинхронную природу. Паззл сложился, и даже носки совпали цветом. Нужно создать еще один шаг конвейера, сервисный и поместить его перед шагом управляющим состоянием.
Общая диаграмма получилась следующая.



В момент входа на страницу/приложение нам нужно создать стор, после чего вызвать первый входной метод в конвейере. Я буду далее называть это методом-раздражителем. А также оформить единственную подписку UI на стор. Метод раздражитель вызовом соответствующего Action-а инициализирует состояние в положение «индикатор прогресса», далее соответственно срабатывает подписка UI и отрисовывает то, как это выглядит.

Метод раздражитель же следующим действием вызывает асинхронное действие на загрузку изначальных данных. Когда приходит ответ с сервера, происходит следующий проход по конвейеру, с еще одним рендером.

В разметке же располагаются подобные вещи

<button onClick={() => service.userClickedApply()} >батон</button>

таким образом в ui-слое отсутствует бизнес логика, присутствует лишь указание, какой метод-раздражитель нужно дернуть при соответствующем действии пользователя.

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

Взаимодействие первого шага конвейера со вторым.




Название сервисов соответствуют названиям UI компонентов, сочиненных специально для этого примера. Страница декомпозирована на левую панель, правую панель и футер, на левой панели и футере тоже предполагается размещать какие нибудь компоненты, на правой панели расположены Payments и UserData. Надо обратить внимание, что соответствующие сервисы и соответствующие состояния UI компонентам не принадлежат, а соответствуют, и имеют тот формат, который эти компоненты принимают на вход.

Если пользовательский ввод касается конкретного компонента, т.е. по требованиям этот пользовательский ввод больше ничего на экране не затрагивает, то вызывается сервис, соответствующий этому конкретному компоненту, его метод создает Action, лежащий рядом с определением его состояния, т.е. интерфейсом (подразумевается тайпскрипт), и собственно редюсером. Непосредственной связи с редюсером этого компонента у сервиса естественно нет, поэтому стрелочка пунктирная. Action подается в центральную точку входа, в стор.

Более интересен случай, когда требование подразумевает что-то изменить в состоянии сразу двух компонентов, к примеру в UserData компоненте человек нажал кнопку «показать платежи за прошлый год» и должна обновиться таблица payments, а на компоненте UserData должно измениться поле «итого». В этом случае должен вызваться сервис соответствующий правой панели, бросить соответствующий Action, и редюсер правой панели должен совершить соответствующие действия над своим состоянием и полями своего состояния, вызвав соответствующие чистые функции. Также как в примере, который я приводил в мотивации архитектуры. Таким же образом, если нам нужно обновить много всего на странице, например если выбран другой пользователь, то, перезагрузив все данные, в сервисе страницы нам нужно вызывать Action уровня редюсера страницы, который обновит сразу всё. Альтернативно, если к примеру данные для левой и правой панели могут загружаться раздельно, показывая отдельные индикаторы прогресса, то из сервиса страницы допускается вызвать соответствующие методы сервиса левой панели и правой. Переигрывать это действие можно как угодно, можно комбинировать эти запросы в параллель, можно последовательно, можно независимо, как больше подходит под требования.

Третий шаг конвейера


Как я уже говорил, подписка на состояние должна быть одна. В данном примере эта подписка на уровне страницы. На странице предполагается подобный код

import {store} from ...
import {LeftPanel, RightPanel, Footer } from ...
export class Page extends React.Component {
    componentWillMount() {
        store.subscribe(() => this.setState(store.getState()));
    } 

    render() {
        return (
        <div>
            <h1>Title here</h1>
            <LeftPanel state={this.state.leftPanel} />
            <RightPanel state={this.state.rightPanel} />
            <Footer state={this.state.footer />
        </div>
       );
    }
}

Далее предполагается то самое разделение на умные и глупые компоненты, компонент, отвечающий за правую панель — специфичен для именно этой страницы, не предполагается, что он будет вызван где то еще, поэтому можно сделать его умным. То есть он должен знать какие именно раздражители нужно вызвать на конкретные действия пользователя. А компонент UserData по желанию можно сделать глупым. И в одном сценарии один сервис совместно с соответствующим редюсером может управлять его данными в памяти, а в другом сценарии, на другой странице другой сервис будет вызывать асинхронные методы.

Выглядеть это будет примерно так

import {rightPanelService, userDataService} from ...
import {UserData } from ...
export class RightPanel extends React.PureComponent{
  render() {
    const state = this.props.state;
    return (
      <div class="right-panel">
         <UserData state={state.userData}
           onLastYearClicked={() => rightPanelService.lastYearClicked()}
           onUserNameChanged={(userName) => userDataService.userNameChanged(userName)} />
         <Payments state={state.payments} ...etc. />
        </div>
    );
  }
}

Тут можно обратить внимание, что использован PureComponent, это означает, что если при обновлении состояния на странице в этом состоянии поле rightPanel имеет то же значение, если изменения касались только других частей страницы, но не этой, то весь рендеринг правой панели и ниже будет отсечен. В этом присутствует положительное влияние чистого кода редюсеров на производительность рендеринга. Раньше этот PureComponent нужно было либо писать самому, благо его реализация исключительно проста, либо импортировать из еще одной библиотеки автора Redux, а сейчас такой компонент включен непосредственно в реакт. В ангуляре (версии 2, 4, 5...) за это отвечает стратегия обновления компонента. Там тоже можно определить правило, что если ни один из его @Input-ов не поменялся (подразумевается reference equality), то dirty check этого компонента, проводить не нужно, и также из цикла будут исключены все дочерние компоненты в его разметке, то есть можно отсечь всю ветвь рендера, как и в реакте.

Далее собственно компонент UserData

export function UserData({state, onLastYearClicked, onUserNameChanged}) {
return (
  <div class="user-data">
    <input value={state.userName} onChange={(newVal) => onUserNameChanged(newVal)} />
    ...etc.
  </div>
);
}

Экстремальное тестирование


Еще одна мысль посетила меня не так давно. По сути получается, что frontend-приложение в котором отсутствует логика, может функционировать даже без рендера разметки. И если вынести на обсуждение такие факты:

1) вызов браузера — это самая «дорогая» в плане времени операция в behavior-тестах UI слоя.
2) Функциональность именно разметки относительно мала в сравнении с остальным приложением
3) Разметку тестировать дорого в плане затрат разработчика, а устранение найденных багов, таких как заползание одних элементов на другие, и т.п. как правило весьма дешево.
т.е. затраты на тестирование не стоят тех багов, которые они могут выявить.

А что если тестировать такое приложение прямо в nodeJS? Не открывая браузер, не производя никакой рендеринг, создай себе стор прямо в тесте, вызывай сервисные методы, имитируй действия пользователя, вызовом методов-раздражителей, да тестируй данные которые получились в сторе в результате таких операций. Протестировать можно успеть намного больше, поскольку не нужно даже писать код взаимодействия с селениумом (или что вы там используете), и тесты будут ходить в десятки а то и сотни раз быстрее, чем обычные.

Разумеется остается вариант дополнительно проверить запуск приложения в браузере, можно даже зайти по разу на каждую страницу, протестировать как работает роутинг, подписка в корне страниц. И такой тест можно положить туда же где лежит длительный нагрузочный тест, который запускается раз в две недели перед поставкой, ну или overnight. А большинство тестов, которые по прежнему behavior тесты и до уровняю unit-тестов еще не скатились, но уже ходят достаточно быстро, поэтому запускать их можно на каждый коммит, да хоть на любой чих в процессе разработки.

К сожалению, подобный фокус вряд ли получится с ангуляром. Поскольку для функционирования приложения все сервисы будут обернуты в @Injectable, и для запросов на backend будете пользоваться ангулярным сервисом Http. Попытка отказаться от dependency injection механизма плюс использовать «родной» fetch вместо Http и Promise вместо Observable может оказаться провальной, велик риск наткнуться на сценарий не поддерживаемый библиотекой zone.js, например заведомо известно, что не поддерживается async/await.

Напоследок


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

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (6):

  1. mayorovp
    /#10757068

    Тут не вполне корректно противопоставляются Knockout и redux. Да, у Knockout есть свои проблемы — но они никак не связаны с UDF. При желании, построить UDF можно как на Knockout, так и на redux, и это будет одинаково просто. А при отсутствии такого желания — можно и там и там все запутать…

    Например, можно попробовать вызывать dispatch из редусеров, потом гадать куда пропал такой хороший UDF :-)

    • VladVR
      /#10758120

      1. Противопоставляется реактивный стиль программирования и чистый код
      2. Да, даже подскажу как, весь стейт приложения в одном observable, меняем его только чистой функцией, в разметке никаких двусторонних байндингов, вуаля.
      3. Требование к редюсеру — он должен быть чистой функцией, dispatch чистой функцией не является, так делать нельзя как минимум по этой причине, а не только потому что это плохая идея в целом.

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

      • mayorovp
        /#10758294

        Вы как-то сильно радикально подошли к задаче. Ни дополнительные observable, ни двусторонние биндинги никак не мешают UDF.

        Достаточно

        1) не кидать исключений,
        2) использовать pureComputed вместо computed.

        И да, не надо противопоставлять реактивный стиль программирования и чистый код, потому что они вообще-то лучшие друзья.

        • VladVR
          /#10759266

          Во первых двунаправленные байндинги в однонаправленной архитектуре — должно насторожить само по себе. Тут это не игра слов, а буквально не подходят по определению.
          А во вторых, т.н pureComputed по определению не являются чистым кодом, в силу недетерминированности.
          Функционально-реактивный стиль да, с чистым кодом в обнимку, только к нокауту это не имеет никакого отношения.

          • mayorovp
            /#10759292

            Прекрасно они подходят. У любого computed или pureComputed операции вычисления+чтения и записи отделены друг от друга. В итоге если не делать ерунды формируется нормальный ациклический поток данных.

  2. Revertis
    /#10761864

    Растет она пока не достигнет порога при котором любое вносимое изменение гарантированно, т.е. с вероятностью близкой к 100%, внесет ошибку.
    Не согласен. С ростом проекта растет количество разработчиков, которые просматривают пулл-реквесты. С таким подходом вероятность не выше 50%.