Logux — инструмент для связи клиента и сервера


Меня зовут Виталий Ризо, я старший фронтенд-разработчик в «Амплифере». Поделюсь, как мы применяем Logux в веб-приложении: организуем обмен данными в реальном времени, уведомления об ошибках без перезагрузки страницы, общение между вкладками браузера и интеграцию с Redux.


«Амплифер» — это сервис для публикации в социальные сети. Нужно было быстро и надёжно уведомлять пользователей об ошибках без перезагрузки страницы: если вдруг не удалось обработать изображение, у «ВКонтакте» упал API, или Фейсбук в очередной раз решил не публиковать пост. Забегая наперёд скажу, что также мы планировали использовать Logux для обсуждения публикаций и переписывания попапа кросспостинга из RSS. Но сначала о том, почему нас не устроили имеющиеся решения.


Возможные решения: WebSocket, Firebase и Swarm.js


Обычно для реализации автообновляемой информации используют WebSocket. С ним не нужно отправлять запросы на получение информации каждую секунду, как пришлось бы делать с традиционными HTTP-запросами, и размер заголовка будет небольшой. Однако у WebSocket есть недостатки:


  • Сложно реализовать обновления в реальном времени. Нужно учесть порядок событий, интеграцию со сторами, подумать о масштабируемости, повторной отправке событий и так далее. Ещё хуже, если требуется отправлять данные с клиента на сервер: необходимо учитывать закрытие браузера в момент отправки, создавать очередь событий на отправку;
  • Можно пропустить важное событие, когда пропадает соединение. Особенно актуально на мобильном интернете;
  • Каждая вкладка создаёт отдельное соединение, что сильно нагружает сервер, клиент и сеть;
  • Для совместного редактирования нужно мержить поступающие изменения, решать конфликты и всё такое (CRDT), а реализовать это сложно.

Есть Firebase, это готовое решение для работы с живым обновлением, но частично проприетарное и требующее переписывания базы данных на бэкенде — для большого проекта с наследием это запредельно сложно. Также в Firebase нет готовой реализации CRDT, да и работать с БД и Redux не самое весёлое занятие. Готовая реализация CRDT есть у Swarm.js, но интегрировать его с Redux крайне сложно, мы пытались.


Почему остановились на Logux


Андрей Ситник придумал решение, учитывающее все требования — Logux. Это инструмент для связи клиента и сервера, который легко интегрируется с Redux, поддерживает CRDT и работу без соединения. Для фронтенд-разработчика это отличное решение, ведь можно забыть про API-запросы: просто диспатчишь события, и происходит магия.


Вот, например, как выполняется запрос «Редактирование записи в БД». Было:


imports-api.js


update (project, id, data) {
  return put(project, `settings/imports/${ id }`, convert(data))
}

imports.js


onUpdate (projectId, importId, changed) {
  return dispatch({
    projectId,
    importId,
    import: changed,
    type: 'amplifr/imports/update'
  })
}

Стало:


imports.js


let dispatch = useDispatch()
let onUpdate = (projectId, importId, changed) => dispatch.sync({
  projectId,
  importId,
  import: changed,
  type: 'amplifr/imports/update'
}, { channels: ['imports/' + projectId] })

Достаточно добавить sync к dispatch, указать канал, и запрос отправится на бэкенд, при этом изменения происходят автоматически во всех вкладках. Теперь расскажу, как применяли Logux для уведомлений об ошибках.


Как использовали Logux для уведомлений об ошибках в Амплифере


Новый раздел ошибок


Новый раздел ошибок


Вернёмся к первоначальной задаче — уведомлениях об ошибках публикации. В Амплифере есть проекты, в которых сгруппированы подключённые страницы в соцсетях. Нужно было добавить соответствующий раздел, где отображаются уведомления для текущего проекта.


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


На фронтенде в первую очередь интегрируем Logux в Redux. Для создания стора Redux используется функция createStore. Чтобы подключить стор к Logux, достаточно «обернуть» эту функцию в специальный createLoguxCreator:


import { createLoguxCreator } from '@logux/redux'

let createStore = createLoguxCreator({
  credentials: loguxToken,
  subprotocol: '0.6.5',
  userId,
  server: 'wss://logux.amplifr.com'
})

let store = createStore(reducers)

store.client.start()

В createLoguxCreator передаются несколько параметров:


  1. Токен и идентификатор пользователя. Нужно договориться с бэкендами, как их передавать. У нас через gon;
  2. Адрес к Logux-серверу;
  3. Версия протокола. Это классная штука, позволяющая перезагружать клиент, если внесены несовместимые изменения в формат данных.

Стор подключён к Logux-серверу и теперь мы получаем сообщения с бэкенда на фронтенде, и наоборот. События, в мета-данных которых будет указан канал posts/1, будут синхронизированы между фронтом и бэком. Не нужно делать запросы вручную, думать над интеграцией в стор и следить за WebSocket.


Как теперь подписаться на изменения? Достаточно придумать название канала — это просто строка, например, posts/1. Воспользуемся методом subscribe и половина работы сделана — теперь компонент подписан на канал и готов получать данные с бэкенда:


import useSubscription from '@logux/redux/use-subscription'

let Notices = props => {
  let isSubscribing = useSubscription([`projects/${ props.id }`])

  if (isSubscribing) {
    return <Loader />
  } else {
    // Render Notices
  }
}

На бэкенде это работает примерно так — если происходит ошибка публикации, то бэкенд посылает HTTP-запрос с новым Redux-экшеном на сервер Logux, а Logux-сервер рассылает событие всем клиентам:


def schedule_logux
  LoguxNotificationWorker.perform_async(
    { type: 'amplifr/notices/add', notice: Logux::NoticePresenter.new(notice).to_json },
    channels: ["projects/#{project.id}"]
  )
end

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


import { useDispatch } from 'react-redux'

let dispatch = useDispatch()
let onNoticeHide = noticeId => dispatch.sync({
  type: 'amplifr/notices/hide',
  noticeId
}, {
  channels: [`projects/${ projectId }`]
})

Когда реализовывали раздел с ошибками, то убедился, в чём кайф Logux:


  • Код знатно упрощается. Не нужно программировать запросы к бэкенду;
  • Не приходиться думать, как синхронизировать данные;
  • Запросы выполняются в фоне, а значит можно обойтись без индикаторов загрузки (крутилок). Даже если в момент диспатча не было интернет-соединения;
  • В случае ошибки при выполнении запроса на бэкенде, экшен просто откатывается, и интерфейс возвращается в прежнее состояние. Ошибку можно обрабатывать, но в нашем случае этого не требовалось.

Без ошибок не обошлось


Амплифер из-за ошибки в разделе с ошибками


Так выглядел Амплифер из-за ошибки в разделе с ошибками


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


Ещё Logux интересно работает с вкладками в браузере. Вкладки «голосуют» и выбирают лидера, соединение с сервером устанавливается только в лидере, а остальные вкладки получают данные через localStorage. Работает это на удивление быстро и просто, вот только в режиме инкогнито в браузерах Firefox и Safari недоступен localStorage! И в первых версиях Logux это не учитывалось, интерфейс падал, либо данные не приходили. Ух, клиенты Амплифера негодовали.


Более того, вся документация была у Ситника в голове, а в этом Logux без ста граммов не разберёшься, а я не пью, поэтому приходилось задалбывать вопросами Андрея. Сейчас стало удобнее и не надо задумываться над логом, ошибки в браузерах исправлены, появилась документация.


Следующим проектом, который мы реализовали в Амплифере с помощью Logux, стали комментарии для публикаций.


Как использовали Logux для добавления комментариев к постам


Обсуждение публикаций в Амплифере


Несмотря на все недочёты, нам понравился Logux, и мы решили сразу делать на нём обсуждения публикаций. Реализация очень похожа на уведомления об ошибках — если в настройках включен режим обсуждения постов, то подписываем редактор на канал:


let PostEditor = { isApprovable, postId, … } => {
  let isSubscribing = useSubscription(isApprovable ? [`posts/${ postId }`] : [])

  if (isSubscribing) {
    return <Loader />
  } else {
    // Render PostEditor
  }
}

Подключаем к стору notes и создаём экшен отправки сообщения:


import { useDispatch, useSelector } from 'react-redux'

let dispatch = useDispatch()
let notes = useSelector(notes.filter(note => note.postId === postId))
let onNoteAdd = note => dispatch.sync({
  type: 'amplifr/notes/add',
  note
}, {
  channels: [`posts/${ postId }`]
})

Пишем простенький редьюсер. Мы ещё добавили очистку комментариев при закрытии попапа, чтоб они отъедали меньше оперативки:


export default function notes (state = [], action) {
  if (action.type === 'amplifr/notes/add') {
    return state.concat([action.note])
  } else if (action.type === 'amplifr/posts/close') {
    return state.filter(i => i.postId !== action.postId)
  } else {
    return state
  }
}

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


И здесь не обошлось без ошибок


Вечная загрузка редактора


Как вы уже знаете, мы обернули весь наш редактор публикаций в декоратор @subscribe. Пока идёт подписка на канал, в компонент передаётся параметр isSubscribing: true. Мы решили показывать крутилку вместо попапа по этому параметру, чтобы не показывать редактор, пока загружаются комментарии. Так исключается неприятное дёргание в процессе загрузки.


Пара клиентов пожаловалась, что у них не открывается редактор публикаций. Оказалось, что у них использовался старый прокси squid 3, у которого трудности с WebSocket (а Logux работает через WebSocket). Мы поняли, что надо показывать индикатор загрузки только в боковом меню с чатом, не блокируя весь редактор. Так и сделали — проблема исчезла.


Как реализовали кросспостинг из RSS с помощью Logux


После уведомлений об ошибках комментариев к постам мы вошли во вкус и не собирались останавливаться, ведь Logux позволяет полностью отказаться от ручных AJAX-запросов. Но переводить критически важную функциональность не рискнули, к тому же мы столкнулись с неожиданным «падением» интерфейса «Амплифера» в Firefox.


Следующим значительным переходом от AJAX запросов к Logux был перевод попапа с кросспостингом из RSS. Это автоматический импорт новых постов из подключённых RSS-каналов в выбранные страницы в соцсетях. Нужно было получать список подключённых RSS-потоков, редактировать существующие и подключать новые.


Настройки кросспостинга


Так выглядят настройки кросспостинга


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


import { useDispatch } from 'react-redux'

let dispatch = useDispatch()
let onCreate = (projectId, importId, import) => {
  return dispatch.sync({
    importId,
    import,
    type: 'amplifr/imports/add'
  }, { channels: ['imports/' + projectId] })
}

let onUpdate = (projectId, importId, changed) => {
  return dispatch.sync({
    importId,
    changed,
    type: 'amplifr/imports/update'
  }, { channels: ['imports/' + projectId] })
}

Как видите, в этом коде не учитывается, что запрос выполняется не мгновенно, потому что Logux подразумевает Optimitstic UI — интерфейс без индикаторов загрузки. Я скептически отношусь к этому интерфейсу, потому что в нём не видно, сохранились ли изменения. Всё-таки веб меня приучил, что нужно подождать подтверждения.


Пример оптимистичного интерфейса


Пример «оптимистичного» интерфейса. Валидация происходит на бэкенде и откатывает события через полторы секунды после закрытия попапа


Мы попробовали реализовать интерфейс без «крутилок», но отказались от этой идеи, так как RSS-лента проходит валидацию при сохранении. Если происходит ошибка, то нужно откатить изменения в сторе и показать попап подключения фида и сообщение об ошибки. Это элементарно реализуется с Logux (dispatch.sync(…).catch(…)), но ощущается неестественно. На валидацию уходит секунда-две, в это время пользователь уже может открыть другой раздел, и тогда придёт сообщение об откате.


Из приятных особенностей: в catch() ошибка приходит сразу в JSON, поэтому можно избавиться от try { JSON.parse(…) } catch { … }. С бэкенда можно передавать произвольные ключи в этой ошибке.


Logux работает у всех пользователей?


Для работы Logux нужен рабочий WebSocket, его поддерживают все современные браузеры, а SPA обычно пишут под них. Но проблемы с соединением могут возникать по другим причинам, и мы решили их найти. Добавили несложный код, чтобы проверить, удалось ли установить соединение:


import status from '@logux/client/status'

let connected = false

status(store.client, state => {
  if (state === 'synchronized') connected = true
}

setTimeout(() => {
  if (!connected) {
    sentry.track(new Error('No Logux connection for 10 minutes'))
  }
}, 60 * 1000)

За полгода собрали около 100 ошибок, это немного. Обращений в поддержку практически не было, но мы подготовили блок-схему для диагностики, чтобы быстро определять проблему и решать её:


Алгоритм поиска проблемы в Logux


Мы обнаружили две причины, почему не устанавливается WebSocket: это устаревшие прокси-сервера, которые могут быть настроены на роутере, и расширения для браузеров, блокирующие соединение, например, AdBlocker или Kaspersky Protection. Однако, как я и написал выше, ошибок, связанных с Logux, за полгода набралось немного.


Logux иногда может работать неправильно, и это нормально


Logux ещё не в финальной версии, поэтому изредка могут происходить удивительные вещи. Например, откат события срабатывает в одной вкладке, а во второй нет. Или закрываешь попап с RSS-импортами, и происходит отписка, хотя этот же попап открыт во второй вкладке. Если что-то работает не так, то бывает сложно понять, почему, и нагуглить решение не получится, нужно разбираться самому или идти к Ситнику.


В целом я начинаю понимать, как разрешать проблемы. Самый ценный источник информации — это лог Logux. В нём хранятся все экшены с мета-информацией. Чтобы получить к ним доступ, в файле, где создаётся стор, включаем лог и помещаем стор в глобальную переменную, чтобы обращаться к логу из консоли:


import log from '@logux/client/log'

let store = createStore(reducers)

log(store.client)

window.store = store

Вот пример на конкретном баге:


  1. Открываем попап со списком RSS-импортов в первой вкладке;
  2. Открываем вторую вкладку с приложением, но не открываем попап;
  3. Добавляем RSS-ленту, умышленно допуская опечатку в адресе;
  4. Появляется ошибка и происходит откат экшена;
  5. Во второй вкладке внезапно открывается попап!

Вводим в консоли:


window.store.client.log.store.created.filter(i => i[0].type === 'amplifr/popups/set')

И обнаруживаем, что в мета-информации ошибка: в meta.tab стоит undefined. Именно этот атрибут определяет, что экшен не должен отображаться в других вкладках. Оказалось, что Андрей переименовывал client.id в client.tabId в пакете @logux/redux и в одном месте он забыл обновить id на tabId. Да, как я говорил, Logux не в финальной версии, и сделан на энтузиазме, но Андрей старается.


Мой коллега, фронтенд-разработчик «Амплифера» Дамир Мельников тоже доволен Logux, несмотря на некоторые нюансы:


В самом начале Logux вызывает очень приятный ступор: «Что? Неужели это всё, что надо сделать, чтобы заработало живое обновление данных с сервера?». После того как с ним поработаешь, приятных впечатлений становится ещё больше — ты пишешь гораздо меньше кода, всё, как в Redux. Ещё мне очень близка идея «оптимистичного интерфейса», когда пользователя ничего не блокирует, но он в курсе возможных ошибок. С Logux это сделать в разы проще.


Закрепим:


  1. Изначально мы остановились на Logux, потому что он легко интегрируется с Redux, поддерживает CRDT и работу без соединения;
  2. C Logux не нужно делать запросы вручную, заморачиваться над интеграцией в стор и следить за выполнением запросов;
  3. Logux помогает серьёзно упростить код, потому что не нужно программировать запросы к бэкенду;
  4. У Logux есть недостатки: система непростая, ещё есть баги и решения не всегда находятся быстро;
  5. В «Амплифере» плюсы Logux кратно перевесили минусы. Мы планируем и дальше использовать при реализации подходящих проектов.


???


Я надеюсь, что Logux найдёт применение в вашем проекте. Если остались вопросы, то пишите мне в твиттере или на почту.


Спасибо Александру Марфицину и Андрею Ситнику за помощь при подготовке статьи.