Skip to content

23. 应用练习 – 第12版

在本章中,我们将编写一个遵循 MVC 架构(模型-视图-控制器)的 Web 应用程序。 该应用程序可输出三种格式的响应:jSON、XML、HTML。 接下来要完成的工作与之前的内容相比,复杂度有了显著提升。我们将复用迄今为止学到的绝大多数概念,并详细阐述通往最终应用程序的所有步骤。

23.1. MVC 架构

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

Image

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

  • 1 - 请求

请求的URL将采用以下形式:http://machine:port/contexte/….?action=uneAction&param1=v1&param2=v2&… [Contrôleur principal]将利用配置文件将请求“路由”至正确的控制器,并定位该控制器内的相应操作。为此,它将使用URL中的[action]字段。 URL [param1=v1&param2=v2&…] 的其余部分由可选参数组成,这些参数将传递给该操作。 此处的 MVC 的 C 字段即为字符串 [Contrôleur principal, Contrôleur / Action]。如果没有任何控制器能处理所请求的操作,Web 服务器将返回“未找到所请求的 URL”的响应。

  • 2 - 处理
    • 选定的操作 [2a] 可以利用 [Contrôleur principal] 传递给它的参数 parami。这些参数可能来自多个来源:
      • 来自 URL 的路径 [/param1/param2/…],
      • 来自 URL [/param1/param2/…] 路径,
      • 浏览器随请求发送的参数;
    • 在处理用户请求时,该操作可能需要 [métier][2b] 层。一旦处理了客户端的请求,该操作可能会触发各种响应。一个典型的例子是:
      • 如果请求无法正确处理,则返回错误响应;
      • 否则返回确认响应;
    • [Contrôleur / Action] 将向主控制器返回其响应 [2c] 以及一个状态码。这些状态码将唯一地表示应用程序当前的状态。它们要么是成功码,要么是错误码;
  • 3 - 响应
    • 根据客户端请求的响应类型(jSON、 XML 或 HTML,[Contrôleur principal] 将实例化 [3a] 以生成相应的响应类型,并要求其将响应发送给客户端。 [Contrôleur principal] 将向其传递由已执行的 [Contrôleur / Action] 提供的响应及状态码;
    • 如果所需的响应类型是 jSON 或 XML,则所选的响应将对 [Contrôleur / Action] 提供的响应进行格式化,并将其发送给 [3c]。 能够利用此响应的客户端可以是 PHP 控制台脚本,也可以是托管在 HTML 页面中的 JavaScript 脚本;
    • 如果期望的响应类型为 HTML,则所选响应将根据给定的状态码,从 HTML、[Vuei] 等视图中选择 [3b]。 这就是 MVC 对应的视图 V。每个状态码仅对应一个视图。该视图 V 将显示已执行的 [Contrôleur / Action] 的响应。 它使用 HTML、CSS 和 JavaScript 对该响应的数据进行渲染。这些数据被称为视图的模型。这就是 MVC 中的 M。此时,客户端通常是一个浏览器;

现在,让我们明确MVC Web架构与分层架构之间的联系。根据对模型的定义,这两个概念可能相关,也可能无关。以一个单层Web应用程序MVC为例:

Image

在上图中,每个[Contrôleur / Action]都集成了[métier]和[dao]层的一部分。 在 [web] 层中确实存在 MVC 架构,但整个应用程序并不具备分层架构。这里只有一个层负责所有功能。

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

Image

[web]层可以不遵循MVC模型来实现。这样虽然确实形成了一个多层架构,但Web层并未实现MVC模型。

例如,在 .NET 环境中,上述 [web] 层可以通过 ASP.NET 和 MVC 来实现,从而形成一种分层架构,其中 [web] 层属于 MVC 类型。 完成上述操作后,可以将该 ASP.NET MVC 层替换为经典的 ASP.NET 层(WebForms),同时保持其余部分 (业务层、DAO、驱动程序)保持不变。 此时便形成了一个分层架构,其中 [web] 层不再属于 MVC 类型。

在 MVC 中,我们提到模型 M 即视图 V(c.a.d)所展示的数据集合。这里给出了 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=authentifier-utilisateur]。 基于该操作,[Contrôleur principal] 将选择 [main.php],在此处为 [Contrôleur secondaire],以处理所请求的操作。 这些控制器也属于 MVC 的 C 部分;
  • [5]:文件夹 [Model] 将包含该应用程序的 [métier] [dao] 层。 根据先前采用的术语,这些元素代表领域模型,根据M所采用的术语,它们可代表MVC中的M;
  • [6]:文件夹 [Responses] 包含负责向客户端发送响应的类。每种期望的响应类型对应一个类:
    • [JsonResponse]:用于响应 jSON;
    • [XmlResponse]:用于响应 XML;
    • [HtmlResponse]:针对回复 HTML;
  • [7]:当需要响应 HTML 时,文件夹 [Views] 包含视图 HTML。 这是 MVC 的视图。它们由类 [HtmlResponse] 激活,该类向其传递待显示的数据。这些数据即视图的模板。根据 M 所采用的术语,这些数据可以是 MVC 的 M;
  • [8]:文件夹[Utilities]包含一些实用工具:
    • [Logger]:用于将日志写入文本文件的类;
    • [Sendmail]:用于发送邮件的类;
  • [9]:文件夹 [Logs] 包含日志文件 [logs.txt]
  • [10]:文件夹 [Entities] 包含各控制器使用的类;

借助此目录结构,可以描述客户端请求的操作的处理流程:

  • [main.php] 接收来自 [3] 的请求;
  • 在进行一些初步验证(该操作是否属于被接受的操作?)后,将其请求转发给负责处理该操作的次级控制器 [4]
  • 次级控制器执行其职责。在处理过程中,它可能需要调用 [métier][dao][5] 层,以及文件夹 [10] 中的实体。 它将响应返回给激活它的主控制器 [main.php]
  • 根据客户所需的响应类型 [jSON, XML, HTML],主控制器 [main.php] 激活文件夹 [Responses][6] 中的某个响应;
  • 响应 [JsonResponse, XmlResponse] 分别向客户发送响应 jSON 或 XML;
  • 响应 [HtmlResponse] 使用文件夹 [Views] [7] 中的某个视图,向客户端发送响应 HTML;
  • 各个控制器可访问文件夹 [8] 中的类 [Logger],以便将日志写入文件夹 [9] 中的日志文件。记录的内容包括:
    • 请求的操作;
    • 其控制器的响应。无论请求的类型为[jSON, XML, HTML]中的哪一种,该响应均以jSON格式记录;
  • 当发生致命错误(HTTP_INTERNAL_SERVER_ERROR)时,主控制器 [main.php] 将通过 [8] 文件夹中的 [SendMail] 类向管理员发送一封邮件;

23.3. 应用程序的操作

客户端将待执行的操作以 [action] 参数的形式,通过 URL 传递给 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:contrôleur]。每个操作都关联了一个负责处理该操作的辅助控制器;
  • 第3行:[init-session]:启动税务计算模拟会话。此操作指定所需的响应类型为[jSON, XML, HTML]
  • 第4行:确定会话类型后,客户需通过操作[authentifier-utilisateur]进行身份验证。在未通过身份验证前,除[init-session]外,所有其他操作均被禁止;
  • 第5行:身份验证通过后,客户可通过操作[calculer-impot]进行一系列税款计算;
  • 第6行:客户可随时通过操作[lister-simulations]查看其已进行的模拟列表;
  • 第7行:客户可通过操作[supprimer-simulation]删除其中部分模拟;
  • 第8行:客户通过操作[fin-session]结束模拟会话。此后,若需继续使用该应用程序,客户必须重新进行身份验证;
  • 第9行:在HTML应用程序中,操作[afficher-calcul-impot]调用显示用于计算税款的表单;

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 行:[action => contrôleur secondaire chargé de la traiter] 关联;
  • 第 66-70 行:[type de réponse => classe Response chargée d’envoyer la réponse au client] 关联;
  • 第 71-75 行:关联 [vue HTML => tableau des codes d’état menant à cette vue]
  • 第 76 行:每当发生异常错误时,视图 [vue-erreurs] 就会在会话 HTML 中显示:
    • 通常,jSON 或 XML 应用程序会通过编程客户端进行查询。客户端向服务器传递的参数可能缺失或有误。所有控制器都会处理这些情况,并向客户端返回错误代码。所有可能的错误情况都必须得到处理;
    • 而对于 HTML 应用程序,情况略有不同。在正常使用中,该 Web 应用程序仅使用 jSON 和 XML 客户端部分可能的使用场景。 举个例子:操作 [calculer-impot] 需要三个通过 POST 提交的参数(由 POST 发送):[marié, enfants, salaire]
      • 如果有一个客户端 jSON 允许手动输入 URL, 则可能向 [calculer-impot] 发起请求时,传入的却是 GET 而非 POST,或者传入 POST 时未提供任何 POST 参数,尽管实际需要三个参数,等等…… jSON服务器必须处理所有这些情况;
      • 对于Web应用程序,[calculer-impot]操作将通过Web表单发起,此时上述两种情况均不适用: [calculer-impot] 操作将与 POST 及三个 [marié, enfants, salaire] 参数一同被调用。 其中某些参数的值可能不正确,但它们会存在。不过,用户可以通过在浏览器中手动输入 URL 来重现某些错误。出于安全考虑,必须处理这种情况;
      • 每当次级控制器返回与 Web 应用程序不兼容的状态码(即配置文件第 72-74 行中未列出的状态码)时,都会显示视图 [vue-erreurs]。 出于教学目的,我们选择了此方案。另一种可能的做法是保持原状,仅重新显示客户端浏览器中当前显示的视图,从而让用户感觉服务器未响应其手动生成的 URL 请求;

23.5. 工具与库的安装

23.5.1. Postman

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

  • 使用任意 URL:这些请求均为手动编写;
  • 通过 GET、POST、PUT、OPTIONS 等向 Web 服务器发送请求;
  • 指定 GET 或 POST 的参数;
  • 设置请求的 HTTP 头部;
  • 接收格式为 jSON、XML、HTML 的响应,
  • 访问响应中的HTTP标头。因此,我们可以获取服务器返回的完整HTTP响应;

由于我们手动生成所查询的 URL,因此能够测试所有可能的错误情况,并观察服务器的反应。

[Postman] 可在 URL 和 [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] 进行同步。这通过旋转的齿轮图标 [7] 表示,每当您在项目 [Postman] 中进行修改时,该图标就会旋转。 若要停止此不必要的同步,请在 [8-9] 中注销;

23.5.2. Symfony / Serializer 库

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

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

[Symfony / Serializer] 库的文档可在 URL:[https://symfony.com/doc/current/components/serializer.html](2019年6月)中查阅。

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

Image

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

Image

23.6. 应用程序的实体

Image

[BaseEntity, Database, ExceptionImpots, TaxAdminData] 实体自 Web 服务 08 版起开始使用(参见链接段落)。

[Simulation] 类将用于封装税款计算模拟中的各项元素:


<?php

namespace Application;

class Simulation extends BaseEntity {
  // 税款计算模拟的属性
  protected $marié;
  protected $enfants;
  protected $salaire;
  protected $impôt;
  protected $surcôte;
  protected $décôte;
  protected $réduction;
  protected $taux;

  // 获取器
  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. [métier] [dao]

Image

Image

[métier][dao] 层的类和接口都位于 [Model] 文件夹中。它们均在之前的版本中已定义并使用过:

ExceptionImpots
[dao] 层抛出的异常类。定义详见链接部分。
InterfaceServerDao
由服务器 [dao] 层实现的接口。定义在“链接”段落中。
ServerDao
[InterfaceServerDao] 接口的实现。实现服务器的 [dao] 层。定义在链接落中
ServerDaoWithSession
接口 [InterfaceServerDao] 的实现。实现服务器的 [dao] 层。定义在链接段落中
InterfaceServerMetier
由服务器层 [métier] 实现的接口。定义在链接段落中。
ServerMetier
[InterfaceMetier] 接口的实现。实现了服务器的 [metier] 层。定义在链接段落中

当前正在编写的应用程序大量使用了已介绍和应用的组件:

  • [métier] [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),选择负责向客户端发送响应的控制器(JsonResponse、 XmlResponse, HtmlResponse) 负责向客户端发送响应,并向其传递所需的所有信息(请求 HTTP、会话、应用程序配置、辅助控制器的响应);
    • 在发送完该响应([3c])后,释放处理该请求时可能调用的资源;

23.9.2. [main.php] - 1

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


<?php

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

// 命名空间
namespace Application;

// Symfony 依赖关系
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;

// PHP 的错误处理
//ini_set("display_errors", "0");
error_reporting(E_ALL && !E_WARNING && !E_NOTICE);
// 获取配置
$configFilename = "config.json";
$fileContents = \file_get_contents($configFilename);
$erreur = FALSE;
// 错误?
if (!$fileContents) {
  // 记录错误
  $état = 131;
  $erreur = TRUE;
  $message = "Le fichier de configuration [$configFilename] n'existe pas";
}
if (!$erreur) {
  // 从配置文件中将代码 JSON 存入关联数组
  $config = \json_decode($fileContents, true);
  // 错误?
  if (!$config) {
    // 记录错误
    $erreur = TRUE;
    $état = 132;
    $message = "Le fichier de configuration [$configFilename] n'a pu être exploité correctement";
  }
}
// 错误?
if ($erreur) {
  // 准备服务器响应 JSON
  // 无法使用配置文件
  // symfony 依赖项
  require_once "C:/myprograms/laragon-lite/www/vendor/autoload.php";
  // 准备响应
  $response = new Response();
  $response->headers->set("content-type", "application/json");
  $response->setCharset("utf-8");
  // 状态码
  $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
  // 内容
  $response->setContent(json_encode(["action" => "", "état" => $état, "réponse" => $message], JSON_UNESCAPED_UNICODE));
  // 发送
  $response->send();
  // 结束
  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]:客户端请求的操作;
      • [état]:执行该操作后的应用程序状态;
      • [réponse]: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 Internal Server Error]
  • [23] ,[Body] 表示响应正文,即服务器在 HTTP [28] 这些标头之后发送的文档;
  • [26] 中,可以看到 [Postman] 已收到响应 jSON;
  • [27] 中,是经过格式化的响应 jSON;
  • [28] 中,是未格式化的原始响应 jSON;
  • [29] 模式下,当响应为 [Preview] 时,将使用 [Preview] 模式。此时,[Preview] 模式将显示接收到的页面;
  • [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],并选择 [duplicate] 选项以复制 [2] 请求;
  • [4] 中,新查询有一个预设名称,将其更改为 [5]

Image

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

Image

  • [11] 中,服务器返回的响应 jSON;

本文展示了如何测试税务计算Web服务的各项操作。

23.9.4. [main.php] – 2

我们继续研究主控制器代码 [main.php]


<?php

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

// 命名空间
namespace Application;

// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;

// PHP 的错误处理
//ini_set("display_errors", "0");
error_reporting(E_ALL && !E_WARNING && !E_NOTICE);
// 获取配置
$configFilename = "config.json";

// 引入脚本所需的依赖项
$rootDirectory = $config["rootDirectory"];
foreach ($config["relativeDependencies"] as $dependency) {
  require_once "$rootDirectory$dependency";
}
// 绝对依赖项(第三方库)
foreach ($config["absoluteDependencies"] as $dependency) {
  require_once "$dependency";
}

// 创建日志文件
try {
  $logger = new Logger($config['logsFilename']);
} catch (ExceptionImpots $ex) {
  // 无法创建日志文件 - 内部服务器错误
  $é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éé"],
    []);
  // 完成
  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 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

interface InterfaceResponse {

  // 请求 $request:正在处理请求
  // 会话 $session:Web 应用程序的会话
  // 数组 $config:应用程序配置
  // int statusCode:响应状态码
  // 数组 $content:服务器响应
  // 数组 $headers:要添加到响应中的 HTTP 头部
  // Logger $logger:用于写入日志的日志器
  
  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 依赖项
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:正在处理的请求
  // 会话 $session:Web 应用程序的会话
  // 数组 $config:应用程序配置
  // int statusCode:响应状态码
  // 数组 $content:服务器响应
  // 数组 $headers:要添加到响应中的 HTTP 头部
  // Logger $logger:用于写入日志的日志器

  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void {

    // Symfony序列化器的准备
    $serializer = new Serializer(
      [
      // 对象序列化所需
      new ObjectNormalizer()],
      // 编码器 jSON
      // 用于选项,在不同选项之间使用 OU
      [new JsonEncoder(new JsonEncode([JsonEncode::OPTIONS => JSON_UNESCAPED_UNICODE]))]
    );
    // 序列化 jSON
    $json = $serializer->serialize($content, 'json');
    // 头部
    $headers = array_merge($headers, ["content-type" => "application/json"]);
    // 发送响应
    parent::sendResponse($statusCode, $json, $headers);
    // 日志
    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 行:添加 HTTP 头部,告知客户端将向其发送 jSON;
  • 第 46 行:请求父类将响应发送给客户端;
  • 第 48-50 行:记录响应 jSON;

父类 [ParentResponse] 的代码如下:


<?php

namespace Application;

// Symfony 依赖项
use Symfony\Component\HttpFoundation\Response;

class ParentResponse {

  // int $statusCode:响应状态码
  // 字符串 $content:待发送的响应正文
  // 根据具体情况,可能是字符串 jSON、XML 或 HTML
  // 数组 $headers:需添加到响应中的标头 HTTP

  public function sendResponse(
    int $statusCode,
    string $content,
    array $headers): void {

    // 服务器文本响应的准备
    $response = new Response();
    $response->setCharset("utf-8");
    // 状态码
    $response->setStatusCode($statusCode);
    // 头部
    foreach ($headers as $text => $value) {
      $response->headers->set($text, $value);
    }
    // 发送响应
    $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:正在处理请求
  // 会话 $session:Web 应用程序的会话
  // 数组 $config:应用程序配置
  // int statusCode:响应状态码 HTTP
  // array $content:服务器的响应
  // 数组 $headers:需添加到响应中的 HTTP 头部
  // Logger $logger:用于写入日志的日志记录器
  
  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],命名为 [erreur-133]

Image

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

23.9.6. [main.php] – 3

继续研究主控制器 [main.php]


<?php

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

// 命名空间
namespace Application;

// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;

// PHP 的错误处理


// 日志文件的创建


// 第一个日志
$logger->write("\n---nouvelle requête\n");
// 当前请求
$request = Request::createFromGlobals();

// 会话
$session = new Session();
$session->start();
// 错误列表
$erreurs = [];
$erreur = FALSE;
// 处理请求的操作
if (!$request->query->has("action")) {
  $erreurs[] = "paramètre [action] manquant";
  $erreur = TRUE;
  $état = 101;
  $action = "";
} else {
  // 保存操作
  $action = strtolower($request->query->get("action"));
}
// 正在记录该操作
$logger->write("action [$action] demandée\n");

// 该操作是否存在?
if (!$erreur && !array_key_exists($action, $config["actions"])) {
  $erreurs[] = "action [$action] invalide";
  $erreur = TRUE;
  $état = 102;
}

// 执行某些操作前必须已知会话类型
if (!$erreur && !$session->has("type") && $action !== "init-session") {
  $erreurs[] = "pas de session en cours. Commencer par action [init-session]";
  $erreur = TRUE;
  $état = 103;
}

// 执行某些操作时必须经过身份验证
if (!$erreur && !$session->has("user") && $action !== "authentifier-utilisateur" && $action !== "init-session") {
  $erreurs[] = "action demandée par utilisateur non authentifié";
  $erreur = TRUE;
  $état = 104;
}

// 错误?
if ($erreurs) {
  // 准备响应但不发送  
  $statusCode = Response::HTTP_BAD_REQUEST;
  $content = ["réponse" => $erreurs];
  $headers = [];
} else {
  // ---------------------------
  // 通过控制器执行操作
  $controller = __NAMESPACE__ . $config["actions"][$action];
  $logger->write("contrôleur : $controller\n");
  list($statusCode, $état, $content, $headers) = (new $controller())->execute($config, $request, $session);
}

// --------------------- 发送响应
// 发生致命错误的情况 HTTP_INTERNAL_SERVER_ERROR
// 如果可以,则向管理员发送邮件
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();
}
// 响应取决于会话类型
if ($session->has("type")) {
  // 会话类型存储在会话中
  $type = $session->get("type");
} else {
  // 如果会话中没有类型,则默认返回 jSON
  $type = "json";
}
// 在控制器响应中添加键 [action, état]
$content = ["action" => $action, "état" => $état] + $content;
// 实例化 [Response] 对象,负责将响应发送给客户端
$response = __NAMESPACE__ . $config["types"][$type]["response"];
(new $response())->send($request, $session, $config, $statusCode, $content, $headers, $logger);

// 响应已发送 - 释放资源
$logger->close();
exit;

注释

  • 在完成初步验证并确认可以运行后,主控制器开始处理所请求的操作:该操作必须满足某些条件;
  • 第21行:记录收到新请求。此前无法执行此操作,因为无法确定日志文件是否有效;
  • 第23行:将客户端请求的所有信息封装到Symfony对象[Request]中;
  • 第26行:启动新会话,若已有会话则获取现有会话;
  • 第 27 行:启用会话;
  • 第 29 行:一个错误消息数组;
  • 第 30 行:一个布尔值,在测试过程中用于指示是否遇到错误;
  • 第 32 行:参数 [action] 必须作为 URL 的组成部分,形式为 [main.php?action=uneAction]。 因此,参数 [action] 属于参数 [$request→query]
  • 第33-36行:[action]参数在URL中缺失的情况。该错误被记录,并被分配状态[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]和[authentifier-utilisateur]外,所有其他操作必须在身份验证后进行。 身份验证通过操作 [authentifier-utilisateur] 完成,若验证成功,该操作会在会话中设置一个 [user] 密钥;
  • 第59行:若操作既非[init-session]也非[authentifier-utilisateur],且会话中不存在密钥[user],则发生错误;
  • 第 60-62 行:记录该错误并将其状态设为 [104]
  • 第66-71行:检查数组[$erreurs]是否为空。如果为空,则说明请求的操作或其执行上下文存在错误;
  • 第 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"
            },

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

  • 第75行:记录次级控制器的名称,以便在开发过程中进行验证;
  • 第76行:执行子控制器。我们稍后将详细讨论子控制器;
  • 第76行:所有次级控制器返回的都是同类型的结果,即一个数组:
    • 数组 [$statusCode] 的第一个元素是待发送响应的状态码 HTTP;
    • 第二个元素 [$état] 是控制器执行后的应用程序状态;
    • 第三个元素 [$content] 是一个关联数组,其唯一键 [réponse] 即为要发送给客户端的响应正文;
    • 第四个元素 [$headers] 是一个标题数组 HTTP,用于添加到发送给客户端的响应中;
  • 第 79 行:到达此处:
    • 要么是因为发生了错误(第68-70行);
    • 或者是在执行完一个控制器(第72-76行)之后;
    • 无论哪种情况,生成客户响应所需的 [$statusCode, $état, $content, $headers] 元素均已确定;
  • 第82-87行:处理状态码[500 Internal Server Error]的特殊情况。 若控制器返回此状态码,则表明应用程序无法运行。例如,在计算税款时,若所使用的SGBD未被启动或已无响应,即会出现这种情况。此时将向应用程序管理员发送邮件进行通知。 我们不会对该代码进行特别说明。[SendAdminMail]类的使用方法已在(链接段落)中介绍过;
  • 第 89-95 行:确定 Web 应用程序的类型 [jSON, XML, HTML]。如果操作 [init-session] 已成功执行,则该类型会与键 [type] 关联在会话中 (第 91 行)。若未成功,则为响应任意设定类型,即类型 jSON(第 94 行);
  • 第 97 行:[$content] 是一个数组,包含唯一键 [réponse] 和唯一值(即发送给客户端的响应正文)。向其中添加键 [action] [état]。 键 [action] 将有助于更好地追踪文件 [logs.txt] 的日志。键 [état] 将发挥两个作用:
    • 它将使 jSON 和 XML 客户端能够了解已执行的操作将 Web 应用程序置于何种状态;
    • 若响应为 HTML,则可据此选择应发送至客户端浏览器的视图 HTML;
  • 第 99 行:选择要执行的 [Response] 类,以将响应发送给客户端;

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

相关响应文件均位于 [Responses] 文件夹中:

Image

所有这些类都实现了[InterfaceResponse]接口,该接口在以下链接中也有介绍:


<?php

namespace Application;

// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

interface InterfaceResponse {

  // 请求 $request:请求正在处理中
  // 会话 $session:Web 应用程序的会话
  // 数组 $config:应用程序配置
  // int statusCode:响应状态码
  // 数组 $content:服务器响应
  // 数组 $headers:要添加到响应中的 HTTP 头部
  // Logger $logger:用于写入日志的日志记录器
  
  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] 的代码:



// 在控制器响应中添加键 [action, état]
$content = ["action" => $action, "état" => $état] + $content;
// 实例化 [Response] 对象,负责将响应发送给客户端
$response = __NAMESPACE__ . $config["types"][$type];
(new $response())->send($request, $session, $config, $statusCode, $content, $headers, $logger);

// 响应已发送 - 释放资源
$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] 中:
    • [erreur-101]:URL中缺少参数[action]的情况;
    • [erreur-102][action]参数存在于URL中但未被识别的情况;
    • [erreur-103]:参数 [action] 存在于 URL 中,且已被识别,但未定义预期的响应类型 [json, xml, html]

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

上图:

  • [2-4] 中,一个不包含 [action] 参数的请求,该参数在 URL [4] 中存在;
  • [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 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

interface InterfaceController {

  // $config 是应用程序的配置
  // 处理请求 Request
  // 使用 Session 会话并可对其进行修改
  // $infos 是每个控制器特有的附加信息
  
  // 返回一个数组 [$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, $état, $content, $headers]
    • [int $statusCode]:HTTP响应的状态码;
    • [int $état]:应用程序在执行结束时的状态;
    • [array $content]:一个关联数组 [réponse=>résultat],其中 [résultat] 类型不限:这是控制器生成的结果,在将该结果序列化为字符串后,将发送给客户端;
    • [array $headers]:待嵌入服务器响应 HTTP 中的 HTTP 头部列表;

每个子控制器由主控制器的以下代码调用:


// 通过其控制器执行该操作
 $controller = __NAMESPACE__ . $config["actions"][$action];
 list($statusCode, $état, $content, $headers) = (new $controller())->execute($config, $request, $session);

第 3 行可见,方法 [execute] 的第 4 个参数 [array $infos=NULL] 未被使用。

23.11. 操作

现在我们来回顾Web服务可能执行的各种操作:

操作
角色
执行上下文
init-session
用于设定所需响应的类型(json、xml、html)
请求 GET main.php?action=init-session&type=x
可随时发出
authentifier-utilisateur
授权或拒绝用户登录
请求 POST main.php?action=authentifier-utilisateur
该请求必须包含两个POST参数 [user, password]
仅当会话类型(json、xml、html)已知时才能发出
计算税款
进行税额计算模拟
请求 POST main.php?action=calculer-impot
该请求必须包含三个POST参数 [marié, enfants, salaire]
仅当会话类型(json、xml、html)已知且用户已通过身份验证时才能发出
lister-simulations
请求查看自会话开始以来执行的模拟列表
请求 GET main.php?action=lister-simulations
该请求不接受任何其他参数
仅当会话类型(json、xml、html)已知且用户已通过身份验证时,方可发出
删除模拟
从模拟列表中删除一个模拟
请求 GET main.php?action=lister-simulations&numéro=x
该请求不接受任何其他参数
仅当会话类型(json、xml、html)已知且用户已通过身份验证时,方可发出
结束会话
结束模拟会话。
从技术上讲,旧的 Web 会话将被删除,并创建一个新的会话
仅当已知会话类型(json、xml、html)且用户已通过身份验证时,才可发出

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

  • 它们会检查自身参数。 对于存在于 URL 中的参数,可在 [Request→query] 对象中找到;对于通过 POST 提交的参数(请求 POST),则可在 [Request→request] 对象中找到;
  • 控制器类似于一个用于验证其参数有效性的函数或方法。不过,对于控制器来说,情况要稍微复杂一些:
    • 预期的参数可能缺失;
    • 预期的参数全是字符串,而函数可以固定其参数的类型。如果预期参数是一个数字,则必须验证该参数的字符串是否确实是一个数字;
    • 在验证完预期参数已存在且语法正确后,还需验证它们在当前执行上下文中是否有效。该上下文存在于会话中。 身份验证便是执行上下文的一个示例。某些操作必须在客户端通过身份验证后才能处理。通常,会话中的某个键会标记身份验证是否已通过;
    • 完成上述验证后,次级控制器方可开始工作。参数验证工作至关重要。我们绝不能接受客户端在应用程序生命周期的任何时刻发送任意数据。必须对应用程序的生命周期进行全面控制;
    • 完成工作后,辅助控制器将返回主控制器所调用的预期数组 [$statusCode, $état, $content, $headers]

接下来,我们将逐一审视各个控制器,或者说,那些主导Web应用程序运行节奏的各项操作。

23.11.1. 操作 [init-session]

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


<?php

namespace Application;

// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;

class InitSessionController implements InterfaceController {

  // $config 是应用程序的配置
  // 处理一个 Request 请求
  // 使用 Session 会话并可对其进行修改
  // $infos 是每个控制器特有的附加信息
  
  // 返回一个数组 [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {

    // 必须包含一个 GET 以及除 [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], []];
    }
    // 获取 GET 的参数
    $erreur = FALSE;
    // 类型
    if (!$request->query->has("type")) {
      $erreur = TRUE;
      $état = 702;
      $message = "paramètre [type] manquant";
    } else {
      $type = strtolower($request->query->get("type"));
    }
    // 类型验证
    if (!$erreur && !array_key_exists($type, $config["types"])) {
      $erreur = TRUE;
      $état = 703;
      $message = "paramètre type [$type] invalide";
    }
    // 错误?
    if ($erreur) {
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // 将会话类型写入会话
    $session->set("type", $type);
    // 成功消息
    $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行:验证该请求是否为包含两个参数的GET请求(参数位于URL中);
  • 第27-31行:若非如此,则记录错误并将结果[$statusCode, $état, $content, $headers]发送给主控制器;
  • 第35-39行:验证[type]参数是否确实存在于URL中。若不存在,则记录错误;
  • 第 40 行:记录会话类型;
  • 第43-47行:验证会话类型是否为以下任一格式(json、xml、html)。若不符,则记录错误;
  • 第49-51行:若发生错误,则向主控制器发送结果[$statusCode, $état, $content, $headers]
  • 第 53 行:将会话类型写入 Web 应用程序的会话中;
  • 第55-57行:控制器完成工作。向主控制器发送成功结果[$statusCode, $état, $content, $headers]

回顾一下主控制器对子控制器响应的处理流程:


// 错误?
if ($erreurs) {
  // 准备响应但不发送  
  $statusCode = Response::HTTP_BAD_REQUEST;
  $content = ["réponse" => $erreurs];
  $headers = [];
} else {
  // ---------------------------
  // 通过控制器执行操作
  $controller = __NAMESPACE__ . $config["actions"][$action];
  $logger->write("contrôleur : $controller\n");
  list($statusCode, $état, $content, $headers) = (new $controller())->execute($config, $request, $session);
}

// --------------------- 发送响应
// 发生致命错误的情况 HTTP_INTERNAL_SERVER_ERROR
// 如果可以,则向管理员发送邮件
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();
}
// 响应取决于会话类型
if ($session->has("type")) {
  // 会话类型存储在会话中
  $type = $session->get("type");
} else {
  // 如果会话中没有类型,则默认返回 jSON
  $type = "json";
}
// 在控制器响应中添加键 [action, état]
$content = ["action" => $action, "état" => $état] + $content;
// 实例化 [Response] 对象,负责将响应发送给客户端
$response = __NAMESPACE__ . $config["types"][$type]["response"];
(new $response())->send($request, $session, $config, $statusCode, $content, $headers, $logger);

// 响应已发送 - 释放资源
$logger->close();
exit;
  • 第12行:主控制器获取子控制器的结果;
  • 第35-36行:经过若干验证后,它根据当前会话的类型(json、xml、html)实例化相应的[JsonResponse, XmlResponse, HtmlResponse]类来发送响应;

接下来,我们将针对[Postman]类进行测试,作为使用[json]类型的模拟会话的一部分。[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] 中,操作 [init-session] 具有类型 jSON;
  • [9-12] 中,服务器响应为 jSON;

23.11.3. 操作 [authentifier-utilisateur]

操作 [authentifier-utilisateur] 由以下控制器 [AuthentifierUtilisateurController] 执行:


<?php

namespace Application;

// Symfony 依赖项
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;

class AuthentifierUtilisateurController implements InterfaceController {

  // $config 是应用程序的配置
  // 处理请求 Request
  // 使用会话 Session 并可对其进行修改
  // $infos 是每个控制器特有的附加信息
  // 返回一个数组 [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {

    // 应包含一个 POST 以及一个唯一的参数 GET
    $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 [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // 获取 POST 的参数
    $erreurs = [];
    // 用户
    $état = 210;
    if (!$request->request->has("user")) {
      $état += 2;
      $erreurs[] = "paramètre [user] manquant";
    } else {
      $user = $request->request->get("user");
    }
    // 密码
    if (!$request->request->has("password")) {
      $état += 4;
      $erreurs[] = "paramètre [password] manquant";
    } else {
      $password = trim($request->request->get("password"));
    }
    // 错误?
    if ($erreurs) {
      // 将结果返回给主控制器
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $erreurs], []];
    }
    // 验证用户凭据
    // 用户是否存在?
    $users = $config["users"];
    $i = 0;
    $trouvé = FALSE;
    while (!$trouvé && $i < count($users)) {
      $trouvé = ($user === $users[$i]["login"] && $users[$i]["passwd"] === $password);
      $i++;
    }
    // 找到?
    if (!$trouvé) {
      // 错误信息
      $message = "Echec de l'authentification [$user, $password]";
      $état = 221;
      // 将结果返回给主控制器
      return [Response::HTTP_UNAUTHORIZED, $état, ["réponse" => $message], []];
    } else {
      // 在会话中记录已验证用户
      $session->set("user", TRUE);
      // 成功消息
      $message = "Authentification réussie [$user, $password]";
      $état = 200;
      // 将结果返回给主控制器
      return [Response::HTTP_OK, $état, ["réponse" => $message], []];
    }
  }

}

注释

  • 等待包含两个POST参数的[POST main.php?action=authentifier-utilisateur]请求;
  • 第24-25行:验证是否收到包含URL中唯一参数的POST请求;
  • 第26-31行:若存在错误,则记录错误并向主控制器返回结果[$statusCode, $état, $content, $headers]
  • 第36-39行:检查提交的值中是否包含参数[user]。若不存在,则记录该错误;
  • 第43-45行:检查提交的值中是否包含参数[password]。若不存在,则记录错误;
  • 第 50-53 行:如果任何一个已提交的值缺失,则向主控制器返回结果 [$statusCode, $état, $content, $headers]
  • 第56-62行:检查从配置文件中获取的[$user,$password]参数对是否存在于[$config[‘users’]]数组中;
  • 第 64-69 行:若不存在,则记录错误。将状态码 HTTP 设为 [Response::HTTP_UNAUTHORIZED],并将结果 [$statusCode, $état, $content, $headers] 返回给主控制器;
  • 第 72 行:身份验证成功。通过在会话中设置密钥 [user] 来记录此结果。该密钥的存在即表明身份验证成功;
  • 第 73-77 行:向主控制器返回成功结果 [$statusCode, $état, $content, $headers]

23.11.4. [Postman] 测试

我们以 jSON 模式对控制器 [AuthentifierUtilisateurController] 进行 [Postman] 测试;

Image

上文:

  • [1-6] 中,[authentifier-utilisateur] 操作使用了 GET [2],而实际应使用 POST;
  • [7-10] 中,服务器返回的响应为 jSON;

将 GET 替换为 POST [2],且不在响应正文中添加参数 [7]

Image

上文:

  • [1-7] 中,POST 未在 [7] 中提交参数;
  • [8-11] 中,包含服务器返回的 jSON 响应;

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

Image

如上:

  • [1-6] 中,包含一个 POST 请求,该请求包含一个 [2] 参数,该参数通过 [password] 提交,并发送到 [4-6]。 提交的参数应添加到请求 [4] 的正文(body)中。向服务器提交值有多种方法。我们选择 [x-www-form-urlencoded] [5] 方法;
  • [8-10] 中,服务器返回的响应为 jSON;

现在我们定义参数 [user],但不使用参数 [password]

Image

上文:

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

现在我们定义两个提交的参数 [user, password],但使用会导致身份验证失败的值:

Image

上文:

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

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

Image

上文:

  • [1-9],请求 POST [2] 带有有效凭证 [6-9]
  • [10-13] 中,服务器返回的响应为 jSON。请注意状态码 HTTP [200 OK] 以及 [10]

23.11.5. 操作 [calculer-impot]

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


<?php

namespace Application;

// Symfony 依赖项
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
// [dao] 层的别名
use \Application\ServerDaoWithSession as ServerDaoWithRedis;

class CalculerImpotController implements InterfaceController {

  // $config 是应用程序的配置
  // 处理一个 Request 请求
  // 使用 Session 会话并可对其进行修改
  // $infos 是每个控制器特有的附加信息
  // 返回一个数组 [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {

    // 应包含一个参数 GET 和三个参数 POST
    $method = strtolower($request->getMethod());
    $erreur = $method !== "post" || $request->query->count() != 1;
    if ($erreur) {
      // 记录错误
      $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 [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // 获取 POST 的参数
    $erreurs = [];
    $état = 310;
    // 婚姻状况
    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é]";
      }
    }
    // 获取子女数量
    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]";
      }
    }
    // 获取年薪
    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]";
      }
    }
    // 错误?
    if ($erreurs) {
      // 将结果返回给主控制器
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $erreurs], []];
    }

    // 已具备开展工作所需的所有信息
    // Redis
    \Predis\Autoloader::register();
    try {
      // 客户端 [predis]
      $redis = new \Predis\Client();
      // 连接服务器以检查其是否在线
      $redis->connect();
    } catch (\Predis\Connection\ConnectionException $ex) {
      // 出错了
      // 将带错误的结果返回给主控制器
      $état = 350;
      return [Response::HTTP_INTERNAL_SERVER_ERROR, $état,
        ["réponse" => "[redis], " . utf8_encode($ex->getMessage())], []];
    }

    // 参数有效
    // 创建图层 [dao]
    if (!$redis->get("taxAdminData")) {
      try {
        // 从数据库中检索税务数据
        $dao = new ServerDaoWithRedis($config["databaseFilename"], NULL);
        // 将检索到的数据存入 Redis
        $redis->set("taxAdminData", $dao->getTaxAdminData());
      } catch (\RuntimeException $ex) {
        // 操作失败
        // 将带错误的结果返回给主控制器
        $état = 340;
        return [Response::HTTP_INTERNAL_SERVER_ERROR, $état,
          ["réponse" => utf8_encode($ex->getMessage())], []];
      }
    } else {
      // 从作用域内存中获取税务数据 [application]
      $arrayOfAttributes = \json_decode($redis->get("taxAdminData"), true);
      $taxAdminData = (new TaxAdminData())->setFromArrayOfAttributes($arrayOfAttributes);
      // 实例化层 [dao]
      $dao = new ServerDaoWithRedis(NULL, $taxAdminData);
    }
    // 创建 [métier] 层
    $métier = new ServerMetier($dao);

    // 现在已具备工作所需的一切条件——计算税款
    $résultat = $métier->calculerImpot($marié, (int) $enfants, (int) $salaire);
    // 将刚刚完成的模拟添加到会话中
    $simulation = new Simulation();
    $résultat = ["marié" => $marié, "enfants" => $enfants, "salaire" => $salaire] + $résultat;
    $simulation->setFromArrayOfAttributes($résultat);
    // 会话中是否存在模拟列表?
    if (!$session->has("simulations")) {
      $simulations = [];
    } else {
      $simulations = $session->get("simulations");
    }
    // 将模拟添加到模拟列表中
    $simulations[] = $simulation;
    // 将模拟结果重新存入会话
    $session->set("simulations", $simulations);
    // 将结果返回给主控制器
    $état = 300;
    return [Response::HTTP_OK, $état, ["réponse" => $résultat], []];
  }

}

注释

  • 预期的请求是 [POST main.php?action=calculer-impot],带有三个提交的参数 [marié, enfants, salaire]
    • [marié] 的值必须在 [oui, non] 中定义;
    • [enfants, salaire] 必须为正整数或零;
  • 第26-27行:验证URL中是否确实包含一个仅含单一参数的POST;
  • 第28-34行:若非如此,则向主控制器发送错误结果;
  • 第36行:将错误消息累积到数组[$erreurs]中;
  • 第39-41行:检查参数[marié]是否存在。若不存在,则记录该错误;
  • 第43-49行:验证[marié]的值是否存在于[oui, non]中。若不存在,则记录该错误;
  • 第51-54行:检查参数[enfants]是否存在。若不存在,则记录错误;
  • 第55-61行:检查参数[enfants]的值是否为正数或零。若非如此,则记录错误;
  • 第 63-66 行:检查参数 [salaire] 是否存在。若不存在,则记录错误;
  • 第 67-72 行:验证参数 [salaire] 的值是否为正数或零。若不满足此条件,则记录错误;
  • 第 75-78 行:如果数组 [$erreurs] 不为空,则说明存在错误。将错误数组放入响应中,并将结果返回给主控制器;
  • 第80行:参数有效。可以计算税款。为此,需构建能够执行此计算的[dao][métier]层;
  • 第82-94行:创建客户端[Redis]
  • 第88-94行:若无法连接到服务器[Redis],则向客户端发送代码[500 Internal Server Error]
  • 第98行:检查服务器[Redis]是否拥有密钥[taxAdminData]。该密钥代表税务管理数据。若密钥不存在,则需从数据库中检索税务数据;
  • 第101行:当需从数据库中获取税务数据时,构建[dao]层[ServerDaoWithRedis]类已在“链接”段落中描述;
  • 第103行:从数据库中检索到的数据被存入[Redis]存储区,其键为[taxAdminData]
  • 第104-110行:若数据库查询失败,则记录[dao]层返回的错误,并将其纳入返回给主控制器的结果中;
  • 第109行:[PDO]层返回的错误消息被编码为[iso-8859-1]。将其编码为[utf-8]
  • 第111-117行:如果[taxAdminData]键在[Redis]存储中存在,则税务数据将直接传递给[dao]层的构造函数;
  • 第 119 行:创建 [métier] 层。[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],分别测试带或不带 POST 参数的情况,以及使用无效的 POST 参数的情况:

Image

如上:

  • 我们发送了一个包含无效POST参数([6-11]、[marié, enfants, salaire])的请求:[POST]、[2]。 您可以通过在 [16] 中取消勾选相应复选框,来选择不提交其中某个参数。这将使您能够测试不同的情况。在上方的屏幕截图中,三个参数均存在且全部无效;
  • [12-15] 中,服务器返回的响应为 jSON;

现在,让我们取消勾选所发送的三个参数中的两个:

Image

如上所示,

  • [5-8] 中,仅发送了 [salaire] 参数,且该参数无效;
  • [9-11] 中,服务器返回的结果为 jSON;

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

Image

上文:

  • [1118] 中,包含有效参数的请求 [6-8]
  • [12-14] 中,服务器返回的响应为 jSON;

23.11.7. 操作 [lister-simulations]

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


<?php

namespace Application;

// Symfony 依赖项
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;

class ListerSimulationsController {

  // $config 是应用程序的配置
  // 处理一个 Request 请求
  // 使用 Session 会话并可对其进行修改
  // $infos 是每个控制器特有的附加信息
  // 返回一个数组 [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {

    //必须仅有一个参数 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 [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // 获取会话中的模拟列表
    if (!$session->has("simulations")) {
      $simulations = [];
    } else {
      $simulations = $session->get("simulations");
    }
    // 向主控制器返回成功结果
    $état = 500;
    return [Response::HTTP_OK, $état, ["réponse" => $simulations], []];
  }

}

注释

  • 请求 [GET main.php?action=lister-simulations]
  • 第 24-25 行:验证是否存在仅含一个参数的 GET 请求;
  • 第26-31行:若非如此,则向主控制器返回带错误的结果;
  • 第33-37行:若会话中存在模拟列表则将其获取(第36行),否则该列表为空(第34行);
  • 第39-40行:将模拟列表返回给主控制器;

23.11.8. 测试 [Postman]

我们将创建两个测试,一个是错误测试,另一个是成功测试。

Image

上文:

  • [1-8] 中,我们发起了一个 [GET] 请求,其中 URL [3, 7-8] 请求中多了一个 [param1] 参数;
  • [9-12] 中,服务器返回的响应为 jSON;

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

Image

上文:

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

请求结果如下:

Image

  • [3-6] 中,服务器返回的响应为 jSON。在本次测试之前,已多次执行 [Postman] 和 [calculer-impot-300] 测试,以在服务器的 Web 会话中创建模拟;

23.11.9. 操作 [supprimer-simulation]

操作 [supprimer-simulation] 由以下辅助控制器 [SupprimerSessionController] 处理:


<?php

namespace Application;

// Symfony 依赖项
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;

class SupprimerSimulationController {

  /// $config 是应用程序的配置
  // 处理一个 Request 请求
  //使用会话(Session)并可对其进行修改
  // $infos 是每个控制器特有的附加信息
  // 返回一个数组 [$statusCode, $état, $content, $headers]
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {

    // 必须包含两个参数 GET
    $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]";
    }
    // 参数 [numéro] 必须存在
    if (!$erreur) {
      $état += 4;
      $erreur = !$request->query->has("numéro");
      if ($erreur) {
        $message = "paramètre [numéro] manquant";
      }
    }
    // 参数 [numéro] 必须有效
    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";
      }
    }
    // 参数 [numéro] 必须在区间 [0,n-1] 内
    // 若 n 为模拟次数
    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";
      }
    }
    // 错误?
    if ($erreur) {
      // 将结果返回给主控制器
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }
    // 删除模拟$numéro
    unset($simulations[$numéro]);
    $simulations = array_values($simulations);
    // 将模拟重新放入会话
    $session->set("simulations", $simulations);
    // 将模拟列表返回给客户端
    $état = 600;
    return [Response::HTTP_OK, $état, ["réponse" => $simulations], []];
  }

}

注释

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

23.11.10. [Postman] 测试

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

Image

上文:

  • [1-6] 中,一个缺少参数 [numéro] 的 GET 请求;
  • [7-10] 中,服务器返回的响应为 jSON;

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

Image

上文:

  • [1-5] 中,包含一个带有无效参数 [numéro] 的请求 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. 操作 [fin-session]

操作 [fin-session] 由以下辅助控制器 [FinSessionController] 处理:


<?php

namespace Application;

// Symfony 依赖项
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;

class FinSessionController implements InterfaceController {

  // $config 是应用程序的配置
  // 处理一个 Request 请求
  // 使用 Session 会话并可对其进行修改
  // $infos 是每个控制器特有的附加信息
  // 返回一个数组 [$statusCode, $état, $content, $headers]

  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {

    //必须有一个唯一的参数 GET
    $method = strtolower($request->getMethod());
    $erreur = $method !== "get" || $request->query->count() != 1;
    // 错误?
    if ($erreur) {
      $état = 401;
      // 结果发送至主控制器
      $message = "GET requis avec le seul paramètre [action] dans l'URL";
      return [Response::HTTP_BAD_REQUEST, $état, ["réponse" => $message], []];
    }

    // 存储会话类型
    $type = $session->get("type");
    // 当前会话失效
    $session->invalidate();
    // 将类型重置到新会话中
    $session->set("type", $type);
    // 发送响应
    $état = 400;
    // 将结果返回给主控制器
    $content = ["réponse" => "session supprimée"];
    return [Response::HTTP_OK, $état, $content, []];
  }

}

注释

  • 请求 [GET main.php?action=fin-session]
  • 第 25-33 行:验证该操作是否为 GET,且唯一参数为 [fin-action]
  • 第 38 行:注销当前会话。这将清除该会话中存储的数据,并启动一个新会话;
  • 第36行:在会话结束前,记录该会话的类型[json, xml, html]
  • 第40行:将前一个会话的类型重新赋予新会话。最终,系统以唯一键[type]启动新会话;
  • 第44-45行:将结果返回给主控制器;

23.11.12. [Postman] 测试

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

Image

上文:

  • [1-5] 中,请求结束会话 [5],但返回的是 POST [2],而非预期的 GET;
  • [6-9] 中,服务器返回的响应为 jSON;

现在来看一个成功的示例。首先观察在最近一次测试中,客户端 [Postman] 与服务器之间交换的会话 Cookie:

Image

上图:

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

现在来看服务器在响应中发送的 HTTP 头部信息:

Image

上文:

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

现在执行一个有效的 [fin-session] 操作:

Image

上文:

  • [1-3] 中,一个有效的 [fin-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 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

interface InterfaceResponse {

  // 请求 $request:请求正在处理中
  // 会话 $session:Web 应用程序的会话
  // 数组 $config:应用程序配置
  // int statusCode:响应状态码
  // 数组 $content:服务器响应
  // 数组 $headers:要添加到响应中的 HTTP 头部
  // Logger $logger:用于写入日志的日志器
  
  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 依赖项
use Symfony\Component\HttpFoundation\Response;

class ParentResponse {

  // int $statusCode:响应状态码 HTTP
  // 字符串 $content:待发送的响应正文
  // 根据具体情况,可能是字符串 jSON、XML 或 HTML
  // 数组 $headers:需添加到响应中的 HTTP 头部

  public function sendResponse(
    int $statusCode,
    string $content,
    array $headers): void {

    // 服务器文本响应的准备
    $response = new Response();
    $response->setCharset("utf-8");
    // 状态码
    $response->setStatusCode($statusCode);
    // 头部
    foreach ($headers as $text => $value) {
      $response->headers->set($text, $value);
    }
    // 发送响应
    $response->setContent($content);
    $response->send();
  }
}

注释

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

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


// 将键 [action, état] 添加到控制器响应中
$content = ["action" => $action, "état" => $état] + $content;
// 实例化负责将响应发送给客户端的对象 [Response]
$response = __NAMESPACE__ . $config["types"][$type]["response"];
(new $response())->send($request, $session, $config, $statusCode, $content, $headers, $logger);

// 响应已发送 - 释放资源
$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 依赖项
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:请求正在处理中
  // 会话 $session:Web 应用程序的会话
  // 数组 $config:应用程序配置
  // int statusCode:响应状态码
  // 数组 $content:服务器响应
  // 数组 $headers:要添加到响应中的 HTTP 头部
  // Logger $logger:用于写入日志的日志器

  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void {

    // Symfony序列化器的准备
    $serializer = new Serializer(
      [
      // 对象序列化所需
      new ObjectNormalizer()],
      // 编码器 jSON
      // 用于选项,在不同选项之间使用 OU
      [new JsonEncoder(new JsonEncode([JsonEncode::OPTIONS => JSON_UNESCAPED_UNICODE]))]
    );
    // 序列化 jSON
    $json = $serializer->serialize($content, 'json');
    // 头部
    $headers = array_merge($headers, ["content-type" => "application/json"]);
    // 发送响应
    parent::sendResponse($statusCode, $json, $headers);
    // 日志
    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 行:响应正文 HHTP 通过前面的序列化器被序列化为 jSON;
  • 第 44 行:添加 HTTP 头部,告知客户端将向其发送 jSON;
  • 第 46 行:请求父类将响应发送给客户端;
  • 第 48-50 行:记录响应 jSON;

23.12.3. [XmlResponse]

[XmlResponse] 以如下方式实现了接口 [InterfaceResponse]


<?php

namespace Application;

// Symfony 依赖项
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:请求正在处理中
  // 会话 $session:Web 应用程序的会话
  // 数组 $config:应用程序配置
  // int statusCode:响应状态码
  // 数组 $content:服务器响应
  // 数组 $headers:要添加到响应中的 HTTP 头部
  // Logger $logger:用于写入日志的日志器

  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void {

    // Symfony序列化器的准备
    $serializer = new Serializer(
      // 对象序列化所需
      [new ObjectNormalizer()],
      [
      // 序列化 XML
      new XmlEncoder(
        [
        XmlEncoder::ROOT_NODE_NAME => 'root',
        XmlEncoder::ENCODING => 'utf-8'
        ]
      ),
      // 序列化 jSON
      new JsonEncoder(new JsonEncode([JsonEncode::OPTIONS => JSON_UNESCAPED_UNICODE]))
      ]
    );
    // 序列化 XML
    $xml = $serializer->serialize($content, 'xml');
    // 头信息
    $headers = array_merge($headers, ["content-type" => "application/xml"]);
    // 发送响应
    parent::sendResponse($statusCode, $xml, $headers);
    // 日志
    if ($logger !== NULL) {
      // QZXW2HTML 日志CalNPTgZQX
      $log = $serializer->serialize($content, 'json');
      $logger->write("réponse=$log\n");
    }
  }

}

注释

  • 第 34-48 行:实例化一个 Symfony 序列化器。构造函数接受两个数组类型的参数;
  • 第 36 行:第一个数组包含一个 [ObjectNormalizer] 类型的实例,该实例参与对象的序列化;
  • 第 37-47 行:第二个数组包含用于序列化的编码器。可以使用同一个序列化器实现多种序列化类型;
  • 第 38-44 行:编码器 XML;
  • 第 41 行:设定生成的 XML 代码的根节点。该代码将采用 <root>[autres balises 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 数据流,其内容取决于处理该操作的辅助控制器返回的状态码。 该映射 [état=>vue] 在配置文件 [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"

该配置的含义如下:[‘nom de la vue’ => ‘états associés à cette vue’]

  • 第2行:如果次级控制器返回了数组[700, 221, 400]的状态,则需显示视图[vue-authentification.php]
  • 第3:如果从属控制器返回了数组状态[200, 300, 341, 350, 800],则需显示视图[vue-calcul-impot.php];
  • 第 4 行:如果从属控制器返回了数组 [500, 600] 中的状态,则应显示视图 [vue-liste-simulations.php]
  • 第6行:如果从属控制器返回的状态不在上述任何表中,则需显示视图[vue-erreurs.php]

这些视图汇总在项目中的 [Views] 文件夹内:

Image

课程代码 [HtmlResponse] 如下:


<?php

namespace Application;

// Symfony 依赖项
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:请求正在处理中
  // 会话 $session:Web 应用程序的会话
  // 数组 $config:应用程序配置
  // int statusCode:响应状态码 HTTP
  // 数组 $content:服务器响应
  // 数组 $headers:要添加到响应中的 HTTP 头部
  // Logger $logger:用于写入日志的日志器

  public function send(
    Request $request = NULL,
    Session $session = NULL,
    array $config,
    int $statusCode,
    array $content,
    array $headers,
    Logger $logger = NULL): void {

    // Symfony序列化器的准备
    $serializer = new Serializer(
      [
      // 用于对象序列化
      new ObjectNormalizer()],
      [
      // 用于响应日志的序列化 jSON
      new JsonEncoder(new JsonEncode([JsonEncode::OPTIONS => JSON_UNESCAPED_UNICODE]))
      ]
    );
    // 响应 HTML 取决于控制器返回的状态码
    $état = $content["état"];
    // 每个状态对应一个视图——在应用程序配置中查找该视图
    // 视图列表
    $vues = array_keys($config["vues"]);
    $trouvé = false;
    $i = 0;
    // 遍历视图列表
    while (!$trouvé && $i < count($vues)) {
      // 与视图编号 i 关联的状态
      $états = $config["vues"][$vues[$i]];
      // 所查找的报表是否位于视图编号 I 关联的报表中?
      if (in_array($état, $états)) {
        // 将显示视图编号 i
        $vueRéponse = $vues[$i];
        $trouvé = true;
      }
      // 下一视图
      $i++;
    }
    // 找到?
    if (!$trouvé) {
      // 如果应用程序当前状态下不存在视图
      // 则显示错误视图
      $vueRéponse = $config["vue-erreurs"];
    }
    // 将待显示的视图 HTML 获取为字符串
    ob_start();
    require __DIR__ . "/../Views/$vueRéponse";
    $html = ob_get_clean();
    // 在报头中指定将发送 HTML
    $headers = array_merge($headers, ["content-type" => "text/html"]);
    // 父类负责实际发送响应
    parent::sendResponse($statusCode, $html, $headers);
    // 以 jSON 格式记录响应(不含 HTML)
    if ($logger !== NULL) {
      // 处理该操作的次级控制器响应的日志,格式为 jSON
      $log = $serializer->serialize($content, 'json');
      $logger->write("réponse=$log\n");
    }
  }

}

注释

  • 第 32-41 行:实例化一个 Symfony 序列化器。这是为了处理该操作(第 72-82 行)的控制器响应时,生成 jSON 日志所必需的;
  • 第 42-57 行:在应用程序配置中查找应显示的视图。该视图取决于处理该操作的控制器返回的状态代码。该代码位于 [$content[‘état’]] 中(第 43 行);
  • 第42-61行:查找与该状态对应的视图;
  • 第62-67行:若未找到任何视图,则表明应用程序处于异常状态代码HTML。关于异常状态的概念将在后文详细说明。在此情况下,将显示一个错误视图;
  • 第68-70行:解析所选视图的代码PHP,并将结果存入变量[$html](第71行);
  • 该代码需要稍作说明。假设所选视图为 [vue-authentification.php],该视图展示了一个身份验证网页表单:
    • 第 69 行:函数 [ob_start] 启动了文档中所称的“输出延迟”。所有由 print、require 等操作生成的内容,通常会立即发送给客户端,但此时会被存入输出缓冲区(ob=output buffer),而不会立即发送给客户端;
    • 第 70 行:加载视图 [vue-authentification.php],这是一个包含代码 PHP 的动态视图 HTML。此时发生两件事:
      • 视图 [vue-authentification.php] 中的代码 PHP 被加载并解释。 结果生成一个名为 [vue-authentification.html] 的视图,其中仅包含 HTML 甚至 CSS 以及 JavaScript 代码,但不再包含 PHP;
      • 该 HTML 代码通常会被发送给客户端。实际上,PHP 解释器遇到的所有非 PHP 代码都会如此处理。 由于输出延迟,该代码 HTML 被放入输出缓冲区而未发送给客户端;
    • 第71行:函数[ob_get_clean]执行两项操作:
      • 它将输出缓冲区的内容(即我们放入其中的页面 [vue-authentification.html])存入变量 [$html]
      • 清空输出缓冲区。对于该缓冲区而言,一切仿佛未曾发生。此外,客户端仍未收到任何内容;
  • 第 70 行:此时正在执行位于 [Responses] 文件夹中的 [HtmlResponse] 类。 因此,要找到该视图,需向上追溯一个层级至 [..],然后进入 [Views] 文件夹。 [__DIR__] 是当前正在运行的脚本所在文件夹的绝对路径,在本例中即为 [C:/myprograms/laragon-lite/www/php7/scripts-web/impots/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 网页应用程序

23.13.1. 视图介绍

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

身份验证视图:

Image

税款计算视图:

Image

模拟列表视图:

Image

意外错误视图:

Image

我们将依次介绍这些视图。

23.13.2. 身份验证视图

23.13.2.1. 视图概述

身份验证视图如下:

Image

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

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

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


<?php
// 页面的测试数据
// 将页面数据封装在 $page 中

?>

<!doctype html>
<html lang="fr">
    <head>
        <!-- 必需的元标签 -->
        <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">
            <!-- 1行12列的横幅 -->
            <?php require "v-bandeau.php"; ?>
            <!-- 9 列身份验证表单 -->
            <div class="row">
                <div class="col-md-9">
                    <?php require "v-authentification.php" ?>
                </div>
            </div>  
            <?php
            // 若出现错误,则显示错误提示
            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 文件。此处我们使用了 CSS Bootstrap 4.1.3 [https://getbootstrap.com/docs/4.0/getting-started/introduction/] 框架 ;
  • 第 15 行:<title> 标签设定了页面的标题:

Image

  • 第 17-43 行:网页主体被封装在 <body> 和 </body> 标签中;
  • 第 18-42 行:<div> 标签界定了页面显示的一个部分。视图中使用的 [class] 属性均指代 Bootstrap 框架。<div class=’container’> 标签界定了一个 Bootstrap 容器;
  • 第 20 行:引入 [v-bandeau.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]的[$modèle]对象中。这只是一个任意的选择。其实也可以选择使用关联数组来实现相同的效果;
  • 第29行:如果用户输入的凭据不正确,认证将失败。此时,认证视图将重新显示并附带一条错误消息。[$modèle→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>元素</li> 的语法;

请记住此代码中需要定义的动态元素:

  • [$modèle→error]:用于显示错误信息;
  • [$modèle→erreurs]:一个错误消息列表(按HTML的定义);

23.13.2.2. 片段 [v-bandeau.php]

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

Image

片段 [v-bandeau.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 行:横幅被封装在一个名为 Jumbotron 的 Bootstrap 部分中([<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-authentification .php] 显示 Web 应用程序的身份验证表单:

Image

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


<!-- 表单 HTML - 使用操作 [authentifier-utilisateur] 提交其值 -->
<form method="post" action="main.php?action=authentifier-utilisateur">

    <!-- 标题 -->
    <div class="alert alert-primary" role="alert">
        <h4>Veuillez vous authentifier</h4>
    </div>

    <!-- Bootstrap 表单 -->
    <fieldset class="form-group">
        <!-- 第一行 -->
        <div class="form-group row">
            <!-- 标签 -->
            <label for="user" class="col-md-3 col-form-label">Nom d'utilisateur</label>
            <div class="col-md-4">
                <!-- 文本输入框 -->
                <input type="text" class="form-control" id="user" name="user"
                       placeholder="Nom d'utilisateur" value="<?= $modèle->login ?>">
            </div>
        </div>
        <!-- 第二行 -->
        <div class="form-group row">
            <!-- 标签 -->
            <label for="password" class="col-md-3 col-form-label">Mot de passe</label>
            <!-- 文本输入框 -->
            <div class="col-md-4">
                <input type="password" class="form-control" id="password" name="password"
                       placeholder="Mot de passe">
            </div>
        </div>
        <!-- 位于第 3 行上的 [submit] 类型按钮-->
        <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。用于调用该URL的方法HTTP,在[form]标签(第2行)的[method]属性中进行了定义;
    • 在此,当用户点击[Valider]按钮(第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’]:输入框的名称。浏览器将通过此名称提交用户输入的值;
    • [placeholder=’invite’]:当用户尚未输入任何内容时,输入框中显示的文本;

Image

  • [value=’valeur’]:输入框一经显示,即会显示文本“值”,即在用户输入其他内容之前。此机制用于在发生错误时显示导致错误的输入内容。 此处的值即为变量 PHP 的值[$modèle→login]
  • 第21-30行:用于密码输入的类似代码;
  • 第27行:[type=’password’] 生成一个文本输入框(可输入任意内容),但输入的字符会被隐藏:

Image

  • 第32-36行:用于按钮的第三行代码 [Valider]
  • 第 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
// 页面测试数据
//
// 计算视图模板
$modèle = getModelForThisView();

function getModelForThisView(): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new \stdClass();
  // 用户标识
  $modèle->login = "albert";
  // 错误列表
  $modèle->error = TRUE;
  $erreurs = ["erreur1", "erreur2"];
  // 构建错误列表 HTML
  $content = "";
  foreach ($erreurs as $erreur) {
    $content .= "<li>$erreur</li>";
  }
  $modèle->erreurs = $content;
  // 横幅图片
  $modèle->logo = "http://localhost/php7/scripts-web/impots/version-12/Tests/logo.jpg";
  // 渲染模板
  return $modèle;
}
?>

<!-- 生成文档 HTML -->
<!doctype html>
<html lang="fr">
    <head>
        <!-- 必需的元标签 -->

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

注释

  • 第 1-5 行:身份验证视图包含由对象 [$modèle] 控制的动态部分。 该对象被称为视图模型。根据 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]操作成功后的状态码:此时将显示空白的身份验证表单;
  • [authentifier-utilisateur-221]221是[authentifier-utilisateur]操作失败后的状态码(用户名或密码不正确):此时将显示身份验证表单以便用户更正;
  • [fin-session-400]:400是[fin-session]操作成功后的状态码:此时显示空的身份验证表单;

既然我们已经知道在何时需要显示身份验证表单,就可以在 [vue-authentification.php] 中计算其模板:

Image

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


<?php
// 继承以下变量
// 请求 $request:当前请求
// 会话 $session:应用程序会话
// 数组 $config:应用程序配置
// 数组 $content:控制器响应
//
// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

// 计算视图模型
$modèle = getModelForThisView($request, $session, $config, $content);

function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new stdClass();
  // 应用程序状态
  $état = $content["état"];
  // 模型取决于状态
  switch ($état) {
    case 700:
    case 400:
      // 显示空表单的情况
      $modèle->login = "";
      // 无错误需显示
      $modèle->error = FALSE;
      break;
    case 221:
      // 身份验证错误
      // 重新显示最初输入的用户
      $modèle->login = $request->request->get("user");
      // 存在需显示的错误
      $modèle->error = TRUE;
      // 错误消息列表 HTML - 此处仅有一条
      $modèle->erreurs = "<li>Echec de l'authentification</li>";
  }
  // 结果
  return $modèle;
}
?>

<!-- 文档 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
  // 徽标
  $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行使用了变量[$modèle→logo],该变量即横幅标识的URL。为了避免在应用程序的四个视图中重复计算该变量四次,该计算已被提取到[v-bandeau.php]片段中;
  • 第 1-11 行展示了如何根据服务器环境中的信息构建 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]操作成功后的状态码:此时将显示空白的身份验证表单;
  • [authentifier-utilisateur-221]221是[authentifier-utilisateur]操作失败(凭证不被识别)后的状态码:此时将显示认证表单以便用户更正;
  • [fin-session-400]400是[fin-session]操作成功后的状态码:此时将显示空的身份验证表单;

只需重新调用这些方法,并检查是否正确显示了认证视图。此处仅展示两个测试:

  • [init-session-html-700]:启动 HTML 会话;

Image

  • [authentifier-utilisateur-221]:用户 [x, x] 的身份验证;

Image

上文:

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

23.13.2.7. Conclusion

我们在未编写其他视图的情况下成功测试了视图 [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
// 继承以下变量
// 请求 $request:当前请求
// 会话 $session:应用程序会话
// array $config:应用程序配置
// array $content:处理该操作的控制器响应
//
// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

// 计算视图模型
$modèle = getModelForThisView($request, $session, $config, $content);

function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new \stdClass();

  // 渲染模型
  return $modèle;
}
?>
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
    <head>
        <!-- 必需的元标签 -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <!-- BootstrapCSS -->
        <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">
            <!-- 横幅 -->
            <?php require "v-bandeau.php"?>
            <!-- 双栏布局 -->
            <div class="row">
                <!-- 菜单 -->
                <div class="col-md-3">
                    <?php require "v-menu.php" ?>
                </div>
                <!-- 计算表单 -->
                <div class="col-md-9">
                    <?php require "v-calcul-impot.php" ?>
                </div>
            </div>  
            <!-- 成功情况 -->
            <?php
            if ($modèle->success) {
              // 显示成功提示
              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 列错误列表
              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 行:如果税额计算成功([$modèle→success=TRUE]),则将税额计算结果显示在绿色框中(第 59-65 行)。 该框位于视图的第三行 Bootstrap(第 54 行),占据九列(第 58 行),位于三个空列(第 55-57 行)的右侧。因此,该框将紧接在税额计算表下方;
  • 第71-87行:如果税额计算失败([$modèle→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] 的代码如下:


<!-- 已提交的表单 HTML -->
<form method="post" action="main.php?action=calculer-impot">
    <!-- 12列蓝色背景消息 -->
    <div class="col-md-12">
        <div class="alert alert-primary" role="alert">
            <h4>Remplissez le formulaire ci-dessous puis validez-le</h4>
        </div>
    </div>
    <!-- 表单元素 -->
    <fieldset class="form-group">
        <!-- 9列的第一行 -->
        <div class="row">
            <!-- 4 列的文字 -->
            <legend class="col-form-label col-md-4 pt-0">Etes-vous marié(e) ou pacsé(e)?</legend>
            <!-- 5列单选按钮-->
            <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>
        <!-- 9列中的第二行 -->
        <div class="form-group row">
            <!-- 4列标签 -->
            <label for="enfants" class="col-md-4 col-form-label">Nombre d'enfants à charge</label>
            <!-- 5列的子女数量数字输入区 -->
            <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>
        <!-- 9列的第三行 -->
        <div class="form-group row">
            <!-- 4列的文字区域 -->
            <label for="salaire" class="col-md-4 col-form-label">Salaire annuel</label>
            <!-- 5列的工资数值输入区 -->
            <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>
        <!-- 第4行,5列的按钮 [submit] -->
        <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])。提交的值将是输入字段的值:
    • 若选中单选按钮,则采用以下形式:
      • [marié=oui](若单选按钮 [Oui] 被选中,见第 16-22 行)。 [marié] 是第 18 行中 [name] 属性的值,[oui] 是第 18 行中 [value] 属性的值;
      • [marié=non](如果单选按钮 [Non] 被选中,第 23-28 行)。 [marié] 是第 24 行 [name] 属性的值,[non] 是第 24 行 [value] 属性的值;
    • 第37行数字输入框的值为[enfants=xx],其中[enfants]是第37行[name]属性的值, 而 [xx] 则是用户通过键盘输入的值;
    • 第46行数字输入框的值为[salaire=xx],其中[salaire]是第46行[name]属性的值, 而 [xx] 则是用户通过键盘输入的值;

最终,提交的值将呈现为 [marié=xx&enfants=yy&salaire=zz]

  • 当用户点击第 53 行中类型为 [submit] 的按钮时,输入的值将被提交;
  • 第16-30行:两个单选按钮:

Image

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

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

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

  • 第 37 行:一个数字输入框 [type="number"],其最小值为 0 [min="0"]。 在现代浏览器中,这意味着用户只能输入大于等于0的数字。在这些现代浏览器中,输入可通过一个可点击向上或向下调节的滑块完成。 第37行的[step="1"]属性表明,该滑块将以1为增量进行操作。这意味着滑块仅接受从0到n、步长为1的整数值。对于手动输入而言,这意味着不接受带小数的数字;

Image

  • 第37行:在某些显示界面中,子女信息输入框需预先填入该区域的上次输入值。为此使用属性[value]来设定输入框中显示的值。该值为动态值,由变量[$modèle→enfants]生成;
  • 第46行:工资输入的说明与子女输入相同;
  • 第53行:类型为[submit]的按钮,该按钮会触发POST,将输入的值传递至URL和[main.php?action=calculer-impot]

Image

23.13.3.3. [v-menu.php] 片段

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

Image

该片段的代码如下:


<!-- Bootstrap 菜单 -->
<nav class="nav flex-column">
    <?php
    // 显示链接列表 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]:是点击链接 [$texte] 时跳转到的 URL。此时,浏览器执行的是一项 [GET $url] 操作。 如果 [$url] 是 URL 的相对链接,那么它前面会加上当前浏览器地址栏中显示的 URL 的根目录。 因此,若要生成链接 [1],而浏览器当前的 URL 属于 [http://chemin/main.php?paramètres] 类型,则将创建如下链接:
<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] 文件夹中,并为视图 [vue-calcul-impot.php] 创建了一个测试模板:

Image

视图 [vue-calcul-impot] 的数据模型如下:


<?php
// 页面测试数据
//
// 计算视图模板
$modèle = getModelForThisView();

function getModelForThisView(): object {
  // 将页面数据封装在 $modèle 中
  $modèle = new \stdClass();
  // 表单
  $modèle->checkedOui = "";
  $modèle->checkedNon = 'checked="checked"';
  $modèle->enfants = 2;
  $modèle->salaire = 300000;
  // 成功消息
  $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 %";
  // 错误消息
  $modèle->error = TRUE;
  $erreurs = ["erreur1", "erreur2"];
  // 构建错误列表 HTML
  $content = "";
  foreach ($erreurs as $erreur) {
    $content .= "<li>$erreur</li>";
  }
  $modèle->erreurs = $content;
  // 菜单
  $modèle->optionsMenu = [
    '模拟列表' => 'main.php?action=模拟列表',
    '结束会话' => 'main.php?action=结束会话'];
  // 横幅图片
  $modèle->logo = "http://localhost/php7/scripts-web/impots/version-12/Tests/logo.jpg";
  // 提交表单
  return $modèle;
}

?>
<!-- 文档 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]

  • [authentifier-utilisateur-200]200是[authentifier-itilisateur]操作成功后的状态码:此时将显示空白的税款计算表单;
  • [calculer-impot-300]300是[calculer-impot]操作成功后的状态码。此时将显示包含已输入数据及税额的计算表单。用户可重新进行计算;
  • [fin-session-400]:400是[fin-session]操作成功后的状态码:此时将显示空白的身份验证表单;
  • 状态码 [341] 表示税额计算有效,但因未连接 SGBD 导致错误;
  • 状态码 [350] 表示税额计算有效,但因未连接到 [Redis] 服务器而引发错误;
  • 状态码 [800] 将在后续说明。我们尚未遇到该情况;
  • 此处假设用户使用的是最新版浏览器。因此,在所研究的表单中,无法在输入字段 [enfants, salaire] 中输入负数、非数字字符串或带小数的数字。若使用旧版浏览器,则可能出现此类情况。 我们将把这些错误视为意外错误,并显示视图 [vue-erreurs]

既然已明确何时应显示税款计算表单,我们即可在 [vue-calcul-impot.php] 中设计其模板:


<?php
// 继承以下变量
// 请求 $request:当前请求
// 会话 $session:应用程序会话
// 数组 $config:应用程序配置
// 数组 $content:处理该操作的控制器响应
//
// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

// 计算视图模型
$modèle = getModelForThisView($request, $session, $config, $content);

function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new \stdClass();
  // 应用程序状态
  $état = $content["état"];
  // 模型依赖于状态
  switch ($état) {
    case 200 :
    case 800:
      // 初始显示一个空表单
      $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:
      // 计算成功 - 显示结果
      $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) . " %";
      // 表单恢复并显示已输入的值
      $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:
    // 数据库HS
    case 350:
      // Redis 服务器 HS
      // 表单已恢复并保留已输入的值
      $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");
      // 错误
      $modèle->success = FALSE;
      $modèle->error = TRUE;
      $modèle->erreurs = "<li>{$content["réponse"]}</li>";
      break;
  }
  //菜单
  $modèle->optionsMenu = [
    "Liste des simulations" => "main.php?action=lister-simulations",
    "Fin de session" => "main.php?action=fin-session"];
  // 渲染表单
  return $modèle;
}
?>
<!-- 文档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] 测试

测试 [calculer-impot-300] 使我们获得状态码 300。这表示税款计算成功:

Image

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

让我们尝试一个错误案例:错误代码 [350] 是由服务器不可用([Redis])引起的:

Image

23.13.4. 模拟列表视图

23.13.4.1. 视图介绍

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

Image

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

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

模拟视图由以下脚本 [vue-liste-simulations.php] 生成:

Image


<?php

// 计算视图模板
$modèle = getModelForThisView();

function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new \stdClass();
  
  // 渲染视图模板
  return $modèle;
}
?>
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
    <head>
        <!-- 必需的元标签 -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <!-- BootstrapCSS -->
        <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">
            <!-- 横幅 -->
            <?php require "v-bandeau.php"; ?>
            <!-- 双栏布局 -->
            <div class="row">
                <!-- 三栏菜单-->
                <div class="col-md-3">
                    <?php require "v-menu.php" ?>
                </div>
                <!-- 9 列模拟列表-->
                <div class="col-md-9">
                    <?php require "v-liste-simulations.php" ?>
                </div>
            </div>  
        </div>
    </body>
</html>

注释

  • 第28行:包含应用程序横幅[1]
  • 第33行:包含菜单[2]。该菜单将以三列形式显示在横幅下方;
  • 第37行:引入[3]模拟表格。该表格将以九列形式显示在横幅下方、菜单右侧;

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

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


<!-- 蓝色背景上的消息 -->
<div class="alert alert-primary" role="alert">
    <h4>Liste de vos simulations</h4>
</div>
<!-- 模拟表格 -->
<table class="table table-sm table-hover table-striped">
    <!-- 表格中六个列的标题 -->
    <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>
    <!-- 表格主体(显示的数据) -->
    <tbody>
        <?php
        $i = 0;
        // 通过遍历模拟表来显示每项模拟
        foreach ($modèle->simulations as $simulation) {
          // 显示包含6列的表格中的一行 - <tr>标签
          // 第1列:行标题(模拟编号) - 标签 <th scope='row'>
          // 第2列:参数值 [marié] - 标签 <td>
          // 第3列:参数值 [enfants] - 标签 <td>
          // 第 4 列:参数值 [salaire] - 标签 <td>
          // 第5列:参数值 [impôt](税款) - 标签 <td>
          // 第6列:参数值 [surcôte] - 标签 <td>
          // 第7列:参数值 [décôte] - 标签 <td>
          // 第8列:参数值 [réduction] - 标签 <td>
          // 第9列:参数值 [taux](税款) - 标签 <td>
          // 第10列:模拟删除链接 - 标签 <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>

注释

  • 一个名为 HTML 的表格使用 <table> 标签创建(第 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行:模拟列表位于[$modèle→simulations]模型中,该模型是一个关联数组;
  • 第50行:用于删除模拟的链接。URL模型采用了表格第一列(第41行)中显示的编号;

23.13.4.2. 目视检查

我们将这些不同元素整合到文件夹 [Tests] 中,并为视图 [vue-liste-simulations.php] 创建了一个测试模板:

Image

视图 [vue-liste-simulations] 的数据模型如下:


<?php
// 计算视图模板
$modèle = getModelForThisView();

function getModelForThisView(): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new \stdClass();
  // 将模拟结果转换为页面所需的格式
  $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
    ]
  ];
  // 菜单选项
  $modèle->optionsMenu = [
    "Calcul de l'impôt" => "main.php?action=afficher-calcul-impot",
    "Fin de session" => "main.php?action=fin-session"];
  // 横幅图片
  $modèle->logo = "http://localhost/php7/scripts-web/impots/version-12/Tests/logo.jpg";
  // 生成模板
  return $modèle;
}
?>
<!-- 文档 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]

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

既然我们已知何时应显示模拟列表,即可在 [vue-liste-simulations.php] 中计算其模型:


<?php
// 继承以下变量
// 请求 $request:当前请求
// 会话 $session:应用程序会话
// 数组 $config:应用程序配置
// 数组 $content:控制器响应
// 无错误
// array $content:控制器响应
//
// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

// 计算视图模板
$modèle = getModelForThisView($request, $session, $config, $content);

function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new \stdClass();
  // 将模拟结果转换为页面所需的格式
  // 这些内容可在执行该操作的控制器响应中找到
  // 以 [Simulation] 类型对象数组的形式
  $objetsSimulation = $content["réponse"];
  // 每个 [Simulation] 对象将被转换为关联数组
  $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()
    ];
  }
  // 菜单选项
  $modèle->optionsMenu = [
    "Calcul de l'impôt" => "main.php?action=afficher-calcul-impot",
    "Fin de session" => "main.php?action=fin-session"];
  // 生成模板
  return $modèle;
}
?>
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
    </head>
    <body>
       
    </body>
</html>

注释

  • 第26-36行:计算片段[v-liste-simulations.php]所使用的模型[$modèle→simulations]
  • 第39-41行:计算片段[v-menu.php]所使用的模型[$modèle→optionsMenu]

23.13.4.4. [Postman] 测试

测试 [lister-simulations-500] 使我们获得状态码 500。这对应于查看模拟的请求:

Image

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

Image

23.13.5. 意外错误视图

此处所指的“意外错误”,是指在正常使用Web应用程序时本不应发生的错误。

以测试 [Postman] [calculer-impot-3xx] 为例,其定义如下:

Image

  • [1-3] 中,一个 POST 请求,其操作为 [calculer-impot]
  • 转换为 [4-6]:在此处可以为 POST 的三个参数定义任意值:
    • [4]:缺少参数 [marié]
    • [5-6][enfants, salaire]的参数存在但无效;
  • 在[9]中,这三个错误均以状态码338报告;

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

  • 所有参数均已存在;
  • 参数 [marié] 的取值来自两个单选按钮的 [value] 属性,因此其值必然是 [oui] [non] 之一;
  • 在现代浏览器中,<input type='number' min='0' step='1' …> 属性确保子女数和工资的输入值必然是 >=0 的整数;

然而,没有任何机制能阻止用户选择 [Postman] 并向我们的服务器发送上述 [calcul-impot-3xx] 测试请求。我们已看到,我们的 Web 应用程序能够正确响应此请求。 我们将那些在HTML应用程序运行过程中不应出现的错误称为“意外错误”。如果发生此类错误,很可能是有人试图“入侵”该应用程序。出于教学目的,我们决定针对此类情况显示错误页面。 实际上,我们可以重新显示之前发送给客户端的最后一个页面。只需将最后发送的响应 HTML 保存在会话中即可。一旦发生意外错误,我们就返回该响应。这样,由于显示的页面没有变化,用户会觉得服务器对错误没有响应。

23.13.5.1. 视图介绍

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

Image

由脚本 [vue-erreurs.php] 生成的视图包含三个部分:

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

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

Image


<?php
// 计算视图模板
$modèle = getModelForThisView();

function getModelForThisView(): object {
  // 将页面数据封装到 $modèle
  $modèle = new \stdClass();

  // 返回视图模板
  return $modèle;
}
?>
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
    <head>
        <!-- 必需的元标签 -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <!-- BootstrapCSS -->
        <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">
            <!-- 12 列横幅 -->
            <?php require "v-bandeau.php"; ?>
            <!-- 双栏布局 -->
            <div class="row">
                <!-- 三栏菜单-->
                <div class="col-md-3">
                    <?php require "v-menu.php" ?>
                </div>
                <!-- 错误列表 -->
                <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> 标签包裹了一个项目符号列表。该项目符号列表由模板 [$modèle->erreurs] 提供;

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

  • [v-bandeau.php]:在链接段落中;
  • [v-menu.php]链接段落;

23.13.5.2. 视觉测试

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

Image

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


<?php
// 计算视图模板
$modèle = getModelForThisView();

function getModelForThisView(): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new \stdClass();

  // 意外错误表
  $erreurs = ["erreur1", "erreur2"];
  // 构建错误列表 HTML
  $modèle->erreurs = "";
  foreach ($erreurs as $erreur) {
    $modèle->erreurs .= "<li>$erreur</li>";
  }
  // 菜单选项
  $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",];
  // 横幅图片
  $modèle->logo = "http://localhost/php7/scripts-web/impots/version-12/Tests/logo.jpg";
  // 返回模板
  return $modèle;
}
?>
<!-- 文档 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
// 继承以下变量
// 请求 $request:当前请求
// 会话 $session:应用程序会话
// 数组 $config:应用程序配置
// 数组 $content:控制器响应
//
// Symfony 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

// 计算视图模型
$modèle = getModelForThisView($request, $session, $config, $content);

function getModelForThisView(Request $request, Session $session, array $config, array $content): object {
  // 将页面数据封装到 $modèle 中
  $modèle = new \stdClass();

  // 从控制器响应中获取错误
  $réponse = $content["réponse"];
  if (!is_array($réponse)) {
    // 单条错误消息
    $erreurs = [$réponse];
  } else {
    // 多个错误消息
    $erreurs = $réponse;
  }
  // 构建错误列表 HTML
  $modèle->erreurs = "";
  foreach ($erreurs as $erreur) {
    $modèle->erreurs .= "<li>$erreur</li>";
  }
  // 菜单选项
  $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",];

  // 返回模板
  return $modèle;
}
?>
<!-- 文档 HTML -->
<!doctype html>
<html lang="fr">
    <head>
        
    </head>
    <body>
        
    </body>
</html>

注释

  • 第 19-32 行:计算视图 [vue-erreurs.php] 所使用的模型 [$modèle→erreurs]
  • 第 34-37 行:计算由片段 [v-menu.php] 使用的模板 [$modèle→optionsMenu]

23.13.5.4. [Postman] 测试

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

Image

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

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

视图
链接
目标
作用
计算税款
[Liste des simulations]
[main.php?action=lister-simulations]
请求模拟列表
  
[Fin de session]
模拟列表
[Calcul de l’impôt]
[main.php?action=afficher-calcul-impot]
显示税款计算视图
  
[Fin de session]
意外错误
[Calcul de l’impôt]
[main.php?action=afficher-calcul-impot]
显示税额计算视图
  
[Liste des simulations]
  
[Fin de session]

需要注意的是,点击链接会触发 GET 跳转至链接目标。[lister-simulations, fin-session] 操作是通过 GET 操作实现的,因此我们可以将其设置为链接目标。 当操作通过 POST 实现时,除非结合 JavaScript 使用,否则无法再使用链接。

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

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

首先,我们需要添加一个新的子控制器。我们将它命名为 [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 依赖项
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Response;

class AfficherCalculImpotController implements InterfaceController {

  // $config 是应用程序的配置
  // 处理请求 Request
  // 使用 Session 会话并可对其进行修改
  // $infos 是每个控制器特有的附加信息
  // 返回一个数组 [$statusCode, $état, $content, $headers]
  
  public function execute(
    array $config,
    Request $request,
    Session $session,
    array $infos = NULL): array {

    // 视图切换——只需设置一个状态码
    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

// 重定向至 [main.php],模式为 [html]
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. Web服务客户端 jSON

23.14.1. 客户端/服务器架构

Image

现在我们关注Web服务[B]的客户端jSON [A]。 客户端 [A] 与 Web 服务 [B] 一样,具有分层结构:

Image

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

Image

大多数类之前已经介绍过并进行了说明:

BaseEntity
链接段落。
TaxPayerData
段落链接
Simulation
段落链接
ExceptionImpots
段落链接
TraitDao
段落链接
Utilitaires
段落链接

23.14.2. 图层 [dao]

Image

23.14.2.1. Interface

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


<?php

// 命名空间
namespace Application;

interface InterfaceClientDao {

  // 读取纳税人数据
  public function getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array;

  // 纳税人税款计算
  public function calculerImpot(string $marié, int $enfants, int $salaire): Simulation;

  // 结果记录
  public function saveResults(string $resultsFilename, array $simulations): void;

  // 身份验证
  public function authentifierUtilisateur(String $user, string $password): void;

  // 模拟列表
  public function listerSimulations(): array;

  // 删除模拟
  public function supprimerSimulation(int $numéro): array;

  // 开始会话
  public function initSession(string $type = 'json'): void;

  // 结束会话
  public function finSession(): void;
}

注释

  • 第 9 行:方法 [getTaxPayersData] 用于处理纳税人数据文件 jSON。该方法由已注释的特性 [TraitDao] 实现(参见链接段落);
  • 第15行:方法[saveResults]用于将多项税款计算结果保存到文件jSON中。 同样,该方法由已注释的 [TraitDao] 特性实现(参见链接段落);
  • 第 12、18、21、27、30 行:针对 Web 服务支持的每项操作,都创建了一个方法;

23.14.2.2. Implémentation

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


<?php

namespace Application;

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

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

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

}

注释

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

23.14.2.2.1. Méthode [initSession]

方法 [initSession] 的实现如下:


public function initSession(string $type = 'json'): void {
    // 创建客户端HTTP
    $httpClient = HttpClient::create();
    // 向服务器发送未经过身份验证的请求
    $response = $httpClient->request('GET', $this->urlServer,
      ["query" => [
          "action" => "init-session",
          "type" => $type
        ],
        "verify_peer" => false
    ]);
    // 获取响应
    $this->getResponse($response);
    // 获取会话cookie
    $headers = $response->getHeaders();
    if (isset($headers["set-cookie"])) {
      // 会话cookie?
      foreach ($headers["set-cookie"] as $cookie) {
        $match = [];
        $match = preg_match("/^PHPSESSID=(.+?);/", $cookie, $champs);
        if ($match) {
          $this->sessionCookie = "PHPSESSID=" . $champs[1];
        }
      }
    }
  }

由于操作 [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) {
    // 获取响应
    $json = $response->getContent(false);
    // 日志
    if ($this->verbose) {
      print "$json\n";
    }
    // 获取响应状态
    $statusCode = $response->getStatusCode();
    // 错误?
    if ($statusCode !== 200) {
      // 出现错误
      throw new ExceptionImpots($json);
    }
    // 返回响应
    $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["réponse"]] 中;

23.14.2.2.3. 方法 [authentifierUtilisateur]

方法 [authentifierUtilisateur] 如下:


public function authentifierUtilisateur(string $user, string $password): void {
    // 创建客户端 HTTP
    $httpClient = HttpClient::create();
    // 向服务器发送带身份验证的请求
    $response = $httpClient->request('POST', $this->urlServer,
      ["query" => [
          "action" => "authentifier-utilisateur"
        ],
        "body" => [
          "user" => $user,
          "password" => $password
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // 获取响应
    $this->getResponse($response);
  }

注释

  • 第 5 行:客户端请求为 POST;
  • 第 6-8 行:URL 中的参数;
  • 第 9-12 行:POST 中的参数;
  • 第14行:会话cookie;
  • 第 17 行:读取响应。我们知道,如果发生错误(HTTP 状态码不为 200),[getResponse] 方法会自行抛出异常;

23.14.2.2.4. 方法 [calculerImpot]

public function calculerImpot(string $marié, int $enfants, int $salaire): Simulation {
    // 创建客户端 HTTP
    $httpClient = HttpClient::create();
    // 向服务器发送请求,不进行身份验证但使用会话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]
    ]);
    // 获取响应
    $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 {
    // 创建客户端 HTTP
    $httpClient = HttpClient::create();
    // 向服务器发送请求,不进行身份验证但使用会话cookie
    $response = $httpClient->request('GET', $this->urlServer,
      ["query" => [
          "action" => "lister-simulations"
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // 获取响应
    return $this->getSimulations($response);
  }

注释

  • 第 5 行:方法 GET;
  • 第 6-8 行:GET 的唯一参数;
  • 第13行:模拟结果的获取由私有方法[getSimulations]负责;

23.14.2.2.6. 方法 [getSimulations]

private function getSimulations(CurlResponse $response): array {
    // 获取响应 JSON
    $array = $this->getResponse($response);
    // 得到一个关联数组
    // 将其转换为 Simulation 对象数组
    $simulations = [];
    foreach ($array as $simulation) {
      $simulations [] = (new Simulation())->setFromArrayOfAttributes($simulation);
    }
    // 返回 Simulation 对象列表
    return $simulations;
}

注释

  • 第 3 行:获取响应中的数组。这是一个多维数组,其中每个子数组都具有 [Simulation] 对象的所有属性;
  • 第6行:若执行到此处,说明方法[getResponse]未抛出异常;
  • 第6-9行:利用响应构建一个[Simulation]对象数组;
  • 第 11 行:返回该数组;

23.14.2.2.7. 方法 [SupprimerSimulation]

public function supprimerSimulation(int $numéro): array {
    // 创建一个客户端 HTTP
    $httpClient = HttpClient::create();
    // 向服务器发送请求,不进行身份验证但使用会话cookie
    $response = $httpClient->request('GET', $this->urlServer,
      ["query" => [
          "action" => "supprimer-simulation",
          "numéro" => $numéro
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // 获取响应
    return $this->getSimulations($response);
  }

注释

  • 第 5 行:执行 GET 请求;
  • 第6-9行:URL的两个参数;
  • 第14行:删除操作后,服务器返回新的模拟数组。返回该数组;

23.14.2.2.8. 方法 [finSession]

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


public function finSession(): void {
    // 创建客户端 HTTP
    $httpClient = HttpClient::create();
    // 向服务器发送请求,不进行身份验证但使用会话cookie
    $response = $httpClient->request('GET', $this->urlServer,
      ["query" => [
          "action" => "fin-session"
        ],
        "verify_peer" => false,
        "headers" => ["Cookie" => $this->sessionCookie]
    ]);
    // 获取响应
    $this->getResponse($response);
  }

注释

  • 第 5 行:发出 GET 请求;
  • 第 6-8 行:URL 的唯一参数;
  • 第 13 行:读取响应。如果响应的 HTTP 代码不为 200,则会抛出异常;

23.14.3. [métier]

Image

23.14.3.1. L’interface

[métier] 层的接口如下:[InterfaceClientMetier.php]


<?php

// 命名空间
namespace Application;

interface InterfaceClientMetier {

  // 计算纳税人的税款
  public function calculerImpot(string $marié, int $enfants, int $salaire): Simulation;

  // 批处理模式下的税款计算
  public function executeBatchImpots(string $taxPayersFileName, string $resultsFilename, string $errorsFileName): void;

  // 身份验证
  public function authentifierUtilisateur(String $user, string $password): void;

  // 模拟列表
  public function listerSimulations(): array;

  // 结果记录
  public function saveResults(string $resultsFilename, array $simulations): void;

  // 删除模拟
  public function supprimerSimulation(int $numéro): array;

  // 开始会话
  public function initSession(string $type = 'json'): void;

  // 结束会话
  public function finSession(): void;
}

注释

  • 只有第12行的方法[executeBatchImpots]是[métier]层的专属方法。其余所有方法均属于[dao]层,并由该层实现;

23.14.3.2. 类 [ClientMetier]

实现 [métier] 层的类如下:


<?php

namespace Application;

class ClientMetier implements InterfaceClientMetier {
  // 属性
  private $clientDao;

  // 制造商
  public function __construct(InterfaceClientDao $clientDao) {
    $this->clientDao = $clientDao;
  }

  // 税款计算
  public function calculerImpot(string $marié, int $enfants, int $salaire): Simulation {
    return $this->clientDao->calculerImpot($marié, $enfants, $salaire);
  }

  // 批处理模式下的税款计算
  public function executeBatchImpots(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void {
    // 允许来自 [dao] 层的异常上报
    // 获取纳税人数据
    $taxPayersData = $this->clientDao->getTaxPayersData($taxPayersFileName, $errorsFileName);
    // 结果表
    $simulations = [];
    // 对结果进行处理
    foreach ($taxPayersData as $taxPayerData) {
      // 计算税款     
      $simulations [] = $this->calculerImpot(
        $taxPayerData->getMarié(),
        $taxPayerData->getEnfants(),
        $taxPayerData->getSalaire());
    }
    // 记录结果
    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 行:[métier] 层在构建时需要引用 [dao] 层;
  • 第 20-38 行:只有方法 [executeBatchImpots] [métier] 层特有的。其他方法的实现将工作委托给 [dao] 层中同名的方法;
  • 第23行:调用[dao]层,以[TaxPayerData]类型的对象数组获取纳税人数据;
  • 第25行:将计算出的各项模拟结果汇总到[$simulations]数组中;
  • 第27-33行:计算[$taxPayersData]表中每位纳税人的税额;
  • 第35-37行:表[$simulations]中的结果被保存到文件jSON中;

[métier] 层几乎没有任何作用。可以考虑将其删除,并将所有内容合并到 [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

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

// 命名空间
namespace Application;

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

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

// 引入脚本所需的依赖项
$rootDirectory = $config["rootDirectory"];
foreach ($config["dependencies"] as $dependency) {
  require "$rootDirectory/$dependency";
}
// 绝对依赖项(第三方库)
foreach ($config["absoluteDependencies"] as $dependency) {
  require "$dependency";
}

// 定义常量
define("TAXPAYERSDATA_FILENAME", "$rootDirectory/{$config["taxPayersDataFileName"]}");
define("RESULTS_FILENAME", "$rootDirectory/{$config["resultsFileName"]}");
define("ERRORS_FILENAME", "$rootDirectory/{$config["errorsFileName"]}");
//
// Symfony 依赖项
use Symfony\Component\HttpClient\HttpClient;

// 创建 [dao] 层
$clientDao = new ClientDao($config["urlServer"]);
// 创建 [métier] 层
$clientMetier = new ClientMetier($clientDao);

// 批处理模式下的税款计算
try {
  // 会话初始化
  $clientMetier->initSession('json');
  // 身份验证
  $clientMetier->authentifierUtilisateur($config["user"]["login"], $config["user"]["passwd"]);
  // 不保存结果的税款计算
  $clientMetier->executeBatchImpots(TAXPAYERSDATA_FILENAME, NULL, ERRORS_FILENAME);
  // 模拟列表
  $clientMetier->listerSimulations();
  // 删除模拟
  $simulations = $clientMetier->supprimerSimulation(1);
  // 保存结果
  $clientMetier->saveResults(RESULTS_FILENAME, $simulations);
  // 结束会话
  $clientMetier->finSession();
  // 未经过身份验证的操作 - 应导致程序崩溃
  $clientMetier->listerSimulations();
} catch (ExceptionImpots $ex) {
  // 显示错误
  print "Une erreur s'est produite : " . $ex->getMessage() . "\n";
}
// 结束
print "Terminé\n";
exit();

注释

  • 第 12-16 行:读取配置文件 [config.json]
  • 第18-26行:加载所有依赖项;
  • 第 28-34 行:定义常量和别名;
  • 第 36-39 行:构建 [dao] [métier] 层;
  • 第 44 行:初始化 jSON 会话;
  • 第 46 行:向服务器进行身份验证;
  • 第 48 行:计算一系列纳税人的税款。不保存结果(NULL 的第二个参数);
  • 第50行:查询所有计算结果;
  • 第52行:删除第1个模拟(列表中的第2个);
  • 第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

客户端 [métier] 层的测试类代码与先前客户端的测试类代码类似:


<?php

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

// 命名空间
namespace Application;

// 常量的定义
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-12");
// 配置文件路径
define("CONFIG_FILENAME", ROOT . "/Data/config.json");

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

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

// 测试类
class ClientDaoTest extends \Codeception\Test\Unit {
  // DAO 层
  private $clientDao;

  public function __construct() {
    parent::__construct();
    // 获取配置
    $config = \json_decode(\file_get_contents(CONFIG_FILENAME), true);
    // 创建 [dao] 层
    $clientDao = new ClientDao($config["urlServer"]);
    // 创建层 [métier]
    $this->métier = new ClientMetier($clientDao);
    // 初始化会话
    $this->métier->initSession("json");
    // 身份验证
    $this->métier->authentifierUtilisateur("admin", "admin");
  }

  // 测试
  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]和[métier]层;
  • 第42-45行:测试方法[test1…, test11]用于测试方法[calculerImpot]。为此,必须先初始化会话jSON并进行身份验证;

测试结果如下:

Image

还应进行许多其他测试:

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