28. 应用练习:第 10 版
28.1. 简介
在税务计算服务器的客户端示例中,如果需要处理 N 名纳税人,线程会依次发送 N 个请求。此处的思路是发送一个包含 N 名纳税人的单一请求。对于每名纳税人,必须发送 [已婚、子女、工资] 信息。这些信息可以作为参数发送:
- 在 URL 中。这会导致 URL 过长且毫无意义;
- 在 HTTP 请求正文中。我们知道,使用浏览器的用户无法看到该正文;
这两种情况下,均可使用 [GET] 或 [POST] 请求。我们将采用 POST 请求,并将参数嵌入 HTTP 请求正文中。
客户端/服务器架构并未改变:

28.2. Web 服务器

[http-servers/05] 文件夹最初是通过复制 [http-servers/02] 文件夹创建的。我们回到客户端与服务器之间的 JSON 交互。我们已经看到,从 JSON 切换到 XML 非常简单。
28.2.1. 配置
配置 [config, config_database, config_layers] 与之前版本保持一致。我们不再赘述。
28.2.2. 主脚本 [main]
[main] 脚本与我们复制的 [http-servers/02] 文件夹中的脚本完全相同。仅有一处不同:
- 第 2 行:现在通过 POST 请求访问 / URL;
28.2.3. [index_controller]
[index_controller] 的演变如下:
- 第 9 行:控制器接收:
- 客户端的请求;
- 服务器配置 [config];
- 第 14–18 行:我们提取 POST 请求体。封装在 HTTP 请求体中的参数可以采用多种编码方式。我们之前已经遇到过一种:[x-www-form-urlencoded]。这里,我们将使用另一种编码:JSON;
- 第 18 行:[request.data] 获取 HTTP 请求的正文。此处我们获取的是文本,且知道该文本是 JSON 格式,表示一个字典列表 [married, children, salary];
- 第 19–24 行:我们提取这个字典列表;
- 第 22–24 行:如果 JSON 解析失败,我们会记录错误;
- 第 26–28 行:如果发现获取到的对象不是列表或是一个空列表,则记录错误;
- 第 29–38 行:若成功获取列表,则验证其确实为字典列表;
- 第 40–43 行:若发生错误,则在此终止并向客户端发送错误响应;
- 第 45–69 行:现在检查每个字典:
- 它们必须包含键 [married, children, salary];
- 它们必须允许我们构建一个有效的 [TaxPayer] 对象;
- 第 65–69 行:如果在字典中检测到错误,则将其添加到该字典中,键名为 ‘error’;
- 第 72–75 行:包含错误的字典已被收集到列表 [list_errors] 中。如果该列表不为空,则将其作为错误响应发送给客户端;
- 第 77 行:此时,我们知道可以从客户端发送的请求正文中创建一个 [TaxPayer] 类型的对象列表;
- 第 84–91 行:我们处理收到的字典列表;
- 第 86 行:从字典中创建一个 [TaxPayer] 对象;
- 第 89 行:为该 [TaxPayer] 计算税款;
- 第 91 行:我们知道 [taxpayer] 已因税额计算而发生变化。将其转换为字典并添加到结果列表中;
- 第 93 行:将该结果列表发送给客户端;
28.2.4. 服务器测试
我们将使用 Postman 客户端测试服务器:
- 启动Web服务器、数据库管理系统(DBMS)以及邮件服务器[hMailServer];
- 启动 Postman 客户端及其控制台(Ctrl-Alt-C);

- 在 [1] 中:发送一个 [POST] 请求;
- 在 [2] 中:服务器的 URL;
- 在 [3] 中:HTTP 请求的正文;
- 在 [5] 中:我们指定该请求体应作为 JSON 字符串发送;
- 在 [4] 中:切换到 [raw] 模式以便能够复制并粘贴 JSON 字符串;
- 在 [6] 中:粘贴从不同版本的 [results.json] 文件中获取的 JSON 字符串。然后,对于每位纳税人,仅保留 [married, salary, children] 这三个属性;

- 在 [7] 中,我们查看 Postman 客户端将发送给服务器的 HTTP 头部;
- 在 [8] 中,我们看到它将发送一个 [Content-Type] 头部,表明请求包含一个 JSON 编码的正文。这是由于之前在 [5] 中做出的选择所致;

- 在 [9-12] 中:我们在请求中包含了服务器所需的凭据;
我们发送此请求。服务器的响应如下:

- 在 [3] 中,我们收到了 JSON 数据;
- 在 [4] 中,纳税人的税款;
让我们查看在 Postman 控制台(Ctrl-Alt-C)中发生的客户端/服务器对话:
Postman客户端发送了以下文本:
- 第 1 行:向服务器发送的 POST 请求;
- 第 2 行:HTTP 身份验证头;
- 第 3 行:客户端告知服务器其将发送一个 JSON 字符串,且该字符串长度为 824 字节(第 11 行);
- 第 13–69 行:请求的 JSON 主体;
服务器返回了以下文本:
HTTP/1.0 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 1461
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Tue, 28 Jul 2020 07:16:34 GMT
{"réponse": {"results": [{"marié": "oui", "enfants": 2, "salaire": 55555, "impôt": 2814, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}, {"marié": "oui", "enfants": 2, "salaire": 50000, "impôt": 1384, "surcôte": 0, "taux": 0.14, "décôte": 384, "réduction": 347}, {"marié": "oui", "enfants": 3, "salaire": 50000, "impôt": 0, "surcôte": 0, "taux": 0.14, "décôte": 720, "réduction": 0}, {"marié": "non", "enfants": 2, "salaire": 100000, "impôt": 19884, "surcôte": 4480, "taux": 0.41, "décôte": 0, "réduction": 0}, {"marié": "non", "enfants": 3, "salaire": 100000, "impôt": 16782, "surcôte": 7176, "taux": 0.41, "décôte": 0, "réduction": 0}, {"marié": "oui", "enfants": 3, "salaire": 100000, "impôt": 9200, "surcôte": 2180, "taux": 0.3, "décôte": 0, "réduction": 0}, {"marié": "oui", "enfants": 5, "salaire": 100000, "impôt": 4230, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}, {"marié": "non", "enfants": 0, "salaire": 100000, "impôt": 22986, "surcôte": 0, "taux": 0.41, "décôte": 0, "réduction": 0}, {"marié": "oui", "enfants": 2, "salaire": 30000, "impôt": 0, "surcôte": 0, "taux": 0.0, "décôte": 0, "réduction": 0}, {"marié": "non", "enfants": 0, "salaire": 200000, "impôt": 64210, "surcôte": 7498, "taux": 0.45, "décôte": 0, "réduction": 0}, {"marié": "oui", "enfants": 3, "salaire": 200000, "impôt": 42842, "surcôte": 17283, "taux": 0.41, "décôte": 0, "réduction": 0}]}}
- 第 1 行:请求成功;
- 第2行:服务器响应正文是一个JSON字符串。其长度为1461字节(第3行);
- 第 7 行:服务器的 JSON 响应;
现在让我们测试一些错误情况。
情况 1:我们发送任意内容
POST / HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
Content-Type: application/json
User-Agent: PostmanRuntime/7.26.2
Accept: */*
Cache-Control: no-cache
Postman-Token: 47652706-9744-46a0-a682-de010e5406c0
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 3
abc
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json; charset=utf-8
Content-Length: 125
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Tue, 28 Jul 2020 07:43:27 GMT
{"réponse": {"erreurs": ["le corps du POST n'est pas une chaîne jSON valide : Expecting value: line 1 column 1 (char 0)"]}}
- 第 13 行:发送了字符串 [abc],这并非有效的 JSON 字符串(第 3 行);
- 第 15 行:服务器返回 400 错误代码;
- 第 21 行:服务器的 JSON 响应;
情况 2:让我们发送一个有效的 JSON 字符串,但该字符串不是列表
POST / HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
Content-Type: application/json
User-Agent: PostmanRuntime/7.26.2
Accept: */*
Cache-Control: no-cache
Postman-Token: 03b64735-9239-47b3-b92d-be7c9ebc7559
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 17
{"att1":"value1"}
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json; charset=utf-8
Content-Length: 97
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Tue, 28 Jul 2020 07:50:11 GMT
{"réponse": {"erreurs": ["le corps du POST n'est pas une liste ou alors cette liste est vide"]}}
情况 3:让我们发送一个 JSON 字符串,该字符串是一个列表,且其元素并非全是字典
POST / HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
Content-Type: application/json
User-Agent: PostmanRuntime/7.26.2
Accept: */*
Cache-Control: no-cache
Postman-Token: a1528a5f-777c-413f-b3be-7d4e9955b12a
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 7
[0,1,2]
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json; charset=utf-8
Content-Length: 85
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Tue, 28 Jul 2020 07:52:10 GMT
{"réponse": {"erreurs": ["le corps du POST doit être une liste de dictionnaires"]}}
案例 4:让我们发送一个包含字典的列表,其中有一个字典的键不正确
POST / HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
Content-Type: application/json
User-Agent: PostmanRuntime/7.26.2
Accept: */*
Cache-Control: no-cache
Postman-Token: ba964d81-c9d9-46ff-a521-b4c4e5639484
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 19
[{"att1":"value1"}]
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json; charset=utf-8
Content-Length: 112
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Tue, 28 Jul 2020 07:54:33 GMT
{"réponse": {"erreurs": [{"att1": "value1", "erreur": "MyException[2, la clé [att1] n'est pas autorisée]"}]}}
案例 5:让我们发送一个包含字典的列表,其中有一个字典缺少键:
POST / HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
Content-Type: application/json
User-Agent: PostmanRuntime/7.26.2
Accept: */*
Cache-Control: no-cache
Postman-Token: 98aec51d-f37d-4c14-81cd-c7ffcbbcdc65
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 18
[{"marié":"oui"}]
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json; charset=utf-8
Content-Length: 125
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Tue, 28 Jul 2020 07:56:40 GMT
{"réponse": {"erreurs": [{"marié": "oui", "erreur": "le dictionnaire doit inclure les clés [marié, enfants, salaire]"}]}}
案例 6:让我们发送一组字典列表,其中一个字典包含正确的键,但有些字典的值不正确:
POST / HTTP/1.1
Authorization: Basic YWRtaW46YWRtaW4=
Content-Type: application/json
User-Agent: PostmanRuntime/7.26.2
Accept: */*
Cache-Control: no-cache
Postman-Token: 3083e601-dee4-4e15-9ea4-fc0328d0fcf0
Host: localhost:5000
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Length: 46
[{"marié":"x", "enfants":"x", "salaire":"x"}]
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json; charset=utf-8
Content-Length: 167
Server: Werkzeug/1.0.1 Python/3.8.1
Date: Tue, 28 Jul 2020 07:59:32 GMT
{"réponse": {"erreurs": [{"marié": "x", "enfants": "x", "salaire": "x", "erreur": "MyException[31, l'attribut marié [x] doit avoir l'une des valeurs oui / non]"}]}}
28.3. Web 客户端

文件 [http-clients/05](版本 10)最初是通过复制文件 [http-clients/02](版本 7)获得的。随后对其进行了修改。
28.3.1. [dao] 层
[dao] 层由以下 [ImpôtsDaoWithHttpClient] 类实现:
- 第 1–26 行:代码与第 7 版及其他版本保持一致;
- 第 27–70 行:引入了一个新方法 [calculate_tax_in_bulk_mode],其目的是为纳税人列表计算税款;
- 第 28 行:[taxpayers] 即为该纳税人列表;
- 第 31–39 行:我们使用 [map] 函数将 [TaxPayer] 对象列表转换为字典列表;
- 第 34–38 行:所使用的 lambda 函数将 [TaxPayer] 类型的对象转换为仅包含 [married, children, salary] 这三个键的 [dict] 类型字典。为此,我们使用了 [BaseEntity.asdict] 方法中的 [included_keys] 参数。 请注意,要确定应包含在 [excluded_keys, included_keys] 参数中的属性确切名称,必须使用预定义字典 [taxpayer.__dict__];
- 第 41–48 行:连接到服务器并获取其 HTTP 响应;
- 第 44、48 行:
- 我们使用静态方法 [requests.post] 向服务器发送 POST 请求;
- 名为 [json] 的参数用于指示 POST 请求主体是一个 JSON 字符串。这将产生两个结果:
- 赋值给命名参数 [json] 的对象(在本例中为字典列表)将被转换为 JSON 字符串;
- 请求头
将被包含在 POST 请求的 HTTP 头部中;
- 第 59 行:将服务器的 JSON 响应反序列化为 [result] 字典;
- 第 61–63 行:处理服务器发回的任何错误;
- 第 65 行:税费计算结果存储在一个字典列表中;
- 第 67–69 行:使用这些结果更新方法在第 28 行最初接收的纳税人列表 [taxpayers];
- 第 70 行:此处,初始纳税人列表已根据税费计算结果进行了更新;
28.3.2. 主脚本 [main]
主脚本 [main] 的演变如下:仅修改了由客户端创建的线程所执行的函数 [thread_function]。其余代码保持不变。
- 第 9–10 行:此前我们使用了一个循环,依次将每位纳税人传递给 [dao.calculate_tax] 方法,而在此处,我们仅调用一次 [dao.calculate_tax_in_bulk_mode] 方法,并将所有纳税人一次性传递给该方法;
28.3.3. 客户端执行
我们将比较以下版本的执行时间:
- 7,其中每个纳税人都是一个 HTTP 请求的对象;
- 10(即本版本),将纳税人分组为单个 HTTP 请求;
首先是第 6 版。为了比较这两个版本,我们将服务器的 [sleep_time] 属性设置为零,以避免线程被迫等待。客户端日志如下:
2020-07-28 14:20:45.811347, Thread-1 : début du thread [Thread-1] avec 4 contribuable(s)
2020-07-28 14:20:45.811347, Thread-1 : début du calcul de l'impôt de {"id": 1, "marié": "oui", "enfants": 2, "salaire": 55555}
…
2020-07-28 14:20:45.913065, Thread-3 : fin du calcul de l'impôt de {"id": 11, "marié": "oui", "enfants": 3, "salaire": 200000, "impôt": 42842, "surcôte": 17283, "taux": 0.41, "décôte": 0, "réduction": 0}
2020-07-28 14:20:45.913065, Thread-3 : fin du thread [Thread-3]
因此,客户端计算11名纳税人税款的执行时间为 [913065-811347= 101718],即约102毫秒。
现在我们用版本 10(服务器 sleep_time 设为零)进行同样的操作。此时客户端日志如下:
2020-07-28 14:25:31.871428, Thread-1 : début du calcul de l'impôt des 4 contribuables
2020-07-28 14:25:31.873594, Thread-2 : début du calcul de l'impôt des 3 contribuables
2020-07-28 14:25:31.877429, Thread-3 : début du calcul de l'impôt des 3 contribuables
2020-07-28 14:25:31.882855, Thread-4 : début du calcul de l'impôt des 1 contribuables
2020-07-28 14:25:31.930723, Thread-2 : {"réponse": {"results": [{"marié": "non", "enfants": 3, "salaire": 100000, "impôt": 16782, "surcôte": 7176, "taux": 0.41, "décôte": 0, "réduction": 0}, {"marié": "oui", "enfants": 3, "salaire": 100000, "impôt": 9200, "surcôte": 2180, "taux": 0.3, "décôte": 0, "réduction": 0}, {"marié": "oui", "enfants": 5, "salaire": 100000, "impôt": 4230, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}]}}
….
2020-07-28 14:25:31.935958, Thread-4 : fin du calcul de l'impôt des 1 contribuables
2020-07-28 14:25:31.935958, Thread-1 : fin du calcul de l'impôt des 4 contribuables
因此,客户端计算11名纳税人税款的执行时间为 [935958-871428= 64530 ns](第8行 – 第1行),即约65毫秒。因此,此新版10相较于版本7,性能提升了约57%。
28.3.4. 客户端 [dao] 层的测试

版本 10 中客户端的 [TestHttpClientDao] 测试与版本 7 中的测试非常相似:
- 第 14 行:我们不再调用 [dao.calculate_tax] 方法,而是调用 [dao.calculate_tax_in_bulk_mode] 方法,并向其传递一个纳税人列表(由方括号表示);
所有测试均通过。