ServerManager 节点端的代码最早是用 getattr(self, action_name) 来分发 WebSocket 消息的——消息里带个 action 字段,拿到什么就叫什么方法。这写法简洁、直觉、上手飞快,项目初期 Rapid Prototyping 的时候没毛病。
但项目从”能跑”到”多人协作”再到”上线运维”,这种分发方式就开始处处漏风了。这篇文章聊聊从草台班子怎么一步步走到 Action Registry,以及中间踩过的那些坑。
草台班子时代:getattr 什么都答应
早期的分发代码长这样:
1 | |
看着挺干净,但问题一个接一个冒出来:
安全问题。getattr 会匹配到任何同名属性——你不小心写了个 handle_close_all_connections 的内部方法,外部发一条 action: "close_all_connections" 就能触发。属性名即接口,而且这个接口没有任何访问控制,任何人只要知道了方法名就能调用。
签名检查散落各处。每个关键操作方法内部都要自己检查签名:
1 | |
每个 handler 开头三行几乎一模一样,复制粘贴了四十多次。哪天签名逻辑要改,就得 grep 全项目一个个修。
安全开关也是手工 if。execute_command 要查 config['safe']['execute_command'],connect_terminal 要查 config['safe']['connect_terminal']……安全开关和动作名称之间的映射只存在于开发者的脑子里,没有代码强制对应。
没人知道有哪些动作。新加一个动作,写个方法就行了。删一个动作?没人知道它在哪里被引用过。Panel 那边下发了节点已经不支持的动作?节点默默忽略,连个日志都不打。
第一步:显式注册表——从”有什么方法”变成”声明了什么接口”
重构的第一步是把隐式的 getattr 分发换成显式的注册表:
1 | |
改动不大但意义不小——MappingProxyType 让这个分发表在运行时不可修改,而且方法到动作的映射变成了一目了然的显式声明。现在要加一个动作,必须显式写进表里,不可能通过发个消息就意外调到内部方法。
分发的逻辑也清晰多了:
1 | |
注意 return True——未知动作只打个 warning 然后继续运行,不会断连接。版本迭代的时候,新 Panel 下发了一个老 Node 不认识的动作,通常不是断连的理由。
第二步:ActionSpec——一个动作不只是个名字
注册表有了,但动作还只是字符串到函数的映射。签名要不要查?安全开关是哪个?这动作是从 Panel 发向 Node、还是 Node 报给 Panel、还是双向?这些信息全靠开发者记住——谁也说不全。
于是有了 ActionSpec:
1 | |
frozen=True 是深思熟虑的——注册表建好就不该被运行时修改,这是安全策略的数据基础,不是配置项。
注册用的是批量注册的写法,看着直观:
1 | |
一眼就能看出来:哪些动作需要签名、哪些方向是 Panel→Node、对应哪个安全开关。信息从散落在各处变成集中声明,可审计、可交叉校验。
第三步:签名策略从手工 if 变成注册表驱动
之前每个 handler 要自己查签名,现在 needs_signature() 直接查注册表:
1 | |
两行查完,不需要在每个 handler 里写 if not sign_data: return。验签的代码集中在 dispatcher 的一个方法里:
1 | |
一个方法管所有动作的验签,签名的逻辑不可能跟业务逻辑对不上——因为签名策略是注册表声明的,不是复制粘贴的。
安全开关的检查也类似——注册表里有 safe_flag,dispatcher 就能自动映射:
1 | |
safe_flag="file_manager" 对应 config.toml 里的 [safe] 段。只要注册表写对了,安全开关永远跟动作是对得上的。
第四步:协议校验脚本——让 Panel 和 Node 的动作表保持同步
ServerManager 是两个独立仓库——Panel 一个,Node 一个。ActionSpec 注册表在 Node 端定义,Panel 那边也有一份协议定义。两边怎么保证一致?
答案是脚本:
1 | |
这个脚本会同时解析两边的注册表,然后对比:
- Panel 定义了但 Node 没注册的动作(下发了无法处理)
- Node 注册了但 Panel 没有的动作(死代码或者版本不同步)
- 两边 critical 标记不一致的动作(安全策略有差异)
- 两边 direction 不一致的动作(协议语义冲突)
脚本在 CI 里跑,任何一方改了注册表没同步另一方,CI 就会红。这不是什么高深的技术,但在多人协作、双仓库维护的场景下,自动化的协议一致校验防止了太多低级失误。
动作分发的三层结构
重构后分发的完整流程长这样:
1 | |
四个决议点,每个都基于注册表里的声明数据,没有一处需要开发者”记住”什么。想加一个新动作?往注册表加一行 ActionSpec,往分发表加一个方法引用,签个名,配好安全开关——CI 脚本会告诉你有没有遗漏。
为什么不用 getattr 之外的其他方案?
有人可能会问:getattr 不好,那用字符串到函数的字典不就行了?为什么还要搞 ActionSpec?
字典是最小可行的改进,它解决了”误触发内部方法”的问题,但解决不了后面三个:签名策略怎么查?安全开关怎么映射?方向怎么校验?这些信息如果只存在于代码的各个角落,那和维护 getattr 版本没太大区别——只是从”散落在方法里”变成了”散落在常量里”。
ActionSpec 的核心价值在于协议即数据。一个 frozen=True 的 dataclass 既是注册表,又是文档,又是校验依据。代码从注册表读取策略,脚本从注册表校验一致性,安全开关从注册表映射动作——都指向同一个数据源。这才是正儿八经的 single source of truth。
从草台到正规,到底值不值?
坦白说,项目初期用 getattr 完全没问题。十几个动作的时候,谁管什么注册表——写个方法就完事了。
但动作数上了四五十,涉及签名、安全开关、双向通信、插件扩展,再加上 Panel 和 Node 两个仓库同步维护,getattr 那套就开始还债了——安全检查遗漏、签名策略不一致、新旧版本动作对不上……这些问题每一个都够你查半天。
重构花了一周,但省下来的是以后每次动动作都有注册表替你守门,CI 脚本替你校验,代码阅读者一眼就能看明白”这个动作需要签名、方向是 Panel→Node、对应 docker_image_manage 开关”。
从”写代码只要能跑”到”写代码要让别人能安全地改动”,中间差的就是这种数据驱动的声明式设计。