Node.js и PHP — общие ссессии в Redis

Для чего вообще нужно выносить сессии в базу данных? Например, для sticky-сессий или для доступа к ним из нескольких разных приложений.

В моем случае был сайт, работающий на PHP, к которому нужно было добавить быстрые сообщения между зарегистрированными пользователями (а-ля Вконтакте). Выбор пал на Websockets, думаю, объяснять почему не стоит. Из платформ я выбрал Node.js. Этот выбор был сделан по следующим причинам:

  1. на Node.js есть Socket.io, а в нем есть готовая клиентская библиотека, поддержка всех основных Push технологий с fallback до flash (привет, IE);
  2. на PHP был не очень удачный опыт с библиотекой Ratchet. Не подумайте, сама библиотека великолепна, но периодически приложение, написанное на ней, перестает отвечать по непонятным причинам. Скорее всего проблема не в ней, а в тонких настройках сервера, но с Socket.io такого не бывало.
  3. да и просто Node.js такой классный =)

Сайт на PHP, а сообщения на сайте на Node.js? WHAT!?. Сумасшествие, да? Наверное =). Но мы любим сложные задачи и нестандартные решения.

Блок с личными сообщениями должен появляться только после авторизации пользователя. Значит нужно в Node.js скрипте проверить авторизовался ли пользователь на сайте (есть ли сессия с определенными параметрами в PHP). Немного теории о том как работают сессии в PHP:

При вызове функции session_start(), во временной папке для сессий, создается новый файл с именем sess_[a-z0-9]{32}, где последние 32 символа — значение cookie PHPSESSID, которая отправляется пользователю. В дальнейшем при изменении массива $_SESSION, он сериализуется(serialize) и сохраняется в этот файл.

Допустим, после того как пользователь вводит свой логин/пароль, в сессию записывается его id из базы данных. Получается из скрипта сообщений нам надо проверить есть ли значение id в его сессии. Первая мысль была, как, наверное и у многих, написать парсер всех файлов в папке с сессиями (да и find и grep никто не отменял).

Пример плохого подхода, не повторять!
var fs = require('fs'),
    sessDir = '/tmp/'; // папка с сессиями

var isAuthorized = function(id, callback) {
  fs.readdir(sessDir, function(err, files) {
    // получение всех файлов в папке
    if(err) throw err;
    files.forEach(function(file) {
      // перебор всех файлов
      fs.lstat(sessDir + file, function(err, stats) {
        // чтение атрибутов файлов
        if (err) throw err;
        if (stats.isFile()) {
          // если это файл
          fs.readFile(sessDir + file, function (err, data) {
            // то читаем его содержимое
            if (err) throw err;
            var str = data.toString();
            /* data - это buffer, содержимое файла. toString() нужен
             * для получения строки, а не буфера. Здесь нужно распарсить
             * содержимое файла сессии и проверить есть ли в нем нужное значение.
             * я для примера просто проверяю вхождение id в строку
             * и при совпадении возвращаю в callback функию true.
             */
            if(str.match(id)) {
              if(callback && typeof callback === 'function') {
                callback(true, str);
              }
            }
          });
        }
      });
    });
  });
};

isAuthorized('qwe', function(auth, file) {
  if(auth) {
    console.log('qwe is authorized');
  }
});

Но… это как-то не красиво, согласны? Постоянно лазить по файловой системе немного накладно, тем более особенность проекта требовала создание сессии на каждого посетителя, поэтому файлов в папке меньше 1000 не бывает. Из этого выходило, что нужно было более быстрое хранилище сессий, которое поддерживало бы PHP и Node.js. Мне давно уже хотелось попробовать Redis как хранилище сессий, а тут как раз задачка подвернулась как нельзя кстати.

Для справки: Redis — это простое и очень быстрое key-value хранилище. Его поддерживают многие платформы (PHP и Node.js в том числе), его просто установить (на Debian/Ubuntu ставится из пакетного менеджера, на CentOS я билдил сам), в будущем я все равно планировал переносить сессии в базу данных, так что это будет проба пера =). Хватит теории, пора действовать!))

Для начала нужно установить Redis. Дальше нам понадобится расширение для PHP, которое позволит сохранять сессии в Redis — phpredis. Инструкция по установке есть на странице расширения, я пробегусь только кратко:

  1. Чтобы скомпилировать библиотеку, понадобится dev-пакет php5.
  2. Придется собрать его из исходников самим, это не страшно =).
    $ sudo apt-get install php5-dev
    $ cd /tmp && wget https://github.com/nicolasff/phpredis/archive/master.zip
    $ unzip master.zip
    $ cd master
    $ phpize
    $ ./configure
    $ make
    $ sudo make install
  3. Осталось его включить: для Ubuntu с PHP5.4+
    $ sudo sh -c "echo 'extension=redis.so' > /etc/php5/apache2/conf.d/redis.ini"
    $ cd /etc/php5/cli && sudo ln -s ../apache2/conf.d/redis.ini
    $ sudo service apache2 restart

Самое время проверить работает ли расширение, небольшой код для теста:

connect("127.0.0.1");
print $redis->echo("ping\n"); // должен вывести ping

Если после запуска появилась надпись «ping», значит все хорошо, идем дальше. Для PHP есть куча готовых классов для работы с сессиями в Redis, но мне не хотелось править код, а хотелось чтоб сессии просто стали хранится в базе. Для этого нужно подправить php.ini.

$ vi /etc/php5/apache2/php.ini

Ищем строку session.save_handler и заменяем в ней files на redis. Дальше ищем строку session.save_path (возможно ее нет — добавляем) и приравниваем ее к «tcp://localhost:6379». Получится примерно так:

session.save_handler = redis
session.save_path = "tcp://localhost:6379"

Перезапускаем apache и — та-да! Ваши сессии отныне хранятся в Redis =).

Хорошо, полдела сделано. Я решил, что после загрузки страницы, будет отсылаться XHR-запрос (AJAX) скрипту сообщений. В заголовках этого запроса будет cookie с PHP сессией, по которой можно будет определить авторизован ли пользователь. Пример кода проверки авторизации:

var express = require('express.io'),
    app = express(),
    redis = require('redis'),
    redisClient = redis.createClient();

app.use(app.router);

/* принимаем строку с sessid, проверяем наличие в Redis,
 * возвращаем true, если в сессии есть поле user_id */
var iam = function(sessid, callback) {
  redisClient.get("PHPREDIS_SESSION:" + sessid, function (err, data) {
    if(err) {
      throw err;
    }
    if(data) {
      var arr = data.split(';'), session = {};
      for (var i = arr.length - 1; i >= 0; i--) {
        var matched = arr[i].match(/(.[^\|]+)\|s:[0-9]*:"(.[^"]*)/);
        if(matched) {
          session[matched[1]] = matched[2];
        }
      }
      if(session.user_id) {
        callback(true, session);
      } else {
        callback(false, session);
      }

    } else {
      callback(false);
    }
  });
};

String.prototype.trim = function() {return this.replace(/^\s+|\s+$/g, '');};

/* принимаем строку с cookie, возвращаем распарсеный объект */
var cokieParser = function(coo) {
  var cookie = coo.split(';'), cook = {};
  for(var x in cookie) {
    if(typeof cookie[x] == 'function') continue;
    var tmp_arr = cookie[x].trim().split('=');
    cook[tmp_arr[0]] = tmp_arr[1];
  }
  return cook;
};

/* проверяем авторизацию при всех запросах */
app.all('*', function(req, res) {
  var cookieVals = cokieParser(req.headers.cookie);
  iam(cookieVals['PHPSESSID'], function(auth){
    if(auth) {
      res.send({status: 1});
    } else {
      res.send({status: 0});
    }
  });
});

app.http().io().listen(3000, function(){
  console.log('Express server listening on port 3000');
});

Код достаточно абстрактный, местами избыточный, местами поверхностный. Тут стоит обратить внимание на строки «PHPSESSID» и «PHPREDIS_SESSION». Первая — стандартное значение в php.ini, второе — в библиотеке phpredis. Не забудьте проверить значения в своих настройках. Я использую библиотеку express.io из-за ее простоты, как понятно из названия, она объединяет в себе отличный фреймворк Express и библиотеку Socket.io.

Надеюсь кому то будет полезна данная информация.