简介:《代码问世:我对代码问世的解决方案(所有年份)》是一份面向希望提升算法能力与Python工程实践的中高级学习者的Advent of Code题解合集,覆盖2017至2020年每日挑战,适合按年份系统刷题、对照思路或备战同类编程竞赛。压缩包共240个文件,以152个Python脚本和83个.input输入文件为主体,另有4张png示意图与1个.gitignore配置,整体约989KB,按年份与日期分层组织,脚本中可清晰看到读取输入与求解算法两部分的拆分。已有138人学习下载,说明其在同类题解资料中具备一定参考价值。读者可从中获得覆盖递归、图遍历、动态规划、矩阵与字符串处理、计算几何、位运算及网络流等主题的完整解法,并观察itertools、collections等标准库及numpy、pandas的运用方式,同时参考文件I/O、生成器与列表推导式带来的性能优化、错误处理调试习惯和Git版本管理实践,进而形成可复用的解题模板与代码组织思路。
1. 代码问世不是把需求丢给生成器:先看清「所有年份」的代码债
某团队接手一个跨了八年的代码库,最早那批模块还带着 Python 2 的写法,中间几年流行一套框架,最近两年又换成另一套,新人要同时读三种风格。代码问世这件事在这里不是“让工具吐出一段能跑的代码”,而是让新生成的代码能在这样的老库里落地:语法要兼容、命名要统一、生成物要可回滚,还得说清哪一年写的代码该按哪套规则改。“所有年份”说的就是这条时间轴上的存量债,谁绕开它,生成器上线第一周就会被历史代码打回来。这套方案适合三类人:正在搭代码生成平台的后端,要接手跨年度项目的维护者,以及给多版本接口做兼容层的工程师。下面按“先建模、再生成、再兼容、再进流水线”的顺序拆开讲。
2. 从 YAML 到可运行骨架:元数据、模板与校验的三层结构
代码生成器最容易翻车的地方不在模板语法,而在“事实来源”不唯一。字段名在 YAML 里写一遍,在模板里拼一遍,在校验脚本里再写一遍,改一个字段要动五处,第三个月就没人敢动了。这一章把生成器拆成三层:元数据层负责描述“要生成什么”,模板层负责“生成成什么样”,校验层负责“生成得对不对”。三层各自有独立入口,任何一层出问题都能单独定位,而不是靠读日志猜。
2.1 先定元数据再写模板:为什么字符串拼接一定会返工
我见过最省事的写法是把建表语句和模板写在一个脚本里,用 f-string 拼出 Python 文件。第一个实体能跑,第二个实体开始出现命名风格分裂,第三个实体加了外键,模板里开始出现if entity == "order"这种分支。这不是模板引擎的问题,是元数据没有约束。
元数据的载体常见有三种:YAML 文件、JSON、以及直接在生成器里用 Python 的 dataclass 声明。三者的差别集中在这几个维度:
| 载体 | 类型检查 | 注释能力 | 生成器内直接引用 | 适合场景 |
|---|---|---|---|---|
| YAML | 无,需额外 schema 校验 | 强 | 需先加载解析 | 业务方也会改的配置 |
| JSON | 无 | 弱,不支持注释 | 需先加载解析 | 机器间传输 |
| dataclass | 强,mypy 可查 | 强 | 可直接 import | 生成器自用、字段类型有限 |
我一般选第三种:用 dataclass 声明字段,YAML 只作为输入格式,加载后立刻转成 dataclass 实例。这样字段类型、可空性、长度约束在进入模板之前就已经被 Python 的类型系统拦了一道,模板里不需要再写if field.type == "str"这种判断。
# schema/model.py from dataclasses import dataclass, field from typing import Literal FieldType = Literal["str", "int", "bool", "datetime"] @dataclass(frozen=True) class Field: name: str type: FieldType nullable: bool = False max_length: int | None = None # 是否出现在列表查询的过滤条件里,生成器据此决定要不要生成索引 filterable: bool = False @dataclass(frozen=True) class Entity: name: str # 实体名,如 order table: str # 物理表名,如 t_order fields: list[Field] = field(default_factory=list) # 生成物落在哪个子包,避免所有文件堆在一个目录 package: str = "models" def __post_init__(self) -> None: if not self.name.isidentifier(): raise ValueError(f"实体名不是合法标识符: {self.name}") if not self.fields: raise ValueError(f"实体 {self.name} 没有任何字段") names = [f.name for f in self.fields] if len(names) != len(set(names)): raise ValueError(f"实体 {self.name} 存在重复字段")上面这段的关键在__post_init__:把能提前暴露的错误全部提前。实体名不是合法标识符、字段为空、字段重名,这三类问题如果留到模板渲染阶段,报错信息会变成 Jinja2 的模板行号,排查成本翻好几倍。filterable这个字段看起来不起眼,它决定了生成器要不要顺手产出索引定义和查询参数,属于“元数据驱动”里最容易被忽略的一环——凡是生成器要用来做分支判断的信息,都必须进元数据,不能散在模板的 if 里。
2.2 用 dataclass 驱动 Jinja2 生成一版能跑的 CRUD 骨架
模板层只做一件事:把元数据翻译成目标语言的文本。模板里允许出现循环和条件,但不允许出现业务规则。比如“金额字段要保留两位小数”这种规则应该写在元数据或校验层,而不是模板里判断字段名。
# tools/gen.py import pathlib from jinja2 import Environment, FileSystemLoader, StrictUndefined from schema.model import Entity from schema.loader import load_entities TEMPLATE_DIR = pathlib.Path(__file__).parent / "templates" def build_env() -> Environment: env = Environment( loader=FileSystemLoader(TEMPLATE_DIR, encoding="utf-8"), # 未定义变量直接报错,避免渲染出带空值的半成品文件 undefined=StrictUndefined, # 保留模板缩进,别让生成文件变成一坨 trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True, ) return env def render_entity(env: Environment, entity: Entity) -> dict[pathlib.Path, str]: out = {} for tpl_name, suffix in [("model.py.j2", ".py"), ("schema.py.j2", "_schema.py")]: tpl = env.get_template(tpl_name) # 统一用 \n 换行,跨操作系统生成结果才能做 diff text = tpl.render(entity=entity).replace("\r\n", "\n") out[pathlib.Path(entity.package) / f"{entity.name}{suffix}"] = text return out def main(out_dir: pathlib.Path) -> None: env = build_env() entities = load_entities(pathlib.Path("schema/entities.yaml")) for entity in entities: for rel_path, text in render_entity(env, entity).items(): target = out_dir / rel_path target.parent.mkdir(parents=True, exist_ok=True) target.write_text(text, encoding="utf-8")模板本身长这样,以模型文件为例:
{# templates/model.py.j2 #} """自动生成,请勿手改。字段来源见 schema/entities.yaml。""" from datetime import datetime from pydantic import BaseModel, Field class {{ entity.name | capitalize }}Model(BaseModel): {% for f in entity.fields %} {%- set annotation = { "str": "str", "int": "int", "bool": "bool", "datetime": "datetime" }[f.type] %} {%- if f.nullable %}{% set annotation = annotation ~ " | None" %}{% endif %} {{ f.name }}: {{ annotation }} = Field( {%- if f.nullable %}, default=None{% endif %} {%- if f.max_length %}, max_length={{ f.max_length }}{% endif %} ) {% endfor %}代码后的参数说明:StrictUndefined是整套生成器里最划算的一个开关,模板里写错变量名会直接抛异常,而不是安静地渲染出空字符串;trim_blocks和lstrip_blocks一起开,才能让{% for %}这类指令不额外占一行;keep_trailing_newline保证文件末尾有换行,不然每次生成都要和格式化工具打架;换行符统一替换成\n,是为了让生成物在任何机器上都能用diff比较,否则跨操作系统协作时 diff 会全线飘红。
类型注解的映射表刻意写在模板里而不是元数据层,原因是它属于“代码风格”而不是“业务事实”。哪天团队换风格,改模板就行,YAML 一个字不用动。这个边界划清楚,后面迁移到别的语言时只需要重写模板,元数据可以完全复用。
2.3 生成完立刻验证:AST 解析、import 冒烟与命名检查
模板渲染成功不代表生成物是合法代码。Jinja2 不会检查 Python 语法,一个漏掉的冒号能让整批文件全部报废,而错误要等到运行时才暴露。所以生成器最后一步必须自带三道检查,任何一道不过就直接失败退出,不写文件。
# tools/verify.py import ast import subprocess import sys import pathlib def check_syntax(files: list[pathlib.Path]) -> list[str]: """第一道:语法与最低版本兼容性。""" problems = [] for p in files: source = p.read_text(encoding="utf-8") try: # feature_version 让解析器按目标版本语法解析,高版本语法会直接报错 ast.parse(source, filename=str(p), feature_version=(3, 8)) except SyntaxError as e: problems.append(f"{p}:{e.lineno} {e.msg}") return problems def check_import(package: str) -> list[str]: """第二道:真实 import 一次,抓运行期错误。""" proc = subprocess.run( [sys.executable, "-c", f"import {package}"], capture_output=True, text=True, ) return [proc.stderr.strip()] if proc.returncode != 0 else [] def check_naming(files: list[pathlib.Path]) -> list[str]: """第三道:类名必须大驼峰,方法名必须小写下划线。""" import re problems = [] for p in files: tree = ast.parse(p.read_text(encoding="utf-8")) for node in ast.walk(tree): if isinstance(node, ast.ClassDef) and not re.fullmatch(r"[A-Z][A-Za-z0-9]*", node.name): problems.append(f"{p}: 类名不符合大驼峰: {node.name}") return problems三道检查分别挡三类问题。ast.parse的feature_version参数是这里最值得说的一项:它只影响语法解析,不检查标准库 API 是否存在,所以它能挡住match语句、海象运算符、type X = ...这些高版本语法,但挡不住datetime.utcnow()这种“语法合法、运行时弃用”的调用。后者要靠第 3 章的适配层处理。
真实 import 那一道别省。语法正确但from datetime import datetime写成了from datetime import datetime as dt又漏改引用,这类错误只有真正 import 才会暴露。代价是一次子进程启动,几秒钟,比线上回滚便宜太多。
命名检查放在最后,原因是它依赖 AST 解析成功,语法都不对就没必要谈命名。三道检查的失败信息统一收集后一次性打印,不要遇到第一个问题就退出,否则修一个跑一次,效率极低。
3. 「所有年份」怎么兼容:跨版本扫描、适配层与迁移脚本
标题里“所有年份”是最容易被低估的部分。一个跑了八年的 Python 项目,代码里同时存在print语句、except Exception, e、f-string、海象运算符、match语句,还可能有 3.12 才引入的类型别名语法。生成器只保证新代码是对的,存量代码还得单独治理,否则新老两套写法会长期并存,最后没人说得清哪套是标准。
3.1 老代码的三种年代病:语法、依赖与约定
语法层面最好查,也最容易低估。常见做法是先确认团队支持的最低版本,比如 3.8,然后按这个版本做一次全量解析。需要注意ast.parse的feature_version只接受主次版本号,且它只做语法层面判定,不做语义判定。也就是说f"{x=}"这种 3.8 引入的调试语法能被正确识别,但dict | dict这种 3.9 才支持的类型联合写法,在运行时会不会报错取决于是否用了from __future__ import annotations。
依赖层面比语法麻烦。requirements 不锁版本,同一份代码在两台机器上装出不同的小版本,行为可能完全不一样。常见做法是生成一份锁定文件,把直接依赖和间接依赖全部固定到补丁号,并把生成器的依赖和业务依赖分成两个文件,避免生成器升级时把业务依赖一起带上去。
约定层面最隐蔽。有的老模块用下划线命名,有的用驼峰;有的失败返回None,有的抛异常;有的日志用 print,有的用 logging。这些东西语法扫描扫不出来,只能靠规则检查加人工梳理。我一般会先跑一遍命名和异常处理的规则检查,把结果按模块聚合成一张表,再和业务方一起定“哪些模块保持不变、哪些模块逐步改”。
3.2 用 AST 的 feature_version 做一次全量语法体检
扫描脚本不长,但要处理好编码问题和跳过目录。
# tools/scan_syntax.py import ast import pathlib import sys import json SKIP_DIRS = {".venv", "venv", "node_modules", ".git", "generated", ".shadow"} TARGET = (3, 8) def iter_py_files(root: pathlib.Path): for p in root.rglob("*.py"): if any(part in SKIP_DIRS for part in p.parts): continue yield p def scan(root: pathlib.Path) -> list[dict]: findings = [] for p in iter_py_files(root): try: source = p.read_text(encoding="utf-8") except UnicodeDecodeError: findings.append({"file": str(p), "line": 0, "msg": "非 UTF-8 编码,需先转码"}) continue try: ast.parse(source, filename=str(p), feature_version=TARGET) except SyntaxError as e: findings.append({"file": str(p), "line": e.lineno, "msg": e.msg}) return findings if __name__ == "__main__": result = scan(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".")) print(json.dumps(result, ensure_ascii=False, indent=2)) # 有语法不兼容时以非零退出码结束,方便接进流水线 sys.exit(1 if result else 0)feature_version=(3, 8)是核心参数,把它改成(3, 10)再跑一遍,两次结果的差集就是“只能在 3.10 以上跑的文件”,这个差集往往就是升级路径上的第一批改造对象。跳过目录里一定要包含generated,否则自动生成的代码会被自己扫一遍,白白浪费一次报错。编码错误单独记一条而不是直接崩,是因为老项目里偶尔真有 GBK 编码的文件,直接抛异常会让整次扫描中断。
这个脚本的局限要提前说清楚:它查不出datetime.utcnow()、collections.Mapping这类标准库变更导致的运行期问题,也查不出第三方库的 API 变化。语法扫描只能当第一道筛子,别指望它包打天下。
3.3 适配层收口:把版本差异挡在入口,而不是散落在业务里
版本差异最忌讳的做法是到处写if sys.version_info >= (3, 12)。判断散落在二十个文件里,升级之后没人敢删。常见做法是把差异收敛到一个适配模块,业务代码只调适配模块,不直接调标准库里有版本差异的 API。
# compat/clock.py """时间相关适配,统一入口,业务代码不要直接调 datetime.utcnow。""" from datetime import datetime, timezone def now_utc() -> datetime: # 3.12 起 utcnow() 被标记弃用,统一改成带时区的 now(timezone.utc) return datetime.now(timezone.utc) def to_naive_utc(dt: datetime) -> datetime: """老代码存的是 naive UTC,新旧混用时统一在边界处转换。""" if dt.tzinfo is None: return dt return dt.astimezone(timezone.utc).replace(tzinfo=None)适配层之上再加一个迁移脚本,把存量代码里的datetime.utcnow()批量替换成now_utc()。替换不要用正则,正则分不清utcnow()是调用还是字符串里的文本。稳妥的做法是用 AST 定位调用点,按行列位置改源码文本,改完重新解析校验一遍。
# tools/migrate_utcnow.py import ast import pathlib def find_calls(path: pathlib.Path) -> list[tuple[int, int]]: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) hits = [] for node in ast.walk(tree): if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): if node.func.attr == "utcnow": hits.append((node.lineno, node.col_offset)) return hits def rewrite(path: pathlib.Path) -> bool: hits = find_calls(path) if not hits: return False lines = path.read_text(encoding="utf-8").splitlines(keepends=True) # 从后往前改,避免前面的替换影响后面记录的行列号 for lineno, col in sorted(hits, reverse=True): line = lines[lineno - 1] old = "datetime.utcnow()" if old in line: lines[lineno - 1] = line.replace(old, "now_utc()") text = "".join(lines) # 改完必须重新解析,确认没有改坏语法 ast.parse(text, filename=str(path)) path.write_text(text, encoding="utf-8") return True按行替换的关键是从后往前遍历,否则前一处替换改变了长度,后面记录的行列号就全错位了。改完重新ast.parse一次是硬性要求,这一步能挡住 90% 的脚本事故。替换完还得补 import:from compat.clock import now_utc,这一步我一般留给格式化工具和 lint 的自动修复,能省不少事。
4. 把生成器接进流水线:一条 make 命令、目录隔离与影子比对
生成器在本地跑通只是及格线,真正决定它能不能长期用的是流水线里的位置。这一章讲三件事:用一条命令把生成、格式化、检查串起来;用目录隔离把生成物和手写代码分开;用影子目录做生成结果的比对和回滚。
4.1 Makefile 四个目标:gen、fmt、check、diff
命令入口统一放在 Makefile 里,好处是新人不用读文档就知道该跑哪条命令,CI 里也可以直接复用同一份定义。
PY := python3 GEN_DIR := generated SHADOW_DIR := .shadow/generated .PHONY: gen fmt check diff promote clean # 生成到临时目录,成功后再替换,避免中途失败留下半成品 gen: rm -rf $(SHADOW_DIR) $(PY) tools/gen.py --out $(SHADOW_DIR) $(PY) tools/verify.py $(SHADOW_DIR) fmt: $(PY) -m black $(GEN_DIR) $(PY) -m ruff check --fix $(GEN_DIR) check: $(PY) -m ruff check $(GEN_DIR) $(PY) -m mypy $(GEN_DIR) # 只比较,不改动,适合在 CI 里做门禁 diff: diff -rq $(GEN_DIR) $(SHADOW_DIR) || true promote: rm -rf $(GEN_DIR) mv $(SHADOW_DIR) $(GEN_DIR)gen里先清空影子目录再生成,是为了避免上次的残留文件混进 diff。verify放在生成之后、替换之前,任何一道检查失败都不会污染正式目录。fmt和check分开,是因为格式化会改文件,而检查不应该改文件,混在一起会导致 CI 在“检查”环节意外产生改动。diff用|| true收尾,是因为diff在有差异时返回非零,直接接进 make 会让目标失败,但我们这里只是想看差异。
promote是所有目标里最需要谨慎的一个:它做的是目录级替换,失败会导致生成物目录直接消失。稳妥的做法是mv之前先cp -r到备份目录,出问题能立刻恢复。这条命令我一般只允许在 CI 里跑,本地手动执行容易出事故。
4.2 生成物目录隔离与手写代码保护块
目录隔离解决的是“谁覆盖谁”的问题。约定很硬:generated/下所有文件由生成器产出,任何人不得手改;手写代码放在src/,通过 import 引用生成物,而不是把逻辑塞进生成物里。
这条约定最难落地的地方在于“偶尔改一行”的冲动。我一般会在两个位置加保护:一是生成文件的头部注释,明确写“自动生成,请勿手改”;二是在代码托管平台的配置里把generated/标记为生成目录,diff 时默认折叠。第二点不是强制的,但能显著减少误改。
需要人写扩展点的地方,用保护块解决。生成器在渲染时识别一对标记,标记之间的内容原样保留:
# tools/protect.py import re import pathlib PATTERN = re.compile( r"(?P<start># <gen:protected:start name=\"(?P<name>[\w-]+)\">)" r"(?P<body>.*?)" r"(?P<end># </gen:protected:end>)", re.S, ) def merge(existing: str, fresh: str) -> str: """把旧文件里保护块的内容搬回新文件,其余部分用新生成的。""" old_blocks = {m.group("name"): m.group("body") for m in PATTERN.finditer(existing)} def repl(m: re.Match) -> str: name = m.group("name") body = old_blocks.get(name, m.group("body")) return f'{m.group("start")}{body}{m.group("end")}' return PATTERN.sub(repl, fresh)merge的调用时机在写文件之前:如果目标文件已存在,先把旧文件的保护块内容抽出来,再填回新渲染的文本。re.S让.匹配换行,否则多行保护块根本匹配不到。name用[\w-]+而不是\w+,是因为实际项目里保护块名经常带中划线,比如custom-hook。这个函数不处理嵌套保护块,嵌套场景我一般直接禁止,规则越简单越不容易出岔子。
4.3 影子比对与回滚:生成结果先进影子目录
影子比对解决的是“这次生成了什么、和上次差在哪”的问题。做法很简单:生成到.shadow/generated,和generated/做一次目录级 diff,差异落到一份报告里,人工确认之后再替换。
#!/usr/bin/env bash # tools/release.sh set -euo pipefail GEN_DIR="generated" SHADOW_DIR=".shadow/generated" BACKUP_DIR=".backup/generated-$(date +%Y%m%d%H%M%S)" rm -rf "$SHADOW_DIR" python3 tools/gen.py --out "$SHADOW_DIR" python3 tools/verify.py "$SHADOW_DIR" if ! diff -rq "$GEN_DIR" "$SHADOW_DIR" > /tmp/gen-diff.txt 2>&1; then echo "生成结果与现有目录存在差异:" cat /tmp/gen-diff.txt fi mkdir -p "$BACKUP_DIR" cp -r "$GEN_DIR" "$BACKUP_DIR" rm -rf "$GEN_DIR" mv "$SHADOW_DIR" "$GEN_DIR" echo "已替换,备份位于 $BACKUP_DIR"set -euo pipefail三个开关缺一不可:-e让任何一步失败就停,-u拦住未定义变量,-o pipefail让管道里的失败也能被捕获。备份目录带时间戳,是为了回滚时能精确选到某一个版本,而不是覆盖式备份导致只有一个“上一次”。diff 有差异时只打印不中断,是因为差异本身可能是预期内的,比如新增了一个实体;真正的中断交给verify。
回滚就是一行rm -rf generated && mv .backup/generated-xxxx generated。这套东西看着土,但比任何复杂的发布系统都可靠,因为它只有目录复制和移动两种操作,没有中间状态。
5. 避坑与排查:生成代码上线前最容易翻车的五个点
生成器这种东西,功能跑通只完成了一半,剩下的一半全在排错上。下面五条是我在实际项目里真踩过的,按“现象 → 原因 → 解决”写清楚,遇到时可以直接对号入座。
第一坑:生成物被人手改,下一次生成被覆盖,改的人还找不到是谁。
现象是某次全量生成之后,线上一个字段的默认值变了,查提交记录只看到生成器的一次批量提交。原因是有人在generated/目录里直接改了默认值,没改元数据。解决分两步:短期在生成文件头部加醒目注释,并在代码评审规则里把generated/的改动列为必须说明;长期把需要定制的部分挪进保护块,或者干脆在src/里写一个子类覆盖默认值。根因是元数据和生成物之间缺少“谁是事实来源”的共识,这个共识不建立,工具做得再好也没用。
第二坑:模板里写业务分支,生成的 SQL 出现拼接,扫出注入风险。
现象是安全扫描报了一条字符串拼接告警,定位到生成出来的查询方法。原因是模板里为了兼容“按不同字段排序”,直接拼了ORDER BY {{ field }},而field来自外部参数。解决是把排序字段做成白名单枚举,在元数据里声明哪些字段可排序,模板只渲染白名单里的值,外部传参一律走映射表。模板层的底线是只做结构不做取值,凡是把外部输入直接拼进文本的地方,都要在元数据层加一道枚举约束。
第三坑:语法检查全过,import 冒烟也过,一跑测试就报循环引用。
现象是生成物单独 import 没问题,和业务代码一起 import 就炸。原因是生成器把模型和 schema 放在同一个包里互相引用,单文件 import 时没触发循环,整体加载时才暴露。解决是固定分层:模型只依赖标准库和类型定义,schema 依赖模型,业务代码依赖 schema,依赖方向单向。生成器在渲染前先按元数据算出依赖顺序,输出时按拓扑序写文件,同时在流水线里加一条“全量 import”的冒烟步骤,专门抓这类问题。
第四坑:跨年份代码里用了已移除的标准库 API,语法扫描查不出来。
现象是升级运行环境之后,某个老模块在启动时报AttributeError,而语法扫描显示完全兼容。原因是collections.Mapping这类别名在新版本被移除,属于语义变更,feature_version管不到。解决是双管齐下:一是用依赖扫描工具(常见做法是vermin这类专门扫最低版本的工具)做一次全量体检,补上语法扫描的盲区;二是在适配层里把这类 API 统一包一层,业务代码不直接引用。升级前先跑扫描、再跑一遍全量测试,两步都过再动生产环境。
第五坑:生成顺序有隐式依赖,并行构建时随机失败。
现象是本地串行跑没问题,CI 并行跑十次挂三次,报错位置每次都不一样。原因是生成器遍历实体时依赖字典顺序,而某个实体的 schema 引用了另一个实体的类型,谁先渲染取决于输入文件的读取顺序。解决是显式声明依赖:在元数据里加一个depends_on字段,生成前先做拓扑排序,有环直接报错退出。另一个辅助手段是让每次生成的输出顺序稳定,比如按实体名排序后再遍历,这样即使依赖没声明全,至少失败是可复现的,不会变成玄学。
这五条的共同点是:问题都不在生成器本身的代码里,而在“生成器和周边系统的边界”上。排查时先问一句“这个错误是生成阶段产生的,还是使用阶段产生的”,能省掉一半时间。
6. 让生成器活过三个大版本:黄金文件、版本号与一次真实返工
生成器这类工具的寿命往往比预期短,不是因为技术过时,而是因为没人敢改它。改模板怕影响存量,改元数据怕漏字段,最后所有人绕开生成器手写代码,工具名存实亡。我后来固定用三个手段给它续命。
第一个手段是黄金文件测试。把一组有代表性的元数据输入和对应的期望输出存进仓库,每次改模板先跑这组测试,差异一目了然。
# tests/test_golden.py import pathlib import shutil from tools.gen import main as gen_main GOLDEN = pathlib.Path("tests/golden") def test_golden_output(tmp_path): # 用固定输入生成到临时目录,再和黄金文件逐字节比较 gen_main(tmp_path) for expected in GOLDEN.rglob("*"): if expected.is_dir(): continue actual = tmp_path / expected.relative_to(GOLDEN) assert actual.exists(), f"缺少生成文件: {actual}" assert actual.read_text(encoding="utf-8") == expected.read_text(encoding="utf-8")这个测试的价值不在断言本身,而在于“改模板必须同步更新黄金文件”这个动作。更新时会强制人看一眼 diff,很多低级错误在这一步就被拦住了。黄金文件的覆盖面不用大,五到八个实体足够,覆盖可空字段、长度约束、可过滤字段这几类组合即可。
第二个手段是给生成器本身打版本号,并把版本号写进每个生成文件的头部。文件头里带上生成器版本和元数据文件的哈希,出问题时一眼能看出这个文件是哪次生成的。
| 验证层 | 检查内容 | 失败后的动作 |
|---|---|---|
| 语法层 | ast.parse+feature_version | 阻断生成,不写文件 |
| 运行层 | 真实 import 冒烟 | 阻断替换,保留旧目录 |
| 快照层 | 黄金文件逐字节比对 | 提示差异,人工确认后更新 |
| 流水线层 | 影子目录 diff | 打印差异,不自动 promote |
四层各管一段,越靠前成本越低、拦截越早。很多人只做第一层,结果问题全压到线上。
第三个手段是一次真实的返工。有个项目生成器上线半年后,元数据里加了一个enum类型,模板里没同步,生成出来的字段类型是字符串,但下游服务按枚举解析,一次性挂了三个接口。事后复盘,根因不是模板漏改,而是类型映射表写在模板里、没有单测覆盖。改法很简单:把映射表提到元数据层,加一条“所有FieldType都必须有映射”的测试,用typing.get_args遍历枚举值逐个断言。这条测试只有六行,但它把“新增类型忘了改模板”这类错误彻底堵死了。
我现在的习惯是:每加一个元数据字段,先写它的校验和测试,再写模板。顺序反过来的话,模板会先跑通,测试永远补不上。这套东西不复杂,难的是克制——克制住“先跑起来再说”的冲动,克制住“这次特殊先手改”的侥幸。希望帮到你。
本文还有配套的精品资源,点击获取