这个应用真的很成功!经过一些初步的测试和培训,数据输入人员已经使用您的新表单好几周了。错误和数据输入时间的减少是巨大的,关于这个程序可能解决的其他问题有很多令人兴奋的讨论。即使是导演也参与了头脑风暴,你很可能很快就会被要求添加一些新功能;应用已经是一个几百行的脚本,随着它的增长,您会担心它的可管理性。您需要花一些时间来组织代码库,为将来的扩展做准备。
在本章中,我们将学习以下主题:
- 如何使用模型视图控制器模式分离应用的关注点
- 如何将代码组织到 Python 包中
- 为包结构创建基本文件和目录的步骤
- 如何使用 Git 版本控制系统跟踪更改
适当的建筑设计对于任何需要扩大规模的项目都至关重要。任何人都可以支起一些木柱,建造一个花园小屋,但房子或摩天大楼需要仔细的规划和工程。软件也不例外;简单的脚本可以避开诸如全局变量或直接操作类属性之类的快捷方式,但随着程序的增长,我们的代码需要隔离和封装不同的功能,以限制我们在任何给定时刻需要理解的复杂性。
我们称之为关注点分离,它是通过使用描述不同应用组件及其交互方式的架构模式来实现的。
这些模式中最持久的可能是 MVC 模式,它是在 20 世纪 70 年代引入的。尽管这种模式经过多年的演变和衍生变化,但基本要点仍然是:将数据、数据表示和应用逻辑保存在单独、独立的组件中。
让我们深入研究这些组件,并在我们的应用的上下文中理解它们。
MVC 中的模型表示数据。这包括数据的存储,也包括查询或操作数据的各种方式。理想情况下,模型不受数据显示方式或 UI 控件授予方式的影响,而是提供一个高级界面,该界面只与其他组件的内部工作关系最小。理论上,如果您决定完全更改程序的 UI(例如,从 Tkinter 应用更改为 web 应用),那么模型应该完全不受影响。
您在模型中找到的一些功能或信息示例包括:
- 准备程序数据并将其写入持久介质(数据文件、数据库等)
- 将文件或数据库中的数据检索成对程序有用的格式
- 一组数据中字段的权威列表及其数据类型和限制
- 根据定义的数据类型和限制验证数据
- 对存储数据的计算
目前我们的应用中没有模型类;数据布局是在 form 类中定义的,Application.on_save()
方法是目前唯一与数据持久性相关的代码。我们需要将此逻辑拆分为一个单独的对象,该对象将定义数据布局并处理所有 CSV 操作。
视图是向用户呈现数据和控件的界面。应用可能有许多视图,通常位于同一数据上。视图不直接与模型对话,理想情况下只包含足够的逻辑来呈现 UI 并将用户操作反馈给控制器。
在视图中找到的一些代码示例包括:
- GUI 布局和小部件定义
- 表单自动化,例如自动完成字段、动态切换小部件或显示错误对话框
- 为演示文稿设置原始数据的格式
我们的DataRecordForm
类是我们的主要视图:它包含应用用户界面的大部分代码。它目前还定义了数据记录的结构。这种逻辑可以保留在视图中,因为视图在将数据传递给模型之前确实需要一种临时存储数据的方法,但从现在起,它不会定义我们的数据记录。
我们将在前进的过程中为应用添加更多视图。
控制器是应用的大中心站。它处理来自用户的请求,并负责在视图和模型之间路由数据。MVC 的大多数变体都会更改控制器的角色(有时还会更改其名称),但重要的是,它充当视图和模型之间的中介。我们的控制器对象需要保存对应用使用的视图和模型的引用,并负责管理它们之间的交互。
控制器中的代码示例包括:
- 应用的启动和关闭逻辑
- 用户界面事件的回调
- 创建模型和视图实例
我们的Application
对象目前充当我们应用的控制器,尽管它也有一些视图和模型逻辑。随着应用的发展,我们将把更多的表示逻辑移到视图中,把更多的数据逻辑移到模型中,在Application
对象中留下主要的连接代码。
最初,以这种方式拆分应用似乎会带来很多不必要的开销。我们必须在不同的对象之间传递数据,并最终编写更多代码来完成完全相同的工作。我们为什么要这样做?
简单地说,我们这样做是为了让扩张变得易于管理。随着应用的增长,复杂性也会增加。将我们的组件彼此隔离,限制了任何一个组件必须管理的复杂性;例如,当我们重新构造表单视图的布局时,我们不需要担心模型将如何构造输出文件中的数据。该计划的这两个方面应该相互独立。
这也有助于我们在放置某些类型的逻辑时保持一致。例如,拥有一个离散的模型对象可以帮助我们避免将 UI 代码与临时数据查询或文件访问尝试混为一谈。
底线是,如果没有一些指导性的架构策略,我们的程序有可能成为一个毫无希望的意大利面条逻辑的纠结。即使不遵守 MVC 设计的严格定义,当应用变得更加复杂时,始终遵循即使是松散的 MVC 模式也会省去很多麻烦。
正如在逻辑上将程序划分为不同的关注点有助于我们管理每个组件的逻辑复杂性一样,在物理上将代码划分为多个文件有助于我们管理每个文件的复杂性。它还加强了组件之间的隔离;例如,你不能共享全局变量,如果你的模型文件导入了tkinter
,你就知道你做错了。
目前还没有制定 Python 应用目录的官方标准,但有一些常见的约定可以帮助我们保持整洁,并使以后打包软件变得更容易。让我们按如下方式设置目录结构:
-
首先,创建一个名为
ABQ_Data_Entry
的目录。这是我们应用的根目录,所以每当我们提到应用根目录时,这就是它。 -
在应用根目录下,创建另一个名为
abq_data_entry
的目录。注意它是小写的。这将是一个 Python 包,包含应用的所有代码;它应该总是被赋予一个相当独特的名称,这样它就不会与现有的 Python 包混淆;我们在这里这样做是为了避免混淆。
Python 模块应始终使用带下划线的所有小写名称命名。这个约定在 Python 的官方样式指南 PEP8 中有详细说明。参见https://www.python.org/dev/peps/pep-0008 了解政治公众人物 8 的更多信息。
- 接下来,在应用根目录下创建一个
docs
文件夹。此文件夹将用于存储有关应用的文档文件。 - 最后,在应用根目录中创建两个空文件:
README.rst
和abq_data_entry.py
,您的目录结构如下:
与之前一样,abq_data_entry.py
是启动程序所执行的主文件。不过,与以前不同的是,它不会包含我们程序的大部分内容。事实上,这个文件应该尽可能小。
打开文件并输入以下代码:
from abq_data_entry.application import Application
app = Application()
app.mainloop()
保存并关闭文件。这个文件的唯一目的是导入我们的Application
类,创建一个实例并运行它。余下的工作将在abq_data_entry
包内进行。我们还没有创建它,所以这个文件还不能运行;在此之前,让我们先处理一下文档。
早在 20 世纪 70 年代,程序就包含一个名为README
的简短文本文件,其中包含程序文档的简明摘要。对于小型程序,它可能是唯一的文档;对于较大的程序,它通常包含用户或管理员的基本飞行前说明。
没有一个指定的内容集合用于一个 ORT T0 文件,但是作为一个基本的指南,考虑下面的部分:
- 说明:程序及其功能的简要说明。我们可以重用规范中的描述,或者类似的描述。这还可能包含主要功能的简要列表。
- 作者信息:作者姓名及版权日期。如果您计划共享您的软件,这一点尤其重要,但即使是对于内部的某些东西,未来的维护人员也可以了解谁创建了软件以及何时创建软件。
- 要求:软件的软硬件要求清单,如有。
- 安装:软件的安装说明、前提条件、依赖关系和基本设置。
- 配置:如何配置应用,有哪些选项可用。这通常针对命令行或配置文件选项,而不是程序中交互设置的选项。
- 用法:说明如何启动应用、命令行参数以及用户使用应用基本功能所需的其他注意事项。
- 一般注意事项:用户应注意的所有注意事项或关键信息。
- bug:应用中已知 bug 或限制的列表。
并非所有这些章节都适用于每个项目;例如,ABQ 数据条目当前没有任何配置选项,因此没有理由设置配置部分。您也可以根据情况添加其他部分;例如,公开发布的软件可能有一个 FAQ 部分,或者开源软件可能有一个关于如何提交补丁的说明的贡献部分。
README
文件以 ASCII 或 Unicode 纯文本编写,可以是自由格式,也可以使用标记语言。因为我们正在做一个 Python 项目,所以我们将使用 StructuredText,Python 文档的官方标记(这就是我们的文件使用rst
文件扩展名的原因)。
StructuredText 标记语言是 Pythondocutils
项目的一部分,可以在 Docutils 网站上找到完整的参考资料 http://docutils.sourceforge.net 。docutils
项目还提供了将 RST 转换为 PDF、ODT、HTML 和 LaTeX 等格式的实用程序。
基本知识可以很快掌握,所以让我们来了解一下:
- 段落是通过在文本块之间留下一个空行来创建的。
- 标题是用非字母数字符号在单行文本下划线创建的。确切的符号并不重要;您首先使用的那个将被视为文档其余部分的一级标题,第二个将被视为二级标题,依此类推。通常情况下,
=
用于一级,-
用于二级,~
用于三级,+
用于四级。 - 标题和副标题的创建与标题类似,只是上面和下面都有一行符号。
- 项目符号列表是以
*
、-
或+
中的任何一个和空格开头的行创建的。切换符号将创建一个子列表,多行点是通过将后续行缩进到文本从第一个项目符号开始的位置来创建的。 - 编号列表与项目符号列表类似,但使用数字(不需要正确排序)或
#
符号作为项目符号。 - 代码示例可以内联指定,方法是将它们括在双反勾字符(```py`)中,或者在一个块中指定,方法是在引入行末尾加上双冒号并缩进代码块。
- 表格可以通过使用
=
符号包围文本列、用空格分隔以表示分栏来创建,也可以通过从|
、-
和+
构建 ASCII 艺术表格来创建。在纯文本编辑器中创建表可能会很乏味,但是一些编程工具有插件来生成 RST 表。
我们已经在第 2 章使用 Tkinter 设计 GUI 应用中使用 RST 来创建我们的程序规范;在这里,您看到了标题、标题、项目符号和表格的使用。让我们浏览一下创建README.rst
文件的过程:
- 打开文件并以标题和说明开始,如下所示:
============================
ABQ Data Entry Application
============================
Description
===========
This program provides a data entry form for ABQ Agrilabs laboratory data.
Features
--------
* Provides a validated entry form to ensure correct data
* Stores data to ABQ-format CSV files
* Auto-fills form fields whenever possible
```py
2. 接下来,我们将通过添加以下代码列出作者:
Alan D Moore, 2018
当然可以加上你自己。最终,其他人可能会处理您的应用;他们应该在这里加上自己的名字和工作日期。现在,添加如下要求:
- Python 3
- Tkinter
现在,我们只需要 Python3 和 Tkinter,但随着应用的增长,我们可能会扩展这个列表。我们的应用实际上不需要安装,也没有配置选项,所以现在我们可以跳过这些部分。相反,我们将跳到`Usage`如下:
To start the application, run::
python3 ABQ_Data_Entry/abq_data_entry.py
除了这个命令,关于运行这个程序真的没有什么需要知道的;没有命令行开关或参数。我们不知道有任何 bug,因此我们只在最后留下一些一般性的注释,如下所示:
The CSV file will be saved to your current directory in the format "abq_data_record_CURRENTDATE.csv", where CURRENTDATE is today's date in ISO format.
This program only appends to the CSV file. You should have a spreadsheet program installed in case you need to edit or check the file.
告诉用户文件将被保存在哪里以及它将被调用似乎是谨慎的,因为这是现在硬编码到程序中的。此外,我们应该提到这样一个事实,即用户应该拥有某种电子表格,因为程序不能编辑或查看数据。这就完成了`README.rst`文件。保存它,让我们转到`docs`文件夹。
# 填充文档文件夹
`docs`文件夹是文档存放的地方。这可以是任何类型的文档:用户手册、程序规范、API 参考、图表等等。
现在,您可以复制我们在前几章中编写的程序规范、接口模型以及技术人员使用的表单副本。
在某些情况下,您可能需要编写用户手册,但目前该程序非常简单,不需要它。
# 制作 Python 包
创建自己的 Python 包非常简单。Python 包由以下三部分组成:
* 目录
* 该目录中的一个或多个 Python 文件
* 目录中名为`__init__.py`的文件
完成此操作后,可以导入整个或部分包,就像导入标准库包一样,前提是脚本与包目录位于同一父目录中。
Note that `__init__.py` in a module is somewhat analogous to what `self.__init__()` is for a class. Code inside it will run whenever the package is imported. The Python community generally discourages putting much code in this file, though, and since no code is actually required, we'll leave this file empty.
让我们开始构建应用包。在`abq_data_entry`下创建以下六个空文件:
* `__init__.py`
* `widgets.py`
* `views.py`
* `models.py`
* `application.py`
* `constants.py`
这些 Python 文件中的每一个都称为**模块**。模块只不过是包目录中的一个 Python 文件。您的目录结构现在应该如下所示:
![](img/06efc903-784c-426e-be9b-ddeb66de7849.png)
此时,您已经有了一个工作包,尽管其中没有实际的代码。要测试这一点,请打开一个终端/命令行窗口,切换到`ABQ_Data_Entry`目录,然后启动 Python shell。
现在,键入以下命令:
from abq_data_entry import application
这应该不会出错。当然,它没有任何作用,但我们将在下一步讨论它。
Don't confuse the term package here with the actual distributable Python packages, such as those you download using `pip`.
# 将应用拆分为多个文件
现在我们的目录结构已经就绪,我们需要开始解析应用脚本并将其拆分为模块文件。我们还需要创建模型类。打开[第 4 章](20.html)中的`abq_data_entry.py`文件,*通过验证和自动化减少用户错误,*让我们开始吧!
# 创建模型模块
当您的应用都是关于数据的时候,最好从模型开始。请记住,模型的工作是管理应用数据的存储、检索和处理,通常是关于其持久存储格式(在本例中为 CSV)。为了实现这一点,我们的模型应该包含关于数据的所有知识。
目前,我们的应用与模型完全不同;有关应用数据的知识分散在表单字段中,`Application`对象只需获取表单包含的任何数据,并在请求保存操作时将其直接填充到 CSV 文件中。由于我们还没有检索或更新信息,我们的应用对 CSV 文件中的内容没有实际的了解。
要将我们的应用迁移到 MVC 体系结构,我们需要创建一个模型类来管理数据存储和检索,并表示有关数据的权威知识源。换句话说,我们必须在模型中对数据字典中包含的知识进行编码。我们还不知道用这些知识我们会做什么,但这就是它的归属。
有几种方法可以存储这些数据,例如创建自定义字段类或`namedtuple`对象,但我们现在将保持简单,只需使用字典,将字段名称映射到字段元数据。
字段元数据同样将存储为有关字段的属性字典,其中包括:
* 是否需要该字段
* 存储在字段中的数据类型
* 可能值列表(如适用)
* 值的最小值、最大值和增量(如适用)
为了存储每个字段的数据类型,让我们定义一些数据类型。打开`constants.py`文件,添加以下代码:
class FieldTypes: string = 1 string_list = 2 iso_date_string = 3 long_string = 4 decimal = 5 integer = 6 boolean = 7
我们已经创建了一个名为`FieldTypes`的类,它只存储一些命名的整数值,它将描述我们要存储的不同类型的数据。我们可以在这里使用 Python 类型,但是区分可能是相同 Python 类型的某些数据类型(例如,`long`、`short`和`date`字符串)是很有用的。注意这里的整数值基本上没有意义;他们只是需要彼此不同。
Python 3 has an `Enum` class, which we could have used here, but it adds very little that we actually need in this case. You may want to investigate this class if you're creating a lot of constants such as our `FieldTypes` class and need additional features.
现在,打开`models.py`,在这里我们将导入`FieldTypes`并创建我们的模型类和字段定义,如下所示:
import csv import os from .constants import FieldTypes as FT
class CSVModel: """CSV file storage""" fields = { "Date": {'req': True, 'type': FT.iso_date_string}, "Time": {'req': True, 'type': FT.string_list, 'values': ['8:00', '12:00', '16:00', '20:00']}, "Technician": {'req': True, 'type': FT.string}, "Lab": {'req': True, 'type': FT.string_list, 'values': ['A', 'B', 'C', 'D', 'E']}, "Plot": {'req': True, 'type': FT.string_list, 'values': [str(x) for x in range(1, 21)]}, "Seed sample": {'req': True, 'type': FT.string}, "Humidity": {'req': True, 'type': FT.decimal, 'min': 0.5, 'max': 52.0, 'inc': .01}, "Light": {'req': True, 'type': FT.decimal, 'min': 0, 'max': 100.0, 'inc': .01}, "Temperature": {'req': True, 'type': FT.decimal, 'min': 4, 'max': 40, 'inc': .01}, "Equipment Fault": {'req': False, 'type': FT.boolean}, "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, "Min Height": {'req': True, 'type': FT.decimal, 'min': 0, 'max': 1000, 'inc': .01}, "Max Height": {'req': True, 'type': FT.decimal, 'min': 0, 'max': 1000, 'inc': .01}, "Median Height": {'req': True, 'type': FT.decimal, 'min': 0, 'max': 1000, 'inc': .01}, "Notes": {'req': False, 'type': FT.long_string} }
注意我们导入`FieldTypes`:`from .constants import FieldTypes`的方式。`constants`前面的点使其成为**相对导入**。可以在 Python 包中使用相对导入来定位同一包中的其他模块。在本例中,我们在`models`模块中,需要访问`abq_data_entry`包中的`constants`模块。单点表示我们当前的父模块(`abq_data_entry`,因此`.constants`表示`abq_data_entry`包的`constants`模块。
相对进口也将我们的定制模块与`PYTHONPATH`中的模块区分开来。因此,我们不必担心任何第三方或标准库包与我们的模块名称冲突。
In addition to field attributes, we're also documenting the order of fields here. In Python 3.6 and later, dictionaries retain the order they were defined by; if you're using an older version of Python 3, you'd need to use the `OrderedDict` class from the `collections` standard library module to preserve the field order.
现在我们有了一个了解需要存储哪些字段的类,我们需要将保存逻辑从应用类迁移到模型中。
我们当前脚本中的代码如下所示:
datestring = datetime.today().strftime("%Y-%m-%d") filename = "abq_data_record_{}.csv".format(datestring) newfile = not os.path.exists(filename)
data = self.recordform.get()
with open(filename, 'a') as fh: csvwriter = csv.DictWriter(fh, fieldnames=data.keys()) if newfile: csvwriter.writeheader() csvwriter.writerow(data)
让我们看一下这段代码,并确定哪些内容进入模型,哪些内容留在控制器中(即,`Application`类):
* 前两行定义了我们要使用的文件名。这可以应用到模型中,但考虑到前面的情况,用户可能希望能够打开任意文件或手动定义文件名。这意味着应用需要能够告诉模型使用哪个文件名,因此最好将决定名称的逻辑留在控制器中。
* `newfile`行确定文件是否存在。作为数据存储介质的一个实现细节,这显然是模型的问题,而不是应用的问题。
* `data = self.recordform.get()`从表单中提取数据。因为我们的模型不知道表单的存在,所以这需要留在控制器中。
* 最后一个块打开文件,创建一个`csv.DictWriter`对象,并附加数据。这绝对是模型的关注点。
现在,让我们开始将代码移动到`CSVModel`类中:
1. 为了开始这个过程,让我们为`CSVModel`创建一个构造函数,它允许我们传入一个文件名:
def __init__(self, filename):
self.filename = filename
构造函数非常简单;它只接受一个`filename`参数并将其存储为属性。现在,我们将迁移 save 逻辑,如下所示:
def save_record(self, data):
"""Save a dict of data to the CSV file"""
newfile = not os.path.exists(self.filename)
with open(self.filename, 'a') as fh:
csvwriter = csv.DictWriter(fh,
fieldnames=self.fields.keys())
if newfile:
csvwriter.writeheader()
csvwriter.writerow(data)
这基本上是我们选择从`Application.on_save()`复制的逻辑,但有一个区别;在对`csv.DictWriter()`的调用中,`fieldnames`参数由模型的`fields`列表定义,而不是由`data`dict 的键定义。这允许我们的模型管理 CSV 文件本身的格式,而不依赖于表单给出的格式。
2. 在完成之前,我们需要处理模块导入。`save_record()`方法使用`os`和`csv`库,所以我们需要导入它们。将其添加到文件顶部,如下所示:
import csv import os
模型就位后,让我们开始处理视图组件。
# 移动小部件
虽然我们可以将所有与 UI 相关的代码放在一个`views`文件中,但我们有很多小部件类,它们确实应该放在自己的文件中,以限制`views`文件的复杂性。
因此,我们将把小部件类的所有代码移到`widgets.py`文件中。小部件包括实现可重用 GUI 组件的所有类,包括像`LabelInput`这样的复合小部件。随着我们开发更多这些,我们将把它们添加到此文件中。
打开`widgets.py`并复制`ValidatedMixin`、`DateInput`、`RequiredEntry`、`ValidatedCombobox`、`ValidatedSpinbox`和`LabelInput`的所有代码。这些是我们的小部件。
`widgets.py`文件需要导入被复制代码使用的任何模块依赖项。我们需要查看代码,找到我们使用的库并导入它们。很明显,我们需要`tkinter`和`ttk`,所以在顶部添加如下内容:
import tkinter as tk from tkinter import ttk
我们的`DateInput`类使用`datetime`库中的`datetime`类,因此也导入该类,如下所示:
from datetime import datetime
最后,我们的`ValidatedSpinbox`类使用`decimal`库中的`Decimal`类和`InvalidOperation`异常,如下所示:
from decimal import Decimal, InvalidOperation
这就是我们目前在`widgets.py`中需要的全部内容,但在重构视图逻辑时,我们将重新访问此文件。
# 移动视图
接下来,我们需要创建`views.py`文件。视图是更大的 GUI 组件,比如我们的`DataRecordForm`类。目前它是我们唯一的视图,但我们将在后面的章节中创建更多视图,它们将添加到这里。
打开`views.py`文件并在`DataRecordForm`类中复制,然后返回顶部处理模块导入。同样,我们需要`tkinter`和`ttk`,我们的文件保存逻辑依赖于`datetime`作为文件名。
将它们添加到文件顶部,如下所示:
import tkinter as tk from tkinter import ttk from datetime import datetime
然而,我们还没有完成;我们实际的小部件不在这里,我们需要导入它们。由于我们将在文件之间进行大量的对象导入,让我们暂停一下,考虑处理这些导入的最佳方法。
有三种方法可以导入对象:
* 使用通配符导入从`widgets.py`引入所有类
* 使用`from ... import ...`格式从`widgets.py`显式导入所有需要的类
* 导入`widgets`并将我们的小部件保存在它们自己的名称空间中
让我们考虑一下这些方法的相对优点:
* 第一个选项是迄今为止最简单的,但随着应用的扩展,它会给我们带来麻烦。通配符导入将引入模块中全局范围内定义的每个名称。这不仅包括我们定义的类,还包括任何导入的模块、别名和定义的变量或函数。随着应用复杂性的增加,这可能会导致意外的后果和微妙的错误。
* 第二个选项更简洁,但这意味着我们需要在添加新类并在不同文件中使用它们时维护导入列表,这导致了一个很长很难看的导入部分,人类很难解析它。
* 第三个选项是目前为止最好的,因为它将所有名称保留在一个名称空间中,并使代码保持优雅的简单。唯一的缺点是,我们需要更新代码,以便对小部件类的所有引用也包括模块名。为了避免这个问题变得笨拙,让我们将`widgets`模块别名为简短的模块,如`w`。
将以下代码添加到导入中:
from . import widgets as w
现在,我们只需要遍历代码并将`w.`前置到`LabelInput`、`RequiredEntry`、`DateEntry`、`ValidatedCombobox`和`ValidatedSpinbox`的所有实例。在 IDLE 或任何其他文本编辑器中,使用一系列搜索和替换操作,应该很容易做到这一点。
例如,表格的`line 1`如下:
self.inputs['Date'] = w.LabelInput( recordinfo, "Date", input_class=w.DateEntry, input_var=tk.StringVar() ) self.inputs['Date'].grid(row=0, column=0) self.inputs['Time'] = w.LabelInput( recordinfo, "Time", input_class=w.ValidatedCombobox, input_var=tk.StringVar(), input_args={"values": ["8:00", "12:00", "16:00", "20:00"]} ) self.inputs['Time'].grid(row=0, column=1) self.inputs['Technician'] = w.LabelInput( recordinfo, "Technician", input_class=w.RequiredEntry, input_var=tk.StringVar() ) self.inputs['Technician'].grid(row=0, column=2)
但是,在您到处查看和更改之前,让我们先停下来,花点时间从代码中重构一些冗余。
# 消除视图逻辑中的冗余
查看视图逻辑中的字段定义:它们包含很多信息,这些信息也在我们的模型中。最小值、最大值、增量和可能的值在这里和我们的模型代码中都有定义。甚至输入小部件的类型也与存储的数据类型直接相关。理想情况下,应该只定义一个位置,并且该位置应该是模型。如果出于某种原因需要更新模型,我们的表单将不同步。
我们需要做的是将字段规范从我们的模型传递到视图类中,并让小部件的细节从该规范中定义。
由于我们的小部件实例是在`LabelInput`类中定义的,因此我们将增强该类,使其能够根据模型的字段规范格式自动计算出`input`类和参数。打开`widgets.py`文件并导入`FieldTypes`类,就像您在`model.py`中所做的一样
现在,找到`LabelInput`类,在`__init__()`方法之前添加以下代码:
field_types = {
FT.string: (RequiredEntry, tk.StringVar),
FT.string_list: (ValidatedCombobox, tk.StringVar),
FT.iso_date_string: (DateEntry, tk.StringVar),
FT.long_string: (tk.Text, lambda: None),
FT.decimal: (ValidatedSpinbox, tk.DoubleVar),
FT.integer: (ValidatedSpinbox, tk.IntVar),
FT.boolean: (ttk.Checkbutton, tk.BooleanVar)
}
该代码充当一个键,将模型的字段类型转换为适合该字段类型的小部件类型和变量类型。
现在,我们需要更新`__init__()`以获取`field_spec`参数,如果给定,则使用它定义输入小部件,如下所示:
def __init__(self, parent, label='', input_class=None,
input_var=None, input_args=None, label_args=None,
field_spec=None, **kwargs):
super().__init__(parent, **kwargs)
input_args = input_args or {}
label_args = label_args or {}
if field_spec: field_type = field_spec.get('type', FT.string) input_class = input_class or self.field_types.get(field_type)[0] var_type = self.field_types.get(field_type)[1] self.variable = input_var if input_var else var_type() # min, max, increment if 'min' in field_spec and 'from_' not in input_args: input_args['from_'] = field_spec.get('min') if 'max' in field_spec and 'to' not in input_args: input_args['to'] = field_spec.get('max') if 'inc' in field_spec and 'increment' not in input_args: input_args['increment'] = field_spec.get('inc') # values if 'values' in field_spec and 'values' not in input_args: input_args['values'] = field_spec.get('values') else: self.variable = input_var if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): input_args["text"] = label input_args["variable"] = self.variable else: self.label = ttk.Label(self, text=label, **label_args) self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) input_args["textvariable"] = self.variable # ... Remainder of init() is the same
让我们来分析一下这些变化:
1. 首先,我们添加了`field_spec`作为关键字参数,并将`None`作为默认参数。我们可能希望在没有字段规范的情况下使用此类,因此我们将此参数保留为可选参数。
2. 如果有`field_spec`给定,我们将执行以下操作:
* 我们将获取`type`值,并将其与类的字段键一起使用以获取`input_class`。如果我们想要覆盖这个,显式传递的`input_class`将覆盖检测到的。
* 我们将以同样的方式确定适当的变量类型。再一次,如果显式地传递了`input_var`,我们更愿意这样做,否则我们将使用根据字段类型确定的。我们将以任何一种方式创建一个实例并将其存储在`self.variable`中。
* 对于`min`、`max`、`inc`和`values`,如果字段规范中存在键,并且对应的`from_`、`to`、`increment`或`values`参数没有显式传入,我们将使用`field_spec`值设置`input_args`变量。
3. 如果没有传入`field_spec`,我们需要从`input_var`参数中赋值`self.variable`。
4. 我们现在使用`self.variable`而不是`input_var`来分配输入的变量,因为这些值可能不再相同,并且`self.variable`将包含正确的引用。
现在,我们可以更新视图代码以利用这一新功能。我们的`DataRecordForm`类需要访问模型的`fields`字典,然后可以使用它向`LabelInput`类发送字段规范。
回到`views.py`文件中,编辑方法签名,以便我们可以传入字段规范字典:
def __init__(self, parent, fields, *args, **kwargs):
通过访问`fields`字典,我们只需从中获取字段规范并将其传递到`LabelInput`类,而无需指定输入类、输入变量和输入参数。
现在,第一行如下所示:
self.inputs['Date'] = w.LabelInput(
recordinfo, "Date",
field_spec=fields['Date'])
self.inputs['Date'].grid(row=0, column=0)
self.inputs['Time'] = w.LabelInput(
recordinfo, "Time",
field_spec=fields['Time'])
self.inputs['Time'].grid(row=0, column=1)
self.inputs['Technician'] = w.LabelInput(
recordinfo, "Technician",
field_spec=fields['Technician'])
self.inputs['Technician'].grid(row=0, column=2)
继续以同样的方式更新其余的小部件,将`input_class`、`input_var`和`input_args`替换为`field_spec`。请注意,当您进入高度字段时,您需要保留`input_args`中定义`min_var`、`max_var`和`focus_update_var`的部分。
例如,`Min Height`输入定义如下:
self.inputs['Min Height'] = w.LabelInput(
plantinfo, "Min Height (cm)",
field_spec=fields['Min Height'],
input_args={"max_var": max_height_var,
"focus_update_var": min_height_var})
就这样。现在,对我们的字段规范的任何更改都可以仅在模型中进行,而表单将只做正确的事情。
# 创建应用文件
最后,让我们按照以下步骤创建控制器类`Application`:
1. 打开`application.py`文件,从脚本复制`Application`类定义。
2. 我们要解决的第一件事是进口。在文件顶部,添加以下代码:
import tkinter as tk from tkinter import ttk from datetime import datetime from . import views as v from . import models as m
当然,我们需要`tkinter`和`ttk`以及`datetime`来定义我们的文件名。尽管我们只需要一个来自`views`和`models`的类,但我们还是要将它们保留在各自的名称空间中。随着应用的扩展,我们可能会有更多的视图,可能还会有更多的模型。
3. 我们需要为新名称空间更新对`__init__()`中`DataRecordForm`的调用,并确保传入所需的字段规范字典,如下所示:
self.recordform = v.DataRecordForm(self, m.CSVModel.fields)
4. 最后,我们需要更新`Application.on_save()`以使用该模型,如下所示:
def on_save(self):
"""Handles save button clicks"""
errors = self.recordform.get_errors()
if errors:
self.status.set(
"Cannot save, error in fields: {}"
.format(', '.join(errors.keys())))
return False
# For now, we save to a hardcoded filename
with a datestring.
datestring = datetime.today().strftime("%Y-%m-%d")
filename = "abq_data_record_{}.csv".format(datestring)
model = m.CSVModel(filename)
data = self.recordform.get()
model.save_record(data)
self.records_saved += 1
self.status.set(
"{} records saved this session".
format(self.records_saved)
)
self.recordform.reset()
正如您所看到的,使用我们的模型是非常无缝的;我们只是通过传入文件名创建了一个`CSVModel`类,然后将表单的数据传递给`save_record()`。
# 运行应用
应用现在完全迁移到新的数据格式。要测试它,请导航到应用根文件夹`ABQ_Data_Entry`,并执行以下命令:
python3 abq_data_entry.py
它应该像[第 4 章](20.html)*中的单个脚本一样,通过验证和自动化减少用户错误,*并在没有错误的情况下运行,如以下屏幕截图所示:
![](img/4151fc4d-d11b-4bf1-a5a3-df5ab3971dca.png)
成功
# 使用版本控制软件
我们的代码结构良好,便于扩展,但还有一个更重要的问题需要解决:**版本控制**。您可能已经熟悉了**版本控制系统**(**VCS**),有时也称为**版本控制**或**源代码管理**,但如果不熟悉,它将是处理大型且不断变化的代码库不可或缺的工具。
在处理应用时,我们有时认为我们知道需要更改什么,但结果证明我们错了。有时我们不知道如何编写代码,需要多次尝试才能找到正确的方法。有时我们需要恢复到很久以前更改过的代码。有时我们有多个人在处理同一段代码,我们需要将他们的更改合并在一起。创建版本控制系统是为了解决这些问题以及更多问题。
有几十种不同的版本控制系统,但其中大多数工作原理基本相同:
* 您有一个对其进行更改的代码的工作副本
* 您可以定期选择要提交回主副本的更改
* 您可以随时签出旧版本的代码,然后恢复到主副本
* 您可以创建代码的分支来试验不同的方法、新特性或大型重构
* 以后可以将这些分支合并回主副本
VCS 提供了一个安全网,让您可以自由地更改代码,而不必担心您会无望地破坏代码:只需几条快速命令即可恢复到已知的工作状态。它还帮助我们记录对代码的更改,并在有机会时与其他人协作。
有几十种 VC 系统可用,但到目前为止,多年来最流行的是**Git**。
# 使用 Git 的超级快速指南
Git 由 Linus Torvalds 创建,作为 Linux 内核项目的版本控制软件,并从那时起发展成为世界上最流行的 VC 软件。它被诸如 GitHub、Bitbucket、SourceForge 和 GitLab 等源代码共享站点所利用。Git 非常强大,掌握它可能需要几个月或几年的时间;幸运的是,基础知识可以在几分钟内掌握。
首先,您需要安装 Git;访问[https://git-scm.com/downloads](https://git-scm.com/downloads) 了解如何在 macOS、Windows、Linux 或其他 Unix 操作系统上安装 Git 的说明。
# 初始化和配置 Git 存储库
安装 Git 后,我们需要按照以下步骤初始化项目目录并将其配置为 Git 存储库:
1. 在应用的根目录(`ABQ_Data_Entry`中运行以下命令:
git init
该命令在我们的项目根目录下创建一个名为`.git`的隐藏目录,并使用组成存储库的基本文件对其进行初始化。`.git`目录将包含我们保存的修订版的所有数据和元数据。
2. 在将任何文件添加到存储库之前,我们需要指示 Git 忽略某些类型的文件。例如,Python 在执行文件时会创建字节码(`.pyc`)文件,我们不想将其保存为代码的一部分。为此,在项目根目录中创建一个名为`.gitignore`的文件,并在其中放入以下行:
*.pyc pycache/
# 添加和提交代码
现在我们的存储库已经初始化,我们可以使用以下命令将文件和目录添加到 Git 存储库中:
git add abq_data_entry git add abq_data_entry.py git add docs git add README.rst
此时,我们的文件已暂存,但尚未提交到存储库。您可以通过输入`git status`随时检查存储库及其文件的状态。
您应该获得以下输出:
On branch master
No commits yet
Changes to be committed: (use "git rm --cached ..." to unstage)
new file: README.rst
new file: abq_data_entry.py
new file: abq_data_entry/__init__.py
new file: abq_data_entry/application.py
new file: abq_data_entry/models.py
new file: abq_data_entry/views.py
new file: abq_data_entry/widgets.py
new file: docs/Application_layout.png
new file: docs/abq_data_entry_spec.rst
new file: docs/lab-tech-paper-form.png
Untracked files: (use "git add ..." to include in what will be committed)
.gitignore
这表明,`abq_data_entry`和`docs`下的所有文件,以及您直接指定的文件,都被转移到存储库中。
让我们继续并提交以下更改:
git commit -m "Initial commit"
此处的`-m`标志传递一条提交消息,该消息与提交一起存储。每次将代码提交到存储库时,都需要编写一条消息。您应该始终使这些信息尽可能有意义,详细说明您所做的更改及其背后的原理。
# 查看和使用我们的提交
要查看存储库的历史记录,请按如下方式运行`git log`命令:
alanm@alanm-laptop:~/ABQ_Data_Entry$ git log commit df48707422875ff545dc30f4395f82ad2d25f103 (HEAD -> master) Author: Alan Moore alan@example.com Date: Thu Dec 21 18:12:17 2017 -0600
Initial commit
如您所见,`Author`、`Date`和`commit`消息显示为我们的最后一次提交。如果我们有更多的提交,它们也会在这里列出,从最新到最旧。您在输出的第一行中看到的长十六进制值是**提交散列**,一个唯一的值,用于标识提交。此值可用于在其他操作中引用提交。
例如,我们可以使用它将存储库重置为过去的状态,如下所示:
1. 删除`README.rst`文件,并确认它已完全消失。
2. 现在,输入命令`git reset --hard df48707`,将`df48707`替换为提交哈希的前七个字符。
3. 再次检查您的文件列表:`README.rst`文件回来了。
这里发生的事情是,我们更改了存储库,然后告诉 Git 将存储库的状态硬重置为第一次提交。如果不想重置存储库,还可以临时签出旧的提交,或者使用特定的提交作为基础创建分支。正如你已经看到的,这为我们提供了一个强大的实验安全网;无论您对代码做了多少修改,任何提交都只是一个命令!
Git 还有许多超出本书范围的特性。如果您想了解更多信息,Git 项目将在[提供免费的在线手册 https://git-scm.com/book](https://git-scm.com/book) 在这里,您可以了解分支和设置远程存储库等高级功能。现在,重要的是在进行中提交更改,以便维护安全网并记录更改的历史。
# 总结
在本章中,您学习了如何为一些重要的扩展准备简单的脚本。您学习了如何将应用的责任区域划分为单独的组件,以及如何将代码划分为单独的模块。您学习了如何使用 StructuredText 记录代码,并使用版本控制跟踪所有更改。
在下一章中,我们将通过实现一些新特性来测试我们的新项目布局。您将学习如何使用 Tkinter 的应用菜单小部件,如何实现文件打开和保存,以及如何使用消息弹出窗口提醒用户或确认操作。