Laravel: Session
Если вы пишете классическое веб-приложение, вы почти наверняка будете использовать сессии. Объяснять, как работают нативные сессии в 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 в качестве своего имени куки.
И в конце мы сохраняем данные в сессию.
Итак, давайте закрепим материал простым описанием процесса в виде блок-схемы:
Комментарии