Skip to content

23. 实践练习 – 第 12 版

在本章中,我们将编写一个遵循 MVC(模型-视图-控制器)架构的 Web 应用程序。该应用程序能够以三种格式返回响应:JSON、XML 和 HTML。与之前的内容相比,我们即将进行的操作在复杂度上有了显著提升。我们将复用迄今为止涵盖的大部分概念,并详细说明构建最终应用程序的所有步骤。

23.1. MVC 架构

我们将按以下方式实现 MVC(模型-视图-控制器)架构模式:

Image

客户端请求的处理流程如下:

  • 1 - 请求

请求的 URL 将采用以下形式:http://machine:port/contexte/….?action=anAction&param1=v1&param2=v2&… [主控制器] 将通过配置文件将请求“路由”到正确的控制器,并定位该控制器内的正确操作。为此,它将使用 URL 中的 [action] 字段。 URL 的其余部分 [param1=v1&param2=v2&…] 由可选参数组成,这些参数将传递给操作。此处的 MVC 中的 C 代表 [主控制器、控制器/操作] 链。如果没有控制器能处理所请求的操作,Web 服务器将响应称未找到所请求的 URL。

  • 2 - 处理
    • 选定的操作 [2a] 可以使用 [主控制器] 传递给它的参数。这些参数可能来自以下几个来源:
      • URL 的路径 [/param1/param2/…]
      • URL 参数 [param1=v1&param2=v2]
      • 浏览器随请求提交的参数;
    • 在处理用户请求时,该操作可能需要调用 [业务][2b]。一旦处理完客户端的请求,可能会触发各种响应。一个典型的例子是:
      • 若请求无法正确处理,则返回错误响应;
      • 否则返回确认响应;
    • [控制器/操作] 将连同状态码一起将其响应 [2c] 返回给主控制器。这些状态码将唯一地表示应用程序的状态。它们要么是成功码,要么是错误码;
  • 3 - 响应
    • 根据客户端请求的是 JSON、XML 还是 HTML 响应,[主控制器] 将实例化 [3a] 相应的响应类型,并指示其将响应发送给客户端。主控制器将同时向其传递响应以及由已执行的 [控制器/操作] 提供的状态码;
    • 如果所需的响应为 JSON 或 XML 类型,选定的响应组件将根据 [控制器/操作] 提供的数据格式化响应并发送 [3c]。能够处理此响应的客户端可以是 PHP 控制台脚本,也可以是嵌入在 HTML 页面中的 JavaScript 脚本;
    • 如果所需的响应是 HTML 类型,则选定的响应将根据收到的状态码选择 [3b] 其中一个 HTML 视图 [Vuei]。 这就是 MVC 中的“V”。一个视图对应一个状态码。该视图 V 将显示已执行的 [Controller / Action] 返回的响应。它将该响应中的数据封装在 HTML、CSS 和 JavaScript 中。这些数据被称为视图模型。这就是 MVC 中的 M。客户端通常是浏览器;

现在,让我们厘清 MVC Web 架构与分层架构之间的关系。根据模型的定义方式不同,这两个概念可能相关,也可能无关。让我们考虑一个单层 MVC Web 应用程序:

Image

在上例中,[控制器/操作]各自包含了[业务]层和[DAO]层的部分内容。在[Web]层中,我们确实采用了MVC架构,但整个应用程序并未采用分层架构。在这里,只有一个层负责处理所有事务。

现在,让我们考虑一种多层Web架构:

Image

[Web]层可以不遵循MVC模型来实现。这样我们就有了多层架构,但Web层并未实现MVC模型。

例如,在 .NET 环境中,上文提到的 [Web] 层可以使用 ASP.NET MVC 来实现,从而形成一个具有 MVC 风格 [Web] 层的分层架构。完成这一步后,我们可以将这个 ASP.NET MVC 层替换为经典的 ASP.NET 层(WebForms),同时保持其余部分(业务逻辑、DAO、驱动程序)不变。 这样,我们就得到了一种分层架构,其 [Web] 层不再基于 MVC。

在 MVC 中,我们曾提到 M 模型即 V 视图所呈现的数据集。关于 MVC 中 M 模型的另一种定义如下:

Image

许多作者认为,位于[Web]层右侧的部分构成了MVC中的M模型。为避免歧义,我们可以将其称为:

  • 当指代[Web]层右侧的所有内容时,称之为领域模型
  • 当指代视图 V 所显示的数据时,称之为视图模型

23.2. NetBeans 项目树

对于 NetBeans 项目,我们将采用一种体现 MVC 模型的架构:

Image

  • [3][main.php] 是我们 MVC 模型的主控制器。它是 MVC 中的 C;
  • [4]: [Controllers] 文件夹将包含次级控制器。 每个控制器处理特定的操作。该操作在 URL 中有所体现,例如 […/main.php?action=authenticate-user]。通过此操作,[主控制器] [main.php] 将选择一个 [次级控制器](本例中为 [AuthentifierUtilisateurController])来处理请求的操作。这些控制器同样属于 MVC 中的 C 部分;
  • [5]: [Model] 文件夹将包含应用程序的 [业务] [DAO] 层。根据之前采用的术语,这些元素代表领域模型,并且根据 MVC 中 M 的术语,可以代表 MVC 中的 M;
  • [6][Responses] 文件夹包含负责向客户端发送响应的类。每种所需的响应类型对应一个类:
    • [JsonResponse]:用于 JSON 响应;
    • [XmlResponse]:用于 XML 响应;
    • [HtmlResponse]:用于 HTML 响应;
  • [7][Views] 文件夹包含在需要 HTML 响应时的 HTML 视图。这是 MVC 中的 V。它们由 [HtmlResponse] 类调用,该类向其传递待显示的数据。这些数据即视图模型。根据 M 所采用的术语,这些数据可代表 MVC 中的 M;
  • [8]: [Utilities] 文件夹包含实用程序:
    • [Logger]:允许将日志记录到文本文件的类;
    • [Sendmail]:用于发送电子邮件的类;
  • [9]: [Logs] 文件夹包含日志文件 [logs.txt]
  • [10][Entities] 文件夹中包含各控制器所使用的类;

利用这种目录结构,我们可以描述客户端请求的操作的处理流程:

  • [main.php] [3] 接收请求;
  • 在执行一些初步检查(该操作是否属于被接受的操作?)后,将其转发给负责处理此操作的二级控制器 [4]
  • 二级控制器执行其任务。在此过程中,它可能需要调用 [business] [DAO][5] 以及 [10] 文件夹中的实体。它将响应返回给调用它的主控制器 [main.php]
  • 根据客户端请求的响应类型 [JSON, XML, HTML],主控制器 [main.php] 调用 [Responses] 文件夹 [6] 中的相应响应;
  • [JsonResponse] 和 [XmlResponse] 响应分别向客户端发送 JSON 或 XML 响应;
  • [HtmlResponse] 则使用 [Views] 文件夹 [7] 中的某个视图,向客户端发送 HTML 响应;
  • 各个控制器均可访问 [Logger] 类(位于 [8] 文件夹中),用于将日志写入 [9] 文件夹中的日志文件。以下内容会被记录:
    • 请求的操作;
    • 控制器的响应。无论请求类型为何(JSON、XML、HTML),响应均以 JSON 格式记录;
  • 若发生致命错误(HTTP_INTERNAL_SERVER_ERROR),主控制器 [main.php] 将通过 [8] 文件夹中的 [SendMail] 类向管理员发送电子邮件;

23.3. 应用程序操作

客户端将待执行的操作作为 URL 中的 [action] 参数 [/main.php?action=xxx] 发送至 Web 服务器。允许的操作列于配置主控制器 [main.php][config.json] 文件中:


"actions":
            {
                "init-session": "\\InitSessionController",
                "authentifier-utilisateur": "\\AuthentifierUtilisateurController",
                "calculer-impot": "\\CalculerImpotController",
                "lister-simulations": "\\ListerSimulationsController",
                "supprimer-simulation": "\\SupprimerSimulationController",
                "fin-session": "\\FinSessionController",
                "afficher-calcul-impot": "\\AfficherCalculImpotController"
},
  • 第 1 行:JSON 字典中的 [actions] 键;
  • 第 3–9 行:一个 [action:controller] 字典。每个操作都与负责处理它的二级控制器相关联;
  • 第 3 行:[init-session]:启动税费计算模拟会话。该操作指定所需的响应类型 [JSON, XML, HTML]
  • 第 4 行:设置会话类型后,客户端必须使用 [authenticate-user] 操作进行身份验证。在客户端完成身份验证之前,除 [init-session] 外,所有其他操作均被禁止;
  • 第 5 行:通过身份验证后,客户端可使用 [calculate-tax] 操作执行一系列税费计算;
  • 第 6 行:客户端可随时通过 [list-simulations] 操作请求查看已执行的模拟列表;
  • 第 7 行:用户可通过 [delete-simulation] 操作删除其中部分模拟;
  • 第 8 行:客户端通过 [end-session] 操作结束模拟会话。此后,若要继续使用该应用程序,需重新登录;
  • 第 9 行:在 HTML 应用程序中,[display-tax-calculation] 操作会显示用于计算税款的表单;

23.4. Web 应用程序配置

该应用程序通过以下 JSON 文件 [config.json] 进行配置:


{
    "databaseFilename": "database.json",
    "rootDirectory": "C:/myprograms/laragon-lite/www/php7/scripts-web/impots/version-12",
    "relativeDependencies": [
 
        "/Entities/BaseEntity.php",
        "/Entities/Simulation.php",
        "/Entities/Database.php",
        "/Entities/TaxAdminData.php",
        "/Entities/ExceptionImpots.php",
 
        "/Utilities/Logger.php",
        "/Utilities/SendAdminMail.php",        
 
        "/Model/InterfaceServerDao.php",
        "/Model/ServerDao.php",
        "/Model/ServerDaoWithSession.php",
        "/Model/InterfaceServerMetier.php",
        "/Model/ServerMetier.php",
 
        "/Responses/InterfaceResponse.php",
        "/Responses/ParentResponse.php",
        "/Responses/JsonResponse.php",
        "/Responses/XmlResponse.php",
        "/Responses/HtmlResponse.php",
 
        "/Controllers/InterfaceController.php",
        "/Controllers/InitSessionController.php",
        "/Controllers/ListerSimulationsController.php",
        "/Controllers/AuthentifierUtilisateurController.php",
        "/Controllers/CalculerImpotController.php",
        "/Controllers/SupprimerSimulationController.php",
        "/Controllers/FinSessionController.php",
        "/Controllers/AfficherCalculImpotController.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": "Logs/logs.txt",
    "actions":
            {
                "init-session": "\\InitSessionController",
                "authentifier-utilisateur": "\\AuthentifierUtilisateurController",
                "calculer-impot": "\\CalculerImpotController",
                "lister-simulations": "\\ListerSimulationsController",
                "supprimer-simulation": "\\SupprimerSimulationController",
                "fin-session": "\\FinSessionController",
                "afficher-calcul-impot": "\\AfficherCalculImpotController"
            },
    "types": {
        "json": "\\JsonResponse",
        "html": "\\HtmlResponse",
        "xml": "\\XmlResponse"
    },
    "vues": {
        "vue-authentification.php": [700, 221, 400],
        "vue-calcul-impot.php": [200, 300, 341, 350, 800],
        "vue-liste-simulations.php": [500, 600]
    },
    "vue-erreurs": "vue-erreurs.php"
}

注释

  • 第 2 行:包含数据库访问配置的 JSON 文件名称;
  • 第 3–39 行:项目依赖项的配置。项目目录树中的所有 PHP 脚本均在此处列出;
  • 第 40–44 行:获授权使用该应用程序的用户;
  • 第 46–54 行:应用程序管理员的电子邮件地址;
  • 第 55 行:日志文件的路径;
  • 第 56–65 行:关联 [操作 => 负责处理该操作的次级控制器]
  • 第 66–70 行:映射 [响应类型 => 负责向客户端发送响应的 Response 类]
  • 第 71–75 行:映射 [HTML 视图 => 导致此视图的状态码表]
  • 第 76 行:每当发生异常错误时,视图 [error-view] 将在 HTML 会话中显示:
    • JSON 或 XML 应用程序通常通过编程客户端进行查询。该客户端向服务器传递的参数可能缺失或错误。控制器会处理这些情况,并向客户端返回错误代码。所有可能的错误情况都必须得到处理;
    • 对于 HTML 应用程序,情况略有不同。在正常使用中,Web 应用程序仅利用了 JSON 和 XML 客户端可能用例中的一小部分。举个例子:[calculate-tax] 操作期望收到三个通过 POST 请求发送的参数:[married, children, salary]
      • 如果我们有一个允许手动输入 URL 的 JSON 客户端,我们可以使用 GET 请求而不是 POST 请求来调用 [calculate-tax] 操作,或者在需要三个参数时发送一个不包含任何参数的 POST 请求等。JSON 服务器必须处理所有这些情况;
      • 在 Web 应用程序中,[calculate-tax] 操作将通过 Web 表单发起请求,此时上述两种情况均不可能发生:[calculate-tax] 操作将通过包含全部三个参数 [married, children, salary] 的 POST 请求发起。其中某些参数的值可能不正确,但它们必定存在。然而,用户可以通过在浏览器中手动输入 URL 来重现某些错误。 出于安全考虑,我们必须处理这种情况;
      • 每当次级控制器返回与 Web 应用程序不兼容的状态码时(即配置文件第 72–74 行中未列出的状态码),系统将显示 [error-view]。我们选择此方案是为了教学目的。 另一种可能的做法是置之不理,仅重新显示客户端浏览器中当前的视图,从而让用户觉得服务器并未响应其手动构造的 URL;

23.5. 安装工具和库

23.5.1. Postman

[Postman] 是一款用于查询 Web 应用程序各类 URL 的工具。它允许我们:

  • 使用任意 URL:这些 URL 均为手动构造的;
  • 使用 GET、POST、PUT、OPTIONS 等方法向 Web 服务器发送请求;
  • 指定 GET 或 POST 参数;
  • 设置请求的 HTTP 头部;
  • 接收 JSON、XML 或 HTML 格式的响应;
  • 访问响应的 HTTP 头部。这使我们能够获取服务器的完整 HTTP 响应;

由于我们是手动构建要查询的 URL,因此能够测试所有可能的错误场景,并观察服务器的反应。

[Postman] 可通过网址 [https://www.getpostman.com/downloads/] 访问。 2019年6月发布的版本为7.2。该版本存在一个缺陷:当向Web服务器连续发送请求时,[Postman 7.2]客户端不会自动返回服务器发送的Cookie,尤其是会话Cookie。为了保持会话,您必须手动将会话Cookie复制到后续请求的HTTP头中。 虽然操作并不复杂,但并不实用。这是之前版本中不存在的缺陷。意识到该缺陷后,[Postman] 团队已在名为 [Postman Canary] 的 Alpha 版本(可能不稳定)中修复了此问题该版本可通过 URL [https://www.getpostman.com/downloads/canary] 获取。本文将使用此版本。 接下来我们将介绍安装方法。如果已有稳定版 [Postman 7.3] 或更高版本可用,建议直接下载:该漏洞很可能已被修复。

请继续安装您所选的 [Postman] 版本。安装过程中系统会提示您创建账户:此处无需此操作。[Postman] 账户用于在不同设备间同步,以便将一台设备的配置复制到另一台设备上。此处均无需这些功能。

安装完成后,[Postman] 将显示以下界面:

Image

  • [2-3] 中,您可以访问产品设置;

Image

  • [6] 中,可查看本文档所使用的版本;
  • 如果您已创建账户,您的计算机与远程 [Postman] 服务器之间会进行同步。当您对 [Postman] 项目进行修改时,出现的旋转图标 [7] 即表示正在同步。若要停止此不必要的同步,请通过 [8-9] 注销;

23.5.2. Symfony / Serializer 库

为了将对象序列化为 JSON 和 XML,我们将使用 [Symfony / Serializer] 库。它在此处具有两个优势:

  • 它在序列化为 JSON 或 XML 时保持一致性:这避免了学习两个具有不同 API(应用程序编程接口)的库;
  • 它原生支持将对象序列化为 JSON 或 XML,即使这些对象的属性是私有的。回想一下,在 JSON 中,要序列化一个对象,其类必须实现 [\JsonSerializable] 接口。 生成的结果是一个 JSON 字符串,其中包含一个以类属性为键的关联数组。反序列化该 JSON 字符串时,我们会获取原始的关联数组,随后必须将其转换为被序列化类的对象。而使用 [Symfony / Serializer],反序列化会立即生成被序列化类的对象。这更简单;

[Symfony / Serializer] 库的文档可通过以下网址获取:[https://symfony.com/doc/current/components/serializer.html](2019年6月)。

要安装此库,请打开 Laragon 终端(参见链接部分),并输入以下命令:

Image

  • [1] 中,是安装 [symfony/serializer] 库的命令;
  • [2] 中,是本项目所需的另一个库:用于实现对象序列化;

Image

23.6. 应用程序实体

Image

自 Web 服务 08 版起,已使用 [BaseEntity、Database、ExceptionImports、TaxAdminData] 这些实体(参见链接部分)。

将使用 [Simulation] 类来封装税务计算模拟的各个要素:


<?php
 
namespace Application;
 
class Simulation extends BaseEntity {
  // attributes of a tax calculation simulation
  protected $marié;
  protected $enfants;
  protected $salaire;
  protected $impôt;
  protected $surcôte;
  protected $décôte;
  protected $réduction;
  protected $taux;
 
  // getters
  public function getMarié() {
    return $this->marié;
  }
 
  public function getEnfants() {
    return $this->enfants;
  }
 
  public function getSalaire() {
    return $this->salaire;
  }
 
  public function getImpôt() {
    return $this->impôt;
  }
 
  public function getSurcôte() {
    return $this->surcôte;
  }
 
  public function getDécôte() {
    return $this->décôte;
  }
 
  public function getRéduction() {
    return $this->réduction;
  }
 
  public function getTaux() {
    return $this->taux;
  }
 
}

评论

  • 第 5 行:[Simulation] 类继承自 [BaseEntity] 类,因此继承了以下方法:
    • [setFromArrayOfAttributes($arrayOfAttributes)]:允许您初始化该类的属性;
    • [__toString]:返回对象的 JSON 字符串;
  • 第 7–14 行:模拟的属性;
  • 第 16–47 行:该类的 getter 方法;

23.7. 应用程序实用工具

Image

[Logger] 类允许您将事件记录到文本文件中。该类的详细说明请参见相关章节。

[SendAdminMail] 类允许您向应用程序管理员发送电子邮件。该类在链接的章节中有详细说明。

23.8. [business] 和 [DAO] 层

Image

Image

[business][DAO] 层的类与接口均归类于 [Model] 文件夹中。它们均已在之前的版本中定义并使用:

ExceptionImports
用于处理 [DAO] 层抛出的异常的类。定义详见相关章节。
InterfaceServerDao
由服务器 [DAO] 层实现的接口。定义在链接部分。
ServerDao
[InterfaceServerDao] 接口的实现。实现服务器的 [dao] 层。定义链接部分。
ServerDaoWithSession
[InterfaceServerDao] 接口的实现。实现服务器的 [dao] 层。定义在“link”部分。
InterfaceServerMetier
由服务器 [business] 层实现的接口。定义在链接部分。
ServerBusiness
[InterfaceMetier] 接口的实现。实现服务器的 [business] 层。定义在“link”部分。

当前正在开发的应用程序广泛使用了已介绍和应用的元素:

  • [业务]层和[DAO]层;
  • [Logger] [SendAdminMail] 工具;
  • [ExceptionImpots、TaxAdminData、Database] 实体;

我们将重点关注应用程序的[Web]层:

Image

23.9. 主控制器 [main.php]

23.9.1. 简介

Image

  • [1-2]:主控制器 [main.php] [1][config.json] 文件 [2] 进行配置;

让我们回顾一下主控制器在 MVC 架构中的位置:

Image

[1] 中,主控制器 [main.php] 是 MVC 架构中处理客户端请求的第一个组件。它承担着以下几个角色:

  • 首先,它执行基本检查:
    • 检查配置文件是否存在且有效;
    • 加载所有项目依赖项。这相当于加载 MVC 架构中的所有组件;
    • 是否指定了请求的操作?如果是,该操作是否有效?
    • 如果请求的操作有效,则选择 [2a] 将处理该操作的二级控制器,并向其传递所需的信息:HTTP 请求、会话以及应用程序配置;
    • 从二级控制器获取 [2c] 响应。根据客户端请求的应用程序类型(JSON、XML、HTML),选择 [3a] 负责向客户端发送响应的响应对象(JsonResponse、XmlResponse、HtmlResponse),并向其传递所需的所有信息(HTTP 请求、会话、应用程序配置、来自二级控制器的响应);
    • 一旦该响应已发送 [3c],释放处理请求时可能已分配的任何资源;

23.9.2. [main.php] - 1

主控制器 [main.php] 的代码如下:


<?php
 
// strict adherence to declared types of function parameters
declare (strict_types=1);
 
// namespace
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
 
// error handling by PHP
//ini_set("display_errors", "0");
error_reporting(E_ALL && !E_WARNING && !E_NOTICE);
// we retrieve the configuration
$configFilename = "config.json";
$fileContents = \file_get_contents($configFilename);
$erreur = FALSE;
// mistake?
if (!$fileContents) {
  // we note the error
  $état = 131;
  $erreur = TRUE;
  $message = "Le fichier de configuration [$configFilename] n'existe pas";
}
if (!$erreur) {
  // retrieve the JSON code from the configuration file in an associative array
  $config = \json_decode($fileContents, true);
  // mistake?
  if (!$config) {
    // we note the error
    $erreur = TRUE;
    $état = 132;
    $message = "Le fichier de configuration [$configFilename] n'a pu être exploité correctement";
  }
}
// mistake?
if ($erreur) {
  // preparation of JSON server response
  // you can't use the configuration file
  // symfony dependencies
  require_once "C:/myprograms/laragon-lite/www/vendor/autoload.php";
  // response preparation
  $response = new Response();
  $response->headers->set("content-type", "application/json");
  $response->setCharset("utf-8");
  // status code
  $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
  // content
  $response->setContent(json_encode(["action" => "", "état" => $état, "réponse" => $message], JSON_UNESCAPED_UNICODE));
  // shipping
  $response->send();
  // end
  exit;
}

注释

  • 第 10–12 行:主控制器使用以下 Symfony 对象:
    • [Request]:当前正在处理的 HTTP 请求;
    • [Session]:Web 应用程序的会话;
    • [Response]:发给客户端的 HTTP 响应;
  • 第 15 行:在整个开发过程中,此行将保持注释状态:这样 PHP 错误就会包含在发送给客户端的文本流中。如果客户端是浏览器,这将允许用户查看服务器遇到的错误。这有助于调试;
  • 第 16 行:报告所有错误(E_ALL),但排除警告(! E_WARNING)和非致命通知(! E_NOTICE)。例如,如果无法打开文件,PHP 会生成 [E_NOTICE] 错误。 如果第 15 行启用了错误显示,文件打开错误将出现在客户端的浏览器中。如果你忘记测试文件打开的结果,这没问题;但如果你计划进行测试,情况就不同了:此时一条 [notice] 行会使服务器对客户端的响应变得杂乱。在开发过程中,第 16 行也应注释掉:你不想遗漏任何错误;
  • 第 19 行:读取配置文件;
  • 第 22–27 行:如果读取操作失败,则记录错误(第 25 行),将应用程序状态设为 [131],并准备错误消息;
  • 第 30 行:对配置文件中的 JSON 字符串进行解码;
  • 第 32–37 行:如果解码失败,则记录错误(第 34 行),将应用程序状态设为 [132],并准备错误消息;
  • 第 40–57 行:若读取配置文件时发生错误,则无法继续执行。此时,我们将为客户端准备一个 JSON 响应:
  • 第 44 行:由于未读取配置文件,因此必须手动导入 [Symfony] 所需的 [autoload] 文件;
  • 第 46–47 行:准备 JSON 响应;
  • 第 50 行:响应的 HTTP 状态码将设置为 500 INTERNAL_SERVER_ERROR;
  • 第 52 行:我们设置响应的 JSON 内容。当前所讨论的 Web 应用程序生成的所有响应都将包含三个键:
      • [action]:客户端请求的操作;
      • [status]:执行此操作后应用程序的状态;
      • [response]:Web 服务器的响应;
  • 第 54 行:将 JSON 响应发送给客户端;

23.9.3. [Postman] 测试 - 1

我们将验证当配置文件缺失或错误时服务器的行为:

Image

我们将把 [Postman] 客户端发送给税务服务器的各种请求整理成集合。

  • [1] 中,创建一个新的集合;
  • [2] 中,为其命名;
  • [3] 中,描述为可选;

Image

  • 在集合 [4] 中,现在出现了一个名为 [impots-server-tests-version12] [5] 的集合;
  • [6] 中,您可以向该集合添加新的请求;

Image

  • [7] 中,为查询指定了一个名称;
  • [8] 中,描述为可选项;

Image

  • [9-11] 中,将请求添加到集合中;
  • [12] 中,选择请求类型;此处为 [GET] 请求。在 [19] 中,列出了可用的不同请求类型;
  • [13] 中,在此处输入服务器的 URL;
  • [14] 中,在此处输入要添加到 URL 中的参数;这些将是 GET 参数。在此处输入而非直接在 URL 中输入的优势在于,[Postman] 会自动对它们进行 URL 编码。若直接在 URL 中输入,则需自行进行 URL 编码;
  • [15] 中,[Authorization] 用于定义将要登录的用户。我们无需使用此选项;
  • [16] 中,填写将随请求发送的 HTTP 头部。部分头部会自动包含在请求中。您可以在此处添加新的头部;
  • [17] 中,[Body] 指的是 [POST] 操作的参数。我们需要使用此选项;

我们将进行以下测试:

  • [main.php] 中,我们指定配置文件为 [config2.json],该文件并不存在:

Image

  • 代码第 16 行必须取消注释;
  • 第 18 行:关于配置文件名的错误;

现在打开 [Postman] [13, 20],输入税费计算 Web 服务器的 URL,并执行 [21]

Image

服务器返回的响应(当然,此时必须已运行 Laragon)如下:

Image

  • [22] 中,服务器返回了 HTTP 状态码 [500 内部服务器错误]
  • [23] 中,[Body] 指响应正文,即位于 HTTP 头部 [28] 之后由服务器发送的文档;
  • [26] 中,我们可以看到 [Postman] 接收到了一个 JSON 响应;
  • [27] 中,格式化的 JSON 响应;
  • [28] 中,未格式化的原始 JSON 响应;
  • [29] 中,当响应为 HTML 时会使用 [预览] 模式。[预览] 模式随后会显示接收到的页面;
  • [30]中,显示了来自服务器的 JSON 响应。这确实就是我们所期望的响应;

[25] 中,服务器响应中发送的 HTTP 头部如下:

Image

  • [32] 中,响应的 JSON 类型;

通过这项初步测试,我们发现:

  • 可以向被测服务器发送任何类型的请求;
  • 可以设置 GET 或 POST 参数;
  • 能够获取完整的响应:包括 HTTP 头部以及紧随其后的文档主体 [Body]

现在,让我们进行第二次测试:

Image

  • [1-3] 中,[config3.json] 文件是一个语法错误的 JSON 文件;
  • [4] 中,[main.php] 被配置为使用 [config3.json]

我们在 [Postman] 中添加一个新请求:

Image

  • [1-3] 中,右键单击 [2] 并选择 [复制] 选项以复制请求 [2]
  • [4] 中,新请求有一个默认名称,我们在 [5] 中将其修改;

Image

  • [6] 中,已重命名的请求;
  • [9-10] 中,我们发送与之前相同的 GET 请求;

Image

  • [11]中,服务器的JSON响应;

这里展示了如何测试税费计算 Web 服务的各项操作。

23.9.4. [main.php] – 2

我们继续分析主控制器的代码 [main.php]


<?php
 
// strict adherence to declared types of function parameters
declare (strict_types=1);
 
// namespace
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
 
// error handling by PHP
//ini_set("display_errors", "0");
error_reporting(E_ALL && !E_WARNING && !E_NOTICE);
// we retrieve the configuration
$configFilename = "config.json";

// include the necessary script dependencies
$rootDirectory = $config["rootDirectory"];
foreach ($config["relativeDependencies"] as $dependency) {
  require_once "$rootDirectory$dependency";
}
// absolute dependencies (third-party libraries)
foreach ($config["absoluteDependencies"] as $dependency) {
  require_once "$dependency";
}
 
// log file creation
try {
  $logger = new Logger($config['logsFilename']);
} catch (ExceptionImpots $ex) {
  // log file could not be created - internal server error
  $état = 133;
  (new JsonResponse())->send(
    NULL, NULL, $config,
    Response::HTTP_INTERNAL_SERVER_ERROR,
    ["action" => "non déterminée", "état" => $état, "réponse" => "Le fichier de logs [{$config['logsFilename']}] n'a pu être créé"],
    []);
  // completed
  exit;
}

注释

  • 第 18 行:现在我们有一个名为 [config.json] 的配置文件,该文件已存在且语法正确。我们还应验证该文件中是否包含预期的键。我们假设这是开发者常规调试流程的一部分。我们本可以将同样的推理应用到前两个错误上;
  • 第 20–28 行:我们引入了 Web 项目所需的所有依赖项。我们已经多次遇到过这段代码;
  • 第 31–43 行:我们尝试创建 [Logger] 对象,该对象将允许我们将事件记录到文件 [$config['logsFilename']] 中。此创建操作可能会失败;
  • 第 33–43 行:处理创建 [Logger] 对象时的错误;
  • 第 35 行:我们设置状态码;
  • 第 36–40 行:发送 JSON 响应;
  • 第 42 行:我们终止脚本;

发送给客户端的所有响应都实现了以下 [InterfaceResponse] 接口:

Image

[InterfaceResponse] 接口的代码如下:


<?php

namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
interface InterfaceResponse {
 
  // Request $request : requête en cours de traitement
  // Session $session: the web application session
  // array $config: application configuration
  // int statusCode: HTTP response status code
  // array $content: server response
  // array $headers: HTTP headers to be added to the response
  // Logger $logger: the logger for writing logs
  
  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void;
}
  • 第 19–27 行:[InterfaceResponse] 接口有一个方法 [send],用于将响应发送给客户端;
  • 第 11–17 行:[send] 方法中各个参数的含义;
  • 第 23–25 行:参数 [$statusCode, $content, $headers] 是应用程序二级控制器标准输出的一部分。不过,响应可能需要额外信息。因此,我们通过前三个参数(第 20–22 行)为其提供这些信息,使其能够访问有关请求、会话和配置的所有信息;
  • 第 26 行:响应需要 [Logger],因为它将记录发送给客户端的响应;

[JsonResponse] 类通过以下方式实现了 [InterfaceResponse] 接口:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\Serializer\Encoder\JsonEncode;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
 
class JsonResponse extends ParentResponse implements InterfaceResponse {
 
  // Request $request : requête en cours de traitement
  // Session $session: the web application session
  // array $config: application configuration
  // int statusCode: HTTP response status code
  // array $content: server response
  // array $headers: HTTP headers to be added to the response
  // Logger $logger: the logger for writing logs
 
  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void {
 
    // symfony serializer preparation
    $serializer = new Serializer(
      [
      // required for object serialization
      new ObjectNormalizer()],
      // encoder jSON
      // for options, make OU between the different options
      [new JsonEncoder(new JsonEncode([JsonEncode::OPTIONS => JSON_UNESCAPED_UNICODE]))]
    );
    // serialization jSON
    $json = $serializer->serialize($content, 'json');
    // headers
    $headers = array_merge($headers, ["content-type" => "application/json"]);
    // sending reply
    parent::sendResponse($statusCode, $json, $headers);
    // log
    if ($logger !== NULL) {
      $logger->write("réponse=$json\n");
    }
  }
 
}

评论

  • 第 13 行:该类实现了 [InterfaceResponse] 接口;
  • 第 13 行:该类继承自 [ParentResponse] 类。所有 [Response] 类型都继承自该类。正是这个父类将响应发送给客户端(第 46 行)。由于这段代码是所有 [Response] 类型共有的,因此被提取到了父类中;
  • 第 33–40 行:实例化 [Symfony] 序列化器,它将把服务器响应 [$content] 转换为 JSON 字符串(第 42 行);
  • 第 34–36 行:[Serializer] 构造函数的第一个参数是一个数组。其中放置了对象序列化所需的 [ObjectNormalizer] 类的实例。在本应用中,这涉及一组模拟,其中每个模拟都是 [Simulation] 类的实例;
  • 第 39 行:[Serializer] 构造函数的第二个参数也是一个数组:它包含序列化过程中使用的所有编码器(XML、JSON、CSV 等);
  • 第 39 行:此处仅有一个编码器,类型为 [JsonEncoder]。原本使用无参构造函数就已足够。此处向构造函数传递了一个 [JsonEncode] 参数,仅用于传递 JSON 编码选项;
  • 第 39 行:[JsonEncode] 构造函数参数是一个选项数组。此处我们使用 [JSON_UNESCAPED_UNICODE] 选项,要求 JSON 字符串中的 UTF-8 字符以原生形式呈现,而非进行“转义”;
  • 第 42 行:使用前面的序列化器将 HTTP 响应正文序列化为 JSON;
  • 第 44 行:我们添加了告知客户端我们将发送 JSON 的 HTTP 头;
  • 第 46 行:请求父类将响应发送给客户端;
  • 第 48–50 行:我们记录 JSON 响应;

父类 [ParentResponse] 的代码如下:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Response;
 
class ParentResponse {
 
  // int $statusCode: HTTP response status code
  // string $content: the body of the response to be sent
  // depending on the case, this is a jSON, XML, HTML string
  // array $headers: HTTP headers to be added to the response
 
  public function sendResponse(
    int $statusCode,
    string $content,
    array $headers): void {
 
    // preparing the server's text response
    $response = new Response();
    $response->setCharset("utf-8");
    // status code
    $response->setStatusCode($statusCode);
    // headers
    foreach ($headers as $text => $value) {
      $response->headers->set($text, $value);
    }
    // we send the answer
    $response->setContent($content);
    $response->send();
  }
}

注释

  • 第 10–13 行:[send] 方法中三个参数的含义;
  • 第 17 行:请注意响应正文的类型为 [string],因此已准备好发送(第 30 行);
  • 第 22 行:响应将包含 UTF-8 字符;
  • 第 24 行:响应的 HTTP 状态码;
  • 第 26–28 行:添加调用代码提供的 HTTP 头部;
  • 第 30–31 行:将响应发送给客户端;

我们已详细说明了 JSON 响应的整个生命周期。后续将不再赘述。您只需记住 [InterfaceResponse] 接口的声明:


interface InterfaceResponse {
 
  // Request $request : requête en cours de traitement
  // Session $session: the web application session
  // array $config: application configuration
  // int statusCode: HTTP response status code
  // array $content: server response
  // array $headers: HTTP headers to be added to the response
  // Logger $logger: the logger for writing logs
  
  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void;
}

主控制器 [main.php] 每次请求向客户端发送响应时,都必须遵循此签名。

23.9.5. 测试 [Postman] – 2

我们将 [config.json] 文件修改如下:

Image

  • [1] 中,我们指定日志文件为 [Logs],这是一个文件夹 [2]。因此,创建 [Logs] 文件应会失败;

我们创建一个新的 [Postman] 请求 [3],命名为 [error-133]

Image

  • [2-4]:我们定义了与前两次测试相同的请求;
  • [5-7]:我们成功获取了预期的 JSON 响应;

23.9.6. [main.php] – 3

让我们继续查看主控制器 [main.php]


<?php
 
// strict adherence to declared types of function parameters
declare (strict_types=1);
 
// namespace
namespace Application;

// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
 
// error handling by PHP

 
// log file creation

 
// 1st log
$logger->write("\n---nouvelle requête\n");
// current query
$request = Request::createFromGlobals();
 
// session
$session = new Session();
$session->start();
// error list
$erreurs = [];
$erreur = FALSE;
// we manage the requested action
if (!$request->query->has("action")) {
  $erreurs[] = "paramètre [action] manquant";
  $erreur = TRUE;
  $état = 101;
  $action = "";
} else {
  // memorize the action
  $action = strtolower($request->query->get("action"));
}
// we log the action
$logger->write("action [$action] demandée\n");
 
// does the action exist?
if (!$erreur && !array_key_exists($action, $config["actions"])) {
  $erreurs[] = "action [$action] invalide";
  $erreur = TRUE;
  $état = 102;
}
 
// the session type must be known before performing certain actions
if (!$erreur && !$session->has("type") && $action !== "init-session") {
  $erreurs[] = "pas de session en cours. Commencer par action [init-session]";
  $erreur = TRUE;
  $état = 103;
}
 
// some actions require authentication
if (!$erreur && !$session->has("user") && $action !== "authentifier-utilisateur" && $action !== "init-session") {
  $erreurs[] = "action demandée par utilisateur non authentifié";
  $erreur = TRUE;
  $état = 104;
}
 
// mistakes?
if ($erreurs) {
  // we prepare the answer without sending it  
  $statusCode = Response::HTTP_BAD_REQUEST;
  $content = ["réponse" => $erreurs];
  $headers = [];
} else {
  // ---------------------------
  // execute the action using its controller
  $controller = __NAMESPACE__ . $config["actions"][$action];
  $logger->write("contrôleur : $controller\n");
  list($statusCode, $état, $content, $headers) = (new $controller())->execute($config, $request, $session);
}
 
// --------------------- we send the answer
// cas de l'erreur fatale HTTP_INTERNAL_SERVER_ERROR
// send an e-mail to the administrator if you can
if ($statusCode === Response::HTTP_INTERNAL_SERVER_ERROR && $config['adminMail'] != NULL) {
  $infosMail = $config['adminMail'];
  $infosMail['message'] = json_encode($content, JSON_UNESCAPED_UNICODE);
  $sendAdminMail = new SendAdminMail($infosMail, $logger);
  $sendAdminMail->send();
}
// the answer depends on the session type
if ($session->has("type")) {
  // the session type is in the session
  $type = $session->get("type");
} else {
  // if no type in session, then the default response is jSON
  $type = "json";
}
// we add the keys [action, state] to the controller response
$content = ["action" => $action, "état" => $état] + $content;
// instantiate the [Response] object responsible for sending the response to the client
$response = __NAMESPACE__ . $config["types"][$type]["response"];
(new $response())->send($request, $session, $config, $statusCode, $content, $headers, $logger);
 
// the reply has been sent - resources are released
$logger->close();
exit;

注释

  • 一旦完成初始检查并确认可以继续,主控制器便专注于处理所请求的操作:它必须满足某些条件;
  • 第 21 行:我们记录收到新请求的事实。此前无法执行此操作,因为我们无法确定日志文件是否有效;
  • 第 23 行:我们将客户端请求中的所有信息封装到 Symfony [Request] 对象中;
  • 第 26 行:我们启动一个新会话,或者如果已有会话则检索现有会话;
  • 第 27 行:激活会话;
  • 第 29 行:一个错误消息数组;
  • 第 30 行:一个布尔值,用于在测试运行时告知是否发生了错误;
  • 第 32 行:URL 中必须包含 [action] 参数,格式为 [main.php?action=someAction]。随后,[action] 参数会被纳入 [$request→query] 参数中;
  • 第 33–36 行:处理 URL 中缺少 [action] 参数的情况。将错误记录到日志中,并为其分配状态码 [101]
  • 第 39 行:如果 URL 中包含 [action] 参数,则将其存储;
  • 第 42 行:记录操作类型;
  • 第 45–49 行:若存在 [action] 参数,则必须有效。所有授权操作均定义在关联数组 [$config["actions"]] 中;
  • 第 46–48 行:如果操作无效,则记录错误并分配状态码 [102]
  • 第 52–56 行:当前操作为有效操作。但仍需满足其他条件。Web 应用程序提供三种响应类型(JSON、XML、HTML)。该类型由 [init-session] 操作设定。该操作将会话类型存入 [type] 键中;
  • 第 52 行:在 [init-session] 操作之外,任何其他操作都必须在会话中包含 [type] 键;
  • 第 53–55 行:若不满足此条件,则记录错误并赋予状态 [103]
  • 第 58–63 行:在 [init-session] [authenticate-user] 操作之外,所有其他操作必须在身份验证之后进行。这通过 [authenticate-user] 操作实现,该操作在身份验证成功时,会在会话中添加一个 [user] 键;
  • 第 59 行:如果操作既不是 [init-session] 也不是 [authenticate-user],且会话中不存在 [user] 键,则会发生错误;
  • 第 60–62 行:将错误记录到日志中,并赋予状态 [104]
  • 第 66–71 行:检查数组 [$errors] 是否不为空。如果是,则表示请求的操作或其执行上下文有误;
  • 第 68–70 行:准备发送给客户端的响应,但暂不发送;
  • 第 68 行:HTTP 状态码;
  • 第 69 行:响应正文;
  • 第 70 行:要添加到响应中的头部;此处无;
  • 第 73 行:我们有一个有效的操作。我们将请求其(二级)控制器进行处理;
  • 第 74 行:我们构造要执行的控制器类的名称。[__NAMESPACE__] 是当前所在的命名空间,此处为 [Application](第 7 行);
  • 二级控制器类的名称位于 [config.json] 文件中:

"actions":
            {
                "init-session": "\\InitSessionController",
                "authentifier-utilisateur": "\\AuthentifierUtilisateurController",
                "calculer-impot": "\\CalculerImpotController",
                "lister-simulations": "\\ListerSimulationsController",
                "supprimer-simulation": "\\SupprimerSimulationController",
                "fin-session": "\\FinSessionController",
                "afficher-calcul-impot": "\\AfficherCalculImpotController"
            },

每个操作都对应一个二级控制器。如果操作是 [authenticate-user],那么第 74 行中的变量 [$controller] 的值将是 [Application/AuthentifierUtilisateurController]

  • 第 75 行:我们记录二级控制器的名称,以便在开发过程中进行验证;
  • 第 76 行:执行二级控制器。稍后我们将再次讨论二级控制器;
  • 第 76 行:所有二级控制器返回的结果类型均为数组:
    • 数组的首个元素 [$statusCode] 是待发送响应的 HTTP 状态码;
    • 第二个元素 [$state] 是控制器执行后的应用程序状态;
    • 第三个元素 [$content] 是一个关联数组仅包含一个键 [response],即要发送给客户端的响应正文;
    • 第四个元素 [$headers] 是一个数组,包含要添加到发送给客户端的响应中的 HTTP 头部;
  • 第 79 行:我们到达此处:
    • 要么是因为发生了错误(第 68–70 行);
    • 或者在执行完控制器之后(第 72–76 行);
    • 无论哪种情况,用于构建客户端响应所需的元素 [$statusCode, $status, $content, $headers] 均已确定;
  • 第 82–87 行:处理状态码 [500 Internal Server Error] 的特例。如果控制器设置了此状态码,则表示应用程序无法正常运行。例如,当使用的数据库管理系统(DBMS)尚未启动或不再响应时,税费计算就会出现这种情况。 此时将向应用程序管理员发送一封电子邮件以通知其情况。我们在此不对此代码进行具体说明。[SendAdminMail] 类的用法已在相关章节中介绍(参见链接部分);
  • 第 89–95 行:我们确定 Web 应用程序的类型 [jSON, XML, HTML]。如果 [init-session] 操作执行成功,该类型会存储在与 [type] 键关联的会话中(第 91 行)。如果未成功,则我们为响应任意设置一个类型,即 JSON 类型(第 94 行);
  • 第 97 行:[$content] 是一个数组,包含一个名为 [response] 的键及其对应的值,即要发送给客户端的响应正文。向其中添加了 [action] [status] 两个键。添加 [action] 键是为了便于在 [logs.txt] 文件中追踪日志。而 [status] 键有两个作用:
    • 它将使 JSON 和 XML 客户端能够了解已执行的操作将 Web 应用程序置于何种状态;
    • 对于 HTML 响应,它将使我们能够选择要发送给客户端浏览器的 HTML 视图;
  • 第 99 行:我们选择 [Response] 类类型来执行,以便将响应发送给客户端;

我们在上一节中已经介绍了 [JsonResponse] 类。它实现了 [InterfaceResponse] 接口,并继承了 [ParentResponse] 类。另外两个类 [XmlResponse][HtmlResponse] 也是如此。

响应类被归类在 [Responses] 文件夹中:

Image

所有这些类都实现了 [InterfaceResponse] 接口,该接口也在链接的章节中进行了介绍:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
interface InterfaceResponse {
 
  // Request $request : requête en cours de traitement
  // Session $session: the web application session
  // array $config: application configuration
  // int statusCode: HTTP response status code
  // array $content: server response
  // array $headers: HTTP headers to be added to the response
  // Logger $logger: the logger for writing logs
  
  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void;
}

该接口仅有一个方法 [send],负责将响应发送给客户端。该方法包含第 11–17 行中描述的 7 个参数。[Responses] 文件夹中的所有类和接口均位于 [Application] 命名空间下(第 3 行)。

让我们回到 [main.php] 中的代码:



// on ajoute les clés [action, état] à la réponse du contrôleur
$content = ["action" => $action, "état" => $état] + $content;
// on instancie l'objet [Response] chargée d'envoyer la réponse au client
$response = __NAMESPACE__ . $config["types"][$type];
(new $response())->send($request, $session, $config, $statusCode, $content, $headers, $logger);
 
// la réponse a été envoyée - on libère les ressources
$logger->close();
exit;
  • 第 5 行:我们实例化了与应用程序类型匹配的 [Response] 类。这些类在 [config.json] 文件中定义如下:

"types": {
        "json": "\\JsonResponse",
        "html": "\\HtmlResponse",
        "xml": "\\XmlResponse"
    },
  • 第 5 行:类名前缀为其命名空间;
  • 第 6 行:实例化 [Response] 类,并调用其 [send] 方法,传入该方法预期的 7 个参数。这些参数来自 [InterfaceResponse] 接口,所有响应类都实现了该接口。这将响应发送给客户端;
  • 第 9 行:关闭日志文件;
  • 第 10 行:主控制器已完成其工作;

23.9.7. [Postman] 测试 – 3

我们将针对 URL 的 [action] 参数测试各种错误情况。

Image

  • [1] 中
    • [error-101]:URL 中缺少 [action] 参数的情况;
    • [error-102]:URL 中存在 [action] 参数但未被识别的情况;
    • [error-103]:URL 中包含 [action] 参数且已被识别,但未定义预期的响应类型 [json, xml, html]

每个请求均已执行。我们直接呈现结果:

上文:

  • [2-4] 中,URL [4] 中不包含 [action] 参数的请求;
  • [5-7] 中,JSON 结果;

Image

上图:

  • [5-9] 中,包含无效 [action] 参数的请求;
  • [10-13] 中,JSON 响应;

Image

上图:

  • [14-19] 中,识别出操作但尚未指定类型(json、xml、html);
  • [20-23] 中,服务器的 JSON 响应;

23.10. 辅助控制器

每个操作由 [Controllers] 文件夹中的某个控制器执行:

Image

Image

在上述应用程序的总体架构中,辅助控制器位于 [2a] 目录下。

每个控制器都实现了以下 [InterfaceController] 接口:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
interface InterfaceController {
 
  // $config is the application configuration
  // traitement d'une requête Request
  // session and can modify it
  // $infos is additional information specific to each controller
  
  // renders an array [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos=NULL): array;
}

注释

  • 所有二级控制器都是通过第 17 行中的 [execute] 方法执行的。我们将主控制器中的已知信息传递给该方法:
    • 第 18 行:[array $config],封装了应用程序配置;
    • 第 19 行:[Request $request],即当前正在处理的 HTTP 请求;
    • 第 20 行:[Session $session],即 Web 应用程序的当前会话;
    • 第 21 行:[array $infos=NULL],这是一个额外的信息数组,用于在方法的前三个参数不足时为控制器提供补充信息。在本应用中,该参数从未被使用过。其包含仅作为预防措施;
  • 第 21 行:[execute] 方法返回数组 [$statusCode, $status, $content, $headers]
    • [int $statusCode]:HTTP 响应状态码;
    • [int $state]:执行结束时应用程序的状态;
    • [array $content]:一个关联数组 [response=>result],其中 [result] 可以是任意类型:这是控制器生成的结果,经字符串序列化后将发送给客户端;
    • [array $headers]:将包含在服务器 HTTP 响应中的 HTTP 头部列表;

每个二级控制器由主控制器中的以下代码调用:


 // on exécute l'action à l'aide de son contrôleur
 $controller = __NAMESPACE__ . $config["actions"][$action];
 list($statusCode, $état, $content, $headers) = (new $controller())->execute($config, $request, $session);

在第 3 行,我们可以看到 [execute] 方法的第四个参数 [array $infos=NULL] 并未被使用。

23.11. 操作

接下来,我们将回顾 Web 服务可能执行的各种操作:

操作
角色
执行上下文
init-session
用于设置所需响应的类型(json、xml、html)
GET请求 main.php?action=init-session&type=x
可随时发送
authenticate-user
授权或拒绝用户的登录
POST 请求 main.php?action=authenticate-user
请求必须包含两个提交参数 [user, password]
仅当已知会话类型(json、xml、html)时才能发出
计算税款
执行税费计算模拟
main.php 发送 POST 请求,请求路径为 main.php?action=calculate-tax
请求必须包含三个提交参数 [married, children, salary]
仅当已知会话类型(json、xml、html)且用户已通过身份验证时才能发出
list-simulations
请求查看自会话开始以来已执行的模拟列表
GET 请求 main.php?action=list-simulations
该请求不接受任何其他参数
只有在已知会话类型(json、xml、html)且用户已通过身份验证的情况下才能发出
delete-simulation
从模拟列表中删除一个模拟
GET 请求 main.php?action=list-simulations&number=x
该请求不接受任何其他参数
仅在已知会话类型(json、xml、html)且用户已通过身份验证时才能发出
end-session
结束模拟会话。
从技术上讲,旧的 Web 会话将被删除,并创建一个新的会话
只有在已知会话类型(json、xml、html)且用户已通过身份验证的情况下才能发出

所有二级控制器均按相同方式处理:

  • 它们会检查参数。URL中包含的参数位于[Request→query]对象中,而通过POST请求提交的参数则位于[Request→request]对象中;
  • 控制器类似于一个检查参数有效性的函数或方法。但对于控制器而言,情况要稍显复杂:
    • 预期的参数可能缺失;
    • 预期参数均为字符串,而函数可以指定其参数的类型。如果预期参数是数字,则必须验证该参数字符串确实是数字;
    • 在验证预期参数存在且语法正确后,还必须验证它们在当前执行上下文中是否有效。该上下文存在于会话中。身份验证示例就是执行上下文的一个例子。某些操作仅应在客户端通过身份验证后才被处理。通常,会话中的某个键会指示身份验证是否已完成;
    • 一旦前面的检查完成,二级控制器即可继续处理。这个参数验证过程非常重要。在应用程序生命周期的任何阶段,我们都不能接受客户端向我们发送任意数据。我们必须对应用程序的生命周期保持完全控制;
    • 完成工作后,辅助控制器会返回主控制器(即调用它的控制器)所期望的数组 [$statusCode, $state, $content, $headers]

接下来我们将回顾各个控制器——换言之,即驱动 Web 应用程序生命周期的各种操作。

23.11.1. [init-session] 操作

[init-session] 操作由以下 [InitSessionController] 处理:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
 
class InitSessionController implements InterfaceController {
 
  // $config is the application configuration
  // traitement d'une requête Request
  // session and can modify it
  // $infos is additional information specific to each controller
  
  // renders an array [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {
 
    // you must have a GET and a single parameter other than [action]
    $method = strtolower($request->getMethod());
    $erreur = $method !== "get" || $request->query->count() != 2;
    if ($erreur) {
      $état = 701;
      $message = "méthode GET exigée avec paramètres [action, type] dans l'URL";
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // retrieve the GET parameters
    $erreur = FALSE;
    // type
    if (!$request->query->has("type")) {
      $erreur = TRUE;
      $état = 702;
      $message = "paramètre [type] manquant";
    } else {
      $type = strtolower($request->query->get("type"));
    }
    // type verification
    if (!$erreur && !array_key_exists($type, $config["types"])) {
      $erreur = TRUE;
      $état = 703;
      $message = "paramètre type [$type] invalide";
    }
    // mistake?
    if ($erreur) {
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // put the session type in the session
    $session->set("type", $type);
    // message of success
    $message = "session démarrée avec type [$type]";
    $état = 700;
    return [Response::HTTP_OK, $état, ["réponse" => $message], []];
  }
 
}

评论

  • 我们预期收到 [GET main.php?action=init-session&type=xxx] 请求
  • 第 25-26 行:我们检查请求是否为带有两个 URL 参数的 GET 请求;
  • 第 27–31 行:如果不符合此条件,则记录错误并将响应 [$statusCode, $status, $content, $headers] 发送给主控制器;
  • 第 35-39 行:我们检查 URL 中是否包含 [type] 参数。如果不包含,则记录错误;
  • 第 40 行:记录会话类型;
  • 第 43–47 行:检查会话类型是否为指定值之一(json、xml、html)。若非如此,则记录错误;
  • 第 49–51 行:若发生错误,则向主控制器发送结果 [$statusCode, $status, $content, $headers]
  • 第 53 行:将会话类型存储在 Web 应用程序会话中;
  • 第 55–57 行:控制器已完成工作。向主控制器发送成功响应 [$statusCode, $status, $content, $headers]

让我们回顾一下主控制器如何处理来自次级控制器的响应:


// erreurs ?
if ($erreurs) {
  // on prépare la réponse sans l'envoyer  
  $statusCode = Response::HTTP_BAD_REQUEST;
  $content = ["réponse" => $erreurs];
  $headers = [];
} else {
  // ---------------------------
  // on exécute l'action à l'aide de son contrôleur
  $controller = __NAMESPACE__ . $config["actions"][$action];
  $logger->write("contrôleur : $controller\n");
  list($statusCode, $état, $content, $headers) = (new $controller())->execute($config, $request, $session);
}
 
// --------------------- on envoie la réponse
// cas de l'erreur fatale HTTP_INTERNAL_SERVER_ERROR
// on envoie un mail à l'administrateur si on peut
if ($statusCode === Response::HTTP_INTERNAL_SERVER_ERROR && $config['adminMail'] != NULL) {
  $infosMail = $config['adminMail'];
  $infosMail['message'] = json_encode($content, JSON_UNESCAPED_UNICODE);
  $sendAdminMail = new SendAdminMail($infosMail, $logger);
  $sendAdminMail->send();
}
// la réponse dépend du type de la session
if ($session->has("type")) {
  // le type de session est dans la session
  $type = $session->get("type");
} else {
  // si pas de type dans session, alors par défaut ce sera une réponse en jSON
  $type = "json";
}
// on ajoute les clés [action, état] à la réponse du contrôleur
$content = ["action" => $action, "état" => $état] + $content;
// on instancie l'objet [Response] chargée d'envoyer la réponse au client
$response = __NAMESPACE__ . $config["types"][$type]["response"];
(new $response())->send($request, $session, $config, $statusCode, $content, $headers, $logger);
 
// la réponse a été envoyée - on libère les ressources
$logger->close();
exit;
  • 第 12 行:主控制器从辅助控制器中获取结果;
  • 第 35-36 行:经过一些检查后,根据当前会话的类型(json、xml、html),通过实例化 [JsonResponse、XmlResponse、HtmlResponse] 中的一个类来发送响应;

接下来,我们将使用 [json] 类型进行 [Postman] 测试,作为模拟会话的一部分。[JsonResponse] 类的功能已在相关章节中介绍。

23.11.2. [Postman] 测试

Image

上文:

  • [2] 中,新增了三个测试;
  • [3-7] 中,[init-session] 操作缺少 [type] 参数;
  • [8-11] 中,服务器的 JSON 响应;

Image

上文:

  • [1-7] 中,[init-session] 操作的 [type] 参数不正确;
  • [8-11] 中,服务器的 JSON 响应;

Image

上文:

  • [1-8] 中,具有 JSON 类型的 [init-session] 操作;
  • [9-12] 中,服务器的 JSON 响应;

23.11.3. [authenticate-user] 操作

[authenticate-user] 操作由以下 [AuthentifierUtilisateurController] 控制器执行:


<?php
 
namespace Application;
 
// symfony dependencies
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
 
class AuthentifierUtilisateurController implements InterfaceController {
 
  // $config is the application configuration
  // traitement d'une requête Request
  // session and can modify it
  // $infos is additional information specific to each controller
  // renders an array [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {
 
    // you must have a POST and a single GET parameter
    $method = strtolower($request->getMethod());
    $erreur = $method !== "post" || $request->query->count() != 1;
    if ($erreur) {
      $état = 201;
      $message = "méthode POST requise, paramètre [action] dans l'URL, paramètres postés [user,password]";
      // return the result to the main controller
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // retrieve POST parameters
    $erreurs = [];
    // user
    $état = 210;
    if (!$request->request->has("user")) {
      $état += 2;
      $erreurs[] = "paramètre [user] manquant";
    } else {
      $user = $request->request->get("user");
    }
    // password
    if (!$request->request->has("password")) {
      $état += 4;
      $erreurs[] = "paramètre [password] manquant";
    } else {
      $password = trim($request->request->get("password"));
    }
    // mistake?
    if ($erreurs) {
      // return the result to the main controller
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $erreurs], []];
    }
    // verification of user credentials
    // does the user exist?
    $users = $config["users"];
    $i = 0;
    $trouvé = FALSE;
    while (!$trouvé && $i < count($users)) {
      $trouvé = ($user === $users[$i]["login"] && $users[$i]["passwd"] === $password);
      $i++;
    }
    // found?
    if (!$trouvé) {
      // error message
      $message = "Echec de l'authentification [$user, $password]";
      $état = 221;
      // return the result to the main controller
      return [Response::HTTP_UNAUTHORIZED, $état, ["réponse" => $message], []];
    } else {
      // we note in the session that we have authenticated the user
      $session->set("user", TRUE);
      // message of success
      $message = "Authentification réussie [$user, $password]";
      $état = 200;
      // return the result to the main controller
      return [Response::HTTP_OK, $état, ["réponse" => $message], []];
    }
  }
 
}

评论

  • 我们预期收到一个包含两个参数 [user, password] [POST main.php?action=authentifier-utilisateur] 请求;
  • 第 24–25 行:我们验证 URL 中是否包含一个参数的 POST 请求;
  • 第 26–31 行:如果出现错误,我们会将其记录并向主控制器返回结果 [$statusCode, $status, $content, $headers]
  • 第 36–39 行:检查提交值中是否包含 [user] 参数。若不存在,则记录错误;
  • 第 43–45 行:检查提交数据中是否包含 [password] 参数。若不存在,则记录错误;
  • 第 50–53 行:如果提交的值中有任何一个缺失,则向主控制器返回结果 [$statusCode, $status, $content, $headers]
  • 第 56–62 行:检查检索到的 [$user,$password] 配对是否存在于配置文件中的 [$config[‘users’]] 数组中;
  • 第 64–69 行:若不满足此条件,则记录错误。将 HTTP 状态码设置为 [Response::HTTP_UNAUTHORIZED],并将结果 [$statusCode, $status, $content, $headers] 返回给主控制器;
  • 第 72 行:认证成功。通过设置 [user] 键在会话中记录此状态。该键的存在表示认证成功;
  • 第 73–77 行:将成功结果 [$statusCode, $status, $content, $headers] 返回给主控制器;

23.11.4. [Postman] 测试

我们在 JSON 模式下对 [AuthentifierUtilisateurController] 控制器执行 [Postman] 测试;

Image

上文:

  • [1-6]中,[authenticate-user]操作使用了GET请求[2],而实际上需要POST请求;
  • [7-10] 中,服务器返回的 JSON 响应;

让我们将 [2] 处的 GET 请求替换为 POST 请求,且不在响应正文 [7] 中包含任何参数:

Image

上文:

  • [1-7] 中,[7] 中发送的无参数 POST 请求;
  • [8-11] 中,服务器返回的 JSON 响应;

现在,让我们在请求正文中添加一个参数 [password] [4]

Image

上文:

  • [1-6] 中,是一个带有 [password] 参数的 POST 请求 [2],该参数通过 POST 方式提交 [4-6]。POST 参数必须添加到请求正文中 [4]。向服务器提交值有多种方法。我们选择 [x-www-form-urlencoded] 方法 [5]
  • [8-10] 中,显示来自服务器的 JSON 响应;

现在,让我们定义不包含 [password] 参数的 [user] 参数:

Image

上文:

  • [1-7] 中,一个不包含 [password] 参数的 POST 请求 [4-7]
  • [8-11] 中,服务器的 JSON 响应;

现在,让我们设置两个参数 [user, password],但使用会导致认证失败的值:

Image

上文:

  • [1-9] 中,是一个包含错误 [user, password] 参数的 POST 请求;
  • [10-13] 中,服务器返回的 JSON 响应。请注意响应中的状态码 [401 未授权] [10]

现在是一个带有有效凭据的 POST 请求:

Image

上文:

  • [1-9] 中,包含有效凭据 [6-9] 的 POST 请求 [2]
  • [10-13] 中,服务器返回的 JSON 响应。请注意 [10] 中的 HTTP 状态码 [200 OK]

23.11.5. [calculate-tax] 操作

[calculer-impot] 操作由以下 [CalculerImpotController] 控制器处理:


<?php
 
namespace Application;
 
// symfony dependencies
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
// layer alias [dao]
use \Application\ServerDaoWithSession as ServerDaoWithRedis;
 
class CalculerImpotController implements InterfaceController {
 
  // $config is the application configuration
  // traitement d'une requête Request
  // session and can modify it
  // $infos is additional information specific to each controller
  // renders an array [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {
 
    // you must have one GET parameter and three POST parameters
    $method = strtolower($request->getMethod());
    $erreur = $method !== "post" || $request->query->count() != 1;
    if ($erreur) {
      // we note the error
      $message = "il faut utiliser la méthode [post] avec [action] dans l'URL et les paramètres postés [marié, enfants, salaire]";
      $état = 301;
      // return result to main controller
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // retrieve POST parameters
    $erreurs = [];
    $état = 310;
    // marital status
    if (!$request->request->has("marié")) {
      $état += 2;
      $erreurs[] = "paramètre [marié] manquant";
    } else {
      $marié = trim(strtolower($request->request->get("marié")));
      $erreur = $marié !== "oui" && $marié !== "non";
      if ($erreur) {
        $état += 4;
        $erreurs[] = "valeur [$marié] invalide pour le paramètre [marié]";
      }
    }
    // the number of children
    if (!$request->request->has("enfants")) {
      $état += 8;
      $erreurs[] = "paramètre [enfants] manquant";
    } else {
      $enfants = trim($request->request->get("enfants"));
      $erreur = !preg_match("/^\d+$/", $enfants);
      if ($erreur) {
        $état += 9;
        $erreurs[] = "valeur [$enfants] invalide pour le paramètre [enfants]";
      }
    }
    // we recover the annual salary
    if (!$request->request->has("salaire")) {
      $erreurs[] = "paramètre [salaire] manquant";
      $état += 16;
    } else {
      $salaire = trim($request->request->get("salaire"));
      $erreur = !preg_match("/^\d+$/", $salaire);
      if ($erreur) {
        $état += 17;
        $erreurs[] = "valeur [$salaire] invalide pour le paramètre [salaire]";
      }
    }
    // mistake?
    if ($erreurs) {
      // return result to main controller
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $erreurs], []];
    }
 
    // we have everything you need to work
    // Redis
    \Predis\Autoloader::register();
    try {
      // customer [predis]
      $redis = new \Predis\Client();
      // connect to the server to see if it's there
      $redis->connect();
    } catch (\Predis\Connection\ConnectionException $ex) {
      // it didn't go well
      // return result with error to main controller
      $état = 350;
      return [Response::HTTP_INTERNAL_SERVER_ERROR, $état,
        ["réponse" => "[redis], " . utf8_encode($ex->getMessage())], []];
    }
 
    // we have valid parameters
    // creation of the [dao] layer
    if (!$redis->get("taxAdminData")) {
      try {
        // retrieve tax data from the database
        $dao = new ServerDaoWithRedis($config["databaseFilename"], NULL);
        // put the recovered data into redis
        $redis->set("taxAdminData", $dao->getTaxAdminData());
      } catch (\RuntimeException $ex) {
        // it didn't go well
        // return result with error to main controller
        $état = 340;
        return [Response::HTTP_INTERNAL_SERVER_ERROR, $état,
          ["réponse" => utf8_encode($ex->getMessage())], []];
      }
    } else {
      // tax data are taken from the [application] scope memory
      $arrayOfAttributes = \json_decode($redis->get("taxAdminData"), true);
      $taxAdminData = (new TaxAdminData())->setFromArrayOfAttributes($arrayOfAttributes);
      // istanciation of the [dao] layer
      $dao = new ServerDaoWithRedis(NULL, $taxAdminData);
    }
    // creation of the [business] layer
    $métier = new ServerMetier($dao);
 
    // we have everything we need to work - tax calculation
    $résultat = $métier->calculerImpot($marié, (int) $enfants, (int) $salaire);
    // we add the simulation just run to the session
    $simulation = new Simulation();
    $résultat = ["marié" => $marié, "enfants" => $enfants, "salaire" => $salaire] + $résultat;
    $simulation->setFromArrayOfAttributes($résultat);
    // is there a list of in-session simulations?
    if (!$session->has("simulations")) {
      $simulations = [];
    } else {
      $simulations = $session->get("simulations");
    }
    // add simulation to simulation list
    $simulations[] = $simulation;
    // simulations are put back into session
    $session->set("simulations", $simulations);
    // return result to main controller
    $état = 300;
    return [Response::HTTP_OK, $état, ["réponse" => $résultat], []];
  }
 
}

评论

  • 预期的请求为 [POST main.php?action=calculate-tax],包含三个提交的参数 [married, children, salary]
    • [married] 必须为 [yes] 或 [no]
    • [children, salary] 必须是正整数或零;
  • 第 26–27 行:我们验证 URL 中是否包含单个参数的 POST 请求;
  • 第 28–34 行:如果不符合此条件,则向主控制器发送错误结果;
  • 第 36 行:我们将错误信息累积到数组 [$errors] 中;
  • 第 39–41 行:检查 [married] 参数是否存在。若不存在,则记录该错误;
  • 第 43–49 行:检查 [married] 参数的值是否为 [yes] 或 [no]。若非如此,则记录错误;
  • 第 51–54 行:检查 [children] 参数是否存在。若不存在,则记录错误;
  • 第 55–61 行:检查 [children] 参数的值是否为正数或零。若不符合,则记录错误;
  • 第 63–66 行:检查 [salary] 参数是否存在。如果不存在,则记录错误;
  • 第 67–72 行:检查 [salary] 参数的值是否为正数或零。若不符合条件,则记录错误;
  • 第 75–78 行:如果 [$errors] 数组不为空,则表示发生了错误。我们将错误数组包含在响应中,并将结果返回给主控制器;
  • 第 80 行:参数有效。我们可以计算税额。为此,我们需要构建懂得如何执行此计算的 [dao] [business] 层;
  • 第 82–94 行:我们创建一个 [Redis] 客户端;
  • 第 88–94 行:如果无法连接到 [Redis] 服务器,则向客户端发送 [500 Internal Server Error] 状态码;
  • 第 98 行:检查 [Redis] 服务器是否存在 [taxAdminData] 键。该键代表税务管理数据。若该键不存在,则必须从数据库中检索税务数据;
  • 第 101 行:当需要从数据库中检索税务数据时,构建 [dao] 层。[ServerDaoWithRedis] 类已在相关章节中进行过说明;
  • 第 103 行:从数据库检索到的数据将以键 [taxAdminData] 存储在 [Redis] 中;
  • 第 104–110 行:若数据库查询失败,将记录 [dao] 层返回的错误,并将其包含在发回主控制器的结果中;
  • 第 109 行:[PDO] 层返回的错误消息采用 [iso-8859-1] 编码,此处将其转换为 [utf-8] 编码;
  • 第 111–117 行:如果 [Redis] 存储中存在 [taxAdminData] 键,则将税务数据直接传递给 [DAO] 层的构造函数;
  • 第 119 行:创建 [business] 层。关于 [ServerMetier] 类的描述请参见链接部分;
  • 第 124–126 行:利用计算出的税额,创建了一个 [Simulation] 对象。[Simulation] 类封装了模拟的数据,已在链接部分中描述;
  • 第 128–132 行:刚构建的模拟必须添加到已计算模拟的列表中。该列表位于会话中,除非尚未进行任何模拟;
  • 第 133–136 行:将该模拟添加到模拟列表中,并将该列表返回给会话;
  • 第 137–139 行:将结果返回给主控制器;

23.11.6. [Postman] 测试

我们在 JSON 模式下对 [CalculerImpotController] 控制器执行 [Postman] 测试;

Image

上文:

  • [1-7]中,我们发送的是[GET]请求而非[POST]请求
  • [8-11] 中,服务器返回的 JSON 响应;

现在,让我们使用 [POST] 方法,尝试带或不带提交参数的情况,以及提交无效参数的情况:

Image

上文:

  • 我们发起一个 [POST] 请求 [2],其中包含无效的提交参数 [6-11] [married, children, salary]。您可以通过取消勾选 [16] 中的复选框来省略其中一个参数。这将使您能够测试不同的场景。在上图截图中,这三个参数均存在且全部无效;
  • [12-15] 中,显示服务器的 JSON 响应;

现在,让我们取消勾选这三个提交参数中的两个:

Image

上文,

  • [5-8] 中,仅提交了 [salary] 参数,而且该参数无效;
  • [9-11]中,是来自服务器的JSON结果;

现在让我们使用有效的参数进行税费计算:

Image

上文:

  • [11-18] 中,包含有效参数 [6-8] 的请求;
  • [12-14] 中,服务器的 JSON 响应;

23.11.7. [lister-simulations] 操作

[lister-simulations] 操作由以下二级控制器 [ListerSimulationsController] 处理:


<?php
 
namespace Application;
 
// symfony dependencies
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
 
class ListerSimulationsController {
 
  // $config is the application configuration
  // traitement d'une requête Request
  // useful session and can modify it
  // $infos is additional information specific to each controller
  // renders an array [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {
 
    // you must have a single parameter GET
    $method = strtolower($request->getMethod());
    $erreur = $method !== "get" || $request->query->count() != 1;
    if ($erreur) {
      $état = 501;
      $message = "GET requis, avec l'unique paramètre [action] dans l'URL";
      // return an error result to the main controller
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // retrieve the list of simulations in the session
    if (!$session->has("simulations")) {
      $simulations = [];
    } else {
      $simulations = $session->get("simulations");
    }
    // a successful result is returned to the main controller
    $état = 500;
    return [Response::HTTP_OK, $état, ["réponse" => $simulations], []];
  }
 
}

评论

  • request [GET main.php?action=list-simulations];
  • 第 24–25 行:我们检查是否收到一个带有单个参数的 GET 请求;
  • 第 26–31 行:如果不符合此条件,则向主控制器返回错误结果;
  • 第 33-37 行:若会话中存在模拟列表(第 36 行),则从中检索该列表;否则列表为空(第 34 行);
  • 第 39-40 行:将模拟列表返回给主控制器;

23.11.8. 测试 [Postman]

我们将创建两个测试,一个用于错误情况,另一个用于成功情况。

Image

上文:

  • [1-8] 中,我们在 URL [3, 7-8] 中添加了一个额外参数 [param1] 并发起 [GET] 请求;
  • [9-12] 中,显示了服务器的 JSON 响应;

现在让我们发送一个有效的请求:

Image

上文:

  • [1-5] 中,这是一个有效的请求;

该请求的结果如下:

Image

  • [3-6] 中,是服务器的 JSON 响应。在此测试之前,已多次运行 [Postman] [calculate-tax-300] 测试,以在服务器的 Web 会话中创建模拟数据;

23.11.9. [delete-simulation] 操作

[delete-simulation] 操作由以下二级控制器 [DeleteSessionController] 处理:


<?php
 
namespace Application;
 
// symfony dependencies
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
 
class SupprimerSimulationController {
 
  /// $config is the application configuration
  // traitement d'une requête Request
  // useful session and can modify it
  // $infos is additional information specific to each controller
  // renders an array [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {
 
    // you must have two GET parameters
    $method = strtolower($request->getMethod());
    $erreur = $method !== "get" || $request->query->count() != 2;
    $état = 600;
    if ($erreur) {
      $état += 2;
      $message = "GET requis, avec les paramètres [action, numéro]";
    }
    // parameter [number] must exist
    if (!$erreur) {
      $état += 4;
      $erreur = !$request->query->has("numéro");
      if ($erreur) {
        $message = "paramètre [numéro] manquant";
      }
    }
    // parameter [number] must be valid
    if (!$erreur) {
      $état += 8;
      $numéro = $request->query->get("numéro");
      $erreur = !preg_match("/^\d+$/", $numéro);
      if ($erreur) {
        $message = "paramètre [$numéro] invalide";
      }
    }
    // parameter [number] must be in the range [0,n-1]
    // if n is the number of simulations
    if (!$erreur) {
      $numéro = (int) $numéro;
      $erreur = !$session->has("simulations");
      if (!$erreur) {
        $simulations = $session->get("simulations");
        $erreur = $numéro < 0 || $numéro >= count($simulations);
      }
      if ($erreur) {
        $état += 16;
        $message = "la simulation n° [$numéro] n'existe pas";
      }
    }
    // mistake?
    if ($erreur) {
      // return the result to the main controller
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // delete the $numéro simulation
    unset($simulations[$numéro]);
    $simulations = array_values($simulations);
    // put the simulations back in the session
    $session->set("simulations", $simulations);
    // we return the list of simulations to the customer
    $état = 600;
    return [Response::HTTP_OK, $état, ["réponse" => $simulations], []];
  }

}

注释

  • request [GET main.php?action=delete-simulation&number=x];
  • 第 24–30 行:我们检查是否收到包含两个参数的 GET 请求;
  • 第 32–38 行:我们检查 URL 参数中是否存在 [number] 参数;
  • 第 40–47 行:我们验证 [number] 参数的值在语法上是否正确;
  • 第 50–61 行:验证模拟 #[number] 是否确实存在。有两种错误情况:
    • 在会话中找不到该模拟列表(第 52 行);
    • 待删除的模拟编号 [number] 在模拟列表中不存在;
  • 第 63–66 行:若发生错误,将向主控制器返回错误结果;
  • 第 68 行:删除模拟 #[number]
  • 第 69 行:[unset] 操作不会改变列表中 [0, n-1] 的索引。为更新这些索引,我们从 [$simulations] 数组中检索值以移除缺失的仿真;
  • 第 71 行:将新的模拟数组放回会话中;
  • 第 73–74 行:将新的模拟列表返回给主控制器;

23.11.10. [Postman] 测试

我们将进行成功和失败测试:

Image

上文:

  • [1-6] 中,不带 [number] 参数的 GET 请求;
  • [7-10] 中,服务器返回的 JSON 响应;

现在是一个包含语法错误的 number 的请求:

Image

上文:

  • [1-5] 中,一个带有无效 [number] 参数 [3, 5] 的 GET 请求;
  • [6-9] 中,服务器的 JSON 响应;

现在是一个包含不存在的模拟编号的请求:

Image

上文:

  • [1-5] 中,一个模拟编号为 100 的请求,该编号在模拟列表中并不存在;
  • [6-9] 中,服务器的 JSON 响应;

现在,我们将从列表中移除模拟 #0,即第一个模拟。首先,让我们使用 [lister-simulations-500] 请求再次获取该列表:

Image

  • [1] 中,目前有 2 个模拟;

我们删除第一个模拟(编号 0):

Image

上图:

  • [1-5] 中,我们删除了模拟 #0 [5]
  • [6-9] 中,是服务器的 JSON 响应。我们可以看到模拟 #0 已被移除;

让我们重复此步骤:

Image

上文:

  • [1] 中,服务器 Web 会话中已无剩余模拟;

23.11.11. [end-session] 操作

[end-session] 操作由以下二级控制器 [FinSessionController] 处理:


<?php
 
namespace Application;
 
// symfony dependencies
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
 
class FinSessionController implements InterfaceController {
 
  // $config is the application configuration
  // traitement d'une requête Request
  // session and can modify it
  // $infos is additional information specific to each controller
  // renders an array [$statusCode, $état, $content, $headers]
 
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {
 
    // you must have a single parameter GET
    $method = strtolower($request->getMethod());
    $erreur = $method !== "get" || $request->query->count() != 1;
    // mistake?
    if ($erreur) {
      $état = 401;
      // result to main controller
      $message = "GET requis avec le seul paramètre [action] dans l'URL";
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
 
    // memorize the session type
    $type = $session->get("type");
    // the current session is invalidated
    $session->invalidate();
    // put the type back in the new session
    $session->set("type", $type);
    // reply sent
    $état = 400;
    // result to main controller
    $content = ["réponse" => "session supprimée"];
    return [Response::HTTP_OK, $état, $content, []];
  }
 
}

评论

  • 请求 [GET main.php?action=end-session];
  • 第 25–33 行:我们验证该操作为带有单个参数 [end-action] 的 GET 请求;
  • 第 38 行:注销当前会话。这将删除其中存储的数据并启动一个新会话;
  • 第 36 行:在结束会话之前,我们存储其类型 [json, xml, html]
  • 第 40 行:将前一个会话的类型设置到新会话中。最后,我们继续使用一个包含单个键 [type] 的新会话;
  • 第 44–45 行:将结果返回给主控制器;

23.11.12. 测试 [Postman]

我们将进行错误测试和成功测试:

Image

上文:

  • [1-5] 中,我们使用 POST [2] 请求结束会话 [5],而非预期的 GET;
  • [6-9] 中,服务器返回的 JSON 响应;

现在,来看一个测试成功的示例。首先,让我们查看在上次测试过程中,客户端 [Postman] 与服务器之间交换的会话 Cookie:

Image

上图:

  • [3]中,客户端[Postman]发送给服务器的会话Cookie;

现在让我们看看服务器在其响应中发送的 HTTP 头部:

Image

上文:

  • [3-4]中,会话 Cookie 并未出现在服务器的响应中。这是正常的。服务器仅在开始新的 Web 会话时发送一次该 Cookie;

现在让我们执行一个有效的 [logout] 操作:

Image

上文:

  • [1-3] 中,一个有效的 [end-session] 操作;
  • [4-7] 中,服务器的 JSON 响应;

让我们来看看服务器响应中发送的 HTTP 头部:

Image

  • [3] 中,服务器发送了 [Set-Cookie] 标头,表明一个新的 Web 会话正在开始;

23.12. 服务器响应类型

23.12.1. 简介

让我们重新审视该应用程序的整体架构:

Image

我们将介绍可能的响应类型 [3a]。这些类型归类在项目的 [Responses] 文件夹中:

Image

我们在相关章节中已介绍了 [JsonResponse] 类。它实现了 [InterfaceResponse] 接口,并继承了 [ParentResponse] 类。另外两个类 [XmlResponse][HtmlResponse] 也是如此。

让我们回顾一下 [InterfaceResponse] 接口的定义:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
interface InterfaceResponse {
 
  // Request $request : requête en cours de traitement
  // Session $session: the web application session
  // array $config: application configuration
  // int statusCode: HTTP response status code
  // array $content: server response
  // array $headers: HTTP headers to be added to the response
  // Logger $logger: the logger for writing logs
  
  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void;
}
  • 第 19–27 行:[InterfaceResponse] 接口有一个 [send] 方法,用于将响应发送给客户端;
  • 第 11–17 行:[send] 方法中各个参数的含义;
  • 第 23–25 行:参数 [$statusCode, $content, $headers] 是应用程序二级控制器返回的标准响应。但响应可能需要额外信息。因此,我们通过前三个参数(第 20–22 行)提供这些信息,使其能够访问有关请求、会话和配置的所有信息;
  • 第 26 行:响应需要 [Logger],因为它将记录发送给客户端的响应;

现在让我们回顾一下 [ParentResponse] 类的代码,该类是这三种响应类型的父类,它抽象了它们的共同点:即向客户端实际发送文本响应:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Response;
 
class ParentResponse {
 
  // int $statusCode: HTTP response status code
  // string $content: the body of the response to be sent
  // depending on the case, this is a jSON, XML, HTML string
  // array $headers: HTTP headers to be added to the response
 
  public function sendResponse(
    int $statusCode,
    string $content,
    array $headers): void {
 
    // preparing the server's text response
    $response = new Response();
    $response->setCharset("utf-8");
    // status code
    $response->setStatusCode($statusCode);
    // headers
    foreach ($headers as $text => $value) {
      $response->headers->set($text, $value);
    }
    // we send the answer
    $response->setContent($content);
    $response->send();
  }
}

注释

  • 第 10–13 行:[send] 方法中三个参数的含义;
  • 第 17 行:请注意响应正文的类型为 [string],因此已准备好发送(第 30 行);
  • 第 22 行:响应将包含 UTF-8 字符;
  • 第 24 行:响应的 HTTP 状态码;
  • 第 26–28 行:添加调用代码提供的 HTTP 头部;
  • 第 30–31 行:将响应发送给客户端;

最后,让我们回顾一下请求将响应发送给客户端的主控制器代码:


// on ajoute les clés [action, état] à la réponse du contrôleur
$content = ["action" => $action, "état" => $état] + $content;
// on instancie l'objet [Response] chargée d'envoyer la réponse au client
$response = __NAMESPACE__ . $config["types"][$type]["response"];
(new $response())->send($request, $session, $config, $statusCode, $content, $headers, $logger);
 
// la réponse a été envoyée - on libère les ressources
$logger->close();
exit;
  • 第 4 行:我们设置要实例化的 [Response] 类的名称;
  • 第 5 行:我们实例化该类,并使用 [send($request, $session, $config, $statusCode, $content, $headers, $logger)] 方法将响应发送给客户端。由于它们都实现了相同的 [InterfaceResponse] 接口,因此不同响应类型的 [send] 方法都具有相同的签名;

23.12.2. [JsonResponse] 类

该类已在相关章节中介绍过。不过,我们在此重现其代码,以便更好地突出这三个响应类的一致性:

[JsonResponse] 类通过以下方式实现了 [InterfaceResponse] 接口:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\Serializer\Encoder\JsonEncode;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
 
class JsonResponse extends ParentResponse implements InterfaceResponse {
 
  // Request $request : requête en cours de traitement
  // Session $session: the web application session
  // array $config: application configuration
  // int statusCode: HTTP response status code
  // array $content: server response
  // array $headers: HTTP headers to be added to the response
  // Logger $logger: the logger for writing logs
 
  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void {
 
    // symfony serializer preparation
    $serializer = new Serializer(
      [
      // required for object serialization
      new ObjectNormalizer()],
      // encoder jSON
      // for options, make OU between the different options
      [new JsonEncoder(new JsonEncode([JsonEncode::OPTIONS => JSON_UNESCAPED_UNICODE]))]
    );
    // serialization jSON
    $json = $serializer->serialize($content, 'json');
    // headers
    $headers = array_merge($headers, ["content-type" => "application/json"]);
    // sending reply
    parent::sendResponse($statusCode, $json, $headers);
    // log
    if ($logger !== NULL) {
      $logger->write("réponse=$json\n");
    }
  }
 
}

评论

  • 第 13 行:该类实现了 [InterfaceResponse] 接口;
  • 第 13 行:该类继承自 [ParentResponse] 类。所有 [Response] 类型都继承自该类。正是这个父类将响应发送给客户端(第 46 行)。由于这段代码是所有 [Response] 类型共有的,因此被提取到了父类中;
  • 第 33–40 行:实例化 [Symfony] 序列化器,它将把服务器响应 [$content] 转换为 JSON 字符串(第 42 行);
  • 第 34–36 行:[Serializer] 构造函数的第一个参数是一个数组。其中放置了对象序列化所需的 [ObjectNormalizer] 类的实例。在本应用中,这涉及一组模拟,其中每个模拟都是 [Simulation] 类的实例;
  • 第 39 行:[Serializer] 构造函数的第二个参数也是一个数组:我们将序列化过程中使用的所有编码器(XML、JSON、CSV 等)放入其中;
  • 第 39 行:此处仅有一个编码器,类型为 [JsonEncoder]。虽然无参构造函数可能已足够,但此处我们向构造函数传递了一个 [JsonEncode] 参数,仅用于传递 JSON 编码选项;
  • 第 39 行:[JsonEncode] 构造函数的参数是一个选项数组。此处我们使用 [JSON_UNESCAPED_UNICODE] 选项,要求 JSON 字符串中的 UTF-8 字符以原生形式呈现,而非进行“转义”;
  • 第 42 行:使用前面的序列化器将 HTTP 响应正文序列化为 JSON;
  • 第 44 行:我们添加了告知客户端我们将发送 JSON 的 HTTP 头;
  • 第 46 行:请求父类将响应发送给客户端;
  • 第 48–50 行:我们记录 JSON 响应;

23.12.3. [XmlResponse] 类

[XmlResponse] 类如下所示实现了 [InterfaceResponse] 接口:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Serializer\Encoder\JsonEncode;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
 
class XmlResponse extends ParentResponse implements InterfaceResponse {
 
  // Request $request : requête en cours de traitement
  // Session $session: the web application session
  // array $config: application configuration
  // int statusCode: HTTP response status code
  // array $content: server response
  // array $headers: HTTP headers to be added to the response
  // Logger $logger: the logger for writing logs
 
  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void {
 
    // symfony serializer preparation
    $serializer = new Serializer(
      // required for object serialization
      [new ObjectNormalizer()],
      [
      // serialization XML
      new XmlEncoder(
        [
        XmlEncoder::ROOT_NODE_NAME => 'root',
        XmlEncoder::ENCODING => 'utf-8'
        ]
      ),
      // serialization jSON
      new JsonEncoder(new JsonEncode([JsonEncode::OPTIONS => JSON_UNESCAPED_UNICODE]))
      ]
    );
    // serialization XML
    $xml = $serializer->serialize($content, 'xml');
    // headers
    $headers = array_merge($headers, ["content-type" => "application/xml"]);
    // sending reply
    parent::sendResponse($statusCode, $xml, $headers);
    // log
    if ($logger !== NULL) {
      // log in jSON
      $log = $serializer->serialize($content, 'json');
      $logger->write("réponse=$log\n");
    }
  }
 
}

评论

  • 第 34–48 行:实例化一个 Symfony 序列化器。构造函数接受两个数组类型的参数;
  • 第 36 行:第一个数组包含一个 [ObjectNormalizer] 类型的实例,用于对象序列化;
  • 第 37–47 行:第二个数组包含用于序列化的编码器。可以通过同一个序列化器配置多种序列化类型;
  • 第 38–44 行:XML 编码器;
  • 第 41 行:设置生成的 XML 代码的根节点。其形式为 <root>[其他 XML 标签]</root>;
  • 第 42 行:编码将使用 UTF-8 字符;
  • 第 46 行:JSON 编码器。该编码器将用于将响应记录到 [logs.txt] 文件中,该文件采用 JSON 格式;
  • 第 50 行:发送给客户端的响应正文被序列化为 XML;
  • 第 52 行:我们在作为参数接收的头部(第 30 行)中添加了 HTTP 头部,告知客户端我们将发送一个 XML 文档;
  • 第 54 行:父类实际将响应发送给客户端;
  • 第 56–60 行:响应的 JSON 日志;

23.12.4. 测试 [Postman]

我们已经在 JSON 中进行了所有可能的错误测试。在 XML 中无需进行额外操作。我们展示两个 XML 响应的示例:

Image

上文:

  • [1-3] 中的 XML 会话启动请求;
  • [4-7] 中,服务器的 XML 响应;

从现在起,所有服务器响应都将采用 XML 格式。我们可以直接复用 [Postman] 中已使用的所有请求,无需进行任何修改,并且每个请求都会收到相应的 XML 响应。例如,让我们进行一次成功的身份验证:

Image

上文:

  • [1-3]中,一个有效的身份验证请求;
  • [4-7] 中,是服务器的 XML 响应;

23.12.5. [HtmlResponse]

当会话类型为 [html] 时,将实例化一个 [HtmlResponse] 类型的对象,用于向客户端发送响应。这将向客户端发送一个 HTML 流,该流取决于处理该操作的二级 控制器返回的状态码。此 [status=>view] 映射在 [config.json] 配置文件中定义如下:


"vues": {
        "vue-authentification.php": [700, 221, 400],
        "vue-calcul-impot.php": [200, 300, 341, 350, 800],
        "vue-liste-simulations.php": [500, 600]
    },
"vue-erreurs": "vue-erreurs.php"

此配置的含义如下:[‘视图名称’ => ‘与该视图关联的状态’]

  • 第 2 行:如果二级控制器返回了数组 [700, 221, 400] 中的某个状态,则必须显示视图 [vue-authentification.php]
  • 第 3 行:如果二级控制器返回数组 [200, 300, 341, 350, 800],则显示视图 [tax-calculation-view.php]
  • 第 4 行:如果二级控制器返回数组 [500, 600],则显示视图 [view-simulation-list.php]
  • 第 6 行:如果二级控制器返回的值不在上述任何数组中,则显示视图 [vue-erreurs.php]

视图位于项目的 [Views] 文件夹中:

Image

[HtmlResponse] 类的代码如下:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Serializer\Encoder\JsonEncode;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
 
class HtmlResponse extends ParentResponse implements InterfaceResponse {
 
  // Request $request : requête en cours de traitement
  // Session $session: the web application session
  // array $config: application configuration
  // int statusCode: HTTP response status code
  // array $content: server response
  // array $headers: HTTP headers to be added to the response
  // Logger $logger: the logger for writing logs
 
  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void {
 
    // symfony serializer preparation
    $serializer = new Serializer(
      [
      // for object serialization
      new ObjectNormalizer()],
      [
      // for jSON serialization of the response log
      new JsonEncoder(new JsonEncode([JsonEncode::OPTIONS => JSON_UNESCAPED_UNICODE]))
      ]
    );
    // the HTML response depends on the status code returned by the controller
    $état = $content["état"];
    // a view corresponds to a state - look for it in the application configuration
    // view list
    $vues = array_keys($config["vues"]);
    $trouvé = false;
    $i = 0;
    // browse the list of views
    while (!$trouvé && $i < count($vues)) {
      // states associated with view n° i
      $états = $config["vues"][$vues[$i]];
      // is the state you're looking for in the states associated with view n° I?
      if (in_array($état, $états)) {
        // the view displayed will be view n° i
        $vueRéponse = $vues[$i];
        $trouvé = true;
      }
      // next view
      $i++;
    }
    // found?
    if (!$trouvé) {
      // if no view exists for the current state of the application
      // render error view
      $vueRéponse = $config["vue-erreurs"];
    }
    // retrieve the HTML view to be displayed in a character string
    ob_start();
    require __DIR__ . "/../Views/$vueRéponse";
    $html = ob_get_clean();
    // we indicate in the headers that we're going to send HTML
    $headers = array_merge($headers, ["content-type" => "text/html"]);
    // the parent class handles the actual sending of the response
    parent::sendResponse($statusCode, $html, $headers);
    // log in jSON of the response without the HTML
    if ($logger !== NULL) {
      // log in jSON of the response from the secondary controller that processed the action
      $log = $serializer->serialize($content, 'json');
      $logger->write("réponse=$log\n");
    }
  }
 
}

评论

  • 第 32–41 行:我们实例化一个 Symfony 序列化器。这是为了处理该操作的控制器返回的 JSON 日志(第 72–82 行);
  • 第 42–57 行:我们根据处理该操作的控制器返回的状态码,在应用程序配置中查找应显示的视图。该状态码存储在 [$content[‘status’]] 中(第 43 行);
  • 第 42–61 行:搜索与该状态对应的视图;
  • 第 62–67 行:若未找到视图,则 HTML 应用程序处于异常状态。我们将在后文中更详细地解释异常状态这一概念。在此情况下,将显示错误视图;
  • 第 68–70 行:解析所选视图的 PHP 代码,并将结果存储在变量 [$html] 中(第 71 行);
  • 这段代码需要稍作说明。假设选定的视图是 [vue-authentification.php],它会显示一个 Web 身份验证表单:
    • 第 69 行:[ob_start] 函数启动了文档中所称的输出缓冲区。由 print、require 及类似操作写入的所有内容——这些内容通常会立即发送给客户端——会被放入输出缓冲区(ob=output buffer)中,而不会发送给客户端;
    • 第 70 行:加载视图 [authentication-view.php];这是一个包含 PHP 代码的动态 HTML 视图。随后发生两件事:
      • [vue-authentification.php] 视图中的 PHP 代码被加载并解释执行。其结果是一个我们将称之为 [vue-authentification.html] 的视图,该视图仅包含 HTML 代码(可能还包含 CSS 和 JavaScript),但不再包含 PHP 代码;
      • 该 HTML 代码通常会被发送给客户端。实际上,PHP 解释器遇到的任何非 PHP 代码的文本都会如此处理。由于启用了输出缓冲,该 HTML 代码会被放入输出缓冲区,而不会被发送给客户端;
    • 第 71 行:[ob_get_clean] 函数执行两项操作:
      • 它将输出缓冲区的内容放入 [$html] 变量中,即之前存入该缓冲区的 [vue-authentification.html] 页面;
      • 清空输出缓冲区。就缓冲区而言,这相当于什么也没发生。此外,客户端仍未收到任何内容;
  • 第 70 行:我们当前正在执行位于 [Responses] 文件夹中的 [HtmlResponse] 类。因此,要查找视图,必须向上移动一层 [..],然后导航至 [Views] 文件夹。 [__DIR__] 是包含当前正在执行的脚本的文件夹的绝对路径;在本例中,该文件夹为 [C:/myprograms/laragon-lite/www/php7/scripts-web/imports/13/Responses]
  • 第 73 行:我们在作为参数接收的 HTTP 头部(第 29 行)中添加一个头部,告知客户端我们将向其发送 HTML;
  • 第 75 行:请求父类实际将响应发送给客户端;
  • 第 77–81 行:将处理当前操作的二级控制器提供的响应 [$content] 以 JSON 格式记录下来;

23.12.6. 测试 [Postman]

要真正测试该会话的 HTML 模式,我们需要检查所有视图。这部分内容我们稍后再进行。接下来我们将执行以下测试:

让我们看看配置文件中的视图列表:


"vues": {
        "vue-authentification.php": [700, 221, 400],
        "vue-calcul-impot.php": [200, 300, 341, 350, 800],
        "vue-liste-simulations.php": [500, 600]
    },
    "vue-erreurs": "vue-erreurs.php"

通过检查执行的 [Postman] 测试,我们可以确定生成上述部分状态码的上下文:

Image

我们可以看到,状态码 [700] 对应于成功的 [init-session] 操作 [2]。上文中我们看到的是 JSON 响应,但响应格式也可能是 XML 或 HTML。本次测试将针对后者进行。根据配置文件,[vue-authentification.php] 视图构成了 HTML 响应。让我们来验证一下。

Image

上文:

  • [1-3] 中,我们初始化了一个 HTML 会话。因此,我们预期会收到 HTML 响应;
  • [4-8] 中,显示来自服务器的 HTML 响应;
  • 标签页 [8] 提供了接收到的 HTML 代码预览;

Image

  • [8-9] 中,显示 HTML 视图的预览;

23.13. HTML Web 应用程序

23.13.1. 视图概述

该 HTML Web 应用程序将使用四个视图:

身份验证视图:

Image

税费计算视图:

Image

模拟列表视图:

Image

意外错误视图:

Image

我们将逐一介绍这些视图。

23.13.2. 身份验证视图

23.13.2.1. 视图概述

身份验证视图如下:

Image

该视图由两个元素组成,我们将它们称为片段:

  • 片段 [1] 由脚本 [v-banner.php] 生成;
  • 片段 [2] 由脚本 [v-authentication.php] 生成;

身份验证视图由以下页面 [vue-authentication.php] 生成:


<?php
// page test data
// encapsulate paged data in $page

?>
 
<!doctype html>
<html lang="fr">
    <head>
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <!-- Bootstrap CSS -->
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
        <title>Application impots</title>
    </head>
    <body>
        <div class="container">
            <!-- bandeau sur 1 ligne et 12 colonnes -->
            <?php require "v-bandeau.php"; ?>
            <!-- formulaire d'authentification sur 9 colonnes -->
            <div class="row">
                <div class="col-md-9">
                    <?php require "v-authentification.php" ?>
                </div>
            </div>  
            <?php
            // if error - displays an error alert
            if ($modèle->error) {
              print <<<EOT
            <div class="row">                
                <div class="col-md-9">
                    <div class="alert alert-danger" role="alert">
                      Les erreurs suivantes se sont produites :
                      <ul>$modèle->erreurs</ul>
                    </div>
                </div>
            </div>
EOT;
            }
            ?>
        </div>
    </body>
</html>

评论

  • 第 7 行:HTML 文档以此行开头;
  • 第 8–44 行:HTML 页面被 <html> 和 </html> 标签所包围;
  • 第 9–16 行:HTML 文档的头部(head);
  • 第 11 行:<meta charset> 标签表明该文档采用 UTF-8 编码;
  • 第 12 行:<meta name='viewport'> 标签设置初始视口显示方式:占据显示屏幕的整个宽度(width),保持初始大小(initial-scale),且不缩放以适应较小的屏幕(shrink-to-fit);
  • 第 14 行:<link rel='stylesheet'> 标签指定了控制视口外观的 CSS 文件。此处我们使用的是 Bootstrap 4.1.3 CSS 框架 [https://getbootstrap.com/docs/4.0/getting-started/introduction/]
  • 第 15 行:title 标签设置页面标题:

Image

  • 第 17–43 行:网页的主体内容被 body 和 /body 标签所包围;
  • 第 18–42 行:<div> 标签界定了页面显示区域。视图中使用的 [class] 属性均指向 Bootstrap CSS 框架。<div class=’container’> 标签界定了 Bootstrap 容器;
  • 第 20 行:我们引入了脚本 [v-banner.php]。该脚本生成页面的横幅 [1]。我们稍后将对此进行说明;
  • 第 22–26 行:<div class=’row’> 标签定义了一个 Bootstrap 行。这些行由 12 个列组成;
  • 第 23 行:<div class=’col-md-9’> 标签定义了一个 9 列的区域;
  • 第 24 行:我们引入脚本 [v-authentification.php],该脚本用于显示页面的身份验证表单 [2]。稍后我们将对此进行说明;
  • 第 27 行:<?php 标签将 PHP 代码插入到 HTML 页面中。该代码在 HTML 页面渲染之前执行,并可对其进行修改;
  • 第 29 行:显示视图中的所有动态数据都将封装在类型为 [stdClass] [$model] 对象中。这是任意选择。我们也可以选择关联数组来实现相同的效果;
  • 第 29 行:若用户输入的凭据不正确,认证将失败。此时,认证视图将重新显示并附带一条错误信息。[$model→error] 属性用于指示是否显示该错误信息;
  • 第 30–39 行:此语法会输出位于 PHP 符号 <<<EOT(第 30 行——EOT=End Of Text 可以替换为任意文本)与第 39 行 EOT 符号(必须与第 30 行使用的符号完全一致)之间的所有文本。 该符号必须写在第 39 行的第一列。位于两个 EOT 符号之间的文本中的 PHP 变量将被解析;
  • 第 33–36 行:定义一个粉色背景的区域(class="alert alert-danger")(第 33 行);

Image

  • 第 34 行:文本;
  • 第 35 行:HTML 标签 <ul>(无序列表)用于显示带项目符号的列表。每个列表项必须采用 <li>item</li> 的语法;

让我们注意一下此代码中需要定义的动态元素:

  • [$model→error]:用于显示错误消息;
  • [$template→errors]:错误消息的列表(HTML 意义上的列表);

23.13.2.2. [v-bandeau.php] 片段

[v-bandeau.php] 片段用于显示 Web 应用程序中所有视图的顶部横幅:

Image

[v-banner.php] 片段的代码如下:


<!-- Bootstrap Jumbotron -->
<div class="jumbotron">
    <div class="row">
        <div class="col-md-4">
            <img src="<?= $logo ?>" alt="Cerisier en fleurs" />
        </div>
        <div class="col-md-8">
            <h1>
                Calculez votre impôt
            </h1>
        </div>
    </div>
</div>

评论

  • 第 2–13 行:横幅被包裹在一个 Bootstrap Jumbotron 区域 [<div class="jumbotron">] 中。该 Bootstrap 类会以特定方式对显示内容进行样式设置,使其脱颖而出;
  • 第 3–12 行:一个 Bootstrap 行;
  • 第 4–6 行:在该行的前四列中放置了一张图片 [img]
  • 第 5 行:语法 [<?= $logo ?>] 等同于语法 [<?php print $logo ?>]。换言之,[src] 属性的值将取自 PHP 变量 [$logo] 的值;
  • 第 7–11 行:该行剩余的 8 个列(请记住总共有 12 个列)将用于显示文本(第 9 行),并采用大字号(<h1>,第 8–10 行);

动态元素:

  • [$logo]:横幅中显示的图片 URL;

23.13.2.3. [v-authentification.php] 片段

[v-authentication.php] 片段用于显示 Web 应用程序的身份验证表单:

Image

[v-authentication.php] 片段的代码如下:


<!-- form HTML - post its values with the [authenticate-user] action -->
<form method="post" action="main.php?action=authentifier-utilisateur">
 
    <!-- title -->
    <div class="alert alert-primary" role="alert">
        <h4>Veuillez vous authentifier</h4>
    </div>
 
    <!-- bootstrap form -->
    <fieldset class="form-group">
        <!-- 1st line -->
        <div class="form-group row">
            <!-- wording -->
            <label for="user" class="col-md-3 col-form-label">Nom d'utilisateur</label>
            <div class="col-md-4">
                <!-- text input field -->
                <input type="text" class="form-control" id="user" name="user"
                       placeholder="Nom d'utilisateur" value="<?= $modèle->login ?>">
            </div>
        </div>
        <!-- 2nd line -->
        <div class="form-group row">
            <!-- wording -->
            <label for="password" class="col-md-3 col-form-label">Mot de passe</label>
            <!-- text input field -->
            <div class="col-md-4">
                <input type="password" class="form-control" id="password" name="password"
                       placeholder="Mot de passe">
            </div>
        </div>
        <!-- submit] button on a 3rd line-->
        <div class="form-group row">
            <div class="col-md-2">
                <button type="submit" class="btn btn-primary">Valider</button>
            </div>
        </div>
    </fieldset>

</form>

评论

  • 第 2–39 行:<form> 标签定义了一个 HTML 表单。该表单通常具有以下特征:
    • 它定义了输入字段(第 17 行和第 27 行的 <input> 标签);
    • 它有一个 [submit] 按钮(第 34 行),用于将输入的值发送至 [form] 标签的 [action] 属性中指定的 URL(第 2 行)。向该 URL 发起请求所使用的 HTTP 方法在 [form] 标签的 [method] 属性中指定(第 2 行);
    • 在此,当用户点击 [Submit] 按钮(第 34 行)时,浏览器将通过 POST 方法(第 2 行)将表单中输入的值提交至 URL [main.php?action=authentifier-utilisateur](第 2 行);
    • 提交的值即用户在第 17 行和第 27 行输入框中输入的内容。这些值将以 [user=xx&password=yy] 的格式提交。参数名称 [user, password] 对应于第 17 行和第 27 行输入框的 [name] 属性;
  • 第 5–7 行:一个 Bootstrap 代码块,用于在蓝色背景上显示标题:

Image

  • 第 10–37 行:一个 Bootstrap 表单。所有表单元素随后将采用特定样式;
  • 第 12–20 行:定义表单的第一行:

Image

  • 第 14 行将标签 [1] 设置为三列布局。[label] 标签的 [for] 属性将该标签与第 17 行输入字段的 [id] 属性关联起来;
  • 第 15–19 行:将输入字段放置在四列布局中;
  • 第 17 行:HTML [input] 标签定义了一个输入框。它具有以下几个属性:
    • [type='text']:这是一个文本输入框。您可以在其中输入任意内容;
    • [class='form-control']:输入字段的 Bootstrap 样式;
    • [id='user']:输入字段的标识符。该标识符通常用于 CSS 和 JavaScript 代码;
    • [name='user']: 输入字段的名称。用户输入的值将由浏览器以该名称提交 [user=xx]
    • [placeholder='prompt']: 用户尚未输入任何内容时,输入框中显示的提示文本;

Image

  • [value='value']: 输入框出现时(即用户尚未输入任何内容时),会显示文本 'value'。此机制用于在发生错误时显示导致错误的输入内容。此处,该值将取自 PHP 变量 [$model->login]
  • 第 21–30 行:密码输入字段的类似代码;
  • 第 27 行:[type='password'] 创建了一个文本输入框(可输入任意内容),但输入的字符会被隐藏:

Image

  • 第 32–36 行:用于 [Submit] 按钮的第三行代码;
  • 第 34 行:由于该按钮具有 [type="submit"] 属性,点击此按钮会触发浏览器将输入的值发送至服务器,如前所述。CSS 属性 [class="btn btn-primary"] 会显示一个蓝色按钮:

Image

还有最后一点需要说明。第 2 行:[action="main.php?action=authentifier-utilisateur"] 属性定义了一个不完整的 URL(它不以 http://machine:port/chemin 开头)。 在本示例中,应用程序的所有 URL 均采用 [http://localhost/php7/scripts-web/impots/version-12/main.php?action=xx] 这种形式。认证视图可通过以下各种 URL 访问:

  • [http://localhost/php7/scripts-web/impots/version-12/main.php?action=init-session&type=html];
  • [http://localhost/php7/scripts-web/impots/version-12/main.php?action=authentifier-utilisateur]

这些 URL 指向位于 [http://localhost/php7/scripts-web/impots/version-12] 的文档 [main.php]。这适用于该应用程序中的所有 URL。 当提交输入的值时,参数 [action="main.php?action=authentifier-utilisateur"] 将在此路径前添加前缀。因此,这些值将被发布到 URL [http://localhost/php7/scripts-web/impots/version-12/main.php?action=authentifier-utilisateur]

23.13.2.4. 视觉测试

在将视图集成到应用程序之前,我们可以对其进行充分的测试。此处的目的是测试其视觉效果。我们将把所有测试视图集中到项目的 [Tests] 文件夹中:

Image

要测试视图 [vue-authentification.php],我们需要创建它将要显示的数据模型:


<?php
// page test data
//
// calculate the view model
$modèle = getModelForThisView();
 
function getModelForThisView(): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();
  // user code
  $modèle->login = "albert";
  // error list
  $modèle->error = TRUE;
  $erreurs = ["erreur1", "erreur2"];
  // build a HTML list of errors
  $content = "";
  foreach ($erreurs as $erreur) {
    $content .= "<li>$erreur</li>";
  }
  $modèle->erreurs = $content;
  // banner image
  $modèle->logo = "http://localhost/php7/scripts-web/impots/version-12/Tests/logo.jpg";
  // we render the model
  return $modèle;
}
?>
 
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        <!-- Required meta tags -->

    </head>
    <body>
        ….
    </body>
</html>

评论

  • 第 1–5 行:身份验证视图包含由 [$model] 对象控制的动态部分。该对象被称为视图模型。根据 MVC 缩写词的两种定义之一,这代表了 MVC 中的 M;
  • 第 5 行:视图模型由 [getModelForThisView] 函数计算得出;
  • 第 9 行:视图模型将被封装在 [stdClass] 类型中;
  • 第 10–22 行:为身份验证视图的动态元素定义了测试值;

可在 NetBeans 中进行可视化测试:

Image

我们将持续进行这些可视化测试,直到对结果感到满意为止。

23.13.2.5. 计算视图模型

一旦确定了视图的外观,我们就可以在实际条件下计算视图模型。让我们回顾一下导致此视图的状态代码。这些代码可在配置文件中找到:


"vues": {
        "vue-authentification.php": [700, 221, 400],
        "vue-calcul-impot.php": [200, 300, 341, 350, 800],
        "vue-liste-simulations.php": [500, 600]
    },
"vue-erreurs": "vue-erreurs.php"

因此,状态码 [700, 221, 400] 会触发身份验证视图的显示。要理解这些代码的含义,我们可以参考在 JSON 应用程序上执行的 [Postman] 测试:

  • [init-session-json-700]:700 是 [init-session] 操作成功后的状态码:此时会显示一个空白的身份验证表单;
  • [authenticate-user-221]:221 是 [authenticate-user] 操作失败(凭据不正确)后的状态码:随后将显示身份验证表单,以便用户更正凭据;
  • [end-session-400]: 400 是 [end-session] 操作成功后的状态码:随后将显示空的身份验证表单;

既然我们已经知道何时应显示认证表单,就可以在 [authentication-view.php] 中计算其模板:

Image

计算视图模板 [vue-authentification.php] 的代码如下:


<?php
// we inherit the following variables
// Request $request : la requête en cours
// Session $session: the application session
// array $config: application configuration
// array $content: controller response
//
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
// calculate the view model
$modèle = getModelForThisView($request, $session, $config, $content);
 
function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // encapsulate paged data in $modèle
  $modèle = new stdClass();
  // application status
  $état = $content["état"];
  // the model depends on the state
  switch ($état) {
    case 700:
    case 400:
      // case of empty form display
      $modèle->login = "";
      // no error to display
      $modèle->error = FALSE;
      break;
    case 221:
      // false authentication
      // the user initially entered is redisplayed
      $modèle->login = $request->request->get("user");
      // there is an error to display
      $modèle->error = TRUE;
      // list HTML of error msg - here only one
      $modèle->erreurs = "<li>Echec de l'authentification</li>";
  }
  // result
  return $modèle;
}
?>
 
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
    </head>
    <body>
        
    </body>
</html>

评论

  • 第 3–6 行:声明了从 [HtmlResponse] 类继承的变量;该类使用 [require] 来显示 [vue-authentification.php] 视图;
  • 第 9–10 行:视图代码中使用的 Symfony 类;
  • 第 15–40 行:[getModelForThisView] 函数负责计算视图模型;
  • 第 19 行:获取处理当前操作的控制器返回的状态码;
  • 第 21–37 行:模型依赖于此状态代码;
  • 第 22–28 行:需要显示空白认证表单的情况;
  • 第 29–37 行:认证失败的情况:显示用户输入的用户名以及一条错误信息。用户随后可以尝试再次认证;

已为横幅编写了专用模板 [v-bandeau.php]


<?php
  // logo
  $scheme = $request->server->get('REQUEST_SCHEME'); // http
  $host = $request->server->get('SERVER_NAME'); // localhost
  $port = $request->server->get('SERVER_PORT'); // 80
  $uri = $request->server->get('REQUEST_URI'); // /php7/scripts-web/impots/version-12/main.php?action=xxx
  $champs = [];
  preg_match("/(.+)\/.+?$/", $uri, $champs);
  $root = $champs[1]; // /php7/scripts-web/impots/version-12
  $modèle->logo = "$scheme://$host:$port$root/Views/logo.jpg"; // http://localhost:80/php7/scripts-web/impots/version-12/Views/logo.jpg
?>
<!-- Bootstrap Jumbotron -->
<div class="jumbotron">
    <div class="row">
        <div class="col-md-4">
            <img src="<?= $modèle->logo ?>" alt="Cerisier en fleurs" />
        </div>
        <div class="col-md-8">
            <h1>
                Calculez votre impôt
            </h1>
        </div>
    </div>
</div>

评论

  • 第 16 行使用了变量 [$template→logo],该变量是横幅徽标的 URL。与其为应用程序的四个视图分别计算该变量四次,不如将此计算纳入片段 [v-banner.php] 中;
  • 第 1–11 行展示了如何利用服务器环境 [$request→server] 中的信息构建 URL [http://localhost:80/php7/scripts-web/impots/version-12/Views/logo.jpg]

23.13.2.6. 测试 [Postman]

我们已创建了返回状态码 [700, 221, 400] 的请求,这些请求会显示身份验证视图。让我们回顾一下:

  • [init-session-html-700]: 700 是 [init-session] 操作成功后的状态码:随后将显示一个空的身份验证表单;
  • [authenticate-user-221]: 221 是 [authenticate-user] 操作失败(凭据不正确)后的状态码:随后将显示认证表单以便修正凭据;
  • [end-session-400]: 400 是 [end-session] 操作成功后的状态码:随后将显示空的身份验证表单;

只需直接复用这些代码,并检查它们是否正确显示了身份验证视图。此处仅展示两个测试:

  • [init-session-html-700]:HTML 会话的开始;

Image

  • [authenticate-user-221]:验证用户 [x, x]

Image

上文:

  • 请求发送了字符串 [user=x&password=x]
  • [4] 中,显示了一条错误信息;
  • [3]中,再次显示了错误的用户;

23.13.2.7. 结论

我们在未编写其他视图的情况下,成功测试了视图 [vue-authentification.php]。这是因为:

  • 所有控制器均已编写完成;
  • [Postman] 允许我们在无需视图的情况下向服务器发送请求。编写控制器时,必须意识到任何人都可以这样做。因此,必须做好处理那些视图无法允许的请求的准备。这些请求是在 [Postman] 中手动创建的。切勿先入为主地认为“此请求不可能发生”,必须进行验证;

23.13.3. 税费计算视图

23.13.3.1. 视图概述

税费计算视图如下:

Image

该视图包含三个部分:

  • 1:顶部横幅由之前介绍过的片段 [v-bandeau.php] 生成;
  • 2:由片段 [v-calcul-impot.php] 生成的税费计算表单;
  • 3:由片段 [v-menu.php] 生成的包含两个链接的菜单;

税费计算视图由以下脚本 [vue-calcul-impot.php] 生成:

Image


<?php
// we inherit the following variables
// Request $request : la requête en cours
// Session $session: the application session
// array $config: application configuration
// array $content: the response of the controller that processed the action
//
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
// calculate the view model
$modèle = getModelForThisView($request, $session, $config, $content);
 
function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();

  // we render the model
  return $modèle;
}
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <!-- Bootstrap CSS -->
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
        <title>Application impots</title>
    </head>
    <body>
        <div class="container">
            <!-- bandeau -->
            <?php require "v-bandeau.php"?>
            <!-- ligne à deux colonnes -->
            <div class="row">
                <!-- le menu -->
                <div class="col-md-3">
                    <?php require "v-menu.php" ?>
                </div>
                <!-- le formulaire de calcul -->
                <div class="col-md-9">
                    <?php require "v-calcul-impot.php" ?>
                </div>
            </div>  
            <!-- cas du succès -->
            <?php
            if ($modèle->success) {
              // a success alert is displayed
              print <<<EOT1
            <div class="row">
                <div class="col-md-3">
 
                </div>
                <div class="col-md-9">
                    <div class="alert alert-success" role="alert">
                        $modèle->impôt</br>
                        $modèle->décôte</br>\n
                        $modèle->réduction</br>\n
                        $modèle->surcôte</br>\n
                        $modèle->taux</br>\n
                    </div>
                </div>
            </div>
EOT1;
            }
            ?>
            <?php
            if ($modèle->error) {
              // 9-column error list
              print <<<EOT2
                <div class="row">
                  <div class="col-md-3">
 
                  </div>
                  <div class="col-md-9">
                      <div class="alert alert-danger" role="alert">
                        L'erreur suivante s'est produite :
                        <ul>$modèle->erreurs</ul>
                      </div>
                  </div>
                </div>
EOT2;
            }
            ?>
        </div>
    </body>
</html>

评论

  • 我们仅对尚未出现的新功能进行注释;
  • 第 37 行:将视图的顶部横幅包含在视图的第一行 Bootstrap 行中;
  • 第 41–43 行:添加菜单,该菜单将占据视图第二行 Bootstrap 布局中的三列;
  • 第 45–47 行:插入税费计算表单,该表单将占据视图第二行 Bootstrap 布局中的九个列;
  • 第 51–69 行:如果税费计算成功 [$model→success=TRUE],则税费计算结果将显示在绿色框中(第 59–65 行)。 该框位于视图的第三行 Bootstrap 行(第 54 行),占据九列(第 58 行),位于三个空列(第 55–57 行)的右侧。因此,该框将紧接在税费计算表单下方;
  • 第 71–87 行:如果税费计算失败 [$model→error=TRUE],则在粉色框中显示一条错误消息(第 80–83 行)。 该框架位于视图的第三行 Bootstrap 区域(第 75 行),占据 九列(第 79 行),位于三列空白区域(第 76–78 行)的右侧。因此,该框架将紧接在税费计算表单下方;

23.13.3.2. 片段 [v-calcul-impot.php]

片段 [v-calcul-impot.php] 显示了 Web 应用程序的登录表单:

Image

[v-calcul-impot.php]片段的代码如下:


<!-- form HTML posted -->
<form method="post" action="main.php?action=calculer-impot">
    <!-- 12-column message on blue background -->
    <div class="col-md-12">
        <div class="alert alert-primary" role="alert">
            <h4>Remplissez le formulaire ci-dessous puis validez-le</h4>
        </div>
    </div>
    <!-- form elements -->
    <fieldset class="form-group">
        <!-- first row of 9 columns -->
        <div class="row">
            <!-- 4-column wording -->
            <legend class="col-form-label col-md-4 pt-0">Etes-vous marié(e) ou pacsé(e)?</legend>
            <!-- 5-column radio buttons-->
            <div class="col-md-5">
                <div class="form-check">
                    <input class="form-check-input" type="radio" name="marié" id="gridRadios1" value="oui" <?= $modèle->checkedOui ?>>
                    <label class="form-check-label" for="gridRadios1">
                        Oui
                    </label>
                </div>
                <div class="form-check">
                    <input class="form-check-input" type="radio" name="marié" id="gridRadios2" value="non" <?= $modèle->checkedNon ?>>
                    <label class="form-check-label" for="gridRadios2">
                        Non
                    </label>
                </div>
            </div>
        </div>
        <!-- second row of 9 columns -->
        <div class="form-group row">
            <!-- 4-column wording -->
            <label for="enfants" class="col-md-4 col-form-label">Nombre d'enfants à charge</label>
            <!-- 5-column numerical entry field for number of children -->
            <div class="col-md-5">
                <input type="number" min="0" step="1" class="form-control" id="enfants" name="enfants" placeholder="Nombre d'enfants à charge" value="<?= $modèle->enfants ?>">
            </div>
        </div>
        <!-- third row of 9 columns -->
        <div class="form-group row">
            <!-- 4-column wording -->
            <label for="salaire" class="col-md-4 col-form-label">Salaire annuel</label>
            <!-- 5-column numeric input field for wages -->
            <div class="col-md-5">
                <input type="number" min="0" step="1" class="form-control" id="salaire" name="salaire" placeholder="Salaire annuel" aria-describedby="salaireHelp" value="<?= $modèle->salaire ?>">
                <small id="salaireHelp" class="form-text text-muted">Arrondissez à l'euro inférieur</small>
            </div>
        </div>
        <!-- fourth row, [submit] button on 5 columns -->
        <div class="form-group row">
            <div class="col-md-5">
                <button type="submit" class="btn btn-primary">Valider</button>
            </div>
        </div>
    </fieldset>
 
</form>

评论

  • 第 2 行:HTML 表单将提交([method] 属性)至 URL [main.php?action=calculer-impot][action] 属性)。提交的值将是表单中输入字段的值:
    • 表单中选中单选按钮的值:
      • 若选中了 [Oui] 单选按钮,则为 [marié=oui](第 16–22 行)。其中 [marié] 是第 18 行 [name] 属性的值,[oui] 是第 18 行 [value] 属性的值;
      • 若选中 [No] 单选按钮,则为 [married=no](第 23–28 行)。其中 [married] 是第 24 行 [name] 属性的值,[no] 是第 24 行 [value] 属性的值;
    • 第 37 行数字输入字段的值,形式为 [children=xx],其中 [children] 是第 37 行 [name] 属性的值,[xx] 是用户通过键盘输入的值;
    • 第46行数字输入字段的值,形式为 [salary=xx],其中 [salary] 是第46行 [name] 属性的值,[xx] 是用户通过键盘输入的值;

最后,提交的值将呈现为 [married=xx&children=yy&salary=zz] 的形式。

  • 当用户点击第53行的[提交]按钮时,将提交所输入的值;
  • 第16–30行:两个单选按钮:

Image

这两个单选按钮属于同一个单选按钮组,因为它们具有相同的 [name] 属性(第 18、24 行)。浏览器确保在单选按钮组内,任何时候都只能选中一个。因此,点击其中一个会取消之前选中的那个;

  • 这些是单选按钮,是因为它们具有 [type="radio"] 属性(第 18、24 行);
  • 在表单显示时(输入之前),必须选中其中一个单选按钮:要实现这一点,只需在相应的 <input type="radio"> 标签中添加 [checked=’checked’] 属性。这可以通过动态变量来实现:
    • 第 18 行中的 [<?= $model->checkedYes ?>]
    • 第 24 行中的 [<?= $model->checkedNo ?>]

这些变量将成为视图模板的一部分。

  • 第 37 行:一个数值输入字段 [type="number"],最小值为 0 [min="0"]。在现代浏览器中,这意味着用户只能输入 >=0 的数字。在这些现代浏览器中,该输入可通过点击上下滑动的滑块来完成。 第 37 行中的 [step="1"] 属性表示滑块将以 1 为增量进行操作。因此,滑块仅接受从 0 到 n 之间、以 1 为增量的整数值。对于手动输入,这意味着带小数的数字将不被接受;

Image

  • 第 37 行:在某些屏幕上,子女输入字段必须预先填入该字段的上次输入内容。为此,我们使用 [value] 属性,该属性用于设置输入字段中显示的值。该值将是动态的,由变量 [$model→children] 生成;
  • 第 46 行:关于薪资输入的说明与子女输入部分相同;
  • 第 53 行:[submit] 按钮会触发将输入的值通过 POST 方式发送至 URL [main.php?action=calculer-impot]

Image

23.13.3.3. [v-menu.php] 片段

该片段在税费计算表单左侧显示一个菜单:

Image

该片段的代码如下:


<!-- bootstrap menu -->
<nav class="nav flex-column">
    <?php
    // affichage d'une liste de liens HTML
    foreach($modèle->optionsMenu as $texte=>$url){
      print <<<EOT3
      <a class="nav-link" href="$url">$texte</a>
EOT3;
    }
    ?>
</nav>

评论

  • 第 2–11 行:HTML 标签 [nav] 包裹着 HTML 文档中包含指向其他文档的导航链接的部分;
  • 第 7 行:HTML 标签 [a] 引入了一个导航链接:
    • [$url]:是用户点击 [$text] 链接时被引导到的 URL。 随后浏览器将执行 [GET $url] 操作。如果 [$url] 是相对 URL,则会在其前添加浏览器地址栏中当前显示的 URL 的根路径。因此,当浏览器的当前 URL 形式为 [http://chemin/main.php?paramètres] 时,要创建链接 [1],我们应创建如下链接:
<a href=’main.php?action=liste-simulation’>Liste des simulations</a>
  • 第 5 行:片段模板 [$modèle→optionsMenu] 将是一个如下形式的数组:
[‘ Liste des simulations’=>’main.php?action=liste-simulations’,
‘ Fin de session’=>’main.php?action=fin-session’]
  • 第 2、7 行:CSS 类 [nav, flex-column, nav-link] 是 Bootstrap 类,用于定义菜单的外观;

23.13.3.4. 视觉测试

我们将这些各种元素收集在 [Tests] 文件夹中,并为视图 [view-tax-calculation.php] 创建了一个测试模板:

Image

[view-tax-calculation]视图的数据模型如下:


<?php
// page test data
//
// calculate the view model
$modèle = getModelForThisView();
 
function getModelForThisView(): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();
  // form
  $modèle->checkedOui = "";
  $modèle->checkedNon = 'checked="checked"';
  $modèle->enfants = 2;
  $modèle->salaire = 300000;
  // message of success
  $modèle->success = TRUE;
  $modèle->impôt = "Montant de l'impôt : 1000 euros";
  $modèle->décôte = "Décôte : 15 euros";
  $modèle->réduction = "Réduction : 20 euros";
  $modèle->surcôte = "Surcôte : 0 euros";
  $modèle->taux = "Taux d'imposition : 14 %";
  // error message
  $modèle->error = TRUE;
  $erreurs = ["erreur1", "erreur2"];
  // build a HTML list of errors
  $content = "";
  foreach ($erreurs as $erreur) {
    $content .= "<li>$erreur</li>";
  }
  $modèle->erreurs = $content;
  // menu
  $modèle->optionsMenu = [
    'Liste des simulations' => 'main.php?action=liste-simulations',
    'Fin de session' => 'main.php?action=fin-session'];
  // banner image
  $modèle->logo = "http://localhost/php7/scripts-web/impots/version-12/Tests/logo.jpg";
  // we render the model
  return $modèle;
}
 
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
    </head>
    <body>
        
    </body>
</html>

评论

  • 第 7–39 行:我们初始化视图 [vue-calcul-impot.php] 以及组件 [v-calcul-impot.php] [v-menu.php] 中的所有动态部分;

我们测试视图 [vue-calcul-impot.php]

Image

我们得到以下结果:

Image

我们对该视图进行调整,直到对视觉效果感到满意为止。随后,我们可以将其集成到当前正在开发的 Web 应用程序中。

23.13.3.5. 计算视图模型

Image

一旦确定了视图的外观,我们就可以在实际环境中计算视图模型。让我们回顾一下导致此视图生成的状态代码。这些代码可在配置文件中找到:


"vues": {
        "vue-authentification.php": [700, 221, 400],
        "vue-calcul-impot.php": [200, 300, 341, 350, 800],
        "vue-liste-simulations.php": [500, 600]
    },
"vue-erreurs": "vue-erreurs.php"

这些状态码 [200, 300, 341, 350, 800] 会触发身份验证视图的显示。要理解这些代码的含义,我们可以参考在 JSON 应用程序上执行的 [Postman] 测试:

  • [authenticate-user-200]:200 是 [authenticate-user] 操作成功后的状态码;随后将显示空白的税费计算表单;
  • [calculate-tax-300]:300 是 [calculate-tax] 操作成功后的状态码。随后将显示包含已输入数据和税额的计算表单。用户随后可以进行另一项计算;
  • [end-session-400]:400 是 [end-session] 操作成功后的状态码:随后将显示空的身份验证表单;
  • 当税费计算有效但因无法连接到 DBMS 而导致错误时,将返回状态码 [341]
  • 状态码 [350] 表示税费计算有效,但因无法连接 [Redis] 服务器而引发错误;
  • 状态码 [800] 将在后续内容中介绍。目前我们尚未遇到该情况;
  • 此处假设用户使用的是现代浏览器。因此,在当前表单中,无法在输入字段 [children, salary] 中输入负数、非数字字符串或小数。若使用旧版浏览器,则可能出现此类情况。我们将把这些错误视为意外错误,并显示 [vue-erreurs] 视图;

既然已明确税费计算表单的显示条件,我们即可在 [tax-calculation-view.php] 中编写其模板:


<?php
// we inherit the following variables
// Request $request : la requête en cours
// Session $session: the application session
// array $config: application configuration
// array $content: the response of the controller that processed the action
//
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
// calculate the view model
$modèle = getModelForThisView($request, $session, $config, $content);
 
function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();
  // application status
  $état = $content["état"];
  // the model depends on the state
  switch ($état) {
    case 200 :
    case 800:
      // initial display of an empty form
      $modèle->success = FALSE; $modèle->errror = FALSE;
      $modèle->checkedNon = 'checked="checked"';
      $modèle->checkedOui = "";
      $modèle->enfants = "";
      $modèle->salaire = "";
      break;
    case 300:
      // successful calculation - result display
      $modèle->success = TRUE;
      $modèle->error = FALSE;
      $modèle->impôt = "Montant de l'impôt : {$content["réponse"]["impôt"]} euros";
      $modèle->décôte = "Décôte : {$content["réponse"]["décôte"]} euros";
      $modèle->réduction = "Réduction : {$content["réponse"]["réduction"]} euros";
      $modèle->surcôte = "Surcôte : {$content["réponse"]["surcôte"]} euros";
      $modèle->taux = "Taux d'imposition : " . ($content["réponse"]["taux"] * 100) . " %";
      // form restored with values entered
      $modèle->checkedOui = $request->request->get("marié") === "oui" ? 'checked="checked"' : "";
      $modèle->checkedNon = $request->request->get("marié") === "oui" ? "" : 'checked="checked"';
      $modèle->enfants = $request->request->get("enfants");
      $modèle->salaire = $request->request->get("salaire");
      break;
    case 341:
    // database HS
    case 350:
      // redis server HS
      // form restored with values entered
      $modèle->checkedOui = $request->request->get("marié") === "oui" ? 'checked="checked"' : "";
      $modèle->checkedNon = $request->request->get("marié") === "oui" ? "" : 'checked="checked"';
      $modèle->enfants = $request->request->get("enfants");
      $modèle->salaire = $request->request->get("salaire");
      // error
      $modèle->success = FALSE;
      $modèle->error = TRUE;
      $modèle->erreurs = "<li>{$content["réponse"]}</li>";
      break;
  }
  //menu
  $modèle->optionsMenu = [
    "Liste des simulations" => "main.php?action=lister-simulations",
    "Fin de session" => "main.php?action=fin-session"];
  // we render the model
  return $modèle;
}
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
        <title>Application impots</title>
    </head>
    <body>
        
    </body>
</html>

评论

  • 第 22–30 行:显示一个空表单;
  • 第 31–45 行:税费计算成功。再次显示输入的数值和税费金额;
  • 第 46–59 行:因 [Redis] [MySQL] 其中一台服务器不可用导致税费计算失败的情况;
  • 第 62–64 行:计算两个菜单选项;

23.13.3.6. [Postman] 测试

[calculate-tax-300] 测试返回状态码 300,表示税费计算成功:

Image

  • [3] 中,导致结果 [2] 的参数值;

让我们尝试一个错误情况:由于 [Redis] 服务器不可用,导致错误 [350]

Image

23.13.4. 模拟列表视图

23.13.4.1. 视图概述

显示模拟列表的视图如下:

Image

由脚本 [vue-liste-simulations] 生成的视图包含三个部分:

  • 1:顶部横幅由前文已介绍的 [v-bandeau.php] 片段生成;
  • 2:由片段 [v-simulation-list.php] 生成的模拟列表;
  • 3:由片段 [v-menu.php] 生成的包含两个链接的菜单;

模拟视图由以下脚本 [simulation-list-view.php] 生成:

Image


<?php
 
// calculate the view model
$modèle = getModelForThisView();
 
function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();
  
  // we render the model
  return $modèle;
}
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <!-- Bootstrap CSS -->
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
        <title>Application impots</title>
    </head>
    <body>
        <div class="container">
            <!-- bandeau -->
            <?php require "v-bandeau.php"; ?>
            <!-- ligne à deux colonnes -->
            <div class="row">
                <!-- menu sur trois colonnes-->
                <div class="col-md-3">
                    <?php require "v-menu.php" ?>
                </div>
                <!-- liste des simulations sur 9 colonnes-->
                <div class="col-md-9">
                    <?php require "v-liste-simulations.php" ?>
                </div>
            </div>  
        </div>
    </body>
</html>

评论

  • 第28行:添加应用程序横幅 [1]
  • 第33行:添加菜单[2]。它将以三列形式显示在横幅下方;
  • 第37行:插入模拟表格 [3]。它将显示在横幅下方、菜单右侧,呈九列布局;

我们已经对该视图的三个片段中的两个进行了说明:

  • [v-banner.php]:在链接部分;
  • [v-menu.php]:在“链接”部分;

片段 [v-liste-simulations.php] 如下所示:


<!-- message on blue background -->
<div class="alert alert-primary" role="alert">
    <h4>Liste de vos simulations</h4>
</div>
<!-- simulation table -->
<table class="table table-sm table-hover table-striped">
    <!-- headers of the six table columns -->
    <thead>
        <tr>
            <th scope="col">#</th>
            <th scope="col">Marié</th>
            <th scope="col">Nombre d'enfants</th>
            <th scope="col">Salaire annuel</th>
            <th scope="col">Montant impôt</th>
            <th scope="col">Surcôte</th>
            <th scope="col">Décôte</th>
            <th scope="col">Réduction</th>
            <th scope="col">Taux</th>
            <th scope="col"></th>
        </tr>
    </thead>
    <!-- table body (data displayed) -->
    <tbody>
        <?php
        $i = 0;
        // on affiche chaque simulation en parcourant le tableau des simulations
        foreach ($modèle->simulations as $simulation) {
          // affichage d'une ligne du tableau avec 6 colonnes - balise <tr>
          // colonne 1 : entête ligne (n° simulation) - balise <th scope='row'>
          // colonne 2 : valeur paramètre [marié] - balise <td>
          // colonne 3 : valeur paramètre [enfants] - balise <td>
          // colonne 4 : valeur paramètre [salaire] - balise <td>
          // colonne 5 : valeur paramètre [impôt] (de l'impôt) - balise <td>
          // colonne 6 : valeur paramètre [surcôte] - balise <td>
          // colonne 7 : valeur paramètre [décôte] - balise <td>
          // colonne 8 : valeur paramètre [réduction] - balise <td>
          // colonne 9 : valeur paramètre [taux] (de l'impôt) - balise <td>
          // colonne 10 : lien de suppression de la simulation - balise <td>
          print <<<EOT
        <tr>
          <th scope="row">$i</th>
          <td>{$simulation["marié"]}</td>
          <td>{$simulation["enfants"]}</td>
          <td>{$simulation["salaire"]}</td>
          <td>{$simulation["impôt"]}</td>
          <td>{$simulation["surcôte"]}</td>
          <td>{$simulation["décôte"]}</td>
          <td>{$simulation["réduction"]}</td>
          <td>{$simulation["taux"]}</td>
          <td><a href="main.php?action=supprimer-simulation&numéro=$i">Supprimer</a></td>
        </tr>
EOT;
          $i++;
        }
        ?>
        </tr>
    </tbody>
</table>

评论

  • 使用 <table> 标签创建了一个 HTML 表格(第 6 行和第 58 行);
  • 表格的列标题被包含在 <thead> 标签(表格标题,第 8 行和第 21 行)中。 <tr> 标签(表格行,第 9 行和第 20 行)定义一行。第 10–15 行:<th> 标签(表格标题)定义列标题。因此共有十个。[scope="col"] 表示该标题适用于列。[scope="row"] 表示该标题适用于行;
  • 第 23–57 行:<tbody> 标签包裹表格显示的数据;
  • 第 40–51 行:<tr> 标签包裹表格的一行;
  • 第 41 行:<th scope=’row’> 标签定义行标题;
  • 第 42–50 行:每个 td 标签定义该行的一列;
  • 第 27 行:模拟列表位于模型 [$model→simulations] 中,这是一个关联数组;
  • 第 50 行:用于删除模拟的链接。该 URL 使用了表格第一列(第 41 行)中显示的编号;

23.13.4.2. 可视化测试

我们将这些不同元素收集在 [Tests] 文件夹中,并为视图创建了一个测试模板 [view-simulation-list.php]

Image

[simulation-list-view]视图的数据模型如下:


<?php
// calculate the view model
$modèle = getModelForThisView();
 
function getModelForThisView(): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();
  // put the simulations in the format expected by the page
  $modèle->simulations = [
    [
      "marié" => "oui",
      "enfants" => 2,
      "salaire" => 60000,
      "impôt" => 448,
      "décôte" => 100,
      "réduction" => 20,
      "surcôte" => 0,
      "taux" => 0.14
    ],
    [
      "marié" => "non",
      "enfants" => 2,
      "salaire" => 200000,
      "impôt" => 25600,
      "décôte" => 0,
      "réduction" => 0,
      "surcôte" => 8400,
      "taux" => 0.45
    ]
  ];
  // menu options
  $modèle->optionsMenu = [
    "Calcul de l'impôt" => "main.php?action=afficher-calcul-impot",
    "Fin de session" => "main.php?action=fin-session"];
  // banner image
  $modèle->logo = "http://localhost/php7/scripts-web/impots/version-12/Tests/logo.jpg";
  // we render the model
  return $modèle;
}
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
    </head>
    <body>
        
    </body>
</html>

评论

  • 第9–30行:HTML表格中显示的模拟数据表;
  • 第32–34行:菜单选项表;

让我们显示这个视图:

Image

我们得到以下结果:

Image

我们对该视图进行调整,直到对视觉效果感到满意为止。随后,我们可以将其集成到当前正在开发的 Web 应用程序中。

23.13.4.3. 计算视图模型

Image

一旦确定了视图的外观,我们就可以在实际环境中计算视图模型。让我们回顾一下导致此视图生成的状态代码。这些代码可在配置文件中找到:


"vues": {
        "vue-authentification.php": [700, 221, 400],
        "vue-calcul-impot.php": [200, 300, 341, 350, 800],
        "vue-liste-simulations.php": [500, 600]
    },
"vue-erreurs": "vue-erreurs.php"

因此,显示模拟视图的是状态码 [500, 600]。要了解这些代码的含义,我们可以参考在 JSON 应用程序上执行的 [Postman] 测试:

  • [list-simulations-500]:500是[list-simulations]操作成功后的状态码:随后将显示用户执行的模拟列表;
  • [delete-simulation-600]:600 是 [delete-simulation] 操作成功后的状态码。随后将显示此次删除后生成的新的模拟列表;

既然我们已知模拟列表应在何时显示,便可计算其模板并在 [view-simulation-list.php] 中实现:


<?php
// we inherit the following variables
// Request $request : la requête en cours
// Session $session: the application session
// array $config: application configuration
// array $content: controller response
// no errors possible
// array $content: controller response
//
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
// calculate the view model
$modèle = getModelForThisView($request, $session, $config, $content);
 
function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();
  // put the simulations in the format expected by the page
  // they are found in the response of the controller that executed the action
  // as an array of objects of type [Simulation]
  $objetsSimulation = $content["réponse"];
  // each [Simulation] object will be transformed into an associative array
  $modèle->simulations = [];
  foreach ($objetsSimulation as $objetSimulation) {
    $modèle->simulations[] = [
      "marié" => $objetSimulation->getMarié(),
      "enfants" => $objetSimulation->getEnfants(),
      "salaire" => $objetSimulation->getSalaire(),
      "impôt" => $objetSimulation->getImpôt(),
      "surcôte" => $objetSimulation->getSurcôte(),
      "décôte" => $objetSimulation->getdécôte(),
      "réduction" => $objetSimulation->getRéduction(),
      "taux" => $objetSimulation->getTaux()
    ];
  }
  // menu options
  $modèle->optionsMenu = [
    "Calcul de l'impôt" => "main.php?action=afficher-calcul-impot",
    "Fin de session" => "main.php?action=fin-session"];
  // we render the model
  return $modèle;
}
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
    </head>
    <body>
       
    </body>
</html>

评论

  • 第 26–36 行:计算片段 [v-list-simulations.php] 所使用的模板 [$template→simulations]
  • 第 39–41 行:计算片段 [v-menu.php] 所使用的模板 [$template→optionsMenu]

23.13.4.4. [Postman] 测试

[list-simulations-500] 测试返回状态码 500。这对应于查看模拟的请求:

Image

[delete-simulation-600] 测试返回 600 状态码。这对应于成功删除第 0 号模拟的情况。返回的结果是一个模拟列表,其中缺少一个模拟:

Image

23.13.5. 查看意外错误

在此,我们将意外错误定义为在正常使用 Web 应用程序时不应发生的错误。

让我们以如下定义的 [Postman] 测试 [calculate-tax-3xx] 为例:

Image

  • [1-3] 中,一个带有 [calculer-impot] 操作的 POST 请求;
  • [4-6] 中:这里我们可以为三个 POST 参数定义任意内容:
    • [4]:缺少 [marié] 参数;
    • [5-6][children, salary] 参数存在但无效;
  • [9] 中,这三个错误均以状态码 338 报告;

然而,在 Web 应用程序的 HTML 表单中,这种情况不会发生:

  • 所有参数均已存在;
  • [married] 参数(其值取自两个单选按钮的 [value] 属性)必须取值为 [yes] [no] 之一;
  • 在现代浏览器中,<input type='number' min='0' step='1' …> 属性确保 children 和 salary 的输入值必须是 >=0 的整数;

然而,没有任何机制能阻止用户使用 [Postman] 将上述 [calcul-impot-3xx] 测试请求发送至我们的服务器。我们已看到,我们的 Web 应用程序知道如何正确响应此请求。我们将意外错误”定义为在 HTML 应用程序上下文中不应发生的错误。如果此类错误发生,很可能有人正在试图“入侵”该应用程序。 出于教学目的,我们决定针对此类情况显示错误页面。实际上,我们也可以直接重新显示之前发送给客户端的页面。实现方法很简单:只需将上次发送的 HTML 响应存储在会话中。一旦发生意外错误,我们就返回该响应。这样,由于显示的页面没有变化,用户会觉得服务器对他们的错误没有做出响应。

23.13.5.1. 查看演示

显示意外错误的视图如下:

Image

[vue-erreurs.php] 脚本生成的页面包含三个部分:

  • 1:顶部横幅由前文已介绍的 [v-banner.php] 片段生成;
  • 2:意外错误;
  • 3:由片段 [v-menu.php] 生成的包含三个链接的菜单;

意外错误的视图由以下 [vue-erreurs.php] 脚本生成:

Image


<?php
// calculate the view model
$modèle = getModelForThisView();
 
function getModelForThisView(): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();

  // we return the model
  return $modèle;
}
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <!-- Bootstrap CSS -->
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
        <title>Application impots</title>
    </head>
    <body>
        <div class="container">
            <!-- bandeau sur 12 colonnes -->
            <?php require "v-bandeau.php"; ?>
            <!-- ligne à deux colonnes -->
            <div class="row">
                <!-- menu sur 3 colonnes-->
                <div class="col-md-3">
                    <?php require "v-menu.php" ?>
                </div>
                <!-- liste des erreurs -->
                <div class="col-md-9">
                    <?php
                    print <<<EOT
                      <div class="alert alert-danger" role="alert">
                        Les erreurs inattendues suivantes se sont produites :
                        <ul>$modèle->erreurs</ul>
                      </div>
EOT;
                    ?>
                </div>
            </div>
        </div>
    </body>
</html>

评论

  • 第27行:添加应用程序横幅 [1]
  • 第 32 行:添加菜单 [2]。它将以三列形式显示在横幅下方;
  • 第 34–44 行:将错误区域以九列形式显示;
  • 第 37–44 行:显示意外错误的 [print] 操作;
  • 第 38 行:此显示内容将出现在一个粉色背景的 Bootstrap 容器中;
  • 第 39 行:介绍性文本;
  • 第 40 行:<ul> 标签包裹着一个项目符号列表。该列表由 [$model->errors] 模型提供;

我们已经对该视图的两个片段进行了说明:

  • [v-bandeau.php]:在链接部分;
  • [v-menu.php]:在链接部分;

23.13.5.2. 视觉测试

我们将这些不同元素收集在 [Tests] 文件夹中,并为视图 [vue-erreurs.php] 创建了一个测试模板:

Image

视图 [vue-erreurs.php] 的数据模型如下:


<?php
// calculate the view model
$modèle = getModelForThisView();
 
function getModelForThisView(): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();
 
  // the table of unexpected errors
  $erreurs = ["erreur1", "erreur2"];
  // build the HTML list of errors
  $modèle->erreurs = "";
  foreach ($erreurs as $erreur) {
    $modèle->erreurs .= "<li>$erreur</li>";
  }
  // menu options
  $modèle->optionsMenu = [
    "Calcul de l'impôt" => "main.php?action=afficher-calcul-impot",
    "Liste des simulations" => "main.php?action=lister-simulations",
    "Fin de session" => "main.php?action=fin-session",];
  // banner image
  $modèle->logo = "http://localhost/php7/scripts-web/impots/version-12/Tests/logo.jpg";
  // we return the model
  return $modèle;
}
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
    </head>
    <body>
        
    </body>
</html>

评论

  • 第 9–15 行:构建 HTML 错误列表;
  • 第 17–20 行:菜单选项数组;

让我们显示这个视图:

Image

我们得到以下结果:

Image

我们对该视图进行调整,直到对视觉效果感到满意为止。随后,我们可以将其集成到当前正在开发的 Web 应用程序中。

23.13.5.3. 计算视图模型

Image

一旦确定了视图的外观,我们就可以在实际条件下计算视图模型。让我们回顾一下导致此视图的状态代码。这些代码可在配置文件中找到:


"vues": {
        "vue-authentification.php": [700, 221, 400],
        "vue-calcul-impot.php": [200, 300, 341, 350, 800],
        "vue-liste-simulations.php": [500, 600]
    },
"vue-erreurs": "vue-erreurs.php"

因此,只有第 [2–4] 行中未列出的状态码才会触发意外错误视图的显示。

用于计算视图模板 [vue-erreurs.php] 的代码如下:


<?php
// we inherit the following variables
// Request $request : la requête en cours
// Session $session: the application session
// array $config: application configuration
// array $content: controller response
//
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
 
// calculate the view model
$modèle = getModelForThisView($request, $session, $config, $content);
 
function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // encapsulate paged data in $modèle
  $modèle = new \stdClass();
 
  // recover errors from the controller response
  $réponse = $content["réponse"];
  if (!is_array($réponse)) {
    // a single error message
    $erreurs = [$réponse];
  } else {
    // several error messages
    $erreurs = $réponse;
  }
  // build the HTML list of errors
  $modèle->erreurs = "";
  foreach ($erreurs as $erreur) {
    $modèle->erreurs .= "<li>$erreur</li>";
  }
  // menu options
  $modèle->optionsMenu = [
    "Calcul de l'impôt" => "main.php?action=afficher-calcul-impot",
    "Liste des simulations" => "main.php?action=lister-simulations",
    "Fin de session" => "main.php?action=fin-session",];
 
  // we return the model
  return $modèle;
}
?>
<!-- document HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
    </head>
    <body>
        
    </body>
</html>

评论

  • 第 19–32 行:计算视图 [view-errors.php] 所使用的模板 [$template→errors]
  • 第 34–37 行:计算片段 [v-menu.php] 所使用的模板 [$template→optionsMenu]

23.13.5.4. 测试 [Postman]

[calculate-tax-3xx] 测试返回状态码 338,这并非预期的状态码。HTML 响应如下:

Image

23.13.6. 应用程序菜单操作的实现

接下来我们将讨论菜单操作的实现。让我们回顾一下之前遇到的链接的含义

查看
链接
目标
角色
税款计算
[模拟列表]
[main.php?action=list-simulations]
请求模拟列表
  
[结束会话]
模拟列表
[税款计算]
[main.php?action=display-tax-calculation]
显示税费计算视图
  
[注销]
意外错误
[税费计算]
[main.php?action=display-tax-calculation]
显示税费计算视图
  
[模拟列表]
  
[结束会话]

需要注意的是,点击链接会向链接的目标发起一个 GET 请求。操作 [lister-simulations、fin-session] 是通过 GET 操作实现的,因此我们可以将其用作链接目标。当操作通过 POST 请求执行时,除非结合 JavaScript 使用,否则无法再使用链接。

从上述操作来看,[display-tax-calculation] 操作似乎尚未实现。这属于两个视图之间的导航操作:JSON 或 XML 服务器没有理由实现它,因为它们不具备视图的概念。引入这一概念的是 HTML 服务器。

因此,我们需要实现 [display-tax-calculation] 操作。这将使我们能够回顾在服务器内实现操作的流程。

首先,我们需要添加一个新的二级控制器。我们将它命名为 [AfficherCalculImpotController]

Image

该控制器必须添加到配置文件 [config.json] 中:


{
    "databaseFilename": "database.json",
    "rootDirectory": "C:/myprograms/laragon-lite/www/php7/scripts-web/impots/version-12",
    "relativeDependencies": [
 

 
        "/Controllers/InterfaceController.php",
        "/Controllers/InitSessionController.php",
        "/Controllers/ListerSimulationsController.php",
        "/Controllers/AuthentifierUtilisateurController.php",
        "/Controllers/CalculerImpotController.php",
        "/Controllers/SupprimerSimulationController.php",
        "/Controllers/FinSessionController.php",
        "/Controllers/AfficherCalculImpotController.php"
    ],
    "absoluteDependencies": [
        "C:/myprograms/laragon-lite/www/vendor/autoload.php",
        "C:/myprograms/laragon-lite/www/vendor/predis/predis/autoload.php"
    ],

    "actions":
            {
                "init-session": "\\InitSessionController",
                "authentifier-utilisateur": "\\AuthentifierUtilisateurController",
                "calculer-impot": "\\CalculerImpotController",
                "lister-simulations": "\\ListerSimulationsController",
                "supprimer-simulation": "\\SupprimerSimulationController",
                "fin-session": "\\FinSessionController",
                "afficher-calcul-impot": "\\AfficherCalculImpotController"
            },

    "vues": {
        "vue-authentification.php": [700, 221, 400],
        "vue-calcul-impot.php": [200, 300, 341, 350, 800],
        "vue-liste-simulations.php": [500, 600]
    },
    "vue-erreurs": "vue-erreurs.php"
}
  • 第 15 行:新的控制器;
  • 第 30 行:新的操作及其控制器;
  • 第 35 行:新控制器将返回状态码 800。切换视图时,不应出现错误;

控制器 [AfficherCalculImpotController.php] 将如下所示:


<?php
 
namespace Application;
 
// symfony dependencies
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Response;
 
class AfficherCalculImpotController implements InterfaceController {
 
  // $config is the application configuration
  // traitement d'une requête Request
  // session and can modify it
  // $infos is additional information specific to each controller
  // renders an array [$statusCode, $état, $content, $headers]
  
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {
 
    // view change - just a status code to set
    return [Response::HTTP_OK, 800, ["réponse" => ""], []];
  }
 
}

评论

  • 第 10 行:与其他辅助控制器一样,新控制器实现了 [InterfaceController] 接口;
  • 视图的更改很容易实现:只需返回与目标视图关联的状态码,如上所示,此处为代码 800;

23.13.7. 实际环境测试

代码已编写完毕,并使用 [Postman] 测试了每个操作。我们仍需在实际场景中测试视图流。我们需要一种初始化 HTML 会话的方法。我们知道需要向服务器发送参数 [action=init-session&type=html]。为避免在浏览器地址栏中手动输入这些参数,我们将 [index.php] 脚本添加到应用程序中:

Image

[index.php]脚本内容如下:


<?php
 
// redirect to [main.php] in [html] mode
header('Location: main.php?action=init-session&type=html');
  • 第 4 行:[header] 是一个 PHP 函数,用于向响应中添加 HTTP 头。 HTTP 头 [Location: main.php?action=init-session&type=html] 指示客户端浏览器重定向到 [Location] 中指定的目标 URL。脚本 [index.php] 是通过 URL [http://localhost/php7/scripts-web/impots/version-12/index.php] 请求的。 当客户端浏览器收到重定向到相对 URL [main.php?action=init-session&type=html] 的指令时,它将请求绝对 URL [http://localhost/php7/scripts-web/impots/version-12/main.php?action=init-session&type=html],并启动 HTML 会话;

启动 URL 可以简化为 [http://localhost/php7/scripts-web/impots/version-12/]。如果 URL 中未指定页面,则默认使用 [index.html, index.php] 页面。因此,此处将使用 [index.php] 脚本;

让我们开始吧:接下来我们将介绍几个视图序列。

在浏览器中,我们启用开发者工具(Firefox 中按 F12),并请求启动 URL [https://localhost/php7/scripts-web/impots/version-12/]

Image

  • [4] 处,服务器的首次响应是一个 302 重定向:
  • [5] 处,向 URL [http://localhost/php7/scripts-web/impots/13/main.php?action=init-session&type=html] 发起了一个新请求;

让我们来仔细看看这个 302 重定向:

Image

  • [8] 中,HTTP 状态码 [302] 是一个重定向码:它告知客户端浏览器,所请求的 URL 已移动。新 URL 在 [9] 中指定。浏览器将通过一个新的 GET 请求执行此重定向:

Image

  • [12-13] 中,浏览器发出的新请求;

让我们填写收到的表单;

Image

然后我们进行几次模拟:

Image

Image

让我们查询模拟列表:

Image

删除第一个模拟:

Image

结束本次会话:

Image

欢迎读者进行进一步测试。

23.14. jSON Web 服务客户端

23.14.1. 客户端/服务器架构

Image

接下来我们将重点探讨 Web 服务 [B] 的 JSON 客户端 [A]。客户端 [A] 与 Web 服务 [B] 一样,具有分层结构:

Image

该架构体现在以下代码组织中:

Image

大多数类已经介绍并说明过了:

BaseEntity
链接段落。
纳税人数据
链接段落。
模拟
链接段落。
税收豁免
段落链接
TraitDao
段落链接
实用工具
段落链接

23.14.2. [dao] 层

Image

23.14.2.1. 接口

[dao]层的接口如下所示 [InterfaceClientDao.php]


<?php
 
// namespace
namespace Application;
 
interface InterfaceClientDao {
 
  // reading taxpayer data
  public function getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array;
 
  // calculating a taxpayer's taxes
  public function calculerImpot(string $marié, int $enfants, int $salaire): Simulation;
 
  // recording results
  public function saveResults(string $resultsFilename, array $simulations): void;
 
  // authentication
  public function authentifierUtilisateur(String $user, string $password): void;
 
  // list of simulations
  public function listerSimulations(): array;
 
  // delete a simulation
  public function supprimerSimulation(int $numéro): array;
 
  // start of session
  public function initSession(string $type = 'json'): void;
 
  // end of session
  public function finSession(): void;
}

注释

  • 第 9 行:[getTaxPayersData] 方法允许您使用包含纳税人数据的 JSON 文件。该方法由 [TraitDao] 特质实现,该特质已在前文讨论过(参见链接段落);
  • 第 15 行:[saveResults] 方法将多个税务计算的结果保存到 JSON 文件中。同样,该方法由已讨论过的 [TraitDao] 特质实现(参见链接段落);
  • 第 12、18、21、27、30 行:针对 Web 服务接受的每个操作,都创建了一个相应的方法;

23.14.2.2. 实现

[InterfaceClientDao] 接口由以下 [ClientDao] 类实现:


<?php
 
namespace Application;
 
// dependencies
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Response\CurlResponse;
 
class ClientDao implements InterfaceClientDao {
  // using a Trait
  use TraitDao;
  // attributes
  private $urlServer;
  private $sessionCookie;
  private $verbose;
 
  // manufacturer
  public function __construct(string $urlServer, bool $verbose = TRUE) {
    $this->urlServer = $urlServer;
    $this->verbose = $verbose;
  }

}

评论

  • 第 18–21 行:构造函数接收两个参数:
    • JSON Web 服务的 URL [$urlServer]
    • 一个布尔值 [$verbose],当设置为 TRUE 时,表示该类应在控制台上显示服务器的响应;
  • 第 14 行:会话 Cookie。其作用已在客户端第 09 版中描述(链接段落);
  • 第 11 行:该类使用了 [TraitDao] 特质,该特质实现了接口的两个方法:
    • [getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array];
    • [function calculateTax(string $married, int $children, int $salary): Simulation];

23.14.2.2.1. 方法 [initSession]

[initSession] 方法的实现如下:


public function initSession(string $type = 'json'): void {
    // create a HTTP customer
    $httpClient = HttpClient::create();
    // make the request to the server without authentication
    $response = $httpClient->request('GET', $this->urlServer,
      ["query" => [
          "action" => "init-session",
          "type" => $type
        ],
        "verify_peer" => false
    ]);
    // the answer is retrieved
    $this->getResponse($response);
    // retrieve the session cookie
    $headers = $response->getHeaders();
    if (isset($headers["set-cookie"])) {
      // session cookie ?
      foreach ($headers["set-cookie"] as $cookie) {
        $match = [];
        $match = preg_match("/^PHPSESSID=(.+?);/", $cookie, $champs);
        if ($match) {
          $this->sessionCookie = "PHPSESSID=" . $champs[1];
        }
      }
    }
  }

由于 [init-session] 操作必须是向 Web 服务请求的第一个操作,因此 [initSession] 方法将是 [dao] 层中被调用的第一个方法。

注释

  • 第 1 行:将所需的会话类型作为参数传递。如果未提供参数,将启动一个 JSON 会话;
  • 第 5–11 行:向 Web 服务发出 GET 请求;
  • 第 7–8 行:两个 GET 参数;
  • 第 10 行:在安全通信(HTTPS)的情况下,不会验证 Web 服务发送的安全证书;
  • 第 13 行:[getResponse] 方法用于获取服务器的响应,并将其作为数组返回。此处并未使用该方法的返回结果。如果 Web 服务响应的 HTTP 状态码不是 200 OK,[getResponse] 方法会抛出异常;
  • 第 14–25 行:由于 [initSession] 方法是 [dao] 层中第一个被执行的方法,因此我们获取会话 Cookie,以便后续方法能将其发回 Web 服务。此代码在 09 版中已做过说明;

23.14.2.2.2. [getResponse] 方法

[getResponse] 方法负责处理 Web 服务响应:


private function getResponse(CurlResponse $response) {
    // the answer is retrieved
    $json = $response->getContent(false);
    // logs
    if ($this->verbose) {
      print "$json\n";
    }
    // retrieve response status
    $statusCode = $response->getStatusCode();
    // mistake?
    if ($statusCode !== 200) {
      // we have an error
      throw new ExceptionImpots($json);
    }
    // we give our answer
    $array = json_decode($json, true);
    return $array["réponse"];
  }

注释

  • 第 1 行:该方法是私有的;
  • 第 1 行:该方法的参数是类型为 [Symfony\Component\HttpClient\Response\CurlResponse] 的 Web 服务响应,即 Symfony 的响应类型,当 [HttpClient][CurlClient] 实现时,也就是由 [curl] 库实现时;
  • 第 3 行:我们从服务器获取 JSON 响应。请注意,[false] 参数的作用是防止在服务器 HTTP 响应状态处于 [3xx, 4xx, 5xx] 范围内时,Symfony 抛出异常;
  • 第 5–7 行:若处于 [$verbose] 模式,则在控制台显示服务器的响应;
  • 第 9–14 行:如果服务器的 HTTP 响应状态码不是 200,则抛出异常,并将服务器的 JSON 响应作为错误消息;
  • 第 16 行:将 JSON 字符串解码为数组;
  • 第 17 行:有用信息位于 [$array["response"]] 中;

23.14.2.2.3. [authenticateUser] 方法

[authenticateUser] 方法如下:


public function authentifierUtilisateur(string $user, string $password): void {
    // create a HTTP customer
    $httpClient = HttpClient::create();
    // make a request to the server with authentication
    $response = $httpClient->request('POST', $this->urlServer,
      ["query" => [
          "action" => "authentifier-utilisateur"
        ],
        "body" => [
          "user" => $user,
          "password" => $password
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // the answer is retrieved
    $this->getResponse($response);
  }

注释

  • 第 5 行:客户端的请求是 POST 请求;
  • 第 6–8 行:URL 中的参数;
  • 第 9–12 行:POST 参数;
  • 第 14 行:会话 Cookie;
  • 第 17 行:我们读取响应。我们知道,如果出现错误(HTTP 状态码非 200),[getResponse] 方法本身会抛出异常;

23.14.2.2.4. [calculateTax] 方法

public function calculerImpot(string $marié, int $enfants, int $salaire): Simulation {
    // create a HTTP customer
    $httpClient = HttpClient::create();
    // make the request to the server without authentication but with the session cookie
    $response = $httpClient->request('POST', $this->urlServer,
      ["query" => [
          "action" => "calculer-impot"],
        "body" => [
          "marié" => $marié,
          "enfants" => $enfants,
          "salaire" => $salaire
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // the answer is retrieved
    $array = $this->getResponse($response);
    return (new Simulation())->setFromArrayOfAttributes($array);
  }

评论

  • 第 6–7 行:单个 URL 参数;
  • 第 8–12 行:三个 POST 参数(第 5 行);
  • 第 17 行:处理响应;
  • 第 18 行:如果到达此处,说明 [getResponse] 方法未抛出异常。我们返回一个 [Simulation] 对象,该对象使用 [getResponse] 返回的数组进行初始化;

23.14.2.2.5. [listerSimulations] 方法

public function listerSimulations(): array {
    // create a HTTP customer
    $httpClient = HttpClient::create();
    // make the request to the server without authentication but with the session cookie
    $response = $httpClient->request('GET', $this->urlServer,
      ["query" => [
          "action" => "lister-simulations"
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // the answer is retrieved
    return $this->getSimulations($response);
  }

注释

  • 第 5 行:GET 方法;
  • 第 6–8 行:唯一的 GET 参数;
  • 第 13 行:由私有方法 [getSimulations] 负责检索模拟数据;

23.14.2.2.6. [getSimulations] 方法

private function getSimulations(CurlResponse $response): array {
    // we retrieve the JSON response
    $array = $this->getResponse($response);
    // we have an array of associative objects
    // we'll turn it into an array of Simulation objects
    $simulations = [];
    foreach ($array as $simulation) {
      $simulations [] = (new Simulation())->setFromArrayOfAttributes($simulation);
    }
    // we render the Simulation object list
    return $simulations;
}

注释

  • 第 3 行:我们从响应中获取数组。这是一个多维数组,其中的每个元素都具有 [Simulation] 对象的所有属性;
  • 第 6 行:如果执行到这里,说明 [getResponse] 方法没有抛出异常;
  • 第 6–9 行:我们使用响应来构建一个 [Simulation] 对象数组;
  • 第 11 行:返回该数组;

23.14.2.2.7. [DeleteSimulation] 方法

public function supprimerSimulation(int $numéro): array {
    // create a HTTP customer
    $httpClient = HttpClient::create();
    // make the request to the server without authentication but with the session cookie
    $response = $httpClient->request('GET', $this->urlServer,
      ["query" => [
          "action" => "supprimer-simulation",
          "numéro" => $numéro
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // the answer is retrieved
    return $this->getSimulations($response);
  }

注释

  • 第 5 行:发出一个 GET 请求;
  • 第 6–9 行:两个 URL 参数;
  • 第 14 行:删除操作完成后,服务器返回新的模拟数组。我们返回该数组;

23.14.2.2.8. [endSession] 方法

与 Web 服务的会话通常通过调用 [endSession] 方法结束:


public function finSession(): void {
    // create a HTTP customer
    $httpClient = HttpClient::create();
    // make the request to the server without authentication but with the session cookie
    $response = $httpClient->request('GET', $this->urlServer,
      ["query" => [
          "action" => "fin-session"
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // the answer is retrieved
    $this->getResponse($response);
  }

注释

  • 第 5 行:我们发起一个 GET 请求;
  • 第 6–8 行:唯一的 URL 参数;
  • 第 13 行:读取响应。如果响应的 HTTP 状态码不是 200,则会抛出异常;

23.14.3. [业务]层

Image

23.14.3.1. 接口

[业务]层的接口如下 [InterfaceClientMetier.php]


<?php
 
// namespace
namespace Application;
 
interface InterfaceClientMetier {
 
  // calculating a taxpayer's taxes
  public function calculerImpot(string $marié, int $enfants, int $salaire): Simulation;
 
  // batch mode tax calculation
  public function executeBatchImpots(string $taxPayersFileName, string $resultsFilename, string $errorsFileName): void;
 
  // authentication
  public function authentifierUtilisateur(String $user, string $password): void;
 
  // list of simulations
  public function listerSimulations(): array;
 
  // recording results
  public function saveResults(string $resultsFilename, array $simulations): void;
 
  // delete a simulation
  public function supprimerSimulation(int $numéro): array;
 
  // start of session
  public function initSession(string $type = 'json'): void;
 
  // end of session
  public function finSession(): void;
}

注释

  • 只有第 12 行中的 [executeBatchImports] 方法属于 [business] 层。其余所有方法都属于 [DAO] 层,由该层实现;

23.14.3.2. [ClientMetier] 类

实现 [business] 层的类如下:


<?php
 
namespace Application;
 
class ClientMetier implements InterfaceClientMetier {
  // attribute
  private $clientDao;
 
  // manufacturer
  public function __construct(InterfaceClientDao $clientDao) {
    $this->clientDao = $clientDao;
  }
 
  // tAX CALCULATION
  public function calculerImpot(string $marié, int $enfants, int $salaire): Simulation {
    return $this->clientDao->calculerImpot($marié, $enfants, $salaire);
  }
 
  // batch mode tax calculation
  public function executeBatchImpots(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void {
    // we let the exceptions coming from the [dao] layer flow upwards
    // retrieve taxpayer data
    $taxPayersData = $this->clientDao->getTaxPayersData($taxPayersFileName, $errorsFileName);
    // results table
    $simulations = [];
    // we exploit them
    foreach ($taxPayersData as $taxPayerData) {
      // tax calculation     
      $simulations [] = $this->calculerImpot(
        $taxPayerData->getMarié(),
        $taxPayerData->getEnfants(),
        $taxPayerData->getSalaire());
    }
    // recording results
    if ($resultsFileName !== NULL) {
      $this->clientDao->saveResults($resultsFileName, $simulations);
    }
  }
 
  public function authentifierUtilisateur(String $user, string $password): void {
    $this->clientDao->authentifierUtilisateur($user, $password);
  }
 
  public function listerSimulations(): array {
    return $this->clientDao->listerSimulations();
  }
 
  public function saveResults(string $resultsFilename, array $simulations): void {
    $this->clientDao->saveResults($resultsFilename, $simulations);
  }
 
  public function supprimerSimulation(int $numéro): array {
    return $this->clientDao->supprimerSimulation($numéro);
  }
 
  public function finSession(): void {
    $this->clientDao->finSession();
  }
 
  public function initSession(string $type = 'json'): void {
    $this->clientDao->initSession($type);
  }
 
}

评论

  • 第 10–12 行:[业务] 层在构建时需要引用 [DAO] 层;
  • 第 20–38 行:只有 [executeBatchImports] 方法是 [业务] 层特有的。其他方法的实现将工作委托给 [DAO] 层中同名的方法;
  • 第 23 行:我们调用 [DAO] 层,将纳税人数据以 [TaxPayerData] 对象数组的形式检索回来;
  • 第 25 行:各种计算结果被累积到 [$simulations] 数组中;
  • 第 27–33 行:我们计算 [$taxPayersData] 数组中每位纳税人的税款;
  • 第 35–37 行:将 [$simulations] 数组中获得的结果保存到 JSON 文件中;

[业务]层几乎没有任何作用。我们可以考虑将其移除,并将所有内容整合到[DAO]层中。

23.14.4. 主脚本

Image

主脚本由以下 [config.json] 文件配置:


{
    "taxPayersDataFileName": "Data/taxpayersdata.json",
    "resultsFileName": "Data/results.json",
    "errorsFileName": "Data/errors.json",
    "rootDirectory": "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-12",
    "dependencies": [
        "/Entities/BaseEntity.php",
        "/Entities/TaxPayerData.php",
        "/Entities/Simulation.php",
        "/Entities/ExceptionImpots.php",
        "/Utilities/Utilitaires.php",
        "/Model/InterfaceClientDao.php",        
        "/Model/TraitDao.php",
        "/Model/ClientDao.php",
        "/Model/InterfaceClientMetier.php",
        "/Model/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-12/main.php"
}

主脚本 [main.php] 如下:


<?php
 
// strict adherence to declared types of function parameters
declare(strict_types = 1);
 
// namespace
namespace Application;
 
// error handling by PHP
// ini_set("display_errors", "0");
//
// configuration file path
define("CONFIG_FILENAME", "../Data/config.json");
 
// we retrieve the configuration
$config = \json_decode(file_get_contents(CONFIG_FILENAME), true);
 
// include the necessary script dependencies
$rootDirectory = $config["rootDirectory"];
foreach ($config["dependencies"] as $dependency) {
  require "$rootDirectory/$dependency";
}
// absolute dependencies (third-party libraries)
foreach ($config["absoluteDependencies"] as $dependency) {
  require "$dependency";
}
 
// definition of constants
define("TAXPAYERSDATA_FILENAME", "$rootDirectory/{$config["taxPayersDataFileName"]}");
define("RESULTS_FILENAME", "$rootDirectory/{$config["resultsFileName"]}");
define("ERRORS_FILENAME", "$rootDirectory/{$config["errorsFileName"]}");
//
// symfony dependencies
use Symfony\Component\HttpClient\HttpClient;
 
// creation of the [dao] layer
$clientDao = new ClientDao($config["urlServer"]);
// creation of the [business] layer
$clientMetier = new ClientMetier($clientDao);
 
// tax calculation in batch mode
try {
  // session initialization
  $clientMetier->initSession('json');
  // authentication
  $clientMetier->authentifierUtilisateur($config["user"]["login"], $config["user"]["passwd"]);
  // tax calculation without saving results
  $clientMetier->executeBatchImpots(TAXPAYERSDATA_FILENAME, NULL, ERRORS_FILENAME);
  // list of simulations
  $clientMetier->listerSimulations();
  // deleting a simulation
  $simulations = $clientMetier->supprimerSimulation(1);
  // saving results
  $clientMetier->saveResults(RESULTS_FILENAME, $simulations);
  // end of session
  $clientMetier->finSession();
  // action without being authenticated - must crash
  $clientMetier->listerSimulations();
} catch (ExceptionImpots $ex) {
  // error is displayed
  print "Une erreur s'est produite : " . $ex->getMessage() . "\n";
}
// end
print "Terminé\n";
exit();

注释

  • 第 12–16 行:处理配置文件 [config.json]
  • 第 18–26 行:加载所有依赖项;
  • 第 28–34 行:定义常量和别名;
  • 第 36–39 行:构建 [dao] [business] 层;
  • 第 44 行:初始化 JSON 会话;
  • 第 46 行:与服务器进行身份验证;
  • 第 48 行:计算一系列纳税人的税款。结果不被保存(第 2 个参数设置为 NULL);
  • 第 50 行:检索所有这些计算的结果;
  • 第 52 行:删除模拟 #1(列表中的第二个);
  • 第 54 行:保存剩余的模拟;
  • 第 56 行:会话结束。这意味着会话 Cookie 已被删除;
  • 第 58 行:我们请求模拟列表。由于会话 Cookie 已被删除,必须重新进行身份验证。因此,我们应收到一条异常提示,表明我们未通过身份验证;

[taxpayersdata.json] 文件内容如下:


[
    {
        "marié": "oui",
        "enfants": 2,
        "salaire": 55555
    },
    {
        "marié": "ouix",
        "enfants": "2x",
        "salaire": "55555x"
    },
    {
        "marié": "oui",
        "enfants": "2",
        "salaire": 50000
    },
    {
        "marié": "oui",
        "enfants": 3,
        "salaire": 50000
    },
    {
        "marié": "non",
        "enfants": 2,
        "salaire": 100000
    },
    {
        "marié": "non",
        "enfants": 3,
        "salaire": 100000
    },
    {
        "marié": "oui",
        "enfants": 3,
        "salaire": 100000
    },
    {
        "marié": "oui",
        "enfants": 5,
        "salaire": 100000
    },
    {
        "marié": "non",
        "enfants": 0,
        "salaire": 100000
    },
    {
        "marié": "oui",
        "enfants": 2,
        "salaire": 30000
    },
    {
        "marié": "non",
        "enfants": 0,
        "salaire": 200000
    },
    {
        "marié": "oui",
        "enfants": 3,
        "salaire": 20000
    }
]

共有12名纳税人,其中1名信息有误。因此总共有11个模拟案例。其中一个将被移除,剩下10个。

运行主脚本后,JSON文件[results.json]如下所示:


[
    {
        "marié": "oui",
        "enfants": "2",
        "salaire": "55555",
        "impôt": 2814,
        "surcôte": 0,
        "décôte": 0,
        "réduction": 0,
        "taux": 0.14
    },
    {
        "marié": "oui",
        "enfants": "3",
        "salaire": "50000",
        "impôt": 0,
        "surcôte": 0,
        "décôte": 720,
        "réduction": 0,
        "taux": 0.14
    },
    {
        "marié": "non",
        "enfants": "2",
        "salaire": "100000",
        "impôt": 19884,
        "surcôte": 4480,
        "décôte": 0,
        "réduction": 0,
        "taux": 0.41
    },
    {
        "marié": "non",
        "enfants": "3",
        "salaire": "100000",
        "impôt": 16782,
        "surcôte": 7176,
        "décôte": 0,
        "réduction": 0,
        "taux": 0.41
    },
    {
        "marié": "oui",
        "enfants": "3",
        "salaire": "100000",
        "impôt": 9200,
        "surcôte": 2180,
        "décôte": 0,
        "réduction": 0,
        "taux": 0.3
    },
    {
        "marié": "oui",
        "enfants": "5",
        "salaire": "100000",
        "impôt": 4230,
        "surcôte": 0,
        "décôte": 0,
        "réduction": 0,
        "taux": 0.14
    },
    {
        "marié": "non",
        "enfants": "0",
        "salaire": "100000",
        "impôt": 22986,
        "surcôte": 0,
        "décôte": 0,
        "réduction": 0,
        "taux": 0.41
    },
    {
        "marié": "oui",
        "enfants": "2",
        "salaire": "30000",
        "impôt": 0,
        "surcôte": 0,
        "décôte": 0,
        "réduction": 0,
        "taux": 0
    },
    {
        "marié": "non",
        "enfants": "0",
        "salaire": "200000",
        "impôt": 64210,
        "surcôte": 7498,
        "décôte": 0,
        "réduction": 0,
        "taux": 0.45
    },
    {
        "marié": "oui",
        "enfants": "3",
        "salaire": "20000",
        "impôt": 0,
        "surcôte": 0,
        "décôte": 0,
        "réduction": 0,
        "taux": 0
    }
]

确实有 10 次模拟。

JSON文件 [errors.json] 的内容如下:


{
    "numéro": 1,
    "erreurs": [
        {
            "marié": "ouix"
        },
        {
            "enfants": "2x"
        },
        {
            "salaire": "55555x"
        }
    ]
}

控制台输出如下(在详细模式下,服务器的 JSON 响应会显示在控制台上):


{"action":"init-session","état":700,"réponse":"session démarrée avec type [json]"}
{"action":"authentifier-utilisateur","état":200,"réponse":"Authentification réussie [admin, admin]"}
{"action":"calculer-impot","état":300,"réponse":{"marié":"oui","enfants":"2","salaire":"55555","impôt":2814,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"oui","enfants":"2","salaire":"50000","impôt":1384,"surcôte":0,"décôte":384,"réduction":347,"taux":0.14}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"oui","enfants":"3","salaire":"50000","impôt":0,"surcôte":0,"décôte":720,"réduction":0,"taux":0.14}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"non","enfants":"2","salaire":"100000","impôt":19884,"surcôte":4480,"décôte":0,"réduction":0,"taux":0.41}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"non","enfants":"3","salaire":"100000","impôt":16782,"surcôte":7176,"décôte":0,"réduction":0,"taux":0.41}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"oui","enfants":"3","salaire":"100000","impôt":9200,"surcôte":2180,"décôte":0,"réduction":0,"taux":0.3}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"oui","enfants":"5","salaire":"100000","impôt":4230,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"non","enfants":"0","salaire":"100000","impôt":22986,"surcôte":0,"décôte":0,"réduction":0,"taux":0.41}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"oui","enfants":"2","salaire":"30000","impôt":0,"surcôte":0,"décôte":0,"réduction":0,"taux":0}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"non","enfants":"0","salaire":"200000","impôt":64210,"surcôte":7498,"décôte":0,"réduction":0,"taux":0.45}}
{"action":"calculer-impot","état":300,"réponse":{"marié":"oui","enfants":"3","salaire":"20000","impôt":0,"surcôte":0,"décôte":0,"réduction":0,"taux":0}}
{"action":"lister-simulations","état":500,"réponse":[{"marié":"oui","enfants":"2","salaire":"55555","impôt":2814,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14,"arrayOfAttributes":null},{"marié":"oui","enfants":"2","salaire":"50000","impôt":1384,"surcôte":0,"décôte":384,"réduction":347,"taux":0.14,"arrayOfAttributes":null},{"marié":"oui","enfants":"3","salaire":"50000","impôt":0,"surcôte":0,"décôte":720,"réduction":0,"taux":0.14,"arrayOfAttributes":null},{"marié":"non","enfants":"2","salaire":"100000","impôt":19884,"surcôte":4480,"décôte":0,"réduction":0,"taux":0.41,"arrayOfAttributes":null},{"marié":"non","enfants":"3","salaire":"100000","impôt":16782,"surcôte":7176,"décôte":0,"réduction":0,"taux":0.41,"arrayOfAttributes":null},{"marié":"oui","enfants":"3","salaire":"100000","impôt":9200,"surcôte":2180,"décôte":0,"réduction":0,"taux":0.3,"arrayOfAttributes":null},{"marié":"oui","enfants":"5","salaire":"100000","impôt":4230,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14,"arrayOfAttributes":null},{"marié":"non","enfants":"0","salaire":"100000","impôt":22986,"surcôte":0,"décôte":0,"réduction":0,"taux":0.41,"arrayOfAttributes":null},{"marié":"oui","enfants":"2","salaire":"30000","impôt":0,"surcôte":0,"décôte":0,"réduction":0,"taux":0,"arrayOfAttributes":null},{"marié":"non","enfants":"0","salaire":"200000","impôt":64210,"surcôte":7498,"décôte":0,"réduction":0,"taux":0.45,"arrayOfAttributes":null},{"marié":"oui","enfants":"3","salaire":"20000","impôt":0,"surcôte":0,"décôte":0,"réduction":0,"taux":0,"arrayOfAttributes":null}]}
{"action":"supprimer-simulation","état":600,"réponse":[{"marié":"oui","enfants":"2","salaire":"55555","impôt":2814,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14,"arrayOfAttributes":null},{"marié":"oui","enfants":"3","salaire":"50000","impôt":0,"surcôte":0,"décôte":720,"réduction":0,"taux":0.14,"arrayOfAttributes":null},{"marié":"non","enfants":"2","salaire":"100000","impôt":19884,"surcôte":4480,"décôte":0,"réduction":0,"taux":0.41,"arrayOfAttributes":null},{"marié":"non","enfants":"3","salaire":"100000","impôt":16782,"surcôte":7176,"décôte":0,"réduction":0,"taux":0.41,"arrayOfAttributes":null},{"marié":"oui","enfants":"3","salaire":"100000","impôt":9200,"surcôte":2180,"décôte":0,"réduction":0,"taux":0.3,"arrayOfAttributes":null},{"marié":"oui","enfants":"5","salaire":"100000","impôt":4230,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14,"arrayOfAttributes":null},{"marié":"non","enfants":"0","salaire":"100000","impôt":22986,"surcôte":0,"décôte":0,"réduction":0,"taux":0.41,"arrayOfAttributes":null},{"marié":"oui","enfants":"2","salaire":"30000","impôt":0,"surcôte":0,"décôte":0,"réduction":0,"taux":0,"arrayOfAttributes":null},{"marié":"non","enfants":"0","salaire":"200000","impôt":64210,"surcôte":7498,"décôte":0,"réduction":0,"taux":0.45,"arrayOfAttributes":null},{"marié":"oui","enfants":"3","salaire":"20000","impôt":0,"surcôte":0,"décôte":0,"réduction":0,"taux":0,"arrayOfAttributes":null}]}
{"action":"fin-session","état":400,"réponse":"session supprimée"}
{"action":"lister-simulations","état":103,"réponse":["pas de session en cours. Commencer par action [init-session]"]}
Une erreur s'est produite : {"action":"lister-simulations","état":103,"réponse":["pas de session en cours. Commencer par action [init-session]"]}
Terminé

23.14.5. 测试 [Codeception]

与之前的客户端一样,版本 12 的客户端可以使用 [Codeception] 进行测试:

Image

客户端 [业务] 层的测试类代码与之前客户端的测试类类似:


<?php
 
// strict adherence to declared types of function parameters
declare (strict_types=1);
 
// namespace
namespace Application;
 
// definition of constants
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-12");
// configuration file path
define("CONFIG_FILENAME", ROOT . "/Data/config.json");
 
// we retrieve the configuration
$config = \json_decode(\file_get_contents(CONFIG_FILENAME), true);
 
// include the necessary script dependencies
$rootDirectory = $config["rootDirectory"];
foreach ($config["dependencies"] as $dependency) {
  require "$rootDirectory$dependency";
}
// absolute dependencies (third-party libraries)
foreach ($config["absoluteDependencies"] as $dependency) {
  require "$dependency";
}
// symfony dependencies
use Symfony\Component\HttpClient\HttpClient;
 
// test class
class ClientDaoTest extends \Codeception\Test\Unit {
  // dao layer
  private $clientDao;
 
  public function __construct() {
    parent::__construct();
    // we retrieve the configuration
    $config = \json_decode(\file_get_contents(CONFIG_FILENAME), true);
    // creation of the [dao] layer
    $clientDao = new ClientDao($config["urlServer"]);
    // creation of the [business] layer
    $this->métier = new ClientMetier($clientDao);
    // session initialization
    $this->métier->initSession("json");
    // authentication
    $this->métier->authentifierUtilisateur("admin", "admin");
  }
 
  // tests
  public function test1() {
    $simulation = $this->métier->calculerImpot("oui", 2, 55555);
    $this->assertEqualsWithDelta(2815, $simulation->getImpôt(), 1);
    $this->assertEqualsWithDelta(0, $simulation->getSurcôte(), 1);
    $this->assertEqualsWithDelta(0, $simulation->getDécôte(), 1);
    $this->assertEqualsWithDelta(0, $simulation->getRéduction(), 1);
    $this->assertEquals(0.14, $simulation->getTaux());
  }
 
  public function test2() {
    ….
  }
 

  public function test11() {

  }
 
}

评论

  • 第34–46行:请注意,测试类的构造函数会在每次测试执行前被调用;
  • 第 38–41 行:构建 [dao] [business] 层;
  • 第 42–45 行:测试方法 [test1…, test11] 用于测试 [calculateTax] 方法。为此,必须先初始化一个 JSON 会话并完成身份验证;

测试结果如下:

Image

还应执行许多其他测试:

  • 测试 [dao] 层的各种方法;
  • 测试Web服务器返回的状态码。这些状态码至关重要,因为其值决定了应显示哪个HTML页面;