表格映射机制
配表系统支持复杂的数据结构,包括嵌套、多态和递归嵌套。然而,Excel 是基于二维表格的结构。
核心问题:如何将灵活的树状甚至递归结构映射到 Excel 的平坦表格结构中?
本文档详细介绍了五种映射机制:auto、pack、sep、fix 和 block,它们提供了从复杂数据结构到表格结构的转换方案。
映射机制详解
Section titled “映射机制详解”auto(自动映射)
Section titled “auto(自动映射)”-
适用类型:基本类型(primitive)、结构体(struct)、接口(interface)
-
占格规则:
- 基本类型:占用 1 列
- 结构体/接口:自动计算所需列数
-
特点:默认映射方式,适用于大多数简单场景
-
示例:
struct Range {rmin:int; // 最小值rmax:int; // 最大值}Range结构体占用 2 列
pack(压缩映射)
Section titled “pack(压缩映射)”-
适用类型:结构体(struct)、接口(interface)、列表(list)、映射(map)
-
占格规则:将整个数据结构压缩到 1 列
-
分隔符:
- 字段间:逗号或分号分隔
- 嵌套结构:使用
()包裹
-
应用场景:
- 减少表格列数
- 处理递归结构
- 简化配置
-
示例:
struct Position (pack) {x:int;y:int;z:int;}数据格式:
"1,2,3"list<Position> (pack)数据格式:
"(1,2,3);(4,5,6)"interface Attr (pack) {struct Damage {value:int;}struct Range {value:int;}}数据格式:
"Damage(12)"list<Attr> (pack)数据格式:
"Damage(12);Range(42)" -
重要说明:当数据结构形成循环引用(递归)时,必须至少在一处使用
pack来打破循环,否则无法计算所需列数
sep(分隔符映射)
Section titled “sep(分隔符映射)”-
适用类型:结构体(struct)、列表(list)
-
占格规则:将整个数据结构压缩到 1 列
-
分隔符:支持自定义分隔符(
:、=、$等) -
示例:
struct Time (sep=':'){hour:int;minute:int;second:int;}数据格式:
"12:10:00" -
使用限制:
- 结构体中的所有字段必须是基本类型(primitive)
- 不支持在类型为结构体的字段上设置
sep(应在结构体定义上设置) - 如果字段是
list<struct>结构,分隔符需要与内部结构的分隔符区分
-
建议:除非有特定分隔符需求,否则推荐使用功能更强大的
pack映射
fix(固定长度映射)
Section titled “fix(固定长度映射)”-
适用类型:列表(list)、映射(map)
-
占格规则:固定列数 = 元素类型占用列数 × count
-
参数:count - 固定长度
-
应用场景:已知确切长度的列表或映射
-
示例:
list<int> (fix=2) // 占用 2 列
block(块状映射)
Section titled “block(块状映射)”-
适用类型:列表(list)、映射(map)
-
占格规则:横向固定列数,纵向可扩展
-
参数:fix - 横向块数
-
应用场景:变长列表,需要在表格中垂直排列
-
示例:
list<RewardItem> (block=1) // 横向占用 1 × RewardItem 列数,纵向任意行数
Block 算法解析
Section titled “Block 算法解析”Block 的读取算法基于”祖先 block 首列”判断嵌套边界。解析器按 schema 预计算每个 block 字段首列对应的祖先 block 首列集合(即词法上包围它的所有外层 block 的首列),扫描后续行时检查这些祖先首列:
// ancestors:本 block 的所有祖先 block 首列(按 schema 预计算)if (isPkCellAllEmpty(line)) { // 主键全为空 => 仍属于本 record // 任一祖先 block 首列非空 => 外层 block 起了新项 => 结束本 block boolean newOuterItem = false; for (int bc : ancestors) { if (!line.get(bc).isCellEmpty()) { newOuterItem = true; break; } } if (newOuterItem) break;
DCell thisCell = line.get(firstColIndex); if (thisCell.isCellEmpty()) { // 本格为空:更深层嵌套 block 的行,忽略,继续 } else { // 本格不为空:属于当前 block res.add(new CellsWithRowIndex(line.subList(firstColIndex, firstColIndex + colSize), row)); }} else { break; // 主键非空 => 下一个 record,结束}Block 支持多层嵌套。判断一行属于”当前 block 的延续”还是”外层 block 起了新项”,看的是外层 block 的首列(每一项的标识列)是否非空,而不是相邻的前一列:
// 示例布局(外层 aebb 块,内嵌 bb 块):// aebb <- 外层第1项 header(aebb 首列有值)// bb <- 内层 bb 的行(aebb 首列为空,故仍属第1项)// bb// aebb <- 外层第2项 header(aebb 首列又有值 => 新的一项)// bb嵌套规则:
规则一:外层 block 的首列(项标识)在内层 block 行必须为空,否则会被判定为外层起了新项而结束当前 block。
以 level.waves(外层)嵌套 wave.spawns(内层)为例,结构定义如下:
struct Wave { waveIndex:int; // 外层 waves 的首列(项标识) spawns:list<Spawn> (block=1); // 内层 block}struct Spawn { pathId:int; // 内层 spawns 的首列(项标识) monsterId:int;}table level[id] { id:int; waves:list<Wave> (block=1);}列布局为 id, waveIndex, pathId, monsterId(waveIndex 是外层 waves 的首列,pathId 是内层 spawns 的首列):
| id | waves.waveIndex | pathId | monsterId | 说明 |
|---|---|---|---|---|
| 1 | 1 | 1001 | 2001 | wave1 首行,waveIndex 有值 → 新 wave |
| 1002 | 2002 | waveIndex 空 → 仍属 wave1 | ||
| 2 | 1003 | 2003 | waveIndex 又有值 → 新 wave2 |
规则二:外层 block 的兄弟字段(非首列)在内层行可为空、也可冗余填值,不影响边界判断(边界只看首列)。
若 Wave 多一个 startTime 字段(列布局 id, waveIndex, startTime, pathId, monsterId),策划在 spawn 行冗余填了 startTime,解析仍按 waveIndex 判边界,spawn 不会丢失:
| id | waves.waveIndex | startTime | pathId | monsterId | 说明 |
|---|---|---|---|---|---|
| 1 | 1 | 0.0 | 1001 | 2001 | wave1 spawn1 |
| 1.0 | 1002 | 2002 | startTime 冗余填了,仍属 wave1 | ||
| 2 | 2.0 | 1003 | 2003 | wave2 |
规则三:支持多层嵌套——外层 block 可继续嵌套内层 block,规则一/二逐层适用。完整两层数据示例见下方 Block 嵌套示例(rewardGroups → items)。
规则四:内层 block 不能作为外层 block 元素的首字段(schema 校验直接拒绝,报 BlockFirstColOverlap)。外层 block 的首列就是其元素 struct 的起始列,若首字段本身又是 block,外层与内层首列重合,该列无法同时承担两层“项标识”,强行解析会把内层每个新项误判为“外层起新项”而 break,导致内层只能读出首个元素。
反例(被拒绝):
struct Inner { innerList:list<X> (block=1); // 首字段就是 block name:text;}table t[id] { id:int; outer:list<Inner> (block=1); // outer 首列 = innerList 首列 → 重合}正例:在 innerList 前放一个非 block 兄弟字段(如 name),让内层 block 首列错开外层首列即可。
规则五:block 项的首列(项标识)为空时,该行不创建新项——嵌套场景下数据并入上一层当前项;单层场景下无上层可并入,数据被丢弃(mustFill 即为防此)。
单层 task.items 为例,结构定义如下:
struct RewardItem { itemId:int; // 外层 items 的首列(项标识) count:int;}table task[id] { id:int; items:list<RewardItem> (block=1);}列布局为 id, itemId, count,第 3 行 itemId 刻意留空:
| id | items.itemId | count | 说明 |
|---|---|---|---|
| 1 | 1001 | 5 | item1 |
| 1002 | 10 | item2 | |
| 20 | itemId 空 → 该行被丢弃,不创建 item | ||
| 1003 | 3 | item3 |
结果:items = [(1001,5), (1002,10), (1003,3)],count=20 那行丢失。
mustFill(必填约束)
Section titled “mustFill(必填约束)”mustFill 用于强制字段必须包含有效值:
- 列表/映射类型:元素个数必须大于 0
- 其他类型:单元格不能为空
exp:taskexp (mustFill); // 经验奖励(必须配置)rewardItems:list<RewardItem> (block=1); // 物品奖励exp字段设置了mustFill,表示必须配置,不能省略- 如果忘记填写对应的 Excel 单元格,系统会报错
Auto 映射示例
Section titled “Auto 映射示例”数据结构定义:
table weapon[id] { id:int; weaponAttrs:weaponAttr; // 武器属性}
interface weaponAttr{ struct Damage { value:int; }}Excel 表格结构:
| id | weaponAttrs | p1 |
|---|---|---|
| 1 | Damage | 2 |
| 2 | Damage | 2 |
说明:
- 没有使用
pack,接口类型和数值需要拆分为 2 列 - 第一列存储类型名称(
Damage) - 第二列存储具体数值(
2)
Fix 列表示例
Section titled “Fix 列表示例”数据结构定义:
interface weaponAttr (pack){ struct Damage{ value:int; }}
table test[id] { id:str; // ID weaponAttrs:list<weaponAttr>(fix=2); // 武器属性列表}Excel 表格结构:
| id | weaponAttrs | p1 | impl2 | p2 |
|---|---|---|---|---|
| 1 | Damage | 2 | ||
| 2 | Damage | 2 |
说明:
fix=2表示列表固定为 2 个元素- 每个
weaponAttr元素占用 2 列(类型 + 数值) - 总共占用 4 列(2 个元素 × 2 列/元素)
Pack 结构体示例
Section titled “Pack 结构体示例”数据结构定义:
struct DmgRatio1 { playerAttr:int; ratio:int;}
struct DmgRatio2 (pack) { playerAttr:int; ratio:int;}
struct DmgRatio3 { playerAttr:int; ratio:int;}
table test[id] { id:int; // ID DmgRatio1:DmgRatio1; // 伤害比例1 DmgRatio2:DmgRatio2; // 伤害比例2 DmgRatio3:DmgRatio3(pack); // 伤害比例3}Excel 表格结构:
| id | DmgRatio1.playerAttr | ratio | DmgRatio2 | DmgRatio3 |
|---|---|---|---|---|
| 1 | 1 | 1 | 2,2 | 3,3 |
说明:
DmgRatio1:未使用 pack,占用 2 列DmgRatio2:在结构体定义上使用 pack,占用 1 列DmgRatio3:在字段上使用 pack,占用 1 列- 两种 pack 方式效果相同:可在结构体定义或字段上设置
Interface 映射示例
Section titled “Interface 映射示例”数据结构定义:
interface IDmgRatio{ struct DmgRatio1 { playerAttr:int; ratio:int; }
struct DmgRatio2 { playerAttr:int; ratio:int; }}
table test[id] { id:int; // ID DmgRatio1:IDmgRatio; DmgRatio2:IDmgRatio;}Excel 表格结构:
| id | DmgRatio1 | p1 | p2 | DmgRatio2 | p1 | p2 |
|---|---|---|---|---|---|---|
| 1 | DmgRatio1 | 1 | 1 | DmgRatio2 | 2 | 2 |
说明:
- 接口类型在配置中必须明确指定具体结构体类型
- 每个接口字段占用 3 列:
- 第 1 列:结构体类型名称
- 第 2-3 列:结构体字段值
对Interface和struct做pack,sep
Section titled “对Interface和struct做pack,sep”interface TestAttr (pack) { struct Damage { value:int; }
struct Range { value:int; }}
struct TestStruct (pack) { playerAttr:int; ratio:int;}
table test[id] { id:int; // 注释行 TestStruct:list<TestStruct> (pack); attrs:list<TestAttr>(pack); sepAttr:list<TestAttr>(sep=';');}| id | TestStruct | attrs | sepAttr |
|---|---|---|---|
| 1 | (1,2),(2,4) | Damage(50),Range(6) | Damage(50);Range(6) |
Pack 在递归结构中的应用
Section titled “Pack 在递归结构中的应用”数据结构定义:
interface CompleteCondition { struct KillMonster { monsterid:int; count:int; }
struct TalkNpc { npcid:int; }
struct CollectItem { itemid:int; count:int; }
struct And { cond1:CompleteCondition (pack); cond2:CompleteCondition (pack); }}任务表定义:
table task...{ ... condition: CompleteCondition;}Excel 表格结构:
| condition | p1 | p2 |
|---|---|---|
| KillMonster | 1001 | 1 |
| And | TalkNpc(5) | CollectItem(2002, 3) |
关键说明:
And结构中的cond1和cond2都设置了(pack),每个字段占用 1 列And结构总共占用 2 列- 递归结构处理:对于形成循环引用的递归结构,必须至少在一处使用
pack来打破循环,否则无法计算所需列数
映射机制对比
Section titled “映射机制对比”| 映射方式 | 适用类型 | 占格规则 | 主要用途 |
|---|---|---|---|
| auto | 基本类型、结构体、接口 | 自动计算 | 默认映射,简单场景 |
| pack | 结构体、接口、列表、映射 | 压缩到 1 列 | 减少列数,处理递归结构 |
| sep | 结构体、列表 | 压缩到 1 列 | 自定义分隔符格式 |
| fix | 列表、映射 | 固定列数 | 已知长度的列表 |
| block | 列表、映射 | 横向固定,纵向扩展 | 变长列表垂直排列 |
- 优先使用
auto:对于简单结构,使用默认的自动映射 - 考虑使用
pack:当需要减少表格列数或处理递归结构时 - 谨慎使用
sep:除非有特定分隔符需求,否则推荐使用pack - 合理使用
fix:仅用于已知确切长度的列表 - 灵活使用
block:处理变长列表,注意嵌套规则 - 善用
mustFill:确保关键字段不为空,提高数据质量
- 在设计复杂数据结构时,提前规划表格映射方式
- 对于递归结构,确保至少有一处使用
pack打破循环 - 使用
mustFill约束 block 首列(项标识)等关键字段,避免因首列为空导致意外的数据合并 - 保持表格结构的清晰性和可维护性