20. 应用练习 – 版本 10
上一版演示表明,所有应用用户共用的税务数据应存储在 [Application] 作用域内存中。我们将使用 Redis 服务器 [https://redis.io] 来实现这一功能。
20.1. Redis
作用域为 [Application] 的内存将由一台 Redis 服务器实现。需要此应用内存的脚本 PHP 将作为该服务器的客户端:

20.2. Redis的安装
Laragon 默认未启用 Redis 服务器。因此,首先需要将其启用:

- 在 [3] 中,启用服务器 [Redis];
- 在 [4] 中,保留 [6379] 端口,这是 Redis 客户端默认使用的端口;
启用 Redis 后,Laragon 服务将自动重启:

20.3. 命令行模式下的 Redis 客户端
可以通过命令模式查询 Redis 服务器。打开一个 Laragon 终端(参见链接部分):

- 在 [1] 中,命令 [redis-cli] 会启动 Redis 服务器的命令行客户端;
截至2019年7月,Redis客户端可通过172条命令与服务器进行交互。 其中一个命令 [command count] [2],会显示这个数字 [3]。
我们仅介绍在PHP应用程序中需要用到的命令。我们将Redis用于唯一一个目的:在Redis内存中存储一个数组[‘attribut’=>’valeur’]。 这可以通过 Redis 命令 [set attribut valeur] [4] 实现。随后可通过命令 [get attribut] [5] 检索该值。这就是我们所需的一切。
可能需要清空Redis的内存。这可以通过命令 [flushdb] [6] 来实现。 随后若查询属性 [titre] [7] 的值,将返回引用 [nil] [8],表明未找到该属性。 也可以使用命令 [exists] [9-10] 来验证属性的存在性。
要退出 Redis 客户端,请输入命令 [quit] [11]。
20.4. 为 PHP 安装 Redis 客户端
现在我们需要为 PHP 安装 Redis 客户端:

有多种实现 Redis 客户端的库。我们将使用 [Predis] [https://github.com/nrk/predis](2019 年 7 月)库。该库与之前的库一样,可通过 [composer] 在 Laragon 终端中安装:

20.5. 服务器代码

配置文件 [config-server.json] 的变更如下:
{
"rootDirectory": "C:/myprograms/laragon-lite/www/php7/scripts-web/impots/version-10",
"databaseFilename": "Data/database.json",
"relativeDependencies": [
"/../version-08/Entities/BaseEntity.php",
"/../version-08/Entities/ExceptionImpots.php",
"/../version-08/Entities/TaxAdminData.php",
"/../version-08/Entities/Database.php",
"/../version-08/Dao/InterfaceServerDao.php",
"/../version-08/Dao/ServerDao.php",
"/../version-09/Dao/ServerDaoWithSession.php",
"/../version-08/Métier/InterfaceServerMetier.php",
"/../version-08/Métier/ServerMetier.php",
"/../version-09/Utilities/Logger.php",
"/../version-09/Utilities/SendAdminMail.php"
],
"absoluteDependencies": [
"C:/myprograms/laragon-lite/www/vendor/autoload.php",
"C:/myprograms/laragon-lite/www/vendor/predis/predis/autoload.php"
],
"users": [
{
"login": "admin",
"passwd": "admin"
}
],
"adminMail": {
"smtp-server": "localhost",
"smtp-port": "25",
"from": "guest@localhost",
"to": "guest@localhost",
"subject": "plantage du serveur de calcul d'impôts",
"tls": "FALSE",
"attachments": []
},
"logsFilename": "Data/logs.txt"
}
注释
- 第 5-15 行:除了脚本 [impots-server.php] 之外,版本 10 没有带来任何新内容。它使用了版本 08 和 09 中的元素;
- 第 19 行:对刚刚安装的 [predis] 库的必要依赖;
[impots-server.php] 服务器代码的演变如下:
<?php
// 严格遵守函数参数的声明类型
declare (strict_types=1);
// 命名空间
namespace Application;
// PHP 的错误处理
ini_set("display_errors", "0");
//
// 配置文件路径
define("CONFIG_FILENAME", "Data/config-server.json");
// 类别名
use \Application\ServerDaoWithSession as ServerDaoWithRedis;
// 会话
$session = new Session();
$session->start();
…
…
// 第一个日志
$logger->write("\n---nouvelle requête\n");
// 获取当前请求
$request = Request::createFromGlobals();
// 仅首次认证
if (!$session->has("user")) {
…
} else {
// 日志
$logger->write("Authentification prise en session…\n");
}
// 用户有效 - 验证接收到的参数
$erreurs = [];
// 必须有三个参数 GET
$method = strtolower($request->getMethod());
…
// 有错误吗?
if ($erreurs) {
// 向客户端发送 400 错误代码 HTTP_BAD_REQUEST
sendResponse($response, ["erreurs" => $erreurs], Response::HTTP_BAD_REQUEST, [], $logger);
// 完成
exit;
} else {
// 日志
$logger->write("paramètres ['marié'=>$marié, 'enfants'=>$enfants, 'salaire'=>$salaire] valides\n");
}
// 已具备所有必要条件
// Redis
\Predis\Autoloader::register();
try {
// 客户端 [predis]
$redis = new \Predis\Client();
// 连接服务器以检查其是否在线
$redis->connect();
} catch (\Predis\Connection\ConnectionException $ex) {
// 内部服务器错误
doInternalServerError("[redis], " . utf8_encode($ex->getMessage()), $response, $config['adminMail'], $logger);
// 完成
exit;
}
// 创建图层 [dao]
if (!$redis->get("taxAdminData")) {
// 从数据库中提取税务数据
$logger->write("données fiscales prises en base de données\n");
try {
// 构建图层 [dao]
$dao = new ServerDaoWithRedis($config["databaseFilename"], NULL);
// 将税务数据放入作用域内存 [application]
// 将隐式调用方法 [TaxAdminData]->__toString
$redis->set("taxAdminData", $dao->getTaxAdminData());
} catch (\RuntimeException $ex) {
// 记录错误
doInternalServerError("[dao], " . utf8_encode($ex->getMessage()), $response, $config['adminMail'], $logger, $redis);
// 完成
exit;
}
} else {
// 从作用域内存中获取税务数据 [application]
$arrayOfAttributes = \json_decode($redis->get("taxAdminData"), true);
$taxAdminData = (new TaxAdminData())->setFromArrayOfAttributes($arrayOfAttributes);
// 实例化层 [dao]
$dao = new ServerDaoWithRedis(NULL, $taxAdminData);
// 日志
$logger->write("données fiscales prises dans redis\n");
}
// 创建层 [métier]
$métier = new ServerMetier($dao);
// 计算税款
$result = $métier->calculerImpot($marié, (int) $enfants, (int) $salaire);
// 返回结果
sendResponse($response, $result, Response::HTTP_OK, [], $logger, $redis);
// 结束
exit;
function doInternalServerError(string $message, Response $response, array $infos,
Logger $logger = NULL, \Predis\Client $predisClient = NULL) {
// $message:错误信息
// $response:响应 HTTP
// $infos:发送邮件的信息表
// $result:结果表
// $logger:应用程序日志记录器
// $predisClient:一个客户端 [predis]
//
// 向管理员发送一封邮件
// SendAdminMail 拦截所有异常并自行记录
$infos['message'] = $message;
$sendAdminMail = new SendAdminMail($infos, $logger);
$sendAdminMail->send();
// 向客户端发送 500 错误代码
sendResponse($response, ["erreur" => $message], Response::HTTP_INTERNAL_SERVER_ERROR, [], $logger, $predisClient);
}
// 向客户端发送响应 HTTP 的函数
function sendResponse(Response $response, array $result, int $statusCode,
array $headers, Logger $logger = NULL, \Predis\Client $predisClient = NULL) {
// $response:响应 HTTP
// $result:结果表
// $statusCode:响应状态 HTTP
// $headers:应放入响应中的 HTTP 头部
// $logger:应用程序日志记录器
// $predisClient:一个客户端 [predis]
//
// 状态 HTTTP
$response->setStatusCode($statusCode);
// 正文
$body = \json_encode(["réponse" => $result], JSON_UNESCAPED_UNICODE);
$response->setContent($body);
// 头部
$response->headers->add($headers);
// 发送
$response->send();
// 日志
if ($logger != NULL) {
$logger->write("$body\n");
$logger->close();
}
// 关闭连接[redis]
if ($predisClient != NULL) {
$predisClient->disconnect();
}
}
注释
- 第 15 行:为类 [\Application\ServerDaoWithSession] 赋予别名 [ServerDaoWithRedis],以反映服务器脚本实现的变更;
- 第 18-19 行:会话被保留。这里有两点信息需要记住:
- 用户已成功认证。该信息属于 [session] 作用域:它与特定用户相关联,对其他用户无效;
- 税务管理数据。该信息作用域为 [application]:它不与特定用户相关联,但对所有用户均有效;
- 第54-64行:创建客户端[redis],该客户端将与服务器[redis]进行通信。 该客户端将通过服务器的默认端口进行通信。如果服务器未使用默认端口,或者未位于 [localhost] 机器上,则需将这些信息传递给 [\Predis\Client] 类的构造函数;
- 第 59 行:立即将客户端连接到服务器以检测其响应;
- 第 60-65 行:若连接 Redis 服务器失败,则向客户端发送错误响应,并向应用程序管理员发送一封邮件;
- 第67行:向[redis]服务器查询键值[taxAdminData]。若未找到,则从数据库中获取税务数据(第72行);
- 第75行: 键 [taxAdminData] 被放置在与变量 [$taxAdminData] 中的字符串 jSON 关联的内存 [redis] 中,该变量是一个类型为 QZXW2HTMLP005326Z 的对象QX。方法 [$redis→set] 期望密钥的值为字符串。因此,它将尝试将类型为 [TaxAdminData] 的对象转换为类型 [string]。 此时将隐式调用方法 [TaxAdminData->__toString]。该方法会生成对象 [TaxAdminData] 的字符串 jSON;
- 第 84 行:键 [taxAdminData] 位于内存中的 [redis] 位置,因此我们获取其值。我们知道这是对象 [TaxAdminData] 的字符串 jSON。 随后对该字符串进行解码,以获取一个属性数组;
- 第 85 行:基于该数组,实例化了一个新的 [TaxAdminData] 对象;
- 第87行:实例化[dao]层;
20.6. 客户端代码

客户端的第 10 版与第 9 版完全相同。唯一的变化是配置文件 [config-client.json]:
{
"rootDirectory": "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-10",
"taxPayersDataFileName": "Data/taxpayersdata.json",
"resultsFileName": "Data/results.json",
"errorsFileName": "Data/errors.json",
"dependencies": [
"/../version-08/Entities/BaseEntity.php",
"/../version-08/Entities/TaxPayerData.php",
"/../version-08/Entities/ExceptionImpots.php",
"/../version-08/Utilities/Utilitaires.php",
"/../version-08/Dao/InterfaceClientDao.php",
"/../version-08/Dao/TraitDao.php",
"/../version-09/Dao/ClientDao.php",
"/../version-08/Métier/InterfaceClientMetier.php",
"/../version-08/Métier/ClientMetier.php"
],
"absoluteDependencies": [
"C:/myprograms/laragon-lite/www/vendor/autoload.php"
],
"user": {
"login": "admin",
"passwd": "admin"
},
"urlServer": "https://localhost:443/php7/scripts-web/impots/version-10/impots-server.php"
}
唯一的变化是第24行,服务器端的URL。
结果与第09版相同。我们来测试一个新的错误情况:

控制台显示的结果如下:
L'erreur suivante s'est produite : {"statut HTTP":500,"erreur":"[redis], Aucune connexion n’a pu être établie car l’ordinateur cible l’a expressément refusée. [tcp:\/\/127.0.0.1:6379]"}
Terminé
20.7. 客户端测试 [Codeception]

版本 10 的测试类 [ClientMetierTest] 与版本 09 的测试类完全相同,只有一个例外:
<?php
// 严格遵守函数参数的声明类型
declare (strict_types=1);
// 命名空间
namespace Application;
// 常量定义
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-10");
…
}
- 第 10 行:测试环境为 10 版客户端的环境;
在开始测试之前,请使用客户端 [redis-cli] 从服务器 [redis] 的内存中删除键 [taxAdminData]:

现在,执行测试:

现在查看服务器的日志 [logs.txt]:
05/07/19 08:52:16:396 :
---nouvelle requête
05/07/19 08:52:16:403 : Autentification en cours…
05/07/19 08:52:16:403 : Authentification réussie [admin, admin]
05/07/19 08:52:16:403 : paramètres ['marié'=>oui, 'enfants'=>2, 'salaire'=>55555] valides
05/07/19 08:52:16:407 : données fiscales prises en base de données
05/07/19 08:52:16:420 : {"réponse":{"impôt":2814,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14}}
05/07/19 08:52:16:546 :
---nouvelle requête
05/07/19 08:52:16:555 : Autentification en cours…
05/07/19 08:52:16:555 : Authentification réussie [admin, admin]
05/07/19 08:52:16:556 : paramètres ['marié'=>oui, 'enfants'=>2, 'salaire'=>50000] valides
05/07/19 08:52:16:559 : données fiscales prises dans redis
05/07/19 08:52:16:559 : {"réponse":{"impôt":1384,"surcôte":0,"décôte":384,"réduction":347,"taux":0.14}}
05/07/19 08:52:16:668 :
---nouvelle requête
05/07/19 08:52:16:675 : Autentification en cours…
05/07/19 08:52:16:675 : Authentification réussie [admin, admin]
05/07/19 08:52:16:675 : paramètres ['marié'=>oui, 'enfants'=>3, 'salaire'=>50000] valides
05/07/19 08:52:16:678 : données fiscales prises dans redis
05/07/19 08:52:16:678 : {"réponse":{"impôt":0,"surcôte":0,"décôte":720,"réduction":0,"taux":0.14}}
05/07/19 08:52:16:776 :
---nouvelle requête
…
我们之前提到,每次测试时都会重新执行测试类的构造函数,这意味着被测试的 [ClientDao] 类在每次测试中都会使用一个不存在的会话 Cookie 进行实例化。因此,这11个测试就好像代表了11个不同的用户,拥有11个不同的会话。
- 第6行:税务数据从数据库中获取;
- 第13、20行:税务数据从内存[redis]中获取。因此,这里确实存在一个由应用程序所有用户共享的内存[application];
20.8. [Redis]服务器的Web界面
我们已经看到,[Redis]服务器可以通过命令行模式进行管理。它也可以通过Web界面进行管理:

- 在 [4] 中,即管理服务器 URL;
- [5],显示服务器存储的密钥;
- [6],服务器当前状态;
点击 [5],即可获取关于密钥 [taxAdminData] 的信息:

- 在 [7] 中,URL 可访问密钥 [taxAdminData] 和 [8] 的信息;
- 在 [9] 中,密钥的状态;
- 在 [10] 中,其值:可识别出 jSON 字符串,该字符串属于 [TaxAdminData] 类型的对象;
- 在 [11] 中,可以删除该键;
- 在 [12] 中,可以添加另一个;