[延伸創作] 深入 Session 與 Cookie:Laravel 的實作

前言

看到 Huli 寫了這篇 深入 Session 與 Cookie:Express、PHP 與 Rails 的實作,想說既然都寫到了 PHP 和 Rails 了,身為 PHP 版 Rails 的 Laravel 怎麼可以缺席呢 (?),所以就也來追追看原始碼吧!

原本想在短時間內就把這篇發出去的,趁著風潮嘛。

結果 Laravel 原始碼果然很難追,Laravel 5.8 版原始碼追著追著 Laravel 6.x 就推出了,擺著擺著 7.x 版又… 🤦‍♂️,就這樣過了八個多月,看來有得改了,還好差異不大。

那我們就開始吧!

TL;DR

可以直接滑最後看結論 😭

本篇閱讀指南

  • 以下內容都盡可能地提供原始碼,並附上在 GitHub 的連結、highlight 對應的行號。
  • Laravel 在 GitHub 的 repo 分兩種:
    • laravel/laravel 是給使用者建立的專案內容,包含了一些可調整的設定檔。本文以 v7.6.0 版為例。
    • laravel/framework 則是框架原始碼本體。本文以 v7.9.2 版為例。
  • 當本文需要您切換檔案時,會直接於內文提到檔案路徑。
    若只是在同一檔案的不同位置,則簡單的附上 GitHub 原始碼超連結。
  • 檔案路徑若標示為 framework 開頭,通常位於您專案的 vendor/laravel/framework 下。
    其餘檔案路徑則是位於您的專案根目錄下。

Laravel Framework(以 v7.9.2 版為例)

Laravel 除了是目前 PHP 上最熱門的框架,也是截至目前 GitHub 上最多 Star 的後端框架。

與 Huli 的順序不同,我們先從 官方文件 來看看 Laravel 怎麼處理 session:

Laravel 文件中的 HTTP Session 章節
Laravel 文件中的 HTTP Session 章節

Laravel 怎麼生成 Session ID 的

我們的構想是這樣:在文件裡面找找看有沒有跟生成 session ID 有關的 method,然後去追它的程式碼。

「重新生成 Session ID」

查看文件的時候發現,Laravel 為了預防「固定 session ID (Session Fixation)」攻擊,提供了以下程式碼來重新生成 session ID,下面這段是 文件裡的範例

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

我們可以將 regenerate() 這個 function 作為我們追蹤原始碼的起點,看看它是怎麼生成 ID 的。這個 function 存在於 framework/src/Illuminate/Session/Store.php

/**
 * Generate a new session identifier.
 *
 * @param  bool  $destroy
 * @return bool
 */
public function regenerate($destroy = false)
{
    return tap($this->migrate($destroy), function () {
        $this->regenerateToken();
    });
}

這裡我們看到了 tap() 這個 Laravel 的 Helper,功能是將第一個參數 $value 代入第二個參數 $callback function 中,並回傳結果。程式碼位於 framework/src/Illuminate/Support/helpers.php,簡化後大致是像這樣:

function tap($value, $callback)
{
    $callback($value);

    return $value;
}

所以我們可以將 regenerate() 改寫成下面這樣:

/**
 * Generate a new session identifier.
 *
 * @param  bool  $destroy
 * @return bool
 */
public function regenerate($destroy = false)
{
    $value = $this->migrate($destroy);
    $this->regenerateToken();

    return $value;
}

建立 Session ID

接著我們追過去,到同檔案中的 migrate() 看看長什麼樣。PHPDoc 寫明了就是要建立 session ID:(原始碼位置

/**
 * Generate a new session ID for the session.
 *
 * @param  bool  $destroy
 * @return bool
 */
public function migrate($destroy = false)
{
    if ($destroy) {
        $this->handler->destroy($this->getId());
    }

    $this->setExists(false);

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

    return true;
}

Laravel 的函式命名的很易懂,我們在這裡找到了令人感興趣的 generateSessionId(),我們追下去:(原始碼位置

/**
 * Get a new, random session ID.
 *
 * @return string
 */
protected function generateSessionId()
{
    return Str::random(40);
}

哎呀,常寫 Laravel 的人一定很熟悉,這是我們常用的 String helper 系列的東西。不過不熟悉 helper 的人應該也可以猜測得到,這裡可能是要產生隨機 40 個字元的亂數。

到這裡,我們幾乎可以確定 Laravel 並不是使用 PHP 原生的 session 機制。

Laravel 並不是使用 PHP 原生的 session 機制

我們到 random helper 確認看看,位於 framework/src/Illuminate/Support/Str.php

/**
 * Generate a more truly "random" alpha-numeric string.
 *
 * @param  int  $length
 * @return string
 */
public static function random($length = 16)
{
    $string = '';

    while (($len = strlen($string)) < $length) {
        $size = $length - $len;

        $bytes = random_bytes($size);

        $string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
    }

    return $string;
}

這個 helper 會先使用 PHP 7 新加入的 random_bytes 原生 function 來生成指定長度的 安全隨機字串,接著依序執行以下事情:

  1. base64_encode:將此隨機字串編碼成 base64。
  2. str_replace:將上述「編碼後的字串」移除 '/''+''=' 這三種字元。
  3. substr:將上述「移除指定字元後的字串」的長度截短至指定長度(這裡是 40)
  4. while:如果長度不夠,就再用一樣的方式生成隨機字串,接在後面,直到長度剛好為止。

至於 random_bytes 是怎麼生成安全隨機字串?PHP 官方文件提到,它其實是使用以下系統 function 的生成器來實作:

這些產生器的正式名稱叫做 密碼學安全偽亂數生成器(CSPRNG)

將 Session Information 寫進 File

目前為止,我們已經知道 session ID 是怎麼生成的了。那麼是怎麼存 session information 呢?

從 Laravel 儲存 Session 的指令下手

先看 Laravel 儲存資料到 session 的兩個 寫法,我們挑第一種寫法來研究 1

$request->session()->put('key', 'value');

我們看看 put 這個 method 在做什麼,位於 framework/src/Illuminate/Session/Store.php

/**
 * Put a key / value pair or array of key / value pairs in the session.
 *
 * @param  string|array  $key
 * @param  mixed  $value
 * @return void
 */
public function put($key, $value = null)
{
    if (! is_array($key)) {
        $key = [$key => $value];
    }

    foreach ($key as $arrayKey => $arrayValue) {
        Arr::set($this->attributes, $arrayKey, $arrayValue);
    }
}

這裡用到了 Laravel 另一個常見的 Array helper:Arr::set,功能是指定 key-value 到陣列中。這裡看起來 Laravel 是把我們給定的資料直接存到 $this->attributes 的陣列裡。

然後呢?程式碼怎麼沒了?

Laravel 的原始碼出了名的難追,沒關係,我們換個思路。

如果儲存資料的 function 只是將資料放進陣列,那總有一個地方在讀取那個陣列,並且把它寫入到 session 中吧。

Driver:儲存 Session 的管道

從 session 文件的 Configuration 段落中可以得知,Laravel 預設支援以下的方式來儲存 session:

  • file:在伺服器中寫入一個檔案來儲存
  • cookie:在瀏覽器的 cookie 中儲存加密後的資料(即 Cookie-based session)
  • database:儲存到伺服器的關聯式資料庫中
  • memcached / redis:儲存到伺服器的快取資料庫
  • array:暫時儲存在伺服器 PHP 執行階段的一個陣列中(測試時使用)

也許我們可以從這些 driver 找找看它寫入的機制。但是要去哪裡找 driver 的原始碼呢?

在 session 文件中的 Adding Custom Session Drivers 指出,我們還可以建立自訂的 driver 來支援 session,而這個 driver 的條件是:

看起來 service provider 會是進入 driver 的起點。那我們找找看有沒有 session 的 service provider?

Session 的 Service Provider

當然有。使用者建立 Laravel 專案時,預設就會把 SessionServiceProvider 自動載入了,設定檔在專案的 config/app.php

/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers
|--------------------------------------------------------------------------
|
| The service providers listed here will be automatically loaded on the
| request to your application. Feel free to add your own services to
| this array to grant expanded functionality to your applications.
|
*/
'providers' => [
    // 略...

    Illuminate\Session\SessionServiceProvider::class,

    // 略...
],

繼續追下去,我們來看看 SessionServiceProvider 做了什麼,原始碼在framework/src/Illuminate/Session/SessionServiceProvider.php

/**
 * Register the service provider.
 *
 * @return void
 */
public function register()
{
    $this->registerSessionManager();

    $this->registerSessionDriver();

    $this->app->singleton(StartSession::class);
}

前兩行看起來是把 session 的 Manager 和 driver 註冊起來。令人比較感興趣的是第三行,使用 singleton 模式 綁定 StartSession::class 到 Laravel 的 服務容器(Service Container) 中。

我們現在的目標是希望知道 Laravel 如何寫入 session,這裡的 StartSession 看起來像是 session 的操作邏輯,我們進去看看。

StartSession 是個 Middleware

StartSession 位於 framework/src/Illuminate/Session/Middleware/StartSession.php

從 Namespace 可以看出來,是個 Middleware果然英雄所見略同, Laravel 和 Express、Rails 一樣都是使用 middleware 來實作 session 機制。

我們把專注力放在 handle method:

/**
 * Handle an incoming request.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Closure  $next
 * @return mixed
 */
public function handle($request, Closure $next)
{
    if (! $this->sessionConfigured()) {
        return $next($request);
    }

    // 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(
        $session = $this->startSession($request)
    );

    $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;
}

這裡的註解強調了 Laravel 並沒有在任何一個地方使用 PHP 原生的 session 機制,因為它 crappy 😅,這個我們後面來談談。

整理一下這個 Middleware 做了什麼:

  1. 檢查 session 設定,沒有設定的話就直接離開 middleware
  2. 啟動 session,將 session data 設定到 request 中
  3. 清除過期的 session 垃圾(依機率觸發)
  4. 通過 middleware,request 進入應用程式,response 回到 middleware
  5. 符合指定條件時儲存當前 request 的 URL 到 session
  6. 將 session cookie 加到 response 中
  7. 儲存 session 資料
  8. 離開 middleware

saveSession():儲存 Session 資料

為了找尋如何儲存 session 資料,我們進入第七點,也就是 saveSession() 這個 method:(原始碼位置

/**
 * Save the session data to storage.
 *
 * @param  \Illuminate\Http\Request  $request
 * @return void
 */
protected function saveSession($request)
{
    $this->manager->driver()->save();
}

我們將依序處理這個漫長的過程(偷偷爆雷):

  1. 找到 manager,也就是 SessionManager
  2. 執行 driver() 取得 driver instance
  3. 再到 save() 執行完整個 session 的儲存動作

SessionManager

依照慣例,$this->manager 是從建構子(constructor)裡 依賴注入(Dependency injection, DI) 進來的,是個 SessionManager:(原始碼位置

/**
 * Create a new session middleware.
 *
 * @param  \Illuminate\Session\SessionManager  $manager
 * @return void
 */
public function __construct(SessionManager $manager)
{
    $this->manager = $manager;
}

我們找到了 SessionManager,位於 framework/src/Illuminate/Session/SessionManager.php

Manager::driver():取得 Driver Instance

但是,SessionManager 裡面並沒有 driver() 的蹤跡。

SessionManager 本身是繼承 Manager:(原始碼位置

class SessionManager extends Manager

剛剛 saveSession() 中提到的 driver() 就位於 Manager 中。為了方便閱讀,這裡我們稍微簡化一下內容:(原始碼位置

/**
 * Get a driver instance.
 *
 * @param  string|null  $driver
 * @return mixed
 *
 * @throws \InvalidArgumentException
 */
public function driver($driver = null)
{
    $driver = $driver ?: $this->getDefaultDriver();

    // If the given driver has not been created before, we will create the instances
    // here and cache it so we can return it next time very quickly. If there is
    // already a driver created by this name, we'll just return that instance.
    if (! isset($this->drivers[$driver])) {
        $this->drivers[$driver] = $this->createDriver($driver);
    }

    return $this->drivers[$driver];
}

先看第一段 code。

$driver = $driver ?: $this->getDefaultDriver();

還記得 saveSession() 中的那行 code 嗎?(第一次問你,後面會再問你一次)

$this->manager->driver()->save();

由於我們在呼叫 driver() 時並沒有指定參數,因此會執行 ?: 後方的 getDefaultDriver() ,然後把它的回傳值賦值給 $driver

getDefaultDriver() 的名稱看起來,這裡會去抓我們在專案 config 中指定的預設 driver。我們暫時回去 SessionManagergetDefaultDriver() 確認一下:(原始碼位置

/**
 * Get the default session driver name.
 *
 * @return string
 */
public function getDefaultDriver()
{
    return $this->config->get('session.driver');
}

果然沒錯。Laravel 會依照我們在專案的 config/session.php 中的設定來選擇要使用的 Driver,預設值File

回來看 driver() 第二段 code。

// If the given driver has not been created before, we will create the instances
// here and cache it so we can return it next time very quickly. If there is
// already a driver created by this name, we'll just return that instance.
if (! isset($this->drivers[$driver])) {
    $this->drivers[$driver] = $this->createDriver($driver);
}

這裡的註解很清楚,Laravel 會依據指定的 driver 執行 createDriver() 來建立一個 instance,順便把這個 instance cache 起來。

我們來看 createDriver() 怎麼作業的,為了方便閱讀我們簡化了一下內容,原始內容請見 原始碼

/**
 * Create a new driver instance.
 *
 * @param  string  $driver
 * @return mixed
 *
 * @throws \InvalidArgumentException
 */
protected function createDriver($driver)
{
    $method = 'create'.Str::studly($driver).'Driver';

    if (method_exists($this, $method)) {
        return $this->$method();
    }
}

裡面用到了 Str::studly 這個 helper,會把字串轉成類似大駝峰式命名法的 Studly caps。所以我們要找的是 createFileDriver()createCookieDriver() 等等這種命名規則的 method。

createFileDriver():建立 File Session Driver 的 Instance

我們以預設的 File driver 為例,createFileDriver() 就在 SessionManager 中,長這樣:(原始碼位置

/**
 * Create an instance of the file session driver.
 *
 * @return \Illuminate\Session\Store
 */
protected function createFileDriver()
{
    return $this->createNativeDriver();
}

/**
 * Create an instance of the file session driver.
 *
 * @return \Illuminate\Session\Store
 */
protected function createNativeDriver()
{
    $lifetime = $this->config->get('session.lifetime');

    return $this->buildSession(new FileSessionHandler(
        $this->container->make('files'), $this->config->get('session.files'), $lifetime
    ));
}

createFileDriver() 直接導向 createNativeDriver()

createNativeDriver() 中的第一行,$lifetime 賦值為我們在專案的 config 中指定 session lifetime 的時間(預設值 為 120 分鐘):

$lifetime = $this->config->get('session.lifetime');

接著看第二行,看起來有點小複雜?我們先看小括號裡面,排版一下:

new FileSessionHandler(
    $this->container->make('files'),
    $this->config->get('session.files'),
    $lifetime
)

如果你瀏覽一下其餘 driver 所對應的 create____Driver() 系列 method,可以發現都是類似的流程,不外乎建立一個該 driver 的 SessionHandler object。像 File driver 建立的就是 FileSessionHandler,在這裡傳入的依序是:

  • File Facade(後面說明)
  • session 檔案儲存的位置(預設值'/storage/framework/sessions'
  • 剛剛第一行拿到的 session lifetime

稍微瞄一下 FileSessionHandler 的建構子,位於 framework/src/Illuminate/Session/FileSessionHandler.php

/**
 * Create a new file driven handler instance.
 *
 * @param  \Illuminate\Filesystem\Filesystem  $files
 * @param  string  $path
 * @param  int  $minutes
 * @return void
 */
public function __construct(Filesystem $files, $path, $minutes)
{
    $this->path = $path;
    $this->files = $files;
    $this->minutes = $minutes;
}

建構子把三個傳來的參數存到 property,簡單整理就好,很後面才會用到:

  • path:存 session 檔案儲存的位置(預設為 '/storage/framework/sessions'
  • files:存 File Facade
  • minutes:存 session lifetime(預設為 120 分鐘)

接著回到 createNativeDriver(),將剛剛建好的 FileSessionHandler 傳入 buildSession() 執行,就開始建立 session 了。

buildSession(): 建立 Session Instance

buildSession() 這名稱看起來,就有進入重頭戲的感覺。位於 framework/src/Illuminate/Session/SessionManager.php

/**
 * Build the session instance.
 *
 * @param  \SessionHandlerInterface  $handler
 * @return \Illuminate\Session\Store
 */
protected function buildSession($handler)
{
    return $this->config->get('session.encrypt')
            ? $this->buildEncryptedSession($handler)
            : new Store($this->config->get('session.cookie'), $handler);
}

首先會去專案的 config/session.php 中抓取加密的設定值。當專案將其設定為 true 時,Laravel 會自動在儲存 session 前先將內容進行加密。

由於加密預設值為 false,我們可以看到 Laravel 會先取得專案的 config/session.php 中指定的 session cookie name(預設值'專案名_session'),然後將其與 FileSessionHandler 一起傳給 Store 的建構子。

Store:Session Instance 本體

Store?有沒有很熟悉,就是我們第一個段落找到的 class。也就是說,其實 $request->session() 所回傳的實體,就是當前 request 的 session instance 本體。

Store 位於 framework/src/Illuminate/Session/Store.php

/**
 * Create a new session instance.
 *
 * @param  string  $name
 * @param  \SessionHandlerInterface  $handler
 * @param  string|null  $id
 * @return void
 */
public function __construct($name, SessionHandlerInterface $handler, $id = null)
{
    $this->setId($id);
    $this->name = $name;
    $this->handler = $handler;
}

字串'專案名_session'FileSessionHandler 都被各自賦值到 object 的 namehandler property 中。而由於剛才 buildSession() 並沒有指定 $id,我們將 setId() 帶入 null:(原始碼位置

/**
 * Set the session ID.
 *
 * @param  string  $id
 * @return void
 */
public function setId($id)
{
    $this->id = $this->isValidId($id) ? $id : $this->generateSessionId();
}

$this->id 會先執行isValidId() 判斷 ID 是否有效:(原始碼位置

/**
 * Determine if this is a valid session ID.
 *
 * @param  string  $id
 * @return bool
 */
public function isValidId($id)
{
    return is_string($id) && ctype_alnum($id) && strlen($id) === 40;
}

很明顯地,null 並不是一個字串,所以後續的判斷式也不會執行了,直接回傳 false 到剛剛的 setId()$this->id 將被賦值為 $this->generateSessionId() 的結果,也就是產生新的 ID。

這個 generateSessionId() 已經在 〈Laravel 怎麼生成 Session ID?〉 段落追蹤過,因此我們知道 ID 為隨機 40 個字元。

return 再 return:回到 saveSession()

我是誰,我在哪?

還記得 saveSession() 中的那行 code 嗎?(這不就來問你第二次了嗎)

$this->manager->driver()->save();

我們翻山越嶺,總算從 driver() 那裡拿到了 session instance 本體,也就是 Store object。接著就要來儲存啦!

save(): 儲存 Session 資料到指定儲存空間(File)

辛苦了!我們到了最後的環節。

我們從 session instance(也就是 Store)中可以找到 save() 這個 method,位於 framework/src/Illuminate/Session/Store.php

/**
 * Save the session data to storage.
 *
 * @return void
 */
public function save()
{
    $this->ageFlashData();

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

    $this->started = false;
}

第一段先執行 ageFlashData(),過去看看:(原始碼位置

/**
 * Age the flash data for the session.
 *
 * @return void
 */
public function ageFlashData()
{
    $this->forget($this->get('_flash.old', []));

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

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

這裡會做快閃資料(Flash Data)生命週期有關的事,由於這裡不是我們的重點,我們就不深入拆解裡面的 function 了,簡單敘述帶過:

  1. 將舊的快閃資料列表找出來,從 session 裡清掉
  2. 把舊的資料列表取代成新的資料列表
  3. 把新的資料列表清空

其實這也就是 session 文件 裡提到的快閃資料機制。

回到上一層 save(),接著看第二段,執行 $this->handler->write(),我們重新排版一下:

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

首先回想一下,還記得 buildSession() 的段落嗎?我們有將 FileSessionHandler 傳給建構子,所以這裡的 $this->handler 就是 FileSessionHandler

先看它傳什麼進去,簡單整理:(點選 function 看原始碼位置)

  • getId():這個 function 會取得建構 Store 時傳入的 session ID
  • prepareForStorage():這個 function 會直接回傳括號裡的資料 2
    • serialize($this->attributes):使用 PHP 原生 serialize() 來序列化 $this->attributes

這裡很明顯地,要儲存的 session 資料就在 $this->attributes 裡。

還記得我們是什麼時候把資料放進去的嗎?請往上找到 〈從 Laravel 儲存 Session 的指令下手〉 段落。

write():寫入 Session 資料到 File

準備好所有傳入參數後,接著就是呼叫 FileSessionHandler::write(),它是位於 framework/src/Illuminate/Session/FileSessionHandler.php

/**
 * {@inheritdoc}
 */
public function write($sessionId, $data)
{
    $this->files->put($this->path.'/'.$sessionId, $data, true);

    return true;
}

這裡的 PHPDoc 是直接繼承自 PHP 原生的 SessionHandlerInterface::write()。在這裡我們可以發現,雖然 Laravel 自己實作 session 機制,但還是有實作 PHP 原生的 interface(但也只有這個環節)。

我們來看 SessionHandlerInterface::write() 的 PHPDoc 寫了什麼:

/**
 * Write session data
 * @link https://php.net/manual/en/sessionhandlerinterface.write.php
 * @param string $session_id The session id.
 * @param string $session_data <p>
 * The encoded session data. This data is the
 * result of the PHP internally encoding
 * the $_SESSION superglobal to a serialized
 * string and passing it as this parameter.
 * Please note sessions use an alternative serialization method.
 * </p>
 * @return bool <p>
 * The return value (usually TRUE on success, FALSE on failure).
 * Note this value is returned internally to PHP for processing.
 * </p>
 * @since 5.4
 */

簡單說就是把 session 的資料編碼(encoded)成序列化(serialized)字串,然後儲存。詳細資訊可以在 PHP 官方 SessionHandlerInterface::write 文件 中找到。

好,我們回到 FileSessionHandler::write(),重點只有一行:

$this->files->put($this->path.'/'.$sessionId, $data, true);

這裡的 $this->files 在 Store 建構時存了 Laravel 提供的 File Facade。

分岔一下,來講 File Facade

Facade 是什麼呢?它是一個靜態代理(static proxy),將 Laravel 的幾乎所有功能都用 服務容器(Service Container)綁定成靜態 Class。這其實也是 一種 design pattern

簡單講,有了 Facade,可以很方便的使用分散在各個 namespace 的 Laravel 功能(或說是 class)。

所以 File Facade 對應到原始 class 是什麼呢?Facade 文件 可以看到,是 Illuminate\Filesystem\Filesystem。其實在 FileSessionHandler 的建構子也寫了,回顧一下:(原始碼位置

public function __construct(Filesystem $files, $path, $minutes)

回來 write():繼續寫入 Session 資料到 File

所以我們回到 write() 的那一行:

$this->files->put($this->path.'/'.$sessionId, $data, true);

先看傳入參數:

  • 第一個參數:存 session 檔案儲存的位置,後面加上 session ID
  • 第二個參數:序列化後的 session data
  • 第三個參數:true

put():把內容寫入檔案

接著找到 Filesystem::put(),位於 framework/src/Illuminate/Filesystem/Filesystem.php

/**
 * Write the contents of a file.
 *
 * @param  string  $path
 * @param  string  $contents
 * @param  bool  $lock
 * @return int|bool
 */
public function put($path, $contents, $lock = false)
{
    return file_put_contents($path, $contents, $lock ? LOCK_EX : 0);
}

看到這個 PHPDoc 鬆了一口氣,終於!我們要把資料寫到檔案了!

這裡使用 PHP 原生的 file_put_contents 來寫檔,參數對應如下:

  • 第一個參數 $filename:檔案位置字串,傳入我們的 $path
    • 變數內容為 session 檔案儲存的位置,後面加上 session ID。
    • 預設資料:'/storage/framework/sessions/${SessionId}'
  • 第二個參數 $data:寫入檔案的資料,傳入我們的 $contents
    • 變數內容為序列化後的 session data。
  • 第三個參數 $flags:寫入檔案時的設定,這裡設定成 LOCK_EX
    • 這裡我們指定檔案在寫入時會鎖定,防止其他人寫入。

這樣就完成 session 的寫檔了!

欸不是,應該要討論 cookie 吧,怎麼沒看到?因為我一直照 Laravel 預設值去追,結果寫完才發現我寫成 File driver 了 🤦‍♂️

(到這裡才發現,Huli 在 PHP 段落也是寫 file 版啊)

那麼,是怎麼存 session information 到 cookie 的呢?我們從頭開始,快轉到 driver 的部分吧。

當專案將 session 的 driver 指定為 'cookie' 時,上方 createFileDriver() 部分就會被改成呼叫 createCookieDriver()

/**
 * Create an instance of the "cookie" session driver.
 *
 * @return \Illuminate\Session\Store
 */
protected function createCookieDriver()
{
    return $this->buildSession(new CookieSessionHandler(
        $this->container->make('cookie'), $this->config->get('session.lifetime')
    ));
}

在 cookie driver 建立的就是 CookieSessionHandler,在這裡傳入的依序是:

  • Cookie Facade
  • session lifetime

長得跟 createFileDriver() 流程幾乎一模一樣,差異是:

  • FileSessionHandler 換成了 CookieSessionHandler
  • CookieSessionHandler 少了中間的參數,也就是指定寫入位置的部分
  • Facade 也從 'file'Illuminate\Filesystem\Filesystem) 換成了 'cookie'Illuminate\Cookie\CookieJar

這樣會有什麼變化呢?我們追追看… 🤷‍♂️

驚喜地發現,中間流程都一樣!

Laravel 的架構做得如此優美,做到了 DRY 原則(Don’t repeat yourself)

由於中間 handler 參數都是使用了 interface 來規範,要一直到 handler 開始處理後流程才會分岔,我們直接跳到 save()

save(): 儲存 Session 資料到指定儲存空間(Cookie)

辛苦了!我們 到了最後的環節。

我們直接快轉到 $this->handler->write(),這次我們的 $this->handler 換成了 CookieSessionHandler

準備好所有傳入參數後,接著就是呼叫 CookieSessionHandler::write(),它是位於 framework/src/Illuminate/Session/CookieSessionHandler.php

/**
 * {@inheritdoc}
 */
public function write($sessionId, $data)
{
    $this->cookie->queue($sessionId, json_encode([
        'data' => $data,
        'expires' => $this->availableAt($this->minutes * 60),
    ]), $this->minutes);

    return true;
}

首先不意外的,CookieSessionHandler 依然是實作 PHP 原生的 SessionHandlerInterface

然後,這裡開始有點不一樣了。

這裡的 $this->cookieStore 建構時存了 Laravel 提供的 Cookie Facade,剛說過了,本尊是 Illuminate\Cookie\CookieJar

所以我們回到 write() 接續看,稍微換個排版:

$this->cookie->queue(
    $sessionId,
    json_encode(
        [
            'data' => $data,
            'expires' => $this->availableAt($this->minutes * 60),
        ]
    ),
    $this->minutes);

這裡參數中有個沒看過的 availableAt(),我們先解決它。它來自 InteractsWithTime 這個 Trait,我們傳入的參數預設為 120 * 60

availableAt():取得 Session 到期時間的 UNIX Timestamp

availableAt() 位於 framework/src/Illuminate/Support/InteractsWithTime.php

/**
 * Get the "available at" UNIX timestamp.
 *
 * @param  \DateTimeInterface|\DateInterval|int  $delay
 * @return int
 */
protected function availableAt($delay = 0)
{
    $delay = $this->parseDateInterval($delay);

    return $delay instanceof DateTimeInterface
        ? $delay->getTimestamp()
        : Carbon::now()->addRealSeconds($delay)->getTimestamp();
}

/**
 * If the given value is an interval, convert it to a DateTime instance.
 *
 * @param  \DateTimeInterface|\DateInterval|int  $delay
 * @return \DateTimeInterface|int
 */
protected function parseDateInterval($delay)
{
    if ($delay instanceof DateInterval) {
        $delay = Carbon::now()->add($delay);
    }

    return $delay;
}

首先第一段,$delay 如果是 DateInterval 型態的話,會被轉為 Carbon object(PHP 界的 Moment.js)。我們這裡 $delayint 型態,所以不會被轉換。

第二段,如果是 DateTimeInterfaceCarbon object 實作的 Interface)的話就轉為 Timestamp;否則,建立現在時間的 Carbon object,加上 $delay 後再轉為 Timestamp。我們明顯是後者的情況。

所以我們回到 write()

$this->cookie->queue(
    $sessionId,
    json_encode(
        [
            'data' => $data,
            'expires' => $this->availableAt($this->minutes * 60),
        ]
    ),
    $this->minutes);

先看傳入參數:

  • 第一個參數:就是個 session ID
  • 第二個參數:一個陣列,放入了 session data 以及過期時間的 timestamp,然後轉成 JSON
  • 第三個參數:session lifetime(預設為 120 分鐘)

接著找到 CookieJar::queue(),位於 framework/src/Illuminate/Cookie/CookieJar.php

/**
 * Queue a cookie to send with the next response.
 *
 * @param  array  $parameters
 * @return void
 */
public function queue(...$parameters)
{
    if (isset($parameters[0]) && $parameters[0] instanceof Cookie) {
        $cookie = $parameters[0];
    } else {
        $cookie = $this->make(...$parameters);
    }

    if (! isset($this->queued[$cookie->getName()])) {
        $this->queued[$cookie->getName()] = [];
    }

    $this->queued[$cookie->getName()][$cookie->getPath()] = $cookie;
}

這裡往裡面追會太繁瑣,所以直接用文字說明。

queue() 是個 Variadic function,將所有傳入的參數依序轉成 $parameters 陣列。

第一段的 if 判斷 $parameters[0] 是不是 Cookie object,這裡我們傳入的是 session ID 字串,所以進入 else 範圍,執行 make()。而 make() 會將我們傳入的參數組合成 Symfony 3Cookie object(\Symfony\Component\HttpFoundation\Cookie)回傳。

第二段的 if 檢查目前的 queue 有沒有包含指定 session ID 為名的資料,如果沒有就先建立一個空陣列到 queued[${sessionId}] 中。

第三段就將新建立的 Cookie object 放入 queued[${sessionId}]['/'] 中。(getPath() 的結果預設為 '/'

這樣就完成 session 排入 cookie queue 了!

補完最後一塊拼圖:你剛講的都是從一半切入耶

我們剛追了超級大段的 code,卻是從一半切入,但還有幾個不清楚的地方:

  • StartSession middleware 什麼時候觸發的?
  • 排好的 cookie queue 什麼時候送出?

Laravel 專案預設的 web Middleware Group

middleware 文件 中有提到,Laravel 預設有綁定 web middleware group 在我們的 web route 上,位於專案的 app/Http/Kernel.php

/**
 * The application's route middleware groups.
 *
 * @var array
 */
protected $middlewareGroups = [
    'web' => [
        // 略 ...

        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,

        // 略 ...
    ],

    // 略 ...

這裡就是 StartSession 的切入點了!第一個問題解決。

另外我們也看到了 AddQueuedCookiesToResponse

來看看 AddQueuedCookiesToResponse 裡面做什麼,位於 framework/src/Illuminate/Cookie/Middleware/AddQueuedCookiesToResponse.php

**
 * Handle an incoming request.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Closure  $next
 * @return mixed
 */
public function handle($request, Closure $next)
{
    $response = $next($request);

    foreach ($this->cookies->getQueuedCookies() as $cookie) {
        $response->headers->setCookie($cookie);
    }

    return $response;
}

重點很明顯是 getQueuedCookies()setCookie() 了,簡單帶過一下。

getQueuedCookies() 使用 Arr::flatten() helper 攤平 cookie queue 的陣列:(原始碼位置

/**
 * Get the cookies which have been queued for the next request.
 *
 * @return \Symfony\Component\HttpFoundation\Cookie[]
 */
public function getQueuedCookies()
{
    return Arr::flatten($this->queued);
}

setCookie() 則是在 Symfony 4 的 class ResponseHeaderBag 中,將 cookie 加到 response header 裡:(原始碼位置

public function setCookie(Cookie $cookie)
{
    $this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie;
    $this->headerNames['set-cookie'] = 'Set-Cookie';
}

到這裡,我們兩個疑問都解決了!

延伸討論:怎樣的字串才是 Laravel 的有效 Session ID?

雖然沒有執行到 isValidId() 中的後續判斷,但我們可以討論一下 Laravel 是如何判斷有效 session ID,再看一次原始碼:(原始碼位置

/**
 * Determine if this is a valid session ID.
 *
 * @param  string  $id
 * @return bool
 */
public function isValidId($id)
{
    return is_string($id) && ctype_alnum($id) && strlen($id) === 40;
}

有效的 session ID 條件有三個:

  1. session ID 必須要是字串
  2. 該字串必須要是 alphanumeric,意即只能由字母和數字組成(ctype_alnum 文件
  3. 該字串的長度必須是 40

後面來談談:為什麼 Laravel 不使用 PHP 原生的 session 機制

除了原始碼的註解提到了 PHP 原生的 session 機制 crappy 外,Laravel 之父 Taylor Otwell 在 2014 年時的一個 Issue 提到:

Using native PHP sessions is not a good solution. Native PHP sessions output cookies straight to the headers and there is no way to change that. It breaks the entire request/response wrapping we are trying to achieve.
[name=Taylor Otwell]

另外,Laravel 4.1 推出時的 Release Notes,在 Improved Session Engine 段落也提及:

With this release, we’re also introducing an entirely new session engine. Similar to the routing improvements, the new session layer is leaner and faster. We are no longer using Symfony’s (and therefore PHP’s) session handling facilities, and are using a custom solution that is simpler and easier to maintain.

這裡我的解讀是,Laravel 希望能針對 Session 有更多的彈性與掌握權,並且用更具 Laravel 的風格整合在核心中。也因為包裹成 middleware,可以在寫入的前後自由地增加業務邏輯。

你有什麼想法嗎?歡迎留言來討論。

總結

結論就是我好累,為了這篇文章熬夜了多久…

統整一下我們增加的 奇怪 Laravel 知識:

  • Laravel 並沒有在任何一個地方使用 PHP 原生的 session 機制
  • Laravel 的 session ID 必須是 40 個英數字混合的字串
  • Laravel 會使用作業系統層級的安全隨機字串 function 自動生成 session ID
  • Laravel 預設 加密 session 中的 data,但可以簡單開啟加密功能
  • Laravel 也是用 middleware 處理 session
  • Laravel 預設將 session information 存在檔案中,但也能選擇各種 driver,像是跟 Rails 一樣的 cookie-based session,或是儲存在 DB 或 Redis/Memcached 中。也可以自行擴充 driver。

參考資料

在追 code 卡關時,感謝以下資料的提點:

感謝 Huli

Huli 對於教學與分享的熱情,一直是我崇拜的偶像(很老氣的用詞但想不到更好的 🤣)

在此附上 Huli 的系列文連結,感謝開啟這個系列讓我成長。

  1. 白話 Session 與 Cookie:從經營雜貨店開始
  2. 淺談 Session 與 Cookie:一起來讀 RFC
  3. 深入 Session 與 Cookie:Express、PHP 與 Rails 的實作

也恭喜 Huli 找到新工作 🎉


  1. 另一個寫法是採用 session() global helper,大同小異,原始碼位於 framework/src/Illuminate/Foundation/helpers.php ↩︎

  2. 如果我們專案設定 session 要加密的話,這個 function 會執行加密的動作。 ↩︎

  3. 另一個 PHP Framework,Laravel 底層基於 Symfony。精確來說,這裡是用到 Symfony 的 symfony/http-foundation 套件的 class。 ↩︎

  4. 精確來說,是 Symfony 的 symfony/http-foundation 套件。 ↩︎



創用 CC 授權條款
本著作由小克製作,以創用CC 姓名標示-相同方式分享 4.0 國際 授權條款釋出。