配置文件是那种「改的人不写代码、写代码的人不看」的东西。上周有人把 port 写成了字符串,服务照常启动,直到健康检查连不上才暴露。
引入 jsonschema 或 pydantic 当然可以,但一个只读一次配置的小服务,为校验多背一个依赖并不划算——pydantic 还会把配置对象变成模型,改动时要连模型一起改。下面这版只依赖标准库,管住四种最常见的错:类型不对、值越界、字段拼错、默认值缺失。
先想清楚要校验什么#
配置校验和 API 参数校验的目标不同。API 要挡住恶意输入,配置只需要挡住手误,外加给出人能看懂的错误位置。所以下面这套 schema 故意做小,只保留够用的关键字:
| 关键字 | 作用 | 例子 |
|---|---|---|
type |
基本类型 | object、array、string、integer、number、boolean |
required |
必填字段名 | ["server"] |
properties |
子字段 schema | {"port": {"type": "integer"}} |
additionalProperties |
是否允许未知字段 | 设为 false 时拼错的键会报错 |
enum |
枚举值 | ["debug", "info", "warn"] |
pattern |
字符串正则(整串匹配) | "^[a-z][a-z0-9_]*$" |
minimum / maximum |
数值边界 | 1 / 65535 |
minLength |
字符串最小长度 | 1 |
items |
数组元素 schema | {"type": "string"} |
default |
缺省值 | "127.0.0.1" |
不实现 $ref、oneOf、format。这些一上,代码量翻倍,而配置里几乎用不到。真需要组合校验,写几行 Python 比写 schema 清楚。
还有一个经常被忽略的目标:错误信息要能直接贴进群里。$.server.port: 不能大于 65535 比 ValidationError: 1 validation error for Config 有用得多,也不需要对方装工具来解读。这是手写校验器唯一能赢过现成库的地方,值得认真做。
类型判断有个坑:bool 是 int 的子类#
# jsonconf.py —— 仅标准库,Python 3.9+
import copy
import json
import re
class ConfigError(ValueError):
def __init__(self, errors):
self.errors = list(errors)
super().__init__(";".join(f"{p}: {m}" for p, m in self.errors))
_TYPES = {"string": str, "integer": int, "number": (int, float),
"boolean": bool, "array": list, "object": dict}
def _is_type(value, name):
if name == "integer": # True 也会通过 isinstance(x, int)
return isinstance(value, int) and not isinstance(value, bool)
if name == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
return isinstance(value, _TYPES[name])
def _join(path, key):
return f"{path}.{key}" if isinstance(key, str) else f"{path}[{key}]"
isinstance(True, int) 在 Python 里是 True。不显式排除布尔值,"workers": true 会被当成合法的整数 1,然后在某个循环里静默跑一个 worker。
反过来,1.0 这种浮点数不该通过 integer 校验,但 1 可以通过 number。这两个判断的严格程度不同,写反了会出现「配置里写 1.0 也能过」的宽松校验,最后在需要整数索引的地方报 TypeError。
校验函数:收集全部错误再抛#
def check(value, schema, path, errors):
t = schema.get("type")
if t and not _is_type(value, t):
errors.append((path, f"期望 {t},实际是 {type(value).__name__}"))
return
if "enum" in schema and value not in schema["enum"]:
errors.append((path, f"必须是 {schema['enum']} 之一"))
if t == "string":
if len(value) < schema.get("minLength", 0):
errors.append((path, f"长度不能小于 {schema['minLength']}"))
if "pattern" in schema and not re.fullmatch(schema["pattern"], value):
errors.append((path, f"不匹配 {schema['pattern']}"))
elif t in ("integer", "number"):
if "minimum" in schema and value < schema["minimum"]:
errors.append((path, f"不能小于 {schema['minimum']}"))
if "maximum" in schema and value > schema["maximum"]:
errors.append((path, f"不能大于 {schema['maximum']}"))
elif t == "object":
props = schema.get("properties", {})
for key in schema.get("required", []):
if key not in value:
errors.append((_join(path, key), "缺少必填字段"))
for key, sub in props.items():
if key in value:
check(value[key], sub, _join(path, key), errors)
if schema.get("additionalProperties") is False:
for key in value.keys() - props.keys():
errors.append((_join(path, key), "未知字段"))
elif t == "array" and "items" in schema:
for i, item in enumerate(value):
check(item, schema["items"], _join(path, i), errors)
几个决定值得解释:
- 一次收集全部错误。改配置的人希望一次看到所有问题,而不是改一个、重启、再看到下一个。代价是第一次失败后会返回一长串,所以每条都带路径。
- 路径用
$.server.port的形式。它和 JSON Pointer 不完全一样,但对人更友好,复制到日志里也能直接定位。 - 类型错误后立即
return。继续对错误类型的值做范围比较会抛TypeError,把「配置错了」变成「程序崩了」。 additionalProperties: false才检查未知字段。默认不检查,方便灰度期加字段;想严格就显式打开,拼错prot会立刻暴露。
路径拼接用 _join 而不是字符串加法,是为了让数组下标长成 $.models[0] 而不是 $.models.0。两者都能定位,但后者容易被误认成名为 0 的字段,尤其是模型名里带点号的时候。
默认值与加载入口#
def apply_defaults(value, schema):
if not isinstance(value, dict):
return value
out = dict(value)
for key, sub in schema.get("properties", {}).items():
if key in out:
out[key] = apply_defaults(out[key], sub)
elif "default" in sub:
out[key] = copy.deepcopy(sub["default"]) # 避免共享可变默认值
return out
def _reject_const(name):
raise ValueError(f"不允许的常量 {name}")
def load_config(path, schema):
try:
with open(path, encoding="utf-8-sig") as f:
data = json.load(f, parse_constant=_reject_const)
except ValueError as e:
raise ConfigError([("$", f"JSON 解析失败:{e}")]) from e
data = apply_defaults(data, schema)
errors = []
check(data, schema, "$", errors)
if errors:
raise ConfigError(errors)
return data
两个细节:utf-8-sig 兼容 Windows 记事本保存的 BOM,否则 json.load 会报 Unexpected UTF-8 BOM;parse_constant 用来拒绝 NaN、Infinity——标准 JSON 不允许这些值,但 Python 的 json 默认接受,写进配置后传到其他语言就会炸。
顺序上先补默认值、再校验。这样「有默认值的必填字段」不会误报缺失,而调用方拿到的对象一定是补全过的。代价是默认值本身写错(比如默认端口是 70000)不会被发现自己——所以在测试里应该对 schema 本身也做一次校验,或者干脆把默认值写进一组固定的测试配置里。
用一份坏配置跑一遍#
SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": ["server"],
"properties": {
"server": {
"type": "object",
"required": ["port"],
"properties": {
"host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "integer", "minimum": 1, "maximum": 65535},
"tls": {"type": "boolean", "default": False},
},
},
"workers": {"type": "integer", "minimum": 1, "maximum": 64, "default": 4},
"models": {"type": "array", "items": {"type": "string", "minLength": 1}},
},
}
try:
cfg = load_config("config.json", SCHEMA)
except ConfigError as e:
for path, msg in e.errors:
print(f"{path}: {msg}")
raise SystemExit(1)
配上一份故意写错的 config.json(port 越过 65535、workers 写成 0、多了一个 extra),输出是三行:
$.server.port: 不能大于 65535$.workers: 不能小于 1$.extra: 未知字段
改对之后,返回的 cfg 里已经补上了 server.host、server.tls 和 workers,业务代码不用再写 get("workers", 4)。
边界与取舍#
- 数字精度:Python 的
int是任意精度,float是双精度。配置里放大整数(如 ID)时,别用浮点表示;跨语言传递时更要注意 JS 只有 53 位安全整数。 - 文件大小:
json.load会一次读进内存。配置文件不该超过几十 KB,如果真到了 MB 级,说明它更适合放进数据库。 - 热加载:监听文件变化时,先读到完整内容再解析,校验通过后再原子替换配置对象;否则可能读到半个文件。
- 错误信息脱敏:配置里常有密码和 token,报错时只打印路径和类型,不要打印值。这一点比校验本身更容易被忽略。
- 未知字段的新旧兼容:上线顺序通常是「先发能读新字段的版本,再改配置」。这段时间里旧版本遇到新字段不能崩,所以
additionalProperties最好只在测试环境设成false。 - 不要校验环境变量注入的值:容器里很多配置来自
env,字符串"8080"是常态。要么在入口处显式转换,要么给这些字段放宽为字符串再解析,别让校验器替你做隐式转换。
校验通过不等于配置正确。端口范围合法但和另一个服务冲突、路径存在但没有写权限,这些只能靠启动后的健康检查发现。校验负责「形状」,不负责「语义」。
结论#
- 两百行标准库足够覆盖配置校验的常见错误,不必为此引入依赖。
bool是int的子类,类型判断必须显式排除。- 一次收集全部错误并带上
$.a.b路径,比快速失败更好用。 - 默认值要用
copy.deepcopy,否则可变默认值会在多次加载间共享。 - 用
utf-8-sig读文件、用parse_constant拒绝NaN,这两个坑都来自标准库的宽松默认。
完