
Написал недавно движок для синхронизации данных, имеющий первоклассную поддержку оффлайна. Например, можно уйти в оффлайн, изменять данные, закрыть браузер, открыть браузер, открыть сайт (выйти в онлайн) и данные смержатся без потерь. Также во время онлайна данные между клиентом и сервером синхронизируются в реальном времени. Хочу рассказать, в чём была идея, какие есть подобные решения/технологии и кому это может пригодиться.
Проблема
Требования к веб-приложениям всё время повышаются. Если раньше все были довольны статическими страничками, то сейчас пользователи хотят, чтобы комментарии под картинками котиков обновлялись сразу, лайки накручивались на глазах, уведомления о событиях приходили не дожидаясь перезагрузки страницы. С ростом популярности мобильных устройств, появилась концепция 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