24. 应用练习:第7版
24.1. 简介
税款计算应用程序的第7版与第6版基本相同,仅在以下细节上有所不同:
- Web客户端将同时发起多个HTTP请求。在上一版本中,这些请求是顺序发起的。因此,服务器在任何时刻都只处理一个请求;
- 服务器将采用多线程架构:能够同时处理多个请求;
- 为跟踪这些请求的执行情况,我们将为Web服务器配备一个日志记录器,用于将请求处理过程中的关键时刻记录到文本文件中;
- 当服务器遇到导致无法启动的问题时(通常是与Web服务器关联的数据库问题),将向应用程序管理员发送一封邮件;
应用程序的架构保持不变:
脚本的目录结构如下:

文件夹 [http-servers/02] 首先通过复制文件夹 [http-servers/01] 获得。随后在此文件夹中进行修改。
24.2. 实用工具

24.2.1. 类 [Logger]
类 [Logger] 用于将 Web 服务器的某些操作记录到文本文件中:
import codecs
import threading
from datetime import date, datetime
from threading import current_thread
from ImpôtsError import ImpôtsError
class Logger:
# 类属性
verrou = threading.RLock()
# 构造函数
def __init__(self, logs_filename: str):
try:
# 以追加模式(a)打开文件
self.__resource = codecs.open(logs_filename, "a", "utf-8")
except BaseException as erreur:
raise ImpôtsError(18, f"{erreur}")
# 写入日志
def write(self, message: str):
# 当前日期/时间
today = date.today()
now = datetime.time(datetime.now())
# 线程名称
thread_name = current_thread().name
# 在向日志文件写入数据期间不希望被打断
# 请求类的同步对象(即锁)——仅有一个线程能获得它
Logger.verrou.acquire()
try:
# 写入日志
self.__resource.write(f"{today} {now}, {thread_name} : {message}")
# 立即写入——否则文本仅会在关闭写入流时写入
# 但我们希望能够追踪日志随时间的变化
self.__resource.flush()
finally:
# 释放同步对象(即锁),以便其他线程能够获取它
Logger.verrou.release()
# 释放资源
def close(self):
# 关闭文件
if self.__resource:
self.__resource.close()
- 第 10-11 行:定义了一个类属性。类属性是该类所有实例共有的属性。通过 [Classe.attribut_de_classe] 这种写法来引用它(第 30、39 行)。 类属性 [verrou] 将作为所有执行第 31-36 行代码的线程的同步对象;
- 第 14-19 行:构造函数接收日志文件的绝对路径。随后打开该文件,并将获取的文件描述符存储在类中;
- 第 17 行:日志文件以“追加”模式(a)打开。每行写入的内容都将追加到文件末尾;
- 第22-39行:方法[write]用于将作为参数传递的消息写入日志文件。该消息后附有两项信息:
- 第24行:当前日期;
- 第25行:当前时间;
- 第 27 行:写入日志的线程名称。这里需要注意的是,Web 应用程序同时为多个用户提供服务。每个请求都会被分配一个线程来执行。如果该线程被暂停(通常是由于 I/O 操作,如网络、文件或数据库操作),则处理器资源将分配给另一个线程。 由于可能发生此类中断,我们无法确保某个线程能在不受干扰的情况下成功向日志文件写入一行内容。因此,可能会出现两个不同线程的日志混杂在一起的情况。虽然这种风险很小,甚至可能为零,但我们还是决定演示如何同步两个线程对共享资源(此处为日志文件)的访问;
- 第30行:在写入之前,线程请求入口门的钥匙。所请求的钥匙即第11行创建的那个。它确实是唯一的:类属性对该类的所有实例而言都是唯一的;
- 在时间戳 T1 时,线程 Thread1 获取了钥匙。随后它即可执行第 33 行代码;
- 在时间戳 T2 时,线程 Thread1 在完成日志写入之前就被暂停;
- 在时间戳 T3 时,获得处理器资源的 Thread2 线程也需要写入日志。它运行到第 30 行时,请求获取入口门的钥匙。系统返回响应称另一条线程已持有该钥匙。于是它被自动暂停。所有请求该钥匙的线程都会经历同样的情况;
- 在时间点 T4,此前被暂停的线程 Thread1 重新获得处理器。随后它完成了日志的写入;
- 第32-36行:日志文件的写入分为两个步骤:
- 第33行:第17行获取的文件描述符使用缓冲区。第33行的操作[write]将数据写入该缓冲区,而非直接写入文件。随后在特定条件下,缓冲区中的数据会被写入文件:
- 缓冲区已满;
- 文件描述符执行了 [close] 或 [flush] 操作;
- 第36行:强制将日志行写入文件。 这样做的目的是为了观察不同线程的日志是否交错记录。如果不这样做,当关闭文件描述符(第45行)时,某个线程的所有日志都会被同时写入。届时将很难察觉某些线程已被终止:必须通过查看日志中的时间戳来判断;
- 第39行:Thread1线程归还了之前分配给它的锁。该锁将能够分配给另一个线程;
- 第22行:因此方法[write]是同步的:每次仅允许一个线程向日志文件写入数据。关键点在于第30行:无论发生什么情况,只有一个线程能获取进入下一行的通行密钥。该线程会一直持有该密钥,直到将其归还(第39行);
- 第 41-45 行:方法 [close] 用于释放分配给日志文件描述符的资源;
写入日志文件的日志将呈现如下形式:
24.2.2. 类 [SendAdminMail]
[SendAminMail]类用于在应用程序“崩溃”时向管理员发送消息。

类 [SendAdminMail] 在脚本 [config] [2] 中配置如下:
# 服务器配置 SMTP
"adminMail": {
# 服务器 SMTP
"smtp-server": "localhost",
# 服务器端口 SMTP
"smtp-port": "25",
# 管理员
"from": "guest@localhost.com",
"to": "guest@localhost.com",
# 邮件主题
"subject": "plantage du serveur de calcul d'impôts",
# 如果服务器 SMTP 需要授权,则 tls 设为 True,否则设为 False
"tls": False
}
类 [SendAdminMail] 接收第 2-13 行中的字典以及邮件发送配置。该类定义如下:
# 导入
import smtplib
from email.mime.text import MIMEText
from email.utils import formatdate
class SendAdminMail:
# -----------------------------------------------------------------------
@staticmethod
def send(config: dict, message: str, verbose: bool = False):
# 向配置为 smtp 的服务器 config['smtp-server'] 发送消息,端口为 config[smtp-port]
# 如果 config['tls'] 为真,则使用 TLS 支持
# 邮件由 config['from'] 发送
# 收件人为 config['to']
# 邮件主题为 config['subject']
# 在配置中找到日志记录器的引用['logger']
# 从配置中获取日志记录器——可能等于 None
logger = config["logger"]
# 服务器 SMTP
server = None
# 发送消息
try:
# 服务器 SMTP
server = smtplib.SMTP(config["smtp-server"])
# 详细模式
server.set_debuglevel(verbose)
# 安全连接?
if config['tls']:
# 开始安全对话
server.starttls()
# 身份验证
server.login(config["user"], config["password"])
# 构建多部分消息——将发送此消息
msg = MIMEText(message)
msg['From'] = config["from"]
msg['To'] = config["to"]
msg['Date'] = formatdate(localtime=True)
msg['Subject'] = config["subject"]
# 发送消息
server.send_message(msg)
# 日志 - 日志记录器可能不存在
if logger:
logger.write(f"[SendAdminMail] Message envoyé à [{config['to']}] : [{message}]\n")
except BaseException as erreur:
# 日志 - 日志器可能不存在
if logger:
logger.write(
f"[SendAdminMail] Erreur [{erreur}] lors de l'envoi à [{config['to']}] du message [{message}] : \n")
finally:
# 操作完成 - 释放函数占用的资源
if server:
server.quit()
- 第24-54行:此处包含已在示例 |smtp/02| 中研究过的代码;
- 第20行:获取日志记录器的引用。该引用在第45行和第49行中使用;
24.3. Web服务器

24.3.1. 配置

该服务器的配置与之前分析过的服务器非常相似。只有文件 [config.py] 略有变化:
def configure(config: dict) -> dict:
import os
# 步骤 1 ------
# 该文件的目录
script_dir = os.path.dirname(os.path.abspath(__file__))
# 根路径
root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"
# 绝对依赖项
absolute_dependencies = [
# 项目文件夹
# BaseEntity, MyException
f"{root_dir}/classes/02/entities",
# InterfaceImpôtsDao, InterfaceImpôtsMétier, InterfaceImpôtsUi
f"{root_dir}/impots/v04/interfaces",
# AbstractImpôtsdao, ImpôtsConsole, ImpôtsMétier
f"{root_dir}/impots/v04/services",
# ImpotsDaoWithAdminDataInDatabase
f"{root_dir}/impots/v05/services",
# AdminData, ImpôtsError, TaxPayer
f"{root_dir}/impots/v04/entities",
# 常量、区间
f"{root_dir}/impots/v05/entities",
# IndexController
f"{root_dir}/impots/http-servers/01/controllers",
# 脚本 [config_database, config_layers]
script_dir,
# Logger, SendAdminMail
f"{script_dir}/../utilities",
]
# 设置系统路径
from myutils import set_syspath
set_syspath(absolute_dependencies)
# 步骤 2 ------
# 应用程序配置
config.update({
# 允许使用该应用程序的用户
"users": [
{
"login": "admin",
"password": "admin"
}
],
# 日志文件
"logsFilename": f"{script_dir}/../data/logs/logs.txt",
# 服务器配置 SMTP
"adminMail": {
# 服务器 SMTP
"smtp-server": "localhost",
# SMTP 服务器的端口
"smtp-port": "25",
# 管理员
"from": "guest@localhost.com",
"to": "guest@localhost.com",
# 邮件主题
"subject": "plantage du serveur de calcul d'impôts",
# 若服务器 SMTP 需要授权,则 TLS 设为 True;否则设为 False
"tls": False
},
# 线程暂停时长(单位:秒)
"sleep_time": 0
})
# 步骤 3 ------
# 数据库配置
import config_database
config["database"] = config_database.configure(config)
# 步骤 4 ------
# 应用程序层的实例化
import config_layers
config['layers'] = config_layers.configure(config)
# 生成配置
return config
- 第40-66行:在服务器配置字典中,添加了与日志记录器相关的元素(第49行)以及向应用程序管理员发送警报邮件的相关内容(第51-63行);
- 第 65 行:为了更清晰地观察线程运行情况,我们将强制部分线程暂停。[sleep_time] 表示暂停时长(单位为秒);
- 第27-28行:请注意,这里使用了前一版本(第6版)的控制器[index_controller];
24.3.2. 主脚本 [main]
主脚本 [main] 如下:
# 等待 mysql 或 pgres 参数
import sys
syntaxe = f"{sys.argv[0]} mysql / pgres"
erreur = len(sys.argv) != 2
if not erreur:
sgbd = sys.argv[1].lower()
erreur = sgbd != "mysql" and sgbd != "pgres"
if erreur:
print(f"syntaxe : {syntaxe}")
sys.exit()
# 配置应用程序
import config
config = config.configure({'sgbd': sgbd})
# 依赖项
from flask import request, Flask
from flask_httpauth import HTTPBasicAuth
import json
import index_controller
from flask_api import status
from SendAdminMail import SendAdminMail
from myutils import json_response
from Logger import Logger
import threading
import time
from random import randint
from ImpôtsError import ImpôtsError
# 身份验证管理器
auth = HTTPBasicAuth()
@auth.verify_password
def verify_password(login, password):
# 用户列表
users = config['users']
# 遍历该列表
for user in users:
if user['login'] == login and user['password'] == password:
return True
# 未找到
return False
# 向管理员发送邮件
def send_adminmail(config: dict, message: str):
# 向应用程序管理员发送邮件
config_mail = config["adminMail"]
config_mail["logger"] = config['logger']
SendAdminMail.send(config_mail, message)
# 检查日志文件
logger = None
erreur = False
message_erreur = None
try:
# 日志记录器
logger = Logger(config["logsFilename"])
except BaseException as exception:
# 控制台日志
print(f"L'erreur suivante s'est produite : {exception}")
# 记录错误
erreur = True
message_erreur = f"{exception}"
# 在配置中保存日志器
config['logger'] = logger
# 错误处理
if erreur:
# 向管理员发送邮件
send_adminmail(config, message_erreur)
# 应用程序结束
sys.exit(1)
# 启动日志
log = "[serveur] démarrage du serveur"
logger.write(f"{log}\n")
print(log)
# 从税务部门获取数据
erreur = False
try:
# admindata 将作为应用程序范围内的只读数据
config["admindata"] = config["layers"]["dao"].get_admindata()
# 成功日志
logger.write("[serveur] connexion à la base de données réussie\n")
except ImpôtsError as ex:
# 记录错误
erreur = True
# 错误日志
log = f"L'erreur suivante s'est produite : {ex}"
# 控制台
print(log)
# 日志文件
logger.write(f"{log}\n")
# 发送邮件给管理员
send_adminmail(config, log)
# 主线程不再需要日志记录器
logger.close()
# 若发生错误则停止
if erreur:
sys.exit(2)
# Flask 应用程序可以启动
app = Flask(__name__)
# 首页 URL
@app.route('/', methods=['GET'])
@auth.login_required
def index():
…
# 仅主程序
if __name__ == '__main__':
# 启动服务器
app.config.update(ENV="development", DEBUG=True)
app.run(threaded=True)
- 第1-10行:脚本等待一个名为[mysql / pgres]的参数,该参数指定要使用的SGBD;
- 第 12-14 行:配置应用程序(Python 路径、层、数据库);
- 第 16-28 行:应用程序所需的依赖项;
- 第 30-43 行:身份验证管理;
- 第46-51行:一个向应用程序管理员发送邮件的函数;
- 该函数接受两个参数:
- config:一个包含键 [adminMail] 和 [logger] 的字典;
- 待发送的消息;
- 第49-50行:准备邮件发送配置;
- 发送邮件;
- 第 54-74 行:检查日志文件是否存在;
- 第70-74行:若无法打开日志文件,则向管理员发送邮件并终止程序;
- 第76-79行:记录服务器启动日志;
- 第81-98行:从数据库中检索税务部门的数据;
- 第 88-98 行:如果未能获取到这些数据,则将错误同时记录在控制台和日志文件中;
- 第 100-101 行:主线程将不再进行日志记录(创建的线程不会使用相同的文件描述符);
- 第103-105行:若无法连接数据库,则终止程序;
- 第 122 行:以多线程模式启动服务器;
函数 [index](第 114 行)内容如下:
# 首页 URL
@app.route('/', methods=['GET'])
@auth.login_required
def index():
logger = None
try:
# 记录
logger = Logger(config["logsFilename"])
# 将其存储在与线程关联的配置中
thread_config = {"logger": logger}
thread_name = threading.current_thread().name
config[thread_name] = {"config": thread_config}
# 记录请求
logger.write(f"[index] requête : {request}\n")
# 如果收到请求,则中断线程
sleep_time = config["sleep_time"]
if sleep_time != 0:
# 暂停是随机的,以便某些线程被中断而其他线程不被中断
aléa = randint(0, 1)
if aléa == 1:
# 暂停前进行日志记录
logger.write(f"[index] mis en pause du thread pendant {sleep_time} seconde(s)\n")
# 暂停
time.sleep(sleep_time)
# 由控制器执行该请求
résultat, status_code = index_controller.execute(request, config)
# 是否发生了致命错误?
if status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
# 向应用程序管理员发送邮件
config_mail = config["adminMail"]
config_mail["logger"] = logger
SendAdminMail.send(config_mail, json.dumps(résultat, ensure_ascii=False))
# 记录响应
logger.write(f"[index] {résultat}\n")
# 发送响应
return json_response(résultat, status_code)
except BaseException as erreur:
# 如果可能,记录错误
if logger:
logger.write(f"[index] {erreur}")
# 准备回复给客户
résultat = {"réponse": {"erreurs": [f"{erreur}"]}}
# 发送响应
return json_response(résultat, status.HTTP_500_INTERNAL_SERVER_ERROR)
finally:
# 若日志文件已打开,则关闭
if logger:
logger.close()
- 第 4 行:当用户请求 URL / 时执行的函数。由于服务器是多线程的(第 112 行),将创建一个线程来执行该函数。该线程可在任何时候被中断并暂停,稍后继续执行。 当代码访问所有线程共享的资源时,必须时刻牢记这一点。此处的此类资源即日志文件:所有线程都会向其中写入数据;
- 第8行:创建日志器实例。因此,所有线程将拥有不同的日志器实例。尽管如此,所有这些日志器都指向同一个日志文件。但需要注意的是,当一个线程关闭其日志器时,这不会影响其他线程的日志器;
- 第9-12行:将日志器存储在应用程序的[config]字典中,并关联一个以线程名称命名的键。 因此,如果有 n 个线程同时运行,[config] 字典中将创建 n 个条目。[config] 是所有线程共享的资源。因此可能需要进行同步。我在此提出了一个假设。 我假设,如果两个线程同时在文件 [config] 中创建条目,且其中一个被另一个中断,这不会产生影响。被中断的线程可以在稍后完成该条目的创建。 如果实验表明该假设不成立,则需对第12行的访问进行同步;
- 第10行:将日志记录器放入字典中;
- 第11行:[threading.current_thread()]是执行本行的线程,即执行函数[index]的线程。记录其名称。每个线程都有一个唯一名称;
- 第12行:保存线程配置。从现在起,我们将始终遵循以下原则:如果存在无法在线程间共享的信息,仍将其放入全局配置中,但会与线程名称相关联;
- 第 14 行:记录当前正在执行的请求;
- 第15-24行:随机暂停某些线程,以便将处理器让给其他线程;
- 第16行:从配置中获取暂停时长(以秒为单位);
- 第17行:仅当暂停时长不为0时才进行暂停;
- 第19行:生成一个介于[0, 1]区间内的随机整数。因此仅可能取值为0或1;
- 第 20 行:仅当随机数为 1 时才暂停线程;
- 第22行:记录线程即将被中断;
- 第24行:将线程暂停 [sleep_time] 秒;
- 第26行:当线程恢复时,由模块[index_controller]执行该请求;
- 第28-32行:如果执行过程中发生[500 INTERNAL SERVER ERROR]类型的错误,则向管理员发送邮件;
- 第 30-31 行:配置字典 [config_mail],该字典将传递给类 [SendAdminMail];
- 第 32 行:发送给管理员的消息是结果字符串 jSON,该结果将发送给客户端;
- 第 33-34 行:记录将发送给客户端的响应(第 36 行);
- 第37-44行:处理可能发生的异常;
- 第39-40行:如果日志器存在,则记录发生的错误;
- 第47-48行:若日志器存在,则将其关闭。最终,该线程在请求开始时创建日志器,并在请求处理完毕后将其关闭;
24.3.3. 控制器 [index_controller]
执行请求的控制器 [index_controller] 与上一版本相同:

24.3.4. 执行
启动 Flask 服务器、邮件服务器 |hMailServer| 以及邮件客户端 |Thunderbird|。不启动 SGBD。服务器停止运行时,控制台日志如下:
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/http-servers/02/flask/main.py mysql
[serveur] démarrage du serveur
L'erreur suivante s'est produite : MyException[27, (mysql.connector.errors.InterfaceError) 2003: Can't connect to MySQL server on 'localhost:3306' (10061 Aucune connexion n’a pu être établie car l’ordinateur cible l’a expressément refusée)
(Background on this error at: http://sqlalche.me/e/13/rvf5)]
Process finished with exit code 2
日志文件 [logs.txt] 的内容如下:
2020-07-23 11:51:38.324752, MainThread : [serveur] démarrage du serveur
2020-07-23 11:51:40.355510, MainThread : L'erreur suivante s'est produite : MyException[27, (mysql.connector.errors.InterfaceError) 2003: Can't connect to MySQL server on 'localhost:3306' (10061 Aucune connexion n’a pu être établie car l’ordinateur cible l’a expressément refusée)
(Background on this error at: http://sqlalche.me/e/13/rvf5)]
2020-07-23 11:51:42.464206, MainThread : [SendAdminMail] Message envoyé à [guest@localhost.com] : [L'erreur suivante s'est produite : MyException[27, (mysql.connector.errors.InterfaceError) 2003: Can't connect to MySQL server on 'localhost:3306' (10061 Aucune connexion n’a pu être établie car l’ordinateur cible l’a expressément refusée)
(Background on this error at: http://sqlalche.me/e/13/rvf5)]]
使用 Thunderbird 检查管理员的邮件 [guest@localhost.com]:

随后启动 SGBD 并请求 URL 和 [http://127.0.0.1:5000/?mari%C3%A9=oui&enfants=3&salaire=200000]。日志内容变为如下:
2020-07-23 11:56:38.891753, MainThread : [serveur] démarrage du serveur
2020-07-23 11:56:38.987999, MainThread : [serveur] connexion à la base de données réussie
2020-07-23 11:56:40.586747, MainThread : [serveur] démarrage du serveur
2020-07-23 11:56:40.655254, MainThread : [serveur] connexion à la base de données réussie
2020-07-23 11:56:54.528360, Thread-2 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=是&子女=3&工资=200000' [GET]>
2020-07-23 11:56:54.530653, Thread-2 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 3, 'salaire': 200000, 'impôt': 42842, 'surcôte': 17283, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
- 第1-4行:需注意服务器启动了两次,因为[Debug=True]模式会触发第二次启动;
- 第5-6行:日志显示了请求的执行时间,此处为2.293毫秒;
24.4. Web客户端


文件 [http-clients/02] 是通过复制文件 [http-clients/01] 获得的。随后对其进行了一些修改。
24.4.1. 配置
应用程序 [config] 的配置与应用程序 [http-clients/02] 基本相同,仅在细节上略有差异:
def configure(config: dict) -> dict:
import os
# 步骤 1 ------
# 该文件的文件夹
script_dir = os.path.dirname(os.path.abspath(__file__))
# 根路径
root_dir = "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020"
# 绝对依赖项
absolute_dependencies = [
# 项目文件夹
# BaseEntity, MyException
f"{root_dir}/classes/02/entities",
# InterfaceImpôtsDao, InterfaceImpôtsMétier, InterfaceImpôtsUi
f"{root_dir}/impots/v04/interfaces",
# AbstractImpôtsdao, ImpôtsConsole, ImpôtsMétier
f"{root_dir}/impots/v04/services",
# ImpotsDaoWithAdminDataInDatabase
f"{root_dir}/impots/v05/services",
# AdminData, ImpôtsError, TaxPayer
f"{root_dir}/impots/v04/entities",
# 常量、区间
f"{root_dir}/impots/v05/entities",
# ImpôtsDaoWithHttpClient
f"{script_dir}/../services",
# 配置脚本
script_dir,
# 记录器
f"{root_dir}/impots/http-servers/02/utilities",
]
# 设置 syspath
from myutils import set_syspath
set_syspath(absolute_dependencies)
# 步骤 2 ------
# 使用常量配置应用程序
config.update({
# 纳税人文件
"taxpayersFilename": f"{script_dir}/../data/input/taxpayersdata.txt",
# 结果文件
"resultsFilename": f"{script_dir}/../data/output/résultats.json",
# 错误文件
"errorsFilename": f"{script_dir}/../data/output/errors.txt",
# 日志文件
"logsFilename": f"{script_dir}/../data/logs/logs.txt",
# 税款计算服务器
"server": {
"urlServer": "http://127.0.0.1:5000/",
"authBasic": True,
"user": {
"login": "admin",
"password": "admin"
}
},
# 调试模式
"debug": True
}
)
# 步骤 3------
# 实例化层
import config_layers
config['layers'] = config_layers.configure(config)
# 加载配置
return config
- 第31-32行:将使用与服务器相同的日志记录器 |Logger|;
- 第 49 行:日志文件的绝对路径;
- 第 60 行:[debug=True] 模式用于将 Web 服务器的响应写入日志文件;
24.4.2. [dao] 层
[ImpôtsDaoWithHttpClient]类的代码略有调整:
# 导入
import requests
from flask_api import status
…
class ImpôtsDaoWithHttpClient(AbstractImpôtsDao, InterfaceImpôtsMétier):
# 构造函数
def __init__(self, config: dict):
# 父类初始化
AbstractImpôtsDao.__init__(self, config)
# 保存配置项
# 通用配置
self.__config = config
# 服务器
self.__config_server = config["server"]
# 调试模式
self.__debug = config["debug"]
# 日志记录器
self.__logger = None
# 未使用的方法
def get_admindata(self) -> AdminData:
pass
# 税额计算
def calculate_tax(self, taxpayer: TaxPayer, admindata: AdminData = None):
# 允许异常上报
…
# 调试模式?
if self.__debug:
# 日志记录器
if not self.__logger:
self.__logger = self.__config['logger']
# 进行日志记录
self.__logger.write(f"{response.text}\n")
# 响应状态码HTTP
status_code = response.status_code
…
- 第17行:保存全局配置。稍后我们将看到,当类[ImpôtsDaoWithHttpClient]的构造函数执行时,字典[config]中尚未包含第37行使用的键[logger]。 正因如此,无法在构造函数中初始化 [self.__logger](第 23 行);
- 第21行:我们在配置中添加了键[debug],用于控制第33至39行的日志;
- 第34行:若处于[debug]模式;
- 第36-37行:可能初始化属性[self.__logger]。当使用方法[calculate_tax]时,键[logger]属于字典[config];
- 第 39 行:记录与服务器响应 HTTP 关联的文本文档;
[dao] 层将由多个线程同时执行。但此处仅创建了该层的单个实例(参见 config_layers)。 因此必须确认代码中是否涉及对共享数据的写入操作,典型情况是修改实现 [dao] 层的 [ImpôtsDaoWithHttpClient] 类的属性。但在上述代码中,第 37 行修改了类实例的属性。 此处虽无大碍,因为所有线程共享同一个日志器。若非如此,对第37行的访问本应进行同步。
24.4.3. 主脚本
主脚本 [main] 的演变如下:
# 正在配置应用程序
import config
config = config.configure({})
# 依赖项
from ImpôtsError import ImpôtsError
import random
import sys
import threading
from Logger import Logger
# 在单线程中执行 [dao] 层
# taxpayers 是一个纳税人列表
def thread_function(dao, logger, taxpayers: list):
…
# 客户端线程列表
threads = []
logger = None
# 代码
try:
# 日志记录器
logger = Logger(config["logsFilename"])
# 将其存储在配置中
config["logger"] = logger
# 获取 [dao] 层
dao = config["layers"]["dao"]
# 读取纳税人数据
taxpayers = dao.get_taxpayers_data()["taxpayers"]
# 纳税人?
if not taxpayers:
raise ImpôtsError(36, f"Pas de contribuables valides dans le fichier {config['taxpayersFilename']}")
# 使用多个线程计算纳税人的税款
i = 0
l_taxpayers = len(taxpayers)
while i < len(taxpayers):
# 每个线程将处理 1 至 4 名纳税人
nb_taxpayers = min(l_taxpayers - i, random.randint(1, 4))
# 该线程处理过的纳税人列表
thread_taxpayers = taxpayers[slice(i, i + nb_taxpayers)]
# 将 i 递增以处理下一个线程
i += nb_taxpayers
# 创建线程
thread = threading.Thread(target=thread_function, args=(dao, logger, thread_taxpayers))
# 将其添加到主脚本的线程列表中
threads.append(thread)
# 启动线程——此操作为异步操作——不等待线程结果
thread.start()
# 主线程等待其启动的所有线程结束
for thread in threads:
thread.join()
# 此时所有线程均已完成工作——每个线程都修改了一个或多个对象 [taxpayer]
# 将结果保存到文件中 jSON
dao.write_taxpayers_results(taxpayers)
except BaseException as erreur:
# 显示错误
print(f"L'erreur suivante s'est produite : {erreur}")
finally:
# 关闭日志记录器
if logger:
logger.close()
# 完成
print("Travail terminé...")
# 如果因错误而停止,则结束可能仍存在的线程
sys.exit()
- 该主脚本与之前的客户端脚本的区别在于,它将生成多个执行线程来向服务器发送请求。第 6 版的客户端是顺序执行所有请求的。 只有在收到第 [i-1] 号请求的响应后,才会发出第 i 号请求。此处我们希望观察服务器在收到多个并发请求时的行为。为此我们需要使用线程;
- 第21行:生成的线程将被放入一个列表中。需要注意的是,脚本[main]本身也是由一个名为[MainThread]的线程执行的。这个主线程将创建其他线程,这些线程负责计算一个或多个纳税人的税款;
- 第26行:创建一个日志器。该日志器将由所有线程共享;
- 第 32 行:获取所有需要计算税款的纳税人;
- 第39-51行:将这些纳税人分配给多个线程;
- 第40-41行:每个线程将处理1至4名纳税人。该数量随机确定;
- [random.randint(1, 4)] 从列表 [1, 2, 3, 4] 中随机生成一个数字;
- 单个线程处理的纳税人数量不得超过 [l-i],其中 [l-i] 表示尚未分配到线程的纳税人数量;
- 因此取这两个值中的较小者;
- 第43行:一旦知道[nb_taxpayers](即该线程处理的纳税人数量),就从纳税人列表中取出这些纳税人:
- [slice(10,12)] 即为 [10, 11, 12] 的集合;
- [résultat["réponse"]] 是列表 [taxpayers[10], taxpayers[11], taxpayers[12]];
- 第45行:递增控制第39行循环的变量i的值;
- 第 47 行:创建一个线程:
- [target=thread_function] 指定线程将执行的函数。即第 16-17 行中的函数。该函数需要三个参数;
- [ags] 是函数 [thread_function] 所期望的三个参数的列表;
创建线程并不意味着立即执行。它只是创建了一个对象,仅此而已;
- 第 48-49 行:将刚创建的线程添加到主线程创建的线程列表中;
- 第 51 行:线程被启动。随后它将与其他活动线程并行执行。在此,它将使用给定的参数执行函数 [thread_function];
- 第53-54行:主线程等待其启动的每个线程完成。举个例子:
- 主线程启动了三个 [th1, th2, th3] 线程;
- 主线程按 for 循环的顺序(第 53-54 行)等待每个线程完成:[th1, th2, th3];
- 假设线程按 [th2, th1, th3] 的顺序结束;
- 主线程等待 th1 结束。当 th2 结束时,不会发生任何事情;
- 当 th1 结束时,主线程开始等待 th2。但 th2 已结束。主线程随即转至下一个线程并等待 th3;
- 当 th3 结束时,主线程结束等待并转到第 57 行执行;
- 第57行将所得结果写入结果文件。这很好地说明了对象引用:
- 第43行:与某个线程关联的列表[thread_payers]包含列表[taxpayers]中对象引用的一些副本;
- 我们知道,税款计算将修改列表 [thread_payers] 中引用所指向的对象。这些对象将被税款计算结果所更新。然而,引用本身并未被修改。 因此,初始列表 [taxpayers] 中的引用“看到”或“指向”了被修改的对象;
由线程执行的 [thread_function] 函数如下:
# 在线程中执行 [dao] 层
# taxpayers 是一个纳税人列表
def thread_function(dao, logger, taxpayers: list):
# 线程开始日志
thread_name = threading.current_thread().name
logger.write(f"début du thread [{thread_name}] avec {len(taxpayers)} contribuable(s)\n")
# 正在计算纳税人的税款
for taxpayer in taxpayers:
# 日志
logger.write(f"début du calcul de l'impôt de {taxpayer}\n")
# 同步计算税款
dao.calculate_tax(taxpayer)
# 日志
logger.write(f"fin du calcul de l'impôt de {taxpayer}\n")
# 线程结束日志
logger.write(f"fin du thread [{thread_name}]\n")
- 由多个线程同时执行的函数通常难以编写:必须始终确保代码不会尝试修改线程间共享的数据。当发生这种情况时,必须对即将被修改的共享数据设置同步访问;
- 第 3 行:该函数接收三个参数:
- [dao]:对 [dao] 层的引用。该数据为共享数据;
- [logger]:日志记录器的引用。该数据是共享的;
- [taxpayers]:纳税人列表。该数据未共享:每个线程管理不同的列表;
- 让我们来分析两个引用 [dao, logger]:
- 我们看到,引用 [dao] 所指向的对象有一个引用 [self.__logger],该引用会被线程修改,但修改后存储的是所有线程共用的值;
- 引用 [logger] 指向一个文件描述符。我们看到在向文件写入日志时可能会出现问题。因此,向文件写入操作已被同步;
- 第 5-6 行:记录线程名称及其需管理的纳税人数量;
- 第8-14行:计算纳税人的税额;
- 第 16 行:记录线程结束;
24.4.4. 运行
按照上一段所述启动Web服务器(Web服务器、SGBD、hMailServer、Thunderbird),然后执行客户端脚本[main]。 在文件 [data/output/errors.txt, data/output/résultats.json] 中,结果与上一版本相同。在文件 [data/logs/logs.txt] 中,有以下日志:
2020-07-24 10:05:20.942404, Thread-1 : début du thread [Thread-1] avec 1 contribuable(s)
2020-07-24 10:05:20.943458, Thread-1 : début du calcul de l'impôt de {"id": 1, "marié": "oui", "enfants": 2, "salaire": 55555}
2020-07-24 10:05:20.943458, Thread-2 : début du thread [Thread-2] avec 3 contribuable(s)
2020-07-24 10:05:20.946502, Thread-3 : début du thread [Thread-3] avec 1 contribuable(s)
2020-07-24 10:05:20.946502, Thread-2 : début du calcul de l'impôt de {"id": 2, "marié": "oui", "enfants": 2, "salaire": 50000}
2020-07-24 10:05:20.947003, Thread-3 : début du calcul de l'impôt de {"id": 5, "marié": "non", "enfants": 3, "salaire": 100000}
2020-07-24 10:05:20.947003, Thread-4 : début du thread [Thread-4] avec 3 contribuable(s)
2020-07-24 10:05:20.950324, Thread-4 : début du calcul de l'impôt de {"id": 6, "marié": "oui", "enfants": 3, "salaire": 100000}
2020-07-24 10:05:20.948449, Thread-5 : début du thread [Thread-5] avec 3 contribuable(s)
2020-07-24 10:05:20.953645, Thread-5 : début du calcul de l'impôt de {"id": 9, "marié": "oui", "enfants": 2, "salaire": 30000}
2020-07-24 10:05:20.976143, Thread-1 : {"réponse": {"result": {"marié": "oui", "enfants": 2, "salaire": 55555, "impôt": 2814, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:20.976695, Thread-1 : fin du calcul de l'impôt de {"id": 1, "marié": "oui", "enfants": 2, "salaire": 55555, "impôt": 2814, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}
2020-07-24 10:05:20.976695, Thread-1 : fin du thread [Thread-1]
2020-07-24 10:05:21.973914, Thread-2 : {"réponse": {"result": {"marié": "oui", "enfants": 2, "salaire": 50000, "impôt": 1384, "surcôte": 0, "taux": 0.14, "décôte": 384, "réduction": 347}}}
2020-07-24 10:05:21.973914, Thread-2 : fin du calcul de l'impôt de {"id": 2, "marié": "oui", "enfants": 2, "salaire": 50000, "impôt": 1384, "surcôte": 0, "taux": 0.14, "décôte": 384, "réduction": 347}
2020-07-24 10:05:21.973914, Thread-2 : début du calcul de l'impôt de {"id": 3, "marié": "oui", "enfants": 3, "salaire": 50000}
2020-07-24 10:05:21.977130, Thread-4 : {"réponse": {"result": {"marié": "oui", "enfants": 3, "salaire": 100000, "impôt": 9200, "surcôte": 2180, "taux": 0.3, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:21.977130, Thread-4 : fin du calcul de l'impôt de {"id": 6, "marié": "oui", "enfants": 3, "salaire": 100000, "impôt": 9200, "surcôte": 2180, "taux": 0.3, "décôte": 0, "réduction": 0}
2020-07-24 10:05:21.977130, Thread-4 : début du calcul de l'impôt de {"id": 7, "marié": "oui", "enfants": 5, "salaire": 100000}
2020-07-24 10:05:21.982634, Thread-3 : {"réponse": {"result": {"marié": "non", "enfants": 3, "salaire": 100000, "impôt": 16782, "surcôte": 7176, "taux": 0.41, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:21.982634, Thread-5 : {"réponse": {"result": {"marié": "oui", "enfants": 2, "salaire": 30000, "impôt": 0, "surcôte": 0, "taux": 0.0, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:21.983134, Thread-3 : fin du calcul de l'impôt de {"id": 5, "marié": "non", "enfants": 3, "salaire": 100000, "impôt": 16782, "surcôte": 7176, "taux": 0.41, "décôte": 0, "réduction": 0}
2020-07-24 10:05:21.983134, Thread-5 : fin du calcul de l'impôt de {"id": 9, "marié": "oui", "enfants": 2, "salaire": 30000, "impôt": 0, "surcôte": 0, "taux": 0.0, "décôte": 0, "réduction": 0}
2020-07-24 10:05:21.983134, Thread-3 : fin du thread [Thread-3]
2020-07-24 10:05:21.983763, Thread-5 : début du calcul de l'impôt de {"id": 10, "marié": "non", "enfants": 0, "salaire": 200000}
2020-07-24 10:05:22.008562, Thread-5 : {"réponse": {"result": {"marié": "non", "enfants": 0, "salaire": 200000, "impôt": 64210, "surcôte": 7498, "taux": 0.45, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:22.008562, Thread-5 : fin du calcul de l'impôt de {"id": 10, "marié": "non", "enfants": 0, "salaire": 200000, "impôt": 64210, "surcôte": 7498, "taux": 0.45, "décôte": 0, "réduction": 0}
2020-07-24 10:05:22.009062, Thread-5 : début du calcul de l'impôt de {"id": 11, "marié": "oui", "enfants": 3, "salaire": 200000}
2020-07-24 10:05:22.016848, Thread-5 : {"réponse": {"result": {"marié": "oui", "enfants": 3, "salaire": 200000, "impôt": 42842, "surcôte": 17283, "taux": 0.41, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:22.017349, Thread-5 : 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-24 10:05:22.017349, Thread-5 : fin du thread [Thread-5]
2020-07-24 10:05:23.008486, Thread-2 : {"réponse": {"result": {"marié": "oui", "enfants": 3, "salaire": 50000, "impôt": 0, "surcôte": 0, "taux": 0.14, "décôte": 720, "réduction": 0}}}
2020-07-24 10:05:23.008486, Thread-2 : fin du calcul de l'impôt de {"id": 3, "marié": "oui", "enfants": 3, "salaire": 50000, "impôt": 0, "surcôte": 0, "taux": 0.14, "décôte": 720, "réduction": 0}
2020-07-24 10:05:23.009749, Thread-2 : début du calcul de l'impôt de {"id": 4, "marié": "non", "enfants": 2, "salaire": 100000}
2020-07-24 10:05:23.011722, Thread-4 : {"réponse": {"result": {"marié": "oui", "enfants": 5, "salaire": 100000, "impôt": 4230, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:23.013723, Thread-4 : fin du calcul de l'impôt de {"id": 7, "marié": "oui", "enfants": 5, "salaire": 100000, "impôt": 4230, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}
2020-07-24 10:05:23.013723, Thread-4 : début du calcul de l'impôt de {"id": 8, "marié": "non", "enfants": 0, "salaire": 100000}
2020-07-24 10:05:23.024135, Thread-2 : {"réponse": {"result": {"marié": "non", "enfants": 2, "salaire": 100000, "impôt": 19884, "surcôte": 4480, "taux": 0.41, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:23.024135, Thread-2 : fin du calcul de l'impôt de {"id": 4, "marié": "non", "enfants": 2, "salaire": 100000, "impôt": 19884, "surcôte": 4480, "taux": 0.41, "décôte": 0, "réduction": 0}
2020-07-24 10:05:23.025178, Thread-2 : fin du thread [Thread-2]
2020-07-24 10:05:23.025178, Thread-4 : {"réponse": {"result": {"marié": "non", "enfants": 0, "salaire": 100000, "impôt": 22986, "surcôte": 0, "taux": 0.41, "décôte": 0, "réduction": 0}}}
2020-07-24 10:05:23.026191, Thread-4 : fin du calcul de l'impôt de {"id": 8, "marié": "non", "enfants": 0, "salaire": 100000, "impôt": 22986, "surcôte": 0, "taux": 0.41, "décôte": 0, "réduction": 0}
2020-07-24 10:05:23.026191, Thread-4 : fin du thread [Thread-4]
- 这些日志显示,系统启动了五个线程来计算 11 名纳税人的税款。这五个线程向税款计算服务器发起了并发请求。我们需要理解其工作原理:
- [Thread-1]线程最先启动。当它获得处理器资源时,会执行代码直至发送其请求HTTP。由于需要等待该请求的结果,它会被自动挂起。此时它失去处理器资源,另一个线程获得该资源;
- 第 1-10 行:对于这 5 个线程,上述过程均会重复。因此,在 [Thread-1] 线程收到响应之前,这 5 个线程就已经全部启动了(第 11 行);
- 线程的结束顺序与它们的启动顺序并不一致。因此,首先结束的是线程 [Thread-3],第 23 行;
在服务器端,[data/logs/logs.txt]文件中的日志如下:
2020-07-24 10:05:01.692980, MainThread : [serveur] démarrage du serveur
2020-07-24 10:05:01.877251, MainThread : [serveur] connexion à la base de données réussie
2020-07-24 10:05:03.596162, MainThread : [serveur] démarrage du serveur
2020-07-24 10:05:03.661160, MainThread : [serveur] connexion à la base de données réussie
2020-07-24 10:05:20.968053, Thread-2 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=是&子女=2&工资=50000' [GET]>
2020-07-24 10:05:20.969132, Thread-2 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-24 10:05:20.970316, Thread-3 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=是&子女=3&工资=100000' [GET]>
2020-07-24 10:05:20.970316, Thread-3 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-24 10:05:20.971335, Thread-4 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=是&子女=2&工资=55555' [GET]>
2020-07-24 10:05:20.972563, Thread-4 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 2, 'salaire': 55555, 'impôt': 2814, 'surcôte': 0, 'taux': 0.14, 'décôte': 0, 'réduction': 0}}}
2020-07-24 10:05:20.974796, Thread-5 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=否&子女=3&工资=100000' [GET]>
2020-07-24 10:05:20.974796, Thread-5 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-24 10:05:20.976143, Thread-6 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=是&子女=2&工资=30000' [GET]>
2020-07-24 10:05:20.976143, Thread-6 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-24 10:05:21.970615, Thread-2 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 2, 'salaire': 50000, 'impôt': 1384, 'surcôte': 0, 'taux': 0.14, 'décôte': 384, 'réduction': 347}}}
2020-07-24 10:05:21.973914, Thread-3 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 3, 'salaire': 100000, 'impôt': 9200, 'surcôte': 2180, 'taux': 0.3, 'décôte': 0, 'réduction': 0}}}
2020-07-24 10:05:21.977130, Thread-6 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 2, 'salaire': 30000, 'impôt': 0, 'surcôte': 0, 'taux': 0.0, 'décôte': 0, 'réduction': 0}}}
2020-07-24 10:05:21.977130, Thread-5 : [index] {'réponse': {'result': {'marié': 'non', 'enfants': 3, 'salaire': 100000, 'impôt': 16782, 'surcôte': 7176, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
2020-07-24 10:05:22.001693, Thread-7 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=是&子女=3&工资=50000' [GET]>
2020-07-24 10:05:22.003013, Thread-7 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-24 10:05:22.003013, Thread-8 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=是&子女=5&工资=100000' [GET]>
2020-07-24 10:05:22.003013, Thread-8 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-24 10:05:22.005871, Thread-9 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=否&子女=0&工资=200000' [GET]>
2020-07-24 10:05:22.006370, Thread-9 : [index] {'réponse': {'result': {'marié': 'non', 'enfants': 0, 'salaire': 200000, 'impôt': 64210, 'surcôte': 7498, 'taux': 0.45, 'décôte': 0, 'réduction': 0}}}
2020-07-24 10:05:22.014170, Thread-10 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=是&子女=3&工资=200000' [GET]>
2020-07-24 10:05:22.014170, Thread-10 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 3, 'salaire': 200000, 'impôt': 42842, 'surcôte': 17283, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
2020-07-24 10:05:23.003533, Thread-7 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 3, 'salaire': 50000, 'impôt': 0, 'surcôte': 0, 'taux': 0.14, 'décôte': 720, 'réduction': 0}}}
2020-07-24 10:05:23.006434, Thread-8 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 5, 'salaire': 100000, 'impôt': 4230, 'surcôte': 0, 'taux': 0.14, 'décôte': 0, 'réduction': 0}}}
2020-07-24 10:05:23.018026, Thread-11 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=否&子女=2&工资=100000' [GET]>
2020-07-24 10:05:23.019074, Thread-11 : [index] {'réponse': {'result': {'marié': 'non', 'enfants': 2, 'salaire': 100000, 'impôt': 19884, 'surcôte': 4480, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
2020-07-24 10:05:23.021447, Thread-12 : [index] requête : <Request 'http://127.0.0.1:5000/?已婚=否&子女=0&工资=100000' [GET]>
2020-07-24 10:05:23.022447, Thread-12 : [index] {'réponse': {'result': {'marié': 'non', 'enfants': 0, 'salaire': 100000, 'impôt': 22986, 'surcôte': 0, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
- 可见共有 11 个线程处理了 11 位纳税人;
- 部分线程被挂起(第6、8、12、14、20、22行),其余则未被挂起(第9、23、25、29、31行);
24.5. [dao] 层的测试
与|上一版本|一样,我们正在测试客户端的 [dao] 层。原理完全相同:

该测试类将在以下环境中运行:

- [2]的配置与我们刚刚研究的[1]完全相同;
测试类 [TestHttpClientDao] 如下所示:
import unittest
from Logger import Logger
class TestHttpClientDao(unittest.TestCase):
def test_1(self) -> None:
from TaxPayer import TaxPayer
# {'已婚': '是', '子女': 2, '工资': 55555,
# '税款': 2814, '附加费': 0, '减免': 0, '减免额': 0, '税率': 0.14}
taxpayer = TaxPayer().fromdict({"marié": "oui", "enfants": 2, "salaire": 55555})
dao.calculate_tax(taxpayer)
# 验证
self.assertAlmostEqual(taxpayer.impôt, 2815, delta=1)
self.assertEqual(taxpayer.décôte, 0)
self.assertEqual(taxpayer.réduction, 0)
self.assertAlmostEqual(taxpayer.taux, 0.14, delta=0.01)
self.assertEqual(taxpayer.surcôte, 0)
…
if __name__ == '__main__':
# 配置应用程序
import config
config = config.configure({})
# 日志记录
logger = Logger(config["logsFilename"])
# 将其保存在配置中
config["logger"] = logger
# 获取层 [dao]
dao = config["layers"]["dao"]
# 执行测试方法
print("tests en cours...")
unittest.main()
- 为该测试创建一个 |运行配置|;
- 启动包含完整环境的 Web 服务器;
- 执行测试;
结果如下:
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/http-clients/02/tests/TestHttpClientDao.py
tests en cours...
...........
----------------------------------------------------------------------
Ran 11 tests in 6.128s
OK
Process finished with exit code 0