Frontender Magazine

ПромисПайпы: гомогенная бизнес-логика

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

Мне как фронтенд-разработчику везло меньше. Создание API — это сложно. Задача сформировать общее понимание того, каким должно быть взаимодействие клиента с сервером, забирает много времени и энергии. Мы начинаем с REST API, но у каждого свое понимание того, что такое REST, затем каждая модификация API требует усилий, времени и долгих обсуждений.

Сейчас, для описании запросов ресурсов с сервера, я использую Промисы. Мне они симпатичны, поскольку у них удобное «chaining API» для потенциально асинхронной бизнес-логики.

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

Давай посмотрим код простой бизнес-логики, построенной на Промисах:

Promise.resolve(item)
  .then(validateItem)
  .then(postItem)
  .then(addItem)
  .catch(handleError)

postItem возвращает Промис, который будет выполнен, когда придет ответ с сервера.

На стороне сервера скорее всего у нас будет такой Node.js/Express код:

app.post('/api/items',
  validateItemMiddleware,
  saveItemInDBMiddleware,
  returnItemMiddleware)

Я думаю, что если бы Промисы появились раньше — @tjholowaychuk бы использовал их в Express.js вместо «middleware» функций.

На Промисах, сервер бы наверное выглядел как-то так:

app.post('/api/items')
  .then(validateItem)
  .then(saveItemInDB)
  .then(returnItem)

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

var postItem = function(data){
  return Promise.resolve(data)
    .then(validateItem)
    .then(saveItemInDB)
    .then(returnItem)
}

Promise.resolve(item)
  .then(validateItem)
  .then(postItem)
  .then(addItem)
  .catch(handleError)

Ясно-понятно, что в этом случае нам не нужно будет API, и мы не будем тратить время на обсуждение «как назвать путь» и «какой выбрать метод» в данном случае.

Если пойти еще дальше, то наш код можно сделать более плоским:

Promise.resolve(item)
  .then(validateItem)
  .then(validateItemServer)
  .then(saveItemInDB)
  .then(addItem)
  .catch(handleError)

Конечно, с простыми Промисами нам этого не сделать, но можно сделать с PromisePipe.

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

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

Схема

Итак, нужно будет написать немного кода, который будет прокидывать эти сообщения. Посмотреть примеры можно тут. Пока я в основном использовал socket.io как транспорт (http, udp, websocket и т.д.), но не должно быть проблем с простыми HTTP, или любым другим протоколом, который может пересылать сообщения.

Скринкаст

C ПромисПайп бизнес-логика будет выглядеть примерно так:

var doOnServer = PromisePipe.in('server')
var addItemAction = PromisePipe()
  .then(validateItem)
  .then(doOnServer(validateItemServer))
  .then(doOnServer(saveItemInDB))
  .then(addItem)
  .catch(handleError);
addItemAction(item) // will pass complete chain

Когда выполнение цепочки подходит к validateItemServer, ПромисПайп перекидывает исполнение на сервер с помощью специального сообщения. validateItemServer и saveItemInDB выполняются на сервере, и сообщение отсылается назад для продолжения выполнения на клиенте начиная с addItem.

В ПромисПайпе можно расширять API своими методами, что дает построить API так, что оно максимально удобно опишет бизнес-логику:

var doOnServer = PromisePipe.in('server')
var addItemAction = PromisePipe()
  .validate('item')
  .validateServer('item')
  .db.save.Item()
  .then(addItem)
  .catch(handleError);
addItemAction(item) // will pass complete chain

Например вот mongoDB API для ПромисПайп. А вот пример todo-app(live) приложения, которое использует «mongo-pipe-api». validateServer и «mongo-pipe-api» должны быть помечены как серверные методы. И тогда эти звенья будут выполняться на сервере.

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

С ПромисПайп мы получаем:

Схема

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

Если вы заметили ошибку, вы всегда можете отредактировать статью, создать issue или просто написать об этом Антону Немцеву в skype ravencry.

Эльдар Джафаров

Комментарии (8 комментариев, если быть точным)

Автар пользователя
SilentImp

Написал небольшой пример, который, может быть, будет кому то полезен. Взять его можно в этом репозитории: https://github.com/SilentImp/PPExample

Вы можете запустить пример выполнив в консоли находясь в директорие с примером:

npm install npm start

И открыв в браузере:

http://localhost:3000/

Если же вы хотите разобрать все по косточкам, то именно это мы и сделаем далее. Сама задача состоит из трех частей: 1. На стороне клиента получить логин и пароль при отправке формы. 2. На стороне сервера проверить логин и пароль. 3. На стороне клиента сообщить пользователю о результате проверки.

Сам проект будет состоять из трех файлов: 1. index.html — статическая html-страничка, которая содержит форму ввода логина и пароля и подключает скрипт frontend.js. 2. backend.js — сервер, который отдает статику и инициирует ПромисПайп на стороне сервера. 3. frontend.js — скрипт, который инициирует ПромисПайп на стороне клиента и передаст в него данные после отправки формы. 4. common.js — скрипт, общий для клиента и сервера и представляющий собой логику работы пайпа.

Пример имеет следующие зависимости: - express — фреймворк с помощью которого реализован сервер - body-parser — парсит тело запроса на предмет переменных - promise-pipe — непосредственно ПромисПайпы - es6-promise — Промисы es6 - browserify — он нужен что бы собрать для использования в браузере frontend.js

Что бы не устанавливать их все отдельно клонируйте в директории с примером запустите npm install.

index.html

Это просто статическая страничка. Она содержит в себе форму, которая позволяет отправить логин и пароль:

<form action="/login/" method="post" class="login-form"> <input type="text" name="login" placeholder="Логин : Carmack" required="required"> <input type="password" name="password" placeholder="Пароль : iddqd" required="required"> <button type="submit">Вход</button> <p class="message message_fail">Вы ошиблись при вводе логина или пароля.</p> <p class="message message_success">Добро пожаловать!</p> </form>

И, кроме того, подключает в конце body скрипт frontend.js (после сборки browserify).

<script src="bundle.js"></script>

backend.js

Для начала просто настроим сервер.

``` /* Мы используем фреймфорк express.js для реализации сервера */ var express = require('express') , app = express() , bodyParser = require('body-parser') , server = require('http').Server(app);

/* Ожидается что промиспайпы получат уже распаршенные данные */ app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.json());

/* Раздача статики, что бы мы могли получить доступ к index.html / app.use(express.static("./")); / Запускаем сервер */ server.listen(3000); ```

Запускать его можно, как и любой скрипт, в консоли коммандой:

node backend.js

После этого открыв ссылку http://localhost:3000/ вы должны увидеть index.html Теперь добавим до server.listen(3000); инициализацию ПромисПайпов.

/* Добавляем скрипт, общий для бекенда и фронтенда */ var pipe = require('./common.js') /* Подключаем скрипт HTTP коннектора промиспайпов */ , connectors = require('./node_modules/promise-pipe/example/connectors/HTTPDuplexStream') /* Получаем экземпляр промиспайпа, созданный в этом скрипте */ , PromisePipe = pipe.PromisePipe /* Указыаем какой транспорт используется при переходе с сервера на клиент */ PromisePipe.stream('server','client').connector(connectors.HTTPServerClientStream(app));

Это позволит обрабатывать сервисную часть промиспайпов. Коннектор может отличаться, например можно использовать сокеты.

frontend.js

Сначала инициализируем работу ПромисПайпа:

/* Подключаем скрипт общий для бекенда и фронтенда */ var pipe = require('./common.js') /* Получаем экземпляр промиспайпа, созданный в этом скрипте */ , PromisePipe = pipe.PromisePipe /* Подключаем скрипт коннектора промиспайпов */ , connectors = require('./node_modules/promise-pipe/example/connectors/HTTPDuplexStream'); /* Указыаем какой транспорт используется */ PromisePipe.stream('client','server').connector(connectors.HTTPClientServerStream());

Так как тут тоже используется require, что бы использовать скрипт в браузере надо будет сначала собрать его с помощью browserify. Для этого наберите в консоли:

browserify frontend.js -o bundle.js

Затем получим из DOM форму и по событию отправки данных будем передавать данные в ПромисПайп:

/* Получаем из DOM форум авторизации */ var form = document.querySelector('.login-form'); /* Перехватываем отправку формы */ form.addEventListener('submit', function (event) { /* Блокируем отправку формы */ event.preventDefault(); /* Получаем данные из формы */ var data = { login: document.querySelector('input[name="login"]').value.trim() , password: document.querySelector('input[name="password"]').value.trim() } /* Очищаем форму */ form.reset(); /* Передаем их в промиспайп */ pipe(data); });

common.js

Это скрипт, который содержит логику и используется и на клиенте и на сервере. Для начала инициируем ПромисПайп и промисы:

/* Создаем переменую с промиспайпом и промисом */ var PromisePipe = require('promise-pipe')() /* Подключаем промисы es6 */ , Promise = require('es6-promise').Promise;

Определим где именно сейчас исполняется скрипт:

/* Если в глобальном пространстве имен нет window, то будем считать, что все происходит на сервере. */ if(typeof(window) !== 'object'){ PromisePipe.setEnv('server'); }

Затем, создадим функции, который будут указывать где именно выполняется та или иная часть цепочки:

``` /* Часть промиспайпа, которая будет выполняться только на сервере */ function serverSide(fn){ fn._env = 'server'; return fn }

/* Часть промиспайпа, которая будет выполняться только на клиенте./ / Так как по умолчанию все происходит на клиенте, то эту функцию / / можно спокойно убрать, но мне кажется с ней — нагляднее. */ function clientSide(fn){ fn._env = 'client'; return fn } ```

И экспортируем экземпляр промиса и цепочку действий, что бы они были доступны в frontend.js и backend.js.

``` /* Логика / / Получив данные на сервере мы проверяем логин и пароль / / и выводим пользователю сообщение о результате. */ module.exports = PromisePipe() .then(serverSide(validateData)) .then(clientSide(success)) .catch(clientSide(fail));

module.exports.PromisePipe = PromisePipe; ```

И теперь давайте посмотрим что происходит внутри каждого из звений цепочки:

validateData()

Тут должна быть проверка логина и пароля. У нас для упрощения примера они просто сравниваются со статическими значениями. Создается промис и в зависимости от результата сравнения выполняются или отклоняется.

/* Проверка данных */ function validateData(data){ /* Создаем промис */ return new Promise(function(resolve, reject){ if( (data.password == "iddqd") &&(data.login == "Carmak") ){ /* Все верно, пользователь авторизирован */ resolve(data); }else{ /* Ошибка при вводе данных */ reject(new Error('Ошибка при вводе логина или пароля')); } }); }

success() и fail()

В зависимости от результата validateData() мы показываем то или иное сообщение пользователю. Вот и все.

``` /* В случае успешной авторизации показываем сообщение */ function success(){ document.querySelector('.message_success').style.display = "block"; document.querySelector('.message_fail').style.display = "none"; }

/* В случае провала авторизации показываем сообщение */ function fail(){ document.querySelector('.message_success').style.display = "none"; document.querySelector('.message_fail').style.display = "block"; } ```

Автар пользователя
madmages

выглядит неплохо. Но а как устроено все с авторизацией для использования апи?

Автар пользователя
edjafarov

@madmages

Про образом PromisePipe можно считать нодовские middleware, так что авторизация выглядит похожим образом:

PromisePipe() .then(fn1) .then(fn2) .then(doOnServer(checkIfUserAuthorizedToDoAction)) .then(doOnServer(doAction1)) .then(doOnServer(doAction2)) .then(fn3) .catch(catchAuthorizationError)

Автар пользователя
Globik

Как дочитал у СайлентИмп до строчки с упоминанием браузерифай, то сразу бросил читать. Извращение.

Автар пользователя
iamstarkov

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

Автар пользователя
edjafarov

@Globik зря - браузерифай - это минимальный коммонжс, вебпак тоже ок с ПромисПайпами. Просто все что можно сделать браузерифаем - можно сделать вебпаком.

Коммонжс нужен в любом случае, т.к. у нас же изоморфный.

Тоесть на вопрос почему браузерифай - ответ, без разницы.

На вопрос зачем КоммонЖс - ответ, потомучто у нас изоморфный код, а нода юзает коммонжс.

Автар пользователя
jt3k

Довольно сложным языком написано. Примеры не достаточно разобраны. Кажется что человек описывает разработку для своих приятелей которые с ней уже знакомы. А у остальных читателей возникнет масса вопросов. Ответить на которые сможет только автор. Предвижу некоторых гневных комментаторов напишу сразу что с промисами знаком и с промиспайпами поработал.

Автар пользователя
edjafarov

@jt3k статьей я хотел закинуть мысль о том что можно писать один код для клиента и сервера, и что можно взять ПромисПайпы и уже поиграться.

Я надеюсь что смогу найти слова и ответить на все вопросы. Сейчас я пишу серию статей с простыми примерами. Вот первая http://eldar.djafarov.com/2015/08/PromisePipe-basics/

там же можно и поигратсья онлайн с базовыми возможностями http://requirebin.com/?gist=51886d9c0cc4f41a6a9c