Skip to content

encode_sparse_array():稀疏数组检测阈值可配 #123

@membphis

Description

@membphis

概述

为 qjson 添加 encode_sparse_array(convert, ratio, safe) API,和 lua-cjson / OpenResty lua-cjson 的行为一致。允许调用方控制 qjson.encode() 对稀疏数组的检测阈值和转换行为,用于需要编码稀疏 Lua table 的场景。

需求

场景

当前 classify_plain_table 硬编码 ENCODE_SPARSE_RATIO = 2ENCODE_SPARSE_SAFE = 10,超稀疏数组始终报错。调用方无法微调行为,例如需要编码 {[1] = "a", [1000] = "b"} 的场景。

local qjson = require("qjson")

-- 默认行为不变(向后兼容)
qjson.encode({[1] = "a", [1000] = "b"})
-- error: "Cannot serialise table: excessively sparse array"

-- 放宽阈值:ratio=10, safe=100
qjson.encode_sparse_array(false, 10, 100)
qjson.encode({[1] = "a", [1000] = "b"})
-- '["a",null,null, ... null,"b"]'

-- 转为对象
qjson.encode_sparse_array(true, 2, 10)
qjson.encode({[1] = "a", [1000] = "b"})
-- '{"1":"a","1000":"b"}'

-- 禁用稀疏检查:ratio=0
qjson.encode_sparse_array(false, 0, 10)
qjson.encode({[1] = "a", [1000] = "b"})
-- 始终按数组编码,填 null 空洞

行为规范

所有调用形式都返回更新后的 convert, ratio, safe 三元组(getter/setter 合一,与 lua-cjson 一致)。setter 不再只在无参时返回 —— local c, r, s = qjson.encode_sparse_array(true) 应拿到 true, 2, 10

调用 行为
qjson.encode_sparse_array() 纯读取,返回当前 convert, ratio, safe(默认 false, 2, 10)
qjson.encode_sparse_array(false, 2, 10) 恢复默认行为
qjson.encode_sparse_array(true, 2, 10) 超稀疏数组转为 object
qjson.encode_sparse_array(false, 0, 10) 禁用稀疏检查,始终按数组编码
qjson.encode_sparse_array(false, 1, 0) 禁止任何稀疏(任何空洞数组都视为超稀疏)

参数说明:

  • convert (boolean):true 时超稀疏数组转为 JSON object;false 时报错(默认)。任意非 false/nil 值按 true 处理(Lua 真值语义)。
  • ratio (number, 非负整数):0 = 禁用稀疏检查(所有正整数 key 的 table 都按数组编码);>0 = 稀疏比例阈值(默认 2)。判定始终受 safe 下限约束 —— 只有 max > safe 时才可能触发。因此 ratio=1 单独并不能"禁止任何稀疏",需同时设 safe=0(见行为表最后一行)。
  • safe (number, 非负整数):max_index ≤ safe 时始终按数组处理(默认 10)。

超稀疏判断逻辑(与 lua-cjson 一致):

excessively_sparse = (ratio > 0) AND (max_index > safe) AND (max_index > key_count * ratio)

三个条件同时成立才触发,saferatio 是与关系,缺一不可。

encode 的影响

  • 仅影响无显式类型 hint 且走 classify_plain_table() 的 plain table。
  • qjson.array() / qjson.object() 类型 hint 或 empty_array_mt 的 table 走 TABLE_TYPE_HINT 快路径,不经过 classify_plain_table,因此不受稀疏配置影响(显式 hint 优先于稀疏检测)。
  • convert=true 且超稀疏时,classify_plain_table 返回 "object" 而非报错,由 encode_plain_tableencode_object 路径。
  • convert=false 且超稀疏时,行为与当前一致(报错)。
  • ratio=0 时跳过稀疏检查,所有正整数 key 的 table 均按数组编码。
  • lazy proxy 的编码路径不受影响(decode 出的数组已有 max 长度,且 JSON 数组本身无空洞,不经过 classify_plain_table)。

设计

方案:模块级全局变量 + getter/setter

lua/qjson/table.lua 中替换硬编码常量,添加模块级状态和 API 函数,修改 classify_plain_table 使用变量。

状态(复用现有 line 588-589 常量名 + 新增 ENCODE_SPARSE_CONVERT):

必须是 local upvalue —— 裸赋值会泄漏进 _G,既污染全局命名空间,又比 upvalue 访问慢(与下文"性能"小节的 upvalue 主张相矛盾)。沿用现有 ENCODE_SPARSE_* 命名,不要加前导下划线,使 diff 最小。

local ENCODE_SPARSE_CONVERT = false
local ENCODE_SPARSE_RATIO   = 2
local ENCODE_SPARSE_SAFE    = 10

getter/setter(先按参数做局部更新,再无条件返回三元组):

function _M.encode_sparse_array(convert, ratio, safe)
    if convert ~= nil then
        ENCODE_SPARSE_CONVERT = convert ~= false
    end
    if ratio ~= nil then
        if type(ratio) ~= "number" or ratio < 0
           or ratio ~= math.floor(ratio) then
            error("bad argument #2 to qjson.encode_sparse_array (expected non-negative integer)")
        end
        ENCODE_SPARSE_RATIO = ratio
    end
    if safe ~= nil then
        if type(safe) ~= "number" or safe < 0
           or safe ~= math.floor(safe) then
            error("bad argument #3 to qjson.encode_sparse_array (expected non-negative integer)")
        end
        ENCODE_SPARSE_SAFE = safe
    end
    return ENCODE_SPARSE_CONVERT, ENCODE_SPARSE_RATIO, ENCODE_SPARSE_SAFE
end

无参调用时三个 if 全部跳过,直接 return 当前值 —— getter 自然成立,无需单独分支。这同时修正了 lua-cjson 兼容性:cjson 的 setter 也始终返回当前配置。

classify_plain_table 修改(line 652-673):

local function classify_plain_table(t)
    local count = 0
    local max = 0
    local all_positive_integer_keys = true
    local saw_key = false
    for k in pairs(t) do
        saw_key = true
        count = count + 1
        if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
            all_positive_integer_keys = false
        elseif k > max then
            max = k
        end
    end
    if not saw_key or not all_positive_integer_keys then
        return "object"
    end
    local ratio = ENCODE_SPARSE_RATIO
    local safe  = ENCODE_SPARSE_SAFE
    if ratio > 0 and max > safe and max > count * ratio then
        if ENCODE_SPARSE_CONVERT then
            return "object"
        end
        error("Cannot serialise table: excessively sparse array")
    end
    return "array", max
end

导出(lua/qjson.lua):

_M.encode_sparse_array = _lazy.encode_sparse_array

编码决策流程

qjson.encode(t)
  └─ encode_plain_table(t, depth, active)
       ├─ empty_array_mt / TABLE_TYPE_HINT  → 直接 array/object(旁路稀疏检查)
       └─ classify_plain_table(t)
            ├─ 非正整数 key         → "object"
            ├─ 空 table              → "object"
            ├─ ratio == 0            → "array", max  (跳过稀疏检查)
            ├─ max > safe AND max > count * ratio
            │    ├─ convert == false → error("excessively sparse array")
            │    └─ convert == true  → "object"
            └─ 其他                  → "array", max

边界情况

场景 行为
{[1]=1, [5]=2}, default (2, 10) 数组:max=5 ≤ safe=10,不触发稀疏检查
{[1]=1, [100]=2}, (2, 0) 报错:max=100 > safe=0 且 100 > 2×2
{[1]=1, [100]=2}, (false, 0, 0) 数组:ratio=0 跳过检查
{[1]=1, [100]=2}, (true, 2, 10) object:convert=true,转为 {"1":1,"100":2}
空 table {}, any settings object(不受稀疏配置影响)
key 含非数字,any settings object(不受稀疏配置影响)

性能

  • classify_plain_table 中额外两次局部变量读取(ENCODE_SPARSE_RATIO / ENCODE_SPARSE_SAFE),开销可忽略。
  • 状态以 local upvalue 而非 _M 字段保存,setter 与 classify_plain_table 共享同一批 upvalue,内部直接引用,保持热路径性能。

测试

该功能引入模块级全局可变状态,单条断言失败会跳过其后的内联 restore,污染后续用例。用 after_each 在框架层统一复位,而不是依赖业务代码顺序执行恢复。

describe("encode_sparse_array", function()
    after_each(function()
        -- 框架保证执行:无论断言成败都复位,避免全局状态泄漏到后续用例
        qjson.encode_sparse_array(false, 2, 10)
    end)

    it("defaults to false, 2, 10", function()
        local c, r, s = qjson.encode_sparse_array()
        assert.are.equal(false, c)
        assert.are.equal(2, r)
        assert.are.equal(10, s)
    end)

    it("setter returns updated values (cjson-compatible)", function()
        local c, r, s = qjson.encode_sparse_array(true, 5, 20)
        assert.are.equal(true, c)
        assert.are.equal(5, r)
        assert.are.equal(20, s)
    end)

    it("getter returns current values", function()
        qjson.encode_sparse_array(true, 5, 20)
        local c, r, s = qjson.encode_sparse_array()
        assert.are.equal(true, c)
        assert.are.equal(5, r)
        assert.are.equal(20, s)
    end)

    it("partial update keeps other fields", function()
        qjson.encode_sparse_array(true, 5, 20)
        local c, r, s = qjson.encode_sparse_array(false) -- 仅改 convert
        assert.are.equal(false, c)
        assert.are.equal(5, r)
        assert.are.equal(20, s)
    end)

    it("convert=true: excessive sparse becomes object", function()
        qjson.encode_sparse_array(true, 2, 3)
        local out = qjson.encode({[1] = "one", [5] = "sparse"})
        assert.are.equal('{"1":"one","5":"sparse"}', out)
    end)

    it("convert=false: excessive sparse errors (default)", function()
        local ok, err = pcall(qjson.encode, {[1] = 1, [1000] = 2})
        assert.is_false(ok)
        assert.is_truthy(string.find(err, "excessively sparse array"))
    end)

    it("ratio=0 disables sparse check", function()
        qjson.encode_sparse_array(false, 0, 10)
        local out = qjson.encode({[1] = "a", [500] = "b"})
        local decoded = require("qjson").decode(out)
        assert.are.equal(500, qjson.len(decoded)) -- lazy proxy 用 qjson.len,不要用 #
        assert.are.equal("b", decoded[500])
    end)

    it("safe threshold: max <= safe always array", function()
        local out = qjson.encode({[1] = "a", [5] = "e"})
        assert.are.equal('["a",null,null,null,"e"]', out)
    end)

    it("rejects non-integer / negative args", function()
        assert.is_false(pcall(qjson.encode_sparse_array, false, 2.5, 10))
        assert.is_false(pcall(qjson.encode_sparse_array, false, -1, 10))
        assert.is_false(pcall(qjson.encode_sparse_array, false, 2, -1))
    end)

    it("backward compatible: default behavior unchanged", function()
        assert.are.equal("[1,null,3]", qjson.encode({[1] = 1, [3] = 3}))
        assert.are.equal('["a",null,null,null,"e"]', qjson.encode({[1] = "a", [5] = "e"}))
        local ok, _ = pcall(qjson.encode, {[1] = 1, [1000] = 2})
        assert.is_false(ok)
    end)
end)

需修改的文件

  1. lua/qjson/table.lua — 核心实现(local 状态变量、getter/setter 始终返回三元组、修改 classify_plain_table)。
  2. lua/qjson.lua — 对外导出 _M.encode_sparse_array = _lazy.encode_sparse_array
  3. docs/migrating-from-cjson.md
    • cjson.encode_sparse_array() 从 Unsupported 表(line ~179)移除,改为"已支持"并加迁移说明;
    • 更新 Incremental checklist 第 6 条(line ~197),移除 "sparse-array configuration"(保留 cjson.new 与 number-precision,后者属 encode_number_precision():控制浮点编码输出精度 #121);
    • 补充"全局状态、跨请求残留"的 OpenResty 注意事项(见下方"已知限制")。
  4. tests/lua/encode_cjson_compat_spec.lua — 新增上述测试用例(含 after_each 复位)。

已知限制

  • 全局可变状态,OpenResty 跨请求残留encode_sparse_array 修改的是模块级配置,在同一个 worker 内对所有后续请求生效;qjson 不提供 cjson.new() 式的实例隔离。建议在 init_by_lua 阶段设置一次,避免在请求处理阶段临时修改(会污染同 worker 的其他请求)。此限制需在迁移文档中明确说明。

不在范围内

  • Rust/FFI 侧变更(纯 Lua 层功能)。
  • 每次 encode() 调用传入 opts 参数(仅模块全局设置,兼容 lua-cjson 模式)。
  • cjson.new() 式的实例级配置隔离。
  • qjson_options C 结构体扩展(稀疏检测在 Lua 编码器中,C 层只负责解析)。

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions