从 getattr 分发到 Action Registry:WebSocket 协议从草台班子到正规军

ServerManager 节点端的代码最早是用 getattr(self, action_name) 来分发 WebSocket 消息的——消息里带个 action 字段,拿到什么就叫什么方法。这写法简洁、直觉、上手飞快,项目初期 Rapid Prototyping 的时候没毛病。

但项目从”能跑”到”多人协作”再到”上线运维”,这种分发方式就开始处处漏风了。这篇文章聊聊从草台班子怎么一步步走到 Action Registry,以及中间踩过的那些坑。

草台班子时代:getattr 什么都答应

早期的分发代码长这样:

1
2
3
4
5
6
async def handle_action(self, action: str, data: dict):
handler = getattr(self, f"handle_{action}", None)
if handler:
await handler(data)
else:
logger.warning(f"Unknown action: {action}")

看着挺干净,但问题一个接一个冒出来:

安全问题getattr 会匹配到任何同名属性——你不小心写了个 handle_close_all_connections 的内部方法,外部发一条 action: "close_all_connections" 就能触发。属性名即接口,而且这个接口没有任何访问控制,任何人只要知道了方法名就能调用。

签名检查散落各处。每个关键操作方法内部都要自己检查签名:

1
2
3
4
5
6
7
async def handle_execute_command(self, data):
sign_data = data.get("_sign")
if not sign_data:
return await self.close_ws(3001)
if not verify_signature(self.node_token(), "execute:run_shell", data, sign_data):
return await self.close_ws(3001)
# ... 然后才是业务逻辑

每个 handler 开头三行几乎一模一样,复制粘贴了四十多次。哪天签名逻辑要改,就得 grep 全项目一个个修。

安全开关也是手工 ifexecute_command 要查 config['safe']['execute_command']connect_terminal 要查 config['safe']['connect_terminal']……安全开关和动作名称之间的映射只存在于开发者的脑子里,没有代码强制对应。

没人知道有哪些动作。新加一个动作,写个方法就行了。删一个动作?没人知道它在哪里被引用过。Panel 那边下发了节点已经不支持的动作?节点默默忽略,连个日志都不打。

第一步:显式注册表——从”有什么方法”变成”声明了什么接口”

重构的第一步是把隐式的 getattr 分发换成显式的注册表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class NodeActionDispatcher:
def __init__(self, client):
self.client = client
self.actions = MappingProxyType({
"node:close": self.close,
"node:init_config": self.init_node_config,
"terminal:create_session": self.terminal_create_session,
"terminal:close_session": self.terminal_close_session,
"terminal:input": self.terminal_input,
"terminal:resize": self.terminal_resize,
"execute:run_shell": self.execute_shell,
"file_manager:list": self.file_manager_list,
"file_manager:read": self.file_manager_read,
# ...
})

改动不大但意义不小——MappingProxyType 让这个分发表在运行时不可修改,而且方法到动作的映射变成了一目了然的显式声明。现在要加一个动作,必须显式写进表里,不可能通过发个消息就意外调到内部方法。

分发的逻辑也清晰多了:

1
2
3
4
5
6
async def dispatch(self, action: str, payload, raw_message: dict) -> bool:
handler = self.actions.get(action)
if handler is None:
logger.error(f"Undefined action: {action}")
return True
# ...

注意 return True——未知动作只打个 warning 然后继续运行,不会断连接。版本迭代的时候,新 Panel 下发了一个老 Node 不认识的动作,通常不是断连的理由。

第二步:ActionSpec——一个动作不只是个名字

注册表有了,但动作还只是字符串到函数的映射。签名要不要查?安全开关是哪个?这动作是从 Panel 发向 Node、还是 Node 报给 Panel、还是双向?这些信息全靠开发者记住——谁也说不全。

于是有了 ActionSpec

1
2
3
4
5
6
7
8
@dataclass(frozen=True)
class ActionSpec:
action: str
direction: ActionDirection # "panel_to_node" | "node_to_panel" | "bidirectional" | ...
critical: bool = False # 需要 HMAC 签名吗?
protocol_version: int = 1
visibility: Literal["public_protocol", "internal"] = "public_protocol"
safe_flag: str = "" # 对应 config.toml 里的安全开关名

frozen=True 是深思熟虑的——注册表建好就不该被运行时修改,这是安全策略的数据基础,不是配置项。

注册用的是批量注册的写法,看着直观:

1
2
3
4
5
6
_bulk(("execute:run_shell",), "panel_to_node", critical=True, safe_flag="execute_command")
_bulk(("terminal:create_session", "terminal:close_session", "terminal:input", "terminal:resize"),
"panel_to_node", critical=True, safe_flag="connect_terminal")
_bulk(("node:heartbeat", "node:upload_running_data"), "node_to_panel")
_bulk(("docker:image:pull", "docker:container:create", "docker:container:stop"),
"panel_to_node", critical=True, safe_flag="docker_manager")

一眼就能看出来:哪些动作需要签名、哪些方向是 Panel→Node、对应哪个安全开关。信息从散落在各处变成集中声明,可审计、可交叉校验。

第三步:签名策略从手工 if 变成注册表驱动

之前每个 handler 要自己查签名,现在 needs_signature() 直接查注册表:

1
2
3
4
5
def needs_signature(action: str) -> bool:
spec = _ACTIONS.get(action)
if spec:
return spec.critical
return action.startswith("plugin:")

两行查完,不需要在每个 handler 里写 if not sign_data: return。验签的代码集中在 dispatcher 的一个方法里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async def verify_if_required(self, action: str, payload, raw_message: dict,
label: str = "关键动作") -> bool:
if not needs_signature(action):
return True
sign_data = raw_message.get("_sign")
if not sign_data:
logger.warning(f"{label}缺少签名: {action}")
await self.client.close_current_ws(code=3001)
return False
if not verify_signature(self.client.node_token(), action, payload or {}, sign_data):
logger.warning(f"{label}签名验证失败: {action}")
await self.client.close_current_ws(code=3001)
return False
return True

一个方法管所有动作的验签,签名的逻辑不可能跟业务逻辑对不上——因为签名策略是注册表声明的,不是复制粘贴的。

安全开关的检查也类似——注册表里有 safe_flag,dispatcher 就能自动映射:

1
2
3
4
5
# 文件管理类操作
async def file_manager_list(self, data):
if not self.client.safe_enabled("file_manager", True):
return
await self.client.services.file_manager.list_dir(data or {})

safe_flag="file_manager" 对应 config.toml 里的 [safe] 段。只要注册表写对了,安全开关永远跟动作是对得上的。

第四步:协议校验脚本——让 Panel 和 Node 的动作表保持同步

ServerManager 是两个独立仓库——Panel 一个,Node 一个。ActionSpec 注册表在 Node 端定义,Panel 那边也有一份协议定义。两边怎么保证一致?

答案是脚本:

1
python scripts/check_action_registry.py --panel /Volumes/Projects/ServerManager

这个脚本会同时解析两边的注册表,然后对比:

  • Panel 定义了但 Node 没注册的动作(下发了无法处理)
  • Node 注册了但 Panel 没有的动作(死代码或者版本不同步)
  • 两边 critical 标记不一致的动作(安全策略有差异)
  • 两边 direction 不一致的动作(协议语义冲突)

脚本在 CI 里跑,任何一方改了注册表没同步另一方,CI 就会红。这不是什么高深的技术,但在多人协作、双仓库维护的场景下,自动化的协议一致校验防止了太多低级失误。

动作分发的三层结构

重构后分发的完整流程长这样:

1
2
3
4
5
6
7
8
9
10
11
收到 WS 消息 → 解析 action 字符串

查 ActionRegistry:这个动作存在吗?→ 不存在,打日志,继续

查 ActionSpec:需要签名吗?→ 需要,验签失败,断连(code=3001)

查 ActionSpec:需要安全开关吗?→ 需要,开关关着,忽略

查 MappingProxyType 分发表:调用对应 handler

handler 内部解析参数,执行业务逻辑

四个决议点,每个都基于注册表里的声明数据,没有一处需要开发者”记住”什么。想加一个新动作?往注册表加一行 ActionSpec,往分发表加一个方法引用,签个名,配好安全开关——CI 脚本会告诉你有没有遗漏。

为什么不用 getattr 之外的其他方案?

有人可能会问:getattr 不好,那用字符串到函数的字典不就行了?为什么还要搞 ActionSpec?

字典是最小可行的改进,它解决了”误触发内部方法”的问题,但解决不了后面三个:签名策略怎么查?安全开关怎么映射?方向怎么校验?这些信息如果只存在于代码的各个角落,那和维护 getattr 版本没太大区别——只是从”散落在方法里”变成了”散落在常量里”。

ActionSpec 的核心价值在于协议即数据。一个 frozen=True 的 dataclass 既是注册表,又是文档,又是校验依据。代码从注册表读取策略,脚本从注册表校验一致性,安全开关从注册表映射动作——都指向同一个数据源。这才是正儿八经的 single source of truth。

从草台到正规,到底值不值?

坦白说,项目初期用 getattr 完全没问题。十几个动作的时候,谁管什么注册表——写个方法就完事了。

但动作数上了四五十,涉及签名、安全开关、双向通信、插件扩展,再加上 Panel 和 Node 两个仓库同步维护,getattr 那套就开始还债了——安全检查遗漏、签名策略不一致、新旧版本动作对不上……这些问题每一个都够你查半天。

重构花了一周,但省下来的是以后每次动动作都有注册表替你守门,CI 脚本替你校验,代码阅读者一眼就能看明白”这个动作需要签名、方向是 Panel→Node、对应 docker_image_manage 开关”。

从”写代码只要能跑”到”写代码要让别人能安全地改动”,中间差的就是这种数据驱动的声明式设计。

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