Skip to content

11. 练习 IMPOTS,使用 WEB 服务和三层架构

我们将重新使用练习 IMPOTS(参见第 4.24.3 6 节),并将其改造成一个客户端/服务器应用程序。服务器脚本将分解为三个部分:

  • 一个名为 [dao](数据访问对象)的层,负责与数据库 MySQL 进行交互
  • 一个名为 [métier] 的层,负责计算税款
  • 一个名为 [web] 的层,负责与 Web 客户端的交互。

客户端脚本 [1]:

  • 向服务器脚本传输三条用于计算税款的必要信息($marié、$enfants、$salaire)
  • 在控制台上显示服务器的响应

服务器脚本 [2] 由服务器的 [web] 层构成。

  • 在新的客户端会话开始时,它将把 MySQL 和 [dbimpots] 数据库中的数据放入数组中。为此,它将调用 [dao] 层。 构建好的数组将被放入客户端会话中,以便在后续的客户端请求中使用。
  • 在客户端请求时,它将传递以下三项信息 ($marié、$enfants、$salaire)传递给 [métier] 层,该层将计算税额 $impot。
  • 服务器脚本将返回计算出的税款 $impôt。

11.1. 客户端脚本(clients_impots_05_web)

客户端脚本将作为税额计算Web服务的客户端。它将向服务器发送(POST)以下形式的参数:

params=$已婚,$子女,$工资,其中

  • $marié 表示字符串 oui non
  • $enfants 表示子女数量,
  • $salaire 表示纳税人的工资

它从名为 [data.txt] 的文本文件中获取上述三个参数,格式为 (已婚、子女、工资):

oui,2,200000
non,2,200000
oui,3,200000
non,3,200000
oui,5,50000
non,0,3000000

客户端脚本

  • 将逐行读取文本文件 [data.txt]
  • 向税务计算Web服务发送字符串 params=$marié,$enfants,$salaire
  • 获取服务器的响应。响应可能有两种形式:
<erreur>message d'erreur</erreur>
<impot>montant de l'impôt</impot>
  • 将服务器的响应以以下两种形式之一保存到文本文件 [resultats.txt] 中:
marié:enfants:salaire:erreur
marié:enfants:salaire:impôt

客户端脚本代码如下:


<?php

// 税务客户
// 错误管理
ini_set("display_errors", "off");

// ---------------------------------------------------------------------------------
// 一个实用函数类
class Utilitaires {

  function cutNewLinechar($ligne) {
    ...
  }

}

// main -----------------------------------------------------
// 常量定义
$DATA = "data.txt";
$RESULTATS = "resultats.txt";
// 服务器数据
$HOTE = "localhost";
$PORT = 80;
$urlServeur = "/exemples-web/impots_05_web.php";

// 纳税人的参数(婚姻状况、子女数量、年薪)
// 已按每位纳税人一行放入文本文件 $DATA 中
// 结果(婚姻状况、子女人数、年薪、应缴税款) 
// 或(婚姻状况、子女数、年薪、错误信息)被放入
// 文本文件 $RESULTATS 中,每行一个结果

// 实用程序类
$u = new Utilitaires();

// 打开纳税人数据文件
$data = fopen($DATA, "r");
if (!$data) {
  print "Impossible d'ouvrir en lecture le fichier des données [$DATA]\n";
  exit;
}

// 打开结果文件
$résultats = fopen($RESULTATS, "w");
if (!$résultats) {
  print "Impossible de créer le fichier des résultats [$RESULTATS]\n";
  exit;
}

// 处理纳税人数据文件的当前行
while ($ligne = fgets($data, 100)) {
  // 移除可能存在的行尾标记
  $ligne = $u->cutNewLineChar($ligne);
  // 提取构成 $ligne 的 3 个字段:已婚:子女:工资
  list($marié, $enfants, $salaire) = explode(",", $ligne);
  // 计算税额
  list($erreur, $impôt) = calculerImpot($HOTE, $PORT, $urlServeur, $cookie, array($marié, $enfants, $salaire));
  // 写入计算结果
  $résultat = $erreur ? "$marié:$enfants:$salaire:$erreur" : "$marié:$enfants:$salaire:$impôt";
  fputs($résultats, "$résultat\n");
  // 下一条数据
}
// 关闭文件
fclose($data);
fclose($résultats);

// 结束
print "Terminé...\n";
exit;

function calculerImpot($HOTE, $PORT, $urlServeur, &$cookie, $params) {
  // 将客户端连接至 ($HOTE,$PORT,$urlServeur)
  // 会在该cookie不为空时发送 $cookie。$cookie 通过引用传递
  // 将 $params 发送至服务器
  // 处理服务器返回的唯一一行
  
  // 在 $HOTE 的 80 端口上建立连接
  $connexion = fsockopen($HOTE, $PORT);
  // 错误?
  if (!$connexion)
    return array("erreur lors de la connexion au serveur ($HOTE, $PORT)");
  // HTTP 协议的头部(headers)必须以空行结尾
  // POST
  fputs($connexion, "POST $urlServeur HTTP/1.1\n");
  // 主机
  fputs($connexion, "Host: localhost\n");
  // 连接
  fputs($connexion, "Connection: close\n");
// 如果 cookie 不为空,则发送
  if ($cookie) {
    fputs($connexion, "Cookie: $cookie\n");
  }//if
  // 现在将客户端指令编码后发送
  $infos = "params=" . urlencode(implode(",", $params));
  // 指定将发送的信息类型
  fputs($connexion, "Content-type: application/x-www-form-urlencoded\n");
  // 发送待发送信息的长度(字符数)
  fputs($connexion, "Content-length: " . strlen($infos) . "\n");
  // 发送空行
  fputs($connexion, "\n");
  // 发送信息
  fputs($connexion, $infos);
  // 显示Web服务器的响应
  // 并注意获取可能存在的cookie
  while ($ligne = fgets($connexion, 1000)) {
    // cookie - 仅在首次响应时
    if (!$cookie) {
      if (preg_match("/^Set-Cookie: (.*?)\s*$/", $ligne, $champs)) {
        $cookie = $champs[1];
      }//if
    }
    // 一旦响应中出现空行,则响应 HTTP 结束
    if (trim($ligne) == "") {
      break;
    }
  }//while
  // 读取结果行
  $ligne = fgets($connexion, 1000);
  // 关闭连接
  fclose($connexion);
  // 计算结果
  $erreur="";
  $impôt="";
  if (preg_match("/^<erreur>(.*?)<\/erreur>\s*$/", $ligne, $champs)) {
    $erreur = $champs[1];
  } else {
    if (preg_match("/^<impot>(.*?)<\/impot>\s*$/", $ligne, $champs)) {
      $impôt = $champs[1];
    }else{
      $erreur="résultat du serveur non exploitable";
    }
  }
  // 返回
  return array($erreur, $impôt);
}

注释

客户端脚本代码包含了一些已见的内容:

  • 第9-15行:类[Utilitaires]已在第3版第6节中介绍
  • 第17-68行:主程序与第1版第4.2节中的程序类似。两者唯一的区别在于第56行的税额计算。
  • 第56行:税额计算函数接受以下参数:
    • $HOTE、$PORT、$urlServeur:用于连接Web服务
    • $cookie:是会话cookie。该参数通过引用传递。其值由税额计算函数确定。首次调用时,该参数无值。此后则有值。
    • array($marié, $enfants, $salaire):代表文件 [data.txt] 中的一个行

税额计算函数返回包含两个结果的数组 ($erreur, $impôt),其中 $erreur 可能是错误信息,$impôt 则是税额。

  • 第 70 – 134 行:这是一个典型的 HTTP 客户端,我们经常遇到此类情况。请注意以下几点:
  • 第 83 行:参数($marié、$enfants、$salaire)通过 POST 传递给服务器
  • 第89-91行:如果客户端有会话标识符,则将其发送给服务器
  • 第 93 行:创建参数 params
  • 第 101 行:发送参数 params
  • 第 104-115 行:客户端读取服务器发送的所有 HTTP 头部,直到遇到表示头部结束的空行。它借此机会在对首次请求的响应中获取会话标识符。
  • 第123-125行:处理可能出现的格式为<erreur>message</erreur>的行
  • 第 126-128 行:对可能出现的 <impot>montant</impot> 格式的行进行同样的处理
  • 第 133 行:返回结果

11.2. 税款计算网络服务

这里我们关注构成该服务器的三个脚本:

相应的 NetBeans 项目如下:

在 [1] 中,服务器由以下 PHP 脚本组成:

  • [impots_05_entites] 包含服务器使用的类
  • [impots_05_dao] 包含 [dao] 层的类和接口
  • [impots_05_metier] 包含 [metier] 层的类和接口
  • [impots_05_web] 包含 [dao] 层的类和接口

首先,我们将介绍 Web 服务各层所使用的两个类。

11.2.1. Web 服务的实体(impots_05_entites)

数据库 MySQL [dbimpots] 包含表 [impots],该表存储了计算税款 [1] 所需的数据:

我们将把表 MySQL 和 [impots] 中的数据存储在对象数组 Tranche 中,其中 Tranche 是以下类:


<?php

// 一个税率区间
class Tranche {

  // 私有字段
  private $limite;
  private $coeffR;
  private $coeffN;

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

  public function setLimite($limite) {
    $this->limite = $limite;
  }

  public function getCoeffR() {
    return $this->coeffR;
  }

  public function setCoeffR($coeffR) {
    $this->coeffR = $coeffR;
  }

  public function getCoeffN() {
    return $this->coeffN;
  }

  public function setCoeffN($coeffN) {
    $this->coeffN = $coeffN;
  }

  // 构造函数
  public function __construct($limite, $coeffR, $coeffN) {
    $this->setLimite($limite);
    $this->setCoeffR($coeffR);
    $this->setCoeffN($coeffN);
  }

  // toString
  public function __toString(){
    return "[$this->limite,$this->coeffR,$this->coeffN]";
  }
}

私有字段 [$limite, $coeffR, $coeffN] 将用于存储表 MySQL 中某一行中 [limites, coeffR, coeffN] 列的数据。

此外,服务器代码将使用其专属的异常类 ImpotsException

1
2
3
4
5
6
7
class ImpotsException extends Exception {

  public function __construct($message, $code=0) {
    parent::__construct($message, $code);
  }

}
  • 第 1 行:类 [ImpotsException] 继承自 PHP 中预定义的类 [Exception] 5
  • 第 3 行:类 [ImpotsException] 的构造函数接受两个参数:
    • $message:一条错误消息
    • $code:一个错误代码

11.2.2. [dao](impots_05_dao)层

[dao] 层负责访问数据库数据:

[dao] 层具有以下接口:

1
2
3
4
5
6
// Dao 接口
interface IImpotsDao {

// 返回一个 Tranche 实体数组
  function getData();
}

接口 IImpotsDao 仅暴露函数 getData。 该函数将表 MySQL 和 [dbimpots.impots] 中的各行数据放入对象数组 Tranche 中。

实现类如下:


<?php

// DAO层
// 依赖关系
require_once "impots_05_entites.php";
// 常量
define("TABLE", "impots");

// -----------------------------------------------------------------
// 抽象实现
abstract class ImpotsDaoWithPdo implements IImpotsDao {

// 私有字段
  private $dsn;
  private $user;
  private $passwd;
  private $tranches;

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

  public function setDsn($dsn) {
    $this->dsn = $dsn;
  }

  public function getUser() {
    return $this->user;
  }

  public function setUser($user) {
    $this->user = $user;
  }

  public function getPasswd() {
    return $this->passwd;
  }

  public function setPasswd($passwd) {
    $this->passwd = $passwd;
  }

  // 构造函数
  public function __construct($dsn, $user, $passwd) {
    // 保存参数
    $this->setDsn($dsn);
    $this->setUser($user);
    $this->setPasswd($passwd);
    // 从 SGBD 获取数据
    // 将 ($user,$pwd) 连接到数据库 $dsn
    try {
      // 连接
      $connexion = new PDO($dsn, $user, $passwd, array(PDO::ATTR_PERSISTENT => true));
      // 读取表 $TABLE
      $requête = "select limites,coeffR,coeffN from " . TABLE;
      // 在连接 $connexion 上执行查询 $requête
      $statement = $connexion->prepare($requête);
      $statement->execute();
      // 处理查询结果
      while ($colonnes = $statement->fetch()) {
        $this->tranches[] = new Tranche($colonnes[0], $colonnes[1], $colonnes[2]);
      }
      // 断开连接
      $connexion=NULL;
    } catch (PDOException $e) {
      // 返回错误
      throw new ImpotsException($e->getMessage(), 1);
    }
  }

  public function getData(){
    return $this->tranches;
  }
}
  • 第 5 行:接口 [IImpotsDao] 的实现需要脚本 [impots_05_entites] 中定义的类。
  • 第 11 行:定义了一个抽象类。抽象类是指无法实例化的类。抽象类必须被派生才能被实例化。 声明类为抽象类的原因可能是无法对其进行实例化(其某些方法未定义),或者不打算对其进行实例化。在此,我们不打算对类 [ImpotsDaoWithPdo] 进行实例化,而是将对派生类进行实例化。
  • 第 11 行:类 [ImpotsDaoWithPdo] 实现了接口 [IImpotsDao]。因此,它必须定义方法 getData。该方法位于第 72-74 行。
  • 第 14 行:$dsn(数据源名称)是一个字符串,用于唯一标识 SGBD 及其所使用的数据库。
  • 第15行:$user标识连接数据库的用户
  • 第 16 行:$passwd 是前一个用户的密码
  • 第 17 行:$tranches 是 Tranche 对象数组,其中将存储表 MySQL [dbimpots.impots]。
  • 第 45-70 行:类的构造函数。该代码已在第 4 版第 8.2 节中出现过。需要注意的是,[ImpotsDaoWithPdo] 对象的构造可能会失败。此时将抛出类型为 [ImpotsException] 的异常。
  • 第72-74行:接口[IImpotsDao]中的方法[getData]。

[ImpotsDaoWithPdo] 类适用于所有 SGBD。该类的构造函数(第 45 行)要求提供数据库的数据源名称。该字符串取决于所使用的 SGBD。 我们选择不强制要求该类的用户必须知道该数据源名称。对于每个 SGBD,都将有一个从 [ImpotsDaoWithPdo] 派生而来的特定类。 对于 SGBD 和 MySQL,对应的类如下:


class ImpotsDaoWithMySQL extends ImpotsDaoWithPdo {

  public function __construct($host, $port, $base, $user, $passwd) {
    parent::__construct("mysql:host=$host;dbname=$base;port=$port", $user, $passwd);
  }

}
  • 第3行中,构建程序不需要数据源名称,只需提供SGBD($host)的主机名、其监听端口($port)以及数据库名称 ($base)。
  • 第 4 行,构建了数据库 MySQL 的数据源名称,并将其用于调用父类的构造函数。

需要注意的是,若要适配另一个 SGBD,只需编写相应的 [ImpotsDaoWithPdo] 派生类即可。每次都需要构建所用 SGBD 专属的数据源名称

11.2.3. [métier](impots_05_metier)层

[metier]层包含税额计算逻辑:

[métier] 层具有以下接口:


<?php

// 业务接口
interface IImpotsMetier {
  public function calculerImpot($marié, $enfants, $salaire);
}

接口 [IImpotsMetier] 仅提供一个方法,即 [calculerImpot] 方法,该方法可根据以下参数计算纳税人的税款:

  • $marié:根据纳税人是否已婚,返回“是”或“否”的字符串
  • $enfants:纳税人的子女数量
  • $salaire:其工资

[web] 层将为其提供这些参数。

[IImpotsMetier] 接口的实现如下:


// 依赖关系
require_once "impots_05_dao.php";

// ------------------------------------------------------------------
// 实现类
class ImpotsMetier implements IImpotsMetier {

  // DAO层
  private $dao;
   // 对象数组[Tranche]
  private $data;

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

  public function setDao($dao) {
    $this->dao = $dao;
  }

  public function setData($data){
    $this->data=$data;
  }
  
  public function __construct($dao) {
    // 获取计算税款所需的数据
    $this->setDao($dao);
    $this->setData($this->dao->getData());
  }

  public function calculerImpot($marié, $enfants, $salaire) {
    // $marié:是,否
    // $enfants:子女数量
    // $salaire:年薪
    // 份额数
    $marié = strtolower($marié);
    if ($marié == "oui")
      $nbParts = $enfants / 2 + 2;
    else
      $nbParts=$enfants / 2 + 1;
    // 若子女至少3名,则额外增加1/2份额
    if ($enfants >= 3)
      $nbParts+=0.5;
    // 应税收入
    $revenuImposable = 0.72 * $salaire;
    // 家庭系数
    $quotient = $revenuImposable / $nbParts;
    // 置于限额表末尾以终止后续循环
    $N = count($this->data);
    $this->data[$N - 1]->setLimite($quotient);
    // 计算税额
    $i = 0;
    while ($i < $N and $quotient > $this->data[$i]->getLimite()) {
      $i++;
    }
    // 由于将 $quotient 放置在 $limites 表的末尾,因此前面的循环
    // 不能超出数组 $limites
    // 现在可以计算税款
    return floor($revenuImposable * $this->data[$i]->getCoeffR() - $nbParts * $this->data[$i]->getCoeffN());
  }

}
  • 第 2 行:[métier] 层需要 [dao] 层的类以及实体(TrancheImpotsException)。
  • 第 6 行:类 [ImpotsMetier] 实现了接口 [IimpotsMetier]。
  • 第 9-11 行:该类的私有字段:
    • $dao:指向 [dao] 层的引用
    • $data:由 [dao] 层提供的 [Tranche] 类型对象数组
  • 第 26-30 行:类构造函数初始化前两个字段。它接收一个指向 [dao] 层的引用作为参数。
  • 第 32-61 行:实现接口 [IimpotsMetier] 的方法 [calculerImpot]。该方法自版本 1 起便已存在(参见第 4.2 节)。

11.2.4. [web] 层(impots_05_web)

[metier]层包含税额计算逻辑:

[web] 层由响应 Web 客户端的 Web 服务构成。需要说明的是,客户端通过发送以下参数向 Web 服务发起请求:params=marié,enfants,salaire。这与我们在前文构建的 Web 服务类似。其代码如下:


<?php

// 业务层
require_once "impots_05_metier.php";

// 错误处理
ini_set("display_errors", "off");

// 表头UTF-8
header("Content-Type: text/plain; charset=utf-8");

// ------------------------------------------------------------------------------
// 税务Web服务
// 常量定义
$HOTE = "localhost";
$PORT = 3306;
$BASE = "dbimpots";
$USER = "root";
$PWD = "";
// 计算税款所需的数据已放入 mysql 表 IMPOTS
// 该表属于数据库 $BASE。该表的结构如下
// 限值 decimal(10,2),coeffR decimal(6,2),coeffN decimal(10,2)
// 纳税人参数(婚姻状况、子女数量、年薪)
// 由客户端以 params=婚姻状况, 子女数量, 年薪 的形式发送
// 结果(婚姻状况、子女数量、年薪、应缴税款)将返回给客户端
// 以 <impot>impot</impot> 的形式返回
// 或以 <错误>错误</错误> 的形式显示,如果参数无效

// 在会话中检索到 [métier] 层
session_start();
if (!isset($_SESSION['metier'])) {
// 实例化 [dao] 层和 [métier] 层
  try {
    $_SESSION['metier'] = new ImpotsMetier(new ImpotsDaoWithMySQL($HOTE, $PORT, $BASE, $USER, $PWD));
  } catch (ImpotsException $ie) {
    print "<erreur>Erreur : " . utf8_encode($ie->getMessage() . "</erreur>");
    exit;
  }
}
$metier = $_SESSION['metier'];

// 获取客户端发送的行
$params = utf8_encode(htmlspecialchars(strtolower(trim($_POST['params']))));
$items = explode(",", $params);
// 参数应仅包含 3 个
if (count($items) != 3) {
  print "<erreur>[$params] : nombre de paramètres invalides</erreur>\n";
  exit;
}//if
// 第一个参数(婚姻状况)必须为是/否
$marié = trim($items[0]);
if ($marié != "oui" and $marié != "non") {
  print "<erreur>[$params] : 1er paramètre invalide</erreur>\n";
  exit;
}//if
// 第二个参数(子女数)必须为整数
if (!preg_match("/^\s*(\d+)\s*$/", $items[1], $champs)) {
  print "<erreur>[$params] : 2ième paramètre invalide</erreur>\n";
  exit;
}//if
$enfants = $champs[1];
// 第三个参数(工资)必须为整数
if (!preg_match("/^\s*(\d+)\s*$/", $items[2], $champs)) {
  print "<erreur>[$params] : 3ième paramètre invalide</erreur>\n";
  exit;
}//if
$salaire = $champs[1];
// 计算税额
$impôt = $metier->calculerImpot($marié, $enfants, $salaire);
// 返回结果
print "<impot>$impôt</impot>\n";
// 结束
exit;
  • 第 4 行:[web] 层需要 [métier] 层的类
  • 第30-40行:对[métier]层的引用被纳入会话。 如果我们记得该层 [métier] 引用了层 [dao],而后者存储了 SGBD 的数据,那么我们就能理解:
    • 客户端的第一个请求将触发对 SGBD 的访问
    • 同一客户端的后续请求将使用 [dao] 层存储的数据。因此不会访问 SGBD。
  • 第34行:构建一个[métier]层,该层与为SGBD实现的[dao]层协同工作 MySQL
  • 第35-37行:处理前一步操作中可能出现的错误。若发生错误,则向客户端发送一条<erreur>message</erreur>数据
  • 第 43 行:获取客户端发出的 'params' 参数。
  • 第46-49行:验证'params'中找到的信息数量
  • 第51-55行:验证第一条信息的有效性
  • 第56-60行:对第二条信息进行同样的验证
  • 第62-66行:对第三条信息进行同样的验证
  • 第 69 行:由 [métier] 层计算税额。
  • 第71行:将结果发送给客户

结果

需要记住的是,客户端 [client_impots_web_05] 调用的是以下文件 [data.txt]:

oui,2,200000
non,2,200000
oui,3,200000
non,3,200000
oui,5,50000
non,0,3000000

基于这些行(已婚、子女、工资),客户端查询税务计算服务器,并将结果写入文本文件 [resultats.txt]。客户端执行后,该文件的内容如下:

oui:2:200000:22504
non:2:200000:33388
oui:3:200000:16400
non:3:200000:22504
oui:5:50000:0
non:0:3000000:1354938

其中每行格式均为(已婚、子女、工资、计算出的税额)。