代码实战 · 2026 年 11 月 11 日

写一个零依赖的 JSON 配置文件校验器

配置文件写错一个类型,服务启动十分钟后才崩在某个分支上。用两百行标准库代码做一次启动校验:类型、范围、枚举、未知字段、默认值,错误一次报全。

配置文件是那种「改的人不写代码、写代码的人不看」的东西。上周有人把 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 的子类#

python
# 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。

校验函数:收集全部错误再抛#

python
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 的字段,尤其是模型名里带点号的时候。

默认值与加载入口#

python
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 本身也做一次校验,或者干脆把默认值写进一组固定的测试配置里。

用一份坏配置跑一遍#

python
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,这两个坑都来自标准库的宽松默认。

完

#Python#配置#标准库
回到顶部 ↑