Skip to content

7. 案例研究:网络产品数据库管理

本案例研究的代码可在此处获取 |ICI|。

目标

  • 编写一个用于管理文章数据库的类
  • 编写一个基于该类的 Web 应用程序
  • 介绍样式表
  • 提出简单Web应用程序开发方法论的初步框架
  • 在客户端浏览器中引入JavaScript

致谢:本案例研究的核心内容摘自让-菲利普·勒布夫(Jean-Philippe Leboeuf)所著、Eyrolles出版社出版的《程序员笔记——PHP/MySQL》一书。

7.1. 引言

一位商户希望管理其店铺销售的商品。他家中已有能完成此任务的 ACCESS 应用程序,但对网络开发充满好奇。他在某互联网服务提供商处拥有账户,该服务商允许客户在其个人文件夹中安装 PHP 脚本。 这使他们能够创建动态网站。此外,这些客户还拥有一个 MySQL 账户,可用于创建数据表,为他们的 PHP 脚本提供数据支持。 因此,该商户拥有一个用户名为 admarticles、密码为 mdparticles 的 MySQL 账户。他拥有一个名为 dbarticles 的数据库,并对该数据库拥有完全控制权。这样,我们的商户就具备了将商品管理系统部署到网上的全部条件。在您这位具备网页开发技能的帮助下,他开始了这一探索之旅。

7.2. 数据库

该商户设计了以下网页首页界面草图:

Image

用户将分为两类:

  • 管理员:可在商品表中执行所有操作(添加、修改、删除、查询等)。他们可以使用上述菜单中的所有选项。特别是,他们可以通过选项 [Requête SQL] 发出任何 SQL 查询。
  • 普通用户(非管理员),其权限受限:包括添加、修改、删除和查看权限。他们可能仅拥有其中部分权限,例如仅具备查看权限。

由于数据库中存在不同类型的用户且权限各异,因此需要进行身份验证。这就是为什么登录页面会首先进行身份验证。 为了区分用户身份及各自的操作权限,将使用两张表:USERS 和 DROITS。表 USERS 的结构如下:

login
用户登录名,用于唯一标识用户。该字段是表的主键。
mdp
用户的明文密码
admin 
若用户为管理员,则为字符 'y'(是);否则为字符 'n'(否)。

该表的内容可能如下:

Image

表 DROITS 规定了表 USERS 中非管理员用户的权限。其结构如下:

login
用户登录名,用于唯一标识该用户。
该字段是表 DROITS 的外键,并引用
表 USERS 的 login 列。
table
用户拥有权限的表名。
ajouter
如果用户对该表具有添加权限,则为字符 'y'(是),
否则为字符 'n'(否)。
modifier
修改权限:'y' 或 'n'
supprimer
删除权限:'y' 或 'n'
consulter
查看权限:'y' 或 'n'

表的内容可能如下:

Image

备注

  • 位于表 USERS 中但不在表 DROITS 中的用户 U 没有任何权限。
  • 在本例中,用户仅能访问 ARTICLES 这一张表。但我们的商家未雨绸缪,已在 DROITS 表的结构中添加了 table 字段,以便日后能向应用程序添加新表。
  • 既然我们假设将使用 MySQL 数据库(该数据库本身能够(且比我们更擅长)在其自己的表中管理这些权限),为何还要在自己的表中管理权限呢? 原因很简单:我们的商家对 MySQL 数据库没有管理权限,无法创建用户并授予其权限。 我们不要忘记,MySQL数据库托管在一家网络服务商处,而商家只是该数据库的普通用户,没有任何管理权限(幸好如此)。 但他拥有名为 dbarticles 的数据库的所有权限,目前他使用用户名 admarticles 和密码 mdparticles 访问该数据库。该应用程序的所有表都位于此数据库中。

ARTICLES 表汇总了商家销售的商品信息。其结构如下:

code
商品代码 - 表的主键
- 恰好4个字符
nom
商品名称
prix
价格
stockActuel
当前库存量
stockMinimum
低于该水平时,
补货订单

其内容最初作为测试可能如下:

Image

7.3. 项目的限制

商家在此将本地应用程序 ACCESS 迁移为 Web 应用程序。他尚不清楚该应用程序的未来走向及发展路径。但他希望新应用程序易于使用且具备可扩展性。正因如此,其 IT 顾问在设计表结构时设想了以下方案:

  • 具有不同权限的各类用户:这将使商家能够将某些任务委派给他人,同时又不授予其管理权限
  • 未来可能新增除ARTICLES表以外的其他表

同一位顾问还提出了其他建议:

  • 他深知在软件开发中,必须明确区分展示层与处理层。Web 应用程序的架构通常如下:

此处的用户界面是一个网页浏览器,但也可能是一个独立应用程序,该应用程序通过网络向Web服务发送请求HTTP,并格式化Web服务返回的结果。 应用逻辑由处理用户请求的脚本构成,此处为 PHP 脚本。 数据源通常是数据库,但也可能是目录 LDAP 或远程 Web 服务。开发人员应确保这三个实体之间保持高度独立,以便其中一个发生变化时,另外两个无需或只需少量调整。因此,商户的 IT 顾问提出了以下建议:

  • 我们将应用程序的业务逻辑放入一个名为 PHP 的类中。因此,上述 [Logique applicative] 模块将由以下元素组成:

在[Logique Applicative]模块中,我们可以区分出

  • [IE=Interface d'Entrée]模块,它是应用程序的入口。无论客户端类型如何,该入口均保持一致。
  • [Classes métier]模块,该模块汇集了应用程序逻辑所需的类。这些类与客户端无关。
  • 响应页面生成器模块 [IS1 IS2 ... IS=Interface de Sortie]。 每个生成器负责为特定类型的客户端格式化应用逻辑提供的结果:HTML 代码用于浏览器或手机 WAP,XML 代码用于独立应用程序,等等。

该模型确保了与客户端的高度独立性。无论客户端发生变更,还是需要调整结果的呈现方式,只需创建或调整相应的输出生成器(如 [IS])。

  • 在Web应用程序中,可通过使用样式表来增强展示层与处理层之间的独立性。样式表控制网页在浏览器中的呈现效果。要更改这种呈现效果,只需修改相应的样式表即可,无需触及处理逻辑。因此,这里将使用样式表。
  • 在上图中,业务类将作为与数据源的接口。假设此处的数据源为 MySQL 数据库。 为了便于将来迁移至其他数据库,我们将使用PEAR库,该库提供的数据库访问类与数据库的实际类型无关。 因此,如果我们的商家发展壮大到足以在企业内部部署微软的IIS Web服务器,他就可以将MySQL数据库替换为SQL Server,而无需(或只需极少)修改业务类。

7.4. 商品类

商品类可以定义如下:

<?php

     // 商品类,基于由以下表组成的商品数据库
     // 商品:(代码、名称、价格、stockActuel、stockMinimum)
     // 用户:(登录名、密码、管理员)
     // 权限:(登录名, 表, 添加, 修改, 删除, 查询)

     // 这是该班级的用户,需提供登录名/密码以执行数据库中的所有操作
     // 因此他已拥有数据库的所有权限。这意味着无需采取
     // 在此处采取特殊的安全预防措施

     // 库
  require_once 'DB.php';

  class articles{

           // 属性
    var $sDSN;                        // 连接字符串
          var $sDatabase;            // 数据库名称
    var $oDB;                        // 连接数据库
    var $aErreurs;                // 错误列表
    var $oRésultats;            // SELECT 查询结果
        var $connecté;                // 布尔值,表示是否已连接数据库
        var $sQuery;                    // 最近执行的查询
    var $sUser;                    // 连接用户的身份
    var $bAdmin;                    // 若用户为管理员,则为真
    var $dDroits;                // 其权限字典 table ->> array(查看,添加,删除,修改)

     // 构造函数
    function articles($dDSN,$sUser,$sMdp){

             // $dDSN:定义待建立连接的字典
       // $dDSN['sgbd']:需要连接的 SGBD 类型
       // $dDSN['host']:托管该连接的主机名称      
       // $dDSN['database']:需要连接的数据库名称      
       // $dDSN['admin']:要连接的数据库所有者的登录名
       // $dDSN['mdpadmin']:其密码
       // $sUser:希望使用文章数据库的用户登录名
       // $sMdp:其密码

       // 在 $oDB 中建立连接,连接到由 $dDSN 定义的数据库,用户身份为 $dDSN['admin']
       // 如果连接成功且用户 $sUser 通过身份验证  
           // 将用户 $sUser 的权限加载到 $bAdmin 和 $dDroits 中
           // 将数据库连接字符串写入 $sDSN
           // 将要连接的数据库名称设置为 $sDataBase
         // 将 $connecté 设为真
       // 若连接失败或用户 $sUser 未正确认证
           // 将相应的错误消息放入列表 $aErreurs
         // 如有必要,关闭连接
         // 将 $connecté 设为 false 

  ...
    }//生成器

    // ------------------------------------------------------------------
    function connect(){
             // (重新)连接到数据库
...
    }//连接

    // ------------------------------------------------------------------
    function disconnect(){
      // 关闭与服务器的连接$sDSN
...
    }//断开连接

    // -------------------------------------------------------------------
    function execute($sQuery,$bAdmin){
            // $sQuery:待执行的请求
       // $bAdmin:若要求以管理员身份执行,则为真
...
    }//执行

    // --------------------------------------------------------------------------
    function addArticle($dArticle){
         // 添加商品 $dArticle(代码、名称、 价格、stockActuel、stockMinimum) 至商品表
   ...
    }//添加          

    // ----------------------------------------------------------------------
    function modifyArticle($dArticle){
             // 修改商品 $dArticle(代码、名称、 价格、stockActuel、stockMinimum)
...
    }//update

    // ----------------------------------------------------------------------
    function deleteArticle($sCode){
             // 从商品表中删除一条商品
       // 其代码为 $sCode
...
    }//删除

    // ----------------------------------------------------------------------
    function vérifierArticle(&$dArticle){
         // 验证商品的有效性 $dArticle(代码、名称、 价格、stockActuel、stockMinimum)
...
    }//验证

    // --------------------------------------------------------------------------
    function selectArticles($dQuery){
             // 执行对商品表的SELECT查询
       // 该查询包含三个部分
       // $dQuery 中的列列表['colonnes']
       // 在 $dQuery 中进行筛选['where']
       // $dQuery 中的显示顺序['orderby']
...
    }//selectArticles            

        // --------------------------------
    function existeArticle($sCode){
         // 如果商品表中存在代码条目 $sCode,则返回 TRUE
...
    }//existeArticle

    // --------------------------------------
    function existeUser($sUser,$sMdp){
             // 检查用户 $sUser 是否存在,其密码为 $sMdp
       // 返回 (int $iErreur, string $sAdmin, hashtable $dDroits)
       // 若数据库操作出现任何错误,则 $iErreur = -1 ——此时将填充列表 $aErreurs
       // 若未找到用户(不存在或密码错误),则 $iErreur = 1
       // 若用户存在但在权限表中无任何权限,则 $iErreur = 2
       // $iErreur = 3(若用户存在且为管理员)
       // $iErreur = 0,如果用户存在且不是管理员
       // $sAdmin="y" 若用户存在且为管理员($iErreur==3),否则等于空字符串
       // $dDroits 是用户权限字典,若该用户非管理员($iErreur==0)
       // 否则为空数组
       // 字典的键是用户拥有权限的表
       // 该表对应的值本身是一个字典,其键为权限
       // (查看、添加、修改、删除),其值为字符串 'y'(是)或 'n'(否),视具体情况而定
...
    }//existeUser

    // --------------------------------------
    function getCodes(){
         // 生成代码表
....
    }//getCodes    

  }//分类
?>      

评论

  • “articles”类使用库PEAR::DB来访问数据库,因此会执行以下命令
require_once 'DB.php';

此引用假设脚本 DB.php 位于 PHP 配置文件中 include_path 选项所指定的某个目录下。

  • 生成器需要知道连接的是哪个数据库以及使用何种身份。这些信息由字典 $dDSN 提供。需要提醒的是,最初的假设是数据库名为 dbarticles,且属于名为 admarticles 的用户,其密码为 mdparticles。 此外,需注意该应用程序支持具有不同权限的多用户。此处存在一个需要澄清的歧义。连接确实是以 admarticles 的身份建立的,最终所有针对 dbarticles 数据库的操作都将以此身份进行,因为这是 SGBD 和 MySQL 所知晓的唯一名称,而它们拥有管理 dbarticles 数据库的充分权限。 为了“模拟”不同用户的存在,我们将让用户 admarticles 以另一用户的权限进行操作,该用户的登录名($sUser)和密码($sMdp)作为参数传递给构造函数。 因此,在对商品数据库执行操作之前,需验证用户($sUser$sMdp)是否具备相应权限。若具备,则由用户admarticles代其执行操作。
  • 商品数据库管理员的登录名和密码必须作为参数传递给构造函数。这是一项合理的预防措施。 如果将这两项信息“硬编码”到类代码中,该类的任何用户都可能轻易冒充商品数据库管理员。事实上,PHP类并未受到保护。 此外,该类中的 $bAdmin 属性(用于指示当前操作的用户($sUser$sMdp)是否为管理员)完全可能被外部直接设置,如下例所示:
$oArticles=new articles($dDSN,$sUser,$sMdp)
// 在此处,$sUser 已被识别为数据库的非管理员用户
$oArticle->bAdmin=TRUE;
// 现在 $sUser 已成为管理员

PHP 并非 JAVA 或 C# 而类 PHP 仅仅是一种比字典稍复杂的数据结构,但它并不具备真正类的安全性——在真正的类中,属性 bAdmin 会被声明为私有或受保护,从而无法从外部对其进行修改。 由于该类的用户必须知道商品数据库管理员的登录名和密码,因此只有管理员才能使用该类。因此,前面的操作对他来说已毫无意义。该类仅是为了给他提供开发便利而存在的。 一个重要后果是无需采取安全防范措施。再次强调,使用类 articles 的用户必然是商品数据库的管理员。

  • 该类通过将错误消息填入属性 $aErreurs 的方式,以统一方式处理数据库连接错误或其他任何错误。因此,在每次操作后,该类的用户都必须检查此列表。
  • 方法 addArticleupdateArticledeleteArticleselectArticles 以及 execute 直接源自前文介绍的 Web 界面原型。它们实际上对应于所提供菜单中的选项。 方法 addArticle modifyArticle 基于方法 vérifierArticle,用于验证即将添加或修改的文章数据是否正确。 同样地,方法 existeArticle 用于验证是否即将添加已存在的商品。 如果使用的是以代码作为主键的文章表,则可以省略此方法。此时,SGBD 自身会因重复项而报告添加失败。它可能会通过一条难以理解的英文错误信息来提示。
  • 待修改或删除的商品将通过其唯一的代码进行标识。方法 getCodes 可用于获取所有这些代码。
  • disconnect方法用于关闭与数据库的连接,该连接是在对象创建时建立的。在此处,connect方法(用于重新建立与数据库的连接)似乎没有实际意义。这使得我们可以使用同一个对象随心所欲地打开和关闭该连接。其价值仅在与Web应用程序结合使用时显现。该应用程序将创建一个articles对象,并将其存储在会话中。 虽然该会话能在后续的客户端-服务器交互过程中保留对象的大部分属性,但无法保留代表已建立连接的属性。因此,每次新的客户端-服务器交互时,都必须重新建立连接。 我们将请求建立持久连接,以便将已建立的连接存储在连接池中并保持永久打开状态。这样,当脚本请求新连接时,该连接将从连接池中获取。因此,其效果与会话能够记住已建立连接的情况相同。
  • 方法 existeUser 可让系统管理员确认是否存在用户 $sUser(其密码为 $sMdp)。 如果存在,该方法可判断其是否为管理员(该信息存储在表 USERS 中),并将此信息存储在属性 $bAdmin 中。 如果该用户不是管理员,该方法将从表 DROITS 中检索其权限,并将其放入属性 $dDroits 中,该属性是一个双索引字典: 若用户 $dDroits 拥有 [$table] 权限,且 [$droit] 权限为 'y',否则为 'n'。

编写 articles 类。数据库访问将通过 PEAR::DB 库实现,该库允许忽略数据库的具体类型。

7.5. WEB 应用程序的结构

现在我们已经有了管理商品数据库的“业务”类,可以在不同的环境中使用它。这里建议将其用于一个Web应用程序。让我们通过以下各个页面来了解该应用程序:

7.5.1. 应用程序的主页

让我们回顾一下之前介绍过的首页:

1234

Image

应用程序的所有页面都将采用上述结构,即一个包含四部分的两行三列表格:

  • 区域1构成表格的第一行。该区域专用于标题,并可选配图片。此行中的三列在此处合并。
  • 第二行包含三个区域,每列一个区域:
    • 区域2包含菜单选项。该区域内又包含一个单列多行的表格,菜单选项被放置在表格的各行中。
    • 区域3为空,仅用于分隔区域2和区域4。实现这种分隔也可以采用其他方式。
    • 区域4包含页面的动态部分。只有这一部分会随着操作的不同而变化,其余部分保持不变。

生成此模板页面的脚本PHP将命名为main.php,其内容可能如下:


<html>
  <head>
      <title>Gestion d'articles</title>
      <link type="text/css" href="<?php echo $dConfig['urlPageStyle'] ?>" rel="stylesheet" />
   </head>
  <body background="<?php echo $dConfig['urlBackGround'] ?>">
    <table>
      <tr height="60">
        <td colspan="3" align="left" valign="top" >
          <h1><?php echo $main["title"] ?></h1>
        </td>
      </tr>
      <tr>
        <td>
          <table>
            <tr>
              <td class="menutitle" >
                                    <a href="<?php echo  $main["liens"]["login"] ?>" ?>Authentification</a>
              </td>
            </tr>
            <tr>
                <td><br /></td>
            </tr>
            <tr>
              <td class="menutitle" >
                Utilisation
              </td>
            </tr>
            <tr height="10"></tr>
            <tr>
              <td class="menublock" >
                <img alt="-" src="../images/radio.gif" />
                <a href="<?php echo  $main["liens"]["addArticle"] ?>" ?>
                  Ajouter un article
                </a>
                 </td>
            </tr>
            <tr>
              <td class="menublock" >                    
                <img alt="-" src="../images/radio.gif" />
                <a href="<?php echo $main["liens"]["updateArticle"] ?>">
                  Modifier un article
                </a>
                    </td>
            </tr>
              <td class="menublock" >                
                <img alt="-" src="../images/radio.gif" />
                <a href="<?php echo $main["liens"]["deleteArticle"] ?>">
                  Supprimer un article
                </a>
              </td>
            </tr>
            <tr>
              <td class="menublock" >
                <img alt="-" src="../images/radio.gif" />
                <a href="<?php echo $main["liens"]["selectArticle"] ?>">
                  Lister des articles
                </a>
              </td>
            </tr>
            <tr>
                <td><br /></td>
            </tr>                
            <tr>
              <td class="menutitle" >
                Administration
              </td>
            </tr>
            <tr height="10"></tr>
            <tr>
              <td class="menublock" >                
                <img alt="-" src="../images/radio.gif" />
                <a href="<?php echo $main["liens"]["sql"] ?>" >
                  Requête SQL
                </a>
              </td>
            </tr>
          </table>
        </td>
            <td>  
            <img alt="/" src="../images/pix.gif" width="10" height="1" />
            </td>
            <td>
            <fieldset>
              <legend><?php echo $main["légende"] ?></legend>
            <?php
                include $main["contenu"];
            ?>
          </fieldset>
        </td>
      </tr>
    </table>
  </body>
</html>

页面中的参数化区域已在上述列表中突出显示。该模板页面通过多种方式进行参数化:

  • 通过一个名为 $main 的字典,该字典包含以下键:
    • title:要放入页面区域 1 的标题
    • liens:用于生成菜单列中链接的字典。这些链接与区域2菜单的选项相关联
    • contenu:要在区域4中显示的页面URL
  • 通过字典 $dConfig,该字典汇总了来自名为 config.php 的应用程序配置文件中的信息
  • 通过页面所用样式表中的类:
      <link type="text/css" href="<?php echo $dConfig['urlPageStyle'] ?>" rel="stylesheet" />

该页面在此处使用了以下样式类:

  • menutitle:用于菜单的主选项
  • menublock:用于菜单的次要选项

更改其中一个参数会改变页面的外观。因此,将 $main 更改为 ['title'] 将更改区域 1 的标题。

7.5.2. 客户请求的标准处理流程

客户通过标准页面区域 2 中的链接与应用程序进行交互。这些链接的类型如下:

apparticles.php?action=xx&phase=y&PHPSESSID=zzzzzzzzzzzz
action
表示当前正在执行的操作,具体包括:
authentifier
用户身份验证
selectArticles
商品选择(浏览)
updateArticle
修改商品
deleteArticle
删除商品
sql
发送任意请求 SQL(管理员)
phase
某项操作可能分多个步骤进行——指当前步骤
PHPSESSID
会话开始时的会话令牌——允许服务器检索先前交互中存储在会话中的信息

同样,表单中的 action 属性也将采用相同的形式。例如,在首页的第 4 区域有一个登录表单。该表单的 HTML 标签定义如下:

<form name="frmLogin" method="post" action="apparticles.php?action=authentifier&phase=1">

客户端请求的处理由名为 apparticles.php 的应用程序主脚本完成。其作用是构建返回给客户端的响应。该脚本始终按照以下方式进行处理:

  • 根据操作名称和当前阶段,它把请求转发给一个专门的函数。该函数将处理请求并生成相应的响应页面。对于每个客户端请求,可能存在多个响应页面:page1page2、……、pagen。这些页面包含需要由该函数计算生成的信息。 因此,这些是参数化页面。它们将由脚本 page1.phppage2.php、……、pagen.php 生成。
  • 为保持一致性,需在模板第4区显示的页面中可变部分也将被放入字典$main中。

假设服务器需要响应请求向客户端发送页面 pagex.php,其操作流程如下:

  • 将页面 pagex.php 所需的值放入字典 $main
  • $main 写入 ['contenu'](该字段指代待显示页面的 URL,位于模板页面的第 4 区域), URL 来自 pagex.php
  • 它将通过以下指令请求显示模板页面
include "main.php";

此时,模板页面将显示出来,其第4区包含脚本代码pagex.php,该代码将被解析以生成第4区的内容。需注意,该区域仅是一个表格中的普通单元格。 因此,由 pagex.php 生成的 HTML 代码不能以 <HTML>、<HEAD>、 <BODY>等标签开头。这些标签已在模板页开头生成。例如,生成首页第4区域的login.php脚本可能如下所示:


<form name="frmLogin" method="post" action="<?php echo $main["post"] ?>">
    <table>
        <tr>
            <td>login</td>
            <td><input type="text" value="<?php echo $main["login"] ?>" name="txtLogin" class="text"></td>
        </tr>
        <tr>
            <td>mot de passe</td>
            <td><input type="password" value="" name="txtMdp" class="text"></td>
      <td><input type="submit" value="Connexion" class="submit"></td>      
        </tr>
    </table>
</form>

可以看出,该页面:

  • 被简化为一个表单
  • 其样式既由字典$main,也由样式表共同设定。

7.5.3. 配置文件

我们始终应尽可能对应用程序进行配置,以避免仅仅因为决定更改脚本或图片的路径等原因而不得不修改代码。因此,主应用程序 apparticles.php 在启动时将加载配置文件 config.php

     // 正在加载配置文件
  include "config.php";

该文件中将包含针对 PHP 的配置指令以及全局变量的初始化:

<?php

     // PHP配置
  ini_set("register_globals","off");
  ini_set("display_errors","off");
  ini_set("expose_php","off");
    ini_set("session.use_cookies","0");    // 无Cookie

     // 商品基础配置
    $dConfig["DSN"]=array(
        "sgbd"=>"mysql",
        "admin"=>"admarticles",
        "mdpadmin"=>"mdparticles",
        "host"=>"localhost",
        "database"=>"dbarticles"
    );

   // 页面网址
    $dConfig['urlBackGround']="../images/standard.jpg";  
  $dConfig["urlPageStyle"]="mystyle.css";  
  $dConfig["urlAppArticles"]="apparticles.php";
  $dConfig["urlPageMain"]="main.php";
  $dConfig["urlPageLogin"]="login.php";
  $dConfig["urlPageErreurs"]="erreurs.php";
  $dConfig["urlPageInfos"]="infos.php";
  $dConfig["urlPageAddArticle"]="addarticle.php";
  $dConfig["urlPageUpdateArticle1"]="updatearticle1.php";
  $dConfig["urlPageUpdateArticle2"]="updatearticle2.php";
  $dConfig["urlPageDeleteArticle1"]="deletearticle1.php";
  $dConfig["urlPageDeleteArticle2"]="deletearticle2.php";
  $dConfig["urlPageSelectArticle1"]="selectarticle1.php";
  $dConfig["urlPageSelectArticle2"]="selectarticle2.php";
  $dConfig["urlPageSQL1"]="sql1.php";
  $dConfig["urlPageSQL2"]="sql2.php";
  $dConfig["urlPageSQL3"]="sql3.php";

   // 主页链接
  $main["liens"]["login"]="$sUrlAppArticles?action=authentifier&phase=0";  
  $main["liens"]["addArticle"]="$sUrlAppArticles?action=addArticle&phase=0";
  $main["liens"]["updateArticle"]="$sUrlAppArticles?action=updateArticle&phase=0";
  $main["liens"]["deleteArticle"]="$sUrlAppArticles?action=deleteArticle&phase=0";
  $main["liens"]["selectArticle"]="$sUrlAppArticles?action=selectArticle&phase=0";
  $main["liens"]["sql"]="$sUrlAppArticles?action=sql&phase=0";

   // 在配置中保存 $main
  $dConfig["main"]=$main;    
?>

7.5.4. 与模板页面关联的样式表

我们已经看到,服务器的响应具有独特的格式,即 main.php。大家可能已经注意到,该脚本生成的页面是原始的,没有任何呈现效果。这有几个好处:

  • 开发者无需担心所创建页面的图形呈现。事实上,他们未必具备制作吸引人的图形页面的技能。这样他们就可以完全专注于代码本身。
  • 脚本的维护变得更加容易。如果脚本中包含样式属性,那么代码结构和样式结构都将难以清晰区分。页面的视觉设计通常由平面设计师负责。设计师通常不愿在自己无法理解的脚本中费力寻找需要修改的样式属性。

然而,我们必须重视网页的视觉效果。毕竟,这正是吸引网民访问网站的关键。在此,版式设计交由样式表负责。页面 main.php 在其代码中指定了用于显示该页面的样式表:

  <head>
      <title>Gestion d'articles</title>
      <link type="text/css" href="<?php echo $dConfig['urlPageStyle'] ?>" rel="stylesheet" />
   </head>

本文档使用的样式表如下:

BODY {
    background : url(../images/standard.jpg);
    border : 2px none #FFDAB9;
    font-family : Garamond;
    font-size : 16px;
    margin-left : 0px;
    padding-left : 20px;
}

INPUT {
    background : #EEE8AA;
    border : 1px solid #EE82EE;
    font-family : Garamond;
    font-size : 18px;
}

INPUT.submit{
    font-family : "Times New Roman";
    font-size : 16px;
    background : #FA8072;
    border : 2px double Green;
    font-weight : bold;
    text-align : center;
    vertical-align : middle;
    cursor : pointer;
}

TD.menutitle{
    background-image : url(../images/menugelgd.gif);
    height : 23px;
    text-align : center;
    vertical-align : middle;
    background : url(../images/menugelgd.gif) no-repeat center;
}

TD.menublock{
    background : url(../images/bandegrismenugd.gif) repeat-x;
    text-align : left;
    vertical-align : middle;
}

A {
    font-family : "Comic Sans MS";
    color : #FF7F50;
    font-size : 15px;
    text-decoration : none;
}

A:HOVER {
    background : #FFA07A;
    color : Red;
}

FIELDSET {
    border : 1px solid #A0522D;
    background : #FFE4C4;
    margin : 10px 10px 10px 10px;
    padding-left : 10px;
    padding-right : 10px;
    padding-bottom : 10px;
}

LEGEND{
    background : #FFA500;
}

TH {
    background : #228B22;
    text-align : center;
    vertical-align : middle;
}

TD.libellé{
    border : 1px solid #008B8B;
    color : #339966;
}

H1 {
    font : bold 20px/30px Garamond;
    color : #FF7F50;
    background : #D1E1F8;
    background-attachment : fixed;
    text-align : center;
    vertical-align : middle;
    font-family : Garamond;
}

SELECT.TEXT {
    background : #6495ED;
    text-align : center;
    color : Aqua;
}

我们不会深入探讨此样式表的细节。我们将直接采用它。稍后我们将了解如何构建和修改它。目前已有相关软件可用于此目的。不过,我们先说明一下样式表中使用的呈现属性所起的作用:

属性:
控制标签 HTML 的呈现:
BODY
<BODY>
H1
<H1> (标题1)
A
<A> (锚点)
A:HOVER
设定用户将鼠标悬停在锚点上时的显示属性
FIELDSET
<FIELDSET> - 并非所有浏览器都支持此标签
LEGEND
<LEGEND> - 并非所有浏览器都支持此标签
INPUT
<INPUT>
INPUT.TEXT
<INPUT class="TEXT">
INPUT.SUBMIT
<INPUT class="SUBMIT">
TH
<TH> (表头)
TD.menutitle
<TD class="menutitle"> (表数据)
TD.menublock
<TD class="menublock">
TD.libellé
<TD class="label">

让我们通过一个示例来看看这些排版规则是如何编写的。 在此示例中,我们将使用可在http://www.bradsoft.com免费获取的TopStyle Lite软件。加载样式表后,会出现一个包含三个区域的窗口:

  1. 一个文本编辑区。只要了解遵循名为CSS(层叠样式表)标准的样式表编写规则,就可以手动定义样式属性。
  2. 区域2显示当前正在构建的属性的可编辑属性。这是最简单的方法。它避免了必须知道数量众多的样式属性的确切名称
  3. 区域3显示了当前正在构建的属性的视觉效果

在上述区域 1 中,将属性 INPUT.submit 复制并粘贴到属性 INPUT.fantaisie 上。 该属性将确定标签 HTML 的呈现样式<INPUT class="fantaisie">

让我们利用区域 2 来修改 INPUT.fantaisie 属性的某些属性:

现在,在关联了上述样式表的页面中,所有 <INPUT ... class="fantaisie"> 标签都将呈现为上文区域 3 中的示例样式。

样式表具有巨大的优势。通过使用样式表,只需修改一个地方——即样式表本身,即可改变Web应用程序的外观。旧版浏览器无法识别样式表。以下<link ..>指令将被某些旧版浏览器忽略:

  <head>
      <title>Gestion d'articles</title>
      <link type="text/css" href="<?php echo $dConfig['urlPageStyle'] ?>" rel="stylesheet" />
   </head>

在我们的应用程序中,这将生成以下主页:

Image

这只是一个没有图形元素的简易页面。情况可能更糟。某些浏览器的版本虽然能识别样式表,但会错误地解释它们。这可能会导致页面显示畸变且无法使用。因此,客户端浏览器的类型问题就提上了议程。虽然存在一些有助于确定客户端浏览器类型的技术,但它们并非完全可靠。 因此,我们可以为不同的浏览器编写不同的样式表,甚至为那些忽略样式表的浏览器编写一个不使用样式表的版本。当然,这会增加开发工作量。本文中忽略了这一重要问题。

借助样式表,我们可以为用户提供个性化的应用环境。我们可以向用户展示一个页面,其中提供多种可选的样式。用户可以选择最适合自己的样式。该选择可以保存在数据库中。当用户再次登录时,应用程序便会使用用户偏好的样式表启动。

7.5.5. 应用程序的输入模块

客户仅会接触到应用程序的入口模块:apparticles.php。其工作原理概括如下:

  • 接收并分析客户端请求。该请求可能已配置参数,也可能未配置。 若请求包含参数,则预期参数如下:action=[action]&phase=[phase]&PHPSESSID=[PHPSESSID]
  • 如果请求未包含参数,或者获取的参数与预期不符,服务器将返回身份验证页面(用户名、密码)。一旦用户正确登录,系统将创建一个会话。该会话将用于在整个客户端-服务器交互过程中存储信息。
  • 如果请求被正确识别,则由一个模块进行处理,该模块取决于当前的操作和阶段。
  • 所有数据库访问均通过业务类 articles.php 进行。
  • 请求的处理始终以向客户端发送页面 main.php 结束,其中已在 $main['contenu'] 中指定了URL,该页面将置于模板页面的第4区域。

脚本 apparticles.php 的框架可能如下:

<?php
     // 物料表管理
  include "config.php";
  include "articles.php";  

  // 待执行操作
  $sAction=$_POST["action"] ? $_POST["action"] : $_GET["action"] ? $_GET["action"] : "authentifier";
  $sAction=strtolower($sAction);
   // 可能阶段
  $sPhase=$_POST["phase"] ? $_POST["phase"] : $_GET["phase"] ? $_GET["phase"] : "0";

  // 会话
  session_start();
  $dSession=$_SESSION["session"];

     // 是否有会话正在进行?
  if(! isset($dSession)){
      // 用户身份验证
    if($sAction=="authentifier" && $sPhase=="0") authentifier_0($dConfig);
    if($sAction=="authentifier" && $sPhase=="1") authentifier_1($dConfig);
    if($sAction=="authentifier" && $sPhase=="2") authentifier_2($dConfig);
     // 请求错误
    authentifier_0($dConfig);        
  }//if - 无会话

     // 获取会话
  $dSession=unserialize($dSession);

     // 处理请求
     // ----- 身份验证
  if($sAction=="authentifier" && $sPhase=="0") authentifier_0($dConfig);
  if($sAction=="authentifier" && $sPhase=="1") authentifier_1($dConfig);
  if($sAction=="authentifier" && $sPhase=="2") authentifier_2($dConfig);  
     // ----- 添加文章
  if($sAction=="addarticle" && $sPhase=="0") addArticle_0($dConfig,$dSession);
  if($sAction=="addarticle" && $sPhase=="1") addArticle_1($dConfig,$dSession);
  if($sAction=="addarticle" && $sPhase=="2") addArticle_2($dConfig,$dSession);
     // ----- 更新商品
  if($sAction=="updatearticle" && $sPhase=="0") updateArticle_0($dConfig,$dSession);
  if($sAction=="updatearticle" && $sPhase=="1") updateArticle_1($dConfig,$dSession);
  if($sAction=="updatearticle" && $sPhase=="2") updateArticle_2($dConfig,$dSession);
  if($sAction=="updatearticle" && $sPhase=="3") updateArticle_3($dConfig,$dSession);
     // ----- 删除商品
  if($sAction=="deletearticle" && $sPhase=="0") deleteArticle_0($dConfig,$dSession);
  if($sAction=="deletearticle" && $sPhase=="1") deleteArticle_1($dConfig,$dSession);
  if($sAction=="deletearticle" && $sPhase=="2") deleteArticle_2($dConfig,$dSession);
    // ----- 查询商品
  if($sAction=="selectarticle" && $sPhase=="0") selectArticle_0($dConfig,$dSession);
  if($sAction=="selectarticle" && $sPhase=="1") selectArticle_1($dConfig,$dSession);
  if($sAction=="selectarticle" && $sPhase=="2") selectArticle_2($dConfig,$dSession);
     // ----- 发送请求 SQL
  if($sAction=="sql" && $sPhase=="0") sql_0($dConfig,$dSession);
  if($sAction=="sql" && $sPhase=="1") sql_1($dConfig,$dSession);
  if($sAction=="sql" && $sPhase=="2") sql_2($dConfig,$dSession);


    // 操作错误 - 显示身份验证页面
  session_destroy();
  authentifier_0($dConfig,"0");
...
?>

请注意以下几点:

  • 处理客户特定请求的函数以生成响应页面并执行 exit 语句结束,该语句终止脚本 apparticles.php 的执行。换言之,无法从这些函数“返回”。
  • 这些函数接受一个或两个参数:
    • $dConfig 是一个字典,其中包含来自配置文件 config.php 的信息。所有函数均使用该字典。
    • $dSession 是一个包含会话信息的字典。它仅在会话创建后存在,即用户认证成功之后。这就是为什么认证函数中没有这个参数。

7.5.6. 错误页面

任何软件应用程序都必须能够正确处理可能出现的错误。Web 应用程序也不例外。在此,当发生错误时,我们将以下页面 erreurs.php 放置在模板页面的第 4 区域:

Les erreurs suivantes se sont produites :
<ul>
    <?php
        for($i=0;$i<count($main["erreurs"]);$i++){
            echo "<li>".$main["erreurs"][$i]."</li>\n";
        }//用于
    ?>
</ul>
<a href="<?php echo $main["href"] ?>"><?php echo $main["lien"] ?></a>

该页面显示了在 $main['erreurs'] 中定义的错误列表。此外,它还可以提供一个返回链接,通常指向错误页面之前的页面。 该链接将通过标签 $main['lien'] 以及 URL $main['href'] 进行定义。 若要避免显示该链接,只需在 $main['lien'] 中设置为空字符串。以下是用户身份验证失败时的错误页面示例:

Image

7.5.7. 信息页面

有时我们只想向用户提供简单信息,例如登录成功。为此,我们将使用以下页面

<?php echo $main["infos"] ?>

为了响应客户端的请求并显示信息,

  • 将信息放入 $main['infos']
  • 将 URL 中的 infos.php 放入 $main['contenu']

例如,当用户正确登录时返回的信息如下:

Image

7.6. 应用程序的工作原理

现在我们已经对要编写的应用程序的总体结构有了清晰的认识。接下来,我们将介绍用户在应用程序中的操作路径、可执行的操作以及从服务器收到的响应。完成这些介绍后,我们就可以编写处理客户端各种请求的函数了。 接下来,我们将通过用户执行某些操作后所看到的页面,来介绍应用程序的运行机制。每次介绍时,我们将明确说明以下几点:

action utilisateur
导致显示该响应的用户初始操作
paramètres envoyés
客户端浏览器针对用户手动操作向服务器发送的参数
page réponse
生成模板页面第4区的脚本

7.6.1. 身份验证

在使用应用程序之前,用户需通过以下页面进行身份验证:

Image

action utilisateur
1 - 初始请求 URL apparticles.php
2 - 使用菜单中的“身份验证”选项
3 - 直接调用 URL articles.php 且参数有误
paramètres envoyés
1 - 未提供参数
2 - action=authenticate?phase=0
3 - 一组错误的参数
page réponse
login.php

在首页上,链接 [Ajouter un article] 的形式如下:action=addarticle?phase=0。其他链接形式相同,其中 action=(authenticate、updatearticle、deletearticle、selectarticle、sql)。用户填写表单并使用按钮 [Connexion]:

Image

响应如下:

Image

action utilisateur
按钮 [Connexion]
paramètres envoyés
action=authentifier?phase=1
page réponse
infos.php

页面标题已修改,以显示用户的登录名及其管理员/用户权限。此外,区域 2 中的所有链接均已修改,以反映会话已启动。已向这些链接添加了参数 PHPSESSID=[PHPSESSID]。

如果服务器无法识别客户端,客户端将收到不同的响应:

Image

action utilisateur
按钮 [Connexion]
paramètres envoyés
action=authentifier?phase=1
page réponse
erreurs.php

链接 [Retour à la page de login] 是指向 URL 的链接,其格式为 apparticles.php?action=authentifier&phase=2&txtLogin=x。 该链接将客户端重定向回登录页面,其中登录字段已由参数 txtLogin 的值自动填充:

Image

action utilisateur
链接 [Retour à la page de login]
paramètres envoyés
action=authentifier?phase=2&txtLogin=x
page réponse
login.php

7.6.2. 添加文章

菜单链接 [Ajouter un article] 将以下页面带入模板页面的第 4 区域:

Image

action utilisateur
链接 [Ajouter un article]
paramètres envoyés
action=addArticle?phase=0&PHPSESSID=[PHPSESSID]
page réponse
addarticle.php

用户填写字段后,通过类型为 submit 的按钮 [Ajouter] 将所有内容发送至服务器。客户端不进行任何验证,由服务器负责验证。服务器可能会返回如下示例所示的错误页面:

请求
响应
action utilisateur
按钮 [Ajouter]
paramètres envoyés
action=addArticle?phase=1&PHPSESSID=[PHPSESSID]
page réponse
erreurs.php

链接 [Retour à la page d'ajout d'article] 可返回输入页面:

请求
响应
action utilisateur
链接 [Retour à la page d'ajout d'article]
paramètres envoyés
action=addArticle?phase=2&PHPSESSID=[PHPSESSID]
page réponse
article.php

如果添加成功,用户将收到一条确认消息:

请求
响应
action utilisateur
按钮 [Ajouter]
paramètres envoyés
action=addArticle?phase=1&PHPSESSID=[PHPSESSID]
page réponse
infos.php

7.6.3. 文章浏览

菜单链接 [Lister des articles] 会将以下页面带入模板页面的第 4 区域:

Image

action utilisateur
菜单链接 [Lister des articles]
paramètres envoyés
action=selectArticle?phase=0&PHPSESSID=[PHPSESSID]
page réponse
select1.php

将向文章表发出一个查询:select [colonnes] from articles where [where] order by [orderby],其中 [colonnes]、 [where] 和 [orderby] 分别是上述字段的值。例如:

请求
响应
action utilisateur
按钮 [Afficher]
paramètres envoyés
action=selectArticle?phase=1&PHPSESSID=[PHPSESSID]
page réponse
select2.php

请求可能有误,此时客户将收到一个错误页面:

请求
响应

无论是否出现错误,点击链接 [Retour à la page de sélection d'articles] 均可返回页面 select1.php

请求
响应
action utilisateur
链接 [Retour à la page de sélection d'articles]
paramètres envoyés
action=selectArticle?phase=2&PHPSESSID=[PHPSESSID]
page réponse
select1.php

7.6.4. 修改条目

菜单链接 [Modifier un article] 将以下页面导入模板页面的第 4 区域:

Image

action utilisateur
菜单链接 [Modifier un article]
paramètres envoyés
action=updateArticle?phase=0&PHPSESSID=[PHPSESSID]
page réponse
updatearticle1.php

在下拉列表中选择要修改的文章代码,然后执行 [OK] 来修改该代码对应的文章:

请求
响应
action utilisateur
按钮 [OK]
paramètres envoyés
action=updateArticle?phase=1&PHPSESSID=[PHPSESSID]
page réponse
updatearticle2.php

获取待修改的文章详情页后,用户即可进行修改:

请求
响应
action utilisateur
按钮 [Modifier]
paramètres envoyés
action=updateArticle?phase=2&PHPSESSID=[PHPSESSID]
page réponse
infos.php

用户在编辑时可能会出错:

请求
响应

点击链接 [Retour à la page de modification d'article] 可返回输入页面:

Image

action utilisateur
链接 [Retour à la page de modification d'article]
paramètres envoyés
action=updateArticle?phase=3&PHPSESSID=[PHPSESSID]
page réponse
updatearticle2.php

7.6.5. 删除文章

菜单链接 [Supprimer un article] 会将以下页面显示在模板页面的第 4 区域:

Image

action utilisateur
菜单链接 [Supprimer un article]
paramètres envoyés
action=deleteArticle?phase=0&PHPSESSID=[PHPSESSID]
page réponse
deletearticle1.php

用户从下拉列表中选择要删除的文章代码:

请求
响应
action utilisateur
按钮 [OK]
paramètres envoyés
action=deleteArticle?phase=1&PHPSESSID=[PHPSESSID]
page réponse
deletearticle2.php

用户通过按钮 [Supprimer] 确认删除文章:

请求
响应
action utilisateur
按钮 [Supprimer]
paramètres envoyés
action=deleteArticle?phase=2&PHPSESSID=[PHPSESSID]
page réponse
infos.php

7.6.6. 管理员请求

菜单链接 [Requête SQL] 会将以下页面显示在模板页面的第 4 区域中:

Image

action utilisateur
菜单链接 [Requête SQL]
paramètres envoyés
action=sql?phase=0&PHPSESSID=[PHPSESSID]
page réponse
sql1.php

在输入框中输入查询文本 SQL,并使用按钮 [Exécuter] 执行该查询。只有管理员才能发出此类查询,如下例所示:

请求
响应
action utilisateur
按钮 [Exécuter]
paramètres envoyés
action=sql?phase=1&PHPSESSID=[PHPSESSID]
page réponse
erreurs.php

点击链接 [Retour à la page d'émission de requêtes SQL] 可返回输入页面:

Image

action utilisateur
链接 [Retour à la page d'émission de requêtes SQL]
paramètres envoyés
action=sql?phase=2&PHPSESSID=[PHPSESSID]
page réponse
sql1.php

如果用户是管理员且请求语法正确:

请求

将获得查询结果:

响应
action utilisateur
按钮 [Exécuter]
paramètres envoyés
action=sql?phase=1&PHPSESSID=[PHPSESSID]
page réponse
sql2.php

可以发出更新表的请求:

请求
响应
action utilisateur
按钮 [Exécuter]
paramètres envoyés
action=sql?phase=1&PHPSESSID=[PHPSESSID]
page réponse
infos.php

7.6.7. 待完成工作

编写应用程序所需的脚本和函数

用户名
类型
角色
apparticles.php
脚本
客户请求处理的入口点
authentifier_0
功能
处理参数为 action=authentifier&phase=0 的请求
authentifier_1
函数
处理带参数的请求 action=authentifier&phase=1
authentifier_2
函数
处理带参数的请求 action=authentifier&phase=2
addarticle_0
函数
处理带参数的请求 action=addArticle&phase=0
addarticle_1
函数
处理带参数的请求 action=addArticle&phase=1
addarticle_2
函数
处理带参数的请求 action=addArticle&phase=2
updatearticle_0
函数
处理带参数的请求 action=updatearticle&phase=0
updatearticle_1
函数
处理带参数的请求 action=updatearticle&phase=1
updatearticle_2
函数
处理带参数的请求 action=updatearticle&phase=2
updatearticle_3
函数
处理带参数的请求 action=updatearticle&phase=3
deletearticle_0
函数
处理带参数的请求 action=deletearticle&phase=0
deletearticle_1
函数
处理带参数的请求 action=deletearticle&phase=1
deletearticle_2
函数
处理带参数的请求 action=deletearticle&phase=2
selectarticle_0
函数
处理带参数的请求 action=selectarticle&phase=0
selectarticle_1
函数
处理带参数的请求 action=selectarticle&phase=1
selectarticle_2
函数
处理带参数的请求 action=selectarticle&phase=2
sql_0
函数
处理带参数的请求 action=sql&phase=0
sql_1
函数
处理带参数的请求 action=sql&phase=1
sql_2
函数
处理带参数的请求 action=sql&phase=2
main.php
脚本
生成页面模板
login.php
脚本
生成登录页面
erreurs.php
脚本
生成错误页面
infos.php
脚本
生成信息页面
addarticle.php
脚本
生成添加文章页面
updatearticle1.php
脚本
生成文章编辑页面的第1页
updatearticle2.php
脚本
生成文章修改的第2页
deletearticle1.php
脚本
生成删除文章的第1页
deletearticle2.php
脚本
生成删除文章的第2页
select1.php
脚本
生成文章筛选的第1页
select2.php
脚本
生成商品选择列表的第2页
sql1.php
脚本
生成查询发送的第1页
sql2.php
脚本
生成请求发送的第2页

7.7. 改进应用程序

目前,我们拥有一款功能完备且用户体验尚可的应用程序。接下来,我们将从以下几个方面对其进行优化:

  • SGBD
  • 安全性
  • 外观
  • 性能

7.7.1. 更改数据库类型

我们的研究假设所使用的 SGBD 是 MySQL。 请将其更改为 SGBD,并证明唯一需要修改的是配置文件 config.php 中变量 $dDSN 的定义。

7.7.2. 提高安全性

在开发 Web 应用程序时,切勿假设客户端是浏览器,也切勿认为其发送的请求受此前发送给它的表单的控制。任何程序都可能作为 Web 应用程序的客户端,因此可能向应用程序发送任何带参数或不带参数的请求。因此,应用程序必须对所有内容进行验证。

若参考脚本代码 apparticles.php,可发现

  • 在没有会话的情况下,除了身份验证之外,无法进行任何操作。只有当用户成功通过身份验证时,会话才存在。 需要提醒的是,会话通过一个较长的字符串(称为会话令牌)进行标识,其格式如下:176a43609572907333118333edf6d1fb。 该令牌可通过多种方式发送至应用程序,例如使用经过配置的 URL:

apparticles.php?PHPSESSID=176a43609572907333118333edf6d1fb. 

如果某个程序通过随机更改令牌值,反复请求上述 URL,试图找到正确的令牌,那么由于可能的组合数量极其庞大,它很可能需要花费数天时间才能生成正确的组合。在此期间,由于会话时长有限,会话极有可能已经结束。 另一个风险在于,令牌在网络上以明文形式传输时可能被截获。这种风险是真实存在的。因此,可以在服务器与客户端之间使用加密连接。

  • 即会话启动后,仅允许执行特定操作。例如,参数设置为 action=tricher&phase=0&URL 的令牌将被拒绝,因为“tricher”并非授权操作。 当参数(操作、阶段)无法识别时,我们的应用程序将返回身份验证页面。

然而,应用程序不会验证授权操作的顺序是否正确。例如,以下两个操作:

  1. action=addArticle&phase=0&PHPSESSID=[PHPSESSID]
  2. action=updateArticle&phase=1&PHPSESSID=[PHPSESSID]

是两个被授权的操作。但操作 2 无权紧跟在操作 1 之后。

如何追踪客户端浏览器请求的 URL 操作序列?

我们可以借助两个变量 PHP: $_SERVER['REQUEST_URI] 和 $_SERVER['HTTP_REFERER],这两项信息是由客户端浏览器在其 HTTP 头部中发送的。

$_SERVER['REQUEST_URI]:这是客户端请求的URI。例如

/apparticles.php?action=addArticle&phase=0&PHPSESSID=[PHPSESSID]

$_SERVER['HTTP_REFERER]: 这是在浏览器中显示的URL,而浏览器正在请求新的URL(即之前的URI)。 例如,如果之前显示了前述URI的浏览器向服务器发出新请求,该服务器的$_SERVER['HTTP_REFERER']变量将取值为

http://机器:端口//apparticles.php?action=addArticle&phase=0&PHPSESSID=[PHPSESSID]

要验证应用程序中的两个操作是否按顺序执行,可以按以下方式操作:

在操作1中:

  • 记录所请求的URI(URI1),并将其记录在会话中

在操作2中:

  • 从操作2中获取HTTP-REFERER。 据此推导出URI(URI2),该值源自此前在发起请求的浏览器中显示的URL。
  • 获取存储在会话中的 URI URI1,该值即为此前向服务器请求的操作对应的 URI
  • 如果操作 2 紧随操作 1 之后,则应满足 URI2=URI1。若不满足此条件,则拒绝执行所请求的操作,并显示身份验证页面。
  • 在会话中记录当前操作的 URI URI2,以便验证后续操作。依此类推。

以下是一个示例。认证通过后,选择链接 [Ajouter un article]:

Image

该页面的 URL 为:

http://localhost:81/st/php/articles/gestion/articles8/apparticles.php?action=addArticle&phase=0&PHPSESSID=006a63e6027f16c70b63cdae93405eeb

直接在浏览器的 [Adresse] 字段中,我们将 URL 修改为:

http://localhost:81/st/php/articles/gestion/articles8/apparticles.php?action=deleteArticle&phase=1&PHPSESSID=006a63e6027f16c70b63cdae93405eeb

随后将跳转至身份验证页面:

Image

这需要进一步说明。当通过在浏览器地址栏直接输入身份信息来请求 URL 时,浏览器不会发送 HTTP_REFERER 头部。 因此,我们的应用程序无法找到前一操作中存储在会话中的 URI(即 URI)。于是,它返回了身份验证页面作为响应。

该机制对浏览器有效,但对编程客户端则完全无效。后者可以发送任意 HTTP_REFERER 头部。因此,它可以“作弊”,声称自己确实经过了某个步骤,而实际上并未执行。 因此必须确保步骤顺序得到遵守。例如,如果请求的操作是 action=addArticle&phase=1(录入)时,前一个操作必须是 action=deleteArticle&phase=0(初始调用录入页面)或 action=addArticle&phase=2(添加错误后返回录入)。 同理,如果请求的操作是 action=addArticle&phase=2(添加),那么前一个操作必须是 action=addArticle&phase=1(输入)。我们可以强制用户遵守这些操作序列。

虽然第一种机制是通用的,可适用于任何应用程序,但第二种机制需要针对每个应用程序进行特定编码,且更为繁琐:必须遍历用户所有可能的操作及其序列。可以将这些序列存储在字典中,如下面的代码所示:

  // 身份验证
  $dPrec['authentifier']['0']=array();
  $dPrec['authentifier']['1']=array(
         array('action'=>'authentifier','phase'=>'0'),
    array('action'=>'authentifier','phase'=>'2')
  );
  $dPrec['authentifier']['2']=array(
         array('action'=>'authentifier','phase'=>'1'),
  );

   // 添加文章
  $dPrec['addarticle']['0']=array();  
  $dPrec['addarticle']['1']=array(
         array('action'=>'addarticle','phase'=>'0'),
    array('action'=>'addarticle','phase'=>'2')
  );
  $dPrec['addarticle']['2']=array(
         array('action'=>'addarticle','phase'=>'1'),
  );

   // 修改文章
  $dPrec['updatearticle']['0']=array();  
  $dPrec['updatearticle']['1']=array(
         array('action'=>'updatearticle','phase'=>'0'),
  );
  $dPrec['updatearticle']['2']=array(
         array('action'=>'updatearticle','phase'=>'1'),
    array('action'=>'updatearticle','phase'=>'3')
  );
  $dPrec['updatearticle']['3']=array(
         array('action'=>'updatearticle','phase'=>'2'),
  );

   // 删除商品
  $dPrec['deletearticle']['0']=array();  
  $dPrec['deletearticle']['1']=array(
         array('action'=>'deletearticle','phase'=>'0'),
  );
  $dPrec['deletearticle']['2']=array(
         array('action'=>'deletearticle','phase'=>'1'),
  );

      // 选择文章
  $dPrec['selectarticle']['0']=array();  
  $dPrec['selectarticle']['1']=array(
         array('action'=>'selectarticle','phase'=>'0'),
    array('action'=>'selectarticle','phase'=>'2')
  );
  $dPrec['selectarticle']['2']=array(
         array('action'=>'selectarticle','phase'=>'1'),
  );

      // 管理员请求
  $dPrec['sql']['0']=array();  
  $dPrec['sql']['1']=array(
         array('action'=>'sql','phase'=>'0'),
    array('action'=>'sql','phase'=>'2')
  );
  $dPrec['sql']['2']=array(
         array('action'=>'sql','phase'=>'1'),
  );

$dPrec['action']['phase'] 是一个数组,其中包含可能先于该动作的动作,以及作为字典索引的阶段。 这些前置动作同样由一个包含“动作”和“阶段”两个键的字典表示。如果某个动作可以由任意动作先行,那么 $dPrec['action']['phase'] 将是一个空数组 如果字典中不存在某项操作,则表示该操作不被允许。以上述“认证”操作为例:

  // 身份验证
  $dPrec['authentifier']['0']=array();
  $dPrec['authentifier']['1']=array(
         array('action'=>'authentifier','phase'=>'0'),
    array('action'=>'authentifier','phase'=>'2')
  );
  $dPrec['authentifier']['2']=array(
         array('action'=>'authentifier','phase'=>'1'),
  );

上述代码表示操作 action=authentifier&phase=0 之前可以是任何操作, action=authentifier&phase=1 之前可以是 action=authentifier&phase=0 或 action=authentifier&phase=2,而 action=authentifier&phase=2 之前可以是操作 action=authentifier&phase=1

编写以下函数:

  // ---------------------------------------------------------------
  function enchainementOK(&$dConfig, &$dSession, $sAction, $sPhase){
       // 检查当前操作($sAction、$sPhase)是否可以跟在上一操作之后
         // 存储在 $dSession['précédent'] 中
         // 允许的链式操作字典位于 $dConfig['précédents']
         // 若链式操作可行则返回 TRUE,否则返回 FALSE
....

此函数允许主应用程序验证操作序列是否正确:

<?php
     // 管理商品表
  include "config.php";
  include "articles.php";  

  // 会话
  session_start();
  $dSession=$_SESSION["session"];

   // 待执行操作
  $sAction=$_POST["action"] ? $_POST["action"] : $_GET["action"] ? $_GET["action"] : "authentifier";
  $sAction=strtolower($sAction);
   // 操作的可能阶段
  $sPhase=$_POST["phase"] ? $_POST["phase"] : $_GET["phase"] ? $_GET["phase"] : "0";

     // 是否有会话正在进行?  
  if(! isset($dSession)){
      // 用户身份验证
    if($sAction=="authentifier" && $sPhase=="0") authentifier_0($dConfig);
    if($sAction=="authentifier" && $sPhase=="1") authentifier_1($dConfig);   
    if($sAction=="authentifier" && $sPhase=="2") authentifier_2($dConfig);    
     // 异常操作
    authentifier_0($dConfig);        
  }//if - 无会话

     // 获取会话
  $dSession=unserialize($dSession);

     // 操作序列是否正常?
  if( ! enchainementOK($dConfig,$dSession,$sAction,$sPhase)){
     // 操作序列异常
    authentifier_0($dConfig);        
  }//if

     // 处理操作
  if($sAction=="authentifier"){
   if($sPhase=="0") authentifier_0($dConfig);  
   if($sPhase=="1") authentifier_1($dConfig);  
   if($sPhase=="2") authentifier_2($dConfig);  
  }//if     
  if($sAction=="addarticle"){
...

7.7.3. 更新“外观”

请记住,在研究该应用程序时提出的一个条件是它必须具备可扩展性。假设几周后,我们发现该应用程序的人机工程学设计需要改进。请修改应用程序,以改变标准页面的结构和布局。修改将在两个位置进行:

  • 在定义标准页面结构的脚本 main.php 中。请对该脚本进行更新。
  • 在决定应用程序“外观”的样式表中。修改该样式表。

7.7.4. 提升性能

目前,我们选择了一个轻量级客户端浏览器:它仅负责呈现功能。可以通过在发送给它的网页中嵌入脚本,使其执行处理任务。这些脚本可以使用不同的语言编写,特别是 VBScript 和 JavaScript。 Internet Explorer和Netscape在浏览器市场上的份额比例约为60:40。此外,IE仅存在于Windows平台,而在Unix等平台(例如Netscape占据主导地位的系统)上则不存在。 Netscape 无法原生执行 VBScript,而这两款浏览器均可执行 JavaScript。鉴于 Netscape 仍占据浏览器市场的重要份额,应避免使用 VBScript。因此,JavaScript 通常被用于客户端脚本。

那些无需服务器介入的处理任务,将委托给客户端脚本完成。 在我们的应用程序中,如果客户端浏览器能在向服务器发送请求前先进行验证,将非常有益。这样,当用户在身份验证表单中留空了字段 [login] 时,就无需向服务器发送身份验证请求。此时,最好是提醒用户其请求有误:

Image

需要注意的是,这并不能阻止服务器验证 login 字段是否为空,因为客户端不一定是浏览器,因此之前的验证可能并未进行。假设客户端是浏览器,将对应用程序的安全性构成重大风险。

请梳理浏览器向服务器发送信息的各个环节,并在可验证的节点编写一个或多个 JavaScript 函数,使浏览器能在将信息发送至服务器之前验证其有效性。

以之前的示例为例,生成身份验证页面的脚本 login.php 将变为如下形式:


<script language="javascript">
    function check(){
       // 验证是否确实存在登录
    with(document.frmLogin){
        champs=/^\s*$/.exec(txtLogin.value);
      if(champs!=null){
          // 无登录
        alert("Vous n'avez pas indiqué de login");
        txtLogin.focus();
        return;
      }//if
       // 数据已存在 - 将其发送至服务器
      submit();
    }//通过
  }//检查
</script>   
    
<form name="frmLogin" method="post" action="<?php echo $main["post"] ?>">
    <table>
        <tr>
            <td>login</td>
            <td><input type="text" value="<?php echo $main["login"] ?>" name="txtLogin" class="text"></td>
        </tr>
        <tr>
            <td>mot de passe</td>
            <td><input type="password" value="" name="txtMdp" class="text"></td>
      <td><input type="button" onclick="check()" value="Connexion" class="submit"></td>      
        </tr>
    </table>
</form>    

7.8. 进一步阅读

最后,我们提出几个方向以深化本案例研究:

  • 值得探讨的是,该应用程序的模板页面是否可以作为来实现。这样,该类便可应用于其他应用程序中。
  • 我们的应用程序非常适合浏览器类客户端,但不太适合“独立应用程序”类客户端。后者必须:
    • 与服务器建立TCP连接
    • 与服务器“对话” HTTP
    • 解析其响应HTML以获取所需信息,因为独立客户端通常不会关注面向浏览器的呈现代码HTML。

如果我们的应用程序能生成 XML 而不是 HTML,那将很有意义。 这样一来,其客户端无论是(相对较新的)浏览器还是独立应用程序均可兼容。对于独立应用程序而言,检索所需信息将毫无困难,因为服务器的 XML 响应中不会包含任何呈现信息,仅包含内容。

  • 我们必须重点关注对文章数据库的并发访问。至少有两个问题需要澄清:
  1. 应用程序使用的 SGBD 是否能正确处理对同一文章的并发访问? 例如,如果两名用户同时修改同一文章(即同时点击[Modifier]按钮),会发生什么情况?这可能取决于底层的SGBD。
  2. 目前我们的应用程序不支持并发访问。不过,即使可能会出现意外情况,数据库应该仍能保持一致性。让我们来看以下事件序列:
      • 用户 U1 进入某篇文章的编辑界面
      • 用户 U2 稍后进入同一文章的删除界面
      • 这两项操作均需要进行客户端与服务端之间的交互。根据各自的工作节奏,用户 U2 可能在 U1 之前完成操作。 当U1完成修改并通过[Modifier]提交时,他将收到信息页面作为响应,其中SGBD会提示他[0 ligne(s) ont été modifiées],这是因为他想要修改的页面在此期间已被删除。 用户无疑会感到惊讶。从用户体验的角度来看,最好能显示一个更清晰地提示错误的页面。 此外,可以考虑在用户开始编辑某篇文章时,立即为其提供该文章的独占访问权限。若此时其他用户试图编辑同一文章,系统将提示“当前已有编辑操作正在进行”。但如果首位用户迟迟不提交编辑,就会导致其他用户被阻塞,从而引发问题。 这需要寻找解决方案,而解决方案在很大程度上取决于所使用的 SGBD 的功能。例如,Oracle 在这方面的功能比 MySQL 更强大。