Пишем драйвер поддержки графовой базы данных Neo4j для Meteor
В Meteor любая работа с даными связана с двусторонней реактивностью. На данный момент 100% реактивностью обладают встроенная в Meteor MongoDB и Redis (оба драйвера разработаны в стенах Meteor), частично реактивность реализована для MySQL и MSSQL (сторонними разработчиками).
Для вышеуказанных баз данных реактивность реализована посредством observer'ов, которые сообщают где, как, когда и какие данные изменились, для того чтобы драйвер, обслуживающий связь [данные <-> представление], знал какие данные и у каких Клиентов обновить. Neo4j лишен каких-либо watcher'ов и observer'ов, но это нас не остановило. Как мы вышли из данной ситуации и зачем нам нужен Neo4j читайте под катом.
Под node.js имеется официальный и одноименный npm-пакет от производителей Neo4j, который изобилует функционалом, но в первых же двух строках его документации мягко намекается, что стабильно работает только подключение к базе GraphDatabase и метод query.
Зачем нам Neo4j
Считаю, что под каждую задачу должен быть свой инструмент, созданный и предназначенный для выполнения данной задачи на высшем уровне. Всем известно, что один гвоздь можно, а иногда и нужно (если потраченное время на поиск или средства на покупку молотка неоправданно завышены) забить тапком. Но для 100 и тем более 100000 гвоздей будет целесообразно обзавестись молотком, а лучше гвоздеметом. В нашем случае нам необходимо хранить и получать данные отношений между записями. Сами данные в денормализованном виде мы продолжаем хранить в MongoDB, а вот отношения этих данных мы храним в Neo4j.
Как все начиналось: Connector
Изначально предполагалось, что создав глобальную переменную, держащую в себе объект типа GraphDatabase, функционала, поставляемого в npm-пакете, будет достаточно для поставленных задач: на тот момент мы писали/считывали данные в/из базы (без реактивности). Так родился neo4jdriver — пакет, содержащий в себе глобально доступный класс Neo4j, при инициализации которого создается соединение с базой, запущенной локально или удаленно. При инициализации можно передать единственный параметр url.
Позже появилась потребность в:
- реактивности;
- observer'е;
- изоморфности — возможность выполнять запросы как с сервера, так и с Клиента;
- стуктуризации приходящих данных — Neo4j по умолчанию на любой MATCH запрос выкидывает кучу мусора, который прилично весит.
Тут-то и началось самое интересное.
Как все продолжалось: Reactivity
Вторым на свет вышел пакет neo4jreactivity, основанный на принципе псевдо-реактивности, реализуемой через прослойку в виде MongoDB. Проще говоря, на любой запрос в Neo4j, мы возвращаем — Mongo\Cursor, который в свою очередь является источником реактивных данных или, как это принято называть в Meteor комьюнити: REACTIVE DATA SOURCE.
Изначально все казалось просто:
- Делаем некую кеширующую коллекцию в MongoDB, содержащую запрос, хеш запроса и ответ от Neo4j;
- Для Клиента создаем сессию, в которой держим массив, содержащий все хеши запросов, на которые необходимо подписаться;
- Все запросы пропускаем через метод Meteor.neo4j.query, который создает хеш из запроса к БД, получает ответ из БД, записывает его в базу и рассылает всем Клиентам, подписанным на данный хеш запроса;
- Для запуска запросов с Клиента делаем Meteor-метод, который кушает! любой запрос и исполняет его на сервере.
На момент релиза одной из первых версий драйвера, на Клиенте Вы могли запустить абсолютно любой запрос, т.е. могли изменить, получить или стереть все данные хранимые в Neo4j. Данная проблема была решена посредством введения методов Meteor.neo4j.methods({}) и Meteor.neo4j.call(methodName, opts, callback), которые работают по принципу стандартного Meteor.methods({}), пример:
if(Meteor.isServer){
Meteor.neo4j.methods({
‘GetUser’: function(){
return ‘MATCH (n:User {_id: {userId}}) RETURN n’
}
});
};
if(Meteor.isClient){
Meteor.neo4j.call(‘GetUser’, {userId: 123}, function(error, data){
if(!error){
Session.set('theUser', data);
}
});
}
Второе, что мы сделали — это property Meteor.neo4j.allowClientQuery, которая принимает значение true и false, и по умолчанию имеет значение false. Это позволит разработчикам на время разработки и тестирования приложения работать в консоли браузера, отправлять данные и проверять полученные.
Если же вы по какой-то причине решите оставить возможность исполнения запросов к Neo4j с Клиента, то предусмотрен следующий функционал, позволяющий ограничить тип запросов к Neo4j. Вам доступны два метода: neo4j.set.allow и neo4j.set.deny. Оба метода принимают единственный параметр — массив строк (array of strings). Дополнительно Вам доступны массивы: Meteor.neo4j.rules.allow, Meteor.neo4j.rules.deny и neo4j.rules.write, которые содержат текущие правила, а последний содержит массив с операторами записи, что позволяет сделать вот такой шорткат:
if(Meteor.isClient){
Meteor.neo4j.set.deny(Meteor.neo4j.rules.write);
}
И запретить все запросы на запись со стороны Клиента. Все методы, описанные в параграфе выше, — изоморфны. Хак со стороны Клиента не пройдет, так как данные дополнительно проверяются на целостность на стороне Сервера.
Следим за данными: свой Observer с блекджеком и listener'ом
Позже было обнаруженно, что запросы на изменение данных не инициировали обновление данных на Клиентах. Реактивность просто не работала до момента, пока один из Клиентов не обратится к измененным данным и не инициирует обновление в MongoDB, и как следствие — на всех Клиентах. Это произошло по причине того, что у нас не было observer'а, который бы следил за измененными данными и инициировал запуск всех запросов, связанных с данными, которые изменились.
Listener
Возвращаемся к нашему идеальному пакету под названием neo4jdriver, стираем весь проект и пишем заново:
- Оставляем структуру класса и инициализацию инстанса класса с возможностью передать url к базе;
- Создаем массив GraphDatabase.callbacks, хранящий коллбеки, принимающие два параметра — query и opts;
- Добавляем метод GraphDatabase.listen(func), принимающий функцию с двумя параметрами — query и opts, все функции падают в массив GraphDatabase.callbacks;
- Переназначаем встроенный в npm-пакет метод query — добавив к нему запуск всех колбеков из массива GraphDatabase.callbacks.
Reactivity Observer:
Первым делом нам необходимо научиться отделять данные запроса от конструкции запроса, для этого был введен параметр sensitivities. Этот параметр содержит данные, которые могут быть изменены. Теперь запись в коллекции Neo4jCacheCollection выглядит следующим образом:
uid // Unique hashed ID of the query
data // Parsed data returned from Graph
query // Original Cypher query string
sensitivities // Sensitive data, which contains a map of parameters, and hardcoded data into query
opts // Original map of parameters for the Cypher query
type // Type of query ('READ'|'WRITE')
created // Creation time
Связываем observer и listener:
- Ставим прослушку на все запросы к Neo4j;
- Получаем sensitivities текущего запроса;
- Находим все запросы на чтение, в которые входят sensitivities из текущего запроса;
- Запускаем повторную выборку для полученных совпадений;
- Дальнейшую необходимую реактивность нам обеспечит прослойка в виде MongoDB.
У нас получилось обеспечить обновление данных при их изменении — на всех Клиентах, очень простым способом.
Получаем только нужные нам данные
Третьей проблемой были данные, которые приходили из Neo4j. Помимо запрошенных нами полей, мы получаем еще кучу пустых объектов, которые нам возвращает npm-пакет. Пустые объекты много весят и не содержат информации, хранить их нам ни к чему. Для отделения полезных и запрошенных данных был написан метод parseReturn, который парсил запрос в БД (Cypher query) и понимал, какие данные были запрошенны и какие поля хотел получить разработчик. После чего, для каждой запрошенной информации создавался объект, содержащий массив нодов с их данными и метаданными. В случае, если запрашиваются отношения нодов, каждый нод содержит в себе объект relations, содержащий в себе данные в виде следующих параметров:
- extensions
- start
- end
- self
- type
Доставляем обновления до Клиентов
Обновлять данные в MongoDB и следить за их изменениями в Neo4j мы научились, а вот вложенные объекты в возвращаемых данных сами по себе обновляться не станут. На помощь нам пришел функционал предлагаемый пакетом reactive-var. Для этого, на Клиенте данные при получении из коллекции Neo4jCache назначаются и возвращаются через ReactiveVar. На Сервере при получении из коллекции Neo4jCache будут возвращены из промиса. На сервере и Клиенте достаточно вызвать метод get() для реативного получения данных. Для тех кому необходимо получить Mongo\Cursor имеется property cursor.
Пример:
/* Изоморфный запрос (Клиент и Сервер) */
allUsers = Meteor.neo4j.query('MATCH (users:User) RETURN users');
/* Получаем данные из запроса */
var users = allUsers.get().users;
/* Получаем Mongo\Cursor запроса */
var usersCursor = allUsers.cursor;
/* Изоморфный запрос (Клиент и Сервер) через callback*/
var allUsers;
Meteor.neo4j.query('MATCH (users:User) RETURN users', null, function(error, data){
allUsers = data.user;
});
На этом этапе мы создали тестовое приложение и опубликовали его на GitHub. Спустя неделю комьюнити разработчиков помогло нам «допилить» драйвер и исправить незначительные баги. Буду рад вопросам и предложениям по усовершенствованию и дальнейшему развитию проекта. Спасибо за внимание.
Ссылки:
- NPM-пакет: node-neo4j
- Neo4j Meteor Driver: neo4jdriver
- Neo4j Meteor Reactivity layer: neo4jreactivity
- Пример использования Neo4j в Meteor: Neo4j based Leaderboard Meteor app
P.S. На данный момент компания Neo4j принимает активное участие в разработке проекта и признала данный драйвер для Meteor официальным.