Восстание государственных машин
Опубликовано: 2022-03-10На дворе уже 2018 год, а бесчисленное количество фронтенд-разработчиков по-прежнему ведут борьбу со сложностью и неподвижностью. Месяц за месяцем они искали святой Грааль: безошибочную архитектуру приложения, которая поможет им создавать быстро и качественно. Я один из таких разработчиков, и я нашел кое-что интересное, что может помочь.
Мы сделали хороший шаг вперед с такими инструментами, как React и Redux. Однако их самих по себе недостаточно в крупномасштабных приложениях. Эта статья познакомит вас с концепцией конечных автоматов в контексте фронтенд-разработки. Вы, вероятно, уже построили несколько из них, даже не подозревая об этом.
Введение в конечные автоматы
Конечный автомат — это математическая модель вычислений. Это абстрактная концепция, согласно которой машина может находиться в разных состояниях, но в данный момент выполняет только одно из них. Существуют различные типы государственных машин. Я считаю, что самая известная из них — машина Тьюринга. Это бесконечный конечный автомат, а это значит, что он может иметь бесчисленное количество состояний. Машина Тьюринга плохо подходит для современной разработки пользовательского интерфейса, потому что в большинстве случаев у нас есть конечное число состояний. Вот почему конечные автоматы, такие как Мили и Мур, имеют больше смысла.
Разница между ними в том, что машина Мура меняет свое состояние только на основе своего предыдущего состояния. К сожалению, у нас есть много внешних факторов, таких как взаимодействие с пользователем и сетевые процессы, а это значит, что машина Мура нам тоже не подходит. Мы ищем машину Мили. Он имеет начальное состояние, а затем переходит в новые состояния на основе ввода и текущего состояния.
Один из самых простых способов проиллюстрировать работу конечного автомата — посмотреть на турникет. Он имеет конечное число состояний: заблокировано и разблокировано. Вот простой график, показывающий нам эти состояния с их возможными входами и переходами.
Исходное состояние турникета заблокировано. Независимо от того, сколько раз мы нажимаем на него, он остается в этом заблокированном состоянии. Однако если мы передаем ему монету, то он переходит в разблокированное состояние. Другая монета в этот момент ничего бы не дала; он все еще будет в разблокированном состоянии. Толчок с другой стороны сработает, и мы сможем пройти. Это действие также переводит компьютер в исходное заблокированное состояние.
Если бы мы хотели реализовать единственную функцию, которая управляет турникетом, мы, вероятно, получили бы два аргумента: текущее состояние и действие. И если вы используете Redux, это, вероятно, звучит для вас знакомо. Это похоже на известную функцию редьюсера, где мы получаем текущее состояние и на основе полезной нагрузки действия решаем, каким будет следующее состояние. Редуктор — это переход в контексте конечных автоматов. На самом деле любое приложение, состояние которого мы можем каким-то образом изменить, можно назвать конечным автоматом. Просто мы реализуем все вручную снова и снова.
Чем конечный автомат лучше?
На работе мы используем Redux, и я им вполне доволен. Однако я начал замечать закономерности, которые мне не нравятся. Под «не нравится» я не имею в виду, что они не работают. Более того, они добавляют сложности и заставляют меня писать больше кода. Мне пришлось заняться побочным проектом, в котором у меня было место для экспериментов, и я решил переосмыслить наши методы разработки React и Redux. Я начал делать заметки о том, что меня беспокоило, и понял, что абстракция конечного автомата действительно решит некоторые из этих проблем. Давайте приступим и посмотрим, как реализовать конечный автомат в JavaScript.
Мы рассмотрим простую проблему. Мы хотим получить данные из внутреннего API и отобразить их пользователю. Самый первый шаг — научиться думать состояниями, а не переходами. Прежде чем мы перейдем к конечным автоматам, мой рабочий процесс для создания такой функции выглядел примерно так:
- Мы отображаем кнопку выборки данных.
- Пользователь нажимает кнопку выборки данных.
- Запустите запрос на серверную часть.
- Получить данные и проанализировать их.
- Покажите это пользователю.
- Или, если есть ошибка, отобразите сообщение об ошибке и покажите кнопку выборки данных, чтобы мы могли снова запустить процесс.
Мы думаем линейно и в основном пытаемся охватить все возможные направления до конечного результата. Один шаг ведет к другому, и мы быстро начинаем разветвлять наш код. Как насчет таких проблем, как двойной щелчок пользователя по кнопке, или пользователь, нажимающий кнопку, пока мы ждем ответа серверной части, или запрос выполняется успешно, но данные повреждены. В этих случаях у нас, вероятно, будут различные флаги, показывающие, что произошло. Наличие флагов означает больше предложений if
и, в более сложных приложениях, больше конфликтов.
Это потому, что мы думаем переходами. Мы сосредоточимся на том, как происходят эти переходы и в каком порядке. Вместо этого было бы намного проще сосредоточиться на различных состояниях приложения. Сколько состояний у нас есть и каковы их возможные входы? Используя тот же пример:
- праздный
В этом состоянии мы отображаем кнопку выборки данных, сидим и ждем. Возможные действия:- нажмите
Когда пользователь нажимает кнопку, мы отправляем запрос на серверную часть, а затем переводим машину в состояние «выборки».
- нажмите
- получение
Запрос в полете, а мы сидим и ждем. Действия:- успех
Данные приходят успешно и не повреждены. Мы каким-то образом используем данные и возвращаемся в состояние «ожидания». - отказ
Если возникает ошибка при выполнении запроса или анализе данных, мы переходим в состояние «ошибка».
- успех
- ошибка
Мы показываем сообщение об ошибке и отображаем кнопку выборки данных. Это состояние принимает одно действие:- повторить попытку
Когда пользователь нажимает кнопку повторной попытки, мы снова запускаем запрос и переводим машину в состояние «выборки».
- повторить попытку
Мы описали примерно те же процессы, но с состояниями и входами.
Это упрощает логику и делает ее более предсказуемой. Это также решает некоторые из проблем, упомянутых выше. Обратите внимание, что пока мы находимся в состоянии «выборки», мы не принимаем никаких кликов. Таким образом, даже если пользователь нажмет кнопку, ничего не произойдет, потому что машина не настроена реагировать на это действие в этом состоянии. Такой подход автоматически устраняет непредсказуемое разветвление логики нашего кода. Это означает, что у нас будет меньше кода для тестирования . Кроме того, некоторые виды тестирования, такие как интеграционное тестирование, могут быть автоматизированы. Подумайте, как бы у нас было действительно четкое представление о том, что делает наше приложение, и мы могли бы создать сценарий, который проходит через определенные состояния и переходы и генерирует утверждения. Эти утверждения могут доказать, что мы достигли всех возможных состояний или проделали определенный путь.
На самом деле записать все возможные состояния проще, чем записать все возможные переходы, потому что мы знаем, какие состояния нам нужны или какие есть. Кстати, в большинстве случаев состояния описывают бизнес-логику нашего приложения, тогда как переходы очень часто вначале неизвестны. Ошибки в нашем программном обеспечении являются результатом действий, выполняемых в неправильном состоянии и/или в неподходящее время. Они оставляют наше приложение в состоянии, о котором мы не знаем, и это ломает нашу программу или заставляет ее вести себя неправильно. Конечно, мы не хотим оказаться в такой ситуации. Конечные автоматы являются хорошими брандмауэрами . Они защищают нас от достижения неизвестных состояний, потому что мы устанавливаем границы того, что и когда может произойти, не говоря явно, как. Концепция конечного автомата очень хорошо сочетается с однонаправленным потоком данных. Вместе они уменьшают сложность кода и проясняют тайну происхождения состояния.
Создание конечного автомата в JavaScript
Хватит разговоров — давайте посмотрим код. Мы будем использовать тот же пример. Основываясь на приведенном выше списке, мы начнем со следующего:
const machine = { 'idle': { click: function () { ... } }, 'fetching': { success: function () { ... }, failure: function () { ... } }, 'error': { 'retry': function () { ... } } }
У нас есть состояния как объекты и их возможные входы как функции. Однако начальное состояние отсутствует. Давайте изменим код выше на это:
const machine = { state: 'idle', transitions: { 'idle': { click: function() { ... } }, 'fetching': { success: function() { ... }, failure: function() { ... } }, 'error': { 'retry': function() { ... } } } }
Как только мы определим все состояния, которые имеют для нас смысл, мы готовы отправить ввод и изменить состояние. Мы сделаем это, используя два вспомогательных метода ниже:
const machine = { dispatch(actionName, ...payload) { const actions = this.transitions[this.state]; const action = this.transitions[this.state][actionName]; if (action) { action.apply(machine, ...payload); } }, changeStateTo(newState) { this.state = newState; }, ... }
Функция dispatch
проверяет, есть ли действие с заданным именем в переходах текущего состояния. Если это так, он запускает его с заданной полезной нагрузкой. Мы также вызываем обработчик action
с machine
в качестве контекста, чтобы мы могли отправлять другие действия с помощью this.dispatch(<action>)
или изменять состояние с помощью this.changeStateTo(<new state>)
.
Следуя пути пользователя из нашего примера, первое действие, которое мы должны отправить, — это click
. Вот как выглядит обработчик этого действия:
transitions: { 'idle': { click: function () { this.changeStateTo('fetching'); service.getData().then( data => { try { this.dispatch('success', JSON.parse(data)); } catch (error) { this.dispatch('failure', error) } }, error => this.dispatch('failure', error) ); } }, ... } machine.dispatch('click');
Сначала мы меняем состояние машины на fetching
. Затем мы инициируем запрос к серверной части. Предположим, у нас есть сервис с методом getData
, который возвращает обещание. Как только проблема разрешена и синтаксический анализ данных прошел успешно, мы отправляем сообщение об success
, если не об failure
.
Все идет нормально. Затем мы должны реализовать success
и failure
действия и входные данные в состоянии fetching
:
transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the data this.changeStateTo('idle'); }, failure: function (error) { this.changeStateTo('error'); } }, ... }
Обратите внимание, как мы освободили наш мозг от необходимости думать о предыдущем процессе. Нас не волнуют клики пользователей или то, что происходит с HTTP-запросом. Мы знаем, что приложение находится в состоянии fetching
, и мы ожидаем именно этих двух действий. Это немного похоже на написание новой логики в изоляции.
Последний бит - это состояние error
. Было бы неплохо, если бы мы предоставили логику повторных попыток, чтобы приложение могло восстанавливаться после сбоя.
transitions: { 'error': { retry: function () { this.changeStateTo('idle'); this.dispatch('click'); } } }
Здесь мы должны продублировать логику, которую мы прописали в обработчике click
. Чтобы избежать этого, мы должны либо определить обработчик как функцию, доступную для обоих действий, либо мы сначала перейдем в состояние idle
, а затем вручную отправим действие click
.
Полный пример рабочего конечного автомата можно найти в моем Codepen.
Управление конечными автоматами с помощью библиотеки
Шаблон конечного автомата работает независимо от того, используем ли мы React, Vue или Angular. Как мы видели в предыдущем разделе, мы можем легко реализовать конечный автомат без особых проблем. Однако иногда библиотека обеспечивает большую гибкость. Некоторые из хороших — Machina.js и XState. Однако в этой статье мы поговорим о Stent, моей Redux-подобной библиотеке, в которой реализована концепция конечных автоматов.
Stent — это реализация контейнера конечных автоматов. Он следует некоторым идеям проектов Redux и Redux-Saga, но обеспечивает, на мой взгляд, более простые процессы без шаблонов. Он разработан с использованием readme-driven development, и я буквально потратил недели только на дизайн API. Поскольку я писал библиотеку, у меня была возможность исправить проблемы, с которыми я столкнулся при использовании архитектур Redux и Flux.
Создание машин
В большинстве случаев наши приложения охватывают несколько доменов. Мы не можем работать только с одной машиной. Итак, Стент позволяет создавать множество машин:
import { Machine } from 'stent'; const machineA = Machine.create('A', { state: ..., transitions: ... }); const machineB = Machine.create('B', { state: ..., transitions: ... });
Позже мы можем получить доступ к этим машинам с помощью метода Machine.get
:
const machineA = Machine.get('A'); const machineB = Machine.get('B');
Подключение машин к логике рендеринга
В моем случае рендеринг выполняется через React, но мы можем использовать любую другую библиотеку. Это сводится к запуску обратного вызова, в котором мы запускаем рендеринг. Одной из первых функций, над которой я работал, была функция connect
:
import { connect } from 'stent/lib/helpers'; Machine.create('MachineA', ...); Machine.create('MachineB', ...); connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { ... rendering here });
Мы говорим, какие машины важны для нас и даем их имена. Обратный вызов, который мы передаем в map
, запускается сначала один раз, а затем каждый раз, когда изменяется состояние некоторых машин. Здесь мы запускаем рендеринг. На данный момент у нас есть прямой доступ к подключенным машинам, поэтому мы можем получить текущее состояние и методы. Есть также mapOnce
для однократного запуска обратного вызова и mapSilent
для пропуска начального выполнения.
Для удобства экспортируется хелпер специально для интеграции с React. Это действительно похоже на connect(mapStateToProps)
Redux.
import React from 'react'; import { connect } from 'stent/lib/react'; class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... } } // MachineA and MachineB are machines defined // using Machine.create function export default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });
Стент запускает наш обратный вызов сопоставления и ожидает получить объект — объект, который отправляется в качестве props
нашему компоненту React.
Что такое состояние в контексте стента?
До сих пор нашим состоянием были простые строки. К сожалению, в реальном мире нам приходится хранить в состоянии не только строку. Вот почему состояние Стента на самом деле является объектом со свойствами внутри. Единственное зарезервированное свойство — name
. Все остальное — это данные конкретного приложения. Например:
{ name: 'idle' } { name: 'fetching', todos: [] } { name: 'forward', speed: 120, gear: 4 }
Мой опыт со Stent до сих пор показывает мне, что если объект состояния станет больше, нам, вероятно, понадобится другая машина, которая обрабатывает эти дополнительные свойства. Идентификация различных состояний занимает некоторое время, но я считаю, что это большой шаг вперед в написании более управляемых приложений. Это немного похоже на предсказание будущего и рисование рамок возможных действий.
Работа с машиной состояний
Как и в примере в начале, мы должны определить возможные (конечные) состояния нашей машины и описать возможные входы:
import { Machine } from 'stent'; const machine = Machine.create('sprinter', { state: { name: 'idle' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } } });
У нас есть начальное состояние, idle
, которое принимает действие run
. Как только машина находится в running
состоянии, мы можем запустить действие stop
, которое возвращает нас в состояние idle
.
Вы, вероятно, помните хелперы dispatch
и changeStateTo
из нашей предыдущей реализации. Эта библиотека обеспечивает ту же логику, но она скрыта внутри, и нам не нужно об этом думать. Для удобства на основе свойства transitions
Stent генерирует следующее:
- вспомогательные методы для проверки того, находится ли машина в определенном состоянии — состояние
idle
создает методisIdle()
, тогда как дляrunning
у нас естьisRunning()
; - вспомогательные методы для отправки действий:
runPlease()
иstopNow()
.
Итак, в приведенном выше примере мы можем использовать это:
machine.isIdle(); // boolean machine.isRunning(); // boolean machine.runPlease(); // fires action machine.stopNow(); // fires action
Комбинируя автоматически сгенерированные методы с служебной функцией connect
, мы можем замкнуть круг. Взаимодействие с пользователем запускает машинный ввод и действие, которое обновляет состояние. Из-за этого обновления функция сопоставления, переданная для connect
, запускается, и мы получаем информацию об изменении состояния. Затем мы перерисовываем.
Обработчики ввода и действий
Вероятно, наиболее важным моментом являются обработчики действий. Это место, где мы пишем большую часть логики приложения, потому что мы реагируем на ввод и измененные состояния. Здесь также интегрировано то, что мне очень нравится в Redux: неизменность и простота функции редьюсера. Суть обработчика действий Stent та же. Он получает текущее состояние и полезную нагрузку действия и должен вернуть новое состояние. Если обработчик ничего не возвращает ( undefined
), то состояние машины остается прежним.
transitions: { 'fetching': { 'success': function (state, payload) { const todos = [ ...state.todos, payload ]; return { name: 'idle', todos }; } } }
Предположим, нам нужно получить данные с удаленного сервера. Мы запускаем запрос и переводим машину в состояние fetching
. Как только данные поступают из серверной части, мы запускаем действие success
, например:
machine.success({ label: '...' });
Затем мы возвращаемся в состояние idle
и сохраняем некоторые данные в виде массива todos
. Есть несколько других возможных значений для установки в качестве обработчиков действий. Первый и самый простой случай — это когда мы передаем только строку, которая становится новым состоянием.
transitions: { 'idle': { 'run': 'running' } }
Это переход от { name: 'idle' }
к { name: 'running' }
с помощью действия run()
. Этот подход полезен, когда у нас есть синхронные переходы между состояниями и нет никаких метаданных. Итак, если мы сохраним что-то еще в состоянии, этот тип перехода смоет это. Точно так же мы можем напрямую передать объект состояния:
transitions: { 'editing': { 'delete all todos': { name: 'idle', todos: [] } } }
Мы переходим от editing
к idle
с помощью действия deleteAllTodos
.
Мы уже видели обработчик функции, и последний вариант обработчика действия — это функция-генератор. Он вдохновлен проектом Redux-Saga и выглядит так:
import { call } from 'stent/lib/helpers'; Machine.create('app', { 'idle': { 'fetch data': function * (state, payload) { yield { name: 'fetching' } try { const data = yield call(requestToBackend, '/api/todos/', 'POST'); return { name: 'idle', data }; } catch (error) { return { name: 'error', error }; } } } });
Если у вас нет опыта работы с генераторами, это может показаться немного загадочным. Но генераторы в JavaScript — мощный инструмент. Нам разрешено приостанавливать наш обработчик действий, многократно изменять состояние и обрабатывать асинхронную логику.
Веселье с генераторами
Когда я впервые познакомился с Redux-Saga, я подумал, что это слишком сложный способ обработки асинхронных операций. На самом деле, это довольно умная реализация шаблона проектирования команд. Главное преимущество этого шаблона в том, что он разделяет вызов логики и ее фактическую реализацию.
Другими словами, мы говорим то, что хотим, а не то, как это должно происходить. Серия блогов Мэтта Хинка помогла мне понять, как реализуются саги, и я настоятельно рекомендую ее прочитать. Я привнес те же идеи в Stent, и для целей этой статьи мы будем говорить, что, уступая вещи, мы даем инструкции о том, чего мы хотим, фактически не делая этого. Как только действие выполнено, мы получаем управление обратно.
На данный момент можно разослать (выдать) пару вещей:
- объект состояния (или строка) для изменения состояния машины;
-
call
помощника вызова (он принимает синхронную функцию, которая является функцией, которая возвращает промис или другую функцию генератора) — мы в основном говорим: «Запустите это для меня, и если это асинхронно, подождите. Как только вы закончите, дайте мне результат.»; - вызов помощника
wait
(он принимает строку, представляющую другое действие); если мы используем эту служебную функцию, мы приостанавливаем обработчик и ждем отправки другого действия.
Вот функция, которая иллюстрирует варианты:
const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... }); } ... transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const [ data, isError ] = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } } }
Как мы видим, код выглядит синхронным, но на самом деле это не так. Просто Стент выполняет скучную часть ожидания разрешенного промиса или перебирает другой генератор.
Как Stent решает мои проблемы с Redux
Слишком много стандартного кода
Архитектура Redux (и Flux) основана на действиях, которые циркулируют в нашей системе. Когда приложение растет, у нас обычно появляется много констант и создателей действий. Эти две вещи очень часто находятся в разных папках, и отслеживание выполнения кода иногда требует времени. Кроме того, при добавлении новой функции нам всегда приходится иметь дело с целым набором действий, что означает определение большего количества имен действий и создателей действий.
В Stent у нас нет имен действий, и библиотека автоматически создает создателей действий для нас:
const machine = Machine.create('todo-app', { state: { name: 'idle', todos: [] }, transitions: { 'idle': { 'add todo': function (state, todo) { ... } } } }); machine.addTodo({ title: 'Fix that bug' });
У нас есть создатель действия machine.addTodo
, определенный непосредственно как метод машины. Этот подход также решил другую проблему, с которой я столкнулся: поиск редуктора, реагирующего на конкретное действие. Обычно в компонентах React мы видим имена создателей действий, такие как addTodo
; однако в редьюсерах мы работаем с постоянным типом действия. Иногда мне приходится переходить к коду создателя действия, чтобы увидеть точный тип. Здесь у нас вообще нет типов.
Непредсказуемые изменения состояния
В целом, Redux хорошо справляется с неизменяемым управлением состоянием. Проблема не в самом Redux, а в том, что разработчику разрешено отправлять любые действия в любое время. Если мы говорим, что у нас есть действие, которое включает свет, можно ли запускать это действие дважды подряд? Если нет, то как мы должны решить эту проблему с помощью Redux? Ну, мы, вероятно, поместили бы в редюсер какой-нибудь код, который защищает логику и проверяет, включены ли уже лампочки — может быть, предложение if
, которое проверяет текущее состояние. Теперь вопрос в том, не выходит ли это за рамки редуктора? Должен ли редуктор знать о таких крайних случаях?
Чего мне не хватает в Redux, так это способа остановить отправку действия на основе текущего состояния приложения, не загрязняя редюсер условной логикой. И я также не хочу принимать это решение на уровне просмотра, где увольняется создатель действия. Со Stent это происходит автоматически, потому что машина не реагирует на действия, которые не объявлены в текущем состоянии. Например:
const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } } }); // this is fine machine.run(); // This will do nothing because at this point // the machine is in a 'running' state and there is // only 'stop' action there. machine.jump();
Тот факт, что машина принимает только определенные входные данные в данный момент времени, защищает нас от странных ошибок и делает наши приложения более предсказуемыми.
Состояния, а не переходы
Redux, как и Flux, заставляет нас думать о переходах. Ментальная модель разработки с Redux в значительной степени определяется действиями и тем, как эти действия преобразуют состояние в наших редюсерах. Это неплохо, но я обнаружил, что разумнее думать о состояниях — в каких состояниях может находиться приложение и как эти состояния представляют бизнес-требования.
Заключение
Концепция конечных автоматов в программировании, особенно в разработке пользовательского интерфейса, открыла мне глаза. Я начал везде видеть конечные автоматы, и у меня есть желание всегда переходить на эту парадигму. Я определенно вижу преимущества наличия более строго определенных состояний и переходов между ними. Я всегда ищу способы сделать мои приложения простыми и читабельными. Я считаю, что государственные машины — это шаг в этом направлении. Концепция проста и в то же время мощна. У него есть потенциал для устранения многих ошибок.