项目做到一定规模,你会发现自己写了一大堆”约定”:别用 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 | |
有几个设计决策值得说:
只检查 Panel→Node 方向的缺失。server_to_node 和 bidirectional 的动作是 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 | |
有人改了 Panel 的注册表忘了同步 Node,CI 就红了。不用再靠人肉 grep 两个仓库来对齐了。
check_architecture:谁在偷偷用 getattr?
重构前的代码大量使用 getattr(self, action_name) 来分发 WebSocket 消息。重构后改成了显式的 MappingProxyType 分发表,但谁能保证以后没人偷偷摸摸又写回 getattr?
check_architecture.py 里有个 AST 扫描器干的就是这事:
1 | |
遍历项目里所有 .py 文件(排除 .venv、__pycache__ 等),解析成 AST,找所有 Call 节点,看函数名是不是 getattr、setattr、hasattr 或 __import__。
找到就报出来,不管合理不合理。为什么不做白名单?因为白名单本身就可能过时。与其维护一个”这里的 getattr 是允许的”列表,不如全部报出来让人工判断。CI 输出里有文件名和行号,扫一眼就知道哪些是新加的需要处理,哪些是历史遗留需要迁移。
dotted() 函数把嵌套属性调用还原成完整路径:
1 | |
这样 os.path.join 会被识别为 os.path.join,而不是只识别出 join。脚本只取最后一个部分(.rsplit(".", 1)[-1]),因为约束是针对内置函数名的,不需要完整路径。
check_security:安全矩阵骨架
目前 check_security.py 只是个骨架——输出一个空的 JSON:
1 | |
但从名字和项目规划文档就能看出意图:安全矩阵检查脚本要做的,是对每个安全相关的动作(签名、路径、命令、文件操作、Docker 操作)验证其测试覆盖率。
实现思路已经在 check_test_matrix.py 里有了雏形——扫描 tests/ 目录下的所有测试文件。安全矩阵脚本最终要做的是:从 ActionSpec 注册表里读出所有标记为 critical=True 的动作,然后检查对应的测试文件里有没有安全边界测试。
比如 execute:run_shell 标记为 critical,那 tests/security/test_command_policy.py 里必须有”合法命令通过”和”危险命令被拦”两组测试用例。缺了就报出来。
这是一个”让测试覆盖率和安全策略显式绑定”的思路——不是靠人记哪些操作需要安全测试,而是注册表和数据驱动。
check_test_matrix:测试文件清单
这个脚本更简单,就是列出所有测试文件:
1 | |
单独看没什么用,但配合其他脚本就变得有价值了。比如 check_security.py 要看”有没有 test_docker_security.py“,就得知道 tests/ 下到底有哪些文件。check_test_matrix.py 提供了这份清单。
export_action_registry:注册表导出
export_action_registry.py 把注册表导出成 JSON 文件:
1 | |
这东西看起来多此一举——注册表不是已经在代码里了吗?为什么要导出 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 | |
三个步骤:扫描动态用法、对比注册表、导出注册表。任何一个非零退出码都会让整个 job 变红。
从”写文档”到”写检查”的思维转变
写文档是”请各位注意”,写检查脚本是”不遵守就 CI 红”。两种方式的差异不是写不写的问题,是执行力的区别。
ServerManager 的这批脚本还不是完善的——check_security.py 还是个骨架,check_test_matrix.py 只是列文件。但框架已经搭好了:注册表是数据、AST 分析是工具、CI 是执行者。每加一个新约束,不用在文档里多写一行”请注意”,只要在对应的脚本里加一个检查条件,CI 就替你守着这条规矩。
项目是活的,规矩也应该是活的。用脚本守规矩,改脚本就能改规矩,比改文档靠谱——因为文档改了没人看,脚本改了 CI 下次跑就生效。