AIGC标识 X3 LUA 异步分布式微服务集群框架

X3 LUA 异步分布式微服务集群框架

Lua 主控,C/C++ 辅助。
进程形态、服务身份、注册发现、协议表、业务逻辑都在 Lua;一个进程 = 一个目录 + 一个 main.lua。
C/C++ 补 Lua 做不了或做不快的那几块:I/O 多路复用、线程、网关加解密与压缩、日志、定时器。
Windows(MinGW-w64)与 Linux 共用一份源码、一套 CMake,产物同批落在 bin/。

x3 是一个服务端骨架:一个宿主进程(x3 / x3.exe)、十几个 C/C++ 模块(dll / so),
加一套用"目录 + main.lua"表达的装配约定。拿它拼服务端时写的是 Lua,管的是线程、连接、
消息与集群;C/C++ 那一层只在需要新原语时才打开。

规模:C/C++ 约 4 万行,Lua 约 5.4 千行。仓库里带一个能跑起来的六进程集群
(注册中心 / 网关 / 登录服 / 两个应用服 / 调度服)和一个客户端剧本。

它面向长连接、多进程、异步消息的后端:游戏服(MMO / 卡牌 / 实时对战)、实时应用、
内部微服务。这些系统的共同需求是"一条连接要在多台机器之间被转发、被广播、被限速",
x3 把它们做成框架的默认路径,业务侧只写 handler。HTTP 侧提供的是 listen +
path → handler 的派发(见第 14 节)。

x3 是开源框架思想与网文架构思想的集大成。 它没有新造机制:把散落在开源实现与架构
文章里的几条做法挑出来,用同一套 id 语义拼成一个能跑的整体:

  • I/O 多路复用取 Linux 的 epoll 与 Windows 的 IOCP,两者之上只留一种事件对象(读一个、写一个);
  • 线程归属取 muduo 的 "one loop per thread" 与 Seastar 的 shard-per-core;
  • 消息语义取 Erlang / Akka 的位置透明,分阶段流水线取 SEDA;
  • 模块形态取 Apache httpd 的 DSO 与模块表,脚本分层取 OpenResty 的 "每个 worker 一个 VM";
  • 单写者原则取 LMAX Disruptor 的 single-writer principle;
  • "万物皆 ID" 的提法取 ygluu 的《万物皆ID、万事皆事件、万人皆数据》,并用到运行侧
    (消息、连接、请求、线程归属)。

名称里的三个词各有所指:

  • LUA 主控 —— 业务逻辑、进程装配、协议表、服务身份、注册发现全在 Lua;C/C++ 只在
    Lua 够不到的地方补位(见第 2 节第 2 条)。
  • 异步 —— 没有阻塞式调用栈:send 投出去就返回,call 靠 reqid 配对、回调回到发起线程;
    跨线程与跨进程是同一套写法(见第 4 节)。
  • 分布式微服务集群 —— 一个目录一个服务,注册中心只存地址、调度中心只做分配、网关只做接入;
    服务之间只按 id 说话,不互相 include、不共享内存(见第 6 节)。

目录

  • 三分钟跑起来
  • 术语
  • 设计思想
  • 特点速览
    1. 万物皆 id
    1. 设计取向
    1. I/O 模型
    1. 消息机制
    1. 线程模型
    1. 微服务集群
    1. 模块与插件化
    1. Lua 分层与装配
    1. 目录与模块
    1. 构建
    1. 运行
    1. 实测记录
    1. 开发约定
    1. 已知限制
    1. 参考点:每条机制的参照
  • 附:设计文档
  • 许可

不同目的各有入口,不必从头读:

想做什么 从哪读起
第一次接触 上面的简介 → 三分钟跑起来 → 术语
判断这套结构合不合用 特点速览 → 第 2 节 → 15.3(整体形态的七个参考点)
接手写业务 第 8 节 → 4.3 → 第 11 节;xa_app/ 是最短的样例
改框架 第 1 节 → 第 3 节 → 第 5 节 → 第 7 节
评估并发模型 第 1 节 → 第 5 节 → 15.5(actor 的寻址与隔离)
排查线上问题 第 13 节(开发约定)→ 第 12 节(实测)→ 第 14 节(已知限制)

三分钟跑起来

./build_linux.sh release               # Windows:build_win.bat release
./start_all.sh                         # registry + gate + login + app×2 + sched
./test-client/start.sh -sched 127.0.0.1:9006 -account alice
./stop_all.sh                          # 优雅停,与 Ctrl+C 同一条路径

Windows 上把 .sh 换成 .bat,其余参数相同。客户端那一步的末尾是
[cli] 结果:PASS(sched=1 hello=1 login=1 app=1)。test/start.sh 是另一条更短的路径:
不起网络,只压消息分发与定时器。构建细节见第 10 节,运行与预期输出见
第 11 节。


术语

下面这几个词在全文反复出现,先列在一起:

词 含义
appname / app_type 服务名 = 进程目录名(xa_gate);app_type 是去掉 xa_ 前缀的部分(gate)
insid 进程的网络 id,全簇唯一,同时充当雪花 id 的机器号
netid 连接 id。服务之间的连接用对端 insid 当 id;客户端侧用网关生成的雪花 id
dstid 目标对象 id,由 bus.enter(objid, obj) 绑定,send_to 按它找线程
msgid 消息 id = crc32(消息名),线上只传这 4 字节
consid 一致性键,非 0 时按 consid % 候选数 在多个实例或多条订阅线程间选一个
reqid 一次 call 的请求 id,应答按它配对
tmsg_ / smsg_ / cmsg_ 三种消息前缀:进程内 / 服务间 / 经网关到客户端
owner 绑定了 dstid 的对象;"线程归属"也用它表示——一个 owner 只属于一条线程
up / down xnet 的两个事件对象:一个管读,一个管写,各绑一条线程

设计思想

整份代码可以压成三个决定,其余都是它们的推论:

  1. 万物皆 id。 名字、地址、连接、请求、线程、定时器,在登记那一刻算成一个整数,
    之后的路由、分发、超时匹配、日志反查全部只认整数。
  2. 一个 id 只有一个执行体。 id 能取模、能哈希,所以"这件事该归谁做"是算出来的:
    不需要查表问人,也不需要为它加锁。
  3. 执行体之间只走消息。 线程之间没有共享容器,于是没有共享内存模型;
    跨线程与跨进程在业务侧是同一套写法。

这三个决定各自导出什么:

决定 导出的结构 落地位置
名字 → 整数 消息 id = crc32(消息名);字符串 id = crc64(名字);服务 id = insid(16 bit 取值);连接 id = netid xid / xbus / xnet
整数 → 执行体 dstid 定线程、consid % 候选数 在多个实例或多条订阅线程间选一个、insid 定连接 xbus / xnet
执行体 → 消息 send / send_to / send_by / call,本进程与跨进程同一套 xbus
整数 → 名字 id2name 反查,日志与协议展示用 xid
名字 → 整数 → 名字 双向表,只在登记时写一次 xid

这套结构带来的改变是并发模型的位移:从"多个线程改同一份数据、用锁排好队",
变成"每条线程只改自己的数据、改动以消息通知别人"。锁在结构层不存在,
因此也不会因为某处写漏而退化。

代价是阻塞操作不留在业务调用栈里:数据访问、网络、文件操作全部写成"投递 + 回调",
send 投出去即返回,call 的结果走回调,xhttp 的 handler 由事件驱动。


特点速览

  • 万物皆 id。 消息 id = crc32(消息名);字符串 id = crc64(名字)(双向表可反查);
    服务端 id 是 insid(16 bit 取值);服务端连接 id 是对端 insid(netid);客户端连接 id 是
    网关发的雪花 id;call 有 reqid;listen 的对象有 dstid;线程有 id(xthread_curr_id),
    定时器有 id;集群也有名字(clustername 进握手首字段)。
    字符串只在登记那一刻参与比较,之后的路由、分发、超时匹配、日志反查全走整数表。
    它同时换来三件事:id 能取模/哈希 ⇒ "定线程、选实例、选集群"都是一次运算;
    整数比较 ⇒ 热路径便宜;名字仍是唯一事实 ⇒ 日志和协议里都看得懂。
    完整清单见第 1 节。

  • I/O:epoll 与 IOCP 之上只有一种事件对象。 两个 event 对象(up / down)分别绑定
    一条线程,Linux 上各是一个 epoll 实例,Windows 上各是一个完成端口;一条连接的读和写归
    不同的 event,于是收发的系统调用处只有一个分叉点(inc/xplat.h),状态机只有一套。
    见第 3 节。

  • 线程:托管线程 + 每线程一个 lua_State。 框架线程 main / bus、xtpool 池线程、
    自建业务线程全部由 xthread 托管:任务队列、周期回调、空闲休眠、超时告警都在框架里。
    跑业务的那几条各带一个 lua_State,由线程自己建、自己关,跨线程不会碰到同一个 state;
    net 是 xnet 自建的时间线程,不碰 Lua。见第 5 节。

  • 消息:消息名就是协议。 不写 .proto、不跑代码生成,消息名经 crc32 变成 4 字节 id,
    线上只传 id;参数是一张 lua 表,msgpack 自动打包解包。广播范围由消息名前缀
    (tmsg_ / smsg_ / cmsg_)唯一决定,调用方不传范围。见第 4 节。

  • 无锁:跨线程只走消息。 线程之间没有共享容器。需要"同一份数据只由一条线程碰",
    就把 id 定死到那条线程上:xtpool_alloc(key) 把同一个 key 固定到一条池线程,
    xa_login 按 userid % #list 选应用服并钉住,send_by(consid) 把同一个键固定到一条线程。
    要串行就定线程,不需要加锁。

  • 微服务:三个角色各管一件事。 注册中心只存地址、调度中心只做分配、网关只做接入;
    服务位置是算出来的(consid % 候选数),不是查出来的,所以没有位置服务这个单点。
    一条客户端连接钉在一个后端实例上,后端掉线时明确断开,不静默换人。
    见第 6 节。

  • 插件化:一个模块 = 一个动态库 + 一张 C 表 + 一份语言绑定。 xthread / xbus / xnet / xgate / xlog … 各自编译成 dll/so,导出 C ABI 与一张 luaL_Reg;模块之间不互相
    include,也不互相调用,要说话就发消息。宿主 x3 只链最少的三个。见第 7 节。

  • 装配:一个进程 = 一个目录 + 一个 main.lua + 一个 run。 新增服务:建目录、
    抄那五行骨架、最后一行换成自己的 run(boot),启动时给 -insid / -endpoint。
    登记、订阅、就绪日志都在这一个 run 里,没有配置文件、没有注解,也没有第二处清单
    要同步。见第 8 节。

  • 初始化与退出都有序。 C++ 侧每个编译单元在自己的静态初始化里
    post_main<"名字">(&init),宿主登记 main 线程那一刻按投递顺序补投 —— 模块不需要
    一张集中清单,也不需要谁先谁后的约定;Lua 侧 require 顺序就是装配顺序。
    退出走一条固定链条:tmsg_close 广播 → xbus::wait_idle() 排空 →
    xthread::stop_all() → 最后一条日志 → xlog::flush() → 各 so 静态析构。
    见第 7.5 节与第 7.6 节。

  • 跨平台与可观测。 xplat.h 是全项目唯一出现 socket #ifdef 的地方,两个平台共用
    同一套 CMake 与同一套连接状态机;xlog 异步落盘、按"进程 + 日期"分目录、{} 占位、
    线程可命名(日志里直接看出是谁在说话);cluster/stat 起一条统计线程,
    各模块用 x*_get_stats 供它采集。

  • 性能:省在结构上,不靠调参。 同进程消息不进 socket,按消息表逐跳分发(接收者恰好
    同线程时就地执行,不排队);运行期只用 4 字节 msgid 查表,不做字符串比较;每线程一个
    lua_State,业务侧没有锁。


1. 万物皆 id

ygluu 在《万物皆ID、万事皆事件、万人皆数据》里给出一句判断:"当 ID 统一的时候,你会发现
很多事情都很好做。"(文章与仓库见15.2)

那篇文章讲的是数据侧的统一:配置文件按模块分配 ID 段位,实例 ID 用 64 位(雪花算法),
名字登记进一个 id-name 系统,条件表达式解析完直接变成 player.datas[9000000] 这样的下标访问。

x3 取的是同一条判断,把范围从数据侧推到运行侧:一条消息从哪来、到哪去、发给谁、谁在等、
落在哪条线程、归哪台机器,全都收敛成一个整数。

1.1 完整清单

下表的接口名按 C ABI 写,Lua 侧是点号形式(xid_addname → xid.addname,xtimer_add →
timer.add),完整清单见9.5。

id 位宽 谁给 用在哪
字符串 id uint64 xid_addname(名字) = crc64(也可业务指定),登记 name2id / id2name 双向表 需要"名字 ↔ id"两向都用的场合(配置表、字段名、技能/物品名)。同名不同 id、同 id 不同名都记 error 并保留先来的那个
msgid 消息 id uint32 crc32(消息名),登记时算一次 listen 的订阅表、send 的接收者清单、by_msg 路由表
insid 服务端 id uint64,契约取值 0~65535 命令行 -insid 全簇唯一;同时充当雪花机器号;决定"谁拨谁"(大的拨小的)
netid 服务端连接 id uint64 握手后双方各自拿对端 insid 当连接 id xnet 的连接表与路由 —— "发往 7001"就是"发给 insid == 7001 的那条连接",不用查表
netid 客户端连接 id uint64(雪花) 网关 accept 之后 xid_gen_id() 生成,机器号 = insid 网关下行时 dstid 直接就是它;dstid = 1 保留为"该网关下所有客户端"
dstid 目标 id uint64 bus.enter(objid, obj) 绑定 send_to / call_to 命中哪个 owner;到网关后被解释成客户端连接
reqid 请求 id uint64 xbus_call 内部生成 应答匹配(30 秒未回丢弃并告警)
consid 一致性键 uint64 业务传(如 userid),或 xid_gen_consid 0 = 轮询;非 0 = consid % 候选数 选服务实例
线程 / 回调 id uint64 线程创建时分配;runcb 的名字注册进 xid 得到 id,执行时记进 curr_job_id 超时告警与卡住告警按它反查"卡在哪个回调";统计表按线程名对齐
定时器 id uint64 xtimer_add 返回 同线程内唯一,用于删除与统计

四类 id 各回答一个问题:

  • 发给谁 —— msgid(哪条消息)、dstid(哪个对象)、netid(哪条连接)。
  • 谁在等 —— reqid(这次请求)、consid(这个一致性键固定落哪台)。
  • 在哪条线程 —— dstid 绑定的线程、consid 映射的订阅线程、线程 id。
  • 在哪台机器 —— insid(哪台)、clustername(哪个集群)。

1.2 两类 netid 共用一个 64 位空间

服务端连接拿对端 insid(≤ 65535),客户端连接是雪花 id(高位是毫秒时间戳,远大于 65535)。
两类连接 id 放在同一个空间里却不冲突,靠的是取值范围天然分开,于是
"客户端 netid 不可能等于 1"这类判断可以直接写进广播语义里 —— 网关的 dstid = 1
表示"我下面所有客户端",这个保留值不会被真实连接占到。

1.3 跨集群:同一套 id 空间铺到多个集群

clustername 是进程身份的一部分(和 insid / appname 一起进 xnet_init),也是握手
报文的第一个字段(超长截断到 31 字节)—— 对端拿它判断"是不是同一个集群"。
字符串只在这里比一次,之后同一个 netid 空间里就同时装着本集群与别的集群的连接。

xnet 层的拨号规则三条:

情形 xnet 拨不拨 参与按 msgid 路由吗
非本集群 拨 —— 地址只在我手里,对方未必有我 不参与(不进 by_msg 表)
同 appname(同一服务的另一个副本) 不拨 —— 两个副本之间没有要交换的业务 不适用
本集群、不同 appname insid 大的拨小的(全集群一致的确定性规则) 参与

clustername 留空 = 不参与任何互连:没有"本",也就算不出"外"。

当前部署走的是更保守的一条:common/cluster/regcli.lua 遇到 cluster 不一样的条目
不拨,给 add_ins 填的 clustername 也一律是本集群。所以跨集群链路是 xnet
提供的能力,启用方式是业务自己 add_svc 并把 clustername 填成对端的集群名。
注册中心那条路径目前只跑单集群(demo 六台全部 cluster = "dev")。

跨集群是两条明确的窄路,不是"再开一条业务通道":

  1. 业务进程 → 网关 → 客户端。bc = 3(XBUS_BCAST_GATES)按 gatename 在服务登记表
    里找网关,不按集群过滤,并自动带上 dstids{1};网关收到后 dstid = 1 表示"我下面
    所有客户端",dstid 是具体值时就是某一个客户端连接。设计上网关常在另一个集群,所以
    这是唯一会常态化跨集群的业务包(demo 里同集群,这条路退化成同集群查找)。
  2. 服务之间。跨集群实例只登记、不进 by_msg,业务包不会按 msgid 流过去;要跨集群
    取数,只能由业务显式按 netid 发(xnet_send(netid, ...)),或用 bc = 2 发给自己
    已经建链的全部服务(跨集群那条也在里面)。

跨集群链路的延迟与可用性不由自己掌握(跨机房 / 跨区),把它留在业务主路径之外,
集群内的 consid % 候选数 就不会被一个远端实例拖慢。跨集群的调用因此都写成显式的。


2. 设计取向

前面三个决定落到工程上,表现为六条取向,一层压一层。

  1. 万物皆 id(第 1 节)—— 运行期只认整数,字符串只在登记时出现一次。

  2. Lua 主控,C/C++ 辅助。 业务逻辑、进程装配(boot.lua + local_runcfg.lua)、协议表
    (common/proto/)、服务身份与注册发现(cluster/)、统计与落库全在 Lua。C/C++ 只做
    三类事:Lua 碰不到的系统调用(socket / epoll / IOCP / 文件)、Lua 做不快的地方(切包、
    id 计算、加解密、压缩)、所有模块共享的底座(线程、日志、定时器)。
    判断标准只有一条:能用 Lua 写清楚的就留在 Lua;进了 C++ 的,都能说出"为什么不能在
    Lua 里做"。

  3. 一个模块一个动态库。 xthread / xbus / xnet / xgate / xlog … 各自编译成 dll/so,
    谁用谁 require。宿主 x3 只链最少的几个,业务侧按需加载。

  4. 跨线程只走消息,不共享数据。 线程之间没有共享容器,也就没有业务锁;
    需要"同一个东西只由一条线程碰",就把目标 id 定死到那条线程上。

  5. 消息 id 可反查。 消息名以 tmsg_ / smsg_ / cmsg_ 开头,线上只传 4 字节 id,
    日志里反查回名字靠同一张进程级表 —— id 是索引,名字才是唯一事实。

  6. 两个平台一套代码。 xplat.h 是全项目唯一出现 socket #ifdef 的地方;
    Windows 用 MinGW-w64 + Ninja,Linux 用 GCC + Ninja,输出名带平台后缀的才分开
    (如 libxgate_win.a / libxgate_linux.a)。


3. I/O 模型

网络层只做三件事:读、写、按 id 找到那条连接。这一章讲这三件事怎么落在两个事件对象上,
两个平台为什么只有一处分叉,以及"一条连接只有一个写者"这条约束从哪来。

3.1 两个 event 对象,两种后端

xnet 的收发路径不创建线程、不做线程间通信。内部有两个事件对象,把读和写拆成两半,
驱动函数挂到宿主的空闲等待钩子上:

main 线程 ── xnet_proc_up(ms)    ── 驱动 up 事件
bus  线程 ── xnet_proc_down(ms)  ── 驱动 down 事件
net  线程 ── xnet 自建 200ms 一圈 ── 空闲超时 / 待释放连接的到期析构 / 重连扫描

哪个事件管哪些读写由 xnet_set_workmode 决定(见 3.3):C 侧默认按侧分离,x3 走 Lua
装配,xnet.init 把模式设成 0(读写分离)—— 读全在 up,写全在 down。

两个事件对象在 Linux 与 Windows 上的落点完全不同,而上层代码完全相同:

Linux Windows
up / down 各是什么 各一个 epoll 实例 各一个完成端口(每个事件对象自己 CreateIoCompletionPort)
一条连接能否挂两处 能 —— 同一个 fd 同时注册进两个 epoll,各关心自己那一半事件 不能 —— 一个 socket 只能关联一个完成端口
事件语义 读走边沿(EPOLLET,一次事件内读到 EAGAIN);写走水平(有残余就一直提醒,发完立刻摘掉) 完成语义:0 字节 WSARecv 探测就绪,再循环 recv 到 WSAEWOULDBLOCK
事件里带什么 epoll_event.data.u64 存 netid,不存 conn* 重叠结构的 user 字段存 netid,同样按它回查连接表

Linux 上的读写分离是真的:up 等 EPOLLIN、down 等 EPOLLOUT,两条线程各自阻塞在
自己的 epoll 上,一个方向没事件不影响另一个方向。Windows 上做不到这一点:socket 关联到
哪个完成端口,它的读完成和写完成就都投到那个端口、由那条线程收尾。于是写由 down 线程
发起、收尾落在读线程 —— 这一条写在 inc/xnet.h 里;Windows 在 x3 里的定位是开发环境,运营环境用 Linux。

3.2 事件里存 id,不存指针

epoll_event.data.u64 存的是 netid,不是 conn*。原因是一条连接会在另一条线程
被关闭并释放(读线程收到协议错就关了),裸指针会变成悬空的。用 id 查表多一次哈希,
换来"事件永远只碰活着的对象"这条性质 —— 一次 use-after-free 会让整个接入进程消失。

关闭路径因此定成四步:标记关闭中 → 从所有关注它的事件里摘掉 → close → 从连接表摘除。
第二步在两个平台上不是同一个动作:Linux 是两个 epoll 各 DEL 一次(apply(c, false, false)
走 EPOLL_CTL_DEL);Windows 的事件层没有注册动作,apply 是空实现,摘除只发生在连接表
与重叠结构上。只关 fd 而漏掉第一步,另一个实例会残留事件,唤醒时 fd 号已被复用,
写进去的就是新连接。

Linux 侧还有一条连带要求:读是边沿触发,连接改名(rekey_conn)换了 id 之后要
io_service::rebind —— 重新 ADD/MOD 一次,既让 data.u64 跟上新 id,也把内核里
那条已经消费掉的边沿重新装上。漏掉这一步,那条连接不再收到任何事件,而且不报错。
Windows 侧不需要:读是每次处理完再投下一次,投递那一刻才取 id。

3.3 两种读写归属

xnet_set_workmode 只改一件事:一条连接挂到哪两个事件上。收发代码、封包格式、回调时序
与模式无关。

模式 读 写 谁在用
读写分离(mode = 0) 所有连接读 up(main) 所有连接写 down(bus) x3:xnet_lua.cpp 的 init 传入 0
按侧分离(mode != 0) listen 侧读 up、connect 侧读 down listen 侧写 down、connect 侧写 up C 侧默认;不经 Lua 装配时维持的行为

C 侧默认是按侧分离,Lua 装配把它改成 0,原因是一条硬约束:出网动作全部由总线投给 bus
线程执行(见 3.4),所以 bus 必须是所有连接的写 owner。按侧分离下 connect 侧的写 owner
是 main,总线投过去的每一包都会被判 bad_owner 丢掉 —— 现象是"连上了、握手也过了,
业务包一条都发不出去"。这个值由 xnet_lua.cpp 的 init 设定,调用方不需要传。

归属只在连接建立那一刻决定(attach),运行期改模式不影响已挂好的连接。

3.4 一条连接只有一个写者

发送只能由该连接的写 owner 线程调用:conn::send_raw 第一件事是比对
send_svc_->is_current(),不匹配就丢弃、bad_owner 加一、记一条 warn —— 不排队、不加锁、
不做跨线程写。理由是两条线程同时往一个 socket 写,字节流会被撕成半包与半包交错,
而这种错误在低并发下不复现。

这条约束不靠调用方自觉:xbus 是出网的唯一出口,它把封包动作投给 bus 线程,
3.3 的模式又让 bus 成为所有连接的写 owner。于是业务代码无论在 main 还是池线程上,
都碰不到 socket,写法上也没有"先拿到连接再写"这条路。

3.5 管理动作单独一条线程

net 线程是本模块唯一自己创建的线程,只做三件时间类的事:空闲(心跳)超时判定、
待释放连接的到期析构、主动连接的重连扫描。它不发任何业务数据,所以不会撕开字节流。

把这三件事从 up / down 里拆出来的理由:它们是纯时间动作,与"哪个方向有事件"
无关。挂在事件的 tick 上就变成"这条线程没被驱动 → 永不断线、永不释放",
宿主只驱动一侧时(单线程测试、纯转发进程)尤其明显。拆出来之后 up / down 只管收发,
net 只管时间,两边都不必知道对方在做什么。

握手补发与心跳这两个发送动作留在写 owner 上,没有搬进 net —— 它们要写 socket。


4. 消息机制

消息层回答四个问题:名字怎么变成 id、谁来收、投给谁、走多远。四个答案都在登记那一刻定下,
运行期只剩整数查表。

4.1 消息名 → msgid

消息名(cmsg_chat / smsg_handshake / tmsg_stat_req)
   │  crc32,登记时算一次
   ▼
uint32 msgid ── 线上只传这 4 个字节 ──▶ 订阅表 / by_msg 路由表 / 日志反查

三条约束构成这套协议:

  • 名字以 tmsg_ / smsg_ / cmsg_ 开头,前缀同时决定广播范围(见 4.4)。
  • id 由 crc32 算,不手写;算出来是 0 的名字要换一个(0 在总线上表示"非法 msgid")。
  • 重名登记幂等:同一个名字算两遍得同一个 id,所以任何进程自己算一遍就能对齐,
    不需要把注册表同步到每台机器。

common/proto/init.lua 把各接收者的协议文件聚合起来,消息名→id 是进程级一张表,
日志、路由、add_svc 都查它。加一条消息 = 登记名字 + 写 handler,两端立刻能用,
没有 IDL、没有代码生成步骤。

4.2 订阅与归属

xbus 的 listen 只对当前线程生效:handler 挂在登记它的那条线程上,之后的消息投递
只往那条线程投。由此得到"登记线程就是归属线程",不存在跨线程代登记。

enter(objid, obj) 把目标和当前线程绑死:之后 send_to(dstid) 命中时,handler 的第一个
参数就是那个对象。一个线程可以绑多个对象,一个对象只能绑一条线程。

4.3 登记与投递

登记只有三件事:

接口 语义
add_msgname(name) 登记消息名,返回 crc32(name);重名幂等
listen(msgid, fn) / listen{...} 订阅;只对当前线程生效
enter(obj) 把"目标对象"绑到当前线程,send_to(dstid) 命中时 handler 第一个参数就是它

投递有四种,区别只在"选谁":

接口 选谁 说明
send(msgid, ...) 订阅者(多线程时按轮询或 consid) 不知道接收者是谁,只知道消息名
send_to(dstid, msgid, ...) 按对象找线程 只投给绑定了 dstid 的那条线程
send_by(consid, msgid, ...) 按一致性键找线程 同一个 consid 恒落同一条线程
call(..., cb) / call_to(dstid, ..., cb) 按对象找线程,并且等它回话 应答回到发起线程的回调

broadcast(msgid, ...) 是 send 的广播版本,范围由消息名前缀唯一决定,调用方不传范围。
本地模块与远端服务用的是同一批函数,业务代码不需要知道对方在哪台机器上。

4.4 广播范围由前缀决定

前缀 范围 说明
tmsg_ LOCAL 本进程所有订阅线程,xbus 自己扇出,不出网
smsg_ SVCS 所有服务连接(服务间广播)
cmsg_ GATES 所有网关,网关再下发给自己下面的客户端

xbus_send 的 bc 参数只是一个开关(要不要广播),范围它决定不了 —— 范围写死在名字里。
这样"这条消息能走多远"在读协议表的时候就能看出来,不需要追到调用点。

4.5 call 与 reqid

call 立刻拿到 reqid,对方回包按 reqid 找回等待者,回调在发起调用的那条线程上
执行(不跨线程碰 lua_State);30 秒未回自动丢弃并告警。C++ 侧另有带返回值的 call_args,
类型由编译期推导。

reqid 在线上封装里位于 msgsize 段内部,和消息体一起被加密压缩,所以任何
"不解密就读 reqid"的路子都不成立 —— 网关按 reqid 配对、转发,都发生在解密之后。

4.6 序列化

消息体用 msgpack(bus.pack / unpack),根是实参数组 —— 服务端 handler 解出来的
就是客户端发的那几个实参,test-client 就是靠这个跟集群对话的。msgpack 的类型是自描述的,
lua 那边不用声明结构;代价是没有字段编号,schema 变化只能靠双方约定,
这一点在第 14 节里列为已知限制。

C ABI 那层 handler 的形参位置是固定的:(netid, owner, msgid, args, result) —— netid 是
消息从哪条连接来(进程内是 0),owner 是 enter 进来的对象,位置固定,不会少一格。
Lua 侧是"能省就省"的版本:enter 过才有第一个位置;smsg 且 netid != 0 才多塞一个
netid;cmsg 未绑定时第一个实参是客户端在网关侧的连接 id(回包要用)。

4.7 一条 cmsg 走过的六跳

把前面几节串起来,一条业务消息从客户端到应用服再回来,只有两次真正跨线程:

① 客户端      xgate.cli_connect{endpoint, ticket, master}
               → 32 字节首包:ticket(8) + 8 + 16 字节密文,AAD = "x3-hello"
② 网关外网口  验票(票一次性,用完即删)→ HKDF-SHA256 派生上行 / 下行两把密钥
③ 客户端发    flag | [dstsize|dstarr] | msgsize | [reqid] | msgid | msgbody
               msgsize 段整体压缩 + 加密,flag、msgsize、idx 恒明文
④ 网关        main 线程读到 → 解密 → 解压 → 切出 msgid / reqid / body
               → 选目标(钉住的应用服优先,否则按 msgid 路由)
               → 投到 bus 线程写出(写 owner,见 3.4)        ← 跨线程 ①
⑤ 应用服      main 线程读到 → xbus 按 msgid 投给订阅线程
               handler 就在订阅线程上就地执行(通常就是 main)
⑥ 回程        handler 里 send_to(来源 netid, cmsg_xxx_ret)
               → 总线投到 bus 线程 → 写回网关                  ← 跨线程 ②
               → 网关把 dstid 当客户端连接 id,加密下发

每一次跨线程都是同一种动作:把包连同目标线程投过去,收件线程自己去写。没有锁,也没有
"等着对方写完"这件事 —— 所以这条路径上的任何一环变慢,都不会把上游卡住,只会让队列变长。


5. 线程模型

执行体是线程。这一章讲线程怎么被托管、lua_State 怎么绑上去、一个周期怎么用掉,
以及为什么"没有调度器"在这里是一个可以承受的选择。

5.1 三种线程

   ┌─ main 线程 ─────────────────────────────────────────────────────
   │   main.lua、登记在 main 上的 handler、定时器
   │   驱动 up 事件:所有连接的读
   └──┬──────────────────────────────────────────────────────────────
      │ post_bus:发送、投递
   ┌──▼──────────────────────────────────────────────────────────────
   │ bus 线程
   │   总线队列 → 按 msgid 扇出 → 封包头 → 驱动 down 事件:所有连接的写
   └──┬──────────────────────────────────────────────────────────────
      │ post:扇出到订阅线程、把应答投回发起线程
   ┌──▼──────────────────────────────────────────────────────────────
   │ 池线程 tpool1..N、自建业务线程(每条自带 lua_State)
   └─────────────────────────────────────────────────────────────────

   net 线程(xnet 自建,200ms 一圈):心跳超时 / 待释放连接析构 / 重连扫描,不发业务数据
  • 固定线程:main(跑 main.lua 与业务回调、驱动 up 事件)、bus(消费总线队列、
    扇出、驱动 down 事件)、net(xnet 自建的纯时间线程)。
  • 池线程:xtpool 建的 N 条,xtpool_alloc(key) 对同一个 key 恒返回同一条线程。
  • 业务线程:由 xthread_create_lua 起的任意条,每条自带 lua_State,跑自己的入口脚本。

所有线程都由 xthread 托管,统一具备:一个任务队列(post)、一组周期回调(runcb)、
一个空闲休眠周期、一个超时告警阈值。xthread_stops() 一次置位,全部线程跳出循环。

5.2 一线程一 lua_State

xthread_create_lua 建 state、配搜索路径、loadfile + pcall;线程退出时 lua_close
也归它。所以跨线程永远不会碰同一个 state —— 这是"业务侧没有锁"的另一半原因:
Lua 的 lua_State 本身就不是线程安全的,把它绑死在线程上,比给它加锁便宜得多。

代价是没有协程级的并发:同一线程上的多个 owner 共享一份 lua_State 和同一个 tick,
一个长任务会拖住同线程的其它模块。这一点靠 xtimer 的每轮预算(k_budget /
k_catchup)缓解,而不是靠调度器把它切走。

5.3 runcb:周期怎么用掉

xthread 的周期回调返回值是"本回调自己休眠了多少毫秒",不是"要不要再来一轮":

  • 返回 0:只做了一轮非阻塞的活,没睡。
  • 返回 > 0:自己睡了这么多(典型是 epoll_wait / select / wait_for 把周期用掉了)。

一轮里只要有一个回调报 > 0,线程就不再额外休眠;全都报 0,线程睡"周期 − 本轮已耗时",
周期从轮次开始算,不往后拖。超时告警会扣掉自报的睡眠,等待本身不计入超时。

这套约定让"事件驱动的线程"和"纯周期线程"用同一个抽象:xnet 的两个 proc_* 报自己
在 epoll_wait 里花掉的时间,业务侧的周期任务报 0,两者都不需要知道自己跑在哪种模式里。

5.4 xtimer 与 xtpool

  • xtimer 的定时器挂在当前线程:注册、删除、触发都在本线程,不走跨线程队列,
    也没有锁。名字在同一 owner 下唯一;owner 的作用是查重与回调透传。
    单次回调默认 15ms 预算,超了打 WARN —— 这是"长任务拖住同线程"的第一道观测手段。
  • xtpool 是线程池:xtpool_alloc(key) 在 key 非 0 时按 key % 线程数 选,
    key 为 0 时按 rr_ 轮询;同一个 key 恒得同一条。需要"某个玩家的活固定一条线程"时用它,
    比自己维护 id→线程映射省事。

5.5 线程收尾

xthread_stops() 一次置位,全部线程跳出循环;xthread::stop_all 不 join 线程对象
(对象与进程同寿,真正的 join 在静态析构期),但会等每条线程真的跳出循环,再留 500ms
让它们收尾。此后任何创建入口都返回失败 —— 退出途中冒出来的新线程活不到被 stop。

进程级的退出链条、以及每一步为什么不能换序,见第 7.6 节。


6. 微服务集群

集群由三个角色组成,每个角色只做一件事。这一章讲它们怎么分工、位置怎么算出来、
一次登录怎么走完,以及网关那一层为什么刻意不做判断。

6.1 三个角色,各管一件事

进程 insid 内网地址 额外端口 角色
xa_registry 11 127.0.0.1:9000 — 注册中心:登记 / 全量下发 / 新服务广播 / 保活
xa_gate 301 127.0.0.1:9001 客户端 9101 网关:验票、加解密、转发、钉住后端
xa_login 201 127.0.0.1:9002 — 登录:按 userid 取模选应用服并钉住会话
xa_app ×2 101 / 102 :9003 / :9004 — 业务服(多开 = 同目录 + 不同 insid/endpoint)
xa_sched 401 127.0.0.1:9005 HTTP 9006 调度:挑网关、发票据、下发客户端口

分工的原则是谁也别替别人记状态:注册中心只存"谁在哪个地址",不做决策;
调度中心只做"这次给谁",不存目录;网关只做"这条连接转发给谁",不管业务。
三个角色各自可以单独重启,重启后从注册中心重新对齐。

一次接入的方向是这样走的:

                      ┌──────────────────────────────────────┐
   客户端 ── 9101 ───▶│ xa_gate 301(外网 9101 / 内网 9001) │
                      └───────┬──────────────────┬───────────┘
                              │ 内网              │ 内网
                              ▼                  ▼
                       xa_login 201        xa_app 101 / 102
                              └─── 选出落服 ─────▶

   客户端 ── HTTP 9006 ──▶ xa_sched 401        挑网关、发票据、给客户端口
   xa_registry 11(:9000)◀── 以上全部注册、保活、收全量列表

6.2 位置是算出来的

选应用服是三行:regcli.of_app("app") 拿在线列表(按 insid 排序),
list[(userid % #list) + 1] 取一台。同一个 userid 每次都落同一台,
所以不需要一个"位置服务"记住"这个玩家在哪台机器上"。

xnet 的按 msgid 路由用的是同一条算式:候选实例按 insid 排序后
consid % 候选数 选一台,consid == 0 时走轮询(pick_index,src/xnet/xnet_router.cpp);
xbus 在同进程有多个订阅线程时也是 consid % 订阅线程数。

这是"算出来的"与"查出来的"的分界:后者需要一个有状态的目录节点,任何一次寻址都要问它,
它挂了整个集群失忆;前者任何一台机器都能独立算出同一个答案,没有单点,
也没有"位置表与实际不符"这类不一致。

代价是实体不能随意迁徙:要换实例就得换 userid 的落点,而 userid 是玩家身份的一部分。
换来的是位置查询零成本、没有单点。

6.3 注册与发现

启动  ──▶ 向 xa_registry 注册(自报 appname / insid / endpoint / 消息清单)
      ◀── 收全量列表(smsg_reg_welcome)
      ◀── 收新服务广播(smsg_reg_new)
      ──▶ 按 insid 规则建链:大的拨小的、同 appname 副本互不拨
      ──▶ 保活:定时 smsg_reg_ping

消息清单从 proto.mid 取,协议只写一处。注册中心的保活超时是静默剔除(不广播下线),
客户端侧靠连接断开感知;同集群实例断开满 3 分钟从本地列表删除,这期间重连由 xnet
按拨号规则自动完成。

网关拨后端服务走 xgate.add_svc 而不是普通的 net.add_ins —— 网关要顺带登记转发路由的
认领清单(哪些 appname 的目标它接得住),这是它区别于普通服务的一处。

6.4 一次完整链路

① 调度   http.get(sched, "/sched?account=xxx")
          → {"code":0,"gate":"ip:port","ticket":N,"key":"<64hex>","ttl":ms}
② 接入   xgate.cli_connect{endpoint=网关客户端口, ticket, master}   ← 首包验票,派生两把方向密钥
③ 登录   cmsg_login [account, prefer] → cmsg_login_ret [ok, code, msg, userid]
④ 业务   cmsg_chat ×2 → cmsg_chat_ret [ok, where, echo]            ← 两次落在同一台应用服

insid 是 16 bit,全簇唯一,同时也是雪花算法的机器号。网关在 ① 之后拿到 ticket 与
32 字节 master,首包校验票据后由 HKDF-SHA256 派生上行 / 下行两把密钥,之后
chacha20-poly1305 加密、LZ4 按块压缩。

网关的消息转发不做业务判断,只做三件事:验票、解密、按 dstid 下发。dstid 到了网关
直接当客户端连接 id,不查表;dstid = 1 是保留值,表示"本网关下所有客户端"。

xgate 内部按单元拆开,每个单元只认一件事:

单元 做什么
xgate_ticket 一次性接入票据(调度服推过来),首包校验后作废
xgate_crypt AEAD 形态加解密,由 xgate_pack 直接调,不经 xnet
xgate_zip LZ4 形态压缩 / 解压
xgate_pack 三个入口:外网首包验票 / 外网业务包解密转发 / 内网包双向
xgate_handshake 握手钩子:记对端服务类型(xnet 只认实例号,不认角色)

客户端口 = 网关内网口 + client_off(demo 里 100,9001 → 9101)。
这个偏移在 xa_gate/local_runcfg.lua 与 xa_sched/local_runcfg.lua 各写了一遍,
两处是同一个数字的两份拷贝。

6.5 网关的限速设计

doc/03-网关CD算法设计.txt 记录的是一套消息级限速(CD,cool down)的算法设计:
以毫秒为单位的信用桶,credit 按真实时间懒补充、封顶 span,判定门槛是 0.9 × cd_ms,
消费的是完整的 cd_ms。

三条性质是这套设计的全部理由:

  • 可累积 —— 空闲 300ms 就有 300ms 信用,允许 TCP 挤压后的连发。
  • 有上限 —— 补充封顶 span,挂机一小时也只能攒到 span。
  • 容差只偶发生效 —— 门槛 0.9T、消费 T,偶发提前 10% 靠下一包补回;持续提前 10%
    每包净亏 10ms,信用耗尽后被拦,长期速率恒为 1/T。

它与 GCRA 的差别正在第三点:GCRA 的 τ 会让持续提前的客户端永久提速 11%,
而这里容差是"可以小额透支",透支由后续间隔偿还。判定为加速的包直接丢弃,不排队、不延迟 ——
排队会把队头阻塞引入转发路径,而合法包恰恰最需要低延迟。


7. 模块与插件化

7.1 一个模块 = 一个动态库 + 一张 C 表 + 一份语言绑定

inc/x*.h           C ABI 声明(C++ 糖写在同一文件)
src/x*/x*.cpp      实现
src/x*/x*_lua.cpp  luaopen_*:一张 luaL_Reg 交给 xlua::open_module
bin/x*.dll / .so   产物

每个模块同时导出两套入口:一组 X*_API 的 C 函数,和一张 lua 表。lua 侧看不到 C 结构体,
只看到函数与 userdata 句柄;C 侧看不到 lua 表,只看到函数指针。两边通过
xlua_module.h 的 open_module + set_field_* 对齐,注册表就是一张 luaL_Reg 数组。

7.2 宿主只链三样

x3.exe 只链 xthread / xbus / xlog,其余模块按需加载:

  • 不用的模块不加载,进程启动快,内存占用只跟用到的功能有关。
  • 模块的依赖关系是单向的:xid → xlog、xbus → xthread + xid、xnet → xbus + xthread,
    没有环。
  • 模块私有单例叫 M,用按值静态 static M md_; + 自定义 ~M() —— so 卸载时才析构,
    加载期初始化、卸载期释放都由模块自己负责,不依赖任何一方替它收尾。

7.3 模块之间不互相 include,也不互相调用

httpd 允许模块调 ap_*,nginx 允许调 ngx_*;这里只允许发消息。耦合因此更松,
代价是跳数更多 —— 一次跨模块动作要过一次总线的分发,而不是一次函数调用。

这条规则有一个例外,是"公共基础设施只有一个实现":xplat.h(socket / 地址 / 超时)、
xendian.h(字节序读写)、xlua_module.h(模块注册)、xbuf.h。这四个是头文件形式的
共享件,不是模块,也不导出符号。做成一件事而不是各写一份,是因为平台相关的错误
(字节序、socket 关闭顺序、超时精度)一旦有第二份实现,就会有一份是错的。

7.4 平台层只有一个分叉点

xplat.h 是全项目唯一允许出现 socket #ifdef 的地方。它提供 socket 类型、地址解析、
非阻塞设置、errno 归一、线程号、单调时钟这些原语;xnet 内部几百处 plat::xxx
通过 xnet_platform.hpp 的两行别名引用它,所以加一个平台只需要改 xplat.h 一处。

Windows 与 Linux 的连接状态机是同一套:accept / connect / 收 / 发 / 关五个动作,
只有底下的系统调用分叉。这样 Windows 上调通的逻辑在 Linux 上不会因为跨线程而失效,
反之亦然。

7.5 有序初始化

C++ 模块的初始化分两段,中间隔着一个"main 线程还不存在"的窗口:

① 静态初始化期   各 cpp 单元自己注册
                 xthread::post_main<"xgate 消息登记">(&register_msgs)
                 (此时不能直接 xbus_listen —— 它只认当前线程,而进程里还没有托管线程)
                          │
                          ▼  挂进 pending 队列(FIFO)
② main 登记后    xthread_set_main(t)  →  按投递顺序补投  →  在 main 线程上执行

三条性质:

  • 没有集中清单。 新增一个单元,只写自己的 init() 与
    namespace { auto _ = init(); },宿主和别的单元都不用改。这是"一个模块一个动态库"
    在源文件一级的对应做法:模块的初始化代码跟着模块走,不跟着宿主走。
  • 顺序可复现,但不承载依赖。 队列是 FIFO,投递顺序就是静态初始化的完成顺序
    (同一个编译单元内按定义顺序)。模块之间不靠这个顺序建立依赖 —— 每个模块只订阅自己
    那几条消息,谁先跑结果一样。所以这个顺序是给诊断用的(日志里能看出谁先初始化),
    不是正确性的一部分。
  • 早投不丢。 在 xthread_set_main 之前投的任务挂起,登记那一刻补投;
    登记之后投的直接进线程队列。模块在静态期就能"预约"一条 main 线程上的初始化,
    不必关心宿主什么时候把 main 线程起来。

Lua 侧不用队列,把顺序直接写进入口:main.lua 依次做 算目录 → boot → 起 stat 线程
→ run(boot),业务的登记全在 run 里。顺序只有一处,读那五行就知道谁先谁后,
不需要在别的地方再维护一份初始化清单。

7.6 有序退出

tmsg_close 广播  →  xbus::wait_idle()  →  xthread::stop_all()
              →  最后一条日志  →  xlog::flush()  →  各 so 静态析构

每一步都在前一步收尾之后才开始,顺序不能换:

步骤 做什么 放在这一步的理由
tmsg_close 广播 通知所有模块"要退了" 广播本身要过总线,要趁线程还在
xbus::wait_idle() 排空还在飞的消息 先停线程再排空,消息会投给已经跳出的线程
xthread::stop_all() 置停止位、等每条线程跳出循环、留 500ms 收尾 线程里可能还有半条业务链,前面两步给它们让出了时间
最后一条日志 只走 fprintf(stderr) 日志队列的落盘线程此时已经停了,再入队没人写
xlog::flush() 把队列里剩下的写完 在最后一条日志之后、析构之前
各 so 静态析构 ~M() 收自己的资源 此时已无线程在用这些模块,最后一步

退出入口只有两个:Ctrl+C(SIGINT)与 x3 -stop,两者执行同一条函数。-stop 从命令行
找到同目录的实例、投递退出请求,等价于按一次 Ctrl+C。

taskkill /F(TerminateProcess)是另一个方向:内核直接收走进程,上面六步一步都跑不到,
tmsg_close 不广播、总线不排空、线程不跳循环、日志队列不落盘。所以停止统一走 -stop,
它和 Ctrl+C 是同一条路径 —— 这也是"有序退出"能成立的前提:退出由进程自己执行。


8. Lua 分层与装配

xa_<app>/main.lua           进程入口:一副固定骨架
xa_<app>/local_runcfg.lua   本进程独有的配置(M.app 进程形态 / M.biz 业务参数)
xa_<app>/<业务>.lua         业务实现,对外只有 run(boot)
xi_<app_type>/              同类型进程的公共实现(app_type = 目录名去掉 xa_ 前缀)
common/boot.lua             进程怎么起来(路径 / 身份 / 日志 / xnet)
common/cluster/             起来之后怎么说话:svc、regcli、runcfg、stat
common/proto/               消息协议(名字→id、接收者清单)

五个服务进程的 main.lua 是同一副骨架,差别只在最后一行(test-client 少一条 stat 线程、
多一行 require("proto"),末尾用 os.exit(退出码) 把结果交给启动脚本):

local main_dir = <本文件所在目录>                 -- 从 debug.getinfo(1,"S").source 截出来
package.path = main_dir .. "/../common/?.lua;" .. package.path
local boot = require("boot")(main_dir)            -- 网关与客户端多传一个 { net = false }

require("xthread").create("stat", boot.comm_root .. "/cluster/stat.lua", 5, 20)
return require("app").run(boot)                   -- ← 唯一不同的那一行
  • 目录由入口算:boot(main_dir) 拿它推出 proj_root / comm_root / xi_app_root /
    log_root / bin_root;不传时退回 script_dir()(沿调用栈取宿主直接跑的那个 chunk)。
    目录一律绝对路径,宿主拒绝相对路径。
  • 身份来自目录 + 命令行:appname = main.lua 所在目录名(命令行不传,避免两处不一致),
    app_type = 目录名去掉 xa_ 前缀;insid / endpoint / peers 来自命令行。
  • 配置分两层:common/cluster/runcfg.lua 放全集群一致的值,local_runcfg.lua 只写
    本进程不一样的那几项,boot 深合并上去,同名 key 以本文件为准。M.app 是进程形态
    (框架读),M.biz 是业务参数(业务读)。
  • 没有部署表:本进程只知道自己的身份,以及注册中心现场告诉它的"集群里有谁"。
    common/proto/init.lua 把各接收者的协议文件聚合起来 —— 消息名→id 是进程级一张表,
    日志、路由、add_svc 都查它。

8.1 一个 run,一处装配

业务的入口只有一个:run(boot)。它里面做三件事 —— 向注册中心登记并发现对端
(cluster.regcli.start(boot))、订阅消息(bus.listen(H))、打一行就绪日志。
main.lua 里除了这行调用只有路径与 stat 线程,所以"这个进程装了哪些模块"看 H 表,
"什么时候开始工作"只有一个答案:run 返回之前。

网关与客户端是在这副骨架上换个填法:{ net = false } 关掉 boot 自带的网络初始化,
网关的 run 自己按顺序做 register_routes → start_gate → start_registry,
客户端的 run 自己拨号。入口形状不变,变的是里面那几步。

8.2 进程 = 目录

加一台同类型进程 = 同一份目录 + 不同 -insid / -endpoint,不复制目录。
xa_app 起两台就是这样做的:main.lua 一份,101 和 102 只是命令行不同。
同类型的公共实现放 xi_<app_type>/,业务差异留在 xa_<app>/ 里。

appname 从目录名取,这一点因此不靠纪律:复制目录就等于换了一个服务名,
"两处写得不一致"的机会在结构上就没有。

8.3 boot 与 cluster 的分工

boot.lua 只负责"怎么起来":路径、配置、身份、日志、xnet.init / xnet.listen。
它是一个工厂函数,返回一张表(appname / insid / endpoint / cluster / runcfg /
comm_root …),业务要什么从这里取。

cluster/ 负责"起来之后怎么说话":svc(身份与对端清单,纯数据)、regcli(注册与发现,
由 run 调 .start(boot))、runcfg(公共配置,被 boot 读走)、stat(统计线程的入口
脚本,由 xthread.create 拉起)。四个文件里只有 regcli 带启动动作,其余两个是表、
一个是线程脚本。

网关与客户端给 boot 传第二个参数 { net = false } —— 这两个进程的网络栈不由 boot 起。
网关把注册发现放在 xgate.start 之后,并覆盖 regcli.dial 走 xgate.add_svc。
两个例外的原因相同:网关的两个 listener 要先于注册发现就位,否则第一批向它注册的连接
没有地方落。

8.4 一个业务模块

xa_app/app.lua 是最短的完整例子,去掉日志与注释后只剩这些:

local bus = require("xbus")

local where          -- "xa_app@127.0.0.1:9003",run 里赋值
local H = {}         -- handler 表:键是消息名,值是函数

function H.smsg_user_login(netid, client_netid, account, userid, gate_insid)
	bus.send_net(netid, "smsg_user_login_ret", client_netid, true, "ok")
end

function H.cmsg_chat(client, netid, text)
	bus.send_via(netid, client, "cmsg_chat_ret", true, where, text)
end

local M = {}

function M.run(boot)
	where = boot.appname .. "@" .. boot.endpoint
	require("cluster.regcli").start(boot)     -- 向注册中心登记、拿对端列表
	bus.listen(H)                             -- 把 H 里每条消息登记到当前线程
	return M
end

return M
  • H 的键就是消息名,bus.listen(H) 一次把整张表登记完,登记在哪条线程就归哪条线程。
  • handler 的形参由消息名决定:smsg 首参是 netid,cmsg 首参是 client(客户端在网关
    侧的连接 id),后面才是消息体。每个 common/proto/*.lua 都在名字下面注了形参顺序。
  • send_net 沿一条连接发;send_via 额外带目标段 —— 服务回包给客户端要用它,
    否则网关不知道下发给哪个连接。

加一条消息是三步:

  1. 在接收方的 common/proto/<receiver>.lua 里加一行 M.cmsg_xxx = common.def("cmsg_xxx"),
    并在下面注释形参;
  2. 接收方写 H.cmsg_xxx = function(...) ... end;
  3. 发送方 bus.send("cmsg_xxx", ...),或按场景用 send_to / send_via / call。

名字一登记,两端同时可用:proto.mid 是进程级的一张表,日志、路由、add_svc 都查它,
没有中间文件要生成。


9. 目录与模块

9.1 目录树

x3/
├── src/                     C/C++ 辅助层(每个子目录 = 一个模块 = 一个 dll/so)
│   ├── x3/                  宿主:建 main 线程跑 main.lua、信号、退出流程、-stop
│   ├── xthread/ xtime/ xlog/ xfile/ xflag/ xid/ xtpool/
│   ├── xbus/ xnet/ xtimer/ xgate/ xhttp/
│   └── CMakeLists.txt       顶层构建(产物统一输出到 ../bin)
├── inc/                     对外头文件(C ABI + C++ 糖);xgate 的头在 src/xgate/ 下
├── bin/                     所有产物:*.dll / *.so / *.dll.a / x3(.exe) / lua 运行库
├── 3rd/                     第三方:lua、msgpack-c、lz4、mbedtls
├── common/                  Lua 公共层
│   ├── boot.lua             进程启动引导(工厂):路径 / 身份 / 日志 / 网络
│   ├── cluster/             运行期公共件:svc 身份、regcli 注册发现、runcfg、stat 统计
│   └── proto/               协议:消息名→id、接收者清单、字段说明(唯一一份)
├── xa_registry/             注册中心(insid 11,:9000)
├── xa_gate/                 网关(insid 301,:9001 → 客户端口 :9101)
├── xa_login/                登录服(insid 201,:9002)
├── xa_app/                  应用服,可多开(insid 101 :9003 / 102 :9004)
├── xa_sched/                调度服(insid 401,:9005;HTTP 分配口 :9006)
├── xi_app/                  同类型进程的公共实现(impl 层)
├── test-client/             客户端剧本(走完整链路:调度→接入→登录→业务)
├── test/                    线程调度 demo(xbus / xthread / xtimer 的用例集)
├── doc/                     设计文档:协议、网络组件、网关限速算法、C/C++ 编码规范
├── log/  run/               运行期日志 / 整簇的 stdout 与 pid
├── handlers.txt             xbus 生成的 listen 签名清单(debug 首次登记时才出现)
├── build_win.bat            Windows 构建(MinGW + Ninja → build/win)
├── build_linux.sh           Linux 构建(→ build/linux),build_linux.bat 是 WSL 包装
└── start_all.bat / .sh      一键起整簇;stop_all.bat / .sh 优雅停

9.2 模块一览

模块 产物 职责 头文件
x3 x3 / x3.exe 宿主:建 main 托管线程跑 main.lua,装信号,跑退出流程,-stop 优雅停 —
xthread xthread.dll/.so 托管线程:任务队列 + 周期回调 + 超时告警;每线程一个 lua_State inc/xthread.h
xtime xtime 单调时钟 / 墙上时钟(ns、ms) inc/xtime.h
xlog xlog 异步日志:多线程入队、单线程落盘、按进程+日期分目录、{} 占位 inc/xlog.h
slog slog 第二个 logger(同实现,换个名字给 Lua 用) —
xfile xfile 路径规范化 / 读文件 / 目录操作 inc/xfile.h
xflag xflag 命令行参数注册(-insid、-endpoint、-peers …) inc/xflag.h
xid xid crc32/crc64、名字→id 表、雪花 gen_id、一致性哈希 consid inc/xid.h
xtpool xtpool 线程池;xtpool_alloc(key) 把同一个 key 固定到一条池线程 inc/xtpool.h
xbus xbus 消息总线:消息名→id、listen/enter、同进程扇出、出网封包(msgpack) inc/xbus.h
xnet xnet TCP:连接(拨号/监听/心跳/重连)、编解(封包/切包/校验)、路由(服务表) inc/xnet.h
xtimer xtimer 每线程时间轮;回调在本线程同步执行;单次回调超时告警 inc/xtimer.h
xgate xgate + 静态库 网关:验票 / AEAD 加解密 / LZ4 压缩 / 转发 / 钉住某个后端实例 src/xgate/xgate.h
xhttp xhttp HTTP 基础服务:listen + path → handler,业务在 Lua inc/xhttp.h
xbuf / xendian / xplat / xlua / xlua_module 头文件 缓冲区、字节序、平台层、Lua 绑定基础设施 inc/*.h

9.3 依赖方向

xid ──▶ xlog
xthread ──▶ xid, xlog
xbus ──▶ xthread, xid, xlog
xnet ──▶ xbus, xthread, xid, xlog
xgate ──▶ xnet, xbus(静态库,显式 start)

xnet 与 xgate 之间是一次显式的 xgate::start(cfg):它按固定顺序挂通道回调、
连接钩子、总线订阅,最后才 xnet_init 与 xnet_listen_ex。这个顺序错了的表现是
"第一批注册上来的服务连不上",所以顺序写在 xgate.h 的注释里。

9.4 配置速查

配置只有两层,boot 深合并后挂在 boot.runcfg:common/cluster/runcfg.lua 放全集群一致的
值,xa_<app>/local_runcfg.lua 只写不一样的那几项(同名 key 以它为准)。下表首列为空的行
表示沿用上一行的文件。

文件 段 键 含义与缺省
common/cluster/runcfg.lua cluster — 集群名,"dev";进握手首字段
env — 环境名,"dev";只用于日志与展示
registry insid / appname / endpoint 注册中心身份:11 / "xa_registry" / 127.0.0.1:9000
net workmode 读写归属:0 = 读全在 up、写全在 down
heartbeat_ms / idle_timeout_ms 心跳 3000 / 空闲断链 30000
max_uppack / max_downpack 上行 / 下行单包上限,各 64K
send_limit 单连接发送队列上限(字节),10M
log debug / monitor_ms debug 开关 / 线程卡住告警周期 30000
xa_gate/local_runcfg.lua gate client_off 客户端口 = 内网口 + 它,100
hello_timeout_ms 首包验票超时 5000
client_max_recv / client_send_limit 客户端收包上限 4096 / 队列 0 = 沿用公共值
crypt require / ticket_ttl_ms 客户端包必须带加密位 / 票据有效期 5000
zip min / max LZ4 压缩区间 256 ~ 64K
forward pin_fallback 钉住的后端断线后是否回落按 msgid 路由,false
xa_sched/local_runcfg.lua sched http_port / ticket_ttl_ms / client_off 调度口 9006 / 票据 5000 / 偏移 100(与网关同一个数)
xa_app/local_runcfg.lua app listen / impl 是否监听内网口 true / 用哪个 xi_ 实现目录(nil = 目录名去掉 xa_)
biz 业务自定义 示例是 tick_ms = 50

四条分界线决定了什么该写在哪:

  • 身份不进配置。 appname 来自目录名,insid / endpoint 来自命令行。配置里再写一份,
    就有两个真相。
  • 协议不进配置。 msgid 在 common/proto/,改它等于换协议;配置改的只是运行参数。
  • 全集群一致的值放 common。 心跳、单包上限、发送队列不一致时,症状是"一边断一边好"。
  • 端口用偏移。 客户端口写 client_off 而不是写死 9101,换机器只改 -endpoint,
    外网口跟着走。

9.5 Lua API 速查

4.3 讲投递语义,这里只列名字,便于查接口。require 进来就能用,模块名与 dll/so 同名:

模块 常用函数
xbus add_msgname / listen / enter / leave / list / send / send_to / send_net / send_via / send_by / broadcast / call / call_to / pack / unpack / wait_idle
xthread create(name, entry, sleep_ms=5, timeout_ms=20) / curr / curr_name / curr_id / stat
xtimer add(name, ms, [owner,] fn [, timeout_ms]) / add_one(name, delay_ms, …) / add_count(name, ms, count, …) / del / stat
xnet init / listen(port) / connect(endpoint) / add_ins{…} / del_ins / close / endpoint / alive / peer_insid / insid_netid / app_netids / is_internal_msg / stat
xgate start{…} / add_svc(insid, appname, endpoint) / add_msg(msgid, kind, appname) / stop / cli_connect / cli_send / cli_recv / cli_close
xhttp listen(port) / on(path, fn) / get(url, cb) / stop
xlog info / debug / warn / error(各有 _fmt 版)/ init / set_debug / is_debug / set_thread_name / stop
xid crc32 / crc64 / gen_id / gen_consid / addname / name2id / id2name
xtpool create / count

三条与调用位置有关的约定:

  • xnet 的 workmode / heartbeat / 包上限要在 init 之前设,init 之后返回 < 0。
  • listen / enter / timer.add 都只作用于当前线程,在哪条线程调就归哪条线程。
  • call 的回调在发起调用的那条线程上执行;跨线程与跨进程的写法一致。

函数签名与参数细节写在 inc/*.h 和 src/*/*_lua.cpp 的注释里,每个 lua 绑定函数上方都有一段
用法示例。


10. 构建

依赖:CMake ≥ 3.20、Ninja;Windows 用 MinGW-w64(C:\mingw64\bin 在 PATH),
Linux 用 g++(WSL 可直接编)。第三方都在 3rd/(lua、msgpack-c、lz4、mbedtls),
不需要额外装。

工程按 C++20 与 64 位写死:CMAKE_CXX_STANDARD 20,32 位工具链在 configure 阶段
直接 FATAL_ERROR,免得 32 位产物混进 bin/。

:: Windows(默认 Debug;第一个参数可给 release)
build_win.bat release
# Linux
./build_linux.sh release
# 在 Windows 的 cmd 里编 Linux(走 WSL):
build_linux.bat release
  • 产物统一落在 bin/:xthread/xtimer/xtpool/xbus/xtime/xlog/slog/xfile/xflag/xid/xnet/xhttp 的
    dll/so + 导入库 *.dll.a,静态网关库 libxgate_{win,linux}.a,宿主 x3(.exe)。
  • 构建目录:build/win、build/linux,互不干扰。
  • 脚本末尾有产物自检:缺哪个会打印 [MISSING] 并以非 0 退出。
  • 仓库里的 .bat 是 CRLF,用 LF 覆盖会解析错乱(报 'st' 不是内部或外部命令 之类)。

11. 运行

11.1 线程调度 demo(test/)

15 个用例:send_to / 自投递 / 池轮询 / send_by 一致性 / call / call_to / 自己 call 自己 / 广播 / 广播含自己 / send_net 无钩子 / 64KB 大包 / 5000 条高频 / 复杂载荷(单向+往返) / 统计上报 / 定时器(30 秒持续运行)。

:: Windows
test\start.bat
# Linux
./test/start.sh

跑完打印 [FINAL] 通信 + 定时器 15/15 通过,然后停在"等 tmsg_close 广播"。这个停顿是
正常状态:demo 不自己退,退出走 Ctrl+C 或 x3 -stop,两者都是
tmsg_close → bus 排空 → 线程收尾 → 日志落盘这条链。

判定口径都写在 test/test_main.lua 里:每个用例先打 [CASE] 说明预期,
末尾 [PASS]/[FAIL] + [FINAL] 汇总,子线程计数靠 tmsg_stat_req 上报回来核对。

11.2 整簇 + 客户端

./start_all.sh          # 起 registry + gate + login + app×2 + sched
./test-client/start.sh -sched 127.0.0.1:9006 -account alice
./stop_all.sh           # 优雅停(走 x3 -stop,与 Ctrl+C 同一路径)
:: Windows:每台一个控制台窗口
start_all.bat
test-client\start.bat -sched 127.0.0.1:9006 -account bob
stop_all.bat

期望输出:

[cli] ① 调度 OK(127.0.0.1:9006 → gate=127.0.0.1:9101 ticket=...)
[cli] ② 接入 OK(客户端口 127.0.0.1:9101)
[cli] ③ 登录 OK(msg=ok userid=...)
[cli] chat#1 OK where=xa_app@127.0.0.1:9003 echo=alice#...:hello-1
[cli] chat#2 OK where=xa_app@127.0.0.1:9003 echo=alice#...:hello-2
[cli] ④ 业务 OK(落服 xa_app@127.0.0.1:9003)
[cli] 结果:PASS(sched=1 hello=1 login=1 app=1)

-account 可以是任意字符串;-prefer <insid> 可点名应用服(不给就按 userid 取模)。
退出码:0 = PASS,2 = 超时 / 断言失败。

11.3 单进程

cd bin
./x3 /abs/path/to/xa_app/main.lua -insid 103 -endpoint 127.0.0.1:9007
./x3 -stop            # 停本目录所有实例;x3 -stop <pid…> 只停指定的

-stop 打的是"退出请求",和 Ctrl+C 完全同一条路径。


12. 实测记录

项目 Windows(MinGW-w64, Release) Linux(WSL, Release)
构建 build_win.bat release 通过,产物自检全 OK build_linux.sh release 通过,产物自检全 OK
线程调度 demo 通信类 14 项全过 15 项全过
整簇 + 客户端 6 进程起齐,test-client PASS(落服 xa_app@9004) 6 进程起齐,test-client PASS(落服 xa_app@9003)
优雅停 x3 -stop → 所有实例已退出,0 残留 stop_all.sh → 6 台逐个退出

13. 开发约定

这一节记的是代码里反复出现的形状,每一处都对应一个具体的失败模式。

  • 编码规范(doc/C-C++编码规范.txt):tab 缩进、花括号独占一行(含单行 if)、
    初始化列表花括号独立成行、行尾无空格、lambda 显式捕获(不用 [=])。
    显式捕获这一条在 so 里尤其重要:隐式捕获 this 的 lambda 存进线程队列后,
    模块卸载时会指向已经析构的对象。
  • 日志:C++ 侧只有 xlog::info/debug/warn/error(xlog::here(), "fmt {}", …),
    here() 作显式实参(宏里塞位置信息会让调用点的行号错位);Lua 侧只有 xlog.info,
    只认裸 {}。退出期走 fprintf(stderr)(见 7.6)。
  • 消息名以 tmsg_ / smsg_ / cmsg_ 开头,id 由 crc32 计算,不手写;
    算出的 id 为 0 时换一个名字。
  • 模块私有单例叫 M,用按值静态 static M md_;(so 卸载才析构)+ 自定义 ~M();
    没有资源要收的 cpp 不硬造 M。每个导出 API 第一句先确认初始化 ——
    dll 可以被任意顺序加载,第一句检查是唯一可靠的位置。
  • 公共基础设施只有一个实现:xplat.h(socket / 地址 / 超时)、xendian.h
    (字节序读写)、xlua_module.h(模块注册)、xbuf.h。这四件事各写两份,
    一定有一份在某个平台上出错,而且错得很安静。
  • 进程 = 目录:加一台同类型进程 = 同一份目录 + 不同 -insid / -endpoint,
    不复制目录。两份目录意味着两份要同步的代码,同步一定会漏。

14. 已知限制

下面这些是当前实现的边界。每一条都写在对应模块的注释里,这里集中列出。

  • 调度下发的 master 由 math.random 拼成,不是密码学随机。换随机源(/dev/urandom 或
    BCryptGenRandom)的改动范围限于 gen_master() 一处。
  • 注册中心的保活超时是静默剔除(不广播下线),客户端侧靠连接断开感知。
  • demo 进程跑完不会自己退,停在"等 tmsg_close 广播"——这是设计的正常状态,
    Ctrl+C 或 x3 -stop 才是退出方式。
  • xhttp 只做基础解析 + path → handler 派发(方法名原样给出,不区分 REST 语义),
    没有 TLS / 路由分组 / keep-alive 调优;客户端侧只有一个极简 GET,
    够"进程当客户端问调度要网关"这种场景。
  • 消息体是 msgpack,没有字段编号。schema 变化靠双方约定,没有兼容性检查。
  • Windows 上受完成端口约束,一条 socket 的读写完成落在同一条线程,第 3.1 节的两方向
    分离只在 Linux 上成立。
  • doc/03 的毫秒信用桶限速目前是算法设计,网关代码里没有对应的判定函数。
  • 没有健康检查端点,也没有 metrics 文本导出;cluster/stat 把统计写进日志。

15. 参考点:每条机制的参照

这一章按设计层面分组:每一节先写 x3 在这一点上定了什么,再列定这条机制时参照过的
地方,最后写 x3 走到哪一步为止。

参考点分三类:能读到实现代码的仓库与库,规范文档(epoll(7)、I/O 完成端口、Lua 手册),
以及 SEDA、actor、single-writer 这类有成熟实现、需要一个名字来指代的经典模型。
一条机制可以有几个参考点,彼此不必相关;同一个参考点也会出现在不同层面。

15.1 到 15.3 是整条思路上的三个大参考点(iogo / amc 两个仓库、名字系统、服务端框架的
整体形态),15.4 到 15.12 按九个层面逐一列出(身份、执行体、通信、I/O、模块、装配、脚本、
部署、观测),15.13 是真正编进产物的第三方库,15.14 收成三条。

15.1 代码仓库:iogo 与 amc

x3 的分层方式(进程=目录、模块=动态库、消息总线、集群分工)参照的是这两个仓库,
左边是仓库,右边是 x3 里对应的位置。

仓库 链接 对应的 x3 技术点
iogo —— GRPC/HTTP 微服务框架 https://github.com/ygluu/iogo 集群拓扑:注册中心只存地址、调度只做分配、网关只做接入 ↦ xa_registry / xa_sched / xa_gate;代码组织:iogo.go(入口装配)↦ common/boot.lua,server.go / client.go ↦ xnet 的 listen / connect 两侧,config.go ↦ local_runcfg.lua,unit + plugin ↦ "一个目录一个进程 + 一个动态库一个模块"
amc —— 异步微服务集群 https://github.com/ygluu/amc svc / module / method 三层拆分 ↦ common/cluster/svc.lua + common/proto/;msg_queue.go / msg_id.go ↦ xthread 的每线程 FIFO 队列 + xbus 的 crc32(name) 消息 id;gate_*.go ↦ xgate

amc 的文件分工:svc.go / svc_module.go / svc_method.go 是服务与模块的拆分,
thread.go / msg_queue.go / msg_id.go 是"线程 + 消息队列 + 消息 id"的最小实现,
net_*.go / gate_*.go 是网关的搬运路径。网络插件尚未完工,消息与模块部分是完整的,
与 xbus + xnet + xgate 逐层对应。

15.2 名字系统:ygluu 的《万物皆ID》与 data-e 的 idname

资料 链接 对应的 x3 技术点
《【图解】AIGC时代的游戏抽象设计:万物皆ID、万事皆事件、万人皆数据》 https://www.cnblogs.com/ygluu/p/19117836 "万物皆 id"这个提法的出处。文章讲数据侧的统一:配置文件按模块分配 ID 段位、实例 ID 用 64 位雪花、名字登记进 id-name 系统、条件表达式解析完变成 player.datas[9000000] 这样的下标访问。x3 的 msgid / insid / netid / dstid / reqid 是同一判断在运行侧的版本:消息、连接、请求、线程归属、广播范围全部整数化
data-e 的 go/names.go https://github.com/ygluu/data-e/blob/master/go/names.go 名字系统的完整实现:RegisterName / GetIdByName / GetNameById / GetCfgById,两张 map 反查,id 分段(高 8 位类型号 + 低 24 位序号)。同一仓库在 2024 年补了 Lua 版,与 x3 的"Lua 主控"是同一个方向

ygluu 那篇文章里三条判断,x3 对应如下:

文章的判断 文章的做法 x3 的做法
万物皆 ID 配置表按模块分段拿 ID,实例用雪花;名字系统把名字换成 id 消息名 crc32、字符串名 crc64、连接 id 与请求 id 全部整数化;整数同时承担寻址与路由
万事皆事件 动作执行、数据改变都触发事件,事件驱动成长线 xbus 的 listen + 定时器构成事件入口;业务模块在 listen 里等消息,不在循环里轮询
万人皆数据 普通怪 / 人形怪 / 地上的物品 / 随从 / 玩家收敛成"地图实体",差异只在配置 进程形态收敛成"目录 + main.lua + local_runcfg.lua",差异只在配置与最后那一行 run

文章给的是数据建模的抽象;x3 用到的是第一条,并把它变成运行期的寻址规则:
if player.datas[9000000] = 200001 这种"名字换成下标"的写法,和
listen(msgid, handler) 的"名字换成查表下标"是同一个动作,只是一个作用在数据上,
一个作用在消息上。

data-e 的 go/names.go 是这条思路最完整的一个实现:

规则 实现
id 分段 高 8 位是类型号,低 24 位是该类型内的序号(typeIdBit = 8、maxNameOfType = 0x00FFFFFF)
注册 RegisterName(type, name, rawId):rawId == 0 时自增分配;非 0 用业务指定值,同类型内查重
反查 GetIdByName / GetNameById / GetCfgById,走 nameCfgOfName 与 nameCfgOfId 两张 map
名字自带元信息 每个名字登记重置周期 rsc、init / min / max、setFunc / getFunc —— 名字就是一条变量声明
有序 id 低 24 位范围(<= 0x00FFFFFF)属 OrderIDName 类型,不挂 set / get

x3 在这条思路上做了三处选择:

  • 消息 id 用 crc32(名字),不用中心分配。 任何进程自己算一遍就得到同一个值,
    不需要把注册表同步到每个进程,也不存在"注册顺序不同、id 就不同";代价是不可反查 ——
    所以 xbus 另外维护一张进程级 name 表,只在登记时写一次,日志与协议展示都查它。
  • 字符串 id 走 xid_addname,双向表但仍是算出来的。 id == 0 时取 crc64(名字),
    非 0 时用业务指定的值;登记 name2id 与 id2name 两张表,同名不同 id、同 id 不同名
    都记 error 并保留先来的那个。这样"名字 ↔ id"两向都能查,却不需要一个中心分配者。
  • 分段 id 只用在能分段的地方。 insid 是 16 bit(全簇唯一,同时当雪花机器号);
    服务实例按名字排序后用 consid % 候选数 选;消息 id 本身不分段,因为 crc32 已经
    均匀分布,取模就够。

这条思路落到 x3 里是一句话:名字是唯一事实,id 只是索引。名字进日志、进协议文档;
id 进热路径、进路由表。

15.3 整体形态:ET 与其它服务端框架

ET( https://github.com/egametang/ET )是 Unity 客户端 + .NET 服务端的双端框架,也是 x3
在整体形态上最完整的一个参考点:按功能切进程、实体按 id 寻址、一套消息双端共用、
数据与逻辑分离,这四件事在 ET 里都已经成型。x3 取的是这四件事的分工,换掉的是它们
定下来的时机 —— ET 把变化留在运行期(挂组件、查位置、重载 dll),x3 把变化挪到启动期
(选入口、算哈希、重启进程)。下面按"ET 的机制 → x3 里对应的东西 → 差别"列,
某一层的细节在对应的分层小节里展开。

ET 的机制 x3 的对应物 差别
组件化装配,1 变 N / N 变 1:每个服务端功能做成组件,按进程类型挂载;开发期全部挂在一个进程里单步调试,发布时换一份多进程配置就拆成 Login / Gate / Map… x3.exe 是唯一宿主,xa_*/main.lua 是一副五行骨架、最后一行调哪个 run 就是挂了哪些模块;local_runcfg.lua 写本进程独有的配置(端口偏移、上限、超时);boot 的第二个参数 { net = false } 关掉它自带的网络初始化 ET 是同一进程里动态挂组件,所以"开发态一进程 = 发布态 N 进程";x3 是"一个目录一个进程",拆进程 = 建目录 + 抄那五行骨架 + 给不同命令行
Actor + MailBox + 位置服务器:实体挂上 MailBoxComponent 即成为 Actor,谁拿到 entity id 都能发消息;不知道位置先问位置服务器(id → 进程) bus.enter(objid, obj) 绑定的对象拿到 dstid,send_to(dstid, …) / call_to 位置透明 ET 的位置是查出来的(位置服务有状态,要注册、要同步);x3 的位置是算出来的(list[userid % #list]、consid % 候选数)。代价是实体不能随意迁徙,换来位置查询零成本、没有单点
Fiber(纤程):ET8 把执行体细化到纤程,调度可选主线程 / 线程池 / 独占线程;纤程之间只走 Actor 消息 xthread 托管的线程就是最小执行体,一个线程一个 lua_State;需要"同一个键固定一条线程"时用 xtpool_alloc(key) / send_by(consid) 没有比线程更细的粒度:同一线程上的多个 owner 共享一份 lua_State 和同一份 tick;ET 一个线程可以跑很多纤程
数据与方法分离:Entity 只有字段,逻辑写成 System 的扩展方法([EntitySystemOf] + Awake/Update),换 dll 就换逻辑 业务逻辑全在 Lua,xa_* 下没有一行 C++;模块只有"订阅 + 回调"两种形态 ET 靠 dll 热重载换逻辑;x3 靠 Lua 换逻辑,但运行期不 reload,改代码走重启进程(见 15.10)
一套消息定义双端共用:Message 定义编进 Model 包,客户端与服务端引同一份,加一条消息只改一处 common/proto/ 一份,服务端与 test-client 都 require;test-client 还直接复用 xgate 的打包 / 解包 ET 是 opcode 数字 + 序列化结构体(带代码生成);x3 是 crc32(消息名) + 一张 lua 表,没有生成步骤
按功能切进程 + 服务发现:Realm(选服)/ Gate / Map / Location / Router / Watcher 各是一个进程角色;ET9 起服务发现单独成包(注册、注销、查询、订阅、心跳、租约、变更通知) xa_registry(只存地址)+ xa_sched(只做分配)+ xa_gate(只做接入)+ xa_app(业务),谁也不替别人记状态 x3 的注册中心是单实例(insid = 11),没有主节点选举,也不做变更订阅
机器人复用客户端逻辑:压测机器人直接跑客户端的逻辑层代码 test-client 是独立 lua 程序,但走同一份 common/proto/ 与同一套 xgate_pack 两边都让机器人走真实客户端路径;x3 这边的机器人只验证链路,不共享业务逻辑
配置表导表 / 行为树 / 预测回滚帧同步 / KCP + 软路由 — x3 定位是服务端骨架:配置只到 local_runcfg.lua 这一层,传输是 TCP,同步模型是请求—回调

取的是分工,换的是可塑性的位置。 ET 让运行期可塑(挂组件、查位置、重载 dll),
x3 让启动期可塑(读清单、算哈希、重启进程)。x3 的运行期因此没有反射、没有位置服务,
也没有重载时的状态迁移;改结构要重启,实体不做迁徙。

整体形态上还有六个参考点。它们与 ET 面对同一组问题 —— 连接怎么接、逻辑跑在哪台、
模块怎么装 —— 各有各的答案,x3 分别取了一段:

框架 语言 x3 参照到的做法 x3 走到哪一步
KBEngine C++ + Python Mailbox 只存实体 id、目的地地址、实体类型,脚本层 other.entity.method() 不用知道对方在哪个进程 ↦ send_to(dstid);LoginApp 只验号、回一个 BaseApp 地址让客户端自己连 ↦ xa_sched 发票据并给客户端口;一个账号登录后固定在一个 BaseApp ↦ 网关把会话钉在一个后端实例 实体分 base / cell 两部分,另有 Space、AOI 与实体迁移;x3 的业务进程不切空间,一台上跑全部场景逻辑
Pomelo Node.js 前端(gate + connector)/ 后端分离:connector 只维护连接与 session,按路由把请求转给后端,后端处理完经 connector 回客户端 ↦ xa_gate + xa_app;master 负责拉起与监控全部进程 ↦ xa_registry + start_all 前端 session 与后端 session 是两份,后端改 session 要 RPC 回写前端;x3 的会话就是那条连接加上钉住的实例
Pitaya Go 前端 / 后端两类节点,路由串 serverType.component.method 决定投给哪台 ↦ add_msg(msgid, kind, appname);会话绑在前端,后端推消息要 PushToFront ↦ 回包必须带客户端在网关侧的连接 id 服务发现依赖 etcd、RPC 依赖 NATS / gRPC;x3 的注册中心是自己起的第 11 号进程,RPC 走自己的总线与 xnet
NoahGameFrame C++ 插件是动态库,导出 DllStartPlugin 交给插件管理器装载 ↦ luaopen_* 交给 xlua::open_module;NFIModule 的 Init / AfterInit / Execute / BeforeShut / Shut 五段生命周期 ↦ post_main 预约初始化 + 退出链条;LuaScriptModule 允许用 Lua 写模块 ↦ 业务全在 Lua 插件清单一本 Plugin.xml,模块之间用 FindModule<T>() 直接调接口;x3 的清单是 main.lua 五行骨架,模块之间只发消息。NF 支持 Lua 脚本热更,x3 改 Lua 走重启进程
leaf Go 每个模块一条 goroutine,模块之间只走 ChanRPC,不直接调函数 ↦ 模块之间只发消息;OnInit 按注册顺序调用、OnDestroy 逆序 ↦ 有序初始化与有序退出;gate 只管连接与路由,业务在别的模块 ↦ xa_gate 只管接入 ChanRPC 有同步 / 异步 / Go 三种调用并支持返回值,x3 只有投递与 call 回调;leaf 的模块数量按"少而粗"设计,x3 按进程目录切
breeze C++ "一切皆 service":toService() 把消息发给另一个 service,不关心对方在本机还是另一个 docker,slotting() 把消息类型绑到处理函数 ↦ bus.send_to + listen;服务之间的依赖写进配置,装载与卸载按依赖拓扑做 ↦ 模块清单在 main.lua、退出按固定链条;部署由 Lua 配置(bin/config.lua)指定哪个服务装在哪个 docker ↦ local_runcfg.lua + -insid / -endpoint service 在运行期按需创建与销毁(玩家下线就卸载对应的 service),x3 的进程与模块在启动期定形,运行期不再装卸

七个参考点拼起来是同一个形状:连接与逻辑分开、实体按 id 寻址、功能按模块装配,
x3 把这三件事都放到启动期定下来 —— 进程形态来自目录名,消息 id 来自名字哈希,
线程归属来自 key。

15.4 身份与寻址:名字登记一次,运行期只剩 id

x3 的做法:消息名 crc32 成 4 字节 msgid;字符串名 crc64 成 64 位字符串 id 并登记
双向表;insid(16 bit 取值)是服务端 id,握手后对端的 insid 直接当连接 id(netid);
客户端连接另给一个雪花 id(同一个 64 位空间,靠取值范围与服务端 id 不重叠);bus.enter
绑定的对象给 dstid;call 给 reqid;线程有 id、定时器有 id;集群名进握手报文第一位,
于是"是不是一个集群的"也只在建链时比一次字符串,之后全按 id 走。

参考点 参照到的做法
Erlang/OTP atom 在加载期进全局 atom 表换成一个整数索引,此后比较与匹配都是整数;进程身份是 pid,跨节点也不变
Erlang 的 pid / port / reference 三种"身份"共处同一个 term 空间,可传递、可比较,但语义不同、不互相冒充 —— x3 服务端连接(对端 insid)与客户端连接(雪花 id)也是同一 64 位空间里的两类
Protocol Buffers / Thrift 字段号在 IDL 里定死,线上只有编号与线型,名字只活在人读的 .proto 里
Unreal Engine FName 字符串进全局 name pool 换一个 32 位索引,FName 之间的比较就是整数比较
Redis Cluster key → CRC16 slot(16384)→ node,三段全是整数,客户端只认 slot 不认地址
Kafka topic-partition 是整数,生产与消费都按分区号定位,顺序性也定义在分区上
Snowflake ID 一个 64 位整数里放"时间 + 机器 + 序号"三段,机器号保证多台机器各自生成的 id 不撞;x3 的客户端连接 id 是这一套,机器号放在最低 16 位、直接等于 insid(src/xid/xsnowflake.hpp:36 位 10ms 时间戳 + 11 位序号 + 16 位机器号)
ygluu《万物皆ID》/ data-e idname 名字注册得 uint32(高 8 位类型 + 低 24 位序号),并保留双向映射(见 15.2)

差别:Erlang 的 atom 表、protobuf 的字段号、UE 的 name pool,id 都来自一次集中的登记
或者编译期生成。x3 用 crc32(名字),任何进程自己算一遍就得同一个值,不需要同步注册表,
也没有代码生成;代价是不可反查,所以 xbus 另存一张进程级名表,只在登记时写一次,
日志和协议展示查它。

15.5 执行体:actor 的寻址与隔离

寻址与隔离这一层,x3 参照的是 actor 模型给出的三条性质:

actor 的性质 x3 的对应 落地位置
实体只靠消息通信,不共享状态 模块之间不 include、不互相调用,只有 bus.send / send_to / send_by / call 7.3;src/xbus/xbus.cpp 的分发路径
同一个实体的消息串行处理 dstid 绑定到一条线程,同线程内按到达顺序执行,业务侧不加锁 xbus.enter + 线程队列(src/xthread/xthread.cpp)
位置透明:拿到 id 就能发,不必知道对端在哪 send_to(dstid) 不区分对端在本进程还是别的进程;跨进程由 xnet 按 netid 转 4.3;src/xnet/xnet_router.cpp 的 by_msg 表

执行体这一侧是线程:一条线程一个执行流,多个对象共享这条线程,按 key 定归属。这一层的
做法参照 Seastar 的 shard-per-core 与 muduo 的 one-loop-per-thread。

参考点 参照到的做法
Actor 模型(Hewitt, 1973) 消息通信、单实体串行、位置透明
Seastar / ScyllaDB shard-per-core:一核一个分片,按 key 哈希定分片,分片内无锁;跨分片只能发消息
Microsoft Orleans grain 激活是单线程的,同一个 grain 的调用天然串行;按 grain id 定位到 silo
LMAX Disruptor single-writer principle:一个状态只有一个写者,读者按序号追赶,锁被设计掉而不是被优化掉
Redis 命令单线程执行,所有数据结构因此都不需要锁
Node.js 一个 event loop 跑完所有回调,业务代码里不出现锁
skynet 一个 lua 服务 = 一个执行体,服务内串行、服务间消息
muduo one loop per thread:一条连接固定由一条线程读写

15.6 通信:位置透明、一次请求一个 id、回调回到原线程

x3 的做法:bus.send / send_to / send_by / call 四个入口,本地模块与远端服务同一套
写法;call 生成 reqid,应答按 reqid 找回等待者,回调在执行 call 的那条线程上跑;
30 秒未回丢弃并告警。

参考点 参照到的做法
Erlang Pid ! Msg 本地与跨节点同一语法,收件人就是 pid,调用方不知道对端在哪台机器
Akka ActorRef 位置透明,tell / ask 对本机 actor 与远端 actor 完全一致
ZeroMQ 同一套 socket API,inproc:// / ipc:// / tcp:// 只换地址字符串
gRPC / Thrift 请求与响应靠 id(HTTP/2 stream id)配对,与 reqid 是同一个手法
.NET SynchronizationContext Post 把回调排回原线程的队列 —— 与"结果回到发起线程的 lua_State"是同一手法
Boost.Asio 的 strand / executor 完成处理器按绑定的 executor 投递,保证同一 strand 内不并发执行

差别:没有 IDL、没有流式、没有背压;call 只有一问一答,超时固定 30 秒(不按调用配置),
也没有"取消"语义。

15.7 I/O 与多路复用:两个 event,两套后端

x3 的做法:读写分离模式下一个连接的读在 main、写在 bus,任何时刻只有一条线程碰它;
另有 net 线程做时间类的事(超时 / 释放 / 重连)。Linux 上 up / down 各是一个 epoll
实例,Windows 上各是一个完成端口;两个平台只在系统调用处分叉,状态机只有一套。

参考点 参照到的做法
libuv 在 epoll / kqueue / event ports / IOCP 之上统一事件循环;loop 绑定一条线程,loop 不是线程安全的
Boost.Asio(Proactor) 在 reactor(epoll / kqueue / select)之上模拟完成语义,Windows 用原生 IOCP、每个 io_context 一个完成端口
nginx 多 worker + epoll ET,一个 worker 一个事件循环;accept 的惊群处理与连接生命周期回收
muduo / 《Linux 多线程服务端编程》 "one loop per thread" 的线程归属:一条连接固定归属一条线程,不跨线程漂移
Half-Sync/Half-Async(Schmidt,POSA 卷二) 上层同步(lua 回调直接写业务)、下层异步(socket 事件驱动),中间是线程队列 —— 正是 main(同步 lua)↔ bus(异步发送)↔ xnet(事件)三层的关系
SEDA(Staged Event-Driven Architecture,分阶段事件驱动) 阶段之间用队列解耦 + 每阶段有界预算:bus 与 net 各自推进,xtimer 用 k_budget / k_catchup 限制单 tick 的工作量
epoll(7) Linux 侧读用 ET、写用水平触发,data.u64 存 netid;rekey_conn 换了 id 之后要 rebind,否则该连接静默不再触发
I/O Completion Ports Windows 侧的完成端口与 ConnectEx / AcceptEx 路径;一个 socket 只关联一个端口,所以两方向的分离退化为同线程收尾
线程池轮询分发 xtpool 的 rr_ 取模选线程;需要"同一个 key 固定同一条线程"时按 id 哈希,而不是轮询

差别:SEDA 的阶段可以动态加线程、有显式排队策略;x3 每个阶段的线程数是固定的,
队列只有"进 / 出"两种背压(满了丢包并计数),不做自适应。libuv 是"一条线程一个 loop"、
Asio 把"完成"抽象成 handler 并由 executor 投递,x3 是"一进程两个 event、分属两条
线程",把"就绪"直接暴露给业务回调。前两者的目标是"把平台差异藏起来",x3 的目标是
"让读和写各有一个 owner,且归属可算" —— 一个是兼容性抽象,一个是性能抽象。
muduo 的一连接一线程在这里被再切一刀:读与写分属两个 owner,代价是跨线程那一步,
换来读侧不会被写阻塞。

15.8 模块:动态库 + 一张 C 表 + 一份语言绑定

x3 的做法:inc/x*.h 里一组 X*_API 的 C 函数(C++ 糖写在同一文件)+ src/x*/x*_lua.cpp
的 luaopen_*(一张 luaL_Reg 交给 xlua::open_module)。模块之间不互相 include。

参考点 参照到的做法
Apache httpd 模块就是一个 module 结构体加 register_hooks 钩子表,LoadModule 动态装载
nginx ngx_module_t 数组,模块自带 ctx 与命令表,编进数组即生效
COM 纯虚接口 + vtable,跨语言只认 ABI;用 IID 定位接口而不是名字
Lua / Node.js 的 C 扩展 宿主提供稳定 C ABI,插件导出固定入口(luaopen_*、napi_register_module_v1)
微内核(Mach / L4) 内核只做 IPC 与调度,其余能力全在用户态服务里;对应宿主 x3 只链 xthread / xbus / xlog 三样

差别:一个模块同时导出两套入口(C API 与 lua 表),lua 侧看不到 C 结构体;而且模块之间没有
直接调用 —— httpd 允许模块调 ap_*、nginx 允许调 ngx_*,这里只能发消息,
耦合比它们更松、跳数比它们更多。

15.9 装配与生命周期:约定优于配置,初始化与退出都有序

x3 的做法:进程 = 目录 + main.lua + run(boot);登记与订阅集中在 run 里
(regcli.start → bus.listen(H));C++ 侧每个编译单元在静态初始化里 post_main
预约一次 main 线程上的初始化;退出按固定链条走(7.5 / 7.6)。

参考点 参照到的做法
Go 的 init() 与 sql.Register 包被 import 就完成注册,没有显式启动步骤 —— 与"boot 之后一个 run 就把自己装好"同构
Rails 的 Convention over Configuration 目录名与文件名即语义,不写 XML 清单
Spring Boot 自动装配 classpath 扫描 + 条件装配,把显式声明压到最少
systemd 的 *.service 目录 文件放进去就生效,没有中心注册表要改;ExecStop 按依赖逆序执行
OpenResty 的 init_worker_by_lua 每个 worker 起来时按脚本把自己那一套装好,不依赖外部编排
Erlang application 按启动的逆序停,先停入口再停依赖
UE 的模块管理器 模块按 LoadingPhase 分批 StartupModule,关闭时按各模块启动完成的逆序 ShutdownModule,依赖方先起后停;x3 的初始化由各编译单元静态期 post_main 预约、main 线程按登记顺序补投,退出走固定链条
nginx graceful shutdown 先停止 accept,再 drain 存量连接,最后退出

差别:没有 DI 容器、没有注解、没有条件装配 —— 装配顺序就是 main.lua 的那五行(Lua)
与静态初始化的投递顺序(C++),而且两者都不依赖这个顺序。退出靠进程内信号而不是外部编排,
所以"进程被 kill -9"这条路径上什么都拿不到 —— 这也是 stop_all 不用 taskkill /F
的原因。

15.10 脚本分层:Lua 主控、C/C++ 补位,一线程一个 VM

x3 的做法:每个被托管的线程一个 lua_State,业务回调只在自己线程的 VM 里跑,
lua_State 不跨线程;xa_* 下没有一行 C++,改业务不需要重新编译。

参考点 参照到的做法
OpenResty nginx(C)+ Lua 业务,一个 worker 一个 lua VM,VM 之间不共享
skynet C 底座 + lua 服务,服务间靠消息,业务不碰线程
skynet 的 lualib 服务启动时按脚本把自己那一套装好,对应 x3 的 run(boot) 里 bus.listen(H)
Redis Lua scripting 单线程 VM 内跑脚本,C 侧提供 API,脚本不得阻塞
Neovim / WoW UI 宿主提供 C API,插件全用 lua 写,改插件不用重编宿主
Lua 5.4 参考手册(C API) 每线程 luaL_newstate + 独立 GC;luaL_ref / luaL_unref 存回调;lua_pcall 隔离业务错误(xlua::pcall);userdata + metatable 做句柄与 luaL_testudata 校验
Programming in Lua(第 4 版) lua 侧模块组织:require + 模块表 + package.path;boot.lua 的 add_path 挂 ?.lua 与 ?/init.lua 就是这里的约定
Lua 5.4 源码(3rd/lua) 编成 bin/liblua 静态链接进每个 x*_lua 模块;lua_State 不跨线程共享,所以不需要给 lua 加锁

差别:RPC 一律写成回调 —— 结果从另一条线程回来,而 lua_State 不能跨线程碰;skynet 用
coroutine 把 RPC 写成像同步调用。lua 代码的更新走重启进程。

15.11 部署:目录 / 调度 / 接入三个角色,会话钉住

x3 的做法:registry 只存地址、sched 只做分配、gate 只做接入并把一条客户端连接
钉在一个后端实例上;分配时 sched 生成 ticket 与 32 字节 master 推给网关,首包校验
票据后由 HKDF-SHA256 派生上行 / 下行两把密钥,之后 chacha20-poly1305 加密、LZ4 按块压缩。

参考点 参照到的做法
Consul + Nomad / Kubernetes 服务目录(存地址与健康)与调度器(做决策)分离,谁也不替对方记状态
Agones 的 GameServerAllocation 一次分配原子地挑一台 Ready 实例、标成 Allocated,把地址与端口回给分配方,之后客户端直连那个地址;分出去的实例不被缩容或滚动更新回收,直到进程自己结束 ↦ xa_sched 挑网关、发票据、把网关端口给客户端,会话由网关钉住
Envoy / HAProxy / nginx 一致性哈希、stick-table、ip_hash 做会话亲和;upstream 掉线按策略处理,不静默改写目标
Kerberos / JWT / 各家 session ticket 一次票据换会话凭据,之后每包不再带账号密码
TLS 1.3 / WireGuard 握手后按方向派生密钥,两个方向各自独立的 nonce 序列

差别:票据是自定义 msgpack 包,不是标准 token;没有证书链、没有 mTLS,master 目前是
math.random 拼的(见第 14 节),换随机源的改动范围限于 gen_master()。doc/03 那套
限速在设计上只做丢弃、不做排队;断线权限留在应用服。

15.12 观测:线程名、统计接口、按序收尾

x3 的做法:xlog 异步落盘,线程可命名(xlog_set_thread_name),日志行头直接看出是
谁在说话;cluster/stat 起一条统计线程,各模块实现 x*_get_stats(T*, int max) 供它采集;
模块退出前把最后一条日志写完再 flush。

参考点 参照到的做法
Prometheus 的 collector 每个子系统实现一个 collect 接口、采集器统一拉取 —— 对应各模块的 x*_get_stats
log4j MDC 每线程一份上下文(这里是线程名),日志自动带上
Erlang application / systemd 按依赖逆序停止,先通知再收尾
epoll(7) 的调试手段 top -H 看到的线程号与日志行头的线程号是同一个数字(plat::thread_id),排障时不需要换算

差别:没有健康检查端点,也没有 metrics 文本格式导出,cluster/stat 只把统计写进日志;
停止靠进程内信号而不是外部编排。

15.13 x3 实际使用的第三方库

3rd/ 下只有这四个,都编进了产物,不是备选清单。

库 链接 对应的 x3 技术点
Lua 5.4 https://www.lua.org/ ;源码见 3rd/lua 编成 bin/liblua,静态链接进每个 x*_lua 模块;线程与 API 细节见 15.10
msgpack-c https://github.com/msgpack/msgpack-c xbus 的业务消息体编码;xgate 握手包的 array(5) 与 [{msgid,msgname}] 服务表;xendian.h 里大端的 msgpack 头读写
LZ4 https://github.com/lz4/lz4 xgate 只编 lib/lz4.c(块格式)做包体压缩,不用 lz4frame / lz4hc
Mbed TLS https://github.com/Mbed-TLS/mbedtls xgate 只编 6 个 .c:chacha20 / poly1305 / chachapoly 做包加密,sha256 做 HKDF,platform_util / constant_time 做支撑;不编 TLS、X.509、PSA

15.14 收敛点

上面十三节可以压成三条,每一条都能指回一个具体的参考点:

  1. 身份定死 —— pid、grain id、slot、FName、crc32(名字) 是同一件事在不同参考点里的
    名字:"名字算一次,之后只认整数"。整数才可比较、可取模、可入路由表。
  2. 写者唯一 —— shard、grain activation、single-writer、按 key 定线程,说的是同一句:
    "同一份状态只有一条执行流能碰",实现里因此没有锁。
  3. 只走消息 —— !、tell、send、bus.send_to,都是跨执行体不共享内存。

x3 把这三条压在同一层:线程。一条线程一个执行流,多个对象按 key 共享同一条;
长任务会拖住同线程的其它模块,单进程承载量由线程数决定。这条边界贯穿全文,
也是第 14 节"已知限制"的来源。


附:设计文档

doc/ 下四份是这套代码的设计记录:

文档 内容
doc/01-协议设计.txt 消息编号、包头、目标数组、兼容策略
doc/02-网络组件设计.txt xnet 三层职责、up / down 两个 event 的读写归属、连接生命周期
doc/03-网关CD算法设计.txt 网关消息级限速(CD)的毫秒信用桶算法:可累积、有上限、容差只偶发生效
doc/C-C++编码规范.txt 命名、缩进、日志、错误处理

许可

Apache License 2.0,见仓库根目录的 LICENSE。

posted @ 2026-11-15 23:51  码客-ygluu  阅读(11)  评论(0)    收藏  举报