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






