Lua 序列化与数据交换格式

梳理 Lua 生态中常用的序列化与数据交换格式:从 string.format 手写序列化、load 反序列化,到 cjson、MessagePack、CBOR 等库的选型与性能对比,并给出游戏存档、配置加载与跨语言通信中的落地建议与安全边界。

序列化(Serialization)是把内存中的数据结构转换成可存储或可传输的字节流的过程,反序列化(Deserialization)则是逆过程。在 Lua 中,需要序列化的场景非常集中:游戏存档、配置表落盘、网络协议编解码、缓存写入以及跨语言进程间通信。不同场景对格式的要求差异很大——存档看重可读性与向后兼容,网络通信看重体积与速度,跨语言通信看重规范一致性。

Lua 本身没有内置的序列化库,这既是负担也是自由:你可以用几行代码手写一个可读的序列化器,也可以引入 cjson、lua-MessagePack 这类 C 扩展获得接近原生的性能。本文先讲清楚各格式的取舍,再落到具体代码与选型建议。文中涉及的库安装方式见 https://plumephp.com/lua-modules-and-packages/。

格式选型:先问三个问题

在动手之前,先明确三个维度,选型几乎就确定了:

维度倾向文本格式倾向二进制格式
是否需要人眼可读配置、调试、存档网络包、大数组
体积与带宽敏感度低高
是否需要跨语言弱(同语言自洽)强(多端约定)

常见的候选格式对比:

格式类型相对体积编码速度跨语言
Lua 表字面量文本大快差(仅 Lua)
JSON文本中中极好
MessagePack二进制小快好
CBOR二进制小快好
Protocol Buffers二进制最小最快好(需 schema)

选择的关键不是"哪个最好",而是"哪个约束最少"。配置表用 JSON 便于人工编辑;高频网络包用 MessagePack 省带宽;多语言强类型接口用 Protobuf。还需要考虑团队现状:如果既有服务全是 JSON,为了省 30% 体积切到 MessagePack 未必划算,因为解析侧的改造成本可能更高。

Lua 原生序列化:string.format 与 load

最小依赖的方案是手写。思路是用 string.format 把基本类型转成 Lua 字面量,递归处理表:

local function serialize(value, seen)
    local t = type(value)
    if t == "number" or t == "boolean" then
        return tostring(value)
    elseif t == "string" then
        return string.format("%q", value)   -- %q 转义引号与换行
    elseif t == "table" then
        seen = seen or {}
        if seen[value] then error("循环引用") end
        seen[value] = true
        local parts = {}
        -- 数组部分
        for i = 1, #value do
            parts[#parts + 1] = serialize(value[i], seen)
        end
        -- 哈希部分
        for k, v in pairs(value) do
            if type(k) ~= "number" or k > #value or k < 1 then
                parts[#parts + 1] = string.format("[%s]=%s",
                    serialize(k, seen), serialize(v, seen))
            end
        end
        seen[value] = nil
        return "{" .. table.concat(parts, ",") .. "}"
    else
        error("不支持的类型: " .. t)
    end
end

print(serialize({1, 2, name = "lua", ok = true}))
-- {1,2,["name"]="lua",["ok"]=true}

反序列化直接用 load 执行这段字面量:

local function deserialize(text)
    local chunk = assert(load("return " .. text, "serialize", "t"))
    return chunk()
end

local data = deserialize('{1,2,["name"]="lua"}')
print(data.name)   -- lua

string.format("%q") 会正确处理转义,但对 math.huge、nan 这类特殊数值无能为力,需要在序列化前显式处理,否则会写出 1e9999 或 -nan 这类无法回读的字面量。更严重的是 load 的安全问题——它会把任意字符串当代码执行,绝不能用于反序列化不可信输入。生产环境请改用 load(..., "t") 仅允许文本模式,或干脆用纯数据格式。

手写方案的另一个常见缺陷是不记录键的顺序,pairs 的遍历顺序不稳定,导致同一份数据两次序列化结果不同,无法做校验和或 diff。需要稳定输出时,要先收集所有键并 table.sort。

JSON:cjson 与 dkjson 的取舍

JSON 是 Lua 生态最通用的文本格式。两个主流实现差异明显:

实现语言速度特性
lua-cjsonC极快默认不区分空数组与空对象
dkjson纯 Lua慢可配置、无编译依赖
rapidjson 绑定C++极快支持流式与 SAX

lua-cjson 的典型用法:

local cjson = require "cjson"

-- 编码
local ok = cjson.encode({name = "lua", version = 5.4})
-- {"name":"lua","version":5.4}

-- 解码
local obj = cjson.decode('{"ok":true,"list":[1,2,3]}')
print(obj.list[2])   -- 2

cjson 有几个必须知道的坑:

  • 空表歧义:cjson.encode({}) 输出 {}(对象),但 cjson.encode(cjson.empty_array) 才输出 []。需要显式控制时用 cjson.empty_array_mt。
  • 稀疏数组:含空洞的数组会被编码成对象,破坏结构。
  • 大整数精度:JSON 数字统一按 double 处理,超过 2^53 的整数会丢精度。
  • null 映射:JSON 的 null 默认解码为 cjson.null(一个 lightuserdata),而非 nil,判空时要用 v == cjson.null。

若对纯 Lua 有强依赖(如嵌入式环境无法编译 C 扩展),则用 dkjson:

local json = require "dkjson"
local text = json.encode({a = 1}, {indent = true})   -- 带缩进,便于人读
local obj, pos, err = json.decode(text)
if err then print("解析失败: " .. err) end

dkjson 支持 indent 美化输出,适合生成配置文件;代价是编码速度通常是 cjson 的十几分之一。性能敏感路径应选 cjson。

MessagePack 与二进制格式

当数据体积成为瓶颈时,二进制格式的优势就体现出来。MessagePack 把整数、字符串、数组、映射编码成带类型前缀的紧凑字节,同样的数据通常只有 JSON 的 40%~70%。

local mp = require "MessagePack"

-- 编码
local packed = mp.pack({id = 1001, tags = {"lua", "fast"}})
print(#packed)   -- 例如 24 字节

-- 解码
local obj = mp.unpack(packed)
print(obj.tags[1])   -- lua

MessagePack 的整数编码按数值范围自适应:小整数只占 1 字节,这让 ID、枚举、坐标这类数据非常省。但它同样不保留类型信息——Lua 的整数与浮点都编码为数字,解码端按规范还原。

对于需要 schema 约束的场景,Protocol Buffers 更合适。Lua 侧常用 lua-protobuf(纯 Lua 解析 .proto):

local pb = require "pb"
pb.loadfile("player.proto")
local encoded = pb.encode("Player", {id = 1, name = "neo"})
local decoded = pb.decode("Player", encoded)

二进制格式的通用代价是不可读:调试时必须借助十六进制工具或专门的反序列化脚本。因此常见做法是"开发用 JSON、上线切二进制",用同一份数据模型驱动两套编解码。Protobuf 还带来 schema 演进的能力:字段号一旦分配就不应复用,删除字段要标记 reserved,这样新旧版本可以共存。

CBOR 与自描述二进制

CBOR(Concise Binary Object Representation)常被称作"二进制版 JSON",它保留了 JSON 的自描述特性(不需要 schema 就能解码),同时体积更小。相比 MessagePack,CBOR 对整数、浮点、时间戳、标签类型的定义更严格,适合需要长期存储、跨系统交换的场景。

local cbor = require "cbor"
local bytes = cbor.encode({ts = os.time(), temp = 23.5, ok = true})
local obj = cbor.decode(bytes)

三者的取舍可以这样记:JSON 图可读,MessagePack 图快,CBOR 图规范。如果数据要落盘存档十年,CBOR 的自描述与标准标签更稳妥;如果只是进程间传一包数据,MessagePack 的成熟度与生态更广。

游戏存档中的序列化实践

游戏存档是 Lua 序列化最典型的场景,需求往往是"可读、可迁移、可容错"。这类项目通常不直接用 JSON,而是自定义一层格式。存档要解决的三个问题:

  1. 版本迁移:老存档要能在新版本加载。做法是在存档头写入 version 字段,加载时按版本号逐级升级。
  2. 完整性校验:防止玩家手改。用 CRC32 或哈希校验关键字段。
  3. 可调试:出问题时能打印出可读内容。
local Save = {}

function Save.dump(state)
    local payload = serialize(state)                  -- 上文的手写序列化器
    local checksum = crc32(payload)                   -- 校验和
    return string.format("-- v%d\n%s\n-- crc:%08x",
        state.version or 1, payload, checksum)
end

function Save.load(text)
    local version = tonumber(text:match("v(%d+)"))
    local body = text:match("\n(.-)\n%-%- crc")
    local data = deserialize(body)
    if version < CURRENT_VERSION then
        data = migrate(data, version)                 -- 逐级升级
    end
    return data
end

这套思路与通用游戏存档系统的设计一致:把"数据内容"与"元信息(版本、校验)“分层,元信息永远用固定格式,数据内容才允许演进。若存档量大,还可对 payload 做压缩(如 zlib)再落盘,读档时解压。

流式与增量解析

当数据体积很大(如几十 MB 的地图数据或日志归档),一次性 decode 会把整棵树读进内存,峰值占用可能翻倍。此时需要流式(streaming)或增量(incremental)解析。

MessagePack 与 JSON 都有支持分块读取的解析器,思路是"喂一段字节、吐一个对象”:

-- 伪代码:增量喂数据,解析器在完整对象就绪时回调
local parser = json.new_parser()
parser.on_object = function(obj)
    process(obj)                 -- 处理完立即释放,不累积
end

for chunk in read_chunks(file) do
    parser:feed(chunk)           -- 每次只解析出能解析的部分
end
parser:finish()

流式解析的三个好处:内存峰值与单条记录大小成正比而非文件总大小;可以边下载边处理;遇到损坏数据能定位到具体偏移。代价是解析器实现更复杂,且不能随机访问——你只能顺序消费。

对于 Lua 表字面量存档,则没有现成的流式解析器,只能整体 load。这也是大存档更推荐二进制格式的原因之一。

二进制布局与字节序

手写二进制格式时,字节序(endianness)是绕不开的坑。Lua 5.3+ 提供 string.pack / string.unpack 直接处理字节序,不必依赖 FFI:

-- "<" 小端,"i4" 32位整数,"d" double,"z" 以 \0 结尾的字符串
local bytes = string.pack("<i4dz", 1001, 23.5, "lua")
local id, temp, name = string.unpack("<i4dz", bytes)
print(id, temp, name)   -- 1001  23.5  lua

格式串的常用标记:

标记含义字节数
i4有符号 32 位整数4
I8无符号 64 位整数8
ddouble8
z零结尾字符串变长
s4长度前缀字符串4 + n

跨端通信时必须两端约定一致:小端(<)在 x86/ARM 上更自然,大端(>)是网络字节序。约定写进协议文档,比在代码里隐式依赖平台更可靠。

反序列化的安全边界

反序列化是攻击面。核心原则只有一条:把数据当数据,不要当代码执行。

  • 避免 load/loadstring 执行输入。若必须(如 Lua 表字面量存档),则用 load(text, name, "t", env) 并传入一个白名单环境,让脚本无法访问 os、io 等危险库。
  • JSON 解析器要限制嵌套深度,防止恶意深层结构导致栈溢出。
  • 限制输入大小,避免一次 decode 吃掉几百 MB 内存。
-- 用受限环境加载不可信的表字面量
local safe_env = {}                          -- 空环境,无任何库
local chunk = load("return " .. text, "data", "t", safe_env)
local ok, data = pcall(chunk)
if not ok then
    error("非法数据: " .. tostring(data))
end

这套沙箱思路与 Lua 与 C 交互时的边界控制一脉相承,涉及原生扩展调用时更要注意内存安全,可参考 https://plumephp.com/lua-c-integration-guide/。

性能与选型建议

把上面的讨论收敛成几条可执行的建议:

  • 配置与调试:JSON(dkjson 带缩进),人可读优先。
  • 高频网络包:MessagePack 或 Protobuf,省带宽优先;二进制格式的编解码开销通常低于文本格式的解析。
  • 游戏存档:自定义文本格式 + 校验和 + 版本号,兼顾可读与容错。
  • 缓存到 Redis:cjson 或 MessagePack,注意 Redis 侧的解码成本。
  • 跨语言强类型:Protobuf,用 schema 保证两端一致。

选型时还要考虑兼容性演进:JSON 加字段天然向后兼容,Protobuf 需要预留 reserved 字段号,MessagePack 无 schema 则要双方约定好字段含义。把"未来怎么加字段"想清楚,比选哪个格式更重要。

一个常被忽视的性能点是反序列化后的对象形态。cjson.decode 返回的是一棵全新的表树,字段访问全靠哈希查找;如果同一份数据会被反复访问,可以在解码后做一次结构转换(如把数组字段转成定长元组),减少后续的哈希开销。序列化本身的耗时往往不是瓶颈,真正拖慢的是解码后高频的字段读取。

常见问题(FAQ)

Lua 序列化后能不能直接跨语言读?

取决于格式。JSON、MessagePack、CBOR、Protobuf 都有成熟的跨语言实现,可以。但手写的 Lua 表字面量(如 {1,2,["k"]=v})其他语言无法直接解析,只适合同语言自洽的场景。

cjson 编码空表为什么是 {} 而不是 []?

因为 Lua 的空表既是数组又是映射,没有类型信息。cjson 默认按对象处理,输出 {}。要输出空数组,用 cjson.encode(cjson.empty_array),或给空表设置 cjson.empty_array_mt 元表。

大整数序列化会丢精度吗?

JSON 会。JSON 规范没有整数类型,所有数字按 IEEE 754 double 处理,超过 2^53 的整数无法精确表示。MessagePack 和 Protobuf 有独立的整数类型,可以无损存储 64 位整数。

反序列化不可信数据最需要注意什么?

三点:不要用 load 执行输入(或必须用白名单环境);限制嵌套深度与输入体积;对解析器做 pcall 包裹,避免畸形数据让进程崩溃。

为什么同一份表两次序列化结果不一样?

因为 pairs 遍历表的顺序不稳定,哈希部分的顺序取决于内部哈希布局。若需要结果可复现(如做校验和或版本 diff),必须先把键收集起来排序,再按固定顺序输出。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. Lua 时间日期处理与时区
  2. Lua 在嵌入式与 IoT 中的开发实践
  3. Lua 与 WebAssembly 互操作