Написал недавно движок для синхронизации данных, имеющий первоклассную поддержку оффлайна. Например, можно уйти в оффлайн, изменять данные, закрыть браузер, открыть браузер, открыть сайт (выйти в онлайн) и данные смержатся без потерь. Также во время онлайна данные между клиентом и сервером синхронизируются в реальном времени. Хочу рассказать, в чём была идея, какие есть подобные решения/технологии и кому это может пригодиться.
Проблема
Требования к веб-приложениям всё время повышаются. Если раньше все были довольны статическими страничками, то сейчас пользователи хотят, чтобы комментарии под картинками котиков обновлялись сразу, лайки накручивались на глазах, уведомления о событиях приходили не дожидаясь перезагрузки страницы. С ростом популярности мобильных устройств, появилась концепция Offline First, с идеей, что приложение должно учитывать нестабильность или отсутствие сети.
Веб — это распределенная среда. А синхронизация данных в распределенной асинхронной среде — задача непростая. Всё облегчается тем, что для большинства приложений нету строгих требований к достоверности отображаемых данных. Не так важно, если с сервера не дойдёт какой-то комментарий для картинки с котиком или если два пользователя увидят чуть разное кол-во лайков.
Обычно, в таких случаях, к уже существующему REST-API прикручивается реализация реал-тайма, где сервер каким-то образом знает, какие данные интересуют клиента в данный момент времени (подписки) и при изменении этих данных в бд, шлёт клиенту патчи с обновлениями через WebSocket соединение, либо используется какое-то готовое решение. Давайте посмотрм поближе, что сейчас популярно использовать для работы с данными в вебе.
Решения
Flux — методология (и реализация) для работы с данными от Facebook. Главная идея — однонаправленность. Как именно данные достаются с сервера не входит в область интереса Flux. При желании, можно сделать Flux-сторы реалтаймовыми.
Redux — новая и самая популярная Flux-подобная библиотека. Отличается от Flux простотой (нет диспатчера, один стор итп).
Relay — новый фреймворк от Facebook, пришедший на смену методологии Flux. Каждый React компонент, может запросить с сервера данные, которые нужны ему. Делается это с помощью GraphQL языка. Такой абстрактный язык запросов может быть хорошим решением, если у вас много разных источников данных (баз данных), но при этом появляется необходимость руками описывать, как он преобразуется в языки запросов баз данных для всей схемы данных. Relay позволяет запросить только часть документа и разрешает ситуации, когда несколько компонентов запросили одни и те же данные, что полезно, когда у вас большое кол-во компонентов. Подписки должны скоро появиться.
Falcor — движок от Netflix. Имеет единый интерфейс для работы с локальными и удалёнными данными. Также интересна концепция paths.
Meteor был своего рода революционером и сильно продвинул идеи изоморфного api для работы с данными и реалтайма. Подписка осуществляется напрямую на монго-запросы, а в качестве патчей при обмене данными выступают операции из монговского oplog'а. Meteor — это даже не фреймворк, а скорее платформа, со своим пакетным менеджером.
Firebase — реалтаймовый BAAS. Интересный и довольно популярный платный сервис, решающий проблему реалтайма в веб-приложениях.
Diffsync использует алгоритм нахождения diff'ов для JSON-объектов, похожий на то, что делает Git для строк. Затем, клиент и сервер обмениваются этими diff'ами. Это может неплохо работать, если у вас в приложении не высокая коллаборативность.
Для того чтобы, полностью застраховать себя от недостоверности и от потери данных, недостаточно обмениваться патчами, нужен более серьезный подход. Есть две техники разрешения конфликтов для распределенных асинхронных систем — OT и CRDT. Данные представляются в виде состояния и лога операций. Состояние является результатом последовательного применения всех операций из лога и имеет свою версию. Обычно минимальной сущностью состояния является документ и в качестве хранилища используются документо-ориентированные базы данных. Лог операций может храниться в том же или другом хранилище. Помимо этого, вместе с состоянием хранятся определенные метаданные — версия состояния, таймстемп, тип данных и тп. У CRDT есть еще state-based вариант, который используется, например, в Riak. Но передавать, при каждом изменении, весь документ (состояние) не так эффективно, как только одну операцию, по этому обычно в вебе используются op-based CRDT.
ShareJS — самая популярная реализация OT. Я уже писал про нее. Можно добавить, что главными достоинствами ShareJS являются операции над строками, массивами, числами (это полезно, если, например, делаете коллаборационный редактор), а также наличие реализации общего JSON типа данных и возможность подписки на Mongo запросы. Для OT нужен источник правды, где собственно и преобразуются операции. Обычно это сервер. Реализовать полноценный оффлайн для OT — задача крайне сложная (не знаю ни одной реализации). Надо сказать, что OT активно используется Google в сервисах типа Docs, Wave. Мы, в компании, где я работаю, используем ShareJS (как часть фреймворка DerbyJS) уже ни один год, и полёт нормальный.
Все описанные выше движки/библиотеки не имеют полноценной поддержки оффлайн, потому что для этого нужны равноправные распределенные реплики данных, как в Git, глобальные версии состояний и тп. Тут есть разные подходы, но наиболее интересным, на мой взгляд, является CRDT. Решения в этой области выглядят так:
Hoodie — Offline First фреймворк, завязанный на CouchDB. В новой версии на клиенте будет использовать PouchDB. CouchDB хранит всю историю состояний документов, это и используется для оффлайна. Можно провести аналогию с Git — при оффлайне, история состояний разделяется на две ветки: серверную и клиентскую, а при онлайне они мержатся. Чем-то похоже на state-based CRDT. CouchDB — по большому счёту key-value хранилище, есть так же базовая реализация запросов, но не такая богатая, как, например, в Mongo.
Swarm — CRDT движок, разработанный нашими сибирскими учёными. Имеет много типов данных — key-value, строки, массивы и тп. Swarm не завязан ни на какую базу данных и, соответственно, не поддерживает подписку за запросы. Реализация подписок на запросы в общем виде (без завязки на конкретную базу данных) — дело нетривиальное.
У Виктора очень интересные доклады и интервью.
Общим моментом для всех решений является то, что если есть серверная часть, то она написана на Javascript и требует NodeJS. Это объясняется тем, что существенная часть кода между клиентом и сервером переиспользуется.
Наверняка есть еще интересные решения, про которые я не знаю или не вспомнил. Делитесь в комментариях, будем обсуждать.
Amelisa
Идея заключалась в том, чтобы совместить CRDT оффлайн возможности и подписки на Mongo-запросы. Обернуть это в изоморфный Racer-подобный api и интегрировать с React. Добавить наработки из ShareJS по контролю доступа, масштабированию и тп.
По сравнению со SwarmJS пришлось пожертвовать разнообразием типов данных. В отличие от ShareJS и его общего JSON типа данных (включающиего операции над объектами, массивами, строками и числами), в Amelisa каждый документ — это обычный key-value (операции над объектами).
Из Transmit взята идея как реализовать серверный рендеринг для дерева компонентов, каждый из которых подписывается на данные изолированно. Сложность тут в том, что не известны входящие данные для нижележащих компонентов, до момента пока вышележащие не получат данные из базы и не отрендерятся.
Также есть подобие join'ов и возможность смешивать подписки на Mongo-запросы с фетчем данных с обычных url (REST-api, сторонние сервисы и тп.).
Более подробно о возможностях читайте в документации. Хотя в данный момент она оставляет желать лучшего и, возможно, проще посмотреть на пример CRUD-приложения, в котором есть авторизация на основе модуля Amelisa Auth и базовый access control. Для самых смелых — исходники.
Скорее всего, Amelisa будет интересна в тех проектах, где нужен оффлайн и синхронизация данных, но при этом нету требования делать коллаборационные редакторы и тп. Хорошим use-кэйсом может быть todo-list приложение, которые работает на телефоне в виде нативного или веб-приложения и на десктопе в виде веб-приложения. При этом пользователь не хочет думать в онлайне он или в оффлайне, чтобы посмотреть свой список задач на сегодня, отметить законченные или добавить новые. А в момент, когда он окажется в онлайне, данные синхронизируются между всеми устройствами.
Также в требования для Amelisa можно включить React, Mongo, NodeJS.
В данный момент работаю над стабилизацией и интеграцией c React Native. Также мы пишем мобильное приложение, где используется Amelisa и совсем скоро оно должно пойти в продакшен. Лучший способ следить за новостями — подписаться на Twitter Amelisa.
Комментарии (15)
Balek
21.02.2016 11:11+1Привет. Звучит вкусно. Для коллаборейтива, получается, нужно только добавить другие типы данных? Архитектурно этому ничего не мешает? Ну да, ещё нужно, чтобы компоненты генерировали правильные операции над данными. И тогда уже получается полный аналог стека Derby/Racer/Share. Помимо оффлайна получаем ещё промисы и, я так понимаю, отсутствие необходимости мучительно пихать все подписки в контроллер. Вкууусно. В дерби даже последние два пункта при моей жизни, видимо, не сделают.
От этих проекций толку мало, на мой взгляд. Один только юзкейс с пользователями и паролями. Я в Share использую filter и "вырезаю" ненужное динамически, в зависимости от того, откуда идёт запрос (клиент или сервер) и от прав доступа.
В preHook можно менять операцию? Всё-таки при создании документа дату хотелось бы сохранять именно так, а не через пост-хук. Чтобы получить атомарность в таких простейших случаях. В рейсере, я так и не понял, можно это делать или нет.
У дмаппера есть большие планы на проект?=)
Желаю успехов. Мне кажется, что проект имеет очень большие шансы выстрелить. Особенно, если ангуляр2 не пошатнет позиции реакта.Balek
21.02.2016 11:14И еще: не думал над тем, чтобы такие пути "users.2.name" заменить на такие: "users[2].name"?
vmakhaev
21.02.2016 13:27Другие типы данных теоретически можно добавить, но это не в ближайших планах. К тому же я еще не придумал как лучше это сделать. Можно, например, попробовать сделать общий JSON тип данных, как в ShareJS, который включает операции над объектами, массивами, строками и числами, но это не просто. Другой вариант, сделать, документы с типом "строка". То есть у документа как бы нету полей и он является строкой. Такое сделать проще и, вообщем, достаточно для коллаборационного редактирования текста.
Поправь меня, если я не прав, в фильтре можно убирать не нужные поля при чтении, но нельзя ограничивать их редактирование. Вообще, мне надо глянуть на это, спасибо за наводку. Просто по какой-то причине, я, наоборот, использую проекции и не использую фильтры.
В preHook операцию менять нельзя, также как и в Racer. Ибо тот кто, тебе эту операцию послал ничего об этом изменении не узнает и у него останется старая версия операции. Тут в любом случае нужна еще одна операция. По поводу атомарности можно подумать, потому что в Amelisa состояние и oplog хранятся в одном документе и по идее, если применять несколько операций на сервере синхронно, то можно сделать, что они должны сохраниться либо все вместе, либо ни одна из них.
По поводу путей. Сейчас можно писать такmodel.get('users.2.name')
и такmodel.get('users', 2, 'name')
. Твой вариант со скобочками — это отсылка к динамическим свойствам js?
Пилим мобильное приложение на Amelisa. Надеюсь стабилизировать до релиза :-)
Спасибо за пожелания.Balek
21.02.2016 14:24Поправь меня, если я не прав, в фильтре можно убирать не нужные поля при чтении, но нельзя ограничивать их редактирование.
Да, конечно. Но инструмент гораздо более гибкий. На мой вгляд, запрет на редактирование должен осуществляться по-другому. Ближе всего к этому заброшенный racer-access. Нужно прописывать этакие правила, как в фаерволе, для всего, что разрешено. Всё остальное запрещено. От racer-access мне только чуть не хватает высокоуровневости. Чтобы одновременно описывать и создание, и редактирование документа. А то часто DRY из-за этого нарушается. В общем, над этой темой надо покумекать, но пока времени нет. Хочется server-query, racer-access, share-scheme и фильтры как-то скомбинировать.
В preHook операцию менять нельзя, также как и в Racer. Ибо тот кто, тебе эту операцию послал ничего об этом изменении не узнает и у него останется старая версия операции.
Да, тупанул. Иначе же никакого оффлайна не выйдет, действительно. Хотя вот racer мог бы заменять свою операцию на ту, что вернётся с сервера. Хоть это и геморрой. Транзакции были бы вообще бомбой, но с оффлайном, наверное, всё равно возникнут проблемы.
По поводу путей.
Нет, это отсылка к массивам.) Просто с дерби получается, что биндинг мы делаем так: {{ user.names[1] }}, а операцию делаем через 'user.names.1'. И я вот не понимаю, зачем вообще ввели эту нотацию. Плюс к этому, я использую lodash, который тоже поддерживает нотацию с квадратными скобками. И получается, что $user.get('names.1'), но _.get($user.get(), 'names[1]'). А ещё один раз я хотел получить объект, у которого ключи начинались с цифры. И фиг. Но это, конечно, не самое страшное.
Пилим мобильное приложение на Amelisa.
Это не большие планы.) Заменить derby на amelisa — большие планы.)
Спасибо за пожелания.
Да, какой там. Этож в моих интересах.) Так что спасибо за проект. Вселяет надежды.vmakhaev
21.02.2016 14:51В racer-access можно было ограничить доступ к документу целиком, но не к конкретным полям. По этому и появились проекции.
Если придумаешь какой-то красивый интерфейс для access control, дай знать. Тут определенно что-то нужно сделать.
По путям. В Amelisa нет темлейтов, по этому такой путаницы не будет. Но при желании можно добавить нотацию с квадратными скобками.
В Derby для ключей из цифр нужно добавлять_
перед ним в пути. Где-то в доках это есть. Дело в том, что если он видит цифру в пути, то подразумевает, что это должен быть массив. В Amelisa нет операций массивами, соответственно, нет таких проблем :-)Balek
21.02.2016 15:57Ты путаешь с share-access скорее всего. Racer-access позволял сделать так:
store.allow 'remove', 'users.*.names', (userId, index, howMany, doc, session) -> session.user.id == userId
Щаблон для пути можно указать любой. Но чего-то мне всё ещё не хватает. В ближайшее время надеюсь его переписать. Буду думать над этим. Если что дельное придумаю, дам знать.
sparksounds
21.02.2016 13:09Однозначно в избранное и звезду на github. Есть у меня один проект, который сейчас в стадии "уже надо рефакторить" и ваша разработка очень кстати.
lolmaus
23.02.2016 11:29- Для меня главной характеристикой CRDT/OT-подобных систем является ограничение на выбор бэкенда. Ни одно существующее решение невозможно встроить в имеющийся бэкенд, только начинать с нуля. Это очень грустно. А Swarm так вообще заменяет собой бэкенд, и всю бизнес-логику приходится писать в подобии хранимых процедур.
Amelisa каждый документ — это обычный key-value (операции над объектами).
Не понятно, о каком типе данных идет речь. В моем JS-понимании "объект" подразумевает JSON.
- Совсем не понимаю, зачем нужно делать привязку к React в качестве требования. Это же слой модели, почему он должен быть жестко завязан на одну реализацию вьюхи? На крайняк же можно сделать абстрактный слой модели и адаптер под вьюху, типа,
amelisa
,amelisa-react
,amelisa-angular
, etc.
vmakhaev
23.02.2016 12:31- Это правда. Объясняется это наличием метаданных (oplog, версии документов и тп), что делает практически невозможным изменять данные в БД, в обход движка. К тому же обычно на сервере нужна NodeJS. Выбор баз данных тоже ограничен. Встроить это в существующую систему практически невозможно. По крайней мере, потребует кардинального изменения архитектуры, где, например, Mongo будет хранилищем данных, которые интересны фронт-энду, и остальной бэк-енд будет в нее складывать результаты каких-нибудь вычислений/преобразований.
- Здесь имеется ввиду, что в Amelisa есть только операции подобные key-value хранилищу: можно записать значение в поле объекта и удалить поле из объекта. В JSON существуют еще массивы, числа, строки, каждый из которых имеет свои операции. Например, можно добавить элемент в конец массива (даже не зная сколько там уже элементов) или, например, инкрементировать число (не зная его текущего значения). Полноценная JSON-реализация есть в ShareJS.
- Всё правильно говоришь. Жесткой привязки к вьюхе нету. Просто для начала взят React, как одна из популярных вьюх, к которой довольно легко привязаться, для чего написано пару хэлперов. В будущем добавятся другие вьюхи/фреймворки и логично будет разнести это по модулям, как ты и написал. Просто на данный момент не хочется распыляться. К тому же можно будет попробовать посмотреть на другие базы данных, например, RethinkDB выглядит интересной.
lolmaus
23.02.2016 12:532) То есть если два устройства редактируют пост, то "квантом" данных является весь посто целиком, и можно только перезаписать его весь, а слияние невозможно?
4) А как в Amelisa с совместным редактированием одного документа несколькими пользователями?vmakhaev
23.02.2016 13:01Да так и есть. Для совместного редактирования нужен, как минимум, тип данных — строка. И в идеале — rich text. Конечно, хочется их добавить, это не приоритете и пока не совсем понятно, как лучше это сделать.
- Это правда. Объясняется это наличием метаданных (oplog, версии документов и тп), что делает практически невозможным изменять данные в БД, в обход движка. К тому же обычно на сервере нужна NodeJS. Выбор баз данных тоже ограничен. Встроить это в существующую систему практически невозможно. По крайней мере, потребует кардинального изменения архитектуры, где, например, Mongo будет хранилищем данных, которые интересны фронт-энду, и остальной бэк-енд будет в нее складывать результаты каких-нибудь вычислений/преобразований.
- Для меня главной характеристикой CRDT/OT-подобных систем является ограничение на выбор бэкенда. Ни одно существующее решение невозможно встроить в имеющийся бэкенд, только начинать с нуля. Это очень грустно. А Swarm так вообще заменяет собой бэкенд, и всю бизнес-логику приходится писать в подобии хранимых процедур.
lolmaus
23.02.2016 12:555) Как насчет ACL (гибкой настройки прав доступа — на уровне документа, на уровне поля)?
vmakhaev
23.02.2016 13:14Так как движок документо-ориентированный и минимальной сущностью, на которую можно подписаться, является документ, с ограничениями прав на уровне документа нету проблем. Есть серверный хук
preHook
, через который проходят все операции и там можно выбросить ошибку, если нет доступа. Хочется добавить, конечно, какой-нибудь декларативный способ это делать в будущем. С полями чуть сложно. Самое лучшее решение, что я знаю — это концепция проекций в ShareJS. Проекция — это виртуальная коллекция, документы, которой содержат только часть полей. С проекцией можно работать так же, как и с обычной коллекцией. Если ты из нее что-то читаешь, то у документов "обрезаются" лишние поля. Если ты в нее пишешь, то идёт проверка, на то, чтобы операции были только в разрешенные поля, иначе ошибка. Ровно в таком же виде, проекции есть в Amelisa.
YChebotaev