11. 练习 IMPOTS,使用 WEB 服务和三层架构
我们将重新使用练习 IMPOTS(参见第 4.2、4.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] 的文本文件中获取上述三个参数,格式为 (已婚、子女、工资):
客户端脚本
- 将逐行读取文本文件 [data.txt]
- 向税务计算Web服务发送字符串 params=$marié,$enfants,$salaire
- 获取服务器的响应。响应可能有两种形式:
- 将服务器的响应以以下两种形式之一保存到文本文件 [resultats.txt] 中:
客户端脚本代码如下:
<?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 行:类 [ImpotsException] 继承自 PHP 中预定义的类 [Exception] 5
- 第 3 行:类 [ImpotsException] 的构造函数接受两个参数:
- $message:一条错误消息
- $code:一个错误代码
11.2.2. [dao](impots_05_dao)层
[dao] 层负责访问数据库数据:
![]() |
[dao] 层具有以下接口:
接口 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] 层的类以及实体(Tranche、ImpotsException)。
- 第 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]:
基于这些行(已婚、子女、工资),客户端查询税务计算服务器,并将结果写入文本文件 [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
其中每行格式均为(已婚、子女、工资、计算出的税额)。






