Skip to content

Latest commit

 

History

History
793 lines (550 loc) · 43.2 KB

File metadata and controls

793 lines (550 loc) · 43.2 KB

五、计划扩展我们的应用

这个应用真的很成功!经过一些初步的测试和培训,数据输入人员已经使用您的新表单好几周了。错误和数据输入时间的减少是巨大的,关于这个程序可能解决的其他问题有很多令人兴奋的讨论。即使是导演也参与了头脑风暴,你很可能很快就会被要求添加一些新功能;应用已经是一个几百行的脚本,随着它的增长,您会担心它的可管理性。您需要花一些时间来组织代码库,为将来的扩展做准备。

在本章中,我们将学习以下主题:

  • 如何使用模型视图控制器模式分离应用的关注点
  • 如何将代码组织到 Python 包中
  • 为包结构创建基本文件和目录的步骤
  • 如何使用 Git 版本控制系统跟踪更改

分离关注点

适当的建筑设计对于任何需要扩大规模的项目都至关重要。任何人都可以支起一些木柱,建造一个花园小屋,但房子或摩天大楼需要仔细的规划和工程。软件也不例外;简单的脚本可以避开诸如全局变量或直接操作类属性之类的快捷方式,但随着程序的增长,我们的代码需要隔离和封装不同的功能,以限制我们在任何给定时刻需要理解的复杂性。

我们称之为关注点分离,它是通过使用描述不同应用组件及其交互方式的架构模式来实现的。

MVC 模式

这些模式中最持久的可能是 MVC 模式,它是在 20 世纪 70 年代引入的。尽管这种模式经过多年的演变和衍生变化,但基本要点仍然是:将数据、数据表示和应用逻辑保存在单独、独立的组件中。

让我们深入研究这些组件,并在我们的应用的上下文中理解它们。

什么是模型?

MVC 中的模型表示数据。这包括数据的存储,也包括查询或操作数据的各种方式。理想情况下,模型不受数据显示方式或 UI 控件授予方式的影响,而是提供一个高级界面,该界面只与其他组件的内部工作关系最小。理论上,如果您决定完全更改程序的 UI(例如,从 Tkinter 应用更改为 web 应用),那么模型应该完全不受影响。

您在模型中找到的一些功能或信息示例包括:

  • 准备程序数据并将其写入持久介质(数据文件、数据库等)
  • 将文件或数据库中的数据检索成对程序有用的格式
  • 一组数据中字段的权威列表及其数据类型和限制
  • 根据定义的数据类型和限制验证数据
  • 对存储数据的计算

目前我们的应用中没有模型类;数据布局是在 form 类中定义的,Application.on_save()方法是目前唯一与数据持久性相关的代码。我们需要将此逻辑拆分为一个单独的对象,该对象将定义数据布局并处理所有 CSV 操作。

什么是景观?

视图是向用户呈现数据和控件的界面。应用可能有许多视图,通常位于同一数据上。视图不直接与模型对话,理想情况下只包含足够的逻辑来呈现 UI 并将用户操作反馈给控制器。

在视图中找到的一些代码示例包括:

  • GUI 布局和小部件定义
  • 表单自动化,例如自动完成字段、动态切换小部件或显示错误对话框
  • 为演示文稿设置原始数据的格式

我们的DataRecordForm类是我们的主要视图:它包含应用用户界面的大部分代码。它目前还定义了数据记录的结构。这种逻辑可以保留在视图中,因为视图在将数据传递给模型之前确实需要一种临时存储数据的方法,但从现在起,它不会定义我们的数据记录。

我们将在前进的过程中为应用添加更多视图。

什么是控制器?

控制器是应用的大中心站。它处理来自用户的请求,并负责在视图和模型之间路由数据。MVC 的大多数变体都会更改控制器的角色(有时还会更改其名称),但重要的是,它充当视图和模型之间的中介。我们的控制器对象需要保存对应用使用的视图和模型的引用,并负责管理它们之间的交互。

控制器中的代码示例包括:

  • 应用的启动和关闭逻辑
  • 用户界面事件的回调
  • 创建模型和视图实例

我们的Application对象目前充当我们应用的控制器,尽管它也有一些视图和模型逻辑。随着应用的发展,我们将把更多的表示逻辑移到视图中,把更多的数据逻辑移到模型中,在Application对象中留下主要的连接代码。

为什么要使我们的设计复杂化?

最初,以这种方式拆分应用似乎会带来很多不必要的开销。我们必须在不同的对象之间传递数据,并最终编写更多代码来完成完全相同的工作。我们为什么要这样做?

简单地说,我们这样做是为了让扩张变得易于管理。随着应用的增长,复杂性也会增加。将我们的组件彼此隔离,限制了任何一个组件必须管理的复杂性;例如,当我们重新构造表单视图的布局时,我们不需要担心模型将如何构造输出文件中的数据。该计划的这两个方面应该相互独立。

这也有助于我们在放置某些类型的逻辑时保持一致。例如,拥有一个离散的模型对象可以帮助我们避免将 UI 代码与临时数据查询或文件访问尝试混为一谈。

底线是,如果没有一些指导性的架构策略,我们的程序有可能成为一个毫无希望的意大利面条逻辑的纠结。即使不遵守 MVC 设计的严格定义,当应用变得更加复杂时,始终遵循即使是松散的 MVC 模式也会省去很多麻烦。

构建我们的应用目录

正如在逻辑上将程序划分为不同的关注点有助于我们管理每个组件的逻辑复杂性一样,在物理上将代码划分为多个文件有助于我们管理每个文件的复杂性。它还加强了组件之间的隔离;例如,你不能共享全局变量,如果你的模型文件导入了tkinter,你就知道你做错了。

基本目录结构

目前还没有制定 Python 应用目录的官方标准,但有一些常见的约定可以帮助我们保持整洁,并使以后打包软件变得更容易。让我们按如下方式设置目录结构:

  1. 首先,创建一个名为ABQ_Data_Entry的目录。这是我们应用的根目录,所以每当我们提到应用根目录时,这就是它。

  2. 在应用根目录下,创建另一个名为abq_data_entry的目录。注意它是小写的。这将是一个 Python 包,包含应用的所有代码;它应该总是被赋予一个相当独特的名称,这样它就不会与现有的 Python 包混淆;我们在这里这样做是为了避免混淆。

Python 模块应始终使用带下划线的所有小写名称命名。这个约定在 Python 的官方样式指南 PEP8 中有详细说明。参见https://www.python.org/dev/peps/pep-0008 了解政治公众人物 8 的更多信息。

  1. 接下来,在应用根目录下创建一个docs文件夹。此文件夹将用于存储有关应用的文档文件。
  2. 最后,在应用根目录中创建两个空文件:README.rstabq_data_entry.py,您的目录结构如下:

abq_data_entry.py 文件

与之前一样,abq_data_entry.py是启动程序所执行的主文件。不过,与以前不同的是,它不会包含我们程序的大部分内容。事实上,这个文件应该尽可能小。

打开文件并输入以下代码:

from abq_data_entry.application import Application

app = Application()
app.mainloop()

保存并关闭文件。这个文件的唯一目的是导入我们的Application类,创建一个实例并运行它。余下的工作将在abq_data_entry包内进行。我们还没有创建它,所以这个文件还不能运行;在此之前,让我们先处理一下文档。

README.rst 文件

早在 20 世纪 70 年代,程序就包含一个名为README的简短文本文件,其中包含程序文档的简明摘要。对于小型程序,它可能是唯一的文档;对于较大的程序,它通常包含用户或管理员的基本飞行前说明。

没有一个指定的内容集合用于一个 ORT T0 文件,但是作为一个基本的指南,考虑下面的部分:

  • 说明:程序及其功能的简要说明。我们可以重用规范中的描述,或者类似的描述。这还可能包含主要功能的简要列表。
  • 作者信息:作者姓名及版权日期。如果您计划共享您的软件,这一点尤其重要,但即使是对于内部的某些东西,未来的维护人员也可以了解谁创建了软件以及何时创建软件。
  • 要求:软件的软硬件要求清单,如有。
  • 安装:软件的安装说明、前提条件、依赖关系和基本设置。
  • 配置:如何配置应用,有哪些选项可用。这通常针对命令行或配置文件选项,而不是程序中交互设置的选项。
  • 用法:说明如何启动应用、命令行参数以及用户使用应用基本功能所需的其他注意事项。
  • 一般注意事项:用户应注意的所有注意事项或关键信息。
  • bug:应用中已知 bug 或限制的列表。

并非所有这些章节都适用于每个项目;例如,ABQ 数据条目当前没有任何配置选项,因此没有理由设置配置部分。您也可以根据情况添加其他部分;例如,公开发布的软件可能有一个 FAQ 部分,或者开源软件可能有一个关于如何提交补丁的说明的贡献部分。

README文件以 ASCII 或 Unicode 纯文本编写,可以是自由格式,也可以使用标记语言。因为我们正在做一个 Python 项目,所以我们将使用 StructuredText,Python 文档的官方标记(这就是我们的文件使用rst文件扩展名的原因)。

重组文本

StructuredText 标记语言是 Pythondocutils项目的一部分,可以在 Docutils 网站上找到完整的参考资料 http://docutils.sourceforge.netdocutils项目还提供了将 RST 转换为 PDF、ODT、HTML 和 LaTeX 等格式的实用程序。

基本知识可以很快掌握,所以让我们来了解一下:

  • 段落是通过在文本块之间留下一个空行来创建的。
  • 标题是用非字母数字符号在单行文本下划线创建的。确切的符号并不重要;您首先使用的那个将被视为文档其余部分的一级标题,第二个将被视为二级标题,依此类推。通常情况下,=用于一级,-用于二级,~用于三级,+用于四级。
  • 标题和副标题的创建与标题类似,只是上面和下面都有一行符号。
  • 项目符号列表是以*-+中的任何一个和空格开头的行创建的。切换符号将创建一个子列表,多行点是通过将后续行缩进到文本从第一个项目符号开始的位置来创建的。
  • 编号列表与项目符号列表类似,但使用数字(不需要正确排序)或#符号作为项目符号。
  • 代码示例可以内联指定,方法是将它们括在双反勾字符(```py`)中,或者在一个块中指定,方法是在引入行末尾加上双冒号并缩进代码块。
  • 表格可以通过使用=符号包围文本列、用空格分隔以表示分栏来创建,也可以通过从|-+构建 ASCII 艺术表格来创建。在纯文本编辑器中创建表可能会很乏味,但是一些编程工具有插件来生成 RST 表。

我们已经在第 2 章使用 Tkinter 设计 GUI 应用中使用 RST 来创建我们的程序规范;在这里,您看到了标题、标题、项目符号和表格的使用。让我们浏览一下创建README.rst文件的过程:

  1. 打开文件并以标题和说明开始,如下所示:
============================
 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.  接下来,我们将通过添加以下代码列出作者:

Authors

Alan D Moore, 2018

当然可以加上你自己最终其他人可能会处理您的应用他们应该在这里加上自己的名字和工作日期现在添加如下要求

Requirements

  • Python 3
  • Tkinter
现在我们只需要 Python3  Tkinter但随着应用的增长我们可能会扩展这个列表我们的应用实际上不需要安装也没有配置选项所以现在我们可以跳过这些部分相反我们将跳到`Usage`如下

Usage

To start the application, run::

python3 ABQ_Data_Entry/abq_data_entry.py

除了这个命令关于运行这个程序真的没有什么需要知道的没有命令行开关或参数我们不知道有任何 bug因此我们只在最后留下一些一般性的注释如下所示

General Notes

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`如下

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 软件它被诸如 GitHubBitbucketSourceForge  GitLab 等源代码共享站点所利用Git 非常强大掌握它可能需要几个月或几年的时间幸运的是基础知识可以在几分钟内掌握首先您需要安装 Git访问[https://git-scm.com/downloads](https://git-scm.com/downloads) 了解如何在 macOSWindowsLinux 或其他 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 的应用菜单小部件,如何实现文件打开和保存,以及如何使用消息弹出窗口提醒用户或确认操作。