Skip to content

Commit ce80359

Browse files
committed
P1 协议子 ID lock 持久化分配
## 问题 `MessageHelper.MessageIdHandler` 历史上按行序自增分配 `Opcode`,proto 文件插入/重排/删除 message 会导致整个模块的 SubId 全线平移,连带 C#/C++/Go/Lua/TS 五个 helper 全部错位。线上协议与历史消息包的对接会因此被破坏。 ## 方案 新增 `ProtoExport/Persistence/` 模块,把 (Module, Name) → SubId 的映射持久化到 lock JSON 文件: - `MessageIdLock`:数据模型,`SortedDictionary` 保证序列化按 key 排序便于 PR diff。 - `MessageIdLockStore`:原子写(.tmp + replace),schema 版本不匹配 / module key 越界直接抛 `InvalidDataException`,绝不静默重排。 - `MessageIdAllocator.Mutate`:决策表 —— Messages 命中沿用 / Retired 命中恢复为活跃 / 新消息 `max+1`(含 retired 占用)/ 缺失消息进 Retired 永不回收。 - `MessageIdCoordinator.AssignAndPersist`:跨文件按 Module 聚合 → Mutate → Save。 - `LockSeedGenerator`:一次性迁移,把当前 Opcode 1:1 序列化为 lock 起点。 ## 接入点 - `MessageHelper.SkipAutoAssignOpcode`:lock 模式下 Parse 不自增,Opcode 全留 0 等 Coordinator 统一分配。 - `LauncherOptions.MessageIdLockPath`:CLI 选项 `--messageIdLockPath`,空字符串保留旧自增行为(向后兼容)。 - `Program.cs` 新增 `--print-lock` 与 `--regenerate-lock` 两个子命令。 - `tools/migrate-message-id-lock.sh`:一次性迁移脚本,先尝试 `Protobuf/Tools/ProtoExport.dll`,否则回退 `dotnet run`,传 `--isServer true` 以冻结全部模块(含 server-only)。 ## 文档 README 新增「子 ID 稳定性(lock 文件)」章节,覆盖:问题(行序自增的代价)、lock 机制、迁移步骤(含「不回滚历史漂移」声明)、merge conflict 处理。 ## 测试 `ProtoExporterGUI.Tests` 已加 `ProjectReference` 指向 `ProtoExport`,4 套测试 58 用例全绿: - `MessageIdAllocatorTests`:首次分配起点 10 / 中间插入 / 重排 / 删除→Retired / 重命名 / 幂等 / 多模块独立 / schema 校验 / 往返等价 / Opcode 写回断言。 - `MessageIdCoordinatorTests`:跨文件续号 / 整体重排与插入 / 删除→Retired / 多模块互不影响 / Lock 损坏报错。 - `LockSeedGeneratorTests`:冻结当前 Opcode 作为起点 / Seed 后 Coordinator 沿用 / Opcode 非法/超界报错 / 格式化与序列化等价。 - `MessageIdLockScenarioTests`:端到端修改性矩阵(基线 + A 插入 / B 重排 / C 删除 / D 重命名 / 回归重跑基线)。 CLI 真实集成(TestProtos 5 模块)也跑了 A 中间插入 / B 整体重排 / C 删除 / D 重命名 / E 重命名回归 / 损坏 lock 拒绝 / schema 不兼容拒绝 —— 全部通过。 ## 向后兼容 未传 `--messageIdLockPath` 时走旧路径,行为零变化。所有现有导出脚本不受影响。 ## 后续 PR PR2(`feat/proto-messageid-lock-gui`):GUI 状态面板(基于本 PR head)。
1 parent ebdf904 commit ce80359

17 files changed

Lines changed: 1948 additions & 26 deletions

‎ProtoExport/LauncherOptions.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ public sealed class LauncherOptions
7676
[Option("requireComments", Required = false, DefaultValue = "none", Description = "注释校验级别: none(不校验) | container(类型级) | member(成员级) | all(全部)")]
7777
public string RequireComments { get; set; }
7878

79+
/// <summary>
80+
/// 子 ID 持久化 lock 文件路径。空字符串表示禁用 lock 模式(保留旧的自增分配行为,向后兼容)。
81+
/// </summary>
82+
[Option("messageIdLockPath", Required = false, DefaultValue = "", Description = "子 ID 持久化 lock 文件路径,留空则禁用 lock 模式(向后兼容旧行为)")]
83+
public string MessageIdLockPath { get; set; }
84+
7985
/// <summary>
8086
/// 解析后的注释校验级别
8187
/// </summary>

‎ProtoExport/MessageHelper.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,24 @@ public static MessageInfoList Parse(string proto, string fileName, string filePa
6767
return messageInfo;
6868
}
6969

70+
/// <summary>
71+
/// 当 <c>true</c> 时,<see cref="Parse"/> 内部不再自动给 <c>Opcode</c> 赋值;
72+
/// 调用方需自行通过 <see cref="Persistence.MessageIdAllocator"/> 等机制分配 SubId。
73+
/// <para>
74+
/// 进程级静态标志位:导出器进程模型为单次命令行(CLI)/ 单实例 GUI,不会并发触发两轮解析。
75+
/// 若未来并发场景出现,需改为参数注入(<see cref="Parse"/> 接收 skipAutoAssign 形参)。
76+
/// </para>
77+
/// </summary>
78+
public static bool SkipAutoAssignOpcode { get; set; }
79+
7080
private static void MessageIdHandler(List<MessageInfo> operationCodeInfos, int start)
7181
{
82+
if (SkipAutoAssignOpcode)
83+
{
84+
// 关闭旧的自增分配。Opcode==0 的消息将由外部 MessageIdAllocator 处理。
85+
return;
86+
}
87+
7288
foreach (var operationCodeInfo in operationCodeInfos)
7389
{
7490
if (operationCodeInfo.IsMessage)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Collections.Generic;
2+
3+
namespace GameFrameX.ProtoExport.Persistence;
4+
5+
/// <summary>
6+
/// <see cref="MessageIdCoordinator.AssignAndPersist"/> 与 <see cref="LockSeedGenerator.SeedFromCurrentOpcodes"/> 的统计结果。
7+
/// </summary>
8+
public sealed class CoordinatorResult
9+
{
10+
/// <summary>
11+
/// 本次涉及的模块数。
12+
/// </summary>
13+
public int ModuleCount { get; }
14+
15+
/// <summary>
16+
/// 本次新增 SubId 的消息总数。
17+
/// </summary>
18+
public int NewlyAssignedCount { get; }
19+
20+
/// <summary>
21+
/// 形如 <c>"&lt;ModuleKey&gt;.&lt;MessageName&gt;"</c> 的新增条目列表。
22+
/// </summary>
23+
public IReadOnlyList<string> NewlyAssigned { get; }
24+
25+
public CoordinatorResult(int moduleCount, int newlyAssignedCount, IReadOnlyList<string> newlyAssigned)
26+
{
27+
ModuleCount = moduleCount;
28+
NewlyAssignedCount = newlyAssignedCount;
29+
NewlyAssigned = newlyAssigned;
30+
}
31+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using System.Collections.Generic;
2+
using System.IO;
3+
4+
namespace GameFrameX.ProtoExport.Persistence;
5+
6+
/// <summary>
7+
/// 把当前解析出的 <see cref="MessageInfoList"/> 序列化为「冻结态」<see cref="MessageIdLock"/>:
8+
/// 每个消息的当前 <c>Opcode</c> 直接落进 <see cref="ModuleEntry.Messages"/>,作为后续稳定分配的起点。
9+
/// <para>
10+
/// 用法:迁移脚本一次调用。语义等同 <c>--regenerate-lock</c>。
11+
/// </para>
12+
/// <para>
13+
/// 注意:旧版本(无 lock)下 Opcode 来自行序自增 —— 此举不是「修复历史」,只是「冻结当前」。
14+
/// 历史里已经发生的重排/插入所产生的协议漂移不会因此回滚。
15+
/// </para>
16+
/// </summary>
17+
public static class LockSeedGenerator
18+
{
19+
/// <summary>
20+
/// 解析所有 proto,关闭自增,按当前 Opcode 序列化为 lock 写入 <paramref name="lockPath"/>。
21+
/// </summary>
22+
public static CoordinatorResult SeedFromCurrentOpcodes(
23+
string lockPath,
24+
IEnumerable<MessageInfoList> lists)
25+
{
26+
ArgumentException.ThrowIfNullOrWhiteSpace(lockPath);
27+
ArgumentNullException.ThrowIfNull(lists);
28+
29+
var lockData = MessageIdLock.CreateEmpty();
30+
31+
// 同 Coordinator:按 Module 分组。
32+
var byModule = new SortedDictionary<short, List<MessageInfo>>();
33+
foreach (var list in lists)
34+
{
35+
if (!byModule.TryGetValue(list.Module, out var bucket))
36+
{
37+
bucket = new List<MessageInfo>();
38+
byModule[list.Module] = bucket;
39+
}
40+
41+
foreach (var info in list.Infos)
42+
{
43+
if (info.IsEnum || !info.IsMessage)
44+
{
45+
continue;
46+
}
47+
48+
bucket.Add(info);
49+
}
50+
}
51+
52+
var moduleKey = new List<string>();
53+
var assigned = new List<string>();
54+
55+
foreach (var (module, messages) in byModule)
56+
{
57+
var key = module.ToString(System.Globalization.CultureInfo.InvariantCulture);
58+
var entry = new ModuleEntry
59+
{
60+
ModuleName = FindModuleName(lists, module),
61+
};
62+
lockData.Modules[key] = entry;
63+
64+
foreach (var info in messages)
65+
{
66+
if (info.Opcode <= 0)
67+
{
68+
throw new InvalidDataException(
69+
$"seed 模式下 module={key} 消息 '{info.Name}' 的当前 Opcode={info.Opcode} 不合法,"
70+
+ "请先跑一次普通导出(不使用 --messageIdLockPath)让行序自增跑完,再做 seed。");
71+
}
72+
73+
if (info.Opcode > MessageIdAllocator.MaxSubId)
74+
{
75+
throw new InvalidDataException(
76+
$"seed 模式下 module={key} 消息 '{info.Name}' 的 Opcode={info.Opcode} 超过 SubId 上限 {MessageIdAllocator.MaxSubId}");
77+
}
78+
79+
entry.Messages[info.Name] = info.Opcode;
80+
assigned.Add($"{key}.{info.Name}");
81+
}
82+
83+
moduleKey.Add(key);
84+
}
85+
86+
MessageIdLockStore.Save(lockPath, lockData);
87+
88+
return new CoordinatorResult(moduleKey.Count, assigned.Count, assigned);
89+
}
90+
91+
private static string FindModuleName(IEnumerable<MessageInfoList> lists, short module)
92+
{
93+
foreach (var list in lists)
94+
{
95+
if (list.Module == module)
96+
{
97+
return list.ModuleName;
98+
}
99+
}
100+
101+
return string.Empty;
102+
}
103+
104+
/// <summary>
105+
/// 把 lock 文件以可读形式打印到 stdout。供 <c>--print-lock</c> 使用。
106+
/// </summary>
107+
public static string FormatLockForDisplay(MessageIdLock lockData)
108+
{
109+
ArgumentNullException.ThrowIfNull(lockData);
110+
return MessageIdLockStore.SaveToString(lockData);
111+
}
112+
}

0 commit comments

Comments
 (0)