Пролог

Всем привет.

Не знал, как начать рассказ, поэтому в качестве пролога решил написать мини-интервью с самим с собой (наверное, это уже шизофрения), где кратко познакомлю вас с тем, что я написал, а после на PHP приведу примеры как всем этим пользоваться. Итак, поехали.

В: Для чего нужен этот микросервис?

О: Основное и единственное его предназначение - работа с сессиями. Этот сервис не является заменой ни memcahed, ни redis, ни mongo, а скорее неким их сплавом.

В: И в чем заключается сплав?

О: Сессию можно рассматривать как объект, который можно хранить в базе данных. Вместе с тем, основным полем по которому идет выборка из множества сессий является ее ID. Вот именно здесь и получается сплав: KV-хранилище где ключом является ID, а значением - объект данных. Конечно же можно использовать и тот же memcached, но тогда объект придется разбивать на ключи с префиксом ID сессии или вообще хранить все в одном месте. За mongo говорить не буду (если что я вообще frontend-разработчик), просто начитан, что это NoSQL база данных с хорошей горизонтальной масштабируемостью.

В: А как насчет масштабируемости?

О: Увы, ее нет.

В: А есть какие-то фишки, почему все должны кинуться и использовать твое решение?

О: Ну когда я начинал писать изначально была цель сделать так, чтобы нельзя было перезатереть ключ сессии. В memcached это называется cas. Потом появилась мысль добавить лимитер на количество запросов к ключу сессии.

В: Что еще за лимитер?

О: Такая штука с помощью которой ты можешь ввести ограничение на количество обращений к ключу сессии в секунду. Например, у тебя есть страница личного кабинета с балансом и ты хочешь чтобы пользователь получал данные о балансе не более 5 раз в секунду. Вот для таких случаев и нужен лимитер.

Установка и запуск

Ссылка на сервер

Ссылка на клиент

Затем распаковываете и переходите в папку где можно выполнить make multi или make mono

Mono версия оптимизирована под однопоточное использование и не поддерживает многопоточность. Multi же работает в обоих режимах.

Бинарники хранятся в папке bin.

Параметры запуска

  • -t - количество потоков (только в multi версии, по умолчанию равно максимальному количеству потоков в системе и не может быть больше его)

  • -p - порт (по умолчанию 2901)

  • -l - лимит на количество сессий (по умолчанию максимальное беззнаковое 32-битное число)

Создание базового класса для работы с сессиями

Создадим базовый класс для работы с сессиями BaseSession

<?php

require_once 'vendor/autoload.php';

class BaseSession {
    private const PORT = 2901;
    private const HOST = '127.0.0.1';

    // Здесь будет храниться сам класс для работы с сессиями
    protected $sess;
    // Время жизни сессии в секундах
    protected $lifetime = 5;
    // ID сессии
    private $uuid;

    function __construct( $uuid = '' ) {
        // $uuid - есть ID сессии
        $this->sess = new MemSess\Client( self::HOST, self::PORT );

        if( !$uuid ) {
            // Тут все просто, если мы не передали id сессии, сервер генерирует новый, lifetime - время жизни сессии, не может быть меньше 0 и больше беззнакового 32-битного числа, значение означает секунды. Не путать с timestamp. 0 означает "вечную жизнь".
            $this->uuid = $this->sess->generate( $this->lifetime );
            $this->_create();
        } else if( !$this->sess->init( $uuid ) ) {
            // А тут если мы передали id, но его нет на сервере, мы его добавляем на сервер.
            // Это сделано для дальнейших примеров, думаю в жизни не стоит доверять данным которые прилетают от пользователя
            $this->sess->add( $uuid, $this->lifetime );
            $this->_create();
        }
    }

    // Метод пригодится потом, когда мы будем наследоваться от этого класса
    protected function _create() {
    }

    // Нужно когда необходимо узнать ID новой сессии
    public function getSessionId() {
        return $this->uuid;
    }

    // Продление времени жизни, параметр идентичен приведенному выше в generate, add
    public function prolong( $lifetime ) {
        $this->sess->prolong( $lifetime );
    }

    // Удаление сессии, вернее пометка к удалению, сервер раз в минуту удаляет помеченные и истекшие сессии сам, но установка пометки уже исключает дальнейшую работу с сессией
    public function remove() {
        $this->sess->remove();
    }
};

Работа с ключами

Создадим класс Storage

<?php

// Подключим базовый класс
require_once 'BaseSession.php';

class Storage extends BaseSession {
    private const KEY_DATA = 'data';

    // Вот и пригодился protected метод для добавления ключа в сессию
    protected function _create() {
        // Третьим параметром можно передать уже знакомый lifetime
        $this->sess->addKey( self::KEY_DATA, [] );
    }

    // Здесь мы просто передаем значение, json_encode выполняется внутри метода setKey
    public function setData( $data ) {
        return $this->sess->setKey( self::KEY_DATA, $data );
    }

    // Тут нам возвращаются данные прогнанные через json_decode
    public function getData() {
        return $this->sess->getKey( self::KEY_DATA );
    }

    // Также ключи можно удалять и продлевать их время жизни, более подробно по ссылке, не будем здесь на этом зацикливаться
};

Создадим файл index.php

<?php

require_once 'Storage.php';

$uuid = '18a45d56-70a9-4294-bd5f-03d126186c87';
// Допустим к нам пришел пользователь с таким ID сессии

$storage = new Storage( $uuid );

$data = $storage->getData();

// Тут все очень просто, если нет в массиве ключа i, добавляем его и итерируем

if( !isset( $data['i'] ) ) {
    $data['i'] = 0;
} else {
    $data['i']++;
}

$storage->setData( $data );

echo $data['i'], PHP_EOL;

Запустите в консоле несколько раз этот скрипт и вы увидите что значение начнет увеличиваться, затем внезапно окажется равным 0. Сработал lifetime который был установлен в базовом скрипте на 5 секунд. Установите его в классе Storage в 0. Теперь итерация будет работать как надо.

Блокировки

Оптимистические

По умолчанию при работе с ключами сессии используется оптимистическая блокировка сессии. То есть при вызове getKey, с сервера передаются два счетчика, первый отвечает за id ключа, второй за id записи. При вызове setKey оба этих счетчика передаются обратно на сервер для сравнения, если счетчики верны то запись обновляется, в противном случае вовзращается код ошибки, который setKey переводит в boolean-значение или выкидывает исключение. Важное замечание: вызов setKey без предварительного вызова getKey приведет к выбросу исключения. По большому счету эта блокировка аналогична cas, который используется в memcached.

Изменим index.php

<?php

require_once 'Storage.php';

$uuid = '18a45d56-70a9-4294-bd5f-03d126186c87';

$storageNeutral = new Storage( $uuid );
$storage1 = new Storage( $uuid );
$storage2 = new Storage( $uuid );

$data1 = $storage1->getData();
$data2 = $storage2->getData();

$data1['value'] = '1';
$data2['value'] = '2';

if( $storage1->setData( $data1 ) ) {
    echo 'value теперь равен 1', PHP_EOL;
    print_r( $storageNeutral->getData() );
}

if( !$storage2->setData( $data2 ) ) {
    echo 'value не может быть равен 2, так как кто-то изменил запись', PHP_EOL;
    print_r( $storageNeutral->getData() );
}

$data2 = $storage2->getData();
$data2['value'] = '2';

if( $storage2->setData( $data2 ) ) {
    echo 'Вот теперь value равен 2', PHP_EOL;
    print_r( $storageNeutral->getData() );
}

Пессимистические

Для создания пессимистической блокировки необходимо вызвать метод lock передав в качестве параметров ключ и время жизни блокировки. Само время жизни не может быть равным 0 или больше 120 секунд. Вообще lock это просто обертка над addKey, сервер ничего не знает про пессимистические блокировки. По идее такая же блокировка используется и в memcached, когда нужно атомарно произвести какую-нибудь операцию над данными. Ничего нового тут нет.

Для снятия блокировки есть метод unlock, если его не вызвать по окончанию работы скрипта, разблокировка вызовется в деструкторе. Также если у вас предполагается длительная работа скрипта между вызовами блокировки и разблокировки, разблокировка вызовется по истечению заданного времени жизни. Но зато вас подстрахует от перезаписи оптимистическая блокировка.

Модифицируем класс Storage, добавив в него методы для работы с блокировкой (lockData, unlockData) и константу LIFETIME_LOCK_DATA.

<?php
require_once 'BaseSession.php';

class Storage extends BaseSession {
    private const KEY_DATA = 'data';
    private const LIFETIME_LOCK_DATA = 1;
    protected $lifetime = 0;

    protected function _create() {
        $this->sess->addKey( self::KEY_DATA, [] );
    }

    public function setData( $data ) {
        return $this->sess->setKey( self::KEY_DATA, $data );
    }

    public function getData() {
        return $this->sess->getKey( self::KEY_DATA );
    }

    public function lockData() {
        return $this->sess->lock( self::KEY_DATA, self::LIFETIME_LOCK_DATA );
    }

    public function unlockData() {
        $this->sess->unlock( self::KEY_DATA );
    }
}; 

Изменим index.php

<?php

require_once 'Storage.php';

$uuid = '18a45d56-70a9-4294-bd5f-03d126186c87';

$storage = new Storage( $uuid );

if( $storage->lockData() ) {
    echo 'Удалось вызвать блокировку', PHP_EOL;
}

if( !$storage->lockData() ) {
    echo 'Не удалось вызвать блокировку', PHP_EOL;
}

Получается, чтобы захватить блокировку нужно написать цикл, в котором будет вызываться lockData до той поры, пока не вернется true. Конечно, можно сделать и так, но это уже сделано в методе lock, вторым параметром можно передать количество попыток захвата блокировки (если 0, то количество бесконечно, по умолчанию 1), а третьим таймаут между попытками (минимально 10 микросекунд, максимально 10 000).

Изменим метод lockData в классе Storage


public function lockData() {
    return $this->sess->lock( self::KEY_DATA, self::LIFETIME_LOCK_DATA, 0 );
}

Теперь, когда мы вызовем файл index.php в консоли, первое условие сработает, а второе нет и скрипт отработает за секунду.

Игнорирование блокировок

Иногда бывает так, что нужно обновить просто взять и обновить данные. Например записать текущие координаты пользователя или обновить CSRF-токен. Блокировки тут, мягко говоря, неуместны. Для эти случаев существует метод setForceKey, который принудительно обновляет запись по ключу игнорируя счетчики ключа и записи.

Дополним класс Storage методами getCSRF, updateCSRF

<?php
require_once 'BaseSession.php';

class Storage extends BaseSession {
    private const KEY_DATA = 'data';
    private const KEY_CSRF = 'csrf';
    private const LIFETIME_LOCK_DATA = 1;
    protected $lifetime = 0;

    protected function _create() {
        $this->sess->addKey( self::KEY_DATA, [] );
        $this->sess->addKey( self::KEY_CSRF, uniqid( '', true ) );
    }

    public function setData( $data ) {
        return $this->sess->setKey( self::KEY_DATA, $data );
    }

    public function getData() {
        return $this->sess->getKey( self::KEY_DATA );
    }

    public function lockData() {
        return $this->sess->lock( self::KEY_DATA, self::LIFETIME_LOCK_DATA, 0 );
    }

    public function unlockData() {
        $this->sess->unlock( self::KEY_DATA );
    }

    public function getCSRF() {
        return $this->sess->getKey( self::KEY_CSRF );
    }

    public function updateCSRF() {
        $this->sess->setForceKey( self::KEY_CSRF, uniqid( '', true ) );
    }
};

Обновим index.php

<?php

require_once 'Storage.php';

// Обновим ID сессии, чтобы добавился ключ CSRF
$uuid = '70efa436-b145-4076-9fdd-60b7c46a079b';

$storage = new Storage( $uuid );

$storage->updateCSRF();

echo $storage->getCSRF(), PHP_EOL;

Обратите внимание, что updateCSRF вызывается перед getCSRF, но это не влечет никакой ошибки, как если бы внутри updateCSRF использовался метод setKey.

Запустите файл несколько раз и вы увидите, как меняется CSRF-токен.

Использование лимитеров

Изредка возникают ситуации, когда мы не хотели бы чтобы пользователь вызывал часто какой-нибудь функционал. Например, построение какого-нибудь сводного отчета или что-то в этом роде. Конечно, вызовы можно детектить и заносить в БД, затем смотреть, а не превышен ли лимит запросов и исходя из этого разрешать дальше пользователю действия или на какое-то время блокировать. И вот тут мне и пришла идея сделать лимитер.

Суть его очень проста, в каждый из 3 методов getKey, setKey, setForceKey дополнительным параметром передается лимит количества запросов в секунду от 0 (считай без лимита) и до 16-битного беззнакового числа. Работает очень просто: если за секунду идет превышение лимита, то запрос завершается выбросом исключения. По одному ключу можно задавать множество лимитов, и их счетчики не будут "пересекаться" друг с другом.

Изменим класс Storage

<?php
require_once 'BaseSession.php';

class Storage extends BaseSession {
    private const KEY_DATA = 'data';
    private const KEY_CSRF = 'csrf';
    private const LIFETIME_LOCK_DATA = 1;
    private const LIMIT_UPDATE_CSRF = 1;
    protected $lifetime = 0;

    protected function _create() {
        $this->sess->addKey( self::KEY_DATA, [] );
        $this->sess->addKey( self::KEY_CSRF, uniqid( '', true ) );
    }

    public function setData( $data ) {
        return $this->sess->setKey( self::KEY_DATA, $data );
    }

    public function getData() {
        return $this->sess->getKey( self::KEY_DATA );
    }

    public function lockData() {
        return $this->sess->lock( self::KEY_DATA, self::LIFETIME_LOCK_DATA, 0 );
    }

    public function unlockData() {
        $this->sess->unlock( self::KEY_DATA );
    }

    public function getCSRF() {
        return $this->sess->getKey( self::KEY_CSRF );
    }

    public function updateCSRF() {
        $this->sess->setForceKey( self::KEY_CSRF, uniqid( '', true ), self::LIMIT_UPDATE_CSRF );
    }
};

Вызовите быстро несколько раз index.php, в первый раз скрипт отработает нормально, во второй упадет с исключением.

Групповые операции

Тут все очень просто. Я даже не знаю, нужно ли это будет вообще, но сделал исходя из своего чувства прекрасного. Короче, в классе Client есть 2 метода для этих групповых операций addKeyToAll, removeKeyFromAll. Как понятно из названия мы можем добавить во все живые сессии ключ или удалить ненужный ключ. Чуть более подробно можно ознакомиться по ссылке

Заключение

Ну вот пожалуй и все.

Конечно, будет наивным полагать, что сейчас кто-то кинется использовать это решение на проде. Но мне было бы интересно узнать, стоит ли это все развивать или идея изначально мертва.

Большое всем спасибо за внимание.

Комментарии (2)


  1. nskforward
    15.01.2024 11:04
    +2

    Для какого-то собственного pet-проекта, наверное, крутое решение. Я бы на своих проектах предпочёл использовать проверенные временем решения для подобных задач. Попробуйте не просто воспроизвести аналоги, а решить какую-то проблему, которую не решают, либо плохо решают другие - вот тогда будет интересно. Удачи!


    1. Trusow Автор
      15.01.2024 11:04

      Насчет решения проблемы и "аналоговнет" есть одна идейка. Но уж очень она узкоспециализированная, и, думаю, в 99% случаев вообще никому не нужна. Проще будет какой-нибудь свой проект запилить со временем) Спасибо за отзыв!