Использование декларативного программирования для создания поддерживаемых веб-приложений
Опубликовано: 2022-03-11В этой статье я покажу, как разумное применение методов декларативного программирования может позволить командам создавать веб-приложения, которые легче расширять и поддерживать.
«…декларативное программирование — это парадигма программирования, выражающая логику вычислений без описания потока управления». — Ремо Х. Янсен, Практическое функциональное программирование с помощью TypeScript
Как и большинство проблем в программном обеспечении, принятие решения об использовании методов декларативного программирования в ваших приложениях требует тщательной оценки компромиссов. Ознакомьтесь с одной из наших предыдущих статей для более подробного обсуждения этих вопросов.
Здесь основное внимание уделяется тому, как можно постепенно применять шаблоны декларативного программирования как для новых, так и для существующих приложений, написанных на языке JavaScript, который поддерживает несколько парадигм.
Во-первых, мы обсудим, как использовать TypeScript как на задней, так и на внешней стороне, чтобы сделать ваш код более выразительным и устойчивым к изменениям. Затем мы изучаем конечные автоматы (FSM), чтобы упростить разработку интерфейса и увеличить участие заинтересованных сторон в процессе разработки.
FSM не новая технология. Они были обнаружены почти 50 лет назад и популярны в таких отраслях, как обработка сигналов, аэронавтика и финансы, где корректность программного обеспечения может иметь решающее значение. Они также очень хорошо подходят для моделирования проблем, часто возникающих в современной веб-разработке, таких как координация сложных асинхронных обновлений состояния и анимации.
Это преимущество возникает из-за ограничений на способ управления состоянием. Конечный автомат может находиться только в одном состоянии одновременно и имеет ограниченное количество соседних состояний, в которые он может переходить в ответ на внешние события (такие как щелчки мышью или ответы на выборку). Результатом обычно является значительное снижение количества дефектов. Однако подходы FSM может быть трудно масштабировать, чтобы они хорошо работали в больших приложениях. Недавние расширения для конечных автоматов, называемые диаграммами состояний, позволяют визуализировать сложные автоматы и масштабировать их до гораздо более крупных приложений, что является разновидностью конечных автоматов, которым посвящена эта статья. Для нашей демонстрации мы будем использовать библиотеку XState, которая является одним из лучших решений для конечных автоматов и диаграмм состояний в JavaScript.
Декларативный сервер с Node.js
Программирование серверной части веб-сервера с использованием декларативных подходов — обширная тема, и обычно ее можно начать с оценки подходящего функционального языка программирования на стороне сервера. Вместо этого давайте предположим, что вы читаете это в то время, когда вы уже выбрали (или рассматриваете) Node.js для своей серверной части.
В этом разделе подробно описан подход к моделированию сущностей на серверной части, который имеет следующие преимущества:
- Улучшенная читаемость кода
- Более безопасный рефакторинг
- Потенциал повышения производительности благодаря моделированию типов гарантий, которое обеспечивает
Гарантии поведения посредством моделирования типов
JavaScript
Рассмотрим задачу поиска данного пользователя по его адресу электронной почты в JavaScript:
function validateEmail(email) { if (typeof email !== "string") return false; return isWellFormedEmailAddress(email); } function lookupUser(validatedEmail) { // Assume a valid email is passed in. // Safe to pass this down to the database for a user lookup.. }
Эта функция принимает адрес электронной почты в виде строки и возвращает соответствующего пользователя из базы данных при наличии совпадения.
Предполагается, что lookupUser()
будет вызываться только после выполнения базовой проверки. Это ключевое предположение. Что, если через несколько недель будет выполнен некоторый рефакторинг, и это предположение больше не будет верным? Скрестим пальцы за то, чтобы модульные тесты обнаружили ошибку, или мы можем отправлять нефильтрованный текст в базу данных!
TypeScript (первая попытка)
Давайте рассмотрим эквивалент TypeScript функции проверки:
function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }
Это небольшое улучшение, поскольку компилятор TypeScript избавил нас от добавления дополнительного шага проверки во время выполнения.
Гарантии безопасности, которые может дать строгая типизация, на самом деле еще не используются. Давайте посмотрим на это.
TypeScript (вторая попытка)
Давайте улучшим безопасность типов и запретим передачу необработанных строк в качестве входных данных для looukupUser
:
type ValidEmail = { value: string }; function validateEmail(input: string): Email | null { if (!isWellFormedEmailAddress(input)) return null; return { value: email }; } function lookupUser(email: ValidEmail): User { // No need to perform validation. Compiler has already ensured only valid emails have been passed in. return lookupUserInDatabase(email.value); }
Это лучше, но громоздко. При любом использовании ValidEmail
доступ к фактическому адресу осуществляется через email.value
. TypeScript использует структурную типизацию, а не номинальную типизацию, используемую такими языками, как Java и C#.
Хотя это мощно, это означает, что любой другой тип, который придерживается этой подписи, считается эквивалентным. Например, следующий тип пароля может быть передан в lookupUser()
без возражений со стороны компилятора:
type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.
TypeScript (третья попытка)
Мы можем добиться номинальной типизации в TypeScript, используя пересечение:
type ValidEmail = string & { _: "ValidEmail" }; function validateEmail(input: string): ValidEmail { // Perform email validation checks.. return input as ValidEmail; } type ValidPassword = string & { _: "ValidPassword" }; function validatePassword(input: string): ValidPassword { ... } lookupUser("[email protected]"); // Error: expected type ValidEmail. lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail. lookupUser(validateEmail("[email protected]")); // Ok.
Теперь мы добились того, что только проверенные строки электронной почты могут быть переданы в lookupUser()
.
Совет для профессионалов: легко примените этот шаблон, используя следующий вспомогательный тип:
type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;
Плюсы
Строго типизируя объекты в вашем домене, мы можем:
- Уменьшите количество проверок, которые необходимо выполнять во время выполнения, которые потребляют драгоценные циклы ЦП сервера (хотя это очень небольшое количество, они складываются при обслуживании тысяч запросов в минуту).
- Поддерживайте меньше базовых тестов из-за гарантий, которые предоставляет компилятор TypeScript.
- Воспользуйтесь преимуществами рефакторинга с помощью редактора и компилятора.
- Улучшите читаемость кода за счет улучшения отношения сигнал/шум.
Минусы
Типовое моделирование сопряжено с некоторыми компромиссами, которые следует учитывать:
- Внедрение TypeScript обычно усложняет цепочку инструментов, что приводит к увеличению времени сборки и выполнения набора тестов.
- Если ваша цель состоит в том, чтобы создать прототип функции и передать ее пользователям как можно скорее, дополнительные усилия, необходимые для явного моделирования типов и их распространения в кодовой базе, могут оказаться неоправданными.
Мы показали, как существующий код JavaScript на сервере или совместно используемом внутреннем/интерфейсном слое проверки можно расширить с помощью типов, чтобы улучшить читаемость кода и обеспечить более безопасный рефакторинг — важные требования для команд.
Декларативные пользовательские интерфейсы
Пользовательские интерфейсы, разработанные с использованием методов декларативного программирования, фокусируются на описании «что», а не на «как». Два из трех основных компонентов Интернета, CSS и HTML, являются декларативными языками программирования, которые выдержали испытание временем и более чем 1 миллиардом веб-сайтов.
React был открыт Facebook в 2013 году, и это значительно изменило ход фронтенд-разработки. Когда я впервые использовал его, мне понравилось, как я мог объявить графический интерфейс как функцию состояния приложения. Теперь я мог составлять большие и сложные пользовательские интерфейсы из более мелких строительных блоков, не вникая в беспорядочные детали манипулирования DOM и не отслеживая, какие части приложения нуждаются в обновлении в ответ на действия пользователя. Я мог бы в значительной степени игнорировать аспект времени при определении пользовательского интерфейса и сосредоточиться на обеспечении правильного перехода моего приложения из одного состояния в другое.
Чтобы упростить разработку пользовательского интерфейса, React добавил уровень абстракции между разработчиком и машиной/браузером: виртуальный DOM .
Другие современные фреймворки веб-интерфейса также восполнили этот пробел, хотя и по-другому. Например, Vue использует функциональную реактивность либо через геттеры/сеттеры JavaScript (Vue 2), либо через прокси (Vue 3). Svelte обеспечивает реактивность благодаря дополнительному этапу компиляции исходного кода (Svelte).
Эти примеры, кажется, демонстрируют большое желание в нашей отрасли предоставить разработчикам лучшие и более простые инструменты для выражения поведения приложений с помощью декларативных подходов.
Декларативное состояние приложения и логика
В то время как уровень представления продолжает вращаться вокруг некоторой формы HTML (например, JSX в React, шаблоны на основе HTML, найденные в Vue, Angular и Svelte), я постулирую, что проблема того, как смоделировать состояние приложения таким образом, чтобы легко понятная другим разработчикам и поддерживаемая по мере роста приложения, все еще не решена. Мы видим свидетельство этого в распространении библиотек и подходов управления состоянием, которое продолжается и по сей день.
Ситуация осложняется растущими ожиданиями от современных веб-приложений. Некоторые возникающие проблемы, которые должны решать современные подходы к государственному управлению:
- Автономные приложения, использующие расширенные методы подписки и кэширования
- Краткий код и повторное использование кода для постоянно сокращающихся требований к размеру пакета
- Спрос на все более сложные пользовательские интерфейсы благодаря высококачественной анимации и обновлениям в реальном времени.
(Повторное) появление конечных автоматов и диаграмм состояний
Автоматы с конечным числом состояний широко используются для разработки программного обеспечения в определенных отраслях, где надежность приложений имеет решающее значение, таких как авиация и финансы. Он также неуклонно набирает популярность для фронтенд-разработки веб-приложений благодаря, например, отличной библиотеке XState.
Википедия определяет конечный автомат как:
Абстрактная машина, которая может находиться ровно в одном из конечного числа состояний в любой момент времени. FSM может переходить из одного состояния в другое в ответ на некоторые внешние воздействия; переход из одного состояния в другое называется переходом. FSM определяется списком своих состояний, начальным состоянием и условиями для каждого перехода.
И далее:
Состояние — это описание состояния системы, ожидающей выполнения перехода.
Конечные автоматы в своей базовой форме плохо масштабируются для больших систем из-за проблемы взрыва состояния. Недавно были созданы диаграммы состояний UML для расширения FSM с помощью иерархии и параллелизма, которые позволяют широко использовать FSM в коммерческих приложениях.

Объявите логику вашего приложения
Во-первых, как выглядит FSM в виде кода? Есть несколько способов реализации конечного автомата в JavaScript.
- Конечный автомат как оператор switch
Вот машина, описывающая возможные состояния, в которых может находиться JavaScript, реализованная с помощью оператора switch:
const initialState = { type: 'idle', error: undefined, result: undefined }; function transition(state = initialState, action) { switch (action) { case 'invoke': return { type: 'pending' }; case 'resolve': return { type: 'completed', result: action.value }; case 'error': return { type: 'completed', error: action.error ; default: return state; } }
Этот стиль кода будет знаком разработчикам, которые использовали популярную библиотеку управления состоянием Redux.
- Конечный автомат как объект JavaScript
Вот та же машина, реализованная как объект JavaScript с использованием библиотеки JavaScript XState:
const promiseMachine = Machine({ id: "promise", initial: "idle", context: { result: undefined, error: undefined, }, states: { idle: { on: { INVOKE: "pending", }, }, pending: { on: { RESOLVE: "success", REJECT: "failure", }, }, success: { type: "final", actions: assign({ result: (context, event) => event.data, }), }, failure: { type: "final", actions: assign({ error: (context, event) => event.data, }), }, }, });
Хотя версия XState менее компактна, объектное представление имеет несколько преимуществ:
- Сам конечный автомат представляет собой простой JSON, который можно легко сохранить.
- Поскольку это декларативно, машину можно визуализировать.
- При использовании TypeScript компилятор проверяет, выполняются ли только допустимые переходы между состояниями.
XState поддерживает диаграммы состояний и реализует спецификацию SCXML, что делает его пригодным для использования в очень больших приложениях.
Визуализация промиса в диаграммах состояний:
Лучшие практики XState
Ниже приведены некоторые рекомендации, которые следует применять при использовании XState, чтобы упростить обслуживание проектов.
Отделите побочные эффекты от логики
XState позволяет независимо указывать побочные эффекты (которые включают такие действия, как ведение журнала или запросы API) от логики конечного автомата.
Это имеет следующие преимущества:
- Содействуйте обнаружению логических ошибок, сохраняя код конечного автомата как можно более чистым и простым.
- Легко визуализируйте конечный автомат без необходимости предварительно удалять дополнительный шаблон.
- Более простое тестирование конечного автомата путем внедрения фиктивных сервисов.
const fetchUsersMachine = Machine({ id: "fetchUsers", initial: "idle", context: { users: undefined, error: undefined, nextPage: 0, }, states: { idle: { on: { FETCH: "fetching", }, }, fetching: { invoke: { src: (context) => fetch(`url/to/users?page=${context.nextPage}`).then((response) => response.json() ), onDone: { target: "success", actions: assign({ users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users nextPage: (context) => context.nextPage + 1, }), }, onError: { target: "failure", error: (_, event) => event.data, // Data holds the error }, }, }, // success state.. // failure state.. }, });
Хотя заманчиво писать конечные автоматы таким образом, пока вы все еще работаете, лучшее разделение задач достигается путем передачи побочных эффектов в качестве опций:
const services = { getUsers: (context) => fetch( `url/to/users?page=${context.nextPage}` ).then((response) => response.json()) } const fetchUsersMachine = Machine({ ... states: { ... fetching: { invoke: { // Invoke the side effect at key: 'getUsers' in the supplied services object. src: 'getUsers', } on: { RESOLVE: "success", REJECT: "failure", }, }, ... }, // Supply the side effects to be executed on state transitions. { services } });
Это также позволяет легко проводить модульное тестирование конечного автомата, позволяя явно имитировать пользовательские выборки:
async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });
Разделение больших машин
Не всегда сразу становится очевидным, как лучше структурировать проблемную область в хорошую иерархию конечного автомата, когда вы только начинаете.
Совет. Используйте иерархию компонентов пользовательского интерфейса, чтобы управлять этим процессом. См. следующий раздел о том, как сопоставить конечные автоматы с компонентами пользовательского интерфейса.
Основным преимуществом использования конечных автоматов является явное моделирование всех состояний и переходов между состояниями в ваших приложениях, чтобы результирующее поведение было ясно понято, что упрощает обнаружение логических ошибок или пробелов.
Чтобы это работало хорошо, машины должны быть небольшими и лаконичными. К счастью, иерархически составить конечные автоматы несложно. В примере с каноническими диаграммами состояний системы светофора само «красное» состояние становится дочерним конечным автоматом. Родительская «светлая» машина не знает о внутренних состояниях «красного», но решает, когда ввести «красный» и каково предполагаемое поведение при выходе:
1-1 Сопоставление конечных автоматов с компонентами пользовательского интерфейса с отслеживанием состояния
Возьмем, к примеру, сильно упрощенный вымышленный сайт электронной коммерции со следующими представлениями React:
<App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>
Процесс генерации конечных автоматов, соответствующих приведенным выше представлениям, может быть знаком тем, кто использовал библиотеку управления состоянием Redux:
- Есть ли у компонента состояние, которое необходимо смоделировать? Например, Admin/Products не может; постраничной выборки на сервер плюс решения для кэширования (например, SWR) может быть достаточно. С другой стороны, такие компоненты, как SignInForm или Cart, обычно содержат состояние, которым необходимо управлять, например данные, введенные в поля, или текущее содержимое корзины.
- Достаточно ли методов локального состояния (например,
setState() / useState()
в React) для выявления проблемы? Отслеживание того, открыто ли в данный момент всплывающее окно корзины, едва ли требует использования конечного автомата. - Не окажется ли получившийся конечный автомат слишком сложным? Если это так, разделите машину на несколько меньших, выявив возможности для создания дочерних машин, которые можно повторно использовать в другом месте. Например, машины SignInForm и RegistrationForm могут вызывать экземпляры дочернего textFieldMachine для моделирования проверки и состояния полей электронной почты, имени и пароля пользователя.
Когда использовать модель конечного автомата
Хотя диаграммы состояний и конечные автоматы могут изящно решить некоторые сложные проблемы, выбор лучших инструментов и подходов для использования в конкретном приложении обычно зависит от нескольких факторов.
Некоторые ситуации, когда использование конечных автоматов блестяще:
- Ваше приложение включает в себя значительный компонент ввода данных, в котором доступность или видимость поля регулируется сложными правилами: например, ввод формы в приложении для страховых случаев. Здесь автоматы помогают обеспечить надежную реализацию бизнес-правил. Кроме того, функции визуализации диаграмм состояний можно использовать для расширения сотрудничества с нетехническими заинтересованными сторонами и выявления подробных бизнес-требований на ранних этапах разработки.
- Чтобы лучше работать при медленных соединениях и предоставлять пользователям более точный опыт , веб-приложения должны управлять все более сложными асинхронными потоками данных. Конечные автоматы явно моделируют все состояния, в которых может находиться приложение, а диаграммы состояний можно визуализировать, чтобы помочь диагностировать и решать проблемы с асинхронными данными.
- Приложения, требующие много сложной анимации на основе состояния. Для сложных анимаций популярны методы моделирования анимации как потоков событий во времени с помощью RxJS. Во многих сценариях это работает хорошо, однако, когда богатая анимация сочетается со сложным рядом известных состояний, конечные автоматы обеспечивают четко определенные «точки покоя», между которыми течет анимация. FSM в сочетании с RxJS кажутся идеальной комбинацией, помогающей обеспечить следующую волну высококачественного и выразительного пользовательского опыта.
- Богатые клиентские приложения , такие как фото- или видеоредактирование, инструменты для создания диаграмм или игры, в которых большая часть бизнес-логики находится на стороне клиента. FSM по своей сути отделены от инфраструктуры или библиотек пользовательского интерфейса, и их легко писать тесты, позволяющие быстро итерировать высококачественные приложения и уверенно их отгружать.
Предостережения относительно конечного автомата
- Общий подход, рекомендации и API для библиотек диаграмм состояний, таких как XState, являются новыми для большинства разработчиков интерфейса, которым потребуются затраты времени и ресурсов, чтобы стать продуктивными, особенно для менее опытных команд.
- Как и в предыдущем предостережении, несмотря на то, что популярность XState продолжает расти и хорошо задокументирована, существующие библиотеки управления состоянием, такие как Redux, MobX или React Context, имеют огромное количество подписчиков, которые предоставляют множество онлайн-информации, которой XState еще не соответствует.
- Для приложений, использующих более простую модель CRUD, будет достаточно существующих методов управления состоянием в сочетании с хорошей библиотекой кэширования ресурсов, такой как SWR или React Query. Здесь дополнительные ограничения, которые обеспечивают FSM, хотя и невероятно полезны в сложных приложениях, могут замедлить разработку.
- Инструмент менее зрелый, чем другие библиотеки управления состоянием, и все еще ведется работа над улучшенной поддержкой TypeScript и расширениями инструментов разработки браузера.
Подведение итогов
Популярность и принятие декларативного программирования в сообществе веб-разработчиков продолжают расти.
В то время как современная веб-разработка продолжает становиться все более сложной, библиотеки и фреймворки, использующие подходы декларативного программирования, появляются все чаще. Причина кажется очевидной — необходимо создать более простые и описательные подходы к написанию программного обеспечения.
Использование языков со строгой типизацией, таких как TypeScript, позволяет лаконично и явно моделировать сущности в предметной области, что снижает вероятность ошибок и количество подверженного ошибкам проверочного кода, которым необходимо манипулировать. Принятие конечных автоматов и диаграмм состояний во внешнем интерфейсе позволяет разработчикам объявлять бизнес-логику приложения посредством переходов между состояниями, что позволяет разрабатывать богатые инструменты визуализации и расширяет возможности для тесного сотрудничества с теми, кто не является разработчиком.
Когда мы делаем это, мы переключаем наше внимание с основ работы приложения на представление более высокого уровня, которое позволяет нам еще больше сосредоточиться на потребностях клиента и создавать долгосрочную ценность.