Skip to content

17. Web服务

:此处所指的Web服务,是指任何向客户端提供原始数据的Web应用程序,在接下来的示例中,即指控制台脚本。 我们不关注特定技术,例如 REST(REpresentational 状态传输)或 SOAP(简单对象访问协议),这些技术以明确定义的格式提供或多或少的原始数据。 REST 返回的是 jSON,而 SOAP 返回的则是 XML。 这些技术都详细规定了客户端应如何向服务器发起请求,以及服务器响应应采用何种形式。在本课程中,我们将对客户端请求和服务器响应的性质采取更灵活的态度。不过,所编写的脚本和使用的工具与 REST 技术所用的非常接近。

17.1. 简介

由于 PHP 程序可由 WEB 服务器执行,此类程序便成为可服务于多个客户端的服务器程序。 从客户端的角度来看,调用Web服务相当于请求该服务的URL。客户端可以使用任何语言编写,特别是PHP。 在后一种情况下,我们将使用刚才介绍过的网络函数。此外,我们还需要懂得如何与Web服务“对话”,即理解http服务器与其客户端之间的通信协议。这正是上一段落的目的。

“链接”一节中描述的Web客户端让我们得以了解HTTP协议的一部分。

Image

在最简单的版本中,客户端与服务器的交互如下:

  • 客户端与Web服务器的80端口建立连接;
  • 客户端发起文档请求;
  • Web 服务器发送所请求的文档并关闭连接;
  • 客户端随后关闭连接;

文档的类型多种多样:可能是 HTML 格式的文本、图片、视频等。它既可以是现成的文档(静态文档),也可以是由脚本即时生成的文档(动态文档)。在后一种情况下,我们称之为 Web 编程。 用于动态生成文档的脚本可以使用多种语言编写:PHP、Python、Perl、Java、Ruby、C#、VB.net……

接下来,我们将使用脚本 PHP 来动态生成文本文档。

Image

  • [1] 中,客户端与服务器建立连接,请求脚本 PHP,并可选择是否向该脚本发送参数;
  • [2] 中,Web 服务器通过解释器 PHP 执行脚本 PHP。该脚本生成一个文档,并将其发送给客户端 [3];
  • 服务器关闭连接。客户端也随之关闭连接;

Web 服务器可同时处理多个客户端。

使用软件包 [Laragon] 时,Web 服务器为 Apache 服务器,这是 Apache 基金会(http://www.apache.org/)提供的一款开源服务器。在以下应用中,需启动 [Laragon]

Image

这将启动 Apache Web 服务器以及 SGBD 和 MySQL。

由 Web 服务器执行的脚本将使用 NetBeans 工具编写。到目前为止,我们编写了在控制台环境中执行的 PHP 脚本:

Image

用户通过控制台请求执行脚本 PHP 并接收其结果。

在接下来的客户端/服务器应用程序中:

  • 客户端脚本在控制台环境中执行;
  • 服务器端脚本在 Web 环境中执行;

Image

服务器端的 PHP 脚本不能位于文件系统中的任意位置。因为 Web 服务器会根据配置指定的位置,查找用户请求的静态和动态文档。 Laragon 的默认配置规定,文档将在 <Laragon>/www 文件夹中查找,其中 <Laragon> 是 Laragon 的安装目录。 因此,如果某个Web客户端请求路径为 URL [http://localhost/D] 的文档 D,Web 服务器将返回路径为 [<Laragon>/www/D] 的文档 D。

在接下来的示例中,我们将服务器脚本放在 [www/php7/scripts-web] 文件夹中。如果一个服务器脚本名为 S.php,则将通过 URL [http://localhost/php7/scripts-web/S.php] 向 Web 服务器请求该脚本。 此时,系统将返回文档 [<Laragon>/www/php7/scripts-web/S.php]

Image

  • [1] 中,文件夹 [<laragon>/www]
  • [2] 对应文件夹 [php7/scripts-web]

要使用 NetBeans 创建服务器脚本,我们将按照以下步骤进行:

Image

  • [1-2] 中,我们创建一个新项目
  • [3-4] 中,我们选用类别 [PHP] 以及项目 [PHP Application]

Image

  • [5] 中,项目名称;
  • [6] 中,项目在文件系统中的文件夹。请注意,该项目位于 [<laragon>/www] 文件夹中,这正是它应处的位置;
  • [7-8] 中,接受系统提供的默认值;
  • [9-10] 中,接受系统提供的默认值。在 [10] 中,请注意我们将放置在此项目中的脚本 URL 将以路径 [http://localhost/php7/scripts-web/] 开头;

Image

  • [11] 中,为您提供了用 PHP 编写的 Web 框架。一旦 Web 应用程序规模稍大,这些框架就必不可少;
  • [12] 中,可通过工具 [Composer] 添加库 PHP。我们在 Laragon 的 [Terminal] 窗口中两次使用了该工具:
    • 用于安装支持发送邮件的库 [SwiftMailer]
    • 安装用于读取邮件的库 [php-mime-mail-parser]
  • 在[13]中,一旦项目创建向导通过验证,该项目就会在“项目”选项卡中显示为[13];

17.2. 编写静态页面

注意:后续操作需确保已启动 [Laragon]

我们将演示如何使用 NetBeans 创建静态页面 HTML(HyperText 标记语言):

Image

  • [1-5] 中,我们创建一个名为 [01] 的文件夹;

Image

Image

  • [6-12] 文件中,我们创建名为 HTML 的文件[exemple-01.html]

生成的 [exemple-01.html] 文件预填充内容如下(2019年5月):


<!DOCTYPE html>
<!--
To change this license header, choose License Headers in Project Properties.
To change this template file, choose Tools | Templates
and open the template in the editor.
-->
<html>
    <head>
        <title>TODO supply a title</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <body>
        <div>TODO write content</div>
    </body>
</html>

让我们按以下方式修改其内容:


<!DOCTYPE html>
<html>
    <head>
        <title>PHP7 par l'exemple</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <body>
        <div><b>Ceci est un exemple de page statique</b></div>
    </body>
</html>

我们更改了页面标题(第4行)及其内容(第9行)。

现在,让我们通过 Laragon 的 Apache 服务器显示该页面 HTML:

Image

  • [1-2] 中,我们通过 Laragon 的 Apache 服务器显示该页面;
  • [3] 中,显示页面的 URL;
  • [4] 中,我们修改了标题;
  • [5] 中,显示了我们修改后的内容;

显示的页面是一个静态页面:可以在浏览器中无限次加载(F5),显示的内容始终相同。

大多数浏览器允许访问客户端与服务器之间交换的数据,即“链接”段落中描述的数据。在 Firefox 浏览器(2019 年 5 月版)中,需执行 F12 才能访问这些数据:

Image

[1] 所示,重新加载页面(F5):

Image

  • [2] 中,浏览器加载的文档:我们选中它;

Image

  • [5] 中,待分析的文档已被选中;
  • [3-4] 中,我们请求查看客户端/服务器交互;
  • [6] 中,显示这些通信记录;

Image

  • [7] 中,选择“标头”选项卡;
  • [8] 中,浏览器请求的 URL;
  • [9] 中,发送到服务器的命令是 [GET http://localhost/php7/scripts-web/01/exemple-01.html HTTP/1.1]
  • [10] 中,浏览器(客户端)随后发送的头部信息为 HTTP;
  • [11] 中,服务器响应的头部为 HTTP;

Image

  • [12-14] 中,是服务器在发送完 HTTP 头部后发出的响应;
  • [14] 中,可以看到客户端浏览器已收到我们构建的页面 HTML。随后,它解析了该代码并显示了以下内容:

Image

17.3. 创建动态页面 PHP

现在,我们正在使用 PHP 编写一个动态页面:

Image

Image

  • [1-8] 中,我们创建了一个 [exemple-01.php] 页面;

生成的 [exemple-01.php] 文件预填充内容如下(2019年5月):


<!DOCTYPE html>
<!--
To change this license header, choose License Headers in Project Properties.
To change this template file, choose Tools | Templates
and open the template in the editor.
-->
<html>
    <head>
        <meta charset="UTF-8">
        <title></title>
    </head>
    <body>
        <?php
        // 请在此处输入您的代码
        ?>
    </body>
</html>

我们将上述代码修改如下:


<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Exemple de page dynamique</title>
    </head>
    <body>
        <?php
        // 时间:当前时刻与1970年1月1日之间的毫秒数
        // 日期时间显示格式
        // d:2位数的日期
        // m:2位数的月份
        // y:2位数的年份
        // H:小时 0,23
        // I:分钟
        // s:秒
        print "<b>Date et heure du jour : </b>" . date("d/m/y H:i:s", time());
        ?>
    </body>
</html>

注释

  • 第 5 行:我们更改了页面标题;
  • 第17行:输出当前的日期和时间;

基本上,上面的脚本 PHP 会在控制台上输出当前时间。 然而,当由Web服务器执行时,通常与脚本运行控制台关联的[print]语句的输出流,会被重定向到连接服务器与客户端的通道中。因此,在Web环境中,上述脚本会将当前时间以文本形式发送给客户端(此处指浏览器)。

现在运行脚本 [exemple-01.php]

Image

  • [3] 中,向 Apache Web 服务器请求的 URL;
  • 生成 [4],即我们修改后的页面标题;
  • [5] 中,由指令 [print] 生成的内容;

这是一个动态页面,因为如果在浏览器中多次刷新页面(F5),其内容会发生变化(时间会更新)。

浏览器接收到了一个 HTML 数据流。要查看该数据流,需在浏览器中显示页面的源代码:

Image

  • 若要获取菜单 [1],请在浏览器中右键点击页面;
  • [2] 中,页面 [exemple-01.php] 的 URL,但前缀为 [view-source :] [3]
  • [4] 中,包含浏览器已显示的 HTML 内容;

因此,必须记住:一个旨在由 Web 服务器执行的脚本 PHP 必须生成数据流 HTML。

现在让我们(F12)来看服务器发送给客户端浏览器的头部信息(HTTP):

Image

  • [3] 中,出现了一个 HTTP 标头,而请求静态页面时该标头并不存在。该标头表明服务器的响应是由脚本 PHP 生成的;

我们已经看到,服务器的响应(此处的流为 HTML)可以由脚本 PHP 生成。该脚本还可以生成标头 HTTP 以及服务器响应中的几乎所有元素。

17.4. HTML语言入门

本章不会过多探讨 WEB 语言的编程。在链接段落中开发了一个 MVC 网络应用程序。 本章主要关注Web服务:即通过Web服务器向其他客户端提供数据的PHP页面。不过,我们认为向读者介绍一些HTML的基础知识还是很有必要的。

网页浏览器可以显示各种文档,其中最常见的是 HTML 文档(HTML 标记语言)。HTML 文档是一种使用 HTML 标签进行格式化的文本。 因此,文本 <b>important</b> 将以粗体显示文本 important。还有一些独立的标签,例如标签 <hr/>,它会显示一条水平线。 我们不会详细介绍 HTML 文本中可能出现的标签。 有许多 WYSIWYG 软件,可以无需编写任何 HTML 代码即可构建 WEB 页面。 这些工具会自动生成基于鼠标操作和预定义控件所设计版面的HTML代码。 因此,用户可以(使用鼠标)在页面中插入一个表格,然后查看软件生成的代码,从而了解在WEB页面中定义表格应使用的标签。这并不复杂。 此外,掌握HTML语言是必不可少的,因为动态Web应用程序必须自行生成HTML代码,并将其发送给WEB客户端。 该代码由程序生成,当然必须清楚需要生成什么内容,才能让客户端获得其期望的网页。

总而言之,开始进行网页编程时,完全无需掌握 HTML 语言的全部内容。 然而,这种知识是必要的,可以通过使用WYSIWYG网页构建软件(如WEB、DreamWeaver以及其他数十种软件)来掌握。 探索 HTML 语言精妙之处的另一种方法是浏览网页,并查看那些具有您尚未了解的有趣特征的网页的源代码。

请看以下示例,其中展示了 WEB 文档中可能包含的某些元素,例如:

  • 一个表格;
  • 一张图片;
  • 一个链接。

Image

HTML 文档的一般格式如下:

<html> <head> <title>标题</title> ... </head> <body 属性> ... </body></html>

整个文档由 <html>…</html> 标签包围。它由两部分组成:

  1. <head>…</head>:这是文档中不可显示的部分。它向将要显示文档的浏览器提供信息。其中通常包含<title>…</title>标签,用于设定浏览器标题栏中显示的文本。此外还可能包含其他标签,特别是定义文档关键词的标签,这些关键词随后会被搜索引擎使用。 该部分还可能包含脚本,通常采用JavaScript或VBScript编写,并将由浏览器执行。
  1. <body 属性>…</body>:这是浏览器将显示的部分。该部分包含的标签会向浏览器指示文档的“预期”视觉呈现形式。每个浏览器都会以自己的方式解释这些标签。 因此,同一网页文档在不同浏览器中的显示效果可能存在差异。这通常是网页设计师面临的难题之一。

我们示例文档的代码如下:


<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
        <title>Quelques balises HTML</title>
    </head>

    <body style="background-image: url(images/standard.jpg)">
        <h1 style="text-align: left">Quelques balises HTML</h1>
        <hr />

        <table border="1">
            <thead>
                <tr>
                    <th>Colonne 1</th>
                    <th>Colonne 2</th>
                    <th>Colonne 3</th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    <td>cellule(1,1)</td>
                    <td style="text-align: center;">cellule(1,2)</td>
                    <td>cellule(1,3)</td>
                </tr>
                <tr>
                    <td>cellule(2,1)</td>
                    <td>cellule(2,2)</td>
                    <td>cellule(2,3</td>
                </tr>
            </tbody>
        </table>
        <br/><br/>
        <table border="0">
            <tr>
                <td>Une image</td>
                <td>
                    <img border="0" src="images/cerisier.jpg"/></td>
            </tr>
            <tr>
                <td>Le site de Polytech'Angers</td>
                <td><a href="http://www.polytech-angers.fr/fr/index.html">ici</a></td>
            </tr>
        </table>
    </body>
</html>
Elément
标签与示例 HTML
titre du document
<title>部分标签 HTML</title>(第 5 行)
文本 [Quelques balises HTML] 将显示在浏览器的标题栏中,该浏览器将显示该文档
barre horizontale
<hr />:显示一条水平线(第 10 行)
tableau
<table 属性>….</table>:用于定义表格(第 12、32 行)
<thead>…</thead>:用于定义列标题(第13、19行)
<tbody>…</tbody>:用于定义表格内容(第20、31行)
<tr 属性>…</tr>:用于定义一行(第 21、25 行)
<td 属性>…</td>:用于定义一个单元格(第 22 行)
示例
<table border="1">…</table>:border 属性定义表格边框的粗细
<td style="text-align: center;">单元格(1,2)</td>(第23行):定义一个内容为单元格(1,2)的单元格。该内容将水平居中(text-align: center)。
image
<img border="0" src="images/cerisier.jpg"/> (第 38 行):定义了一张无边框(border="0")的图片,其源文件位于 Web 服务器上的 [images/cerisier.jpg](src="images/cerisier.jpg")。 该链接位于通过 URL 生成的网页文档中 http://localhost/php7/scripts-web/01/balises.html。 因此,浏览器将请求 URL http://localhost/php7/scripts-web/01/images/cerisier.jpg 以获取此处引用的图片。
lien
<a href="http://www.polytech-angers.fr/fr/index.html">此处</a> (第42行):使文本 ici 作为指向 URL 的链接 http://www.polytech-angers.fr/fr/index.html
fond de page
<body style="background-image: url(images/standard.jpg)"> (第8行):表示用作页面背景的图片位于服务器WEB上的URL [images/standard.jpg]位置。 在本例中,浏览器将向 http://localhost/php7/scripts-web/01/images/standard.jpg 请求 URL 以获取该背景图片。

从这个简单示例中可以看出,为了构建完整的文档,浏览器必须向服务器发出三项请求:

  1. http://localhost/php7/scripts-web/01/images/balises.html 以获取文档源文件 HTML
  2. http://localhost/php7/scripts-web/01/images/cerisier.jpg 用于获取图片 cerisier.jpg
  3. http://localhost/php7/scripts-web/01/images/standard.jpg 用于获取背景图片 standard.jpg

这正是客户端与服务器之间网络通信所显示的内容(浏览器中的 F12):

Image

  • [3-5] 中,可以看到浏览器发出的三个请求;

17.5. 将静态页面动态化

让我们演示如何将页面 HTML [exemple-01.html] 动态化。复制内容

Image

我们将 [exemple-01.html] 的内容复制到了文件 [page-01.php] 中。如果运行 [2] 这个 Web 脚本,浏览器中将显示如下内容:

Image

  • [3] 中,即请求的 URL;
  • [4],即页面标题;
  • [5],即页面内容;

如果显示浏览器接收到的代码,会看到以下内容:

Image

  • [7] 中,HTML 代码被放置在 [exemple-01.php] 脚本中

解释器 PHP 解释了脚本 [page-01.php],并生成了与静态页面 [exemple-01.html] 相同的流 HTML。 在脚本 [page-01.php] 中,没有 PHP,只有 HTML。 由此我们了解到:当解释器 PHP 在脚本 PHP 中发现 HTML 时,它不会对其进行任何处理,而是原样将其发送给客户端。

现在,让我们在脚本 [page-01.php] 中加入一些 PHP 指令,以便解释器 PHP 有事可做:


<!DOCTYPE html>
<html>
    <head>
        <title><?php print $page->title ?></title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <body>
        <div><b><?php print $page->contents ?></b></div>
    </body>
</html>

在第 4 行和第 9 行,我们添加了 PHP 代码,用于动态生成页面的标题和内容。这里假设变量 [$page] 是一个包含待显示数据的对象。

如果我们运行这段新代码,浏览器中将显示以下结果:

Image

  • [1] 中,请求的 URL;
  • [2] 中,由于变量 [$page] 未定义,因此无法显示页面标题;
  • [3] 中,内容同样无法显示;

现在,编写以下 [exemple-02.php] Web 脚本:

Image

[exemple-02.php]脚本如下:


<?php

// 定义要显示的页面元素
$page=new \stdclass();
$page->title="Un nouveau titre";
$page->contents="Un nouveau contenu généré dynamiquement";
// 显示 [page-01]
require_once "page-01.php";
  • 第4-6行:定义对象[$page]
  • 第 8 行:包含脚本 [page-01.php]。该脚本的代码将依次被解释:
    • 变量 [$page] 现已定义,解释器 PHP 将使用该变量;
    • HTML 的代码将原样发送给客户;
    • PHP 和 [print] 操作的结果将被包含在发送给客户的文本流中;

现在,如果我们执行 Web 脚本 [exemple-02.php],浏览器中将显示以下内容:

Image

如果查看浏览器接收到的文本内容:

Image

  • 原先位于 [2] [3] 中的 PHP 代码已被两个 [print] 命令的执行结果所替换;

从这个示例中,我们可以得出两点结论:

  • 面向浏览器的 HTML 页面可被隔离在 PHP 脚本中,该脚本仅包含 HTML 代码以及由 PHP 代码生成的部分动态内容。 这些页面中应尽可能减少 PHP 的使用;
  • 所有生成 HTML 页面中动态数据的逻辑,都必须隔离在纯 PHP 脚本中,且不包含任何页面呈现代码 (HTML、CSS、Javascript…);

这使得任务能够分离:

  • 负责生成待显示网页的任务(HTML、CSS、Javascript…);
  • 构建的 Web 应用程序逻辑任务。该逻辑可以采用三层架构来实现,这与我们之前在控制台脚本中采用的方式完全一致;

接下来,我们将构建特定的 Web 脚本;

  • 它们仅向客户端发送数据,不包含任何格式化内容(HTML、CSS、Javascript)。因此,这些将作为数据服务器而非网页存在;
  • 这些Web脚本的客户端将是控制台脚本,负责接收服务器发送的数据并进行处理;

17.6. 日期/时间客户端/服务器应用程序

现在我们采用以下配置:

Image

我们将编写:

  • 一个 Web 脚本 [1],用于向其客户端发送当前的日期和时间;
  • 一个控制台脚本 [2],它将作为 Web 脚本的客户端:它将接收 Web 脚本发送的日期和时间,并在控制台上显示出来;

Image

  • [1] 中,包含 Web 脚本 [date-time-server.php]
  • [2] 设置为 [date-time-client] 控制台脚本(作为 Web 脚本的客户端);

17.6.1. 服务器脚本

我们在链接段落中已经编写了一个生成当前日期和时间的Web脚本。那就是以下脚本[exemple-01.php]


<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Exemple de page dynamique</title>
    </head>
    <body>
        <?php
        // time:自1970年1月1日以来的毫秒数
        // 日期时间显示格式
        // d:2位数的日期
        // m:2位数的月份
        // y:2位数的年份
        // H:小时 0,23
        // i:分钟
        // s:秒
        print "<b>Date et heure du jour : </b>" . date("d/m/y H:i:s", time());
        ?>
    </body>
</html>

我们曾提到将编写数据服务器脚本:输出未经格式化的原始数据 HTML。因此,服务器脚本 [date-time-server.php] 将如下所示:


<?php

// 设置标题HTP [Content-Type]
header('Content-Type: text/plain; charset=UTF-8');
//
// 发送日期和时间
// time:自1970年1月1日以来的毫秒数
// 日期和时间显示格式
// d:2位数的日期
// m:2位数的月份
// y:2位数的年份
// H:小时 0,23
// i:分钟
// s:秒
print date("d/m/y H:i:s", time());
  • 第4行:设置HTTP [Content-Type]标头,告知客户端即将接收的文档类型。此前,[Content-Type]的值是:[Content-Type: text/html; charset=UTF-8]。 在此,我们告知客户端该文档为无格式文本 HTML。这对我们的控制台客户端并不重要,因为它不会利用此标头。但这对客户端浏览器更为重要,因为它们会利用此标头;

运行以下服务器脚本:

Image

如果我们在浏览器中查看服务器的响应(F12), 我们会发现 [5] 包含服务器脚本设定的 HTTP 标头,而 [8] 则包含接收到的文本文档;

Image

17.6.2. 客户端脚本

“链接”部分,我们开发了多个 HTTP 客户端。我们可以利用它们来获取由服务器脚本 [date-time-server.php] 发送的文本文档。但我们不会这样做。 与处理 SMTP 和 IMAP 协议时一样,我们将使用第三方库,即 Symfony 框架中的 [HttpClient] 组件。

与前两个库一样,我们使用 [Composer] 工具来安装 Symfony 的 [HttpClient] 组件。在 Laragon 的 [Terminal] 窗口中(参见链接段落),输入以下命令:

Image

  • [3] 中,请确认当前工作目录位于 [<laragon>/www/] 文件夹下,其中 <laragon> 是 Laragon 的安装目录;
  • [4] 替换为 [composer],该命令用于安装 Symfony 的 [HttpClient] 库;
  • [5] 中,未安装任何内容,因为 [HttpClient] 库已在此计算机上安装;
  • [6-7] 中,[<laragon>/www/vendor/symfony] 目录下出现了新文件夹;

[5] 文件中,您应该看到类似以下内容:


C:\myprograms\laragon-lite\www
? composer require symfony/http-client
Using version ^4.3 for symfony/http-client
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 4 installs, 0 updates, 0 removals
  - Installing symfony/polyfill-php73 (v1.11.0): Downloading (100%)
  - Installing symfony/http-client-contracts (v1.1.1): Downloading (100%)
  - Installing psr/log (1.1.0): Loading from cache
  - Installing symfony/http-client (v4.3.0): Downloading (100%)
Writing lock file
Generating autoload files

请确保文件夹 [<laragon>/www/vendor] 属于您项目中的 [Include Path] 分支(参见链接段落):

Image

完成上述步骤后,我们可以编写控制台脚本 [date-time-client.php]

Image

控制台脚本 [date-time-client.php] 将利用以下文件:jSON [config-date-time-client.json]

1
2
3
{
    "url": "http://localhost/php7/scripts-web/02/date-time-server.php"
}
  • 第 2 行:服务器脚本中的 URL;

客户端脚本 [date-time-client.php] 将如下所示:


<?php

// 日期/时间服务客户端
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-date-time-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端HTTP
$httpClient = HttpClient::create();

try {
  // 发送请求
  $response = $httpClient->request('GET', $config['url']);
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取头部信息
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 获取响应正文
  $content = $response->getContent();
  // 显示结果
  print "---Réponse du serveur : [$content]\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
  exit;
}

注释

  • 第 10 行:与之前处理库文件的方式相同,我们加载文件 [<laragon>/www/vendor/autoload.php]
  • 第 11 行:声明我们将要使用的类 [HttpClient]
  • 第 13-24 行:从字典 [$config] 中获取脚本配置;
  • 第 27 行:创建一个 [HttpClient] 类型的对象;
  • 第 31 行:使用命令 GET:[GET URL HTTTP/1.1] 向服务器脚本请求 URL。此操作为异步操作。 执行流程在第33行继续,无需等待响应返回;
  • 第33行:查询响应状态。 该状态位于服务器返回的第一个标头中。因此,如果该标头为 HTTP,则响应状态为 200。此操作是阻塞的:只有当客户端收到服务器的完整响应后,程序才会返回;
  • 第 37 行:查询响应的 HTTP 标头;
  • 第42行:请求服务器返回的文档:我们知道此处的文档是文本。
  • 第45-49行:若发生错误,则显示错误信息;

当执行客户端脚本时(需确保 Laragon 已启动,以便访问服务器端脚本),控制台将显示以下结果:


---Réponse avec statut : 200
---Entêtes de la réponse
date: Thu, 30 May 2019 14:42:03 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
content-length: 17
content-type: text/plain; charset=UTF-8
---Réponse du serveur : [30/05/19 14:42:03]

第8行确实成功获取了当前的日期和时间。

您可能好奇客户端脚本向服务器发送了什么。为此,我们将使用我们的通用服务器 TCP(参见链接段落):

Image

  • [1] 中,即工具文件夹;
  • [2] 中,TCP 服务器已在 100 端口上启动;
  • [3] 中,等待键盘输入的命令;

我们修改脚本 [date-time-client.php] 的配置文件:


{
    "url": "http://localhost:100/php7/scripts-web/02/date-time-server.php"
}

这次,客户端通过 100 端口联系服务器 [localhost]。因此,将被调用的将是我们的通用服务器 TCP。 当我们执行控制台脚本 [date-time-client.php] 时,通用服务器 TCP 的控制台输出如下:

Image

  • 变为 [3],即由客户端脚本生成的 HTTP GET 命令;
  • 变为 [4],即控制台脚本的签名;
  • [5] 中,服务器对客户端脚本的响应。需要注意的是,这不是一个有效的 HTTP 响应:
    • 应包含 HTTP 头部;
    • 随后是一行空行;
    • 接着是发送给客户端的文本文档;
  • [6] 中,关闭与客户端脚本的通信,以便脚本检测到已收到完整的响应;

在客户端脚本方面,控制台显示如下:

Image

  • [7] 中,Symfony 客户端接收到的内容;

17.6.3. 服务器脚本 – 版本 2

默认情况下,用于编写 Web 脚本的 PHP 函数并非面向对象的。因此在服务器端,我们不得不将类与传统的 PHP 函数混合使用。 为了实现更统一的编写风格,我们将使用 Symfony 框架的 [HttpFoundation] 库。该库已将 Web 服务所需的所有传统 PHP 函数封装到类和接口系统中。 该库的文档可在 URL [https://symfony.com/doc/current/components/http_foundation.html](2019年5月)处查阅。

要在 Laragon 终端中安装该库,请按以下步骤操作(参见链接段落):

Image

  • [2-3]:请确保当前位于 [<laragon>/www] 文件夹中;
  • [4]:该命令将安装 [HttpFoundation] 库;
  • [5]:在此示例中,库已安装;

首次安装时,您应会看到类似以下的控制台日志:


C:\myprograms\laragon-lite\www
? composer require symfony/http-foundation
Using version ^4.3 for symfony/http-foundation
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 2 installs, 0 updates, 0 removals
  - Installing symfony/mime (v4.3.0): Downloading (100%)
  - Installing symfony/http-foundation (v4.3.0): Downloading (100%)
Writing lock file
Generating autoload files

[date-time-server-2.php] 版本的 Web 服务器如下:


<?php

// 使用 Symfony 库

// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpFoundation\Response;

// 设置 Content-Type 头部
$response=new Response();
$response->headers->set("content-type","text/plain");
$response->setCharset("utf-8");

// 设置响应内容
//
// 发送日期和时间
// time:自 1970 年 1 月 1 日起的毫秒数
// 日期和时间显示格式
// d:2位数的日期
// m:2位数的月份
// y:2位数的年份
// H:小时 0,23
// i:分钟
// s:秒
$response->setContent(date("d/m/y H:i:s", time()));

// 发送响应
$response->send();

注释

  • 第 7 行:Symfony 库 [HttpFoundation] 中的类 [Response] 负责处理 Web 服务对客户端的所有响应;
  • 第 10 行:创建 [Response] 类的实例;
  • 第 11 行:指定响应类型为 [text/plain]
  • 第 12 行:响应内容为文本 UTF-8;
  • 第25行:设置响应文档,即客户端所请求的内容;
  • 第28行:将响应发送给客户端;

17.6.4. 客户端脚本 – 版本 2

客户端脚本保持不变。仅修改其配置文件 [config-date-time-client.json]

1
2
3
{
    "url": "http://localhost/php7/scripts-web/02/date-time-server-2.php"
}

结果与版本 1 相同。

17.7. jSON 数据服务器

Web脚本的响应可能包含多项数据,这些数据可整合为数组和对象。随后,脚本可将这些不同元素封装在字符串 jSON 中发送,由客户端进行解码。

Image

17.7.1. 服务器脚本

脚本 [json-server.php] 使用以下类 [Personne]


<?php

namespace Modèles;

class Personne implements \JsonSerializable {
  // 属性
  private $nom;
  private $prénom;
  private $âge;

  // 将关联数组转换为对象[Personne]
  public function setFromArray(array $assoc): Personne {
    // 用关联数组初始化当前对象
    foreach ($assoc as $attribute => $value) {
      $this->$attribute = $value;
    }
    // 结果
    return $this;
  }

  // 获取器和设置器
  public function getNom() {
    return $this->nom;
  }

  public function getPrénom() {
    return $this->prénom;
  }

  public function setNom($nom) {
    $this->nom = $nom;
    return $this;
  }

  public function setPrénom($prénom) {
    $this->prénom = $prénom;
    return $this;
  }

  public function getÂge() {
    return $this->âge;
  }

  public function setÂge($âge) {
    $this->âge = $âge;
    return $this;
  }

  // toString
  public function __toString(): string {
    return "Personne [$this->prénom, $this->nom, $this->âge]";
  }

  // 实现接口 JsonSerializable
  public function jsonSerialize(): array {
    // 返回一个关联数组,其键为对象的属性
    // 该数组随后可编码为 jSON
    return get_object_vars($this);
  }

  // 将 jSON 转换为 [Personne] 对象
  public static function jsonUnserialize(string $json): Personne {
    // 根据字符串 jSON 创建一个人员
    return (new Personne())->setFromArray(json_decode($json, true));
  }

}

注释

  • 第 5 行:该类实现了接口 PHP [JsonSerializable]。这要求它必须实现第 55-59 行中的方法 [jsonSerialize]。 该方法必须返回一个关联数组,该数组需序列化为 jSON。 当使用表达式 [json_encode($personne)] 时,函数 [json_encode] 会检查类 [Personne] 是否实现了接口 [JsonSerializable]。 如果是,则表达式变为 [json_encode($personne→serialize())]
  • 第 12-19 行:该类没有构造函数,但有一个初始化器。因此,可以通过表达式 [(new Personne())→setFromArray($array)] 实例化类 [Personne]。 初始化器可以有多种类型,而构造函数只能有一个。这些初始化器支持对类型 [(new Personne())→initialiseuri(…)] 进行多种实例化方式;
  • 第 62-65 行:静态函数 [jsonUnserialize] 允许根据字符串 jSON 创建 [Personne] 对象;

脚本 [json-server.php] 如下所示:


<?php

// 依赖项
require_once __DIR__ . "/Personne.php";
use \Modèles\Personne;
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use \Symfony\Component\HttpFoundation\Response;

// 设置 Content-Type 标头及所用字符集
$response = new Response();
$response->headers->set("content-type", "application/json");
$response->setCharset("utf-8");

// 创建一个 Personne 对象
$personne = (new Personne())->setFromArray([
  "nom" => "de la Hûche",
  "prénom" => "jean-paul",
  "âge" => 27]);
// 一个关联数组
$assoc = ["attr1" => "value1",
  "attr2" => [
    "prenom" => "Jean-Paul",
    "nom" => "de la Hûche"
  ]
];
// 响应内容为 jSON
$response->setContent(json_encode([$personne, $assoc]));

// 发送响应
$response->send();

注释

  • 第 4-5 行:导入类 [Personne]
  • 第11行:指定文档类型为[application/json]。浏览器接收到此标头后,将显示jSON字符串的格式化内容,而非纯文本;
  • 第 12 行:字符串 jSON 将包含字符 UTF-8;
  • 第 15-18 行:创建一个 [Personne] 对象;
  • 第20-25行:创建一个两级关联数组;
  • 第27行:将数组中的字符串jSON发送给客户端:
    • 元素 [$personne] 将通过其方法 [jsonSerialize] 序列化为 jSON;
    • 元素 [$assoc] 将原生序列化为 jSON;

若运行此服务器脚本(需启动 Laragon),浏览器中将显示以下响应:

Image

Image

注释

  • [2] 中,格式化后的响应 jSON;
  • [4] 表示对 jSON 响应的格式化处理;请注意重音字符的编码;
  • [6] 中,正是服务器发送的 [application/json] 内容类型导致浏览器进行了此格式化;

17.7.2. 客户端

Image

客户端 [json-client.php] 由以下文件配置:

1
2
3
{
    "url": "http://localhost/php7/scripts-web/03/json-server.php"
}

脚本 [json-client.php] 如下:


<?php

// 服务客户端 jSON
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;
require_once __DIR__ . "/Personne.php";
use \Modèles\Personne;

// 客户端配置
const CONFIG_FILE_NAME = "config-json-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端HTTP
$httpClient = HttpClient::create();

try {
  // 发起请求
  $response = $httpClient->request('GET', $config['url']);
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取头部信息
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 获取响应正文 jSON
  list($personne, $assoc) = json_decode($response->getContent(), true);
  // 根据属性数组实例化一个人员
  $personne = (new Personne())->setFromArray($personne);
  // 显示服务器的响应
  print "---Réponse du serveur\n";
  print "$personne\n";
  print "tableau=" . json_encode($assoc, JSON_UNESCAPED_UNICODE) . "\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}


注释

  • 第 12-13 行:导入类 [Personne]
  • 第 30 行:创建 HTTP 客户端;
  • 第 44 行:解码服务器发送的字符串 jSON。已知被编码的内容是一个包含两个关联数组的二元数组;
  • 第 46 行:创建对象 [Personne],随后在第 49 行将其显示出来;
  • 第50行:显示第二个关联数组。指令[print]无法显示数组。因此,我们将该指令转换为字符串jSON。 为了正确显示带重音的字符,必须设置第二个参数为 [JSON_UNESCAPED_UNICODE]。我们已经看到,带重音的字符确实编码在字符串 jSON 中;

执行客户端脚本后,结果如下:


---Réponse avec statut : 200
---Entêtes de la réponse
date: Sun, 02 Jun 2019 09:56:29 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: no-cache, private
content-length: 143
connection: close
content-type: application/json
---Réponse du serveur
Personne [jean-paul, de la Hûche, 27]
tableau={"attr1":"value1","attr2":{"prenom":"Jean-Paul","nom":"de la Hûche"}}

第 11 行和第 12 行中,重音字符已正确还原。

17.8. 获取 Web 服务的环境变量

服务器脚本在可识别的 Web 环境中运行。该环境存储在字典 $_SERVER 中,这是 PHP 的全局变量。 如果我们使用 [HttpFoundation],该环境将位于字段 [Request→server] 中,其中 [Request] 是 Web 脚本处理的请求 HTTP。

17.8.1. 服务器脚本

我们编写一个服务器应用程序,该应用程序将其运行环境发送给客户端。

Image

Web脚本 [env-server.php] 如下所示:


<?php

// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use \Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;

// 获取请求
$request = Request::createFromGlobals();
// 构建响应
$response = new Response();
// 响应内容为 utf-8 编码的 json
$response->headers->set("content-type", "application/json");
$response->setCharset("utf-8");
// 设置响应内容 jSON
$response->setContent(json_encode($request->server->all()));
// 发送响应
$response->send();
  • 第 9 行:获取类型为 [Request] 的对象,该对象封装了 Web 脚本接收到的 HTTP 请求中所有可用信息,以及该脚本的运行环境;
  • 第13-14行:将包含UTF-8字符的纯文本发送给客户端;
  • 第16行:发送给客户端的信息将是一个字符串,该字符串通过将对象[$request→server→all()]进行jSON序列化而获得:[$request→server]代表Web脚本的运行环境。 这是一个 [ServerBag] 类型的对象,类似于字典。而 [$request→server→all()] 则是真正的字典,即 [ServerBag] 的内容字典;
  • 第 18 行:发送信息;

如果从 NetBeans 执行此脚本,浏览器将显示以下页面:

Image

  • [2] 中,环境字典的各个键;
  • [3] 中,显示这些键的值;

17.8.2. 客户端脚本

Image

客户端脚本 [env-client.php] 由以下文件 jSON [config-env-client.json] 配置:

1
2
3
{
    "url": "http://localhost/php7/scripts-web/04/env-server.php"
}

客户端脚本 [env-client.php] 如下:


<?php

// 服务器脚本环境
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-env-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端 HTTP
$httpClient = HttpClient::create();
try {
  // 向服务器发送请求
  $response = $httpClient->request('GET', $config['url']);
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取头部信息
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 显示服务器响应
  print "---Réponse du serveur\n";
  $env = json_decode($response->getContent());
  foreach ($env as $key => $value) {
    print "[$key]=>$value\n";
  }
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}

注释

  • 第 42 行:对来自服务器的 jSON 响应进行反序列化。得到一个关联数组;
  • 第 43-45 行:显示该关联数组中的所有值;

控制台输出结果如下:


---Réponse avec statut : 200
---Entêtes de la réponse
date: Sun, 02 Jun 2019 17:35:50 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: no-cache, private
content-length: 1505
connection: close
content-type: application/json
---Réponse du serveur
[HTTP_HOST]=>localhost
[HTTP_USER_AGENT]=>Symfony HttpClient/Curl
[HTTP_ACCEPT_ENCODING]=>deflate, gzip
[PATH]=>C:\Program Files (x86)\Mail Enable\BIN;C:\windows\system32;C:\windows;C:\windows\System32\Wbem;C:\windows\System32\WindowsPowerShell\v1.0\;C:\windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\Microsoft SQL Server\130\Tools\Binn\;C:\Program Files (x86)\Mail Enable\BIN64;C:\Users\serge\AppData\Local\Microsoft\WindowsApps;;C:\myprograms\Microsoft VS Code\bin
[SystemRoot]=>C:\windows
[COMSPEC]=>C:\windows\system32\cmd.exe
[PATHEXT]=>.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC
[WINDIR]=>C:\windows
[SERVER_SIGNATURE]=>
[SERVER_SOFTWARE]=>Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
[SERVER_NAME]=>localhost
[SERVER_ADDR]=>::1
[SERVER_PORT]=>80
[REMOTE_ADDR]=>::1
[DOCUMENT_ROOT]=>C:/myprograms/laragon-lite/www
[REQUEST_SCHEME]=>http
[CONTEXT_PREFIX]=>
[CONTEXT_DOCUMENT_ROOT]=>C:/myprograms/laragon-lite/www
[SERVER_ADMIN]=>admin@example.com
[SCRIPT_FILENAME]=>C:/myprograms/laragon-lite/www/php7/scripts-web/04/env-server.php
[REMOTE_PORT]=>63744
[GATEWAY_INTERFACE]=>CGI/1.1
[SERVER_PROTOCOL]=>HTTP/1.1
[REQUEST_METHOD]=>GET
[QUERY_STRING]=>
[REQUEST_URI]=>/php7/scripts-web/04/env-server.php
[SCRIPT_NAME]=>/php7/scripts-web/04/env-server.php
[PHP_SELF]=>/php7/scripts-web/04/env-server.php
[REQUEST_TIME_FLOAT]=>1559496950.644
[REQUEST_TIME]=>1559496950

以下是部分变量的含义(适用于 Windows。在 Linux 中,这些变量会有所不同):

HTTP_HOST
客户端发送的 HTTP [Host: xxx] 头部的 xxx 值
HTTP_USER_AGENT
客户端发送的 HTTP [User_Agent: xxx] 标头的 xxx 值
HTTP_ACCEPT_ENCODING
由客户端发送的 HTTP [Accept-Encoding: xxx] 头部的值 xxx
PATH
在运行服务器脚本的机器上可执行文件的路径
COMSPEC
命令解释器的路径 DOS
PATHEXT
可执行文件的扩展名
WINDIR
Windows 安装文件夹
SERVER_SIGNATURE
Web 服务器的签名。此处无内容。
SERVER_SOFTWARE
Web 服务器的类型
SERVER_NAME
Web 服务器的主机名
SERVER_PORT
Web 服务器的监听端口
SERVER_ADDR
Web 服务器主机的 IP 地址,此处为 127.0.0.1
REMOTE_ADDR
客户端的地址。此处客户端与服务器位于同一台机器上。
REMOTE_PORT
客户端的通信端口
DOCUMENT_ROOT
Web 服务器提供的文档树根目录
REQUEST_SCHEME
TCP 请求的协议http://localhost/php7/…
SERVER_ADMIN
Web 服务器管理员的电子邮件地址
SCRIPT_FILENAME
服务器脚本的完整路径
REMOTE_PORT
客户端发起请求的端口
SERVER_PROTOCOL
Web 服务器使用的协议版本
REQUEST_METHOD
客户端使用的命令。共有四个:GET、POST、PUT、DELETE
QUERY_STRING
随 GET 命令发送的参数 /url?paramètres
REQUEST_URI
客户端请求的URL。如果浏览器请求URL http://machine[:port]/uri,则会得到REQUEST_URI=uri
SCRIPT_NAME
$_SERVER['SCRIPT_FILENAME']=$_SERVER['DOCUMENT_ROOT'].$_SERVER['SCRIPT_NAME']

17.9. 服务器接收客户端发送的参数

17.9.1. 简介

在 HTTP 协议中,客户端有两种方法向 WEB 服务器传递参数:


GET url?param1=val1&param2=val2&param3=val3… HTTP/1.0

其中 vali 的值必须事先进行编码,以便将某些保留字符替换为相应的十六进制值;

  • 它请求服务提供的 URL,格式为

POST url HTTP/1.0

随后在发送至服务器的HTTP头部中插入以下头部:

Content-length=N

客户端发送的后续头部以空行结尾。随后,它可以将数据以以下形式发送:

val1&param2=val2&param3=val3…

其中,vali 的值必须像 GET 方法那样,事先进行编码。发送给服务器的字符数必须为 N,其中 N 是标头中声明的值

Content-length=N

Web 服务的脚本 PHP 用于获取客户端发送的上述 parami 参数,并从以下数组中获取其值:

  • 对于命令 GET,对应数组为 $_GET["parami"]
  • 对于命令 POST,参数为 $_POST["parami"]

以上是 PHP 的基本功能。如果使用 [HttpFoundation] 库,这些参数可在以下位置找到:

  • 对于命令 GET,请使用:[Request]->query->get('parami');
  • 对于命令 POST,则位于:[Request]->request->get('parami');

其中 [Request] 代表 Web 脚本接收到的请求的全部信息;

17.9.2. 客户端 GET – 版本 1

Image

客户端脚本通过以下文件进行配置:jSON [config-parameters-client.json]

1
2
3
4
{
    "url-get": "http://localhost/php7/scripts-web/05/parameters-server.php",
    "url-post": "http://localhost/php7/scripts-web/05/parameters-server.php"
}
  • 第 1 行:客户端 GET 的目标 Web 脚本 URL;
  • 第 2 行:目标 Web 脚本 POST 的 URL;

GET客户端向服务器发送三个[nom, prenom, age]参数[parameters-get-client.php]客户端如下:


<?php

// Web 服务器的 GET 客户端
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-parameters-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端 HTTP
$httpClient = HttpClient::create();

try {
  // 准备参数
  list($prenom, $nom, $age) = array("jean-paul", "de la hûche", 45);
// 对信息进行编码
  $parameters = "prenom=" . urlencode($prenom) .
    "&nom=" . urlencode($nom) .
    "&age=$age”;
  // 发送请求
  $response = $httpClient->request('GET', $config['url-get'] . "?$parameters");
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取头部信息
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 显示服务器响应
  print "---Réponse du serveur [" . $response->getContent() . "]\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}

注释

  • 第 33-35 行:对发送至服务器的参数进行编码。可能包含 UTF-8 字符的 [$prenom, $nom] 参数,使用 [urlencode] 函数进行编码。 所有非字母数字字符(按关系表达式定义)均被替换为 %xx,其中 xx 是该字符的十六进制值。空格则被替换为 + 符号;
  • 第 37 行:请求的 URL 是否为 $URL?$parameters,其中 $parameters 采用 nom=val1&prenom=val2&age=val3 的形式;
  • 第48行:客户端仅需显示响应结果;

我们可能好奇,当发送一个配置为 GET 的请求时,服务器会收到什么。为此,我们从 Laragon 终端(参见链接段落)在本地机器的 100 端口上启动我们的通用服务器 [RawTcpServer]

Image

请确认在 [4] 中,您确实位于实用程序文件夹内。

我们修改配置客户端 GET 和 POST 的文件 jSON [parameters-get-client.json]:


{
    "url-get": "http://localhost:100/php7/scripts-web/05/parameters-server.php",
    "url-post": "http://localhost/php7/scripts-web/05/parameters-server.php"
}
  • 第 2 行:我们更改了 Web 服务器的端口。因此将连接到 [RawTcpServer]

我们运行客户端。在 [RawTcpServer] 的窗口中,我们获得了以下信息:

Image

  • [1] 中,显示了客户端发送的已配置命令 GET。可以清楚地看到某些字符的编码;

17.9.3. 服务器 GET / POST

Image

服务器脚本 [parameters-server.php] 如下:


<?php

// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use \Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;

// 获取请求
$request = Request::createFromGlobals();
// 获取请求参数
$getParameters = $request->query->all();
$bodyParameters = $request->request->all();

// 构建响应
$response = new Response();
// 响应内容为 UTF-8 文本
$response->headers->set("content-type", "application/json");
$response->setCharset("utf-8");
// 响应内容 - 采用 QZXW2HTML 编码的数组CalNPTgZQX
$response->setContent(json_encode([
  "method" => $request->getMethod(),
  "uri" => $request->getRequestUri(),
  "getParameters" => $getParameters,
  "bodyParameters" => $bodyParameters
    ], JSON_UNESCAPED_UNICODE));
// 发送响应
$response->send();

注释

  • 第 9 行:创建 Web 脚本的 [Request] 对象。该对象封装了 Web 脚本从客户端接收到的所有信息;
  • 第 11 行:对象 [Request→query] 属于类型 [ParameterBag],并汇总了客户端可能执行的 GET 操作的参数。 表达式 [Request→query→get(«X»)] 可获取 GET [nom=val1&prenom=val2&age=val3] 参数中的名为 X 的参数。 表达式 [Request→query→all()] 可获取 GET 的参数字典;
  • 第 12 行:对象 [Request→request] 属于类型 [ParameterBag],并集合了作为客户端文档发送至服务器的参数。这些参数也被称为“上传参数”,因为它们属于客户端发送给服务器的文档。 表达式 [Request→request→get(«X»)] 可获取上传参数 [nom=val1&prenom=val2&age=val3] 中名为 X 的参数。表达式 [Request→request→all()] 可获取上传参数的字典;
  • 第 17-18 行:告知客户端将向其发送编码为 UTF-8 的 jSON;
  • 第20-25行:服务器向客户端返回其收到的所有参数,以及客户端执行的[GET / POST / …]操作类型和请求的URI。 该方法由表达式 [$request→getMethod()] 生成。 发送给客户端的文档是一个关联数组的字符串 jSON,其中某些值本身也是关联数组。参数 [JSON_UNESCAPED_UNICODE] 要求将 Unicode 字符(例如带重音的字符)原样发送,而不进行编码;
  • 第27行:响应已发送至客户端;

执行客户端脚本后,结果如下:

---Réponse avec statut : 200
---Entêtes de la réponse
date: Mon, 03 Jun 2019 10:08:45 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: no-cache, private
content-length: 207
connection: close
content-type: application/json
---Réponse du serveur [{"method":"GET","uri":"\/php7\/scripts-web\/05\/parameters-server.php?prenom=jean-paul&nom=de+la+h%C3%BBche&age=45","getParameters":{"prenom":"jean-paul","nom":"de la hûche","age":"45"},"bodyParameters":[]}]
  • 第 10 行:
    • [method]:方法为 GET;
    • [uri]:在所请求的URI中,可以看到来自请求GET的URL编码参数;
    • [getParameters]:GET的参数数组;
    • [bodyParameters]:上传参数数组:该数组为空;

17.9.4. GET 客户端 – 版本 2

在客户端脚本的上一版本中,出于教学目的,我们自己对发送给服务器的参数进行了 URL 编码。对象 [HttpClient] 能够自行完成这项工作。以下是脚本 [parameters-get-client-2.php]


<?php

// Web 服务器的 GET 客户端
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-parameters-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端HTTP
$httpClient = HttpClient::create();
try {
  // 准备参数
  list($prenom, $nom, $age) = array("jean-paul", "de la hûche", 45);
  // 向服务器发送请求
  $response = $httpClient->request('GET', $config['url-get'],
    ["query" => [
        "prenom" => $prenom,
        "nom" => $nom,
        "age" => $age
  ]]);
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取头部信息
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 显示服务器响应
  print "---Réponse du serveur [" . $response->getContent() . "]\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}

注释

  • 第 33-37 行:向第 32 行的 GET 请求中添加参数。[HttpClient] 对象将自行处理 URL 的编码;

17.9.5. 客户端 POST

客户端 HTTP 向 Web 服务器发送以下文本序列:HTTP 头部、空行、文档。在前一个客户端中,该序列如下:

1
2
3
GET /url?paramètres HTTP/1.1
… autres entêtes HTTP
ligne vide

当时没有文档。还有另一种传递参数的方式,即所谓的 POST 方法。在这种情况下,发送给 Web 服务器的文本序列如下:

1
2
3
4
POST /url HTTP/1.1
… autres entêtes HTTP
ligne vide
paramètres

这一次,对于客户端 GET 而言包含在 HTTP 头部中的参数,在客户端 POST 中则作为头部之后发送的文档的一部分。

客户端 POST [parameters-postclient.php] 的脚本如下:


<?php

// Web 服务器的 POST 客户端
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-parameters-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端HTTP
$httpClient = HttpClient::create();
try {
  // 准备参数
  list($prenom, $nom, $age) = array("jean-paul", "de la hûche", 45);
  // 向服务器发送请求
  $response = $httpClient->request('POST', $config['url-post'],
    ["body" => [
        "prenom" => $prenom,
        "nom" => $nom,
        "age" => $age
  ]]);
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取头部信息
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 显示服务器的响应
  print "---Réponse du serveur [" . $response->getContent() . "]\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}
  • 第 32 行:现在有一个类型为 POST 的 HTTP 请求;
  • 第33-37行:POST的参数被称为POST请求的正文(body):即客户端发送给服务器的文档。此处发送了三个参数[nom, prenom, age]
  • 第48行:显示服务器的响应jSON;

客户端脚本的执行结果如下:


---Réponse avec statut : 200
---Entêtes de la réponse
date: Mon, 03 Jun 2019 11:43:02 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: no-cache, private
content-length: 163
connection: close
content-type: application/json
---Réponse du serveur [{"method":"POST","uri":"\/php7\/scripts-web\/05\/parameters-server.php","getParameters":[],"bodyParameters":{"prenom":"jean-paul","nom":"de la hûche","age":"45"}}]
  • 第10行:方法为[Post],参数类型为[bodyParameters][uri]所示,不存在[getParameters]参数

我们可能好奇在发出 POST 请求时,服务器会收到什么。为此,我们从 Laragon 终端(参见链接段落)在本地机器的 100 端口上启动通用服务器 [RawTcpServer]

Image

请确认在 [4] 中,您确实位于实用程序文件夹内。

我们修改配置客户端 POST 的文件 jSON [config-parameters-client.json]


{
    "url-get": "http://localhost:100/php7/scripts-web/05/parameters-server.php",
    "url-post": "http://localhost:100/php7/scripts-web/05/parameters-server.php"
}
  • 第 3 行:我们更改了 Web 服务器的端口。因此将连接到 [RawTcpServer]

我们运行客户端。在 [RawTcpServer] 的窗口中,我们获得以下信息:

Image

  • [6] 中,命令为 POST;
  • [7] 中:HTTP [Content-Length] 标头给出了客户端将发送给服务器的文档的字节数。 HTTP [Content-Type] 标头说明了该文档的性质。[application/x-www-form-urlencoded] 类型表示 URL 编码的文本;
  • [8] 中,空行标志着 HTTP 头部结束,并表示 44 字节文档的开始。屏幕截图中未显示的是文档本身。 这是参数的 URL 编码字符串:[prenom=jean-paul&nom=de+la+h%C3%BBche&age=45]。读者可以验证它确实有 44 个字符;

17.9.6. 一个混合型POST客户

在 POST 中,可以将 URL 中编码的参数与客户发送的文档中 HTTP 头部之后编码的参数混合使用。以下是一个 [parameters-mixte-postclient.php] 的示例:


<?php

// Web 服务器的 POST 客户端
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-parameters-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端HTTP
$httpClient = HttpClient::create();
try {
  // 准备参数
  list($prenom, $nom, $age) = array("jean-paul", "de la hûche", 45);
  // 向服务器发送请求
  $response = $httpClient->request('POST', $config['url-post'],
    [
      // 文档参数(正文)
      "body" => [
        "prenom" => $prenom,
        "nom" => $nom,
        "age" => $age
      ],
      // URL(查询)的参数
      "query" => [
        "prenom2" => $prenom,
        "nom2" => $nom,
        "age2" => $age
  ]]);
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取头部信息
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 显示服务器响应
  print "---Réponse du serveur [" . $response->getContent() . "]\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}

注释

  • 第 32 行:一个 POST 请求;
  • 第 40-45 行:URL 中的 URL 编码参数;
  • 第 35-39 行:请求正文(body、document)中的 URL 编码参数;

执行后,控制台显示以下结果:

---Réponse avec statut : 200
---Entêtes de la réponse
date: Mon, 03 Jun 2019 12:34:23 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: no-cache, private
content-length: 270
connection: close
content-type: application/json
---Réponse du serveur [{"method":"POST","uri":"\/php7\/scripts-web\/05\/parameters-server.php?prenom2=jean-paul&nom2=de%20la%20h%C3%BBche&age2=45","getParameters":{"prenom2":"jean-paul","nom2":"de la hûche","age2":"45"},"bodyParameters":{"prenom":"jean-paul","nom":"de la hûche","age":"45"}}]
  • 第 10 行:可见服务器成功获取了这两种类型的参数;

17.9.7. 一个混合型 GET 客户端

尝试使用请求 GET 执行与之前相同的操作。脚本 [parameters-mixte-get-client.php] 如下:


<?php

// Web 服务器的 POST 客户端
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-parameters-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端HTTP
$httpClient = HttpClient::create();
try {
  // 准备参数
  list($prenom, $nom, $age) = array("jean-paul", "de la hûche", 45);
  // 向服务器发送请求
  $response = $httpClient->request('GET', $config['url-post'],
    [
      // 文档参数(正文)
      "body" => [
        "prenom" => $prenom,
        "nom" => $nom,
        "age" => $age
      ],
      // URL(查询)的参数
      "query" => [
        "prenom2" => $prenom,
        "nom2" => $nom,
        "age2" => $age
  ]]);
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取头部信息
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 显示服务器响应
  print "---Réponse du serveur [" . $response->getContent() . "]\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}

注释

  • 第 32 行:一个 POST 请求;
  • 第 40-45 行:URL 中的 URL 编码参数;
  • 第 35-39 行:请求正文(body、document)中的 URL 编码参数;

执行后,控制台显示以下结果:

---Réponse avec statut : 200
---Entêtes de la réponse
date: Mon, 03 Jun 2019 12:41:19 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: no-cache, private
content-length: 217
connection: close
content-type: application/json
---Réponse du serveur [{"method":"GET","uri":"\/php7\/scripts-web\/05\/parameters-server.php?prenom2=jean-paul&nom2=de%20la%20h%C3%BBche&age2=45","getParameters":{"prenom2":"jean-paul","nom2":"de la hûche","age2":"45"},"bodyParameters":[]}]
  • 第 10 行:可见服务器并未收到客户端发送的文档中包含的 URL 编码参数。查看客户端发送的 HTTP 请求头,发现其确实发送了一个 44 个字符的文档,但服务器并未处理该文档;

那么,究竟该选择哪种方法向服务器发送信息?

  • [GET URL?param1=val1&param2=val2&…]方法使用了一个经过配置的URL,该链接可作为超链接使用。这是其主要优势:用户可以将此类链接添加到书签中;
  • 在其他应用中,可能不希望在 URL 中显示发送给服务器的参数。例如出于安全考虑。因此将使用 [POST] 方法,并将 URL 编码的参数放入发送给服务器的文档中;

17.10. Web会话管理

在之前的客户端/服务器示例中,其工作原理如下:

  • 客户端向Web服务器的80端口建立连接;
  • 发送文本序列:HTTP 头部、空行、[document];
  • 作为响应,服务器发送同类型的字符串;
  • 服务器关闭与客户端的连接;
  • 客户端关闭与服务器的连接;

如果同一客户端随后向Web服务器发出新请求,客户端与服务器之间将建立新的连接。服务器无法判断连接的客户端是否曾访问过,还是首次请求。在两次连接之间,服务器会“忘记”其客户端。因此,人们称HTTP协议为无状态协议。 然而,服务器记住其客户端是有益的。例如,如果应用程序是安全的,客户端会向服务器发送用户名和密码以进行身份验证。如果服务器在两次连接之间“忘记”了该客户端,那么客户端在每次新连接时都必须重新验证身份,这是不可行的。

为了追踪客户端,服务器采取以下方式:在收到客户端的首次请求时,服务器会在响应中包含一个标识符,客户端随后必须在每次新请求中将该标识符发回给服务器。借助这个针对每个客户端各不相同的标识符,服务器能够识别客户端。随后,服务器可以为该客户端管理一个存储空间,该空间以唯一关联于客户端标识符的形式存在。

技术实现过程如下:

  • 在响应新客户端时,服务器会在响应中包含以下标头:HTTP Set-Cookie:MotClé=标识符。服务器仅在首次请求时执行此操作;
  • 在后续请求中,客户端将通过 HTTP Cookie: MotClé=标识符 头部返回其标识符,以便服务器识别;

人们可能会疑惑,服务器是如何判断当前面对的是新客户端还是已访问过的客户端的。 这是通过客户端请求头中是否包含 HTTP Cookie 来判断的。对于新客户端,该请求头不存在。

某个客户端的所有连接统称为一个会话。

17.10.1. 配置文件 [php.ini]

为确保会话管理在 PHP 中正常运行,需验证其配置是否正确。在 Windows 系统中,其配置文件为 php.ini。 根据运行环境(控制台、Web),需在不同的文件夹中查找配置文件 [php.ini]。要查找这些文件夹,请使用以下脚本:

1
2
3
4
<?php

// 信息 PHP
phpinfo();

第4行,函数phpinfo提供了关于执行该脚本的解释器PHP的信息。它特别指出了所使用的配置文件[php.ini]的路径。

我们之前已在控制台环境中使用过该脚本(参见链接段落)。在 Web 环境中,将得到以下结果:

Image

  • [1-2] 中,文件 [php.ini] 用于配置 Web 脚本解释器。该文件中包含一个 session 部分:
[Session]
session.save_handler = files
session.save_path = "C:/myprograms/laragon-lite/tmp"
session.use_strict_mode = 0
session.use_cookies = 1
session.use_only_cookies = 1
session.name = PHPSESSID
session.auto_start = 0
session.cookie_lifetime = 0
session.cookie_path = /
session.cookie_domain =
session.cookie_httponly =
session.serialize_handler = php
session.gc_probability = 1
session.gc_divisor = 1000
session.gc_maxlifetime = 36000
session.referer_check =
session.cache_limiter = nocache
session.cache_expire = 180
session.use_trans_sid = 0
session.trans_sid_tags = "a=href,area=href,frame=src,form="
session.sid_bits_per_character = 5
  • 第 2 行:客户端会话数据将保存到文件中;
  • 第3行:会话数据保存的文件夹。如果该文件夹不存在,则不会报告任何错误,且会话管理功能无法正常工作;
  • 第4-6:表明会话标识符由HTTP、Set-Cookie和Cookie这三个标头管理
  • 第 7 行:Set-Cookie 标头将采用 Set-Cookie: PHPSESSID=identifiant_de_session 的格式
  • 第 8 行:客户端会话不会自动启动。服务器脚本必须通过 session_start() 指令显式请求;
  • 第 9 行:会话 Cookie 在客户端浏览器未关闭时一直有效;
  • 第 10 行:会话 Cookie 应返回的路径。若为 [session.cookie_path = /xxx],则每当浏览器请求 URL 类型的 [/xxx/yyy/zzz] 时,必须返回该 Cookie。 此处路径 [/] 表示,对于该网站的所有 URL 请求,都必须返回该 Cookie;
  • 第 13 行:某些会话对象必须经过序列化才能存储在文件中。PHP 通过 [serialize / unserialize] 函数负责此序列化/反序列化操作;
  • 第 16 行:超过此生存周期后,存储在备份文件中的会话对象将被视为过期;
  • 第 19 行:会话的生命周期。超过此时间,将创建一个新会话,且前一个会话中保存的对象将被丢失;

17.10.2. 示例 1

17.10.2.1. 服务器

Image

对于 Web 服务而言,会话标识符的管理是透明的。该标识符由 Web 服务器管理。Web 服务可通过 session_start() 语句访问客户端的会话。 此时,Web 服务可通过字典 $_SESSION 在客户端会话中读写数据。若使用库 [HttpFoundation],则可通过表达式 [Request→getSession] 访问会话。

以下代码 [session-server.php] 演示了三个计数器的会话管理。每次收到新请求时,Web 脚本都会递增这些计数器,并将它们存入会话中,以便在下一次请求时能够检索到。


<?php
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use \Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;

//
// 获取请求
$request = Request::createFromGlobals();
// 会话
$session = new Session();
$session->start();
// 从会话中获取三个计数器
if ($session->has("N1")) {
  // 递增计数器 N1
  $session->set("N1", (int) $session->get("N1") + 1);
} else {
  // 计数器 N1 不在会话中 - 创建该计数器
  $session->set("N1", 0);
}
if ($session->has("N2")) {
  // 递增计数器 N2
  $session->set("N2", (int) $session->get("N2") + 1);
} else {
  // 计数器 N2 未处于会话中 - 正在创建
  $session->set("N2", 10);
}
if ($session->has("N3")) {
  // 计数器 N3 递增
  $session->set("N3", (int) $session->get("N3") + 1);
} else {
  // 计数器 N3 未处于会话中 - 正在创建
  $session->set("N3", 100);
}
// 正在生成响应
$response = new Response();
// 响应内容为 UTF-8 文本
$response->headers->set("content-type", "application/json");
$response->setCharset("utf-8");
// 响应将是一个包含三个计数器的数组 jSON
$response->setContent(json_encode([
  "N1" => $session->get("N1"),
  "N2" => $session->get("N2"),
  "N3" => $session->get("N3")]));

// 发送响应
$response->send();
  • 第 10 行:对象 [$request] 封装了 Web 脚本接收到的请求的所有信息;
  • 第12-13行:创建并激活会话。对象[Session]封装了与客户端发送的会话cookie相对应的会话数据。如果客户端未发送此类cookie,则[Session]中不会存储任何数据。 Web脚本将在其首次响应中包含HTTP和[Set-Cookie : PHPSESSID=xxx]这两个标头。 在后续请求中,客户端将发送 HTTP [Cookie : PHPSESSID=xxx] 头部,以指定其希望使用的会话内容。会话即客户端的内存;
  • 第15行:检查会话中是否存在名为[N1]的键。这将作为我们的第一个计数器名称。如果不存在(第20行),则为其赋值0并将其存入会话。如果存在(第23行),则:
    • 从会话中读取该计数器;
    • 将其值加1;
    • 将其重新存入会话;
  • 第22-35行:对另外两个计数器N2和N3执行相同操作;
  • 第36-40行:准备一个类型为[application/json]的响应;
  • 第 42-45 行:响应内容为字符串 jSON,该字符串来自包含三个计数器的数组;
  • 第48行:将响应发送给客户端;

在客户端/服务器关系中,服务器端对客户端会话的管理取决于两个参与方:客户端和服务器:

  • 服务器负责在客户端首次请求时向其发送一个标识符
  • 客户端有责任在每次新请求时返回该标识符。如果未返回,服务器将认为这是新客户端,并为新会话生成一个新的标识符。

结果

我们使用网页浏览器作为客户端。默认情况下(实际上是配置所致),浏览器会将服务器发送的会话标识符如实发回给服务器。随着请求的进行,浏览器将收到服务器发送的三个计数器,并观察到其值逐渐增加。

Image

  • [2] 中,这是对 Web 服务的首次请求;
  • [4] 中,第四次请求显示计数器确实已递增。随着请求的进行,计数器的值确实被保存下来;

使用开发者模式查看服务器与客户端之间交换的 HTTP 请求头。 我们关闭 Firefox 以结束当前与服务器的会话,然后重新打开并启用开发者模式(F12)。这将清除浏览器的当前会话,从而开始一个新的会话。我们请求服务 [session-server.php]

Image

[5] 中,可以看到服务器在响应客户端首次请求时发送的会话标识符。它使用了 HTTP Set-Cookie 头部。

现在,我们通过在网页浏览器中刷新页面(F5)发起一个新的请求:

Image

从上文可以注意到两点:

  • [11] 中,Web 浏览器通过 HTTP Cookie 标头返回了会话标识符。
  • 而在 [12] 中,Web 服务在其响应中不再包含该标识符。现在,客户端需要负责在每次请求中发送该标识符。

17.10.2.2. 客户端

现在,我们编写一个基于前述服务器脚本的客户端脚本。在会话管理方面,它应像网页浏览器一样:

  • 在服务器对其首次请求的响应中,它必须找到服务器发送的会话标识符。它知道该标识符位于 HTTP Set-Cookie 标头中。
  • 在随后的每次请求中,它都必须将收到的标识符发回给服务器。它将通过 HTTP Cookie 标头实现这一点。

Image

客户端 [session-client] 由以下文件配置:jSON [config-session-client.json]

1
2
3
{
    "url": "http://localhost/php7/scripts-web/06/session-server.php"
}

客户端 [session-client] 的代码如下:


<?php

// 会话管理
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-session-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端HTTP
$httpClient = HttpClient::create();
try {
  // 将执行 10 次请求
  for ($i = 0; $i < 10; $i++) {
    // 向服务器发送请求
    if (!isset($sessionCookie)) {
      // 不使用会话
      $response = $httpClient->request('GET', $config['url']);
    } else {
      // 使用会话
      $response = $httpClient->request('GET', $config['url'],
        ["headers" => ["Cookie" => $sessionCookie]]);
    }
    // 响应状态
    $statusCode = $response->getStatusCode();
    print "---Réponse avec statut : $statusCode\n";
    // 获取请求头
    print "---Entêtes de la réponse\n";
    $headers = $response->getHeaders();
    foreach ($headers as $type => $value) {
      print "$type: " . $value[0] . "\n";
    }
    // 若存在,则获取会话cookie
    if (isset($headers["set-cookie"])) {
      // 会话cookie?
      foreach ($headers["set-cookie"] as $cookie) {
        $match = [];
        $match = preg_match("/^PHPSESSID=(.+?);/", $cookie, $champs);
        if ($match) {
          $sessionCookie = "PHPSESSID=" . $champs[1];
        }
      }
    }
  }
  // 显示服务器响应 jSON
  print "---Réponse du serveur : {$response->getContent()}\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}


注释

  • 第 27 行:创建客户端 HTTP;
  • 第30行:向服务器[session-server.php]发送10次相同请求;
  • 第 32 行:变量 [$sessionCookie] 的值将取自客户端接收到的 HTTP [Set-Cookie] 头部;
  • 第32-34行:如果该变量不存在,说明会话尚未开始。发送命令[GET],不带头部[Cookie]
  • 第35-38行:否则,会话已启动,则发送带[Cookie]头部的[GET]命令。该头部的值为[$sessionCookie]
  • 第50行:如果[Set-Cookie]头部包含在接收到的HTTP头部中,则查找会话cookie;
  • 第 52 行:Web 服务器可能会发送多个 [Set-Cookie] 头部。会话 Cookie 只是其中之一。在本例中,它的特殊之处在于其格式为 [PHPSESSID=xxx;]
  • 第53-57行:使用正则表达式查找会话cookie;
  • 第 62 行:在发送完 10 个请求后,显示服务器返回的最后一个响应 jSON;

结果

执行客户端脚本后,NetBeans 控制台将显示以下内容:


"C:\myprograms\laragon-lite\bin\php\php-7.2.11-Win32-VC15-x64\php.exe" "C:\Data\st-2019\dev\php7\poly\scripts-console\clients web\06\session-client.php"
---Réponse avec statut : 200
---Entêtes de la réponse
date: Tue, 04 Jun 2019 13:41:34 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: max-age=0, private, must-revalidate
set-cookie: PHPSESSID=1cerjgsgdlc35e1mkenvtltmh8; path=/
content-length: 25
connection: close
content-type: application/json
---Réponse avec statut : 200
---Entêtes de la réponse
date: Tue, 04 Jun 2019 13:41:34 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: max-age=0, private, must-revalidate
content-length: 25
connection: close
content-type: application/json
---Réponse avec statut : 200
---Entêtes de la réponse
date: Tue, 04 Jun 2019 13:41:34 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: max-age=0, private, must-revalidate
content-length: 25
connection: close
content-type: application/json
---Réponse avec statut : 200
…………………………………………………………
---Réponse avec statut : 200
---Entêtes de la réponse
date: Tue, 04 Jun 2019 13:41:34 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: max-age=0, private, must-revalidate
content-length: 25
connection: close
content-type: application/json
---Réponse du serveur : {"N1":9,"N2":19,"N3":109}
  • 第8行:服务器在首次响应中发送了会话标识符。在随后的响应中,它不再发送该标识符;
  • 第41行:三个计数器[N1, N2, N3]确实已递增了9次。在第1次请求时,它们被清零;

以下示例表明,也可以将数组或对象的值保存到会话中。

17.10.3. 示例 2

17.10.3.1. 服务器

Image

我们将一个 [Personne] 对象放入会话中。该类的定义如下:


<?php

namespace Modèles;

class Personne implements \JsonSerializable {
  // 属性
  private $nom;
  private $prénom;
  private $âge;

  // 将关联数组转换为对象 [Personne]
  public function setFromArray(array $assoc): Personne {
    // 使用关联数组初始化当前对象
    foreach ($assoc as $attribute => $value) {
      $this->$attribute = $value;
    }
    // 结果
    return $this;
  }

  // getter 和 setter
  public function getNom() {
    return $this->nom;
  }

  public function getPrénom() {
    return $this->prénom;
  }

  public function setNom($nom) {
    $this->nom = $nom;
    return $this;
  }

  public function setPrénom($prénom) {
    $this->prénom = $prénom;
    return $this;
  }

  public function getÂge() {
    return $this->âge;
  }

  public function setÂge($âge) {
    $this->âge = $âge;
    return $this;
  }

  // toString
  public function __toString(): string {
    return "Personne [$this->prénom, $this->nom, $this->âge]";
  }

  // 实现接口 JsonSerializable
  public function jsonSerialize(): array {
    // 返回一个关联数组,其键为对象的属性
    // 该数组随后可编码为 jSON
    return get_object_vars($this);
  }

  // 将 jSON 转换为 [Personne] 对象
  public static function jsonUnserialize(string $json): Personne {
    // 根据字符串 jSON 创建一个人员
    return (new Personne())->setFromArray(json_decode($json, true));
  }

}

服务器脚本如下:


<?php

// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;
use \Symfony\Component\HttpFoundation\Session\Session;
require_once __DIR__ . "/Personne.php";
use \Modèles\Personne;

//
// 获取当前请求
$request = Request::createFromGlobals();

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

// 从会话中获取各种数据
// 数组
if ($session->has("tableau")) {
  // 数组位于会话中 - 递增其所有值
  $tableau = $session->get("tableau");
  for ($i = 0; $i < count($tableau); $i++) {
    $tableau[$i] += 1;
  }
  // 将数组放回会话中
  $session->set("tableau", $tableau);
} else {
  // 数组不在会话中 - 创建该数组
  $tableau = [0, 10, 100];
  // 将其放入会话
  $session->set("tableau", $tableau);
}
// 字典
if ($session->has("assoc")) {
  // [assoc] 位于会话中 - 递增其所有元素
  $assoc = $session->get("assoc");
  foreach ($assoc as $key => $value) {
    $assoc[$key] = $value + 1;
  }
  // 将 $assoc 放入会话
  $session->set("assoc", $assoc);
} else {
  // [assoc] 不在会话中 - 创建它
  $assoc = ["un" => 0, "deux" => 10, "trois" => 100];
  // 将 $assoc 放入会话
  $session->set("assoc", $assoc);
}
// Personne 对象
if ($session->has("personne")) {
  // [personne] 已在会话中 - 增加其年龄
  $personne = $session->get("personne");
  $personne->setÂge($personne->getÂge() + 1);
} else {
  // [personne] 不在会话中 - 创建该对象
  $personne = (new Personne())->setFromArray(
    ["prénom" => "Léonard", "nom" => "Hûche", "âge" => 0]);
  // 将 $personne 放入会话
  $session->set("personne", $personne);
}
// 生成响应
$response = new Response();
// 响应内容为 jSON utf-8
$response->headers->set("content-type", "application/json");
$response->setCharset("utf-8");
$response->setContent(json_encode([
  "tableau" => $tableau,
  "assoc" => $assoc,
  "personne" => $personne], JSON_UNESCAPED_UNICODE));

// 发送响应
$response->send();

注释

  • 第 16-17 行:获取当前会话并将其激活;
  • 第21-34行:管理一个已存入会话的数组[tableau]。每次收到新请求时,其元素值均加1;
  • 第36-49行:管理一个已存入会话的关联数组 [assoc]。每次收到新请求时,其元素值均递增1;
  • 第 51-61 行:管理一个已存入会话的 [Personne] 对象。每次收到新请求时,该人的年龄都会增加 1;
  • 第 62-73 行:向客户端发送响应 jSON:来自关联数组的字符串 jSON;

在 NetBeans 中运行此脚本。前两次请求的结果如下(第二次请求在浏览器中显示为 F5):

Image

  • 可以看到,在 [6-8] 中,所有计数器均已递增;

17.10.3.2. 客户端

Image

该客户端与示例 1(链接段落)中的相同。我们仅修改其配置文件 [config-session-client]


{
    "url": "http://localhost/php7/scripts-web/07/session-server.php"
}

执行后产生以下结果:


---Réponse avec statut : 200
---Entêtes de la réponse
date: Tue, 04 Jun 2019 14:25:24 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: max-age=0, private, must-revalidate
set-cookie: PHPSESSID=qbfrj8clr20mod3eriur71mao6; path=/
content-length: 119
connection: close
content-type: application/json
---Réponse avec statut : 200
………….……………………………………………………….
---Réponse avec statut : 200
---Entêtes de la réponse
date: Tue, 04 Jun 2019 14:25:24 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: max-age=0, private, must-revalidate
content-length: 119
connection: close
content-type: application/json
---Réponse du serveur : {"tableau":[9,19,109],"assoc":{"un":9,"deux":19,"trois":109},"personne":{"nom":"Hûche","prénom":"Léonard","âge":9}}
  • [22] 行中,可以看到所有计数器均已递增;

17.11. 身份验证

现在我们关注仅面向特定用户的Web服务。客户端必须先向Web服务进行身份验证,才能获得响应。

17.11.1. 客户端

Image

客户端代码 [auth-client.php] 如下:


<?php

// 会话管理
//
// 错误处理
//ini_set("error_reporting", E_ALL & ~ E_WARNING & ~E_DEPRECATED & ~E_NOTICE);
//ini_set("display_errors", "off");
//
// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;

// 客户端配置
const CONFIG_FILE_NAME = "config-auth-client.json";

// 获取配置
if (!file_exists(CONFIG_FILE_NAME)) {
  print "Le fichier de configuration [" . CONFIG_FILE_NAME . "] n'existe pas\n";
  exit;
}
if (!$config = \json_decode(\file_get_contents(CONFIG_FILE_NAME), true)) {
  print "Erreur lors de l'exploitation du fichier de configuration jSON [" . CONFIG_FILE_NAME . "]\n";
  exit;
}

// 创建客户端HTTP
$httpClient = HttpClient::create([
    'auth_basic' => ['admin', 'admin'],
    // "verify_peer" => false,
    // "verify_host" => false
  ]);


try {
  // 向服务器发送请求
  $response = $httpClient->request('GET', $config['url']);
  // 响应状态
  $statusCode = $response->getStatusCode();
  print "---Réponse avec statut : $statusCode\n";
  // 获取请求头
  print "---Entêtes de la réponse\n";
  $headers = $response->getHeaders();
  foreach ($headers as $type => $value) {
    print "$type: " . $value[0] . "\n";
  }
  // 显示服务器的响应 jSON
  print "---Réponse du serveur : {$response->getContent()}\n";
} catch (TypeError | RuntimeException $ex) {
  // 显示错误
  print "Erreur de communication avec le serveur : " . $ex->getMessage() . "\n";
}

注释

  • 第 27-31 行:向静态方法 [HttpClient::create] 传递了一个参数,即一个关联数组;
  • 第28行:键 [auth_basic] 的值为一个包含两个元素的数组 [user, password]。客户端将使用这些元素向 Web 服务进行身份验证。 键 [auth_basic] 指代一种名为 [Autorization Basic] 的认证类型,该名称源自客户端将发出的标头 HTTP。此外还存在其他认证类型;
  • 除了该代码外,该客户端与之前的客户端完全相同;

为了查看客户端发送的 HTTP 报头,我们将像之前多次做过的那样,将其连接到通用服务器 TCP(即 [RawTcpServer])

Image

我们使用以下 [config-auth-client.json] 配置启动客户端:


{
    "url": "http://localhost:100/php7/scripts-web/08/auth-server.php"
}

随后,服务器 [RawTcpServer] 接收以下数据行:

Image

  • [5] 中,可以看到客户端发送的 [Autorization : Basic XXX] 头部。字符串 XXX 是经过 Base64 编码的 [user:password]

为确认这一点,您可以在 [https://www.base64decode.org/] 网站上对收到的字符串进行解码:

Image

17.11.2. 服务器

Image

服务器 [auth-server.php] 如下:


<?php

// 依赖项
require_once 'C:/myprograms/laragon-lite/www/vendor/autoload.php';
use \Symfony\Component\HttpFoundation\Response;
use \Symfony\Component\HttpFoundation\Request;

// 授权用户
$users = ["admin" => "admin"];
//
// 获取当前请求
$request = Request::createFromGlobals();
// 身份验证
$requestUser = $request->headers->get('php-auth-user');
$requestPassword = $request->headers->get('php-auth-pw');
// 用户是否存在?
$trouvé = array_key_exists($requestUser, $users) && $users[$requestUser] === $requestPassword;
// 准备响应
$response = new Response();
// 设置响应状态码
if (!$trouvé) {
  // 未找到 - 代码 401
  $response->setStatusCode(Response::HTTP_UNAUTHORIZED);
  $response->headers->add(["WWW-Authenticate"=> "Basic realm=".utf8_decode("\"PHP7 par l'exemple\"")]);
} else {
  // 找到 - 状态码 200
  $response->setStatusCode(Response::HTTP_OK);
}
// 响应无内容,仅含头部信息HTTP
$response->send();

注释

  • 第 9 行:授权用户,此处仅有一个用户,登录[admin],密码 [admin]
  • 第14行:用户标识从标头[PHP-AUTH-USER]中获取。这不是客户端发送的标头,而是服务器端的PHP构建的标头;
  • 第 15 行:从标头 [PHP-AUTH-PW] 中获取用户的密码,该标头由 PHP 构建;
  • 第 17 行:在授权用户列表中查找试图登录的用户;
  • 第23-24行:若未识别出该用户,则向客户端发送
    • 第23行:代码[401 Unauthorized]
    • 第24行:发送一个[WWW-Authenticate: Basic realm=”quelque chose”]标头。大多数浏览器都能识别该标头,并会弹出一个认证窗口,提示用户进行身份验证。 HTTP 头信息必须编码为 ISO 8859-1。 NetBeans文本采用UTF-8编码。函数[utf8_decode]负责将UTF-8转换为ISO 8859-1。 在此处,该函数并非必需,因为字符串 [PHP7 par l’exemple] 中的字符在 UTF-8 和 ISO 8859-1 中是相同的。 该函数仅用于提示 HTTP 头部所使用的编码;
  • 第25行:如果用户已被识别,则向客户端发送代码[200 OK]

让我们通过浏览器请求 URL 和 [auth-server.php]

Image

可以看到浏览器弹出了一个身份验证窗口。在 [2] 中,可以看到服务器发送的 [WWW-Authenticate] 标头值。如果查看浏览器接收到的 HTTP 标头,会发现以下内容:

1
2
3
4
5
6
7
8
9
HTTP/1.0 401 Unauthorized
Date: Fri, 07 Jun 2019 09:11:23 GMT
Server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
X-Powered-By: PHP/7.2.11
Cache-Control: no-cache, private
WWW-Authenticate: Basic realm="PHP7 par l'exemple"
Content-Length: 0
Connection: close
Content-Type: text/html; charset=UTF-8
  • 第 1 行:响应的代码 [401 Unauthorized]
  • 第 6 行:HTTP [WWW-Authenticate] 标头;
  • 第7行:响应正文为空;

如果在 [3-4] 中输入两次 [admin],则服务器的响应如下:

1
2
3
4
5
6
7
8
HTTP/1.0 200 OK
Date: Fri, 07 Jun 2019 09:21:00 GMT
Server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
X-Powered-By: PHP/7.2.11
Cache-Control: no-cache, private
Content-Length: 0
Connection: close
Content-Type: text/html; charset=UTF-8
  • 第 1 行:响应状态码 200 OK;
  • 第 6 行:响应正文为空;

如果在 [3-4] 中输入错误的凭据,用于测试的 [Firefox] 浏览器将无限期显示身份验证窗口,直到输入正确的凭据为止。 每次与服务器进行往返通信时,都会收到相同的响应,从而触发浏览器的身份验证窗口。

现在,让我们使用一个未授权的用户来运行客户端 [auth-client.php]。服务器的响应如下:


---Réponse avec statut : 401
---Entêtes de la réponse
Erreur de communication avec le serveur : HTTP/1.0 401 Unauthorized returned for "https://localhost/php7/scripts-web/08/auth-server.php".
  • [1] 中,客户端确实收到了 401 状态码;
  • 而在 [3] 中,客户端抛出了异常。 该异常由 Symfony 客户端 [HttpClient] 触发:当响应状态码 HTTP 表明服务器端发生错误,且客户端尝试读取服务器响应的头部或内容时,它会抛出异常。 第 3 行的信息显示,服务器返回了 [HTTP/1.0 401 Unauthorized] 状态码,表明用户未被识别;

现在,让我们使用授权用户 [‘admin’,’admin’] 运行客户端 [auth-client.php]。此时服务器的响应如下:


---Réponse avec statut : 200
---Entêtes de la réponse
date: Wed, 05 Jun 2019 10:11:02 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: no-cache, private
content-length: 0
connection: close
content-type: text/html; charset=UTF-8
---Réponse du serveur :

  • 第 1 行:服务器响应为 [HTTP/1. 200 OK]
  • 第 7 行:响应内容为空(0 字节);

17.11.3. 保障客户端/服务器连接的安全

我们看到,为了向服务器进行身份验证,客户端发送了以下请求头:

authorization: Basic YWRtaW46YWRtaW4=

如果该行被间谍程序拦截,该程序将能够轻松地从字符串 [YWRtaW46YWRtaW4=] 中解码出以 Base64 编码的凭据 [login, mot de passe]。 因此,身份验证必须在客户端与服务器之间的安全连接中进行。安全的 URL 协议使用 [HTTPS] 协议,而非 HTTP 协议。 [HTTPS]协议即安全客户端/服务器连接中的HTTP协议。安全的URL采用[https://chemin_document]的形式。

并非所有 Web 服务器都支持此格式的 URL。必须对其进行修改才能使其安全。Laragon 的 Apache 服务器是安全的,但 HTTPS 协议默认处于非活动状态。必须在 Laragon 菜单中将其激活:

Image

  • 更改为 [4],需启用 Apache 服务器的 SSL 加密功能;

完成上述操作后,Apache 服务器将自动重启:

Image

  • [1] 中,会出现一个绿色挂锁:这表明 HTTPS 协议已启用;
  • [2] 中,会出现一个新的服务端口,此处为 443 端口。这是安全协议 HTTPS 的服务端口;

现在我们已拥有一个安全服务器,请按以下方式修改客户端的配置文件 [config-auth-client.json]


{
    "url": "https://localhost:443/php7/scripts-web/08/auth-server.php"
}

[2] 中,协议已变为 [https],端口变为 [443]

现在,让我们使用授权用户 [admin, admin] 运行客户端 [auth-client.php]。控制台输出结果如下:

Erreur de communication avec le serveur : Peer certificate cannot be authenticated with given CA certificates for"https://localhost/php7/scripts-web/08/auth-server.php".

Symfony 客户端 [HttpClient] 抛出了异常,因为服务器向其发送的受信任证书未被 [HttpClient] 接受。SSL 的通信使用的是由官方机构认证的受信任证书。 在 Laragon 的 Apache 服务器上启用 HTTPS 协议时,系统为 Apache 服务器生成了一个自签名证书。自签名证书是指未经官方机构验证的证书。Symfony 客户端 [HttpClient] 拒绝了该自签名证书。

可以要求 [HttpClient] 不要验证服务器发送的证书的有效性。这可以通过 [HttpClient::create] 方法中的选项来实现:


// 创建客户端 HTTP
$httpClient = HttpClient::create([
    'auth_basic' => ['admin', 'admin'],
    "verify_peer" => false
  ]);

第 4 行要求不验证服务器的证书。我们在“链接”段落中的 [http-02.php] 脚本中已经遇到过这个问题。 该脚本使用库 [libcurl] 连接到网站 HTTP 和 HTTPS。当时我们为该库使用了以下配置:


// 初始化会话 cURL
  $curl = curl_init($url);
  if ($curl === FALSE) {
    // 发生错误
    return "Erreur lors de l'initialisation de la session cURL pour le site [$site]";
  }
  // curl 选项
  $options = [
    // 详细模式
    CURLOPT_VERBOSE => true,
    // 新连接 - 无缓存
    CURLOPT_FRESH_CONNECT => true,
    // 请求超时(以秒为单位)
    CURLOPT_TIMEOUT => $timeout,
    CURLOPT_CONNECTTIMEOUT => $timeout,
    // 不验证证书有效性SSL
    CURLOPT_SSL_VERIFYPEER => false,
    // 跟随重定向
    CURLOPT_FOLLOWLOCATION => true,
    // 以字符串形式获取请求的文档
    CURLOPT_RETURNTRANSFER => true
  ];

  // curl 配置
curl_setopt_array($curl, $options);

第 17 行,常量 [CURLOPT_SSL_VERIFYPEER] 控制是否验证服务器发送的证书。 当在 PHP 的配置中启用了 [curl] 扩展(如本例所示)时,客户端 [HttpClient] 实际上即为客户端 [curl]。 此时由 [HttpClient::create] 实例化的类即为 [CurlHttpClient][curl] 中的常量在此类中依然可用,但名称有所不同:


$curlopts = [
            CURLOPT_URL => $url,
            CURLOPT_USERAGENT => 'Symfony HttpClient/Curl',
            CURLOPT_TCP_NODELAY => true,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
            CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS => 0 < $options['max_redirects'] ? $options['max_redirects'] : 0,
            CURLOPT_COOKIEFILE => '', // 在重定向过程中跟踪cookie
            CURLOPT_CONNECTTIMEOUT_MS => 1000 * $options['timeout'],
            CURLOPT_PROXY => $options['proxy'],
            CURLOPT_NOPROXY => $options['no_proxy'] ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '',
            CURLOPT_SSL_VERIFYPEER => $options['verify_peer'],
            CURLOPT_SSL_VERIFYHOST => $options['verify_host'] ? 2 : 0,
            CURLOPT_CAINFO => $options['cafile'],
            CURLOPT_CAPATH => $options['capath'],
            CURLOPT_SSL_CIPHER_LIST => $options['ciphers'],
            CURLOPT_SSLCERT => $options['local_cert'],
            CURLOPT_SSLKEY => $options['local_pk'],
            CURLOPT_KEYPASSWD => $options['passphrase'],
            CURLOPT_CERTINFO => $options['capture_peer_cert_chain'],
        ];

我们用黄色高亮标出了 [CurlHttpClient] 所使用的常量。

如果现在使用用户 [admin, admin] 运行客户端 [auth-client],我们将得到以下结果:


---Réponse avec statut : 200
---Entêtes de la réponse
date: Wed, 05 Jun 2019 10:44:37 GMT
server: Apache/2.4.35 (Win64) OpenSSL/1.1.0i PHP/7.2.11
x-powered-by: PHP/7.2.11
cache-control: no-cache, private
content-length: 0
connection: close
content-type: text/html; charset=UTF-8
---Réponse du serveur :

用户已成功识别。如果我们使用除 [admin, admin] 以外的用户来运行 [auth-client] 客户端,将得到以下结果:

1
2
3
---Réponse avec statut : 403
---Entêtes de la réponse
Erreur de communication avec le serveur : HTTP/1.0 403 Forbidden returned for "https://localhost/php7/scripts-web/08/auth-server.php".

现在,我们已经掌握了如何在安全服务器上进行身份验证。