架构约束不该只在文档里——让它变成跑不过的 CI 脚本

项目做到一定规模,你会发现自己写了一大堆”约定”:别用 getattr 分发消息、签名策略必须从注册表读、新动作必须加 ActionSpec、路径拼接必须走 safe_join……这些约定写在文档里,新人来了未必看,自己过两个月也未必记得。

ServerManager 的 Node 端踩过这个坑。重构的时候在项目规范文档里写了一堆规则——“不许用 getattr 分发动作”“签名策略必须读注册表”“所有路径必须走 safe_join”。写了没两天,PR 里照样出现 getattr(self, action_name)。不是开发者不守规矩,是规矩只在文档里,代码不会自己拒绝你。

后来把关键约束写成了脚本,CI 一跑就知道有没有人犯规。这篇聊聊这些脚本都检查什么、怎么检查,以及为什么这么做而不是那么做。

check_action_registry:Panel 和 Node 的动作表对不齐?

ServerManager 是双仓库架构——Panel(Django 后端)和 Node(Python 客户端)各自维护一份 WebSocket 动作注册表。Panel 定义了 77+ 个动作,Node 也注册了对应的一批。两边必须完全同步,否则 Panel 下发了一个 Node 不认识的动作(或者更糟的,Node 忽略了一个应该签名验证的动作),线上就会出问题。

check_action_registry.py 干的事很简单——从两边各 import 注册表,然后找差异:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--panel", required=False)
parser.add_argument("--report-only", action="store_true")
args = parser.parse_args()
node_actions = get_all_actions()
missing = []
if args.panel:
panel_root = Path(args.panel)
sys.path.insert(0, str(panel_root))
from apps.node_manager.protocol import get_all_actions as panel_get_all
panel_actions = panel_get_all()
for action, spec in panel_actions.items():
direction = spec.direction.value if isinstance(spec.direction, Enum) else str(spec.direction)
if direction in {"server_to_node", "bidirectional"} and action not in node_actions:
missing.append(action)
print(json.dumps({
"node_actions": len(node_actions),
"missing_panel_to_node_actions": missing,
}, ensure_ascii=False, indent=2))
return 0 if args.report_only or not missing else 1

有几个设计决策值得说:

只检查 Panel→Node 方向的缺失server_to_nodebidirectional 的动作是 Panel 一定会下发给 Node 的。如果 Node 没注册这种动作,那消息来了就没人接。但 Node→Panel 方向的动作是 Node 主动发的,Panel 那边多几个不影响——Node 不会因为 Panel 不认识自己的上报消息而崩溃。

退出码不是 0 就是 1。CI 脚本最实用的信号就是退出码——0 表示通过,1 表示有缺失。--report-only 选项让脚本变成只输出 JSON 不退出 1,用于仪表盘展示。

import 真实注册表而不是读 JSON。脚本直接从 Python 模块 import get_all_actions(),拿到的是运行时的真实数据。如果改成读静态 JSON 文件,那 JSON 可能是过期的。

在 CI 里加一行:

1
- python scripts/check_action_registry.py --panel /path/to/ServerManager

有人改了 Panel 的注册表忘了同步 Node,CI 就红了。不用再靠人肉 grep 两个仓库来对齐了。

check_architecture:谁在偷偷用 getattr?

重构前的代码大量使用 getattr(self, action_name) 来分发 WebSocket 消息。重构后改成了显式的 MappingProxyType 分发表,但谁能保证以后没人偷偷摸摸又写回 getattr

check_architecture.py 里有个 AST 扫描器干的就是这事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def dynamic_usage():
findings = []
for path in sorted(ROOT.glob("**/*.py")):
if any(part in {".venv", "__pycache__", ".git"} for part in path.parts):
continue
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except (SyntaxError, UnicodeDecodeError):
continue
rel = path.relative_to(ROOT).as_posix()
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = dotted(node.func).rsplit(".", 1)[-1]
if name in {"getattr", "setattr", "hasattr", "__import__"}:
findings.append({"path": rel, "line": node.lineno, "symbol": name})
return findings

遍历项目里所有 .py 文件(排除 .venv__pycache__ 等),解析成 AST,找所有 Call 节点,看函数名是不是 getattrsetattrhasattr__import__

找到就报出来,不管合理不合理。为什么不做白名单?因为白名单本身就可能过时。与其维护一个”这里的 getattr 是允许的”列表,不如全部报出来让人工判断。CI 输出里有文件名和行号,扫一眼就知道哪些是新加的需要处理,哪些是历史遗留需要迁移。

dotted() 函数把嵌套属性调用还原成完整路径:

1
2
3
4
5
6
7
def dotted(node):
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
parent = dotted(node.value)
return f"{parent}.{node.attr}" if parent else node.attr
return ""

这样 os.path.join 会被识别为 os.path.join,而不是只识别出 join。脚本只取最后一个部分(.rsplit(".", 1)[-1]),因为约束是针对内置函数名的,不需要完整路径。

check_security:安全矩阵骨架

目前 check_security.py 只是个骨架——输出一个空的 JSON:

1
print(json.dumps({"status": "security matrix scaffold", "cases": []}, ensure_ascii=False, indent=2))

但从名字和项目规划文档就能看出意图:安全矩阵检查脚本要做的,是对每个安全相关的动作(签名、路径、命令、文件操作、Docker 操作)验证其测试覆盖率。

实现思路已经在 check_test_matrix.py 里有了雏形——扫描 tests/ 目录下的所有测试文件。安全矩阵脚本最终要做的是:从 ActionSpec 注册表里读出所有标记为 critical=True 的动作,然后检查对应的测试文件里有没有安全边界测试。

比如 execute:run_shell 标记为 critical,那 tests/security/test_command_policy.py 里必须有”合法命令通过”和”危险命令被拦”两组测试用例。缺了就报出来。

这是一个”让测试覆盖率和安全策略显式绑定”的思路——不是靠人记哪些操作需要安全测试,而是注册表和数据驱动。

check_test_matrix:测试文件清单

这个脚本更简单,就是列出所有测试文件:

1
2
3
root = Path(__file__).resolve().parents[1]
tests = sorted(path.relative_to(root).as_posix() for path in root.glob("tests/**/*.py"))
print(json.dumps({"tests": tests}, ensure_ascii=False, indent=2))

单独看没什么用,但配合其他脚本就变得有价值了。比如 check_security.py 要看”有没有 test_docker_security.py“,就得知道 tests/ 下到底有哪些文件。check_test_matrix.py 提供了这份清单。

export_action_registry:注册表导出

export_action_registry.py 把注册表导出成 JSON 文件:

1
2
actions = [asdict(spec) for spec in get_all_actions().values()]
Path(args.output).write_text(json.dumps(actions, ensure_ascii=False, indent=2), encoding="utf-8")

这东西看起来多此一举——注册表不是已经在代码里了吗?为什么要导出 JSON?

用途有两:

Panel 端的协议文档。导出的 JSON 可以丢进 Panel 的文档或 API 描述里,让前端开发者知道有哪些动作可以用,不需要翻 Node 端的 Python 代码。

CI 脚本的数据源check_action_registry.py 是 import Python 模块来比较的。但如果 Panel 是另一个语言写的(比如 Node.js),import 不进来怎么办?导出 JSON 文件作为接口协议的中间格式,两边各 import 自己的注册表、和 JSON 对比,就不需要语言层面的互操作了。

为什么不用 linter 和 type checker?

有人会问:这些约束为什么不放到 mypy 或 ruff 的规则里?

getattr 检查——ruff 确实可以配置规则禁止某些函数调用,但 getattr 在很多场景下是合理的(比如动态配置读取)。你要的是”在 WebSocket 分发代码里不用 getattr”,不是”全局禁止 getattr”。AST 扫描可以根据上下文(文件路径、类名)做更精细的判断,linter 的规则做不了这么细。

注册表同步——这是跨仓库的检查,需要同时 import 两个项目的代码。mypy 和 ruff 都是在单仓库范围内跑的,没有”对比两个仓库的数据结构”这个能力。

安全矩阵——需要结合运行时数据(ActionSpec 注册表)和文件系统状态(测试文件),这超出了 linter 的能力范围。

linter 和 type checker 解决的是语言层面的问题——类型错误、未定义变量、代码风格。架构约束解决的是设计层面的问题——“新动作必须注册到分发表”“签名策略必须从注册表读取”“路径必须走 safe_join”。这些问题不是代码写错了,是代码违反了设计意图。

怎么把这些脚本串进 CI

GitHub Actions 里加一个 job:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
check-architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install -r requirements.txt
- name: Check dynamic usage
run: python scripts/check_architecture.py dynamic-usage --report-only
- name: Check action registry sync
run: |
git clone --depth 1 https://github.com/org/ServerManager.git /tmp/panel
python scripts/check_action_registry.py --panel /tmp/panel
- name: Export action registry
run: python scripts/export_action_registry.py --output reports/action_registry.json

三个步骤:扫描动态用法、对比注册表、导出注册表。任何一个非零退出码都会让整个 job 变红。

从”写文档”到”写检查”的思维转变

写文档是”请各位注意”,写检查脚本是”不遵守就 CI 红”。两种方式的差异不是写不写的问题,是执行力的区别。

ServerManager 的这批脚本还不是完善的——check_security.py 还是个骨架,check_test_matrix.py 只是列文件。但框架已经搭好了:注册表是数据、AST 分析是工具、CI 是执行者。每加一个新约束,不用在文档里多写一行”请注意”,只要在对应的脚本里加一个检查条件,CI 就替你守着这条规矩。

项目是活的,规矩也应该是活的。用脚本守规矩,改脚本就能改规矩,比改文档靠谱——因为文档改了没人看,脚本改了 CI 下次跑就生效。

欢迎关注我的其它发布渠道