Как Амплифер использует 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
передаются несколько параметров:
- Токен и идентификатор пользователя. Нужно договориться с бэкендами, как их передавать. У нас через gon;
- Адрес к Logux-серверу;
- Версия протокола. Это классная штука, позволяющая перезагружать клиент, если внесены несовместимые изменения в формат данных.
Стор подключён к 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
} 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
} 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). Мы поняли, что надо показывать индикатор загрузки только в боковом меню с чатом, не блокируя весь редактор. Так и сделали — проблема исчезла.
После уведомлений об ошибках комментариев к постам мы вошли во вкус и не собирались останавливаться, ведь 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 ошибок, это немного. Обращений в поддержку практически не было, но мы подготовили блок-схему для диагностики, чтобы быстро определять проблему и решать её:
Мы обнаружили две причины, почему не устанавливается 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
Вот пример на конкретном баге:
- Открываем попап со списком RSS-импортов в первой вкладке;
- Открываем вторую вкладку с приложением, но не открываем попап;
- Добавляем RSS-ленту, умышленно допуская опечатку в адресе;
- Появляется ошибка и происходит откат экшена;
- Во второй вкладке внезапно открывается попап!
Вводим в консоли:
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 это сделать в разы проще.
Закрепим:
- Изначально мы остановились на Logux, потому что он легко интегрируется с Redux, поддерживает CRDT и работу без соединения;
- C Logux не нужно делать запросы вручную, заморачиваться над интеграцией в стор и следить за выполнением запросов;
- Logux помогает серьёзно упростить код, потому что не нужно программировать запросы к бэкенду;
- У Logux есть недостатки: система непростая, ещё есть баги и решения не всегда находятся быстро;
- В «Амплифере» плюсы Logux кратно перевесили минусы. Мы планируем и дальше использовать при реализации подходящих проектов.
⌘⌘⌘
Я надеюсь, что Logux найдёт применение в вашем проекте. Если остались вопросы, то пишите мне в твиттере или на почту.
Спасибо Александру Марфицину и Андрею Ситнику за помощь при подготовке статьи.