跳到主要内容 Python 安全有效地处理配置的最佳实践 | 极客日志
Python
Python 安全有效地处理配置的最佳实践 本文探讨了在 Python 中安全高效处理配置的最佳实践。主要原则包括使用标识符而非字符串键访问配置值,采用静态类型检查以减少错误,在程序启动早期验证配置数据,以及在配置使用的地方声明配置项。通过结合数据类、类型注解及 dacite 等工具,可以构建更健壮的配置系统,避免运行时错误并提升可维护性。
神经兮兮 发布于 2025/2/6 更新于 2026/4/21 3 浏览大多数计算机应用程序都可以使用配置来指定行为,无论是通过命令行标志、环境变量还是配置文件。作为一名软件开发人员,处理配置时会遇到一些挑战,例如解析不合法的输入、验证它以及在程序的任意位置访问它。以 Python 为例,在这篇文章中,我想分享一些可以帮助您安全有效地处理配置的最佳实践,并且我希望和您达成共识:这些是在您自己的代码中应该遵循的合理原则。
引言
除了最简单的程序外,所有的程序都有一组参数来控制它们的行为。作为具体的例子,考虑 ls 工具的输出格式、nginx 监听的端口或 git 在提交消息中使用的电子邮件地址。根据应用程序的大小和复杂性,可能有许多这样的参数,它们可能只影响一个小的执行细节或者整个程序行为。
当您处理配置时,有很多方面需要考虑:首先,它是如何从外部传递到您的程序中的,如何解析和验证?其次,如何在程序内部处理、访问和在组件之间传递?根据应用程序的类型,您必须考虑在程序运行时用户如何检查和更新它。从操作的角度来看,您可能必须考虑如何管理、测试多个配置并将其部署到生产环境中。
每一个主题都可能变得相当复杂,值得深入探讨。不过,在这篇博文中,我只想关注第二个方面。我将介绍一些处理程序内部配置的指导原则,这些原则是经过时间检验的,我想推荐给任何开发中小型应用程序的人。
在过去,我用各种编程语言(如 Go、Scala 和 Python)构建和维护应用程序。在这篇文章中,我想以 Python 为例,因为它的动态特性允许使用很多机制用以提高开发速度和灵活性(例如,在运行时修改类),但从长远来看可能会使维护和重构更加困难。
一个简单的例子 当谈到软件应该如何工作以及组件应该如何交互的重要思想时,有时很难与实际编码联系起来。为了避免出现这种情况,让我们跳到下面的代码示例中,看看我想在本文中解决的一些问题:
config = {
"user" : {
"email" : "[email protected] "
},
"port" : 8080
}
print (config["user" ]["email" ])
print (config["port" ])
在评论中,我已经给出了一些关于该代码可能存在的缺点,但是让我们现在更详细地探讨一下。
指导原则 编程是一项在智力上具有挑战性的任务,因此我认为作为软件工程师,我们应该将尽可能多的复杂任务委托给我们的工具,如 IDE、linter、格式化程序、编译器或类型检查程序。如果可以使用一个工具来发现错误和提高代码质量,那么我认为这就证明了用这种工具来编写代码是合理的。
另外,如果尽管我们仔细检查和使用了工具,但代码中仍有错误,那么应该在应用程序启动时尽快报告,这会产生一个重要的警告消息,并且在许多情况下,程序会立即退出。最糟糕的事情莫过于在一次看似成功的部署的几个小时后,半夜里发现某个配置密钥丢失。
基于这些基础,我认为处理应用程序内部配置的数据结构应该遵循以下四个原则:
它应该使用标识符而不是字符串键来访问配置值。
它的值应该是静态类型的。
应该尽早验证。
它应该声明在它使用的地方。
一、使用标识符而不是字符串键值 可能与近年来文件交换和序列化格式的某种'JSONification'有关,以 PEP 484 为标准的字符串键词典 Dict[str, Any] 似乎已经成为许多 Python 开发人员的一站式数据结构。很简单,只需使用 json.loads() 处理一个 json 格式的字符串后放入 Python 字典,然后使用像 config["port"] 或 config["user"]["email"] 一样的代码随意访问它,就像我在介绍性示例中所做的那样。(这种方法不是 Python 独有的,例如 Scala 的 Lightbend 配置库也有一个类似 conf.getInt("foo.bar") 的 API。)如果需要新的配置条目,只需将其添加到 JSON 文件中,并在整个代码中立即使用它。
无法检测不一致的拼写,例如,密钥是'user'还是'users'。
如果存在不一致,不能明确哪里发生了错误。只有和字典中的值相同才是正确的。
在实际访问数据之前,不会发现丢失的数据。
无法使用 IDE/工具来重命名密钥,需要找到并替换字符串的所有匹配项。
不能使用检查变量名格式一致性的工具。
重复的字符串解析和字典查找相当费力。
因此,我建议使用标识符,而不是使用字符串键(在字典中或作为某些 get() 方法的参数)。直接的方法是使用类成员,然后编写 config.user.email,而不是 config["user"]["email"]。请注意,Python 的数据类(在 3.7 版中引入,但在 3.6 版中通过 dataclasses 模块提供)对于保存此类数据非常方便。
在编译语言中,编译器显然会立即告诉您是否存在拼写错误,但对于 Python,一个足够现代的 IDE 通常会指出是否使用了未声明的变量或类成员。
类中定义中的名称才是唯一确定的正确名称。
即使在 Python 中,声明的变量也可能没有初始化(参见 PEP 526),但在许多情况下,IDE 或 linter 会告诉您这一点。
使用 IDE 可以轻松完成重命名。
可以应用普通格式化程序或样式检查程序。
二、静态类型 在上一节中,我们看到了 Dict[str, Any] 的 str 部分是如何导致问题的,现在让我们来看看 Any 部分。我不想在这里讨论静态类型编程语言和动态类型编程语言的所有方面,但就程序正确性而言,有一些证据表明,静态类型检查减少了修复错误时的工作量并且效果更好。在 Python 中,mypy 可以对使用类型注释的代码执行此类检查。我想鼓励您在代码中使用这些注释,而不仅仅是在使用配置时。
从上面的一个例子来看,start_server(port=os.environ.get("port", 80)),对于需要整数值端口的函数,如果设置了环境变量 port,则此代码将失败,因为 os.environ 的条目始终是字符串类型。您可能知道这一点,但如果 start_server() 函数声明为类似 start_server(port: int),那么使用 mypy 进行的检查将显示出问题:
import os
def start_server (port: int ) -> None :
print (f"Starting server on port {port} " )
start_server(port=os.environ.get("port" , 80 ))
error : Argument 1 to "start_server" has incompatible type "Union[str, int]" ; expected "int"
除了这些基本检查之外,静态类型还提供了一种优雅的方法来限制代码可能接受的输入集。例如,当您有一个引用文件的配置项时,请使用 pathlib.Path 而不是 str,并避免处理字符串格式的无效文件名。如果有固定数量的可选值,请使用 enum.Enum 来表示它。如果只能指定一个或另一个值,请使用 Union。如果值是可选的,则通过使用 Optional 显式表达。通过使用类型系统正式指定允许或禁止的值,您可以使用工具来发现您没有覆盖的代码路径,或者那些实际上永远不会覆盖的代码路径。
还有一件事要考虑,特别是在处理诸如持续时间、重量、距离、速度等物理维度时,要抽象出维度,而不是具体的单位。例如,与其像 check_interval_s: float 或 check_interval_ms: int 那样声明配置项,不如像 check_interval: datetime.timedelta 那样声明它。然后,您可以根据这些维度编写大部分代码,在抽象级别上使用它们进行计算,并且只在使用外部库时(例如调用 time.sleep(check_interval.total_seconds()))将它们转换为具体值。
最后要注意:在 Python 中,类型注释在运行时没有验证效果。即使所有代码都被注释并通过类型检查,如果变量 a: int 在运行时是一个字符串,那么意外的事情也会发生。下一节的主题是确保实际数据看起来符合您的预期。
三、早期验证 对于大多数配置值,拥有一个特定的格式、类型或数据范围才是有意义的。如前一节所述使用静态类型已经是限定值必须要有某种格式的示例。可能还有其他约束,如最小值和最大值,与某个正则表达式匹配,或指向配置的另一个(已存在)部分。
一种简单的方法是在使用配置的位置执行验证。例如,你可以写:
if not isinstance (config.port, int ):
raise ValueError("Port must be an integer" )
必须在使用该值的每个位置验证该值,从而导致代码重复。或者,您在使用它时需要记住是否已经验证了它。
如果有问题,那么只有在第一次访问配置值时才会出现问题。这使得发现错误更加困难,并且需要更多的力气来检查新的配置值是否实际有效。
如上所述,在 Python 中,即使在类声明中声明 port: int,config.port 在运行时也可以是一个字符串。你绝对不想在每次使用该值时都去检查。
因此,我建议在程序启动后尽快验证配置,如果发现配置无效,请立即退出。注意,如果您选择使用上一节中建议的适当类型来表示配置条目,那么在许多情况下,只要能成功地解析配置就能保证配置有效(参见解析,不要验证)。
在操作方面,早期验证确保程序在启动后的一段时间内不会因为配置无效而退出。从开发的角度看,它使工作变得更容易,因为您可以在任何地方假设配置数据只包含有效值,并且可以像使用程序中的任何其他对象一样安全地使用配置。
四、在使用配置的地方声明它 最后一个原则是,配置项应该声明在它们使用的地方附近,例如,作为使用它的代码所在模块中的一个类。
此规则不能直接从上述基础派生,因为它不一定有助于更有效地使用工具,也不一定有助于及早预防或报告错误。但是,与在一个地方声明所有配置条目相比,它在软件工程方面有两个优点:
物理封闭性有助于导航 ,例如,更容易找到使用某个配置项的位置。此外,如果您使用的数据结构还定义了配置值的有效边界,那么在接近依赖这些边界的代码旁定义配置是有意义的。
它有助于避免在不同的、不相关的组件中使用相同的配置项 。假设您有一个条目,例如 timeout,它定义在一个公共位置,并且可以从所有模块访问,那么很容易会去在不同的不相关位置重用同一个 timeout 条目,而不是添加一个新条目并适当地命名和记录它。和在模块中本地定义配置对比下,则更容易看出这样做不好,例如,您很可能不会在 db.backend 模块中导入 web.http.config.client.timeout 以将其用作数据库连接池的设置。
在测试以配置为参数的组件时,只需要为组件使用的条目创建模拟配置对象,而不需要为整个应用程序模拟完整的配置。
每个模块的子配置可以通过组合或继承组装成一个更大的类。一般来说,我建议组合,因为从多个小配置类继承可能在某个点上导致命名冲突。
把碎片拼在一起 所以让我们看看如何将这些原则组合成一个小的代码示例。这个例子深受 Alexandru Nedelcu 的 Scala 最佳实践集合第 3.5 节所述方法的启发。
我们有三个模块,每个模块都定义了类型良好的配置类。(为了简洁起见,我省略了 import 语句。)
from dataclasses import dataclass
@dataclass
class UserConfig :
email: str
name: str
from dataclasses import dataclass
@dataclass
class ServerConfig :
host: str
port: int
例如,app.user 模块中的类可以在构造函数中获取其本地配置类的实例并使用它,而不必担心类型不匹配或缺少值。用户模块中的单元测试不必模拟整个应用程序配置。
注意,数据类特别适合这个应用程序,因为它们不能声明成员而不初始化,这与普通的 Python 类相反。如果在 dataclass 声明中添加了一个成员,那么 mypy 将报告代码中在没有为新成员提供值的情况下构造实例的所有位置。
然后,位于不同模块中的主程序可以定义一个应用程序范围的配置类,如下所示:
from dataclasses import dataclass
from app.user import UserConfig
from app.server import ServerConfig
@dataclass
class AppConfig :
user: UserConfig
server: ServerConfig
到目前为止,我还没有讨论如何实际创建实例并对这个全局配置类执行验证。对于类似这样的简单情况,将字典转换为数据类的 dacite 库非常有用。请考虑以下代码:
import json
from dacite import from_dict
from main import AppConfig
with open ('config.json' ) as f:
raw_config = json.load(f)
try :
config = from_dict(data_class=AppConfig, data=raw_config)
except Exception as e:
print (f"Configuration error: {e} " )
exit(1 )
print (config.server.port)
如果执行此代码时没有异常,那么我们就有了一个有效的配置对象。我希望可以和您达成共识,上述都是传递配置数据的更好方法,而不仅仅是一个包含已解析的 JSON 内容的字典。
相关免费在线工具 curl 转代码 解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online