Skip to content

19. 应用练习 – 第 9 版

在此版本中,我们将按以下方式改进服务器:

  • 目前,每次请求都会从数据库中检索税务管理数据。我们将使用会话机制:
    • 当用户首次发起请求时,从数据库中检索税务管理数据并存入会话;
    • 当同一用户后续请求时,将从会话中检索税务管理数据。由于数据库查询成本较高,预计可略微缩短执行时间;
  • 服务器将把重要时刻记录到文本文件中:
    • 身份验证成功或失败;
    • 客户端发送的参数是否有效;
    • 税款计算结果;
    • 各种错误情况;
  • 若发生致命错误,将向应用程序管理员发送一封邮件;

客户端也需进行修改,以处理我们将发送给它的会话cookie。

19.1. 服务器

我们关注的是应用程序的服务器端。

Image

该架构将通过以下脚本实现:

Image

19.1.1. 实用工具

Image

19.1.1.1. [Logger]

[Logger] 将用于将日志写入文本文件:


<?php

namespace Application;

class Logger {
  // 属性
  private $resource;

  // 构造函数
  public function __construct(string $logsFilename) {
    // 打开文件
    $this->resource = fopen($logsFilename, "a");
    if (!$this->resource) {
      throw new ExceptionImpots("Echec lors de la création du fichier de logs [$logsFilename]");
    }
  }

  // 在日志中写入消息
  public function write(string $message) {
    fputs($this->resource, (new \DateTime())->format("d/m/y H:i:s:v") . " : $message");
  }

  // 关闭日志文件
  public function close() {
    fclose($this->resource);
  }

}

注释

  • 第 7 行:日志文件的资源;
  • 第 10 行:类构造函数接收日志文件名作为参数;
  • 第 12 行:以追加模式 (a+) 打开文本文件:文件将被打开,其内容得以保留。写入操作将在当前内容之后进行;
  • 第13-15行:若无法打开文件,则抛出异常;
  • 第19-21行:方法[write]用于将消息[$message]写入日志文件,并在消息前添加日期和时间;
  • 第24-16行:方法 [close] 用于关闭日志文件;

:服务器应用程序可能同时为多个客户端提供服务。但所有客户端共用一个日志文件。因此,存在对该文件进行写入操作时的并发访问风险。因此,必须对写入操作进行同步,以避免数据混淆。 为此,PHP 提供了 [https://www.php.net/manual/fr/book.sem.php] 信号量。本文中我们将忽略写入操作的同步问题,但需时刻注意这一潜在风险。

19.1.1.2. [SendAdminMail] 类

[SendAdminMail] 类可在应用程序崩溃时向管理员发送邮件:


<?php

namespace Application;

class SendAdminMail {
  // 属性
  private $config;
  private $logger;

  // 构造函数
  public function __construct(array $config, Logger $logger = NULL) {
    $this->config = $config;
    $this->logger = $logger;
  }

  public function send() {
    // 将 $this->config['message'] 发送至 SMTP 服务器 $this->config['smtp-server'] 至端口 $infos[smt-port]
    // 如果 $this->config['tls'] 为真,则将使用 TLS 支持
    // 邮件由 $this->config['from'] 发送
    // 收件人为 $this->config['to']
    // 该邮件的主题为 $this->config['subject']
    // 将 $this->config['attachments'] 的附件附加到邮件中
    // 该方法的结果
    try {
      // 创建邮件
      $message = (new \Swift_Message())
        // 邮件主题
        ->setSubject($this->config["subject"])
        // 发件人
        ->setFrom($this->config["from"])
        // 使用字典的收件人(setTo/setCc/setBcc)
        ->setTo($this->config["to"])
        // 邮件正文
        ->setBody($this->config["message"])
      ;
      // 附件
      foreach ($this->config["attachments"] as $attachment) {
        // 附件路径
        $fileName = __DIR__ . $attachment;
        // 验证文件是否存在
        if (file_exists($fileName)) {
          // 将文档作为附件添加到消息中
          $message->attach(\Swift_Attachment::fromPath($fileName));
        } else {
          if ($this->logger !== NULL) {
            // 错误
            $this->logger->write("L'attachement [$fileName] n'existe pas\n");
          }
        }
      }
      // 协议 TLS ?
      if ($this->config["tls"] === "TRUE") {
        // TLS
        $transport = (new \Swift_SmtpTransport($this->config["smtp-server"], $this->config["smtp-port"], 'tls'))
          ->setUsername($this->config["user"])
          ->setPassword($this->config["password"]);
      } else {
        // 没有 TLS
        $transport = (new \Swift_SmtpTransport($this->config["smtp-server"], $this->config["smtp-port"]));
      }
      // 发送管理器
      $mailer = new \Swift_Mailer($transport);
      // 发送消息
      $mailer->send($message);
      // 结束
      if ($this->logger !== NULL) {
        $this->logger->write("Message [{$this->config["message"]}] envoyé à {$this->config["to"]}\n");
      }
    } catch (\Throwable $ex) {
      // 错误
      if ($this->logger !== NULL) {
        $this->logger->write("Erreur lors de l'envoi du message [{$this->config["message"]}] à {$this->config["to"]}\n");
      }
    }
  }

}

注释

  • 第 11 行:构造函数接收两个参数:
    • [$config]:一个关联数组,包含发送邮件所需的所有信息;
    • [$logger]:一个日志器,用于记录邮件发送过程中的重要时刻;

该关联数组将采用以下形式:

1
2
3
4
5
6
7
8
9
{
        "smtp-server": "localhost",
        "smtp-port": "25",
        "from": "guest@localhost",
        "to": "guest@localhost",
        "subject": "plantage du serveur de calcul d'impôts",
        "tls": "FALSE",
        "attachments": []
}
  • 第 16-76 行:方法 [send] 用于发送邮件。该代码已在链接段落中介绍并描述;

19.1.2. [dao]

Image

[ServeurDaoWithSession.php]脚本如下:


<?php

// 命名空间
namespace Application;

// 类定义ImpotsWithDataInDatabase
class ServerDaoWithSession extends ServerDao {

  // 构造函数
  public function __construct(string $databaseFilename = NULL, TaxAdminData $taxAdminData = NULL) {
    // 最简单的情况
    if ($taxAdminData !== NULL) {
      $this->taxAdminData = $taxAdminData;
    } else {
      // 将控制权交由父类
      parent::__construct($databaseFilename);
    }
  }

}

注释

  • 第 7 行:09 版的 [ServerDaoWithSession] 类扩展了 08 版的 [ServerDao] 类。实际上,[ServerDao] 类已具备使用数据库的能力。 现在我们只需处理税务管理数据已获取的情况:
  • 第 10 行:构造函数现在接收两个参数:
    • [string $databaseFilename]:若税务部门数据尚未获取,则为包含数据库连接信息的文件名;否则为 NULL;
    • [TaxAdminData $taxAdminData]:若税务部门数据已获取,则为该数据;否则为 NULL;

在启动Web会话时, 将使用一个非 NULL 类型的 [$databaseFilename] 对象和一个 [taxAdminData] 类型的 NULL 对象来构建 [dao] 层。 随后将从数据库中检索税务管理数据并存储在会话中。在同一会话的后续请求中, [dao]层将使用来自该会话的[databaseFilename]、NULL和[taxAdminData]对象构建,而非NULL。 因此不会进行数据库查询。

19.1.3. 服务器脚本

服务器脚本 [impots-server.php] 由以下文件 jSON [config-server.json] 配置:


{
    "rootDirectory": "C:/myprograms/laragon-lite/www/php7/scripts-web/impots/version-09",
    "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",
        "/Dao/ServerDaoWithSession.php",
        "/../version-08/Métier/InterfaceServerMetier.php",
        "/../version-08/Métier/ServerMetier.php",
        "/Utilities/Logger.php",
        "/Utilities/SendAdminMail.php"
    ],
    "absoluteDependencies": ["C:/myprograms/laragon-lite/www/vendor/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"
}

服务器脚本 [impots-server.php] 的演变如下:


<?php

// 严格遵守函数参数的声明类型
declare (strict_types=1);

// 命名空间
namespace Application;

// 通过 PHP 进行错误处理
ini_set("display_errors", "0");
//
// 配置文件路径
define("CONFIG_FILENAME", "Data/config-server.json");

// 获取配置
$config = \json_decode(file_get_contents(CONFIG_FILENAME), true);

// 引入脚本所需的依赖项
$rootDirectory = $config["rootDirectory"];
foreach ($config["relativeDependencies"] as $dependency) {
  require "$rootDirectory$dependency";
}
// 绝对依赖项(第三方库)
foreach ($config["absoluteDependencies"] as $dependency) {
  require "$dependency";
}
//
// Symfony 依赖项
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;

// 会话
$session = new Session();
$session->start();

// 准备服务器的响应 JSON
$response = new Response();
$response->headers->set("content-type", "application/json");
$response->setCharset("utf-8");

// 日志文件的创建
try {
  $logger = new Logger($config['logsFilename']);
} catch (ExceptionImpots $ex) {
  // 内部服务器错误
  doInternalServerError($ex->getMessage(), $response, NULL, $config['adminMail']);
  // 完成
  exit;
}

// 第一个日志
$logger->write("\n---nouvelle requête\n");

// 获取当前请求
$request = Request::createFromGlobals();
// 仅首次进行身份验证
if (!$session->has("user")) {
  // 日志
  $logger->write("Autentification en cours…\n");
  // 身份验证

  }
  // 是否找到用户?
  if (!$trouvé) {
    // 未找到 - 代码 401 HTTP_UNAUTHORIZED
    sendResponse(
      $response,
      ["erreur" => "Echec de l'authentification [$requestUser, $requestPassword]"],
      Response::HTTP_UNAUTHORIZED,
      ["WWW-Authenticate" => "Basic realm=" . utf8_decode("\"Serveur de calcul d'impôts\"")],
      $logger
    );
    // 完成
    exit;
  } else {
    // 在会话中记录已验证用户
    $session->set("user", TRUE);
    // 日志
    $logger->write("Authentification réussie [$requestUser, $requestPassword]\n");
  }
} else {
  // 日志
  $logger->write("Authentification prise en session…\n");
}
// 用户有效 - 验证接收到的参数
$erreurs = [];
// 应有三个参数 GET


// 有错误吗?
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");
}
// 已具备所有必要的工作条件
// 创建图层 [dao]
if (!$session->has("taxAdminData")) {
  // 数据从数据库中提取
  $logger->write("données fiscales prises en base de données\n");
  try {
    // 构建图层 [dao]
    $dao = new ServerDaoWithSession($config["databaseFilename"], NULL);
    // 将数据放入会话
    $session->set("taxAdminData", $dao->getTaxAdminData());
  } catch (\RuntimeException $ex) {
    // 记录错误
    doInternalServerError(utf8_encode($ex->getMessage()), $response, $logger, $config['adminMail']);
    // 完成
    exit;
  }
} else {
  // 从会话中获取数据
  $dao = new ServerDaoWithSession(NULL, $session->get("taxAdminData"));
  // 日志
  $logger->write("données fiscales prises en session\n");
}
// 创建图层 [métier]
$métier = new ServerMetier($dao);
// 计算税款
$result = $métier->calculerImpot($marié, (int) $enfants, (int) $salaire);
// 返回结果
sendResponse($response, $result, Response::HTTP_OK, [], $logger);
// 结束
exit;

function doInternalServerError(string $message, Response $response, Logger $logger = NULL, array $infos) {
  // 向管理员发送邮件
  // SendAdminMail 拦截所有异常并自行记录日志
  $infos['message'] = $message;
  $sendAdminMail = new SendAdminMail($infos, $logger);
  $sendAdminMail->send();
  // 向客户端发送 500 错误代码
  sendResponse($response, ["erreur" => $message], Response::HTTP_INTERNAL_SERVER_ERROR, [], $logger);
}

// 向客户端发送响应 HTTP 的函数
function sendResponse(Response $response, array $result, int $statusCode, array $headers, Logger $logger) {
  // $response:响应 HTTP
  // $result:结果表
  // $statusCode:响应状态 HTTP
  // $headers:应放入响应中的 HTTP 头部
  // $logger:应用程序日志记录器
  //
  // 状态 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();
  }
}

注释

  • 第 34-35 行:启动一个会话;
  • 第38-40行:准备jSON响应;
  • 第 42-50 行:尝试创建日志文件。若发生异常,则调用方法 [doInternalServer](第 132-140 行);
  • 第 132 行:方法 [doInternalServer] 接受四个参数:
    • [$message]:待记录的消息。必须采用 UTF-8 编码;
    • [$response]:封装服务器对客户端响应的 [Response] 对象;
    • [$logger]:用于生成日志的 [Logger] 对象;
    • [$infos]:用于向应用程序管理员发送邮件的信息;
  • 第 135-137 行:向应用程序管理员发送一封电子邮件;
  • 第139行:向客户端发送响应:
    • $response:响应 HTTP;
    • $result:服务器发送数组 [‘réponse’=>["erreur" => $message]] 中的字符串 jSON;
    • $statusCode:[Response::HTTP_INTERNAL_SERVER_ERROR],状态码 500;
    • $headers:[],响应中无需添加 HTTP 头部;
    • $logger:应用程序日志记录器;
  • 第 58 行:借助已建立的会话,客户端认证仅需执行一次:
    • 客户端通过身份验证后,将在会话中设置一个密钥 [user](第 78 行);
    • 当同一客户端发起下一次请求时,第58行可避免重复进行已无必要的身份验证;
  • 第103行:借助已建立的会话,数据库查询仅需执行一次:
    • 首次请求时,将执行数据库查询(第108行)。随后,检索到的数据会被存入会话(第110行),并关联密钥 [taxAdminData]
    • 在后续请求中,会从会话中检索到密钥 [taxAdminData](第 103 行),随后将磁盘数据直接传递给 [dao] 层(第 119 行);
  • 第111-116行:数据库中的税务数据检索可能失败。在此情况下,向客户端发送代码[500 Internal Server Error]
  • 第113行:驱动程序异常MySQL的错误消息编码为ISO 8859-1。将其转换为UTF-8以便正确记录;
  • 其余代码与上一版本几乎完全相同;
  • 第 143-164 行:函数 [sendResponse] 将所有响应发送给客户端;
  • 第144-148行:参数说明;
  • 第 153 行:响应始终是数组 [‘résultat’=>qqChose] 中的字符串 jSON;
  • 第156行:有时需要在响应中添加HTTP头部。第71行即为这种情况;
  • 第 158 行:发送响应;
  • 第160-163行:响应被记录,日志器关闭;

19.1.4. 测试 [Codeception]

Image

我们将仅测试[dao],因为这是唯一发生变化的层。

[ServerDaoTest]测试代码如下:


<?php

// 严格遵守函数参数的声明类型
declare (strict_types=1);

// 命名空间
namespace Application;

// 常量的定义
define("ROOT", "C:/myprograms/laragon-lite/www/php7/scripts-web/impots/version-09");
// 配置文件路径
define("CONFIG_FILENAME", ROOT . "/Data/config-server.json");

// 获取配置
$config = \json_decode(\file_get_contents(CONFIG_FILENAME), true);
// 引入脚本所需的依赖项
$rootDirectory = $config["rootDirectory"];
foreach ($config["relativeDependencies"] as $dependency) {
  require "$rootDirectory$dependency";
}
// 绝对依赖项(第三方库)
foreach ($config["absoluteDependencies"] as $dependency) {
  require "$dependency";
}

// 测试 -----------------------------------------------------

class ServerDaoTest extends \Codeception\Test\Unit {
  // TaxAdminData
  private $taxAdminData;

  public function __construct() {
    // 父级
    parent::__construct();
    // 获取配置
    $config = \json_decode(\file_get_contents(CONFIG_FILENAME), true);
    // 创建层[dao]
    $dao = new ServerDaoWithSession(ROOT . "/" . $config["databaseFilename"]);
    $this->taxAdminData = $dao->getTaxAdminData();
  }

  // 测试
  public function testTaxAdminData() {

  }

}
  • 第 9-24 行:创建一个与服务器脚本 [impots-server] 完全相同的运行环境;
  • 第38:为构建[dao]层,实例化类[ServerDaoWithSession]

测试结果如下:

Image

19.2. 客户端

我们关注应用程序的客户端部分。

Image

该架构将通过以下脚本实现:

Image

在新版本中,仅以下内容发生变更:

  • 配置文件 [config-client.json]
  • 客户端的 [dao] 层;

19.2.1. [dao]

[Dao]层的变化如下:


<?php

namespace Application;

// 依赖关系
use \Symfony\Component\HttpClient\HttpClient;

class ClientDao implements InterfaceClientDao {
  // 使用一个特征
  use TraitDao;
  // 属性
  private $urlServer;
  private $user;
  private $sessionCookie;

  // 构造函数
  public function __construct(string $urlServer, array $user) {
    $this->urlServer = $urlServer;
    $this->user = $user;
  }

  // 税款计算
  public function calculerImpot(string $marié, int $enfants, int $salaire): array {
    // 会话cookie?
    if (!$this->sessionCookie) {
      // 创建客户HTTP
      $httpClient = HttpClient::create([
          'auth_basic' => [$this->user["login"], $this->user["passwd"]],
          "verify_peer" => false
      ]);
      // 不使用会话cookie向服务器发送请求
      $response = $httpClient->request('GET', $this->urlServer,
        ["query" => [
            "marié" => $marié,
            "enfants" => $enfants,
            "salaire" => $salaire
          ]
      ]);
    } else {
      // 使用会话cookie向服务器发送请求
      // 创建客户端 HTTP
      $httpClient = HttpClient::create([
          "verify_peer" => false
      ]);
      $response = $httpClient->request('GET', $this->urlServer,
        ["query" => [
            "marié" => $marié,
            "enfants" => $enfants,
            "salaire" => $salaire
          ],
          "headers" => ["Cookie" => $this->sessionCookie]
      ]);
    }
    // 获取响应
    $json = $response->getContent(false);
    $array = \json_decode($json, true);
    $réponse = $array["réponse"];
    // 日志
    print "$json=json\n";
    // 获取响应状态
    $statusCode = $response->getStatusCode();
    // 错误?
    if ($statusCode !== 200) {
      // 出现错误 - 抛出异常
      $réponse = ["statut HTTP" => $statusCode] + $réponse;
      $message = \json_encode($réponse, JSON_UNESCAPED_UNICODE);
      throw new ExceptionImpots($message);
    }
    if (!$this->sessionCookie) {
      // 获取会话cookie
      $headers = $response->getHeaders();
      if (isset($headers["set-cookie"])) {
        // 会话cookie?
        foreach ($headers["set-cookie"] as $cookie) {
          $match = [];
          $match = preg_match("/^PHPSESSID=(.+?);/", $cookie, $champs);
          if ($match) {
            $this->sessionCookie = "PHPSESSID=" . $champs[1];
          }
        }
      }
    }
    // 返回响应
    return $réponse;
  }

}

注释

[dao] 层的修改在于现在开始管理会话:

  • 第 14 行:会话 Cookie;
  • 第 25-39 行:首次请求时该 Cookie 不存在:此时向服务器发送请求并附带身份验证信息(第 28 行);
  • 第40-53行:后续请求中通常已持有会话cookie,因此不再发送身份验证信息(第42-44行);
  • 第69-82行:服务器对首次请求的响应将包含一个会话cookie。我们将其获取。该代码已在“链接”段落中使用并解释过;
  • 第 78 行:获取的会话 Cookie 被存储在类属性 [$sessionCookie] 中;

:本可保留旧版 [dao] 层,并在每次请求时进行身份验证,因为其开销微乎其微。出于教学目的,我们特意重现了 HTTP 客户端如何管理会话。

19.2.2. 配置文件

配置文件 jSON 的变更如下:


{
    "rootDirectory": "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-09",
    "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",
        "/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-09/impots-server.php"
}

仅第24行的URL发生了变化。

19.3. 一些测试

19.3.1. 测试 1

首先,我们在无错误的环境中运行客户端。结果与之前版本一致。但现在服务器端出现了一个名为 [logs.txt] 的日志文件:


04/07/19 13:16:08:523 :
---nouvelle requête
04/07/19 13:16:08:529 : Autentification en cours
04/07/19 13:16:08:529 : Authentification réussie [admin, admin]
04/07/19 13:16:08:529 : paramètres ['marié'=>oui, 'enfants'=>2, 'salaire'=>55555] valides
04/07/19 13:16:08:529 : tranches d'impôts prises en base de données
04/07/19 13:16:08:534 : {"réponse":{"impôt":2814,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14}}
04/07/19 13:16:08:643 :
---nouvelle requête
04/07/19 13:16:08:648 : Authentification prise en session
04/07/19 13:16:08:648 : paramètres ['marié'=>oui, 'enfants'=>2, 'salaire'=>50000] valides
04/07/19 13:16:08:648 : tranches d'impôts prises en session
04/07/19 13:16:08:648 : {"réponse":{"impôt":1384,"surcôte":0,"décôte":384,"réduction":347,"taux":0.14}}
04/07/19 13:16:08:769 :
---nouvelle requête
04/07/19 13:16:08:775 : Authentification prise en session
04/07/19 13:16:08:775 : paramètres ['marié'=>oui, 'enfants'=>3, 'salaire'=>50000] valides
04/07/19 13:16:08:775 : tranches d'impôts prises en session
04/07/19 13:16:08:775 : {"réponse":{"impôt":0,"surcôte":0,"décôte":720,"réduction":0,"taux":0.14}}
04/07/19 13:16:08:888 :
---nouvelle requête

  • 第3-7行:首次请求时,会进行身份验证并查询数据库数据;
  • 第9-14行:在后续请求中,不再进行身份验证,数据通过会话获取。此后各次请求(第15行及之后)均重复此过程;

19.3.2. 测试 2

现在关闭数据库 MySQL。客户端显示以下控制台结果:


L'erreur suivante s'est produite : {"statut HTTP":500,"erreur":"SQLSTATE[HY000] [2002] Aucune connexion n’a pu être établie car l’ordinateur cible l’a expressément refusée.\r\n"}
Terminé

服务器端,我们看到以下日志:


04/07/19 13:19:52:396 :
---nouvelle requête
04/07/19 13:19:52:405 : Autentification en cours…
04/07/19 13:19:52:405 : Authentification réussie [admin, admin]
04/07/19 13:19:52:405 : paramètres ['marié'=>oui, 'enfants'=>2, 'salaire'=>55555] valides
04/07/19 13:19:52:405 : tranches d'impôts prises en base de données
04/07/19 13:19:54:461 : {"réponse":{"erreur":"SQLSTATE[HY000] [2002] Aucune connexion n’a pu être établie car l’ordinateur cible l’a expressément refusée.\r\n"}}
04/07/19 13:19:55:602 : Message [SQLSTATE[HY000] [2002] Aucune connexion n’a pu être établie car l’ordinateur cible l’a expressément refusée.
] envoyé à guest@localhost
04/07/19 13:19:55:706 :
---nouvelle requête

要获取应用程序管理员收到的邮件,需使用第X段落中与配置文件[config-imap-01.json]关联的脚本[imap-03.php]

{
    "{localhost:110/pop3}": {
        "imap-server": "localhost",
        "imap-port": "110",
        "user": "guest@localhost",
        "password": "guest",
        "pop3": "TRUE",
        "output-dir": "output/localhost-pop3"
    }
}

结果如下:

Image

文件 [message_1.txt] 包含以下文本:


return-path: guest@localhost
received: from localhost (localhost [127.0.0.1]) by DESKTOP-528I5CU with ESMTP ; Thu, 4 Jul 2019 15:20:22 +0200
message-id: <c82d26df5fb352e10a51577cd1b9ed87@localhost>
date: Thu, 04 Jul 2019 13:20:20 +0000
subject: plantage du serveur de calcul d'impôts
from: guest@localhost
to: guest@localhost
mime-version: 1.0
content-type: text/plain; charset=utf-8
content-transfer-encoding: quoted-printable

SQLSTATE[HY000] [2002] Aucune connexion n’a pu être établie car l’ordinateur cible l’a expressément refusée.

19.3.3. 测试 3

现在让我们确保无法创建文件 [logs.txt]。为此,只需创建一个名为 [logs.txt] 的文件夹:

Image

完成上述操作后,运行客户端。

在客户端,我们得到以下控制台输出:


L'erreur suivante s'est produite : {"statut HTTP":500,"erreur":"Echec lors de la création du fichier de logs [Data\/logs.txt]"}
Terminé

在服务器端,虽然没有日志,但管理员会收到以下邮件:


return-path: guest@localhost
received: from localhost (localhost [127.0.0.1]) by DESKTOP-528I5CU with ESMTP ; Thu, 4 Jul 2019 15:31:49 +0200
message-id: <b2cee274f3437952231d62152ba1cdb3@localhost>
date: Thu, 04 Jul 2019 13:31:48 +0000
subject: plantage du serveur de calcul d'impôts
from: guest@localhost
to: guest@localhost
mime-version: 1.0
content-type: text/plain; charset=utf-8
content-transfer-encoding: quoted-printable

Echec lors de la création du fichier de logs [Data/logs.txt]

19.3.4. 测试 4

这次,我们在客户端配置文件中为连接的客户端指定了错误的凭据。

客户端在控制台显示以下结果:


L'erreur suivante s'est produite : {"statut HTTP":401,"erreur":"Echec de l'authentification [x, x]"}
Terminé

在服务器端,会出现以下日志:


---nouvelle requête
04/07/19 13:36:05:789 : Autentification en cours…
04/07/19 13:36:05:789 : {"réponse":{"erreur":"Echec de l'authentification [x, x]"}}

19.3.5. 测试 5

将正确的用户 [admin, admin] 重新添加到客户端配置文件中。

现在,让我们直接在浏览器中访问服务器上的 URL [http://localhost/php7/scripts-web/impots/version-08/impots-server.php],而不传递任何参数:

在服务器的日志文件,可以看到以下几行:


---nouvelle requête
04/07/19 13:37:33:711 : Autentification en cours…
04/07/19 13:37:33:711 : Authentification réussie [admin, admin]
04/07/19 13:37:33:711 : {"réponse":{"erreurs":["Méthode GET requise avec les seuls paramètres [marié, enfants, salaire]","paramètre marié manquant","paramètre enfants manquant","paramètre salaire manquant"]}}

19.4. [Codeception] 测试

与之前版本的做法一样,我们将为 09 版本编写测试用例 [Codeception]

Image

19.4.0.1. [métier] 层测试

[ClientMetierTest.php] 测试如下:


<?php

// 严格遵守函数参数的声明类型
declare (strict_types=1);

// 命名空间
namespace Application;

// 常量定义
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-09");

// 配置文件路径
define("CONFIG_FILENAME", ROOT . "/Data/config-client.json");

// 获取配置
$config = \json_decode(file_get_contents(CONFIG_FILENAME), true);

// 引入脚本所需的依赖项
$rootDirectory = $config["rootDirectory"];
foreach ($config["dependencies"] as $dependency) {
  require "$rootDirectory/$dependency";
}
// 绝对依赖项(第三方库)
foreach ($config["absoluteDependencies"] as $dependency) {
  require "$dependency";
}
//
// 使用
use Codeception\Test\Unit;
use const CONFIG_FILENAME;
use const ROOT;

// 测试类
class ClientMetierTest extends Unit {
  
}

注释

  • 与08版测试类相比,仅第10行有所变化,该行指定了待测客户端的根目录;

测试结果如下:

Image

查看服务器日志会很有趣:[logs.txt]


04/07/19 13:48:48:525 :
---nouvelle requête
04/07/19 13:48:48:536 : Autentification en cours…
04/07/19 13:48:48:536 : Authentification réussie [admin, admin]
04/07/19 13:48:48:536 : paramètres ['marié'=>oui, 'enfants'=>2, 'salaire'=>55555] valides
04/07/19 13:48:48:536 : données fiscales prises en base de données
04/07/19 13:48:48:548 : {"réponse":{"impôt":2814,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14}}
04/07/19 13:48:48:635 :
---nouvelle requête
04/07/19 13:48:48:645 : Autentification en cours…
04/07/19 13:48:48:645 : Authentification réussie [admin, admin]
04/07/19 13:48:48:645 : paramètres ['marié'=>oui, 'enfants'=>2, 'salaire'=>50000] valides
04/07/19 13:48:48:645 : données fiscales prises en base de données
04/07/19 13:48:48:655 : {"réponse":{"impôt":1384,"surcôte":0,"décôte":384,"réduction":347,"taux":0.14}}
04/07/19 13:48:48:751 :
---nouvelle requête
04/07/19 13:48:48:762 : Autentification en cours…
04/07/19 13:48:48:762 : Authentification réussie [admin, admin]
04/07/19 13:48:48:762 : paramètres ['marié'=>oui, 'enfants'=>3, 'salaire'=>50000] valides
04/07/19 13:48:48:762 : données fiscales prises en base de données
04/07/19 13:48:48:773 : {"réponse":{"impôt":0,"surcôte":0,"décôte":720,"réduction":0,"taux":0.14}}
04/07/19 13:48:48:865 :
---nouvelle requête

---nouvelle requête
04/07/19 13:48:49:546 : Autentification en cours…
04/07/19 13:48:49:546 : Authentification réussie [admin, admin]
04/07/19 13:48:49:546 : paramètres ['marié'=>oui, 'enfants'=>3, 'salaire'=>200000] valides
04/07/19 13:48:49:546 : données fiscales prises en base de données
04/07/19 13:48:49:551 : {"réponse":{"impôt":42842,"surcôte":17283,"décôte":0,"réduction":0,"taux":0.41}}

可以发现,税务管理数据始终是从数据库中获取的,从未在会话中获取。让我们回到已执行的测试代码:


<?php

// 严格遵守函数参数的声明类型
declare (strict_types=1);

// 命名空间
namespace Application;



// 测试类
class ClientMetierTest extends Unit {
  // 业务层
  private $métier;

  public function __construct() {
    parent::__construct();
    // 获取配置
    $config = \json_decode(\file_get_contents(CONFIG_FILENAME), true);
    // 创建 [dao] 层
    $clientDao = new ClientDao($config["urlServer"], $config["user"]);
    // 创建层[métier]
    $this->métier = new ClientMetier($clientDao);
  }

  // 测试
  public function test1() {

  }

  public function test2() {

  }

  public function test3() {

  }



}

在测试类 [Codeception] 中,构造函数会在每次测试时被调用

  • 第21行:因此,每个测试都会创建一个新的[ClientDao],并带有会话cookie NULL。这解释了为何该客户端无法利用任何会话;

此示例表明,会话并非存储税务管理数据的合适位置。实际上,这些数据对应用程序的所有用户都是通用的。然而在此处,它们却在每个用户的会话中被重复存储。

在Web编程中,共享数据通常分为三种可见性类型:

  • 由Web应用程序所有用户共享的数据。这类数据通常为只读数据。PHP本身不具备此类存储功能;
  • 同一客户端的请求共享的数据。这些数据存储在会话中。此时,我们称之为客户端会话,以指代客户端的内存。同一客户端的所有请求均可访问该会话,并在其中存储和读取信息。在之前的脚本中,该会话由 Symfony 对象 [HttpFoundation\Session\Session] 实现;
  • 请求内存,或称请求上下文。用户的请求可能由多个连续的操作进行处理。 请求上下文允许操作 1 向操作 2 传递信息。在之前的脚本中,请求由 Symfony 对象 [HttpFoundation\Request] 实现,其内存由属性 [HttpFoundation\Request::attributes] 实现;

Image

已有第三方库可为 PHP 提供应用程序内存。应用练习的新版本展示了其中一种库的使用方法。