Skip to content

11. 应用练习 – 版本 4

该税务计算应用程序将实现以下分层结构:

Image

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

11.1. 脚本结构树

Image

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] 层:

Image

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] 方法的实现代码。 我们知道,该用例可通过两种方式实现(参见链接段落):

  1. 创建一个抽象类 C,其中包含派生类共有的代码。类 C 实现了接口 I,但某些必须在派生类中声明的方法在类 C 中被声明为抽象方法,因此类 C 本身也是抽象类。 随后创建从 C 派生的类 C1 和 C2,它们各自以自己的方式实现父类 C 中未定义(抽象)的方法;
  2. 创建一个与前一解决方案中的抽象类 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] 层:

Image

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] 层的脚本:

Image

主脚本如下所示:[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]

1
2
3
4
5
6
7
8
9
{"marié":"oui","enfants":2,"salaire":55555,"impôt":2814,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14}
{"marié":"oui","enfants":2,"salaire":50000,"impôt":1384,"surcôte":0,"décôte":384,"réduction":347,"taux":0.14}
{"marié":"oui","enfants":3,"salaire":50000,"impôt":0,"surcôte":0,"décôte":720,"réduction":0,"taux":0.14}
{"marié":"non","enfants":2,"salaire":100000,"impôt":19884,"surcôte":4480,"décôte":0,"réduction":0,"taux":0.41}
{"marié":"oui","enfants":3,"salaire":100000,"impôt":9200,"surcôte":2180,"décôte":0,"réduction":0,"taux":0.3}
{"marié":"non","enfants":0,"salaire":100000,"impôt":22986,"surcôte":0,"décôte":0,"réduction":0,"taux":0.41}
{"marié":"oui","enfants":2,"salaire":30000,"impôt":0,"surcôte":0,"décôte":0,"réduction":0,"taux":0}
{"marié":"non","enfants":0,"salaire":200000,"impôt":64210,"surcôte":7498,"décôte":0,"réduction":0,"taux":0.45}
{"marié":"oui","enfants":3,"salaire":200000,"impôt":42842,"surcôte":17283,"décôte":0,"réduction":0,"taux":0.41}

11.6.2. 测试 2

在主脚本中,为纳税人文件指定了一个不存在的文件名:

const TAXPAYERS_DATA_FILENAME = "taxpayersdata2.txt";

此时控制台显示的结果如下:


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:

Image

上述代码的第 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 框架:

Image

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

Image

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

11.7.2. [CodeCeption] 集成到 NetBeans 中

Image

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

Image

Image

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

Image

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

Image

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

11.7.3. [dao] 层的测试

Image

  • 我们将所有测试创建在文件夹 [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实体(此处为数组)是否相同;

要执行此测试类,请按以下步骤操作:

Image

  • [1-2] 中执行测试;
  • [3]:测试结果窗口;
  • [4]:已执行的测试类;
  • [5]:测试结果。此处唯一的测试方法已通过;
  • [6]:当测试失败或更常见的情况是未执行任何测试时,需查看[6]窗口。通常是测试环境加载失败,导致无法执行任何测试。 [6]中显示的错误与执行常规PHP脚本时出现的错误相同;

下面展示一个测试失败的示例:

在测试类中,我们在常量定义中引入了一个错误:


// 常量
define("ROOT", "C:/Data/st-2019/dev/php7/poly/scripts-console/impots/version-04x");

然后执行测试。得到的结果如下:

Image

在窗口 [4] 中:

Image

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次,每次采用不同的纳税人配置;

测试结果如下:

Image

11.7.5. 后续版本的测试

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