Laravel: Session

13.01.2023 в 17:26
8359
+5

Если вы пишете классическое веб-приложение, вы почти наверняка будете использовать сессии. Объяснять, как работают нативные сессии в php, я не буду, тем более, что Laravel их не использует. Об этом можно убедиться, как минимум, прочитав комментарий в коде:

// If a session driver has been configured, we will need to start the session here
// so that the data is ready for an application. Note that the Laravel sessions
// do not make use of PHP "native" sessions in any way since they are crappy.

Однако, тем не менее, механизм используется такой же: для каждого пользователя в момент обращения к приложению генерируется уникальный идентификатор сессии и кладется в куки. Далее все положенные в сессию данные будут сохраняться под этим идентификатором. Именно такой вариант использования сессий избавляет вас от необходимости запускать сессии вручную, контролировать, чтобы они были запущены правильно и вы не ловили ошибки вида Cannot send session cookie — headers already sent by. Все это благодаря тому, что фреймворк делает это за вас: он самостоятельно "запускает" сессию, генерирует идентификатор, добавляет его в куки, сохраняет данные и достает их в момент следующего обращения клиента к приложению. Давайте подробнее разберем, как ему это удается.

Session

Для управления сессиями во фреймворке используется интерфейс Session и его реализации: Store и EncryptedStore. Для хранения используются несколько хранилищ: файловое, база данных, memcached, redis, куки, массив (для тестов) и некоторые другие. Все они реализуют php-шный интерфейс SessionHandlerInterface. Таким образом, вы можете добавить свое хранилище и заставить фреймворк использовать его. Все, что вам необходимо сделать, чтобы ваше хранилище заработало, это реализовать несколько методов: открыть/закрыть сессию (в общем случае вы можете просто вернуть из этих методов true), записать/прочитать, уничтожить и собрать мусор (данные сессии, которые превысили допустимую дату хранения).

Собрав эти знания, рассмотрим, как происходит старт сессии:

public function start()
{
    $this->loadSession();

    if (! $this->has('_token')) {
         $this->regenerateToken();
    }

    return $this->started = true;
}

protected function loadSession()
{
    $this->attributes = array_merge($this->attributes, $this->readFromHandler());
}

protected function readFromHandler()
{
   if ($data = $this->handler->read($this->getId())) {
       $data = @unserialize($this->prepareForUnserialize($data));

       if ($data !== false && ! is_null($data) && is_array($data)) {
            return $data;
       }
   }

    return [];
} 

Метод loadSession читает из хранилища данные текущей сессии: если идентификатор сессии был сформирован в этом же запросе, то метод вернет пустой массив, как и в случае, если никаких данных для идентификатора просто не было записано; в обратном случае в свойство $attributes объекта Session будет записан массив данных, который мы позднее сможем прочитать с помощью вспомогательных методов all, only, has, get, pull и так далее. Также Session сгенерирует новый _token, если его нет, этот токен используется при отправке форм на сервер. Обычно его называют csrf токеном.

Метод prepareForUnserialize в случае с обычным Store просто возвращает те же данные, что были переданы в качестве аргумента. В случае с EncryptedStore данные будут расшифрованы с помощью Illuminate\Contracts\Encryption\Encrypter:

protected function prepareForUnserialize($data)
{
   try {
      return $this->encrypter->decrypt($data);
   } catch (DecryptException $e) {
      return serialize([]);
   }
}

После старта сессии все, что вы положили в прошлый раз в нее, доступно вам через методы объекта сессии:

use Illuminate\Contracts\Session\Session

public function someAction(Session $session): JsonResponse
{
    if (!$session->has('some_key')) {
        $session->put('some_key', 'some_value'); 
    }

    dd($session->get('some_key'));
}

Чтобы данные из сессии были доступны при следующем запросе, их нужно сохранить:

public function save()
{
    $this->ageFlashData();

    $this->handler->write($this->getId(), $this->prepareForStorage(
        serialize($this->attributes)
    ));

    $this->started = false;
}

protected function prepareForStorage($data)
{
   return $data;
}

public function ageFlashData()
{
    $this->forget($this->get('_flash.old', []));

    $this->put('_flash.old', $this->get('_flash.new', []));

    $this->put('_flash.new', []);
}

Метод ageFlashData записывает в сессию так называемые флаш-сообщения — сообщения, которые вы должны будете достать при следующем обновлении страницы/редиректе для показа пользователю. Обычно туда записывают сообщения об ошибках или успешных результатах каких-то действий, когда не хотят использовать javascript.

Метод prepareForStorage в случае с обычным Store просто возвращает те же данные. Если вы используете EncryptedStore, то ваши данные перед записью зашифруются:

protected function prepareForStorage($data)
{
   return $this->encrypter->encrypt($data);
}

Еще один важный момент в жизненном цикле сессий — это инвалидация:

public function invalidate()
{
   $this->flush();

   return $this->migrate(true);
}

public function migrate($destroy = false)
{
    if ($destroy) {
        $this->handler->destroy($this->getId());
    }

    $this->setExists(false);

    $this->setId($this->generateSessionId());

    return true;
}

При инвалидации (чаще всего происходящей после логаута или смены аккаунта) мы очищаем состояние объекта Session, просим хранилище удалить все данные под текущим sessionId, а далее генерируем новый идентификатор, под которым пользователь будет находиться уже, например, анонимным.

Итак, вроде стало понятно, что и когда именно делает фреймворк, но кто этим всем управляет?

StartSession

StartSession — это миддлвара, внутри которой фреймворк запускает сессию, генерирует идентификатор, если его нет, достает данные, сохраняет и заполняет куки.

В сухом остатке имеем следующее:

class StartSession
{
    public function handle($request, Closure $next)
    {
        if (! $this->sessionConfigured()) {
            return $next($request);
        }

        $session = $this->getSession($request);

        if ($this->manager->shouldBlock() ||
            //
        } else {
            return $this->handleStatefulRequest($request, $session, $next);
        }
    }

    protected function handleStatefulRequest(Request $request, $session, Closure $next)
    {
        // If a session driver has been configured, we will need to start the session here
        // so that the data is ready for an application. Note that the Laravel sessions
        // do not make use of PHP "native" sessions in any way since they are crappy.
        $request->setLaravelSession(
            $this->startSession($request, $session)
        );

        $this->collectGarbage($session);

        $response = $next($request);

        $this->storeCurrentUrl($request, $session);

        $this->addCookieToResponse($response, $session);

        // Again, if the session has been configured we will need to close out the session
        // so that the attributes may be persisted to some storage medium. We will also
        // add the session identifier cookie to the application response headers now.
        $this->saveSession($request);

        return $response;
    }
}

В методе getSession мы должны получить объект Session и проставить идентификатор с помощью метода setId:

public function getSession(Request $request)
{
    return tap($this->manager->driver(), function ($session) use ($request) {
        $session->setId($request->cookies->get($session->getName()));
    });
}

Напомню, что метод setId либо проставляет идентификатор, если он валидный (как минимум, не равен null), либо генерирует новый:

public function setId($id)
{
    $this->id = $this->isValidId($id) ? $id : $this->generateSessionId();
}

Таким образом, если вы еще не имеете идентификатор, фреймворк сгенерирует его для вас.

После этого мы запускаем сессию и кладем объект сессии в Illuminate\Http\Request, чтобы впоследствии вы смогли достать сессию в контроллере через реквест:

$request->setLaravelSession(
    $this->startSession($request, $session)
);

Старт выглядит следующим образом:

protected function startSession(Request $request, $session)
{
   return tap($session, function ($session) use ($request) {
       $session->setRequestOnHandler($request);

       $session->start();
   });
}

Как выглядит start объекта Session, мы уже рассмотрели выше.

Далее фреймворк должен собрать мусор — удалить из текущего хранилища сессии устаревшие данные. Поскольку не все хранилища умеют очищать данные сами, их нужно почистить вручную. Для того, чтобы не собирать мусор каждый запрос, используется механизм "лотереи":

protected function collectGarbage(Session $session)
{
    $config = $this->manager->getSessionConfig();

        // Here we will see if this request hits the garbage collection lottery by hitting
        // the odds needed to perform garbage collection on any given request. If we do
        // hit it, we'll call this handler to let it delete all the expired sessions.
     if ($this->configHitsLottery($config)) {
         $session->getHandler()->gc($this->getSessionLifetimeInSeconds());
     }
}

protected function configHitsLottery(array $config)
{
   return random_int(1, $config['lottery'][1]) <= $config['lottery'][0];
}

По умолчанию используется значение [2, 100]: то есть если выпало так, что рандомное значение меньше или равно двум, то хранилище очищает устаревшие данные.

Например, если gc будет вызван на базе данных как хранилище, произойдет следующее:

public function gc($lifetime)
{
    $this->getQuery()->where('last_activity', '<=', $this->currentTime() - $lifetime)->delete();
}

Далее миддлвара пропускает весь пайплайн с помощью $response = $next($request); и дожидается ответа, чтобы сохранить данные и добавить куку в ответ:

$this->storeCurrentUrl($request, $session);

$this->addCookieToResponse($response, $session);

// Again, if the session has been configured we will need to close out the session
// so that the attributes may be persisted to some storage medium. We will also
// add the session identifier cookie to the application response headers now.
$this->saveSession($request);

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

Добавляем в куку в ответ:

protected function addCookieToResponse(Response $response, Session $session)
{
    if ($this->sessionIsPersistent($config = $this->manager->getSessionConfig())) {
        $response->headers->setCookie(new Cookie(
           $session->getName(), $session->getId(), $this->getCookieExpirationDate(),
           $config['path'], $config['domain'], $config['secure'] ?? false,
           $config['http_only'] ?? true, false, $config['same_site'] ?? null
       ));
    }
}

Не забудем проставить куке флаг http_only => true, чтобы к ней нельзя было получить доступ из javascript.

Кука сохранится под именем laravel_session, если вы не поменяли APP_NAME в .env или не прописали SESSION_COOKIE в качестве своего имени куки.

И в конце мы сохраняем данные в сессию.

Итак, давайте закрепим материал простым описанием процесса в виде блок-схемы:
session flow

loader
13.01.2023 в 17:26
8359
+5
Комментарии
Новый комментарий

Логические задачи с собеседований