扩展 Luban 导表流程:自动生成业务 ID

配置表有了类型定义之后,业务代码里仍然可能散落着很多数字 ID。读代码时需要回到表格查含义,复制到另一处时也容易写错。手工维护一份常量文件能够改善可读性,但会增加一份需要同步的数据。

我从 2025 年 9 月开始扩展项目的导表流程,让道具 ID 随导表一起生成。到 2026 年初,实体、界面、音效等类型也有类似需求,于是又把重复的输出流程抽成公共生成器,并继续接入 NPC ID。

让数据表成为唯一的数据来源

这里没有替换 Luban 的数据导出能力。Luban 先完成表格解析与数据输出,项目自己的后处理再读取导出结果,生成业务常量。

配置表经 Luban 导出,再由各类 ID 生成器共用输出逻辑生成 C# 常量

例如下面是一组虚构的配置:

ID 系统 名称
101 Tool 小铲子
102 Tool 喷壶

生成后,业务可以这样引用:

1
2
3
4
5
6
7
8
9
10
public static class ItemId
{
public const int Undefined = 0;

/// <summary>小铲子</summary>
public const int Tool_XiaoChanZi = 101;

/// <summary>喷壶</summary>
public const int Tool_PenHu = 102;
}

数值仍然来自配置表,常量名负责提供可读的访问入口。修改生成文件没有意义,下次导表就会被覆盖,因此文件头也明确标记为自动生成。

名称生成需要处理哪些约束

项目当时采用“系统名 + 名称拼音”的方式生成道具常量。这能减少不同系统之间的命名冲突,也让开发者在输入系统前缀后获得一组较容易辨认的提示。

生成前先检查系统字段能否对应到有效枚举。如果配置里填了不存在的系统值,应该在生成阶段报告所在数据,而不是输出一个看起来正常、实际含义不明的常量。

中文名转拼音之后仍然可能重名,因此实现中维护一个已用名称集合。再次遇到同名结果时,为后来的常量附加数据 ID,并输出警告。

1
2
Tool_XiaoChanZi
Tool_XiaoChanZi_103

这是一种务实的兼容方式,但它并不保证名称永远不变。修改文案、调整重名项的先后顺序,都可能影响生成结果。需要长期稳定的引用时,更合适的是在表里提供单独的代码名字段,让展示文案与程序标识分别维护。

拼音也不是完整的 C# 标识符清洗。项目的名称约定能覆盖日常数据,并不意味着任意标点、数字开头或关键字都可以直接生成合法代码。这类情况需要在输入约束或生成器中明确处理,最后仍要通过编译检查。

重复的是输出流程,变化的是表格规则

生成器逐渐增多之后,很多代码开始重复:检查参数、创建命名空间、写注释、输出常量和建立目录。每增加一种表,就要复制一段基本相同的文件写入逻辑。

我把它们拆成两层:

层级 负责的事情
各表生成器 读取数据、选择条目、校验业务字段、决定常量名称
公共基类 组织 C# 结构、生成注释、输出常量或枚举、写入文件

上层向公共方法提供一组简单的数据项:常量名、ID 和描述。这样新增一种表时,关注点就落在“哪些条目需要生成、应该叫什么”,不必再次维护文件格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public abstract class IdGenerator
{
public abstract void GenerateCode();

protected static void WriteConstants(
string targetNamespace,
string className,
string outputPath,
IReadOnlyList<IdEntry> entries)
{
string content = BuildSource(targetNamespace, className, entries);
WriteWhenChanged(outputPath, content);
}
}

这里展示的是职责结构,省略了项目自己的数据类型。常量和枚举可以分别提供输出入口,不必为了统一形式强行把所有表都生成同一种声明。

发现和执行生成器

项目的生成器都位于同一个工具程序集里,因此加载器可以反射查找公共基类的非抽象派生类,筛选有无参构造函数的类型,再实例化并统一执行。

实际执行前按类型名排序,使同一组生成器的执行顺序确定。这个顺序只用于保持行为可预期,不应该承载隐含的跨生成器依赖;如果 B 必须读取 A 的结果,就需要显式安排依赖关系。

统一入口也方便同时为不同代码模块输出文件:表的读取和命名规则复用,命名空间、类名和目标目录则由对应生成器提供。

这次实现会记录单个生成器的异常,并继续尝试剩余生成器。这样的行为便于一次发现多个问题,但“后面的表生成完成”不能当作整个流程成功。接入自动构建时,需要汇总错误并通过失败状态阻止使用不完整的输出。

内容不变时跳过写入

即使生成结果完全相同,直接覆盖文件也会改变修改时间,增加文件监听、资源刷新或编译链路的无效工作。因此公共输出方法先比较内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
static void WriteWhenChanged(string outputPath, string content)
{
string directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);

if (File.Exists(outputPath) &&
string.Equals(File.ReadAllText(outputPath), content,
StringComparison.Ordinal))
return;

File.WriteAllText(outputPath, content);
}

要让这个比较有意义,生成内容本身也必须稳定。不要在每次输出里加入当前时间,也不要依赖无序集合决定条目顺序。输入与生成规则都没变化时,重复执行应得到相同文本。

常量能提前暴露哪些问题

使用命名常量后,删除或重命名配置导致某个符号消失,会在代码编译时暴露引用错误;使用者也可以通过注释看到这条配置的含义。

但常量只能覆盖编译期可见的引用。网络下发的 ID、存档里的旧 ID,以及运行时拼出的资源关系,仍然需要数据校验。生成代码不会自动证明所有配置引用都有效。

还有一个容易忽略的点:C# 的 const 会在编译时内联到调用方。如果生成常量与使用常量的程序集分开更新,必须确保依赖方也重新编译,不能只替换定义常量的程序集。

把约定放进可执行的流程

这次扩展最直接的收益,是业务不必手抄数字,新增表类型也可以沿同一个入口生成代码。命名与校验规则集中之后,后续调整有了明确的位置。

我保留各张表自己的选择和命名逻辑,只统一重复的输出部分。对于项目工具,这样的边界已经足够实用:规则写在生成器里,结果随导表产生,最终由编译和资源接入一起检查。

参考:Luban。