消息运行时语义
本文档包含 Shield 消息通信相关的运行时语义决策。
MessageEnvelope
MessageEnvelope 是 runtime 内部信封,Lua 用户不直接构造。
注:下面的 C++ 结构是概念模型,用于说明字段语义;实际实现见
include/shield/core/service_message.hpp(ServiceMessage/CallResponseMessage)。service call 的请求-响应关联由LuaServiceManager基于call_session与 CAF actor 消息、coroutine pending/resume 维护,不在 Lua API 中暴露。
enum class MessageKind {
Send,
CallRequest,
CallResponse,
System,
};
struct MessageEnvelope {
MessageKind kind;
ServiceAddress src;
ServiceAddress dst;
uint64_t request_id;
Deadline deadline;
TraceContext trace;
MessageFlags flags;
MessagePayload payload;
};规则:
src由 runtime 填充,Lua 不允许伪造。Send的request_id为 0。CallRequest和CallResponse使用非 0request_id。
服务间 vs 客户端:上述
request_id关联仅用于 service ↔ service 的shield.call。客户端 ↔ gateway 之间没有框架级 RPC:PacketKind/seq是 wire 层字段,当前无端到端语义,不构成client.call、不维护 seq pending map。客户端 request/response/push 的 correlation token 由业务 payload 字段自管(见 架构决策记录 AD-05、网关设计)。
deadline本地用 monotonic time。- 跨节点传输时发送 remaining timeout,不发送本机 monotonic deadline。
trace用于 ops、profile、慢调用定位。
顺序保证:
同一个 src -> dst 的本地消息按发送顺序入队。
不同 src 之间不保证全局顺序。
跨节点只保证同一连接内写入顺序。ClientIngress / ClientEgress
客户端 RPC 在 Gateway 与目标 Service 之间传递的内部消息。
ClientIngress
客户端 RPC 进入目标 actor 时使用(自架构纠偏 M3 起为入站唯一入口):
struct ClientIngress {
ClientContextData context; // {gateway_address, session_id, session_epoch,
// player_id, protocol_profile_id}
uint32_t route_id = 0; // 来自 wire header
std::vector<uint8_t> body_bytes; // 纯业务数据,原样传递
std::optional<nlohmann::json> decoded_request; // pipeline codec 产出的规范 JSON
};- 这是 CAF/Shield runtime 内部消息,不是客户端 wire 格式,也不是 Lua API。
route_id来自 wire header 的 frame decode,不是从 body 提取。body_bytes是 wire frame 的 body 部分。handler 的 request 值按 descriptor 契约成形:decoded_request优先;否则 request_codec 为空或json的 route 由目标 VM 把body_bytes按 JSON 解码(失败回退原始字节字符串);其他 request_codec(如raw)始终传原始字节字符串。- 目标 VM 按 spawn 期编译的 route 表把
route_id直接分发到handler(ClientContext, request)。 - CAF behavior 按内部消息类型区分
ClientIngress、普通 service send/call、 生命周期控制等。
session 生命周期(接入/断开/被踢)以 typed ClientControlMessage 传递, Lua 侧约定:Bound → on_client_bound(ctx, client),Disconnected → on_disconnect(ctx, client, reason),Unbound → on_client_unbound(ctx, client, reason),Reconnected 预留(当前警告并丢弃)。
玩家重连窗口不经由
Reconnected控制消息:shield_player在认证路径内 识别同 uid 的存活会话并 restore(复用实例、按序 flush 离线队列), 见 runtime-player.md 与 lua-api.md "Player API"。
ClientEgress
服务端发送 response/push 到 Gateway 时使用:
struct ClientEgress {
ClientContextData context; // 携带 session_id + session_epoch
uint32_t route_id = 0; // 由 shield.client_rpc.<name> helper 绑定
std::vector<uint8_t> body_bytes; // 按路由 schema 编码的业务数据
std::optional<nlohmann::json> message;
};- Gateway actor 收到后校验 session 存在、存活、epoch 相等、owner 匹配,把 route_id 写入 wire header,body_bytes 作为 body。
客户端 RPC 状态码
当前只有 client_rpc.epoch_expired 是真实回传的结构化状态码,其余各项没有结构化错误回传:
| 状态码 | 说明 | 当前实现 |
|---|---|---|
client_rpc.not_authenticated | 未认证 session 访问需认证 RPC | 未回传结构化码:记 WARN 并丢弃消息 |
client_rpc.route_not_found | 未知 route_id | 未回传结构化码:WARN + 计数(egress_route_not_found) |
client_rpc.direction_rejected | 方向不匹配 | 未回传结构化码:WARN + 计数(egress_direction_rejected) |
client_rpc.epoch_expired | session epoch 过期 | 已实现的结构化状态码 |
client_rpc.handler_missing | 目标 VM 缺少 handler binding | 非 RPC 回传码:spawn 期绑定失败时的错误文本 |
MessagePayload
实现状态:本节的 MessagePayload/PayloadCodec/LuaRequestPayload 结构当前不存在于源码,属于格式规格预留。消息路径实际使用 nlohmann::json 序列化(只校验 1MB 消息大小与 unsupported 值,无嵌套深度检查)。LuaPackEncoder/LuaPackDecoder 存在但当前只被测试引用,未接入消息路径;encode_failed 仅由 unsupported 类型映射产生。
core 中 payload 是不可变二进制 buffer。
enum class PayloadCodec : uint16_t {
LuaPack = 1,
RawBytes = 2,
};
struct MessagePayload {
PayloadCodec codec;
uint16_t version;
ByteBuffer bytes;
};Lua 默认编码:
LuaRequestPayload {
method: string
argc: uint32
args: LuaValue[]
}支持类型:
| 类型 | 支持 |
|---|---|
nil | 是 |
boolean | 是 |
integer | 是 |
number | 是 |
string | 是 |
table | 有限制 |
ServiceHandle | 是,作为扩展类型 |
function | 否 |
thread/coroutine | 否 |
| 普通 userdata | 否 |
| 循环引用 table | 否 |
table 规则:
array table: 连续整数 key 1..n
map table: string/integer key
禁止: table/function/userdata key
默认最大嵌套深度: 64本地消息也需要序列化,不直接传 Lua 对象指针。优化只能共享 immutable ByteBuffer,不能改变语义。
LuaPack 序列化格式
实现状态:LuaPack 是 Shield 内置的二进制序列化格式规格,但当前未接入消息路径(消息实际用 JSON 序列化);编码器/解码器实现存在,只被测试引用。本节按格式规格阅读。
LuaPack 是 Shield 内置的二进制序列化格式,用于消息编码。
Wire Format
┌─────────────────────────────────────────────────────────┐
│ Header (4 bytes) │
│ - magic: 2 bytes (0x4C, 0x50 = "LP") │
│ - version: 1 byte │
│ - flags: 1 byte │
├─────────────────────────────────────────────────────────┤
│ Payload │
│ - type_tag: 1 byte │
│ - data: variable length │
└─────────────────────────────────────────────────────────┘字节序:Little-Endian(与 x86/ARM 一致)。
类型编码
| 类型 | Tag | 编码 |
|---|---|---|
| nil | 0x00 | 无数据 |
| false | 0x01 | 无数据 |
| true | 0x02 | 无数据 |
| integer | 0x03 | 8 bytes (int64 LE) |
| number | 0x04 | 8 bytes (double LE) |
| short_string | 0x05 | 1 byte len + data (len < 256) |
| string | 0x06 | 4 bytes len (uint32 LE) + data |
| array | 0x07 | 4 bytes count + elements |
| map | 0x08 | 4 bytes count + key-value pairs |
| service_handle | 0x10 | 4 bytes node + 8 bytes id |
| extension | 0xFF | 2 bytes type_id + 4 bytes len + data |
字符串编码
短字符串 (< 256 bytes):
[0x05] [len:u8] [data:len bytes]
长字符串 (>= 256 bytes):
[0x06] [len:u32 LE] [data:len bytes]数组编码
[0x07] [count:u32 LE] [element1] [element2] ...空数组:[0x07] [0x00 0x00 0x00 0x00]
Map 编码
[0x08] [count:u32 LE] [key1] [value1] [key2] [value2] ...key 必须是 string 或 integer 类型。
ServiceHandle 编码
[0x10] [node:u32 LE] [id:u64 LE]用于跨服务传递 handle 引用。
扩展类型
[0xFF] [type_id:u16 LE] [len:u32 LE] [data:len bytes]扩展类型用于自定义编码,如:
- 0x0001: Timestamp (int64)
- 0x0002: UUID (16 bytes)
- 0x0003: Decimal (string representation)
嵌套深度限制
默认最大嵌套深度 64 层,超过返回 encode_failed 错误(该行为属于 LuaPack 编码器规格;当前消息路径不经过它,JSON 路径无嵌套深度检查)。
配置:
# 未接线(预留):codec 配置键当前无解析
actors:
- name: gateway
script: scripts/auth.lua
codec:
max_nesting_depth: 64 # 最大嵌套深度
max_string_length: 1048576 # 最大字符串长度 (1MB)
max_array_length: 1000000 # 最大数组长度
max_map_entries: 100000 # 最大 map 条目数shield.send
shield.send 是 at-most-once、非阻塞、无 ACK 的投递 API。
local ok, err = shield.send(target, "kick", uid)返回成功只表示 runtime 接受消息进入投递流程,不表示 receiver 已收到或处理成功。
Phase 1 规则
Phase 1 的 send 只冻结最小投递语义,不暴露 QoS、可靠投递或可配置背压策略:
send不挂起 coroutine。- 不自动重试。
- self-send 允许,但不是 reentrant call,而是入队到未来调度点。
- target 可以是 handle 或 name。
- target 是 name 时,每次发送动态 query registry。
- target 是 handle 时,直接按 handle 路由。
- CAF actor mailbox 当前无界,因此
send在本地投递时不会返回mailbox_full;背压与 QoS 策略为后续设计项。
可靠处理必须用 shield.call 或业务 ACK。
Deferred 高级选项
以下选项是后续消息/QoS 设计草案,不属于 Phase 1 Lua API,也不进入当前验收矩阵。引入前必须先更新 Lua API 契约 和 Lua API 测试用例。
local ok, err = shield.send(target, "event", data, {
-- QoS 优先级
priority = "high", -- "low" | "normal" | "high" | "urgent"
-- 可靠投递(仅对 send 有效,需要业务 ACK)
reliable = false, -- true 时 runtime 会跟踪投递状态
})优先级:
| 优先级 | 值 | 说明 |
|---|---|---|
urgent | 0 | 最高优先级,立即处理 |
high | 1 | 高优先级 |
normal | 2 | 普通优先级(默认) |
low | 3 | 低优先级 |
优先级为后续设计项,当前 CAF actor mailbox 不保证消息排序。
当前实现状态:Phase 1 已支持单节点本地 service name 字符串路由,消息通过 CAF actor 异步投递到目标 service。shield.call 是 coroutine-aware 的: Lua 侧经 _coro_call + coroutine.yield 挂起 caller 协程,默认超时 5000ms,由超时 sweep 扫描过期 pending call 并恢复 caller(条件变量只用于 外部非协程上下文的同步等待路径)。非 reentrant self-send 已实现。
shield.call 返回格式
shield.call 返回 ok, ...。
local ok, value = shield.call(target, "get_profile", uid)
if not ok then
local err = value
end成功:
ok == true
后续返回值是 callee 业务返回值失败:
ok == false
第二个返回值是 Error业务返回 nil 或 false 不产生歧义:
-- callee
function M.check(uid)
return false, "banned"
end
-- caller
local ok, allowed, reason = shield.call("auth", "check", uid)
-- ok == true
-- allowed == false
-- reason == "banned"response payload 需要保存 argc,以保留 trailing nil。
当前实现状态:本地 shield.call 已返回 true, ...callee_returns 或 false, Error,支持业务返回 false 与 runtime 错误区分。method dispatch 已协程化:caller 协程在 call 处挂起,runtime 维护 pending call registry, 超时 sweep 恢复 caller 并返回 timeout 错误;返回值经 table.pack/table.unpack 保留 trailing nil。late response(已超时或未知 session 的迟到响应)被直接丢弃。
call 超时
shield.call 使用默认超时,不允许默认无限等待。
默认值:
call_timeout = 5s覆盖超时使用单独 API,避免最后一个业务参数和 options table 歧义:
local ok, value = shield.call("db.player", "get", uid)
local ok, value = shield.call_timeout(30000, "db.player", "get", uid)规则:
- caller coroutine 挂起。
- 超时后 caller 恢复
false, Error{ code = "timeout" }。 - pending call 从 registry 移除。
- callee 不会被自动取消。
- late response 被丢弃(对未知 session 的迟到响应直接丢弃,当前不计入任何 ops 指标)。
- timeout 必须传递到 envelope deadline。
错误对象(make_error 只产出 code/message/retryable/detail 字段):
{
code = "timeout",
message = "call timeout",
retryable = true,
}nested call
service handler 内允许再次调用 shield.call。
function M.login(uid)
local ok, profile = shield.call("db.player", "get", uid)
if not ok then
return false, profile
end
return true, profile
end规则:
- nested call 只挂起当前 coroutine。
- 同一个 service 可以继续处理其他 ready message。
- runtime 不做死锁检测。
- 循环调用依赖 call timeout 释放。
- 需要避免在持有业务锁或临界状态时发起 call。
self-call 允许,但必须按普通消息入队和调度,不能直接递归调用 handler。
同 service coroutine 调度
每个 service 拥有一个 Lua VM,VM 内允许多个 Lua coroutine。
规则:
- 同一时间最多一个 OS thread 进入同一个 Lua VM。
- 每条 incoming message 创建或复用一个 coroutine 执行。
- handler 执行到
call/sleep/ await timer 时 yield。 - 当前 coroutine yield 后,该 service 可以处理下一条 ready message。
- response/timer 到达后,把对应 coroutine 放回 ready queue。
- 不提供抢占式调度,只在显式 yield 点切换。
这与 Skynet 类似:业务状态不会多线程并行访问,但会在 yield 点发生重入和交错。
服务级资源限制(mailbox、coroutine、pending call 等)见 服务语义。
错误处理
Shield 区分 Runtime 错误和业务错误。完整错误码列表见 错误码参考。
错误传播
function M.login(uid)
local ok, result = shield.call("player", "get_profile", uid)
if not ok then
-- Runtime 错误,返回给上层
return nil, result
end
if not result.ok then
-- 业务错误,按业务契约处理
return nil, result
end
return result.profile
end