4.3 耦合与解耦:什么时候不该拆
4.1 的依赖图只记录了一件事:谁必须先有谁。但系统之间还有一层更隐蔽的关系——A 虽然业务上不依赖 B,却要读写 B 维护的数据,于是 B 一改,A 就得跟着改。这一节把这种「非前置的耦合」也画进图里,量化出每个系统的变更影响半径,然后回答一个常被忽略的问题:解耦不是越多越好,什么时候该把耦合留着不拆。
仍然先划边界。站内 SLG 技术架构系列 讨论的是代码模块之间怎么降低耦合(接口隔离、依赖倒置);本节讨论的是产品侧的系统耦合——哪些系统在数据与状态上缠在一起,以及这种纠缠对版本排期和改动风险意味着什么。前者关心「代码好不好改」,后者关心「改一个系统会不会逼着改一串系统」。
4.3.1 前置依赖之外,还有一层耦合
把耦合分三类,用整数权重表达相对强度。结构耦合(struct)就是 4.1 的 deps,权重最高,因为它是硬约束——没有前置,系统根本跑不起来。数据耦合(data)是「A 读写 B 维护的数据」,比如战斗结算要读科技研究出来的属性加成。时序耦合(timing)最弱但也最烦人:两个系统没有数据依赖,但必须在同一帧或同一回合内一致更新,否则状态会错位。
只靠 deps 判断耦合是不够的。看一个例子:battle 在依赖图里只依赖 troop 和 hero,但战斗结算要读 research 的科技加成、要读 production 的补给上限——这两条都是数据耦合,不写下来就会在改动时踩雷。本节的耦合图 = 结构边(来自 ch04_systems.json)+ 数据/时序边(单独配置),两者合并后算指标。
对每个系统算四个数:扇出(fan-out,它依赖多少个别的系统)、扇入(fan-in,多少个别的系统依赖它)、耦合分(按权重加权求和)、影响半径(反向后可达的节点数,即「改它要连带改多少个下游」)。影响半径是最该盯的一个——它直接对应「这个改动能不能一个人做完」。
4.3.2 非结构耦合边的配置
结构边不重复配置,直接从 ch04_systems.json 读;下面这份 D 层配置只补充数据耦合与时序耦合,并给出权重与基线阈值。baseline 里的三个数字是 B 基准用的「健康区间上限」,后文会用到。
{
"version": 1,
"note": "非结构耦合边:from 系统读写 to 系统的数据/状态。struct 边来自 ch04_systems.json 的 deps,此处只列 data/timing 两类,避免重复。",
"weights": { "struct": 3, "data": 2, "timing": 1 },
"baseline": { "maxFanOut": 3, "maxImpactRadius": 9, "maxScore": 8 },
"edges": [
{ "from": "production", "to": "building", "kind": "data" },
{ "from": "research", "to": "building", "kind": "data" },
{ "from": "troop", "to": "research", "kind": "data" },
{ "from": "battle", "to": "research", "kind": "data" },
{ "from": "battle", "to": "production", "kind": "data" },
{ "from": "occupy", "to": "production", "kind": "data" },
{ "from": "occupy", "to": "battle", "kind": "timing" },
{ "from": "rank", "to": "hero", "kind": "data" },
{ "from": "alliance", "to": "city", "kind": "data" },
{ "from": "alliance-war", "to": "battle", "kind": "timing" },
{ "from": "alliance-war", "to": "rank", "kind": "data" },
{ "from": "season", "to": "production", "kind": "timing" },
{ "from": "season", "to": "research", "kind": "timing" },
{ "from": "shop", "to": "hero", "kind": "data" },
{ "from": "quest", "to": "building", "kind": "data" },
{ "from": "quest", "to": "battle", "kind": "timing" }
]
}
十六条例子里,season 通过时序耦合同时挂在 production 与 research 上——赛季重置时要清空资源与科技的进度,这两条不是结构依赖,但漏了就会导致赛季切换后数值错乱。这类「跨系统的一致性要求」正是时序耦合的典型来源。
4.3.3 耦合度量脚本
下面这段是 S 层脚本,把结构边与非结构边合并成一张「谁依赖谁」的图,算出每个系统的扇入、扇出、耦合分与影响半径,产出 JSON、CSV 与影响半径条形图 SVG。
# file: sim/ch04_coupling.py
# tier: S
# verify: python3 sim/ch04_coupling.py --config config/
"""耦合度量:把结构依赖(systems.json 的 deps)与非结构耦合(coupling.json 的 data/timing 边)
合并成一张「谁依赖谁」的有向图,算每个系统的扇入、扇出、耦合分与变更影响半径。
语义统一为「from 依赖 to」(from 读/写 to 的数据)。影响半径 = 反向后从本节点可达的节点数,
即「改动本系统会波及多少个下游系统」。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "out")
NS = "http://www.w3.org/2000/svg"
def load(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def metrics(systems: list[dict], coupling: dict) -> dict:
ids = [s["id"] for s in systems]
names = {s["id"]: s["name"] for s in systems}
weights = coupling["weights"]
edges = [{"from": s["id"], "to": p, "kind": "struct"} for s in systems for p in s["deps"]]
edges += [{"from": e["from"], "to": e["to"], "kind": e["kind"]} for e in coupling["edges"]]
fan_out = {i: set() for i in ids}
fan_in = {i: set() for i in ids}
score = {i: 0 for i in ids}
rev = {i: set() for i in ids}
for e in edges:
fan_out[e["from"]].add(e["to"])
fan_in[e["to"]].add(e["from"])
score[e["from"]] += weights[e["kind"]]
rev[e["to"]].add(e["from"])
def impact(sid: str) -> int:
seen: set[str] = set()
stack = list(rev[sid])
while stack:
x = stack.pop()
if x not in seen:
seen.add(x)
stack += rev[x]
return len(seen)
rows = sorted(({"id": i, "name": names[i], "fanOut": len(fan_out[i]), "fanIn": len(fan_in[i]),
"score": score[i], "impactRadius": impact(i)} for i in ids),
key=lambda r: (-r["impactRadius"], -r["score"], r["id"]))
return {"systems": rows, "totalEdges": len(edges),
"byKind": {k: sum(1 for e in edges if e["kind"] == k) for k in weights},
"maxImpact": max(r["impactRadius"] for r in rows), "maxFanOut": max(r["fanOut"] for r in rows)}
def write_svg(path: str, res: dict) -> None:
rows, W, H, PL, PR, PT = res["systems"], 640, 420, 120, 40, 30
bh, gap = 16, 5
hi = max(r["impactRadius"] for r in rows) or 1
iw = W - PL - PR
svg = ET.Element("svg", {"xmlns": NS, "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#fff"})
ET.SubElement(svg, "text", {"x": "16", "y": "20", "font-size": "13"}).text = \
f"《王朝纪元》变更影响半径(改动该系统的下游波及面,共 {res['totalEdges']} 条耦合边)"
for i, r in enumerate(rows):
y, w = PT + i * (bh + gap), iw * r["impactRadius"] / hi
ET.SubElement(svg, "rect", {"x": str(PL), "y": str(y), "width": f"{w:.1f}", "height": str(bh), "fill": "#1f6feb", "opacity": "0.85"})
ET.SubElement(svg, "text", {"x": str(PL - 8), "y": f"{y + bh - 3}", "font-size": "11", "text-anchor": "end"}).text = r["name"]
ET.SubElement(svg, "text", {"x": f"{PL + w + 6:.1f}", "y": f"{y + bh - 3}", "font-size": "10"}).text = str(r["impactRadius"])
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
args = ap.parse_args()
cfg_dir = args.config if os.path.isabs(args.config) else os.path.join(HERE, "..", args.config)
res = metrics(load(os.path.join(cfg_dir, "ch04_systems.json"))["systems"],
load(os.path.join(cfg_dir, "ch04_coupling.json")))
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ch04_coupling.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
with open(os.path.join(OUT, "ch04_coupling.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["rank", "id", "name", "fanIn", "fanOut", "score", "impactRadius"])
for k, r in enumerate(res["systems"], 1):
w.writerow([k, r["id"], r["name"], r["fanIn"], r["fanOut"], r["score"], r["impactRadius"]])
write_svg(os.path.join(OUT, "ch04_coupling.svg"), res)
print(json.dumps({"totalEdges": res["totalEdges"], "byKind": res["byKind"], "maxImpact": res["maxImpact"],
"maxFanOut": res["maxFanOut"],
"topImpact": [{"id": r["id"], "impactRadius": r["impactRadius"], "score": r["score"]} for r in res["systems"][:5]]},
ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
4.3.4 实跑结果
本机运行 python3 sim/ch04_coupling.py --config config/,输出:
{"totalEdges": 38, "byKind": {"struct": 22, "data": 11, "timing": 5}, "maxImpact": 17, "maxFanOut": 4, "topImpact": [{"id": "account", "impactRadius": 17, "score": 0}, {"id": "city", "impactRadius": 15, "score": 3}, {"id": "building", "impactRadius": 13, "score": 6}, {"id": "production", "impactRadius": 13, "score": 5}, {"id": "research", "impactRadius": 10, "score": 5}]}
38 条耦合边里,22 条结构、11 条数据、5 条时序。影响半径最高的是 account(17)——几乎每个系统都直接或间接挂在账号下,这是必然的,它也是唯一一个「改它等于动全站」的节点。真正值得关注的是 battle:它扇出 4(依赖 troop、hero、research、production),耦合分 10 是全表最高。完整排序里前几位:
| rank | id | name | fanIn | fanOut | score | impactRadius |
|---|---|---|---|---|---|---|
| 1 | account | 账号 | 3 | 0 | 0 | 17 |
| 2 | city | 主城 | 5 | 1 | 3 | 15 |
| 3 | building | 建筑升级 | 4 | 2 | 6 | 13 |
| 4 | production | 资源产出 | 4 | 2 | 5 | 13 |
| 7 | troop | 兵种训练 | 1 | 2 | 8 | 9 |
| 8 | battle | 战斗结算 | 5 | 4 | 10 | 8 |
| 14 | alliance-war | 联盟战 | 1 | 4 | 9 | 1 |
注意一个反直觉的现象:battle 的扇出和耦合分都很高,影响半径却只有 8;而 city 扇出只有 1,影响半径却有 15。原因是影响半径衡量的是「下游」——city 是所有经济系统的上游,动它会带倒一大片;battle 是下游汇聚点,依赖很多但被依赖少,改它反而影响面有限。这说明「耦合分高」和「改动风险大」不是一回事:前者看它依赖谁,后者看谁依赖它。排期时真正该谨慎的是影响半径大的节点。
4.3.5 B 基准:软告警而非硬门禁
度量本身不告诉你「多少算多」。下面这段是 B 层基准脚本,把度量结果对照 baseline 里的三个上限,输出超出健康区间的系统清单。它是软告警:无论报多少条,进程一律返回 0——阈值是可调的团队约定,不是必须通过的门禁。
# file: sim/ch04_coupling_baseline.py
# tier: B
# verify: python3 sim/ch04_coupling.py --config config/ && python3 sim/ch04_coupling_baseline.py
"""B 基准:把耦合度量结果对照基线阈值,输出「哪些系统超出健康区间」的软告警 JSON。
只读 sim/ch04_coupling.py 产出的 out/ch04_coupling.json,不重算公式。
软告警:无论告警多少,进程一律返回 0;阈值是可调的团队约定,不是硬门禁。
"""
from __future__ import annotations
import json
import os
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "out")
def main() -> int:
with open(os.path.join(OUT, "ch04_coupling.json"), encoding="utf-8") as f:
res = json.load(f)
with open(os.path.join(HERE, "..", "config", "ch04_coupling.json"), encoding="utf-8") as f:
base = json.load(f)["baseline"]
warns = []
for r in res["systems"]:
hits = []
if r["fanOut"] > base["maxFanOut"]:
hits.append(f"fanOut {r['fanOut']}>{base['maxFanOut']}")
if r["impactRadius"] > base["maxImpactRadius"]:
hits.append(f"impactRadius {r['impactRadius']}>{base['maxImpactRadius']}")
if r["score"] > base["maxScore"]:
hits.append(f"score {r['score']}>{base['maxScore']}")
if hits:
warns.append({"id": r["id"], "name": r["name"], "reasons": hits})
report = {
"baseline": base,
"systemsChecked": len(res["systems"]),
"warnCount": len(warns),
"warnings": warns,
"verdict": "soft-warn" if warns else "healthy",
}
with open(os.path.join(OUT, "ch04_coupling_baseline.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0 # 软告警:不硬失败
if __name__ == "__main__":
raise SystemExit(main())
本机运行后,warnCount 为 9,verdict 是 soft-warn。九条告警里,五条是影响半径超 9(account、city、building、production、research、hero 中的前五),两条是 battle 与 alliance-war 同时触发了扇出与耦合分上限。这些告警不是说「这些系统有问题」,而是说「改动它们之前,先确认影响面」。软告警的用法是:迭代开始前跑一次,把它当作一次风险提示,而不是当作「必须先降到阈值以下才能开工」。
4.3.6 什么时候不该拆
拿到影响半径排序后,最自然的冲动是「把影响半径大的都拆开」。但这往往是错的,理由有三。
第一,拆会引入新的耦合。把 production 拆成「伐木」「采石」「冶铁」三个独立系统,它们之间不会再有结构耦合,但「资源上限」「采集速度加成」这些共享状态会立刻变成三条数据耦合——总量守恒的要求不会因为拆分而消失,只会从内部一致性变成跨系统一致性,后者更难保证。第二,拆的收益取决于变更频率。如果 account 一年只改两次,它 17 的影响半径其实无所谓;真正值得拆的是那些「每两周就要改、每次都要连带改五个下游」的系统。第三,过早拆分会锁死玩法。开荒期的系统边界本来就该保持模糊,等玩法定型、变更集中在某几个点之后,再按真实的变更热点去拆。
所以判断标准不是「耦合高不高」,而是「这个耦合是不是在拖慢迭代」。一个可操作的做法是:把影响半径和近三个版本的改动次数放一起看,只对「高影响半径 × 高频改动」的系统动手;其余的高耦合系统,留着不拆反而更省事。本节的所有数字都来自本机实跑,是同一份配置下的相对量级,绝对数值不可移植到别的项目。
4.3.7 从系统地图到功能架构
到这里,第四章完成了:从系统清单(4.1)到依赖图校验、到解锁节奏(4.2)、再到耦合度量与解耦判断(4.3),我们手里有了一套可校验、可仿真的系统地图。但系统地图还是「业务视角」——它说的是哪些系统存在、谁依赖谁,还没回答「代码上这些系统怎么组织、界面上的入口归谁管」。下一章就把系统地图落到功能树与模块边界上,讨论模块职责怎么划分、状态归谁所有、跨模块的契约怎么定。
阅读导航:上一节:4.2 解锁节奏与前置链 · 下一节:5.1 功能树与模块职责 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。