11. 应用练习 – 版本 4
该税务计算应用程序将实现以下分层结构:

我们将沿用第3版中“链接”部分的组件,并对其进行修改以适应应用程序的新架构。这通常被称为“重构”。在此我们假设应用程序所需的数据存储在文本文件中。[Dao]层将负责与这些文件的交互。
11.1. 脚本结构树

11.2. 层间交换的对象
我们将保留第 3 版中的某些对象。此处再次列出以供参考。
异常 [ExceptionImpots] 是 [Dao] 层在遇到数据访问问题或数据性质问题(数据不正确)时将抛出的异常。
<?php
// 命名空间
namespace Application;
class ExceptionImpots extends \RuntimeException {
public function __construct(string $message, int $code=0) {
parent::__construct($message, $code);
}
}
[Utilitaires] 类汇集了用于管理文本文件的有用方法(此处仅有一个方法):
<?php
// 命名空间
namespace Application;
// 一个实用函数类
abstract class Utilitaires {
public static function cutNewLinechar(string $ligne): string {
// 如果存在,则删除 $ligne 的行尾标记
$longueur = strlen($ligne); // 行长度
while (substr($ligne, $longueur - 1, 1) == "\n" or substr($ligne, $longueur - 1, 1) == "\r") {
$ligne = substr($ligne, 0, $longueur - 1);
$longueur--;
}
// 结束 - 返回该行
return($ligne);
}
}
[TaxAdminData] 类是封装税务管理数据的类:
<?php
namespace Application;
class TaxAdminData {
// 税率区间
private $limites;
private $coeffR;
private $coeffN;
// 税额计算常数
private $plafondQfDemiPart;
private $plafondRevenusCelibatairePourReduction;
private $plafondRevenusCouplePourReduction;
private $valeurReducDemiPart;
private $plafondDecoteCelibataire;
private $plafondDecoteCouple;
private $plafondImpotCouplePourDecote;
private $plafondImpotCelibatairePourDecote;
private $abattementDixPourcentMax;
private $abattementDixPourcentMin;
// 初始化
public function setFromJsonFile(string $taxAdminDataFilename): TaxAdminData {
// 读取税务数据文件的内容
$fileContents = \file_get_contents($taxAdminDataFilename);
…
// 返回对象
return $this;
}
private function check($value): \stdClass {
…
return $result;
}
// toString
public function __toString() {
// 对象的JSON字符串
return \json_encode(\get_object_vars($this), JSON_UNESCAPED_UNICODE);
}
// 获取器和设置器
public function getLimites() {
return $this->limites;
}
…
public function setLimites($limites) {
$this->limites = $limites;
return $this;
}
…
}
我们新增了一个类 [TaxPayerData],用于封装写入结果文件中的数据:
<?php
// 命名空间
namespace Application;
// 数据类
class TaxPayerData {
// 计算纳税人税款所需的数据
private $marié;
private $enfants;
private $salaire;
// 税款计算结果
private $montant;
private $surcôte;
private $décôte;
private $réduction;
private $taux;
// 设置器
public function setFromParameters(string $marié, int $nbEnfants, int $salaireAnnuel) : TaxPayerData{
// 计算税款所需的纳税人数据
$this->marié = $marié;
$this->enfants = $nbEnfants;
$this->salaire = $salaireAnnuel;
// 返回初始化后的对象
return $this;
}
// 获取器和设置器
public function getMarié() {
return $this->marié;
}
…
public function setMarié($marié) {
$this->marié = $marié;
return $this;
}
…
// toString
public function __toString() {
// 对象的 JSON 字符串
return \json_encode(\get_object_vars($this), JSON_UNESCAPED_UNICODE);
}
}
注意:请使用自动代码生成功能来生成构造函数、getter 和 setter(参见链接段落)。请注意,setter 是“流式”的。
11.3. [dao] 层
这里我们关注应用程序中的 [1] 层:

11.3.1. [InterfaceDao] 接口
[dao] 层的接口如下所示:[InterfaceDao.php]:
<?php
// 命名空间
namespace Application;
interface InterfaceDao {
// 读取纳税人数据
public function getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array;
// 读取税务机关数据(税率档次)
public function getTaxAdminData(): TaxAdminData;
// 结果记录
public function saveResults(string $resultsFilename, array $taxPayersData): void;
}
注释
- 技术规范如下:
- 纳税人的数据存储在文本文件中;
- 将税款计算结果保存到文本文件中;
- 将可能出现的错误记录到文本文件中;
- 目前尚不清楚税务部门提供的数据以何种格式存在。对于每种新格式,都需要通过一个新类来实现 [InterfaceDao] 接口;
- 接口中的方法在访问数据时若遇到不可恢复的错误,必须抛出类型为 [ExceptionImpots] 的异常;
- 第 9 行:用于获取纳税人数据的 [statut marital, nombre d’enfants, salaire annuel] 方法;
- 第一个参数是包含这些数据的文本文件名;
- 第二个参数是用于记录可能遇到的错误的文本文件名;
- 第12行:用于获取税务机关数据的方法。此处未传入任何参数,因为尚不清楚数据的存储格式;
- 第15行:用于将税款计算结果保存到文本文件中的方法,该文件的名称作为参数传入;
在编写接口 [InterfaceDao] 时,我们知道根据税务部门数据的存储方式不同,方法 [getTaxAdminData] 的实现方式也会有所不同。 因此,接口 [InterfaceDao] 将由不同的类来实现,每个类负责处理特定类型的数据存储(数组、文本文件、数据库、Web 服务)。不过,这些派生类将拥有共同的代码,即 [getTaxPayersData, saveResults] 方法的实现代码。 我们知道,该用例可通过两种方式实现(参见链接段落):
- 创建一个抽象类 C,其中包含派生类共有的代码。类 C 实现了接口 I,但某些必须在派生类中声明的方法在类 C 中被声明为抽象方法,因此类 C 本身也是抽象类。 随后创建从 C 派生的类 C1 和 C2,它们各自以自己的方式实现父类 C 中未定义(抽象)的方法;
- 创建一个与前一解决方案中的抽象类 C 几乎完全相同的特质 T。该特质不实现接口 I,因为从语法上讲它无法实现。 随后创建类 C1 和 C2,它们实现接口 I 并使用特征 T。这些类只需实现特征 T 未实现的接口 I 中的方法即可;
在此示例中,我们将使用一个名为 [TraitDao] 的特质。
11.3.2. 特征 [TraitDao]
[TraitDao] 特性的代码如下 [TraitDao.php]:
<?php
// 命名空间
namespace Application;
trait TraitDao {
// 读取纳税人数据
public function getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array {
// 纳税人数据表
$taxPayersData = [];
// 错误表
$errors = [];
// 只要处理文件,就可能会出现不少错误
try {
// 读取用户数据
// 每行数据格式为:婚姻状况、子女数、年薪
$taxPayersFile = fopen($taxPayersFilename, "r");
if (!$taxPayersFile) {
throw new ExceptionImpots("Impossible d'ouvrir en lecture les déclarations des contribuables [$taxPayersFilename]", 12);
}
// 处理用户数据文件中的当前行
// 该行格式为:婚姻状况、子女数、年薪
$num = 1; // 当前行号
$nbErreurs = 0; // 遇到的错误数量
while ($ligne = fgets($taxPayersFile, 100)) {
// 忽略空行
$ligne = trim($ligne);
if (strlen($ligne) == 0) {
// 下一行
$num++;
// 重新循环
continue;
}
// 移除可能存在的换行符
$ligne = Utilitaires::cutNewLineChar($ligne);
// 提取组成 $ligne 的 3 个字段:已婚:子女:工资
list($marié, $enfants, $salaire) = explode(",", $ligne);
// 对其进行验证
// 婚姻状况必须为“是”或“否”
$marié = trim(strtolower($marié));
$erreur = ($marié !== "oui" and $marié !== "non");
if (!$erreur) {
// 子女数量必须为整数
$enfants = trim($enfants);
if (!preg_match("/^\d+$/", $enfants)) {
$erreur = TRUE;
} else {
$enfants = (int) $enfants;
}
}
if (!$erreur) {
// 工资为不包含欧元分数的整数
$salaire = trim($salaire);
if (!preg_match("/^\d+$/", $salaire)) {
$erreur = TRUE;
} else {
$salaire = (int) $salaire;
}
}
// 错误?
if ($erreur) {
$errors[] = "la ligne [$num] du fichier [$taxPayersFilename] est erronée";
$nbErreurs++;
} else {
// 保存信息
$taxPayersData[] = (new TaxPayerData())->setFromParameters($marié, $enfants, $salaire);
}
// 下一行
$num++;
}
// 是否已到达文件末尾?
if (!feof($taxPayersFile)) {
// 因读取错误退出循环
throw new ExceptionImpots("Erreur lors de la lecture de la ligne n° [$num] du fichier [$taxPayersFilename]");
} else {
// 因遇到文件结束标记而退出循环
// 将错误保存到文本文件中
$this->saveString($errorsFilename, implode("\n", $errors));
// 函数结果
return $taxPayersData;
}
} finally {
// 若文件已打开则关闭
if ($taxPayersFile) {
fclose($taxPayersFile);
}
}
}
// 记录结果
public function saveResults(string $resultsFilename, array $taxPayersData): void {
// 将数组 [$taxPayersData] 保存到文本文件 [$resultsFileName] 中
// 如果文本文件 [$resultsFileName] 不存在,则创建该文件
$this->saveString($resultsFilename, implode("\n", $taxPayersData));
}
// 将表中的结果记录到文本文件中
private function saveString(string $fileName, string $data): void {
// 将表格 [$data] 保存到文本文件 [$fileName] 中
// 如果文本文件 [$fileName] 不存在,则创建该文件
if (file_put_contents($fileName, $data) === FALSE) {
throw new ExceptionImpots("Erreur lors de l'enregistrement de données dans le fichier texte [$fileName]");
}
}
}
注释
- 第 6 行:此处定义的是一个特质,而非类;
- 第 9-89 行:方法 [getTaxPayersData] 实现了接口 [InterfaceDao] 中同名的方法。 它从名为 [$taxPayersFilename] 的文本文件中提取纳税人 [statut marital, nombre d’enfants, salaire annuel] 的数据。 并将这些数据以 [$taxPayersData] 数组的形式返回,其中包含类型为 [TaxPayerData] 的元素(第 67、81 行);
- 方法 [getTaxPayersData] 与上文链接段落中描述的方法 [AbstractBaseImpots::executeBatchImpots] 非常相似,但存在以下区别:
- 方法 [getTaxPayersData] 仅用于检索纳税人数据,不进行税额计算。此功能由层 [métier] 负责;
- 与方法 [executeBatchImpots] 一样,它会报告错误。此处错误首先存储在数组 [$errors] 中(第 13 行),该数组在处理结束时会被保存到文本文件中(第 79 行)。根据具体情况,该数组可能为空,也可能不为空;
- 若发生不可恢复的错误,将抛出类型为 [ExceptionImpots] 的异常(第 20、75 行);
- 第73行:请注意第26-71行循环结束时的处理逻辑。 实际上,函数 [fgets] 存在一个缺陷:无论行读取操作是遇到文件结束标记,还是因错误导致读取失败,它都会将布尔值 FALSE 设为真。 为区分这两种情况,我们使用函数 [feof] 检测是否已到达文件末尾。若未到达文件末尾,则说明发生了错误,此时将抛出异常;
- 第83-88行:无论在处理文件时是否发生异常,都会执行[finally];
- 第 85 行:如果文件已被打开,则该文件的“句柄”[$taxPayersFile] 取布尔值 TRUE,否则取 FALSE;
- 第99-105行:调用第79行中使用的私有方法[saveString],将错误数组写入文本文件;
- 第 99 行:方法 [saveString] 接收两个参数:
- [string $filename],即用于存储数据的文本文件名;
- [string $data],即要写入文本文件的字符串。该字符串将由多行组成,每行以换行符 \n 结尾;
- 第 102 行:函数 PHP [file_puts_contents] 将字符串写入文本文件。 该函数负责打开文件、将字符串写入其中并关闭文件。若发生错误,则返回布尔值 FALSE;
- 第 103 行:若发生错误,则抛出异常;
- 第 92-96 行:实现接口 [InterfaceDao] 的方法 [saveResults]。再次使用了私有方法 [saveString]。 此处 [saveString] 的第二个参数是一个字符串,由数组 [$taxPayersData] 构建而成,该数组的元素类型为 [TaxPayerData]。人们可能会好奇该操作的结果会是什么:
implode("\n", $taxPayersData)
我们在类 [TaxPayerData](链接段落)中定义了以下方法 [__toString]:
public function __toString() {
// 对象的JSON字符串
return \json_encode(\get_object_vars($this), JSON_UNESCAPED_UNICODE);
}
该操作
implode("\n", $taxPayersData)
将把数组 [$taxPayersData] 的每个元素(通过其方法 [__toString] 转换为字符串)与换行符 \n 拼接起来。这将生成如下格式的字符串:
json1\njson2\n…
结论
[TraitDao] 类实现了 [InterfaceDao] 接口中的两个方法:[getTaxPayersData] 和 [saveResults]:
<?php
// 命名空间
namespace Application;
interface InterfaceDao {
// 读取纳税人数据
public function getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array;
// 读取税务机关数据(税率档次)
public function getTaxAdminData(): TaxAdminData;
// 结果记录
public function saveResults(string $resultsFilename, array $taxPayersData): void;
}
接下来我们需要实现 [getTaxAdminData] 方法,该方法用于从税务管理部门获取数据。
11.3.3. 类 [ImpotsWithTaxAdminDataInJsonFile]
类 [ImpotsWithTaxAdminDataInJsonFile] 以如下方式实现了接口 [InterfaceDao]:
<?php
// 命名空间
namespace Application;
// 定义类ImpotsWithDataInFile
class DaoImpotsWithTaxAdminDataInJsonFile implements InterfaceDao {
// 使用属性
use TraitDao;
// 类型为 TaxAdminData 的对象,其中包含各税档的数据
private $taxAdminData;
// 构造函数
public function __construct(string $taxAdminDataFilename) {
// 需要初始化属性 [$this->taxAdminData]
$this->taxAdminData = (new TaxAdminData())->setFromJsonFile($taxAdminDataFilename);
}
// 返回用于计算税款的数据
public function getTaxAdminData(): TaxAdminData {
return $this->taxAdminData;
}
}
注释
- 第 7 行:类 [ImpotsWithTaxAdminDataInJsonFile] 实现了接口 [InterfaceDao];
- 第 9 行:类 [ImpotsWithTaxAdminDataInJsonFile] 使用了特征 [traitDao],而该特征已知实现了接口 [InterfaceDao] 中的方法 [getTaxPayersData] 和 [saveResults]。因此,类 [ImpotsWithTaxAdminDataInJsonFile] 只需实现方法 [getTaxAdminData],该方法用于获取税务管理数据;
- 第 11 行:第 20-22 行中的 [getTaxAdminData] 方法返回的 [TaxAdminData] 类型属性。该属性由第 14-17 行中的构造函数初始化;
我们已经完成了应用程序中[dao]层的开发:现在有一个类完全实现了我们设定的[InterfaceDao]接口。接下来我们可以转向[métier]层。
11.4. [métier] 层
接下来我们将实现架构中的 [2] 层:

11.4.1. [InterfaceMétier] 接口
[métier] 层的接口如下:
<?php
// 命名空间
namespace Application;
interface InterfaceMetier {
// 计算纳税人的税款
public function calculerImpot(string $marié, int $enfants, int $salaire): array;
// 批处理模式下的税款计算
public function executeBatchImpots(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void;
}
注释
- 第 9 行:[InterfaceMétier] 接口能够计算特定纳税人的税额,前提是提供以下信息:婚姻状况、子女数量、年薪。 方法 [calculerImpot] 不使用 [dao] 层,因此不会抛出异常;
- 第 9 行:接口 [InterfaceMétier] 还可以计算一组纳税人的税额,这些纳税人的数据汇总在名为 [$taxPayersFileName] 的文本文件中。它将结果写入名为 [$resultsFileName] 的文本文件中。 方法 [executeBatchImpots] 必须调用负责文件系统访问的 [dao] 层。 此时,[dao]层可能会抛出异常,而方法[executeBatchImpots]不会捕获这些异常:它将允许异常向上传递至主脚本。非致命错误将记录在名为[$errorsFileName]的文本文件中;
- 第 9 行:方法 [calculerImpot] 是一个纯粹的 [métier] 方法。它不关心所用数据的来源;
- 第 12 行:方法 [executeBatchImpots] 将调用底层 [dao] 进行文本文件的读写操作。它将反复调用业务方法 [calculerImpot];
11.4.2. [Metier] 类
类 [Metier] 以如下方式实现了接口 [InterfaceMetier]:
<?php
// 命名空间
namespace Application;
class Metier implements InterfaceMetier {
// DAO 层
private $dao;
// 税务管理数据
private $taxAdminData;
//---------------------------------------------
// [dao] 层的设置器
public function setDao(InterfaceDao $dao) {
$this->dao = $dao;
return $this;
}
public function __construct(InterfaceDao $dao) {
// 在 [dao] 层中存储一个引用
$this->dao = $dao;
// 检索用于计算税款的数据
// 方法 [getTaxAdminData] 可能抛出异常 ExceptionImpots
// 随后将其回传至调用代码
$this->taxAdminData = $this->dao->getTaxAdminData();
}
// 计算税额
// --------------------------------------------------------------------------
public function calculerImpot(string $marié, int $enfants, int $salaire): array {
…
// 结果
return ["impôt" => floor($impot), "surcôte" => $surcôte, "décôte" => $décôte, "réduction" => $réduction, "taux" => $taux];
}
// --------------------------------------------------------------------------
private function calculerImpot2(string $marié, int $enfants, float $salaire): array {
…
// 结果
return ["impôt" => $impôt, "surcôte" => $surcôte, "taux" => $coeffR[$i]];
}
// revenuImposable=年薪-免税额
// 免税额有最低和最高限额
private function getRevenuImposable(float $salaire): float {
…
// 结果
return floor($revenuImposable);
}
// 计算可能的折减
private function getDecôte(string $marié, float $salaire, float $impots): float {
…
// 结果
return ceil($décôte);
}
// 计算可能的减额
private function getRéduction(string $marié, float $salaire, int $enfants, float $impots): float {
…
// 结果
return ceil($réduction);
}
// 批处理模式下的税款计算
public function executeBatchImpots(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void {
…
// 记录结果
$this->dao->saveResults($resultsFileName, $results);
}
}
注释
- 第 6 行:类 [Metier] 实现了接口 [InterfaceMetier], 即方法 [calculerImpot](第 30-34 行)和 [executeBatchImpots](第 66-70 行);
- 第 8 行:对 [dao] 层的引用。 必须存在该引用,以便 [métier] 层在需要外部数据时知道向谁请求。该属性将通过第 14-17 行的 setter 方法或第 19-26 行的构造函数进行初始化;
- 第 10 行:类型为 [TaxAdminData] 的对象,封装了税务管理数据。这些数据是业务方法 [calculerImpot] 所必需的。该属性通过第 19-26 行的构造函数进行初始化;
- 第19-26行:构造函数初始化该类的两个属性:
- 属性 [$dao] 通过作为构造函数参数传递的引用进行初始化。 需要注意的是,该参数的类型与接口 [InterfaceDao] 相同,因此任何实现该接口的类均可初始化类 [Metier];
- 通过调用[dao]层中的[getTaxAdminData]方法来初始化[$taxAdminData]属性;
由此可知,当方法 [calculerImpots] 和 [executeBatchImpots] 执行时,两个属性 [$dao] 和 [$taxAdminData] 均被初始化。
方法 [calculerImpots] 如下:
public function calculerImpot(string $marié, int $enfants, int $salaire): array {
// $marié:是,否
// $enfants:子女数量
// $salaire:年薪
// $this->taxAdminData:税务部门数据
//
// 验证是否已获取税务部门数据
if ($this->taxAdminData === NULL) {
$this->taxAdminData = $this->getTaxAdminData();
}
// 计算含子女的税额
$result1 = $this->calculerImpot2($marié, $enfants, $salaire);
$impot1 = $result1["impôt"];
// 计算不含子女的税额
if ($enfants != 0) {
$result2 = $this->calculerImpot2($marié, 0, $salaire);
$impot2 = $result2["impôt"];
// 应用家庭商数上限
$plafonDemiPart = $this->taxAdminData->getPlafondQfDemiPart();
if ($enfants < 3) {
// $PLAFOND_QF_DEMI_PART 欧元(前两名子女)
$impot2 = $impot2 - $enfants * $plafonDemiPart;
} else {
// $PLAFOND_QF_DEMI_PART 欧元(前两名子女),后续子女金额翻倍
$impot2 = $impot2 - 2 * $plafonDemiPart - ($enfants - 2) * 2 * $plafonDemiPart;
}
} else {
$impot2 = $impot1;
$result2 = $result1;
}
// 取最高税率
if ($impot1 > $impot2) {
$impot = $impot1;
$taux = $result1["taux"];
$surcôte = $result1["surcôte"];
} else {
$surcôte = $impot2 - $impot1 + $result2["surcôte"];
$impot = $impot2;
$taux = $result2["taux"];
}
// 计算可能的减免额
$décôte = $this->getDecôte($marié, $salaire, $impot);
$impot -= $décôte;
// 计算可能的减税额
$réduction = $this->getRéduction($marié, $salaire, $enfants, $impot);
$impot -= $réduction;
// 结果
return ["impôt" => floor($impot), "surcôte" => $surcôte, "décôte" => $décôte, "réduction" => $réduction, "taux" => $taux];
}
注释
- 此代码属于第 3 版中的 [AbstractBaseImpots::calculerImpot] 方法,具体说明见链接段落。私有方法 [calculerImpot2, getDecôte, getRéduction, getRevenuImposable] 也是如此;
方法 [Metier::executeBatchImpots] 如下:
public function executeBatchImpots(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void {
// 上报来自 [dao] 层的异常
// 获取纳税人数据
$taxPayersData = $this->dao->getTaxPayersData($taxPayersFileName, $errorsFileName);
// 结果表
$results = [];
// 对结果进行分析
foreach ($taxPayersData as $taxPayerData) {
// 计算税款
$result = $this->calculerImpot(
$taxPayerData->getMarié(),
$taxPayerData->getEnfants(),
$taxPayerData->getSalaire());
// 填写[$taxPayerData]
$taxPayerData->setMontant($result["impôt"]);
$taxPayerData->setDécôte($result["décôte"]);
$taxPayerData->setSurCôte($result["surcôte"]);
$taxPayerData->setTaux($result["taux"]);
$taxPayerData->setRéduction($result["réduction"]);
// 将结果填入结果表
$results [] = $taxPayerData;
}
// 保存结果
$this->dao->saveResults($resultsFileName, $results);
}
注释
- 第 1 行:该方法必须针对文本文件 [$taxPayersFileName] 中找到的每个纳税人,反复调用方法 [calculerImpot]。并将结果写入名为 [$resultsFileName] 的文本文件中。 遇到的非致命错误将记录在名为 [$errorsFileName] 的文本文件中。该方法本身不会抛出异常,但会将 [dao] 层抛出的异常向上传递;
- 第 4 行:向 [dao] 层请求纳税人数据。该层返回一个 [TaxPayerData] 类型的数组,该类型是 [marié, nbEnfants, salaire, montant, décôte, réduction, surcôte, taux] 属性类的实例(参见链接段落)。 如果此处发生异常,由于未被 catch 捕获,异常将自动回传至调用代码。这意味着一旦发生异常,第 6 行将不会执行;
- 第 6 行:类型为 [TaxPayerData] 的结果数组;
- 第 8-22 行:计算纳税人数组 [$taxPayersData] 中每个元素的税额。为此,调用内部方法 [calculerImpot](第 10 行);
- 第15-19行:利用所得结果初始化[TaxPayerData]表中尚未初始化的属性;
- 第21行:将所得结果累加到结果表[$results]中;
- 第24行:在为所有纳税人计算完税款后,结果被保存到文本文件中。这项工作由[dao]层负责;
结论
通常,[métier] 层编写起来相当简单,因为它调用的是 [dao] 层,而后者负责管理数据访问及相应的错误处理。
11.5. 主脚本
现在我们编写架构中 [3] 层的脚本:

主脚本如下所示:[main.php]:
<?php
// 严格遵守函数参数的声明类型
declare (strict_types=1);
// 命名空间
namespace Application;
// 通过 PHP 进行错误处理
//ini_set("display_errors", "0");
// 接口和类的包含
require_once __DIR__ . "/TaxAdminData.php";
require_once __DIR__ . "/TaxPayerData.php";
require_once __DIR__ . "/ExceptionImpots.php";
require_once __DIR__ . "/Utilitaires.php";
require_once __DIR__ . "/InterfaceDao.php";
require_once __DIR__ . "/TraitDao.php";
require_once __DIR__ . "/DaoImpotsWithTaxAdminDataInJsonFile.php";
require_once __DIR__ . "/InterfaceMetier.php";
require_once __DIR__ . "/Metier.php";
// 测试 -----------------------------------------------------
// 常量定义
const TAXPAYERSDATA_FILENAME = "taxpayersdata.txt";
const RESULTS_FILENAME = "resultats.txt";
const ERRORS_FILENAME = "errors.txt";
const TAXADMINDATA_FILENAME = "taxadmindata.json";
try {
// 创建图层 [dao]
$dao = new DaoImpotsWithTaxAdminDataInJsonFile(TAXADMINDATA_FILENAME);
// 创建图层 [métier]
$métier = new Metier($dao);
// 批处理模式下的税款计算
$métier->executeBatchImpots(TAXPAYERSDATA_FILENAME, RESULTS_FILENAME, ERRORS_FILENAME);
} catch (ExceptionImpots $ex) {
// 显示错误
print $ex->getMessage() . "\n";
}
// 结束
print "Terminé\n";
exit;
注释
- 第 24 行:纳税人数据文件的名称;
- 第25行:结果文件名;
- 第26行:错误文件的名称;
- 第27行:包含税务机关数据的文件jSON的名称;
- 第31行:创建图层[dao];
- 第33行:基于该[dao]图层创建[métier]图层;
- 第35行:执行[métier]层的[executeBatchImpots]方法;
- 第36-39行:我们已经看到,[métier]层可能会抛出异常。这些异常在此处被拦截;
11.6. 视觉测试
11.6.1. 测试 1
使用以下纳税人文件 [taxpayersdata.txt]:
oui,2,55555
oui,2,50000
oui,3,50000
non,2,100000
non,3x,100000
oui,3,100000
oui,5,100000x
non,0,100000
oui,2,30000
non,0,200000
oui,3,200000
将生成如下错误文件 [errors.txt]:
la ligne [5] du fichier [taxpayersdata.txt] est erronée
la ligne [7] du fichier [taxpayersdata.txt] est erronée
以及以下结果文件 [resultats.txt]:
11.6.2. 测试 2
在主脚本中,为纳税人文件指定了一个不存在的文件名:
此时控制台显示的结果如下:
Warning: fopen(taxpayersdata2.txt): failed to open stream: No such file or directory in C:\Data\st-2019\dev\php7\poly\scripts-console\impots\version-04\TraitDao.php on line 18
Impossible d'ouvrir en lecture les déclarations des contribuables [taxpayersdata2.txt]
Terminé
Done.
- 第 1 行:PHP 解释器的警告;
- 第 2 行:[dao] 层抛出的异常错误消息;
可以屏蔽解释器的错误消息 PHP:

上述代码的第 21 行要求不显示 PHP 错误。在开发阶段,必须显示这些错误。在生产环境中,则需将其隐藏。
执行结果如下:
Impossible d'ouvrir en lecture les déclarations des contribuables [taxpayersdata2.txt]
Terminé
11.7. 测试 [Codeception]
视觉测试非常不充分:
- 通常仅进行少量测试;
- 在进行目视检查时,注意力或多或少会有所疏忽,导致细节可能被遗漏;
在实际的职业开发中,测试由专门负责此项工作的测试人员编写,这是他们的主要职责。因此,他们会力求使测试尽可能全面。为此,他们会使用测试框架。
本文将使用Codeception测试框架 [https://codeception.com/],因为它可集成到NetBeans中。这是一个功能极其丰富的框架。 我们仅会使用其中部分功能。其核心思想是:在应用程序练习的每个新版本发布后,能快速验证其是否正常运行。成功的测试结果能让开发者对其编写的代码充满信心,这至关重要。
11.7.1. 安装 [Codeception] 框架
与许多 PHP 库一样,[Codeception] 框架需通过 [Composer] 进行安装。因此,我们需要打开 Laragon 终端(参见链接段落)。
首先,我们需要安装测试框架 PHPUnit 和 [https://phpunit.de/]。实际上,Codeception 底层使用的是 PHPUnit 框架:

接下来,我们安装 Codeception 框架:

就这样。现在让我们看看如何将 [Codeception] 集成到 NetBeans 中。
11.7.2. 将 [CodeCeption] 集成到 NetBeans 中

- 在 [1-2] 中,可访问项目属性;
- 在 [3-4] 中,将 [Codeception] 设为该项目的测试框架之一;


- 在 [5-8] 中,为该项目初始化框架 [Codeception];

- 在 [9] 中,创建了一个名为 [tests] 的文件夹,以及一个名为 [codeception.yml] 的配置文件,位于 [10-11] 中。 文件 [11] 与文件 [10] 相同。Codeception 只是创建了一个名为 [Important Files] 的文件夹,以便为文件 [10] 赋予特殊含义;
- 在 [12-13] 中,我们又回到了项目属性;

- 在 [14-16] 中,将文件夹 [tests] [16] 指定为项目的测试文件夹;
- 在 [16] 中,文件夹 [tests] 随即以新名称 [Test Files] 出现。该文件夹在项目 PHP 中的存在表明,该项目集成了一个编程测试框架;
- 我们将测试创建在文件夹 [unit] [17] 中;
11.7.3. [dao] 层的测试

- 我们将所有测试创建在文件夹 [unit] [1] 中;
- 测试类的名称 [Codeception] 必须以关键字 [Test] 结尾,否则这些类将不会被识别为测试类;
我们的测试类 [Codeception] 将采用以下形式:[https://codeception.com/docs/05-UnitTests]:
<?php
// 严格遵守函数参数的声明类型
declare (strict_types=1);
// 命名空间
namespace Application;
// 加载测试环境
…
class DaoTest extends \Codeception\Test\Unit {
// 测试属性
private $attribut1;
public function __construct() {
parent::__construct();
// 测试环境的初始化
…
}
// 测试
public function testTaxAdminData() {
// 测试
$this->assertEquals($expected, $actual);
$this->assertEqualsWithDelta($expected, $actual, $delta);
$this->assertTrue($actual);
$this->assertFalse($actual);
$this->assertNull($actual);
$this->assertEmpty($actual);
$this→assertSame($expected, $actual);
…
}
}
注释
- 第 7 行:测试类将位于与被测应用程序相同的命名空间中;
- 第9-10行:此处包含用于加载被测类和接口的操作[require];
- 第12行:测试类的名称必须以关键字[Test]结尾。该类必须继承自类[\Codeception\Test\Unit];
- 第16-20行:构造函数将用于初始化测试环境;
- 第 23 行:测试方法的名称必须以关键字 [test] 开头;
- 第25-31行:可以使用各种测试方法;
测试类 [DaoTest] 将如下所示:
<?php
// 严格遵守函数参数的声明类型
declare (strict_types=1);
// 命名空间
namespace Application;
// 常量
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-04");
define("VENDOR", "C:/myprograms/laragon-lite/www/vendor");
// 接口和类的包含
require_once ROOT . "/TaxAdminData.php";
require_once ROOT . "/TaxPayerData.php";
require_once ROOT . "/ExceptionImpots.php";
require_once ROOT . "/Utilitaires.php";
require_once ROOT . "/InterfaceDao.php";
require_once ROOT . "/TraitDao.php";
require_once ROOT . "/DaoImpotsWithTaxAdminDataInJsonFile.php";
require_once ROOT . "/InterfaceMetier.php";
require_once ROOT . "/Metier.php";
require_once VENDOR. "/autoload.php";;
// 测试 -----------------------------------------------------
// 常量定义
const TAXADMINDATA_FILENAME = "taxadmindata.json";
class DaoTest extends \Codeception\Test\Unit {
// TaxAdminData
private $taxAdminData;
public function __construct() {
parent::__construct();
// 创建图层 [dao]
$dao = new DaoImpotsWithTaxAdminDataInJsonFile(ROOT . "/" . TAXADMINDATA_FILENAME);
$this->taxAdminData = $dao->getTaxAdminData();
}
// 测试
public function testTaxAdminData() {
…
}
}
注释
为了构建应用练习某版本的测试,我们将使用与该版本主脚本相同的环境。版本 04 的环境是以下脚本 [main.php]:
<?php
// 严格遵守函数参数的声明类型
declare (strict_types=1);
// 命名空间
namespace Application;
// 通过 PHP 进行错误处理
ini_set("display_errors", "0");
// 接口和类的包含
require_once __DIR__ . "/TaxAdminData.php";
require_once __DIR__ . "/TaxPayerData.php";
require_once __DIR__ . "/ExceptionImpots.php";
require_once __DIR__ . "/Utilitaires.php";
require_once __DIR__ . "/InterfaceDao.php";
require_once __DIR__ . "/TraitDao.php";
require_once __DIR__ . "/DaoImpotsWithTaxAdminDataInJsonFile.php";
require_once __DIR__ . "/InterfaceMetier.php";
require_once __DIR__ . "/Metier.php";
// 测试 -----------------------------------------------------
// 常量定义
const TAXPAYERSDATA_FILENAME = "taxpayersdata.txt";
const RESULTS_FILENAME = "resultats.txt";
const ERRORS_FILENAME = "errors.txt";
const TAXADMINDATA_FILENAME = "taxadmindata.json";
try {
// 创建图层 [dao]
$dao = new DaoImpotsWithTaxAdminDataInJsonFile(TAXADMINDATA_FILENAME);
// 创建图层 [métier]
$métier = new Metier($dao);
// 批处理模式下的税款计算
$métier->executeBatchImpots(TAXPAYERSDATA_FILENAME, RESULTS_FILENAME, ERRORS_FILENAME);
} catch (ExceptionImpots $ex) {
// 显示错误
print $ex->getMessage() . "\n";
}
// 结束
print "Terminé\n";
exit;
为了测试 [dao] 层,在测试类中:
- 我们将采用 [main.php] 第 13-27 行中的环境;
- 在测试类的构造函数中,我们按照第31行所示构建[dao]层;
- 编写测试方法;
我们将对所有测试类都采用这种方式。
让我们回到测试类的完整代码:
<?php
// 严格遵守函数参数的声明类型
declare (strict_types=1);
// 命名空间
namespace Application;
// 常量
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-04");
define("VENDOR", "C:/myprograms/laragon-lite/www/vendor");
// 接口和类的包含
require_once ROOT . "/TaxAdminData.php";
require_once ROOT . "/TaxPayerData.php";
require_once ROOT . "/ExceptionImpots.php";
require_once ROOT . "/Utilitaires.php";
require_once ROOT . "/InterfaceDao.php";
require_once ROOT . "/TraitDao.php";
require_once ROOT . "/DaoImpotsWithTaxAdminDataInJsonFile.php";
require_once ROOT . "/InterfaceMetier.php";
require_once ROOT . "/Metier.php";
require_once VENDOR. "/autoload.php";;
// 测试 -----------------------------------------------------
// 常量定义
const TAXADMINDATA_FILENAME = "taxadmindata.json";
class DaoTest extends \Codeception\Test\Unit {
// TaxAdminData
private $taxAdminData;
public function __construct() {
parent::__construct();
// 创建 [dao] 层
$dao = new DaoImpotsWithTaxAdminDataInJsonFile(ROOT . "/" . TAXADMINDATA_FILENAME);
$this->taxAdminData = $dao->getTaxAdminData();
}
// 测试
public function testTaxAdminData() {
// 计算常量
$this->assertEquals(1551, $this->taxAdminData->getPlafondQfDemiPart());
$this->assertEquals(21037, $this->taxAdminData->getPlafondRevenusCelibatairePourReduction());
$this->assertEquals(42074, $this->taxAdminData->getPlafondRevenusCouplePourReduction());
$this->assertEquals(3797, $this->taxAdminData->getValeurReducDemiPart());
$this->assertEquals(1196, $this->taxAdminData->getPlafondDecoteCelibataire());
$this->assertEquals(1970, $this->taxAdminData->getPlafondDecoteCouple());
$this->assertEquals(1595, $this->taxAdminData->getPlafondImpotCelibatairePourDecote());
$this->assertEquals(2627, $this->taxAdminData->getPlafondImpotCouplePourDecote());
$this->assertEquals(12502, $this->taxAdminData->getAbattementDixPourcentMax());
$this->assertEquals(437, $this->taxAdminData->getAbattementDixPourcentMin());
// 税率区间
$this->assertSame([9964.0, 27519.0, 73779.0, 156244.0, 0.0], $this->taxAdminData->getLimites());
$this->assertSame([0.0, 0.14, 0.30, 0.41, 0.45], $this->taxAdminData->getCoeffR());
$this->assertSame([0.0, 1394.96, 5798.0, 13913.69, 20163.45], $this->taxAdminData->getCoeffN());
}
}
注释
- 第10-25行:加载测试所需的环境并定义常量;
- 第31-36行:构建[dao]层(第34行),随后初始化第29行的[$taxAdminData]属性。该属性包含税务管理数据;
- 第39-55行:唯一的测试方法。该方法用于验证[$taxAdminData]属性的内容是否符合预期;
- 第41-50行:验证税额计算常量;
- 第52-55行:税率档次的验证。方法[assertSame]用于验证两个PHP实体(此处为数组)是否相同;
要执行此测试类,请按以下步骤操作:

- 在 [1-2] 中执行测试;
- [3]:测试结果窗口;
- [4]:已执行的测试类;
- [5]:测试结果。此处唯一的测试方法已通过;
- [6]:当测试失败或更常见的情况是未执行任何测试时,需查看[6]窗口。通常是测试环境加载失败,导致无法执行任何测试。 [6]中显示的错误与执行常规PHP脚本时出现的错误相同;
下面展示一个测试失败的示例:
在测试类中,我们在常量定义中引入了一个错误:
// 常量
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-04x");
然后执行测试。得到的结果如下:

在窗口 [4] 中:

11.7.4. [métier] 层的测试
测试类 [MetierTest] 遵循与类 [DaoTest] 相同的构建规则,但包含更多的测试方法:
<?php
// 严格遵守函数参数的声明类型
declare (strict_types=1);
// 命名空间
namespace Application;
// 常量
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-04");
define("VENDOR", "C:/myprograms/laragon-lite/www/vendor");
// 接口和类的包含
require_once ROOT . "/TaxAdminData.php";
require_once ROOT . "/TaxPayerData.php";
require_once ROOT . "/ExceptionImpots.php";
require_once ROOT . "/Utilitaires.php";
require_once ROOT . "/InterfaceDao.php";
require_once ROOT . "/TraitDao.php";
require_once ROOT . "/DaoImpotsWithTaxAdminDataInJsonFile.php";
require_once ROOT . "/InterfaceMetier.php";
require_once ROOT . "/Metier.php";
require_once VENDOR. "/autoload.php";;
// 测试 -----------------------------------------------------
// 常量定义
const TAXADMINDATA_FILENAME = "taxadmindata.json";
class MetierTest extends \Codeception\Test\Unit {
// 业务层
private $métier;
public function __construct() {
parent::__construct();
// 创建 [dao] 层
$dao = new DaoImpotsWithTaxAdminDataInJsonFile(ROOT . "/" . TAXADMINDATA_FILENAME);
// 创建层[métier]
$this->métier = new Metier($dao);
}
// 测试
public function test1() {
$result = $this->métier->calculerImpot("oui", 2, 55555);
$this->assertEqualsWithDelta(2815, $result["impôt"], 1);
$this->assertEqualsWithDelta(0, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.14, $result["taux"]);
}
public function test2() {
$result = $this->métier->calculerImpot("oui", 2, 50000);
$this->assertEqualsWithDelta(1385, $result["impôt"], 1);
$this->assertEqualsWithDelta(0, $result["surcôte"], 1);
$this->assertEqualsWithDelta(384, $result["décôte"], 1);
$this->assertEqualsWithDelta(347, $result["réduction"], 1);
$this->assertEquals(0.14, $result["taux"]);
}
public function test3() {
$result = $this->métier->calculerImpot("oui", 3, 50000);
$this->assertEqualsWithDelta(0, $result["impôt"], 1);
$this->assertEqualsWithDelta(0, $result["surcôte"], 1);
$this->assertEqualsWithDelta(720, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.14, $result["taux"]);
}
public function test4() {
$result = $this->métier->calculerImpot("non", 2, 100000);
$this->assertEqualsWithDelta(19884, $result["impôt"], 1);
$this->assertEqualsWithDelta(4480, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.41, $result["taux"]);
}
public function test5() {
$result = $this->métier->calculerImpot("non", 3, 100000);
$this->assertEqualsWithDelta(16782, $result["impôt"], 1);
$this->assertEqualsWithDelta(7176, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.41, $result["taux"]);
}
public function test6() {
$result = $this->métier->calculerImpot("oui", 3, 100000);
$this->assertEqualsWithDelta(9200, $result["impôt"], 1);
$this->assertEqualsWithDelta(2180, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.3, $result["taux"]);
}
public function test7() {
$result = $this->métier->calculerImpot("oui", 5, 100000);
$this->assertEqualsWithDelta(4230, $result["impôt"], 1);
$this->assertEqualsWithDelta(0, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.14, $result["taux"]);
}
public function test8() {
$result = $this->métier->calculerImpot("non", 0, 100000);
$this->assertEqualsWithDelta(22986, $result["impôt"], 1);
$this->assertEqualsWithDelta(0, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.41, $result["taux"]);
}
public function test9() {
$result = $this->métier->calculerImpot("oui", 2, 30000);
$this->assertEqualsWithDelta(0, $result["impôt"], 1);
$this->assertEqualsWithDelta(0, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0, $result["taux"]);
}
public function test10() {
$result = $this->métier->calculerImpot("non", 0, 200000);
$this->assertEqualsWithDelta(64210, $result["impôt"], 1);
$this->assertEqualsWithDelta(7498, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.45, $result["taux"]);
}
public function test11() {
$result = $this->métier->calculerImpot("oui", 3, 200000);
$this->assertEqualsWithDelta(42842, $result["impôt"], 1);
$this->assertEqualsWithDelta(17283, $result["surcôte"], 1);
$this->assertEqualsWithDelta(0, $result["décôte"], 1);
$this->assertEqualsWithDelta(0, $result["réduction"], 1);
$this->assertEquals(0.41, $result["taux"]);
}
}
注释
- 第 10-25 行:加载定义测试环境的文件。该环境与 [dao] 层相同;
- 第31-37行:实例化[dao]和[métier]层;
- 第40-47行:进行税额计算测试;
- 第 41 行:使用 [métier] 层进行某项税款计算;
- 第42-46行:验证所得结果是否与税务部门模拟器[https://www3.impots.gouv.fr/simulateur/calcul_impot/2019/simplifie/index.htm]的结果一致;
- 第23-26行:等值测试以1欧元为单位进行。事实上,我们发现由于四舍五入的问题,文档中的算法给出的结果与预期值相差不超过1欧元;
- 第27行:税率计算不包含误差范围;
- 第49-137行:重复进行此类测试10次,每次采用不同的纳税人配置;
测试结果如下:

11.7.5. 后续版本的测试
接下来,对 [dao] 和 [métier] 层的测试将与 04 版本完全相同。唯一的变化是测试环境。因此,我们将仅展示该环境及测试结果。