在单元测试那一章节,我们要求用户仓库类需要提供方法来检查用户是否已经存在,但我们的 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 = :username
,count
即返回符合条件的条数,如果能找到记录返回 1
即有一条信息,没有就是 0
了。方法最后使用了 (bool)
操作强行将返回的 0
或 1
转换成 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 保存到会话里,表示用户已经登录。当然也有可能因用户名找不到,或者密码错误登录失败,这两种情况,打算分别抛出新的 UserNotFoundException
和 BadCredentialException
。
既然我们要先通过用户名查找对应的用户,那 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
里的代码也拆分到 Domain
和 Infrastructure
里,但目前就先不再分了)。
<?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.

写作累,服务器还越来越贵
求分担,祝愿好人一生平安
天使打赏人