Привет! Меня зовут Артём Арутюнян, много где меня можно встретить под ником artalar. 10 лет я разрабатываю крупные веб-сервисы, и вот уже четыре года менеджер состояния, исследуя тему реактивности, консистентности данных и состояния. А началось всё с простого вопроса: почему React, самая популярная современная библиотека для написания UI, по умолчанию полностью рушит приложение и показывает белый экран при появлении ошибки в любом компоненте во время рендера?
В данной статье хотелось бы описать и раскрыть формальную сторону вопроса «что такое состояние» для лучшего понимания фундаментальных основ надёжности любого клиентского приложения. Хочется уже поставить точки в некоторых вопросах терминологии, чтобы чётче отвечать на вопросы «что, когда и зачем брать» при выборе библиотек и технологий для клиентского веб-приложения.
Примеров будет немного, т. к. их достаточное описание превратило бы статью в небольшую книжку. Но надеюсь, что сами идеи всё же будут понятны.
Данная статья является расширенной версией её англоязычного варианта, который я написал пару месяцев назад.
Мы пойдём от общего к частному и поэтапно будем декларировать на основе простых понятий более сложные: Информация > Данные > Кеш > Стейт > Транзакция.
Из описаний ниже видно, что некоторые концепции имеют схожие характеристики и названия и берут по чуть-чуть из разных областей: теории конечных автоматов (КА), теории баз данных и ACID в частности. Общая суть управления состояния, которой мы занимаемся в рядовой веб-разработке, в итоге заключается в реализации тех или иных свойств и функций КА и ACID в контексте реактивного программирования.
▍ Information
Информация — это какие-либо сведения, независимо от формы их представления.
Информация просто существует, она не привязана к какому-то носителю, будь то текст, пословица или наскальный рисунок. Это общее слово, означающее наличие каких-то (конкретных или нет) знаний. Управляется каналами связи (интернет, письменность, речь).
▍ Data
Данные — структурированная информация.
Данные — это информация, которая всё ещё может быть не привязана к какому-то носителю (а может быть и привязана: обрабатываться или храниться на нём), но она должна иметь чёткую структуру, выражаемую, например, типами статически типизируемого ЯП или документацией к её источнику.
Менеджер данных — это средство управления (хранения и обработки) определённой информации. Чаще всего это информация определённого типа и семантики — домена. Например, частные случаи менеджера данных: библиотеки для управления формами (Formik, Final-form), роутингом (React-router, Router5), отображением (React, Ember).
▍ Cache
Кэш — это экземпляр данных, привязанный к какому-то носителю и имеющий время жизни.
Последнее условие очень важно — вычислительные ресурсы не бесконечны, и поэтому мы не можем всегда вычислять производные данные по любому запросу заново — нужно сохранять промежуточные результаты. При этом сохранять абсолютно все промежуточные результаты мы не можем, поэтому управление кэшем — это всегда сложная задача баланса между его количеством, временем жизни и теми ресурсами, что он экономит. Обычно управлением кэша занимаются дата-менеджеры с фокусом на доменной области, которая близка к вводу-выводу (IO), т. е. занимается получением или отправкой данных. Например: React-query, RTK Query, SWR, urql, Apollo Client.
▍ State
Состояние — это семантически согласованный кэш.
Подробнее познакомиться с концепцией семантики в языках программирования можно по моей статье или докладу.
О состоянии можно говорить как о данных, связанных каким-то смыслом, хотя часто речь идёт о каком-то конкретном кэше. Например, данные о светофоре содержат информацию о трёх лампочках, их цветах и о том, какая из них включена. Семантика же вытекает из предметной области и является смыслом данных: одновременно может быть включена лишь одна лампочка, а порядок их переключения строго регламентирован. Эта информация описывается не структурой данных, а кодом, поэтому менее явная, хотя не менее важная.
Стоит заметить, что эту информацию можно записать в виде данных, используя концепцию конечных автоматов.
Важная отличительная особенность состояния как явления, которую можно вывести из примера со светофором, заключается в необходимости согласованности данных: мы не можем включить одну лампочку, не выключив другую, иначе мы получим ошибочные данные с их непредсказуемым влиянием на пользователя. Свойство состояния быть всегда согласованным, т. е. содержать не противоречащие друг другу данные, в теории баз данных называется атомарностью.
В теории конечных автоматов считается, что количество возможных состояний должно быть конечно. Это очень красивая теория, которая, при её реализации, позволяет намного проще статически валидировать и тестировать код. Наивная реализация не так сложна, как может показаться, и топорно может быть описана просто как дублированный перебор всех возможных вариантов входящих значений, в чём может помочь статическая типизация, т. е. статическое описание всех этих частных вариантов. Делать абсолютный перебор кажется избыточным или нереализуемым из-за неизвестного количества вариантов входных значений. Можно попытаться найти промежуточное решение и сделать дизайн интерфейсов обработки данных с некоторыми ограничениями, которые будут позволять обрабатывать только специфические данные. В реальной разработке это постоянно происходит, когда абстрактные интерфейсы обрастают доменной специфичностью и предоставляют узкие методы для работы с конкретными наборами данных, вроде уже упомянутых библиотек для управления формами, роутингом, сетевым кэшем.
Проблема специфичных библиотек возникает на границе их совместного использования, когда наличие отдельных очередей подписчиков мешает поддержать атомарность в транзакции или реализовать синхронный батчинг (накопление изменений перед их последующей обработкой).
▍ Transaction
Транзакция — процесс перехода между состояниями. Также является частью теории баз данных и в контексте этой статьи может использоваться как обозначение операции перехода из теории конечных автоматов.
Изменение данных, или переход из одного состояния в другое не может быть абсолютно мгновенным и требует выполнить ряд шагов, в которых может возникнуть ошибка, которая приведёт нас к дилемме — что делать с изменениями из уже выполненных шагов? Это сложный и дискуссионный вопрос, который может иметь разные ответы в разных системах, но базовой лучшей практикой, выработанной в проектировании баз данных, является концепция ACID, пропагандирующая гарантию атомарности системы, о которой упоминалось выше, и означающей недопущение несогласованного состояния (его частичного обновления). Точнее говоря, несогласованные данные могут жить лишь в кратковременной транзакции, которая по своему завершению должна сохранить либо все накопленные данные полностью и гарантированно, либо, при возникновении ошибки в процессе, не применить новые изменения вовсе.
Кто-то скажет, что отбрасывание всех изменений не согласуется с модульными системами, где падение в одном модуле не должно влиять на другие модули. Это верно с точки зрения общей работы приложения, но не с точки зрения транзакции какого-то конкретного процесса, а они (транзакции) только такие и бывают. Т. е. каждая транзакция — это контейнер какой-то логической операции, возможно бизнес-процесса, которая если и затрагивает разные модули системы, то делает это, очевидно, по причинам наличия связей между этими модулями, которые нужно учитывать или, что бы они не попадали в транзакцию, описывать как-то иначе, вне основного контекста менеджера состояния.
Это была основная теория с ключевыми определениями, теперь можно немного углубиться в прикладную область. Обработку транзакции можно реализовать двумя подходами.
В обоих подходах обработки данных в транзакции можно получить серьёзные проблемы при допущении асинхронных транзакций, когда шаг может приостанавливать (например, промисом) транзакцию. Мы не будем разбирать подобные примеры, т. к. это отдельная огромная тема, начать изучение которой можно с того же ACID. Можно лишь заметить, что невозможно придумать универсальных систем разрешения конфликтов асинхронных операций, т. к. в зависимости от решаемой задачи могут потребоваться разные стратегии поведения. Тот же CRDT чаще всего предполагает стратегию приоритета последней по времени операции, которую невозможно применять в финансовом секторе.
▍ Patch
Patch — это накопление изменения в новой структуре данных, которая будет объединена с основным хранилищем данных после успешного завершения транзакции или удалена в случае ошибки (но может быть использована для дебага). Этот подход часто применяется в иммутабельных системах. Вот пример реализации дружественной к пользователю библиотеки.
▍ Immutability
Иммутабельность — подход к изменению данных через их частичное или полное пересоздание. Можно выделить несколько подходов.
- Snapshot / Dump — полный слепок данных.
- Persistent — данные базируются на частичном инкременте предыдущей версии данных, т. е. в новую структуру копируются новые изменённые данные и старые не изменённые.
- Transient — данные имеют свойства неизменяемых данных, но допускают мутации, которые никак не скажутся на обращении к этим данным других источников. Хорошим примером может служить функция reduce, переданный ей колбек исполняется синхронно и больше не вызывается после завершения обхода, поэтому в нём допускается безопасная мутация аккумулятора.
const elementsById = elementsList.reduce((acc, el) => {
// `return { ...acc, [el.id]: el }` избыточно
acc[el.id] = el;
return acc;
}, {});
▍ Rollback
Rollback — это техника накопления информации о том, как обратить сделанные изменения. Т. е. во время транзакции мы мутируем основное хранилище данных, предварительно сохраняя предыдущие значения. При появлении ошибки мы откатываем транзакцию, применяя роллбеки и восстанавливая предыдущие значения. Вот пример реализации.
Есть и обратный откату подход — event sourcing. Он предполагает накапливать не информацию о том, как восстановить предыдущую версию состояния (в этом случае нам нужно идти к нужной версии с конца, поэтапно применяя откат за откатом), а сохранять лог всех входящих событий, из которых происходит вычисление каждой следующей версии состояния. В этом случае, храня начальное состояние (обычно, оно небольшое), можно поэтапно применяя событие из лога получать всё более свежую версию состояния.
Все эти подходы могут иметь разную вычислительную стоимость и с разной сложностью интегрироваться в архитектуру приложения, поэтому нужно взвешенно подходить к выбору какого-то конкретного или сочетать их. Например, делать слепок состояния каждые N событий и чистить лог предыдущих событий, кажется эффективным вариантом в случае необходимости путешествия между не более чем N версиями.
Самое явное практическое применение путешествия между версиями состояния — это time tavel для дебага и тестирования, когда нужно быстро воспроизвести какое-то неявное состояние системы (или до которого сложно / долго дойти «руками») чтобы протестировать следующий переход в новое состояние или вообще как какое-то действие применяется к определённому состоянию.
▍ Persistence
Могут быть и вполне бизнесовые требования, реализация которых аналогична реализации time travel — чаще всего, это сохранение клиентского состояния (или лога событий / откатов) в ПЗУ (это тоже называют термином persistence, который мы разбирали выше) для возможности его восстановления после выключения приложения (закрытия или перезагрузки вкладки в случае web-приложений). Чем сложнее приложение и его клиентское состояние, тем сложнее будет эта задача. Первостепенно, состояние должно быть сериализуемое (serializable) — т. е. иметь возможность быть сконвертировано в бинарное представление, например, для помещения в localStorage. Уже на этом этапе мы понимаем что не можем хранить в состоянии функции, будем иметь сложности при использовании Symbol, Map, Set из ES5 (т.к. они не имеют специфического представления в JSON) и BigInt из ES8. Также сложности будут при наличии циклических зависимостей в объектах, что является распространённым паттерном при использовании мутабельных структур данных, например с MobX. Часть этих проблем решается продвинутыми сериализаторами (которые нужно писать самому или использовать сторонние библиотеки), но если использовать иммутабельные структуры данных в большинстве случаев будет достаточно простых JSON.stringify / JSON.parse. Конечно, стоит учитывать, что у клиента не бесконечное пространство на диске и мы не можем складывать в него тысячи событий или мегабайты слепков / дампов, поэтому хорошей практикой является выделение ключевых мест (частей состояния) для персистентности и их точечная обработка, что обычно также усложняет этот процесс.
Но задача сложнее, чем просто выбор используемой структуры данных и принятие некоторых ограничений. Со временем приложение меняется и структура его состояния тоже, что приводит нас к проблеме — что, если пользователь сохранит состояние в формате одной версии приложения, а загрузит его уже в новой версии приложения, в которой формат состояния будет отличаться — как это обрабатывать и что делать при различиях в частях состояния? Здесь, опять же, могут быть разные стратегии разрешения конфликтов и, скорее всего, они будут зависеть от типа и важности данных, которые находятся в состоянии. Также чтобы это обрабатывать корректно, стоит в состоянии фиксировать версию приложения и, возможно, придерживаться какой-то системы разновидностей версий, вроде semver, и полностью сбрасывать персистентные данные при мажорном обновлении — это самая простая стратегия.
Так как речь идёт о клиентском состоянии, стоит помнить, что путешествия по нему никак не связаны с состоянием бэкенда (базой) и обстоятельства могут так сложиться, что стейт, к которому был произведён откат, может конфликтовать с данными от удалённого сервера, иначе говоря, сайд-эффекты живут отдельной жизнью и это стоит учитывать. Например, если пользователь разлогинился в приложении и токен авторизации был инвалидирован, откатившись назад к какому-то состоянию мы не сможем повторить какую-то операцию из него, потому что любой запрос на бэкенд теперь будет отдавать 401.
▍ Side-effect
Что такое сайд-эффект, тема достаточно спорная. В общем это какое-то вычисление, результат которого влияет на внешний мир. Что такое внешний мир, определить уже сложно, но в моём понимании это что-то неподконтрольное. Я бы выделил интересным свойством внешнего мира — невозможность отката какого-то с ним взаимодействия. И наоборот, если интерфейс какого-то вычисления предоставляет роллбек, то, может быть, это уже не сайд-эффект? Например, является ли возможность использования AbortController для Fetch возможностью отката отправленных изменений? Нет, потому что откат может потребоваться уже после завершения отправки запроса. Но если каждая функция обновления данных на бэкенде у нас сопровождается функцией отмены этого обновления — является ли группа таких функций чистой функцией? Тоже нет, потому что в этом случае нарушается правило идемпотентности — повторимости результата при одинаковых входных значениях. Т. е. если мы вызовем функцию добавления товара в корзину, у нас будет 1 товар в корзине, а если мы вызовем функцию повторно, товаров в корзине будет уже два. Можно было бы сказать что идемпотентность любой функции достигается вызовом роллбека на ней и это может быть правдой, но в реальности он часто является сайд-эффектом, и на это нельзя слепо полагаться (вспомним пример с разлогином — мы не можем откатить эту операцию, это противоречит политикам безопасности). Разделять сайд-эффекты и чистые функции важно, потому что чистые функции дают предсказуемый и переиспользуемый результат, которым мы легко можем управлять.
Логично при совершении какой-то операции стараться сначала выполнять все чистые функции и уже в случае их успеха выполнять сайд-эффекты, потому что при ошибке в чистых функциях мы сможем полностью отбросить результат и недопустить неконсистентное состояние (клиентское или системы в общем — между клиентом и бэкендом), а при ошибке в сайд-эффекте гарантий на недопуск неконсистентности нет. Например, для лайка новости нам нужно: увеличить общий счётчик лайков всех новостей в локальном стейте, отправить запрос на бэкенд (сайд-эффект), переключить статус лайка в локальном стейте. Если перечисленные действия выполнять в описанном порядке и по какой-то причине переключение статуса выкинуло ошибку (например, мы неправильно передали идентификатор записи с новостью, получили из списка новостей undefined и не смогли обратиться к полю статуса), то мы получим неконсистентность: информация на бэкенде обновлена и частично обновлена локально (общий счётчик увеличен) — откатывать всё или оставлять так — всё будет неудачным компромиссом. Но если бы мы сначала произвели работу с локальным состоянием, а лишь потом намеревались сделать сайд-эффекты, то при ошибке в переключении статуса мы бы откатили все его накопленные изменения и могли бы безопасно не вызывать сайд-эффект — никакой неконсистентности.
Ключевая функция менеджера состояния — это наличие двух последовательных очередей — очередь чистых функций — выполнения вычисления (computed / derived) нового состояния и очередь сайд-эффектов — подписок (subscribers / listeners). Наличие двух последовательных очередей гарантирует максимально безопасное исполнение сайд-эффектов, когда весь клиентский стейт уже находится в консистентном состоянии.
Стоит заметить, что из-за невозможности контроля сайд-эффектов хорошей практикой является их вызов с оборачиванием каждой функции в try-catch для их изоляции друг от друга. Но из этого вытекает другая проблема — что делать, если наша транзакция, реализующая какую-то бизнес-логику, проходит через несколько менеджеров? Например, у нас есть данные формы, которые нужно отправить на бэкенд. В этом будут участвовать аж три менеджера: формы (отвечает за заполнение и валидацию полей), сетевого кэша (отвечает за данные и их статусы — загрузка / ошибка) и роутинг (переход на следующую необходимую страницу). При сабмите формы мы валидируем и выгружаем данные её полей и отправляем подписчикам — роутингу и на бэкенд. Но что, если при отправке данных возникнет ошибка (например, проблемы с качеством сетевого соединения) — откатить транзакцию, т. е. восстановить данные формы автоматически уже не получится, потому что ошибка произошла в сайд-эффекте. Но даже если бы ошибка произошла до отправки данных на сервер — при их сериализации, например, или просто из-за ошибки в коде — операция всё равно не могла бы быть отменена, потому что с точки зрения форм-менеджера ошибка произошла в очереди неподконтрольных сайд-эффектов. В итоге мы можем иметь пустую форму, но неотправленные данные — это частая проблема UX, которую можно встретить и на маленьких сайтах, и в больших веб-приложениях.
Сейчас разработчики при использовании различных менеджеров для каждой локальной задачи вручную описывают обработку ошибки для каждой доменной области (имеет свою очередь подписок). Но эту работу можно было бы уменьшить, если бы каждый доменный менеджер строился поверх какой-то единой фундаментальной библиотеки. Эта библиотека не должна предоставлять решения абсолютно всех задач, наоборот, быть настолько простой, чтобы её можно было без проблем использовать для построения каких-то более сложных и специфичных решений. Главная задача такой фундаментальной библиотеки — единая точка управления очередями подписчиков и экземпляром транзакции. Звучит достаточно абстрактно и на деле реализации таких библиотек функционально могут очень отличаться и даже работать под капотом совсем не так, как описано выше. Но, для примера, можно рассмотреть библиотеку redux-saga — она работает с любым eventemiter и предоставляет эффективные и элегантные абстракции для построения конкурентных и отменяемых или переиспользуемых цепочек синхронных и асинхронных операций.
Фундаментальной библиотекой может выступать и часто выступает менеджер view-слоя: React / Vue. Это хороший подход, который позволяет достичь высокой производительности небольшими силами на приложениях маленького и среднего размера. Но стоит учитывать, что подобные библиотеки фокусируются на том, что компонент — это переиспользуемая единица со своим локальным состоянием, и поэтому они плохо справляются с задачей иметь общие компоненты / сервисы, имеющие общедоступное состояние. Чем более интерактивное приложение и чем больше оно содержит промежуточных состояний перехода между фичами / процессами или предоставляет широкие возможности для работы с данными, их асинхронной обработкой (step-by-step формы, например), тем выразительнее, проще и эффективнее будет описывать логику работы приложения в отдельном техническом слое, для управления которого, скорее всего, лучше всего подойдёт менеджер состояния.
Завершая это повествование и подводя итоги, не могу не порекомендовать свою разработку. В этой библиотеке продуманы и собраны все лучшие практики, описанные в этой статье (и даже больше), а её вес всего 2KB. Сейчас идёт активная работа над экосистемой, пакет для работы с сетевым кешем уже достаточно зрелый, в скором времени появятся пакеты для персистенции, форм и ещё куча всего.
Автор: Артём Арутюнян