15. 应用练习 - 第4版

在此,我们将重新审视|第3版|章节中描述的练习,并使用类和接口来实现它。我们将编写两个应用程序:
应用程序 1 将如下所示:

一个主脚本 [main] 将实例化 [DAO] 层和 [business] 层:
- [DAO] 层将负责管理存储在文本文件中的数据,以及后续存储在数据库中的数据;
- [业务]层将负责计算税款;
在此应用程序中,不会有用户输入:纳税人数据将来自一个文本文件,该文件的名称将传递给 [main] 模块。
在应用程序 2 中,用户将通过键盘输入纳税人数据。此时,架构将演变为如下形式:

- [DAO](数据访问对象)层负责处理外部数据的访问
- [业务]层负责处理业务逻辑,本例中即税费计算。该层不直接处理数据。这些数据可来自两个来源:
- 持久化数据来自 [DAO] 层;
- [UI] 层用于处理用户提供的数据。
- [UI](用户界面)层负责处理与用户的交互;
- [main] 充当协调者;
在下文中,[dao]、[business] 和 [ui] 层将分别通过一个类来实现。[business] 和 [dao] 层在两个应用程序中是相同的。这就是为什么它们被合并为应用程序练习的单一版本。
15.1. 版本 4 – 应用程序 1
版本 4 用于计算存储在文本文件中的一组纳税人的税款。其架构如下:

15.1.1. 实体

实体是数据类。其作用是封装数据,并提供用于验证数据有效性的getter/setter方法。实体在各层之间进行传递。单个实体可以在[ui]层与[dao]层之间双向传递。
15.1.1.1. [ImpôtsError] 类
我们将使用一个自定义异常类:
一旦 [business] 和 [DAO] 层遇到问题,就会抛出此异常。它继承自 [MyException] 类。因此,其使用方式如下:[raise ImpôtsError(error_code, error_message)]。
15.1.1.2. [AdminData] 类
[AdminData] 类封装了税务计算中使用的常量:
- 第 5 行:[AdminData] 类继承了 |BaseEntity| 部分中描述的 [BaseEntity] 类。请注意,继承 [BaseEntity] 类的类必须定义:
- 一个类属性 [excluded_keys](第 7 行),用于列出在将对象转换为字典时被排除的属性;
- 一个静态方法 [get_allowed_keys](第 10–26 行),用于返回在用字典初始化对象时被接受的属性列表;
我们没有使用设置器来验证用于初始化 [AdminData] 对象的数据。这是因为该对象是唯一的且由配置定义,因此不太可能包含错误。
15.1.1.3. [TaxPayer] 类
[TaxPayer] 类将用于建模纳税人:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | |
注:
- [TaxPayer] 类封装了一个纳税人;
- 第 7 行:[TaxPayer] 类继承自 [BaseEntity] 类。因此它具有一个标识符 [id];
- 第 20 行:[AdminData] 对象的状态中不排除任何属性;
- 第 22–25 行:类属性。这些在第 9–17 行中进行了说明;
- 第 27–58 行:类属性的获取器;
- 第 60–161 行:类属性的设置器。请注意,与简单的字典相比,类封装数据的优势在于,类可以通过其设置器验证属性的有效性;
15.1.2. [dao] 层

我们将把该层的实现类归类到 [services] 文件夹中。这些类将实现 [interfaces] 文件夹中定义的接口。

15.1.2.1. [InterfaceImpôtsDao] 接口
[dao] 层将实现以下 [InterfaceImpôtsDao] 接口(文件 InterfaceImpôtsDao.py):
该接口定义了三个方法:
- [get_admindata]:用于检索税率表的方法。 请注意,这里并未提供获取该数据的具体方式。后续实现中,该数据将先从文本文件中读取,再从数据库中读取。具体采用哪种数据存储方式,将由实现该接口的类自行适配。因此,我们将分别创建一个从文本文件读取税率表的类,以及一个从数据库读取税率表的类。这两个类都将实现 [get_admindata] 方法;
- [get_taxpayers_data]:用于检索纳税人数据的方法。同样,我们未指定数据存储位置。此处仅处理数据存储在文本文件中的情况;
- [write_taxpayers_results]:用于持久化税款计算结果的方法。我们同样不指定存储位置,仅处理将结果持久化到文本文件的情况。参数 [taxpayers_results] 将是待持久化的结果列表;
15.1.2.2. [AbstractImpôtsDao] 类
[dao] 层将由两个类实现:
- 一个类负责从文本文件中读取数据(纳税人、计算结果、税率表);
- 另一个将从文本文件中读取数据(纳税人、计算结果),并从数据库中读取税率表;
这两个类仅在处理税率表的方式上有所不同。纳税人数据和税额计算结果将采用相同的管理方式。因此,我们将通过父类 [AbstractImpôtsDao] 来管理它们。税率表的具体处理将由两个子类负责:
- [ImpôtsDaoWithAdminDataInJsonFile] 类将从 JSON 格式的文本文件中获取税率表;
- [ImpôtsDaoWithAdminDataInDatabase] 类将从数据库中获取税率表;
父类 [AbstractImpôtsDao] 的定义如下:
- 第 13 行:类 [AbstractImpôtsDao] 实现了接口 [InterfaceImpôtsDao]。因此,它包含该接口的三个方法:
- [get_taxpayers_data]:第 31 行;
- [write_taxpayers_results]:第 35 行;
- [get_admindata]:第 40 行。该方法不会由 [AbstractImpôtsDao] 类实现,因此被声明为抽象方法(第 39 行);
- 第 16 行:构造函数接收一个 [config] 字典,其中包含以下信息:
- [taxpayersFilename]:包含纳税人数据的文本文件名称;
- [resultsFilename]:用于存储处理结果的文本文件名;
- [errorsFilename]:记录处理 [taxpayersFilename] 文件时遇到的错误的文本文件名;
[get_taxpayers_data] 方法如下:
- 第 4 行:纳税人数据(已婚、子女、工资)将被放入一个 [TaxPayer] 类型的对象列表中;
- 第8-9行:我们打开纳税人文本文件进行读取。其内容格式如下:
与以前的版本相比:
- [taxpayersFilename] 文件中的每一行都以纳税人 ID 开头,即一个数字;
- 允许包含注释和空行;
- 我们将处理错误。因此,第 17、19 和 21 行必须被标记为无效。错误将记录在单独的文件中;
让我们继续审查代码:
- 第 4 行:将文本文件中的数据传输到 [taxPayersData] 列表中;
- 第14–31行:逐行读取纳税人文件;
- 第 14 行:当读取到空行(没有任何内容,甚至没有换行符 \r\n)时,即表示到达文件末尾;
- 第 20 行:忽略空行和注释。如果移除文本前后空格后,第一字符是 # 字符,则该行被视为注释;
- 第 24 行:有效行由四个以逗号分隔的字段组成。这些字段被提取出来。如果分配的数据点数不恰好为四个,则将数据赋值给四元元组的操作将失败;
- 第 25 行:如果检索到的四个字段 [id, married, children, salary] 中任何一个无效,则 [BaseEntity.fromdict] 方法将引发 [MyException] 异常;
- 第 25–26 行:将一个 [TaxPayer] 对象添加到纳税人列表 [taxpayers_data] 中;
- 第 27–29 行:将所有错误收集到列表 [errors] 中。该列表在第 6 行创建;
- 第 33–36 行:将遇到的错误列表保存到文本文件 [errorsFilename] 中。错误分为两类:
- 行中预期字段的数量不正确;
- 行中的信息不正确,导致无法构建 [TaxPayer] 对象;
- 第 39–41 行:通过将任何错误(BaseException)封装为 [TaxPayerError] 类型来捕获并传递该错误;
- 第 42–45 行:无论操作是否成功,若纳税人文本文件已被打开,则将其关闭;
[write_taxpayers_results] 方法必须生成格式如下所示的 JSON 文件:
[
{
"id": 1,
"marié": "oui",
"enfants": 2,
"salaire": 55555,
"impôt": 2814,
"surcôte": 0,
"taux": 0.14,
"décôte": 0,
"réduction": 0
},
{
"id": 2,
"marié": "oui",
"enfants": 2,
"salaire": 50000,
"impôt": 1384,
"surcôte": 0,
"taux": 0.14,
"décôte": 384,
"réduction": 347
},
{
"id": 3,
"marié": "oui",
"enfants": 3,
"salaire": 50000,
"impôt": 0,
"surcôte": 0,
"taux": 0.14,
"décôte": 720,
"réduction": 0
},
…
]
[write_taxpayers_results] 方法如下:
- 第 2 行:该方法接收一个纳税人列表 [taxpayers],并需将其以 JSON 格式保存到文本文件 [self.taxpayers_results_filename] 中;
- 第 10 行:创建 UTF-8 格式的结果文件;
- 第 12 行:这里我们引入了 [map] 函数,其语法为 [map (function, list1)]。该 [function] 会被应用于 [list1] 的每个元素,并生成一个新元素,该元素会被添加到列表 [list2] 中。最后,对于每个 i:
liste2[i]=fonction(liste1[i])
此处,[list1] 是列表 [taxPayers],即 [TaxPayer] 类型的对象列表。 函数 [function] 在此以所谓的 [lambda] 函数形式表示,它描述了对列表 [taxpayers] 中的元素 [taxpayer] 所施加的转换:每个 [taxpayer] 元素都被替换为其字典 [taxpayer.asdict()]。最后,生成的列表 [list2] 就是 [taxpayers] 列表中各元素的字典列表;
- 第 12 行:[map] 函数返回的结果并非列表 [list2],而是一个类型为 [map] 的对象。要获得 [list2],必须使用表达式 [list(mapping)](第 14 行);
- 第 14 行:将列表 [list2] 以 JSON 格式保存到文件 [self.taxpayers_results_filename] 中;
- 第 15–17 行:捕获任何类型的异常,并将其包装在 [ImpôtsError] 中,然后重新抛出(第 17 行);
- 第 19–21 行:无论操作是否成功,如果结果文件已被打开,则将其关闭;
15.1.2.3. 类 [ImpôtsDaoWithAdminDataInJsonFile]
[ImpôtsDaoWithAdminDataInJsonFile] 类将继承自 [AbstractImpôtsDao] 类,并实现其父类未实现的 [getAdminData] 方法。该类将从 JSON 文件中检索税务管理数据:
{
"limites": [9964, 27519, 73779, 156244, 0],
"coeffr": [0, 0.14, 0.3, 0.41, 0.45],
"coeffn": [0, 1394.96, 5798, 13913.69, 20163.45],
"plafond_qf_demi_part": 1551,
"plafond_revenus_celibataire_pour_reduction": 21037,
"plafond_revenus_couple_pour_reduction": 42074,
"valeur_reduc_demi_part": 3797,
"plafond_decote_celibataire": 1196,
"plafond_decote_couple": 1970,
"plafond_impot_couple_pour_decote": 2627,
"plafond_impot_celibataire_pour_decote": 1595,
"abattement_dixpourcent_max": 12502,
"abattement_dixpourcent_min": 437
}
[ImpôtsDaoWithAdminDataInJsonFile] 类的定义如下:
- 第 11 行:[ImpôtsDaoWithAdminDataInJsonFile] 类继承自 [AbstractImpôtsDao] 类。因此,它实现了 [InterfaceImpôtsDao] 接口;
- 第 13 行:构造函数接收一个字典作为参数,该字典包含第 14–17 行中的信息;
- 第 20 行:初始化父类;
- 第 24 行:打开包含税务管理数据的 JSON 文件;
- 第 25 行:打开包含税务机关数据的 UTF-8 文件;
- 第 27 行:读取文件内容并将其放入类型为 [AdminData] 的 [self.admindata] 对象中。JSON 文件中的键必须与 [AdminData] 对象支持的属性匹配;否则,[fromdict] 方法将抛出异常;
- 第 28–30 行:异常处理。任何可能发生的异常在重新抛出前都会被包装为 [ImpôtsError] 类型;
- 第 32–34 行:若文件已被打开,则将其关闭;
- 第 42–43 行:实现 [InterfaceImpôtsDao] 接口的 [get_admindata] 方法;
15.1.3. [business] 层

15.1.3.1. [InterfaceImpôtsMétier] 接口
[业务]层的接口如下:
- [BusinessTaxInterface] 接口定义了一个方法:
- 第 12 行:[calculate_tax] 方法用于计算单个纳税人 [taxpayer] 的税款。[admindata] 是封装税务管理数据的 [AdminData] 对象;
- 第 12 行:[calculate_tax] 方法不返回结果。获取的数据(税款、附加费、折扣、减免、税率)包含在 [taxpayer] 参数中:调用前,这些属性为空;调用后,它们已被初始化;
15.1.3.2. [BusinessTaxes] 类
[ImpôtsMétier] 类如下所示实现了 [InterfaceImpôtsMétier] 接口:

该类的方法源自 [impôts_module_02] 模块,详见 |[impôts.v02.modules.impôts_module_02] 模块| 部分。我们已将方法参数限制为仅两个:
- taxpayer(id, married, children, salary, tax, discount, surcharge, reduction, rate):表示纳税人及其税款的对象;
- admindata:封装税务管理数据的对象;
我们将通过一个方法演示所做的更改;
- 第 3 行:[calculate_tax] 方法是 [InterfaceImpôtsMétier] 接口中的唯一方法。它接受两个参数:
- [tapPayer]:正在为其计算税款的纳税人;
- [admindata]:封装税务管理数据的对象;
- 计算结果封装在 [taxpayer] 参数中(第 40–50 行)。因此,该对象的内容在调用该方法前后并不相同;
15.1.4. 针对 [dao] 和 [business] 层的测试

- [TestDaoMétier] 是用于测试 [dao] 和 [business] 层的 UnitTest 类;
- [config] 是测试配置文件;
[config] 的配置如下:
- 第 4–23 行:我们为测试配置 Python 路径;
- 第 32–41 行:实例化 [dao] 和 [business] 层。将它们的引用存储在 [config] 字典中;
- 第 44 行:返回此字典;
[TestDaoMétier] 测试类如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | |
注释
- 第 11 行:测试类继承自 [unittest.TestCase] 类;
- 第 13–19 行:在 UnitTest 中,[setUp] 方法会在每个 [test_] 方法之前执行;
- 第 16 行:从前面提到的 [config] 脚本中获取配置;
- 第 18 行:存储对 [business] 层的引用;
- 第 19 行:从 [DAO] 层请求封装税务管理数据的 [AdminData] 对象并将其存储;
- 第 21–173 行:11 个测试,其结果已在 2019 年官方税务网站 |https://www3.impots.gouv.fr/simulateur/calcul_impot/2019/simplifie/index.htm| 上验证;
- 第 21–33 行:所有测试均基于相同的模板构建;
- 第 22 行:导入 [TaxPayer] 类;
- 第 24 行:待测试的纳税人;
- 第25行:预期结果;
- 第 26 行:创建纳税人的 [TaxPayer] 对象;
- 第 27 行:计算其税款。结果存储在 [taxpayer] 中;
- 第29–33行:验证所得结果;
- 第29行:我们将税额四舍五入至最接近的欧元。测试确实表明,本文档中该算法得出的结果可能与官方数据相差最多1欧元;
运行测试得出以下结果:

15.1.5. 主脚本

主脚本由以下 [config] 脚本配置:
这与用于测试 [business] 和 [dao] 层的配置类似。
主脚本 [main.py] 如下:
注释
- 第 2–4 行:我们获取应用程序配置。我们还知道应用程序的 Python 路径已构建完成;
- 第 9–11 行:我们获取 [business] 和 [DAO] 层的引用;
- 第 15 行:我们从税务管理局获取数据;
- 第 17 行:我们获取需要计算税款的纳税人列表;
- 第 19–20 行:如果该列表为空,则抛出异常;
- 第 22–25 行:使用 [business] 层为各个 [taxpayer] 对象计算税款;
- 第 27 行:[taxpayers] 现已成为一个 [TaxPayer] 对象列表,其中属性(tax、discount、surcharge、reduction、rate)已赋值。该列表被写入 JSON 文件;
- 第 28–30 行:捕获任何潜在错误;
- 第 31–33 行:在所有情况下均执行;
运行该脚本得出的结果与之前版本相同。纳税人错误文件是本版本的新功能。运行 [main] 脚本后,其内容如下:
Analyse du fichier C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\impots\v04\main\01/../../data/input/taxpayersdata.txt
Ligne 17, not enough values to unpack (expected 4, got 2)
Ligne 19, too many values to unpack (expected 4)
Ligne 21, MyException[1, L'identifiant d'une entité <class 'TaxPayer.TaxPayer'> doit être un entier >=0]
出错的行如下:
15.2. 版本 4 – 应用程序 2
在此版本中,用户通过键盘输入纳税人列表。应用程序架构如下:

新增了一个模块:[ui](用户界面)层,该层将与用户进行交互。该层将包含一个接口,并由一个类来实现。

15.2.1. [InterfaceImpôtsUi] 接口
[InterfaceImpôtsUi] 接口将仅包含一个方法,即第 8 至 10 行中的方法。此处将通过控制台应用程序实现该接口,但也可以通过图形用户界面来实现。在两种实现中,传递给 [run] 方法的参数将不相同。为解决此问题,通常的做法是:
- 不向 [run] 方法传递任何参数(或传递最少的参数);
- 将参数传递给实现该接口的类的构造函数。这些参数在不同实现中可能有所不同。这些参数将作为类属性进行存储;
- 确保 [run] 方法使用这些类属性(self.x);
此方法允许通过各实现类的构造函数参数来定义一个非常通用的接口。该方法已在模块化版本 #1 中使用。
15.2.2. [ImpôtsConsole] 类
[ImpôtsConsole] 类通过以下方式实现了 [InterfaceImpôtsUi] 接口:
- 第 9 行:[TaxConsole] 类实现了 [TaxUiInterface] 接口;
- 第 11 行:类构造函数接收一个参数,即包含应用程序配置的 [config] 字典;
- 第 13 行:从税务机关获取数据以计算税款;
- 第 14 行:存储对 [business] 层的引用;
- 第 16 行:实现接口的 [run] 方法;
- 第 19–53 行:用户交互。这包括
- 向纳税人询问三项信息(婚姻状况、子女情况、工资);
- 计算其应缴税款;
- 显示结果;
- 当用户对第一个问题回答 * 时,对话结束;
- 第20–27行:程序询问纳税人是否已婚,并验证回答的有效性;
- 第29–31行:若用户对该问题回答“*”,则对话结束;
- 第32–39行:询问纳税人有几个孩子,并验证回答的有效性;
- 第40–47行:询问纳税人的年薪,并验证回答的有效性;
- 第48–50行:利用这些信息,[业务]层计算纳税人的应纳税额;
- 第52行:显示税额;
15.2.3. 主脚本
主脚本 [main] 由以下 [config] 文件配置:
主脚本如下(main.py):
- 第 1-4 行:获取应用程序配置;
- 第 10 行:获取 [ui] 层的引用;
- 第12-21行:代码结构与前一个应用程序相同:代码包裹在try/catch块中以捕获任何潜在的异常;
- 第 15 行:我们请求 [ui] 层执行操作:随后开始用户交互;
- 第 16–18 行:捕获任何潜在的异常;
以下是一个执行示例:
C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/impots/v04/main/02/main.py
Le contribuable est-il marié / pacsé (oui/non) (* pour arrêter) : oui
Nombre d'enfants : 3
Salaire annuel : 200000
Impôt du contribuable = {"id": 0, "marié": "oui", "enfants": 3, "salaire": 200000, "impôt": 42842, "surcôte": 17283, "taux": 0.41, "décôte": 0, "réduction": 0}
Le contribuable est-il marié / pacsé (oui/non) (* pour arrêter) : *
Travail terminé...
Process finished with exit code 0