Хранилища данных в Node.js: Redis
Последний раз я серьёзно заинтересовался Redis после посещения DevConf 2010 — там был очень интересный доклад о возможностях и применении этой системы. Теперь, когда API Ноды несколько устаканилось и коннекторы пришли в более-менее стабильный вид, можно поэкспериментировать и с Redis, помимо прочих хранилищ
Установка redis
Redis будем собирать из исходников. Мы возьмём вторую ветку:
wget http://redis.googlecode.com/files/redis-2.0.0-rc4.tar.gz
Распаковываем полученный tgz-файл и в получившейся директории делаем make. Всё, Redis должен скомпилироваться. Как вариант, можете установить Redis из менеджера пакетов, но тогда внимательно смотрите на результаты тестов коннектора — в старых версиях Redis некоторых возможностей может не оказаться.
По умолчанию Redis запускается не в фоновом режиме, и отправляет всякие debug-сообщения прямо в консоль. Чтобы это исправить, надо отредактировать его конфиг. Он лежит в той же папке и называется redis.conf. Меням там:
daemonize no logfile stdout
…на…
daemonize yes logfile /var/log/redis.log
Теперь запускаем redis с этим конфигом:
./redis-server ./redis.conf
Я скопировал конфиг в /etc/redis.conf, но это уже дело вкуса.
Использование с Node.js
Нам нужен будет коннектор node-redis-client, но не основной ветки, а redis-2.0. Делаем это так:
git clone git://github.com/fictorial/redis-node-client.git cd redis-node-client git checkout -b redis-2.0 origin/redis-2.0
Теперь у нас есть клиент для второй ветки Redis. Чтобы убедиться что всё работает, запускаем тесты из соответствующей директории:
cd test node test.js
Если сервер скажет [INFO] All tests have passed., значит всё установлено и настроено правильно, и мы готовы к использованию коннектора.
Есть ещё тест фунционала Publish/Subscribe в папке examples, там очень простой и понятный код. Так реализуется Publish-часть:
var
sys = require("sys"),
client = require("../lib/redis-client").createClient();
// Publish a message once a second to a random channel.
setInterval(function () {
var
channelName = "channel-" + Math.random().toString().substr(2),
payload = "The time is " + (new Date());
sys.puts('Channel is ' + channelName);
client.publish(channelName, payload,
function (err, reply) {
sys.puts("Published message to " +
(reply === 0 ? "no one" : (reply + " subscriber(s).")));
});
}, 1000);
Здесь Node соединяется с Redis-сервером и каждую секунду отправляет сообщение в случайный канал (channelName) с текущим временем. А вот так выглядит клиент:
var
sys = require("sys"),
client = require("../lib/redis-client").createClient();
sys.puts("waiting for messages...");
client.subscribeTo("*",
function (channel, message, subscriptionPattern) {
var output = "[" + channel;
if (subscriptionPattern)
output += " (from pattern '" + subscriptionPattern + "')";
output += "]: " + message;
sys.puts(output);
});
Здесь мы подписываемся на все каналы (“*”), и выводим все полученные сообщения на экран.
Практика
Но просто так соединяться с Redis из Node не очень интересно. Надо сделать хоть какой то сервис, чтобы опробовать технологию в деле. Я хочу сделать твиттероподобный сайт, как в PHP-мануале Redis.
Вначале — о разных видах данных в Redis. Помимо простого сохранения строки по ключу Redis умеет работать со множествами и списками. Множество — неупорядоченный набор элементов, можно добавлять и удалять элементы атомарно. Список — упорядоченный набор. Множества я буду использовать там где порядок элементов не важен (фолловеры, фолловинги), списки будут использоваться собственно для постов, чтобы они шли по порядку.
Но прежде чем приступать к постам, надо сделать способ регистрации и входа для пользователей. При регистрации мы проверяем не занято ли имя пользователя и сохраняем в Redis примерно такую структуру:
SET uid:1000:username kurokikaze SET uid:1000:password 5f4dcc3b5aa765d61d8327deb882cf99
Здесь 1000 — уникальный идентификатор пользователя. Чтобы назначить новому пользователю уникальный ID, используем атомарный инкремент. Операция INCR возвращает значение переменной и вместе с тем увеличивает её на единицу, так что разные вызовы будут возвращать последовательные ID. Этот паттерн применяется везде, где в MySQL Вы воткнули бы AUTO_INCREMENT. ID для нового пользователя будет храниться в переменной global:nextUserId
Сам процесс регистрации выглядит следующим образом:
var registerUser = function(username, password, callback) {
// check username availability
client.incr('global:nextUserId', function(err, uid) {
if (uid) {
sys.puts('Uid is ' + uid);
client.get('username:' + username + ':id', function(err, id) {
if (!id) {
client.set('uid:' + uid + ':username', username, function(err) {
client.set('uid:' + uid + ':password', hash.md5(password), function(err) {
client.set('username:' + username + ':id', uid, function(err) {
callback(true, parseInt(uid.toString()));
});
});
});
} else {
sys.puts('Username already taken!');
callback(true, parseInt(id.toString()));
}
});
} else {
sys.puts('Cannot get UID: ' + err);
callback(true);
}
});
}
По хорошему, чтобы избежать спагетти-кода, для серии последовательных вызовов здесь лучше использовать Do, но пока сойдёт и так. MD5 я использую из библиотеки hashlib.
Логин работает примерно так же:
var login = function(username, password, callback) {
client.get('username:' + username + ':uid', function(err, uid) {
if (uid) {
client.get('uid:' + uid + ':password', function(err, password){
if (hash.md5(password) == password) {
callback(false, parseInt(uid.toString()));
} else {
sys.puts('Wrong password');
callback(true);
}
});
} else {
sys.puts('No such user');
callback(true);
}
});
}
Все ID, возвращаемые из Redis (особенно числовые) лучше пропускать через toString(), чтобы преобразовать их из буферов в строки. Теперь сделаем фолловинг. Фолловинг будет работать с помощью множеств. Множества поддерживают несколько команд, нас интересуют sadd (добавление элемента), srem (удаление элемента) и smembers — получение списка элементов множества. Фолловим:
var follow = function(uid, fid, callback) {
client.sadd('uid:' + uid + ':following', fid, function(err) {
if (err) {
sys.puts('Error following');
callback(true);
} else {
client.sadd('uid:' + fid + ':followers', uid, function(err) {
if (err) {
callback(true);
sys.puts('Error following');
} else {
callback(false);
}
});
}
});
}
И unfollow (пока без проверки на ошибки):
var unfollow = function(uid, fid, callback) {
client.srem('uid:' + uid + ':following', fid, function(err) {
client.srem('uid:' + fid + ':followers', uid, function(err) {
callback(false);
});
});
}
Redis-коннектор следует обычному соглашению для Node — первым в callback передаётся флаг ошибки. Это должно позволить использовать его с Do без дополнительного геморроя.
Постинг нам надо делать не только в свой аккаунт, но и фолловерам. Для этого мы получаем список фолловеров и каждому дублируем пост.
var post = function(uid, body, callback) {
// В global:nextPostId хранится ID следующего поста, мы его используем как автоинкремент
client.incr('global:nextPostId', function(err, pid) {
// Получаем фолловеров
client.smembers('uid:' + uid + ':followers', function(err, followers) {
if (err) {
sys.puts('Error writing to followers');
}
// Получаем ID текущего автора
client.get('uid:' + uid + ':username', function(err, username) {
if (!err) {
// Размещаем пост в своём таймлайне
client.lpush('uid:' + uid + ':posts', JSON.stringify({'author':uid, 'body':body}), function() {
callback(false, pid);
});
// Размещаем пост в таймлайнах фолловеров, но не дожидаемся выполнения
for (var fid in followers) {
sys.puts('To ' + followers[fid] + ' timeline');
client.lpush('uid:' + followers[fid] + ':posts', JSON.stringify({'author':uid, 'body':body}), function() {});
}
} else {
sys.puts('Post from non-existing username');
callback(true);
}
});
})
});
}
В принципе, что то уже получается. Объединим это в один модуль и уже можно пробовать:
ritter.registerUser('user_leader','password', function(err, uid_leader) {
ritter.registerUser('user_follower', 'password', function(err, uid_follower) {
sys.puts('Leader is ' + uid_leader + ', follower is ' + uid_follower);
ritter.follow(uid_follower, uid_leader, function(err) {
if (err) {
sys.puts('Error following leader');
} else {
sys.puts('Now following leader');
ritter.post(uid_leader, 'To all followers: hello', function(err) {
if (err) {
sys.puts('Error posting to leader timeline');
}
ritter.getPosts(uid_follower, function(err, data) {
if (err) {
sys.puts('Something wrong');
} else {
sys.puts('##Posts:');
for (var pid in data) {
sys.puts('Post: ' + data[pid].toString());
}
}
});
});
}
});
});
});
Без Do опять получается callback-спагетти
Здесь мы регистрируем двух юзеров, заставляем одного фолловить другого и проверяем, появятся ли его пост в ленте фолловера. У меня всё сработало нормально. В принципе, этот модуль уже можно использовать с Express для создания твиттероподобного сайта. У нас есть возможность регистрироваться, просматривать фолловеров, фолловингов, размещать статусы и просматривать свою ленту — минимальный набор возможностей для подобного сервиса.

Как у вас отлично все это получается, надо наконец собраться и попробовать