架构决策记录
本文档冻结 Shield 当前阶段的架构决策,作为架构设计与实现推进的对齐基准。决策一旦写入本文,后续实现必须围绕该契约收敛。
若本文与 架构总纲、核心设计理念 或 Skynet 对比 冲突,以本文为准。
背景:设计目标
Shield 的设计约定是:
参考 skynet 的 service/call/send/coroutine 语义,用 CAF 承接 actor runtime,Service 是唯一的可寻址 RPC 单元。
见 核心设计理念 §3、Skynet 对比 §CAF 与 Skynet 语义的关系。
本文冻结的决策,意在把 CAF actor runtime、客户端交互契约和玩家承载模型收口,避免后续引入独立 service runtime 或额外可寻址对象模型。
AD-01:CAF 是唯一 actor runtime
决策:Service 必须建立在 CAF actor 语义之上;spawn / send / call / exit 收口到 shield_core,不再在 Lua 层另起一套独立 runtime。
理由:这是项目最初的约定,也是「不重复造 skynet」的核心。CAF 提供了 actor / mailbox / scheduler / request-reply / remote actor,完全够用;选型没有错。
推进顺序(见 roadmap):
- [
dispatch_stackthread-local 化 + 服务注册表加 shared_mutex(零语义变化)。] - [
shield.call使用 CAF request/reply 驱动 coroutine yield/resume。manager->call()路由到 CAF actor + 条件变量阻塞等待,自调用返回错误。] - per-service 执行权 + 全局激活队列 + N worker 抢占调度(达成 skynet 式多核并发,每 VM 串行)。
AD-02:Service 是唯一可寻址 RPC 单元
决策:
- 只有 Service 拥有 mailbox、CAF address、
send / call能力。 - 一个在线玩家 = 一个 PlayerService = 一个 CAF Actor = 一个私有 Lua VM。
- 玩家业务状态是该 service 私有 Lua table,不另起运行时对象。
理由:严格保持 skynet 模型——send / call 的目标永远是 Service,不是 Entity、不是玩家对象。这样 owner 边界最清晰,并发模型最简单,调试最容易。
含义:
- 跨服务调用:
shield.send(service, method, ...)/shield.call(service, method, ...)。 - 客户端 RPC 的
route_id先由描述符选择逻辑服务名;Gateway 再从该 session 的动态路由上下文解析实际 Service actor。逻辑服务名默认是player,也可以是scene、room、map等当前玩家已绑定的服务。 - 目标 Service adapter 在本 VM 内按 header
route_id直达启动期注册的 handler。业务 Lua 不接收通用客户端消息后再自行分发。
AD-03:不引入 Entity 运行时
决策:第一阶段不做以下任何一项:
PlayerEntity/AvatarEntity- Entity mailbox
- Entity
send / call/ Entity RPC - 全局可写
EntityManager.get(id) AvatarShard/player_pool分片承载
普通数据(房间、战斗、怪物、掉落物等)作为所属 service 内部 Lua table;只有确实需要独立 actor 语义的对象才成为独立 service。
理由:
- 引入「可
send/call的 PlayerEntity」等于在 Service 这层 actor runtime 之上再叠一层 actor runtime,复杂度爆炸,owner/mailbox/timer/coroutine/RPC 全部出现两套归属。 - KBEngine 的 Entity 之所以能 RPC,是因为它的 Entity 本身就是「带领域语义的 actor-like 对象」,背后有完整运行时;那是另一条路线,不在本阶段采用。
- 第一版优先把 service/call/runtime 语义做对、做稳,降低理解成本和出错面。
未来:若真需要「Entity 即轻量 actor」,作为一次明确的架构升级引入,不预埋、不污染当前 API。
参见 实体与组件化草案(该文已降级为草案/非当前契约)。
澄清(shield_player P0 起):本文禁止的是「可 send/call 的 Entity actor」这层第二 runtime。shield_player 的 PlayerSession(模块持有的会话元数据)与 PlayerRef({uid, node_id, service_id, epoch} 值引用)不是 actor:没有 mailbox、没有自己的 RPC/timer/coroutine,不能作为 shield.send/call 的 target,因此不在禁止范围内;player_pool 分片仍是压测前置的可选策略(OD-012)。
AD-04:one-player-one-service 为默认容量模型
决策:默认每个在线玩家对应一个 PlayerService(独立 Lua VM)。不为未测到的内存压力提前做 shard / pool 优化。
理由:
- 这是 skynet agent 模型的默认形态,owner 最清晰、重连/登出/保存/定时器都挂在同一处。
- 内存取舍经评估可接受:每玩家 VM 的实际成本需用真实压测定,而不是凭空假设后提前优化。
- 提前引入 shard 会把「玩家路由、owner 校验、shard 内状态管理」等复杂度前置,违背「先把架构做对」的原则。
未来:仅在真实压测证明每玩家 VM 成本不可接受时,才引入 player_pool / avatar_shard,且作为独立架构升级项,不改 public API。
AD-05:客户端 RPC 路由设计
决策:wire header 携带 route_id,body 是纯业务数据。所有客户端消息经 Gateway 发给 session 绑定的目标服务(登录前 → AuthService,登录后 → PlayerService)。route_id 的唯一职责是在目标 Service VM 内命中启动期缓存的 Lua handler。Gateway 不从 route_id 解析目标服务,不做多目标路由。
wire 格式:
+------------------+------------------+
| Header | Body |
| (route_id, ...) | (业务数据) |
+------------------+------------------+Header 携带 route_id 等传输层字段。Body 是纯业务数据,编码格式由 ProtocolProfile 决定(json / protobuf / msgpack)。route_id 不出现在 body 中。
入站路径:
socket bytes
→ frame decode → 读 header.route_id
→ Gateway 路由表校验(route_id 合法 + direction + 认证要求)
→ session.target(AuthService 或 PlayerService)
→ CAF send ClientIngress { session_id, epoch, player_id, route_id, body_bytes }
→ 目标 VM: route_id → cached handler → decode body → invoke handler(ctx, client, request)- Gateway 只读 header 中的
route_id做合法性校验,不解码 body。 body_bytes原样传递到目标 VM,由目标 VM 按 RPC schema 解码。- handler 签名固定为
handler(ctx, ClientContext, request)(ctx为 sender 上下文表),不含 route_id 或原始 frame。
预登录路由:认证前 session 尚未绑定 PlayerService,预登录 RPC(login、token 验证等)目标为 AuthService,使用同样的 route_id → handler 机制。认证成功后 Gateway 原子切换 session.target = PlayerService。
出站路径:codegen 生成的 server-to-client helper 已绑定 route_id;出站 encode 按目标 s2c 路由自己的 schema 类型名寻址(同名约定或该路由的 request_schema 覆盖,同 route_id 回包被出站 direction 校验拒绝)。业务只传 ClientContext + 业务参数。helper 编码 body_bytes 后 CAF 发送 ClientEgress 到 Gateway,Gateway 把 route_id 写入 wire header,body_bytes 作为 body。
codec 与路由无关:codec 是 session 级固定的(src/transport/protocol.cpp 监听器构建期解析 vtable 并加入 codecs_),不按 route 选择。codec_for_route() 忽略 route,恒返回 default_codec_id(src/transport/protocol.cpp:1520-1524)。
RPC 描述符:每个客户端 RPC 在编译期由描述符定义 route_id、name、direction、binding、request_schema、request_codec(schema_id / response_schema / binding_hint 已移除,配置出现即报错;schema 寻址收敛为「路由名同名约定 + request_schema 显式覆盖」)。目标 Service 启动时编译为 route_id → cached Lua handler 映射。name 只用于日志和调试,不进入 wire。
请求、响应与 push 边界:
- Shield 提供 descriptor 驱动的客户端 RPC method dispatch,以及生成的 server-to-client response/push helper。
- 第一版不提供通用
client.call、seq pending map、future 或自动超时关联。若某个 RPC 需要 correlation token,由该 RPC 契约显式定义业务字段。 PacketKind/seq不暴露为 Lua API,也不借用 CAF request id 建立客户端 pending 表。
详见 客户端 RPC 路由设计、网关设计、Lua API。
AD-06:SessionHandle 边界
决策(实现口径见下方后继说明;SessionHandle 类型已删除):
- 裸 session/连接句柄只存在于 network / gateway 边界,不进入业务 Lua 或 CAF payload。
- 登录后每个
ClientIngress必须携带 runtime 注入的可信客户端上下文:Gateway 地址、session_id、session epoch、player_id和 protocol profile id。它们是可序列化值,不是连接对象。 - Lua handler 只看到只读
ClientContext与该 RPC 的业务参数;ClientContext可派生用于回包的ClientRef;该值内部保留 protocol profile identity,但不向业务暴露 socket、CAF handle、codec 或 frame。 - Player/Scene/Room/Map Service 通过注册的 server-to-client RPC helper 发送
ClientEgress;它们不调用通用 session send API,也不接触 codec / frame / ProtocolPipeline。
理由:网络连接所有权归 Gateway,但直接接收客户端 RPC 的任意目标 Service 都必须知道可信玩家身份,并能把响应送回原连接。因此跨 actor 传递稳定的值语义 client reference,而不是连接 handle。这样:
SessionHandle不会随消息序列化或跨线程传播。- 目标 Service 无论位于本地还是远端,都能获得可信
player_id和回包引用。 - 业务 Service 与具体协议、socket 实现彻底解耦。
- 断线、重连和 stale 回包的 owner/epoch 校验集中在 Gateway 一处。
后继实现(架构纠偏 M2 起):SessionHandle 类型已删除。上述边界由只读 ClientContext(单次入站的 userdata)与 ClientRef(可回包的值引用,经 __shield_client_ref JSON 标记跨服务序列化并物化为只读 userdata)承载;session 单一 target 绑定与 epoch 由 Gateway 的 SessionBinding 管理,裸连接句柄不再有任何业务侧类型。
AD-07:可控业务时钟(分层时间)
决策:时间分层——Lua 业务时间(shield.now / os.time 无参 / os.date 无参)走可注入的 wall-clock Clock,测试可拨;C++ 调度/网络时间(deadline_ms / timestamp / CAF delayed_send / pump 扫描)锁死真实 monotonic。os.clock 不接入(真实 CPU 时间,用于性能计时)。定时器触发保持真实时钟(属于调度层),callback 内业务读到的时间可拨。
理由:游戏业务强依赖日期语义(刷新/活动/过期),必须可拨才能测试;而网络心跳、call 超时、定时触发属调度层,拨动会破坏运行时本身(连接误超时、调度错乱)。分层让"可测"与"运行时正确"不冲突。业务代码大量直用 os.time(仓库实证缓存过期等 ~12 处),只拨 shield.now 无意义,故 os.time/os.date 无参必须一并接入同一 Clock。os.clock 不动——旧的 DI/IoC 实验脚本(已删除)曾用它做忙等 while os.clock()-start<0.01,拨了会死循环。
实现状态:已实现。shield.now() 读 LuaServiceManager 持有的 Clock(默认 SystemClock,system_clock 墙钟 UTC ms);shield.monotonic() 读 steady_clock(不可拨)。os.time()/os.date() 无参 hook 在 register_full_shield_api 安装,带参形式保留原函数。attach_clock() 用于测试注入 MockClock。Clock/SystemClock/MockClock 定义于 include/shield/lua/clock.hpp。test_lua_api_clock 覆盖 12 个用例:默认墙钟、一致性、MockClock set/advance、os hook 粒度(含 table 带参不受影响)、monotonic 不可拨、缓存过期/签到/冷却完整业务场景。
AD-08:密码学只提供原语,认证语义在 Lua 业务层
决策:runtime 提供密码学原语库(shield.crypto:base64/base64url/hex 编解码、SHA-256、HMAC-SHA256、安全随机字节、常数时间比较),不提供认证/签名等业务语义。JWT 这类"签发什么 claim、何时过期、哪个 issuer 被接受"的决策属于业务,放在 Lua 层(scripts/lib/jwt.lua 为参考实现)。原 C++ 插件 plugins/auth_jwt 随之弃用,保留一个过渡期后移除。
理由:认证策略是业务语义,会随产品形态变化(多租户 issuer、refresh token、非对称签名、密钥轮换),而 C++ 插件是 ABI 冻结的二进制边界——每种策略都塞进插件层等于把业务决策写进 ABI。原语是稳定面:算法与编码格式变化慢、可独立测试(RFC 向量),且实现可直接用已有依赖(OpenSSL 已在依赖树内),无需自行开发。分层后"换认证方案"是改 Lua,不是发新二进制;runtime 也不必为每种 token 形态维护插件 API 版本。
实现状态(2026-09-28 落地,2026-10-09 扩展):include/shield/lua/lua_crypto.hpp + src/lua/lua_crypto.cpp,由 register_full_shield_api 装配;shield_lua 显式链接 OpenSSL::Crypto。API 表见 lua-api.md。scripts/lib/jwt.lua(HS256 与 EdDSA 双算法 + RFC 7517 JWKS 公钥集助手)为纯 Lua 参考实现,只用原语拼装,自带最小 JSON 编解码;二期原语(PBKDF2/HKDF、AES-256-GCM、Ed25519)已于 2026-10-09 补齐。plugins/auth_jwt/manifest.yaml 标 deprecated(不删,兼容既有部署)。
决策与本仓库其他文档的关系
| 决策 | 影响文档 |
|---|---|
| AD-01 | architecture.md、architecture-core-concepts.md、skynet-comparison.md、runtime-messaging.md、roadmap.md |
| AD-02 | runtime-service.md、runtime-player.md、skynet-comparison.md |
| AD-03 | runtime-entity.md、runtime-player.md、architecture.md |
| AD-04 | runtime-player.md |
| AD-05 | gateway.md、protocol-routing-design.md、lua-api.md、runtime-messaging.md |
| AD-06 | runtime-network.md、runtime-player.md、gateway.md、lua-api.md |
| AD-07 | runtime-timer.md、lua-api.md |
| AD-08 | lua-api.md、plugin-system.md、architecture.md |