Skip to content

Latest commit

 

History

History
1391 lines (1029 loc) · 78.5 KB

File metadata and controls

1391 lines (1029 loc) · 78.5 KB

十四、配置文件和持久化

配置文件是对象持久化的一种形式。它包含应用程序某些默认状态的序列化、纯文本、可编辑表示。我们将扩展第 10 章序列化和保存中所示的序列化技术–JSON、YAML、Pickle、CSV 和 XML,以创建专门用于应用程序配置的文件。对纯文本的关注意味着 pickle 表示将被排除在外。由于应用程序配置的相对复杂性,CSV 文件也不经常用于此目的。

在用户可以使用可编辑的纯文本配置文件之前,我们必须将应用程序设计为可配置的。这通常需要仔细考虑依赖关系和限制。此外,我们必须定义应用程序将使用的某种配置对象。在许多情况下,配置将基于默认值;我们可能希望允许系统范围内的默认值与用户特定的覆盖这些默认值。我们将探讨配置数据的六种表示形式,如下所示:

  • INI 文件使用的格式是 Windows 的一部分。该文件之所以受欢迎,部分原因在于它是现有格式中的一种,并且有着悠久的历史。
  • PY 文件是普通的老 Python 代码。由于语法的熟悉性和简单性,它们有许多优点。该配置可由应用程序中的import语句使用。
  • JSON 和 YAML 都设计为用户友好且易于编辑。
  • 属性文件通常在 Java 环境中使用。它们相对容易使用,而且它们的设计也很人性化。这方面没有内置的解析器,本章包括了这种文件格式的正则表达式。
  • XML 文件很流行,但它们很冗长,这意味着有时很难正确编辑它们。macOS 使用基于 XML 的格式,称为属性列表或 PLIST 文件。

每种形式都给我们带来了一些优点和缺点;没有一种技术可以说是最好的。在许多情况下,选择是基于对其他软件的熟悉程度和兼容性。在第 15 章设计原则和模式中,我们将回到配置这一主题。此外,在后面的章节中,我们将广泛使用配置选项。在本章中,我们将介绍以下主题:

  • 配置文件用例
  • 表示、持久化、状态和可用性
  • 在 INI 文件和 PY 文件中存储配置
  • 通过eval()变量处理更多文本
  • 在 PY 文件中存储配置
  • 为什么exec()不是问题
  • 使用ChainMap进行默认和覆盖
  • 在 JSON 或 YAML 中存储配置
  • 使用 PLIST 等 XML 文件

技术要求

本章的代码文件可在上找到 https://git.io/fj2UP

配置文件用例

有两个配置文件用例。有时,我们可以稍微扩展定义以添加第三个用例。前两个应该非常清楚:

  • 用户需要编辑配置文件
  • 一个软件将读取一个配置文件,并利用选项和参数来定制其行为

配置文件很少是应用程序的主要输入。通常,他们只定制程序的行为。例如,web 服务器的配置文件可能会定制服务器的行为,但 web 请求是一个主要输入,而数据库或文件系统是另一个主要输入。在 GUI 应用程序的情况下,用户的交互事件是一个输入,文件或数据库可能是另一个输入;配置文件可以微调应用程序;在这种情况下,配置参数可能是主要输入

应用程序和配置输入之间的区别也很模糊。理想情况下,无论配置细节如何,应用程序都有一个行为。但是,从实用角度来看,配置可能会为现有应用程序引入额外的策略或状态,从而从根本上改变其行为。在这种情况下,配置参数成为代码的一部分,而不仅仅是应用于固定代码库的选项或限制。

除了由人更改之外,配置文件的另一个用例是保存应用程序的当前状态。例如,在 GUI 应用程序中,保存各种窗口的位置和大小是很常见的。另一个例子是,web 界面通常使用 cookies 来保存事务状态。将缓慢变化的面向用户的配置与应用程序当前操作状态的更动态的属性分离开来通常是有帮助的。可以使用相同类型的文件格式,但这两种用途是不同的。

配置文件可以为应用程序提供多个参数域和参数值。我们需要更详细地研究这些不同类型的数据中的一些,以便决定如何最好地表示它们。以下列出了一些常见的参数类型:

  • 设备名称,可能与文件系统的位置重叠
  • 文件系统位置和搜索路径
  • 界限
  • 消息模板和数据格式规范
  • 消息文本,可能为国际化而翻译
  • 网络名称、地址和端口号
  • 可选行为,有时称为功能切换
  • 安全密钥、令牌、用户名和密码

这些值来自许多不同的域。通常,配置值具有使用字符串、整数和浮点数的相对通用的表示形式。其目的是使用一个整洁的文本表示,这对一个人来说相对容易编辑。这意味着我们的 Python 应用程序必须解析面向人的输入。

在某些情况下,我们可能有需要分隔符的值列表。我们也可能有大的多行文本块。在这些情况下,值的表示可能涉及更复杂的标点和更复杂的解析算法。

还有一个附加的配置值,它不是一个简单的类型,具有整洁的文本表示形式。我们可以将此项目符号添加到前面的列表中:

  • 附加功能、插件和扩展;实际上,附加代码

这种配置值具有挑战性。我们不一定提供简单的字符串输入或数字限制。事实上,我们提供了扩展应用程序的代码。当插件在 Python 代码中时,一个选项是提供已安装 Python 模块的路径,因为它将在使用此虚线名称的import语句中使用:package.module.object。然后,应用程序可以执行from package.module import object的变化,并使用给定的类或函数作为应用程序的一部分。

对于作为插件或扩展的一部分引入非 Python 代码的配置,我们有两种其他技术来使外部代码可用:

  • 对于不适合执行的二进制程序,我们可以尝试使用ctypes模块调用已定义的 API 方法
  • 对于可执行程序的二进制文件,subprocess模块为我们提供了执行它们的方法

这两种技术都超出了本书的范围。本章将重点讨论获取参数或参数值的核心问题,这些参数或参数值是传统的 Python 值。

下一节将讨论表示、持久化、状态和可用性。

表示、持久化、状态和可用性

在查看配置文件时,我们看到的是对象状态的人性化版本。通常,我们会提供多个对象的状态。当我们编辑配置文件时,我们正在更改对象的持久状态,当应用程序启动(或重新启动)时,该对象将被重新加载。我们有两种查看配置文件的常用方法:

  • 从参数名到配置值的一个或一组映射。请注意,即使存在嵌套映射,结构也基本上是键和值。
  • 具有配置值的复杂属性和属性的序列化对象。区别在于除了用户提供的值之外,还可以使用属性、方法和派生值。

这两种观点是等价的;映射视图依赖于内置字典或命名空间对象。序列化对象将是一个更复杂的 Python 对象,它是从对象的外部可编辑表示形式创建的。字典的优点是将几个参数放入一个简单的结构中非常简单。序列化对象的优点是能够跟踪复杂关系的数量。

要使平面字典或命名空间正常工作,必须仔细选择参数名称。设计配置的一部分是设计有用的键,这是我们在第 11 章通过 Shelve*、*和第 12 章、*通过 SQLite 存储和检索对象中看到的。*映射需要唯一的名称,以便应用程序的其他部分可以正确地引用它。

当我们试图将一个配置文件简化为一个映射时,我们经常会发现存在多组相关参数。这将在整个名称集合中产生名称空间。让我们考虑使用其他 Web 服务的 Web 应用程序;我们可能有两组平行的参数:service_one_host_nameservice_one_port_number,以及service_two_host_nameservice_two_port_number。这可以是四个独立的名称,或者我们可以使用更复杂的结构将名称组合成两个相关的组;例如,通过创建一个配置数据结构,例如{"service_one": {"host_name": "example.com", "port_number": 8080}, etc.}

在使用简单映射和使用更复杂的序列化 Python 对象之间存在着模糊的距离。我们将在本章中介绍的一些模块使用复杂的嵌套字典和命名空间对象。各种备选解决方案表明,没有单一的最佳方式来组织配置参数。

查看logging配置示例,了解如何配置复杂系统非常具有挑战性,这是很有帮助的。Python 日志对象记录器、格式化程序、过滤器和处理程序之间的关系必须绑定在一起,以创建应用程序可用的记录器。如果缺少任何部件,记录器将不会产生输出。标准库参考的第 16.8 节描述了记录配置文件的两种不同语法。我们来看看登录第 16 章日志和警告模块

在某些情况下,直接使用 Python 代码作为配置文件可能更简单。在本例中,配置是一个 Python 模块,使用一个简单的import语句引入详细信息。如果配置文件的语法增加了太多的复杂性,那么它可能没有任何实际价值。

一旦确定了总体数据结构,该结构的范围就有两种常见的设计模式。

应用程序配置设计模式

对于用于配置 Python 应用程序的对象范围,有两种核心设计模式:

  • 全局属性映射:全局对象可以包含所有配置参数。简单的类定义可能是提供名称和值的理想方式;这倾向于遵循单例设计模式,确保只存在一个实例。备选方案包括一个具有name: value对的字典,或一个具有属性值的types.SimpleNamespace对象。
  • 对象构造:我们将定义一种工厂工厂集合,使用配置数据来构建应用程序的对象,而不是单个对象。在这种情况下,配置信息在程序启动时使用一次,以后不再使用。配置信息不是作为全局对象保存的。

全局属性地图设计非常流行,因为它简单且可扩展。第一个示例是使用类对象,其定义如下:

class Configuration: 
   some_attribute = "default_value" 

我们可以使用前面的类定义作为属性的全局容器。在初始化过程中,在解析配置文件时,我们可能会遇到类似的情况:

Configuration.some_attribute = "user-supplied value" 

在程序的其他地方,我们可以使用Configuration.some_attribute的值。这个主题的一个变体是创建一个更正式的单例类设计模式。这通常是通过一个全局模块完成的,因为它可以以一种为我们提供可访问的全局定义的方式轻松导入。

第二个示例涉及使用模块进行配置。我们可能有一个名为configuration.py的模块。在该文件中,我们可以有如下定义:

settings = {
    "some_attribute": "user-supplied value"
}

现在,应用程序可以使用configuration.settings作为应用程序所有设置的全局存储库。函数或类可以解析配置文件,用应用程序将使用的配置值加载此字典。

在 21 点模拟中,我们可能会看到以下代码:

shoe = Deck(configuration.settings['decks']) 

或者,我们可能会看到以下代码:

If bet > configuration.settings['limit']: raise InvalidBet() 

通常,我们会尽量避免为配置使用全局变量。由于全局变量在任何地方都隐式存在,因此除了配置值之外,它还可能被误用来进行有状态处理。我们通常可以通过对象构造相对更灵活地处理配置,而不是全局变量。在下一节中,我们将查看构造对象作为实现配置更改的方法的示例。

通过对象构造进行配置

通过对象构造配置应用程序时,目标是在启动时构建所需的对象。实际上,配置文件定义了将要生成的对象的各种初始化参数。

我们通常可以将大部分初始对象构造集中在一个main()函数中。这将创建执行应用程序实际工作的对象。我们将在第 18 章处理命令行中重新讨论并扩展这些设计问题。

现在我们来考虑一个模拟二十一点游戏和赌博策略。当我们运行模拟时,我们希望收集自变量的特定组合的性能。这些变量可能包括一些赌场政策,包括套牌数量、桌位限制和庄家规则。这些变量可能包括玩家的游戏策略,比如何时击打、站立、分开和击倒。它还可以包括玩家的投注策略,如平盘投注、鞅投注或一些更拜占庭式的投注系统;我们的基线代码是这样开始的:

    import csv

def     simulate_blackjack() ->     None    :
    # Configuration
    dealer_rule = Hit17()
    split_rule = NoReSplitAces()
    table = Table(
            decks    =    6    ,     limit    =    50    ,     dealer    =dealer_rule, 
            split    =split_rule,     payout    =(    3    ,     2    )
    )
    player_rule = SomeStrategy()
    betting_rule = Flat()
    player = Player(
            play    =player_rule,     betting    =betting_rule, 
            max_rounds    =    100    , init_    stake    =    50
        )

    # Operation
    simulator = Simulate(table, player,     samples    =    100    )
    result_path = Path.cwd() /     "data"     /     "ch14_simulation.dat"
            with     result_path.open(    "w"    ,     newline    =    ""    )     as     results:
        wtr = csv.writer(results)
        wtr.writerows(gamestats)

在本例中,代码的Configuration部分构建了Operation阶段中要使用的六个单独对象。这些对象包括dealer_rulesplit_ruletableplayer_rulebetting_ruleplayer。此外,table和辅助对象以及player和其他两个对象之间存在一组复杂的依赖关系。

代码的第二部分Operation使用tableplayer构建了一个Simulate实例。然后,csvwriter 对象从simulator实例写入行。最终的writerows()函数取决于Simulate类提供的__next__()方法。

前面的例子是一种技术尖峰——一种初始草案解决方案——具有硬编码的对象实例和初始值。任何改变本质上都是重写。更完善的应用程序将依赖外部提供的配置参数来确定对象的类别及其初始值。当我们从代码中分离配置参数时,这意味着我们不必调整代码来进行更改。这为我们提供了一致的、可测试的软件。通过更改配置输入,而不是更改代码,可以完成一个小的更改。

Simulate类有一个类似于以下代码的 API:

    from dataclasses import dataclass

@dataclass
        class     Simulate:
        """Mock simulation."""

                table: Table
    player: Player
    samples:     int

                    def         __iter__    (    self    ) -> Iterator[Tuple]:
            """Yield statistical samples."""
        # Actual processing goes here...    

这允许我们使用一些适当的初始化参数来构建Simulate()对象。一旦我们构建了Simulate()的一个实例,我们就可以迭代该对象以获得一系列统计摘要对象。

下一个版本可以使用配置文件中的配置参数,而不是硬编码的类名。例如,应该使用一个参数来决定是否为dealer_rule值创建Hit17Stand17实例。类似地,split_rule值应该是体现赌场中使用的几种不同分割规则的几个类别中的一种选择。

在其他情况下,应使用参数值为Simulate__init__()方法提供参数。例如,牌组数、豪斯博彩限额和 21 点支付值是用于创建Table实例的配置值。

一旦构建了对象,它们就会通过Simulate.__next__()方法正常交互,生成一系列统计输出值。不再需要全局参数池:参数值通过其实例变量绑定到对象中。

对象构造设计没有全局属性映射那么简单。虽然更复杂,但它具有避免全局变量的优点,并且它还具有使参数处理在主工厂函数中处于中心和明显位置的优点。

在使用对象构造时添加新参数可能会导致重构应用程序以公开参数或关系。这会使它看起来比从名称到值的全局映射更复杂。

这种技术的一个显著优点是删除了应用程序中深层的复杂if语句。使用Strategy设计模式倾向于将决策推进到对象构造中。除了简化处理外,if语句的删除意味着要执行的语句更少,这可以提高性能。

在下一节中,我们将演示如何实现配置层次结构。

实现配置层次结构

对于配置文件应该放在哪里,我们通常有几种选择。有几个公共位置,我们可以使用任意选项组合为参数创建一种继承层次结构:

  • Python 安装目录:我们可以通过模块的__file__属性找到模块的安装位置。从这里,我们可以使用Path对象定位配置文件:
>>> import this 
>>> from pathlib import Path
>>> Path(this.__file__)
PosixPath('/Users/slott/miniconda3/envs/mastering/lib/python3.7/this.py')
  • 系统应用程序安装目录:这通常基于拥有的用户名。在某些情况下,我们可以简单地创建一个特殊的用户 ID 来拥有应用程序本身。这让我们可以使用~theapp/作为配置位置。我们可以使用Path("~theapp").expanduser()跟踪配置默认值。在其他情况下,应用程序的代码可能位于/opt/var目录中。
  • 系统范围的配置目录:通常出现在/etc中。请注意,这可以在 Windows 上转换为C:\etc
  • 当前用户的主目录:我们通常使用Path.home()来标识用户的主目录。
  • 当前工作目录:我们通常使用Path.cwd()来标识当前工作目录。
  • 命令行参数中命名的文件:这是一个显式命名的文件,不需要对名称进行进一步处理。

应用程序可以集成所有这些源的配置选项。任何安装默认值都应视为最通用且最不特定于用户的;这些默认值可以由更具体的值覆盖。

这可能会产生一个文件列表,如以下代码:

from pathlib import Path

config_locations = (
    Path(__file__),
        # Path("~thisapp").expanduser(), requires special username
    Path("/opt") / "someapp",
                Path(    "/etc"    ) / "someapp",
    Path.home(),
    Path.cwd(),
)
candidates = (dir / "someapp.config" 
         for         dir         in         config_locations)
    config_paths = [path     for     path     in     candidates     if     path.exists()]

这里,config_locations变量是配置文件可能所在的可选路径的元组。candidates生成器将创建包含具有公共基名称someapp.config的基路径的路径。最后一个列表对象config_paths是为那些实际存在的路径构建的。其思想是首先提供最通用的名称,最后提供最用户特定的名称。

一旦有了这个配置文件名列表,我们就可以使用以下代码将通过命令行参数提供的任何文件名附加到列表的末尾:

config_paths.append(command_line_option) 

这为我们提供了放置用户更新配置文件的位置列表以及配置默认值。

让我们来看看如何在 INI 文件中存储配置。

将配置存储在 INI 文件中

INI 文件格式起源于早期的 Windows 操作系统。解析这些文件的模块是configparser。有关 INI 文件的更多详细信息,您可以参考这篇 Wikipedia 文章中的许多有用链接:http://en.wikipedia.org/wiki/INI_file

INI 文件在每个节中都有属性。我们的示例主程序有三个部分:表配置、播放器配置和整体模拟数据收集。对于此模拟,我们将使用与以下示例类似的 INI 文件:

; Default casino rules 
[table] 
    dealer= Hit17 
    split= NoResplitAces 
    decks= 6 
    limit= 50 
    payout= (3,2) 

; Player with SomeStrategy 
; Need to compare with OtherStrategy 
[player] 
    play= SomeStrategy 
    betting= Flat 
    max_rounds= 100 
    init_stake= 50 

[simulator] 
    samples= 100 
    outputfile= p2_c13_simulation.dat 

我们将参数分为三个部分。在每个部分中,我们都提供了一些命名参数,这些参数与前面的模型应用程序初始化中显示的类名和初始化值相对应。

可以使用本示例中所示的代码解析单个文件:

import configparser 
config = configparser.ConfigParser() 
config.read('blackjack.ini') 

在这里,我们创建了一个解析器实例,并向该解析器提供了目标配置文件名。解析器将读取文件,定位节,并定位每个节中的各个属性。

如果我们想支持文件的多个位置,我们可以使用config.read(config_names)。当我们向ConfigParser.read()提供文件名列表时,它将按照特定顺序读取文件。我们希望提供从最通用的第一个到最具体的最后一个的文件。将首先解析作为软件安装一部分的通用配置文件,以提供默认值。稍后将解析特定于用户的配置以覆盖这些默认值。

解析完文件后,我们需要使用各种参数和设置。下面是一个函数,它基于通过解析配置文件创建的给定配置对象来构造对象。我们将把它分成三部分;下面是构建Table实例的部分:

    def     main_ini(config: configparser.ConfigParser) ->     None    :
    dealer_nm = config.get(    "table"    ,     "dealer"    ,     fallback    =    "Hit17"    )
    dealer_rule = {
            "Hit17"    : Hit17(),
            "Stand17"    : Stand17(),
    }.get(dealer_nm, Hit17())
    split_nm = config.get(    "table"    ,     "split"    ,     fallback    =    "ReSplit"    )
    split_rule = {
            "ReSplit"    : ReSplit(),
            "NoReSplit"    : NoReSplit(),
            "NoReSplitAces"    : NoReSplitAces(),
    }.get(split_nm, ReSplit())
    decks = config.getint(    "table"    ,     "decks"    ,     fallback    =    6    )
    limit = config.getint(    "table"    ,     "limit"    ,     fallback    =    100    )
    payout =     eval    (
        config.get(    "table"    ,     "payout"    ,     fallback    =    "(3,2)"    )
    )
    table = Table(
            decks    =decks,     limit    =limit,     dealer    =dealer_rule, 
            split    =split_rule,     payout    =payout
    )

我们已经使用 INI 文件[table]部分的属性来选择类名并提供初始化值。这里有三大类情况:

  • 将字符串映射到类名:我们已经使用了一个映射来根据字符串类名查找对象。这样做是为了创建dealer_rulesplit_rule。如果类池受到相当大的更改,我们可以将此映射移动到单独的工厂函数中。字典的.get()方法包括一个默认的对象实例,例如Hit17()
  • 获取ConfigParser可以为我们解析的值:类可以直接处理strintfloatbool等内置类型的值。像getint()这样的方法处理这些转换。该类具有从字符串到布尔值的复杂映射,使用了多种常见代码和同义词TrueFalse
  • 评估非内置的东西:在payout的情况下,我们有一个字符串值'(3,2)',它不是ConfigParser直接支持的数据类型。我们有两个选择来处理这个问题。我们可以尝试自己解析它,或者我们可以坚持该值是一个有效的 Python 表达式,并让 Python 这样做。在本例中,我们使用了eval()。一些程序员称之为安全问题。下一节将讨论这一点。

下面是本例的第二部分,它使用 INI 文件[player]部分的属性来选择类和参数值:

    player_nm = config.get(
        "player", "play", fallback="SomeStrategy")
    player_rule = {
        "SomeStrategy": SomeStrategy(),
        "AnotherStrategy": AnotherStrategy()
    }.get(player_nm, SomeStrategy())
    bet_nm = config.get("player", "betting", fallback="Flat")
    betting_rule = {
        "Flat": Flat(),
        "Martingale": Martingale(),
        "OneThreeTwoSix": OneThreeTwoSix()
    }.get(bet_nm, Flat())
    max_rounds = config.getint("player", "max_rounds", fallback=100)
    init_stake = config.getint("player", "init_stake", fallback=50)
    player = Player(
        play=player_rule, 
        betting=betting_rule,
        max_rounds=max_rounds, 
        init_stake=init_stake
    )

这使用字符串到类的映射以及内置的数据类型。它初始化两个策略对象,然后根据这两个策略以及两个整数配置值创建Player

这是最后一部分;这将创建整个模拟器:

outputfile = config.get(
    "simulator", "outputfile", fallback="blackjack.csv")
samples = config.getint("simulator", "samples", fallback=100)
simulator = Simulate(table, player, samples=samples)
with Path(outputfile).open("w", newline="") as results:
    wtr = csv.writer(results)
    wtr.writerows(simulator)

我们使用了[simulator]部分的两个参数,它们超出了对象创建的狭窄范围。outputfile属性用于命名文件;samples属性作为方法函数的参数提供。

下一节将演示如何通过eval()变体处理更多文本。

通过 eval()变量处理更多文本

配置文件可能具有不具有简单字符串表示形式的类型值。例如,集合可以作为tuplelist文本提供;映射可以作为dict文本提供。我们有几种选择来处理这些更复杂的值。

这些选择解决了转换能够容忍多少 Python 语法的问题。对于某些类型(intfloatboolcomplexdecimal.Decimalfractions.Fraction,我们可以安全地将字符串转换为文字值,因为这些类型的__init__()对象可以处理字符串值。

但是,对于其他类型,我们不能简单地进行字符串转换。关于如何继续,我们有几种选择:

  • 禁止这些数据类型,并依赖配置文件语法和处理规则从非常简单的部分组装复杂的 Python 值;这是乏味的,但它可以工作。在表支出的情况下,我们需要将支出分成分子和分母的两个单独的配置项。对于一个简单的两元组来说,这是一个非常复杂的配置文件。
  • 使用ast.literal_eval(),因为它处理许多 Python 文本值的情况。这通常是理想的解决方案。
  • 使用eval()简单地计算字符串并创建预期的 Python 对象。这将解析比ast.literal_eval()更多种类的对象。但是,确实要考虑一下这种普遍性是否真的需要。
  • 使用ast模块编译并检查生成的代码对象。这个审查过程可以检查import语句,也可以使用一些小的允许模块集。这相当复杂,;如果我们有效地允许代码,也许我们应该设计一个框架,并简单地包括 Python 代码。

如果我们通过网络执行 Python 对象的 RESTful 传输,那么结果文本的eval()是不可信的。您可以参考第 10 章序列化和保存–JSON、YAML、Pickle、CSV 和 XML

然而,在读取本地配置文件的情况下,eval()肯定是可用的。在某些情况下,Python 应用程序代码与配置文件一样容易修改。当基本代码可以调整时,担心eval()可能没有帮助。

以下是我们如何使用ast.literal_eval()而不是eval()

>>> import ast 
>>> ast.literal_eval('(3,2)') 
(3, 2) 

这拓宽了配置文件中可能值的范围。它不允许任意 Python 对象,但它允许广泛的文本值,包括元组。

让我们来看看如何在 PY 文件中存储配置。

将配置存储在 PY 文件中

PY 文件格式意味着使用 Python 代码作为配置文件。这与使用相同的语言实现应用程序很好地匹配。我们将有一个简单的模块配置文件;配置是用 Python 语法编写的。这可以消除获取配置值所需的复杂解析。

使用 Python 为我们提供了一些设计注意事项。使用 Python 作为配置文件有两种总体策略:

  • 顶层脚本:在本例中,配置文件只是最顶层的主程序。
  • exec()导入:在这种情况下,我们的配置文件提供收集到模块全局变量中的参数值。

我们可以设计如下代码所示的顶级脚本文件:

from simulator import *

def simulate_SomeStrategy_Flat() -> None:
    dealer_rule = Hit17()
    split_rule = NoReSplitAces()
    table = Table(
        decks=6, limit=50, dealer=dealer_rule, split=split_rule, payout=(3, 2)
    )
    player_rule = SomeStrategy()
    betting_rule = Flat()
    player = Player(
        play=player_rule, betting=betting_rule, max_rounds=100, init_stake=50)

    simulate(table, player, Path.cwd()/"data"/"ch14_simulation2a.dat", 100)

if __name__ == "__main__": 
    simulate_SomeStrategy_Flat() 

这提供了许多用于创建和初始化对象的配置参数。在这种应用程序中,配置只是作为代码编写的。我们已经将公共处理分解成一个单独的函数simulate(),它使用配置的对象tableplayerPath目标;以及要生成的样本数。配置以代码的形式呈现,而不是解析和转换字符串。

使用 Python 作为配置语言的一个潜在缺点是 Python 语法的潜在复杂性。这通常是一个不相关的问题,原因有二。首先,经过一些仔细的设计,配置的语法应该是简单的赋值语句,包含几个(),实例。其次,更重要的是,其他配置文件有自己复杂的语法,这与 Python 语法不同。使用单一语言可以大大降低复杂性。

simulate()功能是从整个simulator应用程序中导入的。此simulate()功能类似于以下代码:

import csv
from pathlib import Path

    def     simulate(table: Table, player: Player, outputpath: Path, samples:     int    ) ->     None    :
    simulator = Simulate(table, player,     samples    =samples)
        with     outputpath.open(    "w"    ,     newline    =    ""    )     as     results:
        wtr = csv.writer(results)
            for     gamestats     in     simulator:
            wtr.writerow(gamestats)

此函数对于表、播放器、文件名和样本数是通用的。给定所需的配置对象,它构建最终的Simulate实例并收集结果数据。

这种配置技术的一个潜在困难是缺少方便的默认值。顶层脚本必须完整,即所有配置参数必须存在,在大多数情况下,这不是限制。在一些默认值很重要的情况下,我们将研究两种提供有用默认值的方法。

通过类定义进行配置

顶级脚本配置有时遇到的困难是缺少方便的默认值。为了提供默认值,我们可以使用普通类继承。下面是我们如何使用类定义构建具有配置值的对象:

    class     Example2(simulation.AppConfig):
    dealer_rule = Hit17()
    split_rule = NoReSplitAces()
    table = Table(
            decks    =    6    ,     limit    =    50    ,     dealer    =dealer_rule,     split    =split_rule,     payout    =(    3    ,     2    )
    )
    player_rule = SomeStrategy()
    betting_rule = Flat()
    player = Player(    play    =player_rule,     betting    =betting_rule,     max_rounds    =    100    ,     init_stake    =    50    )
    outputfile = Path.cwd()/    "data"    /    "ch14_simulation2b.dat"
                samples =     100

这允许我们使用默认配置值定义AppConfig类。我们在这里定义的类Example2可以覆盖AppConfig类中定义的默认值。

我们还可以使用 mixin 将定义分解为可重用的部分。我们可以将类分解为表、播放器和模拟组件,并通过 mixin 将它们组合起来。有关 mixin 类设计的更多信息,请参见第 9 章装饰和 mixin–横切方面

在两个方面,类定义的使用推动了面向对象设计的发展。这种类没有方法定义;我们只将这个类用作单例对象。但是,这是一种非常整洁的方式,可以打包一小块代码,以便赋值语句填充一个小名称空间。

我们可以修改我们的simulate()函数以接受此类定义作为参数:

    def     simulate_c(config: Union[Type[AppConfig], SimpleNamespace]) ->     None    :
    simulator = Simulate(config.table, config.player, config.samples)
        with     Path(config.outputfile).open(    "w"    ,     newline    =    ""    )     as     results:
        wtr = csv.writer(results)
        wtr.writerow(simulator)

此函数从整体配置对象中选取相关值config.tableconfig.playerconfig.samples,并使用它们构建Simulate实例并执行该实例。结果与前面的simulate()函数相同,但参数结构不同。下面是我们如何为该函数提供类的单个实例:

if __name__ == "__main__": 
    simulation.simulate_c(Example2) 

请注意,我们没有提供Example2类的实例。我们正在使用类对象本身。Type[AppConfig]类型提示显示类本身是预期的,而不是类的实例。

这种方法的一个潜在缺点是收集命令行参数与argparse不兼容。我们可以通过定义与types.SimpleNamespace对象兼容的接口来解决这个问题。这种重叠在类型提示中被形式化:Union[Type[AppConfig], SimpleNamespace]。此类型定义允许使用多种对象来提供配置参数。

除了使用类之外,我们还可以创建一个SimpleNamespace对象,使其具有类似的语法来使用配置参数值。

通过 SimpleNamespace 进行配置

使用types.SimpleNamespace对象,我们可以根据需要简单地添加属性;这类似于使用类定义。定义类时,所有赋值语句都本地化到该类。当创建一个SimpleNamespace对象时,我们需要用正在填充的NameSpace对象显式限定每个名称。理想情况下,我们可以创建如下代码所示的SimpleNamespace

>>> import types 
>>> config = types.SimpleNamespace(  
...     param1="some value", 
...     param2=3.14, 
... ) 
>>> config 
namespace(param1='some value', param2=3.14) 

如果所有的配置值彼此独立,那么这种方法就可以很好地工作。然而,在我们的例子中,配置值之间存在一些复杂的依赖关系。我们可以通过以下两种方式之一处理此问题:

  • 仅提供独立值,并将其留给应用程序来构建依赖值
  • 以增量方式在命名空间中构建值

要仅创建独立值,我们可以执行以下操作:

import types
config2c = types.SimpleNamespace( 
  dealer_rule=Hit17(), 
  split_rule=NoReSplitAces(), 
  player_rule=SomeStrategy(), 
  betting_rule=Flat(), 
  outputfile=Path.cwd()/"data"/"ch14_simulation2c.dat", 
  samples=100, 
  ) 
config2c.table = Table(
    decks=6,
    limit=50,
    dealer=config2c.dealer_rule,
    split=config2c.split_rule,
    payout=(3, 2),
)
config2c.player = Player(
    play=config2c.player_rule, 
    betting=config2c.betting_rule, 
    max_rounds=100, 
    init_stake=50
)

在这里,我们为配置创建了具有六个独立值的SimpleNamespace。然后,我们更新了配置,添加了另外两个依赖于四个独立值的值。

config2c对象与上例中通过计算Example4()创建的对象几乎相同。请注意,基类是不同的,但属性集及其值是相同的。这里是另一种选择,我们在顶层脚本中以增量方式构建配置:

from types import SimpleNamespace

config2d = SimpleNamespace()
config2d.dealer_rule = Hit17()
config2d.split_rule = NoReSplitAces()
config2d.table = Table(
        decks    =    6    ,
        limit    =    50    ,
        dealer    =config2d.dealer_rule,
        split    =config2d.split_rule,
        payout    =(    3    ,     2    ),
)
config2d.player_rule = SomeStrategy()
config2d.betting_rule = Flat()
config2d.player = Player(
        play    =config2d.player_rule, 
        betting    =config2d.betting_rule, 
        max_rounds    =    100    , 
        init_stake    =    50
    )
config2d.outputfile = Path.cwd() /     "data"     /     "ch14_simulation2d.dat"
    config2d.samples =     100    

此配置对象可以使用前面显示的相同simulate_c()功能。

遗憾的是,这与使用顶级脚本进行配置的问题相同。没有简便的方法为配置对象提供默认值。

提供默认值的简单方法是通过包含默认参数值的函数。

我们可能希望有一个可以导入的工厂函数,它使用适当的默认值创建SimpleNamespace

From simulation import  make_config 
config2 = make_config()

如果我们使用类似于前面代码的代码,config2对象将具有工厂函数make_config()指定的默认值。用户提供的配置只需要为默认值提供覆盖。

我们的默认供应make_config()功能可以使用以下代码:

    def     make_config(
    dealer_rule: DealerRule = Hit17(),
    split_rule: SplitRule = NoReSplitAces(),
    decks:     int     =     6    ,
    limit:     int     =     50    ,
    payout: Tuple[    int    ,     int    ] = (    3    ,     2    ),
    player_rule: PlayerStrategy = SomeStrategy(),
    betting_rule: BettingStrategy = Flat(),
    base_name:     str     =     "ch14_simulation2e.dat"    ,
    samples:     int     =     100    ,
) -> SimpleNamespace:
        return     SimpleNamespace(
            dealer_rule    =dealer_rule,
            split_rule    =split_rule,
            table    =Table(
                decks    =decks,
                limit    =limit,
                dealer    =dealer_rule,
                split    =split_rule,
                payout    =payout,
        ),
            payer_rule    =player_rule,
            betting_rule    =betting_rule,
            player    =Player(
                play    =player_rule, 
                betting    =betting_rule, 
                max_rounds    =    100    , 
                init_stake    =    50
                    ),
            outputfile    =Path.cwd() /     "data"     / base_name,
            samples    =samples,
    )

在此,make_config()函数将通过一系列赋值语句构建默认配置。派生的配置值,包括table属性和player属性,是根据原始输入构建的。

然后,应用程序只能设置有趣的覆盖值,如下所示:

config_b = make_config(dealer_rule=Stand17()) 
simulate_c(config_b) 

这似乎通过仅指定覆盖值来保持相当清晰。

第 2 章、*中的【初始化方法】、*中的所有技术都适用于此类配置工厂函数的定义。如果需要的话,我们可以增加很大的灵活性。这样做的优点是与argparse模块解析命令行参数的方式非常吻合。我们将在第 18 章处理命令行中对此进行详细介绍。

让我们探讨如何使用 Python 和exec()进行配置。

将 Python 与 exec()一起用于配置

当我们决定使用 Python 作为配置的符号时,我们可以使用exec()函数来计算受约束名称空间中的代码块。我们可以想象编写如下代码所示的配置文件:

    # SomeStrategy setup

        # Table
        dealer_rule = Hit17()
        split_rule = NoReSplitAces()
        table = Table(decks=6, limit=50, dealer=dealer_rule,
                split=split_rule, payout=(3,2))

        # Player
        player_rule = SomeStrategy()
        betting_rule = Flat()
        player = Player(play=player_rule, betting=betting_rule,
                max_rounds=100, init_stake=50)

        # Simulation
        outputfile = Path.cwd()/"data"/"ch14_simulation3a.dat"
        samples = 100    

这是一组令人愉快、易于阅读的配置参数。它类似于我们将在下一节中探讨的 INI 文件和属性文件。我们可以使用exec()函数评估该文件,创建一种名称空间:

code =     compile    (py_file.read(),     "stringio"    ,     "exec"    )
assignments: Dict[    str    , Any] =     dict    ()
    exec    (code,     globals    (), assignments)
config = SimpleNamespace(**assignments)

simulate(config.table, config.player, config.outputfile, config.samples)

在本例中,代码对象code是使用compile()函数创建的。注意,这不是必需的;我们可以简单地将文件的文本提供给exec()函数,它将编译代码并执行它。

exec()的调用提供了三个参数:

  • 编译后的代码对象
  • 应用于解析任何全局名称的词典
  • 将用于创建的任何本地语言的词典

代码块完成后,赋值语句将用于在本地字典中构建值;在本例中,assignments变量。这些键将是变量名。然后将其转换为SimpleNamespace对象,以便与前面提到的其他初始化技术兼容。

assignments字典的值类似于以下输出:

{'betting_rule': Flat(),
 'dealer_rule': Hit17(),
 'outputfile': PosixPath('/Users/slott/mastering-oo-python-2e/data/ch14_simulation3a.dat'),
 'player': Player(play=SomeStrategy(), betting=Flat(), max_rounds=100, init_stake=50, rounds=100, stake=50),
 'player_rule': SomeStrategy(),
 'samples': 100,
 'split_rule': NoReSplitAces(),
 'table': Table(decks=6, limit=50, dealer=Hit17(), split=NoReSplitAces(), payout=(3, 2))}

用于创建一个SimpleNamespace对象config。然后,simulate()函数可以使用名称空间对象来执行模拟。使用SimpleNamespace对象可以更容易地参考各个配置设置。初始字典需要像assignments['samples']这样的代码。生成的配置对象可与config.samples等代码一起使用

下一节是关于为什么使用exec()解析 Python 代码不存在安全风险的离题。

为什么 exec()不是问题

上一节讨论了eval();同样的考虑也适用于exec()

一般情况下,可用globals()的设置是严格控制的。通过将ossubprocess模块从提供给exec()的全局模块中移除,可以消除对ossubprocess模块或__import__()功能的访问。

如果您有一个邪恶的程序员会巧妙地破坏配置文件,那么请记住,他们可以完全访问整个 Python 源代码。那么,既然他们可以更改应用程序代码本身,为什么还要浪费时间巧妙地调整配置文件呢?

有一个问题可以这样概括:*如果有人认为他们可以通过配置文件强制输入新代码来修补应用程序,该怎么办?*尝试这种方法的人很可能会通过其他一些同样聪明或混乱的渠道来破解应用程序。避免 Python 配置文件并不能阻止不道德的程序员通过做一些不明智的事情来破坏东西。Python 源代码可直接用于修改,因此不必要地担心exec()可能没有好处。

在某些情况下,可能需要改变理念。高度可定制的应用程序实际上可能是一个通用框架,而不是一个整洁、完整的应用程序。一个框架被设计为使用附加代码进行扩展。

如果通过 web 应用程序下载配置参数,则不应使用exec()eval()和 Python 语法。对于这些情况,参数需要使用 JSON 或 YAML 等语言。从远程计算机接受配置文件是一种 RESTful 状态传输。这在第 13 章发送和共享对象中有介绍。

在下一节中,我们将探讨其中一个集合,作为在单个方便对象中提供覆盖值和默认值的方法。

对默认值和替代使用 ChainMap

我们通常会有一个配置文件层次结构。前面,我们列出了几个可以安装配置文件的位置。例如,configparser模块被设计为以特定顺序读取大量文件,并通过让后续文件覆盖早期文件的值来集成设置。

我们可以使用collections.ChainMap类实现优雅的默认值处理。您可以参考第 7 章创建容器和集合了解此类的一些背景知识。我们需要将配置参数保留为dict实例,这是使用exec()评估 Python 语言初始化文件时可以很好地解决的问题。

使用它需要我们将配置参数设计为一个简单的值字典。对于具有大量复杂配置值的应用程序来说,这可能有点负担,这些配置值是从多个源集成的。我们将向您展示一种合理的方法来平铺名称。

首先,我们将根据标准位置构建文件列表:

from collections import ChainMap 
from pathlib import Path 
config_name = "config.py"
    config_locations = (
    Path.cwd(),
    Path.home(),
    Path("/etc/thisapp"),
    # Optionally Path("~thisapp").expanduser(), when an app has a "home" directory
    Path(__file__),
)
candidates = (dir / config_name         for         dir         in         config_locations)
    config_paths = (path     for     path     in     candidates     if     path.exists())

我们从一个目录列表开始,其中显示了搜索值的顺序。首先,查看当前工作目录中的配置文件;然后,查看用户的主目录。/etc/thisapp目录(或者可能是~thisapp目录)可以包含安装默认值。最后,将检查 Python 库。配置文件的每个候选位置用于创建一个生成器表达式,并分配给candidates变量。config_paths生成器应用过滤器,因此只有实际存在的文件才会加载到ChainMap实例中。

一旦我们有了候选文件的名称,我们就可以通过将每个文件折叠到地图中来构建ChainMap,如下所示:

cm_config: typing.ChainMap[    str    , Any] = ChainMap()
    for     path     in     config_paths:
    config_layer: Dict[    str    , Any] = {}
    source_code = path.read_text()
        exec    (source_code,     globals    (), config_layer)
    cm_config.maps.append(config_layer)

simulate(config.table, config.player, config.outputfile, config.samples)

通过创建一个新的空映射(可以使用局部变量更新)来包含每个文件。exec()函数将把文件的局部变量添加到空映射中。新地图将附加到ChainMap对象cm_configmaps属性。

ChainMap中,通过搜索地图序列并查找请求的键和关联值来解析每个名称。考虑将两个配置文件加载到 AuthT1-Type 中,给出与以下示例类似的结构:

ChainMap({},
 {'betting_rule': Martingale(),
 ...
 },
 {'betting_rule': Flat(),
 ...
 })

在这里,许多细节被替换为...,以简化输出。该链具有三个贴图的序列:

  1. 第一张地图是空的。当值被分配给ChainMap对象时,它们进入这个初始映射,首先搜索这个初始映射。
  2. 第二个映射来自最本地的文件,即加载到映射中的第一个文件;它们是对默认值的覆盖。
  3. 最后一个映射具有应用程序默认值;他们将最后被搜查。

唯一的缺点是,对配置值的引用将使用字典表示法,例如,config['betting_rule']。除了字典项访问之外,我们还可以扩展ChainMap()来实现属性访问。

下面是ChainMap的一个子类,如果我们发现getitem()字典符号太麻烦,可以使用它:

    class     AttrChainMap(ChainMap):

        def         __getattr__    (    self    , name:     str    ) -> Any:
            if     name ==     "maps"    :
                return         self    .    __dict__    [    "maps"    ]
            return         super    ().get(name,     None    )

        def         __setattr__    (    self    , name:     str    , value: Any) ->     None    :
            if     name ==     "maps"    :
                self    .    __dict__    [    "maps"    ] = value
                return
                        self    [name] = value

我们现在可以说config.table而不是config['table']。这揭示了对ChainMap扩展的一个有趣限制,即我们不能将maps用作属性。maps键是父ChainMap类的一级属性,此扩展必须保持不变。

我们可以使用许多不同的语法定义从键到值的映射。在下一节中,我们将了解定义参数值的 JSON 和 YAML 格式。

将配置存储在 JSON 或 YAML 文件中

我们可以相对轻松地将配置值存储在 JSON 或 YAML 文件中。语法设计为用户友好的。虽然我们可以在 YAML 中表示各种各样的内容,但我们在某种程度上仅限于在 JSON 中表示更窄范围的对象类。我们可以使用类似于以下代码的 JSON 配置文件:

{ 
    "table":{ 
        "dealer":"Hit17", 
        "split":"NoResplitAces", 
        "decks":6, 
        "limit":50, 
        "payout":[3,2] 
    }, 
    "player":{ 
        "play":"SomeStrategy", 
        "betting":"Flat", 
        "rounds":100, 
        "stake":50 
    }, 
    "simulator":{ 
        "samples":100, 
        "outputfile":"p2_c13_simulation.dat" 
    } 
} 

JSON 文档看起来像一个字典字典;这正是加载此文件时将构建的同一对象。我们可以使用以下代码加载单个配置文件:

import json 
config = json.load("config.json") 

这允许我们使用config['table']['dealer']查找要用于经销商规则的特定类别。我们可以使用config['player']['betting']定位玩家的特定下注策略类名。

与 INI 文件不同,我们可以轻松地将tuple编码为一系列值。因此,config['table']['payout']值将是一个合适的两元素序列。严格来说,它不会是tuple,但它足够近,我们可以使用它而不必使用ast.literal_eval()

下面是我们如何使用这个嵌套结构。我们只向您展示main_nested_dict()函数的第一部分:

    def     main_nested_dict(config: Dict[    str    , Any]) ->     None    :
    dealer_nm = config.get(    "table"    , {}).get(    "dealer"    ,     "Hit17"    )
    dealer_rule = {
            "Hit17"    : Hit17(), 
            "Stand17"    : Stand17()
    }.get(dealer_nm, Hit17())
    split_nm = config.get(    "table"    , {}).get(    "split"    ,     "ReSplit"    )
    split_rule = {
            "ReSplit"    : ReSplit(), 
            "NoReSplit"    : NoReSplit(), 
            "NoReSplitAces"    : NoReSplitAces()
    }.get(split_nm, ReSplit())
    decks = config.get(    "table"    , {}).get(    "decks"    ,     6    )
    limit = config.get(    "table"    , {}).get(    "limit"    ,     100    )
 payout = config.get(    "table"    , {}).get(    "payout"    , (    3    ,     2    ))
    table = Table(
            decks    =decks,     limit    =limit,     dealer    =dealer_rule,     split    =split_rule,     payout    =payout
    )

这与前面提到的main_ini()功能非常相似。当我们使用configparser将其与前面的版本进行比较时,很明显复杂性几乎相同。命名稍微简单一点,就是用config.get('table',{}).get('decks')代替config.getint('table','decks')

主要区别显示在突出显示的行中。JSON 格式为我们提供了正确解码的整数值和正确的值序列。我们不需要使用eval()ast.literal_eval()来解码元组。其他用于构建Player和配置Simulate对象的部分与main_ini()版本类似。

在某些情况下,JSON 文件的嵌套结构可能会让人难以编辑。简化语法的一种方法是使用稍微不同的方法来组织数据。在下一节中,我们将探索一种通过使用更平坦的结构来消除一些复杂性的方法。

使用平坦的 JSON 配置

如果我们想通过集成多个配置文件来提供默认值,我们不能同时使用ChainMap和这样的嵌套字典。我们要么将程序的参数展平,要么寻找另一种方法来合并来自不同来源的参数。

通过在名称之间使用简单的.分隔符,我们可以轻松地将名称平坦化,以反映顶级节和节中的低级属性。在这种情况下,我们的 JSON 文件可能类似于以下代码:

{'player.betting': 'Flat',
  'player.play': 'SomeStrategy',
  'player.rounds': '100',
  'player.stake': '50',
  'simulator.outputfile': 'data/ch14_simulation5.dat',
  'simulator.samples': '100',
  'table.dealer': 'Hit17',
  'table.decks': '6',
  'table.limit': '50',
  'table.payout': '(3,2)',
  'table.split': 'NoResplitAces'}

这样做的好处是允许我们使用ChainMap来累积来自各种来源的配置值。它还稍微简化了定位特定参数值的语法。给定一个配置文件名列表,config_names,我们可以这样做:

config = ChainMap(*[json.load(file) for file in config_names]) 

这将从配置文件名列表中构建一个适当的ChainMap。这里,我们正在将一个dict文本列表加载到ChainMap中,第一个dict文本将是键搜索的第一个文本。

我们可以用这样的方法来利用ChainMap。我们只展示第一部分,它构建了Table实例:

    def     main_cm(config: Dict[    str    , Any]) ->     None    :
    dealer_nm = config.get(    "table.dealer"    ,     "Hit17"    )
    dealer_rule = {    "Hit17"    : Hit17(),     "Stand17"    : Stand17()}.get(dealer_nm, Hit17())
    split_nm = config.get(    "table.split"    ,     "ReSplit"    )
    split_rule = {
            "ReSplit"    : ReSplit(), 
            "NoReSplit"    : NoReSplit(), 
            "NoReSplitAces"    : NoReSplitAces()
    }.get(
        split_nm, ReSplit()
    )
    decks =     int    (config.get(    "table.decks"    ,     6    ))
    limit =     int    (config.get(    "table.limit"    ,     100    ))
    payout = config.get(    "table.payout"    , (    3    ,     2    ))
    table = Table(
            decks    =decks, 
            limit    =limit, 
            dealer    =dealer_rule, 
            split    =split_rule, 
            payout    =payout
    )

其他用于构建Player和配置Simulate对象的部分与main_ini()版本类似。但是,本例中省略了它们。

当我们使用configparser将其与之前的版本进行比较时,很明显复杂性几乎相同。但是,命名稍微简单一些;在这里,我们使用int(config.get('table.decks'))代替config.getint('table','decks')

属性的 JSON 格式便于使用。然而,语法对人们来说并不友好。在下一节中,我们将了解如何使用 YAML 语法而不是 JSON 语法。

加载 YAML 配置

由于 YAML 语法包含 JSON 语法,因此可以使用 YAML 和 JSON 加载前面的示例。以下是 JSON 文件中嵌套字典技术的一个版本:

    # Complete Simulation Settings
        table: !!python/object:Chapter_14.simulation_model.Table
          dealer: !!python/object:Chapter_14.simulation_model.Hit17 {}
          decks: 6
          limit: 50
          payout: !!python/tuple [3, 2]
          split: !!python/object:Chapter_14.simulation_model.NoReSplitAces {}
        player: !!python/object:Chapter_14.simulation_model.Player
          betting:  !!python/object:Chapter_14.simulation_model.Flat {}
          init_stake: 50
          max_rounds: 100
          play: !!python/object:Chapter_14.simulation_model.SomeStrategy {}
          rounds: 0
          stake: 63.0
        samples: 100
        outputfile: data/ch14_simulation4c.dat

这通常比纯 JSON 更容易编辑。对于配置由字符串和整数控制的应用程序,这有许多优点。加载此文件的过程与加载 JSON 文件的过程相同:

import yaml 
config = yaml.load("config.yaml") 

这与嵌套字典具有相同的限制。我们没有一个简单的方法来处理默认值,除非我们将名称展平。

然而,当我们超越简单的字符串和整数时,我们可以尝试利用 YAML 编码类名和创建自定义类实例的能力。下面是一个 YAML 文件,它将直接构建我们模拟所需的配置对象:

# Complete Simulation Settings 
table: !!python/object:__main__.Table 
  dealer: !!python/object:__main__.Hit17 {} 
  decks: 6 
  limit: 50 
  payout: !!python/tuple [3, 2] 
  split: !!python/object:__main__.NoReSplitAces {} 
player: !!python/object:__main__.Player 
  betting:  !!python/object:__main__.Flat {} 
  init_stake: 50 
  max_rounds: 100 
  play: !!python/object:__main__.SomeStrategy {} 
  rounds: 0 
  stake: 63.0 
samples: 100 
outputfile: data/ch14_simulation4c.dat

我们在 YAML 中编码了类名和实例构造,允许我们定义TablePlayer的完整初始化。我们可以按如下方式使用此初始化文件:

    import     yaml

    if     __name__ ==     "__main__"    :

    config = yaml.load(yaml1_file)
        print    (config)

    simulate(
        config[    "table"    ], 
        config[    "player"    ],
        Path(config[    "outputfile"    ]), 
        config[    "samples"    ]
    )

这向我们展示了 YAML 配置文件可以用于人工编辑。YAML 为我们提供了与 Python 相同的功能,但语法不同。对于这种类型的示例,Python 配置脚本可能比 YAML 更好。

另一种可用于配置参数的格式称为属性文件。我们将研究属性文件的结构和解析,并在下一节中学习如何使用它们。

将配置存储在属性文件中

属性文件通常与 Java 程序一起使用。但是,我们没有理由不能在 Python 中使用它们。它们相对容易解析,并允许我们以方便易用的格式对配置参数进行编码。有关格式的更多信息,请参考http://en.wikipedia.org/wiki/.propertieshttps://docs.oracle.com/javase/10/docs/api/java/util/Properties.html#load(java.io.Reader)

以下是属性文件的外观:

# Example Simulation Setup 

player.betting: Flat 
player.play: SomeStrategy 
player.rounds: 100 
player.stake: 50 

table.dealer: Hit17 
table.decks: 6 
table.limit: 50 
table.payout: (3,2) 
table.split: NoResplitAces 

simulator.outputfile = data/ch14_simulation5.dat
simulator.samples = 100 

这在简单性方面有一些优势。section.property限定名通常用于将相关属性构造为节。如果使用了太多级别的嵌套,那么在非常复杂的配置文件中,这些嵌套可能会变得很长。

这种格式有很大的灵活性。但是,可以对各行进行分析,以创建从属性名称到属性值的映射。在下一节中,我们将了解如何解析属性文件。

解析属性文件

Python 标准库中没有内置的属性解析器。我们可以从 Python 包索引(中下载属性文件解析器 https://pypi.python.org/pypi )。然而,它不是一个非常复杂的类,在高级面向对象编程中是一个很好的练习。

我们将把类分解为顶级 API 函数和低级解析函数。以下是一些总体 API 方法:

    import     re

    class     PropertyParser:

        def     read_string(    self    , data:     str    ) -> Iterator[Tuple[    str    ,     str    ]]:
            return         self    ._parse(data)

        def     read_file(    self    , file: IO[str]) -> Iterator[Tuple[    str    ,     str    ]]:
        data = file.read()
            return         self    .read_string(data)

        def     read(    self    , path: Path) -> Iterator[Tuple[    str    ,     str    ]]:
            with     path.open(    "r"    )     as     file:
                return         self    .read_file(file)

这里的基本特性是它将解析文件名、文件或文本块。这遵循configparser中的设计模式。一种常见的替代方法是使用较少的方法并使用isinstance()来确定参数的类型,以及确定对其执行什么处理。

文件名以Path对象的形式给出。文件一般为io.TextIOBase实例,打字模块提供IO[str]提示;文本块也是字符串。因此,许多库使用load()处理文件或文件名,并使用loads()处理简单字符串。类似这样的东西将与json的设计模式相呼应:

    def     load(    self    , file_name_or_path: Union[TextIO,     str    , Path]) -> Iterator[Tuple[    str    ,     str    ]]:
        if         isinstance    (file_name_or_path, io.TextIOBase):
            return         self    .loads(file_name_or_path.read())
        else    :
        name_or_path = cast(Union[    str    , Path], file_name_or_path)
            with     Path(name_or_path).open(    "r"    )     as     file:
                return         self    .loads(file.read())

    def     loads(    self    , data:     str    ) -> Iterator[Tuple[    str    ,     str    ]]:
        return         self    ._parse(data)

这些方法还将处理文件、文件名或文本块。提供文件后,可以读取和解析该文件。当提供路径或字符串时,它用于打开具有给定名称的文件。决定因素是在各种库、包和模块之间实现一致的设计。以下是_parse()方法:

key_element_pat = re.compile(    r"(.*?)\s*(?<!\\)[:=\s]\s*(.*)"    )

    def     _parse(    self    , data:     str    ) -> Iterator[Tuple[    str    ,     str    ]]:
    logical_lines = (
        line.strip()     for     line     in     re.sub(    r"\\\n\s*"    ,     ""    , data).splitlines()
    )
    non_empty = (line     for     line     in     logical_lines     if         len    (line) !=     0    )
    non_comment = (
        line
            for     line     in     non_empty
            if not     (line.startswith(    "#"    )     or     line.startswith(    "!"    ))
    )
        for     line     in     non_comment:
        ke_match =     self    .key_element_pat.match(line)
            if     ke_match:
            key, element = ke_match.group(    1    ), ke_match.group(    2    )
            else    :
            key, element = line,     ""
                    key =     self    ._escape(key)
        element =     self    ._escape(element)
            yield     key, element

此方法从三个生成器表达式开始,以处理属性文件中物理线和逻辑线的一些总体功能。生成器表达式将三个语法规则分开。生成器表达式具有延迟执行的优点;这意味着在for line in non_comment语句对这些表达式求值之前,不会从这些表达式创建中间结果。

第一个表达式分配给logical_lines,合并以\结尾的物理行,以创建更长的逻辑行。前导(和尾随)空格被剥离,只留下行内容。r"\\\n\s*"正则表达式RE用于定位 continuations。它匹配一行末尾的\和下一行的所有前导空格。

分配给non_empty的第二个表达式将仅迭代长度非零的行;请注意,此筛选器将拒绝空行。

第三个表达式non_comment将只迭代不以#!开头的行。以#!开头的行将被此过滤器拒绝;这消除了注释行。

由于这三个生成器表达式,for line in non_comment循环只在适当地去掉额外空格的非注释、非空白和逻辑行中进行迭代。循环的主体会分离每一条剩余的线,以分离键和元素,然后应用self._escape()函数来扩展任何转义序列。

关键元素模式key_element_pat寻找非转义字符的显式分隔符,如:=或空格。该模式使用否定的 lookback 断言,即 RE 为(?<!\\),以指示以下 RE 必须是非转义的;因此,以下模式必须而不是前面加\(?<!\\)[:=\s]子模式与未转义的:=或空格字符匹配。它允许使用外观奇怪的建筑红线,如a\:b: value;该物业为a:b。该元素为value。密钥中的:必须使用前面的\进行转义。

中的括号重新捕获属性及其关联的元素。如果找不到由两部分组成的键元素模式,则没有分隔符,该行仅为属性名称,元素为""

属性和元素形成一个两元组的序列。通过提供配置映射,可以很容易地将序列转换为字典,这与我们看到的其他配置表示方案类似。它们也可以保留为序列,以特定顺序显示文件的原始内容。最后一部分是一个小方法函数,用于将元素中的任何转义序列转换为其最终 Unicode 字符:

    def     _escape(    self    , data:     str    ) ->     str    :
    d1 = re.sub(    r"\\([:#!=\s])"    ,     lambda     x: x.group(    1    ), data)
    d2 = re.sub(    r"\\u([0-9A-Fa-f]+)"    ,     lambda     x:     chr    (    int    (x.group(    1    ),     16    )), d1)
        return     d2

_escape()方法函数执行两次替换过程。第一次通过将转义的标点符号替换为其纯文本版本:\:\#\!\=\都已删除\。对于 Unicode 转义,数字字符串用于创建正确的 Unicode 字符,以替换\uxxxx序列。十六进制数字转换为整数,整数转换为替换字符。

这两个替换可以组合到一个操作中,以避免创建只会被丢弃的中间字符串。这将提高性能;它应该类似于以下代码:

d2 = re.sub(
        r"\\([:#!=\s])|\\u([0-9A-Fa-f]+)"    ,
        lambda     x: x.group(    1    )     if     x.group(    1    )     else         chr    (    int    (x.group(    2    ),     16    )),
    data,
)

性能更好的好处可能会被 RE 和替换功能的复杂性所抵消。

解析属性值之后,我们需要在应用程序中使用它们。在下一节中,我们将研究使用属性文件的方法。

使用属性文件

对于如何使用属性文件,我们有两种选择。我们可以遵循configparser的设计模式,解析多个文件,从各种值的并集创建单个映射。或者,我们可以遵循ChainMap模式,为每个配置文件创建一系列属性映射。

ChainMap处理相当简单,为我们提供了所有必需的功能:

pp = PropertyParser()

candidate_list = [prop_file]
config = ChainMap(
    *[    dict    (pp.read_file(file)) 
          for     file     in         reversed    (candidate_list)
      ]
)

我们按照相反的顺序列出了列表:最具体的设置将在内部列表中位于第一位,而最一般的设置将位于最后一位。一旦ChainMap被加载,我们就可以使用这些属性来初始化和构建我们的PlayerTableSimulate实例。

这似乎比从多个源更新单个映射更简单。此外,这遵循用于处理 JSON 或 YAML 配置文件的模式。

我们可以用这样的方法来利用ChainMap。这与前面提到的main_cm()功能非常相似。我们只展示第一部分,它构建了Table实例:

import ast

    def     main_cm_prop(config):
    dealer_nm = config.get(    "table.dealer"    ,     "Hit17"    )
    dealer_rule = {    "Hit17"    : Hit17(),     "Stand17"    : Stand17()}.get(dealer_nm, Hit17())
    split_nm = config.get(    "table.split"    ,     "ReSplit"    )
    split_rule = {
            "ReSplit"    : ReSplit(),     "NoReSplit"    : NoReSplit(),     "NoReSplitAces"    : NoReSplitAces()
    }.get(
        split_nm, ReSplit()
    )
    decks =     int    (config.get(    "table.decks"    ,     6    ))
    limit =     int    (config.get(    "table.limit"    ,     100    ))
    payout = ast.literal_eval(config.get(    "table.payout"    ,     "(3,2)"    ))
    table = Table(
            decks    =decks,     limit    =limit,     dealer    =dealer_rule,     split    =split_rule,     payout    =payout
    )

此版本与main_cm()函数的区别在于对支出元组的处理。在以前的版本中,JSON(和 YAML)可以解析元组。使用属性文件时,所有值都是简单字符串。我们必须使用eval()ast.literal_eval()来评估给定值。此main_cm_str()功能的其他部分与main_cm()相同。

使用属性文件格式是保存配置数据的一种方法。在下一节中,我们将研究如何使用 XML 语法来表示配置参数。

使用 XML 文件–PLIST 和其他

正如我们在第 10 章中提到的,*序列化和保存–JSON、YAML、Pickle、CSV 和 XML,*Python 的xml包包含许多解析 XML 文件的模块。由于 XML 文件的广泛采用,经常需要在 XML 文档和 Python 对象之间进行转换。与 JSON 或 YAML 不同,XML 的映射并不简单。

用 XML 表示配置数据的一种常见方法是 PLIST 文件。有关 PLIST 格式的更多信息,请参考https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/PropertyLists/Introduction/Introduction.html

安装了 XCode 的 Macintosh 用户可以执行man plist以查看基于 XML 格式的大量文档。PLIST 格式的优点是它使用了一些非常通用的标记。这使得创建 PLIST 文件和解析它们变得很容易。以下是带有配置参数的 PLIST 示例文件:

<?xml version="1.0" encoding="UTF-8"?> 
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 
<plist version="1.0"> 
<dict> 
  <key>player</key> 
  <dict> 
    <key>betting</key> 
    <string>Flat</string> 
    <key>play</key> 
    <string>SomeStrategy</string> 
    <key>rounds</key> 
    <integer>100</integer> 
    <key>stake</key> 
    <integer>50</integer> 
  </dict> 
  <key>simulator</key> 
  <dict> 
    <key>outputfile</key> 
    <string>ch14_simulation6a.dat</string> 
    <key>samples</key> 
    <integer>100</integer> 
  </dict> 
  <key>table</key> 
  <dict> 
    <key>dealer</key> 
    <string>Hit17</string> 
    <key>decks</key> 
    <integer>6</integer> 
    <key>limit</key> 
    <integer>50</integer> 
    <key>payout</key> 
    <array> 
      <integer>3</integer> 
      <integer>2</integer> 
    </array> 
    <key>split</key> 
    <string>NoResplitAces</string> 
  </dict> 
</dict> 
</plist> 

在这里,我们将在本例中向您展示字典结构的嵌套字典。使用 XML 标记编码的 Python 兼容类型有很多:

| Python 类型 | Plist 标签 | | str | <string> | | float | <real> | | int | <integer> | | datetime | <date> | | boolean | <true/> or <false/> | | bytes | <data> | | list | <array> | | dict | <dict> |

如上例所示,dict<key>值是字符串。这使得 PLIST 文件成为模拟应用程序中参数的一种非常令人愉快的编码。我们可以相对轻松地加载符合 PLIST 的 XML 文件:

    import     plistlib
    print    (plistlib.load(plist_file))

这将从 XML 序列化重构配置参数。然后,我们可以使用这个嵌套的 dictionary-of-dictionary 结构和前面关于 JSON 配置文件的部分中所示的main_nested_dict()函数。

使用单个模块函数解析文件使 PLIST 格式非常吸引人。由于缺乏对任何自定义 Python 类定义的支持,这相当于 JSON 或属性文件

除了标准化的 PLIST 模式之外,我们还可以定义自己的定制模式。在下一节中,我们将研究如何创建问题域特有的 XML。

自定义 XML 配置文件

对于更复杂的 XML 配置文件,您可以参考http://wiki.metawerx.net/wiki/Web.xml 。这些文件包含专用标记和通用标记的混合。这些文档可能很难解析。一般有两种方法:

  • 编写一个文档处理类,该类使用 XPath 查询来定位 XML 文档结构中的标记。在本例中,我们将创建一个具有属性(或方法)的类,以在 XML 文档中定位请求的信息。
  • 将 XML 文档展开到 Python 数据结构中。这是前面提到的plist模块所遵循的方法。这将把 XML 文本值转换为本机 Python 对象。

基于web.xml文件的示例,我们将设计我们自己的定制 XML 文档来配置我们的模拟应用程序:

<?xml version="1.0" encoding="UTF-8"?> 
<simulation> 
    <table> 
        <dealer>Hit17</dealer> 
        <split>NoResplitAces</split> 
        <decks>6</decks> 
        <limit>50</limit> 
        <payout>(3,2)</payout> 
    </table> 
    <player> 
        <betting>Flat</betting> 
        <play>SomeStrategy</play> 
        <rounds>100</rounds> 
        <stake>50</stake> 
    </player> 
    <simulator> 
        <outputfile>data/ch14_simulation6b.dat</outputfile> 
        <samples>100</samples> 
    </simulator> 
</simulation> 

这是一个专门的 XML 文件。我们没有提供 DTD 或 XSD,因此没有针对模式验证 XML 的正式方法。但是,该文件很小,易于调试,并且与其他示例初始化文件并行。下面是一个Configuration类,它可以使用 XPath 查询从该文件检索信息:

    import     xml.etree.ElementTree     as     XML

    class     Configuration:

        def     read_file(    self    , file):
            self    .config = XML.parse(file)

        def     read(    self    , filename):
            self    .config = XML.parse(filename)

        def     read_string(    self    , text):
            self    .config = XML.fromstring(text)

        def     get(    self    , qual_name, default):
        section, _, item = qual_name.partition(    "."    )
        query =     "./{0}/{1}"    .format(section, item)
        node =     self    .config.find(query)
            if     node     is None    :
                return     default
            return     node.text

        def         __getitem__    (    self    , section):
        query =     "./{0}"    .format(section)
        parent =     self    .config.find(query)
            return         dict    ((item.tag, item.text)     for     item     in     parent)

我们已经实现了三种方法来加载 XML 文档:read()read_file()read_string()。每一个都只是将自己委托给xml.etree.ElementTree类的现有方法函数。这与configparserAPI 类似。我们也可以使用load()loads()方法名称,因为它们将分别委托给parse()fromstring()

对于配置数据的访问,我们实现了两种方法:get()__getitem__()。这些方法构建 XPath 查询来定位 XML 结构中的节和项。get()方法允许我们使用如下代码:stake = int(config.get('player.stake', 50))__getitem__()方法允许我们使用如下代码:stake = config['player']['stake']

解析比 PLIST 文件稍微复杂一些。但是,XML 文档比等效的 PLIST 文档简单得多。

我们可以使用main_cm_prop()函数(在前面的属性文件部分中提到)来处理此配置。

总结

在本章中,我们探讨了许多表示配置参数的方法。其中大多数是基于我们在第 10 章中看到的更通用的序列化技术,序列化和保存–JSON、YAML、Pickle、CSV 和 XMLconfigparser模块提供了一种额外的格式,对某些用户来说很舒服。

配置文件的关键特性是,内容可以由人工轻松编辑。因此,建议不要将 pickle 文件作为良好的表示形式。

设计考虑和权衡

配置文件可以简化运行应用程序或启动服务器。这可以将所有相关参数放在一个易于阅读和修改的文件中。我们可以将这些文件置于配置控制之下,跟踪更改历史,并通常使用它们来提高软件的质量。

对于这些文件,我们有几种可供选择的格式,所有这些格式都相当人性化,易于编辑。它们的不同之处在于解析的容易程度以及对可编码 Python 数据的任何限制:

  • INI 文件:这些文件易于解析,仅限于字符串和数字。
  • Python 代码PY 文件):我们可以使用主脚本进行配置;在这种情况下,将没有额外的解析和限制。我们也可以使用exec()处理单独的文件;这使得解析变得很简单,而且同样没有限制。
  • JSONYAML 文件:这些文件很容易解析。它们支持字符串、数字、命令和列表。YAML 可以对 Python 进行编码,但为什么不直接使用 Python 呢?
  • 属性文件:这些文件需要一个特殊的解析器。它们仅限于字符串。
  • XML 文件
    • PLIST 文件:这些文件很容易解析。它们支持字符串、数字、命令和列表。
    • 定制 XML:这些文件需要一个特殊的解析器。它们仅限于字符串,但是到 Python 对象的映射允许类执行各种转换。

与其他应用程序或服务器共存通常会决定配置文件的首选格式。如果我们有其他使用 PLIST 或 INI 文件的应用程序,那么我们的 Python 应用程序应该做出更适合用户使用的选择。

从可以表示的对象的范围来看,我们有四大类配置文件:

  • 仅包含字符串的简单文件:自定义 XML 和属性文件。
  • 包含 Python 文本的简单文件:INI 文件。
  • 包含 Python 文本、列表和 dicts的更复杂文件:JSON、YAML、PLIST 和 XML。
  • 任何 Python 的东西:我们可以使用 YAML 来实现这一点,但当 Python 的语法比 YAML 更清晰时,这似乎很愚蠢。通过 Python 类定义提供配置值非常简单,并导致默认值和覆盖值的愉快层次结构。

创建共享配置

当我们在第 19 章模块和包设计中查看模块设计注意事项时,我们将看到模块如何符合单例设计模式。这意味着我们只能导入一个模块一次,并且单个实例是共享的。

因此,通常需要在不同的模块中定义配置并导入它。这允许单独的模块共享一个公共配置。每个模块将导入共享配置模块;配置模块将定位配置文件并创建实际的配置对象。

模式演化

配置文件是面向公众的 API 的一部分。作为应用程序设计者,我们必须解决模式演化的问题。如果我们更改类定义,我们将如何更改配置?

由于配置文件通常具有有用的默认值,因此它们通常非常灵活。原则上,内容完全是可选的。

当一个软件经历了重大的版本更改——更改了 API 或数据库模式——配置文件也可能经历重大更改。可能必须包括配置文件的版本号,以消除旧配置参数与当前版本参数之间的歧义。

对于较小的版本更改,配置文件(如数据库、输入和输出文件以及 API)应保持兼容。任何配置参数处理都应具有适当的备选方案和默认值,以应对较小的版本更改。

配置文件是应用程序的一级输入。这不是事后想出来的,也不是权宜之计。它必须与其他输入和输出一样仔细设计。当我们在第 16 章、*日志和警告模块、*和第 18 章、*处理命令行中查看更大的应用程序架构设计时,*将在解析配置文件的基础上展开。

期待

在接下来的章节中,我们将探讨更大规模的设计考虑。第 15 章设计原则和模式将阐述一些有助于构建面向对象程序类定义的一般原则。第 16 章日志和警告模块将研究如何使用loggingwarnings用于创建审核信息以及进行调试的模块。我们将在第 17 章可测试性设计中介绍可测试性设计以及如何使用unittestdoctest第 18 章处理命令行将介绍如何使用argparse模块解析选项和参数。我们将更进一步,使用命令设计模式创建可以组合和扩展的程序组件,而无需编写 shell 脚本。在第 19 章模块和包设计中,我们将介绍模块和包设计。在第 20 章质量和文档中,我们将研究如何记录我们的设计,以确保我们的软件是正确的,并且得到了正确的实施。