Laravel核心解讀–Session原始碼解析

kevinyan發表於2018-08-11

Session 模組原始碼解析

由於HTTP最初是一個匿名、無狀態的請求/響應協議,伺服器處理來自客戶端的請求然後向客戶端回送一條響應。現代Web應用程式為了給使用者提供個性化的服務往往需要在請求中識別出使用者或者在使用者的多條請求之間共享資料。Session 提供了一種在多個請求之間儲存、共享有關使用者的資訊的方法。Laravel 通過同一個可讀性強的 API 處理各種自帶的 Session 後臺驅動程式。

Session支援的驅動:

  • file – 將 Session 儲存在 storage/framework/sessions 中。
  • cookie – Session 儲存在安全加密的 Cookie 中。
  • database – Session 儲存在關係型資料庫中。
  • memcached / redis – Sessions 儲存在其中一個快速且基於快取的儲存系統中。
  • array – Sessions 儲存在 PHP 陣列中,不會被持久化。

這篇文章我們來詳細的看一下LaravelSession服務的實現原理,Session服務有哪些部分組成以及每部分的角色、它是何時被註冊到服務容器的、請求是在何時啟用session的以及如何為session擴充套件驅動。

註冊Session服務

在之前的很多文章裡都提到過,服務是通過服務提供器註冊到服務容器裡的,Laravel在啟動階段會依次執行config/app.phpproviders陣列裡的服務提供器register方法來註冊框架需要的服務,所以我們很容易想到session服務也是在這個階段被註冊到服務容器裡的。

`providers` => [

    /*
     * Laravel Framework Service Providers...
     */
    ......
    IlluminateSessionSessionServiceProvider::class
    ......
],

果真在providers裡確實有SessionServiceProvider 我們看一下它的原始碼,看看session服務的註冊細節

namespace IlluminateSession;

use IlluminateSupportServiceProvider;
use IlluminateSessionMiddlewareStartSession;

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

        $this->registerSessionDriver();

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

    /**
     * Register the session manager instance.
     *
     * @return void
     */
    protected function registerSessionManager()
    {
        $this->app->singleton(`session`, function ($app) {
            return new SessionManager($app);
        });
    }

    /**
     * Register the session driver instance.
     *
     * @return void
     */
    protected function registerSessionDriver()
    {
        $this->app->singleton(`session.store`, function ($app) {
            // First, we will create the session manager which is responsible for the
            // creation of the various session drivers when they are needed by the
            // application instance, and will resolve them on a lazy load basis.
            return $app->make(`session`)->driver();
        });
    }
}

SessionServiceProvider中一共註冊了三個服務:

  • session服務,session服務解析出來後是一個SessionManager物件,它的作用是建立session驅動器並且在需要時解析出驅動器(延遲載入),此外一切訪問、更新session資料的方法呼叫都是由它代理給對應的session驅動器來實現的。
  • session.store Session驅動器,IlluminateSessionStore的例項,Store類實現了IlluminateContractsSessionSession契約向開發者提供了統一的介面來訪問Session資料,驅動器通過不同的SessionHandler來訪問databaseredismemcache等不同的儲存介質裡的session資料。
  • StartSession::class 中介軟體,提供了在請求開始時開啟Session,響應傳送給客戶端前將session標示符寫入到Cookie中,此外作為一個terminate中介軟體在響應傳送給客戶端後它在terminate()方法中會將請求中對session資料的更新儲存到儲存介質中去。

建立Session驅動器

上面已經說了SessionManager是用來建立session驅動器的,它裡面定義了各種個樣的驅動器建立器(建立驅動器例項的方法) 通過它的原始碼來看一下session驅動器是證明被建立出來的:

<?php

namespace IlluminateSession;

use IlluminateSupportManager;

class SessionManager extends Manager
{
    /**
     * 呼叫自定義驅動建立器 (通過Session::extend註冊的)
     *
     * @param  string  $driver
     * @return mixed
     */
    protected function callCustomCreator($driver)
    {
        return $this->buildSession(parent::callCustomCreator($driver));
    }

    /**
     * 建立陣列型別的session驅動器(不會持久化)
     *
     * @return IlluminateSessionStore
     */
    protected function createArrayDriver()
    {
        return $this->buildSession(new NullSessionHandler);
    }

    /**
     * 建立Cookie session驅動器
     *
     * @return IlluminateSessionStore
     */
    protected function createCookieDriver()
    {
        return $this->buildSession(new CookieSessionHandler(
            $this->app[`cookie`], $this->app[`config`][`session.lifetime`]
        ));
    }

    /**
     * 建立檔案session驅動器
     *
     * @return IlluminateSessionStore
     */
    protected function createFileDriver()
    {
        return $this->createNativeDriver();
    }

    /**
     * 建立檔案session驅動器
     *
     * @return IlluminateSessionStore
     */
    protected function createNativeDriver()
    {
        $lifetime = $this->app[`config`][`session.lifetime`];

        return $this->buildSession(new FileSessionHandler(
            $this->app[`files`], $this->app[`config`][`session.files`], $lifetime
        ));
    }

    /**
     * 建立Database型的session驅動器
     *
     * @return IlluminateSessionStore
     */
    protected function createDatabaseDriver()
    {
        $table = $this->app[`config`][`session.table`];

        $lifetime = $this->app[`config`][`session.lifetime`];

        return $this->buildSession(new DatabaseSessionHandler(
            $this->getDatabaseConnection(), $table, $lifetime, $this->app
        ));
    }

    /**
     * Get the database connection for the database driver.
     *
     * @return IlluminateDatabaseConnection
     */
    protected function getDatabaseConnection()
    {
        $connection = $this->app[`config`][`session.connection`];

        return $this->app[`db`]->connection($connection);
    }

    /**
     * Create an instance of the APC session driver.
     *
     * @return IlluminateSessionStore
     */
    protected function createApcDriver()
    {
        return $this->createCacheBased(`apc`);
    }

    /**
     * 建立memcache session驅動器
     *
     * @return IlluminateSessionStore
     */
    protected function createMemcachedDriver()
    {
        return $this->createCacheBased(`memcached`);
    }

    /**
     * 建立redis session驅動器
     *
     * @return IlluminateSessionStore
     */
    protected function createRedisDriver()
    {
        $handler = $this->createCacheHandler(`redis`);

        $handler->getCache()->getStore()->setConnection(
            $this->app[`config`][`session.connection`]
        );

        return $this->buildSession($handler);
    }

    /**
     * 建立基於Cache的session驅動器 (建立memcache、apc驅動器時都會呼叫這個方法)
     *
     * @param  string  $driver
     * @return IlluminateSessionStore
     */
    protected function createCacheBased($driver)
    {
        return $this->buildSession($this->createCacheHandler($driver));
    }

    /**
     * 建立基於Cache的session handler
     *
     * @param  string  $driver
     * @return IlluminateSessionCacheBasedSessionHandler
     */
    protected function createCacheHandler($driver)
    {
        $store = $this->app[`config`]->get(`session.store`) ?: $driver;

        return new CacheBasedSessionHandler(
            clone $this->app[`cache`]->store($store),
            $this->app[`config`][`session.lifetime`]
        );
    }

    /**
     * 構建session驅動器
     *
     * @param  SessionHandlerInterface  $handler
     * @return IlluminateSessionStore
     */
    protected function buildSession($handler)
    {
        if ($this->app[`config`][`session.encrypt`]) {
            return $this->buildEncryptedSession($handler);
        }

        return new Store($this->app[`config`][`session.cookie`], $handler);
    }

    /**
     * 構建加密的Session驅動器
     *
     * @param  SessionHandlerInterface  $handler
     * @return IlluminateSessionEncryptedStore
     */
    protected function buildEncryptedSession($handler)
    {
        return new EncryptedStore(
            $this->app[`config`][`session.cookie`], $handler, $this->app[`encrypter`]
        );
    }

    /**
     * 獲取config/session.php裡的配置
     *
     * @return array
     */
    public function getSessionConfig()
    {
        return $this->app[`config`][`session`];
    }

    /**
     * 獲取配置裡的session驅動器名稱
     *
     * @return string
     */
    public function getDefaultDriver()
    {
        return $this->app[`config`][`session.driver`];
    }

    /**
     * 設定配置裡的session名稱
     *
     * @param  string  $name
     * @return void
     */
    public function setDefaultDriver($name)
    {
        $this->app[`config`][`session.driver`] = $name;
    }
}

通過SessionManager的原始碼可以看到驅動器對外提供了統一的訪問介面,而不同型別的驅動器之所以能訪問不同的儲存介質是驅動器是通過SessionHandler來訪問儲存介質裡的資料的,而不同的SessionHandler統一都實現了PHP內建的SessionHandlerInterface介面,所以驅動器能夠通過統一的介面方法訪問到不同的session儲存介質裡的資料。

驅動器訪問Session 資料

開發者使用Session門面或者$request->session()訪問Session資料都是通過session服務即SessionManager物件轉發給對應的驅動器方法的,在IlluminateSessionStore的原始碼中我們也能夠看到Laravel裡用到的session方法都定義在這裡。

Session::get($key);
Session::has($key);
Session::put($key, $value);
Session::pull($key);
Session::flash($key, $value);
Session::forget($key);

上面這些session方法都能在IlluminateSessionStore類裡找到具體的方法實現

<?php

namespace IlluminateSession;

use Closure;
use IlluminateSupportArr;
use IlluminateSupportStr;
use SessionHandlerInterface;
use IlluminateContractsSessionSession;

class Store implements Session
{
    /**
     * The session ID.
     *
     * @var string
     */
    protected $id;

    /**
     * The session name.
     *
     * @var string
     */
    protected $name;

    /**
     * The session attributes.
     *
     * @var array
     */
    protected $attributes = [];

    /**
     * The session handler implementation.
     *
     * @var SessionHandlerInterface
     */
    protected $handler;

    /**
     * Session store started status.
     *
     * @var bool
     */
    protected $started = false;

    /**
     * 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, 通過session handler從儲存介質中讀出資料暫存在attributes屬性裡
     *
     * @return bool
     */
    public function start()
    {
        $this->loadSession();

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

        return $this->started = true;
    }

    /**
     * 通過session handler從儲存中載入session資料暫存到attributes屬性裡
     *
     * @return void
     */
    protected function loadSession()
    {
        $this->attributes = array_merge($this->attributes, $this->readFromHandler());
    }

    /**
     * 通過handler從儲存中讀出session資料
     *
     * @return array
     */
    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 [];
    }

    /**
     * Prepare the raw string data from the session for unserialization.
     *
     * @param  string  $data
     * @return string
     */
    protected function prepareForUnserialize($data)
    {
        return $data;
    }

    /**
     * 將session資料儲存到儲存中
     *
     * @return bool
     */
    public function save()
    {
        $this->ageFlashData();

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

        $this->started = false;
    }

    /**
     * Checks if a key is present and not null.
     *
     * @param  string|array  $key
     * @return bool
     */
    public function has($key)
    {
        return ! collect(is_array($key) ? $key : func_get_args())->contains(function ($key) {
            return is_null($this->get($key));
        });
    }

    /**
     * Get an item from the session.
     *
     * @param  string  $key
     * @param  mixed  $default
     * @return mixed
     */
    public function get($key, $default = null)
    {
        return Arr::get($this->attributes, $key, $default);
    }

    /**
     * Get the value of a given key and then forget it.
     *
     * @param  string  $key
     * @param  string  $default
     * @return mixed
     */
    public function pull($key, $default = null)
    {
        return Arr::pull($this->attributes, $key, $default);
    }

    /**
     * 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);
        }
    }

    /**
     * Flash a key / value pair to the session.
     *
     * @param  string  $key
     * @param  mixed   $value
     * @return void
     */
    public function flash(string $key, $value = true)
    {
        $this->put($key, $value);

        $this->push(`_flash.new`, $key);

        $this->removeFromOldFlashData([$key]);
    }

    /**
     * Remove one or many items from the session.
     *
     * @param  string|array  $keys
     * @return void
     */
    public function forget($keys)
    {
        Arr::forget($this->attributes, $keys);
    }

    /**
     * Remove all of the items from the session.
     *
     * @return void
     */
    public function flush()
    {
        $this->attributes = [];
    }


    /**
     * Determine if the session has been started.
     *
     * @return bool
     */
    public function isStarted()
    {
        return $this->started;
    }

    /**
     * Get the name of the session.
     *
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Set the name of the session.
     *
     * @param  string  $name
     * @return void
     */
    public function setName($name)
    {
        $this->name = $name;
    }

    /**
     * Get the current session ID.
     *
     * @return string
     */
    public function getId()
    {
        return $this->id;
    }

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

    /**
     * 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;
    }

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

    /**
     * Set the existence of the session on the handler if applicable.
     *
     * @param  bool  $value
     * @return void
     */
    public function setExists($value)
    {
        if ($this->handler instanceof ExistenceAwareInterface) {
            $this->handler->setExists($value);
        }
    }

    /**
     * Get the CSRF token value.
     *
     * @return string
     */
    public function token()
    {
        return $this->get(`_token`);
    }
    
    /**
     * Regenerate the CSRF token value.
     *
     * @return void
     */
    public function regenerateToken()
    {
        $this->put(`_token`, Str::random(40));
    }
}

由於驅動器的原始碼比較多,我只留下一些常用和方法,並對關鍵的方法做了註解,完整原始碼可以去看IlluminateSessionStore類的原始碼。 通過Store類的原始碼我們可以發現:

  • 每個session資料裡都會有一個_token資料來做CSRF防範。
  • Session開啟後會將session資料從儲存中讀出暫存到attributes屬性。
  • 驅動器提供給應用操作session資料的方法都是直接操作的attributes屬性裡的資料。

同時也會產生一些疑問,在平時開發時我們並沒有主動的去開啟和儲存session,資料是怎麼載入和持久化的?通過session在使用者的請求間共享資料是需要在客戶端cookie儲存一個session id的,這個cookie又是在哪裡設定的?

上面的兩個問題給出的解決方案是最開始說的第三個服務StartSession中介軟體

StartSession 中介軟體

<?php

namespace IlluminateSessionMiddleware;

use Closure;
use IlluminateHttpRequest;
use IlluminateSupportCarbon;
use IlluminateSessionSessionManager;
use IlluminateContractsSessionSession;
use IlluminateSessionCookieSessionHandler;
use SymfonyComponentHttpFoundationCookie;
use SymfonyComponentHttpFoundationResponse;

class StartSession
{
    /**
     * The session manager.
     *
     * @var IlluminateSessionSessionManager
     */
    protected $manager;

    /**
     * Indicates if the session was handled for the current request.
     *
     * @var bool
     */
    protected $sessionHandled = false;

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

    /**
     * Handle an incoming request.
     *
     * @param  IlluminateHttpRequest  $request
     * @param  Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $this->sessionHandled = true;

        // 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.
        if ($this->sessionConfigured()) {
            $request->setLaravelSession(
                $session = $this->startSession($request)
            );

            $this->collectGarbage($session);
        }

        $response = $next($request);

        // 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.
        if ($this->sessionConfigured()) {
            $this->storeCurrentUrl($request, $session);

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

        return $response;
    }

    /**
     * Perform any final actions for the request lifecycle.
     *
     * @param  IlluminateHttpRequest  $request
     * @param  SymfonyComponentHttpFoundationResponse  $response
     * @return void
     */
    public function terminate($request, $response)
    {
        if ($this->sessionHandled && $this->sessionConfigured() && ! $this->usingCookieSessions()) {
            $this->manager->driver()->save();
        }
    }

    /**
     * Start the session for the given request.
     *
     * @param  IlluminateHttpRequest  $request
     * @return IlluminateContractsSessionSession
     */
    protected function startSession(Request $request)
    {
        return tap($this->getSession($request), function ($session) use ($request) {
            $session->setRequestOnHandler($request);

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

    /**
     * Add the session cookie to the application response.
     *
     * @param  SymfonyComponentHttpFoundationResponse  $response
     * @param  IlluminateContractsSessionSession  $session
     * @return void
     */
    protected function addCookieToResponse(Response $response, Session $session)
    {
        if ($this->usingCookieSessions()) {
            //將session資料儲存到cookie中,cookie名是本條session資料的ID識別符號
            $this->manager->driver()->save();
        }

        if ($this->sessionIsPersistent($config = $this->manager->getSessionConfig())) {
           //將本條session的ID識別符號儲存到cookie中,cookie名是session配置檔案裡設定的cookie名
            $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
            ));
        }
    }


    /**
     * Determine if the configured session driver is persistent.
     *
     * @param  array|null  $config
     * @return bool
     */
    protected function sessionIsPersistent(array $config = null)
    {
        $config = $config ?: $this->manager->getSessionConfig();

        return ! in_array($config[`driver`], [null, `array`]);
    }

    /**
     * Determine if the session is using cookie sessions.
     *
     * @return bool
     */
    protected function usingCookieSessions()
    {
        if ($this->sessionConfigured()) {
            return $this->manager->driver()->getHandler() instanceof CookieSessionHandler;
        }

        return false;
    }
}

同樣的我只保留了最關鍵的程式碼,可以看到中介軟體在請求進來時會先進行session start操作,然後在響應返回給客戶端前將session id 設定到了cookie響應頭裡面, cookie的名稱是由config/session.php裡的cookie配置項設定的,值是本條session的ID識別符號。與此同時如果session驅動器用的是CookieSessionHandler還會將session資料儲存到cookie裡cookie的名字是本條session的ID標示符(呃, 有點繞,其實就是把存在redis裡的那些session資料以ID為cookie名存到cookie裡了, 值是JSON格式化的session資料)。

最後在響應傳送完後,在terminate方法裡會判斷驅動器用的如果不是CookieSessionHandler,那麼就呼叫一次$this->manager->driver()->save();將session資料持久化到儲存中 (我現在還沒有搞清楚為什麼不統一在這裡進行持久化,可能看完Cookie服務的原始碼就清楚了)。

新增自定義驅動

關於新增自定義驅動,官方文件給出了一個例子,MongoHandler必須實現統一的SessionHandlerInterface介面裡的方法:

<?php

namespace AppExtensions;

class MongoHandler 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) {}
}

定義完驅動後在AppServiceProvider裡註冊一下:

<?php

namespace AppProviders;

use AppExtensionsMongoSessionStore;
use IlluminateSupportFacadesSession;
use IlluminateSupportServiceProvider;

class SessionServiceProvider extends ServiceProvider
{
    /**
     * 執行註冊後引導服務。
     *
     * @return void
     */
    public function boot()
    {
        Session::extend(`mongo`, function ($app) {
            // Return implementation of SessionHandlerInterface...
            return new MongoSessionStore;
        });
    }
}

這樣在用SessionManagerdriver方法建立mongo型別的驅動器的時候就會呼叫callCustomCreator方法去建立mongo型別的Session驅動器了。

本文已經收錄在系列文章Laravel原始碼學習裡,歡迎訪問閱讀。

相關文章