Написал недавно движок для синхронизации данных, имеющий первоклассную поддержку оффлайна. Например, можно уйти в оффлайн, изменять данные, закрыть браузер, открыть браузер, открыть сайт (выйти в онлайн) и данные смержатся без потерь. Также во время онлайна данные между клиентом и сервером синхронизируются в реальном времени. Хочу рассказать, в чём была идея, какие есть подобные решения/технологии и кому это может пригодиться.





Проблема


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


  1. YChebotaev
    21.02.2016 11:01
    +8

    1. Написать фреймворк для списка дел.
    2. Зачеркнуть первый пункт.
    3. ???
    4. Прибыль!


  1. Balek
    21.02.2016 11:11
    +1

    Привет. Звучит вкусно. Для коллаборейтива, получается, нужно только добавить другие типы данных? Архитектурно этому ничего не мешает? Ну да, ещё нужно, чтобы компоненты генерировали правильные операции над данными. И тогда уже получается полный аналог стека Derby/Racer/Share. Помимо оффлайна получаем ещё промисы и, я так понимаю, отсутствие необходимости мучительно пихать все подписки в контроллер. Вкууусно. В дерби даже последние два пункта при моей жизни, видимо, не сделают.

    От этих проекций толку мало, на мой взгляд. Один только юзкейс с пользователями и паролями. Я в Share использую filter и "вырезаю" ненужное динамически, в зависимости от того, откуда идёт запрос (клиент или сервер) и от прав доступа.

    В preHook можно менять операцию? Всё-таки при создании документа дату хотелось бы сохранять именно так, а не через пост-хук. Чтобы получить атомарность в таких простейших случаях. В рейсере, я так и не понял, можно это делать или нет.

    У дмаппера есть большие планы на проект?=)

    Желаю успехов. Мне кажется, что проект имеет очень большие шансы выстрелить. Особенно, если ангуляр2 не пошатнет позиции реакта.


    1. Balek
      21.02.2016 11:14

      И еще: не думал над тем, чтобы такие пути "users.2.name" заменить на такие: "users[2].name"?


    1. vmakhaev
      21.02.2016 13:27

      Другие типы данных теоретически можно добавить, но это не в ближайших планах. К тому же я еще не придумал как лучше это сделать. Можно, например, попробовать сделать общий JSON тип данных, как в ShareJS, который включает операции над объектами, массивами, строками и числами, но это не просто. Другой вариант, сделать, документы с типом "строка". То есть у документа как бы нету полей и он является строкой. Такое сделать проще и, вообщем, достаточно для коллаборационного редактирования текста.

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

      В preHook операцию менять нельзя, также как и в Racer. Ибо тот кто, тебе эту операцию послал ничего об этом изменении не узнает и у него останется старая версия операции. Тут в любом случае нужна еще одна операция. По поводу атомарности можно подумать, потому что в Amelisa состояние и oplog хранятся в одном документе и по идее, если применять несколько операций на сервере синхронно, то можно сделать, что они должны сохраниться либо все вместе, либо ни одна из них.

      По поводу путей. Сейчас можно писать так model.get('users.2.name') и так model.get('users', 2, 'name'). Твой вариант со скобочками — это отсылка к динамическим свойствам js?

      Пилим мобильное приложение на Amelisa. Надеюсь стабилизировать до релиза :-)

      Спасибо за пожелания.


      1. 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 — большие планы.)

        Спасибо за пожелания.
        Да, какой там. Этож в моих интересах.) Так что спасибо за проект. Вселяет надежды.


        1. vmakhaev
          21.02.2016 14:51

          В racer-access можно было ограничить доступ к документу целиком, но не к конкретным полям. По этому и появились проекции.
          Если придумаешь какой-то красивый интерфейс для access control, дай знать. Тут определенно что-то нужно сделать.

          По путям. В Amelisa нет темлейтов, по этому такой путаницы не будет. Но при желании можно добавить нотацию с квадратными скобками.
          В Derby для ключей из цифр нужно добавлять _ перед ним в пути. Где-то в доках это есть. Дело в том, что если он видит цифру в пути, то подразумевает, что это должен быть массив. В Amelisa нет операций массивами, соответственно, нет таких проблем :-)


          1. Balek
            21.02.2016 15:57

            Ты путаешь с share-access скорее всего. Racer-access позволял сделать так:
            store.allow 'remove', 'users.*.names', (userId, index, howMany, doc, session) -> session.user.id == userId

            Щаблон для пути можно указать любой. Но чего-то мне всё ещё не хватает. В ближайшее время надеюсь его переписать. Буду думать над этим. Если что дельное придумаю, дам знать.


  1. sparksounds
    21.02.2016 13:09

    Однозначно в избранное и звезду на github. Есть у меня один проект, который сейчас в стадии "уже надо рефакторить" и ваша разработка очень кстати.


  1. lolmaus
    23.02.2016 11:29

    1. Для меня главной характеристикой CRDT/OT-подобных систем является ограничение на выбор бэкенда. Ни одно существующее решение невозможно встроить в имеющийся бэкенд, только начинать с нуля. Это очень грустно. А Swarm так вообще заменяет собой бэкенд, и всю бизнес-логику приходится писать в подобии хранимых процедур.

    2. Amelisa каждый документ — это обычный key-value (операции над объектами).

      Не понятно, о каком типе данных идет речь. В моем JS-понимании "объект" подразумевает JSON.

    3. Совсем не понимаю, зачем нужно делать привязку к React в качестве требования. Это же слой модели, почему он должен быть жестко завязан на одну реализацию вьюхи? На крайняк же можно сделать абстрактный слой модели и адаптер под вьюху, типа, amelisa, amelisa-react, amelisa-angular, etc.


    1. vmakhaev
      23.02.2016 12:31

      1. Это правда. Объясняется это наличием метаданных (oplog, версии документов и тп), что делает практически невозможным изменять данные в БД, в обход движка. К тому же обычно на сервере нужна NodeJS. Выбор баз данных тоже ограничен. Встроить это в существующую систему практически невозможно. По крайней мере, потребует кардинального изменения архитектуры, где, например, Mongo будет хранилищем данных, которые интересны фронт-энду, и остальной бэк-енд будет в нее складывать результаты каких-нибудь вычислений/преобразований.

      2. Здесь имеется ввиду, что в Amelisa есть только операции подобные key-value хранилищу: можно записать значение в поле объекта и удалить поле из объекта. В JSON существуют еще массивы, числа, строки, каждый из которых имеет свои операции. Например, можно добавить элемент в конец массива (даже не зная сколько там уже элементов) или, например, инкрементировать число (не зная его текущего значения). Полноценная JSON-реализация есть в ShareJS.

      3. Всё правильно говоришь. Жесткой привязки к вьюхе нету. Просто для начала взят React, как одна из популярных вьюх, к которой довольно легко привязаться, для чего написано пару хэлперов. В будущем добавятся другие вьюхи/фреймворки и логично будет разнести это по модулям, как ты и написал. Просто на данный момент не хочется распыляться. К тому же можно будет попробовать посмотреть на другие базы данных, например, RethinkDB выглядит интересной.


      1. lolmaus
        23.02.2016 12:53

        2) То есть если два устройства редактируют пост, то "квантом" данных является весь посто целиком, и можно только перезаписать его весь, а слияние невозможно?

        4) А как в Amelisa с совместным редактированием одного документа несколькими пользователями?


        1. vmakhaev
          23.02.2016 13:01

          Да так и есть. Для совместного редактирования нужен, как минимум, тип данных — строка. И в идеале — rich text. Конечно, хочется их добавить, это не приоритете и пока не совсем понятно, как лучше это сделать.


  1. lolmaus
    23.02.2016 12:55

    5) Как насчет ACL (гибкой настройки прав доступа — на уровне документа, на уровне поля)?


    1. vmakhaev
      23.02.2016 13:14

      Так как движок документо-ориентированный и минимальной сущностью, на которую можно подписаться, является документ, с ограничениями прав на уровне документа нету проблем. Есть серверный хук preHook, через который проходят все операции и там можно выбросить ошибку, если нет доступа. Хочется добавить, конечно, какой-нибудь декларативный способ это делать в будущем. С полями чуть сложно. Самое лучшее решение, что я знаю — это концепция проекций в ShareJS. Проекция — это виртуальная коллекция, документы, которой содержат только часть полей. С проекцией можно работать так же, как и с обычной коллекцией. Если ты из нее что-то читаешь, то у документов "обрезаются" лишние поля. Если ты в нее пишешь, то идёт проверка, на то, чтобы операции были только в разрешенные поля, иначе ошибка. Ровно в таком же виде, проекции есть в Amelisa.


  1. none
    24.02.2016 01:24

    довольно успешно использовал на одном проект решение, построенное с использованием Dexie.js — на клиенте использовал аддоны, идущие в поставке с Dexie, бекенд реализовал взяв за основу заготовку, также предоставляемую в примерах Dexie.js