人人都能看懂的全栈开发教程——会话和 Cookie

人人都能看懂的全栈开发教程——会话和 Cookie

Chris Yue No Comment
Posts

在单元测试那一章节,我们要求用户仓库类需要提供方法来检查用户是否已经存在,但我们的 UserRepository 类还没有实现它,我们先把这个方法加出来,并且做一些改造:

<?php

namespace Infrastructure;

use Domain\UserRepositoryInterface;
use Pdo;

class UserRepository implements UserRepositoryInterface
{
    private $pdo;

    public function __construct()
    {
        // 因为 pdo 在两个方法里都需要用,为了不重复写创建 pdo 的代码,我们把这个过程挪到了构造函数里
        $this->pdo = require __DIR__.'/../pdo.php';
    }

    public function add(string $username, string $password): void
    {
        $statement = $this->pdo->prepare('insert into user (username, password) values (:username, :password)');
        $statement->execute(compact('username', 'password'));
    }

    // 这是我们新增的检查用户名是否存在的方法
    public function hasUsernameExisted(string $username): bool
    {
        $statement = $this->pdo->prepare('select count(*) from user where username = :username');
        $statement->execute(compact('username'));

        return (bool) $statement->fetchColumn();
    }
}

我们检查用户是否存在用的 SQL 语句是 select count(*) from user where username = :usernamecount 即返回符合条件的条数,如果能找到记录返回 1 即有一条信息,没有就是 0 了。方法最后使用了 (bool) 操作强行将返回的 01 转换成 false 或者 true,来满足我们的方法的返回值类型为布尔型。另外,这也应该是大家第一次正式接触 SQL 语句的 where 语句。在 SQL 中,where 即筛选,后面跟的是各种筛选的条件,其实很直观的,大家都能猜得到,后面我们会接触更多更复杂的 where 语句。

除了代码可以检查用户名重复的问题,MySQL 本身也提供这样的功能,我们需要给 username 字段添加『唯一』键:

alter table user add unique key(username)
# 注意,如果在 user 表里已经存在重复的 username 了,需要先把重复的记录删除
# 我们可以执行 delete from user 即清空表再执行上面的 SQL

MySQL 的唯一键不但可以作为『最后的防线』防止用户名重复,而且 MySQL 的唯一键也是有索引的,现在大家只用知道,索引可以让针对这个字段的查询更快就行。

现在我们开始创建登录界面了,我们的需求是:如果用户在没有登录的情况下,无论是访问首页还是新增任务页都要显示登录页面。为了代码的重用性我们自然应该想到,要把对用户登录的检查,放到一个单独的 PHP 文件里。

我们先创建登录的模板文件 template/login.html.php

<!DOCTYPE html>
<html>
  <head>
    <title>登录</title>
  </head>
  <body>
    <form action="login.php" method="post">
      <input type="text" name="username" placeholder="用户名">
      <input type="text" name="password" placeholder="密码">
      <input type="submit" value="提交">
    </form>
  </body>
</html>

然后我们创建 security/http 目录,用来表示跟网络和用户安全有关的代码。

在这个目录里我们创建 login-check.php 文件,并写下以下内容:

<?php
// 打开会话功能
session_start();
// 如果当前会话有用户 ID,则直接返回
if (!empty($_SESSION['uid'])) {
    return;
}
// 否则如需求所说,显示登录页
include __DIR__.'/../../template/login.html.php';
// 因为显示登录页之后就不能往下执行了,所以要使用 die 停止执行后面的 PHP 脚本
die();

上面的代码提到一个概念叫『会话』,或者说 Session,那会话到底是指什么呢?

直到上一章,我们的网站给所有访问者提供的页面,在不同机器以及不同的浏览器里所能看到和使用的,都是一样的,然而网站服务器也可以给每个单独的浏览器一个独立的空间,用来存一些个性化的数据,这个空间,就叫做『会话』。从会话的特点来看,是非常适合做登录信息保存的。

在 PHP 里,要使用会话功能,需要先运行 session_start 函数开启,或者在 PHP 的配置文件里设置自动开启。这里就不说 PHP 配置文件如何修改了,如果感兴趣大家可自行查询。PHP 打开会话功能之后,就可以使用 $_SESSION 这个『超级变量』了,所谓超级变量,就是一个哪里都能访问的,PHP 自己帮我们创建好的变量。所有的超级变量都是数组,存放了很多有用的信息。不知道大家是否还记得接受用户输入的任务内容时,我们用了一个 $_POST ,它也是一个超级变量。再比如 $_SERVER ,包含了服务器相关、请求相关、运行环境等很多有用的但也很敏感的信息,所以千万要注意,这个信息可不能轻易外泄。说到 $_SERVER,PHP 也提供一个非常有用的函数 phpinfo,它以网页表格的形式,非常直观的给出 PHP 相关的所有信息,大家可以试试。

不同于其他超级变量从一开始就有值,或者从用户的输入取值,$_SESSION 最初是没有值的。而给 $_SESSION 赋值了之后,在一定时间内,这个值就一直存在,并且在请求结束之后都不会消失。

那 PHP 是怎么做到不同的浏览器『绑定』到不同的会话数据上的呢?以及,会话里保存的数据的时间有多长,是靠什么来决定的呢?这又不得不说到 Cookie 了。

Cookie 并不是 PHP 里的概念,它是 HTTP 协议里的一个概念,服务器可以通过在浏览器里用『种』Cookie 的方式,来让每个浏览器在每次访问网站的时候,都『偷偷』得给服务器传递信息。

我们可以做一个实验,在正式使用 login-check.php 文件之前,先打开首页,并且把浏览器的『开发者工具』打开,再到『网络』界面。不同的浏览器打开的方式不一样,我就不说怎么打开了,请自行网上查询各自使用的浏览器如何打开开发者工具的网络监控,总之,当打开浏览器的网络监控功能之后,就能看到每次刷新页面时,浏览器发起的请求详情和服务器响应的详情了,大家一定要先试试看,熟悉一下界面。

我们再回到代码,打开首页控制器文件 web/index.php,引入 login-check.php 文件:

<?php

require '../security/http/login-check.php';
...

这个时候再刷新首页(但别多次刷新,服务器只会在第一次访问时设置 Cookie),首页应该会变成出现登录表单。大家现在把焦点放在开发者工具的『网络』的界面上,在请求记录列表里,点击对首页访问的记录,这个时候会弹出这条访问记录的详细信息。大家再仔细看看『头信息』部分的响应部分,会有一条头信息叫做 Set-Cookie,后面跟着一字符串,大概长这样:

PHPSESSID=ihbkl2haqodbs7qgarib35qk66; path=/

其中 PHPSESSID 是 Cookie 的名称,即 PHP Session ID,而后面的一场串是给当前浏览器分配的唯一 会话ID。ID 最后还有一个 path=/ 意思是此 Cookie 在此网站根目录下——其实也就是整站——都管用。

当 Cookie 被成功发送到到浏览器后,浏览器会保存 Cookie,并且在下一次发送请求时把 Cookie 随请求的头一起发送到服务器。PHP Session 的机制是先生成一个字符串,也就是会话 ID,通过 Set-Cookie 种到浏览器,每个浏览器被分配的字符串都不同。当浏览器带着这个 Cookie 包含的会话 ID 再发送到服务端的时候,PHP 就知道是哪个浏览器发送的了。这就是为什么服务器可以将会话和一个浏览器『绑定』的原理。

接下来在说说会话的时间。在 HTTP 协议里,Cookie 是有『生存时间』的。服务器可以在发送 Set-Cookie 头信息时,告诉浏览器 Cookie 可以『活』多长时间。如果一个 Cookie 过了时长『死』掉了,那么自然服务器也就不知道这个浏览器是谁了。这就是为什么,当你使用浏览器的删除 Cookie 记录的功能时,如果你选择了『全部删除』,会发现大量你曾经登录过的网站都需要你重新登录,因为大部分的网站的登录,都是这么做的。

所以,会话能保存多长时间,跟 Cookie 保存多长时间有关系,但不仅仅只跟 Cookie 有关系。

大家可以想象 PHP 有一个自带的小型数据库,用于存放用户的会话。而这个小型的数据库,也可以定义数据保存的时长。如果 Cookie 生命周期为 1 天,而 PHP 的这个数据库存放数据的有效期只有 1 个小时,那么会话也就一个小时,也可以这么说,PHP 会话的生命周期,是 Cookie 和会话数据生命周期相对更短那个。不过要注意的是,会话数据的生命是可以靠『再次获取数据』这个行为去『续命』的,但 Cookie 不能续,所以如果 Cookie 的生命是 1 天,会话数据生命只有 1 个小时,但如果在一个小时里,有请求去『激活』会话,那么会话的生命会重新续 1 个小时。

说完了 Cookie 和 Session 的概念,我们把剩下用来实现用户登录的代码写出来,首先用户有登录的需求,理所当然 Domain\UserManager 应当有一个 login 方法,参数为 $username$password,也就是用户输入的用户名和密码。而返回的则是数据库里用户所有的信息,我们打算把登录用户的 ID 保存到会话里,表示用户已经登录。当然也有可能因用户名找不到,或者密码错误登录失败,这两种情况,打算分别抛出新的 UserNotFoundExceptionBadCredentialException

既然我们要先通过用户名查找对应的用户,那 Domain\UserRepositoryInterface 接口必然需要一个查找用户的方法,我们把它定义为 findOneByUsername,参数为用户名,返回数据库对应的用户数据,如果没找到就返回 null,PHP 里的 null 和 MySQL 里的 null,以及 JavaScript 里的 null 都一个意思,即『什么都没有』。

假如我们找到了对应的用户记录,我们还需要判断用户的密码和数据库里的密码是否匹配。将数据库返回的加密密码和用户传过来的明文密码做比较的工作,还是由 Domain\PasswordEncoderInterface 来负责的好,所以我们在它上面再定义一个方法 verify,并有两个参数,一个用来接受数据库返回的加密密码,一个用来接受用户输入的明文密码。

到此整个代码写出来长什么样子,我们已经比较清楚了。注意我们在计划代码如何写的时候,都只聊接口或者方法。如果一个项目比较大,可能会有若干个人专门设计类和接口,这个职业就是架构师。

接下来我们按照我们设计好的接口,来实现我们的代码。首先我们先把两个异常类写出来。因为比较简单,例子就写在一起了,至于文件放什么地方,大家可以自己推测一下,不清楚可以参考文章最后的链接里的代码:

<?php

namespace Domain;

class BadCredentialException extends \Exception
{
}
<?php

namespace Domain;

class UserNotFoundException extends \Exception
{
}

接下来是 Domain\UserManager 的登录方法:

<?php

...

class UserManager
{
    ...
    public function login(string $username, string $password): ?array
    {
        $user = $this->repository->findOneByUsername($username);
        if (null === $user) {
            throw new UserNotFoundException(sprintf('找不到用户 %s', $username));
        }

        if ($this->passwordEncoder->verify($password, $user['password'])) {
            return $user;
        }

        throw new BadCredentialException('账户密码不匹配');
    }
}

以及它所需要的两个接口里的方法的定义:

<?php

namespace Domain;

interface UserRepositoryInterface
{
    ...
    public function findOneByUsername(string $username): ?array;
}
<?php

namespace Domain;

interface PasswordEncoderInterface
{
    ...
    public function verify(string $password, string $hash): bool;
}

当然,不能忘记更新测试文件 tests\Domain\UserManager.php

<?php

...
use Domain\UserNotFoundException;
use Domain\BadCredentialException;

class UserManagerTest extends TestCase
{
    public function testLogin()
    {
        $repository = $this->prophesize(UserRepositoryInterface::class);
        $repository->findOneByUsername('chris')->willReturn([
            'username' => 'chris',
            'password' => '654321',
        ]);

        $encoder = $this->prophesize(PasswordEncoderInterface::class);
        $encoder->verify('123456', '654321')->willReturn(true)->shouldBeCalledOnce();

        $userManager = new UserManager(
            $repository->reveal(),
            $encoder->reveal()
        );

        $userManager->login('chris', '123456');
    }

    public function testLoginUserNotFound()
    {
        $repository = $this->prophesize(UserRepositoryInterface::class);
        $repository->findOneByUsername('chris')->willReturn(null);

        $encoder = $this->prophesize(PasswordEncoderInterface::class);
        $encoder->verify()->shouldNotBeCalled();

        $userManager2 = new UserManager(
            $repository->reveal(),
            $encoder->reveal()
        );

        $this->expectException(UserNotFoundException::class);
        $userManager2->login('chris', '123456');
    }

    public function testLoginBadCredential()
    {
        $repository = $this->prophesize(UserRepositoryInterface::class);
        $repository->findOneByUsername('chris')->willReturn([
            'username' => 'chris',
            'password' => '654321',
        ]);

        $encoder = $this->prophesize(PasswordEncoderInterface::class);
        $encoder->verify('123456', '654321')->willReturn(false)->shouldBeCalledOnce();

        $userManager = new UserManager(
            $repository->reveal(),
            $encoder->reveal()
        );

        $this->expectException(BadCredentialException::class);
        $userManager->login('chris', '123456');
    }
}

这时再运行我们的测试命令,会看见测试变成了 5 个,并且有 10 个检查。

确认业务代码和接口都没问题了,我们再来实现两个接口的方法的具体代码:

<?php

...

class DefaultPasswordEncoder implements PasswordEncoderInterface
{
    ...
    public function verify(string $password, string $hash): bool
    {
        return password_verify($password, $hash);
    }
}
<?php

...

class UserRepository implements UserRepositoryInterface
{
    ...
    public function findOneByUsername(string $username): ?array
    {
        $statement = $this->pdo->prepare('select * from user where username = :username');
        $statement->execute(compact('username'));

        $user = $statement->fetch(Pdo::FETCH_ASSOC);
        if (!is_array($user)) {
            return null;
        }

        return $user;
    }
}

最后是我们处理登录的控制器 web/login.php

<?php

use Domain\UserManager;
use Infrastructure\UserRepository;
use Infrastructure\DefaultPasswordEncoder;

// 依然要记得开启会话功能
session_start();
// 先检查用户输入,如果用户输入有问题,就没有必要再引入自动加载文件和创建 UserManager 对象了,节省资源
if (empty($_POST['username']) || empty($_POST['password'])) {
    die('用户名和密码不能为空');
}

require __DIR__.'/../vendor/autoload.php';

$userManager = new UserManager(new UserRepository(), new DefaultPasswordEncoder());

$user = $userManager->login($_POST['username'], $_POST['password']);
$_SESSION['uid'] = $user['id'];

header('Location: /'); // 登录成功后重定向到首页

到此你就可以使用页面的登录功能了,只不过我们需要修改以前增加任务以及列出任务的代码,实现当前新增和列出的任务都只能是与当前用户绑定的。因为这个改动只跟数据有关,所以我们应该能立马反应过来,我们只用改 model 目录里的代码即可(当然,按理说我们也应该把 model 里的代码也拆分到 DomainInfrastructure 里,但目前就先不再分了)。

<?php
// 此为 model/add-task.php
$pdo = require __DIR__.'/../pdo.php';

$statement = $pdo->prepare('insert into task (user_id, content, created_at) values (:uid, :content, :date)');
$statement->execute(['uid' => $_SESSION['uid'], 'content' => $content, 'date' => date('Y-m-d H:i:s')]);
<?php
// 此为 model/tasks.php
$pdo = require __DIR__.'/../pdo.php';

$statement = $pdo->prepare('select * from task where user_id = :uid');
$statement->execute(['uid' => $_SESSION['uid']]);

return $statement->fetchAll();

大家可以多创建几个用户,每个用户都创建和查看任务试试,看看是不是每个用户的任务都隔离开了。

关于 Cookie 和 Session 的话题,后面还会遇到。本篇最后先告诉大家,Cookie 的生命周期由 PHP 的配置 session.cookie_lifetime 控制,单位为秒,默认情况是 0,不是说生命只有 0 秒,而是说 Cookie 跟随浏览器打开的时间,只有当浏览器关闭了,才会消失;而服务端会话数据的生命周期由 session.gc_maxlifetime 控制,单位也是秒,默认 1440 也就是 24 分钟。

本章节完整代码见这里

人人都能看懂的全栈开发教程——会话和 Cookie by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

微信赞赏码

如果觉得文章还不错,就请扫码鼓励一下作者吧
天使打赏人

发表评论

2 + 8 =