引言
前一篇把 CSV 与 JSON 两种结构化文件讲透了,其中 JSON 的读写是本文的直接前置。第七章一路走来:1-3 篇解决 open 与读写 API,第 4 篇 pathlib,第 5 篇防御式读写,第 6 篇 os 模块,第 7 篇结构化文件。本篇把这些能力收拢到一个非常贴近日常工作的场景里:程序的配置读取。
几乎每个真实程序都需要在启动时读一批”人改的、机器读的”参数:服务器地址、端口、开关、超时阈值。这些参数直接写死在代码里,改一次就要改代码、走发布流程;抽到配置文件里,运维改一行就能调整整个系统的行为。本篇解决三个问题:配置文件用什么格式(ini / json / yaml 的取舍)、怎么读出来并转换成正确类型(字符串到 int/bool/float)、怎么给默认值、怎么校验必填项。
前置知识:open 与编码(本篇第 1-3 篇)、字典的合并(第六章)、异常捕获与自定义异常的 raise … from 异常链(第四章第 8 篇,本篇校验部分会用到自定义异常类)。第 9 篇的日志监控工具会以本篇的配置读法作为它的配置基础,第 10 篇测验会考察本文涉及的 configparser 用法。
概念与原理
配置文件的本质
配置与普通数据文件的区别在于”可维护性”。配置文件是给人类维护的:它需要注释、需要容错(缺一项不该崩)、需要默认值。数据文件是给程序交换的:它需要精确、紧凑、类型完整。所以”读配置”这个动作天然由三个步骤组成——解析(把文本变成结构)、转换(把字符串变成 int/bool/float 等正确类型)、兜底(缺失项用默认值补齐,而不是直接崩溃)。
三种配置格式的选型
本篇要如实比较三种主流方案,它们的支持现状不同:
| 格式 | 标准库支持 | 嵌套能力 | 注释 | 典型场景 |
|---|---|---|---|---|
| INI | configparser(内置) | 仅两段式 section/key | 支持(; 和 #) | 轻量运维配置、工具默认参数 |
| JSON | json(内置) | 任意深度 | 不支持 | 程序间交换、带类型的配置 |
| YAML | 无,需 pip install pyyaml | 任意深度 | 支持 | Docker/K8s/CI 等生态配置 |
INI 诞生于早期 Windows 的 ini 文件传统,语法是 [section] + key = value 两段式,天生为”配置”设计:段落(section)把不同领域的参数分组,DEFAULT 段还能给所有段落提供继承默认值。它的短板是结构扁平——值里面想嵌套复杂结构只能自己拼字符串。
JSON 的优势是类型与嵌套。同样是端口配置,写 8000 还是 “8000”,读出来的类型不同,程序可以据此做更强的校验。但它有两个配置场景的硬伤:注释(配置文件里”这行为什么要这样配”往往是刚需)和人对它的容忍度(多一个逗号整文件解析失败,对维护者不友好)。
YAML 可读性最好、支持注释与嵌套,现代基础设施(容器编排、CI、云原生)几乎都选它。代价是它不是标准库——必须 pip install pyyaml(导入名是 yaml),且缩进敏感,稍微错位就解析错误或静默产出意外结构。本文主体用零依赖的 INI 与 JSON 演示,YAML 只给出一个真实示例并如实说明依赖。
选型建议一句话:程序间交换数据用 JSON;自己的轻量配置用 INI(零依赖、够用);嵌套深、要注释、团队都熟 YAML 时用 YAML。
configparser 的四个设计
理解了这四点,INI 就没什么神秘的了。其一,大小写不敏感:默认的 optionxform 会把键名折叠成小写,读 [server] 里的 Host 与 host 等价。其二,DEFAULT 段是”全局默认值”,任何 section 里查不到某个键时都会回落到 DEFAULT 段找,但这也会造成”某个值不是我写的却生效了”的困惑。其三,插值(interpolation):默认的 BasicInterpolation 支持 %(name)s 语法引用同段的其他值,比如 log = %(base)s/app.log;代价是普通值里的 % 必须写成 %% 转义,否则直接抛 InterpolationSyntaxError。其四,类型转换:get() 返回字符串,getint()/getfloat()/getboolean() 在字符串基础上做转换,getboolean 只接受一组固定写法(1/yes/true/on 与 0/no/false/off,大小写不限),其他写法抛 ValueError。
校验与默认值的职责划分
配置读进内存之后,还有两道工序。默认值合并:与内置默认字典合并,用户的配置盖过默认值;嵌套配置必须用”深合并”(递归合并子字典),否则浅合并会把整个子字典替换掉、吞掉它的默认项(本篇实测对比会展示这个差异)。必填项校验:有些配置项没有合理的默认值(API 密钥、服务地址),缺失必须报错而不是静默运行——这正是第四章自定义异常的用武之地,把”配置不合法”建模成业务异常,用 raise … from 保留原始异常链。敏感信息(密钥、口令)则不应该出现在配置文本里,改由环境变量注入,避免提交进版本库。
操作与实现
第一步:用 configparser 读 INI
先自建一份 app.ini(包含两个 section:server 与 database),再读取并做类型转换:
import configparser
import os
import tempfile
tmp = tempfile.mkdtemp()
cfg_path = os.path.join(tmp, "app.ini")
with open(cfg_path, "w", encoding="utf-8") as f:
f.write("""[server]
host = 127.0.0.1
port = 8000
debug = true
[database]
host = db.example.com
port = 5432
max_connections = 100
""")
config = configparser.ConfigParser()
config.read(cfg_path, encoding="utf-8")
print("sections:", config.sections())
print("server.host =", config.get("server", "host"))
print("database.max_connections =", config.getint("database", "max_connections"))
print("server.debug =", config.getboolean("server", "debug"))
sections: ['server', 'database']
server.host = 127.0.0.1
database.max_connections = 100
server.debug = True
read() 接受路径(或路径列表),encoding=“utf-8” 显式指定编码,避免 Windows 默认编码引起的跨平台问题。三兄弟的对应关系:get 返回字符串,getint 返回 int(“100” 转成 100),getboolean 返回 bool(“true” 转成 True)。
第二步:read() 的静默失败陷阱
configparser 的 read() 对不存在的文件不抛异常,而是悄悄返回”实际读成功的文件列表”(空列表)。这个设计让”多文件分层配置”很方便(后读的文件覆盖先读的),但也意味着拼错路径时程序会在空配置下继续运行:
import configparser
import os
import tempfile
tmp = tempfile.mkdtemp()
config = configparser.ConfigParser()
result = config.read(os.path.join(tmp, "not-exist.ini"), encoding="utf-8")
print("read() 返回值:", result)
print("sections:", config.sections())
read() 返回值: []
sections: []
因此生产代码里读配置后应检查 result 是否为空(或用 config.has_section 验证关键 section 存在),主动报错而不是放任静默。
第三步:DEFAULT 段——全局默认值
把公共默认值写进 [DEFAULT],每个 section 都能读到;某个 section 自己定义了同名键则本段优先:
import configparser
config = configparser.ConfigParser()
config.read_string("""[DEFAULT]
timeout = 30
retry = 3
[api]
url = https://api.example.com
[db]
url = postgresql://local
""")
print("api.timeout =", config.get("api", "timeout"))
print("db.timeout =", config.get("db", "timeout"))
print("db.retry =", config.getint("db", "retry"))
print("api 能查到 retry 吗:", config.has_option("api", "retry"))
api.timeout = 30
db.timeout = 30
db.retry = 3
api 能查到 retry 吗: True
api 和 db 都没有显式写 timeout,却都读到了 DEFAULT 提供的 30;has_option 同样把 DEFAULT 提供的项算作”存在”。这套机制相当于 INI 的内置默认值系统,但它有两个反直觉处要留意:sections() 列表里并不包含 [DEFAULT] 本身;继承的默认值会出现在 items(section) 的每条结果里,排查”这个值哪来的”时要先想到 DEFAULT。
第四步:getboolean 的取值白名单
getboolean 不是通用布尔解析,它对值有严格的白名单:1/yes/true/on 转 True,0/no/false/off 转 False(大小写不限),其余一律 ValueError:
import configparser
config = configparser.ConfigParser()
config.read_string("""[flags]
a = yes
b = 1
c = on
d = true
e = no
f = 0
g = off
h = false
z = 也许
""")
for k in "abcdefgh":
print(" ", k, "=", config.getboolean("flags", k))
try:
config.getboolean("flags", "z")
except ValueError as e:
print("非法布尔值报错:", e)
a = True
b = True
c = True
d = True
e = False
f = False
g = False
h = False
非法布尔值报错: Not a boolean: 也许
这份白名单的好处是:无论配置文件里写 yes、1 还是 on,读到的都是同一个 True,团队里不同人写法不一致也不会出 bug;坏处是:如果哪天有人在配置里写 “Y” 或 “是”,程序会在启动时直接抛 ValueError——这是好事,尽早暴露配置错误。
第五步:缺失键的 fallback 与 NoOptionError
get 系列都支持 fallback 参数:键存在返回值,键不存在返回 fallback,不抛异常。不传 fallback 时缺失键抛出 NoOptionError(也是第四章讲的 ValueError 家族之外的标准异常),调用方可以捕获它做更精细的处理:
import configparser
config = configparser.ConfigParser()
config.read_string("[server]\nhost = 127.0.0.1\nport = 8000\n")
print("已有值:", config.get("server", "host", fallback="unknown"))
print("缺失值兜底:", config.get("server", "timeout", fallback="30"))
print("整型兜底:", config.getint("server", "timeout", fallback=60))
try:
config.get("server", "nope")
except configparser.NoOptionError as e:
print("NoOptionError:", e)
已有值: 127.0.0.1
缺失值兜底: 30
整型兜底: 60
NoOptionError: No option 'nope' in section: 'server'
fallback 的值与键存在与否无关,只与”查不到”相关:第一个 print 里 host 明明有值,fallback=“unknown” 不生效;后两个的 timeout 不存在,fallback 分别按字符串和整型兜底。
第六步:% 插值与转义
默认开启 BasicInterpolation 时,% 是特殊字符:%(name)s 引用同段(含 DEFAULT)其他键的值,单独的 % 后跟其他字符则直接抛 InterpolationSyntaxError。日志路径里常见的日期格式 %Y%m%d 正是经典受害者:
import configparser
config = configparser.ConfigParser()
config.read_string("[log]\npath = logs/app-%Y%m%d.log\nlevel = DEBUG\n")
try:
print(config.get("log", "path"))
except configparser.InterpolationSyntaxError as e:
print("InterpolationSyntaxError:", e)
config2 = configparser.ConfigParser()
config2.read_string("[log]\npath = 完成率 100%%\n")
print("转义之后:", config2.get("log", "path"))
InterpolationSyntaxError: '%' must be followed by '%' or '(', found: '%Y%m%d.log'
转义之后: 完成率 100%
解决办法有二:值里的 % 写成 %%(上面第二个例子,“100%%” 读回 “100%”);或者创建解析器时传 interpolation=None 关闭插值,代价是失去 %(name)s 引用能力。先用引用后用转义的顺序是:如果配置里确实需要 %(name)s,就保留插值并仔细转义所有普通 %。
第七步:JSON 配置 + 默认值深合并
INI 之外最常见的方案是 JSON 配置与内置默认字典合并。浅合并(dict.update)有一个隐蔽 bug:整棵子字典被替换,子字典里的默认项悄悄消失。深合并递归处理嵌套的字典,只覆盖用户真正改写的叶子:
import json
defaults = {
"host": "127.0.0.1",
"port": 8000,
"cache": {"enabled": True, "size_mb": 64},
"retry": 3,
}
user_config = {
"port": 9000,
"cache": {"enabled": False},
}
def deep_merge(base, override):
merged = dict(base)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = deep_merge(merged[key], value)
else:
merged[key] = value
return merged
print("深合并:", json.dumps(deep_merge(defaults, user_config), ensure_ascii=False))
shallow = dict(defaults)
shallow.update(user_config)
print("浅合并:", json.dumps(shallow, ensure_ascii=False))
深合并: {"host": "127.0.0.1", "port": 9000, "cache": {"enabled": false, "size_mb": 64}, "retry": 3}
浅合并: {"host": "127.0.0.1", "port": 9000, "cache": {"enabled": false}, "retry": 3}
对比两行输出:深合并保留了 cache.size_mb=64,浅合并把整个 cache 换成 {“enabled”: false},默认的 64 消失。判断”该深还是该浅”的标准:配置里有没有嵌套结构,有就必须深合并。
第八步:必填校验 + 自定义异常
有些键没有默认值可言(api_key、endpoint),缺失必须报错。这里把第四章的自定义异常派上用场:定义 ConfigError,JSON 解析失败和必填项缺失都抛它;JSONDecodeError 用 raise … from e 转链,保留原始错误位置:
import json
class ConfigError(Exception):
"""配置文件不合法或缺少必填项时抛出(自定义异常见第四章)。"""
def load_config(text):
try:
config = json.loads(text)
except json.JSONDecodeError as e:
raise ConfigError(
"配置文件不是合法 JSON:第 %d 行第 %d 列附近出问题" % (e.lineno, e.colno)
) from e
required = {"api_key", "endpoint"}
missing = required - set(config)
if missing:
raise ConfigError("缺少必填配置项: %s" % sorted(missing))
return config
ok = '{"api_key": "sk-1234", "endpoint": "https://api.example.com/v1"}'
print("合法配置:", load_config(ok))
for bad in ('{"endpoint": "https://api.example.com/v1"}',
'{"api_key": , "endpoint": "x"}'):
try:
load_config(bad)
except ConfigError as e:
print("校验失败:", e)
合法配置: {'api_key': 'sk-1234', 'endpoint': 'https://api.example.com/v1'}
校验失败: 缺少必填配置项: ['api_key']
校验失败: 配置文件不是合法 JSON:第 1 行第 13 列附近出问题
两种失败模式都被收敛成同一种 ConfigError:第一种是”值合法但缺键”(集合差集算出来的缺失项),第二种是”语法都不合法”(异常链里保留着 JSONDecodeError 的原始信息)。调用方只需要 except ConfigError 一处,就能统一处理”配置坏了”这一类错误。
第九步:完整可运行示例
把所有零件组装起来:一个”日志监控工具”的配置读取(顺带衔接第 9 篇的日志工具),INI 文件、DEFAULT 段、四种取值方式一次到位:
import configparser
import os
import tempfile
tmp = tempfile.mkdtemp()
cfg_path = os.path.join(tmp, "monitor.ini")
with open(cfg_path, "w", encoding="utf-8") as f:
f.write("""[DEFAULT]
interval = 60
level = INFO
[source]
log_file = logs/app.log
tail_lines = 200
[alert]
enabled = yes
max_error_rate = 5.0
""")
config = configparser.ConfigParser()
config.read(cfg_path, encoding="utf-8")
settings = {
"log_file": config.get("source", "log_file"),
"tail_lines": config.getint("source", "tail_lines"),
"interval": config.getint("source", "interval"), # 来自 DEFAULT
"level": config.get("alert", "level"), # 来自 DEFAULT
"alert_enabled": config.getboolean("alert", "enabled"),
"max_error_rate": config.getfloat("alert", "max_error_rate"),
}
print("最终生效配置:")
for key, value in settings.items():
print(" %-15s = %r" % (key, value))
最终生效配置:
log_file = 'logs/app.log'
tail_lines = 200
interval = 60
level = 'INFO'
alert_enabled = True
max_error_rate = 5.0
注意 source 与 alert 两个 section 都没有写 interval 和 level,它们来自 [DEFAULT];alert 段只写了 enabled 和 max_error_rate,其余全部由默认值补齐。这一份代码就是第 9 篇日志监控工具启动加载配置的原型。
第十步:敏感信息改走环境变量
密钥、口令这类敏感信息不应写进配置文件——配置文件会进版本库、会被复制、会被截图。标准做法是环境变量注入,Python 用 os.getenv 读取:
import os
token = os.getenv("DINGDING_WEBHOOK_TOKEN")
print("token 是否已配置:", bool(token))
token 是否已配置: False
在本机上这个环境变量不存在,所以打印 False。真实部署时由部署平台(或启动脚本)注入环境变量,代码零改动。配置文件里只保留非敏感的路径、端口、开关。
附:YAML 的真实示例
最后给 YAML 一个完整示例。再次强调:yaml 不是标准库,运行前必须先 pip install pyyaml(导入名 yaml)。缩进的层级代表嵌套,列表用 - 开头:
# 需要第三方库:pip install pyyaml(导入名是 yaml)
import yaml
yaml_text = """server:
host: 127.0.0.1
port: 8000
features:
- auth
- cache
"""
data = yaml.safe_load(yaml_text)
print(type(data).__name__)
print(data)
dict
{'server': {'host': '127.0.0.1', 'port': 8000}, 'features': ['auth', 'cache']}
safe_load(而不是 load)是规定动作:safe_load 只构造普通数据类型,杜绝了老式 load 在不可信输入上可能触发的对象构造风险(历史上有过著名的反序列化漏洞)。如果项目里已经引入了 PyYAML(大部分现代 Python Web 项目都有),配置文件选 YAML 是合理的;否则为零依赖牺牲一点可读性,完全可以用 INI 或 JSON 平替。
易错点与陷阱
-
% 在配置值里是特殊字符。默认 BasicInterpolation 下,日志路径里的 %Y%m%d、百分比 “80%” 都会让 get() 抛 InterpolationSyntaxError(本文已实测),解决次序是:需要引用语法就写 %% 转义,不需要就 interpolation=None 关掉插值。
-
getboolean 不是万能布尔解析。它只认 1/yes/true/on 与 0/no/false/off(大小写不限),写 “Y”、写 “是”、写 “maybe” 都直接 ValueError: Not a boolean。反过来这也是优点:配置错误在启动时立刻暴露,不会带病运行。
-
浅合并吞掉嵌套默认值。defaults.update(user_config) 会把整个 cache 子字典替换掉,size_mb 这类默认项无声消失(实测对比见第七步)。凡配置存在嵌套结构,默认值合并必须深合并。
-
read() 找不到文件不报错。返回空列表、程序继续用空配置运行,是个真正的静默失败。读配置后要检查返回值或 has_section,拿不准就让”配置缺失”显式报错而不是假装一切正常。
-
敏感信息随手入库。把 API 密钥、数据库口令写进配置文件并提交版本库,一旦仓库公开就是安全事故。密钥走环境变量(os.getenv),配置只放非敏感参数。
-
DEFAULT 段的隐式默认值。同一键在各 section 都能读到、has_option 返回 True、items() 里也带出来,排查”这个值我没写怎么生效了”时先想到 DEFAULT。
小结
配置读取的完整流程是”解析 → 转换 → 兜底 → 校验”四部曲。格式选型记住一句话:INI 零依赖适合轻量配置(configparser 的 section/DEFAULT/插值与 getint/getboolean/fallback 全家桶),JSON 类型与嵌套强(配合深合并与自定义 ConfigError 校验是最常见组合),YAML 可读性最好但必须追加 pyyaml 依赖且用 safe_load。两条红线贯穿始终:文件不存在要显式报错,密钥不进配置文件。本文的完整示例直接为第 9 篇日志监控工具的配置模块铺路,第 10 篇测验将从这三个层面考察本章。
练习与思考题
- 写一个函数 read_ini(path, required_sections):用 configparser 读取 INI,若 read() 结果为空或缺少 required_sections 中的某个 section,抛出你自己定义的一个 ConfigError(可复用本文的类),并给出错误信息。提示:read() 返回列表,sections() 返回全部 section 名。
- 把第七步的 deep_merge 扩展成支持二层以上的嵌套(比如 defaults = {“a”: {“b”: {“c”: 1}}}),并验证 override = {“a”: {“b”: {“d”: 2}}} 合并后 c 与 d 同时存在。提示:递归本身就支持任意深度,重点检查”键在 base 中是 dict、override 中也是 dict”这个递归条件。
- 设计一份 GET 请求客户端的最小 INI:至少包含 [http](base_url、timeout_seconds、retry)与 [auth](token_env 指向环境变量名),写出读取代码:超时用 getfloat、重试用 getint、token 用 os.getenv 从 token_env 指定的环境变量取值。回答:如果 retry 写成了 “three”,程序什么时候、抛什么类型的错?
- 思考题:一个配置项需要同时支持”默认值、用户覆盖、必须校验”三层语义,你会把这套逻辑放在哪个函数里(解析、合并还是校验)?以及为什么 read() 的静默失败设计在”多文件分层配置”场景下反而是个优点?