会话

简介

HTTP 驱动的应用是无状态的,会话(Session)提供了一种跨请求保存用户信息的方式。这些用户信息通常存放在一个持久化存储后端中,后续请求可以访问到。

Laravel 自带了多种会话后端,通过一套统一、表达力强的 API 来访问。支持 Memcached、Redis 和数据库等常用后端。

配置

应用的会话配置文件在 config/session.php。务必看看这个文件里有哪些选项可用。默认情况下,Laravel 使用 database 会话驱动。

driver 配置项决定了每次请求的会话数据存到哪里。Laravel 自带这些驱动:

  • file —— 会话数据存在 storage/framework/sessions 目录中。
  • cookie —— 会话数据存在安全、加密的 cookie 中。 (基本不用)
  • database —— 会话数据存在关系型数据库中。
  • memcached / redis —— 会话数据存在这些快速的、基于缓存的存储中。(放redis最常用)
  • dynamodb —— 会话数据存在 AWS DynamoDB 中。
  • array —— 会话数据存在 PHP 数组中,不会被持久化。(测试用)

信息

array 驱动主要用于测试,防止会话中存储的数据被持久化。

驱动前提条件

数据库

使用 database 会话驱动时,需要确保数据库里有一张存会话数据的表。通常 Laravel 默认的 0001_01_01_000000_create_users_table.php 数据库迁移已经包含了这张表;但如果你出于某种原因没有 sessions 表,可以用 make:session-table Artisan 命令生成迁移:

php artisan make:session-table

php artisan migrate

Redis

在 Laravel 中使用 Redis 会话之前,需要通过 PECL 安装 PhpRedis PHP 扩展,或者通过 Composer 安装 predis/predis 包(~1.0)。更多 Redis 配置信息请查阅 Laravel 的 Redis 文档。

信息

可以用 SESSION_CONNECTION 环境变量或 session.php 配置文件中的 connection 选项来指定会话存储使用哪个 Redis 连接。

操作会话

读取数据

Laravel 里操作会话数据主要有两种方式:全局 session() 辅助函数和 Request 实例。先来看通过 Request 实例访问会话,它可以在路由闭包或控制器方法中类型提示。控制器方法的依赖会由 Laravel 服务容器自动注入:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\View\View;

class UserController extends Controller
{
    /**
     * 显示指定用户的资料。
     */
    public function show(Request $request, string $id): View
    {
        $value = $request->session()->get('key');

        // ...

        $user = $this->users->find($id);

        return view('user.profile', ['user' => $user]);
    }
}

从会话中取值时,可以给 get() 方法传第二个参数作为默认值。如果指定的键在会话中不存在,就返回这个默认值。如果传一个闭包作为默认值且键不存在,闭包会被执行并返回其结果:

$value = $request->session()->get('key', 'default');

$value = $request->session()->get('key', function () {
    return 'default';
});

全局 session 辅助函数

也可以用全局 session() 函数来读取和存储会话数据。当 session() 辅助函数只传一个字符串参数时,返回该会话键的值。当传一个键值对数组时,这些值会被存入会话:

Route::get('/home', function () {
    // 从会话中取一条数据...
    $value = session('key');

    // 指定默认值...
    $value = session('key', 'default');

    // 往会话中存一条数据...
    session(['key' => 'value']);
});

信息

通过 HTTP 请求实例和通过全局 session() 辅助函数操作会话,实际差别不大。两种方式都可以通过 assertSessionHas() 方法进行测试,该方法在所有测试用例中均可用。

获取所有会话数据

要获取会话中的所有数据,可以用 all() 方法:

$data = $request->session()->all();

获取部分会话数据

用 only() 和 except() 方法可以获取会话数据的子集:

$data = $request->session()->only(['username', 'email']);

$data = $request->session()->except(['username', 'email']);

判断会话中是否存在某项

要判断会话中是否存在某项,用 has() 方法。has() 方法在项存在且不为 null 时返回 true:

if ($request->session()->has('users')) {
    // ...
}

要判断会话中是否存在某项(即使值为 null 也算存在),用 exists() 方法:

if ($request->session()->exists('users')) {
    // ...
}

要判断会话中是否不存在某项,用 missing() 方法。missing() 方法在项不存在时返回 true:

if ($request->session()->missing('users')) {
    // ...
}

存储数据

往会话中存数据,通常用请求实例的 put() 方法或全局 session() 辅助函数:

// 通过请求实例...
$request->session()->put('key', 'value');

// 通过全局 session 辅助函数...
session(['key' => 'value']);

往数组类型的会话值中追加元素

push() 方法可以往数组类型的会话值中追加一个新值。比如 user.teams 键存了一个团队名数组,可以这样追加:

$request->session()->push('user.teams', 'developers');

取出并删除

pull() 方法会在一条语句中取出并删除会话中的某项:

$value = $request->session()->pull('key', 'default');

自增和自减

如果会话数据中有整数需要自增或自减,可以用 increment() 和 decrement() 方法:

$request->session()->increment('count');

$request->session()->increment('count', $incrementBy = 2);

$request->session()->decrement('count');

$request->session()->decrement('count', $decrementBy = 2);

闪存数据

有时候你可能只想在下一个请求中保留某些会话数据。用 flash() 方法就行。用这个方法存的数据在当前请求和下一个 HTTP 请求中立即可用,下一个请求结束后闪存数据就会被删除。闪存数据主要用来存短期的状态消息:

$request->session()->flash('status', 'Task was successful!');

如果需要让闪存数据多保留几个请求,可以用 reflash() 方法,它会把所有闪存数据再多保留一个请求。如果只需要保留特定的闪存数据,用 keep() 方法:

$request->session()->reflash();

$request->session()->keep(['username', 'email']);

如果只想在当前请求中保留闪存数据,用 now() 方法:

$request->session()->now('status', 'Task was successful!');

删除数据

forget() 方法会从会话中删除一条数据。要删除所有数据,用 flush() 方法:

// 删除单个键...
$request->session()->forget('name');

// 删除多个键...
$request->session()->forget(['name', 'status']);

$request->session()->flush();

重新生成 Session ID

重新生成 Session ID 通常是为了防止恶意用户利用会话固定攻击你的应用。

如果你使用 Laravel 的起步套件或 Laravel Fortify,认证过程中会自动重新生成 Session ID。如果需要手动重新生成,可以用 regenerate() 方法:

$request->session()->regenerate();

如果需要重新生成 Session ID 的同时删除所有会话数据,用 invalidate() 方法:

$request->session()->invalidate();

会话缓存

Laravel 的会话缓存提供了一种方便的方式来缓存作用域限定在单个用户会话中的数据。与全局应用缓存不同,会话缓存数据会按会话自动隔离,并在会话过期或销毁时自动清理。会话缓存支持所有熟悉的 Laravel 缓存方法,比如 get()、put()、remember()、forget() 等,只是作用域限定在当前会话。

会话缓存非常适合存储临时的、用户专属的数据——需要在同一会话的多次请求间保持,但又不需要永久存储。比如表单数据、临时计算结果、API 响应,或任何应该绑定到特定用户会话的短暽数据。

通过会话的 cache() 方法访问会话缓存:

$discount = $request->session()->cache()->get('discount');

$request->session()->cache()->put(
    'discount', 10, now()->plus(minutes: 5)
);

更多 Laravel 缓存方法请查阅缓存文档。

会话阻塞

注意

要使用会话阻塞,应用必须使用支持原子锁的缓存驱动。目前支持原子锁的缓存驱动包括 memcached、dynamodb、redis、mongodb(包含在官方 mongodb/laravel-mongodb 包中)、database、file 和 array 驱动。另外,不能使用 cookie 会话驱动。

默认情况下,Laravel 允许使用同一会话的请求并发执行。比如你用 JavaScript HTTP 库同时发出两个请求,它们会同时执行。对大多数应用来说这没问题;但少数应用在并发请求两个不同端点且都往会话写数据时,可能会出现会话数据丢失。

为了缓解这个问题,Laravel 提供了限制同一会话并发请求的功能。只需在路由定义上链式调用 block() 方法。下面这个例子中,来访请求到 /profile 端点时会获取一个会话锁。在这个锁被持有期间,任何共享同一 Session ID 的请求到 /profile 或 /order 端点都会等第一个请求执行完后再继续:

Route::post('/profile', function () {
    // ...
})->block($lockSeconds = 10, $waitSeconds = 10);

Route::post('/order', function () {
    // ...
})->block($lockSeconds = 10, $waitSeconds = 10);

block() 方法接受两个可选参数。第一个参数是会话锁最多持有的秒数,超过这个时间锁会被释放。当然,如果请求在这之前执行完了,锁会提前释放。

第二个参数是请求在尝试获取会话锁时最多等待的秒数。如果在给定秒数内无法获取锁,会抛出 Illuminate\Contracts\Cache\LockTimeoutException 异常。

如果两个参数都不传,锁最多持有 10 秒,请求最多等待 10 秒:

Route::post('/profile', function () {
    // ...
})->block();

添加自定义 Session 驱动

实现驱动

如果现有的会话驱动都不满足你的需求,Laravel 允许你编写自己的会话处理器。自定义会话驱动需要实现 PHP 内置的 SessionHandlerInterface 接口。这个接口只有几个简单方法。一个 MongoDB 实现的骨架长这样:

<?php

namespace App\Extensions;

class MongoSessionHandler implements \SessionHandlerInterface
{
    public function open($savePath, $sessionName) {}
    public function close() {}
    public function read($sessionId) {}
    public function write($sessionId, $data) {}
    public function destroy($sessionId) {}
    public function gc($lifetime) {}
}

Laravel 没有为扩展提供默认目录,你可以随意放在任何地方。这个例子里我们建了一个 Extensions 目录来放 MongoSessionHandler。

这些方法的用途不太直观,下面逐个说明:

  • open() 方法通常用于基于文件的会话存储系统。由于 Laravel 自带了 file 会话驱动,你很少需要在这个方法里写什么逻辑。直接留空就行。
  • close() 方法和 open() 方法一样,通常也可以忽略。对大多数驱动来说不需要。
  • read() 方法应返回与给定 $sessionId 关联的会话数据的字符串版本。在你的驱动中读取或存储会话数据时不需要做任何序列化或其他编码,Laravel 会帮你处理序列化。
  • write() 方法应将与 $sessionId 关联的 $data 字符串写入某种持久化存储系统,比如 MongoDB 或你选择的其他存储。同样不需要做序列化——Laravel 已经替你处理好了。
  • destroy() 方法应从持久化存储中删除与 $sessionId 关联的数据。
  • gc() 方法应销毁所有比给定 $lifetime(UNIX 时间戳)更旧的会话数据。对于 Memcached 和 Redis 这类自带过期的系统,这个方法可以留空。

注册驱动

驱动实现好后,就可以注册到 Laravel 了。要往 Laravel 的会话后端添加额外驱动,可以用 Session 门面提供的 extend() 方法。你应该在服务提供者的 boot() 方法中调用 extend() 方法。可以在现有的 App\Providers\AppServiceProvider 里调,也可以创建一个全新的服务提供者:

<?php

namespace App\Providers;

use App\Extensions\MongoSessionHandler;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\ServiceProvider;

class SessionServiceProvider extends ServiceProvider
{
    /**
     * 注册应用服务。
     */
    public function register(): void
    {
        // ...
    }

    /**
     * 启动应用服务。
     */
    public function boot(): void
    {
        Session::extend('mongo', function (Application $app) {
            // 返回一个 SessionHandlerInterface 的实现...
            return new MongoSessionHandler;
        });
    }
}

注册好会话驱动后,就可以通过 SESSION_DRIVER 环境变量或 config/session.php 配置文件把 mongo 驱动设为应用的会话驱动。