автор - gaperton

типы и дизайн

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

Так как юзкейсов бывает много, для архитектурной работы их полезно побить на классы, и выбрать по одному характерному представителю каждого класса, получив минимально-полное множество юзкейсов, которым должен удовлетворять дизайн. Остальные - временно забыть. Почему так: потому, что основная выгода проектирования - в том, что получившаяся структура позволит снизить стоимость последующих правок. Возможно - по заранее неизвестным требованиям, которые появятся в будущем. Если не ставить такой цели - то можно ничего особо не дизайнить, толку один хрен не будет.

Более точное понимание будет, если разделить "дизайн" на три подуровня.

1)Common design principlesсамая дорогая ошибка
2)собственно, designумеренно дорогая ошибка
3)Detailed designдешевая ошибка

Первое проверить впрямую невозможно. Для проверки design principles надо в их терминах что-нить задизайнить, и проверить наложением юзкейсов. Здесь важно не пропустить важного класса юзкейсов.

Второе - подразбиение системы на слабосвязные структурные компоненты, модули, и абстрактные типы данных, в соответствии с common design principles. Вот оно проверяется наложением юзкейсов. Обычно, без получения такой картины очень сложно внятным образом отпараллелить работу на группу людей.

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

Третье - алгоритмы и структуры данных. Если предыдущий пункт сделан правильно - то эта хрень оказывает влияние в основном на производительность. Проверяется вместе с (1) и (2) прототипами proof of the concept.

Так как (1) проверить невозможно, оно разрабатывается и уточняется одновременно с (2). Иногда невозможно сделать (1) и (2) без (3). Точность ранней прикидки сильно зависит от двух ортогональных вещей.

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

Стоимость правок зависит не от объема, а от степени связности куска кода, в который вносится правка, с другими подсистемами. Это можно назвать "хрупкостью" кода. А объем кода отлично кореллирует с временем его написания и количеством внесенных в него ошибок. При этом - в целом чем больше времени тратится на "дизайн", тем меньше получается объем кода, со всеми вытекающими. Но на "дизайн" время-то тоже тратится при этом.


автор - tonsky

UI dev

Я расскажу о построении веб-приложений со сложным интерфейсом. Под сложными я понимаю интерактивные страницы и приложения, у которых на экране одновременно развивается более одного сценария.

В чем проблема сложных интерфейсов? Их сложность растет нелинейно относительно количества фич.

Подход «разделяй и властвуй» работает плохо, потому что человек ожидает от интерфейса адекватности, контекстности, уместности — а это значит, что фичи начинают переплетаться между собой и влиять друг на друга. Каждый новый компонент влияет на предыдущие, и предыдущие нетривиально связываются с новым. Для неинтерактивного приложения это еще как-то можно учесть — как минимум, условия игры фиксированы на момент рендеринга страницы. Но у нас же приложение! Новые товары могут прилететь на страницу по аяксу. Пользователь может накликать «никогда не показывать мне это, это и это». Через 5 минут страница ничего общего не будет иметь с тем, как она выглядела на момент загрузки. И все это надо адекватно отрисовывать.

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

Так и получается, что там, где в бэкенде — три поля в табличке и сгенерированные фреймворком заглушки, на фронте — 3000+ строк тайного знания.

Ликбез по ФП

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

Чтобы использовать чистые функции, нужны

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

Преимущества неизменяемых структур:

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

Наивная реализация иммутабельных коллекций (даже copy-on-write) была бы жутко неэффективной, поэтому используются оптимизации, в частности, структурное переиспользование. Все структуры реализованы как деревья, и при изменении одного из листов пересоздается только одна ветка, а все остальные используются напрямую по ссылкам из старого дерева. Такие структуры называются персистентыми (просто термин, ничего общего с сохранением на диск).

Естественно, это не даром. Скорость ниже. Если совсем грубо, то на небольших объемах, на типовых операциях (добавить элемент, поискать элемент) персистентные структуры примерно в 2 раза дороже.

Впрочем, есть и обратная сторона. Некоторые задачи решаются эффективнее: например, создать большой vector (10k элементов) уже на 30% быстрее, чем js array, потому что реаллокаций меньше

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

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

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

Давайте представим, что DOM-дерево неизменяемо. Тогда все переходы между состояниями можно выразить чистыми функциями. Уже неплохо, потому что дает как минимум тестируемость — захотели посмотреть, как прячется окошко, создали DOM с окошком, вызвали DOM2 = hideWindow(DOM), проверили что в DOM2 окошка уже не видно.

У этого подхода есть проблема нелинейного роста сложности. Если у нас N состояний интерфейса, нужно реализовать N² переходов. Проблема решается выделением модели:

Мы реализуем (чистыми функциями, разумеется) только render: Model → DOM (плюс переход между моделями, но он, как правило, принципиально проще), а конверсию DOM → DOM2 берет на себя библиотека. Вместо развесистых и сложных мутаций DOM мы пишем одну функцию, которая вычисляет DOM с нуля, хоть каждый кадр, на основе только входных данных — в данном случае, модели. Самое важное, что такой подход снимает головную боль по мутации DOM (превращая квадратичную зависимость по количеству рендер-кода в линейную) и открывает возможности для высокоуровневых оптимизаций.

Ленивость. Куски нового дерева можно не вычислять, если вычислены куски старого (мемоизация!), их рендеринг это чистая функция и входные параметры не изменились. Иметь чистую функцию рендеринга удобно и по другой причине: можно внезапно показать любую модель. Это означает, что что бы мы ни подсунули рендерингу, он это отрисует. Сильно упрощает:

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

Теперь давайте посмотрим, что получится, если сделать модель иммутабельной?

Впервые идею сделать модель персистентной предложил David Nolen в библиотеке Om. Он заметил, что таким образом мы получаем почти даром две вещи: undo/redo стек и эффективную ленивость. Чтобы сделать undo/redo, мы просто сохраняем ссылки на все состояния модели. Поскольку они иммутабельные, а функция рендеринга чистая, мы можем на любую модель в любой момент переключиться.

Как вы помните, в иммутабельных структурах данных нельзя сделать inplace модификацию, а значит, чтобы определить, были ли изменения, не нужно делать глубокое сравнение. Это позволяет быстро реализовать ComponentUpdate простым сравнением ссылок и эффективно отсекать целые ветки компонентов, если мы видим, что аргумент рендер-функции не поменялся.

Итак, давайте взглянем на архитектуру еще раз:

Рендеринг при этом ленивый, поскольку он сравнивает иммутабельные модели между собой, он может очень эффективно отсечь целые куски DOM-дерева, просто сравнив указатели на данные, от которых они зависят. Дальше можно с этой архитектурой делать разные вариации, насколько фантазия позволяет. Можно сериализовать всю модель в строку и переслать по почте. Потом вставить ее и получить в точности то же состояние интерфейса. Можно локально кешировать модель в localStorage и рендерить ее сразу, параллельно ходить на сервер за свежей, по приходу переключаться.

С сохраненной историей модели, помимо undo/redo, можно работать как с источником данных. Можно делать по ней запросы, например: поле ввода автодополняет то, что пользователь в него уже вводил. Можно синхронизировать историю на сервер — тогда при загрузке страницы вы сможете отменить последнее действие, которое сделали, может быть, два года назад.

Лог событий делится на две части: подтвержденный сервером и локальный оптимистичный. Приложение хранит две модели: насчитанную только по подтвержденному сервером логу, и текущую оптимистичную модель. Когда пользователь что-то нажимает и генерирует таким образом событие, оно сразу же попадает в оптимистичный лог и обновляет локальную модель. Эта модель используется для рендеринга интерфейса, на все действия пользователь получает немедленную обратную связь, даже если соединения с сервером нет. Параллельно фоновый процесс пытается синхронизировать локальные события с сервером. Когда это удается, сервер присылает какой-то ответ. Вне зависимости от содержимого, локальная модель выбрасывается, и события применяются поверх последней подтвержденной модели. В простейшем случае сервер просто подтвердит все события пользователя, таким образом мы получим на сервере в точности такую же модель, как та, на которую смотрит пользователь. С точки зрения интерфейса ничего не произойдет.

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

Elm использует иммутабельность для реализации фич еще более фантастических: у них есть time-travelling debugger. Если ты видишь, что что-то сломалось, не беда — можно отмотать назад и посмотреть, как оно там было. Можно даже поменять код в середине дебага и посмотреть, как оно бы сложилось, если бы он сразу был такой. Это возможно благодаря простым вещам, о которых мы только что говорили: чистоте, иммутабельности состояния и сохранения истории всех событий. Если вы именно так построили архитектуру приложения, вы можете и у себя собрать подобное.

Более-менее практических реализаций описанной архитектуры я знаю две.

ClojureScript

диалект Clojure с компиляцией в JS. Описанная архитектура реализуется в ClojureScript на React-based фреймворках Om, Quiescent, Reagent, Rum и на freactive со своим virtual dom. Состояние все они хранят в персистентных структурах данных, кастомных или встроенных.

Elm

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

JS

Наконец, все то же самое можно делать и на простом js. Ребята из React команды подхватили идею Om и сделали библиотеку immutable.js с персистентными структурами — теперь все то же самое можно собрать и на vanilla.js (естественно, со всеми недостатками джаваскрипта

* * *

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

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

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

Чистыми (pure) называются функции, не производящие побочных эффектов: печати, изменения состояния, сетевых запросов. Как правило, чистота фиксируется на уровне соглашений, но бывает и Хаскель. Такие функции безопасно вызывать как угодно, откуда угодно и сколько угодно раз.

Смежным является понятие ссылочной прозрачности (referential transparency) — это функции, не зависящие от глобального состояния, только от собственных аргументов. Результат таких функций можно безопасно кэшировать. Чистые ссылочно-прозрачные функции упрощают чтение кода (наглядно, откуда что пришло и куда ушло) и юнит-тестирование (не нужна установка окружения).

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

ООП приучило нас хранить данные и код вместе, и мало кто задался вопросом: «не грешновато ли?». На самом деле программы всегда в первую очередь про данные: они берут что-то одно (семь) и превращают это в что-то другое (сорок два). Иметь данные максимально открытыми, доступными, универсальными удобно — к ним можно применять много разных вещей, не только то, что предусмотрел автор программы. Программа может быть даже не всегда запущена, но данные от этого никуда не деваются.

Иммутабельность (immutability) знакома многим по строкам: однажды созданную строку нельзя поменять, но можно создать новую, например, конкатенацией. Со старой при этом ничего не происходит, она все так же доступна (исключение — C/C++, но эти ребята любят, чтобы было трудно). Такой же подход можно распространить на коллекции: списки, словари, множества, структуры. В иммутабельный список нельзя добавить элемент, но можно создать новый список, в котором на один элемент больше. Естественно, иммутабельные структуры дороже в использовании, но не летально. Хорошие реализации (persistent data structures) переиспользуют части состояния «предыдущих» объектов так, что накладные расходы получаются небольшими

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

Ленивость (lazyness) это способ отложить вычисления до востребования. Вместо ответа функция может вернуть рецепт вычисления ответа, в надежде, что вызывающая сторона сама им распорядится. Используется в основном для оптимизации вычислений.

Традиционный веб-интерфейс это одно большое, сложное, глобальное мутабельное дерево DOM. Пока все думают, что приложение работает, оно меняет это дерево сразу, бесконтрольно и со всех сторон. Примерно такие вещи видят функциональные программисты в своих кошмарах: холодный пот, леденящий ужас, крик посреди ночи, element.appendChild()

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

Теперь примемся за сложность. Если у нас N состояний DOM-дерева, нам надо написать N² функций, переводящих каждое состояние в каждое. Если нужно добавить новое состояние, нужно написать N функций перехода в него и N функций перехода из него. В реальности паутинка будет пореже, но характер зависимости все равно нелинейный.

В жизни такое редко бывает, а в программировании вот случилось: нас спасает модель. Мы можем свести наше приложение к функции, переводящей модель в DOM. Из разных моделей получается разные деревья. Вся логика реализуется на уровне переходов между моделями. Переходом между DOM-деревьями можно нагрузить библиотеку. Это придает какую-никакую структуру и существенно снижает количество кода, работающего с DOM: до N вместо N²

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

До этого момента я рассказывал о широкоизвестных вещах. Примерно так работает React и другие virtualdom фреймворки. Но даже тут уже цветет функциональное программирование: иммутабельный DOM (на самом деле он разовый, то есть выкидывается сразу после генерации, что делает его эффективно иммутабельным), функция рендеринга чистая (React-у нужна свобода решать, где, когда и сколько раз ее звать), глобальное состояние не используется. И это мы только начали. Давайте пойдем до конца и объявим иммутабельной и модель тоже

Теперь логика приложения тоже выражается чистой функцией: переход между состояниями берет старую модель и генерирует на ее основании новую. Иммутабельная модель дает ключ к ленивому рендерингу. Иммутабельные структуры нельзя незаметно поменять глубоко внутри: надо пойти вглубь, поменять там и все обратно аккуратно перепаковать. Поэтому можно очень быстро определить, грязная модель или чистая: достаточно сравнить ссылки. Поскольку модель редко меняется целиком, а компоненты приложения зависят от разных частей модели, можно быстро понять, какие куски надо перерисовать, а какие не изменились. Эта оптимизация (shouldComponentUpdate) выключена в React по-умолчанию и включается только вручную под вашу ответственность. Для иммутабельных аргументов ее можно включить сразу везде. Таким образом рендер становится ленивым: вычисляются только те части DOM, которые интересны, все остальное лежит в виде рецептов и не дергается.

Второй приятный бонус иммутабельной модели это хранение истории. Ссылки на старых моделей остаются валидными, и если мы будем хранить их в каком-нибудь списке, можно будет бегать туда-сюда по истории.

Взглянем на архитектуру целиком: Приложение состоит из одной текущей модели, стека истории моделей и функции рендеринга, переводящей модель в DOM. Поскольку рендеру все равно, откуда пришла модель, легко сделать превью предыдущих состояний истории и отмену по Ctrl+Z. Более того, поскольку рендеру действительно все равно, можно дополнить архитектуру спекулятивной моделью: все превьюшки, незаконченные операции в процессе, неприменённые настройки можно рендерить, просто вычислив, как выглядела бы модель, если бы. Такая модель нигде не сохраняется, вычисляется на лету и просто разово передается рендеру. История наших моделей — это голые данные, соответственно, мы можем строить по ним запросы.

Красота модели отдельно еще и в том, как изящно разбивается архитектура на слабосвязанные части. Рендеринг ничего не знает о синхронизации с сервером, сервер ничего не знает о локальном кешировании, и так далее. Модель можно отправить работать на сервере, если, например, откусить от нее рендер. Каждому из них глубоко все равно, откуда и как появилась модель и кто еще на нее смотрит. Это качество, за которое стоит бороться.

Другой пример, когда иммутабельность может быть полезна: совместная работа с документом. В данной архитектуре используется Event Sourcing: модель это результат свертки (fold) всех произошедших с ней событий (тоже, кстати, функциональный концепт): Мы храним два лога событий: локальный и подтвержденный сервером. Все локальные события сначала пишутся в локальный лог. На его основании вычисляется локальная модель и рисуется интерфейс. Это обеспечивает мгновенную обратную связь и работу без интернета. За кадром, параллельно и независимо, локальный лог пытается синхронизироваться с сервером. Если сервер подтвердил события, локальный лог выкидывается, а подтвержденные события добавляются в подтвержденную часть и применяются к подтвержденной модели. Это обеспечивает идентичность лога на всех клиентах. Уведомление о событиях коллег получается автоматически: сервер просто пушит их сразу в подтвержденный лог. В этой архитектуре важно иметь возможность хранить предыдущие снэпшоты модели и уметь перевычислять по ним более свежее состояние, ничего при этом не разрушая.

Еще более сумасшедшая штука: отладка с путешествиями во времени: Это тоже своего рода event sourcing, только события здесь записываются на самом раннем, низком уровне — все внешние источники (ввод пользователя, таймеры, сеть) — и только после этого попадают в код приложения. Такое разделение на (очень сырые) данные и код позволяет сериализовать, переслать и воспроизвести сессию работы с приложением. Что более важно, можно код изменить и посмотреть, как бы выглядела сессия с новым кодом. Такой подход требует серьезной дисциплины (или технологии, которая ее обеспечит), но и выигрыш почти неслыханный для обычных приложений. Может пригодиться в разработке и техподдержке: получил вместе с багом лог событий, поправил, проверил что баг не повторяется.

Итак, что нужно, чтобы сделать то же самое?

Обязательно понадобятся реализации Virtual DOM

* * *

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