Мастер конвейеров: передача состояния
Привет, замыкание, мой старый друг.
Мастер конвейеров: передача состояния
Сталкивались ли вы с трудностями при передаче состояния с помощью функциональных конвейеров?
Организация (или её отсутствие) вашего кода напрямую влияет на простоту передачи состояния.
В этой статье мы рассмотрим эффективный приём передачи состояния через конвейер. По пути мы улучшим организацию и читаемость кода.
В центре внимания этой статьи — следующий «реальный» фрагмент: функция оформления заказа, которая принимает userId и массив products. Она возвращает цепочку промисов, последовательно выполняющую 4 функции.
const checkout = (userId: number, products: number[]) => { return getProductsSubtotal(userId, products) .then(subTotal => applyTaxes(userId, subTotal)) .then(total => purchaseProducts(userId, total)) .then(result => sendReceipt(userId, result));};Подождите-ка, этот код на самом деле довольно неплох, насколько это возможно для конвейеров в JS!
Однако у него есть несколько тонких недостатков, которые в сочетании могут превратиться в более серьёзные проблемы.
Одна из проблем — мы повторно передаём userId в каждую (логически связанную) функцию. Теперь добавьте к этому другую проблему, которую легко упускают разработчики и TypeScript: перепутанные числовые аргументы легко создают скрытую ошибку. (См. applyTaxes и purchaseProducts. Что идёт первым — userId или amount?)
Прежде чем решить, как улучшить этот код, давайте определим плюсы и минусы.
Плюсы и минусы
Плюсы
- Хорошее использование замыкания! Передача
userIdиproductsодин раз! - Согласованное именование аргументов.
- Относительно эффективная и лаконичная композиция 4 ключевых функций для оформления заказа.
- «Бесплатное» управление потоком ошибок. (Ошибки поднимаются из любых вложенных функций, отклоняя промис, возвращаемый
checkout().)
Минусы
- Многократная передача
userIdутомительна. - Функции не принимают один аргумент (не унарны). Это влияет на компонуемость. См. финальный пример — почему?
- Неочевидно, что возвращает каждая функция. (Это результат отправки письма или та переменная
result? Или?) - Непонятно, как добавить функциональность (например, скажем, нам нужно загрузить скидку/кредит/баллы клиента и т.д.)
- Иногда «временные» имена параметров (как в каждом
.then(param => {})) добавляют контекст. Однако со временем они, скорее всего, станут местом для накопления именовательного мусора.
Решение, часть 1: создайте модуль!
Этот приём заключается в организации связанных функций в единый модуль (например, CartHelpers). Он не требует конкретного паттерна. Изучите фабричные функции, классы, замыкания, миксины и т.д. Найдите то, что имеет смысл для вашего проекта и команды.
Фабрика CartHelpers
Пример модуля CartHelpers, где userId передаётся один раз, а все методы принимают один аргумент.
const CartHelpers = (userId: number) => { return { getProductsSubtotal: products => getProductsSubtotal(userId, products), applyTaxes: subTotal => applyTaxes(userId, subTotal), purchaseProducts: total => purchaseProducts(userId, total), sendReceipt: invoice => sendReceipt(userId, invoice) };};Класс CartHelpers
Если классы — ваша тема, адаптировать несложно:
class CartHelpers { constructor(userId) { this.userId = userId; } getProductsSubtotal = products => getProductsSubtotal(this.userId, products); applyTaxes = subTotal => applyTaxes(this.userId, subTotal); purchaseProducts = total => purchaseProducts(this.userId, total); sendReceipt = invoice => sendReceipt(this.userId, invoice);}Некоторые немедленные преимущества:
- Устранение повторяющейся передачи переменных.
- DRY:
CartHelpersабстрагирует повторяющийся аргументuserId. - Каждый метод принимает только необходимые аргументы. Вызов
cart.applyTaxes(subTotal)читается совершенно однозначно.
- DRY:
- Функции с одним аргументом в
CartHelpersболее читаемы, с более ясной целью.
Группируя связанные функции, мы создаём возможность сократить открытую поверхность (например, checkout(), «публичные» методы CartHelpers.)
Меньше поверхности === меньше когнитивной нагрузки, лучше тестируемость и поддерживаемость. Проектируйте системы с намерением и фокусом. ✨
Использование Checkout и CartHelpers
Посмотрим, как теперь выглядит функция checkout():
export const checkout = ({ userId, products }) => { const cart = CartHelpers(userId);
return Promise.resolve(products) .then(products => cart.getProductsSubtotal(products)) .then(subTotal => cart.applyTaxes(subTotal)) .then(total => cart.purchaseProducts(total)) .then(result => cart.sendReceipt(result));};Checkout с дальнейшими улучшениями
Можно ли улучшить ещё? Да! Нам вообще не нужно повторять аргументы!
Когда аргументы функции предоставляются выводом предыдущих функций, код можно упростить ещё сильнее.
export const checkout = ({ userId, products }) => { const cart = CartHelpers(userId);
// 🌈 Функции складываются как Lego и читаются как обычные «человеческие слова»! 💅 return Promise.resolve(products) .then(cart.getProductsSubtotal) .then(cart.applyTaxes) .then(cart.purchaseProducts) .then(cart.sendReceipt);};Если кажется неестественным объединять параметры в единственные (объектные) аргументы, рассмотрите возможность разделения ваших функций ИЛИ объединения их в более уместно ограниченные модули.
С чего начать?
Найдите связанные функции и сгруппируйте их вместе. (Например, CartHelpers.)
Часть проблемы при поиске возможных логических модулей — это выявление связанного кода в первую очередь.
Что делает функции связанными?
Один интересный приём: найдите повторение в параметрах функций. Спросите, есть ли взаимосвязь? Или скрытая ответственность?
- ✅ Функции с повторяющимися, общими аргументами. (Например, если 4 метода принимают
userRewards, скорее всего, вам нужен модульRewardsили подобный.) - ✅ Функции, аргументы которых предоставляются напрямую выводом предыдущих функций. (Последовательности шагов. Например,
Extract,Transform,Load.) - ❌ Всё, что смутно связано с областью функций, «покупка продуктов?»
- ❌ Функции с общим префиксом или суффиксом в именовании?
- ❌ Функции, требующие большие объекты в качестве аргументов, несмотря на использование лишь нескольких значений изнутри этих объектов. (Например,
applyTaxes({ user, business, rewards, kitchenSink })противapplyTaxes({ subTotal }))
Хотя не существует единственного «правильного ответа» на проектирование модулей, полезно определить 2-3 варианта организации — набросайте план, напишите «фантазийный» код, спросите «вызывает ли это радость?»
Вам может казаться, что
cart.sendReceipt()не принадлежит к методам, связанным с платежами. Возможно,customerNotifications.sendReceipt()— более подходящее место для клиентских уведомлений. ЕслиCartHelperдостаточно важен, он может выступать в роли контроллера, внутренне вызывая все необходимые сервисы, такие какcustomerNotifications.
Как узнать, помогаете ли вы?
Если читаемость не страдает по мере устранения случайных аргументов, ПОЗДРАВЛЯЮ!!! Вы, вероятно, создали модуль с ясной и устойчивой областью ответственности!
- Устранение промежуточных аргументов заставляет «слои» проявляться.
- Должно быть трудно сбросить случайный код в неправильное место!
Итак, это подводит к вопросу: куда добавлять функциональность?
По моему опыту, есть две основные стратегии для оценки при добавлении функциональности:
- Расширить/отрефакторить существующий метод. (Когда новый код достаточно близок к существующему.)
- Создать новую (пятую) функцию в нужном месте цепочки. (Если новый код не связан с существующими функциями.)
В конечном итоге это упрощает решение, куда принадлежит новая функциональность. (Например, cart.applyDiscounts(), cart.applyTaxes(), rewards.getBalance().)
Заключение
Передача состояния через сложный конвейер может быть непростой. Однако с небольшой практикой рефакторинга вы обнаружите, что пишете более читаемый код с меньшей когнитивной нагрузкой.
Вопросы? Комментарии? Замечания? Не стесняйтесь обращаться @justsml или email.
Следите за следующей частью серии
Мы изучим вынесение состояния наружу и расширение функциональности нашего модуля!