Skip to content

11. 税费计算:基于 Web 服务和三层架构的练习

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

  • 一个名为 [DAO](数据访问对象)的层,负责处理与 MySQL 数据库的交互
  • 一个名为[business]的层,负责计算税款
  • 一个名为 [web] 的层,负责处理与 Web 客户端的通信。

客户端脚本 [1]:

  • 将计算税款所需的三个信息项($married、$children、$salary)发送给服务器脚本
  • 在控制台显示服务器的响应

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

  • 在新的客户端会话开始时,它会使用来自 MySQL 数据库 [dbimports] 的数据填充数组。为此,它将调用 [DAO] 层。构建好的数组将被放入客户端会话中,以便在后续的客户端请求中使用。
  • 当客户端发出请求时,它会将三项信息($married、$children、$salary)传递给 [业务逻辑] 层,该层将计算税额 $tax。
  • 服务器脚本将返回计算出的税额 $tax。

11.1. 客户端脚本 (clients_impots_05_web)

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

params=$married,$children,$salary其中

  • $married 表示字符串 "yes" 或 "no"
  • $children 表示子女数量,
  • $salary 表示纳税人的工资

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

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

客户端脚本

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

客户端脚本代码如下:


<?php
 
// tax client
// error management
ini_set("display_errors", "off");
 
// ---------------------------------------------------------------------------------
// a class of utility functions
class Utilitaires {
 
  function cutNewLinechar($ligne) {
    ...
  }
 
}
 
// main -----------------------------------------------------
// definition of constants
$DATA = "data.txt";
$RESULTATS = "resultats.txt";
// server data
$HOTE = "localhost";
$PORT = 80;
$urlServeur = "/exemples-web/impots_05_web.php";
 
// taxable person parameters (marital status, number of children, annual salary)
// were placed in the $DATA text file, one line for each taxpayer
// results (marital status, number of children, annual salary, tax payable) 
// or (marital status, number of children, annual salary, error msg) are placed in
// the $RESULTATS text file, with one result per line
 
// utility class
$u = new Utilitaires();
 
// opening taxpayer data files
$data = fopen($DATA, "r");
if (!$data) {
  print "Impossible d'ouvrir en lecture le fichier des données [$DATA]\n";
  exit;
}
 
// open results file
$résultats = fopen($RESULTATS, "w");
if (!$résultats) {
  print "Impossible de créer le fichier des résultats [$RESULTATS]\n";
  exit;
}
 
// the current line of the taxpayer data file is used
while ($ligne = fgets($data, 100)) {
  // remove any end-of-line marker
  $ligne = $u->cutNewLineChar($ligne);
  // we retrieve the 3 fields married:children:salary which form $ligne
  list($marié, $enfants, $salaire) = explode(",", $ligne);
  // tax calculation
  list($erreur, $impôt) = calculerImpot($HOTE, $PORT, $urlServeur, $cookie, array($marié, $enfants, $salaire));
  // enter the result
  $résultat = $erreur ? "$marié:$enfants:$salaire:$erreur" : "$marié:$enfants:$salaire:$impôt";
  fputs($résultats, "$résultat\n");
  // following data
}
// close files
fclose($data);
fclose($résultats);
 
// end
print "Terminé...\n";
exit;
 
function calculerImpot($HOTE, $PORT, $urlServeur, &$cookie, $params) {
  // connects client to ($HOTE,$PORT,$urlServeur)
  // sends the $cookie cookie if it is non-empty. $cookie is passed by reference
  // sends $params to the server
  // exploits the single line returned by the server
 
  // open a connection on port 80 of $HOTE
  $connexion = fsockopen($HOTE, $PORT);
  // mistake?
  if (!$connexion)
    return array("erreur lors de la connexion au serveur ($HOTE, $PORT)");
  // protocol HTTP headers must end with an empty line
  // POST
  fputs($connexion, "POST $urlServeur HTTP/1.1\n");
  // Host
  fputs($connexion, "Host: localhost\n");
  // Connection
  fputs($connexion, "Connection: close\n");
// send cookie if non-empty
  if ($cookie) {
    fputs($connexion, "Cookie: $cookie\n");
  }////if
  // now we send the client instruction after encoding it
  $infos = "params=" . urlencode(implode(",", $params));
  // on indique quel type d'informations on va envoyer
  fputs($connexion, "Content-type: application/x-www-form-urlencoded\n");
  // send the size (number of characters) of the information to be sent
  fputs($connexion, "Content-length: " . strlen($infos) . "\n");
  // send an empty line
  fputs($connexion, "\n");
  // we send the news
  fputs($connexion, $infos);
  // the web server response is displayed
  // and we take care to recover any cookie
  while ($ligne = fgets($connexion, 1000)) {
    // cookie - only on 1st response
    if (!$cookie) {
      if (preg_match("/^Set-Cookie: (.*?)\s*$/", $ligne, $champs)) {
        $cookie = $champs[1];
      }//if
    }
    // as soon as there is an empty line, the HTTP response is terminated
    if (trim($ligne) == "") {
      break;
    }
  }////while
  // read line result
  $ligne = fgets($connexion, 1000);
  // close the connection
  fclose($connexion);
  // result calculation
  $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
  return array($erreur, $impôt);
}

注释

客户端脚本代码包含我们之前见过的元素:

  • 第 9–15 行:[Utilities] 类在第 3 版第 6 节中引入
  • 第 17–68 行:主程序与第 1 版第 4.2 节中的类似。唯一的区别在于第 56 行的税额计算。
  • 第 56 行:税费计算函数接受以下参数:
    • $HOST$PORT$serverURL:用于连接 Web 服务
    • $cookie:是会话 Cookie。该参数按引用传递。其值由税费计算函数设定。首次调用时,该参数无值;此后则有值。
    • array($married, $children, $salary):代表 [data.txt] 文件中的一行

税费计算函数返回一个包含两个结果的数组 ($error, $tax),其中 $error 表示可能的错误信息,$tax 表示税额。

  • 第 70–134 行:这是一个经典的 HTTP 客户端,我们曾多次遇到过此类客户端。请注意以下几点:
  • 第 83 行:参数 ($married, $children, $salary) 通过 POST 请求发送至服务器
  • 第 89–91 行:如果客户端有会话 ID,则将其发送给服务器
  • 第 93 行:创建 params 参数
  • 第 101 行:发送 `params` 参数
  • 第 104–115 行:客户端读取服务器发送的所有 HTTP 头部,直到遇到标记头部结束的空行。它利用这个机会从对第一个请求的响应中检索会话 ID。
  • 第 123–125 行:处理任何形式为 <error>message</error> 的行
  • 第 126–128 行:对任何 <tax>amount</tax> 格式的行进行相同处理
  • 第 133 行:返回结果

11.2. 税费计算 Web 服务

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

相应的 NetBeans 项目如下:

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

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

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

11.2.1. Web 服务实体 (impots_05_entities)

MySQL 数据库 [dbimpots] 中有一个名为 [impots] 的表,其中包含计算税款所需的数据 [1]:

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


<?php
 
// a tax bracket
class Tranche {
 
  // private fields
  private $limite;
  private $coeffR;
  private $coeffN;
 
  // getters and setters
  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;
  }
 
  // manufacturer
  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 表 [impots] 中某行对应的 [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 5 中预定义的 [Exception] 类
  • 第 3 行:[ImpotsException] 类的构造函数接受两个参数:
    • $message:错误消息
    • $code:错误代码

11.2.2. [dao] 层 (impots_05_dao)

[dao] 层提供对数据库数据的访问:

[dao] 层具有以下接口:

1
2
3
4
5
6
// dao interface
interface IImpotsDao {

// renders an array of Slice entities
  function getData();
}

IImpotsDao 接口仅公开了 getData 函数。该函数将 MySQL 表 [dbimpots.impots] 中的各行数据放入一个 Tranche 对象数组中。

实现类如下:


<?php
 
// dao layer
// dependencies
require_once "impots_05_entites.php";
// constants
define("TABLE", "impots");
 
// -----------------------------------------------------------------
// abstract implementation
abstract class ImpotsDaoWithPdo implements IImpotsDao {
 
// private fields
  private $dsn;
  private $user;
  private $passwd;
  private $tranches;
 
// getters and setters
  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;
  }
 
  // manufacturer
  public function __construct($dsn, $user, $passwd) {
    // save parameters
    $this->setDsn($dsn);
    $this->setUser($user);
    $this->setPasswd($passwd);
    // retrieve data from SGBD
    // connects ($user,$pwd) to base $dsn
    try {
      // connection
      $connexion = new PDO($dsn, $user, $passwd, array(PDO::ATTR_PERSISTENT => true));
      // read table $TABLE
      $requête = "select limites,coeffR,coeffN from " . TABLE;
      // executes the $requête request on the $connexion connection
      $statement = $connexion->prepare($requête);
      $statement->execute();
      // query result evaluation
      while ($colonnes = $statement->fetch()) {
        $this->tranches[] = new Tranche($colonnes[0], $colonnes[1], $colonnes[2]);
      }
      // disconnect
      $connexion=NULL;
    } catch (PDOException $e) {
      // return with error
      throw new ImpotsException($e->getMessage(), 1);
    }
  }
 
  public function getData(){
    return $this->tranches;
  }
}
  • 第 5 行:实现 [IImpotsDao] 接口需要 [impots_05_entities] 脚本中定义的类。
  • 第 11 行:抽象类的定义。抽象类是一种无法实例化的类。必须从抽象类派生出子类才能进行实例化。声明类为抽象类可能是因为该类无法实例化(其部分方法未定义),也可能是因为我们希望对其进行实例化。在此,我们不希望实例化 [ImpotsDaoWithPdo]。 我们将实例化派生类。
  • 第 11 行:[ImpotsDaoWithPdo] 类实现了 [IImpotsDao] 接口。因此,它必须定义 getData 方法。该方法位于第 72–74 行。
  • 第 14 行:$dsn(数据源名称)是一个字符串,用于唯一标识所使用的 DBMS 和数据库。
  • 第 15 行:$user 标识连接到数据库的用户
  • 第 16 行:$passwd 是前一个用户的密码
  • 第 17 行:$tranches Tranche 对象的数组,MySQL 表 [dbimpots.impots] 将存储于此数组中。
  • 第 45–70 行:类构造函数。该代码已在第 4 版第 8.2 节中出现过。请注意,[ImpotsDaoWithPdo] 对象的构造可能会失败。此时将抛出 [ImpotsException]。
  • 第 72–74 行:[IImpotsDao] 接口的 [getData] 方法。

[ImpotsDaoWithPdo] 类适用于任何数据库管理系统(DBMS)。第 45 行的类构造函数需要知道数据库的数据源名称。该字符串取决于所使用的 DBMS。 我们选择不要求该类的用户了解此数据源名称。针对每种 DBMS,都将有一个从 [ImpotsDaoWithPdo] 派生的特定类。对于 MySQL DBMS,该类如下所示:


class ImpotsDaoWithMySQL extends ImpotsDaoWithPdo {
 
  public function __construct($host, $port, $base, $user, $passwd) {
    parent::__construct("mysql:host=$host;dbname=$base;port=$port", $user, $passwd);
  }
 
}
  • 在第 3 行中,构造函数并未请求数据源名称,而是仅请求 DBMS 主机名称 ($host)、其监听端口 ($port) 以及数据库名称 ($base)。
  • 第 4 行:构建 MySQL 数据库的数据源名称,并用于调用父类的构造函数。

请注意,若要适配其他数据库管理系统,只需编写一个从 [ImpotsDaoWithPdo] 派生的相应类即可。在每种情况下,都必须构建针对所用数据库管理系统的特定数据源名称

11.2.3. [业务]层 (impots_05_metier)

[业务]层包含税费计算逻辑:

[业务]层具有以下接口:


<?php
 
// business interface
interface IImpotsMetier {
  public function calculerImpot($marié, $enfants, $salaire);
}

[IImpotsMetier] 接口仅公开了一个方法,即 [calculateTax] 方法,该方法根据以下参数计算纳税人的税额:

  • $married:根据纳税人是否已婚,返回“yes”或“no”的字符串
  • $children:纳税人拥有的子女数量
  • $salary:纳税人的工资

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

[IImpotsMetier] 接口的实现如下:


// dependencies
require_once "impots_05_dao.php";
 
// ------------------------------------------------------------------
// implementation class
class ImpotsMetier implements IImpotsMetier {
 
  // dao layer
  private $dao;
   // object array [Slice]
  private $data;
 
  // getter and 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) {
    // retrieve the data needed to calculate taxes
    $this->setDao($dao);
    $this->setData($this->dao->getData());
  }
 
  public function calculerImpot($marié, $enfants, $salaire) {
    // $marié : yes, no
    // $enfants : number of children
    // $salaire: annual salary
    // number of shares
    $marié = strtolower($marié);
    if ($marié == "oui")
      $nbParts = $enfants / 2 + 2;
    else
      $nbParts=$enfants / 2 + 1;
    // an additional 1/2 share if at least 3 children
    if ($enfants >= 3)
      $nbParts+=0.5;
    // taxable income
    $revenuImposable = 0.72 * $salaire;
    // family quotient
    $quotient = $revenuImposable / $nbParts;
    // is set at the end of the limit table to stop the following loop
    $N = count($this->data);
    $this->data[$N - 1]->setLimite($quotient);
    // tAX CALCULATION
    $i = 0;
    while ($i < $N and $quotient > $this->data[$i]->getLimite()) {
      $i++;
    }
    // because $quotient has been placed at the end of the $limites array, the previous loop
    // cannot exceed the table $limites
    // now we can calculate the tax
    return floor($revenuImposable * $this->data[$i]->getCoeffR() - $nbParts * $this->data[$i]->getCoeffN());
  }
 
}
  • 第 2 行:[business] 层需要 [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)

[business] 层包含税费计算逻辑:

[Web]层由响应Web客户端的Web服务组成。回顾一下,这些客户端通过提交以下参数向Web服务发送请求:params=married,children,salary。这是一个与我们在前几节中构建的Web服务类似的服务。其代码如下:


<?php
 
// business layer
require_once "impots_05_metier.php";
 
// error management
ini_set("display_errors", "off");
 
// uTF-8 header
header("Content-Type: text/plain; charset=utf-8");
 
// ------------------------------------------------------------------------------
// tax web service
// definition of constants
$HOTE = "localhost";
$PORT = 3306;
$BASE = "dbimpots";
$USER = "root";
$PWD = "";
// the data required to calculate the tax has been placed in the mysql table IMPOTS
// belonging to the $BASE database. The table has the following structure
// limits decimal(10,2), coeffR decimal(6,2), coeffN decimal(10,2)
// taxable person parameters (marital status, number of children, annual salary)
// are sent by the customer in the form params=marital status, number of children, annual salary
// results (marital status, number of children, annual salary, tax payable) are returned to the customer
// in the form <impot>impot</impot>
// or as <error>error</error>, if parameters are invalid
 
// retrieve the [business] layer in the session
session_start();
if (!isset($_SESSION['metier'])) {
// instantiation of the [dao] layer and the [business] layer
  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'];
 
// retrieve the line sent by the client
$params = utf8_encode(htmlspecialchars(strtolower(trim($_POST['params']))));
$items = explode(",", $params);
// there must be only 3 parameters
if (count($items) != 3) {
  print "<erreur>[$params] : nombre de paramètres invalides</erreur>\n";
  exit;
}//if
// first parameter (marital status) must be yes/no
$marié = trim($items[0]);
if ($marié != "oui" and $marié != "non") {
  print "<erreur>[$params] : 1er paramètre invalide</erreur>\n";
  exit;
}//if
// the second parameter (number of children) must be an integer
if (!preg_match("/^\s*(\d+)\s*$/", $items[1], $champs)) {
  print "<erreur>[$params] : 2ième paramètre invalide</erreur>\n";
  exit;
}//if
$enfants = $champs[1];
// the third parameter (salary) must be an integer
if (!preg_match("/^\s*(\d+)\s*$/", $items[2], $champs)) {
  print "<erreur>[$params] : 3ième paramètre invalide</erreur>\n";
  exit;
}//if
$salaire = $champs[1];
// tax calculation
$impôt = $metier->calculerImpot($marié, $enfants, $salaire);
// return the result
print "<impot>$impôt</impot>\n";
// end
exit;
  • 第 4 行:[Web] 层需要 [业务] 层的类
  • 第 30–40 行:对 [business] 层的引用作用域限定在会话内。若回顾该 [business] 层持有对 [DAO] 层的引用,而后者存储着 DBMS 数据,则可知:
    • 客户端的首次请求将触发对 DBMS 的访问
    • 来自同一客户端的后续请求将使用 [DAO] 层存储的数据。因此,不会再次访问 DBMS。
  • 第 34 行:构建一个与针对 MySQL DBMS 实现的 [DAO] 层协作的 [业务] 层
  • 第35–37行:处理前一操作中可能出现的错误。在此情况下,向客户端发送一行<error>message</error>
  • 第 43 行:检索客户端发送的 'params' 参数。
  • 第 46–49 行:检查 'params' 中找到的项目数量
  • 第 51–55 行:检查第一条信息的有效性
  • 第 56–60 行:对第二条信息执行相同检查
  • 第 62–66 行:对第三条信息进行同样的检查
  • 第 69 行:[业务] 层计算税额。
  • 第 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

基于这些行(已婚、子女、工资),客户端向税费计算服务器发起查询,并将结果写入文本文件 [results.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

其中每行数据格式为 (已婚, 子女, 工资, 计算税额)。